ruby_event_store-outbox 0.0.11 → 0.0.12

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: e6e7bf65a4d5106581d89918d0bb21bf6f19ee4e08a6f525d27665b41c07b3b0
4
- data.tar.gz: eb1350270156bfd886bc11b94dfe858beda55692aa9c72832ca23fcadafe5fc0
3
+ metadata.gz: c1d9945d7b34ce9254c8150dc671453cdf3c72a5799bc39b11f1e86e109baa8c
4
+ data.tar.gz: 58fb2365dd712495a8681fb78ed444662422cfddf3b2b165fda5e4299082dc91
5
5
  SHA512:
6
- metadata.gz: eda134f0e4a489546c9b05fd541d27c83f24bdac1e60da244398df09e3b913c650180b0c3472d5d6c0afd502870f025de01d0e94389c90cd491c7504e9693697
7
- data.tar.gz: c73fcda82e6635636ae1eccbaeb3c52d4b2c438a7a55e075fccc254d18e118b5ea79266b48926074ec38bdf51b2b81cd787c6f7a61bf6facb5086ab1a5dd166d
6
+ metadata.gz: 01fbff71356a4095838f197b96e9673762d02f9677d0d0df3075757861c273131b636b6e181db0ce165fa7d04f2d45b6450e4cd19a49261eb6f9e87d6782b8f7
7
+ data.tar.gz: 53bea6ab8e0e3c2bfad41018fbc65d7f2de33af168b05d5578cf6349a56614be4c4e00448b3954ed59ca42f637908e635701f5c2eb2ef7d4958f1078c8eac18d
@@ -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,6 +5,7 @@ module RubyEventStore
5
5
  end
6
6
  end
7
7
 
8
+ require_relative 'outbox/fetch_specification'
8
9
  require_relative 'outbox/record'
9
10
  require_relative 'outbox/sidekiq_scheduler'
10
11
  require_relative 'outbox/consumer'
@@ -59,7 +59,8 @@ module RubyEventStore
59
59
  end
60
60
 
61
61
  def build_consumer(options)
62
- logger = Logger.new(STDOUT, level: options.log_level, progname: "RES-Outbox")
62
+ consumer_uuid = SecureRandom.uuid
63
+ logger = Logger.new(STDOUT, level: options.log_level, progname: "RES-Outbox #{consumer_uuid}")
63
64
  consumer_configuration = Consumer::Configuration.new(
64
65
  split_keys: options.split_keys,
65
66
  message_format: options.message_format,
@@ -69,6 +70,7 @@ module RubyEventStore
69
70
  )
70
71
  metrics = Metrics.from_url(options.metrics_url)
71
72
  outbox_consumer = RubyEventStore::Outbox::Consumer.new(
73
+ consumer_uuid,
72
74
  options,
73
75
  logger: logger,
74
76
  metrics: metrics,
@@ -3,11 +3,13 @@ require "redis"
3
3
  require "active_record"
4
4
  require "ruby_event_store/outbox/record"
5
5
  require "ruby_event_store/outbox/sidekiq5_format"
6
+ require "ruby_event_store/outbox/sidekiq_processor"
6
7
 
7
8
  module RubyEventStore
8
9
  module Outbox
9
10
  class Consumer
10
11
  SLEEP_TIME_WHEN_NOTHING_TO_DO = 0.1
12
+ MAXIMUM_BATCH_FETCHES_IN_ONE_LOCK = 10
11
13
 
12
14
  class Configuration
13
15
  def initialize(
@@ -38,28 +40,26 @@ module RubyEventStore
38
40
  attr_reader :split_keys, :message_format, :batch_size, :database_url, :redis_url
39
41
  end
40
42
 
41
- def initialize(configuration, clock: Time, logger:, metrics:)
43
+ def initialize(consumer_uuid, configuration, clock: Time, logger:, metrics:)
42
44
  @split_keys = configuration.split_keys
43
45
  @clock = clock
44
- @redis = Redis.new(url: configuration.redis_url)
45
46
  @logger = logger
46
47
  @metrics = metrics
47
48
  @batch_size = configuration.batch_size
49
+ @consumer_uuid = consumer_uuid
48
50
  ActiveRecord::Base.establish_connection(configuration.database_url) unless ActiveRecord::Base.connected?
49
51
  if ActiveRecord::Base.connection.adapter_name == "Mysql2"
50
- ActiveRecord::Base.connection.execute("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;")
51
52
  ActiveRecord::Base.connection.execute("SET SESSION innodb_lock_wait_timeout = 1;")
52
53
  end
53
54
 
54
55
  raise "Unknown format" if configuration.message_format != SIDEKIQ5_FORMAT
55
- @message_format = SIDEKIQ5_FORMAT
56
+ @processor = SidekiqProcessor.new(Redis.new(url: configuration.redis_url))
56
57
 
57
58
  @gracefully_shutting_down = false
58
59
  prepare_traps
59
60
  end
60
61
 
61
62
  def init
62
- @redis.sadd("queues", split_keys)
63
63
  logger.info("Initiated RubyEventStore::Outbox v#{VERSION}")
64
64
  logger.info("Handling split keys: #{split_keys ? split_keys.join(", ") : "(all of them)"}")
65
65
  end
@@ -76,62 +76,129 @@ module RubyEventStore
76
76
  end
77
77
 
78
78
  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
79
+ remaining_split_keys = @split_keys.dup
80
+
81
+ was_something_changed = false
82
+ while (split_key = remaining_split_keys.shift)
83
+ was_something_changed |= handle_split(FetchSpecification.new(processor.message_format, split_key))
84
+ end
85
+ was_something_changed
86
+ end
87
+
88
+ def handle_split(fetch_specification)
89
+ obtained_lock = obtain_lock_for_process(fetch_specification)
90
+ return false unless obtained_lock
91
+
92
+ something_processed = false
93
+
94
+ MAXIMUM_BATCH_FETCHES_IN_ONE_LOCK.times do
95
+ batch = retrieve_batch(fetch_specification)
96
+ if batch.empty?
97
+ break
86
98
  end
87
99
 
88
- now = @clock.now.utc
89
100
  failed_record_ids = []
90
- records.group_by(&:split_key).each do |split_key, records2|
101
+ updated_record_ids = []
102
+ batch.each do |record|
91
103
  begin
92
- failed = handle_group_of_records(now, split_key, records2)
93
- failed_record_ids.concat(failed.map(&:id))
104
+ now = @clock.now.utc
105
+ processor.process(record, now)
106
+
107
+ record.update_column(:enqueued_at, now)
108
+ something_processed |= true
109
+ updated_record_ids << record.id
94
110
  rescue => e
95
- failed_record_ids.concat(records2.map(&:id))
111
+ failed_record_ids << record.id
96
112
  e.full_message.split($/).each {|line| logger.error(line) }
97
113
  end
98
114
  end
99
115
 
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)
116
+ metrics.write_point_queue(
117
+ enqueued: updated_record_ids.size,
118
+ failed: failed_record_ids.size,
119
+ format: fetch_specification.message_format,
120
+ split_key: fetch_specification.split_key,
121
+ remaining: get_remaining_count(fetch_specification)
122
+ )
103
123
 
104
124
  logger.info "Sent #{updated_record_ids.size} messages from outbox table"
105
- true
125
+
126
+ obtained_lock = refresh_lock_for_process(obtained_lock)
127
+ break unless obtained_lock
106
128
  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
129
+
130
+ metrics.write_point_queue(
131
+ format: fetch_specification.message_format,
132
+ split_key: fetch_specification.split_key,
133
+ remaining: get_remaining_count(fetch_specification)
134
+ ) unless something_processed
135
+
136
+ release_lock_for_process(fetch_specification)
137
+
138
+ processor.after_batch
139
+
140
+ something_processed
115
141
  end
116
142
 
117
143
  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
144
+ attr_reader :split_keys, :logger, :batch_size, :metrics, :processor, :consumer_uuid
145
+
146
+ def obtain_lock_for_process(fetch_specification)
147
+ result = Lock.obtain(fetch_specification, consumer_uuid, clock: @clock)
148
+ case result
149
+ when :deadlocked
150
+ logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
151
+ metrics.write_operation_result("obtain", "deadlocked")
152
+ return false
153
+ when :lock_timeout
154
+ logger.warn "Obtaining lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
155
+ metrics.write_operation_result("obtain", "lock_timeout")
156
+ return false
157
+ when :taken
158
+ logger.debug "Obtaining lock for split_key '#{fetch_specification.split_key}' unsuccessful (taken)"
159
+ metrics.write_operation_result("obtain", "taken")
160
+ return false
161
+ else
162
+ return result
163
+ end
164
+ end
165
+
166
+ def release_lock_for_process(fetch_specification)
167
+ result = Lock.release(fetch_specification, consumer_uuid)
168
+ case result
169
+ when :ok
170
+ when :deadlocked
171
+ logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (deadlock)"
172
+ metrics.write_operation_result("release", "deadlocked")
173
+ when :lock_timeout
174
+ logger.warn "Releasing lock for split_key '#{fetch_specification.split_key}' failed (lock timeout)"
175
+ metrics.write_operation_result("release", "lock_timeout")
176
+ when :not_taken_by_this_process
177
+ logger.debug "Releasing lock for split_key '#{fetch_specification.split_key}' failed (not taken by this process)"
178
+ metrics.write_operation_result("release", "not_taken_by_this_process")
179
+ else
180
+ raise "Unexpected result #{result}"
181
+ end
182
+ end
183
+
184
+ def refresh_lock_for_process(lock)
185
+ result = lock.refresh(clock: @clock)
186
+ case result
187
+ when :deadlocked
188
+ logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (deadlock)"
189
+ metrics.write_operation_result("refresh", "deadlocked")
190
+ return false
191
+ when :lock_timeout
192
+ logger.warn "Refreshing lock for split_key '#{lock.split_key}' failed (lock timeout)"
193
+ metrics.write_operation_result("refresh", "lock_timeout")
194
+ return false
195
+ when :stolen
196
+ logger.debug "Refreshing lock for split_key '#{lock.split_key}' unsuccessful (stolen)"
197
+ metrics.write_operation_result("refresh", "stolen")
198
+ return false
199
+ else
200
+ return result
132
201
  end
133
- @redis.lpush("queue:#{split_key}", elements)
134
- failed
135
202
  end
136
203
 
137
204
  def prepare_traps
@@ -146,6 +213,14 @@ module RubyEventStore
146
213
  def initiate_graceful_shutdown
147
214
  @gracefully_shutting_down = true
148
215
  end
216
+
217
+ def retrieve_batch(fetch_specification)
218
+ Record.remaining_for(fetch_specification).order("id ASC").limit(batch_size).to_a
219
+ end
220
+
221
+ def get_remaining_count(fetch_specification)
222
+ Record.remaining_for(fetch_specification).count
223
+ end
149
224
  end
150
225
  end
151
226
  end
@@ -0,0 +1,6 @@
1
+ module RubyEventStore
2
+ module Outbox
3
+ class ConsumerProcess
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,13 @@
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
+ end
12
+ end
13
+ 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
@@ -8,9 +8,97 @@ module RubyEventStore
8
8
  self.primary_key = :id
9
9
  self.table_name = 'event_store_outbox'
10
10
 
11
+ def self.remaining_for(fetch_specification)
12
+ where(format: fetch_specification.message_format, split_key: fetch_specification.split_key, enqueued_at: nil)
13
+ end
14
+
11
15
  def hash_payload
12
16
  JSON.parse(payload).deep_symbolize_keys
13
17
  end
18
+
19
+ def enqueued?
20
+ !enqueued_at.nil?
21
+ end
22
+ end
23
+
24
+ class Lock < ::ActiveRecord::Base
25
+ self.primary_key = :split_key
26
+ self.table_name = 'event_store_outbox_locks'
27
+
28
+ def self.obtain(fetch_specification, process_uuid, clock:)
29
+ l = nil
30
+ transaction do
31
+ l = get_lock_record(fetch_specification)
32
+
33
+ return :taken if l.recently_locked?
34
+
35
+ l.update!(
36
+ locked_by: process_uuid,
37
+ locked_at: clock.now,
38
+ )
39
+ end
40
+ l
41
+ rescue ActiveRecord::Deadlocked
42
+ :deadlocked
43
+ rescue ActiveRecord::LockWaitTimeout
44
+ :lock_timeout
45
+ end
46
+
47
+ def refresh(clock:)
48
+ transaction do
49
+ current_process_uuid = locked_by
50
+ lock!
51
+ if locked_by == current_process_uuid
52
+ update!(locked_at: clock.now)
53
+ return self
54
+ else
55
+ return :stolen
56
+ end
57
+ end
58
+ rescue ActiveRecord::Deadlocked
59
+ :deadlocked
60
+ rescue ActiveRecord::LockWaitTimeout
61
+ :lock_timeout
62
+ end
63
+
64
+ def self.release(fetch_specification, process_uuid)
65
+ transaction do
66
+ l = get_lock_record(fetch_specification)
67
+ return :not_taken_by_this_process if !l.locked_by?(process_uuid)
68
+
69
+ l.update!(locked_by: nil, locked_at: nil)
70
+ end
71
+ :ok
72
+ rescue ActiveRecord::Deadlocked
73
+ :deadlocked
74
+ rescue ActiveRecord::LockWaitTimeout
75
+ :lock_timeout
76
+ end
77
+
78
+ def locked_by?(process_uuid)
79
+ locked_by.eql?(process_uuid)
80
+ end
81
+
82
+ def recently_locked?
83
+ locked_by && locked_at > 10.minutes.ago
84
+ end
85
+
86
+ private
87
+ def self.lock_for_split_key(fetch_specification)
88
+ lock.find_by(format: fetch_specification.message_format, split_key: fetch_specification.split_key)
89
+ end
90
+
91
+ def self.get_lock_record(fetch_specification)
92
+ l = lock_for_split_key(fetch_specification)
93
+ if l.nil?
94
+ begin
95
+ l = create!(format: fetch_specification.message_format, split_key: fetch_specification.split_key)
96
+ rescue ActiveRecord::RecordNotUnique
97
+ l = lock_for_split_key(fetch_specification)
98
+ end
99
+ end
100
+ l
101
+ end
14
102
  end
15
103
  end
16
104
  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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+ require "ruby_event_store/outbox/sidekiq5_format"
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
+ Record.create!(
19
+ format: SIDEKIQ5_FORMAT,
20
+ split_key: payload.fetch('queue'),
21
+ payload: payload.to_json
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
27
+ 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
8
+ def initialize
9
+ @sidekiq_producer = SidekiqProducer.new
10
+ end
11
+
9
12
  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
13
+ sidekiq_producer.call(klass, [serialized_event.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.12"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
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.12
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: 2020-08-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_event_store
@@ -53,12 +53,16 @@ files:
53
53
  - lib/ruby_event_store/outbox.rb
54
54
  - lib/ruby_event_store/outbox/cli.rb
55
55
  - lib/ruby_event_store/outbox/consumer.rb
56
+ - lib/ruby_event_store/outbox/consumer_process.rb
57
+ - lib/ruby_event_store/outbox/fetch_specification.rb
56
58
  - lib/ruby_event_store/outbox/metrics.rb
57
59
  - lib/ruby_event_store/outbox/metrics/influx.rb
58
60
  - lib/ruby_event_store/outbox/metrics/null.rb
59
61
  - lib/ruby_event_store/outbox/record.rb
60
62
  - lib/ruby_event_store/outbox/sidekiq5_format.rb
61
63
  - lib/ruby_event_store/outbox/sidekiq_message_handler.rb
64
+ - lib/ruby_event_store/outbox/sidekiq_processor.rb
65
+ - lib/ruby_event_store/outbox/sidekiq_producer.rb
62
66
  - lib/ruby_event_store/outbox/sidekiq_scheduler.rb
63
67
  - lib/ruby_event_store/outbox/version.rb
64
68
  homepage: https://railseventstore.org