Chapter 14 · Transactions, Savepoints, Execution Strategies, and Cross-Context Coordination
Resilient Execution Strategies: Retryable Failures, Idempotency, and Transaction Delegates
Place user transactions inside provider execution-strategy delegates, distinguish transient replay from business conflicts, and design for ambiguous commit outcomes.
Learning outcomes
An execution strategy is EF/provider logic that can replay database operations after failures classified as transient. It is not a generic catch/retry loop, and it is not optimistic-concurrency resolution. When the application starts its own transaction, replay must encompass the entire transaction so the unit can be reconstructed safely.
Explain provider execution strategies and transient-failure classification without hard-coding universal retry counts.
Inspect the strategy supplied by the current provider and keep SQLite’s non-cloud baseline honest.
Recognize the InvalidOperationException caused by combining a retrying strategy with an unmanaged user transaction.
Execute the entire user transaction inside CreateExecutionStrategy().ExecuteAsync.
Reason about unknown commit outcomes and idempotency/client-generated identity.
Use verification or durable transaction/outbox IDs when replay safety requires proof of prior success.
Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, the existing Guid Revision concurrency token, and the Chapter 12 outbox teaching model when referenced. SQL Server retry/MARS/System.Transactions examples are optional provider-specific paths. EF Core 11 previews are excluded.
1. Provider retries classify infrastructure failures, not business conflicts
| Failure | Execution strategy candidate? | Why |
|---|---|---|
| SQL/Azure SQL transient connection error | Often, using SQL Server provider strategy | Provider knows error numbers and retry semantics. |
| Network interruption | Possibly | Depends on provider classification and commit ambiguity. |
| SQLite BUSY/LOCKED | Do not assume EF SQLite has the same retry strategy as SQL Server | SQLite locking/timeout policy is provider/engine-specific. |
| DbUpdateConcurrencyException | No generic transient replay | Stored business version changed; Chapter 13 resolution policy applies. |
| Unique/check constraint violation | Usually no | Same invalid input will normally fail again. |
Never copy EnableRetryOnFailure to providers that
do not expose the same extension or error taxonomy. The SQL
Server provider includes a retrying execution strategy; the
mandatory SQLite lab focuses on the API contract and local
transaction safety without pretending it is Azure SQL.
2. Inspect the strategy you actually configured
var strategy = db.Database.CreateExecutionStrategy();Console.WriteLine(strategy.GetType().FullName);Console.WriteLine($"RetriesOnFailure: {strategy.RetriesOnFailure}");
Record this output in the lab. If the SQLite strategy is non-retrying, that is useful evidence—not a missing feature to paper over. A production SQL Server configuration can opt into connection resiliency:
options.UseSqlServer( connectionString, sql => sql.EnableRetryOnFailure());
Do not freeze universal retry counts or delays. Provider defaults and workload tolerance are part of production configuration, monitoring, and incident design.
3. Deliberately wrong under a retrying strategy: start a transaction outside replay
await using var tx = await db.Database.BeginTransactionAsync(ct);// Under a retrying strategy, EF cannot replay this user transaction automatically.await db.SaveChangesAsync(ct);await tx.CommitAsync(ct);
With a retrying execution strategy such as SQL Server’s, EF
documents an InvalidOperationException explaining
that user-initiated transactions are not supported in that form.
A command cannot be retried independently if correctness
requires replaying earlier commands in the same transaction.
4. Repair: the transaction is inside the strategy delegate
var strategy = db.Database.CreateExecutionStrategy();await strategy.ExecuteAsync(async () =>{ await using var attempt = await factory.CreateDbContextAsync(ct); await using var tx = await attempt.Database.BeginTransactionAsync(ct); var order = await attempt.WorkOrders.SingleAsync(x => x.Id == id, ct); order.ReviseSummary("retry-safe transaction attempt"); order.AdvanceRevision(); await attempt.SaveChangesAsync(ct); attempt.Set<OutboxMessage>().Add(new OutboxMessage { Id = messageId, // stable client-generated identity OccurredUtc = occurredUtc, Type = "WorkOrderChanged", PayloadJson = payload }); await attempt.SaveChangesAsync(ct); await tx.CommitAsync(ct);});
The delegate describes the unit that can be replayed. A fresh
context per attempt prevents a failed connection/transaction
from contaminating a later attempt. Stable inputs such as
messageId are created outside the delegate so each
replay represents the same logical operation.
5. Commit can fail ambiguously
If the connection drops while commit is occurring, the client may not know whether the server committed. Replaying as though rollback occurred can duplicate work if the first commit actually succeeded. EF’s connection-resiliency guidance explicitly calls this the idempotency/commit-unknown problem.
| Technique | What it buys | Boundary |
|---|---|---|
| Client-generated Guid key | A replay collides with the same identity rather than silently inserting another auto-key row | Requires domain/schema to accept stable client identity. |
| Rebuild application state | Discard failed context and re-read database | May require user/operator confirmation when outcome is unknown. |
| ExecuteInTransaction + verifySucceeded | Checks whether the logical operation is already present after commit ambiguity | Verification itself must tolerate the same infrastructure faults. |
| Transaction/outbox operation ID | Durable idempotency evidence stored with the same local commit | Needs schema, uniqueness, cleanup/retention, and operational ownership. |
6. Verification pattern with AcceptAllChanges deferred
var strategy = db.Database.CreateExecutionStrategy();await strategy.ExecuteInTransactionAsync( db, operation: (context, token) => context.SaveChangesAsync(acceptAllChangesOnSuccess: false, token), verifySucceeded: (context, token) => context.Set<OutboxMessage>() .AsNoTracking() .AnyAsync(x => x.Id == messageId, token));db.ChangeTracker.AcceptAllChanges();
acceptAllChangesOnSuccess: false keeps the tracked
state replayable until the strategy has determined success. This
is advanced infrastructure code: test it with deliberate failure
injection and keep the verification key unique and meaningful.
7. The strategy may buffer result sets
EF’s connection-resiliency documentation notes that enabling retries causes EF to internally buffer result sets so a query can be replayed. That can materially increase memory for large streaming queries. Retry configuration therefore affects query memory behavior as well as writes—another reason not to enable a setting by cargo cult.
8. Hands-on lab
-
Print the mandatory SQLite execution-strategy type and
RetriesOnFailure. - Execute a simple strategy delegate and verify normal behavior even if the provider is non-retrying.
- Write a unit/integration seam that injects a fake/test execution strategy or fault wrapper to prove the transaction delegate can run more than once without producing a second logical outbox ID.
-
Keep
messageIdoutside the replay delegate and assert uniqueness/idempotency. -
Implement the documented
ExecuteInTransactionAsyncverification shape on the disposable outbox table. -
For optional SQL Server, enable retries and reproduce the
user-transaction
InvalidOperationException; then move the transaction into the delegate. -
Record conflict exceptions separately and prove your retry
classifier does not treat
DbUpdateConcurrencyExceptionas transient infrastructure. - Document memory implications before enabling retries around very large result streams.
Check your understanding
- Why must a user transaction be inside a retrying execution-strategy delegate?
- Is DbUpdateConcurrencyException normally a transient provider failure?
- Why create a stable message/operation ID outside the replay delegate?
- What is an ambiguous commit?
- Why delay AcceptAllChanges in a verification pattern?
- Can retrying execution strategies affect query memory?
Review the answers
Because a transient failure may require replaying the entire transaction unit, not one command in isolation.
No. It represents changed business/database state and needs a conflict policy.
Every retry should represent the same logical operation and be detectable/deduplicable.
A failure occurs while committing and the client cannot know whether the server committed or rolled back.
It keeps tracked state available for replay until transaction success has been verified.
Yes. EF documents internal result buffering when retries are enabled.
9. Production judgment and bridge
Retry only failures the provider classifies as transient, replay the correct unit, and make ambiguous commits observable/idempotent. Chapter 14’s next lesson keeps one local transaction but spreads work across multiple EF contexts and raw ADO.NET, showing that transaction coordination and change-tracker coordination are separate problems.
Authoritative references
- Connection Resiliency - EF Core — provider execution strategies, user-transaction delegate pattern, ambiguous commits, and verification.
- Using Transactions - EF Core — interaction between transactions and execution strategies.
- SQL Server EF Core provider — provider-specific connection resiliency configuration boundary.
- IExecutionStrategy API — execution strategy contract and RetriesOnFailure.
- Handling Concurrency Conflicts - EF Core — business-state conflicts that must not be conflated with transient retries.