pub_sub_model_sync 0.5.8.1 → 0.6.0

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.
@@ -0,0 +1,12 @@
1
+ version: '2'
2
+
3
+ services:
4
+ test:
5
+ build: .
6
+ command: sh -c 'bundle exec rspec'
7
+ volumes:
8
+ - .:/myapp
9
+ - bundler_gems:/usr/local/bundle/
10
+
11
+ volumes:
12
+ bundler_gems:
Binary file
@@ -14,11 +14,24 @@ module PubSubModelSync
14
14
  end
15
15
  end
16
16
 
17
- def retry_error(error_klass, qty: 2, &block)
18
- @retries ||= 0
17
+ # @param errors (Array(Class|String))
18
+ def retry_error(errors, qty: 2, &block)
19
+ retries ||= 0
19
20
  block.call
20
- rescue error_klass => _e
21
- (@retries += 1) <= qty ? retry : raise
21
+ rescue => e
22
+ retries += 1
23
+ res = errors.find { |e_type| match_error?(e, e_type) }
24
+ raise if !res || retries > qty
25
+
26
+ sleep(qty * 0.1) && retry
27
+ end
28
+
29
+ private
30
+
31
+ # @param error (Exception)
32
+ # @param error_type (Class|String)
33
+ def match_error?(error, error_type)
34
+ error_type.is_a?(String) ? error.message.include?(error_type) : error.is_a?(error_type)
22
35
  end
23
36
  end
24
37
  end
@@ -22,7 +22,7 @@ module PubSubModelSync
22
22
  cattr_accessor :project, :credentials, :topic_name, :subscription_name
23
23
 
24
24
  # rabbitmq service
25
- cattr_accessor :bunny_connection, :queue_name, :topic_name, :subscription_name
25
+ cattr_accessor :bunny_connection, :topic_name, :subscription_name
26
26
 
27
27
  # kafka service
28
28
  cattr_accessor :kafka_connection, :topic_name, :subscription_name
@@ -2,7 +2,7 @@
2
2
 
3
3
  module PubSubModelSync
4
4
  class MessageProcessor < PubSubModelSync::Base
5
- attr_accessor :payload, :raise_error
5
+ attr_accessor :payload
6
6
 
7
7
  # @param payload (Payload): payload to be delivered
8
8
  # @Deprecated: def initialize(data, klass, action)
@@ -15,22 +15,28 @@ module PubSubModelSync
15
15
  @payload = PubSubModelSync::Payload.new(payload, { klass: klass, action: action })
16
16
  end
17
17
 
18
- def process
18
+ def process!
19
19
  filter_subscribers.each(&method(:run_subscriber))
20
20
  end
21
21
 
22
+ def process
23
+ process!
24
+ rescue => e
25
+ notify_error(e)
26
+ end
27
+
22
28
  private
23
29
 
24
30
  def run_subscriber(subscriber)
31
+ subscriber = subscriber.dup
25
32
  return unless processable?(subscriber)
26
33
 
27
- retry_error(ActiveRecord::ConnectionTimeoutError, qty: 2) do
34
+ errors = [ActiveRecord::ConnectionTimeoutError, 'deadlock detected', 'could not serialize access']
35
+ retry_error(errors, qty: 2) do
28
36
  subscriber.process!(payload)
29
37
  res = config.on_success_processing.call(payload, { subscriber: subscriber })
30
38
  log "processed message with: #{payload.inspect}" if res != :skip_log
31
39
  end
32
- rescue => e
33
- raise_error ? raise : print_subscriber_error(e, subscriber)
34
40
  end
35
41
 
36
42
  def processable?(subscriber)
@@ -40,9 +46,9 @@ module PubSubModelSync
40
46
  end
41
47
 
42
48
  # @param error (Error)
43
- def print_subscriber_error(error, subscriber)
49
+ def notify_error(error)
44
50
  info = [payload, error.message, error.backtrace]
45
- res = config.on_error_processing.call(error, { payload: payload, subscriber: subscriber })
51
+ res = config.on_error_processing.call(error, { payload: payload })
46
52
  log("Error processing message: #{info}", :error) if res != :skip_log
47
53
  end
48
54
 
@@ -3,45 +3,127 @@
3
3
  module PubSubModelSync
4
4
  class MessagePublisher < PubSubModelSync::Base
5
5
  class << self
6
+ class MissingPublisher < StandardError; end
7
+ attr_accessor :transaction_key
8
+
6
9
  def connector
7
10
  @connector ||= PubSubModelSync::Connector.new
8
11
  end
9
12
 
10
- def publish_data(klass, data, action)
11
- payload = PubSubModelSync::Payload.new(data, { klass: klass.to_s, action: action.to_sym })
12
- publish(payload)
13
+ # Permits to group all payloads with the same ordering_key and be processed in the same order
14
+ # they are published by the subscribers. Grouping by ordering_key allows us to enable
15
+ # multiple workers in our Pub/Sub service(s), and still guarantee that related payloads will
16
+ # be processed in the correct order, despite of the multiple threads. This thanks to the fact
17
+ # that Pub/Sub services will always send messages with the same `ordering_key` into the same
18
+ # worker/thread.
19
+ # @param key (String): This key will be used as the ordering_key for all payload
20
+ # inside this transaction.
21
+ def transaction(key, &block)
22
+ parent_key = init_transaction(key)
23
+ begin
24
+ block.call
25
+ ensure
26
+ end_transaction(parent_key)
27
+ end
13
28
  end
14
29
 
15
- # @param model: ActiveRecord model
16
- # @param action: (Sym) Action name
17
- # @param publisher: (Publisher, optional) Publisher to be used
18
- def publish_model(model, action, publisher = nil)
19
- return if model.ps_skip_sync?(action)
30
+ # Starts a new transaction
31
+ # @return (String) returns parent transaction key
32
+ def init_transaction(key)
33
+ parent_key = transaction_key
34
+ self.transaction_key = transaction_key.presence || key
35
+ parent_key
36
+ end
20
37
 
21
- publisher ||= model.class.ps_publisher(action)
22
- payload = publisher.payload(model, action)
23
- res_before = model.ps_before_sync(action, payload.data)
24
- return if res_before == :cancel
38
+ # Restores to the last transaction key
39
+ def end_transaction(parent_key)
40
+ self.transaction_key = parent_key
41
+ end
25
42
 
43
+ # Publishes any value to pubsub
44
+ # @param klass (String): Class name
45
+ # @param data (Hash): Data to be delivered
46
+ # @param action (:symbol): action name
47
+ # @param headers (Hash, optional): header settings (More in Payload.headers)
48
+ # @return Payload
49
+ def publish_data(klass, data, action, headers: {})
50
+ attrs = { klass: klass.to_s, action: action.to_sym }
51
+ payload = PubSubModelSync::Payload.new(data, attrs, headers)
26
52
  publish(payload)
27
- model.ps_after_sync(action, payload.data)
28
53
  end
29
54
 
30
- def publish(payload, raise_error: false)
31
- if config.on_before_publish.call(payload) == :cancel
32
- log("Publish message cancelled: #{payload}") if config.debug
33
- return
55
+ # Publishes custom model action
56
+ # @param model (ActiveRecord): Model object owner of the data
57
+ # @param data (Hash): Data to be delivered
58
+ # @param action (:symbol): action name
59
+ # @param as_klass (String, optional): Class name (default model class name)
60
+ # @param headers (Hash, optional): header settings (More in Payload.headers)
61
+ # @return Payload
62
+ def publish_model_data(model, data, action, as_klass: nil, headers: {})
63
+ headers = PubSubModelSync::Publisher.headers_for(model, action).merge(headers)
64
+ publish_data(as_klass || model.class.name, data, action, headers: headers)
65
+ end
66
+
67
+ # Publishes model info to pubsub
68
+ # @param model (ActiveRecord model)
69
+ # @param action (Sym): Action name
70
+ # @param custom_data (Hash, optional): If present custom_data will be used as the payload data.
71
+ # @param custom_headers (Hash): Refer Payload.headers
72
+ # @return Payload
73
+ def publish_model(model, action, custom_data: nil, custom_headers: {})
74
+ return if model.ps_skip_sync?(action)
75
+
76
+ publisher = model.class.ps_publisher(action)
77
+ error_msg = "No publisher found for: \"#{[model.class.name, action]}\""
78
+ raise(MissingPublisher, error_msg) unless publisher
79
+
80
+ payload = publisher.payload(model, action, custom_data: custom_data, custom_headers: custom_headers)
81
+ transaction(payload.headers[:ordering_key]) do # catch and group all :ps_before_sync syncs
82
+ publish(payload) { model.ps_after_sync(action, payload) } if ensure_model_publish(model, action, payload)
34
83
  end
84
+ end
85
+
86
+ # Publishes payload to pubsub
87
+ # @param payload (PubSubModelSync::Payload)
88
+ # @return Payload
89
+ # Raises error if exist
90
+ def publish!(payload, &block)
91
+ return unless ensure_publish(payload)
35
92
 
36
93
  log("Publishing message: #{[payload]}")
37
94
  connector.publish(payload)
38
95
  config.on_after_publish.call(payload)
96
+ block&.call
97
+ payload
98
+ end
99
+
100
+ # Similar to :publish! method
101
+ # Notifies error via :on_error_publish instead of raising error
102
+ # @return Payload
103
+ def publish(payload, &block)
104
+ publish!(payload, &block)
39
105
  rescue => e
40
- raise_error ? raise : notify_error(e, payload)
106
+ notify_error(e, payload)
41
107
  end
42
108
 
43
109
  private
44
110
 
111
+ def ensure_publish(payload)
112
+ payload.headers[:ordering_key] = @transaction_key if @transaction_key.present?
113
+ forced_ordering_key = payload.headers[:forced_ordering_key]
114
+ payload.headers[:ordering_key] = forced_ordering_key if forced_ordering_key
115
+ cancelled = config.on_before_publish.call(payload) == :cancel
116
+ log("Publish cancelled by config.on_before_publish: #{payload}") if config.debug && cancelled
117
+ !cancelled
118
+ end
119
+
120
+ def ensure_model_publish(model, action, payload)
121
+ res_before = model.ps_before_sync(action, payload)
122
+ cancelled = res_before == :cancel
123
+ log("Publish cancelled by model.ps_before_sync: #{payload}") if config.debug && cancelled
124
+ !cancelled
125
+ end
126
+
45
127
  def notify_error(exception, payload)
46
128
  info = [payload, exception.message, exception.backtrace]
47
129
  res = config.on_error_publish.call(exception, { payload: payload })
@@ -26,6 +26,10 @@ module PubSubModelSync
26
26
  end
27
27
 
28
28
  class MockTopic
29
+ def name
30
+ 'name'
31
+ end
32
+
29
33
  def subscription(*_args)
30
34
  @subscription ||= MockSubscription.new
31
35
  end
@@ -28,16 +28,29 @@ module PubSubModelSync
28
28
  def subscribe(*_args)
29
29
  true
30
30
  end
31
+
32
+ def mark_message_as_processed(*_args)
33
+ true
34
+ end
31
35
  end
32
36
 
33
37
  def producer(*_args)
34
38
  MockProducer.new
35
39
  end
40
+ alias async_producer producer
36
41
 
37
42
  def consumer(*_args)
38
43
  MockConsumer.new
39
44
  end
40
45
 
46
+ def topics
47
+ []
48
+ end
49
+
50
+ def create_topic(_name)
51
+ true
52
+ end
53
+
41
54
  def close
42
55
  true
43
56
  end
@@ -6,7 +6,19 @@ module PubSubModelSync
6
6
  attr_reader :data, :attributes, :headers
7
7
 
8
8
  # @param data (Hash: { any value }):
9
- # @param attributes (Hash: { klass: string, action: :sym }):
9
+ # @param attributes (Hash: { klass*: string, action*: :sym }):
10
+ # @param headers (Hash):
11
+ # key (String): identifier of the payload, default:
12
+ # klass/action: when class message
13
+ # klass/action/model.id: when model message
14
+ # ordering_key (String): messages with the same key are processed in the same order they
15
+ # were delivered, default:
16
+ # klass: when class message
17
+ # klass/id: when model message
18
+ # topic_name (String|Array<String>): Specific topic name to be used when delivering the
19
+ # message (default first topic)
20
+ # forced_ordering_key (String, optional): Will force to use this value as the ordering_key,
21
+ # even withing transactions. Default nil.
10
22
  def initialize(data, attributes, headers = {})
11
23
  @data = data
12
24
  @attributes = attributes
@@ -21,7 +33,7 @@ module PubSubModelSync
21
33
  end
22
34
 
23
35
  def klass
24
- attributes[:klass]
36
+ attributes[:klass].to_s
25
37
  end
26
38
 
27
39
  def action
@@ -31,16 +43,14 @@ module PubSubModelSync
31
43
  # Process payload data
32
44
  # (If error will raise exception and wont call on_error_processing callback)
33
45
  def process!
34
- process do |publisher|
35
- publisher.raise_error = true
36
- end
46
+ publisher = PubSubModelSync::MessageProcessor.new(self)
47
+ publisher.process!
37
48
  end
38
49
 
39
50
  # Process payload data
40
51
  # (If error will call on_error_processing callback)
41
52
  def process
42
53
  publisher = PubSubModelSync::MessageProcessor.new(self)
43
- yield(publisher) if block_given?
44
54
  publisher.process
45
55
  end
46
56
 
@@ -48,7 +58,7 @@ module PubSubModelSync
48
58
  # (If error will raise exception and wont call on_error_publish callback)
49
59
  def publish!
50
60
  klass = PubSubModelSync::MessagePublisher
51
- klass.publish(self, raise_error: true)
61
+ klass.publish!(self)
52
62
  end
53
63
 
54
64
  # Publish payload to pubsub
@@ -68,8 +78,10 @@ module PubSubModelSync
68
78
  private
69
79
 
70
80
  def build_headers
71
- headers[:uuid] ||= SecureRandom.uuid
72
81
  headers[:app_key] ||= PubSubModelSync::Config.subscription_key
82
+ headers[:key] ||= [klass, action].join('/')
83
+ headers[:ordering_key] ||= klass
84
+ headers[:uuid] ||= SecureRandom.uuid
73
85
  end
74
86
 
75
87
  def validate!
@@ -2,34 +2,50 @@
2
2
 
3
3
  module PubSubModelSync
4
4
  class Publisher
5
- attr_accessor :attrs, :actions, :klass, :as_klass
5
+ attr_accessor :attrs, :actions, :klass, :as_klass, :headers
6
6
 
7
- def initialize(attrs, klass, actions = nil, as_klass = nil)
7
+ # @param headers (Hash): refer Payload.headers
8
+ def initialize(attrs, klass, actions = nil, as_klass: nil, headers: {})
8
9
  @attrs = attrs
9
10
  @klass = klass
10
11
  @actions = actions || %i[create update destroy]
11
12
  @as_klass = as_klass || klass
13
+ @headers = headers
12
14
  end
13
15
 
14
- def payload(model, action)
15
- PubSubModelSync::Payload.new(payload_data(model), payload_attrs(model, action))
16
+ # Builds the payload with model information defined for :action (:create|:update|:destroy)
17
+ # @param custom_headers (Hash, default {}): refer Payload.headers
18
+ def payload(model, action, custom_data: nil, custom_headers: {})
19
+ payload_headers = self.class.headers_for(model, action)
20
+ .merge(headers)
21
+ .merge(custom_headers)
22
+ data = custom_data || payload_data(model)
23
+ PubSubModelSync::Payload.new(data, payload_attrs(model, action), payload_headers)
24
+ end
25
+
26
+ def self.headers_for(model, action)
27
+ key = [model.class.name, action, model.id].join('/')
28
+ { ordering_key: ordering_key_for(model), key: key }
29
+ end
30
+
31
+ def self.ordering_key_for(model)
32
+ [model.class.name, model.id || SecureRandom.uuid].join('/')
16
33
  end
17
34
 
18
35
  private
19
36
 
20
37
  def payload_data(model)
21
- source_props = @attrs.map { |prop| prop.to_s.split(':').first }
22
- data = model.as_json(only: source_props, methods: source_props)
23
- aliased_props = @attrs.select { |prop| prop.to_s.include?(':') }
24
- aliased_props.each do |prop|
38
+ @attrs.map do |prop|
25
39
  source, target = prop.to_s.split(':')
26
- data[target] = data.delete(source)
27
- end
28
- data.symbolize_keys
40
+ [target || source, model.send(source.to_sym)]
41
+ end.to_h.symbolize_keys
29
42
  end
30
43
 
31
44
  def payload_attrs(model, action)
32
- { klass: (as_klass || model.class.name).to_s, action: action.to_sym }
45
+ {
46
+ klass: (as_klass || model.class.name).to_s,
47
+ action: action.to_sym
48
+ }
33
49
  end
34
50
  end
35
51
  end
@@ -4,6 +4,7 @@ module PubSubModelSync
4
4
  module PublisherConcern
5
5
  def self.included(base)
6
6
  base.extend(ClassMethods)
7
+ base.send(:ps_init_transaction_callbacks)
7
8
  end
8
9
 
9
10
  # Before initializing sync service (callbacks: after create/update/destroy)
@@ -17,39 +18,39 @@ module PubSubModelSync
17
18
  end
18
19
 
19
20
  # before delivering data (return :cancel to cancel sync)
20
- def ps_before_sync(_action, _data); end
21
+ def ps_before_sync(_action, _payload); end
21
22
 
22
23
  # after delivering data
23
- def ps_after_sync(_action, _data); end
24
+ def ps_after_sync(_action, _payload); end
24
25
 
25
26
  # To perform sync on demand
26
- # @param attrs (Array, optional): custom attrs to be used
27
- # @param as_klass (Array, optional): custom klass name to be used
28
- # @param publisher (Publisher, optional): custom publisher object
29
- def ps_perform_sync(action = :create, attrs: nil, as_klass: nil,
30
- publisher: nil)
31
- publisher ||= self.class.ps_publisher(action).dup
32
- publisher.attrs = attrs if attrs
33
- publisher.as_klass = as_klass if as_klass
34
- PubSubModelSync::MessagePublisher.publish_model(self, action, publisher)
27
+ # @param action (Sym): CRUD action name
28
+ # @param custom_data (nil|Hash) If present custom_data will be used as the payload data. I.E.
29
+ # data generator will be ignored
30
+ # @param custom_headers (Hash, optional): refer Payload.headers
31
+ def ps_perform_sync(action = :create, custom_data: nil, custom_headers: {})
32
+ p_klass = PubSubModelSync::MessagePublisher
33
+ p_klass.publish_model(self, action, custom_data: custom_data, custom_headers: custom_headers)
35
34
  end
36
35
 
37
36
  module ClassMethods
38
37
  # Permit to configure to publish crud actions (:create, :update, :destroy)
39
- def ps_publish(attrs, actions: %i[create update destroy], as_klass: nil)
38
+ # @param headers (Hash, optional): Refer Payload.headers
39
+ def ps_publish(attrs, actions: %i[create update destroy], as_klass: nil, headers: {})
40
40
  klass = PubSubModelSync::Publisher
41
- publisher = klass.new(attrs, name, actions, as_klass)
41
+ publisher = klass.new(attrs, name, actions, as_klass: as_klass, headers: headers)
42
42
  PubSubModelSync::Config.publishers << publisher
43
43
  actions.each do |action|
44
- ps_register_callback(action.to_sym, publisher)
44
+ ps_register_callback(action.to_sym)
45
45
  end
46
46
  end
47
47
 
48
- # On demand class level publisher
49
- def ps_class_publish(data, action:, as_klass: nil)
50
- as_klass = (as_klass || name).to_s
48
+ # Klass level notification
49
+ # @deprecated this method was deprecated in favor of:
50
+ # PubSubModelSync::MessagePublisher.publish_data(...)
51
+ def ps_class_publish(data, action:, as_klass: nil, headers: {})
51
52
  klass = PubSubModelSync::MessagePublisher
52
- klass.publish_data(as_klass, data, action.to_sym)
53
+ klass.publish_data((as_klass || name).to_s, data, action.to_sym, headers: headers)
53
54
  end
54
55
 
55
56
  # Publisher info for specific action
@@ -61,12 +62,28 @@ module PubSubModelSync
61
62
 
62
63
  private
63
64
 
64
- def ps_register_callback(action, publisher)
65
+ # TODO: skip all enqueued notifications after_rollback (when failed)
66
+ # Initialize calls to start and end pub_sub transactions and deliver all them in the same order
67
+ def ps_init_transaction_callbacks
68
+ start_transaction = lambda do
69
+ key = PubSubModelSync::Publisher.ordering_key_for(self)
70
+ @ps_old_transaction_key = PubSubModelSync::MessagePublisher.init_transaction(key)
71
+ end
72
+ end_transaction = -> { PubSubModelSync::MessagePublisher.end_transaction(@ps_old_transaction_key) }
73
+ after_create start_transaction, prepend: true # wait for ID
74
+ before_update start_transaction, prepend: true
75
+ before_destroy start_transaction, prepend: true
76
+ after_commit end_transaction
77
+ after_rollback end_transaction
78
+ end
79
+
80
+ # Configure specific callback and execute publisher when called callback
81
+ def ps_register_callback(action)
65
82
  after_commit(on: action) do |model|
66
83
  disabled = PubSubModelSync::Config.disabled_callback_publisher.call(model, action)
67
84
  if !disabled && !model.ps_skip_callback?(action)
68
85
  klass = PubSubModelSync::MessagePublisher
69
- klass.publish_model(model, action.to_sym, publisher)
86
+ klass.publish_model(model, action.to_sym)
70
87
  end
71
88
  end
72
89
  end