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
89fn lock_path_for(db_path: &Path, command: &str) -> Result<PathBuf> {
90    let canonical = db_path
91        .canonicalize()
92        .with_context(|| format!("canonicalize {}", db_path.display()))?;
93    Ok(PathBuf::from(format!("{}.{command}.lock", canonical.display())))
94}
95
96/// Acquires an exclusive, non-blocking advisory lock scoped to this exact
97/// database file and command. Fails immediately (refusing the run, per the
98/// concurrency decision in the design doc) if another live process already
99/// holds it - never blocks waiting for it to free up.
100pub fn acquire_lock(db_path: &Path, command: &str) -> Result<LockGuard> {
101    use fs2::FileExt;
102    let lock_path = lock_path_for(db_path, command)?;
103    let file = OpenOptions::new()
104        .create(true)
105        .write(true)
106        .open(&lock_path)
107        .with_context(|| format!("open lock file {}", lock_path.display()))?;
108    file.try_lock_exclusive()
109        .map_err(|_| anyhow::anyhow!("{command} is already running against {}", db_path.display()))?;
110    Ok(LockGuard(file))
111}
112
113/// True if another live process currently holds `command`'s lock for
114/// `db_path`. Never blocks: probes with a non-blocking try-lock and releases
115/// immediately if it succeeds, so this is safe to call from a read path.
116pub fn is_locked(db_path: &Path, command: &str) -> Result<bool> {
117    use fs2::FileExt;
118    let lock_path = lock_path_for(db_path, command)?;
119    if !lock_path.exists() {
120        return Ok(false);
121    }
122    let file = OpenOptions::new()
123        .write(true)
124        .open(&lock_path)
125        .with_context(|| format!("open lock file {}", lock_path.display()))?;
126    match file.try_lock_exclusive() {
127        Ok(()) => {
128            FileExt::unlock(&file).ok();
129            Ok(false)
130        }
131        Err(_) => Ok(true),
132    }
133}
134
135/// Wraps `f` with pipeline-run bookkeeping: refuses to start if `command` is
136/// already running against `db_path`, records a `running` row before calling
137/// `f`, then records `success`/`failed` (with `f`'s error message, if any)
138/// once `f` returns - all before this function itself returns. Every
139/// `std::process::exit` call site this design touches happens strictly after
140/// its wrapped operation already returned a `Result` (see the design doc's
141/// "key design insight"), so this finalization is never skipped by an exit
142/// call - only an actual crash mid-`f()` skips it, which is exactly what the
143/// lock-based `crashed` detection in `read_all` is for.
144pub fn track<T>(
145    conn: &Connection,
146    db_path: &Path,
147    command: &str,
148    f: impl FnOnce() -> Result<T>,
149) -> Result<T> {
150    ensure_pipeline_runs_table(conn)?;
151    let _lock = acquire_lock(db_path, command)?;
152    start_run(conn, command)?;
153    let started = std::time::Instant::now();
154    let result = f();
155    let duration_ms = started.elapsed().as_millis() as i64;
156    match &result {
157        Ok(_) => finish_run(conn, command, "success", duration_ms, None)?,
158        Err(e) => finish_run(conn, command, "failed", duration_ms, Some(&e.to_string()))?,
159    }
160    result
161}
162
163/// Installs a SIGINT handler that marks `command`'s row `interrupted` (using
164/// its already-recorded `started_at` to compute duration) and exits 130, the
165/// standard SIGINT exit code. Call this once, after `track()`'s `start_run`
166/// has already written the `running` row for `command` - the handler opens
167/// its own fresh connection since the main thread's `Connection` isn't
168/// safely shareable across the handler boundary. Best-effort: any error
169/// inside the handler is swallowed (there's no useful way to report it once
170/// the process is already exiting on a signal).
171pub fn install_sigint_handler(db_path: &Path, command: &'static str) -> Result<()> {
172    let db_path = db_path.to_path_buf();
173    ctrlc::set_handler(move || {
174        if let Ok(conn) = Connection::open(&db_path) {
175            let started_at: Option<String> = conn
176                .query_row(
177                    "SELECT started_at FROM pipeline_runs WHERE command = ?1",
178                    params![command],
179                    |r| r.get(0),
180                )
181                .optional()
182                .ok()
183                .flatten();
184            let duration_ms = started_at
185                .and_then(|s| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok())
186                .map(|started| {
187                    (chrono::Utc::now().naive_utc() - started).num_milliseconds().max(0)
188                })
189                .unwrap_or(0);
190            let _ = finish_run(&conn, command, "interrupted", duration_ms, None);
191        }
192        std::process::exit(130);
193    })
194    .context("installing SIGINT handler")
195}
196
197#[derive(Debug, Clone, PartialEq, Serialize)]
198pub struct PipelineRunStatus {
199    pub command: String,
200    pub last_run_at: Option<String>,
201    /// "running" | "success" | "failed" | "interrupted" | "crashed" | None if never run.
202    /// "crashed" is computed here, never a stored value - see the design doc.
203    pub status: Option<String>,
204    pub duration_ms: Option<i64>,
205    pub currently_running: bool,
206}
207
208pub fn read_all(conn: &Connection, db_path: &Path) -> Result<Vec<PipelineRunStatus>> {
209    ensure_pipeline_runs_table(conn)?;
210    let mut out = Vec::with_capacity(TRACKED_COMMANDS.len());
211    for command in TRACKED_COMMANDS {
212        let row: Option<(String, Option<i64>, String)> = conn
213            .query_row(
214                "SELECT started_at, duration_ms, status FROM pipeline_runs WHERE command = ?1",
215                params![command],
216                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
217            )
218            .optional()?;
219
220        let currently_running = is_locked(db_path, command)?;
221
222        let (last_run_at, status, duration_ms) = match row {
223            None => (None, None, None),
224            Some((started_at, duration_ms, stored_status)) => {
225                let status = if stored_status == "running" && !currently_running {
226                    "crashed".to_string()
227                } else {
228                    stored_status
229                };
230                (Some(started_at), Some(status), duration_ms)
231            }
232        };
233
234        out.push(PipelineRunStatus {
235            command: command.to_string(),
236            last_run_at,
237            status,
238            duration_ms,
239            currently_running,
240        });
241    }
242    Ok(out)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn test_db() -> Connection {
250        let conn = Connection::open_in_memory().unwrap();
251        ensure_pipeline_runs_table(&conn).unwrap();
252        conn
253    }
254
255    #[test]
256    fn ensure_pipeline_runs_table_is_idempotent() {
257        let conn = test_db();
258        ensure_pipeline_runs_table(&conn).unwrap();
259    }
260
261    #[test]
262    fn start_run_then_finish_run_records_success() {
263        let conn = test_db();
264        start_run(&conn, "embed").unwrap();
265
266        let status: String = conn
267            .query_row("SELECT status FROM pipeline_runs WHERE command = 'embed'", [], |r| r.get(0))
268            .unwrap();
269        assert_eq!(status, "running");
270
271        finish_run(&conn, "embed", "success", 1234, None).unwrap();
272
273        let (status, duration_ms, summary): (String, i64, Option<String>) = conn
274            .query_row(
275                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
276                [],
277                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
278            )
279            .unwrap();
280        assert_eq!(status, "success");
281        assert_eq!(duration_ms, 1234);
282        assert_eq!(summary, None);
283    }
284
285    #[test]
286    fn start_run_upserts_resetting_prior_finish_fields() {
287        let conn = test_db();
288        start_run(&conn, "embed").unwrap();
289        finish_run(&conn, "embed", "failed", 500, Some("boom")).unwrap();
290
291        start_run(&conn, "embed").unwrap(); // second run begins
292
293        let (status, duration_ms, summary): (String, Option<i64>, Option<String>) = conn
294            .query_row(
295                "SELECT status, duration_ms, summary FROM pipeline_runs WHERE command = 'embed'",
296                [],
297                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
298            )
299            .unwrap();
300        assert_eq!(status, "running");
301        assert_eq!(duration_ms, None);
302        assert_eq!(summary, None);
303
304        let count: i64 = conn.query_row("SELECT COUNT(*) FROM pipeline_runs", [], |r| r.get(0)).unwrap();
305        assert_eq!(count, 1, "upsert, not a second row");
306    }
307
308    #[test]
309    fn acquire_lock_refuses_a_second_concurrent_acquisition() {
310        let db_file = tempfile::NamedTempFile::new().unwrap();
311        let db_path = db_file.path();
312
313        let _first = acquire_lock(db_path, "faces").unwrap();
314        let second = acquire_lock(db_path, "faces");
315        assert!(second.is_err(), "a second concurrent lock on the same command must be refused");
316    }
317
318    #[test]
319    fn acquire_lock_allows_different_commands_concurrently() {
320        let db_file = tempfile::NamedTempFile::new().unwrap();
321        let db_path = db_file.path();
322
323        let _faces_lock = acquire_lock(db_path, "faces").unwrap();
324        let embed_lock = acquire_lock(db_path, "embed");
325        assert!(embed_lock.is_ok(), "different commands must not contend for the same lock");
326    }
327
328    #[test]
329    fn acquire_lock_is_available_again_after_release() {
330        let db_file = tempfile::NamedTempFile::new().unwrap();
331        let db_path = db_file.path();
332
333        {
334            let _lock = acquire_lock(db_path, "scan").unwrap();
335        } // dropped here, releasing the flock
336
337        let second = acquire_lock(db_path, "scan");
338        assert!(second.is_ok(), "lock must be available again once the guard is dropped");
339    }
340
341    #[test]
342    fn track_records_success_and_returns_the_value() {
343        let conn = test_db();
344        let db_file = tempfile::NamedTempFile::new().unwrap();
345
346        let result = track(&conn, db_file.path(), "embed", || Ok(42)).unwrap();
347        assert_eq!(result, 42);
348
349        let status: String = conn
350            .query_row("SELECT status FROM pipeline_runs WHERE command = 'embed'", [], |r| r.get(0))
351            .unwrap();
352        assert_eq!(status, "success");
353    }
354
355    #[test]
356    fn track_records_failure_with_the_error_message() {
357        let conn = test_db();
358        let db_file = tempfile::NamedTempFile::new().unwrap();
359
360        let result: Result<()> = track(&conn, db_file.path(), "classify", || {
361            Err(anyhow::anyhow!("something broke"))
362        });
363        assert!(result.is_err());
364
365        let (status, summary): (String, Option<String>) = conn
366            .query_row(
367                "SELECT status, summary FROM pipeline_runs WHERE command = 'classify'",
368                [],
369                |r| Ok((r.get(0)?, r.get(1)?)),
370            )
371            .unwrap();
372        assert_eq!(status, "failed");
373        assert_eq!(summary.as_deref(), Some("something broke"));
374    }
375
376    #[test]
377    fn track_refuses_when_already_locked() {
378        let conn = test_db();
379        let db_file = tempfile::NamedTempFile::new().unwrap();
380
381        let _held = acquire_lock(db_file.path(), "scan").unwrap();
382        let result: Result<()> = track(&conn, db_file.path(), "scan", || Ok(()));
383        assert!(result.is_err(), "track must refuse to run while the lock is already held");
384
385        let count: i64 = conn
386            .query_row("SELECT COUNT(*) FROM pipeline_runs WHERE command = 'scan'", [], |r| r.get(0))
387            .unwrap();
388        assert_eq!(count, 0);
389    }
390
391    #[test]
392    fn read_all_reports_none_for_a_never_run_command() {
393        let conn = test_db();
394        let db_file = tempfile::NamedTempFile::new().unwrap();
395
396        let statuses = read_all(&conn, db_file.path()).unwrap();
397        let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
398        assert_eq!(embed.last_run_at, None);
399        assert_eq!(embed.status, None);
400        assert!(!embed.currently_running);
401    }
402
403    #[test]
404    fn read_all_reports_success_after_a_completed_run() {
405        let conn = test_db();
406        let db_file = tempfile::NamedTempFile::new().unwrap();
407
408        track(&conn, db_file.path(), "embed", || Ok(())).unwrap();
409
410        let statuses = read_all(&conn, db_file.path()).unwrap();
411        let embed = statuses.iter().find(|s| s.command == "embed").unwrap();
412        assert_eq!(embed.status.as_deref(), Some("success"));
413        assert!(embed.last_run_at.is_some());
414        assert!(!embed.currently_running);
415    }
416
417    #[test]
418    fn read_all_reports_currently_running_while_locked() {
419        let conn = test_db();
420        let db_file = tempfile::NamedTempFile::new().unwrap();
421
422        start_run(&conn, "faces").unwrap();
423        let _held = acquire_lock(db_file.path(), "faces").unwrap();
424
425        let statuses = read_all(&conn, db_file.path()).unwrap();
426        let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
427        assert_eq!(faces.status.as_deref(), Some("running"));
428        assert!(faces.currently_running);
429    }
430
431    #[test]
432    fn read_all_reports_crashed_when_running_but_not_locked() {
433        let conn = test_db();
434        let db_file = tempfile::NamedTempFile::new().unwrap();
435
436        start_run(&conn, "faces").unwrap();
437
438        let statuses = read_all(&conn, db_file.path()).unwrap();
439        let faces = statuses.iter().find(|s| s.command == "faces").unwrap();
440        assert_eq!(faces.status.as_deref(), Some("crashed"));
441        assert!(!faces.currently_running);
442
443        let stored_status: String = conn
444            .query_row("SELECT status FROM pipeline_runs WHERE command = 'faces'", [], |r| r.get(0))
445            .unwrap();
446        assert_eq!(stored_status, "running", "read_all must not write back the crashed label");
447    }
448
449    #[test]
450    fn install_sigint_handler_does_not_error_when_called_once() {
451        let db_file = tempfile::NamedTempFile::new().unwrap();
452        // Only one handler can be installed per process for the life of the
453        // test binary; this just confirms the call itself succeeds.
454        // (ctrlc::set_handler errors if called twice in the same process,
455        // so this is deliberately the only test that calls it in this suite.)
456        let result = install_sigint_handler(db_file.path(), "scan");
457        assert!(result.is_ok());
458    }
459}