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()` below does not rely
5//! on Drop/RAII for the success/failure bookkeeping, only the lock's
6//! release does, and even that is backstopped by the OS releasing `flock` on
7//! any process death.
8
9use anyhow::{Context, Result};
10use rusqlite::{params, Connection, OptionalExtension};
11use serde::Serialize;
12use std::fs::{File, OpenOptions};
13use std::path::{Path, PathBuf};
14
15/// The eight commands tracked so far. Extended with `locations` on
16/// 2026-08-01 (was seven after `prune`'s addition earlier the same day).
17/// It's a clean fit for the same one-shot start/finish model the others
18/// use: `videre locations` is a full-recompute batch pass, not an
19/// interactive per-query command. `report`, `search`, `mcp`, and `config`
20/// remain deliberately excluded: `report --faces`/`--show-faces` and `mcp`
21/// are long-running servers with no natural "finished" moment (the same
22/// reason `videre watch` itself is excluded. See below), `search` is an
23/// interactive per-query command rather than a library-processing pipeline
24/// stage (true even for its new `--location` mode, which is a single query
25/// like any other `search` invocation, not a batch job), and `config` is a
26/// trivial instant read/write with nothing meaningful to time. Revisit only
27/// if a real driver for tracking one of those emerges. See TECH_DEBT.md.
28///
29/// `videre watch` itself is deliberately not in this list, it has no
30/// "finished" moment during normal operation, so it gets its own liveness
31/// lock (see `watch_lock_path`) but no `pipeline_runs` row.
32pub const TRACKED_COMMANDS: [&str; 8] =
33    ["scan", "faces", "embed", "classify", "dedupe", "fix-dates", "prune", "locations"];
34
35pub fn ensure_pipeline_runs_table(conn: &Connection) -> rusqlite::Result<()> {
36    conn.execute_batch(
37        "CREATE TABLE IF NOT EXISTS pipeline_runs (
38            command      TEXT PRIMARY KEY,
39            started_at   TEXT NOT NULL,
40            finished_at  TEXT,
41            status       TEXT NOT NULL,
42            duration_ms  INTEGER,
43            summary      TEXT
44        );",
45    )
46}
47
48pub fn start_run(conn: &Connection, command: &str) -> rusqlite::Result<()> {
49    conn.execute(
50        "INSERT INTO pipeline_runs (command, started_at, status)
51         VALUES (?1, datetime('now'), 'running')
52         ON CONFLICT(command) DO UPDATE SET
53             started_at = excluded.started_at,
54             status = 'running',
55             finished_at = NULL,
56             duration_ms = NULL,
57             summary = NULL",
58        params![command],
59    )?;
60    Ok(())
61}
62
63pub fn finish_run(
64    conn: &Connection,
65    command: &str,
66    status: &str,
67    duration_ms: i64,
68    summary: Option<&str>,
69) -> rusqlite::Result<()> {
70    conn.execute(
71        "UPDATE pipeline_runs SET
72             finished_at = datetime('now'),
73             status = ?2,
74             duration_ms = ?3,
75             summary = ?4
76         WHERE command = ?1",
77        params![command, status, duration_ms, summary],
78    )?;
79    Ok(())
80}
81
82/// Holds an open, flock'd file for as long as it's alive. Dropping it closes
83/// the file, which releases the flock, the OS does the same thing
84/// automatically if the process dies without ever dropping this (SIGKILL,
85/// power loss), so there is no correctness dependency on Drop actually
86/// running; it's just the tidy path.
87pub struct LockGuard(#[allow(dead_code)] File);
88
89/// Lock file for one (database, command) pair, under `<videre home>/locks/`.
90///
91/// The name is `<db stem>-<hash of the canonical db path>.<command>.lock`. The
92/// hash is what makes this correct rather than merely tidy: two libraries can
93/// both be named `photos.db` in different directories, and keying on the
94/// basename alone would make them share a lock, silently serializing unrelated
95/// libraries, and making `videre stats` report one as running because the other
96/// is. The readable stem is kept purely so a human listing the directory can
97/// tell which database a lock belongs to.
98///
99/// The canonicalize call also means two paths to the same database (a symlink,
100/// `./photos.db` vs an absolute path) resolve to one lock, which is the
101/// property the old sidecar scheme got for free by living next to the file.
102fn lock_path_for(db_path: &Path, command: &str) -> Result<PathBuf> {
103    use std::hash::{Hash, Hasher};
104    let canonical = db_path
105        .canonicalize()
106        .with_context(|| format!("canonicalize {}", db_path.display()))?;
107    let mut hasher = std::collections::hash_map::DefaultHasher::new();
108    canonical.hash(&mut hasher);
109    let stem = canonical
110        .file_stem()
111        .map(|s| s.to_string_lossy().to_string())
112        .unwrap_or_else(|| "db".to_string());
113    Ok(crate::home::locks_dir()?.join(format!(
114        "{stem}-{:016x}.{command}.lock",
115        hasher.finish()
116    )))
117}
118
119/// Deletes every pre-`locks/` sidecar lock (`<db path>.<command>.lock`) left
120/// behind by older versions, so upgrading actually clears the clutter from
121/// `~/.videre` instead of leaving a permanent litter of zero-byte files.
122///
123/// Only removes it when an exclusive `flock` succeeds, which proves no live
124/// process is holding it. An older binary running concurrently would still
125/// hold its sidecar, and we leave that alone, deleting a held lock file
126/// wouldn't release the lock anyway (the `flock` lives on the inode), it would
127/// just let the next process create a fresh file and a second, independent
128/// lock. Entirely best-effort: any failure here is ignored, since this is
129/// housekeeping and must never be able to fail a real run.
130fn remove_legacy_sidecar_locks(db_path: &Path) {
131    use fs2::FileExt;
132    let Ok(canonical) = db_path.canonicalize() else { return };
133    // Sweep every command's sidecar, not just the one being acquired: cleaning
134    // only the current command would leave the rest sitting in the user's
135    // directory until each of those commands happened to run, and something
136    // like `watch` may not run for weeks. One command now clears the lot.
137    for command in TRACKED_COMMANDS.iter().copied().chain(["watch"]) {
138        let legacy = PathBuf::from(format!("{}.{command}.lock", canonical.display()));
139        if !legacy.exists() {
140            continue;
141        }
142        let Ok(file) = OpenOptions::new().write(true).open(&legacy) else { continue };
143        if file.try_lock_exclusive().is_ok() {
144            let _ = fs2::FileExt::unlock(&file);
145            drop(file);
146            let _ = std::fs::remove_file(&legacy);
147        }
148    }
149}
150
151/// Acquires an exclusive, non-blocking advisory lock scoped to this exact
152/// database file and command. Fails immediately (refusing the run, per the
153/// concurrency decision in the design doc) if another live process already
154/// holds it, never blocks waiting for it to free up.
155pub fn acquire_lock(db_path: &Path, command: &str) -> Result<LockGuard> {
156    use fs2::FileExt;
157    let lock_path = lock_path_for(db_path, command)?;
158    if let Some(dir) = lock_path.parent() {
159        std::fs::create_dir_all(dir)
160            .with_context(|| format!("create lock directory {}", dir.display()))?;
161    }
162    remove_legacy_sidecar_locks(db_path);
163    let file = OpenOptions::new()
164        .create(true)
165        .write(true)
166        .open(&lock_path)
167        .with_context(|| format!("open lock file {}", lock_path.display()))?;
168    file.try_lock_exclusive()
169        .map_err(|_| anyhow::anyhow!("{command} is already running against {}", db_path.display()))?;
170    Ok(LockGuard(file))
171}
172
173/// True if another live process currently holds `command`'s lock for
174/// `db_path`. Never blocks: probes with a non-blocking try-lock and releases
175/// immediately if it succeeds, so this is safe to call from a read path.
176pub fn is_locked(db_path: &Path, command: &str) -> Result<bool> {
177    use fs2::FileExt;
178    let lock_path = lock_path_for(db_path, command)?;
179    if !lock_path.exists() {
180        return Ok(false);
181    }
182    let file = OpenOptions::new()
183        .write(true)
184        .open(&lock_path)
185        .with_context(|| format!("open lock file {}", lock_path.display()))?;
186    match file.try_lock_exclusive() {
187        Ok(()) => {
188            FileExt::unlock(&file).ok();
189            Ok(false)
190        }
191        Err(_) => Ok(true),
192    }
193}
194
195/// Wraps `f` with pipeline-run bookkeeping: refuses to start if `command` is
196/// already running against `db_path`, records a `running` row before calling
197/// `f`, then records `success`/`failed` (with `f`'s error message, if any)
198/// once `f` returns, all before this function itself returns. Every
199/// `std::process::exit` call site this design touches happens strictly after
200/// its wrapped operation already returned a `Result` (see the design doc's
201/// "key design insight"), so this finalization is never skipped by an exit
202/// call, only an actual crash mid-`f()` skips it, which is exactly what the
203/// lock-based `crashed` detection in `read_all` is for.
204pub fn track<T>(
205    conn: &Connection,
206    db_path: &Path,
207    command: &str,
208    f: impl FnOnce() -> Result<T>,
209) -> Result<T> {
210    ensure_pipeline_runs_table(conn)?;
211    let _lock = acquire_lock(db_path, command)?;
212    start_run(conn, command)?;
213    let started = std::time::Instant::now();
214    let result = f();
215    let duration_ms = started.elapsed().as_millis() as i64;
216    match &result {
217        Ok(_) => finish_run(conn, command, "success", duration_ms, None)?,
218        Err(e) => finish_run(conn, command, "failed", duration_ms, Some(&e.to_string()))?,
219    }
220    result
221}
222
223/// Installs a SIGINT handler that marks `command`'s row `interrupted` (using
224/// its already-recorded `started_at` to compute duration) and exits 130, the
225/// standard SIGINT exit code. Call this once, after `track()`'s `start_run`
226/// has already written the `running` row for `command`, the handler opens
227/// its own fresh connection since the main thread's `Connection` isn't
228/// safely shareable across the handler boundary. Best-effort: any error
229/// inside the handler is swallowed (there's no useful way to report it once
230/// the process is already exiting on a signal).
231pub fn install_sigint_handler(db_path: &Path, command: &'static str) -> Result<()> {
232    let db_path = db_path.to_path_buf();
233    ctrlc::set_handler(move || {
234        if let Ok(conn) = Connection::open(&db_path) {
235            let started_at: Option<String> = conn
236                .query_row(
237                    "SELECT started_at FROM pipeline_runs WHERE command = ?1",
238                    params![command],
239                    |r| r.get(0),
240                )
241                .optional()
242                .ok()
243                .flatten();
244            let duration_ms = started_at
245                .and_then(|s| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok())
246                .map(|started| {
247                    (chrono::Utc::now().naive_utc() - started).num_milliseconds().max(0)
248                })
249                .unwrap_or(0);
250            let _ = finish_run(&conn, command, "interrupted", duration_ms, None);
251        }
252        std::process::exit(130);
253    })
254    .context("installing SIGINT handler")
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize)]
258pub struct PipelineRunStatus {
259    pub command: String,
260    pub last_run_at: Option<String>,
261    /// "running" | "success" | "failed" | "interrupted" | "crashed" | None if never run.
262    /// "crashed" is computed here, never a stored value. See the design doc.
263    pub status: Option<String>,
264    pub duration_ms: Option<i64>,
265    pub currently_running: bool,
266}
267
268pub fn read_all(conn: &Connection, db_path: &Path) -> Result<Vec<PipelineRunStatus>> {
269    ensure_pipeline_runs_table(conn)?;
270    let mut out = Vec::with_capacity(TRACKED_COMMANDS.len());
271    for command in TRACKED_COMMANDS {
272        let row: Option<(String, Option<i64>, String)> = conn
273            .query_row(
274                "SELECT started_at, duration_ms, status FROM pipeline_runs WHERE command = ?1",
275                params![command],
276                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
277            )
278            .optional()?;
279
280        let currently_running = is_locked(db_path, command)?;
281
282        let (last_run_at, status, duration_ms) = match row {
283            None => (None, None, None),
284            Some((started_at, duration_ms, stored_status)) => {
285                let status = if stored_status == "running" && !currently_running {
286                    "crashed".to_string()
287                } else {
288                    stored_status
289                };
290                (Some(started_at), Some(status), duration_ms)
291            }
292        };
293
294        out.push(PipelineRunStatus {
295            command: command.to_string(),
296            last_run_at,
297            status,
298            duration_ms,
299            currently_running,
300        });
301    }
302    Ok(out)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// Points `VIDERE_HOME` at one throwaway directory for the whole test
310    /// binary, so lock files land there instead of the developer's real
311    /// `~/.videre/locks`. Necessary since locks moved out of the database's own
312    /// directory: before that, a temp-file database put its locks in a temp dir
313    /// that cleaned itself up, but now every test would write into the real
314    /// home and leave litter behind forever (test databases have random names,
315    /// so the files would accumulate, never being reused or overwritten).
316    ///
317    /// The `set_var` happens inside `get_or_init` so it runs exactly once even
318    /// though tests share a process and run in parallel, calling `set_var`
319    /// from several threads at once would otherwise be a data race against
320    /// every concurrent `getenv`.
321    fn isolated_home() {
322        static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
323        HOME.get_or_init(|| {
324            let dir = std::env::temp_dir()
325                .join(format!("videre-test-home-{}", std::process::id()));
326            std::fs::create_dir_all(&dir).expect("create isolated test home");
327            std::env::set_var("VIDERE_HOME", &dir);
328            dir
329        });
330    }
331
332    fn test_db() -> Connection {
333        let conn = Connection::open_in_memory().unwrap();
334        ensure_pipeline_runs_table(&conn).unwrap();
335        conn
336    }
337
338    #[test]
339    fn lock_lives_under_the_videre_home_locks_dir_not_beside_the_database() {
340        isolated_home();
341        let db = tempfile::NamedTempFile::new().unwrap();
342        let path = lock_path_for(db.path(), "scan").unwrap();
343
344        assert_eq!(path.parent().unwrap(), crate::home::locks_dir().unwrap());
345        assert!(
346            path.file_name().unwrap().to_string_lossy().ends_with(".scan.lock"),
347            "unexpected lock file name: {}",
348            path.display()
349        );
350        assert_ne!(
351            path.parent().unwrap(),
352            db.path().parent().unwrap(),
353            "lock must not be a sidecar in the database's own directory anymore"
354        );
355    }
356
357    #[test]
358    fn same_basename_in_different_directories_gets_distinct_locks() {
359        // The reason the lock name carries a hash of the full path rather than
360        // just the stem: two libraries can each be called photos.db. Sharing a
361        // lock between them would serialize unrelated work and make `videre
362        // stats` report one as running because the other is.
363        isolated_home();
364        let a_dir = tempfile::tempdir().unwrap();
365        let b_dir = tempfile::tempdir().unwrap();
366        let a = a_dir.path().join("photos.db");
367        let b = b_dir.path().join("photos.db");
368        std::fs::write(&a, b"").unwrap();
369        std::fs::write(&b, b"").unwrap();
370
371        let a_lock = lock_path_for(&a, "scan").unwrap();
372        let b_lock = lock_path_for(&b, "scan").unwrap();
373        assert_ne!(a_lock, b_lock, "identically-named databases must not share a lock");
374
375        // ...and both still land in the one locks directory.
376        assert_eq!(a_lock.parent(), b_lock.parent());
377    }
378
379    #[test]
380    fn acquiring_a_lock_removes_the_legacy_sidecar_left_by_older_versions() {
381        isolated_home();
382        let db = tempfile::NamedTempFile::new().unwrap();
383        let legacy = PathBuf::from(format!(
384            "{}.scan.lock",
385            db.path().canonicalize().unwrap().display()
386        ));
387        std::fs::write(&legacy, b"").unwrap();
388        assert!(legacy.exists());
389
390        let _guard = acquire_lock(db.path(), "scan").unwrap();
391        assert!(!legacy.exists(), "stale sidecar lock should have been cleaned up");
392    }
393
394    #[test]
395    fn ensure_pipeline_runs_table_is_idempotent() {
396        let conn = test_db();
397        ensure_pipeline_runs_table(&conn).unwrap();
398    }
399
400    #[test]
401    fn start_run_then_finish_run_records_success() {
402        let conn = test_db();
403        start_run(&conn, "embed").unwrap();
404
405        let status: String = conn
406            .query_row("SELECT status FROM pipeline_runs WHERE command = 'embed'", [], |r| r.get(0))
407            .unwrap();
408        assert_eq!(status, "running");
409
410        finish_run(&conn, "embed", "success", 1234, None).unwrap();
411
412        let (status, duration_ms, summary): (String, i64, Option<String>) = conn
413            .query_row(
414                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
415                [],
416                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
417            )
418            .unwrap();
419        assert_eq!(status, "success");
420        assert_eq!(duration_ms, 1234);
421        assert_eq!(summary, None);
422    }
423
424    #[test]
425    fn start_run_upserts_resetting_prior_finish_fields() {
426        let conn = test_db();
427        start_run(&conn, "embed").unwrap();
428        finish_run(&conn, "embed", "failed", 500, Some("boom")).unwrap();
429
430        start_run(&conn, "embed").unwrap(); // second run begins
431
432        let (status, duration_ms, summary): (String, Option<i64>, Option<String>) = conn
433            .query_row(
434                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
435                [],
436                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
437            )
438            .unwrap();
439        assert_eq!(status, "running");
440        assert_eq!(duration_ms, None);
441        assert_eq!(summary, None);
442
443        let count: i64 = conn.query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0)).unwrap();
444        assert_eq!(count, 1, "upsert, not a second row");
445    }
446
447    #[test]
448    fn acquire_lock_refuses_a_second_concurrent_acquisition() {
449        isolated_home();
450        let db_file = tempfile::NamedTempFile::new().unwrap();
451        let db_path = db_file.path();
452
453        let _first = acquire_lock(db_path, "faces").unwrap();
454        let second = acquire_lock(db_path, "faces");
455        assert!(second.is_err(), "a second concurrent lock on the same command must be refused");
456    }
457
458    #[test]
459    fn acquire_lock_allows_different_commands_concurrently() {
460        isolated_home();
461        let db_file = tempfile::NamedTempFile::new().unwrap();
462        let db_path = db_file.path();
463
464        let _faces_lock = acquire_lock(db_path, "faces").unwrap();
465        let embed_lock = acquire_lock(db_path, "embed");
466        assert!(embed_lock.is_ok(), "different commands must not contend for the same lock");
467    }
468
469    #[test]
470    fn acquire_lock_is_available_again_after_release() {
471        isolated_home();
472        let db_file = tempfile::NamedTempFile::new().unwrap();
473        let db_path = db_file.path();
474
475        {
476            let _lock = acquire_lock(db_path, "scan").unwrap();
477        } // dropped here, releasing the flock
478
479        let second = acquire_lock(db_path, "scan");
480        assert!(second.is_ok(), "lock must be available again once the guard is dropped");
481    }
482
483    #[test]
484    fn track_records_success_and_returns_the_value() {
485        isolated_home();
486        let conn = test_db();
487        let db_file = tempfile::NamedTempFile::new().unwrap();
488
489        let result = track(&conn, db_file.path(), "embed", || Ok(42)).unwrap();
490        assert_eq!(result, 42);
491
492        let status: String = conn
493            .query_row("SELECT status FROM pipeline_runs WHERE command = 'embed'", [], |r| r.get(0))
494            .unwrap();
495        assert_eq!(status, "success");
496    }
497
498    #[test]
499    fn track_records_failure_with_the_error_message() {
500        isolated_home();
501        let conn = test_db();
502        let db_file = tempfile::NamedTempFile::new().unwrap();
503
504        let result: Result<()> = track(&conn, db_file.path(), "classify", || {
505            Err(anyhow::anyhow!("something broke"))
506        });
507        assert!(result.is_err());
508
509        let (status, summary): (String, Option<String>) = conn
510            .query_row(
511                "SELECT status, summary FROM pipeline_runs WHERE command = 'classify'",
512                [],
513                |r| Ok((r.get(0)?, r.get(1)?)),
514            )
515            .unwrap();
516        assert_eq!(status, "failed");
517        assert_eq!(summary.as_deref(), Some("something broke"));
518    }
519
520    #[test]
521    fn track_refuses_when_already_locked() {
522        isolated_home();
523        let conn = test_db();
524        let db_file = tempfile::NamedTempFile::new().unwrap();
525
526        let _held = acquire_lock(db_file.path(), "scan").unwrap();
527        let result: Result<()> = track(&conn, db_file.path(), "scan", || Ok(()));
528        assert!(result.is_err(), "track must refuse to run while the lock is already held");
529
530        let count: i64 = conn
531            .query_row("SELECT COUNT(*) FROM pipeline_runs WHERE command = 'scan'", [], |r| r.get(0))
532            .unwrap();
533        assert_eq!(count, 0);
534    }
535
536    #[test]
537    fn read_all_reports_none_for_a_never_run_command() {
538        isolated_home();
539        let conn = test_db();
540        let db_file = tempfile::NamedTempFile::new().unwrap();
541
542        let statuses = read_all(&conn, db_file.path()).unwrap();
543        let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
544        assert_eq!(embed.last_run_at, None);
545        assert_eq!(embed.status, None);
546        assert!(!embed.currently_running);
547    }
548
549    #[test]
550    fn read_all_reports_success_after_a_completed_run() {
551        isolated_home();
552        let conn = test_db();
553        let db_file = tempfile::NamedTempFile::new().unwrap();
554
555        track(&conn, db_file.path(), "embed", || Ok(())).unwrap();
556
557        let statuses = read_all(&conn, db_file.path()).unwrap();
558        let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
559        assert_eq!(embed.status.as_deref(), Some("success"));
560        assert!(embed.last_run_at.is_some());
561        assert!(!embed.currently_running);
562    }
563
564    #[test]
565    fn read_all_reports_currently_running_while_locked() {
566        isolated_home();
567        let conn = test_db();
568        let db_file = tempfile::NamedTempFile::new().unwrap();
569
570        start_run(&conn, "faces").unwrap();
571        let _held = acquire_lock(db_file.path(), "faces").unwrap();
572
573        let statuses = read_all(&conn, db_file.path()).unwrap();
574        let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
575        assert_eq!(faces.status.as_deref(), Some("running"));
576        assert!(faces.currently_running);
577    }
578
579    #[test]
580    fn read_all_reports_crashed_when_running_but_not_locked() {
581        isolated_home();
582        let conn = test_db();
583        let db_file = tempfile::NamedTempFile::new().unwrap();
584
585        start_run(&conn, "faces").unwrap();
586
587        let statuses = read_all(&conn, db_file.path()).unwrap();
588        let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
589        assert_eq!(faces.status.as_deref(), Some("crashed"));
590        assert!(!faces.currently_running);
591
592        let stored_status: String = conn
593            .query_row("SELECT status FROM pipeline_runs WHERE command = 'faces'", [], |r| r.get(0))
594            .unwrap();
595        assert_eq!(stored_status, "running", "read_all must not write back the crashed label");
596    }
597
598    #[test]
599    fn install_sigint_handler_does_not_error_when_called_once() {
600        let db_file = tempfile::NamedTempFile::new().unwrap();
601        // Only one handler can be installed per process for the life of the
602        // test binary; this just confirms the call itself succeeds.
603        // (ctrlc::set_handler errors if called twice in the same process,
604        // so this is deliberately the only test that calls it in this suite.)
605        let result = install_sigint_handler(db_file.path(), "scan");
606        assert!(result.is_ok());
607    }
608}