Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 2 additions & 3 deletions src/responses/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
31 changes: 22 additions & 9 deletions src/responses/state/snapshot-select.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,62 @@
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<string, StoredResponseState>,
totalMaxBytes: number,
residentEntryMaxBytes: number,
): Array<[string, unknown]> {
): string[] {
const ordered = [...states].reverse();
const persisted = new Map<string, [string, unknown]>();
const persisted = new Map<string, string>();
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);
}
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(",") + "]}";
}
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions tests/responses/responses-state-snapshot-select.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, StoredResponseState> {
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<string, StoredResponseState>, 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)) }));
}
});
});
Loading