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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8e5f321b0b92e7e1c3f1926e28b692398fd453e22c591ad0715fa7bc93b1b34
4
- data.tar.gz: 5be7f4d4378c6a0fdc4f2edbec9fd438dca66a98ec1b9659d6a6a44d8d4d3cb4
3
+ metadata.gz: 0412aaeeded0b8ecd59e6f694e12b9cd692d60f280012bd55517a8871b43c579
4
+ data.tar.gz: e013959ec40f1222529265807bcc637582d27d7a07a5be1d29f0cf1dafb46057
5
5
  SHA512:
6
- metadata.gz: 83bde341ce60aaaee1bcfd1c323484641fcfbc7e2d8b74fcd34ef0afe7eb91dc9d332254f34049dfe5b24120bfb717bf1d7cdc758130481059fcd9b1b5a7d848
7
- data.tar.gz: 585ff0eb908008acdc9b323dd138dcb676955c2c87586f62d71d7926c534cfe988c1e071ae2b308455299a3a1672123ea054a5a70f2abea3194fad2456f38af4
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.3.1'
8
+ gem 'rake', '~> 13.4.2'
9
9
 
10
10
  gem 'rspec', '~> 3.13.2'
11
11
 
12
- gem 'rubocop', '~> 1.82.1'
12
+ gem 'rubocop', '~> 1.91.0'
13
13
 
14
- gem 'rubocop-rails', '~> 2.34.3'
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.1.0)
5
- bunny (>= 2.22)
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.2)
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.5.0)
24
+ amq-protocol (2.9.0)
25
25
  ast (2.4.3)
26
26
  base64 (0.3.0)
27
- bigdecimal (4.0.1)
28
- bunny (2.24.0)
29
- amq-protocol (~> 2.3)
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.6)
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.14.8)
37
+ i18n (1.15.2)
36
38
  concurrent-ruby (~> 1.0)
37
- json (2.18.0)
38
- language_server-protocol (3.17.0.5)
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.1)
43
+ minitest (6.0.6)
44
+ drb (~> 2.0)
42
45
  prism (~> 1.5)
43
- parallel (1.27.0)
44
- parser (3.3.10.1)
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.8.0)
51
+ prism (1.9.0)
48
52
  racc (1.8.1)
49
- rack (3.2.4)
53
+ rack (3.2.7)
50
54
  rainbow (3.1.1)
51
- rake (13.3.1)
52
- rbtree (0.4.6)
53
- regexp_parser (2.11.3)
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.7)
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.6)
67
- rubocop (1.82.1)
68
- json (~> 2.3)
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 (~> 1.10)
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.48.0, < 2.0)
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.49.0)
82
+ rubocop-ast (1.50.0)
79
83
  parser (>= 3.3.7.2)
80
84
  prism (~> 1.7)
81
- rubocop-rails (2.34.3)
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.75.0, < 2.0)
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
- set (1.1.2)
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.2.0)
96
- unicode-emoji (~> 4.1)
97
- unicode-emoji (4.2.0)
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
- x86_64-linux
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.3.1)
111
+ rake (~> 13.4.2)
107
112
  rspec (~> 3.13.2)
108
- rubocop (~> 1.82.1)
109
- rubocop-rails (~> 2.34.3)
113
+ rubocop (~> 1.91.0)
114
+ rubocop-rails (~> 2.38.0)
110
115
 
111
116
  BUNDLED WITH
112
- 2.3.26
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
@@ -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
- self.class.database_agnostic_not_null_violation = RabbitCarrots.configuration.orm == :activerecord ? ActiveRecord::NotNullViolation : RabbitCarrots::EventHandlers::Errors::PlaceholderError
22
- self.class.database_agnostic_connection_not_established = RabbitCarrots.configuration.orm == :activerecord ? ActiveRecord::ConnectionNotEstablished : ::Mongo::Error::SocketError
23
- self.class.database_agnostic_record_invalid = RabbitCarrots.configuration.orm == :activerecord ? ActiveRecord::RecordInvalid : ::Mongoid::Errors::Validations
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
- while @running
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.channel.with do |channel|
82
- exchange = channel.topic(exchange_name || RabbitCarrots.configuration.rabbitmq_exchange_name, durable: true)
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
- logger.info "Listening on QUEUE: #{queue_name} for ROUTING KEYS: #{routing_keys}"
85
- queue = channel.queue(queue_name, durable: true, arguments: queue_arguments)
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
- routing_keys.map(&:strip).each { |k| queue.bind(exchange, routing_key: k) }
98
+ routing_keys.map(&:strip).each { |k| queue.bind(exchange, routing_key: k) }
88
99
 
89
- queue.subscribe(block: false, manual_ack: true, prefetch: 10) do |delivery_info, properties, payload|
90
- break if @shutdown_requested
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.ack(delivery_info.delivery_tag, false)
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.nack(delivery_info.delivery_tag, false, false)
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.nack(delivery_info.delivery_tag, false, true)
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.ack(delivery_info.delivery_tag, false)
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.nack(delivery_info.delivery_tag, false, true)
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.nack(delivery_info.delivery_tag, false, true)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RabbitCarrots
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bunny'
4
+ require 'connection_pool'
5
+
3
6
  require_relative 'rabbit_carrots/version'
4
7
  require 'rabbit_carrots/errors'
5
8
  require 'rabbit_carrots/connection'
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.1.0
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-01-18 00:00:00.000000000 Z
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: '2.22'
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: '2.22'
26
+ version: '3.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: connection_pool
29
29
  requirement: !ruby/object:Gem::Requirement