Analyzer rule
LC063: User transaction under a retrying execution strategy
LC063 flags BeginTransaction on an EF Core context set up with EnableRetryOnFailure, which throws unless the transaction runs in the execution strategy.
- Default severity
- Warning
- Code fix
- Available
- Category
- Reliability
- Domain
- Execution & Async
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC063.severity = error
LC063: User transaction under a retrying execution strategy
In Plain Terms
The courier promises to try the delivery again if the first attempt fails, but only if the parcel is sealed. You hand over half a parcel and keep the rest in your pocket, so the courier refuses the job.
Goal
Detect a transaction the code starts itself (BeginTransaction, BeginTransactionAsync, UseTransaction) on a DbContext that is configured with a retrying execution strategy, when the transaction does not run through that strategy.
The Problem
Connection resiliency (EnableRetryOnFailure() on the SQL Server, Azure SQL, PostgreSQL, MySQL or Oracle provider, or a custom strategy derived from ExecutionStrategy) retries each operation on its own when a transient error occurs. It cannot retry half of a transaction, so EF Core refuses to run operations inside a transaction the code started:
// services.AddDbContext<AppDb>(o => o.UseSqlServer(cs, sql => sql.EnableRetryOnFailure()));
await using var tx = await db.Database.BeginTransactionAsync();
db.Orders.Add(order);
await db.SaveChangesAsync(); // InvalidOperationException
await tx.CommitAsync();
The configured execution strategy ‘SqlServerRetryingExecutionStrategy’ does not support user-initiated transactions. Use the execution strategy returned by ‘DbContext.Database.CreateExecutionStrategy()’ to execute all the operations in the transaction as a retriable unit.
Retries are often only enabled outside development, so the exception tends to show up first in production. See the EF Core documentation on execution strategies and transactions and dotnet/efcore#34825.
The Fix
Run the whole transaction through the context’s execution strategy, so a transient failure retries all of it:
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
db.Orders.Add(order);
await db.SaveChangesAsync();
await tx.CommitAsync();
});
The code inside the delegate can run more than once, so keep work that must happen once (sending an email, calling another service) outside it, and make sure the delegate can start again after a failure. If a failure during commit can leave the outcome unknown, ExecuteInTransactionAsync with a verification delegate checks whether the commit succeeded before retrying.
Analyzer Logic
ID: LC063
Category: Reliability
Severity: Warning
- Find the retrying configurations in the project:
EnableRetryOnFailure(...)on an EF Core provider options builder (the method’s type or the receiver’s type derives fromRelationalDbContextOptionsBuilder<,>, or is a*DbContextOptionsBuilderin aMicrosoft.EntityFrameworkCorenamespace; a same-named method on any other builder does not count), andExecutionStrategy(...)on such a builder whose factory creates a type derived from EF Core’sExecutionStrategybase class. - Tie each configuration to a context type: the
TContextof an enclosingAddDbContext,AddDbContextPool,AddDbContextFactoryorAddPooledDbContextFactorycall (the implementation type when there are two) or of aDbContextOptionsBuilder<TContext>chain, or the context whoseOnConfiguringoverride holds the call. A registration of a constructed generic context such asTenantDb<Customer>applies to that construction only; anOnConfiguringin the generic context itself applies to every construction. A configuration that cannot be tied to one (for example in a helper shared by several registrations) counts only when the project declares exactly one non-abstract context type. - Report
Database.BeginTransaction(...),Database.BeginTransactionAsync(...),Database.UseTransaction(transaction)andUseTransactionAsync(transaction)on a context whose static type is a configured context. A registration orDbContextOptionsBuilder<TContext>chain configures exactly that type (registeringBaseDbwith retries says nothing about aDerivedDb), while anOnConfiguringoverride also applies to every context deriving from the one that declares it, up to the first derived context whose ownOnConfiguringoverride never callsbase.OnConfiguring(...)(that override replaces the inherited configuration, and counts only if it enables retries itself).
When it stays quiet (non-goals)
- The transaction starts inside a delegate passed to the strategy’s
Execute,ExecuteAsync,ExecuteInTransactionorExecuteInTransactionAsyncas itsoperationorverifySucceededdelegate (a delegate passed as thestateargument runs whenever the operation invokes it, so it reports), or to a method in the project whose every use of that delegate parameter hands it to one of those strategy calls, directly or by invoking it inside the lambda passed to one (such as aResilientTransactionhelper). A method that also calls the delegate itself (for example in a fallback branch), stores it, returns it, passes it anywhere else, or captures it in another lambda is not a wrapper, and neither is one that only mentions an execution strategy. - The transaction starts in a method or local function that only ever runs under an execution strategy (a local function declared inside a strategy lambda is judged by where it is called, so one that escapes, for example as
escaped = Save;, reports): every reference to it in the project is a call inside a lambda passed to the strategy, the method passed directly to the strategy as a method group, or a call from another method that itself only runs under a strategy (followed through any number of calls). Methods that call each other are judged together: they are exempt when at least one call into the group runs under the strategy and every call into the group from outside it does. One caller outside the strategy is enough to report. A helper that only builds the delegate, as instrategy.Execute(BuildWork()), and a method group only stored inside the lambda (Action later = Save;) do not count, and recursion without an entry from the strategy proves nothing. Callers in other projects, or through interface and virtual calls, are not seen. - The transaction starts inside a lambda whose invocation the rule cannot follow: stored in a field, returned, passed on through a local to another method, or never invoked. A lambda invoked in place (
((Action)(() => ...))()) or through a local whose only uses are calls (Action work = () => ...; work();,work.Invoke()orwork?.Invoke()) is analyzed like inline code, and stays quiet only when every such call runs under a strategy. Anasynclambda invoked in place counts as inline when its task is awaited right there (directly or throughConfigureAwait), and for code that runs before its firstawaitoutside any loop that awaits; the rest of its body runs after the strategy delegate has returned, and a transaction there reports. - Retries are turned off:
EnableRetryOnFailure(0),maxRetryCount: 0, a strategy created with a constant retry count of 0, or a strategy that does not derive fromExecutionStrategy(such asNonRetryingExecutionStrategy). - The context’s static type is
DbContext, an interface, or a context with no retrying configuration in this project. Configuration in another project, and retries switched on by a library (for example .NET Aspire’sAddSqlServerDbContext), are not seen. A custom retrying strategy is recognised only when theExecutionStrategy(...)lambda constructs it inline (d => new MyRetryingStrategy(d)); a factory method group or helper that returns one is not followed, so the rule stays quiet rather than guessing. UseTransaction(null), which clears the transaction.TransactionScopeis not tracked.
A method whose every caller runs under the strategy stays quiet. That includes calls from a lambda invoked in place inside the strategy delegate (strategy.Execute(() => ((Action)(() => Save()))())) and from a lambda kept in a local whose only use is the strategy’s delegate argument (Action work = () => Save(); strategy.Execute(work);, also in parentheses, through a cast or in top-level statements). If that local is also invoked, passed elsewhere or reassigned, Save still reports. An override that calls base.OnConfiguring(...) inherits the base context’s retries only when the call binds to the overridden method, not to another OnConfiguring overload.
Code Fix
For the simple shape, the fix moves the transaction into the execution strategy. When the transaction is the single local of a using or await using declaration directly inside a block, the declaration and the rest of the block move into the delegate. When it is owned by a using statement directly inside a block, that statement moves:
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
});
The delegate is async and awaited when the moved code awaits; otherwise the fix uses strategy.Execute(() => { ... }). Comments above the transaction stay above the strategy. The fix adds using Microsoft.EntityFrameworkCore; when the file needs it for Execute/ExecuteAsync, and picks another name when strategy is taken.
There is no fix when the moved code contains return, yield, goto or a label, when it calls ConfigureAwait with anything other than the constant true (the new outer await cannot keep that choice without guessing), when the context receiver could change between the two reads the fix makes (anything other than this, a readonly field, a get-only auto-property that is not virtual, abstract or an override unless its type is sealed, or a parameter or local the method never writes), for UseTransaction, for the static RelationalDatabaseFacadeExtensions.BeginTransaction(...) form, for a transaction that is not owned by a using, or for a using with several resources. The fixer compiles the result and offers nothing when the rewrite would add an error, which also covers ref/out parameters used in the moved code, break/continue out of the block, and locals that are no longer definitely assigned after it.
Test Cases
Violations
// With AddDbContext<AppDb>(o => o.UseSqlServer(cs, sql => sql.EnableRetryOnFailure()))
await using var tx = await db.Database.BeginTransactionAsync(ct); await db.SaveChangesAsync(ct); await tx.CommitAsync(ct);
using (var tx = db.Database.BeginTransaction()) { db.SaveChanges(); tx.Commit(); }
db.Database.UseTransaction(externalTransaction); db.SaveChanges();
Valid
await db.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
});
// With EnableRetryOnFailure(0), or on a context without retries
using var tx = db.Database.BeginTransaction(); db.SaveChanges(); tx.Commit();