Skip to content

Speed up cache freshness checks on large archives#266

Merged
daaain merged 3 commits into
mainfrom
perf/cache-freshness-check
Jul 7, 2026
Merged

Speed up cache freshness checks on large archives#266
daaain merged 3 commits into
mainfrom
perf/cache-freshness-check

Conversation

@daaain

@daaain daaain commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Speed up cache freshness checks 13–54× on large archives (#12)

Even with a fully warm cache, launching the TUI on a big archive spent seconds
re-verifying freshness. This PR attacks the check itself, benchmarked against a
real 2 GB / 5,381-item copy of ~/.claude/projects (63 projects, ~1,830
session JSONLs, 1,603 sessions).

Results

Warm cache, best of 3, before → after on current main (which already
includes #251):

Scenario main this PR speed-up
TUI launch, one project (453 files) 646 ms 12 ms 54×
TUI project selector (all 63 projects) 1,190 ms 276 ms
Full-archive freshness scan 4,652 ms 358 ms 13×

The --tui launch path runs the freshness check twice (once in
_launch_tui_with_cache_check, again in SessionBrowser.load_sessions), so
the user-visible launch delay was ~1.3 s and is now ~25 ms.

What was actually slow

The file stats were never the problem — stat'ing all 1,830 files takes ~5 ms,
and even a full recursive walk of the 2 GB tree is ~150 ms. The measured costs:

  1. A SQLite connection per file. get_modified_files() called
    is_file_cached() per file, each opening a fresh connection (~40× the cost
    of the query it runs). Speed up the SQLite metadata-cache build (connection reuse + synchronous=NORMAL) #251 fixed this pattern for cache builds via
    batch(), but the freshness-check paths don't run inside a batch.
  2. Per-file sidecar probes. subagents_fingerprint() (Support hierarchies of agents #213) does an
    is_dir() probe + glob per file, ~160 ms across the archive — almost all
    for files that can't have sidecars.
  3. A dead query. get_cached_project_data() ran
    SELECT DISTINCT session_id FROM messages WHERE file_id = ? per cached
    file to fill CachedFileInfo.session_ids — a field nothing reads
    (production code, tests, or docs). This is what made the project selector
    slow.
  4. Migration re-checks. Every CacheManager construction re-ran the
    migration check (~2 ms); the TUI selector constructs one per project row,
    on every resize.

Changes (all in cache.py)

  • Batched get_modified_files() — one query fetches every cached row for
    the project (one connection open; zero extra inside a batch() scope), and
    one os.scandir per parent directory determines which files even can
    have sidecars, so the fingerprint scan only runs for those (~154 of 1,830
    here). Semantics are identical to the per-file path — verified below.
  • Dropped CachedFileInfo.session_ids and its N-queries-per-call.
    (Measured alternatives: single GROUP BY query = 209 ms, per-file lookups
    on a shared connection = 131 ms, not doing it = 4 ms.)
  • get_cached_project_data() fetches working_directories on the same
    connection
    instead of opening a second one per call.
  • Memoised the migration check per DB path per process, with an
    exists() guard so --clear-cache's DB deletion still re-migrates.

Connection lifecycle is untouched: connection-per-call stays the default
(the Windows-safe behaviour pinned by #251's integrity tests), and the new
code composes with batch().

Approaches considered and rejected

  • Directory-mtime fast path (from the issue): unsound as a freshness
    check — appending to a JSONL doesn't update the directory mtime, so it can
    only detect create/delete/rename. And since per-file stats cost ~5 ms
    total, it would save nothing meaningful.
  • Hash-based / probabilistic checking: both optimise the stats, which
    were never the bottleneck; probabilistic is also non-deterministic.
  • Parallel scanning / lazy validation: unnecessary at 12 ms; parallelism
    could still help the one-time initial parse (~400 s for the full 2 GB),
    but that's a rare event and separate from this issue.

Verification

  • End-to-end change detection on the real archive: touched file detected,
    new sidecar detected (including for a session with no pre-existing
    <stem>/ dir — the case the scandir shortcut could plausibly have
    broken), new file detected, and full parity with per-file
    is_file_cached() both outside and inside a batch() scope.
  • 2,323 unit tests + 68 TUI tests pass (including Speed up the SQLite metadata-cache build (connection reuse + synchronous=NORMAL) #251's connection-
    lifecycle integrity tests); ruff check and ty check clean.

Numbers above are from a Linux dev container; absolute values will differ on
macOS but the ratios should hold, since the fix removes work rather than
speeding it up.

Summary by CodeRabbit

  • Performance Improvements
    • Significantly faster TUI startup on large archives via batched cache freshness checks and fewer filesystem probes.
    • More efficient cache validation for file updates, including sidecar fingerprint handling.
    • Reduced repeated cache migration work during a single process run.
  • Bug Fixes
    • Improved cache metadata rebuilding so changes in project files are reflected more reliably, minimizing stale results.
  • Documentation
    • Updated developer documentation to describe the improved cache freshness and cache migration upgrade behavior.

Even with a fully warm cache, launching the TUI on a multi-thousand-
file archive spent seconds re-verifying freshness. Profiling against
a real 2GB / 5,381-item ~/.claude/projects copy showed the file stats
were never the problem (~5ms for 1,830 files); the cost was per-file
SQLite connections, per-file sidecar dir probes, and a dead query.

Four changes, all in cache.py:

- get_modified_files(): batched equivalent of per-file
  is_file_cached() — one query fetches every cached row for the
  project (one connection open instead of one per file, composing
  with batch() from #251), and one scandir per parent directory
  rules out subagent-sidecar fingerprints (#213) so the fingerprint
  scan only runs for files that actually have a <stem>/ dir
  (~154 of 1,830 in the test archive). Semantics verified identical
  to the per-file path on real data.

- get_cached_project_data(): drop the per-file
  "SELECT DISTINCT session_id FROM messages" loop feeding
  CachedFileInfo.session_ids — the field was written but never read
  anywhere (production, tests, or docs), and cost N queries over the
  largest table on every call. Field removed from the model.

- get_cached_project_data(): fetch working_directories on the same
  connection instead of opening a second one per call.

- _init_database(): memoise the migration check per DB path per
  process — the TUI project selector constructs a CacheManager per
  row on every resize, each re-paying ~2ms of migration checks. An
  exists() guard keeps --clear-cache's DB deletion re-migrating.

Measured on the 2GB archive (63 projects, warm cache, best of 3),
before -> after on current main:

  TUI launch, one project (453 files):   646ms -> 12ms   (54x)
  TUI project selector (63 projects):   1190ms -> 276ms  (4x)
  Full-archive freshness scan:          4652ms -> 358ms  (13x)

Approaches considered and rejected: directory-mtime fast path
(unsound — appends don't touch dir mtime; and stats were never the
bottleneck), hash/probabilistic checks (same reason), parallelism
(unnecessary at these timings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d3765f29-d498-4d6d-98cf-c3c4e5ac3b86

📥 Commits

Reviewing files that changed from the base of the PR and between 39c1c21 and 96871c4.

📒 Files selected for processing (1)
  • claude_code_log/cache.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • claude_code_log/cache.py

📝 Walkthrough

Walkthrough

Refactors the SQLite cache module to memoize migration checks per database path, centralize freshness validation, batch cached-file metadata queries for freshness scanning, remove session_ids from CachedFileInfo, and reuse an open connection for working directories. Documentation is updated to match.

Changes

Cache freshness and migration memoization

Layer / File(s) Summary
Migration memoization on DB init
claude_code_log/cache.py
Adds process-wide migration tracking for database paths and skips repeated run_migrations() calls when a path has already been handled.
Freshness checks and cache schema
claude_code_log/cache.py
Centralizes cache-row freshness checks, makes is_file_cached() delegate to the shared helper, removes session_ids from CachedFileInfo, and computes working directories from the existing SQLite connection.
Batched modified-files scan
claude_code_log/cache.py
Loads cached metadata for candidate JSONL files in one query, applies mtime and subagent fingerprint checks, and uses cached directory scans to determine modified files.
Cache behavior documentation
dev-docs/application_model.md
Updates the Cache and Migrations sections to describe the revised freshness scan and memoized migration behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: faster cache freshness checks for large archives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/cache-freshness-check

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
claude_code_log/cache.py (1)

912-990: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Freshness logic duplicated between is_file_cached() and get_modified_files().

Both methods independently re-implement the same mtime-tolerance check and the NULL-fingerprint acceptance rule (Lines 616-629 vs. 955-989). The batched version is a legitimate optimization (scandir pre-filter, single query), but any future change to the freshness rules now needs to be applied in two places, risking drift.

Consider extracting the pure decision (row, source_mtime, current_fp) → bool into a small shared helper that both methods call, keeping the batching/scandir optimization only in get_modified_files().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@claude_code_log/cache.py` around lines 912 - 990, The freshness decision
logic is duplicated between is_file_cached() and get_modified_files(), so
extract the pure cache-validity check into a shared helper that takes the cached
row, current source_mtime, and current subagents fingerprint and returns whether
the file is fresh. Update both is_file_cached() and get_modified_files() to call
that helper, while keeping get_modified_files()’s batching and scandir
optimization intact; use the existing symbols is_file_cached,
get_modified_files, and subagents_fingerprint to locate the shared rule.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@claude_code_log/cache.py`:
- Around line 912-990: The freshness decision logic is duplicated between
is_file_cached() and get_modified_files(), so extract the pure cache-validity
check into a shared helper that takes the cached row, current source_mtime, and
current subagents fingerprint and returns whether the file is fresh. Update both
is_file_cached() and get_modified_files() to call that helper, while keeping
get_modified_files()’s batching and scandir optimization intact; use the
existing symbols is_file_cached, get_modified_files, and subagents_fingerprint
to locate the shared rule.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d629b424-72d0-4446-8587-ec40ccb935e0

📥 Commits

Reviewing files that changed from the base of the PR and between 42e54d4 and d46f5ab.

📒 Files selected for processing (2)
  • claude_code_log/cache.py
  • dev-docs/application_model.md

@daaain daaain merged commit b1f5182 into main Jul 7, 2026
17 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.

1 participant