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