tobox 0.3.2 → 0.4.0
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 +4 -4
- data/CHANGELOG.md +38 -2
- data/README.md +64 -0
- data/lib/tobox/application.rb +9 -2
- data/lib/tobox/cli.rb +3 -3
- data/lib/tobox/configuration.rb +28 -1
- data/lib/tobox/fetcher.rb +1 -7
- data/lib/tobox/plugins/sentry.rb +10 -10
- data/lib/tobox/plugins/stats.rb +121 -0
- data/lib/tobox/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f162a3a57381f1e5e15ddb1034e82bdd7fcddc720f6fa38264351c6e04407aa9
|
4
|
+
data.tar.gz: 7b4ca98af244fff5ee76eb38141047b1c97ee40dd79c942cdb0e82480b8f8439
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 673ead552e6419b25e876e13134fc4b9b04f9505544f04f572570c2bc8d7495f8e5e511047d4545c4bd6ff03a3b0e472c65ec69b8a40ffe355931e3bdb907a4d
|
7
|
+
data.tar.gz: 52ba20bddfaaafb13555fc4e72860feea2bb9377480191dcce5be291d136831559f3579467fc44f032cd94ac833094938264a193af558a334b51d2b6550801bd
|
data/CHANGELOG.md
CHANGED
@@ -1,13 +1,49 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.4.0] - 2023-05-19
|
3
4
|
|
4
|
-
|
5
|
+
### Features
|
6
|
+
|
7
|
+
#### `:stats` plugin
|
8
|
+
|
9
|
+
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).
|
10
|
+
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
plugin(:stats)
|
14
|
+
on_stats(5) do |stats_collector| # every 5 seconds
|
15
|
+
stats = stats_collector.collect
|
16
|
+
StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
|
17
|
+
end
|
18
|
+
```
|
19
|
+
|
20
|
+
Read more about it in [the project README](https://gitlab.com/os85/tobox#stats).
|
21
|
+
|
22
|
+
#### on_start/on_stop callbacks
|
23
|
+
|
24
|
+
The `on_start` and `on_stop` callbacks can now be defined in `tobox` configuration:
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
# tobox.rb
|
28
|
+
on_start do
|
29
|
+
puts "tobox is starting..."
|
30
|
+
end
|
31
|
+
on_stop do
|
32
|
+
puts "tobox is stopping..."
|
33
|
+
end
|
34
|
+
```
|
35
|
+
|
36
|
+
### Bugfixes
|
37
|
+
|
38
|
+
* tobox configuration file is now only loaded after everything else, so access to application code is guaranteed.
|
39
|
+
|
40
|
+
## [0.3.2] - 2023-03-06
|
5
41
|
|
6
42
|
### Bugfixes
|
7
43
|
|
8
44
|
* allow sentry error capture if `report_after_retries` option is turned off.
|
9
45
|
|
10
|
-
## [0.3.1] -
|
46
|
+
## [0.3.1] - 2023-03-03
|
11
47
|
|
12
48
|
### Bugfixes
|
13
49
|
|
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)
|
@@ -482,6 +483,69 @@ end
|
|
482
483
|
plugin(:datadog)
|
483
484
|
```
|
484
485
|
|
486
|
+
<a id="markdown-datadog" name="stats"></a>
|
487
|
+
### Stats
|
488
|
+
|
489
|
+
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).
|
490
|
+
|
491
|
+
```ruby
|
492
|
+
plugin(:stats)
|
493
|
+
on_stats(5) do |stats_collector| # every 5 seconds
|
494
|
+
stats = stats_collector.collect
|
495
|
+
#
|
496
|
+
# stats => {
|
497
|
+
# pending_count: number of new events in the outbox table
|
498
|
+
# failing_count: number of events which have failed processing but haven't reached the threshold
|
499
|
+
# failed_count: number of events which have failed the max number of tries
|
500
|
+
# inbox_count: (if used) number of events marked as received in the inbox table
|
501
|
+
# }
|
502
|
+
#
|
503
|
+
# now you can send them to your statsd collector
|
504
|
+
#
|
505
|
+
StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
|
506
|
+
end
|
507
|
+
```
|
508
|
+
|
509
|
+
#### Bring your own leader election
|
510
|
+
|
511
|
+
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.
|
512
|
+
|
513
|
+
##### Postgres advisory locks
|
514
|
+
|
515
|
+
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:
|
516
|
+
|
517
|
+
```ruby
|
518
|
+
c.on_stats(5) do |stats_collector, db|
|
519
|
+
if db.get(Sequel.function(:pg_try_advisory_lock, 1))
|
520
|
+
stats = stats_collector.collect
|
521
|
+
StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
|
522
|
+
end
|
523
|
+
end
|
524
|
+
```
|
525
|
+
|
526
|
+
If a server goes down, one of the remaining ones will acquire the lock and ensure stats processing.
|
527
|
+
|
528
|
+
##### Redis distributed locks
|
529
|
+
|
530
|
+
If you're already using [redis](https://redis.io/), you can use its distributed lock feature to achieve the goal:
|
531
|
+
|
532
|
+
```ruby
|
533
|
+
# using redlock
|
534
|
+
c.on_stats(5) do |stats_collector, db|
|
535
|
+
begin
|
536
|
+
lock_info = lock_manager.lock("outbox", 5000)
|
537
|
+
|
538
|
+
stats = stats_collector.collect
|
539
|
+
StatsD.gauge('outbox_pending_backlog', stats[:pending_count])
|
540
|
+
|
541
|
+
# extend to hold the lock for the next loop
|
542
|
+
lock_info = lock_manager.lock("outbox", 5000, extend: lock_info)
|
543
|
+
rescue Redlock::LockError
|
544
|
+
# some other server already has the lock, try later
|
545
|
+
end
|
546
|
+
end
|
547
|
+
```
|
548
|
+
|
485
549
|
<a id="markdown-supported-rubies" name="supported-rubies"></a>
|
486
550
|
## Supported Rubies
|
487
551
|
|
data/lib/tobox/application.rb
CHANGED
@@ -6,18 +6,23 @@ module Tobox
|
|
6
6
|
@configuration = configuration
|
7
7
|
@running = false
|
8
8
|
|
9
|
-
|
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(
|
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|
|
data/lib/tobox/configuration.rb
CHANGED
@@ -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,22 @@ 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
|
+
Sequel.connect(@config[:database_uri].to_s, database_opts)
|
67
|
+
else
|
68
|
+
Sequel::DATABASES.first
|
69
|
+
end
|
70
|
+
raise Error, "no database found" unless @database
|
71
|
+
|
72
|
+
if @database.frozen?
|
73
|
+
raise "#{@database} must have the :date_arithmetic extension loaded" unless Sequel.respond_to?(:date_add)
|
74
|
+
else
|
75
|
+
@database.extension :date_arithmetic
|
76
|
+
@database.loggers << @default_logger unless @config[:environment] == "production"
|
77
|
+
end
|
78
|
+
|
63
79
|
freeze
|
64
80
|
end
|
65
81
|
|
@@ -70,6 +86,16 @@ module Tobox
|
|
70
86
|
self
|
71
87
|
end
|
72
88
|
|
89
|
+
def on_start(&callback)
|
90
|
+
(@lifecycle_events[:on_start] ||= []) << callback
|
91
|
+
self
|
92
|
+
end
|
93
|
+
|
94
|
+
def on_stop(&callback)
|
95
|
+
(@lifecycle_events[:on_stop] ||= []) << callback
|
96
|
+
self
|
97
|
+
end
|
98
|
+
|
73
99
|
def on_before_event(&callback)
|
74
100
|
(@lifecycle_events[:before_event] ||= []) << callback
|
75
101
|
self
|
@@ -116,6 +142,7 @@ module Tobox
|
|
116
142
|
@handlers.each_value(&:freeze).freeze
|
117
143
|
@lifecycle_events.each_value(&:freeze).freeze
|
118
144
|
@plugins.freeze
|
145
|
+
@database.freeze
|
119
146
|
super
|
120
147
|
end
|
121
148
|
|
data/lib/tobox/fetcher.rb
CHANGED
@@ -10,13 +10,7 @@ module Tobox
|
|
10
10
|
|
11
11
|
@logger = @configuration.default_logger
|
12
12
|
|
13
|
-
|
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]
|
data/lib/tobox/plugins/sentry.rb
CHANGED
@@ -30,16 +30,16 @@ module Tobox
|
|
30
30
|
|
31
31
|
scope = ::Sentry.get_current_scope
|
32
32
|
|
33
|
-
scope.set_contexts(
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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,121 @@
|
|
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
|
+
@outbox_table = config[:table]
|
39
|
+
@outbox_ds = @db[@outbox_table]
|
40
|
+
|
41
|
+
inbox_table = config[:inbox_table]
|
42
|
+
@inbox_ds = @db[inbox_table] if inbox_table
|
43
|
+
|
44
|
+
logger = config.default_logger
|
45
|
+
|
46
|
+
stats = method(:collect_event_stats)
|
47
|
+
stats.instance_eval do
|
48
|
+
alias collect call
|
49
|
+
end
|
50
|
+
|
51
|
+
@th = Thread.start do
|
52
|
+
Thread.current.name = "outbox-stats"
|
53
|
+
|
54
|
+
loop do
|
55
|
+
logger.debug { "stats worker: sleep for #{interval}s..." }
|
56
|
+
sleep interval
|
57
|
+
|
58
|
+
begin
|
59
|
+
emit_event_stats(stats)
|
60
|
+
rescue RuntimeError => e
|
61
|
+
@error_handlers.each { |hd| hd.call(e) }
|
62
|
+
end
|
63
|
+
|
64
|
+
break unless @running
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
@running = true
|
69
|
+
end
|
70
|
+
|
71
|
+
def stop
|
72
|
+
return unless @running
|
73
|
+
|
74
|
+
@th.terminate
|
75
|
+
|
76
|
+
@db.disconnect
|
77
|
+
|
78
|
+
@running = false
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def emit_event_stats(stats)
|
84
|
+
@stats_handlers.each do |hd|
|
85
|
+
hd.call(stats, @db)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def collect_event_stats
|
90
|
+
stats = @outbox_ds.group_and_count(
|
91
|
+
Sequel.case([
|
92
|
+
[{ last_error: nil }, "pending_count"],
|
93
|
+
[Sequel.expr([:attempts]) < @max_attempts, "failing_count"]
|
94
|
+
],
|
95
|
+
"failed_count").as(:status)
|
96
|
+
)
|
97
|
+
stats = stats.as_hash(:status, :count).transform_keys(&:to_sym)
|
98
|
+
|
99
|
+
# fill it in
|
100
|
+
stats[:pending_count] ||= 0
|
101
|
+
stats[:failing_count] ||= 0
|
102
|
+
stats[:failed_count] ||= 0
|
103
|
+
|
104
|
+
stats[:inbox_count] = @inbox_ds.count if @inbox_ds
|
105
|
+
stats
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
class << self
|
110
|
+
def configure(config)
|
111
|
+
emitter = StatsEmitter.new(config)
|
112
|
+
|
113
|
+
config.on_start(&emitter.method(:start))
|
114
|
+
config.on_stop(&emitter.method(:stop))
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
register_plugin :stats, Stats
|
120
|
+
end
|
121
|
+
end
|
data/lib/tobox/version.rb
CHANGED
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.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- HoneyryderChuck
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-05-19 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.
|
81
|
+
rubygems_version: 3.4.6
|
81
82
|
signing_key:
|
82
83
|
specification_version: 4
|
83
84
|
summary: Transactional outbox pattern implementation in ruby
|