chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
593
third_party/pi/packages/durable/test/session-document-retention.test.ts
vendored
Normal file
593
third_party/pi/packages/durable/test/session-document-retention.test.ts
vendored
Normal file
@@ -0,0 +1,593 @@
|
||||
// The Session's document cache is bounded by use: unattached documents leave it after the retention period and load
|
||||
// from Storage again; documents with a watch or a document state never leave it.
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type AgentEvent,
|
||||
type ConversationId,
|
||||
defineDoc,
|
||||
defineDocFamily,
|
||||
defineTool,
|
||||
type HarnessSettings,
|
||||
MemoryStorage,
|
||||
type StorageWrite,
|
||||
watchEvents,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { SessionImpl, setDefaultDocumentRetention } from "../src/session/session.ts";
|
||||
import { chatSetup, openChat } from "./chat-support.ts";
|
||||
import { addTool } from "./harness-support.ts";
|
||||
import { ControlledStorage, context, createConversation } from "./session-support.ts";
|
||||
import { deferred, eventually } from "./task-support.ts";
|
||||
|
||||
type Counter = { count: number };
|
||||
const CounterDoc = defineDoc<Counter>({
|
||||
kind: "test.retention.counter",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ count: 0 }),
|
||||
});
|
||||
const RewindableDoc = defineDoc<Counter>({
|
||||
kind: "test.retention.rewindable",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ count: 0 }),
|
||||
});
|
||||
/** A third-party conversation family: one member per event. */
|
||||
const EventDoc = defineDocFamily<{ seq: number }, number>({
|
||||
kind: "test.retention.event",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
family: true,
|
||||
initial: (seq) => ({ seq }),
|
||||
});
|
||||
const SessionCounterDoc = defineDoc<Counter>({
|
||||
kind: "test.retention.session-counter",
|
||||
version: 1,
|
||||
scope: "session",
|
||||
initial: () => ({ count: 0 }),
|
||||
});
|
||||
|
||||
/** A session document whose checkpoint predicate throws, so the commit that changes it fails while it settles. */
|
||||
const Failing = defineDoc<Counter>({
|
||||
kind: "test.retention.failing",
|
||||
version: 1,
|
||||
scope: "session",
|
||||
initial: () => ({ count: 0 }),
|
||||
checkpointWhen: () => {
|
||||
throw new Error("checkpoint failed");
|
||||
},
|
||||
});
|
||||
|
||||
/** The default retention of this run, as the setup file set it. */
|
||||
const defaultForRun =
|
||||
process.env.PI_DURABLE_DOCUMENT_RETENTION_MS === undefined
|
||||
? 600_000
|
||||
: Number(process.env.PI_DURABLE_DOCUMENT_RETENTION_MS);
|
||||
|
||||
/** A conversation document any Harness test can read. */
|
||||
const TestDoc = defineDoc<Counter>({
|
||||
kind: "test.retention.harness",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ count: 0 }),
|
||||
});
|
||||
|
||||
/** A Session with a controllable clock and retention. */
|
||||
function open(retentionMs: number) {
|
||||
const clock = { now: 1_000 };
|
||||
const storage = new ControlledStorage();
|
||||
const session = new SessionImpl(
|
||||
storage,
|
||||
() => clock.now,
|
||||
() => retentionMs,
|
||||
);
|
||||
const sizes = () => session.documentCacheSizes;
|
||||
return { storage, session, clock, sizes };
|
||||
}
|
||||
|
||||
/** Let the line run what is queued on it: an eviction job, then return. */
|
||||
async function settleLine(session: SessionImpl): Promise<void> {
|
||||
await session.readOnLine(async () => {});
|
||||
}
|
||||
|
||||
async function bump(session: SessionImpl, conversationId: ConversationId, by = 1): Promise<void> {
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(CounterDoc, conversationId)).count += by;
|
||||
}, context);
|
||||
}
|
||||
|
||||
describe("document cache retention", () => {
|
||||
it("evicts an unattached document once its retention passed, and loads it again with the same value", async () => {
|
||||
const { session, storage, clock, sizes } = open(60_000);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId, 3);
|
||||
expect(sizes().cached).toBeGreaterThan(0);
|
||||
// Not yet: inside the retention period.
|
||||
clock.now += 59_000;
|
||||
await bump(session, conversationId, 0);
|
||||
await settleLine(session);
|
||||
expect(sizes().cached).toBeGreaterThan(0);
|
||||
// Past it, with no use since: the next commit evicts it.
|
||||
clock.now += 61_000;
|
||||
await session.commit(async () => {}, context);
|
||||
await session.commit(async (tx) => {
|
||||
await tx.doc(SessionCounterDoc);
|
||||
}, context);
|
||||
await settleLine(session);
|
||||
const reads = storage.documentReadCount;
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 3 });
|
||||
expect(storage.documentReadCount).toBeGreaterThan(reads);
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("evicts with retention 0 right after use, on the line", async () => {
|
||||
const { session, sizes } = open(0);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
await eventually(async () => {
|
||||
await settleLine(session);
|
||||
return sizes().cached === 0;
|
||||
});
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 1 });
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("never evicts a document a watch or a document state observes, and evicts it once the last detaches", async () => {
|
||||
const { session, storage, clock, sizes } = open(1_000);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
const watch = (await session.watchDoc(CounterDoc, conversationId, context))!;
|
||||
const state = (await session.documentState(CounterDoc, conversationId, context))!;
|
||||
const seen: number[] = [];
|
||||
watch.start(async (value) => {
|
||||
seen.push(value?.count ?? -1);
|
||||
});
|
||||
clock.now += 1_000_000;
|
||||
await bump(session, conversationId);
|
||||
await settleLine(session);
|
||||
// Still cached: a read long after its retention passed loads nothing from Storage.
|
||||
expect(state.value?.count).toBe(2);
|
||||
await eventually(() => seen.includes(2));
|
||||
const reads = storage.documentReadCount;
|
||||
clock.now += 1_000_000;
|
||||
await session.commit(async (tx) => {
|
||||
await tx.doc(SessionCounterDoc);
|
||||
}, context);
|
||||
await settleLine(session);
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 2 });
|
||||
expect(storage.documentReadCount).toBe(reads);
|
||||
await watch.stop();
|
||||
clock.now += 1_000_000;
|
||||
await bump(session, conversationId);
|
||||
await settleLine(session);
|
||||
expect(state.value?.count).toBe(3);
|
||||
state.dispose();
|
||||
clock.now += 1_000_000;
|
||||
await session.commit(async () => {}, context);
|
||||
await session.commit(async (tx) => {
|
||||
await tx.doc(SessionCounterDoc);
|
||||
}, context);
|
||||
await settleLine(session);
|
||||
// The session document was just used; the counter is gone.
|
||||
expect(sizes().cached).toBe(1);
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("never takes a tracker from a transaction that holds it", async () => {
|
||||
const { session, clock } = open(0);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
const inside = deferred();
|
||||
const release = deferred();
|
||||
const commit = session.commit(async (tx) => {
|
||||
const draft = await tx.doc(CounterDoc, conversationId);
|
||||
inside.resolve();
|
||||
await release.promise;
|
||||
draft.count += 10;
|
||||
}, context);
|
||||
await inside.promise;
|
||||
// Retention passes while the transaction holds the document; eviction waits on the line behind it.
|
||||
clock.now += 1_000_000;
|
||||
release.resolve();
|
||||
await commit;
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 11 });
|
||||
await bump(session, conversationId);
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 12 });
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("writes after a reload continue the stored deltas and bases", async () => {
|
||||
const { session, storage } = open(0);
|
||||
const conversationId = await createConversation(session);
|
||||
const writes: StorageWrite[][] = [];
|
||||
const commit = storage.commit.bind(storage);
|
||||
storage.commit = (batch: readonly StorageWrite[], commitContext: Context) => {
|
||||
writes.push([...batch]);
|
||||
return commit(batch, commitContext);
|
||||
};
|
||||
for (let n = 0; n < 5; n++) {
|
||||
await bump(session, conversationId);
|
||||
await settleLine(session);
|
||||
}
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 5 });
|
||||
// Every bump after the first is a change of the same incarnation, never a second creation.
|
||||
const creations = writes
|
||||
.flat()
|
||||
.filter((write) => write.type === "document.create" && write.record.kind === CounterDoc.definition.kind);
|
||||
expect(creations).toHaveLength(1);
|
||||
await session.close(context);
|
||||
// And a fresh Session over the same storage reads the same value.
|
||||
const reopened = new SessionImpl(storage.reopen(), Date.now, () => 0);
|
||||
expect(await reopened.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 5 });
|
||||
await reopened.close(context);
|
||||
});
|
||||
|
||||
it("keeps history and forks of rewindable documents after eviction", async () => {
|
||||
const { session } = open(0);
|
||||
const conversationId = await createConversation(session);
|
||||
const first = await session.commit(async (tx) => {
|
||||
(await tx.doc(RewindableDoc, conversationId)).count = 1;
|
||||
return (await tx.appendEntry(conversationId, { kind: "note", data: null })).id;
|
||||
}, context);
|
||||
await settleLine(session);
|
||||
const second = await session.commit(async (tx) => {
|
||||
(await tx.doc(RewindableDoc, conversationId)).count = 2;
|
||||
return (await tx.appendEntry(conversationId, { kind: "note", data: null })).id;
|
||||
}, context);
|
||||
await settleLine(session);
|
||||
expect(await session.snapshotAsOf(RewindableDoc, conversationId, first, context)).toEqual({ count: 1 });
|
||||
expect(await session.snapshotAsOf(RewindableDoc, conversationId, second, context)).toEqual({ count: 2 });
|
||||
const fork = await session.commit(
|
||||
async (tx) => (await tx.forkConversation(conversationId, first, { ownership: { kind: "ownerless" } })).id,
|
||||
context,
|
||||
);
|
||||
await settleLine(session);
|
||||
expect(await session.snapshot(RewindableDoc, fork, context)).toEqual({ count: 1 });
|
||||
expect(await session.snapshot(RewindableDoc, conversationId, context)).toEqual({ count: 2 });
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("bounds a third-party family with a member per event", async () => {
|
||||
const { session, sizes } = open(0);
|
||||
const conversationId = await createConversation(session);
|
||||
for (let seq = 0; seq < 200; seq++) {
|
||||
await session.commit(async (tx) => {
|
||||
await tx.doc(EventDoc, conversationId, String(seq), seq);
|
||||
}, context);
|
||||
}
|
||||
await eventually(async () => {
|
||||
await settleLine(session);
|
||||
return sizes().cached === 0;
|
||||
});
|
||||
expect(await session.snapshot(EventDoc, conversationId, "123", context)).toEqual({ seq: 123 });
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("keeps the cache of a long-lived Session bounded across many conversations", async () => {
|
||||
const { session, clock, sizes } = open(10_000);
|
||||
for (let n = 0; n < 300; n++) {
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
clock.now += 1_000;
|
||||
}
|
||||
await settleLine(session);
|
||||
// Only what was used in the last 10 s, about ten conversations' worth.
|
||||
expect(sizes().cached).toBeLessThanOrEqual(12);
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("never changes the outcome of a commit when the clock throws", async () => {
|
||||
const clock = { now: 1_000, fail: false };
|
||||
const storage = new ControlledStorage();
|
||||
const session = new SessionImpl(
|
||||
storage,
|
||||
() => {
|
||||
if (clock.fail) throw new Error("clock");
|
||||
return clock.now;
|
||||
},
|
||||
() => 0,
|
||||
);
|
||||
const conversationId = await createConversation(session);
|
||||
clock.fail = true;
|
||||
await bump(session, conversationId, 42);
|
||||
clock.fail = false;
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 42 });
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("evicts after a commit whose settlement fails", async () => {
|
||||
const { session, clock, sizes } = open(10_000);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
// Created first: the predicate runs only for changes to an existing document.
|
||||
await session.commit((tx) => tx.doc(Failing).then(() => undefined), context);
|
||||
await settleLine(session);
|
||||
// Retention passes inside a commit whose checkpoint predicate throws while it settles.
|
||||
await expect(
|
||||
session.commit(async (tx) => {
|
||||
(await tx.doc(Failing)).count++;
|
||||
clock.now += 1_000_000;
|
||||
}, context),
|
||||
).rejects.toThrow("checkpoint failed");
|
||||
expect(sizes().cached).toBe(0);
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("evicts promptly once the retention is shortened", async () => {
|
||||
const retention = { ms: 600_000 };
|
||||
const storage = new ControlledStorage();
|
||||
const clock = { now: 1_000 };
|
||||
const session = new SessionImpl(
|
||||
storage,
|
||||
() => clock.now,
|
||||
() => retention.ms,
|
||||
);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
retention.ms = 0;
|
||||
clock.now += 2_000;
|
||||
// A read, no commit: the sweep pending for ten minutes from now moves up.
|
||||
await session.snapshot(CounterDoc, conversationId, context);
|
||||
await eventually(async () => {
|
||||
await settleLine(session);
|
||||
return session.documentCacheSizes.cached === 0;
|
||||
});
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("does not keep a replacement cached for an observer of the retired incarnation", async () => {
|
||||
const { session, sizes } = open(0);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
const state = (await session.documentState(CounterDoc, conversationId, context))!;
|
||||
await session.commit((tx) => tx.retireDoc(CounterDoc, conversationId), context);
|
||||
expect(state.value).toBeNull();
|
||||
await bump(session, conversationId, 5);
|
||||
await eventually(async () => {
|
||||
await settleLine(session);
|
||||
return sizes().cached === 0;
|
||||
});
|
||||
expect(await session.snapshot(CounterDoc, conversationId, context)).toEqual({ count: 5 });
|
||||
state.dispose();
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("leaves no pin behind when an observer's load loses a race with close", async () => {
|
||||
const { session, storage } = open(0);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
await eventually(async () => {
|
||||
await settleLine(session);
|
||||
return session.documentCacheSizes.cached === 0;
|
||||
});
|
||||
const gate = storage.holdFindDocument();
|
||||
const attaching = session.documentState(CounterDoc, conversationId, context);
|
||||
await gate.entered;
|
||||
const closing = session.close(context);
|
||||
gate.release();
|
||||
await expect(attaching).rejects.toThrow();
|
||||
await closing;
|
||||
expect(session.documentCacheSizes).toMatchObject({ cached: 0, idle: 0, attached: 0 });
|
||||
});
|
||||
|
||||
it("evicts on its timer without a commit, and tolerates a detach called twice", async () => {
|
||||
const storage = new ControlledStorage();
|
||||
const session = new SessionImpl(storage, Date.now, () => 20);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
const state = (await session.documentState(CounterDoc, conversationId, context))!;
|
||||
state.dispose();
|
||||
state.dispose();
|
||||
const until = Date.now() + 2_000;
|
||||
while (session.documentCacheSizes.cached > 0 && Date.now() < until)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(session.documentCacheSizes.cached).toBe(0);
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("migrates a document again after its eviction", async () => {
|
||||
const V1 = defineDoc<{ count: number }>({
|
||||
kind: "test.retention.migrating",
|
||||
version: 1,
|
||||
scope: "session",
|
||||
initial: () => ({ count: 1 }),
|
||||
});
|
||||
let migrations = 0;
|
||||
const V2 = defineDoc<{ count: number; migrated: boolean }>({
|
||||
kind: "test.retention.migrating",
|
||||
version: 2,
|
||||
scope: "session",
|
||||
initial: () => ({ count: 0, migrated: true }),
|
||||
migrate: (value) => {
|
||||
migrations++;
|
||||
return { count: (value as { count: number }).count, migrated: true };
|
||||
},
|
||||
});
|
||||
const { session } = open(0);
|
||||
await session.commit((tx) => tx.doc(V1).then(() => undefined), context);
|
||||
await settleLine(session);
|
||||
expect(await session.snapshot(V2, context)).toEqual({ count: 1, migrated: true });
|
||||
await settleLine(session);
|
||||
expect(await session.snapshot(V2, context)).toEqual({ count: 1, migrated: true });
|
||||
// Not written back, so each cold load migrates again.
|
||||
expect(migrations).toBe(2);
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("treats a retention that is not a number of milliseconds as the default, and Infinity as never", async () => {
|
||||
// The default as shipped, also in the suite run that forces another.
|
||||
setDefaultDocumentRetention(600_000);
|
||||
onTestFinished(() => setDefaultDocumentRetention(defaultForRun));
|
||||
for (const retention of [Number.NaN, -5, "soon" as unknown as number]) {
|
||||
const { session, clock, sizes } = open(retention);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
clock.now += 60_000;
|
||||
await session.commit(async () => {}, context);
|
||||
await settleLine(session);
|
||||
// Not dropped at once, as a retention of 0 would: the ten-minute default holds it.
|
||||
expect(sizes().cached).toBe(1);
|
||||
await session.close(context);
|
||||
}
|
||||
const { session, clock, sizes } = open(Number.POSITIVE_INFINITY);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
clock.now += 1e15;
|
||||
await session.commit(async () => {}, context);
|
||||
await settleLine(session);
|
||||
expect(sizes().cached).toBe(1);
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("keeps no timer where timers cannot be unreferenced, and evicts on commits", async () => {
|
||||
const setTimer = globalThis.setTimeout;
|
||||
let timers = 0;
|
||||
globalThis.setTimeout = ((callback: () => void, ms?: number) => {
|
||||
timers++;
|
||||
const timer = setTimer(callback, ms);
|
||||
// As in Cloudflare Workers: a plain number, no `unref`.
|
||||
return Number(timer as unknown as number) as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout;
|
||||
try {
|
||||
const { session, clock, sizes } = open(1_000);
|
||||
const conversationId = await createConversation(session);
|
||||
await bump(session, conversationId);
|
||||
await bump(session, conversationId);
|
||||
// Tried once, then never again.
|
||||
expect(timers).toBeLessThanOrEqual(1);
|
||||
clock.now += 10_000;
|
||||
await session.commit(async () => {}, context);
|
||||
await settleLine(session);
|
||||
expect(sizes().cached).toBe(0);
|
||||
await session.close(context);
|
||||
} finally {
|
||||
globalThis.setTimeout = setTimer;
|
||||
}
|
||||
});
|
||||
|
||||
it("follows a Harness settings change at the next use", async () => {
|
||||
setDefaultDocumentRetention(600_000);
|
||||
onTestFinished(() => setDefaultDocumentRetention(defaultForRun));
|
||||
const setup = chatSetup();
|
||||
const settings: { -readonly [K in keyof HarnessSettings]: HarnessSettings[K] } = setup.settings;
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.commit(async (tx) => {
|
||||
(await tx.doc(TestDoc, root.id)).count++;
|
||||
}, context);
|
||||
const sizesOf = () => (harness as unknown as SessionImpl).documentCacheSizes;
|
||||
expect(sizesOf().cached).toBeGreaterThan(0);
|
||||
settings.documentRetentionMs = 0;
|
||||
await harness.snapshot(TestDoc, root.id, context);
|
||||
await eventually(async () => {
|
||||
await (harness as unknown as SessionImpl).readOnLine(async () => {});
|
||||
return sizesOf().cached === 0;
|
||||
});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("gives the same events with documents evicted right after use as with documents kept", async () => {
|
||||
const run = async (documentRetentionMs: number): Promise<string[]> => {
|
||||
const setup = chatSetup();
|
||||
setup.settings.documentRetentionMs = documentRetentionMs;
|
||||
setup.settings.progress = { outputIntervalMs: 0 };
|
||||
addTool(
|
||||
setup.registry,
|
||||
defineTool({
|
||||
name: "steps",
|
||||
description: "Streams three steps",
|
||||
parameters: Type.Object({}),
|
||||
execute: async (_args, api) => {
|
||||
for (const step of ["a\n", "b\n", "c\n"]) {
|
||||
api.output(step);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
return {};
|
||||
},
|
||||
}),
|
||||
);
|
||||
setup.faux.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("steps", {}, { id: "s1" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage([fauxText("done")]),
|
||||
]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
const stream = await watchEvents(harness, root.id, context);
|
||||
const events: AgentEvent[] = [];
|
||||
stream.start(async (batch) => {
|
||||
events.push(...batch);
|
||||
});
|
||||
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
|
||||
await harness.waitForIdle(context);
|
||||
await stream.stop();
|
||||
await harness.close(context);
|
||||
// Ids and times differ between runs; the sequence of event types and tool outputs must not.
|
||||
return events.map((event) =>
|
||||
event.type === "tool_execution_update" ? `update:${JSON.stringify(event.output)}` : event.type,
|
||||
);
|
||||
};
|
||||
expect(await run(0)).toEqual(await run(600_000));
|
||||
});
|
||||
|
||||
it("keeps a reloaded newer version cached while an observer of the older one is attached", async () => {
|
||||
const V1 = defineDoc<Counter>({
|
||||
kind: "test.retention.versioned",
|
||||
version: 1,
|
||||
scope: "session",
|
||||
initial: () => ({ count: 3 }),
|
||||
});
|
||||
const V2 = defineDoc<Counter & { v2: true }>({
|
||||
kind: "test.retention.versioned",
|
||||
version: 2,
|
||||
scope: "session",
|
||||
initial: () => ({ count: 0, v2: true }),
|
||||
migrate: (value) => ({ count: (value as Counter).count, v2: true }),
|
||||
});
|
||||
const { session, sizes } = open(0);
|
||||
await session.commit((tx) => tx.doc(V1).then(() => undefined), context);
|
||||
const state = (await session.documentState(V1, context))!;
|
||||
expect(await session.snapshot(V2, context)).toEqual({ count: 3, v2: true });
|
||||
await settleLine(session);
|
||||
expect(sizes().cached).toBe(1);
|
||||
state.dispose();
|
||||
await eventually(async () => {
|
||||
await settleLine(session);
|
||||
return sizes().cached === 0;
|
||||
});
|
||||
await session.close(context);
|
||||
});
|
||||
|
||||
it("queues one sweep job, not one per document, while a commit holds the line", async () => {
|
||||
const { session } = open(0);
|
||||
const conversationIds: ConversationId[] = [];
|
||||
for (let n = 0; n < 50; n++) {
|
||||
const id = await createConversation(session);
|
||||
await bump(session, id);
|
||||
conversationIds.push(id);
|
||||
}
|
||||
await eventually(async () => {
|
||||
await settleLine(session);
|
||||
return session.documentCacheSizes.cached === 0;
|
||||
});
|
||||
const before = session.documentCacheSizes.sweeps;
|
||||
// One commit loads 50 evicted documents from storage, each touched as it loads; the sweep job stays pending while
|
||||
// the line is held.
|
||||
await session.commit(async (tx) => {
|
||||
for (const id of conversationIds) {
|
||||
(await tx.doc(CounterDoc, id)).count++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}, context);
|
||||
await settleLine(session);
|
||||
expect(session.documentCacheSizes.cached).toBe(0);
|
||||
expect(session.documentCacheSizes.sweeps - before).toBeLessThanOrEqual(2);
|
||||
await session.close(context);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user