dionysus-rb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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