Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions crates/infigraph-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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..."
Expand Down Expand Up @@ -503,3 +525,40 @@ pub struct IndexResult {
pub extractions: Vec<FileExtraction>,
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"
);
}
}
Loading