async-rabbitmq 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8e21899276061f5e0ecd749000e4b7bf5b81932a491a1704e0b2e643cf647b1
4
- data.tar.gz: a7fcd5f434e5e677ad7fce840ca388e323264edd8b2347f8ed92ec73a0fd7a3e
3
+ metadata.gz: 63e32dac7aed43a3627da9fec4b8b2df543592f940f5301eddc4b388869f5728
4
+ data.tar.gz: '08d691a8d45ec1d1cb4162b6b8e874a3bd1bf52813c9ca9004d2439a0eaa2d67'
5
5
  SHA512:
6
- metadata.gz: 0a41b23ef4e9d1fdad8403e8dbc99783f53205ed6b0fe5cb3e891b79d9763b9a9c0ec05bceda15f78de1fb47e0fb5b65bdbc5b756c2d19cf9c6609d2ee13fd3c
7
- data.tar.gz: 4e7795488172face945464fbd3d36c9355b7b6500705238079df4ae38b0c6529c67088c84323354c0772bd31562b9efb7b1927c06872b369addc4d56638c5815
6
+ metadata.gz: 0c3b6732b01c0a6014bf6bd420ffd59e8f4093be2c253194d74accf67b57545d5655e5ac70856c18c4dd4c5697aed8e79e30282af00954fd9e482bc04d65d19f
7
+ data.tar.gz: 64dcd5ac758a50d502fc86d99809a7df1ddb64a19c34251282fc12ccf33ea657011cf86d51c35c1b86e59efd699205ef93e7bad97bf5fc01d1523609559dd50e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.0] - 2026-10-09
6
+
7
+ ### Fixed
8
+
9
+ - **A consumer backlog no longer costs a fiber per queued message.** The dispatch loop started an
10
+ `Async::Task` per delivery and took the pool semaphore from inside it, so `pool_size` capped how
11
+ many handlers *ran*, not how many *existed*. A parked task measured ~15.7 KB (the fiber object
12
+ plus its touched stack pages) against ~48 bytes for a queue entry, and 20,000 backlogged messages
13
+ cost 315 MB of RSS. Handlers now run in `pool_size` long-lived worker fibers draining a queue of
14
+ deliveries — the same shape as Bunny's `ConsumerWorkPool`, the .NET client's consumer dispatcher
15
+ and amqp091-go.
16
+
17
+ That queue is deliberately unbounded. The dispatch loop is also what delivers this channel's
18
+ confirms, close-ok and returns, so blocking it on a full queue would deadlock a handler that
19
+ publishes and waits for confirms — which is precisely rabbitmq-amqp-java-client#328, where a
20
+ bounded work pool parked the I/O thread in `LinkedBlockingQueue#put`. Memory is therefore bounded
21
+ by prefetch on a manual-ack consumer and unbounded on an auto-ack one, as it is in every client,
22
+ because automatic acknowledgement has no backpressure to offer.
23
+ - **The missing-prefetch warning no longer sends auto-ack consumers to a no-op.** RabbitMQ applies
24
+ prefetch only to *unacknowledged* messages, so `basic_qos` does nothing at all on a
25
+ `manual_ack: false` consumer. The warning told you to call it anyway, and then went quiet once you
26
+ had — silence in the one case where the caller most believes the consumer is bounded and it is
27
+ not. It now says which of the two cases you are in, and still warns an auto-ack consumer that has
28
+ set a prefetch.
29
+
30
+ ### Added
31
+
32
+ - `Channel#backlog` — deliveries handed to the handler workers but not yet picked up, the number
33
+ Bunny reports as `ConsumerWorkPool#backlog`.
34
+ - `AsyncRabbitMQ.warn_unbounded_consumers` — a deliberate way to silence the unbounded-consumer
35
+ warning for someone who has read it and accepted the trade, instead of turning the logger down
36
+ and losing every other warning a channel raises (stale delivery tags, broker-cancelled consumers,
37
+ handler exceptions). One process-wide setting, read when a consumer starts; defaults to `true`.
38
+
39
+ ### Changed
40
+
41
+ - A backlog still queued when the channel goes away is now **finished on a graceful `close`** (as
42
+ Bunny's `ConsumerWorkPool#shutdown` drains) and **discarded when the connection is lost or the
43
+ channel is reopened**, where the broker requeues the same messages and running the queued handlers
44
+ too would process each one twice.
45
+ - `Channel#pool_size=` resizes by adding or retiring workers rather than resizing a semaphore.
46
+ Growing takes effect at once; shrinking retires a worker as soon as the queue drains to the
47
+ sentinel — immediately when idle, after the current backlog when busy. A running handler is never
48
+ interrupted.
49
+
5
50
  ## [0.4.0] - 2026-10-07
6
51
 
7
52
  Reliability review against a payments workload. The two delivery-correctness items
data/README.md CHANGED
@@ -1,8 +1,36 @@
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
+ ## Bunny is the gold standard
7
+
8
+ Bunny is the client Ruby learned RabbitMQ on. Its API is the one Ruby developers
9
+ already know, its defaults are the ones a decade of production traffic settled,
10
+ and its source is the reference this library was written against. None of that
11
+ is improved on here, and none of it is meant to be.
12
+
13
+ **Parity with Bunny is a design goal**, chosen deliberately over fiber-native
14
+ maximalism: the audience for this gem is people who already know Bunny, and
15
+ surprising them is a bug. Where Bunny has an answer, that is the answer here.
16
+ `pool_size` defaults to 1 and serialises handlers because `ConsumerWorkPool`
17
+ does. A consumer backlog is an unbounded queue drained by a fixed set of workers
18
+ because that is what `ConsumerWorkPool` is. `basic_get` acknowledges manually by
19
+ default because Bunny's does. `spec/integration/25_bunny_api_parity_spec.rb`
20
+ exists to keep it honest.
21
+
22
+ Where this client does diverge, the README says so and says why - see the two
23
+ publishing differences below, and `basic_qos`, which also resizes the handler
24
+ pool where Bunny's leaves it alone.
25
+
26
+ If you are not running under the fiber scheduler, use Bunny. It is excellent,
27
+ and this gem has no advantage over it outside an async application.
28
+
29
+ ## What this adds
30
+
31
+ The runtime, not the API. Built on the
4
32
  [async](https://github.com/socketry/async) ecosystem and
5
- [amq-protocol](https://github.com/ruby-amqp/amq-protocol). No threads: the
33
+ [amq-protocol](https://github.com/ruby-amqp/amq-protocol), with no threads: the
6
34
  reader, writer, heartbeat and every consumer handler are fibers, so it fits
7
35
  Falcon, async-http and anything else running under the fiber scheduler.
8
36
 
@@ -140,11 +168,30 @@ ch.basic_cancel(tag)
140
168
  ch.each("q") { |delivery, header, body| ... } # blocks until the consumer or channel goes away
141
169
  ```
142
170
 
143
- Consumer handlers run in their own fibers, at most `pool_size` at a time per
144
- channel. Set `basic_qos` before `basic_consume`: without a prefetch limit the
145
- broker sends the whole queue as fast as it can and one fiber is created per
146
- delivery, so memory tracks queue depth rather than concurrency. The client
147
- warns once per channel if you don't.
171
+ Each channel runs `pool_size` long-lived handler fibers (default 1, serialized,
172
+ Bunny parity) draining a queue of deliveries, so a backlog costs the messages it
173
+ holds rather than a fiber per message. `ch.backlog` reports how many deliveries
174
+ are waiting, as Bunny's `ConsumerWorkPool#backlog` does.
175
+
176
+ What bounds that backlog depends on the ack mode. With `manual_ack: true`, set
177
+ `basic_qos` before `basic_consume` and the broker holds everything past the
178
+ prefetch window. With automatic acks there is no backpressure to be had:
179
+ RabbitMQ applies prefetch only to *unacknowledged* messages, so the broker
180
+ treats each one as acked on send and keeps sending whatever you set. The client
181
+ warns once per channel in either case, and says which of the two you are in.
182
+
183
+ If you have read that warning and accepted the trade - an auto-ack consumer on a
184
+ queue you know stays shallow, say - switch it off during boot:
185
+
186
+ ```ruby
187
+ AsyncRabbitMQ.warn_unbounded_consumers = false
188
+ ```
189
+
190
+ It is deliberately one process-wide setting rather than a per-channel option: an
191
+ application either accepts unbounded consumers or it does not. The point of
192
+ having it at all is that the alternative is turning the logger down, which would
193
+ also lose stale delivery tags, broker-cancelled consumers and handler
194
+ exceptions.
148
195
 
149
196
  ### Acknowledging, and delivery tags across a reconnect
150
197
 
@@ -282,8 +329,7 @@ end
282
329
  ```
283
330
 
284
331
  `Async::Condition#wait`, a `sleep`, or your own supervisor task work equally
285
- well. Versions up to 0.3.0 kept the reactor alive by accident, because those
286
- tasks were children of whichever task called `connect`.
332
+ well.
287
333
 
288
334
  ## Connection recovery
289
335
 
@@ -24,6 +24,10 @@ module AsyncRabbitMQ
24
24
  # after an RPC timeout, used when the session has no rpc_timeout set.
25
25
  RESYNC_TIMEOUT = 5
26
26
 
27
+ # Pushed onto the work queue to retire one handler worker. Compared by
28
+ # identity, so it can never collide with a delivery.
29
+ WORKER_STOP = Object.new.freeze
30
+
27
31
  include Instrumented
28
32
 
29
33
  attr_reader :channel_id, :pool_size
@@ -89,14 +93,27 @@ module AsyncRabbitMQ
89
93
  @pending_content = nil
90
94
  @recovered_condition = nil # fibers parked while the connection recovers
91
95
  @pool_size = validate_pool_size!(pool_size)
92
- @pool_sem = Async::Semaphore.new(@pool_size)
96
+ @work_queue = nil # Async::Queue of [method, header, body, entry]
97
+ @workers = [] # long-lived handler fibers draining it
93
98
  end
94
99
 
95
- # Resize the consumer-handler concurrency cap. Shrinking does not evict
96
- # already-running handlers; new deliveries park until permits free up.
100
+ # Resize the consumer-handler concurrency cap by adding or retiring
101
+ # workers. Growing takes effect at once. Shrinking is a sentinel on the
102
+ # work queue, so it retires a worker as soon as the queue drains to it:
103
+ # immediately when idle, after the current backlog when busy. A handler
104
+ # already running is never interrupted.
97
105
  def pool_size=(n)
98
- @pool_size = validate_pool_size!(n)
99
- @pool_sem.limit = @pool_size
106
+ n = validate_pool_size!(n)
107
+ return if n == @pool_size
108
+
109
+ @pool_size = n
110
+ resize_workers
111
+ end
112
+
113
+ # Deliveries handed to the handler workers but not yet picked up. Bunny
114
+ # reports the same number as ConsumerWorkPool#backlog.
115
+ def backlog
116
+ @work_queue&.size || 0
100
117
  end
101
118
 
102
119
  def open?
@@ -147,6 +164,7 @@ module AsyncRabbitMQ
147
164
  instrument("channel.closed") { { channel: @channel_id, reason: :user } }
148
165
  @session.channel_closed(@channel_id)
149
166
  @queue&.push(nil) # wake dispatch_loop so it can detect :closed and exit
167
+ stop_workers
150
168
  wake_each_waiters
151
169
  end
152
170
 
@@ -409,7 +427,7 @@ module AsyncRabbitMQ
409
427
  # handlers run concurrently across all consumers on this channel.
410
428
  # Returns the consumer tag.
411
429
  def basic_consume(queue_name, consumer_tag: "", manual_ack: false, exclusive: false, arguments: {}, &block)
412
- warn_unbounded_prefetch(queue_name)
430
+ warn_unbounded_prefetch(queue_name, manual_ack)
413
431
  resp = rpc(
414
432
  AMQ::Protocol::Basic::Consume.encode(@channel_id, queue_name, consumer_tag, false, !manual_ack, exclusive, false, arguments),
415
433
  AMQ::Protocol::Basic::ConsumeOk
@@ -667,6 +685,9 @@ module AsyncRabbitMQ
667
685
  @confirm_condition = nil
668
686
  release_outstanding_slots(error)
669
687
  @queue&.push(nil) rescue nil
688
+ # The connection is gone, so the queued deliveries are too: the broker
689
+ # requeues whatever was unacknowledged and sends it again on the new one.
690
+ stop_workers(discard: true)
670
691
  rescue => e
671
692
  # ignore — best-effort unblock
672
693
  end
@@ -832,20 +853,34 @@ module AsyncRabbitMQ
832
853
  true
833
854
  end
834
855
 
835
- # Without a prefetch limit the broker sends as fast as it can and the
836
- # dispatch loop starts a task per delivery. pool_size caps how many run at
837
- # once, not how many exist, so memory grows with the queue depth rather
838
- # than with the concurrency. Warned once per channel; basic_qos fixes it.
839
- def warn_unbounded_prefetch(queue_name)
856
+ # Without a bound the broker sends as fast as it can and the backlog is
857
+ # held in this process. What can bound it depends on the ack mode, so the
858
+ # advice does too: RabbitMQ applies prefetch only to *unacknowledged*
859
+ # messages, so on an auto-ack consumer basic_qos never engages and telling
860
+ # the user to call it would be wrong. Warned once per channel.
861
+ def warn_unbounded_prefetch(queue_name, manual_ack)
862
+ return unless AsyncRabbitMQ.warn_unbounded_consumers
840
863
  return if @qos_warned
841
- return if @prefetch && @prefetch[:count].to_i > 0
864
+ # A prefetch answers the manual-ack case, so having set one is a reason to
865
+ # stay quiet there. It does nothing for an auto-ack consumer, so it is not
866
+ # a reason here: that is precisely the case where the caller believes the
867
+ # consumer is bounded and it is not.
868
+ return if manual_ack && @prefetch && @prefetch[:count].to_i > 0
842
869
 
843
870
  @qos_warned = true
844
871
  @logger.warn(
845
- "Channel #{@channel_id}: consuming from #{queue_name} without basic_qos. " \
846
- "The broker will send the whole queue as fast as it can and one task is " \
847
- "created per delivery, so memory tracks queue depth. Call " \
848
- "basic_qos(prefetch_count: n) before basic_consume."
872
+ if manual_ack
873
+ "Channel #{@channel_id}: consuming from #{queue_name} without basic_qos. " \
874
+ "The broker will send the whole queue as fast as it can and the backlog is " \
875
+ "held in this process. Call basic_qos(prefetch_count: n) before " \
876
+ "basic_consume to bound it."
877
+ else
878
+ "Channel #{@channel_id}: consuming from #{queue_name} with automatic acks. " \
879
+ "The broker treats every message as acknowledged on send, so basic_qos " \
880
+ "cannot bound this consumer and the whole queue is held in this process. " \
881
+ "Use manual_ack: true with basic_qos(prefetch_count: n) to get " \
882
+ "backpressure, or watch #backlog."
883
+ end
849
884
  )
850
885
  end
851
886
 
@@ -904,9 +939,80 @@ module AsyncRabbitMQ
904
939
  # -------------------------------------------------------------------------
905
940
 
906
941
  def start_dispatch_task
942
+ start_workers
907
943
  @session.spawn_background { dispatch_loop }
908
944
  end
909
945
 
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.
952
+ def start_workers
953
+ stop_workers(discard: true)
954
+ queue = @work_queue = Async::Queue.new
955
+ @workers = Array.new(@pool_size) { @session.spawn_background { worker_loop(queue) } }
956
+ end
957
+
958
+ # Retire this generation's workers. The sentinel goes on the back of the
959
+ # queue, so by default they finish the deliveries already queued before
960
+ # standing down - Bunny's ConsumerWorkPool#shutdown drains the same way.
961
+ #
962
+ # +discard+ throws that backlog away instead, for when the connection
963
+ # underneath it has gone: the broker requeues whatever was unacknowledged
964
+ # and sends it again on the new one, so running these handlers too would
965
+ # process every queued message twice. dequeue(timeout: 0) returns at once
966
+ # on an empty queue, so the drain cannot suspend us mid-teardown.
967
+ def stop_workers(discard: false)
968
+ queue = @work_queue
969
+ nil while discard && queue&.dequeue(timeout: 0)
970
+ @workers.size.times { queue&.push(WORKER_STOP) }
971
+ @workers = []
972
+ @work_queue = nil
973
+ end
974
+
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.
977
+ 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
988
+ end
989
+
990
+ def worker_loop(queue)
991
+ loop do
992
+ job = queue.dequeue
993
+ break if job.nil? || job.equal?(WORKER_STOP)
994
+
995
+ run_handler(*job)
996
+ end
997
+ end
998
+
999
+ def run_handler(method, header, body, entry)
1000
+ started = instrument_clock
1001
+ begin
1002
+ entry[:block].call(method, header, body)
1003
+ rescue => e
1004
+ handle_consumer_error(e, method, entry)
1005
+ ensure
1006
+ if started
1007
+ instrument("message.consumed") do
1008
+ { channel: @channel_id, queue: entry[:queue_name], consumer_tag: method.consumer_tag,
1009
+ bytes: body.to_s.bytesize, redelivered: method.redelivered,
1010
+ duration: instrument_elapsed(started) }
1011
+ end
1012
+ end
1013
+ end
1014
+ end
1015
+
910
1016
  def dispatch_loop
911
1017
  loop do
912
1018
  msg = @queue.pop
@@ -979,24 +1085,15 @@ module AsyncRabbitMQ
979
1085
  if entry
980
1086
  stamp_delivery_tag(method)
981
1087
  @unsettled[method.delivery_tag.to_i] = true if entry[:manual_ack]
982
- Async do
983
- @pool_sem.acquire do
984
- started = instrument_clock
985
- begin
986
- entry[:block].call(method, header, body)
987
- rescue => e
988
- handle_consumer_error(e, method, entry)
989
- ensure
990
- if started
991
- instrument("message.consumed") do
992
- { channel: @channel_id, queue: entry[:queue_name], consumer_tag: method.consumer_tag,
993
- bytes: body.to_s.bytesize, redelivered: method.redelivered,
994
- duration: instrument_elapsed(started) }
995
- end
996
- end
997
- end
998
- end
999
- end
1088
+ # Hand off without suspending: Async::Queue wraps Thread::Queue,
1089
+ # whose push never blocks. dispatch_loop must stay free, because it
1090
+ # is also what delivers this channel's confirms, close-ok and
1091
+ # returns - a handler that publishes and waits for confirms would
1092
+ # otherwise deadlock against the loop that would have woken it. That
1093
+ # is not hypothetical: it is rabbitmq-amqp-java-client#328, where a
1094
+ # bounded work pool parked the I/O thread in LinkedBlockingQueue#put.
1095
+ # A nil queue means the channel is going away and the delivery is moot.
1096
+ @work_queue&.push([method, header, body, entry])
1000
1097
  else
1001
1098
  @logger.warn("Delivery on channel #{@channel_id} for unknown consumer #{method.consumer_tag}")
1002
1099
  end
@@ -1119,6 +1216,7 @@ module AsyncRabbitMQ
1119
1216
  @reply_condition = nil
1120
1217
  # Stop dispatch_loop
1121
1218
  @queue&.push(nil)
1219
+ stop_workers(discard: true)
1122
1220
  wake_each_waiters
1123
1221
  end
1124
1222
 
@@ -1,3 +1,3 @@
1
1
  module AsyncRabbitMQ
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -1,3 +1,21 @@
1
+ module AsyncRabbitMQ
2
+ class << self
3
+ # Whether a channel warns, once, when it starts a consumer whose backlog
4
+ # nothing bounds - an auto-ack consumer, or a manual-ack one with no
5
+ # prefetch. True by default.
6
+ #
7
+ # Set it false when you have read the warning and accepted the trade, so
8
+ # that silencing it does not mean turning the logger down and losing every
9
+ # other warning a channel raises (stale delivery tags, broker-cancelled
10
+ # consumers, handler exceptions). It is read when a consumer starts, so set
11
+ # it during boot, before the first #basic_consume.
12
+ #
13
+ # AsyncRabbitMQ.warn_unbounded_consumers = false
14
+ attr_accessor :warn_unbounded_consumers
15
+ end
16
+ self.warn_unbounded_consumers = true
17
+ end
18
+
1
19
  require_relative "async_rabbitmq/version"
2
20
  require_relative "async_rabbitmq/log"
3
21
  require_relative "async_rabbitmq/errors"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-rabbitmq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Penney