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.
- 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
|