The local grep service: one newest-first file walk, sidecar-or-scan per file (LLP 0265 T4) - #951
The local grep service: one newest-first file walk, sidecar-or-scan per file (LLP 0265 T4)#951bgmcmullen wants to merge 3 commits into
Conversation
…er file (LLP 0265 T4) src/core/search/grep_service.js is the client half of LLP 0264: the same two-tier search the server runs, folded onto the client's single cache. executeGrepSearch flushes the dataset's pending spool first (the query seam's own freshness move, now exported from sql.js), walks every live data file newest message-day first, and serves each file through its hypgrep sidecar (parquetFind, index proposes, shared matcher confirms) when one exists beside it, by brute scan under the narrow SCAN_COLUMNS projection when not. Budget, truncated/exhausted, hit shape and sort order mirror the server byte for byte through the shared core/search modules. Two row gates the server does not need: - Purge: a raw file read does not apply Iceberg position deletes, so the walk carries each file's committed delete positions (new listLiveDataFiles export on the iceberg store) and filters both tiers by them; a stale sidecar cannot resurrect a purged row (LLP 0104). - Visibility: every surfaced row passes the LLP 0105 lattice check via cwdWithheldFromCaller, hoisted out of withLocalOnlyVisibility so the SQL path and this walk share one predicate instead of two copies. The check runs after the match, so withheldRows counts hits the caller was not allowed to see and costs no result budget. The shared types gain GrepSearchParams, the wire shape every serving surface accepts. Tests cover both tiers end to end on a real Iceberg cache: locators and snippets, newest-first truncation, from/to and session/chain scoping, the JSON tool_args column matching through cellText, local-only withholding at every caller rank, purge on both tiers, the sub-ngram literal query answering exactly through an index, and spool-captured rows surfacing after the service's own flush. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…failing open Review fixes on the T4 grep service (LLP 0265): - The limit returned the OLDEST matches. `hits.length = limit` ran before `sortHits`, so the cut fell in walk order; rows inside one data file run oldest to newest, so `--limit 3` over today's session answered the first three matches, not the newest three. Hits are now trimmed in sort order (amortized, so the buffer stays bounded), and the file walk stops only once every file still ahead is strictly older than the oldest kept hit. - `AbortSignal.timeout` threw out of the service. Its reason is a DOMException named `TimeoutError`, which the `AbortError` name check rejected, so the documented "partial answer, marked not exhausted" became an error for the deadline shape the signal exists to carry. - `chainId` without `sessionId` was silently discarded, answering across every session instead of the chain's. - `limit` is validated beside the query: an absent one made the budget NaN and walked the whole cache, a negative one threw a bare RangeError. - `listLiveDataFiles` swallowed a metadata load failure and answered `[]`, so a corrupt table made grep report zero hits where `hyp query sql` raises. It now propagates, matching `dataSourceForTable`. - `sortHits`' tiebreak returns 0 for equal keys, now that the buffer is sorted repeatedly. Five regression tests, each verified to fail on the pre-fix tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
neutral review: changes requested (fixes pushed)Reviewed Fixed1. HIGH - 2. MEDIUM - 3. MEDIUM - 4. MEDIUM - 5. LOW - 6. LOW (follow-on) - Five regression tests were added to Not fixed: needs a decision before T6HIGH - This breaks LLP 0264 #lifecycle's stated invariant ("index state is never a correctness input") and I did not push a fix because the three available resolutions have materially different performance contracts and the choice belongs in the LLP, not in a reviewer's patch:
LOW (noted, not fixed) - |
The sidecar existence probe only ruled out a missing index. A sidecar that exists but cannot be read (a half-written index from a killed build, a truncation from a full disk, a format the installed hypgrep refuses) throws from inside parquetFind, where the footer is parsed and the version checked, and nothing caught it: one poisoned sidecar failed every grep over the whole cache, including the partitions the walk never reached. That makes index state a correctness input, which LLP 0264 #lifecycle says it never is. The indexed read now degrades to the scan tier when the index proves unusable before it produced a row, and the reader catch no longer special-cases ENOENT: any unreadable sidecar is an unindexed file. A failure after the first row still propagates, because retrying it as a scan would count the collected hits twice. Also give the file-walk comparator a 0 for equal days, like sortHits: one day is many files and the early break reads the walk as strictly day-descending, so same-day order should come from the comparator rather than from whatever the engine's sort happens to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review: PR #951, the local grep service (LLP 0265 T4)Verdict: request changes. The tier structure, purge gate, visibility gate, sort-order truncation and abort handling are all correct and well argued. Two defects in how the indexed tier is entered are fixed and pushed. One HIGH finding is real, reproduced, and needs a human decision (it cannot be fixed inside T4 without killing the indexed tier outright). Three perf/robustness findings are deferred with reasons. Baseline: HIGH, DEFERRED, needs a decision: the indexed tier silently loses every
|
neutral triage: residual findings deferred, PR clear to mergeBoth review rounds are exhausted with findings still open, so triage classified each residual at head All nine fixed findings from the two review rounds were verified present at this head (sort-order truncation via Four findings remain open. None blocks this merge: The residuals are enumerated with |
|
Superseded by #984, which carries this work (and every review fix from this PR) on a single branch cut from current master. The stack could not be merged in sequence: this repo squash-merges, so merging one PR put a commit on master sharing no history with the branches behind it, and each remaining PR then re-proposed its parent's changes. Verified by simulation: merging #951 and then #953 produced add/add conflicts in Integrating on current master also surfaced two problems invisible here: a The branch is retained, so this can be reopened if needed. This PR's task: T4, the local grep service. |
What this is
LLP 0265 T4, the wave-2 task: the local grep-search service, built on T1 (hypgrep, #877) and T3 (the shared
core/searchmodules, #876). After this, the enginehyp query grepneeds exists and is fully tested; T5 (the verb) is what makes it user-visible.The shape
executeGrepSearchinsrc/core/search/grep_service.jsmirrors the server'ssrc/search/grep-search.jstier for tier, folded onto the client's single cache:settlePendingCacheForQuery, now exported fromsql.jsso the two read surfaces share one flush policy. The settle list includes the gateway's label spool table (proxy_messages_v5), which partition discovery cannot see before its first flush - without that, a row captured seconds ago would be unsearchable..index.parquetbeside it is served throughparquetFind(the index proposes candidate blocks, the shared matcher confirms); a file without one is brute-scanned under the narrowSCAN_COLUMNSprojection. Sidecar existence is the marker, no ledger (LLP 0264 #lifecycle). No sidecar exists anywhere until T6 lands, so today every file takes the scan tier: slower, never wrong.truncated/exhausted, sort newest-first with the same tiebreaks, project hits through the shared allowlist socontent_textleads the snippets.Unlike the server there is no cross-tier day-exclusion rule: a client row lives in exactly one file and each file is served by exactly one tier, so double-counting is impossible by construction.
Two row gates the server does not need
listLiveDataFilesexport on the iceberg store, reusing the purge path's own delete-map loader) and filters both tiers by row position -parquetFindyields__index__for exactly this. A stale sidecar built before a purge cannot resurrect the purged row.cwdWithheldFromCaller, hoisted out ofwithLocalOnlyVisibilityso the SQL path and this walk share one predicate instead of two drifting copies. The check runs after the match predicate, solocalOnly.withheldRowscounts hits the caller was not allowed to see (the number T5 will render as guidance) and a withheld row consumes no result budget.One deliberate deviation from the plan's sketch
The plan sketched "per-partition source from
storage.dataSourceForTablewrapped inwithLocalOnlyVisibility". A table-levelAsyncDataSourcecannot deliver two things the same sentence also requires: the newest-day-first walk (a table scan yields rows in file order, so a truncated answer would keep the oldest matches) and per-file tiering (an indexed file must be excluded from the table scan or its rows are counted twice). So the service walks files directly and carries the two guarantees the wrapper would have provided - position deletes and the visibility lattice - explicitly, each through the same shared machinery the wrapped path uses (loadDeletedPositions,cwdWithheldFromCaller). What the plan's verification note was really pinning (purge-correct, visibility-correct rows, no reimplemented policy) still holds, and the tests prove it row for row.The test that proves it
test/core/search-grep-service.test.js, 14 tests over a real Iceberg cache built with the gateway's own partition declaration:exhaustedsemanticsfrom/topruning at the file walk, session/chain scoping, case-insensitive literal and regex modestool_argscolumn producing a hit throughcellText(the drift T3's shared matcher exists to prevent)full, visible to an equal-rank caller, fail-closed with no derivable cwd, surfaced by the overridedle) still answering exactly through the index (the T7 "literal cliff" is performance, not truth)Verification
node --test test/core/search-grep-service.test.js: 14/14.npm run typecheck: clean.npm test: green except two failures that reproduce identically on a cleanorigin/masterworktree on this machine and are unrelated:command-dispatchhelp-text (agraphcommand registers locally but not in CI) and the LLP duplicate-number lint tripping on an untracked local draft (llp/0266-local-inference-capture-lane.decision.md, not part of any commit).🤖 Generated with Claude Code