Skip to content

Commit 67a27b8

Browse files
committed
fix(discovery-index): bound TtlCache by a max-entry cap enforced on set
Without a cap, a key that is never re-read (e.g. a scope no caller repeats, or a soft-claim that's never released) stays resident in the long-lived module-level caches for the whole process lifetime. set() now drops already-expired entries first, then evicts the oldest-inserted entry until the store is back at or under the cap.
1 parent 1d2b142 commit 67a27b8

3 files changed

Lines changed: 110 additions & 7 deletions

File tree

packages/discovery-index/src/cache.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,24 @@
33
// (src/selfhost/redis-cache.ts) live in the main Cloudflare Worker package and aren't importable from a
44
// separate npm workspace package — there's no in-repo precedent for a plain in-memory keyed-with-expiry
55
// cache, so this is net new, kept deliberately small (lazy expiry check on read, no background sweep;
6-
// entries this service caches — GitHub issue metadata — are cheap to recompute on a stale miss).
6+
// entries this service caches — GitHub issue metadata — are cheap to recompute on a stale miss). A
7+
// constructor-supplied max-entry cap, enforced in `set`, bounds retention for keys that are never re-read.
78

89
interface Entry<V> {
910
value: V;
1011
expiresAt: number;
1112
}
1213

14+
/** Default max-entry cap for a TtlCache with no explicit cap passed to its constructor. */
15+
export const DEFAULT_CACHE_MAX_ENTRIES = 5_000;
16+
1317
export class TtlCache<V> {
1418
private readonly store = new Map<string, Entry<V>>();
1519

16-
constructor(private readonly now: () => number = Date.now) {}
20+
constructor(
21+
private readonly now: () => number = Date.now,
22+
private readonly maxEntries: number = DEFAULT_CACHE_MAX_ENTRIES,
23+
) {}
1724

1825
/** Returns the cached value, or undefined if absent or expired (an expired entry is evicted on read). */
1926
get(key: string): V | undefined {
@@ -26,7 +33,24 @@ export class TtlCache<V> {
2633
return entry.value;
2734
}
2835

36+
/** Bounds the store to `maxEntries` before inserting a NEW key: first drops every already-expired entry,
37+
* then (if still at cap) evicts the oldest-inserted entries -- the Map's own insertion order -- until
38+
* there's room. Overwriting an already-present key never grows the store, so it skips this entirely. */
39+
private evictForCapacity(key: string): void {
40+
if (this.store.has(key) || this.store.size < this.maxEntries) return;
41+
const now = this.now();
42+
for (const [storedKey, entry] of this.store) {
43+
if (entry.expiresAt <= now) this.store.delete(storedKey);
44+
}
45+
while (this.store.size >= this.maxEntries) {
46+
const oldestKey = this.store.keys().next().value;
47+
if (oldestKey === undefined) break;
48+
this.store.delete(oldestKey);
49+
}
50+
}
51+
2952
set(key: string, value: V, ttlMs: number): void {
53+
this.evictForCapacity(key);
3054
this.store.set(key, { value, expiresAt: this.now() + Math.max(0, ttlMs) });
3155
}
3256

packages/discovery-index/src/server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { serve } from "@hono/node-server";
88
import type { AiPolicyVerdict, DiscoveryIndexCandidate } from "@loopover/engine";
99
import { createApp } from "./app.js";
10-
import { TtlCache } from "./cache.js";
10+
import { DEFAULT_CACHE_MAX_ENTRIES, TtlCache } from "./cache.js";
1111
import { DEFAULT_CACHE_TTL_MS } from "./discovery-query.js";
1212
import { GitHubClient } from "./github-client.js";
1313
import { captureUnhandledPostHogError, flushDiscoveryIndexPostHog, initDiscoveryIndexPostHog, resolvePostHogEnvironment, shutdownDiscoveryIndexPostHog } from "./posthog.js";
@@ -27,10 +27,10 @@ const softClaimTtlMs =
2727

2828
const app = createApp({
2929
github: new GitHubClient({ token: githubToken }),
30-
resultCache: new TtlCache<DiscoveryIndexCandidate[]>(),
31-
policyCache: new TtlCache<AiPolicyVerdict>(),
30+
resultCache: new TtlCache<DiscoveryIndexCandidate[]>(Date.now, DEFAULT_CACHE_MAX_ENTRIES),
31+
policyCache: new TtlCache<AiPolicyVerdict>(Date.now, DEFAULT_CACHE_MAX_ENTRIES),
3232
cacheTtlMs,
33-
softClaimStore: new SoftClaimStore(new TtlCache(), softClaimTtlMs),
33+
softClaimStore: new SoftClaimStore(new TtlCache(Date.now, DEFAULT_CACHE_MAX_ENTRIES), softClaimTtlMs),
3434
githubConfigured: githubToken.trim().length > 0,
3535
});
3636

test/unit/discovery-index/cache.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { readFileSync } from "node:fs";
12
import { describe, expect, it } from "vitest";
2-
import { TtlCache } from "../../../packages/discovery-index/src/cache";
3+
import { DEFAULT_CACHE_MAX_ENTRIES, TtlCache } from "../../../packages/discovery-index/src/cache";
4+
5+
const SERVER_SOURCE = readFileSync("packages/discovery-index/src/server.ts", "utf8");
36

47
function clock(startMs = 0) {
58
let now = startMs;
@@ -65,4 +68,80 @@ describe("discovery-index TtlCache (#7164)", () => {
6568
expect(await cache.getOrCompute("k", 100, compute)).toBe(2);
6669
expect(calls).toBe(2);
6770
});
71+
72+
describe("max-entry cap", () => {
73+
it("exports a positive default cap constant", () => {
74+
expect(Number.isInteger(DEFAULT_CACHE_MAX_ENTRIES)).toBe(true);
75+
expect(DEFAULT_CACHE_MAX_ENTRIES).toBeGreaterThan(0);
76+
});
77+
78+
it("with a cap of 2, a third distinct key evicts the oldest and stays at size 2", () => {
79+
const cache = new TtlCache<string>(Date.now, 2);
80+
cache.set("a", "1", 60_000);
81+
cache.set("b", "2", 60_000);
82+
cache.set("c", "3", 60_000);
83+
expect(cache.size).toBe(2);
84+
expect(cache.get("a")).toBeUndefined();
85+
expect(cache.get("c")).toBe("3");
86+
});
87+
88+
it("under the cap, set does not evict anything", () => {
89+
const cache = new TtlCache<string>(Date.now, 2);
90+
cache.set("a", "1", 60_000);
91+
expect(cache.size).toBe(1);
92+
expect(cache.get("a")).toBe("1");
93+
});
94+
95+
it("evicts already-expired entries before falling back to oldest-inserted eviction", () => {
96+
const c = clock();
97+
const cache = new TtlCache<string>(c.now, 2);
98+
cache.set("a", "1", 100); // will be expired
99+
c.advance(101);
100+
cache.set("b", "2", 60_000); // live
101+
cache.set("c", "3", 60_000); // expired-drop of "a" makes room, "b" survives
102+
expect(cache.size).toBe(2);
103+
expect(cache.get("a")).toBeUndefined();
104+
expect(cache.get("b")).toBe("2");
105+
expect(cache.get("c")).toBe("3");
106+
});
107+
108+
it("REGRESSION: a key that is never re-read must not survive past the entry cap", () => {
109+
const cap = 10;
110+
const cache = new TtlCache<number>(Date.now, cap);
111+
for (let i = 0; i < cap + 50; i++) {
112+
cache.set(`key-${i}`, i, 60_000);
113+
expect(cache.size).toBeLessThanOrEqual(cap);
114+
}
115+
expect(cache.size).toBe(cap);
116+
});
117+
118+
it("overwriting an already-present key at the cap does not evict any other entry", () => {
119+
const cache = new TtlCache<string>(Date.now, 2);
120+
cache.set("a", "1", 60_000);
121+
cache.set("b", "2", 60_000);
122+
cache.set("b", "2-updated", 60_000);
123+
expect(cache.size).toBe(2);
124+
expect(cache.get("a")).toBe("1");
125+
expect(cache.get("b")).toBe("2-updated");
126+
});
127+
128+
it("falls back to the default cap when no cap is passed to the constructor", () => {
129+
const cache = new TtlCache<number>();
130+
for (let i = 0; i < DEFAULT_CACHE_MAX_ENTRIES + 5; i++) {
131+
cache.set(`key-${i}`, i, 60_000);
132+
}
133+
expect(cache.size).toBe(DEFAULT_CACHE_MAX_ENTRIES);
134+
});
135+
});
136+
137+
describe("server.ts wiring (#7164)", () => {
138+
it("passes an explicit cap to all three long-lived cache instances", () => {
139+
const explicitCapSites = [...SERVER_SOURCE.matchAll(/new TtlCache(?:<[^>]*>)?\([^)]*DEFAULT_CACHE_MAX_ENTRIES[^)]*\)/g)];
140+
expect(explicitCapSites).toHaveLength(3);
141+
});
142+
143+
it("imports the cap constant from cache.ts", () => {
144+
expect(SERVER_SOURCE).toMatch(/import\s*\{[^}]*DEFAULT_CACHE_MAX_ENTRIES[^}]*\}\s*from\s*"\.\/cache\.js"/);
145+
});
146+
});
68147
});

0 commit comments

Comments
 (0)