diff --git a/src/infra/outbound/directory-cache.test.ts b/src/infra/outbound/directory-cache.test.ts new file mode 100644 index 00000000000..ce7d041951e --- /dev/null +++ b/src/infra/outbound/directory-cache.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { DirectoryCache } from "./directory-cache.js"; + +describe("DirectoryCache", () => { + const cfg = {} as OpenClawConfig; + + it("expires entries after ttl", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const cache = new DirectoryCache(1000, 10); + + cache.set("a", "value-a", cfg); + expect(cache.get("a", cfg)).toBe("value-a"); + + vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); + expect(cache.get("a", cfg)).toBeUndefined(); + + vi.useRealTimers(); + }); + + it("evicts oldest keys when max size is exceeded", () => { + const cache = new DirectoryCache(60_000, 2); + cache.set("a", "value-a", cfg); + cache.set("b", "value-b", cfg); + cache.set("c", "value-c", cfg); + + expect(cache.get("a", cfg)).toBeUndefined(); + expect(cache.get("b", cfg)).toBe("value-b"); + expect(cache.get("c", cfg)).toBe("value-c"); + }); + + it("refreshes insertion order on key updates", () => { + const cache = new DirectoryCache(60_000, 2); + cache.set("a", "value-a", cfg); + cache.set("b", "value-b", cfg); + cache.set("a", "value-a2", cfg); + cache.set("c", "value-c", cfg); + + // Updating "a" should keep it and evict older "b". + expect(cache.get("a", cfg)).toBe("value-a2"); + expect(cache.get("b", cfg)).toBeUndefined(); + expect(cache.get("c", cfg)).toBe("value-c"); + }); +}); diff --git a/src/infra/outbound/directory-cache.ts b/src/infra/outbound/directory-cache.ts index 8dccac50ff9..97aca418eb4 100644 --- a/src/infra/outbound/directory-cache.ts +++ b/src/infra/outbound/directory-cache.ts @@ -22,25 +22,35 @@ export function buildDirectoryCacheKey(key: DirectoryCacheKey): string { export class DirectoryCache { private readonly cache = new Map>(); private lastConfigRef: OpenClawConfig | null = null; + private readonly maxSize: number; - constructor(private readonly ttlMs: number) {} + constructor( + private readonly ttlMs: number, + maxSize = 2000, + ) { + this.maxSize = Math.max(1, Math.floor(maxSize)); + } get(key: string, cfg: OpenClawConfig): T | undefined { this.resetIfConfigChanged(cfg); + this.pruneExpired(Date.now()); const entry = this.cache.get(key); if (!entry) { return undefined; } - if (Date.now() - entry.fetchedAt > this.ttlMs) { - this.cache.delete(key); - return undefined; - } return entry.value; } set(key: string, value: T, cfg: OpenClawConfig): void { this.resetIfConfigChanged(cfg); - this.cache.set(key, { value, fetchedAt: Date.now() }); + const now = Date.now(); + this.pruneExpired(now); + // Refresh insertion order so active keys are less likely to be evicted. + if (this.cache.has(key)) { + this.cache.delete(key); + } + this.cache.set(key, { value, fetchedAt: now }); + this.evictToMaxSize(); } clearMatching(match: (key: string) => boolean): void { @@ -64,4 +74,25 @@ export class DirectoryCache { } this.lastConfigRef = cfg; } + + private pruneExpired(now: number): void { + if (this.ttlMs <= 0) { + return; + } + for (const [cacheKey, entry] of this.cache.entries()) { + if (now - entry.fetchedAt > this.ttlMs) { + this.cache.delete(cacheKey); + } + } + } + + private evictToMaxSize(): void { + while (this.cache.size > this.maxSize) { + const oldestKey = this.cache.keys().next().value; + if (typeof oldestKey !== "string") { + break; + } + this.cache.delete(oldestKey); + } + } }