From 4af5cabf1874a672161c2c58dc3694b78cdd35b4 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 14 Aug 2026 12:48:48 +0900 Subject: [PATCH] fix(usage): bound incremental append reads --- src/usage/log.ts | 4 ++++ tests/api-usage.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/usage/log.ts b/src/usage/log.ts index 2067d1e50..8d4d4854d 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -841,6 +841,10 @@ async function readUsageEntriesIncrementally( // A shrink means truncation or replacement-in-place; the retained rows may no // longer correspond to file contents, so refuse to extend them. if (size < retained.coveredThroughBytes) return null; + // Extending retained state is only an optimization; never let a large burst turn + // the bounded management read into an unbounded read of everything appended since + // the previous poll. A full read below will load only the requested tail window. + if (size - retained.coveredThroughBytes > maxReadBytes) return null; // Verify the retained REGION is unchanged before anything is reused. Identity keeps // dev/ino/birthtime, and an append and an in-place rewrite both move mtime/ctime // forward, so only the bytes themselves settle it. diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index 58a38a232..f6db775c6 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -578,6 +578,35 @@ describe("GET /api/usage", () => { } }); + test("an append burst larger than the byte window falls back to a bounded full read", async () => { + const now = Date.now(); + const maxReadBytes = 512; + const row = (id: string): string => `${JSON.stringify({ + requestId: id, + timestamp: now, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + })}\n`; + const path = join(testDir, "usage.jsonl"); + writeFileSync(path, row("seed")); + + await usageLogModule.readUsageSnapshotForManagement(maxReadBytes); + const parsedBeforeBurst = usageReadCacheStatsForTests().parsedLines; + appendFileSync(path, Array.from({ length: 100 }, (_, index) => row(`burst-${index}`)).join("")); + + const snapshot = await usageLogModule.readUsageSnapshotForManagement(maxReadBytes); + const stats = usageReadCacheStatsForTests(); + expect(stats.fullReads).toBe(2); + expect(stats.tailReads).toBe(0); + expect(stats.parsedLines - parsedBeforeBurst).toBe(snapshot.entries.length); + expect(snapshot.entries.length).toBeLessThan(100); + }); + test("appends to an over-window ledger stay incremental and bounded", async () => { const now = Date.now(); writeFixture(now);