1use anyhow::{Context, Result};
9use rusqlite::{params, Connection, OptionalExtension};
10use serde::Serialize;
11
12pub const TRACKED_COMMANDS: [&str; 8] = [
30 "scan",
31 "faces",
32 "embed",
33 "classify",
34 "dedupe",
35 "fix-dates",
36 "prune",
37 "locations",
38];
39
40pub fn ensure_pipeline_runs_table(conn: &Connection) -> rusqlite::Result<()> {
41 conn.execute_batch(
42 "CREATE TABLE IF NOT EXISTS pipeline_runs (
43 command TEXT PRIMARY KEY,
44 started_at TEXT NOT NULL,
45 finished_at TEXT,
46 status TEXT NOT NULL,
47 duration_ms INTEGER,
48 summary TEXT
49 );",
50 )
51}
52
53pub fn start_run(conn: &Connection, command: &str) -> rusqlite::Result<()> {
54 conn.execute(
55 "INSERT INTO pipeline_runs (command, started_at, status)
56 VALUES (?1, datetime('now'), 'running')
57 ON CONFLICT(command) DO UPDATE SET
58 started_at = excluded.started_at,
59 status = 'running',
60 finished_at = NULL,
61 duration_ms = NULL,
62 summary = NULL",
63 params![command],
64 )?;
65 Ok(())
66}
67
68pub fn finish_run(
69 conn: &Connection,
70 command: &str,
71 status: &str,
72 duration_ms: i64,
73 summary: Option<&str>,
74) -> rusqlite::Result<()> {
75 conn.execute(
76 "UPDATE pipeline_runs SET
77 finished_at = datetime('now'),
78 status = ?2,
79 duration_ms = ?3,
80 summary = ?4
81 WHERE command = ?1",
82 params![command, status, duration_ms, summary],
83 )?;
84 Ok(())
85}
86
87pub fn track_in<T, F>(
99 conn: &Connection,
100 ctx: &crate::library::LibraryContext,
101 guard: &crate::library_locks::CommandGuard,
102 command: &str,
103 f: F,
104) -> Result<T>
105where
106 F: FnOnce() -> Result<T>,
107{
108 track_in_as(conn, ctx, guard, command, command, f)
109}
110
111pub fn track_in_as<T, F>(
119 conn: &Connection,
120 ctx: &crate::library::LibraryContext,
121 guard: &crate::library_locks::CommandGuard,
122 lock_command: &str,
123 label: &str,
124 f: F,
125) -> Result<T>
126where
127 F: FnOnce() -> Result<T>,
128{
129 guard.ensure_matches(ctx, lock_command)?;
130 ctx.ensure_root_identity()?;
135 ensure_pipeline_runs_table(conn)?;
136 start_run(conn, label)?;
137 let started = std::time::Instant::now();
138 let result = f();
139 let duration_ms = started.elapsed().as_millis().min(i64::MAX as u128) as i64;
140 match result {
141 Ok(value) => {
142 finish_run(conn, label, "success", duration_ms, None)?;
143 Ok(value)
144 }
145 Err(error) => {
146 if let Err(record_error) =
149 finish_run(conn, label, "failed", duration_ms, Some(&error.to_string()))
150 {
151 return Err(error.context(format!(
152 "also could not record the failed run: {record_error}"
153 )));
154 }
155 Err(error)
156 }
157 }
158}
159
160pub fn record_heartbeat_in(
166 conn: &Connection,
167 ctx: &crate::library::LibraryContext,
168 command: &str,
169) -> Result<()> {
170 ctx.ensure_root_identity()?;
173 ensure_pipeline_runs_table(conn)?;
174 conn.execute(
175 "INSERT INTO pipeline_runs (command, started_at, status, summary)
176 VALUES (?1, datetime('now'), 'success', 'last successful cycle')
177 ON CONFLICT(command) DO UPDATE SET
178 started_at = excluded.started_at,
179 status = 'success',
180 finished_at = NULL,
181 duration_ms = NULL,
182 summary = excluded.summary",
183 params![command],
184 )?;
185 Ok(())
186}
187
188pub fn read_all_in(
194 conn: &Connection,
195 ctx: &crate::library::LibraryContext,
196) -> Result<Vec<PipelineRunStatus>> {
197 ensure_pipeline_runs_table(conn)?;
198 let mut out = Vec::with_capacity(TRACKED_COMMANDS.len());
199 for command in TRACKED_COMMANDS {
200 out.push(read_one_in(conn, ctx, command)?);
201 }
202 if conn
208 .query_row(
209 "SELECT 1 FROM pipeline_runs WHERE command = 'watch'",
210 [],
211 |r| r.get::<_, i64>(0),
212 )
213 .optional()?
214 .is_some()
215 {
216 out.push(read_one_in(conn, ctx, "watch")?);
217 }
218 if conn
225 .query_row(
226 "SELECT 1 FROM pipeline_runs WHERE command = 'location-names'",
227 [],
228 |r| r.get::<_, i64>(0),
229 )
230 .optional()?
231 .is_some()
232 {
233 out.push(read_one_in(conn, ctx, "location-names")?);
234 }
235 if conn
240 .query_row(
241 "SELECT 1 FROM pipeline_runs WHERE command = 'face-recluster'",
242 [],
243 |r| r.get::<_, i64>(0),
244 )
245 .optional()?
246 .is_some()
247 {
248 out.push(read_one_in(conn, ctx, "face-recluster")?);
249 }
250 Ok(out)
251}
252
253fn read_one_in(
261 conn: &Connection,
262 ctx: &crate::library::LibraryContext,
263 command: &str,
264) -> Result<PipelineRunStatus> {
265 let row: Option<(String, Option<i64>, String)> = conn
266 .query_row(
267 "SELECT started_at, duration_ms, status FROM pipeline_runs WHERE command = ?1",
268 params![command],
269 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
270 )
271 .optional()?;
272 let currently_running = match command {
273 "locations" => {
274 crate::library_locks::command_locked(ctx, "locations")?
275 && !crate::library_locks::command_locked(ctx, "location-names")?
276 }
277 "faces" => {
282 crate::library_locks::command_locked(ctx, "faces")?
283 && !crate::library_locks::command_locked(ctx, "face-recluster")?
284 }
285 other => crate::library_locks::command_locked(ctx, other)?,
286 };
287 let (last_run_at, status, duration_ms) = match row {
288 None => (None, None, None),
289 Some((started_at, duration_ms, stored_status)) => {
290 let status = if stored_status == "running" && !currently_running {
291 "crashed".to_string()
292 } else {
293 stored_status
294 };
295 (Some(started_at), Some(status), duration_ms)
296 }
297 };
298 Ok(PipelineRunStatus {
299 command: command.to_string(),
300 last_run_at,
301 status,
302 duration_ms,
303 currently_running,
304 })
305}
306
307static SIGINT_INSTALLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
325static SIGINT_COMMAND: std::sync::Mutex<Option<&'static str>> = std::sync::Mutex::new(None);
326
327pub fn install_sigint_handler_in(
328 ctx: std::sync::Arc<crate::library::LibraryContext>,
329 command: &'static str,
330) -> Result<()> {
331 ctx.ensure_root_identity()
332 .context("validating the library before installing the SIGINT handler")?;
333 if let Ok(mut current) = SIGINT_COMMAND.lock() {
335 *current = Some(command);
336 }
337 if SIGINT_INSTALLED.load(std::sync::atomic::Ordering::SeqCst) {
340 return Ok(());
341 }
342 ctrlc::set_handler(move || {
343 let command = SIGINT_COMMAND
344 .lock()
345 .ok()
346 .and_then(|c| *c)
347 .unwrap_or(command);
348 if ctx.ensure_root_identity().is_ok() {
349 if let Ok(conn) = crate::library_db::open_without_create(&ctx.paths.db) {
350 let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
351 let started_at: Option<String> = conn
352 .query_row(
353 "SELECT started_at FROM pipeline_runs WHERE command = ?1",
354 params![command],
355 |r| r.get(0),
356 )
357 .optional()
358 .ok()
359 .flatten();
360 let duration_ms = started_at
361 .and_then(|s| {
362 chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok()
363 })
364 .map(|started| {
365 (chrono::Utc::now().naive_utc() - started)
366 .num_milliseconds()
367 .max(0)
368 })
369 .unwrap_or(0);
370 let _ = finish_run(&conn, command, "interrupted", duration_ms, None);
371 }
372 }
373 crate::shutdown::flush();
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}