Coordination
Choose plain writes, functional updates, stale guards, or claims without losing concurrent work.
This page owns row coordination: acquire ownership, proceed from the granted state, and release on every exit path.
Choose the owner first
| Where the result lands | Start here |
|---|---|
| Existing application path: API, GraphQL operation, Postgres transaction, filesystem write, or Git branch merge | Coordinate existing work |
| Ablo model row, written through the Ablo client | Continue on this page |
If Ablo only decides who may start, use the first row and stop here. The claim examples below own Ablo-row writes; they are not the starter for wrapping an existing operation.
Ablo gives you several concurrency tools because not every write has the same meaning. Choose the narrowest one that matches the operation.
| Situation | Use | Result |
|---|---|---|
| Set an independent value | update({ id, data }) |
Last-write-wins when no claim applies. |
| Compute a value from the current row | update(id, current => next) |
Re-reads and retries if the row changes concurrently. |
| Write only if earlier rows are still current | reads: [record, rules] |
Rejects when an explicitly named dependency changed. |
| Read, call a model, then write | claim({ id }) |
Other participants cannot write the claimed target by default until your claim ends. |
If a model call sits between the read and the write, take a claim. A stale guard tells you the row moved after you have already paid for the turn. A claim makes the contender wait before it spends anything, and it reads the winner’s result rather than reasoning against state that has since moved.
The important boundary is explicit: a plain update does not claim a row and does not carry a stale premise. It is intentionally last-write-wins.
Claim permissions
Claims coordinate authority already granted by a session; they do not grant it.
The can API has no separate claim operation.
| Operation | Required model permission | Authority received |
|---|---|---|
| Read / list | read |
Read authorized rows |
| Acquire a row or field claim | update or delete |
Coordinate the target, with the corresponding mutation authority |
| Update through a claim | update |
Update authorized rows; the claim guards its selected target |
| Delete through a claim | delete |
Delete authorized rows |
For example, can: { conversations: ['read', 'update'] } permits updates to
all fields on authorized conversation rows, not just executionOwner.
Selecting fields: fields => fields.executionOwner narrows coordination,
not the session’s write permission. Omit delete and create when unnecessary.
Use a subject rule and server-verified membership to restrict rows. If a worker
must only change execution state, put that state in a separately authorized
model or keep mutations behind an application endpoint that validates the patch.
For a process that outlives its starting function, see the runnable account multiplayer ownership lifecycle. It handles contention, failed initialization, ownership loss and shared cleanup.
Explicit read dependencies
Pass the exact rows that produced a decision on the write:
const record = await ablo.records.read({ id: recordId });
const policy = await ablo.policies.read({ id: policyId });
if (!record || !policy) throw new Error('required input is missing');
const result = await model({ record, policy });
await ablo.records.update({
id: record.id,
data: result,
reads: [record, policy],
});
This means “apply this update only if the rows used to produce it have not changed.” The exact returned objects carry opaque evidence; no watermark is exposed. Same-row and cross-row dependencies use one shape. Incidental reads do nothing, and cloned, fabricated, or cross-client rows fail locally.
When one decision needs several Ablo reads plus application-owned memory or
retrieval results, Context assembles those values and returns
the exact authoritative rows as ctx.reads.
An undefined result cannot carry evidence. Guarded absence therefore remains
a separate low-level design; do not treat a missing read as an automatic
create-if-absent condition.
Functional updates
When the next value is a function of the current one, pass an updater rather than fixed data:
const document = await ablo.records.update(recordId, (current) => ({
revision: current.revision + 1,
content: revise(current.content),
}));
The SDK reads the current row, runs the updater, and writes only if that row is still current. If another write wins first, it re-reads and runs the updater again. This prevents the usual lost-update race without holding a claim across your application code.
Use this form only for a pure calculation. Because the updater may run more than once, do not send email, charge a card, call a model, or perform another side effect inside it.
You can bound or cancel reconciliation:
await ablo.records.update(
recordId,
(current) => ({ revision: current.revision + 1 }),
{ retries: 8, signal: request.signal },
);
If contention continues beyond the retry budget, the call rejects with
AbloContentionError and does not apply a stale calculation.
Stale guards
Use explicit returned rows when application code reads first and writes later, but does not need to reserve the row:
const report = await ablo.reports.read({ id: reportId });
if (!report) throw new Error('report missing');
await ablo.reports.update({
id: report.id,
data: { status: 'ready' },
reads: [report],
});
There is no stale-mode option on the write. If a declared row changed, Ablo
rejects the whole mutation with AbloStaleContextError. Re-read and recompute,
or use the functional update form when the computation is pure and retryable.
To make an unconditional assignment, omit reads deliberately.
See Concurrency Convention for guarded batches
and the get / read boundary.
Claims
Use a claim when work must remain exclusive across a slow gap such as an LLM call or another external service:
await using claim = await ablo.reports.claim({
id: reportId,
description: 'generating forecast',
});
const forecast = await generateForecast(claim.data.location);
await ablo.reports.update({
id: claim.data.id,
data: { forecast, status: 'ready' },
claim,
});
If another participant already holds the target, claim waits its turn and
then resolves with a fresh row in claim.data. Ordinary reads remain open. Pass
the handle as claim on the write so Ablo can verify that you still own the row
and that it has not changed since the claim was granted.
Bind claims with await using whenever possible. The claim then releases when
the scope exits, including when the external call or write throws. For runtimes
without explicit resource management, use try/finally and
await claim.release().
Handle an expired claim
When heartbeat is unset, the lease ends at its TTL. A delayed write that passes
the expired handle rejects with AbloClaimedError and code claim_lost. Do not
apply the prepared result elsewhere; clean up best-effort, then restart from a
new claim and its fresh claim.data.
import { AbloClaimedError } from '@abloatai/ablo';
const claim = await ablo.tasks.claim({ id: taskId, ttl: '2s' });
try {
await new Promise((resolve) => setTimeout(resolve, 2300));
await ablo.tasks.update({
id: claim.data.id,
data: { status: 'done' },
claim,
});
} catch (error) {
if (error instanceof AbloClaimedError && error.code === 'claim_lost') {
console.log(error.code);
} else {
throw error;
}
} finally {
try { await claim.release(); } catch { /* already expired */ }
}
Keep a claim alive
Set ttl to how quickly another worker should recover if this one stops. If the
work can take longer, set heartbeat: true so Ablo renews the claim:
const claim = await ablo.records.claim({
id: recordId,
ttl: '30s',
heartbeat: true,
});
Leave heartbeat out when the claim should expire after the TTL; do not pass
false. If a write returns claim_lost, discard that result, claim the row
again, and restart from the new claim.data.
One identity per participant
Explicit claims coordinate authenticated participants. Two clients using the same credential represent the same participant and do not exclude one another. Mint a distinct scoped session for each independently coordinated agent:
import Sessions from '@abloatai/ablo/sessions';
const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
const session = await sessions.create({
agent: { id: `forecast-agent-${workerId}` },
can: { records: ['read', 'update'] },
});
const agent = Ablo({ schema, session });
Functional updates do not require distinct participant identities because they protect the row version rather than a participant-held claim.
Skip instead of wait
For deduplicated jobs, skip work when another participant already owns it:
const claim = await ablo.records.claim({
id: recordId,
contention: { mode: 'skip' },
});
if (!claim) return;
try {
await processTask(claim.data);
} finally {
await claim.release();
}
To wait with limits, keep the contention settings together:
const claim = await ablo.records.claim({
id: recordId,
contention: {
mode: 'wait',
maxDepth: 3,
timeoutMs: 30_000,
signal: request.signal,
},
});
Claim part of a row
Narrow a claim when independent fields may be edited concurrently:
await using claim = await ablo.records.claim({
id: recordId,
fields: (record) => record.status,
});
Claims on disjoint fields can coexist. A whole-row claim conflicts with every field claim on that row.
Relations do not create hierarchical claims
A parent: true relation controls ownership, access inheritance, and sync
routing. It does not make claims conflict across related rows. For example, a
claim on one document row and a claim on one of its page rows have different
model-and-ID targets and can coexist.
Choose the row that represents the actual unit of exclusive work. Page rows allow different pages to process concurrently. If a whole-document operation must exclude every page operation, enumerate the authoritative page manifest, acquire page claims in one stable order, and guard the manifest against change. Do not infer that exclusion from the schema relation alone.
The target options are:
| Option | Purpose |
|---|---|
options.field |
Claim one field by its wire-level name. Prefer the typed selector in application code. |
options.fields |
Claim one or more schema fields with a typed selector. |
options.meta |
Attach application-defined metadata observers may display. |
Observe coordination
Read current claim state without blocking:
const holder = ablo.records.claim.state({ id: recordId });
const queue = ablo.records.claim.queue({ id: recordId });
Use this state for presence and progress UI. Do not use an observed null as a
substitute for claiming: another participant can acquire the row immediately
after your read.
The main methods are:
| Method | Purpose |
|---|---|
claim({ id, ...options }) |
Read and claim an existing model row; the handle includes fresh row data. |
claim.state({ id }) |
Read the current holder without blocking. |
claim.queue({ id }) |
Read the current wait order. |
claim.release({ id }) |
Release early when you do not hold a handle. |
This page owns row-backed claims: model.claim({ id }) reads and claims an Ablo
model row, and the handle carries fresh data. Identifier-only claims before an
existing authoritative service have a different persistence boundary; use the
coordinate-existing-work guide for that form.
Choosing correctly
- Prefer a plain update for values that do not depend on an earlier read.
- Prefer a functional update for a quick, pure read-modify-write calculation.
- Prefer a stale guard when your caller should decide how to reconcile.
- Prefer a claim when you must hold exclusivity across slow or side-effecting work.
- Prefer idempotency for safe retries; it solves a different problem from concurrency.
For exact error codes and recovery guidance, see Errors. For what a confirmed write promises, see Guarantees.