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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0be3e9919cb080bf7e57127356e00cc5a13e5d8af6943ee87f688e515771d9ec
4
- data.tar.gz: 215095033e4763f2b5b4bd166f4dcc66fb741424244a4c4a13989974f5dfbb14
3
+ metadata.gz: 15d95687a102c98fcc33859b3a369dc9026f04fa58f7f785bcad1d9ccb40010f
4
+ data.tar.gz: 656b296f9d72d67877945317d4a6d5505de63044f74e08c506970b2c841599f4
5
5
  SHA512:
6
- metadata.gz: 914972ea18b05b0542bc091cd3cdd602f420c3551f24c598b9d6711ff879076e6e0a03879f27419da6526a449b7e4364ce8e6e0aebd70a698dc250696a0c54f5
7
- data.tar.gz: 419bfcd4c5604c72ace312acb8deebfa3081546b137c7f7c5ee267be5e0f49a8f21a745147e17b99ee002b2e95db7c48a03d84cd578dc3ca72bd452ad8581280
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
- concurrency 4
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
- There are a few extra features you can run on top a "vanilla" transactional outbox implementation. This is how you can accomplish them using `tobox`.
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-ordered-event-processing" name="ordered-event-processing"></a>
354
- ### Ordered event processing
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 `group_column` configuration.
409
+ One solution is to have a single worker processing the "outbox" events. Another is to use the `:event_grouping` plugin.
359
410
 
360
- What you have to do is:
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. set the "group_column" configuration
423
+ 2. Enable the plugin
373
424
 
374
425
  ```ruby
375
426
  # in your tobox.rb
376
- group_column :group_id
377
- index :group_id
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
- 3. reference them in the configuration
479
+ 2. Load the plugin and reference them in the configuration
425
480
 
426
481
  ```ruby
427
482
  # tobox.rb
428
- inbox_table :inbox
429
- inbox_column :inbox_id
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
- 4. insert related outbox events with an inbox id
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
- **NOTE**: make sure you keep cleaning the inbox periodically from older messages, once there's no more danger of receiving them again.
506
+ #### Configuration
450
507
 
451
- <a id="markdown-plugins" name="plugins"></a>
452
- ## Plugins
453
508
 
454
- `tobox` ships with a very simple plugin system. (TODO: add docs).
509
+ ##### inbox table
455
510
 
456
- Plugins can be loaded in the config via `plugin`:
511
+ Defines the name of the table to be used for inbox (`:inbox` by default).
457
512
 
458
- ```ruby
459
- # tobox.rb
460
- plugin(:plugin_name)
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.6, and always latest JRuby and Truffleruby.
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", Integer, "Shutdown timeout (in seconds)" do |arg|
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
@@ -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
- group_column: nil,
22
- inbox_table: nil,
23
- inbox_column: nil,
22
+ created_at_column: nil,
24
23
  max_attempts: 10,
25
- exponential_retry_factor: 4,
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 DEFAULT_CONFIGURATION.key?(meth) && args.size == 1
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(meth, *args) ||
171
- DEFAULT_CONFIGURATION.key?(meth) ||
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
- @db.transaction(savepoint: false) do
43
- if @group_column
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
- events = nil
72
- error = nil
73
- unless event_ids.empty?
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
- return blk ? 0 : [] if events.nil?
98
-
99
- return events unless blk
48
+ if event_id
49
+ with_event(event_id) do |event|
50
+ num_events = 1
100
51
 
101
- if events
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
- @ds.where(id: event[:id]).returning.update(
124
- attempts: Sequel[@table][:attempts] + 1,
119
+ update_params = {
125
120
  run_at: Sequel.date_add(Sequel::CURRENT_TIMESTAMP,
126
- seconds: event[:attempts] + (1**@exponential_retry_factor)),
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
- ).first
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
@@ -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
- @outbox_table = config[:table]
41
- @outbox_ds = @db[@outbox_table]
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
- inbox_table = config[:inbox_table]
44
- @inbox_ds = @db[inbox_table] if inbox_table
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(_configuration)
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
- FiberScheduler do
18
- @workers.each_with_index do |wk, _idx|
19
- Fiber.schedule { do_work(wk) }
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
- begin
31
- Timeout.timeout(shutdown_timeout) { @fiber_thread.value }
32
- rescue Timeout::Error
33
- # hard exit
34
- @fiber_thread.raise(KillError)
35
- @fiber_thread.value
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
- while Process.clock_gettime(::Process::CLOCK_MONOTONIC) - deadline < shutdown_timeout
33
- return if @threads.empty?
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
- sleep 0.5
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
- while (th = @threads.pop)
41
- th.value # waits
42
- end
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
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tobox
4
- class Pool
5
- class KillError < Interrupt; end
4
+ class KillError < Interrupt; end
6
5
 
6
+ class Pool
7
7
  def initialize(configuration)
8
8
  @configuration = configuration
9
9
  @logger = @configuration.default_logger
data/lib/tobox/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tobox
4
- VERSION = "0.4.5"
4
+ VERSION = "0.5.1"
5
5
  end
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 = Fetcher.new(label, configuration)
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.5
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-02-28 00:00:00.000000000 Z
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.6.0
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.4.10
84
+ rubygems_version: 3.5.3
82
85
  signing_key:
83
86
  specification_version: 4
84
87
  summary: Transactional outbox pattern implementation in ruby