From ac2439b8afd5c3b86913a796e9c8ee2364ab8305 Mon Sep 17 00:00:00 2001 From: Damian Pozimski Date: Tue, 18 Aug 2026 15:49:28 +0200 Subject: [PATCH] fix(api): scale pool usage snapshot limits by pool member count (summed budget) (#10253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * Hide health-check excluded models from /v1/models catalog (#10026) Mirror the request-time exclusion rule (provider_specific_data.excludedModels) in the unified catalog builder: a model is hidden when its provider has connections but none of them is eligible for it. Applied across the PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops so ghost models no longer appear as available. Co-authored-by: ritheshcn25 * fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055) * fix(models): memoize getModelsDevPricing for /v1/models catalog resolveCatalogPricing called getModelsDevPricing once per model while building GET /v1/models. Each call re-scanned models_dev_pricing and JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging the event loop so even /healthz timed out (#9685, #10052). Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing and add a unit test for invalidation. Signed-off-by: Ravi Tharuma * fix(db): invalidate modelsDevPricing cache on DB reset (#10055) Copilot review fixes: 1. Register invalidateModelsDevPricingCache() with DB state reset system so resetDbInstance() clears the process-local memo, preventing stale pricing data from surviving across DB reset/restore operations. 2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055). The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing() results until saveModelsDevPricing()/clearModelsDevPricing() to avoid re-scanning all pricing rows on every /v1/models request. Without this hook, backup restore and test DB resets would serve stale cached data from the previous connection. Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts --------- Signed-off-by: Ravi Tharuma Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent * fix(api): scale pool usage snapshot limits by member count (summed budget) --------- Signed-off-by: Ravi Tharuma Co-authored-by: diegosouzapw Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw Co-authored-by: ritheshcn25 Co-authored-by: ritheshcn25 Co-authored-by: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- src/app/api/quota/pools/[id]/usage/route.ts | 27 +++- .../quota-pool-usage-summed-budget.test.ts | 146 ++++++++++++++++++ 2 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 tests/unit/quota-pool-usage-summed-budget.test.ts diff --git a/src/app/api/quota/pools/[id]/usage/route.ts b/src/app/api/quota/pools/[id]/usage/route.ts index 35ef47cd93..cb03f4a04c 100644 --- a/src/app/api/quota/pools/[id]/usage/route.ts +++ b/src/app/api/quota/pools/[id]/usage/route.ts @@ -1,8 +1,10 @@ /** * GET /api/quota/pools/[id]/usage — pool consumption snapshot with dimensions * - * Resolves the pool's provider plan to get dimensions, then calls - * poolUsageWithDimensions on the QuotaStore interface. + * Resolves the pool's provider plan to get dimensions, scales each dimension + * limit by the pool's member-connection count (the same summed budget + * enforce.ts applies), then calls poolUsageWithDimensions on the QuotaStore + * interface. * * Auth: requireManagementAuth * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) @@ -43,12 +45,27 @@ export async function GET(request: Request, { params }: RouteParams): Promise 0 + ? pool.connectionIds.length + : 1; + const effectiveDimensions = plan.dimensions.map((dim) => ({ + ...dim, + limit: dim.limit * accountCount, + })); + + // 4. Get the quota store and call poolUsageWithDimensions (on the interface since v3.8.12) const store = await getQuotaStore(); let snapshot: PoolUsageSnapshot; - if (plan.dimensions.length > 0) { - snapshot = await store.poolUsageWithDimensions(id, plan.dimensions); + if (effectiveDimensions.length > 0) { + snapshot = await store.poolUsageWithDimensions(id, effectiveDimensions); } else { // Fallback: no plan dimensions configured — return minimal snapshot snapshot = await store.poolUsage(id); diff --git a/tests/unit/quota-pool-usage-summed-budget.test.ts b/tests/unit/quota-pool-usage-summed-budget.test.ts new file mode 100644 index 0000000000..9d8791aea3 --- /dev/null +++ b/tests/unit/quota-pool-usage-summed-budget.test.ts @@ -0,0 +1,146 @@ +/** + * tests/unit/quota-pool-usage-summed-budget.test.ts + * + * Regression: GET /api/quota/pools/[id]/usage reported per-account plan limits + * for multi-connection pools while enforce.ts scales every dimension by the + * pool's member-connection count (summed budget, see quota-summed-budget.test.ts). + * A pool with N connections therefore looked ~N× more utilised on the dashboard + * than enforcement actually allowed: a 27-connection pool at 3% real utilisation + * rendered as 81%, and per-key `borrowing` flags tripped N× too early. + * + * The fix scales plan.dimensions by accountCount in the usage route before + * calling poolUsageWithDimensions — the same multiply enforce.ts applies — so + * the snapshot's limit, fairShare, deficit and borrowing all describe the + * budget enforcement really uses. + * + * Levels: + * A (structural): the route computes accountCount with the enforce.ts + * fallback semantics and passes the scaled dimensions to the store. + * B (logic): replicate the store's per-key math to prove that scaling the + * dimension limit corrects fairShare and the borrowing flag for a pool + * shape where the unscaled snapshot misreports both. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(fileURLToPath(import.meta.url), "..", "..", ".."); +const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8"); + +const ROUTE = "src/app/api/quota/pools/[id]/usage/route.ts"; + +// --------------------------------------------------------------------------- +// Level A — structural: the route applies the summed-budget multiply +// --------------------------------------------------------------------------- + +test("usage route computes accountCount with the enforce.ts fallback semantics", () => { + const src = read(ROUTE); + assert.ok( + /Array\.isArray\(pool\.connectionIds\)\s*&&\s*pool\.connectionIds\.length\s*>\s*0/.test(src), + "route must guard connectionIds exactly like enforce.ts" + ); + assert.ok( + /\?\s*pool\.connectionIds\.length\s*:\s*1/.test(src), + "route must fall back to accountCount = 1 for legacy pools without connectionIds" + ); +}); + +test("usage route scales every dimension limit by accountCount before calling the store", () => { + const src = read(ROUTE); + assert.ok( + /limit:\s*dim\.limit\s*\*\s*accountCount/.test(src), + "route must multiply dim.limit by accountCount" + ); + assert.ok( + /poolUsageWithDimensions\(\s*id,\s*effectiveDimensions\s*\)/.test(src), + "route must pass the scaled dimensions to poolUsageWithDimensions" + ); + assert.ok( + !/poolUsageWithDimensions\(\s*id,\s*plan\.dimensions\s*\)/.test(src), + "route must NOT pass the unscaled plan.dimensions to the store" + ); +}); + +test("usage endpoint still wraps the snapshot as { usage: snapshot }", () => { + const src = read(ROUTE); + assert.ok( + /NextResponse\.json\(\s*\{\s*usage:/.test(src), + "endpoint contract from quota-pool-usage-shape.test.ts must survive the fix" + ); +}); + +// --------------------------------------------------------------------------- +// Level B — logic: scaled limits correct fairShare and borrowing +// +// Replicates the per-key math from sqliteQuotaStore.poolUsageWithDimensions: +// fairShare = (weight / 100) × planDim.limit +// borrowing = consumed > fairShare +// for a 27-connection pool where one key consumed more than its per-account +// slice but far less than its share of the summed budget. +// --------------------------------------------------------------------------- + +test("summed-budget snapshot: fairShare and borrowing describe the enforced budget", () => { + const PER_ACCOUNT_LIMIT = 66.67; // per-connection plan limit (L) + const ACCOUNT_COUNT = 27; // pool members (N) + const WEIGHT = 3.97; // key's allocation weight (%) + const CONSUMED = 10.75; // above weight% × L, far below weight% × N × L + + const perKeySnapshot = (dimLimit: number) => { + const fairShare = (WEIGHT / 100) * dimLimit; + return { fairShare, borrowing: CONSUMED > fairShare }; + }; + + // Unscaled (the bug): the key looks like a borrower at 27× the real threshold. + const unscaled = perKeySnapshot(PER_ACCOUNT_LIMIT); + assert.ok( + unscaled.borrowing, + "sanity: against the per-account limit this consumption reads as borrowing" + ); + + // Scaled (the fix): same consumption sits comfortably inside the enforced fair share. + const scaled = perKeySnapshot(PER_ACCOUNT_LIMIT * ACCOUNT_COUNT); + assert.equal( + Math.round(scaled.fairShare * 100) / 100, + Math.round(((WEIGHT / 100) * PER_ACCOUNT_LIMIT * ACCOUNT_COUNT) * 100) / 100, + "fairShare must be weight% of the summed budget" + ); + assert.equal( + scaled.borrowing, + false, + "a key inside its summed-budget fair share must not be flagged as borrowing" + ); + + // Utilisation follows the same correction: consumedTotal / limit. + const CONSUMED_TOTAL = 54.09; + const shownUnscaled = CONSUMED_TOTAL / PER_ACCOUNT_LIMIT; + const shownScaled = CONSUMED_TOTAL / (PER_ACCOUNT_LIMIT * ACCOUNT_COUNT); + assert.ok(shownUnscaled > 0.8, "sanity: the bug rendered ~81% utilisation"); + assert.ok(shownScaled < 0.035, "the fix renders the real ~3% utilisation"); +}); + +test("summed-budget snapshot: single-connection and legacy pools are unchanged", () => { + const PER_ACCOUNT_LIMIT = 1000; + + const accountCount = (pool: { connectionIds?: string[] }) => + Array.isArray(pool.connectionIds) && pool.connectionIds.length > 0 + ? pool.connectionIds.length + : 1; + + assert.equal( + PER_ACCOUNT_LIMIT * accountCount({ connectionIds: ["conn-a"] }), + PER_ACCOUNT_LIMIT, + "1-connection pool: limit unchanged" + ); + assert.equal( + PER_ACCOUNT_LIMIT * accountCount({ connectionIds: [] }), + PER_ACCOUNT_LIMIT, + "empty connectionIds: fallback to 1" + ); + assert.equal( + PER_ACCOUNT_LIMIT * accountCount({}), + PER_ACCOUNT_LIMIT, + "legacy pool without connectionIds: fallback to 1" + ); +});