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