fix(oom): prevent unbounded memory growth in caches and provider registry

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)
This commit is contained in:
soyelmismo
2026-05-30 20:59:19 -05:00
parent e51ab949fa
commit 2fd12711bb
3 changed files with 132 additions and 20 deletions

View File

@@ -580,7 +580,7 @@ function mapStainlessArch() {
// ── Registry ──────────────────────────────────────────────────────────────
export const REGISTRY: Record<string, RegistryEntry> = {
const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
// ─── OAuth Providers ───────────────────────────────────────────────────
kie: {
id: "kie",
@@ -4227,12 +4227,55 @@ export const REGISTRY: Record<string, RegistryEntry> = {
},
};
/**
* Lazy registry proxy — entries are only materialized on first access.
* Reduces memory pressure when most providers are unused.
*/
const _registryCache = new Map<string, RegistryEntry>();
const _registryKeys: string[] = Object.keys(_REGISTRY_EAGER);
export const REGISTRY: Record<string, RegistryEntry> = new Proxy(
{} as Record<string, RegistryEntry>,
{
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<string, LegacyProvider> {
const providers: Record<string, LegacyProvider> = {};
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<string, LegacyProvider> {
/** Generate PROVIDER_MODELS map (alias → model list) */
export function generateModels(): Record<string, RegistryModel[]> {
const models: Record<string, RegistryModel[]> = {};
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<string, RegistryModel[]> {
/** Generate PROVIDER_ID_TO_ALIAS map */
export function generateAliasMap(): Record<string, string> {
const map: Record<string, string> = {};
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<string> | null = (() => {
let _passthroughProviderIds: Set<string> | null = null;
function ensurePassthroughProviderIds(): Set<string> {
if (_passthroughProviderIds) return _passthroughProviderIds;
try {
const ids = new Set<string>();
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<string>();
}
})();
return _passthroughProviderIds;
}
export function getPassthroughProviders(): Set<string> {
return _passthroughProviderIds ?? new Set<string>();
return ensurePassthroughProviderIds();
}
// ── Registry Lookup Helpers ───────────────────────────────────────────────
const _byAlias = new Map<string, RegistryEntry>();
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<string, readonly string[]>();
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);

View File

@@ -188,6 +188,29 @@ function toMetricView<T extends ModelMetrics>(
// In-memory store
const metrics = new Map<string, ComboMetricsEntry>();
const shadowMetrics = new Map<string, ComboShadowMetricsEntry>();
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<string, ComboMetricsView | null> {
* 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();
}

View File

@@ -1821,6 +1821,29 @@ const _antigravityAvailableModelsInflight = new Map<string, Promise<unknown>>();
const _antigravityCreditProbeCache = new Map<string, { data: number | null; fetchedAt: number }>();
const _antigravityCreditProbeInflight = new Map<string, Promise<number | null>>();
// ── 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;
}