async-rabbitmq 0.5.0 → 0.7.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: 63e32dac7aed43a3627da9fec4b8b2df543592f940f5301eddc4b388869f5728
4
- data.tar.gz: '08d691a8d45ec1d1cb4162b6b8e874a3bd1bf52813c9ca9004d2439a0eaa2d67'
3
+ metadata.gz: acf485922e3e284c6d637b9047d44e521402e466f43e9cc7158cd7e880653559
4
+ data.tar.gz: 887b7316e17d2d0dce494cb4f9663dc341361a02cc89b621e7f1855bc2af9568
5
5
  SHA512:
6
- metadata.gz: 0c3b6732b01c0a6014bf6bd420ffd59e8f4093be2c253194d74accf67b57545d5655e5ac70856c18c4dd4c5697aed8e79e30282af00954fd9e482bc04d65d19f
7
- data.tar.gz: 64dcd5ac758a50d502fc86d99809a7df1ddb64a19c34251282fc12ccf33ea657011cf86d51c35c1b86e59efd699205ef93e7bad97bf5fc01d1523609559dd50e
6
+ metadata.gz: 2c5fe4cebf5dddc0dfb80a00fafb96e83be3578cb08f8b2f969599ace41dc644b46a1a05abdd878ad0bf8746182724d124a5ab83d4aabedd556f149dfb7ff741
7
+ data.tar.gz: 7b04b91fa3fffff2fc863a151bc4346e57b7e976ba2cffd3f0242dfd576e0c1f5467f036878acfec875c005ee43a582ed183fd9c28fa9a3d385cb3ab722ee9fb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.7.0] - 2026-10-17
6
+
7
+ ### Fixed
8
+
9
+ - A consumer's `on_error:` survives re-registration after a reconnect or a channel reopen; it no
10
+ longer falls back to the channel default.
11
+ - A consumer's `exclusive:` flag and `arguments:` (such as `x-priority`) survive re-registration
12
+ after a reconnect or a channel reopen; they no longer come back as the defaults.
13
+
14
+ ### Added
15
+
16
+ - `Channel#each` passes its remaining keywords to `basic_consume`, so `on_error:`, `exclusive:`
17
+ and `arguments:` work there as they do on the other two ways to start a consumer.
18
+
19
+ ## [0.6.0] - 2026-10-11
20
+
21
+ ### Fixed
22
+
23
+ - A consumer handler raising outside `StandardError` no longer stops the channel dispatching.
24
+ `SystemStackError` and `ScriptError` now fail that delivery through the configured error
25
+ disposition. `Async::Stop`, `SystemExit`, `SignalException` and `NoMemoryError` still propagate.
26
+ Applies equally to the `on_handler_error` hook and to settling after an error.
27
+ - Handler workers are created on demand instead of one per prefetch slot. `basic_qos(prefetch_count: 500)`
28
+ previously created 500 fibers and retired all but `pool_size` of them unused: 12.6 MB at prefetch
29
+ 500 across two channels. Concurrency is unchanged.
30
+
31
+ ### Added
32
+
33
+ - `on_error:` on a consumer, and `Channel#on_error_disposition` for the channel default, choose what
34
+ happens to a delivery whose handler raised:
35
+ - `:dead_letter` (default) — `reject(requeue: false)`: one attempt, then the dead-letter route.
36
+ - `:retry` — `reject(requeue: true)`: counts towards `x-delivery-limit`.
37
+ - `:release` — `nack(requeue: true)`: requeued without counting towards the limit.
38
+ - `:leave` — settles nothing; the handler is responsible.
39
+ - `Channel#on_handler_error` receives `(error, delivery_info, queue_name)` and may return a
40
+ disposition to override the default for that delivery.
41
+
5
42
  ## [0.5.0] - 2026-10-09
6
43
 
7
44
  ### 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, the delivery is nacked with `requeue: false` — so a
225
- message that always fails dead-letters instead of looping — and
226
- `on_handler_error` is called. A delivery the handler already settled itself is
227
- 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:
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,22 @@ 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
- def basic_consume(queue_name, consumer_tag: "", manual_ack: false, exclusive: false, arguments: {}, &block)
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, manual_ack: manual_ack }
486
+ # Everything basic.consume was called with, so re_register_consumers can
487
+ # reproduce the consumer exactly rather than a default-valued lookalike.
488
+ @consumers[resp.consumer_tag] = { queue_name: queue_name, block: block,
489
+ manual_ack: manual_ack, on_error: on_error,
490
+ exclusive: exclusive, arguments: arguments }
436
491
  topology&.record_consumer(resp.consumer_tag, queue_name)
437
492
  instrument("consumer.registered") do
438
493
  { channel: @channel_id, queue: queue_name, consumer_tag: resp.consumer_tag, manual_ack: manual_ack }
@@ -605,10 +660,23 @@ module AsyncRabbitMQ
605
660
  # receives the exception, the Basic::Deliver method frame and the queue
606
661
  # name. The delivery has already been nacked (requeue: false) if the
607
662
  # consumer was registered with manual_ack: true.
663
+ # The block receives the exception, the Basic::Deliver frame and the queue
664
+ # name, and runs *before* the delivery is settled. Return one of
665
+ # ERROR_DISPOSITIONS to decide this message's fate; return anything else
666
+ # (or nothing) to take the consumer's or channel's configured default.
608
667
  def on_handler_error(&block)
609
668
  @on_handler_error = block
610
669
  end
611
670
 
671
+ # The channel-wide default disposition for a handler that raises, used by
672
+ # any consumer that did not set its own. See ERROR_DISPOSITIONS.
673
+ attr_reader :on_error_disposition
674
+
675
+ def on_error_disposition=(value)
676
+ validate_disposition!(value)
677
+ @on_error_disposition = value
678
+ end
679
+
612
680
  # -------------------------------------------------------------------------
613
681
  # Flow control
614
682
  # -------------------------------------------------------------------------
@@ -627,9 +695,11 @@ module AsyncRabbitMQ
627
695
  # the broker cancels the consumer (e.g. the queue is deleted) or closes the
628
696
  # channel. A connection loss with automatic recovery is transparent: the
629
697
  # consumer is re-registered and #each keeps waiting.
630
- def each(queue_name, manual_ack: false, &block)
698
+ # Remaining keywords go to #basic_consume, so +on_error+, +exclusive+ and
699
+ # +arguments+ work here as they do there.
700
+ def each(queue_name, manual_ack: false, **opts, &block)
631
701
  assert_open!
632
- tag = basic_consume(queue_name, manual_ack: manual_ack, &block)
702
+ tag = basic_consume(queue_name, manual_ack: manual_ack, **opts, &block)
633
703
  cond = @each_waiters[tag] = Async::Condition.new
634
704
  cond.wait
635
705
  nil
@@ -905,33 +975,67 @@ module AsyncRabbitMQ
905
975
  # the consumer stops receiving anything at all.
906
976
  def handle_consumer_error(error, method, entry)
907
977
  @logger.error(
908
- "Channel #{@channel_id}: consumer #{method.consumer_tag} raised " \
909
- "#{error.class}: #{error.message}"
978
+ "Channel #{@channel_id}: consumer #{method.consumer_tag} raised " "#{error.class}: #{error.message}"
910
979
  )
911
980
  instrument("consumer.error") do
912
981
  { channel: @channel_id, consumer_tag: method.consumer_tag, queue: entry[:queue_name],
913
982
  error: error.class.name, message: error.message }
914
983
  end
915
984
 
985
+ # The hook runs before the delivery is settled, so what it returns can
986
+ # decide the disposition. Anything outside ERROR_DISPOSITIONS (including
987
+ # nil, and whatever a hook that was not written to choose happens to
988
+ # return) falls through to the configured default.
989
+ chosen = call_handler_error_hook(error, method, entry)
990
+ disposition = ERROR_DISPOSITIONS.include?(chosen) ? chosen : consumer_disposition(entry)
991
+ return if disposition == :leave
992
+
916
993
  # Only a manual-ack delivery the handler has not already settled. A
917
994
  # handler that acks and then raises in whatever follows would otherwise
918
- # be nacking a tag the broker has already resolved: that is a 406, which
919
- # closes the channel and takes its consumers with it.
920
- if entry[:manual_ack] && unsettled?(method.delivery_tag)
921
- begin
922
- basic_nack(method.delivery_tag, requeue: false)
923
- rescue => e
924
- @logger.error("Channel #{@channel_id}: could not nack after consumer error: #{e.class}: #{e.message}")
995
+ # be rejecting a tag the broker has already resolved: that is a 406,
996
+ # which closes the channel and takes its consumers with it.
997
+ return unless entry[:manual_ack] && unsettled?(method.delivery_tag)
998
+
999
+ begin
1000
+ if disposition == :release
1001
+ # nack, not reject: released rather than failed, so the broker does
1002
+ # not count it against the message.
1003
+ basic_nack(method.delivery_tag, requeue: true)
1004
+ else
1005
+ basic_reject(method.delivery_tag, requeue: disposition == :retry)
925
1006
  end
1007
+ rescue *PROPAGATE_FROM_HANDLER
1008
+ raise
1009
+ rescue Exception => e # rubocop:disable Lint/RescueException
1010
+ @logger.error("Channel #{@channel_id}: could not settle after consumer error: #{e.class}: #{e.message}")
926
1011
  end
1012
+ end
927
1013
 
928
- return unless @on_handler_error
1014
+ # Returns whatever the hook returned, or nil if there is no hook or it
1015
+ # raised. A hook that blows up must not also lose the delivery.
1016
+ def call_handler_error_hook(error, method, entry)
1017
+ return nil unless @on_handler_error
929
1018
 
930
- begin
931
- @on_handler_error.call(error, method, entry[:queue_name])
932
- rescue => e
933
- @logger.error("Channel #{@channel_id}: on_handler_error hook raised #{e.class}: #{e.message}")
934
- end
1019
+ @on_handler_error.call(error, method, entry[:queue_name])
1020
+ rescue *PROPAGATE_FROM_HANDLER
1021
+ raise
1022
+ rescue Exception => e # rubocop:disable Lint/RescueException
1023
+ # The hook is the caller's code as much as the handler is, and it runs
1024
+ # while a delivery is still unsettled: letting it escape would strand the
1025
+ # message and kill the worker.
1026
+ @logger.error("Channel #{@channel_id}: on_handler_error hook raised #{e.class}: #{e.message}")
1027
+ nil
1028
+ end
1029
+
1030
+ def consumer_disposition(entry)
1031
+ entry[:on_error] || @on_error_disposition
1032
+ end
1033
+
1034
+ def validate_disposition!(value)
1035
+ return if ERROR_DISPOSITIONS.include?(value)
1036
+
1037
+ raise ArgumentError,
1038
+ "on_error must be one of #{ERROR_DISPOSITIONS.map(&:inspect).join(', ')} (got #{value.inspect})"
935
1039
  end
936
1040
 
937
1041
  # -------------------------------------------------------------------------
@@ -943,16 +1047,38 @@ module AsyncRabbitMQ
943
1047
  @session.spawn_background { dispatch_loop }
944
1048
  end
945
1049
 
946
- # One long-lived fiber per pool_size, each draining the work queue for the
947
- # life of this channel generation. A backlogged delivery costs a 4-element
948
- # array (~48 bytes) rather than a parked Async::Task and its fiber stack
949
- # (~15.7 KB measured), so memory tracks the messages held, not the fibers
950
- # holding them. Bunny's ConsumerWorkPool is the same shape: fixed workers
951
- # draining an unbounded ::Queue.
1050
+ # Up to pool_size long-lived fibers drain the work queue for the life of
1051
+ # this channel generation. A backlogged delivery costs a 4-element array
1052
+ # (~48 bytes) rather than a parked Async::Task and its fiber stack (~15.7 KB
1053
+ # measured), so memory tracks the messages held, not the fibers holding
1054
+ # them. Bunny's ConsumerWorkPool is the same shape: workers draining an
1055
+ # unbounded ::Queue.
1056
+ #
1057
+ # The queue is made here; the workers are not. See #ensure_worker.
952
1058
  def start_workers
953
1059
  stop_workers(discard: true)
954
- queue = @work_queue = Async::Queue.new
955
- @workers = Array.new(@pool_size) { @session.spawn_background { worker_loop(queue) } }
1060
+ @work_queue = Async::Queue.new
1061
+ @workers = []
1062
+ end
1063
+
1064
+ # Create a worker on demand, never up front.
1065
+ #
1066
+ # basic_qos couples pool_size to prefetch_count, so creating them eagerly
1067
+ # meant a caller asking for a prefetch of 500 got 500 fibers, and a caller
1068
+ # who then set pool_size back down - the documented escape hatch from that
1069
+ # coupling - retired all but pool_size of them without a single delivery
1070
+ # between them. A fiber's touched stack pages stay in the process RSS after
1071
+ # it exits, measured at ~12 KB apiece: 12.6 MB at prefetch 500 across two
1072
+ # channels, which is the whole of the consumer memory difference the 24-hour
1073
+ # soaks showed.
1074
+ #
1075
+ # Demand never exceeds pool_size, so the concurrency bound is unchanged: a
1076
+ # channel that genuinely runs pool_size handlers at once still gets there,
1077
+ # one delivery at a time.
1078
+ def ensure_worker(queue)
1079
+ return if queue.nil? || @workers.size >= @pool_size
1080
+
1081
+ @workers << @session.spawn_background { worker_loop(queue) }
956
1082
  end
957
1083
 
958
1084
  # Retire this generation's workers. The sentinel goes on the back of the
@@ -972,21 +1098,27 @@ module AsyncRabbitMQ
972
1098
  @work_queue = nil
973
1099
  end
974
1100
 
975
- # Workers are bound to the queue they were started on, never to @work_queue,
976
- # so a worker outliving its generation cannot start draining the next one.
1101
+ # Only ever shrinks. Growing is left to #ensure_worker, so raising pool_size
1102
+ # costs nothing until there are deliveries that need the extra concurrency.
977
1103
  def resize_workers
978
- queue = @work_queue
979
- return unless queue
980
-
981
- delta = @pool_size - @workers.size
982
- if delta.positive?
983
- delta.times { @workers << @session.spawn_background { worker_loop(queue) } }
984
- elsif delta.negative?
985
- (-delta).times { queue.push(WORKER_STOP) }
986
- @workers.pop(-delta)
987
- end
1104
+ queue = @work_queue
1105
+ excess = @workers.size - @pool_size
1106
+ return if queue.nil? || !excess.positive?
1107
+
1108
+ excess.times { queue.push(WORKER_STOP) }
1109
+ @workers.pop(excess)
988
1110
  end
989
1111
 
1112
+ # Workers are bound to the queue they were started on, never to @work_queue,
1113
+ # so a worker outliving its generation cannot start draining the next one.
1114
+ #
1115
+ # A worker spawned from dispatch_loop takes its first delivery inside
1116
+ # dispatch_loop's own turn, because Async runs a new task immediately rather
1117
+ # than scheduling it. That is safe: a handler that waits - on a confirm, on
1118
+ # an RPC reply - suspends, and suspending hands control back to
1119
+ # dispatch_loop, so the reply still arrives. The two reentrancy examples in
1120
+ # 57_consumer_backlog_spec.rb pin that. An earlier revision yielded here
1121
+ # first; it was removed because no test could show it preventing anything.
990
1122
  def worker_loop(queue)
991
1123
  loop do
992
1124
  job = queue.dequeue
@@ -1000,7 +1132,9 @@ module AsyncRabbitMQ
1000
1132
  started = instrument_clock
1001
1133
  begin
1002
1134
  entry[:block].call(method, header, body)
1003
- rescue => e
1135
+ rescue *PROPAGATE_FROM_HANDLER
1136
+ raise
1137
+ rescue Exception => e # rubocop:disable Lint/RescueException
1004
1138
  handle_consumer_error(e, method, entry)
1005
1139
  ensure
1006
1140
  if started
@@ -1019,8 +1153,20 @@ module AsyncRabbitMQ
1019
1153
  break if msg.nil?
1020
1154
  handle_message(msg)
1021
1155
  end
1156
+ rescue *PROPAGATE_FROM_HANDLER
1157
+ raise
1022
1158
  rescue => e
1023
1159
  @logger.error("Channel #{@channel_id} dispatch error: #{e.class}: #{e.message}")
1160
+ rescue Exception => e # rubocop:disable Lint/RescueException
1161
+ # Handlers are contained now, so nothing should reach here. If something
1162
+ # does, this channel is about to stop delivering anything at all - no
1163
+ # confirms, no RPC replies, no close-ok - and the one thing that must not
1164
+ # happen is for it to go quiet without saying so.
1165
+ @logger.error(
1166
+ "Channel #{@channel_id} dispatch died on #{e.class}: #{e.message}. " \
1167
+ "The channel will not deliver anything further."
1168
+ )
1169
+ raise
1024
1170
  end
1025
1171
 
1026
1172
  def handle_message(msg)
@@ -1093,7 +1239,10 @@ module AsyncRabbitMQ
1093
1239
  # is not hypothetical: it is rabbitmq-amqp-java-client#328, where a
1094
1240
  # bounded work pool parked the I/O thread in LinkedBlockingQueue#put.
1095
1241
  # A nil queue means the channel is going away and the delivery is moot.
1096
- @work_queue&.push([method, header, body, entry])
1242
+ if (queue = @work_queue)
1243
+ queue.push([method, header, body, entry])
1244
+ ensure_worker(queue)
1245
+ end
1097
1246
  else
1098
1247
  @logger.warn("Delivery on channel #{@channel_id} for unknown consumer #{method.consumer_tag}")
1099
1248
  end
@@ -1494,7 +1643,9 @@ module AsyncRabbitMQ
1494
1643
  def re_register_consumers
1495
1644
  @consumers.each do |tag, entry|
1496
1645
  begin
1497
- basic_consume(entry[:queue_name], consumer_tag: tag, manual_ack: entry[:manual_ack], &entry[:block])
1646
+ basic_consume(entry[:queue_name], consumer_tag: tag, manual_ack: entry[:manual_ack],
1647
+ exclusive: entry[:exclusive], arguments: entry[:arguments] || {},
1648
+ on_error: entry[:on_error], &entry[:block])
1498
1649
  rescue => e
1499
1650
  # Typically 404: the queue is gone and was not recovered. The broker
1500
1651
  # closes the channel on that, so say so instead of failing silently.
@@ -1,3 +1,3 @@
1
1
  module AsyncRabbitMQ
2
- VERSION = "0.5.0"
2
+ VERSION = "0.7.0"
3
3
  end
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Penney
@@ -174,7 +174,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
174
174
  - !ruby/object:Gem::Version
175
175
  version: '0'
176
176
  requirements: []
177
- rubygems_version: 3.6.7
177
+ rubygems_version: 4.0.20
178
178
  specification_version: 4
179
179
  summary: Fiber-native Ruby AMQP 0-9-1 client built on the Async ecosystem
180
180
  test_files: []