Repository navigation
perf(summary): cache pre-today aggregates so pushes don't rescan history - #17
Conversation
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>
|
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:
One latent issue found while probing — not a blocker today. If some future code calls a summary (hence 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 rowNo current path can hit this (
Fine to address in a follow-up — no need to hold this PR for it. |
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>
Refs #13. The design is the one proposed in #13 (comment).
Problem
Every scanner
updatemakes 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=1every 30 s, even when idle. Several queries inbuildSummary/computeCostsscanned all history on every call.Measured on synthetic data with 1M events (Linux, Node 22.22):
buildSummary(…, 30)date(…,'localtime')GROUP BYaggregateCosts(db, 0)COUNT(DISTINCT session_id)for todayCOUNT(*)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 overts < local midnight. Callers query only today's rows and merge them in.Invalidation. Any one of the following triggers a full rebuild:
main.eventsset 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.PRAGMA data_versionchanges when another connection commits, for exampletoken-watcher scanwhileserveis running. Only commits change it: I checked that opening a secondStoreand reading leaves it unchanged, so the menu-bar app and read-only CLI commands don't cause rebuilds.localDayStart(now)) moves.What is cached:
by_day_all, all-time totals, peak day, streak);aggregateCostsis split intocostRows(SQL) andpriceCostRows(JS). The cache holds the raw group rows, not priced results, sopricing.jsonedits and FX changes still apply immediately. Merged rows are sorted in GROUP BY key order, so tie ordering and theunpricedlist match a single query;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.mjsbuilds 40 days of events: peak and off-peak hours, long context, an unpriced model, NULL model and NULL session.aggregateCosts(db, 0), per-day GROUP BY, event count, today's distinct sessions).data_version, makes the matching cases fail.origin/maincode, the equivalence assertions pass and only the "was it recomputed" assertions fail, so results are unchanged.npm testpasses (913 checks) on Node 22.22.Notes
opencode-memory.mjsintest/run.mjs. Whichever merges second needs a one-line rebase.🤖 Generated with Claude Code