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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +1 -1
- data/CHANGELOG.md +13 -0
- data/Dockerfile +6 -0
- data/Gemfile.lock +2 -1
- data/README.md +182 -97
- data/docker-compose.yaml +12 -0
- data/docs/notifications-diagram.png +0 -0
- data/lib/pub_sub_model_sync/base.rb +16 -3
- data/lib/pub_sub_model_sync/config.rb +1 -1
- data/lib/pub_sub_model_sync/message_processor.rb +3 -1
- data/lib/pub_sub_model_sync/message_publisher.rb +85 -18
- data/lib/pub_sub_model_sync/mock_google_service.rb +4 -0
- data/lib/pub_sub_model_sync/mock_kafka_service.rb +13 -0
- data/lib/pub_sub_model_sync/payload.rb +16 -4
- data/lib/pub_sub_model_sync/publisher.rb +23 -12
- data/lib/pub_sub_model_sync/publisher_concern.rb +37 -20
- data/lib/pub_sub_model_sync/service_base.rb +13 -4
- data/lib/pub_sub_model_sync/service_google.rb +52 -17
- data/lib/pub_sub_model_sync/service_kafka.rb +35 -12
- data/lib/pub_sub_model_sync/service_rabbit.rb +40 -33
- data/lib/pub_sub_model_sync/subscriber.rb +13 -11
- data/lib/pub_sub_model_sync/subscriber_concern.rb +8 -5
- data/lib/pub_sub_model_sync/tasks/worker.rake +11 -0
- data/lib/pub_sub_model_sync/version.rb +1 -1
- metadata +5 -2
data/docker-compose.yaml
ADDED
Binary file
|
@@ -14,11 +14,24 @@ module PubSubModelSync
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
-
|
17
|
+
# @param errors (Array(Class|String))
|
18
|
+
def retry_error(errors, qty: 2, &block)
|
18
19
|
retries ||= 0
|
19
20
|
block.call
|
20
|
-
rescue
|
21
|
-
|
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, :
|
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
|
-
|
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
|
-
|
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
|
24
|
-
|
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
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
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
|
-
# @
|
87
|
+
# @param payload (PubSubModelSync::Payload)
|
88
|
+
# @return Payload
|
38
89
|
# Raises error if exist
|
39
|
-
def publish!(payload)
|
40
|
-
|
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
|
-
|
53
|
-
|
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 })
|
@@ -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
|
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
|
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
|
-
|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
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
|
-
|
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,
|
21
|
+
def ps_before_sync(_action, _payload); end
|
21
22
|
|
22
23
|
# after delivering data
|
23
|
-
def ps_after_sync(_action,
|
24
|
+
def ps_after_sync(_action, _payload); end
|
24
25
|
|
25
26
|
# To perform sync on demand
|
26
|
-
# @param
|
27
|
-
# @param
|
28
|
-
#
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
-
|
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
|
44
|
+
ps_register_callback(action.to_sym)
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
48
|
-
#
|
49
|
-
|
50
|
-
|
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
|
-
|
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
|
86
|
+
klass.publish_model(model, action.to_sym)
|
70
87
|
end
|
71
88
|
end
|
72
89
|
end
|