1use anyhow::{Context, Result};
10use rusqlite::{params, Connection, OptionalExtension};
11use serde::Serialize;
12use std::fs::{File, OpenOptions};
13use std::path::{Path, PathBuf};
14
15pub 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
82pub 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
96pub 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
113pub 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
135pub 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
163pub 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 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(); 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 } 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 let result = install_sigint_handler(db_file.path(), "scan");
457 assert!(result.is_ok());
458 }
459}