How RustGrid uses row_version-based ETags for deterministic concurrency
Tickets touch many actors (humans and bots). We need edits that either unquestionably apply to the version you saw or fail loudly. Optimistic concurrency with row_version ETags gives us that determinism without heavyweight locks.
Core idea
Every mutable row (tickets, comments, labels, …) carries a monotonically increasing row_version (64-bit int).
HTTP responses include an ETag derived from that row_version (example: W/"v:42").
Mutations must supply If-Match: W/"v:42" (HTTP) or expected_row_version = 42 (gRPC).
If the stored version does not match the expected value, we return 412 Precondition Failed (HTTP) or FAILED_PRECONDITION (gRPC).
Read path
Fetch the resource with the version:
SELECT ..., row_version
FROM tickets
WHERE id = $1;
Send the ETag:
ETag: W/"v:{row_version}"
Optional: allow cache validators with If-None-Match on GETs.
Write path (PATCH/PUT)
Client sends:
If-Match: W/"v:42"
Content-Type: application/json
Server executes a guarded update:
UPDATE tickets
SET title = $2,
description = $3,
row_version = row_version + 1,
updated_at = now()
WHERE id = $1 AND row_version = $4
RETURNING row_version;
If UPDATE ... RETURNING yields 0 rows, the version did not match, so we return 412.
On success, return the new ETag W/"v:43".
Determinism properties
- No lost update. You cannot clobber an edit you did not read.
- Causal clarity. Every mutation cites the exact prior state it was based on.
- Idempotency synergy. Pair with request idempotency to make retries safe even under races.
HTTP details we enforce
- Require
If-Matchon all non-idempotent writes (PATCH/PUT/DELETE). Missing header returns428 Precondition Required. - Use weak ETags (
W/) to keep the format flexible while still tying to row_version. - Prefer
412over409. It tells clients their precondition failed, not that the resource is “in conflict” abstractly.
gRPC mirror
Protos carry:
message UpdateTicketRequest {
string id = 1;
uint64 expected_row_version = 2; // required
TicketPatch patch = 3;
}
Server returns new_row_version or FAILED_PRECONDITION with a structured error
(actual_row_version, expected_row_version) for fast client merges.
Merging strategy (client)
On 412 or FAILED_PRECONDITION:
- Fetch the fresh entity.
- Attempt semantic merge (field-level). If clean, resubmit with the new ETag/version.
- If not clean, surface a “review diff” UX (or keep it headless and emit a conflict ticket).
Pitfalls and how RustGrid avoids them
- Clock-based validators. We never use timestamps (non-monotonic on replicas). Use the integer row_version.
- Silent overwrites via “last write wins”. Disallowed. Every write must prove lineage (
If-Match). - Batch writes. Each row checks its own version; all guarded updates occur inside a single transaction.
- Partial PATCH ambiguity. We apply patches server-side and bump row_version exactly once per successful mutation.
Nice extras
SELECT ... FOR UPDATE SKIP LOCKEDis not needed for ordinary edits; optimistic concurrency scales better for bursty workloads.- Audit and NOTIFY. On version bump, we append an audit row and notify with
{id, old_version, new_version}for realtime listeners. - Caching. Intermediaries can cache GETs and rely on
If-None-Match; writes naturally invalidate via new ETags.