[Messenger][Amqp] Add a prefetch_count option to consume messages instead of fetching them one by one - #65924
Open
nicolas-grekas wants to merge 2 commits into
Open
[Messenger][Amqp] Add a prefetch_count option to consume messages instead of fetching them one by one#65924nicolas-grekas wants to merge 2 commits into
nicolas-grekas wants to merge 2 commits into
Conversation
nicolas-grekas
force-pushed
the
amqp-push-consumption
branch
from
September 9, 2026 06:31
dfc7642 to
6fbda1a
Compare
This was referenced Sep 9, 2026
Member
|
aahah, Already tried that, but I found it ugly :) |
Member
Author
|
Yet it works and there's little way around, isn't it? See also linked PRs for the future? |
|
+1 for this feature |
…tead of fetching them one by one The transport asks the broker for one message at a time, with a basic.get per message, including when the worker wants a batch. RabbitMQ documents this as the least efficient way to consume: every fetch is a round trip, an idle queue is polled on every loop, and no consumer shows up in the management UI. Setting prefetch_count registers a consumer per queue instead and lets the broker push messages as capacity frees up. On 5000 messages against a loopback broker that is 13 to 18 times faster, and the gap widens with the round trip time. On an idle queue the call now returns when the read timeout expires, which takes the place of the worker sleep rather than adding to it.
The workaround the consumer needed applies to basic.get as well: a signal that reaches the process while the extension waits for the broker is dropped when that wait ends by throwing, so a read timeout on a stalled connection loses it. Since 8.2 the receiver implements KeepaliveReceiverInterface, and the keepalive alarm is rescheduled by its own handler, so a single lost SIGALRM stops the keepalive for the rest of the process's life and the messages being handled are redelivered once their TTL expires. Move the dance to a private helper and use it on both paths.
nicolas-grekas
force-pushed
the
amqp-push-consumption
branch
from
September 13, 2026 19:23
765089a to
521de9e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The AMQP transport asks the broker for one message at a time:
Connection::get()callsAMQPQueue::get(), onebasic.getper message, including when the worker wants a batch. RabbitMQ documents this as the least efficient way to consume, since every fetch is a round trip and an idle queue is polled on every loop. No consumer shows up in the management UI either, which is what #30259 reported.This adds a
prefetch_countoption. Any value greater than zero registers a consumer per queue (basic.consume) and lets the broker push messages as capacity frees up:5000 messages, ack each, against a loopback broker, which is the best case for
basic.getsince there is barely any round trip time to save:The prefetch count is what buys the pipelining, so it has to be worth setting. With the default
--fetch-size=1:What changes when it is on
getFromQueues()round-robins the queues itself today. Oneconsume()call serves every consumer of the connection (the extension routes each message back to its queue by consumer tag), so the broker decides. PublishingL,L,LthenH,H,Hwith the high priority queue registered first deliversL0 L1 L2 H0 H1 H2.read_timeoutbecomes bounded. It defaults to 0, which means block forever, so consuming defaults it to 1 second. That is also what makes an idle queue return, and since the worker computes its sleep from the start of the iteration, the read timeout takes the place of that sleep instead of adding to it.prefetch_countmeans a larger redelivery burst per stop.keepalive()used to sendqos(0, 0)for the traffic, which would drop the prefetch limit. It now resends the current one.Batching
The consume callback cannot
yield, so the receiver collects into an array and yields afterwards, which fits--fetch-size. It waits for the first message with the connection read timeout, then fills the rest of the batch with a 1 ms timeout: a partial batch returns at once rather than being held for the whole read timeout, the waybasic.getreturns as soon as a queue runs dry.The 1 ms timeout is what the extension gives us today to say "take what is buffered and come back". php-amqp/php-amqp#627 adds an
AMQP_NB_CONSUMEflag that does exactly that without a timeout and without throwing, which would turn that second phase intoAMQP_JUST_CONSUME | AMQP_NB_CONSUME. It is open since June; I built it and it works, but the bridge cannot depend on an unreleased extension, so this is a follow-up rather than a blocker.Also fixes the pull path
The same loss reaches
basic.get: a signal that arrives while the extension waits for the broker is dropped when that wait ends by throwing, which a read timeout on a stalled connection does. Since 8.2 the receiver implementsKeepaliveReceiverInterfaceand the keepalive alarm is rescheduled by its own handler, so one lostSIGALRMstops the keepalive for the rest of the process's life and the messages in flight are redelivered once their TTL expires. The dance moved to a private helper used by both paths, and the added test fails without it.About signals
AMQPQueue::consume()reports the read timeout by throwing, and until php/php-src#23624 the engine drops every signal that was queued while an internal function was running when that function returns with an exception pending. A worker spends essentially all of its wall time inside that call, so it would miss everySIGTERM: 653 iterations of a 10 ms read timeout and the signal never arrives, not even through an explicitpcntl_signal_dispatch(). Hence thepcntl_async_signals()dance around the blocking call, which dispatches them by hand. Measured stop latency with it, on an unpatched PHP:Stop latency is bounded by
read_timeout, which is the same contractget()has today. The workaround stays correct once the engine no longer drops signals, so it does not need a version guard.Kept opt-in for now, since the ordering and redelivery changes are visible. Tested against RabbitMQ 3.12.1 with ext-amqp 2.2.0; the new integration test covers the round trip.