mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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.
This commit is contained in:
committed by
GitHub
parent
98b1aa34b5
commit
1503044055
1
changelog.d/fixes/8065-quota-cache-globalthis.md
Normal file
1
changelog.d/fixes/8065-quota-cache-globalthis.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(routing): anchor quota routing cache on globalThis so cross-chunk reads/writes stay consistent (#8065)
|
||||
@@ -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<string, QuotaCacheEntry>;
|
||||
refreshingSet: Set<string>;
|
||||
refreshTimer: ReturnType<typeof setInterval> | 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<string, QuotaCacheEntry>();
|
||||
const MAX_CONCURRENT_REFRESHES = 5;
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let tickRunning = false;
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -203,7 +233,7 @@ function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaIn
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function __clearForTests() {
|
||||
cache.clear();
|
||||
getState().cache.clear();
|
||||
}
|
||||
|
||||
function isAntigravityQuotaExhausted(
|
||||
@@ -260,7 +290,7 @@ export function isQuotaExhaustedForRequest(
|
||||
provider: string,
|
||||
requestedModel: string | null = null
|
||||
): boolean {
|
||||
const entry = cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId);
|
||||
const entry = getState().cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId);
|
||||
if (!entry) return false;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -294,7 +324,7 @@ export function setQuotaCache(
|
||||
const exhausted = isExhausted(quotas);
|
||||
// #4438 — capture the prior entry BEFORE overwriting the cache so we can skip
|
||||
// redundant snapshot writes for idle connections whose quota didn't change.
|
||||
const prior = cache.get(connectionId);
|
||||
const prior = getState().cache.get(connectionId);
|
||||
const entry: QuotaCacheEntry = {
|
||||
connectionId,
|
||||
provider,
|
||||
@@ -303,7 +333,7 @@ export function setQuotaCache(
|
||||
exhausted,
|
||||
nextResetAt: exhausted ? earliestResetAt(quotas) : null,
|
||||
};
|
||||
cache.set(connectionId, entry);
|
||||
getState().cache.set(connectionId, entry);
|
||||
|
||||
if (entry && rawQuotas) {
|
||||
for (const [windowKey, quotaInfo] of Object.entries(rawQuotas)) {
|
||||
@@ -355,10 +385,11 @@ export function setQuotaCache(
|
||||
* Get cached quota entry (returns null if not cached).
|
||||
*/
|
||||
export function getQuotaCache(connectionId: string): QuotaCacheEntry | null {
|
||||
return cache.get(connectionId) || null;
|
||||
return getState().cache.get(connectionId) || null;
|
||||
}
|
||||
|
||||
function hydrateQuotaCacheFromSnapshots(connectionId: string): QuotaCacheEntry | null {
|
||||
const { cache } = getState();
|
||||
if (cache.has(connectionId)) return cache.get(connectionId) || null;
|
||||
|
||||
let snapshots;
|
||||
@@ -423,7 +454,7 @@ function hydrateQuotaCacheFromSnapshots(connectionId: string): QuotaCacheEntry |
|
||||
* Returns false if no cache entry exists (unknown = assume available).
|
||||
*/
|
||||
export function isAccountQuotaExhausted(connectionId: string): boolean {
|
||||
const entry = cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId);
|
||||
const entry = getState().cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId);
|
||||
if (!entry) return false;
|
||||
if (!entry.exhausted) return false;
|
||||
|
||||
@@ -455,7 +486,7 @@ export function getQuotaWindowStatus(
|
||||
windowName: string,
|
||||
thresholdPercent = DEFAULT_QUOTA_THRESHOLD_PERCENT
|
||||
): QuotaWindowStatus | null {
|
||||
const entry = cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId);
|
||||
const entry = getState().cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId);
|
||||
if (!entry) return null;
|
||||
|
||||
const now = Date.now();
|
||||
@@ -490,7 +521,7 @@ export function getQuotaWindowStatus(
|
||||
* Uses 5-minute fixed TTL since we don't know the actual resetAt.
|
||||
*/
|
||||
export function markAccountExhaustedFrom429(connectionId: string, provider: string) {
|
||||
cache.set(connectionId, {
|
||||
getState().cache.set(connectionId, {
|
||||
connectionId,
|
||||
provider,
|
||||
quotas: {},
|
||||
@@ -502,9 +533,8 @@ export function markAccountExhaustedFrom429(connectionId: string, provider: stri
|
||||
|
||||
// ─── Background Refresh ─────────────────────────────────────────────────────
|
||||
|
||||
const refreshingSet = new Set<string>();
|
||||
|
||||
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) + "...",
|
||||
|
||||
40
tests/unit/repro-8065-quota-cache-cross-instance.test.ts
Normal file
40
tests/unit/repro-8065-quota-cache-cross-instance.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user