Optimistic concurrency
Mark a column with [InquiryConcurrencyToken] and every UPDATE / DELETE will add a WHERE RowVersion = @RowVersion check. If another transaction has bumped the version since you read the row, the affected-row count is 0 and the operation returns false (or null for an update returning Task<TEntity?>). Set InquiryOptions.ThrowOnConcurrencyConflict = true to throw InquiryConcurrencyException instead.
You write
using Inquiry.Entities;
[InquiryTable("Orders")]
public sealed class Order
{
[InquiryKey] public int OrderID { get; set; }
[InquiryColumn] public string Status { get; set; } = "";
[InquiryConcurrencyToken]
public int RowVersion { get; set; }
}
public partial class OrderStore : InquiryStore<Order>
{
[InquiryUpdate]
public partial Task<bool> UpdateAsync(Order order, CancellationToken ct = default);
[InquiryUpdate]
public partial Task<Order?> UpdateReturningAsync(Order order, CancellationToken ct = default);
[InquirySelectOneByKey]
public partial Task<Order?> SelectByKeyAsync(int orderID, CancellationToken ct = default);
}
The generator emits
The row-version column is included in the WHERE, and the new value is the old one plus one:
private const string _sqlUpdate =
"UPDATE \"Orders\" SET \"Status\" = @Status, \"RowVersion\" = \"RowVersion\" + 1 " +
"WHERE \"OrderID\" = @OrderID AND \"RowVersion\" = @RowVersion";
If you call UpdateAsync with a stale order.RowVersion, zero rows match and the call returns false. Compare-and-swap, baked at compile time.
Refresh before the next edit
The bool-returning update binds the token on the input and does not refresh that object. With no competing writer, the first update can succeed, but a second update of the same instance still binds the old token. It returns false (or throws when configured), because the database token has advanced.
Use update-returning when the caller will keep editing:
var original = await store.SelectByKeyAsync(orderID, ct)
?? throw new InvalidOperationException("Order not found.");
original.Status = "Packed";
var saved = await store.UpdateReturningAsync(original, ct);
if (saved is null)
return; // Handle the conflict or missing row before continuing.
saved.Status = "Shipped";
var savedAgain = await store.UpdateReturningAsync(saved, ct);
if (savedAgain is null)
return; // The second write can also conflict.
// Keep savedAgain for the next edit; original and saved retain their old tokens.
These are two distinct successful writes when no conflict occurs. Each uses the entity returned by the preceding read/write. If using the bool form, reread after success before another edit. Do not increment tokens in application code: numeric and database-generated token strategies differ. When ThrowOnConcurrencyConflict is enabled, catch InquiryConcurrencyException instead of relying only on the null branches. See Errors and partial outcomes for failure timing.
Provider-specific notes
- SQL Server can use native
ROWVERSION— declare a non-nullablebyte[]with[InquiryConcurrencyToken(DatabaseGenerated = true)]. Generated schema DDL emitsROWVERSION NOT NULL; inserts and bulk inserts omit the column; updates match the original eight-byte token. The returning form materializes the database-generated replacement; the bool form does not refresh the input.SqlType, length/precision/scale, defaults, computed expressions, converters, keys, and nullable token shapes are rejected at build time (INQ068) because they would weaken that contract. - Other providers reject
DatabaseGenerated = true; use an ORM-managed numeric token there. - PostgreSQL typically uses an integer column with explicit increment (as above), or
xminfor system-managed. - MySQL uses an integer column with explicit increment.
- Oracle uses an integer or
TIMESTAMPcolumn with explicit increment.
See also
- CRUD — baseline update/delete.
- Soft delete — composes with concurrency.