rails-transactional-outbox 0.1.0

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