tobox 0.4.5 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +105 -47
- data/lib/tobox/cli.rb +7 -1
- data/lib/tobox/configuration.rb +11 -8
- data/lib/tobox/fetcher.rb +78 -81
- data/lib/tobox/plugins/event_grouping.rb +47 -0
- data/lib/tobox/plugins/inbox.rb +46 -0
- data/lib/tobox/plugins/progress.rb +33 -0
- data/lib/tobox/plugins/stats.rb +23 -4
- data/lib/tobox/pool/fiber_pool.rb +50 -10
- data/lib/tobox/pool/threaded_pool.rb +22 -11
- data/lib/tobox/pool.rb +2 -2
- data/lib/tobox/version.rb +1 -1
- data/lib/tobox/worker.rb +1 -1
- data/lib/tobox.rb +3 -1
- metadata +7 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 15d95687a102c98fcc33859b3a369dc9026f04fa58f7f785bcad1d9ccb40010f
|
4
|
+
data.tar.gz: 656b296f9d72d67877945317d4a6d5505de63044f74e08c506970b2c841599f4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 172b54fd340f865dbc26a30266f815fe556424cd150c8205c872da02d3725def786648710097b0880de41cc16940a39bd34c8592049d22a907d527ff84b26a3a
|
7
|
+
data.tar.gz: 1dafbf50e8d18c4eb7f4dfd31539322230bd1d11d403f250e4612a0579e878c366fe6b2d44eb24ae17f32476bd25994457563d3fd5ef1a46d7499dc92a93ee1d
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,29 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.5.1] - 2024-09-26
|
4
|
+
|
5
|
+
### Improvements
|
6
|
+
|
7
|
+
* Refactoring of management of event id which replaces `SELECT id IN (?)` resulting queries with `SELECT id = ?`.
|
8
|
+
* Process shutdown is now more predictable, also in the grace period.
|
9
|
+
* `grace_shutdown_timeout` is a new configuration, by default 5 (seconds).
|
10
|
+
|
11
|
+
## [0.5.0] - 2024-09-16
|
12
|
+
|
13
|
+
### Features
|
14
|
+
|
15
|
+
A new `:progress` plugin can be used in order to release database transactions before handling events (useful for when event handling times vary and may cause transaction bookkeeping overhead in the database).
|
16
|
+
|
17
|
+
**Note**: This may become the default behaviour in a future release.
|
18
|
+
|
19
|
+
### Improvements
|
20
|
+
|
21
|
+
* The event grouping and inbox capabilities were converted into plugins (respectively, `:event_grouping` and `:inbox`).
|
22
|
+
|
23
|
+
### Bugfixes
|
24
|
+
|
25
|
+
* exponential backoff calculation was broken.
|
26
|
+
* behaviour fixed for databases which do not support `ON CONFLICT` or `UPDATE ... RETURNING` (like MySQL).
|
3
27
|
|
4
28
|
## [0.4.5] - 2024-02-28
|
5
29
|
|
data/README.md
CHANGED
@@ -13,10 +13,10 @@ Simple, data-first events processing framework based on the [transactional outbo
|
|
13
13
|
- [Usage](#usage)
|
14
14
|
- [Configuration](#configuration)
|
15
15
|
- [Event](#event)
|
16
|
-
- [Features](#features)
|
17
|
-
- [Ordered event processing](#ordered-event-processing)
|
18
|
-
- [Inbox](#inbox)
|
19
16
|
- [Plugins](#plugins)
|
17
|
+
- [Progress](#progress)
|
18
|
+
- [Event Grouping](#event-grouping)
|
19
|
+
- [Inbox](#inbox)
|
20
20
|
- [Zeitwerk](#zeitwerk)
|
21
21
|
- [Sentry](#sentry)
|
22
22
|
- [Datadog](#datadog)
|
@@ -204,7 +204,17 @@ table :outbox
|
|
204
204
|
Maximum number of times a failed attempt to process an event will be retried (`10` by default).
|
205
205
|
|
206
206
|
```ruby
|
207
|
-
|
207
|
+
max_attempts 4
|
208
|
+
```
|
209
|
+
|
210
|
+
**Note**: the new attempt will be retried in `n ** 4`, where `n` is the number of past attempts for that event.
|
211
|
+
|
212
|
+
### `exponential_retry_factor`
|
213
|
+
|
214
|
+
Factor by which the number of seconds until an event can be retried will be exponentially calculated, i.e. 2 seconds on first attempt, then 4, then 8, then 16 (`2` by default).
|
215
|
+
|
216
|
+
```ruby
|
217
|
+
exponential_retry_factor 2
|
208
218
|
```
|
209
219
|
|
210
220
|
**Note**: the new attempt will be retried in `n ** 4`, where `n` is the number of past attempts for that event.
|
@@ -231,6 +241,10 @@ Time (in seconds) to wait before checking again for events in the outbox.
|
|
231
241
|
|
232
242
|
Time (in seconds) to wait for events to finishing processing, before hard-killing the process.
|
233
243
|
|
244
|
+
### `grace_shutdown_timeout`
|
245
|
+
|
246
|
+
Grace period (in seconds) to wait after, hard-killing the work in progress, and before exiting the process.
|
247
|
+
|
234
248
|
### `on(event_type) { |before, after| }`
|
235
249
|
|
236
250
|
callback executed when processing an event of the given type. By default, it'll yield the state of data before and after the event (unless `message_to_arguments` is set).
|
@@ -320,17 +334,6 @@ Overrides the internal logger (an instance of `Logger`).
|
|
320
334
|
|
321
335
|
Overrides the default log level ("info" when in "production" environment, "debug" otherwise).
|
322
336
|
|
323
|
-
### group_column
|
324
|
-
|
325
|
-
Defines the column to be used for event grouping, when [ordered processing of events is a requirement](#ordered-event-processing).
|
326
|
-
|
327
|
-
### inbox table
|
328
|
-
|
329
|
-
Defines the name of the table to be used for inbox, when [inbox usage is a requirement](#inbox).
|
330
|
-
|
331
|
-
### inbox column
|
332
|
-
|
333
|
-
Defines the column in the outbox table which references the inbox table, when one is set.
|
334
337
|
|
335
338
|
<a id="markdown-event" name="event"></a>
|
336
339
|
## Event
|
@@ -345,36 +348,85 @@ The event is composed of the following properties:
|
|
345
348
|
|
346
349
|
(*NOTE*: The event is also composed of other properties which are only relevant for `tobox`.)
|
347
350
|
|
348
|
-
<a id="markdown-features" name="features"></a>
|
349
|
-
## Features
|
350
351
|
|
351
|
-
|
352
|
+
<a id="markdown-plugins" name="plugins"></a>
|
353
|
+
## Plugins
|
354
|
+
|
355
|
+
`tobox` ships with a very simple plugin system. (TODO: add docs).
|
356
|
+
|
357
|
+
Plugins can be loaded in the config via `plugin`:
|
358
|
+
|
359
|
+
```ruby
|
360
|
+
# tobox.rb
|
361
|
+
plugin(:plugin_name)
|
362
|
+
```
|
363
|
+
|
364
|
+
<a id="markdown-progress" name="progress"></a>
|
365
|
+
### Progress
|
366
|
+
|
367
|
+
By default, the database transaction used to consume the event is kept open while the event is handled. While this ensures atomic event consumption, it may also cause overhead related to transaction management given enough load, particularly in cases where event handling time varies (i.e. throttled HTTP requests).
|
368
|
+
|
369
|
+
The `:progress` plugin fixes this by releasing the databaase transaction after fetching the event. It does so by making the fetched event "invisible" for a certain period, during which the event must be successfully handled.
|
370
|
+
|
371
|
+
Here's how to use it:
|
372
|
+
|
373
|
+
```ruby
|
374
|
+
# in your tobox.rb
|
375
|
+
plugin :progress
|
376
|
+
|
377
|
+
visibility_timeout 90 # default: 30
|
378
|
+
```
|
379
|
+
|
380
|
+
3. insert related outbox events with the same group id
|
381
|
+
|
382
|
+
```ruby
|
383
|
+
order = Order.new(
|
384
|
+
item_id: item.id,
|
385
|
+
price: 20_20,
|
386
|
+
currency: "EUR"
|
387
|
+
)
|
388
|
+
DB.transaction do
|
389
|
+
order.save
|
390
|
+
DB[:outbox].insert(event_type: "order_created", group_id: order.id, data_after: order.to_hash)
|
391
|
+
DB[:outbox].insert(event_type: "billing_event_started", group_id: order.id, data_after: order.to_hash)
|
392
|
+
end
|
393
|
+
|
394
|
+
# "order_created" will be processed first
|
395
|
+
# "billing_event_created" will only start processing once "order_created" finishes
|
396
|
+
```
|
397
|
+
|
398
|
+
#### Configuration
|
399
|
+
|
400
|
+
##### `visibility_timeout`
|
401
|
+
|
402
|
+
Timeout (in seconds) after which a previously marked-for-consumption event can be retried (default: `30`)
|
352
403
|
|
353
|
-
<a id="markdown-
|
354
|
-
###
|
404
|
+
<a id="markdown-event-grouping" name="event-grouping"></a>
|
405
|
+
### Event grouping
|
355
406
|
|
356
407
|
By default, events are taken and processed from the "outbox" table concurrently by workers, which means that, while worker A may process the most recent event, and worker B takes the following, worker B may process it faster than worker A. This may be an issue if the consumer expects events from a certain context to arrive in a certain order.
|
357
408
|
|
358
|
-
One solution is to have a single worker processing the "outbox" events. Another is to use the `
|
409
|
+
One solution is to have a single worker processing the "outbox" events. Another is to use the `:event_grouping` plugin.
|
359
410
|
|
360
|
-
|
411
|
+
All you have to do is:
|
361
412
|
|
362
413
|
1. add a "group id" column to the "outbox" table
|
363
414
|
|
364
415
|
```ruby
|
365
416
|
create_table(:outbox) do
|
366
417
|
primary_key :id
|
367
|
-
column :group_id, :integer
|
368
|
-
# The type is irrelevant, could also be :string, :uuid...
|
418
|
+
column :group_id, :integer # The type is irrelevant, could also be :string, :uuid...
|
369
419
|
# ..
|
420
|
+
index :group_id
|
370
421
|
```
|
371
422
|
|
372
|
-
2.
|
423
|
+
2. Enable the plugin
|
373
424
|
|
374
425
|
```ruby
|
375
426
|
# in your tobox.rb
|
376
|
-
|
377
|
-
|
427
|
+
plugin :event_grouping
|
428
|
+
|
429
|
+
group_column :group_id # by default already `:group_id`
|
378
430
|
```
|
379
431
|
|
380
432
|
3. insert related outbox events with the same group id
|
@@ -394,25 +446,28 @@ end
|
|
394
446
|
# "order_created" will be processed first
|
395
447
|
# "billing_event_created" will only start processing once "order_created" finishes
|
396
448
|
```
|
449
|
+
|
450
|
+
#### Configuration
|
451
|
+
|
452
|
+
##### `group_column`
|
453
|
+
|
454
|
+
Defines the database column to be used for event grouping (`:group_id` by default).
|
455
|
+
|
397
456
|
<a id="inbox" name="inbox"></a>
|
398
457
|
### Inbox
|
399
458
|
|
400
|
-
`tobox` also supports the [inbox pattern](https://event-driven.io/en/outbox_inbox_patterns_and_delivery_guarantees_explained/), to ensure "exactly-once" processing of events. This is achieved by "tagging" events with a unique identifier, and registering them in the inbox before processing (and if they're there, ignoring it altogether).
|
459
|
+
Via the `:inbox` plugin, `tobox` also supports the [inbox pattern](https://event-driven.io/en/outbox_inbox_patterns_and_delivery_guarantees_explained/), to ensure "exactly-once" processing of events. This is achieved by "tagging" events with a unique identifier, and registering them in the inbox before processing (and if they're there, ignoring it altogether).
|
401
460
|
|
402
461
|
In order to do so, you'll have to:
|
403
462
|
|
404
|
-
1. add an "inbox" table in the database
|
463
|
+
1. add an "inbox" table in the database and the unique id reference in the outbox table:
|
405
464
|
|
406
465
|
```ruby
|
407
466
|
create_table(:inbox) do
|
408
467
|
column :inbox_id, :varchar, null: true, primary_key: true # it can also be a uuid, you decide
|
409
468
|
column :created_at, "timestamp without time zone", null: false, default: Sequel::CURRENT_TIMESTAMP
|
410
469
|
end
|
411
|
-
```
|
412
|
-
|
413
|
-
2. add the unique id reference in the outbox table:
|
414
470
|
|
415
|
-
```ruby
|
416
471
|
create_table(:outbox) do
|
417
472
|
primary_key :id
|
418
473
|
column :type, :varchar, null: false
|
@@ -421,15 +476,17 @@ create_table(:outbox) do
|
|
421
476
|
foreign_key :inbox_id, :inbox
|
422
477
|
```
|
423
478
|
|
424
|
-
|
479
|
+
2. Load the plugin and reference them in the configuration
|
425
480
|
|
426
481
|
```ruby
|
427
482
|
# tobox.rb
|
428
|
-
|
429
|
-
|
483
|
+
plugin :inbox
|
484
|
+
|
485
|
+
inbox_table :inbox # :inbox by default already
|
486
|
+
inbox_column :inbox_id # :inbox_id by default already
|
430
487
|
```
|
431
488
|
|
432
|
-
|
489
|
+
3. insert related outbox events with an inbox id
|
433
490
|
|
434
491
|
```ruby
|
435
492
|
order = Order.new(
|
@@ -446,19 +503,19 @@ end
|
|
446
503
|
# assuming this bit above runs two times in two separate workers, each will be processed by tobox only once.
|
447
504
|
```
|
448
505
|
|
449
|
-
|
506
|
+
#### Configuration
|
450
507
|
|
451
|
-
<a id="markdown-plugins" name="plugins"></a>
|
452
|
-
## Plugins
|
453
508
|
|
454
|
-
|
509
|
+
##### inbox table
|
455
510
|
|
456
|
-
|
511
|
+
Defines the name of the table to be used for inbox (`:inbox` by default).
|
457
512
|
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
513
|
+
##### inbox column
|
514
|
+
|
515
|
+
Defines the column in the outbox table which references the inbox table (`:inbox_id` by default).
|
516
|
+
|
517
|
+
|
518
|
+
**NOTE**: make sure you keep cleaning the inbox periodically from older messages, once there's no more danger of receiving them again.
|
462
519
|
|
463
520
|
It ships with the following integrations.
|
464
521
|
|
@@ -530,6 +587,7 @@ on_stats(5) do |stats_collector| # every 5 seconds
|
|
530
587
|
# now you can send them to your statsd collector
|
531
588
|
#
|
532
589
|
StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
|
590
|
+
StatsD.gauge('outbox_oldest_message_age', stats[:oldest_event_age_in_seconds])
|
533
591
|
end
|
534
592
|
```
|
535
593
|
|
@@ -576,7 +634,7 @@ end
|
|
576
634
|
<a id="markdown-supported-rubies" name="supported-rubies"></a>
|
577
635
|
## Supported Rubies
|
578
636
|
|
579
|
-
All Rubies greater or equal to 2.
|
637
|
+
All Rubies greater or equal to 2.7, and always latest JRuby and Truffleruby.
|
580
638
|
|
581
639
|
|
582
640
|
<a id="markdown-rails-support" name="rails-support"></a>
|
data/lib/tobox/cli.rb
CHANGED
@@ -108,12 +108,18 @@ module Tobox
|
|
108
108
|
opts[:tag] = arg
|
109
109
|
end
|
110
110
|
|
111
|
-
o.on "-t", "--shutdown-timeout NUM",
|
111
|
+
o.on "-t", "--shutdown-timeout NUM", Float, "Shutdown timeout (in seconds)" do |arg|
|
112
112
|
raise ArgumentError, "must be positive" unless arg.positive?
|
113
113
|
|
114
114
|
opts[:shutdown_timeout] = arg
|
115
115
|
end
|
116
116
|
|
117
|
+
o.on "-g", "--shutdown-grace-timeout NUM", Float, "Shutdown grace timeout (in seconds)" do |arg|
|
118
|
+
raise ArgumentError, "must be positive" unless arg.positive?
|
119
|
+
|
120
|
+
opts[:grace_shutdown_timeout] = arg
|
121
|
+
end
|
122
|
+
|
117
123
|
o.on "--verbose", "Print more verbose output" do |arg|
|
118
124
|
opts[:verbose] = arg
|
119
125
|
end
|
data/lib/tobox/configuration.rb
CHANGED
@@ -7,7 +7,8 @@ module Tobox
|
|
7
7
|
class Configuration
|
8
8
|
extend Forwardable
|
9
9
|
|
10
|
-
attr_reader :handlers, :lifecycle_events, :arguments_handler, :default_logger, :database
|
10
|
+
attr_reader :plugins, :handlers, :lifecycle_events, :arguments_handler, :default_logger, :database, :fetcher_class,
|
11
|
+
:config
|
11
12
|
|
12
13
|
def_delegator :@config, :[]
|
13
14
|
|
@@ -18,13 +19,12 @@ module Tobox
|
|
18
19
|
database_uri: nil,
|
19
20
|
database_options: nil,
|
20
21
|
table: :outbox,
|
21
|
-
|
22
|
-
inbox_table: nil,
|
23
|
-
inbox_column: nil,
|
22
|
+
created_at_column: nil,
|
24
23
|
max_attempts: 10,
|
25
|
-
exponential_retry_factor:
|
24
|
+
exponential_retry_factor: 2,
|
26
25
|
wait_for_events_delay: 5,
|
27
26
|
shutdown_timeout: 10,
|
27
|
+
grace_shutdown_timeout: 5,
|
28
28
|
concurrency: 4, # TODO: CPU count
|
29
29
|
worker: :thread
|
30
30
|
}.freeze
|
@@ -45,6 +45,7 @@ module Tobox
|
|
45
45
|
@handlers = {}
|
46
46
|
@message_to_arguments = nil
|
47
47
|
@plugins = []
|
48
|
+
@fetcher_class = Class.new(Fetcher)
|
48
49
|
|
49
50
|
if block
|
50
51
|
case block.arity
|
@@ -141,6 +142,8 @@ module Tobox
|
|
141
142
|
|
142
143
|
extend(plugin::ConfigurationMethods) if defined?(plugin::ConfigurationMethods)
|
143
144
|
|
145
|
+
@fetcher_class.__send__(:include, plugin::FetcherMethods) if defined?(plugin::FetcherMethods)
|
146
|
+
|
144
147
|
plugin.configure(self, **options, &block) if plugin.respond_to?(:configure)
|
145
148
|
end
|
146
149
|
|
@@ -157,7 +160,7 @@ module Tobox
|
|
157
160
|
private
|
158
161
|
|
159
162
|
def method_missing(meth, *args, &block)
|
160
|
-
if
|
163
|
+
if @config.key?(meth) && args.size == 1
|
161
164
|
@config[meth] = args.first
|
162
165
|
elsif /\Aon_(.*)\z/.match(meth) && args.empty?
|
163
166
|
on(Regexp.last_match(1).to_sym, &block)
|
@@ -167,8 +170,8 @@ module Tobox
|
|
167
170
|
end
|
168
171
|
|
169
172
|
def respond_to_missing?(meth, *args)
|
170
|
-
super
|
171
|
-
|
173
|
+
super ||
|
174
|
+
@config.key?(meth) ||
|
172
175
|
/\Aon_(.*)\z/.match(meth)
|
173
176
|
end
|
174
177
|
end
|
data/lib/tobox/fetcher.rb
CHANGED
@@ -13,14 +13,10 @@ module Tobox
|
|
13
13
|
@db = configuration.database
|
14
14
|
|
15
15
|
@table = configuration[:table]
|
16
|
-
@group_column = configuration[:group_column]
|
17
16
|
@exponential_retry_factor = configuration[:exponential_retry_factor]
|
18
17
|
|
19
18
|
max_attempts = configuration[:max_attempts]
|
20
19
|
|
21
|
-
@inbox_table = configuration[:inbox_table]
|
22
|
-
@inbox_column = configuration[:inbox_column]
|
23
|
-
|
24
20
|
@ds = @db[@table]
|
25
21
|
|
26
22
|
run_at_conds = [
|
@@ -32,6 +28,8 @@ module Tobox
|
|
32
28
|
.where(run_at_conds)
|
33
29
|
.order(Sequel.desc(:run_at, nulls: :first), :id)
|
34
30
|
|
31
|
+
@mark_as_fetched_params = { attempts: Sequel[@table][:attempts] + 1, last_error: nil }
|
32
|
+
|
35
33
|
@before_event_handlers = Array(@configuration.lifecycle_events[:before_event])
|
36
34
|
@after_event_handlers = Array(@configuration.lifecycle_events[:after_event])
|
37
35
|
@error_event_handlers = Array(@configuration.lifecycle_events[:error_event])
|
@@ -39,73 +37,19 @@ module Tobox
|
|
39
37
|
|
40
38
|
def fetch_events(&blk)
|
41
39
|
num_events = 0
|
42
|
-
|
43
|
-
|
44
|
-
group = @pick_next_sql.for_update
|
45
|
-
.skip_locked
|
46
|
-
.limit(1)
|
47
|
-
.select(@group_column)
|
48
|
-
|
49
|
-
# get total from a group, to compare to the number of future locked rows.
|
50
|
-
total_from_group = @ds.where(@group_column => group).count
|
51
|
-
|
52
|
-
event_ids = @ds.where(@group_column => group)
|
53
|
-
.order(Sequel.desc(:run_at, nulls: :first), :id)
|
54
|
-
.for_update.skip_locked.select_map(:id)
|
55
|
-
|
56
|
-
if event_ids.size != total_from_group
|
57
|
-
# this happens if concurrent workers locked different rows from the same group,
|
58
|
-
# or when new rows from a given group have been inserted after the lock has been
|
59
|
-
# acquired
|
60
|
-
event_ids = []
|
61
|
-
end
|
62
|
-
|
63
|
-
# lock all, process 1
|
64
|
-
event_ids = event_ids[0, 1]
|
65
|
-
else
|
66
|
-
event_ids = @pick_next_sql.for_update
|
67
|
-
.skip_locked
|
68
|
-
.limit(1).select_map(:id) # lock starts here
|
69
|
-
end
|
40
|
+
events_tr do
|
41
|
+
event_id = nil
|
70
42
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
@db.transaction(savepoint: true) do
|
75
|
-
events = @ds.where(id: event_ids).returning.delete
|
76
|
-
|
77
|
-
if blk
|
78
|
-
num_events = events.size
|
79
|
-
|
80
|
-
events.map! do |ev|
|
81
|
-
try_insert_inbox(ev) do
|
82
|
-
ev[:metadata] = try_json_parse(ev[:metadata])
|
83
|
-
handle_before_event(ev)
|
84
|
-
yield(to_message(ev))
|
85
|
-
ev
|
86
|
-
end
|
87
|
-
rescue StandardError => e
|
88
|
-
error = e
|
89
|
-
raise Sequel::Rollback
|
90
|
-
end.compact!
|
91
|
-
else
|
92
|
-
events.map!(&method(:to_message))
|
93
|
-
end
|
94
|
-
end
|
43
|
+
event_id_tr do
|
44
|
+
event_id = fetch_event_id
|
45
|
+
mark_as_fetched(event_id) if event_id
|
95
46
|
end
|
96
47
|
|
97
|
-
|
98
|
-
|
99
|
-
|
48
|
+
if event_id
|
49
|
+
with_event(event_id) do |event|
|
50
|
+
num_events = 1
|
100
51
|
|
101
|
-
|
102
|
-
events.each do |event|
|
103
|
-
if error
|
104
|
-
event.merge!(mark_as_error(event, error))
|
105
|
-
handle_error_event(event, error)
|
106
|
-
else
|
107
|
-
handle_after_event(event)
|
108
|
-
end
|
52
|
+
prepare_event(event, &blk)
|
109
53
|
end
|
110
54
|
end
|
111
55
|
end
|
@@ -115,19 +59,82 @@ module Tobox
|
|
115
59
|
|
116
60
|
private
|
117
61
|
|
62
|
+
def prepare_event(event)
|
63
|
+
event[:metadata] = try_json_parse(event[:metadata])
|
64
|
+
handle_before_event(event)
|
65
|
+
yield(to_message(event))
|
66
|
+
end
|
67
|
+
|
68
|
+
def fetch_event_id
|
69
|
+
@pick_next_sql.for_update
|
70
|
+
.skip_locked
|
71
|
+
.limit(1).select_map(:id).first # lock starts here
|
72
|
+
end
|
73
|
+
|
74
|
+
def mark_as_fetched(event_id)
|
75
|
+
@ds.where(id: event_id).update(@mark_as_fetched_params)
|
76
|
+
end
|
77
|
+
|
78
|
+
def events_tr(&block)
|
79
|
+
@db.transaction(savepoint: false, &block)
|
80
|
+
end
|
81
|
+
|
82
|
+
def event_id_tr
|
83
|
+
yield
|
84
|
+
end
|
85
|
+
|
86
|
+
def with_event(event_id, &blk)
|
87
|
+
event, error = yield_event(event_id, &blk)
|
88
|
+
|
89
|
+
if error
|
90
|
+
event.merge!(mark_as_error(event, error))
|
91
|
+
handle_error_event(event, error)
|
92
|
+
else
|
93
|
+
handle_after_event(event)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def yield_event(event_id)
|
98
|
+
events_ds = @ds.where(id: event_id)
|
99
|
+
event = error = nil
|
100
|
+
|
101
|
+
begin
|
102
|
+
event = events_ds.first
|
103
|
+
|
104
|
+
yield event
|
105
|
+
|
106
|
+
events_ds.delete
|
107
|
+
rescue StandardError => e
|
108
|
+
error = e
|
109
|
+
end
|
110
|
+
|
111
|
+
[event, error]
|
112
|
+
end
|
113
|
+
|
118
114
|
def log_message(msg)
|
119
115
|
"(worker: #{@label}) -> #{msg}"
|
120
116
|
end
|
121
117
|
|
122
118
|
def mark_as_error(event, error)
|
123
|
-
|
124
|
-
attempts: Sequel[@table][:attempts] + 1,
|
119
|
+
update_params = {
|
125
120
|
run_at: Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
|
126
|
-
seconds: event[:attempts]
|
121
|
+
seconds: @exponential_retry_factor**(event[:attempts] - 1)),
|
127
122
|
# run_at: Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
|
128
123
|
# seconds: Sequel.function(:POWER, Sequel[@table][:attempts] + 1, 4)),
|
129
124
|
last_error: "#{error.message}\n#{error.backtrace.join("\n")}"
|
130
|
-
|
125
|
+
}
|
126
|
+
|
127
|
+
set_event_retry_attempts(event, update_params)
|
128
|
+
end
|
129
|
+
|
130
|
+
def set_event_retry_attempts(event, update_params)
|
131
|
+
ds = @ds.where(id: event[:id])
|
132
|
+
if @ds.supports_returning?(:update)
|
133
|
+
ds.returning.update(update_params).first
|
134
|
+
else
|
135
|
+
ds.update(update_params)
|
136
|
+
ds.first
|
137
|
+
end
|
131
138
|
end
|
132
139
|
|
133
140
|
def to_message(event)
|
@@ -148,16 +155,6 @@ module Tobox
|
|
148
155
|
data
|
149
156
|
end
|
150
157
|
|
151
|
-
def try_insert_inbox(event)
|
152
|
-
return yield unless @inbox_table && @inbox_column
|
153
|
-
|
154
|
-
ret = @db[@inbox_table].insert_conflict.insert(@inbox_column => event[@inbox_column])
|
155
|
-
|
156
|
-
return unless ret
|
157
|
-
|
158
|
-
yield
|
159
|
-
end
|
160
|
-
|
161
158
|
def handle_before_event(event)
|
162
159
|
@logger.debug do
|
163
160
|
log_message("outbox event (type: \"#{event[:type]}\", attempts: #{event[:attempts]}) starting...")
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tobox
|
4
|
+
module Plugins
|
5
|
+
module EventGrouping
|
6
|
+
def self.configure(conf)
|
7
|
+
conf.config[:group_column] = :group_id
|
8
|
+
end
|
9
|
+
|
10
|
+
module FetcherMethods
|
11
|
+
def initialize(_, configuration)
|
12
|
+
super
|
13
|
+
|
14
|
+
@group_column = configuration[:group_column]
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def fetch_event_id
|
20
|
+
group = @pick_next_sql.for_update
|
21
|
+
.skip_locked
|
22
|
+
.limit(1)
|
23
|
+
.select(@group_column)
|
24
|
+
|
25
|
+
# get total from a group, to compare to the number of future locked rows.
|
26
|
+
total_from_group = @ds.where(@group_column => group).count
|
27
|
+
|
28
|
+
event_ids = @ds.where(@group_column => group)
|
29
|
+
.order(Sequel.desc(:run_at, nulls: :first), :id)
|
30
|
+
.for_update.skip_locked.select_map(:id)
|
31
|
+
|
32
|
+
if event_ids.size != total_from_group
|
33
|
+
# this happens if concurrent workers locked different rows from the same group,
|
34
|
+
# or when new rows from a given group have been inserted after the lock has been
|
35
|
+
# acquired
|
36
|
+
event_ids = []
|
37
|
+
end
|
38
|
+
|
39
|
+
# lock all, process 1
|
40
|
+
event_ids.first
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
register_plugin :event_grouping, EventGrouping
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tobox
|
4
|
+
module Plugins
|
5
|
+
module Inbox
|
6
|
+
def self.configure(conf)
|
7
|
+
conf.config[:inbox_table] = :inbox
|
8
|
+
conf.config[:inbox_column] = :unique_id
|
9
|
+
end
|
10
|
+
|
11
|
+
module FetcherMethods
|
12
|
+
def initialize(_, configuration)
|
13
|
+
super
|
14
|
+
|
15
|
+
inbox_table = configuration[:inbox_table]
|
16
|
+
|
17
|
+
@inbox_ds = @db[inbox_table]
|
18
|
+
@inbox_column = configuration[:inbox_column]
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def prepare_event(event, &blk)
|
24
|
+
try_insert_inbox(event) { super }
|
25
|
+
end
|
26
|
+
|
27
|
+
def try_insert_inbox(event)
|
28
|
+
if @inbox_ds.respond_to?(:supports_insert_conflict?) && @inbox_ds.supports_insert_conflict?
|
29
|
+
ret = @inbox_ds.insert_conflict.insert(@inbox_column => event[@inbox_column])
|
30
|
+
|
31
|
+
return event unless ret
|
32
|
+
else
|
33
|
+
begin
|
34
|
+
@inbox_ds.insert(@inbox_column => event[@inbox_column])
|
35
|
+
rescue Sequel::UniqueConstraintViolation
|
36
|
+
return event
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
yield
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
register_plugin :inbox, Inbox
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tobox
|
4
|
+
module Plugins
|
5
|
+
module Progress
|
6
|
+
def self.configure(conf)
|
7
|
+
conf.config[:visibility_timeout] = 30
|
8
|
+
end
|
9
|
+
|
10
|
+
module FetcherMethods
|
11
|
+
private
|
12
|
+
|
13
|
+
def initialize(_, configuration)
|
14
|
+
super
|
15
|
+
|
16
|
+
@mark_as_fetched_params[:run_at] = Sequel.date_add(
|
17
|
+
Sequel::CURRENT_TIMESTAMP,
|
18
|
+
seconds: configuration[:visibility_timeout]
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def events_tr
|
23
|
+
yield
|
24
|
+
end
|
25
|
+
|
26
|
+
def event_id_tr(&block)
|
27
|
+
@db.transaction(savepoint: false, &block)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
register_plugin :progress, Progress
|
32
|
+
end
|
33
|
+
end
|
data/lib/tobox/plugins/stats.rb
CHANGED
@@ -25,6 +25,8 @@ module Tobox
|
|
25
25
|
|
26
26
|
config = @config
|
27
27
|
|
28
|
+
plugins = config.plugins.map(&:name)
|
29
|
+
|
28
30
|
interval = config.stats_interval_seconds
|
29
31
|
@stats_handlers = Array(config.lifecycle_events[:stats])
|
30
32
|
|
@@ -34,14 +36,24 @@ module Tobox
|
|
34
36
|
|
35
37
|
@max_attempts = config[:max_attempts]
|
36
38
|
|
39
|
+
@created_at_column = config[:created_at_column]
|
40
|
+
|
37
41
|
@db = Sequel.connect(config.database.opts.merge(max_connections: 1))
|
42
|
+
@db.loggers = config.database.loggers
|
38
43
|
Array(config.lifecycle_events[:database_connect]).each { |cb| cb.call(@db) }
|
39
44
|
|
40
|
-
|
41
|
-
@outbox_ds = @db[
|
45
|
+
outbox_table = config[:table]
|
46
|
+
@outbox_ds = @db[outbox_table]
|
47
|
+
|
48
|
+
if plugins.include?("Tobox::Plugins::Inbox")
|
49
|
+
inbox_table = config[:inbox_table]
|
50
|
+
@inbox_ds = @db[inbox_table]
|
51
|
+
end
|
42
52
|
|
43
|
-
|
44
|
-
|
53
|
+
if @created_at_column
|
54
|
+
# discard already handled events
|
55
|
+
@oldest_event_age_ds = @outbox_ds.where(last_error: nil, run_at: nil).order(Sequel.asc(:id))
|
56
|
+
end
|
45
57
|
|
46
58
|
logger = config.default_logger
|
47
59
|
|
@@ -104,6 +116,13 @@ module Tobox
|
|
104
116
|
stats[:failed_count] ||= 0
|
105
117
|
|
106
118
|
stats[:inbox_count] = @inbox_ds.count if @inbox_ds
|
119
|
+
|
120
|
+
if @oldest_event_age_ds
|
121
|
+
created_at = @oldest_event_age_ds.get(@created_at_column)
|
122
|
+
age = created_at ? (Time.now - created_at).to_i : 0
|
123
|
+
stats[:oldest_event_age_in_seconds] = age
|
124
|
+
end
|
125
|
+
|
107
126
|
stats
|
108
127
|
end
|
109
128
|
end
|
@@ -5,34 +5,74 @@ require "fiber_scheduler"
|
|
5
5
|
|
6
6
|
module Tobox
|
7
7
|
class FiberPool < Pool
|
8
|
-
def initialize(
|
8
|
+
def initialize(_)
|
9
9
|
Sequel.extension(:fiber_concurrency)
|
10
10
|
super
|
11
|
+
@fibers = []
|
12
|
+
|
13
|
+
@fiber_mtx = Mutex.new
|
14
|
+
@fiber_cond = ConditionVariable.new
|
15
|
+
@fiber_thread = nil
|
11
16
|
end
|
12
17
|
|
13
18
|
def start
|
14
19
|
@fiber_thread = Thread.start do
|
15
20
|
Thread.current.name = "tobox-fibers-thread"
|
16
21
|
|
17
|
-
|
18
|
-
|
19
|
-
|
22
|
+
begin
|
23
|
+
FiberScheduler do
|
24
|
+
@fiber_mtx.synchronize do
|
25
|
+
@workers.each do |worker|
|
26
|
+
@fibers << start_fiber_worker(worker)
|
27
|
+
end
|
28
|
+
@fiber_cond.signal
|
29
|
+
end
|
20
30
|
end
|
31
|
+
rescue KillError
|
32
|
+
@fibers.each { |f| f.raise(KillError) }
|
21
33
|
end
|
22
34
|
end
|
35
|
+
@fiber_mtx.synchronize do
|
36
|
+
@fiber_cond.wait(@fiber_mtx)
|
37
|
+
end
|
23
38
|
end
|
24
39
|
|
25
40
|
def stop
|
26
41
|
shutdown_timeout = @configuration[:shutdown_timeout]
|
42
|
+
grace_shutdown_timeout = @configuration[:grace_shutdown_timeout]
|
27
43
|
|
28
44
|
super
|
29
45
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
46
|
+
@fiber_thread.join(shutdown_timeout)
|
47
|
+
|
48
|
+
return unless @fiber_thread.alive?
|
49
|
+
|
50
|
+
@fiber_thread.raise(KillError)
|
51
|
+
@fiber_thread.join(grace_shutdown_timeout)
|
52
|
+
@fiber_thread.kill
|
53
|
+
@fiber_thread.join(1)
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def start_fiber_worker(worker)
|
59
|
+
Fiber.schedule do
|
60
|
+
do_work(worker)
|
61
|
+
|
62
|
+
@fiber_mtx.synchronize do
|
63
|
+
@fibers.delete(Fiber.current)
|
64
|
+
|
65
|
+
if worker.finished? && @running
|
66
|
+
idx = @workers.index(worker)
|
67
|
+
|
68
|
+
raise Error, "worker not found" unless idx
|
69
|
+
|
70
|
+
subst_worker = Worker.new(worker.label, @configuration)
|
71
|
+
@workers[idx] = subst_worker
|
72
|
+
subst_fiber = start_fiber_worker(subst_worker)
|
73
|
+
@fiber_mtx.synchronize { @fibers << subst_fiber }
|
74
|
+
end
|
75
|
+
end
|
36
76
|
end
|
37
77
|
end
|
38
78
|
end
|
@@ -22,24 +22,35 @@ module Tobox
|
|
22
22
|
|
23
23
|
def stop
|
24
24
|
shutdown_timeout = @configuration[:shutdown_timeout]
|
25
|
-
|
26
|
-
deadline = Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
25
|
+
grace_shutdown_timeout = @configuration[:grace_shutdown_timeout]
|
27
26
|
|
28
27
|
super
|
29
28
|
Thread.pass # let workers finish
|
30
29
|
|
31
30
|
# soft exit
|
32
|
-
|
33
|
-
|
31
|
+
join = lambda do |timeout|
|
32
|
+
start = Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
33
|
+
|
34
|
+
loop do
|
35
|
+
terminating_th = @threads.synchronize { @threads.first }
|
36
|
+
|
37
|
+
return unless terminating_th
|
38
|
+
|
39
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
40
|
+
|
41
|
+
break if elapsed > timeout
|
34
42
|
|
35
|
-
|
43
|
+
terminating_th.join(timeout - elapsed)
|
44
|
+
end
|
36
45
|
end
|
37
46
|
|
47
|
+
join.call(shutdown_timeout)
|
48
|
+
|
38
49
|
# hard exit
|
39
|
-
@threads.each { |th| th.raise(KillError) }
|
40
|
-
|
41
|
-
|
42
|
-
|
50
|
+
@threads.synchronize { @threads.each { |th| th.raise(KillError) } }
|
51
|
+
join.call(grace_shutdown_timeout)
|
52
|
+
@threads.synchronize { @threads.each(&:kill) }
|
53
|
+
join.call(1)
|
43
54
|
end
|
44
55
|
|
45
56
|
private
|
@@ -56,13 +67,13 @@ module Tobox
|
|
56
67
|
if worker.finished? && @running
|
57
68
|
idx = @workers.index(worker)
|
58
69
|
|
70
|
+
raise Error, "worker not found" unless idx
|
71
|
+
|
59
72
|
subst_worker = Worker.new(worker.label, @configuration)
|
60
73
|
@workers[idx] = subst_worker
|
61
74
|
subst_thread = start_thread_worker(subst_worker)
|
62
75
|
@threads << subst_thread
|
63
76
|
end
|
64
|
-
# all workers went down abruply, we need to kill the process.
|
65
|
-
# @parent_thread.raise(Interrupt) if wk.finished? && @threads.empty? && @running
|
66
77
|
end
|
67
78
|
end
|
68
79
|
end
|
data/lib/tobox/pool.rb
CHANGED
data/lib/tobox/version.rb
CHANGED
data/lib/tobox/worker.rb
CHANGED
@@ -8,7 +8,7 @@ module Tobox
|
|
8
8
|
@label = label
|
9
9
|
@wait_for_events_delay = configuration[:wait_for_events_delay]
|
10
10
|
@handlers = configuration.handlers || {}
|
11
|
-
@fetcher =
|
11
|
+
@fetcher = configuration.fetcher_class.new(label, configuration)
|
12
12
|
@finished = false
|
13
13
|
|
14
14
|
return unless (message_to_arguments = configuration.arguments_handler)
|
data/lib/tobox.rb
CHANGED
@@ -9,6 +9,8 @@ require "mutex_m"
|
|
9
9
|
module Tobox
|
10
10
|
class Error < StandardError; end
|
11
11
|
|
12
|
+
EMPTY = [].freeze
|
13
|
+
|
12
14
|
module Plugins
|
13
15
|
@plugins = {}
|
14
16
|
@plugins.extend(Mutex_m)
|
@@ -34,8 +36,8 @@ module Tobox
|
|
34
36
|
end
|
35
37
|
end
|
36
38
|
|
37
|
-
require_relative "tobox/configuration"
|
38
39
|
require_relative "tobox/fetcher"
|
39
40
|
require_relative "tobox/worker"
|
40
41
|
require_relative "tobox/pool"
|
41
42
|
require_relative "tobox/application"
|
43
|
+
require_relative "tobox/configuration"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: tobox
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- HoneyryderChuck
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-09-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: sequel
|
@@ -45,6 +45,9 @@ files:
|
|
45
45
|
- lib/tobox/plugins/datadog/configuration.rb
|
46
46
|
- lib/tobox/plugins/datadog/integration.rb
|
47
47
|
- lib/tobox/plugins/datadog/patcher.rb
|
48
|
+
- lib/tobox/plugins/event_grouping.rb
|
49
|
+
- lib/tobox/plugins/inbox.rb
|
50
|
+
- lib/tobox/plugins/progress.rb
|
48
51
|
- lib/tobox/plugins/sentry.rb
|
49
52
|
- lib/tobox/plugins/stats.rb
|
50
53
|
- lib/tobox/plugins/zeitwerk.rb
|
@@ -71,14 +74,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
74
|
requirements:
|
72
75
|
- - ">="
|
73
76
|
- !ruby/object:Gem::Version
|
74
|
-
version: 2.
|
77
|
+
version: 2.7.0
|
75
78
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
76
79
|
requirements:
|
77
80
|
- - ">="
|
78
81
|
- !ruby/object:Gem::Version
|
79
82
|
version: '0'
|
80
83
|
requirements: []
|
81
|
-
rubygems_version: 3.
|
84
|
+
rubygems_version: 3.5.3
|
82
85
|
signing_key:
|
83
86
|
specification_version: 4
|
84
87
|
summary: Transactional outbox pattern implementation in ruby
|