pub_sub_model_sync 0.5.9 → 1.0.beta1

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,328 +77,460 @@ 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
82
  See details here: https://github.com/zendesk/ruby-kafka
54
-
55
- Kafka Confluence example:
56
- ```ruby
57
- kafka_settings = {
58
- sasl_plain_username: '...',
59
- sasl_plain_password: '...',
60
- ssl_ca_certs_from_system: true,
61
- logger: Rails.logger, client_id: 'my-app-name'
62
- }
63
- PubSubModelSync::Config.kafka_connection = [['...confluent.cloud:9092'], kafka_settings]
64
- ```
65
- Note: You need to create the topic manually on Kafka Confluence
66
83
 
67
84
  - Add publishers/subscribers to your models (See examples below)
68
85
 
69
86
  - Start subscribers to listen for publishers (Only in the app that has subscribers)
70
- ```ruby
71
- rake pub_sub_model_sync:start
87
+ ```bash
88
+ DB_POOL=20 bundle exec rake pub_sub_model_sync:start
72
89
  ```
73
- Note: Publishers do not need todo this
74
- Note2 (Rails 6+): Due to Zeitwerk, you need to load listeners manually when syncing without mentioned task (like rails console)
75
- ```ruby
76
- # PubSubModelSync::Config.subscribers ==> []
77
- PubSubModelSync::Runner.preload_listeners
78
- # PubSubModelSync::Config.subscribers ==> [#<PubSubModelSync::Subscriber:0x000.. @klass="Article", @action=:create..., ....]
79
- ```
80
-
81
- - 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:
82
93
  ```PubSubModelSync::MessagePublisher.publish_data('Test message', {sample_value: 10}, :create)```
83
94
 
84
- ## 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**
85
102
  ```ruby
86
103
  # App 1 (Publisher)
87
- # attributes: name email age
88
104
  class User < ActiveRecord::Base
89
105
  include PubSubModelSync::PublisherConcern
90
- 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]) }
91
109
  end
92
110
 
93
111
  # App 2 (Subscriber)
94
112
  class User < ActiveRecord::Base
95
113
  include PubSubModelSync::SubscriberConcern
96
- ps_subscribe(%i[name])
97
- ps_class_subscribe(:greeting)
98
-
99
- def self.greeting(data)
100
- puts 'Class message called'
101
- end
114
+ ps_subscribe([:create, :update, :destroy], %i[name email], id: :id) # crud notifications
102
115
  end
103
116
 
104
- # Samples
105
- User.create(name: 'test user', email: 'sample@gmail.com') # Review your App 2 to see the created user (only name will be saved)
106
- User.new(name: 'test user').ps_perform_sync(:create) # similar to above to perform sync on demand
107
-
108
- User.ps_class_publish({ msg: 'Hello' }, action: :greeting) # User.greeting method (Class method) will be called in App2
109
- 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)
110
121
  ```
111
122
 
112
- ## Advanced Example
123
+ ### **Advanced Example**
113
124
  ```ruby
114
125
  # App 1 (Publisher)
115
126
  class User < ActiveRecord::Base
116
- self.table_name = 'publisher_users'
117
127
  include PubSubModelSync::PublisherConcern
118
- ps_publish(%i[id:client_id name:full_name email], actions: %i[update], as_klass: 'Client')
119
-
120
- def ps_skip_callback?(_action)
121
- false # here logic with action to skip push message
122
- end
123
-
124
- def ps_skip_sync?(_action)
125
- false # here logic with action to skip push message
126
- 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] }) }
127
129
  end
128
130
 
129
131
  # App 2 (Subscriber)
130
132
  class User < ActiveRecord::Base
131
- self.table_name = 'subscriber_users'
132
133
  include PubSubModelSync::SubscriberConcern
133
- ps_subscribe(%i[name], actions: %i[update], from_klass: 'Client', id: %i[client_id email])
134
- ps_class_subscribe(:greeting, from_action: :custom_greeting, from_klass: 'CustomUser')
135
- 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
136
137
 
137
- def self.greeting(data)
138
- puts 'Class message called through custom_greeting'
138
+ def send_email
139
+ puts "sending email to #{email}"
139
140
  end
140
141
 
141
- # def self.ps_find_model(data)
142
- # where(email: data[:email], ...).first_or_initialize
143
- # end
142
+ def self.batch_disable(data)
143
+ puts "disabling users: #{data[:ids]}"
144
+ end
144
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..")
145
149
  ```
146
150
 
147
- Note: Be careful with collision of names
148
- ```
149
- # ps_publish %i[name_data:name name:key] # key will be replaced with name_data
150
- ps_publish %i[name_data:name key_data:key] # use alias to avoid collision
151
- ```
152
-
153
- ## API
154
- ### Subscribers
155
- - Permit to configure class level subscriptions
156
- ```ps_class_subscribe(action_name, from_action: nil, from_klass: nil)```
157
- * from_action: (Optional) Source method name
158
- * 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
+ ```
159
193
 
160
- - Permit to configure instance level subscriptions (CRUD)
161
- ```ps_subscribe(attrs, from_klass: nil, actions: nil, id: nil)```
162
- * attrs: (Array/Required) Array of all attributes to be synced
163
- * from_klass: (String/Optional) Source class name (Instead of the model class name, will use this value)
164
- * actions: (Array/Optional, default: create/update/destroy) permit to customize action names
165
- * id: (Sym|Array/Optional, default: id) Attr identifier(s) to find the corresponding model
166
-
167
- - Permit to configure a custom model finder
168
- ```ps_find_model(data)```
169
- * 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
170
203
  Must return an existent or a new model object
171
204
 
172
- - Get crud subscription configured for the class
173
- ```User.ps_subscriber(action_name)```
174
- * 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
+ ```
175
215
 
176
- - Inspect all configured subscribers
177
- ```PubSubModelSync::Config.subscribers```
178
216
 
179
- - Permit to customize the way to detect if the subscribed model was changed (Only for update action).
180
- ```.ps_subscriber_changed?(data)```
181
- 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
182
225
 
183
- - Permit to perform custom actions before saving sync of the model (:cancel can be returned to skip sync)
184
- ```.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
+ ```
185
231
 
186
- ### Publishers
187
- - Permit to configure crud publishers
188
- ```ps_publish(attrs, actions: nil, as_klass: nil)```
189
- * attrs: (Array/Required) Array of attributes to be published
190
- * actions: (Array/Optional, default: create/update/destroy) permit to customize action names
191
- * as_klass: (String/Optional) Output class name (Instead of the model class name, will use this value)
232
+ #### **Publishing notifications**
192
233
 
193
- - Permit to cancel sync called after create/update/destroy (Before initializing sync service)
194
- ```model.ps_skip_callback?(action)```
195
- Default: False
196
- 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
197
268
 
198
- - Callback called before preparing data for sync (Permit to stop sync)
199
- ```model.ps_skip_sync?(action)```
200
- 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)
201
274
 
202
- - Callback called before sync (After preparing data)
203
- ```model.ps_before_sync(action, data_to_deliver)```
204
- 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:`
205
282
 
206
- - Callback called after sync
207
- ```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
+ ```
208
288
 
209
- - Perform sync on demand (:create, :update, :destroy):
210
- The target model will receive a notification to perform the indicated action
211
- ```my_model.ps_perform_sync(action_name, custom_settings = {})```
212
- * custom_settings: override default settings defined for action_name ({ attrs: [], as_klass: nil })
213
-
214
- - Publish a class level notification:
215
- ```User.ps_class_publish(data, action: action_name, as_klass: custom_klass_name)```
216
- Target class ```User.action_name``` will be called when message is received
217
- * data: (required, :hash) message value to deliver
218
- * action_name: (required, :sim) Action name
219
- * as_klass: (optional, :string) Custom class name (Default current model name)
220
-
221
- - 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
222
336
  ```ruby
223
- payload = PubSubModelSync::Payload.new({ title: 'hello' }, { action: :greeting, klass: 'User' })
224
337
  payload.publish! # publishes notification data. It raises exception if fails and does not call ```:on_error_publishing``` callback
225
338
  payload.publish # publishes notification data. On error does not raise exception but calls ```:on_error_publishing``` callback
226
339
  payload.process! # process a notification data. It raises exception if fails and does not call ```.on_error_processing``` callback
227
340
  payload.publish # process a notification data. It does not raise exception if fails but calls ```.on_error_processing``` callback
228
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`).
229
365
 
230
- - Get crud publisher configured for the class
231
- ```User.ps_publisher(action_name)```
232
- * action_name (default :create, :sym): can be :create, :update, :destroy
233
-
234
- ## Testing with RSpec
366
+ - Manual transactions
367
+ `PubSubModelSync::MessagePublisher::transaction(key, max_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
+ - `max_buffer:` (Boolean, default: `PubSubModelSync::Config.transactions_max_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**
235
383
  - Config: (spec/rails_helper.rb)
236
384
  ```ruby
237
-
385
+
238
386
  # when using google service
239
387
  require 'pub_sub_model_sync/mock_google_service'
240
388
  config.before(:each) do
241
389
  google_mock = PubSubModelSync::MockGoogleService.new
242
390
  allow(Google::Cloud::Pubsub).to receive(:new).and_return(google_mock)
243
391
  end
244
-
392
+
245
393
  # when using rabbitmq service
246
- require 'pub_sub_model_sync/mock_rabbit_service'
394
+ require 'pub_sub_model_sync/mock_rabbit_service'
247
395
  config.before(:each) do
248
396
  rabbit_mock = PubSubModelSync::MockRabbitService.new
249
397
  allow(Bunny).to receive(:new).and_return(rabbit_mock)
250
398
  end
251
-
399
+
252
400
  # when using apache kafka service
253
- require 'pub_sub_model_sync/mock_kafka_service'
401
+ require 'pub_sub_model_sync/mock_kafka_service'
254
402
  config.before(:each) do
255
403
  kafka_mock = PubSubModelSync::MockKafkaService.new
256
404
  allow(Kafka).to receive(:new).and_return(kafka_mock)
257
405
  end
258
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
259
419
  ```
260
420
  - Examples:
261
421
  ```ruby
262
422
  # Subscriber
263
- it 'receive model message' do
423
+ it 'receive model notification' do
264
424
  data = { name: 'name', id: 999 }
265
425
  payload = PubSubModelSync::Payload.new(data, { klass: 'User', action: :create })
266
426
  payload.process!
267
- expect(User.where(id: data[:id]).any?).to be_truth
427
+ expect(User.where(id: data[:id])).to be_any
268
428
  end
269
-
270
- it 'receive class message' do
429
+
430
+ it 'receive class notification' do
271
431
  data = { msg: 'hello' }
272
432
  action = :greeting
273
- payload = PubSubModelSync::Payload.new(data, { klass: 'User', action: action })
433
+ payload = PubSubModelSync::Payload.new(data, { klass: 'User', action: action, mode: :klass })
274
434
  payload.process!
275
435
  expect(User).to receive(action)
276
436
  end
277
-
437
+
278
438
  # Publisher
279
- it 'publish model action' do
280
- publisher = PubSubModelSync::MessagePublisher
439
+ it 'publish model notification' do
440
+ publisher = PubSubModelSync::MessagePublisher
281
441
  user = User.create(name: 'name', email: 'email')
282
442
  expect(publisher).to receive(:publish_model).with(user, :create, anything)
283
443
  end
284
-
285
- it 'publish class message' do
286
- publisher = PubSubModelSync::MessagePublisher
287
- data = {msg: 'hello'}
288
- action = :greeting
289
- User.ps_class_publish(data, action: action)
290
- 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)
291
450
  end
292
451
  ```
293
452
 
294
- ## Extra configurations
453
+ ## **Extra configurations**
295
454
  ```ruby
296
455
  config = PubSubModelSync::Config
297
456
  config.debug = true
298
457
  ```
299
-
300
- - ```.subscription_name = 'app-2'```
301
- Permit to define a custom consumer identifier (Default: Rails application name)
302
- - ```.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```
303
467
  (true/false*) => show advanced log messages
304
- - ```.logger = Rails.logger```
468
+ - ```.logger = Rails.logger```
305
469
  (Logger) => define custom logger
306
- - ```.disabled_callback_publisher = ->(_model, _action) { false }```
307
- (true/false*) => if true, does not listen model callbacks for auto sync (Create/Update/Destroy)
308
- - ```.on_before_processing = ->(payload, {subscriber:}) { puts payload }```
309
- (Proc) => called before processing received message (:cancel can be returned to skip processing)
310
- - ```.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 }```
311
473
  (Proc) => called when a message was successfully processed
312
- - ```.on_error_processing = ->(exception, {payload:, subscriber:}) { payload.delay(...).process! }```
474
+ - ```.on_error_processing = ->(exception, {payload:, subscriber:}) { payload.delay(...).process! }```
313
475
  (Proc) => called when a message failed when processing (delayed_job or similar can be used for retrying)
314
- - ```.on_before_publish = ->(payload) { puts payload }```
315
- (Proc) => called before publishing a message (:cancel can be returned to skip publishing)
316
- - ```.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 }```
317
479
  (Proc) => called after publishing a message
318
- - ```.on_error_publish = ->(exception, {payload:}) { payload.delay(...).publish! }```
480
+ - ```.on_error_publish = ->(exception, {payload:}) { payload.delay(...).publish! }```
319
481
  (Proc) => called when failed publishing a message (delayed_job or similar can be used for retrying)
320
-
321
- ## TODO
322
- - Add alias attributes when subscribing (similar to publisher)
323
- - Add flag ```model.ps_process_payload``` to retrieve the payload used to process the pub/sub sync
324
- - Auto publish update only if payload has changed
325
- - On delete, payload must only be composed by ids
326
- - Feature to publish multiple message at a time with the ability to exclude similar messages by klass and action (use the last one)
327
- 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 })
328
- - Add DB table to use as a shield to skip publishing similar notifications or publish partial notifications (similar idea when processing notif)
329
- - add callback: on_message_received(payload)
330
- - Sometimes the listener service stops listening, debug idea:
331
- ```subscriber.on_error do
332
- log('error gogooogle')
333
- end
334
- loop{ log(['loooooop:', {l: subscriber.last_error, stopped: subscriber.stopped?, started: subscriber.started?}]); sleep 2 }
335
- ```
336
-
337
- ## Q&A
338
- - Error "could not obtain a connection from the pool within 5.000 seconds"
339
- 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))
340
- To fix the problem, edit config/database.yml and increase the quantity of ```pool: 10```
341
- - Google pubsub: How to process notifications parallely and not sequentially (default 1 thread)?
342
- ```ruby PubSubModelSync::ServiceGoogle::LISTEN_SETTINGS = { threads: { callback: qty_threads } } ```
343
- Note: by this way some notifications can be processed before others thus missing relationship errors can appear
482
+ - ```.transactions_max_buffer = 100``` (Integer) Once this quantity of notifications is reached, then all notifications will immediately be delivered.
483
+ Note: There is no way to rollback delivered notifications if current transaction fails
484
+ - ```.enable_rails4_before_commit = true``` (true*|false) When false will disable rails 4 hack compatibility and then CRUD notifications will be prepared using `after_commit` callback instead of `before_commit` which will not rollback sql transactions if fails.
485
+
486
+ ## **TODO**
487
+ - Auto publish update only if payload has changed (see ways to compare previous payload vs new payload)
488
+ - Improve transactions to exclude similar messages by klass and action. Sample:
489
+ ```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 })```
490
+ - Add DB table to use as a shield to prevent publishing similar notifications and publish partial notifications (similar idea when processing notif)
491
+ - Last notification is not being delivered immediately in google pubsub (maybe force with timeout 10secs and service.deliver_messages)
492
+ - Update folder structure
493
+ - Support for blocks in ps_publish and ps_subscribe
494
+ - Services support to deliver multiple payloads from transactions
495
+
496
+ ## **Q&A**
497
+ - I'm getting error "could not obtain a connection from the pool within 5.000 seconds"... what does this mean?
498
+ 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))
499
+ 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`
344
500
  - How to retry failed syncs with sidekiq?
345
501
  ```ruby
346
502
  # lib/initializers/pub_sub_config.rb
347
-
503
+
348
504
  class PubSubRecovery
349
505
  include Sidekiq::Worker
350
506
  sidekiq_options queue: :pubsub, retry: 2, backtrace: true
351
-
507
+
352
508
  def perform(payload_data, action)
353
509
  payload = PubSubModelSync::Payload.from_payload_data(payload_data)
354
510
  payload.send(action)
355
511
  end
356
512
  end
357
-
513
+
358
514
  PubSubModelSync::Config.on_error_publish = lambda do |_e, data|
359
515
  PubSubRecovery.perform_async(data[:payload].to_h, :publish!)
360
516
  end
361
517
  PubSubModelSync::Config.on_error_processing = lambda do |_e, data|
362
518
  PubSubRecovery.perform_async(data[:payload].to_h, :process!)
363
519
  end
364
- ```
520
+ ```
365
521
 
366
- ## Contributing
522
+ ## **Contributing**
367
523
 
368
524
  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.
369
525
 
370
- ## License
526
+ ## **License**
371
527
 
372
528
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
373
529
 
374
- ## Code of Conduct
530
+ ## **Code of Conduct**
375
531
 
376
532
  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).
533
+
534
+ ## **Running tests**
535
+ - `docker-compose run test`
536
+ - `docker-compose run test bash -c "rubocop"`