Optimistic concurrency and change tracking
nextorm deliberately has no change tracking and no identity map, so concurrency is explicit: put the expected token in
Where, and read0affected rows as a conflict. This page shows that pattern, how to read the new token back, the token-guarded upsert, and how to build a thin change-tracking layer on top when you wantSaveChanges-style ergonomics.
Prerequisites: Data modification (UPDATE) · Data modification (MERGE) · Transactions · Entities and metadata
Why there is no built-in change tracker
A change tracker is only useful when the framework owns the loaded entity: it keeps the instance in an
identity map, remembers the original values, notices mutations and turns them into UPDATEs in
SaveChanges. nextorm does none of that — it is a query builder with an explicit DML surface
(Limitations and out-of-scope features):
- every terminal (
ToList,Update,Insert,Merge,Delete, ...) issues exactly one command and returns its result; - a loaded row is a plain POCO the caller owns; the context never sees it again;
- there is no identity map, no original-values snapshot and no "changed properties" concept.
For concurrency this means the framework cannot know which token value a row had when you read it, so it
cannot add the check for you. It only has to hand you the two primitives you need — a WHERE that
compares the token, and the affected-row count — and both already exist. Optimistic concurrency is
therefore a pattern on top of the explicit surface, documented here, rather than an
IfUnchanged/WithRefresh API shipped by the library.
The token column
Optimistic concurrency needs a value that changes on every write. Map it as an ordinary property; nextorm compares it like any other column.
| Token | Provider | Mapped as | Notes |
|---|---|---|---|
long / int version |
all | long Version { get; set; } |
application-managed; increment in the same statement |
rowversion / timestamp |
SQL Server | byte[] Version { get; } |
database-managed; mark computed |
TIMESTAMP ... ON UPDATE CURRENT_TIMESTAMP |
MySQL / MariaDB | DateTime Version { get; set; } |
database-managed |
xmin (system column) |
PostgreSQL | uint Revision { get; set; } |
no native rowversion column; map the system column with [Column("xmin")] and mark it computed, uint is the CLR type for xid |
[SqlTable("orders")]
public interface IOrder
{
[Key] long Id { get; }
string Status { get; set; }
long Version { get; set; } // application-managed concurrency token
}
A database-managed token is never written by the application, so mark it computed
([DatabaseGenerated(DatabaseGeneratedOption.Computed)] or .Computed()) — Set(entity) then skips it
automatically.
Check on update
Load the row, remember its token, then put both the key and the original token in Where:
public sealed class ConcurrencyException(string message) : Exception(message);
var originalVersion = order.Version;
var affected = ctx.CreateUpdateBuilder<IOrder>()
.Set(o => o.Status, "paid")
.Set(o => o.Version, o => o.Version + 1) // portable: bump in SQL
.Where(o => o.Id == order.Id && o.Version == originalVersion)
.Update();
if (affected == 0)
throw new ConcurrencyException($"Order {order.Id} was modified by someone else.");
-- PostgreSQL
update orders set status = @p0, version = version + 1 where (id = @p1 and version = @p2)
Whereis the same predicate translator as a queryWHERE, soVersion == originalVersionbinds the token as a parameter, not as inline SQL.Set(o => o.Version, o => o.Version + 1)increments atomically in the database — no read-modify-write race in application code.- Drop the version predicate and the update becomes last write wins.
- For a database-managed token, do not
Setit at all (it is computed); only compare it inWhere.
0 affected rows is deliberately ambiguous: the row may have been deleted, or its token may have
changed. If you need to tell the two apart, run one extra FirstOrDefault on the key.
Read the new token back
After a successful update the token has changed. When the provider can return rows, ask for the new value in the same statement:
var row = ctx.CreateUpdateBuilder<IOrder>()
.Set(o => o.Status, "paid")
.Set(o => o.Version, o => o.Version + 1)
.Where(o => o.Id == order.Id && o.Version == order.Version)
.Returning(o => new { o.Id, o.Version }) // UPDATE ... RETURNING / OUTPUT
.Single();
order.Version = row.Version; // caller-owned write-back
-- PostgreSQL / SQLite
update orders set status = @p0, version = version + 1 where (id = @p1 and version = @p2) returning id, version
-- SQL Server
update orders set status = @p0, version = version + 1 output inserted.id, inserted.version where (id = @p1 and version = @p2)
Returning is available on PostgreSQL (RETURNING), SQLite (RETURNING, 3.35+) and SQL Server
(OUTPUT), on the predicate form only. MySQL/MariaDB and ClickHouse reject it — there, re-select the row
(inside the same transaction, if any) to obtain the new token. There is no framework-side "refresh":
nextorm does not hold your entity, so you assign the returned value, exactly like after any other read.
PostgreSQL xmin
A PostgreSQL table has no rowversion column, but every row carries the xmin system column — the
transaction that last wrote it. Map it to an ordinary uint property (uint is the CLR type xid maps
to), and no separate version column is needed:
[SqlTable("orders")]
public interface IOrder
{
[Key] long Id { get; }
string Status { get; set; }
[DatabaseGenerated(DatabaseGeneratedOption.Computed)]
[Column("xmin")] uint Revision { get; set; } // system column, never written by the application
}
xmin is database-managed, so the property is marked computed
([DatabaseGenerated(DatabaseGeneratedOption.Computed)] or .Computed()) and must never be Set; with
computed set, .Set(entity) skips it and an entity insert leaves the column alone. The check
and the write-back are the same as for any token — compare Revision in Where and read the new value
back with Returning:
var affected = ctx.CreateUpdateBuilder<IOrder>()
.Set(o => o.Status, "paid")
.Where(o => o.Id == order.Id && o.Revision == order.Revision)
.Update(); // stale token → 0 rows
// The same check and the read-back can be combined in one statement:
var row = ctx.CreateUpdateBuilder<IOrder>()
.Set(o => o.Status, "paid")
.Where(o => o.Id == order.Id && o.Revision == order.Revision)
.Returning(o => new { o.Id, o.Revision }) // UPDATE ... RETURNING xmin
.Single();
order.Revision = row.Revision;
-- PostgreSQL
update orders set status = @p0 where (id = @p1 and xmin = @p2) returning xmin
The update is self-maintaining: xmin is the id of the transaction that last modified the row, so only a
write from a different transaction yields a new token (repeated writes in the same transaction keep it),
and there is nothing to bump in the SET clause.
Use xmin as an equality token only. Transaction ids are not monotonic, so never order by
Revision and never compare it with </> — only Revision == original in Where. A uint
comparison/write parameter binds as PostgreSQL xid, a 32-bit transaction id subject to wraparound, so
an individual value is opaque.
Token-guarded upsert
When the same row may or may not exist, a full MERGE can insert or update atomically and refuse a stale
match, on providers that render conditional branches (SQL Server, PostgreSQL 15+):
ctx.CreateMergeBuilder<IOrder>()
.Using(order) // entity, batch or server-side query
.OnKeys()
.WhenMatched((t, s) => t.Version == s.Version)
.ThenUpdate((t, s) => new { Status = s.Status })
.WhenNotMatched().ThenInsert()
.Merge();
Each update assignment names its destination and an explicit right-hand side:
.ThenUpdate((t, s) => new { Status = s.Status }).
-- SQL Server
merge into orders as target using (values (@p0, @p1, @p2)) as source (id, status, version) on target.id = source.id when matched and target.version = source.version then update set target.status = source.status when not matched then insert (id, status, version) values (source.id, source.status, source.version);
-- PostgreSQL 15+
merge into orders as target using (values (@p0, @p1, @p2)) as source (id, status, version) on target.id = source.id when matched and target.version = source.version then update set status = source.status when not matched then insert (id, status, version) values (source.id, source.status, source.version)
- A
WHEN MATCHED AND <token>branch that does not fire leaves the existing row untouched — this is skip if stale, not fail if stale. Read the affected-row count or aReturningprojection if you need to react; for a hard "throw on conflict", use the update pattern above. - The conditional branch (
WHEN MATCHED AND ...) is supported on SQL Server and PostgreSQL 15+ only; SQLite/MySQL/MariaDB support the key upsert only and reject the generalMERGE. See MERGE.
Rolling your own change tracking
There is no row-materialization hook, so a user-land tracker attaches rows as it loads them and diffs
them at SaveChanges time. For one entity type it is small — and the writes go out as one batch
instead of one round trip per row:
public sealed class OrderVersion
{
public long Id { get; set; }
public long Version { get; set; }
}
public sealed class OrderTracker
{
private readonly IDataContext _ctx;
private readonly Dictionary<long, (IOrder Entity, long Version, string Status)> _original = new();
public OrderTracker(IDataContext ctx) => _ctx = ctx;
public List<IOrder> Load()
{
var rows = _ctx.From<IOrder>().ToList();
foreach (var row in rows)
_original[row.Id] = (row, row.Version, row.Status); // snapshot
return rows;
}
public int SaveChanges()
{
var changed = _original
.Where(x => x.Value.Entity.Status != x.Value.Status) // detect changes
.ToArray();
if (changed.Length == 0)
return 0;
var batch = _ctx.CreateBatchBuilder();
foreach (var (id, (entity, version, _)) in changed)
batch.Update(_ctx.CreateUpdateBuilder<IOrder>()
.Set(o => o.Status, entity.Status)
.Set(o => o.Version, o => o.Version + 1)
.Where(o => o.Id == id && o.Version == version));
var ids = changed.Select(x => x.Key).ToArray();
var result = batch
.AddQuery(_ctx.From<IOrder>()
.Where(o => ids.Contains(o.Id))
.Select(o => new OrderVersion { Id = o.Id, Version = o.Version }))
.Execute();
var updated = result.Read<OrderVersion>().ToDictionary(r => r.Id);
foreach (var (id, (entity, version, _)) in changed)
{
// absent → deleted; unchanged token → the UPDATE matched nothing
if (!updated.TryGetValue(id, out var row) || row.Version == version)
throw new ConcurrencyException($"Order {id} was modified or deleted.");
_original[id] = (entity, row.Version, entity.Status); // new snapshot
}
return changed.Length;
}
}
- One
UPDATE ... WHERE id = @p AND version = @vper changed row plus the read-backSELECTtravel as a single batch — one round trip on one session (Executing statements in one batch). BatchBuilder.Executerequires at least one result-bearing query, so the read-back is not optional; that query is also the per-row conflict oracle.RecordsAffectedis one driver total across the whole batch, so it can only say that some row conflicted, never which one.- A row missing from the result was deleted; a row whose token still equals the original one did not
match its
WHERE. The new tokens are written back into the snapshot, so a secondSaveChangeson the same tracker starts from the current values. - The batch path needs
ISqlDialect.SupportsBatch— PostgreSQL, SQL Server, SQLite, MySQL and MariaDB. ClickHouse and the in-memory context reject batches, so keep the per-row loop (oneUpdate()per changed row) there.
Wrap SaveChanges in a transaction so a conflict rolls the whole unit of work back:
await using var tx = await ((ITransactionManager)ctx).BeginTransactionAsync();
try
{
tracker.SaveChanges();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
To generalise, diff the mapped properties instead of one field — DataContextCache.Metadata[typeof(T)].Properties
exposes the mapping (IPropertyMetadata) — or generate a typed
tracker with a source generator. nextorm deliberately stops at the primitives: an identity map, lazy
loading, SaveChanges ordering and a "changed property" model are out of scope
(Limitations and out-of-scope features).
Provider support
| Provider | Token check (WHERE + row count) |
RETURNING / OUTPUT on UPDATE |
Conditional MERGE branch |
|---|---|---|---|
| PostgreSQL | yes | RETURNING |
yes (15+) |
| SQL Server | yes | OUTPUT |
yes |
| SQLite | yes | RETURNING (3.35+) |
— (key upsert only) |
| MySQL / MariaDB | yes | — | — (key upsert only) |
| ClickHouse | predicate required; UPDATE reports no affected-row count |
— | — |
| In-memory | — (query-only) | — | — |
ClickHouse is the notable exception: ALTER TABLE ... UPDATE reports no affected-row count, so the
0 == conflict test cannot be used there. Use a token in the SET expression that only matches the
expected value, or re-read the row, to detect contention.