How to Use Optimistic Concurrency Control
Optimistic Concurrency Control (OCC) prevents one user's save from silently
overwriting another user's changes. Instead of locking a row for the duration of
an edit, Framework M gives every update a reviewed-snapshot token (_version)
and rejects a save whose token no longer matches the stored value.
This guide shows how to enable OCC on a DocType, how to send the token from a client, and how to handle the two failure responses.
For the underlying contract and migration recipe, see Optimistic Concurrency Control (OCC) in Architecture Concepts.
Enable OCC on a DocType
OCC is opt-in per DocType. Add a single line to Meta:
from typing import ClassVar
from framework_m_core.domain.base_doctype import BaseDocType
class Invoice(BaseDocType):
customer: str
amount: float
class Meta:
concurrency: ClassVar[str] = "optimistic"
On startup, SchemaMapper adds a _version integer column to the table
(NOT NULL DEFAULT 0). DocTypes that do not opt in are unchanged — they retain
last-writer-wins behaviour.
Existing tables. Adding
_versionto a table that already has rows is an app-owned migration. See the backfill recipe under Optimistic Concurrency Control (OCC) in Architecture Concepts.
Read the Current Version
Every read surfaces _version so the client knows which snapshot it is editing.
Over HTTP:
curl http://localhost:8000/api/v1/Invoice/8f14e45f-...
{
"id": "8f14e45f-...",
"name": "INV-0001",
"customer": "Acme",
"amount": 100.0,
"_version": 3
}
From repository code:
invoice = await repo.get(session, invoice_id)
current_version = invoice.__dict__["_version"]
Send the Token With the Update
Include the _version you read in the update request body. The router extracts
it, strips it from the write payload, and passes it to the repository as the
expected version.
curl -X PUT http://localhost:8000/api/v1/Invoice/8f14e45f-... \
-H 'Content-Type: application/json' \
-d '{"customer": "Acme Corp", "amount": 120.0, "_version": 3}'
The repository issues a compare-and-swap:
UPDATE invoice SET ..., _version = 4
WHERE id = :id AND _version = 3;
If exactly one row is affected, the write succeeded and the response carries the
new _version:
{ "id": "8f14e45f-...", "customer": "Acme Corp", "amount": 120.0, "_version": 4 }
From repository code, pass the token explicitly:
invoice = await repo.get(session, invoice_id)
invoice.amount = 120.0
saved = await repo.save(session, invoice, version=invoice.__dict__["_version"])
await uow.commit()
Handle a Stale Write (409 Conflict)
If another writer advanced _version after you read it, the WHERE clause
matches no rows and Framework M raises StaleEntityError, returned as HTTP 409
Conflict:
{
"error": "StaleEntity",
"message": "Stale entity: Invoice 'INV-0001' expected _version 3, stored is 4. Re-read and retry.",
"doctype": "Invoice",
"doc_id": "INV-0001",
"expected_version": 3,
"current_version": 4
}
The resource is not modified. To recover, re-read the document, decide how to
merge your change with the current state, and retry with the fresh _version:
try:
invoice = await repo.get(session, invoice_id)
invoice.amount = 120.0
await repo.save(session, invoice, version=invoice.__dict__["_version"])
await uow.commit()
except StaleEntityError as exc:
# exc.expected_version, exc.current_version are available
await session.rollback()
raise # or re-read and retry
Handle a Missing Token (428 Precondition Required)
For an OCC-enabled DocType, an update without a _version is rejected before
any write is attempted. Framework M raises MissingVersionError, returned as
HTTP 428 Precondition Required:
{
"error": "MissingVersion",
"message": "Missing version token: updating Invoice 'INV-0001' with Meta.concurrency='optimistic' requires an expected _version.",
"doctype": "Invoice",
"doc_id": "INV-0001"
}
This is deliberate: it forces the client to read the current state rather than
silently performing a last-writer-wins update. Fix it by including _version
in the request body (see Send the Token With the Update).
Know Which Writes Bypass OCC
Participation is explicit. The following table shows which write paths enforce the token:
| Write path | Enforces _version |
|---|---|
Generated CRUD PUT /api/v1/{doctype}/{id} | ✅ yes |
GenericRepository.save(..., version=n) | ✅ yes |
Controller / RPC writes via save() | ✅ yes |
bulk_set_value | ❌ no (intentional) |
bulk_set_value bypasses OCC by design — it is a bulk correction primitive, not
a user-reviewed edit. If you need reviewed bulk edits, perform them as a series
of single-entity save() calls with tokens. This exception is documented in
RFC-0013.
Related
- Architecture Concepts: Optimistic Concurrency Control — the contract, schema shape, and migration recipe
- RFC-0013: Database Operations and Concurrency — the normative specification
- Explain: Why Optimistic Concurrency Needs a Reviewed Snapshot — why the contract is shaped this way