perf: stop the search index from freezing the event loop - #287
Open
NovakPAai wants to merge 1 commit into
Open
Conversation
buildSearchIndex re-parsed every session with detail — a findSessionFile lookup plus a full detail load (sync fs read + JSON.parse per line) each — in one synchronous tick, on whichever request happened to miss the 60s cache. Measured on a 900-session history: a 3.6s hard stall of the event loop, which also stalls every other API call and the terminal WebSocket data pump. The analytics job already solved this exact problem with chunk+yield (_scheduleAnalyticsRecompute); search never got the same treatment. Chunking per session alone wasn't enough: real histories have a heavy tail (median session ~0.1MB here, but codex transcripts up to 78MB), and readLines slurps the whole file into a string before splitting — one such session blocks for seconds regardless of the outer chunk size. - buildSearchIndex is async, processes sessions in small chunks (8 — each item is far heavier than the computeSessionCost calls analytics batches 80-at-a-time) and yields via setImmediate between them. - JSONL sessions over SEARCH_STREAM_THRESHOLD (4MB) are read line-by-line off a stream that yields every 2000 lines, so one huge transcript can't block either. Nothing is truncated — this only changes *when* the work happens, not what gets indexed. - getSearchIndex is stale-while-revalidate (mirroring getCostAnalytics): a >60s-old index is still overwhelmingly accurate, so it's served instantly while the refresh runs in the background. Only a genuine cold start awaits. - Concurrent rebuilds dedupe into one in-flight job, so a burst of searches during a rebuild no longer queues N full-history scans. - Collapsed six near-identical per-format if/else branches into a SEARCH_DETAIL_LOADERS lookup table, and the two JSONL readers now share one per-line parser so they can't drift. Snippet cap is a named constant instead of a repeated 500. - searchFullText is now async; /api/search and the `codbash search` CLI command updated accordingly. Measured before/after on the same 900-session history: worst event-loop stall 3622ms -> 60ms cold build 3.6s -> 3.0s warm search ~14ms (unchanged) Search results verified byte-identical to origin/main across 6 queries (496 result rows) via a git-worktree differential run.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
buildSearchIndexre-parsed every session with detail — afindSessionFilelookup plus a full detail load (syncfsread +JSON.parseper line) each — in one synchronous tick, on whichever request happened to miss the 60s cache. Measured on a 900-session history: a 3.6s hard stall of the event loop, which also stalls every other API call and the terminal WebSocket data pump. The analytics job already solved this exact problem with chunk+yield (_scheduleAnalyticsRecompute); search never got the same treatment.Chunking per session alone wasn't enough. Real histories have a heavy tail — median session ~0.1MB on my machine, but codex transcripts up to 78MB — and
readLinesslurps the whole file into a string before splitting, so one such session blocks for seconds regardless of the outer chunk size.buildSearchIndexis async, processes sessions in small chunks (8 — each item is far heavier than thecomputeSessionCostcalls analytics batches 80-at-a-time) and yields viasetImmediatebetween them.SEARCH_STREAM_THRESHOLD(4MB) are read line-by-line off a stream that yields every 2000 lines, so one huge transcript can't block either. Nothing is truncated — this only changes when the work happens, not what gets indexed.getSearchIndexis stale-while-revalidate (mirroringgetCostAnalytics): a >60s-old index is still overwhelmingly accurate for search, so it's served instantly while the refresh runs in the background. Only a genuine cold start awaits.if/elsebranches collapsed into aSEARCH_DETAIL_LOADERSlookup table (they differed only by loader name, so a fix to one silently missed the other five); the two JSONL readers now share one per-line parser so they can't drift; the repeated500snippet cap is a named constant.searchFullTextis now async;/api/searchand thecodbash searchCLI command updated accordingly.Measured before/after (same 900-session history)
Test plan
node --test "test/**/*.test.js"— 268 passed, 1 skipped (win32-only); 11 new contract tests intest/search-index-chunking.test.jsorigin/mainacross 6 queries / 496 result rows, via a differential run against agit worktreeof main — the refactor and the streaming path change nothing about what search returns/api/search5.8s → warm 13ms;/api/versionstayed at ~0.16s throughout the cold build instead of hangingcodbash search <query>CLI verified for both hit and no-results paths (it's top-level CJS, so the now-async call runs in an IIFE with an error path)🤖 Generated with Claude Code