tobox 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb81b3eab97621080f2cf55ba787943d26a11a38c0e807313a810adc8c0d8136
4
- data.tar.gz: 0556f5ba59e7b2feb8a47b04d53e00869d041e747a6789c7f4fe4067f17ad795
3
+ metadata.gz: 4960d6151461ab6e051605687d99cc69089e498aaea66d97416b92158e48cc4e
4
+ data.tar.gz: e3f2e0a6408696ff1852a484101ca6fa95e5e5e713d1ccb0b829040bb0a748ad
5
5
  SHA512:
6
- metadata.gz: d109a8b77dafed4066d60cff257541e3eb1a7a6950efa7e94ec75e6e68fb1cf60e5684edc46b9ec99f0f56573807f354b16b7ee5c188b94a848cc52423528c30
7
- data.tar.gz: f8d87bd79921b774326e7db4afd532696814c2b38d5e45deae582eaacdd7e7f161c92b81d627ab2b1c17c51bad0a8a0a1be000839cd675da885a78107e0f4d16
6
+ metadata.gz: ac5d961bc737299d4cd1e3d353955f97b12d984e4b3378b56dbeaf84c5d969ed3b6c02902c70b7ed0fd14230696d44992012d35053565988349b13405f6144ef
7
+ data.tar.gz: 3a921e5ca3f42fa47f8485a0a9bf44c42c31f0f767cfafc81ef1af6d6e43ac83966baffcb20ff28df9c90abb8a4c16e6829c4e9aa9e86ca009ab4fde38a92bd1
data/CHANGELOG.md CHANGED
@@ -1,13 +1,64 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.1] - 2023-05-24
3
4
 
4
- ## [0.3.2] - 2022-03-06
5
+ ### Features
6
+
7
+ #### `on_database_connect`
8
+
9
+ this adds an extension point for internal sequel database objects, in cases where some tweaks are required (such as in the case of, when using database SSL proxies, setting connection validators).
10
+
11
+ ```ruby
12
+ # tobox.rb
13
+ on_database_connect do |db|
14
+ db.extension(:connection_validator)
15
+ end
16
+ ```
17
+
18
+ ## [0.4.0] - 2023-05-19
19
+
20
+ ### Features
21
+
22
+ #### `:stats` plugin
23
+
24
+ The `:stats` plugin collects statistics related with the outbox table periodically, and exposes them to app code (which can then relay them to a statsD collector, or similar tool).
25
+
26
+
27
+ ```ruby
28
+ plugin(:stats)
29
+ on_stats(5) do |stats_collector| # every 5 seconds
30
+ stats = stats_collector.collect
31
+ StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
32
+ end
33
+ ```
34
+
35
+ Read more about it in [the project README](https://gitlab.com/os85/tobox#stats).
36
+
37
+ #### on_start/on_stop callbacks
38
+
39
+ The `on_start` and `on_stop` callbacks can now be defined in `tobox` configuration:
40
+
41
+ ```ruby
42
+ # tobox.rb
43
+ on_start do
44
+ puts "tobox is starting..."
45
+ end
46
+ on_stop do
47
+ puts "tobox is stopping..."
48
+ end
49
+ ```
50
+
51
+ ### Bugfixes
52
+
53
+ * tobox configuration file is now only loaded after everything else, so access to application code is guaranteed.
54
+
55
+ ## [0.3.2] - 2023-03-06
5
56
 
6
57
  ### Bugfixes
7
58
 
8
59
  * allow sentry error capture if `report_after_retries` option is turned off.
9
60
 
10
- ## [0.3.1] - 2022-03-03
61
+ ## [0.3.1] - 2023-03-03
11
62
 
12
63
  ### Bugfixes
13
64
 
data/README.md CHANGED
@@ -20,6 +20,7 @@ Simple, data-first events processing framework based on the [transactional outbo
20
20
  - [Zeitwerk](#zeitwerk)
21
21
  - [Sentry](#sentry)
22
22
  - [Datadog](#datadog)
23
+ - [Stats](#stats)
23
24
  - [Supported Rubies](#supported-rubies)
24
25
  - [Rails support](#rails-support)
25
26
  - [Why?](#why)
@@ -267,6 +268,20 @@ callback executed when an exception was raised in the worker, before processing
267
268
  on_error_worker { |exception| Sentry.capture_exception(exception) }
268
269
  ```
269
270
 
271
+ ### `on_database_connect { |db| }`
272
+
273
+ Callback executed right after initializing the `sequel` database object. This can be used, for example, to load database-level extensions and plugins, and set parameters (such as connection pool tweaks). This callback will also be used by plugins which instantiate its own separate database objects (such as in the case of the [stats](#stats) plugin).
274
+
275
+ This callback won't be executed if the database object is created outside of `tobox` configuration parameters.
276
+
277
+
278
+ ```ruby
279
+ on_database_connect do |db|
280
+ db.extension(:connection_validator)
281
+ db.pool.connection_validation_timeout = -1
282
+ end
283
+ ```
284
+
270
285
  ### `message_to_arguments { |event| }`
271
286
 
272
287
  if exposing raw data to the `on` handlers is not what you'd want, you can always override the behaviour by providing an alternative "before/after fetcher" implementation.
@@ -482,6 +497,69 @@ end
482
497
  plugin(:datadog)
483
498
  ```
484
499
 
500
+ <a id="markdown-datadog" name="stats"></a>
501
+ ### Stats
502
+
503
+ The `stats` plugin collects statistics related with the outbox table periodically, and exposes them to app code (which can then relay them to a statsD collector, or similar tool).
504
+
505
+ ```ruby
506
+ plugin(:stats)
507
+ on_stats(5) do |stats_collector| # every 5 seconds
508
+ stats = stats_collector.collect
509
+ #
510
+ # stats => {
511
+ # pending_count: number of new events in the outbox table
512
+ # failing_count: number of events which have failed processing but haven't reached the threshold
513
+ # failed_count: number of events which have failed the max number of tries
514
+ # inbox_count: (if used) number of events marked as received in the inbox table
515
+ # }
516
+ #
517
+ # now you can send them to your statsd collector
518
+ #
519
+ StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
520
+ end
521
+ ```
522
+
523
+ #### Bring your own leader election
524
+
525
+ The stats collection runs on every `tobox` initiated. If you're launching it in multiple servers / containers / pods, this means you'll be collecting statistics about the same database on all of these instances. This may not be desirable, and you may want to do this collection in a single instance. This is not a problem that `tobox` can solve by itself, so you'll have to take care of that yourself. Still, here are some cheap recommendations.
526
+
527
+ ##### Postgres advisory locks
528
+
529
+ If your database is PostgreSQL, you can leverage session-level [advisory locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS) to ensure single-instance access to this functionality. `tobox` also exposes the database instance to the `on_stats` callback:
530
+
531
+ ```ruby
532
+ c.on_stats(5) do |stats_collector, db|
533
+ if db.get(Sequel.function(:pg_try_advisory_lock, 1))
534
+ stats = stats_collector.collect
535
+ StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
536
+ end
537
+ end
538
+ ```
539
+
540
+ If a server goes down, one of the remaining ones will acquire the lock and ensure stats processing.
541
+
542
+ ##### Redis distributed locks
543
+
544
+ If you're already using [redis](https://redis.io/), you can use its distributed lock feature to achieve the goal:
545
+
546
+ ```ruby
547
+ # using redlock
548
+ c.on_stats(5) do |stats_collector, db|
549
+ begin
550
+ lock_info = lock_manager.lock("outbox", 5000)
551
+
552
+ stats = stats_collector.collect
553
+ StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
554
+
555
+ # extend to hold the lock for the next loop
556
+ lock_info = lock_manager.lock("outbox", 5000, extend: lock_info)
557
+ rescue Redlock::LockError
558
+ # some other server already has the lock, try later
559
+ end
560
+ end
561
+ ```
562
+
485
563
  <a id="markdown-supported-rubies" name="supported-rubies"></a>
486
564
  ## Supported Rubies
487
565
 
@@ -6,18 +6,23 @@ module Tobox
6
6
  @configuration = configuration
7
7
  @running = false
8
8
 
9
- worker = @configuration[:worker]
9
+ @on_start_handlers = Array(configuration.lifecycle_events[:on_start])
10
+ @on_stop_handlers = Array(configuration.lifecycle_events[:on_stop])
11
+
12
+ worker = configuration[:worker]
10
13
 
11
14
  @pool = case worker
12
15
  when :thread then ThreadedPool
13
16
  when :fiber then FiberPool
14
17
  else worker
15
- end.new(@configuration)
18
+ end.new(configuration)
16
19
  end
17
20
 
18
21
  def start
19
22
  return if @running
20
23
 
24
+ @on_start_handlers.each(&:call)
25
+
21
26
  @pool.start
22
27
  @running = true
23
28
  end
@@ -25,6 +30,8 @@ module Tobox
25
30
  def stop
26
31
  return unless @running
27
32
 
33
+ @on_stop_handlers.each(&:call)
34
+
28
35
  @pool.stop
29
36
 
30
37
  @running = false
data/lib/tobox/cli.rb CHANGED
@@ -18,15 +18,15 @@ module Tobox
18
18
  def run
19
19
  options = @options
20
20
 
21
+ # boot
22
+ options.fetch(:require).each(&method(:require))
23
+
21
24
  config = Configuration.new do |c|
22
25
  c.instance_eval(File.read(options.fetch(:config_file)), options.fetch(:config_file), 1)
23
26
  end
24
27
 
25
28
  logger = config.default_logger
26
29
 
27
- # boot
28
- options.fetch(:require).each(&method(:require))
29
-
30
30
  # signals
31
31
  pipe_read, pipe_write = IO.pipe
32
32
  %w[INT TERM].each do |sig|
@@ -7,7 +7,7 @@ module Tobox
7
7
  class Configuration
8
8
  extend Forwardable
9
9
 
10
- attr_reader :handlers, :lifecycle_events, :arguments_handler, :default_logger
10
+ attr_reader :handlers, :lifecycle_events, :arguments_handler, :default_logger, :database
11
11
 
12
12
  def_delegator :@config, :[]
13
13
 
@@ -60,6 +60,24 @@ module Tobox
60
60
  @default_logger = @config[:logger] || Logger.new(STDERR, formatter: DEFAULT_LOG_FORMATTER) # rubocop:disable Style/GlobalStdStream
61
61
  @default_logger.level = @config[:log_level] || (env == "production" ? Logger::INFO : Logger::DEBUG)
62
62
 
63
+ @database = if @config[:database_uri]
64
+ database_opts = {}
65
+ database_opts[:max_connections] = @config[:concurrency] if @config[:worker] == :thread
66
+ db = Sequel.connect(@config[:database_uri].to_s, database_opts)
67
+ Array(@lifecycle_events[:database_connect]).each { |cb| cb.call(db) }
68
+ db
69
+ else
70
+ Sequel::DATABASES.first
71
+ end
72
+ raise Error, "no database found" unless @database
73
+
74
+ if @database.frozen?
75
+ raise "#{@database} must have the :date_arithmetic extension loaded" unless Sequel.respond_to?(:date_add)
76
+ else
77
+ @database.extension :date_arithmetic
78
+ @database.loggers << @default_logger unless @config[:environment] == "production"
79
+ end
80
+
63
81
  freeze
64
82
  end
65
83
 
@@ -70,6 +88,16 @@ module Tobox
70
88
  self
71
89
  end
72
90
 
91
+ def on_start(&callback)
92
+ (@lifecycle_events[:on_start] ||= []) << callback
93
+ self
94
+ end
95
+
96
+ def on_stop(&callback)
97
+ (@lifecycle_events[:on_stop] ||= []) << callback
98
+ self
99
+ end
100
+
73
101
  def on_before_event(&callback)
74
102
  (@lifecycle_events[:before_event] ||= []) << callback
75
103
  self
@@ -90,6 +118,11 @@ module Tobox
90
118
  self
91
119
  end
92
120
 
121
+ def on_database_connect(&callback)
122
+ (@lifecycle_events[:database_connect] ||= []) << callback
123
+ self
124
+ end
125
+
93
126
  def message_to_arguments(&callback)
94
127
  @arguments_handler = callback
95
128
  self
@@ -116,6 +149,7 @@ module Tobox
116
149
  @handlers.each_value(&:freeze).freeze
117
150
  @lifecycle_events.each_value(&:freeze).freeze
118
151
  @plugins.freeze
152
+ @database.freeze
119
153
  super
120
154
  end
121
155
 
data/lib/tobox/fetcher.rb CHANGED
@@ -10,13 +10,7 @@ module Tobox
10
10
 
11
11
  @logger = @configuration.default_logger
12
12
 
13
- database_uri = @configuration[:database_uri]
14
- @db = database_uri ? Sequel.connect(database_uri.to_s) : Sequel::DATABASES.first
15
- raise Error, "no database found" unless @db
16
-
17
- @db.extension :date_arithmetic
18
-
19
- @db.loggers << @logger unless @configuration[:environment] == "production"
13
+ @db = configuration.database
20
14
 
21
15
  @table = configuration[:table]
22
16
  @group_column = configuration[:group_column]
@@ -30,16 +30,16 @@ module Tobox
30
30
 
31
31
  scope = ::Sentry.get_current_scope
32
32
 
33
- scope.set_contexts(
34
- id: event[:id],
35
- type: event[:type],
36
- attempts: event[:attempts],
37
- created_at: event[:created_at],
38
- run_at: event[:run_at],
39
- last_error: event[:last_error]&.byteslice(0..1000),
40
- version: Tobox::VERSION,
41
- db_adapter: @db_scheme
42
- )
33
+ scope.set_contexts(tobox: {
34
+ id: event[:id],
35
+ type: event[:type],
36
+ attempts: event[:attempts],
37
+ created_at: event[:created_at],
38
+ run_at: event[:run_at],
39
+ last_error: event[:last_error]&.byteslice(0..1000),
40
+ version: Tobox::VERSION,
41
+ db_adapter: @db_scheme
42
+ })
43
43
  scope.set_tags(
44
44
  outbox: @db_table,
45
45
  event_id: event[:id],
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tobox
4
+ module Plugins
5
+ module Stats
6
+ module ConfigurationMethods
7
+ attr_reader :stats_interval_seconds
8
+
9
+ def on_stats(stats_interval_seconds, &callback)
10
+ @stats_interval_seconds = stats_interval_seconds
11
+
12
+ (@lifecycle_events[:stats] ||= []) << callback
13
+ self
14
+ end
15
+ end
16
+
17
+ class StatsEmitter
18
+ def initialize(config)
19
+ @config = config
20
+ @running = false
21
+ end
22
+
23
+ def start
24
+ return if @running
25
+
26
+ config = @config
27
+
28
+ interval = config.stats_interval_seconds
29
+ @stats_handlers = Array(config.lifecycle_events[:stats])
30
+
31
+ return if @stats_handlers.empty?
32
+
33
+ @error_handlers = Array(config.lifecycle_events[:error_worker])
34
+
35
+ @max_attempts = config[:max_attempts]
36
+
37
+ @db = Sequel.connect(config.database.opts.merge(single_threaded: true))
38
+ Array(config.lifecycle_events[:database_connect]).each { |cb| cb.call(@db) }
39
+
40
+ @outbox_table = config[:table]
41
+ @outbox_ds = @db[@outbox_table]
42
+
43
+ inbox_table = config[:inbox_table]
44
+ @inbox_ds = @db[inbox_table] if inbox_table
45
+
46
+ logger = config.default_logger
47
+
48
+ stats = method(:collect_event_stats)
49
+ stats.instance_eval do
50
+ alias collect call
51
+ end
52
+
53
+ @th = Thread.start do
54
+ Thread.current.name = "outbox-stats"
55
+
56
+ loop do
57
+ logger.debug { "stats worker: sleep for #{interval}s..." }
58
+ sleep interval
59
+
60
+ begin
61
+ emit_event_stats(stats)
62
+ rescue RuntimeError => e
63
+ @error_handlers.each { |hd| hd.call(e) }
64
+ end
65
+
66
+ break unless @running
67
+ end
68
+ end
69
+
70
+ @running = true
71
+ end
72
+
73
+ def stop
74
+ return unless @running
75
+
76
+ @th.terminate
77
+
78
+ @db.disconnect
79
+
80
+ @running = false
81
+ end
82
+
83
+ private
84
+
85
+ def emit_event_stats(stats)
86
+ @stats_handlers.each do |hd|
87
+ hd.call(stats, @db)
88
+ end
89
+ end
90
+
91
+ def collect_event_stats
92
+ stats = @outbox_ds.group_and_count(
93
+ Sequel.case([
94
+ [{ last_error: nil }, "pending_count"],
95
+ [Sequel.expr([:attempts]) < @max_attempts, "failing_count"]
96
+ ],
97
+ "failed_count").as(:status)
98
+ )
99
+ stats = stats.as_hash(:status, :count).transform_keys(&:to_sym)
100
+
101
+ # fill it in
102
+ stats[:pending_count] ||= 0
103
+ stats[:failing_count] ||= 0
104
+ stats[:failed_count] ||= 0
105
+
106
+ stats[:inbox_count] = @inbox_ds.count if @inbox_ds
107
+ stats
108
+ end
109
+ end
110
+
111
+ class << self
112
+ def configure(config)
113
+ emitter = StatsEmitter.new(config)
114
+
115
+ config.on_start(&emitter.method(:start))
116
+ config.on_stop(&emitter.method(:stop))
117
+ end
118
+ end
119
+ end
120
+
121
+ register_plugin :stats, Stats
122
+ end
123
+ end
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.3.2"
4
+ VERSION = "0.4.1"
5
5
  end
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.3.2
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - HoneyryderChuck
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-03-06 00:00:00.000000000 Z
11
+ date: 2023-05-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sequel
@@ -46,6 +46,7 @@ files:
46
46
  - lib/tobox/plugins/datadog/integration.rb
47
47
  - lib/tobox/plugins/datadog/patcher.rb
48
48
  - lib/tobox/plugins/sentry.rb
49
+ - lib/tobox/plugins/stats.rb
49
50
  - lib/tobox/plugins/zeitwerk.rb
50
51
  - lib/tobox/pool.rb
51
52
  - lib/tobox/pool/fiber_pool.rb
@@ -77,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
78
  - !ruby/object:Gem::Version
78
79
  version: '0'
79
80
  requirements: []
80
- rubygems_version: 3.3.7
81
+ rubygems_version: 3.4.6
81
82
  signing_key:
82
83
  specification_version: 4
83
84
  summary: Transactional outbox pattern implementation in ruby