diff --git a/changelog.d/fixes/8065-quota-cache-globalthis.md b/changelog.d/fixes/8065-quota-cache-globalthis.md new file mode 100644 index 0000000000..8c4251adc1 --- /dev/null +++ b/changelog.d/fixes/8065-quota-cache-globalthis.md @@ -0,0 +1 @@ +- fix(routing): anchor quota routing cache on globalThis so cross-chunk reads/writes stay consistent (#8065) diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index f37dbc2533..6371cde370 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -59,11 +59,41 @@ const REFRESH_INTERVAL_MS = 60 * 1000; // Background tick every 1 minute export const DEFAULT_QUOTA_THRESHOLD_PERCENT = 99; // ─── State ────────────────────────────────────────────────────────────────── +// +// #8065 — Next.js `output: "standalone"` builds can load this module from +// independent webpack chunks (e.g. the instrumentation-hook-started +// `providerLimitsSyncScheduler` write path vs an API-route/SSE-handler read +// path such as `auth.ts::evaluateQuotaLimitPolicy()`) — each gets its OWN +// top-level module state, so a bare module-scope `Map` silently splits the +// cache in two. Anchor all mutable state on `globalThis` so every chunk +// shares one instance. Mirrors the identical fix already applied for +// `src/lib/pricingSync.ts` (commit de9d748dac, #6325) and the same pattern in +// `src/lib/credentialHealth/cache.ts`. + +interface QuotaCacheState { + cache: Map; + refreshingSet: Set; + refreshTimer: ReturnType | null; + tickRunning: boolean; +} + +declare global { + var __omnirouteQuotaCacheState: QuotaCacheState | undefined; +} + +function getState(): QuotaCacheState { + if (!globalThis.__omnirouteQuotaCacheState) { + globalThis.__omnirouteQuotaCacheState = { + cache: new Map(), + refreshingSet: new Set(), + refreshTimer: null, + tickRunning: false, + }; + } + return globalThis.__omnirouteQuotaCacheState; +} -const cache = new Map(); const MAX_CONCURRENT_REFRESHES = 5; -let refreshTimer: ReturnType | null = null; -let tickRunning = false; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -203,7 +233,7 @@ function normalizeQuotas(rawQuotas: Record): Record(); - async function refreshEntry(entry: QuotaCacheEntry) { + const { cache, refreshingSet } = getState(); if (refreshingSet.has(entry.connectionId)) return; refreshingSet.add(entry.connectionId); @@ -546,13 +576,14 @@ function needsRefresh(entry: QuotaCacheEntry, now: number): boolean { } async function backgroundRefreshTick() { - if (tickRunning) return; - tickRunning = true; + const state = getState(); + if (state.tickRunning) return; + state.tickRunning = true; try { cleanupOldSnapshots(); const now = Date.now(); - const pending = [...cache.values()].filter((e) => needsRefresh(e, now)); + const pending = [...state.cache.values()].filter((e) => needsRefresh(e, now)); // Refresh in batches to avoid thundering herd for (let i = 0; i < pending.length; i += MAX_CONCURRENT_REFRESHES) { @@ -560,7 +591,7 @@ async function backgroundRefreshTick() { await Promise.allSettled(batch.map(refreshEntry)); } } finally { - tickRunning = false; + state.tickRunning = false; } } @@ -568,18 +599,20 @@ async function backgroundRefreshTick() { * Start the background refresh timer. */ export function startBackgroundRefresh() { - if (refreshTimer) return; - refreshTimer = setInterval(backgroundRefreshTick, REFRESH_INTERVAL_MS); - refreshTimer?.unref?.(); + const state = getState(); + if (state.refreshTimer) return; + state.refreshTimer = setInterval(backgroundRefreshTick, REFRESH_INTERVAL_MS); + state.refreshTimer?.unref?.(); } /** * Stop the background refresh timer. */ export function stopBackgroundRefresh() { - if (refreshTimer) { - clearInterval(refreshTimer); - refreshTimer = null; + const state = getState(); + if (state.refreshTimer) { + clearInterval(state.refreshTimer); + state.refreshTimer = null; } } @@ -595,6 +628,7 @@ export function getQuotaCacheStats() { ageMs: number; }> = []; + const { cache } = getState(); for (const entry of cache.values()) { entries.push({ connectionId: entry.connectionId.slice(0, 8) + "...", diff --git a/tests/unit/repro-8065-quota-cache-cross-instance.test.ts b/tests/unit/repro-8065-quota-cache-cross-instance.test.ts new file mode 100644 index 0000000000..aa1bd7970d --- /dev/null +++ b/tests/unit/repro-8065-quota-cache-cross-instance.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-cache-xinst-8065-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#8065 a renewed quota written by one module instance is invisible to another module instance's routing read", async () => { + const connectionId = "conn-codex-8065"; + + // Instance R: simulates auth.ts's routing/credential-selection chunk. + const quotaCacheR = await import("../../src/domain/quotaCache.ts?instance=R"); + quotaCacheR.setQuotaCache(connectionId, "codex", { + session: { remainingPercentage: 0, resetAt: new Date(Date.now() + 5 * 86400000).toISOString() }, + }); + assert.equal(quotaCacheR.isQuotaExhaustedForRequest(connectionId, "codex"), true); + + // Instance W: simulates providerLimitsSyncScheduler's instrumentation-node.ts chunk. + const quotaCacheW = await import("../../src/domain/quotaCache.ts?instance=W"); + quotaCacheW.setQuotaCache(connectionId, "codex", { + session: { remainingPercentage: 100, resetAt: new Date(Date.now() + 7 * 86400000).toISOString() }, + }); + assert.equal(quotaCacheW.isQuotaExhaustedForRequest(connectionId, "codex"), false); + + // Back on instance R — must see the renewed quota written by instance W. + assert.equal( + quotaCacheR.isQuotaExhaustedForRequest(connectionId, "codex"), + false, + "instance R must see the renewed quota written by instance W" + ); +});