mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
296 lines
22 KiB
Markdown
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.
|