diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b4d27b9425e..d910dc0ae31 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1502,6 +1502,7 @@ "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", + "responses-state-snapshot-select.test.ts": "responses", "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", diff --git a/src/responses/state.ts b/src/responses/state.ts index 21241e9faef..892ba556e09 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -19,7 +19,7 @@ import { writeResponseSpillDurably, } from "./spill-store"; import { collectReferencedSpillFileNames, snapshotReferencedSpillFileNames } from "./state/spill-inspect"; -import { selectSnapshotEntries } from "./state/snapshot-select"; +import { selectSnapshotEntries, snapshotPayload } from "./state/snapshot-select"; import { clientCarriedPrefixLength, providerIssuedIdentity } from "./state/replay-fingerprint"; export type { ResponseStateTempRecoveryResult, ResponseStateTempRecoveryOptions } from "./state/temp-recovery"; export type { ResponseSpillDirInspection } from "./spill-store"; @@ -705,8 +705,7 @@ async function writeBoundedSnapshot(path: string, attemptLimit: number): Promise try { for (let attempt = 0; attempt < attemptLimit; attempt += 1) { const revision = stateRevision; - const entries = selectSnapshotEntries(states, snapshotTotalBytes(), SNAPSHOT_ENTRY_MAX_BYTES); - const payload = JSON.stringify({ version: 2, states: entries }); + const payload = snapshotPayload(selectSnapshotEntries(states, snapshotTotalBytes(), SNAPSHOT_ENTRY_MAX_BYTES)); const payloadBytes = Buffer.byteLength(payload, "utf8"); const payloadDigest = Bun.hash(payload).toString(36); // A mutation does not always change what gets persisted: entries past the diff --git a/src/responses/state/snapshot-select.ts b/src/responses/state/snapshot-select.ts index 51bcc5a2c84..73f503ec3af 100644 --- a/src/responses/state/snapshot-select.ts +++ b/src/responses/state/snapshot-select.ts @@ -1,30 +1,34 @@ import type { StoredResponseState } from "../state"; /** - * Pick the snapshot entries that fit the byte budgets, in `states` order. + * Pick the snapshot entries that fit the byte budgets, in `states` order, and + * return them already serialized. * * Bounded stubs and tombstones are selected before residents: they are the * only durable references a spill file has, and demotion is oldest-first, so a * single newest-first pass would let resident payloads consume the whole * budget ahead of them. Residents then fill what remains, newest-first so the * most recent chains survive both legacy snapshot caps. + * + * Each entry has to be stringified once anyway to measure it, so the kept + * strings are returned instead of the entries: serializing the whole selection + * again would walk up to the full snapshot budget a second time per write. */ export function selectSnapshotEntries( states: ReadonlyMap, totalMaxBytes: number, residentEntryMaxBytes: number, -): Array<[string, unknown]> { +): string[] { const ordered = [...states].reverse(); - const persisted = new Map(); + const persisted = new Map(); let total = 0; // UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip past // both snapshot caps at up to 2x the intended size. - const sizeOf = (entry: [string, unknown]): number => Buffer.byteLength(JSON.stringify(entry), "utf8"); for (const [id, state] of ordered) { if (state.kind === "resident") continue; const { sizeBytes: _sizeBytes, ...smallState } = state; - const entry: [string, unknown] = [id, smallState]; - const size = sizeOf(entry); + const entry = JSON.stringify([id, smallState]); + const size = Buffer.byteLength(entry, "utf8"); if (total + size > totalMaxBytes) continue; total += size; persisted.set(id, entry); @@ -32,18 +36,27 @@ export function selectSnapshotEntries( for (const [id, state] of ordered) { if (state.kind !== "resident") continue; const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state; - const entry: [string, unknown] = [id, resident]; - const size = sizeOf(entry); + const entry = JSON.stringify([id, resident]); + const size = Buffer.byteLength(entry, "utf8"); if (size > residentEntryMaxBytes) continue; if (total + size > totalMaxBytes) break; total += size; persisted.set(id, entry); } // Emit in map order so reload and count eviction keep the same relative order as `states`. - const entries: Array<[string, unknown]> = []; + const entries: string[] = []; for (const [id] of states) { const kept = persisted.get(id); if (kept) entries.push(kept); } return entries; } + +/** + * The snapshot file body for serialized entries. Byte-identical to + * `JSON.stringify({ version: 2, states: entries.map(e => JSON.parse(e)) })`: compact + * JSON.stringify renders an array element exactly as it renders that value alone. + */ +export function snapshotPayload(serializedEntries: readonly string[]): string { + return '{"version":2,"states":[' + serializedEntries.join(",") + "]}"; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 83170e64b6b..c66dbf5ece8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1515,6 +1515,7 @@ "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", + "responses-state-snapshot-select.test.ts": "responses", "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", diff --git a/tests/responses/responses-state-snapshot-select.test.ts b/tests/responses/responses-state-snapshot-select.test.ts new file mode 100644 index 00000000000..0cd6ca86e85 --- /dev/null +++ b/tests/responses/responses-state-snapshot-select.test.ts @@ -0,0 +1,66 @@ +/** + * The snapshot writer reuses the per-entry strings it already produced to measure each + * entry, instead of serializing the whole selection a second time. That is only safe if + * the file bytes stay exactly what \`JSON.stringify({ version: 2, states })\` wrote before: + * the unchanged-payload digest skip and every reader depend on them. The expected + * strings below were produced by the previous implementation. + */ +import { describe, expect, test } from "bun:test"; +import type { StoredResponseState } from "../../src/responses/state"; +import { selectSnapshotEntries, snapshotPayload } from "../../src/responses/state/snapshot-select"; + +const RESIDENT_ENTRY_MAX_BYTES = 200; + +function fixtureStates(): Map { + const rows: Array<[string, unknown]> = [ + ["resp_old_resident", { + kind: "resident", + createdAt: 1, + items: [{ type: "message", content: 'quote " backslash \\ newline \n tab \t' }], + providerOutputStart: 1, + sizeBytes: 999, + }], + ["resp_spill", { kind: "spill", createdAt: 2, clientThreadId: "thr_1", spill: { file: "a.json", bytes: 10 }, sizeBytes: 5000 }], + ["resp_failed", { kind: "spill-failed", createdAt: 3, sizeBytes: 7 }], + ["resp_multibyte", { + kind: "resident", + createdAt: 4, + items: ["ν•œκΈ€ βœ“ πŸš€", { nested: { a: undefined, b: null, c: [1.5, -0, 1e21] } }], + sizeBytes: 1, + }], + ["resp_oversized", { kind: "resident", createdAt: 5, items: ["x".repeat(300)], sizeBytes: 1 }], + ["resp_newest", { kind: "resident", createdAt: 6, items: ["newest"], sizeBytes: 1 }], + ]; + return new Map(rows as Array<[string, StoredResponseState]>); +} + +function payload(states: Map, totalMaxBytes: number): string { + return snapshotPayload(selectSnapshotEntries(states, totalMaxBytes, RESIDENT_ENTRY_MAX_BYTES)); +} + +describe("responses-state snapshot payload bytes", () => { + test("matches the previous whole-tree serialization when everything fits", () => { + expect(payload(fixtureStates(), 10_000)).toBe( + '{"version":2,"states":[["resp_old_resident",{"createdAt":1,"items":[{"type":"message","content":"quote \\" backslash \\\\ newline \\n tab \\t"}],"providerOutputStart":1}],["resp_spill",{"kind":"spill","createdAt":2,"clientThreadId":"thr_1","spill":{"file":"a.json","bytes":10}}],["resp_failed",{"kind":"spill-failed","createdAt":3}],["resp_multibyte",{"createdAt":4,"items":["ν•œκΈ€ βœ“ πŸš€",{"nested":{"b":null,"c":[1.5,0,1e+21]}}]}],["resp_newest",{"createdAt":6,"items":["newest"]}]]}', + ); + }); + + test("matches the previous serialization when the byte budget cuts residents off", () => { + // Stubs take 160 bytes, the newest resident 50; the multibyte resident's 102 UTF-8 + // bytes then overflow 300, which ends the newest-first resident pass. + expect(payload(fixtureStates(), 300)).toBe( + '{"version":2,"states":[["resp_spill",{"kind":"spill","createdAt":2,"clientThreadId":"thr_1","spill":{"file":"a.json","bytes":10}}],["resp_failed",{"kind":"spill-failed","createdAt":3}],["resp_newest",{"createdAt":6,"items":["newest"]}]]}', + ); + }); + + test("an empty store still writes the version envelope", () => { + expect(payload(new Map(), 10_000)).toBe('{"version":2,"states":[]}'); + }); + + test("joining the kept strings equals serializing the parsed entries as one tree", () => { + for (const budget of [0, 120, 300, 10_000]) { + const kept = selectSnapshotEntries(fixtureStates(), budget, RESIDENT_ENTRY_MAX_BYTES); + expect(snapshotPayload(kept)).toBe(JSON.stringify({ version: 2, states: kept.map(entry => JSON.parse(entry)) })); + } + }); +});