1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum JobType {
44 Enrich,
46 IngestClaudeCode,
48 IngestCodex,
50}
51
52impl JobType {
53 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
63fn 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
74fn 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
90pub 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
102pub 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
146fn 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
162pub 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
206pub fn worker_cost_mb() -> u64 {
209 LLM_WORKER_RSS_MB
210}
211
212pub fn acquire_cli_slot(
217 max_concurrency: usize,
218 wait_seconds: Option<u64>,
219) -> Result<(File, usize), AppError> {
220 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 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 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
265pub 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 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 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(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
345fn 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
375pub 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
452fn 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 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 #[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 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}