Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions devlog/_plan/260806_disposition_sweep/060_usage_cap_500k.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 060 — usage management read cap: 200k → 500k entries

User authorization (2026-08-06): adopt the user's uncommitted 500k edits from
the main checkout, "의견을 종합해서" — synthesize the review context and land
it properly. Authority includes push + PR + merge for this item.

## Synthesis (why 500k is the right call now)

- The cap is a READER bound (`MANAGEMENT_USAGE_MAX_ENTRIES` in
`src/usage/log.ts`), not a retention bound: raw `usage.jsonl` is never
deleted by it. Raising it widens what one management read can return.
- The BYTE cap (`managementUsageMaxReadBytes`, 64 MiB default) bounds the raw
INPUT of a read before the entry cap applies (log.ts:532-558); the entry cap
trims parsed rows afterwards (log.ts:500-519). Corrected per audit: 64 MiB
bounds input bytes, while 500k raises the TRANSIENT parse/summary heap and
CPU — up to 300k more normalized JS objects can be retained per read, and
`/api/usage` does several full passes with per-request Map/Set aggregation
(summary.ts:301-583). This is a bounded, transient cost on a management
read, not an unbounded leak — but it must be measured, not assumed.
- Acceptance criterion added: a representative 500k-entry benchmark (small
rows, worst-case unique request ids) must show acceptable wall time and
peak RSS delta for `readUsageSnapshotForManagement` + `summarizeUsage`
before landing.
- Prior lesson (usage-log cap incident): "a reader cap can hide data without
deleting it" — the 200k clip was exactly that failure shape for the GUI.
- With the #1008 rollup sidecar (still open) history older than the window
will come from folded rows anyway; until it lands, the raw window IS the
history, which strengthens the case for the wider entry cap now.
- Overlap handling: this branch cuts from current dev, applying the same
content as the user's dirty edits. The main checkout stays untouched; once
this lands, the user's local diff becomes content-identical to HEAD.

## Work

1. Branch `codex/usage-entry-cap-500k` off current dev; apply the two-file
change (constant + test expectations).
2. 500k benchmark (bench script in .tmp/, not committed): parse + summarize
wall time and heap delta at 200k vs 500k; record numbers in this ledger.
3. Focused tests + typecheck; terra regression audit. Collision check done:
#1008's log.ts diff starts at the reader (~line 523) and does not touch
the cap line or this test — no hunk collision (audit-verified).
4. Push, PR to dev, CI green, merge with `--match-head-commit` pin.

## Ledger

| Step | Evidence |
|------|----------|
| Benchmark (bench.ts in mktemp, not committed) | Baseline 200k-cap: 200k entries, read 475ms, summarize 358ms, ΔRSS 547MiB. With 500k cap on a 96.7MiB/500k-row file: byte cap binds first → 330,585 entries, read 835ms, summarize 609ms, ΔRSS ~1.1GiB transient. 600k-row file: identical 330,585 entries — the 64MiB byte window is the effective bound for realistic rows; the 500k entry cap is a secondary guard, not the binding limit |
| Cap-binding benchmark (bench2, audit-required) | Tiny rows densely packing 63.5MiB → 629,205 rows in the byte window; parse-all-then-slice yields exactly 500,000 entries (129,205 dropped). read 1,436ms, summarize 637ms, ΔRSS 897MiB transient. This exercises the exact widened case (entry cap binding, unique request ids) |
| Acceptance threshold + verdict | Threshold: ≤3s combined read+summarize, ≤1.5GiB transient RSS on the worst case. Measured: 2.07s combined, 897MiB — inside threshold. Request-scoped, no steady-state retention; acceptable for an on-demand admin endpoint |
| Stale comment | tests/usage-log.test.ts:138 timing comment updated to the 500k shape (audit minor) |
2 changes: 1 addition & 1 deletion src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ export type UsageLogRevision = {
let usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
const MANAGEMENT_USAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
const MANAGEMENT_USAGE_READ_CHUNK_BYTES = 1024 * 1024;
const MANAGEMENT_USAGE_MAX_ENTRIES = 200_000;
const MANAGEMENT_USAGE_MAX_ENTRIES = 500_000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial

Verify production memory headroom before release.

parseUsageTextCooperatively parses the complete byte-window input before it applies MANAGEMENT_USAGE_MAX_ENTRIES. The higher cap therefore allows more entries to remain in the snapshot and flow into /api/usage aggregation. The devlog reports approximately 897 MiB transient RSS for the cap-binding case at devlog/_plan/260806_disposition_sweep/060_usage_cap_500k.md:49. Identical reads share parsing, but readUsageSnapshotForManagement still returns a separate entries.slice() to each caller.

Run one cap-binding read and concurrent /api/usage requests under the production Bun memory limit. If the process lacks sufficient headroom, bound parsing or aggregation to the newest 500,000 entries, or enforce a deployment-specific lower limit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/usage/log.ts` at line 424, Validate production Bun memory headroom with a
cap-binding read and concurrent /api/usage requests, including the separate
entries.slice() behavior in readUsageSnapshotForManagement. If headroom is
insufficient, update parseUsageTextCooperatively or the aggregation path to
retain only the newest MANAGEMENT_USAGE_MAX_ENTRIES entries, or enforce an
appropriate deployment-specific lower limit.

const MANAGEMENT_USAGE_FLIGHT_STALE_MS = 30_000;
export interface ManagementUsageSnapshot {
entries: PersistedUsageEntry[];
Expand Down
8 changes: 4 additions & 4 deletions tests/usage-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,16 @@ describe("usage log", () => {
test("usage byte-prefix truncation and entry-count truncation report independent metadata", async () => {
writeFileSync(
usageLogPath(),
`${Array.from({ length: 200_001 }, (_, index) => JSON.stringify({ requestId: String(index) })).join("\n")}\n`,
`${Array.from({ length: 500_001 }, (_, index) => JSON.stringify({ requestId: String(index) })).join("\n")}\n`,
);
const snapshot = await readUsageSnapshotForManagement();
expect(snapshot.entries).toHaveLength(200_000);
expect(snapshot.entries).toHaveLength(500_000);
expect(snapshot.entries[0]?.requestId).toBe("1");
expect(snapshot.entries.at(-1)?.requestId).toBe("200000");
expect(snapshot.entries.at(-1)?.requestId).toBe("500000");
expect(snapshot.truncatedPrefixBytes).toBe(0);
expect(snapshot.entriesTruncated).toBe(true);
expect(snapshot.entriesDropped).toBe(1);
}, STORE_BUDGET_MS); // parsing 200,001 rows IS the entry-cap assertion; windows-latest measured ~5.05s against Bun's 5s default.
}, STORE_BUDGET_MS); // parsing 500,001 rows IS the entry-cap assertion; the 200k-row variant measured ~5.05s on windows-latest against Bun's 5s default.

test("stale usage-read flight is replaced and old completion cannot clear new owner", async () => {
writeFileSync(
Expand Down
Loading