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.

Advanced145–185 minutesdeterministic race + failure-classification labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

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.

01

Coordinate two DbContext instances so both read the same revision before either writes.

02

Assert DbUpdateConcurrencyException deterministically without depending on thread timing.

03

Build a separate SQLite lock/busy experiment and classify it as a provider failure, not a business conflict.

04

Distinguish concurrency resolution from execution-strategy transient retry.

05

Test bounded retry/merge behavior and final database state, not only exception type.

06

Capture logs/metrics that make conflict rates and transient faults separately observable.

Reproducible baseline

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

csharp · TaskCompletionSource coordination
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

csharp · postconditions matter
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.

sql · conceptual lock-owner sequence
BEGIN IMMEDIATE;UPDATE work_ordersSET summary = 'lock owner'WHERE work_order_id = 201;-- keep transaction open while the second connection attempts its write
Do not make this the concurrency test

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

csharp · application-level classification
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

csharp · bounded conflict handler contract
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

csharp · structured event dimensions
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

  1. Reset the deterministic SQLite file and record the starting Revision.
  2. Run the synchronized two-context test and assert one success + one DbUpdateConcurrencyException.
  3. Verify the database contains the winner's summary and a new revision.
  4. Run store-wins/client-wins/merge policy tests with an explicit max-attempt count.
  5. Run the separate lock/busy experiment and confirm it is caught/classified outside the concurrency branch.
  6. Cancel a conflict-resolution operation and verify cancellation is not transformed into a retry.
  7. Capture separate structured logs/metrics for concurrency and transient failures.
  8. Document how the production provider's execution strategy/transient taxonomy differs from SQLite before deploying the same policy elsewhere.

Check your understanding

  1. Why not use random Task.Delay calls to create a race?
  2. How does the deterministic test ensure both writers are stale peers?
  3. Is SQLITE_BUSY a business concurrency conflict?
  4. Should an execution strategy automatically solve DbUpdateConcurrencyException?
  5. Why test final database state?
  6. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n