Skip to main content

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 _version to 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 pathEnforces _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.