Optimistic Locking
A row that two callers update concurrently can lose one write — both readbalance = 100, both compute balance + 10, both write balance = 110,
and the bank is short ten dollars. Optimistic locking detects this at the
database boundary and rejects the second write with 412 Precondition Failed, leaving the row untouched.
Schema attribute
Add@version to one Int field per model:
- exactly one
@versionfield per model - type must be required
Int - cannot also be the primary key
i64 at runtime.
Update flow
Every successful update emitsversion = version + 1 in the same SQL
statement that writes the new state. The generated REST router:
- returns
ETag: "<version>"onGET /resource/<id>and on the response of any successful mutation - requires
If-Match: "<version>"onPATCH /resource/<id>andDELETE /resource/<id> - responds
412 Precondition FailedonPATCHorDELETEwhenIf-Matchis missing - responds
412 Precondition FailedonPATCHorDELETEwhen the supplied version is stale - distinguishes “stale version” from “row not found” by probing the read policy after the update or delete fails
DELETE /resource/<id> enforces If-Match the same way PATCH does: the
generated delete handler requires the header, rejects a missing or stale
version with 412 Precondition Failed, and leaves the row untouched on
failure. This closed a prior asymmetry where a concurrent delete could
remove a row out from under a caller holding a stale version — landed via
#538, which added
delete_if_match_decl/delete_if_match_apply to
crates/cratestack-macros/src/axum/model/prep/etag.rs, deliberately
mirroring update_if_match_decl/update_if_match_apply. The gate is
purely descriptor.version_column.is_some(), independent of
@@soft_delete — a soft-deleted row is still a real mutation and gets the
same protection a hard delete does.
If-Match, or sending a stale version, on either request returns
412 Precondition Failed and leaves the row untouched.
Programmatic use
Internal Rust callers thread the expected version throughif_match:
delete takes the same builder:
if_match on a versioned model returns CratestackError::PreconditionFailed
before any SQL runs, for both update and delete. Banks treat the version
check as a contract, not a hint — there is no “force update” or “force
delete” escape hatch on the generated path.
Input filtering
@version is excluded from both Create<Model>Input and Update<Model>Input:
- clients cannot seed the initial version on create — the framework sets it to
0 - clients cannot replay or skip a version through a PATCH body — the column is bumped server-side
Interaction with @@soft_delete
Soft-delete tombstones bump the version column too, so callers that re-read
after a delete observe a fresh ETag. Live updates against a tombstoned
row match zero rows and return the same “precondition failed” shape.
When to use it
Add@version to:
- balances, ledger entries, transfers, holds, reservations
- any row a workflow reads, decides on, then writes back
- any row a webhook can update concurrently with a user-facing flow
- append-only event tables
- rows updated by exactly one writer (lookup tables, configuration)
- denormalised counters that already use SQL-level atomic increments
Read Next
- Idempotency — protects against duplicate execution; complements lost-update protection
- Transaction isolation — closes the read-write skew window inside the same transaction
- Field attributes — full list of supported field attributes