pi-mono/packages/durable/test/session-support.ts
Mario Zechner 6c2fd73b0c feat(durable): deferred storage of throttled commits
A commit may be marked deferrable ({ persistence: { kind: "deferred", withinMs? } }) on Session, Conversation,
TaskRuntime and a tool's api.commit. When every write changes an existing conversation document with history
"latest" and fork "initial", it is adopted in memory and shown to watches at once, and stored with the next commit
that reads or writes the document, before the document leaves memory, at close, or within withinMs (default
settings.progress.persistWithinMs, 1000 ms). The generation partial throttle and the tool output throttle use it.

subscribeCommits() gets these commits only once stored, with exactly what was stored; internal observers get
memory-only publications. Spec §1/§13 changed, new §4.1. 32 streams over SQLite: storage transactions 562/s -> 99/s,
disk 14.3 -> 4.9 MB/s, CPU 29% -> 22% of one core. A crash loses up to about 1 s of streamed content.
2026-10-24 17:49:30 +00:00

180 lines
5.8 KiB
TypeScript

import type { Context } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
type CommitChange,
type CommitPublication,
type ConversationId,
type DocumentAddress,
type DocumentCommitChange,
type DocumentId,
type DocumentPoint,
type DocumentRecord,
type Id,
MemoryStorage,
type Seq,
type Session,
type StorageWrite,
} from "@earendil-works/pi-durable";
import { type ObservedCommit, SessionImpl } from "../src/session/session.ts";
export const context: Context = BACKGROUND_CONTEXT;
type Deferred = { readonly promise: Promise<void>; readonly resolve: () => void };
function deferred(): Deferred {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
/** A gate that holds calls until released and reports when the first held call arrives. */
export type Gate = {
readonly entered: Promise<void>;
release(): void;
};
/** Memory storage with observable commits, held calls, and injected commit failures. */
export class ControlledStorage extends MemoryStorage {
/** Exact borrowed batches admitted by Session. */
readonly admittedCommits: (readonly StorageWrite[])[] = [];
/** Detached batches for value assertions. */
readonly commits: (readonly StorageWrite[])[] = [];
mintCount = 0;
documentReadCount = 0;
#commitGate: { gate: Deferred; entered: Deferred } | undefined;
#findGate: { gate: Deferred; entered: Deferred } | undefined;
#commitFailure: Error | undefined;
holdCommits(): Gate {
const held = { gate: deferred(), entered: deferred() };
this.#commitGate = held;
return { entered: held.entered.promise, release: () => this.#release("commit", held) };
}
holdFindDocument(): Gate {
const held = { gate: deferred(), entered: deferred() };
this.#findGate = held;
return { entered: held.entered.promise, release: () => this.#release("find", held) };
}
/** Simulate a crash during the held commit: it never reaches storage, and later commits proceed. */
crash(): void {
this.#commitGate = undefined;
}
failNextCommit(error: Error): void {
this.#commitFailure = error;
}
/** Reopen after `close()`, as a fresh process would reopen the same database, keeping every committed record. */
reopen(): this {
(this as unknown as { closed: boolean }).closed = false;
return this;
}
#release(kind: "commit" | "find", held: { gate: Deferred }): void {
if (kind === "commit" && this.#commitGate === held) this.#commitGate = undefined;
if (kind === "find" && this.#findGate === held) this.#findGate = undefined;
held.gate.resolve();
}
override async commit(
writes: readonly StorageWrite[],
adopt: (seq: Seq) => void,
commitContext: Context,
): Promise<Seq> {
this.admittedCommits.push(writes);
this.commits.push(structuredClone(writes));
const held = this.#commitGate;
if (held !== undefined) {
held.entered.resolve();
await held.gate.promise;
}
const failure = this.#commitFailure;
if (failure !== undefined) {
this.#commitFailure = undefined;
throw failure;
}
return super.commit(writes, adopt, commitContext);
}
override mintId<I extends Id<string>>(): Promise<I> {
this.mintCount++;
return super.mintId<I>();
}
override document(id: DocumentId, at: DocumentPoint, callContext: Context) {
this.documentReadCount++;
return super.document(id, at, callContext);
}
override async findDocument(
address: DocumentAddress,
at: DocumentPoint,
callContext: Context,
): Promise<DocumentRecord | undefined> {
const held = this.#findGate;
if (held !== undefined) {
held.entered.resolve();
await held.gate.promise;
}
return super.findDocument(address, at, callContext);
}
}
/** Session kernel plus its controlled storage and every committed publication. */
export function openTestSession(): {
readonly storage: ControlledStorage;
readonly session: SessionImpl;
readonly publications: CommitPublication[];
} {
const storage = new ControlledStorage();
// These tests pin the cache's identities and loads, so documents stay as long as the default keeps them.
const session = new SessionImpl(storage, Date.now, () => 600_000);
const publications: CommitPublication[] = [];
session.subscribeCommits((publication) => {
publications.push(publication);
});
return { storage, session, publications };
}
export function documentChanges(publication: {
readonly changes: readonly CommitChange[];
}): readonly Extract<DocumentCommitChange, { readonly type: "document" }>[] {
return publication.changes.filter(
(change): change is Extract<DocumentCommitChange, { readonly type: "document" }> => change.type === "document",
);
}
/**
* Observe every commit as memory adopts it, deferred ones before they are stored, as views and document watches do;
* `subscribeCommits()` sees only what was stored.
*/
export function observeCommits(
session: Session,
listener: (publication: ObservedCommit, context: Context) => void,
): () => void {
// `observeCommits()` is internal, so the public type lacks it; every Session and Harness is a `SessionImpl`.
return (session as unknown as SessionImpl).observeCommits(listener);
}
export function documentCopyChanges(
publication: CommitPublication,
): readonly Extract<DocumentCommitChange, { readonly type: "document.copy" }>[] {
return publication.changes.filter(
(change): change is Extract<DocumentCommitChange, { readonly type: "document.copy" }> =>
change.type === "document.copy",
);
}
/** Create one conversation and return its ID. */
export async function createConversation(session: SessionImpl): Promise<ConversationId> {
return session.commit(async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, context);
}
/** Resolve after pending microtasks and one macrotask turn. */
export function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}