Concurrency
Two callers that update the same row are last-writer-wins unless the entity declares a concurrency strategy. With one, every row carries a token, a read hands it to the client, and a write may hand it back: the update or delete applies only if the row still carries that token. Nothing is locked while the client thinks — the check happens at write time.
The concurrency: block
Section titled “The concurrency: block”entities: - name: sales_order concurrency: strategy: version # none (default) | version | timestamp column: _rowversion # version only; this is the default name required: false # true: an update or delete without a token is refused| Key | Default | Meaning |
|---|---|---|
strategy | none | version keeps a counter column the service increments on every write. timestamp uses the updatedon column every entity inherits from the common trait. |
column | _rowversion | The counter’s column name. version only; timestamp always reads updatedon. |
required | false | true makes the token mandatory: an update or delete that carries none is refused with 428. |
versionadds a read-only, not-null integer column (default1) to the entity. It is 1 on create and moves on every update, soft delete and restore. A client cannot write it — a payload that carries it is a 422READONLYvalidation failure.timestampneeds no migration, but two writes in the same microsecond are indistinguishable and a clock that goes backwards breaks it. Use it for entities that cannot take a new column.- Neither strategy is available on an entity with a versioned
history:strategy (type2,type6): every version row already carries_version_num, and the block fails the load.
The token is the column’s value rendered as a string: the counter in decimal, the timestamp as RFC 3339 with nanoseconds, UTC.
The token on the wire
Section titled “The token on the wire”| Direction | Where |
|---|---|
| Read | Every row carries the column in data, so a list gives one token per row. Single-row responses — get by id, and the row a create or update returns — also carry it as ETag: "<token>". |
| Write | If-Match: "<token>" on PATCH /data/rest/{entity}/id/{id} and DELETE /data/rest/{entity}/id/{id}. A W/ prefix is tolerated; If-Match: * means “must exist”, which a bare write already means, and is ignored. |
| Write, in the body | "expect": {"version": 7} or "expect": {"updatedon": "2026-09-09T10:00:00.123456Z"} on the update body, and on each update / delete row of the bulk envelope. expect never reaches the row. |
Both forms may be present when they agree.
# read: the token comes back as ETagcurl -si https://<host>/data/rest/sales_order/id/01JC8… -H 'Authorization: Bearer <token>' …# HTTP/1.1 200 OK# ETag: "7"
# write: hand it backcurl -sX PATCH https://<host>/data/rest/sales_order/id/01JC8… \ -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \ -H 'If-Match: "7"' \ -d '{"status":"shipped"}'What comes back
Section titled “What comes back”| Status | code | details.code | When |
|---|---|---|---|
| 200 | — | — | The row still carried the token; the write applied and the response ETag is the new token |
| 412 | update_failed / delete_failed | version_mismatch | The row exists with another token. details.expected is what the caller sent, details.current the row’s token now, so a client can show the newer state or retry |
| 404 | update_failed / delete_failed | — | The precondition named a row that does not exist — or one the caller may not see: the check re-reads as the caller, so a row hidden by a row-level rule is missing, never revealed |
| 428 | update_failed / delete_failed | precondition_required | The entity says required: true and the write carried no token |
| 400 | update_failed / delete_failed | precondition_unsupported | A token on an entity with no strategy |
| 400 | update_failed / delete_failed | precondition_invalid | A token of the wrong kind — a timestamp on a version entity, or the reverse |
| 400 | bad_precondition | — | If-Match or expect does not parse as a token |
| 400 | precondition_conflict | — | If-Match and expect are both present and disagree |
A bulk envelope with a mismatched operation fails as a batch, as every bulk failure does.
Optimistic concurrency needs no locks and works on every backend. Two other kinds of lock exist for callers that need to hold something:
| Kind | What it does | Where it is available |
|---|---|---|
| Row locks | A query holds the rows it returns for the rest of the transaction (FOR UPDATE / FOR SHARE, waiting, NOWAIT or SKIP LOCKED) — the pattern for claiming the oldest queued item without racing another worker | PostgreSQL. SQLite inside a write transaction, where the whole database is held. Refused on DuckDB and ClickHouse |
| Named locks | One critical section per key, scoped to the tenant (stock:42), held until the transaction ends | PostgreSQL (advisory locks). SQLite inside a write transaction. Refused on DuckDB and ClickHouse |
Both live only inside a transaction. On the HTTP surface that means a
declared action with
transaction: true (and lock: "<name>" for a named lock); plain GET and QUERY never lock,
and a lock key in a query sent to them is refused. A wait that runs past the transaction’s
lock timeout, or a deadlock the database broke, is reported with details.retryable: true.
details.code | Status | When |
|---|---|---|
lock_requires_transaction | 400 | A row or named lock outside a transaction |
lock_unsupported | 400 | The backend has no such lock; details.backend names it |
lock_timeout | 409 | The wait ran out, or NOWAIT met a held row; details.retryable: true |
deadlock | 409 | The database broke a deadlock; details.retryable: true |
See also
Section titled “See also”- Relations — what a delete does to child rows
- REST reference — headers and status tables per route
- Error codes — every code and its
details