Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Messenger] Add DispatchOnFailureStamp to dispatch a message when another one fails - #65917

Open
nicolas-grekas wants to merge 2 commits into
symfony:8.2from
nicolas-grekas:messenger-dispatch-on-failure
Open

[Messenger] Add DispatchOnFailureStamp to dispatch a message when another one fails#65917
nicolas-grekas wants to merge 2 commits into
symfony:8.2from
nicolas-grekas:messenger-dispatch-on-failure

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Member
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

Stacked on #65903, which has to be merged first: the diff shows both until then.

A message can now declare what to dispatch when it fails for good:

$bus->dispatch(new ProcessPodcast($id), [
    new ChainStamp(new OptimizePodcast($id), new ReleasePodcast($id)),
    new DispatchOnFailureStamp(new PodcastFailed($id)),
]);

This is the "catch" that #50462 asked for, without serializing a closure: the failure hook is a message, and the failed message plus the error travel with it. The hook belongs to the message that can fail, not to the chain, so it is just as useful on a single message ("notify when this import fails"); ChainMiddleware copies it onto each next step, so whichever step breaks fires it once.

When it fires

"For good" means after the retries:

  • In a worker, when WorkerMessageFailedEvent reports that the message will not be retried (DispatchOnFailureListener, priority 0, between the retry listener and the failure-transport listener).
  • In a synchronous dispatch, when handling throws (DispatchOnFailureMiddleware, at the end of the default before middleware so that it wraps doctrine_transaction: the failure message is dispatched after the rollback). The same middleware fires when a sync:// transport with a failure transport ([Messenger] Add retry and failure transport support to the sync transport #65902) sends the message there instead of throwing, which it detects from the SentToFailureTransportStamp on the returned envelope.
  • A message that is only sent to a transport does not fire anything at send time.

A nested synchronous dispatch inside a worker handler has no retries of its own, so its failure fires on every attempt of the outer message.

What is dispatched

Envelope::wrap($failureMessage) with a FailedMessageStamp holding the message that failed, the ErrorDetailsStamp of the failure, and the BusNameStamp of the failed message so that the same bus handles it. With #65899, a handler reads both by type-hinting them:

public function __invoke(PodcastFailed $message, FailedMessageStamp $failed, ErrorDetailsStamp $error): void

The failure message carries no DispatchOnFailureStamp of its own, so a failing failure message does not loop.

Public API

  • Symfony\Component\Messenger\Stamp\DispatchOnFailureStamp(object $message)
  • Symfony\Component\Messenger\Stamp\FailedMessageStamp(object $message)
  • Symfony\Component\Messenger\EventListener\DispatchOnFailureListener
  • Symfony\Component\Messenger\Middleware\DispatchOnFailureMiddleware
  • FrameworkBundle registers both, with dispatch_on_failure in the default middleware stack (guarded for older Messenger versions).

Checks

  • ./phpunit src/Symfony/Component/Messenger/Tests and ./phpunit src/Symfony/Bundle/FrameworkBundle/Tests: green (usual missing-server skips).
  • Revert-verified: the new tests fail without the stamps, listener and middleware; the bundle tests fail without the wiring.

Documentation

  • The stamp, the three firing points and the "after retries" rule.
  • What the failure message receives, and the stamp arguments example.
  • The nested-synchronous-dispatch caveat.
  • Serializer note: the stamps carry message objects, so transports using the Symfony serializer need normalizers for them, as with ChainStamp and RedispatchMessage.

@wachterjohannes wachterjohannes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design avoids the obvious trap: the stamp is not copied onto the failure message, so a failing failure message cannot loop. Details inline: dispatching the failure message can itself throw, and neither call site catches that. Both may need a fix, and the right answer might differ between them.

A ChainStamp lists the messages to handle after the one it is attached to,
in order. The new ChainMiddleware dispatches the next message of the chain
once the current one is handled, with a DispatchAfterCurrentBusStamp, and
passes the remaining messages along in a new ChainStamp. The next message
keeps the bus name of the current one unless it names its own bus.

When a step fails, the rest of the chain is not dispatched. The remaining
steps travel with a message sent to a transport, so the chain continues
from there once a failed step is retried.

The Messenger bundle registers the middleware as messenger.middleware.chain,
bound to the routable message bus, and adds it to the default middleware
stack between send_message and handle_message.
…ther one fails

A DispatchOnFailureStamp names the message to dispatch when the message
it is attached to fails for good. The failure message is dispatched with
a FailedMessageStamp holding the message that failed, the ErrorDetailsStamp
recorded on the failed envelope or one created from the exception, and
the bus name of the failed message unless it names its own bus.

A worker gives up on a message when the retry listener does not retry it:
the new DispatchOnFailureListener dispatches the failure message then, at
priority 0, before the message is sent to a failure transport. When a
message is handled synchronously, the exception is the give-up: the new
DispatchOnFailureMiddleware catches it, dispatches the failure message
and rethrows. The middleware also fires when the rest of the stack returns
an envelope with a new SentToFailureTransportStamp, which a synchronous
transport adds when it sends a failed message to a failure transport
instead of throwing. A message received from a transport is skipped by
the middleware, since the worker decides when it fails for good.

Dispatching the failure message can itself fail, when it has no handler or
when its handler throws. That failure is logged at error level and goes no
further: it must not replace the exception of the message that failed, and
it must not stop the other listeners of the worker event, such as the one
that sends the failed message to the failure transport.

ChainMiddleware copies the stamp of the current step onto the next one
unless the next step carries its own, so that a chain dispatches the
failure message once, whichever step fails.

The Messenger bundle registers the middleware as
messenger.middleware.dispatch_on_failure, at the end of the default
"before" middleware so that it wraps user middleware such as the Doctrine
transaction one, and the listener as
messenger.failure.dispatch_on_failure_listener. Both use the routable
message bus and log on the "messenger" channel.
@nicolas-grekas
nicolas-grekas force-pushed the messenger-dispatch-on-failure branch from d0967fa to 60bb704 Compare September 13, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants