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.
@@ -1,35 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PubSubModelSync
4
- class Publisher
5
- attr_accessor :attrs, :actions, :klass, :as_klass
6
-
7
- def initialize(attrs, klass, actions = nil, as_klass = nil)
8
- @attrs = attrs
9
- @klass = klass
10
- @actions = actions || %i[create update destroy]
11
- @as_klass = as_klass || klass
4
+ class Publisher < PubSubModelSync::Base
5
+ attr_accessor :model, :action, :data, :mapping, :headers, :as_klass
6
+
7
+ # @param model (ActiveRecord::Base)
8
+ # @param action (@see PublishConcern::ps_publish)
9
+ # @param settings (@see PublishConcern::ps_publish): { data:, mapping:, headers:, as_klass: }
10
+ def initialize(model, action, settings = {})
11
+ @model = model
12
+ @action = action
13
+ @data = settings[:data] || {}
14
+ @mapping = settings[:mapping] || []
15
+ @headers = settings[:headers] || {}
16
+ @as_klass = settings[:as_klass] || model.class.name
17
+ end
18
+
19
+ # @return (Payload)
20
+ def payload
21
+ values = compute_value(data)
22
+ values = mapping_data.merge(values)
23
+ PubSubModelSync::Payload.new(values, settings_data, headers_data)
12
24
  end
13
25
 
14
- def payload(model, action)
15
- PubSubModelSync::Payload.new(payload_data(model), payload_attrs(model, action))
26
+ def self.ordering_key_for(model)
27
+ [model.class.name, model.id || SecureRandom.uuid].join('/')
16
28
  end
17
29
 
18
30
  private
19
31
 
20
- def payload_data(model)
21
- source_props = @attrs.map { |prop| prop.to_s.split(':').first }
22
- data = model.as_json(only: source_props, methods: source_props)
23
- aliased_props = @attrs.select { |prop| prop.to_s.include?(':') }
24
- aliased_props.each do |prop|
25
- source, target = prop.to_s.split(':')
26
- data[target] = data.delete(source)
27
- end
28
- data.symbolize_keys
32
+ def headers_data
33
+ klass_name = model.class.name
34
+ key = [klass_name, action, model.id || SecureRandom.uuid].join('/')
35
+ def_data = { ordering_key: self.class.ordering_key_for(model), key: key }
36
+ def_data.merge(compute_value(headers))
29
37
  end
30
38
 
31
- def payload_attrs(model, action)
32
- { klass: (as_klass || model.class.name).to_s, action: action.to_sym }
39
+ def compute_value(value)
40
+ res = value
41
+ res = model.send(value, action) if value.is_a?(Symbol) # method name
42
+ res = value.call(model, action) if value.is_a?(Proc)
43
+ res
44
+ end
45
+
46
+ def settings_data
47
+ { klass: as_klass, action: action }
48
+ end
49
+
50
+ def mapping_data
51
+ mapping.map do |prop|
52
+ source, target = prop.to_s.split(':')
53
+ [target || source, model.send(source.to_sym)]
54
+ end.to_h.symbolize_keys
33
55
  end
34
56
  end
35
57
  end
@@ -2,73 +2,83 @@
2
2
 
3
3
  module PubSubModelSync
4
4
  module PublisherConcern
5
- def self.included(base)
6
- base.extend(ClassMethods)
7
- end
5
+ extend ActiveSupport::Concern
8
6
 
9
- # Before initializing sync service (callbacks: after create/update/destroy)
10
- def ps_skip_callback?(_action)
11
- false
7
+ included do
8
+ extend ClassMethods
9
+ ps_init_transaction_callbacks if self <= ActiveRecord::Base
12
10
  end
13
11
 
14
12
  # before preparing data to sync
15
- def ps_skip_sync?(_action)
13
+ def ps_skip_publish?(_action)
16
14
  false
17
15
  end
16
+ alias ps_skip_sync? ps_skip_publish? # @deprecated
18
17
 
19
18
  # before delivering data (return :cancel to cancel sync)
20
- def ps_before_sync(_action, _data); end
19
+ def ps_before_publish(_action, _payload); end
20
+ alias ps_before_sync ps_before_publish # @deprecated
21
21
 
22
22
  # after delivering data
23
- def ps_after_sync(_action, _data); end
23
+ def ps_after_publish(_action, _payload); end
24
+ alias ps_after_sync ps_after_publish # @deprecated
24
25
 
25
- # 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)
26
+ # Delivers a notification via pubsub
27
+ # @param action (Sym|String) Sample: create|update|save|destroy|<any_other_key>
28
+ # @param mapping? (Array<String>) If present will generate data using the mapping and added to the payload.
29
+ # Sample: ["id", "full_name:name"]
30
+ # @param data? (Hash|Symbol|Proc)
31
+ # Hash: Data to be added to the payload
32
+ # Symbol: Method name to be called to retrieve payload data (must return a hash value, receives :action name)
33
+ # Proc: Block to be called to retrieve payload data
34
+ # @param headers? (Hash|Symbol|Proc): (All available attributes in Payload.headers)
35
+ # Hash: Data that will be merged with default header values
36
+ # Symbol: Method name that will be called to retrieve header values (must return a hash, receives :action name)
37
+ # Proc: Block to be called to retrieve header values
38
+ # @param as_klass? (String): Output class name used instead of current class name
39
+ def ps_publish(action, data: {}, mapping: [], headers: {}, as_klass: self.class.name)
40
+ p_klass = PubSubModelSync::MessagePublisher
41
+ p_klass.publish_model(self, action, data: data, mapping: mapping, headers: headers, as_klass: as_klass)
35
42
  end
43
+ delegate :ps_class_publish, to: :class
36
44
 
37
45
  module ClassMethods
38
- # Permit to configure to publish crud actions (:create, :update, :destroy)
39
- def ps_publish(attrs, actions: %i[create update destroy], as_klass: nil)
40
- klass = PubSubModelSync::Publisher
41
- publisher = klass.new(attrs, name, actions, as_klass)
42
- PubSubModelSync::Config.publishers << publisher
43
- actions.each do |action|
44
- ps_register_callback(action.to_sym, publisher)
45
- end
46
- end
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
46
+ # Publishes a class level notification via pubsub
47
+ # @param data (Hash): Data of the notification
48
+ # @param action (Symbol): action name of the notification
49
+ # @param as_klass (String, default current class name): Class name of the notification
50
+ # @param headers (Hash, optional): header settings (More in Payload.headers)
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
- # Publisher info for specific action
56
- def ps_publisher(action = :create)
57
- PubSubModelSync::Config.publishers.find do |publisher|
58
- publisher.klass == name && publisher.actions.include?(action)
56
+ # @param crud_actions (Symbol|Array<Symbol>): :create, :update, :destroy
57
+ # @param method_name (Symbol, optional) method to be called
58
+ def ps_on_crud_event(crud_actions, method_name = nil, &block)
59
+ crud_actions = Array(crud_actions)
60
+ callback = ->(action) { method_name ? send(method_name, action) : instance_exec(action, &block) }
61
+ commit_name = respond_to?(:before_commit) ? :before_commit : :after_commit
62
+ crud_actions.each do |action|
63
+ send(commit_name, on: :create) { instance_exec(action, &callback) } if action == :create
64
+ send(commit_name, on: :update) { instance_exec(action, &callback) } if action == :update
65
+ after_destroy { instance_exec(action, &callback) } if action == :destroy
59
66
  end
60
67
  end
61
68
 
62
69
  private
63
70
 
64
- def ps_register_callback(action, publisher)
65
- after_commit(on: action) do |model|
66
- disabled = PubSubModelSync::Config.disabled_callback_publisher.call(model, action)
67
- if !disabled && !model.ps_skip_callback?(action)
68
- klass = PubSubModelSync::MessagePublisher
69
- klass.publish_model(model, action.to_sym, publisher)
70
- end
71
+ # Initialize calls to start and end pub_sub transactions and deliver all them in the same order
72
+ def ps_init_transaction_callbacks
73
+ start_transaction = lambda do
74
+ key = id ? PubSubModelSync::Publisher.ordering_key_for(self) : nil
75
+ @ps_transaction = PubSubModelSync::MessagePublisher.init_transaction(key)
71
76
  end
77
+ after_create start_transaction, prepend: true # wait for ID
78
+ before_update start_transaction, prepend: true
79
+ before_destroy start_transaction, prepend: true
80
+ after_commit { @ps_transaction.deliver_all }
81
+ after_rollback(prepend: true) { @ps_transaction.rollback }
72
82
  end
73
83
  end
74
84
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PubSubModelSync
4
+ class RunSubscriber < Base
5
+ attr_accessor :subscriber, :payload, :model
6
+
7
+ delegate :settings, to: :subscriber
8
+
9
+ # @param subscriber(Subscriber)
10
+ # @param payload(Payload)
11
+ def initialize(subscriber, payload)
12
+ @subscriber = subscriber
13
+ @payload = payload
14
+ end
15
+
16
+ def call
17
+ klass_subscription? ? run_class_message : run_model_message
18
+ end
19
+
20
+ private
21
+
22
+ def klass_subscription?
23
+ subscriber.settings[:mode] == :klass
24
+ end
25
+
26
+ def run_class_message
27
+ model_class = subscriber.klass.constantize
28
+ model_class.ps_processing_payload = payload # TODO: review for parallel notifications
29
+ call_action(model_class, payload.data) if ensure_sync(model_class)
30
+ end
31
+
32
+ # support for: create, update, destroy
33
+ def run_model_message
34
+ @model = find_model
35
+ model.ps_processing_payload = payload
36
+ return unless ensure_sync(model)
37
+
38
+ populate_model
39
+ model.send(:ps_before_save_sync) if model.respond_to?(:ps_before_save_sync)
40
+ call_action(model)
41
+ end
42
+
43
+ def ensure_sync(object)
44
+ res = true
45
+ res = false if settings[:if] && !parse_condition(settings[:if], object)
46
+ res = false if settings[:unless] && parse_condition(settings[:unless], object)
47
+ log("Cancelled save sync by subscriber condition : #{[payload]}") if !res && debug?
48
+ res
49
+ end
50
+
51
+ def call_action(object, *args)
52
+ action_name = settings[:to_action]
53
+ if action_name.is_a?(Proc)
54
+ args.prepend(object) unless klass_subscription?
55
+ action_name.call(*args)
56
+ else # method name
57
+ action_name = :save if %i[create update].include?(action_name.to_sym)
58
+ object.send(action_name, *args)
59
+ end
60
+ raise(object.errors) if object.respond_to?(:errors) && object.errors.any?
61
+ end
62
+
63
+ def parse_condition(condition, object)
64
+ proc_args = klass_subscription? ? [] : [object]
65
+ case condition
66
+ when Proc then condition.call(*proc_args)
67
+ when Array then condition.all? { |method_name| object.send(method_name) }
68
+ else # method name
69
+ object.send(condition)
70
+ end
71
+ end
72
+
73
+ def find_model
74
+ model_class = subscriber.klass.constantize
75
+ return model_class.ps_find_model(payload.data) if model_class.respond_to?(:ps_find_model)
76
+
77
+ model_class.where(model_identifiers).first_or_initialize
78
+ end
79
+
80
+ # @param mappings (Array<String>) supports aliasing, sample: ["id", "full_name:name"]
81
+ # @return (Hash) hash with the correct attr names and its values
82
+ def parse_mapping(mappings)
83
+ mappings.map do |prop|
84
+ source, target = prop.to_s.split(':')
85
+ key = (target || source).to_sym
86
+ next unless payload.data.key?(source.to_sym)
87
+
88
+ [key, payload.data[source.to_sym]]
89
+ end.compact.to_h.symbolize_keys
90
+ end
91
+
92
+ # @return (Hash) hash including identifiers and its values
93
+ def model_identifiers
94
+ @model_identifiers ||= parse_mapping(Array(settings[:id]).map(&:to_s))
95
+ end
96
+
97
+ def populate_model
98
+ values = parse_mapping(subscriber.mapping).except(model_identifiers.keys)
99
+ values.each do |attr, value|
100
+ model.send("#{attr}=", value)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -4,8 +4,6 @@ require 'pub_sub_model_sync/payload'
4
4
  module PubSubModelSync
5
5
  class ServiceBase < PubSubModelSync::Base
6
6
  SERVICE_KEY = 'service_model_sync'
7
- PUBLISH_SETTINGS = {}.freeze
8
- LISTEN_SETTINGS = {}.freeze
9
7
 
10
8
  def listen_messages
11
9
  raise 'method :listen_messages must be defined in service'
@@ -22,23 +20,45 @@ module PubSubModelSync
22
20
 
23
21
  private
24
22
 
23
+ # @param payload (Payload)
24
+ # @return (String): Json Format
25
+ def encode_payload(payload)
26
+ data = payload.to_h
27
+ not_important_keys = %i[ordering_key topic_name forced_ordering_key]
28
+ reduce_payload_size = !config.debug
29
+ data[:headers].except!(*not_important_keys) if reduce_payload_size
30
+ data.to_json
31
+ end
32
+
25
33
  # @param (String: Payload in json format)
26
34
  def process_message(payload_info)
27
- payload = parse_payload(payload_info)
28
- log("Received message: #{[payload]}") if config.debug
29
- if same_app_message?(payload)
30
- log("Skip message from same origin: #{[payload]}") if config.debug
35
+ retries ||= 0
36
+ payload = decode_payload(payload_info)
37
+ return payload.process unless same_app_message?(payload)
38
+
39
+ log("Skipping message from same origin: #{[payload]}") if config.debug
40
+ rescue => e
41
+ retry if can_retry_process_message?(e, payload, retries += 1)
42
+ end
43
+
44
+ def can_retry_process_message?(error, payload, retries)
45
+ error_payload = [payload, error.message, error.backtrace]
46
+ if retries <= 5
47
+ sleep(retries)
48
+ log("Error while starting to process a message (retrying #{retries} retries...): #{error_payload}", :error)
49
+ rescue_database_connection if lost_db_connection_err?(error)
50
+ true
31
51
  else
32
- payload.process
52
+ log("Retried 5 times and error persists, exiting...: #{error_payload}", :error)
53
+ Process.exit!(true)
33
54
  end
34
- rescue => e
35
- error = [payload, e.message, e.backtrace]
36
- log("Error parsing received message: #{error}", :error)
37
55
  end
38
56
 
39
- def parse_payload(payload_info)
40
- info = JSON.parse(payload_info).deep_symbolize_keys
41
- ::PubSubModelSync::Payload.new(info[:data], info[:attributes], info[:headers])
57
+ # @return Payload
58
+ def decode_payload(payload_info)
59
+ payload = ::PubSubModelSync::Payload.from_payload_data(JSON.parse(payload_info))
60
+ log("Received message: #{[payload]}") if config.debug
61
+ payload
42
62
  end
43
63
 
44
64
  # @param payload (Payload)
@@ -46,5 +66,19 @@ module PubSubModelSync
46
66
  key = payload.headers[:app_key]
47
67
  key && key == config.subscription_key
48
68
  end
69
+
70
+ def lost_db_connection_err?(error)
71
+ return true if error.class.name == 'PG::UnableToSend' # rubocop:disable Style/ClassEqualityComparison
72
+
73
+ error.message.match?(/lost connection/i)
74
+ end
75
+
76
+ def rescue_database_connection
77
+ log('Lost DB connection. Attempting to reconnect...', :warn)
78
+ ActiveRecord::Base.connection.reconnect!
79
+ rescue
80
+ log('Cannot reconnect to database, exiting...', :error)
81
+ Process.exit!(true)
82
+ end
49
83
  end
50
84
  end
@@ -7,50 +7,86 @@ end
7
7
 
8
8
  module PubSubModelSync
9
9
  class ServiceGoogle < ServiceBase
10
- LISTEN_SETTINGS = { threads: { callback: 1 }, message_ordering: true }.freeze
10
+ LISTEN_SETTINGS = { message_ordering: true }.freeze
11
+ PUBLISH_SETTINGS = {}.freeze
11
12
  TOPIC_SETTINGS = {}.freeze
12
13
  SUBSCRIPTION_SETTINGS = { message_ordering: true }.freeze
13
- attr_accessor :service, :topic, :subscription, :subscriber
14
+
15
+ # @!attribute topics (Hash): { key: Topic1, ... }
16
+ # @!attribute publish_topics (Hash): { key: Topic1, ... }
17
+ attr_accessor :service, :topics, :subscribers, :publish_topics
14
18
 
15
19
  def initialize
16
20
  @service = Google::Cloud::Pubsub.new(project: config.project,
17
21
  credentials: config.credentials)
18
- @topic = service.topic(config.topic_name) ||
19
- service.create_topic(config.topic_name, TOPIC_SETTINGS)
20
- topic.enable_message_ordering!
22
+ Array(config.topic_name || 'model_sync').each(&method(:init_topic))
21
23
  end
22
24
 
23
25
  def listen_messages
24
- @subscription = subscribe_to_topic
25
- @subscriber = subscription.listen(LISTEN_SETTINGS, &method(:process_message))
26
26
  log('Listener starting...')
27
- subscriber.start
27
+ @subscribers = subscribe_to_topics
28
28
  log('Listener started')
29
29
  sleep
30
- subscriber.stop.wait!
30
+ subscribers.each { |subscriber| subscriber.stop.wait! }
31
31
  log('Listener stopped')
32
32
  end
33
33
 
34
+ # @param payload (PubSubModelSync::Payload)
34
35
  def publish(payload)
35
- topic.publish_async(payload.to_json, message_headers) do |res|
36
- raise 'Failed to publish the message.' unless res.succeeded?
36
+ p_topic_names = Array(payload.headers[:topic_name] || config.default_topic_name)
37
+ message_topics = p_topic_names.map(&method(:find_topic))
38
+ message_topics.each do |topic|
39
+ topic.publish_async(encode_payload(payload), message_headers(payload)) do |res|
40
+ raise 'Failed to publish the message.' unless res.succeeded?
41
+ end
37
42
  end
38
43
  end
39
44
 
40
45
  def stop
41
46
  log('Listener stopping...')
42
- subscriber.stop!
47
+ subscribers.each(&:stop!)
43
48
  end
44
49
 
45
50
  private
46
51
 
47
- def message_headers
48
- { SERVICE_KEY => true, ordering_key: SERVICE_KEY }.merge(PUBLISH_SETTINGS)
52
+ def find_topic(topic_name)
53
+ topic_name = topic_name.to_s
54
+ return topics.values.first unless topic_name.present?
55
+
56
+ topics[topic_name] || publish_topics[topic_name] || init_topic(topic_name, only_publish: true)
49
57
  end
50
58
 
51
- def subscribe_to_topic
52
- topic.subscription(config.subscription_key) ||
53
- topic.subscribe(config.subscription_key, SUBSCRIPTION_SETTINGS)
59
+ # @param only_publish (Boolean): if false is used to listen and publish messages
60
+ # @return (Topic): returns created or loaded topic
61
+ def init_topic(topic_name, only_publish: false)
62
+ topic_name = topic_name.to_s
63
+ @topics ||= {}
64
+ @publish_topics ||= {}
65
+ topic = service.topic(topic_name) || service.create_topic(topic_name, TOPIC_SETTINGS)
66
+ topic.enable_message_ordering!
67
+ publish_topics[topic_name] = topic if only_publish
68
+ topics[topic_name] = topic unless only_publish
69
+ topic
70
+ end
71
+
72
+ # @param payload (PubSubModelSync::Payload)
73
+ def message_headers(payload)
74
+ {
75
+ SERVICE_KEY => true,
76
+ ordering_key: payload.headers[:ordering_key]
77
+ }.merge(PUBLISH_SETTINGS)
78
+ end
79
+
80
+ # @return [Subscriber]
81
+ def subscribe_to_topics
82
+ topics.map do |key, topic|
83
+ subs_name = "#{config.subscription_key}_#{key}"
84
+ subscription = topic.subscription(subs_name) || topic.subscribe(subs_name, SUBSCRIPTION_SETTINGS)
85
+ subscriber = subscription.listen(LISTEN_SETTINGS, &method(:process_message))
86
+ subscriber.start
87
+ log("Subscribed to topic: #{topic.name} as: #{subs_name}")
88
+ subscriber
89
+ end
54
90
  end
55
91
 
56
92
  def process_message(received_message)