Skip to content

feat(queue): reclaim the 84% of the queue nothing ever reclaimed - #117

Merged
pitimon merged 1 commit into
mainfrom
feat/103-compact-and-row-invariant
Jul 25, 2026
Merged

feat(queue): reclaim the 84% of the queue nothing ever reclaimed#117
pitimon merged 1 commit into
mainfrom
feat/103-compact-and-row-invariant

Conversation

@pitimon

@pitimon pitimon commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses #103: sync --compact, plus the column invariant that lived only in prose now running in doctor.

Measured on a real install: 34,492 lines, 5,595 unique keys, 83.8% superseded, 11 MB — and readQueueData re-reads and re-dedups all of it on every endpoint call, 6-8 per dashboard refresh, auto-refresh every 30s.

Why the design risk is near zero

The readers already define the output. readQueueData keeps the last row per source|model|hour_start, so compaction keeps the last raw line per that key — not a re-serialised row. Byte-identical API responses follow by construction rather than by luck: the surviving bytes are the exact bytes the reader would have kept. A test reproduces the reader's dedup and asserts its output is identical before and after.

Unparseable lines are kept. They are invisible to every reader already, so dropping them changes no response — but a partial write worth investigating should survive a routine maintenance command.

Three things found while building this

1. The invariant as documented is wrong, and my check inherited the error

Running it against a real 34,922-row queue reported 8,236 violations. Every one was source=codex, and in every case the difference was exactly reasoning_output_tokens.

The rows were right. Codex and every-code fold reasoning into output_tokens, and computeRowCost (src/lib/pricing/index.js:309) already made that distinction by charging their reasoning at zero. CLAUDE.md said "sum of all columns" with no exception — the prose was the defect.

Corrected there, and expectedTotal() is now the single rule shared by doctor and the #104 parser harness, which had copied the same too-strict version.

After the fix: 13 findings, all inside 2 real codex buckets whose total exceeds its columns by 5,337. Left as a warn — genuine data, not a check artefact.

2. Automatic compaction is NOT shipped, though #103 proposed it

Wired up first, it immediately rewrote an 11 MB production queue — 34,924 lines down to 5,636 — during npm test.

Sync now recommends --compact past the threshold and does not act. Rewriting the user's data file unasked turns every path that reaches sync into a data-mutating path, and that is a decision for the person whose data it is.

3. The reason it could reach production data

test/init-uninstall.test.js spawned the notify handler in a child process with env: { ...process.env }. Overriding process.env.HOME in the parent never reaches a child, so it resolved ~/.tokentracker and ran a real sync.

Pre-existing — it had been appending rows to the maintainer's real queue all along. Compaction only made it visible. The helper now requires an isolated home and throws without one, with a test for that.

Verified empirically: ci:local no longer changes the real queue's mtime.

Test plan

  • ci:local exit 0 — 914 root tests (+12), 256 dashboard
  • Reader-view equality before and after compaction, on a fixture mixing claude and codex rows across two keys — queue: compact the append-only store and enforce the row invariant #103's actual definition of done, not the line count
  • The surviving line is byte-identical to the original, including an unknown future field with non-alphabetical key order
  • Interrupt safety: the rename is forced to fail, and the original is asserted intact with no temp file left behind
  • No-op when nothing is superseded — the file is not rewritten at all
  • doctor flags a column-sum violation, a negative count, a non-numeric column and a misaligned bucket; a codex row that folds reasoning is not flagged, and the same numbers from claude are
  • sync --compact end-to-end on an isolated HOME: 6 lines → 1, keeping the latest
  • Every touched function under the 50-line rule; queue-compact.js is 220 lines

Not in this PR

Automatic compaction (see above) and the concurrent-append test. The operation runs inside the existing sync lock, which is where a concurrent writer is already excluded.

Addresses #103. `sync --compact`, and the column invariant that lived only in
prose now runs in `doctor`.

Measured on a real install: 34,492 lines, 5,595 unique keys, 83.8% superseded,
11 MB — and readQueueData re-reads and re-dedups ALL of it on every endpoint
call, 6-8 per dashboard refresh, auto-refresh every 30s.

The design risk is near zero because THE READERS ALREADY DEFINE THE OUTPUT.
readQueueData keeps the last row per `source|model|hour_start`, so compaction
keeps the last RAW LINE per that key — not a re-serialised row. Byte-identical
API responses follow by construction, not by luck: the surviving bytes are the
exact bytes the reader would have kept. There is a test that reproduces the
reader's dedup and asserts its output is identical before and after.

Unparseable lines are KEPT. They are invisible to every reader already, so
dropping them changes no response — but a partial write worth investigating
should survive a routine maintenance command.

THREE THINGS FOUND WHILE BUILDING THIS.

1. The invariant as written is WRONG, and my check inherited the error.
   Running it against a real 34,922-row queue reported 8,236 violations — every
   one source=codex, and in every case the difference was exactly
   reasoning_output_tokens. The rows were right: codex and every-code fold
   reasoning INTO output_tokens, and computeRowCost (pricing/index.js:309)
   already made that distinction by charging their reasoning at zero.
   CLAUDE.md said "sum of all columns" with no exception. Corrected there, and
   expectedTotal() is now the single rule shared by doctor and the #104 parser
   harness, which had copied the same too-strict version.
   After the fix: 13 findings, all in 2 real codex buckets whose total exceeds
   its columns by 5,337. Left as a WARN — genuine data, not a check artefact.

2. AUTOMATIC compaction is NOT shipped, though #103 proposed it. Wired up
   first, it immediately rewrote an 11 MB production queue (34,924 -> 5,636
   lines) during `npm test`. Sync now RECOMMENDS `--compact` past the threshold
   and does not act. Rewriting the user's data file unasked turns every path
   that reaches sync into a data-mutating path.

3. The reason it could: test/init-uninstall.test.js spawned the notify handler
   in a CHILD PROCESS with `env: {...process.env}`, so overriding
   process.env.HOME in the parent never reached it and the child resolved
   ~/.tokentracker. Pre-existing — it had been appending rows to the
   maintainer's real queue all along; compaction only made it visible. The
   helper now requires an isolated `home` and throws without one, with a test
   for that. Verified: ci:local no longer changes the real queue's mtime.

Interrupt safety is write-then-rename, so the file is either entirely the old
one or entirely the new one; a test forces the rename to fail and asserts the
original is intact and no temp file is left behind.

ci:local exit 0: 914 root tests, 256 dashboard.
@pitimon
pitimon merged commit 089b737 into main Jul 25, 2026
1 check passed
@pitimon
pitimon deleted the feat/103-compact-and-row-invariant branch July 25, 2026 22:30
pitimon pushed a commit that referenced this pull request Jul 25, 2026
A flake I introduced in #117 and did not see until the hermes fixture run.

Giving the notify handler an isolated HOME was the right fix — it stopped the
child resolving the developer's real ~/.tokentracker. But the handler
deliberately spawns a DETACHED background sync, and that sync now writes inside
the test's own temp directory. The write can land after execFile's callback has
fired, so the `fs.rm` in `finally` races it and fails with ENOTEMPTY.

Cleanup retries instead of waiting on a process the handler detaches on purpose.
16 call sites, one helper.

Verified by running the file 5 times: 19 pass, 0 fail, every run. A single green
run proves nothing about a race.

Also recording the process error that let this reach a commit: I ran `ci:local`
and chained the commit after `echo "exit=$?"`, which always succeeds, so the
`&&` did not gate on the red suite. The commit landed on a failing gate. Fixed
by capturing the exit code into a variable and testing it.

ci:local exit 0: 933 root tests, 262 dashboard.
pitimon added a commit that referenced this pull request Jul 25, 2026
…120)

* test(parsers): the hermes fixture the allowlist was standing in for

Closes the last DoD item on issue 104. The allowlist entry said a fixture
"needs a real schema, not a JSONL line" — so this builds one.

node:sqlite ships with Node 22+, already the floor here, and readHermesSessions
falls back to it when the sqlite3 CLI is absent. If neither is available the
harness reports the fixture produced no rows rather than passing quietly — the
same guard that caught the empty kimi fixture.

Schema taken from the parser's own query (rollout.js:3216), not guessed, and
started_at/ended_at are epoch SECONDS with the bucket coming from
`ended_at ?? started_at`.

Three sessions across two databases, because the profile walk is what is unique
to this parser: a default state.db plus profiles/work/state.db, and a
profiles/empty/ with no database that must be skipped rather than throw. Also a
session with cache and reasoning at zero, which still has to satisfy the column
sum rather than be excused from it.

Verified by reading the emitted rows, not just the exit code: 3 sessions ->
2 buckets grouped by model, 4290+820+15000+300+120 = 20530 and 500+60+0+0+40 =
600, and claude-opus-5 present proves the profile database was read.

Allowlist 21 -> 20, max_size lowered in the same commit as the ratchet requires.

ci:local exit 0: 933 root tests, 262 dashboard.

* test: cleanup raced the background sync the HOME fix made land in-tree

A flake I introduced in #117 and did not see until the hermes fixture run.

Giving the notify handler an isolated HOME was the right fix — it stopped the
child resolving the developer's real ~/.tokentracker. But the handler
deliberately spawns a DETACHED background sync, and that sync now writes inside
the test's own temp directory. The write can land after execFile's callback has
fired, so the `fs.rm` in `finally` races it and fails with ENOTEMPTY.

Cleanup retries instead of waiting on a process the handler detaches on purpose.
16 call sites, one helper.

Verified by running the file 5 times: 19 pass, 0 fail, every run. A single green
run proves nothing about a race.

Also recording the process error that let this reach a commit: I ran `ci:local`
and chained the commit after `echo "exit=$?"`, which always succeeds, so the
`&&` did not gate on the red suite. The commit landed on a failing gate. Fixed
by capturing the exit code into a variable and testing it.

ci:local exit 0: 933 root tests, 262 dashboard.

---------

Co-authored-by: itarun.p <itarun.p@somapait.com>
pitimon added a commit that referenced this pull request Jul 25, 2026
The last unticked box: "Compaction is safe against a concurrent append (test
with the sync lock held)."

It is safe BECAUSE of the lock, not on its own, so a test that only asserted the
happy path would be claiming a property the code does not have. Both halves are
demonstrated instead.

First, the hazard is real and shown to be real: patching writeFileSync to append
to the queue in the window between the temp write and the rename, the appended
row IS lost. It was on the old inode and went with it. If that test ever starts
failing because the append survives, compaction grew a merge step and the lock
requirement should be re-examined — a finding, not a broken test.

Second, the lock genuinely excludes a second holder, and releases. Without that
"compaction runs inside the lock" would guarantee nothing.

Third, the positive case in the arrangement sync actually uses — lock, compact,
release — with a would-be concurrent writer refused mid-operation.

Fourth, a source fact pinning the ORDER in sync.js. Reordering those lines would
leave the other three green while reopening the window, which is exactly the
kind of regression the other three cannot see.

ci:local exit 0: 956 root tests, 302 dashboard.

Co-authored-by: itarun.p <itarun.p@somapait.com>
pitimon added a commit that referenced this pull request Jul 26, 2026
15 commits since v0.39.43. Tracker: #125.

User-facing:
- The Projects panel honours the date range. It read no query parameter at
  all, so picking "24h" narrowed every other card while Projects kept showing
  all-time totals with nothing on screen saying so (#118).
- Per-repo cost, with rows written before the change named as unpriced rather
  than shown as a confident $0 (#121).
- Sources with no per-repo attribution are named, instead of their absence
  reading as "that tool cost nothing here" (#118).
- A plan-value card answering what README:32 already promised, labelled as
  list-price-equivalent rather than a saving (#122).
- The Codex quota chip no longer vanishes on a 401 (#119).
- `sync --compact`, which reclaimed 34,924 lines to 5,636 on a real install
  (#117).

Behind the scenes: outbound-privacy validator hardening (#111, #114), avatar
proxy per-hop revalidation, port-aware allowlisting and a real download cap
(#109, #112), a parser conformance ratchet (#116, #120), scripts/graph out of
the product gate (#115), and a usage-limits fixture capture tool (#124).

Version bumped in all four lockstep locations by the `version` hook:
package.json, package-lock.json, both TokenTrackerBar targets, and the Windows
csproj. validate:version-lockstep passes.

WATCH AFTER SHIPPING: #121 migrates cursors.json on every user's first sync
after upgrading, re-keying project buckets from project|source|hour to
project|source|model|hour. Without that the old bucket strands and its usage is
counted twice. Verified read-only against a real 2,015-bucket state — every key
migrated, total_tokens preserved exactly at 6,281,653,062, and all 2,015
queuedKey markers survived, the loss of which would re-append every row. That
is one machine; a differently-shaped state is the residual risk.

ci:local exit 0: 972 root tests, 302 dashboard.

Co-authored-by: itarun.p <itarun.p@somapait.com>
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.

1 participant