Skip to main content

oximedia_workflow/
checkpoint.rs

1//! File-based workflow checkpoint/resume support for `oximedia-workflow`.
2//!
3//! [`CheckpointManager`] persists workflow execution state to disk so that a
4//! failed or interrupted workflow can be resumed from the last saved checkpoint
5//! rather than re-run from the beginning.
6//!
7//! # Storage layout
8//!
9//! Each checkpoint is written as a JSON file:
10//! ```text
11//! <storage_dir>/<workflow_id>.checkpoint.json
12//! ```
13//!
14//! # Example
15//!
16//! ```rust,no_run
17//! use oximedia_workflow::checkpoint::{CheckpointManager, WorkflowCheckpoint};
18//! use std::collections::HashMap;
19//!
20//! let mgr = CheckpointManager::new(std::env::temp_dir());
21//!
22//! let cp = WorkflowCheckpoint {
23//!     workflow_id: "wf-001".to_string(),
24//!     completed_steps: vec!["ingest".to_string()],
25//!     step_outputs: HashMap::new(),
26//!     created_at: 0,
27//!     workflow_version: 1,
28//! };
29//!
30//! mgr.save(&cp).expect("save checkpoint");
31//! let loaded = mgr.load("wf-001").expect("load checkpoint");
32//! assert_eq!(loaded.completed_steps, vec!["ingest"]);
33//! ```
34
35use crate::error::{Result, WorkflowError};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::path::PathBuf;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41// ---------------------------------------------------------------------------
42// WorkflowCheckpoint
43// ---------------------------------------------------------------------------
44
45/// A snapshot of workflow execution state persisted to disk.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct WorkflowCheckpoint {
48    /// Unique identifier of the workflow this checkpoint belongs to.
49    pub workflow_id: String,
50    /// Names of steps that had completed when this checkpoint was taken.
51    pub completed_steps: Vec<String>,
52    /// Arbitrary JSON outputs produced by each completed step, keyed by step name.
53    pub step_outputs: HashMap<String, serde_json::Value>,
54    /// Unix timestamp (seconds) when the checkpoint was created.
55    pub created_at: u64,
56    /// Schema / content version of the workflow definition at checkpoint time.
57    pub workflow_version: u32,
58}
59
60impl WorkflowCheckpoint {
61    /// Creates a new checkpoint with the current wall-clock timestamp.
62    #[must_use]
63    pub fn new(workflow_id: impl Into<String>, workflow_version: u32) -> Self {
64        let created_at = SystemTime::now()
65            .duration_since(UNIX_EPOCH)
66            .map(|d| d.as_secs())
67            .unwrap_or(0);
68        Self {
69            workflow_id: workflow_id.into(),
70            completed_steps: Vec::new(),
71            step_outputs: HashMap::new(),
72            created_at,
73            workflow_version,
74        }
75    }
76
77    /// Marks a step as completed, optionally recording its output.
78    pub fn mark_step_completed(
79        &mut self,
80        step_name: impl Into<String>,
81        output: Option<serde_json::Value>,
82    ) {
83        let name = step_name.into();
84        if !self.completed_steps.contains(&name) {
85            self.completed_steps.push(name.clone());
86        }
87        if let Some(v) = output {
88            self.step_outputs.insert(name, v);
89        }
90    }
91
92    /// Returns `true` if the given step was already completed.
93    #[must_use]
94    pub fn is_step_completed(&self, step_name: &str) -> bool {
95        self.completed_steps.iter().any(|s| s == step_name)
96    }
97
98    /// Returns the output for a completed step, if any was recorded.
99    #[must_use]
100    pub fn step_output(&self, step_name: &str) -> Option<&serde_json::Value> {
101        self.step_outputs.get(step_name)
102    }
103
104    /// Returns the number of completed steps.
105    #[must_use]
106    pub fn completed_count(&self) -> usize {
107        self.completed_steps.len()
108    }
109}
110
111// ---------------------------------------------------------------------------
112// CheckpointManager
113// ---------------------------------------------------------------------------
114
115/// Manages file-based checkpoints for one or more workflows.
116///
117/// Each checkpoint is persisted as a JSON file named
118/// `<storage_dir>/<workflow_id>.checkpoint.json`.
119#[derive(Debug, Clone)]
120pub struct CheckpointManager {
121    storage_dir: PathBuf,
122}
123
124impl CheckpointManager {
125    /// Creates a new manager that stores checkpoints under `storage_dir`.
126    ///
127    /// The directory is created on first use (lazily), so it does not need to
128    /// exist at construction time.
129    #[must_use]
130    pub fn new(storage_dir: PathBuf) -> Self {
131        Self { storage_dir }
132    }
133
134    /// Returns the storage directory path.
135    #[must_use]
136    pub fn storage_dir(&self) -> &PathBuf {
137        &self.storage_dir
138    }
139
140    // ------------------------------------------------------------------
141    // Internal helpers
142    // ------------------------------------------------------------------
143
144    /// Returns the path for a given workflow's checkpoint file.
145    fn checkpoint_path(&self, workflow_id: &str) -> PathBuf {
146        self.storage_dir
147            .join(format!("{}.checkpoint.json", workflow_id))
148    }
149
150    /// Ensures the storage directory exists.
151    fn ensure_dir(&self) -> Result<()> {
152        std::fs::create_dir_all(&self.storage_dir).map_err(WorkflowError::Io)
153    }
154
155    // ------------------------------------------------------------------
156    // Public API
157    // ------------------------------------------------------------------
158
159    /// Serialises `checkpoint` to JSON and writes it to disk.
160    ///
161    /// Overwrites any previously saved checkpoint for the same `workflow_id`.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`WorkflowError::Io`] if the file cannot be written, or
166    /// [`WorkflowError::Serialization`] if JSON serialisation fails.
167    pub fn save(&self, checkpoint: &WorkflowCheckpoint) -> Result<()> {
168        self.ensure_dir()?;
169        let path = self.checkpoint_path(&checkpoint.workflow_id);
170        let json = serde_json::to_vec_pretty(checkpoint)?;
171        std::fs::write(&path, &json).map_err(WorkflowError::Io)
172    }
173
174    /// Reads and deserialises the checkpoint for `workflow_id` from disk.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`WorkflowError::FileNotFound`] if no checkpoint exists, or
179    /// [`WorkflowError::Io`] / [`WorkflowError::Serialization`] on read/parse
180    /// failure.
181    pub fn load(&self, workflow_id: &str) -> Result<WorkflowCheckpoint> {
182        let path = self.checkpoint_path(workflow_id);
183        if !path.exists() {
184            return Err(WorkflowError::FileNotFound(path));
185        }
186        let bytes = std::fs::read(&path).map_err(WorkflowError::Io)?;
187        let cp: WorkflowCheckpoint = serde_json::from_slice(&bytes)?;
188        Ok(cp)
189    }
190
191    /// Deletes the checkpoint file for `workflow_id`.
192    ///
193    /// Returns `Ok(())` even if no checkpoint file existed.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`WorkflowError::Io`] if the file exists but cannot be removed.
198    pub fn delete(&self, workflow_id: &str) -> Result<()> {
199        let path = self.checkpoint_path(workflow_id);
200        if path.exists() {
201            std::fs::remove_file(&path).map_err(WorkflowError::Io)?;
202        }
203        Ok(())
204    }
205
206    /// Returns `true` if a checkpoint file exists for `workflow_id`.
207    #[must_use]
208    pub fn exists(&self, workflow_id: &str) -> bool {
209        self.checkpoint_path(workflow_id).exists()
210    }
211
212    /// Returns the workflow IDs of all checkpoints currently on disk.
213    ///
214    /// Scans `storage_dir` for files matching `*.checkpoint.json` and strips
215    /// the suffix to produce the workflow ID.  Files that do not parse as
216    /// valid checkpoints are silently skipped.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`WorkflowError::Io`] if the storage directory cannot be read.
221    pub fn list_checkpoints(&self) -> Result<Vec<String>> {
222        if !self.storage_dir.exists() {
223            return Ok(Vec::new());
224        }
225
226        let mut ids = Vec::new();
227        let entries = std::fs::read_dir(&self.storage_dir).map_err(WorkflowError::Io)?;
228
229        for entry in entries {
230            let entry = entry.map_err(WorkflowError::Io)?;
231            let file_name = entry.file_name();
232            let name = file_name.to_string_lossy();
233            if let Some(id) = name.strip_suffix(".checkpoint.json") {
234                // Verify the file is a valid checkpoint before advertising it.
235                if self.exists(id) {
236                    ids.push(id.to_string());
237                }
238            }
239        }
240
241        ids.sort();
242        Ok(ids)
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Free functions
248// ---------------------------------------------------------------------------
249
250/// Returns `true` when the given step should be **skipped** because it was
251/// already recorded as completed in `checkpoint`.
252///
253/// Pass `None` when no checkpoint is available (first run), and the function
254/// always returns `false`.
255///
256/// # Examples
257///
258/// ```rust
259/// use oximedia_workflow::checkpoint::{WorkflowCheckpoint, should_skip_step};
260///
261/// let mut cp = WorkflowCheckpoint::new("wf-001", 1);
262/// cp.mark_step_completed("ingest", None);
263///
264/// assert!(should_skip_step(&Some(cp), "ingest"));
265/// assert!(!should_skip_step(&None, "ingest"));
266/// ```
267#[must_use]
268pub fn should_skip_step(checkpoint: &Option<WorkflowCheckpoint>, step_name: &str) -> bool {
269    checkpoint
270        .as_ref()
271        .map(|cp| cp.is_step_completed(step_name))
272        .unwrap_or(false)
273}
274
275// ===========================================================================
276// Tests
277// ===========================================================================
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    // Helper: unique temp dir per test to avoid collisions.
284    fn temp_dir(suffix: &str) -> PathBuf {
285        let base = std::env::temp_dir().join(format!("oximedia_cp_test_{}", suffix));
286        // Remove any leftovers from a previous run (ignore errors).
287        let _ = std::fs::remove_dir_all(&base);
288        base
289    }
290
291    // ------------------------------------------------------------------
292    // WorkflowCheckpoint unit tests
293    // ------------------------------------------------------------------
294
295    #[test]
296    fn test_checkpoint_new_fields() {
297        let cp = WorkflowCheckpoint::new("wf-999", 2);
298        assert_eq!(cp.workflow_id, "wf-999");
299        assert_eq!(cp.workflow_version, 2);
300        assert!(cp.completed_steps.is_empty());
301        assert!(cp.step_outputs.is_empty());
302        // created_at should be non-zero (we are running after 1970).
303        assert!(cp.created_at > 0);
304    }
305
306    #[test]
307    fn test_mark_step_completed_no_output() {
308        let mut cp = WorkflowCheckpoint::new("wf-001", 1);
309        cp.mark_step_completed("ingest", None);
310        assert!(cp.is_step_completed("ingest"));
311        assert!(cp.step_output("ingest").is_none());
312        assert_eq!(cp.completed_count(), 1);
313    }
314
315    #[test]
316    fn test_mark_step_completed_with_output() {
317        let mut cp = WorkflowCheckpoint::new("wf-001", 1);
318        let val = serde_json::json!({"frames": 1000});
319        cp.mark_step_completed("analyse", Some(val.clone()));
320        assert!(cp.is_step_completed("analyse"));
321        assert_eq!(cp.step_output("analyse"), Some(&val));
322    }
323
324    #[test]
325    fn test_mark_step_completed_dedup() {
326        let mut cp = WorkflowCheckpoint::new("wf-001", 1);
327        cp.mark_step_completed("transcode", None);
328        cp.mark_step_completed("transcode", None);
329        assert_eq!(cp.completed_count(), 1);
330    }
331
332    #[test]
333    fn test_is_step_completed_false() {
334        let cp = WorkflowCheckpoint::new("wf-001", 1);
335        assert!(!cp.is_step_completed("missing_step"));
336    }
337
338    // ------------------------------------------------------------------
339    // CheckpointManager: save / load round-trip
340    // ------------------------------------------------------------------
341
342    #[test]
343    fn test_save_and_load() {
344        let dir = temp_dir("save_load");
345        let mgr = CheckpointManager::new(dir.clone());
346
347        let mut cp = WorkflowCheckpoint::new("wf-save", 1);
348        cp.mark_step_completed("step-a", Some(serde_json::json!({"ok": true})));
349        mgr.save(&cp).expect("save should succeed");
350
351        let loaded = mgr.load("wf-save").expect("load should succeed");
352        assert_eq!(loaded.workflow_id, "wf-save");
353        assert_eq!(loaded.completed_steps, vec!["step-a"]);
354        assert_eq!(
355            loaded.step_output("step-a"),
356            Some(&serde_json::json!({"ok": true}))
357        );
358    }
359
360    #[test]
361    fn test_load_nonexistent_returns_error() {
362        let dir = temp_dir("load_missing");
363        let mgr = CheckpointManager::new(dir);
364        let result = mgr.load("no-such-workflow");
365        assert!(
366            result.is_err(),
367            "loading a missing checkpoint must return Err"
368        );
369    }
370
371    #[test]
372    fn test_save_overwrites_previous() {
373        let dir = temp_dir("overwrite");
374        let mgr = CheckpointManager::new(dir);
375
376        let mut cp1 = WorkflowCheckpoint::new("wf-ow", 1);
377        cp1.mark_step_completed("step-a", None);
378        mgr.save(&cp1).expect("first save");
379
380        let mut cp2 = WorkflowCheckpoint::new("wf-ow", 1);
381        cp2.mark_step_completed("step-a", None);
382        cp2.mark_step_completed("step-b", None);
383        mgr.save(&cp2).expect("second save");
384
385        let loaded = mgr.load("wf-ow").expect("load after overwrite");
386        assert_eq!(loaded.completed_count(), 2);
387    }
388
389    // ------------------------------------------------------------------
390    // CheckpointManager: exists
391    // ------------------------------------------------------------------
392
393    #[test]
394    fn test_exists_true_after_save() {
395        let dir = temp_dir("exists_true");
396        let mgr = CheckpointManager::new(dir);
397        let cp = WorkflowCheckpoint::new("wf-ex", 1);
398        mgr.save(&cp).expect("save");
399        assert!(mgr.exists("wf-ex"));
400    }
401
402    #[test]
403    fn test_exists_false_before_save() {
404        let dir = temp_dir("exists_false");
405        let mgr = CheckpointManager::new(dir);
406        assert!(!mgr.exists("wf-not-saved"));
407    }
408
409    #[test]
410    fn test_exists_false_after_delete() {
411        let dir = temp_dir("exists_after_delete");
412        let mgr = CheckpointManager::new(dir);
413        let cp = WorkflowCheckpoint::new("wf-del-ex", 1);
414        mgr.save(&cp).expect("save");
415        mgr.delete("wf-del-ex").expect("delete");
416        assert!(!mgr.exists("wf-del-ex"));
417    }
418
419    // ------------------------------------------------------------------
420    // CheckpointManager: delete
421    // ------------------------------------------------------------------
422
423    #[test]
424    fn test_delete_existing() {
425        let dir = temp_dir("delete_existing");
426        let mgr = CheckpointManager::new(dir);
427        let cp = WorkflowCheckpoint::new("wf-del", 1);
428        mgr.save(&cp).expect("save");
429        assert!(mgr.exists("wf-del"));
430        mgr.delete("wf-del").expect("delete should succeed");
431        assert!(!mgr.exists("wf-del"));
432    }
433
434    #[test]
435    fn test_delete_nonexistent_ok() {
436        let dir = temp_dir("delete_nonexistent");
437        let mgr = CheckpointManager::new(dir);
438        // Should not error even though nothing was saved.
439        assert!(mgr.delete("ghost-workflow").is_ok());
440    }
441
442    // ------------------------------------------------------------------
443    // CheckpointManager: list_checkpoints
444    // ------------------------------------------------------------------
445
446    #[test]
447    fn test_list_empty_when_no_dir() {
448        let dir = temp_dir("list_nodir");
449        // Do NOT create the directory.
450        let mgr = CheckpointManager::new(dir);
451        let ids = mgr.list_checkpoints().expect("list should succeed");
452        assert!(ids.is_empty());
453    }
454
455    #[test]
456    fn test_list_checkpoints_after_multiple_saves() {
457        let dir = temp_dir("list_multiple");
458        let mgr = CheckpointManager::new(dir);
459
460        for id in &["wf-alpha", "wf-beta", "wf-gamma"] {
461            let cp = WorkflowCheckpoint::new(*id, 1);
462            mgr.save(&cp).expect("save");
463        }
464
465        let mut ids = mgr.list_checkpoints().expect("list");
466        ids.sort();
467        assert_eq!(ids, vec!["wf-alpha", "wf-beta", "wf-gamma"]);
468    }
469
470    #[test]
471    fn test_list_excludes_deleted() {
472        let dir = temp_dir("list_after_delete");
473        let mgr = CheckpointManager::new(dir);
474
475        mgr.save(&WorkflowCheckpoint::new("wf-keep", 1))
476            .expect("save keep");
477        mgr.save(&WorkflowCheckpoint::new("wf-gone", 1))
478            .expect("save gone");
479        mgr.delete("wf-gone").expect("delete gone");
480
481        let ids = mgr.list_checkpoints().expect("list");
482        assert!(ids.contains(&"wf-keep".to_string()));
483        assert!(!ids.contains(&"wf-gone".to_string()));
484    }
485
486    // ------------------------------------------------------------------
487    // should_skip_step
488    // ------------------------------------------------------------------
489
490    #[test]
491    fn test_should_skip_step_with_completed_step() {
492        let mut cp = WorkflowCheckpoint::new("wf-skip", 1);
493        cp.mark_step_completed("encode", None);
494        assert!(should_skip_step(&Some(cp), "encode"));
495    }
496
497    #[test]
498    fn test_should_skip_step_with_missing_step() {
499        let cp = WorkflowCheckpoint::new("wf-skip", 1);
500        assert!(!should_skip_step(&Some(cp), "not-yet-done"));
501    }
502
503    #[test]
504    fn test_should_skip_step_with_no_checkpoint() {
505        assert!(!should_skip_step(&None, "any-step"));
506    }
507
508    #[test]
509    fn test_should_skip_step_multiple_steps() {
510        let mut cp = WorkflowCheckpoint::new("wf-multi", 1);
511        cp.mark_step_completed("ingest", None);
512        cp.mark_step_completed("transcode", None);
513        assert!(should_skip_step(&Some(cp.clone()), "ingest"));
514        assert!(should_skip_step(&Some(cp.clone()), "transcode"));
515        assert!(!should_skip_step(&Some(cp), "upload"));
516    }
517}