Chapter 13 · Optimistic Concurrency and Conflict Resolution
Resolve DbUpdateConcurrencyException with Client Wins, Store Wins, or Field-Level Merge
Use DbUpdateConcurrencyException entries and property snapshots to implement store-wins, client-wins, and field-level merge policies with bounded retries.
Learning outcomes
A concurrency exception is not a request to “retry until it works.” It is a request to decide what the application means when the proposal, the original version, and the current database version disagree. ServiceHub needs different policies for different fields and workflows.
Inspect exception Entries, CurrentValues, OriginalValues, and GetDatabaseValuesAsync.
Implement store-wins by discarding the stale proposal and reloading the database state.
Implement client-wins consciously by refreshing originals before a bounded retry.
Implement a three-way field-level merge using original/proposed/database values.
Handle row deletion and irreconcilable conflicts explicitly.
Cap retries and emit conflict telemetry without logging confidential payloads.
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. The three value sets form a merge problem
| Value set | Meaning | Source |
|---|---|---|
| Original | What this context read/believed when tracking started. | entry.OriginalValues |
| Proposed/current | What this client now wants to persist. | entry.CurrentValues |
| Database | What is stored now after the competing writer. | await entry.GetDatabaseValuesAsync() |
A field-level merge is analogous to a three-way source-control merge: original is the common base, proposed is one branch, database is the other. If only one side changed a property, merging can be safe. If both sides changed it differently, the domain must decide.
2. Store wins: discard the stale proposal
catch (DbUpdateConcurrencyException ex){ foreach (var entry in ex.Entries) { await entry.ReloadAsync(ct); // Entry is now aligned to the current store row and typically Unchanged. } return Conflict("The work order changed; current server state was reloaded.");}
Store-wins is appropriate when the attempted edit is low value, automatically recomputable, or the user should review the latest state before editing again. It is not “successful save”; the client proposal was discarded and that outcome must be visible.
3. Client wins: refresh the precondition, then retry deliberately
for (var attempt = 1; attempt <= 2; attempt++){ try { await db.SaveChangesAsync(ct); break; } catch (DbUpdateConcurrencyException ex) when (attempt < 2) { foreach (var entry in ex.Entries) { var databaseValues = await entry.GetDatabaseValuesAsync(ct); if (databaseValues is null) throw new WorkOrderDeletedConflictException(); // Keep proposed values; accept the database version as the new comparison base. entry.OriginalValues.SetValues(databaseValues); // Publish a fresh application-managed version for the retry. entry.CurrentValues[nameof(WorkOrder.Revision)] = Guid.NewGuid(); } }}
This intentionally overwrites conflicting database fields with the client's current proposal if the second attempt succeeds. Use it only when the business policy explicitly says the client has authority. Refreshing originals is what changes the next predicate; simply retrying the exact stale predicate achieves nothing.
4. Field-level merge: compare each property to the common base
var proposed = entry.CurrentValues;var original = entry.OriginalValues;var database = await entry.GetDatabaseValuesAsync(ct) ?? throw new WorkOrderDeletedConflictException();var mergeableProperties = new[]{ nameof(WorkOrder.Summary) // Add only domain fields whose merge policy is explicitly defined.};foreach (var propertyName in mergeableProperties){ var was = original[propertyName]; var mine = proposed[propertyName]; var theirs = database[propertyName]; var mineChanged = !Equals(mine, was); var theirsChanged = !Equals(theirs, was); if (!mineChanged && theirsChanged) proposed[propertyName] = theirs; // take store-only edit else if (mineChanged && theirsChanged && !Equals(mine, theirs)) throw new WorkOrderMergeConflictException(propertyName, mine, theirs);}// Generated/audit/FK fields are not implicitly merged by this allow-list.entry.OriginalValues.SetValues(database);entry.CurrentValues[nameof(WorkOrder.Revision)] = Guid.NewGuid();await db.SaveChangesAsync(ct);
This generic shape is useful for teaching, but production merge policy should usually be explicit per domain field. A summary decision, technician assignment, approval status, and financial amount do not have the same merge semantics.
5. The backing field does not change the concurrency method
ServiceHub maps Summary through the
_summary backing field. Conflict resolution should
still use the mapped EF property metadata named
Summary, as Chapter 11 established. Do not attempt
entry.Property("_summary") unless the field itself
was separately configured as a field-only property.
var proposedSummary = entry.CurrentValues[nameof(WorkOrder.Summary)];var originalSummary = entry.OriginalValues[nameof(WorkOrder.Summary)];var databaseSummary = databaseValues[nameof(WorkOrder.Summary)];
6. Deleted rows require a different decision
GetDatabaseValuesAsync can return
null when the row no longer exists. “Client wins”
is no longer merely an update decision: recreating a deleted
aggregate may violate identifiers, uniqueness, workflow history,
foreign keys, or legal retention rules.
Treat deletion-vs-edit as an explicit domain conflict. Returning HTTP 409/412, asking the user to re-create a new work order, or rejecting a background job may all be safer than inserting a replacement row.
7. Hands-on policy lab
- Reset SQLite and prepare two contexts reading the same work order/revision.
- Make writer A change Summary and commit.
-
Make writer B change the same Summary and capture
DbUpdateConcurrencyException. - Run store-wins and verify B's proposed summary is discarded.
- Reset; run bounded client-wins and verify the final database summary/revision.
- Reset; create non-overlapping changes in a teaching pair of mapped fields and run the three-way merge.
- Create a same-field divergent edit and verify your merge policy surfaces an irreconcilable conflict rather than picking arbitrarily.
-
Delete the row from writer A and verify B handles
GetDatabaseValuesAsync == null.
Check your understanding
- What are the three inputs to a field-level merge?
- What does store-wins do to the client proposal?
- What must client-wins refresh before retry?
- Why generate a fresh Revision for the retry?
- What does null GetDatabaseValues mean?
- Why cap retries?
Review the answers
Original values, proposed/current values, and current database values.
It discards/replaces it with the latest store state.
Original values, so the next concurrency predicate compares against the current database version.
A successful retry must publish a new version that invalidates other stale writers.
The row no longer exists, so update resolution becomes a deletion/recreation domain decision.
Repeated new conflicts can otherwise cause an unbounded loop and hide sustained contention/business disagreement.
8. Production judgment and bridge
Conflict resolution belongs close to domain/application policy, not in a generic “retry all concurrency exceptions” utility. Emit structured telemetry such as entity type, operation, attempt count, and conflict field names—without dumping confidential values. Lesson 4 moves that policy across process boundaries where versions become ETags, message preconditions, and offline-edit contracts.
Authoritative references
- Handling Concurrency Conflicts - EF Core — Current/Original/DatabaseValues and retry pattern.
- Accessing Tracked Entities - EF Core — PropertyValues APIs and entry metadata.
- DbUpdateConcurrencyException API — exception contract and conflicting Entries.
- GetDatabaseValuesAsync API — reading current store values for conflict resolution.
- EF Core diagnostics — observing conflicts without relying on exception text parsing.