Spaces:
Runtime error
Runtime error
| /** | |
| * Quota Cache β Domain Layer | |
| * | |
| * In-memory cache of provider quota data per connectionId. | |
| * Populated by: | |
| * - Dashboard usage endpoint (GET /api/usage/[connectionId]) | |
| * - 429 responses marking account as exhausted | |
| * | |
| * Background refresh runs every 1 minute: | |
| * - Active accounts (quota > 0%): refetch every 5 minutes | |
| * - Exhausted accounts: refetch every 5 minutes (or immediately after resetAt passes) | |
| * | |
| * @module domain/quotaCache | |
| */ | |
| import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts"; | |
| import { getProviderConnectionById, resolveProxyForConnection } from "@/lib/localDb"; | |
| import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; | |
| import { safePercentage } from "@/shared/utils/formatting"; | |
| import { | |
| saveQuotaSnapshot, | |
| cleanupOldSnapshots, | |
| getLatestQuotaSnapshotsForConnection, | |
| } from "@/lib/db/quotaSnapshots"; | |
| import { recordProviderQuotaResetEventIfChanged } from "@/lib/db/quotaResetEvents"; | |
| // βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| interface QuotaInfo { | |
| remainingPercentage: number; | |
| resetAt: string | null; | |
| } | |
| interface QuotaCacheEntry { | |
| connectionId: string; | |
| provider: string; | |
| quotas: Record<string, QuotaInfo>; | |
| fetchedAt: number; | |
| exhausted: boolean; | |
| nextResetAt: string | null; | |
| windowDurationMs?: number | null; // T08: optional rolling window duration | |
| } | |
| interface QuotaWindowStatus { | |
| remainingPercentage: number; | |
| usedPercentage: number; | |
| resetAt: string | null; | |
| reachedThreshold: boolean; | |
| } | |
| // βββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ACTIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes for active accounts | |
| const EXHAUSTED_TTL_MS = 5 * 60 * 1000; // 5 minutes for 429-sourced entries (no resetAt) | |
| const EXHAUSTED_REFRESH_MS = 5 * 60 * 1000; // 5 minutes: recheck exhausted accounts (aligned with TTL) | |
| const REFRESH_INTERVAL_MS = 60 * 1000; // Background tick every 1 minute | |
| export const DEFAULT_QUOTA_THRESHOLD_PERCENT = 99; | |
| // βββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const cache = new Map<string, QuotaCacheEntry>(); | |
| const MAX_CONCURRENT_REFRESHES = 5; | |
| let refreshTimer: ReturnType<typeof setInterval> | null = null; | |
| let tickRunning = false; | |
| // βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function isExhausted(quotas: Record<string, QuotaInfo>): boolean { | |
| const entries = Object.values(quotas); | |
| if (entries.length === 0) return false; | |
| return entries.every((q) => q.remainingPercentage <= 0); | |
| } | |
| /** | |
| * T08 β Auto-advance quota window. | |
| * If we know the window duration, advance past the expired window(s) to | |
| * avoid blocking requests when the quota reset already happened but the | |
| * background refresh hasn't run yet. | |
| */ | |
| function advancedWindowResetAt(entry: QuotaCacheEntry, now: number): { exhausted: false } | null { | |
| if (!entry.nextResetAt) return null; | |
| const resetMs = parseDate(entry.nextResetAt); | |
| if (resetMs === null) return null; | |
| // If the window's resetAt is in the past, the quota has been renewed. | |
| // Eagerly mark as available so requests don't wait for the 5-min TTL. | |
| if (resetMs <= now) { | |
| return { exhausted: false }; | |
| } | |
| // If we know the window duration, check if the *next* window also passed. | |
| if (entry.windowDurationMs && entry.windowDurationMs > 0) { | |
| const elapsed = now - resetMs; | |
| if (elapsed >= 0) return { exhausted: false }; | |
| } | |
| return null; | |
| } | |
| function parseDate(value: string): number | null { | |
| const ms = new Date(value).getTime(); | |
| return Number.isNaN(ms) ? null : ms; | |
| } | |
| function clampPercent(value: number): number { | |
| if (!Number.isFinite(value)) return 0; | |
| return Math.max(0, Math.min(100, value)); | |
| } | |
| function normalizeWindowKey(value: unknown): string { | |
| if (typeof value !== "string") return ""; | |
| return value | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, " ") | |
| .trim(); | |
| } | |
| function resolveQuotaWindow( | |
| quotas: Record<string, QuotaInfo>, | |
| windowName: string | |
| ): QuotaInfo | null { | |
| const direct = quotas[windowName]; | |
| if (direct) return direct; | |
| const normalizedTarget = normalizeWindowKey(windowName); | |
| if (!normalizedTarget) return null; | |
| const prefixMatches: Array<{ key: string; quota: QuotaInfo }> = []; | |
| for (const [key, quota] of Object.entries(quotas)) { | |
| const normalizedKey = normalizeWindowKey(key); | |
| if (!normalizedKey) continue; | |
| if (normalizedKey === normalizedTarget) return quota; | |
| // Support canonical selection of generic windows from labeled windows, | |
| // e.g. "weekly" from "weekly (7d)" or "session" from "session (5h)". | |
| if (normalizedKey.startsWith(`${normalizedTarget} `)) { | |
| prefixMatches.push({ key, quota }); | |
| } | |
| } | |
| // Deterministic fallback: choose the lexicographically first matching key. | |
| if (prefixMatches.length > 0) { | |
| prefixMatches.sort((a, b) => a.key.localeCompare(b.key)); | |
| return prefixMatches[0].quota; | |
| } | |
| return null; | |
| } | |
| function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null { | |
| let earliest: string | null = null; | |
| let earliestMs = Infinity; | |
| for (const q of Object.values(quotas)) { | |
| if (!q.resetAt) continue; | |
| const ms = parseDate(q.resetAt); | |
| if (ms !== null && ms < earliestMs) { | |
| earliestMs = ms; | |
| earliest = q.resetAt; | |
| } | |
| } | |
| return earliest; | |
| } | |
| /** | |
| * #4438 β Decide whether a quota snapshot row is worth persisting. | |
| * | |
| * The background refresh ticks every 60s for ALL connections, so idle accounts | |
| * (whose quota never changes) were generating 400K+ identical snapshot rows/day. | |
| * Returns true only when this window has no prior cached observation, or when its | |
| * `remaining_percentage` / `is_exhausted` differs from the last cached entry β so | |
| * the first observation and every real change persist, but idle no-op refreshes | |
| * stop writing. Pure (no I/O) for trivial unit testing. | |
| */ | |
| export function quotaSnapshotChanged( | |
| prior: | |
| | { quotas?: Record<string, { remainingPercentage: number }>; exhausted?: boolean } | |
| | null | |
| | undefined, | |
| windowKey: string, | |
| remainingPercentage: number, | |
| exhausted: boolean | |
| ): boolean { | |
| if (!prior) return true; | |
| const priorWindow = prior.quotas?.[windowKey]; | |
| if (!priorWindow) return true; | |
| return ( | |
| priorWindow.remainingPercentage !== remainingPercentage || | |
| (prior.exhausted ?? false) !== exhausted | |
| ); | |
| } | |
| function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaInfo> { | |
| const result: Record<string, QuotaInfo> = {}; | |
| for (const [key, q] of Object.entries(rawQuotas)) { | |
| if (q && typeof q === "object") { | |
| result[key] = { | |
| remainingPercentage: | |
| safePercentage(q.remainingPercentage) ?? | |
| (q.total > 0 ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 0), | |
| resetAt: q.resetAt || null, | |
| }; | |
| } | |
| } | |
| return result; | |
| } | |
| // βββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Store quota data for a connection (called by usage endpoint and background refresh). | |
| */ | |
| export function setQuotaCache( | |
| connectionId: string, | |
| provider: string, | |
| rawQuotas: Record<string, any> | |
| ) { | |
| const quotas = normalizeQuotas(rawQuotas); | |
| 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 entry: QuotaCacheEntry = { | |
| connectionId, | |
| provider, | |
| quotas, | |
| fetchedAt: Date.now(), | |
| exhausted, | |
| nextResetAt: exhausted ? earliestResetAt(quotas) : null, | |
| }; | |
| cache.set(connectionId, entry); | |
| if (entry && rawQuotas) { | |
| for (const [windowKey, quotaInfo] of Object.entries(rawQuotas)) { | |
| if (!quotaInfo || typeof quotaInfo !== "object") continue; | |
| const remainingPercentage = | |
| safePercentage(quotaInfo.remainingPercentage) ?? | |
| (quotaInfo.total > 0 | |
| ? Math.round(((quotaInfo.total - (quotaInfo.used || 0)) / quotaInfo.total) * 100) | |
| : 0); | |
| recordProviderQuotaResetEventIfChanged({ | |
| provider, | |
| connectionId, | |
| windowKey, | |
| currentResetAt: quotaInfo.resetAt ?? null, | |
| currentRemainingPercentage: remainingPercentage, | |
| previousObservation: prior?.quotas?.[windowKey] | |
| ? { | |
| resetAt: prior.quotas[windowKey].resetAt, | |
| remainingPercentage: prior.quotas[windowKey].remainingPercentage, | |
| } | |
| : null, | |
| }); | |
| // #4438 β only persist on the first observation or a real change. | |
| if (!quotaSnapshotChanged(prior, windowKey, remainingPercentage, entry.exhausted)) continue; | |
| try { | |
| saveQuotaSnapshot({ | |
| provider, | |
| connection_id: connectionId, | |
| window_key: windowKey, | |
| remaining_percentage: remainingPercentage, | |
| is_exhausted: entry.exhausted ? 1 : 0, | |
| next_reset_at: quotaInfo.resetAt ?? null, | |
| window_duration_ms: entry.windowDurationMs ?? null, | |
| raw_data: null, | |
| }); | |
| } catch (error) { | |
| console.error("[quotaCache] Failed to save snapshot:", error); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Get cached quota entry (returns null if not cached). | |
| */ | |
| export function getQuotaCache(connectionId: string): QuotaCacheEntry | null { | |
| return cache.get(connectionId) || null; | |
| } | |
| function hydrateQuotaCacheFromSnapshots(connectionId: string): QuotaCacheEntry | null { | |
| if (cache.has(connectionId)) return cache.get(connectionId) || null; | |
| let snapshots; | |
| try { | |
| snapshots = getLatestQuotaSnapshotsForConnection(connectionId); | |
| } catch { | |
| return null; | |
| } | |
| if (!snapshots.length) return null; | |
| const quotas: Record<string, QuotaInfo> = {}; | |
| let provider = ""; | |
| let fetchedAt = 0; | |
| let exhausted = false; | |
| let windowDurationMs: number | null = null; | |
| for (const snapshot of snapshots) { | |
| const camelSnapshot = snapshot as unknown as { | |
| windowKey?: string; | |
| remainingPercentage?: number | null; | |
| isExhausted?: number; | |
| nextResetAt?: string | null; | |
| windowDurationMs?: number | null; | |
| createdAt?: string; | |
| }; | |
| const windowKey = camelSnapshot.windowKey ?? snapshot.window_key; | |
| if (!windowKey) continue; | |
| provider = provider || snapshot.provider || ""; | |
| quotas[windowKey] = { | |
| remainingPercentage: clampPercent( | |
| Number(camelSnapshot.remainingPercentage ?? snapshot.remaining_percentage ?? 0) | |
| ), | |
| resetAt: camelSnapshot.nextResetAt ?? snapshot.next_reset_at ?? null, | |
| }; | |
| exhausted = exhausted || (camelSnapshot.isExhausted ?? snapshot.is_exhausted) === 1; | |
| const snapshotWindowDurationMs = | |
| camelSnapshot.windowDurationMs ?? snapshot.window_duration_ms ?? null; | |
| if (snapshotWindowDurationMs && snapshotWindowDurationMs > 0) { | |
| windowDurationMs = snapshotWindowDurationMs; | |
| } | |
| const createdAtVal = camelSnapshot.createdAt ?? snapshot.created_at; | |
| const createdAtMs = createdAtVal ? parseDate(createdAtVal) : null; | |
| if (createdAtMs !== null) fetchedAt = Math.max(fetchedAt, createdAtMs); | |
| } | |
| if (Object.keys(quotas).length === 0) return null; | |
| const entry: QuotaCacheEntry = { | |
| connectionId, | |
| provider, | |
| quotas, | |
| fetchedAt: fetchedAt || Date.now(), | |
| exhausted, | |
| nextResetAt: exhausted ? earliestResetAt(quotas) : null, | |
| windowDurationMs, | |
| }; | |
| cache.set(connectionId, entry); | |
| return entry; | |
| } | |
| /** | |
| * Check if an account's quota is exhausted based on cached data. | |
| * Returns false if no cache entry exists (unknown = assume available). | |
| */ | |
| export function isAccountQuotaExhausted(connectionId: string): boolean { | |
| const entry = cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId); | |
| if (!entry) return false; | |
| if (!entry.exhausted) return false; | |
| const now = Date.now(); | |
| // T08 β Auto window advance: if resetAt is in the past, eagerly treat as not exhausted. | |
| // This prevents stale exhaustion blocking when background refresh hasn't run yet. | |
| const advanced = advancedWindowResetAt(entry, now); | |
| if (advanced) { | |
| // Optimistically clear the exhausted flag so we unblock requests immediately. | |
| // The next background refresh will update with the real quota state. | |
| entry.exhausted = false; | |
| return false; | |
| } | |
| // Exhausted entries without resetAt expire after fixed TTL | |
| const age = now - entry.fetchedAt; | |
| if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false; | |
| return true; | |
| } | |
| /** | |
| * Return quota window status for a connection (e.g., session/weekly). | |
| * Returns null when no cache or no window data is available. | |
| */ | |
| export function getQuotaWindowStatus( | |
| connectionId: string, | |
| windowName: string, | |
| thresholdPercent = DEFAULT_QUOTA_THRESHOLD_PERCENT | |
| ): QuotaWindowStatus | null { | |
| const entry = cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId); | |
| if (!entry) return null; | |
| const now = Date.now(); | |
| const window = resolveQuotaWindow(entry.quotas, windowName); | |
| if (!window) return null; | |
| const remainingPercentage = clampPercent(window.remainingPercentage); | |
| const usedPercentage = clampPercent(100 - remainingPercentage); | |
| let resetAt = window.resetAt || null; | |
| let windowExpired = false; | |
| if (resetAt) { | |
| const resetMs = parseDate(resetAt); | |
| if (resetMs !== null && resetMs <= now) { | |
| resetAt = null; | |
| windowExpired = true; | |
| } | |
| } | |
| return { | |
| remainingPercentage, | |
| usedPercentage, | |
| resetAt, | |
| // If reset time has already passed, avoid stale cached percentages blocking selection. | |
| reachedThreshold: windowExpired ? false : usedPercentage >= thresholdPercent, | |
| }; | |
| } | |
| /** | |
| * Mark an account as quota-exhausted from a 429 response (no quota data available). | |
| * Uses 5-minute fixed TTL since we don't know the actual resetAt. | |
| */ | |
| export function markAccountExhaustedFrom429(connectionId: string, provider: string) { | |
| cache.set(connectionId, { | |
| connectionId, | |
| provider, | |
| quotas: {}, | |
| fetchedAt: Date.now(), | |
| exhausted: true, | |
| nextResetAt: null, | |
| }); | |
| } | |
| // βββ Background Refresh βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const refreshingSet = new Set<string>(); | |
| async function refreshEntry(entry: QuotaCacheEntry) { | |
| if (refreshingSet.has(entry.connectionId)) return; | |
| refreshingSet.add(entry.connectionId); | |
| try { | |
| const connection = await getProviderConnectionById(entry.connectionId); | |
| if (!connection || connection.authType !== "oauth" || !connection.isActive) { | |
| cache.delete(entry.connectionId); | |
| return; | |
| } | |
| const proxyInfo = await resolveProxyForConnection(entry.connectionId); | |
| const usage = await runWithProxyContext(proxyInfo?.proxy || null, () => | |
| getUsageForProvider(connection) | |
| ); | |
| if (usage?.quotas) { | |
| setQuotaCache(entry.connectionId, entry.provider, usage.quotas); | |
| } | |
| } catch (err) { | |
| console.warn( | |
| `[QuotaCache] Refresh failed for ${entry.connectionId.slice(0, 8)}:`, | |
| (err as any)?.message || err | |
| ); | |
| } finally { | |
| refreshingSet.delete(entry.connectionId); | |
| } | |
| } | |
| function needsRefresh(entry: QuotaCacheEntry, now: number): boolean { | |
| const age = now - entry.fetchedAt; | |
| if (entry.exhausted) { | |
| if (entry.nextResetAt) { | |
| const resetMs = parseDate(entry.nextResetAt); | |
| if (resetMs !== null && resetMs <= now) return true; | |
| } | |
| return age >= EXHAUSTED_REFRESH_MS; | |
| } | |
| return age >= ACTIVE_TTL_MS; | |
| } | |
| async function backgroundRefreshTick() { | |
| if (tickRunning) return; | |
| tickRunning = true; | |
| try { | |
| cleanupOldSnapshots(); | |
| const now = Date.now(); | |
| const pending = [...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) { | |
| const batch = pending.slice(i, i + MAX_CONCURRENT_REFRESHES); | |
| await Promise.allSettled(batch.map(refreshEntry)); | |
| } | |
| } finally { | |
| tickRunning = false; | |
| } | |
| } | |
| /** | |
| * Start the background refresh timer. | |
| */ | |
| export function startBackgroundRefresh() { | |
| if (refreshTimer) return; | |
| refreshTimer = setInterval(backgroundRefreshTick, REFRESH_INTERVAL_MS); | |
| refreshTimer?.unref?.(); | |
| } | |
| /** | |
| * Stop the background refresh timer. | |
| */ | |
| export function stopBackgroundRefresh() { | |
| if (refreshTimer) { | |
| clearInterval(refreshTimer); | |
| refreshTimer = null; | |
| } | |
| } | |
| /** | |
| * Get cache stats (for debugging/dashboard). | |
| */ | |
| export function getQuotaCacheStats() { | |
| const entries: Array<{ | |
| connectionId: string; | |
| provider: string; | |
| exhausted: boolean; | |
| nextResetAt: string | null; | |
| ageMs: number; | |
| }> = []; | |
| for (const entry of cache.values()) { | |
| entries.push({ | |
| connectionId: entry.connectionId.slice(0, 8) + "...", | |
| provider: entry.provider, | |
| exhausted: entry.exhausted, | |
| nextResetAt: entry.nextResetAt, | |
| ageMs: Date.now() - entry.fetchedAt, | |
| }); | |
| } | |
| return { total: cache.size, entries }; | |
| } | |