1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum JobType {
43 Enrich,
45}
46
47impl JobType {
48 fn tag(self) -> &'static str {
50 match self {
51 JobType::Enrich => "enrich",
52 }
53 }
54}
55
56fn 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
67fn cache_dir() -> Result<PathBuf, AppError> {
75 crate::paths::cache_dir()
76}
77
78pub 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
90pub 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
134fn 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
150pub 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
194pub fn worker_cost_mb() -> u64 {
197 LLM_WORKER_RSS_MB
198}
199
200pub fn acquire_cli_slot(
205 max_concurrency: usize,
206 wait_seconds: Option<u64>,
207) -> Result<(File, usize), AppError> {
208 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 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 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
253pub 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 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 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(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
333fn 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
363pub 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
439fn 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 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 #[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 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}