Skip to content

Fix publisher confirms, Laravel 13 worker options, and terminal-failure ownership - #29

Merged
iamfarhad merged 1 commit into
masterfrom
fix/publisher-confirms-worker-options-failure-ownership
Aug 4, 2026
Merged

iamfarhad merged 1 commit into
masterfrom
fix/publisher-confirms-worker-options-failure-ownership

Conversation

@iamfarhad

Copy link
Copy Markdown
Owner

Fixes four issues reported against 1.3.4. Each was validated against the source on master before fixing, and every regression test was re-run against the unfixed code to prove it fails without the change.

Fixes #25
Fixes #26
Fixes #27
Fixes #28

Publisher confirms aborted the publish (#25)

PublisherConfirms::enable() called confirmSelect() without installing ext-amqp's confirm callbacks, so the extension refused to process the broker's acknowledgement and turned a successful publish into a fatal Unhandled basic.ack method from server received.

The callback fix from 64ad6b3 only ever existed on the unmerged test/rmq4-laravel13-coverage branch, so master and the 1.3.4 release were both affected. Implemented fresh rather than ported, since #26 reviews that approach as incorrect.

  • ACK/NACK callbacks are registered before confirm mode is switched on.
  • Registration happens exactly once per instance, so repeated enable() calls cannot stack handlers.
  • The ACK callback releases waitForConfirm() as soon as nothing is outstanding, instead of returning true unconditionally and blocking for the whole timeout.
  • A confirm-wait timeout (AMQPQueueException) is now reported as a failed confirmation instead of escaping unhandled. AMQPConnectionException still propagates untouched, so RabbitQueue's dead-channel retry keeps working.

A NACK could fail a later, acknowledged publish (#26)

The stored NACK was never cleared, so on a long-lived publisher or worker every subsequent waitForConfirms() re-threw the first broker NACK.

NACK state is now single-use: the wait captures it, clears it, and only then reports the failure. disable() and clearPending() clear it as well, and a wait that fails for any other reason no longer leaves stale state behind for the next publish. Documented behaviour when several callbacks arrive before a wait returns: the first NACK observed is reported by the next wait, then dropped.

rabbitmq:consume failed to start on Laravel 13 (#27)

The command overrides queue:work's signature but omitted --stop-when-empty-for and --json.

Reproduced before fixing: WorkCommand::gatherWorkerOptions() reads stop-when-empty-for unconditionally, so Symfony threw for the undefined option and the worker exited before consuming anything — even when the option was never passed — putting deployments into a restart loop.

One correction to the report: --json did not crash, because outputUsingJson() guards with hasOption(). It was still rejected by the parser, so JSON worker output was unreachable. Both options are now declared with their queue:work meanings.

A test asserts the command defines every option the installed WorkCommand reads (parsed from the framework source), so this drift is caught on whichever Laravel version CI runs.

Terminal failure had two sinks (#28)

A permanently failed job was rejected and copied to a hard-coded failed_messages exchange, so a queue with broker dead-letter routing recorded the same failure twice, with divergent retention, alerting, and replay policies.

Ownership is now explicit via failed.ownership (RABBITMQ_FAILED_OWNERSHIP):

Value Behaviour
broker (new default) Reject without requeue; the queue's configured x-dead-letter-exchange owns the failure.
exchange Reject, then also publish a copy to failed.exchange (previous behaviour, now with a configurable destination).

Two adjacent defects found in the same path and fixed:

  • The re-entry guard only compared the envelope's exchange name, which is empty for default-exchange deliveries — so consuming failed_messages could republish failures in a loop. It now checks the consumed queue too.
  • The copy was routed through the connection's configured publishing exchange rather than the failure queue declared for it. It is now published through the default exchange.

Settings are read from the failing job's own connection, falling back to the default rabbitmq connection. failureOwner() is protected, so a custom job class (options.queue.job) can decide ownership per job.

⚠️ Behaviour change

Defaulting to broker means anyone relying on the failed_messages copy without dead-letter routing stops receiving it until they set RABBITMQ_FAILED_OWNERSHIP=exchange. #28 asked to prefer broker ownership, so that is the default; the restore path is documented in CHANGELOG.md, a new UPGRADE.md section, and docs/production.md.

Testing

  • 33 new tests across PublisherConfirmsTest, ConsumeCommandTest, and FailureOwnershipTest; 187 total pass.
  • Verified non-vacuous by re-running each set against the unfixed source: 11/17 confirms, 4/6 command, 8/10 ownership fail without the change.
  • Pint clean. No new PHPStan errors — the 15 reported are pre-existing in Consumer.php, Support/RpcClient.php, and the admin commands.
  • FailureOwnershipTest intentionally has no skipIfAmqpExtensionLoaded() guard: it uses plain Mockery doubles (never overload: instance mocks), exactly like the existing unguarded AMQPChannel/AMQPConnection mocks, so the Terminal job failure can reject to RabbitMQ and also copy the message to hard-coded failed_messages #28 coverage runs in CI where ext-amqp is loaded rather than being silently skipped.

Fixes four issues reported against 1.3.4.

Publisher confirms could not complete a publish (#25). enable() called
confirmSelect() without installing ext-amqp's confirm callbacks, so the
extension refused to process the broker's acknowledgement and aborted the
publish with "Unhandled basic.ack method from server received.". The ACK
and NACK callbacks are now registered before confirm mode is switched on,
exactly once per instance so repeated enable() calls cannot stack
handlers. The callbacks release waitForConfirm() as soon as nothing is
outstanding instead of blocking for the whole timeout, and a confirm-wait
timeout (AMQPQueueException) is now reported as a failed confirmation
instead of escaping unhandled.

A NACK could fail a later, acknowledged publish (#26). The stored NACK was
never cleared, so on a long-lived publisher or worker every subsequent
waitForConfirms() re-threw the first broker NACK. NACK state is now
single-use: the wait takes it, clears it, and only then reports the
failure. disable() and clearPending() clear it too, and a wait that fails
for another reason no longer leaves stale state behind.

rabbitmq:consume failed to start on Laravel 13 (#27). The command
overrides queue:work's signature but omitted --stop-when-empty-for and
--json. WorkCommand::gatherWorkerOptions() reads stop-when-empty-for
unconditionally, so Symfony threw for the undefined option and the worker
exited before consuming anything -- even when the option was never
passed -- putting deployments into a restart loop. Both options are now
declared with their queue:work meanings, and a test compares the command
against every option the installed WorkCommand reads so future framework
drift is caught.

Terminal failure had two sinks (#28). A permanently failed job was
rejected and also copied to a hard-coded failed_messages exchange, so a
queue with broker dead-letter routing recorded the same failure twice,
with divergent retention, alerting, and replay policies. Ownership is now
explicit via failed.ownership: 'broker' (new default) rejects without
requeue and lets the queue's dead-letter exchange own the failure, while
'exchange' keeps the previous copy behaviour against a configurable
destination. The copy is published through the default exchange so it
lands in the failure queue declared for it, and the re-entry guard also
checks the consumed queue -- not just the envelope's exchange name -- so
consuming the failure queue can no longer republish failures in a loop.

Settings are read from the failing job's own connection, falling back to
the default rabbitmq connection.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@iamfarhad
iamfarhad merged commit fbf5679 into master Aug 4, 2026
11 checks passed
@iamfarhad
iamfarhad deleted the fix/publisher-confirms-worker-options-failure-ownership branch August 4, 2026 09:23
@iamfarhad iamfarhad mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment