diff --git a/changelog.d/fixes/13280-bounded-routing-caches.md b/changelog.d/fixes/13280-bounded-routing-caches.md new file mode 100644 index 0000000000..639f25ecce --- /dev/null +++ b/changelog.d/fixes/13280-bounded-routing-caches.md @@ -0,0 +1 @@ +- **fix(quota):** in-process routing and quota caches (quality tracker, saturation and rate-limit header caches, quota-fetcher cache, learned rate limits, account buckets) are now size-bounded through one shared `boundedMap` — caps sit far above normal deployments, evictions are logged once per minute per cache instead of per entry, and state whose loss would change routing (live saturated quota buckets, evaluator quality scores) is never evicted ([#13280](https://github.com/diegosouzapw/OmniRoute/pull/13280)) — thanks @maxmad64bis diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 2b2dabce66..27ebed935a 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -585,7 +585,7 @@ }, "open-sse/services/rateLimitManager.ts": { "@typescript-eslint/no-unused-vars": { - "count": 2 + "count": 1 } }, "open-sse/services/routing/index.ts": { diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index ab5a87a693..43ed09d68d 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -24,10 +24,8 @@ import { type QuotaFetcher, type QuotaInfo, } from "./quotaPreflight.ts"; -import { - getAntigravityQuotaFamily, - getQuotaFetchScope, -} from "./antigravityQuotaFamily.ts"; +import { getAntigravityQuotaFamily, getQuotaFetchScope } from "./antigravityQuotaFamily.ts"; +import { boundedMap } from "../../src/lib/quota/boundedMap.ts"; type UsageFetcher = ( connection: Parameters[0], @@ -77,29 +75,19 @@ export function __resetGenericQuotaFetcherForTests(): void { pendingForceRefreshMiss.clear(); } -interface CacheEntry { - quota: QuotaInfo; - fetchedAt: number; -} - -const cache = new Map(); +// One entry per (provider, connection); 4096 keeps even very large account pools +// from ever evicting. An evicted entry only costs one extra upstream quota read. +const cache = boundedMap("quota-fetcher-cache", 4096, "ttl", CACHE_TTL_MS); function connectionKey(provider: string, connectionId: string): string { return `${provider.trim()}::${connectionId.trim()}`; } -function quotaCacheScope( - provider: string, - requestedModel?: string | null -): string { +function quotaCacheScope(provider: string, requestedModel?: string | null): string { return getQuotaFetchScope(provider, requestedModel); } -function cacheKey( - provider: string, - connectionId: string, - requestedModel?: string | null -): string { +function cacheKey(provider: string, connectionId: string, requestedModel?: string | null): string { return `${connectionKey(provider, connectionId)}::${quotaCacheScope(provider, requestedModel)}`; } @@ -125,22 +113,14 @@ function markPendingForceRefreshMiss(key: string): void { if (isPendingForceRefresh(key)) pendingForceRefreshMiss.set(key, Date.now()); } -function cachedQuotaIfFresh( - key: string, - forceRefresh: boolean, - now: number -): QuotaInfo | null { +function cachedQuotaIfFresh(key: string, forceRefresh: boolean, now: number): QuotaInfo | null { if (forceRefresh) return null; - const cached = cache.get(key); - if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.quota; + const cached = cache.get(key, now); + if (cached !== undefined) return cached; return null; } -function isForceRefreshMissCooling( - key: string, - forceRefresh: boolean, - now: number -): boolean { +function isForceRefreshMissCooling(key: string, forceRefresh: boolean, now: number): boolean { if (!forceRefresh) return false; const missedAt = pendingForceRefreshMiss.get(key); return missedAt !== undefined && now - missedAt < CACHE_TTL_MS; @@ -150,18 +130,17 @@ function isForceRefreshMissCooling( function isConcurrentForceRefresh(key: string, refreshStamp: number | undefined): boolean { const currentStamp = pendingForceRefresh.get(key); if (currentStamp === refreshStamp) return false; - return ( - currentStamp !== undefined && - Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS - ); + return currentStamp !== undefined && Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS; } -// 5min — same as Codex. Expiry is lazy on read (`isPendingForceRefresh`); -// this timer only reaps keys nobody fetches after the 5min TTL. +// 5min — same TTL as the original reap (CACHE_TTL_MS * 5). Expiry lazy on read +// (boundedMap ttl policy); this timer only keeps the sweep of +// pendingForceRefresh (5-min TTL, no systematic lazy read) + an opportunistic purge +// of stale cache entries along the way (get auto-purges). const _cacheCleanup = setInterval(() => { const now = Date.now(); - for (const [key, entry] of cache) { - if (now - entry.fetchedAt > CACHE_TTL_MS * 5) cache.delete(key); + for (const key of cache.keys()) { + cache.get(key); } for (const key of pendingForceRefresh.keys()) { dropExpiredPendingForceRefresh(key, now); @@ -289,10 +268,7 @@ export function convertUsageToQuotaInfo( const normalized = normalizeQuotaWindows(providerScopedWindows, context); const scopedEntries = Object.values(providerScopedWindows); - const percentUsed = scopedEntries.reduce( - (worst, entry) => Math.max(worst, entry.percentUsed), - 0 - ); + const percentUsed = scopedEntries.reduce((worst, entry) => Math.max(worst, entry.percentUsed), 0); const resetAt = scopedEntries.reduce<{ percentUsed: number; resetAt: string | null } | null>( (worst, entry) => (!worst || entry.percentUsed > worst.percentUsed ? entry : worst), @@ -322,10 +298,7 @@ function isAntigravityProvider(provider: string | null | undefined): boolean { return provider === "antigravity" || provider === "agy"; } -function antigravityWeeklyWindowMatchesFamily( - key: string, - family: "gemini" | "claude" -): boolean { +function antigravityWeeklyWindowMatchesFamily(key: string, family: "gemini" | "claude"): boolean { if (!key.endsWith("_weekly")) return false; return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly"; } @@ -456,7 +429,7 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) const unscopedQuota = convertUsageToQuotaInfo(usage, { provider }); registerQuotaWindows(provider, Object.keys(unscopedQuota?.windows || quota.windows || {})); - cache.set(key, { quota, fetchedAt: Date.now() }); + cache.set(key, quota); return quota; }; diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 562aa3ec30..1546351865 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -39,6 +39,7 @@ import { getExecutorTimeoutMs, resolveConnectionTimeoutMs, } from "../handlers/chatCore/upstreamTimeouts.ts"; +import { boundedMap } from "../../src/lib/quota/boundedMap.ts"; interface LearnedLimitEntry { provider: string; @@ -90,8 +91,12 @@ const enabledConnections = new Set(); const connectionRateLimitOverrides = new Map>(); // Store learned limits for persistence (debounced) -const learnedLimits: Record = {}; -const MAX_LEARNED_LIMITS = 200; +// One learned entry per limiter key (provider:connection[:model]). The previous +// `MAX_LEARNED_LIMITS = 200` was declared but never enforced; enforcing 200 would +// start evicting (dropping persisted limits) on deployments with many +// connection×model limiters, so the enforced cap is set well above that. +export const MAX_LEARNED_LIMITS = 2048; +const learnedLimits = boundedMap("learned-limits", MAX_LEARNED_LIMITS, "lru"); const limiterLastUsed = new Map(); let persistTimer: ReturnType | null = null; const pendingAsyncOperations = new Set>(); @@ -969,7 +974,7 @@ export function getAllRateLimitStatus() { * Get all learned limits (for dashboard display). */ export function getLearnedLimits() { - return { ...learnedLimits }; + return { ...Object.fromEntries(learnedLimits) }; } // ─── Persistence ──────────────────────────────────────────────────────────── @@ -977,10 +982,8 @@ export function getLearnedLimits() { async function persistLearnedLimitsNow() { try { const { updateSettings } = await import("@/lib/db/settings"); - await updateSettings({ learnedRateLimits: JSON.stringify(learnedLimits) }); - logRateLimit( - `💾 [RATE-LIMIT] Persisted learned limits for ${Object.keys(learnedLimits).length} provider(s)` - ); + await updateSettings({ learnedRateLimits: JSON.stringify(Object.fromEntries(learnedLimits)) }); + logRateLimit(`💾 [RATE-LIMIT] Persisted learned limits for ${learnedLimits.size} provider(s)`); } catch (err) { errorRateLimit("[RATE-LIMIT] Failed to persist learned limits:", err.message); } @@ -996,12 +999,12 @@ function recordLearnedLimit( model: string | null = null ) { const key = getLimiterKey(provider, connectionId, model); - learnedLimits[key] = { + learnedLimits.set(key, { ...limits, provider, connectionId, lastUpdated: Date.now(), - }; + }); // Debounce: save at most once per PERSIST_DEBOUNCE_MS if (!persistTimer) { @@ -1054,8 +1057,8 @@ export async function __resetRateLimitManagerForTests() { limiterWatchdog.reset(); shutdownHandlersRegistered = false; - for (const key of Object.keys(learnedLimits)) { - delete learnedLimits[key]; + for (const key of [...learnedLimits.keys()]) { + learnedLimits.delete(key); } if (pendingAsyncOperations.size > 0) { @@ -1108,14 +1111,14 @@ async function loadPersistedLimits() { const remaining = toNumber(data.remaining, 0); const minTime = toNumber(data.minTime, 0); - learnedLimits[key] = { + learnedLimits.set(key, { provider, connectionId, lastUpdated, ...(limit > 0 ? { limit } : {}), ...(remaining >= 0 ? { remaining } : {}), ...(minTime >= 0 ? { minTime } : {}), - }; + }); // Apply to limiter if it exists and has rate limit enabled if (connectionId && enabledConnections.has(connectionId)) { diff --git a/open-sse/services/routing/quality.ts b/open-sse/services/routing/quality.ts index b4e06a9ed9..f24f2fd269 100644 --- a/open-sse/services/routing/quality.ts +++ b/open-sse/services/routing/quality.ts @@ -30,6 +30,7 @@ * Statistics are plain arithmetic (EWMA + small counters), O(1) per event, safe * under the Node event loop's single thread — no lock-free/atomic trickery. */ +import { boundedMap } from "../../../src/lib/quota/boundedMap.ts"; /** EWMA smoothing factor (alpha). Lower = slower adaptation. */ const OPERATIONAL_ALPHA = 0.2; @@ -60,7 +61,18 @@ interface QualityState { lastTs: number; } -const states = new Map(); +/** + * Cap on tracked (provider, model) pairs. Only pairs that actually carry traffic + * are tracked, so normal deployments stay far below it; past it the + * least-recently-used pair without an evaluator score is dropped (it restarts + * cold/neutral). Pairs holding a semantic score are never evicted — that score + * only comes from an evaluator run and cannot be re-learned from traffic. + */ +export const QUALITY_STATES_CAP = 4096; + +const states = boundedMap("routing-quality", QUALITY_STATES_CAP, "lru", 0, { + shouldEvict: (s) => s.semantic === null, +}); function keyOf(provider: string, model: string): string { return `${provider}/${model}`; @@ -273,7 +285,9 @@ export function getQualityScore(provider: string, model: string): number { /** Full snapshot of the tracker for explainability / dashboard. */ export function getQualitySnapshot(limit = 200): ProviderQuality[] { const views: ProviderQuality[] = []; - for (const [key] of states) { + // Snapshot copy: LRU get refreshes recency (reinsertion), so iterating live + get() + // would loop forever. Snapshot behavior unchanged. + for (const [key] of [...states]) { const slash = key.indexOf("/"); const provider = slash >= 0 ? key.slice(0, slash) : key; const model = slash >= 0 ? key.slice(slash + 1) : key; diff --git a/src/lib/quota/accountBuckets.ts b/src/lib/quota/accountBuckets.ts index 6b7ac1a14d..e12a286d43 100644 --- a/src/lib/quota/accountBuckets.ts +++ b/src/lib/quota/accountBuckets.ts @@ -16,6 +16,7 @@ * * Part of: Quota Sharing Engine — Phase 3 (#3 multi-window buckets). */ +import { boundedMap } from "./boundedMap"; // --------------------------------------------------------------------------- // Types @@ -62,8 +63,21 @@ export const SATURATION_THRESHOLD_PCT = 100; // In-process store // --------------------------------------------------------------------------- +/** + * Soft cap on stored buckets. Only SATURATED buckets are ever stored (a + * below-threshold observation deletes the entry), so evicting a live one would + * silently turn "saturated" into "eligible" — the fail-open this store exists to + * prevent. Over the cap only buckets whose reset instant already passed (stale + * saturation the next read would drop anyway) are evicted; live saturated + * buckets are never evicted and the store grows past the cap instead. 4096 ≈ + * 1000+ connections × their 5h/7d/7d: windows all saturated at once. + */ +export const ACCOUNT_BUCKETS_SOFT_CAP = 4096; + /** Key: `${connectionId}::${windowKey}`. */ -const _buckets = new Map(); +const _buckets = boundedMap("account-buckets", ACCOUNT_BUCKETS_SOFT_CAP, "lru", 0, { + shouldEvict: (entry, _key, nowMs) => entry.resetsAtMs > 0 && nowMs >= entry.resetsAtMs, +}); function storeKey(connectionId: string, windowKey: string): string { return `${connectionId}::${windowKey}`; @@ -104,7 +118,7 @@ export function isBucketSaturated( ): boolean { if (!connectionId || !windowKey) return false; // fail-open const key = storeKey(connectionId, windowKey); - const entry = _buckets.get(key); + const entry = _buckets.get(key, nowMs); if (!entry) return false; // fail-open // Lazy reset: the window rolled over → the saturation is stale. @@ -156,7 +170,7 @@ export function recordUsage( return; } - _buckets.set(key, { saturated: true, resetsAtMs }); + _buckets.set(key, { saturated: true, resetsAtMs }, nowMs); } /** diff --git a/src/lib/quota/boundedMap.ts b/src/lib/quota/boundedMap.ts new file mode 100644 index 0000000000..4c42be2e7a --- /dev/null +++ b/src/lib/quota/boundedMap.ts @@ -0,0 +1,179 @@ +// src/lib/quota/boundedMap.ts — size-capped Map for in-process routing/quota caches. +import { createLogger } from "@/shared/utils/logger"; + +/** + * - `lru`: no expiry; over the cap the least-recently-USED entry goes first + * (`get` refreshes recency). + * - `ttl`: entries expire `ttlMs` after they were last `set` (expired reads return + * undefined and drop the entry); over the cap expired entries are swept first, + * then the oldest-WRITTEN entry goes (reads do not refresh anything). + */ +export type BoundedMapPolicy = "lru" | "ttl"; + +export interface BoundedMapLogger { + warn(meta: Record, message: string): void; +} + +export interface BoundedMapOptions { + /** + * Return false to protect an entry from eviction. Protected entries are NEVER + * evicted: when every remaining entry is protected the map grows past its cap + * (and says so in the log) rather than dropping state whose loss would change + * routing — e.g. a saturated quota bucket (fail-open) or a semantic quality pin. + */ + shouldEvict?: (value: V, key: string, nowMs: number) => boolean; + /** Defaults to the project logger (`quota:bounded-map`). */ + log?: BoundedMapLogger; + /** Minimum gap between two eviction log lines for one map. Default 60s. */ + logIntervalMs?: number; +} + +interface Entry { + value: V; + ts: number; +} + +export interface BoundedMap { + get(key: string, nowMs?: number): V | undefined; + set(key: string, value: V, nowMs?: number): void; + delete(key: string): boolean; + clear(): void; + readonly size: number; + keys(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, V]>; + /** Lifetime counters, for tests and diagnostics. */ + stats(): { evictions: number; overflowInserts: number }; +} + +const DEFAULT_LOG_INTERVAL_MS = 60_000; + +let defaultLogger: BoundedMapLogger | null = null; +function getDefaultLogger(): BoundedMapLogger { + defaultLogger ??= createLogger("quota:bounded-map"); + return defaultLogger; +} + +export function boundedMap( + name: string, + limit: number, + policy: BoundedMapPolicy, + ttlMs = 0, + options: BoundedMapOptions = {} +): BoundedMap { + const inner = new Map>(); + const shouldEvict = options.shouldEvict ?? (() => true); + const logIntervalMs = options.logIntervalMs ?? DEFAULT_LOG_INTERVAL_MS; + const expires = policy === "ttl" && ttlMs > 0; + + let evictions = 0; + let overflowInserts = 0; + // Aggregated logging: the first event logs at once, later ones are summed and + // reported at most once per logIntervalMs — a hot cache at its cap must not + // produce one log line per request. + let pendingEvictions = 0; + let pendingOverflows = 0; + let lastLogAt = Number.NEGATIVE_INFINITY; + + function maybeLog(nowMs: number): void { + if (pendingEvictions === 0 && pendingOverflows === 0) return; + if (nowMs - lastLogAt < logIntervalMs) return; + lastLogAt = nowMs; + (options.log ?? getDefaultLogger()).warn( + { + map: name, + cap: limit, + size: inner.size, + evicted: pendingEvictions, + overflowInserts: pendingOverflows, + }, + `[boundedMap:${name}] cap ${limit} reached: evicted ${pendingEvictions} entr${pendingEvictions === 1 ? "y" : "ies"}` + + (pendingOverflows > 0 + ? `, grew past the cap ${pendingOverflows}x (all entries protected)` + : "") + ); + pendingEvictions = 0; + pendingOverflows = 0; + } + + function isExpired(entry: Entry, nowMs: number): boolean { + return expires && nowMs - entry.ts > ttlMs; + } + + function sweepExpired(nowMs: number): void { + for (const [k, e] of inner) { + if (isExpired(e, nowMs)) inner.delete(k); + } + } + + /** Map iteration order is recency (lru) or write order (ttl): the first evictable key wins. */ + function findVictim(nowMs: number): string | undefined { + for (const [k, e] of inner) { + if (shouldEvict(e.value, k, nowMs)) return k; + } + return undefined; + } + + function makeRoom(nowMs: number): void { + if (inner.size < limit) return; + if (expires) sweepExpired(nowMs); + while (inner.size >= limit) { + const victim = findVictim(nowMs); + if (victim === undefined) { + overflowInserts += 1; + pendingOverflows += 1; + break; + } + inner.delete(victim); + evictions += 1; + pendingEvictions += 1; + } + maybeLog(nowMs); + } + + return { + get(key: string, nowMs: number = Date.now()): V | undefined { + const entry = inner.get(key); + if (!entry) return undefined; + if (isExpired(entry, nowMs)) { + inner.delete(key); + return undefined; + } + if (policy === "lru") { + inner.delete(key); + inner.set(key, entry); + } + return entry.value; + }, + set(key: string, value: V, nowMs: number = Date.now()): void { + if (inner.has(key)) inner.delete(key); + else makeRoom(nowMs); + inner.set(key, { value, ts: nowMs }); + }, + delete(key: string): boolean { + return inner.delete(key); + }, + clear(): void { + inner.clear(); + }, + get size(): number { + return inner.size; + }, + keys(): IterableIterator { + return inner.keys(); + }, + [Symbol.iterator](): IterableIterator<[string, V]> { + const nowMs = Date.now(); + const it = inner.entries(); + function* gen(): Generator<[string, V]> { + for (const [k, e] of it) { + if (isExpired(e, nowMs)) continue; + yield [k, e.value]; + } + } + return gen(); + }, + stats() { + return { evictions, overflowInserts }; + }, + }; +} diff --git a/src/lib/quota/saturationSignals.ts b/src/lib/quota/saturationSignals.ts index eb0365445a..b9986f5a62 100644 --- a/src/lib/quota/saturationSignals.ts +++ b/src/lib/quota/saturationSignals.ts @@ -24,6 +24,7 @@ */ import { createLogger } from "@/shared/utils/logger"; +import { boundedMap } from "./boundedMap"; import { updateAccountBuckets, type ClaudeUsageResult } from "./accountBuckets"; import type { QuotaUnit, QuotaWindow } from "./dimensions"; @@ -33,23 +34,20 @@ const log = createLogger("quota:saturation"); // Types // --------------------------------------------------------------------------- -interface CacheEntry { - value: number; // 0..1 - ts: number; // epoch ms -} - interface DimensionSpec { unit: QuotaUnit; window: QuotaWindow; } // --------------------------------------------------------------------------- -// In-memory cache (Map) +// In-memory cache (boundedMap, 30s TTL). Caps are +// generous (one entry per connection/provider/dimension or provider/connection): +// an evicted entry only costs one extra read, and normal deployments never hit them. // --------------------------------------------------------------------------- const CACHE_TTL_MS = 30_000; // 30 seconds -const _cache = new Map(); +const _cache = boundedMap("saturation-cache", 4096, "ttl", CACHE_TTL_MS); // Pending miss fetches, keyed like _cache. Concurrent getSaturation calls for // the same key share the promise instead of firing one upstream read each. @@ -79,9 +77,19 @@ interface TokenHeaderEntry { ts: number; } -const _rateLimitHeaders = new Map(); -const _tokenHeaders = new Map(); const RL_HEADER_TTL_MS = 5 * 60 * 1000; // 5 minutes +const _rateLimitHeaders = boundedMap( + "saturation-rl-headers", + 4096, + "ttl", + RL_HEADER_TTL_MS +); +const _tokenHeaders = boundedMap( + "saturation-token-headers", + 4096, + "ttl", + RL_HEADER_TTL_MS +); /** Test-only: clear the rate-limit + token header caches between asserts. */ export function _clearRateLimitHeaders(): void { @@ -249,7 +257,7 @@ export function getTokenHeaderSaturation( connectionId: string ): { saturation: number; resetAt: number | null } | null { const entry = _tokenHeaders.get(`${provider}:${connectionId}`); - if (!entry || Date.now() - entry.ts > RL_HEADER_TTL_MS) return null; + if (!entry) return null; if (!(entry.limit > 0)) return null; const used = entry.limit - entry.remaining; const saturation = Math.min(1, Math.max(0, used / entry.limit)); @@ -342,7 +350,7 @@ async function fetchBailianSaturation(connectionId: string, dim: DimensionSpec): */ function anthropicHeaderSaturation(connectionId: string): number { const entry = _rateLimitHeaders.get(`anthropic:${connectionId}`); - if (!entry || Date.now() - entry.ts > RL_HEADER_TTL_MS) return 0; + if (!entry) return 0; const used = entry.limit - entry.remaining; return Math.min(1, Math.max(0, used / entry.limit)); @@ -538,8 +546,8 @@ export async function getSaturation( ): Promise { const key = cacheKey(connectionId, provider, dim); const cached = _cache.get(key); - if (cached && Date.now() - cached.ts < CACHE_TTL_MS) { - return cached.value; + if (cached !== undefined) { + return cached; } const pending = _inflight.get(key); @@ -569,7 +577,7 @@ export async function getSaturation( ); value = 0; } - _cache.set(key, { value, ts: Date.now() }); + _cache.set(key, value); return value; })(); _inflight.set(key, task); diff --git a/tests/unit/quota-bounded-map.test.ts b/tests/unit/quota-bounded-map.test.ts new file mode 100644 index 0000000000..5781fb5237 --- /dev/null +++ b/tests/unit/quota-bounded-map.test.ts @@ -0,0 +1,316 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { boundedMap } = await import("../../src/lib/quota/boundedMap.ts"); +const core = await import("../../src/lib/db/core.ts"); + +type LogLine = { meta: Record; message: string }; +function captureLog() { + const lines: LogLine[] = []; + return { + lines, + log: { + warn: (meta: Record, message: string) => lines.push({ meta, message }), + }, + }; +} + +test.after(() => { + core.resetDbInstance(); +}); + +// ── boundedMap primitives ──────────────────────────────────────────────────── + +test("lru: evicts the least-recently-used entry and get refreshes recency", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "lru", 0, { log }); + m.set("a", 1); + m.set("b", 2); + m.set("c", 3); + assert.equal(m.get("a"), 1); // a is now the most recent + m.set("d", 4); // evicts b + assert.equal(m.get("b"), undefined); + assert.equal(m.get("a"), 1); + assert.equal(m.size, 3); + assert.deepEqual(m.stats(), { evictions: 1, overflowInserts: 0 }); +}); + +test("ttl: reads never refresh — the oldest-written entry is evicted", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "ttl", 60_000, { log }); + m.set("a", 1, 0); + m.set("b", 2, 1); + m.set("c", 3, 2); + assert.equal(m.get("a", 3), 1); // a read, but ttl ignores recency + m.set("d", 4, 4); // evicts a (oldest write), unlike lru which would evict b + assert.equal(m.get("a", 5), undefined); + assert.equal(m.get("b", 5), 2); +}); + +test("ttl: entries expire after ttlMs; lru entries never expire", () => { + const ttl = boundedMap("t", 10, "ttl", 1000); + ttl.set("a", 1, 0); + assert.equal(ttl.get("a", 1000), 1); + assert.equal(ttl.get("a", 1001), undefined); + assert.equal(ttl.size, 0, "an expired read drops the entry"); + + const lru = boundedMap("t", 10, "lru", 1000); + lru.set("a", 1, 0); + assert.equal(lru.get("a", 10_000_000), 1); +}); + +test("ttl: expired entries are swept before any fresh entry is evicted", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "ttl", 100, { log }); + m.set("old1", 1, 0); + m.set("fresh", 2, 150); + m.set("old2", 3, 0); + m.set("new", 4, 160); // old1 + old2 expired at 160 → swept, fresh survives + assert.equal(m.get("fresh", 170), 2); + assert.equal(m.get("new", 170), 4); + assert.equal(m.stats().evictions, 0, "a sweep of expired entries is not an eviction"); +}); + +test("protected entries are never evicted: the map grows past the cap instead", () => { + const { log } = captureLog(); + const m = boundedMap<{ pin: boolean }>("t", 2, "lru", 0, { + shouldEvict: (v) => !v.pin, + log, + }); + m.set("pin1", { pin: true }); + m.set("x", { pin: false }); + m.set("y", { pin: false }); // evicts x, the only evictable entry + assert.equal(m.get("x"), undefined); + m.set("pin2", { pin: true }); // evicts y + m.set("pin3", { pin: true }); // nothing evictable → grows + assert.equal(m.size, 3); + for (const key of ["pin1", "pin2", "pin3"]) assert.deepEqual(m.get(key), { pin: true }); + assert.deepEqual(m.stats(), { evictions: 2, overflowInserts: 1 }); +}); + +test("eviction logging is aggregated and rate-limited, never one line per eviction", () => { + const { lines, log } = captureLog(); + const m = boundedMap("hot-cache", 10, "lru", 0, { log, logIntervalMs: 60_000 }); + for (let i = 0; i < 10; i++) m.set(`seed-${i}`, i, 0); + for (let i = 0; i < 1000; i++) m.set(`k-${i}`, i, 1000 + i); // 1000 evictions in ~1s + assert.equal(lines.length, 1, "first eviction logs once, the rest are aggregated"); + assert.equal(lines[0].meta.map, "hot-cache"); + assert.equal(lines[0].meta.evicted, 1); + + m.set("late", 1, 1000 + 61_000); // past the interval → one summary line + assert.equal(lines.length, 2); + assert.equal( + lines[1].meta.evicted, + 1000, + "the summary carries every eviction since the last line" + ); + assert.match(lines[1].message, /\[boundedMap:hot-cache\] cap 10 reached: evicted 1000 entries/); +}); + +test("the default logger is the project logger, not console.warn", () => { + const original = console.warn; + let consoleWarnings = 0; + console.warn = () => { + consoleWarnings += 1; + }; + try { + const m = boundedMap("console-check", 1, "lru"); + m.set("a", 1); + m.set("b", 2); + m.set("c", 3); + assert.equal(m.stats().evictions, 2); + } finally { + console.warn = original; + } + assert.equal(consoleWarnings, 0); +}); + +test("keys() iteration tolerates delete during iteration", () => { + const m = boundedMap("t", 10, "lru"); + m.set("a", 1); + m.set("b", 2); + for (const key of m.keys()) { + if (key === "a") m.delete(key); + } + assert.deepEqual([...m.keys()], ["b"]); +}); + +// ── account buckets: never fail open ───────────────────────────────────────── + +test("account buckets never evict a live saturated bucket, even past the soft cap", async () => { + const b = await import("../../src/lib/quota/accountBuckets.ts"); + b._clearBucketsForTest(); + const now = 1_800_000_000_000; + const future = new Date(now + 3_600_000).toISOString(); + try { + const total = b.ACCOUNT_BUCKETS_SOFT_CAP + 25; + for (let i = 0; i < total; i++) b.recordUsage(`conn-live-${i}`, "5h", 100, future, now); + assert.equal(b._bucketCountForTest(), total, "no saturated bucket was dropped"); + assert.equal(b.isBucketSaturated("conn-live-0", "5h", now + 1), true, "oldest still saturated"); + assert.equal(b.isBucketSaturated(`conn-live-${total - 1}`, "5h", now + 1), true); + } finally { + b._clearBucketsForTest(); + } +}); + +test("account buckets at the cap evict buckets whose reset already passed first", async () => { + const b = await import("../../src/lib/quota/accountBuckets.ts"); + b._clearBucketsForTest(); + const now = 1_800_000_000_000; + const soon = new Date(now + 1_000).toISOString(); + const later = new Date(now + 3_600_000).toISOString(); + try { + b.recordUsage("conn-stale", "5h", 100, soon, now); // resets 1s later + for (let i = 1; i < b.ACCOUNT_BUCKETS_SOFT_CAP; i++) { + b.recordUsage(`conn-keep-${i}`, "5h", 100, later, now); + } + assert.equal(b._bucketCountForTest(), b.ACCOUNT_BUCKETS_SOFT_CAP); + b.recordUsage("conn-new", "5h", 100, later, now + 5_000); // stale bucket is evictable now + assert.equal(b._bucketCountForTest(), b.ACCOUNT_BUCKETS_SOFT_CAP, "stale bucket made room"); + assert.equal(b.isBucketSaturated("conn-keep-1", "5h", now + 5_001), true); + assert.equal(b.isBucketSaturated("conn-new", "5h", now + 5_001), true); + } finally { + b._clearBucketsForTest(); + } +}); + +// ── quality tracker under real pressure ────────────────────────────────────── + +test("quality: past the cap the LRU unscored pair is dropped, semantic pins survive", async () => { + const q = await import("../../open-sse/services/routing/quality.ts"); + q.resetQualityTracker(); + const event = (provider: string, model: string) => ({ + provider, + model, + outcome: "success", + status: 200, + latencyMs: 100, + finishReason: "stop", + }); + try { + q.recordQualityEvent(event("pinned", "model")); + q.setSemanticQuality("pinned", "model", 0.9, 1); + q.recordQualityEvent(event("first", "unscored")); + for (let i = 0; i < q.QUALITY_STATES_CAP + 50; i++) { + q.recordQualityEvent(event("bulk", `m-${i}`)); + } + const snapshot = q.getQualitySnapshot(q.QUALITY_STATES_CAP * 2); + assert.equal(snapshot.length, q.QUALITY_STATES_CAP, "tracker stays at its cap"); + const pinned = snapshot.find((v) => v.provider === "pinned"); + assert.ok(pinned, "the semantic pin survived the pressure"); + assert.equal(pinned.semantic, 0.9); + assert.equal(q.getProviderQuality("first", "unscored").samples, 0, "LRU unscored pair evicted"); + assert.ok(q.getProviderQuality("bulk", `m-${q.QUALITY_STATES_CAP + 49}`).samples > 0); + } finally { + q.resetQualityTracker(); + } +}); + +// ── learned rate limits ────────────────────────────────────────────────────── + +const HEADERS = { + "x-ratelimit-limit-requests": "100", + "x-ratelimit-remaining-requests": "5", + "x-ratelimit-reset-requests": "30s", +}; + +test("learnedLimits: a deployment above the old unenforced 200 keeps every entry", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + for (let i = 0; i < 300; i++) { + rl.enableRateLimitProtection(`conn-many-${i}`); + rl.updateFromHeaders("openai", `conn-many-${i}`, HEADERS, 200); + } + assert.equal(Object.keys(rl.getLearnedLimits()).length, 300); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +test("learnedLimits: capped at MAX_LEARNED_LIMITS", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + for (let i = 0; i <= rl.MAX_LEARNED_LIMITS; i++) { + rl.enableRateLimitProtection(`conn-cap-${i}`); + rl.updateFromHeaders("openai", `conn-cap-${i}`, HEADERS, 200); + } + const learned = rl.getLearnedLimits(); + assert.equal(Object.keys(learned).length, rl.MAX_LEARNED_LIMITS); + assert.equal(learned["openai:conn-cap-0"], undefined, "oldest entry evicted"); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +test("learnedLimits persist/load round-trip", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + const settings = await import("../../src/lib/db/settings.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + rl.enableRateLimitProtection("conn-rt"); + rl.updateFromHeaders("openai", "conn-rt", HEADERS, 200); + await rl.__flushLearnedLimitsForTests(); + const raw = (await settings.getSettings())?.learnedRateLimits; + assert.equal(typeof raw, "string"); + const parsed = JSON.parse(raw as string) as Record; + assert.equal(parsed["openai:conn-rt"]?.limit, 100); + await rl.__resetRateLimitManagerForTests(); + assert.deepEqual(rl.getLearnedLimits(), {}); + await rl.initializeRateLimits(); + assert.ok(rl.getLearnedLimits()["openai:conn-rt"], "load restores the persisted entry"); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +// ── TTL caches keep their read-through behaviour ──────────────────────────── + +test("saturation cache: hits stay cached below the cap, the evicted key refetches past it", async () => { + const sat = await import("../../src/lib/quota/saturationSignals.ts"); + sat._clearSaturationCache(); + let calls = 0; + sat.__setGenericUsageFetcherForTests(async () => { + calls++; + return { percentUsed: 0.1 }; + }); + const dim = { unit: "tokens", window: "hourly" } as const; + try { + for (let i = 0; i < 600; i++) await sat.getSaturation(`conn-sat-${i}`, "some-provider", dim); + const warm = calls; + await sat.getSaturation("conn-sat-0", "some-provider", dim); + assert.equal(calls, warm, "600 entries (above the old 512 cap) are still cached"); + + for (let i = 600; i < 4097; i++) await sat.getSaturation(`conn-sat-${i}`, "some-provider", dim); + const before = calls; + // ttl policy: the read above did not refresh conn-sat-0, so as the oldest write + // it is the one entry evicted by the 4097th insert. + await sat.getSaturation("conn-sat-0", "some-provider", dim); + assert.ok(calls > before, `evicted key must refetch (calls ${before} -> ${calls})`); + } finally { + sat.__setGenericUsageFetcherForTests(null); + sat._clearSaturationCache(); + } +}); + +test("quota-fetcher cache: entries above the old 512 cap stay cached", async () => { + const g = await import("../../open-sse/services/genericQuotaFetcher.ts"); + g.__resetGenericQuotaFetcherForTests(); + let calls = 0; + g.__setGenericUsageFetcherForTests(async () => { + calls++; + return { quotas: { session: { remainingPercentage: 50, resetAt: null } } }; + }); + try { + for (let i = 0; i < 600; i++) { + await g.fetchGenericQuota(`gqf-${i}`, { id: `gqf-${i}`, provider: "openai" }); + } + const before = calls; + await g.fetchGenericQuota("gqf-0", { id: "gqf-0", provider: "openai" }); + assert.equal(calls, before, "cache hit, no refetch"); + } finally { + g.__setGenericUsageFetcherForTests(null); + g.__resetGenericQuotaFetcherForTests(); + } +});