pi-mono/packages/durable/test/session-conversation-tails.test.ts
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

81 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { idFromNumber } from "../src/ids.ts";
import { ConversationTails, type HeadMarker } from "../src/session/conversation-tails.ts";
import type { ConversationId, EntryId, EntryRecord, StorageWrite } from "../src/types.ts";
const conversation = (id: number) => idFromNumber<ConversationId>(id);
const entryId = (id: number) => idFromNumber<EntryId>(id);
function entry(id: number, conversationId: number, head?: number): StorageWrite {
const value: EntryRecord = {
id: entryId(id),
conversationId: conversation(conversationId),
kind: "note",
...(head === undefined ? {} : { head: entryId(head) }),
};
return { type: "entry", value };
}
const never = (): Promise<never> => Promise.reject(new Error("read"));
describe("conversation tails", () => {
it("reads once, then follows the commits", async () => {
const tails = new ConversationTails();
let reads = 0;
const read = async () => {
reads++;
return entryId(5);
};
expect(await tails.latest(conversation(1), read)).toBe(5);
expect(await tails.latest(conversation(1), read)).toBe(5);
expect(reads).toBe(1);
tails.apply([entry(9, 1)]);
expect(await tails.latest(conversation(1), never)).toBe(9);
});
it("knows a new root conversation is empty, and a fork only once read or written", async () => {
const tails = new ConversationTails();
tails.apply([
{ type: "conversation", value: { id: conversation(2) } },
{
type: "conversation",
value: { id: conversation(3), parent: { conversationId: conversation(2), at: entryId(4) } },
},
]);
expect(await tails.latest(conversation(2), never)).toBeUndefined();
expect(await tails.marker(conversation(2), undefined, never)).toBeUndefined();
expect(await tails.latest(conversation(3), async () => entryId(4))).toBe(4);
});
it("answers a cutoff from the newest marker only at or after it", async () => {
const tails = new ConversationTails();
tails.apply([{ type: "conversation", value: { id: conversation(1) } }, entry(10, 1), entry(11, 1, 10)]);
const marker = (await tails.marker(conversation(1), undefined, never)) as HeadMarker;
expect(marker.id).toBe(11);
expect((await tails.marker(conversation(1), entryId(12), never))?.id).toBe(11);
// An older cutoff may have an older marker: read, and not kept.
let reads = 0;
const older = async () => {
reads++;
return undefined;
};
expect(await tails.marker(conversation(1), entryId(10), older)).toBeUndefined();
expect(await tails.marker(conversation(1), entryId(10), older)).toBeUndefined();
expect(reads).toBe(2);
expect((await tails.marker(conversation(1), undefined, never))?.id).toBe(11);
});
it("keeps a bounded number of conversations, fewer with markers", () => {
const tails = new ConversationTails();
for (let id = 1; id <= 5000; id++) tails.apply([entry(2 * id, id), entry(2 * id + 1, id, 2 * id)]);
expect(tails.size).toBe(4096);
expect(tails.markers).toBe(256);
});
it("freezes the markers it keeps", () => {
const tails = new ConversationTails();
const write = entry(2, 1, 1);
tails.apply([write]);
expect(Object.isFrozen((write as { value: object }).value)).toBe(true);
});
});