From 1db387d99e0e631067ec884ac8e0ba34d7ddc017 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Sun, 19 Jul 2026 00:54:05 -0400 Subject: [PATCH] fix: don't wipe the graph on lock contention from another live process Infigraph::init() treated every KuzuBackend::open() failure as corruption and wiped + rebuilt the database -- including "Could not set lock on file", Kuzu's own error for "another process already has this database open." That's lock contention, not corruption: a second `infigraph` command (e.g. `stats`) running while an `infigraph watch` already holds the graph open would silently destroy the watcher's live data and rebuild from whatever partial state it could see. Add is_lock_contention_error() to distinguish Kuzu's lock-contention error text from genuine corruption (e.g. the "Database ID ... does not match" error, which legitimately still triggers a wipe). On lock contention, return a clear error instead of wiping. Verified end-to-end with two real, separate OS processes (a real `infigraph watch` holding a graph open, a second `infigraph stats` invocation against the same path): previously the second call's failure wiped the watcher's data; now it returns "graph is locked by another infigraph process ... not corrupted, so it was left untouched" and the watcher's data survives intact. Reproduced against this session's own history: this is what corrupted sittir's .infigraph/graph earlier tonight. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TAXDJyFdnA4BV1U2fxufdC --- crates/infigraph-core/src/lib.rs | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/infigraph-core/src/lib.rs b/crates/infigraph-core/src/lib.rs index 37de9d2..e97d165 100644 --- a/crates/infigraph-core/src/lib.rs +++ b/crates/infigraph-core/src/lib.rs @@ -48,6 +48,18 @@ pub(crate) fn escape_str(s: &str) -> String { s.replace('\\', "\\\\").replace('\'', "\\'") } +/// Kuzu's IO-layer error for "another process holds this database's lock" +/// (see docs.ladybugdb.com/concurrency) is lock contention, not corruption. +/// `GraphStore::open` collapses the underlying Kuzu error into a stringified +/// `anyhow::Error` before it reaches `Infigraph::init`, so there's no +/// structured error variant to match on here -- only Kuzu's own error text. +/// This was previously indistinguishable from genuine corruption, so a +/// second `infigraph` process opening a graph while a watcher already had +/// it open would trigger `wipe_graph`, destroying the watcher's live data. +fn is_lock_contention_error(err: &anyhow::Error) -> bool { + err.to_string().contains("Could not set lock on file") +} + /// The main entry point for the infigraph framework. pub struct Infigraph { root: PathBuf, @@ -110,6 +122,16 @@ impl Infigraph { self.backend_kind = BackendKind::Kuzu(kb); Ok(()) } + Err(first_err) if is_lock_contention_error(&first_err) => { + // Another live process (e.g. a running `infigraph watch`) holds + // this database open -- not corruption. Wiping here would destroy + // a perfectly good graph out from under that process. + Err(first_err).with_context(|| { + "graph is locked by another infigraph process (e.g. a running \ + `infigraph watch`) -- not corrupted, so it was left untouched. \ + Run `infigraph watch-status` or try again in a moment." + }) + } Err(first_err) => { eprintln!( "[graph] open failed ({first_err}), wiping corrupt graph and rebuilding..." @@ -503,3 +525,40 @@ pub struct IndexResult { pub extractions: Vec, pub resolve_stats: resolve::ResolveStats, } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression test for a data-loss bug: `Infigraph::init()` used to + /// treat every Kuzu open failure as corruption and wipe the graph -- + /// including plain lock contention from another live process (e.g. a + /// running `infigraph watch`), destroying its data. `is_lock_contention_error` + /// is the check that now distinguishes the two. A genuine two-OS-process + /// reproduction isn't exercised here -- `Database::new()` doesn't + /// conflict across two `Infigraph` instances in the *same* process, only + /// across separate processes, and this codebase deliberately avoids + /// fork()-based tests given the tokio/rayon runtime state that would be + /// undefined in a forked child (same reasoning as `scip_enrich_exit_message` + /// in infigraph-cli's index.rs, which tests extracted logic rather than + /// the full spawn path for the same class of reason). + #[test] + fn is_lock_contention_error_matches_kuzu_lock_message() { + let err = anyhow::anyhow!( + "failed to open kuzu db: IO exception: Could not set lock on file : /repo/.infigraph/graph" + ); + assert!(is_lock_contention_error(&err)); + } + + #[test] + fn is_lock_contention_error_does_not_match_genuine_corruption() { + let err = anyhow::anyhow!( + "failed to open kuzu db: Runtime exception: Database ID for temporary file \ + '/repo/.infigraph/graph.wal.checkpoint' does not match the current database." + ); + assert!( + !is_lock_contention_error(&err), + "a genuine format/ID mismatch must still be treated as corruption and wiped" + ); + } +}