Skip to content

feat(persistence): FTS term-dict + FST sidecar durability across kill-9 (kernel M4, task #50) - #313

Merged
pilotspacex-byte merged 4 commits into
mainfrom
feat/fts-term-dict-sidecar
Jul 13, 2026
Merged

feat(persistence): FTS term-dict + FST sidecar durability across kill-9 (kernel M4, task #50)#313
pilotspacex-byte merged 4 commits into
mainfrom
feat/fts-term-dict-sidecar

Conversation

@pilotspacex-byte

Copy link
Copy Markdown
Contributor

Summary

Kernel M4, task #50: FTS was the last plane without kill-9 durability — the inverted index was rebuilt by full keyspace rescan on every boot, and the FST sidecar's load path was deliberately unwired (loaded term-ids were stale id-space garbage vs the rebuilt dict → silent FUZZY/PREFIX corruption).

Design

Combined sidecar {shard_dir}/{index_name}.tfst (magic TFS2, versioned, written via atomic_write_durable) persists per TEXT field: next_id, FST high-water mark, every (term, id) pair, and the FST bytes — together, so none can go stale relative to the others. On boot, load_term_fst_sidecars runs after index-schema restore but before the keyspace rescan: the rescan's get_or_insert resolves known terms to persisted ids and hands fresh non-colliding ids only to new terms — the loaded FST's id-space matches the live dictionary by construction, closing the corruption that kept the load path unwired. Fail-closed per index (missing/corrupt/version-mismatch/schema-change → full rescan, never partial). FT.COMPACT writes the sidecar; new additive FT.INFO counters sidecar_recovered_indexes / text_indexes_total.

Verification

  • Equivalence test: FUZZY/PREFIX expand_terms + BM25 results identical between sidecar-recovered restart and from-scratch rebuild.
  • New default-GREEN crash cells cross_plane_prod_{s1,s4}_text_fts_sidecar_isolated (create → docs → COMPACT → more docs → kill-9 → restart → FUZZY finds all + recovered-counter ≥1) — 3× green on macOS release AND Linux VM (46-cell suite green; second commit fixed a fixture false-negative the binary run caught — stemming edge, test-data bug not product bug).
  • New fuzz target term_fst_sidecar; 24 index_persist unit tests; fmt + clippy both matrices clean for all touched files.

With this, every plane (KV/vector/graph/text/WS/MQ/temporal) has kill-9 durability in prod config — the v0.8 headline invariant. Remaining RED cells are legacy-mode (--disk-offload disable) graph only.

Refs: task #50, K3 (#296), K4 (#297).

…-9 (kernel M4, task #50)

Design note:

FTS was the last plane not kill-9-lossless. Every restart rebuilt every
text index from scratch by rescanning the keyspace and reassigning term
ids by `DashTable` hash-iteration first-encounter order -- not
reproducible across restarts. That's why a pre-existing `.fst` sidecar
write path had its load path deliberately left unwired in production
(`TextStore::load_fst_sidecars` had zero callers): loading it once
(kernel-m2-brief-2026-07-12.md) corrupted FUZZY/PREFIX search silently --
the sidecar's baked-in term ids collided with whatever ids the fresh
rescan happened to assign the same terms this time.

Fix shape: persist the term dictionary itself alongside the FST, in one
atomic sidecar per (shard, index) -- `{shard_dir}/{index_name}.tfst`,
magic `TFS2`, version-stamped, written via the shared
`atomic_write_durable` primitive (temp + fsync + rename + dir-fsync, same
as every other text/vector sidecar). Per TEXT field it stores `next_id`,
`fst_high_water_mark`, every `(term, id)` pair, and the optional FST
bytes -- all four written together so they can never independently go
stale relative to each other.

`TermDictionary::from_pairs` reconstructs a dictionary whose ids are
taken VERBATIM from the sidecar (never reassigned) and whose `next_id`
continues the persisted high-water mark. Loading happens in
`TextStore::load_term_fst_sidecars`, wired into shard boot
(`src/shard/event_loop.rs`) immediately AFTER text index schemas are
restored from `text-indexes.meta` but BEFORE the keyspace auto-reindex
rescan runs. Seeding the term dict first is the whole trick: the rescan's
`get_or_insert` calls resolve already-known terms to their PERSISTED ids
(idempotent, not reassigned) and only hand out brand-new, non-colliding
ids to terms that are genuinely new since the sidecar was written. That
makes the loaded FST safe -- its baked-in ids and the live dictionary's
ids are the same id-space by construction, not by hope.

Staleness handling is fail-closed and per-index: missing sidecar (no
FT.COMPACT ever ran, or index created after last compact) is a silent
no-op -- today's full-rescan behavior, unchanged. Truncated/corrupt/
version-mismatched bytes, or a field-count mismatch (schema changed since
the sidecar was written), aborts loading for that index entirely --
never partially applies a sidecar (e.g. seeding the dict but skipping a
bad FST, or vice versa), which would silently reintroduce the exact
id-space mismatch this closes. `FT.COMPACT` now persists via the
combined saver (`save_term_fst_sidecar_for_index`) instead of the old
FST-only one; the old FST-only save/load pair is kept `#[cfg(test)]`
only, for its own pre-existing roundtrip tests.

Multi-shard: the sidecar is written per shard_dir (each shard owns its
own persist_dir), so it is naturally per-shard-keyed -- no cross-shard
aliasing risk.

Observability: new additive `FT.INFO` counters `sidecar_recovered_indexes`
/ `text_indexes_total` (same additive-merge convention as the vector
engine's `graph_segments` / `segments_with_exact_rerank`), set via a new
`TextIndex.recovered_from_sidecar` flag.

Testing:
- `TermDictionary::from_pairs`: roundtrip-equivalence to live
  `get_or_insert`, plus fail-closed cases (id >= next_id, duplicate id,
  duplicate term, hwm > next_id).
- `index_persist`: serialize/deserialize roundtrip, disk save/load
  roundtrip, missing-sidecar, bad magic/version, too-short, truncated
  mid-payload, non-UTF8 term bytes -- all fail closed (Err), never panic.
- `TextStore` equivalence gate (the core proof): build an index, compact
  (builds + persists the sidecar), then simulate a restart (fresh empty
  TextStore + schema-only index + `load_term_fst_sidecars` + re-index the
  same docs, exactly the boot sequence in event_loop.rs) and assert FUZZY
  and PREFIX `expand_terms` results -- and the BM25 search results built
  on top of them -- are byte-for-byte identical to the pre-crash index.
  Plus: missing-sidecar no-op, field-count-mismatch fail-closed.
- New fuzz target `term_fst_sidecar` (fuzz/fuzz_targets/) covers the
  sidecar decoder against arbitrary bytes.
- New default-GREEN crash-matrix cells
  `cross_plane_prod_{s1,s4}_text_fts_sidecar_isolated`
  (tests/crash_matrix_cross_plane): FT.CREATE, index docs, FT.COMPACT,
  index MORE docs post-compact (exercises id-continuation past the
  sidecar's high-water mark), kill -9, restart, assert a FUZZY query
  finds every doc and `FT.INFO sidecar_recovered_indexes >= 1`. Unlike
  the RED root-cause cells tracked elsewhere in this matrix, this one is
  expected to hold -- a regression here means the sidecar write, the boot
  wiring, or the id-continuation invariant broke.

Gates: text-module unit tests green (index_persist, term_dict, store --
including the new equivalence/fail-closed tests), `cargo fmt --check`
clean, `cargo clippy --lib --tests -- -D warnings` clean (default
features), `cargo clippy --no-default-features --features
runtime-tokio,jemalloc --lib --tests -- -D warnings` clean for every file
touched by this change (grep-confirmed; that matrix currently has ~33
pre-existing, unrelated clippy errors in files this change never touches
-- persistence_tick.rs / timers.rs / db_quota.rs / eviction.rs /
vector/store.rs -- left untouched per "surgical changes only"), fuzz
target compiles (`cargo check --bin term_fst_sidecar` inside fuzz/), and
the crash-matrix integration crate compiles clean
(`cargo test --test crash_matrix_cross_plane --no-run`). The binary-based
GREEN run of the new crash-matrix cells (which needs a built release
binary, and every cell in this suite is `#[ignore]`/"run explicitly" by
convention) was still building under heavy concurrent-build contention on
this machine at commit time -- run
`cargo test --release --test crash_matrix_cross_plane -- --ignored
text_fts_sidecar` to confirm before shipping.

Confidence: completeness 0.9, clarity 0.92, practicality 0.9,
optimization 0.85 (id-continuation avoids any FST rebuild cost on a
sidecar-recovered boot), edge-cases 0.88 (schema-change and corruption
paths covered; not covered: FST bytes present but term-dict absent from a
half-written pre-K3 sidecar format -- not reachable since this is a new
format, TFS2, distinct from the legacy TFST), self-evaluation 0.9.

author: Tin Dang
…e (task #50 follow-up)

The `text_fts_sidecar_isolated` scenario's pre-compact doc:1 body was "deep
learning machinery". "machinery" stems to a different token than "machine"
(Porter/Snowball -y handling), so it is NOT within edit-distance-1 of the
FUZZY probe "machn" used by the test. That made the GREEN tripwire cell
fail with a message that looked exactly like a real sidecar-recovery
regression ("FUZZY 'machn' must find {txtiso}:txt:1") on the first binary
run against the release build, but the failure was in the test fixture's
corpus, not in `load_term_fst_sidecars` or the sidecar format -- the other
4 of 5 docs (which do contain "machine") passed correctly both before and
after the fix.

Fixed by changing doc:1's body to "deep learning machine" so every doc in
the fixture actually contains a term that stems to "machin". Verified 3x
consecutive GREEN at both shards=1 and shards=4 against the release
binary (`cargo test --release --test crash_matrix_cross_plane -- --ignored
text_fts_sidecar`).

author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 047a1177-1de0-4ab2-a3ba-e6b27d939b4c

📥 Commits

Reviewing files that changed from the base of the PR and between 2482742 and cf791d5.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • fuzz/Cargo.toml
  • fuzz/fuzz_targets/term_fst_sidecar.rs
  • src/command/vector_search/ft_admin.rs
  • src/command/vector_search/ft_info.rs
  • src/shard/event_loop.rs
  • src/text/index_persist.rs
  • src/text/store.rs
  • src/text/term_dict.rs
  • tests/crash_matrix_cross_plane/planes.rs
  • tests/crash_matrix_cross_plane/scenarios.rs
  • tests/crash_matrix_cross_plane/tests_prod.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fts-term-dict-sidecar

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pilotspacex-byte
pilotspacex-byte merged commit bb4a58c into main Jul 13, 2026
8 checks passed
@TinDang97
TinDang97 deleted the feat/fts-term-dict-sidecar branch July 13, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants