Skip to main content

Explanation: Why Optimistic Concurrency Needs a Reviewed Snapshot

Two people open the same invoice. One raises the amount; the other fixes a typo in the customer name. Both press Save.

With last-writer-wins, the second save silently discards the first. Nobody gets an error, nobody notices, and the database ends up with a state that never existed in anyone's head — the corrected name sitting alongside the old amount. This is a lost update, and it is one of the most expensive classes of bug because it produces no signal at all.

This chapter explains why Framework M solves it the way it does: with a reviewed-snapshot token rather than row locks, and with participation that writers must explicitly opt into.

To apply it, see How to Use Optimistic Concurrency Control. For the normative contract, see RFC-0013.


Why Not Pessimistic Locking?​

The obvious fix for lost updates is a lock: when a user opens a document for editing, SELECT ... FOR UPDATE and hold the row until they save.

That model assumes edits are short. Web applications break the assumption. A user opens an invoice, gets a phone call, and saves forty minutes later. Holding a database row lock across a human think-time is not viable — it exhausts connections, blocks unrelated writers, and in a stateless, horizontally-scaled deployment the next request may not even land on the same process.

Optimistic concurrency inverts the trade-off. Writers do not reserve anything up front. Each write carries proof of which version it was based on, and the database rejects the write if reality has moved on. Conflicts are rare, so paying the cost only when they occur is far cheaper than holding locks always.

SELECT ... FOR UPDATE is still used elsewhere — for short, server-side critical sections — but it is precisely the wrong tool for a lock that a human holds open for minutes.


The Token Is a Snapshot, Not a Counter​

The naive implementation of OCC is to count writes: increment a _version on every update and reject an update whose expected number is behind.

That is necessary but not sufficient. The property that actually matters is that the token identifies the exact state the writer reviewed. Hence reviewed snapshot: _version is not merely "how many times this row changed", it is the handle by which a client says "I am editing the document as it looked at version 3".

This is why _version is surfaced on every read path — get(), list_entities(), and the HTTP payloads — and carried back on the write. A token the client cannot see is a token the client cannot review.

It is also why a write that supplies no token is rejected rather than defaulted. Defaulting to "current version" would silently restore last-writer-wins for every caller who forgot, and the failure would be invisible until data was already lost. Framework M refuses the write with 428 Precondition Required instead: a loud, recoverable error beats a quiet, permanent one.


Compare-and-Swap Is the Whole Safety Argument​

The guarantee rests on a single SQL predicate:

UPDATE invoice SET ..., _version = _version + 1
WHERE id = :id AND _version = :expected;

The database evaluates the WHERE clause and writes the row as one atomic operation. There is no window between "check the version" and "write the row" for another writer to slip into — which is exactly the window a read-then-write in application code would open.

Consequently the outcome is decided by row count alone:

  • one row affected → this writer held the current snapshot; the write is linearised and the token advances
  • zero rows affected → either the token is stale (409 Conflict) or the row is gone (VersionConflictError)

This is why Framework M performs the check inside the UPDATE rather than issuing a separate SELECT first. Any implementation that reads the version in one statement and writes in another has not actually solved the race — it has only made it narrower.


Why bulk_set_value Opts Out​

bulk_set_value deliberately bypasses OCC, and this is a design decision rather than a gap.

Bulk writes are used for administrative and system-wide corrections — "set the archive flag on every invoice older than a year". They are not a human reviewing one document; they are a program broadcasting one value across many rows. There is no per-row snapshot for the caller to have reviewed, so there is no meaningful token to compare. Forcing a token there would either require the caller to fan out into thousands of single-row reads, or invent a token that pretends to a review which never happened.

The contract therefore states participation explicitly: eligible writers are the single-entity paths, and bulk operations are named as exempt. The danger this creates is a caller assuming a bulk operation is safe where a reviewed edit was required; naming the exception in the documentation, the RFC, and the how-to is how that assumption gets caught.

A writer that needs a lost-update-safe bulk edit should express it as a sequence of single-entity save(..., version=n) calls.


What This Design Does Not Do​

Worth being precise about the boundary, because OCC is often over-claimed:

  • It does not merge. A stale write is rejected, not resolved. Merging conflicting intent is a product decision the framework cannot make for you.
  • It does not prevent all lost updates. Any writer that bypasses the token — bulk_set_value, raw SQL outside the repository, a direct database client — is outside the guarantee. The contract binds the writers that participate.
  • It does not substitute for idempotency. Retrying a submitted request and editing a stale document are different problems; OCC addresses the second. See idempotency for the first.
  • It does not require the token to be globally ordered. _version is monotonic per row, which is all compare-and-swap needs.

The Trade-off, Stated Plainly​

OCC exchanges a rare, loud failure for the total absence of a silent one. Writers occasionally see a 409 and must re-read; in return, no update is ever quietly overwritten by a concurrent one.

For a framework whose stated posture is that correctness should be an infrastructure concern rather than per-controller boilerplate, that is the right side of the trade — and it is why the token is opt-in per DocType rather than globally forced, so applications with genuinely single-writer tables are not made to pay for a guarantee they do not need.