Skip to main content

videre_core/
pipeline_runs.rs

1//! Per-command pipeline run history and liveness, surfaced by `videre stats`
2//! and other dashboard-style callers.
3//!
4//! `track_in()` below does not rely on Drop/RAII for the success/failure
5//! bookkeeping: the library-scoped command lock's release does, backstopped by
6//! the OS releasing `flock` on any process death.
7
8use anyhow::{Context, Result};
9use rusqlite::{params, Connection, OptionalExtension};
10use serde::Serialize;
11
12/// The eight commands tracked so far. Extended with `locations` on
13/// 2026-08-01 (was seven after `prune`'s addition earlier the same day).
14/// It's a clean fit for the same one-shot start/finish model the others
15/// use: `videre locations` is a full-recompute batch pass, not an
16/// interactive per-query command. `gallery`, `search`, `mcp`, and `config`
17/// remain deliberately excluded: `report --faces`/`--show-faces` and `mcp`
18/// are long-running servers with no natural "finished" moment (the same
19/// reason `videre watch` itself is excluded. See below), `search` is an
20/// interactive per-query command rather than a library-processing pipeline
21/// stage (true even for its new `--location` mode, which is a single query
22/// like any other `search` invocation, not a batch job), and `config` is a
23/// trivial instant read/write with nothing meaningful to time. Revisit only
24/// if a real driver for tracking one of those emerges. See TECH_DEBT.md.
25///
26/// `videre watch` itself is deliberately not in this list, it has no
27/// "finished" moment during normal operation, so it gets its own liveness
28/// lock (see `watch_lock_path`) but no `pipeline_runs` row.
29pub 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
87/// Wraps `f` with pipeline-run bookkeeping for a command whose per-command
88/// lock is already held as a
89/// [`library_locks::CommandGuard`](crate::library_locks::CommandGuard).
90///
91/// The guard is verified rather than trusted: it must have been taken for
92/// exactly `ctx` and `command`, because a mismatched pairing would record
93/// one library's run against another (or one command's row against another
94/// command's) with nothing visibly wrong. The model is `start_run` before
95/// `f`, `success`/`failed` (with the error message) after, all before
96/// returning. It never reacquires the supplied guard, so converting a command
97/// to the library-scoped locks cannot double-lock.
98pub 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
111/// Like [`track_in`], but the recorded label may differ from the command the
112/// guard was taken for. Watch's location stage shares the `locations` command
113/// lock with the standalone clustering recompute (so the two cannot overlap
114/// on SQLite's single writer) while writing its own `location-names` row.
115/// The guard is still verified to belong to this library and to the command
116/// it was actually taken for, so a mismatched guard is refused exactly as
117/// `track_in` refuses one; only the row's name comes from `label`.
118pub 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    // The guard's match is a wiring check on path strings; this rechecks that
131    // the root still names the same library before any run row is written, so
132    // a root swapped out between guard acquisition and now is refused rather
133    // than recorded against whatever now sits at the path.
134    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            // Record the failure, but never let a bookkeeping error mask the
147            // real one: the original error is what the caller acted on.
148            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
160/// Records that a recurring command (`watch`) completed one cycle. Unlike a
161/// start/stop run, the heartbeat is the whole record: `started_at` is the
162/// moment of the last successful cycle, and there is no duration to keep. A
163/// watcher that dies mid-cycle leaves the previous cycle's heartbeat, so a
164/// dead watcher reads as a stale last-cycle time, never as a fake crash.
165pub fn record_heartbeat_in(
166    conn: &Connection,
167    ctx: &crate::library::LibraryContext,
168    command: &str,
169) -> Result<()> {
170    // Same identity check as `track_in`: a root swapped between the cycle's
171    // start and now must not have its cycle recorded against another library.
172    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
188/// Every tracked command's last run and current liveness. Liveness is probed
189/// through the library's own lock files (`library_locks::command_locked`), so a
190/// library-scoped command shows up as running exactly when a library-scoped
191/// reader asks; a `running` row whose lock no live process holds reads back as
192/// `crashed`.
193pub 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    // `watch` is deliberately absent from TRACKED_COMMANDS: it is a recurring
203    // loop, not a start/stop run, so it never takes the per-command run-row
204    // machinery. Its heartbeat (see `record_heartbeat_in`) makes it reportable
205    // all the same; it appears here only once a heartbeat exists, so a library
206    // never watched is not lectured about a stage it never started.
207    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    // `location-names` is watch's incremental reverse-geocoding stage, on the
219    // same only-once-a-row-exists rule as the heartbeat: a library that never
220    // watched with --location is not lectured about it. It is deliberately
221    // distinct from `locations`, whose row means the standalone clustering
222    // recompute, and deliberately not called `geocode`, which in this codebase
223    // means forward geocoding (see `videre_core::geocode`).
224    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    // `face-recluster` is watch's periodic global face recluster, on the same
236    // only-once-a-row-exists rule: a library that never watched with --faces
237    // is not lectured about repair passes it never ran. Distinct from
238    // `faces`, whose row means a detection run.
239    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
253/// One command's row plus lock-derived liveness. Shared by the tracked loop
254/// and the watch heartbeat read, which need identical semantics.
255///
256/// The `locations` row is the one special case: its lock is shared with
257/// watch's location-names stage, which holds both locks while it runs, so a
258/// recompute is active exactly when the shared lock is held and the stage's
259/// own lock is not. Every other command's liveness is its own lock.
260fn 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        // Same shared-lock shape as locations: watch's recluster holds the
278        // faces lock while its own face-recluster lock marks it as the
279        // holder, so a plain faces read must not claim detection is running
280        // on the strength of the shared lock alone.
281        "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
307/// Install a SIGINT handler that marks `command`'s row `interrupted` and exits
308/// 130, against the library the context pins.
309///
310/// The library's identity is validated before the handler is installed (and
311/// again inside it): a handler bound to a root that no longer names its
312/// library would write another library's database on the way out. The
313/// handler's connection opens without `CREATE`, so an exiting process can
314/// never conjure a database that initialization is responsible for. Same
315/// best-effort contract as the global version: errors inside the handler
316/// are swallowed, there is no useful way to report them once the process is
317/// already exiting on a signal.
318/// A `videre pipeline` runs several tracked stages in one process, but the
319/// `ctrlc` crate permits exactly one handler per process. So the handler is
320/// installed once and then retargeted: each stage records the command it is
321/// running, and the single handler marks whichever command is current when the
322/// signal arrives. Re-installing is a silent no-op rather than the "already
323/// registered" error every stage after the first used to raise.
324static 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    // Point the single, process-wide handler at the stage now running.
334    if let Ok(mut current) = SIGINT_COMMAND.lock() {
335        *current = Some(command);
336    }
337    // Later stages in the same process only retarget the handler above; one
338    // handler per process is all `ctrlc` allows.
339    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    /// "running" | "success" | "failed" | "interrupted" | "crashed" | None if never run.
386    /// "crashed" is computed here, never a stored value. See the design doc.
387    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        // A fresh library has never run watch: no row, no liveness time.
406        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        // A second heartbeat is an update of the same row, not an error or a
421        // second entry: recurring commands have exactly one now.
422        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(); // second run begins
474
475        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    /// One library with its state and locks directories in place, plus a
493    /// connection with the runs table: the minimum the library-scoped
494    /// bookkeeping needs, without pulling the database layer's
495    /// initialization in. Locks only need the directories to exist.
496    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        // The other commands' rows are untouched: one command's bookkeeping
527        // never overwrites another's.
528        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        // A guard held for scan must not bookkeep embed's row.
551        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        // Nor may one library's guard bookkeep another library's run, even
560        // under the same command name.
561        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        // Released: the finished run is not running.
584        drop(guard);
585        // A held command lock shows up as running to a library-scoped
586        // reader, with no row of its own: the probe is what says live, not
587        // the presence of a row.
588        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        // A library never watched with --location has no location-names row:
602        // the read must not lecture about a stage it never ran (same rule as
603        // the watch heartbeat above).
604        assert!(read_all_in(&conn, &ctx)
605            .unwrap()
606            .iter()
607            .all(|r| r.command != "location-names"));
608        // One watch cycle writes the row; from then on the stage reports
609        // like any tracked command.
610        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        // Same only-once-a-row-exists rule as location-names: a library that
625        // never watched with --faces is not lectured about repair passes it
626        // never ran.
627        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        // The stage holds BOTH locks while it runs: `faces` for coordination
645        // with detection and the standalone command, and its own
646        // `face-recluster` lock as its identity. A healthy minutes-long pass
647        // therefore reads as running on its own row, never crashed, and the
648        // faces row must not claim detection is running on the strength of
649        // the shared lock alone.
650        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        // Once the stage is gone without finishing, the stale running row
678        // reads back as exactly what it is.
679        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        // The stage holds BOTH locks while it runs: `locations` for
692        // coordination with the standalone recompute, and its own
693        // `location-names` lock as its identity. A healthy cycle therefore
694        // reads as running and active on both rows' behalf, never crashed,
695        // and `status --check` cannot fail during normal operation.
696        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        // The lock's holder is the location-names stage, so the locations
713        // entry must not claim a recompute is running on the strength of the
714        // shared lock alone.
715        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        // Once the stage is gone without finishing, the stale running row
723        // reads back as exactly what it is.
724        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        // Watch died mid-stage: the location-names row is left "running"
737        // while the OS released both locks. A standalone recompute then
738        // starts and takes the locations lock. The stale row must read as
739        // crashed, and the live recompute must keep its running report: the
740        // stage's own lock being free is what proves the holder is the
741        // recompute, not the stage.
742        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        // Watch's location stage shares the `locations` command lock with the
770        // standalone recompute while writing its own `location-names` row:
771        // the guard is verified against the command it was taken for, and the
772        // label is what lands in the table.
773        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        // A guard taken for a different command than the claimed lock is
793        // still refused, exactly as track_in refuses one.
794        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        // A context whose root was replaced after construction must be
812        // refused before any handler is installed, so this deliberately
813        // never reaches ctrlc::set_handler (only one handler can exist per
814        // process, and another test in this suite owns that slot).
815        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        // A pipeline installs the handler once and retargets it for each later
830        // stage, so a second install must succeed silently rather than raise
831        // "already registered" - the warning pipeline used to print between
832        // scan and faces.
833        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}