fix(queue): keep the consumer alive when the broker fails - #96
Merged
Conversation
loks0n
requested review from
ChiragAgg5k,
abnegate and
lohanidamodar
as code owners
July 27, 2026 19:01
consume() called $this->consumer->receive() unguarded, so anything the broker threw unwound out of the loop and ended the worker. A single unreachable moment cost the process, and the message currently in flight went with it. Nothing at that level retried. What looked like resilience came from the connection pool underneath: its reconnect attempts delayed each failed receive by tens of seconds, which acted as an accidental backoff in front of the missing guard. Pools 2.0 removes those retries, so the gap becomes a crash loop rather than a slow stall — but the guard was missing either way, and a consumer should survive a broker outage regardless of what its pool happens to do. Route both consume loops through nextMessage(), which reports the failure and pauses for RECEIVE_BACKOFF before the next attempt. The loop then carries on, so the worker outlives the outage and resumes when the broker returns. Reporting goes to the existing $errorCallback with a null message rather than to error_log. Its signature is already nullable and Server already guards for null before setting the message on the context, so this is the case that shape was for: a consumer logs a broker failure through the same Server::error() hook it already registers for handler failures, instead of the library deciding on its behalf. The callback is invoked defensively — a reporting hook that throws must not cost the worker either, which is the whole point of the change. One helper on the base class rather than a try/catch in each loop, since Swoole overrides consume() and the Workerman path inherits it. The consume() callbacks are now documented, including that $errorCallback can receive a null message. The added test drives the Swoole loop with a consumer that fails twice and then delegates to a working broker, asserting both failures reach the callback with a null message. Before this change the test dies with "Uncaught RuntimeException: broker unreachable"; after it, both failures are absorbed and the queued message is processed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loks0n
force-pushed
the
fix/queue-consumer-survives-broker-failure
branch
from
July 27, 2026 19:11
fa56cc8 to
d6b032c
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 bug
Both consume loops called
receive()unguarded:Anything the broker throws unwinds out of
consume()and ends the worker. A single unreachable moment costs the process, and whatever was in flight goes with it.Note the asymmetry this creates with the method right below it, whose docblock already promises the opposite:
Message handling was made total. Message acquisition was not.
Why it looked fine
Nothing at that level retried. What resembled resilience came from the connection pool underneath:
reconnectAttempts×reconnectSleepdelayed each failedreceive()by tens of seconds, which behaved like a backoff in front of the missing guard.#94 removes those pool retries, which turns the slow stall into a crash loop. But the guard was missing either way — a consumer should survive a broker outage regardless of what its pool happens to do — so this stands on its own and is worth landing first.
The fix
Both loops route through one helper on the base class:
Returning
nulllands on the existingcontinue, so the loop carries on and the worker resumes when the broker returns.One helper rather than a
try/catchin each loop, becauseSwooleoverridesconsume()and the Workerman path inherits the base.sleep()is coroutine-hooked under Swoole, so it yields rather than blocking the worker; under the plain adapter it blocks, which is correct for a single-threaded consumer.Reporting is the consumer's decision
The failure goes to the existing
$errorCallbackrather than toerror_log. Its signature is already nullable, andServeralready guards for it:So this is the case that shape was for. A consumer logs a broker failure through the same
Server::error()hook it already registers for handler failures, instead of the library deciding on its behalf — no new API, and nothing writes to stderr behind the caller's back.The callback is invoked defensively: a reporting hook that throws must not cost the worker either, which is the whole point of the change.
consume()'s callbacks are now documented, including that$errorCallbackcan receive a null message.Test
Drives the Swoole loop with a consumer that fails twice, then delegates to a working broker, asserting both failures reach the callback with a null message. Verified against the unguarded code:
Registered in the
unitsuite — it needs no Redis, using the existingInMemoryConnectiondouble.bin/monorepo check queue,test queueandvalidateall pass.Scope
RECEIVE_BACKOFFis a flat 1s. Escalating backoff would be kinder to a broker that is down for a long stretch, but a fixed pause is the smaller change and matches the existingRECEIVE_TIMEOUTshape; worth revisiting if it ever proves noisy.I did not add a second test for the base
Adapter::consume()path. It calls the same helper, and covering it would mean a concreteAdapterstub implementing four abstract methods to exercise one shared line.🤖 Generated with Claude Code