rails-transactional-outbox 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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +13 -0
  3. data/.github/workflows/ci.yml +49 -0
  4. data/.gitignore +13 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +150 -0
  7. data/CHANGELOG.md +5 -0
  8. data/Gemfile +19 -0
  9. data/Gemfile.lock +142 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +285 -0
  12. data/Rakefile +10 -0
  13. data/bin/console +15 -0
  14. data/bin/rails_transactional_outbox_health_check +13 -0
  15. data/bin/setup +8 -0
  16. data/lib/rails-transactional-outbox.rb +3 -0
  17. data/lib/rails_transactional_outbox/configuration.rb +33 -0
  18. data/lib/rails_transactional_outbox/error_handlers/null_error_handler.rb +9 -0
  19. data/lib/rails_transactional_outbox/error_handlers.rb +6 -0
  20. data/lib/rails_transactional_outbox/event_type.rb +37 -0
  21. data/lib/rails_transactional_outbox/exponential_backoff.rb +9 -0
  22. data/lib/rails_transactional_outbox/health_check.rb +48 -0
  23. data/lib/rails_transactional_outbox/monitor.rb +47 -0
  24. data/lib/rails_transactional_outbox/outbox_entries_processor.rb +56 -0
  25. data/lib/rails_transactional_outbox/outbox_entry_factory.rb +32 -0
  26. data/lib/rails_transactional_outbox/outbox_model.rb +78 -0
  27. data/lib/rails_transactional_outbox/railtie.rb +11 -0
  28. data/lib/rails_transactional_outbox/record_processor.rb +35 -0
  29. data/lib/rails_transactional_outbox/record_processors/active_record_processor.rb +39 -0
  30. data/lib/rails_transactional_outbox/record_processors/base_processor.rb +15 -0
  31. data/lib/rails_transactional_outbox/record_processors.rb +6 -0
  32. data/lib/rails_transactional_outbox/reliable_model/reliable_callback.rb +41 -0
  33. data/lib/rails_transactional_outbox/reliable_model/reliable_callbacks_registry.rb +26 -0
  34. data/lib/rails_transactional_outbox/reliable_model.rb +81 -0
  35. data/lib/rails_transactional_outbox/runner.rb +106 -0
  36. data/lib/rails_transactional_outbox/runner_sleep_interval.rb +14 -0
  37. data/lib/rails_transactional_outbox/tracers/datadog_tracer.rb +35 -0
  38. data/lib/rails_transactional_outbox/tracers/null_tracer.rb +9 -0
  39. data/lib/rails_transactional_outbox/tracers.rb +7 -0
  40. data/lib/rails_transactional_outbox/version.rb +7 -0
  41. data/lib/rails_transactional_outbox.rb +54 -0
  42. data/lib/tasks/rails_transactional_outbox.rake +11 -0
  43. data/rails-transactional-outbox.gemspec +44 -0
  44. metadata +188 -0
data/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # RailsTransactionalOutbox
2
+
3
+ An implementation of transactional outbox pattern to be used with Rails.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'rails-transactional-outbox'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install rails-transactional-outbox
20
+
21
+ ## Usage
22
+
23
+ To get familiar with the pattern:
24
+ - [Pattern: Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html)
25
+ - [Transactional Outbox: What is it and why you need it?](https://morningcoffee.io/what-is-a-transaction-outbox-and-why-you-need-it.html)
26
+
27
+ Create the initializer with the following content:
28
+
29
+ ``` rb
30
+ Rails.application.config.to_prepare do
31
+ RailsTransactionalOutbox.configure do |config|
32
+ config.database_connection_provider = ActiveRecord::Base # required
33
+ config.transaction_provider = ActiveRecord::Base # required
34
+ config.logger = Rails.logger # required
35
+ config.outbox_model = OutboxEntry # required
36
+ config.error_handler = Sentry # non-required, but highly recommended, defaults to RailsTransactionalOutbox::ErrorHandlers::NullErrorHandler
37
+
38
+ config.transactional_outbox_worker_sleep_seconds = 1 # optional, defaults to 0.5
39
+ config.transactional_outbox_worker_idle_delay_multiplier = 5 # optional, defaults to 1, if there are no outbox entries to be processed, then the sleep time for the thread will be equal to transactional_outbox_worker_idle_delay_multiplier * transactional_outbox_worker_sleep_seconds
40
+ config.outbox_batch_size = 100 # optional, defaults to 100
41
+ config.add_record_processor(MyCustomOperationProcerssor) # optional, by default it contains only one processor for ActiveRecord, but you could add more
42
+ end
43
+ end
44
+ ```
45
+
46
+ Create OutboxEntry model (or use a different name, just make sure to adjust config/migration) with the following content:
47
+
48
+ ``` rb
49
+ class OutboxEntry < ApplicationRecord
50
+ include RailsTransactionalOutbox::OutboxModel
51
+
52
+ # optional, if you want to use encryption
53
+ crypt_keeper :changeset, :arguments, encryptor: :postgres_pgp, key: ENV.fetch("CRYPT_KEEPER_KEY"), encoding: "UTF-8"
54
+ outbox_encrypt_json_for :changeset, :arguments
55
+ end
56
+ ```
57
+
58
+ And use the following migration:
59
+
60
+ ``` rb
61
+ create_table(:outbox_entries) do |t|
62
+ t.string "resource_class"
63
+ t.string "resource_id"
64
+ t.string "event_name", null: false
65
+ t.string "context", null: false
66
+ t.datetime "processed_at"
67
+ t.text "arguments", null: false, default: "{}"
68
+ t.text "changeset", null: false, default: "{}"
69
+ t.datetime "failed_at"
70
+ t.datetime "retry_at"
71
+ t.string "error_class"
72
+ t.string "error_message"
73
+ t.integer "attempts", null: false, default: 0
74
+ t.datetime "created_at", precision: 6, null: false
75
+ t.datetime "updated_at", precision: 6, null: false
76
+
77
+ t.index %w[resource_class event_name], name: "idx_outbox_enc_entries_on_resource_class_and_event"
78
+ t.index %w[resource_class resource_id], name: "idx_outbox_enc_entries_on_resource_class_and_resource_id"
79
+ t.index ["context"], name: "idx_outbox_enc_entries_on_topic"
80
+ t.index ["created_at"], name: "idx_outbox_enc_entries_on_created_at"
81
+ t.index ["created_at"], name: "idx_outbox_enc_entries_on_created_at_not_processed", where: "processed_at IS NULL"
82
+ t.index %w[resource_class created_at], name: "idx_outbox_enc_entries_on_resource_class_and_created_at"
83
+ t.index %w[resource_class processed_at], name: "idx_outbox_enc_entries_on_resource_class_and_processed_at"
84
+ end
85
+ ```
86
+
87
+ Keep in mind that `arguments` and `changeset` are `text` columns here. If you don't want to use encryption, replace them with `jsonb` columns:
88
+
89
+ ```rb
90
+ t.jsonb "arguments", null: false, default: {}
91
+ t.jsonb "changeset", null: false, default: {}
92
+ ```
93
+
94
+ The following columns: `resource_class`, `resource_id` and `changeset` are dedicated to ActiveRecord integration. Do not try to modify these columns for custom processors.
95
+
96
+ As the last step, include `RailsTransactionalOutbox::ReliableModel` module in the models that are supposed to have reliable `after_commit` callbacks, for example:
97
+
98
+ ``` ruby
99
+ class User < ActiveRecord::Base
100
+ include RailsTransactionalOutbox::ReliableModel
101
+ end
102
+ ```
103
+
104
+ Now, you can just replace `after_commit` callbacks with `reliable_after_commit`. The interface is going to be the same as for `after_commit`:
105
+
106
+ - you can provide `:on` option to specific when the callback should be executed
107
+ - you can use both blocks or symbols as the method names
108
+ - you can pass `:if` and `:unless` options
109
+ - you can also use `reliable_after_create_commit`, `reliable_after_update_commit`, `reliable_after_destroy_commit`, `reliable_after_save_commit`.
110
+
111
+ When executing the callbacks, you can use `previous_changes` which will contain the changes that are persisted as changesets. One potential gotcha is that Time/Date types are stored as strings, so oyu might need to handle some conversion to be on the safe side.
112
+
113
+ Inclusion of this module will result in OutboxEntry records being created after create/update/destroy. For these entries, the `context` column will be populated with `active_record` value.
114
+
115
+ ### Custom processors
116
+
117
+ If you want to add some custom processor either for ActiveRecord or for custom service objects, create an object inheriting from `RailsTransactionalOutbox::RecordProcessors::BaseProcessor`, which has the following interface:
118
+
119
+
120
+ ``` ruby
121
+ class RailsTransactionalOutbox
122
+ class RecordProcessors
123
+ class BaseProcessor
124
+ def applies?(_record)
125
+ raise "implement me"
126
+ end
127
+
128
+ def call(_record)
129
+ raise "implement me"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ ```
135
+
136
+ For a reference, this is an example of `ActiveRecordProcessor`:
137
+
138
+ ``` ruby
139
+ class RailsTransactionalOutbox
140
+ class RecordProcessors
141
+ class ActiveRecordProcessor < RailsTransactionalOutbox::RecordProcessors::BaseProcessor
142
+ ACTIVE_RECORD_CONTEXT = "active_record"
143
+ private_constant :ACTIVE_RECORD_CONTEXT
144
+
145
+ def self.context
146
+ ACTIVE_RECORD_CONTEXT
147
+ end
148
+
149
+ def applies?(record)
150
+ record.context == ACTIVE_RECORD_CONTEXT
151
+ end
152
+
153
+ def call(record)
154
+ model = record.infer_model or raise CouldNotFindModelError.new(record)
155
+ model.previous_changes = record.transformed_changeset.with_indifferent_access
156
+ model.reliable_after_commit_callbacks.for_event_type(record.event_type).each do |callback|
157
+ callback.call(model)
158
+ end
159
+ end
160
+
161
+ class CouldNotFindModelError < StandardError
162
+ attr_reader :record
163
+
164
+ def initialize(record)
165
+ super()
166
+ @record = record
167
+ end
168
+
169
+ def to_s
170
+ "could not find model for outbox record: #{record.id}"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ ```
177
+
178
+ If you want to extent the behavior of `ActiveRecordProcessor`, you could actually create a new processor that handles exactly the same context as multiple processors can be used for the same context.
179
+
180
+ When adding a custom processor for service objects/operations, you might want to use `arguments` column, to keep all the arguments there.
181
+
182
+ If you use encryption and you want to deal with properly deserialized hash, you `transformed_changeset` and `transformed_arguments` methods (like `ActiveRecordProcessor` does.)
183
+
184
+ When dealing with custom service objects, remember to create OutboxEntry records inside the same transaction:
185
+
186
+ ``` rb
187
+ class MyServiceObject
188
+ def call(user_id)
189
+ transaction do
190
+ execute_some_logic
191
+ OutboxEntry.create!(context: "service_object", event_name: "my_service_object_called", arguments: { user_id: user_id })
192
+ end
193
+ end
194
+ end
195
+ ```
196
+
197
+ ### Running outbox worker
198
+
199
+ Use the following Rake task:
200
+
201
+ ```
202
+ RAILS_TRANSACTIONAL_OUTBOX_THREADS_NUMBER=5 DB_POOL=10 bundle exec rake rails_transactional_outbox:worker
203
+ ```
204
+
205
+ If you want to use just a single thread:
206
+
207
+ ```
208
+ bundle exec rake bookingsync_prometheus:producer
209
+ ```
210
+
211
+ ### Archiving old outbox records
212
+
213
+ You will probably want to periodically archive/delete processed outbox records. It's recommended to use [tartarus-rb](https://github.com/BookingSync/tartarus-rb) for that.
214
+
215
+ Here is an example config:
216
+
217
+ ```
218
+ tartarus.register do |item|
219
+ item.model = OutboxEntry
220
+ item.cron = "5 4 * * *"
221
+ item.queue = "default"
222
+ item.archive_items_older_than = -> { 3.days.ago }
223
+ item.timestamp_field = :processed_at
224
+ item.archive_with = :delete_all_using_limit_in_batches
225
+ end
226
+ ```
227
+
228
+ ### Health Checks
229
+
230
+ First, you need to set `REDIS_URL` ENV variable to provide the URL for Redis.
231
+
232
+ Then, you need to explicitly enable the health check (e.g. in the initializer):
233
+
234
+ ``` rb
235
+ RailsTransactionalOutbox.enable_outbox_worker_healthcheck
236
+ ```
237
+
238
+ To perform the actual health check, use `bin/rails_transactional_outbox_health_check`. On success, the script exits with `0` status and on failure, it logs the error and exits with `1` status.
239
+
240
+ ```
241
+ bundle exec rails_transactional_outbox_health_check
242
+ ```
243
+
244
+ The logic is based on checking a special value in Redis that is set (and unset) for a given container when Outbox workers are initialized/stopped/processing messages.
245
+
246
+ It works for both readiness and liveness checks.
247
+
248
+ #### Events, hooks and monitors
249
+
250
+ You can subscribe to certain events that are published by `RailsTransactionalOutbox.monitor`. The monitor is based on [`dry-monitor`](https://github.com/dry-rb/dry-monitor).
251
+
252
+ Available events and arguments are:
253
+
254
+ - "rails_transactional_outbox.started", no arguments
255
+ - "rails_transactional_outbox.stopped", no arguments
256
+ - "rails_transactional_outbox.shutting_down", no arguments
257
+ - "rails_transactional_outbox.record_processing_failed", arguments: outbox_record
258
+ - "rails_transactional_outbox.record_processed", no arguments: outbox_record
259
+ - "rails_transactional_outbox.error", arguments: error, error_message
260
+ - "rails_transactional_outbox.heartbeat", no arguments
261
+
262
+
263
+ #### Testing the logic from reliable_after_commit callbacks
264
+
265
+ The fastest way to handle it would be to add this to `rails_helper.rb`:
266
+
267
+ ``` ruby
268
+ ApplicationRecord.after_commit do
269
+ RailsTransactionalOutbox::OutboxEntriesProcessor.new.call
270
+ end
271
+ ```
272
+
273
+ ## Development
274
+
275
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
276
+
277
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
278
+
279
+ ## Contributing
280
+
281
+ Bug reports and pull requests are welcome on GitHub at https://github.com/BookingSync/rails-transactional-outbox.
282
+
283
+ ## License
284
+
285
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
9
+
10
+ import("./lib/tasks/rails_transactional_outbox.rake")
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "rails/transactional/outbox"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "logger"
5
+ require_relative "../lib/rails_transactional_outbox"
6
+
7
+ result = RailsTransactionalOutbox::HealthCheck.check
8
+ if result.empty?
9
+ exit 0
10
+ else
11
+ Logger.new($stdout).fatal("[Rails Transactional Outbox Worker] health check failed: #{result}")
12
+ exit 1
13
+ end
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_transactional_outbox"
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class Configuration
5
+ attr_accessor :database_connection_provider, :logger, :outbox_model, :transaction_provider
6
+ attr_writer :error_handler, :transactional_outbox_worker_sleep_seconds,
7
+ :transactional_outbox_worker_idle_delay_multiplier, :outbox_batch_size
8
+
9
+ def error_handler
10
+ @error_handler || RailsTransactionalOutbox::ErrorHandlers::NullErrorHandler
11
+ end
12
+
13
+ def transactional_outbox_worker_sleep_seconds
14
+ @transactional_outbox_worker_sleep_seconds || 0.5
15
+ end
16
+
17
+ def transactional_outbox_worker_idle_delay_multiplier
18
+ @transactional_outbox_worker_idle_delay_multiplier || 10
19
+ end
20
+
21
+ def outbox_batch_size
22
+ @outbox_batch_size || 100
23
+ end
24
+
25
+ def record_processors
26
+ @record_processors ||= [RailsTransactionalOutbox::RecordProcessors::ActiveRecordProcessor.new]
27
+ end
28
+
29
+ def add_record_processor(record_processor)
30
+ record_processors << record_processor
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class ErrorHandlers
5
+ class NullErrorHandler
6
+ def self.capture_exception(_error); end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class ErrorHandlers
5
+ end
6
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class EventType
5
+ EVENT_TYPES = %i[create update destroy].freeze
6
+ EVENT_NAME_SUFFIXES = %w[created updated destroyed].freeze
7
+ private_constant :EVENT_TYPES, :EVENT_NAME_SUFFIXES
8
+
9
+ def self.resolve_from_event_name(event_name)
10
+ EVENT_TYPES.find(-> { raise "unknown event type: #{event_name}" }) do |event_type|
11
+ event_name.to_s.end_with?(new(event_type).event_name_suffix)
12
+ end
13
+ end
14
+
15
+ attr_reader :event_type
16
+ private :event_type
17
+
18
+ def initialize(event_type)
19
+ @event_type = event_type.to_sym
20
+ end
21
+
22
+ def to_sym
23
+ event_type
24
+ end
25
+
26
+ def event_name_suffix
27
+ EVENT_TYPES
28
+ .zip(EVENT_NAME_SUFFIXES)
29
+ .to_h
30
+ .fetch(event_type) { raise "unknown event type: #{event_type}" }
31
+ end
32
+
33
+ def destroy?
34
+ event_type == :destroy
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class ExponentialBackoff
5
+ def self.backoff_for(multiplier, count)
6
+ (multiplier * (2**count))
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ class RailsTransactionalOutbox
6
+ class HealthCheck
7
+ KEY_PREFIX = "__rails_transactional__outbox_worker__running__"
8
+ VALUE = "OK"
9
+ private_constant :KEY_PREFIX, :VALUE
10
+
11
+ def self.check(redis_url: ENV.fetch("REDIS_URL", nil), hostname: ENV.fetch("HOSTNAME", nil),
12
+ expiry_time_in_seconds: 120)
13
+ new(redis_url: redis_url, hostname: hostname, expiry_time_in_seconds: expiry_time_in_seconds).check
14
+ end
15
+
16
+ attr_reader :redis_client, :hostname, :expiry_time_in_seconds
17
+
18
+ def initialize(redis_url: ENV.fetch("REDIS_URL", nil), hostname: ENV.fetch("HOSTNAME", nil),
19
+ expiry_time_in_seconds: 120)
20
+ @redis_client = Redis.new(url: redis_url)
21
+ @hostname = hostname
22
+ @expiry_time_in_seconds = expiry_time_in_seconds
23
+ end
24
+
25
+ def check
26
+ value = redis_client.get(key)
27
+ if value == VALUE
28
+ ""
29
+ else
30
+ "[Rails Transactional Outbox Worker - expected #{VALUE} under #{key}, found: #{value}] "
31
+ end
32
+ end
33
+
34
+ def register_heartbeat
35
+ redis_client.set(key, VALUE, ex: expiry_time_in_seconds)
36
+ end
37
+
38
+ def worker_stopped
39
+ redis_client.del(key)
40
+ end
41
+
42
+ private
43
+
44
+ def key
45
+ "#{KEY_PREFIX}#{hostname}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class Monitor < Dry::Monitor::Notifications
5
+ EVENTS = %w[
6
+ rails_transactional_outbox.started
7
+ rails_transactional_outbox.stopped
8
+ rails_transactional_outbox.shutting_down
9
+ rails_transactional_outbox.record_processing_failed
10
+ rails_transactional_outbox.record_processed
11
+ rails_transactional_outbox.error
12
+ rails_transactional_outbox.heartbeat
13
+ ].freeze
14
+
15
+ private_constant :EVENTS
16
+
17
+ def initialize
18
+ super(:rails_transactional_outbox)
19
+ EVENTS.each { |event| register_event(event) }
20
+ end
21
+
22
+ def subscribe(event)
23
+ return super if events.include?(event.to_s)
24
+
25
+ raise UnknownEventError.new(events, event)
26
+ end
27
+
28
+ def events
29
+ EVENTS
30
+ end
31
+
32
+ class UnknownEventError < StandardError
33
+ attr_reader :available_events, :current_event
34
+ private :available_events, :current_event
35
+
36
+ def initialize(available_events, current_event)
37
+ super()
38
+ @available_events = available_events
39
+ @current_event = current_event
40
+ end
41
+
42
+ def message
43
+ "unknown event: #{current_event}, the available events are: #{available_events.join(", ")}"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class OutboxEntriesProcessor
5
+ attr_reader :config
6
+ private :config
7
+
8
+ def initialize(config: RailsTransactionalOutbox.configuration)
9
+ @config = config
10
+ end
11
+
12
+ def call
13
+ return [] unless outbox_model.any_records_to_process?
14
+
15
+ transaction do
16
+ outbox_model.fetch_processable(outbox_batch_size).to_a.tap do |records_to_process|
17
+ failed_records = Concurrent::Array.new
18
+ # TODO: considering adding internal threads for better efficiency, these are just IO operations
19
+ # that don't require ordering of any kind
20
+ records_to_process.each do |record|
21
+ begin
22
+ process(record)
23
+ rescue => e
24
+ record.handle_error(e)
25
+ record.save!
26
+ failed_records << record
27
+ end
28
+ yield record if block_given?
29
+ end
30
+ processed_records = records_to_process - failed_records
31
+ mark_as_processed(processed_records)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ delegate :outbox_model, :outbox_batch_size, :transaction_provider, to: :config
39
+ delegate :transaction, to: :transaction_provider
40
+
41
+ def mark_as_processed(processed_records)
42
+ outbox_model
43
+ .where(id: processed_records)
44
+ .update_all(processed_at: Time.current, error_class: nil, error_message: nil,
45
+ failed_at: nil, retry_at: nil)
46
+ end
47
+
48
+ def process(record)
49
+ record_processor.call(record)
50
+ end
51
+
52
+ def record_processor
53
+ @record_processor ||= RailsTransactionalOutbox::RecordProcessor.new
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class OutboxEntryFactory
5
+ attr_reader :config
6
+ private :config
7
+
8
+ def initialize(config: RailsTransactionalOutbox.configuration)
9
+ @config = config
10
+ end
11
+
12
+ def build(model, event_type)
13
+ config.outbox_model.new(attributes_for_entry(model, event_type))
14
+ end
15
+
16
+ private
17
+
18
+ def attributes_for_entry(model, event_type)
19
+ {
20
+ resource_class: model.class.to_s,
21
+ resource_id: model.id,
22
+ changeset: model.saved_changes,
23
+ event_name: "#{model.model_name.singular}_#{event_name_suffix(event_type)}",
24
+ context: RailsTransactionalOutbox::RecordProcessors::ActiveRecordProcessor.context
25
+ }
26
+ end
27
+
28
+ def event_name_suffix(event_type)
29
+ RailsTransactionalOutbox::EventType.new(event_type).event_name_suffix
30
+ end
31
+ end
32
+ end