Skip to content

fix(summary): never touch the history cache inside a transaction - #18

Merged
luwill merged 2 commits into
luwill:mainfrom
amenjason936:fix/history-cache-txn
Oct 20, 2026
Merged

luwill merged 2 commits into
luwill:mainfrom
amenjason936:fix/history-cache-txn

Conversation

@amenjason936

Copy link
Copy Markdown
Contributor

Follow-up to the #17 review. Stacked on #17: this branch contains its commit (0aa1ce3), so please merge #17 first. After that, only 5f8a92f remains in this diff.

Problem (from your review)

The tw_history_guard.dirty flag rolls back with the enclosing transaction, but the JS entries do not. If cachedHistory rebuilt the cache inside a write transaction that later rolled back, the cache would keep serving the undone rows, and nothing would invalidate it. The current code never reaches this path, because summaries are not built inside transactions. This change makes that invariant enforced instead of implicit.

Fix

I went with your option 1, but cachedHistory checks the connection itself instead of relying on a flag set by the scanner. That way any future transaction, not just replay(), is covered.

  • When the connection is in a transaction, cachedHistory returns compute(boundary) directly and does not read or write the cache or the guard table. The result is correct for whatever the transaction can see, and callers get no new exception path.
  • Detection uses db.isTransaction, which exists from Node 22.16 on. engines allows 22.13, so on older versions it probes with BEGIN: if that throws, we are already in a transaction; otherwise it does COMMIT right away. A deferred BEGIN takes no locks.
  • historyCache.js also gets a header comment stating the invariant (your option 2).

Tests

test/history-cache.mjs includes your repro:

  1. Write a history row, then read.
  2. BEGIN, write another history row, read inside the transaction (it must see its own row).
  3. ROLLBACK, then compare against a cold recompute on a fresh connection.

The case runs twice: once through db.isTransaction, and once with that property hidden behind a Proxy so the BEGIN-probe path is used. Both cases fail without the fix (stale cache after rollback) and pass with it. Full npm test is green on Node 22.22.

🤖 Generated with Claude Code

amenjason936 and others added 2 commits October 20, 2026 02:09
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>
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 luwill#17 review as a regression test,
covering both detection paths.

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

luwill commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Reviewed and verified in a clean worktree — LGTM.

  • Full suite green, including both new rollback tests (db.isTransaction path and the BEGIN-probe path).
  • db.isTransaction claims check out on Node 24: the property exists, is a boolean, and correctly reports false outside / true inside a transaction. Nested BEGIN throws exactly "cannot start a transaction within a transaction", which is what the probe relies on. With engines: >=22.13 the fallback is genuinely needed, and the CI 22.13 legs exercise it for real.
  • Re-ran the repro from my perf(summary): cache pre-today aggregates so pushes don't rescan history #17 review against this branch: inside the transaction the read sees its own uncommitted row; after ROLLBACK the cache matches the table (no staleness). I additionally checked the commit direction — a history write committed after an in-txn read is picked up correctly, since the trigger's dirty flag commits with the transaction and forces the rebuild.
  • Probing with a deferred BEGIN + immediate COMMIT when not in a transaction takes no locks and doesn't disturb data_version, so the fallback is safe on the hot path (and it only runs on Node < 22.16 anyway).

Checking the connection instead of a scanner-set flag (option 1, generalized) is the right call — it covers any future transaction, not just replay(). Merging after #17 as noted.

@luwill
luwill merged commit 8be05c1 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