From 8fdf1ddf7bbb9575c521fb0449c87eb74db9e9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Wed, 6 May 2026 10:47:12 +0000 Subject: [PATCH] fix: prevent DO RPC size limit crash on large chat sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ProjectData DO's getMessages() could return >32MiB of serialized data for sessions with many streaming tokens, hitting Cloudflare's hard RPC serialization ceiling and causing "internal server error". Two fixes: 1. Lower DEFAULT_CHAT_SESSION_MESSAGE_LIMIT from 3000 to 500 — the frontend already paginates via before/hasMore. 2. Add an RPC size guard in getMessages() that estimates cumulative response size and truncates early (setting hasMore=true) if approaching the 30MiB budget. This prevents the crash even if a caller passes a high limit. Root cause: session 046208eb had 5,672 messages (~45MB serialized). The 3000-row default caused a 45MB RPC return, exceeding the 32MB limit. Co-Authored-By: Claude Opus 4.6 --- .../durable-objects/project-data/messages.ts | 48 +++++++++++++++++-- packages/shared/src/constants/defaults.ts | 8 +++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/api/src/durable-objects/project-data/messages.ts b/apps/api/src/durable-objects/project-data/messages.ts index 28ba452be..6d18b3493 100644 --- a/apps/api/src/durable-objects/project-data/messages.ts +++ b/apps/api/src/durable-objects/project-data/messages.ts @@ -258,6 +258,22 @@ export function persistMessageBatch( return { persisted, duplicates, persistedMessages, workspaceId, firstUserContent, hadTopic }; } +/** + * Cloudflare DO RPC has a hard 32 MiB serialization ceiling. + * We leave a 2 MiB margin for the session envelope, pagination metadata, + * and JSON structural overhead. + */ +const RPC_SIZE_BUDGET_BYTES = 30 * 1024 * 1024; // 30 MiB + +function estimateRowBytes(row: Record): number { + let size = 64; // object overhead + fixed fields (id, role, created_at, sequence) + const content = row.content; + if (typeof content === 'string') size += content.length * 2; // UTF-16 chars + const tm = row.tool_metadata; + if (typeof tm === 'string') size += tm.length * 2; + return size; +} + export function getMessages( sql: SqlStorage, sessionId: string, @@ -284,11 +300,37 @@ export function getMessages( params.push(limit + 1); const rows = sql.exec(query, ...params).toArray(); - const hasMore = rows.length > limit; - const messageRows = hasMore ? rows.slice(0, limit) : rows; + let hasMore = rows.length > limit; + const candidateRows = hasMore ? rows.slice(0, limit) : rows; + + // RPC size guard: walk the result set (newest-first) and stop before + // exceeding the serialization budget. Because the query returns rows in + // DESC order and we reverse() before returning, we trim from the END + // of the candidate list (i.e. the oldest messages) so the caller still + // sees the most recent messages and can paginate backwards for older ones. + let cumulativeBytes = 0; + let safeCount = candidateRows.length; + for (let i = 0; i < candidateRows.length; i++) { + const row = candidateRows[i]!; + cumulativeBytes += estimateRowBytes(row); + if (cumulativeBytes > RPC_SIZE_BUDGET_BYTES) { + safeCount = i; // exclude this row and everything after + hasMore = true; + log.warn('messages.rpc_size_guard_truncated', { + sessionId, + requestedLimit: limit, + totalRows: candidateRows.length, + truncatedTo: safeCount, + estimatedBytes: cumulativeBytes, + }); + break; + } + } + + const trimmedRows = candidateRows.slice(0, safeCount); return { - messages: messageRows.reverse().map((row) => parseChatMessageRow(row)), + messages: trimmedRows.reverse().map((row) => parseChatMessageRow(row)), hasMore, }; } diff --git a/packages/shared/src/constants/defaults.ts b/packages/shared/src/constants/defaults.ts index d4b0fbad3..e57254ce5 100644 --- a/packages/shared/src/constants/defaults.ts +++ b/packages/shared/src/constants/defaults.ts @@ -52,10 +52,14 @@ export const DEFAULT_TASK_LIST_MAX_PAGE_SIZE = 200; /** * Default message limit for chat session REST endpoints (project chat view). * Streaming-token chat messages produce many more DB rows than logical messages, - * so this limit is much higher than SAM_HISTORY_LOAD_LIMIT (200). + * so this limit is higher than SAM_HISTORY_LOAD_LIMIT (200). + * + * Kept well below the Cloudflare DO RPC serialization ceiling (32 MiB) — + * large tool-call output can easily push 3 000 rows past the limit. + * The frontend already paginates via the `before` / `hasMore` contract. * Override via CHAT_SESSION_MESSAGE_LIMIT env var. */ -export const DEFAULT_CHAT_SESSION_MESSAGE_LIMIT = 3000; +export const DEFAULT_CHAT_SESSION_MESSAGE_LIMIT = 500; /** Default callback timeout for delegated task updates in milliseconds. */ export const DEFAULT_TASK_CALLBACK_TIMEOUT_MS = 10000;