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//! See docs/superpowers/specs/2026-07-31-dashboard-stats-pass-b-design.md
4//! for the full design, and in particular why `track_in()` below does not rely
5//! on Drop/RAII for the success/failure bookkeeping: the library-scoped command
6//! lock's release does, backstopped by the OS releasing `flock` on any process
7//! death.
8
9use anyhow::{Context, Result};
10use rusqlite::{params, Connection, OptionalExtension};
11use serde::Serialize;
12
13/// The eight commands tracked so far. Extended with `locations` on
14/// 2026-08-01 (was seven after `prune`'s addition earlier the same day).
15/// It's a clean fit for the same one-shot start/finish model the others
16/// use: `videre locations` is a full-recompute batch pass, not an
17/// interactive per-query command. `gallery`, `search`, `mcp`, and `config`
18/// remain deliberately excluded: `report --faces`/`--show-faces` and `mcp`
19/// are long-running servers with no natural "finished" moment (the same
20/// reason `videre watch` itself is excluded. See below), `search` is an
21/// interactive per-query command rather than a library-processing pipeline
22/// stage (true even for its new `--location` mode, which is a single query
23/// like any other `search` invocation, not a batch job), and `config` is a
24/// trivial instant read/write with nothing meaningful to time. Revisit only
25/// if a real driver for tracking one of those emerges. See TECH_DEBT.md.
26///
27/// `videre watch` itself is deliberately not in this list, it has no
28/// "finished" moment during normal operation, so it gets its own liveness
29/// lock (see `watch_lock_path`) but no `pipeline_runs` row.
30pub 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
88/// Wraps `f` with pipeline-run bookkeeping for a command whose per-command
89/// lock is already held as a
90/// [`library_locks::CommandGuard`](crate::library_locks::CommandGuard).
91///
92/// The guard is verified rather than trusted: it must have been taken for
93/// exactly `ctx` and `command`, because a mismatched pairing would record
94/// one library's run against another (or one command's row against another
95/// command's) with nothing visibly wrong. The model is `start_run` before
96/// `f`, `success`/`failed` (with the error message) after, all before
97/// returning. It never reacquires the supplied guard, so converting a command
98/// to the library-scoped locks cannot double-lock.
99pub 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
112/// Like [`track_in`], but the recorded label may differ from the command the
113/// guard was taken for. Watch's location stage shares the `locations` command
114/// lock with the standalone clustering recompute (so the two cannot overlap
115/// on SQLite's single writer) while writing its own `location-names` row.
116/// The guard is still verified to belong to this library and to the command
117/// it was actually taken for, so a mismatched guard is refused exactly as
118/// `track_in` refuses one; only the row's name comes from `label`.
119pub 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    // The guard's match is a wiring check on path strings; this rechecks that
132    // the root still names the same library before any run row is written, so
133    // a root swapped out between guard acquisition and now is refused rather
134    // than recorded against whatever now sits at the path.
135    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            // Record the failure, but never let a bookkeeping error mask the
148            // real one: the original error is what the caller acted on.
149            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
161/// Records that a recurring command (`watch`) completed one cycle. Unlike a
162/// start/stop run, the heartbeat is the whole record: `started_at` is the
163/// moment of the last successful cycle, and there is no duration to keep. A
164/// watcher that dies mid-cycle leaves the previous cycle's heartbeat, so a
165/// dead watcher reads as a stale last-cycle time, never as a fake crash.
166pub fn record_heartbeat_in(
167    conn: &Connection,
168    ctx: &crate::library::LibraryContext,
169    command: &str,
170) -> Result<()> {
171    // Same identity check as `track_in`: a root swapped between the cycle's
172    // start and now must not have its cycle recorded against another library.
173    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
189/// Every tracked command's last run and current liveness. Liveness is probed
190/// through the library's own lock files (`library_locks::command_locked`), so a
191/// library-scoped command shows up as running exactly when a library-scoped
192/// reader asks; a `running` row whose lock no live process holds reads back as
193/// `crashed`.
194pub 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    // `watch` is deliberately absent from TRACKED_COMMANDS: it is a recurring
204    // loop, not a start/stop run, so it never takes the per-command run-row
205    // machinery. Its heartbeat (see `record_heartbeat_in`) makes it reportable
206    // all the same; it appears here only once a heartbeat exists, so a library
207    // never watched is not lectured about a stage it never started.
208    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    // `location-names` is watch's incremental reverse-geocoding stage, on the
220    // same only-once-a-row-exists rule as the heartbeat: a library that never
221    // watched with --location is not lectured about it. It is deliberately
222    // distinct from `locations`, whose row means the standalone clustering
223    // recompute, and deliberately not called `geocode`, which in this codebase
224    // means forward geocoding (see `videre_core::geocode`).
225    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    // `face-recluster` is watch's periodic global face recluster, on the same
237    // only-once-a-row-exists rule: a library that never watched with --faces
238    // is not lectured about repair passes it never ran. Distinct from
239    // `faces`, whose row means a detection run.
240    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
254/// One command's row plus lock-derived liveness. Shared by the tracked loop
255/// and the watch heartbeat read, which need identical semantics.
256///
257/// The `locations` row is the one special case: its lock is shared with
258/// watch's location-names stage, which holds both locks while it runs, so a
259/// recompute is active exactly when the shared lock is held and the stage's
260/// own lock is not. Every other command's liveness is its own lock.
261fn 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        // Same shared-lock shape as locations: watch's recluster holds the
279        // faces lock while its own face-recluster lock marks it as the
280        // holder, so a plain faces read must not claim detection is running
281        // on the strength of the shared lock alone.
282        "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
308/// Install a SIGINT handler that marks `command`'s row `interrupted` and exits
309/// 130, against the library the context pins.
310///
311/// The library's identity is validated before the handler is installed (and
312/// again inside it): a handler bound to a root that no longer names its
313/// library would write another library's database on the way out. The
314/// handler's connection opens without `CREATE`, so an exiting process can
315/// never conjure a database that initialization is responsible for. Same
316/// best-effort contract as the global version: errors inside the handler
317/// are swallowed, there is no useful way to report them once the process is
318/// already exiting on a signal.
319/// A `videre pipeline` runs several tracked stages in one process, but the
320/// `ctrlc` crate permits exactly one handler per process. So the handler is
321/// installed once and then retargeted: each stage records the command it is
322/// running, and the single handler marks whichever command is current when the
323/// signal arrives. Re-installing is a silent no-op rather than the "already
324/// registered" error every stage after the first used to raise.
325static 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    // Point the single, process-wide handler at the stage now running.
335    if let Ok(mut current) = SIGINT_COMMAND.lock() {
336        *current = Some(command);
337    }
338    // Later stages in the same process only retarget the handler above; one
339    // handler per process is all `ctrlc` allows.
340    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        crate::shutdown::flush();
375        std::process::exit(130);
376    })
377    .context("installing SIGINT handler")?;
378    SIGINT_INSTALLED.store(true, std::sync::atomic::Ordering::SeqCst);
379    Ok(())
380}
381
382#[derive(Debug, Clone, PartialEq, Serialize)]
383pub struct PipelineRunStatus {
384    pub command: String,
385    pub last_run_at: Option<String>,
386    /// "running" | "success" | "failed" | "interrupted" | "crashed" | None if never run.
387    /// "crashed" is computed here, never a stored value. See the design doc.
388    pub status: Option<String>,
389    pub duration_ms: Option<i64>,
390    pub currently_running: bool,
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn test_db() -> Connection {
398        let conn = Connection::open_in_memory().unwrap();
399        ensure_pipeline_runs_table(&conn).unwrap();
400        conn
401    }
402
403    #[test]
404    fn heartbeat_records_last_cycle_and_reads_back() {
405        let (_t, ctx, conn) = in_library();
406        // A fresh library has never run watch: no row, no liveness time.
407        assert!(read_all_in(&conn, &ctx)
408            .unwrap()
409            .iter()
410            .all(|r| r.command != "watch"));
411        record_heartbeat_in(&conn, &ctx, "watch").unwrap();
412        let w = read_all_in(&conn, &ctx)
413            .unwrap()
414            .into_iter()
415            .find(|r| r.command == "watch")
416            .expect("the heartbeat row must surface in the run read");
417        assert!(w.last_run_at.is_some(), "started_at is the last-cycle time");
418        assert_eq!(w.status.as_deref(), Some("success"));
419        assert!(!w.currently_running, "no watch process holds the lock");
420
421        // A second heartbeat is an update of the same row, not an error or a
422        // second entry: recurring commands have exactly one now.
423        record_heartbeat_in(&conn, &ctx, "watch").unwrap();
424        assert_eq!(
425            read_all_in(&conn, &ctx)
426                .unwrap()
427                .iter()
428                .filter(|r| r.command == "watch")
429                .count(),
430            1
431        );
432    }
433
434    #[test]
435    fn ensure_pipeline_runs_table_is_idempotent() {
436        let conn = test_db();
437        ensure_pipeline_runs_table(&conn).unwrap();
438    }
439
440    #[test]
441    fn start_run_then_finish_run_records_success() {
442        let conn = test_db();
443        start_run(&conn, "embed").unwrap();
444
445        let status: String = conn
446            .query_row(
447                "SELECT status FROM pipeline_runs WHERE command = 'embed'",
448                [],
449                |r| r.get(0),
450            )
451            .unwrap();
452        assert_eq!(status, "running");
453
454        finish_run(&conn, "embed", "success", 1234, None).unwrap();
455
456        let (status, duration_ms, summary): (String, i64, Option<String>) = conn
457            .query_row(
458                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
459                [],
460                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
461            )
462            .unwrap();
463        assert_eq!(status, "success");
464        assert_eq!(duration_ms, 1234);
465        assert_eq!(summary, None);
466    }
467
468    #[test]
469    fn start_run_upserts_resetting_prior_finish_fields() {
470        let conn = test_db();
471        start_run(&conn, "embed").unwrap();
472        finish_run(&conn, "embed", "failed", 500, Some("boom")).unwrap();
473
474        start_run(&conn, "embed").unwrap(); // second run begins
475
476        let (status, duration_ms, summary): (String, Option<i64>, Option<String>) = conn
477            .query_row(
478                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
479                [],
480                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
481            )
482            .unwrap();
483        assert_eq!(status, "running");
484        assert_eq!(duration_ms, None);
485        assert_eq!(summary, None);
486
487        let count: i64 = conn
488            .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
489            .unwrap();
490        assert_eq!(count, 1, "upsert, not a second row");
491    }
492
493    /// One library with its state and locks directories in place, plus a
494    /// connection with the runs table: the minimum the library-scoped
495    /// bookkeeping needs, without pulling the database layer's
496    /// initialization in. Locks only need the directories to exist.
497    fn in_library() -> (
498        tempfile::TempDir,
499        crate::library::LibraryContext,
500        Connection,
501    ) {
502        let temp = tempfile::tempdir().unwrap();
503        let root = temp.path().join("photos");
504        std::fs::create_dir(&root).unwrap();
505        let ctx = crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap();
506        std::fs::create_dir_all(&ctx.paths.locks).unwrap();
507        let conn = Connection::open_in_memory().unwrap();
508        ensure_pipeline_runs_table(&conn).unwrap();
509        (temp, ctx, conn)
510    }
511
512    #[test]
513    fn track_in_records_runs_under_an_already_held_command_guard() {
514        let (_t, ctx, conn) = in_library();
515        let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
516        let result = track_in(&conn, &ctx, &guard, "scan", || Ok(7)).unwrap();
517        assert_eq!(result, 7);
518        let (status, summary): (String, Option<String>) = conn
519            .query_row(
520                "SELECT status, summary FROM pipeline_runs WHERE command = 'scan'",
521                [],
522                |r| Ok((r.get(0)?, r.get(1)?)),
523            )
524            .unwrap();
525        assert_eq!(status, "success");
526        assert_eq!(summary, None);
527        // The other commands' rows are untouched: one command's bookkeeping
528        // never overwrites another's.
529        let count: i64 = conn
530            .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
531            .unwrap();
532        assert_eq!(count, 1);
533        let failed: Result<()> =
534            track_in(&conn, &ctx, &guard, "scan", || Err(anyhow::anyhow!("boom")));
535        failed.unwrap_err();
536        let (status, summary): (String, Option<String>) = conn
537            .query_row(
538                "SELECT status, summary FROM pipeline_runs WHERE command = 'scan'",
539                [],
540                |r| Ok((r.get(0)?, r.get(1)?)),
541            )
542            .unwrap();
543        assert_eq!(status, "failed");
544        assert_eq!(summary.as_deref(), Some("boom"));
545    }
546
547    #[test]
548    fn track_in_refuses_a_guard_from_another_command_or_library() {
549        let (_t, ctx, conn) = in_library();
550        let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
551        // A guard held for scan must not bookkeep embed's row.
552        let result: Result<()> = track_in(&conn, &ctx, &guard, "embed", || Ok(()));
553        let err = result.unwrap_err();
554        assert!(format!("{err:#}").contains("scan"), "{err:#}");
555        let count: i64 = conn
556            .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
557            .unwrap();
558        assert_eq!(count, 0, "a refused guard must write no row");
559
560        // Nor may one library's guard bookkeep another library's run, even
561        // under the same command name.
562        let temp = tempfile::tempdir().unwrap();
563        let other_root = temp.path().join("other");
564        std::fs::create_dir(&other_root).unwrap();
565        let other =
566            crate::library::LibraryContext::new(&other_root, &temp.path().join("cache")).unwrap();
567        let result: Result<()> = track_in(&conn, &other, &guard, "scan", || Ok(()));
568        let err = result.unwrap_err();
569        assert!(
570            format!("{err:#}").contains(other_root.file_name().unwrap().to_string_lossy().as_ref()),
571            "{err:#}"
572        );
573        let count: i64 = conn
574            .query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0))
575            .unwrap();
576        assert_eq!(count, 0);
577    }
578
579    #[test]
580    fn read_all_in_answers_liveness_from_the_library_locks() {
581        let (_t, ctx, conn) = in_library();
582        let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
583        track_in(&conn, &ctx, &guard, "scan", || Ok(())).unwrap();
584        // Released: the finished run is not running.
585        drop(guard);
586        // A held command lock shows up as running to a library-scoped
587        // reader, with no row of its own: the probe is what says live, not
588        // the presence of a row.
589        let _faces = crate::library_locks::try_command(&ctx, "faces").unwrap();
590        let statuses = read_all_in(&conn, &ctx).unwrap();
591        let scan = statuses.iter().find(|s| s.command == "scan").unwrap();
592        assert_eq!(scan.status.as_deref(), Some("success"));
593        assert!(!scan.currently_running);
594        let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
595        assert_eq!(faces.status, None);
596        assert!(faces.currently_running);
597    }
598
599    #[test]
600    fn location_names_stage_surfaces_only_once_a_row_exists() {
601        let (_t, ctx, conn) = in_library();
602        // A library never watched with --location has no location-names row:
603        // the read must not lecture about a stage it never ran (same rule as
604        // the watch heartbeat above).
605        assert!(read_all_in(&conn, &ctx)
606            .unwrap()
607            .iter()
608            .all(|r| r.command != "location-names"));
609        // One watch cycle writes the row; from then on the stage reports
610        // like any tracked command.
611        start_run(&conn, "location-names").unwrap();
612        finish_run(&conn, "location-names", "success", 5, None).unwrap();
613        let names = read_all_in(&conn, &ctx)
614            .unwrap()
615            .into_iter()
616            .find(|r| r.command == "location-names")
617            .expect("a written location-names row must surface in the run read");
618        assert_eq!(names.status.as_deref(), Some("success"));
619        assert!(!names.currently_running);
620    }
621
622    #[test]
623    fn face_recluster_stage_surfaces_only_once_a_row_exists() {
624        let (_t, ctx, conn) = in_library();
625        // Same only-once-a-row-exists rule as location-names: a library that
626        // never watched with --faces is not lectured about repair passes it
627        // never ran.
628        assert!(read_all_in(&conn, &ctx)
629            .unwrap()
630            .iter()
631            .all(|r| r.command != "face-recluster"));
632        start_run(&conn, "face-recluster").unwrap();
633        finish_run(&conn, "face-recluster", "success", 5, None).unwrap();
634        let recluster = read_all_in(&conn, &ctx)
635            .unwrap()
636            .into_iter()
637            .find(|r| r.command == "face-recluster")
638            .expect("a written face-recluster row must surface in the run read");
639        assert_eq!(recluster.status.as_deref(), Some("success"));
640        assert!(!recluster.currently_running);
641    }
642
643    #[test]
644    fn a_running_face_recluster_stage_reads_running_not_crashed() {
645        // The stage holds BOTH locks while it runs: `faces` for coordination
646        // with detection and the standalone command, and its own
647        // `face-recluster` lock as its identity. A healthy minutes-long pass
648        // therefore reads as running on its own row, never crashed, and the
649        // faces row must not claim detection is running on the strength of
650        // the shared lock alone.
651        let (_t, ctx, conn) = in_library();
652        let faces_guard = crate::library_locks::try_command(&ctx, "faces").unwrap();
653        let recluster_guard = crate::library_locks::try_command(&ctx, "face-recluster").unwrap();
654        start_run(&conn, "face-recluster").unwrap();
655
656        let recluster = read_all_in(&conn, &ctx)
657            .unwrap()
658            .into_iter()
659            .find(|r| r.command == "face-recluster")
660            .expect("a started face-recluster row must surface");
661        assert_eq!(
662            recluster.status.as_deref(),
663            Some("running"),
664            "an actively running recluster is running, not crashed"
665        );
666        assert!(recluster.currently_running);
667
668        let faces = read_all_in(&conn, &ctx)
669            .unwrap()
670            .into_iter()
671            .find(|r| r.command == "faces")
672            .unwrap();
673        assert!(
674            !faces.currently_running,
675            "the shared lock's holder is the recluster, not a detection run"
676        );
677
678        // Once the stage is gone without finishing, the stale running row
679        // reads back as exactly what it is.
680        drop(faces_guard);
681        drop(recluster_guard);
682        let recluster = read_all_in(&conn, &ctx)
683            .unwrap()
684            .into_iter()
685            .find(|r| r.command == "face-recluster")
686            .unwrap();
687        assert_eq!(recluster.status.as_deref(), Some("crashed"));
688    }
689
690    #[test]
691    fn a_running_location_names_stage_reads_running_not_crashed() {
692        // The stage holds BOTH locks while it runs: `locations` for
693        // coordination with the standalone recompute, and its own
694        // `location-names` lock as its identity. A healthy cycle therefore
695        // reads as running and active on both rows' behalf, never crashed,
696        // and `status --check` cannot fail during normal operation.
697        let (_t, ctx, conn) = in_library();
698        let guard = crate::library_locks::try_command(&ctx, "locations").unwrap();
699        let names_guard = crate::library_locks::try_command(&ctx, "location-names").unwrap();
700        start_run(&conn, "location-names").unwrap();
701
702        let names = read_all_in(&conn, &ctx)
703            .unwrap()
704            .into_iter()
705            .find(|r| r.command == "location-names")
706            .expect("a started location-names row must surface");
707        assert_eq!(
708            names.status.as_deref(),
709            Some("running"),
710            "an actively running stage is running, not crashed"
711        );
712        assert!(names.currently_running);
713        // The lock's holder is the location-names stage, so the locations
714        // entry must not claim a recompute is running on the strength of the
715        // shared lock alone.
716        let locations = read_all_in(&conn, &ctx)
717            .unwrap()
718            .into_iter()
719            .find(|r| r.command == "locations")
720            .unwrap();
721        assert!(!locations.currently_running);
722
723        // Once the stage is gone without finishing, the stale running row
724        // reads back as exactly what it is.
725        drop(guard);
726        drop(names_guard);
727        let names = read_all_in(&conn, &ctx)
728            .unwrap()
729            .into_iter()
730            .find(|r| r.command == "location-names")
731            .unwrap();
732        assert_eq!(names.status.as_deref(), Some("crashed"));
733    }
734
735    #[test]
736    fn a_stale_names_row_does_not_mask_a_running_recompute() {
737        // Watch died mid-stage: the location-names row is left "running"
738        // while the OS released both locks. A standalone recompute then
739        // starts and takes the locations lock. The stale row must read as
740        // crashed, and the live recompute must keep its running report: the
741        // stage's own lock being free is what proves the holder is the
742        // recompute, not the stage.
743        let (_t, ctx, conn) = in_library();
744        start_run(&conn, "location-names").unwrap();
745
746        let guard = crate::library_locks::try_command(&ctx, "locations").unwrap();
747        start_run(&conn, "locations").unwrap();
748
749        let statuses = read_all_in(&conn, &ctx).unwrap();
750        let names = statuses
751            .iter()
752            .find(|r| r.command == "location-names")
753            .unwrap();
754        assert_eq!(
755            names.status.as_deref(),
756            Some("crashed"),
757            "a stale row must not borrow the recompute's lock liveness"
758        );
759        let locations = statuses.iter().find(|r| r.command == "locations").unwrap();
760        assert_eq!(locations.status.as_deref(), Some("running"));
761        assert!(
762            locations.currently_running,
763            "the recompute is the real live holder and must be reported as such"
764        );
765        drop(guard);
766    }
767
768    #[test]
769    fn track_in_as_records_a_label_under_another_commands_guard() {
770        // Watch's location stage shares the `locations` command lock with the
771        // standalone recompute while writing its own `location-names` row:
772        // the guard is verified against the command it was taken for, and the
773        // label is what lands in the table.
774        let (_t, ctx, conn) = in_library();
775        let guard = crate::library_locks::try_command(&ctx, "locations").unwrap();
776        track_in_as(
777            &conn,
778            &ctx,
779            &guard,
780            "locations",
781            "location-names",
782            || Ok(()),
783        )
784        .unwrap();
785        let (command, status): (String, String) = conn
786            .query_row("SELECT command, status FROM pipeline_runs", [], |r| {
787                Ok((r.get(0)?, r.get(1)?))
788            })
789            .unwrap();
790        assert_eq!(command, "location-names");
791        assert_eq!(status, "success");
792
793        // A guard taken for a different command than the claimed lock is
794        // still refused, exactly as track_in refuses one.
795        let guard = crate::library_locks::try_command(&ctx, "scan").unwrap();
796        let result: Result<()> = track_in_as(
797            &conn,
798            &ctx,
799            &guard,
800            "locations",
801            "location-names",
802            || Ok(()),
803        );
804        assert!(
805            result.is_err(),
806            "a scan guard must not bookkeep under the locations lock"
807        );
808    }
809
810    #[test]
811    fn install_sigint_handler_in_validates_the_library_before_installing() {
812        // A context whose root was replaced after construction must be
813        // refused before any handler is installed, so this deliberately
814        // never reaches ctrlc::set_handler (only one handler can exist per
815        // process, and another test in this suite owns that slot).
816        let temp = tempfile::tempdir().unwrap();
817        let root = temp.path().join("photos");
818        std::fs::create_dir(&root).unwrap();
819        let ctx = std::sync::Arc::new(
820            crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap(),
821        );
822        std::fs::rename(&root, temp.path().join("moved")).unwrap();
823        std::fs::create_dir(&root).unwrap();
824        let err = install_sigint_handler_in(ctx, "scan").unwrap_err();
825        assert!(format!("{err:#}").contains("no longer names"), "{err:#}");
826    }
827
828    #[test]
829    fn install_sigint_handler_in_is_idempotent_within_a_process() {
830        // A pipeline installs the handler once and retargets it for each later
831        // stage, so a second install must succeed silently rather than raise
832        // "already registered" - the warning pipeline used to print between
833        // scan and faces.
834        let temp = tempfile::tempdir().unwrap();
835        let root = temp.path().join("photos");
836        std::fs::create_dir(&root).unwrap();
837        let ctx = std::sync::Arc::new(
838            crate::library::LibraryContext::new(&root, &temp.path().join("cache")).unwrap(),
839        );
840        install_sigint_handler_in(ctx.clone(), "scan").expect("first install");
841        install_sigint_handler_in(ctx, "faces")
842            .expect("a second install in the same process must be a no-op, not an error");
843    }
844}