async-rabbitmq 0.5.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 +23 -0
- data/README.md +102 -4
- data/lib/async_rabbitmq/channel.rb +183 -39
- data/lib/async_rabbitmq/version.rb +1 -1
- 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,29 @@
|
|
|
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
|
+
|
|
5
28
|
## [0.5.0] - 2026-10-09
|
|
6
29
|
|
|
7
30
|
### Fixed
|
data/README.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
A fiber-native RabbitMQ (AMQP 0-9-1) client for Ruby, built on the amazing work
|
|
4
4
|
of [Bunny](https://github.com/ruby-amqp/bunny).
|
|
5
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
|
+
|
|
6
23
|
## Bunny is the gold standard
|
|
7
24
|
|
|
8
25
|
Bunny is the client Ruby learned RabbitMQ on. Its API is the one Ruby developers
|
|
@@ -221,17 +238,98 @@ sent and `false` when the tag belonged to an earlier generation and was
|
|
|
221
238
|
dropped. A dropped ack is not a lost message: the broker requeued it when the
|
|
222
239
|
channel went, and it is redelivered on the new connection.
|
|
223
240
|
|
|
224
|
-
When a handler raises
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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:
|
|
228
279
|
|
|
229
280
|
```ruby
|
|
230
281
|
ch.on_handler_error do |error, delivery, queue_name|
|
|
231
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
|
|
232
288
|
end
|
|
233
289
|
```
|
|
234
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
|
+
|
|
235
333
|
### RabbitMQ 4.2+ and transient queues
|
|
236
334
|
|
|
237
335
|
RabbitMQ 4.2 and later refuse to declare a queue that is both non-durable
|
|
@@ -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.
|
|
@@ -77,6 +122,7 @@ module AsyncRabbitMQ
|
|
|
77
122
|
@on_cancel = nil
|
|
78
123
|
@on_error = nil
|
|
79
124
|
@on_handler_error = nil
|
|
125
|
+
@on_error_disposition = :dead_letter
|
|
80
126
|
@resyncing = false
|
|
81
127
|
@resync_condition = nil
|
|
82
128
|
# Incremented on every reopen (connection recovery or a single-channel
|
|
@@ -426,13 +472,19 @@ module AsyncRabbitMQ
|
|
|
426
472
|
# gated by the channel's pool_size semaphore so at most +pool_size+
|
|
427
473
|
# handlers run concurrently across all consumers on this channel.
|
|
428
474
|
# Returns the consumer tag.
|
|
429
|
-
|
|
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
|
|
430
481
|
warn_unbounded_prefetch(queue_name, manual_ack)
|
|
431
482
|
resp = rpc(
|
|
432
483
|
AMQ::Protocol::Basic::Consume.encode(@channel_id, queue_name, consumer_tag, false, !manual_ack, exclusive, false, arguments),
|
|
433
484
|
AMQ::Protocol::Basic::ConsumeOk
|
|
434
485
|
)
|
|
435
|
-
@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 }
|
|
436
488
|
topology&.record_consumer(resp.consumer_tag, queue_name)
|
|
437
489
|
instrument("consumer.registered") do
|
|
438
490
|
{ channel: @channel_id, queue: queue_name, consumer_tag: resp.consumer_tag, manual_ack: manual_ack }
|
|
@@ -605,10 +657,23 @@ module AsyncRabbitMQ
|
|
|
605
657
|
# receives the exception, the Basic::Deliver method frame and the queue
|
|
606
658
|
# name. The delivery has already been nacked (requeue: false) if the
|
|
607
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.
|
|
608
664
|
def on_handler_error(&block)
|
|
609
665
|
@on_handler_error = block
|
|
610
666
|
end
|
|
611
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
|
+
|
|
612
677
|
# -------------------------------------------------------------------------
|
|
613
678
|
# Flow control
|
|
614
679
|
# -------------------------------------------------------------------------
|
|
@@ -905,33 +970,67 @@ module AsyncRabbitMQ
|
|
|
905
970
|
# the consumer stops receiving anything at all.
|
|
906
971
|
def handle_consumer_error(error, method, entry)
|
|
907
972
|
@logger.error(
|
|
908
|
-
"Channel #{@channel_id}: consumer #{method.consumer_tag} raised "
|
|
909
|
-
"#{error.class}: #{error.message}"
|
|
973
|
+
"Channel #{@channel_id}: consumer #{method.consumer_tag} raised " "#{error.class}: #{error.message}"
|
|
910
974
|
)
|
|
911
975
|
instrument("consumer.error") do
|
|
912
976
|
{ channel: @channel_id, consumer_tag: method.consumer_tag, queue: entry[:queue_name],
|
|
913
977
|
error: error.class.name, message: error.message }
|
|
914
978
|
end
|
|
915
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
|
+
|
|
916
988
|
# Only a manual-ack delivery the handler has not already settled. A
|
|
917
989
|
# handler that acks and then raises in whatever follows would otherwise
|
|
918
|
-
# be
|
|
919
|
-
# closes the channel and takes its consumers with it.
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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)
|
|
925
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}")
|
|
926
1006
|
end
|
|
1007
|
+
end
|
|
927
1008
|
|
|
928
|
-
|
|
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
|
|
929
1013
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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})"
|
|
935
1034
|
end
|
|
936
1035
|
|
|
937
1036
|
# -------------------------------------------------------------------------
|
|
@@ -943,16 +1042,38 @@ module AsyncRabbitMQ
|
|
|
943
1042
|
@session.spawn_background { dispatch_loop }
|
|
944
1043
|
end
|
|
945
1044
|
|
|
946
|
-
#
|
|
947
|
-
#
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
#
|
|
951
|
-
#
|
|
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.
|
|
952
1053
|
def start_workers
|
|
953
1054
|
stop_workers(discard: true)
|
|
954
|
-
|
|
955
|
-
@workers
|
|
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) }
|
|
956
1077
|
end
|
|
957
1078
|
|
|
958
1079
|
# Retire this generation's workers. The sentinel goes on the back of the
|
|
@@ -972,21 +1093,27 @@ module AsyncRabbitMQ
|
|
|
972
1093
|
@work_queue = nil
|
|
973
1094
|
end
|
|
974
1095
|
|
|
975
|
-
#
|
|
976
|
-
#
|
|
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.
|
|
977
1098
|
def resize_workers
|
|
978
|
-
queue
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
elsif delta.negative?
|
|
985
|
-
(-delta).times { queue.push(WORKER_STOP) }
|
|
986
|
-
@workers.pop(-delta)
|
|
987
|
-
end
|
|
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)
|
|
988
1105
|
end
|
|
989
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.
|
|
990
1117
|
def worker_loop(queue)
|
|
991
1118
|
loop do
|
|
992
1119
|
job = queue.dequeue
|
|
@@ -1000,7 +1127,9 @@ module AsyncRabbitMQ
|
|
|
1000
1127
|
started = instrument_clock
|
|
1001
1128
|
begin
|
|
1002
1129
|
entry[:block].call(method, header, body)
|
|
1003
|
-
rescue
|
|
1130
|
+
rescue *PROPAGATE_FROM_HANDLER
|
|
1131
|
+
raise
|
|
1132
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
1004
1133
|
handle_consumer_error(e, method, entry)
|
|
1005
1134
|
ensure
|
|
1006
1135
|
if started
|
|
@@ -1019,8 +1148,20 @@ module AsyncRabbitMQ
|
|
|
1019
1148
|
break if msg.nil?
|
|
1020
1149
|
handle_message(msg)
|
|
1021
1150
|
end
|
|
1151
|
+
rescue *PROPAGATE_FROM_HANDLER
|
|
1152
|
+
raise
|
|
1022
1153
|
rescue => e
|
|
1023
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
|
|
1024
1165
|
end
|
|
1025
1166
|
|
|
1026
1167
|
def handle_message(msg)
|
|
@@ -1093,7 +1234,10 @@ module AsyncRabbitMQ
|
|
|
1093
1234
|
# is not hypothetical: it is rabbitmq-amqp-java-client#328, where a
|
|
1094
1235
|
# bounded work pool parked the I/O thread in LinkedBlockingQueue#put.
|
|
1095
1236
|
# A nil queue means the channel is going away and the delivery is moot.
|
|
1096
|
-
|
|
1237
|
+
if (queue = @work_queue)
|
|
1238
|
+
queue.push([method, header, body, entry])
|
|
1239
|
+
ensure_worker(queue)
|
|
1240
|
+
end
|
|
1097
1241
|
else
|
|
1098
1242
|
@logger.warn("Delivery on channel #{@channel_id} for unknown consumer #{method.consumer_tag}")
|
|
1099
1243
|
end
|