ecotone-dev icon indicating copy to clipboard operation
ecotone-dev copied to clipboard

Dead Letter for Synchronously Processed Commands/Events

Open lifinsky opened this issue 9 months ago • 8 comments

Description

Currently, Ecotone provides a built-in Dead Letter mechanism that relies on DBAL for storing failed messages in the database. However, this mechanism is designed primarily for asynchronous message processing.

In synchronous execution flows — such as HTTP request handling — there is no automatic way to leverage the existing DLQ functionality when an exception occurs. This presents several issues:

  • If a synchronously executed command or event fails (e.g., during an HTTP request), it is lost unless explicitly caught and handled.
  • Developers are forced to implement custom exception handling logic to manually store failed messages, which leads to inconsistency with Ecotone’s built-in DLQ.
  • Moving all such commands/events to an asynchronous channel just to use the DLQ is often impractical.

Use Case

  • Webhook processing: Some webhooks should not be retried but must still be logged in the DLQ for later analysis.

  • Business-critical HTTP requests: Commands triggered by HTTP should not be retried automatically but should still be stored in the DLQ for debugging.

  • Exception handling for synchronous workflows: Ensuring that failed messages are safely persisted in the database instead of being lost.

lifinsky avatar Feb 26 '25 19:02 lifinsky

I understand the need for Commands where we trigger an action from HTTP Controller. I don't see the use case for synchronous Events however. As synchronous Events are result of synchronous Commands, so they are part of same transaction, therefore in my opinion they should fail together and be treated as one.

What I can propose is custom ErrorChannel on the level of Command Bus, which then can be used for different Commands as needed.

It would be defined as follows:

#[ErrorChannel("dbal_dead_letter")]
interface CommandBusWithDeadLetter extends CommandBus
{
}

What do you think?

dgafka avatar Mar 26 '25 19:03 dgafka

I understand the need for Commands where we trigger an action from HTTP Controller. I don't see the use case for synchronous Events however. As synchronous Events are result of synchronous Commands, so they are part of same transaction, therefore in my opinion they should fail together and be treated as one.

What I can propose is custom ErrorChannel on the level of Command Bus, which then can be used for different Commands as needed.

It would be defined as follows:

#[ErrorChannel("dbal_dead_letter")] interface CommandBusWithDeadLetter extends CommandBus { } What do you think?

It's a good solution, but it would be even more flexible to define it for specific command handlers. And of course, it's important to have the ability to trigger the processing again via a console command — ideally allowing the handler to reprocess it asynchronously.

lifinsky avatar Mar 26 '25 19:03 lifinsky

It's doable for Message Handlers, however I don't see how this solution can be stable at that level. So suppose we put ErrorChannel flag on top of one of three synchronous Event Handlers (Transaction is wrapping all of them). Now Handler having Error Channel will fail with SQL exception, let's say Error Channel will handle the error, yet the next Event Handler try to trigger any other SQL within scope of same transaction, and as a result this will fail (because transaction is already marked for rollback).

This is exactly the same issue, which we discussed with nested retries. Therefore those operations are not atomic on that level, and can't be consider as such. That's why Error Channel is actually at the beginning of the flow, before transaction management.

Unless I am missing something, that has to be done on the level of Message Gateway (Command Bus) to be stable for all scenarios.

dgafka avatar Mar 27 '25 07:03 dgafka

It's doable for Message Handlers, however I don't see how this solution can be stable at that level. So suppose we put ErrorChannel flag on top of one of three synchronous Event Handlers (Transaction is wrapping all of them). Now Handler having Error Channel will fail with SQL exception, let's say Error Channel will handle the error, yet the next Event Handler try to trigger any other SQL within scope of same transaction, and as a result this will fail (because transaction is already marked for rollback).

This is exactly the same issue, which we discussed with nested retries. Therefore those operations are not atomic on that level, and can't be consider as such. That's why Error Channel is actually at the beginning of the flow, before transaction management.

Unless I am missing something, that has to be done on the level of Message Gateway (Command Bus) to be stable for all scenarios.

On root CommandHandler as option

lifinsky avatar Mar 27 '25 08:03 lifinsky

It's doable for Message Handlers, however I don't see how this solution can be stable at that level. So suppose we put ErrorChannel flag on top of one of three synchronous Event Handlers (Transaction is wrapping all of them). Now Handler having Error Channel will fail with SQL exception, let's say Error Channel will handle the error, yet the next Event Handler try to trigger any other SQL within scope of same transaction, and as a result this will fail (because transaction is already marked for rollback). This is exactly the same issue, which we discussed with nested retries. Therefore those operations are not atomic on that level, and can't be consider as such. That's why Error Channel is actually at the beginning of the flow, before transaction management. Unless I am missing something, that has to be done on the level of Message Gateway (Command Bus) to be stable for all scenarios.

On root CommandHandler as option

That still does not solve the problem. As it will handle the exception on the Command Handler level, and then try to commit the transaction on Command Bus level (because error was handled) and that will fail.

dgafka avatar Mar 27 '25 09:03 dgafka

@dgafka Can’t we roll back the current transaction and save to the dead letter table (error_messages) by placing an around interceptor for the ErrorChannel between the transactional interceptor and our command handler?

lifinsky avatar Mar 27 '25 23:03 lifinsky

@lifinsky transaction interceptors are around interceptors, therefore they work in this manner:

beginTransaction();
   $methodInvocation->proceed(); // Whatever is intercepted
commit();

And they are set up on Command Bus level, not on Command Handler level. Therefore it looks like this: Image

So it does not trigger on the Command Handler level, but just after the moment you trigger Command Bus.

Now if we would put ErrorChannel on Command Handler level (around interceptor for Command Handler), then it would happen down the stack, so it couldn't really happen before transaction interceptor:

Image


So yea we can do custom error channel, however it has to be on CommandBus level then to work for all cases, and then using precedence we can adjust the positions against transaction interceptor:

Image

dgafka avatar Mar 28 '25 08:03 dgafka

I was guided by the idea of ​​not registering handlers for nested commands and supporting a common ecosystem of attributes for methods.

lifinsky avatar Mar 28 '25 08:03 lifinsky

This is now available as Enterprise feature starting from 1.258.1

Cheers :)

dgafka avatar Jun 14 '25 20:06 dgafka