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.
data/README.md CHANGED
@@ -1,16 +1,43 @@
1
- # PubSubModelSync
2
- Permit to sync models data and make calls between rails apps using google or rabbitmq or apache kafka pub/sub service.
3
-
4
- Note: This gem is based on [MultipleMan](https://github.com/influitive/multiple_man) which for now looks unmaintained.
5
-
6
- ## Features
7
- - Sync CRUD operations between Rails apps. So, all changes made on App1, will be reflected on App2, App3.
8
- Example: If User is created on App1, this user will be created on App2 too with the accepted attributes.
9
- - Ability to make class level communication
10
- Example: If User from App1 wants to generate_email, this can be listened on App2, App3, ... to make corresponding actions
11
- - Change pub/sub service at any time
12
-
13
- ## Installation
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
- ## Usage
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
- ```ruby
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: Publishers do not need todo this
62
- Note2 (Rails 6+): Due to Zeitwerk, you need to load listeners manually when syncing without mentioned task (like rails console)
63
- ```ruby
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
- ## Examples
95
+ - More configurations: [here](#extra-configurations)
96
+
97
+ ## **Notifications Diagram**
98
+ ![Diagram](/docs/notifications-diagram.png?raw=true)
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
- # Samples
93
- User.create(name: 'test user', email: 'sample@gmail.com') # Review your App 2 to see the created user (only name will be saved)
94
- User.new(name: 'test user').ps_perform_sync(:create) # similar to above to perform sync on demand
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
- ## Advanced Example
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:client_id name:full_name email], actions: %i[update], as_klass: 'Client')
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[name], actions: %i[update], from_klass: 'Client', id: %i[client_id email])
122
- ps_class_subscribe(:greeting, from_action: :custom_greeting, from_klass: 'CustomUser')
123
- alias_attribute :full_name, :name
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 self.greeting(data)
126
- puts 'Class message called through custom_greeting'
138
+ def send_email
139
+ puts "sending email to #{email}"
127
140
  end
128
141
 
129
- # def self.ps_find_model(data)
130
- # where(email: data[:email], ...).first_or_initialize
131
- # end
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
- Note: Be careful with collision of names
136
- ```
137
- # ps_publish %i[name_data:name name:key] # key will be replaced with name_data
138
- ps_publish %i[name_data:name key_data:key] # use alias to avoid collision
139
- ```
140
-
141
- ## API
142
- ### Subscribers
143
- - Permit to configure class level subscriptions
144
- ```ps_class_subscribe(action_name, from_action: nil, from_klass: nil)```
145
- * from_action: (Optional) Source method name
146
- * from_klass: (Optional) Source class name
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
- - Permit to configure instance level subscriptions (CRUD)
149
- ```ps_subscribe(attrs, from_klass: nil, actions: nil, id: nil)```
150
- * attrs: (Array/Required) Array of all attributes to be synced
151
- * from_klass: (String/Optional) Source class name (Instead of the model class name, will use this value)
152
- * actions: (Array/Optional, default: create/update/destroy) permit to customize action names
153
- * id: (Sym|Array/Optional, default: id) Attr identifier(s) to find the corresponding model
154
-
155
- - Permit to configure a custom model finder
156
- ```ps_find_model(data)```
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
- - Get crud subscription configured for the class
161
- ```User.ps_subscriber(action_name)```
162
- * action_name (default :create, :sym): can be :create, :update, :destroy
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
- - Permit to customize the way to detect if the subscribed model was changed (Only for update action).
168
- ```.ps_subscriber_changed?(data)```
169
- By default: ```model.changed?```
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
- - Permit to perform custom actions before saving sync of the model (:cancel can be returned to skip sync)
172
- ```.ps_before_save_sync(payload)```
226
+ def method_publisher_name(action)
227
+ ps_publish(action, data: {}, mapping: [], headers: {}, as_klass: nil)
228
+ end
229
+ end
230
+ ```
173
231
 
174
- ### Publishers
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
- - Permit to cancel sync called after create/update/destroy (Before initializing sync service)
182
- ```model.ps_skip_callback?(action)```
183
- Default: False
184
- Note: Return true to cancel sync
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
- - Callback called before preparing data for sync (Permit to stop sync)
187
- ```model.ps_skip_sync?(action)```
188
- Note: return true to cancel sync
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
- - Callback called before sync (After preparing data)
191
- ```model.ps_before_sync(action, data_to_deliver)```
192
- Note: If the method returns ```:cancel```, the sync will be stopped (message will not be published)
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
- - Callback called after sync
195
- ```model.ps_after_sync(action, data_delivered)```
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
- - Perform sync on demand (:create, :update, :destroy):
198
- The target model will receive a notification to perform the indicated action
199
- ```my_model.ps_perform_sync(action_name, custom_settings = {})```
200
- * custom_settings: override default settings defined for action_name ({ attrs: [], as_klass: nil })
201
-
202
- - Publish a class level notification:
203
- ```User.ps_class_publish(data, action: action_name, as_klass: custom_klass_name)```
204
- Target class ```User.action_name``` will be called when message is received
205
- * data: (required, :hash) message value to deliver
206
- * action_name: (required, :sim) Action name
207
- * as_klass: (optional, :string) Custom class name (Default current model name)
208
-
209
- - Payload actions
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
- - Get crud publisher configured for the class
219
- ```User.ps_publisher(action_name)```
220
- * action_name (default :create, :sym): can be :create, :update, :destroy
221
-
222
- ## Testing with RSpec
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 message' do
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]).any?).to be_truth
427
+ expect(User.where(id: data[:id])).to be_any
256
428
  end
257
-
258
- it 'receive class message' do
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 action' do
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 message' do
274
- publisher = PubSubModelSync::MessagePublisher
275
- data = {msg: 'hello'}
276
- action = :greeting
277
- User.ps_class_publish(data, action: action)
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
- - ```.subscription_name = 'app-2'```
289
- Permit to define a custom consumer identifier (Default: Rails application name)
290
- - ```.debug = true```
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
- - ```.disabled_callback_publisher = ->(_model, _action) { false }```
295
- (true/false*) => if true, does not listen model callbacks for auto sync (Create/Update/Destroy)
296
- - ```.on_before_processing = ->(payload, {subscriber:}) { puts payload }```
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
- ## TODO
310
- - Add alias attributes when subscribing (similar to publisher)
311
- - Add flag ```model.ps_process_payload``` to retrieve the payload used to process the pub/sub sync
312
- - Auto publish update only if payload has changed
313
- - On delete, payload must only be composed by ids
314
- - Feature to publish multiple message at a time with the ability to exclude similar messages by klass and action (use the last one)
315
- PubSubModelSync::MessagePublisher.batch_publish({ 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 })
316
- - Add DB table to use as a shield to skip publishing similar notifications or publish partial notifications (similar idea when processing notif)
317
- - add callback: on_message_received(payload)
318
-
319
- ## Q&A
320
- - Error "could not obtain a connection from the pool within 5.000 seconds"
321
- This problem occurs because pub/sub dependencies (kafka, google-pubsub, rabbitmq) use 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))
322
- To fix the problem, edit config/database.yml and increase the quantity of ```pool: 10```
323
- - Google pubsub: How to process notifications parallely and not sequentially (default 1 thread)?
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"`