Skip to content

EF Core transactional middleware silently ignores TransactionMiddlewareMode.Eager for handler chains that return storage actions (Insert<T> / UnitOfWork<T> / Update<T>) #3039

Description

@valentinroland-MG

EF Core transactional middleware silently ignores TransactionMiddlewareMode.Eager for handler chains that return storage actions (Insert<T> / UnitOfWork<T> / Update<T>)

Wolverine version: 6.3.2

Summary

When a handler chain's return value includes EF Core storage actions (Insert<T>, Update<T>, UnitOfWork<T>), the EF Core transactional middleware is applied in its lightweight form even when the chain is explicitly configured for TransactionMiddlewareMode.Eager via chain.Tags["TransactionMiddlewareMode"] from an IHandlerPolicy. The generated handler then contains:

  • no EnrollDbContextInTransaction / EnlistInOutboxAsync (no outbox enrollment),
  • no Database.BeginTransactionAsync(...),
  • only the trailing SaveChangesAsync ("Added by EF Core Transaction Middleware").

Chains for the same message types whose handlers return only cascading messages (or Task) get the full eager pattern as expected. There is no warning or error — the transaction is just silently absent.

Configuration

The global transaction mode in our solution is set to Lightweight using options.UseEntityFrameworkCoreTransactions(Wolverine.Persistence.TransactionMiddlewareMode.Lightweight);

We opt specific messages into eager transactions with a marker interface and a policy, since handlers are doing DB operations that must live inside the handler-spanning transaction:

public class EagerTransactionForMessagePolicy<TMessage> : IHandlerPolicy
{
    public void Apply(IReadOnlyList<HandlerChain> chains, GenerationRules rules, IServiceContainer container)
    {
        foreach (var chain in chains.Where(c => typeof(TMessage).IsAssignableFrom(c.MessageType)))
        {
            chain.Tags["TransactionMiddlewareMode"] = TransactionMiddlewareMode.Eager;
        }
    }
}

// registration
options.Policies.Add<EagerTransactionForMessagePolicy<ISpecificMessage>>();

This is the same Tags key (EFCorePersistenceFrameProvider.TransactionModeKey) that TransactionalAttribute.Modify writes.

Observed behaviour

Across one codegen run (75 of them for messages implementing our marker interface):

  • 35 chains were generated eager (outbox enrollment + BeginTransactionAsync + commit).
  • 40 chains were generated with no transaction at all.
  • Cross-referencing: there is zero overlap between generated files containing BeginTransactionAsync and files containing EfCoreStorageActionApplier.

The discriminator is the handler's return shape, not the message. The same message type splits across sagas:

Message Handler returns Generated
SomeMessageA Task<SomeCascadedMessage> Eager ✔
SomeMessageB Task<(SomeCascadedMessage, UnitOfWork<Something>)> No transaction ✘
SomeMessageC Task Eager ✔
SomeMessageD Task<Insert<Something>> No transaction ✘

Generated output for the storage-action chain (abridged; note the absence of any outbox/transaction frames):

public override async Task HandleAsync(MessageContext context, CancellationToken cancellation)
{
    await using var serviceScope = _serviceScopeFactory.CreateAsyncScope();
    var dbContext = serviceScope.ServiceProvider.GetRequiredService<AppDbContext>();
    // ... saga load, message execution, EfCoreStorageActionApplier.ApplyAction(...), saga version bump ...
    var result_of_SaveChangesAsync1 = await dbContext.SaveChangesAsync(cancellation);

    // Added by EF Core Transaction Middleware
    var result_of_SaveChangesAsync = await dbContext.SaveChangesAsync(cancellation);
}

The sibling chain (same saga, same DbContext, message returning no storage actions) is generated with the expected:

var efCoreEnvelopeTransaction = new EfCoreEnvelopeTransaction(dbContext, context, ...);
await context.EnlistInOutboxAsync(efCoreEnvelopeTransaction);
// Start the actual database transaction if one does not already exist
if (dbContext.Database.CurrentTransaction == null)
{
    await dbContext.Database.BeginTransactionAsync(cancellation);
}
try { ... } // commit via efCoreEnvelopeTransaction.CommitAsync / rollback on exception

Questions / asks

Is there a supported way to set the transaction mode per message type from a policy that is honored for storage-action chains — or could ApplyTransactionSupport defer/re-evaluate the mode after user IHandlerPolicy instances run? Marker-interface policies are the natural way to express "all X messages must be eager"; per-method attributes are easy to forget on new handlers.

Impact

Our handlers acquire SELECT ... FOR NO KEY UPDATE row locks that must be held until commit. In the affected chains those statements run in autocommit, so the locks are released immediately and concurrency protection silently disappears.

Workaround

  • IHandlerPolicy setting chain.Tags["TransactionMiddlewareMode"] = Eager: ignored for storage-action chains (this report).
  • [Transactional(Mode = TransactionMiddlewareMode.Eager)] on the handler method: works on 6.3.2 (verified — the generated handler gets the outbox enrollment + BeginTransactionAsync). We are annotating the affected handlers as an interim measure, but this is per-method and easy to miss for newly added handlers.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions