Skip to content

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.

An update compares your expected revision to the stored one: a match applies and bumps rev, a difference runs a three-way merge that merges disjoint fields or raises a conflict on overlap, and a future or unknown revision raises a precondition error An update compares your expected revision to the stored one: a match applies and bumps rev, a difference runs a three-way merge that merges disjoint fields or raises a conflict on overlap, and a future or unknown revision raises a precondition error

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 raises exc.precondition (history_not_found_retry). Neither is retried for you: re-read the document and resubmit with its current rev.

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.

  • Document.validate_historical_consistency(old, patch) is the check behind the merge — True when the concurrent change and yours touch disjoint fields.
  • Document.historical_update(old, patch) is what the merge writes: the part of patch that changes old, or None on a conflict.
  • command.touch(pk) bumps last_update_at and rev without other changes; touch(pk, rev=rev) bumps only a document still at rev.

Retrying a conflict safely is only half the story — a retried write must also not apply twice. That guarantee is Idempotency.