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 +4 -4
- data/README.md +19 -2
- data/lib/generators/ruby_event_store/outbox/migration_generator.rb +0 -5
- data/lib/generators/ruby_event_store/outbox/templates/create_event_store_outbox_template.rb +8 -0
- data/lib/ruby_event_store/outbox.rb +2 -1
- data/lib/ruby_event_store/outbox/cleanup_strategies/clean_old_enqueued.rb +19 -0
- data/lib/ruby_event_store/outbox/cleanup_strategies/none.rb +13 -0
- data/lib/ruby_event_store/outbox/cli.rb +11 -4
- data/lib/ruby_event_store/outbox/consumer.rb +142 -53
- data/lib/ruby_event_store/outbox/consumer_process.rb +6 -0
- data/lib/ruby_event_store/outbox/fetch_specification.rb +31 -0
- data/lib/ruby_event_store/outbox/metrics/influx.rb +16 -2
- data/lib/ruby_event_store/outbox/metrics/null.rb +3 -0
- data/lib/ruby_event_store/outbox/repository.rb +153 -0
- data/lib/ruby_event_store/outbox/sidekiq_processor.rb +44 -0
- data/lib/ruby_event_store/outbox/sidekiq_producer.rb +30 -0
- data/lib/ruby_event_store/outbox/sidekiq_scheduler.rb +10 -17
- data/lib/ruby_event_store/outbox/version.rb +1 -1
- metadata +13 -7
- data/lib/ruby_event_store/outbox/record.rb +0 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '008b96c4b43e3cbfc23d0aa8c9ebb160f0adbfcfc82a976967f1c56338346cc0'
|
4
|
+
data.tar.gz: e65157c0638ecec0a4967a251e820979d18eb92e55212f107be95c23d5a4315b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
|
@@ -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/
|
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
|
@@ -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
|
-
|
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
|
-
|
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/
|
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.
|
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
|
-
|
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
|
-
@
|
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
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
|
111
|
+
updated_record_ids = []
|
112
|
+
batch.each do |record|
|
91
113
|
begin
|
92
|
-
|
93
|
-
|
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.
|
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
|
-
|
101
|
-
|
102
|
-
|
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
|
-
|
135
|
+
|
136
|
+
refresh_successful = refresh_lock_for_process(obtained_lock)
|
137
|
+
break unless refresh_successful
|
106
138
|
end
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
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, :
|
119
|
-
|
120
|
-
def
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
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,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
|
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
|
-
|
40
|
+
format: format,
|
41
|
+
split_key: split_key,
|
28
42
|
}
|
29
43
|
})
|
30
44
|
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
|
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
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
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.
|
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:
|
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: '
|
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: '
|
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/
|
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
|