pub_sub_model_sync 0.5.8.2 → 1.0.beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +1 -1
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +44 -0
- data/Dockerfile +6 -0
- data/Gemfile.lock +144 -135
- data/README.md +370 -194
- data/docker-compose.yaml +12 -0
- data/docs/notifications-diagram.png +0 -0
- data/lib/pub_sub_model_sync.rb +2 -0
- data/lib/pub_sub_model_sync/base.rb +22 -5
- data/lib/pub_sub_model_sync/config.rb +15 -7
- data/lib/pub_sub_model_sync/message_processor.rb +15 -10
- data/lib/pub_sub_model_sync/message_publisher.rb +92 -20
- data/lib/pub_sub_model_sync/mock_google_service.rb +4 -0
- data/lib/pub_sub_model_sync/mock_kafka_service.rb +13 -0
- data/lib/pub_sub_model_sync/payload.rb +32 -16
- data/lib/pub_sub_model_sync/publisher.rb +43 -21
- data/lib/pub_sub_model_sync/publisher_concern.rb +54 -44
- data/lib/pub_sub_model_sync/run_subscriber.rb +104 -0
- data/lib/pub_sub_model_sync/service_base.rb +47 -13
- data/lib/pub_sub_model_sync/service_google.rb +53 -17
- data/lib/pub_sub_model_sync/service_kafka.rb +40 -13
- data/lib/pub_sub_model_sync/service_rabbit.rb +41 -33
- data/lib/pub_sub_model_sync/subscriber.rb +14 -61
- data/lib/pub_sub_model_sync/subscriber_concern.rb +21 -28
- data/lib/pub_sub_model_sync/tasks/worker.rake +11 -0
- data/lib/pub_sub_model_sync/transaction.rb +57 -0
- data/lib/pub_sub_model_sync/version.rb +1 -1
- metadata +9 -4
data/README.md
CHANGED
@@ -1,16 +1,43 @@
|
|
1
|
-
# PubSubModelSync
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
-
|
8
|
-
|
9
|
-
-
|
10
|
-
|
11
|
-
-
|
12
|
-
|
13
|
-
|
1
|
+
# **PubSubModelSync**
|
2
|
+
This gem permits to sync automatically model data, send custom notifications between multiple Rails applications by publishing notifications via pubsub (Google PubSub, RabbitMQ, or Apache Kafka). Out of the scope this gem includes transactions to keep Data consistency by processing notifications in the order they were delivered.
|
3
|
+
These notifications use JSON format to easily be decoded by subscribers (Rails applications and even other languages)
|
4
|
+
|
5
|
+
- [**PubSubModelSync**](#pubsubmodelsync)
|
6
|
+
- [**Features**](#features)
|
7
|
+
- [**Installation**](#installation)
|
8
|
+
- [**Configuration**](#configuration)
|
9
|
+
- [**Notifications Diagram**](#notifications-diagram)
|
10
|
+
- [**Examples**](#examples)
|
11
|
+
- [**Basic Example**](#basic-example)
|
12
|
+
- [**Advanced Example**](#advanced-example)
|
13
|
+
- [**API**](#api)
|
14
|
+
- [**Subscribers**](#subscribers)
|
15
|
+
- [**Registering Subscriptions**](#registering-subscriptions)
|
16
|
+
- [**Subscription helpers**](#subscription-helpers)
|
17
|
+
- [**Publishers**](#publishers)
|
18
|
+
- [**Publishing notifications**](#publishing-notifications)
|
19
|
+
- [**Publisher Helpers**](#publisher-helpers)
|
20
|
+
- [**Publisher callbacks**](#publisher-callbacks)
|
21
|
+
- [**Payload**](#payload)
|
22
|
+
- [**Transactions**](#transactions)
|
23
|
+
- [**Testing with RSpec**](#testing-with-rspec)
|
24
|
+
- [**Extra configurations**](#extra-configurations)
|
25
|
+
- [**TODO**](#todo)
|
26
|
+
- [**Q&A**](#qa)
|
27
|
+
- [**Contributing**](#contributing)
|
28
|
+
- [**License**](#license)
|
29
|
+
- [**Code of Conduct**](#code-of-conduct)
|
30
|
+
|
31
|
+
## **Features**
|
32
|
+
- Sync model data between Rails apps: All changes made on App1, will be immediately reflected on App2, App3, etc.
|
33
|
+
Example: If User is created on App1, this user will be created on App2, App3 too with the accepted attributes.
|
34
|
+
- Ability to send class level communications
|
35
|
+
Example: If App1 wants to send emails to multiple users, this can be listened on App2, to deliver corresponding emails
|
36
|
+
- Change pub/sub service at any time: Switch between rabbitmq, kafka, google pubsub
|
37
|
+
- Support for transactions: Permits to keep data consistency between applications by processing notifications in the same order they were delivered.
|
38
|
+
- Ability to send notifications to a specific topic (single application) or multiple topics (multiple applications)
|
39
|
+
|
40
|
+
## **Installation**
|
14
41
|
Add this line to your application's Gemfile:
|
15
42
|
```ruby
|
16
43
|
gem 'pub_sub_model_sync'
|
@@ -22,15 +49,16 @@ gem 'ruby-kafka' # to use apache kafka pub/sub service
|
|
22
49
|
And then execute: $ bundle install
|
23
50
|
|
24
51
|
|
25
|
-
##
|
52
|
+
## **Configuration**
|
26
53
|
|
27
54
|
- Configuration for google pub/sub (You need google pub/sub service account)
|
28
55
|
```ruby
|
29
56
|
# initializers/pub_sub_config.rb
|
30
|
-
PubSubModelSync::Config.service_name = :google
|
57
|
+
PubSubModelSync::Config.service_name = :google
|
31
58
|
PubSubModelSync::Config.project = 'google-project-id'
|
32
59
|
PubSubModelSync::Config.credentials = 'path-to-the-config'
|
33
|
-
PubSubModelSync::Config.topic_name = 'sample-topic'
|
60
|
+
PubSubModelSync::Config.topic_name = 'sample-topic'
|
61
|
+
PubSubModelSync::Config.subscription_name = 'my-app1'
|
34
62
|
```
|
35
63
|
See details here:
|
36
64
|
https://github.com/googleapis/google-cloud-ruby/tree/master/google-cloud-pubsub
|
@@ -39,8 +67,8 @@ And then execute: $ bundle install
|
|
39
67
|
```ruby
|
40
68
|
PubSubModelSync::Config.service_name = :rabbitmq
|
41
69
|
PubSubModelSync::Config.bunny_connection = 'amqp://guest:guest@localhost'
|
42
|
-
PubSubModelSync::Config.queue_name = 'model-sync'
|
43
70
|
PubSubModelSync::Config.topic_name = 'sample-topic'
|
71
|
+
PubSubModelSync::Config.subscription_name = 'my-app2'
|
44
72
|
```
|
45
73
|
See details here: https://github.com/ruby-amqp/bunny
|
46
74
|
|
@@ -49,310 +77,458 @@ And then execute: $ bundle install
|
|
49
77
|
PubSubModelSync::Config.service_name = :kafka
|
50
78
|
PubSubModelSync::Config.kafka_connection = [["kafka1:9092", "localhost:2121"], { logger: Rails.logger }]
|
51
79
|
PubSubModelSync::Config.topic_name = 'sample-topic'
|
80
|
+
PubSubModelSync::Config.subscription_name = 'my-app3'
|
52
81
|
```
|
53
|
-
See details here: https://github.com/zendesk/ruby-kafka
|
82
|
+
See details here: https://github.com/zendesk/ruby-kafka
|
54
83
|
|
55
84
|
- Add publishers/subscribers to your models (See examples below)
|
56
85
|
|
57
86
|
- Start subscribers to listen for publishers (Only in the app that has subscribers)
|
58
|
-
```
|
59
|
-
rake pub_sub_model_sync:start
|
87
|
+
```bash
|
88
|
+
DB_POOL=20 bundle exec rake pub_sub_model_sync:start
|
60
89
|
```
|
61
|
-
Note:
|
62
|
-
|
63
|
-
|
64
|
-
# PubSubModelSync::Config.subscribers ==> []
|
65
|
-
PubSubModelSync::Runner.preload_listeners
|
66
|
-
# PubSubModelSync::Config.subscribers ==> [#<PubSubModelSync::Subscriber:0x000.. @klass="Article", @action=:create..., ....]
|
67
|
-
```
|
68
|
-
|
69
|
-
- Check the service status with:
|
90
|
+
Note: You need more than 15 DB pools to avoid "could not obtain a connection from the pool within 5.000 seconds". https://devcenter.heroku.com/articles/concurrency-and-database-connections
|
91
|
+
|
92
|
+
- Check the service status with:
|
70
93
|
```PubSubModelSync::MessagePublisher.publish_data('Test message', {sample_value: 10}, :create)```
|
71
94
|
|
72
|
-
|
95
|
+
- More configurations: [here](#extra-configurations)
|
96
|
+
|
97
|
+
## **Notifications Diagram**
|
98
|
+

|
99
|
+
|
100
|
+
## **Examples**
|
101
|
+
### **Basic Example**
|
73
102
|
```ruby
|
74
103
|
# App 1 (Publisher)
|
75
|
-
# attributes: name email age
|
76
104
|
class User < ActiveRecord::Base
|
77
105
|
include PubSubModelSync::PublisherConcern
|
78
|
-
ps_publish(%i[id name email])
|
106
|
+
ps_on_crud_event(:create) { ps_publish(:create, mapping: %i[id name email]) }
|
107
|
+
ps_on_crud_event(:update) { ps_publish(:update, mapping: %i[id name email]) }
|
108
|
+
ps_on_crud_event(:destroy) { ps_publish(:destroy, mapping: %i[id]) }
|
79
109
|
end
|
80
110
|
|
81
111
|
# App 2 (Subscriber)
|
82
112
|
class User < ActiveRecord::Base
|
83
113
|
include PubSubModelSync::SubscriberConcern
|
84
|
-
ps_subscribe(%i[name])
|
85
|
-
ps_class_subscribe(:greeting)
|
86
|
-
|
87
|
-
def self.greeting(data)
|
88
|
-
puts 'Class message called'
|
89
|
-
end
|
114
|
+
ps_subscribe([:create, :update, :destroy], %i[name email], id: :id) # crud notifications
|
90
115
|
end
|
91
116
|
|
92
|
-
#
|
93
|
-
User.create(name: 'test user', email: 'sample@gmail.com') #
|
94
|
-
|
95
|
-
|
96
|
-
User.ps_class_publish({ msg: 'Hello' }, action: :greeting) # User.greeting method (Class method) will be called in App2
|
97
|
-
PubSubModelSync::MessagePublisher.publish_data(User, { msg: 'Hello' }, :greeting) # similar to above when not included publisher concern
|
117
|
+
# CRUD syncs
|
118
|
+
my_user = User.create(name: 'test user', email: 'sample@gmail.com') # Publishes `:create` notification (App 2 syncs the new user)
|
119
|
+
my_user.update(name: 'changed user') # Publishes `:update` notification (App2 updates changes)
|
120
|
+
my_user.destroy # Publishes `:destroy` notification (App2 destroys the corresponding user)
|
98
121
|
```
|
99
122
|
|
100
|
-
|
123
|
+
### **Advanced Example**
|
101
124
|
```ruby
|
102
125
|
# App 1 (Publisher)
|
103
126
|
class User < ActiveRecord::Base
|
104
|
-
self.table_name = 'publisher_users'
|
105
127
|
include PubSubModelSync::PublisherConcern
|
106
|
-
ps_publish(%i[id
|
107
|
-
|
108
|
-
def ps_skip_callback?(_action)
|
109
|
-
false # here logic with action to skip push message
|
110
|
-
end
|
111
|
-
|
112
|
-
def ps_skip_sync?(_action)
|
113
|
-
false # here logic with action to skip push message
|
114
|
-
end
|
128
|
+
ps_on_crud_event([:create, :update]) { ps_publish(:save, mapping: %i[id name:full_name email], as_klass: 'App1User', headers: { topic_name: %i[topic1 topic2] }) }
|
115
129
|
end
|
116
130
|
|
117
131
|
# App 2 (Subscriber)
|
118
132
|
class User < ActiveRecord::Base
|
119
|
-
self.table_name = 'subscriber_users'
|
120
133
|
include PubSubModelSync::SubscriberConcern
|
121
|
-
ps_subscribe(%i[
|
122
|
-
|
123
|
-
|
134
|
+
ps_subscribe(:save, %i[full_name:customer_name], id: [:id, :email], from_klass: 'App1User')
|
135
|
+
ps_subscribe(:send_welcome, %i[email], to_action: :send_email, if: ->(model) { model.email.present? })
|
136
|
+
ps_class_subscribe(:batch_disable) # class subscription
|
124
137
|
|
125
|
-
def
|
126
|
-
puts
|
138
|
+
def send_email
|
139
|
+
puts "sending email to #{email}"
|
127
140
|
end
|
128
141
|
|
129
|
-
|
130
|
-
|
131
|
-
|
142
|
+
def self.batch_disable(data)
|
143
|
+
puts "disabling users: #{data[:ids]}"
|
144
|
+
end
|
132
145
|
end
|
146
|
+
my_user = User.create(name: 'test user', email: 's@gmail.com') # Publishes `:save` notification as class name `App1User` (App2 syncs the new user)
|
147
|
+
my_user.ps_publish(:send_welcome, mapping: %i[id email]) # Publishes `:send_welcome` notification (App2 prints "sending email to...")
|
148
|
+
PubSubModelSync::MessagePublisher.publish_data(User, { ids: [my_user.id] }, :batch_disable) # Publishes class notification (App2 prints "disabling users..")
|
133
149
|
```
|
134
150
|
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
```
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
```
|
145
|
-
|
146
|
-
|
151
|
+
## **API**
|
152
|
+
### **Subscribers**
|
153
|
+
|
154
|
+
#### **Registering Subscriptions**
|
155
|
+
```ruby
|
156
|
+
class MyModel < ActiveRecord::Base
|
157
|
+
ps_subscribe(action, mapping, settings)
|
158
|
+
ps_class_subscribe(action, settings)
|
159
|
+
end
|
160
|
+
```
|
161
|
+
- Instance subscriptions: `ps_subscribe(action, mapping, settings)`
|
162
|
+
When model receives the corresponding notification, `action` or `to_action` method will be called on the model. Like: `model.destroy`
|
163
|
+
- `action` (Symbol|Array<Symbol>) Only notifications with this action name will be processed by this subscription. Sample: save|create|update|destroy|<any_other_action>
|
164
|
+
- `mapping` (Array<String>) Data mapping from payload data into model attributes, sample: ["email", "full_name:name"] (Note: Only these attributes will be assigned/synced to the current model)
|
165
|
+
- `[email]` means that `email` value from payload will be assigned to `email` attribute from current model
|
166
|
+
- `[full_name:name]` means that `full_name` value from payload will be assigned to `name` attribute from current model
|
167
|
+
- `settings` (Hash<:from_klass, :to_action, :id, :if, :unless>)
|
168
|
+
- `from_klass:` (String, default current class): Only notifications with this class name will be processed by this subscription
|
169
|
+
- `to_action:` (Symbol|Proc, default `action`):
|
170
|
+
When Symbol: Model method to process the notification
|
171
|
+
When Proc: Block to process the notification
|
172
|
+
- `id:` (Symbol|Array<Symbol|String>, default: `:id`) identifier attribute(s) to find the corresponding model instance (Supports for mapping format)
|
173
|
+
Sample: `id: :id` will search for a model like: `model_class.where(id: payload.data[:id])`
|
174
|
+
Sample: `id: [:id, :email:user_email]` will search for a model like: `model_class.where(id: payload.data[:id], user_email: payload.data[:email])`
|
175
|
+
- `if:` (Symbol|Proc|Array<Symbol>) Method(s) or block called for the confirmation before calling the callback
|
176
|
+
- `unless:` (Symbol|Proc|Array<Symbol>) Method or block called for the negation before calling the callback
|
177
|
+
|
178
|
+
- Class subscriptions: `ps_class_subscribe(action, settings)`
|
179
|
+
When current class receives the corresponding notification, `action` or `to_action` method will be called on the Class. Like: `User.hello(data)`
|
180
|
+
* `action` (Symbol) Notification.action name
|
181
|
+
* `settings` (Hash) refer ps_subscribe.settings except(:id)
|
182
|
+
|
183
|
+
- `ps_processing_payload` a class and instance variable that saves the current payload being processed
|
184
|
+
|
185
|
+
- (Only instance subscription) Perform custom actions before saving sync of the model (`:cancel` can be returned to skip sync)
|
186
|
+
```ruby
|
187
|
+
class MyModel < ActiveRecord::Base
|
188
|
+
def ps_before_save_sync
|
189
|
+
# puts ps_processing_payload.data[:id]
|
190
|
+
end
|
191
|
+
end
|
192
|
+
```
|
147
193
|
|
148
|
-
-
|
149
|
-
```
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
* data: (Hash) Data received from sync
|
194
|
+
- (Only instance subscription) Configure a custom model finder (optional)
|
195
|
+
```ruby
|
196
|
+
class MyModel < ActiveRecord::Base
|
197
|
+
def ps_find_model(data)
|
198
|
+
where(custom_finder: data[:custom_value]).first_or_initialize
|
199
|
+
end
|
200
|
+
end
|
201
|
+
```
|
202
|
+
* `data`: (Hash) Payload data received from sync
|
158
203
|
Must return an existent or a new model object
|
159
204
|
|
160
|
-
|
161
|
-
|
162
|
-
|
205
|
+
#### **Subscription helpers**
|
206
|
+
- List all configured subscriptions
|
207
|
+
```ruby
|
208
|
+
PubSubModelSync::Config.subscribers
|
209
|
+
```
|
210
|
+
- Manually process or reprocess a notification (useful when failed)
|
211
|
+
```ruby
|
212
|
+
payload = PubSubModelSync::Payload.new(data, attributes, headers)
|
213
|
+
payload.process!
|
214
|
+
```
|
163
215
|
|
164
|
-
- Inspect all configured subscribers
|
165
|
-
```PubSubModelSync::Config.subscribers```
|
166
216
|
|
167
|
-
|
168
|
-
|
169
|
-
|
217
|
+
### **Publishers**
|
218
|
+
```ruby
|
219
|
+
class MyModel < ActiveRecord::Base
|
220
|
+
ps_on_crud_event([:create, :update, :destroy], :method_publisher_name) # using method callback
|
221
|
+
ps_on_crud_event([:create, :update, :destroy]) do |action| # using block callback
|
222
|
+
ps_publish(action, data: {}, mapping: [], headers: {}, as_klass: nil)
|
223
|
+
ps_class_publish({}, action: :my_action, as_klass: nil, headers: {})
|
224
|
+
end
|
170
225
|
|
171
|
-
|
172
|
-
|
226
|
+
def method_publisher_name(action)
|
227
|
+
ps_publish(action, data: {}, mapping: [], headers: {}, as_klass: nil)
|
228
|
+
end
|
229
|
+
end
|
230
|
+
```
|
173
231
|
|
174
|
-
|
175
|
-
- Permit to configure crud publishers
|
176
|
-
```ps_publish(attrs, actions: nil, as_klass: nil)```
|
177
|
-
* attrs: (Array/Required) Array of attributes to be published
|
178
|
-
* actions: (Array/Optional, default: create/update/destroy) permit to customize action names
|
179
|
-
* as_klass: (String/Optional) Output class name (Instead of the model class name, will use this value)
|
232
|
+
#### **Publishing notifications**
|
180
233
|
|
181
|
-
-
|
182
|
-
|
183
|
-
|
184
|
-
|
234
|
+
- `ps_on_crud_event(crud_actions, method_name = nil, &block)` Listens for CRUD events and calls provided `block` or `method` to process event callback
|
235
|
+
- `crud_actions` (Symbol|Array<Symbol>) Crud event(s) to be observed (Allowed: `:create, :update, :destroy`)
|
236
|
+
- `method_name` (Symbol, optional) method to be called to process action callback
|
237
|
+
- `block` (Proc, optional) Block to be called to process action callback
|
238
|
+
**Note1**: Due to rails callback ordering, this method uses `before_commit` callback when creating or updating models to ensure expected notifications order, sample:
|
239
|
+
```ruby
|
240
|
+
user = User.create(name: 'asasas', posts_attributes: [{ title: 't1' }, { title: 't2' }])
|
241
|
+
```
|
242
|
+
1: User notification
|
243
|
+
2: First post notification
|
244
|
+
3: Second post notification
|
245
|
+
|
246
|
+
**Note2**: Due to rails callback ordering, this method uses `after_destroy` callback when destroying models to ensure the expected notifications ordering.
|
247
|
+
```ruby
|
248
|
+
user.destroy
|
249
|
+
```
|
250
|
+
1: Second post notification
|
251
|
+
2: First post notification
|
252
|
+
3: User notification
|
253
|
+
|
254
|
+
- `ps_publish(action, data: {}, mapping: [], headers: {}, as_klass: nil)` Delivers an instance notification via pubsub
|
255
|
+
- `action` (Sym|String) Action name of the instance notification. Sample: create|update|destroy|<any_other_key>
|
256
|
+
- `mapping:` (Array<String>, optional) Generates payload data using the provided mapper:
|
257
|
+
- Sample: `["id", "name"]` will result into `{ id: <model.id>, name: <model.name>}`
|
258
|
+
- Sample: `["id", "full_name:name"]` will result into `{ id: <model.id>, name: <model.full_name>}`
|
259
|
+
- `data:` (Hash|Symbol|Proc, optional)
|
260
|
+
- When Hash: Data to be added to the final payload
|
261
|
+
- When Symbol: Method name to be called to retrieve payload data (must return a `hash`, receives `:action` as arg)
|
262
|
+
- When Proc: Block to be called to retrieve payload data (must return a `hash`, receives `:model, :action` as args)
|
263
|
+
- `headers:` (Hash|Symbol|Proc, optional): Defines how the notification will be delivered and be processed (All available attributes in Payload.headers)
|
264
|
+
- When Hash: Data that will be merged with default header values
|
265
|
+
- When Symbol: Method name that will be called to retrieve header values (must return a hash, receives `:action` arg)
|
266
|
+
- When Proc: Block to be called to retrieve header values (must return a `hash`, receives `:model, :action` as args)
|
267
|
+
- `as_klass:` (String, default current class name): Output class name used instead of current class name
|
185
268
|
|
186
|
-
-
|
187
|
-
|
188
|
-
|
269
|
+
- `ps_class_publish` Delivers a Class notification via pubsub
|
270
|
+
- `data` (Hash): Data of the notification
|
271
|
+
- `action` (Symbol): action name of the notification
|
272
|
+
- `as_klass:` (String, default current class name): Class name of the notification
|
273
|
+
- `headers:` (Hash, optional): header settings (More in Payload.headers)
|
189
274
|
|
190
|
-
|
191
|
-
|
192
|
-
|
275
|
+
#### **Publisher helpers**
|
276
|
+
- Publish a class notification from anywhere
|
277
|
+
```ruby
|
278
|
+
PubSubModelSync::MessagePublisher.publish_data(klass, data, action, headers: )
|
279
|
+
```
|
280
|
+
- `klass`: (String) Class name to be used
|
281
|
+
- Refer to `ps_class_publish` except `as_klass:`
|
193
282
|
|
194
|
-
-
|
195
|
-
```
|
283
|
+
- Manually publish or republish a notification (useful when failed)
|
284
|
+
```ruby
|
285
|
+
payload = PubSubModelSync::Payload.new(data, attributes, headers)
|
286
|
+
payload.publish!
|
287
|
+
```
|
196
288
|
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
289
|
+
#### **Publisher callbacks**
|
290
|
+
- Prevent delivering a notification (called before building payload)
|
291
|
+
If returns "true", will not publish notification
|
292
|
+
```ruby
|
293
|
+
class MyModel < ActiveRecord::Base
|
294
|
+
def ps_skip_publish?(action)
|
295
|
+
# logic here
|
296
|
+
end
|
297
|
+
end
|
298
|
+
```
|
299
|
+
|
300
|
+
- Do some actions before publishing notification.
|
301
|
+
If returns ":cancel", notification will not be delivered
|
302
|
+
```ruby
|
303
|
+
class MyModel < ActiveRecord::Base
|
304
|
+
def ps_before_publish(action, payload)
|
305
|
+
# logic here
|
306
|
+
end
|
307
|
+
end
|
308
|
+
```
|
309
|
+
|
310
|
+
- Do some actions after notification was delivered.
|
311
|
+
```ruby
|
312
|
+
class MyModel < ActiveRecord::Base
|
313
|
+
def ps_after_publish(action, payload)
|
314
|
+
# logic here
|
315
|
+
end
|
316
|
+
end
|
317
|
+
```
|
318
|
+
|
319
|
+
|
320
|
+
### **Payload**
|
321
|
+
Any notification before delivering is transformed as a Payload for a better portability.
|
322
|
+
|
323
|
+
- Attributes
|
324
|
+
* `data`: (Hash) Data to be published or processed
|
325
|
+
* `info`: (Hash) Notification info
|
326
|
+
- `action`: (String) Notification action name
|
327
|
+
- `klass`: (String) Notification class name
|
328
|
+
- `mode`: (Symbol: `:model`|`:class`) Kind of notification
|
329
|
+
* `headers`: (Hash) Notification settings that defines how the notification will be processed or delivered.
|
330
|
+
- `key`: (String, optional) identifier of the payload, default: `<klass_name>/<action>` when class message, `<model.class.name>/<action>/<model.id>` when model message (Useful for caching techniques).
|
331
|
+
- `ordering_key`: (String, optional): messages with the same value are processed in the same order they were delivered, default: `klass_name` when class message, `<model.class.name>/<model.id>` when instance message
|
332
|
+
- `topic_name`: (String|Array<String>, optional): Specific topic name (can be seen as a channel) to be used when delivering the message (default first topic from config).
|
333
|
+
- `forced_ordering_key`: (String, optional): Will force to use this value as the `ordering_key`, even withing transactions. Default `nil`.
|
334
|
+
|
335
|
+
- Actions
|
210
336
|
```ruby
|
211
|
-
payload = PubSubModelSync::Payload.new({ title: 'hello' }, { action: :greeting, klass: 'User' })
|
212
337
|
payload.publish! # publishes notification data. It raises exception if fails and does not call ```:on_error_publishing``` callback
|
213
338
|
payload.publish # publishes notification data. On error does not raise exception but calls ```:on_error_publishing``` callback
|
214
339
|
payload.process! # process a notification data. It raises exception if fails and does not call ```.on_error_processing``` callback
|
215
340
|
payload.publish # process a notification data. It does not raise exception if fails but calls ```.on_error_processing``` callback
|
216
341
|
```
|
342
|
+
|
343
|
+
## **Transactions**
|
344
|
+
This Gem supports to publish multiple notifications to be processed in the same order they are published.
|
345
|
+
* Crud syncs auto includes transactions which works as the following:
|
346
|
+
```ruby
|
347
|
+
class User
|
348
|
+
ps_on_crud_event(:create) { ps_publish(:create, mapping: %i[id name]) }
|
349
|
+
has_many :posts
|
350
|
+
accepts_nested_attributes_for :posts
|
351
|
+
end
|
352
|
+
|
353
|
+
class Post
|
354
|
+
belongs_to :user
|
355
|
+
ps_on_crud_event(:create) { ps_publish(:create, mapping: %i[id title]) }
|
356
|
+
end
|
357
|
+
|
358
|
+
User.create!(name: 'test', posts_attributes: [{ title: 'Post 1' }, { title: 'Post 2' }])
|
359
|
+
```
|
360
|
+
When user is created, `User`:`:save` notification is published with the ordering_key = `User/<user_id>`.
|
361
|
+
Posts created together with the user model publishes `Post`:`:save` notification each one using its parents (user model) `ordering_key`.
|
362
|
+
By this way parent notification and all inner notifications are processed in the same order they were published (includes notifications from callbacks like `ps_before_publish`).
|
363
|
+
|
364
|
+
**Note**: When any error is raised when saving user or posts, the transaction is cancelled and thus all notifications wont be delivered (customizable by `PubSubModelSync::Config.transactions_use_buffer`).
|
217
365
|
|
218
|
-
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
366
|
+
- Manual transactions
|
367
|
+
`PubSubModelSync::MessagePublisher::transaction(key, use_buffer: , &block)`
|
368
|
+
- `key` (String|nil) Key used as the ordering key for all inner notifications (When nil, will use `ordering_key` of the first notification)
|
369
|
+
- `use_buffer:` (Boolean, default: `PubSubModelSync::Config.transactions_use_buffer`)
|
370
|
+
If true: will save all notifications and deliver all them when transaction has successfully finished. If transaction has failed, then all saved notifications will be discarded (not delivered).
|
371
|
+
If false: will deliver all notifications immediately (no way to rollback notifications if transaction has failed)
|
372
|
+
Sample:
|
373
|
+
```ruby
|
374
|
+
PubSubModelSync::MessagePublisher::transaction('my-custom-key') do
|
375
|
+
user = User.create(name: 'test') # `User`:`:create` notification
|
376
|
+
post = Post.create(title: 'sample') # `Post`:`:create` notification
|
377
|
+
PubSubModelSync::MessagePublisher.publish_data(User, { ids: [user.id] }, :send_welcome) # `User`:`:send_welcome` notification
|
378
|
+
end
|
379
|
+
```
|
380
|
+
All notifications uses `ordering_key: 'my-custom-key'` and will be processed in the same order they were published.
|
381
|
+
|
382
|
+
## **Testing with RSpec**
|
223
383
|
- Config: (spec/rails_helper.rb)
|
224
384
|
```ruby
|
225
|
-
|
385
|
+
|
226
386
|
# when using google service
|
227
387
|
require 'pub_sub_model_sync/mock_google_service'
|
228
388
|
config.before(:each) do
|
229
389
|
google_mock = PubSubModelSync::MockGoogleService.new
|
230
390
|
allow(Google::Cloud::Pubsub).to receive(:new).and_return(google_mock)
|
231
391
|
end
|
232
|
-
|
392
|
+
|
233
393
|
# when using rabbitmq service
|
234
|
-
require 'pub_sub_model_sync/mock_rabbit_service'
|
394
|
+
require 'pub_sub_model_sync/mock_rabbit_service'
|
235
395
|
config.before(:each) do
|
236
396
|
rabbit_mock = PubSubModelSync::MockRabbitService.new
|
237
397
|
allow(Bunny).to receive(:new).and_return(rabbit_mock)
|
238
398
|
end
|
239
|
-
|
399
|
+
|
240
400
|
# when using apache kafka service
|
241
|
-
require 'pub_sub_model_sync/mock_kafka_service'
|
401
|
+
require 'pub_sub_model_sync/mock_kafka_service'
|
242
402
|
config.before(:each) do
|
243
403
|
kafka_mock = PubSubModelSync::MockKafkaService.new
|
244
404
|
allow(Kafka).to receive(:new).and_return(kafka_mock)
|
245
405
|
end
|
246
406
|
|
407
|
+
#
|
408
|
+
config.before(:each) do
|
409
|
+
# **** disable payloads generation, sync callbacks to improve tests speed
|
410
|
+
allow(PubSubModelSync::MessagePublisher).to receive(:publish_data) # disable class level notif
|
411
|
+
allow(PubSubModelSync::MessagePublisher).to receive(:publish_model) # disable instance level notif
|
412
|
+
|
413
|
+
# **** when testing model syncs, it can be re enabled by:
|
414
|
+
# before do
|
415
|
+
# allow(PubSubModelSync::MessagePublisher).to receive(:publish_data).and_call_original
|
416
|
+
# allow(PubSubModelSync::MessagePublisher).to receive(:publish_model).and_call_original
|
417
|
+
# end
|
418
|
+
end
|
247
419
|
```
|
248
420
|
- Examples:
|
249
421
|
```ruby
|
250
422
|
# Subscriber
|
251
|
-
it 'receive model
|
423
|
+
it 'receive model notification' do
|
252
424
|
data = { name: 'name', id: 999 }
|
253
425
|
payload = PubSubModelSync::Payload.new(data, { klass: 'User', action: :create })
|
254
426
|
payload.process!
|
255
|
-
expect(User.where(id: data[:id])
|
427
|
+
expect(User.where(id: data[:id])).to be_any
|
256
428
|
end
|
257
|
-
|
258
|
-
it 'receive class
|
429
|
+
|
430
|
+
it 'receive class notification' do
|
259
431
|
data = { msg: 'hello' }
|
260
432
|
action = :greeting
|
261
|
-
payload = PubSubModelSync::Payload.new(data, { klass: 'User', action: action })
|
433
|
+
payload = PubSubModelSync::Payload.new(data, { klass: 'User', action: action, mode: :klass })
|
262
434
|
payload.process!
|
263
435
|
expect(User).to receive(action)
|
264
436
|
end
|
265
|
-
|
437
|
+
|
266
438
|
# Publisher
|
267
|
-
it 'publish model
|
268
|
-
publisher = PubSubModelSync::MessagePublisher
|
439
|
+
it 'publish model notification' do
|
440
|
+
publisher = PubSubModelSync::MessagePublisher
|
269
441
|
user = User.create(name: 'name', email: 'email')
|
270
442
|
expect(publisher).to receive(:publish_model).with(user, :create, anything)
|
271
443
|
end
|
272
|
-
|
273
|
-
it 'publish class
|
274
|
-
publisher = PubSubModelSync::MessagePublisher
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
expect(publisher).to receive(:publish_data).with('User', data, action)
|
444
|
+
|
445
|
+
it 'publish class notification' do
|
446
|
+
publisher = PubSubModelSync::MessagePublisher
|
447
|
+
user = User.create(name: 'name', email: 'email')
|
448
|
+
user.ps_class_publish({msg: 'hello'}, action: :greeting)
|
449
|
+
expect(publisher).to receive(:publish_data).with('User', data, :greeting)
|
279
450
|
end
|
280
451
|
```
|
281
452
|
|
282
|
-
## Extra configurations
|
453
|
+
## **Extra configurations**
|
283
454
|
```ruby
|
284
455
|
config = PubSubModelSync::Config
|
285
456
|
config.debug = true
|
286
457
|
```
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
458
|
+
- `.topic_name = ['topic1', 'topic 2']`: (String|Array<String>)
|
459
|
+
Topic name(s) to be used to listen all notifications from when listening. Additionally first topic name is used as the default topic name when publishing a notification.
|
460
|
+
- `.subscription_name = "my-app-1"`: (String, default Rails.application.name)
|
461
|
+
Subscriber's identifier which helps to:
|
462
|
+
* skip self messages
|
463
|
+
* continue the sync from the last synced notification when service was restarted.
|
464
|
+
- `.default_topic_name = "my_topic"`: (String|Array<String>, optional(default first topic from `topic_name`))
|
465
|
+
Topic name used as the default topic if not defined in the payload when publishing a notification
|
466
|
+
- ```.debug = true```
|
291
467
|
(true/false*) => show advanced log messages
|
292
|
-
- ```.logger = Rails.logger```
|
468
|
+
- ```.logger = Rails.logger```
|
293
469
|
(Logger) => define custom logger
|
294
|
-
- ```.
|
295
|
-
(
|
296
|
-
- ```.
|
297
|
-
(Proc) => called before processing received message (:cancel can be returned to skip processing)
|
298
|
-
- ```.on_success_processing = ->(payload, {subscriber:}) { puts payload }```
|
470
|
+
- ```.on_before_processing = ->(payload, {subscriber:}) { puts payload }```
|
471
|
+
(Proc) => called before processing received message (:cancel can be returned to skip processing)
|
472
|
+
- ```.on_success_processing = ->(payload, {subscriber:}) { puts payload }```
|
299
473
|
(Proc) => called when a message was successfully processed
|
300
|
-
- ```.on_error_processing = ->(exception, {payload:, subscriber:}) { payload.delay(...).process! }```
|
474
|
+
- ```.on_error_processing = ->(exception, {payload:, subscriber:}) { payload.delay(...).process! }```
|
301
475
|
(Proc) => called when a message failed when processing (delayed_job or similar can be used for retrying)
|
302
|
-
- ```.on_before_publish = ->(payload) { puts payload }```
|
303
|
-
(Proc) => called before publishing a message (:cancel can be returned to skip publishing)
|
304
|
-
- ```.on_after_publish = ->(payload) { puts payload }```
|
476
|
+
- ```.on_before_publish = ->(payload) { puts payload }```
|
477
|
+
(Proc) => called before publishing a message (:cancel can be returned to skip publishing)
|
478
|
+
- ```.on_after_publish = ->(payload) { puts payload }```
|
305
479
|
(Proc) => called after publishing a message
|
306
|
-
- ```.on_error_publish = ->(exception, {payload:}) { payload.delay(...).publish! }```
|
480
|
+
- ```.on_error_publish = ->(exception, {payload:}) { payload.delay(...).publish! }```
|
307
481
|
(Proc) => called when failed publishing a message (delayed_job or similar can be used for retrying)
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
-
|
312
|
-
-
|
313
|
-
|
314
|
-
-
|
315
|
-
|
316
|
-
-
|
317
|
-
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
```ruby PubSubModelSync::ServiceGoogle::LISTEN_SETTINGS = { threads: { callback: qty_threads } } ```
|
325
|
-
Note: by this way some notifications can be processed before others thus missing relationship errors can appear
|
482
|
+
- ```.transactions_use_buffer = true``` (true*|false) Default value for `use_buffer` in transactions.
|
483
|
+
|
484
|
+
## **TODO**
|
485
|
+
- Auto publish update only if payload has changed (see ways to compare previous payload vs new payload)
|
486
|
+
- Improve transactions to exclude similar messages by klass and action. Sample:
|
487
|
+
```PubSubModelSync::MessagePublisher.transaction(key, { same_keys: :use_last_as_first|:use_last|:use_first_as_last|:keep*, same_data: :use_last_as_first*|:use_last|:use_first_as_last|:keep })```
|
488
|
+
- Add DB table to use as a shield to prevent publishing similar notifications and publish partial notifications (similar idea when processing notif)
|
489
|
+
- Last notification is not being delivered immediately in google pubsub (maybe force with timeout 10secs and service.deliver_messages)
|
490
|
+
- Update folder structure
|
491
|
+
- Support for blocks in ps_publish and ps_subscribe
|
492
|
+
- Services support to deliver multiple payloads from transactions
|
493
|
+
|
494
|
+
## **Q&A**
|
495
|
+
- I'm getting error "could not obtain a connection from the pool within 5.000 seconds"... what does this mean?
|
496
|
+
This problem occurs because pub/sub dependencies (kafka, google-pubsub, rabbitmq) uses many threads to perform notifications where the qty of threads is greater than qty of DB pools ([Google pubsub info](https://github.com/googleapis/google-cloud-ruby/blob/master/google-cloud-pubsub/lib/google/cloud/pubsub/subscription.rb#L888))
|
497
|
+
To fix the problem, edit config/database.yml and increase the quantity of ```pool: ENV['DB_POOL'] || 5``` and `DB_POOL=20 bundle exec rake pub_sub_model_sync:start`
|
326
498
|
- How to retry failed syncs with sidekiq?
|
327
499
|
```ruby
|
328
500
|
# lib/initializers/pub_sub_config.rb
|
329
|
-
|
501
|
+
|
330
502
|
class PubSubRecovery
|
331
503
|
include Sidekiq::Worker
|
332
504
|
sidekiq_options queue: :pubsub, retry: 2, backtrace: true
|
333
|
-
|
505
|
+
|
334
506
|
def perform(payload_data, action)
|
335
507
|
payload = PubSubModelSync::Payload.from_payload_data(payload_data)
|
336
508
|
payload.send(action)
|
337
509
|
end
|
338
510
|
end
|
339
|
-
|
511
|
+
|
340
512
|
PubSubModelSync::Config.on_error_publish = lambda do |_e, data|
|
341
513
|
PubSubRecovery.perform_async(data[:payload].to_h, :publish!)
|
342
514
|
end
|
343
515
|
PubSubModelSync::Config.on_error_processing = lambda do |_e, data|
|
344
516
|
PubSubRecovery.perform_async(data[:payload].to_h, :process!)
|
345
517
|
end
|
346
|
-
```
|
518
|
+
```
|
347
519
|
|
348
|
-
## Contributing
|
520
|
+
## **Contributing**
|
349
521
|
|
350
522
|
Bug reports and pull requests are welcome on GitHub at https://github.com/owen2345/pub_sub_model_sync. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
351
523
|
|
352
|
-
## License
|
524
|
+
## **License**
|
353
525
|
|
354
526
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
355
527
|
|
356
|
-
## Code of Conduct
|
528
|
+
## **Code of Conduct**
|
357
529
|
|
358
530
|
Everyone interacting in the PubSubModelSync project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/pub_sub_model_sync/blob/master/CODE_OF_CONDUCT.md).
|
531
|
+
|
532
|
+
## **Running tests**
|
533
|
+
- `docker-compose run test`
|
534
|
+
- `docker-compose run test bash -c "rubocop"`
|