Skip to main content

sqlite_graphrag/
lock.rs

1//! Counting semaphore via lock files to limit parallel CLI invocations.
2//!
3//! `acquire_cli_slot` tries to acquire one of `N` available slots by opening the file
4//! `cli-slot-{N}.lock` in the OS cache directory and obtaining an exclusive `flock`.
5//! The returned [`std::fs::File`] MUST be kept alive for the entire duration of `main`;
6//! dropping it releases the slot automatically for the next invocation.
7//!
8//! When `wait_seconds` is `Some(n) > 0`, the function polls every
9//! [`crate::constants::CLI_LOCK_POLL_INTERVAL_MS`] milliseconds until the deadline. When it
10//! is `None` or `Some(0)`, a single attempt is made and `Err(AppError::AllSlotsFull)` is
11//! returned immediately if all slots are occupied.
12//!
13//! ## Job-type singleton (G28-B, v1.0.68)
14//!
15//! Heavy long-running jobs (`enrich`, `ingest --mode claude-code`,
16//! `ingest --mode codex`) also acquire a *singleton* lock per `(job_type,
17//! namespace)` via `acquire_job_singleton`.  This guarantees at most one
18//! heavy job per namespace runs at any time, which was the root cause
19//! of the 2026-06-03 process-proliferation incident (4 parallel `enrich`
20//! instances × N workers × 10 MCP servers = ~192 spawned processes).
21// Workload: I/O-bound (flock polling with exponential backoff sleep)
22
23use std::fs::{File, OpenOptions};
24use std::path::{Path, PathBuf};
25use std::thread;
26use std::time::{Duration, Instant};
27
28use directories::ProjectDirs;
29use fs4::fs_std::FileExt;
30
31use crate::constants::{
32    CLI_LOCK_POLL_INTERVAL_MS, JOB_SINGLETON_POLL_INTERVAL_MS, LLM_WORKER_RSS_MB,
33    MAX_CONCURRENT_CLI_INSTANCES,
34};
35use crate::errors::AppError;
36
37/// Job-type classification for `acquire_job_singleton`.
38///
39/// `Light` is intentionally NOT a variant here because lightweight
40/// commands (`recall`, `stats`, `read`, `list`) share the existing
41/// counting-semaphore in [`acquire_cli_slot`] and do not need a singleton.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum JobType {
44    /// `enrich` command (LLM-driven entity/relation/body enrichment).
45    Enrich,
46    /// `ingest --mode claude-code` (LLM-curated ingestion).
47    IngestClaudeCode,
48    /// `ingest --mode codex` (OpenAI Codex CLI ingestion).
49    IngestCodex,
50}
51
52impl JobType {
53    /// Returns the kebab-case tag used inside the lock file name.
54    fn tag(self) -> &'static str {
55        match self {
56            JobType::Enrich => "enrich",
57            JobType::IngestClaudeCode => "ingest-claude-code",
58            JobType::IngestCodex => "ingest-codex",
59        }
60    }
61}
62
63/// Returns the lock file path for the given slot.
64///
65/// Precedence: XDG setting `paths.cache` (via `config set`) > OS default
66/// cache directory via `directories::ProjectDirs`. No product env vars.
67/// The slot must be 1-based.
68fn slot_path(slot: usize) -> Result<PathBuf, AppError> {
69    let cache = cache_dir()?;
70    std::fs::create_dir_all(&cache)?;
71    Ok(cache.join(format!("cli-slot-{slot}.lock")))
72}
73
74/// Resolves the lock-file directory from XDG config or OS ProjectDirs.
75fn cache_dir() -> Result<PathBuf, AppError> {
76    if let Ok(Some(override_dir)) = crate::config::get_setting("paths.cache") {
77        if !override_dir.trim().is_empty() {
78            return Ok(PathBuf::from(override_dir.trim()));
79        }
80    }
81    let dirs = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
82        AppError::Io(std::io::Error::new(
83            std::io::ErrorKind::NotFound,
84            "could not determine cache directory for sqlite-graphrag lock files",
85        ))
86    })?;
87    Ok(dirs.cache_dir().to_path_buf())
88}
89
90/// Computes a short, filesystem-safe hash of the database path so two distinct
91/// databases (e.g. `/tmp/a.sqlite` and `/tmp/b.sqlite`) get distinct lock
92/// files in the shared cache directory. First 12 hex chars of BLAKE3 are
93/// sufficient for collision avoidance across the local filesystem.
94pub fn db_path_hash(db_path: &Path) -> String {
95    let canonical = db_path
96        .canonicalize()
97        .unwrap_or_else(|_| db_path.to_path_buf());
98    let hash = blake3::hash(canonical.to_string_lossy().as_bytes());
99    hash.to_hex().to_string()[..12].to_string()
100}
101
102/// Returns the singleton lock file path for a given (job_type, namespace, db_hash).
103///
104/// Layout: `job-singleton-{tag}-{namespace_slug}-{db_hash}.lock` in the same
105/// cache dir as the CLI slots. The namespace is sanitised to a filesystem-safe
106/// slug (lowercase, hyphens, alphanumeric) and defaults to `default` when
107/// empty. The `db_hash` is the BLAKE3 prefix returned by [`db_path_hash`].
108///
109/// G30 (v1.0.69): the previous implementation ignored the database path
110/// entirely, so two concurrent `enrich` invocations against different
111/// `graphrag.sqlite` files (production vs. test) collided on the same
112/// cache-dir lock. The db_hash scope makes the singleton per-database while
113/// still sharing the same cache dir.
114pub fn job_singleton_path(
115    job_type: JobType,
116    namespace: &str,
117    db_hash: &str,
118) -> Result<PathBuf, AppError> {
119    let cache = cache_dir()?;
120    std::fs::create_dir_all(&cache)?;
121    let slug = if namespace.is_empty() {
122        "default".to_string()
123    } else {
124        namespace
125            .chars()
126            .map(|c| {
127                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
128                    c.to_ascii_lowercase()
129                } else {
130                    '-'
131                }
132            })
133            .collect::<String>()
134    };
135    let safe_hash: String = db_hash
136        .chars()
137        .filter(|c| c.is_ascii_alphanumeric())
138        .take(16)
139        .collect();
140    Ok(cache.join(format!(
141        "job-singleton-{}-{slug}-{safe_hash}.lock",
142        job_type.tag()
143    )))
144}
145
146/// Tries to open and exclusively lock the lock file for the given slot.
147///
148/// Returns `Ok(file)` if the slot is free, or `Err(io::Error)` if it is
149/// held by another instance (non-blocking).
150fn try_acquire_slot(slot: usize) -> Result<File, AppError> {
151    let path = slot_path(slot)?;
152    let file = OpenOptions::new()
153        .read(true)
154        .write(true)
155        .create(true)
156        .truncate(false)
157        .open(&path)?;
158    file.try_lock_exclusive().map_err(AppError::Io)?;
159    Ok(file)
160}
161
162/// Acquires a concurrency slot from the `max_concurrency`-position semaphore.
163///
164/// Iterates slots `1..=max_concurrency` attempting `try_lock_exclusive` on each
165/// `cli-slot-N.lock` file. When a free slot is found, returns `(File, slot_number)`.
166/// If all slots are occupied:
167///
168/// - If `wait_seconds` is `None` or `Some(0)`, returns immediately with
169///   `AppError::AllSlotsFull { max, waited_secs: 0 }`.
170/// - If `wait_seconds` is `Some(n) > 0`, enters a polling loop every
171///
172/// Returns the maximum number of parallel CLI instances the host can sustain
173/// without thrashing. The formula:
174///
175///   safe = min(cpus, available_mb / per_worker_mb) * 1.0
176///
177/// replaces the previous `... * 0.5` halving factor. The `* 0.5` was the
178/// root cause of G18: even on a 64 GB host the result was always
179/// clamped to 4 because of the division-by-2.
180///
181/// The per-worker cost is `LLM_WORKER_RSS_MB` (350): since v1.0.79 every
182/// build is LLM-only (the `embedding-legacy` feature and the ONNX path
183/// were removed), so the higher fastembed worker cost no longer applies.
184///
185/// Returns 1 as a defensive floor when system stats are unavailable.
186pub fn calculate_safe_concurrency() -> usize {
187    use sysinfo::System;
188    let mut sys = System::new();
189    sys.refresh_memory();
190    let available_mb = sys.available_memory() / 1_048_576;
191    let cpus = std::thread::available_parallelism()
192        .map(|n| n.get())
193        .unwrap_or(2);
194
195    let per_worker_mb = LLM_WORKER_RSS_MB;
196
197    let memory_bound = if available_mb == 0 {
198        cpus
199    } else {
200        (available_mb / per_worker_mb.max(1)) as usize
201    };
202    let raw = cpus.min(memory_bound).max(1);
203    raw.min(MAX_CONCURRENT_CLI_INSTANCES)
204}
205
206/// v1.0.75 — Returns the worker cost in MiB used by `calculate_safe_concurrency`.
207/// Exposed for telemetry and `--info` output.
208pub fn worker_cost_mb() -> u64 {
209    LLM_WORKER_RSS_MB
210}
211
212///   `AppError::AllSlotsFull { max, waited_secs: n }` if no slot opens.
213///
214/// The returned `File` MUST be kept alive until the process exits; dropping it
215/// releases the slot automatically via the implicit `flock` on close.
216pub fn acquire_cli_slot(
217    max_concurrency: usize,
218    wait_seconds: Option<u64>,
219) -> Result<(File, usize), AppError> {
220    // G18: use env override or 2*cpus as ceiling instead of hardcoded 4
221    let ncpus = std::thread::available_parallelism()
222        .map(|n| n.get())
223        .unwrap_or(4);
224    let ceiling = crate::config::get_setting("cli.max_instances")
225        .ok()
226        .flatten()
227        .and_then(|v| v.parse::<usize>().ok())
228        .unwrap_or_else(|| (2 * ncpus).max(MAX_CONCURRENT_CLI_INSTANCES));
229    let max = max_concurrency.clamp(1, ceiling);
230    let wait_secs = wait_seconds.unwrap_or(0);
231
232    // Tentativa inicial sem espera.
233    if let Some((file, slot)) = try_any_slot(max)? {
234        return Ok((file, slot));
235    }
236
237    if wait_secs == 0 {
238        return Err(AppError::AllSlotsFull {
239            max,
240            waited_secs: 0,
241        });
242    }
243
244    // Polling loop with progressive backoff until the deadline.
245    let deadline = Instant::now() + Duration::from_secs(wait_secs);
246    let mut polls: u64 = 0;
247    loop {
248        let poll_delay = CLI_LOCK_POLL_INTERVAL_MS
249            .saturating_mul(1 + polls / 4)
250            .min(CLI_LOCK_POLL_INTERVAL_MS * 4);
251        thread::sleep(Duration::from_millis(poll_delay));
252        polls += 1;
253        if let Some((file, slot)) = try_any_slot(max)? {
254            return Ok((file, slot));
255        }
256        if Instant::now() >= deadline {
257            return Err(AppError::AllSlotsFull {
258                max,
259                waited_secs: wait_secs,
260            });
261        }
262    }
263}
264
265/// Acquires a process-wide singleton lock for a heavy job type and namespace.
266///
267/// G28-B (v1.0.68): ensures at most one `enrich`, `ingest --mode
268/// claude-code`, or `ingest --mode codex` runs at a time per namespace.
269/// A second invocation in the same namespace either:
270///
271/// - Returns immediately with `AppError::JobSingletonLocked { job_type,
272///   namespace }` when `wait_seconds` is `None` or `Some(0)`.
273/// - Polls every [`JOB_SINGLETON_POLL_INTERVAL_MS`] ms until the lock
274///   drops or the deadline expires, returning the same error on timeout.
275///
276/// The returned `File` MUST be kept alive until the process exits;
277/// dropping it releases the singleton for the next invocation.
278pub fn acquire_job_singleton(
279    job_type: JobType,
280    namespace: &str,
281    db_path: &Path,
282    wait_seconds: Option<u64>,
283    force: bool,
284) -> Result<File, AppError> {
285    let db_hash = db_path_hash(db_path);
286    let path = job_singleton_path(job_type, namespace, &db_hash)?;
287
288    // G30+G09: when --force is set, attempt to break a stale lock by
289    // detecting and removing a pre-existing lock file. This is a last
290    // resort: only enabled by an explicit operator flag. A real orphan
291    // lock from a previous crash leaves a 0-byte file behind, which the
292    // next non-forced caller would still try to lock.
293    if force && path.exists() {
294        tracing::warn!(target: "lock",
295            path = %path.display(),
296            "force=true; removing pre-existing singleton lock file"
297        );
298        let _ = std::fs::remove_file(&path);
299    }
300
301    let file = OpenOptions::new()
302        .read(true)
303        .write(true)
304        .create(true)
305        .truncate(false)
306        .open(&path)?;
307    if let Err(e) = file.try_lock_exclusive() {
308        if !is_lock_contended(&e) {
309            return Err(AppError::Io(e));
310        }
311        // Already held by another instance.
312        let wait_secs = wait_seconds.unwrap_or(0);
313        if wait_secs == 0 {
314            return Err(AppError::JobSingletonLocked {
315                job_type: job_type.tag().to_string(),
316                namespace: namespace.to_string(),
317            });
318        }
319        let deadline = Instant::now() + Duration::from_secs(wait_secs);
320        // Drop the failed handle before polling; flock is per-process so we
321        // re-open each attempt to refresh contention state.
322        drop(file);
323        loop {
324            thread::sleep(Duration::from_millis(JOB_SINGLETON_POLL_INTERVAL_MS));
325            let file = OpenOptions::new()
326                .read(true)
327                .write(true)
328                .create(true)
329                .truncate(false)
330                .open(&path)?;
331            if file.try_lock_exclusive().is_ok() {
332                return Ok(file);
333            }
334            if Instant::now() >= deadline {
335                return Err(AppError::JobSingletonLocked {
336                    job_type: job_type.tag().to_string(),
337                    namespace: namespace.to_string(),
338                });
339            }
340        }
341    }
342    Ok(file)
343}
344
345/// G45: returns the lock file path for the embedding singleton
346/// of a `(namespace, db_hash)` pair. Layout:
347/// `embed-singleton-{namespace_slug}-{db_hash}.lock` in the same
348/// cache directory as the other singletons. The namespace is sanitised
349/// to a filesystem-safe slug the same way as [`job_singleton_path`].
350fn embedding_singleton_path(namespace: &str, db_hash: &str) -> Result<PathBuf, AppError> {
351    let cache = cache_dir()?;
352    std::fs::create_dir_all(&cache)?;
353    let slug = if namespace.is_empty() {
354        "default".to_string()
355    } else {
356        namespace
357            .chars()
358            .map(|c| {
359                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
360                    c.to_ascii_lowercase()
361                } else {
362                    '-'
363                }
364            })
365            .collect::<String>()
366    };
367    let safe_hash: String = db_hash
368        .chars()
369        .filter(|c| c.is_ascii_alphanumeric())
370        .take(16)
371        .collect();
372    Ok(cache.join(format!("embed-singleton-{slug}-{safe_hash}.lock")))
373}
374
375/// G45: acquires a cross-process singleton lock for LLM embedding
376/// operations against a given `(namespace, db)` pair.
377///
378/// The lock is opened and held with `flock` (same mechanism as
379/// [`acquire_job_singleton`]). Two CLI invocations writing to the same
380/// database while both are calling the LLM on entity names will now
381/// serialise: the second one receives [`AppError::EmbeddingSingletonLocked`]
382/// (exit 75) instead of double-spawning `claude -p` / `codex exec`
383/// subprocesses.
384///
385/// Behaviour:
386/// - `wait_seconds = Some(0)` or `None` → fail immediately if held.
387/// - `wait_seconds = Some(n) > 0` → poll every
388///   [`JOB_SINGLETON_POLL_INTERVAL_MS`] ms until the lock drops or the
389///   deadline expires.
390/// - `force = true` → remove a stale lock file before acquiring
391///   (operator escape hatch, same contract as `acquire_job_singleton`).
392///
393/// The returned [`File`] MUST be kept alive for the duration of the
394/// embedding work; dropping it releases the singleton for the next
395/// process.
396pub fn acquire_embedding_singleton(
397    namespace: &str,
398    db_path: &Path,
399    wait_seconds: Option<u64>,
400    force: bool,
401) -> Result<File, AppError> {
402    let db_hash = db_path_hash(db_path);
403    let path = embedding_singleton_path(namespace, &db_hash)?;
404
405    if force && path.exists() {
406        tracing::warn!(target: "lock.g45",
407            path = %path.display(),
408            "force=true; removing pre-existing embedding singleton lock file"
409        );
410        let _ = std::fs::remove_file(&path);
411    }
412
413    let file = OpenOptions::new()
414        .read(true)
415        .write(true)
416        .create(true)
417        .truncate(false)
418        .open(&path)?;
419    if let Err(e) = file.try_lock_exclusive() {
420        if !is_lock_contended(&e) {
421            return Err(AppError::Io(e));
422        }
423        let wait_secs = wait_seconds.unwrap_or(0);
424        if wait_secs == 0 {
425            return Err(AppError::EmbeddingSingletonLocked {
426                namespace: namespace.to_string(),
427            });
428        }
429        let deadline = Instant::now() + Duration::from_secs(wait_secs);
430        drop(file);
431        loop {
432            thread::sleep(Duration::from_millis(JOB_SINGLETON_POLL_INTERVAL_MS));
433            let file = OpenOptions::new()
434                .read(true)
435                .write(true)
436                .create(true)
437                .truncate(false)
438                .open(&path)?;
439            if file.try_lock_exclusive().is_ok() {
440                return Ok(file);
441            }
442            if Instant::now() >= deadline {
443                return Err(AppError::EmbeddingSingletonLocked {
444                    namespace: namespace.to_string(),
445                });
446            }
447        }
448    }
449    Ok(file)
450}
451
452/// Tries to acquire any free slot in `1..=max`, returning the first available one.
453///
454/// Returns `Ok(Some((file, slot)))` if a slot was obtained, `Ok(None)` if all are
455/// occupied (`EWOULDBLOCK`). Propagates I/O errors other than "lock contended".
456fn try_any_slot(max: usize) -> Result<Option<(File, usize)>, AppError> {
457    for slot in 1..=max {
458        match try_acquire_slot(slot) {
459            Ok(file) => return Ok(Some((file, slot))),
460            Err(AppError::Io(e)) if is_lock_contended(&e) => continue,
461            Err(e) => return Err(e),
462        }
463    }
464    Ok(None)
465}
466
467fn is_lock_contended(error: &std::io::Error) -> bool {
468    if error.kind() == std::io::ErrorKind::WouldBlock {
469        return true;
470    }
471
472    #[cfg(windows)]
473    {
474        matches!(error.raw_os_error(), Some(32 | 33))
475    }
476
477    #[cfg(not(windows))]
478    {
479        false
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use std::sync::atomic::{AtomicUsize, Ordering};
487    static SEQ: AtomicUsize = AtomicUsize::new(0);
488
489    fn unique_ns() -> String {
490        let n = SEQ.fetch_add(1, Ordering::SeqCst);
491        let pid = std::process::id();
492        format!("test-{pid}-{n}")
493    }
494
495    #[test]
496    fn job_singleton_path_sanitises_namespace() {
497        let p = job_singleton_path(JobType::Enrich, "Foo Bar/Baz", "abc123def456")
498            .expect("path should resolve");
499        let name = p.file_name().unwrap().to_string_lossy().to_string();
500        assert!(name.contains("enrich"), "got {name}");
501        assert!(name.contains("foo-bar-baz"), "got {name}");
502        assert!(
503            name.contains("abc123def456"),
504            "must embed db_hash: got {name}"
505        );
506    }
507
508    #[test]
509    fn job_singleton_blocks_second_invocation_same_namespace() {
510        let ns = unique_ns();
511        let db = std::env::temp_dir().join(format!("test-{}.sqlite", unique_ns()));
512        let first = acquire_job_singleton(JobType::Enrich, &ns, &db, Some(0), false)
513            .expect("first acquire should succeed");
514        let second = acquire_job_singleton(JobType::Enrich, &ns, &db, Some(0), false);
515        assert!(
516            matches!(second, Err(AppError::JobSingletonLocked { .. })),
517            "expected JobSingletonLocked, got {second:?}"
518        );
519        drop(first);
520    }
521
522    #[test]
523    fn job_singleton_allows_different_namespaces() {
524        let ns_a = unique_ns();
525        let ns_b = unique_ns();
526        let db_a = std::env::temp_dir().join(format!("test-a-{}.sqlite", unique_ns()));
527        let db_b = std::env::temp_dir().join(format!("test-b-{}.sqlite", unique_ns()));
528        let first = acquire_job_singleton(JobType::IngestClaudeCode, &ns_a, &db_a, Some(0), false)
529            .expect("ns_a should acquire");
530        let second = acquire_job_singleton(JobType::IngestClaudeCode, &ns_b, &db_b, Some(0), false)
531            .expect("ns_b should acquire in parallel");
532        drop(first);
533        drop(second);
534    }
535
536    #[test]
537    fn job_singleton_scoped_by_db_hash() {
538        // G30: two databases, same namespace, different content. Both locks
539        // should succeed because the db_hash differs.
540        let ns = unique_ns();
541        let db_a = std::env::temp_dir().join(format!("test-x-{}.sqlite", unique_ns()));
542        let db_b = std::env::temp_dir().join(format!("test-y-{}.sqlite", unique_ns()));
543        let first = acquire_job_singleton(JobType::Enrich, &ns, &db_a, Some(0), false)
544            .expect("db_a should acquire");
545        let second = acquire_job_singleton(JobType::Enrich, &ns, &db_b, Some(0), false)
546            .expect("db_b should acquire independently (G30 fix)");
547        drop(first);
548        drop(second);
549    }
550
551    #[test]
552    fn db_path_hash_is_stable_for_same_path() {
553        let p = std::env::temp_dir().join("hashing-test.sqlite");
554        let h1 = db_path_hash(&p);
555        let h2 = db_path_hash(&p);
556        assert_eq!(h1, h2, "same path must produce same hash");
557        assert_eq!(h1.len(), 12, "BLAKE3 prefix must be 12 hex chars");
558    }
559
560    #[test]
561    fn db_path_hash_differs_for_different_paths() {
562        let a = std::env::temp_dir().join("hash-a.sqlite");
563        let b = std::env::temp_dir().join("hash-b.sqlite");
564        assert_ne!(db_path_hash(&a), db_path_hash(&b));
565    }
566
567    // G45: embedding singleton — cross-process coordination
568    #[test]
569    fn g45_embedding_singleton_blocks_second_invocation_same_db() {
570        let ns = unique_ns();
571        let db = std::env::temp_dir().join(format!("g45-{}.sqlite", unique_ns()));
572        let first = acquire_embedding_singleton(&ns, &db, Some(0), false)
573            .expect("first acquire should succeed");
574        let second = acquire_embedding_singleton(&ns, &db, Some(0), false);
575        assert!(
576            matches!(second, Err(AppError::EmbeddingSingletonLocked { .. })),
577            "expected EmbeddingSingletonLocked, got {second:?}"
578        );
579        drop(first);
580    }
581
582    #[test]
583    fn g45_embedding_singleton_allows_different_namespaces() {
584        let ns_a = unique_ns();
585        let ns_b = unique_ns();
586        let db = std::env::temp_dir().join(format!("g45-multi-{}.sqlite", unique_ns()));
587        let first =
588            acquire_embedding_singleton(&ns_a, &db, Some(0), false).expect("ns_a should acquire");
589        let second = acquire_embedding_singleton(&ns_b, &db, Some(0), false)
590            .expect("ns_b should acquire in parallel (different namespace)");
591        drop(first);
592        drop(second);
593    }
594
595    #[test]
596    fn g45_embedding_singleton_scoped_by_db_hash() {
597        // Same namespace, different databases → independent locks.
598        let ns = unique_ns();
599        let db_a = std::env::temp_dir().join(format!("g45-x-{}.sqlite", unique_ns()));
600        let db_b = std::env::temp_dir().join(format!("g45-y-{}.sqlite", unique_ns()));
601        let first =
602            acquire_embedding_singleton(&ns, &db_a, Some(0), false).expect("db_a should acquire");
603        let second = acquire_embedding_singleton(&ns, &db_b, Some(0), false)
604            .expect("db_b should acquire independently (G45 db_hash scope)");
605        drop(first);
606        drop(second);
607    }
608}