pub_sub_model_sync 0.5.9 → 1.0.beta1

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.
@@ -2,73 +2,86 @@
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) # rubocop:disable Metrics/MethodLength
59
+ callback = ->(action) { method_name ? send(method_name, action) : instance_exec(action, &block) }
60
+ commit_name = respond_to?(:before_commit) ? :before_commit : :after_commit
61
+ Array(crud_actions).each do |action|
62
+ if action == :destroy
63
+ after_destroy { instance_exec(action, &callback) }
64
+ elsif PubSubModelSync::Config.enable_rails4_before_commit # rails 4 compatibility
65
+ define_method("ps_before_#{action}_commit") { instance_exec(action, &callback) }
66
+ else
67
+ send(commit_name, on: action) { instance_exec(action, &callback) }
68
+ end
59
69
  end
60
70
  end
61
71
 
62
72
  private
63
73
 
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
74
+ # Initialize calls to start and end pub_sub transactions and deliver all them in the same order
75
+ def ps_init_transaction_callbacks
76
+ start_transaction = lambda do
77
+ key = id ? PubSubModelSync::Publisher.ordering_key_for(self) : nil
78
+ @ps_transaction = PubSubModelSync::MessagePublisher.init_transaction(key)
71
79
  end
80
+ after_create start_transaction, prepend: true # wait for ID
81
+ before_update start_transaction, prepend: true
82
+ before_destroy start_transaction, prepend: true
83
+ after_commit { @ps_transaction.finish }
84
+ after_rollback(prepend: true) { @ps_transaction.rollback }
72
85
  end
73
86
  end
74
87
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'pub_sub_model_sync'
4
4
  require 'rails'
5
+ require 'pub_sub_model_sync/config'
5
6
  module PubSubModelSync
6
7
  class Railtie < ::Rails::Railtie
7
8
  railtie_name :pub_sub_model_sync
@@ -9,5 +10,9 @@ module PubSubModelSync
9
10
  rake_tasks do
10
11
  load 'pub_sub_model_sync/tasks/worker.rake'
11
12
  end
13
+
14
+ configure do
15
+ require 'pub_sub_model_sync/initializers/before_commit' if PubSubModelSync::Config.enable_rails4_before_commit
16
+ end
12
17
  end
13
18
  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)
@@ -7,14 +7,20 @@ end
7
7
 
8
8
  module PubSubModelSync
9
9
  class ServiceKafka < ServiceBase
10
+ QTY_WORKERS = 10
11
+ LISTEN_SETTINGS = {}.freeze
12
+ PUBLISH_SETTINGS = {}.freeze
13
+ PRODUCER_SETTINGS = { delivery_threshold: 200, delivery_interval: 30 }.freeze
10
14
  cattr_accessor :producer
11
- attr_accessor :config, :service, :consumer
15
+
16
+ # @!attribute topic_names (Array): ['topic 1', 'topic 2']
17
+ attr_accessor :service, :consumer, :topic_names
12
18
 
13
19
  def initialize
14
- @config = PubSubModelSync::Config
15
20
  settings = config.kafka_connection
16
21
  settings[1][:client_id] ||= config.subscription_key
17
22
  @service = Kafka.new(*settings)
23
+ @topic_names = ensure_topics(Array(config.topic_name || 'model_sync'))
18
24
  end
19
25
 
20
26
  def listen_messages
@@ -28,12 +34,10 @@ module PubSubModelSync
28
34
  end
29
35
 
30
36
  def publish(payload)
31
- settings = {
32
- topic: config.topic_name,
33
- headers: { SERVICE_KEY => true }
34
- }.merge(PUBLISH_SETTINGS)
35
- producer.produce(payload.to_json, settings)
36
- producer.deliver_messages
37
+ message_topics = Array(payload.headers[:topic_name] || config.default_topic_name)
38
+ message_topics.each do |topic_name|
39
+ producer.produce(encode_payload(payload), message_settings(payload, topic_name))
40
+ end
37
41
  end
38
42
 
39
43
  def stop
@@ -43,22 +47,45 @@ module PubSubModelSync
43
47
 
44
48
  private
45
49
 
50
+ def message_settings(payload, topic_name)
51
+ {
52
+ topic: ensure_topics(topic_name),
53
+ partition_key: payload.headers[:ordering_key],
54
+ headers: { SERVICE_KEY => true }
55
+ }.merge(PUBLISH_SETTINGS)
56
+ end
57
+
46
58
  def start_consumer
47
- @consumer = service.consumer(group_id: config.subscription_key)
48
- consumer.subscribe(config.topic_name)
59
+ subscription_key = config.subscription_key
60
+ @consumer = service.consumer(group_id: subscription_key)
61
+ topic_names.each do |topic_name|
62
+ log("Subscribed to topic: #{topic_name} as #{subscription_key}")
63
+ consumer.subscribe(topic_name)
64
+ end
49
65
  end
50
66
 
51
67
  def producer
52
68
  return self.class.producer if self.class.producer
53
69
 
54
70
  at_exit { self.class.producer.shutdown }
55
- self.class.producer = service.producer
71
+ self.class.producer = service.async_producer(PRODUCER_SETTINGS)
56
72
  end
57
73
 
58
74
  def process_message(message)
59
- return unless message.headers[SERVICE_KEY]
75
+ super(message.value) if message.headers[SERVICE_KEY]
76
+ end
60
77
 
61
- super(message.value)
78
+ # Check topic existence, create if missing topic
79
+ # @param names (Array<String>|String)
80
+ # @return (Array|String) return @param names
81
+ def ensure_topics(names)
82
+ missing_topics = Array(names) - (@known_topics || service.topics)
83
+ missing_topics.each do |name|
84
+ service.create_topic(name)
85
+ end
86
+ @known_topics ||= [] # cache service.topics to reduce verification time
87
+ @known_topics = (@known_topics + Array(names)).uniq
88
+ names
62
89
  end
63
90
  end
64
91
  end