Chapter 04 · Keys, Indexes, Property Semantics, Value Generation, and Shadow State
Concurrency Tokens, Store-Generated Values, and Mapping Decisions that Affect Updates
Connect concurrency-token and store-generated property mappings to real UPDATE predicates, affected-row detection, and provider-specific optimistic-concurrency mechanisms.
Learning outcomes
Two ServiceHub dispatchers can load the same work order, make
different edits, and save seconds apart. Without a concurrency
policy the later save can overwrite the earlier one. EF Core
implements optimistic concurrency by adding original
concurrency-token values to UPDATE/DELETE
predicates and checking affected-row counts. This lesson
connects that write behavior to the property metadata you
configured throughout Chapter 04.
Configure an application-managed concurrency token that works in the mandatory SQLite lab.
Inspect original/current token values and the generated UPDATE predicate that detects a lost update.
Distinguish a concurrency conflict from deadlocks, timeouts, transient failures, and validation errors.
Compare application-managed tokens with SQL Server rowversion without presenting rowversion as portable.
Inspect store-generated/save behaviors to explain which columns EF sends, omits, or expects back.
Run a deterministic two-context conflict and repair it without blanket last-write-wins behavior.
1. Optimistic concurrency assumes conflicts are exceptional—but detectable
EF does not keep a database row locked for the entire time a
user edits a form. Instead, a query records the original value
of configured concurrency tokens. On update, EF includes those
original values in the WHERE predicate. If another
transaction changed the token first, zero rows match and EF
throws DbUpdateConcurrencyException.
A concurrency conflict is not a deadlock, command timeout, network failure, or retryable transient provider error. Each mechanism has different evidence and recovery. Chapter 13 treats conflict-resolution policies in depth; this lesson establishes the mapping/write mechanism.
2. Add an application-managed token for SQLite
SQLite does not have SQL Server's automatically changing
rowversion type. Microsoft specifically recommends
application-managed concurrency tokens as a portable way to
implement optimistic concurrency where no native auto-updating
token exists.
public Guid Revision { get; private set; } = Guid.NewGuid();public void AdvanceRevision(){ Revision = Guid.NewGuid();}
builder.Property(x => x.Revision) .HasColumnName("revision") .IsConcurrencyToken();
This token is not database-generated. Application code must assign a new value for each meaningful update that should invalidate stale writers.
3. Original and current values play different roles
var workOrder = await db.WorkOrders.SingleAsync( x => x.WorkOrderNumber == "WO-2026-000201", ct);var revision = db.Entry(workOrder).Property(x => x.Revision);Console.WriteLine($"Original: {revision.OriginalValue}");Console.WriteLine($"Current: {revision.CurrentValue}");workOrder.ReviseSummary("Inspect cavitation alarm and suction pressure");workOrder.AdvanceRevision();Console.WriteLine($"Original: {revision.OriginalValue}"); // old tokenConsole.WriteLine($"Current: {revision.CurrentValue}"); // new tokenawait db.SaveChangesAsync(ct);
For the update, the old/original token belongs in the predicate and the new/current token belongs in the values being written. That is how one statement both verifies “I am editing the version I read” and publishes a new version for later writers.
4. Read the generated UPDATE semantically
Exact SQL formatting and parameter names are provider/version details, but the shape should be equivalent to:
UPDATE work_ordersSET summary = @p0, revision = @p1WHERE work_order_id = @id AND revision = @original_revisionRETURNING 1;
If another writer has already changed revision, the
predicate matches zero rows. EF detects the zero affected-row
result for a concurrency-protected update and raises
DbUpdateConcurrencyException.
5. Deterministic two-context conflict
await using var first = await factory.CreateDbContextAsync(ct);await using var second = await factory.CreateDbContextAsync(ct);var a = await first.WorkOrders.SingleAsync( x => x.WorkOrderNumber == "WO-2026-000201", ct);var b = await second.WorkOrders.SingleAsync( x => x.WorkOrderNumber == "WO-2026-000201", ct);a.ReviseSummary("Dispatcher A confirmed suction pressure check");a.AdvanceRevision();await first.SaveChangesAsync(ct);b.ReviseSummary("Dispatcher B changed motor inspection scope");b.AdvanceRevision();try{ await second.SaveChangesAsync(ct);}catch (DbUpdateConcurrencyException ex){ Console.WriteLine($"Concurrency conflict on {ex.Entries.Count} tracked entry/entries."); throw; // Chapter 13 implements merge/retry policies.}
Do not “repair” this lab by catching the exception and blindly retrying the same stale state. A valid response may require store-wins, client-wins, field-level merge, user intervention, or domain-specific recomputation.
6. Deliberately broken token: never advance it
If Revision is configured as a concurrency token
but remains unchanged across updates, both writers can still
match the same token. The first update does not invalidate the
second writer, so the second can overwrite data without a
conflict.
var workOrder = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);workOrder.ReviseSummary(newSummary);// BUG: Revision remains unchanged.await db.SaveChangesAsync(ct);
The repair is to regenerate the application-managed token whenever a change should invalidate stale writers. Centralizing that policy in a domain method, SaveChanges pipeline, or interceptor can reduce omissions—but those mechanisms must be tested and are covered later.
7. SQL Server rowversion is a different, provider-specific mechanism
SQL Server has a database-generated binary
rowversion value that changes when a row is
updated. EF's SQL Server provider can configure a
byte[] property with IsRowVersion().
This is not portable to SQLite/PostgreSQL/MySQL/Oracle.
// SQL Server provider-specific entity variant.public byte[] RowVersion { get; private set; } = Array.Empty<byte>();builder.Property(x => x.RowVersion) .HasColumnName("row_version") .IsRowVersion();
IsRowVersion() combines concurrency-token semantics
with store-generated-on-add-or-update behavior for SQL Server.
PostgreSQL's xmin and other provider mechanisms
have different types/lifecycles. Chapter 13 and provider
chapters compare them explicitly.
8. Store-generated values change what EF sends and reads back
The model now contains several categories:
| Property | Owner | Write behavior concept |
|---|---|---|
Id |
Database/store-generated on add | EF omits/uses provider generation and propagates final key |
CreatedUtc shadow |
SQLite default on add | EF expects store value after insert according to provider support |
SearchLabel shadow |
Computed from stored columns | Application should not treat it as ordinary writable state |
Revision |
Application-managed concurrency token | Current token written; original token used in update predicate |
Summary |
Application/domain | Changed value written through backing-field mapping |
Inspect ValueGenerated and save behaviors rather
than guessing which properties will appear in a command.
var entity = db.Model.FindEntityType(typeof(WorkOrder))!;foreach (var property in entity.GetProperties()){ Console.WriteLine( $"{property.Name}: " + $"generated={property.ValueGenerated}, " + $"before={property.GetBeforeSaveBehavior()}, " + $"after={property.GetAfterSaveBehavior()}, " + $"concurrency={property.IsConcurrencyToken}");}
9. Mapping choices influence write amplification and correctness
A concurrency token adds predicate work to every guarded update.
Store-generated values may require provider-specific
RETURNING/OUTPUT or follow-up
retrieval. Computed columns remove application write
responsibility but move logic into database DDL. Broadly marking
many properties as concurrency tokens can create excessive
conflicts; marking too little can permit lost updates.
Use the smallest token scope that matches your domain consistency requirement. A single row-wide revision token is simple but conflicts on any guarded write. Field-specific tokens are possible but make conflict logic more complex.
10. Hands-on lab: prove lost-update detection
-
Add
RevisiontoWorkOrderand deterministic seed rows. -
Configure it with
IsConcurrencyToken; keep the SQLite path application-managed. - Generate/review/apply the migration to the disposable database.
- Load one row and record original/current revision values.
-
Change the summary, call
AdvanceRevision, save, and inspect SQL/logs. - Run the two-context conflict exactly once with deterministic work-order identity.
-
Capture
DbUpdateConcurrencyExceptionevidence without automatically retrying stale data. -
Reset, then deliberately omit
AdvanceRevisionand observe that two writers can overwrite without a token mismatch; repair the policy. - Run the metadata probe and classify every generated/concurrency property.
-
Document SQL Server
rowversionas a provider variant only; do not install SQL Server just to complete the mandatory lab.
Verification checklist
- The first writer changes both business state and revision token.
-
The second stale writer receives
DbUpdateConcurrencyException. - No retry is performed without a conflict-resolution decision.
- SQLite and SQL Server token mechanisms are not described as interchangeable.
- Generated/store-owned properties are not treated as ordinary application-writable fields.
Check your understanding
- What makes EF raise DbUpdateConcurrencyException for an optimistic-concurrency update?
- Why must an application-managed token be regenerated?
- Is a concurrency conflict the same as a deadlock?
- What does SQL Server IsRowVersion combine?
- Why should computed/store-generated properties not be treated like ordinary writable properties?
- Why is blindly retrying a concurrency exception dangerous?
Review the answers
The update/delete predicate includes original concurrency-token values and affects zero rows because the stored token no longer matches.
Changing the token invalidates stale writers; if it never changes, stale predicates can continue matching.
No. They are distinct mechanisms with different causes/evidence/recovery.
Concurrency-token semantics with SQL Server store-generated rowversion behavior.
The database owns or derives those values, so sending arbitrary application values can conflict with generation/save behavior.
The retry can simply reapply stale state and overwrite a newer decision; resolution requires comparing/reloading/merging according to domain policy.
11. Chapter 04 production judgment and bridge
Chapter 04 has turned “properties” into a precise persistence contract: keys define identity, indexes define candidate access paths, value-generation metadata defines ownership/timing, shadow/indexer/property-bag patterns define storage location, and concurrency tokens alter write predicates. Each choice affects migrations, provider behavior, tracking, disconnected workflows, and production observability.
Before production, test key/unique/null semantics on the target database, inspect real plans before tuning indexes, review every generated/default/computed expression in migration DDL, keep shadow names covered by model tests, and choose a concurrency mechanism that your provider and domain actually support. Chapter 05 now builds relationships on top of these foundations: principal/dependent roles, navigations, requiredness, cascades, many-to-many joins, and relationship fixup.
Authoritative references
- Handling Concurrency Conflicts - EF Core — optimistic concurrency, application-managed tokens, and provider-specific rowversion guidance
- Generated Values - EF Core — store-generated/default/computed value behavior
- PropertySaveBehavior — before/after save semantics
- SQL Server Value Generation — SQL Server identity/sequence/rowversion provider behavior
- Microsoft.EntityFrameworkCore.Sqlite 10.0.11 — mandatory provider version checkpoint