pub_sub_model_sync 0.5.10 → 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)
17
+ # @param errors (Array(Class|String))
18
+ def retry_error(errors, qty: 2, &block)
18
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
@@ -28,9 +28,11 @@ module PubSubModelSync
28
28
  private
29
29
 
30
30
  def run_subscriber(subscriber)
31
+ subscriber = subscriber.dup
31
32
  return unless processable?(subscriber)
32
33
 
33
- retry_error(ActiveRecord::ConnectionTimeoutError, qty: 2) do
34
+ errors = [ActiveRecord::ConnectionTimeoutError, 'deadlock detected', 'could not serialize access']
35
+ retry_error(errors, qty: 2) do
34
36
  subscriber.process!(payload)
35
37
  res = config.on_success_processing.call(payload, { subscriber: subscriber })
36
38
  log "processed message with: #{payload.inspect}" if res != :skip_log
@@ -3,60 +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
 
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
28
+ end
29
+
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
37
+
38
+ # Restores to the last transaction key
39
+ def end_transaction(parent_key)
40
+ self.transaction_key = parent_key
41
+ end
42
+
10
43
  # Publishes any value to pubsub
11
44
  # @param klass (String): Class name
12
45
  # @param data (Hash): Data to be delivered
13
46
  # @param action (:symbol): action name
14
- def publish_data(klass, data, action)
47
+ # @param headers (Hash, optional): header settings (More in Payload.headers)
48
+ # @return Payload
49
+ def publish_data(klass, data, action, headers: {})
15
50
  attrs = { klass: klass.to_s, action: action.to_sym }
16
- payload = PubSubModelSync::Payload.new(data, attrs)
51
+ payload = PubSubModelSync::Payload.new(data, attrs, headers)
17
52
  publish(payload)
18
53
  end
19
54
 
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
+
20
67
  # Publishes model info to pubsub
21
68
  # @param model (ActiveRecord model)
22
69
  # @param action (Sym): Action name
23
- # @param publisher (Publisher, optional): Publisher to be used
24
- def publish_model(model, action, publisher = nil)
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: {})
25
74
  return if model.ps_skip_sync?(action)
26
75
 
27
- publisher ||= model.class.ps_publisher(action)
28
- payload = publisher.payload(model, action)
29
- res_before = model.ps_before_sync(action, payload.data)
30
- return if res_before == :cancel
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
31
79
 
32
- publish(payload)
33
- model.ps_after_sync(action, payload.data)
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)
83
+ end
34
84
  end
35
85
 
36
86
  # Publishes payload to pubsub
37
- # @attr payload (PubSubModelSync::Payload)
87
+ # @param payload (PubSubModelSync::Payload)
88
+ # @return Payload
38
89
  # Raises error if exist
39
- def publish!(payload)
40
- if config.on_before_publish.call(payload) == :cancel
41
- log("Publish message cancelled: #{payload}") if config.debug
42
- return
43
- end
90
+ def publish!(payload, &block)
91
+ return unless ensure_publish(payload)
44
92
 
45
93
  log("Publishing message: #{[payload]}")
46
94
  connector.publish(payload)
47
95
  config.on_after_publish.call(payload)
96
+ block&.call
97
+ payload
48
98
  end
49
99
 
50
100
  # Similar to :publish! method
51
101
  # Notifies error via :on_error_publish instead of raising error
52
- def publish(payload)
53
- publish!(payload)
102
+ # @return Payload
103
+ def publish(payload, &block)
104
+ publish!(payload, &block)
54
105
  rescue => e
55
106
  notify_error(e, payload)
56
107
  end
57
108
 
58
109
  private
59
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
+
60
127
  def notify_error(exception, payload)
61
128
  info = [payload, exception.message, exception.backtrace]
62
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
@@ -7,7 +7,18 @@ module PubSubModelSync
7
7
 
8
8
  # @param data (Hash: { any value }):
9
9
  # @param attributes (Hash: { klass*: string, action*: :sym }):
10
- # @param headers (Hash: { key?: string, ...any_key?: anything }):
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.
11
22
  def initialize(data, attributes, headers = {})
12
23
  @data = data
13
24
  @attributes = attributes
@@ -22,7 +33,7 @@ module PubSubModelSync
22
33
  end
23
34
 
24
35
  def klass
25
- attributes[:klass]
36
+ attributes[:klass].to_s
26
37
  end
27
38
 
28
39
  def action
@@ -67,9 +78,10 @@ module PubSubModelSync
67
78
  private
68
79
 
69
80
  def build_headers
70
- headers[:uuid] ||= SecureRandom.uuid
71
81
  headers[:app_key] ||= PubSubModelSync::Config.subscription_key
72
- headers[:key] ||= [klass.to_s, action].join('/')
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,32 +2,43 @@
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
16
  # Builds the payload with model information defined for :action (:create|:update|:destroy)
15
- def payload(model, action)
16
- headers = { key: [model.class.name, action, model.id].join('/') }
17
- PubSubModelSync::Payload.new(payload_data(model), payload_attrs(model, action), headers)
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('/')
18
33
  end
19
34
 
20
35
  private
21
36
 
22
37
  def payload_data(model)
23
- source_props = @attrs.map { |prop| prop.to_s.split(':').first }
24
- data = model.as_json(only: source_props, methods: source_props)
25
- aliased_props = @attrs.select { |prop| prop.to_s.include?(':') }
26
- aliased_props.each do |prop|
38
+ @attrs.map do |prop|
27
39
  source, target = prop.to_s.split(':')
28
- data[target] = data.delete(source)
29
- end
30
- data.symbolize_keys
40
+ [target || source, model.send(source.to_sym)]
41
+ end.to_h.symbolize_keys
31
42
  end
32
43
 
33
44
  def payload_attrs(model, action)
@@ -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