Concurrency Convention
What Ablo checks when a guarded write depends on earlier state.
Ablo never infers whether a write depends on earlier state. You decide, in two
places. The model’s conflict setting in the schema says what each kind of
participant does when it hits a conflict, and it is the policy for that model.
A per-write onStale states the disposition for one write. Ablo enforces what
you declared and nothing else.
Unguarded writes
A plain write has no stale premise:
await ablo.records.update({ id, data: { status: 'done' } });
If no active claim conflicts with it, the write is last-write-wins. That is a
choice rather than a fallback: use it for independent assignments where the
latest value should win. When a model’s writes are never independent, say so
once in its conflict setting instead of at every call site.
Guarded writes
Pass the exact returned rows when a write is based on values previously read:
const record = await ablo.records.get({ id });
const policy = await ablo.policies.get({ id: policyId });
if (!record || !policy) throw new Error('required input is missing');
await ablo.records.update({
id: record.id,
data: { status: 'done' },
reads: [record, policy],
});
Ablo privately resolves each exact object to its model, id, and read watermark, then compares those premises with current state when the write is accepted. Clones, fabrications, and rows returned by another client are rejected locally.
| Disposition | If the premise is stale |
|---|---|
reject |
Reject the write with AbloStaleContextError. |
notify |
Keep the current row, return a StaleNotification, and let the caller reconcile. |
overwrite |
Apply the new value without enforcing the stale premise. |
notify is useful when an agent or human can merge the new information.
reject is useful when the caller should restart from fresh state. Use
overwrite only when the newer assignment should unconditionally win.
Functional updates
For a pure read-modify-write calculation, use the functional update form:
await ablo.counters.update(counterId, (current) => ({
value: current.value + 1,
}));
It performs the read, guarded write, and bounded reconciliation loop for you. See Coordination.
Claims
A claim protects a target across a longer interval. By default, other participants cannot write the claimed target, while contenders that claim it wait their turn. Reads remain open. A model’s explicit conflict policy can choose a different disposition for a participant kind.
Claims and stale guards protect different things:
- A claim excludes other participants while it is held.
- A stale guard proves that the state a write depended on has not changed.
- A write made under a claim is still rejected if its own claimed snapshot has become stale.
See Coordination for the API.
Cross-row and batch premises
Model writes and lower-level commits can declare rows they read even when the write targets somewhere else. This protects decisions such as “update the record only if the deal I inspected has not changed.” A stale batch premise applies to the whole batch so atomicity is preserved.
Use the high-level model methods unless you are building a custom runtime. When you do use batch premises, declare only the rows or groups that materially influenced the decision; overly broad premises create unnecessary contention.
Notifications
A StaleNotification identifies the stale premise and provides the current
state needed to reconcile. The original write has not been applied.
A typical loop is:
- Inspect the current value in the notification.
- Recompute the intended change.
- Submit a new guarded write with a fresh premise.
Give this loop a retry budget. Continuous contention should surface to the caller rather than retry forever.
Boundaries
Concurrency control does not replace:
- database constraints and transactions for application invariants;
- authorization for deciding who may read or write;
- idempotency for safely replaying the same request;
- claims for exclusivity across slow, side-effecting work.
The rule is simple: the model’s conflict setting is the policy, and each write
declares what it read. Plain writes are last-write-wins because declaring
nothing is itself a decision, so make it deliberately.