ruby_event_store-outbox 0.0.11 → 0.0.16

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: e6e7bf65a4d5106581d89918d0bb21bf6f19ee4e08a6f525d27665b41c07b3b0
4
- data.tar.gz: eb1350270156bfd886bc11b94dfe858beda55692aa9c72832ca23fcadafe5fc0
3
+ metadata.gz: '008b96c4b43e3cbfc23d0aa8c9ebb160f0adbfcfc82a976967f1c56338346cc0'
4
+ data.tar.gz: e65157c0638ecec0a4967a251e820979d18eb92e55212f107be95c23d5a4315b
5
5
  SHA512:
6
- metadata.gz: eda134f0e4a489546c9b05fd541d27c83f24bdac1e60da244398df09e3b913c650180b0c3472d5d6c0afd502870f025de01d0e94389c90cd491c7504e9693697
7
- data.tar.gz: c73fcda82e6635636ae1eccbaeb3c52d4b2c438a7a55e075fccc254d18e118b5ea79266b48926074ec38bdf51b2b81cd787c6f7a61bf6facb5086ab1a5dd166d
6
+ metadata.gz: f0181744b8c621bc1f0b20d6d4a65ac779db7ba48a80c9f1a02f39208762aa82e5579e745f20cd35f2b54992876065f803e04e24c1c239abe22641f927327070
7
+ data.tar.gz: 453ce565584436b1b7dd145c738a0d50e95821cb8b8f5d9c00f0d70d2a8ea88e585402de534388459d04853d293c0bcbf7027285d31bbd00aeaef09471cf666d
data/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  # Ruby Event Store Outbox
2
2
 
3
- Very much work in progress.
3
+ ![Ruby Event Store Outbox](https://github.com/RailsEventStore/rails_event_store/workflows/ruby_event_store-outbox/badge.svg)
4
4
 
5
+ **Experimental feature of RES ecosystem.**
6
+
7
+ This repository includes a process and a Rails Event Store scheduler, which can be used to transactionally enqueue background jobs into your background jobs tool of choice. The scheduler included in this repo adds the jobs into the RDBMS into specific table instead of redis inside your transaction, and the process is enqueuing the jobs from that table to the background jobs tool.
5
8
 
6
9
  ## Installation (app)
7
10
 
@@ -35,7 +38,21 @@ end
35
38
  Run following process in any way you prefer:
36
39
 
37
40
  ```
38
- res_outbox --database-url="mysql2://root@0.0.0.0:3306/my_database" --redis-url="redis://localhost:6379/0" --log-level=info
41
+ res_outbox --database-url="mysql2://root@0.0.0.0:3306/my_database" --redis-url="redis://localhost:6379/0" --log-level=info --split-keys=sidekiq_queue1,sidekiq_queue2
42
+ ```
43
+
44
+ It is possible to run as many instances as you prefer, but it does not make sense to run more instances than there are different split keys (sidekiq queues), as one process is operating at one moment only one split key.
45
+
46
+ ### Metrics
47
+
48
+ It is possible for the outbox process to send metrics to InfluxDB. In order to do that, specify a `--metrics-url` parameter, for example:
49
+
50
+ ```
51
+ res_outbox --database-url="mysql2://root@0.0.0.0:3306/my_database" \
52
+ --redis-url="redis://localhost:6379/0" \
53
+ --log-level=info \
54
+ --split-keys=sidekiq_queue1,sidekiq_queue2 \
55
+ --metrics-url=http://user:password@localhost:8086/dbname"
39
56
  ```
40
57
 
41
58
 
@@ -16,12 +16,7 @@ module RubyEventStore
16
16
 
17
17
  private
18
18
 
19
- def rails_version
20
- Rails::VERSION::STRING
21
- end
22
-
23
19
  def migration_version
24
- return nil if Gem::Version.new(rails_version) < Gem::Version.new("5.0.0")
25
20
  "[4.2]"
26
21
  end
27
22
 
@@ -11,5 +11,13 @@ class CreateEventStoreOutbox < ActiveRecord::Migration<%= migration_version %>
11
11
  end
12
12
  add_index :event_store_outbox, [:format, :enqueued_at, :split_key], name: "index_event_store_outbox_for_pool"
13
13
  add_index :event_store_outbox, [:created_at, :enqueued_at], name: "index_event_store_outbox_for_clear"
14
+
15
+ create_table(:event_store_outbox_locks, force: false) do |t|
16
+ t.string :format, null: false
17
+ t.string :split_key, null: false
18
+ t.datetime :locked_at, null: true
19
+ t.string :locked_by, null: true, limit: 36
20
+ end
21
+ add_index :event_store_outbox_locks, [:format, :split_key], name: "index_event_store_outbox_locks_for_locking", unique: true
14
22
  end
15
23
  end
@@ -5,7 +5,8 @@ module RubyEventStore
5
5
  end
6
6
  end
7
7
 
8
- require_relative 'outbox/record'
8
+ require_relative 'outbox/fetch_specification'
9
+ require_relative 'outbox/repository'
9
10
  require_relative 'outbox/sidekiq_scheduler'
10
11
  require_relative 'outbox/consumer'
11
12
  require_relative 'outbox/version'
@@ -0,0 +1,19 @@
1
+ module RubyEventStore
2
+ module Outbox
3
+ module CleanupStrategies
4
+ class CleanOldEnqueued
5
+ def initialize(repository, duration)
6
+ @repository = repository
7
+ @duration = duration
8
+ end
9
+
10
+ def call(fetch_specification)
11
+ repository.delete_enqueued_older_than(fetch_specification, duration)
12
+ end
13
+
14
+ private
15
+ attr_reader :repository, :duration
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ module RubyEventStore
2
+ module Outbox
3
+ module CleanupStrategies
4
+ class None
5
+ def initialize
6
+ end
7
+
8
+ def call(_fetch_specification)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -6,11 +6,11 @@ require "ruby_event_store/outbox/metrics"
6
6
  module RubyEventStore
7
7
  module Outbox
8
8
  class CLI
9
- Options = Struct.new(:database_url, :redis_url, :log_level, :split_keys, :message_format, :batch_size, :metrics_url)
9
+ Options = Struct.new(:database_url, :redis_url, :log_level, :split_keys, :message_format, :batch_size, :metrics_url, :cleanup_strategy)
10
10
 
11
11
  class Parser
12
12
  def self.parse(argv)
13
- options = Options.new(nil, nil, :warn, nil, nil, 100)
13
+ options = Options.new(nil, nil, :warn, nil, nil, 100, nil, :none)
14
14
  OptionParser.new do |option_parser|
15
15
  option_parser.banner = "Usage: res_outbox [options]"
16
16
 
@@ -42,6 +42,10 @@ module RubyEventStore
42
42
  options.metrics_url = metrics_url
43
43
  end
44
44
 
45
+ option_parser.on("--cleanup CLEANUP_STRATEGY", "A strategy for cleaning old records. One of: none or iso8601 duration format how old enqueued records should be removed") do |cleanup_strategy|
46
+ options.cleanup_strategy = cleanup_strategy
47
+ end
48
+
45
49
  option_parser.on_tail("--version", "Show version") do
46
50
  puts VERSION
47
51
  exit
@@ -59,17 +63,20 @@ module RubyEventStore
59
63
  end
60
64
 
61
65
  def build_consumer(options)
62
- logger = Logger.new(STDOUT, level: options.log_level, progname: "RES-Outbox")
66
+ consumer_uuid = SecureRandom.uuid
67
+ logger = Logger.new(STDOUT, level: options.log_level, progname: "RES-Outbox #{consumer_uuid}")
63
68
  consumer_configuration = Consumer::Configuration.new(
64
69
  split_keys: options.split_keys,
65
70
  message_format: options.message_format,
66
71
  batch_size: options.batch_size,
67
72
  database_url: options.database_url,
68
73
  redis_url: options.redis_url,
74
+ cleanup: options.cleanup_strategy,
69
75
  )
70
76
  metrics = Metrics.from_url(options.metrics_url)
71
77
  outbox_consumer = RubyEventStore::Outbox::Consumer.new(
72
- options,
78
+ consumer_uuid,
79
+ consumer_configuration,
73
80
  logger: logger,
74
81
  metrics: metrics,
75
82
  )
@@ -1,13 +1,18 @@
1
1
  require "logger"
2
2
  require "redis"
3
3
  require "active_record"
4
- require "ruby_event_store/outbox/record"
4
+ require "ruby_event_store/outbox/repository"
5
5
  require "ruby_event_store/outbox/sidekiq5_format"
6
+ require "ruby_event_store/outbox/sidekiq_processor"
7
+ require "ruby_event_store/outbox/fetch_specification"
8
+ require "ruby_event_store/outbox/cleanup_strategies/none"
9
+ require "ruby_event_store/outbox/cleanup_strategies/clean_old_enqueued"
6
10
 
7
11
  module RubyEventStore
8
12
  module Outbox
9
13
  class Consumer
10
- SLEEP_TIME_WHEN_NOTHING_TO_DO = 0.1
14
+ SLEEP_TIME_WHEN_NOTHING_TO_DO = 0.5
15
+ MAXIMUM_BATCH_FETCHES_IN_ONE_LOCK = 10
11
16
 
12
17
  class Configuration
13
18
  def initialize(
@@ -15,13 +20,15 @@ module RubyEventStore
15
20
  message_format:,
16
21
  batch_size:,
17
22
  database_url:,
18
- redis_url:
23
+ redis_url:,
24
+ cleanup:
19
25
  )
20
26
  @split_keys = split_keys
21
27
  @message_format = message_format
22
28
  @batch_size = batch_size || 100
23
29
  @database_url = database_url
24
30
  @redis_url = redis_url
31
+ @cleanup = cleanup
25
32
  freeze
26
33
  end
27
34
 
@@ -32,34 +39,37 @@ module RubyEventStore
32
39
  batch_size: overriden_options.fetch(:batch_size, batch_size),
33
40
  database_url: overriden_options.fetch(:database_url, database_url),
34
41
  redis_url: overriden_options.fetch(:redis_url, redis_url),
42
+ cleanup: overriden_options.fetch(:cleanup, cleanup)
35
43
  )
36
44
  end
37
45
 
38
- attr_reader :split_keys, :message_format, :batch_size, :database_url, :redis_url
46
+ attr_reader :split_keys, :message_format, :batch_size, :database_url, :redis_url, :cleanup
39
47
  end
40
48
 
41
- def initialize(configuration, clock: Time, logger:, metrics:)
49
+ def initialize(consumer_uuid, configuration, clock: Time, logger:, metrics:)
42
50
  @split_keys = configuration.split_keys
43
51
  @clock = clock
44
- @redis = Redis.new(url: configuration.redis_url)
45
52
  @logger = logger
46
53
  @metrics = metrics
47
54
  @batch_size = configuration.batch_size
48
- ActiveRecord::Base.establish_connection(configuration.database_url) unless ActiveRecord::Base.connected?
49
- if ActiveRecord::Base.connection.adapter_name == "Mysql2"
50
- ActiveRecord::Base.connection.execute("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;")
51
- ActiveRecord::Base.connection.execute("SET SESSION innodb_lock_wait_timeout = 1;")
52
- end
55
+ @consumer_uuid = consumer_uuid
53
56
 
54
57
  raise "Unknown format" if configuration.message_format != SIDEKIQ5_FORMAT
55
- @message_format = SIDEKIQ5_FORMAT
58
+ @processor = SidekiqProcessor.new(Redis.new(url: configuration.redis_url))
56
59
 
57
60
  @gracefully_shutting_down = false
58
61
  prepare_traps
62
+
63
+ @repository = Repository.new(configuration.database_url)
64
+ @cleanup_strategy = case configuration.cleanup
65
+ when :none
66
+ CleanupStrategies::None.new
67
+ else
68
+ CleanupStrategies::CleanOldEnqueued.new(repository, ActiveSupport::Duration.parse(configuration.cleanup))
69
+ end
59
70
  end
60
71
 
61
72
  def init
62
- @redis.sadd("queues", split_keys)
63
73
  logger.info("Initiated RubyEventStore::Outbox v#{VERSION}")
64
74
  logger.info("Handling split keys: #{split_keys ? split_keys.join(", ") : "(all of them)"}")
65
75
  end
@@ -76,62 +86,133 @@ module RubyEventStore
76
86
  end
77
87
 
78
88
  def one_loop
79
- Record.transaction do
80
- records_scope = Record.lock.where(format: message_format, enqueued_at: nil)
81
- records_scope = records_scope.where(split_key: split_keys) if !split_keys.nil?
82
- records = records_scope.order("id ASC").limit(batch_size).to_a
83
- if records.empty?
84
- metrics.write_point_queue(status: "ok")
85
- return false
89
+ remaining_split_keys = @split_keys.dup
90
+
91
+ was_something_changed = false
92
+ while (split_key = remaining_split_keys.shift)
93
+ was_something_changed |= handle_split(FetchSpecification.new(processor.message_format, split_key))
94
+ end
95
+ was_something_changed
96
+ end
97
+
98
+ def handle_split(fetch_specification)
99
+ obtained_lock = obtain_lock_for_process(fetch_specification)
100
+ return false unless obtained_lock
101
+
102
+ something_processed = false
103
+
104
+ MAXIMUM_BATCH_FETCHES_IN_ONE_LOCK.times do
105
+ batch = retrieve_batch(fetch_specification)
106
+ if batch.empty?
107
+ break
86
108
  end
87
109
 
88
- now = @clock.now.utc
89
110
  failed_record_ids = []
90
- records.group_by(&:split_key).each do |split_key, records2|
111
+ updated_record_ids = []
112
+ batch.each do |record|
91
113
  begin
92
- failed = handle_group_of_records(now, split_key, records2)
93
- failed_record_ids.concat(failed.map(&:id))
114
+ now = @clock.now.utc
115
+ processor.process(record, now)
116
+
117
+ repository.mark_as_enqueued(record, now)
118
+ something_processed |= true
119
+ updated_record_ids << record.id
94
120
  rescue => e
95
- failed_record_ids.concat(records2.map(&:id))
121
+ failed_record_ids << record.id
96
122
  e.full_message.split($/).each {|line| logger.error(line) }
97
123
  end
98
124
  end
99
125
 
100
- updated_record_ids = records.map(&:id) - failed_record_ids
101
- Record.where(id: updated_record_ids).update_all(enqueued_at: now)
102
- metrics.write_point_queue(status: "ok", enqueued: updated_record_ids.size, failed: failed_record_ids.size)
126
+ metrics.write_point_queue(
127
+ enqueued: updated_record_ids.size,
128
+ failed: failed_record_ids.size,
129
+ format: fetch_specification.message_format,
130
+ split_key: fetch_specification.split_key,
131
+ remaining: get_remaining_count(fetch_specification)
132
+ )
103
133
 
104
134
  logger.info "Sent #{updated_record_ids.size} messages from outbox table"
105
- true
135
+
136
+ refresh_successful = refresh_lock_for_process(obtained_lock)
137
+ break unless refresh_successful
106
138
  end
107
- rescue ActiveRecord::Deadlocked
108
- logger.warn "Outbox fetch deadlocked"
109
- metrics.write_point_queue(status: "deadlocked")
110
- false
111
- rescue ActiveRecord::LockWaitTimeout
112
- logger.warn "Outbox fetch lock timeout"
113
- metrics.write_point_queue(status: "lock_timeout")
114
- false
139
+
140
+ metrics.write_point_queue(
141
+ format: fetch_specification.message_format,
142
+ split_key: fetch_specification.split_key,
143
+ remaining: get_remaining_count(fetch_specification)
144
+ ) unless something_processed
145
+
146
+ release_lock_for_process(fetch_specification)
147
+
148
+ cleanup_strategy.call(fetch_specification)
149
+
150
+ processor.after_batch
151
+
152
+ something_processed
115
153
  end
116
154
 
117
155
  private
118
- attr_reader :split_keys, :logger, :message_format, :batch_size, :metrics
119
-
120
- def handle_group_of_records(now, split_key, records)
121
- failed = []
122
- elements = []
123
- records.each do |record|
124
- begin
125
- elements << JSON.generate(JSON.parse(record.payload).merge({
126
- "enqueued_at" => now.to_f,
127
- }))
128
- rescue => e
129
- failed << record
130
- e.full_message.split($/).each {|line| logger.error(line) }
131
- end
156
+ attr_reader :split_keys, :logger, :batch_size, :metrics, :processor, :consumer_uuid, :repository, :cleanup_strategy
157
+
158
+ def obtain_lock_for_process(fetch_specification)
159
+ result = repository.obtain_lock_for_process(fetch_specification, consumer_uuid, clock: @clock)
160
+ case result
161
+ when :deadlocked
162
+ logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
163
+ metrics.write_operation_result("obtain", "deadlocked")
164
+ return false
165
+ when :lock_timeout
166
+ logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
167
+ metrics.write_operation_result("obtain", "lock_timeout")
168
+ return false
169
+ when :taken
170
+ logger.debug "Obtaining lock for split_key '#{fetch_specification.split_key}' unsuccessful (taken)"
171
+ metrics.write_operation_result("obtain", "taken")
172
+ return false
173
+ else
174
+ return result
175
+ end
176
+ end
177
+
178
+ def release_lock_for_process(fetch_specification)
179
+ result = repository.release_lock_for_process(fetch_specification, consumer_uuid)
180
+ case result
181
+ when :ok
182
+ when :deadlocked
183
+ logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
184
+ metrics.write_operation_result("release", "deadlocked")
185
+ when :lock_timeout
186
+ logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
187
+ metrics.write_operation_result("release", "lock_timeout")
188
+ when :not_taken_by_this_process
189
+ logger.debug "Releasing lock for split_key '#{fetch_specification.split_key}' failed (not taken by this process)"
190
+ metrics.write_operation_result("release", "not_taken_by_this_process")
191
+ else
192
+ raise "Unexpected result #{result}"
193
+ end
194
+ end
195
+
196
+ def refresh_lock_for_process(lock)
197
+ result = lock.refresh(clock: @clock)
198
+ case result
199
+ when :ok
200
+ return true
201
+ when :deadlocked
202
+ logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (deadlock)"
203
+ metrics.write_operation_result("refresh", "deadlocked")
204
+ return false
205
+ when :lock_timeout
206
+ logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (lock timeout)"
207
+ metrics.write_operation_result("refresh", "lock_timeout")
208
+ return false
209
+ when :stolen
210
+ logger.debug "Refreshing lock for split_key '#{lock.split_key}' unsuccessful (stolen)"
211
+ metrics.write_operation_result("refresh", "stolen")
212
+ return false
213
+ else
214
+ raise "Unexpected result #{result}"
132
215
  end
133
- @redis.lpush("queue:#{split_key}", elements)
134
- failed
135
216
  end
136
217
 
137
218
  def prepare_traps
@@ -146,6 +227,14 @@ module RubyEventStore
146
227
  def initiate_graceful_shutdown
147
228
  @gracefully_shutting_down = true
148
229
  end
230
+
231
+ def retrieve_batch(fetch_specification)
232
+ repository.retrieve_batch(fetch_specification, batch_size)
233
+ end
234
+
235
+ def get_remaining_count(fetch_specification)
236
+ repository.get_remaining_count(fetch_specification)
237
+ end
149
238
  end
150
239
  end
151
240
  end
@@ -0,0 +1,6 @@
1
+ module RubyEventStore
2
+ module Outbox
3
+ class ConsumerProcess
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,31 @@
1
+ module RubyEventStore
2
+ module Outbox
3
+ class FetchSpecification
4
+ def initialize(message_format, split_key)
5
+ @message_format = message_format
6
+ @split_key = split_key
7
+ freeze
8
+ end
9
+
10
+ attr_reader :message_format, :split_key
11
+
12
+ def ==(other)
13
+ other.instance_of?(self.class) &&
14
+ other.message_format.eql?(message_format) &&
15
+ other.split_key.eql?(split_key)
16
+ end
17
+
18
+ BIG_VALUE = 0b111111100100010000010010110010101011011101110101001100100110000
19
+
20
+ def hash
21
+ [
22
+ self.class,
23
+ message_format,
24
+ split_key,
25
+ ].hash ^ BIG_VALUE
26
+ end
27
+
28
+ alias_method :eql?, :==
29
+ end
30
+ end
31
+ end
@@ -17,14 +17,28 @@ module RubyEventStore
17
17
  @influxdb_client = InfluxDB::Client.new(**options)
18
18
  end
19
19
 
20
- def write_point_queue(status:, enqueued: 0, failed: 0)
20
+ def write_operation_result(operation, result)
21
+ write_point("ruby_event_store.outbox.lock", {
22
+ values: {
23
+ value: 1,
24
+ },
25
+ tags: {
26
+ operation: operation,
27
+ result: result,
28
+ }
29
+ })
30
+ end
31
+
32
+ def write_point_queue(enqueued: 0, failed: 0, remaining: 0, format: nil, split_key: nil)
21
33
  write_point("ruby_event_store.outbox.queue", {
22
34
  values: {
23
35
  enqueued: enqueued,
24
36
  failed: failed,
37
+ remaining: remaining,
25
38
  },
26
39
  tags: {
27
- status: status,
40
+ format: format,
41
+ split_key: split_key,
28
42
  }
29
43
  })
30
44
  end
@@ -2,6 +2,9 @@ module RubyEventStore
2
2
  module Outbox
3
3
  module Metrics
4
4
  class Null
5
+ def write_operation_result(operation, result)
6
+ end
7
+
5
8
  def write_point_queue(**kwargs)
6
9
  end
7
10
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_support/core_ext/numeric/time.rb'
5
+
6
+ module RubyEventStore
7
+ module Outbox
8
+ class Repository
9
+ RECENTLY_LOCKED_DURATION = 10.minutes
10
+
11
+ class Record < ::ActiveRecord::Base
12
+ self.primary_key = :id
13
+ self.table_name = 'event_store_outbox'
14
+
15
+ def self.remaining_for(fetch_specification)
16
+ where(format: fetch_specification.message_format, split_key: fetch_specification.split_key, enqueued_at: nil)
17
+ end
18
+
19
+ def self.for_fetch_specification(fetch_specification)
20
+ where(format: fetch_specification.message_format, split_key: fetch_specification.split_key)
21
+ end
22
+
23
+ def hash_payload
24
+ JSON.parse(payload).deep_symbolize_keys
25
+ end
26
+
27
+ def enqueued?
28
+ !enqueued_at.nil?
29
+ end
30
+ end
31
+
32
+ class Lock < ::ActiveRecord::Base
33
+ self.table_name = 'event_store_outbox_locks'
34
+
35
+ def self.obtain(fetch_specification, process_uuid, clock:)
36
+ transaction do
37
+ l = get_lock_record(fetch_specification)
38
+
39
+ if l.recently_locked?
40
+ :taken
41
+ else
42
+ l.update!(
43
+ locked_by: process_uuid,
44
+ locked_at: clock.now,
45
+ )
46
+ l
47
+ end
48
+ end
49
+ rescue ActiveRecord::Deadlocked
50
+ :deadlocked
51
+ rescue ActiveRecord::LockWaitTimeout
52
+ :lock_timeout
53
+ end
54
+
55
+ def refresh(clock:)
56
+ transaction do
57
+ current_process_uuid = locked_by
58
+ lock!
59
+ if locked_by == current_process_uuid
60
+ update!(locked_at: clock.now)
61
+ :ok
62
+ else
63
+ :stolen
64
+ end
65
+ end
66
+ rescue ActiveRecord::Deadlocked
67
+ :deadlocked
68
+ rescue ActiveRecord::LockWaitTimeout
69
+ :lock_timeout
70
+ end
71
+
72
+ def self.release(fetch_specification, process_uuid)
73
+ transaction do
74
+ l = get_lock_record(fetch_specification)
75
+ if !l.locked_by?(process_uuid)
76
+ :not_taken_by_this_process
77
+ else
78
+ l.update!(locked_by: nil, locked_at: nil)
79
+ :ok
80
+ end
81
+ end
82
+ rescue ActiveRecord::Deadlocked
83
+ :deadlocked
84
+ rescue ActiveRecord::LockWaitTimeout
85
+ :lock_timeout
86
+ end
87
+
88
+ def locked_by?(process_uuid)
89
+ locked_by.eql?(process_uuid)
90
+ end
91
+
92
+ def recently_locked?
93
+ locked_by && locked_at > RECENTLY_LOCKED_DURATION.ago
94
+ end
95
+
96
+ def fetch_specification
97
+ FetchSpecification.new(format, split_key)
98
+ end
99
+
100
+ private
101
+ def self.lock_for_split_key(fetch_specification)
102
+ lock.find_by(format: fetch_specification.message_format, split_key: fetch_specification.split_key)
103
+ end
104
+
105
+ def self.get_lock_record(fetch_specification)
106
+ l = lock_for_split_key(fetch_specification)
107
+ if l.nil?
108
+ begin
109
+ l = create!(format: fetch_specification.message_format, split_key: fetch_specification.split_key)
110
+ rescue ActiveRecord::RecordNotUnique
111
+ l = lock_for_split_key(fetch_specification)
112
+ end
113
+ end
114
+ l
115
+ end
116
+ end
117
+
118
+ def initialize(database_url)
119
+ ActiveRecord::Base.establish_connection(database_url) unless ActiveRecord::Base.connected?
120
+ if ActiveRecord::Base.connection.adapter_name == "Mysql2"
121
+ ActiveRecord::Base.connection.execute("SET SESSION innodb_lock_wait_timeout = 1;")
122
+ end
123
+ end
124
+
125
+ def retrieve_batch(fetch_specification, batch_size)
126
+ Record.remaining_for(fetch_specification).order("id ASC").limit(batch_size).to_a
127
+ end
128
+
129
+ def get_remaining_count(fetch_specification)
130
+ Record.remaining_for(fetch_specification).count
131
+ end
132
+
133
+ def obtain_lock_for_process(fetch_specification, process_uuid, clock:)
134
+ Lock.obtain(fetch_specification, process_uuid, clock: clock)
135
+ end
136
+
137
+ def release_lock_for_process(fetch_specification, process_uuid)
138
+ Lock.release(fetch_specification, process_uuid)
139
+ end
140
+
141
+ def mark_as_enqueued(record, now)
142
+ record.update_column(:enqueued_at, now)
143
+ end
144
+
145
+ def delete_enqueued_older_than(fetch_specification, duration)
146
+ Record
147
+ .for_fetch_specification(fetch_specification)
148
+ .where("enqueued_at < ?", duration.ago)
149
+ .delete_all
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_event_store/outbox/sidekiq5_format"
4
+
5
+ module RubyEventStore
6
+ module Outbox
7
+ class SidekiqProcessor
8
+ InvalidPayload = Class.new(StandardError)
9
+
10
+ def initialize(redis)
11
+ @redis = redis
12
+ @recently_used_queues = Set.new
13
+ end
14
+
15
+ def process(record, now)
16
+ parsed_record = JSON.parse(record.payload)
17
+
18
+ queue = parsed_record["queue"]
19
+ raise InvalidPayload.new("Missing queue") if queue.nil? || queue.empty?
20
+ payload = JSON.generate(parsed_record.merge({
21
+ "enqueued_at" => now.to_f,
22
+ }))
23
+
24
+ redis.lpush("queue:#{queue}", payload)
25
+
26
+ @recently_used_queues << queue
27
+ end
28
+
29
+ def after_batch
30
+ if !@recently_used_queues.empty?
31
+ redis.sadd("queues", @recently_used_queues.to_a)
32
+ @recently_used_queues.clear
33
+ end
34
+ end
35
+
36
+ def message_format
37
+ SIDEKIQ5_FORMAT
38
+ end
39
+
40
+ private
41
+ attr_reader :redis
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+ require "ruby_event_store/outbox/repository"
5
+
6
+ module RubyEventStore
7
+ module Outbox
8
+ class SidekiqProducer
9
+ def call(klass, args)
10
+ sidekiq_client = Sidekiq::Client.new(Sidekiq.redis_pool)
11
+ item = {
12
+ 'class' => klass,
13
+ 'args' => args,
14
+ }
15
+ normalized_item = sidekiq_client.__send__(:normalize_item, item)
16
+ payload = sidekiq_client.__send__(:process_single, normalized_item.fetch('class'), normalized_item)
17
+ if payload
18
+ Repository::Record.create!(
19
+ format: SIDEKIQ5_FORMAT,
20
+ split_key: payload.fetch('queue'),
21
+ payload: payload.to_json
22
+ )
23
+ end
24
+ end
25
+
26
+ private
27
+ attr_reader :repository
28
+ end
29
+ end
30
+ end
@@ -1,31 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'sidekiq'
4
- require "ruby_event_store/outbox/sidekiq5_format"
3
+ require "ruby_event_store/outbox/sidekiq_producer"
5
4
 
6
5
  module RubyEventStore
7
6
  module Outbox
8
7
  class SidekiqScheduler
9
- def call(klass, serialized_event)
10
- sidekiq_client = Sidekiq::Client.new(Sidekiq.redis_pool)
11
- item = {
12
- 'class' => klass,
13
- 'args' => [serialized_event.to_h],
14
- }
15
- normalized_item = sidekiq_client.__send__(:normalize_item, item)
16
- payload = sidekiq_client.__send__(:process_single, normalized_item.fetch('class'), normalized_item)
17
- if payload
18
- Record.create!(
19
- format: SIDEKIQ5_FORMAT,
20
- split_key: payload.fetch('queue'),
21
- payload: payload.to_json
22
- )
23
- end
8
+ def initialize
9
+ @sidekiq_producer = SidekiqProducer.new
10
+ end
11
+
12
+ def call(klass, serialized_record)
13
+ sidekiq_producer.call(klass, [serialized_record.to_h])
24
14
  end
25
15
 
26
16
  def verify(subscriber)
27
17
  Class === subscriber && subscriber.respond_to?(:through_outbox?) && subscriber.through_outbox?
28
18
  end
19
+
20
+ private
21
+ attr_reader :sidekiq_producer
29
22
  end
30
23
  end
31
24
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyEventStore
4
4
  module Outbox
5
- VERSION = "0.0.11"
5
+ VERSION = "0.0.16"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,27 +1,27 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_event_store-outbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arkency
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-06-26 00:00:00.000000000 Z
11
+ date: 2021-01-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_event_store
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - '='
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: 1.0.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - '='
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: 1.0.0
27
27
  - !ruby/object:Gem::Dependency
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '3.0'
33
+ version: '5.2'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '3.0'
40
+ version: '5.2'
41
41
  description:
42
42
  email:
43
43
  - dev@arkency.com
@@ -51,14 +51,20 @@ files:
51
51
  - lib/generators/ruby_event_store/outbox/migration_generator.rb
52
52
  - lib/generators/ruby_event_store/outbox/templates/create_event_store_outbox_template.rb
53
53
  - lib/ruby_event_store/outbox.rb
54
+ - lib/ruby_event_store/outbox/cleanup_strategies/clean_old_enqueued.rb
55
+ - lib/ruby_event_store/outbox/cleanup_strategies/none.rb
54
56
  - lib/ruby_event_store/outbox/cli.rb
55
57
  - lib/ruby_event_store/outbox/consumer.rb
58
+ - lib/ruby_event_store/outbox/consumer_process.rb
59
+ - lib/ruby_event_store/outbox/fetch_specification.rb
56
60
  - lib/ruby_event_store/outbox/metrics.rb
57
61
  - lib/ruby_event_store/outbox/metrics/influx.rb
58
62
  - lib/ruby_event_store/outbox/metrics/null.rb
59
- - lib/ruby_event_store/outbox/record.rb
63
+ - lib/ruby_event_store/outbox/repository.rb
60
64
  - lib/ruby_event_store/outbox/sidekiq5_format.rb
61
65
  - lib/ruby_event_store/outbox/sidekiq_message_handler.rb
66
+ - lib/ruby_event_store/outbox/sidekiq_processor.rb
67
+ - lib/ruby_event_store/outbox/sidekiq_producer.rb
62
68
  - lib/ruby_event_store/outbox/sidekiq_scheduler.rb
63
69
  - lib/ruby_event_store/outbox/version.rb
64
70
  homepage: https://railseventstore.org
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_record'
4
-
5
- module RubyEventStore
6
- module Outbox
7
- class Record < ::ActiveRecord::Base
8
- self.primary_key = :id
9
- self.table_name = 'event_store_outbox'
10
-
11
- def hash_payload
12
- JSON.parse(payload).deep_symbolize_keys
13
- end
14
- end
15
- end
16
- end