Skip to content

Commit 1e978dd

Browse files
CorVousclaude
andauthored
Migration safety: rebuild FTS after incompatible-index wipe, fail fast on embedder feature gap (#210)
* fix(history): rebuild FTS from DB when on-disk index was wiped or empty Opening a store over a foreign/incompatible tantivy index (e.g. the retired Python impl's index with a different schema) makes open_or_recreate wipe it to a fresh empty index. rebuild_fts had no call sites, so all indexed history was silently lost and never repopulated. open_dir now surfaces whether it took the recreate path; HistoryStore::open repopulates any index that was recreated or is empty while its source table still holds rows, from the DB, synchronously (small corpus), logging counts. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(run): fail fast on embedder feature-gap before opening the store create_embedder's error (e.g. fastembed without the local-embed extra) was swallowed by .unwrap_or(None) at the async_main call site, so startup proceeded to open and wipe the FTS and only died later in create_projectors under a misleading Discord-token hint. Extract a testable resolve_embedder seam and call it in run_inner BEFORE Familiar::load_from_disk opens the store: on error, log the real message (which names the fix) and exit 1. The embedder is threaded into async_main. Co-Authored-By: Claude Opus 4.8 <[email protected]> * fix(activities): de-flake c1 wake test — raise recv_wake hang-guard to 30s recv_wake's 1s timeout is only a hang-guard (every caller .expects Some; none asserts None), but the runner does several spawn_blocking DB round-trips before publishing the wake, which can outlast 1s on a loaded current-thread test runtime — turning the guard into a false failure (~67% under CPU load). Raise the bound to 30s; a genuinely-stuck test still fails, the race is gone. Co-Authored-By: Claude Opus 4.8 <[email protected]> * style: rustfmt import ordering in commands/run.rs Co-Authored-By: Claude Opus 4.8 <[email protected]> --------- Co-authored-by: Claude Opus 4.8 <[email protected]>
1 parent 21d07af commit 1e978dd

5 files changed

Lines changed: 282 additions & 18 deletions

File tree

familiar-connect/src/activities/engine.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2697,7 +2697,12 @@ mod tests {
26972697
async fn recv_wake(
26982698
sub: &mut crate::bus::in_process::Subscription,
26992699
) -> Option<std::sync::Arc<Event>> {
2700-
timeout(StdDur::from_secs(1), sub.recv())
2700+
// Pure hang-guard: every caller `.expect`s `Some`, so the bound only
2701+
// exists to fail a genuinely-stuck test instead of hanging CI forever.
2702+
// Keep it generous — the runner does several `spawn_blocking` DB
2703+
// round-trips before publishing, which can outlast a tight 1s deadline
2704+
// on a loaded current-thread test runtime (the `c1_*` flake).
2705+
timeout(StdDur::from_secs(30), sub.recv())
27012706
.await
27022707
.ok()
27032708
.flatten()

familiar-connect/src/commands/run.rs

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@ use crate::activities::engine::{
2828
};
2929
use crate::bot::{BotHandle, build_activity_presence_cb};
3030
use crate::budget::TierBudget;
31+
use crate::config::EmbeddingConfig;
3132
use crate::context::layers::ChannelResolver;
3233
use crate::context::{
3334
Assembler, CharacterCardLayer, ConversationSummaryLayer, LorebookLayer, OperatingModeLayer,
3435
PeopleDossierLayer, RagContextLayer, RecentHistoryLayer, ReflectionLayer,
3536
};
36-
use crate::embedding::Embedder;
37+
use crate::embedding::{Embedder, EmbeddingError};
3738
use crate::familiar::Familiar;
3839
use crate::focus::FocusManager;
3940
use crate::sleep::maintenance::SleepPromptText;
@@ -97,6 +98,30 @@ pub fn resolve_familiar_root(
9798
Ok(dir)
9899
}
99100

101+
/// Resolve the startup embedder from `config`, or fail.
102+
///
103+
/// The composition root calls this BEFORE opening the history store / FTS: a
104+
/// misconfigured embedding backend (e.g. `fastembed` without the `local-embed`
105+
/// extra) must refuse to start rather than wipe the FTS and then die deep in
106+
/// `create_projectors` under a misleading Discord-token hint. The backend error
107+
/// (which already names the real fix) is propagated, never swallowed into
108+
/// `None`.
109+
///
110+
/// # Errors
111+
/// Whatever [`create_embedder`](crate::embedding::create_embedder) returns —
112+
/// unknown backend, bad dimensionality, or the `fastembed` `local-embed` gap.
113+
#[cfg_attr(
114+
not(feature = "discord"),
115+
allow(
116+
dead_code,
117+
reason = "the only non-test caller is the `discord`-gated `run_inner`; \
118+
the fail-fast contract is unit-tested under default features"
119+
)
120+
)]
121+
fn resolve_embedder(config: &EmbeddingConfig) -> Result<Option<Arc<dyn Embedder>>, EmbeddingError> {
122+
crate::embedding::create_embedder(config)
123+
}
124+
100125
/// The two hardcoded operating-mode strings (byte-exact; Python
101126
/// `_default_assembler`).
102127
fn operating_modes() -> HashMap<String, String> {
@@ -444,6 +469,19 @@ fn run_inner(token: &str, familiar_root: &Path) -> i32 {
444469
None
445470
};
446471

472+
// Resolve the embedder BEFORE loading the familiar (which opens + possibly
473+
// rebuilds the history store / FTS). A misconfigured backend must refuse to
474+
// start here, while nothing has been mutated — not fail deep in
475+
// `create_projectors` after the store is already open. Its error names the
476+
// real fix (e.g. the `local-embed` extra), so surface it verbatim.
477+
let embedder = match resolve_embedder(&config.embedding) {
478+
Ok(embedder) => embedder,
479+
Err(err) => {
480+
tracing::error!("Embedding backend unavailable: {err}");
481+
return 1;
482+
}
483+
};
484+
447485
let familiar = match Familiar::load_from_disk(
448486
familiar_root,
449487
llm_clients,
@@ -474,7 +512,7 @@ fn run_inner(token: &str, familiar_root: &Path) -> i32 {
474512
return 1;
475513
}
476514
};
477-
match runtime.block_on(async_main(token.to_owned(), familiar)) {
515+
match runtime.block_on(async_main(token.to_owned(), familiar, embedder)) {
478516
Ok(()) => 0,
479517
Err(err) => {
480518
tracing::error!(
@@ -601,7 +639,11 @@ fn spawn_signal_listener(controller: Arc<ShutdownController>) {
601639
clippy::too_many_lines,
602640
reason = "the composition root wires every subsystem; splitting obscures the ordering contract"
603641
)]
604-
async fn async_main(token: String, mut familiar: Familiar) -> anyhow::Result<()> {
642+
async fn async_main(
643+
token: String,
644+
mut familiar: Familiar,
645+
embedder: Option<Arc<dyn Embedder>>,
646+
) -> anyhow::Result<()> {
605647
use std::sync::Mutex;
606648

607649
use crate::bot::{ActivityResync, AsyncBotStore, BotStore, CreateBotDeps, create_bot};
@@ -689,8 +731,8 @@ async fn async_main(token: String, mut familiar: Familiar) -> anyhow::Result<()>
689731
})
690732
.await?;
691733

692-
let embedder = crate::embedding::create_embedder(&familiar.config.embedding).unwrap_or(None);
693-
734+
// Embedder resolved at the composition root (before the store opened); a bad
735+
// backend would already have aborted startup in `run_inner`.
694736
let voice_assembler = Arc::new(default_assembler(
695737
&familiar,
696738
familiar.config.voice_window_size,
@@ -1083,11 +1125,12 @@ async fn async_main(token: String, mut familiar: Familiar) -> anyhow::Result<()>
10831125
mod tests {
10841126
use super::{
10851127
ShutdownController, ShutdownStage, build_activity_engine, default_assembler,
1086-
resolve_familiar_root,
1128+
resolve_embedder, resolve_familiar_root,
10871129
};
10881130
use crate::activities::engine::ActivityEngine;
10891131
use crate::bot::{BotHandle, Presence, PresenceSink};
10901132
use crate::budget::TierBudget;
1133+
use crate::config::EmbeddingConfig;
10911134
use crate::familiar::Familiar;
10921135
use crate::focus::FocusManager;
10931136
use crate::processors::SendText;
@@ -1098,6 +1141,38 @@ mod tests {
10981141
use std::sync::{Arc, Mutex};
10991142
use tempfile::TempDir;
11001143

1144+
// --- resolve_embedder (composition-root fail-fast) ---
1145+
1146+
/// The composition root must surface the embedder feature-gap as an error
1147+
/// (which names the `local-embed` fix), not swallow it into `None` and let
1148+
/// startup proceed to mutate the store. The default test build lacks the
1149+
/// `local-embed` extra, so `fastembed` genuinely has no backend here.
1150+
#[test]
1151+
fn resolve_embedder_fastembed_without_extra_errors() {
1152+
let config = EmbeddingConfig {
1153+
backend: "fastembed".to_owned(),
1154+
..EmbeddingConfig::default()
1155+
};
1156+
let err = resolve_embedder(&config)
1157+
.err()
1158+
.expect("fastembed without the local-embed extra must fail fast");
1159+
assert!(
1160+
err.to_string().contains("local-embed"),
1161+
"error must name the real fix, got: {err}"
1162+
);
1163+
}
1164+
1165+
/// A disabled backend resolves to `None` without erroring — fail-fast must
1166+
/// not become fail-always.
1167+
#[test]
1168+
fn resolve_embedder_off_backend_is_none() {
1169+
let config = EmbeddingConfig {
1170+
backend: "off".to_owned(),
1171+
..EmbeddingConfig::default()
1172+
};
1173+
assert!(resolve_embedder(&config).unwrap().is_none());
1174+
}
1175+
11011176
// --- resolve_familiar_root (ported from test_run_cmd.py) ---
11021177

11031178
#[test]

familiar-connect/src/history/fts.rs

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,25 +182,31 @@ impl TantivyFts {
182182

183183
/// On-disk index rooted at `dir` (created if absent). If an existing index
184184
/// there is unreadable/version-incompatible, the directory is wiped and a
185-
/// fresh empty index is created — callers repopulate via
185+
/// fresh empty index is created. The returned flag is `true` in exactly that
186+
/// wipe-and-recreate case, so callers can repopulate from the source-of-truth
187+
/// tables via
186188
/// [`HistoryStore::rebuild_fts`](super::store::HistoryStore::rebuild_fts).
187-
pub fn open_dir(dir: &Path) -> Result<Self, StoreError> {
189+
pub fn open_dir(dir: &Path) -> Result<(Self, bool), StoreError> {
188190
std::fs::create_dir_all(dir).map_err(|e| StoreError::Fts(e.to_string()))?;
189-
let index = Self::open_or_recreate(dir)?;
190-
Self::finish(index)
191+
let (index, recreated) = Self::open_or_recreate(dir)?;
192+
Ok((Self::finish(index)?, recreated))
191193
}
192194

193-
fn open_or_recreate(dir: &Path) -> Result<Index, StoreError> {
195+
/// Open the on-disk index, or wipe-and-recreate it if incompatible. The bool
196+
/// reports whether the recreate path was taken.
197+
fn open_or_recreate(dir: &Path) -> Result<(Index, bool), StoreError> {
194198
let schema = build_schema();
195199
let mmap = MmapDirectory::open(dir).map_err(|e| StoreError::Fts(e.to_string()))?;
196200
if let Ok(index) = Index::open_or_create(mmap, schema.clone()) {
197-
return Ok(index);
201+
return Ok((index, false));
198202
}
199203
// Incompatible/corrupt on-disk index — wipe and start fresh.
200204
std::fs::remove_dir_all(dir).map_err(|e| StoreError::Fts(e.to_string()))?;
201205
std::fs::create_dir_all(dir).map_err(|e| StoreError::Fts(e.to_string()))?;
202206
let mmap = MmapDirectory::open(dir).map_err(|e| StoreError::Fts(e.to_string()))?;
203-
Index::open_or_create(mmap, schema).map_err(|e| StoreError::Fts(e.to_string()))
207+
let index =
208+
Index::open_or_create(mmap, schema).map_err(|e| StoreError::Fts(e.to_string()))?;
209+
Ok((index, true))
204210
}
205211

206212
fn finish(index: Index) -> Result<Self, StoreError> {
@@ -233,6 +239,14 @@ impl TantivyFts {
233239
})
234240
}
235241

242+
/// Whether the index currently holds no documents. Used after `open_dir` to
243+
/// decide whether a freshly-created (but not recreate-flagged) index needs
244+
/// repopulating from the source tables.
245+
#[must_use]
246+
pub fn is_empty(&self) -> bool {
247+
self.reader.searcher().num_docs() == 0
248+
}
249+
236250
/// Test seam: install (or clear with `None`) a fake commit that fails before
237251
/// the real commit runs. Mirrors monkeypatching `FtsIndex._commit_writer`.
238252
pub fn set_commit_fault(&self, fault: Option<CommitFault>) {
@@ -414,6 +428,41 @@ mod tests {
414428
assert!(hits[0].1 > 0.0, "BM25 score must be positive");
415429
}
416430

431+
#[test]
432+
fn open_dir_flags_recreate_on_incompatible_schema() {
433+
use tantivy::Index;
434+
use tantivy::schema::{STORED, STRING, Schema};
435+
436+
let dir = tempfile::tempdir().unwrap();
437+
// Write an index whose schema differs from `build_schema` (row_id as text
438+
// rather than i64) — stands in for the Python-vs-Rust incompatibility.
439+
let mut sb = Schema::builder();
440+
sb.add_text_field("row_id", STRING | STORED);
441+
sb.add_text_field("content", STRING | STORED);
442+
Index::create_in_dir(dir.path(), sb.build()).unwrap();
443+
444+
let (idx, recreated) = TantivyFts::open_dir(dir.path()).unwrap();
445+
assert!(
446+
recreated,
447+
"incompatible on-disk schema must trigger recreate"
448+
);
449+
assert!(idx.is_empty(), "a recreated index starts empty");
450+
}
451+
452+
#[test]
453+
fn open_dir_preserves_compatible_index() {
454+
let dir = tempfile::tempdir().unwrap();
455+
{
456+
let (idx, recreated) = TantivyFts::open_dir(dir.path()).unwrap();
457+
assert!(!recreated);
458+
idx.add(1, "the quick brown fox").unwrap();
459+
}
460+
let (idx, recreated) = TantivyFts::open_dir(dir.path()).unwrap();
461+
assert!(!recreated, "a compatible reopen must not wipe the index");
462+
assert!(!idx.is_empty());
463+
assert_eq!(idx.search("fox", 5).len(), 1);
464+
}
465+
417466
#[test]
418467
fn upsert_replaces_prior_doc() {
419468
let idx = TantivyFts::in_memory().unwrap();

familiar-connect/src/history/store.rs

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,9 +1053,15 @@ impl HistoryStore {
10531053
std::fs::create_dir_all(parent)?;
10541054
}
10551055
let fts_root = parent.unwrap_or_else(|| Path::new(".")).join("fts");
1056-
let fts_turns = Box::new(TantivyFts::open_dir(&fts_root.join("turns"))?);
1057-
let fts_facts = Box::new(TantivyFts::open_dir(&fts_root.join("facts"))?);
1058-
Self::init(Db::open(path)?, fts_turns, fts_facts)
1056+
let (fts_turns, turns_recreated) = TantivyFts::open_dir(&fts_root.join("turns"))?;
1057+
let (fts_facts, facts_recreated) = TantivyFts::open_dir(&fts_root.join("facts"))?;
1058+
// An index that was wiped-and-recreated, or is otherwise empty, has lost
1059+
// whatever the retired Python impl indexed; repopulate it from the DB.
1060+
let turns_stale = turns_recreated || fts_turns.is_empty();
1061+
let facts_stale = facts_recreated || fts_facts.is_empty();
1062+
let store = Self::init(Db::open(path)?, Box::new(fts_turns), Box::new(fts_facts))?;
1063+
store.repopulate_stale_fts(turns_stale, facts_stale)?;
1064+
Ok(store)
10591065
}
10601066

10611067
/// Open a store with caller-supplied FTS indexes. The DB is set up exactly
@@ -3285,13 +3291,70 @@ impl HistoryStore {
32853291

32863292
/// Drop and repopulate the tantivy turns index from `turns`.
32873293
pub fn rebuild_fts(&self) -> Result<(), StoreError> {
3294+
self.rebuild_turns_fts().map(|_| ())
3295+
}
3296+
3297+
/// Clear and repopulate the turns index from the `turns` table; returns the
3298+
/// number of rows indexed.
3299+
fn rebuild_turns_fts(&self) -> Result<usize, StoreError> {
32883300
self.fts_turns.clear()?;
32893301
let rows = self.db.query_map(
32903302
"SELECT id, content FROM turns ORDER BY id ASC",
32913303
vec![],
32923304
|r| Ok((r.get::<_, i64>("id")?, r.get::<_, String>("content")?)),
32933305
)?;
3294-
self.fts_turns.add_many(&rows)
3306+
let count = rows.len();
3307+
self.fts_turns.add_many(&rows)?;
3308+
Ok(count)
3309+
}
3310+
3311+
/// Clear and repopulate the facts index from the `facts` table; returns the
3312+
/// number of rows indexed.
3313+
fn rebuild_facts_fts(&self) -> Result<usize, StoreError> {
3314+
self.fts_facts.clear()?;
3315+
let rows =
3316+
self.db
3317+
.query_map("SELECT id, text FROM facts ORDER BY id ASC", vec![], |r| {
3318+
Ok((r.get::<_, i64>("id")?, r.get::<_, String>("text")?))
3319+
})?;
3320+
let count = rows.len();
3321+
self.fts_facts.add_many(&rows)?;
3322+
Ok(count)
3323+
}
3324+
3325+
/// Repopulate any FTS index that was wiped/recreated or is empty while its
3326+
/// source table still holds rows (the Python-index migration failure mode).
3327+
/// Synchronous — the live corpus is ~10k rows.
3328+
fn repopulate_stale_fts(&self, turns_stale: bool, facts_stale: bool) -> Result<(), StoreError> {
3329+
if turns_stale && self.table_has_rows("turns")? {
3330+
let rows = self.rebuild_turns_fts()?;
3331+
tracing::info!(
3332+
target: "familiar_connect.history",
3333+
index = "turns",
3334+
rows,
3335+
"rebuilt FTS index from DB (on-disk index was wiped or empty)"
3336+
);
3337+
}
3338+
if facts_stale && self.table_has_rows("facts")? {
3339+
let rows = self.rebuild_facts_fts()?;
3340+
tracing::info!(
3341+
target: "familiar_connect.history",
3342+
index = "facts",
3343+
rows,
3344+
"rebuilt FTS index from DB (on-disk index was wiped or empty)"
3345+
);
3346+
}
3347+
Ok(())
3348+
}
3349+
3350+
/// Cheap existence probe for one of the fixed source tables.
3351+
fn table_has_rows(&self, table: &str) -> Result<bool, StoreError> {
3352+
// `table` is a fixed literal from `repopulate_stale_fts`, never user input.
3353+
let present = self
3354+
.db
3355+
.query_scalar_i64(format!("SELECT EXISTS(SELECT 1 FROM {table})"), vec![])?
3356+
.unwrap_or(0);
3357+
Ok(present != 0)
32953358
}
32963359

32973360
/// Highest turn id indexed for `familiar_id` (a `MAX(turns.id)` query; the

0 commit comments

Comments
 (0)