From 8bf57e8bdef98fd7715b5344660bc0e5f88260be Mon Sep 17 00:00:00 2001 From: Feelw00 Date: Sun, 19 Apr 2026 15:29:25 +0900 Subject: [PATCH] fix(gateway): bound costUsageCache with MAX + FIFO eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression: `costUsageCache` in `src/gateway/server-methods/usage.ts` had no delete/prune/evict path. The TTL check at L310 only gates stale reads — on a miss after expiry, `set()` overwrites the same key but never removes stale keys. `parseDateRange` derives cacheKey from `getTodayStartMs`, so cacheKey rolls at every UTC 00:00, and additional axes (days / startDate / endDate / utcOffset) multiply cardinality. The macOS menu polls `usage.cost` every ~45s with no params, exercising `parseDateRange`'s default branch every day. Over gateway uptime the map grows monotonically. Three sibling caches in the same subsystem already implement MAX + FIFO eviction (resolvedSessionKeyByRunId, TRANSCRIPT_SESSION_KEY_CACHE, sessionTitleFieldsCache). This change mirrors their pattern: - `COST_USAGE_CACHE_MAX = 256` (matches RUN_LOOKUP_CACHE_LIMIT and TRANSCRIPT_SESSION_KEY_CACHE_MAX). - New `setCostUsageCache(cacheKey, entry)` helper checks size + evicts `keys().next().value` when adding a new key would exceed the cap. - The three existing `costUsageCache.set(...)` call sites now route through the helper. TTL-on-read, in-flight dedup, and overwrite-on-same-key semantics are preserved. Adds `src/gateway/server-methods/usage.cost-usage-cache.test.ts` which drives growth through `__test.loadCostUsageSummaryCached` with 600 distinct (startMs, endMs) pairs (mirrors day rollover + range switches). Pre-fix the Map grows to 600; post-fix it plateaus, the last key is retained, and the first key is evicted (FIFO). AI-assisted (fully tested). 432 server-methods tests pass, pnpm check + pnpm build clean. --- .../usage.cost-usage-cache.test.ts | 90 +++++++++++++++++++ src/gateway/server-methods/usage.ts | 21 ++++- 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/gateway/server-methods/usage.cost-usage-cache.test.ts diff --git a/src/gateway/server-methods/usage.cost-usage-cache.test.ts b/src/gateway/server-methods/usage.cost-usage-cache.test.ts new file mode 100644 index 00000000000..3eff1838217 --- /dev/null +++ b/src/gateway/server-methods/usage.cost-usage-cache.test.ts @@ -0,0 +1,90 @@ +// Regression: costUsageCache (usage.ts:65) has no production delete/prune/evict +// path. The TTL at L310 is read-only — on a miss after expiry, set() overwrites +// the same key but never removes stale keys. parseDateRange derives cacheKey +// from getTodayStartMs so cacheKey rolls at every UTC 00:00, and additional +// axes (days, startDate, endDate, utcOffset) multiply cardinality. +// +// The same file has three sibling caches that implement MAX + FIFO eviction +// (resolvedSessionKeyByRunId, TRANSCRIPT_SESSION_KEY_CACHE, +// sessionTitleFieldsCache); costUsageCache alone lacked the pattern. +// +// Production trigger: MenuSessionsInjector polls usage.cost every ~45s with +// no params, exercising parseDateRange's default branch on every UTC day +// rollover. The Control UI adds more key variance via explicit startDate / +// endDate / utcTimeZone combinations. +// +// CAL-003 compliance: no mock of internal branches. Growth is driven through +// the __test.loadCostUsageSummaryCached seam (same entry point usage.test.ts +// already exercises) with distinct (startMs, endMs) pairs. Only the external +// loadCostUsageSummary dependency is stubbed. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +vi.mock("../../infra/session-cost-usage.js", async () => { + const actual = await vi.importActual( + "../../infra/session-cost-usage.js", + ); + return { + ...actual, + loadCostUsageSummary: vi.fn(async () => ({ + updatedAt: Date.now(), + startDate: "2026-02-01", + endDate: "2026-02-02", + daily: [], + totals: { + totalTokens: 1, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalCost: 0, + }, + })), + }; +}); + +import { __test } from "./usage.js"; + +describe("costUsageCache bounded growth", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + + beforeEach(() => { + __test.costUsageCache.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("does not grow without bound when (startMs, endMs) varies across day rollover and range switches", async () => { + const config = {} as OpenClawConfig; + + // 600 distinct (startMs, endMs) pairs — larger than the 256 caps used by + // the smallest sibling caches (RUN_LOOKUP_CACHE_LIMIT, + // TRANSCRIPT_SESSION_KEY_CACHE_MAX) and small enough that the test runs + // quickly. + const ITERATIONS = 600; + + for (let i = 0; i < ITERATIONS; i++) { + const startMs = Date.UTC(2026, 0, 1) + i * DAY_MS; + const endMs = startMs + (i % 3 === 0 ? DAY_MS : 7 * DAY_MS) - 1; + await __test.loadCostUsageSummaryCached({ startMs, endMs, config }); + } + + // Primary: map must be bounded. Pre-fix this equals ITERATIONS (600). + expect(__test.costUsageCache.size).toBeLessThan(ITERATIONS); + + // Secondary: the most recent entry must still be present. FIFO evicts + // oldest-first, never the newest. + const lastStartMs = Date.UTC(2026, 0, 1) + (ITERATIONS - 1) * DAY_MS; + const lastEndMs = lastStartMs + ((ITERATIONS - 1) % 3 === 0 ? DAY_MS : 7 * DAY_MS) - 1; + const lastCacheKey = `${lastStartMs}-${lastEndMs}`; + expect(__test.costUsageCache.has(lastCacheKey)).toBe(true); + + // Tertiary: the oldest entry must have been evicted once the cap was + // exceeded. Pre-fix all 600 entries remain and this fails too. + const firstStartMs = Date.UTC(2026, 0, 1); + const firstEndMs = firstStartMs + DAY_MS - 1; + const firstCacheKey = `${firstStartMs}-${firstEndMs}`; + expect(__test.costUsageCache.has(firstCacheKey)).toBe(false); + }); +}); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index c985b0bef13..8c33000f575 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -49,6 +49,7 @@ import { import type { GatewayRequestHandlers, RespondFn } from "./types.js"; const COST_USAGE_CACHE_TTL_MS = 30_000; +const COST_USAGE_CACHE_MAX = 256; const DAY_MS = 24 * 60 * 60 * 1000; type DateRange = { startMs: number; endMs: number }; @@ -64,6 +65,20 @@ type CostUsageCacheEntry = { const costUsageCache = new Map(); +// Store an entry with FIFO eviction when adding a new key would exceed the +// cap. Mirrors the pattern in session-transcript-key.ts and server-session-key.ts +// so the cache stays bounded under sliding-window usage queries (each +// day/range combination produces a distinct key). +function setCostUsageCache(cacheKey: string, entry: CostUsageCacheEntry): void { + if (!costUsageCache.has(cacheKey) && costUsageCache.size >= COST_USAGE_CACHE_MAX) { + const oldest = costUsageCache.keys().next().value; + if (oldest !== undefined) { + costUsageCache.delete(oldest); + } + } + costUsageCache.set(cacheKey, entry); +} + function resolveSessionUsageFileOrRespond( key: string, respond: RespondFn, @@ -325,7 +340,7 @@ async function loadCostUsageSummaryCached(params: { config: params.config, }) .then((summary) => { - costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() }); + setCostUsageCache(cacheKey, { summary, updatedAt: Date.now() }); return summary; }) .catch((err) => { @@ -338,12 +353,12 @@ async function loadCostUsageSummaryCached(params: { const current = costUsageCache.get(cacheKey); if (current?.inFlight === inFlight) { current.inFlight = undefined; - costUsageCache.set(cacheKey, current); + setCostUsageCache(cacheKey, current); } }); entry.inFlight = inFlight; - costUsageCache.set(cacheKey, entry); + setCostUsageCache(cacheKey, entry); if (entry.summary) { return entry.summary;