pub_sub_model_sync 0.5.8.2 → 1.0.beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ version: '2'
2
+
3
+ services:
4
+ test:
5
+ build: .
6
+ command: sh -c 'bundle exec rspec'
7
+ volumes:
8
+ - .:/app
9
+ - bundler_gems:/usr/local/bundle/
10
+
11
+ volumes:
12
+ bundler_gems:
Binary file
@@ -10,8 +10,10 @@ require 'pub_sub_model_sync/subscriber_concern'
10
10
  require 'pub_sub_model_sync/message_publisher'
11
11
  require 'pub_sub_model_sync/publisher_concern'
12
12
  require 'pub_sub_model_sync/runner'
13
+ require 'pub_sub_model_sync/transaction'
13
14
  require 'pub_sub_model_sync/connector'
14
15
  require 'pub_sub_model_sync/message_processor'
16
+ require 'pub_sub_model_sync/run_subscriber'
15
17
 
16
18
  require 'pub_sub_model_sync/publisher'
17
19
  require 'pub_sub_model_sync/subscriber'
@@ -2,7 +2,7 @@
2
2
 
3
3
  module PubSubModelSync
4
4
  class Base
5
- delegate :config, :log, to: self
5
+ delegate :config, :log, :debug?, to: self
6
6
 
7
7
  class << self
8
8
  def config
@@ -12,13 +12,30 @@ module PubSubModelSync
12
12
  def log(message, kind = :info)
13
13
  config.log message, kind
14
14
  end
15
+
16
+ def debug?
17
+ config.debug
18
+ end
15
19
  end
16
20
 
17
- def retry_error(error_klass, qty: 2, &block)
18
- @retries ||= 0
21
+ # @param errors (Array(Class|String))
22
+ def retry_error(errors, qty: 2, &block)
23
+ retries ||= 0
19
24
  block.call
20
- rescue error_klass => _e
21
- (@retries += 1) <= qty ? retry : raise
25
+ rescue => e
26
+ retries += 1
27
+ res = errors.find { |e_type| match_error?(e, e_type) }
28
+ raise if !res || retries > qty
29
+
30
+ sleep(qty * 0.1) && retry
31
+ end
32
+
33
+ private
34
+
35
+ # @param error (Exception)
36
+ # @param error_type (Class|String)
37
+ def match_error?(error, error_type)
38
+ error_type.is_a?(String) ? error.message.include?(error_type) : error.is_a?(error_type)
22
39
  end
23
40
  end
24
41
  end
@@ -3,12 +3,12 @@
3
3
  module PubSubModelSync
4
4
  class Config
5
5
  cattr_accessor(:subscribers) { [] }
6
- cattr_accessor(:publishers) { [] }
7
6
  cattr_accessor(:service_name) { :google }
8
7
 
9
8
  # customizable callbacks
10
9
  cattr_accessor(:debug) { false }
11
10
  cattr_accessor :logger # LoggerInst
11
+ cattr_accessor(:transactions_use_buffer) { true }
12
12
 
13
13
  cattr_accessor(:on_before_processing) { ->(_payload, _info) {} } # return :cancel to skip
14
14
  cattr_accessor(:on_success_processing) { ->(_payload, _info) {} }
@@ -16,16 +16,15 @@ module PubSubModelSync
16
16
  cattr_accessor(:on_before_publish) { ->(_payload) {} } # return :cancel to skip
17
17
  cattr_accessor(:on_after_publish) { ->(_payload) {} }
18
18
  cattr_accessor(:on_error_publish) { ->(_exception, _info) {} }
19
- cattr_accessor(:disabled_callback_publisher) { ->(_model, _action) { false } }
20
19
 
21
20
  # google service
22
- cattr_accessor :project, :credentials, :topic_name, :subscription_name
21
+ cattr_accessor :project, :credentials, :topic_name, :subscription_name, :default_topic_name
23
22
 
24
23
  # rabbitmq service
25
- cattr_accessor :bunny_connection, :queue_name, :topic_name, :subscription_name
24
+ cattr_accessor :bunny_connection, :topic_name, :subscription_name, :default_topic_name
26
25
 
27
26
  # kafka service
28
- cattr_accessor :kafka_connection, :topic_name, :subscription_name
27
+ cattr_accessor :kafka_connection, :topic_name, :subscription_name, :default_topic_name
29
28
 
30
29
  def self.log(msg, kind = :info)
31
30
  msg = "PS_MSYNC ==> #{msg}"
@@ -37,8 +36,17 @@ module PubSubModelSync
37
36
  end
38
37
 
39
38
  def self.subscription_key
40
- subscription_name ||
41
- (Rails.application.class.parent_name rescue '') # rubocop:disable Style/RescueModifier
39
+ klass = Rails.application.class
40
+ app_name = klass.respond_to?(:module_parent_name) ? klass.module_parent_name : klass.parent_name
41
+ subscription_name || app_name
42
+ end
43
+
44
+ class << self
45
+ alias default_topic_name_old default_topic_name
46
+
47
+ def default_topic_name
48
+ default_topic_name_old || Array(topic_name).first
49
+ end
42
50
  end
43
51
  end
44
52
  end
@@ -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
+ processor = PubSubModelSync::RunSubscriber.new(subscriber, payload)
25
32
  return unless processable?(subscriber)
26
33
 
27
- retry_error(ActiveRecord::ConnectionTimeoutError, qty: 2) do
28
- subscriber.process!(payload)
34
+ errors = [ActiveRecord::ConnectionTimeoutError, 'deadlock detected', 'could not serialize access']
35
+ retry_error(errors, qty: 5) do
36
+ processor.call
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,16 +46,15 @@ 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
 
49
55
  def filter_subscribers
50
56
  config.subscribers.select do |subscriber|
51
- subscriber.settings[:from_klass].to_s == payload.klass.to_s &&
52
- subscriber.settings[:from_action].to_s == payload.action.to_s
57
+ subscriber.from_klass == payload.klass && subscriber.action == payload.action && payload.mode == subscriber.mode
53
58
  end
54
59
  end
55
60
  end
@@ -3,45 +3,117 @@
3
3
  module PubSubModelSync
4
4
  class MessagePublisher < PubSubModelSync::Base
5
5
  class << self
6
+ class MissingPublisher < StandardError; end
7
+ attr_accessor :current_transaction
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
+ # @see Transaction.new(...)
20
+ # @param key (String|Nil)
21
+ # @param block (Yield) block to be executed
22
+ def transaction(key, settings = {}, &block)
23
+ t = init_transaction(key, settings)
24
+ block.call
25
+ t.deliver_all
26
+ rescue
27
+ t.rollback
28
+ raise
29
+ ensure
30
+ t.clean_publisher
13
31
  end
14
32
 
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)
20
-
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
33
+ # Starts a new transaction
34
+ # @param key (@transaction_key)
35
+ # @return (Transaction)
36
+ def init_transaction(key, settings = {})
37
+ new_transaction = PubSubModelSync::Transaction.new(key, settings)
38
+ if current_transaction
39
+ current_transaction.add_transaction(new_transaction)
40
+ else
41
+ self.current_transaction = new_transaction
42
+ end
43
+ new_transaction
44
+ end
25
45
 
46
+ # Publishes a class level notification via pubsub
47
+ # @refer PublisherConcern.ps_class_publish
48
+ # @return Payload
49
+ def publish_data(klass, data, action, headers: {})
50
+ attrs = { klass: klass.to_s, action: action.to_sym, mode: :klass }
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
+ # @param model (ActiveRecord::Base)
56
+ # @param action (Symbol: @see PublishConcern::ps_publish)
57
+ # @param settings (Hash: @see Publisher.new.settings)
58
+ def publish_model(model, action, settings = {})
59
+ return if model.ps_skip_publish?(action)
60
+
61
+ publisher = PubSubModelSync::Publisher.new(model, action, settings)
62
+ payload = publisher.payload
63
+
64
+ transaction(payload.headers[:ordering_key]) do # catch and group all :ps_before_publish syncs
65
+ publish(payload) { model.ps_after_publish(action, payload) } if ensure_model_publish(model, action, payload)
34
66
  end
67
+ end
35
68
 
36
- log("Publishing message: #{[payload]}")
69
+ # Publishes payload to pubsub
70
+ # @param payload (PubSubModelSync::Payload)
71
+ # @return Payload
72
+ # Raises error if exist
73
+ def publish!(payload, &block)
74
+ payload.headers[:ordering_key] = ordering_key_for(payload)
75
+ return unless ensure_publish(payload)
76
+
77
+ current_transaction ? current_transaction.add_payload(payload) : connector_publish(payload)
78
+ block&.call
79
+ payload
80
+ end
81
+
82
+ def connector_publish(payload)
37
83
  connector.publish(payload)
84
+ log("Published message: #{[payload]}")
38
85
  config.on_after_publish.call(payload)
86
+ end
87
+
88
+ # Similar to :publish! method
89
+ # Notifies error via :on_error_publish instead of raising error
90
+ # @return Payload
91
+ def publish(payload, &block)
92
+ publish!(payload, &block)
39
93
  rescue => e
40
- raise_error ? raise : notify_error(e, payload)
94
+ notify_error(e, payload)
41
95
  end
42
96
 
43
97
  private
44
98
 
99
+ def ensure_publish(payload)
100
+ cancelled = config.on_before_publish.call(payload) == :cancel
101
+ log("Publish cancelled by config.on_before_publish: #{payload}") if config.debug && cancelled
102
+ !cancelled
103
+ end
104
+
105
+ def ordering_key_for(payload)
106
+ current_transaction&.key ||= payload.headers[:ordering_key]
107
+ payload.headers[:forced_ordering_key] || current_transaction&.key || payload.headers[:ordering_key]
108
+ end
109
+
110
+ def ensure_model_publish(model, action, payload)
111
+ res_before = model.ps_before_publish(action, payload)
112
+ cancelled = res_before == :cancel
113
+ log("Publish cancelled by model.ps_before_publish: #{payload}") if config.debug && cancelled
114
+ !cancelled
115
+ end
116
+
45
117
  def notify_error(exception, payload)
46
118
  info = [payload, exception.message, exception.backtrace]
47
119
  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
@@ -3,13 +3,25 @@
3
3
  module PubSubModelSync
4
4
  class Payload
5
5
  class MissingInfo < StandardError; end
6
- attr_reader :data, :attributes, :headers
6
+ attr_reader :data, :info, :headers
7
7
 
8
8
  # @param data (Hash: { any value }):
9
- # @param attributes (Hash: { klass: string, action: :sym }):
10
- def initialize(data, attributes, headers = {})
9
+ # @param info (Hash: { klass*: string, action*: :sym, mode?: :klass|:model }):
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.
22
+ def initialize(data, info, headers = {})
11
23
  @data = data
12
- @attributes = attributes
24
+ @info = { mode: :model }.merge(info)
13
25
  @headers = headers
14
26
  build_headers
15
27
  validate!
@@ -17,30 +29,32 @@ module PubSubModelSync
17
29
 
18
30
  # @return Hash: payload data
19
31
  def to_h
20
- { data: data, attributes: attributes, headers: headers }
32
+ { data: data, info: info, headers: headers }
21
33
  end
22
34
 
23
35
  def klass
24
- attributes[:klass]
36
+ info[:klass].to_s
25
37
  end
26
38
 
27
39
  def action
28
- attributes[:action]
40
+ info[:action].to_sym
41
+ end
42
+
43
+ def mode
44
+ info[:mode].to_sym
29
45
  end
30
46
 
31
47
  # Process payload data
32
48
  # (If error will raise exception and wont call on_error_processing callback)
33
49
  def process!
34
- process do |publisher|
35
- publisher.raise_error = true
36
- end
50
+ publisher = PubSubModelSync::MessageProcessor.new(self)
51
+ publisher.process!
37
52
  end
38
53
 
39
54
  # Process payload data
40
55
  # (If error will call on_error_processing callback)
41
56
  def process
42
57
  publisher = PubSubModelSync::MessageProcessor.new(self)
43
- yield(publisher) if block_given?
44
58
  publisher.process
45
59
  end
46
60
 
@@ -48,7 +62,7 @@ module PubSubModelSync
48
62
  # (If error will raise exception and wont call on_error_publish callback)
49
63
  def publish!
50
64
  klass = PubSubModelSync::MessagePublisher
51
- klass.publish(self, raise_error: true)
65
+ klass.publish!(self)
52
66
  end
53
67
 
54
68
  # Publish payload to pubsub
@@ -59,21 +73,23 @@ module PubSubModelSync
59
73
  end
60
74
 
61
75
  # convert payload data into Payload
62
- # @param data [Hash]: payload data (:data, :attributes, :headers)
76
+ # @param data [Hash]: payload data (:data, :info, :headers)
63
77
  def self.from_payload_data(data)
64
78
  data = data.deep_symbolize_keys
65
- new(data[:data], data[:attributes], data[:headers])
79
+ new(data[:data], data[:info], data[:headers])
66
80
  end
67
81
 
68
82
  private
69
83
 
70
84
  def build_headers
71
- headers[:uuid] ||= SecureRandom.uuid
72
85
  headers[:app_key] ||= PubSubModelSync::Config.subscription_key
86
+ headers[:key] ||= [klass, action].join('/')
87
+ headers[:ordering_key] ||= klass
88
+ headers[:uuid] ||= SecureRandom.uuid
73
89
  end
74
90
 
75
91
  def validate!
76
- raise MissingInfo if !attributes[:klass] || !attributes[:action]
92
+ raise MissingInfo if !info[:klass] || !info[:action]
77
93
  end
78
94
  end
79
95
  end