@abloatai/ablo 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +109 -2
- package/README.md +7 -12
- package/docs/agent-messaging.md +2 -2
- package/docs/api.md +2 -2
- package/docs/concurrency-convention.md +82 -268
- package/docs/coordination.md +175 -906
- package/docs/data-sources.md +32 -1
- package/docs/guarantees.md +25 -21
- package/docs/how-it-works.md +10 -40
- package/docs/identity.md +48 -0
- package/docs/index.md +2 -2
- package/docs/migration.md +46 -443
- package/docs/operating-on-your-database.md +29 -36
- package/examples/README.md +24 -0
- package/examples/agent-turn.ts +37 -0
- package/examples/data-source/ablo-driver.ts +4 -4
- package/examples/data-source/customer-server.ts +9 -4
- package/examples/data-source/schema.ts +1 -1
- package/examples/expensive-agent-turn.ts +76 -0
- package/examples/quickstart.ts +9 -7
- package/package.json +7 -4
- package/docs/internal/README.md +0 -18
- package/docs/internal/agent-fleet-coordination-design.md +0 -171
- package/docs/internal/commit-identifiers.md +0 -91
- package/docs/internal/concurrency-open-decisions.md +0 -37
- package/docs/internal/data-source-reverse-channel.md +0 -147
- package/docs/internal/per-field-conflict-detection.md +0 -165
- package/docs/internal/postgres-replication.md +0 -64
- package/docs/internal/serializable-schema.md +0 -119
- package/docs/internal/structure.md +0 -36
package/docs/coordination.md
CHANGED
|
@@ -1,998 +1,267 @@
|
|
|
1
|
-
# Coordination
|
|
2
|
-
|
|
3
|
-
> Claim mechanics and the API behind them: who holds a row, who is waiting, and how the line moves.
|
|
4
|
-
|
|
5
|
-
> **Governing convention:** [`concurrency-convention.md`](./concurrency-convention.md)
|
|
6
|
-
> — the non-coercion principle (surface state, let the actor decide), the full
|
|
7
|
-
> `onStale` taxonomy, the batch premise (`reads[]`), and the boundaries. Read
|
|
8
|
-
> that for the *why* and the contract; this reference is the *how* (claim
|
|
9
|
-
> mechanics + API).
|
|
10
|
-
|
|
11
|
-
Coordinate long-running work on a row so agents — and the people watching them —
|
|
12
|
-
don't clobber each other. Most writes need none of this — a plain `ablo.<model>.update({ id, data })`
|
|
13
|
-
is **last-write-wins** by default.
|
|
14
|
-
|
|
15
|
-
> **Read-modify-write under contention? Use the functional update — it owns all
|
|
16
|
-
> of this for you.** When the new value is computed from the current one (the
|
|
17
|
-
> shape that races), pass a function instead of data:
|
|
18
|
-
>
|
|
19
|
-
> ```ts
|
|
20
|
-
> await ablo.documents.update(id, (current) => ({ content: revise(current.content) }));
|
|
21
|
-
> ```
|
|
22
|
-
>
|
|
23
|
-
> The SDK reads the freshest row, runs your updater, writes it as a
|
|
24
|
-
> compare-and-swap, and re-reads + re-runs on any concurrent write — no claim, no
|
|
25
|
-
> per-agent identity, no `stale_context` / `claim_*` codes, no retry loop, and no
|
|
26
|
-
> way to silently clobber. See [Functional update](#functional-update).
|
|
27
|
-
> The rest of this page is the **low-level** machinery the functional form is
|
|
28
|
-
> built on — reach for it directly only when you need to **hold a row across a
|
|
29
|
-
> slow gap with side effects** (e.g. presence badges, multi-row handles), or want
|
|
30
|
-
> explicit FIFO ordering. For lost-update detection on a single read-modify-write,
|
|
31
|
-
> prefer the functional form over hand-rolling `claim` / `readAt` / `onStale`.
|
|
32
|
-
|
|
33
|
-
Claims don't lock. If another writer holds the row, `claim` waits for them,
|
|
34
|
-
re-reads the fresh row, then hands it to you — so two writers serialize instead
|
|
35
|
-
of clobbering. The wait is a **server-side FIFO queue**: a second claimer blocks
|
|
36
|
-
until promoted to the head of the line — it does not fail and does not poll.
|
|
37
|
-
Reads stay open: reading a claimed row is allowed unless the caller explicitly
|
|
38
|
-
asks for claimed gating. A claim carries a TTL so a crashed holder is
|
|
39
|
-
auto-released and the queue advances.
|
|
40
|
-
|
|
41
|
-
A claim is also as narrow as you make it. Select one or more `fields` and
|
|
42
|
-
exclusion follows the target: **two claims on different fields of the same row
|
|
43
|
-
are both granted**, and only claims that share a field queue behind each other.
|
|
44
|
-
See [claiming part of a row](#claiming-part-of-a-row).
|
|
45
|
-
|
|
46
|
-
> **Transport: both wait — only the mechanism differs.** `claim({ id })` means
|
|
47
|
-
> "serialize me behind whoever holds this row" on every transport. The
|
|
48
|
-
> realtime client parks the promise on its socket and resolves it on the grant
|
|
49
|
-
> frame. The **stateless HTTP client** (`Ablo({ transport: 'http' })` — the
|
|
50
|
-
> transport server-side agents use) holds the same place in the same
|
|
51
|
-
> server-side FIFO line; under the hood it heartbeats its queued ticket until
|
|
52
|
-
> the line moves, then re-reads the row and resolves to the same held claim.
|
|
53
|
-
> The same snippet works on both.
|
|
54
|
-
>
|
|
55
|
-
> Shape the wait the same way on either transport: cap it with
|
|
56
|
-
> `waitTimeoutMs` (rejects `grant_timeout` and leaves the line), cancel it
|
|
57
|
-
> from outside with `signal` (an `AbortSignal`; rejects
|
|
58
|
-
> `claim_wait_aborted`), bound the line you'll join with `maxQueueDepth`
|
|
59
|
-
> (`queue_too_deep`), or skip waiting entirely with `queue: false` — the
|
|
60
|
-
> try-claim, which resolves `null` when the target is held (skipped work
|
|
61
|
-
> is not an error) and takes no place in line. For
|
|
62
|
-
> callers that manage the wait themselves, the ticket surface remains:
|
|
63
|
-
> `ablo.claims.get({ claimId })` polls a ticket to its grant,
|
|
64
|
-
> `ablo.claims.heartbeat({ claimId })` keeps the slot, and
|
|
65
|
-
> `ablo.claims.release({ claimId })` leaves the line. And contention can be
|
|
66
|
-
> treated as a signal rather than a wait at all — catch the error, re-read
|
|
67
|
-
> fresh, regenerate, retry; see [Errors](#errors) for the loop sketch.
|
|
68
|
-
|
|
69
|
-
This reference opens with [the model](#the-model-three-layers-one-decision) — the
|
|
70
|
-
one answer to "how do two agents not clobber each other" — then covers the
|
|
71
|
-
[claim state object](#the-claim-state-object), the SDK [methods](#methods)
|
|
72
|
-
(`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
|
|
73
|
-
claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
|
|
74
|
-
|
|
75
|
-
> **Before anything else: one identity per agent (for the low-level claim
|
|
76
|
-
> path).** The [functional update](#functional-update) does
|
|
77
|
-
> **not** need this — its safety comes from the row watermark (compare-and-swap),
|
|
78
|
-
> not from participant identity, so it's correct even when many workers share one
|
|
79
|
-
> `sk_`. The rule below applies when you take **explicit `claim`s** for FIFO
|
|
80
|
-
> exclusion or presence.
|
|
81
|
-
>
|
|
82
|
-
> Coordination excludes
|
|
83
|
-
> *participants*, and a participant **is the key**, not the client object. The
|
|
84
|
-
> server derives identity from the credential's scope — so **N clients sharing
|
|
85
|
-
> one `sk_`/`ek_` are one participant**: they all see the same `heldBy`, never
|
|
86
|
-
> queue behind each other, and a "second" claimer silently re-takes the lease it
|
|
87
|
-
> already holds (no mutual exclusion). To get real per-agent exclusion, mint a
|
|
88
|
-
> **distinct scoped `rk_` per agent** and bind a client to it:
|
|
89
|
-
>
|
|
90
|
-
> ```ts
|
|
91
|
-
> const { token } = await ablo.sessions.create({ agent: { id: `agent-${i}` } }); // rk_
|
|
92
|
-
> const agent = Ablo({ schema, apiKey: token }); // this agent's own participant
|
|
93
|
-
> ```
|
|
94
|
-
>
|
|
95
|
-
> Now `agent-0` holds while `agent-1`/`agent-2` queue in FIFO order and drain in
|
|
96
|
-
> turn. See [sessions](./sessions.md#agent-sessions-rk_) for the minting flow.
|
|
97
|
-
>
|
|
98
|
-
> **Testing exclusion:** mint two sessions with different `agent.id` values,
|
|
99
|
-
> assert those values differ, let A acquire the row, and inspect
|
|
100
|
-
> `claim.state({ id })` before B calls
|
|
101
|
-
> `claim({ id, contention: { mode: 'skip' } })`. B must receive `null`.
|
|
102
|
-
> Creating two clients from the same key tests re-entrancy, not contention.
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
## The model: three layers, one decision
|
|
107
|
-
|
|
108
|
-
Ablo has exactly **three** coordination layers. They are **not** three competing
|
|
109
|
-
answers to the same question — they stack, and only one of them is a decision you
|
|
110
|
-
make:
|
|
111
|
-
|
|
112
|
-
| layer | kind | what it does | enforces? |
|
|
113
|
-
|---|---|---|---|
|
|
114
|
-
| **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." Reading or claiming a row auto-enrolls you in its sync group, so `claim.state({ id })` observes co-participants from any client (browser or Node agent) with no manual subscribe step. | **No.** Advisory only: it never blocks or rejects a write. |
|
|
115
|
-
| **Claim** (`claim`/`claim.queue`/`claim.release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants: mutual exclusion. |
|
|
116
|
-
| **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time: lost-update detection. |
|
|
117
|
-
|
|
118
|
-
**The one decision: do you hold the row across a slow gap (read → LLM call →
|
|
119
|
-
write)?**
|
|
120
|
-
|
|
121
|
-
- **No** (the common case — a single quick `update`): a plain `ablo.<model>.update`
|
|
122
|
-
is **last-write-wins** — it carries no `readAt`, so the server skips the stale
|
|
123
|
-
check and the write simply lands. That's fine for most fields. If you need
|
|
124
|
-
lost-update detection on a no-claim write, pass `readAt` + `onStale: 'reject'`
|
|
125
|
-
yourself and it rejects with `AbloStaleContextError` when the row moved under
|
|
126
|
-
you.
|
|
127
|
-
- **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
|
|
128
|
-
excludes other participants for the duration, queues contenders fairly, and —
|
|
129
|
-
see below — your own writes under it stay stale-guarded too.
|
|
130
|
-
|
|
131
|
-
**How they compose (what wins):** If you don't hold the row, claims win — a
|
|
132
|
-
non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
|
|
133
|
-
`readAt`. If you do hold it, your own writes are still stale-checked — a row that
|
|
134
|
-
moved between your snapshot and your write still rejects with
|
|
135
|
-
`AbloStaleContextError`. With no claim held and no `readAt`, there is **no**
|
|
136
|
-
stale protection — the plain write is last-write-wins; opt into lost-update
|
|
137
|
-
detection by passing `readAt` + `onStale` yourself. Presence (`claim.state`)
|
|
138
|
-
never decides anything — read it to render, act on the errors. The two checks are
|
|
139
|
-
independent: one rejects writes from people who don't hold the claim, the other
|
|
140
|
-
rejects writes based on a stale snapshot, and the SDK adds the stale-check for you
|
|
141
|
-
when you write under a claim **you took on this client**, so there you don't pass
|
|
142
|
-
anything extra.
|
|
143
|
-
|
|
144
|
-
---
|
|
145
|
-
|
|
146
|
-
## Declaring conflict behaviour in the schema (Axis 3)
|
|
147
|
-
|
|
148
|
-
The two enforcement layers above are decided **per write** (claim a row, or pass
|
|
149
|
-
`readAt` + `onStale`). You can also declare a model's **default** conflict
|
|
150
|
-
disposition once, in the schema, so every commit to that model is governed
|
|
151
|
-
without per-call wiring. This is the third coordination axis — orthogonal to
|
|
152
|
-
`policy` (who may read a row) and `groups` (which delta channels it fans into).
|
|
153
|
-
|
|
154
|
-
Set a model's `conflict` stance with `coordination`, naming one rule per kind of
|
|
155
|
-
committer:
|
|
1
|
+
# Coordination
|
|
156
2
|
|
|
157
|
-
|
|
158
|
-
import { coordination, model, z } from '@abloatai/ablo/schema';
|
|
159
|
-
|
|
160
|
-
export const cards = model(
|
|
161
|
-
{
|
|
162
|
-
title: z.string(),
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
// "a human's edit always wins (never blocked); an agent yields"
|
|
166
|
-
conflict: coordination.humansOverwrite().agentsReject(),
|
|
167
|
-
}
|
|
168
|
-
);
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
Each rule pairs a committer with a disposition, drawn from the same `onStale`
|
|
172
|
-
vocabulary the write guards use:
|
|
173
|
-
|
|
174
|
-
| disposition | meaning |
|
|
175
|
-
|---|---|
|
|
176
|
-
| `overwrite` | the write wins; that committer is never blocked. |
|
|
177
|
-
| `reject` | the write is refused; that committer yields to a held claim / stale snapshot. |
|
|
178
|
-
| `notify` | hold the write and hand back the current value so the committer re-reads and re-applies (stale writes only). |
|
|
179
|
-
|
|
180
|
-
That gives nine rules — `humansOverwrite` / `humansReject` / `humansNotify`,
|
|
181
|
-
`agentsOverwrite` / `agentsReject` / `agentsNotify`, `systemOverwrite` /
|
|
182
|
-
`systemReject` / `systemNotify` — and a chain may name as many as it needs. A
|
|
183
|
-
kind left unnamed falls through to the engine default, and a kind named twice
|
|
184
|
-
takes the later rule.
|
|
185
|
-
|
|
186
|
-
When the rules are assembled at runtime rather than written out, each one is
|
|
187
|
-
also a standalone function, and `coordination()` merges them:
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
import { coordination, humansOverwrite, agentsReject } from '@abloatai/ablo/schema';
|
|
191
|
-
|
|
192
|
-
const stance = coordination(humansOverwrite(), agentsReject());
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
Both forms produce the same thing: a map keyed by the committer's participant
|
|
196
|
-
kind, which is what travels to the server.
|
|
197
|
-
|
|
198
|
-
```ts
|
|
199
|
-
{ user: 'overwrite', agent: 'reject' }
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### How it relates to per-write coordination
|
|
203
|
-
|
|
204
|
-
- The `conflict` map is **pure, serializable data**: it ships in your pushed
|
|
205
|
-
schema (`npx ablo push`) and the engine interprets it at the commit
|
|
206
|
-
chokepoint — there is no per-model code on the server.
|
|
207
|
-
- An **omitted committer kind falls through to the engine default**: reject, and
|
|
208
|
-
honor a per-write `onStale: 'notify'`. Declaring `conflict` is purely
|
|
209
|
-
additive — existing schemas behave exactly as before.
|
|
210
|
-
- It sets the **default** disposition; a per-write `onStale` and a held `claim`
|
|
211
|
-
still apply on top as described above. Think of `conflict` as "the house rule
|
|
212
|
-
for this model," and `claim` / `onStale` as what an individual write does
|
|
213
|
-
within it.
|
|
3
|
+
> Choose plain writes, functional updates, stale guards, or claims without losing concurrent work.
|
|
214
4
|
|
|
215
|
-
|
|
216
|
-
|
|
5
|
+
Ablo gives you several concurrency tools because not every write has the same
|
|
6
|
+
meaning. Choose the narrowest one that matches the operation.
|
|
217
7
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
## The claim state object
|
|
221
|
-
|
|
222
|
-
The claim state object is the live record that a participant is coordinating work on
|
|
223
|
-
a model row. It's what `claim.state()` returns and what observers render.
|
|
224
|
-
|
|
225
|
-
| field | type | description |
|
|
8
|
+
| Situation | Use | Result |
|
|
226
9
|
|---|---|---|
|
|
227
|
-
|
|
|
228
|
-
|
|
|
229
|
-
|
|
|
230
|
-
|
|
|
231
|
-
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
232
|
-
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it: a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
233
|
-
| `position` | `number?` | 0-based place in the FIFO line: present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
234
|
-
| `createdAt` | `number?` | Ms-epoch the holder opened it. Optional: derived shapes may omit it. |
|
|
235
|
-
| `expiresAt` | `number` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive: a crash-cleanup floor, not a duration you size. |
|
|
236
|
-
| `meta` | `Record<string, unknown>?` | The claim's open metadata bag, as it stands on the wire. A [heartbeat](#heartbeat-holding-a-claim-for-long-running-work) writes its `details` here under `progress`: last beat wins, so an observer can read what a long hold is doing without the holder releasing it. Distinct from `target.meta`, which is the shape your program declared: a declared shape has no member for a key the coordinator wrote. |
|
|
237
|
-
|
|
238
|
-
```jsonc
|
|
239
|
-
{
|
|
240
|
-
"id": "claim_8fJ2",
|
|
241
|
-
"status": "active",
|
|
242
|
-
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
243
|
-
"description": "editing",
|
|
244
|
-
"heldBy": "agent:forecaster",
|
|
245
|
-
"participantKind": "agent",
|
|
246
|
-
"createdAt": 1748160000000,
|
|
247
|
-
"expiresAt": 1748160030000,
|
|
248
|
-
"meta": { "progress": { "phase": "writing", "done": 2, "of": 5 } }
|
|
249
|
-
}
|
|
250
|
-
```
|
|
10
|
+
| Set an independent value | `update({ id, data })` | Last-write-wins when no claim applies. |
|
|
11
|
+
| Compute a value from the current row | `update(id, current => next)` | Re-reads and retries if the row changes concurrently. |
|
|
12
|
+
| Write only if earlier rows are still current | `reads: [task, policy]` | Rejects when an explicitly named dependency changed. |
|
|
13
|
+
| Read, call a model, then write | `claim({ id })` | Other participants cannot write the claimed target by default until your claim ends. |
|
|
251
14
|
|
|
252
|
-
|
|
15
|
+
**If a model call sits between the read and the write, take a claim.** A stale
|
|
16
|
+
guard tells you the row moved after you have already paid for the turn. A claim
|
|
17
|
+
makes the contender wait before it spends anything, and it reads the winner's
|
|
18
|
+
result rather than reasoning against state that has since moved.
|
|
253
19
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
(free) ───────────▶ active ───────────────────────▶ committed
|
|
257
|
-
│
|
|
258
|
-
┌───────────┴───────────┐
|
|
259
|
-
▼ ▼
|
|
260
|
-
canceled expired
|
|
261
|
-
(release w/o write) (TTL; holder died)
|
|
262
|
-
```
|
|
20
|
+
The important boundary is explicit: a plain update does not claim a row and
|
|
21
|
+
does not carry a stale premise. It is intentionally last-write-wins.
|
|
263
22
|
|
|
264
|
-
|
|
265
|
-
states drop out of the live stream, so a claim you can see is either `active`
|
|
266
|
-
(the holder) or `queued` (waiting in the FIFO line behind it; see
|
|
267
|
-
[`claim.queue`](#claimqueue)).
|
|
23
|
+
## Explicit read dependencies
|
|
268
24
|
|
|
269
|
-
|
|
270
|
-
here — no second subscription, and nothing to poll:
|
|
25
|
+
Pass the exact rows that produced a decision on the write:
|
|
271
26
|
|
|
272
27
|
```ts
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
---
|
|
278
|
-
|
|
279
|
-
## Methods
|
|
280
|
-
|
|
281
|
-
One word — "claim" — names four distinct things; keep them separate as you read:
|
|
282
|
-
|
|
283
|
-
- **the lease (claim handle):** the *object* returned by `ablo.<model>.claim({ id })`
|
|
284
|
-
(`ClaimHandle`, an `AsyncDisposable` with `.data` and `.release()`).
|
|
285
|
-
- **acquiring a claim/lease:** the *verb* `ablo.<model>.claim({ id })`, the call
|
|
286
|
-
that takes the lease.
|
|
287
|
-
- **`claim.state` / `claim.queue`:** the *inspection namespace* hanging off the
|
|
288
|
-
model, for reading who holds the row and who's lined up.
|
|
289
|
-
- **the write's `claim` param:** `update({ id, data, claim })`, where you pass a
|
|
290
|
-
lease the proxy didn't take itself.
|
|
291
|
-
|
|
292
|
-
Each method below follows one fixed shape: **signature · what it does ·
|
|
293
|
-
parameters · returns · example**.
|
|
294
|
-
|
|
295
|
-
### `claim`
|
|
296
|
-
|
|
297
|
-
```ts
|
|
298
|
-
ablo.<model>.claim({ id, ...options }): Promise<ClaimHandle<T>> // handle; AsyncDisposable, auto-releases with `await using`
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
Claim a row so other writers serialize behind you until you're done; reads stay
|
|
302
|
-
open by default. The claim acquires through the server's fair FIFO queue: if the
|
|
303
|
-
target is free the lease is yours immediately, and if another participant holds
|
|
304
|
-
it your claim **waits in line** and resolves only once it reaches the head —
|
|
305
|
-
then re-reads so the claimed snapshot reflects what the previous holder
|
|
306
|
-
committed. There's no polling and no race window — the server decides the order,
|
|
307
|
-
so two claimers can't both think they won.
|
|
308
|
-
|
|
309
|
-
**Parameters** — every option is flat on the call, and each sits on one of
|
|
310
|
-
four axes. `claim({ id })` alone is a complete call; each axis is opt-in.
|
|
311
|
-
|
|
312
|
-
*What you claim* — the target, narrowed below the row:
|
|
313
|
-
|
|
314
|
-
| name | type | required | description |
|
|
315
|
-
|---|---|---|---|
|
|
316
|
-
| `id` | `string` | yes | The row id: same id as `get` / `update`. |
|
|
317
|
-
| `options.fields` | field selector | no | Claim fields declared by the model's Zod schema instead of the whole row: `fields: (task) => task.status`, or `fields: (task) => [task.status, task.title]` for several. The model supplies its own fields, so autocomplete is exact, a typo does not compile, and a schema rename is a compile error at every use. Two sets conflict where they intersect, so holders of disjoint fields do not wait for each other; see [claiming part of a row](#claiming-part-of-a-row). |
|
|
318
|
-
|
|
319
|
-
*What others see* — the presence half:
|
|
320
|
-
|
|
321
|
-
| name | type | required | description |
|
|
322
|
-
|---|---|---|---|
|
|
323
|
-
| `options.description` | `string` | no | Peer-visible description of the work, shown to observers (default `'editing'`). |
|
|
324
|
-
| `options.meta` | `object` | no | App-defined structured metadata, carried verbatim to every participant observing the claim. Declare its shape once on `Register`'s `ClaimMeta` slot. |
|
|
28
|
+
const task = await ablo.tasks.get({ id: taskId });
|
|
29
|
+
const policy = await ablo.policies.get({ id: policyId });
|
|
30
|
+
if (!task || !policy) throw new Error('required input is missing');
|
|
325
31
|
|
|
326
|
-
|
|
32
|
+
const result = await model({ task, policy });
|
|
327
33
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
| `options.maxQueueDepth` | `number` | no | Legacy flat spelling of `contention.maxDepth`. Prefer `contention` for new code. |
|
|
333
|
-
| `options.waitTimeoutMs` | `number` | no | Legacy flat spelling of `contention.timeoutMs`. Prefer `contention` for new code. |
|
|
334
|
-
| `options.signal` | `AbortSignal` | no | Legacy flat spelling of `contention.signal`. Prefer `contention` for new code. |
|
|
335
|
-
|
|
336
|
-
*How long you hold* — the lease:
|
|
337
|
-
|
|
338
|
-
| name | type | required | description |
|
|
339
|
-
|---|---|---|---|
|
|
340
|
-
| `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set: the lease renews while your connection is alive, so it only matters once you go silent. |
|
|
341
|
-
| `options.heartbeat` | `true \| Duration \| { every?, onBeat?, onLost? }` | no | Keep the lease alive for work that outlives the TTL: `true` beats every third of the TTL, a duration sets the cadence, and the structured form carries the cadence and both callbacks in one place: `onBeat` fires after every successful beat (chiefly `queueDepth`, the pressure signal), `onLost` once if a beat learns the lease is gone. The loop stops on release. |
|
|
342
|
-
|
|
343
|
-
The request-scoped `onStatus` callback receives one discriminated event. It is
|
|
344
|
-
observational: an exception in UI or telemetry code never changes the claim
|
|
345
|
-
attempt.
|
|
346
|
-
|
|
347
|
-
| event | meaning | claim promise |
|
|
348
|
-
|---|---|---|
|
|
349
|
-
| `queued` | this request joined the wait line | remains pending |
|
|
350
|
-
| `granted` | this request owns the lease | resolves with the claim |
|
|
351
|
-
| `skipped` | `mode: 'skip'` found another participant holding the target | resolves `null` |
|
|
352
|
-
| `failed` | the attempt could not complete, for example timeout, cancellation, authorization, or connectivity | rejects with `event.error` |
|
|
353
|
-
|
|
354
|
-
```ts
|
|
355
|
-
const claim = await ablo.tasks.claim({
|
|
356
|
-
id,
|
|
357
|
-
contention: {
|
|
358
|
-
mode: 'wait',
|
|
359
|
-
maxDepth: 3,
|
|
360
|
-
timeoutMs: 30_000,
|
|
361
|
-
onStatus(event) {
|
|
362
|
-
if (event.type === 'queued') {
|
|
363
|
-
console.log(`${event.ahead} participant(s) ahead`);
|
|
364
|
-
} else if (event.type === 'granted') {
|
|
365
|
-
console.log(event.waited ? 'your turn' : 'granted immediately');
|
|
366
|
-
} else {
|
|
367
|
-
console.warn(event.error.code, event.error.message);
|
|
368
|
-
}
|
|
369
|
-
},
|
|
370
|
-
},
|
|
34
|
+
await ablo.tasks.update({
|
|
35
|
+
id: task.id,
|
|
36
|
+
data: result,
|
|
37
|
+
reads: [task, policy],
|
|
371
38
|
});
|
|
372
39
|
```
|
|
373
40
|
|
|
374
|
-
|
|
375
|
-
|
|
41
|
+
This means “apply this update only if the rows used to produce it have not
|
|
42
|
+
changed.” The exact returned objects carry opaque evidence; no watermark is
|
|
43
|
+
exposed. Same-row and cross-row dependencies use one shape. Incidental reads do
|
|
44
|
+
nothing, and cloned, fabricated, or cross-client rows fail locally.
|
|
376
45
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
contention: {
|
|
381
|
-
mode: 'skip',
|
|
382
|
-
onStatus: (event) => {
|
|
383
|
-
if (event.type === 'skipped') metrics.increment('claim.skipped');
|
|
384
|
-
},
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
if (!claim) return;
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
The high-level `claim` queues by default, so on contention you either get the row
|
|
391
|
-
when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
|
|
392
|
-
`grant_timeout`).
|
|
46
|
+
An `undefined` result cannot carry evidence. Guarded absence therefore remains
|
|
47
|
+
a separate low-level design; do not treat a missing read as an automatic
|
|
48
|
+
create-if-absent condition.
|
|
393
49
|
|
|
394
|
-
|
|
395
|
-
fresh row snapshot taken once the lease is yours, and `handle.release()` gives
|
|
396
|
-
the claim back. Bind it with `await using` so the claim auto-releases when the
|
|
397
|
-
scope exits.
|
|
50
|
+
## Functional updates
|
|
398
51
|
|
|
399
|
-
|
|
52
|
+
When the next value is a function of the current one, pass an updater rather
|
|
53
|
+
than fixed data:
|
|
400
54
|
|
|
401
55
|
```ts
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
56
|
+
const document = await ablo.documents.update(documentId, (current) => ({
|
|
57
|
+
revision: current.revision + 1,
|
|
58
|
+
content: revise(current.content),
|
|
59
|
+
}));
|
|
406
60
|
```
|
|
407
61
|
|
|
408
|
-
The
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
the next waiter is promoted, with no `finally` to remember. And nothing reaches
|
|
413
|
-
the server until `update`, so a failure *before* the write leaves the row exactly
|
|
414
|
-
as it was: claiming and committing are separate steps, so a failure between them
|
|
415
|
-
has nothing to roll back. (A failure *after* a successful write leaves that write
|
|
416
|
-
committed — pass an idempotency key on the write if you replay the block.) The
|
|
417
|
-
lower-level [`claim.release`](#claimrelease) shows the manual `try/finally`
|
|
418
|
-
equivalent for when you hold a claim without `await using`.
|
|
62
|
+
The SDK reads the current row, runs the updater, and writes only if that row is
|
|
63
|
+
still current. If another write wins first, it re-reads and runs the updater
|
|
64
|
+
again. This prevents the usual lost-update race without holding a claim across
|
|
65
|
+
your application code.
|
|
419
66
|
|
|
420
|
-
|
|
67
|
+
Use this form only for a pure calculation. Because the updater may run more than
|
|
68
|
+
once, do not send email, charge a card, call a model, or perform another side
|
|
69
|
+
effect inside it.
|
|
421
70
|
|
|
422
|
-
|
|
423
|
-
so it hands you its own fields:
|
|
71
|
+
You can bound or cancel reconciliation:
|
|
424
72
|
|
|
425
73
|
```ts
|
|
426
|
-
await
|
|
74
|
+
await ablo.documents.update(
|
|
75
|
+
documentId,
|
|
76
|
+
(current) => ({ revision: current.revision + 1 }),
|
|
77
|
+
{ retries: 8, signal: request.signal },
|
|
78
|
+
);
|
|
427
79
|
```
|
|
428
80
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
nothing to add to your schema file.
|
|
81
|
+
If contention continues beyond the retry budget, the call rejects with
|
|
82
|
+
`AbloContentionError` and does not apply a stale calculation.
|
|
432
83
|
|
|
433
|
-
|
|
434
|
-
contract and low-level coordination protocol.
|
|
84
|
+
## Stale guards
|
|
435
85
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
fields of the same row are both granted**, and only claims that share a field
|
|
439
|
-
queue behind each other.
|
|
86
|
+
Use explicit returned rows when application code reads first and writes later,
|
|
87
|
+
but does not need to reserve the row:
|
|
440
88
|
|
|
441
89
|
```ts
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// fields, so both are granted at once and neither waits on the other.
|
|
445
|
-
await using priced = await ablo.orders.claim({
|
|
446
|
-
id: orderId,
|
|
447
|
-
fields: (o) => [o.total, o.discount],
|
|
448
|
-
description: 'repricing',
|
|
449
|
-
});
|
|
90
|
+
const report = await ablo.reports.get({ id: reportId });
|
|
91
|
+
if (!report) throw new Error('report missing');
|
|
450
92
|
|
|
451
|
-
await
|
|
452
|
-
id:
|
|
453
|
-
|
|
454
|
-
|
|
93
|
+
await ablo.reports.update({
|
|
94
|
+
id: report.id,
|
|
95
|
+
data: { status: 'ready' },
|
|
96
|
+
reads: [report],
|
|
455
97
|
});
|
|
456
98
|
```
|
|
457
99
|
|
|
458
|
-
|
|
459
|
-
concurrently; two claims that both name `total` queue; naming no field covers
|
|
460
|
-
every field, so it conflicts with any narrower claim on the row.
|
|
100
|
+
The dispositions are:
|
|
461
101
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
and that is not solved yet. When it is, sub-field targets return, and they
|
|
468
|
-
return working.
|
|
102
|
+
| `onStale` | Behavior |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `reject` | Reject the write if its premise is stale. |
|
|
105
|
+
| `notify` | Leave the row unchanged and return the current value for reconciliation. |
|
|
106
|
+
| `overwrite` | Apply the write without a stale check. |
|
|
469
107
|
|
|
470
|
-
|
|
471
|
-
and
|
|
108
|
+
See [Concurrency Convention](./concurrency-convention.md) for guarded batches
|
|
109
|
+
and notifications.
|
|
472
110
|
|
|
473
|
-
###
|
|
111
|
+
### Decide the model's conflict policy
|
|
474
112
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
113
|
+
Who yields is a design decision about the model, not something to restate on
|
|
114
|
+
every write. Declare it once, in the schema, and it travels to the server with
|
|
115
|
+
the rest of the model:
|
|
478
116
|
|
|
479
117
|
```ts
|
|
480
|
-
|
|
481
|
-
id: 'report_stockholm',
|
|
482
|
-
ifClaimed: 'fail',
|
|
483
|
-
});
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
- `ifClaimed: 'return'` (the default) reads now and includes active work metadata.
|
|
487
|
-
- `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
|
|
488
|
-
|
|
489
|
-
Reads never block on a claim — there is no `ifClaimed: 'wait'`. Waiting for a row
|
|
490
|
-
to free up is a **claim-side** concern: take `ablo.<model>.claim({ id })` (it
|
|
491
|
-
queues fairly behind the current holder and re-reads the fresh row once it's
|
|
492
|
-
yours). Use `ifClaimed: 'fail'` when a read should simply refuse to proceed
|
|
493
|
-
against a row someone else is mid-editing.
|
|
494
|
-
|
|
495
|
-
### `claim.state`
|
|
118
|
+
import { coordination, model, z } from '@abloatai/ablo/schema';
|
|
496
119
|
|
|
497
|
-
|
|
498
|
-
|
|
120
|
+
const cards = model(
|
|
121
|
+
{ title: z.string() },
|
|
122
|
+
{
|
|
123
|
+
conflict: coordination.humansOverwrite().agentsReject(),
|
|
124
|
+
},
|
|
125
|
+
);
|
|
499
126
|
```
|
|
500
127
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
The first call also starts row-scoped observation. Because that subscription is
|
|
505
|
-
asynchronous, an imperative script may briefly read `null` before the reactive
|
|
506
|
-
snapshot arrives; subscribe to changes or wait for the expected holder when the
|
|
507
|
-
observation itself is the test. Late subscriptions are backfilled across server
|
|
508
|
-
instances, and a skipped conflict attempt seeds its authoritative holder
|
|
509
|
-
summary into the same snapshot immediately.
|
|
510
|
-
|
|
511
|
-
**You don't subscribe to anything first.** Reading or claiming a row
|
|
512
|
-
automatically enrolls you in that row's sync group: reading it (including
|
|
513
|
-
`get`, or `claim.state` itself) gives you **read-interest**, and
|
|
514
|
-
`claim`-ing it gives you a **pinned write-intent**. So `claim.state({ id })`
|
|
515
|
-
observes co-participants on that row from **any** client — a browser, a Server
|
|
516
|
-
Action, or a Node agent — and a holder sees its own claim, with no manual
|
|
517
|
-
subscribe step. There is no `participants.join` to call: the typed
|
|
518
|
-
`ablo.<model>` surface (read / `claim` / `claim.state` / `claim.queue`) is the
|
|
519
|
-
whole coordination API.
|
|
520
|
-
|
|
521
|
-
**Parameters**
|
|
522
|
-
|
|
523
|
-
| name | type | required | description |
|
|
524
|
-
|---|---|---|---|
|
|
525
|
-
| `id` | `string` | yes | The row id. |
|
|
526
|
-
|
|
527
|
-
**Returns** — an active [claim state object](#the-claim-state-object) on the row, or
|
|
528
|
-
`null` when the row is free.
|
|
128
|
+
An omitted participant kind uses the engine default, `reject`. A per-write
|
|
129
|
+
`onStale` states the disposition for that one write. Keep the policy simple, and
|
|
130
|
+
document any rule that lets a participant overwrite a held claim.
|
|
529
131
|
|
|
530
|
-
|
|
531
|
-
answers with a single claim. That is the whole story for a whole-row claim, and
|
|
532
|
-
only part of it once you [claim parts of a row](#claiming-part-of-a-row): three
|
|
533
|
-
agents holding `total`, `discount`, and `status` are all active at once, and
|
|
534
|
-
this read surfaces one of them. To render every holder, a badge per claimed
|
|
535
|
-
field or a chip per participant, use [`claim.list`](#claimlist).
|
|
132
|
+
## Claims
|
|
536
133
|
|
|
537
|
-
|
|
134
|
+
Use a claim when work must remain exclusive across a slow gap such as an LLM
|
|
135
|
+
call or another external service:
|
|
538
136
|
|
|
539
137
|
```ts
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
Returns the active claim state when the row is held, or `null` when it's free:
|
|
545
|
-
|
|
546
|
-
```jsonc
|
|
547
|
-
{
|
|
548
|
-
"id": "claim_8fJ2",
|
|
549
|
-
"status": "active",
|
|
550
|
-
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
551
|
-
"description": "editing",
|
|
552
|
-
"heldBy": "agent:forecaster",
|
|
553
|
-
"participantKind": "agent",
|
|
554
|
-
"expiresAt": 1748160030000
|
|
555
|
-
}
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
### `claim.list`
|
|
559
|
-
|
|
560
|
-
```ts
|
|
561
|
-
ablo.<model>.claim.list({ id })
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
Every holder of a row. Same synchronous, reactive read as `claim.state` — off
|
|
565
|
-
the same local snapshot, safe to call inline in a render — and the same list
|
|
566
|
-
envelope as [`claim.queue`](#claimqueue).
|
|
567
|
-
|
|
568
|
-
Reach for it whenever a row can be claimed by field. One agent repricing
|
|
569
|
-
`total` while another marks `status` are two active claims on one row, and only
|
|
570
|
-
this read returns both.
|
|
571
|
-
|
|
572
|
-
**Parameters**
|
|
573
|
-
|
|
574
|
-
| name | type | required | description |
|
|
575
|
-
|---|---|---|---|
|
|
576
|
-
| `id` | `string` | yes | The row id. |
|
|
577
|
-
|
|
578
|
-
**Returns** — `{ object: 'list', data: Claim[] }`. Your own claim comes first
|
|
579
|
-
when this client holds one, then the other participants'. Empty `data` when the
|
|
580
|
-
row is free.
|
|
581
|
-
|
|
582
|
-
**Example** — a badge on every claimed field:
|
|
583
|
-
|
|
584
|
-
```tsx
|
|
585
|
-
const { data: holders } = ablo.orders.claim.list({ id: orderId });
|
|
586
|
-
|
|
587
|
-
return FIELDS.map((field) => {
|
|
588
|
-
const held = holders.find((c) => c.target.field === field);
|
|
589
|
-
return <FieldBadge key={field} field={field} by={held?.heldBy} note={held?.description} />;
|
|
138
|
+
await using claim = await ablo.reports.claim({
|
|
139
|
+
id: reportId,
|
|
140
|
+
description: 'generating forecast',
|
|
590
141
|
});
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
`claim.state({ id })` remains the right read when a row is claimed whole, or
|
|
594
|
-
when all you need is "is anyone working here" — it answers with one claim and
|
|
595
|
-
`null` when the row is free.
|
|
596
|
-
|
|
597
|
-
### `claim.queue`
|
|
598
|
-
|
|
599
|
-
```ts
|
|
600
|
-
ablo.<model>.claim.queue({ id })
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
Read the **wait line** behind a row — the FIFO of claims queued behind the
|
|
604
|
-
current holder, in promotion order. Like `claim.state`, it's synchronous and
|
|
605
|
-
reactive (it reads the local coordination snapshot, kept current by the server's
|
|
606
|
-
queue-mutation frames), and reading never blocks. Where `claim.state` answers "who
|
|
607
|
-
holds it," `claim.queue` answers "who's lined up next" — render "3rd in line", or
|
|
608
|
-
decide the wait isn't worth it.
|
|
609
|
-
|
|
610
|
-
**Parameters**
|
|
611
|
-
|
|
612
|
-
| name | type | required | description |
|
|
613
|
-
|---|---|---|---|
|
|
614
|
-
| `id` | `string` | yes | The row id. |
|
|
615
|
-
|
|
616
|
-
**Returns** — a structured queue snapshot:
|
|
617
|
-
|
|
618
|
-
- `waiting` — queued [claim state objects](#the-claim-state-object) in
|
|
619
|
-
promotion order, excluding the active holder;
|
|
620
|
-
- `next` — the first waiter, or `null`;
|
|
621
|
-
- `size` — how many participants are waiting;
|
|
622
|
-
- `data` — the same array as `waiting`, retained as the standard list-envelope
|
|
623
|
-
member for compatibility.
|
|
624
142
|
|
|
625
|
-
|
|
143
|
+
const forecast = await generateForecast(claim.data.location);
|
|
626
144
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
console.log(line.waiting.map((claim) => claim.heldBy));
|
|
632
|
-
```
|
|
633
|
-
|
|
634
|
-
### `claim.release`
|
|
635
|
-
|
|
636
|
-
```ts
|
|
637
|
-
ablo.<model>.claim.release({ id }): Promise<void>
|
|
638
|
-
```
|
|
639
|
-
|
|
640
|
-
Release a claim you hold. Usually **implicit** — the `await using` scope exiting
|
|
641
|
-
releases for you, and TTL cleans up a crashed holder.
|
|
642
|
-
Call this only to give a manually held claim back early (claimed, then decided
|
|
643
|
-
not to write).
|
|
644
|
-
Releasing **promotes the head of the queue**: the next waiter receives the claim.
|
|
645
|
-
|
|
646
|
-
**Parameters**
|
|
647
|
-
|
|
648
|
-
| name | type | required | description |
|
|
649
|
-
|---|---|---|---|
|
|
650
|
-
| `id` | `string` | yes | The row id you hold a claim on. No-op if you don't hold it. |
|
|
651
|
-
|
|
652
|
-
**Returns** — resolves once the claim is released.
|
|
653
|
-
|
|
654
|
-
**Example**
|
|
655
|
-
|
|
656
|
-
```ts
|
|
657
|
-
const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', description: 'reviewing' });
|
|
658
|
-
const report = claim.data;
|
|
659
|
-
try {
|
|
660
|
-
const ok = await reviewExternally(report);
|
|
661
|
-
if (!ok) return; // abandon, no write
|
|
662
|
-
await ablo.weatherReports.update({ id: report.id, data: { status: 'ready' } });
|
|
663
|
-
} finally {
|
|
664
|
-
await ablo.weatherReports.claim.release({ id: report.id });
|
|
665
|
-
}
|
|
145
|
+
await ablo.reports.update({
|
|
146
|
+
id: claim.data.id,
|
|
147
|
+
data: { forecast, status: 'ready' },
|
|
148
|
+
});
|
|
666
149
|
```
|
|
667
150
|
|
|
668
|
-
|
|
151
|
+
If another participant already holds the target, `claim` waits its turn and
|
|
152
|
+
then resolves with a fresh row in `claim.data`. Ordinary reads remain open. By
|
|
153
|
+
default, a write from a participant that does not hold the active claim is
|
|
154
|
+
rejected; an explicit model conflict policy can choose otherwise.
|
|
669
155
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
156
|
+
Bind claims with `await using` whenever possible. The claim then releases when
|
|
157
|
+
the scope exits, including when the external call or write throws. For runtimes
|
|
158
|
+
without explicit resource management, use `try/finally` and
|
|
159
|
+
`await claim.release()`.
|
|
673
160
|
|
|
674
|
-
|
|
675
|
-
outlives it (an agent run, a background worker's job) keeps its lease by
|
|
676
|
-
**beating**, the same pattern as an SQS visibility heartbeat or a Temporal
|
|
677
|
-
activity heartbeat. Each beat extends the lease from now (never shortens it,
|
|
678
|
-
and each extension is clamped server-side); a crashed worker stops beating and
|
|
679
|
-
its lease lapses within one beat window, promoting the next waiter.
|
|
161
|
+
### One identity per participant
|
|
680
162
|
|
|
681
|
-
|
|
682
|
-
|
|
163
|
+
Explicit claims coordinate authenticated participants. Two clients using the
|
|
164
|
+
same credential represent the same participant and do not exclude one another.
|
|
165
|
+
Mint a distinct scoped session for each independently coordinated agent:
|
|
683
166
|
|
|
684
167
|
```ts
|
|
685
|
-
|
|
686
|
-
id:
|
|
687
|
-
description: 'generating',
|
|
688
|
-
ttl: '5m',
|
|
689
|
-
heartbeat: { onLost: () => abortWork() }, // `true` and '2m' are the shorthands
|
|
168
|
+
const { token } = await server.sessions.create({
|
|
169
|
+
agent: { id: `forecast-agent-${workerId}` },
|
|
690
170
|
});
|
|
691
|
-
await runLongGeneration(claim.data); // lease held for the duration
|
|
692
|
-
// scope exit releases; the loop stops with it
|
|
693
|
-
```
|
|
694
171
|
|
|
695
|
-
|
|
696
|
-
queue moved on — rejects with `AbloClaimedError` (`claim_lost`) and stops the
|
|
697
|
-
auto-loop. For a worker with no socket, **the failed beat is the loss
|
|
698
|
-
notification**; abandon or re-claim, and remember any write attempted under
|
|
699
|
-
the old lease is independently rejected by its `readAt` guard. Transient
|
|
700
|
-
failures (a connection blip) don't stop the loop — the next tick retries.
|
|
701
|
-
|
|
702
|
-
Each beat's answer carries two more things:
|
|
703
|
-
|
|
704
|
-
- **`queueDepth`:** how many participants wait in line behind the lease.
|
|
705
|
-
This is the cooperative-yield pressure signal: a worker that can checkpoint
|
|
706
|
-
may release early when others wait. Read it from the resolved beat, or pass
|
|
707
|
-
`heartbeat: { onBeat }` when claiming to observe every auto-beat.
|
|
708
|
-
- **progress `details`:** `held.heartbeat({ details: { pages: 42, of: 100 } })`
|
|
709
|
-
stores the payload as the claim's peer-visible `meta.progress` (last beat
|
|
710
|
-
wins, via `claim.state`). This is presence, not a checkpoint: it dies with
|
|
711
|
-
the lease. Durable progress belongs in the data itself — write a row, and
|
|
712
|
-
every subscriber already sees it.
|
|
713
|
-
|
|
714
|
-
Cooperative yield has a server-side backstop your deployment can turn on: a
|
|
715
|
-
**cumulative-hold ceiling**. Left unset — the default — a holder that keeps
|
|
716
|
-
beating holds the row as long as it likes, and the line behind it depends on
|
|
717
|
-
that holder reading `queueDepth` and releasing of its own accord. With a ceiling
|
|
718
|
-
configured for a model, a holder that runs past its fair share *while contenders
|
|
719
|
-
are queued* is preempted at the server: its next beat comes back `claim_lost`
|
|
720
|
-
(reason `preempted`) — the same loss you already handle, so abandon or re-claim,
|
|
721
|
-
and any write attempted under the old lease is fenced regardless. A holder with
|
|
722
|
-
no one waiting is never preempted, however long it runs. It is the same idea as
|
|
723
|
-
an SQS message that cannot stay invisible past a hard cap however often its lock
|
|
724
|
-
is refreshed, narrowed here to bite only under real contention.
|
|
725
|
-
|
|
726
|
-
Works identically on both transports: the realtime client sends a
|
|
727
|
-
`claim_heartbeat` frame; the HTTP client posts
|
|
728
|
-
`POST /api/v1/models/{model}/{id}/claim/heartbeat` (`{ ttl?, claimId?, details? }`).
|
|
729
|
-
Over HTTP, a **queued** claim can heartbeat too — it refreshes the waiter's
|
|
730
|
-
slot in the line (a queued slot is TTL'd like a lease) and reports
|
|
731
|
-
`{ status: 'queued', position }`.
|
|
732
|
-
|
|
733
|
-
A stateless worker holding **many** rows beats them all in one round trip:
|
|
734
|
-
`ablo.claims.heartbeatAll({ ttl: '5m' })` → `POST /api/v1/claims/heartbeat`, one
|
|
735
|
-
entry per extended lease. This is the socketless twin of the realtime
|
|
736
|
-
keepalive, which already renews every held lease on each ping.
|
|
737
|
-
|
|
738
|
-
### durability: what a claim survives
|
|
739
|
-
|
|
740
|
-
A lease belongs to your **identity** — the participant behind the credential —
|
|
741
|
-
not to the socket it was claimed on; the server keys each lease by participant
|
|
742
|
-
and `claimId`. That one fact decides what a claim lives through.
|
|
743
|
-
|
|
744
|
-
**A brief blip is transparent.** The realtime client reconnects on its own
|
|
745
|
-
(exponential backoff), and on each reconnect it re-announces every claim it
|
|
746
|
-
still holds, so the server renews those leases and peers never see them flicker.
|
|
747
|
-
A heartbeat that would land while the socket is momentarily down is skipped
|
|
748
|
-
rather than failed — the next tick retries once the connection is back. Nothing
|
|
749
|
-
to write: hold the claim and keep working.
|
|
750
|
-
|
|
751
|
-
**A crashed holder frees the claim quickly — and it is the keepalive, not the
|
|
752
|
-
TTL, that does it.** A dead holder is caught whichever way fires first: a clean
|
|
753
|
-
socket close releases immediately, and a silent socket that never sent a close
|
|
754
|
-
frame (a crashed tab, a dropped NAT) is reaped on the keepalive cycle (a ~30s
|
|
755
|
-
ping / 10s pong window) and released then. Either way the next waiter is
|
|
756
|
-
promoted within tens of seconds. This reclaim is **per-connection**, and it runs
|
|
757
|
-
whether or not the TTL is anywhere near lapsing. Release fires only when your
|
|
758
|
-
**last** connection goes, so a second connection under the same identity keeps
|
|
759
|
-
the claim held.
|
|
760
|
-
|
|
761
|
-
**The TTL is the deeper floor — for when the server itself restarts.** The live
|
|
762
|
-
claim roster is held in memory, so a server restart would lose it; the durable
|
|
763
|
-
lease in the coordination store carries the TTL, and a reconnecting client
|
|
764
|
-
re-announces its claims before that TTL lapses. So size `ttl` to cover a
|
|
765
|
-
deploy or restart window, not your work duration — beating covers the work
|
|
766
|
-
duration.
|
|
767
|
-
|
|
768
|
-
**To hold a claim across a holder crash, give it a durable identity.** A claim
|
|
769
|
-
that must outlive a single failure belongs to a process that stays up — a
|
|
770
|
-
backend worker or agent with its own credential — rather than one ephemeral
|
|
771
|
-
browser tab. On reconnect the SDK re-announces it; if the row was granted onward
|
|
772
|
-
while you were gone, that re-announce comes back as `AbloClaimedError`
|
|
773
|
-
(`claim_lost`) — re-claim (you rejoin the line fairly) and retry from the fresh
|
|
774
|
-
snapshot.
|
|
775
|
-
|
|
776
|
-
| the holder… | what happens to the claim |
|
|
777
|
-
| --- | --- |
|
|
778
|
-
| blips, then reconnects within the window | renewed automatically on reconnect: no interruption |
|
|
779
|
-
| crashes or drops for good | released within one keepalive cycle; the queue advances |
|
|
780
|
-
| still has a second live connection | survives: release fires only on the last connection |
|
|
781
|
-
| loses the server to a restart | rides the TTL in the coordination store; re-announced on reconnect |
|
|
782
|
-
|
|
783
|
-
### `join`: presence for a set of rows
|
|
784
|
-
|
|
785
|
-
Reading or claiming a row auto-enrolls you in its sync group, which is enough for
|
|
786
|
-
`claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
|
|
787
|
-
presence on a known set of rows — a workspace's documents, a board's cards — and react to
|
|
788
|
-
who joins or leaves, use `join`:
|
|
789
|
-
|
|
790
|
-
```ts
|
|
791
|
-
await using room = await ablo.documents.join(slideIds, { ttl: '5m' });
|
|
792
|
-
room.peers; // who else is here, live
|
|
172
|
+
const agent = Ablo({ schema, apiKey: token });
|
|
793
173
|
```
|
|
794
174
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
disposal). It is the model-scoped successor to the old top-level
|
|
798
|
-
`ablo.participants.join({ scope })`. **WebSocket only** — presence needs a live
|
|
799
|
-
socket, so `join` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
|
|
800
|
-
throws on any non-ws construction.
|
|
175
|
+
Functional updates do not require distinct participant identities because they
|
|
176
|
+
protect the row version rather than a participant-held claim.
|
|
801
177
|
|
|
802
|
-
###
|
|
178
|
+
### Skip instead of wait
|
|
803
179
|
|
|
804
|
-
|
|
805
|
-
`ablo.<model>.update({ id, data })`. The auto-guarding holds **only when this same
|
|
806
|
-
client took the claim** via `ablo.<model>.claim({ id })` (the proxy remembers the
|
|
807
|
-
lease in-process): that `update` is then stale-guarded against the snapshot the
|
|
808
|
-
claim took (`readAt` = snapshot watermark, `onStale: 'reject'`) and attributed to
|
|
809
|
-
the claim's lease, so it rejects with [`AbloStaleContextError`](#errors) if the
|
|
810
|
-
row changed under you.
|
|
180
|
+
For deduplicated jobs, skip work when another participant already owns it:
|
|
811
181
|
|
|
812
182
|
```ts
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
A claim handle minted by **another client** (or returned over HTTP) is not known
|
|
818
|
-
to this proxy, so a plain `update` won't pick it up. Pass it explicitly:
|
|
819
|
-
|
|
820
|
-
```ts
|
|
821
|
-
await ablo.weatherReports.update({ id, data: { status: 'ready' }, claim: handle });
|
|
822
|
-
```
|
|
823
|
-
|
|
824
|
-
**Self-stale on a second write.** The claim's watermark is fixed at claim time
|
|
825
|
-
and is **not** re-baselined as you write. So a *second* `update` under one held
|
|
826
|
-
claim is stale-checked against the snapshot the claim took — which your *first*
|
|
827
|
-
write already moved past — and rejects with `AbloStaleContextError` against your
|
|
828
|
-
own earlier write. Re-read (and re-claim) between writes if you need to write the
|
|
829
|
-
same row more than once under one claim.
|
|
183
|
+
const claim = await ablo.tasks.claim({
|
|
184
|
+
id: taskId,
|
|
185
|
+
contention: { mode: 'skip' },
|
|
186
|
+
});
|
|
830
187
|
|
|
831
|
-
|
|
832
|
-
participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
|
|
833
|
-
'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
|
|
834
|
-
behind the current holder and re-reads once it's yours, so your `update` lands
|
|
835
|
-
on fresh data. You never conflict with your own claim, and reads are never gated.
|
|
188
|
+
if (!claim) return;
|
|
836
189
|
|
|
837
|
-
```ts
|
|
838
190
|
try {
|
|
839
|
-
await
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
// someone else holds it — claim the row and retry from fresh state
|
|
843
|
-
}
|
|
191
|
+
await processTask(claim.data);
|
|
192
|
+
} finally {
|
|
193
|
+
await claim.release();
|
|
844
194
|
}
|
|
845
195
|
```
|
|
846
196
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
## Errors
|
|
850
|
-
|
|
851
|
-
All extend `AbloError` (`packages/transaction/src/errors.ts`). Catch by `type` or
|
|
852
|
-
inspect the `code`.
|
|
853
|
-
|
|
854
|
-
| error | `code` | thrown when | carries |
|
|
855
|
-
|---|---|---|---|
|
|
856
|
-
| `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away: the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited), while you were holding or waiting. | `claims?` |
|
|
857
|
-
| `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable: re-attempt the claim. | `claims?` |
|
|
858
|
-
| `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
|
|
859
|
-
| `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join: fail-fast instead of waiting. | `claims?` |
|
|
860
|
-
| `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds: the server's pre-commit check rejected it. |: |
|
|
861
|
-
| `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. |: |
|
|
862
|
-
| `AbloStaleContextError` |: | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot: your reasoning is stale. | `readAt`, `conflicts[]` |
|
|
863
|
-
| `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model proxy built without the collaboration runtime: an internal/advanced construction path. The standard `Ablo({ schema, apiKey })` client enables claiming for **every** model; there is no per-model claim config to add. |: |
|
|
864
|
-
| `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. |: |
|
|
865
|
-
|
|
866
|
-
`AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
|
|
867
|
-
that moved during your generation window — use it for selective regeneration
|
|
868
|
-
(re-think only the documents that changed, not the whole workspace) and for metrics.
|
|
197
|
+
To wait with limits, keep the policy together:
|
|
869
198
|
|
|
870
199
|
```ts
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
// The row moved under us — re-read and regenerate from the fresh snapshot.
|
|
881
|
-
} else throw err;
|
|
882
|
-
}
|
|
200
|
+
const claim = await ablo.tasks.claim({
|
|
201
|
+
id: taskId,
|
|
202
|
+
contention: {
|
|
203
|
+
mode: 'wait',
|
|
204
|
+
maxDepth: 3,
|
|
205
|
+
timeoutMs: 30_000,
|
|
206
|
+
signal: request.signal,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
883
209
|
```
|
|
884
210
|
|
|
885
|
-
###
|
|
211
|
+
### Claim part of a row
|
|
886
212
|
|
|
887
|
-
|
|
888
|
-
it yourself only when one attempt isn't a pure re-read-and-recompute — e.g. you
|
|
889
|
-
must hold an explicit `claim` for a presence badge across the gap, or coordinate
|
|
890
|
-
several rows in one handle. For a single read-modify-write, use the functional
|
|
891
|
-
form instead of this.
|
|
213
|
+
Narrow a claim when independent fields may be edited concurrently:
|
|
892
214
|
|
|
893
215
|
```ts
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
(
|
|
897
|
-
|
|
898
|
-
e.code === 'claim_lost')); // a human preempted us
|
|
899
|
-
|
|
900
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
901
|
-
try {
|
|
902
|
-
await using claim = await ablo.reports.claim({ id }); // acquire or claim_queued
|
|
903
|
-
const fresh = claim.data; // read at the lease moment
|
|
904
|
-
const next = await generate(fresh); // slow gap
|
|
905
|
-
await ablo.reports.update({ id, data: next, claim }); // first writer wins
|
|
906
|
-
break; // landed
|
|
907
|
-
} catch (err) {
|
|
908
|
-
if (!RETRYABLE(err) || attempt === MAX_ATTEMPTS) throw err;
|
|
909
|
-
await sleep(80 + attempt * 40 + Math.random() * 60); // jitter: don't lock-step
|
|
910
|
-
}
|
|
911
|
-
}
|
|
216
|
+
await using claim = await ablo.tasks.claim({
|
|
217
|
+
id: taskId,
|
|
218
|
+
fields: (task) => task.status,
|
|
219
|
+
});
|
|
912
220
|
```
|
|
913
221
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
line instead of throwing `claim_queued`.
|
|
222
|
+
Claims on disjoint fields can coexist. A whole-row claim conflicts with every
|
|
223
|
+
field claim on that row.
|
|
917
224
|
|
|
918
|
-
|
|
225
|
+
The target options are:
|
|
919
226
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
`
|
|
925
|
-
|
|
926
|
-
```ts
|
|
927
|
-
const row = await ablo.documents.update(documentId, (current) => ({
|
|
928
|
-
content: revise(current.content), // "given the latest, here is the next"
|
|
929
|
-
}));
|
|
930
|
-
```
|
|
227
|
+
| Option | Purpose |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `options.field` | Claim one field by its wire-level name. Prefer the typed selector in application code. |
|
|
230
|
+
| `options.fields` | Claim one or more schema fields with a typed selector. |
|
|
231
|
+
| `options.meta` | Attach application-defined metadata observers may display. |
|
|
931
232
|
|
|
932
|
-
|
|
933
|
-
guarantee can't drift): read the freshest row + its watermark → run your updater
|
|
934
|
-
→ write it as a **compare-and-swap** against that watermark (`readAt` +
|
|
935
|
-
`onStale: 'reject'`) → on any concurrent write, re-read and re-run. Correctness
|
|
936
|
-
comes from the watermark, **not** from participant identity — so it's immune to
|
|
937
|
-
the shared-credential clobber footgun and needs no `claim` and no per-agent `rk_`.
|
|
233
|
+
## Observe coordination
|
|
938
234
|
|
|
939
|
-
|
|
940
|
-
the call returns the reconciled row or, at the extreme, throws **one** error:
|
|
235
|
+
Read current claim state without blocking:
|
|
941
236
|
|
|
942
237
|
```ts
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
try {
|
|
946
|
-
await ablo.documents.update(id, (cur) => ({ content: revise(cur.content) }), {
|
|
947
|
-
retries: 16, // reconcile rounds before giving up (default 16)
|
|
948
|
-
signal: req.signal, // optional: abort the loop if the request is cancelled
|
|
949
|
-
});
|
|
950
|
-
} catch (err) {
|
|
951
|
-
if (err instanceof AbloContentionError) {
|
|
952
|
-
// The row stayed continuously contended past the budget — nothing was
|
|
953
|
-
// written. err.attempts, err.cause (the last conflict). Back off, raise
|
|
954
|
-
// `retries`, or move the row to the WebSocket transport (fair FIFO queue).
|
|
955
|
-
}
|
|
956
|
-
}
|
|
238
|
+
const holder = ablo.tasks.claim.state({ id: taskId });
|
|
239
|
+
const queue = ablo.tasks.claim.queue({ id: taskId });
|
|
957
240
|
```
|
|
958
241
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
propagates immediately without retrying.
|
|
242
|
+
Use this state for presence and progress UI. Do not use an observed `null` as a
|
|
243
|
+
substitute for claiming: another participant can acquire the row immediately
|
|
244
|
+
after your read.
|
|
963
245
|
|
|
964
|
-
|
|
246
|
+
The main methods are:
|
|
965
247
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
|
971
|
-
|
|
|
972
|
-
| `
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
`observability` provider to `Ablo({ ... })` and the client reports every claim
|
|
987
|
-
lifecycle event and stale-write collision it sees. The batteries-included
|
|
988
|
-
provider is `ClaimLog`, and `collisions()` is the eval primitive:
|
|
989
|
-
|
|
990
|
-
```ts
|
|
991
|
-
const log = new ClaimLog();
|
|
992
|
-
const ablo = Ablo({ schema, apiKey, observability: log });
|
|
993
|
-
|
|
994
|
-
expect(log.collisions()).toHaveLength(0); // no one stepped on anyone
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
See [Debugging & Logs](./debugging.md) for the setup, the event shapes, a
|
|
998
|
-
reactive activity feed, and routing events to your own backend.
|
|
248
|
+
| Method | Purpose |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `claim({ id })` | Acquire the target, waiting by default. |
|
|
251
|
+
| `claim.state({ id })` | Read the current holder without blocking. |
|
|
252
|
+
| `claim.queue({ id })` | Read the current wait order. |
|
|
253
|
+
| `claim.release({ id })` | Release early when you do not hold a handle. |
|
|
254
|
+
| `join({ scope })` | Observe presence for a broader scope. |
|
|
255
|
+
|
|
256
|
+
## Choosing correctly
|
|
257
|
+
|
|
258
|
+
- Prefer a plain update for values that do not depend on an earlier read.
|
|
259
|
+
- Prefer a functional update for a quick, pure read-modify-write calculation.
|
|
260
|
+
- Prefer a stale guard when your caller should decide how to reconcile.
|
|
261
|
+
- Prefer a claim when you must hold exclusivity across slow or side-effecting
|
|
262
|
+
work.
|
|
263
|
+
- Prefer idempotency for safe retries; it solves a different problem from
|
|
264
|
+
concurrency.
|
|
265
|
+
|
|
266
|
+
For exact error codes and recovery guidance, see [Errors](./errors.md). For what
|
|
267
|
+
a confirmed write promises, see [Guarantees](./guarantees.md).
|