pi-mono/packages/durable/docs/group-commit.md
Mario Zechner e5c6182bab feat(durable): group commit and batch Storage.commit
A Session no longer holds its line while a commit is stored. Commits that
arrive while one storage call is in flight go out together in the next call,
each with its own seq and outcome; a job runs until it touches a key an
unpublished commit wrote, then waits for that commit's publication. Outcomes
are delivered in line order; publication is in line order, one commit per
macrotask. The scheduler's decisions run as regions that record the keys they
read and rerun after a wait, with side effects only after a clean check.

Storage.commit(batches, receive, context) replaces commit(writes, adopt,
context): prefix semantics (Durable's own checks refuse a batch and keep the
ones before it; any other failure rejects the call), receipts per stored
batch, optional publishing() scope. Memory, SQLite, JSONL, IndexedDB and
StoreStorage implement it natively; StoreStorage through the new
ChordStore.commitSeries (one store transaction per call), replacing
commitInBatch.

A normal run makes no storage reads: bounded record cache, conversation
tails, task-document index, kept context ranges. A tool slot's running status
and a generation's attempt are stored with the next commit (12 instead of 14
commits per run).

One Harness with 128 conversations over storage with a 4 ms round trip: about
5 -> 440 agent runs per second. Design: packages/durable/docs/group-commit.md.
2026-10-30 10:41:11 +00:00

296 lines
22 KiB
Markdown

# Group commit for the Durable Session
Status: design, revision 2 (after three reviews). Branch `durable-perf` (worktree `~/pi-perf` on slayer), on top of the
uncommitted prerequisites (record cache, conversation tails, kept context ranges advanced at publish, task-document
index, deferred tool-slot `running` and generation attempt). Read `docs/spec.md` §1, §4, §4.1, §5, §8, §10.1 alongside.
## 1. Problem
A Session has one mutation line (`SessionImpl.#tail`). Each commit runs its callback, settles, calls `Storage.commit()`,
and adopts while holding the line, so every storage round trip of every conversation is serialized. After the
prerequisites the line makes no storage reads in a normal run; a run that calls one tool stores 12 commits. Measured on
slayer (one Harness, faux model, MemoryStorage with a simulated round trip per call, 32 concurrent conversations):
26.8 runs/s at 4 ms, 84 at 1 ms, 895 at 0 ms (CPU, one core). Throughput is fixed by the line.
Group commit sends the commits that accumulate while one write is in flight as one storage call. Modelled with result
fences (each job on a run's critical path waits about one round trip): ~250 runs/s at 4 ms with 32 conversations,
rising toward the CPU ceiling with more conversations. A conflict probe (`/tmp/conflict`) measured, per run, 0 writing
jobs that would wait for one tool call or a streamed answer, 0.13 for three parallel tools.
## 2. Semantics
### Unchanged
- Every logical commit keeps its own `seq`; history (`snapshotAsOf`, fork `asOf` copies, `createdAt`/`retiredAt`)
keeps per-commit boundaries.
- A commit's promise resolves only once it is durable and published; outcomes are delivered in line order (§5.1).
- Publication is in line order, deferred commits included (§5.2): telemetry events, Session listeners, the
`observeCommits()` dispatch, waits and aborts per commit; host observers of commit A run, and the continuations of
public waits A resolves start, before commit B is published (§5.3).
- No callback observes unstored state (§4). Memory adopts a commit at its publication.
- A commit adopted by Storage and then rejected stays published and rejects, as today (§3.4).
- Storage failure is fatal; callers are never told something storage does not hold.
### Changed (accepted)
1. A callback can run while an earlier commit's storage call is in flight. It runs until its first touch of a key an
unpublished commit wrote, waits until that commit's storage call settled and it is published, then continues. Code
before that touch runs early, assignments to caller variables included; acting on such values before the commit
resolves was already unsafe (a callback's own writes can fail to store today).
2. Cancellation is checked when a job starts; a job can start earlier.
3. If a batch fails fatally, later callbacks may already have run; their outcomes are `SessionFailed` (§5.1).
4. On a path that fails the Session, which commits survive differs: a stored prefix survives. Every caller's outcome
matches storage. On a bug path (adoption or an internal listener of part i throws) parts after i can be durable but
unpublished; their commits reject with `SessionFailed`; reopening recovers them. Likewise, once another error has
failed the Session, a call that settles afterwards, and the parts of a call not yet published when it fails, are
durable but unpublished and reject with `SessionFailed` (the first error); an `observeCommits()` consumer, such as a
replica, misses them until reopen.
5. Read results (`tx.task()`, `tx.conversation()`, `getTask()`, `runtime.entry()`, entries in published commits) are
frozen shared objects; mutating one throws (documented on `Tx`).
6. Prerequisite presentation changes (spec §8.2, §8.4 updated): a tool slot's `running` and a nested slot's final
arguments, and the generation's attempt, are stored like progress; after a crash a slot may show `pending` while
the task's checkpoint is `execute` (a safe rerun sets it again).
7. A wait whose commit is in flight when close begins ends with the closed error; the commit is still stored and
published by the drain (today this already happens when close lands before adopt).
8. Spec text to update: invariant 7, §4 (host listeners run while the line is held), §4.1 (holds leave at the last step
before `Storage.commit()`: now at logical admission), §10.1 (per-part outcomes), the `observeCommits` doc, the
`SessionImpl` class doc, the scheduler class invariant (decisions stay serialized; their storage overlaps).
## 3. Storage
### 3.1 `commit`
```ts
/**
* Persist `batches` in order, each as one ordinary atomic commit with its own seq, checked against the state the
* earlier ones left, in as few round trips as the backend can. Stops at the first batch Durable's own checks refuse:
* the batches before it are durable, it and the ones after are not. Calls `receive(index, seq)` once per durable batch,
* in order, synchronously, after all of them are durable and before the next commit starts. Resolves with the number of
* durable batches and the refusal, if any. Rejects for an unknown outcome (connection lost, statement or commit
* failed); `receive` calls made before the rejection still stand.
*/
commit(
batches: readonly (readonly StorageWrite[])[],
receive: (index: number, seq: Seq) => void,
context: Context,
): Promise<{ readonly stored: number; readonly refused?: unknown }>;
```
`receive` is a durability receipt, not publication: the Session publishes later (§5.3). It is Storage's only write
method (it replaced the earlier one-batch `commit(writes, adopt, context)`, and took its name): every backend implements
it natively. Tests and suites that write one batch directly use `commitWrites()` (`@earendil-works/pi-durable/testing`),
which throws the refusal.
Example: `[A, B, C, D]`, C refused. A and B are durable and published; C rejects with the refusal; D rejects with
`SessionFailed`; the Session fails. This is today's outcome (A and B stored, C fails, D never reaches storage).
### 3.2 Backends
A refusal is one of Durable's own checks (`checkGlobalIds`, `checkDocumentActions`, copy resolution), run against the
transaction's state before that batch writes anything. Any other error is an unknown outcome. No savepoints (Durable
Objects' SQL API does not allow them).
- SQLite: one transaction; per batch: read `next_seq`, run the checks, apply, advance; on a refusal stop and commit the
prefix. `prepareDocumentActions` and `candidateNextId` per batch.
- MemoryStorage: per batch `prepareCommit()` (checks and detaches, changing nothing) then apply; stop at the first
refusal (prefix semantics allow it).
- JSONL: check batch by batch against the in-memory state, append the prefix's commit markers in one write; a torn append
recovers a shorter complete prefix, which prefix semantics allow.
- IndexedDB: one `readwrite` transaction per call (metadata read once, written once at the end). Per batch every read
and check runs first (`checkGlobalIds`, `checkDocumentActions`, then `resolveCopies`, which reads each copy's source
against the state the earlier batches left), and only then its writes. A refusal stops issuing writes; the transaction
commits the prefix. An error of IndexedDB itself (a `DOMException`, or a failed request or transaction without an
error, also as a check's cause) aborts the transaction: the call rejects with nothing stored.
- StoreStorage (p3's primary storage): one series of the Chord store's commit queue per call
(`ChordStore.commitSeries`): one backend transaction for all batches, one seq and one outbox commit per batch, the
store's metadata (`last_seq`, `next_id`) written once per call. A batch's `DurableCommit.check(step)` (ids,
documents, copies through the step's reader, so it sees the earlier batches; the same reads a single commit makes)
runs before its writes; a throw refuses it and the series commits the prefix, unless one of the check's statements
failed (the store watches the check's executor), which rolls the series back and rejects, as does a throw of the
writes; a failed `COMMIT` is an unknown outcome that stops the store. Receipts run after `COMMIT`, before the next
step of the queue; the Session then publishes each commit in its own host batch (`publishing`). Memory per call is
the call's staged commits and one receipt entry per batch; the catalog is copied only if a batch changes a table
record.
A Storage may also implement `publishing?(publish: () => void, context: Context): void`: the Session calls it for each
part's publication and the Storage runs `publish` inside its own scope. StoreStorage implements it with its attached
host's `batch.within(...)`, so Chord sources over Durable's views stay marked in one batch per commit.
### 3.3 Wrappers
- `CachedStorage` applies write-through at publication (§5.3), not at the receipt. Its read rule becomes per key: a read
records `version(key)` at its start and keeps its result only if no commit published since wrote that key. Versions
exist only for keys with a read in flight (registered at read start, removed at read end), so the map is bounded by
reads in flight.
- `GuardedStorage.commit` guards the call but does not fail the Session itself on rejection; the writer does
(§3.4), after publishing the received parts.
### 3.4 Outcomes per logical commit
The writer keeps a ledger per call: which parts were received.
- Received: published (§5.3), then resolved through the fences.
- `commit` resolved with a refusal at part k: parts before k as received; part k rejects with the refusal; parts
after k and every queued job: `SessionFailed`; the Session fails after the received parts are published.
- `commit` rejected: received parts are published first, then reject with the error (adopted-then-rejected, as
today); unreceived parts reject with the error; the Session fails.
- Adoption or an internal listener of part i throws: part i as today (the Session fails after its storage call);
parts after i are not published and reject with `SessionFailed`.
## 4. The line
### 4.1 Mechanics
- The line runs one job at a time; callbacks never interleave with each other's synchronous code.
- A job with writes, after its callback and settlement succeed, takes the held changes it will store (`takeHeld`) and
joins the pending batch (logical admission). Its outcome is parked (§5.1).
- The writer runs off the line: it sends the pending batch with `commit` when no call is in flight and the previous
call's parts are published. A job waiting on a gate never blocks the writer.
- Backpressure: at most `MAX_OUTSTANDING` (initially 256) admitted jobs whose outcome is not delivered, writing or not;
beyond it the line waits.
- Unpublished write set: per key, the newest unpublished writer. Registered at logical admission (immediate commits) or
at callback end (deferred commits, which stay in it until their publication); removed at publication only if the key
still maps to that writer.
- Touch: every read or write a job makes checks its key. On a hit the job awaits the gate of the newest writer of that
key, then proceeds. A gate opens when that commit is published and its storage call settled (holds released).
### 4.2 Keys
| Key | Read by | Written by |
|---|---|---|
| `task:<id>` | `tx.task`, committed-task reads, every task record a scheduler region reads (decided task, wait members and their `on`, cycle walks) | task create or write |
| `owned:<node>` | every node a walk over live children expands (`#hasOwnedLive`, `#ownedLive`, `#walkDown`, `#waitCycle`, `ownedTasks`, `#idle` in a line job) | task creation and a task becoming terminal: every node from the task's parent up to and including the nearest live task or the root conversation (the nodes `#relist` lists or unlists), chain loaded at admission if needed; conversation creation under a task: that task's chain |
| `roots` | global idle in a line job (`#idle(undefined)`) | every task creation whose chain reaches an ownerless conversation (directly or through terminal tasks and conversations), and every task becoming terminal there (conservative: whether the root is listed can change under pending commits) |
| `intent` | every cancellation check (`#belowCancelled`, `#cancellingOwner`, including the empty-intent fast path, `#inScope`, `#nearestLiveAbove`) | abort marks (set, upgraded, cleared), a non-completed held or terminal outcome, restart marks |
| `conv:<id>` | `tx.conversation`, `#requireConversation`, edge loads | conversation creation |
| `entry:<id>` | `tx.entry(id)`, `storage.entry(conv, id)` | entry append |
| `entry-append:<conv>` | `latestEntryId`, `latestHeadMarker`, entry scans, context bounds | entry append (blind) |
| `sub:<id>` | `tx.submission`, settlement/placement resolution | submission create or write |
| `req:<conv>:<requestId>` | `submissionByRequest` | submission creation with a request ID |
| `doc:<address>` | `tx.doc`, `retireDoc`, cold loads, `conversationDocumentOnLine`, fork copies of the source | document create/change/retire/copy at the address; held changes the commit takes |
| `docscope:<scope>` | `scanDocuments`, task-document index reads | document creation/retirement in the scope (blind) |
| `*` | scans that cannot be keyed (`scanTasks`, `scanSubmissions` by status, `scanConversations`); `unloadDocuments`, close, version reload | conflicts with every unpublished write |
Blind writes conflict with reads of the key, not with each other (appends with distinct minted IDs, creations in one
scope). Created records' keys are ordinary keys: a minted ID can leak through a caller variable before publication, and a
read of it waits.
### 4.3 Liveness-only reads (not keyed)
A stale read that only delays work needs no key: a reservation pass not seeing a task a pending commit made runnable;
a waiter's check whether its dependencies are still live; the projection of immutable ownership fields (`owner`,
`conversationId`, `background`) during chain loading (not the rest of `#loadChain`, which installs settled nodes and
null edges after the check); a reservation pass skipping a task with an invocation without reading its record.
Cancellation decisions and terminalization are never liveness-only.
### 4.4 Line reads outside `tx`
`readOnLine(job)` becomes `readOnLine((storage) => job)`: the job gets a gated Storage view whose reads record keys and
wait on gates. All current `readOnLine` call sites (harness.ts, scheduler.ts runtime reads, submissions.ts, task-graph.ts,
view.ts, `captureContextBounds`) use it; the Session's line helpers (`latestEntryId`, `latestHeadMarker`,
`conversationDocumentOnLine`, cold loads) are gated too. Off-line reads keep using the ungated Storage.
## 5. Outcomes and publication
### 5.1 Outcome fences
Every job's outcome (commits with and without writes, `readOnLine` jobs, deferred commits, callback errors) is
delivered in line order. A Session-fatal failure makes every later outcome `SessionFailed`; a nonfatal rejection (a
callback's own error, cancellation, validation, `ReadOnlySession`) affects only its job. Fences hold outcomes, not the
line. Exemption: a scheduler-internal job without writes whose outcome goes only to scheduler code and whose touch set
is clean (no hit) delivers at once; it is equivalent to having run before every unpublished commit. A job with writes
(a reservation, a step that faults, a reconcile pass) is never exempt: its outcome waits for its own publication, so no
invocation starts on a reservation that is not durable.
### 5.2 Deferred commits
A deferred commit's callback runs and settles like any job; its memory adoption and publication are fenced like an
immediate commit's publication: they happen when every earlier unpublished commit is published. Its keys stay in the
unpublished write set until then. Holds are taken at logical admission, so a commit never stores a hold adopted after
it in line order.
### 5.3 Publishing a call's parts
After `commit` settles (or the received prefix is known, §3.4): publish part 0 (cache write-through, conversation
tails, adoption, Session listeners, host dispatch, waits), then a macrotask hop (`setImmediate` where available, else a
`MessageChannel` message), then part 1, and so on. The hop lets public wait continuations (`Submission.wait()`,
`waitForTask()`, `commit()`) of part i start before part i+1 is published. Tests use the public APIs.
### 5.4 Close, unload, holds, eviction
- Close: seal admission; run close listeners; signal every invocation (scheduler); let admitted callbacks finish,
gates included; drain pending and in-flight calls and their publication; join invocations and run `writeAtClose()`
through the writer; store remaining holds through the writer; end observers; close Storage.
- `unloadDocuments()`, version reloads (also nested inside a callback), hold timer stores: `*` barriers through the
writer; holds captured after the barrier.
- Documents with unpublished changes stay pinned.
## 6. Scheduler
Scheduler state changes at publication, as today. Inside a callback, decisions read it synchronously. Each decision is
restructured as a pure plan:
1. A decision region runs with a synchronous "recording" flag set; helpers record keys (§4.2) only while it is set
(the same helpers run at publication inside `#observe`, unrecorded).
2. The region reads, records, and computes a plan or an error, staging nothing and mutating no scheduler state.
3. If a recorded key has an unpublished writer: discard the plan (or error), await the gate, reload scopes
(`#loadScopes`), rebuild the overlay (`overlayOf`, `#links`), and run the region again. A waiting job holds the line,
so unpublished writes only shrink; re-runs converge.
4. After a clean check: apply the plan: stage writes, mutate scheduler sets, create or end invocations, run `migrate`,
take registry snapshots, report. None of these runs inside a region, so none repeats.
Sites to restructure (hardest first): `#reserve` (runnable iteration, abandoned marks, cut-off faults, `#resolve` with
`migrate` and reports, invocation creation); `#step`/`#decide` (registry refresh, `task_outdated` reports,
cancellation, `#end`); `#finalize` (rounds, overlay mutation, awaited `#settleOutcome` between rounds: one region per
round); `#validateWait`/`#waitCycle` (async member loads, cycle errors gated like plans); `abort`; `#commitState`
terminal (chain loads per overlay task, walk); `#reconcile` (intent walks, marks; it captures and clears work lists
before its commit: move the take inside, after the check). `abortConversation` and cascades scan with `*`.
Sweep: `#sweep` and `#scheduleSweep` do not drop nodes loaded by a job whose commit is unpublished; chain loads record
the nodes they install for their job, released at that commit's publication or failure.
Later, the ownership index can move into the Session behind `tx` reads with the same keys.
## 7. Bounds
- Outstanding jobs: at most `MAX_OUTSTANDING`, a count, not bytes: pending writes are bounded by what one commit can
hold (tool output is bounded by output limits), times the count.
- Unpublished write set: one entry per key per newest writer, removed at publication.
- Cache versions: only keys with a read in flight.
- Fences, gates, ledgers: settled at publication or Session failure; nothing outlives close.
- Existing: record cache (conversations 4096, submissions 256; tasks: every live one, the ended ones a waiting or just-resumed task names, plus 256 ended), conversation tails (4096 IDs, 256 markers),
task-document index (live tasks and their documents), kept context ranges (busy or within retention; an idle range
past its expiry is dropped when an entry would extend it; expiry fixed at idle start, the timer uses the current
setting: document and test), document cache (retention).
## 8. Tests
- `commit` per backend (conformance for every backend): prefix stored, refusal index, seq per batch, copies
against earlier batches' state, JSONL torn append; IndexedDB: a copy refused mid-call keeps the prefix, an IndexedDB
error in a check or a write rejects with nothing stored; StoreStorage: one backend transaction per call; Chord's
`commitSeries`: refusal, a failed check statement and a throwing write roll back, a throwing receipt stops the store.
- Gating per key class, with deterministic interleavings: records; ancestor chain membership (live P → terminal B →
terminal A → empty C, creation in C vs P terminal); global idle with empty roots; pending abort mark vs child step
(empty-intent fast path); concurrent opposite waits; leaked entry ID read by `tx.entry`; request IDs; absent
addresses; held documents (hold created after a reader prepared); `*` barriers; `readOnLine` read after an
un-awaited commit.
- Outcome fences: no-write, read, deferred, callback errors in line order; nonfatal rejections do not poison later jobs;
fatal failure poisons later outcomes.
- Deferred publication behind a pending immediate commit, and when that commit fails.
- Failures per part: refusal, rejection after receipts (received parts published, then rejected), adoption failure.
- Publication order through public waits: A's `Submission.wait()`/`waitForTask()` continuation sees B unpublished.
- Close during in-flight calls with an invocation waiting on its signal; unload, version reload, hold timer during
flight; backpressure on all outstanding jobs.
- Scheduler retries: `migrate`, registry snapshots, reports and cleanup run once across re-runs; sweep vs chain loads.
- Randomized interleavings: random commits over overlapping keys, random latency and refusals; storage equals the stored
commits replayed in seq order, no callback observed unpublished state (instrumented oracle), outcomes match storage.
- Prerequisites: tool crash at both sides of the intent commit (sibling flushing presentation first); kept range
installed after a publication between return and install; live retention change; cache eviction and close; tails
eviction, fork markers.
- Bounds: outstanding, write sets, versions, fences return to zero when idle.
- Existing suites: durable (both retention modes), workers.
## 9. Benchmark
Conc probe at 0/1/4/20 ms with 1/8/32/64/128 conversations, one tool, three parallel tools, streaming; dpetrou's repro
as is and with warm documents. Report throughput, p50/p95 run latency, batch sizes, gates per run, fence delay.