rails-transactional-outbox 0.1.0 → 0.2.0

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