Skip to content

Commit f35fbc8

Browse files
fede-kamelclaudevincentkoc
authored
fix(gateway): cap agentRunCache to prevent unbounded growth under run fan-out (#77973)
* fix(gateway): cap agentRunCache to prevent unbounded growth under run fan-out Time-based prune only reclaims entries past the 10-minute TTL window; a burst of run fan-out can add far more entries than the window reclaims, so the cache could grow without bound between prunes. Add a FIFO entry cap (5000) enforced on insert, mirroring the existing Discord REST entity-cache bound. Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> * test(gateway): preserve waited run snapshots under cache cap --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent 909be7b commit f35fbc8

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

src/gateway/server-methods/agent-job.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { setSafeTimeout } from "../../utils/timer-delay.js";
1010
import type { AgentWaitTerminalSnapshot } from "./agent-wait-dedupe.js";
1111

1212
const AGENT_RUN_CACHE_TTL_MS = 10 * 60_000;
13+
const AGENT_RUN_CACHE_MAX_ENTRIES = 5_000;
1314
/**
1415
* Embedded runs can emit transient lifecycle `error` events while auth/model
1516
* failover is still in progress. Give errors a short grace window so a
@@ -63,6 +64,28 @@ function recordAgentRunSnapshot(entry: AgentRunSnapshot) {
6364
return;
6465
}
6566
agentRunCache.set(entry.runId, entry);
67+
// Time-based prune only fires on the TTL window; under high run fan-out a
68+
// burst can add far more entries than the window reclaims. Cap with a FIFO
69+
// drop so the cache cannot grow without bound between prunes.
70+
enforceAgentRunCacheMaxEntries();
71+
}
72+
73+
function enforceAgentRunCacheMaxEntries() {
74+
if (agentRunCache.size <= AGENT_RUN_CACHE_MAX_ENTRIES) {
75+
return;
76+
}
77+
const toRemove = agentRunCache.size - AGENT_RUN_CACHE_MAX_ENTRIES;
78+
let removed = 0;
79+
for (const runId of agentRunCache.keys()) {
80+
if (removed >= toRemove) {
81+
break;
82+
}
83+
if ((agentRunWaiterCounts.get(runId) ?? 0) > 0) {
84+
continue;
85+
}
86+
agentRunCache.delete(runId);
87+
removed += 1;
88+
}
6689
}
6790

6891
function shouldPreserveTerminalSnapshot(
@@ -494,5 +517,12 @@ export const testing = {
494517
resetWaiters(): void {
495518
agentRunWaiterCounts.clear();
496519
},
520+
getAgentRunCacheSize(): number {
521+
return agentRunCache.size;
522+
},
523+
resetAgentRunCache(): void {
524+
agentRunCache.clear();
525+
},
526+
agentRunCacheMaxEntries: AGENT_RUN_CACHE_MAX_ENTRIES,
497527
};
498528
export { testing as __testing };

src/gateway/server-methods/server-methods.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
} from "../chat-display-projection.js";
3838
import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
3939
import { ExecApprovalManager } from "../exec-approval-manager.js";
40-
import { waitForAgentJob } from "./agent-job.js";
40+
import { __testing as agentJobTesting, waitForAgentJob } from "./agent-job.js";
4141
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
4242
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
4343
import { createExecApprovalHandlers } from "./exec-approval.js";
@@ -592,6 +592,74 @@ describe("waitForAgentJob", () => {
592592
vi.useRealTimers();
593593
}
594594
});
595+
596+
it("caps agentRunCache at AGENT_RUN_CACHE_MAX_ENTRIES via FIFO drop", () => {
597+
agentJobTesting.resetAgentRunCache();
598+
const max = agentJobTesting.agentRunCacheMaxEntries;
599+
const overflow = 25;
600+
const prefix = `cap-${Date.now()}-${Math.random().toString(36).slice(2)}`;
601+
for (let i = 0; i < max + overflow; i++) {
602+
emitAgentEvent({
603+
runId: `${prefix}-${i}`,
604+
stream: "lifecycle",
605+
data: { phase: "end", startedAt: i, endedAt: i + 1 },
606+
});
607+
}
608+
expect(agentJobTesting.getAgentRunCacheSize()).toBe(max);
609+
agentJobTesting.resetAgentRunCache();
610+
});
611+
612+
it("does not evict cached terminal snapshots with active fresh waiters", async () => {
613+
agentJobTesting.resetAgentRunCache();
614+
const max = agentJobTesting.agentRunCacheMaxEntries;
615+
const prefix = `cap-waiter-${Date.now()}-${Math.random().toString(36).slice(2)}`;
616+
const waitedRunId = `${prefix}-waited`;
617+
emitAgentEvent({
618+
runId: waitedRunId,
619+
stream: "lifecycle",
620+
data: { phase: "end", startedAt: 1_000, endedAt: 1_100 },
621+
});
622+
const waitPromise = waitForAgentJob({
623+
runId: waitedRunId,
624+
timeoutMs: 5_000,
625+
ignoreCachedSnapshot: true,
626+
});
627+
628+
for (let i = 0; i < max + 25; i++) {
629+
emitAgentEvent({
630+
runId: `${prefix}-${i}`,
631+
stream: "lifecycle",
632+
data: { phase: "end", startedAt: i, endedAt: i + 1 },
633+
});
634+
}
635+
const cached = await waitForAgentJob({ runId: waitedRunId, timeoutMs: 0 });
636+
expectRecordFields(cached, {
637+
status: "ok",
638+
startedAt: 1_000,
639+
endedAt: 1_100,
640+
});
641+
expect(agentJobTesting.getAgentRunCacheSize()).toBe(max);
642+
643+
emitAgentEvent({
644+
runId: waitedRunId,
645+
stream: "lifecycle",
646+
data: { phase: "end", startedAt: 10_000, endedAt: 10_100 },
647+
});
648+
649+
const waited = await waitPromise;
650+
expectRecordFields(waited, {
651+
status: "ok",
652+
startedAt: 10_000,
653+
endedAt: 10_100,
654+
});
655+
emitAgentEvent({
656+
runId: `${prefix}-after-waiter`,
657+
stream: "lifecycle",
658+
data: { phase: "end", startedAt: 20_000, endedAt: 20_100 },
659+
});
660+
expect(agentJobTesting.getAgentRunCacheSize()).toBe(max);
661+
agentJobTesting.resetAgentRunCache();
662+
});
595663
});
596664

597665
describe("augmentChatHistoryWithCanvasBlocks", () => {

0 commit comments

Comments
 (0)