Skip to content

Sidecar builds at maintenance: compaction finalizes a file, the index follows (LLP 0265 T6) - #953

Closed
bgmcmullen wants to merge 4 commits into
grep/t4-grep-servicefrom
grep/t6-sidecar-build
Closed

Sidecar builds at maintenance: compaction finalizes a file, the index follows (LLP 0265 T6)#953
bgmcmullen wants to merge 4 commits into
grep/t4-grep-servicefrom
grep/t6-sidecar-build

Conversation

@bgmcmullen

@bgmcmullen bgmcmullen commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What this is

LLP 0265 T6, the wave-4 task, stacked on T4 (#951) like T5 is - retarget to master when that merges. After this PR the indexed tier is real: compaction finalizes a generation's data files and maintenance immediately gives each one a hypgrep .index.parquet beside it, which T4's walk then serves through parquetFind. No user-visible change except speed.

The shape

  • Build at the finalize point. maintainCache follows every committed rewrite of the grep dataset (GREP_DATASET, now exported beside the allowlist so the search service and the build pass cannot disagree) with buildSidecarsForTable over the new generation, in its own maintenance.grep_index span, reported per partition (sidecarsBuilt/sidecarsFailed). The pass can never fail the partition's own verdict: an index that cannot be built costs speed, never the tick.
  • Worker thread, ported from the server. index_worker.js/index_worker_thread.js are the server pair's shape: bytes in, bytes out by transfer, no IO in the thread, only searchable string columns indexed, a zero-byte answer treated as a protocol breach. One behavioral fix over the port: the worker holds an event-loop ref exactly while a build is in flight. The server's always-unref() worker never bit because a daemon's loop always has other handles; in any process whose loop would otherwise drain (every test, a CLI one-shot), awaiting an unref'd worker's reply deadlocks - the loop empties before the message arrives.
  • Existence is the marker; the publish is atomic. Write to .index.parquet.tmp, then rename. A killed daemon leaves nothing that lists as a finished index, and the next pass rebuilds whatever is missing. No ledger.
  • Poison bound. Three failed builds quarantine the file (in-memory, process-lifetime - a restart is the retry, and a persisted poison list would outlive the bug it recorded). A quarantined or unindexed file is served by the scan tier, tested end to end.

Two hazards found and closed

  1. Sidecars would have re-triggered compaction forever. countDataFiles and measureDataDir count *.parquet under data/ - which is where sidecars live. Three data files plus three sidecars reads as six, the LLP 0199 baseline gate sees "grew since compaction", and the partition rewrites itself every tick, generating new sidecars, forever. Both counters now exclude .index.parquet, and a test pins that a second unforced tick stays converged. (The test helpers' own liveDataFiles glob needed the same fix: it was truncating a sidecar to simulate a torn data file, and the rewrite shrugged.)
  2. A corrupt sidecar used to fail the whole search. T4's indexed tier propagated any sidecar read error. It now collects into local buffers and commits only on success, so an unreadable sidecar (torn by anything outside the atomic publish) degrades that one file to the brute scan - logged, no double-counted rows, no failed query.

Tests

test/core/search-sidecar-build.test.js, 7 tests over real caches with the real worker thread: build one sidecar per live file and prove grep serves the indexed tier with identical hits; existence-marker idempotency; the three-attempt quarantine with the scan tier still answering; the corrupt-sidecar fallback; maintenance indexing exactly the grep dataset (a control dataset compacts with no sidecars); sidecars not reading as growth; and a retired generation dying whole with its sidecars inside - the no-GC-code guarantee the plan asked to pin.

npm run typecheck clean; npm test green modulo the two known environment-only failures noted on #951.

🤖 Generated with Claude Code

… follows (LLP 0265 T6)

Compaction is the moment a data file stops changing, so it is the one
point where a hypgrep index can be built once and stay valid against its
rows. maintainCache now follows every committed rewrite of the grep
dataset with a sidecar-build pass over the new generation's files, in a
worker thread (createIndex is seconds of straight-line CPU and the
daemon is single-threaded), one file at a time. The worker handle and
thread are ports of the server's index-worker pair, with one behavioral
fix: the worker holds an event-loop ref exactly while a build is in
flight, because an always-unref'd worker deadlocks any process whose
loop would otherwise drain while awaiting the build.

Sidecar existence is the completion marker, no ledger: the publish is a
write-then-rename, a killed daemon leaves nothing half-claimed, and the
next pass rebuilds whatever is missing. A file whose build keeps failing
is quarantined after three attempts (in-memory, process-lifetime; a
restart is the retry) and the scan tier serves it forever after: index
presence is purely a performance property. The build pass can never fail
the partition's own maintenance verdict.

Two hazards found and closed on the way:

- countDataFiles and measureDataDir counted sidecars (*.parquet in
  data/), which would have made every just-indexed partition read as
  "grew since compaction" and rewrite itself every tick through the LLP
  0199 baseline gate. Both now exclude .index.parquet; a test pins that
  a second unforced tick stays converged.
- A corrupt sidecar used to fail the whole search; the indexed tier now
  runs into local buffers and commits only on success, so an unreadable
  sidecar degrades that one file to the brute scan with no double count.

GREP_DATASET joins the shared searchable-columns module so the search
service and the build pass cannot disagree about which dataset carries
indexes.

Tests: per-file build and existence-marker idempotency, the quarantine
budget with the scan tier still serving, the corrupt-sidecar fallback,
maintenance building indexes for exactly the grep dataset, sidecars not
re-triggering compaction, and a retired generation dying whole with its
sidecars inside (the no-GC-code guarantee). The compaction-effectiveness
tests' liveDataFiles helper learns the same sidecar exclusion the
production counters did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bgmcmullen bgmcmullen added the neutral:adopt Foreign PR adopted into neutral's reconcile scope label Aug 19, 2026
@philcunliffe philcunliffe added the neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) label Aug 20, 2026
…sion-safe publish, and failures that name their file

- Hoist `sidecarPathFor` beside `GREP_DATASET` in searchable_columns.js.
  The build pass and the search service each carried their own copy of
  the `.index.parquet` rule; two copies of a path contract drift into a
  build that writes an index nobody probes for.
- Give the publish scratch file a random token. A fixed `<sidecar>.tmp`
  is only atomic for a single writer: the daemon tick and a hand-run
  `hyp` over the same cache would interleave into one scratch file and
  rename the mixture into place as a finished sidecar. The scratch file
  is now also removed on the failure path.
- Name the data file on `grep_index.build_failed` /
  `grep_index.file_quarantined` / `grep_search.sidecar_unreadable`, and
  add the component/operation attributes, so three warnings can be told
  apart as one poisoned file or three.
- Append rather than spread the indexed tier's buffered hits: `limit`
  reaches the service unvalidated and one file can fill the budget.
- Correct the module docs: the pass runs only behind a committed
  compaction, which always publishes a fresh generation, so it never
  re-attempts a file it skipped or failed on, and a daemon restart is
  not a retry. Add the LLP 0264#lifecycle ref the module realizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor

Verdict: changes requested

The T6 machinery is well built (the worker handle, the ref-while-pending discipline, the transfer-list guard, the manifest-driven file walk, and the retired-generation reclaim all hold up under scrutiny). One finding blocks: the indexed tier silently answers a different question than the scan tier, which is exactly the invariant searchable_columns.js opens by promising. Five smaller findings are fixed and pushed (07cd466).


HIGH (not fixed, needs a design decision)

1. tool_args matches vanish on the indexed tier. src/core/search/index_worker_thread.js:80-104, src/core/search/searchable_columns.js:28-39

searchableStringColumns() keeps only string leaves (converted_type === 'UTF8' or logical_type.type === 'STRING') intersected with SEARCHABLE_COLUMNS. tool_args is declared type: 'JSON' (hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js:84), which icebergTypeForBasicType maps to iceberg variant (src/core/cache/iceberg/schema.js:194). Dumping the written parquet schema confirms what that becomes on disk:

tool_args |type=undefined |conv=undefined |logical={"type":"VARIANT"} |children=2
  metadata |type=BYTE_ARRAY |conv=undefined |children=0
  value    |type=BYTE_ARRAY |conv=undefined |children=0

Not a string leaf, so tool_args is never in textColumns, so the index proposes no block for it and parquetFind's rowFilter never sees the row. The brute scan, by contrast, does test it, via cellText rendering the object (which is the stated reason it is in the set at all, searchable_columns.js:16-19).

Reproduced against this branch with one row whose only occurrence of the pattern is inside tool_args:

SCAN TIER   hits=1 scanned=1 indexed=0
build       { built: 1, present: 0, failed: 0, quarantined: 0 }
INDEX TIER  hits=0 scanned=0 indexed=1

So hyp query grep "/etc/hosts" where the hit lives in a Read tool's arguments answers today and stops answering, silently, for every compacted file once T6 lands, while still answering from the uncompacted tail. That is a recall regression introduced by this PR, and it directly contradicts the module's own opening invariant ("no tier can surface a match another tier cannot").

Not fixed here because every sound repair is an LLP-level change, not a code fix: drop tool_args from the shared allowlist (which LLP 0264#shared has the server importing, and whose inclusion searchable_columns.js argues for explicitly), project it to a real string column at write time, or give the indexed tier a narrow companion read for the columns the index cannot cover. STUCK: which of those three does LLP 0264 want?


MEDIUM (not fixed, design-shaped)

2. Nothing ever rebuilds a missing sidecar. src/core/cache/maintenance.js:259

The pass is gated on report.compacted, and a compaction always publishes a fresh generation directory (generationLayout, maintenance.js:346-388), so every pass sees paths it has never seen before. Consequences: a partition that has converged (grewSinceCompaction === false, or compactionKnownIneffective) never gets indexed at all, which for a source= whose client has been detached is permanent; a pass cut short by daemon shutdown leaves those files on the scan tier until new rows happen to make compaction due again; and the MAX_INDEX_ATTEMPTS poison bound can never fire in the daemon, because isQuarantined(sourcePath) is asked about a path that by construction is new. The bound is ported from a server where archive files persist; that lifecycle does not carry over. The docs claiming otherwise are fixed below, the behaviour is not - deciding whether the pass should also run on a non-compacting tick belongs to LLP 0264#lifecycle.

3. A corrupt sidecar is permanent. src/core/search/sidecar_build.js:105-108

fs.existsSync is the whole presence test, and nothing ever replaces an existing sidecar. A sidecar that is unreadable therefore costs a caught exception plus a full brute scan on every query of that file, forever. The PR's own test pins the degradation; nothing pins recovery. Deleting the sidecar from the read-side failure path, or checking the footer before counting a file present, would close it - both are read-path-writes-cache decisions worth stating rather than assuming.

LOW (not fixed)

4. The build pass sits outside max_tick_ms. src/core/cache/maintenance.js:259-283 - the budget is only checked at the top of the partition loop; the pass then reads every live data file of the compacted generation into memory and indexes each (seconds of CPU per file, by this PR's own header), with no deadline, per-file cap, or signal. A wide generation overruns the configured tick by the whole build before the loop can break.

5. Sidecar bytes are invisible to every disk-accounting surface. src/core/cache/maintenance.js:1514-1520, 1565-1580 - excluding *.index.parquet from countDataFiles/measureDataDir is right for the compaction heuristics, but cacheStatus derives from those same helpers, so nothing counts the index footprint and the bounded-disk-growth acceptance gate would not see it grow. A separate sidecarBytes on CacheStatusPartition (T7 territory) rather than folding it back into the compaction inputs.


Fixed and pushed (07cd466)

  • src/core/search/grep_service.js:213 / sidecar_build.js:78 - duplicated path contract. Both sides carried their own .replace(/\.parquet$/i, '.index.parquet'). Hoisted as sidecarPathFor beside GREP_DATASET in searchable_columns.js, for the reason that comment already gives: two copies mean the build can write an index the service never probes for.
  • sidecar_build.js:121 - the publish scratch file was not collision-safe. ${sidecarPath}.tmp is deterministic per data file, and maintainCache is reachable from both the daemon tick and the CLI. Two overlapping passes interleave into one scratch file and then rename the mixture into place as a finished sidecar. Now carries a randomUUID() token, and is removed on the failure path.
  • sidecar_build.js:130 and grep_service.js:261 - failures did not name their file. grep_index.build_failed, grep_index.file_quarantined, and grep_search.sidecar_unreadable now carry the data file / sidecar path plus the component and operation attributes, so three warnings can be told apart as one poisoned file or three, and the unreadable-sidecar warning names the file an operator has to delete.
  • grep_service.js:255 - hits.push(...found). limit reaches this service unvalidated and one file can fill the whole budget; a spread of that many arguments is an argument-count overflow, not a push. Appended in a loop.
  • sidecar_build.js:22-28, 31-39 - docs corrected to match finding 2. "the next pass simply rebuilds whatever is missing" and "a daemon restart is the retry" were both false under the compaction gate. Also added the @ref LLP 0264#lifecycle the module realizes but did not carry.

npm test (4626 pass, 0 fail) and npm run typecheck green on 07cd466.

@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 20, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor

Stuck: the indexed tier cannot cover tool_args, and every repair is an LLP-level call

What neutral was doing. The review rung on PR #953 (LLP 0265 T6, grep/t6-sidecar-build), reviewed at e5be52a5. Five findings were fixed and pushed; the head is now 07cd4661 and CI is green. The full review record is in the comment above.

Why it cannot proceed. One HIGH finding is a true blocker and no repair is available to neutral without a design decision.

searchable_columns.js states the invariant this change set exists to hold: "no tier can surface a match another tier cannot", shared so that "zero hits" means the same thing locally and remotely (@ref LLP 0264#shared). It puts tool_args in the set deliberately, noting it is JSON and that the scan matcher renders it with cellText first, because "a column in this set that cannot produce a hit is worse than one that is absent from it".

The indexed tier cannot honor that. index_worker_thread.js searchableStringColumns() keeps only childless nodes whose converted type is UTF8 or whose logical type is STRING. tool_args is type: 'JSON', which lands in parquet as a group with logical_type: {type: 'VARIANT'} and two untyped BYTE_ARRAY children, so it is filtered out and never indexed.

Result: on every compacted file, a row whose only occurrence of the pattern lives in tool_args returns hits=1 from the scan tier and hits=0 from the indexed tier. The reviewing worker reproduced exactly that against this branch. It is a silent recall regression: no error, no warning, just fewer results once a file compacts, which is the failure mode the shared allowlist was written to prevent.

What it needs from you. Which repair does LLP 0264 want? Each has a different blast radius, and the choice is a design decision, not a mechanical fix:

  1. Drop tool_args from SEARCHABLE_COLUMNS. Tiers agree again immediately. Costs the ability to grep tool arguments anywhere, contradicts the module's own argument for including it, and the server imports the same set, so this changes remote behaviour too.
  2. Project tool_args to a real string column at write time. Preserves recall on both tiers and indexes cleanly. Touches the write path and the dataset schema, and needs a migration story for already-written files.
  3. Give the indexed tier a narrow companion read for columns the index cannot cover. Preserves recall with no schema change, but reintroduces a partial scan on the indexed path and needs its cost bounded.

Also unresolved on this PR (not blocking, recorded so you see the whole picture): nothing rebuilds a missing sidecar, since the pass is gated on report.compacted and compaction always publishes a fresh generation, so the ported poison bound cannot fire in the daemon; a corrupt sidecar is permanent; the build pass sits outside max_tick_ms; and sidecar bytes are invisible to every disk accounting surface.

How to unstick. Reply with a comment on this PR (or push to the branch); neutral monitors this thread and will re-engage with your guidance on its next tick.

@bgmcmullen

Copy link
Copy Markdown
Contributor Author

Decision: option 1. Drop tool_args from SEARCHABLE_COLUMNS.

Follow-up filed as #977 (restore coverage once hypgrep can index VARIANT). Options 2 and 3 are recorded there and are not being taken now.

Why option 1 and not the others. The gap is not a client regression, it is the client declining to paper over a gap both repos already have. The server's shared row predicate gates on typeof value === 'string' && value !== '' (hypaware-server/src/search/grep-search.js:452, repeated in toHit at :337), and an object-valued cell fails it on every tier, while its index worker filters VARIANT out exactly as ours does. So tool_args has never produced a hit anywhere in the fleet - it is decoded on every brute scan for the cost, named in the grep_search tool blurb (daemon.js:1174), and dead. T3's cellText coercion, written on the premise that "the indexed tier, which reads the column's own text, answers otherwise" (matcher.js:112-119), is what made our scan tier uniquely able to match it, and that premise was false in both repos. Options 2 and 3 would be adding coverage neither repo has ever had, which is a different decision than LLP 0264's "mirror the server tier for tier."

What to change on this branch:

  • Remove 'tool_args' from SEARCHABLE_COLUMNS in src/core/search/searchable_columns.js. SCAN_COLUMNS is derived from it, so the brute scan stops decoding the column too; that is intended, and it makes the scan cheaper.
  • Rewrite the module comment that currently justifies its inclusion ("All but one of these hold STRING... a column in this set that cannot produce a hit is worse than one that is absent from it"). Replace it with the VARIANT reason and a pointer to Restore tool_args grep coverage once hypgrep can index VARIANT columns #977, in the same spirit as server LLP 0157 #identifier-columns: record the loss so it is on the record rather than discovered.
  • Fix matcher.js:112-119. Its stated premise is false today. Keep the cellText coercion itself - it is cheap, it keeps rowTest, test and locate answering identically on any non-string cell, and it is what Restore tool_args grep coverage once hypgrep can index VARIANT columns #977 needs in place - but the comment must stop claiming the indexed tier reads the column's text.
  • Update the tests that pin the set: test/core/search-searchable-columns.test.js:18 (the deepEqual list) and :49 (the SCAN_COLUMNS list), plus anything in test/core/search-exports.test.js that counts on it.
  • Add the assertion this whole finding is about: a row matching only in tool_args returns zero hits from both tiers. The invariant under test is tier agreement, not coverage.

Governance: no new LLP needed here. LLP 0264 never enumerates the columns - it points at the module and defers to server LLP 0157 (see its line 50, "the same set, imported from the same module"), so removing a column contradicts nothing 0264 settled. Server LLP 0157 is Status: Draft and still editable, so it gets amended in place rather than superseded. That amendment and the now-wrong tool blurb are hypaware-server work, tracked separately; this branch does not wait on them.

Not addressed by this comment, and still open on this PR: nothing rebuilds a missing sidecar (the pass is gated on report.compacted, so the ported poison bound cannot fire in the daemon), a corrupt sidecar is permanent, the build pass sits outside max_tick_ms, and sidecar bytes are invisible to disk accounting. Land those separately unless one of them is cheap to fold in here.

Sequencing. This PR lands first, keeping its sidecarPathFor move into searchable_columns.js and the single contract owner. #954 then rebases and takes the two-line import fix, per the answer left on that thread. Also note this branch is behind its own base (grep/t4-grep-service) and needs a rebase regardless of the above.

@philcunliffe philcunliffe removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 20, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor

Re-engaging with your guidance - option 1 taken: drop tool_args from SEARCHABLE_COLUMNS, rewrite the module comment with the VARIANT reason and a pointer to #977, correct the false premise in the matcher.js:112-119 comment while keeping the cellText coercion, update the two column-list tests plus search-exports.test.js, add the both-tiers-zero-hits tier-agreement assertion, and rebase onto grep/t4-grep-service; #953 lands first, the four recorded residuals stay open unless cheap to fold in.

philcunliffe pushed a commit that referenced this pull request Aug 20, 2026
…: nine columns, tool_args moves to the not-searched list

The maintainer's unstick on PR #954 settled that #953 drops tool_args
from SEARCHABLE_COLUMNS. The verb's coverage clause interpolates the
constant so it follows on its own, and the query_grep_roundtrip smoke
never seeds or asserts a tool_args hit, so the SKILL.md enumeration
(both host copies, one shared line) was the only surface stating the
old set. The two deliberate host-only lines are untouched, so the
divergence fixture needs no re-record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lowlist

Merge of the base branch plus the maintainer's decision on #953.

## Conflict: src/core/search/grep_service.js

Both branches independently fixed "a poisoned sidecar fails the whole
query", so the conflict is two spellings of one fix, not two intents.

- t4 (`b590b110`) hoisted a `searchIndexed` helper that collects through
  the shared `collect`/`trimHits` machinery (its `8b694e5b` sort-order
  truncation) and degrades ONLY when the read failed before the first
  row; a later failure propagates, because the rows already pushed to
  the shared buffer could not be taken back.
- t6 buffered the attempt into a local array and committed on success,
  so any failure degrades, and logged `grep_search.sidecar_unreadable`
  naming the file that needs deleting.

Taken: t4's structure with t6's buffering and log folded in. The helper
keeps its name, the 2-argument `isAbort(err, signal)` t4 introduced (t6
still called the 1-argument form, which would have turned a timeout into
a thrown error), the day-descending early break, and sort-order
truncation; it gains the local buffer, so a sidecar that tears mid-read
degrades that one file instead of failing the query, and it gains the
warning. `trimHits` is generalized to `trimBuffer(list)` so the per-file
buffer is trimmed by the same rule as the shared one: buffering must not
trade the walk's memory bound away, and cutting the buffer in walk order
would reintroduce the bug `8b694e5b` fixed.

## Decision on #953: tool_args leaves SEARCHABLE_COLUMNS

`tool_args` is the dataset's one VARIANT column. The index worker only
indexes STRING leaves in the allowlist and the server's row predicate
gates on `typeof value === 'string'`, so the column has never produced a
hit on any tier in either repository: T3's `cellText` coercion made the
client's scan tier uniquely able to match it, on a premise ("the indexed
tier reads the column's own text") that was false on both sides. Rather
than add coverage neither repo has ever had, the column is dropped and
the loss is recorded. Follow-up: #977.

- `SEARCHABLE_COLUMNS` loses `'tool_args'`; `SCAN_COLUMNS` derives from
  it, so the brute scan stops decoding the column too.
- The module comment now gives the VARIANT reason and points at #977,
  in the spirit of server LLP 0157 #identifier-columns.
- `cellText` keeps its coercion (it is what keeps `rowTest`, `test` and
  `locate` answering identically on any cell shape, and #977 needs it in
  place), but its comment stops claiming the indexed tier reads the
  column's text. Its `@ref` gloss is corrected to match.
- `toHit` and `GrepSearchMatcher` lose the same false claim.
- Tests: the pinned allowlist drops the column, a new test pins its
  absence from both the allowlist and the scan projection, and the
  matcher tests exercise the coercion through a column that is actually
  searchable while pinning that `tool_args` no longer matches.
- New in `search-grep-service.test.js`: a row matching only in
  `tool_args` returns zero hits from BOTH tiers, scan and indexed, with
  the tier counters proving each one really served the file. The
  invariant is tier agreement, not coverage.

No LLP change: LLP 0264 never enumerates the columns (it points at this
module and defers to server LLP 0157), so nothing it settled is
contradicted. Amending server LLP 0157 and the now-wrong `grep_search`
tool blurb is hypaware-server work, tracked separately.

`sidecarPathFor` stays the single contract owner in
`searchable_columns.js`; no re-export from `sidecar_build.js` is
restored (#954 takes the import fix on its side).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor

Merged grep/t4-grep-service in and applied @bgmcmullen's option-1 decision in the same push. New head: c00072b1.

Conflict resolution

One conflicted file, src/core/search/grep_service.js, and it was two spellings of one fix rather than two intents: both branches independently closed "a poisoned sidecar fails the whole query".

what the side wanted
base (b590b110) a hoisted searchIndexed helper collecting through the shared collect/trimHits machinery (8b694e5b's sort-order truncation), degrading only when the read failed before the first row; a later failure propagates, because rows already pushed to the shared buffer cannot be taken back
head (t6) the attempt buffered into a local array and committed on success, so any failure degrades, plus a grep_search.sidecar_unreadable warning naming the file that needs deleting

Chosen: the base's structure with the head's two improvements folded in. The helper keeps its name, the day-descending early break, sort-order truncation, and the 2-argument isAbort(err, signal) the base introduced (the head still called the 1-argument form, which after a naive merge would have turned an AbortSignal.timeout deadline into a thrown error instead of the documented partial answer). It gains the local buffer, so a sidecar that tears mid-read degrades that one file rather than failing the query, and it gains the warning.

trimHits is generalized to trimBuffer(list) so the per-file buffer is trimmed by the same rule as the shared one. Buffering must not quietly trade away the walk's memory bound, and cutting the buffer in walk order (which is what the head's break at budget did) would reintroduce the bug 8b694e5b fixed.

src/core/cache/iceberg/store.js and test/core/search-grep-service.test.js auto-merged; no semantic conflict in either.

Guidance items

item landed
Remove 'tool_args' from SEARCHABLE_COLUMNS yes. SCAN_COLUMNS derives from it, so the brute scan stops decoding the column too
Rewrite the module comment yes. The "all but one hold STRING / worse than absent" justification is replaced with the VARIANT reason, the fact that no tier in either repo has ever produced a hit from it, and a pointer to #977, recorded in the spirit of server LLP 0157 #identifier-columns
Fix matcher.js cellText, keeping the coercion yes. The coercion is untouched. Its comment no longer claims the indexed tier reads the column's own text; it now says what the coercion is really for (keeping rowTest, test, locate and makeSnippet answering identically for any cell shape) and what it explicitly does not do. The @ref LLP 0264#shared gloss ("every allowlisted column is really searchable on every tier, including the JSON one") was false too, so it was rewritten rather than left
Update the tests that pin the set yes. search-searchable-columns.test.js:18 drops the column; :49 needed no edit (it is [...SEARCHABLE_COLUMNS, ...], derived, not a second literal), and a new test pins the absence from both the allowlist and the scan projection. search-exports.test.js turned out not to count on the set at all. search-matcher.test.js did: three tests drove the coercion through tool_args rows, which now row-test false; they run through a genuinely searchable column instead and additionally pin that tool_args no longer matches
The both-tiers assertion yes, in search-grep-service.test.js: a row matching only in tool_args returns zero hits from both tiers, with the tier counters proving the scan tier really read the file and the indexed tier really served it, plus a control query proving the row itself is still reachable (so the zero is the column, not a missing row). Framed as tier agreement, not coverage
No new LLP agreed and verified: nothing under llp/ enumerates the grep columns, so LLP 0264 is not contradicted. Server LLP 0157 and the grep_search tool blurb are hypaware-server work, not blocking here
Keep sidecarPathFor in searchable_columns.js, no re-export from sidecar_build.js yes, untouched. #954 takes the import fix on its side

Two extra spots carried the same now-false claim and were corrected for honesty: toHit's doc in grep_service.js ("the JSON column (tool_args) that produced a rowTest match also produces the matched column here") and the GrepSearchMatcher doc in types.d.ts.

Explicitly not touched, per the guidance: nothing rebuilds a missing sidecar; a corrupt sidecar is permanent; the build pass sits outside max_tick_ms; sidecar bytes are invisible to disk accounting.

Local verification

  • npm run typecheck - clean.
  • npm test - 4633 pass, 1 fail, 1 skipped. The single failure is test/core/hyparquet-floor-pin.test.js ("the read path resolves the one root hyparquet, not a nested copy"), the known sandbox-environment failure: hypgrep/node_modules carries hyparquet@1.27.1 and hyparquet-writer@0.16.4, both below the LLP 0222 floor, because this environment's install did not dedupe through the overrides. Unrelated to this change.
  • The five search suites on their own: 58/58 pass, including the new both-tiers test and the merged sidecar-degradation tests.
  • npm run smoke -- query_grep_roundtrip - not runnable on this branch: that flow does not exist yet here, it arrives with T7 (94a22b3a). I checked T7's copy against this change and it does not exercise tool_args, so it is not broken by the removal.
  • Ran the smokes that do exist and touch these paths: core_boot_noop ok, cache_query_debounce ok. local_only_query_withhold FAILs, but it fails identically on an unmodified origin/grep/t4-grep-service checkout (the assertion is on hyp ignore --local-only's output text, nothing to do with grep), so it is pre-existing, not a regression from this push.

… scratch is not data bytes

The merge resolution's per-file buffer degraded one file instead of failing
the query, but it also made a deadline throw away everything the index had
already produced for the file it landed in. hypgrep checks the signal at
every coalesced range boundary, so a deadline lands inside a file, and on a
newest-first walk that is the newest file the caller most wants. Committed
before the abort propagates: safe because an abort ends the walk, so the
file is never rescanned and no row can be counted twice.

grep_search.sidecar_unreadable named only the sidecar, but parquetFind opens
the source data file through the same factory and runs the row filter per
row, so a torn source parquet lands in that catch too and points the
operator at a healthy index. Renamed to grep_search.indexed_read_failed and
both files are named.

measureDataDir excluded `*.index.parquet` but not the build's publish
scratch, `<file>.index.parquet.<uuid>.tmp`, which survives a kill between
write and rename with no reaper until the generation retires. countDataFiles
already skips it, so counting its bytes broke the shared-file-set invariant
in the dangerous direction: needsCompaction compacts on a LOW average, so a
large orphan makes a fragmented partition read as healthy. Test pins it.

Also recorded, not fixed: a sidecar freezes the allowlist it was built over
(hypgrep stores hypgrep.text_columns in the index and prunes to it, and
nothing compares that stamp to today's SEARCHABLE_COLUMNS), so #977 has to
invalidate existing sidecars rather than only build new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor

Round 2 verdict: approve once CI is green. Four findings, all fixed and pushed as bf79490e.

Reviewed c00072b1 against grep/t4-grep-service. @bgmcmullen's option-1 decision landed in full; the conflict resolution in grep_service.js is correct in structure but lost one behaviour from the base side, which is finding 1 below. No new blocker, so the PR is unstuck.


Guidance verification (against the committed tree, not the applying worker's prose)

item verified
'tool_args' removed from SEARCHABLE_COLUMNS yes, searchable_columns.js:36-46. SCAN_COLUMNS is [...SEARCHABLE_COLUMNS, ...] at :128, so the brute scan stops decoding it, as intended
module comment rewritten with the VARIANT reason and a #977 pointer yes, searchable_columns.js:16-27. Gives the iceberg-variant reason, states that neither repo's tier has ever produced a hit from it, keeps the "worse than absent" argument but pointed the other way, and names #977
matcher.js:112-119 premise corrected, coercion KEPT yes. cellText's body is byte-identical; only the doc changed (matcher.js:111-131). It no longer claims the indexed tier reads the column's text, and the false @ref LLP 0264#shared gloss was rewritten rather than left
tests that pin the set updated yes. search-searchable-columns.test.js:18 drops the column; :39 is a new both-lists absence assertion. :49 correctly needed no edit (derived, not a second literal). search-exports.test.js genuinely does not pin the set. search-matcher.test.js did: three tests were rerouted through content_text and now additionally pin that tool_args does not match
both-tiers-zero-hits assertion yes, search-grep-service.test.js:241. Asserts zero hits on the scan tier with scannedFiles >= 1, zero on the indexed tier with indexedFiles >= 1, plus a control query proving the row is still reachable. Framed as tier agreement, as asked
no new LLP correct. Nothing under llp/ enumerates the columns; LLP 0264 #shared points at the module. Every @ref anchor in the diff (0264#shared, 0264#lifecycle, 0265#out-of-scope) resolves
base merged in yes. git merge-base --is-ancestor origin/grep/t4-grep-service HEAD passes; c00072b1 is a real merge of 07cd4661 and b590b110
sidecarPathFor stays in searchable_columns.js yes, :70, single owner, no re-export from sidecar_build.js

The conflict resolution was checked against both parents. git diff b590b110 c00072b1 shows nothing from the base side dropped, and git diff 07cd4661 c00072b1 touches no T6 file. The searchIndexed helper keeps the base's name, the day-descending early break (grep_service.js:388), sort-order truncation through the generalized trimBuffer, and the 2-argument isAbort(err, signal) at all three call sites. The head's per-file local buffer and its warning are folded in.


Findings

1. MEDIUM (fixed). An aborted indexed read threw away the file's already-matched rows. src/core/search/grep_service.js:310 (at c00072b1)

The pre-merge base collected into the shared hits as it went, so an abort kept what it had. The merged version buffers into found and commits at :322-327, after the loop, but the catch rethrows on isAbort before reaching it, so found and withheldHere are both discarded.

hypgrep calls signal?.throwIfAborted() at every coalesced range boundary, so a deadline lands inside a file, not between files, and that is the normal case, not the rare one. On a newest-first walk the interrupted file is the newest and most valuable one, so an AbortSignal.timeout query answered exhausted: false with zero hits from exactly the file the caller cared most about, and that file's withheld-row count vanished from the usage_policy.query_withhold report. That contradicts what 8b694e5b established on the base: an abort returns the partial answer.

Committing before the rethrow is safe, and the reason is specific: an abort ends the walk (grep_service.js:392-397 breaks out), so unlike a degrade there is no rescan to double-count against. The file still does not count toward indexedFiles, matching the base's counter semantics, since it was not served whole.

Not covered by a new test: reaching this path deterministically requires an abort between hypgrep's internal range checkpoints, which the service cannot inject without pinning hypgrep's read chunking. The invariant is stated in the code instead.

2. MEDIUM (recorded, not fixed - needs the same decision the deferred residuals need). A sidecar freezes the allowlist it was built over. src/core/search/index_worker_thread.js:101, src/core/search/grep_service.js:283

searchableStringColumns() intersects the file's string columns with SEARCHABLE_COLUMNS at build time. hypgrep stores that list in the index (hypgrep.text_columns, createIndex.js:245) and parquetFind prunes candidate blocks to it (parquetFind.js:36-45). The index carries a hypgrep format version that queryIndex hard-fails on, but no allowlist stamp, and the service never compares the returned textColumns against today's constant.

Combined with the intended no-rebuild lifecycle, the first release that adds a column to the allowlist makes every already-indexed file answer that column with zero while uncompacted files answer correctly: silent, and permanent for the life of the generation. This is not the tool_args coverage decision, which is settled; it is that the mechanism cannot notice when the allowlist moves. #977 is exactly this scenario and needs to invalidate existing sidecars, not merely start building new ones.

Not raised as a blocker because it is the same shape as the residuals you deferred (nothing rebuilds a sidecar) and needs the same call. Recorded in sidecar_build.js:36-47 in the record-the-loss spirit you asked for on the column, so it is on the record rather than rediscovered when #977 lands.

3. LOW (fixed). The build's publish scratch was counted as data bytes. src/core/cache/maintenance.js:1573 (at c00072b1)

measureDataDir's new filter excluded *.index.parquet but not <file>.index.parquet.<uuid>.tmp, which sidecar_build.js:124 writes into the same data/ dir. countDataFiles already skips it (no .parquet suffix), so the new comment's own "the two must see the same file set" invariant was broken, and broken in the dangerous direction: needsCompaction compacts when the average is low (maintenance.js:688), so an inflated byte total makes a genuinely fragmented partition read as healthy and go unrewritten. The rm at sidecar_build.js:133 only covers a thrown build; a SIGKILL or OOM between write and rename leaves an orphan with no reaper until the generation retires.

Fixed by testing includes('.index.parquet') rather than endsWith, which covers both halves of the pair. Pinned by a new test (search-sidecar-build.test.js:171) that writes an orphaned scratch and asserts a due compaction still runs; the test was confirmed to fail against the old endsWith predicate.

4. LOW (fixed). grep_search.sidecar_unreadable named the wrong file. src/core/search/grep_service.js:311 (at c00072b1)

parquetFind opens the source data file through the same asyncBufferFactory and runs rowFilter: accept per row, so a torn source parquet, or a throw out of the matcher, lands in that catch and was logged with sidecar_file naming a perfectly healthy index. The comment above the field claimed the line was the only notice that a sidecar needs deleting, which made the misattribution actionable in the wrong direction: in the corrupt-source case the fallback then reads the same file, throws, and fails the whole query anyway, after pointing the operator at the wrong path.

Renamed to grep_search.indexed_read_failed (the event was introduced by 07cd4661 in this same stack and nothing else references it) and it now carries both sidecar_file and data_file.

Also folded in: the append-not-spread comment at :324 claimed limit "reaches this service unvalidated", which the base merge falsified - 8b694e5b added the Number.isSafeInteger(limit) && limit > 0 gate at :113. The conclusion still holds (validated is not bounded, so one file can fill a buffer of millions), so only the premise was corrected.


Checked and found sound

Worker respawn and failAll map ownership across a crash; ref-while-pending so an awaited build completes and an idle worker never holds a shutting-down daemon; transferable()'s pooled-Buffer guard; ByteWriter.getBuffer() returning a real ArrayBuffer, so the transfer list is valid; searchableStringColumns being functionally identical to hypgrep's own getTextColumnsFromSchema; listLiveDataFiles being manifest-driven, so a sidecar can never be mistaken for a data file; the liveDir the build targets matching resolveIcebergDir for both layouts; report.compacted being set only after the cursor commit, so readCursorSync really names the new generation; and both retention eviction paths being whole-directory rm -r, so sidecars die with their partition (LLP 0264 #lifecycle asks for a test pinning that too - only the retired-generation half is pinned, search-sidecar-build.test.js:212; the retention half is correct by construction).

Dismissed after checking: index_worker_thread.js's static import { ByteWriter } from 'hyparquet-writer' is not an undeclared dependency - it is a declared optionalDependency, and src/core/cache/iceberg/stream_append.js:4 already statically imports the same package on the core cache write path, which is what LLP 0264 #dependency means by "exactly as the cache write path does". src/ ships whole, so the worker module is in the published file set.

Still open, per your decision

Nothing rebuilds a missing sidecar; a corrupt sidecar is permanent; the build pass sits outside max_tick_ms; sidecar bytes are invisible to disk accounting. Not re-raised, not re-stuck. Finding 2 above joins that family.

Local verification on bf79490e

npm run typecheck clean. npm test: 4634 pass, 1 fail, 1 skipped - the fail is test/core/hyparquet-floor-pin.test.js, the known sandbox dedupe failure, which reproduces identically on unmodified origin/grep/t4-grep-service. The six search suites on their own: 59/59. Smokes core_boot_noop, cache_query_debounce, gateway_claude_capture, local_parquet_export, status_diagnostics, cli_bundled_plugins_activated, hypignore_capture_drop, otel_loopback_capture, claude_telemetry_capture all ok. local_only_query_withhold FAILs on its hyp ignore --local-only output assertion, and was confirmed to fail identically in a clean origin/grep/t4-grep-service worktree, so it is pre-existing and unrelated. CI on the PR is the authority.

@philcunliffe

Copy link
Copy Markdown
Contributor

Triage verdict at bf79490e: no blocker remains, the PR can merge safely

Review-round budget (2 rounds) is exhausted, so this is the triage rung's judgement on everything still open, checked against the committed tree at this head, not the review prose.

Resolved. The round-1 HIGH blocker (tool_args vanishing on the indexed tier) was settled by @bgmcmullen's option-1 decision and verified landed in round 2. Round-2 findings 1, 3, and 4 (abort discarding the buffered rows, the publish scratch counted as data bytes, the misattributed unreadable-sidecar warning) are confirmed fixed in the tree at bf79490e (grep_service.js commits the buffer before an abort rethrows; measureDataDir filters with includes('.index.parquet'); the event is grep_search.indexed_read_failed and names both files).

Deferred, non-blocking. Five residuals stay open, all enumerated with evidence in #980:

  1. Nothing rebuilds a missing sidecar (pass gated on report.compacted, maintenance.js:259): costs speed only, the scan tier serves unindexed files exactly. Explicitly deferred on-thread.
  2. A corrupt sidecar is permanent (existsSync is the whole presence test): degrades that one file to the brute scan, correctness intact. Explicitly deferred on-thread.
  3. The build pass sits outside max_tick_ms: background-daemon latency, bounded per compaction event. Explicitly deferred on-thread.
  4. Sidecar bytes invisible to disk accounting: observability gap, growth bounded by the generation lifecycle (sidecars die with their directory). Explicitly deferred on-thread; sidecarBytes is T7 territory.
  5. Round-2 finding 2, a sidecar freezes the allowlist it was built over: non-blocking today because the shipped allowlist is fully indexable, so both tiers agree as merged; it goes live only when the set grows, which is exactly the Restore tool_args grep coverage once hypgrep can index VARIANT columns #977 scenario, and it is recorded in the sidecar_build.js module doc.

None of these can produce a production defect in the tree as merged: every gap costs speed, index coverage of the optimization, or observability, never a wrong answer, data loss, or a crash. Findings 1, 2, and 5 share one design surface (the rebuild/invalidation lifecycle) and #980 notes they are best solved together.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 21, 2026
bgmcmullen pushed a commit that referenced this pull request Aug 21, 2026
…: nine columns, tool_args moves to the not-searched list

The maintainer's unstick on PR #954 settled that #953 drops tool_args
from SEARCHABLE_COLUMNS. The verb's coverage clause interpolates the
constant so it follows on its own, and the query_grep_roundtrip smoke
never seeds or asserts a tool_args hit, so the SKILL.md enumeration
(both host copies, one shared line) was the only surface stating the
old set. The two deliberate host-only lines are untouched, so the
divergence fixture needs no re-record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bgmcmullen added a commit that referenced this pull request Aug 21, 2026
The T7 review commit that stopped restating the sidecar name imported
sidecarPathFor from sidecar_build.js, which is where it lived when T7
was branched. #953's own review round moved it beside GREP_DATASET in
searchable_columns.js, so re-stacking T7 on the current T6 tip left the
import naming an export that is no longer there: a typecheck error and
every maintenance test red. Point it at the module that exports it now.

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

Copy link
Copy Markdown
Contributor Author

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 grep_service.js and search-grep-service.test.js.

Integrating on current master also surfaced two problems invisible here: a sidecarPathFor import left dangling when #953's review moved the function, and master's new D1 short-flag gate (LLP 0293), which query grep trips as the second verb with a greedy positional. Both are fixed in #984.

The branch is retained, so this can be reopened if needed.

This PR's task: T6, sidecar builds at maintenance.

@bgmcmullen bgmcmullen closed this Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants