Skip to content

invertedstore: land the segment-based index component (standalone, unwired) - #106

Merged
codetrek merged 69 commits into
mainfrom
feat/invertedstore
Jul 7, 2026
Merged

invertedstore: land the segment-based index component (standalone, unwired)#106
codetrek merged 69 commits into
mainfrom
feat/invertedstore

Conversation

@codetrek

@codetrek codetrek commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Lands the segment-based invertedstore (a future, LSM-like alternative to the pebble-backed invertedindex) as a standalone, deliberately unwired component. Production is unchanged — it keeps running the pebble invertedindex, exactly as main.

Why unwired (not integrated)

invertedstore is not yet proven to replace invertedindex at scale (hot-keyword merge/search OOM, no max-seg cap, single-JSON MANIFEST — see core/invertedstore/README.md "Known scale gaps"). There is no reason to modify a mature, externally-depended-upon package (core/invertedindex is published) for an unproven replacement. So this branch adds invertedstore as a component and reverts every integration change outside it; integration is deferred until invertedstore is stable.

Scope (verified: git diff main HEAD touches ONLY these)

  • core/invertedstore/** — the full component + tests + the ingestion-perf work (F v5 off-worker spill, C.2–4, H compact head, the C.4 clear()-retains-capacity footgun fix that 6.5×'d the build) + crash-recovery (reconcile.go). Decoupled from any invertedindex interface (own *Batch; SearchResult reuses the existing invertedindex.SearchResult struct).
  • core/invertedstore/README.md + docs/design/invertedstore-*.md — measurements, findings, roadmap.
  • AGENTS.md — the SDD working-principles update.

Everything else is byte-identical to main: core/invertedindex untouched (no Indexer, no adapter); documents/engine/symbols/server keep concrete pebble *invertedindex.Index.

Verification

Both modules green: go build/vet/test ./... + -race on invertedstore/invertedindex. (Only the untracked core/cmd/idxbench dev harness fails to build — intentionally out of scope.)

Squash-merge (the branch carries a Merge main commit to converge out-of-scope files to main's HEAD; the squashed diff is the scope above).

Generated with Claude Code

oc-engteam and others added 30 commits June 24, 2026 14:53
…o prod

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3-round reviewed; replaces pebble-backed core/invertedindex with a segment-based
store (write-once sorted runs + tiered merge + segment-local term-id forward map).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
int64 docid, 4-byte BE tableId, invertedValue (addsByteLen+adds+dels), and the
nKw-prefixed forward value (tombstone=nKw 0; fixes the spike's ordinal-0 aliasing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SSTable blocks (inline small / external large), ordinal-ordered term-dict region,
25-byte footer with both data+dict codec ids, scanPrefix, ord->string resolve.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Atomic replace (tmp+fsync+rename+dir-fsync); missing MANIFEST -> fresh empty;
no recovery watermark (recovery is indexer-driven). JSON, FormatVersion-led.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open/Close, CreateTable/DeleteTable (sync via queue.RunFunc, persisted in MANIFEST);
per-table head (latest action per (kw,docid) + in-memory docid dedup); spill at
CapBytes writes an L0 segment via segWriter and installs a new MANIFEST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge, concurrency, differential

Completes the self-contained store (design T3-T6, T8, T11):
- P5 Store-level chunk-LRU + newest-wins forwardKeywords (ord->string resolve)
- P6 Search (prefix newest-wins union) + GetDocs (exact, no prefix leak)
- P7 Update/Batch (async; term-id full re-post + per-keyword tombstones; delete)
- P8 tiered merger: per-(kw,docid) newest-wins reconciliation (fixes add->del->add),
  ord->ord remap + term-dict rebuild, covering-merge reclamation
- P9 concurrency: atomic.Pointer snapshot, head RWMutex, refcount-deferred deletion
- P12 differential vs invertedindex: identical hit sets, table isolation, recovery

75 tests pass, -race clean, go vet clean. Built via subagent-driven-development
(impl + 3 cross-reviewers + fix per phase).

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

#105 already gave invertedindex its own forward map + 3-arg Update and dropped
documents/symbols doc-words. This wires the live path to core/invertedstore behind a
new invertedindex.Indexer interface (both *invertedindex.Index via IndexerAdapter and
*invertedstore.Store satisfy it; shared SearchResult):
- documents.Store / symbols / engine / searcher / server retyped to invertedindex.Indexer
- server constructs invertedstore.Open(<ver>/invertedstore, mpsc, Options{AutoMerge:true})
- StorageVersion 1.5 -> 1.6 + 1.5 cleanup + reindex-on-upgrade
- indexer-driven crash recovery (invertedstore reconcile / ForwardDocids)
- re-apply the two shared-mpsc deadlock fixes (documents.Delete, symbols.AddFunctions
  must not call the async Update/table-ops from inside a worker task) + no-deadlock guards

Whole workspace green: go build/vet/test ./... pass in both modules. Built via
subagent-driven-development (scout + impl + 3 cross-reviewers, 0 hard issues, gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…wed) + task breakdown

bottomDeadFraction is 73% of build CPU on a cold linux build (full-decompression scan
after every spill, structurally 0 on a clean build). Replace with O(#segments) metadata
deadFraction: per-segment segMeta.Postings + per-table liveByTable (recomputed on Open,
catalog-gated, incremental in applyBatch). Synchronous orphan reclaim on Open closes the
DeleteTable-window-crash byte leak. Spec + task breakdown each cross-reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unt (deadFraction 'written')

Count adds+dels at spill (head.go) and at the merge keep-path (merge.go, so a covering
merge's dropped keys contribute 0 and the output's Postings == its live adds). Bump
FormatVersion 1->2 (greenfield). Cross-checked by decoding the segment, not a constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Ords; ForwardDocids wraps it

The segment newest-wins forward resolution is factored out so the Open live-recompute reuses
the exact same tombstone/newest-wins/per-table semantics (no parallel scan that could drift
from Search), surfacing each live docid's ords (ForwardDocids discards them). Behavior of
ForwardDocids unchanged — all 7 reconcile tests stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…inct, DeleteTable-aware)

Maintained in-lock in applyBatch (delta = new distinct − old distinct, dedup(old) since the
forward stores raw keywords); DeleteTable drops the partition in O(1). Plain arithmetic
counter (missing key = 0, no CreateTable seed). Tests drive the real Update+sync path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ds (catalog-gated)

Open rebuilds liveByTable from the segments' [F] records via the shared forEachLiveSegmentForward
(distinct ords, newest-wins, only catalog tables) after publishSnapshotLocked — the segment-anchored
authority for the live counter, so crash recovery cannot inflate it.

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

The core swap: deadFraction = 1 - Σ(catalog liveByTable) / Σ(segMeta.Postings), clamped — a
metadata sum, no decompression. Deletes the bottomDeadFraction streaming scan that cost 73% of
build CPU and was structurally 0 on a clean build. Running sum catalog-gated to match the Open
recompute. coveringMergeHook counts covering merges (both triggered + forced paths). Tests:
cold-build-never-fires regression guard, trigger-fires-on-deletes, covering-preserves-live,
tiered/spill invariance, + the §5 live-written≤headCap invariant. (Spec §8.1 0.33→0.667 fix.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leteTable-window crash)

A crash after DeleteTable's MANIFEST write but before its scheduled covering merge installs leaves
the dropped table's segments on disk. Open now detects a non-empty segment covering a non-catalog
table and runs ONE covering merge via q.RunFunc — AutoMerge-INDEPENDENT (the default is off, where
triggerMerge no-ops and the bytes would leak). Catalog-gated recompute already keeps it out of
liveByTable. Empty segments skipped (MinTable=0 would false-positive). Tested AutoMerge-off.

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

§8.5(a) head-only loss + over-replay, §8.5(b) partially-durable + over-replay: live equals the
true distinct count (durable docs' re-Update nets Δ0 via segment forward read) — the round-2
BLOCKER guard. §8.7 add->del->add in ONE batch pins the in-batch old-selection path. Threshold
0.25 needs no recalibration (cold=0 never fires; 60% deletes fires; verified in trigger_test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 95s cold build is GC-churn + single-worker serialization (merge 34 + spill 28 + addPosting
19 + forward 8, all on one mpsc worker). A: merge COMPUTE off-worker, install on-worker (single
mutator preserved — review killed the two-writer/four-MANIFEST-writer hazard). B: forward
docid-range skip. C: per-op alloc churn (memory play, ~0-3s wall). D: keep zstd. E: backpressure
by postings. Honest target A+B+C+E = pebble parity (~55-65s); F (spill-encode off-worker) needed
to beat it. Numbers review-calibrated, re-measured per change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…→multi-review→breakdown→multi-review→TDD/workflow

Codifies the SDD flow as the top working principle: no code edits (not even a prototype, spike,
or 'just measure it') before a written spec is multi-agent-reviewed, task-broken-down, and that
breakdown cross-reviewed. Per the user's repeated direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+head-fix+A+B+C+E+G+F)

3-round multi-agent review. Key outcomes: (F0) inline term dict kills the writeTermDict re-read,
free -9s zero-concurrency — missed by v1; (head-fix) lazy dels map -5-8s, addPosting floor is
~12-14s not 19; (A) merge COMPUTE off-worker (single mutator preserved), add input refcounts;
(F) spill encode off-worker HARDENED against the silent-corruption BLOCKER (forwardKeywords must
consult the spilling tier — worker's own diff), atomic detach/install lock sections, NextSegId at
detach, copy-under-lock head lifetime, spilling docid-skip so F doesn't undo B, maxInflightSpills
bound; (G) Open sweeps orphan seg files (the 'GC'd on Open' comment was false). Goal: best
achievable (~25-32s), measured per change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bite-sized TDD tasks implementing spec v4, sequenced F0 -> head-fix -> B -> A
-> C/E -> G -> F (free single-threaded wins first, F last + hardened). Each item
independently measured on real ext4 + committed. Flags a deadlock in the spec's
F bound (worker-blocks-detach vs worker-side install) and encodes the
deadlock-safe synchronous-fallback correction for the cross-review.

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

Resolves all BLOCKERs/MAJORs from the multi-agent cross-review:
- define searchDocidsForTest (was used by the B1 gate but never existed)
- F0: genuine red via a block-reread counter (was a fake/half-applied red)
- F dispatchSpill: reserve slot before detach, no worker-side channel send
  (the prior fix reintroduced the deadlock on spillCh) + spillSem/spillWG
- idxbench commands: add required -tokens/-data flags
- sequence E before A/F (queue-saturation vs RunFunc installs)
- covering-hook fires post-install (counts completed merges); liveTables
  staleness window documented + test required
- Task 4: new off-worker race test + waitMergeIdle convergence test
- Task 3: name the onForwardRead tests (stay green, do not relax)
- Task 7A: concrete Search tier snippet; Task 7D crash-stub teardown
- R1 resolutions section records every finding + fix

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

A fix is unverified until a FRESH review round confirms it (a deadlock fix
routinely reintroduces a deadlock elsewhere). Iterate review rounds until a full
round returns zero Blocking/Major; applied-but-not-re-reviewed is not done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R2 (3 reviewers) confirmed R1 BLOCKER fixes compile/correct but found the
deadlock fix migrated the cycle into CloseAndWait, plus new gaps:
- CloseAndWait deadlock: spillWG.Wait() must run off-worker (BLOCKER)
- off-worker install-failure stranded the spilling entry forever -> retry +
  hold-slot-on-giveup (BLOCKER)
- installSpill publish-then-remove ordering pinned (MAJOR)
- delete maybeMerge/maybeCoveringMerge (dead after A -> cov-gate break) (MAJOR)
- C.1 fast-path-taken hook+test; search-regression/disk measurement step (MAJOR)
- forwardKeywords recursive-RLock caution; 7C onSpillingProbe (was a
  non-existent hook); concrete ForwardDocids tier code; drop dead accessor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R3 (2 reviewers) confirmed all R2 concurrency fixes correct; fixed 2 MAJORs in
the R2 deltas:
- C.1 applyOneOp signature said (over bool, err error) but call sites need
  error-only; 'lines 122-174 verbatim' wrongly included inBatch/seen loop
  bookkeeping -> corrected to applyOneOp(...) error, bookkeeping stays in loop
- give-up durability claim was false (CloseAndWait flushes s.head not
  s.spilling) -> reclassified as crash-equivalent volatile loss
- MaxInflightSpills/MaxInstallRetries concrete withDefaults (zero retries would
  no-op the loop -> instant strand); installBackoff const + time import pinned

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R4 (2 independent reviewers) both verdict 'zero Blocking/Major, ready to
implement'. The review loop converged R1->R2->R3->R4. Breakdown satisfies
AGENTS.md Principle 0 stage 4 (cross-review until clean); ready for stage 5
(TDD implementation), order F0->head-fix->B->E->A->C->G->F.

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

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

The coordinator must NOT hand-edit product code in the main loop — every code
edit happens inside a Workflow subagent. Per item: red->green->gates, then
multi-agent review loop until zero blocker/major, then commit, then next item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…press guard (R5)

The F0 workflow's implementation surfaced a defect the 4 breakdown-review rounds
missed: the finishDictReread counter (fired inside writeTermDict) is a tautology
once writeTermDict is deleted (no call site -> 0 by construction), and a
byte-identical oracle passes against BOTH old and new code. Replaced with the
genuine, PERSISTENT discriminator: finish() must decompress ZERO data blocks
(old writeTermDict decompresses every [I] block; inline + openSegment decompress
none) via an onDecompress hook on codec.decompress. Lesson: TDD-in-practice
caught what static review could not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oc-engteam and others added 18 commits June 25, 2026 22:51
…give-up wording)

Breakdown review verdict: READY to implement. Two MINOR doc-precision nits folded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…me id (F v5)

Move the residual spill encode off the mpsc worker. One in-flight spill; the seg
id is assigned at INSTALL (encode writes seg-tmp-<n>.dat, install does
NextSegId++ + rename) so the parked spill always installs with the highest id =
newest -> no spill-vs-spill or merge-vs-spill inversion, no merge deferral. A
worker-controlled blockProducer gate (Cond.L==&s.mu, producer for-loops before
AddFunc) bounds the live head only on over-cap+spillInFlight; E unchanged.
installSpill publishes-before-remove and re-dispatches an over-cap head on ANY
table. CloseAndWait drains the encode off the worker (caller goroutine,
install-first). G sweeps seg-tmp-*. B1 silent-corruption gated at zero
concurrency. idxbench measurement deferred (needs the lx.gob corpus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e map reuse

Measured on lx (94.5k docs, post-F): build 45s (beats pebble 64s), disk 238 MiB
(2.7x < pebble), but build peak RSS ~1 GB (pebble 610) from 30 GB alloc churn ->
~25% CPU in GC. mergeSegments is 44% alloc / 31% CPU; its per-keyword adds/dels
maps (merge.go:275-276) alone are 2.1 GB flat -> C.4: hoist+clear+reuse the two
maps (consumed per key, safe). C.2 decompress scratch (1.95 GB), C.3 segWriter
encode scratch (encodeDocs/appendUvarint/flushDictChunk/encodeForward, ~6 GB).
RSS/GC wins (merge is off-worker), not build-wall. Head maps out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…egrity test (review)

Aliasing review: C.4 (merge map reuse) + C.3 (encode value scratch) SAFE. C.2
(per-cursor decompress reuse) UNSAFE naively — addEntry retains the cursor key
via blkFirst->firstKey->finish UNCOPIED, so reusing the block corrupts the
persisted block-index first-key, which the differential hits-test MISSES (a
too-early sort.Search start still finds the key). Fix: copy first-key at capture
(segment.go:119) + a dedicated block-index-integrity test. C.4 must clear() both
maps unconditionally incl. the dropped-key path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Profile (lx, post-F): 30 GB build alloc churn -> ~25% CPU in GC, ~1 GB peak RSS.
mergeSegments is 44% alloc. C.4: hoist the per-keyword adds/dels maps out of the
merge loop, clear()+reuse them each key (2.1 GB saved; cleared unconditionally
incl. the dropped-key path). C.3: reuse a per-segWriter encode-value scratch for
encodeDocs/encodeForward (addEntry copies immediately). C.2: per-cursor decompress
scratch reuse + copy blkFirst at capture (segment.go) so a reused block can't
corrupt the persisted block-index first-key. RSS/GC win (merge is off-worker), hits
identical. Block-index-integrity test guards C.2 (the differential misses it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dered ops slice)

Measured (idxbench -peakheap): build peak LIVE heap ~467 MB (->~1 GB RSS) is
HEAD-dominated — addPosting 188 MB (66%) is the per-keyword map[int64]struct{},
~48-96 B overhead each even for a 1-doc keyword. THAT is why store needs ~1 GB
build RSS vs pebble's 610 (compact memtable); GOMEMLIMIT only masks it; C.2-4
(churn) didn't move it because peak RSS = the live head. The map's dedup is
redundant (encode appendDeltaDocs already sort+dedups); only the cross add-vs-del
latest-wins is real. Change: postingDelta{ops []int64} (docid<<1|isAdd, append);
resolveOps (stable-sort, last-per-docid) at spill + Search, non-mutating
(copy-before-sort: M2 + concurrent Search). ~5-6x less head memory; on-disk
format byte-identical. Gated by differential hits-identical + resolveOps unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-docid contract (review)

Review confirmed resolveOps provably equivalent to the map (empirically tested
every sequence) + all readers/format/Postings preserved. Fixed 2 MAJORs:
(1) packing precondition 0<=docid<2^62 (idtable monotonic counter) + assert +
struct/parallel fallback; (2) the sort MUST be sort.SliceStable keyed on docid
ONLY (v>>1) — sorting the full packed int64 is WRONG (isAdd low bit makes an add
always last -> add->del mis-resolves). Plus: resolveOps fresh scratch per call;
head_lazy_dels_test.go (reads pd.adds/dels as maps) must be rewritten; drop the
posting() +16 estimate.

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

Thin TDD wrapper over the 2-round-reviewed spec §5b: resolveOps unit test (incl.
the add->del discriminator the wrong sort fails + a no-mutate test), behavior-
preservation via the differential hits-identical, RSS measurement via idxbench
-peakheap. 7A-7E + acceptance follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 4 setToSlice sites

Breakdown review: NEEDS FIX (mechanical). Use the existing eqInt64s (not the
undefined eqInt64 / name-trap) + sort.Slice + import sort; spell out all FOUR
setToSlice->resolveOps sites (Search/GetDocs x live+spilling) + DELETE setToSlice;
refresh stale adds/dels comments.

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

The H IMPLEMENTATION surfaced (3rd time TDD caught a spec defect static review
missed) that the store's docid is the FULL int64 range — TestDifferential_Int64-
DocidFullRange feeds 1<<62/MaxInt64 — so the v1/v2 docid<<1|isAdd packing's
<2^62 precondition is UNSATISFIABLE. Revised per Principle 0: primary is the
parallel postingDelta{docids []int64; isAdd []uint64} bitset (full range, same
~8.1 B/op, ~6-8x < the map). Measured: peak inuse 156 MB, addPosting map hog
GONE. Also: H's +8/op accounting shifts spill cadence -> surfaces a latent F B1
test cleanup-ordering -race (fix the test, a 5th file). resolveOps: stable-sort
by docid, last-per-docid (non-stable fails add->del).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… is the remaining work

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

Replace postingDelta{adds,dels map[int64]struct{}} (66% of build peak live heap: ~48-96 B/keyword map overhead even for a 1-doc keyword) with postingDelta{docids []int64; isAdd []uint64} — a parallel ordered op log, O(1) append, ~8.1 B/op. resolveOps (stable-sort by docid, last-op-per-docid wins, non-mutating fresh scratch) reproduces the map latest-wins at spill + Search/GetDocs. On-disk format byte-identical (differential hits-identical 2,414,505, incl. the full-int64-range docid test the rejected docid<<1 packing could not represent). Measured: peak inuse 156 MB (vs ~284), the addPosting map hog GONE (head ~26 MB). Also fixes a latent F B1 test cleanup-ordering -race (drain spills before nil-ing the encodeSpillBlock hook) that H's +8/op spill-cadence shift surfaced.

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

C.4 (581383a) hoisted mergeSegments' per-keyword adds/dels reconciliation maps
out of the loop and clear()+reused them. Go's clear() empties a map but RETAINS
its bucket capacity, so a map once grown by a high-cardinality keyword stays
huge and every later key's `for d := range adds` drains O(retained capacity)
instead of O(key size) — turning the merge into O(numKeys × peak). On the lx
corpus (94559 docs, 2.41M hits) this regressed the full build 6.5×: 46s → 277s.

Root-caused via CPU profile (63% of build CPU in maps.Iter.Next +
ctrlGroup.matchFull under mergeSegments), a bisection (a52da8d 46s vs current
277s landing on 581383a), and env ruled out: a tmpfs build was identically slow
(fsync not the cause) and a 1/5-corpus build was CPU-bound and healthy.

Fix = revert ONLY C.4: declare adds/dels FRESH per key again (the a52da8d
structure). Keep C.2 (segWriter blkFirst copy) and C.3 (encodeScratch reuse) —
not implicated. Output is byte-identical (a fresh empty map == a clear()ed one);
the full differential suite is unchanged.

Measured on lx (/workspace xfs, same session):
  build      277s   -> 42.6s    (6.5x; regression removed)
  peak RSS   484    -> 393 MiB  (-19%; the buggy reused map stayed resident)
  disk       233.6  -> 234.9 MiB (same)
  hits       2414505 (identical)
  search     8840   -> 9029 us/q (noise; reader path untouched)

Adds merge_highcardinality_test.go: a high-cardinality keyword flanked by many
tiny keywords, through a tiered AND a covering merge, asserting per-keyword
adds/dels via segInvRecords. Coverage for the map-drain shape; NOT a perf guard
(the bug is perf-only — see the spec). invertedstore go-cov 94.6%; -race green.

SDD artifacts: docs/design/invertedstore-merge-mapreuse-regression-fix-{spec,
tasks}.md (spec → 2-round multi-agent review → tasks → review → workflow TDD,
all converged to zero Blocker/Major).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…eep invertedstore unwired)

invertedstore is not production-ready yet (scale gaps: hot-keyword merge/search
OOM, no max-seg cap, single-JSON MANIFEST). Revert the LIVE server backend from
invertedstore to the pebble-backed invertedindex so this branch can land its
invertedstore component + groundwork without flipping production. Variant 1 —
keep the abstraction:

- server constructs invertedindex.New wrapped in NewIndexerAdapter, returning the
  invertedindex.Indexer seam (not *invertedstore.Store, not *invertedindex.Index);
  restore the pebble indexdb (storage.Open .../index) + defer Close.
- StorageVersion 1.6 -> 1.5; drop "1.5" from the cleanup list (it would have wiped
  the now-live 1.5 store).
- KEEP: the invertedindex.Indexer/Batch/SearchResult interface + the pebble adapter,
  the shared-mpsc deadlock fixes (documents/symbols), and the entire core/invertedstore
  component (now unwired — exercised only by its own tests + the idxbench harness);
  documents.New/symbols.Init stay typed to the Indexer seam.
- Tests that used invertedstore as the live/test backend switched to invertedindex.New
  + NewIndexerAdapter (+ flush-aware polling for pebble's flushed-read visibility);
  deadlock guards and invertedstore component tests kept.

Both modules green: go build/vet/test ./... + -race on invertedstore/invertedindex.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…n roadmap

- core/invertedstore/README.md: durable status + measured data (build 42.6s /
  234.9 MiB / 393 MiB RSS on lx; store-vs-pebble A/B; spill+merge cadence ~23
  tiered merges; Fanout/write-amp sweep) + size-tiered strategy vs Lucene/RocksDB
  + corrected ground-truth facts + the C.4 clear()-retains-capacity footgun +
  the known scale gaps (hot-keyword OOM, no max-seg cap, deletion trade).
- docs/design/invertedstore-luceneization-exploration.md: the pre-spec design
  exploration (forward/inverted split, per-doc delete, max-seg cap, bloom-vs-dict,
  + the merge-memory/scale reframe), adversarially reviewed.
- docs/design/invertedstore-luceneization-implementation-plan.md: the sequenced
  roadmap (D0 harness → streaming merge → streaming search → max-seg cap → one
  StorageVersion reindex) + decision resolutions + holistic search impact.

Docs only; no code change. invertedstore stays the unwired go-forward backend.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Revert ALL integration code outside core/invertedstore back to main,
keeping core/invertedstore as a standalone component:
- restore the 25 modified files (collection/documents/engine/symbols/
  server/storage) to main's version
- delete the 6 files added on the branch (deadlock tests, e2e test,
  invertedindex adapter.go/indexer.go)
- decouple core/invertedstore from the now-removed invertedindex.Indexer/
  Batch interfaces: NewBatch/Update return *Batch, drop the compile
  assertions, fix doc comments; keep SearchResult = invertedindex.SearchResult

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… seam in invertedindex

The integration seam was reverted out (core/invertedindex stays pristine — it is a
published package other projects depend on). Fix the stale Status line that claimed
invertedstore "satisfies the invertedindex.Indexer seam"; it is now a deliberately
unwired standalone component, integration deferred until it is stable at scale.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@codetrek codetrek changed the title invertedstore: land the segment-based index component (unwired) + perf work + docs invertedstore: land the segment-based index component (standalone, unwired) Jun 30, 2026
oc-engteam and others added 8 commits July 6, 2026 13:32
…ction coverage ≥80%

Two CI failures after landing invertedstore standalone:
1. App "Check Formatting": 6 invertedstore test files were not gofmt'd
   (apply_fastpath/crash_recovery/dictcache/merge_offworker/search/spilling_read).
   gofmt -w (go 1.24.2, CI's version).
2. Core "Coverage gate": go-cov's strict per-function ≥80% gate failed on 9
   invertedstore functions — coverage the removed integration test
   (engine/invertedstore_e2e_test.go) used to provide. A standalone component must
   cover its own functions:
   - DELETE the dead `blockDiskSize` (0%, no call sites, unused).
   - Add focused invertedstore tests for the error/lifecycle branches:
     readManifest (malformed/EISDIR) 72.7→100, writeManifest (marshal error via a
     test-only nil-default `marshalManifestErr` hook) 75→100, writeManifestBytes
     (write/rename error) 66.7→81, newSegWriter 66.7→100, mustReadAt (short read)
     50→100, sweepOrphanSegments (seg-tmp sweep) 78.9→84.2, newChunkLRU 66.7→100,
     fileSize (stat error) 75→100.

Verified with go 1.24.2 (CI's version): go-cov reports ZERO invertedstore functions
<80% (pkg 95.7%); gofmt clean; go test + -race ./invertedstore/ green; root build
clean. Scope unchanged — still only core/invertedstore + invertedstore docs + AGENTS.md
differ from main.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…while-open

Core Windows CI ran the invertedstore suite for the first time (prior runs were
matrix-canceled by the ubuntu coverage-gate failure) and the whole suite failed on
two POSIX-only file semantics:

1. writeManifestBytes fsync'd the directory (os.Open(dir)+d.Sync()) to make the
   MANIFEST rename durable — runs on EVERY spill + merge-install. Windows cannot
   FlushFileBuffers a directory handle, so it errored on every MANIFEST write.
   Fix: extract syncDir(dir) — POSIX open+Sync unchanged (byte-identical durability),
   no-op on Windows (NTFS journals metadata; a dir fsync is neither needed nor
   supported for rename durability — same as bolt/badger/pebble).

2. installSpill renamed the off-worker temp segment to its final name while the
   segment's read fd was still open. Go's os.Open on Windows lacks FILE_SHARE_DELETE,
   so MoveFileEx refuses to rename an open source (ERROR_SHARING_VIOLATION). Fix:
   renameSegmentFile reseats the fd across the rename (close → os.Rename → reopen),
   applied to the install rename + both rollback renames. Safe: installSpill runs on
   the worker before publishSnapshotLocked, so no reader holds the fd yet.

POSIX behavior/durability unchanged. Verified on Linux (go 1.24.2): go build/vet/
test/-race ./invertedstore/ green; gofmt clean; go-cov invertedstore 95.7%, no
function <80%. The Windows path itself can only be confirmed on Windows CI.

Known follow-ups (Minor, pre-existing pattern): renameSegmentFile/openSegment swallow
a reopen error (rare fd-exhaustion → nil fd → read panic); drainMerge has timing-
dependent (flaky) coverage.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…d in a3a8543)

a3a8543 added renameSegmentFile (Windows rename-while-open fix) but the staging
missed this test file, so CI saw renameSegmentFile at 62.5% (only the happy-path
coverage from the off-worker spill tests) and the Linux coverage gate failed.
This file covers BOTH the success reseat and the rename-FAILURE reseat-at-source
branch, taking renameSegmentFile ≥80%.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…nal branch

drainMerge's <-mergeSignal branch is timing-FLAKY through the normal stop path
(mergeLoop's select{<-mergeStop; <-mergeSignal} picks randomly, so a pending
trigger is only ~50% observed by drainMerge). That flakiness intermittently drops
drainMerge to 50% and fails the Linux per-function coverage gate (it just did on CI).
Cover it deterministically instead: stop the background loop, inject a pending
trigger, call drainMerge directly, and assert it collapses the >=Fanout L0 segments
(a real merge, not a touch). drainMerge → reliably 100%.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…s diagnostic dump

The "Test (macOS/Windows)" step runs under GitHub's default `bash -e`. With
`set -o pipefail`, a failing `go test | tee vitest.log | grep` pipeline returns
non-zero, and `-e` aborts the script IMMEDIATELY — before `status=${PIPESTATUS[0]}`
and the `if fail: cat vitest.log` dump. So a macOS/Windows failure logged only the
grep'd `--- FAIL` summary, never the assertion detail (the full -v log). Add
`set +e` so the status is captured and the full log is dumped on failure, then
`exit "$status"` still fails the job.

(General CI fix; needed to debug the invertedstore Windows-portability failures on
this branch.)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…ows fd hygiene)

The Core Windows CI job failed ~24 invertedstore tests with
"TempDir RemoveAll cleanup: remove ...seg-*.dat: The process cannot access the
file because it is being used by another process." Those tests open a *Store
(each Open opens seg-*.dat read fds) and return without CloseAndWait, so the
still-open fds block t.TempDir's RemoveAll on Windows. On POSIX this is hidden
by unlink-open-fd semantics. CloseAndWait already closes every segment fd
(retireKeepFile) — the fix is simply to call it before the test returns.

- newForwardSkipStore / newBackpressureStore: register t.Cleanup(CloseAndWait)
  (covers the ApplyFastPath / SpillingTier / ForwardSkip / Backpressure tests).
- single-store leakers: add `defer s.CloseAndWait()` right after Open.
- reopen / crash-recovery tests: close the final reopened instance too.
- spill_offworker leakers: t.Cleanup(CloseAndWait), ordered LIFO after the
  body's hook-clear cleanup so the close-time flush never blocks on a parked encode.
- TestSpillF_CrashLosesDetachedHeadNoOrphan intrinsically abandons a live store
  to model a mid-spill process death, so it cannot CloseAndWait — skip it on
  Windows (matching internal/core/symbols/seal_idtable_durability_test.go); the
  property is still exercised on Linux/macOS.

Test-only; no product code changed. Verified with go1.24.2: full + -short
invertedstore suites green, -race green, gofmt clean, go-cov gate 95.8%
(no function below 80%).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…e the go-cov gate)

sortSegMetasById (merge.go) is a pure insertion sort exercised only by the
timing-dependent AutoMerge/merge paths, so its per-function coverage flaked:
the inner swap line runs only when the merge feeds it out-of-order segMetas.
On this branch's CI run it dropped to 66.7% and tripped the go-cov <80%
CRITICAL gate (it had passed the prior runs purely by merge-ordering luck).

Pin it with a direct, deterministic white-box unit test: an unsorted
>=2-element input ({3,1,2}->{1,2,3}) forces the swap and the comparison-false
early stop every run, plus reverse/already-sorted/single/empty cases assert
the exact resulting Id order (reflect.DeepEqual — a real invariant, not
coverage theater). No Store/queue/t.TempDir, so it is fast and leak-free.

Verified with go1.24.2: the new test is green, gofmt clean, and the go-cov
gate now reports zero CRITICAL functions (sortSegMetasById >=85%,
core/invertedstore 95.8%).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
The docs/design/invertedstore-* files (spec / task-breakdown / plan /
exploration / implementation-plan / per-regression fix notes — 11 files,
~6.6k lines) are one-off process artifacts, not as-built architecture docs,
so they do not belong in the tree. The durable conclusions and measurements
they produced already live in core/invertedstore/README.md (status,
measurements, merge strategy vs Lucene/RocksDB, key findings, the C.4
regression, and the known scale gaps / roadmap). Removing them leaves PR #106
as core/invertedstore/** (code + README) + AGENTS.md, nothing else.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@codetrek
codetrek merged commit af985bc into main Jul 7, 2026
4 checks passed
@codetrek
codetrek deleted the feat/invertedstore branch July 7, 2026 01:19
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.

2 participants