rails-transactional-outbox 0.1.0 → 0.2.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 +4 -4
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +4 -1
- data/README.md +20 -0
- data/lib/rails_transactional_outbox/configuration.rb +18 -1
- data/lib/rails_transactional_outbox/null_lock_client.rb +15 -0
- data/lib/rails_transactional_outbox/outbox_entries_processor.rb +2 -42
- data/lib/rails_transactional_outbox/outbox_entries_processors/base_processor.rb +56 -0
- data/lib/rails_transactional_outbox/outbox_entries_processors/non_ordered_processor.rb +20 -0
- data/lib/rails_transactional_outbox/outbox_entries_processors/ordered_by_causality_key_processor.rb +33 -0
- data/lib/rails_transactional_outbox/outbox_entries_processors.rb +6 -0
- data/lib/rails_transactional_outbox/outbox_entry_factory.rb +5 -2
- data/lib/rails_transactional_outbox/outbox_model.rb +25 -4
- data/lib/rails_transactional_outbox/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 60bbddad0806646606ec74645b6cdeb9e4cb36775f661f9a5226822d12e9cd78
|
4
|
+
data.tar.gz: c7e9b82b9f3c84b9f09b8b27728a55562e524be4ce2e0f16653baeee965941f0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3848c22971da6d9e727ae2a9430995ad225cbdd06df4fdcc7a6dcb93c98ec0fef7f1e9102ab9eec01d30889449d9ac9a6c33d87a5e239309ebb8bd2ec3366532
|
7
|
+
data.tar.gz: 4895905ed12adead29478d3d2b235625587c2ad43f65b6e87592a1b19010af2097dfd149c2567c9ef91fee4838e45aa353a0f7a0202bb977bbdbd5e7065d6574
|
data/.rubocop.yml
CHANGED
@@ -115,6 +115,7 @@ Metrics/AbcSize:
|
|
115
115
|
Exclude:
|
116
116
|
- 'lib/rails_transactional_outbox/runner.rb'
|
117
117
|
- 'lib/rails_transactional_outbox/outbox_entries_processor.rb'
|
118
|
+
- 'lib/rails_transactional_outbox/outbox_entries_processors/non_ordered_processor.rb'
|
118
119
|
|
119
120
|
Metrics/MethodLength:
|
120
121
|
Max: 20
|
@@ -132,10 +133,12 @@ Lint/EmptyClass:
|
|
132
133
|
Exclude:
|
133
134
|
- 'lib/rails_transactional_outbox/error_handlers.rb'
|
134
135
|
- 'lib/rails_transactional_outbox/record_processors.rb'
|
136
|
+
- 'lib/rails_transactional_outbox/outbox_entries_processors.rb'
|
135
137
|
|
136
138
|
Metrics/BlockLength:
|
137
139
|
Exclude:
|
138
140
|
- 'lib/rails_transactional_outbox/reliable_model.rb'
|
141
|
+
- 'lib/rails_transactional_outbox/outbox_model.rb'
|
139
142
|
|
140
143
|
Lint/ConstantDefinitionInBlock:
|
141
144
|
Exclude:
|
data/CHANGELOG.md
CHANGED
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -9,7 +9,7 @@ GIT
|
|
9
9
|
PATH
|
10
10
|
remote: .
|
11
11
|
specs:
|
12
|
-
rails-transactional-outbox (0.
|
12
|
+
rails-transactional-outbox (0.2.0)
|
13
13
|
activerecord (>= 5)
|
14
14
|
activesupport (>= 3.2)
|
15
15
|
concurrent-ruby
|
@@ -73,6 +73,8 @@ GEM
|
|
73
73
|
rainbow (3.1.1)
|
74
74
|
rake (13.0.6)
|
75
75
|
redis (4.8.0)
|
76
|
+
redlock (1.3.0)
|
77
|
+
redis (>= 3.0.0, < 6.0)
|
76
78
|
regexp_parser (2.5.0)
|
77
79
|
rexml (3.2.5)
|
78
80
|
rspec (3.11.0)
|
@@ -130,6 +132,7 @@ DEPENDENCIES
|
|
130
132
|
pg
|
131
133
|
rails-transactional-outbox!
|
132
134
|
rake (~> 13.0)
|
135
|
+
redlock
|
133
136
|
rspec (~> 3.0)
|
134
137
|
rubocop
|
135
138
|
rubocop-performance
|
data/README.md
CHANGED
@@ -39,6 +39,11 @@ Rails.application.config.to_prepare do
|
|
39
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
40
|
config.outbox_batch_size = 100 # optional, defaults to 100
|
41
41
|
config.add_record_processor(MyCustomOperationProcerssor) # optional, by default it contains only one processor for ActiveRecord, but you could add more
|
42
|
+
|
43
|
+
config.lock_client = Redlock::Client.new([ENV["REDIS_URL"]]) # required if you want to use RailsTransactionalOutbox::OutboxEntriesProcessors::OrderedByCausalityKeyProcessor, defaults to RailsTransactionalOutbox::NullLockClient. Check its interface and the interface of `redlock` gem. To cut the long story short, when the lock is acquired, a hash with the structure outlined in RailsTransactionalOutbox::NullLockClient should be yielded, if the lock is not acquired, a nil should be yielded.
|
44
|
+
config.lock_expiry_time = 10_000 # not required, defaults to 10_000, the unit is milliseconds
|
45
|
+
config.outbox_entries_processor = `RailsTransactionalOutbox::OutboxEntriesProcessors::OrderedByCausalityKeyProcessor`.new # not required, defaults to RailsTransactionalOutbox::OutboxEntriesProcessors::NonOrderedProcessor.new
|
46
|
+
config.outbox_entry_causality_key_resolver = ->(model) { model.tenant_id } # not required, defaults to a lambda returning nil. Needed when using `outbox_entry_causality_key_resolver`
|
42
47
|
end
|
43
48
|
end
|
44
49
|
```
|
@@ -66,6 +71,7 @@ create_table(:outbox_entries) do |t|
|
|
66
71
|
t.datetime "processed_at"
|
67
72
|
t.text "arguments", null: false, default: "{}"
|
68
73
|
t.text "changeset", null: false, default: "{}"
|
74
|
+
t.string "causality_key"
|
69
75
|
t.datetime "failed_at"
|
70
76
|
t.datetime "retry_at"
|
71
77
|
t.string "error_class"
|
@@ -79,6 +85,7 @@ create_table(:outbox_entries) do |t|
|
|
79
85
|
t.index ["context"], name: "idx_outbox_enc_entries_on_topic"
|
80
86
|
t.index ["created_at"], name: "idx_outbox_enc_entries_on_created_at"
|
81
87
|
t.index ["created_at"], name: "idx_outbox_enc_entries_on_created_at_not_processed", where: "processed_at IS NULL"
|
88
|
+
t.index ["causality_key", created_at"], name: "idx_outbox_enc_entries_on_c_key_crtd_at_n_proc", where: "processed_at IS NULL"
|
82
89
|
t.index %w[resource_class created_at], name: "idx_outbox_enc_entries_on_resource_class_and_created_at"
|
83
90
|
t.index %w[resource_class processed_at], name: "idx_outbox_enc_entries_on_resource_class_and_processed_at"
|
84
91
|
end
|
@@ -112,6 +119,19 @@ When executing the callbacks, you can use `previous_changes` which will contain
|
|
112
119
|
|
113
120
|
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
121
|
|
122
|
+
### Ordering/Preserving causality
|
123
|
+
|
124
|
+
There are two type of processors that have very different behavior depending on the concurrency:
|
125
|
+
|
126
|
+
1. `RailsTransactionalOutbox::OutboxEntriesProcessors::NonOrderedProcessor` (used by default):
|
127
|
+
|
128
|
+
By default, the order will be preserved only if there is no concurrency (i.e. a single process with a single thread). Internally, `.lock("FOR UPDATE SKIP LOCKED")` is used to avoid conflicts and other issues related to concurrency but at the cost of no longer preserving the causality of outbox entries (although the entries are ordered by `created_at`).
|
129
|
+
|
130
|
+
2`RailsTransactionalOutbox::OutboxEntriesProcessors::OrderedByCausalityKeyProcessor`:
|
131
|
+
|
132
|
+
Uses lock (e.g. Redlock) to preserve causality determined by `causality_key` (e.g. a tenant ID).
|
133
|
+
|
134
|
+
|
115
135
|
### Custom processors
|
116
136
|
|
117
137
|
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:
|
@@ -4,7 +4,8 @@ class RailsTransactionalOutbox
|
|
4
4
|
class Configuration
|
5
5
|
attr_accessor :database_connection_provider, :logger, :outbox_model, :transaction_provider
|
6
6
|
attr_writer :error_handler, :transactional_outbox_worker_sleep_seconds,
|
7
|
-
:transactional_outbox_worker_idle_delay_multiplier, :outbox_batch_size
|
7
|
+
:transactional_outbox_worker_idle_delay_multiplier, :outbox_batch_size, :outbox_entries_processor,
|
8
|
+
:lock_client, :lock_expiry_time, :outbox_entry_causality_key_resolver
|
8
9
|
|
9
10
|
def error_handler
|
10
11
|
@error_handler || RailsTransactionalOutbox::ErrorHandlers::NullErrorHandler
|
@@ -29,5 +30,21 @@ class RailsTransactionalOutbox
|
|
29
30
|
def add_record_processor(record_processor)
|
30
31
|
record_processors << record_processor
|
31
32
|
end
|
33
|
+
|
34
|
+
def outbox_entries_processor
|
35
|
+
@outbox_entries_processor ||= RailsTransactionalOutbox::OutboxEntriesProcessors::NonOrderedProcessor.new
|
36
|
+
end
|
37
|
+
|
38
|
+
def lock_client
|
39
|
+
@lock_client || RailsTransactionalOutbox::NullLockClient
|
40
|
+
end
|
41
|
+
|
42
|
+
def lock_expiry_time
|
43
|
+
@lock_expiry_time || 10_000
|
44
|
+
end
|
45
|
+
|
46
|
+
def outbox_entry_causality_key_resolver
|
47
|
+
@outbox_entry_causality_key_resolver || ->(_model) {}
|
48
|
+
end
|
32
49
|
end
|
33
50
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class RailsTransactionalOutbox
|
4
|
+
class NullLockClient
|
5
|
+
def self.lock(resource_key, expiration_time)
|
6
|
+
payload = {
|
7
|
+
validity: expiration_time,
|
8
|
+
resource: resource_key,
|
9
|
+
value: "null_lock_client_lock"
|
10
|
+
}
|
11
|
+
|
12
|
+
yield payload
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -9,48 +9,8 @@ class RailsTransactionalOutbox
|
|
9
9
|
@config = config
|
10
10
|
end
|
11
11
|
|
12
|
-
def call
|
13
|
-
|
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
|
12
|
+
def call(&block)
|
13
|
+
config.outbox_entries_processor.call(&block)
|
54
14
|
end
|
55
15
|
end
|
56
16
|
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class RailsTransactionalOutbox
|
4
|
+
class OutboxEntriesProcessors
|
5
|
+
class BaseProcessor
|
6
|
+
attr_reader :config
|
7
|
+
private :config
|
8
|
+
|
9
|
+
def initialize(config: RailsTransactionalOutbox.configuration)
|
10
|
+
@config = config
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(&block)
|
14
|
+
return [] unless outbox_model.any_records_to_process?
|
15
|
+
|
16
|
+
execute(&block)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
delegate :outbox_model, :outbox_batch_size, to: :config
|
22
|
+
|
23
|
+
def execute(&_block)
|
24
|
+
raise "implement me"
|
25
|
+
end
|
26
|
+
|
27
|
+
def process_records(records_to_process, &block)
|
28
|
+
failed_records = []
|
29
|
+
records_to_process.each do |record|
|
30
|
+
begin
|
31
|
+
process(record)
|
32
|
+
rescue => e
|
33
|
+
record.handle_error(e)
|
34
|
+
record.save!
|
35
|
+
failed_records << record
|
36
|
+
end
|
37
|
+
yield record if block
|
38
|
+
end
|
39
|
+
processed_records = records_to_process - failed_records
|
40
|
+
mark_as_processed(processed_records)
|
41
|
+
end
|
42
|
+
|
43
|
+
def process(record)
|
44
|
+
record_processor.call(record)
|
45
|
+
end
|
46
|
+
|
47
|
+
def record_processor
|
48
|
+
@record_processor ||= RailsTransactionalOutbox::RecordProcessor.new
|
49
|
+
end
|
50
|
+
|
51
|
+
def mark_as_processed(processed_records)
|
52
|
+
outbox_model.mark_as_processed(processed_records)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class RailsTransactionalOutbox
|
4
|
+
class OutboxEntriesProcessors
|
5
|
+
class NonOrderedProcessor < RailsTransactionalOutbox::OutboxEntriesProcessors::BaseProcessor
|
6
|
+
private
|
7
|
+
|
8
|
+
delegate :transaction_provider, to: :config
|
9
|
+
delegate :transaction, to: :transaction_provider
|
10
|
+
|
11
|
+
def execute(&block)
|
12
|
+
transaction do
|
13
|
+
outbox_model.fetch_processable(outbox_batch_size).to_a.tap do |records_to_process|
|
14
|
+
process_records(records_to_process, &block)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/rails_transactional_outbox/outbox_entries_processors/ordered_by_causality_key_processor.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class RailsTransactionalOutbox
|
4
|
+
class OutboxEntriesProcessors
|
5
|
+
class OrderedByCausalityKeyProcessor < RailsTransactionalOutbox::OutboxEntriesProcessors::BaseProcessor
|
6
|
+
private
|
7
|
+
|
8
|
+
delegate :lock_client, :lock_expiry_time, to: :config
|
9
|
+
|
10
|
+
def execute(&block)
|
11
|
+
unprocessed_causality_keys.each_with_object([]) do |causality_key, processed_records|
|
12
|
+
lock_client.lock(lock_name(causality_key), lock_expiry_time) do |locked|
|
13
|
+
next unless locked
|
14
|
+
|
15
|
+
processed_records.concat(fetch_records(causality_key).tap { |records| process_records(records, &block) })
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def unprocessed_causality_keys
|
21
|
+
outbox_model.unprocessed_causality_keys
|
22
|
+
end
|
23
|
+
|
24
|
+
def lock_name(causality_key)
|
25
|
+
"RailsTransactionalOutbox-#{causality_key}"
|
26
|
+
end
|
27
|
+
|
28
|
+
def fetch_records(causality_key)
|
29
|
+
outbox_model.fetch_processable_for_causality_key(outbox_batch_size, causality_key).to_a
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -10,18 +10,21 @@ class RailsTransactionalOutbox
|
|
10
10
|
end
|
11
11
|
|
12
12
|
def build(model, event_type)
|
13
|
-
|
13
|
+
outbox_model.new(attributes_for_entry(model, event_type))
|
14
14
|
end
|
15
15
|
|
16
16
|
private
|
17
17
|
|
18
|
+
delegate :outbox_model, :outbox_entry_causality_key_resolver, to: :config
|
19
|
+
|
18
20
|
def attributes_for_entry(model, event_type)
|
19
21
|
{
|
20
22
|
resource_class: model.class.to_s,
|
21
23
|
resource_id: model.id,
|
22
24
|
changeset: model.saved_changes,
|
23
25
|
event_name: "#{model.model_name.singular}_#{event_name_suffix(event_type)}",
|
24
|
-
context: RailsTransactionalOutbox::RecordProcessors::ActiveRecordProcessor.context
|
26
|
+
context: RailsTransactionalOutbox::RecordProcessors::ActiveRecordProcessor.context,
|
27
|
+
causality_key: outbox_entry_causality_key_resolver.call(model)
|
25
28
|
}
|
26
29
|
end
|
27
30
|
|
@@ -6,17 +6,38 @@ class RailsTransactionalOutbox
|
|
6
6
|
|
7
7
|
included do
|
8
8
|
scope :fetch_processable, lambda { |batch_size|
|
9
|
-
|
9
|
+
processable_now
|
10
10
|
.lock("FOR UPDATE SKIP LOCKED")
|
11
|
-
.where("retry_at IS NULL OR retry_at <= ?", Time.current)
|
12
11
|
.order(created_at: :asc)
|
13
12
|
.limit(batch_size)
|
14
13
|
}
|
15
14
|
|
16
|
-
|
15
|
+
scope :fetch_processable_for_causality_key, lambda { |batch_size, causality_key|
|
16
|
+
processable_now
|
17
|
+
.where(causality_key: causality_key)
|
18
|
+
.order(created_at: :asc)
|
19
|
+
.limit(batch_size)
|
20
|
+
}
|
21
|
+
|
22
|
+
scope :processable_now, lambda {
|
17
23
|
where(processed_at: nil)
|
18
24
|
.where("retry_at IS NULL OR retry_at <= ?", Time.current)
|
19
|
-
|
25
|
+
}
|
26
|
+
|
27
|
+
def self.unprocessed_causality_keys
|
28
|
+
processable_now
|
29
|
+
.select("causality_key")
|
30
|
+
.distinct
|
31
|
+
.pluck(:causality_key)
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.any_records_to_process?
|
35
|
+
processable_now.exists?
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.mark_as_processed(processed_records)
|
39
|
+
where(id: processed_records).update_all(processed_at: Time.current, error_class: nil, error_message: nil,
|
40
|
+
failed_at: nil, retry_at: nil)
|
20
41
|
end
|
21
42
|
|
22
43
|
def self.outbox_encrypt_json_for(*encryptable_json_attributes)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rails-transactional-outbox
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Karol Galanciak
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-08
|
11
|
+
date: 2022-09-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -139,7 +139,12 @@ files:
|
|
139
139
|
- lib/rails_transactional_outbox/exponential_backoff.rb
|
140
140
|
- lib/rails_transactional_outbox/health_check.rb
|
141
141
|
- lib/rails_transactional_outbox/monitor.rb
|
142
|
+
- lib/rails_transactional_outbox/null_lock_client.rb
|
142
143
|
- lib/rails_transactional_outbox/outbox_entries_processor.rb
|
144
|
+
- lib/rails_transactional_outbox/outbox_entries_processors.rb
|
145
|
+
- lib/rails_transactional_outbox/outbox_entries_processors/base_processor.rb
|
146
|
+
- lib/rails_transactional_outbox/outbox_entries_processors/non_ordered_processor.rb
|
147
|
+
- lib/rails_transactional_outbox/outbox_entries_processors/ordered_by_causality_key_processor.rb
|
143
148
|
- lib/rails_transactional_outbox/outbox_entry_factory.rb
|
144
149
|
- lib/rails_transactional_outbox/outbox_model.rb
|
145
150
|
- lib/rails_transactional_outbox/railtie.rb
|