Skip to content

perf(scanner): batch inserts + bulk snapshot eviction (~33x on 1 GB) - #18

Open
Dig1taly wants to merge 1 commit into
nateherkai:mainfrom
Dig1taly:perf/batched-inserts-scanner
Open

perf(scanner): batch inserts + bulk snapshot eviction (~33x on 1 GB)#18
Dig1taly wants to merge 1 commit into
nateherkai:mainfrom
Dig1taly:perf/batched-inserts-scanner

Conversation

@Dig1taly

Copy link
Copy Markdown

Summary

Replaces the per-message INSERT/DELETE loop in `scan_file()` with batched `executemany()` and a single temp-table-driven snapshot eviction. Observable behavior is unchanged; throughput is dramatically higher on heavy data sets.

Measured speedup

On a real `~/.claude/projects` of 1.0 GB / 566 files / ~200k messages:

Time Notes
Before (main `5375650`) 4773 s (~80 min) Full first scan from empty DB
After (this PR) 145 s (~2.4 min) Same workload, same machine
Speedup ~33×

Incremental scans (the common case once the DB is built) stay fast either way - the per-file mtime/offset short-circuit short-circuits before the new code path even runs.

What changed

All inside `token_dashboard/scanner.py`. Public API of `scan_file` and `scan_dir` is unchanged.

  • `_parse_file()` - pure-Python parser that returns lists of dicts and an `end_offset`. No DB access. Splits the parsing concern from the writing concern.
  • `_dedupe_inflight_snapshots()` - collapses streaming snapshots within the in-memory batch before any DB work. Same semantics as `_evict_prior_snapshots()` (keep only the last uuid per `(session_id, message_id)`), just done in Python over the new batch.
  • `_evict_prior_snapshots_bulk()` - replaces N inline `SELECT + DELETE` round-trips with one temp-table JOIN against `messages`, followed by chunked `DELETE ... IN (...)` against both `messages` and `tool_calls`. Chunks stay under SQLite's default `SQLITE_MAX_VARIABLE_NUMBER` (999).
  • `scan_file()` - now parses to memory, dedupes the batch, bulk-evicts prior snapshots, then writes messages and tools via `executemany`. Partial-line / EOF / decode-error handling preserved verbatim.

Constraints respected

  • Stdlib only. No new dependencies, no `pip install`.
  • Public function signatures and return shape unchanged.
  • Streaming-snapshot semantics preserved (`test_scanner_dedup.py` is the authoritative spec; passes).
  • Partial-line handling preserved (`test_scanner_rescan.py` exercises the appended-lines path; passes).
  • All inserts still use parameterized SQL.

Why it's so much faster

Two compounding wins on heavy data sets:

  1. Per-message INSERT round-trips eliminated. Python ↔ SQLite has measurable per-call overhead. With ~200k messages and ~115k tool rows, switching to `executemany` removes hundreds of thousands of context switches.
  2. Eviction round-trips eliminated. The old code ran a `SELECT` then a per-snapshot `DELETE` for every assistant message that carried a `message.id`. On long sessions with many streaming snapshots, that was the dominant cost. One temp-table JOIN at end-of-file does the same work in O(file) instead of O(messages).

Tests

```
$ python -m unittest discover tests
Ran 71 tests in 17.170s
OK
```

Including the eviction tests in `test_scanner_dedup.py` which directly exercise the new `_evict_prior_snapshots_bulk` path through `scan_file` and verify that earlier snapshots are dropped from both `messages` and `tool_calls` while the final tally survives.

Not in this PR (deliberately)

  • No process-pool parallelism. `ProcessPoolExecutor` on Windows uses `spawn` with pickle IPC overhead per task, which would partially erode the win on the dataset size where it matters most. Worth revisiting separately with a benchmark.
  • No `orjson` / `msgspec`. Stdlib-only rule.
  • No schema changes. Same DB file is readable by older `token-dashboard` versions after this code runs against it.

Happy to split into smaller commits, rename anything, or rework if you'd like a different shape.

Replace the per-message INSERT/DELETE loop with batched executemany() and
a single temp-table-driven eviction pass.

Observable behavior is unchanged: scan_file() still tracks per-file byte
offsets, partial-line handling is identical, streaming-snapshot dedup
still keeps only the final uuid per (session_id, message_id), and full
rescans remain idempotent.

What changed:
- _parse_file(): pure-Python parser, returns lists of dicts (no DB touch)
- _dedupe_inflight_snapshots(): collapses streaming snapshots within the
  in-memory batch before any DB work
- _evict_prior_snapshots_bulk(): one temp-table-driven JOIN to find dead
  uuids, then chunked DELETEs (stays under SQLITE_MAX_VARIABLE_NUMBER)
- scan_file(): parse -> dedupe -> evict -> executemany INSERT
- scan_dir() unchanged

Measured on 1.0 GB / 566 files / ~200k messages:
  before: 4773 s (~80 min)
  after:    145 s (~2.4 min)
  speedup: ~33x

All 71 unit tests pass, including the streaming-snapshot dedup tests in
test_scanner_dedup.py which exercise the eviction semantics.

Stdlib only, no new dependencies, no API changes.
muckybuzzwoo added a commit to muckybuzzwoo/token-dashboard that referenced this pull request May 22, 2026
Adapts upstream PR nateherkai#18 (Dig1taly) to this fork.

Replaces per-message INSERT/DELETE round-trips with batched executemany()
and a single temp-table-driven snapshot eviction pass. Fork-specific
additions to preserve correctness:

- _parse_file()'s output (and the new scan_file() return value) carries
  the days and sessions sets that scan_dir() needs for incremental
  rebuild of the materialised summary tables (introduced by PR nateherkai#13).
  Without this, summaries would silently grow stale after every scan.
- File-level batched commits (BATCH_SIZE=20) from PR nateherkai#13 remain in
  scan_dir(); the new in-file batching from nateherkai#18 composes on top of that,
  not as a replacement.

Measured locally on this fork: a full rescan of 589 files / 63k messages
drops from minutes to ~7.5 seconds.

Upstream PR: nateherkai#18
muckybuzzwoo added a commit to muckybuzzwoo/token-dashboard that referenced this pull request May 22, 2026
…kai#19

- nateherkai#18 (perf scanner) and nateherkai#19 (attributionSkill + split skills view)
  added to the Integrated upstream PRs table with adaptation notes.
- nateherkai#17 added to Deliberately skipped with the reason: PR nateherkai#17's progress
  callback signature (scanned, total, totals) is strictly less
  informative than this fork's (index, total, path, totals) from PR #2,
  so integrating it would degrade the CLI progress printer.
- Differences from upstream rewritten: clarifies the new Skills &
  Commands route's split between "You ran" (slash commands via
  attribution_skill) and "Claude invoked" (real Skill tool calls),
  and notes that the two sources are de-duplicated against each
  other so the same slash command never inflates both columns.
- Scanner performance section updated to reflect the post-nateherkai#18
  measurement (~7.5s full rescan of ~600 files / 60k+ messages).
muckybuzzwoo added a commit to muckybuzzwoo/token-dashboard that referenced this pull request May 22, 2026
After PR nateherkai#18 the production scan path uses _evict_prior_snapshots_bulk
exclusively. The single-row helper was kept "for historical reference"
in the PR nateherkai#18 commit, but nothing in the codebase calls it. Removing
it eliminates an unowned half-decision and consolidates the eviction
docstring on the function that actually runs.

The CLAUDE.md "Streaming-snapshot dedup" bullet still points at the
old symbol; that reference will be updated in a separate CLAUDE.md
revision (out of scope for this fork-integration sweep).
muckybuzzwoo added a commit to muckybuzzwoo/token-dashboard that referenced this pull request May 22, 2026
- README clone URL now points at the fork (was upstream).
- "The 7 tabs" → "The 8 tabs" — adds RTK and updates the Skills entry
  to describe the new "You ran" / "Claude invoked" split. Settings
  section mentions Team plans (PR nateherkai#15) and runtime .claude-folder
  switching (PR nateherkai#7).
- First-run scan-time estimate dropped from 20–60s to 5–10s to
  reflect the PR nateherkai#18 batched-insert scanner. Prompts entry now
  mentions the CSV / Markdown export (PR nateherkai#10).
- FORK_NOTES.md gains a "Last updated" line at the top pointing at
  the commits page as the running change log.
@muckybuzzwoo

Copy link
Copy Markdown

Integrated this into a fork at https://github.com/muckybuzzwoo/token-dashboard (commit 870c8db) with one adaptation: scan_file's return value now also carries the days and sessions sets that the fork's materialised summary tables need for incremental rebuild — without those, summaries would silently grow stale after every scan.

Real-world measurement on the fork's owner's DB: ~600 files / 60k+ messages full rescan completes in ~7.5 seconds. Thanks for the speedup!

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