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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6343854fdf11162d383989d784559a868d52eb17b5b2eb0ec00b52c7d91997c
4
- data.tar.gz: 351f3beae606216af85b13f42a2262675c8de21fb2f77588c9ad0c249c4cc187
3
+ metadata.gz: 60bbddad0806646606ec74645b6cdeb9e4cb36775f661f9a5226822d12e9cd78
4
+ data.tar.gz: c7e9b82b9f3c84b9f09b8b27728a55562e524be4ce2e0f16653baeee965941f0
5
5
  SHA512:
6
- metadata.gz: 43707b7e3083d3c8512c03e9db85601f10fa77664a180b87abd34ca5b641b51ffb2e3b4add3eea7129fd1adad480f667c0531a43fc783b4548f18792d32bd6a5
7
- data.tar.gz: e8ac0f50cacfa30d0b8fab99299cdafec95fb5cf9d1451824957dd21a2cd7fc207b9fbc728ee2b4d5c2b63de287036558d39916bd7470eb6ae4ac10cef443ed7
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
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2022-09-08
4
+
5
+ - Introduce `RailsTransactionalOutbox::OutboxEntriesProcessors::OrderedByCausalityKeyProcessor`
6
+
3
7
  ## [0.1.0] - 2022-08-23
4
8
 
5
9
  - Initial release
data/Gemfile CHANGED
@@ -10,6 +10,7 @@ gem "rake", "~> 13.0"
10
10
  gem "crypt_keeper", github: "jmazzi/crypt_keeper"
11
11
  gem "ddtrace"
12
12
  gem "pg"
13
+ gem "redlock"
13
14
  gem "rspec", "~> 3.0"
14
15
  gem "rubocop", require: false
15
16
  gem "rubocop-performance"
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: .
11
11
  specs:
12
- rails-transactional-outbox (0.1.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
- 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
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
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsTransactionalOutbox
4
+ class OutboxEntriesProcessors
5
+ end
6
+ end
@@ -10,18 +10,21 @@ class RailsTransactionalOutbox
10
10
  end
11
11
 
12
12
  def build(model, event_type)
13
- config.outbox_model.new(attributes_for_entry(model, event_type))
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
- where(processed_at: nil)
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
- def self.any_records_to_process?
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
- .exists?
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)
@@ -3,5 +3,5 @@
3
3
  class RailsTransactionalOutbox
4
4
  module Version
5
5
  end
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
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.1.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-31 00:00:00.000000000 Z
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