diff --git a/src/server/management/usage-aggregate-cache.ts b/src/server/management/usage-aggregate-cache.ts index 1cabf9b85b..519ff7775f 100644 --- a/src/server/management/usage-aggregate-cache.ts +++ b/src/server/management/usage-aggregate-cache.ts @@ -66,6 +66,7 @@ const pinnedAggregates = new Set(); let baseFlight: Promise | null = null; const filteredFlights = new Map>(); const retainedFilteredAggregates = new Map(); +const MAX_CONCURRENT_FILTERED_AGGREGATES = 4; function currentTimeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -283,6 +284,9 @@ export async function getFilteredUsageAggregate(filter: { ]); const existing = filteredFlights.get(key); if (existing) return existing; + if (filteredFlights.size >= MAX_CONCURRENT_FILTERED_AGGREGATES) { + throw new Error("too many concurrent filtered usage aggregates"); + } const flight = refreshFilteredAggregate(key, normalizedFilter, fixedWindow); filteredFlights.set(key, flight); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c445858e6b..77309004d4 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -365,10 +365,10 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou `src/server/hub-usage.ts` serves `GET /v1/usage` on hubs for an explicit configured data key. The authenticated key selects the aggregate; query parameters cannot select an API-key identity. Unscoped environment/admin credentials and loopback bypass are not admitted. The response projects only this client's numeric totals, provider/model/day rows and incomplete-history metadata through `src/remote/hub-usage.ts`; accounts, raw records and key IDs are omitted. Unknown fields are stripped at every object boundary and the serialized body is capped at 1 MiB. -Custom usage windows are immutable bounds on the streaming accumulator, applied to each -ledger entry before attribution and daily aggregation. The filtered aggregate cache includes -both inclusive millisecond bounds in its identity and retains the existing ledger revision, -overlay-version and timezone checks. Preset warming never consumes custom summaries. +Custom usage windows are immutable bounds on the streaming accumulator, applied before attribution and daily +aggregation. The filtered cache includes both inclusive millisecond bounds in its identity and retains the existing ledger revision, overlay-version and timezone checks. +At most four distinct filtered scans may run concurrently; identical requests share one scan and excess distinct +work fails closed. Preset warming never consumes custom summaries. The response retains its preset range discriminator for compatibility and explicitly marks `customWindow`, `since`, and `until`; the chart uses the window's local calendar days with the existing 366-day cap. GUI custom reports bypass the held preset/session cache. diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index c2a539bf94..d954a4de32 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -252,6 +252,36 @@ describe("retained usage aggregate cache", () => { } }); + test("distinct filtered scans have bounded concurrency while identical callers share a flight", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let releaseScans!: () => void; + const scansBlocked = new Promise(resolve => { releaseScans = resolve; }); + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + await scansBlocked; + return originalScan(options); + }); + try { + const flights = Array.from({ length: 4 }, (_, index) => + getFilteredUsageAggregate({ provider: `provider-${index}` })); + await Bun.sleep(0); + expect(scanSpy).toHaveBeenCalledTimes(4); + + const shared = getFilteredUsageAggregate({ provider: "provider-0" }); + await expect(getFilteredUsageAggregate({ provider: "provider-4" })) + .rejects.toThrow("too many concurrent filtered usage aggregates"); + expect(scanSpy).toHaveBeenCalledTimes(4); + + releaseScans(); + const results = await Promise.all([...flights, shared]); + expect(results[4]!.accumulator).toBe(results[0]!.accumulator); + } finally { + releaseScans(); + scanSpy.mockRestore(); + } + }); + test("filtered retention invalidates when pricing inputs change", async () => { writeFileSync(join(testDir, "usage.jsonl"), line("one")); const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively;