diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 6f51870ad3..b3bfd8f65b 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -83,7 +83,10 @@ The request then follows normal combo selection and failover. Explicit provider/combo selectors and configured combo aliases take precedence over this recall. Failed, incomplete, or cancelled responses do not replace the last successful selection. Recall is -process-local and bounded to 256 lanes for 30 minutes; it does not store account credentials. +process-local and bounded to 256 conversations for 30 minutes, and to 1 KiB per remembered model +name and 64 KiB in total; expired entries are also cleaned up in the background. A response whose +model name is too large to retain leaves the previous selection untouched rather than clearing it. +Recall does not store account credentials. Without usable conversation identity or valid remembered state, normal compaction routing applies. A restart clears the remembered state. diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b8d4087431..238127c1ce 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -66,7 +66,7 @@ alias는 클라이언트가 요청하는 공개 이름만 바꿉니다. 콤보 클라이언트가 콤보를 바꾼 뒤 공급자 접두사 없는 모델 이름으로 압축을 요청하면, opencodex는 같은 대화에서 가장 최근에 응답을 성공적으로 마친 콤보를 기억해 사용할 수 있습니다. 모델 이름이 완료된 응답과 일치하고, 현재 설정에 해당 콤보와 대상이 남아 있어야 합니다. 압축 요청도 일반 콤보 선택과 페일오버를 따릅니다. -명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하며 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다. +명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하고, 모델 이름 하나당 1 KiB·전체 64 KiB로 제한하며, 만료된 기록은 배경에서도 정리합니다. 모델 이름이 너무 커서 보관할 수 없는 응답은 이전 선택을 지우지 않고 그대로 둡니다. 기록은 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다. ## 전략 선택 diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 13a22bfce0..849f145848 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -21,7 +21,7 @@ import { } from "../combos/failover"; import { reconcileComboWarningMemos } from "../combos/request"; import { reconcileComboRotationState } from "../combos/resolve"; -import { reconcileComboRecall } from "../server/responses/combo-session-recall"; +import { reconcileComboRecall, sweepExpiredComboRecall } from "../server/responses/combo-session-recall"; import { listLiveComboTargetKeys } from "../combos/types"; import { listLiveConfigOwnershipRoots, @@ -112,7 +112,11 @@ export const STATE_STORE_REGISTRATIONS = [ { name: "model-cache-history", reconcileGeneration: reconcileModelCacheGeneration }, { name: "pool-rotation", reconcileGeneration: reconcilePoolRotationState }, { name: "combo-rotation", reconcileGeneration: reconcileComboRotationState }, - { name: "combo-session-recall", reconcileGeneration: reconcileComboRecall }, + { + name: "combo-session-recall", + sweepExpired: sweepExpiredComboRecall, + reconcileGeneration: reconcileComboRecall, + }, { name: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff }, { name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState }, { name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState }, diff --git a/src/server/responses/combo-session-recall.ts b/src/server/responses/combo-session-recall.ts index 84dfd8d466..b3af31f1c4 100644 --- a/src/server/responses/combo-session-recall.ts +++ b/src/server/responses/combo-session-recall.ts @@ -8,14 +8,46 @@ interface ComboRecallEntry { target: Pick; responseModel: string; at: number; + /** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */ + bytes: number; } const RECALL_CAPACITY = 256; const RECALL_TTL_MS = 30 * 60 * 1000; +/** + * A model id is provider-reported and arrives on the response, so nothing upstream of here + * bounds its length. Lane keys are already SHA-256 digests, so the model string is the only + * field that can grow, and 256 lanes alone do not bound the bytes they hold. + */ +const RECALL_MODEL_BYTES_MAX = 1024; +const RECALL_TOTAL_BYTES_MAX = 64 * 1024; const recall = new Map(); +let recallBytes = 0; let lastReconciledGeneration = 0; let liveOwners: Pick | undefined; +/** Every removal path goes through here so the byte counter can never drift from the map. */ +function deleteEntry(lane: string): boolean { + const entry = recall.get(lane); + if (!entry) return false; + recall.delete(lane); + recallBytes -= entry.bytes; + return true; +} + +/** + * UTF-8 size of a remembered model id, or null when it is too large to retain. + * + * The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller + * than the code-unit count, so an oversized string is rejected without encoding it, and the + * bound cannot be defeated by paying the allocation it exists to prevent. + */ +function boundedModelBytes(responseModel: string): number | null { + if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null; + const bytes = Buffer.byteLength(responseModel, "utf8"); + return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes; +} + function ownsEntry(context: Pick, entry: ComboRecallEntry): boolean { return context.comboIds.has(entry.comboId) && context.providerNames.has(entry.target.provider) @@ -32,14 +64,29 @@ export function rememberComboForLane( if (!lane || !comboId || !responseModel.trim()) return; // Reject even a same-named recreated owner: its previous in-flight turn is obsolete. if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return; - const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() }; + // An unretainable model id DECLINES the write; it must not clear the lane. Every other + // rejection above returns the same way, and clearing here would let a late completion erase + // a newer selection that this function has no ordering information to compare against. + const bytes = boundedModelBytes(responseModel); + if (bytes === null) return; + const entry = { + comboId, + target: { provider: target.provider, model: target.model }, + responseModel, + at: Date.now(), + bytes, + }; if (liveOwners && !ownsEntry(liveOwners, entry)) return; - recall.delete(lane); + deleteEntry(lane); recall.set(lane, entry); - while (recall.size > RECALL_CAPACITY) { + recallBytes += bytes; + // Insertion order is recency order, because every write re-inserts its lane at the back. + // Evicting from the front therefore drops the least recently written lane, never this one: + // a single entry is capped well below the aggregate budget, so it always fits. + while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) { const oldest = recall.keys().next().value; - if (oldest === undefined) break; - recall.delete(oldest); + if (oldest === undefined || oldest === lane) break; + deleteEntry(oldest); } } @@ -57,12 +104,25 @@ export function recallComboForLane( || !Object.hasOwn(config.providers, entry.target.provider) || !provider || provider.disabled === true || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) { - recall.delete(lane); + deleteEntry(lane); return undefined; } return entry.responseModel === model ? entry.comboId : undefined; } +/** + * Periodic expiry. Without it a lane that is never read again and never touched by a config + * reconciliation holds its entry for the life of the process: the existing TTL is only + * evaluated on read or on generation change. + */ +export function sweepExpiredComboRecall(now: number): number { + let removed = 0; + for (const [lane, entry] of recall) { + if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1; + } + return removed; +} + export function reconcileComboRecall(context: GenerationContext): number { if (context.generation <= lastReconciledGeneration) return 0; lastReconciledGeneration = context.generation; @@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number { let removed = 0; for (const [lane, entry] of recall) { if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) { - recall.delete(lane); - removed += 1; + if (deleteEntry(lane)) removed += 1; } } return removed; @@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number { /** Test-only reset, alongside the combo rotation/cooldown resets. */ export function clearComboRecallForTests(): void { recall.clear(); + recallBytes = 0; lastReconciledGeneration = 0; liveOwners = undefined; } diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 54ece4ec41..98f518946e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -178,6 +178,20 @@ explicit configured selectors before consulting bounded lane state. The existing reconciliation owns removal of obsolete targets and generation fencing; core imports no registration composition root or Lab code. Recall retains routing identity only, never account credentials. +Retention is bounded on four axes: 256 lanes, 30 minutes, 1 KiB per remembered model id, and 64 KiB +in aggregate. The model id is the only field of unbounded length — lane keys are already SHA-256 +digests — so the lane cap alone does not bound the bytes those lanes hold. The size test runs on code +units before encoding, since a UTF-8 encoding is never smaller than its code-unit count and the bound +must not pay the allocation it exists to prevent. Aggregate eviction drops the least recently written +lane, which is the front of the map because every write re-inserts its own lane at the back. + +An unretainable model id declines the write rather than clearing the lane, matching how every other +rejection in `rememberComboForLane` returns. Clearing would let a late completion erase a newer +selection, and the publication path carries a config generation, not a request order, so it has no +basis on which to decide that its own result is the newer one. The store is also swept periodically +now: the TTL was previously evaluated only on read or on a generation change, so a lane never read +again held its entry for the life of the process. + > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, diff --git a/tests/oauth/state-store-sweeper.test.ts b/tests/oauth/state-store-sweeper.test.ts index 5b691d28d6..4cfb17ee84 100644 --- a/tests/oauth/state-store-sweeper.test.ts +++ b/tests/oauth/state-store-sweeper.test.ts @@ -215,6 +215,72 @@ describe("state-store sweeper", () => { } }); + describe("bounded combo recall retention", () => { + const config: OcxConfig = { + port: 0, defaultProvider: "a", + providers: { a: { adapter: "openai-chat", baseUrl: "https://a.example/v1" } }, + combos: { first: { targets: [{ provider: "a", model: "m1" }] } }, + }; + const remember = (lane: string, responseModel: string) => + rememberComboForLane(lane, "first", { provider: "a", model: "m1" }, responseModel, captureConfigGeneration()); + /** A distinct model id of exactly 1 KiB, the largest this store will retain. */ + const fullModel = (index: number) => `${index}-`.padEnd(1024, "m"); + + test("an unretainable model id declines the write instead of clearing the lane", () => { + remember("lane", "kept-model"); + // A model id is provider-reported and arrives on the response, so its length is not + // bounded upstream of here. Refusing to retain it must not also destroy what is there: + // this callback carries a config generation, not a request order, so it cannot know its + // own result is newer than the entry it would be erasing. + remember("lane", "x".repeat(1025)); + expect(recallComboForLane(config, "lane", "kept-model")).toBe("first"); + + // Measured in UTF-8 bytes, not code units: 600 three-byte characters is 1,800 bytes. + remember("lane", "가".repeat(600)); + expect(recallComboForLane(config, "lane", "kept-model")).toBe("first"); + + // And an oversized id never establishes a lane of its own. + remember("fresh", "x".repeat(4096)); + expect(recallComboForLane(config, "fresh", "x".repeat(4096))).toBeUndefined(); + }); + + test("the aggregate byte budget evicts the least recently written lane", () => { + // 64 KiB holds exactly 64 maximum-size entries, well inside the 256-lane cap, so this + // isolates the byte budget from the lane count. + for (let i = 0; i < 64; i += 1) remember(`lane-${i}`, fullModel(i)); + expect(recallComboForLane(config, "lane-0", fullModel(0))).toBe("first"); + + remember("lane-64", fullModel(64)); + expect(recallComboForLane(config, "lane-0", fullModel(0))).toBeUndefined(); + expect(recallComboForLane(config, "lane-1", fullModel(1))).toBe("first"); + expect(recallComboForLane(config, "lane-64", fullModel(64))).toBe("first"); + }); + + test("a rewritten lane is charged once, not once per write", () => { + // Replacing a lane must release the old entry's bytes. If it did not, 64 rewrites of one + // lane would exhaust the whole budget and start evicting unrelated lanes. + remember("stable", "stable-model"); + for (let i = 0; i < 64; i += 1) remember("churn", fullModel(i)); + expect(recallComboForLane(config, "stable", "stable-model")).toBe("first"); + expect(recallComboForLane(config, "churn", fullModel(63))).toBe("first"); + }); + + test("a periodic tick expires a lane that is never read again and releases its bytes", () => { + registerStateStore(STATE_STORE_REGISTRATIONS.find(row => row.name === "combo-session-recall")!); + for (let i = 0; i < 64; i += 1) remember(`stale-${i}`, fullModel(i)); + + // Before this the TTL was only evaluated on read or on a generation change, so a lane + // nobody reads again held its entry for the life of the process. + expect(sweepExpired(Date.now() + 30 * 60 * 1_000)).toEqual({ storesVisited: 1, rowsRemoved: 64 }); + expect(recallComboForLane(config, "stale-0", fullModel(0))).toBeUndefined(); + + // The budget is genuinely free again: a full refill keeps its own oldest lane, which + // could not happen if the swept entries had left their bytes behind. + for (let i = 0; i < 64; i += 1) remember(`fresh-${i}`, fullModel(i)); + expect(recallComboForLane(config, "fresh-0", fullModel(0))).toBe("first"); + }); + }); + test("a sweeper tick expires continuation and Antigravity rows without store traffic", () => { rememberResponseState({ input: "old" }, { id: "resp_sweeper_ttl", output: [], status: "completed" }); observeAntigravityReplay("gemini-3-pro", "session-old", [{