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 +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +157 -13
- data/lib/async_rabbitmq/channel.rb +292 -50
- data/lib/async_rabbitmq/version.rb +1 -1
- data/lib/async_rabbitmq.rb +18 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77df2cb6ea955db0392941563a8e6c43cc55ad06d429c1f8d45ea05bd79ecb50
|
|
4
|
+
data.tar.gz: 1ea2fbdc554c5901638002e7d4c16cbaf5c9af7189434b06fb1003eef2894d14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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.
|
|
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
|
-
@
|
|
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
|
|
96
|
-
#
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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,
|
|
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
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
|
884
|
-
# closes the channel and takes its consumers with it.
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
|
data/lib/async_rabbitmq.rb
CHANGED
|
@@ -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"
|