perf: stop reading whole session files, and index them incrementally - #82
perf: stop reading whole session files, and index them incrementally#82Davidb-2107 wants to merge 2 commits into
Conversation
On a machine with ~2.3 GB under ~/.claude/projects, the main process sat at a 139 MB median but spiked past 250 MB in 24% of samples, peaking at 498 MB (921 MB across all processes) — with a single terminal open, so neither the terminals nor the grid view were involved. Two causes, both "read the whole file to use a little of it". A session .jsonl held as a JS string costs ~2x its size in RAM, since V8 stores non-latin1 text as UTF-16. 1. Three sites read an entire file just to get its head: schedule-runner.js kept 4000 chars — every 60s, for every project folder main.js kept 8000 chars derive-project-path kept the first line carrying `cwd` The scheduler one dominated: a 61 MB session file allocated ~122 MB once a minute, which is the sawtooth in the main process. 2. readSessionFile re-read the file in full on every append, to produce ~9 KB of metadata. The projects watcher fires that on each write, so an active session re-read its whole history every few seconds. Session files are append-only (folder-index-state.js already relies on it; measured here: 0 rewrites and 0 truncations across 1214 files), and every field readSessionFile extracts is either a first occurrence or a running total. So it now resumes from the byte offset the previous pass reached, persisted alongside a 4 KB head hash and a size check that fall back to a full read if the file was rewritten or truncated. Adds jsonl-scan.js with the two supported ways to walk these files — scanLines (chunked, resumable, early-exit) and readHead. Measured on the same workload, main process over 4 minutes: before rss 136 -> 520 MB, heap peak 360 MB, 3 jumps of +354 MB after rss 145 -> 170 MB, heap peak 12 MB, 0 jumps A/B against the released build over 11 minutes, one terminal open: peak across all processes 921 -> 526 MB main process peak 498 -> 158 MB samples above 250 MB 24% -> 0% Re-indexing after an append: ~0 MB and 22 ms, from 152 MB and 447 ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e test Branch was 14 behind. Two things needed fixing, both merge-order artifacts — doctly#60 and doctly#65 landed after this branch was written. schedule-runner.js conflicted, but the two changes compose rather than compete. doctly#65 made scanSchedules prefer a cache_meta folder→projectPath lookup, falling back to reading a JSONL head only for folders missing from the cache; this branch made that head read cheap. Resolved by keeping doctly#65's structure and putting readHead(…, 4096) inside its readProjectPathFromJsonl fallback, so the common path does no file read at all and the fallback no longer loads a possibly-hundreds-of-MB file to look at its first line. test/reconcile-cache.test.js failed because refreshFolder now fetches the cached row to use as resume state, and that test's fake db predates the method: ✖ reconcileCacheFromFilesystem indexes new and stale folders … getCachedSession → undefined Added getCachedSession() { return null; } to the fake, modelling a session with nothing indexed yet. Fixed in the fake rather than guarding the call site: the real db provides the method, and a guard would mask genuine wiring errors. 27/27 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed this properly — it's careful work and the diagnosis holds up on my machine too. For calibration: 3,290 session files, 3.1 GB, largest 136.6 MB. Under the current code an append to that file re-reads all of it into a JS string — roughly 273 MB of transient allocation — every few seconds while the session is live. Your sawtooth reproduces. Heads up: I pushed a merge commit to this branch (5016379) fixing the two things that were blocking it. Both were merge-order artifacts, not defects in your work — #60 and #65 landed after you wrote this. Details below so nothing is a surprise. What's done right
The resume-safety triad is the part I'd have most expected to find wrong, and it's complete: The migration is additive with no cache wipe — NULL I also specifically audited the riskiest refactor here, narrowing What I changed in 5016379
I added Remaining — your callLong lines are quadratic. Two test gaps. The truncation path ( Smaller notes. Nothing above blocks merging. Happy to take it as-is and file the long-line concat separately if you'd rather land the win now. |
What I saw
On a machine with ~2.3 GB under
~/.claude/projects(1400 sessions, largest file 217 MB), the main process sat at a 139 MB median but spiked past 250 MB in 24% of samples, peaking at 498 MB — 921 MB across all processes.That was measured with a single terminal open, so neither the terminals, the grid view, nor the WebGL renderers were involved. I chased those first and the measurements ruled them out.
Why
Two variations of the same thing: reading a whole file to use a little of it. A session
.jsonlheld as a JS string costs ~2x its size in RAM, since V8 stores non-latin1 text as UTF-16.1. Three sites read an entire file just to get its head.
schedule-runner.jsscanSchedulesmain.js(session slug)derive-project-path.jscwdThe scheduler one dominates: a 61 MB session file allocates ~122 MB once a minute. That is the sawtooth.
2.
readSessionFilere-read the file in full on every append, to produce ~9 KB of metadata. The projects watcher fires it on each write, so an active session re-read its entire history every few seconds.What this changes
Adds
jsonl-scan.jswith the two supported ways to walk these files —scanLines(chunked, resumable, early-exit) andreadHead— and routes every caller through it.For the indexer: session files are append-only (
folder-index-state.jsalready relies on this; measured here as 0 rewrites and 0 truncations across 1214 files), and every fieldreadSessionFileextracts is either a first occurrence or a running total. So it resumes from the byte offset the previous pass reached, persisted in four newsession_cachecolumns.Safety: a 4 KB head hash and a size check catch a file that was rewritten in place or truncated, falling back to a full read rather than emitting a corrupt message count. Migration v4 adds the columns without wiping the cache —
indexedBytesstays NULL on existing rows, which reads as "cannot resume", so each session is fully re-read once and incrementally after that.Measurements
Main process, 4 minutes, same workload:
A/B against the released build, both running side by side for 11 minutes on the same watched directory:
Re-indexing after an append: ~0 MB and 22 ms, down from 152 MB and 447 ms.
The ~480 MB floor (Electron + renderer + GPU) is untouched — this only removes the spikes.
Tests
Four tests in
test/read-session-file.test.jscover the resume path: that an append reads only the appended bytes, that the incremental result equals a full re-read, that a rewritten file falls back to a full read, and that a single-message file with no trailing newline still indexes.Verified by running the build against a copy of a real 1400-session database.
Happy to split this into two PRs (the head-reads and the incremental indexer) if you'd prefer to review them separately.