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 guard.ensure_matches(ctx, command)?;
110 ctx.ensure_root_identity()?;
115 ensure_pipeline_runs_table(conn)?;
116 start_run(conn, command)?;
117 let started = std::time::Instant::now();
118 let result = f();
119 let duration_ms = started.elapsed().as_millis().min(i64::MAX as u128) as i64;
120 match result {
121 Ok(value) => {
122 finish_run(conn, command, "success", duration_ms, None)?;
123 Ok(value)
124 }
125 Err(error) => {
126 if let Err(record_error) = finish_run(
129 conn,
130 command,
131 "failed",
132 duration_ms,
133 Some(&error.to_string()),
134 ) {
135 return Err(error.context(format!(
136 "also could not record the failed run: {record_error}"
137 )));
138 }
139 Err(error)
140 }
141 }
142}
143
144pub fn record_heartbeat_in(
150 conn: &Connection,
151 ctx: &crate::library::LibraryContext,
152 command: &str,
153) -> Result<()> {
154 ctx.ensure_root_identity()?;
157 ensure_pipeline_runs_table(conn)?;
158 conn.execute(
159 "INSERT INTO pipeline_runs (command, started_at, status, summary)
160 VALUES (?1, datetime('now'), 'success', 'last successful cycle')
161 ON CONFLICT(command) DO UPDATE SET
162 started_at = excluded.started_at,
163 status = 'success',
164 finished_at = NULL,
165 duration_ms = NULL,
166 summary = excluded.summary",
167 params![command],
168 )?;
169 Ok(())
170}
171
172pub fn read_all_in(
178 conn: &Connection,
179 ctx: &crate::library::LibraryContext,
180) -> Result<Vec<PipelineRunStatus>> {
181 ensure_pipeline_runs_table(conn)?;
182 let mut out = Vec::with_capacity(TRACKED_COMMANDS.len());
183 for command in TRACKED_COMMANDS {
184 out.push(read_one_in(conn, ctx, command)?);
185 }
186 if conn
192 .query_row(
193 "SELECT 1 FROM pipeline_runs WHERE command = 'watch'",
194 [],
195 |r| r.get::<_, i64>(0),
196 )
197 .optional()?
198 .is_some()
199 {
200 out.push(read_one_in(conn, ctx, "watch")?);
201 }
202 Ok(out)
203}
204
205fn read_one_in(
208 conn: &Connection,
209 ctx: &crate::library::LibraryContext,
210 command: &str,
211) -> Result<PipelineRunStatus> {
212 let row: Option<(String, Option<i64>, String)> = conn
213 .query_row(
214 "SELECT started_at, duration_ms, status FROM pipeline_runs WHERE command = ?1",
215 params![command],
216 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
217 )
218 .optional()?;
219 let currently_running = crate::library_locks::command_locked(ctx, command)?;
220 let (last_run_at, status, duration_ms) = match row {
221 None => (None, None, None),
222 Some((started_at, duration_ms, stored_status)) => {
223 let status = if stored_status == "running" && !currently_running {
224 "crashed".to_string()
225 } else {
226 stored_status
227 };
228 (Some(started_at), Some(status), duration_ms)
229 }
230 };
231 Ok(PipelineRunStatus {
232 command: command.to_string(),
233 last_run_at,
234 status,
235 duration_ms,
236 currently_running,
237 })
238}
239
240static SIGINT_INSTALLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
258static SIGINT_COMMAND: std::sync::Mutex<Option<&'static str>> = std::sync::Mutex::new(None);
259
260pub fn install_sigint_handler_in(
261 ctx: std::sync::Arc<crate::library::LibraryContext>,
262 command: &'static str,
263) -> Result<()> {
264 ctx.ensure_root_identity()
265 .context("validating the library before installing the SIGINT handler")?;
266 if let Ok(mut current) = SIGINT_COMMAND.lock() {
268 *current = Some(command);
269 }
270 if SIGINT_INSTALLED.load(std::sync::atomic::Ordering::SeqCst) {
273 return Ok(());
274 }
275 ctrlc::set_handler(move || {
276 let command = SIGINT_COMMAND
277 .lock()
278 .ok()
279 .and_then(|c| *c)
280 .unwrap_or(command);
281 if ctx.ensure_root_identity().is_ok() {
282 if let Ok(conn) = crate::library_db::open_without_create(&ctx.paths.db) {
283 let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
284 let started_at: Option<String> = conn
285 .query_row(
286 "SELECT started_at FROM pipeline_runs WHERE command = ?1",
287 params![command],
288 |r| r.get(0),
289 )
290 .optional()
291 .ok()
292 .flatten();
293 let duration_ms = started_at
294 .and_then(|s| {
295 chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok()
296 })
297 .map(|started| {
298 (chrono::Utc::now().naive_utc() - started)
299 .num_milliseconds()
300 .max(0)
301 })
302 .unwrap_or(0);
303 let _ = finish_run(&conn, command, "interrupted", duration_ms, None);
304 }
305 }
306 std::process::exit(130);
307 })
308 .context("installing SIGINT handler")?;
309 SIGINT_INSTALLED.store(true, std::sync::atomic::Ordering::SeqCst);
310 Ok(())
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize)]
314pub struct PipelineRunStatus {
315 pub command: String,
316 pub last_run_at: Option<String>,
317 pub status: Option<String>,
320 pub duration_ms: Option<i64>,
321 pub currently_running: bool,
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn test_db() -> Connection {
329 let conn = Connection::open_in_memory().unwrap();
330 ensure_pipeline_runs_table(&conn).unwrap();
331 conn
332 }
333
334 #[test]
335 fn heartbeat_records_last_cycle_and_reads_back() {
336 let (_t, ctx, conn) = in_library();
337 assert!(read_all_in(&conn, &ctx)
339 .unwrap()
340 .iter()
341 .all(|r| r.command != "watch"));
342 record_heartbeat_in(&conn, &ctx, "watch").unwrap();
343 let w = read_all_in(&conn, &ctx)
344 .unwrap()
345 .into_iter()
346 .find(|r| r.command == "watch")
347 .expect("the heartbeat row must surface in the run read");
348 assert!(w.last_run_at.is_some(), "started_at is the last-cycle time");
349 assert_eq!(w.status.as_deref(), Some("success"));
350 assert!(!w.currently_running, "no watch process holds the lock");
351
352 record_heartbeat_in(&conn, &ctx, "watch").unwrap();
355 assert_eq!(
356 read_all_in(&conn, &ctx)
357 .unwrap()
358 .iter()
359 .filter(|r| r.command == "watch")
360 .count(),
361 1
362 );
363 }
364
365 #[test]
366 fn ensure_pipeline_runs_table_is_idempotent() {
367 let conn = test_db();
368 ensure_pipeline_runs_table(&conn).unwrap();
369 }
370
371 #[test]
372 fn start_run_then_finish_run_records_success() {
373 let conn = test_db();
374 start_run(&conn, "embed").unwrap();
375
376 let status: String = conn
377 .query_row(
378 "SELECT status FROM pipeline_runs WHERE command = 'embed'",
379 [],
380 |r| r.get(0),
381 )
382 .unwrap();
383 assert_eq!(status, "running");
384
385 finish_run(&conn, "embed", "success", 1234, None).unwrap();
386
387 let (status, duration_ms, summary): (String, i64, Option<String>) = conn
388 .query_row(
389 "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
390 [],
391 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
392 )
393 .unwrap();
394 assert_eq!(status, "success");
395 assert_eq!(duration_ms, 1234);
396 assert_eq!(summary, None);
397 }
398
399 #[test]
400 fn start_run_upserts_resetting_prior_finish_fields() {
401 let conn = test_db();
402 start_run(&conn, "embed").unwrap();
403 finish_run(&conn, "embed", "failed", 500, Some("boom")).unwrap();
404
405 start_run(&conn, "embed").unwrap(); let (status, duration_ms, summary): (String, Option<i64>, Option<String>) = conn
408 .query_row(
409 "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
410 [],
411 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
412 )
413 .unwrap();
414 assert_eq!(status, "running");
415 assert_eq!(duration_ms, None);
416 assert_eq!(summary, None);
417
418 let count: i64 = conn
419 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
420 .unwrap();
421 assert_eq!(count, 1, "upsert, not a second row");
422 }
423
424 fn in_library() -> (
429 tempfile::TempDir,
430 crate::library::LibraryContext,
431 Connection,
432 ) {
433 let temp = tempfile::tempdir().unwrap();
434 let root = temp.path().join("photos");
435 std::fs::create_dir(&root).unwrap();
436 let ctx = crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
437 std::fs::create_dir_all(&ctx.paths.locks).unwrap();
438 let conn = Connection::open_in_memory().unwrap();
439 ensure_pipeline_runs_table(&conn).unwrap();
440 (temp, ctx, conn)
441 }
442
443 #[test]
444 fn track_in_records_runs_under_an_already_held_command_guard() {
445 let (_t, ctx, conn) = in_library();
446 let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
447 let result = track_in(&conn, &ctx, &guard, "scan", || Ok(7)).unwrap();
448 assert_eq!(result, 7);
449 let (status, summary): (String, Option<String>) = conn
450 .query_row(
451 "SELECT status, summary FROM pipeline_runs WHERE command = 'scan'",
452 [],
453 |r| Ok((r.get(0)?, r.get(1)?)),
454 )
455 .unwrap();
456 assert_eq!(status, "success");
457 assert_eq!(summary, None);
458 let count: i64 = conn
461 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
462 .unwrap();
463 assert_eq!(count, 1);
464 let failed: Result<()> =
465 track_in(&conn, &ctx, &guard, "scan", || Err(anyhow::anyhow!("boom")));
466 failed.unwrap_err();
467 let (status, summary): (String, Option<String>) = conn
468 .query_row(
469 "SELECT status, summary FROM pipeline_runs WHERE command = 'scan'",
470 [],
471 |r| Ok((r.get(0)?, r.get(1)?)),
472 )
473 .unwrap();
474 assert_eq!(status, "failed");
475 assert_eq!(summary.as_deref(), Some("boom"));
476 }
477
478 #[test]
479 fn track_in_refuses_a_guard_from_another_command_or_library() {
480 let (_t, ctx, conn) = in_library();
481 let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
482 let result: Result<()> = track_in(&conn, &ctx, &guard, "embed", || Ok(()));
484 let err = result.unwrap_err();
485 assert!(format!("{err:#}").contains("scan"), "{err:#}");
486 let count: i64 = conn
487 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
488 .unwrap();
489 assert_eq!(count, 0, "a refused guard must write no row");
490
491 let temp = tempfile::tempdir().unwrap();
494 let other_root = temp.path().join("other");
495 std::fs::create_dir(&other_root).unwrap();
496 let other =
497 crate::library::LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
498 let result: Result<()> = track_in(&conn, &other, &guard, "scan", || Ok(()));
499 let err = result.unwrap_err();
500 assert!(
501 format!("{err:#}").contains(other_root.file_name().unwrap().to_string_lossy().as_ref()),
502 "{err:#}"
503 );
504 let count: i64 = conn
505 .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
506 .unwrap();
507 assert_eq!(count, 0);
508 }
509
510 #[test]
511 fn read_all_in_answers_liveness_from_the_library_locks() {
512 let (_t, ctx, conn) = in_library();
513 let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
514 track_in(&conn, &ctx, &guard, "scan", || Ok(())).unwrap();
515 drop(guard);
517 let _faces = crate::library_locks::try_command(&ctx, "faces").unwrap();
521 let statuses = read_all_in(&conn, &ctx).unwrap();
522 let scan = statuses.iter().find(|s| s.command == "scan").unwrap();
523 assert_eq!(scan.status.as_deref(), Some("success"));
524 assert!(!scan.currently_running);
525 let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
526 assert_eq!(faces.status, None);
527 assert!(faces.currently_running);
528 }
529
530 #[test]
531 fn install_sigint_handler_in_validates_the_library_before_installing() {
532 let temp = tempfile::tempdir().unwrap();
537 let root = temp.path().join("photos");
538 std::fs::create_dir(&root).unwrap();
539 let ctx = std::sync::Arc::new(
540 crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap(),
541 );
542 std::fs::rename(&root, temp.path().join("moved")).unwrap();
543 std::fs::create_dir(&root).unwrap();
544 let err = install_sigint_handler_in(ctx, "scan").unwrap_err();
545 assert!(format!("{err:#}").contains("no longer names"), "{err:#}");
546 }
547
548 #[test]
549 fn install_sigint_handler_in_is_idempotent_within_a_process() {
550 let temp = tempfile::tempdir().unwrap();
555 let root = temp.path().join("photos");
556 std::fs::create_dir(&root).unwrap();
557 let ctx = std::sync::Arc::new(
558 crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap(),
559 );
560 install_sigint_handler_in(ctx.clone(), "scan").expect("first install");
561 install_sigint_handler_in(ctx, "faces")
562 .expect("a second install in the same process must be a no-op, not an error");
563 }
564}