ruby_event_store-outbox 0.0.11 → 0.0.12

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: 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