mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user