From 726f7ae00c8162637d976867c55332ce83518097 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Mon, 2 Nov 2026 14:12:43 +0800 Subject: [PATCH] perf(server): thread snapshots skip serializing a row past the row limit The history window serialized each row to count its bytes before checking the item and turn limits, so the first excluded row was always stringified and thrown away. Check those limits first. Byte accounting is unchanged for every row that could still be admitted. Co-Authored-By: Claude Opus 5.5 --- .../threadHistoryPaging.test.ts | 79 +++++++++++++++++++ .../orchestration-v2/threadHistoryPaging.ts | 4 +- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index f4e54000222fc..f7b8412a2cb1d 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -66,6 +66,26 @@ function makeRow( }; } +function makeTurnStart( + index: number, + createdBy: "user" | "agent", +): OrchestrationV2ProjectedTurnItem { + const row = makeRow(index); + return { + ...row, + item: { + ...row.item, + type: "user_message", + createdBy, + creationSource: "provider", + inputIntent: "turn_start", + messageId: MessageId.make(`prompt-${index}`), + text: `Prompt ${index}`, + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; +} + function interruptItem( id: string, type: "run_interrupt_request" | "run_interrupt_result", @@ -450,6 +470,65 @@ describe("threadHistoryPaging", () => { } }); + it.each([ + { + name: "item ceiling", + items: Array.from({ length: 5 }, (_, index) => makeRow(index)), + policy: { maxItems: 3, maxEncodedBytes: 1_000 }, + admitted: 3, + measured: 3, + }, + { + name: "user turn ceiling", + items: [makeRow(0), makeTurnStart(1, "user"), makeRow(2)], + policy: { maxUserTurns: 1, maxItems: 100, maxEncodedBytes: 1_000 }, + admitted: 2, + measured: 2, + }, + { + name: "raw turn ceiling", + items: [ + makeTurnStart(0, "user"), + ...Array.from({ length: 151 }, (_, index) => makeTurnStart(index + 1, "agent")), + ], + policy: { maxUserTurns: 10, maxItems: 1_000, maxEncodedBytes: 1_000_000 }, + admitted: 150, + measured: 150, + }, + { + name: "byte budget, which still admits an exact fit", + items: Array.from({ length: 5 }, (_, index) => makeRow(index)), + policy: { maxItems: 100, maxEncodedBytes: 20 }, + admitted: 2, + measured: 3, + }, + { + name: "byte budget, which still admits an oversized first row", + items: Array.from({ length: 5 }, (_, index) => makeRow(index)), + policy: { maxItems: 100, maxEncodedBytes: 5 }, + admitted: 1, + measured: 2, + }, + ])("measures only the rows needed to stop at the $name", (testCase) => { + const measured: OrchestrationV2ProjectedTurnItem[] = []; + const page = selectRecentTimelineWindow({ + items: testCase.items, + snapshotSequence: 1, + policy: testCase.policy, + rowEncodedBytes: (row) => { + measured.push(row); + return 10; + }, + }); + + expect(page.items.map((row) => row.sourceItemId)).toEqual( + testCase.items.slice(-testCase.admitted).map((row) => row.sourceItemId), + ); + expect(measured).toHaveLength(testCase.measured); + expect(page.hasMoreHistory).toBe(true); + expect(decodeThreadHistoryCursor(page.nextCursor!).si).toBe(page.items[0]?.sourceItemId); + }); + it("recovers identity-miss cursors when position is past a shrunken timeline", () => { const items = Array.from({ length: 5 }, (_, index) => makeRow(index)); const cursor = encodeThreadHistoryCursor({ diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 03d056531a760..2033e99f8a6e5 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -331,16 +331,16 @@ function selectOlderTimelinePage(input: { turnLimit === undefined ? policy.maxItems : (policy.maxTurnItems ?? policy.maxItems); for (let index = end - 1; index >= 0; index -= 1) { const row = input.items[index]!; - const rowBytes = rowCost(row); if ( selected.length > 0 && (selected.length >= maxItems || - encodedBytes + rowBytes > policy.maxEncodedBytes || (turnLimit !== undefined && (userTurns >= turnLimit || rawTurns >= THREAD_HISTORY_MAX_RAW_TURNS))) ) { break; } + const rowBytes = rowCost(row); + if (selected.length > 0 && encodedBytes + rowBytes > policy.maxEncodedBytes) break; selected.push(row); encodedBytes += rowBytes; if (isThreadHistoryUserTurn(row.item)) userTurns += 1;