From 5e44c8c6a7a3f576c99af8476eea08943b3b83e0 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 18:24:00 -0400 Subject: [PATCH 1/8] feat: stamp compile-time build hash into infigraph-core Co-Authored-By: Claude Fable 5 # Conflicts: # crates/infigraph-core/src/lib.rs --- crates/infigraph-core/build.rs | 60 +++++++++++++++++++++++++ crates/infigraph-core/src/lib.rs | 6 +++ crates/infigraph-core/tests/lockfile.rs | 9 ++++ 3 files changed, 75 insertions(+) create mode 100644 crates/infigraph-core/build.rs create mode 100644 crates/infigraph-core/tests/lockfile.rs diff --git a/crates/infigraph-core/build.rs b/crates/infigraph-core/build.rs new file mode 100644 index 0000000..60b71d3 --- /dev/null +++ b/crates/infigraph-core/build.rs @@ -0,0 +1,60 @@ +use std::path::Path; +use std::process::Command; + +fn main() { + let sha = Command::new("git") + .args(["rev-parse", "--short=12", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + let dirty = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| !o.stdout.is_empty()) + .unwrap_or(false); + + let hash = match sha { + Some(s) if dirty => format!("{s}-dirty"), + Some(s) => s, + None => "unknown".to_string(), + }; + println!("cargo:rustc-env=INFIGRAPH_BUILD_HASH={hash}"); + + // `.git/HEAD` alone only changes on a checkout of a different ref; an + // ordinary commit on the current branch instead updates + // `refs/heads/` (or `packed-refs`, once refs get packed), so all + // three must be watched or build_hash() keeps reporting the previous + // commit's SHA across normal dev-loop rebuilds. Resolve the git dir via + // `git rev-parse --git-dir` rather than assuming a fixed relative path; + // if git isn't available, skip rerun emission entirely -- this matches + // the "unknown" degradation above. + let git_dir = Command::new("git") + .args(["rev-parse", "--git-dir"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + if let Some(git_dir) = git_dir { + let git_dir = Path::new(&git_dir); + let head_path = git_dir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head_path.display()); + + if let Ok(head_contents) = std::fs::read_to_string(&head_path) { + if let Some(ref_path) = head_contents.trim().strip_prefix("ref: ") { + println!( + "cargo:rerun-if-changed={}", + git_dir.join(ref_path).display() + ); + println!( + "cargo:rerun-if-changed={}", + git_dir.join("packed-refs").display() + ); + } + } + } +} diff --git a/crates/infigraph-core/src/lib.rs b/crates/infigraph-core/src/lib.rs index 37de9d2..728ea7a 100644 --- a/crates/infigraph-core/src/lib.rs +++ b/crates/infigraph-core/src/lib.rs @@ -48,6 +48,12 @@ pub(crate) fn escape_str(s: &str) -> String { s.replace('\\', "\\\\").replace('\'', "\\'") } +/// Short git SHA this binary was built from ("-dirty" when the tree had +/// uncommitted changes; "unknown" outside a git checkout). Stamped into +/// lock-file identity payloads so stale-binary holders are identifiable. +pub fn build_hash() -> &'static str { + env!("INFIGRAPH_BUILD_HASH") +} /// The main entry point for the infigraph framework. pub struct Infigraph { root: PathBuf, diff --git a/crates/infigraph-core/tests/lockfile.rs b/crates/infigraph-core/tests/lockfile.rs new file mode 100644 index 0000000..24af4de --- /dev/null +++ b/crates/infigraph-core/tests/lockfile.rs @@ -0,0 +1,9 @@ +use infigraph_core::build_hash; + +#[test] +fn test_build_hash_is_nonempty() { + let h = build_hash(); + assert!(!h.is_empty()); + // In a git checkout this is a short sha, possibly "-dirty"; outside git it's "unknown". + assert!(h == "unknown" || h.len() >= 7, "unexpected build hash: {h}"); +} From b68a80813e420f8b1a6203f12690b2cd7f31ecc3 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 18:33:14 -0400 Subject: [PATCH 2/8] feat: LockInfo identity payload and structured Busy error for lock files Co-Authored-By: Claude Fable 5 --- crates/infigraph-core/src/lib.rs | 1 + crates/infigraph-core/src/lockfile.rs | 77 +++++++++++++++++++++++++ crates/infigraph-core/tests/lockfile.rs | 46 +++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 crates/infigraph-core/src/lockfile.rs diff --git a/crates/infigraph-core/src/lib.rs b/crates/infigraph-core/src/lib.rs index 728ea7a..0a07522 100644 --- a/crates/infigraph-core/src/lib.rs +++ b/crates/infigraph-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod extract; pub mod graph; pub mod lang; pub mod learned; +pub mod lockfile; pub mod manifest; pub mod meta; pub mod model; diff --git a/crates/infigraph-core/src/lockfile.rs b/crates/infigraph-core/src/lockfile.rs new file mode 100644 index 0000000..610c81a --- /dev/null +++ b/crates/infigraph-core/src/lockfile.rs @@ -0,0 +1,77 @@ +//! Lock-file mechanics shared by all infigraph locks (graph write lock, +//! and future operation/session/registry locks). +//! +//! Model: a kernel advisory flock (via `fs2`) is the source of truth for +//! "held" — flocks release automatically when the holder dies, so no +//! liveness polling is needed. The JSON identity payload written into the +//! lock file exists for diagnostics (who holds it, since when, built from +//! what) and is never trusted for liveness decisions. Conservative rule: +//! a held flock with an unreadable payload is an *unknown holder* — +//! bounded-wait then `Busy`, never broken. + +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +/// Identity payload stamped into a held lock file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockInfo { + pub pid: u32, + pub role: String, + pub build_hash: String, + /// Unix epoch seconds at acquisition. + pub acquired_at: u64, +} + +impl LockInfo { + pub fn current(role: &str) -> Self { + Self { + pid: std::process::id(), + role: role.to_string(), + build_hash: crate::build_hash().to_string(), + acquired_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + } + } +} + +/// Returned when a lock could not be acquired within the wait budget. +#[derive(Debug)] +pub struct Busy { + pub lock_path: PathBuf, + pub holder: Option, + pub waited: Duration, +} + +impl std::fmt::Display for Busy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.holder { + Some(h) => { + let held_for = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs().saturating_sub(h.acquired_at)) + .unwrap_or(0); + write!( + f, + "{} is locked by {} (PID {}), held {}s — waited {}s, giving up", + self.lock_path.display(), + h.role, + h.pid, + held_for, + self.waited.as_secs() + ) + } + None => write!( + f, + "{} is locked by an unknown holder — waited {}s, giving up", + self.lock_path.display(), + self.waited.as_secs() + ), + } + } +} + +impl std::error::Error for Busy {} diff --git a/crates/infigraph-core/tests/lockfile.rs b/crates/infigraph-core/tests/lockfile.rs index 24af4de..88f16bc 100644 --- a/crates/infigraph-core/tests/lockfile.rs +++ b/crates/infigraph-core/tests/lockfile.rs @@ -1,4 +1,7 @@ use infigraph_core::build_hash; +use infigraph_core::lockfile::{Busy, LockInfo}; +use std::path::PathBuf; +use std::time::Duration; #[test] fn test_build_hash_is_nonempty() { @@ -7,3 +10,46 @@ fn test_build_hash_is_nonempty() { // In a git checkout this is a short sha, possibly "-dirty"; outside git it's "unknown". assert!(h == "unknown" || h.len() >= 7, "unexpected build hash: {h}"); } + +#[test] +fn test_lockinfo_current_and_roundtrip() { + let info = LockInfo::current("test-role"); + assert_eq!(info.pid, std::process::id()); + assert_eq!(info.role, "test-role"); + assert_eq!(info.build_hash, build_hash()); + assert!(info.acquired_at > 1_700_000_000, "acquired_at should be epoch seconds"); + + let json = serde_json::to_string(&info).unwrap(); + let back: LockInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(back.pid, info.pid); + assert_eq!(back.role, info.role); +} + +#[test] +fn test_busy_display_names_holder() { + let busy = Busy { + lock_path: PathBuf::from("/tmp/x.lock"), + holder: Some(LockInfo { + pid: 4242, + role: "infigraph watch".into(), + build_hash: "abc123".into(), + acquired_at: 0, + }), + waited: Duration::from_secs(30), + }; + let msg = busy.to_string(); + assert!(msg.contains("4242"), "message should name holder pid: {msg}"); + assert!(msg.contains("infigraph watch"), "message should name role: {msg}"); + assert!(msg.contains("30"), "message should mention wait: {msg}"); +} + +#[test] +fn test_busy_display_unknown_holder() { + let busy = Busy { + lock_path: PathBuf::from("/tmp/x.lock"), + holder: None, + waited: Duration::from_secs(5), + }; + let msg = busy.to_string(); + assert!(msg.contains("unknown"), "unknown holder should be stated: {msg}"); +} From 25371c9ce23cde67d86c12b0710a646c944fc6b0 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 18:45:28 -0400 Subject: [PATCH 3/8] feat: lockfile try_acquire with identity stamping and stale-payload adoption Co-Authored-By: Claude Fable 5 --- crates/infigraph-core/src/lockfile.rs | 79 +++++++++++++++++++++- crates/infigraph-core/tests/lockfile.rs | 88 +++++++++++++++++++++++-- 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/crates/infigraph-core/src/lockfile.rs b/crates/infigraph-core/src/lockfile.rs index 610c81a..5efc53f 100644 --- a/crates/infigraph-core/src/lockfile.rs +++ b/crates/infigraph-core/src/lockfile.rs @@ -9,9 +9,13 @@ //! a held flock with an unreadable payload is an *unknown holder* — //! bounded-wait then `Busy`, never broken. -use std::path::PathBuf; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use anyhow::Result; +use fs2::FileExt; use serde::{Deserialize, Serialize}; /// Identity payload stamped into a held lock file. @@ -75,3 +79,76 @@ impl std::fmt::Display for Busy { } impl std::error::Error for Busy {} + +/// RAII guard for a held lock file. Releasing (drop) truncates the payload +/// then unlocks, so a cleanly-released lock file is empty. +pub struct LockFile { + file: File, + path: PathBuf, +} + +impl LockFile { + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for LockFile { + fn drop(&mut self) { + // Best-effort: clear payload before the flock releases so readers + // never see a stale identity on a free lock we released cleanly. + let _ = self.file.set_len(0); + let _ = fs2::FileExt::unlock(&self.file); + } +} + +fn open_lock_file(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(path)?) +} + +fn stamp(file: &mut File, role: &str) -> Result<()> { + let info = LockInfo::current(role); + let json = serde_json::to_string(&info)?; + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(json.as_bytes())?; + file.flush()?; + Ok(()) +} + +/// Best-effort read of the current holder's identity. `None` when the file +/// is missing, empty, or unparseable (old binary / mid-write). +pub fn read_holder(path: &Path) -> Option { + let mut buf = String::new(); + File::open(path).ok()?.read_to_string(&mut buf).ok()?; + serde_json::from_str(buf.trim()).ok() +} + +/// Non-blocking acquisition. `Ok(None)` when another open file description +/// holds the flock. On success the identity payload is stamped. +pub fn try_acquire(path: &Path, role: &str) -> Result> { + let mut file = open_lock_file(path)?; + match file.try_lock_exclusive() { + Ok(()) => { + stamp(&mut file, role)?; + Ok(Some(LockFile { + file, + path: path.to_path_buf(), + })) + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock || e.raw_os_error() == Some(33) => + { + Ok(None) + } + Err(e) => Err(anyhow::anyhow!("lock error on {}: {e}", path.display())), + } +} diff --git a/crates/infigraph-core/tests/lockfile.rs b/crates/infigraph-core/tests/lockfile.rs index 88f16bc..fd8af81 100644 --- a/crates/infigraph-core/tests/lockfile.rs +++ b/crates/infigraph-core/tests/lockfile.rs @@ -1,7 +1,9 @@ use infigraph_core::build_hash; +use infigraph_core::lockfile; use infigraph_core::lockfile::{Busy, LockInfo}; use std::path::PathBuf; use std::time::Duration; +use tempfile::TempDir; #[test] fn test_build_hash_is_nonempty() { @@ -17,7 +19,10 @@ fn test_lockinfo_current_and_roundtrip() { assert_eq!(info.pid, std::process::id()); assert_eq!(info.role, "test-role"); assert_eq!(info.build_hash, build_hash()); - assert!(info.acquired_at > 1_700_000_000, "acquired_at should be epoch seconds"); + assert!( + info.acquired_at > 1_700_000_000, + "acquired_at should be epoch seconds" + ); let json = serde_json::to_string(&info).unwrap(); let back: LockInfo = serde_json::from_str(&json).unwrap(); @@ -38,8 +43,14 @@ fn test_busy_display_names_holder() { waited: Duration::from_secs(30), }; let msg = busy.to_string(); - assert!(msg.contains("4242"), "message should name holder pid: {msg}"); - assert!(msg.contains("infigraph watch"), "message should name role: {msg}"); + assert!( + msg.contains("4242"), + "message should name holder pid: {msg}" + ); + assert!( + msg.contains("infigraph watch"), + "message should name role: {msg}" + ); assert!(msg.contains("30"), "message should mention wait: {msg}"); } @@ -51,5 +62,74 @@ fn test_busy_display_unknown_holder() { waited: Duration::from_secs(5), }; let msg = busy.to_string(); - assert!(msg.contains("unknown"), "unknown holder should be stated: {msg}"); + assert!( + msg.contains("unknown"), + "unknown holder should be stated: {msg}" + ); +} + +#[test] +fn test_try_acquire_stamps_identity() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a.lock"); + let guard = lockfile::try_acquire(&path, "unit-test") + .unwrap() + .expect("free lock"); + let holder = lockfile::read_holder(&path).expect("payload written"); + assert_eq!(holder.pid, std::process::id()); + assert_eq!(holder.role, "unit-test"); + assert_eq!(holder.build_hash, build_hash()); + drop(guard); +} + +#[test] +fn test_try_acquire_none_when_held() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("b.lock"); + let _guard = lockfile::try_acquire(&path, "first") + .unwrap() + .expect("free lock"); + let second = lockfile::try_acquire(&path, "second").unwrap(); + assert!( + second.is_none(), + "second handle must not acquire a held lock" + ); +} + +#[test] +fn test_release_clears_payload_and_reacquire_works() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("c.lock"); + { + let _guard = lockfile::try_acquire(&path, "first") + .unwrap() + .expect("free lock"); + } + // After clean release the payload is cleared (empty file), and the lock is free. + assert!( + lockfile::read_holder(&path).is_none(), + "payload should clear on drop" + ); + let again = lockfile::try_acquire(&path, "second").unwrap(); + assert!(again.is_some(), "lock should be reacquirable after drop"); +} + +#[test] +fn test_stale_payload_without_flock_is_adopted() { + // Simulates a holder that died without cleanup (kernel released the + // flock; stale JSON remains). Acquisition must succeed and overwrite. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("d.lock"); + std::fs::write( + &path, + r#"{"pid":999999,"role":"dead","build_hash":"x","acquired_at":1}"#, + ) + .unwrap(); + let guard = lockfile::try_acquire(&path, "adopter").unwrap(); + assert!( + guard.is_some(), + "free flock with stale payload must be adopted" + ); + let holder = lockfile::read_holder(&path).unwrap(); + assert_eq!(holder.role, "adopter"); } From f2f70849fd054b6d2cf55ddecf14935532624775 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 18:53:46 -0400 Subject: [PATCH 4/8] feat: bounded-wait lock acquisition with Busy error naming the holder Co-Authored-By: Claude Fable 5 --- crates/infigraph-core/src/lockfile.rs | 26 ++++++++++++- crates/infigraph-core/tests/lockfile.rs | 51 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/infigraph-core/src/lockfile.rs b/crates/infigraph-core/src/lockfile.rs index 5efc53f..51392d7 100644 --- a/crates/infigraph-core/src/lockfile.rs +++ b/crates/infigraph-core/src/lockfile.rs @@ -12,7 +12,7 @@ use std::fs::File; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::Result; use fs2::FileExt; @@ -82,6 +82,7 @@ impl std::error::Error for Busy {} /// RAII guard for a held lock file. Releasing (drop) truncates the payload /// then unlocks, so a cleanly-released lock file is empty. +#[derive(Debug)] pub struct LockFile { file: File, path: PathBuf, @@ -152,3 +153,26 @@ pub fn try_acquire(path: &Path, role: &str) -> Result> { Err(e) => Err(anyhow::anyhow!("lock error on {}: {e}", path.display())), } } + +/// Blocking acquisition with a wait budget. Polls `try_acquire` with +/// backoff (50ms doubling to a 500ms cap). On expiry returns a `Busy` +/// error carrying the holder identity when the payload is readable. +pub fn acquire(path: &Path, role: &str, timeout: Duration) -> Result { + let start = Instant::now(); + let mut delay = Duration::from_millis(50); + loop { + if let Some(guard) = try_acquire(path, role)? { + return Ok(guard); + } + if start.elapsed() >= timeout { + return Err(anyhow::Error::new(Busy { + lock_path: path.to_path_buf(), + holder: read_holder(path), + waited: start.elapsed(), + })); + } + let remaining = timeout.saturating_sub(start.elapsed()); + std::thread::sleep(delay.min(remaining)); + delay = (delay * 2).min(Duration::from_millis(500)); + } +} diff --git a/crates/infigraph-core/tests/lockfile.rs b/crates/infigraph-core/tests/lockfile.rs index fd8af81..50fa737 100644 --- a/crates/infigraph-core/tests/lockfile.rs +++ b/crates/infigraph-core/tests/lockfile.rs @@ -133,3 +133,54 @@ fn test_stale_payload_without_flock_is_adopted() { let holder = lockfile::read_holder(&path).unwrap(); assert_eq!(holder.role, "adopter"); } + +#[test] +fn test_acquire_waits_then_succeeds() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("e.lock"); + let guard = lockfile::try_acquire(&path, "short-holder").unwrap().expect("free"); + let path2 = path.clone(); + let t = std::thread::spawn(move || { + // Holder releases after 200ms; waiter has a 5s budget. + std::thread::sleep(Duration::from_millis(200)); + drop(guard); + }); + let acquired = lockfile::acquire(&path2, "waiter", Duration::from_secs(5)).unwrap(); + t.join().unwrap(); + assert_eq!(lockfile::read_holder(&path2).unwrap().role, "waiter"); + drop(acquired); +} + +#[test] +fn test_acquire_times_out_with_busy_naming_holder() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("f.lock"); + let _guard = lockfile::try_acquire(&path, "long-holder").unwrap().expect("free"); + let err = lockfile::acquire(&path, "impatient", Duration::from_millis(300)) + .expect_err("must time out while held"); + let busy = err.downcast_ref::().expect("error must be Busy"); + let holder = busy.holder.as_ref().expect("holder identity readable"); + assert_eq!(holder.role, "long-holder"); + assert_eq!(holder.pid, std::process::id()); + assert!(busy.waited >= Duration::from_millis(300)); +} + +#[test] +fn test_acquire_timeout_unknown_holder_on_bare_flock() { + // Old-binary compatibility: flock held but no payload (pre-identity + // binaries never write one). Must time out as unknown holder, never break. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("g.lock"); + let bare = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&path) + .unwrap(); + fs2::FileExt::lock_exclusive(&bare).unwrap(); + let err = lockfile::acquire(&path, "modern", Duration::from_millis(200)) + .expect_err("must time out"); + let busy = err.downcast_ref::().expect("error must be Busy"); + assert!(busy.holder.is_none(), "bare flock has unknown holder"); + fs2::FileExt::unlock(&bare).unwrap(); +} From ae7bd489872093275699a3aae48fb896b91a5694 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 19:03:30 -0400 Subject: [PATCH 5/8] feat: WriteLock adopts lockfile identity + bounded wait (30s default, Busy on expiry) Co-Authored-By: Claude Fable 5 --- crates/infigraph-core/src/graph/store.rs | 58 ++++++++++------------- crates/infigraph-core/tests/write_lock.rs | 26 ++++++++++ 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/crates/infigraph-core/src/graph/store.rs b/crates/infigraph-core/src/graph/store.rs index 9846ea6..c38c0e0 100644 --- a/crates/infigraph-core/src/graph/store.rs +++ b/crates/infigraph-core/src/graph/store.rs @@ -2,51 +2,39 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::Result; -use fs2::FileExt; use kuzu::{Connection, Database, SystemConfig}; use super::schema::{CREATE_SCHEMA, MIGRATIONS}; use super::store_util::escape; +use crate::lockfile::{self, LockFile}; /// RAII guard for exclusive write access to the graph store. -/// Holds an advisory file lock on `.lock`. +/// Holds an advisory file lock on `.lock` with an identity +/// payload (see `crate::lockfile`). +#[derive(Debug)] pub struct WriteLock { - _file: std::fs::File, + _guard: LockFile, } +/// Role string stamped into the graph write lock's identity payload. +const GRAPH_WRITE_ROLE: &str = "graph-write"; + +/// Default wait budget for the graph write lock. Individual write calls +/// are short; 30s of waiting means something is wedged — surface it. +const GRAPH_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + impl WriteLock { fn acquire(lock_path: &Path) -> Result { - if let Some(parent) = lock_path.parent() { - std::fs::create_dir_all(parent)?; - } - let file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(lock_path)?; - file.lock_exclusive() - .map_err(|e| anyhow::anyhow!("failed to acquire write lock: {e}"))?; - Ok(Self { _file: file }) + Self::acquire_with_timeout(lock_path, GRAPH_WRITE_TIMEOUT) + } + + fn acquire_with_timeout(lock_path: &Path, timeout: std::time::Duration) -> Result { + let guard = lockfile::acquire(lock_path, GRAPH_WRITE_ROLE, timeout)?; + Ok(Self { _guard: guard }) } fn try_acquire(lock_path: &Path) -> Result> { - if let Some(parent) = lock_path.parent() { - std::fs::create_dir_all(parent)?; - } - let file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(lock_path)?; - match file.try_lock_exclusive() { - Ok(()) => Ok(Some(Self { _file: file })), - Err(ref e) - if e.kind() == std::io::ErrorKind::WouldBlock || e.raw_os_error() == Some(33) => - { - Ok(None) - } - Err(e) => Err(anyhow::anyhow!("lock error: {e}")), - } + Ok(lockfile::try_acquire(lock_path, GRAPH_WRITE_ROLE)?.map(|guard| Self { _guard: guard })) } } @@ -82,11 +70,17 @@ impl GraphStore { Ok(Self { db, lock_path }) } - /// Acquire exclusive write lock. Blocks until available. + /// Acquire exclusive write lock. Waits up to 30s, returning `Busy` if + /// still held at expiry. pub fn write_lock(&self) -> Result { WriteLock::acquire(&self.lock_path) } + /// Acquire the write lock with a caller-chosen wait budget. + pub fn write_lock_with_timeout(&self, timeout: std::time::Duration) -> Result { + WriteLock::acquire_with_timeout(&self.lock_path, timeout) + } + /// Try to acquire write lock without blocking. Returns None if already held. pub fn try_write_lock(&self) -> Result> { WriteLock::try_acquire(&self.lock_path) diff --git a/crates/infigraph-core/tests/write_lock.rs b/crates/infigraph-core/tests/write_lock.rs index e34d57b..9a8573a 100644 --- a/crates/infigraph-core/tests/write_lock.rs +++ b/crates/infigraph-core/tests/write_lock.rs @@ -125,3 +125,29 @@ fn test_write_lock_different_stores_same_path() { drop(file1); } + +#[test] +fn test_write_lock_stamps_identity_and_busy_on_timeout() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("identity.db"); + let store = GraphStore::open(&db_path).unwrap(); + + let _held = store.write_lock().unwrap(); + let holder = infigraph_core::lockfile::read_holder(&db_path.with_extension("lock")) + .expect("write lock should stamp identity"); + assert_eq!(holder.pid, std::process::id()); + assert_eq!(holder.role, "graph-write"); + + // A second store on the same path must time out with Busy, not hang. + let store2 = GraphStore::open(&db_path); // may fail: kuzu holds its own db lock + if let Ok(store2) = store2 { + let err = store2 + .write_lock_with_timeout(std::time::Duration::from_millis(300)) + .expect_err("held lock must yield Busy"); + assert!( + err.downcast_ref::() + .is_some(), + "expected Busy, got: {err}" + ); + } +} From eb44f832f1fb5bca56bb17c100190d935c6f4183 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 19:06:28 -0400 Subject: [PATCH 6/8] perf: start lock backoff at 1ms to keep contended acquisition near kernel-flock throughput Co-Authored-By: Claude Fable 5 --- crates/infigraph-core/src/lockfile.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/infigraph-core/src/lockfile.rs b/crates/infigraph-core/src/lockfile.rs index 51392d7..1dd4ebd 100644 --- a/crates/infigraph-core/src/lockfile.rs +++ b/crates/infigraph-core/src/lockfile.rs @@ -155,11 +155,11 @@ pub fn try_acquire(path: &Path, role: &str) -> Result> { } /// Blocking acquisition with a wait budget. Polls `try_acquire` with -/// backoff (50ms doubling to a 500ms cap). On expiry returns a `Busy` +/// backoff (1ms doubling to a 500ms cap). On expiry returns a `Busy` /// error carrying the holder identity when the payload is readable. pub fn acquire(path: &Path, role: &str, timeout: Duration) -> Result { let start = Instant::now(); - let mut delay = Duration::from_millis(50); + let mut delay = Duration::from_millis(1); loop { if let Some(guard) = try_acquire(path, role)? { return Ok(guard); From 09a6497df5a0a3c670516e6388dae041b25b038f Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 19:32:47 -0400 Subject: [PATCH 7/8] refactor: extract now_epoch_secs helper in lockfile Co-Authored-By: Claude Fable 5 --- crates/infigraph-core/src/lockfile.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/infigraph-core/src/lockfile.rs b/crates/infigraph-core/src/lockfile.rs index 1dd4ebd..fd6cbc4 100644 --- a/crates/infigraph-core/src/lockfile.rs +++ b/crates/infigraph-core/src/lockfile.rs @@ -34,14 +34,20 @@ impl LockInfo { pid: std::process::id(), role: role.to_string(), build_hash: crate::build_hash().to_string(), - acquired_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0), + acquired_at: now_epoch_secs(), } } } +/// Current time as Unix epoch seconds. Falls back to 0 on a clock error +/// (e.g. system time before the epoch) rather than panicking. +fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// Returned when a lock could not be acquired within the wait budget. #[derive(Debug)] pub struct Busy { @@ -54,10 +60,7 @@ impl std::fmt::Display for Busy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.holder { Some(h) => { - let held_for = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs().saturating_sub(h.acquired_at)) - .unwrap_or(0); + let held_for = now_epoch_secs().saturating_sub(h.acquired_at); write!( f, "{} is locked by {} (PID {}), held {}s — waited {}s, giving up", From f7197634182735c54c0ebbf22dd9c6a8cf0b7547 Mon Sep 17 00:00:00 2001 From: Pradeep Mouli Date: Mon, 20 Jul 2026 20:29:35 -0400 Subject: [PATCH 8/8] style: rustfmt tests/lockfile.rs Co-Authored-By: Claude Fable 5 --- crates/infigraph-core/tests/lockfile.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/infigraph-core/tests/lockfile.rs b/crates/infigraph-core/tests/lockfile.rs index 50fa737..8a514a6 100644 --- a/crates/infigraph-core/tests/lockfile.rs +++ b/crates/infigraph-core/tests/lockfile.rs @@ -138,7 +138,9 @@ fn test_stale_payload_without_flock_is_adopted() { fn test_acquire_waits_then_succeeds() { let dir = TempDir::new().unwrap(); let path = dir.path().join("e.lock"); - let guard = lockfile::try_acquire(&path, "short-holder").unwrap().expect("free"); + let guard = lockfile::try_acquire(&path, "short-holder") + .unwrap() + .expect("free"); let path2 = path.clone(); let t = std::thread::spawn(move || { // Holder releases after 200ms; waiter has a 5s budget. @@ -155,7 +157,9 @@ fn test_acquire_waits_then_succeeds() { fn test_acquire_times_out_with_busy_naming_holder() { let dir = TempDir::new().unwrap(); let path = dir.path().join("f.lock"); - let _guard = lockfile::try_acquire(&path, "long-holder").unwrap().expect("free"); + let _guard = lockfile::try_acquire(&path, "long-holder") + .unwrap() + .expect("free"); let err = lockfile::acquire(&path, "impatient", Duration::from_millis(300)) .expect_err("must time out while held"); let busy = err.downcast_ref::().expect("error must be Busy"); @@ -178,8 +182,8 @@ fn test_acquire_timeout_unknown_holder_on_bare_flock() { .open(&path) .unwrap(); fs2::FileExt::lock_exclusive(&bare).unwrap(); - let err = lockfile::acquire(&path, "modern", Duration::from_millis(200)) - .expect_err("must time out"); + let err = + lockfile::acquire(&path, "modern", Duration::from_millis(200)).expect_err("must time out"); let busy = err.downcast_ref::().expect("error must be Busy"); assert!(busy.holder.is_none(), "bare flock has unknown holder"); fs2::FileExt::unlock(&bare).unwrap();