Skip to content

perf(summary): cache pre-today aggregates so pushes don't rescan history - #17

Merged
luwill merged 1 commit into
luwill:mainfrom
amenjason936:perf/summary-history-cache
Oct 20, 2026
Merged

luwill merged 1 commit into
luwill:mainfrom
amenjason936:perf/summary-history-cache

Conversation

@amenjason936

Copy link
Copy Markdown
Contributor

Refs #13. The design is the one proposed in #13 (comment).

Problem

Every scanner update makes the dashboard refetch /api/summary. While an agent is working that is roughly once a second, multiplied by the number of open tabs. The menu-bar app also polls /api/summary?days=1 every 30 s, even when idle. Several queries in buildSummary / computeCosts scanned all history on every call.

Measured on synthetic data with 1M events (Linux, Node 22.22):

Before After (warm)
buildSummary(…, 30) 6.5 s ~90 ms
└ all-history per-day date(…,'localtime') GROUP BY ~4.0 s cached
└ aggregateCosts(db, 0) ~1.8 s cached
└ COUNT(DISTINCT session_id) for today 440 ms 15 ms
└ per-tool health COUNT(*) 68 ms cached

Before this change, the menu-bar poll alone cost 6.5 s of CPU every 30 s at this size, about 20% of a core while idle.

Approach

New src/historyCache.js: a per-connection cache of aggregates over ts < local midnight. Callers query only today's rows and merge them in.

Invalidation. Any one of the following triggers a full rebuild:

  • History writes on this connection: TEMP triggers on main.events set a dirty flag when an insert, update or delete touches a row before the boundary. That covers every write path (supersede, Codex model backfill, dropStaleCodexRows, Antigravity corrections) without adding hooks to each write method. The flag lives in a TEMP table, so it rolls back with the write's transaction. TEMP objects exist only on this connection, so the database schema is unchanged and older versions opening the same file are unaffected.
  • Other connections: PRAGMA data_version changes when another connection commits, for example token-watcher scan while serve is running. Only commits change it: I checked that opening a second Store and reading leaves it unchanged, so the menu-bar app and read-only CLI commands don't cause rebuilds.
  • Midnight: the boundary (localDayStart(now)) moves.

What is cached:

  • all-history per-day totals (by_day_all, all-time totals, peak day, streak);
  • all-time cost groups. aggregateCosts is split into costRows (SQL) and priceCostRows (JS). The cache holds the raw group rows, not priced results, so pricing.json edits and FX changes still apply immediately. Merged rows are sorted in GROUP BY key order, so tie ordering and the unpriced list match a single query;
  • the part before today of the 30-day per-day tool series and per-day cost series. The local day is a group key and midnight is a group boundary, so the two segments never share a group and are simply concatenated;
  • per-tool event counts and last timestamps for the health check.

Unchanged: aggregateCosts (used by ROI) and the remaining 30-day window queries. Their cost scales with the last 30 days of usage, not total history.

Tests

The new test/history-cache.mjs builds 40 days of events: peak and off-peak hours, long context, an unpriced model, NULL model and NULL session.

  • The first call matches direct full-history SQL (aggregateCosts(db, 0), per-day GROUP BY, event count, today's distinct sessions).
  • A warm call returns exactly what a cold recompute on a fresh connection returns, in each of these cases:
    • after today-only inserts, with no history recompute (counted);
    • after a history insert, UPDATE (model backfill), DELETE, a row moved from today into history, and a write from another connection, each of which recomputes;
    • after a rolled-back history write, which keeps the cache;
    • after crossing midnight, which recomputes.
  • Mutation checks: deleting the UPDATE trigger, or ignoring data_version, makes the matching cases fail.
  • On origin/main code, the equivalence assertions pass and only the "was it recomputed" assertions fail, so results are unchanged.

npm test passes (913 checks) on Node 22.22.

Notes

  • A cold rebuild costs the same as one old call (~7 s at 1M events). It happens once after startup, after a history write, or after midnight, instead of on every push.
  • This conflicts with perf(scan): remove two quadratic paths (Codex first scan, long lines) #16 on a single line: both register a test after opencode-memory.mjs in test/run.mjs. Whichever merges second needs a one-line rebase.

🤖 Generated with Claude Code

Every scanner 'update' makes the dashboard refetch /api/summary, and the
menu-bar app polls it every 30 s. Several queries in it scanned all
history each time: 6.5 s per call at 1M events (synthetic), the likely
source of luwill#13's sustained CPU.

src/historyCache.js caches aggregates over ts < local midnight per
connection; callers query only today's rows and merge. Invalidation:
- TEMP triggers on main.events flag inserts/updates/deletes touching
  rows before the boundary. They cover every write path (supersede,
  model backfill, Codex rebuilds), roll back with the write's
  transaction, and do not change the database schema.
- PRAGMA data_version catches commits from other connections
  (`token-watcher scan` while serve runs).
- the boundary moving past midnight.
Raw SQL group rows are cached, not priced results, so pricing.json and
FX changes still apply immediately.

Cached: all-history per-day totals, all-time cost groups (aggregateCosts
split into costRows + priceCostRows; merged rows are sorted in GROUP BY
key order so results match a single query), the pre-today part of the
30-day per-day tool and cost series, and per-tool health counts.
Also `COUNT(DISTINCT +session_id)` for today's sessions: without it
SQLite walks idx_events_session over all history (440 ms -> 15 ms).

1M events: warm 6.5 s -> ~90 ms; writing today's rows keeps the cache;
a history write rebuilds once (~7 s, same as before).

test/history-cache.mjs: first call equals direct full-history SQL;
warm == cold recompute after today inserts, history insert / UPDATE /
DELETE, a row moved into history, another connection's write, a rolled
back write, and crossing midnight, with recompute counts asserted.
Removing the UPDATE trigger or the data_version check fails the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@luwill

luwill commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Reviewed in detail, verified the key assumptions, and ran the full suite locally in a clean worktree — all green, including the warm==cold equivalence checks and the mutation tests. Two assumptions I specifically confirmed:

  • stageWrites() replay goes through the same this.db connection, so the TEMP triggers really do cover every collector write path (supersede, dropStaleCodexRows, Antigravity corrections) with no gaps.
  • PRAGMA data_version behaves as relied upon: own-connection commits don't bump it, other-connection commits do, reads don't.

One latent issue found while probing — not a blocker today. If some future code calls a summary (hence cachedHistory) inside a write transaction that later rolls back, the JS-side entries survive the rollback while the trigger's dirty flag rolls back to clean, leaving a stale cache that nothing will invalidate. Reproduced with:

const s = new Store('/tmp/hc.db');
const b = localDayStart(Date.now());
s.insertEvent({ ts: b - 86400000, tool: 'dsh', total_tokens: 100, dedup_key: 'adv:1' });
const read = () => cachedHistory(s.db, 'count', b, (bd) =>
  s.db.prepare('SELECT COUNT(*) n FROM events WHERE ts >= ? AND ts < ?').all(0, bd))[0].n;
read();                                   // 1
s.db.exec('BEGIN');
s.insertEvent({ ts: b - 3600000, tool: 'dsh', total_tokens: 1, dedup_key: 'adv:2' });
read();                                   // 2 — recomputed inside the txn
s.db.exec('ROLLBACK');
read();                                   // still 2, but the table has 1 row

No current path can hit this (buildSummary / computeCosts never run inside a write transaction, and the scanner's replay() only calls write methods), so I'm fine merging as-is. But could you add a cheap hardening so it can't bite later? Options, in order of my preference:

  1. Have the scanner set a JS flag while a replay() transaction is open, and have cachedHistory treat "write transaction open on this connection" as always-rebuild (or throw). This turns the hazard into an enforced invariant.
  2. At minimum, a prominent invariant comment in historyCache.js: never call from inside a transaction that can roll back.

Fine to address in a follow-up — no need to hold this PR for it.

luwill pushed a commit that referenced this pull request Oct 20, 2026
The guard's dirty flag rolls back with the transaction but the JS
entries do not, so a cache rebuilt inside a transaction that later
rolls back would keep serving the undone rows. cachedHistory now
computes directly when the connection is in a transaction and leaves
the cache and guard alone. db.isTransaction needs Node 22.16; older
versions fall back to probing with BEGIN.

Adds the rollback repro from the #17 review as a regression test,
covering both detection paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@luwill
luwill merged commit 6384af4 into luwill:main Oct 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants