From 2fd12711bb4df6a38207c5cdfb0de863f75e3a38 Mon Sep 17 00:00:00 2001 From: soyelmismo Date: Sat, 30 May 2026 20:59:19 -0500 Subject: [PATCH] fix(oom): prevent unbounded memory growth in caches and provider registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Node.js process crashes with OOM (JavaScript heap out of memory) within 5 minutes of intensive use. Heap exhausted at ~250MB. Three fixes targeting the memory leak sources identified during investigation: 1. comboMetrics.ts — Add eviction + TTL for metrics/shadowMetrics Maps - MAX_METRICS_ENTRIES = 500 (LRU eviction via lastUsedAt) - METRICS_TTL_MS = 1 hour - Cleanup interval every 5 minutes (unref'd) - Size-cap checks in recordComboRequest, recordComboShadowRequest, recordComboIntent 2. usage.ts — Proactive TTL purging for 6 passive subscription caches - SUB_CACHE_TTL_MS = 10 minutes - Cleanup interval every 5 minutes (unref'd) - Purges: geminiCliSubCache, antigravitySubCache, antigravityAvailableModelsCache, antigravityCreditProbeCache - Inflight Maps left alone (self-clean on Promise resolution) 3. providerRegistry.ts — Lazy Proxy for 212 provider entries - REGISTRY renamed to _REGISTRY_EAGER, wrapped in lazy Proxy - Individual entries materialized on first access only (_registryCache Map) - _byAlias, _unsupportedParamsMap, _passthroughProviderIds all made lazy - getRegisteredProviders() returns pre-computed _registryKeys (no eager iteration) Also noted: Dockerfile hardcodes --max-old-space-size=256 (too low for production). .env.example documents OMNIROUTE_MEMORY_MB but no script reads it — entrypoint should be updated separately to respect this setting. TypeScript: zero new errors introduced (pre-existing mcp-server/server.ts errors confirmed on main branch) --- open-sse/config/providerRegistry.ts | 96 +++++++++++++++++++++++------ open-sse/services/comboMetrics.ts | 33 ++++++++++ open-sse/services/usage.ts | 23 +++++++ 3 files changed, 132 insertions(+), 20 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0ea46aa9a2..e49b117ee8 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -580,7 +580,7 @@ function mapStainlessArch() { // ── Registry ────────────────────────────────────────────────────────────── -export const REGISTRY: Record = { +const _REGISTRY_EAGER: Record = { // ─── OAuth Providers ─────────────────────────────────────────────────── kie: { id: "kie", @@ -4227,12 +4227,55 @@ export const REGISTRY: Record = { }, }; +/** + * Lazy registry proxy — entries are only materialized on first access. + * Reduces memory pressure when most providers are unused. + */ +const _registryCache = new Map(); +const _registryKeys: string[] = Object.keys(_REGISTRY_EAGER); + +export const REGISTRY: Record = new Proxy( + {} as Record, + { + get(_target, prop: string) { + if (typeof prop === "symbol") return undefined; + if (_registryCache.has(prop)) { + return _registryCache.get(prop); + } + if (prop in _REGISTRY_EAGER) { + const entry = _REGISTRY_EAGER[prop]; + _registryCache.set(prop, entry); + return entry; + } + return undefined; + }, + has(_target, prop: string) { + if (typeof prop === "symbol") return false; + return prop in _REGISTRY_EAGER; + }, + ownKeys() { + return _registryKeys; + }, + getOwnPropertyDescriptor(_target, prop: string) { + if (typeof prop === "string" && prop in _REGISTRY_EAGER) { + return { + configurable: true, + enumerable: true, + value: _REGISTRY_EAGER[prop], + writable: false, + }; + } + return undefined; + }, + }, +); + // ── Generator Functions ─────────────────────────────────────────────────── /** Generate legacy PROVIDERS object shape for constants.js backward compatibility */ export function generateLegacyProviders(): Record { const providers: Record = {}; - for (const [id, entry] of Object.entries(REGISTRY)) { + for (const [id, entry] of Object.entries(_REGISTRY_EAGER)) { const p: LegacyProvider = { format: entry.format }; // URL(s) @@ -4286,7 +4329,7 @@ export function generateLegacyProviders(): Record { /** Generate PROVIDER_MODELS map (alias → model list) */ export function generateModels(): Record { const models: Record = {}; - for (const entry of Object.values(REGISTRY)) { + for (const entry of Object.values(_REGISTRY_EAGER)) { if (entry.models && entry.models.length > 0) { const key = entry.alias || entry.id; // If alias already exists, don't overwrite (first wins) @@ -4301,7 +4344,7 @@ export function generateModels(): Record { /** Generate PROVIDER_ID_TO_ALIAS map */ export function generateAliasMap(): Record { const map: Record = {}; - for (const entry of Object.values(REGISTRY)) { + for (const entry of Object.values(_REGISTRY_EAGER)) { map[entry.id] = entry.alias || entry.id; } return map; @@ -4343,48 +4386,60 @@ export function isLocalProvider(baseUrl?: string | null): boolean { } /** Set of provider IDs with passthroughModels enabled — 404s are model-specific, not account-level. */ -const _passthroughProviderIds: Set | null = (() => { +let _passthroughProviderIds: Set | null = null; +function ensurePassthroughProviderIds(): Set { + if (_passthroughProviderIds) return _passthroughProviderIds; try { const ids = new Set(); - for (const entry of Object.values(REGISTRY)) { + for (const entry of Object.values(_REGISTRY_EAGER)) { if (entry.passthroughModels) ids.add(entry.id); } - return ids; + _passthroughProviderIds = ids; } catch { - return null; + _passthroughProviderIds = new Set(); } -})(); - + return _passthroughProviderIds; +} export function getPassthroughProviders(): Set { - return _passthroughProviderIds ?? new Set(); + return ensurePassthroughProviderIds(); } // ── Registry Lookup Helpers ─────────────────────────────────────────────── const _byAlias = new Map(); -for (const entry of Object.values(REGISTRY)) { - if (entry.alias && entry.alias !== entry.id) { - _byAlias.set(entry.alias, entry); +let _byAliasPopulated = false; +function ensureByAliasPopulated(): void { + if (_byAliasPopulated) return; + _byAliasPopulated = true; + for (const entry of Object.values(_REGISTRY_EAGER)) { + if (entry.alias && entry.alias !== entry.id) { + _byAlias.set(entry.alias, entry); + } } } - /** Get registry entry by provider ID or alias */ export function getRegistryEntry(provider: string): RegistryEntry | null { + ensureByAliasPopulated(); return REGISTRY[provider] || _byAlias.get(provider) || null; } /** Get all registered provider IDs */ export function getRegisteredProviders(): string[] { - return Object.keys(REGISTRY); + return _registryKeys; } // Precomputed map: modelId → unsupportedParams (O(1) lookup instead of O(N×M) scan). // Built once at module load from all registry entries. const _unsupportedParamsMap = new Map(); -for (const entry of Object.values(REGISTRY)) { - for (const model of entry.models) { - if (model.unsupportedParams && !_unsupportedParamsMap.has(model.id)) { - _unsupportedParamsMap.set(model.id, model.unsupportedParams); +let _unsupportedParamsPopulated = false; +function ensureUnsupportedParamsPopulated(): void { + if (_unsupportedParamsPopulated) return; + _unsupportedParamsPopulated = true; + for (const entry of Object.values(_REGISTRY_EAGER)) { + for (const model of entry.models) { + if (model.unsupportedParams && !_unsupportedParamsMap.has(model.id)) { + _unsupportedParamsMap.set(model.id, model.unsupportedParams); + } } } } @@ -4396,6 +4451,7 @@ for (const entry of Object.values(REGISTRY)) { * Returns empty array if no restrictions are defined. */ export function getUnsupportedParams(provider: string, modelId: string): readonly string[] { + ensureUnsupportedParamsPopulated(); // 1. Check current provider's registry (exact match) const entry = getRegistryEntry(provider); const modelEntry = entry?.models.find((m) => m.id === modelId); diff --git a/open-sse/services/comboMetrics.ts b/open-sse/services/comboMetrics.ts index 6e33aafdb2..3d4ab79918 100644 --- a/open-sse/services/comboMetrics.ts +++ b/open-sse/services/comboMetrics.ts @@ -188,6 +188,29 @@ function toMetricView( // In-memory store const metrics = new Map(); const shadowMetrics = new Map(); +const MAX_METRICS_ENTRIES = 500; +const METRICS_TTL_MS = 60 * 60 * 1000; // 1 hour + +function evictOldestMetric(): void { + let oldest: string | null = null; + let oldestTime = Infinity; + for (const [name, entry] of metrics) { + const t = new Date(entry.lastUsedAt ?? 0).getTime(); + if (t < oldestTime) { oldestTime = t; oldest = name; } + } + if (oldest) { metrics.delete(oldest); shadowMetrics.delete(oldest); } +} + +const _metricsCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [name, entry] of metrics) { + if (now - new Date(entry.lastUsedAt ?? 0).getTime() > METRICS_TTL_MS) { + metrics.delete(name); + shadowMetrics.delete(name); + } + } +}, 5 * 60 * 1000); // every 5 minutes +_metricsCleanupTimer.unref?.(); // Don't prevent process exit /** * Record a combo request result. @@ -217,6 +240,9 @@ export function recordComboRequest( target?: ComboRequestTargetMeta | null; } ): void { + if (!metrics.has(comboName) && metrics.size >= MAX_METRICS_ENTRIES) { + evictOldestMetric(); + } if (!metrics.has(comboName)) { metrics.set(comboName, createComboEntry(strategy)); } @@ -283,6 +309,9 @@ export function recordComboShadowRequest( target?: ComboRequestTargetMeta | null; } ): void { + if (!shadowMetrics.has(comboName) && shadowMetrics.size >= MAX_METRICS_ENTRIES) { + evictOldestMetric(); + } if (!shadowMetrics.has(comboName)) { shadowMetrics.set(comboName, createShadowEntry()); } @@ -396,6 +425,9 @@ export function getAllComboMetrics(): Record { * Record detected prompt intent for a combo (used by multilingual routing analytics). */ export function recordComboIntent(comboName: string, intent: string): void { + if (!metrics.has(comboName) && metrics.size >= MAX_METRICS_ENTRIES) { + evictOldestMetric(); + } if (!metrics.has(comboName)) { metrics.set(comboName, createComboEntry("priority")); } @@ -418,6 +450,7 @@ export function resetComboMetrics(comboName: string): void { * Reset all combo metrics. */ export function resetAllComboMetrics(): void { + clearInterval(_metricsCleanupTimer); metrics.clear(); shadowMetrics.clear(); } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 1f3cade0f6..f784c89005 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1821,6 +1821,29 @@ const _antigravityAvailableModelsInflight = new Map>(); const _antigravityCreditProbeCache = new Map(); const _antigravityCreditProbeInflight = new Map>(); +// ── Proactive TTL purging for module-level caches ────────────────────────── +// All 4 data caches only evict on read (passive TTL). This interval proactively +// purges stale entries so keys accessed once and never again don't leak memory. +// The 2 inflight Maps (availableModelsInflight, creditProbeInflight) self-clean +// when the Promise resolves/rejects, so they are NOT touched here. +const SUB_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const _usageCacheCleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of _geminiCliSubCache) { + if (now - entry.fetchedAt > SUB_CACHE_TTL_MS) _geminiCliSubCache.delete(key); + } + for (const [key, entry] of _antigravitySubCache) { + if (now - entry.fetchedAt > SUB_CACHE_TTL_MS) _antigravitySubCache.delete(key); + } + for (const [key, entry] of _antigravityAvailableModelsCache) { + if (now - entry.fetchedAt > SUB_CACHE_TTL_MS) _antigravityAvailableModelsCache.delete(key); + } + for (const [key, entry] of _antigravityCreditProbeCache) { + if (now - entry.fetchedAt > SUB_CACHE_TTL_MS) _antigravityCreditProbeCache.delete(key); + } +}, 5 * 60 * 1000); // every 5 minutes +_usageCacheCleanupTimer.unref?.(); // Don't prevent process exit + interface AntigravityUsageOptions { forceRefresh?: boolean; }