fix(db): sync the FTS index from the schema; give the headless engine its own maintenance - #462
Merged
Conversation
… its own maintenance
`source_items_fts` is an external-content FTS5 table (`content='source_items'`),
so SQLite maintains nothing about it automatically — every write to
`source_items` has to be mirrored into the index by hand. Three paths never were:
- `batch_upsert_pending_source_items` wrote title/content with no FTS statement
at all, so any item whose embedding failed was unsearchable.
- Nothing in the repository issued an FTS `'delete'`. `cleanup_old_items`,
`run_maintenance`, `prune_noise` and the cascade trigger all removed rows from
`source_items` and left their postings behind. Retention had simply never
fired on the founder's machine; the first successful run would have made
search worse, not better.
- The paths that DID write used `INSERT OR REPLACE` *after* updating
`source_items`. On an external-content table the implicit REPLACE-delete reads
the old values back from the content table, which by then already held the NEW
text — so it removed the new postings and stranded the old ones.
Measured on a read-only snapshot of the live 247 MB corpus: FTS5
`('integrity-check', 1)` fails, 2,631 divergent terms across 38 items, and a
search for a word that had been edited OUT of an item still returns it.
`('integrity-check', 0)` passes on that same corpus — on an external-content
table rank 0 checks only the index's own b-trees — which is a large part of why
this went unnoticed. `PRAGMA quick_check`, which runs at every startup, also
passes.
Schema 104 replaces the hand-maintenance with `trg_source_items_fts_{insert,
update,delete}` and rebuilds the index once. A trigger
is the right home because no future call site can forget it, and because the
`'delete'` command needs the pre-update values that only `OLD.*` still has. The
UPDATE trigger is narrowed to `OF title, content` with a `WHEN old IS NOT new`
guard, so this is strictly *less* write amplification than the unconditional
`INSERT OR REPLACE` it replaces: the scoring drain's `relevance_score` stamps
and a re-fetch that rewrites identical text now touch the index not at all.
Also here:
- The headless engine runs `run_scheduled_maintenance` at the end of every
cycle, at the end of a drain, and every 10 drain cycles. Every previous caller
lived in the GUI monitoring loop, so a headless-only day did all of the
writing and none of the upkeep — `scheduler_state` froze at 2026-08-12 13:54
while `fourda-engine` wrote until 08-13 14:05 with zero checkpoints, zero
`PRAGMA optimize` and zero VACUUM. The TRUNCATE gate drops from 50 MB to
16 MB, a size the engine can actually reach: `wal_autocheckpoint = 1000` at a
4 KiB page churns around 4 MB, so an un-truncated WAL sat between the two
numbers indefinitely (25.9 MB when reported, 47.7 MB a day later).
- Backup pruning understood only `<db>.backup.vN`, so the two unbounded families
grew forever: hand-made `.bak-pre-*` snapshots (up to 1.57 GB each) and
`.db.corrupt-<unix>` quarantine copies. It now keeps the newest 2 of each
family independently, matched strictly against this database's own file name,
and treats a hand-made snapshot plus its `-wal`/`-shm` siblings as ONE
retention unit — a database restored without its WAL is worse than neither.
The retention rule is extracted as a pure function with tests. This deletes
nothing now — it only makes the pruner capable of collecting them.
- `prune_orphaned_project_dependencies` no longer calls `std::fs::metadata`
with a transaction open (one dead mapped network drive pinned a WAL snapshot
for a full SMB timeout), and its delete phase opens `BEGIN IMMEDIATE` so
contention is a bounded wait rather than an un-retryable
`SQLITE_BUSY_SNAPSHOT` on lock upgrade.
Nine new tests in `db/fts_sync_tests.rs` cover the insert, update, delete,
scoring-update, rebuild and `hybrid_search` paths, plus seven for the pruner.
Verified real by restoring the pre-fix `sources.rs` and `migrations.rs` and
re-running: 8 of the 9 fail, each with the symptom it names. The ninth
(`scoring_updates_do_not_disturb_the_index`) correctly passes before and after.
The real migration was also run end-to-end over a copy of a snapshot of the
live corpus: 12,273 items to schema 104 in 5.9 s with `source_vec` intact,
`('integrity-check', 1)` passing afterwards, and passing again after a
full-corpus retention delete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUeKTKwNmdow8yUk3q8RB2
runyourempire
enabled auto-merge (squash)
August 15, 2026 16:24
runyourempire
added a commit
that referenced
this pull request
Aug 15, 2026
…destroys the corpus) (#464) ## Opening a newer database with an older 4DA build destroys the user's corpus Found while rehearsing the schema-104 activation from #462 on a **copy** of the founder's live database. Measured, not inferred. The 2026-08-14 build opened a schema-104 database. The migration guard in `migrations.rs` correctly refused it. `get_database()`'s last-resort fallback then read that refusal as corruption: ``` WARN 4da::db: Database open failed after preemptive recovery — last-resort fallback error=Database schema version 104 is newer than this version of 4DA supports (max 103). INFO 4da::db: Corrupt database preserved, creating fresh database corrupt="…\4da.db.corrupt" INFO 4da::db: Running Phase 1: multi-format files (schema version 1 -> 2) ``` Before / after, same directory: | file | size | schema | source_items | |---|---|---|---| | `4da.db` (what the app now uses) | 1.3 MB | 103 | **0** | | `4da.db.corrupt` (the real corpus) | 283 MB | 104 | **15,659** | The app comes up empty and starts re-fetching from zero. One log line is the only trace. **Every rollback to a previous release does this** — and on this fleet it needs no rollback at all, because the scheduled background refresh runs whatever was last compiled into `target/debug/fourda.exe`. The guard itself has been correct since 2026-03-29. The bug is entirely in how the caller classifies its error. ## The fix **1. A schema-too-new error is routed away from the corrupt-db fallback.** `state.rs` now bails out and returns the error, mirroring the `is_database_lock_contention` bail-out directly above it — that precedent already existed for exactly this shape of problem. The detector keys on **both** `SQLITE_MISMATCH` and a phrase shared with the producer via `SCHEMA_TOO_NEW_PHRASE`, so: - an unrelated `SQLITE_MISMATCH` cannot suppress genuine corruption recovery, and - producer and detector cannot drift apart. Getting this wrong in either direction is expensive: too narrow and the corpus is destroyed; too broad and a genuinely corrupt database never heals. Both directions are tested. **2. Quarantine copies are no longer auto-pruned.** #462 added `*.db.corrupt` / `*.db.corrupt-<unix>` to the backup pruner to reclaim disk. That was my change and it was unsafe: a quarantined database is the user's only copy of that data, and — per the bug above — can be their entire live corpus. Reclaiming 338 MB is not worth a chance of deleting 15,659 items. They stay *classified* so the pruner can report the disk they hold; only `*.db.backup.vN` and hand-made `*.bak-*` snapshots are collected. **3. The guard gets tests.** It had none in ~5 months. A future schema is refused with an error that says why; a database at the current schema still reopens cleanly with a consistent FTS index (so the guard cannot pass by being indiscriminate); and one test asserts **end-to-end that the error the guard actually produces is the one the detector recognises** — testing them apart would let them drift and silently re-arm the corpus-destroying path. ## Also Documents the two skew traps in CLAUDE.md's gotchas. Both cost real time this week and both present as your own bug: - **Old binary vs. newer database** (above) — migrate and rebuild together. - **Stale worktree base** — `main` moved 6 commits during one agent session, after which the pre-commit ghost gate failed citing 13 "NEW" ghost commands in files the branch never touched. They had simply been allowlisted upstream in #434. Re-fetch and rebase before committing; if a gate blames code you did not write, check your base before you touch an allowlist. ## Verification `cargo fmt --check` clean · `cargo clippy -- -D warnings` clean for **both** default and `--features experimental` · `cargo test --lib` **4,378 passed / 0 failed / 8 ignored**. The founder's live database was **not** migrated and **not** written to. All of the above was measured on copies taken with SQLite's online backup API. That decision is the point of this PR: activating #462 before the binaries are rebuilt would have destroyed the corpus on the next scheduled refresh. ## Activation, in the right order With this merged, activation is safe and is a single ordered operation: 1. `git pull` in `D:\4DA` 2. `cd src-tauri && cargo build --bin fourda --bin fourda-engine` 3. launch — the migration runs, rebuilding the FTS index (447 ms on the 15,659-item corpus) Step 2 before step 3 is the whole rule. Doing 3 with a stale step 2 is what this PR makes survivable. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
runyourempire
added a commit
that referenced
this pull request
Aug 16, 2026
…own detector still read "frozen" (#475) #462 gave the headless engine its own DB maintenance, and it works — the WAL checkpoint and `PRAGMA optimize` genuinely run at the end of every cycle. But `run_cycle_maintenance()` never recorded the run. `scheduler_state::persist_run(DB_MAINTENANCE, ..)` was reachable only from the GUI monitoring loop (`monitoring.rs:510`), so on a machine that runs the engine via the scheduled task and rarely opens the GUI, that row stays frozen at whenever a GUI last ran. **Measured on the live corpus today:** ``` db_maintenance runs=25 last=3001 min ago (~50 hours) vacuum runs= 1 last=6449 min ago ``` …while the engine had checkpointed on every cycle throughout — observed directly in yesterday's run log (`Scheduled maintenance: WAL checkpoint + optimize complete wal_mb=9`). ## Why this is worth its own PR That row is not incidental telemetry. It is the exact signal the forensic audit used to **find** the missing headless maintenance in the first place — *"scheduler_state froze at 2026-08-12 13:54:19 while fourda-engine kept writing until 08-13 14:05:11 — a 24-hour window with zero checkpoint"*. Leaving it unwritten converts that detector into a permanent false negative. The next person to check concludes maintenance is broken when it is running fine — or concludes it is broken when it genuinely is, and **cannot tell the two apart**. That is the audit's own thesis reintroduced by the fix for it. ## Two deliberate choices **The stamp is written after the work, not before.** The GUI path stamps first and then runs, which is defensible there because it also uses the stamp as an interval lock. Here the stamp has one job — to say maintenance ran — so it is only written when it did. **An unreadable clock skips the write rather than defaulting to `0`.** A zero timestamp renders as "never ran", which is the same lie in the other direction. Losing one stamp is recoverable; a false one is not. ## Verification `cargo check` and `cargo clippy --bin fourda-engine -- -D warnings` clean; `cargo fmt --check` clean. **Not covered by a unit test, stated plainly:** `persist_run` opens its own connection through the global database path, so there is no seam to point it at a temp DB without a refactor larger than the fix. The falsifier is direct and cheap instead — `scheduler_state.db_maintenance` must advance within one engine cycle of the rebuilt binary running. I will verify that live after merge rather than assert it here. ## Left open deliberately Nothing runs VACUUM on a headless-only machine. `run_scheduled_maintenance` is checkpoint + optimize; VACUUM is a separate, heavier GUI-side job — and `vacuum` above has run **exactly once** in this corpus's lifetime. That needs its own decision about cadence and lock behaviour, not a line in this function. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Fq96xWyPQjx2bCCzWtsnC9 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
source_items_ftsis an external content FTS5 table (content='source_items'). SQLitestores only the inverted index and reads the text back out of
source_items, so itmaintains nothing automatically — every write has to be mirrored in by hand. Three
paths never were:
batch_upsert_pending_source_itemstitle/contentwith no FTS statement at all — any item whose embedding failed was unsearchable.cleanup_old_items,run_maintenance,prune_noise,trg_source_items_cascade_deletesource_itemsand left their postings behind. No FTS'delete'existed anywhere in the repository.upsert_source_item,batch_upsert_source_itemsINSERT OR REPLACEafter updatingsource_items. On an external-content table the implicit REPLACE-delete reads the old values back from the content table — which already held the NEW text — so it removed the new postings and stranded the old.The third one was not in the original report and is the dominant cause on the live corpus:
it runs on every re-fetch of an item whose text changed.
Measured on a read-only snapshot of the live 247 MB corpus
Rank matters, and it is why this went unnoticed. On an external-content table
('integrity-check', 0)checks only that the index's own b-trees are well formed — itpasses on the corrupted corpus. Only
('integrity-check', 1)recomputes the indexchecksum from
source_items.PRAGMA quick_check, whichDatabase::newruns at everystartup, also passes.
The user-visible symptom, reproduced on real rows: searching a word that had been edited
out of an item still returns that item.
The fix
Schema 104 replaces all hand-maintenance with
trg_source_items_fts_{insert,update,delete}and rebuilds the index once. The three manual FTS statements are deleted. Measured
end-to-end on a copy of the live database, the whole
Database::newpath — pre-migrationbackup copy of 247 MB included — took 5.9 s.
A trigger is the right home for two reasons: no future call site can forget it, and the
'delete'command needs the pre-update values that onlyOLD.*still has.Two deliberate narrowings on the UPDATE trigger make this strictly less write
amplification than what it replaces:
OF title, content— the scoring drain stamps thousands ofrelevance_scoreupdatesper run and now touches the index not at all.
WHEN OLD.title IS NOT NEW.title OR OLD.content IS NOT NEW.content— a re-fetch thatrewrites identical text (the common case) does no index work, where the unconditional
INSERT OR REPLACEalways did.Migration, not startup repair. A guarded startup repair would need
integrity-checkrank 1 — a full tokenizing scan of the content table — on every launch of both the GUI and
the engine, and it would grow linearly with the corpus. The migration runs exactly once,
inside the existing transactional + backed-up +
migration_history-recorded framework.fts_integrity_check()andrebuild_fts_index()are exposed for diagnostics and tests.Also in this PR
Defect 2 — headless engine had no DB maintenance.
run_scheduled_maintenancenow runsat the end of every headless cycle, at the end of a drain, and every 10 drain cycles.
Every previous caller lived in the GUI monitoring loop, so a headless-only day did all of
the writing and none of the upkeep —
scheduler_statefroze at 2026-08-12 13:54 whilefourda-enginewrote until 08-13 14:05 with zero checkpoints. The TRUNCATE gate drops50 MB → 16 MB, a size the engine can actually reach:
wal_autocheckpoint = 1000at a4 KiB page churns around 4 MB, so an un-truncated WAL sat between the two numbers
indefinitely (25.9 MB when reported, 47.7 MB a day later).
Defect 3 — backup pruning. The pruner understood only
<db>.backup.vN, so twounbounded families grew forever: hand-made
.bak-pre-*snapshots and.db.corrupt-<unix>quarantine copies (the corruption-recovery path writes one per incident). It now keeps the
newest 2 of each family independently, where the retention unit for a hand-made
snapshot is the snapshot plus its
-wal/-shmsiblings — a database restored withoutits WAL, or a WAL without its database, is worse than neither. (The first cut of this
counted the sibling as its own slot; my own
families_are_pruned_independentlytest caughtit splitting pairs, and there is now a test pinning the pairing rule.)
Conservative by construction: matched strictly against this database's own file name, an
unparseable suffix is left alone, a file whose mtime cannot be read sorts newest rather
than oldest,
4da.db.bakeryis not mistaken for a backup, and the just-written backup isprotected. The retention rule is extracted as a pure function with tests — the previous
prune bug survived precisely because the rule was only reachable through real
read_diroutput. Nothing is deleted by this PR; the pruner is merely made capable.
Defect 4 — partial.
prune_orphaned_project_dependenciesis restructured intoread / stat / write phases so
std::fs::metadatano longer runs with a transaction open(one dead mapped network drive pinned a WAL snapshot for a full SMB timeout, stalling
checkpointing), and its delete phase opens
BEGIN IMMEDIATEso contention becomes abounded wait instead of an un-retryable
SQLITE_BUSY_SNAPSHOTon lock upgrade.The broader
retry_on_busy+BEGIN IMMEDIATEsweep across the codebase is not done —see "Left untouched" below.
Verification
The new tests are real. Restored the pre-fix
sources.rsandmigrations.rs, kept thefinal set of tests, and re-ran: 8 of 9 fail, each with the symptom it names.
The real migration was run over the real corpus. A throwaway in-crate test (not
committed) pointed
Database::newat a copy of the live snapshot, so the whole Rust pathran — sqlite-vec registration, pre-migration backup, schema 103 → 104, the new pruner:
The Python-level verification could not cover this: stock
sqlite3cannot loadvec0, sonothing touching
source_vecwas exercised there.Gates:
cargo fmt --checkclean ·cargo clippy -- -D warningsclean for both defaultand
--features experimental·check-file-sizes,check-doc-location,private-asset-guard,ghost-commands,compound-quality-checkandvalidate-translationsclean.cargo test --tests— 4,640 passed / 0 failed / 13 ignored across all 9 binaries:fourda_lib(lib)stack_simulationvictauri_dogfoodpipeline_integrationclimigration_testssource_resiliencefourda,fourda-engineThe operator's live database was never opened for writing. All measurement was done on
snapshots taken with SQLite's online backup API (a plain file copy of a live WAL database
tears).
Two things worth knowing about the machine, not this PR
os error 112) — Defect 3's exactfailure mode, arriving on its own. 14 worktrees hold ~230 GB of
src-tauri/targetbetweenthem, and
cleanup-orphaned-worktrees.cjs --executereclaims none of it (0 dirs removable;it only has 8 merged branches to delete). Space was freed and every suite re-run.
c1fd348c; by commit timeorigin/mainwas 6 commits ahead, and the pre-commit ghost gatefailed with 13 new ghost commands in files this PR never touches. The cause was that
scripts/ghost-command-backlog.jsonhad gained 13 entries upstream (#434 — "unblock Rustcommits on the ghost gate"). Rebasing onto current
maincleared it. Worth a note in theworktree docs: re-fetch before committing, because that failure reads as your fault.
Left untouched
The
retry_on_busyhelper +BEGIN IMMEDIATEsweep (rest of Defect 4). ApplyingBEGIN IMMEDIATEand a retry wrapper to the codebase'sunchecked_transaction()callsites is a large, uniform change to locking behavior, and doing it to some write paths is
worse than doing it to none — mixed deferred/immediate writers can deadlock in ways neither
does alone. It wants its own PR with a contention benchmark. The one site fixed here was
fixed because it had a specific, independently-diagnosed bug (blocking I/O inside a
transaction) rather than as a partial sweep.
busy_timeoutleft at 5000 ms. Raising it is a one-line change that would reduceSQLITE_BUSY, but it trades errors for UI stalls of up to the new timeout, and I have nocontention measurement to size it from. Deliberate non-change.
The 262 ad-hoc
Connection::opencall sites are out of scope as instructed.Nothing on the operator's machine was deleted or mutated. The migration will run on
first launch after merge; it writes
4da.db.backup.v103first (existing behavior) and thewhole step measured 5.9 s on a copy of the current corpus.
Note for activation: both binaries need rebuilding before the migration takes effect —
fourda.exeandfourda-engine.exeeach runDatabase::new, and whichever starts firstperforms the migration.