pub_sub_model_sync 0.5.10 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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