From 9a78ea2225ede859dd829989405c212de9983e49 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 20:36:37 -0300 Subject: [PATCH] fix(providers): stop marking a multi-quota-window provider exhausted when only some windows are depleted (LIMIT-200 snapshot eviction drops idle healthy windows) (#8431) (#8445) Co-authored-by: ikelvingo --- .../fixes/8431-multiwindow-quota-eviction.md | 1 + src/lib/db/quotaSnapshots.ts | 36 +++--- .../8431-multiwindow-quota-eviction.test.ts | 119 ++++++++++++++++++ 3 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fixes/8431-multiwindow-quota-eviction.md create mode 100644 tests/unit/8431-multiwindow-quota-eviction.test.ts diff --git a/changelog.d/fixes/8431-multiwindow-quota-eviction.md b/changelog.d/fixes/8431-multiwindow-quota-eviction.md new file mode 100644 index 0000000000..9db9be88f5 --- /dev/null +++ b/changelog.d/fixes/8431-multiwindow-quota-eviction.md @@ -0,0 +1 @@ +- fix(providers): stop marking a multi-quota-window provider exhausted when only some windows are depleted (LIMIT-200 snapshot eviction drops idle healthy windows) (#8431) diff --git a/src/lib/db/quotaSnapshots.ts b/src/lib/db/quotaSnapshots.ts index e8495365ae..1e77cc47fb 100644 --- a/src/lib/db/quotaSnapshots.ts +++ b/src/lib/db/quotaSnapshots.ts @@ -84,29 +84,37 @@ export function getQuotaSnapshots(opts: { } } +/** + * Returns the single latest snapshot row for each distinct `window_key` + * ever observed for this connection. + * + * Deliberately NOT a "most recent N rows across all windows" query: a + * connection with many quota windows where only a subset actively churn + * (frequent writes as they drain) and the rest stay idle/healthy (a single + * old row each, thanks to the #4438 no-op-write dedup) would otherwise have + * its recent-rows slice flooded entirely by the hot windows, silently + * evicting the idle windows from rehydration (#8431). Scoping "latest" PER + * window_key via a window function keeps every window visible regardless of + * how skewed the write frequency is across windows. + */ export function getLatestQuotaSnapshotsForConnection(connectionId: string): QuotaSnapshotRow[] { const db = getDbInstance() as unknown as DbLike; try { const rows = db .prepare( - `SELECT * FROM quota_snapshots - WHERE connection_id = ? - ORDER BY created_at DESC - LIMIT 200` + `SELECT * FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY window_key ORDER BY created_at DESC, id DESC + ) AS rn + FROM quota_snapshots + WHERE connection_id = ? + ) + WHERE rn = 1` ) .all(connectionId); - const latestByWindow = new Map(); - for (const row of rows) { - const snapshot = rowToCamel(row) as unknown as QuotaSnapshotRow; - const windowKey = - (snapshot as unknown as { windowKey?: string }).windowKey ?? snapshot.window_key; - if (!windowKey || latestByWindow.has(windowKey)) continue; - latestByWindow.set(windowKey, snapshot); - } - - return [...latestByWindow.values()]; + return rows.map((row) => rowToCamel(row) as unknown as QuotaSnapshotRow); } catch (err: any) { if (err?.message?.includes("no such table")) { return []; diff --git a/tests/unit/8431-multiwindow-quota-eviction.test.ts b/tests/unit/8431-multiwindow-quota-eviction.test.ts new file mode 100644 index 0000000000..ebe0767a50 --- /dev/null +++ b/tests/unit/8431-multiwindow-quota-eviction.test.ts @@ -0,0 +1,119 @@ +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"; + +/** + * #8431 — a provider with many quota windows (e.g. codebuddy-cn: 1 Monthly + + * up to 8 Bonus Packs) can be wrongly reported as fully exhausted after a + * fresh boot (empty in-memory cache) even though most windows still have + * balance. + * + * Root cause: `getLatestQuotaSnapshotsForConnection()` fetched the most + * recent 200 rows for the connection (across ALL windows), then deduped by + * `window_key` *inside* that slice. A connection where a few windows churn + * frequently (draining, so they keep writing fresh rows) and the rest stay + * idle/healthy (a single old row each) can have its top-200 slice entirely + * flooded by the hot windows once they collectively accumulate >200 rows. + * The idle-but-healthy windows' only row falls outside the slice and is + * silently dropped from rehydration, so `isExhausted()` — which is correct + * on the data it's given — reports the connection exhausted because every + * window it was handed genuinely is at 0%. + * + * Regression guard: without the fix, only the 3 hot windows survive + * rehydration (of 9 total) and `isQuotaExhaustedForRequest` wrongly reports + * `true`. With the fix, all 9 windows survive and the request stays + * eligible because 6 of the 9 windows still have balance. + */ +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-quota-8431-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const quotaSnapshotsDb = await import("../../src/lib/db/quotaSnapshots.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +const COLD_WINDOWS = ["Bonus Pack 1", "Bonus Pack 2", "Bonus Pack 3", "Bonus Pack 4", "Weekly", "Daily"]; +const HOT_WINDOWS = ["Monthly", "Bonus Pack 5", "Bonus Pack 6"]; + +test("#8431 idle healthy windows survive rehydration even when hot windows accumulate >200 rows", () => { + const connectionId = "conn-codebuddy-cn-8431"; + const provider = "codebuddy-cn"; + + // 6 cold, healthy windows — each written exactly once (mirrors the #4438 + // no-op-write dedup for windows whose value never changes) and BEFORE the + // hot rows below. + for (const windowKey of COLD_WINDOWS) { + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: windowKey, + remaining_percentage: 80, + is_exhausted: 0, + next_reset_at: "2099-01-01T00:00:00.000Z", + window_duration_ms: null, + raw_data: null, + }); + } + + // 3 hot, actively-draining windows — 70 iterations x 3 windows = 210 rows, + // all created after the cold rows, exceeding the old LIMIT 200. + for (let i = 0; i < 70; i++) { + for (const windowKey of HOT_WINDOWS) { + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: windowKey, + remaining_percentage: 0, + is_exhausted: 1, + next_reset_at: "2099-01-08T00:00:00.000Z", + window_duration_ms: null, + raw_data: null, + }); + } + } + + const rehydrated = quotaSnapshotsDb.getLatestQuotaSnapshotsForConnection(connectionId); + const rehydratedKeys = rehydrated + .map((s) => (s as unknown as { windowKey?: string }).windowKey ?? s.window_key) + .sort(); + + assert.equal( + rehydrated.length, + 9, + `expected all 9 windows to survive rehydration, got ${rehydrated.length}: ${rehydratedKeys.join(", ")}` + ); + + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, provider, "deepseek-v4-pro"), + false, + "6 of 9 windows still have balance — the connection must not be reported as exhausted" + ); +}); + +test("#8431 a single-window provider is still correctly reported exhausted", () => { + const connectionId = "conn-single-window-8431"; + const provider = "openai"; + + quotaSnapshotsDb.saveQuotaSnapshot({ + provider, + connection_id: connectionId, + window_key: "weekly", + remaining_percentage: 0, + is_exhausted: 1, + next_reset_at: null, + window_duration_ms: null, + raw_data: null, + }); + + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, provider, "gpt-5"), + true, + "single depleted window must still correctly report exhaustion" + ); +});