1use anyhow::{Context, Result};
10use rusqlite::{params, Connection, OptionalExtension};
11use serde::Serialize;
12use std::fs::{File, OpenOptions};
13use std::path::{Path, PathBuf};
14
15pub const TRACKED_COMMANDS: [&str; 8] = [
33 "scan",
34 "faces",
35 "embed",
36 "classify",
37 "dedupe",
38 "fix-dates",
39 "prune",
40 "locations",
41];
42
43pub fn ensure_pipeline_runs_table(conn: &Connection) -> rusqlite::Result<()> {
44 conn.execute_batch(
45 "CREATE TABLE IF NOT EXISTS pipeline_runs (
46 command TEXT PRIMARY KEY,
47 started_at TEXT NOT NULL,
48 finished_at TEXT,
49 status TEXT NOT NULL,
50 duration_ms INTEGER,
51 summary TEXT
52 );",
53 )
54}
55
56pub fn start_run(conn: &Connection, command: &str) -> rusqlite::Result<()> {
57 conn.execute(
58 "INSERT INTO pipeline_runs (command, started_at, status)
59 VALUES (?1, datetime('now'), 'running')
60 ON CONFLICT(command) DO UPDATE SET
61 started_at = excluded.started_at,
62 status = 'running',
63 finished_at = NULL,
64 duration_ms = NULL,
65 summary = NULL",
66 params![command],
67 )?;
68 Ok(())
69}
70
71pub fn finish_run(
72 conn: &Connection,
73 command: &str,
74 status: &str,
75 duration_ms: i64,
76 summary: Option<&str>,
77) -> rusqlite::Result<()> {
78 conn.execute(
79 "UPDATE pipeline_runs SET
80 finished_at = datetime('now'),
81 status = ?2,
82 duration_ms = ?3,
83 summary = ?4
84 WHERE command = ?1",
85 params![command, status, duration_ms, summary],
86 )?;
87 Ok(())
88}
89
90pub struct LockGuard(#[allow(dead_code)] File);
96
97fn lock_path_for(db_path: &Path, command: &str) -> Result<PathBuf> {
111 use std::hash::{Hash, Hasher};
112 let canonical = db_path
113 .canonicalize()
114 .with_context(|| format!("canonicalize {}", db_path.display()))?;
115 let mut hasher = std::collections::hash_map::DefaultHasher::new();
116 canonical.hash(&mut hasher);
117 let stem = canonical
118 .file_stem()
119 .map(|s| s.to_string_lossy().to_string())
120 .unwrap_or_else(|| "db".to_string());
121 Ok(crate::home::locks_dir()?.join(format!("{stem}-{:016x}.{command}.lock", hasher.finish())))
122}
123
124fn remove_legacy_sidecar_locks(db_path: &Path) {
136 use fs2::FileExt;
137 let Ok(canonical) = db_path.canonicalize() else {
138 return;
139 };
140 for command in TRACKED_COMMANDS.iter().copied().chain(["watch"]) {
145 let legacy = PathBuf::from(format!("{}.{command}.lock", canonical.display()));
146 if !legacy.exists() {
147 continue;
148 }
149 let Ok(file) = OpenOptions::new().write(true).open(&legacy) else {
150 continue;
151 };
152 if file.try_lock_exclusive().is_ok() {
153 let _ = fs2::FileExt::unlock(&file);
154 drop(file);
155 let _ = std::fs::remove_file(&legacy);
156 }
157 }
158}
159
160pub fn acquire_lock(db_path: &Path, command: &str) -> Result<LockGuard> {
165 use fs2::FileExt;
166 let lock_path = lock_path_for(db_path, command)?;
167 if let Some(dir) = lock_path.parent() {
168 std::fs::create_dir_all(dir)
169 .with_context(|| format!("create lock directory {}", dir.display()))?;
170 }
171 remove_legacy_sidecar_locks(db_path);
172 let file = OpenOptions::new()
173 .create(true)
174 .write(true)
175 .open(&lock_path)
176 .with_context(|| format!("open lock file {}", lock_path.display()))?;
177 file.try_lock_exclusive().map_err(|_| {
178 anyhow::anyhow!("{command} is already running against {}", db_path.display())
179 })?;
180 Ok(LockGuard(file))
181}
182
183pub fn is_locked(db_path: &Path, command: &str) -> Result<bool> {
187 use fs2::FileExt;
188 let lock_path = lock_path_for(db_path, command)?;
189 if !lock_path.exists() {
190 return Ok(false);
191 }
192 let file = OpenOptions::new()
193 .write(true)
194 .open(&lock_path)
195 .with_context(|| format!("open lock file {}", lock_path.display()))?;
196 match file.try_lock_exclusive() {
197 Ok(()) => {
198 FileExt::unlock(&file).ok();
199 Ok(false)
200 }
201 Err(_) => Ok(true),
202 }
203}
204
205pub fn track<T>(
215 conn: &Connection,
216 db_path: &Path,
217 command: &str,
218 f: impl FnOnce() -> Result<T>,
219) -> Result<T> {
220 ensure_pipeline_runs_table(conn)?;
221 let _lock = acquire_lock(db_path, command)?;
222 start_run(conn, command)?;
223 let started = std::time::Instant::now();
224 let result = f();
225 let duration_ms = started.elapsed().as_millis() as i64;
226 match &result {
227 Ok(_) => finish_run(conn, command, "success", duration_ms, None)?,
228 Err(e) => finish_run(conn, command, "failed", duration_ms, Some(&e.to_string()))?,
229 }
230 result
231}
232
233pub fn install_sigint_handler(db_path: &Path, command: &'static str) -> Result<()> {
242 let db_path = db_path.to_path_buf();
243 ctrlc::set_handler(move || {
244 if let Ok(conn) = Connection::open(&db_path) {
245 let started_at: Option<String> = conn
246 .query_row(
247 "SELECT started_at FROM pipeline_runs WHERE command = ?1",
248 params![command],
249 |r| r.get(0),
250 )
251 .optional()
252 .ok()
253 .flatten();
254 let duration_ms = started_at
255 .and_then(|s| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok())
256 .map(|started| {
257 (chrono::Utc::now().naive_utc() - started)
258 .num_milliseconds()
259 .max(0)
260 })
261 .unwrap_or(0);
262 let _ = finish_run(&conn, command, "interrupted", duration_ms, None);
263 }
264 std::process::exit(130);
265 })
266 .context("installing SIGINT handler")
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize)]
270pub struct PipelineRunStatus {
271 pub command: String,
272 pub last_run_at: Option<String>,
273 pub status: Option<String>,
276 pub duration_ms: Option<i64>,
277 pub currently_running: bool,
278}
279
280pub fn read_all(conn: &Connection, db_path: &Path) -> Result<Vec<PipelineRunStatus>> {
281 ensure_pipeline_runs_table(conn)?;
282 let mut out = Vec::with_capacity(TRACKED_COMMANDS.len());
283 for command in TRACKED_COMMANDS {
284 let row: Option<(String, Option<i64>, String)> = conn
285 .query_row(
286 "SELECT started_at, duration_ms, status FROM pipeline_runs WHERE command = ?1",
287 params![command],
288 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
289 )
290 .optional()?;
291
292 let currently_running = is_locked(db_path, command)?;
293
294 let (last_run_at, status, duration_ms) = match row {
295 None => (None, None, None),
296 Some((started_at, duration_ms, stored_status)) => {
297 let status = if stored_status == "running" && !currently_running {
298 "crashed".to_string()
299 } else {
300 stored_status
301 };
302 (Some(started_at), Some(status), duration_ms)
303 }
304 };
305
306 out.push(PipelineRunStatus {
307 command: command.to_string(),
308 last_run_at,
309 status,
310 duration_ms,
311 currently_running,
312 });
313 }
314 Ok(out)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 fn isolated_home() {
334 static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
335 HOME.get_or_init(|| {
336 let dir = std::env::temp_dir().join(format!("videre-test-home-{}", std::process::id()));
337 std::fs::create_dir_all(&dir).expect("create isolated test home");
338 std::env::set_var("VIDERE_HOME", &dir);
339 dir
340 });
341 }
342
343 fn test_db() -> Connection {
344 let conn = Connection::open_in_memory().unwrap();
345 ensure_pipeline_runs_table(&conn).unwrap();
346 conn
347 }
348
349 #[test]
350 fn lock_lives_under_the_videre_home_locks_dir_not_beside_the_database() {
351 isolated_home();
352 let db = tempfile::NamedTempFile::new().unwrap();
353 let path = lock_path_for(db.path(), "scan").unwrap();
354
355 assert_eq!(path.parent().unwrap(), crate::home::locks_dir().unwrap());
356 assert!(
357 path.file_name()
358 .unwrap()
359 .to_string_lossy()
360 .ends_with(".scan.lock"),
361 "unexpected lock file name: {}",
362 path.display()
363 );
364 assert_ne!(
365 path.parent().unwrap(),
366 db.path().parent().unwrap(),
367 "lock must not be a sidecar in the database's own directory anymore"
368 );
369 }
370
371 #[test]
372 fn same_basename_in_different_directories_gets_distinct_locks() {
373 isolated_home();
378 let a_dir = tempfile::tempdir().unwrap();
379 let b_dir = tempfile::tempdir().unwrap();
380 let a = a_dir.path().join("photos.db");
381 let b = b_dir.path().join("photos.db");
382 std::fs::write(&a, b"").unwrap();
383 std::fs::write(&b, b"").unwrap();
384
385 let a_lock = lock_path_for(&a, "scan").unwrap();
386 let b_lock = lock_path_for(&b, "scan").unwrap();
387 assert_ne!(
388 a_lock, b_lock,
389 "identically-named databases must not share a lock"
390 );
391
392 assert_eq!(a_lock.parent(), b_lock.parent());
394 }
395
396 #[test]
397 fn acquiring_a_lock_removes_the_legacy_sidecar_left_by_older_versions() {
398 isolated_home();
399 let db = tempfile::NamedTempFile::new().unwrap();
400 let legacy = PathBuf::from(format!(
401 "{}.scan.lock",
402 db.path().canonicalize().unwrap().display()
403 ));
404 std::fs::write(&legacy, b"").unwrap();
405 assert!(legacy.exists());
406
407 let _guard = acquire_lock(db.path(), "scan").unwrap();
408 assert!(
409 !legacy.exists(),
410 "stale sidecar lock should have been cleaned up"
411 );
412 }
413
414 #[test]
415 fn ensure_pipeline_runs_table_is_idempotent() {
416 let conn = test_db();
417 ensure_pipeline_runs_table(&conn).unwrap();
418 }
419
420 #[test]
421 fn start_run_then_finish_run_records_success() {
422 let conn = test_db();
423 start_run(&conn, "embed").unwrap();
424
425 let status: String = conn
426 .query_row(
427 "SELECT status FROM pipeline_runs WHERE command = 'embed'",
428 [],
429 |r| r.get(0),
430 )
431 .unwrap();
432 assert_eq!(status, "running");
433
434 finish_run(&conn, "embed", "success", 1234, None).unwrap();
435
436 let (status, duration_ms, summary): (String, i64, Option<String>) = conn
437 .query_row(
438 "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
439 [],
440 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
441 )
442 .unwrap();
443 assert_eq!(status, "success");
444 assert_eq!(duration_ms, 1234);
445 assert_eq!(summary, None);
446 }
447
448 #[test]
449 fn start_run_upserts_resetting_prior_finish_fields() {
450 let conn = test_db();
451 start_run(&conn, "embed").unwrap();
452 finish_run(&conn, "embed", "failed", 500, Some("boom")).unwrap();
453
454 start_run(&conn, "embed").unwrap(); let (status, duration_ms, summary): (String, Option<i64>, Option<String>) = conn
457 .query_row(
458 "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
459 [],
460 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
461 )
462 .unwrap();
463 assert_eq!(status, "running");
464 assert_eq!(duration_ms, None);
465 assert_eq!(summary, None);
466
467 let count: i64 = conn
468 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
469 .unwrap();
470 assert_eq!(count, 1, "upsert, not a second row");
471 }
472
473 #[test]
474 fn acquire_lock_refuses_a_second_concurrent_acquisition() {
475 isolated_home();
476 let db_file = tempfile::NamedTempFile::new().unwrap();
477 let db_path = db_file.path();
478
479 let _first = acquire_lock(db_path, "faces").unwrap();
480 let second = acquire_lock(db_path, "faces");
481 assert!(
482 second.is_err(),
483 "a second concurrent lock on the same command must be refused"
484 );
485 }
486
487 #[test]
488 fn acquire_lock_allows_different_commands_concurrently() {
489 isolated_home();
490 let db_file = tempfile::NamedTempFile::new().unwrap();
491 let db_path = db_file.path();
492
493 let _faces_lock = acquire_lock(db_path, "faces").unwrap();
494 let embed_lock = acquire_lock(db_path, "embed");
495 assert!(
496 embed_lock.is_ok(),
497 "different commands must not contend for the same lock"
498 );
499 }
500
501 #[test]
502 fn acquire_lock_is_available_again_after_release() {
503 isolated_home();
504 let db_file = tempfile::NamedTempFile::new().unwrap();
505 let db_path = db_file.path();
506
507 {
508 let _lock = acquire_lock(db_path, "scan").unwrap();
509 } let second = acquire_lock(db_path, "scan");
512 assert!(
513 second.is_ok(),
514 "lock must be available again once the guard is dropped"
515 );
516 }
517
518 #[test]
519 fn track_records_success_and_returns_the_value() {
520 isolated_home();
521 let conn = test_db();
522 let db_file = tempfile::NamedTempFile::new().unwrap();
523
524 let result = track(&conn, db_file.path(), "embed", || Ok(42)).unwrap();
525 assert_eq!(result, 42);
526
527 let status: String = conn
528 .query_row(
529 "SELECT status FROM pipeline_runs WHERE command = 'embed'",
530 [],
531 |r| r.get(0),
532 )
533 .unwrap();
534 assert_eq!(status, "success");
535 }
536
537 #[test]
538 fn track_records_failure_with_the_error_message() {
539 isolated_home();
540 let conn = test_db();
541 let db_file = tempfile::NamedTempFile::new().unwrap();
542
543 let result: Result<()> = track(&conn, db_file.path(), "classify", || {
544 Err(anyhow::anyhow!("something broke"))
545 });
546 assert!(result.is_err());
547
548 let (status, summary): (String, Option<String>) = conn
549 .query_row(
550 "SELECT status, summary FROM pipeline_runs WHERE command = 'classify'",
551 [],
552 |r| Ok((r.get(0)?, r.get(1)?)),
553 )
554 .unwrap();
555 assert_eq!(status, "failed");
556 assert_eq!(summary.as_deref(), Some("something broke"));
557 }
558
559 #[test]
560 fn track_refuses_when_already_locked() {
561 isolated_home();
562 let conn = test_db();
563 let db_file = tempfile::NamedTempFile::new().unwrap();
564
565 let _held = acquire_lock(db_file.path(), "scan").unwrap();
566 let result: Result<()> = track(&conn, db_file.path(), "scan", || Ok(()));
567 assert!(
568 result.is_err(),
569 "track must refuse to run while the lock is already held"
570 );
571
572 let count: i64 = conn
573 .query_row(
574 "SELECT COUNT(*) FROM pipeline_runs WHERE command = 'scan'",
575 [],
576 |r| r.get(0),
577 )
578 .unwrap();
579 assert_eq!(count, 0);
580 }
581
582 #[test]
583 fn read_all_reports_none_for_a_never_run_command() {
584 isolated_home();
585 let conn = test_db();
586 let db_file = tempfile::NamedTempFile::new().unwrap();
587
588 let statuses = read_all(&conn, db_file.path()).unwrap();
589 let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
590 assert_eq!(embed.last_run_at, None);
591 assert_eq!(embed.status, None);
592 assert!(!embed.currently_running);
593 }
594
595 #[test]
596 fn read_all_reports_success_after_a_completed_run() {
597 isolated_home();
598 let conn = test_db();
599 let db_file = tempfile::NamedTempFile::new().unwrap();
600
601 track(&conn, db_file.path(), "embed", || Ok(())).unwrap();
602
603 let statuses = read_all(&conn, db_file.path()).unwrap();
604 let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
605 assert_eq!(embed.status.as_deref(), Some("success"));
606 assert!(embed.last_run_at.is_some());
607 assert!(!embed.currently_running);
608 }
609
610 #[test]
611 fn read_all_reports_currently_running_while_locked() {
612 isolated_home();
613 let conn = test_db();
614 let db_file = tempfile::NamedTempFile::new().unwrap();
615
616 start_run(&conn, "faces").unwrap();
617 let _held = acquire_lock(db_file.path(), "faces").unwrap();
618
619 let statuses = read_all(&conn, db_file.path()).unwrap();
620 let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
621 assert_eq!(faces.status.as_deref(), Some("running"));
622 assert!(faces.currently_running);
623 }
624
625 #[test]
626 fn read_all_reports_crashed_when_running_but_not_locked() {
627 isolated_home();
628 let conn = test_db();
629 let db_file = tempfile::NamedTempFile::new().unwrap();
630
631 start_run(&conn, "faces").unwrap();
632
633 let statuses = read_all(&conn, db_file.path()).unwrap();
634 let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
635 assert_eq!(faces.status.as_deref(), Some("crashed"));
636 assert!(!faces.currently_running);
637
638 let stored_status: String = conn
639 .query_row(
640 "SELECT status FROM pipeline_runs WHERE command = 'faces'",
641 [],
642 |r| r.get(0),
643 )
644 .unwrap();
645 assert_eq!(
646 stored_status, "running",
647 "read_all must not write back the crashed label"
648 );
649 }
650
651 #[test]
652 fn install_sigint_handler_does_not_error_when_called_once() {
653 let db_file = tempfile::NamedTempFile::new().unwrap();
654 let result = install_sigint_handler(db_file.path(), "scan");
659 assert!(result.is_ok());
660 }
661}