dionysus-rb 0.1.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.
Files changed (102) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +61 -0
  3. data/.github/workflows/ci.yml +77 -0
  4. data/.gitignore +12 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +175 -0
  7. data/.rubocop_todo.yml +53 -0
  8. data/CHANGELOG.md +227 -0
  9. data/Gemfile +10 -0
  10. data/Gemfile.lock +258 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +1206 -0
  13. data/Rakefile +10 -0
  14. data/assets/logo.svg +51 -0
  15. data/bin/console +11 -0
  16. data/bin/karafka_health_check +14 -0
  17. data/bin/outbox_worker_health_check +12 -0
  18. data/bin/setup +8 -0
  19. data/dionysus-rb.gemspec +64 -0
  20. data/docker-compose.yml +44 -0
  21. data/lib/dionysus/checks/health_check.rb +50 -0
  22. data/lib/dionysus/checks.rb +7 -0
  23. data/lib/dionysus/consumer/batch_events_publisher.rb +33 -0
  24. data/lib/dionysus/consumer/config.rb +97 -0
  25. data/lib/dionysus/consumer/deserializer.rb +231 -0
  26. data/lib/dionysus/consumer/dionysus_event.rb +42 -0
  27. data/lib/dionysus/consumer/karafka_consumer_generator.rb +56 -0
  28. data/lib/dionysus/consumer/params_batch_processor.rb +65 -0
  29. data/lib/dionysus/consumer/params_batch_transformations/remove_duplicates_strategy.rb +54 -0
  30. data/lib/dionysus/consumer/params_batch_transformations.rb +4 -0
  31. data/lib/dionysus/consumer/persistor.rb +157 -0
  32. data/lib/dionysus/consumer/registry.rb +84 -0
  33. data/lib/dionysus/consumer/synced_data/assign_columns_from_synced_data.rb +27 -0
  34. data/lib/dionysus/consumer/synced_data/assign_columns_from_synced_data_job.rb +26 -0
  35. data/lib/dionysus/consumer/synced_data.rb +4 -0
  36. data/lib/dionysus/consumer/synchronizable_model.rb +93 -0
  37. data/lib/dionysus/consumer/workers_group.rb +18 -0
  38. data/lib/dionysus/consumer.rb +36 -0
  39. data/lib/dionysus/monitor.rb +48 -0
  40. data/lib/dionysus/producer/base_responder.rb +46 -0
  41. data/lib/dionysus/producer/config.rb +104 -0
  42. data/lib/dionysus/producer/deleted_record_serializer.rb +17 -0
  43. data/lib/dionysus/producer/genesis/performed.rb +11 -0
  44. data/lib/dionysus/producer/genesis/stream_job.rb +13 -0
  45. data/lib/dionysus/producer/genesis/streamer/base_job.rb +44 -0
  46. data/lib/dionysus/producer/genesis/streamer/standard_job.rb +43 -0
  47. data/lib/dionysus/producer/genesis/streamer.rb +40 -0
  48. data/lib/dionysus/producer/genesis.rb +62 -0
  49. data/lib/dionysus/producer/karafka_responder_generator.rb +133 -0
  50. data/lib/dionysus/producer/key.rb +14 -0
  51. data/lib/dionysus/producer/model_serializer.rb +105 -0
  52. data/lib/dionysus/producer/outbox/active_record_publishable.rb +74 -0
  53. data/lib/dionysus/producer/outbox/datadog_latency_reporter.rb +26 -0
  54. data/lib/dionysus/producer/outbox/datadog_latency_reporter_job.rb +11 -0
  55. data/lib/dionysus/producer/outbox/datadog_latency_reporter_scheduler.rb +47 -0
  56. data/lib/dionysus/producer/outbox/datadog_tracer.rb +32 -0
  57. data/lib/dionysus/producer/outbox/duplicates_filter.rb +26 -0
  58. data/lib/dionysus/producer/outbox/event_name.rb +26 -0
  59. data/lib/dionysus/producer/outbox/health_check.rb +48 -0
  60. data/lib/dionysus/producer/outbox/latency_tracker.rb +43 -0
  61. data/lib/dionysus/producer/outbox/model.rb +117 -0
  62. data/lib/dionysus/producer/outbox/producer.rb +26 -0
  63. data/lib/dionysus/producer/outbox/publishable.rb +106 -0
  64. data/lib/dionysus/producer/outbox/publisher.rb +131 -0
  65. data/lib/dionysus/producer/outbox/records_processor.rb +56 -0
  66. data/lib/dionysus/producer/outbox/runner.rb +120 -0
  67. data/lib/dionysus/producer/outbox/tombstone_publisher.rb +22 -0
  68. data/lib/dionysus/producer/outbox.rb +103 -0
  69. data/lib/dionysus/producer/partition_key.rb +42 -0
  70. data/lib/dionysus/producer/registry/validator.rb +32 -0
  71. data/lib/dionysus/producer/registry.rb +165 -0
  72. data/lib/dionysus/producer/serializer.rb +52 -0
  73. data/lib/dionysus/producer/suppressor.rb +18 -0
  74. data/lib/dionysus/producer.rb +121 -0
  75. data/lib/dionysus/railtie.rb +9 -0
  76. data/lib/dionysus/rb/version.rb +5 -0
  77. data/lib/dionysus/rb.rb +8 -0
  78. data/lib/dionysus/support/rspec/outbox_publishable.rb +78 -0
  79. data/lib/dionysus/topic_name.rb +15 -0
  80. data/lib/dionysus/utils/default_message_filter.rb +25 -0
  81. data/lib/dionysus/utils/exponential_backoff.rb +7 -0
  82. data/lib/dionysus/utils/karafka_datadog_listener.rb +20 -0
  83. data/lib/dionysus/utils/karafka_sentry_listener.rb +9 -0
  84. data/lib/dionysus/utils/null_error_handler.rb +6 -0
  85. data/lib/dionysus/utils/null_event_bus.rb +5 -0
  86. data/lib/dionysus/utils/null_hermes_event_producer.rb +5 -0
  87. data/lib/dionysus/utils/null_instrumenter.rb +7 -0
  88. data/lib/dionysus/utils/null_lock_client.rb +13 -0
  89. data/lib/dionysus/utils/null_model_factory.rb +5 -0
  90. data/lib/dionysus/utils/null_mutex_provider.rb +7 -0
  91. data/lib/dionysus/utils/null_retry_provider.rb +7 -0
  92. data/lib/dionysus/utils/null_tracer.rb +5 -0
  93. data/lib/dionysus/utils/null_transaction_provider.rb +15 -0
  94. data/lib/dionysus/utils/sidekiq_batched_job_distributor.rb +24 -0
  95. data/lib/dionysus/utils.rb +6 -0
  96. data/lib/dionysus/version.rb +7 -0
  97. data/lib/dionysus-rb.rb +3 -0
  98. data/lib/dionysus.rb +133 -0
  99. data/lib/tasks/dionysus.rake +18 -0
  100. data/log/development.log +0 -0
  101. data/sig/dionysus/rb.rbs +6 -0
  102. metadata +585 -0
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dionysus::Producer::Outbox::Model
4
+ extend ActiveSupport::Concern
5
+
6
+ OBSERVER_TOPIC = "__outbox_observer__"
7
+ CHANGESET_COLUMN = "changeset"
8
+ private_constant :OBSERVER_TOPIC, :CHANGESET_COLUMN
9
+
10
+ def self.observer_topic
11
+ OBSERVER_TOPIC
12
+ end
13
+
14
+ included do
15
+ scope :fetch_publishable, lambda { |batch_size, topic|
16
+ outbox_worker_publishing_delay = Dionysus::Producer.configuration.outbox_worker_publishing_delay
17
+
18
+ records = where(published_at: nil, topic: topic)
19
+ .where("retry_at IS NULL OR retry_at <= ?", Time.current)
20
+ .order(created_at: :asc)
21
+ .limit(batch_size)
22
+ if outbox_worker_publishing_delay > 0.seconds
23
+ records = records.where("created_at <= ?", Time.current + outbox_worker_publishing_delay)
24
+ end
25
+ records
26
+ }
27
+ scope :published_since, ->(time) { where("published_at >= ?", time) }
28
+ scope :not_published, -> { where(published_at: nil) }
29
+
30
+ belongs_to :resource, polymorphic: true, foreign_type: :resource_class, optional: true
31
+
32
+ def self.pending_topics
33
+ not_published.select("DISTINCT topic").map(&:topic)
34
+ end
35
+
36
+ def self.handles_changeset?
37
+ column_names.include?(CHANGESET_COLUMN)
38
+ end
39
+
40
+ def self.encrypts_changeset!
41
+ define_method :changeset= do |payload|
42
+ super(payload.to_json)
43
+ end
44
+ end
45
+ end
46
+
47
+ def observer?
48
+ topic == Dionysus::Producer::Outbox::Model.observer_topic
49
+ end
50
+
51
+ def transformed_changeset
52
+ return {} unless self.class.handles_changeset?
53
+
54
+ if changeset.respond_to?(:to_hash)
55
+ changeset.symbolize_keys
56
+ else
57
+ JSON.parse(changeset).symbolize_keys
58
+ end
59
+ end
60
+
61
+ def published?
62
+ published_at.present?
63
+ end
64
+
65
+ def failed?
66
+ failed_at.present?
67
+ end
68
+
69
+ def handle_error(raised_error, clock: Time)
70
+ @error = raised_error
71
+ self.error_class = raised_error.class
72
+ self.error_message = raised_error.message
73
+ self.failed_at = clock.current
74
+ self.attempts ||= 0
75
+ self.attempts += 1
76
+ self.retry_at = clock.current.advance(seconds: Dionysus::Utils::ExponentialBackoff.backoff_for(5,
77
+ attempts))
78
+ end
79
+
80
+ def error
81
+ if error_class_arity == 1 || error_class_arity == -1
82
+ error_class.constantize.new(error_message)
83
+ else
84
+ StandardError.new("#{error_class_constant}: #{error_message}")
85
+ end
86
+ end
87
+
88
+ def resource_created_at
89
+ return created_at if resource.nil?
90
+
91
+ resource.created_at
92
+ end
93
+
94
+ def publishing_latency
95
+ return unless published?
96
+
97
+ published_at - created_at
98
+ end
99
+
100
+ def created_event?
101
+ event_name.to_s.end_with?("created")
102
+ end
103
+
104
+ def updated_event?
105
+ event_name.to_s.end_with?("updated")
106
+ end
107
+
108
+ private
109
+
110
+ def error_class_constant
111
+ error_class.constantize
112
+ end
113
+
114
+ def error_class_arity
115
+ error_class_constant.instance_method(:initialize).arity
116
+ end
117
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::Outbox::Producer
4
+ attr_reader :config
5
+ private :config
6
+
7
+ def initialize(config: Dionysus::Producer.configuration)
8
+ @config = config
9
+ end
10
+
11
+ def call(topic, batch_size: config.outbox_publishing_batch_size)
12
+ outbox_model.fetch_publishable(batch_size, topic).to_a.tap do |records_to_publish|
13
+ records_processor.call(records_to_publish) do |record|
14
+ yield record if block_given?
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ delegate :outbox_model, to: :config
22
+
23
+ def records_processor
24
+ @records_processor ||= Dionysus::Producer::Outbox::RecordsProcessor.new
25
+ end
26
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::Outbox::Publishable < SimpleDelegator
4
+ attr_reader :model, :config, :soft_delete_column
5
+ private :config, :soft_delete_column
6
+
7
+ def initialize(model, config: Dionysus::Producer.configuration)
8
+ @model = model
9
+ @config = config
10
+ @soft_delete_column = config.soft_delete_column.to_s
11
+ super(model)
12
+ end
13
+
14
+ def model_class
15
+ model.class
16
+ end
17
+
18
+ def primary_key_attribute
19
+ model.class.primary_key
20
+ end
21
+
22
+ def publishable_id
23
+ model.public_send(primary_key_attribute)
24
+ end
25
+
26
+ def model_name
27
+ model.class.model_name
28
+ end
29
+
30
+ def resource_name
31
+ model_name.singular
32
+ end
33
+
34
+ def previously_changed?
35
+ previous_changes.present?
36
+ end
37
+
38
+ def previous_changes_include_canceled?
39
+ !!previous_changes[soft_delete_column]
40
+ end
41
+
42
+ def previous_changes_uncanceled?
43
+ previous_changes_include_canceled? && previous_changes[soft_delete_column][0].present? && visible?
44
+ end
45
+
46
+ def previous_changes_canceled?
47
+ previous_changes_include_canceled? && previous_changes[soft_delete_column][0].blank? && soft_deleted?
48
+ end
49
+
50
+ def previous_changes_still_canceled?
51
+ previous_changes_include_canceled? && previous_changes[soft_delete_column][0].present? && soft_deleted?
52
+ end
53
+
54
+ # TODO: Check if this is needed
55
+ def previous_changed_still_visible?
56
+ previous_changes_include_canceled? && previous_changes[soft_delete_column][0].blank? && visible?
57
+ end
58
+
59
+ def soft_deleted?
60
+ public_send(soft_delete_column).present?
61
+ end
62
+
63
+ def soft_deletable?
64
+ model.respond_to?(soft_delete_column)
65
+ end
66
+
67
+ def visible?
68
+ !soft_deleted?
69
+ end
70
+
71
+ def topics
72
+ top_level_topics = Dionysus::Producer
73
+ .responders_for(model.class)
74
+ .map(&:primary_topic)
75
+ topics_from_dependencies = Dionysus::Producer
76
+ .responders_for_dependency_parent(model.class)
77
+ .map(&:last)
78
+ .map(&:primary_topic)
79
+
80
+ [top_level_topics, topics_from_dependencies]
81
+ .flatten
82
+ .uniq
83
+ .tap { |standard_topics| standard_topics << observer_topic if add_observer_topic? }
84
+ end
85
+
86
+ def changeset
87
+ if model.destroyed?
88
+ {
89
+ model.class.primary_key => [model.public_send(model.class.primary_key), nil],
90
+ "created_at" => [model.created_at, nil]
91
+ }
92
+ else
93
+ model.previous_changes
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ delegate :observer_topic, to: Dionysus::Producer::Outbox::Model
100
+ delegate :observers_with_responders_for, to: Dionysus::Producer
101
+ delegate :outbox_model, to: :config
102
+
103
+ def add_observer_topic?
104
+ outbox_model.handles_changeset? && observers_with_responders_for(self, changeset).any?
105
+ end
106
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::Outbox::Publisher
4
+ attr_reader :config
5
+ private :config
6
+
7
+ def initialize(config:)
8
+ @config = config
9
+ end
10
+
11
+ def publish(outbox_record, options = {})
12
+ return if Dionysus::Producer::Suppressor.suppressed?
13
+
14
+ instrument("publishing_with_dionysus") do
15
+ resource_class = outbox_record.resource_class.constantize
16
+ primary_key = resource_class.primary_key
17
+ primary_key_value = outbox_record.resource_id
18
+ topic = outbox_record.topic
19
+ resource = resource_class.find_by(primary_key => primary_key_value) ||
20
+ resource_class.new(primary_key => primary_key_value)
21
+ event_name = outbox_record.event_name
22
+ if resource.new_record? && outbox_record.created_event?
23
+ logger.error(
24
+ "Attempted to publish #{resource.class}, id: #{resource.id} but it was deleted, that should never happen!"
25
+ )
26
+ return
27
+ end
28
+
29
+ if resource.new_record? && outbox_record.updated_event?
30
+ logger.error(
31
+ "There was an update of #{resource.class}, id: #{resource.id} but it was deleted, that should never happen!"
32
+ )
33
+ return
34
+ end
35
+
36
+ publish_for_top_level_resource(outbox_record, resource, event_name, topic, options)
37
+ publish_for_dependency(resource, topic, options)
38
+ end
39
+ end
40
+
41
+ def publish_observers(outbox_record)
42
+ return if Dionysus::Producer::Suppressor.suppressed?
43
+
44
+ instrument("publishing_observers_with_dionysus") do
45
+ resource_class = outbox_record.resource_class.constantize
46
+ primary_key = resource_class.primary_key
47
+ primary_key_value = outbox_record.resource_id
48
+ resource = resource_class.find_by(primary_key => primary_key_value) ||
49
+ resource_class.new(primary_key => primary_key_value)
50
+ changeset = outbox_record.transformed_changeset
51
+
52
+ Dionysus::Producer.observers_with_responders_for(resource,
53
+ changeset).each do |observers, responder|
54
+ if observers.count > config.observers_inline_maximum_size
55
+ execute_genesis_for_observers(observers, responder)
56
+ else
57
+ observers.each { |observer_record| publish_observer(observer_record, responder) }
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ delegate :instrumenter, :error_handler, to: :config
66
+ delegate :instrument, to: :instrumenter
67
+ delegate :logger, to: Dionysus
68
+
69
+ def publish_for_top_level_resource(outbox_record, resource, event_name, topic, options)
70
+ Dionysus::Producer.responders_for_model_for_topic(resource.class, topic).each do |responder|
71
+ partition_key = outbox_record.partition_key.presence || Dionysus::Producer::PartitionKey.new(
72
+ resource
73
+ ).to_key(responder: responder)
74
+ key = Dionysus::Producer::Key.new(resource).to_key
75
+
76
+ responder.call(generate_message(event_name, resource), options.merge(partition_key: partition_key, key: key))
77
+ end
78
+ end
79
+
80
+ def publish_for_dependency(resource, topic, options)
81
+ Dionysus::Producer.responders_for_dependency_parent_for_topic(resource.class,
82
+ topic).each do |parent_klass, responder|
83
+ parent_event_name = Dionysus::Producer::Outbox::EventName.new(
84
+ parent_klass.model_name.singular
85
+ ).updated
86
+
87
+ if resource.class.reflect_on_association(parent_klass.model_name.singular)
88
+ parent_records = [resource.public_send(parent_klass.model_name.singular)]
89
+ elsif resource.class.reflect_on_association(parent_klass.model_name.plural)
90
+ parent_records = resource.public_send(parent_klass.model_name.plural)
91
+ else
92
+ next
93
+ end
94
+
95
+ example_parent_record = parent_records.first or next
96
+ partition_key = Dionysus::Producer::PartitionKey.new(example_parent_record, config: config)
97
+ .to_key(responder: responder)
98
+ key = Dionysus::Producer::Key.new(example_parent_record).to_key
99
+
100
+ parent_records.each do |parent_record|
101
+ responder.call(generate_message(parent_event_name, parent_record),
102
+ options.merge(partition_key: partition_key, key: key))
103
+ end
104
+ end
105
+ end
106
+
107
+ def publish_observer(observer_record, responder)
108
+ publishable = Dionysus::Producer::Outbox::Publishable.new(observer_record)
109
+ partition_key = Dionysus::Producer::PartitionKey.new(publishable).to_key(responder: responder)
110
+ key = Dionysus::Producer::Key.new(publishable).to_key
111
+ event_name = Dionysus::Producer::Outbox::EventName.new(publishable.resource_name).updated
112
+
113
+ responder.call(generate_message(event_name, publishable), partition_key: partition_key, key: key)
114
+ end
115
+
116
+ def generate_message(event_name, resource)
117
+ [[event_name, resource, {}]]
118
+ end
119
+
120
+ def execute_genesis_for_observers(observers, responder)
121
+ resource_class = observers.first.class
122
+ primary_key = resource_class.primary_key
123
+
124
+ Dionysus::Producer::Genesis::Streamer::StandardJob.enqueue(
125
+ resource_class.where(primary_key => observers),
126
+ resource_class,
127
+ responder.primary_topic,
128
+ number_of_days: 0.1
129
+ )
130
+ end
131
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::Outbox::RecordsProcessor
4
+ attr_reader :config
5
+ private :config
6
+
7
+ def initialize(config: Dionysus::Producer.configuration)
8
+ @config = config
9
+ end
10
+
11
+ def call(records)
12
+ failed_records = []
13
+ records_to_publish = resolve_records_to_publish(records)
14
+ records_to_publish.each do |record|
15
+ begin
16
+ publish(record)
17
+ rescue => e
18
+ record.handle_error(e)
19
+ record.save!
20
+ failed_records << record
21
+ end
22
+ yield record if block_given?
23
+ end
24
+ published_records = records - failed_records
25
+ mark_as_published(published_records)
26
+ records
27
+ end
28
+
29
+ private
30
+
31
+ delegate :outbox_model, to: :config
32
+
33
+ def resolve_records_to_publish(records)
34
+ return records unless config.remove_consecutive_duplicates_before_publishing
35
+
36
+ Dionysus::Producer::Outbox::DuplicatesFilter.call(records)
37
+ end
38
+
39
+ def publish(record)
40
+ if record.observer?
41
+ outbox_publisher.publish_observers(record)
42
+ else
43
+ outbox_publisher.publish(record)
44
+ end
45
+ end
46
+
47
+ def outbox_publisher
48
+ @outbox_publisher ||= Dionysus::Producer.outbox_publisher
49
+ end
50
+
51
+ def mark_as_published(published_records)
52
+ outbox_model
53
+ .where(id: published_records)
54
+ .update_all(published_at: Time.current, error_class: nil, error_message: nil, failed_at: nil, retry_at: nil)
55
+ end
56
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::Outbox::Runner
4
+ attr_reader :logger, :id, :config
5
+ private :logger, :config
6
+
7
+ def initialize(config: Dionysus::Producer.configuration,
8
+ logger: Dionysus.logger)
9
+ @id = SecureRandom.uuid
10
+ @logger = logger
11
+ logger.push_tags("Dionysus::Producer::Outbox::Runners #{id}") if logger.respond_to?(:push_tags)
12
+ @config = config
13
+ end
14
+
15
+ def start
16
+ log("started")
17
+ instrument("outbox_producer.started")
18
+ @should_stop = false
19
+ ensure_database_connection!
20
+ loop do
21
+ if @should_stop
22
+ instrument("outbox_producer.shutting_down")
23
+ log("shutting down")
24
+ break
25
+ end
26
+ process_topics
27
+ instrument("outbox_producer.heartbeat")
28
+ sleep outbox_worker_sleep_seconds
29
+ end
30
+ rescue => e
31
+ error_handler.capture_exception(e)
32
+ log("error: #{e} #{e.message}")
33
+ instrument("outbox_producer.error", error: e, error_message: e.message)
34
+ raise e
35
+ end
36
+
37
+ def stop
38
+ log("Outbox worker stopping")
39
+ instrument("outbox_producer.stopped")
40
+ @should_stop = true
41
+ end
42
+
43
+ private
44
+
45
+ delegate :outbox_worker_sleep_seconds, :database_connection_provider, :outbox_model,
46
+ :lock_client, :lock_expiry_time, :error_handler,
47
+ :outbox_publishing_batch_size, to: :config
48
+
49
+ delegate :pending_topics, to: :outbox_model
50
+ delegate :lock, to: :lock_client
51
+
52
+ def process_topics
53
+ pending_topics.each do |topic|
54
+ instrument("outbox_producer.processing_topic", topic: topic) do
55
+ tracer.trace("outbox_producer", topic) do
56
+ lock(lock_name(topic), lock_expiry_time) do |locked|
57
+ if locked
58
+ producer.call(topic, batch_size: outbox_publishing_batch_size) do |record|
59
+ if record.failed?
60
+ instrument("outbox_producer.publishing_failed", outbox_record: record)
61
+ error("failed to publish #{record.inspect}")
62
+ error_handler.capture_exception(record.error)
63
+ else
64
+ debug("published #{record.inspect}")
65
+ instrument("outbox_producer.published", outbox_record: record)
66
+ end
67
+ end
68
+ instrument("outbox_producer.processed_topic", topic: topic)
69
+ else
70
+ debug("lock exists for #{topic} topic")
71
+ instrument("outbox_producer.lock_exists_for_topic", topic: topic)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ def ensure_database_connection!
80
+ database_connection_provider.connection.reconnect!
81
+ end
82
+
83
+ def log(message)
84
+ logger.info("#{log_prefix} #{message}")
85
+ end
86
+
87
+ def debug(message)
88
+ logger.debug("#{log_prefix} #{message}")
89
+ end
90
+
91
+ def error(message)
92
+ logger.error("#{log_prefix} #{message}")
93
+ end
94
+
95
+ def lock_name(topic)
96
+ "dionysus_#{topic}_lock"
97
+ end
98
+
99
+ def log_prefix
100
+ "[Dionysus] Outbox worker"
101
+ end
102
+
103
+ def instrument(*args, **kwargs)
104
+ Dionysus.monitor.instrument(*args, **kwargs) do
105
+ yield if block_given?
106
+ end
107
+ end
108
+
109
+ def tracer
110
+ @tracer ||= if Object.const_defined?(:Datadog)
111
+ Dionysus::Producer::Outbox::DatadogTracer.new
112
+ else
113
+ Dionysus::Utils::NullTracer
114
+ end
115
+ end
116
+
117
+ def producer
118
+ @producer ||= Dionysus::Producer::Outbox::Producer.new
119
+ end
120
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::Outbox::TombstonePublisher
4
+ TOMBSTONE = nil
5
+ private_constant :TOMBSTONE
6
+
7
+ attr_reader :config
8
+ private :config
9
+
10
+ def initialize(config: Dionysus::Producer.configuration)
11
+ @config = config
12
+ end
13
+
14
+ def tombstone(resource, responder, options = {})
15
+ partition_key = options.fetch(:partition_key) do
16
+ Dionysus::Producer::PartitionKey.new(resource, config: config).to_key(responder: responder)
17
+ end
18
+ key = options.fetch(:key) { Dionysus::Producer::Key.new(resource).to_key }
19
+
20
+ responder.call(TOMBSTONE, partition_key: partition_key, key: key)
21
+ end
22
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::Outbox
4
+ attr_reader :outbox_model, :config
5
+ private :outbox_model, :config
6
+
7
+ def initialize(outbox_model, config:)
8
+ @outbox_model = outbox_model
9
+ @config = config
10
+ end
11
+
12
+ def insert_created(record)
13
+ insert(Publishable.new(record), :created)
14
+ end
15
+
16
+ def insert_updated(record)
17
+ publishable = Publishable.new(record)
18
+ return [] unless publishable.previously_changed?
19
+
20
+ if publishable.respond_to?(config.soft_delete_column)
21
+ event_type = event_type_for_update_of_soft_deletable_record(publishable) or return []
22
+ insert(publishable, event_type)
23
+ else
24
+ insert(publishable, :updated)
25
+ end
26
+ end
27
+
28
+ def insert_destroyed(record)
29
+ publishable = Publishable.new(record)
30
+
31
+ insert(publishable, :destroyed)
32
+ end
33
+
34
+ private
35
+
36
+ delegate :transaction_provider, :transactional_outbox_enabled, to: :config
37
+
38
+ def insert(publishable, event_type)
39
+ return [] unless transactional_outbox_enabled
40
+
41
+ transaction_provider.transaction do
42
+ publishable.topics.map do |topic|
43
+ attributes = {
44
+ resource: publishable.model,
45
+ event_name: event_name_for(publishable, event_type),
46
+ partition_key: partition_key_for_publishable_for_topic(publishable, topic),
47
+ topic: topic
48
+ }
49
+ attributes[:changeset] = publishable.changeset if observer_topic?(topic)
50
+
51
+ outbox_model.create!(attributes)
52
+ end
53
+ end
54
+ end
55
+
56
+ def partition_key_for_publishable_for_topic(publishable, topic)
57
+ responder = Dionysus::Producer
58
+ .responders_for_model_for_topic(publishable.model_class, topic)
59
+ .first or return
60
+
61
+ Dionysus::Producer::PartitionKey.new(publishable).to_key(responder: responder)
62
+ end
63
+
64
+ def observer_topic?(topic)
65
+ topic == Dionysus::Producer::Outbox::Model.observer_topic
66
+ end
67
+
68
+ def event_name_for(publishable, event_type)
69
+ Dionysus::Producer::Outbox::EventName
70
+ .new(publishable.resource_name)
71
+ .for_event_type(event_type)
72
+ end
73
+
74
+ def event_type_for_update_of_soft_deletable_record(publishable)
75
+ if publishable.previous_changes_include_canceled?
76
+ event_type_for_update_of_soft_deletable_record_for_soft_delete_state_change(publishable)
77
+ else
78
+ event_type_for_update_of_soft_deletable_record_for_standard_state_change(publishable)
79
+ end
80
+ end
81
+
82
+ def event_type_for_update_of_soft_deletable_record_for_soft_delete_state_change(publishable)
83
+ if publishable.previous_changes_uncanceled?
84
+ :created
85
+ elsif publishable.previous_changes_canceled?
86
+ :destroyed
87
+ elsif publishable.previous_changes_still_canceled? || publishable.previous_changed_still_visible?
88
+ nil
89
+ else
90
+ raise "that should never happen"
91
+ end
92
+ end
93
+
94
+ def event_type_for_update_of_soft_deletable_record_for_standard_state_change(publishable)
95
+ if publishable.visible? || (publishable.soft_deleted? && publishable.dionysus_publish_updates_after_soft_delete?)
96
+ :updated
97
+ elsif publishable.soft_deleted?
98
+ nil
99
+ else
100
+ raise "that should never happen"
101
+ end
102
+ end
103
+ end