diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c16c850..bed9316 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,11 @@ jobs: # those tests' deterministic seams already exercise the race per-iteration. # Linux runs the FULL suite (no -short) via the coverage gate above. set -o pipefail + # GitHub's default bash runs with `set -e`; without disabling it the failing + # `go test | tee | grep` pipeline aborts the script BEFORE the diagnostic dump + # below, so a macOS/Windows failure never prints its assertion detail. Capture + # the status explicitly and dump on failure instead. + set +e go test -short -v -timeout 15m ./... 2>&1 | tee vitest.log \ | grep -E '^(ok|FAIL|--- (PASS|FAIL):|panic:)' | grep -vE '^--- PASS:.*\(0\.00s\)$' status=${PIPESTATUS[0]} diff --git a/AGENTS.md b/AGENTS.md index 2b35c01..ffe3477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,43 +3,41 @@ Mandatory working principles for any AI agent (Claude Code, etc.) operating in this repository. They override default behavior. **Read them at the start of every session.** -## 0. Every change happens in a git worktree — no exceptions - -**Before making ANY change to this repository — code, tests, docs, config, even a -one-line edit or an edit to this file — work inside a dedicated git worktree, never -the primary `main` checkout.** There are no exceptions and no "too small to bother" -cases. - -- At the start of any task that will modify files, create/enter a worktree FIRST - (native `EnterWorktree`, else `git worktree add` under `.claude/worktrees/`). -- Never edit files in the primary working tree. If you have already started there, - move the changes into a worktree (e.g. `git diff > /tmp/p.patch`, apply it in the - worktree) and `git restore` the main checkout to clean before continuing. -- This isolates in-flight work, keeps `main` pristine, and makes every change - reviewable as its own branch. - -## 1. Code changes follow spec → review → task breakdown → review → implementation — no exceptions - -**Never jump straight to editing code.** Every change to code (and the tests/config that -accompany it) goes through this flow, in order. Each step produces a **written artifact**, -and each **review** gate must be explicitly approved before the next step starts: - -1. **Spec** — write WHAT changes and WHY: problem, goals / non-goals, design, the - interfaces & files affected, durability / compatibility impact, risks, and how it - will be verified. -2. **Review** — the spec is reviewed and approved before any decomposition. -3. **Task breakdown** — decompose the approved spec into concrete, ordered, - independently-verifiable tasks. -4. **Review** — the task breakdown is reviewed and approved. -5. **Implementation (SDD)** — implement strictly per the approved spec and tasks. If - reality diverges from the spec, STOP and amend the spec (back through review) — do - not improvise in code. - -A plan sketched in chat is NOT a spec. Measurement / exploration spikes are allowed -*before* the spec (to inform it), but production code changes wait for an approved spec -**and** task breakdown. There is no "too small to spec" exception. - -## 2. Infrastructure: ship any real benefit, however small +## 0. NEVER write code directly — the SDD flow is mandatory, no exceptions + +For any code change, however small or "obvious", you MUST follow this pipeline IN ORDER and +NEVER skip a stage: + +1. **Spec** — write the design/spec first (chunked per Principle 3). +2. **Multi-agent review** — dispatch MULTIPLE independent review agents to cross-review the + spec; fix every blocker/major before proceeding. +3. **Task breakdown** — decompose into bite-sized TDD tasks. +4. **Multi-agent cross-review** — multiple agents cross-review the task breakdown; fix issues. +5. **Implementation — driven by a WORKFLOW, never by hand.** TDD (red → green), **ONE item at a + time**, orchestrated through the Workflow tool (multi-agent): the coordinator MUST NOT hand-edit + product code in the main loop — every code edit happens inside a workflow subagent. For each item + the workflow: writes the failing test → runs it red → implements → runs it green → runs the gates, + then dispatches MULTIPLE independent review agents and LOOPS (fix → re-review) until that item + returns zero blocker/major; only THEN commits it and moves to the next item. Never batch several + items before reviewing. If you catch yourself opening an editor on product code outside a workflow, + STOP — author the workflow instead. + +**The review stages are a LOOP, not a single pass — re-review until clean.** Whenever you fix +findings from a review (stage 2, 4, or 5), you MUST dispatch a FRESH round of multiple independent +review agents on the REVISED artifact and repeat — your own edits are unverified until a new review +round confirms them, and a fix routinely introduces a new blocker (e.g. a deadlock fix that +reintroduces the deadlock elsewhere). Keep iterating rounds until a full round returns **zero +Blocking and zero Major** findings. Do NOT advance to the next stage, and do NOT report the artifact +as done, after merely *applying* fixes — applied-but-not-re-reviewed is not done. Record each round's +findings + resolutions in the artifact so the convergence is auditable. + +Do NOT jump straight to editing code, not even for a "quick prototype", a "let me just +measure it" spike, or a one-line fix. Prototyping a change before the spec/review is still +"writing code directly" and is forbidden. Measurement that requires new/changed product code +follows the same flow. If you catch yourself opening an editor before the spec is written and +reviewed, STOP and go back to stage 1. + +## 1. Infrastructure: ship any real benefit, however small This work is infrastructure. If a change produces a **real, correct benefit — even a tiny one — do it.** Do not skip a sound improvement because the measured win looks @@ -51,7 +49,7 @@ lever" or an easier alternative. - Don't substitute a different, larger-scope change for the obvious small one. - Do the complete job: sweep **all** the safe cases, not just the big ones. -## 3. Verify at the source — no substitute environment or method +## 2. Verify at the source — no substitute environment or method **Verify a problem, and its fix, WHERE the problem actually occurs.** Do not use a proxy environment or a substitute method and then draw conclusions from it. @@ -63,14 +61,13 @@ proxy environment or a substitute method and then draw conclusions from it. - Don't be clever or presumptuous. Go to where the issue is, reproduce it there, and validate the fix there. - Do not propose unrequested "alternative approaches" in place of verifying the real - thing at its source. Don't fragment a small in-flight change into a separate, - deferred branch/PR to avoid doing it now — make it part of the work you are already - doing, in your current worktree (Principle 0). + thing at its source. Don't spin up a new worktree/PR for a small in-flight change — + make it directly in the branch you are already working in. -When the two meet: make the real infrastructure improvement (Principle 2) **and** prove -it in the real failing environment (Principle 3) — never in a convenient substitute. +When the two meet: make the real infrastructure improvement (Principle 1) **and** prove +it in the real failing environment (Principle 2) — never in a convenient substitute. -## 4. Author large files incrementally — chunk, don't dump +## 3. Author large files incrementally — chunk, don't dump When creating a large file (a plan, spec, design doc, or sizable code file), **do not emit the whole thing in one giant write.** Build it up in chunks: create the file with @@ -82,3 +79,27 @@ its header/skeleton first, then append one section at a time. after the entire artifact lands, and produces a cleaner edit history. - Applies to generated docs and plans especially, but to any long file: prefer a sequence of focused appends over one monolithic write. + +## 4. A perf demo must be format-identical to the real implementation + +When you measure a design with a demo/prototype/spike, **the demo's on-disk format and +data path must be EXACTLY what the real code will implement.** No simplified, packed, +"good-enough", or approximate version is acceptable as a source of numbers. + +- **The disk format is the contract.** The byte layout the demo writes MUST be the byte + layout the production code writes — same blocks, same indexes, same chunking, same + encodings. A simplified layout produces simplified (i.e. wrong, usually optimistic) + numbers — disk size, memory, read amplification all change with the format. +- **Every feature is measured through the demo, not estimated.** If a feature exists in + the design (the forward map, tombstones, compression, merge, large-value chunking), + it must be present and exercised in the demo before any number that involves it is + reported. "Inverted-only", "merge handled separately", "forward estimated" etc. are + self-deception — the deployed system always pays those costs, so the measurement must + too. +- The CODE may be rough (messy, unfactored, demo-quality) — that is fine. The FORMAT and + the set of features exercised may **not** be rough or partial. +- If a measurement was taken on a simplified path, it does not count. Rebuild the demo to + the real format and re-measure. + +This is the data-integrity counterpart to Principle 2: Principle 2 says measure in the +real *environment*; Principle 3 says measure with the real *format and feature set*. diff --git a/core/invertedstore/README.md b/core/invertedstore/README.md new file mode 100644 index 0000000..2a8d298 --- /dev/null +++ b/core/invertedstore/README.md @@ -0,0 +1,123 @@ +# invertedstore — status, measurements & findings + +The segment-based (LSM-like) inverted index: the **go-forward** replacement for the pebble-backed +`core/invertedindex`. Full as-built architecture: [`docs/design/invertedstore-design.md`](../../docs/design/invertedstore-design.md). +This README is the durable record of **measured data + key conclusions** (so the next iteration does +not re-derive them); the forward-looking redesign lives in +[`docs/design/invertedstore-luceneization-exploration.md`](../../docs/design/invertedstore-luceneization-exploration.md) +and [`...-implementation-plan.md`](../../docs/design/invertedstore-luceneization-implementation-plan.md). + +## Status (2026-06) + +**Built and component-complete, but NOT integrated.** The production server runs on the pebble-backed +`invertedindex`; `invertedstore` is a **standalone, deliberately unwired** component. It is held back +because it is **not yet mature at scale** (see "Known scale gaps" below), and — since `core/invertedindex` +is a published package other projects depend on — **no swap seam / interface is added to it** for an +unproven replacement. The integration approach will be designed only once invertedstore is stable. The +component is exercised by its own tests + the `core/cmd/idxbench` A/B harness (a dev-only, uncommitted +tool). Form: single mpsc-worker-owned head buffer → atomically-published immutable sealed segments + +a MANIFEST; size-tiered background merge; single-mutator invariant; lock-free refcounted reader +snapshots. + +## Measurements (lx corpus: 94,559 docs; `/workspace` xfs; default config CapBytes 16 MiB / Fanout 4 / L0 snappy, merged zstd) + +### Build / steady state (current tree, post C.4 fix) +| metric | value | +|---|---| +| build (AutoMerge on) | **42.6 s** | +| disk (settled) | **234.9 MiB** | +| peak build RSS | **393 MiB** | +| search | ~9.0 ms/q over 198 queries (hits 2,414,505) | +| final live segments | 3 (1×L1 + 2×L2) | + +### Store vs pebble `invertedindex` (A/B, same corpus; store re-confirmed this session, pebble from the prior A/B) +| | store | pebble | | +|---|---|---|---| +| build | ~42.6 s | ~64 s | store **~1.5× faster** | +| disk | 234.9 MiB | ~643 MiB | store **~2.7× smaller** | +| peak RSS | 393 MiB | ~610 MiB | store lower | +| search | — | — | store **~4× SLOWER** (the known weak axis: read-amp = scan every live segment) | + +### Spill / merge cadence +- A spill fires when the head's byte estimate reaches **CapBytes (16 MiB)** → an L0 segment ≈ **4.6 MiB on + disk** (snappy, ~3.4× compression). The lx corpus produces **~56–69** L0 spills. +- With AutoMerge on (Fanout 4): **~23 tiered merge passes** collapse the 56 spills to **3** segments — a + merge roughly every **2–3 spills / ~1.7 s**, run concurrently (off-worker) with ingest. **No covering + merge fires on a pure-add build** (dead-fraction ≈ 0; covering only triggers at ≥ 0.25 or DeleteTable). + +### Fanout / write-amplification sweep (`idxbench -fanout`, write_bytes = real disk writes) +| config | build | disk written (amp) | final segs | +|---|---|---|---| +| AutoMerge **off** (pure build) | 30.1 s | 322 MiB (**~1×**) | 69 (all L0) | +| Fanout **4** (default) | 40.8 s | 725 MiB (**2.25×**) | 3 | +| Fanout **8** | 50.8 s *(run-to-run outlier)* | 766 MiB (2.4×) | 5 | +| Fanout **16** | 35.2 s *(reproduced)* | 540 MiB (**1.7×**) | 8 | + +→ Merges are **overlapped** with ingest, so AutoMerge adds modest wall-time (+~10 s vs pure build) but +**~2.25× write amplification**; larger Fanout trades fewer/cheaper merges (less write-amp) for more +residual segments (worse search). The pure-build vs merged disk gap (322→234.9 MiB) is the merge's +recompress (L0 snappy → merged zstd) + dedup. + +## Merge strategy (and where it sits vs Lucene / RocksDB) + +**Size-tiered** (Cassandra STCS-like), NOT leveled (LevelDB/pebble): a level with ≥ Fanout (4) segments +is k-way merged into ONE next-level segment; segments within a level are full-keyspace overlapping sorted +runs, so a query scans **every** live segment newest→oldest (read-amp = segment count — the source of the +~4× search gap vs pebble's leveled 1-file-per-level). A **covering** merge (all live segments → one, +triggered at dead-fraction ≥ 0.25 or DeleteTable) is the escape hatch that reclaims tombstones/dead-table +keys and collapses read-amp — the analog of Lucene `forceMerge(1)`. + +This is the **same family as Lucene's `TieredMergePolicy`** (immutable segments, size-tiered, forceMerge): +deliberately write-optimized (low write-amp, cheap build) trading read-amp — aligned with the priority +order **build ≫ mem > search**. Differences from Lucene: (1) reconciliation is **per-(keyword,docid) +newest-wins** because our docid is a reused-with-new-content external id, not Lucene's append-only +segment-local docid + liveDocs bitset; (2) selection is the crude "whole level ≥ Fanout" (no Lucene +score-based sizing); (3) **no max-segment-size cap** (Lucene's `maxMergedSegmentBytes`); (4) no concurrent +merges; (5) FST term index was measured **slower** than the sorted-keyword dict and is rejected. + +## Key findings (corrected ground-truth — respect these in any redesign) + +- **docid is a monotonic sequential int64 from idtable (`nextId++`), STABLE-PER-KEY, never recycled.** The + MD5 is the content/path *key* that maps into the id. Re-indexing the same file reuses the same id with a + NEW keyword set — so a plain Lucene deleted-docid bitset is insufficient (the live id can't just be + marked dead). +- **`[I]` keys sort before `[F]`** in a segment, so during a merge a posting is emitted before its doc's + forward record (its version) is seen → any merge-time version filter needs a **resident** version table, + not inline resolution. +- **Search resolves deletes INLINE** via the inverted value's `dels` half (newest-wins) and **never reads + the forward map**. The term-id ordinal coupling (forward stores ordinals into the segment's sorted + inverted dict) is purely a forward concern and is the source of ~120 lines of merge remap/ordSentinel + complexity + the "tiered merge can't drop a key" constraint. +- **Merge is streaming across blocks/segments (one decompressed block per cursor) but NOT within a single + keyword:** a hot keyword's whole posting list is materialized (the reconciliation map + `readExternal` + reads the whole blob) — in BOTH merge and search. This is the dominant **scale OOM vector**. + +## The C.4 regression (a recorded footgun) + +`clear()` on a Go map does **not** release its bucket capacity. C.4 (commit 581383a) hoisted the +per-keyword `adds`/`dels` reconciliation maps out of the merge loop and `clear()`+reused them; once a +high-cardinality keyword grew a map, every later small key's `for d := range adds` scanned the retained +(mostly empty) buckets → **O(numKeys × peakBuckets)**, regressing the lx build **6.5× (46→277 s)**. Fix +(977fa05): revert to a **fresh map per key** → build 42.6 s, RSS −19% (the giant reused map no longer +stays resident). Lesson: never `clear()`+reuse a Go map across keys of wildly varying size. + +## Known scale gaps (lx is a TEST corpus; real targets are orders of magnitude larger) + +The above numbers are on a 234 MiB test corpus; none of these gaps shows there. For a general engine they +are correctness/scalability floors — see the [exploration](../../docs/design/invertedstore-luceneization-exploration.md) ++ [implementation plan](../../docs/design/invertedstore-luceneization-implementation-plan.md): + +1. **Hot-keyword OOM** — merge AND search materialize a whole keyword's posting list. Fixes: streaming + per-keyword reconciliation (bounds the cross-source union, no reindex) → then **chunked/block postings** + (fully df-independent, in the one reindex). +2. **No max-segment-size cap** — merge grows unbounded; needs `MaxMergedSegmentBytes` with + newest-contiguous-by-id subset selection (an OLD subset would invert newest-wins). +3. **Deletion is a trade** — per-keyword del-postings, reclaimed only by a full-index covering rewrite at + 25% garbage. A per-doc forward-version tombstone makes deletes O(1) on the write side BUT adds a + search-time liveness filter; the two cannot both be free. +4. **O(segments) MANIFEST** rewritten on every install + **O(docs) `recomputeLive`** on Open — worsen as + #2 multiplies segment count. + +Roadmap (no large corpus to validate yet): D0 synthetic-stress harness → streaming merge → streaming +search → max-seg cap → ONE StorageVersion reindex (forward/inverted split + per-doc delete + chunked +postings + keyword-range skip). diff --git a/core/invertedstore/apply_fastpath_test.go b/core/invertedstore/apply_fastpath_test.go new file mode 100644 index 0000000..3fc094e --- /dev/null +++ b/core/invertedstore/apply_fastpath_test.go @@ -0,0 +1,40 @@ +package invertedstore + +import "testing" + +// A warm 1-op edit (drop a keyword) MUST still diff against the forward and tombstone the dropped +// keyword — the fast path must not skip the diff. (Guards that len(ops)==1 still reads `old`.) +func TestApplyFastPath_WarmEditTombstonesDroppedKeyword(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + s.Update(tid, 1, []string{"alpha", "beta"}) + s.spillForTest(tid) // seal so the next edit reads the forward from a segment + s.Update(tid, 1, []string{"alpha"}) // drop "beta" + s.q.RunFunc(func() error { return nil }) // drain + // "beta" must no longer resolve to docid 1. + if got := searchDocidsForTest(t, s, tid, "beta"); len(got) != 0 { + t.Fatalf("beta still maps to %v after the warm 1-op edit dropped it", got) + } + if got := searchDocidsForTest(t, s, tid, "alpha"); len(got) != 1 || got[0] != 1 { + t.Fatalf("alpha should still map to {1}, got %v", got) + } +} + +func TestApplyFastPath_TakenForOneOpNotMultiOp(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + var fast int + applyFastPathTaken = func() { fast++ } + t.Cleanup(func() { applyFastPathTaken = nil }) + + s.Update(tid, 1, []string{"a"}) // 1-op → fast path + s.q.RunFunc(func() error { return nil }) + if fast != 1 { + t.Fatalf("1-op apply took the fast path %d times, want 1", fast) + } + b := s.NewBatch() + b.Update(tid, 2, []string{"b"}).Update(tid, 3, []string{"c"}) // 2-op → multi-op loop + b.Commit() + s.q.RunFunc(func() error { return nil }) + if fast != 1 { + t.Fatalf("multi-op batch took the 1-op fast path (fast=%d, want still 1)", fast) + } +} diff --git a/core/invertedstore/backpressure.go b/core/invertedstore/backpressure.go new file mode 100644 index 0000000..ad5f91f --- /dev/null +++ b/core/invertedstore/backpressure.go @@ -0,0 +1,50 @@ +package invertedstore + +import "sync" + +// postingBudget is a variable-amount counting semaphore bounding in-flight postings (spec §7, E). The +// producer acquire()s before enqueuing an apply; the apply release()s after running. A request larger +// than the whole budget is capped (acquire/release the same capped amount) so it never self-deadlocks. +type postingBudget struct { + mu sync.Mutex + cond *sync.Cond + cap int64 + used int64 +} + +func newPostingBudget(capacity int64) *postingBudget { + if capacity <= 0 { + capacity = 1 + } + b := &postingBudget{cap: capacity} + b.cond = sync.NewCond(&b.mu) + return b +} + +// acquire blocks until n (capped at the budget) tokens are free, reserves them, and returns the +// amount actually reserved (which the caller MUST later release exactly). n<=0 reserves nothing. +func (b *postingBudget) acquire(n int64) int64 { + if n <= 0 { + return 0 + } + if n > b.cap { + n = b.cap + } + b.mu.Lock() + for b.used+n > b.cap { + b.cond.Wait() + } + b.used += n + b.mu.Unlock() + return n +} + +func (b *postingBudget) release(n int64) { + if n <= 0 { + return + } + b.mu.Lock() + b.used -= n + b.cond.Broadcast() + b.mu.Unlock() +} diff --git a/core/invertedstore/backpressure_test.go b/core/invertedstore/backpressure_test.go new file mode 100644 index 0000000..7ecfcbb --- /dev/null +++ b/core/invertedstore/backpressure_test.go @@ -0,0 +1,248 @@ +package invertedstore + +import ( + "go/ast" + "go/parser" + "go/token" + "sort" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codetrek/haystack/core/queue" +) + +// searchDocidsForTest returns the live docids of the EXACT keyword kw in tableId, sorted — a thin +// []int64 view over GetDocs for membership assertions. (GetDocs, not Search: exact, not prefix.) +func searchDocidsForTest(t *testing.T, s *Store, tableId int, kw string) []int64 { + t.Helper() + r := s.GetDocs(tableId, kw) + out := make([]int64, 0, len(r.DocIds)) + for d := range r.DocIds { + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// newBackpressureStore mirrors newForwardSkipStore: a started queue + Open + one table (AutoMerge +// off), with caller-chosen Options so a test can set a tiny MaxInflightPostings. +func newBackpressureStore(t *testing.T, opts Options) (*Store, int) { + t.Helper() + q := queue.NewMpsc("backpressure") + q.Start() + s, err := Open(t.TempDir(), q, opts) + if err != nil { + t.Fatal(err) + } + tid, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + // Release every open segment fd at test end (retireKeepFile via CloseAndWait). Without this the + // read fds a spill opens stay live, and on Windows t.TempDir's RemoveAll cannot delete seg-*.dat + // while a handle is open. Registered before each test body's own gate cleanup, so it runs LAST + // (LIFO) — after the gate is released, applyGate cleared, and the queue drained — meaning + // CloseAndWait's head-flush RunFunc never blocks on a parked apply. No caller of this helper + // closes the store itself, so one close here is safe. + t.Cleanup(func() { s.CloseAndWait() }) + return s, tid +} + +// TestBackpressure_PeakInflightBoundedByBudget is the behavioral red→green discriminator. A producer +// fires far more 1-posting Updates than the budget while every apply is parked on a gate, so applies +// CANNOT drain (the single worker blocks inside the first gated apply; the rest sit buffered). With +// the producer backpressure wired, Update blocks once the in-flight postings reach the budget, so the +// producer returns from at most `cap` Updates and the peak in-flight never exceeds the cap. WITHOUT +// the wiring Update never blocks: the producer returns from all `producers` Updates, so the peak +// in-flight equals `producers` (>> cap) — this assertion fails on the BOUND (not on a compile error). +// +// In-flight = postings whose Update has returned to the producer but whose apply has not yet +// completed (released). It is counted directly (producer +1 per returned Update, apply -1 after the +// gate releases) so the red is real even though the gate keeps budget.used pinned regardless. +func TestBackpressure_PeakInflightBoundedByBudget(t *testing.T) { + const cap = 3 + const producers = 20 // each fires 1 posting (1 keyword), 20 >> cap; < mpsc buffer (100) + + s, tid := newBackpressureStore(t, Options{CapBytes: 1 << 20, MaxInflightPostings: cap}) + + // inflight = Updates returned to the producer minus applies that have completed. While the gate + // holds, no apply completes, so inflight == the number of Updates the producer got past. + var inflight, peak atomic.Int64 + bump := func() { + v := inflight.Add(1) + for { + p := peak.Load() + if v <= p || peak.CompareAndSwap(p, v) { + break + } + } + } + + // Gate every apply: it blocks until release, so applies cannot drain. On release each apply + // decrements inflight (it has run to completion). + release := make(chan struct{}) + var releaseOnce sync.Once + releaseGate := func() { releaseOnce.Do(func() { close(release) }) } + applyGate = func() { + <-release + inflight.Add(-1) + } + // Drain BEFORE clearing the global: release the gate so any parked apply can finish, then + // RunFunc blocks until every prior-enqueued closure (each of which reads applyGate) has run, so + // the last `if applyGate != nil` read happens-before this nil write. Otherwise an in-flight + // closure on the still-alive worker races the next test reassigning the package-global. + t.Cleanup(func() { + releaseGate() + s.q.RunFunc(func() error { return nil }) + applyGate = nil + }) + + // Fire the producers from one goroutine; with the wiring it blocks at the budget until applies + // drain (which they never do here — the gate holds them), so not all Updates return. + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < producers; i++ { + s.Update(tid, int64(i), []string{uniqWord(i)}) + bump() // this Update returned: +1 in-flight posting + } + }() + + // Wait until the producer has either blocked at the budget (wired: inflight stalls at ~cap) or + // fired everything (unwired: inflight reaches `producers`). Poll until it is quiescent. + deadline := time.After(3 * time.Second) + var last int64 = -1 + stable := 0 +poll: + for { + cur := inflight.Load() + if cur == last { + stable++ + if stable >= 20 { // ~200ms of no movement → producer is quiescent + break poll + } + } else { + stable = 0 + last = cur + } + select { + case <-deadline: + break poll + default: + } + time.Sleep(10 * time.Millisecond) + } + + if p := peak.Load(); p > int64(cap) { + t.Fatalf("peak in-flight postings = %d exceeds budget %d (producer did not block)", p, cap) + } + + // Releasing the gate lets every apply drain and the producer finish. + releaseGate() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("producer never finished after the gate released") + } + s.q.RunFunc(func() error { return nil }) // drain +} + +// TestBackpressure_OversizedSingleUpdateDoesNotDeadlock: a single Update with more keywords than the +// whole budget must cap its acquire to the budget and proceed (never self-deadlock waiting for room +// that can never exist). +func TestBackpressure_OversizedSingleUpdateDoesNotDeadlock(t *testing.T) { + const cap = 2 + s, tid := newBackpressureStore(t, Options{CapBytes: 1 << 20, MaxInflightPostings: cap}) + + kws := []string{"a", "b", "c", "d", "e"} // 5 > cap 2 + done := make(chan struct{}) + go func() { + defer close(done) + s.Update(tid, 1, kws) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("oversized single Update self-deadlocked (acquire not capped to the budget)") + } + s.q.RunFunc(func() error { return nil }) // drain + got := searchDocidsForTest(t, s, tid, "c") + if len(got) != 1 || got[0] != 1 { + t.Fatalf("oversized Update did not apply: c -> %v, want {1}", got) + } +} + +// TestBackpressure_DeletesNeverBlock: a delete (0 keywords) acquires nothing, so it never blocks even +// when the budget is fully held by parked applies. +func TestBackpressure_DeletesNeverBlock(t *testing.T) { + const cap = 1 + s, tid := newBackpressureStore(t, Options{CapBytes: 1 << 20, MaxInflightPostings: cap}) + + // Park one 1-keyword apply on the gate so the budget is fully consumed. + release := make(chan struct{}) + entered := make(chan struct{}, 1) + applyGate = func() { + select { + case entered <- struct{}{}: + default: + } + <-release + } + // Drain BEFORE clearing the global: close release so the parked apply unblocks and finishes, then + // RunFunc blocks until every prior-enqueued closure (each of which reads applyGate) has run, so the + // last `if applyGate != nil` read happens-before this nil write (no race vs the next test's write). + t.Cleanup(func() { + close(release) + s.q.RunFunc(func() error { return nil }) + applyGate = nil + }) + + go s.Update(tid, 1, []string{"alpha"}) + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("the parked apply never entered the gate") + } + + // A delete (0 keywords) acquires 0 tokens and must NOT block on the full budget. + deleted := make(chan struct{}) + go func() { + defer close(deleted) + s.Update(tid, 2, nil) // 0 keywords -> delete + }() + select { + case <-deleted: + case <-time.After(5 * time.Second): + t.Fatal("a delete (0 keywords) blocked on the budget; deletes must never block") + } +} + +// TestBackpressure_ApplyBatchDoesNotReferenceBudget is the static guard from Step 6: the acquire MUST +// be on the PRODUCER (Update/Commit), never inside applyBatch. If applyBatch ever references s.budget, +// the acquire moved onto the worker (it would self-deadlock or defeat the bound). Parse update.go and +// assert applyBatch's body has no `budget` selector. +func TestBackpressure_ApplyBatchDoesNotReferenceBudget(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "update.go", nil, 0) + if err != nil { + t.Fatal(err) + } + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != "applyBatch" { + continue + } + ast.Inspect(fn, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + if sel.Sel.Name == "budget" { + t.Fatalf("applyBatch references s.budget — the backpressure acquire must be on the producer, not the worker") + } + return true + }) + } +} diff --git a/core/invertedstore/block_index_test.go b/core/invertedstore/block_index_test.go new file mode 100644 index 0000000..017cf69 --- /dev/null +++ b/core/invertedstore/block_index_test.go @@ -0,0 +1,107 @@ +package invertedstore + +import ( + "bytes" + "encoding/binary" + "path/filepath" + "testing" +) + +// block_index_test.go — the dedicated block-index-integrity guard for C.2 (per-cursor decompress +// reuse). The differential hits-test does NOT cover this class: a corrupt idx[i].firstKey that is +// too LOW only makes scanPrefix start its sort.Search one block early, so every key is still found +// (the hits stay correct) while the persisted block index is silently wrong. This test reads each +// block's TRUE first record key off disk and asserts it byte-equals the persisted idx[i].firstKey — +// the only thing that catches a reused-block buffer clobbering a retained firstKey. + +// blockFirstKey decodes the first record's key from data block i (the on-disk truth: the first +// uvarint(klen) key of the decompressed block), independent of seg.idx[i].firstKey. +func blockFirstKey(seg *segment, i int) []byte { + blk := seg.blockBytes(i) + kl, n := binary.Uvarint(blk) + return append([]byte(nil), blk[n:n+int(kl)]...) +} + +// assertBlockIndexIntact checks that for EVERY data block i, the persisted index first-key exactly +// equals block i's true first record key. A C.2 per-cursor decompress reuse without the blkFirst copy +// corrupts this (a reused source block overwrites a writer firstKey still pointing into it). +func assertBlockIndexIntact(t *testing.T, seg *segment) { + t.Helper() + if len(seg.idx) == 0 { + t.Fatalf("segment %d has no data blocks; the test must force multiple blocks", seg.id) + } + for i := range seg.idx { + want := blockFirstKey(seg, i) + got := seg.idx[i].firstKey + if !bytes.Equal(got, want) { + t.Fatalf("seg %d block %d: persisted idx.firstKey %x != block's true first key %x", + seg.id, i, got, want) + } + } +} + +// TestMerge_BlockIndexFirstKeysIntact builds several L0 segments, each spanning MANY data blocks (so +// merge cursors cross block boundaries repeatedly), runs a tiered merge, and then asserts — on BOTH +// the live merged segment AND a fresh reopen from disk — that every block-index first-key equals the +// real first record key of its block. This is the genuine red→green discriminator for C.2: with a +// per-cursor block-buffer reuse but NO `w.blkFirst = append([]byte(nil), key...)` copy, a cursor's +// advance() that crosses a block boundary overwrites the buffer the retained writer firstKey still +// aliases, corrupting the persisted index — while the differential hits-test stays GREEN. +func TestMerge_BlockIndexFirstKeysIntact(t *testing.T) { + // Tiny BlockTarget forces many data blocks per segment (both sources and the merged output), so the + // merge crosses cursor block boundaries while a writer block is still open (the aliasing window). + s, tbl := newMergeStoreOpts(t, Options{Fanout: 3, BlockTarget: 64, DictChunkBytes: 64}) + defer s.CloseAndWait() + + // Three L0 segments of distinct keywords. Many keywords per segment => many blocks per source. + // Disjoint keyword ranges per segment keep ordering simple while still interleaving across cursors + // at merge time (segment B's "b*" keys sort between A's and C's, etc., is NOT required — what + // matters is each cursor advances across several of its own blocks during the merge). + want := map[string]int64{} // keyword -> docid ground truth, for the differential net + seal := func(prefix string, base int64, n int) { + for i := 0; i < n; i++ { + kw := kwf(prefix, i) + docid := base + int64(i) + s.addPostingForTest(tbl, kw, docid) + want[kw] = docid + } + s.forceSpill(tbl) + } + seal("a", 1000, 40) + seal("b", 2000, 40) + seal("c", 3000, 40) + + if len(s.segs) != 3 { + t.Fatalf("want 3 L0 segments before merge, got %d", len(s.segs)) + } + + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge with 3 L0 segments at Fanout 3") + } + if len(s.segs) != 1 { + t.Fatalf("after merging 3 L0 segments want 1 segment, got %d", len(s.segs)) + } + merged := s.segs[0] + if len(merged.idx) < 3 { + t.Fatalf("merged segment has only %d blocks; the test needs several to exercise the aliasing window", len(merged.idx)) + } + + // (1) The block index of the LIVE merged segment must be intact. + assertBlockIndexIntact(t, merged) + + // (2) The block index PERSISTED to disk must be intact (reopen from the file: this is what every + // future Open reads). Independent of the in-memory handle. + reopened := openSegment(filepath.Join(s.dir, segFileName(merged.id))) + defer reopened.close() + assertBlockIndexIntact(t, reopened) + + // (3) DIFFERENTIAL NET (the test that MISSES the corruption): every keyword still resolves to its + // docid. This must stay GREEN even with a corrupt-but-too-low firstKey, proving the integrity + // assertions above — not this — are what discriminate C.2. + for kw, docid := range want { + r := s.Search(tbl, kw, 0, nil) + if !hasDoc(r, docid) { + t.Fatalf("differential: keyword %q lost docid %d after merge: %v", kw, docid, r.DocIds) + } + } +} diff --git a/core/invertedstore/codec.go b/core/invertedstore/codec.go new file mode 100644 index 0000000..146e2fd --- /dev/null +++ b/core/invertedstore/codec.go @@ -0,0 +1,88 @@ +package invertedstore + +import ( + "github.com/golang/snappy" + "github.com/klauspost/compress/zstd" +) + +const ( + codecNone = byte(0) + codecSnappy = byte(1) + codecZstd = byte(2) +) + +type codec struct { + id byte + enc *zstd.Encoder + dec *zstd.Decoder +} + +func newCodec(id byte) *codec { + c := &codec{id: id} + if id == codecZstd { + c.enc, _ = zstd.NewWriter(nil, + zstd.WithEncoderLevel(zstd.SpeedFastest), + zstd.WithEncoderConcurrency(1), + zstd.WithWindowSize(128*1024)) + c.dec, _ = zstd.NewReader(nil, zstd.WithDecoderConcurrency(1)) + } + return c +} + +func (c *codec) compress(src []byte) []byte { + switch c.id { + case codecSnappy: + return snappy.Encode(nil, src) + case codecZstd: + return c.enc.EncodeAll(src, nil) + default: + return append([]byte(nil), src...) + } +} + +// onDecompress, when non-nil, is invoked at the start of every codec.decompress. Test-only (F0): a +// test counts data-block decompressions DURING finish() — the genuine red→green discriminator (old +// writeTermDict re-reads+decompresses each [I] block; the inline build decompresses none) that a +// byte-identical oracle cannot provide. nil in production (one predictable branch). A test that +// installs it MUST NOT t.Parallel (same constraint as the merge observers). +var onDecompress func() + +func (c *codec) decompress(src []byte, rawLen int) []byte { + return c.decompressInto(make([]byte, 0, rawLen), src, rawLen) +} + +// decompressInto decompresses src into a buffer reusing dst's backing array when it has room for the +// rawLen decompressed bytes (C.2: the mergeCursor hands its previous block buffer so a k-way merge +// over K sources allocates O(K) block buffers, not one per block). dst may be nil. The returned slice +// may alias dst's storage, so a caller that REUSES the same dst across calls MUST NOT retain bytes +// from a previous decompressInto into it (the mergeCursor's blkFirst-copy in segment.go addEntry +// enforces this for the writer; readers that retain bytes already copy out). rawLen is the +// decompressed length; the buffer is sized to it up front so snappy/zstd reuse it in place. +func (c *codec) decompressInto(dst, src []byte, rawLen int) []byte { + if onDecompress != nil { + onDecompress() + } + // Present a zero-length slice with rawLen capacity so the decoders reuse dst in place (snappy's + // Decode reuses only when len(dst) >= decodedLen, zstd's DecodeAll appends into dst[:0]). + if cap(dst) >= rawLen { + dst = dst[:0] + } else { + dst = make([]byte, 0, rawLen) + } + switch c.id { + case codecSnappy: + d, err := snappy.Decode(dst[:rawLen], src) + if err != nil { + panic(err) + } + return d + case codecZstd: + d, err := c.dec.DecodeAll(src, dst) + if err != nil { + panic(err) + } + return d + default: + return append(dst, src...) + } +} diff --git a/core/invertedstore/codec_test.go b/core/invertedstore/codec_test.go new file mode 100644 index 0000000..4280726 --- /dev/null +++ b/core/invertedstore/codec_test.go @@ -0,0 +1,30 @@ +package invertedstore + +import ( + "bytes" + "testing" +) + +func TestCodecRoundTrip(t *testing.T) { + payload := bytes.Repeat([]byte("the quick brown fox 0123456789 "), 2000) // compressible + for _, id := range []byte{codecNone, codecSnappy, codecZstd} { + c := newCodec(id) + comp := c.compress(payload) + got := c.decompress(comp, len(payload)) + if !bytes.Equal(got, payload) { + t.Fatalf("codec %d round-trip mismatch", id) + } + if id != codecNone && len(comp) >= len(payload) { + t.Fatalf("codec %d did not compress (%d >= %d)", id, len(comp), len(payload)) + } + } +} + +func TestZstdBounded(t *testing.T) { + // zstd must be bounded (concurrency 1, small window) so it can't blow memory. + c := newCodec(codecZstd) + if c.enc == nil || c.dec == nil { + t.Fatal("zstd codec must hold a bounded encoder+decoder") + } + _ = c.decompress(c.compress([]byte("x")), 1) // smoke +} diff --git a/core/invertedstore/concurrency.go b/core/invertedstore/concurrency.go new file mode 100644 index 0000000..1c02447 --- /dev/null +++ b/core/invertedstore/concurrency.go @@ -0,0 +1,298 @@ +package invertedstore + +// concurrency.go — P9 (design §6 Concurrency model; task T8). +// +// Harden the Store for one-writer / many-reader. The pieces this file adds: +// +// - segSnapshot + atomic.Pointer[segSnapshot]: the live segment set is PUBLISHED via an +// atomic pointer that the worker swaps on every seal/merge/table change (spill, installMerge). +// Search/GetDocs/forwardKeywords load that pointer ONCE per call to get a consistent set, +// so a reader never observes a half-applied swap (some-old + some-new segments). +// +// - per-segment refcount + deferred deletion: a merge swaps the MANIFEST to DROP input segments, +// but a reader may be mid-scan on one. Each segment carries an atomic refcount; the published +// snapshot holds ONE ref per live segment, and a reader that acquires a snapshot bumps an extra +// ref on each of its segments for the duration of the scan. installMerge marks a retired segment +// and drops its published ref; the file is close()d + os.Remove()d only when the refcount reaches +// zero — i.e. only after the last in-flight reader that still references it has finished. POSIX +// keeps an already-open fd valid across unlink, so in-flight reads complete with no use-after-free. +// +// - the acquire/swap handoff is serialized by the Store RWMutex (already guarding the head): a +// reader acquires its extra refs under s.mu.RLock(); the worker swaps + releases under +// s.mu.Lock(). That closes the load-then-incref window (a reader either takes its ref before the +// worker can drop the published ref, OR observes the already-swapped new snapshot) WITHOUT a +// reader ever blocking on the writer's I/O — the lock is held only for the O(1) pointer swap, ref +// bookkeeping, and a cheap in-memory MANIFEST marshal, NEVER for the slow MANIFEST fsync or any +// segment file I/O (spill/installMerge do the two fsyncs via writeManifestBytes OUTSIDE the lock; +// design §6 "Locks only for the brief mutation"; "no Search blocks on a writer"). +// +// The chunk-LRU is already its own mutex-guarded structure (dictcache.go) and is purged of a retired +// segment's entries on the swap (installMerge), so the cache never serves bytes for a gone segment. +// +// - background merger on its OWN goroutine (design §6 "Background merger (own goroutine, off the +// critical path)"). The pre-P9 P8 shortcut re-enqueued maybeMerge onto the SAME mpsc worker via +// s.q.AddFunc from inside a worker task (spill). That self-enqueue DEADLOCKS the moment the queue +// fills: the worker blocks sending to its own queue while it is the only consumer (observed: a +// race-stress run wedged with the worker parked in AddFunc's chan-send). P9 moves the merger to a +// dedicated goroutine triggered by a NON-BLOCKING signal: the worker never sends to its own queue; +// the merge goroutine drives merges back onto the worker via RunFunc (a DIFFERENT goroutine, so a +// blocking wait on the worker is safe and progresses), preserving the single-mutator invariant. + +import ( + "os" +) + +// segSnapshot is an immutable, atomically-published view of the live sealed segment set, OLDEST -> +// NEWEST by seal-sequence id (Search scans it in reverse for newest-wins). It is replaced wholesale +// on every change; a reader loads the pointer once and holds that exact slice for its whole call. +type segSnapshot struct { + segs []*segment +} + +// emptySnapshot is the initial published value (no segments) so Search can Load() without a nil check. +var emptySnapshot = &segSnapshot{} + +// publishSnapshotLocked installs s.segs as the new live segment set. The caller MUST hold s.mu (it +// is the worker mutating the segment set) and MUST have already taken the published ref on every NEW +// segment and arranged to retire (drop the published ref on) every REMOVED segment. It only swaps the +// atomic pointer from a private copy of s.segs; ref bookkeeping is the caller's (spill increfs the new +// seg; installMerge increfs the merged output and retires the inputs). Search loads this pointer once. +func (s *Store) publishSnapshotLocked() { + cp := make([]*segment, len(s.segs)) + copy(cp, s.segs) + s.snap.Store(&segSnapshot{segs: cp}) +} + +// acquireSnapshot loads the published segment set and bumps a reader ref on each of its segments, so +// none can be torn down (close + unlink) while this reader is mid-scan — even if a concurrent merge +// retires it. It takes s.mu.RLock() so the load + the incref loop cannot interleave with the worker's +// swap-then-retire (which holds s.mu.Lock()): the reader either takes its refs on the pre-swap +// segments before the worker drops their published ref, or it loads the post-swap snapshot. The +// caller MUST releaseSnapshot the returned slice when done (release decrefs + tears down at 0). +func (s *Store) acquireSnapshot() []*segment { + s.mu.RLock() + segs := s.acquireSnapshotLocked() + s.mu.RUnlock() + return segs +} + +// acquireSnapshotLocked is acquireSnapshot for a caller that ALREADY holds s.mu.RLock() — it loads +// the published set and increfs each segment. Search uses it so the head copy and the segment-ref +// acquisition happen in one RLock window (a single consistent point). The caller still owns the +// returned slice and MUST releaseSnapshot it. The incref must be inside the RLock so it cannot race +// the worker's retire (which decrefs under s.mu.Lock()). +func (s *Store) acquireSnapshotLocked() []*segment { + snap := s.snap.Load() + segs := snap.segs + for _, seg := range segs { + seg.refs.Add(1) + } + return segs +} + +// releaseSnapshot drops the reader ref this scan held on each segment. A segment whose refcount +// reaches zero AND that has been retired by a merge is torn down here (close fd + unlink file) — so a +// merged-away file outlives every in-flight reader and is removed only once the last one is done. +func (s *Store) releaseSnapshot(segs []*segment) { + for _, seg := range segs { + seg.release() + } +} + +// release drops one ref on the segment. When the count reaches zero and the segment has been retired +// (a merge dropped it from the live set), it is closed and its file unlinked — the deferred deletion +// that makes a merged-away segment safe to read until the last reader finishes. A still-live segment +// (refs never hits zero while it is published, since the published ref keeps it >= 1) is never torn +// down here. The retired flag is set by the worker BEFORE it drops the published ref, so the worker's +// own drop-to-zero (no readers) tears the segment down immediately, and a reader's drop-to-zero (the +// worker dropped the published ref first, the reader was the last holder) tears it down then. +func (seg *segment) release() { + if seg.refs.Add(-1) == 0 && seg.retired.Load() { + seg.teardown() + } +} + +// retire marks the segment as merged-away and drops its PUBLISHED ref. If no reader currently holds +// it (refcount falls to zero) it is torn down immediately; otherwise the last reader's release tears +// it down. MUST be called by the worker AFTER the new snapshot (without this segment) is published, +// so a reader that loaded the old snapshot has already taken its own ref under the RLock. Setting +// retired before the decref ensures release() observes it. +func (seg *segment) retire() { + seg.retired.Store(true) + if seg.refs.Add(-1) == 0 { + seg.teardown() + } +} + +// retireKeepFile is retire for a CLEAN CLOSE: it drops the published ref and tears the segment down +// when the last reader releases, but teardown only CLOSES the fd — it does NOT unlink the file (the +// segment is still live in the on-disk MANIFEST and must survive for the next Open). It is the same +// deferred path retire() uses, so a Search in flight during CloseAndWait that holds a ref keeps the +// fd valid until it releases — no use-after-free on a closed fd (P9/T8). Setting keepFile + retired +// before the decref ensures teardown() (which may run on this drop or a later reader's release) +// observes both. +func (seg *segment) retireKeepFile() { + seg.keepFile.Store(true) + seg.retired.Store(true) + if seg.refs.Add(-1) == 0 { + seg.teardown() + } +} + +// teardown closes the segment's fd and (unless keepFile is set) unlinks its file. Idempotent via the +// atomic done flag so a concurrent reader-release and worker-retire racing to zero only tear down +// once. Called only when the refcount has reached zero on a retired segment, so no one can read it +// afterwards. keepFile (a clean-close retire) keeps the file so the next Open still finds it. +func (seg *segment) teardown() { + if seg.tornDown.CompareAndSwap(false, true) { + seg.close() + if !seg.keepFile.Load() { + os.Remove(seg.path) + } + } +} + +// ---- background merge scheduler (design §6 "own goroutine") ----------------- +// +// The merge goroutine decouples merge SCHEDULING from the mpsc worker so the worker never blocks +// sending to its own queue. A spill (or DeleteTable) raises a non-blocking trigger; the goroutine +// coalesces triggers (a buffered-1 channel + a "force covering" atomic) and, for each, runs the merge +// passes ON the worker via q.RunFunc — serialized with applies/spills so the segment set keeps a +// single mutator, but driven from a separate goroutine so the RunFunc wait can never self-deadlock. +// +// Quiescence (for tests) is tracked with two monotonic counters: triggerMerge bumps mergeReqSeq; the +// loop, after each completed pass, stores the reqSeq it observed into mergeAckSeq. waitMergeIdle waits +// for mergeAckSeq >= the mergeReqSeq it sampled, which a coalesced follow-up pass always reaches — no +// "consumed the signal but not yet marked busy" window to fall through. + +// startMergeLoop launches the background merge goroutine (idempotent; only when AutoMerge is on). It +// is started in Open and stopped in CloseAndWait. +func (s *Store) startMergeLoop() { + if !s.opts.AutoMerge { + return + } + s.mergeSignal = make(chan struct{}, 1) + s.mergeStop = make(chan struct{}) + s.mergeDone = make(chan struct{}) + go s.mergeLoop() +} + +// mergeLoop is the background merge goroutine. It waits for a trigger, then runs the scheduled tiered +// (and, if a DeleteTable forced it, covering) merge passes — each as plan (worker) -> compute +// (off-worker, on THIS goroutine) -> install (worker). It exits on mergeStop after a final drain so a +// merge in flight at Close completes. Errors from a merge are dropped: a background merge failing must +// not crash the process (it runs off any caller), and the live set is left consistent by installMerge's +// persist-then-publish on every failure path. +func (s *Store) mergeLoop() { + defer close(s.mergeDone) + for { + select { + case <-s.mergeStop: + s.drainMerge() + return + case <-s.mergeSignal: + s.runScheduledMerge() + } + } +} + +// runScheduledMerge executes the tiered + covering merge passes. It snapshots the current request +// sequence FIRST, runs the passes, then publishes that sequence as acked — so a waitMergeIdle that +// sampled any reqSeq <= the snapshot sees it satisfied. forceCovering (set by DeleteTable) guarantees +// a covering merge even when the dead-fraction trigger would not fire. Each pass is driven as +// plan (worker) -> compute (off-worker) -> install (worker): only the plan selection + the install +// swap touch shared state ON the worker (single-mutator invariant preserved, exactly one MANIFEST +// writer); the heavy mergeSegments compute runs HERE on the merge goroutine, so it never blocks the +// worker behind it (A). +func (s *Store) runScheduledMerge() { + req := s.mergeReqSeq.Load() + force := s.forceCovering.Swap(false) + // Tiered passes: plan (worker) -> compute (off-worker) -> install (worker), until no level qualifies. + for { + var plan *mergePlan + _ = s.q.RunFunc(func() error { plan = s.selectTieredMergePlan(); return nil }) + if plan == nil { + break + } + // Break the pass on an install failure. installMerge rolls s.man back to the pre-merge set, so a + // persistent failure (disk full, MANIFEST.tmp unwritable) leaves the SAME level still >= Fanout; + // re-looping would immediately re-select it and re-run the heavy mergeSegments compute forever (a + // hot CPU/IO livelock). Giving up the pass restores the pre-off-worker "drop the pass; the next + // trigger retries" semantics — a background merge error is deliberately dropped here, and the + // final ackSeq.Store below still lets waitMergeIdle converge (the trigger was processed, even if + // the merge could not be installed). A transient failure is simply retried by the next trigger. + if err := s.runMergePlan(plan); err != nil { + break + } + } + // One covering pass if forced (DeleteTable) or the dead fraction crosses. A single covering pass is + // one-shot (no loop), so an install failure here cannot livelock — drop the error (a background + // covering merge that could not install is retried by the next trigger), exactly as the loop above. + var cplan *mergePlan + _ = s.q.RunFunc(func() error { cplan = s.selectCoveringMergePlan(force); return nil }) + if cplan != nil { + _ = s.runMergePlan(cplan) + } + s.mergeAckSeq.Store(req) +} + +// drainMerge runs one final scheduled merge if a trigger is pending at stop, so a spill that signaled +// right before Close is not silently dropped (the head is already flushed by CloseAndWait's spill; +// this collapses any segments that spill produced). Non-blocking: only if a signal is queued. +func (s *Store) drainMerge() { + select { + case <-s.mergeSignal: + s.runScheduledMerge() + default: + } +} + +// triggerMerge raises a non-blocking merge trigger. Called from the worker (spill) or any goroutine +// (DeleteTable). It NEVER blocks: it bumps the request sequence and does a coalescing non-blocking +// send (if a trigger is already pending the new one is folded in — the next pass re-reads the live +// segment set, so one pass covers all spills since the last). This is the fix for the worker +// self-enqueue deadlock — the worker raises a flag instead of sending a task to its own queue. +// covering=true also sets forceCovering so the scheduled pass runs a covering merge. +func (s *Store) triggerMerge(covering bool) { + if !s.opts.AutoMerge { + return + } + if covering { + s.forceCovering.Store(true) + } + s.mergeReqSeq.Add(1) + select { + case s.mergeSignal <- struct{}{}: + default: // a trigger is already pending; coalesce (the next pass sees the latest reqSeq + state) + } +} + +// stopMergeLoop signals the merge goroutine to drain and exit, and waits for it. Idempotent. Called by +// CloseAndWait AFTER the final head spill (so the drain collapses the just-spilled segments) but +// BEFORE the segment fds are closed (so a merge in flight does not read a closed fd). +func (s *Store) stopMergeLoop() { + if !s.opts.AutoMerge || s.mergeStop == nil { + return + } + close(s.mergeStop) + <-s.mergeDone +} + +// waitMergeIdle blocks until every merge trigger raised so far has been processed (mergeAckSeq has +// caught up to the mergeReqSeq sampled here AND no signal is still pending). Test-only — it lets a +// test deterministically wait for AutoMerge to settle instead of sleeping. It terminates because +// spills (the only trigger source) have stopped before a test calls this, so reqSeq is stable and a +// coalesced pass drives ackSeq up to it. +func (s *Store) waitMergeIdle() { + if !s.opts.AutoMerge { + return + } + target := s.mergeReqSeq.Load() + for s.mergeAckSeq.Load() < target || len(s.mergeSignal) != 0 { + target = s.mergeReqSeq.Load() + // Bounce off the worker (the merge executor) to give an in-flight pass room to finish, then + // re-check. A no-op RunFunc returns only after every earlier-enqueued worker task — including a + // merge pass's RunFunc — has run, so this both yields and synchronizes. + _ = s.q.RunFunc(func() error { return nil }) + } +} diff --git a/core/invertedstore/concurrency_test.go b/core/invertedstore/concurrency_test.go new file mode 100644 index 0000000..6acd27d --- /dev/null +++ b/core/invertedstore/concurrency_test.go @@ -0,0 +1,367 @@ +package invertedstore + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codetrek/haystack/core/queue" +) + +// concurrency_test.go — P9 (design §6 Concurrency model; task T8) acceptance tests. +// +// The MUST-PASS cases this file proves: +// +// - go test -race is clean under concurrent Search + Update + (forced) merge; +// - a reader mid-scan on a segment being merged away COMPLETES with no use-after-free (the +// merged-away file is unlinked only after the last reader of it releases its ref); +// - no Search blocks on a writer (Search returns while a long worker task is in flight). +// +// These are exactly the hazards the pre-P9 immediate-deletion path had: installMerge used to close + +// unlink an input segment's fd the instant the MANIFEST swapped, so a reader that had copied the seg +// slice and was still mid-scan read a closed fd and panicked in mustReadAt. The refcount + deferred +// deletion (concurrency.go) fixes that; these tests would panic / hang against the old code. + +// newConcStore opens a store with the given options + one table for the concurrency tests. +func newConcStore(t *testing.T, opts Options) (*Store, int) { + t.Helper() + dir := t.TempDir() + q := queue.NewMpsc("invconc") + q.Start() + s, err := Open(dir, q, opts) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + return s, tbl +} + +// segFileExists reports whether the on-disk segment file for id still exists in the store dir. +func (s *Store) segFileExists(id uint64) bool { + _, err := os.Stat(filepath.Join(s.dir, segFileName(id))) + return err == nil +} + +// --- 1. Deferred deletion: a reader mid-scan on a merged-away segment is safe --- + +// TestConcurrency_ReaderMidScanSurvivesMerge is the deterministic "no use-after-free" proof. A reader +// acquires the live snapshot (taking a ref on every segment) and is therefore "mid-scan". A covering +// merge then runs on the worker and retires (merges away) those exact segments. Because the reader +// still holds refs, the merged-away files MUST NOT be unlinked yet, and the reader MUST still read +// correct data from them (the open fd stays valid). Only after the reader releases its snapshot are +// the files unlinked. Against the pre-P9 immediate-unlink path this reader would read a closed fd and +// panic in mustReadAt. +func TestConcurrency_ReaderMidScanSurvivesMerge(t *testing.T) { + // High Fanout so only the explicit covering merge fires (no incidental tiered merge mid-test). + s, tbl := newConcStore(t, Options{Fanout: 100}) + defer s.CloseAndWait() + + // Two sealed segments, each a distinct doc under keyword "alpha". + s.applyForTest(tbl, 10, []string{"alpha"}) + s.forceSpill(tbl) + s.applyForTest(tbl, 20, []string{"alpha"}) + s.forceSpill(tbl) + if len(s.segs) != 2 { + t.Fatalf("expected 2 segments before merge, got %d", len(s.segs)) + } + inputIds := []uint64{s.segs[0].id, s.segs[1].id} + + // A reader acquires the snapshot and holds it (== mid-scan): refs on both input segments are up. + held := s.acquireSnapshot() + if len(held) != 2 { + t.Fatalf("snapshot should hold 2 segments, got %d", len(held)) + } + + // The worker runs a covering merge that retires both inputs into one new segment. + s.coveringMergeForTest(t) + if len(s.segs) != 1 { + t.Fatalf("covering merge must compact to 1 segment, got %d", len(s.segs)) + } + + // DEFERRED DELETION: the inputs are merged away from the live set, but the reader still holds + // refs, so their files MUST still exist (POSIX: an open fd survives unlink anyway, but the design + // defers the unlink itself to refcount 0). + for _, id := range inputIds { + if !s.segFileExists(id) { + t.Fatalf("merged-away segment %d unlinked while a reader still holds it (use-after-free)", id) + } + } + + // The held reader scans the retired segments — this is the "mid-scan completes" case. It must read + // the original docids with no panic (the fds are alive). Against pre-P9 (immediate close+unlink) + // this scanPrefix would panic on a closed fd. + lo := invertedKey(uint32(tbl), "alpha") + hi := prefixUpper(lo) + got := map[int64]bool{} + for _, seg := range held { + seg.scanPrefix(lo, hi, func(_ []byte, v []byte) { + ab, _ := splitInvertedValue(v) + decodeDocs(ab, func(d int64) { got[d] = true }) + }) + } + if !got[10] || !got[20] { + t.Fatalf("reader mid-scan on retired segments got %v, want {10,20}", got) + } + + // Release the snapshot: now the last ref drops and the deferred deletion fires. + s.releaseSnapshot(held) + for _, id := range inputIds { + if s.segFileExists(id) { + t.Fatalf("merged-away segment %d NOT unlinked after the last reader released it", id) + } + } + + // The live merged segment still serves the union through the normal Search path. + r := s.Search(tbl, "alpha", 0, nil) + if !hasDoc(r, 10) || !hasDoc(r, 20) { + t.Fatalf("post-merge Search got %v, want both 10 and 20", r.DocIds) + } +} + +// --- 2. -race clean under concurrent Search + Update + merge ------------------ + +// TestConcurrency_SearchUpdateMergeRaceClean drives many concurrent Searchers + Updaters while the +// worker forces merges (AutoMerge on, small Fanout, tiny CapBytes so spills + merges churn). It is +// the race-detector gate: the test passes if it completes with no race, no panic (a use-after-free on +// a merged-away fd would panic in mustReadAt), and the final state is correct after the writers drain. +func TestConcurrency_SearchUpdateMergeRaceClean(t *testing.T) { + // Tiny cap + small fanout + AutoMerge so the worker spills and merges constantly under load — the + // background merger races real concurrent Search/Update calls, which is the whole point of T8. + s, tbl := newConcStore(t, Options{CapBytes: 1 << 12, Fanout: 2, AutoMerge: true}) + defer s.CloseAndWait() + + const nDocs = 400 + const nReaders = 8 + const dur = 700 * time.Millisecond + + // Baseline the machinery counters so we can assert spills + merges ACTUALLY fired during the + // churn (not just that the final state is correct). NextSegId advances once per sealed segment; + // mergeAckSeq advances once per completed background merge pass. If a future refactor silently + // disabled spills/merges, the final-state assertion alone would still pass while testing nothing + // of the merge/deferred-deletion path under concurrency — the very thing T8 is about. + s.mu.RLock() + startSegId := s.man.NextSegId + s.mu.RUnlock() + startMergeAck := s.mergeAckSeq.Load() + + var stop atomic.Bool + var wg sync.WaitGroup + + // Writers: continuously Update docs (full keyword sets that overlap so postings churn + tombstone). + for w := 0; w < 3; w++ { + wg.Add(1) + go func(base int) { + defer wg.Done() + for i := 0; !stop.Load(); i++ { + d := int64((base*nDocs + i) % nDocs) + kw := []string{ + "alpha", + fmt.Sprintf("doc%d", d%17), + fmt.Sprintf("w%d", base), + } + s.Update(tbl, d, kw) + } + }(w) + } + + // Readers: continuously Search the shared "alpha" prefix + a per-doc keyword. Must never panic on + // a merged-away segment and must always return a (possibly partial) consistent result. + for r := 0; r < nReaders; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for !stop.Load() { + _ = s.Search(tbl, "alpha", 0, nil) + _ = s.Search(tbl, "doc", 0, nil) + _ = s.GetDocs(tbl, "alpha") + } + }() + } + + time.Sleep(dur) + stop.Store(true) + wg.Wait() + + // Drain all queued async Updates, then assert the final state is sane: every doc that was last + // written with "alpha" is searchable. We re-Update a known set so the final state is deterministic. + for d := int64(0); d < nDocs; d++ { + s.Update(tbl, d, []string{"alpha", fmt.Sprintf("final%d", d)}) + } + s.sync() // drain the worker so every async Update (and the spills they triggered) has run + s.waitMergeIdle() // let the background merger settle (it runs on its own goroutine now, P9) + + r := s.Search(tbl, "alpha", 0, nil) + for d := int64(0); d < nDocs; d++ { + if !hasDoc(r, d) { + t.Fatalf("after concurrent churn + final re-Update, doc %d missing from alpha search", d) + } + } + + // Assert the churn actually drove the machinery T8 hardens: at least one segment was sealed + // (spills fired) AND at least one background merge pass acked (the merger ran concurrently with + // the readers). Without these, the race test could pass green while exercising none of the + // concurrent merge / deferred-deletion path. + s.mu.RLock() + endSegId := s.man.NextSegId + s.mu.RUnlock() + if endSegId <= startSegId { + t.Fatalf("expected spills to seal segments during the churn (NextSegId %d -> %d)", startSegId, endSegId) + } + if s.mergeAckSeq.Load() <= startMergeAck { + t.Fatalf("expected the background merger to run a pass during the churn (mergeAckSeq %d -> %d)", startMergeAck, s.mergeAckSeq.Load()) + } +} + +// --- 3. No Search blocks on a writer's I/O ------------------------------------ + +// TestConcurrency_SearchDoesNotBlockOnWriter proves the design §6 invariant that a writer holds s.mu +// ONLY for the O(1) snapshot swap, NEVER across its slow MANIFEST fsync — so a concurrent Search does +// not block on the writer's I/O. It drives a REAL spill (the actual writer path) and wedges it INSIDE +// writeManifestBytes (via the beforeManifestFsync test hook, simulating a slow fsync). At that moment +// the spill is mid-I/O. We then assert a concurrent Search returns promptly. If the writer held s.mu +// across writeManifestBytes (the pre-fix bug), Search's acquireSnapshotLocked() RLock would block for +// the whole fsync and this test would time out. The hook holds NO lock, so the test fails ONLY if the +// CODE holds the lock across I/O — exactly the invariant under test. +func TestConcurrency_SearchDoesNotBlockOnWriter(t *testing.T) { + s, tbl := newConcStore(t, Options{Fanout: 100}) + defer s.CloseAndWait() + + // Seed a sealed segment so Search has something to scan (and so its RLock acquires real refs). + s.applyForTest(tbl, 1, []string{"alpha"}) + s.forceSpill(tbl) + + // Buffer a second doc in the head, then force a spill whose MANIFEST fsync we wedge mid-flight. + s.applyForTest(tbl, 2, []string{"alpha"}) + + atFsync := make(chan struct{}) + releaseFsync := make(chan struct{}) + beforeManifestFsync = func() { + close(atFsync) + <-releaseFsync // hold the spill INSIDE writeManifestBytes (mid-I/O), holding NO lock + } + defer func() { beforeManifestFsync = nil }() + + // Drive the real spill on the worker; it will block in the fsync hook (so RunFunc would not return + // until we release). Run it in a goroutine so the test can race a Search against the held fsync. + spillDone := make(chan struct{}) + go func() { + s.spillForTest(tbl) + close(spillDone) + }() + <-atFsync // the spill is now wedged mid-MANIFEST-fsync, holding no lock per the invariant + + // A Search MUST return promptly even though a writer is mid-fsync. With the fix (lock released + // across the fsync) the RLock is free; against the bug (lock held across the fsync) this blocks. + done := make(chan SearchResult, 1) + go func() { done <- s.Search(tbl, "alpha", 0, nil) }() + select { + case r := <-done: + // Doc 1 is in the sealed segment; doc 2 is still in the (un-spilled) head — either is fine, + // the point is the call RETURNED while the writer was mid-fsync. + if !hasDoc(r, 1) { + t.Fatalf("Search returned but missing the sealed doc 1: %v", r.DocIds) + } + case <-time.After(2 * time.Second): + close(releaseFsync) + <-spillDone + t.Fatal("Search blocked on a writer mid-MANIFEST-fsync (the lock is held across I/O)") + } + + // Let the spill's fsync complete and the worker drain. + close(releaseFsync) + <-spillDone + + // The just-spilled doc 2 is now visible too — the spill completed correctly after the fsync. + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) { + t.Fatalf("after the spill completed, alpha must contain both docs 1 and 2: %v", r.DocIds) + } +} + +// TestConcurrency_SearchSurvivesCloseRace proves Close honors the P9 refcount path: a Search in +// flight when CloseAndWait runs must NOT read a closed fd. Many readers loop Searching while Close +// drops the published segment set; with the refcount path each segment's fd is closed only after the +// last in-flight reader releases its ref, so no reader ever reads a closed fd (no panic in +// mustReadAt). Against the pre-fix force-close path (seg.close() inline under the lock) a reader +// mid-scan after RUnlock would read a closed fd and panic. Run under -race. +func TestConcurrency_SearchSurvivesCloseRace(t *testing.T) { + s, tbl := newConcStore(t, Options{Fanout: 100}) + + // A few sealed segments so a Search scans real fds across the Close. + for d := int64(1); d <= 5; d++ { + s.applyForTest(tbl, d, []string{"alpha"}) + s.forceSpill(tbl) + } + + var wg sync.WaitGroup + stop := make(chan struct{}) + for r := 0; r < 8; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + // A Search after Close returns empty (emptySnapshot / absent table) — that is fine; it + // must just never PANIC reading a closed fd. The refcount path guarantees that. + _ = s.Search(tbl, "alpha", 0, nil) + _ = s.GetDocs(tbl, "alpha") + } + }() + } + + // Let readers ramp, then Close concurrently with them. + time.Sleep(20 * time.Millisecond) + s.CloseAndWait() + close(stop) + wg.Wait() +} + +// --- 4. chunk-LRU entries are purged for a merged-away segment ---------------- + +// TestConcurrency_ChunkLRUPurgedOnMerge proves the chunk-LRU drops a retired segment's cached dict +// chunks on the MANIFEST swap (design §6: "entries for a merged-away segment are purged on the swap"). +// A forward read warms the LRU with a segment's dict chunk; after a covering merge retires that +// segment, the LRU must hold zero chunks for its id. +func TestConcurrency_ChunkLRUPurgedOnMerge(t *testing.T) { + s, tbl := newConcStore(t, Options{Fanout: 100}) + defer s.CloseAndWait() + + s.applyForTest(tbl, 1, []string{"alpha", "beta"}) + s.forceSpill(tbl) + s.applyForTest(tbl, 2, []string{"gamma"}) + s.forceSpill(tbl) + segIds := []uint64{s.segs[0].id, s.segs[1].id} + + // Warm the LRU: a forward read resolves doc 1's ordinals through seg 0's term-dict region, which + // reads + caches its dict chunk. forwardKeywords runs on the worker. + s.q.RunFunc(func() error { + _, _ = s.forwardKeywords(tbl, 1) + _, _ = s.forwardKeywords(tbl, 2) + return nil + }) + cachedBefore := 0 + for _, id := range segIds { + cachedBefore += s.dictCache.countForSeg(id) + } + if cachedBefore == 0 { + t.Fatal("expected the forward read to warm the chunk LRU, but it is empty") + } + + // Covering merge retires both segments; installMerge purges their LRU entries on the swap. + s.coveringMergeForTest(t) + for _, id := range segIds { + if n := s.dictCache.countForSeg(id); n != 0 { + t.Fatalf("chunk LRU still holds %d chunks for merged-away segment %d (not purged on swap)", n, id) + } + } +} diff --git a/core/invertedstore/crash_recovery_test.go b/core/invertedstore/crash_recovery_test.go new file mode 100644 index 0000000..f17cb76 --- /dev/null +++ b/core/invertedstore/crash_recovery_test.go @@ -0,0 +1,104 @@ +package invertedstore + +import "testing" + +// add→del→add on ONE docid in ONE batch exercises the in-batch `old` selection (the 2nd/3rd ops +// read `old` from inBatch state, not a forward read) — the delta path most prone to a double-count. +// Final distinct set is {a} → live 1; the Open recompute must agree. Spec §8.7. +func TestLiveByTable_InBatchAddDelAdd(t *testing.T) { + s, tid := newUpdateStore(t) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + bt := s.NewBatch() + bt.Update(tid, 1, []string{"a", "b", "c"}) + bt.Update(tid, 1, nil) // delete in the same batch + bt.Update(tid, 1, []string{"a"}) // re-add, distinct 1 + bt.Commit() + s.sync() + + if got := s.LiveByTableForTest()[tid]; got != 1 { + t.Fatalf("in-batch add->del->add live=%d want 1", got) + } + s.spillForTest(tid) + s.RecomputeLiveForTest() + if got := s.LiveByTableForTest()[tid]; got != 1 { + t.Fatalf("recompute after in-batch live=%d want 1", got) + } + s.assertCounterInvariantForTest(t) +} + +// Crash shape (a): head-only loss + indexer over-replay must not double-count live. Docs spilled +// before the crash are durable; docs only in the head are lost; the indexer re-Updates ALL docs from +// its cursor. live must equal the true distinct-pair count (re-Updating a durable doc nets Δ0 because +// its old set is read from the segment), not an inflated value. Spec §8.5(a) (the round-2 BLOCKER). +func TestCrashRecovery_HeadOnlyLoss_NoDoubleCount(t *testing.T) { + dir := t.TempDir() + s := openAt(t, dir, Options{AutoMerge: false}) + tid, err := s.CreateTable("t") + if err != nil { + t.Fatal(err) + } + for d := int64(1); d <= 10; d++ { + s.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s.sync() + s.spillForTest(tid) // docs 1-10 durable + for d := int64(11); d <= 20; d++ { + s.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s.sync() // docs 11-20 only in the head + s.dropHeadCloseSegmentsForTest() // crash: docs 11-20 lost + + s2 := openAt(t, dir, Options{AutoMerge: false}) + defer s2.CloseAndWait() // release the reopened store's segment fds (Windows RemoveAll) + for d := int64(1); d <= 20; d++ { // indexer over-replays ALL docs + s2.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s2.sync() + if got := s2.LiveByTableForTest()[tid]; got != 40 { + t.Fatalf("after crash+over-replay live=%d want 40 (20 docs * 2 distinct, no double-count)", got) + } + s2.assertCounterInvariantForTest(t) +} + +// Crash shape (b): part of a table durable, the rest lost with the head; over-replay. Verifies the +// durable docs' re-Update reads their durable forward (Δ0) while the lost docs are re-added once. +// Spec §8.5(b). +func TestCrashRecovery_PartiallyDurable_NoDoubleCount(t *testing.T) { + dir := t.TempDir() + s := openAt(t, dir, Options{AutoMerge: false}) + tid, err := s.CreateTable("t") + if err != nil { + t.Fatal(err) + } + // Two spilled batches (both durable), then a third batch left in the head (lost). + for d := int64(1); d <= 5; d++ { + s.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s.sync() + s.spillForTest(tid) + for d := int64(6); d <= 10; d++ { + s.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s.sync() + s.spillForTest(tid) + for d := int64(11); d <= 15; d++ { + s.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s.sync() // 11-15 only in head + s.dropHeadCloseSegmentsForTest() + + s2 := openAt(t, dir, Options{AutoMerge: false}) + defer s2.CloseAndWait() // release the reopened store's segment fds (Windows RemoveAll) + // recompute alone (before replay) sees the 10 durable docs. + if got := s2.LiveByTableForTest()[tid]; got != 20 { + t.Fatalf("post-crash recompute live=%d want 20 (10 durable docs * 2)", got) + } + for d := int64(1); d <= 15; d++ { + s2.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s2.sync() + if got := s2.LiveByTableForTest()[tid]; got != 30 { + t.Fatalf("after over-replay live=%d want 30 (15 docs * 2, no double-count of the 10 durable)", got) + } + s2.assertCounterInvariantForTest(t) +} diff --git a/core/invertedstore/dictcache.go b/core/invertedstore/dictcache.go new file mode 100644 index 0000000..faa88eb --- /dev/null +++ b/core/invertedstore/dictcache.go @@ -0,0 +1,253 @@ +package invertedstore + +import ( + "container/list" + "encoding/binary" + "sort" + "sync" +) + +// dictcache.go — P5 (design §8 resolution, §6 chunk LRU; task T3). +// +// The forward map is stored as segment-local term-ids (ordinals into the segment's own sorted +// inverted term dict, §8). Resolving a doc's ordinals back to keyword STRINGS — which the Update +// diff (P7) needs — reads the winning segment's compact term-dict region: ord -> chunk via a +// binary search on firstOrd, then decompress that ~4 KiB chunk and slice out the string. +// +// chunkLRU is the Store-level cache of those DECOMPRESSED dict chunks, keyed by +// (segmentId, chunkIdx) so the same hot chunk shared across docs (common terms, recently-edited +// files) stays resident under real editing locality. It is byte-budgeted (Options.ChunkCacheBytes, +// default 32 MiB) and mutex-guarded; entries of a merged-away segment are purged on a MANIFEST +// swap (P8 merge), so the cache never pins a segment that is gone. Search never touches it — it is +// read on the Update (forward) path only. + +// chunkCacheKey identifies one decompressed dict chunk by its OWNING segment id and chunk index. +// Keying on the stable seal-sequence id (not the *segment pointer) lets purge() drop a merged-away +// segment's chunks even after its handle is replaced, and keeps the key comparable for the map. +type chunkCacheKey struct { + segId uint64 + chunkIdx int +} + +// chunkCacheEntry is one cached decompressed chunk; raw is the chunk's uncompressed bytes +// ((uvarint(klen) keyword)* in ordinal order). key is stored so an LRU eviction from the back of +// the list can delete the matching map entry. +type chunkCacheEntry struct { + key chunkCacheKey + raw []byte +} + +// chunkLRU is a byte-budgeted LRU over decompressed term-dict chunks, keyed by +// (segmentId, chunkIdx). Most-recently-used at the FRONT; eviction pops the BACK until used <= +// budget (always keeping at least one entry so a single oversized chunk can still be served). +type chunkLRU struct { + mu sync.Mutex + budget int64 + used int64 + ll *list.List + m map[chunkCacheKey]*list.Element +} + +func newChunkLRU(budget int64) *chunkLRU { + if budget <= 0 { + budget = 32 << 20 + } + return &chunkLRU{budget: budget, ll: list.New(), m: map[chunkCacheKey]*list.Element{}} +} + +// get returns the decompressed bytes of segment s's dict chunk ci, hitting the cache or +// reading+decompressing on a miss and inserting (then evicting LRU back entries to honor the +// budget). The caller must have already run s.ensureDictIndex() (resolveOrdsCached does) so +// s.dictChunks is fully built and immutable; this method's mutex guards only the LRU map/list, +// while concurrent reads of s.dictChunks[ci] are safe because ensureDictIndex builds that slice +// once (sync.Once) and never mutates it again. +func (c *chunkLRU) get(s *segment, ci int) []byte { + k := chunkCacheKey{segId: s.id, chunkIdx: ci} + c.mu.Lock() + defer c.mu.Unlock() + if e, ok := c.m[k]; ok { + c.ll.MoveToFront(e) + return e.Value.(*chunkCacheEntry).raw + } + dc := s.dictChunks[ci] + comp := make([]byte, dc.compLen) + mustReadAt(s.f, comp, dc.compOff) + raw := s.dictCodec.decompress(comp, dc.rawLen) + c.m[k] = c.ll.PushFront(&chunkCacheEntry{k, raw}) + c.used += int64(len(raw)) + for c.used > c.budget && c.ll.Len() > 1 { + back := c.ll.Back() + ce := back.Value.(*chunkCacheEntry) + c.used -= int64(len(ce.raw)) + c.ll.Remove(back) + delete(c.m, ce.key) + } + return raw +} + +// purge drops every cached chunk owned by segId. Called on a MANIFEST swap when a merge retires +// segId so the cache never holds (or serves) bytes for a segment that is gone. Concurrency-safe. +func (c *chunkLRU) purge(segId uint64) { + c.mu.Lock() + defer c.mu.Unlock() + for k, e := range c.m { + if k.segId == segId { + ce := e.Value.(*chunkCacheEntry) + c.used -= int64(len(ce.raw)) + c.ll.Remove(e) + delete(c.m, k) + } + } +} + +// usedBytes is the cache's current decompressed-byte footprint (test/observability helper). +func (c *chunkLRU) usedBytes() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.used +} + +// countForSeg returns how many cached chunks currently belong to segId (test/observability helper). +func (c *chunkLRU) countForSeg(segId uint64) int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for k := range c.m { + if k.segId == segId { + n++ + } + } + return n +} + +// resolveOrdsCached maps requested ordinals -> keyword strings for ONE segment, reading each +// needed dict chunk through the Store-level chunk LRU (so hot chunks are shared/resident). A +// straight port of the spike's resolveOrdsChunk, but the LRU is keyed by (segmentId, chunkIdx). +// s.ensureDictIndex builds the (tiny) per-chunk firstOrd/offset index lazily. +func (s *segment) resolveOrdsCached(need map[uint32]struct{}, lru *chunkLRU) map[uint32]string { + s.ensureDictIndex() + res := make(map[uint32]string, len(need)) + byChunk := map[int][]uint32{} + for o := range need { + i := sort.Search(len(s.dictChunks), func(i int) bool { return s.dictChunks[i].firstOrd > o }) - 1 + if i < 0 { + continue + } + byChunk[i] = append(byChunk[i], o) + } + for ci, ords := range byChunk { + raw := lru.get(s, ci) + c := s.dictChunks[ci] + want := make(map[uint32]struct{}, len(ords)) + for _, o := range ords { + want[o] = struct{}{} + } + cur := c.firstOrd + for q := 0; q < len(raw); { + kl, m := binary.Uvarint(raw[q:]) + q += m + if _, ok := want[cur]; ok { + res[cur] = string(raw[q : q+int(kl)]) + } + q += int(kl) + cur++ + } + } + return res +} + +// forwardKeywords reads a doc's CURRENT keyword set (newest-wins forward lookup, §6/§8): the +// HEAD's pending forward FIRST (so a doc edited twice within one spill window diffs against its +// live keywords, not a stale sealed copy), then sealed segments newest -> oldest. The winning +// forwardValue is decoded; nKw==0 is the forward-tombstone => (nil, deleted=true). Otherwise the +// term-ids are resolved to strings via the winning segment's term-dict region through the chunk +// LRU. A miss everywhere => (nil, false): the doc is unknown (a cold doc on the build path). +// +// This is the read the Update diff (P7) pays under term-id; Search never calls it. +func (s *Store) forwardKeywords(tableId int, docid int64) (words []string, deleted bool) { + s.mu.RLock() + // 1. HEAD first (newest of all): an explicit pending delete is a tombstone; a pending + // forward set wins over any sealed record for this docid. Finding either in the head is a + // real forward read (the doc was seen before), so the hook fires. + if w, del, found := headForwardLookup(s.head[tableId], docid); found { + s.mu.RUnlock() + s.noteForwardRead() + return w, del + } + // 1b. The spilling tier (item F, B1): heads DETACHED for off-worker encode, between the live head + // and the sealed segments, newest -> oldest by detach order. A doc whose forward lives only in a + // detached head must still resolve here, or a re-post would diff against an empty old set and + // drop no tombstone (silent corruption). Resolved (copied) inside the SAME RLock as the live + // head read, before RUnlock — acquireSnapshot below re-takes the RLock, so this must NOT nest. + for i := len(s.spilling) - 1; i >= 0; i-- { // newest detached head wins + e := s.spilling[i] + if e.tableId != tableId { + continue + } + // Task 7C inserts the docid-range skip here: if docid < e.minDocid || docid > e.maxDocid { continue } + if w, del, found := headForwardLookup(e.head, docid); found { + s.mu.RUnlock() + s.noteForwardRead() + return w, del + } + } + s.mu.RUnlock() + + // Snapshot the live segments under the refcount model (P9): forwardKeywords runs ON the worker + // (from applyBatch), so no concurrent merge can retire a segment mid-scan, but it uses the same + // acquire/release path as Search for uniformity — the refs just bump and drop on the worker. The + // scan below (file I/O + decompress) runs without holding the Store lock. The head RLock above is + // released FIRST (acquireSnapshot re-takes the RLock; holding it recursively could deadlock a + // waiting writer); forwardKeywords is on the worker so the head can't change between the windows. + segs := s.acquireSnapshot() + defer s.releaseSnapshot(segs) + + // A cold build has no sealed segments and missed the head above, so there is NOTHING to read + // — return write-only WITHOUT firing the hook (design §6: "all docs are new ⇒ the forward read + // misses ⇒ write-only"). Only a scan that actually touches segment I/O counts as a forward read. + if len(segs) == 0 { + return nil, false + } + + tid := uint32(tableId) + probed := false + for i := len(segs) - 1; i >= 0; i-- { // newest wins + seg := segs[i] + if !seg.coversDocid(docid) { + continue // B: no forward record for docid can exist in this segment — skip, no I/O + } + if !probed { + s.noteForwardRead() // first segment we actually touch = the first real forward read + probed = true + } + s.noteForwardProbe() + val, ok := seg.lookupForward(forwardKey(tid, docid)) + if !ok { + continue + } + ords, del := decodeForward(val) + if del { + return nil, true // forward-tombstone: the doc is deleted, older records cannot win + } + need := make(map[uint32]struct{}, len(ords)) + for _, o := range ords { + need[o] = struct{}{} + } + res := seg.resolveOrdsCached(need, s.dictCache) + out := make([]string, 0, len(ords)) + for _, o := range ords { + w, ok := res[o] + if !ok { + // Every forward ordinal MUST resolve: the spill invariant writes a doc's + // forward only after emitting each of its keywords as an [I] term, so the + // ordinal is always in this segment's term dict. An unresolvable ordinal means + // a corrupt/inconsistent segment; fail loud rather than silently injecting an + // empty keyword that would corrupt the Update diff (consistent with mustReadAt). + panic("invertedstore: unresolvable forward ordinal in segment") + } + out = append(out, w) + } + return out, false + } + return nil, false +} diff --git a/core/invertedstore/dictcache_test.go b/core/invertedstore/dictcache_test.go new file mode 100644 index 0000000..73356f6 --- /dev/null +++ b/core/invertedstore/dictcache_test.go @@ -0,0 +1,404 @@ +package invertedstore + +import ( + "sort" + "sync" + "testing" +) + +// dictcache_test.go — P5 (design §8 resolution + §6 chunk LRU; task T3). +// +// Covers the newest-wins forward lookup forwardKeywords and the Store-level dict-chunk LRU: +// - a doc only in the head resolves to its exact keyword set; +// - a deleted doc (head delForward, AND a sealed forward-tombstone) reads empty; +// - a doc resolved from a sealed segment equals the exact keyword set; +// - the chunk LRU never exceeds its byte budget; +// - newest-wins: the head's pending forward beats a stale sealed copy; +// - ordinal-0 keyword is present (not mistaken for the nKw=0 tombstone); +// - purge drops a retired segment's cached chunks. + +func sortedCopy(in []string) []string { + out := append([]string(nil), in...) + sort.Strings(out) + return out +} + +func eqStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + a, b = sortedCopy(a), sortedCopy(b) + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestForwardKeywords_HeadOnlyDoc: a doc that lives ONLY in the in-memory head (never spilled) +// resolves directly from the head's pending forward — no segment read needed. +func TestForwardKeywords_HeadOnlyDoc(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + want := []string{"alpha", "gamma", "delta"} + s.applyForTest(tbl, 10, want) // stays in the head; no spill + words, deleted := s.forwardKeywords(tbl, 10) + if deleted { + t.Fatal("head-only doc 10 wrongly reported deleted") + } + if !eqStrings(words, want) { + t.Fatalf("head-only forwardKeywords = %v, want %v", sortedCopy(words), sortedCopy(want)) + } +} + +// TestForwardKeywords_HeadDeletedDoc: a doc deleted in the head (delForward) reads empty — +// deleted=true and a nil keyword set — even though it was never sealed. +func TestForwardKeywords_HeadDeletedDoc(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + s.applyForTest(tbl, 20, []string{"alpha", "beta"}) // present... + s.applyForTest(tbl, 20, nil) // ...then deleted (head delForward) + words, deleted := s.forwardKeywords(tbl, 20) + if !deleted { + t.Fatalf("head-deleted doc 20 should read deleted, got words=%v", words) + } + if words != nil { + t.Fatalf("deleted doc should have nil keyword set, got %v", words) + } +} + +// TestForwardKeywords_SealedDoc_ResolvesExactSet: after spill, a doc's forward resolves (via the +// segment term-dict region + chunk LRU) to its EXACT keyword set. +func TestForwardKeywords_SealedDoc_ResolvesExactSet(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + doc10 := []string{"alpha", "gamma"} + doc11 := []string{"beta"} + s.applyForTest(tbl, 10, doc10) + s.applyForTest(tbl, 11, doc11) + s.spillForTest(tbl) // forward now lives in a sealed segment, head empty + + if w, del := s.forwardKeywords(tbl, 10); del || !eqStrings(w, doc10) { + t.Fatalf("sealed doc 10 = (%v, del=%v), want %v", sortedCopy(w), del, sortedCopy(doc10)) + } + if w, del := s.forwardKeywords(tbl, 11); del || !eqStrings(w, doc11) { + t.Fatalf("sealed doc 11 = (%v, del=%v), want %v", sortedCopy(w), del, sortedCopy(doc11)) + } +} + +// TestForwardKeywords_SealedTombstone_ReadsEmpty: a sealed forward-tombstone (nKw=0) in a NEWER +// segment must win over an older non-empty forward record — the doc reads empty (no resurrection). +func TestForwardKeywords_SealedTombstone_ReadsEmpty(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + // Segment 1 (older): doc 30 present with keywords. + s.applyForTest(tbl, 30, []string{"alpha", "beta"}) + s.spillForTest(tbl) + // Segment 2 (newer): doc 30 deleted -> a sealed forward-tombstone record. + s.applyForTest(tbl, 30, nil) + s.spillForTest(tbl) + + if len(s.segs) != 2 { + t.Fatalf("expected 2 sealed segments, got %d", len(s.segs)) + } + words, deleted := s.forwardKeywords(tbl, 30) + if !deleted { + t.Fatalf("doc 30 should read deleted from the newer sealed tombstone, got words=%v", words) + } + if words != nil { + t.Fatalf("deleted doc should have nil keyword set, got %v", words) + } +} + +// TestForwardKeywords_HeadWinsOverStaleSegment: the head's pending forward is NEWER than any +// sealed copy, so a re-edit within a later window resolves to the head's keywords, not the segment's. +func TestForwardKeywords_HeadWinsOverStaleSegment(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + s.applyForTest(tbl, 40, []string{"old1", "old2"}) + s.spillForTest(tbl) // stale copy sealed in segment + s.applyForTest(tbl, 40, []string{"new1", "new2", "new3"}) // re-edit, now in the head + w, del := s.forwardKeywords(tbl, 40) + if del { + t.Fatal("re-edited doc 40 wrongly deleted") + } + if !eqStrings(w, []string{"new1", "new2", "new3"}) { + t.Fatalf("head should win over stale segment: got %v", sortedCopy(w)) + } +} + +// TestForwardKeywords_SealedHeadDeleteWinsOverSegment: a head delete (delForward) after a sealed +// non-empty forward reads empty (the head is the newest action, beats the sealed copy). +func TestForwardKeywords_SealedHeadDeleteWinsOverSegment(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + s.applyForTest(tbl, 50, []string{"alpha"}) + s.spillForTest(tbl) // sealed non-empty + s.applyForTest(tbl, 50, nil) // delete pending in the head + if w, del := s.forwardKeywords(tbl, 50); !del || w != nil { + t.Fatalf("head delete should win over sealed copy: got (%v, del=%v)", w, del) + } +} + +// TestForwardKeywords_OrdinalZeroPresent: a doc whose single keyword is ordinal 0 must read back +// PRESENT, not be mistaken for the nKw=0 forward-tombstone (a live doc encodes 0x01 0x00). +func TestForwardKeywords_OrdinalZeroPresent(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + // "aaa" sorts first => ordinal 0 in the segment term dict. Make it the ONLY keyword of doc 60. + s.applyForTest(tbl, 60, []string{"zzz"}) // some other term so the dict isn't single-element + s.applyForTest(tbl, 61, []string{"aaa"}) // doc 61's only keyword is ordinal 0 + s.spillForTest(tbl) + + w, del := s.forwardKeywords(tbl, 61) + if del { + t.Fatal("ordinal-0 doc 61 wrongly read as deleted (tombstone alias)") + } + if !eqStrings(w, []string{"aaa"}) { + t.Fatalf("ordinal-0 doc 61 = %v, want [aaa]", sortedCopy(w)) + } +} + +// TestForwardKeywords_UnknownDocMiss: a docid never written (cold) misses everywhere => not +// deleted, empty set (the cold-build write-only case relies on this miss). +func TestForwardKeywords_UnknownDocMiss(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + w, del := s.forwardKeywords(tbl, 999) + if del || w != nil { + t.Fatalf("unknown doc should miss (nil, false), got (%v, del=%v)", w, del) + } +} + +// TestChunkLRU_NeverExceedsBudget: drive resolution of many distinct ordinals across many spilled +// segments through a tiny-budget LRU and assert the cache footprint never exceeds the budget +// after any resolve (eviction keeps it bounded). +func TestChunkLRU_NeverExceedsBudget(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + // Tiny budget so even a few decompressed chunks force eviction. + budget := int64(8 << 10) + s.dictCache = newChunkLRU(budget) + + // Build several segments, each with a fat vocabulary so the term-dict region is many chunks. + const segs, perSeg = 6, 200 + docid := int64(1) + for sg := 0; sg < segs; sg++ { + for d := 0; d < 40; d++ { + kws := make([]string, perSeg/4) + for k := range kws { + kws[k] = uniqueWord(sg, d, k) + } + s.applyForTest(tbl, docid, kws) + docid++ + } + s.spillForTest(tbl) + } + + // Resolve every doc's forward (touches chunks across all segments); check the budget holds. + for d := int64(1); d < docid; d++ { + if _, del := s.forwardKeywords(tbl, d); del { + t.Fatalf("doc %d wrongly deleted during budget sweep", d) + } + if used := s.dictCache.usedBytes(); used > budget { + t.Fatalf("chunk LRU exceeded budget: used=%d > budget=%d", used, budget) + } + } + if used := s.dictCache.usedBytes(); used > budget { + t.Fatalf("chunk LRU over budget at end: used=%d > budget=%d", used, budget) + } + + // Non-vacuity guard: the corpus must hold far more distinct decompressed dict-chunk bytes + // than the budget, so eviction genuinely fired (otherwise the bound above is trivially met + // and the test proves nothing). Sum each segment's raw dict-chunk bytes via ensureDictIndex. + var totalRaw int64 + for _, seg := range s.segs { + seg.ensureDictIndex() + for _, dc := range seg.dictChunks { + totalRaw += int64(dc.rawLen) + } + } + if totalRaw <= budget { + t.Fatalf("test is vacuous: total dict-chunk bytes %d <= budget %d (no eviction forced)", totalRaw, budget) + } +} + +// TestForwardKeywords_ConcurrentResolveRace drives many goroutines calling forwardKeywords against +// freshly-spilled (NOT yet dict-indexed) segments through a shared tiny-budget LRU. The first touch +// of each segment lazily builds its dict-chunk index (segment.ensureDictIndex), so without the +// build being one-time-safe (sync.Once) concurrent first-touches race on s.dictChunks — this test +// fails under `-race`. It also asserts every goroutine resolves each doc to its EXACT keyword set, +// so a torn lazy build (partial dictChunks) would surface as a wrong/garbled resolution too. +func TestForwardKeywords_ConcurrentResolveRace(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + // Build several fresh segments, each with a fat vocabulary (many dict chunks). Record each + // doc's expected keyword set for the concurrent correctness check below. + tiny := int64(8 << 10) + s.dictCache = newChunkLRU(tiny) // tiny shared budget -> concurrent evictions too + + const segs, docsPerSeg, kwsPerDoc = 5, 12, 30 + want := map[int64][]string{} + docid := int64(1) + for sg := 0; sg < segs; sg++ { + for d := 0; d < docsPerSeg; d++ { + kws := make([]string, kwsPerDoc) + for k := range kws { + kws[k] = uniqueWord(sg, d, k) + } + s.applyForTest(tbl, docid, kws) + want[docid] = kws + docid++ + } + s.spillForTest(tbl) + } + if len(s.segs) != segs { + t.Fatalf("expected %d sealed segments, got %d", segs, len(s.segs)) + } + // The segments are freshly opened/spilled — their dict indexes are NOT yet built, so the + // concurrent first-touch below exercises the lazy build under contention. (Do not call any + // resolve here first, or the race window closes.) + + maxDoc := docid - 1 + const workers = 8 + var wg sync.WaitGroup + errCh := make(chan string, workers) + start := make(chan struct{}) + for g := 0; g < workers; g++ { + wg.Add(1) + go func(seed int64) { + defer wg.Done() + <-start // release all goroutines together to maximize first-touch overlap + for iter := 0; iter < 3; iter++ { + for d := int64(1); d <= maxDoc; d++ { + w, del := s.forwardKeywords(tbl, d) + if del { + select { + case errCh <- "doc wrongly deleted": + default: + } + return + } + if !eqStrings(w, want[d]) { + select { + case errCh <- "doc resolved to wrong keyword set under concurrency": + default: + } + return + } + } + } + }(int64(g)) + } + close(start) + wg.Wait() + close(errCh) + if msg, bad := <-errCh; bad { + t.Fatalf("concurrent forwardKeywords: %s", msg) + } + if used := s.dictCache.usedBytes(); used > tiny { + t.Fatalf("chunk LRU exceeded budget under concurrency: used=%d > budget=%d", used, tiny) + } +} + +// TestChunkLRU_PurgeDropsSegmentChunks: after caching chunks for a segment, purge(segId) drops +// exactly that segment's entries (a merged-away segment is evicted on the MANIFEST swap, §6). +func TestChunkLRU_PurgeDropsSegmentChunks(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + s.applyForTest(tbl, 70, []string{"alpha", "beta", "gamma"}) + s.spillForTest(tbl) + s.applyForTest(tbl, 71, []string{"delta", "epsilon"}) + s.spillForTest(tbl) + if len(s.segs) != 2 { + t.Fatalf("expected 2 segments, got %d", len(s.segs)) + } + seg0, seg1 := s.segs[0], s.segs[1] + + // Resolve both docs to populate the cache with chunks from both segments. + s.forwardKeywords(tbl, 70) + s.forwardKeywords(tbl, 71) + if cnt := s.dictCache.countForSeg(seg0.id); cnt == 0 { + t.Fatal("expected cached chunks for seg0 after resolve") + } + if cnt := s.dictCache.countForSeg(seg1.id); cnt == 0 { + t.Fatal("expected cached chunks for seg1 after resolve") + } + + s.dictCache.purge(seg0.id) + if cnt := s.dictCache.countForSeg(seg0.id); cnt != 0 { + t.Fatalf("purge(seg0) left %d chunks", cnt) + } + if cnt := s.dictCache.countForSeg(seg1.id); cnt == 0 { + t.Fatal("purge(seg0) must NOT drop seg1's chunks") + } +} + +// uniqueWord builds a deterministic distinct keyword per (segment,doc,slot) so each segment has a +// fat, distinct vocabulary (many dict chunks). +func uniqueWord(sg, d, k int) string { + const alpha = "abcdefghijklmnopqrstuvwxyz" + enc := func(n int) string { + if n == 0 { + return "a" + } + var b []byte + for n > 0 { + b = append(b, alpha[n%26]) + n /= 26 + } + return string(b) + } + return "w_" + enc(sg) + "_" + enc(d) + "_" + enc(k) +} + +// TestNewChunkLRUDefaultsBudget: a non-positive budget is defaulted to 32 MiB (Options leaves it 0 +// only when the caller wants the default; the LRU must never run with a zero/negative budget that +// would evict every insert). +func TestNewChunkLRUDefaultsBudget(t *testing.T) { + const defaultBudget = int64(32 << 20) + for _, in := range []int64{0, -1, -4096} { + c := newChunkLRU(in) + if c.budget != defaultBudget { + t.Fatalf("newChunkLRU(%d).budget = %d, want default %d", in, c.budget, defaultBudget) + } + } + // A positive budget is honored unchanged. + if c := newChunkLRU(4096); c.budget != 4096 { + t.Fatalf("newChunkLRU(4096).budget = %d, want 4096", c.budget) + } +} diff --git a/core/invertedstore/differential_test.go b/core/invertedstore/differential_test.go new file mode 100644 index 0000000..fb476f6 --- /dev/null +++ b/core/invertedstore/differential_test.go @@ -0,0 +1,1113 @@ +package invertedstore + +// differential_test.go — P12 / build-step 10 / task T11 (design §11). +// +// The acceptance gate for invertedstore: build BOTH the real, pebble-backed core/invertedindex AND +// invertedstore from the SAME synthetic corpus, run the SAME prefix queries through each, and assert +// IDENTICAL hit sets — the production-shape analogue of the spike's "hit parity 2,414,505" (the spike +// fed invertedindex + the sortruns proto the same token dump; see cmd/sortbench/main.go doPebble / +// doSortruns / sampleQueries / runSearch). The spike's narrow workload (globally-unique edit words, +// no tableId, no real delete reconciliation) MISSED several correctness cases; T11/§11 calls them out +// explicitly, so on top of the bulk parity this file adds targeted cases for each spike-missed gap: +// +// - add -> del -> add resolved end-to-end through Update (read-time AND after a forced merge). +// - delete-no-resurrect: a deleted doc never reappears from an older sealed segment. +// - tableId multi-tenancy isolation: two tables never see each other's docs (Search / GetDocs), +// exercised at the SEGMENT layer (both tables force-spilled, >=2 sealed segments) so the 4-byte +// fixed-width tableId key-prefix scan is the thing under test, not the trivial per-table head map. +// - int64 docids full range: docids near 1<<40 and math.MaxInt64 round-trip through spill + merge +// (the §11 owed re-measure: the spike computes deltas in int32 space; production is int64). +// - crash recovery (design §9 / T10, indexer-driven): a "crash" loses the volatile head; on reopen +// the indexer re-Updates every doc newer than its own cursor (incl. low ids) and reconciles +// deletions, and the recovered hit set is identical to the source-of-truth oracle. Idempotent +// re-Update of already-sealed docs leaves the hit set unchanged. (The store keeps NO recovery +// watermark; recovery is driven entirely through the public Update path + the forwardKeywords +// resolution hook the indexer uses for its idempotency/deletion check — the real §9 contract.) +// +// Both stores are driven through their REAL public write paths (no test seams for the writes): +// invertedindex via q.AddFunc(idx.Update(...)) + CloseAndWait (the final flush, exactly as the spike's +// doPebble does), invertedstore via Batch.Commit + sync. invertedindex requires the caller to supply +// oldKeywords; the store owns its forward map and diffs internally — the differential harness tracks +// oldKeywords for the invertedindex side only, so the SAME logical edit stream reaches both engines. +// +// Two regression-guard benchmarks back the perf contract as CI guards (design §11 / build-step 10): +// BenchmarkBuild_MemoryCapped (a multi-segment build under a hard GOMEMLIMIT-equivalent +// debug.SetMemoryLimit — the §1/§3 "memory is the hard constraint and it holds" guard, where pebble +// blows up under the same cap) and BenchmarkCodeEditUpdate (the §8 code-edit incremental update path: +// a fixed file set re-edited over many rounds through Batch). The §11 owed re-measures (tableId-in-key, +// int64 full-range, in-memory-dedup peak, backgrounded-merge foreground) are folded in as the +// benchmarks' reported metrics + the targeted correctness cases above. + +import ( + "fmt" + "math" + "math/rand" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "sort" + "testing" + + "github.com/codetrek/haystack/core/invertedindex" + "github.com/codetrek/haystack/core/kv/pebblekv" + "github.com/codetrek/haystack/core/queue" +) + +// ---- synthetic corpus ------------------------------------------------------- + +// corpusDoc is one synthetic document: a docid and its current keyword set. +type corpusDoc struct { + id int64 + keywords []string +} + +// genCorpus builds a deterministic synthetic corpus of nDocs documents, each a word list of 5..25 +// keywords drawn from a vocabulary of vocab terms. The vocabulary is a mix of shared common prefixes +// (so prefix queries fan out to many keywords, like real code identifiers) and per-term suffixes, so +// the corpus exercises the prefix-union path both stores must agree on. Deterministic (fixed seed) so +// a failure is reproducible. +func genCorpus(nDocs, vocab int, seed int64) ([]corpusDoc, []string) { + rng := rand.New(rand.NewSource(seed)) + + // A vocabulary with shared prefixes: ~24 stems crossed with a numeric suffix, so many distinct + // keywords share a 3-5 char prefix (the realistic prefix-fanout the search path must union). + stems := []string{ + "alpha", "alphanum", "alphabet", "beta", "betamax", "gamma", "gammaray", "delta", + "deltav", "epsilon", "zeta", "eta", "theta", "iota", "kappa", "lambda", + "index", "indexer", "indexing", "search", "searcher", "store", "storage", "stored", + } + terms := make([]string, 0, vocab) + seen := map[string]struct{}{} + for len(terms) < vocab { + stem := stems[rng.Intn(len(stems))] + w := fmt.Sprintf("%s%d", stem, rng.Intn(vocab)) + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + terms = append(terms, w) + } + + docs := make([]corpusDoc, nDocs) + for i := 0; i < nDocs; i++ { + n := 5 + rng.Intn(21) // 5..25 keywords + kwSet := map[string]struct{}{} + for len(kwSet) < n { + kwSet[terms[rng.Intn(len(terms))]] = struct{}{} + } + kws := make([]string, 0, len(kwSet)) + for w := range kwSet { + kws = append(kws, w) + } + sort.Strings(kws) + docs[i] = corpusDoc{id: int64(i + 1), keywords: kws} // docids 1..nDocs + } + return docs, terms +} + +// sampleQueriesDiff mirrors cmd/sortbench/main.go sampleQueries: high-doc-frequency whole terms plus +// truncated-to-5-rune prefixes, deduped/lowercased. The exact term list is irrelevant to parity (both +// engines run the SAME queries); what matters is that the set spans full-term lookups and short +// high-fanout prefixes so the differential covers the prefix-union path, not just exact keys. +func sampleQueriesDiff(docs []corpusDoc) []string { + docFreq := map[string]int{} + for _, d := range docs { + for _, w := range d.keywords { + docFreq[w]++ + } + } + type tf struct { + t string + f int + } + all := make([]tf, 0, len(docFreq)) + for t, f := range docFreq { + if len([]rune(t)) >= 3 { + all = append(all, tf{t, f}) + } + } + sort.Slice(all, func(i, j int) bool { + if all[i].f != all[j].f { + return all[i].f > all[j].f + } + return all[i].t < all[j].t + }) + seen := map[string]struct{}{} + var qs []string + add := func(s string) { + if _, ok := seen[s]; ok || s == "" { + return + } + seen[s] = struct{}{} + qs = append(qs, s) + } + for i := 0; i < len(all) && i < 50; i++ { + add(all[i].t) // top-frequency whole terms (exact) + } + step := len(all) / 150 + if step < 1 { + step = 1 + } + for i := 0; i < len(all) && len(qs) < 200; i += step { + r := []rune(all[i].t) + if len(r) > 5 { + r = r[:5] // truncated prefixes -> high fanout + } + add(string(r)) + } + // A few short shared stems guarantee large-fanout prefix unions are exercised. + for _, p := range []string{"alph", "beta", "gam", "delt", "ind", "sea", "sto"} { + add(p) + } + return qs +} + +// ---- the two engines under test -------------------------------------------- + +// invIndexHarness wraps a real pebble-backed core/invertedindex driven exactly like the spike's +// doPebble: each Update is enqueued on the mpsc worker; CloseAndWait forces the final flush so Search +// sees every posting. Since #105 invertedindex owns its own forward map, Update takes the current +// keyword set only (3-arg) — identical to invertedstore — so the SAME edit stream reaches both engines. +type invIndexHarness struct { + t *testing.T + dir string + db interface{ Close() error } + q *queue.Mpsc + idx *invertedindex.Index + closed bool +} + +func newInvIndexHarness(t *testing.T) *invIndexHarness { + t.Helper() + dir, err := os.MkdirTemp("", "invidx-diff-*") + if err != nil { + t.Fatal(err) + } + db, err := pebblekv.Open(filepath.Join(dir, "pebble"), 0) + if err != nil { + t.Fatal(err) + } + q := queue.NewMpsc("diff-invidx") + q.Start() + idx, err := invertedindex.New(db, q, invertedindex.Options{}) + if err != nil { + t.Fatal(err) + } + return &invIndexHarness{t: t, dir: dir, db: db, q: q, idx: idx} +} + +// update feeds one logical edit. #105's invertedindex owns its forward map and diffs internally, so +// Update takes only the doc's CURRENT keyword set (empty = delete) — the same contract as invertedstore. +func (h *invIndexHarness) update(tableId int, docid int64, keywords []string) { + newKw := append([]string(nil), keywords...) + h.q.AddFunc(func() error { h.idx.Update(tableId, docid, newKw); return nil }) +} + +// flush forces every enqueued Update through and flushes the pending buffers to pebble, so Search is +// authoritative. CloseAndWait is the spike's flush mechanism (doPebble searches AFTER CloseAndWait); +// it is idempotent-safe to call once, so we mark closed and skip it in teardown. +func (h *invIndexHarness) flush() { + h.q.RunTask(&queue.NopeTask{}) + h.idx.CloseAndWait() + h.closed = true +} + +func (h *invIndexHarness) search(tableId int, query string) map[int64]struct{} { + return h.idx.Search(tableId, query, -1, nil).DocIds +} + +func (h *invIndexHarness) teardown() { + if !h.closed { + h.idx.CloseAndWait() + } + h.q.Stop() + h.db.Close() + os.RemoveAll(h.dir) +} + +// invStoreHarness wraps a real invertedstore driven through its public Batch/Update path. +type invStoreHarness struct { + t *testing.T + s *Store + b *Batch + dir string + q *queue.Mpsc + opts Options +} + +func newInvStoreHarness(t *testing.T, opts Options) *invStoreHarness { + t.Helper() + dir := t.TempDir() + q := queue.NewMpsc("diff-invstore") + q.Start() + s, err := Open(dir, q, opts) + if err != nil { + t.Fatal(err) + } + return &invStoreHarness{t: t, s: s, dir: dir, q: q, opts: opts} +} + +// crashAndReopen simulates a process crash: the volatile head (any apply that has not yet spilled to +// a sealed segment) is LOST, while every fsync'd sealed segment named in the durable MANIFEST +// survives. We drain in-flight applies, then close ONLY the segment fds (without spilling the head — +// the production CloseAndWait would spill it, which is the opposite of a crash) by retiring the +// published snapshot, stop the old worker, and Open the same dir on a FRESH queue. The reopened store +// holds exactly the durable segments; its head is empty. This is the design §9 crash-consistency +// guarantee (sealed segments durable, head volatile) that the indexer-driven recovery then repairs. +func (h *invStoreHarness) crashAndReopen() { + h.t.Helper() + if h.b != nil { // make sure queued ops land in the head before we yank it away + h.b.Commit() + h.b = nil + } + h.s.sync() // drain every enqueued apply onto the head/segments + // Drop the published segment set and close the open fds WITHOUT spilling the head (files kept on + // disk for the next Open). This is the crash: the head map is simply abandoned with the *Store. + h.s.dropHeadCloseSegmentsForTest() + h.q.Stop() + + q := queue.NewMpsc("diff-invstore-reopen") + q.Start() + s, err := Open(h.dir, q, h.opts) + if err != nil { + h.t.Fatalf("reopen after crash: %v", err) + } + h.s, h.q = s, q +} + +func (h *invStoreHarness) update(tableId int, docid int64, keywords []string) { + if h.b == nil { + h.b = h.s.NewBatch() + } + h.b.Update(tableId, docid, keywords) +} + +// flush commits the accumulated batch (one apply task) and drains the worker so Search is authoritative. +func (h *invStoreHarness) flush() { + if h.b != nil { + h.b.Commit() + h.b = nil + } + h.s.sync() +} + +func (h *invStoreHarness) search(tableId int, query string) map[int64]struct{} { + return h.s.Search(tableId, query, -1, nil).DocIds +} + +func (h *invStoreHarness) teardown() { + h.s.CloseAndWait() + h.q.Stop() +} + +// ---- comparison helpers ----------------------------------------------------- + +// assertSameHits fails the test if the two docid sets differ, printing the symmetric difference. +func assertSameHits(t *testing.T, query string, want, got map[int64]struct{}) { + t.Helper() + if len(want) != len(got) { + t.Errorf("query %q: hit count differs invertedindex=%d invertedstore=%d", query, len(want), len(got)) + } + var missing, extra []int64 + for d := range want { + if _, ok := got[d]; !ok { + missing = append(missing, d) + } + } + for d := range got { + if _, ok := want[d]; !ok { + extra = append(extra, d) + } + } + if len(missing) > 0 || len(extra) > 0 { + sort.Slice(missing, func(i, j int) bool { return missing[i] < missing[j] }) + sort.Slice(extra, func(i, j int) bool { return extra[i] < extra[j] }) + cap := func(s []int64) []int64 { + if len(s) > 10 { + return s[:10] + } + return s + } + t.Errorf("query %q: hit sets differ. in invertedindex but not invertedstore=%v ; in invertedstore but not invertedindex=%v", + query, cap(missing), cap(extra)) + } +} + +// ---- MUST-PASS: identical hit sets vs invertedindex (the spike's parity) ---- + +// TestDifferential_IdenticalHitSets is the headline gate: build BOTH engines from the SAME ~3,000-doc +// synthetic corpus, run the SAME ~200 prefix queries through each, and assert byte-for-byte identical +// hit sets per query (the production-shape analogue of the spike's hit-parity 2,414,505). A tiny +// CapBytes forces invertedstore to spill many L0 segments so the search-time newest-wins union across +// MULTIPLE segments + head is exercised (not a single in-memory head), which is the read path that +// must match pebble's LSM read. +func TestDifferential_IdenticalHitSets(t *testing.T) { + docs, _ := genCorpus(3000, 600, 0xC0FFEE) + queries := sampleQueriesDiff(docs) + if len(queries) < 50 { + t.Fatalf("expected a substantial query set, got %d", len(queries)) + } + + // Each engine numbers tables independently (invertedindex's first id is 0 via GetIncrementalId; + // the store's is 1), so capture each engine's OWN table id rather than assuming equality — the + // tableId is just an internal namespace handle; parity is about identical hit sets for the same + // logical table, fed the same docs. + ii := newInvIndexHarness(t) + defer ii.teardown() + tblII, err := ii.idx.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + // Small CapBytes -> many spills -> multi-segment search on the store side. + is := newInvStoreHarness(t, Options{CapBytes: 64 << 10}) + defer is.teardown() + tblIS, err := is.s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + // Feed the IDENTICAL doc set to both engines (cold build: every docid new, oldKeywords nil). + for _, d := range docs { + ii.update(tblII, d.id, d.keywords) + is.update(tblIS, d.id, d.keywords) + } + ii.flush() + is.flush() + + // invertedstore must have spilled multiple segments (the multi-segment read path is the point). + if len(is.s.segs) < 2 { + t.Fatalf("expected the small-cap store to spill multiple segments, got %d", len(is.s.segs)) + } + + totalHits := 0 + for _, qy := range queries { + want := ii.search(tblII, qy) + got := is.search(tblIS, qy) + assertSameHits(t, qy, want, got) + totalHits += len(want) + } + if totalHits == 0 { + t.Fatal("queries returned no hits at all — corpus/query generation is broken, parity is vacuous") + } + t.Logf("differential parity over %d queries: %d total hits identical across both engines", len(queries), totalHits) +} + +// TestDifferential_IdenticalAfterEdits proves the store's INCREMENTAL edit path converges to the same +// hit sets as a clean build of the net final state. The spike only ever fed globally-unique words, so +// it never proved that re-edits + deletes (the store's full re-post + per-keyword tombstones, resolved +// newest-wins across spilled segments) leave the index in the right live state. +// +// The store receives the FULL incremental edit stream (initial build + re-edit rounds + deletes, +// driven through Batch). The oracle is a FRESH invertedindex built ONCE from each doc's NET final +// keyword set. We deliberately do NOT feed the incremental stream to invertedindex: invertedindex +// coalesces a within-flush-window add+delete of the same (keyword,docid) to the delete (its flush +// processes all pendingWrites then all pendingDeletes, ignoring intra-window order), so an interleaved +// stream is not a faithful oracle — but a clean build of the final live state IS the unambiguous target +// both engines must agree on. So this asserts: store(incremental edits) == invertedindex(final state). +func TestDifferential_IdenticalAfterEdits(t *testing.T) { + docs, terms := genCorpus(1500, 400, 0xBEEF) + + is := newInvStoreHarness(t, Options{CapBytes: 48 << 10}) + defer is.teardown() + tblIS, err := is.s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + // Track the live (net) keyword set per doc as we drive the incremental stream into the store. + live := map[int64][]string{} + for _, d := range docs { + is.update(tblIS, d.id, d.keywords) + live[d.id] = d.keywords + } + + // Edit rounds against the STORE only: re-edit a deterministic subset (fresh keyword sets) and + // delete some docs. The store diffs internally; we just keep `live` as the ground-truth net state. + rng := rand.New(rand.NewSource(0x5EED)) + for round := 0; round < 3; round++ { + for _, d := range docs { + r := rng.Float64() + switch { + case r < 0.15: // delete + is.update(tblIS, d.id, nil) + delete(live, d.id) + case r < 0.50: // re-edit: a fresh random keyword set + n := 4 + rng.Intn(12) + set := map[string]struct{}{} + for len(set) < n { + set[terms[rng.Intn(len(terms))]] = struct{}{} + } + kws := make([]string, 0, len(set)) + for w := range set { + kws = append(kws, w) + } + sort.Strings(kws) + is.update(tblIS, d.id, kws) + live[d.id] = kws + default: // untouched this round + } + } + } + is.flush() + + // Oracle: a fresh invertedindex built once from the NET final live state (cold, oldKeywords nil). + ii := newInvIndexHarness(t) + defer ii.teardown() + tblII, err := ii.idx.CreateTable("files") + if err != nil { + t.Fatal(err) + } + liveDocs := make([]corpusDoc, 0, len(live)) + for id, kws := range live { + ii.update(tblII, id, kws) + liveDocs = append(liveDocs, corpusDoc{id: id, keywords: kws}) + } + ii.flush() + + queries := sampleQueriesDiff(liveDocs) + totalHits := 0 + for _, qy := range queries { + want := ii.search(tblII, qy) + got := is.search(tblIS, qy) + assertSameHits(t, qy, want, got) + totalHits += len(want) + } + if totalHits == 0 { + t.Fatal("post-edit queries returned no hits — the edit stream emptied the index, parity is vacuous") + } + t.Logf("post-edit differential parity over %d queries: %d total hits identical (store incremental == invertedindex final-state)", len(queries), totalHits) +} + +// ---- MUST-PASS: add -> del -> add (end to end through Update) ---------------- + +// TestDifferential_AddDelAdd_PresentEndToEnd drives add -> delete -> add for one doc through the REAL +// Update path on the store, across spills (and a forced merge), and asserts the doc resolves PRESENT — +// the §11/T6 case the spike's concat-not-reconcile merge got wrong. The store is the system under test; +// the DESIGN (newest-wins per (keyword,docid)) is the oracle here, NOT invertedindex: invertedindex +// coalesces a within-flush-window add+delete+add of the same (keyword,docid) to the DELETE (it flushes +// all pendingWrites then all pendingDeletes regardless of intra-window order), so it would report this +// stream ABSENT — which is exactly the ambiguity the store's explicit newest-wins resolution removes. +// Each of the three actions lands in its OWN L0 segment so this exercises multi-segment resolution AND +// the merge reconciliation, not a single in-memory head. +func TestDifferential_AddDelAdd_PresentEndToEnd(t *testing.T) { + is := newInvStoreHarness(t, Options{CapBytes: 1, Fanout: 3}) // tiny cap so each edit spills its own L0 seg + defer is.teardown() + tbl, err := is.s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + // add -> del -> add, each followed by a forced spill so the three actions land in three different + // L0 segments — the multi-segment newest-wins resolution the spike never built. + is.update(tbl, 10, []string{"alpha"}) + is.flush() + is.s.forceSpill(tbl) // seg0: alpha ADD 10 + + is.update(tbl, 10, nil) // delete + is.flush() + is.s.forceSpill(tbl) // seg1: alpha DEL 10 + forward-tombstone + + is.update(tbl, 10, []string{"alpha"}) // re-add + is.flush() + is.s.forceSpill(tbl) // seg2: alpha ADD 10 (newest) + + if len(is.s.segs) != 3 { + t.Fatalf("expected 3 L0 segments (one per action), got %d", len(is.s.segs)) + } + + // Read-time newest-wins (before any merge) must resolve PRESENT. + got := is.search(tbl, "alpha") + if _, ok := got[10]; !ok { + t.Fatalf("add->del->add must resolve PRESENT end-to-end (read-time newest-wins), got %v", got) + } + // The forward must reflect the latest re-add ({alpha}), not the delete. + words, deleted := is.s.forwardKeywords(tbl, 10) + if deleted || len(words) != 1 || words[0] != "alpha" { + t.Fatalf("forward after add->del->add must be {alpha} live, got words=%v deleted=%v", words, deleted) + } + + // Force a tiered merge of the three L0 segments and re-assert: the reconciliation must survive the + // merge too (the newest ADD wins over the older DEL — the case the spike's concat merge got wrong). + if !is.s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge of the 3 L0 segments at Fanout 3") + } + if _, ok := is.search(tbl, "alpha")[10]; !ok { + t.Fatalf("add->del->add must STILL be PRESENT after a merge, store got %v", is.search(tbl, "alpha")) + } +} + +// ---- MUST-PASS: delete-no-resurrect ----------------------------------------- + +// TestDifferential_DeleteNoResurrect deletes a doc that was sealed (with a live forward + postings) in +// an older segment, then asserts BOTH engines report it absent — and crucially that the store does not +// resurrect it from the older non-empty segment (the forward-tombstone path). A merge spanning the +// delete + the older live record must keep it absent. The net state (deleted) is unambiguous, so +// invertedindex is a faithful oracle here (its within-window coalescing also lands on the delete). +func TestDifferential_DeleteNoResurrect(t *testing.T) { + ii := newInvIndexHarness(t) + defer ii.teardown() + tblII, err := ii.idx.CreateTable("files") + if err != nil { + t.Fatal(err) + } + is := newInvStoreHarness(t, Options{CapBytes: 1, Fanout: 2}) + defer is.teardown() + tblIS, err := is.s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + // Seal a live doc 10 {alpha,beta} in an older segment. + ii.update(tblII, 10, []string{"alpha", "beta"}) + is.update(tblIS, 10, []string{"alpha", "beta"}) + is.flush() + is.s.forceSpill(tblIS) + + // Delete it; seal the delete in a newer segment. + ii.update(tblII, 10, nil) + is.update(tblIS, 10, nil) + is.flush() + is.s.forceSpill(tblIS) + ii.flush() + + for _, kw := range []string{"alpha", "beta"} { + want := ii.search(tblII, kw) + got := is.search(tblIS, kw) + assertSameHits(t, kw+" (deleted)", want, got) + if _, ok := got[10]; ok { + t.Fatalf("deleted doc 10 must be ABSENT from %q (no resurrection), store got %v", kw, got) + } + } + + // The store's own forward must report the doc deleted (not a stale {alpha,beta}). + words, deleted := is.s.forwardKeywords(tblIS, 10) + if !deleted || len(words) != 0 { + t.Fatalf("store forward of deleted doc 10 must be empty/deleted, got words=%v deleted=%v", words, deleted) + } + + // Merge the two segments and re-assert absence (the forward-tombstone survives the merge). + if !is.s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge of the 2 segments at Fanout 2") + } + for _, kw := range []string{"alpha", "beta"} { + if _, ok := is.search(tblIS, kw)[10]; ok { + t.Fatalf("deleted doc 10 must STILL be ABSENT from %q after merge", kw) + } + } +} + +// ---- MUST-PASS: tableId multi-tenancy isolation ----------------------------- + +// TestDifferential_TableIsolation indexes overlapping docids into TWO tables in each engine and asserts +// a query in one table never returns the other table's docs — and that each engine agrees per table. +// Tables are isolated keyword namespaces (design §5: tableId is a fixed-width 4-byte key prefix); the +// spike carried NO tableId, so this is a pure production-format case. Each engine numbers its tables +// independently (index A=0/B=1, store A=1/B=2), so we map the logical tables A/B to each engine's OWN ids. +func TestDifferential_TableIsolation(t *testing.T) { + ii := newInvIndexHarness(t) + defer ii.teardown() + iiA, err := ii.idx.CreateTable("A") + if err != nil { + t.Fatal(err) + } + iiB, err := ii.idx.CreateTable("B") + if err != nil { + t.Fatal(err) + } + + is := newInvStoreHarness(t, Options{CapBytes: 32 << 10}) + defer is.teardown() + isA, err := is.s.CreateTable("A") + if err != nil { + t.Fatal(err) + } + isB, err := is.s.CreateTable("B") + if err != nil { + t.Fatal(err) + } + + // Same docids in BOTH tables, but DISJOINT vocabularies (prefixed ta_/tb_), so a leak across tables + // would return a docid under a keyword it never had in that table. Overlapping docids (1..200 in + // both) make any cross-table bleed observable. + docsA, _ := genCorpus(200, 80, 1) + docsB, _ := genCorpus(200, 80, 2) + prefixAll := func(docs []corpusDoc, p string) { + for i := range docs { + for j := range docs[i].keywords { + docs[i].keywords[j] = p + docs[i].keywords[j] + } + } + } + prefixAll(docsA, "ta_") + prefixAll(docsB, "tb_") + + feed := func(iiTbl, isTbl int, docs []corpusDoc) { + for _, d := range docs { + ii.update(iiTbl, d.id, d.keywords) + is.update(isTbl, d.id, d.keywords) + } + } + feed(iiA, isA, docsA) + feed(iiB, isB, docsB) + ii.flush() + is.flush() + + // Force BOTH tables' heads to seal into segments so the isolation we assert below is the + // SEGMENT-level one: the 4-byte fixed-width tableId key-prefix scan (design §5), NOT the trivial + // per-table head map. A query's segment scan over [I]+tableId+keyword must not bleed into an + // adjacent tableId's records. Without this the whole corpus can sit in the head (200 small docs < + // the cap), where table isolation is vacuous (separate maps) and the production-format case the + // spike never had goes untested. Assert segments actually exist so the check can't silently regress. + is.s.forceSpill(isA) + is.s.forceSpill(isB) + if len(is.s.segs) < 2 { + t.Fatalf("expected both tables sealed into segments to exercise segment-level tableId isolation, got %d", len(is.s.segs)) + } + + // A query for table B's vocabulary prefix in table A must be EMPTY in both engines, and vice versa. + for _, qy := range []string{"tb_", "tb_beta", "tb_store"} { + if got := is.search(isA, qy); len(got) != 0 { + t.Errorf("store table A leaked table B's keyword %q -> %v", qy, got) + } + if got := ii.search(iiA, qy); len(got) != 0 { + t.Errorf("(oracle) invertedindex table A leaked %q -> %v", qy, got) + } + } + for _, qy := range []string{"ta_", "ta_alpha", "ta_index"} { + if got := is.search(isB, qy); len(got) != 0 { + t.Errorf("store table B leaked table A's keyword %q -> %v", qy, got) + } + if got := ii.search(iiB, qy); len(got) != 0 { + t.Errorf("(oracle) invertedindex table B leaked %q -> %v", qy, got) + } + } + + // And the per-table hit sets must MATCH the invertedindex oracle for each table's own queries. + for _, qy := range []string{"ta_", "ta_alph", "ta_ind", "ta_sea"} { + assertSameHits(t, "A:"+qy, ii.search(iiA, qy), is.search(isA, qy)) + } + for _, qy := range []string{"tb_", "tb_beta", "tb_sto", "tb_gam"} { + assertSameHits(t, "B:"+qy, ii.search(iiB, qy), is.search(isB, qy)) + } + + // Non-empty sanity: each table's own prefix actually returns docs (isolation isn't vacuously empty). + if len(is.search(isA, "ta_")) == 0 { + t.Fatal("store table A own-prefix query returned no docs — isolation check would be vacuous") + } + if len(is.search(isB, "tb_")) == 0 { + t.Fatal("store table B own-prefix query returned no docs — isolation check would be vacuous") + } +} + +// TestDifferential_GetDocsTableIsolation checks GetDocs (exact-key) honors table isolation too: the +// same exact keyword in two tables returns only that table's docs. +func TestDifferential_GetDocsTableIsolation(t *testing.T) { + is := newInvStoreHarness(t, Options{}) + defer is.teardown() + tblA, _ := is.s.CreateTable("A") + tblB, _ := is.s.CreateTable("B") + + is.update(tblA, 100, []string{"shared", "onlya"}) + is.update(tblB, 100, []string{"shared", "onlyb"}) + is.update(tblB, 200, []string{"shared"}) + is.flush() + + // Seal both tables into segments so GetDocs's exact-key match runs over the on-disk 4-byte + // tableId key prefix (design §5), not the trivial per-table head map — the production-format + // isolation the spike (no tableId) never had. Without this the tiny corpus stays in the head + // and the segment-prefix path goes untested. + is.s.forceSpill(tblA) + is.s.forceSpill(tblB) + if len(is.s.segs) < 2 { + t.Fatalf("expected both tables sealed into segments to exercise segment-level GetDocs isolation, got %d", len(is.s.segs)) + } + + a := is.s.GetDocs(tblA, "shared").DocIds + if _, ok := a[100]; !ok || len(a) != 1 { + t.Fatalf("GetDocs(A,\"shared\") must be exactly {100}, got %v", a) + } + b := is.s.GetDocs(tblB, "shared").DocIds + if _, ok := b[100]; !ok { + t.Fatalf("GetDocs(B,\"shared\") must contain 100, got %v", b) + } + if _, ok := b[200]; !ok || len(b) != 2 { + t.Fatalf("GetDocs(B,\"shared\") must be exactly {100,200}, got %v", b) + } + // A keyword only in table A must not appear in table B. + if got := is.s.GetDocs(tblB, "onlya").DocIds; len(got) != 0 { + t.Fatalf("GetDocs(B,\"onlya\") must be empty (A-only keyword), got %v", got) + } +} + +// ---- MUST-PASS: int64 docids — full range (the §11 owed re-measure) --------- + +// TestDifferential_Int64DocidFullRange closes the §11 "int64 docids — full range" owed re-measure: the +// spike computes posting/ordinal deltas in int32 space (byte-identical only for ids < 2^31 at its +// corpus). The production codec is int64 throughout (forwardKey writes an 8-byte BE docid; encodeDocs/ +// decodeDocs delta-varint over int64). This indexes docs whose ids span low, ~2^40, and math.MaxInt64-1 +// alongside a low id sharing a keyword, then asserts Search/GetDocs/forward round-trip every high id +// correctly across a spill AND a tiered merge — the real write/read path, not an estimate. invertedindex +// is the oracle for Search parity (its docid is int64 too), so a high id that survives one engine but +// not the other is caught. +func TestDifferential_Int64DocidFullRange(t *testing.T) { + hi := []int64{1 << 31, 1 << 40, 1 << 62, math.MaxInt64 - 1, math.MaxInt64} + lo := int64(7) + + ii := newInvIndexHarness(t) + defer ii.teardown() + tblII, err := ii.idx.CreateTable("files") + if err != nil { + t.Fatal(err) + } + is := newInvStoreHarness(t, Options{CapBytes: 1, Fanout: 2}) // tiny cap -> each edit spills its own seg + defer is.teardown() + tblIS, err := is.s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + // A low id and every high id all carry "wide"; each high id also carries a unique exact keyword. + feed := func(id int64, kws []string) { + ii.update(tblII, id, kws) + is.update(tblIS, id, kws) + } + feed(lo, []string{"wide", "lowonly"}) + for i, id := range hi { + feed(id, []string{"wide", fmt.Sprintf("hi%d", i)}) + is.flush() + is.s.forceSpill(tblIS) // spread the high ids across multiple L0 segments + } + ii.flush() + + // Parity on the shared prefix "wide": both engines must return the low id + every high id. + want := ii.search(tblII, "wide") + got := is.search(tblIS, "wide") + assertSameHits(t, "wide", want, got) + for _, id := range append([]int64{lo}, hi...) { + if _, ok := got[id]; !ok { + t.Fatalf("docid %d (0x%x) missing from store Search(wide) — int64 round-trip broken: %v", id, id, got) + } + } + + // Exact-key GetDocs + the forward map must round-trip each high id under its unique keyword. + for i, id := range hi { + kw := fmt.Sprintf("hi%d", i) + d := is.s.GetDocs(tblIS, kw).DocIds + if _, ok := d[id]; !ok || len(d) != 1 { + t.Fatalf("GetDocs(%q) must be exactly {%d (0x%x)}, got %v", kw, id, id, d) + } + words, deleted := is.s.forwardKeywords(tblIS, id) + if deleted || !containsStr(words, "wide") || !containsStr(words, kw) { + t.Fatalf("forward of high docid %d (0x%x) must round-trip {wide,%s}, got words=%v deleted=%v", id, id, kw, words, deleted) + } + } + + // Force a tiered merge spanning the high-id segments; the int64 ids must survive the ord->ord remap + // + re-encode (the merge re-delta-varints the posting lists — int64 deltas, not int32). + for is.s.mergeOneLevelForTest(t) { // collapse to the bottom level + } + got = is.search(tblIS, "wide") + assertSameHits(t, "wide (post-merge)", ii.search(tblII, "wide"), got) + for _, id := range append([]int64{lo}, hi...) { + if _, ok := got[id]; !ok { + t.Fatalf("docid %d (0x%x) lost across merge — int64 delta re-encode broken: %v", id, id, got) + } + } +} + +// ---- MUST-PASS: crash recovery (design §9 / T10, indexer-driven) ------------ + +// recoveryDoc is one source doc the indexer tracks: its current keywords and a monotonically increasing +// source version (mtime analogue). The indexer's durable cursor is "max version already indexed"; on +// reopen it re-Updates every doc with version > cursor and deletes docids in the store's forward map +// that are no longer in source. This is the §9 indexer-driven recovery contract, modeled exactly. +type recoveryDoc struct { + id int64 + version int64 + kws []string + deleted bool // true once the source removes the doc +} + +// TestDifferential_CrashRecovery_IdenticalHitSet is the design §9 / T10 acceptance case modeled through +// the store's REAL public API: a "crash" (crashAndReopen) loses the volatile head; on reopen the +// indexer, from its OWN durable cursor, re-Updates every source doc newer than the cursor — INCLUDING +// low-id docs edited just before the crash — and reconciles deletions (a docid the indexer knows it +// removed from source is re-Updated empty). The store keeps NO recovery watermark; recovery rides +// entirely on Update + the forwardKeywords resolution hook. After the replay the recovered hit set must +// be byte-identical to a clean invertedindex built from the final source state, AND a redundant second +// replay (idempotency) must leave it unchanged. +// +// Concretely the crash drops two kinds of head-resident edits the indexer must heal: +// - a LOW-id doc edited just before the crash (its new keywords were only in the volatile head), +// - a DELETE issued just before the crash (the forward-tombstone was only in the head), +// +// plus brand-new docs the indexer hadn't sealed yet. The §9 guarantee is that re-Updating from source +// is idempotent in result, so over-replay can't corrupt the index. +func TestDifferential_CrashRecovery_IdenticalHitSet(t *testing.T) { + docs, terms := genCorpus(800, 200, 0xCA5E) + + is := newInvStoreHarness(t, Options{CapBytes: 24 << 10}) // small cap -> some docs sealed, rest in head + defer is.teardown() + tbl, err := is.s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + // The indexer's source-of-truth view + its durable cursor (max version persisted before the crash). + src := map[int64]*recoveryDoc{} + var version int64 + put := func(id int64, kws []string) { + version++ + src[id] = &recoveryDoc{id: id, version: version, kws: kws} + is.update(tbl, id, kws) + } + del := func(id int64) { + version++ + if d := src[id]; d != nil { + d.deleted, d.version = true, version + } + is.update(tbl, id, nil) + } + + // Initial build, then spill PART of it so some docs are durable and the rest sit in the volatile head. + for _, d := range docs { + put(d.id, d.keywords) + } + is.flush() + is.s.forceSpill(tbl) // seal the build so far -> durable segment(s) + + // Post-seal edits that live ONLY in the volatile head (will be LOST by the crash): a low-id re-edit, + // a delete, and a couple of brand-new docs. These are exactly what the indexer must replay. + rng := rand.New(rand.NewSource(0xF00D)) + freshKws := func(n int) []string { + set := map[string]struct{}{} + for len(set) < n { + set[terms[rng.Intn(len(terms))]] = struct{}{} + } + out := make([]string, 0, n) + for w := range set { + out = append(out, w) + } + sort.Strings(out) + return out + } + put(1, freshKws(8)) // LOW-id doc edited just before crash (new kws only in the head) + put(2, freshKws(6)) // another low-id re-edit + del(3) // DELETE just before crash (forward-tombstone only in the head) + put(9001, freshKws(7)) // brand-new doc the indexer hadn't sealed yet + put(9002, freshKws(5)) + is.flush() // applied to the head, NOT spilled — these are the volatile, crash-lost edits + + // ---- CRASH: lose the volatile head; only fsync'd sealed segments survive. ---- + is.crashAndReopen() + + // The post-seal edits are GONE: doc 9001/9002 were never sealed, so the recovered store can't have + // them yet (proves the crash actually dropped the head — otherwise recovery would be vacuous). + for _, id := range []int64{9001, 9002} { + if words, deleted := is.s.forwardKeywords(tbl, id); !deleted && len(words) > 0 { + t.Fatalf("crash should have dropped unsealed doc %d, but it survived as %v — recovery test is vacuous", id, words) + } + } + + // ---- INDEXER-DRIVEN RECOVERY (design §9): from the indexer's own cursor, re-Update every source ---- + // doc newer than the cursor (incl. low ids) and reconcile deletions. The store has NO watermark; the + // indexer drives recovery via the public Update path + forwardKeywords for its idempotency/deletion + // check. We deliberately replay from cursor 0 (over-replay) to also prove idempotency on sealed docs. + replay := func() { + b := is.s.NewBatch() + for id, d := range src { + if d.deleted { + // Deletion reconcile: a docid still live in the store's forward but absent from source. + if _, deleted := is.s.forwardKeywords(tbl, id); !deleted { + b.Update(tbl, id, nil) + } + continue + } + b.Update(tbl, id, d.kws) // re-Update is idempotent in result (§9) + } + b.Commit() + is.s.sync() + } + replay() + + // Oracle: a clean invertedindex built once from the FINAL source state (deleted docs omitted). + ii := newInvIndexHarness(t) + defer ii.teardown() + tblII, err := ii.idx.CreateTable("files") + if err != nil { + t.Fatal(err) + } + liveDocs := make([]corpusDoc, 0, len(src)) + for id, d := range src { + if d.deleted { + continue + } + ii.update(tblII, id, d.kws) + liveDocs = append(liveDocs, corpusDoc{id: id, keywords: d.kws}) + } + ii.flush() + + queries := sampleQueriesDiff(liveDocs) + totalHits := 0 + for _, qy := range queries { + want := ii.search(tblII, qy) + got := is.search(tbl, qy) + assertSameHits(t, "recovered:"+qy, want, got) + totalHits += len(want) + } + if totalHits == 0 { + t.Fatal("recovered queries returned no hits — recovery emptied the index, parity is vacuous") + } + + // The low-id edit lost in the head must be re-applied (no stale postings), the delete must NOT + // resurrect, and doc 9001/9002 must now be present — the §9 targeted guarantees. + if w, deleted := is.s.forwardKeywords(tbl, 1); deleted || !sameSet(w, src[1].kws) { + t.Fatalf("recovered forward of low-id doc 1 must be its post-edit kws %v, got %v deleted=%v", src[1].kws, w, deleted) + } + if _, deleted := is.s.forwardKeywords(tbl, 3); !deleted { + t.Fatalf("deleted doc 3 must STAY deleted after recovery (no resurrection)") + } + if w, deleted := is.s.forwardKeywords(tbl, 9001); deleted || len(w) == 0 { + t.Fatalf("brand-new doc 9001 lost at crash must be re-applied by recovery, got %v deleted=%v", w, deleted) + } + + // ---- IDEMPOTENCY: a redundant second replay must leave the hit set unchanged (§9 over-replay). ---- + replay() + for _, qy := range queries { + assertSameHits(t, "idempotent:"+qy, ii.search(tblII, qy), is.search(tbl, qy)) + } + t.Logf("crash-recovery differential parity over %d queries: %d total hits identical (recovered == invertedindex final-state), idempotent under re-replay", len(queries), totalHits) +} + +// ---- helpers for the new cases ---------------------------------------------- + +func containsStr(ss []string, w string) bool { + for _, s := range ss { + if s == w { + return true + } + } + return false +} + +// sameSet (order-independent keyword-set equality) is shared with merge_test.go in this package. + +// ---- CI regression-guard benchmarks (design §11 / build-step 10) ------------ + +// benchCorpusDocs builds the shared benchmark corpus once (deterministic) so both build and memory +// numbers are comparable across runs. +func benchCorpusDocs(n int) []corpusDoc { + docs, _ := genCorpus(n, n/5+1, 0xB0BA) + return docs +} + +// BenchmarkBuild_MemoryCapped is the §1/§3 "memory is the hard constraint and it holds" CI regression +// guard: it cold-builds a multi-segment index UNDER A HARD MEMORY LIMIT (debug.SetMemoryLimit, the +// in-process equivalent of GOMEMLIMIT — the exact knob §3 reports pebble blowing up under). The store's +// memory is bounded by CapBytes regardless of corpus size, so a small cap must build comfortably under +// a tight limit; this benchmark fails (OOM / GC death) if a future change makes the build buffer grow +// unbounded like pebble's pendingWrites. It reports peak HeapAlloc and the spilled segment count as the +// regression metrics. AutoMerge ON so the foreground build also drives the backgrounded merge (the §11 +// "backgrounded-merge foreground time" owed re-measure: foreground stays the spill path, merge is off it). +func BenchmarkBuild_MemoryCapped(b *testing.B) { + const memLimit = 256 << 20 // 256 MiB, the §3 GOMEMLIMIT the comparison is reported against + prev := debug.SetMemoryLimit(memLimit) + defer debug.SetMemoryLimit(prev) + + docs := benchCorpusDocs(20000) + b.ResetTimer() + var peakHeap uint64 + var segCount int + for i := 0; i < b.N; i++ { + dir := b.TempDir() + q := queue.NewMpsc("bench-build") + q.Start() + // Small cap -> many spills -> the bounded-memory build path; AutoMerge keeps live seg count down. + s, err := Open(dir, q, Options{CapBytes: 256 << 10, Fanout: 4, AutoMerge: true}) + if err != nil { + b.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + b.Fatal(err) + } + batch := s.NewBatch() + for _, d := range docs { + batch.Update(tbl, d.id, d.keywords) + } + batch.Commit() + s.sync() + s.waitMergeIdle() // let the backgrounded merge settle (so the merge cost is measured, not hidden) + + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + if ms.HeapAlloc > peakHeap { + peakHeap = ms.HeapAlloc + } + segCount = len(s.segs) + s.CloseAndWait() + q.Stop() + } + b.ReportMetric(float64(peakHeap)/(1<<20), "peakHeapMiB") + b.ReportMetric(float64(segCount), "liveSegs") +} + +// BenchmarkCodeEditUpdate is the §8 code-edit incremental-update CI regression guard: a fixed set of +// "files" (docids) is re-edited over many rounds through Batch (the realistic editor workload — the +// same file touched repeatedly with a slightly changed keyword set), driving the term-id full-re-post + +// per-keyword-tombstone + forward-read path. It guards the §8 incremental-update cost (the price the +// term-id forward pays on edit) against regression. ns/op is per edited-file-update. +func BenchmarkCodeEditUpdate(b *testing.B) { + const nFiles = 256 + // A stable vocabulary so re-edits churn the SAME keywords across files (forward reads hit warm dict). + _, terms := genCorpus(1, 400, 0xED17) + rng := rand.New(rand.NewSource(0xED17)) + fileKws := func() []string { + n := 8 + rng.Intn(24) + set := map[string]struct{}{} + for len(set) < n { + set[terms[rng.Intn(len(terms))]] = struct{}{} + } + out := make([]string, 0, n) + for w := range set { + out = append(out, w) + } + sort.Strings(out) + return out + } + + dir := b.TempDir() + q := queue.NewMpsc("bench-edit") + q.Start() + s, err := Open(dir, q, Options{CapBytes: 512 << 10, Fanout: 4, AutoMerge: true}) + if err != nil { + b.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + b.Fatal(err) + } + // Seed the files once so every measured update is a real re-edit (forward-diff), not a cold add. + seed := s.NewBatch() + for id := int64(1); id <= nFiles; id++ { + seed.Update(tbl, id, fileKws()) + } + seed.Commit() + s.sync() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + id := int64((i % nFiles) + 1) + s.Update(tbl, id, fileKws()) // re-edit one file with a fresh keyword set (full re-post + tombstones) + if i%nFiles == nFiles-1 { + s.sync() // periodically drain so the worker queue can't grow unbounded across b.N + } + } + b.StopTimer() + s.sync() + s.waitMergeIdle() + s.CloseAndWait() + q.Stop() +} diff --git a/core/invertedstore/drain_merge_test.go b/core/invertedstore/drain_merge_test.go new file mode 100644 index 0000000..6e44525 --- /dev/null +++ b/core/invertedstore/drain_merge_test.go @@ -0,0 +1,55 @@ +package invertedstore + +import ( + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +// TestDrainMerge_PendingSignalMergesAtStop deterministically covers drainMerge's signal-pending +// branch. drainMerge runs on the merge goroutine when it observes mergeStop, and processes a merge +// trigger that was raised just before Close (so a last-second spill's segments still get merged). +// Via the normal stop path this branch is TIMING-FLAKY — mergeLoop's select{<-mergeStop; <-mergeSignal} +// picks randomly, so a pending signal is only ~50% observed by drainMerge (the rest are consumed by +// the main loop first). So exercise it directly: stop the background loop, inject a pending trigger, +// then call drainMerge and assert it collapses the >=Fanout L0 segments. +func TestDrainMerge_PendingSignalMergesAtStop(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("drainmerge") + q.Start() + defer q.Stop() + s, err := Open(dir, q, Options{AutoMerge: true, Fanout: 2}) + if err != nil { + t.Fatal(err) + } + tid, _ := s.CreateTable("files") + + // Stop the background merger so the trigger we raise below has no concurrent consumer, and + // neutralize mergeStop so CloseAndWait's stopMergeLoop is a no-op (stopMergeLoop is not + // idempotent — a second close(mergeStop) would panic). + s.stopMergeLoop() + s.mergeStop = nil + + // Two L0 segments (>= Fanout=2); each spill's triggerMerge coalesces into mergeSignal (cap 1), + // leaving exactly one pending trigger. + s.applyForTest(tid, 1, []string{"alpha"}) + s.spillForTest(tid) + s.applyForTest(tid, 2, []string{"alpha"}) + s.spillForTest(tid) + before := len(s.SegmentsForTest()) + if before < 2 { + t.Fatalf("want >= 2 L0 segments before drain, got %d", before) + } + + // A trigger is pending; drainMerge must take the <-mergeSignal branch and run the merge. + s.drainMerge() + + if after := len(s.SegmentsForTest()); after >= before { + t.Fatalf("drainMerge did not merge the pending L0 segments: before=%d after=%d", before, after) + } + // The merged index still resolves the posting written to both docs. + if res := s.Search(tid, "alpha", -1, nil); len(res.DocIds) != 2 { + t.Fatalf("after drain-merge Search(alpha) = %d docids, want 2", len(res.DocIds)) + } + s.CloseAndWait() +} diff --git a/core/invertedstore/export_test.go b/core/invertedstore/export_test.go new file mode 100644 index 0000000..7e5ec6e --- /dev/null +++ b/core/invertedstore/export_test.go @@ -0,0 +1,231 @@ +package invertedstore + +import ( + "strconv" + "sync/atomic" + "testing" +) + +// This file provides test-only accessors (compiled only under `go test`) that drive the real +// worker-side apply/spill so store_test.go can exercise the head buffer + spill path WITHOUT the +// full Update path (P7). They run their work on the mpsc worker, exactly as the production write +// path does, so the concurrency contract (head mutated only on the worker) is preserved. + +// applyForTest is a minimal stand-in for the P7 Update apply: for a cold doc (no diff against an +// older forward) it sets the doc's forward keyword set and adds a posting for each keyword. It +// runs on the worker and is synchronous (blocks until applied). +func (s *Store) applyForTest(tableId int, docid int64, keywords []string) { + s.q.RunFunc(func() error { + s.mu.Lock() + h := s.head[tableId] + if h == nil { + h = newHeadTable() + s.head[tableId] = h + } + if len(keywords) == 0 { + h.deleteForward(docid) + } else { + h.setForward(docid, keywords) + for _, kw := range keywords { + h.addPosting(kw, docid) + } + } + s.mu.Unlock() + return nil + }) +} + +// spillForTest forces a spill of the table's head on the worker (synchronous). It FIRST drains any +// in-flight off-worker spill (F v5): a tiny-CapBytes test that auto-detached the head via the real +// Update path must observe the detached head's segment installed before (and instead of double-sealing +// it) — so the synchronous spillForTest reconciles with the async path and stays deterministic. Then it +// synchronously spills whatever remains in the (post-detach) head. +func (s *Store) spillForTest(tableId int) { + s.WaitSpillsForTest() // let any in-flight async detach finish installing first + s.q.RunFunc(func() error { return s.spill(tableId) }) +} + +// SegmentsForTest returns a copy of the live segMeta set (the MANIFEST's segment list). +func (s *Store) SegmentsForTest() []segMeta { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]segMeta(nil), s.man.Segments...) +} + +// LiveByTableForTest returns a copy of the per-table live-pair counter. +func (s *Store) LiveByTableForTest() map[int]int64 { + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[int]int64, len(s.liveByTable)) + for k, v := range s.liveByTable { + out[k] = v + } + return out +} + +// RecomputeLiveForTest re-runs the Open-time live recompute on the worker (zeroes liveByTable first +// so the test verifies the recompute reproduces the value, not that it merely left it alone). +func (s *Store) RecomputeLiveForTest() { + s.q.RunFunc(func() error { + s.mu.Lock() + s.liveByTable = map[int]int64{} + s.recomputeLive() + s.mu.Unlock() + return nil + }) +} + +// dropHeadCloseSegmentsForTest simulates a process crash for the recovery tests (T11/§9): it discards +// the volatile in-memory head (every apply that has NOT yet spilled to a sealed segment is LOST) and +// closes the open segment fds WITHOUT spilling the head, keeping the on-disk files so the next Open +// finds exactly the durable, MANIFEST-named segments. This is deliberately NOT CloseAndWait — +// CloseAndWait spills the head first (a clean close), which is the opposite of a crash. It mirrors +// CloseAndWait's segment teardown (stop the merge loop, publish emptySnapshot so no late reader +// acquires a ref, retireKeepFile each segment) but drops the head map instead of flushing it, leaving +// the store in the design §9 crash-consistency state: sealed segments durable, head volatile/lost. +func (s *Store) dropHeadCloseSegmentsForTest() { + s.spillWG.Wait() // F v5: let any in-flight off-worker encode finish (it may still be writing a file) + s.stopMergeLoop() // drain + stop the background merger before any fd is closed + s.mu.Lock() + s.head = map[int]*headTable{} // the crash: the volatile head is simply gone + segs := s.segs + s.segs = nil + s.snap.Store(emptySnapshot) // drop the published set first so no late reader acquires a ref + s.mu.Unlock() + for _, seg := range segs { + seg.retireKeepFile() // close the fd, keep the file (still live in the on-disk MANIFEST) + } +} + +// DeadFractionForTest exposes the covering-merge trigger value. +func (s *Store) DeadFractionForTest() float64 { return s.deadFraction() } + +// uniqWord makes a per-index distinct keyword (so each doc adds a unique posting). +func uniqWord(n int) string { return "w" + strconv.Itoa(n) } + +// installCoveringCounter installs the package coveringMergeHook to count covering merges (covering +// BOTH the dead-fraction-triggered and the DeleteTable/orphan forced paths). Read via .Load(). The +// hook may run on the worker (synchronous coveringMerge) OR on the merge goroutine (the off-worker +// covering path fires it post-install in runMergePlan), so the atomic keeps it -race clean. Cleared on +// test cleanup. +func installCoveringCounter(t *testing.T) *atomic.Int64 { + t.Helper() + var n atomic.Int64 + coveringMergeHook = func() { n.Add(1) } + t.Cleanup(func() { coveringMergeHook = nil }) + return &n +} + +// assertCounterInvariantForTest checks the spec §5 invariant: live (catalog-gated) never goes +// negative and exceeds sealed `written` by at most a head's worth of postings (≤ CapBytes, since a +// posting is ≥ 1 byte). A larger excess signals a counter over-count bug the deadFraction clamp would +// otherwise silently swallow. +func (s *Store) assertCounterInvariantForTest(t *testing.T) { + t.Helper() + s.mu.RLock() + defer s.mu.RUnlock() + var written, live int64 + for _, sm := range s.man.Segments { + written += sm.Postings + } + for tid, v := range s.liveByTable { + if _, ok := s.man.Tables[tid]; ok { + live += v + } + } + if live < 0 { + t.Fatalf("counter invariant: live=%d went negative", live) + } + if live-written > int64(s.opts.CapBytes) { + t.Fatalf("counter invariant: live(%d) - written(%d) = %d exceeds headCap(%d) — over-count bug", + live, written, live-written, s.opts.CapBytes) + } +} + +// installForwardProbeCounter counts segment forward PROBES (non-skipped lookupForward calls). The +// hook runs on the worker; the atomic keeps it -race clean. Cleared on cleanup. +func (s *Store) installForwardProbeCounter(t *testing.T) *atomic.Int64 { + t.Helper() + var n atomic.Int64 + s.onForwardProbe = func() { n.Add(1) } + t.Cleanup(func() { s.onForwardProbe = nil }) + return &n +} + +// forwardKeywordsForTest runs forwardKeywords on the worker (synchronous), so a test can drive the +// "read old keyword set" path directly and observe the probe counter. +func (s *Store) forwardKeywordsForTest(tableId int, docid int64) (words []string, deleted bool) { + s.q.RunFunc(func() error { + words, deleted = s.forwardKeywords(tableId, docid) + return nil + }) + return +} + +// segRefsByIdForTest returns the current refcount of the live segment handle with the given id, or -1 +// if no live handle has that id (it was already retired + torn down, or never existed). Used by the +// off-worker merge tests (A) to assert each input segment is ref-held (>= 2: the published snapshot +// ref + the plan's incref) across the off-worker mergeSegments compute, so a concurrent retire cannot +// free an input mid-read. Reads s.segs under the lock; the refs themselves are atomic. +func (s *Store) segRefsByIdForTest(id uint64) int64 { + s.mu.RLock() + defer s.mu.RUnlock() + for _, seg := range s.segs { + if seg.id == id { + return seg.refs.Load() + } + } + return -1 +} + +// injectSpillingHeadForTest detaches tableId's CURRENT head into s.spilling WITHOUT encoding it (the +// head stays readable as a spilling tier) — a test stand-in for 7B's real detach, so 7A's read tiers +// can be tested without driving the async encode. The seg id is assigned at install (v5), so this +// reserves only a temp-file counter. Runs on the worker. +func (s *Store) injectSpillingHeadForTest(tableId int) { + s.q.RunFunc(func() error { + s.mu.Lock() + defer s.mu.Unlock() + h := s.head[tableId] + if h == nil { + return nil + } + s.head[tableId] = newHeadTable() + minD, maxD := headForwardRange(h) + s.spillTempCtr++ + s.spilling = append(s.spilling, &spillEntry{tableId: tableId, head: h, tempN: s.spillTempCtr, + minDocid: minD, maxDocid: maxD}) + return nil + }) +} + +// SpillingLenForTest returns the number of currently-detached spilling heads (item F observability). +func (s *Store) SpillingLenForTest() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.spilling) +} + +// SpillInFlightForTest reports whether a detached head is currently being encoded off-worker (F v5). +func (s *Store) SpillInFlightForTest() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.spillInFlight +} + +// WaitSpillsForTest blocks until every in-flight off-worker encode/install goroutine has finished +// (F v5). A test that drives the async detach MUST drain these before its t.TempDir() is removed, or a +// still-running encode panics writing into the deleted dir. Drives the worker too so a re-dispatched +// install lands. Idempotent. +func (s *Store) WaitSpillsForTest() { + s.spillWG.Wait() + s.q.RunFunc(func() error { return nil }) // flush any install RunFunc the last encode enqueued + s.spillWG.Wait() // and any spill that install re-dispatched +} + +// BlockProducerForTest reports whether the producer gate is currently engaged (F v5). +func (s *Store) BlockProducerForTest() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.blockProducer +} diff --git a/core/invertedstore/foreach_forward_test.go b/core/invertedstore/foreach_forward_test.go new file mode 100644 index 0000000..e583cd0 --- /dev/null +++ b/core/invertedstore/foreach_forward_test.go @@ -0,0 +1,32 @@ +package invertedstore + +import "testing" + +// forEachLiveSegmentForward surfaces each live docid's ORDS (newest-wins, tombstones excluded) — +// the data ForwardDocids discards but the Open live-recompute needs. Duplicate ords (a doc indexed +// with duplicate keywords) collapse under distinctOrds to the distinct-keyword count. +func TestForEachLiveSegmentForward_SurfacesOrds(t *testing.T) { + s, tid := newUpdateStore(t) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + s.applyForTest(tid, 1, []string{"a", "b", "c"}) + s.applyForTest(tid, 2, []string{"a", "a", "b"}) // raw forward keeps the dup + s.spillForTest(tid) + + got := map[int64]int{} + s.mu.RLock() + segs := append([]*segment(nil), s.segs...) + s.mu.RUnlock() + s.forEachLiveSegmentForward(tid, map[int64]struct{}{}, segs, + func(docid int64, ords []uint32, deleted bool) bool { + if !deleted { + got[docid] = len(distinctOrds(ords)) + } + return true + }) + if got[1] != 3 { + t.Fatalf("doc 1 distinct ords = %d, want 3", got[1]) + } + if got[2] != 2 { + t.Fatalf("doc 2 distinct ords (dup-collapsed) = %d, want 2", got[2]) + } +} diff --git a/core/invertedstore/forward_skip_test.go b/core/invertedstore/forward_skip_test.go new file mode 100644 index 0000000..93a3efd --- /dev/null +++ b/core/invertedstore/forward_skip_test.go @@ -0,0 +1,163 @@ +package invertedstore + +import ( + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +// newForwardSkipStore mirrors newMergeStore: a started queue + Open + one table (AutoMerge off). +func newForwardSkipStore(t *testing.T, opts Options) (*Store, int) { + t.Helper() + q := queue.NewMpsc("fwdskip") + q.Start() + s, err := Open(t.TempDir(), q, opts) + if err != nil { + t.Fatal(err) + } + tid, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + // Release every open segment fd at test end (retireKeepFile via CloseAndWait). Without this the + // read fds a spill opens stay live, and on Windows t.TempDir's RemoveAll cannot delete seg-*.dat + // while a handle is open. No caller of this helper closes the store itself, so one close here is safe. + t.Cleanup(func() { s.CloseAndWait() }) + return s, tid +} + +// Three sealed segments with DISJOINT ascending docid ranges (one table). A docid above all ranges +// probes 0 segments; an in-range docid probes only the covering segment. +func TestForwardSkip_ProbesOnlyCoveringSegment(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + // Seal three segments: docids [1..3], [10..12], [20..22]. + for _, base := range []int64{1, 10, 20} { + for d := base; d < base+3; d++ { + s.applyForTest(tid, d, []string{uniqWord(int(d))}) + } + s.spillForTest(tid) + } + if got := len(s.SegmentsForTest()); got != 3 { + t.Fatalf("want 3 segments, got %d", got) + } + + probes := s.installForwardProbeCounter(t) + + // A brand-new high docid (cold-build shape) is above every range → 0 probes. + probes.Store(0) + s.forwardKeywordsForTest(tid, 999) + if n := probes.Load(); n != 0 { + t.Fatalf("new high docid probed %d segments, want 0 (all range-skipped)", n) + } + + // An in-range docid (11) probes ONLY the [10..12] segment → exactly 1 probe. + probes.Store(0) + words, _ := s.forwardKeywordsForTest(tid, 11) + if n := probes.Load(); n != 1 { + t.Fatalf("in-range docid probed %d segments, want 1", n) + } + if len(words) != 1 || words[0] != uniqWord(11) { + t.Fatalf("forward for docid 11 = %v, want [%s]", words, uniqWord(11)) + } +} + +// An [I]-present, [F]-absent segment (a head that only added postings via the test stub never sets a +// forward — but for the real path: a spill of only deletes emits forward-tombstones; here assert the +// empty-range case always-skips). Build a segment with NO forward records and confirm it is skipped. +func TestForwardSkip_EmptyForwardRangeAlwaysSkips(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + // addPosting without setForward → [I] present, [F] absent (exercised via a worker task). + s.q.RunFunc(func() error { + s.mu.Lock() + h := newHeadTable() + h.addPosting("orphanKw", 7) + s.head[tid] = h + s.mu.Unlock() + return s.spill(tid) + }) + sm := s.SegmentsForTest() + if len(sm) != 1 || sm[0].MinDocid <= sm[0].MaxDocid { + t.Fatalf("forward-absent segment should have an empty (min>max) range, got %+v", sm) + } + probes := s.installForwardProbeCounter(t) + s.forwardKeywordsForTest(tid, 7) + if n := probes.Load(); n != 0 { + t.Fatalf("empty-range segment probed %d times, want 0", n) + } +} + +// A pre-B (FormatVersion 2) manifest has no docid range — the segMeta MinDocid/MaxDocid unmarshal to +// [0,0], a VALID-looking range that would mis-skip every docid != 0. Open of a < 3 manifest must +// recompute each segment's range from its forward records and rewrite at v3. Build a real segment, +// crash-close, hand-craft the on-disk MANIFEST back to v2 with a stale [0,0] range, then reopen and +// assert the range is corrected, FormatVersion == 3, and an in-range read still resolves+skips right. +func TestForwardSkip_LegacyManifestUpgrade(t *testing.T) { + dir := t.TempDir() + s := openAt(t, dir, Options{AutoMerge: false, CapBytes: 1 << 20}) + tid, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + for d := int64(10); d <= 12; d++ { + s.applyForTest(tid, d, []string{uniqWord(int(d))}) + } + s.spillForTest(tid) + if got := len(s.SegmentsForTest()); got != 1 { + t.Fatalf("want 1 segment, got %d", got) + } + s.dropHeadCloseSegmentsForTest() // close fds, keep the segment file + MANIFEST on disk + + // Hand-craft the on-disk MANIFEST back to a pre-B v2 with a stale [0,0] range on every segment. + man, err := readManifest(dir) + if err != nil { + t.Fatal(err) + } + man.FormatVersion = 2 + for i := range man.Segments { + man.Segments[i].MinDocid = 0 + man.Segments[i].MaxDocid = 0 + } + if err := writeManifest(dir, man); err != nil { + t.Fatal(err) + } + + // Reopen: Open must detect FormatVersion < 3 and recompute each segment's range. + s2 := openAt(t, dir, Options{AutoMerge: false, CapBytes: 1 << 20}) + defer s2.CloseAndWait() // release the reopened store's segment fds (Windows RemoveAll) + sm := s2.SegmentsForTest() + if len(sm) != 1 { + t.Fatalf("want 1 segment after reopen, got %d", len(sm)) + } + if sm[0].MinDocid != 10 || sm[0].MaxDocid != 12 { + t.Fatalf("upgraded range = [%d,%d], want [10,12]", sm[0].MinDocid, sm[0].MaxDocid) + } + + // The on-disk MANIFEST must now be at v3 with the corrected range persisted. + man2, err := readManifest(dir) + if err != nil { + t.Fatal(err) + } + if man2.FormatVersion != 3 { + t.Fatalf("on-disk FormatVersion = %d, want 3 after upgrade", man2.FormatVersion) + } + if man2.Segments[0].MinDocid != 10 || man2.Segments[0].MaxDocid != 12 { + t.Fatalf("persisted range = [%d,%d], want [10,12]", man2.Segments[0].MinDocid, man2.Segments[0].MaxDocid) + } + + // An in-range read still resolves through the corrected range (1 probe), and an out-of-range + // docid is skipped (0 probes) — the stale [0,0] would have mis-skipped docid 11 entirely. + probes := s2.installForwardProbeCounter(t) + probes.Store(0) + words, _ := s2.forwardKeywordsForTest(tid, 11) + if n := probes.Load(); n != 1 { + t.Fatalf("in-range docid 11 probed %d segments, want 1", n) + } + if len(words) != 1 || words[0] != uniqWord(11) { + t.Fatalf("forward for docid 11 = %v, want [%s]", words, uniqWord(11)) + } + probes.Store(0) + s2.forwardKeywordsForTest(tid, 999) + if n := probes.Load(); n != 0 { + t.Fatalf("out-of-range docid 999 probed %d segments, want 0", n) + } +} diff --git a/core/invertedstore/head.go b/core/invertedstore/head.go new file mode 100644 index 0000000..40b4979 --- /dev/null +++ b/core/invertedstore/head.go @@ -0,0 +1,562 @@ +package invertedstore + +import ( + "os" + "path/filepath" + "sort" +) + +// postingDelta is a keyword's pending head state for one spill window: an ORDERED log of the +// (add|del) operations applied to (keyword, docid) pairs, APPENDED in action order (item H, spec +// §5b). docids are stored RAW in `ops` (one int64 each) and the per-op isAdd flag in a parallel +// bitset `isAdd` (one BIT each, 64 ops per uint64 word) — ~8 B/op + ~0.125 B/op for the flag, far +// below the per-keyword Go map the eager adds/dels sets cost (~48–96 B/entry + two map headers). +// +// Raw int64 docids + a parallel bitset (spec §5b's named full-range fallback) replace the earlier +// `docid<<1 | isAdd` single-int64 packing: that packing stole the low bit, so it could only carry +// docids in [0, 2^62) and the spec's full-int64-range invariant (the §11 owed re-measure, which +// round-trips docids up to math.MaxInt64) was unrepresentable. The raw-docid+bitset form costs the +// same ~8 B/op yet carries the WHOLE int64 range — no out-of-range case to assert away. +// +// The two jobs the old maps did are recovered at CONSUME time by resolveOps: dedup-on-insert is +// redundant (the on-disk encode appendDeltaDocs sort+dedups anyway), and the cross add-vs-del +// "latest action wins" rule is resolved by a STABLE sort keyed on docid only — the LAST op per +// docid is the survivor. +type postingDelta struct { + docids []int64 // raw docid per op, APPENDED in action order + isAdd []uint64 // parallel bitset: bit i is set iff ops[i] is an add (else a tombstone) +} + +// nOps returns the number of ops appended to pd. +func (pd *postingDelta) nOps() int { return len(pd.docids) } + +// opAt returns the docid and isAdd flag of the i-th op. +func (pd *postingDelta) opAt(i int) (docid int64, isAdd bool) { + return pd.docids[i], pd.isAdd[i>>6]&(1<>6 >= len(pd.isAdd) { + pd.isAdd = append(pd.isAdd, 0) + } + if isAdd { + pd.isAdd[i>>6] |= 1 << uint(i&63) + } + pd.docids = append(pd.docids, docid) +} + +// headTable is the per-table in-memory head buffer (worker-owned; read under the Store RWMutex). +// It holds the inverted deltas (keyword -> adds/dels), the forward entries (docid -> keyword +// strings, encoded to segment-local term-ids at spill), the set of docids whose forward is a +// tombstone (deleted docs), and a running logical byte estimate that drives spill. +type headTable struct { + inv map[string]*postingDelta // keyword -> ordered add/del op log (resolved latest-wins at consume) + fwd map[int64][]string // docid -> keyword strings (-> ordinals at spill) + delForward map[int64]struct{} // docids whose forward is a tombstone + bytes int64 // logical byte estimate (matches the spike's accounting) +} + +func newHeadTable() *headTable { + return &headTable{ + inv: map[string]*postingDelta{}, + fwd: map[int64][]string{}, + delForward: map[int64]struct{}{}, + } +} + +// posting returns keyword's postingDelta, creating an empty one (nil ops slices) on first sight and +// charging a slice-header-sized estimate (item H, spec §5b: the old eager version charged +16 for its +// two map headers; the per-keyword fixed charge is +24). The struct now holds TWO slices (docids + +// isAdd), but both headers are EMPTY/nil at creation — no backing array is allocated until the first +// appendOp — so the fixed charge stays at one slice-header (24 B): the docids array's per-op growth is +// the +8 charged below, and the isAdd bitset's growth (one uint64 word per 64 ops, ~0.125 B/op) is +// folded into that same +8. The per-op cost (h.bytes += 8) is charged in addPosting/tombstonePosting, +// so h.bytes ≈ the actual memory (slightly UNDER-counting by the bitset's amortized word: ~8.125 B/op +// actual vs +8 charged) and CapBytes is honest. Keeping +24 (not +48) matches the spec §5b PRIMARY single-`ops`-slice cadence, so +// the fallback's spill cadence is the one the spec measured. +func (h *headTable) posting(keyword string) *postingDelta { + pd := h.inv[keyword] + if pd == nil { + pd = &postingDelta{} + h.inv[keyword] = pd + h.bytes += int64(len(keyword)) + 24 // keyword string + one ops slice header (spec §5b cadence) + } + return pd +} + +// addPosting records that docid is a member of keyword (item H): an O(1) append of (docid, isAdd=true) +// in ACTION order — no lookup, no per-keyword map, no in-memory dedup (resolveOps recovers latest-wins +// + dedup at consume time). docids are stored RAW alongside a parallel isAdd bitset, so the FULL int64 +// range round-trips (spec §5b's named full-range representation); there is no packable-range precondition. +func (h *headTable) addPosting(keyword string, docid int64) { + h.posting(keyword).appendOp(docid, true) + h.bytes += 8 +} + +// tombstonePosting records that docid is removed from keyword (item H). Symmetric to addPosting: an +// O(1) append of (docid, isAdd=false) in action order. Full int64 range, no precondition. +func (h *headTable) tombstonePosting(keyword string, docid int64) { + h.posting(keyword).appendOp(docid, false) + h.bytes += 8 +} + +// setForward records the doc's current full keyword set (clears any pending tombstone for it). +func (h *headTable) setForward(docid int64, words []string) { + delete(h.delForward, docid) + h.fwd[docid] = words + h.bytes += int64(8 + len(words)*4) +} + +// deleteForward records that the doc is deleted (forward-tombstone): drop any pending forward +// entry and mark the docid for an explicit tombstone record at spill, so an older non-empty +// forward record in a sealed segment can never win and resurrect the doc. +func (h *headTable) deleteForward(docid int64) { + delete(h.fwd, docid) + h.delForward[docid] = struct{}{} + h.bytes += 12 +} + +// spill writes the current head for tableId as one immutable L0 segment, appends its segMeta, +// durably rewrites the MANIFEST, publishes the opened segment into s.segs, and resets the head. +// MUST run on the worker (it mutates s.man/s.segs/s.head). Mirrors the spike's spill shape +// (cmd/sortbench/main.go func spill) but uses the production segWriter/encoders, int64 docids, +// the 4-byte tableId keys, and the nKw-prefixed forward value (incl. explicit forward-tombstones). +// +// This is the SYNCHRONOUS spill (spillForTest + the CloseAndWait flush): it encodes the head AND +// installs the segment, both on the worker. The hot build path instead uses the OFF-WORKER detach + +// encode + install (dispatchSpill/encodeSpill/installSpill, F v5). +func (s *Store) spill(tableId int) error { + s.mu.RLock() + h := s.head[tableId] + s.mu.RUnlock() + if h == nil || (len(h.inv) == 0 && len(h.fwd) == 0 && len(h.delForward) == 0) { + return nil + } + + s.mu.RLock() + segId := s.man.NextSegId + s.mu.RUnlock() + path := filepath.Join(s.dir, segFileName(segId)) + res := s.encodeHeadToFile(h, tableId, path) + seg := res.seg + seg.id = segId // P5: chunk-LRU keys decompressed dict chunks by (segmentId, chunkIdx) + seg.minDocid, seg.maxDocid = res.minD, res.maxD // B + seg.refs.Store(1) // P9: the published snapshot holds one ref on this newly sealed segment + sm := s.spillSegMeta(res, segId, tableId) + + // Persist the new MANIFEST, then publish — but keep the slow fsync OUT of the reader-blocking + // critical section (P9/T8, design §6: "readers never block on a writer's I/O — the lock is held + // only for the O(1) pointer swap and ref bookkeeping, never for spill/merge/file work"). All + // writes run on the single mpsc worker, so there is no concurrent writer of s.man; the lock here + // guards s.man only against concurrent READERS (tableInfo). So: (a) under the lock, append the + // segMeta + bump NextSegId + marshal the manifest to bytes (cheap, no I/O); (b) OUTSIDE the lock, + // do the two fsyncs (writeManifestBytes) — a concurrent Search/GetDocs is not blocked on them; (c) + // re-take the lock only for the O(1) s.segs append + publishSnapshotLocked + head reset. + s.mu.Lock() + s.man.Segments = append(s.man.Segments, sm) + s.man.NextSegId++ + b, err := marshalManifest(s.man) + if err != nil { + s.man.Segments = s.man.Segments[:len(s.man.Segments)-1] // roll back the in-memory manifest + s.man.NextSegId-- + s.mu.Unlock() + seg.refs.Store(0) // never published: drop the ref we just took before closing + seg.close() + return err + } + s.mu.Unlock() + + if err := writeManifestBytes(s.dir, b); err != nil { + // The fsync failed: roll the in-memory manifest back to the pre-spill set so it stays + // consistent with the still-old on-disk MANIFEST and with s.segs (which we never touched). + s.mu.Lock() + s.man.Segments = s.man.Segments[:len(s.man.Segments)-1] + s.man.NextSegId-- + s.mu.Unlock() + seg.refs.Store(0) // never published: drop the ref we just took before closing + seg.close() + return err + } + + s.mu.Lock() + s.segs = append(s.segs, seg) + s.publishSnapshotLocked() // P9: republish the live set (this spill's new segment) for readers + s.head[tableId] = newHeadTable() + s.mu.Unlock() + + // Background merger (design §6, P8/P9): a new L0 segment may push a level to >= Fanout, or push the + // bottom level's dead fraction over the covering threshold. When AutoMerge is on, raise a + // NON-BLOCKING trigger on the background merge goroutine (concurrency.go). spill runs ON the worker, + // so it MUST NOT send a task to its own queue (s.q.AddFunc would block-send and self-deadlock once + // the queue fills — the worker is the only consumer). triggerMerge just flips a flag/channel; the + // merge goroutine drives the actual passes back onto the worker via RunFunc. + s.triggerMerge(false) + return nil +} + +// spillResult is the product of encoding a head into a segment file (the read-only, install-free part +// of a spill, shared by the synchronous spill + the off-worker encodeSpill): the opened segment and +// the segMeta fields derived at encode time (the forward docid span + the posting count). +type spillResult struct { + seg *segment + minD, maxD int64 + postings int64 +} + +// spillSegMeta builds the L0 segMeta for an encoded head, given the seg id (assigned at install for +// the off-worker path) and the table it belongs to. The codecs come from s.opts (the values +// encodeHeadToFile actually used), so the meta matches the bytes on disk. +func (s *Store) spillSegMeta(r spillResult, id uint64, tableId int) segMeta { + tid := uint32(tableId) + return segMeta{ + Id: id, Level: 0, + DataCodec: s.opts.DataCodecL0, DictCodec: s.opts.DictCodec, + MinTable: tid, MaxTable: tid, + Size: fileSize(r.seg.path), + Postings: r.postings, + MinDocid: r.minD, MaxDocid: r.maxD, + } +} + +// encodeHeadToFile encodes head (READ-ONLY) for tableId into the segment file at path and returns the +// opened segment + its derived segMeta fields. It does NOT touch s.man/s.segs/s.head, so it is safe to +// run OFF the worker over a DETACHED head (F v5): the only shared state it reads is s.dir/s.opts (both +// immutable after Open). The synchronous spill and the off-worker encodeSpill share it byte-for-byte. +func (s *Store) encodeHeadToFile(h *headTable, tableId int, path string) spillResult { + // 1. The term dict is the union of keywords with adds and keywords with tombstones; both + // are [I] records. Sort once: that single sort yields the sorted inverted order AND each + // keyword's ordinal (its term-id) for the term-id forward value. + terms := make([]string, 0, len(h.inv)) + for kw := range h.inv { + terms = append(terms, kw) + } + sort.Strings(terms) + kw2ord := make(map[string]uint32, len(terms)) + for i, t := range terms { + kw2ord[t] = uint32(i) + } + + // 2. New L0 segment writer: snappy data blocks, the dict codec, term-id mode on. + w := newSegWriter(path, + newCodec(s.opts.DataCodecL0), newCodec(s.opts.DictCodec), + s.opts.BlockTarget, s.opts.Chunk, s.opts.InlineThreshold, true, s.opts.DictChunkBytes) + + // 3. Inverted records in sorted term order: [I] tableId keyword -> invertedValue(adds,dels). + tid := uint32(tableId) + var postings int64 // count add+del entries for segMeta.Postings (the deadFraction `written` term) + for _, t := range terms { + pd := h.inv[t] + adds, dels := resolveOps(pd) + postings += int64(len(adds) + len(dels)) + w.addEntry(invertedKey(tid, t), encodeInvertedValue(adds, dels)) + } + + // 4. Forward records ascending by docid (== ascending forward key, and [I] < [F], so no + // second full sort). A live doc -> term-id forward value; a deleted doc -> forward + // tombstone. Both key spaces are merged into one ascending docid stream. + type fwdRec struct { + docid int64 + deleted bool + words []string + } + recs := make([]fwdRec, 0, len(h.fwd)+len(h.delForward)) + for d, words := range h.fwd { + recs = append(recs, fwdRec{docid: d, words: words}) + } + for d := range h.delForward { + recs = append(recs, fwdRec{docid: d, deleted: true}) + } + sort.Slice(recs, func(i, j int) bool { return recs[i].docid < recs[j].docid }) + // B: the forward-read skip range covers every EMITTED forward record (live + tombstone). recs is + // sorted ascending by docid, so the span is its ends; an empty recs keeps the always-skip range. + minD, maxD := emptyDocidRange() + if len(recs) > 0 { + minD, maxD = recs[0].docid, recs[len(recs)-1].docid + } + for _, r := range recs { + if r.deleted { + w.addEntry(forwardKey(tid, r.docid), forwardTombstone()) + continue + } + ords := make([]uint32, 0, len(r.words)) + for _, word := range r.words { + ords = append(ords, kw2ord[word]) + } + w.addEntry(forwardKey(tid, r.docid), encodeForward(ords)) + } + + // 5. Seal: finish() fsyncs the file and returns the opened segment. + seg := w.finish(path) + return spillResult{seg: seg, minD: minD, maxD: maxD, postings: postings} +} + +// detachHeadLocked detaches tableId's current head for an OFF-WORKER encode (F v5). The CALLER must +// hold s.mu.Lock AND must have already verified (in the SAME lock section) that the head is over-cap +// and !spillInFlight, so two applies can never both detach (one-in-flight stays race-free). It swaps +// the live head for a fresh one, pushes the old head onto s.spilling (readers resolve it as a tier +// between the live head and the segments — B1), reserves a temp-file counter (NOT a seg id; the id is +// assigned at install), and sets spillInFlight. It returns the entry to dispatch (encode + install) on +// a background goroutine OUTSIDE the lock — detachHeadLocked itself does NO I/O. +func (s *Store) detachHeadLocked(tableId int) *spillEntry { + h := s.head[tableId] + s.head[tableId] = newHeadTable() + minD, maxD := headForwardRange(h) + s.spillTempCtr++ + e := &spillEntry{tableId: tableId, head: h, tempN: s.spillTempCtr, minDocid: minD, maxDocid: maxD} + s.spilling = append(s.spilling, e) + s.spillInFlight = true + return e +} + +// dispatchSpill spawns the background goroutine that encodes the detached head OFF the worker and then +// installs it ON the worker (F v5). The encode is read-only over the detached head (M2); the install +// is a worker RunFunc (single-mutator preserved). The install is RETRIED on a transient write failure +// up to MaxInstallRetries, then GIVEN UP: a persistently-failing disk would otherwise wedge the +// producer at the gate forever, so a give-up drops the detached head (treated as crash-volatile), +// clears spillInFlight/blockProducer + broadcasts, and re-dispatches any over-cap head — exactly the +// install's own liveness path. spillWG tracks the goroutine so CloseAndWait can drain it OFF the worker. +func (s *Store) dispatchSpill(e *spillEntry) { + s.spillWG.Add(1) + go func() { + defer s.spillWG.Done() + res := s.encodeSpill(e) + for attempt := 0; ; attempt++ { + var err error + s.q.RunFunc(func() error { err = s.installSpill(e, res); return nil }) + if err == nil { + return + } + if attempt+1 >= s.opts.MaxInstallRetries { + // Give up: the disk is persistently failing. Drop the detached head (crash-volatile — + // indexer replay recovers it), remove its temp file, clear the in-flight + gate state, and + // re-dispatch any over-cap head so the producer makes progress. Runs on the worker. + s.q.RunFunc(func() error { return s.giveUpSpill(e, res) }) + return + } + } + }() +} + +// encodeSpill encodes the detached head into its temp file (seg-tmp-.dat) OFF the worker — strictly +// READ-ONLY over the detached head (M2), touching no shared mutable state. encodeSpillBlock (test-only) +// parks here so a test can hold the head in s.spilling across a re-post (the B1 gate). +func (s *Store) encodeSpill(e *spillEntry) spillResult { + if encodeSpillBlock != nil { + encodeSpillBlock() + } + tempPath := filepath.Join(s.dir, segTempFileName(e.tempN)) + return s.encodeHeadToFile(e.head, e.tableId, tempPath) +} + +// renameSegmentFile renames seg's backing file from -> to, RESEATING seg's open fd across the +// rename. It closes seg.f before os.Rename and reopens it at the resulting path after — because a +// rename of a file whose handle is still open is refused on Windows: os.Open (which openSegment uses +// to open the segment) requests share mode FILE_SHARE_READ|FILE_SHARE_WRITE with NO FILE_SHARE_DELETE, +// and os.Rename == MoveFileEx(from,to,MOVEFILE_REPLACE_EXISTING) must open the source with DELETE +// access, so the missing share flag makes the rename fail with ERROR_SHARING_VIOLATION. POSIX renames +// an open fd fine (the fd follows the inode), so closing+reopening is behaviorally a no-op there — an +// extra close/open on a segment NO reader can yet reach (it is not published into s.segs until after a +// successful install), so there is no race and POSIX durability/correctness is unchanged. A rename does +// not change the file's BYTES, so seg's already-parsed footer / block index / codecs stay valid; only +// the fd (and seg.path) are reseated. On a rename error the fd is reopened at `from` (the file did not +// move) so the caller's bounded retry / rollback still sees a live segment; seg.path is set to the +// resulting name in both cases. +func renameSegmentFile(seg *segment, from, to string) error { + seg.close() + if err := os.Rename(from, to); err != nil { + seg.f, _ = os.Open(from) // rename failed: the file is still at `from`; reseat the fd there + seg.path = from + return err + } + seg.f, _ = os.Open(to) + seg.path = to + return nil +} + +// installSpill installs the off-worker-encoded segment ON the worker (F v5). MUST run on the worker +// (it mutates s.man/s.segs/s.spilling). The seg id is assigned HERE (install order ⇒ correct +// newest-wins), the temp file is renamed atomically to seg-.dat (renameSegmentFile reseats the +// open fd across the rename so it is Windows-safe), the segMeta is appended + the MANIFEST durably +// rewritten (persist-then-publish), the snapshot is republished, and the entry is removed from +// s.spilling — PUBLISH BEFORE REMOVE, so a reader never sees +// the doc in NEITHER tier. Finally spillInFlight is cleared, blockProducer cleared + broadcast, and +// EVERY table is re-checked for an over-cap head (one-in-flight is store-wide, so a different table's +// head that filled while this spill was in flight is found + re-dispatched here — LOAD-BEARING for +// liveness). On a write failure it rolls back and returns the error for the dispatch's bounded retry; +// the entry stays in s.spilling (data preserved) and spillInFlight stays set across the retry. +func (s *Store) installSpill(e *spillEntry, res spillResult) error { + seg := res.seg + tempPath := filepath.Join(s.dir, segTempFileName(e.tempN)) // == seg.path on entry (encode wrote it) + + s.mu.Lock() + id := s.man.NextSegId + finalPath := filepath.Join(s.dir, segFileName(id)) + if err := renameSegmentFile(seg, tempPath, finalPath); err != nil { + s.mu.Unlock() + return err // transient (e.g. disk full); dispatch retries. The temp file + entry are preserved. + } + seg.id = id // P5: the chunk-LRU keys decompressed dict chunks by (segmentId, chunkIdx) + seg.minDocid, seg.maxDocid = res.minD, res.maxD // B + seg.refs.Store(1) // P9: the published snapshot holds one ref on this new segment + sm := s.spillSegMeta(res, id, e.tableId) + s.man.Segments = append(s.man.Segments, sm) + s.man.NextSegId++ + b, err := marshalManifest(s.man) + if err != nil { + s.man.Segments = s.man.Segments[:len(s.man.Segments)-1] // roll back the in-memory manifest + s.man.NextSegId-- + seg.refs.Store(0) + renameSegmentFile(seg, finalPath, tempPath) // restore the temp file for the retry (final name is unreferenced) + s.mu.Unlock() + return err + } + s.mu.Unlock() + + if err := writeManifestBytes(s.dir, b); err != nil { + s.mu.Lock() + s.man.Segments = s.man.Segments[:len(s.man.Segments)-1] // roll back to the pre-install set + s.man.NextSegId-- + seg.refs.Store(0) + renameSegmentFile(seg, finalPath, tempPath) // restore the temp file for the retry (MANIFEST never recorded it) + s.mu.Unlock() + return err + } + + s.mu.Lock() + s.segs = append(s.segs, seg) + sortSegmentsById(s.segs) // the new id is the highest, so this is O(n) tail-insert — keep oldest->newest + s.publishSnapshotLocked() // PUBLISH the new segment BEFORE removing the spilling entry (the doc is in + s.removeSpillingLocked(e) // both tiers for an instant, never in neither — the forbidden direction) + s.spillInFlight = false + s.clearBlockProducerLocked() + next := s.findOverCapHeadLocked() // re-check ALL tables (one-in-flight is store-wide) + s.mu.Unlock() + + if next != nil { + s.dispatchSpill(next) + } + s.triggerMerge(false) + return nil +} + +// giveUpSpill abandons a detached head after the install retries are exhausted (a persistently-failing +// disk). MUST run on the worker. The head's data is dropped (crash-volatile — indexer replay recovers +// it), its temp file removed, the in-flight + gate state cleared + broadcast, and any over-cap head +// re-dispatched so the producer is never wedged. Mirrors a successful install's liveness tail. +func (s *Store) giveUpSpill(e *spillEntry, res spillResult) error { + res.seg.refs.Store(0) + res.seg.close() + os.Remove(res.seg.path) // the temp file (rename never succeeded durably) + + s.mu.Lock() + s.removeSpillingLocked(e) + s.spillInFlight = false + s.clearBlockProducerLocked() + next := s.findOverCapHeadLocked() + s.mu.Unlock() + + if next != nil { + s.dispatchSpill(next) + } + return nil +} + +// removeSpillingLocked removes entry e from s.spilling (caller holds s.mu.Lock). Order within the slice +// is preserved (newest last) so the read tiers stay correctly ordered for the OTHER in-flight entries — +// there is at most one in v5, but the removal is general. +func (s *Store) removeSpillingLocked(e *spillEntry) { + for i, x := range s.spilling { + if x == e { + s.spilling = append(s.spilling[:i], s.spilling[i+1:]...) + return + } + } +} + +// clearBlockProducerLocked clears the producer gate and wakes every parked producer (caller holds +// s.mu.Lock; spillCond.L == &s.mu). Broadcast (not Signal): Broadcast wakes all, and each re-checks the +// for-loop condition — the install relieved exactly one head's worth, so a still-over-cap producer +// re-parks, but a producer whose head is now under cap proceeds. A no-op if the gate was not set. +func (s *Store) clearBlockProducerLocked() { + if s.blockProducer { + s.blockProducer = false + } + s.spillCond.Broadcast() // safe to broadcast unconditionally; harmless if no producer is parked +} + +// findOverCapHeadLocked scans ALL tables for a head at/over CapBytes and, if found, detaches it for the +// next off-worker spill, returning the entry to dispatch (or nil). Caller holds s.mu.Lock and has just +// cleared spillInFlight, so this re-detach respects one-in-flight. LOAD-BEARING for liveness: a head +// that went over-cap while a spill was in flight (possibly on a DIFFERENT table) is detached here, so +// the producer parked at the gate is released and the build converges. +func (s *Store) findOverCapHeadLocked() *spillEntry { + if s.spillInFlight { + return nil // already re-detached (defensive; install/give-up clear it before calling) + } + for tid, h := range s.head { + if h != nil && h.bytes >= int64(s.opts.CapBytes) { + return s.detachHeadLocked(tid) + } + } + return nil +} + +// resolveOps reduces a keyword's ordered op log (raw docid in pd.docids + isAdd in the parallel +// pd.isAdd bitset, APPENDED in action order) to the per-docid survivors: a docid is in adds iff its +// LAST op is an add, else in dels (item H, spec §5b). It recovers EXACTLY the eager adds/dels maps' +// result. +// +// It is pure and NON-MUTATING: it copies the ops into a FRESH scratch slice and sorts the scratch +// (never pd's slices — the detached-head encode and concurrent Search both share the head's pd), so +// two concurrent callers can never alias. The CALLER must hold the appropriate lock (the Store RLock +// for a live head; ownership for a detached head) across this call so the copy is consistent with the +// worker's appends. resolveOps allocates a fresh scratch per call (no shared/pooled scratch). +// +// The sort is sort.SliceStable keyed on docid ONLY — NOT a value that folds in isAdd: a key that lets +// isAdd break ties makes an add always sort last, so `add->del` mis-resolves to add. Stable + +// docid-keyed keeps equal docids in insertion order, so the last op for a docid is its true latest +// action. +func resolveOps(pd *postingDelta) (adds, dels []int64) { + n := pd.nOps() + if n == 0 { + return nil, nil + } + // scratch packs (docid, isAdd) per op into a struct so the stable docid-keyed sort carries the + // flag along; raw int64 docids (full range) need a side flag rather than the old low-bit steal. + type scratchOp struct { + docid int64 + isAdd bool + } + scratch := make([]scratchOp, n) + for i := 0; i < n; i++ { + d, a := pd.opAt(i) + scratch[i] = scratchOp{docid: d, isAdd: a} + } + sort.SliceStable(scratch, func(i, j int) bool { return scratch[i].docid < scratch[j].docid }) + // Equal docids are adjacent (and in insertion order); the LAST op of each run decides add vs del. + for i := 0; i < n; { + j := i + 1 + for j < n && scratch[j].docid == scratch[i].docid { + j++ + } + last := scratch[j-1] + if last.isAdd { + adds = append(adds, last.docid) + } else { + dels = append(dels, last.docid) + } + i = j + } + return adds, dels +} + +// fileSize returns the on-disk size of path (0 on error — only used for the segMeta size field). +func fileSize(path string) int64 { + fi, err := os.Stat(path) + if err != nil { + return 0 + } + return fi.Size() +} diff --git a/core/invertedstore/head_lazy_dels_test.go b/core/invertedstore/head_lazy_dels_test.go new file mode 100644 index 0000000..809e021 --- /dev/null +++ b/core/invertedstore/head_lazy_dels_test.go @@ -0,0 +1,81 @@ +package invertedstore + +import ( + "os" + "path/filepath" + "testing" +) + +// On an adds-only keyword the op log holds only add-ops (no del cross-bookkeeping) and resolves to +// exactly those docids in adds, none in dels (item H — the slice replaces the lazy del-map). +func TestHeadFix_AddsOnlyResolvesToAdds(t *testing.T) { + h := newHeadTable() + h.addPosting("alpha", 1) + h.addPosting("alpha", 2) + pd := h.inv["alpha"] + if pd.nOps() != 2 { + t.Fatalf("nOps = %d, want 2 appended add-ops", pd.nOps()) + } + adds, dels := resolveOps(pd) + sortInt64Slice(adds) + if !eqInt64s(adds, []int64{1, 2}) { + t.Fatalf("adds = %v, want {1,2}", adds) + } + if len(dels) != 0 { + t.Fatalf("dels = %v, want none on an adds-only keyword", dels) + } +} + +// add -> tombstone -> re-add on the same (kw,docid) must collapse to the survivor (PRESENT), exactly +// as the eager-map version did: the LAST op (the re-add) wins, so the docid is a live add, not a del. +func TestHeadFix_AddDelReaddResolves(t *testing.T) { + h := newHeadTable() + h.addPosting("k", 5) // op: add 5 + h.tombstonePosting("k", 5) // op: del 5 + h.addPosting("k", 5) // op: add 5 (latest = add) + adds, dels := resolveOps(h.inv["k"]) + if !eqInt64s(adds, []int64{5}) { + t.Fatalf("docid 5 should be a live add after add/del/re-add, adds = %v", adds) + } + if len(dels) != 0 { + t.Fatalf("docid 5 should NOT be tombstoned after the final re-add, dels = %v", dels) + } + // tombstone-first path appends a del-op and resolves to a del (no add bookkeeping needed). + h.tombstonePosting("t", 9) // op: del 9 + adds, dels = resolveOps(h.inv["t"]) + if len(adds) != 0 { + t.Fatalf("tombstone-only keyword should have no adds, adds = %v", adds) + } + if !eqInt64s(dels, []int64{9}) { + t.Fatalf("dels = %v, want {9}", dels) + } +} + +// sortInt64Slice is a tiny in-place ascending sort for stable assertions (resolveOps' adds are in +// docid-ascending order already, but sort defensively). +func sortInt64Slice(s []int64) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j-1] > s[j]; j-- { + s[j-1], s[j] = s[j], s[j-1] + } + } +} + +// TestFileSize_ExistingAndMissing: fileSize returns the on-disk byte length of an existing file and +// falls back to 0 when os.Stat errors (a missing file) — the segMeta.Size field must not propagate a +// stat error, only a best-effort size. +func TestFileSize_ExistingAndMissing(t *testing.T) { + dir := t.TempDir() + present := filepath.Join(dir, "present.dat") + body := []byte("0123456789") // 10 bytes + if err := os.WriteFile(present, body, 0o644); err != nil { + t.Fatal(err) + } + if got := fileSize(present); got != int64(len(body)) { + t.Fatalf("fileSize(present) = %d, want %d", got, len(body)) + } + // A path that does not exist: os.Stat errors, so fileSize returns 0 (never a negative/garbage). + if got := fileSize(filepath.Join(dir, "does-not-exist.dat")); got != 0 { + t.Fatalf("fileSize(missing) = %d, want 0 on a stat error", got) + } +} diff --git a/core/invertedstore/keys.go b/core/invertedstore/keys.go new file mode 100644 index 0000000..9be55da --- /dev/null +++ b/core/invertedstore/keys.go @@ -0,0 +1,176 @@ +package invertedstore + +import ( + "encoding/binary" + "sort" +) + +const ( + ktInverted = byte(0x01) // [I] tableId keyword -> invertedValue (sorts BEFORE forward) + ktForward = byte(0x02) // [F] tableId docid -> forwardValue +) + +func appendUvarint(b []byte, v uint64) []byte { + var t [binary.MaxVarintLen64]byte + n := binary.PutUvarint(t[:], v) + return append(b, t[:n]...) +} + +func invertedKey(tableId uint32, keyword string) []byte { + b := make([]byte, 5+len(keyword)) + b[0] = ktInverted + binary.BigEndian.PutUint32(b[1:5], tableId) + copy(b[5:], keyword) + return b +} + +func forwardKey(tableId uint32, docid int64) []byte { + b := make([]byte, 13) + b[0] = ktForward + binary.BigEndian.PutUint32(b[1:5], tableId) + binary.BigEndian.PutUint64(b[5:13], uint64(docid)) + return b +} + +func decodeDocs(b []byte, fn func(int64)) { + var cur uint64 + for i := 0; i < len(b); { + d, n := binary.Uvarint(b[i:]) + if n <= 0 { + return + } + cur += d + fn(int64(cur)) + i += n + } +} + +// encodeDocs: sort + dedup + delta-varint (gaps are non-negative). int64 (production docid). +// +// It COPIES the input before sorting (copy-before-sort), so a caller may pass a slice it still +// owns/shares without having it reordered out from under it. The merge path (merge.go) builds a +// reconciled posting list and re-encodes it; copy-before-sort guarantees the merge never mutates a +// source-derived slice it might re-read, and is cheap relative to the delta-varint it already does. +func encodeDocs(docs []int64) []byte { + buf, _ := appendDeltaDocs(nil, nil, docs) + return buf +} + +// invertedValue := uvarint(addsByteLen) delta-varint(adds) delta-varint(dels) (dels run to end) +func encodeInvertedValue(adds, dels []int64) []byte { + ab := encodeDocs(adds) + out := appendUvarint(nil, uint64(len(ab))) + out = append(out, ab...) + out = append(out, encodeDocs(dels)...) + return out +} + +func splitInvertedValue(v []byte) (adds, dels []byte) { + al, n := binary.Uvarint(v) + return v[n : n+int(al)], v[n+int(al):] +} + +// encodeScratch holds the reusable byte/int scratch buffers for the merge value-encode path (C.3). +// The merge owns one of these; each encode reuses the same backing arrays. Safe because addEntry +// copies the produced value into blkRaw immediately (the value is never retained across encodes), and +// the encoded output is segment/merge scratch — NOT head storage (so it does not violate the F +// read-only-detached-head invariant). +type encodeScratch struct { + val []byte // the assembled inverted/forward value + docs []byte // delta-varint of the adds sub-list (the length-prefixed region) + srt []int64 // copy-before-sort scratch for the int64 doc lists + ord []uint32 // copy-before-sort scratch for forward ords +} + +// appendDeltaDocs sorts a copy of docs (into srt) and appends their dedup'd delta-varint to dst, +// returning (dst, srt) so both backing arrays are reused. It never reorders docs (copy-before-sort). +func appendDeltaDocs(dst []byte, srt, docs []int64) ([]byte, []int64) { + cp := append(srt[:0], docs...) + sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] }) + var prev int64 + first := true + for _, d := range cp { + if !first && d == prev { + continue + } + delta := d + if !first { + delta = d - prev + } + dst = appendUvarint(dst, uint64(delta)) + prev, first = d, false + } + return dst, cp +} + +// encodeInvertedValueInto builds an inverted value (uvarint(addsLen) adds-delta dels-delta) into +// e.val[:0], reusing e's buffers — the C.3 reuse of encodeInvertedValue for the merge path. +func (e *encodeScratch) encodeInvertedValueInto(adds, dels []int64) []byte { + // adds first (into e.docs) so we can length-prefix it; then dels appended straight into e.val. + ab, srt := appendDeltaDocs(e.docs[:0], e.srt, adds) + e.docs, e.srt = ab, srt + out := appendUvarint(e.val[:0], uint64(len(ab))) + out = append(out, ab...) + out, srt = appendDeltaDocs(out, e.srt, dels) + e.srt = srt + e.val = out + return out +} + +// encodeForwardInto builds a forward value (uvarint(nKw) sorted-ord-delta) into e.val[:0], reusing e's +// buffers — the C.3 reuse of encodeForward for the merge path. +func (e *encodeScratch) encodeForwardInto(ords []uint32) []byte { + cp := append(e.ord[:0], ords...) + e.ord = cp + sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] }) + out := appendUvarint(e.val[:0], uint64(len(cp))) + var prev uint32 + first := true + for _, o := range cp { + delta := uint64(o) + if !first { + delta = uint64(o - prev) + } + out = appendUvarint(out, delta) + prev, first = o, false + } + e.val = out + return out +} + +// forwardValue := uvarint(nKw) delta-varint(sorted term-ids); nKw==0 (single 0x00) ⇒ tombstone. +// A live doc has nKw>=1, so it can never alias the tombstone (even term-id 0 ⇒ 0x01 0x00). +func encodeForward(ords []uint32) []byte { + cp := append([]uint32(nil), ords...) + sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] }) + out := appendUvarint(nil, uint64(len(cp))) + var prev uint32 + first := true + for _, o := range cp { + delta := uint64(o) + if !first { + delta = uint64(o - prev) + } + out = appendUvarint(out, delta) + prev, first = o, false + } + return out +} + +func forwardTombstone() []byte { return []byte{0x00} } // nKw==0 + +func decodeForward(v []byte) (ords []uint32, deleted bool) { + n, p := binary.Uvarint(v) + if n == 0 { + return nil, true + } + ords = make([]uint32, 0, n) + var cur uint64 + for i := uint64(0); i < n; i++ { + d, m := binary.Uvarint(v[p:]) + p += m + cur += d + ords = append(ords, uint32(cur)) + } + return ords, false +} diff --git a/core/invertedstore/keys_test.go b/core/invertedstore/keys_test.go new file mode 100644 index 0000000..9e8ac0f --- /dev/null +++ b/core/invertedstore/keys_test.go @@ -0,0 +1,83 @@ +package invertedstore + +import ( + "sort" + "testing" +) + +func TestKeyEncoding(t *testing.T) { + // [I] keyType(1) tableId(4 BE) keyword + ik := invertedKey(7, "return") + if ik[0] != ktInverted || len(ik) != 5+len("return") { + t.Fatalf("inverted key shape: % x", ik) + } + // [F] keyType(1) tableId(4 BE) docid(8 BE int64); [I] (0x01) must sort before [F] (0x02) + fk := forwardKey(7, 1<<40) // a docid > 2^31 to prove int64 width + if fk[0] != ktForward || len(fk) != 13 { + t.Fatalf("forward key shape: % x", fk) + } + if string(invertedKey(7, "")) >= string(fk) { + t.Fatal("[I] must sort before [F]") + } + // tableId is fixed-width so 2 vs 10 sort numerically and prefixes are unambiguous + if string(invertedKey(2, "z")) >= string(invertedKey(10, "a")) { + t.Fatal("fixed-width tableId mis-sorts") + } +} + +func TestPostingsRoundTrip(t *testing.T) { + in := []int64{5, 1 << 40, 1, 1, 9} // unsorted, dup, and > 2^31 + var got []int64 + decodeDocs(encodeDocs(in), func(d int64) { got = append(got, d) }) + want := []int64{1, 5, 9, 1 << 40} // sorted + deduped + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } +} + +func TestInvertedValueRoundTrip(t *testing.T) { + adds, dels := []int64{1, 4, 9}, []int64{4} + ab, db := splitInvertedValue(encodeInvertedValue(adds, dels)) + var ga, gd []int64 + decodeDocs(ab, func(d int64) { ga = append(ga, d) }) + decodeDocs(db, func(d int64) { gd = append(gd, d) }) + if len(ga) != 3 || len(gd) != 1 || gd[0] != 4 { + t.Fatalf("inverted value split wrong: adds=%v dels=%v", ga, gd) + } +} + +func TestForwardTombstoneNoAlias(t *testing.T) { + // The blocker: a single-keyword doc whose only term-id is ordinal 0 must NOT + // look like a delete. forwardValue = uvarint(nKw) delta-varint(ords); tombstone = nKw 0. + live := encodeForward([]uint32{0}) // nKw=1, ord 0 → bytes 0x01 0x00 + if ords, deleted := decodeForward(live); deleted || len(ords) != 1 || ords[0] != 0 { + t.Fatalf("single-ord-0 doc misread: ords=%v deleted=%v bytes=% x", ords, deleted, live) + } + tomb := forwardTombstone() + if len(tomb) != 1 || tomb[0] != 0x00 { + t.Fatalf("tombstone must be a single 0x00: % x", tomb) + } + if _, deleted := decodeForward(tomb); !deleted { + t.Fatal("tombstone not detected as delete") + } + // round-trip a multi-keyword doc, order-independent + in := []uint32{9, 0, 4} + ords, deleted := decodeForward(encodeForward(in)) + sort.Slice(ords, func(i, j int) bool { return ords[i] < ords[j] }) + if deleted || len(ords) != 3 || ords[0] != 0 || ords[1] != 4 || ords[2] != 9 { + t.Fatalf("forward round-trip wrong: %v", ords) + } +} + +func TestForwardGoldenBytes(t *testing.T) { + // nKw=1 then ord 0 → exactly 0x01 0x00 (the anti-alias guarantee, frozen) + got := encodeForward([]uint32{0}) + if len(got) != 2 || got[0] != 0x01 || got[1] != 0x00 { + t.Fatalf("forward golden changed: % x", got) + } +} diff --git a/core/invertedstore/live_count_test.go b/core/invertedstore/live_count_test.go new file mode 100644 index 0000000..6be079f --- /dev/null +++ b/core/invertedstore/live_count_test.go @@ -0,0 +1,68 @@ +package invertedstore + +import "testing" + +// liveByTable tracks distinct live (keyword,docid) pairs per table. These tests drive the REAL +// apply path (Update+sync → applyBatch), where the counter delta lives — NOT applyForTest, which +// bypasses applyBatch and would leave the counter at 0. +func TestLiveByTable_DeltaBranches(t *testing.T) { + s, tid := newUpdateStore(t) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + up := func(docid int64, kw []string) { s.Update(tid, docid, kw); s.sync() } + live := func() int64 { return s.LiveByTableForTest()[tid] } + + up(1, []string{"a", "b", "c"}) + if live() != 3 { + t.Fatalf("cold add live=%d want 3", live()) + } + up(1, []string{"a", "b", "c", "d"}) // grow +1 + if live() != 4 { + t.Fatalf("grow live=%d want 4", live()) + } + up(1, []string{"a"}) // shrink 4 -> 1 + if live() != 1 { + t.Fatalf("shrink live=%d want 1", live()) + } + up(1, nil) // delete + if live() != 0 { + t.Fatalf("delete live=%d want 0", live()) + } + up(99, nil) // delete an unknown docid -> Δ0 + if live() != 0 { + t.Fatalf("del-unknown live=%d want 0", live()) + } + up(99, nil) // double-delete -> Δ0 + if live() != 0 { + t.Fatalf("double-delete live=%d want 0", live()) + } + up(2, []string{"x", "x", "y"}) // duplicate keyword -> distinct 2 + if live() != 2 { + t.Fatalf("dup-kw live=%d want 2", live()) + } +} + +// DeleteTable drops the table's whole liveByTable partition in O(1) and leaves other tables intact. +func TestLiveByTable_DeleteTableDropsPartition(t *testing.T) { + s, a := newUpdateStore(t) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + b, err := s.CreateTable("B") + if err != nil { + t.Fatal(err) + } + s.Update(a, 1, []string{"a1", "a2"}) + s.Update(b, 1, []string{"b1", "b2", "b3"}) + s.sync() + if got := s.LiveByTableForTest(); got[a] != 2 || got[b] != 3 { + t.Fatalf("live=%v want a=2 b=3", got) + } + if err := s.DeleteTable(b); err != nil { + t.Fatal(err) + } + got := s.LiveByTableForTest() + if _, ok := got[b]; ok { + t.Fatalf("liveByTable still has dropped table B: %v", got) + } + if got[a] != 2 { + t.Fatalf("table A live changed after DeleteTable(B): %v", got) + } +} diff --git a/core/invertedstore/live_recompute_test.go b/core/invertedstore/live_recompute_test.go new file mode 100644 index 0000000..292bb5a --- /dev/null +++ b/core/invertedstore/live_recompute_test.go @@ -0,0 +1,61 @@ +package invertedstore + +import ( + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +// openAt opens a store at a GIVEN dir (newUpdateStore uses a fresh TempDir, so it can't reopen). +// It stops the worker at test end; closing/crashing the store is the caller's choice. +func openAt(t *testing.T, dir string, opts Options) *Store { + t.Helper() + q := queue.NewMpsc("openat") + q.Start() + s, err := Open(dir, q, opts) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { q.Stop() }) + return s +} + +// recomputeLive reproduces the incremental counter exactly from the segments' forward records. +func TestRecomputeLive_EqualsIncremental(t *testing.T) { + s, tid := newUpdateStore(t) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + s.Update(tid, 1, []string{"a", "b", "c"}) + s.Update(tid, 2, []string{"a", "a", "b"}) // dup -> distinct 2 + s.sync() + s.spillForTest(tid) + + want := s.LiveByTableForTest()[tid] + if want != 5 { + t.Fatalf("incremental live=%d want 5", want) + } + s.RecomputeLiveForTest() // zero, then rebuild from segment forward records + if got := s.LiveByTableForTest()[tid]; got != want { + t.Fatalf("recompute live=%d != incremental %d", got, want) + } +} + +// On a real reopen, Open's recomputeLive rebuilds liveByTable from the durable segments. +func TestRecomputeLive_OnReopen(t *testing.T) { + dir := t.TempDir() + s := openAt(t, dir, Options{}) + tid, err := s.CreateTable("t") + if err != nil { + t.Fatal(err) + } + s.Update(tid, 1, []string{"a", "b", "c"}) + s.Update(tid, 2, []string{"b", "c"}) + s.sync() + s.spillForTest(tid) + s.CloseAndWait() + + s2 := openAt(t, dir, Options{}) + defer s2.CloseAndWait() // release the reopened store's segment fds (Windows RemoveAll) + if got := s2.LiveByTableForTest()[tid]; got != 5 { + t.Fatalf("reopened live=%d want 5 (doc1 abc=3 + doc2 bc=2)", got) + } +} diff --git a/core/invertedstore/manifest.go b/core/invertedstore/manifest.go new file mode 100644 index 0000000..19d7d0b --- /dev/null +++ b/core/invertedstore/manifest.go @@ -0,0 +1,179 @@ +package invertedstore + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "time" +) + +// segMeta describes one immutable, live segment file in the MANIFEST: its seal-sequence id +// (== the seg-%06d.dat suffix), its merge level, the codec ids needed to read its data +// blocks and term-dict region (a reader must never guess a region's codec), the tableId +// range it covers (for prune-by-table on Search/merge), and its on-disk size. +type segMeta struct { + Id uint64 `json:"id"` + Level int `json:"level"` + DataCodec byte `json:"dataCodec"` + DictCodec byte `json:"dictCodec"` + MinTable uint32 `json:"minTable"` + MaxTable uint32 `json:"maxTable"` + Size int64 `json:"size"` + // Postings is the number of inverted posting ENTRIES (adds + dels) the segment stores — the + // `written` term of the covering-merge trigger (deadFraction). Set at spill/merge time from the + // counts already in hand; per-segment so it is crash-consistent (travels in the same MANIFEST as + // the segment). A covering merge drops all dels, so a covering output's Postings = its live adds. + Postings int64 `json:"postings"` + // MinDocid/MaxDocid bound the docids of the forward records (live AND tombstone) this segment + // emitted — the forward-read skip range (spec §4 item B). A read for a docid outside [Min,Max] + // cannot find a forward record here, so forwardKeywords skips the segment without decompressing a + // block. An empty forward output is the inverted range Min=MaxInt64 > Max=MinInt64, which always + // skips. Persisted so Open needs no scan; FormatVersion 3 guarantees the fields are present (a + // pre-3 manifest is upgraded on Open — a stale [0,0] would mis-skip). + MinDocid int64 `json:"minDocid"` + MaxDocid int64 `json:"maxDocid"` +} + +// tableInfo is one entry of the table catalog (replaces pebble's table rows). +type tableInfo struct { + Id int `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Description string `json:"description"` +} + +// manifest is the store's only durable metadata: the storage version, the live segment set, +// the table catalog, and the monotonic next-ids. It carries NO recovery watermark — recovery +// is indexer-driven (design §9), so the store need only be crash-consistent. It is replaced +// atomically (write MANIFEST.tmp, fsync, rename, fsync dir) on every seal/merge/table change. +type manifest struct { + FormatVersion int `json:"formatVersion"` // bump on any breaking manifest change + StorageVersion string `json:"storageVersion"` + Segments []segMeta `json:"segments"` + Tables map[int]tableInfo `json:"tables"` + NextTableId int `json:"nextTableId"` + NextSegId uint64 `json:"nextSegId"` +} + +// newManifest returns a fresh, empty manifest for a not-yet-written store. Ids start at 1 so +// the first table/segment is 1 (a 0 id is "absent"). +func newManifest() *manifest { + return &manifest{FormatVersion: 3, Tables: map[int]tableInfo{}, NextTableId: 1, NextSegId: 1} +} + +// readManifest loads dir/MANIFEST. A missing MANIFEST (a fresh dir) is NOT an error — it +// yields a fresh empty manifest (so Open can bootstrap). A torn MANIFEST.tmp is ignored: the +// atomic write below never renames a partial file into place, so only a fully-written MANIFEST +// is ever read. +func readManifest(dir string) (*manifest, error) { + b, err := os.ReadFile(filepath.Join(dir, "MANIFEST")) + if os.IsNotExist(err) { + return newManifest(), nil + } + if err != nil { + return nil, err + } + var m manifest + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + if m.Tables == nil { + m.Tables = map[int]tableInfo{} + } + return &m, nil +} + +// writeManifest atomically replaces dir/MANIFEST with m: marshal, write MANIFEST.tmp, fsync it, +// rename it over MANIFEST, then fsync the directory so the rename itself is durable. A crash at any +// point leaves either the old MANIFEST or the new one — never a torn file (a half-written +// MANIFEST.tmp is never renamed and is ignored by readManifest). Kept for the table-catalog paths +// (CreateTable/DeleteTable) where the marshal+fsync already run on the worker with no concurrent +// reader of the head/segment set — the spill/merge paths instead split this (marshalManifest under +// the lock, writeManifestBytes outside it) so a reader never blocks on the fsync (P9/T8, design §6). +func writeManifest(dir string, m *manifest) error { + b, err := marshalManifest(m) + if err != nil { + return err + } + return writeManifestBytes(dir, b) +} + +// marshalManifestErr, when non-nil, forces marshalManifest to return its error instead of the +// marshaled bytes. Test-only fault-injection hook (like beforeManifestFsync): a manifest built from +// the fixed struct never fails json.Marshal in production, so the writeManifest/spill/merge error +// branch that handles a marshal failure is otherwise unreachable. A test installs one to exercise +// that branch. nil in production (one predictable, never-taken branch); a test installing it MUST +// NOT run t.Parallel. +var marshalManifestErr error + +// marshalManifest serializes a manifest to its on-disk JSON bytes. It is split out of writeManifest +// so a writer (spill/installMerge) can capture the bytes WHILE it briefly holds s.mu (the marshal +// reads s.man's maps/slices, which a reader may also be reading under RLock — concurrent reads are +// safe, but the in-memory s.man must not be mutated concurrently), then perform the slow fsync via +// writeManifestBytes OUTSIDE the lock. No I/O here, so it is cheap to run under the lock. +func marshalManifest(m *manifest) ([]byte, error) { + if marshalManifestErr != nil { + return nil, marshalManifestErr + } + return json.Marshal(m) +} + +// beforeManifestFsync, when non-nil, is invoked by writeManifestBytes at the START of its I/O (after +// the marshaled bytes are captured, before the file write + fsyncs). Test-only observability/blocking +// hook (P9/T8): a test installs one that blocks, kicks a real spill on the worker so it reaches this +// point, and asserts a concurrent Search returns promptly — proving the writer holds NO lock across +// its I/O. nil in production (one predictable, never-taken branch). Same parallel-safety constraint +// as the merge observers: a test installing it MUST NOT run t.Parallel. +var beforeManifestFsync func() + +// writeManifestBytes durably installs the already-marshaled MANIFEST bytes b: write MANIFEST.tmp, +// fsync it, rename it over MANIFEST, then fsync the directory (same atomic, crash-safe sequence as +// writeManifest). It touches NO shared in-memory state — only the filesystem — so the spill/merge +// paths call it OUTSIDE s.mu, keeping the two fsyncs off the reader-blocking critical section +// (design §6: "readers never block on a writer's I/O"). All writes run on the single mpsc worker, so +// there is never a concurrent writeManifestBytes racing for the MANIFEST.tmp file. +func writeManifestBytes(dir string, b []byte) error { + if beforeManifestFsync != nil { + beforeManifestFsync() + } + tmp := filepath.Join(dir, "MANIFEST.tmp") + f, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := f.Write(b); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(tmp, filepath.Join(dir, "MANIFEST")); err != nil { + return err + } + // fsync the dir so the rename is durable (no-op on Windows — see syncDir) + return syncDir(dir) +} + +// syncDir fsyncs a directory so a rename within it is durable. On POSIX a directory fsync is +// required for the rename's new dentry to survive a crash, so we open the dir and Sync it. On +// Windows a directory handle cannot be flushed (FlushFileBuffers on a directory fails with +// ERROR_ACCESS_DENIED / "Incorrect function") and NTFS journals its metadata — the rename is +// already crash-durable without an explicit directory fsync — so it is a no-op there. This is the +// standard portability pattern (bolt/badger/pebble do the same); the POSIX durability behavior is +// unchanged. +func syncDir(dir string) error { + if runtime.GOOS == "windows" { + return nil + } + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} diff --git a/core/invertedstore/manifest_test.go b/core/invertedstore/manifest_test.go new file mode 100644 index 0000000..03b171b --- /dev/null +++ b/core/invertedstore/manifest_test.go @@ -0,0 +1,169 @@ +package invertedstore + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestManifestRoundTrip(t *testing.T) { + dir := t.TempDir() + m := &manifest{ + FormatVersion: 1, StorageVersion: "1.6", NextTableId: 3, NextSegId: 5, + Tables: map[int]tableInfo{1: {Id: 1, Description: "files"}}, + Segments: []segMeta{{Id: 4, Level: 0, DataCodec: codecSnappy, DictCodec: codecZstd, MinTable: 1, MaxTable: 1, Size: 123}}, + } + if err := writeManifest(dir, m); err != nil { + t.Fatal(err) + } + // no stray tmp left behind + if _, err := os.Stat(filepath.Join(dir, "MANIFEST.tmp")); !os.IsNotExist(err) { + t.Fatal("MANIFEST.tmp should not linger after atomic rename") + } + got, err := readManifest(dir) + if err != nil { + t.Fatal(err) + } + if got.NextTableId != 3 || got.NextSegId != 5 || len(got.Segments) != 1 || + got.Segments[0].Id != 4 || got.Tables[1].Description != "files" { + t.Fatalf("manifest round-trip mismatch: %+v", got) + } +} + +func TestManifestMissingIsEmpty(t *testing.T) { + // reading a dir with no MANIFEST yields a fresh empty manifest, not an error + m, err := readManifest(t.TempDir()) + if err != nil || m == nil || len(m.Segments) != 0 { + t.Fatalf("fresh dir should give empty manifest: %v %+v", err, m) + } +} + +// TestReadManifestMalformedJSONIsError: a MANIFEST whose bytes are present but NOT valid JSON is a +// hard error (a torn/corrupt file the atomic writer should never have produced), not a silent +// empty manifest — readManifest surfaces the json.Unmarshal failure. +func TestReadManifestMalformedJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "MANIFEST"), []byte("{not valid json"), 0o644); err != nil { + t.Fatal(err) + } + m, err := readManifest(dir) + if err == nil { + t.Fatalf("malformed MANIFEST should error, got manifest %+v", m) + } +} + +// TestReadManifestNilTablesSeeded: a valid MANIFEST that omits the "tables" field unmarshals with a +// nil Tables map; readManifest must seed a non-nil empty map so callers (CreateTable) can index it +// without a nil-map panic. +func TestReadManifestNilTablesSeeded(t *testing.T) { + dir := t.TempDir() + // JSON with no "tables" key at all → m.Tables unmarshals to nil. + if err := os.WriteFile(filepath.Join(dir, "MANIFEST"), + []byte(`{"formatVersion":3,"nextTableId":1,"nextSegId":1}`), 0o644); err != nil { + t.Fatal(err) + } + m, err := readManifest(dir) + if err != nil { + t.Fatalf("readManifest: %v", err) + } + if m.Tables == nil { + t.Fatal("readManifest must seed a non-nil Tables map when the MANIFEST omits it") + } +} + +// TestReadManifestReadErrorNotNotExist: a read error that is NOT os.IsNotExist (here: MANIFEST is a +// DIRECTORY, so os.ReadFile fails with EISDIR) is surfaced as an error, distinct from the +// missing-file bootstrap path. +func TestReadManifestReadErrorNotNotExist(t *testing.T) { + dir := t.TempDir() + // Make MANIFEST a directory: os.ReadFile returns a non-NotExist error (read: is a directory). + if err := os.Mkdir(filepath.Join(dir, "MANIFEST"), 0o755); err != nil { + t.Fatal(err) + } + m, err := readManifest(dir) + if err == nil { + t.Fatalf("reading a MANIFEST that is a directory should error, got %+v", m) + } + if os.IsNotExist(err) { + t.Fatalf("error should not be IsNotExist (that is the bootstrap path): %v", err) + } +} + +// TestWriteManifestMarshalError: when the (test-injected) marshal step fails, writeManifest returns +// that error WITHOUT touching the filesystem — no MANIFEST(.tmp) is written. +func TestWriteManifestMarshalError(t *testing.T) { + dir := t.TempDir() + sentinel := errors.New("marshal boom") + marshalManifestErr = sentinel + t.Cleanup(func() { marshalManifestErr = nil }) + + err := writeManifest(dir, newManifest()) + if !errors.Is(err, sentinel) { + t.Fatalf("writeManifest should return the marshal error, got %v", err) + } + // A marshal failure precedes all I/O: no MANIFEST or MANIFEST.tmp is created. + if _, statErr := os.Stat(filepath.Join(dir, "MANIFEST")); !os.IsNotExist(statErr) { + t.Fatalf("no MANIFEST should exist after a marshal failure: %v", statErr) + } + if _, statErr := os.Stat(filepath.Join(dir, "MANIFEST.tmp")); !os.IsNotExist(statErr) { + t.Fatalf("no MANIFEST.tmp should exist after a marshal failure: %v", statErr) + } +} + +// TestWriteManifestBytesWriteError: when writing the MANIFEST.tmp bytes fails (the tmp path is a +// symlink to /dev/full → ENOSPC on write), writeManifestBytes surfaces the write error and never +// renames a torn file over MANIFEST. +func TestWriteManifestBytesWriteError(t *testing.T) { + if _, err := os.Stat("/dev/full"); err != nil { + t.Skip("/dev/full not available") + } + dir := t.TempDir() + // os.Create truncates but follows the symlink → the underlying open target is /dev/full, so the + // subsequent f.Write returns ENOSPC. + if err := os.Symlink("/dev/full", filepath.Join(dir, "MANIFEST.tmp")); err != nil { + t.Fatal(err) + } + err := writeManifestBytes(dir, []byte(`{"formatVersion":3}`)) + if err == nil { + t.Fatal("writeManifestBytes should return the /dev/full write error") + } + // The write failed before the rename: no MANIFEST was installed. + if _, statErr := os.Stat(filepath.Join(dir, "MANIFEST")); !os.IsNotExist(statErr) { + t.Fatalf("no MANIFEST should be installed after a write failure: %v", statErr) + } +} + +// TestWriteManifestBytesRenameError: when the rename of MANIFEST.tmp over MANIFEST fails (here: +// MANIFEST is a NON-EMPTY directory, so rename cannot replace it), writeManifestBytes surfaces the +// rename error. +func TestWriteManifestBytesRenameError(t *testing.T) { + dir := t.TempDir() + // A non-empty directory named MANIFEST: os.Rename(tmp, MANIFEST) fails (cannot overwrite a + // non-empty directory with a file). + manDir := filepath.Join(dir, "MANIFEST") + if err := os.Mkdir(manDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manDir, "occupant"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + err := writeManifestBytes(dir, []byte(`{"formatVersion":3}`)) + if err == nil { + t.Fatal("writeManifestBytes should return the rename error when MANIFEST is a non-empty dir") + } +} + +// TestSyncDirOpenErrorSurfaced: on POSIX, syncDir opens the directory to fsync it, so a directory +// that cannot be opened (it does not exist) surfaces the os.Open error rather than silently skipping +// the durability fsync. On Windows syncDir is a deliberate no-op (a directory handle cannot be +// flushed and NTFS does not need it), so there is no open+error path to exercise — skip there. +func TestSyncDirOpenErrorSurfaced(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("syncDir is a no-op on Windows (no directory fsync); the os.Open error path is POSIX-only") + } + if err := syncDir(filepath.Join(t.TempDir(), "no-such-dir")); err == nil { + t.Fatal("syncDir should surface the os.Open error for a directory that does not exist") + } +} diff --git a/core/invertedstore/merge.go b/core/invertedstore/merge.go new file mode 100644 index 0000000..fab4d7f --- /dev/null +++ b/core/invertedstore/merge.go @@ -0,0 +1,793 @@ +package invertedstore + +// merge.go — P8 (design §6 merger + §8 remap; task T6). +// +// The background tiered merger: the long-term correctness + bounded-K story. A level with >= +// Fanout segments is streaming k-way merged into ONE next-level segment; a COVERING merge fully +// compacts the bottom level + everything above to reclaim dangling tombstones, fully-tombstoned +// keys and dead-tableId keys. Every merge: +// +// - is a STREAMING k-way merge (one block per source resident at a time — bounded memory); +// - RECONCILES each (keyword, docid) NEWEST-WINS across the inputs (merged oldest -> newest, the +// latest add-or-tombstone wins) — it does NOT blindly concatenate adds+dels, so add->del->add +// on one (keyword,docid) collapses to the survivor (PRESENT). This is the fix the spike never +// had (its edit workload added globally-unique words, so concat never aliased); +// - in a TIERED merge CANNOT drop a keyword key (the term-id remap append-index IS the source +// ordinal, §8): a fully-tombstoned keyword persists as a small del-only record, and a +// forward-tombstone (nKw=0) is carried through verbatim; +// - in a COVERING merge (bottom + everything above) DOES reclaim: it drops fully-tombstoned keys, +// drops dangling tombstones (keeps adds-only, since nothing older survives to be suppressed), +// drops forward-tombstones, and drops keys for tableIds no longer in the catalog — the remap +// then carries a sentinel for each dropped key so a surviving forward never dereferences one; +// - REMAPS forward ordinals srcOrd -> outputOrd via per-source int arrays (built incrementally as +// the merged term dict is emitted) and rebuilds the term-dict region (segWriter.writeTermDict), +// so merge memory is Sum(source term counts) ints — NOT a string map; +// - swaps the MANIFEST crash-safely (new segment fully written + fsync'd before the swap), then +// deletes the input files and purges their chunk-LRU entries (pre-T8: inputs are dropped +// immediately on swap; refcount-deferred deletion is T8). +// +// Port of cmd/sortbench/main.go mergeSegments + maybeMerge, in production shape: []byte keys with a +// 4-byte tableId, int64 docids/ordinals, per-(kw,docid) reconciliation (the spike concatenated), +// the covering-merge reclamation path (new — not in the spike), and the worker-owned segment set. + +import ( + "encoding/binary" + "os" + "path/filepath" +) + +// ordSentinel marks a source ordinal whose keyword was DROPPED by a covering merge (so it has no +// output ordinal). A surviving forward should never reference a dropped keyword — a covering merge +// only drops fully-tombstoned (no live add) or dead-table keywords, and a live forward only claims +// keywords it has a live add for. If a forward DOES reference a dropped ordinal (a pre-existing +// inverted/forward inconsistency in the input — a corrupt or legacy segment), the merge SELF-HEALS +// by dropping that stale term from the forward rather than crashing: the keyword has no live posting +// so the forward must not claim it. The merge MUST NOT panic here — it runs on the mpsc worker +// goroutine, which has no recover, so a panic would brick the whole process (not just one request), +// turning a recoverable, contained data-quality event into a hard crash. See design §6/§8. +const ordSentinel = ^uint32(0) + +// mergeRemapObserver, when non-nil, is invoked by mergeSegments with the realized per-source remap +// arrays just before the output is finished. Test-only observability (P8) for the "merge memory = +// Sum source term counts, int arrays not a string map" assertion; nil in production. +// +// It (and mergeDroppedForwardTermObserver below) are package globals on the merge hot path: a test +// installs one, runs a merge synchronously on the worker, then clears it (defer). This is safe ONLY +// because no merge test runs in parallel — a test that installs either hook MUST NOT call t.Parallel +// (the merge tests don't). The production nil-check is one predictable branch. +var mergeRemapObserver func(remap [][]uint32) + +// mergeDroppedForwardTermObserver, when non-nil, is invoked by mergeSegments once for each forward +// term it had to drop because the term's keyword was reclaimed by a covering merge (a self-heal of a +// pre-existing inverted/forward inconsistency in the input). Test-only observability (P8); nil in +// production. A non-zero count signals a contained data-quality event, never a crash. Same parallel- +// safety constraint as mergeRemapObserver (no t.Parallel in a test that installs it). +var mergeDroppedForwardTermObserver func() + +// mergeComputeBlock, when non-nil, is invoked at the START of mergeSegments (the off-worker compute). +// Test-only (A): a test installs one that blocks on a channel, kicks a background merge, and asserts +// the worker still drains an Update while the compute is parked — proving the compute is OFF the +// worker. nil in production. Same no-t.Parallel constraint as the other merge observers. +var mergeComputeBlock func() + +// mergeCursor streams one source segment's (key,value) records in sorted order, decoding external +// values on the fly. Only ONE decompressed block is resident per cursor, so a k-way merge over K +// sources holds K blocks — bounded memory regardless of segment size. (Port of the spike +// mergeCursor; keys are now full []byte = keyType(1) tableId(4 BE) keyword|docid.) +type mergeCursor struct { + s *segment + bi int + blk []byte + p int + key []byte + val []byte + done bool +} + +func newMergeCursor(s *segment) *mergeCursor { + c := &mergeCursor{s: s} + if len(s.idx) > 0 { + c.blk = s.blockBytes(0) + } + c.advance() + return c +} + +// advance reads the next record into c.key/c.val, decoding an external value if needed, crossing +// block boundaries, and setting c.done at end-of-segment. +func (c *mergeCursor) advance() { + for { + if c.p < len(c.blk) { + kl, n := binary.Uvarint(c.blk[c.p:]) + c.p += n + c.key = c.blk[c.p : c.p+int(kl)] + c.p += int(kl) + flag := c.blk[c.p] + c.p++ + if flag == 0 { + vl, n2 := binary.Uvarint(c.blk[c.p:]) + c.p += n2 + c.val = c.blk[c.p : c.p+int(vl)] + c.p += int(vl) + } else { + off, n2 := binary.Uvarint(c.blk[c.p:]) + c.p += n2 + cl, n3 := binary.Uvarint(c.blk[c.p:]) + c.p += n3 + c.val = c.s.readExternal(int64(off), int(cl)) + } + return + } + c.bi++ + if c.bi >= len(c.s.idx) { + c.done = true + return + } + // Reuse this cursor's previous block buffer as the decompress destination (C.2): one buffer + // per cursor, not one per block. The previous block's records are fully consumed by this + // cursor before we advance to the next block. + c.blk = c.s.blockBytesInto(c.blk, c.bi) + c.p = 0 + } +} + +// keyType / keyTableId pull the type byte and 4-byte BE tableId out of a full key. +func keyType(k []byte) byte { return k[0] } +func keyTableId(k []byte) uint32 { return binary.BigEndian.Uint32(k[1:5]) } + +// mergeResult is the product of one mergeSegments call: the opened output segment + its segMeta. +type mergeResult struct { + seg *segment + sm segMeta +} + +// mergeSegments streams a k-way merge of segs (ordered OLDEST -> NEWEST) into one output segment at +// the given level, with per-(keyword,docid) newest-wins reconciliation + term-id ord->ord remap + +// term-dict rebuild. covering=true runs the reclaiming bottom compaction (drop fully-tombstoned +// keys, dangling tombstones, forward-tombstones, and dead-tableId keys — liveTables gates that +// last). It returns the opened output segment + its segMeta; it does NOT touch the MANIFEST or +// delete inputs (the caller's installMerge does that crash-safely). +// +// The merge is bounded-memory: one block per source cursor, the remap arrays are ints (Sum source +// term counts), and the term-dict region is rebuilt by re-reading the just-written [I] blocks. +func (s *Store) mergeSegments(segs []*segment, outId uint64, level int, dataCodec byte, covering bool, liveTables map[int]bool) mergeResult { + curs := make([]*mergeCursor, len(segs)) + for i, seg := range segs { + curs[i] = newMergeCursor(seg) + } + if mergeComputeBlock != nil { + mergeComputeBlock() + } + path := filepath.Join(s.dir, segFileName(outId)) + w := newSegWriter(path, + newCodec(dataCodec), newCodec(s.opts.DictCodec), + s.opts.BlockTarget, s.opts.Chunk, s.opts.InlineThreshold, true, s.opts.DictChunkBytes) + + // term-id remap: as the merged term dict ([I] keys, sorted) is emitted, each key is assigned + // its output ordinal and, for EVERY source that contained the key (in srcOrd order), one entry + // is appended to remap[src]. So remap[src][srcOrd] = outputOrd (or ordSentinel for a key dropped + // by a covering merge). Because [I] sorts before [F], every remap entry is built before any + // forward record is emitted. tableRange tracks the output's covered tableIds for prune. + remap := make([][]uint32, len(segs)) + outOrd := uint32(0) + var postings int64 // count emitted add+del entries for the output segMeta.Postings + outMinDocid, outMaxDocid := emptyDocidRange() + noteDocid := func(d int64) { + if d < outMinDocid { + outMinDocid = d + } + if d > outMaxDocid { + outMaxDocid = d + } + } + minTable := uint32(0) + maxTable := uint32(0) + haveTable := false + noteTable := func(t uint32) { + if !haveTable || t < minTable { + minTable = t + } + if !haveTable || t > maxTable { + maxTable = t + } + haveTable = true + } + + // C.3: one reusable encode-scratch for the whole merge — addEntry copies each value into blkRaw + // immediately, so the assembled value/sort buffers are reused record-to-record (never retained). + var enc encodeScratch + + for { + // Find the minimum key across all live cursors (byte-wise). first guards "no min yet". + var min []byte + first := true + for _, cu := range curs { + if cu.done { + continue + } + if first || compareKeys(cu.key, min) < 0 { + min, first = cu.key, false + } + } + if first { + break // all cursors drained + } + // hit = source indexes whose current key == min, in cursor order (== OLDEST -> NEWEST). + var hit []int + for i, cu := range curs { + if !cu.done && equalKeys(cu.key, min) { + hit = append(hit, i) + } + } + + tid := keyTableId(min) + tableLive := liveTables == nil || liveTables[int(tid)] + + if keyType(min) == ktForward { + // FORWARD: newest source wins (last in hit). Remap its ordinals; a tombstone (nKw=0) + // decodes to zero ords -> remaps nothing -> re-emits nKw=0 verbatim. + src := hit[len(hit)-1] + if covering && (!tableLive) { + // dead-tableId forward: drop. + } else { + val := curs[src].val + ords, deleted := decodeForward(val) + if deleted { + // covering merge drops forward-tombstones (nothing older survives to suppress); + // a tiered merge carries them through verbatim. + if !covering { + w.addEntry(min, forwardTombstone()) + noteTable(tid) + noteDocid(int64(binary.BigEndian.Uint64(min[5:13]))) // B: tombstone counts toward the skip range + } + } else { + out := make([]uint32, 0, len(ords)) + dropped := false + for _, o := range ords { + mo := remap[src][o] + if mo == ordSentinel { + // The forward references a keyword the covering merge reclaimed (fully + // tombstoned / dead-table). This is a pre-existing inverted/forward + // inconsistency in the input — the public write path cannot produce it, only a + // corrupt or legacy segment. Self-heal: drop this stale term from the forward (the + // keyword has no live posting, so the doc must not claim it) and keep going. NEVER + // panic — this runs on the un-recovered mpsc worker, where a panic bricks the + // process; a contained, valid output segment is the correct background-merge outcome. + dropped = true + if mergeDroppedForwardTermObserver != nil { + mergeDroppedForwardTermObserver() + } + continue + } + out = append(out, mo) + } + // If EVERY term was reclaimed (covering merge), the doc has no live keyword left. Don't + // emit encodeForward(nil) — that is the nKw=0 tombstone encoding, and a covering merge + // drops forward-tombstones anyway. Drop the forward record entirely (a clean miss), the + // same outcome a covering merge gives any doc with nothing live to point at. + if dropped && len(out) == 0 { + // drop the forward entirely + } else { + w.addEntry(min, enc.encodeForwardInto(out)) + noteTable(tid) + noteDocid(int64(binary.BigEndian.Uint64(min[5:13]))) // B: live forward counts toward the skip range + } + } + } + } else { + // INVERTED: reconcile (keyword,docid) NEWEST-WINS across hit (oldest->newest). action + // maps docid -> latest action (true=add, false=del); insertion-ordered isn't needed, the + // encoders sort. We walk hit in cursor order, which IS oldest->newest, so a later source's + // add or del overwrites an earlier one for the same docid. + // + // adds/dels reconcile (keyword,docid) newest-wins; declared FRESH per key. They MUST NOT be + // hoisted out of the loop and clear()+reused: 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` then drains O(retained capacity) instead of O(key size) + // — turning the merge into O(numKeys × peak) (the 6× build regression). See the + // invertedstore-merge-mapreuse-regression-fix spec. + adds := map[int64]struct{}{} + dels := map[int64]struct{}{} + for _, i := range hit { + ab, db := splitInvertedValue(curs[i].val) + // A spilled/merged value never holds both an add and a del for the same docid, but + // process adds THEN dels within one source so a del overwrites an add for the same docid + // (the source's own single latest action lands); across sources, the LATER source wins. + decodeDocs(ab, func(d int64) { delete(dels, d); adds[d] = struct{}{} }) + decodeDocs(db, func(d int64) { delete(adds, d); dels[d] = struct{}{} }) + } + keep := true + var addList, delList []int64 + if covering { + // Bottom compaction: nothing older survives, so a del can suppress nothing — drop ALL + // dels (dangling tombstones reclaimed). Keep adds-only. A key with no surviving add is + // fully tombstoned -> drop the key entirely. A dead-tableId key -> drop. + for d := range adds { + addList = append(addList, d) + } + if !tableLive || len(addList) == 0 { + keep = false + } + } else { + // Tiered merge: keep both adds and dels (a del must still suppress an older add in a + // segment NOT part of this merge); NEVER drop a key (the remap append-index == srcOrd). + for d := range adds { + addList = append(addList, d) + } + for d := range dels { + delList = append(delList, d) + } + } + + if keep { + w.addEntry(min, enc.encodeInvertedValueInto(addList, delList)) + postings += int64(len(addList) + len(delList)) // segMeta.Postings (only emitted keys count) + noteTable(tid) + for _, i := range hit { + remap[i] = append(remap[i], outOrd) // append index == this key's srcOrd in seg i + } + outOrd++ + } else { + // Key dropped by the covering merge: every source that had it gets a sentinel at this + // srcOrd so the per-source remap stays index-aligned (srcOrd -> sentinel/no-output). + for _, i := range hit { + remap[i] = append(remap[i], ordSentinel) + } + } + } + + for _, i := range hit { + curs[i].advance() + } + } + + seg := w.finish(path) + seg.id = outId + seg.minDocid, seg.maxDocid = outMinDocid, outMaxDocid // B + if mergeRemapObserver != nil { + mergeRemapObserver(remap) + } + size := fileSize(path) + sm := segMeta{ + Id: outId, + Level: level, + DataCodec: dataCodec, + DictCodec: s.opts.DictCodec, + MinTable: minTable, + MaxTable: maxTable, + Size: size, + Postings: postings, + MinDocid: outMinDocid, // B + MaxDocid: outMaxDocid, // B + } + return mergeResult{seg: seg, sm: sm} +} + +// compareKeys / equalKeys order/compare full []byte keys lexicographically (the on-disk sort +// order). Kept tiny and inlinable; bytes.Compare would do but this avoids the import churn and is +// the merge inner loop. +func compareKeys(a, b []byte) int { + n := len(a) + if len(b) < n { + n = len(b) + } + for i := 0; i < n; i++ { + if a[i] != b[i] { + if a[i] < b[i] { + return -1 + } + return 1 + } + } + switch { + case len(a) < len(b): + return -1 + case len(a) > len(b): + return 1 + default: + return 0 + } +} + +func equalKeys(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// installMerge crash-safely swaps the MANIFEST to replace inputs (by id) with the merged output, +// publishes the new segment snapshot for readers, then DEFERS each input's deletion until no reader +// references it (P9/T8 refcount). MUST run on the worker (it mutates s.man/s.segs under the lock). A +// crash between writing the new segment and the MANIFEST swap leaves the inputs live + the new +// segment an unreferenced orphan (GC'd on next Open, design §9). +func (s *Store) installMerge(inputIds map[uint64]bool, res mergeResult) error { + res.seg.refs.Store(1) // P9: the published snapshot will hold one ref on the merged output + + // Persist the new MANIFEST, then publish — keeping the slow fsync OUT of the reader-blocking + // critical section (P9/T8, design §6: "readers never block on a writer's I/O — the lock is held + // only for the O(1) pointer swap and ref bookkeeping, never for spill/merge/file work"). All + // writes run on the single mpsc worker, so the lock here guards s.man only against concurrent + // READERS (tableInfo). So: (a) under the lock, compute the new segMeta set, swap s.man.Segments, + // and marshal the manifest to bytes (cheap, no I/O); (b) OUTSIDE the lock, do the two fsyncs + // (writeManifestBytes) so a concurrent Search/GetDocs is not blocked on them; (c) re-take the lock + // only for the O(1) s.segs swap + publishSnapshotLocked + retire of the inputs. + // + // Persist-then-publish keeps s.man/s.segs and the on-disk MANIFEST consistent on every failure + // path: do NOT mutate s.segs (the live handle set) until writeManifestBytes succeeds. If the write + // fails after we swapped s.man, we roll s.man back to the pre-merge set under the lock; s.segs was + // never touched, so the in-memory state, the still-old on-disk MANIFEST, and the open handles all + // agree (the just-written orphan output is removed). A crash between writing the new segment and + // the MANIFEST swap leaves the inputs live + the new segment an unreferenced orphan (GC'd on next + // Open, design §9). + s.mu.Lock() + newSegs := make([]segMeta, 0, len(s.man.Segments)) + for _, sm := range s.man.Segments { + if !inputIds[sm.Id] { + newSegs = append(newSegs, sm) + } + } + newSegs = append(newSegs, res.sm) + prevSegs := s.man.Segments + s.man.Segments = newSegs + b, err := marshalManifest(s.man) + if err != nil { + s.man.Segments = prevSegs // roll the in-memory manifest back to the pre-merge set + s.mu.Unlock() + res.seg.refs.Store(0) // never published: drop the ref before removing the orphan output + res.seg.close() + os.Remove(res.seg.path) + return err + } + s.mu.Unlock() + + if err := writeManifestBytes(s.dir, b); err != nil { + s.mu.Lock() + s.man.Segments = prevSegs // roll the in-memory manifest back to the pre-merge set + s.mu.Unlock() + res.seg.refs.Store(0) // never published: drop the ref before removing the orphan output + res.seg.close() + os.Remove(res.seg.path) // the output is unreferenced; remove only after the rollback + return err + } + + // Publish the new live segment slice (oldest->newest by id, preserving search's newest-wins + // scan order). Collect the retired input handles to retire AFTER publishing the new snapshot. + s.mu.Lock() + var retired []*segment + live := make([]*segment, 0, len(s.segs)) + for _, seg := range s.segs { + if inputIds[seg.id] { + retired = append(retired, seg) + continue + } + live = append(live, seg) + } + live = append(live, res.seg) + sortSegmentsById(live) + s.segs = live + // Publish the new snapshot BEFORE retiring the inputs, all under the write lock: a reader either + // already loaded the OLD snapshot and took its refs on the inputs under its RLock (so retire's + // decref won't hit zero — that reader's release will, later), or it loads the NEW snapshot here + // and never references the inputs. Either way no in-flight reader is left scanning a torn-down fd. + s.publishSnapshotLocked() + for _, seg := range retired { + s.dictCache.purge(seg.id) // chunk-LRU: drop the gone segment's cached dict chunks on swap + seg.retire() // deferred deletion: close + unlink only once refcount hits zero + } + s.mu.Unlock() + return nil +} + +// sortSegmentsById orders segments ascending by seal-sequence id (== oldest->newest), which Search +// relies on (it scans newest->oldest by walking the slice in reverse). A merged segment gets a +// FRESH (higher) id than all its inputs, so it correctly sorts as the newest of the set it replaces. +func sortSegmentsById(segs []*segment) { + for i := 1; i < len(segs); i++ { + for j := i; j > 0 && segs[j-1].id > segs[j].id; j-- { + segs[j-1], segs[j] = segs[j], segs[j-1] + } + } +} + +// nextSegId allocates and reserves the next seal-sequence id under the lock (mirrors spill's bump). +func (s *Store) nextSegId() uint64 { + s.mu.Lock() + id := s.man.NextSegId + s.man.NextSegId++ + s.mu.Unlock() + return id +} + +// pickLowestQualifyingLevelLocked returns the lowest level with >= Fanout live segments + its metas +// (oldest->newest), ok=false if none qualifies. Caller holds s.mu (R or W) — no lock taken here. +func (s *Store) pickLowestQualifyingLevelLocked() (level int, metas []segMeta, ok bool) { + byLevel := map[int][]segMeta{} + maxL := 0 + for _, sm := range s.man.Segments { + byLevel[sm.Level] = append(byLevel[sm.Level], sm) + if sm.Level > maxL { + maxL = sm.Level + } + } + for l := 0; l <= maxL; l++ { + if len(byLevel[l]) >= s.opts.Fanout { + m := byLevel[l] + sortSegMetasById(m) + return l, m, true + } + } + return 0, nil, false +} + +// mergeOneLevel finds the LOWEST level with >= Fanout live segments and merges all of them into one +// next-level segment. Returns merged=false when no level qualifies. Segments are merged +// oldest->newest (ascending id) so newest-wins reconciliation is correct. +func (s *Store) mergeOneLevel() (bool, error) { + s.mu.RLock() + level, metas, ok := s.pickLowestQualifyingLevelLocked() + s.mu.RUnlock() + if !ok { + return false, nil + } + inputIds := map[uint64]bool{} + for _, m := range metas { + inputIds[m.Id] = true + } + segs := s.segsByIds(inputIds) // raw handles; safe — the whole sync merge is one worker task + outId := s.nextSegId() + res := s.mergeSegments(segs, outId, level+1, s.opts.DataCodecMerged, false, nil) + return true, s.installMerge(inputIds, res) +} + +// segsByIdsLocked returns the open handles whose ids are in ids, oldest->newest, with a READER REF +// bumped on each (caller MUST releaseSnapshot them). Caller holds s.mu (Lock here — the plan reserves +// outId in the same window). The incref-under-lock closes the load-then-retire race (spec §8). +func (s *Store) segsByIdsLocked(ids map[uint64]bool) []*segment { + out := make([]*segment, 0, len(ids)) + for _, seg := range s.segs { + if ids[seg.id] { + seg.refs.Add(1) + out = append(out, seg) + } + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j-1].id > out[j].id; j-- { + out[j-1], out[j] = out[j], out[j-1] + } + } + return out +} + +// mergePlan is one off-worker merge pass decided on the worker under s.mu: ref-held inputs, a reserved +// output id, and the mergeSegments parameters. The plan's input refs are released after install. +type mergePlan struct { + inputIds map[uint64]bool + segs []*segment // ref-held (segsByIdsLocked); released by runMergePlan after install + outId uint64 + level int + dataCodec byte + covering bool + liveTables map[int]bool +} + +// selectTieredMergePlan picks the lowest qualifying level, increfs its inputs, and reserves outId — +// ALL under one s.mu.Lock (no gap). Returns nil if no level qualifies. MUST run on the worker. +func (s *Store) selectTieredMergePlan() *mergePlan { + s.mu.Lock() + defer s.mu.Unlock() + level, metas, ok := s.pickLowestQualifyingLevelLocked() + if !ok { + return nil + } + inputIds := map[uint64]bool{} + for _, m := range metas { + inputIds[m.Id] = true + } + segs := s.segsByIdsLocked(inputIds) + outId := s.man.NextSegId + s.man.NextSegId++ + return &mergePlan{inputIds: inputIds, segs: segs, outId: outId, level: level + 1, + dataCodec: s.opts.DataCodecMerged} +} + +// selectCoveringMergePlan decides a covering pass (force, or the dead fraction crosses with >= 2 +// segments), increfs ALL live inputs, snapshots liveTables, and reserves outId — under one s.mu.Lock. +// It fires coveringMergeHook here (counter parity with the synchronous coveringMerge). MUST run on the +// worker. Returns nil if nothing to compact. +func (s *Store) selectCoveringMergePlan(force bool) *mergePlan { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.man.Segments) == 0 { + return nil + } + if !force { + if len(s.man.Segments) < 2 || s.deadFractionLocked() < coveringDeadThreshold { + return nil + } + } + // NOTE (cross-review): coveringMergeHook fires at INSTALL time in runMergePlan (counting COMPLETED + // covering merges, parity with the synchronous coveringMerge), NOT here at plan time — a plan can + // still fail to install, and a test that reads the counter then asserts segment state must not race + // a not-yet-run install. + level := 0 + inputIds := map[uint64]bool{} + for _, sm := range s.man.Segments { + inputIds[sm.Id] = true + if sm.Level > level { + level = sm.Level + } + } + liveTables := map[int]bool{} + for id := range s.man.Tables { + liveTables[id] = true + } + segs := s.segsByIdsLocked(inputIds) + outId := s.man.NextSegId + s.man.NextSegId++ + return &mergePlan{inputIds: inputIds, segs: segs, outId: outId, level: level, + dataCodec: s.opts.DataCodecMerged, covering: true, liveTables: liveTables} +} + +// runMergePlan runs the heavy compute OFF the worker, then installs ON the worker, then releases the +// plan's input refs (so a retired input is torn down only after the compute AND every reader finish). +// It RETURNS the install error so the tiered loop can break the pass on a persistent install failure +// instead of immediately re-selecting the SAME still-qualifying level and re-running the heavy +// compute forever (a hot livelock). installMerge rolls s.man back to the pre-merge set on a failed +// MANIFEST write, so a returned error leaves the live set intact and the next trigger retries — the +// pre-off-worker semantics ("give up the pass; the next trigger retries"), not a spin. +func (s *Store) runMergePlan(p *mergePlan) error { + res := s.mergeSegments(p.segs, p.outId, p.level, p.dataCodec, p.covering, p.liveTables) + err := s.q.RunFunc(func() error { return s.installMerge(p.inputIds, res) }) + if err == nil && p.covering && coveringMergeHook != nil { + coveringMergeHook() // count COMPLETED covering merges (parity); the hook is atomic (-race safe) + } + s.releaseSnapshot(p.segs) + return err +} + +// deadFraction is the covering-merge trigger: the fraction of WRITTEN inverted postings a covering +// merge would reclaim, computed from metadata only (no decompression — this replaces the old +// bottomDeadFraction full-scan that cost 73% of build CPU). written = Σ segMeta.Postings over all +// live segments; live = Σ liveByTable over CATALOG tables only (a stale post-DeleteTable partition +// for a non-catalog table is excluded, matching the catalog-gated Open recompute). The head-resident +// live pairs (≤ CapBytes) can make live slightly exceed sealed written → clamp the negative to 0 +// (a safe under-trigger). MUST be called under the worker (reads under RLock; liveByTable is mutated +// under the write lock in applyBatch). Spec §4.3. +func (s *Store) deadFraction() float64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.deadFractionLocked() +} + +// deadFractionLocked is deadFraction's body; caller holds s.mu (R or W). +func (s *Store) deadFractionLocked() float64 { + var written int64 + for _, sm := range s.man.Segments { + written += sm.Postings + } + var live int64 + for t, n := range s.liveByTable { + if _, ok := s.man.Tables[t]; ok { + live += n + } + } + if written <= 0 { + return 0 + } + d := 1 - float64(live)/float64(written) + if d < 0 { + d = 0 + } + return d +} + +// coveringDeadThreshold is the dead-fraction trigger for a covering merge (design §6). +const coveringDeadThreshold = 0.25 + +// coveringMergeHook, when non-nil, is invoked at the top of every coveringMerge — covering BOTH the +// dead-fraction-triggered path (maybeCoveringMerge) AND the DeleteTable/Open-orphan forced path. A +// test installs it to count covering merges; nil in production. +var coveringMergeHook func() + +// coveringMerge compacts ALL live segments (the bottom level + everything above) into one segment +// at the max level + 0 (it stays the bottom), reclaiming everything a covering merge can. It is +// also what DeleteTable schedules so a dropped table's bytes go even if its segments sit at the +// bottom with no further writes. MUST run on the worker. +func (s *Store) coveringMerge() error { + if coveringMergeHook != nil { + coveringMergeHook() + } + s.mu.RLock() + if len(s.man.Segments) == 0 { + s.mu.RUnlock() + return nil + } + metas := append([]segMeta(nil), s.man.Segments...) + level := 0 + for _, sm := range metas { + if sm.Level > level { + level = sm.Level + } + } + liveTables := map[int]bool{} + for id := range s.man.Tables { + liveTables[id] = true + } + s.mu.RUnlock() + + sortSegMetasById(metas) + inputIds := map[uint64]bool{} + for _, m := range metas { + inputIds[m.Id] = true + } + segs := s.segsByIds(inputIds) + outId := s.nextSegId() + res := s.mergeSegments(segs, outId, level, s.opts.DataCodecMerged, true, liveTables) + return s.installMerge(inputIds, res) +} + +// reclaimOrphanTables runs ONE synchronous covering merge on the worker if any non-empty live +// segment covers a tableId that is ABSENT from the catalog — orphan dead-table bytes left when a +// DeleteTable removed a table from the catalog (durably) but a crash dropped the volatile covering +// merge it scheduled before it installed (spec §6). It is AutoMerge-INDEPENDENT: it calls +// coveringMerge directly via q.RunFunc (the covering merge needs no merge loop), unlike triggerMerge +// which no-ops when AutoMerge is off (the default). The [MinTable,MaxTable] test is a range, not a +// set, so it can only OVER-detect → at worst one extra covering merge that is a near-no-op on an +// already-clean index, never a miss. Empty (Postings==0) segments are skipped: they have nothing to +// reclaim and their MinTable==0 (no key ever set the range) would false-positive forever. +func (s *Store) reclaimOrphanTables() { + s.mu.RLock() + orphan := false + for _, sm := range s.man.Segments { + if sm.Postings == 0 { + continue + } + for t := sm.MinTable; t <= sm.MaxTable; t++ { + if _, ok := s.man.Tables[int(t)]; !ok { + orphan = true + break + } + } + if orphan { + break + } + } + s.mu.RUnlock() + if orphan { + _ = s.q.RunFunc(func() error { return s.coveringMerge() }) + } +} + +// segsByIds returns the open segment handles whose ids are in ids, in OLDEST -> NEWEST (ascending +// id) order — the order mergeSegments needs for newest-wins reconciliation. Read under the lock. +func (s *Store) segsByIds(ids map[uint64]bool) []*segment { + s.mu.RLock() + out := make([]*segment, 0, len(ids)) + for _, seg := range s.segs { + if ids[seg.id] { + out = append(out, seg) + } + } + s.mu.RUnlock() + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j-1].id > out[j].id; j-- { + out[j-1], out[j] = out[j], out[j-1] + } + } + return out +} + +// sortSegMetasById orders segMetas ascending by id (oldest->newest) in place. +func sortSegMetasById(metas []segMeta) { + for i := 1; i < len(metas); i++ { + for j := i; j > 0 && metas[j-1].Id > metas[j].Id; j-- { + metas[j-1], metas[j] = metas[j], metas[j-1] + } + } +} diff --git a/core/invertedstore/merge_highcardinality_test.go b/core/invertedstore/merge_highcardinality_test.go new file mode 100644 index 0000000..9b394f2 --- /dev/null +++ b/core/invertedstore/merge_highcardinality_test.go @@ -0,0 +1,462 @@ +package invertedstore + +import ( + "sort" + "testing" +) + +// merge_highcardinality_test.go — characterization of the merge reconciliation under the +// "one very high-cardinality keyword adjacent to many tiny keywords" shape (spec +// invertedstore-merge-mapreuse-regression-fix-spec.md §7.2; task T1). +// +// This is a CORRECTNESS / coverage case, explicitly NOT a perf-regression guard: the C.4 fix +// changes only WHERE the adds/dels reconciliation maps are allocated (hoisted+clear()-reused vs +// fresh-per-key), never the merged OUTPUT, so this test passes byte-identically on the buggy +// (clear()-reuse) and fixed (fresh-map) tree. Its job is to (a) fill a real coverage gap — no +// other test builds a single giant posting list flanked by a long tail of tiny keywords, the exact +// map-population shape the fix touches — and (b) document that the revert preserves behavior. +// +// Two INDEPENDENT sub-cases / stores: a covering merge compacts everything to ONE segment, so a +// tiered merge cannot be run after a covering one in the same store. +// +// - Tiered (mergeOneLevelForTest): keeps BOTH adds and dels, never drops a key. +// - Covering (coveringMergeForTest): drops ALL dels and drops any key with zero surviving adds. +// +// The assertion seam is segInvRecords(seg, tbl) (the read-back of the sealed merged segment), NOT a +// Search-only presence check — Search would hide the del side a tiered merge must preserve. + +// bigKeyword is the single high-cardinality term whose posting list dominates the reconciliation +// maps; bigCard keeps the unit test fast while still building a map far larger than the tiny ones +// (the regression's "huge map then tiny maps" drain shape). +const ( + bigKeyword = "thebigterm" + bigCard = 20000 +) + +// refModel is an independent newest-wins reference for one keyword across the merge sources. It +// mirrors merge.go:296-325 EXACTLY: per source, process adds THEN dels (a del overrides an add for +// the same docid within one source); across sources, the LATER (newer / higher-id) source wins. +// applySource is called once per source in OLDEST -> NEWEST order. +type refModel struct { + adds map[int64]struct{} + dels map[int64]struct{} +} + +func newRefModel() *refModel { + return &refModel{adds: map[int64]struct{}{}, dels: map[int64]struct{}{}} +} + +// applySource folds one source's resolved adds/dels into the running newest-wins state. Within the +// source adds are processed THEN dels (so a del overrides an add for the same docid); a later +// applySource call (a newer source) overrides an earlier one for the same docid. This is the exact +// rule of merge.go's `decodeDocs(ab, ...)` then `decodeDocs(db, ...)` over hit in oldest->newest +// cursor order. +func (m *refModel) applySource(adds, dels []int64) { + for _, d := range adds { + delete(m.dels, d) + m.adds[d] = struct{}{} + } + for _, d := range dels { + delete(m.adds, d) + m.dels[d] = struct{}{} + } +} + +// tieredResult is the reference for a TIERED merge: keep BOTH adds and dels (a del must still +// suppress an older add in a segment outside this merge), and NEVER drop the key. +func (m *refModel) tieredResult() (adds, dels []int64) { + return sortedKeys(m.adds), sortedKeys(m.dels) +} + +// coveringResult is the reference for a COVERING merge: drop ALL dels (nothing older survives to be +// suppressed) and drop the key entirely when no add survives. keep=false => the key must be GONE +// from the merged segment. +func (m *refModel) coveringResult() (adds []int64, keep bool) { + adds = sortedKeys(m.adds) + return adds, len(adds) > 0 +} + +func sortedKeys(m map[int64]struct{}) []int64 { + out := make([]int64, 0, len(m)) + for d := range m { + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func sortedInt64Copy(s []int64) []int64 { + out := append([]int64(nil), s...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func equalInt64(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestMerge_HighCardinality_TieredPreservesReconciliation builds Fanout L0 segments where ONE +// keyword (bigKeyword) carries a huge posting list and MANY tiny keywords (1-2 docids each) flank it +// on BOTH sides of the term-dict key order, so the merge drain hits a huge map and then tiny maps. +// Cross-source re-adds AND tombstones on the big keyword exercise newest-wins + the del side. After a +// tiered merge, every keyword's adds/dels in the single merged segment must equal the independent +// reference (which keeps both adds and dels and never drops a key). +func TestMerge_HighCardinality_TieredPreservesReconciliation(t *testing.T) { + const fanout = 4 + s, tbl := newMergeStore(t, fanout) + defer s.CloseAndWait() + + // Per-keyword reference, folded source-by-source in oldest->newest (== spill) order. + ref := map[string]*refModel{} + refFor := func(kw string) *refModel { + m := ref[kw] + if m == nil { + m = newRefModel() + ref[kw] = m + } + return m + } + // Build one source segment: issue the recorded ops into the head, fold them into the reference + // (resolving the source's own ops latest-wins per docid so the recorded adds/dels match what the + // sealed segment stores), then forceSpill to seal it. + type op struct { + kw string + docid int64 + add bool + } + buildSource := func(ops []op) { + // resolve this source's ops latest-wins per (kw,docid): the LAST op for a docid decides + // add-vs-del, exactly as resolveOps does at spill time (the sealed segment value holds at most + // one action per (kw,docid)). + last := map[string]map[int64]bool{} // kw -> docid -> isAdd (latest) + order := []string{} // first-seen keyword order (deterministic) + seen := map[string]bool{} + for _, o := range ops { + if last[o.kw] == nil { + last[o.kw] = map[int64]bool{} + } + last[o.kw][o.docid] = o.add + if !seen[o.kw] { + seen[o.kw] = true + order = append(order, o.kw) + } + if o.add { + s.addPostingForTest(tbl, o.kw, o.docid) + } else { + s.tombstoneForTest(tbl, o.kw, o.docid) + } + } + for _, kw := range order { + var adds, dels []int64 + for d, isAdd := range last[kw] { + if isAdd { + adds = append(adds, d) + } else { + dels = append(dels, d) + } + } + refFor(kw).applySource(sortedInt64Copy(adds), sortedInt64Copy(dels)) + } + s.forceSpill(tbl) + } + + // Tiny keywords are split into a band that sorts BEFORE bigKeyword and a band that sorts AFTER it + // in the [I] key order (segInvRecords / the merge walk both order by keyword bytes). "aa.." sorts + // before "thebigterm"; "zz.." sorts after — so the big map is flanked by tiny maps on both sides. + loKw := func(n int) string { return kwf("aa_tiny_", n) } + hiKw := func(n int) string { return kwf("zz_tiny_", n) } + + // --- Source 0 (oldest): big keyword gets the first half of its posting list; tiny flankers. --- + var src0 []op + for _, n := range []int{0, 1, 2, 3} { + src0 = append(src0, op{loKw(n), int64(100000 + n), true}) // tiny, before big + src0 = append(src0, op{hiKw(n), int64(200000 + n), true}) // tiny, after big + } + for d := int64(0); d < bigCard/2; d++ { + src0 = append(src0, op{bigKeyword, d, true}) + } + // a tiny keyword that will be re-added live in a later source after a tombstone here + src0 = append(src0, op{loKw(99), 100099, true}) + buildSource(src0) + + // --- Source 1: big keyword gets the second half + a few tombstones on docids it added in src0 + // (cross-source del on the big keyword); plus more tiny flankers + a cross-source re-add. --- + var src1 []op + for d := int64(bigCard / 2); d < bigCard; d++ { + src1 = append(src1, op{bigKeyword, d, true}) + } + // tombstone three docids the big keyword added in src0 (newest-wins: these become DEL in tiered) + for _, d := range []int64{5, 10, 15} { + src1 = append(src1, op{bigKeyword, d, false}) + } + for _, n := range []int{4, 5, 6} { + src1 = append(src1, op{loKw(n), int64(100000 + n), true}) + src1 = append(src1, op{hiKw(n), int64(200000 + n), true}) + } + // cross-source re-add: loKw(99) doc 100099 was added in src0; tombstone then re-add it here so the + // newest action (add) wins within src1, and the whole keyword stays a live add overall. + src1 = append(src1, op{loKw(99), 100099, false}) + src1 = append(src1, op{loKw(99), 100099, true}) + buildSource(src1) + + // --- Source 2: a cross-source re-add of two big-keyword docids tombstoned in src1 (so newest add + // wins again -> they go back to live adds), plus more tiny flankers. --- + var src2 []op + src2 = append(src2, op{bigKeyword, 5, true}) // re-add (src1 deleted it) -> live + src2 = append(src2, op{bigKeyword, 10, true}) // re-add -> live; doc 15 stays deleted + for _, n := range []int{7, 8} { + src2 = append(src2, op{loKw(n), int64(100000 + n), true}) + src2 = append(src2, op{hiKw(n), int64(200000 + n), true}) + } + buildSource(src2) + + // --- Source 3 (newest): a cross-source tombstone on a big-keyword docid + tiny flankers, to fill + // out Fanout segments so the tiered merge fires. --- + var src3 []op + src3 = append(src3, op{bigKeyword, 7, false}) // delete doc 7 (added live in src0) -> newest=del + for _, n := range []int{9, 10} { + src3 = append(src3, op{loKw(n), int64(100000 + n), true}) + src3 = append(src3, op{hiKw(n), int64(200000 + n), true}) + } + buildSource(src3) + + if len(s.segs) != fanout { + t.Fatalf("expected %d L0 segments before merge, got %d", fanout, len(s.segs)) + } + + if !s.mergeOneLevelForTest(t) { + t.Fatalf("expected a tiered merge to fire with %d L0 segments at Fanout %d", fanout, fanout) + } + if len(s.segs) != 1 { + t.Fatalf("after merging %d L0 segments expected 1 segment, got %d", fanout, len(s.segs)) + } + + got := segInvRecords(s.segs[0], tbl) + + // Tiered keeps EVERY keyword (never drops a key) and BOTH its adds and dels must equal the model. + if len(got) != len(ref) { + t.Fatalf("tiered merged keyword count = %d, want %d (tiered must drop no key)", len(got), len(ref)) + } + for kw, m := range ref { + rec, ok := got[kw] + if !ok { + t.Fatalf("tiered merge dropped keyword %q (tiered must never drop a key)", kw) + } + wantAdds, wantDels := m.tieredResult() + gotAdds := sortedInt64Copy(rec.adds) + gotDels := sortedInt64Copy(rec.dels) + if !equalInt64(gotAdds, wantAdds) { + if kw == bigKeyword { + t.Fatalf("tiered merge %q adds mismatch: got %d adds, want %d adds (first diff at the docid level)", + kw, len(gotAdds), len(wantAdds)) + } + t.Fatalf("tiered merge %q adds = %v, want %v", kw, gotAdds, wantAdds) + } + if !equalInt64(gotDels, wantDels) { + t.Fatalf("tiered merge %q dels = %v, want %v", kw, gotDels, wantDels) + } + } + + // Spot-check the load-bearing reconciliation on the big keyword: docs 5 and 10 were add(src0) -> + // del(src1) -> add(src2) => live adds; doc 7 add(src0) -> del(src3) => del; doc 15 add -> del => + // del. These prove the across-source newest-wins + the kept-del side under the huge map. + big := got[bigKeyword] + bigAdds := map[int64]bool{} + for _, d := range big.adds { + bigAdds[d] = true + } + bigDels := map[int64]bool{} + for _, d := range big.dels { + bigDels[d] = true + } + for _, d := range []int64{5, 10} { + if !bigAdds[d] || bigDels[d] { + t.Errorf("big keyword doc %d: add->del->add must reconcile to a LIVE add (adds=%v dels=%v)", d, bigAdds[d], bigDels[d]) + } + } + for _, d := range []int64{7, 15} { + if bigAdds[d] || !bigDels[d] { + t.Errorf("big keyword doc %d: add->del (newest) must reconcile to a DEL (adds=%v dels=%v)", d, bigAdds[d], bigDels[d]) + } + } +} + +// TestMerge_HighCardinality_CoveringReclaimsTombstones mirrors +// TestMerge_CoveringReclaimsTombstonesAndDuplicates under the high-cardinality shape: ONE big +// keyword with a huge posting list (some docids tombstoned), MANY tiny flanking keywords, AND a +// fully-tombstoned tiny keyword (every add cancelled by a later del). A covering merge drops ALL +// dels, keeps adds-only, and drops any key with zero surviving adds — so the merged segment's adds +// must equal the reference's covering result and the fully-tombstoned key must be GONE. +func TestMerge_HighCardinality_CoveringReclaimsTombstones(t *testing.T) { + // High Fanout so ONLY the explicit covering merge fires (no tiered merge in between). + s, tbl := newMergeStore(t, 100) + defer s.CloseAndWait() + + ref := map[string]*refModel{} + refFor := func(kw string) *refModel { + m := ref[kw] + if m == nil { + m = newRefModel() + ref[kw] = m + } + return m + } + type op struct { + kw string + docid int64 + add bool + } + buildSource := func(ops []op) { + last := map[string]map[int64]bool{} + order := []string{} + seen := map[string]bool{} + for _, o := range ops { + if last[o.kw] == nil { + last[o.kw] = map[int64]bool{} + } + last[o.kw][o.docid] = o.add + if !seen[o.kw] { + seen[o.kw] = true + order = append(order, o.kw) + } + if o.add { + s.addPostingForTest(tbl, o.kw, o.docid) + } else { + s.tombstoneForTest(tbl, o.kw, o.docid) + } + } + for _, kw := range order { + var adds, dels []int64 + for d, isAdd := range last[kw] { + if isAdd { + adds = append(adds, d) + } else { + dels = append(dels, d) + } + } + refFor(kw).applySource(sortedInt64Copy(adds), sortedInt64Copy(dels)) + } + s.forceSpill(tbl) + } + + loKw := func(n int) string { return kwf("aa_tiny_", n) } + hiKw := func(n int) string { return kwf("zz_tiny_", n) } + const fullyTombstoned = "mm_doomed" // a tiny key whose single add is cancelled by a later del + + // --- Source 0: big keyword first half + tiny flankers + the doomed key gets an add (to be + // cancelled later) + a tiny keyword whose only docid is tombstoned in a later source. --- + var src0 []op + for _, n := range []int{0, 1, 2} { + src0 = append(src0, op{loKw(n), int64(100000 + n), true}) + src0 = append(src0, op{hiKw(n), int64(200000 + n), true}) + } + for d := int64(0); d < bigCard/2; d++ { + src0 = append(src0, op{bigKeyword, d, true}) + } + src0 = append(src0, op{fullyTombstoned, 300000, true}) // the only add the doomed key ever gets + buildSource(src0) + + // --- Source 1: big keyword second half + a tombstone on a big-keyword docid (dangling tombstone + // the covering merge must reclaim) + tiny flankers + the doomed key's cancelling tombstone. --- + var src1 []op + for d := int64(bigCard / 2); d < bigCard; d++ { + src1 = append(src1, op{bigKeyword, d, true}) + } + for _, d := range []int64{3, 8} { + src1 = append(src1, op{bigKeyword, d, false}) // tombstone -> dangling under covering + } + for _, n := range []int{3, 4} { + src1 = append(src1, op{loKw(n), int64(100000 + n), true}) + src1 = append(src1, op{hiKw(n), int64(200000 + n), true}) + } + src1 = append(src1, op{fullyTombstoned, 300000, false}) // cancels the doomed key's only add + buildSource(src1) + + // --- Source 2 (newest): a cross-source re-add of one tombstoned big-keyword docid (so it is live + // again, surviving the covering merge) + tiny flankers. --- + var src2 []op + src2 = append(src2, op{bigKeyword, 3, true}) // re-add (src1 deleted it) -> live; doc 8 stays del + for _, n := range []int{5, 6} { + src2 = append(src2, op{loKw(n), int64(100000 + n), true}) + src2 = append(src2, op{hiKw(n), int64(200000 + n), true}) + } + buildSource(src2) + + preSegs := len(s.segs) + if preSegs < 3 { + t.Fatalf("expected >=3 segments before the covering merge, got %d", preSegs) + } + + s.coveringMergeForTest(t) + + if len(s.segs) != 1 { + t.Fatalf("covering merge must compact to 1 segment, got %d", len(s.segs)) + } + got := segInvRecords(s.segs[0], tbl) + + // Build the covering reference: every keyword with a surviving add must be present with EXACTLY + // those adds and ZERO dels; a keyword with no surviving add must be ABSENT. + wantKeys := 0 + for kw, m := range ref { + wantAdds, keep := m.coveringResult() + if !keep { + if _, ok := got[kw]; ok { + t.Errorf("covering merge must drop zero-add key %q, got %v", kw, got[kw]) + } + continue + } + wantKeys++ + rec, ok := got[kw] + if !ok { + t.Fatalf("covering merge dropped live keyword %q (it has surviving adds %v)", kw, wantAdds) + } + if len(rec.dels) != 0 { + t.Errorf("covering merge must reclaim ALL dels, %q kept dels=%v", kw, rec.dels) + } + gotAdds := sortedInt64Copy(rec.adds) + if !equalInt64(gotAdds, wantAdds) { + if kw == bigKeyword { + t.Errorf("covering merge %q adds count = %d, want %d", kw, len(gotAdds), len(wantAdds)) + } else { + t.Errorf("covering merge %q adds = %v, want %v", kw, gotAdds, wantAdds) + } + } + } + if len(got) != wantKeys { + t.Errorf("covering merged keyword count = %d, want %d (covering drops zero-add keys)", len(got), wantKeys) + } + + // Load-bearing: the fully-tombstoned key is GONE (its single add was cancelled by a later del, and + // covering drops all dels -> zero surviving adds -> key dropped). Mirror the "ghost" assertion of + // TestMerge_CoveringReclaimsTombstonesAndDuplicates. + if _, ok := got[fullyTombstoned]; ok { + t.Errorf("covering merge must drop the fully-tombstoned key %q, got %v", fullyTombstoned, got[fullyTombstoned]) + } + // Load-bearing: the big keyword kept NO dels (the two tombstoned docids are reclaimed; doc 3 came + // back live via the src2 re-add, doc 8 stays gone as a clean miss — not a del). + big, ok := got[bigKeyword] + if !ok { + t.Fatalf("covering merge dropped the big keyword (it has thousands of surviving adds)") + } + if len(big.dels) != 0 { + t.Errorf("covering merge must reclaim the big keyword's dels, kept %v", big.dels) + } + bigAdds := map[int64]bool{} + for _, d := range big.adds { + bigAdds[d] = true + } + if !bigAdds[3] { + t.Errorf("big keyword doc 3 (del then re-added newest) must survive the covering merge as a live add") + } + if bigAdds[8] { + t.Errorf("big keyword doc 8 (tombstoned, never re-added) must be reclaimed (absent), not a live add") + } +} diff --git a/core/invertedstore/merge_offworker_test.go b/core/invertedstore/merge_offworker_test.go new file mode 100644 index 0000000..34a13c9 --- /dev/null +++ b/core/invertedstore/merge_offworker_test.go @@ -0,0 +1,660 @@ +package invertedstore + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codetrek/haystack/core/queue" +) + +// merge_offworker_test.go — item A (merge COMPUTE off the worker; spec Task 4 Steps 5–6) acceptance +// tests. The off-worker change runs the heavy mergeSegments on the merge goroutine and installs back +// ON the worker (one MANIFEST writer). These tests guard the exact hazards that move introduces: +// +// - Step 5 (this file's first test): a parked off-worker compute must NOT block the worker — an +// Update task enqueued while mergeSegments is parked still drains promptly. +// - Step 5 race + hit-identity: while the compute is parked, concurrent Updates + Searches run +// -race clean and the post-drain live hit set equals a SERIAL reference build (a second store fed +// the same net state with AutoMerge off) — the off-worker path has an equivalence net under load. +// - Step 5 waitMergeIdle convergence: with a deliberately slow install (beforeManifestFsync delay), +// waitMergeIdle returns ONLY after the install lands (mergeAckSeq is stored AFTER the last +// runMergePlan, which awaits its install RunFunc) — proven, not assumed. +// - Step 6 ref-held-during-compute: while parked, each input segment's refs.Load() >= 2 (the +// published snapshot ref + the plan's incref), so a concurrent retire cannot free an input +// mid-read; after release the merged-away inputs are torn down (files removed) and a reopened +// MANIFEST lists only the merged output. +// - Step 6 liveTables staleness: a DeleteTable racing an in-flight covering compute is benign — the +// reclaim is still correct and a follow-up covering pass cleans the now-deleted table. +// +// The merge observer hooks (mergeComputeBlock, beforeManifestFsync, coveringMergeHook) are package +// globals on the merge hot path, so NO test in this file may call t.Parallel. + +// parkAt installs a mergeComputeBlock that signals `entered` the first time a compute reaches it and +// then blocks on `release`. The returned `unpark` clears the hook (so a later drain does not re-park) +// and closes `release` (idempotent), letting any parked compute proceed. parkAt does NOT register a +// Cleanup itself — the caller MUST arrange for unpark to run BEFORE its store's CloseAndWait (so the +// drain at Close is not re-parked into a deadlock); the standard pattern is to register CloseAndWait +// FIRST (runs last, LIFO) then call parkAt and register unpark (runs first). +func parkAt(t *testing.T) (entered chan struct{}, release chan struct{}, unpark func()) { + t.Helper() + entered = make(chan struct{}, 1) + release = make(chan struct{}) + var releaseOnce sync.Once + unpark = func() { + mergeComputeBlock = nil + releaseOnce.Do(func() { close(release) }) + } + mergeComputeBlock = func() { + select { + case entered <- struct{}{}: + default: + } + <-release + } + return entered, release, unpark +} + +// waitEntered blocks until a parked compute has signaled entry, failing the test on timeout. +func waitEntered(t *testing.T, entered chan struct{}) { + t.Helper() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("merge compute never started off the worker (parked block never reached)") + } +} + +// With the merge COMPUTE off the worker, a parked compute must NOT block the worker: an Update +// enqueued while mergeSegments is blocked still completes promptly. +func TestMergeOffWorker_ComputeDoesNotBlockWorker(t *testing.T) { + q := queue.NewMpsc("offworker") + q.Start() + s, err := Open(t.TempDir(), q, Options{AutoMerge: true, Fanout: 2, CapBytes: 1 << 12}) + if err != nil { + t.Fatal(err) + } + tbl, _ := s.CreateTable("files") + + // Register CloseAndWait FIRST so it runs LAST (LIFO) — after unpark has cleared the hook + released + // the parked compute, so the Close-time drain is not re-parked into a deadlock and the merge output + // is settled before t.TempDir's RemoveAll. + t.Cleanup(func() { s.CloseAndWait() }) + entered, _, unpark := parkAt(t) + t.Cleanup(unpark) + + // Seal >= Fanout segments so the background merger fires a tiered pass (compute will park). + for i := 0; i < 4; i++ { + s.applyForTest(tbl, int64(1000+i), []string{uniqWord(1000 + i)}) + s.spillForTest(tbl) + } + waitEntered(t, entered) + + // The compute is parked. A worker task (RunFunc) MUST still run — proving the compute is off-worker. + done := make(chan struct{}) + go func() { s.q.RunFunc(func() error { return nil }); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("worker blocked behind the off-worker merge compute (compute is ON the worker)") + } +} + +// docSet maps docid -> live keyword set; the empty/absent entry is a deleted doc. It is the net state +// fed identically to the store-under-test and the serial reference build so a hit-identity assertion +// compares the off-worker merge path against a deterministic, AutoMerge-off oracle. +type docSet map[int64][]string + +// buildSerialReference builds a fresh store with AutoMerge OFF, applies net `state` for each doc via +// the public Update path, drains, and returns it. With AutoMerge off the off-worker merge path is +// never exercised, so this is the equivalence oracle: same net data, no background merge. The caller +// owns Close. +func buildSerialReference(t *testing.T, state docSet) (*Store, int) { + t.Helper() + q := queue.NewMpsc("offworker-ref") + q.Start() + s, err := Open(t.TempDir(), q, Options{}) // AutoMerge defaults OFF + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + for d, kws := range state { + s.Update(tbl, d, kws) + } + s.sync() + return s, tbl +} + +// searchDocSet returns the live docid set Search yields for `query` (prefix) on tableId. +func searchDocSet(s *Store, tableId int, query string) map[int64]bool { + r := s.Search(tableId, query, 0, nil) + out := make(map[int64]bool, len(r.DocIds)) + for d := range r.DocIds { + out[d] = true + } + return out +} + +// assertSameDocSet fails the test unless a == b (as docid sets), reporting the symmetric difference. +func assertSameDocSet(t *testing.T, query string, got, want map[int64]bool) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("query %q: hit set size %d != reference %d (got=%v want=%v)", query, len(got), len(want), got, want) + } + for d := range want { + if !got[d] { + t.Fatalf("query %q: doc %d in reference but missing from off-worker store (got=%v)", query, d, got) + } + } + for d := range got { + if !want[d] { + t.Fatalf("query %q: doc %d in off-worker store but not the reference (want=%v)", query, d, want) + } + } +} + +// TestMergeOffWorker_ConcurrentUpdateSearchUnderParkedCompute is the Step 5 adversarial equivalence +// net. While the off-worker mergeSegments is held OPEN (parked via mergeComputeBlock), it fires +// concurrent Updates + Searches against the parked compute, then unparks, drains, and asserts the +// live hit set equals a SERIAL reference build (a second store fed the same net state with AutoMerge +// off). The concurrent Updates re-write each doc to its OWN final keyword set (idempotent), so the +// net state is deterministic regardless of interleaving with the parked compute; the Searches prove +// readers do not panic on a segment being merged away and -race stays clean. Run under -race this is +// the off-worker path's "hits identical under concurrent load + inputs not torn down mid-compute" +// guard the single off-worker-ness test does not provide. +func TestMergeOffWorker_ConcurrentUpdateSearchUnderParkedCompute(t *testing.T) { + q := queue.NewMpsc("offworker-stress") + q.Start() + // Small Fanout + tiny CapBytes so sealing a handful of docs reaches Fanout and the background + // merger fires a tiered pass (whose compute we park). AutoMerge on drives the off-worker path. + s, err := Open(t.TempDir(), q, Options{AutoMerge: true, Fanout: 2, CapBytes: 1 << 11}) + if err != nil { + t.Fatal(err) + } + tbl, _ := s.CreateTable("files") + + // The deterministic NET state: each doc has "alpha" (a shared prefix that fans out) + a per-doc + // keyword. This is fed to BOTH the off-worker store and the serial reference. + const nDocs = 24 + state := docSet{} + for d := int64(1); d <= nDocs; d++ { + state[d] = []string{"alpha", fmt.Sprintf("doc%d", d)} + } + + t.Cleanup(func() { s.CloseAndWait() }) + entered, _, unpark := parkAt(t) + t.Cleanup(unpark) + + // Establish the net state, then seal enough segments to fire a tiered merge (the parked compute). + for d := int64(1); d <= nDocs; d++ { + s.applyForTest(tbl, d, state[d]) + s.spillForTest(tbl) // one segment per doc => well past Fanout=2 => tiered passes fire + } + waitEntered(t, entered) + + // The compute is PARKED. Fire concurrent Updates (idempotent re-writes of the same net state) + + // Searches against the parked compute. Run them for a short, bounded burst so they overlap the + // parked window; the idempotent re-writes keep the final net state deterministic. + var wg sync.WaitGroup + stop := make(chan struct{}) + for w := 0; w < 3; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + for d := int64(1); d <= nDocs; d++ { + s.Update(tbl, d, state[d]) // idempotent: net state unchanged + } + } + }() + } + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = s.Search(tbl, "alpha", 0, nil) + _ = s.Search(tbl, "doc", 0, nil) + _ = s.GetDocs(tbl, "alpha") + } + }() + } + + // Let the concurrent load overlap the parked compute, then unpark and stop the load. + time.Sleep(100 * time.Millisecond) + unpark() // clear the hook + release the parked compute (subsequent passes do not re-park) + close(stop) + wg.Wait() + + // Drain: every async Update applied, the background merger settled, the off-worker compute installed. + s.sync() + s.waitMergeIdle() + + // Hit-identity: the off-worker store's live hits equal a serial reference fed the same net state. + ref, refTbl := buildSerialReference(t, state) + defer ref.CloseAndWait() + for _, q := range []string{"alpha", "doc"} { + assertSameDocSet(t, q, searchDocSet(s, tbl, q), searchDocSet(ref, refTbl, q)) + } + // Per-doc keyword equivalence too (each doc resolvable under its own keyword). + for d := int64(1); d <= nDocs; d++ { + q := fmt.Sprintf("doc%d", d) + assertSameDocSet(t, q, searchDocSet(s, tbl, q), searchDocSet(ref, refTbl, q)) + } +} + +// TestMergeOffWorker_WaitMergeIdleWaitsForInstall is the Step 5 convergence guard: with a deliberately +// SLOW install (a beforeManifestFsync delay armed for exactly the merge install), waitMergeIdle must +// return ONLY after the install lands. mergeAckSeq is stored in runScheduledMerge AFTER the last +// runMergePlan returns, and runMergePlan awaits its install RunFunc — so a waitMergeIdle that sampled +// the merge's reqSeq cannot observe ackSeq catch up until the (slow) install has completed. We pin the +// arming window deterministically by PARKING the off-worker compute (which runs strictly BEFORE the +// install): while parked, the spills' manifest writes are already done, so arming the slow hook now +// targets only the upcoming install. After unpark, we assert waitMergeIdle blocks across the slow +// install and the merged output is live by the time it returns. +func TestMergeOffWorker_WaitMergeIdleWaitsForInstall(t *testing.T) { + q := queue.NewMpsc("offworker-idle") + q.Start() + s, err := Open(t.TempDir(), q, Options{AutoMerge: true, Fanout: 2, CapBytes: 1 << 12}) + if err != nil { + t.Fatal(err) + } + tbl, _ := s.CreateTable("files") + + t.Cleanup(func() { s.CloseAndWait() }) + entered, _, unpark := parkAt(t) + t.Cleanup(unpark) + t.Cleanup(func() { beforeManifestFsync = nil }) + + // Seal exactly Fanout L0 segments => one tiered merge fires; its compute parks at the hook. Drive + // the PUBLIC Update path so liveByTable is maintained (deadFraction ~0 => a TIERED pass, not the + // bogus-deadFraction covering merge the bare test seam would force). + s.Update(tbl, 1, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 2, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + waitEntered(t, entered) + + if len(s.SegmentsForTest()) != 2 { + t.Fatalf("expected 2 L0 inputs before the merge installs, got %d", len(s.SegmentsForTest())) + } + + // Arm the slow install NOW (the spills' manifest writes already completed before the compute parked, + // so the next manifest write is the merge install). installDone is closed by the hook AFTER its + // delay, so we can prove waitMergeIdle did not return before the (slow) install ran. + const installDelay = 250 * time.Millisecond + var installStartedAt atomic.Int64 + installDone := make(chan struct{}) + beforeManifestFsync = func() { + installStartedAt.CompareAndSwap(0, time.Now().UnixNano()) + time.Sleep(installDelay) + select { + case <-installDone: + default: + close(installDone) + } + } + + // Unpark: the compute finishes, then the (slow) install runs on the worker, then mergeAckSeq is + // stored. A reader thread calls waitMergeIdle concurrently; it must NOT return until the install has + // completed. + unpark() + + idleReturned := make(chan time.Time, 1) + go func() { + s.waitMergeIdle() + idleReturned <- time.Now() + }() + + // waitMergeIdle must still be blocked while the slow install is in flight. Give the install time to + // start but not finish: assert idle has NOT returned before the install delay elapses. + select { + case <-installDone: + case <-time.After(5 * time.Second): + t.Fatal("slow install never ran (merge install's manifest write was not reached)") + } + installFinishedAt := time.Now() + + var idleAt time.Time + select { + case idleAt = <-idleReturned: + case <-time.After(5 * time.Second): + t.Fatal("waitMergeIdle never returned after the slow install completed") + } + + // Convergence: waitMergeIdle returned only AFTER the install's manifest write finished — proving + // mergeAckSeq is stored after the install RunFunc, not before it. + if idleAt.Before(installFinishedAt) { + t.Fatalf("waitMergeIdle returned at %v, BEFORE the slow install finished at %v — ackSeq stored before install", + idleAt, installFinishedAt) + } + if installStartedAt.Load() == 0 { + t.Fatal("the install's manifest write never started — the merge did not install") + } + + // And the merged output is live by the time waitMergeIdle returned: the 2 L0 inputs collapsed. + if n := len(s.SegmentsForTest()); n >= 2 { + t.Fatalf("expected the tiered merge to collapse the 2 L0 inputs once installed, got %d segments", n) + } + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) { + t.Fatalf("after the slow install, alpha must contain both docs 1 and 2: %v", r.DocIds) + } +} + +// TestMergeOffWorker_InputsRefHeldAcrossCompute is the Step 6 lifecycle guard. While the off-worker +// mergeSegments is PARKED, each input segment must be ref-held with refs.Load() >= 2 — the published +// snapshot ref (1, set at seal) plus the plan's incref (segsByIdsLocked bumps it to 2) — so a +// concurrent retire cannot free an input mid-read across the off-worker compute. After unpark + drain, +// the merged-away inputs must be torn down (files removed) and a REOPENED MANIFEST must list only the +// merged output. A regression that dropped the plan incref early (re-opening the load-then-retire race +// the plan closes) would leave an input at refs==1 while parked and fail this directly — the present +// off-worker-ness test would not catch it. +func TestMergeOffWorker_InputsRefHeldAcrossCompute(t *testing.T) { + dir := t.TempDir() + s := openAt(t, dir, Options{AutoMerge: true, Fanout: 3, CapBytes: 1 << 12}) + tbl, _ := s.CreateTable("files") + + entered, _, unpark := parkAt(t) + t.Cleanup(unpark) // clear the hook on any early t.Fatal so a later drain does not re-park + + // Seal exactly Fanout L0 segments => one tiered merge fires; its compute parks at the hook with the + // plan's input refs already taken (selectTieredMergePlan ran on the worker before mergeSegments). + // Drive the PUBLIC Update path (not applyForTest) so liveByTable is maintained and deadFraction + // stays ~0 — otherwise the bogus deadFraction=1.0 of the bare test seam would fire a covering merge + // instead of the tiered pass this test pins. + const fanout = 3 + for d := int64(1); d <= fanout; d++ { + s.Update(tbl, d, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + } + waitEntered(t, entered) + + // Capture the input ids while parked. All Fanout L0 segments are the tiered inputs. + inputs := s.SegmentsForTest() + if len(inputs) != fanout { + t.Fatalf("expected %d L0 inputs while the compute is parked, got %d", fanout, len(inputs)) + } + inputIds := make([]uint64, 0, len(inputs)) + for _, sm := range inputs { + inputIds = append(inputIds, sm.Id) + } + + // REF-HELD ACROSS COMPUTE: each input is held at refs >= 2 (published snapshot + plan incref) while + // the off-worker compute is parked, so a concurrent retire cannot free it mid-read. + for _, id := range inputIds { + if got := s.segRefsByIdForTest(id); got < 2 { + t.Fatalf("input segment %d held at refs=%d while the off-worker compute is parked, want >= 2 "+ + "(published snapshot + plan incref) — the plan must keep the input pinned across the compute", id, got) + } + } + + // Unpark + drain: the compute finishes, installs on the worker, and releases the plan refs. The + // merged-away inputs then drop to zero and are torn down (close + unlink). + unpark() + s.sync() + s.waitMergeIdle() + + // Post-release teardown: every merged-away input file is removed, and the live set is the single + // merged output (the inputs collapsed). + live := s.SegmentsForTest() + if len(live) != 1 { + t.Fatalf("expected the tiered merge to collapse %d inputs into 1 output, got %d live segments", fanout, len(live)) + } + outId := live[0].Id + for _, id := range inputIds { + if id == outId { + continue + } + if s.segFileExists(id) { + t.Fatalf("merged-away input segment %d file still exists after install + release (not torn down)", id) + } + if got := s.segRefsByIdForTest(id); got != -1 { + t.Fatalf("merged-away input segment %d still has a live handle (refs=%d) after release", id, got) + } + } + + // A REOPENED MANIFEST lists ONLY the merged output — the inputs were removed durably, not just + // dropped from the in-memory set. + s.CloseAndWait() + s2 := openAt(t, dir, Options{AutoMerge: false}) + reSegs := s2.SegmentsForTest() + if len(reSegs) != 1 { + t.Fatalf("reopened MANIFEST lists %d segments, want only the merged output", len(reSegs)) + } + if reSegs[0].Id != outId { + t.Fatalf("reopened MANIFEST names segment %d, want the merged output %d", reSegs[0].Id, outId) + } + if r := s2.Search(tbl, "alpha", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) || !hasDoc(r, 3) { + t.Fatalf("after reopen, alpha must contain docs 1,2,3 from the merged output: %v", r.DocIds) + } + s2.CloseAndWait() +} + +// segHasTableKeys reports whether the live single segment holds any [I] record for tableId — used to +// observe the liveTables-staleness window: a dead table's keys are over-retained for ONE covering +// pass that snapshotted the table as live, then reclaimed by the follow-up pass. +func segHasTableKeys(t *testing.T, s *Store, tableId int) bool { + t.Helper() + segs := s.acquireSnapshot() + defer s.releaseSnapshot(segs) + for _, seg := range segs { + if len(segInvRecords(seg, tableId)) > 0 { + return true + } + } + return false +} + +// TestMergeOffWorker_DeleteTableRacesCoveringCompute is the Step 6 liveTables-staleness guard. +// selectCoveringMergePlan snapshots liveTables under the lock, but the off-worker compute + install +// run later; a DeleteTable in that window changes the catalog AFTER selection. This must be benign: +// the racing covering pass over-retains the now-deleted table's keys for ONE pass (it merged with the +// stale liveTables that still listed the table), Search/GetDocs for the deleted table read empty +// immediately regardless, and the follow-up covering pass the DeleteTable scheduled reclaims the dead +// table's bytes. The surviving table's data is correct throughout. +func TestMergeOffWorker_DeleteTableRacesCoveringCompute(t *testing.T) { + q := queue.NewMpsc("offworker-staletables") + q.Start() + // High Fanout so NO tiered pass qualifies — the only mergeSegments is the covering one we park, so + // the parked compute is deterministically the covering pass that snapshotted liveTables = {A, B}. + s, err := Open(t.TempDir(), q, Options{AutoMerge: true, Fanout: 100, CapBytes: 1 << 12}) + if err != nil { + t.Fatal(err) + } + tblA, _ := s.CreateTable("A") + tblB, _ := s.CreateTable("B") + + t.Cleanup(func() { s.CloseAndWait() }) + entered, _, unpark := parkAt(t) + t.Cleanup(unpark) + + // Two sealed segments holding both tables' data (covering needs the bottom + everything above). + s.applyForTest(tblA, 1, []string{"alpha"}) + s.applyForTest(tblB, 1, []string{"beta"}) + s.spillForTest(tblA) + s.spillForTest(tblB) + s.applyForTest(tblA, 2, []string{"alpha"}) + s.applyForTest(tblB, 2, []string{"beta"}) + s.spillForTest(tblA) + s.spillForTest(tblB) + + // Force a covering pass; its compute parks AFTER selectCoveringMergePlan snapshotted liveTables = + // {A, B} (both still in the catalog at selection time). + s.triggerMerge(true) + waitEntered(t, entered) + + // RACE: delete table B while the covering compute is parked — the catalog changes AFTER the plan's + // liveTables snapshot. DeleteTable runs on the (free) worker and schedules a follow-up covering pass. + if err := s.DeleteTable(tblB); err != nil { + t.Fatalf("DeleteTable raced with the parked covering compute: %v", err) + } + // B reads empty IMMEDIATELY after delete, regardless of whether its bytes are reclaimed yet + // (Search/GetDocs gate on the catalog). + if r := s.Search(tblB, "beta", 0, nil); len(r.DocIds) != 0 { + t.Fatalf("deleted table B must read empty immediately, got %v", r.DocIds) + } + + // Unpark: the parked covering pass installs with the STALE liveTables (B still listed live) — B's + // keys are over-retained for this one pass (the benign staleness window). Then the follow-up pass + // (scheduled by DeleteTable) runs with a FRESH liveTables = {A} and reclaims B. + unpark() + s.sync() + s.waitMergeIdle() + + // CORRECTNESS: the surviving table A is intact and complete after the racing merges. + rA := s.Search(tblA, "alpha", 0, nil) + if !hasDoc(rA, 1) || !hasDoc(rA, 2) { + t.Fatalf("surviving table A must keep docs 1,2 across the DeleteTable-racing covering merge: %v", rA.DocIds) + } + // B is gone from the catalog, so it reads empty. + if r := s.Search(tblB, "beta", 0, nil); len(r.DocIds) != 0 { + t.Fatalf("deleted table B must read empty after the merges settle, got %v", r.DocIds) + } + // RECLAIM: the follow-up covering pass dropped B's [I] keys from the live segment — the dead table's + // bytes are reclaimed even though the racing pass snapshotted B as live. + if segHasTableKeys(t, s, tblB) { + t.Fatal("deleted table B's keys were not reclaimed by the follow-up covering pass (stale liveTables not cleaned)") + } + // And A's keys are still present (not collaterally reclaimed). + if !segHasTableKeys(t, s, tblA) { + t.Fatal("surviving table A's keys were dropped by the covering reclaim (over-reclaim bug)") + } +} + +// TestMergeOffWorker_PersistentInstallFailureDoesNotSpin guards the error-path regression the +// off-worker tiered loop introduced: when installMerge fails persistently (here the MANIFEST.tmp path +// is a directory so writeManifestBytes can never create the temp file), installMerge rolls the live +// set back to the pre-merge inputs — so the SAME level stays >= Fanout. If runMergePlan swallowed the +// install error and the loop only broke on plan==nil, runScheduledMerge would re-select the same +// level and re-run the heavy mergeSegments compute forever (a hot CPU/IO livelock), AND +// runScheduledMerge would never reach mergeAckSeq.Store, so waitMergeIdle would hang. The fix makes +// runMergePlan RETURN the install error and the tiered loop break on it (drop the pass; the next +// trigger retries). This test bounds the mergeSegments invocation count per pass and proves a later +// trigger (after the failure is cleared) succeeds. +func TestMergeOffWorker_PersistentInstallFailureDoesNotSpin(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("offworker-spin") + q.Start() + s, err := Open(dir, q, Options{AutoMerge: true, Fanout: 2, CapBytes: 1 << 12}) + if err != nil { + t.Fatal(err) + } + tbl, _ := s.CreateTable("files") + t.Cleanup(func() { s.CloseAndWait() }) + + // Count mergeSegments invocations AND park the FIRST one. mergeComputeBlock fires at the START of + // each mergeSegments: every call bumps computeCount (a spinning tiered loop would re-run it + // unboundedly; the fix runs it once per pass then breaks on the failed install), and the FIRST call + // blocks on `release` after signalling `entered`. Parking the first compute is what makes the + // failure injection deterministic: it lets the two spills finish their OWN manifest writes (so two + // L0 segments go live and the tiered merge actually has inputs) and pins the merge at the compute, + // BEFORE any install runs — so when we then make MANIFEST.tmp a directory it fails ONLY the + // install(s), never the spills. (Blocking MANIFEST.tmp before the spills would fail the spills too, + // publishing no segments, so no merge would ever fire — the compute would never run.) + // + // unpark only RELEASES the parked compute; the hook stays installed as a pure counter so the later + // retry's compute is still counted (the retry assertion reads computeCount). t.Cleanup clears it. + var computeCount atomic.Int64 + entered := make(chan struct{}, 1) + release := make(chan struct{}) + var parkOnce sync.Once + mergeComputeBlock = func() { + computeCount.Add(1) + parkOnce.Do(func() { + entered <- struct{}{} + <-release + }) + } + t.Cleanup(func() { mergeComputeBlock = nil }) + var releaseOnce sync.Once + unpark := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unpark) // release any still-parked compute on an early t.Fatal so Close's drain proceeds + + // Seal Fanout L0 segments => a tiered merge fires; its compute parks at the hook (the two spills' + // own manifest writes have already completed). Drive the PUBLIC Update path so liveByTable is + // maintained (deadFraction ~0 => the TIERED loop under test, not the one-shot covering merge the + // bare test seam's bogus deadFraction=1.0 would fire instead). + for d := int64(1); d <= 2; d++ { + s.Update(tbl, d, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + } + waitEntered(t, entered) // the two spills are durable; the merge is parked at the compute, pre-install + + // NOW make MANIFEST.tmp un-creatable as a file (it is a directory) so EVERY installMerge + // writeManifest fails — a persistent install failure that hits ONLY the install, not the (already + // done) spills. + tmpBlock := filepath.Join(dir, "MANIFEST.tmp") + if err := os.Mkdir(tmpBlock, 0o755); err != nil { + t.Fatal(err) + } + + // Unpark: the compute finishes, the install fails persistently. With the fix the pass runs + // mergeSegments once, the install fails, the loop breaks, and runScheduledMerge reaches + // mergeAckSeq.Store — so waitMergeIdle CONVERGES (a spin would hang it forever). + unpark() + + idleDone := make(chan struct{}) + go func() { s.waitMergeIdle(); close(idleDone) }() + select { + case <-idleDone: + case <-time.After(5 * time.Second): + t.Fatal("waitMergeIdle never converged under a persistent install failure — the tiered loop is spinning " + + "(runScheduledMerge never reached mergeAckSeq.Store)") + } + + // The compute ran a BOUNDED number of times (the fix: ~one mergeSegments per qualifying pass, then + // break on the failed install). A spin would have run it hundreds/thousands of times in the window. + // Allow a small margin for an extra coalesced trigger, but assert it is nowhere near a spin. + if n := computeCount.Load(); n == 0 { + t.Fatal("expected the tiered merge to run mergeSegments at least once") + } else if n > 8 { + t.Fatalf("mergeSegments ran %d times under a persistent install failure — the tiered loop is spinning "+ + "(re-selecting the rolled-back level and recomputing the merge)", n) + } + + // The inputs are still live (install rolled back), so the store is fully usable. + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) { + t.Fatalf("data must survive the failed installs: alpha = %v", r.DocIds) + } + if n := len(s.SegmentsForTest()); n != 2 { + t.Fatalf("after the failed install the 2 inputs must still be live (rolled back), got %d segments", n) + } + + // Clear the failure; a LATER trigger must succeed (the pass was dropped, not poisoned). + if err := os.Remove(tmpBlock); err != nil { + t.Fatal(err) + } + before := computeCount.Load() + s.triggerMerge(false) // re-trigger the (now-installable) tiered merge + s.waitMergeIdle() + if computeCount.Load() <= before { + t.Fatal("the retry trigger did not run a fresh merge compute") + } + if n := len(s.SegmentsForTest()); n != 1 { + t.Fatalf("after the failure is cleared and a retry trigger, the 2 inputs must merge to 1, got %d segments", n) + } + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) { + t.Fatalf("after the successful retry, alpha must contain both docs: %v", r.DocIds) + } +} diff --git a/core/invertedstore/merge_robustness_test.go b/core/invertedstore/merge_robustness_test.go new file mode 100644 index 0000000..b5a2383 --- /dev/null +++ b/core/invertedstore/merge_robustness_test.go @@ -0,0 +1,261 @@ +package invertedstore + +import ( + "math/rand" + "os" + "path/filepath" + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +// merge_robustness_test.go — P8 (design §6 merger; task T6) robustness regressions. +// +// These cover the three background-merger hazards the cross-review surfaced, as behavior-named +// tests (not investigation scratch): +// +// - a covering merge must NOT crash the worker goroutine when a (corrupt/legacy) surviving forward +// references a keyword the merge reclaimed; it self-heals to a valid segment; +// - a covering merge over a public-path edit churn round-trips every live doc's forward (the strong +// public-path fuzz that proves the reclaim path is sound end-to-end); +// - installMerge must keep s.man / s.segs / the on-disk MANIFEST consistent when the MANIFEST write +// fails mid-install (no corruption of the live segment set, the inputs stay live). + +// --- the sentinel self-heal: a stale forward must not brick the worker -------- + +// TestCoveringMerge_StaleForwardSelfHealsNoPanic builds a deliberately INCONSISTENT input the public +// write path cannot produce (a corrupt/legacy segment): doc 1's forward says {alpha} (seg0) while a +// later segment fully tombstones the alpha posting WITHOUT clearing the forward (seg1). A covering +// merge reclaims the fully-tombstoned 'alpha' keyword, so doc 1's forward now references a dropped +// keyword ordinal. The merge runs on the un-recovered mpsc worker, so it MUST NOT panic (a worker +// panic bricks the whole process): it self-heals by dropping the stale term, producing a valid +// reopenable segment, and reports the heal via the test observer. +func TestCoveringMerge_StaleForwardSelfHealsNoPanic(t *testing.T) { + s, tbl := newMergeStore(t, 100) // high Fanout so only the explicit covering merge fires + defer s.CloseAndWait() + + // seg0: doc 1 live with forward {alpha} + alpha ADD 1 (the real apply path writes both). + s.applyForTest(tbl, 1, []string{"alpha"}) + s.forceSpill(tbl) + // seg1: tombstone the alpha POSTING for doc 1 but leave the forward stale (head-level helper that + // does NOT touch the forward map) — this is the inverted/forward divergence a corrupt segment has. + s.tombstoneForTest(tbl, "alpha", 1) + s.forceSpill(tbl) + + if len(s.segs) != 2 { + t.Fatalf("expected 2 segments before covering merge, got %d", len(s.segs)) + } + + var heals int + mergeDroppedForwardTermObserver = func() { heals++ } + defer func() { mergeDroppedForwardTermObserver = nil }() + + // MUST NOT panic on the worker. coveringMergeForTest fails the test (does not crash) if it errors. + s.coveringMergeForTest(t) + + if len(s.segs) != 1 { + t.Fatalf("covering merge must compact to 1 segment, got %d", len(s.segs)) + } + if heals == 0 { + t.Fatal("expected the covering merge to self-heal the stale forward term (observer never fired)") + } + // alpha is fully tombstoned -> reclaimed, and doc 1's forward (its only keyword reclaimed) drops to + // a clean miss. The segment is valid and reopenable: search and forward both read empty, no panic. + if r := s.Search(tbl, "alpha", 0, nil); len(r.DocIds) != 0 { + t.Errorf("alpha must have no live postings after the covering merge, got %v", r.DocIds) + } + got, deleted := s.forwardKeywords(tbl, 1) + if deleted || len(got) != 0 { + t.Errorf("doc 1's stale forward must self-heal to empty (a miss), got words=%v deleted=%v", got, deleted) + } +} + +// --- public-path covering fuzz: every live doc's forward round-trips ---------- + +// TestCoveringMerge_PublicFuzzForwardRoundTrips drives a randomized add/re-add/delete churn through +// the PUBLIC Update path only (tiny CapBytes forcing many spills), then runs a covering merge and +// asserts every doc's surviving keyword set round-trips through the rebuilt, remapped term dict and +// matches an independently-tracked ground truth — and that the public path never trips the +// sentinel self-heal (the inverted del and the forward re-post stay co-located for every docid). +func TestCoveringMerge_PublicFuzzForwardRoundTrips(t *testing.T) { + const vocab = 24 + kw := func(i int) string { return kwf("w", i) } + + for seed := int64(0); seed < 40; seed++ { + rng := rand.New(rand.NewSource(seed)) + dir := t.TempDir() + q := queue.NewMpsc("invcovfuzz") + q.Start() + // Tiny CapBytes so the public Update path spills frequently mid-run (many segments to merge). + s, err := Open(dir, q, Options{CapBytes: 256, Fanout: 1000}) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + truth := map[int64][]string{} // docid -> current live keyword set ("" deleted => absent) + var heals int + mergeDroppedForwardTermObserver = func() { heals++ } + + for op := 0; op < 400; op++ { + d := int64(rng.Intn(12) + 1) + if rng.Intn(5) == 0 { + // delete + s.Update(tbl, d, nil) + delete(truth, d) + continue + } + // pick a random non-empty keyword subset + n := rng.Intn(4) + 1 + set := map[string]struct{}{} + for k := 0; k < n; k++ { + set[kw(rng.Intn(vocab))] = struct{}{} + } + words := make([]string, 0, len(set)) + for w := range set { + words = append(words, w) + } + s.Update(tbl, d, words) + truth[d] = words + } + s.sync() + s.coveringMergeForTest(t) + + mergeDroppedForwardTermObserver = nil + if heals != 0 { + t.Fatalf("seed %d: public Update path must never need a sentinel self-heal, got %d", seed, heals) + } + if len(s.segs) != 1 { + t.Fatalf("seed %d: covering merge must compact to 1 segment, got %d", seed, len(s.segs)) + } + + // Every live doc's forward round-trips to its exact keyword set; every deleted doc reads empty. + for d := int64(1); d <= 12; d++ { + got, deleted := s.forwardKeywords(tbl, d) + want, live := truth[d] + if !live { + if deleted == false && len(got) == 0 { + continue // a clean miss is the expected post-covering state for a deleted doc + } + if len(got) != 0 { + t.Fatalf("seed %d: deleted doc %d must read empty, got %v", seed, d, got) + } + continue + } + if deleted { + t.Fatalf("seed %d: live doc %d unexpectedly deleted after covering merge", seed, d) + } + if !sameSet(got, want) { + t.Fatalf("seed %d: doc %d forward after covering merge = %v, want %v", seed, d, got, want) + } + // The inverted side must agree: every live keyword still lists the doc. + for _, w := range want { + if r := s.Search(tbl, w, 0, nil); !hasDoc(r, d) { + t.Fatalf("seed %d: doc %d missing from keyword %q after covering merge, got %v", seed, d, w, r.DocIds) + } + } + } + s.CloseAndWait() + } +} + +// --- installMerge rollback: a MANIFEST-write failure must not corrupt state --- + +// TestInstallMerge_ManifestWriteFailureRollsBack forces the MANIFEST write inside installMerge to +// fail (by pre-creating a DIRECTORY named MANIFEST.tmp so os.Create cannot make the temp file) AFTER +// the merged output segment is written. installMerge must roll back: s.man must still name exactly +// the live INPUT segments (not the deleted output), s.segs must still hold them, the orphan output +// file is removed, and the store stays usable + reopenable. This guards the persist-then-publish +// invariant (never mutate the live segment set before the durable write succeeds). +func TestInstallMerge_ManifestWriteFailureRollsBack(t *testing.T) { + s, tbl := newMergeStore(t, 100) // high Fanout: drive the merge explicitly + defer s.CloseAndWait() + + s.Update(tbl, 1, []string{"apple", "mango"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 2, []string{"banana", "mango"}) + s.sync() + s.forceSpill(tbl) + + if len(s.segs) != 2 { + t.Fatalf("expected 2 input segments, got %d", len(s.segs)) + } + // Snapshot the pre-merge segment-id set the MANIFEST must still name after the failed install. + s.mu.RLock() + wantIds := map[uint64]bool{} + for _, sm := range s.man.Segments { + wantIds[sm.Id] = true + } + nSegFiles := len(wantIds) + s.mu.RUnlock() + + // Make MANIFEST.tmp un-creatable as a file (it is an existing directory) so writeManifest fails + // AFTER mergeSegments has already written + fsync'd the output segment. + tmpDirBlock := filepath.Join(s.dir, "MANIFEST.tmp") + if err := os.Mkdir(tmpDirBlock, 0o755); err != nil { + t.Fatal(err) + } + + // Run the covering merge on the worker; installMerge's writeManifest must fail and return an error + // (NOT crash, NOT corrupt state). coveringMerge returns that error. + err := s.q.RunFunc(func() error { return s.coveringMerge() }) + if err == nil { + t.Fatal("expected coveringMerge to return the MANIFEST-write error") + } + // Unblock MANIFEST.tmp again so later spills/close can write. + if err := os.Remove(tmpDirBlock); err != nil { + t.Fatal(err) + } + + // The in-memory manifest must still name EXACTLY the pre-merge inputs (rolled back), and s.segs + // must still hold their handles — no divergence, no dangling reference to the deleted output. + s.mu.RLock() + gotIds := map[uint64]bool{} + for _, sm := range s.man.Segments { + gotIds[sm.Id] = true + } + segHandles := len(s.segs) + s.mu.RUnlock() + if len(gotIds) != len(wantIds) { + t.Fatalf("after failed install, manifest names %d segments, want the %d inputs", len(gotIds), len(wantIds)) + } + for id := range wantIds { + if !gotIds[id] { + t.Fatalf("after failed install, manifest dropped live input segment %d (gotIds=%v)", id, gotIds) + } + } + if segHandles != len(wantIds) { + t.Fatalf("after failed install, s.segs holds %d handles, want the %d inputs", segHandles, len(wantIds)) + } + // The orphan output segment file must be gone (installMerge removes it on the rollback path). + dents, _ := os.ReadDir(s.dir) + segCount := 0 + for _, de := range dents { + if de.IsDir() { + continue + } + name := de.Name() + if len(name) >= 4 && name[:4] == "seg-" { + segCount++ + } + } + if segCount != nSegFiles { + t.Fatalf("after failed install, found %d seg-*.dat files, want %d (orphan output not removed)", segCount, nSegFiles) + } + + // The store stays usable: data is intact and a covering merge now succeeds. + if r := s.Search(tbl, "mango", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) { + t.Errorf("data must survive the failed install: mango = %v", r.DocIds) + } + s.coveringMergeForTest(t) // now succeeds + if len(s.segs) != 1 { + t.Fatalf("after a successful retry the merge must compact to 1 segment, got %d", len(s.segs)) + } + if r := s.Search(tbl, "mango", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) { + t.Errorf("data must survive the successful retry: mango = %v", r.DocIds) + } +} diff --git a/core/invertedstore/merge_test.go b/core/invertedstore/merge_test.go new file mode 100644 index 0000000..248262c --- /dev/null +++ b/core/invertedstore/merge_test.go @@ -0,0 +1,629 @@ +package invertedstore + +import ( + "strconv" + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +// merge_test.go — P8 (design §6 merger + §8 remap; task T6) acceptance tests. +// +// These are the correctness cases the spike's narrow (globally-unique-word) edit workload never +// exercised: per-(keyword,docid) newest-wins reconciliation (add->del->add), forward-tombstone +// survival across a merge, forward round-trip after the ord->ord remap, covering-merge reclamation, +// and the bounded (int-array, not string-map) merge memory. + +// --- test seams: drive the real worker-side merge synchronously -------------- + +// newMergeStore opens a store with an explicit Fanout (so a test can force a tiered merge with a +// known number of segments) and AutoMerge OFF (the test drives merges itself, deterministically). +func newMergeStore(t *testing.T, fanout int) (*Store, int) { + t.Helper() + dir := t.TempDir() + q := queue.NewMpsc("invmerge") + q.Start() + s, err := Open(dir, q, Options{Fanout: fanout}) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + return s, tbl +} + +// mergeOneLevelForTest runs one tiered merge pass on the worker (synchronous). Returns whether a +// level qualified and merged. +func (s *Store) mergeOneLevelForTest(t *testing.T) bool { + t.Helper() + var merged bool + err := s.q.RunFunc(func() error { + var e error + merged, e = s.mergeOneLevel() + return e + }) + if err != nil { + t.Fatalf("mergeOneLevel: %v", err) + } + return merged +} + +// coveringMergeForTest runs a full covering merge on the worker (synchronous). +func (s *Store) coveringMergeForTest(t *testing.T) { + t.Helper() + if err := s.q.RunFunc(func() error { return s.coveringMerge() }); err != nil { + t.Fatalf("coveringMerge: %v", err) + } +} + +// segInvRecords reads every [I] record of segment seg for tableId tbl, returning per keyword the +// decoded adds and dels — used to assert post-merge segment contents (dels reclaimed, keys dropped). +func segInvRecords(seg *segment, tbl int) map[string]struct { + adds, dels []int64 +} { + out := map[string]struct { + adds, dels []int64 + }{} + lo := invertedKey(uint32(tbl), "") + hi := []byte{ktForward} + seg.scanPrefix(lo, hi, func(key, value []byte) { + if key[0] != ktInverted { + return + } + kw := string(key[5:]) + ab, db := splitInvertedValue(value) + var adds, dels []int64 + decodeDocs(ab, func(d int64) { adds = append(adds, d) }) + decodeDocs(db, func(d int64) { dels = append(dels, d) }) + out[kw] = struct { + adds, dels []int64 + }{adds, dels} + }) + return out +} + +// --- MUST-PASS 1: add -> del -> add, then a merge resolves PRESENT ----------- + +// TestMerge_AddDelAddResolvesPresent builds three L0 segments for one (keyword,docid): add, then +// del, then add. A tiered merge of all three must reconcile newest-wins (the final ADD survives), +// so Search finds the doc — the case the spike's concat-not-reconcile merge got WRONG. +func TestMerge_AddDelAddResolvesPresent(t *testing.T) { + s, tbl := newMergeStore(t, 3) // Fanout 3 so three L0 segments trigger one tiered merge + defer s.CloseAndWait() + + s.addPostingForTest(tbl, "alpha", 10) + s.forceSpill(tbl) // seg0: alpha ADD 10 + s.tombstoneForTest(tbl, "alpha", 10) + s.forceSpill(tbl) // seg1: alpha DEL 10 + s.addPostingForTest(tbl, "alpha", 10) + s.forceSpill(tbl) // seg2: alpha ADD 10 (newest) + + if len(s.segs) != 3 { + t.Fatalf("expected 3 L0 segments before merge, got %d", len(s.segs)) + } + // Sanity: even BEFORE the merge, read-time newest-wins already says PRESENT. + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 10) { + t.Fatalf("pre-merge read-time newest-wins should already be PRESENT: %v", r.DocIds) + } + + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge to fire with 3 L0 segments at Fanout 3") + } + if len(s.segs) != 1 { + t.Fatalf("after merging 3 L0 segments expected 1 segment, got %d", len(s.segs)) + } + + // The merged segment must resolve the (alpha,10) reconciliation to PRESENT. + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 10) { + t.Fatalf("add->del->add must resolve PRESENT after merge, got %v", r.DocIds) + } + // The single merged segment must carry alpha as a LIVE add (newest add wins over the older del). + recs := segInvRecords(s.segs[0], tbl) + rec, ok := recs["alpha"] + if !ok { + t.Fatal("merged segment must keep the alpha key") + } + if len(rec.adds) != 1 || rec.adds[0] != 10 { + t.Fatalf("merged alpha adds = %v, want [10]", rec.adds) + } + // The del was co-located with its newest add inside this merge, so it is reconciled away. + if len(rec.dels) != 0 { + t.Fatalf("merged alpha dels = %v, want [] (add->del->add reconciled to a live add)", rec.dels) + } +} + +// TestMerge_AddDelResolvesAbsent is the symmetric case: add then del (newest), merged, must resolve +// ABSENT (the del is the latest action). With the add co-located, the merged inverted record is +// empty (no live add) so Search must not return the doc. +func TestMerge_AddDelResolvesAbsent(t *testing.T) { + s, tbl := newMergeStore(t, 2) + defer s.CloseAndWait() + + s.addPostingForTest(tbl, "alpha", 10) + s.forceSpill(tbl) // seg0: alpha ADD 10 + s.tombstoneForTest(tbl, "alpha", 10) + s.forceSpill(tbl) // seg1: alpha DEL 10 (newest) + + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge to fire") + } + if r := s.Search(tbl, "alpha", 0, nil); hasDoc(r, 10) { + t.Fatalf("add->del must resolve ABSENT after merge, got %v", r.DocIds) + } +} + +// --- MUST-PASS 2: a forward-tombstone survives a merge spanning the delete + an older record ---- + +// TestMerge_ForwardTombstoneSurvives builds seg0 with doc 10 live (forward {alpha}) and seg1 with +// doc 10 DELETED (forward-tombstone nKw=0 + alpha per-keyword tombstone). A tiered merge spanning +// both must carry the forward-tombstone through (newest wins) so the doc still reads EMPTY — the +// older non-empty forward must NOT win and resurrect the doc. +func TestMerge_ForwardTombstoneSurvives(t *testing.T) { + s, tbl := newMergeStore(t, 2) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) // seg0: doc 10 live, forward {alpha}, alpha ADD 10 + s.Update(tbl, 10, nil) // DELETE + s.sync() + s.forceSpill(tbl) // seg1: forward-tombstone + alpha DEL 10 (newest) + + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge to fire") + } + + // Forward of the deleted doc must still report deleted/empty after the merge. + words, deleted := s.forwardKeywords(tbl, 10) + if !deleted || len(words) != 0 { + t.Fatalf("forward-tombstone must survive the merge: words=%v deleted=%v", words, deleted) + } + // And the doc must be absent from Search. + if r := s.Search(tbl, "alpha", 0, nil); hasDoc(r, 10) { + t.Fatalf("deleted doc 10 must stay absent after the merge, got %v", r.DocIds) + } +} + +// --- MUST-PASS 3: forward round-trip correct after merge (ord->ord remap) ---- + +// TestMerge_ForwardRoundTripAfterMerge builds several live docs whose keyword sets span two +// segments with DIFFERENT per-segment ordinals (so the remap is exercised: the same keyword has a +// different ordinal in each source). After a tiered merge, every doc's forward must resolve to its +// exact keyword set through the rebuilt term-dict region. +func TestMerge_ForwardRoundTripAfterMerge(t *testing.T) { + s, tbl := newMergeStore(t, 2) + defer s.CloseAndWait() + + // Two cold-build batches sealed into two segments, with overlapping AND distinct keywords so the + // ordinals differ between segments. doc->keywords ground truth. + truth := map[int64][]string{ + 1: {"apple", "mango"}, + 2: {"banana"}, + 3: {"apple", "banana", "cherry"}, + 4: {"date", "mango"}, + } + s.Update(tbl, 1, truth[1]) + s.Update(tbl, 2, truth[2]) + s.sync() + s.forceSpill(tbl) // seg0: docs 1,2 + s.Update(tbl, 3, truth[3]) + s.Update(tbl, 4, truth[4]) + s.sync() + s.forceSpill(tbl) // seg1: docs 3,4 + + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge to fire") + } + if len(s.segs) != 1 { + t.Fatalf("expected 1 merged segment, got %d", len(s.segs)) + } + + for d, want := range truth { + got, deleted := s.forwardKeywords(tbl, d) + if deleted { + t.Fatalf("doc %d unexpectedly deleted after merge", d) + } + if !sameSet(got, want) { + t.Fatalf("doc %d forward after merge = %v, want %v (remap broken)", d, got, want) + } + } + // And the inverted side stays searchable for every keyword. + for _, kw := range []string{"apple", "banana", "cherry", "date", "mango"} { + if r := s.Search(tbl, kw, 0, nil); len(r.DocIds) == 0 { + t.Errorf("keyword %q lost all postings after merge", kw) + } + } +} + +// --- MUST-PASS 4: covering merge bounds tombstone / duplicate growth --------- + +// TestMerge_CoveringReclaimsTombstonesAndDuplicates builds a long edit run for ONE doc that churns a +// keyword (add, remove, re-add ...) across many segments plus a doc that ends DELETED, so the bottom +// carries dangling tombstones, a fully-tombstoned key, and duplicate adds. A covering merge must +// reclaim them: the result has NO dels (dangling tombstones gone), NO fully-tombstoned key, and a +// deleted doc's forward-tombstone is dropped — while every LIVE result is preserved. +func TestMerge_CoveringReclaimsTombstonesAndDuplicates(t *testing.T) { + s, tbl := newMergeStore(t, 100) // high Fanout so only the EXPLICIT covering merge fires + defer s.CloseAndWait() + + // doc 10: alpha churned add/remove/add across 4 segments; ends with alpha LIVE + beta LIVE. + s.Update(tbl, 10, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 10, []string{"beta"}) // drop alpha (alpha tombstone), add beta + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 10, []string{"alpha", "beta"}) // re-add alpha + s.sync() + s.forceSpill(tbl) + + // doc 20: only ever had keyword "ghost", then DELETED -> ghost becomes fully tombstoned. + s.Update(tbl, 20, []string{"ghost"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 20, nil) // delete -> forward-tombstone + ghost tombstone + s.sync() + s.forceSpill(tbl) + + preSegs := len(s.segs) + if preSegs < 5 { + t.Fatalf("expected >=5 segments before the covering merge, got %d", preSegs) + } + + s.coveringMergeForTest(t) + + if len(s.segs) != 1 { + t.Fatalf("covering merge must compact to 1 segment, got %d", len(s.segs)) + } + merged := s.segs[0] + recs := segInvRecords(merged, tbl) + + // alpha + beta are LIVE for doc 10 and must survive with NO dels (dangling tombstones reclaimed). + for _, kw := range []string{"alpha", "beta"} { + rec, ok := recs[kw] + if !ok { + t.Fatalf("covering merge dropped live keyword %q", kw) + } + if len(rec.dels) != 0 { + t.Errorf("covering merge must reclaim ALL dels, %q kept dels=%v", kw, rec.dels) + } + found := false + for _, d := range rec.adds { + if d == 10 { + found = true + } + } + if !found { + t.Errorf("live keyword %q must still contain doc 10, adds=%v", kw, rec.adds) + } + } + // ghost is fully tombstoned -> the covering merge must DROP the key entirely. + if _, ok := recs["ghost"]; ok { + t.Errorf("covering merge must drop the fully-tombstoned key 'ghost', got %v", recs["ghost"]) + } + // The deleted doc 20 reads empty; its forward-tombstone need no longer exist (nothing to suppress). + if _, deleted := s.forwardKeywords(tbl, 20); deleted { + // deleted==true would mean a tombstone record is still present; covering merge drops it, so a + // plain miss (deleted==false, empty) is the expected post-covering state. + t.Errorf("covering merge should drop the forward-tombstone of doc 20 (a miss, not a tombstone)") + } + if r := s.Search(tbl, "ghost", 0, nil); len(r.DocIds) != 0 { + t.Errorf("ghost must have no live postings after the covering merge, got %v", r.DocIds) + } + // Live doc 10 still searchable + its forward round-trips. + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 10) { + t.Errorf("doc 10 must stay present under alpha after covering merge, got %v", r.DocIds) + } + got, _ := s.forwardKeywords(tbl, 10) + if !sameSet(got, []string{"alpha", "beta"}) { + t.Errorf("doc 10 forward after covering merge = %v, want {alpha,beta}", got) + } +} + +// TestMerge_CoveringDropsDeadTableKeys: a covering merge drops [I]/[F] keys for a tableId no longer +// in the catalog (DeleteTable scheduled it). After the merge the dead table's bytes are gone and a +// surviving table is untouched. +func TestMerge_CoveringDropsDeadTableKeys(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("invmergedt") + q.Start() + s, err := Open(dir, q, Options{Fanout: 100}) + if err != nil { + t.Fatal(err) + } + defer s.CloseAndWait() + keep, _ := s.CreateTable("keep") + drop, _ := s.CreateTable("drop") + + s.Update(keep, 1, []string{"alpha"}) + s.Update(drop, 2, []string{"beta"}) + s.sync() + s.forceSpill(keep) + s.forceSpill(drop) + + // Drop the table from the catalog (no AutoMerge, so no auto-scheduled covering merge). + if err := s.DeleteTable(drop); err != nil { + t.Fatal(err) + } + s.coveringMergeForTest(t) + + if len(s.segs) != 1 { + t.Fatalf("covering merge must compact to 1 segment, got %d", len(s.segs)) + } + // The dead table's keyword must be gone from the merged segment. + if recs := segInvRecords(s.segs[0], drop); len(recs) != 0 { + t.Errorf("covering merge must drop dead-table keys, found %v", recs) + } + // The surviving table is untouched. + if r := s.Search(keep, "alpha", 0, nil); !hasDoc(r, 1) { + t.Errorf("surviving table 'keep' must still contain doc 1 under alpha, got %v", r.DocIds) + } +} + +// --- MUST-PASS 5: merge memory ~= Sum source term counts (int arrays) -------- + +// TestMerge_RemapMemoryIsIntArrays asserts the merge's remap is per-source INT arrays whose total +// length equals the sum of the source segments' [I] (term) counts — NOT a string map. We instrument +// the merge with a test hook that reports the realized remap sizes, then compare to the independently +// counted source term totals. +func TestMerge_RemapMemoryIsIntArrays(t *testing.T) { + s, tbl := newMergeStore(t, 3) + defer s.CloseAndWait() + + // Three segments with overlapping + distinct keywords. + s.Update(tbl, 1, []string{"apple", "banana"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 2, []string{"banana", "cherry"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 3, []string{"apple", "date"}) + s.sync() + s.forceSpill(tbl) + + // Independently count each source segment's [I] term count (the spill order == oldest->newest). + wantPerSource := make([]int, 0, len(s.segs)) + wantTotal := 0 + for _, seg := range s.segs { + n := len(segInvRecords(seg, tbl)) + wantPerSource = append(wantPerSource, n) + wantTotal += n + } + + var gotPerSource []int + mergeRemapObserver = func(remap [][]uint32) { + gotPerSource = make([]int, len(remap)) + for i, r := range remap { + gotPerSource[i] = len(r) + } + } + defer func() { mergeRemapObserver = nil }() + + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge to fire") + } + + if len(gotPerSource) != len(wantPerSource) { + t.Fatalf("remap source count = %d, want %d", len(gotPerSource), len(wantPerSource)) + } + gotTotal := 0 + for i := range gotPerSource { + if gotPerSource[i] != wantPerSource[i] { + t.Errorf("source %d remap length = %d, want = its [I] term count %d", i, gotPerSource[i], wantPerSource[i]) + } + gotTotal += gotPerSource[i] + } + if gotTotal != wantTotal { + t.Fatalf("total remap entries = %d, want Sum(source term counts) = %d", gotTotal, wantTotal) + } +} + +// TestMerge_CoveringEmptyResultIsValid: a covering merge that drops EVERYTHING (the only table is +// deleted) must still produce a well-formed, reopenable segment (empty blocks + footer), not panic. +func TestMerge_CoveringEmptyResultIsValid(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("invmergeempty") + q.Start() + s, err := Open(dir, q, Options{Fanout: 100}) + if err != nil { + t.Fatal(err) + } + tbl, _ := s.CreateTable("files") + s.Update(tbl, 1, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 2, []string{"beta"}) + s.sync() + s.forceSpill(tbl) + + if err := s.DeleteTable(tbl); err != nil { + t.Fatal(err) + } + s.coveringMergeForTest(t) // drops both tables' keys -> empty merged segment + if len(s.segs) != 1 { + t.Fatalf("covering merge must still produce exactly one (empty) segment, got %d", len(s.segs)) + } + s.CloseAndWait() + + // Reopen: the empty merged segment parses cleanly. + s2 := openTestStore(t, dir) + defer s2.CloseAndWait() + if len(s2.segs) != 1 { + t.Fatalf("after reopen expected 1 segment, got %d", len(s2.segs)) + } +} + +// --- crash-safe MANIFEST swap: merged segment set survives a reopen ---------- + +// TestMerge_ManifestSwapSurvivesReopen merges, closes, and REOPENS the store: the merged segment +// must be the only live segment (inputs unlinked), and all data (search + forward) intact. This +// exercises the crash-safe MANIFEST swap + input deletion (a reopen reads only what the swapped +// MANIFEST names; orphan inputs are gone). +func TestMerge_ManifestSwapSurvivesReopen(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("invmergereopen") + q.Start() + s, err := Open(dir, q, Options{Fanout: 2}) + if err != nil { + t.Fatal(err) + } + tbl, _ := s.CreateTable("files") + + s.Update(tbl, 1, []string{"apple", "mango"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 2, []string{"banana", "mango"}) + s.sync() + s.forceSpill(tbl) + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge to fire") + } + if len(s.segs) != 1 { + t.Fatalf("expected 1 merged segment, got %d", len(s.segs)) + } + s.CloseAndWait() + + // Reopen: the MANIFEST names exactly the merged segment; the input files are gone. + s2 := openTestStore(t, dir) + defer s2.CloseAndWait() + if len(s2.segs) != 1 { + t.Fatalf("after reopen expected exactly the merged segment, got %d", len(s2.segs)) + } + if r := s2.Search(tbl, "mango", 0, nil); !hasDoc(r, 1) || !hasDoc(r, 2) { + t.Errorf("merged mango postings lost across reopen: %v", r.DocIds) + } + got, _ := s2.forwardKeywords(tbl, 1) + if !sameSet(got, []string{"apple", "mango"}) { + t.Errorf("doc 1 forward lost across reopen: %v", got) + } +} + +// --- AutoMerge: the background tiered merger fires automatically -------------- + +// TestMerge_AutoMergeBackgroundFires turns AutoMerge ON and spills Fanout segments; the worker must +// auto-enqueue the tiered merge so the live segment count drops below Fanout, with search intact. +func TestMerge_AutoMergeBackgroundFires(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("invautomerge") + q.Start() + s, err := Open(dir, q, Options{Fanout: 3, AutoMerge: true}) + if err != nil { + t.Fatal(err) + } + defer s.CloseAndWait() + tbl, _ := s.CreateTable("files") + + for d := int64(1); d <= 3; d++ { + s.Update(tbl, d, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + } + // The spill that reached Fanout raised a background merge trigger (P9: merges run on their own + // goroutine now, not as a re-enqueued worker task). waitMergeIdle blocks until the merger settles. + s.waitMergeIdle() + + if len(s.segs) >= 3 { + t.Fatalf("AutoMerge should have collapsed the 3 L0 segments below Fanout, got %d segments", len(s.segs)) + } + r := s.Search(tbl, "alpha", 0, nil) + for d := int64(1); d <= 3; d++ { + if !hasDoc(r, d) { + t.Errorf("doc %d lost after auto-merge, got %v", d, r.DocIds) + } + } +} + +// --- streaming merge over many keywords + block boundaries ------------------- + +// TestMerge_StreamingManyKeywordsAcrossBlocks merges two segments each holding many keywords (so the +// merge crosses multiple data blocks per source and the streaming cursor advances across block +// boundaries), with overlapping keywords (reconciled) and distinct ones. Every keyword's union must +// be preserved and every doc's forward must round-trip — a port of the spike's k-way merge over real +// block geometry, in production shape. +func TestMerge_StreamingManyKeywordsAcrossBlocks(t *testing.T) { + // Small blocks so a few hundred keywords span several blocks per segment. + s, tbl := newMergeStoreOpts(t, Options{Fanout: 2, BlockTarget: 256, DictChunkBytes: 128}) + defer s.CloseAndWait() + + // seg0: docs 1..50, each with 3 keywords drawn from a shared vocabulary (overlap across docs). + truth := map[int64][]string{} + for d := int64(1); d <= 50; d++ { + kws := []string{kwf("k", int(d%17)), kwf("k", int(d%23)), kwf("u0_", int(d))} + truth[d] = kws + s.Update(tbl, d, kws) + } + s.sync() + s.forceSpill(tbl) + // seg1: docs 51..100, overlapping the k* vocabulary so the merge reconciles shared keywords. + for d := int64(51); d <= 100; d++ { + kws := []string{kwf("k", int(d%17)), kwf("k", int(d%29)), kwf("u1_", int(d))} + truth[d] = kws + s.Update(tbl, d, kws) + } + s.sync() + s.forceSpill(tbl) + + if !s.mergeOneLevelForTest(t) { + t.Fatal("expected a tiered merge to fire") + } + if len(s.segs) != 1 { + t.Fatalf("expected 1 merged segment, got %d", len(s.segs)) + } + + // Every doc's forward round-trips through the rebuilt (remapped) term dict. + for d, want := range truth { + got, deleted := s.forwardKeywords(tbl, d) + if deleted { + t.Fatalf("doc %d unexpectedly deleted after streaming merge", d) + } + if !sameSet(got, want) { + t.Fatalf("doc %d forward after streaming merge = %v, want %v", d, got, want) + } + } + // A shared keyword unions docs from BOTH source segments (reconciled, not lost). + r := s.Search(tbl, kwf("k", 0), 0, nil) // k0 appears for docs where d%17==0 or d%23==0 or d%29==0 + if len(r.DocIds) == 0 { + t.Fatal("shared keyword k0 lost all postings after the streaming merge") + } +} + +// kwf builds a deterministic keyword "prefixN". +func kwf(prefix string, n int) string { + return prefix + strconv.Itoa(n) +} + +// newMergeStoreOpts is newMergeStore with explicit Options (block geometry, codecs, ...). +func newMergeStoreOpts(t *testing.T, opts Options) (*Store, int) { + t.Helper() + dir := t.TempDir() + q := queue.NewMpsc("invmergeopts") + q.Start() + s, err := Open(dir, q, opts) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + return s, tbl +} + +// sameSet reports whether two keyword slices hold the same SET of strings (order-independent). +func sameSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + m := make(map[string]struct{}, len(a)) + for _, x := range a { + m[x] = struct{}{} + } + for _, y := range b { + if _, ok := m[y]; !ok { + return false + } + } + return true +} diff --git a/core/invertedstore/orphan_reclaim_test.go b/core/invertedstore/orphan_reclaim_test.go new file mode 100644 index 0000000..3956105 --- /dev/null +++ b/core/invertedstore/orphan_reclaim_test.go @@ -0,0 +1,81 @@ +package invertedstore + +import "testing" + +// A crash in the DeleteTable window (catalog durably drops the table, but the volatile covering +// merge it scheduled never installs) leaves the dropped table's segments on disk. Open must: (i) NOT +// resurrect the table into liveByTable (catalog-gated recompute), and (ii) synchronously reclaim its +// bytes via a covering merge — independent of AutoMerge. Spec §6 / §8.5(c). +// +// Setup uses AutoMerge:false so DeleteTable leaves EXACTLY the crash-window on-disk state +// deterministically (its covering merge no-ops), with no goroutine to block or race. +func TestOrphanReclaim_DeleteTableWindowCrash(t *testing.T) { + dir := t.TempDir() + s := openAt(t, dir, Options{AutoMerge: false}) + a, err := s.CreateTable("A") + if err != nil { + t.Fatal(err) + } + b, err := s.CreateTable("B") + if err != nil { + t.Fatal(err) + } + s.Update(a, 1, []string{"a1", "a2"}) + s.Update(b, 1, []string{"b1", "b2", "b3"}) + s.sync() + s.spillForTest(a) + s.spillForTest(b) + if err := s.DeleteTable(b); err != nil { // catalog drops B; covering merge no-ops (AutoMerge off) + t.Fatal(err) + } + if !segmentCoversTable(s, b) { + t.Fatal("setup: B's orphan segment should still be present after DeleteTable (AutoMerge off)") + } + s.CloseAndWait() + + // Reopen with AutoMerge OFF — the orphan reclaim must STILL run (synchronous, not via the + // AutoMerge-gated triggerMerge). This is the round-3-BLOCKER guard. + s2 := openAt(t, dir, Options{AutoMerge: false}) + defer s2.CloseAndWait() // release the reopened store's segment fds (Windows RemoveAll) + if _, ok := s2.LiveByTableForTest()[b]; ok { + t.Fatalf("dropped table B resurrected into liveByTable: %v", s2.LiveByTableForTest()) + } + if segmentCoversTable(s2, b) { + t.Fatal("orphan B bytes not reclaimed on Open (synchronous covering merge did not run)") + } + // Table A survived intact. + if got := s2.LiveByTableForTest()[a]; got != 2 { + t.Fatalf("table A live=%d want 2 after orphan reclaim", got) + } + s2.assertCounterInvariantForTest(t) +} + +// A clean reopen (no orphan tables) must NOT run an orphan covering merge. +func TestOrphanReclaim_CleanReopenNoMerge(t *testing.T) { + dir := t.TempDir() + s := openAt(t, dir, Options{AutoMerge: false}) + tid, err := s.CreateTable("t") + if err != nil { + t.Fatal(err) + } + s.Update(tid, 1, []string{"a", "b"}) + s.sync() + s.spillForTest(tid) + s.CloseAndWait() + + n := installCoveringCounter(t) + s2 := openAt(t, dir, Options{AutoMerge: false}) + defer s2.CloseAndWait() // release the reopened store's segment fds (Windows RemoveAll) + if got := n.Load(); got != 0 { + t.Fatalf("clean reopen ran %d covering merges, want 0", got) + } +} + +func segmentCoversTable(s *Store, tableId int) bool { + for _, sm := range s.SegmentsForTest() { + if sm.Postings > 0 && uint32(tableId) >= sm.MinTable && uint32(tableId) <= sm.MaxTable { + return true + } + } + return false +} diff --git a/core/invertedstore/orphan_sweep_test.go b/core/invertedstore/orphan_sweep_test.go new file mode 100644 index 0000000..884d58e --- /dev/null +++ b/core/invertedstore/orphan_sweep_test.go @@ -0,0 +1,106 @@ +package invertedstore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +func TestOrphanSweep_RemovesUnlistedSegmentOnOpen(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("orphansweep") + q.Start() + s, err := Open(dir, q, Options{}) + if err != nil { + t.Fatal(err) + } + tid, _ := s.CreateTable("files") + s.applyForTest(tid, 1, []string{"alpha"}) + s.spillForTest(tid) // one LIVE segment, in the MANIFEST + live := s.SegmentsForTest() + if len(live) != 1 { + t.Fatalf("want 1 live segment, got %d", len(live)) + } + s.CloseAndWait() + + // Simulate a crash-after-reserve orphan: a seg file at an id NOT in the MANIFEST. + orphan := filepath.Join(dir, segFileName(999999)) + if err := os.WriteFile(orphan, []byte("garbage-not-a-real-segment"), 0o644); err != nil { + t.Fatal(err) + } + + q2 := queue.NewMpsc("orphansweep2") + q2.Start() + s2, err := Open(dir, q2, Options{}) + if err != nil { + t.Fatal(err) + } + defer s2.CloseAndWait() + if _, err := os.Stat(orphan); !os.IsNotExist(err) { + t.Fatalf("orphan segment was not swept on Open (stat err=%v)", err) + } + // The live segment + its data survive. + if got := s2.SegmentsForTest(); len(got) != 1 || got[0].Id != live[0].Id { + t.Fatalf("live segment lost after sweep: %+v", got) + } + if _, err := os.Stat(filepath.Join(dir, segFileName(live[0].Id))); err != nil { + t.Fatalf("live segment file removed by sweep: %v", err) + } +} + +// TestOrphanSweep_RemovesTempSpillFileOnOpen: an off-worker spill that crashed before install left a +// seg-tmp-*.dat behind (never live in the MANIFEST). Open must sweep it (item G / F v5), while +// leaving unrelated files (MANIFEST) and any subdirectory untouched. +func TestOrphanSweep_RemovesTempSpillFileOnOpen(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("orphansweep-tmp") + q.Start() + s, err := Open(dir, q, Options{}) + if err != nil { + t.Fatal(err) + } + tid, _ := s.CreateTable("files") + s.applyForTest(tid, 1, []string{"alpha"}) + s.spillForTest(tid) // one LIVE segment, in the MANIFEST + live := s.SegmentsForTest() + if len(live) != 1 { + t.Fatalf("want 1 live segment, got %d", len(live)) + } + s.CloseAndWait() + + // A crash-mid-encode orphan: an off-worker spill temp file the install never renamed. + tmpOrphan := filepath.Join(dir, segTempFileName(7)) + if err := os.WriteFile(tmpOrphan, []byte("half-written-temp-encode"), 0o644); err != nil { + t.Fatal(err) + } + // A subdirectory the sweep must SKIP (e.IsDir branch), not try to remove/parse. + subDir := filepath.Join(dir, "subdir") + if err := os.Mkdir(subDir, 0o755); err != nil { + t.Fatal(err) + } + + q2 := queue.NewMpsc("orphansweep-tmp2") + q2.Start() + s2, err := Open(dir, q2, Options{}) + if err != nil { + t.Fatal(err) + } + defer s2.CloseAndWait() + + if _, err := os.Stat(tmpOrphan); !os.IsNotExist(err) { + t.Fatalf("seg-tmp orphan was not swept on Open (stat err=%v)", err) + } + // The subdirectory is left alone (IsDir skip). + if fi, err := os.Stat(subDir); err != nil || !fi.IsDir() { + t.Fatalf("sweep must not touch a subdirectory: err=%v", err) + } + // The live segment survives. + if got := s2.SegmentsForTest(); len(got) != 1 || got[0].Id != live[0].Id { + t.Fatalf("live segment lost after temp sweep: %+v", got) + } + if _, err := os.Stat(filepath.Join(dir, segFileName(live[0].Id))); err != nil { + t.Fatalf("live segment file removed by sweep: %v", err) + } +} diff --git a/core/invertedstore/reconcile.go b/core/invertedstore/reconcile.go new file mode 100644 index 0000000..362925f --- /dev/null +++ b/core/invertedstore/reconcile.go @@ -0,0 +1,223 @@ +package invertedstore + +// reconcile.go — §9 durability hook: the forward-docid enumeration the indexer drives the +// deletion-reconciliation pass over on Open. +// +// Recovery is INDEXER-driven (design §9): the store keeps no recovery watermark, only +// crash-consistency (sealed segments durable, head volatile). On Open the indexer re-Updates every +// source doc newer than its OWN durable cursor and reconciles deletions — a docid that is LIVE in +// the store's forward map but ABSENT from source must be re-Updated with empty keywords (= delete). +// To drive that deletion pass the indexer needs to enumerate the store's currently-live forward +// docids; ForwardDocids is that enumeration hook (the one PUBLIC API the §9 contract was missing). + +import ( + "encoding/binary" +) + +// ForwardDocids invokes fn for every docid that is currently LIVE (present, not tombstoned) in the +// forward map of tableId, resolving newest-wins exactly like forwardKeywords: the head's pending +// forward is newest, then sealed segments newest -> oldest, and the FIRST source to mention a docid +// — a live forward OR a forward-tombstone — decides it. A docid the head/newer segment deleted is +// never yielded even if an older segment still holds a live forward for it. fn returning false stops +// the enumeration early. +// +// It is the §9 deletion-reconciliation hook: the indexer calls it on Open, and for each yielded +// docid that is ABSENT from its source view re-Updates with empty keywords (= delete). Re-Update is +// idempotent in result (§9), so the indexer may over-yield/over-replay without corrupting the index. +// +// Concurrency: like Search/GetDocs it snapshots the head (under the RLock) and acquires a refcounted +// segment snapshot, so it is safe to call concurrently with writes — though §9 calls it on Open +// before serving, when there are no concurrent writers. It does NOT resolve term-ids to strings (the +// deletion pass needs only the docid), so it never touches the dict cache. +func (s *Store) ForwardDocids(tableId int, fn func(docid int64) bool) { + if _, ok := s.tableInfo(tableId); !ok { + return + } + + // decided[docid] = true means the newest source for this docid has already been seen, so every + // older source is ignored for it (newest-wins). We do NOT pre-populate it from a flat set: the + // head is processed first, then segments newest->oldest, marking each docid decided on first sight. + decided := map[int64]struct{}{} + + // 1. Snapshot the head's forward state under the RLock (the worker mutates h.fwd/h.delForward + // under s.mu.Lock()), then acquire the refcounted segment snapshot in the SAME window so the + // head copy and the segment set are one consistent point (mirrors Search/forwardKeywords). + s.mu.RLock() + h := s.head[tableId] + var headLive []int64 + if h != nil { + for d := range h.delForward { + decided[d] = struct{}{} // a pending delete is newest: this docid is dead, decided + } + for d := range h.fwd { + // A pending delete for the same docid (recorded above) wins — they cannot both exist in + // one head (setForward/deleteForward are mutually exclusive), but guard defensively. + if _, dead := decided[d]; dead { + continue + } + decided[d] = struct{}{} + headLive = append(headLive, d) + } + } + // Spilling tier (item F, B1): heads DETACHED for off-worker encode, between the live head and the + // sealed segments, newest -> oldest. A tombstone in a newer-or-equal tier decides the docid dead; a + // live forward not yet decided is yielded (after the head's headLive, before the segment resolver). + // Collected inside this RLock window, before RUnlock. + var spillingLive []int64 // spilling-tier live fwd docids, newest -> oldest; yielded after headLive + for i := len(s.spilling) - 1; i >= 0; i-- { + e := s.spilling[i] + if e.tableId != tableId { + continue + } + for d := range e.head.delForward { + decided[d] = struct{}{} // a tombstone in a newer-or-equal tier decides the docid dead + } + for d := range e.head.fwd { + if _, dead := decided[d]; dead { + continue + } + decided[d] = struct{}{} + spillingLive = append(spillingLive, d) + } + } + segs := s.acquireSnapshotLocked() + s.mu.RUnlock() + defer s.releaseSnapshot(segs) + + // 2. Head is newest: yield its live forwards first. + for _, d := range headLive { + if !fn(d) { + return + } + } + + // 2b. Spilling tier next, newest -> oldest (already collected in that order). + for _, d := range spillingLive { + if !fn(d) { + return + } + } + + // 3. Segments newest -> oldest: the shared resolver yields each not-yet-decided docid's newest + // forward; ForwardDocids only needs the docid, so it ignores the ords and yields live ones. + s.forEachLiveSegmentForward(tableId, decided, segs, func(docid int64, _ []uint32, deleted bool) bool { + if deleted { + return true // forward-tombstone: dead, decided, not yielded — keep scanning + } + return fn(docid) + }) +} + +// forEachLiveSegmentForward is the segment half of ForwardDocids's newest-wins forward resolution, +// factored out so the Open live-recompute (recomputeLive) can reuse the EXACT same resolution +// (tombstone handling, newest-wins, per-table) and surface each live docid's ORDS — which +// ForwardDocids discards. It scans segs (a caller-owned slice — the refcounted snapshot for +// ForwardDocids, or s.segs directly on Open when there are no concurrent readers) newest -> oldest: +// the first segment to mention a docid decides it (a forward-tombstone marks it dead via deleted=true, +// a live forward yields its ords). `decided` carries any newer-source decisions (the head's, for +// ForwardDocids; empty for the head-less Open recompute). visit returning false stops early. It does +// NOT take s.mu — the caller owns the consistency of `segs` (snapshot ref or single-threaded Open). +func (s *Store) forEachLiveSegmentForward(tableId int, decided map[int64]struct{}, segs []*segment, visit func(docid int64, ords []uint32, deleted bool) (keepGoing bool)) { + tid := uint32(tableId) + lo := forwardKeyPrefix(tid) + hi := prefixUpper(lo) + for i := len(segs) - 1; i >= 0; i-- { + stop := false + segs[i].scanPrefix(lo, hi, func(key, value []byte) { + if stop { + return + } + docid := int64(binary.BigEndian.Uint64(key[5:13])) // keyType(1)+tableId(4 BE) then docid(8 BE) + if _, seen := decided[docid]; seen { + return // an equal-or-newer source already decided this docid + } + decided[docid] = struct{}{} + ords, del := decodeForward(value) + if !visit(docid, ords, del) { + stop = true + } + }) + if stop { + return + } + } +} + +// distinctOrds returns the count of distinct ords as a slice (the input is sorted by encodeForward, +// so a single skip-equal-previous pass dedups). The forward stores RAW ords (encodeForward does not +// dedup, head.setForward keeps caller duplicates), so a doc indexed with duplicate keywords yields +// duplicate ords; distinctOrds collapses them to match the inverted index, which dedups via addPosting. +func distinctOrds(ords []uint32) []uint32 { + if len(ords) <= 1 { + return ords + } + out := ords[:1] + for _, o := range ords[1:] { + if o != out[len(out)-1] { + out = append(out, o) + } + } + return out +} + +// forwardKeyPrefix is the [F] tableId key prefix (no docid) — the lower bound for scanning a table's +// entire forward keyspace. Shares the layout of forwardKey: keyType(1) + tableId(4 BE). +func forwardKeyPrefix(tableId uint32) []byte { + b := make([]byte, 5) + b[0] = ktForward + binary.BigEndian.PutUint32(b[1:5], tableId) + return b +} + +// upgradeSegmentRanges recomputes every live segment's [minDocid,maxDocid] from its forward records +// (live AND tombstone) and rewrites the MANIFEST at FormatVersion 3. One-time legacy migration for +// the forward-skip range (B): a pre-3 manifest lacks the fields, so a stale [0,0] would mis-skip. +// Open-only (no snapshot refcount, no concurrent writers). +func (s *Store) upgradeSegmentRanges() error { + for i := range s.segs { + seg := s.segs[i] + minD, maxD := emptyDocidRange() + lo := []byte{ktForward} + hi := prefixUpper(lo) + seg.scanPrefix(lo, hi, func(key, _ []byte) { + d := int64(binary.BigEndian.Uint64(key[5:13])) + if d < minD { + minD = d + } + if d > maxD { + maxD = d + } + }) + seg.minDocid, seg.maxDocid = minD, maxD + for j := range s.man.Segments { + if s.man.Segments[j].Id == seg.id { + s.man.Segments[j].MinDocid, s.man.Segments[j].MaxDocid = minD, maxD + } + } + } + s.man.FormatVersion = 3 + return writeManifest(s.dir, s.man) +} + +// recomputeLive rebuilds s.liveByTable from the segments' forward records, catalog-gated. It is the +// authoritative anchor for the live counter (spec §4.2.1): called on Open, it is consistent with +// `written` (Σ segMeta.Postings) by construction, so a crash that dropped unspilled head writes +// drops them from `live` too — no persisted scalar to go stale, no double-count on indexer replay. +// +// It iterates ONLY catalog tables (s.man.Tables), so a table dropped by DeleteTable whose segments +// are not yet covering-merged away is NOT resurrected into `live`. The head is empty on Open, so it +// runs segments-only (decided starts empty) over s.segs directly — safe lock-free here because Open +// has no concurrent readers/writers yet (it runs after publishSnapshotLocked, before startMergeLoop). +// MUST NOT be called once the store is serving (it reads s.segs without the snapshot refcount). +func (s *Store) recomputeLive() { + s.liveByTable = make(map[int]int64, len(s.man.Tables)) + for tid := range s.man.Tables { + s.forEachLiveSegmentForward(tid, map[int64]struct{}{}, s.segs, + func(_ int64, ords []uint32, deleted bool) bool { + if !deleted { + s.liveByTable[tid] += int64(len(distinctOrds(ords))) + } + return true + }) + } +} diff --git a/core/invertedstore/reconcile_test.go b/core/invertedstore/reconcile_test.go new file mode 100644 index 0000000..896c9ef --- /dev/null +++ b/core/invertedstore/reconcile_test.go @@ -0,0 +1,145 @@ +package invertedstore + +import ( + "sort" + "testing" +) + +// collectForward drains ForwardDocids into a sorted slice for easy assertion. +func collectForward(s *Store, tbl int) []int64 { + var got []int64 + s.ForwardDocids(tbl, func(d int64) bool { + got = append(got, d) + return true + }) + sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) + return got +} + +func eqInt64s(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestForwardDocids_HeadOnly: live forwards buffered in the head (no spill) are enumerated. +func TestForwardDocids_HeadOnly(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha", "beta"}) + s.Update(tbl, 20, []string{"gamma"}) + s.sync() + + if got := collectForward(s, tbl); !eqInt64s(got, []int64{10, 20}) { + t.Fatalf("head-only forward docids = %v, want [10 20]", got) + } +} + +// TestForwardDocids_AbsentTable: an unknown/deleted table yields nothing (no panic). +func TestForwardDocids_AbsentTable(t *testing.T) { + s, _ := newUpdateStore(t) + defer s.CloseAndWait() + + if got := collectForward(s, 9999); len(got) != 0 { + t.Fatalf("absent table forward docids = %v, want empty", got) + } +} + +// TestForwardDocids_AcrossSegments: forwards sealed into segments are enumerated, and a docid that +// lives in multiple segments (re-Updated then re-spilled) is yielded exactly once. +func TestForwardDocids_AcrossSegments(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha"}) + s.Update(tbl, 20, []string{"beta"}) + s.sync() + s.forceSpill(tbl) // seg 1: forwards for 10, 20 + + s.Update(tbl, 10, []string{"alpha", "gamma"}) // re-post 10 (forward in a newer segment too) + s.Update(tbl, 30, []string{"delta"}) + s.sync() + s.forceSpill(tbl) // seg 2: forwards for 10 (again), 30 + + if got := collectForward(s, tbl); !eqInt64s(got, []int64{10, 20, 30}) { + t.Fatalf("cross-segment forward docids = %v, want [10 20 30] (10 deduped)", got) + } +} + +// TestForwardDocids_NewestWinsDelete: a delete (forward-tombstone) in a newer source must suppress +// an older live forward for the same docid, whether the delete is in the head or in a newer segment. +func TestForwardDocids_NewestWinsDelete(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + // Doc 10: live in seg 1, deleted in the head (newest) -> must NOT be yielded. + s.Update(tbl, 10, []string{"alpha"}) + s.Update(tbl, 40, []string{"omega"}) + s.sync() + s.forceSpill(tbl) + s.Update(tbl, 10, nil) // delete 10 (head forward-tombstone, newest) + s.sync() + + if got := collectForward(s, tbl); !eqInt64s(got, []int64{40}) { + t.Fatalf("head-delete forward docids = %v, want [40] (10 deleted)", got) + } + + // Now seal the delete into a newer segment and confirm the tombstone still wins across segments. + s.forceSpill(tbl) // seg 2: forward-tombstone for 10 + if got := collectForward(s, tbl); !eqInt64s(got, []int64{40}) { + t.Fatalf("segment-delete forward docids = %v, want [40] (10 tombstoned newest-wins)", got) + } +} + +// TestForwardDocids_EarlyStop: returning false from fn stops enumeration. +func TestForwardDocids_EarlyStop(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + for d := int64(1); d <= 50; d++ { + s.Update(tbl, d, []string{"alpha"}) + } + s.sync() + s.forceSpill(tbl) + + n := 0 + s.ForwardDocids(tbl, func(d int64) bool { + n++ + return n < 3 // stop after the 3rd yield + }) + if n != 3 { + t.Fatalf("early-stop visited %d docids, want exactly 3", n) + } +} + +// TestForwardDocids_TableIsolation: ForwardDocids(t) yields only table t's docids, never another +// table's, even when both share the same docid values. +func TestForwardDocids_TableIsolation(t *testing.T) { + s, tblA := newUpdateStore(t) + defer s.CloseAndWait() + tblB, err := s.CreateTable("other") + if err != nil { + t.Fatal(err) + } + + s.Update(tblA, 10, []string{"alpha"}) + s.Update(tblB, 10, []string{"beta"}) + s.Update(tblB, 11, []string{"gamma"}) + s.sync() + s.forceSpill(tblA) + s.forceSpill(tblB) + + if got := collectForward(s, tblA); !eqInt64s(got, []int64{10}) { + t.Fatalf("table A forward docids = %v, want [10]", got) + } + if got := collectForward(s, tblB); !eqInt64s(got, []int64{10, 11}) { + t.Fatalf("table B forward docids = %v, want [10 11]", got) + } +} diff --git a/core/invertedstore/rename_reseat_test.go b/core/invertedstore/rename_reseat_test.go new file mode 100644 index 0000000..7b1b4f5 --- /dev/null +++ b/core/invertedstore/rename_reseat_test.go @@ -0,0 +1,61 @@ +package invertedstore + +import ( + "os" + "path/filepath" + "testing" +) + +// renameSegmentFile must reseat the segment's fd across a rename (close then reopen at the new +// name) so the install path is Windows-safe — os.Rename cannot move a file whose handle is still +// open there. This exercises the SUCCESS path directly (the off-worker spill tests hit it too, but +// this pins the contract): after the rename the file lives only at the new name and the segment is +// still readable through the reseated fd. +func TestRenameSegmentFileReseatsFdToNewName(t *testing.T) { + seg := writeTestSeg(t, true) + defer seg.close() + from := seg.path + to := filepath.Join(filepath.Dir(from), "seg-renamed.dat") + + if err := renameSegmentFile(seg, from, to); err != nil { + t.Fatalf("renameSegmentFile(%q -> %q) = %v, want nil", from, to, err) + } + if seg.path != to { + t.Fatalf("seg.path = %q, want %q after successful rename", seg.path, to) + } + if _, err := os.Stat(from); !os.IsNotExist(err) { + t.Fatalf("source file still present at %q after rename (stat err = %v)", from, err) + } + // The reseated fd must read the file at its new name. + if _, ok := seg.lookupForward(forwardKey(1, 10)); !ok { + t.Fatal("segment not readable through the reseated fd after rename to the new name") + } +} + +// When the rename FAILS, renameSegmentFile must leave the segment live at its SOURCE path — the fd +// reopened at `from` and seg.path pointing back at `from` — so installSpill's bounded retry (and its +// eventual giveUpSpill cleanup) still sees a readable segment at the temp name it will re-rename / +// remove. This is the retry-safety invariant the install rollback paths depend on; it is otherwise +// unexercised (a real os.Rename failure is not injectable through the store), so cover it directly. +func TestRenameSegmentFileErrorKeepsSegmentAtSource(t *testing.T) { + seg := writeTestSeg(t, true) + defer seg.close() + from := seg.path + // Renaming into a directory that does not exist fails on every OS, and leaves the file at `from`. + badTo := filepath.Join(filepath.Dir(from), "no-such-subdir", "seg.dat") + + err := renameSegmentFile(seg, from, badTo) + if err == nil { + t.Fatalf("renameSegmentFile(%q -> %q) = nil, want an error (target dir absent)", from, badTo) + } + if seg.path != from { + t.Fatalf("seg.path = %q, want the source %q after a failed rename", seg.path, from) + } + if _, statErr := os.Stat(from); statErr != nil { + t.Fatalf("source file missing at %q after a failed rename: %v", from, statErr) + } + // The fd must be reopened at `from` so a retry reads live data (never a lost segment). + if _, ok := seg.lookupForward(forwardKey(1, 10)); !ok { + t.Fatal("segment not readable at the source path after a failed rename — a retry would lose it") + } +} diff --git a/core/invertedstore/resolve_ops_test.go b/core/invertedstore/resolve_ops_test.go new file mode 100644 index 0000000..832c86f --- /dev/null +++ b/core/invertedstore/resolve_ops_test.go @@ -0,0 +1,96 @@ +package invertedstore + +import ( + "reflect" + "sort" + "testing" +) + +// pdFromOps builds a postingDelta from an ordered (docid, isAdd) op log, mirroring the worker's +// append order. Used by the resolve tests to feed an exact op sequence (incl. full-int64-range +// docids) through resolveOps without going through the Store. +func pdFromOps(ops ...struct { + docid int64 + isAdd bool +}) *postingDelta { + pd := &postingDelta{} + for _, o := range ops { + pd.appendOp(o.docid, o.isAdd) + } + return pd +} + +func op(docid int64, isAdd bool) struct { + docid int64 + isAdd bool +} { + return struct { + docid int64 + isAdd bool + }{docid, isAdd} +} + +func TestResolveOps_LatestWinsMatchesMap(t *testing.T) { + type o = struct { + docid int64 + isAdd bool + } + cases := []struct { + name string + ops []o + adds, dels []int64 + }{ + {"add only", []o{op(5, true)}, []int64{5}, nil}, + {"del only", []o{op(5, false)}, nil, []int64{5}}, + {"add then del (latest=del)", []o{op(5, true), op(5, false)}, nil, []int64{5}}, + {"del then add (latest=add)", []o{op(5, false), op(5, true)}, []int64{5}, nil}, + {"add del add (latest=add)", []o{op(5, true), op(5, false), op(5, true)}, []int64{5}, nil}, + {"repeated add dedups", []o{op(5, true), op(5, true)}, []int64{5}, nil}, + {"interleaved", []o{op(1, true), op(2, false), op(1, false), op(2, true)}, []int64{2}, []int64{1}}, + {"cold-build append-only", []o{op(3, true), op(7, true), op(1, true)}, []int64{1, 3, 7}, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + adds, dels := resolveOps(pdFromOps(c.ops...)) + sort.Slice(adds, func(i, j int) bool { return adds[i] < adds[j] }) + sort.Slice(dels, func(i, j int) bool { return dels[i] < dels[j] }) + if !eqInt64s(adds, c.adds) || !eqInt64s(dels, c.dels) { // reuse the existing eqInt64s; do NOT add eqInt64 + t.Fatalf("resolveOps(%v) = adds %v dels %v, want adds %v dels %v", c.ops, adds, dels, c.adds, c.dels) + } + }) + } +} + +// Full-int64-range docids (>= 2^62, incl. math.MaxInt64) MUST round-trip through resolveOps — the raw +// docid + parallel isAdd bitset carries them with no packable-range limit (item H, spec §5b fallback). +func TestResolveOps_FullInt64Range(t *testing.T) { + const maxInt64 = int64(^uint64(0) >> 1) // math.MaxInt64 without importing math + pd := pdFromOps( + op(1<<62, true), // an add at exactly 2^62 (the old packing's panic point) + op(maxInt64, false), // del at MaxInt64 + op(maxInt64, true), // re-add at MaxInt64 -> latest is add + op(1<<62, false), // del at 2^62 -> latest is del + ) + adds, dels := resolveOps(pd) + sort.Slice(adds, func(i, j int) bool { return adds[i] < adds[j] }) + sort.Slice(dels, func(i, j int) bool { return dels[i] < dels[j] }) + if !eqInt64s(adds, []int64{maxInt64}) { + t.Fatalf("adds = %v, want {MaxInt64} (re-add wins at MaxInt64)", adds) + } + if !eqInt64s(dels, []int64{1 << 62}) { + t.Fatalf("dels = %v, want {1<<62} (del wins at 2^62)", dels) + } +} + +// resolveOps MUST NOT mutate its input postingDelta (concurrent Search + the read-only detached-head +// encode share the head's pd): it resolves on a fresh scratch copy of the docids + isAdd bitset. +func TestResolveOps_DoesNotMutateInput(t *testing.T) { + pd := pdFromOps(op(2, true), op(1, false), op(2, false)) + docidsCp := append([]int64(nil), pd.docids...) + isAddCp := append([]uint64(nil), pd.isAdd...) + resolveOps(pd) + if !reflect.DeepEqual(pd.docids, docidsCp) || !reflect.DeepEqual(pd.isAdd, isAddCp) { + t.Fatalf("resolveOps mutated its input: docids %v (want %v) isAdd %v (want %v)", + pd.docids, docidsCp, pd.isAdd, isAddCp) + } +} diff --git a/core/invertedstore/search.go b/core/invertedstore/search.go new file mode 100644 index 0000000..c1e659b --- /dev/null +++ b/core/invertedstore/search.go @@ -0,0 +1,265 @@ +package invertedstore + +import ( + "strings" + + "github.com/codetrek/haystack/core/invertedindex" +) + +// SearchResult is the membership result of a Search/GetDocs: the live docids whose keyword(s) +// matched. WildDocIds is preserved for compatibility with invertedindex's SearchResult (the +// suffix/wildcard path) — the store does NOT populate it; it is caller-populated per design §4. +// +// It is a type ALIAS of invertedindex.SearchResult (NOT a separate definition), so the store +// returns the IDENTICAL named type that invertedindex defines (the SearchResult struct is +// intentionally reused). invertedstore importing invertedindex is cycle-free: invertedindex does +// not import invertedstore. The shape is byte-identical (DocIds/WildDocIds map[int64]struct{} with +// the same json tags), so every existing SearchResult{...} literal and field access in this package +// compiles unchanged. +type SearchResult = invertedindex.SearchResult + +// Search returns the live docids of every keyword that has the lowercased query as a PREFIX, +// in the given table. It is the prefix scan of design §4/§6: +// +// 1. Snapshot the head + the live segment set under the RLock: copy the head's matching deltas +// out of the live maps and copy the segment slice, then release. (The head is mutated only on +// the worker under the write lock, so it MUST be read under the RLock; the segment FILES are +// immutable, so the segment scan runs lock-free on the snapshot.) +// 2. Prefix-scan by ([I], tableId, lowercased query) over the head, then over the segments +// NEWEST -> OLDEST. +// 3. Resolve each (keyword, docid) newest-wins: the FIRST source to mention a (keyword,docid) +// — an add OR a tombstone — decides it; older mentions are ignored. The head is newest, then +// segments in reverse order. Within ONE source's value for a keyword, a tombstone wins over +// an add (a spilled value never holds both for a docid, but a del still claims the pair so an +// older add can't resurrect it). +// 4. Apply filterKeyword (skip a whole keyword whose string the caller rejects) and limit. +// +// An absent/deleted tableId returns an empty result immediately (no segment touched). WildDocIds +// is left nil — the store never populates it. +func (s *Store) Search(tableId int, query string, limit int, filterKeyword func(string) bool) SearchResult { + res := SearchResult{DocIds: map[int64]struct{}{}} + if _, ok := s.tableInfo(tableId); !ok { + return res + } + tid := uint32(tableId) + lo := invertedKey(tid, strings.ToLower(query)) + hi := prefixUpper(lo) + + // Per-keyword newest-wins resolution. seen[keyword] holds the set of docids already DECIDED + // for that keyword (an add that survived or a tombstone that killed it); the first source to + // touch a (keyword,docid) wins, so a docid already in seen[keyword] is skipped by every older + // source. live holds the surviving (present) docids per keyword. We resolve per keyword (not a + // flat docid set) because Search unions MANY keywords under one prefix, and a tombstone for kw1 + // must not suppress an add of the same docid under a different kw2. + seen := map[string]map[int64]struct{}{} + live := map[string]map[int64]struct{}{} + ensure := func(m map[string]map[int64]struct{}, kw string) map[int64]struct{} { + s := m[kw] + if s == nil { + s = map[int64]struct{}{} + m[kw] = s + } + return s + } + + // merge applies one source's postings for a keyword under newest-wins. dels are processed + // FIRST so a tombstone claims the (kw,docid) before an add in the SAME source could mark it + // live; then adds add only docids not yet decided. (Mirrors the spike search closure, which + // stamps the del-set before the add-set.) + merge := func(kw string, adds, dels []int64) { + if filterKeyword != nil && !filterKeyword(kw) { + return + } + sk := ensure(seen, kw) + for _, d := range dels { + if _, ok := sk[d]; ok { + continue // older source already decided this (kw,docid) + } + sk[d] = struct{}{} // tombstone wins; not live + } + lk := ensure(live, kw) + for _, d := range adds { + if _, ok := sk[d]; ok { + continue + } + sk[d] = struct{}{} + lk[d] = struct{}{} + } + } + + // headPosting is a keyword's head deltas COPIED out of the live head maps under the RLock, so + // the (immutable) segment scan below can run lock-free without racing the worker that mutates + // the head under s.mu.Lock() (head.go addPosting/tombstonePosting). + type headPosting struct { + kw string + adds, dels []int64 + } + + // 1. Snapshot. The head is mutated only on the worker under s.mu.Lock(), so we MUST read its + // matching deltas (range h.inv + resolveOps of each keyword's copied ordered op log) WHILE + // holding the RLock — copying them into local slices — and acquire the segment snapshot's reader + // refs in the SAME RLock window (P9 acquireSnapshotLocked), so the head-copy and the segment set + // are a single consistent point (a spill that moves a posting head->segment can never make it + // vanish from BOTH). The segment FILES are immutable so the scan runs lock-free after RUnlock; + // releaseSnapshot drops the refs (and unlinks a merged-away file once this was its last reader). + q := strings.ToLower(query) + s.mu.RLock() + h := s.head[tableId] + var headHits []headPosting + if h != nil { + for kw, pd := range h.inv { + if !strings.HasPrefix(kw, q) { + continue + } + adds, dels := resolveOps(pd) // copy ops into scratch under the RLock, resolve on the copy (item H) + headHits = append(headHits, headPosting{kw: kw, adds: adds, dels: dels}) + } + } + // Spilling tier (item F, B1): heads DETACHED for off-worker encode, between the live head and the + // sealed segments, newest -> oldest. Append each matching keyword's deltas into the SAME ordered + // headHits so merge sees live-head -> spilling newest->oldest -> segments (newest-wins). Copied + // inside this RLock window (before RUnlock); the spilling iteration does NOT re-lock. + for i := len(s.spilling) - 1; i >= 0; i-- { + e := s.spilling[i] + if e.tableId != tableId { + continue + } + for kw, pd := range e.head.inv { + if !strings.HasPrefix(kw, q) { + continue + } + adds, dels := resolveOps(pd) // copy ops into scratch under the RLock, resolve on the copy (item H) + headHits = append(headHits, headPosting{kw: kw, adds: adds, dels: dels}) + } + } + segs := s.acquireSnapshotLocked() + s.mu.RUnlock() + defer s.releaseSnapshot(segs) + + // 2a. Head is the newest source: merge its already-copied per-keyword deltas. + for _, hp := range headHits { + merge(hp.kw, hp.adds, hp.dels) + } + + // 2b. Segments newest -> oldest. + for i := len(segs) - 1; i >= 0; i-- { + segs[i].scanPrefix(lo, hi, func(key, value []byte) { + kw := string(key[5:]) // keyType(1) + tableId(4 BE) then keyword + ab, db := splitInvertedValue(value) + var adds, dels []int64 + decodeDocs(ab, func(d int64) { adds = append(adds, d) }) + decodeDocs(db, func(d int64) { dels = append(dels, d) }) + merge(kw, adds, dels) + }) + } + + // 3. Union the surviving docids across all matched keywords, honoring limit (limit <= 0 = all). + for _, lk := range live { + for d := range lk { + if limit > 0 && len(res.DocIds) >= limit { + if _, dup := res.DocIds[d]; !dup { + return res + } + continue + } + res.DocIds[d] = struct{}{} + } + } + return res +} + +// GetDocs returns the live docids of the EXACT keyword key (no lowercasing, no filterKeyword, no +// limit). It is kept separate from Search precisely so the fixed-width 4-byte tableId prefix can +// never leak a longer keyword: a Search by prefix "a" matches "a", "ab", "abc", … but GetDocs("a") +// must match ONLY the keyword "a". We enforce this by resolving a SINGLE exact key — scanPrefix +// over [key, key++) still visits the whole prefix block, so we compare the visited key for exact +// byte-equality and ignore any "a"+suffix record. +func (s *Store) GetDocs(tableId int, key string) SearchResult { + res := SearchResult{DocIds: map[int64]struct{}{}} + if _, ok := s.tableInfo(tableId); !ok { + return res + } + tid := uint32(tableId) + want := invertedKey(tid, key) + hi := prefixUpper(want) + + seen := map[int64]struct{}{} + live := map[int64]struct{}{} + merge := func(adds, dels []int64) { + for _, d := range dels { + if _, ok := seen[d]; ok { + continue + } + seen[d] = struct{}{} + } + for _, d := range adds { + if _, ok := seen[d]; ok { + continue + } + seen[d] = struct{}{} + live[d] = struct{}{} + } + } + + // Snapshot. resolveOps the head's matching ordered op log WHILE holding the RLock — it copies the + // keyword's ops (raw docids + the isAdd bitset) into a fresh scratch and resolves on the copy, so + // the worker's concurrent appends (h.inv[key].appendOp under s.mu.Lock()) never race the read — and + // acquire the segment snapshot's reader refs in the SAME RLock window (P9). Segment files are + // immutable, so the segment scan below runs lock-free on the refcounted snapshot; releaseSnapshot + // drops the refs afterward. + s.mu.RLock() + h := s.head[tableId] + var headAdds, headDels []int64 + headHit := false + if h != nil { + if pd := h.inv[key]; pd != nil { + headHit = true + headAdds, headDels = resolveOps(pd) // copy ops into scratch under the RLock, resolve on the copy (item H) + } + } + // Spilling tier (item F, B1): copy each detached head's deltas for this exact key, newest -> oldest, + // inside this RLock window — merged between the live head and the segments (newest-wins). + type spillHit struct{ adds, dels []int64 } + var spillHits []spillHit + for i := len(s.spilling) - 1; i >= 0; i-- { + e := s.spilling[i] + if e.tableId != tableId { + continue + } + if pd := e.head.inv[key]; pd != nil { + adds, dels := resolveOps(pd) // copy ops into scratch under the RLock, resolve on the copy (item H) + spillHits = append(spillHits, spillHit{adds: adds, dels: dels}) + } + } + segs := s.acquireSnapshotLocked() + s.mu.RUnlock() + defer s.releaseSnapshot(segs) + + // Head is newest. + if headHit { + merge(headAdds, headDels) + } + + // Spilling tier next, newest -> oldest (already in that order). + for _, sh := range spillHits { + merge(sh.adds, sh.dels) + } + + // Segments newest -> oldest; compare each visited key for EXACT equality so a longer keyword + // sharing the prefix (the "a" vs "ab" leak) is rejected. + for i := len(segs) - 1; i >= 0; i-- { + segs[i].scanPrefix(want, hi, func(key, value []byte) { + if string(key) != string(want) { + return + } + ab, db := splitInvertedValue(value) + var adds, dels []int64 + decodeDocs(ab, func(d int64) { adds = append(adds, d) }) + decodeDocs(db, func(d int64) { dels = append(dels, d) }) + merge(adds, dels) + }) + } + + res.DocIds = live + return res +} diff --git a/core/invertedstore/search_test.go b/core/invertedstore/search_test.go new file mode 100644 index 0000000..e680984 --- /dev/null +++ b/core/invertedstore/search_test.go @@ -0,0 +1,355 @@ +package invertedstore + +import ( + "sync" + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +// newSearchStore opens a fresh store with a created table, returning the store and tableId. +func newSearchStore(t *testing.T) (*Store, int) { + t.Helper() + dir := t.TempDir() + q := queue.NewMpsc("invsearch") + q.Start() + s, err := Open(dir, q, Options{}) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + return s, tbl +} + +// tombstoneForTest tombstones (keyword,docid) in the head on the worker, mirroring what the P7 +// Update apply does when a keyword is removed from a doc. Used to build add->del->add states +// across un-merged L0 segments without the full Update path. +func (s *Store) tombstoneForTest(tableId int, keyword string, docid int64) { + s.q.RunFunc(func() error { + s.mu.Lock() + h := s.head[tableId] + if h == nil { + h = newHeadTable() + s.head[tableId] = h + } + h.tombstonePosting(keyword, docid) + s.mu.Unlock() + return nil + }) +} + +// addPostingForTest adds a single (keyword,docid) posting in the head on the worker, without +// touching the forward map, so a test can re-add a posting after a tombstone in an older segment. +func (s *Store) addPostingForTest(tableId int, keyword string, docid int64) { + s.q.RunFunc(func() error { + s.mu.Lock() + h := s.head[tableId] + if h == nil { + h = newHeadTable() + s.head[tableId] = h + } + h.addPosting(keyword, docid) + s.mu.Unlock() + return nil + }) +} + +func hasDoc(r SearchResult, d int64) bool { + _, ok := r.DocIds[d] + return ok +} + +// --- Head participates in the union ----------------------------------------- + +// TestSearch_HeadParticipates: a posting that lives ONLY in the unspilled head is found, and a +// posting in a sealed segment is also found — the union spans head + segments. +func TestSearch_HeadParticipates(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + // doc 10 spilled to a segment; doc 11 stays in the head (no spill). + s.applyForTest(tbl, 10, []string{"alpha"}) + s.spillForTest(tbl) + s.applyForTest(tbl, 11, []string{"alpha"}) + + r := s.Search(tbl, "alpha", 0, nil) + if !hasDoc(r, 10) { + t.Errorf("doc 10 (segment) missing from union: %v", r.DocIds) + } + if !hasDoc(r, 11) { + t.Errorf("doc 11 (head) missing from union: %v", r.DocIds) + } +} + +// --- Tombstoned doc absent -------------------------------------------------- + +// TestSearch_TombstonedDocAbsent: a doc tombstoned (in a newer segment than its add) is absent. +func TestSearch_TombstonedDocAbsent(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.applyForTest(tbl, 10, []string{"alpha"}) // add in seg A + s.spillForTest(tbl) + s.tombstoneForTest(tbl, "alpha", 10) // del in seg B (newer) + s.spillForTest(tbl) + + r := s.Search(tbl, "alpha", 0, nil) + if hasDoc(r, 10) { + t.Errorf("tombstoned doc 10 should be absent, got %v", r.DocIds) + } +} + +// TestSearch_TombstonedDocAbsentFromHead: tombstone in the head (newest) suppresses an add in an +// older segment. +func TestSearch_TombstonedDocAbsentFromHead(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.applyForTest(tbl, 10, []string{"alpha"}) // add in seg A + s.spillForTest(tbl) + s.tombstoneForTest(tbl, "alpha", 10) // del in the unspilled head + + r := s.Search(tbl, "alpha", 0, nil) + if hasDoc(r, 10) { + t.Errorf("head tombstone should suppress doc 10, got %v", r.DocIds) + } +} + +// --- add -> del -> add across UN-MERGED L0 segments ------------------------- + +// TestSearch_AddDelAdd_PresentAcrossUnmergedSegments: a doc added (seg A), tombstoned (seg B), +// then RE-ADDED (seg C, newest) resolves PRESENT at read — the newer add wins over the older +// tombstone, with NO merge. +func TestSearch_AddDelAdd_PresentAcrossUnmergedSegments(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.addPostingForTest(tbl, "alpha", 10) // seg A: add + s.spillForTest(tbl) + s.tombstoneForTest(tbl, "alpha", 10) // seg B: del + s.spillForTest(tbl) + s.addPostingForTest(tbl, "alpha", 10) // seg C: add (newest) + s.spillForTest(tbl) + + if len(s.segs) != 3 { + t.Fatalf("expected 3 un-merged L0 segments, got %d", len(s.segs)) + } + r := s.Search(tbl, "alpha", 0, nil) + if !hasDoc(r, 10) { + t.Errorf("add->del->add should resolve PRESENT (newest add wins), got %v", r.DocIds) + } +} + +// TestSearch_AddDelAdd_SymmetricAbsent: the symmetric case — add (seg A), re-add (seg B), +// tombstone (seg C, newest) resolves ABSENT (newest tombstone wins over older adds), NO merge. +func TestSearch_AddDelAdd_SymmetricAbsent(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.addPostingForTest(tbl, "alpha", 10) // seg A: add + s.spillForTest(tbl) + s.addPostingForTest(tbl, "alpha", 10) // seg B: add again + s.spillForTest(tbl) + s.tombstoneForTest(tbl, "alpha", 10) // seg C: del (newest) + s.spillForTest(tbl) + + if len(s.segs) != 3 { + t.Fatalf("expected 3 un-merged L0 segments, got %d", len(s.segs)) + } + r := s.Search(tbl, "alpha", 0, nil) + if hasDoc(r, 10) { + t.Errorf("add->add->del should resolve ABSENT (newest del wins), got %v", r.DocIds) + } +} + +// --- prefix union of many keywords; a tombstone of kw1 must not suppress kw2's add of same doc - + +func TestSearch_PrefixUnionPerKeyword(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + // doc 10: alpha added (seg A), then alpha tombstoned (seg B). doc 10 ALSO in alphabet (seg A). + s.addPostingForTest(tbl, "alpha", 10) + s.addPostingForTest(tbl, "alphabet", 10) + s.spillForTest(tbl) + s.tombstoneForTest(tbl, "alpha", 10) // only alpha tombstoned + s.spillForTest(tbl) + + // prefix "alph" matches both "alpha" (doc 10 tombstoned) and "alphabet" (doc 10 live). + r := s.Search(tbl, "alph", 0, nil) + if !hasDoc(r, 10) { + t.Errorf("doc 10 still live under keyword alphabet, must be present: %v", r.DocIds) + } +} + +// --- filterKeyword + limit -------------------------------------------------- + +func TestSearch_FilterKeyword(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.applyForTest(tbl, 10, []string{"alpha"}) + s.applyForTest(tbl, 11, []string{"alphabet"}) + s.spillForTest(tbl) + + // reject "alphabet"; only "alpha" docs survive. + r := s.Search(tbl, "alph", 0, func(kw string) bool { return kw == "alpha" }) + if !hasDoc(r, 10) || hasDoc(r, 11) { + t.Errorf("filterKeyword should keep only alpha's doc 10, got %v", r.DocIds) + } +} + +func TestSearch_Limit(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + for d := int64(1); d <= 10; d++ { + s.applyForTest(tbl, d, []string{"alpha"}) + } + s.spillForTest(tbl) + + r := s.Search(tbl, "alpha", 3, nil) + if len(r.DocIds) != 3 { + t.Errorf("limit=3 should cap result at 3, got %d", len(r.DocIds)) + } +} + +// --- WildDocIds preserved but never populated ------------------------------- + +func TestSearch_WildDocIdsNotPopulated(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.applyForTest(tbl, 10, []string{"alpha"}) + s.spillForTest(tbl) + + r := s.Search(tbl, "alpha", 0, nil) + if r.WildDocIds != nil { + t.Errorf("store must NOT populate WildDocIds, got %v", r.WildDocIds) + } +} + +// --- absent/deleted table returns empty ------------------------------------- + +func TestSearch_AbsentTableEmpty(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.applyForTest(tbl, 10, []string{"alpha"}) + s.spillForTest(tbl) + + if r := s.Search(999, "alpha", 0, nil); len(r.DocIds) != 0 { + t.Errorf("absent table should be empty, got %v", r.DocIds) + } + if err := s.DeleteTable(tbl); err != nil { + t.Fatal(err) + } + if r := s.Search(tbl, "alpha", 0, nil); len(r.DocIds) != 0 { + t.Errorf("deleted table should be empty, got %v", r.DocIds) + } +} + +// --- GetDocs: exact key, no prefix leak ------------------------------------- + +// TestGetDocs_NoPrefixLeak: GetDocs("a") must match ONLY keyword "a", NOT "a"+suffix. Guards the +// fixed-width-tableId prefix from leaking a longer keyword (design §4/T4). +func TestGetDocs_NoPrefixLeak(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.applyForTest(tbl, 10, []string{"a"}) // keyword "a" + s.applyForTest(tbl, 11, []string{"ab"}) // keyword "ab" (shares the prefix "a") + s.applyForTest(tbl, 12, []string{"abc"}) + s.spillForTest(tbl) + + r := s.GetDocs(tbl, "a") + if !hasDoc(r, 10) { + t.Errorf("GetDocs(\"a\") must include doc 10 (keyword \"a\"), got %v", r.DocIds) + } + if hasDoc(r, 11) || hasDoc(r, 12) { + t.Errorf("GetDocs(\"a\") must NOT leak \"ab\"/\"abc\" docs, got %v", r.DocIds) + } +} + +// TestGetDocs_ExactNoLowercasing: GetDocs is exact — it does NOT lowercase, so an upper-cased +// query does not match a lower-cased keyword (Search would). +func TestGetDocs_ExactNoLowercasing(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.applyForTest(tbl, 10, []string{"alpha"}) + s.spillForTest(tbl) + + if r := s.GetDocs(tbl, "ALPHA"); hasDoc(r, 10) { + t.Errorf("GetDocs is exact (no lowercasing); ALPHA must not match keyword alpha: %v", r.DocIds) + } + if r := s.GetDocs(tbl, "alpha"); !hasDoc(r, 10) { + t.Errorf("GetDocs(alpha) must match keyword alpha, got %v", r.DocIds) + } +} + +// TestGetDocs_HeadAndTombstone: GetDocs also unions head + segments newest-wins. +func TestGetDocs_HeadAndTombstone(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + s.addPostingForTest(tbl, "alpha", 10) + s.spillForTest(tbl) + s.tombstoneForTest(tbl, "alpha", 10) // tombstone in head (newest) + + if r := s.GetDocs(tbl, "alpha"); hasDoc(r, 10) { + t.Errorf("head tombstone must suppress doc 10 in GetDocs, got %v", r.DocIds) + } +} + +// --- concurrency: head read under RLock vs worker write ----------------------- + +// TestSearch_ConcurrentReadVsHeadWrite drives Search and GetDocs CONCURRENTLY with worker-side +// addPosting/tombstonePosting on the SAME keyword/table, so a reader that scanned the head map +// outside the RLock would race the worker's map writes. Under -race this fails (and without -race +// Go can escalate it to `fatal error: concurrent map iteration and map write`); with the head +// deltas copied INSIDE the RLock it is clean. This guards the §6 "readers RLock to scan the head" +// invariant that Search/GetDocs claim. Run via: GOWORK=off go test ./invertedstore/ -race. +func TestSearch_ConcurrentReadVsHeadWrite(t *testing.T) { + s, tbl := newSearchStore(t) + defer s.CloseAndWait() + + // Seed a sealed segment so reads also scan an immutable segment alongside the live head. + s.applyForTest(tbl, 1, []string{"alpha"}) + s.spillForTest(tbl) + + const iters = 2000 + var wg sync.WaitGroup + + // Writer: hammer the head with adds/tombstones on "alpha" (and a sibling so the prefix scan + // in Search ranges multiple keywords) on the worker, the only legal head mutator. + wg.Add(1) + go func() { + defer wg.Done() + for i := int64(0); i < iters; i++ { + s.addPostingForTest(tbl, "alpha", i) + s.addPostingForTest(tbl, "alphabet", i) + if i%2 == 0 { + s.tombstoneForTest(tbl, "alpha", i) + } + } + }() + + // Two reader goroutines: a prefix Search (ranges the whole head inv map) and an exact GetDocs + // (reads h.inv[key].adds/dels), both racing the writer. + for r := 0; r < 2; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iters; i++ { + _ = s.Search(tbl, "alph", 0, nil) + _ = s.GetDocs(tbl, "alpha") + } + }() + } + + wg.Wait() +} diff --git a/core/invertedstore/segment.go b/core/invertedstore/segment.go new file mode 100644 index 0000000..a470eaa --- /dev/null +++ b/core/invertedstore/segment.go @@ -0,0 +1,485 @@ +package invertedstore + +import ( + "bufio" + "bytes" + "encoding/binary" + "math" + "os" + "sort" + "sync" + "sync/atomic" +) + +// ---- key prefix helpers (port spike main.go:213-234, verbatim) ------------- + +func prefixUpper(p []byte) []byte { + u := make([]byte, len(p)) + copy(u, p) + for i := len(u) - 1; i >= 0; i-- { + if u[i] != 0xff { + u[i]++ + return u[:i+1] + } + } + return nil +} + +func hasPrefixBytes(b, p []byte) bool { + if len(b) < len(p) { + return false + } + for i := range p { + if b[i] != p[i] { + return false + } + } + return true +} + +// ---- segment writer (packed blocks; inline small / external large) --------- +// +// Standard SSTable-style format: a segment is a sequence of DATA BLOCKS; each block +// packs N records (key+value) and is compressed AS ONE UNIT. A record's value is INLINE +// when small (≤ threshold bytes) and EXTERNAL (≤chunk compressed chunks, the record holds +// a pointer) when large. Two key-types share the keyspace: [I] keyword->postings and +// [F] docid->forward. Keys are full []byte (keyType + 4B BE tableId + keyword|docid). + +type blockEntry struct { + firstKey []byte + off int64 +} + +type segWriter struct { + f *os.File + bw *bufio.Writer + off int64 + dataCodec, dictCodec *codec + blockTarget, chunk int + threshold, dictChunk int + termid bool + idx []blockEntry + blkRaw []byte // current block's packed records + blkFirst []byte + blkHave bool + + // inline term-dict accumulation (F0): built as [I] keys are added, written at finish — no + // re-read of own blocks. dictRaw is the current chunk; dictRegion is the compressed chunks so far. + dictRaw []byte + dictRegion []byte + dictOrd uint32 + dictChunkFirst uint32 +} + +func newSegWriter(path string, data, dict *codec, blockTarget, chunk, threshold int, termid bool, dictChunk int) *segWriter { + f, err := os.Create(path) + if err != nil { + panic(err) + } + if dictChunk <= 0 { + dictChunk = blockTarget + } + return &segWriter{f: f, bw: bufio.NewWriterSize(f, 1<<20), dataCodec: data, dictCodec: dict, + blockTarget: blockTarget, chunk: chunk, threshold: threshold, termid: termid, dictChunk: dictChunk} +} + +// writeExternalValue writes a large value as ≤chunk compressed chunks and returns +// (offset, totalCompLen). chunk := uvarint(rawLen) uvarint(compLen) bytes. +// (port spike main.go:597-615; codec is dataCodec.) +func (w *segWriter) writeExternalValue(raw []byte) (int64, int) { + start := w.off + for p := 0; p < len(raw); { + end := p + w.chunk + if end > len(raw) { + end = len(raw) + } + piece := raw[p:end] + comp := w.dataCodec.compress(piece) + var hdr []byte + hdr = appendUvarint(hdr, uint64(len(piece))) + hdr = appendUvarint(hdr, uint64(len(comp))) + w.bw.Write(hdr) + w.bw.Write(comp) + w.off += int64(len(hdr) + len(comp)) + p = end + } + return start, int(w.off - start) +} + +// addEntry packs a record (key+value) into the current block. Small values are +// inline; large values are written externally now and the record holds a pointer. +// +// record := uvarint(klen) key flag(1) +// flag==0 inline: uvarint(vlen) value +// flag==1 external: uvarint(off) uvarint(compLen) +// +// (port spike main.go:622-644; key is now []byte.) +func (w *segWriter) addEntry(key []byte, value []byte) { + if !w.blkHave { + // Copy the block's first key at capture (C.2): it is retained as blockEntry.firstKey through + // finish, but `key` may alias a mergeCursor's reusable decompress buffer (blockBytesInto), so a + // later advance() that crosses a block boundary would overwrite the bytes and corrupt the + // persisted block-index first-key. One small copy per block; also hardens the writer generally. + w.blkFirst, w.blkHave = append([]byte(nil), key...), true + } + w.blkRaw = appendUvarint(w.blkRaw, uint64(len(key))) + w.blkRaw = append(w.blkRaw, key...) + if len(value) <= w.threshold { + w.blkRaw = append(w.blkRaw, 0) + w.blkRaw = appendUvarint(w.blkRaw, uint64(len(value))) + w.blkRaw = append(w.blkRaw, value...) + } else { + vOff, vLen := w.writeExternalValue(value) + w.blkRaw = append(w.blkRaw, 1) + w.blkRaw = appendUvarint(w.blkRaw, uint64(vOff)) + w.blkRaw = appendUvarint(w.blkRaw, uint64(vLen)) + } + if len(w.blkRaw) >= w.blockTarget { + w.flushBlock() + } + if w.termid && key[0] == ktInverted { + if len(w.dictRaw) == 0 { + w.dictChunkFirst = w.dictOrd + } + kw := key[5:] // keyType(1) + tableId(4 BE) then keyword + w.dictRaw = appendUvarint(w.dictRaw, uint64(len(kw))) + w.dictRaw = append(w.dictRaw, kw...) + w.dictOrd++ + if len(w.dictRaw) >= w.dictChunk { + w.flushDictChunk() + } + } +} + +// flushBlock compresses the current packed block and appends it. (port spike main.go:646-660.) +func (w *segWriter) flushBlock() { + if len(w.blkRaw) == 0 { + return + } + comp := w.dataCodec.compress(w.blkRaw) + w.idx = append(w.idx, blockEntry{w.blkFirst, w.off}) + var hdr []byte + hdr = appendUvarint(hdr, uint64(len(w.blkRaw))) + hdr = appendUvarint(hdr, uint64(len(comp))) + w.bw.Write(hdr) + w.bw.Write(comp) + w.off += int64(len(hdr) + len(comp)) + w.blkRaw = w.blkRaw[:0] + w.blkHave = false +} + +// finish writes the term-dict region (term-id only), the block index, and the 25-byte +// footer (both codec ids), then opens and returns the segment reader. +func (w *segWriter) finish(path string) *segment { + w.flushBlock() + var dictOff int64 + if w.termid { + w.flushDictChunk() // flush the final partial chunk + dictOff = w.off // == biOff when the region is empty (forward-only segment), as before + w.bw.Write(w.dictRegion) + w.off += int64(len(w.dictRegion)) + } + biOff := w.off + var bi []byte + bi = appendUvarint(bi, uint64(len(w.idx))) + for _, e := range w.idx { + bi = appendUvarint(bi, uint64(len(e.firstKey))) + bi = append(bi, e.firstKey...) + bi = appendUvarint(bi, uint64(e.off)) + } + w.bw.Write(bi) + w.off += int64(len(bi)) + var foot [25]byte + binary.BigEndian.PutUint64(foot[0:8], uint64(biOff)) + binary.BigEndian.PutUint64(foot[8:16], uint64(dictOff)) // 0 ⇒ no term-dict region + foot[16] = w.dataCodec.id + foot[17] = w.dictCodec.id + copy(foot[18:], "SRSEG\x00\x00") + w.bw.Write(foot[:]) + w.bw.Flush() + w.f.Sync() + w.f.Close() + return openSegment(path) +} + +// flushDictChunk compresses the current inline dict chunk and appends it to dictRegion (the same +// uvarint(chunkFirst) uvarint(rawLen) uvarint(compLen) comp layout writeTermDict produced). +func (w *segWriter) flushDictChunk() { + if len(w.dictRaw) == 0 { + return + } + comp := w.dictCodec.compress(w.dictRaw) + w.dictRegion = appendUvarint(w.dictRegion, uint64(w.dictChunkFirst)) + w.dictRegion = appendUvarint(w.dictRegion, uint64(len(w.dictRaw))) + w.dictRegion = appendUvarint(w.dictRegion, uint64(len(comp))) + w.dictRegion = append(w.dictRegion, comp...) + w.dictRaw = w.dictRaw[:0] +} + +// ---- segment reader -------------------------------------------------------- + +type segment struct { + f *os.File + id uint64 // seal-sequence id (== seg-%06d.dat); the chunk-LRU key (P5) + dataCodec, dictCodec *codec + idx []blockEntry + biOff, dictOff int64 + path string + minDocid, maxDocid int64 // forward-record docid span (B); set from segMeta on Open / at seal + dictChunks []dictChunk // built lazily for resolve (P3 index mode) + dictOnce sync.Once // guards the one-time, build-once-read-only dictChunks init + + // Concurrency (P9/T8, concurrency.go): the live snapshot holds one PUBLISHED ref per segment; + // a reader bumps an extra ref for its scan. retired is set when a merge drops the segment from + // the live set (or Close drops the whole set); when refs reaches zero on a retired segment it is + // torn down (close fd, and — UNLESS keepFile is set — unlink the file). keepFile distinguishes a + // Close-retire (just close the fd; the file must survive for the next Open) from a merge-retire + // (close + unlink the merged-away file). tornDown makes teardown idempotent under a reader/worker + // race to the final decref. + refs atomic.Int64 + retired atomic.Bool + keepFile atomic.Bool + tornDown atomic.Bool +} + +// emptyDocidRange is the inverted "no forward records" span: min > max, so coversDocid is always +// false and forwardKeywords always skips the segment (spec §4 item B). +func emptyDocidRange() (min, max int64) { return math.MaxInt64, math.MinInt64 } + +// coversDocid reports whether a forward record for docid could exist in this segment. +func (s *segment) coversDocid(docid int64) bool { return docid >= s.minDocid && docid <= s.maxDocid } + +// dictChunk locates one compressed term-dict chunk for on-demand (index-mode) resolution. +type dictChunk struct { + firstOrd uint32 + compOff int64 + compLen int + rawLen int +} + +// openSegment opens a finished segment file and parses the 25-byte footer + block index. +// (port spike main.go:770-797: 25-byte footer with BOTH codec ids; firstKey is []byte.) +func openSegment(path string) *segment { + f, _ := os.Open(path) + fi, _ := f.Stat() + sz := fi.Size() + foot := make([]byte, 25) + mustReadAt(f, foot, sz-25) + biOff := int64(binary.BigEndian.Uint64(foot[0:8])) + dictOff := int64(binary.BigEndian.Uint64(foot[8:16])) + s := &segment{f: f, dataCodec: newCodec(foot[16]), dictCodec: newCodec(foot[17]), + biOff: biOff, dictOff: dictOff, path: path} + // parse block index [biOff, sz-25) + bi := make([]byte, sz-25-biOff) + mustReadAt(f, bi, biOff) + p := 0 + nb, n := binary.Uvarint(bi[p:]) + p += n + s.idx = make([]blockEntry, 0, nb) + for i := uint64(0); i < nb; i++ { + fkl, n := binary.Uvarint(bi[p:]) + p += n + fk := append([]byte(nil), bi[p:p+int(fkl)]...) + p += int(fkl) + off, n := binary.Uvarint(bi[p:]) + p += n + s.idx = append(s.idx, blockEntry{fk, int64(off)}) + } + return s +} + +func (s *segment) close() { s.f.Close() } + +// blockBytes reads & decompresses data block i. (port spike main.go:799-807.) +func (s *segment) blockBytes(i int) []byte { + return s.blockBytesInto(nil, i) +} + +// blockBytesInto reads & decompresses data block i, reusing dst's backing array for the decompressed +// output when it fits (C.2: the mergeCursor hands its previous block buffer so a k-way merge over K +// sources allocates O(K) block buffers, not one per block). The returned slice may alias dst, so the +// caller MUST NOT retain bytes from a prior block into the same dst (the writer's blkFirst-copy in +// addEntry enforces this for the merge path). The compressed-read scratch (comp) is still allocated +// per call — only the larger decompressed buffer is reused. +func (s *segment) blockBytesInto(dst []byte, i int) []byte { + hdr := make([]byte, 20) + s.f.ReadAt(hdr, s.idx[i].off) + rl, n := binary.Uvarint(hdr) + cl, n2 := binary.Uvarint(hdr[n:]) + comp := make([]byte, cl) + mustReadAt(s.f, comp, s.idx[i].off+int64(n+n2)) + return s.dataCodec.decompressInto(dst, comp, int(rl)) +} + +// readExternal reads & decompresses an external value's chunks. (port spike main.go:817-830.) +func (s *segment) readExternal(off int64, compLen int) []byte { + buf := make([]byte, compLen) + mustReadAt(s.f, buf, off) + var raw []byte + for p := 0; p < len(buf); { + rl, n := binary.Uvarint(buf[p:]) + p += n + cl, n2 := binary.Uvarint(buf[p:]) + p += n2 + raw = append(raw, s.dataCodec.decompress(buf[p:p+int(cl)], int(rl))...) + p += int(cl) + } + return raw +} + +// scanBlock parses records of a decompressed block. fn gets (key, inlineValue OR external +// pointer). Returning false stops. (port spike main.go:834-860.) +func scanBlock(blk []byte, fn func(key, inline []byte, extOff int64, extLen int, external bool) bool) { + for p := 0; p < len(blk); { + kl, n := binary.Uvarint(blk[p:]) + p += n + key := blk[p : p+int(kl)] + p += int(kl) + flag := blk[p] + p++ + if flag == 0 { + vl, n2 := binary.Uvarint(blk[p:]) + p += n2 + val := blk[p : p+int(vl)] + p += int(vl) + if !fn(key, val, 0, 0, false) { + return + } + } else { + off, n2 := binary.Uvarint(blk[p:]) + p += n2 + cl, n3 := binary.Uvarint(blk[p:]) + p += n3 + if !fn(key, nil, int64(off), int(cl), true) { + return + } + } + } +} + +// value resolves a record to its value bytes. (port spike main.go:863-868.) +func (s *segment) value(inline []byte, extOff int64, extLen int, external bool) []byte { + if external { + return s.readExternal(extOff, extLen) + } + return inline +} + +// scanPrefix visits records whose key has the prefix [lo, hi). (port spike main.go:871-896; +// firstKey/key comparisons are now byte-wise.) +func (s *segment) scanPrefix(lo, hi []byte, fn func(key, value []byte)) { + start := sort.Search(len(s.idx), func(i int) bool { return bytes.Compare(s.idx[i].firstKey, lo) > 0 }) - 1 + if start < 0 { + start = 0 + } + for bi := start; bi < len(s.idx); bi++ { + if hi != nil && bytes.Compare(s.idx[bi].firstKey, hi) >= 0 { + break + } + blk := s.blockBytes(bi) + stop := false + scanBlock(blk, func(key, inline []byte, extOff int64, extLen int, external bool) bool { + if hi != nil && bytes.Compare(key, hi) >= 0 { + stop = true + return false + } + if hasPrefixBytes(key, lo) { + fn(key, s.value(inline, extOff, extLen, external)) + } + return true + }) + if stop { + break + } + } +} + +// lookupForward point-reads the forward value for a forward key (one record), if present. +// (port spike main.go:899-909; takes the full []byte forward key.) +func (s *segment) lookupForward(key []byte) ([]byte, bool) { + lo := key + hi := prefixUpper(lo) + var out []byte + found := false + s.scanPrefix(lo, hi, func(key, value []byte) { + out = append([]byte(nil), value...) + found = true + }) + return out, found +} + +// ensureDictIndex (index mode) scans only the term-dict chunk HEADERS (firstOrd, offset, +// lengths) — tiny, no strings held — so a resolve decompresses just the chunks holding the +// requested ordinals. Bounded memory. (port spike main.go:960-979.) +// +// The build runs exactly once under a sync.Once: dictChunks is append-once here and read-only +// thereafter, so concurrent resolves (P5 forwardKeywords, and the concurrent readers T8 will +// publish) all observe a fully-built, immutable slice. Once.Do establishes the happens-before +// that makes the populated dictChunks visible to every caller before Do returns. +func (s *segment) ensureDictIndex() { + if s.dictOff == 0 { + return + } + s.dictOnce.Do(func() { + hdr := make([]byte, 30) + for pos := s.dictOff; pos < s.biOff; { + mustReadAt(s.f, hdr, pos) + p := 0 + fo, a := binary.Uvarint(hdr[p:]) + p += a + rl, b := binary.Uvarint(hdr[p:]) + p += b + cl, c := binary.Uvarint(hdr[p:]) + p += c + compOff := pos + int64(p) + s.dictChunks = append(s.dictChunks, dictChunk{uint32(fo), compOff, int(cl), int(rl)}) + pos = compOff + int64(cl) + } + }) +} + +// resolveOrds maps requested term-id ordinals -> keyword strings via the term-dict chunk +// index (decompress only the chunks holding requested ordinals, with the dictCodec). This +// is the no-cache resolve; the Store-level chunk LRU is a later task (design T3) that wraps +// it. (port spike main.go:981-1013 resolveOrdsIndex, renamed resolveOrds.) +func (s *segment) resolveOrds(need map[uint32]struct{}) map[uint32]string { + s.ensureDictIndex() + res := make(map[uint32]string, len(need)) + byChunk := map[int][]uint32{} + for o := range need { + i := sort.Search(len(s.dictChunks), func(i int) bool { return s.dictChunks[i].firstOrd > o }) - 1 + if i < 0 { + continue + } + byChunk[i] = append(byChunk[i], o) + } + for ci, ords := range byChunk { + c := s.dictChunks[ci] + comp := make([]byte, c.compLen) + mustReadAt(s.f, comp, c.compOff) + raw := s.dictCodec.decompress(comp, c.rawLen) + want := make(map[uint32]struct{}, len(ords)) + for _, o := range ords { + want[o] = struct{}{} + } + cur := c.firstOrd + for q := 0; q < len(raw); { + kl, m := binary.Uvarint(raw[q:]) + q += m + if _, ok := want[cur]; ok { + res[cur] = string(raw[q : q+int(kl)]) + } + q += int(kl) + cur++ + } + } + return res +} + +// mustReadAt reads exactly len(b) bytes at off, panicking on a short/failed read (a corrupt +// segment is unrecoverable here; the reader API gains error returns in a later task). +func mustReadAt(f *os.File, b []byte, off int64) { + if _, err := f.ReadAt(b, off); err != nil { + panic(err) + } +} diff --git a/core/invertedstore/segment_inline_dict_test.go b/core/invertedstore/segment_inline_dict_test.go new file mode 100644 index 0000000..397a78b --- /dev/null +++ b/core/invertedstore/segment_inline_dict_test.go @@ -0,0 +1,106 @@ +package invertedstore + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +var dictKws = []string{"alpha", "beta", "delta", "gamma", "kappa", "omega", "zeta"} + +// writeDictSegment builds a small term-id segment: the 7 sorted [I] keys + one [F] record (which must +// NOT enter the dict). blockTarget 64 forces multiple data blocks so the old re-read decompresses >1. +func writeDictSegment(path string, dictChunk int) *segWriter { + w := newSegWriter(path, newCodec(codecSnappy), newCodec(codecZstd), 64, 1<<16, 1<<10, true, dictChunk) + tid := uint32(7) + for _, kw := range dictKws { + w.addEntry(invertedKey(tid, kw), encodeInvertedValue([]int64{1}, nil)) + } + w.addEntry(forwardKey(tid, 1), encodeForward([]uint32{0, 1, 2, 3, 4, 5, 6})) + return w +} + +// rereadDictRegion independently reconstructs the expected term-dict region bytes by scanning the +// segment's own [I] data blocks in order — the SAME bytes finish() must produce inline. Oracle: it +// shares no code with the inline builder under test. +func rereadDictRegion(t *testing.T, s *segment, dictChunk int, dict *codec) []byte { + t.Helper() + var region, chunk []byte + var ord, chunkFirst uint32 + flush := func() { + if len(chunk) == 0 { + return + } + comp := dict.compress(chunk) + region = appendUvarint(region, uint64(chunkFirst)) + region = appendUvarint(region, uint64(len(chunk))) + region = appendUvarint(region, uint64(len(comp))) + region = append(region, comp...) + chunk = chunk[:0] + } + for i := range s.idx { + scanBlock(s.blockBytes(i), func(key, _ []byte, _ int64, _ int, _ bool) bool { + if key[0] != ktInverted { + return true + } + if len(chunk) == 0 { + chunkFirst = ord + } + kw := key[5:] + chunk = appendUvarint(chunk, uint64(len(kw))) + chunk = append(chunk, kw...) + ord++ + if len(chunk) >= dictChunk { + flush() + } + return true + }) + } + flush() + return region +} + +// THE GENUINE RED: finish() must decompress zero data blocks (no re-read). Fails before F0 (the +// re-read decompresses every [I] block), passes after, and persists (a re-introduced re-read decompresses). +func TestInlineDict_FinishDecompressesNoDataBlocks(t *testing.T) { + path := filepath.Join(t.TempDir(), "seg-000001.dat") + w := writeDictSegment(path, 8) + var decompresses int + onDecompress = func() { decompresses++ } + t.Cleanup(func() { onDecompress = nil }) + seg := w.finish(path) + onDecompress = nil // stop before any read-path decompress + defer seg.close() + if decompresses != 0 { + t.Fatalf("finish() decompressed %d data blocks (re-read path); the inline dict build must decompress 0", decompresses) + } +} + +// Correctness net: the on-disk dict region byte-equals the independent oracle, and every ordinal +// round-trips to its keyword. (Passes against both old + new code — it pins format, not behavior.) +func TestInlineDict_RegionByteIdenticalToReread(t *testing.T) { + path := filepath.Join(t.TempDir(), "seg-000001.dat") + dictChunk := 8 + w := writeDictSegment(path, dictChunk) + seg := w.finish(path) + defer seg.close() + + want := rereadDictRegion(t, seg, dictChunk, seg.dictCodec) + got := make([]byte, seg.biOff-seg.dictOff) + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + mustReadAt(f, got, seg.dictOff) + if !bytes.Equal(got, want) { + t.Fatalf("inline dict region (%d B) != oracle (%d B)", len(got), len(want)) + } + res := seg.resolveOrds(map[uint32]struct{}{0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {}}) + for i, kw := range dictKws { + if res[uint32(i)] != kw { + t.Fatalf("ord %d resolved %q, want %q", i, res[uint32(i)], kw) + } + } +} diff --git a/core/invertedstore/segment_test.go b/core/invertedstore/segment_test.go new file mode 100644 index 0000000..330d89b --- /dev/null +++ b/core/invertedstore/segment_test.go @@ -0,0 +1,141 @@ +package invertedstore + +import ( + "os" + "path/filepath" + "sort" + "testing" +) + +// writeTestSeg writes a segment with [I] keyword records (postings) and [F] forward records +// (term-ids), term-id mode on, then returns the opened segment. +func writeTestSeg(t *testing.T, termid bool) *segment { + t.Helper() + path := filepath.Join(t.TempDir(), "seg00001.dat") + w := newSegWriter(path, newCodec(codecSnappy), newCodec(codecZstd), 32768, 65536, 1024, termid, 4096) + // term dict (ordinal order) = sorted inverted keywords: alpha=0, beta=1, gamma=2 + terms := []string{"alpha", "beta", "gamma"} + for i, kw := range terms { + w.addEntry(invertedKey(1, kw), encodeInvertedValue([]int64{int64(i + 10)}, nil)) + } + // forward: doc 10 has {alpha(0), gamma(2)}; doc 11 deleted (tombstone) + if termid { + w.addEntry(forwardKey(1, 10), encodeForward([]uint32{0, 2})) + } + w.addEntry(forwardKey(1, 11), forwardTombstone()) + return w.finish(path) +} + +func TestSegmentRoundTrip(t *testing.T) { + s := writeTestSeg(t, true) + defer s.close() + // footer carries both codec ids + if s.dataCodec.id != codecSnappy || s.dictCodec.id != codecZstd { + t.Fatalf("footer codec ids wrong: data=%d dict=%d", s.dataCodec.id, s.dictCodec.id) + } + // prefix scan for keyword "beta" finds exactly doc 11's posting + lo := invertedKey(1, "beta") + hi := prefixUpper(lo) + var hits []int64 + s.scanPrefix(lo, hi, func(_ []byte, val []byte) { + ab, _ := splitInvertedValue(val) + decodeDocs(ab, func(d int64) { hits = append(hits, d) }) + }) + if len(hits) != 1 || hits[0] != 11 { + t.Fatalf("scanPrefix(beta) = %v, want [11]", hits) + } + // forward point lookup + term-id resolve: doc 10 → {alpha, gamma} + val, ok := s.lookupForward(forwardKey(1, 10)) + if !ok { + t.Fatal("forward lookup miss for doc 10") + } + ords, deleted := decodeForward(val) + if deleted { + t.Fatal("doc 10 wrongly read as deleted") + } + need := map[uint32]struct{}{} + for _, o := range ords { + need[o] = struct{}{} + } + got := s.resolveOrds(need) // ord -> keyword via term-dict region + words := []string{got[0], got[2]} + sort.Strings(words) + if words[0] != "alpha" || words[1] != "gamma" { + t.Fatalf("resolve = %v, want [alpha gamma]", words) + } + // doc 11 forward is a tombstone + tval, _ := s.lookupForward(forwardKey(1, 11)) + if _, del := decodeForward(tval); !del { + t.Fatal("doc 11 should read as deleted") + } +} + +func TestSegmentGoldenFooter(t *testing.T) { + s := writeTestSeg(t, true) + defer s.close() + fi, err := s.f.Stat() + if err != nil { + t.Fatalf("stat: %v", err) + } + sz := fi.Size() + foot := make([]byte, 25) + if _, err := s.f.ReadAt(foot, sz-25); err != nil { + t.Fatalf("read footer: %v", err) + } + if string(foot[18:25]) != "SRSEG\x00\x00" { + t.Fatalf("footer magic wrong: % x", foot[18:25]) + } + if foot[16] != codecSnappy { + t.Fatalf("footer data codec id = %d, want %d", foot[16], codecSnappy) + } + if foot[17] != codecZstd { + t.Fatalf("footer dict codec id = %d, want %d", foot[17], codecZstd) + } +} + +// TestNewSegWriterDefaultsDictChunk: passing dictChunk <= 0 defaults it to blockTarget (the +// term-dict chunk size falls back to the block size when the caller does not size it). +func TestNewSegWriterDefaultsDictChunk(t *testing.T) { + path := filepath.Join(t.TempDir(), "seg-defaultdict.dat") + const blockTarget = 4096 + w := newSegWriter(path, newCodec(codecSnappy), newCodec(codecZstd), blockTarget, 65536, 1024, true, 0) + defer func() { _ = w.f.Close() }() + if w.dictChunk != blockTarget { + t.Fatalf("dictChunk=%d, want blockTarget %d when dictChunk<=0", w.dictChunk, blockTarget) + } +} + +// TestNewSegWriterPanicsOnUncreatablePath: newSegWriter cannot recover from a failed os.Create (a +// corrupt/unwritable dir is unrecoverable at seal time), so it panics — here the path's parent +// directory does not exist. +func TestNewSegWriterPanicsOnUncreatablePath(t *testing.T) { + badPath := filepath.Join(t.TempDir(), "no-such-subdir", "seg.dat") + defer func() { + if r := recover(); r == nil { + t.Fatal("newSegWriter should panic when os.Create fails") + } + }() + newSegWriter(badPath, newCodec(codecSnappy), newCodec(codecZstd), 4096, 65536, 1024, true, 4096) +} + +// TestMustReadAtPanicsOnShortRead: mustReadAt requires an exact-length read at off; a read past the +// end of the file (nothing there) returns io.EOF, which mustReadAt turns into a panic (a corrupt +// segment is unrecoverable at this layer). +func TestMustReadAtPanicsOnShortRead(t *testing.T) { + path := filepath.Join(t.TempDir(), "tiny.bin") + if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + defer func() { + if r := recover(); r == nil { + t.Fatal("mustReadAt should panic on a short/EOF read past the file end") + } + }() + // Read 16 bytes starting at offset 100, well past the 2-byte file → io.EOF, zero bytes read. + mustReadAt(f, make([]byte, 16), 100) +} diff --git a/core/invertedstore/segmeta_postings_test.go b/core/invertedstore/segmeta_postings_test.go new file mode 100644 index 0000000..98c2f5b --- /dev/null +++ b/core/invertedstore/segmeta_postings_test.go @@ -0,0 +1,66 @@ +package invertedstore + +import "testing" + +// decodeInvertedEntryCount is the independent oracle for segMeta.Postings: it decodes every live +// segment's [I] records and sums their (add+del) docids — so the field is cross-checked against the +// real bytes, not a hand-computed constant. +func decodeInvertedEntryCount(t *testing.T, s *Store) int64 { + t.Helper() + s.mu.RLock() + segs := append([]*segment(nil), s.segs...) + s.mu.RUnlock() + var n int64 + for _, seg := range segs { + c := newMergeCursor(seg) + for !c.done { + if keyType(c.key) == ktInverted { + adds, dels := splitInvertedValue(c.val) + decodeDocs(adds, func(int64) { n++ }) + decodeDocs(dels, func(int64) { n++ }) + } + c.advance() + } + } + return n +} + +// A spilled segment's Postings equals the inverted (add+del) entries it stores — verified by +// decoding the segment, not asserting a hand constant (spec §8.8). +func TestSegMetaPostings_DecodeCrossCheck(t *testing.T) { + s, tid := newUpdateStore(t) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + s.applyForTest(tid, 1, []string{"a", "b", "c"}) + s.applyForTest(tid, 2, []string{"b", "c"}) + s.spillForTest(tid) + + var metaSum int64 + for _, sm := range s.SegmentsForTest() { + metaSum += sm.Postings + } + if oracle := decodeInvertedEntryCount(t, s); metaSum != oracle { + t.Fatalf("Σ segMeta.Postings = %d, decoded inverted entries = %d", metaSum, oracle) + } + if metaSum != 5 { // 3 (doc1) + 2 (doc2) adds, 0 dels + t.Fatalf("Σ Postings = %d, want 5", metaSum) + } +} + +// A covering merge that reclaims everything (all docs deleted) writes an output segment with +// Postings 0 — the terminal state the deadFraction `written <= 0 → 0` guard rests on (spec §8.8). +func TestSegMetaPostings_EmptyCoveringOutput(t *testing.T) { + s, tid := newUpdateStore(t) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + s.applyForTest(tid, 1, []string{"a", "b"}) + s.spillForTest(tid) + s.Update(tid, 1, nil) // real delete path: reads old={a,b} from the segment, tombstones them + s.sync() + s.spillForTest(tid) + s.coveringMergeForTest(t) // drops fully-tombstoned keys + forward-tombstone -> empty output + + for _, sm := range s.SegmentsForTest() { + if sm.Postings != 0 { + t.Fatalf("empty covering output Postings = %d, want 0", sm.Postings) + } + } +} diff --git a/core/invertedstore/segmeta_sort_test.go b/core/invertedstore/segmeta_sort_test.go new file mode 100644 index 0000000..a48ed7a --- /dev/null +++ b/core/invertedstore/segmeta_sort_test.go @@ -0,0 +1,48 @@ +package invertedstore + +import ( + "reflect" + "testing" +) + +// ids extracts the .Id sequence of a segMeta slice for order assertions. +func ids(metas []segMeta) []uint64 { + out := make([]uint64, len(metas)) + for i, m := range metas { + out[i] = m.Id + } + return out +} + +// TestSortSegMetasByIdOrdersAscending pins the in-place insertion sort that orders segMetas +// oldest->newest by Id. Its production callers are timing-dependent merge paths, so this direct, +// deterministic unit test forces the swap and early-stop branches every run (independent of merge +// ordering): {3,1,2} needs two swaps to become {1,2,3} and its second insertion hits the +// metas[j-1].Id <= metas[j].Id false-condition early stop, while the single/empty cases cover the +// len<2 no-op (the outer loop never runs). +func TestSortSegMetasByIdOrdersAscending(t *testing.T) { + cases := []struct { + name string + in []uint64 + want []uint64 + }{ + {"unsorted forces swaps and early stop", []uint64{3, 1, 2}, []uint64{1, 2, 3}}, + {"already sorted stays put", []uint64{1, 2, 3}, []uint64{1, 2, 3}}, + {"reverse fully sorts", []uint64{5, 4, 3, 2, 1}, []uint64{1, 2, 3, 4, 5}}, + {"single element no-op", []uint64{5}, []uint64{5}}, + {"empty no-op", []uint64{}, []uint64{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + metas := make([]segMeta, len(tc.in)) + for i, id := range tc.in { + metas[i] = segMeta{Id: id} + } + sortSegMetasById(metas) + got := ids(metas) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("sortSegMetasById(%v) got Id order %v, want %v", tc.in, got, tc.want) + } + }) + } +} diff --git a/core/invertedstore/spill_offworker_test.go b/core/invertedstore/spill_offworker_test.go new file mode 100644 index 0000000..1c536bf --- /dev/null +++ b/core/invertedstore/spill_offworker_test.go @@ -0,0 +1,559 @@ +package invertedstore + +import ( + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/codetrek/haystack/core/queue" +) + +// newSpillOffworkerStore opens a store with the given Options and one table (mirrors +// newForwardSkipStore), so a test can drive the async detach/encode/install path of F (v5). +func newSpillOffworkerStore(t *testing.T, opts Options) (*Store, int) { + t.Helper() + q := queue.NewMpsc("spilloffworker") + q.Start() + s, err := Open(t.TempDir(), q, opts) + if err != nil { + t.Fatal(err) + } + // Drain any in-flight off-worker spill goroutine before t.TempDir() is removed, so a leaked encode + // never panics writing into the deleted dir. Registered before CreateTable so it runs (LIFO) after + // the test body's own cleanups (e.g. closing a parked encode's release channel). + t.Cleanup(s.WaitSpillsForTest) + tid, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + return s, tid +} + +// drain blocks until every task enqueued on the worker so far has run (a no-op RunFunc returns only +// after all earlier-enqueued closures completed). +func (s *Store) drainForTest() { s.q.RunFunc(func() error { return nil }) } + +// parkEncode installs an encodeSpillBlock that signals `entered` (buffered, every entry) and blocks on +// `release`. unpark clears the hook and closes release (idempotent), so a Close-time drain is not +// re-parked. The encodeSpillBlock package global means NO test using this may call t.Parallel. +func parkEncode(t *testing.T) (entered chan struct{}, release chan struct{}, unpark func()) { + t.Helper() + entered = make(chan struct{}, 64) + release = make(chan struct{}) + var once sync.Once + unpark = func() { + encodeSpillBlock = nil + once.Do(func() { close(release) }) + } + encodeSpillBlock = func() { + select { + case entered <- struct{}{}: + default: + } + <-release + } + return entered, release, unpark +} + +// TestSpillF_B1_RepostAfterDetachTombstonesDropped is THE B1 zero-concurrency silent-corruption gate. +// With a tiny CapBytes the first Update over-caps the head, so F detaches it for an OFF-WORKER encode +// (parked here via encodeSpillBlock) and pushes it onto s.spilling. A SECOND Update for the SAME doc +// that drops a keyword must diff against the doc's OLD keyword set — which now lives ONLY in the +// detached (spilling) head — so the dropped keyword is tombstoned. If forwardKeywords did not consult +// the spilling tier, the re-post would diff against an empty `old`, drop no tombstone, and the dropped +// keyword would resurrect (silent corruption, ZERO concurrency). Run -count=20 for determinism. +func TestSpillF_B1_RepostAfterDetachTombstonesDropped(t *testing.T) { + s, tbl := newSpillOffworkerStore(t, Options{CapBytes: 64}) + // Release the store's segment fds at test end (Windows RemoveAll cannot delete seg-*.dat while a + // handle is open). Registered here so it runs (LIFO) AFTER the drain+hook-clear cleanup below — by + // then close(release) has fired in the body, so the close-time flush never blocks on the parked encode. + t.Cleanup(func() { s.CloseAndWait() }) + + // Park the off-worker encode so the detached head STAYS in s.spilling across the re-post. + release := make(chan struct{}) + parked := make(chan struct{}, 1) + encodeSpillBlock = func() { + select { + case parked <- struct{}{}: + default: + } + <-release + } + // DRAIN-FIRST cleanup (-race): under H's +8/op accounting the head re-caps sooner, so a re-dispatched + // spill goroutine may still be reading encodeSpillBlock at head.go:346. Drain every in-flight spill + // FIRST (the test body has close(release)d, so a re-dispatched encode returns immediately), THEN nil + // the hook — never nil it while a spill goroutine reads it. Registering this in the SAME t.Cleanup + // keeps the two steps ordered (a bare `encodeSpillBlock = nil` cleanup runs LIFO before the store's + // own WaitSpillsForTest drain and races the live read). + t.Cleanup(func() { + s.WaitSpillsForTest() + encodeSpillBlock = nil + }) + + // First post: [alpha, beta]. The tiny cap over-caps the head ⇒ async detach (encode parks). + s.Update(tbl, 1, []string{"alpha", "beta"}) + select { + case <-parked: + case <-time.After(5 * time.Second): + t.Fatal("encode never parked — the over-cap head was not detached for an off-worker encode") + } + // Assert the ASYNC branch was taken (fail loud if it silently took a sync spill path). + if n := s.SpillingLenForTest(); n != 1 { + t.Fatalf("len(s.spilling) = %d after detach, want 1 (the async detach branch must be taken)", n) + } + + // Second post for the SAME doc, dropping beta. forwardKeywords must read doc 1's old [alpha,beta] + // from the spilling tier so beta is tombstoned. + s.Update(tbl, 1, []string{"alpha"}) + s.drainForTest() + + // Release the parked encode and let the install complete. + close(release) + s.drainForTest() + // Bounce the worker once more so a re-dispatched install RunFunc lands. + s.drainForTest() + + if got := searchDocidsForTest(t, s, tbl, "beta"); len(got) != 0 { + t.Fatalf("doc still searchable under dropped keyword 'beta' = %v, want [] (forwardKeywords must read the detached head via spilling)", got) + } + if got := searchDocidsForTest(t, s, tbl, "alpha"); len(got) != 1 || got[0] != 1 { + t.Fatalf("doc 1 not searchable under retained keyword 'alpha' = %v, want [1]", got) + } +} + +// maxSegIdForTest returns the highest live segment id (the newest segment), or 0 if none. +func (s *Store) maxSegIdForTest() uint64 { + var mx uint64 + for _, sm := range s.SegmentsForTest() { + if sm.Id > mx { + mx = sm.Id + } + } + return mx +} + +// TestSpillF_InstallTimeId_NewestWins proves the v5 install-time id: a head detached for an off-worker +// encode is the NEWEST data, so when it installs AFTER a concurrent merge that ran while it was parked, +// it must get a HIGHER id (newest) and its newer tombstone must NOT be resurrected by the older merge +// output. With detach-time ids (the v4 defect) the merge would reserve a higher id than the parked +// spill and outrank it, resurrecting the dropped keyword. +func TestSpillF_InstallTimeId_NewestWins(t *testing.T) { + s, tbl := newSpillOffworkerStore(t, Options{CapBytes: 1 << 20, Fanout: 2, AutoMerge: true}) + t.Cleanup(func() { s.CloseAndWait() }) + + // Seal two L0 segments where doc 1 has keyword "alpha" (so an older tier holds the add). Fanout=2 + // makes these two eligible for a tiered merge. + s.applyForTest(tbl, 1, []string{"alpha"}) + s.spillForTest(tbl) + s.applyForTest(tbl, 2, []string{"alpha"}) + s.spillForTest(tbl) + if n := len(s.SegmentsForTest()); n != 2 { + t.Fatalf("want 2 sealed segments, got %d", n) + } + idBeforeSpill := s.maxSegIdForTest() + + // Re-post doc 1 dropping "alpha" (tombstone), then detach that head for an off-worker encode (parked). + encEntered, _, unparkEnc := parkEncode(t) + t.Cleanup(unparkEnc) + s.q.RunFunc(func() error { + s.mu.Lock() + h := s.head[tbl] + if h == nil { + h = newHeadTable() + s.head[tbl] = h + } + h.tombstonePosting("alpha", 1) + h.setForward(1, nil) + e := s.detachHeadLocked(tbl) + s.mu.Unlock() + s.dispatchSpill(e) + return nil + }) + select { + case <-encEntered: + case <-time.After(5 * time.Second): + t.Fatal("spill encode never parked") + } + if s.SpillingLenForTest() != 1 { + t.Fatalf("want exactly 1 parked spill, got %d", s.SpillingLenForTest()) + } + + // While the spill is parked, run a tiered merge of the two L0 segments ON the worker (the merge + // carries "alpha" for doc 1 forward; it reserves+assigns an id NOW). Its install-time id is lower + // than the spill's, which installs LATER. + s.mergeOneLevelForTest(t) + idAfterMerge := s.maxSegIdForTest() + if idAfterMerge <= idBeforeSpill { + t.Fatalf("merge should have produced a new segment id > %d, got %d", idBeforeSpill, idAfterMerge) + } + + // Release the parked encode and let the spill install. + unparkEnc() + s.WaitSpillsForTest() + + // The spill (newest data) must have the HIGHEST id (installed after the merge). + idAfterSpill := s.maxSegIdForTest() + if idAfterSpill <= idAfterMerge { + t.Fatalf("parked spill must install with the highest id (newest): merge id=%d, spill id=%d", idAfterMerge, idAfterSpill) + } + // The spill's newer tombstone of "alpha" for doc 1 wins over the merge's older add ⇒ doc 1 is NOT + // resurrected. (Doc 2 still legitimately holds "alpha", so the result is [2], never containing 1.) + got := searchDocidsForTest(t, s, tbl, "alpha") + for _, d := range got { + if d == 1 { + t.Fatalf("doc 1 resurrected under 'alpha' (result %v) — an older merge output outranked the newer spill (install-time id broken)", got) + } + } +} + +// TestSpillF_GateBoundAndLiveness_SingleTable drives a fast producer against an artificially-slowed +// encode (parked, released in stages): at most ONE spill is ever in flight (peak len(s.spilling) ≤ 1), +// the producer parks at the blockProducer gate, and once each install lands the over-cap head is +// re-dispatched so the build CONVERGES (no wedge) — all timeout-guarded. +func TestSpillF_GateBoundAndLiveness_SingleTable(t *testing.T) { + s, tbl := newSpillOffworkerStore(t, Options{CapBytes: 64}) + t.Cleanup(func() { s.CloseAndWait() }) + + // Encodes signal entry (buffered) and block until released one at a time, so we can hold a spill in + // flight while the producer keeps posting. + gate := make(chan struct{}) // one token per release + entered := make(chan struct{}, 256) + encodeSpillBlock = func() { + entered <- struct{}{} + <-gate + } + t.Cleanup(func() { encodeSpillBlock = nil }) + + // A producer goroutine floods distinct over-cap docs; each Update may park at the gate. + const n = 40 + prodDone := make(chan struct{}) + go func() { + for d := int64(1); d <= n; d++ { + s.Update(tbl, d, []string{uniqWord(int(d)), "shared"}) + } + close(prodDone) + }() + + // Release encodes one at a time; after EACH, assert ≤ 1 spill is ever in flight, until the producer + // finishes and all spills drain. + deadline := time.After(20 * time.Second) + for { + select { + case <-entered: + // A spill parked: there must be at most one detached head outstanding. + if got := s.SpillingLenForTest(); got > 1 { + t.Fatalf("peak len(s.spilling) = %d, want <= 1 (one-in-flight violated)", got) + } + gate <- struct{}{} // let this encode proceed to install + case <-deadline: + t.Fatal("build did not converge — producer wedged at the gate (no re-dispatch / lost wakeup)") + default: + select { + case <-prodDone: + // Producer done; drain any remaining in-flight spill (release pending encodes). + go func() { + for { + select { + case <-entered: + gate <- struct{}{} + case <-time.After(200 * time.Millisecond): + return + } + } + }() + s.WaitSpillsForTest() + // Every doc is searchable under "shared" (across installed segments + head) ⇒ converged. + if got := searchDocidsForTest(t, s, tbl, "shared"); len(got) != n { + t.Fatalf("after convergence, 'shared' has %d docs, want %d", len(got), n) + } + return + default: + time.Sleep(time.Millisecond) + } + } + } +} + +// TestSpillF_GateBoundAndLiveness_MultiTable is the multi-table liveness gate: a table-B head that goes +// over-cap WHILE a table-A spill is in flight must be found + re-dispatched by installSpill's +// re-check-ALL-tables (not just the just-installed table), or the multi-table workload wedges. +func TestSpillF_GateBoundAndLiveness_MultiTable(t *testing.T) { + s, tblA := newSpillOffworkerStore(t, Options{CapBytes: 64}) + t.Cleanup(func() { s.CloseAndWait() }) + tblB, err := s.CreateTable("filesB") + if err != nil { + t.Fatal(err) + } + + gate := make(chan struct{}) + entered := make(chan struct{}, 256) + encodeSpillBlock = func() { + entered <- struct{}{} + <-gate + } + t.Cleanup(func() { encodeSpillBlock = nil }) + + const n = 20 + prodDone := make(chan struct{}) + go func() { + // Interleave the two tables so a B head fills while an A spill is in flight (and vice-versa). + for d := int64(1); d <= n; d++ { + s.Update(tblA, d, []string{uniqWord(1000 + int(d)), "shA"}) + s.Update(tblB, d, []string{uniqWord(2000 + int(d)), "shB"}) + } + close(prodDone) + }() + + deadline := time.After(20 * time.Second) + drained := false + for !drained { + select { + case <-entered: + if got := s.SpillingLenForTest(); got > 1 { + t.Fatalf("peak len(s.spilling) = %d across two tables, want <= 1", got) + } + gate <- struct{}{} + case <-deadline: + t.Fatal("multi-table build wedged — a different-table over-cap head was not re-dispatched") + default: + select { + case <-prodDone: + go func() { + for { + select { + case <-entered: + gate <- struct{}{} + case <-time.After(200 * time.Millisecond): + return + } + } + }() + s.WaitSpillsForTest() + drained = true + default: + time.Sleep(time.Millisecond) + } + } + } + if got := searchDocidsForTest(t, s, tblA, "shA"); len(got) != n { + t.Fatalf("table A 'shA' has %d docs after convergence, want %d", len(got), n) + } + if got := searchDocidsForTest(t, s, tblB, "shB"); len(got) != n { + t.Fatalf("table B 'shB' has %d docs after convergence, want %d", len(got), n) + } +} + +// TestSpillF_CloseAndWaitDrainsInFlightEncode: with the in-flight encode blocked then released, +// CloseAndWait returns within a timeout (the drain is OFF the worker, no v4 self-deadlock) and the doc +// is durable on reopen. +func TestSpillF_CloseAndWaitDrainsInFlightEncode(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("spillclose") + q.Start() + s, err := Open(dir, q, Options{CapBytes: 64}) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + entered, _, unpark := parkEncode(t) + // Over-cap the head so it detaches + the encode parks. + s.Update(tbl, 1, []string{"alpha", "beta", "gamma"}) + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("encode never parked") + } + + // Release the encode shortly, then CloseAndWait must return within a timeout (off-worker drain). + go func() { + time.Sleep(50 * time.Millisecond) + unpark() + }() + closed := make(chan struct{}) + go func() { s.CloseAndWait(); close(closed) }() + select { + case <-closed: + case <-time.After(10 * time.Second): + t.Fatal("CloseAndWait did not return — in-flight encode drain self-deadlocked (v4 regression)") + } + q.Stop() + + // Reopen: the doc the in-flight spill installed must be durable. + q2 := queue.NewMpsc("spillclose-reopen") + q2.Start() + t.Cleanup(q2.Stop) + s2, err := Open(dir, q2, Options{CapBytes: 64}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { s2.CloseAndWait() }) + if got := searchDocidsForTest(t, s2, tbl, "alpha"); len(got) != 1 || got[0] != 1 { + t.Fatalf("doc 1 not durable on reopen under 'alpha' = %v, want [1]", got) + } +} + +// TestSpillF_InstallFailureGiveUpBound forces writeManifestBytes to fail persistently (MANIFEST.tmp as +// a directory) so installSpill never succeeds: the dispatch retries a BOUNDED number of times, then +// gives up — dropping the detached head (crash-volatile), removing its temp file, clearing +// spillInFlight/blockProducer, and re-dispatching. No spin, no stuck producer, len(s.spilling) bounded. +func TestSpillF_InstallFailureGiveUpBound(t *testing.T) { + dir := t.TempDir() + q := queue.NewMpsc("spillgiveup") + q.Start() + s, err := Open(dir, q, Options{CapBytes: 64, MaxInstallRetries: 3}) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.CloseAndWait() }) + + // Make MANIFEST.tmp a directory so os.Create(MANIFEST.tmp) in writeManifestBytes fails persistently. + tmpAsDir := filepath.Join(dir, "MANIFEST.tmp") + if err := os.Mkdir(tmpAsDir, 0o755); err != nil { + t.Fatal(err) + } + + // Over-cap the head ⇒ detach ⇒ async encode ⇒ install retries then gives up. + s.Update(tbl, 1, []string{"alpha", "beta", "gamma"}) + s.WaitSpillsForTest() + + // Give-up: no detached head left in flight, the gate is clear, and no temp file orphan remains. + if got := s.SpillingLenForTest(); got != 0 { + t.Fatalf("after give-up len(s.spilling) = %d, want 0 (the head was dropped)", got) + } + if s.SpillInFlightForTest() { + t.Fatal("spillInFlight still set after give-up") + } + if s.BlockProducerForTest() { + t.Fatal("blockProducer still set after give-up — the producer would be wedged") + } + ents, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range ents { + if isSegTempFileName(e.Name()) { + t.Fatalf("temp file %q left after give-up", e.Name()) + } + } + // No segment was installed (the MANIFEST write never succeeded); the lost head is crash-volatile. + if got := len(s.SegmentsForTest()); got != 0 { + t.Fatalf("no segment should have installed under a failing MANIFEST write, got %d", got) + } + + // Remove the blocker; the store still accepts writes and a clean Close is durable for the next doc. + if err := os.Remove(tmpAsDir); err != nil { + t.Fatal(err) + } +} + +// TestSpillF_CrashLosesDetachedHeadNoOrphan: a crash with a detached-but-not-installed head loses it +// (volatile, like today's unspilled head) AND leaves no seg-tmp-* orphan after reopen (G sweeps it). +func TestSpillF_CrashLosesDetachedHeadNoOrphan(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("this crash sim intentionally ABANDONS the store `s` (never CloseAndWait): after detaching a " + + "head it unparks the off-worker encode and Stops the queue, so the released encode races to " + + "finish writing its temp segment file and fails its install against the stopped queue — modeling " + + "a process death mid-spill. Closing s cleanly would instead install the detached head and defeat " + + "the 'head lost on crash' property. The abandoned store's open segment/temp-file handle would be " + + "released by a real Windows crash killing the process, but in-process it blocks t.TempDir " + + "RemoveAll. The property itself (detached-but-uninstalled head is lost, no seg-tmp orphan " + + "survives reopen) holds cross-platform and is exercised on Linux/macOS.") + } + dir := t.TempDir() + q := queue.NewMpsc("spillcrash") + q.Start() + s, err := Open(dir, q, Options{CapBytes: 64}) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + + entered, _, unpark := parkEncode(t) + // Detach a head and park its encode (so it is detached-but-not-installed at the "crash"). Two + // keywords push the head over the tiny CapBytes so the over-cap detach fires. + s.Update(tbl, 1, []string{"alpha", "beta"}) + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("encode never parked") + } + if s.SpillingLenForTest() != 1 { + t.Fatalf("want 1 detached head, got %d", s.SpillingLenForTest()) + } + + // Crash: abandon the store + the parked encode goroutine without installing. The temp file (if the + // parked encode already created it) is an orphan; nothing is in the MANIFEST. + unpark() // let the parked goroutine proceed so it isn't leaked into the next test (it will fail + q.Stop() // to install against the stopped queue, harmlessly) + time.Sleep(100 * time.Millisecond) + + // Reopen on a fresh queue: the detached head is GONE (no segment), and G removed any seg-tmp-* orphan. + q2 := queue.NewMpsc("spillcrash-reopen") + q2.Start() + t.Cleanup(q2.Stop) + s2, err := Open(dir, q2, Options{CapBytes: 64}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { s2.CloseAndWait() }) + if got := len(s2.SegmentsForTest()); got != 0 { + t.Fatalf("detached-but-not-installed head should be lost on crash, got %d segments", got) + } + ents, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range ents { + if isSegTempFileName(e.Name()) { + t.Fatalf("seg-tmp-* orphan %q survived reopen (G did not sweep it)", e.Name()) + } + } +} + +// TestSpillF_SyncSpillManifestWriteFailureRollsBack exercises the SYNCHRONOUS spill's write-failure +// rollback (the spillForTest / Close-flush path, distinct from the off-worker installSpill): a failing +// MANIFEST write must roll the in-memory manifest back and seal NO segment, leaving the head readable. +func TestSpillF_SyncSpillManifestWriteFailureRollsBack(t *testing.T) { + s, tbl := newSpillOffworkerStore(t, Options{CapBytes: 1 << 20}) // large cap: no async detach + t.Cleanup(func() { s.CloseAndWait() }) // release segment fds (Windows RemoveAll) + s.applyForTest(tbl, 1, []string{"alpha", "beta"}) + + // Make MANIFEST.tmp a directory so writeManifestBytes' os.Create fails — the synchronous spill must + // roll back (no segment appended, NextSegId restored, head preserved). + tmpAsDir := filepath.Join(s.dir, "MANIFEST.tmp") + if err := os.Mkdir(tmpAsDir, 0o755); err != nil { + t.Fatal(err) + } + s.spillForTest(tbl) // spillForTest discards the error; the rollback branch still runs + + if got := len(s.SegmentsForTest()); got != 0 { + t.Fatalf("failed spill must seal no segment, got %d", got) + } + // The head was preserved (rollback did not reset it): doc 1 is still searchable from the head tier. + if got := searchDocidsForTest(t, s, tbl, "alpha"); len(got) != 1 || got[0] != 1 { + t.Fatalf("doc 1 lost after a rolled-back spill = %v, want [1] (head preserved)", got) + } + + // Removing the blocker lets a subsequent spill succeed (the store is not wedged). + if err := os.Remove(tmpAsDir); err != nil { + t.Fatal(err) + } + s.spillForTest(tbl) + if got := len(s.SegmentsForTest()); got != 1 { + t.Fatalf("spill after clearing the blocker must seal 1 segment, got %d", got) + } +} diff --git a/core/invertedstore/spilling.go b/core/invertedstore/spilling.go new file mode 100644 index 0000000..caa4c66 --- /dev/null +++ b/core/invertedstore/spilling.go @@ -0,0 +1,61 @@ +package invertedstore + +// spilling.go — item F (B1 fix): the `spilling` read tier. A head DETACHED for off-worker encode is +// published into s.spilling and resolved by every read path as a tier BETWEEN the live head and the +// sealed segments (newest -> oldest by detach order), so a doc whose forward lives only in a detached +// head is never momentarily invisible to forwardKeywords / Search / GetDocs / ForwardDocids. + +// spillEntry is one DETACHED head being encoded off-worker (item F). It is published into s.spilling +// at detach (under s.mu.Lock) and removed at install (under s.mu.Lock). Readers resolve it as a tier +// BETWEEN the live head and the sealed segments, newest -> oldest by detach order. The head is +// READ-ONLY once detached (the encode + readers only read it); it is never pooled/reused while listed. +type spillEntry struct { + tableId int + head *headTable + tempN uint64 // temp-file counter; the seg id is assigned at INSTALL (v5 install-time id) + minDocid, maxDocid int64 // forward-record docid span (the spilling-head analog of B; Task 7C) +} + +// encodeSpillBlock, when non-nil, is invoked at the START of encodeSpill (the off-worker encode of a +// detached head — item F v5). Test-only: the B1 gate installs one that blocks on a channel so the +// detached head STAYS in s.spilling across a same-doc re-post, proving forwardKeywords reads the +// detached head via the spilling tier (the silent-corruption fix). nil in production. +var encodeSpillBlock func() + +// headForwardLookup resolves docid's forward decision in ONE head: found=false ⇒ this head does not +// mention the docid (keep looking older). Words are COPIED so the caller may use them after dropping +// the lock (M1 copy-under-RLock). Caller holds s.mu.RLock. +func headForwardLookup(h *headTable, docid int64) (words []string, deleted, found bool) { + if h == nil { + return nil, false, false + } + if _, del := h.delForward[docid]; del { + return nil, true, true + } + if w, ok := h.fwd[docid]; ok { + return append([]string(nil), w...), false, true + } + return nil, false, false +} + +// headForwardRange is the docid span of a head's forward records (live fwd + delForward) — the +// spilling-head analog of segMeta's [MinDocid,MaxDocid] (B). 7A uses the full span; Task 7C wires the +// docid-range skip into forwardKeywords' spilling loop using it. An empty head ⇒ the always-skip range. +func headForwardRange(h *headTable) (min, max int64) { + min, max = emptyDocidRange() + note := func(d int64) { + if d < min { + min = d + } + if d > max { + max = d + } + } + for d := range h.fwd { + note(d) + } + for d := range h.delForward { + note(d) + } + return +} diff --git a/core/invertedstore/spilling_read_test.go b/core/invertedstore/spilling_read_test.go new file mode 100644 index 0000000..72b4f65 --- /dev/null +++ b/core/invertedstore/spilling_read_test.go @@ -0,0 +1,111 @@ +package invertedstore + +import "testing" + +// A doc whose forward is in the spilling tier (NOT the live head, NOT a segment) must still resolve +// via forwardKeywords — the B1 read. Without the spilling tier this returns (nil,false) and a re-post +// would drop no tombstones (silent corruption). +func TestSpillingTier_ForwardKeywordsReadsDetachedHead(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + s.applyForTest(tid, 1, []string{"alpha", "beta"}) + s.injectSpillingHeadForTest(tid) // doc 1's forward now lives ONLY in spilling + if len(s.SegmentsForTest()) != 0 { + t.Fatalf("inject must not seal a segment") + } + got, del := s.forwardKeywordsForTest(tid, 1) + if del { + t.Fatal("doc 1 is live, not deleted") + } + if len(got) != 2 { + t.Fatalf("forward for doc 1 = %v, want [alpha beta] (read from the spilling tier)", got) + } +} + +// Search (prefix) must consult the spilling tier newest-wins: a keyword tombstoned ONLY in the +// detached head must suppress the SAME keyword's stale add still living in a sealed segment. Without +// the spilling tier in Search, the segment's add resurfaces and "alpha" resurrects for doc 1. +func TestSpillingTier_SearchReadsDetachedHead(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + // Seal a segment where doc 1 has keyword "alpha". + s.applyForTest(tid, 1, []string{"alpha"}) + s.spillForTest(tid) + if len(s.SegmentsForTest()) != 1 { + t.Fatalf("want 1 sealed segment after spill") + } + // Re-post doc 1 dropping "alpha" (tombstones the posting), then detach into spilling. + s.q.RunFunc(func() error { + s.mu.Lock() + h := s.head[tid] + if h == nil { + h = newHeadTable() + s.head[tid] = h + } + h.tombstonePosting("alpha", 1) + h.setForward(1, nil) + s.mu.Unlock() + return nil + }) + s.injectSpillingHeadForTest(tid) // the "alpha" tombstone now lives ONLY in spilling + if len(s.SegmentsForTest()) != 1 { + t.Fatalf("inject must not seal another segment") + } + r := s.Search(tid, "alpha", 0, nil) + if _, ok := r.DocIds[1]; ok { + t.Fatalf("doc 1 resurrected for prefix 'alpha' — Search did not consult the spilling tier (newest-wins tombstone)") + } +} + +// GetDocs (exact keyword) must consult the spilling tier newest-wins: same shape as Search but for the +// single exact key. The spilling head's tombstone of "alpha" for doc 1 must beat the sealed add. +func TestSpillingTier_GetDocsReadsDetachedHead(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + s.applyForTest(tid, 1, []string{"alpha"}) + s.spillForTest(tid) + if len(s.SegmentsForTest()) != 1 { + t.Fatalf("want 1 sealed segment after spill") + } + s.q.RunFunc(func() error { + s.mu.Lock() + h := s.head[tid] + if h == nil { + h = newHeadTable() + s.head[tid] = h + } + h.tombstonePosting("alpha", 1) + h.setForward(1, nil) + s.mu.Unlock() + return nil + }) + s.injectSpillingHeadForTest(tid) + if got := searchDocidsForTest(t, s, tid, "alpha"); len(got) != 0 { + t.Fatalf("GetDocs('alpha') = %v, want [] — GetDocs did not consult the spilling tier (newest-wins tombstone)", got) + } +} + +// ForwardDocids must consult the spilling tier newest-wins: a doc LIVE only in spilling is yielded, and +// a doc TOMBSTONED in spilling (but live in an older segment) is NOT yielded. Without the spilling tier +// in ForwardDocids the live-only-in-spilling doc is missed and the spilling-tombstoned doc resurrects. +func TestSpillingTier_ForwardDocidsReadsDetachedHead(t *testing.T) { + s, tid := newForwardSkipStore(t, Options{CapBytes: 1 << 20}) + // Seal a segment where doc 2 is live (so an older tier holds a forward for it). + s.applyForTest(tid, 2, []string{"gamma"}) + s.spillForTest(tid) + if len(s.SegmentsForTest()) != 1 { + t.Fatalf("want 1 sealed segment after spill") + } + // Detach a head where doc 1 is live (only in spilling) and doc 2 is tombstoned (overriding the seg). + s.applyForTest(tid, 1, []string{"alpha"}) + s.applyForTest(tid, 2, nil) // deleteForward(2): forward-tombstone + s.injectSpillingHeadForTest(tid) + if len(s.SegmentsForTest()) != 1 { + t.Fatalf("inject must not seal another segment") + } + live := map[int64]bool{} + s.ForwardDocids(tid, func(d int64) bool { live[d] = true; return true }) + if !live[1] { + t.Fatalf("doc 1 (live only in spilling) not yielded — ForwardDocids did not consult the spilling tier") + } + if live[2] { + t.Fatalf("doc 2 (tombstoned in spilling) yielded — ForwardDocids let an older segment forward resurrect it") + } +} diff --git a/core/invertedstore/store.go b/core/invertedstore/store.go new file mode 100644 index 0000000..db9b81e --- /dev/null +++ b/core/invertedstore/store.go @@ -0,0 +1,421 @@ +package invertedstore + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/codetrek/haystack/core/queue" +) + +// Options configure a Store. Zero values are filled with the design §4 defaults by +// (*Options).withDefaults, so callers can pass Options{} for a sane production setup. +type Options struct { + CapBytes int // head byte cap (the memory knob); default 16 MiB + Fanout int // tiered-merge fanout; default 4 + DataCodecL0 byte // L0 spill data-block codec; default snappy + DataCodecMerged byte // background-merged data-block codec; default zstd (bounded) + DictCodec byte // term-dict region codec; default zstd + DictChunkBytes int // term-dict chunk size; default 4096 + ChunkCacheBytes int // Store-level dict-chunk LRU budget; default 32 MiB + InlineThreshold int // value <= this is inline, else external; default 1 KiB + + // MaxInflightPostings bounds the postings (Σ keyword copies) buffered between the producer and the + // worker — the memory bound (spec §7, item E). The producer blocks in Update/Commit until the + // budget frees; applyBatch releases via the enqueued closure's defer. 0 ⇒ default 4 × CapBytes. + MaxInflightPostings int + + // MaxInstallRetries bounds installSpill's retry loop on a persistently-failing MANIFEST write (F + // v5): after this many failed attempts the dispatch gives up, drops the detached head (treating it + // as crash-volatile), clears spillInFlight/blockProducer, and re-dispatches, so a dead disk cannot + // wedge the producer forever. 0 ⇒ default 5. + MaxInstallRetries int + + // AutoMerge enables the background tiered merger (P8): after each spill the worker enqueues a + // maybeMerge task (tiered fanout + covering-merge trigger). It defaults OFF so a test that asserts + // an exact segment count is not surprised by a merge collapsing segments; production wiring (and + // the P8 merge tests) turn it on. A merge is still always available synchronously via the merge + // entry points (mergeForTest/coveringMergeForTest) regardless of this flag. + AutoMerge bool + + // blockTarget/chunk are the segment block geometry; kept here (not in design §4) so the + // spill path can size blocks/external chunks. Defaults match the SSTable conventions used + // by the spike (32 KiB blocks, 64 KiB external chunks). + BlockTarget int // raw bytes per data block before compression; default 32 KiB + Chunk int // raw bytes per external-value chunk; default 64 KiB +} + +func (o Options) withDefaults() Options { + if o.CapBytes <= 0 { + o.CapBytes = 16 << 20 + } + if o.Fanout <= 0 { + o.Fanout = 4 + } + if o.DataCodecL0 == 0 { + o.DataCodecL0 = codecSnappy + } + if o.DataCodecMerged == 0 { + o.DataCodecMerged = codecZstd + } + if o.DictCodec == 0 { + o.DictCodec = codecZstd + } + if o.DictChunkBytes <= 0 { + o.DictChunkBytes = 4096 + } + if o.ChunkCacheBytes <= 0 { + o.ChunkCacheBytes = 32 << 20 + } + if o.InlineThreshold <= 0 { + o.InlineThreshold = 1 << 10 + } + if o.MaxInflightPostings <= 0 { + o.MaxInflightPostings = 4 * o.CapBytes // CapBytes already defaulted above + } + if o.MaxInstallRetries <= 0 { + o.MaxInstallRetries = 5 + } + if o.BlockTarget <= 0 { + o.BlockTarget = 32 << 10 + } + if o.Chunk <= 0 { + o.Chunk = 64 << 10 + } + return o +} + +// Store is the pebble-free, segment-based inverted index. It owns a byte-capped in-memory +// head (per table), an atomically-replaced MANIFEST, and the set of immutable sealed segments. +// +// Concurrency (design §6, P9/T8): all writes (table ops, applies, spills, merges) run on the single +// mpsc worker, so the head and segment set have one mutator. The live segment set is PUBLISHED via +// an atomic.Pointer[segSnapshot] (concurrency.go) that the worker swaps on every seal/merge/table +// change; Search/GetDocs/forwardKeywords load it once and hold a refcounted view, so a merged-away +// segment's file is unlinked only after the last in-flight reader of it finishes (deferred deletion, +// no use-after-free). The RWMutex guards the head maps + the MANIFEST + the worker's seg slice and +// serializes the brief acquire/swap handoff; readers never block on a writer's I/O. +type Store struct { + dir string + q queue.Queue + opts Options + + mu sync.RWMutex + man *manifest + head map[int]*headTable // tableId -> in-memory head (P4c) + segs []*segment // worker-owned live sealed segment slice, oldest->newest (the swap source) + + // spilling holds heads DETACHED for off-worker encode (item F), newest last. Readers consult it as + // a tier between the live head and the sealed segments (B1). Published at detach + removed at + // install, both under s.mu.Lock. Read (copied) under s.mu.RLock. Never refcounted/pooled. + spilling []*spillEntry + + // F v5 off-worker spill state (all guarded by s.mu): + // spillInFlight — true between a detach and its install: at most ONE spill is in flight, so a + // same-table spill can never install out of order (kills the v4 ordering defect). + // blockProducer — the worker-controlled producer gate: set on over-cap-while-spillInFlight, + // cleared at install (or give-up). Update/Commit park on spillCond while it is set, + // BEFORE q.AddFunc (a parked producer holds zero queue slots → deadlock-free). + // spillCond — sync.Cond over &s.mu; install Broadcasts it after clearing blockProducer. + // spillTempCtr — monotonic temp-file counter (seg-tmp-.dat); the seg id is assigned at install. + // spillWG — tracks in-flight encode/install goroutines so CloseAndWait can drain them OFF + // the worker (Wait() on the caller goroutine, never inside a worker RunFunc). + spillInFlight bool + blockProducer bool + spillCond *sync.Cond + spillTempCtr uint64 + spillWG sync.WaitGroup + + // liveByTable[tableId] = distinct live (keyword,docid) pairs in that table = Σ over the table's + // live docs of their distinct keyword count. The `live` term of the covering-merge trigger + // (deadFraction). NOT persisted: recomputed on Open from the segments' forward records + // (recomputeLive), maintained incrementally in applyBatch (under s.mu.Lock, so the RLock read in + // deadFraction is race-free), and dropped per-table by DeleteTable. A plain arithmetic counter + // (missing key reads 0; no CreateTable seeding). Mutated only on the worker, like head/segs. + liveByTable map[int]int64 + + // snap is the atomically-published live segment set readers load (concurrency.go, P9/T8). The + // worker rebuilds + Store()s it from s.segs on every spill/merge/table change; a reader Load()s it + // once per call and refcounts its segments for the scan. Always non-nil (Open seeds emptySnapshot). + snap atomic.Pointer[segSnapshot] + + // Background merge scheduler (concurrency.go, P9/T8). The merger runs on its OWN goroutine, not by + // re-enqueuing onto the mpsc worker (which self-deadlocks when the queue fills). A spill/DeleteTable + // raises a non-blocking trigger on mergeSignal; mergeLoop drives the merge passes back onto the + // worker via RunFunc. forceCovering makes the next pass a covering merge (DeleteTable). The + // req/ack sequence counters let waitMergeIdle (test) wait for quiescence. Only set when AutoMerge on. + mergeSignal chan struct{} + mergeStop chan struct{} + mergeDone chan struct{} + forceCovering atomic.Bool + mergeReqSeq atomic.Int64 // bumped by every triggerMerge + mergeAckSeq atomic.Int64 // set to the reqSeq a completed pass observed + + // dictCache is the Store-level LRU of decompressed term-dict chunks, keyed by + // (segmentId, chunkIdx) (design §6/§8). It is read only on the forward (Update) path — + // Search never touches it — and is purged of a segment's chunks when a merge retires it. + dictCache *chunkLRU + + // budget bounds the in-flight postings buffered between the producer and the worker (spec §7, E). + // Update/Commit acquire before enqueuing an apply; applyBatch's enqueued closure releases via defer. + budget *postingBudget + + // onForwardRead, if non-nil, is invoked by forwardKeywords whenever it performs a REAL + // forward read (a head-forward hit or a segment-I/O scan) — i.e. not on a cold-build miss. + // Test-only observability hook (P7) for the "cold build takes no forward read" assertion; + // it is set/read only on the worker so it needs no extra locking. + onForwardRead func() + + // onForwardProbe, if non-nil, fires once per segment forwardKeywords actually PROBES (decompresses + // a block via lookupForward) — i.e. NOT for a range-skipped segment. Test-only (B): asserts a + // cold-build read skips every sealed segment. Set/read only on the worker. + onForwardProbe func() +} + +// noteForwardRead fires the forward-read observability hook if one is installed (P7). +func (s *Store) noteForwardRead() { + if s.onForwardRead != nil { + s.onForwardRead() + } +} + +func (s *Store) noteForwardProbe() { + if s.onForwardProbe != nil { + s.onForwardProbe() + } +} + +// segFileName is the on-disk name for a sealed segment with the given seal-sequence id. +// Matches design §5's layout (seg-000123.dat). +func segFileName(id uint64) string { return fmt.Sprintf("seg-%06d.dat", id) } + +// segTempFileName is the on-disk name for an in-flight off-worker spill encode (F v5): the encode +// writes seg-tmp-.dat (n from the private spillTempCtr) and install atomically renames it to +// seg-.dat (id assigned at install). A crash mid-encode leaves the temp file an orphan that G +// (sweepOrphanSegments) removes on the next Open. +func segTempFileName(n uint64) string { return fmt.Sprintf("seg-tmp-%06d.dat", n) } + +// isSegTempFileName reports whether name is an off-worker spill temp file (seg-tmp-*.dat, F v5). Such +// a file is NEVER live in the MANIFEST (install renames it before recording the segMeta), so Open +// always sweeps it as an orphan. +func isSegTempFileName(name string) bool { + return strings.HasPrefix(name, "seg-tmp-") && strings.HasSuffix(name, ".dat") +} + +// parseSegFileName extracts the seal-sequence id from a "seg-%06d.dat" name; ok=false for any other +// name, so MANIFEST/MANIFEST.tmp, unrelated files, and seg-tmp-*.dat are left to the caller's own +// handling (sweepOrphanSegments removes a temp file explicitly via isSegTempFileName). +func parseSegFileName(name string) (uint64, bool) { + if !strings.HasPrefix(name, "seg-") || !strings.HasSuffix(name, ".dat") { + return 0, false + } + id, err := strconv.ParseUint(name[len("seg-"):len(name)-len(".dat")], 10, 64) + if err != nil { + return 0, false + } + return id, true +} + +// sweepOrphanSegments removes any seg-*.dat in the store dir whose id is NOT live in the MANIFEST +// (item G) — an orphan left when a crash hit between reserving an outId + writing the segment file +// and installing the MANIFEST (off-worker merge A / spill F). It also removes any seg-tmp-*.dat (an +// off-worker spill encode F v5 crashed before install renamed it). Makes the merge.go "GC'd on Open" +// claim true. Open-only (single-threaded, exclusive owner). +func (s *Store) sweepOrphanSegments() error { + live := make(map[uint64]bool, len(s.man.Segments)) + for _, sm := range s.man.Segments { + live[sm.Id] = true + } + ents, err := os.ReadDir(s.dir) + if err != nil { + return err + } + for _, e := range ents { + if e.IsDir() { + continue + } + if isSegTempFileName(e.Name()) { + // An off-worker spill temp file is never live in the MANIFEST: remove it (F v5 crash orphan). + if err := os.Remove(filepath.Join(s.dir, e.Name())); err != nil && !os.IsNotExist(err) { + return err + } + continue + } + id, ok := parseSegFileName(e.Name()) + if !ok || live[id] { + continue + } + if err := os.Remove(filepath.Join(s.dir, e.Name())); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// Open reads (or bootstraps) the MANIFEST under path and opens each live segment file. A +// missing MANIFEST yields a fresh empty store. The queue must already be started. +// +// Open creates path (and any missing parents) before reading the MANIFEST, matching the +// idtable/vectorstore Open ergonomics: the production wiring is +// Open(filepath.Join(storagePath, StorageVersion, "invertedstore"), ...) — a versioned +// subdir that does NOT exist on first boot — and readManifest/writeManifest would otherwise +// fail with "no such file or directory" on the MANIFEST(.tmp) path. +func Open(path string, q queue.Queue, opts Options) (*Store, error) { + if err := os.MkdirAll(path, 0o755); err != nil { + return nil, fmt.Errorf("invertedstore: create dir %q: %w", path, err) + } + man, err := readManifest(path) + if err != nil { + return nil, err + } + s := &Store{ + dir: path, + q: q, + opts: opts.withDefaults(), + man: man, + head: map[int]*headTable{}, + liveByTable: map[int]int64{}, + } + s.dictCache = newChunkLRU(int64(s.opts.ChunkCacheBytes)) + s.budget = newPostingBudget(int64(s.opts.MaxInflightPostings)) + s.spillCond = sync.NewCond(&s.mu) // F v5 producer gate; L is s.mu so the flag is checked under it + if err := s.sweepOrphanSegments(); err != nil { + return nil, err + } + for _, sm := range man.Segments { + seg := openSegment(filepath.Join(path, segFileName(sm.Id))) + seg.id = sm.Id // P5: the chunk-LRU keys decompressed dict chunks by (segmentId, chunkIdx) + seg.minDocid, seg.maxDocid = sm.MinDocid, sm.MaxDocid // B + seg.refs.Store(1) // P9: the published snapshot holds one ref per live segment + s.segs = append(s.segs, seg) + } + if man.FormatVersion < 3 { + // Pre-B manifests have no docid range (unmarshals to [0,0], which would mis-skip every docid + // != 0). Recompute each segment's range from its forward records, then persist at v3 so the + // stale range can never reach forwardKeywords. + if err := s.upgradeSegmentRanges(); err != nil { + return nil, err + } + } + s.snap.Store(emptySnapshot) + s.publishSnapshotLocked() // seed the atomic pointer with the opened set (no concurrent readers yet) + s.recomputeLive() // rebuild the live counter from the opened segments (catalog-gated, §4.2.1) + s.startMergeLoop() // P9: background merger on its own goroutine (no-op unless AutoMerge) + s.reclaimOrphanTables() // §6: synchronously reclaim a dead table left orphaned by a DeleteTable-window crash + return s, nil +} + +// CloseAndWait flushes any non-empty head (spilling it to a sealed segment so no buffered write +// is lost across a clean close), stops the background merge goroutine, then closes every open +// segment. It runs the flush on the worker so it is serialized with in-flight applies. The merge +// goroutine is stopped AFTER the spill (so its final drain can collapse the just-spilled segments) +// but BEFORE the fds are closed (so a merge in flight never reads a closed fd). +// +// Close honors the P9/T8 refcount path so it is SAFE against a Search/GetDocs in flight: it publishes +// emptySnapshot (no later reader can acquire these segments) and then retireKeepFile()s each segment. +// A reader that acquired a refcounted snapshot just before Close still holds its refs, so each +// segment's fd is closed only once that last reader releases — no use-after-free reading a closed fd. +// retireKeepFile (unlike the merge path's retire) does NOT unlink the files: they are still live in +// the on-disk MANIFEST and must survive for the next Open. Callers should still quiesce writers (Close +// is terminal), but a racing reader is handled correctly rather than crashing. +func (s *Store) CloseAndWait() { + // F v5 drain (spec §7a "CloseAndWait"): FIRST clear the producer gate + broadcast so any producer + // parked in waitProducerGate is released and can finish/observe the close — broadcast BEFORE we wait + // on the in-flight encode, or a producer stuck in spillCond.Wait() can never be quiesced. Callers + // quiesce their own producers (Close is terminal); this only unblocks the gate. + s.mu.Lock() + s.blockProducer = false + s.spillCond.Broadcast() + s.mu.Unlock() + // Drain the in-flight off-worker encode + its install on THIS caller goroutine (NEVER inside a + // worker RunFunc — that deadlocks against the install's own RunFunc, the v4 regression). The install + // runs first (it is a worker RunFunc the dispatch goroutine drives), preserving seal order; a + // re-dispatch chained from that install is also tracked by spillWG, so Wait() covers the whole chain. + s.spillWG.Wait() + + // THEN flush any remaining live head synchronously on the worker (a clean close loses no buffered + // write). The in-flight spill already installed above, so this only seals heads that never over-capped. + s.q.RunFunc(func() error { + s.mu.Lock() + tables := make([]int, 0, len(s.head)) + for id, h := range s.head { + if h != nil && (len(h.inv) > 0 || len(h.fwd) > 0 || len(h.delForward) > 0) { + tables = append(tables, id) + } + } + s.mu.Unlock() + for _, id := range tables { + if err := s.spill(id); err != nil { + return err + } + } + return nil + }) + s.stopMergeLoop() // P9: drain + stop the background merger before we close any segment fd + s.mu.Lock() + segs := s.segs + s.segs = nil + s.snap.Store(emptySnapshot) // P9: drop the published set FIRST so no late reader acquires a ref + s.mu.Unlock() + // Drop the published ref on each segment via the refcount path: the fd is closed (file kept) only + // once the last in-flight reader that still holds a ref has released it (deferred, no closed-fd read). + for _, seg := range segs { + seg.retireKeepFile() + } +} + +// CreateTable allocates the next table id, records it in the catalog, and durably rewrites the +// MANIFEST. It returns a value, so it runs synchronously on the worker (design §6: don't call +// from within a worker task). +func (s *Store) CreateTable(description string) (int, error) { + var id int + err := s.q.RunFunc(func() error { + s.mu.Lock() + defer s.mu.Unlock() + id = s.man.NextTableId + s.man.NextTableId++ + s.man.Tables[id] = tableInfo{Id: id, CreatedAt: time.Now(), Description: description} + return writeManifest(s.dir, s.man) + }) + return id, err +} + +// DeleteTable drops the table's catalog entry and durably rewrites the MANIFEST. The table's +// [I]/[F] segment bytes are NOT reclaimed synchronously — Search/GetDocs return empty for an absent +// tableId immediately (segments are immutable, no DeletePrefix). Instead, when AutoMerge is on, +// DeleteTable SCHEDULES a covering merge (design §6/§8, P8): a covering merge drops keys for +// tableIds no longer in the catalog, so the dead table's bytes are reclaimed even if its segments +// sit at the bottom level with no further writes. The schedule is enqueued (q.AddFunc) so the +// merge runs off the synchronous DeleteTable return. +func (s *Store) DeleteTable(tableId int) error { + err := s.q.RunFunc(func() error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.man.Tables, tableId) + delete(s.head, tableId) + delete(s.liveByTable, tableId) // drop the table's live-pair partition in O(1) (spec §4.2.3) + return writeManifest(s.dir, s.man) + }) + if err != nil { + return err + } + // P9: schedule a covering merge on the background merge goroutine (non-blocking trigger), not via + // s.q.AddFunc — a covering merge dropping the dead table's keys is what reclaims its bytes even if + // its segments sit at the bottom level with no further writes (design §6/§8). + s.triggerMerge(true) + return nil +} + +// tableInfo returns the catalog entry for tableId, if present. A small read helper used by the +// tests and (later) Search to reject an absent/deleted table. +func (s *Store) tableInfo(tableId int) (tableInfo, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + ti, ok := s.man.Tables[tableId] + return ti, ok +} diff --git a/core/invertedstore/store_test.go b/core/invertedstore/store_test.go new file mode 100644 index 0000000..10c3ec2 --- /dev/null +++ b/core/invertedstore/store_test.go @@ -0,0 +1,114 @@ +package invertedstore + +import ( + "path/filepath" + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +func openTestStore(t *testing.T, dir string) *Store { + t.Helper() + q := queue.NewMpsc("invtest") + q.Start() + s, err := Open(dir, q, Options{}) + if err != nil { + t.Fatal(err) + } + return s +} + +// TestOpenCreatesMissingDir guards the production wiring ergonomic: Open is called +// on a versioned subdir (storagePath//invertedstore) that does NOT exist +// on first boot, so Open must MkdirAll it before reading the MANIFEST rather than +// failing with "no such file or directory" on MANIFEST.tmp. +func TestOpenCreatesMissingDir(t *testing.T) { + // A nested path none of whose components exist yet. + dir := filepath.Join(t.TempDir(), "v1.6", "invertedstore") + q := queue.NewMpsc("invtest-mkdir") + q.Start() + s, err := Open(dir, q, Options{}) + if err != nil { + t.Fatalf("Open on non-existent dir: %v", err) + } + // A fresh store must be usable: create a table and reopen to confirm the + // MANIFEST was written into the just-created dir. + id, err := s.CreateTable("files") + if err != nil { + t.Fatalf("CreateTable: %v", err) + } + s.CloseAndWait() + + s2 := openTestStore(t, dir) + defer s2.CloseAndWait() + if _, ok := s2.tableInfo(id); !ok { + t.Fatalf("table %d not persisted after Open created the dir", id) + } +} + +func TestCreateDeleteTablePersist(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + id, err := s.CreateTable("files") + if err != nil || id != 1 { + t.Fatalf("CreateTable: id=%d err=%v", id, err) + } + id2, _ := s.CreateTable("symbols") + if id2 != 2 { + t.Fatalf("second table id=%d, want 2", id2) + } + s.CloseAndWait() + + // reopen: catalog persisted, next id continues + s2 := openTestStore(t, dir) + defer s2.CloseAndWait() + id3, _ := s2.CreateTable("third") + if id3 != 3 { + t.Fatalf("after reopen next id=%d, want 3", id3) + } + if err := s2.DeleteTable(1); err != nil { + t.Fatalf("DeleteTable: %v", err) + } + if _, ok := s2.tableInfo(1); ok { + t.Fatal("table 1 should be gone from the catalog after DeleteTable") + } +} + +func TestSpillAndReopen(t *testing.T) { + dir := t.TempDir() + s := openTestStore(t, dir) + tbl, _ := s.CreateTable("files") + // the internal building blocks Update (P7) will call: doc 10 = {alpha,gamma}; doc 11 = {beta} + s.applyForTest(tbl, 10, []string{"alpha", "gamma"}) + s.applyForTest(tbl, 11, []string{"beta"}) + s.spillForTest(tbl) // force a spill + s.CloseAndWait() + + s2 := openTestStore(t, dir) + defer s2.CloseAndWait() + if len(s2.segs) != 1 { + t.Fatalf("expected 1 sealed segment after reopen, got %d", len(s2.segs)) + } + seg := s2.segs[0] + lo := invertedKey(uint32(tbl), "alpha") + var hits []int64 + seg.scanPrefix(lo, prefixUpper(lo), func(_ []byte, v []byte) { + ab, _ := splitInvertedValue(v) + decodeDocs(ab, func(d int64) { hits = append(hits, d) }) + }) + if len(hits) != 1 || hits[0] != 10 { + t.Fatalf("alpha postings after reopen = %v, want [10]", hits) + } + fv, ok := seg.lookupForward(forwardKey(uint32(tbl), 10)) + if !ok { + t.Fatal("forward lookup miss for doc 10") + } + ords, _ := decodeForward(fv) + need := map[uint32]struct{}{} + for _, o := range ords { + need[o] = struct{}{} + } + if got := seg.resolveOrds(need); len(got) != 2 { + t.Fatalf("doc 10 resolved to %d keywords, want 2: %v", len(got), got) + } +} diff --git a/core/invertedstore/trigger_test.go b/core/invertedstore/trigger_test.go new file mode 100644 index 0000000..2b52b1a --- /dev/null +++ b/core/invertedstore/trigger_test.go @@ -0,0 +1,146 @@ +package invertedstore + +import "testing" + +// deadFraction endpoints on known inputs (AutoMerge off so the trigger doesn't move the value +// mid-assertion). cold build → 0 (the pathology guard); delete-all → 1. +func TestDeadFraction_Unit(t *testing.T) { + s, tid := newUpdateStore(t) // AutoMerge off, large cap + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + for d := int64(1); d <= 100; d++ { + s.Update(tid, d, []string{"common", uniqWord(int(d))}) + } + s.sync() + s.spillForTest(tid) + if df := s.DeadFractionForTest(); df != 0 { + t.Fatalf("cold build deadFraction=%.4f want 0", df) + } + s.assertCounterInvariantForTest(t) + + // Delete the first 50 docs: written = 200 adds + 100 tombstones = 300; live = 50*2 = 100; + // deadFraction = 1 - 100/300 = 0.667 (NOT 0.33 — the dead fraction is dead/written). + for d := int64(1); d <= 50; d++ { + s.Update(tid, d, nil) + } + s.sync() + s.spillForTest(tid) + if df := s.DeadFractionForTest(); df < 0.66 || df > 0.67 { + t.Fatalf("delete-half deadFraction=%.4f want ~0.667", df) + } + s.assertCounterInvariantForTest(t) + + // Delete the rest → live = 0 → deadFraction = 1. + for d := int64(51); d <= 100; d++ { + s.Update(tid, d, nil) + } + s.sync() + s.spillForTest(tid) + if df := s.DeadFractionForTest(); df < 0.999 { + t.Fatalf("delete-all deadFraction=%.4f want 1.0", df) + } +} + +// THE regression guard: a clean cold build that spills MANY segments must never fire a covering +// merge — deadFraction stays 0 because live tracks written. (With the old bottomDeadFraction this +// path ran a full-decompression scan after every spill; here it is a metadata sum.) +func TestDeadFraction_ColdBuildNoCoveringMerge(t *testing.T) { + s, tid := newUpdateStoreOpts(t, Options{CapBytes: 4 << 10, AutoMerge: true}) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + n := installCoveringCounter(t) + for d := 0; d < 3000; d++ { + s.Update(tid, int64(d), []string{"w", uniqWord(d)}) + } + s.sync() + s.waitMergeIdle() + if df := s.DeadFractionForTest(); df >= coveringDeadThreshold { + t.Fatalf("cold-build deadFraction=%.4f want < %.2f", df, coveringDeadThreshold) + } + if got := n.Load(); got != 0 { + t.Fatalf("covering merges fired %d times on a clean cold build, want 0", got) + } + s.assertCounterInvariantForTest(t) +} + +// The trigger still fires when garbage accumulates: delete a large fraction and a covering merge runs. +func TestDeadFraction_TriggerFiresOnDeletes(t *testing.T) { + s, tid := newUpdateStoreOpts(t, Options{CapBytes: 4 << 10, AutoMerge: true}) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + n := installCoveringCounter(t) + for d := 0; d < 1000; d++ { + s.Update(tid, int64(d), []string{"w", uniqWord(d)}) + } + s.sync() + s.waitMergeIdle() + if got := n.Load(); got != 0 { + t.Fatalf("covering fired during clean build: %d", got) + } + for d := 0; d < 600; d++ { // delete 60% -> well over threshold + s.Update(tid, int64(d), nil) + } + s.sync() + s.waitMergeIdle() + if got := n.Load(); got < 1 { + t.Fatalf("covering merge did not fire after 60%% deletes (count=%d)", got) + } + s.assertCounterInvariantForTest(t) +} + +// A garbage-reclaiming covering merge on a CLEAN fixture preserves the live count while reclaiming +// written bytes (spec §4.2.3 / §8.4). (On an inconsistent input the self-heal path may drop a forward +// term — not asserted here.) +func TestCovering_PreservesLive_CleanFixture(t *testing.T) { + s, tid := newUpdateStore(t) // AutoMerge off + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + for d := int64(1); d <= 50; d++ { + s.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s.sync() + s.spillForTest(tid) + for d := int64(1); d <= 50; d++ { // re-post identical sets -> superseded copies (garbage, no inconsistency) + s.Update(tid, d, []string{"k", uniqWord(int(d))}) + } + s.sync() + s.spillForTest(tid) + + liveBefore := s.LiveByTableForTest()[tid] + writtenBefore := sumPostings(s) + s.coveringMergeForTest(t) + liveAfter := s.LiveByTableForTest()[tid] + writtenAfter := sumPostings(s) + + if liveAfter != liveBefore { + t.Fatalf("covering merge changed live: %d -> %d (must preserve)", liveBefore, liveAfter) + } + if writtenAfter >= writtenBefore { + t.Fatalf("covering merge reclaimed nothing: written %d -> %d", writtenBefore, writtenAfter) + } + s.assertCounterInvariantForTest(t) +} + +// Tiered merges + spills during a clean build leave liveByTable untouched (no covering merge, and +// live equals the exact distinct-pair count). Spec §4.2.3 (merge path never touches liveByTable). +func TestTieredMergeAndSpill_LeaveLiveUnchanged(t *testing.T) { + s, tid := newUpdateStoreOpts(t, Options{CapBytes: 2 << 10, AutoMerge: true, Fanout: 4}) + defer s.CloseAndWait() // release segment fds so Windows t.TempDir RemoveAll can delete seg-*.dat + n := installCoveringCounter(t) + for d := 0; d < 1000; d++ { + s.Update(tid, int64(d), []string{"k", uniqWord(d)}) // "k" shared, uniqWord distinct + } + s.sync() + s.waitMergeIdle() + if got := n.Load(); got != 0 { + t.Fatalf("covering merge fired on a clean build (count=%d) — tiered only expected", got) + } + if got := s.LiveByTableForTest()[tid]; got != 2000 { + t.Fatalf("live=%d want 2000 (1000 docs * 2 distinct kw); tiered merge/spill must not change it", got) + } + s.assertCounterInvariantForTest(t) +} + +func sumPostings(s *Store) int64 { + var n int64 + for _, sm := range s.SegmentsForTest() { + n += sm.Postings + } + return n +} diff --git a/core/invertedstore/update.go b/core/invertedstore/update.go new file mode 100644 index 0000000..d52c75d --- /dev/null +++ b/core/invertedstore/update.go @@ -0,0 +1,260 @@ +package invertedstore + +// update.go — P7 (design §6 write path, §8 full re-post; task T5). +// +// The write side of the store. All public writes are thread-safe and ASYNCHRONOUS: each enqueues +// an apply task on the mpsc worker via q.AddFunc, so callers never need to be "on the worker" (the +// contract improvement over invertedindex). Update is exactly a single-item Batch; Batch amortizes +// N ops into ONE apply task. The apply runs on the single worker, serialized with spills and table +// ops, so the head and segment set have one mutator and Search reads concurrently via the RWMutex. +// +// Diff model (term-id, §8). term-id CANNOT do a string-style delta — a doc's forward references its +// FULL current keyword set, which must all be [I] keys in the segment that holds the forward — so on +// edit the doc is FULLY RE-POSTED: addPosting for EVERY current keyword, plus a per-keyword +// tombstone for each keyword the doc no longer has. Empty keywords ⇒ DELETE: a forward-tombstone +// (nKw=0) plus a tombstone in ALL the doc's old keywords, so an older non-empty segment can never +// resurrect it. This is a direct port of cmd/sortbench/main.go runUpdates' termid branch, in +// production shape (int64 docids, the head buffer, the forward read of P5). + +// updateOp is one queued (tableId, docid, keywords) edit. keywords == nil/empty ⇒ delete the doc. +type updateOp struct { + tableId int + docid int64 + keywords []string +} + +// Batch accumulates updateOps in memory; Commit enqueues ONE apply task that applies them in order +// on the worker (a repeated docid → last op wins). It is the bulk-ingest path; Update is the n=1 +// convenience that wraps a single-op Batch. +type Batch struct { + s *Store + ops []updateOp +} + +// waitProducerGate blocks the calling PRODUCER goroutine while the worker has engaged the F v5 +// producer gate (over-cap with a spill already in flight). It loops `for blockProducer` (NOT an `if`: +// Broadcast wakes all parked producers but each install relieves only one head's worth, so a producer +// must re-check) on spillCond, whose L is s.mu — so the flag is read under the same lock the worker +// sets/clears it under, closing the lost-wakeup window. It MUST be called BEFORE q.AddFunc: a parked +// producer holds ZERO queue slots, which is what keeps the gate deadlock-free (the worker can always +// drain the in-flight install RunFunc and clear the gate). The Wait releases + reacquires s.mu. +func (s *Store) waitProducerGate() { + s.mu.Lock() + for s.blockProducer { + s.spillCond.Wait() + } + s.mu.Unlock() +} + +// applyGate, when non-nil, is invoked at the START of an enqueued apply closure (on the worker), +// BEFORE applyBatch runs — while the producer's acquired budget is still held. Test-only (E): a test +// installs one that blocks so applies cannot drain, then observes the in-flight postings pile up at +// the budget ceiling (the producer must block once the budget fills). nil in production. +var applyGate func() + +// applyFastPathTaken, when non-nil, fires in applyBatch's len(ops)==1 branch (the 1-op fast path). +// Test-only (C.1): a test asserts the fast path is actually taken for a 1-op apply and NOT for a +// multi-op batch — the behavior test alone passes even without the fast path, so this hook is the +// only thing that covers the optimization being taken. nil in production. +var applyFastPathTaken func() + +// NewBatch starts an empty Batch bound to this store. +func (s *Store) NewBatch() *Batch { return &Batch{s: s} } + +// Update appends a (tableId, docid, keywords) op to the batch. keywords is the doc's CURRENT full +// keyword set; empty ⇒ delete. Returns the batch for chaining. +func (b *Batch) Update(tableId int, docid int64, keywords []string) *Batch { + // Defensive copy: the caller's slice may be mutated/reused after Update returns, but the op is + // applied LATER on the worker. nil keywords stays nil (a delete). + var kw []string + if len(keywords) > 0 { + kw = append([]string(nil), keywords...) + } + b.ops = append(b.ops, updateOp{tableId: tableId, docid: docid, keywords: kw}) + return b +} + +// Commit enqueues the batch as a SINGLE async apply task (q.AddFunc). An empty batch is a no-op. +// Ops are applied in order on the worker; a docid repeated in the batch resolves to its LAST op. +func (b *Batch) Commit() { + if len(b.ops) == 0 { + return + } + ops := b.ops + b.ops = nil // a committed batch is spent; don't let a later Commit re-apply + s := b.s + var postings int64 + for _, op := range ops { + postings += int64(len(op.keywords)) + } + got := s.budget.acquire(postings) // producer backpressure (spec §7 E) + s.waitProducerGate() // F v5 producer gate (over-cap + spill-in-flight); before q.AddFunc + s.q.AddFunc(func() error { + defer s.budget.release(got) + if applyGate != nil { + applyGate() + } + return s.applyBatch(ops) + }) +} + +// Update is the single-item Batch: it enqueues ONE async apply task for one doc. keywords is the +// doc's CURRENT full keyword set; empty ⇒ delete. Thread-safe; never blocks (design §4/§6). +func (s *Store) Update(tableId int, docid int64, keywords []string) { + var kw []string + if len(keywords) > 0 { + kw = append([]string(nil), keywords...) + } + op := updateOp{tableId: tableId, docid: docid, keywords: kw} + got := s.budget.acquire(int64(len(kw))) // producer backpressure (spec §7 E) + s.waitProducerGate() // F v5 producer gate (over-cap + spill-in-flight); before q.AddFunc + s.q.AddFunc(func() error { + defer s.budget.release(got) + if applyGate != nil { + applyGate() + } + return s.applyBatch([]updateOp{op}) + }) +} + +// applyBatch applies ops in order on the worker. For each op it diffs the doc's CURRENT keywords +// (the forward read of P5 — head pending first, then segments newest→oldest) against the new set +// and full-re-posts; an empty new set deletes. After every op it spills the table if the head's +// byte estimate crossed CapBytes (reusing P4c spill), so memory stays bounded mid-batch. +// +// In-batch last-wins: a docid touched earlier in THIS batch must diff against the keywords its +// earlier op set (not a stale sealed copy and not a forward read that hasn't observed the earlier +// op's head writes through the dedup yet), so we track the in-batch state per (tableId,docid). This +// also means a cold-build batch — every docid new, never re-touched — takes NO forward read at all. +func (s *Store) applyBatch(ops []updateOp) error { + // 1-op fast path (C.1): the hot Update path is always 1-op, and a 1-op batch cannot repeat a + // docid, so the inBatch/seen last-wins bookkeeping is dead weight and `old` always comes from the + // forward read. Skip the maps entirely and apply the single op directly. + if len(ops) == 1 { + if applyFastPathTaken != nil { + applyFastPathTaken() // test-only (C.1): proves the 1-op fast path is actually taken + } + op := ops[0] + old, _ := s.forwardKeywords(op.tableId, op.docid) + return s.applyOneOp(op, old) + } + + // inBatch[(tableId,docid)] is the doc's keyword set as left by its latest op SO FAR in this + // batch; a nil entry that EXISTS means the last op deleted it. Presence (ok) means "seen this + // batch", so a later op for the same docid diffs against it instead of re-reading the forward. + type dk struct { + t int + d int64 + } + inBatch := map[dk]([]string){} + seen := map[dk]bool{} + + for _, op := range ops { + key := dk{op.tableId, op.docid} + + // 1. Old keyword set. If this docid was already touched in this batch, its old state is the + // last op's result (no forward read). Otherwise read the forward map (P5). + var old []string + if seen[key] { + old = inBatch[key] + } else { + words, _ := s.forwardKeywords(op.tableId, op.docid) + old = words + } + + if err := s.applyOneOp(op, old); err != nil { + return err + } + + seen[key] = true + if len(op.keywords) == 0 { + inBatch[key] = nil + } else { + inBatch[key] = op.keywords + } + } + return nil +} + +// applyOneOp applies a single op against its already-resolved `old` keyword set: it takes s.mu, +// re-posts the doc into the head (DELETE ⇒ tombstone old keywords + forward-tombstone; otherwise +// FULL RE-POST + tombstone removed keywords), updates the liveByTable delta, then spills the table +// outside the lock if the head crossed CapBytes. The in-batch last-wins bookkeeping (inBatch/seen) +// is NOT here — it closes over multi-op loop state and stays in applyBatch's loop. Returns the spill +// error (the spill-on-`over` stays inside). +func (s *Store) applyOneOp(op updateOp, old []string) error { + s.mu.Lock() + h := s.head[op.tableId] + if h == nil { + h = newHeadTable() + s.head[op.tableId] = h + } + + // liveByTable delta (spec §4.2.2): live pairs change by (new distinct − old distinct). `old` + // may carry caller duplicates (the forward stores raw keywords), so dedup it — len(old) is not + // the distinct count. Runs under s.mu.Lock so deadFraction's RLock read is race-free. + oldN := int64(distinctStrings(old)) + + if len(op.keywords) == 0 { + // DELETE: tombstone the docid in ALL its old keywords + write a forward-tombstone, so + // no older non-empty segment can win and resurrect the doc (design §6). + for _, w := range old { + h.tombstonePosting(w, op.docid) + } + h.deleteForward(op.docid) + s.liveByTable[op.tableId] -= oldN + } else { + // FULL RE-POST (term-id, §8): add EVERY current keyword (addPosting dedups in the head), + // then a per-keyword tombstone for each removed keyword (in old, not in new). + newSet := make(map[string]struct{}, len(op.keywords)) + for _, w := range op.keywords { + newSet[w] = struct{}{} + } + for w := range newSet { + h.addPosting(w, op.docid) + } + for _, w := range old { + if _, ok := newSet[w]; !ok { + h.tombstonePosting(w, op.docid) + } + } + h.setForward(op.docid, op.keywords) + s.liveByTable[op.tableId] += int64(len(newSet)) - oldN + } + over := h.bytes >= int64(s.opts.CapBytes) + + // Over-cap handling (F v5): the spill encode runs OFF the worker. If no spill is in flight, detach + // this head NOW (under the SAME lock — the over-cap read + spillInFlight read + detach are one + // section, so two applies can never both detach) and dispatch its encode after dropping the lock. + // If a spill IS in flight, the worker must NOT detach a second one (one-in-flight); instead it sets + // the producer gate so Update/Commit park BEFORE enqueuing more applies — the live head keeps the + // data, bounded by that backpressure, until the in-flight install re-dispatches it. + var toDispatch *spillEntry + if over { + if !s.spillInFlight { + toDispatch = s.detachHeadLocked(op.tableId) + } else { + s.blockProducer = true // the producer parks until installSpill clears + broadcasts + } + } + s.mu.Unlock() + + if toDispatch != nil { + s.dispatchSpill(toDispatch) + } + return nil +} + +// distinctStrings counts the distinct strings in ss. Converts a doc's (possibly caller-duplicated) +// keyword slice to its distinct count for the liveByTable delta, matching the inverted index which +// dedups via addPosting. +func distinctStrings(ss []string) int { + if len(ss) <= 1 { + return len(ss) + } + seen := make(map[string]struct{}, len(ss)) + for _, s := range ss { + seen[s] = struct{}{} + } + return len(seen) +} diff --git a/core/invertedstore/update_test.go b/core/invertedstore/update_test.go new file mode 100644 index 0000000..7f185e3 --- /dev/null +++ b/core/invertedstore/update_test.go @@ -0,0 +1,318 @@ +package invertedstore + +import ( + "testing" + + "github.com/codetrek/haystack/core/queue" +) + +// newUpdateStore opens a fresh store with a created table for the Update/Batch tests. It uses a +// large CapBytes so a test only spills when it explicitly asks (forceSpill), unless it overrides. +func newUpdateStore(t *testing.T) (*Store, int) { + t.Helper() + return newUpdateStoreOpts(t, Options{}) +} + +func newUpdateStoreOpts(t *testing.T, opts Options) (*Store, int) { + t.Helper() + dir := t.TempDir() + q := queue.NewMpsc("invupdate") + q.Start() + s, err := Open(dir, q, opts) + if err != nil { + t.Fatal(err) + } + tbl, err := s.CreateTable("files") + if err != nil { + t.Fatal(err) + } + return s, tbl +} + +// sync drains the worker so an async Update is observable. RunFunc enqueues an empty task and +// blocks until it (and therefore every earlier-enqueued Update) has run. It also settles any in-flight +// OFF-WORKER spill (F v5): an over-cap Update now detaches its head for an async encode+install, so a +// worker-only drain would return before the segment is installed — sync waits for that too, so a test +// that observes s.segs / merges after sync sees the same settled state the synchronous spill produced. +func (s *Store) sync() { + s.q.RunFunc(func() error { return nil }) + s.WaitSpillsForTest() // settle in-flight off-worker spills (encode + install + any re-dispatch chain) + s.q.RunFunc(func() error { return nil }) +} + +// forceSpill spills the table's head on the worker (synchronous) — reuses the P4c test seam. +func (s *Store) forceSpill(tbl int) { s.spillForTest(tbl) } + +// --- after edits Search reflects adds + removals ----------------------------- + +// TestUpdate_SearchReflectsAddsAndRemovals: an initial Update then a second Update with a changed +// keyword set must add the new keyword and drop the removed one, observed through Search. +func TestUpdate_SearchReflectsAddsAndRemovals(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha", "beta"}) + s.sync() + + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 10) { + t.Fatalf("after first Update, alpha must contain doc 10: %v", r.DocIds) + } + if r := s.Search(tbl, "beta", 0, nil); !hasDoc(r, 10) { + t.Fatalf("after first Update, beta must contain doc 10: %v", r.DocIds) + } + + // Spill so the first edit is sealed; the second edit must override it across segments. + s.forceSpill(tbl) + + // Second edit: drop beta, keep alpha, add gamma. + s.Update(tbl, 10, []string{"alpha", "gamma"}) + s.sync() + + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 10) { + t.Errorf("alpha (kept, full re-post) must still contain doc 10: %v", r.DocIds) + } + if r := s.Search(tbl, "gamma", 0, nil); !hasDoc(r, 10) { + t.Errorf("gamma (added) must contain doc 10: %v", r.DocIds) + } + if r := s.Search(tbl, "beta", 0, nil); hasDoc(r, 10) { + t.Errorf("beta (removed) must NOT contain doc 10 anymore: %v", r.DocIds) + } +} + +// TestUpdate_SearchReflectsRemovalAcrossSpill: the removal must hold even when the add lives in an +// OLDER sealed segment (per-keyword tombstone written to the newer segment, newest-wins at read). +func TestUpdate_SearchReflectsRemovalAcrossSpill(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha", "beta"}) + s.sync() + s.forceSpill(tbl) // alpha+beta sealed in seg A + + s.Update(tbl, 10, []string{"alpha"}) // drop beta + s.sync() + s.forceSpill(tbl) // tombstone for beta sealed in seg B (newer) + + if len(s.segs) != 2 { + t.Fatalf("expected 2 sealed segments, got %d", len(s.segs)) + } + if r := s.Search(tbl, "beta", 0, nil); hasDoc(r, 10) { + t.Errorf("beta tombstone in newer segment must suppress doc 10, got %v", r.DocIds) + } + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 10) { + t.Errorf("alpha still live, doc 10 must be present, got %v", r.DocIds) + } +} + +// --- DELETE then re-read returns empty (no resurrection from an older segment) - + +// TestUpdate_DeleteThenReadEmpty: Update with empty keywords deletes the doc; after a spill that +// seals the delete on top of an older segment carrying the live forward + postings, the doc must +// read EMPTY (forward-tombstone) and its postings must be gone from Search. +func TestUpdate_DeleteThenReadEmpty(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha", "beta"}) + s.sync() + s.forceSpill(tbl) // live forward + postings sealed in seg A + + s.Update(tbl, 10, nil) // DELETE + s.sync() + s.forceSpill(tbl) // forward-tombstone + per-keyword tombstones sealed in seg B (newer) + + // Postings gone. + if r := s.Search(tbl, "alpha", 0, nil); hasDoc(r, 10) { + t.Errorf("deleted doc 10 must be absent from alpha, got %v", r.DocIds) + } + if r := s.Search(tbl, "beta", 0, nil); hasDoc(r, 10) { + t.Errorf("deleted doc 10 must be absent from beta, got %v", r.DocIds) + } + // Forward reads empty (deleted) — NO resurrection from the older non-empty record. + words, deleted := s.forwardKeywords(tbl, 10) + if !deleted { + t.Errorf("forward of deleted doc 10 must report deleted, got words=%v deleted=%v", words, deleted) + } + if len(words) != 0 { + t.Errorf("forward of deleted doc 10 must be empty, got %v", words) + } +} + +// TestUpdate_DeleteThenReUpdateResurrectsCleanly: after a DELETE, a later Update re-adds the doc; +// it must become present again (the forward-tombstone does not permanently brick the docid). +func TestUpdate_DeleteThenReUpdateResurrectsCleanly(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha"}) + s.sync() + s.forceSpill(tbl) + + s.Update(tbl, 10, nil) // delete + s.sync() + s.forceSpill(tbl) + + if r := s.Search(tbl, "alpha", 0, nil); hasDoc(r, 10) { + t.Fatalf("after delete, doc 10 must be absent, got %v", r.DocIds) + } + + s.Update(tbl, 10, []string{"alpha", "delta"}) // re-add + s.sync() + + if r := s.Search(tbl, "alpha", 0, nil); !hasDoc(r, 10) { + t.Errorf("re-Update after delete must make doc 10 present in alpha, got %v", r.DocIds) + } + if r := s.Search(tbl, "delta", 0, nil); !hasDoc(r, 10) { + t.Errorf("re-Update after delete must make doc 10 present in delta, got %v", r.DocIds) + } + words, deleted := s.forwardKeywords(tbl, 10) + if deleted { + t.Errorf("re-added doc 10 must not report deleted, got deleted=%v", deleted) + } + if len(words) != 2 { + t.Errorf("re-added doc 10 forward must be {alpha,delta}, got %v", words) + } +} + +// --- a docid repeated in one Batch -> last op wins --------------------------- + +// TestBatch_RepeatedDocidLastWins: a Batch with two Updates of the SAME docid resolves to the +// LAST op's keyword set (applied in order on one apply task). +func TestBatch_RepeatedDocidLastWins(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + b := s.NewBatch() + b.Update(tbl, 10, []string{"alpha", "beta"}) + b.Update(tbl, 10, []string{"gamma"}) // last op for doc 10 + b.Commit() + s.sync() + + if r := s.Search(tbl, "gamma", 0, nil); !hasDoc(r, 10) { + t.Errorf("last op {gamma} must win: gamma should contain doc 10, got %v", r.DocIds) + } + if r := s.Search(tbl, "alpha", 0, nil); hasDoc(r, 10) { + t.Errorf("superseded op {alpha,beta} must NOT leave doc 10 in alpha, got %v", r.DocIds) + } + if r := s.Search(tbl, "beta", 0, nil); hasDoc(r, 10) { + t.Errorf("superseded op {alpha,beta} must NOT leave doc 10 in beta, got %v", r.DocIds) + } + words, deleted := s.forwardKeywords(tbl, 10) + if deleted || len(words) != 1 || words[0] != "gamma" { + t.Errorf("forward after batch must be {gamma}, got words=%v deleted=%v", words, deleted) + } +} + +// TestBatch_RepeatedDocidLastWinsDelete: an Update then a DELETE of the same docid in one Batch +// resolves to delete (last op wins). +func TestBatch_RepeatedDocidLastWinsDelete(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + b := s.NewBatch() + b.Update(tbl, 10, []string{"alpha"}) + b.Update(tbl, 10, nil) // delete wins + b.Commit() + s.sync() + + if r := s.Search(tbl, "alpha", 0, nil); hasDoc(r, 10) { + t.Errorf("delete (last op) must win: alpha must not contain doc 10, got %v", r.DocIds) + } + _, deleted := s.forwardKeywords(tbl, 10) + if !deleted { + t.Errorf("doc 10 must be deleted after batch ending in a delete") + } +} + +// TestBatch_MultipleDocsOneTask: a Batch over distinct docids applies them all (one apply task). +func TestBatch_MultipleDocsOneTask(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + b := s.NewBatch() + for d := int64(1); d <= 5; d++ { + b.Update(tbl, d, []string{"alpha"}) + } + b.Commit() + s.sync() + + r := s.Search(tbl, "alpha", 0, nil) + for d := int64(1); d <= 5; d++ { + if !hasDoc(r, d) { + t.Errorf("doc %d missing after batch, got %v", d, r.DocIds) + } + } +} + +// --- cold-build (all-new) path takes no forward read ------------------------- + +// TestUpdate_ColdBuildNoForwardRead: on a cold build (every doc new) the apply must NOT read the +// forward map (the read misses anyway). We assert this by counting forward reads through a hook: +// for an all-new batch the count stays 0. +func TestUpdate_ColdBuildNoForwardRead(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + var reads int + s.onForwardRead = func() { reads++ } + + b := s.NewBatch() + for d := int64(1); d <= 20; d++ { + b.Update(tbl, d, []string{"alpha", "beta"}) + } + b.Commit() + s.sync() + + if reads != 0 { + t.Errorf("cold build (all-new docids) must take no forward read, got %d reads", reads) + } + // sanity: the postings still landed. + if r := s.Search(tbl, "alpha", 0, nil); len(r.DocIds) != 20 { + t.Errorf("cold build should index 20 docs under alpha, got %d", len(r.DocIds)) + } +} + +// TestUpdate_WarmEditTakesForwardRead: editing a doc the store has already seen DOES read the +// forward map (to diff old vs new) — the complement of the cold-build assertion. +func TestUpdate_WarmEditTakesForwardRead(t *testing.T) { + s, tbl := newUpdateStore(t) + defer s.CloseAndWait() + + s.Update(tbl, 10, []string{"alpha"}) + s.sync() + + var reads int + s.onForwardRead = func() { reads++ } + + s.Update(tbl, 10, []string{"alpha", "beta"}) // doc 10 already known + s.sync() + + if reads == 0 { + t.Errorf("editing a known doc must read the forward map to diff, got 0 reads") + } +} + +// --- Update is a single-item Batch (spill mid-batch is fine) ------------------ + +// TestUpdate_SpillMidBatch: a CapBytes small enough to spill during a batch still applies every op +// correctly (the head + segment set are worker-owned; the apply runs to completion). +func TestUpdate_SpillMidBatch(t *testing.T) { + // Tiny cap so the batch spills partway through. + s, tbl := newUpdateStoreOpts(t, Options{CapBytes: 1}) + defer s.CloseAndWait() + + b := s.NewBatch() + for d := int64(1); d <= 50; d++ { + b.Update(tbl, d, []string{"alpha"}) + } + b.Commit() + s.sync() + + if len(s.segs) == 0 { + t.Fatalf("a tiny CapBytes should have spilled at least one segment mid-batch, got %d", len(s.segs)) + } + r := s.Search(tbl, "alpha", 0, nil) + if len(r.DocIds) != 50 { + t.Errorf("all 50 docs must be searchable across spilled segments + head, got %d: %v", len(r.DocIds), r.DocIds) + } +}