From 150304405574d3ea3b1f65b2f4188a15f5b2f0d2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:28:03 -0300 Subject: [PATCH] fix(routing): anchor quota cache on globalThis for cross-chunk consistency (#8065) (#8150) src/domain/quotaCache.ts kept its quota state (cache Map, refreshingSet, refreshTimer, tickRunning) in bare module-scope variables. In a Next.js 16 `output: "standalone"` build, code reachable only from instrumentation-node.ts (providerLimitsSyncScheduler's write path) and code reachable from an API-route/SSE-handler chunk (auth.ts::evaluateQuotaLimitPolicy()'s read path) can be compiled into separate server chunks, each independently instantiating this module's top-level state. A quota renewal written by the sync scheduler was invisible to the routing read path, leaving accounts stuck exhausted until a full process restart. Anchors all quota-cache state on a single globalThis-held object, following the same pattern already used in src/lib/credentialHealth/cache.ts and src/lib/db/core.ts, and the identical fix already shipped for this exact failure mode in src/lib/pricingSync.ts (#6325 / commit de9d748dac). Regression test: tests/unit/repro-8065-quota-cache-cross-instance.test.ts imports the module twice under distinct query-string specifiers to force two separate module instances, proving a write from one instance is now visible to a read from the other. --- .../fixes/8065-quota-cache-globalthis.md | 1 + src/domain/quotaCache.ts | 80 +++++++++++++------ ...ro-8065-quota-cache-cross-instance.test.ts | 40 ++++++++++ 3 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 changelog.d/fixes/8065-quota-cache-globalthis.md create mode 100644 tests/unit/repro-8065-quota-cache-cross-instance.test.ts 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" + ); +});