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.
EF Core transactional middleware silently ignores
TransactionMiddlewareMode.Eagerfor 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 forTransactionMiddlewareMode.Eagerviachain.Tags["TransactionMiddlewareMode"]from anIHandlerPolicy. The generated handler then contains:EnrollDbContextInTransaction/EnlistInOutboxAsync(no outbox enrollment),Database.BeginTransactionAsync(...),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:
This is the same
Tagskey (EFCorePersistenceFrameProvider.TransactionModeKey) thatTransactionalAttribute.Modifywrites.Observed behaviour
Across one codegen run (75 of them for messages implementing our marker interface):
BeginTransactionAsync+ commit).BeginTransactionAsyncand files containingEfCoreStorageActionApplier.The discriminator is the handler's return shape, not the message. The same message type splits across sagas:
SomeMessageATask<SomeCascadedMessage>SomeMessageBTask<(SomeCascadedMessage, UnitOfWork<Something>)>SomeMessageCTaskSomeMessageDTask<Insert<Something>>Generated output for the storage-action chain (abridged; note the absence of any outbox/transaction frames):
The sibling chain (same saga, same DbContext, message returning no storage actions) is generated with the expected:
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
ApplyTransactionSupportdefer/re-evaluate the mode after userIHandlerPolicyinstances 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 UPDATErow 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
IHandlerPolicysettingchain.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.