async-rabbitmq 0.4.0 → 0.6.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: c8e21899276061f5e0ecd749000e4b7bf5b81932a491a1704e0b2e643cf647b1
4
- data.tar.gz: a7fcd5f434e5e677ad7fce840ca388e323264edd8b2347f8ed92ec73a0fd7a3e
3
+ metadata.gz: 77df2cb6ea955db0392941563a8e6c43cc55ad06d429c1f8d45ea05bd79ecb50
4
+ data.tar.gz: 1ea2fbdc554c5901638002e7d4c16cbaf5c9af7189434b06fb1003eef2894d14
5
5
  SHA512:
6
- metadata.gz: 0a41b23ef4e9d1fdad8403e8dbc99783f53205ed6b0fe5cb3e891b79d9763b9a9c0ec05bceda15f78de1fb47e0fb5b65bdbc5b756c2d19cf9c6609d2ee13fd3c
7
- data.tar.gz: 4e7795488172face945464fbd3d36c9355b7b6500705238079df4ae38b0c6529c67088c84323354c0772bd31562b9efb7b1927c06872b369addc4d56638c5815
6
+ metadata.gz: '0197dc20bc43a0863883f0a723a4120844c05bbc1542a42509fa7cb98473414f860d034a65cdf8e645a8a7aec414c6bfa347887b803d191c14745ec4dc4b2681'
7
+ data.tar.gz: aaa2b3835cfefed5612f5b0e93f68dd3f9985d0cd4436c1201e5d7907b2fe25c194a05249de67680b7531ce3c8aaa22bc8d95a8fa15317539755da9c6eb8f0ba
data/CHANGELOG.md CHANGED
@@ -2,6 +2,74 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.6.0] - 2026-10-11
6
+
7
+ ### Fixed
8
+
9
+ - A consumer handler raising outside `StandardError` no longer stops the channel dispatching.
10
+ `SystemStackError` and `ScriptError` now fail that delivery through the configured error
11
+ disposition. `Async::Stop`, `SystemExit`, `SignalException` and `NoMemoryError` still propagate.
12
+ Applies equally to the `on_handler_error` hook and to settling after an error.
13
+ - Handler workers are created on demand instead of one per prefetch slot. `basic_qos(prefetch_count: 500)`
14
+ previously created 500 fibers and retired all but `pool_size` of them unused: 12.6 MB at prefetch
15
+ 500 across two channels. Concurrency is unchanged.
16
+
17
+ ### Added
18
+
19
+ - `on_error:` on a consumer, and `Channel#on_error_disposition` for the channel default, choose what
20
+ happens to a delivery whose handler raised:
21
+ - `:dead_letter` (default) — `reject(requeue: false)`: one attempt, then the dead-letter route.
22
+ - `:retry` — `reject(requeue: true)`: counts towards `x-delivery-limit`.
23
+ - `:release` — `nack(requeue: true)`: requeued without counting towards the limit.
24
+ - `:leave` — settles nothing; the handler is responsible.
25
+ - `Channel#on_handler_error` receives `(error, delivery_info, queue_name)` and may return a
26
+ disposition to override the default for that delivery.
27
+
28
+ ## [0.5.0] - 2026-10-09
29
+
30
+ ### Fixed
31
+
32
+ - **A consumer backlog no longer costs a fiber per queued message.** The dispatch loop started an
33
+ `Async::Task` per delivery and took the pool semaphore from inside it, so `pool_size` capped how
34
+ many handlers *ran*, not how many *existed*. A parked task measured ~15.7 KB (the fiber object
35
+ plus its touched stack pages) against ~48 bytes for a queue entry, and 20,000 backlogged messages
36
+ cost 315 MB of RSS. Handlers now run in `pool_size` long-lived worker fibers draining a queue of
37
+ deliveries — the same shape as Bunny's `ConsumerWorkPool`, the .NET client's consumer dispatcher
38
+ and amqp091-go.
39
+
40
+ That queue is deliberately unbounded. The dispatch loop is also what delivers this channel's
41
+ confirms, close-ok and returns, so blocking it on a full queue would deadlock a handler that
42
+ publishes and waits for confirms — which is precisely rabbitmq-amqp-java-client#328, where a
43
+ bounded work pool parked the I/O thread in `LinkedBlockingQueue#put`. Memory is therefore bounded
44
+ by prefetch on a manual-ack consumer and unbounded on an auto-ack one, as it is in every client,
45
+ because automatic acknowledgement has no backpressure to offer.
46
+ - **The missing-prefetch warning no longer sends auto-ack consumers to a no-op.** RabbitMQ applies
47
+ prefetch only to *unacknowledged* messages, so `basic_qos` does nothing at all on a
48
+ `manual_ack: false` consumer. The warning told you to call it anyway, and then went quiet once you
49
+ had — silence in the one case where the caller most believes the consumer is bounded and it is
50
+ not. It now says which of the two cases you are in, and still warns an auto-ack consumer that has
51
+ set a prefetch.
52
+
53
+ ### Added
54
+
55
+ - `Channel#backlog` — deliveries handed to the handler workers but not yet picked up, the number
56
+ Bunny reports as `ConsumerWorkPool#backlog`.
57
+ - `AsyncRabbitMQ.warn_unbounded_consumers` — a deliberate way to silence the unbounded-consumer
58
+ warning for someone who has read it and accepted the trade, instead of turning the logger down
59
+ and losing every other warning a channel raises (stale delivery tags, broker-cancelled consumers,
60
+ handler exceptions). One process-wide setting, read when a consumer starts; defaults to `true`.
61
+
62
+ ### Changed
63
+
64
+ - A backlog still queued when the channel goes away is now **finished on a graceful `close`** (as
65
+ Bunny's `ConsumerWorkPool#shutdown` drains) and **discarded when the connection is lost or the
66
+ channel is reopened**, where the broker requeues the same messages and running the queued handlers
67
+ too would process each one twice.
68
+ - `Channel#pool_size=` resizes by adding or retiring workers rather than resizing a semaphore.
69
+ Growing takes effect at once; shrinking retires a worker as soon as the queue drains to the
70
+ sentinel — immediately when idle, after the current backlog when busy. A running handler is never
71
+ interrupted.
72
+
5
73
  ## [0.4.0] - 2026-10-07
6
74
 
7
75
  Reliability review against a payments workload. The two delivery-correctness items
data/README.md CHANGED
@@ -1,8 +1,53 @@
1
1
  # async-rabbitmq
2
2
 
3
- A fiber-native RabbitMQ (AMQP 0-9-1) client for Ruby, built on the
3
+ A fiber-native RabbitMQ (AMQP 0-9-1) client for Ruby, built on the amazing work
4
+ of [Bunny](https://github.com/ruby-amqp/bunny).
5
+
6
+ ## Installing
7
+
8
+ ```ruby
9
+ # Gemfile
10
+ gem "async-rabbitmq", "~> 0.5"
11
+ ```
12
+
13
+ or
14
+
15
+ ```bash
16
+ gem install async-rabbitmq
17
+ ```
18
+
19
+ Requires Ruby 3.4+ and RabbitMQ 3.13+ (tested against 4.x). The gem also
20
+ installs an `async-rabbitmq` command for publishing, consuming, inspecting and
21
+ purging from a terminal.
22
+
23
+ ## Bunny is the gold standard
24
+
25
+ Bunny is the client Ruby learned RabbitMQ on. Its API is the one Ruby developers
26
+ already know, its defaults are the ones a decade of production traffic settled,
27
+ and its source is the reference this library was written against. None of that
28
+ is improved on here, and none of it is meant to be.
29
+
30
+ **Parity with Bunny is a design goal**, chosen deliberately over fiber-native
31
+ maximalism: the audience for this gem is people who already know Bunny, and
32
+ surprising them is a bug. Where Bunny has an answer, that is the answer here.
33
+ `pool_size` defaults to 1 and serialises handlers because `ConsumerWorkPool`
34
+ does. A consumer backlog is an unbounded queue drained by a fixed set of workers
35
+ because that is what `ConsumerWorkPool` is. `basic_get` acknowledges manually by
36
+ default because Bunny's does. `spec/integration/25_bunny_api_parity_spec.rb`
37
+ exists to keep it honest.
38
+
39
+ Where this client does diverge, the README says so and says why - see the two
40
+ publishing differences below, and `basic_qos`, which also resizes the handler
41
+ pool where Bunny's leaves it alone.
42
+
43
+ If you are not running under the fiber scheduler, use Bunny. It is excellent,
44
+ and this gem has no advantage over it outside an async application.
45
+
46
+ ## What this adds
47
+
48
+ The runtime, not the API. Built on the
4
49
  [async](https://github.com/socketry/async) ecosystem and
5
- [amq-protocol](https://github.com/ruby-amqp/amq-protocol). No threads: the
50
+ [amq-protocol](https://github.com/ruby-amqp/amq-protocol), with no threads: the
6
51
  reader, writer, heartbeat and every consumer handler are fibers, so it fits
7
52
  Falcon, async-http and anything else running under the fiber scheduler.
8
53
 
@@ -140,11 +185,30 @@ ch.basic_cancel(tag)
140
185
  ch.each("q") { |delivery, header, body| ... } # blocks until the consumer or channel goes away
141
186
  ```
142
187
 
143
- Consumer handlers run in their own fibers, at most `pool_size` at a time per
144
- channel. Set `basic_qos` before `basic_consume`: without a prefetch limit the
145
- broker sends the whole queue as fast as it can and one fiber is created per
146
- delivery, so memory tracks queue depth rather than concurrency. The client
147
- warns once per channel if you don't.
188
+ Each channel runs `pool_size` long-lived handler fibers (default 1, serialized,
189
+ Bunny parity) draining a queue of deliveries, so a backlog costs the messages it
190
+ holds rather than a fiber per message. `ch.backlog` reports how many deliveries
191
+ are waiting, as Bunny's `ConsumerWorkPool#backlog` does.
192
+
193
+ What bounds that backlog depends on the ack mode. With `manual_ack: true`, set
194
+ `basic_qos` before `basic_consume` and the broker holds everything past the
195
+ prefetch window. With automatic acks there is no backpressure to be had:
196
+ RabbitMQ applies prefetch only to *unacknowledged* messages, so the broker
197
+ treats each one as acked on send and keeps sending whatever you set. The client
198
+ warns once per channel in either case, and says which of the two you are in.
199
+
200
+ If you have read that warning and accepted the trade - an auto-ack consumer on a
201
+ queue you know stays shallow, say - switch it off during boot:
202
+
203
+ ```ruby
204
+ AsyncRabbitMQ.warn_unbounded_consumers = false
205
+ ```
206
+
207
+ It is deliberately one process-wide setting rather than a per-channel option: an
208
+ application either accepts unbounded consumers or it does not. The point of
209
+ having it at all is that the alternative is turning the logger down, which would
210
+ also lose stale delivery tags, broker-cancelled consumers and handler
211
+ exceptions.
148
212
 
149
213
  ### Acknowledging, and delivery tags across a reconnect
150
214
 
@@ -174,17 +238,98 @@ sent and `false` when the tag belonged to an earlier generation and was
174
238
  dropped. A dropped ack is not a lost message: the broker requeued it when the
175
239
  channel went, and it is redelivered on the new connection.
176
240
 
177
- When a handler raises, the delivery is nacked with `requeue: false` — so a
178
- message that always fails dead-letters instead of looping — and
179
- `on_handler_error` is called. A delivery the handler already settled itself is
180
- left alone, so acking and then raising in whatever follows is safe:
241
+ ### When a handler raises
242
+
243
+ The delivery is settled for you. What happens to it is the *disposition*, and
244
+ there are three:
245
+
246
+ | | what it does | when |
247
+ |---|---|---|
248
+ | `:dead_letter` (default) | `reject(requeue: false)` | one attempt, then the queue's dead-letter route. Safe on any queue. |
249
+ | `:retry` | `reject(requeue: true)` | the message failed — hand it back and let the broker count it. See below. |
250
+ | `:release` | `nack(requeue: true)` | *not* the message's fault — hand it back without counting it. |
251
+ | `:leave` | nothing | you settle it yourself. This is Bunny's behaviour. |
252
+
253
+ `:retry` and `:release` both return the message; the difference is blame.
254
+ RabbitMQ counts a delivery as failed only on `basic.reject` (AMQP 1.0
255
+ `modified` with `delivery-failed`), not on `basic.nack` (AMQP 1.0 `released`).
256
+ So use `:retry` for a message that cannot be processed, and `:release` when the
257
+ message is innocent and you simply cannot handle it now — shutting down,
258
+ shedding load, a downstream outage. Rejecting during a gateway outage would
259
+ walk your whole backlog to the dead-letter queue in `x-delivery-limit`
260
+ attempts; releasing it costs the messages nothing.
261
+
262
+ The trade is that `:release` has no backstop: nothing advances towards
263
+ `x-delivery-limit`, so a genuinely poison message is redelivered forever. Use it
264
+ when you know the failure is yours, not the message's.
265
+
266
+ Set it per consumer, or channel-wide for all of them:
267
+
268
+ ```ruby
269
+ ch.on_error_disposition = :retry # channel default
270
+ ch.basic_consume("q", manual_ack: true, on_error: :leave) { |d, h, b| ... }
271
+ ```
272
+
273
+ A delivery the handler already settled itself is left alone, so acking and then
274
+ raising in whatever follows is safe.
275
+
276
+ `on_handler_error` runs **before** the delivery is settled, so what it returns
277
+ can decide this one message's fate. Return a disposition to override; return
278
+ anything else, or nothing, to take the configured default:
181
279
 
182
280
  ```ruby
183
281
  ch.on_handler_error do |error, delivery, queue_name|
184
282
  Sentry.capture_exception(error, extra: { queue: queue_name })
283
+ case error
284
+ when GatewayDown then :release # innocent, do not count it
285
+ when TransientUpstreamError then :retry # failed, count towards the limit
286
+ else :dead_letter
287
+ end
185
288
  end
186
289
  ```
187
290
 
291
+ A hook that raises is logged and the configured default applies, so a broken
292
+ hook cannot strand a delivery.
293
+
294
+ ### Letting the broker do the retrying
295
+
296
+ `:retry` is built for quorum queues, which count failed deliveries themselves:
297
+
298
+ ```ruby
299
+ ch.quorum_queue("orders.commands", arguments: {
300
+ "x-delivery-limit" => 5, # retire after 5 failures
301
+ "x-delayed-retry-type" => "failed", # back off between attempts
302
+ "x-delayed-retry-min" => 1_000,
303
+ "x-delayed-retry-max" => 30_000,
304
+ "x-dead-letter-exchange" => "",
305
+ "x-dead-letter-routing-key" => "orders.commands.parked",
306
+ })
307
+ ```
308
+
309
+ The broker spaces the attempts (`min(min_delay × delivery_count, max_delay)`),
310
+ counts them in the `x-delivery-count` header, and dead-letters the message once
311
+ `x-delivery-limit` is reached. There is no second delay queue to declare and no
312
+ attempt counting to write.
313
+
314
+ Two things that are easy to get wrong:
315
+
316
+ **It has to be `reject`, not `nack`.** RabbitMQ only treats a delivery as
317
+ *failed* on `basic.reject`; after `basic.nack` the `x-delivery-count` does not
318
+ move, so a nacked message is redelivered forever and never reaches the limit.
319
+ This client always rejects, which is why `:retry` works at all. If you settle
320
+ deliveries yourself, use `basic_reject`.
321
+
322
+ **Reconnects spend the same budget.** A channel closing with the delivery
323
+ unacknowledged — a disconnect, a recovery, a broker-side channel error — counts
324
+ as a failed delivery too. A message can therefore be dead-lettered by three
325
+ reconnects without your handler ever failing. Size `x-delivery-limit` for
326
+ handler failures *plus* the reconnects you expect, and read the dead-letter
327
+ queue as "failed or unlucky".
328
+
329
+ Without `x-delivery-limit`, `:retry` is an infinite loop for a message that
330
+ always fails. That is why the default is `:dead_letter`: it is safe on any
331
+ queue, and a default should not depend on configuration the client cannot see.
332
+
188
333
  ### RabbitMQ 4.2+ and transient queues
189
334
 
190
335
  RabbitMQ 4.2 and later refuse to declare a queue that is both non-durable
@@ -282,8 +427,7 @@ end
282
427
  ```
283
428
 
284
429
  `Async::Condition#wait`, a `sleep`, or your own supervisor task work equally
285
- well. Versions up to 0.3.0 kept the reactor alive by accident, because those
286
- tasks were children of whichever task called `connect`.
430
+ well.
287
431
 
288
432
  ## Connection recovery
289
433
 
@@ -9,6 +9,51 @@ module AsyncRabbitMQ
9
9
  # Duck-typed stream interface: #each (yields deliveries) and #write (publishes).
10
10
  # Does NOT inherit Async::IO::Stream — a channel is logical, not physical IO.
11
11
  class Channel
12
+ # What to do with a delivery whose handler raised. Chosen per consumer, or
13
+ # returned by the on_handler_error hook to decide per message.
14
+ #
15
+ # :dead_letter reject(requeue: false) -- one attempt, then the queue's
16
+ # dead-letter route. Safe on any queue, so it is the default:
17
+ # :retry on a queue with no delivery limit loops forever, and
18
+ # a default must not depend on configuration the client
19
+ # cannot see.
20
+ # :retry reject(requeue: true) -- hand it back and let the broker
21
+ # count. A quorum queue with x-delivery-limit retires it (and
22
+ # dead-letters it) once the limit is reached, with
23
+ # x-delayed-retry-* spacing the attempts.
24
+ # :release nack(requeue: true) -- hand it back WITHOUT counting the
25
+ # failure against the message. For a failure that is not the
26
+ # message's fault: shutting down, shedding load, a downstream
27
+ # outage. On a poison message this loops forever, since
28
+ # nothing advances towards x-delivery-limit.
29
+ # :leave settle nothing. Bunny's behaviour: the delivery stays
30
+ # unacknowledged and it is yours to resolve.
31
+ #
32
+ # :retry rejects and :release nacks, and the difference is the whole point.
33
+ # The broker counts a delivery as failed only on basic.reject (AMQP 1.0
34
+ # modified/delivery-failed); after basic.nack the x-delivery-count does not
35
+ # move (AMQP 1.0 released). So reject means "this message failed, hold it
36
+ # against it" and nack means "have it back, no blame". For a single
37
+ # delivery the verbs are otherwise equivalent.
38
+ ERROR_DISPOSITIONS = %i[dead_letter retry release leave].freeze
39
+
40
+ # A handler is application code and can raise anything. Catching only
41
+ # StandardError let a handler raising outside that hierarchy escape and take
42
+ # the channel's frame dispatch with it, so the catch below is deliberately
43
+ # as wide as Ruby allows - and these are what it has to put back.
44
+ #
45
+ # Async stops a task by raising Async::Stop into it, and Async::Stop is not
46
+ # a StandardError precisely so that ordinary rescues leave it alone.
47
+ # Swallowing it would leave handler workers unstoppable, and a channel close
48
+ # or a reactor shutdown would hang on a parked handler. The signals beside
49
+ # it are not this delivery's problem either: the process is exiting, was
50
+ # interrupted, or has run out of memory.
51
+ #
52
+ # SystemStackError and ScriptError are deliberately absent. An accidental
53
+ # infinite recursion in a handler is an ordinary bug and should fail that
54
+ # one delivery, not silence the channel.
55
+ PROPAGATE_FROM_HANDLER = [Async::Stop, SystemExit, SignalException, NoMemoryError].freeze
56
+
12
57
  # A message published under confirms whose fate is unknown: the broker
13
58
  # neither acked nor nacked it before the connection went. +payload+ and its
14
59
  # routing are kept so it can be published again on another connection.
@@ -24,6 +69,10 @@ module AsyncRabbitMQ
24
69
  # after an RPC timeout, used when the session has no rpc_timeout set.
25
70
  RESYNC_TIMEOUT = 5
26
71
 
72
+ # Pushed onto the work queue to retire one handler worker. Compared by
73
+ # identity, so it can never collide with a delivery.
74
+ WORKER_STOP = Object.new.freeze
75
+
27
76
  include Instrumented
28
77
 
29
78
  attr_reader :channel_id, :pool_size
@@ -73,6 +122,7 @@ module AsyncRabbitMQ
73
122
  @on_cancel = nil
74
123
  @on_error = nil
75
124
  @on_handler_error = nil
125
+ @on_error_disposition = :dead_letter
76
126
  @resyncing = false
77
127
  @resync_condition = nil
78
128
  # Incremented on every reopen (connection recovery or a single-channel
@@ -89,14 +139,27 @@ module AsyncRabbitMQ
89
139
  @pending_content = nil
90
140
  @recovered_condition = nil # fibers parked while the connection recovers
91
141
  @pool_size = validate_pool_size!(pool_size)
92
- @pool_sem = Async::Semaphore.new(@pool_size)
142
+ @work_queue = nil # Async::Queue of [method, header, body, entry]
143
+ @workers = [] # long-lived handler fibers draining it
93
144
  end
94
145
 
95
- # Resize the consumer-handler concurrency cap. Shrinking does not evict
96
- # already-running handlers; new deliveries park until permits free up.
146
+ # Resize the consumer-handler concurrency cap by adding or retiring
147
+ # workers. Growing takes effect at once. Shrinking is a sentinel on the
148
+ # work queue, so it retires a worker as soon as the queue drains to it:
149
+ # immediately when idle, after the current backlog when busy. A handler
150
+ # already running is never interrupted.
97
151
  def pool_size=(n)
98
- @pool_size = validate_pool_size!(n)
99
- @pool_sem.limit = @pool_size
152
+ n = validate_pool_size!(n)
153
+ return if n == @pool_size
154
+
155
+ @pool_size = n
156
+ resize_workers
157
+ end
158
+
159
+ # Deliveries handed to the handler workers but not yet picked up. Bunny
160
+ # reports the same number as ConsumerWorkPool#backlog.
161
+ def backlog
162
+ @work_queue&.size || 0
100
163
  end
101
164
 
102
165
  def open?
@@ -147,6 +210,7 @@ module AsyncRabbitMQ
147
210
  instrument("channel.closed") { { channel: @channel_id, reason: :user } }
148
211
  @session.channel_closed(@channel_id)
149
212
  @queue&.push(nil) # wake dispatch_loop so it can detect :closed and exit
213
+ stop_workers
150
214
  wake_each_waiters
151
215
  end
152
216
 
@@ -408,13 +472,19 @@ module AsyncRabbitMQ
408
472
  # gated by the channel's pool_size semaphore so at most +pool_size+
409
473
  # handlers run concurrently across all consumers on this channel.
410
474
  # Returns the consumer tag.
411
- def basic_consume(queue_name, consumer_tag: "", manual_ack: false, exclusive: false, arguments: {}, &block)
412
- warn_unbounded_prefetch(queue_name)
475
+ # +on_error+ decides what happens to a delivery whose handler raises; see
476
+ # ERROR_DISPOSITIONS. nil takes the channel's default, set with
477
+ # #on_error_disposition=.
478
+ def basic_consume(queue_name, consumer_tag: "", manual_ack: false, exclusive: false,
479
+ arguments: {}, on_error: nil, &block)
480
+ validate_disposition!(on_error) if on_error
481
+ warn_unbounded_prefetch(queue_name, manual_ack)
413
482
  resp = rpc(
414
483
  AMQ::Protocol::Basic::Consume.encode(@channel_id, queue_name, consumer_tag, false, !manual_ack, exclusive, false, arguments),
415
484
  AMQ::Protocol::Basic::ConsumeOk
416
485
  )
417
- @consumers[resp.consumer_tag] = { queue_name: queue_name, block: block, manual_ack: manual_ack }
486
+ @consumers[resp.consumer_tag] = { queue_name: queue_name, block: block,
487
+ manual_ack: manual_ack, on_error: on_error }
418
488
  topology&.record_consumer(resp.consumer_tag, queue_name)
419
489
  instrument("consumer.registered") do
420
490
  { channel: @channel_id, queue: queue_name, consumer_tag: resp.consumer_tag, manual_ack: manual_ack }
@@ -587,10 +657,23 @@ module AsyncRabbitMQ
587
657
  # receives the exception, the Basic::Deliver method frame and the queue
588
658
  # name. The delivery has already been nacked (requeue: false) if the
589
659
  # consumer was registered with manual_ack: true.
660
+ # The block receives the exception, the Basic::Deliver frame and the queue
661
+ # name, and runs *before* the delivery is settled. Return one of
662
+ # ERROR_DISPOSITIONS to decide this message's fate; return anything else
663
+ # (or nothing) to take the consumer's or channel's configured default.
590
664
  def on_handler_error(&block)
591
665
  @on_handler_error = block
592
666
  end
593
667
 
668
+ # The channel-wide default disposition for a handler that raises, used by
669
+ # any consumer that did not set its own. See ERROR_DISPOSITIONS.
670
+ attr_reader :on_error_disposition
671
+
672
+ def on_error_disposition=(value)
673
+ validate_disposition!(value)
674
+ @on_error_disposition = value
675
+ end
676
+
594
677
  # -------------------------------------------------------------------------
595
678
  # Flow control
596
679
  # -------------------------------------------------------------------------
@@ -667,6 +750,9 @@ module AsyncRabbitMQ
667
750
  @confirm_condition = nil
668
751
  release_outstanding_slots(error)
669
752
  @queue&.push(nil) rescue nil
753
+ # The connection is gone, so the queued deliveries are too: the broker
754
+ # requeues whatever was unacknowledged and sends it again on the new one.
755
+ stop_workers(discard: true)
670
756
  rescue => e
671
757
  # ignore — best-effort unblock
672
758
  end
@@ -832,20 +918,34 @@ module AsyncRabbitMQ
832
918
  true
833
919
  end
834
920
 
835
- # Without a prefetch limit the broker sends as fast as it can and the
836
- # dispatch loop starts a task per delivery. pool_size caps how many run at
837
- # once, not how many exist, so memory grows with the queue depth rather
838
- # than with the concurrency. Warned once per channel; basic_qos fixes it.
839
- def warn_unbounded_prefetch(queue_name)
921
+ # Without a bound the broker sends as fast as it can and the backlog is
922
+ # held in this process. What can bound it depends on the ack mode, so the
923
+ # advice does too: RabbitMQ applies prefetch only to *unacknowledged*
924
+ # messages, so on an auto-ack consumer basic_qos never engages and telling
925
+ # the user to call it would be wrong. Warned once per channel.
926
+ def warn_unbounded_prefetch(queue_name, manual_ack)
927
+ return unless AsyncRabbitMQ.warn_unbounded_consumers
840
928
  return if @qos_warned
841
- return if @prefetch && @prefetch[:count].to_i > 0
929
+ # A prefetch answers the manual-ack case, so having set one is a reason to
930
+ # stay quiet there. It does nothing for an auto-ack consumer, so it is not
931
+ # a reason here: that is precisely the case where the caller believes the
932
+ # consumer is bounded and it is not.
933
+ return if manual_ack && @prefetch && @prefetch[:count].to_i > 0
842
934
 
843
935
  @qos_warned = true
844
936
  @logger.warn(
845
- "Channel #{@channel_id}: consuming from #{queue_name} without basic_qos. " \
846
- "The broker will send the whole queue as fast as it can and one task is " \
847
- "created per delivery, so memory tracks queue depth. Call " \
848
- "basic_qos(prefetch_count: n) before basic_consume."
937
+ if manual_ack
938
+ "Channel #{@channel_id}: consuming from #{queue_name} without basic_qos. " \
939
+ "The broker will send the whole queue as fast as it can and the backlog is " \
940
+ "held in this process. Call basic_qos(prefetch_count: n) before " \
941
+ "basic_consume to bound it."
942
+ else
943
+ "Channel #{@channel_id}: consuming from #{queue_name} with automatic acks. " \
944
+ "The broker treats every message as acknowledged on send, so basic_qos " \
945
+ "cannot bound this consumer and the whole queue is held in this process. " \
946
+ "Use manual_ack: true with basic_qos(prefetch_count: n) to get " \
947
+ "backpressure, or watch #backlog."
948
+ end
849
949
  )
850
950
  end
851
951
 
@@ -870,33 +970,67 @@ module AsyncRabbitMQ
870
970
  # the consumer stops receiving anything at all.
871
971
  def handle_consumer_error(error, method, entry)
872
972
  @logger.error(
873
- "Channel #{@channel_id}: consumer #{method.consumer_tag} raised " \
874
- "#{error.class}: #{error.message}"
973
+ "Channel #{@channel_id}: consumer #{method.consumer_tag} raised " "#{error.class}: #{error.message}"
875
974
  )
876
975
  instrument("consumer.error") do
877
976
  { channel: @channel_id, consumer_tag: method.consumer_tag, queue: entry[:queue_name],
878
977
  error: error.class.name, message: error.message }
879
978
  end
880
979
 
980
+ # The hook runs before the delivery is settled, so what it returns can
981
+ # decide the disposition. Anything outside ERROR_DISPOSITIONS (including
982
+ # nil, and whatever a hook that was not written to choose happens to
983
+ # return) falls through to the configured default.
984
+ chosen = call_handler_error_hook(error, method, entry)
985
+ disposition = ERROR_DISPOSITIONS.include?(chosen) ? chosen : consumer_disposition(entry)
986
+ return if disposition == :leave
987
+
881
988
  # Only a manual-ack delivery the handler has not already settled. A
882
989
  # handler that acks and then raises in whatever follows would otherwise
883
- # be nacking a tag the broker has already resolved: that is a 406, which
884
- # closes the channel and takes its consumers with it.
885
- if entry[:manual_ack] && unsettled?(method.delivery_tag)
886
- begin
887
- basic_nack(method.delivery_tag, requeue: false)
888
- rescue => e
889
- @logger.error("Channel #{@channel_id}: could not nack after consumer error: #{e.class}: #{e.message}")
990
+ # be rejecting a tag the broker has already resolved: that is a 406,
991
+ # which closes the channel and takes its consumers with it.
992
+ return unless entry[:manual_ack] && unsettled?(method.delivery_tag)
993
+
994
+ begin
995
+ if disposition == :release
996
+ # nack, not reject: released rather than failed, so the broker does
997
+ # not count it against the message.
998
+ basic_nack(method.delivery_tag, requeue: true)
999
+ else
1000
+ basic_reject(method.delivery_tag, requeue: disposition == :retry)
890
1001
  end
1002
+ rescue *PROPAGATE_FROM_HANDLER
1003
+ raise
1004
+ rescue Exception => e # rubocop:disable Lint/RescueException
1005
+ @logger.error("Channel #{@channel_id}: could not settle after consumer error: #{e.class}: #{e.message}")
891
1006
  end
1007
+ end
892
1008
 
893
- return unless @on_handler_error
1009
+ # Returns whatever the hook returned, or nil if there is no hook or it
1010
+ # raised. A hook that blows up must not also lose the delivery.
1011
+ def call_handler_error_hook(error, method, entry)
1012
+ return nil unless @on_handler_error
894
1013
 
895
- begin
896
- @on_handler_error.call(error, method, entry[:queue_name])
897
- rescue => e
898
- @logger.error("Channel #{@channel_id}: on_handler_error hook raised #{e.class}: #{e.message}")
899
- end
1014
+ @on_handler_error.call(error, method, entry[:queue_name])
1015
+ rescue *PROPAGATE_FROM_HANDLER
1016
+ raise
1017
+ rescue Exception => e # rubocop:disable Lint/RescueException
1018
+ # The hook is the caller's code as much as the handler is, and it runs
1019
+ # while a delivery is still unsettled: letting it escape would strand the
1020
+ # message and kill the worker.
1021
+ @logger.error("Channel #{@channel_id}: on_handler_error hook raised #{e.class}: #{e.message}")
1022
+ nil
1023
+ end
1024
+
1025
+ def consumer_disposition(entry)
1026
+ entry[:on_error] || @on_error_disposition
1027
+ end
1028
+
1029
+ def validate_disposition!(value)
1030
+ return if ERROR_DISPOSITIONS.include?(value)
1031
+
1032
+ raise ArgumentError,
1033
+ "on_error must be one of #{ERROR_DISPOSITIONS.map(&:inspect).join(', ')} (got #{value.inspect})"
900
1034
  end
901
1035
 
902
1036
  # -------------------------------------------------------------------------
@@ -904,17 +1038,130 @@ module AsyncRabbitMQ
904
1038
  # -------------------------------------------------------------------------
905
1039
 
906
1040
  def start_dispatch_task
1041
+ start_workers
907
1042
  @session.spawn_background { dispatch_loop }
908
1043
  end
909
1044
 
1045
+ # Up to pool_size long-lived fibers drain the work queue for the life of
1046
+ # this channel generation. A backlogged delivery costs a 4-element array
1047
+ # (~48 bytes) rather than a parked Async::Task and its fiber stack (~15.7 KB
1048
+ # measured), so memory tracks the messages held, not the fibers holding
1049
+ # them. Bunny's ConsumerWorkPool is the same shape: workers draining an
1050
+ # unbounded ::Queue.
1051
+ #
1052
+ # The queue is made here; the workers are not. See #ensure_worker.
1053
+ def start_workers
1054
+ stop_workers(discard: true)
1055
+ @work_queue = Async::Queue.new
1056
+ @workers = []
1057
+ end
1058
+
1059
+ # Create a worker on demand, never up front.
1060
+ #
1061
+ # basic_qos couples pool_size to prefetch_count, so creating them eagerly
1062
+ # meant a caller asking for a prefetch of 500 got 500 fibers, and a caller
1063
+ # who then set pool_size back down - the documented escape hatch from that
1064
+ # coupling - retired all but pool_size of them without a single delivery
1065
+ # between them. A fiber's touched stack pages stay in the process RSS after
1066
+ # it exits, measured at ~12 KB apiece: 12.6 MB at prefetch 500 across two
1067
+ # channels, which is the whole of the consumer memory difference the 24-hour
1068
+ # soaks showed.
1069
+ #
1070
+ # Demand never exceeds pool_size, so the concurrency bound is unchanged: a
1071
+ # channel that genuinely runs pool_size handlers at once still gets there,
1072
+ # one delivery at a time.
1073
+ def ensure_worker(queue)
1074
+ return if queue.nil? || @workers.size >= @pool_size
1075
+
1076
+ @workers << @session.spawn_background { worker_loop(queue) }
1077
+ end
1078
+
1079
+ # Retire this generation's workers. The sentinel goes on the back of the
1080
+ # queue, so by default they finish the deliveries already queued before
1081
+ # standing down - Bunny's ConsumerWorkPool#shutdown drains the same way.
1082
+ #
1083
+ # +discard+ throws that backlog away instead, for when the connection
1084
+ # underneath it has gone: the broker requeues whatever was unacknowledged
1085
+ # and sends it again on the new one, so running these handlers too would
1086
+ # process every queued message twice. dequeue(timeout: 0) returns at once
1087
+ # on an empty queue, so the drain cannot suspend us mid-teardown.
1088
+ def stop_workers(discard: false)
1089
+ queue = @work_queue
1090
+ nil while discard && queue&.dequeue(timeout: 0)
1091
+ @workers.size.times { queue&.push(WORKER_STOP) }
1092
+ @workers = []
1093
+ @work_queue = nil
1094
+ end
1095
+
1096
+ # Only ever shrinks. Growing is left to #ensure_worker, so raising pool_size
1097
+ # costs nothing until there are deliveries that need the extra concurrency.
1098
+ def resize_workers
1099
+ queue = @work_queue
1100
+ excess = @workers.size - @pool_size
1101
+ return if queue.nil? || !excess.positive?
1102
+
1103
+ excess.times { queue.push(WORKER_STOP) }
1104
+ @workers.pop(excess)
1105
+ end
1106
+
1107
+ # Workers are bound to the queue they were started on, never to @work_queue,
1108
+ # so a worker outliving its generation cannot start draining the next one.
1109
+ #
1110
+ # A worker spawned from dispatch_loop takes its first delivery inside
1111
+ # dispatch_loop's own turn, because Async runs a new task immediately rather
1112
+ # than scheduling it. That is safe: a handler that waits - on a confirm, on
1113
+ # an RPC reply - suspends, and suspending hands control back to
1114
+ # dispatch_loop, so the reply still arrives. The two reentrancy examples in
1115
+ # 57_consumer_backlog_spec.rb pin that. An earlier revision yielded here
1116
+ # first; it was removed because no test could show it preventing anything.
1117
+ def worker_loop(queue)
1118
+ loop do
1119
+ job = queue.dequeue
1120
+ break if job.nil? || job.equal?(WORKER_STOP)
1121
+
1122
+ run_handler(*job)
1123
+ end
1124
+ end
1125
+
1126
+ def run_handler(method, header, body, entry)
1127
+ started = instrument_clock
1128
+ begin
1129
+ entry[:block].call(method, header, body)
1130
+ rescue *PROPAGATE_FROM_HANDLER
1131
+ raise
1132
+ rescue Exception => e # rubocop:disable Lint/RescueException
1133
+ handle_consumer_error(e, method, entry)
1134
+ ensure
1135
+ if started
1136
+ instrument("message.consumed") do
1137
+ { channel: @channel_id, queue: entry[:queue_name], consumer_tag: method.consumer_tag,
1138
+ bytes: body.to_s.bytesize, redelivered: method.redelivered,
1139
+ duration: instrument_elapsed(started) }
1140
+ end
1141
+ end
1142
+ end
1143
+ end
1144
+
910
1145
  def dispatch_loop
911
1146
  loop do
912
1147
  msg = @queue.pop
913
1148
  break if msg.nil?
914
1149
  handle_message(msg)
915
1150
  end
1151
+ rescue *PROPAGATE_FROM_HANDLER
1152
+ raise
916
1153
  rescue => e
917
1154
  @logger.error("Channel #{@channel_id} dispatch error: #{e.class}: #{e.message}")
1155
+ rescue Exception => e # rubocop:disable Lint/RescueException
1156
+ # Handlers are contained now, so nothing should reach here. If something
1157
+ # does, this channel is about to stop delivering anything at all - no
1158
+ # confirms, no RPC replies, no close-ok - and the one thing that must not
1159
+ # happen is for it to go quiet without saying so.
1160
+ @logger.error(
1161
+ "Channel #{@channel_id} dispatch died on #{e.class}: #{e.message}. " \
1162
+ "The channel will not deliver anything further."
1163
+ )
1164
+ raise
918
1165
  end
919
1166
 
920
1167
  def handle_message(msg)
@@ -979,23 +1226,17 @@ module AsyncRabbitMQ
979
1226
  if entry
980
1227
  stamp_delivery_tag(method)
981
1228
  @unsettled[method.delivery_tag.to_i] = true if entry[:manual_ack]
982
- Async do
983
- @pool_sem.acquire do
984
- started = instrument_clock
985
- begin
986
- entry[:block].call(method, header, body)
987
- rescue => e
988
- handle_consumer_error(e, method, entry)
989
- ensure
990
- if started
991
- instrument("message.consumed") do
992
- { channel: @channel_id, queue: entry[:queue_name], consumer_tag: method.consumer_tag,
993
- bytes: body.to_s.bytesize, redelivered: method.redelivered,
994
- duration: instrument_elapsed(started) }
995
- end
996
- end
997
- end
998
- end
1229
+ # Hand off without suspending: Async::Queue wraps Thread::Queue,
1230
+ # whose push never blocks. dispatch_loop must stay free, because it
1231
+ # is also what delivers this channel's confirms, close-ok and
1232
+ # returns - a handler that publishes and waits for confirms would
1233
+ # otherwise deadlock against the loop that would have woken it. That
1234
+ # is not hypothetical: it is rabbitmq-amqp-java-client#328, where a
1235
+ # bounded work pool parked the I/O thread in LinkedBlockingQueue#put.
1236
+ # A nil queue means the channel is going away and the delivery is moot.
1237
+ if (queue = @work_queue)
1238
+ queue.push([method, header, body, entry])
1239
+ ensure_worker(queue)
999
1240
  end
1000
1241
  else
1001
1242
  @logger.warn("Delivery on channel #{@channel_id} for unknown consumer #{method.consumer_tag}")
@@ -1119,6 +1360,7 @@ module AsyncRabbitMQ
1119
1360
  @reply_condition = nil
1120
1361
  # Stop dispatch_loop
1121
1362
  @queue&.push(nil)
1363
+ stop_workers(discard: true)
1122
1364
  wake_each_waiters
1123
1365
  end
1124
1366
 
@@ -1,3 +1,3 @@
1
1
  module AsyncRabbitMQ
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -1,3 +1,21 @@
1
+ module AsyncRabbitMQ
2
+ class << self
3
+ # Whether a channel warns, once, when it starts a consumer whose backlog
4
+ # nothing bounds - an auto-ack consumer, or a manual-ack one with no
5
+ # prefetch. True by default.
6
+ #
7
+ # Set it false when you have read the warning and accepted the trade, so
8
+ # that silencing it does not mean turning the logger down and losing every
9
+ # other warning a channel raises (stale delivery tags, broker-cancelled
10
+ # consumers, handler exceptions). It is read when a consumer starts, so set
11
+ # it during boot, before the first #basic_consume.
12
+ #
13
+ # AsyncRabbitMQ.warn_unbounded_consumers = false
14
+ attr_accessor :warn_unbounded_consumers
15
+ end
16
+ self.warn_unbounded_consumers = true
17
+ end
18
+
1
19
  require_relative "async_rabbitmq/version"
2
20
  require_relative "async_rabbitmq/log"
3
21
  require_relative "async_rabbitmq/errors"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-rabbitmq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Penney