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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +13 -0
- data/.github/workflows/ci.yml +49 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.rubocop.yml +150 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +142 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/Rakefile +10 -0
- data/bin/console +15 -0
- data/bin/rails_transactional_outbox_health_check +13 -0
- data/bin/setup +8 -0
- data/lib/rails-transactional-outbox.rb +3 -0
- data/lib/rails_transactional_outbox/configuration.rb +33 -0
- data/lib/rails_transactional_outbox/error_handlers/null_error_handler.rb +9 -0
- data/lib/rails_transactional_outbox/error_handlers.rb +6 -0
- data/lib/rails_transactional_outbox/event_type.rb +37 -0
- data/lib/rails_transactional_outbox/exponential_backoff.rb +9 -0
- data/lib/rails_transactional_outbox/health_check.rb +48 -0
- data/lib/rails_transactional_outbox/monitor.rb +47 -0
- data/lib/rails_transactional_outbox/outbox_entries_processor.rb +56 -0
- data/lib/rails_transactional_outbox/outbox_entry_factory.rb +32 -0
- data/lib/rails_transactional_outbox/outbox_model.rb +78 -0
- data/lib/rails_transactional_outbox/railtie.rb +11 -0
- data/lib/rails_transactional_outbox/record_processor.rb +35 -0
- data/lib/rails_transactional_outbox/record_processors/active_record_processor.rb +39 -0
- data/lib/rails_transactional_outbox/record_processors/base_processor.rb +15 -0
- data/lib/rails_transactional_outbox/record_processors.rb +6 -0
- data/lib/rails_transactional_outbox/reliable_model/reliable_callback.rb +41 -0
- data/lib/rails_transactional_outbox/reliable_model/reliable_callbacks_registry.rb +26 -0
- data/lib/rails_transactional_outbox/reliable_model.rb +81 -0
- data/lib/rails_transactional_outbox/runner.rb +106 -0
- data/lib/rails_transactional_outbox/runner_sleep_interval.rb +14 -0
- data/lib/rails_transactional_outbox/tracers/datadog_tracer.rb +35 -0
- data/lib/rails_transactional_outbox/tracers/null_tracer.rb +9 -0
- data/lib/rails_transactional_outbox/tracers.rb +7 -0
- data/lib/rails_transactional_outbox/version.rb +7 -0
- data/lib/rails_transactional_outbox.rb +54 -0
- data/lib/tasks/rails_transactional_outbox.rake +11 -0
- data/rails-transactional-outbox.gemspec +44 -0
- 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
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,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,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,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
|