Chapter 13 · Optimistic Concurrency and Conflict Resolution
Test Races Deterministically and Distinguish Concurrency Conflicts from Transient Failures
Build deterministic multi-context race tests, classify concurrency conflicts separately from provider transient faults, and assert the retry policy itself.
Learning outcomes
Concurrency tests that “start two tasks and hope they collide” are flaky. The goal is to control the causal order: both actors must read the same version, one actor must commit a new version, and the stale actor must then attempt its conditional write. Infrastructure faults need their own tests and retry policy.
Coordinate two DbContext instances so both read the same revision before either writes.
Assert DbUpdateConcurrencyException deterministically without depending on thread timing.
Build a separate SQLite lock/busy experiment and classify it as a provider failure, not a business conflict.
Distinguish concurrency resolution from execution-strategy transient retry.
Test bounded retry/merge behavior and final database state, not only exception type.
Capture logs/metrics that make conflict rates and transient faults separately observable.
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, and the existing application-managed Guid Revision token. SQL Server rowversion and PostgreSQL xmin are optional provider comparisons, not mandatory infrastructure. EF Core 11 previews are excluded.
1. Deterministic conflict test: synchronize reads, serialize writes
var bothRead = new CountdownEvent(2);var firstCommitted = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously);async Task WriterA(){ await using var db = await factory.CreateDbContextAsync(ct); var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct); bothRead.Signal(); bothRead.Wait(ct); order.ReviseSummary("A: replace bearing"); order.AdvanceRevision(); await db.SaveChangesAsync(ct); firstCommitted.SetResult();}async Task WriterB(){ await using var db = await factory.CreateDbContextAsync(ct); var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct); bothRead.Signal(); bothRead.Wait(ct); await firstCommitted.Task.WaitAsync(ct); order.ReviseSummary("B: inspection only"); order.AdvanceRevision(); await Assert.ThrowsAsync<DbUpdateConcurrencyException>( () => db.SaveChangesAsync(ct));}await Task.WhenAll(WriterA(), WriterB());
The logical conflict is deterministic because writer B cannot save until writer A has committed. There is no need to make two SQLite writes physically overlap.
2. Assert more than the exception
await using var verify = await factory.CreateDbContextAsync(ct);var stored = await verify.WorkOrders.AsNoTracking() .SingleAsync(x => x.Id == id, ct);Assert.Equal("A: replace bearing", stored.Summary);Assert.NotEqual(startingRevision, stored.Revision);Assert.Equal(1, observedConcurrencyConflicts);
A useful test proves the winner, the final version, the loser behavior, and any merge/retry policy. Otherwise a regression could still throw the “right” exception after partial or incorrect state changes elsewhere.
3. Separate lab: SQLite lock/busy is not optimistic concurrency
To observe infrastructure/database contention separately, use a
different disposable SQLite file and hold a write
transaction open with a short/zero busy wait before another
connection attempts a write. The expected provider symptom is a
SQLite busy/locked error, not
DbUpdateConcurrencyException.
BEGIN IMMEDIATE;UPDATE work_ordersSET summary = 'lock owner'WHERE work_order_id = 201;-- keep transaction open while the second connection attempts its write
SQLite file-lock timing, busy timeout, and provider connection settings are implementation details. Use the ordered token test for deterministic optimistic concurrency; use the lock lab only to prove exception classification.
4. Recovery policy must branch by failure class
try{ await db.SaveChangesAsync(ct);}catch (DbUpdateConcurrencyException ex){ // Business-state conflict: merge/reload/reject according to domain policy. await conflictHandler.ResolveAsync(ex, ct);}catch (DbException ex) when (transientDetector.IsTransient(ex)){ // Infrastructure transient: provider-specific retry/idempotency policy. throw new RetryableInfrastructureException("Database transient failure", ex);}
DbException is not synonymous with transient. The
detector/execution strategy must come from provider-specific
knowledge. SQL Server, PostgreSQL, MySQL, Oracle, and SQLite
have different error taxonomies and retry support.
5. EF execution strategies do not resolve business conflicts
An EF provider execution strategy can retry operations the provider classifies as transient. A concurrency exception means the stored business version changed; rerunning without a resolution decision simply repeats or overwrites intent. When user-managed transactions and retrying strategies are combined, follow the provider/EF documented transaction delegate pattern rather than nesting arbitrary retries.
| Event | Retry automatically? | Required decision |
|---|---|---|
| Concurrency token mismatch | No generic retry | Store/client/merge/reject policy. |
| Known provider transient before commit | Possibly, with documented execution strategy | Idempotent retry semantics. |
| Ambiguous connection failure near commit | Not safely by assumption | Idempotency/reconciliation because commit outcome may be unknown. |
| Validation/constraint error | Usually no | Fix input/domain/schema issue. |
6. Test the policy itself
var result = await service.UpdateSummaryAsync( id, expectedRevision, "new summary", maxConflictAttempts: 2, ct);Assert.True(result.IsConflict || result.IsSuccess);Assert.InRange(result.Attempts, 1, 2);
For store-wins/client-wins/merge tests, inject deterministic conflict points and assert attempts are capped. Also test cancellation so a request cancellation token stops resolution/retry work promptly.
7. Observability: count conflicts and transients separately
logger.LogWarning( "Concurrency conflict entity={Entity} operation={Operation} attempt={Attempt}", "WorkOrder", "UpdateSummary", attempt);// Separate metric names/dimensions for provider transient failures.
Useful production signals include conflict rate by command/use case, average resolution attempts, irreconcilable-conflict count, transient provider failures, transaction retries, and final outcomes. Avoid logging full proposed/database values when they may contain secrets or personal/customer data.
8. Final chapter lab
- Reset the deterministic SQLite file and record the starting Revision.
-
Run the synchronized two-context test and assert one success +
one
DbUpdateConcurrencyException. - Verify the database contains the winner's summary and a new revision.
- Run store-wins/client-wins/merge policy tests with an explicit max-attempt count.
- Run the separate lock/busy experiment and confirm it is caught/classified outside the concurrency branch.
- Cancel a conflict-resolution operation and verify cancellation is not transformed into a retry.
- Capture separate structured logs/metrics for concurrency and transient failures.
- Document how the production provider's execution strategy/transient taxonomy differs from SQLite before deploying the same policy elsewhere.
Check your understanding
- Why not use random Task.Delay calls to create a race?
- How does the deterministic test ensure both writers are stale peers?
- Is SQLITE_BUSY a business concurrency conflict?
- Should an execution strategy automatically solve DbUpdateConcurrencyException?
- Why test final database state?
- Why separate metrics for conflicts and transients?
Review the answers
They make thread/OS timing part of correctness and produce flaky tests.
Both read before either writes; writer B waits until writer A commits, then writes with its old token.
No. It is a database locking/provider failure.
No. A business conflict needs resolution policy, not generic transient replay.
Exception type alone does not prove the correct winner, token, or absence of partial side effects.
They have different root causes, owners, remediation, and capacity/business implications.
9. Production judgment and bridge to Chapter 14
Optimistic concurrency is now an observable contract: token ownership, conditional DML, resolution policy, cross-process version propagation, deterministic tests, and separate transient-fault handling. Chapter 14 builds on this by examining transactions, savepoints, isolation levels, execution strategies, and cross-context coordination—mechanisms that protect atomicity and wider invariants rather than merely detecting a stale row version.
Authoritative references
- Handling Concurrency Conflicts - EF Core — conflict detection and resolution semantics.
- Connection Resiliency - EF Core — execution strategies and transient retry behavior.
- Using Transactions - EF Core — transactions with execution strategies and save behavior.
- DbUpdateConcurrencyException API — typed business-conflict exception surface.
- SQLite Result and Error Codes — BUSY/LOCKED classification for the optional lock lab.
- EF Core logging and diagnostics — structured observation of EF operations and failures.