rabbit_carrots 1.1.0 → 1.2.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 +41 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +44 -39
- data/README.md +37 -0
- data/lib/puma/plugin/rabbit_carrots.rb +6 -1
- data/lib/rabbit_carrots/configuration.rb +15 -1
- data/lib/rabbit_carrots/connection.rb +12 -0
- data/lib/rabbit_carrots/core.rb +190 -24
- data/lib/rabbit_carrots/tasks/rmq.rake +4 -1
- data/lib/rabbit_carrots/version.rb +1 -1
- data/lib/rabbit_carrots.rb +3 -0
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0412aaeeded0b8ecd59e6f694e12b9cd692d60f280012bd55517a8871b43c579
|
|
4
|
+
data.tar.gz: e013959ec40f1222529265807bcc637582d27d7a07a5be1d29f0cf1dafb46057
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13514329b505be358cd9ff2eddffa099c3a955150df51cfc045ac9ceb6d01779e848e707d391dcc4610ee75055cf251cf15174897758f29f4fea1f352f51639b
|
|
7
|
+
data.tar.gz: b4d242e1b5bc1050f4fc975f82cf287e7cb4410225b243a5ac05844cd3b6874456c4109c5e524ad353bca84b9ef89c36d1a1871e1dd7557eaa48845f859e19c8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-09-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Consumers stopped consuming, permanently, after any connection recovery.**
|
|
8
|
+
The exchange was declared passively, which records `durable: false` for
|
|
9
|
+
topology recovery. On the first reconnect Bunny replayed that against a
|
|
10
|
+
durable exchange, the broker answered `PRECONDITION_FAILED - inequivalent arg
|
|
11
|
+
'durable'`, and the channel closed, taking every consumer with it. The process
|
|
12
|
+
stayed alive and exited 0, so nothing restarted it.
|
|
13
|
+
- Acking or nacking a closed channel no longer raises into Bunny's work pool.
|
|
14
|
+
The rescue for a failed ack used to nack on the same closed channel, and that
|
|
15
|
+
second raise killed the consumer.
|
|
16
|
+
- `start` no longer raises `NameError` when the configured ORM is not loaded.
|
|
17
|
+
- `bunny` and `connection_pool` are required by the gem.
|
|
18
|
+
- Each consumer gets its own channel. Channels came from a pool that was
|
|
19
|
+
handed back as soon as `subscribe` returned, so once there were more
|
|
20
|
+
mappings than the pool size, consumers silently shared channels — and
|
|
21
|
+
therefore shared a prefetch, a work pool, and the fate of that channel.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- A supervisor that checks the connection, consumers and channels every
|
|
26
|
+
`supervision_interval`. After `unhealthy_grace` without consuming it shuts
|
|
27
|
+
down and exits non-zero so the process is restarted. Transient reconnects are
|
|
28
|
+
tolerated.
|
|
29
|
+
- `Core#start` returns false when the service died; `rabbit_carrots:eat` and the
|
|
30
|
+
Puma plugin exit non-zero on it. `Core#healthy?` added.
|
|
31
|
+
- Config: `rabbitmq_exchange_durable` (default `true`), `prefetch` (default 10,
|
|
32
|
+
was hardcoded), `supervision_interval` (5s), `startup_grace` (60s),
|
|
33
|
+
`unhealthy_grace` (60s).
|
|
34
|
+
- Specs that run against a real broker (`docker compose up -d`), including a
|
|
35
|
+
regression test that severs the connection and asserts messages are still
|
|
36
|
+
consumed.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `bunny` floor raised from `>= 2.22` to `>= 3.1`; 2.x and 3.x record topology
|
|
41
|
+
differently and only 3.x is tested.
|
|
42
|
+
- The rake task and Puma plugin now exit non-zero when the consumer dies.
|
|
43
|
+
|
|
3
44
|
## [0.1.0] - 2022-12-01
|
|
4
45
|
|
|
5
46
|
- Initial release
|
data/Gemfile
CHANGED
|
@@ -5,10 +5,10 @@ source 'https://rubygems.org'
|
|
|
5
5
|
# Specify your gem's dependencies in rabbit_carrots.gemspec
|
|
6
6
|
gemspec
|
|
7
7
|
|
|
8
|
-
gem 'rake', '~> 13.
|
|
8
|
+
gem 'rake', '~> 13.4.2'
|
|
9
9
|
|
|
10
10
|
gem 'rspec', '~> 3.13.2'
|
|
11
11
|
|
|
12
|
-
gem 'rubocop', '~> 1.
|
|
12
|
+
gem 'rubocop', '~> 1.91.0'
|
|
13
13
|
|
|
14
|
-
gem 'rubocop-rails', '~> 2.
|
|
14
|
+
gem 'rubocop-rails', '~> 2.38.0'
|
data/Gemfile.lock
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
rabbit_carrots (1.
|
|
5
|
-
bunny (>=
|
|
4
|
+
rabbit_carrots (1.2.0)
|
|
5
|
+
bunny (>= 3.1)
|
|
6
6
|
connection_pool (>= 2.4)
|
|
7
7
|
|
|
8
8
|
GEM
|
|
9
9
|
remote: https://rubygems.org/
|
|
10
10
|
specs:
|
|
11
|
-
activesupport (8.1.
|
|
11
|
+
activesupport (8.1.3.1)
|
|
12
12
|
base64
|
|
13
13
|
bigdecimal
|
|
14
14
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
@@ -21,36 +21,40 @@ GEM
|
|
|
21
21
|
securerandom (>= 0.3)
|
|
22
22
|
tzinfo (~> 2.0, >= 2.0.5)
|
|
23
23
|
uri (>= 0.13.1)
|
|
24
|
-
amq-protocol (2.
|
|
24
|
+
amq-protocol (2.9.0)
|
|
25
25
|
ast (2.4.3)
|
|
26
26
|
base64 (0.3.0)
|
|
27
|
-
bigdecimal (4.
|
|
28
|
-
bunny (
|
|
29
|
-
amq-protocol (~> 2.
|
|
27
|
+
bigdecimal (4.1.3)
|
|
28
|
+
bunny (3.4.0)
|
|
29
|
+
amq-protocol (~> 2.9)
|
|
30
|
+
logger (~> 1, >= 1.7)
|
|
31
|
+
openssl (>= 3.3)
|
|
30
32
|
sorted_set (~> 1, >= 1.0.2)
|
|
31
|
-
concurrent-ruby (1.3.
|
|
33
|
+
concurrent-ruby (1.3.8)
|
|
32
34
|
connection_pool (3.0.2)
|
|
33
35
|
diff-lcs (1.6.2)
|
|
34
36
|
drb (2.2.3)
|
|
35
|
-
i18n (1.
|
|
37
|
+
i18n (1.15.2)
|
|
36
38
|
concurrent-ruby (~> 1.0)
|
|
37
|
-
json (
|
|
38
|
-
language_server-protocol (3.17.0.
|
|
39
|
+
json (3.0.2)
|
|
40
|
+
language_server-protocol (3.17.0.6)
|
|
39
41
|
lint_roller (1.1.0)
|
|
40
42
|
logger (1.7.0)
|
|
41
|
-
minitest (6.0.
|
|
43
|
+
minitest (6.0.6)
|
|
44
|
+
drb (~> 2.0)
|
|
42
45
|
prism (~> 1.5)
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
openssl (4.0.2)
|
|
47
|
+
parallel (2.2.0)
|
|
48
|
+
parser (3.3.12.0)
|
|
45
49
|
ast (~> 2.4.1)
|
|
46
50
|
racc
|
|
47
|
-
prism (1.
|
|
51
|
+
prism (1.9.0)
|
|
48
52
|
racc (1.8.1)
|
|
49
|
-
rack (3.2.
|
|
53
|
+
rack (3.2.7)
|
|
50
54
|
rainbow (3.1.1)
|
|
51
|
-
rake (13.
|
|
52
|
-
rbtree (0.4.
|
|
53
|
-
regexp_parser (2.
|
|
55
|
+
rake (13.4.2)
|
|
56
|
+
rbtree (0.4.7)
|
|
57
|
+
regexp_parser (2.13.0)
|
|
54
58
|
rspec (3.13.2)
|
|
55
59
|
rspec-core (~> 3.13.0)
|
|
56
60
|
rspec-expectations (~> 3.13.0)
|
|
@@ -60,53 +64,54 @@ GEM
|
|
|
60
64
|
rspec-expectations (3.13.5)
|
|
61
65
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
62
66
|
rspec-support (~> 3.13.0)
|
|
63
|
-
rspec-mocks (3.13.
|
|
67
|
+
rspec-mocks (3.13.8)
|
|
64
68
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
65
69
|
rspec-support (~> 3.13.0)
|
|
66
|
-
rspec-support (3.13.
|
|
67
|
-
rubocop (1.
|
|
68
|
-
json (
|
|
70
|
+
rspec-support (3.13.7)
|
|
71
|
+
rubocop (1.91.0)
|
|
72
|
+
json (>= 2.3)
|
|
69
73
|
language_server-protocol (~> 3.17.0.2)
|
|
70
74
|
lint_roller (~> 1.1.0)
|
|
71
|
-
parallel (
|
|
75
|
+
parallel (>= 1.10)
|
|
72
76
|
parser (>= 3.3.0.2)
|
|
73
77
|
rainbow (>= 2.2.2, < 4.0)
|
|
74
78
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
75
|
-
rubocop-ast (>= 1.
|
|
79
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
76
80
|
ruby-progressbar (~> 1.7)
|
|
77
81
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
78
|
-
rubocop-ast (1.
|
|
82
|
+
rubocop-ast (1.50.0)
|
|
79
83
|
parser (>= 3.3.7.2)
|
|
80
84
|
prism (~> 1.7)
|
|
81
|
-
rubocop-rails (2.
|
|
85
|
+
rubocop-rails (2.38.0)
|
|
82
86
|
activesupport (>= 4.2.0)
|
|
83
87
|
lint_roller (~> 1.1)
|
|
84
88
|
rack (>= 1.1)
|
|
85
|
-
rubocop (>= 1.
|
|
89
|
+
rubocop (>= 1.89.0, < 2.0)
|
|
86
90
|
rubocop-ast (>= 1.44.0, < 2.0)
|
|
87
91
|
ruby-progressbar (1.13.0)
|
|
88
92
|
securerandom (0.4.1)
|
|
89
|
-
|
|
90
|
-
sorted_set (1.0.3)
|
|
93
|
+
sorted_set (1.1.0)
|
|
91
94
|
rbtree
|
|
92
|
-
set (~> 1.0)
|
|
93
95
|
tzinfo (2.0.6)
|
|
94
96
|
concurrent-ruby (~> 1.0)
|
|
95
|
-
unicode-display_width (3.
|
|
96
|
-
unicode-emoji (~> 4.
|
|
97
|
-
unicode-emoji (4.
|
|
97
|
+
unicode-display_width (3.3.0)
|
|
98
|
+
unicode-emoji (~> 4.3)
|
|
99
|
+
unicode-emoji (4.3.0)
|
|
98
100
|
uri (1.1.1)
|
|
99
101
|
|
|
100
102
|
PLATFORMS
|
|
103
|
+
aarch64-linux-musl
|
|
101
104
|
arm64-darwin-25
|
|
102
|
-
|
|
105
|
+
x86-linux-musl
|
|
106
|
+
x86_64-linux-gnu
|
|
107
|
+
x86_64-linux-musl
|
|
103
108
|
|
|
104
109
|
DEPENDENCIES
|
|
105
110
|
rabbit_carrots!
|
|
106
|
-
rake (~> 13.
|
|
111
|
+
rake (~> 13.4.2)
|
|
107
112
|
rspec (~> 3.13.2)
|
|
108
|
-
rubocop (~> 1.
|
|
109
|
-
rubocop-rails (~> 2.
|
|
113
|
+
rubocop (~> 1.91.0)
|
|
114
|
+
rubocop-rails (~> 2.38.0)
|
|
110
115
|
|
|
111
116
|
BUNDLED WITH
|
|
112
|
-
|
|
117
|
+
4.0.19
|
data/README.md
CHANGED
|
@@ -33,9 +33,14 @@ RabbitCarrots.configure do |c|
|
|
|
33
33
|
c.rabbitmq_password = ENV.fetch('RABBITMQ__PASSWORD', nil)
|
|
34
34
|
c.rabbitmq_vhost = ENV.fetch('RABBITMQ__VHOST', nil)
|
|
35
35
|
c.rabbitmq_exchange_name = ENV.fetch('RABBITMQ__EXCHANGE_NAME', nil)
|
|
36
|
+
c.rabbitmq_exchange_durable = true # must match the exchange on the broker
|
|
36
37
|
c.automatically_recover = true
|
|
37
38
|
c.network_recovery_interval = 5
|
|
38
39
|
c.recovery_attempts = 5
|
|
40
|
+
c.prefetch = 10
|
|
41
|
+
c.supervision_interval = 5
|
|
42
|
+
c.startup_grace = 60
|
|
43
|
+
c.unhealthy_grace = 60
|
|
39
44
|
c.orm = :activerecord || :mongoid
|
|
40
45
|
c.routing_key_mappings = [
|
|
41
46
|
{ routing_keys: ['RK1', 'RK2'], queue: 'QUEUE_NAME', handler: 'CLASS HANDLER IN STRING' },
|
|
@@ -63,6 +68,38 @@ If no errors are thrown, the message will be acknowledged soon after the ```hand
|
|
|
63
68
|
|
|
64
69
|
Note: Any other unrescued exception raised inside ```handle!``` the that is a subclass of ```StandardError``` will trigger a NACK and re-queue.
|
|
65
70
|
|
|
71
|
+
### Exchange durability
|
|
72
|
+
|
|
73
|
+
`rabbitmq_exchange_durable` has to match the exchange as it exists on the
|
|
74
|
+
broker. It defaults to `true`, which is what a shared event bus exchange
|
|
75
|
+
normally is.
|
|
76
|
+
|
|
77
|
+
This is not cosmetic. Bunny records how an entity was declared and replays that
|
|
78
|
+
declaration during automatic topology recovery, and topology recovery is not
|
|
79
|
+
configurable. Declare a durable exchange as non-durable and every reconnect —
|
|
80
|
+
a node put into maintenance, a failover, a brief network blip — is answered with
|
|
81
|
+
`PRECONDITION_FAILED - inequivalent arg 'durable'`, which closes the channel and
|
|
82
|
+
takes every consumer on it with it. The process stays up and stops consuming.
|
|
83
|
+
|
|
84
|
+
If the durability is wrong, the service now says so and refuses to start rather
|
|
85
|
+
than consuming from a channel that will not survive its first reconnect.
|
|
86
|
+
|
|
87
|
+
### Health and restarts
|
|
88
|
+
|
|
89
|
+
A consumer subscribes and then does its work on Bunny's own threads, so the
|
|
90
|
+
process staying alive says nothing about whether it is still consuming. The
|
|
91
|
+
supervisor checks every `supervision_interval` seconds that the connection is
|
|
92
|
+
open, every consumer is still registered, and every channel is still alive.
|
|
93
|
+
|
|
94
|
+
A reconnect in progress is indistinguishable from a dead consumer, so the
|
|
95
|
+
service is only declared dead after `unhealthy_grace` seconds without
|
|
96
|
+
consuming. It then shuts down and **exits non-zero**, so that Kubernetes, Puma
|
|
97
|
+
or systemd restarts it. Make sure whatever supervises the process actually
|
|
98
|
+
restarts it on failure.
|
|
99
|
+
|
|
100
|
+
`Core#start` returns `true` when it shut down because it was asked to, and
|
|
101
|
+
`false` when it died.
|
|
102
|
+
|
|
66
103
|
### Running
|
|
67
104
|
|
|
68
105
|
For better scalability and improved performance, you can run rabbit_carrots in standalone mode by invoking the following command:
|
|
@@ -30,9 +30,14 @@ Puma::Plugin.create do
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
32
|
def start_rabbit_carrots_consumer
|
|
33
|
-
core_service.start(kill_to_restart_on_standard_error: true)
|
|
33
|
+
healthy = core_service.start(kill_to_restart_on_standard_error: true)
|
|
34
|
+
|
|
35
|
+
# Exits the forked child, not the Rails process, so that
|
|
36
|
+
# monitor_rabbit_carrots notices and brings Puma down with it.
|
|
37
|
+
exit(1) unless healthy # rubocop:disable Rails/Exit
|
|
34
38
|
rescue StandardError => e
|
|
35
39
|
Rails.logger.error "Error starting Rabbit Carrots: #{e.message}"
|
|
40
|
+
exit(1) # rubocop:disable Rails/Exit
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def stop_rabbit_carrots
|
|
@@ -16,9 +16,23 @@ module RabbitCarrots
|
|
|
16
16
|
:rabbitmq_vhost,
|
|
17
17
|
:routing_key_mappings,
|
|
18
18
|
:rabbitmq_exchange_name,
|
|
19
|
+
# Must match the exchange on the broker, or topology recovery
|
|
20
|
+
# is rejected and every consumer is lost. Defaults to true.
|
|
21
|
+
:rabbitmq_exchange_durable,
|
|
19
22
|
:automatically_recover,
|
|
20
23
|
:network_recovery_interval,
|
|
21
|
-
:recovery_attempts
|
|
24
|
+
:recovery_attempts,
|
|
25
|
+
# Unacked messages allowed per consumer. Defaults to 10.
|
|
26
|
+
:prefetch,
|
|
27
|
+
# Seconds between supervisor health checks. Defaults to 5.
|
|
28
|
+
:supervision_interval,
|
|
29
|
+
# Seconds to wait for every consumer to subscribe at boot.
|
|
30
|
+
# Defaults to 60.
|
|
31
|
+
:startup_grace,
|
|
32
|
+
# Seconds the service may go without consuming before the
|
|
33
|
+
# supervisor gives up. Must outlast Bunny's recovery.
|
|
34
|
+
# Defaults to 60.
|
|
35
|
+
:unhealthy_grace
|
|
22
36
|
|
|
23
37
|
def orm
|
|
24
38
|
@orm ||= :activerecord
|
|
@@ -27,5 +27,17 @@ module RabbitCarrots
|
|
|
27
27
|
connection.create_channel
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
# Consumers get a channel of their own rather than one from the pool. A
|
|
32
|
+
# pooled channel is returned as soon as `subscribe` returns, so several
|
|
33
|
+
# consumers end up sharing one channel, which means they share a prefetch,
|
|
34
|
+
# a work pool, and the fate of that channel when it errors.
|
|
35
|
+
def create_channel(...)
|
|
36
|
+
connection.create_channel(...)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def open?
|
|
40
|
+
!connection.nil? && connection.open?
|
|
41
|
+
end
|
|
30
42
|
end
|
|
31
43
|
end
|
data/lib/rabbit_carrots/core.rb
CHANGED
|
@@ -13,19 +13,29 @@ module RabbitCarrots
|
|
|
13
13
|
def initialize(logger: nil)
|
|
14
14
|
@logger = create_logger_adapter(logger || Logger.new(Rails.env.production? ? '/proc/self/fd/1' : $stdout))
|
|
15
15
|
@threads = []
|
|
16
|
+
@consumers = []
|
|
17
|
+
@consumers_mutex = Mutex.new
|
|
16
18
|
@running = true
|
|
17
19
|
@shutdown_requested = false
|
|
20
|
+
@healthy = true
|
|
18
21
|
end
|
|
19
22
|
|
|
23
|
+
# Returns true when the service shut down because it was asked to, false
|
|
24
|
+
# when it shut down because it could no longer consume.
|
|
20
25
|
def start(kill_to_restart_on_standard_error: false)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
self.class.
|
|
26
|
+
activerecord = RabbitCarrots.configuration.orm == :activerecord
|
|
27
|
+
|
|
28
|
+
self.class.database_agnostic_not_null_violation = activerecord ? error_class('ActiveRecord::NotNullViolation') : RabbitCarrots::EventHandlers::Errors::PlaceholderError
|
|
29
|
+
self.class.database_agnostic_connection_not_established = error_class(activerecord ? 'ActiveRecord::ConnectionNotEstablished' : 'Mongo::Error::SocketError')
|
|
30
|
+
self.class.database_agnostic_record_invalid = error_class(activerecord ? 'ActiveRecord::RecordInvalid' : 'Mongoid::Errors::Validations')
|
|
24
31
|
|
|
25
32
|
channels = RabbitCarrots.configuration.routing_key_mappings.map do |mapping|
|
|
26
33
|
{ **mapping, handler: mapping[:handler].constantize }
|
|
27
34
|
end
|
|
28
35
|
|
|
36
|
+
@expected_consumers = channels.size
|
|
37
|
+
@startup_deadline = monotonic_now + startup_grace
|
|
38
|
+
|
|
29
39
|
channels.each do |channel|
|
|
30
40
|
handler_class = channel[:handler]
|
|
31
41
|
raise "#{handler_class.name} must respond to `handle!`" unless handler_class.respond_to?(:handle!)
|
|
@@ -45,18 +55,14 @@ module RabbitCarrots
|
|
|
45
55
|
Signal.trap('INT') { request_shutdown }
|
|
46
56
|
Signal.trap('TERM') { request_shutdown }
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
if @shutdown_requested
|
|
50
|
-
request_shutdown
|
|
51
|
-
sleep 1
|
|
52
|
-
break
|
|
53
|
-
end
|
|
54
|
-
sleep 1
|
|
55
|
-
end
|
|
58
|
+
supervise
|
|
56
59
|
|
|
57
60
|
@threads.each(&:join)
|
|
61
|
+
|
|
62
|
+
@healthy
|
|
58
63
|
rescue StandardError => e
|
|
59
64
|
logger.error "Error starting Rabbit Carrots: #{e.message}"
|
|
65
|
+
false
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def request_shutdown
|
|
@@ -77,53 +83,213 @@ module RabbitCarrots
|
|
|
77
83
|
@running = false
|
|
78
84
|
end
|
|
79
85
|
|
|
86
|
+
def healthy?
|
|
87
|
+
@healthy
|
|
88
|
+
end
|
|
89
|
+
|
|
80
90
|
def run_task(queue_name:, handler_class:, routing_keys:, queue_arguments: {}, exchange_name: nil, kill_to_restart_on_standard_error: false)
|
|
81
|
-
RabbitCarrots::Connection.instance.
|
|
82
|
-
|
|
91
|
+
channel = RabbitCarrots::Connection.instance.create_channel
|
|
92
|
+
exchange_name ||= RabbitCarrots.configuration.rabbitmq_exchange_name
|
|
93
|
+
exchange = declare_exchange(channel, exchange_name)
|
|
83
94
|
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
logger.info "Listening on QUEUE: #{queue_name} for ROUTING KEYS: #{routing_keys}"
|
|
96
|
+
queue = channel.queue(queue_name, durable: true, arguments: queue_arguments)
|
|
86
97
|
|
|
87
|
-
|
|
98
|
+
routing_keys.map(&:strip).each { |k| queue.bind(exchange, routing_key: k) }
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
consumer = queue.subscribe(block: false, manual_ack: true, prefetch:) do |delivery_info, properties, payload|
|
|
101
|
+
next if @shutdown_requested
|
|
91
102
|
|
|
103
|
+
begin
|
|
92
104
|
logger.info "Received from queue: #{queue_name}, Routing Keys: #{routing_keys}"
|
|
93
105
|
handler_class.handle!(channel, delivery_info, properties, payload)
|
|
94
|
-
channel
|
|
106
|
+
acknowledge(channel, delivery_info)
|
|
95
107
|
rescue RabbitCarrots::EventHandlers::Errors::NackMessage, JSON::ParserError => _e
|
|
96
108
|
payload = encode_payload(payload)
|
|
97
109
|
logger.warn "Nacked message: #{payload}"
|
|
98
|
-
channel
|
|
110
|
+
reject(channel, delivery_info, requeue: false)
|
|
99
111
|
rescue RabbitCarrots::EventHandlers::Errors::NackAndRequeueMessage => _e
|
|
100
112
|
payload = encode_payload(payload)
|
|
101
113
|
logger.warn "Nacked and Requeued message: #{payload}"
|
|
102
|
-
channel
|
|
114
|
+
reject(channel, delivery_info, requeue: true)
|
|
103
115
|
rescue self.class.database_agnostic_not_null_violation, self.class.database_agnostic_record_invalid => e
|
|
104
116
|
payload = encode_payload(payload)
|
|
105
117
|
logger.warn "Null constraint or Invalid violation: #{payload}. Error: #{e.message}"
|
|
106
|
-
channel
|
|
118
|
+
acknowledge(channel, delivery_info)
|
|
107
119
|
rescue self.class.database_agnostic_connection_not_established => e
|
|
108
120
|
payload = encode_payload(payload)
|
|
109
121
|
logger.warn "Error connection not established to the database: #{payload}. Error: #{e.message}"
|
|
110
122
|
sleep 3
|
|
111
|
-
channel
|
|
123
|
+
reject(channel, delivery_info, requeue: true)
|
|
112
124
|
rescue StandardError => e
|
|
113
125
|
payload = encode_payload(payload)
|
|
114
126
|
logger.error "Error handling message: #{payload}. Error: #{e.message}"
|
|
115
127
|
sleep 3
|
|
116
|
-
channel
|
|
128
|
+
reject(channel, delivery_info, requeue: true)
|
|
117
129
|
Process.kill('SIGTERM', Process.pid) if kill_to_restart_on_standard_error
|
|
118
130
|
end
|
|
119
131
|
end
|
|
132
|
+
|
|
133
|
+
register_consumer(queue_name:, channel:, consumer:)
|
|
120
134
|
rescue StandardError => e
|
|
121
135
|
logger.error "Bunny session error: #{e.message}"
|
|
136
|
+
@healthy = false
|
|
122
137
|
request_shutdown
|
|
123
138
|
end
|
|
124
139
|
|
|
125
140
|
private
|
|
126
141
|
|
|
142
|
+
# Falls back to a class that is never raised when the ORM is not loaded.
|
|
143
|
+
def error_class(name)
|
|
144
|
+
Object.const_get(name)
|
|
145
|
+
rescue NameError
|
|
146
|
+
RabbitCarrots::EventHandlers::Errors::PlaceholderError
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Don't declare passively; Bunny topology recovery replays options, causing durable/exchange mismatch errors.
|
|
150
|
+
def declare_exchange(channel, exchange_name)
|
|
151
|
+
channel.topic(exchange_name, durable: exchange_durable?)
|
|
152
|
+
rescue Bunny::PreconditionFailed => e
|
|
153
|
+
logger.error(
|
|
154
|
+
"Exchange '#{exchange_name}' exists on the broker with a different durability than the one " \
|
|
155
|
+
"declared here (durable: #{exchange_durable?}). Set `config.rabbitmq_exchange_durable` to " \
|
|
156
|
+
"match the exchange. Error: #{e.message}"
|
|
157
|
+
)
|
|
158
|
+
raise
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def exchange_durable?
|
|
162
|
+
durable = RabbitCarrots.configuration.rabbitmq_exchange_durable
|
|
163
|
+
durable.nil? ? true : durable # rubocop:disable Style/RedundantCondition
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def prefetch
|
|
167
|
+
RabbitCarrots.configuration.prefetch || 10
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def supervision_interval
|
|
171
|
+
RabbitCarrots.configuration.supervision_interval || 5
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def startup_grace
|
|
175
|
+
RabbitCarrots.configuration.startup_grace || 60
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def unhealthy_grace
|
|
179
|
+
RabbitCarrots.configuration.unhealthy_grace || 60
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def register_consumer(queue_name:, channel:, consumer:)
|
|
183
|
+
@consumers_mutex.synchronize do
|
|
184
|
+
@consumers << { queue_name:, channel:, consumer: }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# `subscribe` is non-blocking, so the consumer threads finish at boot and
|
|
189
|
+
# every delivery happens on Bunny's threads, which this class does not own.
|
|
190
|
+
# Without this loop, a channel closed by a failed recovery leaves the
|
|
191
|
+
# process alive and idle forever.
|
|
192
|
+
def supervise
|
|
193
|
+
while @running
|
|
194
|
+
if @shutdown_requested
|
|
195
|
+
request_shutdown
|
|
196
|
+
sleep 1
|
|
197
|
+
break
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if consumers_healthy?
|
|
201
|
+
logger.info 'Rabbit Carrots recovered and is consuming again' if @degraded_since
|
|
202
|
+
@degraded_since = nil
|
|
203
|
+
else
|
|
204
|
+
@degraded_since ||= monotonic_now
|
|
205
|
+
degraded_for = monotonic_now - @degraded_since
|
|
206
|
+
|
|
207
|
+
if degraded_for >= unhealthy_grace
|
|
208
|
+
@healthy = false
|
|
209
|
+
logger.error "Rabbit Carrots has not been consuming for #{degraded_for.round}s; shutting down so the process can be restarted"
|
|
210
|
+
request_shutdown
|
|
211
|
+
break
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A reconnect in progress is indistinguishable from a dead consumer.
|
|
215
|
+
logger.warn "Rabbit Carrots is not consuming (#{degraded_for.round}s); waiting for recovery"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
sleep supervision_interval
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def consumers_healthy?
|
|
223
|
+
return false unless connection_open?
|
|
224
|
+
|
|
225
|
+
registered = @consumers_mutex.synchronize { @consumers.dup }
|
|
226
|
+
|
|
227
|
+
if registered.size < @expected_consumers.to_i
|
|
228
|
+
return true if monotonic_now < @startup_deadline
|
|
229
|
+
|
|
230
|
+
logger.warn "Only #{registered.size} of #{@expected_consumers} consumers registered within #{startup_grace}s"
|
|
231
|
+
return false
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
registered.all? { |entry| consumer_alive?(entry) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def consumer_alive?(entry)
|
|
238
|
+
channel = entry[:channel]
|
|
239
|
+
consumer = entry[:consumer]
|
|
240
|
+
|
|
241
|
+
unless channel.open?
|
|
242
|
+
logger.warn "Channel for queue #{entry[:queue_name]} is closed"
|
|
243
|
+
return false
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Bunny can regenerate the tag when it recovers a consumer.
|
|
247
|
+
return true if channel.consumers.key?(consumer.consumer_tag) || channel.consumers.value?(consumer)
|
|
248
|
+
|
|
249
|
+
logger.warn "Consumer for queue #{entry[:queue_name]} is no longer registered on its channel"
|
|
250
|
+
false
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
logger.warn "Could not determine consumer health for queue #{entry[:queue_name]}: #{e.message}"
|
|
253
|
+
false
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def connection_open?
|
|
257
|
+
return true if RabbitCarrots::Connection.instance.open?
|
|
258
|
+
|
|
259
|
+
logger.warn 'RabbitMQ connection is closed'
|
|
260
|
+
false
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
logger.warn "Could not determine RabbitMQ connection health: #{e.message}"
|
|
263
|
+
false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Acking a closed channel raises, and so does the nack in the rescue that
|
|
267
|
+
# follows; that second raise escapes into Bunny's work pool and takes the
|
|
268
|
+
# consumer with it. The broker redelivers whatever was left unacked.
|
|
269
|
+
def acknowledge(channel, delivery_info)
|
|
270
|
+
return false unless channel.open?
|
|
271
|
+
|
|
272
|
+
channel.ack(delivery_info.delivery_tag, false)
|
|
273
|
+
true
|
|
274
|
+
rescue StandardError => e
|
|
275
|
+
logger.error "Failed to ack message on a closed or closing channel: #{e.message}"
|
|
276
|
+
false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def reject(channel, delivery_info, requeue:)
|
|
280
|
+
return false unless channel.open?
|
|
281
|
+
|
|
282
|
+
channel.nack(delivery_info.delivery_tag, false, requeue)
|
|
283
|
+
true
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
logger.error "Failed to nack message on a closed or closing channel: #{e.message}"
|
|
286
|
+
false
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def monotonic_now
|
|
290
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
291
|
+
end
|
|
292
|
+
|
|
127
293
|
def create_logger_adapter(logger)
|
|
128
294
|
return logger if logger.respond_to?(:info) && logger.respond_to?(:error) && logger.respond_to?(:warn)
|
|
129
295
|
|
|
@@ -8,6 +8,9 @@ namespace :rabbit_carrots do
|
|
|
8
8
|
|
|
9
9
|
core_service = RabbitCarrots::Core.new(logger:)
|
|
10
10
|
|
|
11
|
-
core_service.start(kill_to_restart_on_standard_error: true)
|
|
11
|
+
healthy = core_service.start(kill_to_restart_on_standard_error: true)
|
|
12
|
+
|
|
13
|
+
# Exit non-zero so that whatever supervises the process restarts it.
|
|
14
|
+
exit(1) unless healthy
|
|
12
15
|
end
|
|
13
16
|
end
|
data/lib/rabbit_carrots.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rabbit_carrots
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brusk Awat
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bunny
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '3.1'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '3.1'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: connection_pool
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|