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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63e32dac7aed43a3627da9fec4b8b2df543592f940f5301eddc4b388869f5728
4
- data.tar.gz: '08d691a8d45ec1d1cb4162b6b8e874a3bd1bf52813c9ca9004d2439a0eaa2d67'
3
+ metadata.gz: 77df2cb6ea955db0392941563a8e6c43cc55ad06d429c1f8d45ea05bd79ecb50
4
+ data.tar.gz: 1ea2fbdc554c5901638002e7d4c16cbaf5c9af7189434b06fb1003eef2894d14
5
5
  SHA512:
6
- metadata.gz: 0c3b6732b01c0a6014bf6bd420ffd59e8f4093be2c253194d74accf67b57545d5655e5ac70856c18c4dd4c5697aed8e79e30282af00954fd9e482bc04d65d19f
7
- data.tar.gz: 64dcd5ac758a50d502fc86d99809a7df1ddb64a19c34251282fc12ccf33ea657011cf86d51c35c1b86e59efd699205ef93e7bad97bf5fc01d1523609559dd50e
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, 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,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
- 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
+ @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 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}")
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
- return unless @on_handler_error
1009
+ # Returns whatever the hook returned, or nil if there is no hook or it
1010
+ # raised. A hook that blows up must not also lose the delivery.
1011
+ def call_handler_error_hook(error, method, entry)
1012
+ return nil unless @on_handler_error
929
1013
 
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
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
- # 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.
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
- queue = @work_queue = Async::Queue.new
955
- @workers = Array.new(@pool_size) { @session.spawn_background { worker_loop(queue) } }
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
- # 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.
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 = @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
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 => e
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
- @work_queue&.push([method, header, body, entry])
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
@@ -1,3 +1,3 @@
1
1
  module AsyncRabbitMQ
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Penney