Concurrency & conflicts
Two requests that read the same document and write it back shouldn't silently clobber each other. Forze uses optimistic concurrency keyed on a revision number, with a history-based merge that lets non-overlapping edits through and rejects genuine collisions.
The revision¶
Every Document carries a rev — an integer
that starts at 1, is frozen on the model, and is bumped by the store on each
write. You read at a revision, then write back the one you read:
order = await ctx.document.query(order_spec).get(order_id)
await ctx.document.command(order_spec).update(
order.id, order.rev, OrderUpdate(status="paid"),
)
The rev you pass is the revision you expect to be current.
What happens on a concurrent write¶
The store compares your expected rev to the stored one:
- Match — apply the change and bump
rev. - Differ — load the history snapshot at your revision and run a three-way
merge. Edits that touch disjoint fields merge through (last-writer-wins per
field); edits that touch the same fields raise
exc.conflict(historical_consistency_violation). A field your write resends with the value it had at your revision touches nothing and is not written, so a concurrent change to it is kept. - A future revision raises
exc.precondition(revision_mismatch); a revision whose history snapshot is missing raisesexc.precondition(history_not_found_retry). Neither is retried for you: re-read the document and resubmit with its currentrev.
So a stale write doesn't blindly overwrite: it either merges cleanly or surfaces a conflict for the caller to resolve.
History powers the merge
The three-way merge needs document history enabled. Without it, any
revision mismatch is a precondition error rather than a merge attempt.
Refusing any change since you read¶
The merge is right for ordinary edits and wrong for an action that approves one
version: confirming an order as reviewed, signing off a document, accepting a
quote. Any change since the reviewer read it should send them back. Pass
merge=False to refuse every revision but the stored one, history or not:
await ctx.document.command(order_spec).update(
order.id, order.rev, OrderUpdate(status="confirmed"), merge=False,
)
To record the sign-off without changing any field, touch it at the revision you read:
await ctx.document.command(order_spec).touch(order.id, rev=order.rev)
A stale rev raises exc.precondition (revision_mismatch), even for an update
that changes nothing. The check is atomic: the write matches the revision you
passed, so a writer committing between the store's read and its write makes it
fail too. merge=False with no revision to check is refused as configuration.
An update that changes nothing writes nothing. Postgres checks its revision under a row lock, and Firestore in its transaction's read; both keep the row until commit. Mongo checks the revision it read and keeps nothing, since it locks a document only by writing it. Inside a Mongo transaction that read is the transaction's snapshot, so a writer can still commit before yours. Touch at the revision when the check must last until commit there.
update_many(..., merge=False) applies the check to every row, and
touch_many(pks, revs=...) takes one revision per id. Every row is checked
before any is written. A row a concurrent writer moves after that check is
refused at its own write, and only a transaction undoes the rows written before
it. Postgres opens one of its own; Mongo, Firestore and the in-memory store do
not. Run a strict bulk write in a transaction when it must be all or nothing.
The in-memory store keeps no history, so it is always this strict.
Related write helpers¶
Document.validate_historical_consistency(old, patch)is the check behind the merge —Truewhen the concurrent change and yours touch disjoint fields.Document.historical_update(old, patch)is what the merge writes: the part ofpatchthat changesold, orNoneon a conflict.command.touch(pk)bumpslast_update_atandrevwithout other changes;touch(pk, rev=rev)bumps only a document still atrev.
Retrying a conflict safely is only half the story — a retried write must also not apply twice. That guarantee is Idempotency.