1use anyhow::{Context, Result};
10use rusqlite::{params, Connection, OptionalExtension};
11use serde::Serialize;
12
13pub const TRACKED_COMMANDS: [&str; 8] = [
31 "scan",
32 "faces",
33 "embed",
34 "classify",
35 "dedupe",
36 "fix-dates",
37 "prune",
38 "locations",
39];
40
41pub fn ensure_pipeline_runs_table(conn: &Connection) -> rusqlite::Result<()> {
42 conn.execute_batch(
43 "CREATE TABLE IF NOT EXISTS pipeline_runs (
44 command TEXT PRIMARY KEY,
45 started_at TEXT NOT NULL,
46 finished_at TEXT,
47 status TEXT NOT NULL,
48 duration_ms INTEGER,
49 summary TEXT
50 );",
51 )
52}
53
54pub fn start_run(conn: &Connection, command: &str) -> rusqlite::Result<()> {
55 conn.execute(
56 "INSERT INTO pipeline_runs (command, started_at, status)
57 VALUES (?1, datetime('now'), 'running')
58 ON CONFLICT(command) DO UPDATE SET
59 started_at = excluded.started_at,
60 status = 'running',
61 finished_at = NULL,
62 duration_ms = NULL,
63 summary = NULL",
64 params![command],
65 )?;
66 Ok(())
67}
68
69pub fn finish_run(
70 conn: &Connection,
71 command: &str,
72 status: &str,
73 duration_ms: i64,
74 summary: Option<&str>,
75) -> rusqlite::Result<()> {
76 conn.execute(
77 "UPDATE pipeline_runs SET
78 finished_at = datetime('now'),
79 status = ?2,
80 duration_ms = ?3,
81 summary = ?4
82 WHERE command = ?1",
83 params![command, status, duration_ms, summary],
84 )?;
85 Ok(())
86}
87
88pub fn track_in<T, F>(
100 conn: &Connection,
101 ctx: &crate::library::LibraryContext,
102 guard: &crate::library_locks::CommandGuard,
103 command: &str,
104 f: F,
105) -> Result<T>
106where
107 F: FnOnce() -> Result<T>,
108{
109 track_in_as(conn, ctx, guard, command, command, f)
110}
111
112pub fn track_in_as<T, F>(
120 conn: &Connection,
121 ctx: &crate::library::LibraryContext,
122 guard: &crate::library_locks::CommandGuard,
123 lock_command: &str,
124 label: &str,
125 f: F,
126) -> Result<T>
127where
128 F: FnOnce() -> Result<T>,
129{
130 guard.ensure_matches(ctx, lock_command)?;
131 ctx.ensure_root_identity()?;
136 ensure_pipeline_runs_table(conn)?;
137 start_run(conn, label)?;
138 let started = std::time::Instant::now();
139 let result = f();
140 let duration_ms = started.elapsed().as_millis().min(i64::MAX as u128) as i64;
141 match result {
142 Ok(value) => {
143 finish_run(conn, label, "success", duration_ms, None)?;
144 Ok(value)
145 }
146 Err(error) => {
147 if let Err(record_error) =
150 finish_run(conn, label, "failed", duration_ms, Some(&error.to_string()))
151 {
152 return Err(error.context(format!(
153 "also could not record the failed run: {record_error}"
154 )));
155 }
156 Err(error)
157 }
158 }
159}
160
161pub fn record_heartbeat_in(
167 conn: &Connection,
168 ctx: &crate::library::LibraryContext,
169 command: &str,
170) -> Result<()> {
171 ctx.ensure_root_identity()?;
174 ensure_pipeline_runs_table(conn)?;
175 conn.execute(
176 "INSERT INTO pipeline_runs (command, started_at, status, summary)
177 VALUES (?1, datetime('now'), 'success', 'last successful cycle')
178 ON CONFLICT(command) DO UPDATE SET
179 started_at = excluded.started_at,
180 status = 'success',
181 finished_at = NULL,
182 duration_ms = NULL,
183 summary = excluded.summary",
184 params![command],
185 )?;
186 Ok(())
187}
188
189pub fn read_all_in(
195 conn: &Connection,
196 ctx: &crate::library::LibraryContext,
197) -> Result<Vec<PipelineRunStatus>> {
198 ensure_pipeline_runs_table(conn)?;
199 let mut out = Vec::with_capacity(TRACKED_COMMANDS.len());
200 for command in TRACKED_COMMANDS {
201 out.push(read_one_in(conn, ctx, command)?);
202 }
203 if conn
209 .query_row(
210 "SELECT 1 FROM pipeline_runs WHERE command = 'watch'",
211 [],
212 |r| r.get::<_, i64>(0),
213 )
214 .optional()?
215 .is_some()
216 {
217 out.push(read_one_in(conn, ctx, "watch")?);
218 }
219 if conn
226 .query_row(
227 "SELECT 1 FROM pipeline_runs WHERE command = 'location-names'",
228 [],
229 |r| r.get::<_, i64>(0),
230 )
231 .optional()?
232 .is_some()
233 {
234 out.push(read_one_in(conn, ctx, "location-names")?);
235 }
236 if conn
241 .query_row(
242 "SELECT 1 FROM pipeline_runs WHERE command = 'face-recluster'",
243 [],
244 |r| r.get::<_, i64>(0),
245 )
246 .optional()?
247 .is_some()
248 {
249 out.push(read_one_in(conn, ctx, "face-recluster")?);
250 }
251 Ok(out)
252}
253
254fn read_one_in(
262 conn: &Connection,
263 ctx: &crate::library::LibraryContext,
264 command: &str,
265) -> Result<PipelineRunStatus> {
266 let row: Option<(String, Option<i64>, String)> = conn
267 .query_row(
268 "SELECT started_at, duration_ms, status FROM pipeline_runs WHERE command = ?1",
269 params![command],
270 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
271 )
272 .optional()?;
273 let currently_running = match command {
274 "locations" => {
275 crate::library_locks::command_locked(ctx, "locations")?
276 && !crate::library_locks::command_locked(ctx, "location-names")?
277 }
278 "faces" => {
283 crate::library_locks::command_locked(ctx, "faces")?
284 && !crate::library_locks::command_locked(ctx, "face-recluster")?
285 }
286 other => crate::library_locks::command_locked(ctx, other)?,
287 };
288 let (last_run_at, status, duration_ms) = match row {
289 None => (None, None, None),
290 Some((started_at, duration_ms, stored_status)) => {
291 let status = if stored_status == "running" && !currently_running {
292 "crashed".to_string()
293 } else {
294 stored_status
295 };
296 (Some(started_at), Some(status), duration_ms)
297 }
298 };
299 Ok(PipelineRunStatus {
300 command: command.to_string(),
301 last_run_at,
302 status,
303 duration_ms,
304 currently_running,
305 })
306}
307
308static SIGINT_INSTALLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
326static SIGINT_COMMAND: std::sync::Mutex<Option<&'static str>> = std::sync::Mutex::new(None);
327
328pub fn install_sigint_handler_in(
329 ctx: std::sync::Arc<crate::library::LibraryContext>,
330 command: &'static str,
331) -> Result<()> {
332 ctx.ensure_root_identity()
333 .context("validating the library before installing the SIGINT handler")?;
334 if let Ok(mut current) = SIGINT_COMMAND.lock() {
336 *current = Some(command);
337 }
338 if SIGINT_INSTALLED.load(std::sync::atomic::Ordering::SeqCst) {
341 return Ok(());
342 }
343 ctrlc::set_handler(move || {
344 let command = SIGINT_COMMAND
345 .lock()
346 .ok()
347 .and_then(|c| *c)
348 .unwrap_or(command);
349 if ctx.ensure_root_identity().is_ok() {
350 if let Ok(conn) = crate::library_db::open_without_create(&ctx.paths.db) {
351 let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
352 let started_at: Option<String> = conn
353 .query_row(
354 "SELECT started_at FROM pipeline_runs WHERE command = ?1",
355 params![command],
356 |r| r.get(0),
357 )
358 .optional()
359 .ok()
360 .flatten();
361 let duration_ms = started_at
362 .and_then(|s| {
363 chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok()
364 })
365 .map(|started| {
366 (chrono::Utc::now().naive_utc() - started)
367 .num_milliseconds()
368 .max(0)
369 })
370 .unwrap_or(0);
371 let _ = finish_run(&conn, command, "interrupted", duration_ms, None);
372 }
373 }
374 std::process::exit(130);
375 })
376 .context("installing SIGINT handler")?;
377 SIGINT_INSTALLED.store(true, std::sync::atomic::Ordering::SeqCst);
378 Ok(())
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize)]
382pub struct PipelineRunStatus {
383 pub command: String,
384 pub last_run_at: Option<String>,
385 pub status: Option<String>,
388 pub duration_ms: Option<i64>,
389 pub currently_running: bool,
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn test_db() -> Connection {
397 let conn = Connection::open_in_memory().unwrap();
398 ensure_pipeline_runs_table(&conn).unwrap();
399 conn
400 }
401
402 #[test]
403 fn heartbeat_records_last_cycle_and_reads_back() {
404 let (_t, ctx, conn) = in_library();
405 assert!(read_all_in(&conn, &ctx)
407 .unwrap()
408 .iter()
409 .all(|r| r.command != "watch"));
410 record_heartbeat_in(&conn, &ctx, "watch").unwrap();
411 let w = read_all_in(&conn, &ctx)
412 .unwrap()
413 .into_iter()
414 .find(|r| r.command == "watch")
415 .expect("the heartbeat row must surface in the run read");
416 assert!(w.last_run_at.is_some(), "started_at is the last-cycle time");
417 assert_eq!(w.status.as_deref(), Some("success"));
418 assert!(!w.currently_running, "no watch process holds the lock");
419
420 record_heartbeat_in(&conn, &ctx, "watch").unwrap();
423 assert_eq!(
424 read_all_in(&conn, &ctx)
425 .unwrap()
426 .iter()
427 .filter(|r| r.command == "watch")
428 .count(),
429 1
430 );
431 }
432
433 #[test]
434 fn ensure_pipeline_runs_table_is_idempotent() {
435 let conn = test_db();
436 ensure_pipeline_runs_table(&conn).unwrap();
437 }
438
439 #[test]
440 fn start_run_then_finish_run_records_success() {
441 let conn = test_db();
442 start_run(&conn, "embed").unwrap();
443
444 let status: String = conn
445 .query_row(
446 "SELECT status FROM pipeline_runs WHERE command = 'embed'",
447 [],
448 |r| r.get(0),
449 )
450 .unwrap();
451 assert_eq!(status, "running");
452
453 finish_run(&conn, "embed", "success", 1234, None).unwrap();
454
455 let (status, duration_ms, summary): (String, i64, Option<String>) = conn
456 .query_row(
457 "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
458 [],
459 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
460 )
461 .unwrap();
462 assert_eq!(status, "success");
463 assert_eq!(duration_ms, 1234);
464 assert_eq!(summary, None);
465 }
466
467 #[test]
468 fn start_run_upserts_resetting_prior_finish_fields() {
469 let conn = test_db();
470 start_run(&conn, "embed").unwrap();
471 finish_run(&conn, "embed", "failed", 500, Some("boom")).unwrap();
472
473 start_run(&conn, "embed").unwrap(); let (status, duration_ms, summary): (String, Option<i64>, Option<String>) = conn
476 .query_row(
477 "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
478 [],
479 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
480 )
481 .unwrap();
482 assert_eq!(status, "running");
483 assert_eq!(duration_ms, None);
484 assert_eq!(summary, None);
485
486 let count: i64 = conn
487 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
488 .unwrap();
489 assert_eq!(count, 1, "upsert, not a second row");
490 }
491
492 fn in_library() -> (
497 tempfile::TempDir,
498 crate::library::LibraryContext,
499 Connection,
500 ) {
501 let temp = tempfile::tempdir().unwrap();
502 let root = temp.path().join("photos");
503 std::fs::create_dir(&root).unwrap();
504 let ctx = crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
505 std::fs::create_dir_all(&ctx.paths.locks).unwrap();
506 let conn = Connection::open_in_memory().unwrap();
507 ensure_pipeline_runs_table(&conn).unwrap();
508 (temp, ctx, conn)
509 }
510
511 #[test]
512 fn track_in_records_runs_under_an_already_held_command_guard() {
513 let (_t, ctx, conn) = in_library();
514 let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
515 let result = track_in(&conn, &ctx, &guard, "scan", || Ok(7)).unwrap();
516 assert_eq!(result, 7);
517 let (status, summary): (String, Option<String>) = conn
518 .query_row(
519 "SELECT status, summary FROM pipeline_runs WHERE command = 'scan'",
520 [],
521 |r| Ok((r.get(0)?, r.get(1)?)),
522 )
523 .unwrap();
524 assert_eq!(status, "success");
525 assert_eq!(summary, None);
526 let count: i64 = conn
529 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
530 .unwrap();
531 assert_eq!(count, 1);
532 let failed: Result<()> =
533 track_in(&conn, &ctx, &guard, "scan", || Err(anyhow::anyhow!("boom")));
534 failed.unwrap_err();
535 let (status, summary): (String, Option<String>) = conn
536 .query_row(
537 "SELECT status, summary FROM pipeline_runs WHERE command = 'scan'",
538 [],
539 |r| Ok((r.get(0)?, r.get(1)?)),
540 )
541 .unwrap();
542 assert_eq!(status, "failed");
543 assert_eq!(summary.as_deref(), Some("boom"));
544 }
545
546 #[test]
547 fn track_in_refuses_a_guard_from_another_command_or_library() {
548 let (_t, ctx, conn) = in_library();
549 let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
550 let result: Result<()> = track_in(&conn, &ctx, &guard, "embed", || Ok(()));
552 let err = result.unwrap_err();
553 assert!(format!("{err:#}").contains("scan"), "{err:#}");
554 let count: i64 = conn
555 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
556 .unwrap();
557 assert_eq!(count, 0, "a refused guard must write no row");
558
559 let temp = tempfile::tempdir().unwrap();
562 let other_root = temp.path().join("other");
563 std::fs::create_dir(&other_root).unwrap();
564 let other =
565 crate::library::LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
566 let result: Result<()> = track_in(&conn, &other, &guard, "scan", || Ok(()));
567 let err = result.unwrap_err();
568 assert!(
569 format!("{err:#}").contains(other_root.file_name().unwrap().to_string_lossy().as_ref()),
570 "{err:#}"
571 );
572 let count: i64 = conn
573 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
574 .unwrap();
575 assert_eq!(count, 0);
576 }
577
578 #[test]
579 fn read_all_in_answers_liveness_from_the_library_locks() {
580 let (_t, ctx, conn) = in_library();
581 let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
582 track_in(&conn, &ctx, &guard, "scan", || Ok(())).unwrap();
583 drop(guard);
585 let _faces = crate::library_locks::try_command(&ctx, "faces").unwrap();
589 let statuses = read_all_in(&conn, &ctx).unwrap();
590 let scan = statuses.iter().find(|s| s.command == "scan").unwrap();
591 assert_eq!(scan.status.as_deref(), Some("success"));
592 assert!(!scan.currently_running);
593 let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
594 assert_eq!(faces.status, None);
595 assert!(faces.currently_running);
596 }
597
598 #[test]
599 fn location_names_stage_surfaces_only_once_a_row_exists() {
600 let (_t, ctx, conn) = in_library();
601 assert!(read_all_in(&conn, &ctx)
605 .unwrap()
606 .iter()
607 .all(|r| r.command != "location-names"));
608 start_run(&conn, "location-names").unwrap();
611 finish_run(&conn, "location-names", "success", 5, None).unwrap();
612 let names = read_all_in(&conn, &ctx)
613 .unwrap()
614 .into_iter()
615 .find(|r| r.command == "location-names")
616 .expect("a written location-names row must surface in the run read");
617 assert_eq!(names.status.as_deref(), Some("success"));
618 assert!(!names.currently_running);
619 }
620
621 #[test]
622 fn face_recluster_stage_surfaces_only_once_a_row_exists() {
623 let (_t, ctx, conn) = in_library();
624 assert!(read_all_in(&conn, &ctx)
628 .unwrap()
629 .iter()
630 .all(|r| r.command != "face-recluster"));
631 start_run(&conn, "face-recluster").unwrap();
632 finish_run(&conn, "face-recluster", "success", 5, None).unwrap();
633 let recluster = read_all_in(&conn, &ctx)
634 .unwrap()
635 .into_iter()
636 .find(|r| r.command == "face-recluster")
637 .expect("a written face-recluster row must surface in the run read");
638 assert_eq!(recluster.status.as_deref(), Some("success"));
639 assert!(!recluster.currently_running);
640 }
641
642 #[test]
643 fn a_running_face_recluster_stage_reads_running_not_crashed() {
644 let (_t, ctx, conn) = in_library();
651 let faces_guard = crate::library_locks::try_command(&ctx, "faces").unwrap();
652 let recluster_guard = crate::library_locks::try_command(&ctx, "face-recluster").unwrap();
653 start_run(&conn, "face-recluster").unwrap();
654
655 let recluster = read_all_in(&conn, &ctx)
656 .unwrap()
657 .into_iter()
658 .find(|r| r.command == "face-recluster")
659 .expect("a started face-recluster row must surface");
660 assert_eq!(
661 recluster.status.as_deref(),
662 Some("running"),
663 "an actively running recluster is running, not crashed"
664 );
665 assert!(recluster.currently_running);
666
667 let faces = read_all_in(&conn, &ctx)
668 .unwrap()
669 .into_iter()
670 .find(|r| r.command == "faces")
671 .unwrap();
672 assert!(
673 !faces.currently_running,
674 "the shared lock's holder is the recluster, not a detection run"
675 );
676
677 drop(faces_guard);
680 drop(recluster_guard);
681 let recluster = read_all_in(&conn, &ctx)
682 .unwrap()
683 .into_iter()
684 .find(|r| r.command == "face-recluster")
685 .unwrap();
686 assert_eq!(recluster.status.as_deref(), Some("crashed"));
687 }
688
689 #[test]
690 fn a_running_location_names_stage_reads_running_not_crashed() {
691 let (_t, ctx, conn) = in_library();
697 let guard = crate::library_locks::try_command(&ctx, "locations").unwrap();
698 let names_guard = crate::library_locks::try_command(&ctx, "location-names").unwrap();
699 start_run(&conn, "location-names").unwrap();
700
701 let names = read_all_in(&conn, &ctx)
702 .unwrap()
703 .into_iter()
704 .find(|r| r.command == "location-names")
705 .expect("a started location-names row must surface");
706 assert_eq!(
707 names.status.as_deref(),
708 Some("running"),
709 "an actively running stage is running, not crashed"
710 );
711 assert!(names.currently_running);
712 let locations = read_all_in(&conn, &ctx)
716 .unwrap()
717 .into_iter()
718 .find(|r| r.command == "locations")
719 .unwrap();
720 assert!(!locations.currently_running);
721
722 drop(guard);
725 drop(names_guard);
726 let names = read_all_in(&conn, &ctx)
727 .unwrap()
728 .into_iter()
729 .find(|r| r.command == "location-names")
730 .unwrap();
731 assert_eq!(names.status.as_deref(), Some("crashed"));
732 }
733
734 #[test]
735 fn a_stale_names_row_does_not_mask_a_running_recompute() {
736 let (_t, ctx, conn) = in_library();
743 start_run(&conn, "location-names").unwrap();
744
745 let guard = crate::library_locks::try_command(&ctx, "locations").unwrap();
746 start_run(&conn, "locations").unwrap();
747
748 let statuses = read_all_in(&conn, &ctx).unwrap();
749 let names = statuses
750 .iter()
751 .find(|r| r.command == "location-names")
752 .unwrap();
753 assert_eq!(
754 names.status.as_deref(),
755 Some("crashed"),
756 "a stale row must not borrow the recompute's lock liveness"
757 );
758 let locations = statuses.iter().find(|r| r.command == "locations").unwrap();
759 assert_eq!(locations.status.as_deref(), Some("running"));
760 assert!(
761 locations.currently_running,
762 "the recompute is the real live holder and must be reported as such"
763 );
764 drop(guard);
765 }
766
767 #[test]
768 fn track_in_as_records_a_label_under_another_commands_guard() {
769 let (_t, ctx, conn) = in_library();
774 let guard = crate::library_locks::try_command(&ctx, "locations").unwrap();
775 track_in_as(
776 &conn,
777 &ctx,
778 &guard,
779 "locations",
780 "location-names",
781 || Ok(()),
782 )
783 .unwrap();
784 let (command, status): (String, String) = conn
785 .query_row("SELECT command, status FROM pipeline_runs", [], |r| {
786 Ok((r.get(0)?, r.get(1)?))
787 })
788 .unwrap();
789 assert_eq!(command, "location-names");
790 assert_eq!(status, "success");
791
792 let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
795 let result: Result<()> = track_in_as(
796 &conn,
797 &ctx,
798 &guard,
799 "locations",
800 "location-names",
801 || Ok(()),
802 );
803 assert!(
804 result.is_err(),
805 "a scan guard must not bookkeep under the locations lock"
806 );
807 }
808
809 #[test]
810 fn install_sigint_handler_in_validates_the_library_before_installing() {
811 let temp = tempfile::tempdir().unwrap();
816 let root = temp.path().join("photos");
817 std::fs::create_dir(&root).unwrap();
818 let ctx = std::sync::Arc::new(
819 crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap(),
820 );
821 std::fs::rename(&root, temp.path().join("moved")).unwrap();
822 std::fs::create_dir(&root).unwrap();
823 let err = install_sigint_handler_in(ctx, "scan").unwrap_err();
824 assert!(format!("{err:#}").contains("no longer names"), "{err:#}");
825 }
826
827 #[test]
828 fn install_sigint_handler_in_is_idempotent_within_a_process() {
829 let temp = tempfile::tempdir().unwrap();
834 let root = temp.path().join("photos");
835 std::fs::create_dir(&root).unwrap();
836 let ctx = std::sync::Arc::new(
837 crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap(),
838 );
839 install_sigint_handler_in(ctx.clone(), "scan").expect("first install");
840 install_sigint_handler_in(ctx, "faces")
841 .expect("a second install in the same process must be a no-op, not an error");
842 }
843}