Skip to main content

oximedia_distributed/
checkpointing.rs

1//! Distributed checkpoint management.
2//!
3//! Provides checkpoint creation, storage, retrieval, and recovery planning
4//! for fault-tolerant distributed encoding jobs.
5
6#![allow(dead_code)]
7
8use crate::consensus::NodeId;
9use std::collections::HashMap;
10
11/// Unique identifier for a checkpoint.
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct CheckpointId {
14    /// The node that created this checkpoint.
15    pub node_id: NodeId,
16    /// Monotonically increasing sequence number within the node.
17    pub sequence: u64,
18}
19
20impl CheckpointId {
21    /// Create a new checkpoint ID.
22    #[must_use]
23    pub fn new(node_id: NodeId, sequence: u64) -> Self {
24        Self { node_id, sequence }
25    }
26
27    /// Format as a string key: "node-{id}-seq-{seq}".
28    #[must_use]
29    pub fn to_string(&self) -> String {
30        format!("node-{}-seq-{}", self.node_id.inner(), self.sequence)
31    }
32}
33
34impl std::fmt::Display for CheckpointId {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{}", self.to_string())
37    }
38}
39
40/// A single checkpoint record.
41#[derive(Debug, Clone)]
42pub struct Checkpoint {
43    /// The checkpoint's unique identifier.
44    pub id: CheckpointId,
45    /// The job this checkpoint belongs to.
46    pub job_id: String,
47    /// Size of the serialized state in bytes.
48    pub state_size_bytes: u64,
49    /// Unix epoch milliseconds when this checkpoint was created.
50    pub created_at_ms: u64,
51    /// Arbitrary key-value metadata.
52    pub metadata: HashMap<String, String>,
53}
54
55impl Checkpoint {
56    /// Create a new checkpoint.
57    pub fn new(
58        id: CheckpointId,
59        job_id: impl Into<String>,
60        state_size_bytes: u64,
61        created_at_ms: u64,
62    ) -> Self {
63        Self {
64            id,
65            job_id: job_id.into(),
66            state_size_bytes,
67            created_at_ms,
68            metadata: HashMap::new(),
69        }
70    }
71
72    /// Create a checkpoint with metadata.
73    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
74        self.metadata.insert(key.into(), value.into());
75        self
76    }
77}
78
79/// In-memory store of checkpoints, keyed by `CheckpointId`.
80#[derive(Debug, Default)]
81pub struct CheckpointStore {
82    checkpoints: HashMap<String, Checkpoint>,
83}
84
85impl CheckpointStore {
86    /// Create a new empty checkpoint store.
87    #[must_use]
88    pub fn new() -> Self {
89        Self {
90            checkpoints: HashMap::new(),
91        }
92    }
93
94    /// Save a checkpoint.
95    pub fn save(&mut self, checkpoint: Checkpoint) {
96        let key = checkpoint.id.to_string();
97        self.checkpoints.insert(key, checkpoint);
98    }
99
100    /// Load a checkpoint by ID, returning a reference if found.
101    #[must_use]
102    pub fn load(&self, id: &CheckpointId) -> Option<&Checkpoint> {
103        self.checkpoints.get(&id.to_string())
104    }
105
106    /// Return the most recently created checkpoint for a job (by `created_at_ms`).
107    #[must_use]
108    pub fn latest_for_job(&self, job_id: &str) -> Option<&Checkpoint> {
109        self.checkpoints
110            .values()
111            .filter(|c| c.job_id == job_id)
112            .max_by_key(|c| c.created_at_ms)
113    }
114
115    /// Purge old checkpoints for each job, keeping only the `keep_last_n` most recent.
116    pub fn purge_old(&mut self, keep_last_n: usize) {
117        // Collect job IDs
118        let job_ids: Vec<String> = self
119            .checkpoints
120            .values()
121            .map(|c| c.job_id.clone())
122            .collect::<std::collections::HashSet<_>>()
123            .into_iter()
124            .collect();
125
126        for job_id in job_ids {
127            // Gather keys and timestamps for this job
128            let mut job_checkpoints: Vec<(String, u64)> = self
129                .checkpoints
130                .iter()
131                .filter(|(_, c)| c.job_id == job_id)
132                .map(|(k, c)| (k.clone(), c.created_at_ms))
133                .collect();
134
135            if job_checkpoints.len() <= keep_last_n {
136                continue;
137            }
138
139            // Sort by timestamp descending; keep first keep_last_n, remove rest
140            job_checkpoints.sort_by(|a, b| b.1.cmp(&a.1));
141            for (key, _) in job_checkpoints.into_iter().skip(keep_last_n) {
142                self.checkpoints.remove(&key);
143            }
144        }
145    }
146
147    /// Return the total number of stored checkpoints.
148    #[must_use]
149    pub fn len(&self) -> usize {
150        self.checkpoints.len()
151    }
152
153    /// Return true if the store is empty.
154    #[must_use]
155    pub fn is_empty(&self) -> bool {
156        self.checkpoints.is_empty()
157    }
158}
159
160/// Policy governing checkpoint behaviour.
161#[derive(Debug, Clone)]
162pub struct CheckpointPolicy {
163    /// How often to create checkpoints (seconds).
164    pub frequency_secs: u32,
165    /// Maximum number of checkpoints to retain per job.
166    pub keep_last_n: usize,
167    /// Whether to compress checkpoint state.
168    pub compress: bool,
169}
170
171impl Default for CheckpointPolicy {
172    fn default() -> Self {
173        Self {
174            frequency_secs: 300,
175            keep_last_n: 5,
176            compress: true,
177        }
178    }
179}
180
181/// Finds the best checkpoint to use when recovering a failed job.
182pub struct RecoveryPlanner;
183
184impl RecoveryPlanner {
185    /// Find the most suitable recovery point for a failed job.
186    ///
187    /// Returns the latest checkpoint for the given job, if any exists.
188    #[must_use]
189    pub fn find_recovery_point<'a>(
190        store: &'a CheckpointStore,
191        failed_job_id: &str,
192    ) -> Option<&'a Checkpoint> {
193        store.latest_for_job(failed_job_id)
194    }
195}
196
197/// Estimate of the cost to recover from a given checkpoint.
198#[derive(Debug, Clone)]
199pub struct RecoveryEstimate {
200    /// The checkpoint to recover from.
201    pub checkpoint: CheckpointId,
202    /// Number of frames that need to be re-processed.
203    pub reprocess_frames: u64,
204    /// Estimated time in seconds to complete recovery.
205    pub estimated_recovery_secs: f64,
206}
207
208impl RecoveryEstimate {
209    /// Create a recovery estimate.
210    #[must_use]
211    pub fn new(
212        checkpoint: CheckpointId,
213        reprocess_frames: u64,
214        estimated_recovery_secs: f64,
215    ) -> Self {
216        Self {
217            checkpoint,
218            reprocess_frames,
219            estimated_recovery_secs,
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn make_id(node: u64, seq: u64) -> CheckpointId {
229        CheckpointId::new(NodeId::new(node), seq)
230    }
231
232    fn make_checkpoint(node: u64, seq: u64, job: &str, ts: u64) -> Checkpoint {
233        Checkpoint::new(make_id(node, seq), job, 1024, ts)
234    }
235
236    #[test]
237    fn test_checkpoint_id_to_string() {
238        let id = make_id(3, 7);
239        assert_eq!(id.to_string(), "node-3-seq-7");
240    }
241
242    #[test]
243    fn test_checkpoint_id_display() {
244        let id = make_id(1, 2);
245        assert_eq!(format!("{id}"), "node-1-seq-2");
246    }
247
248    #[test]
249    fn test_store_save_and_load() {
250        let mut store = CheckpointStore::new();
251        let cp = make_checkpoint(1, 1, "job-a", 1000);
252        let id = cp.id.clone();
253        store.save(cp);
254
255        let loaded = store.load(&id);
256        assert!(loaded.is_some());
257        assert_eq!(loaded.expect("loading should succeed").job_id, "job-a");
258    }
259
260    #[test]
261    fn test_store_load_missing() {
262        let store = CheckpointStore::new();
263        assert!(store.load(&make_id(99, 99)).is_none());
264    }
265
266    #[test]
267    fn test_latest_for_job() {
268        let mut store = CheckpointStore::new();
269        store.save(make_checkpoint(1, 1, "job-a", 1000));
270        store.save(make_checkpoint(1, 2, "job-a", 3000));
271        store.save(make_checkpoint(1, 3, "job-a", 2000));
272
273        let latest = store.latest_for_job("job-a");
274        assert!(latest.is_some());
275        assert_eq!(latest.expect("latest should exist").id.sequence, 2); // ts=3000 is latest
276    }
277
278    #[test]
279    fn test_latest_for_job_missing() {
280        let store = CheckpointStore::new();
281        assert!(store.latest_for_job("no-such-job").is_none());
282    }
283
284    #[test]
285    fn test_purge_old_keeps_n() {
286        let mut store = CheckpointStore::new();
287        for i in 1..=5 {
288            store.save(make_checkpoint(1, i, "job-b", i as u64 * 1000));
289        }
290        assert_eq!(store.len(), 5);
291        store.purge_old(3);
292        assert_eq!(store.len(), 3);
293    }
294
295    #[test]
296    fn test_purge_old_no_op_when_few() {
297        let mut store = CheckpointStore::new();
298        store.save(make_checkpoint(1, 1, "job-c", 1000));
299        store.save(make_checkpoint(1, 2, "job-c", 2000));
300        store.purge_old(5);
301        assert_eq!(store.len(), 2);
302    }
303
304    #[test]
305    fn test_recovery_planner_finds_latest() {
306        let mut store = CheckpointStore::new();
307        store.save(make_checkpoint(1, 1, "job-fail", 5000));
308        store.save(make_checkpoint(1, 2, "job-fail", 9000));
309
310        let cp = RecoveryPlanner::find_recovery_point(&store, "job-fail");
311        assert!(cp.is_some());
312        assert_eq!(cp.expect("checkpoint should exist").created_at_ms, 9000);
313    }
314
315    #[test]
316    fn test_recovery_planner_no_checkpoint() {
317        let store = CheckpointStore::new();
318        let cp = RecoveryPlanner::find_recovery_point(&store, "missing-job");
319        assert!(cp.is_none());
320    }
321
322    #[test]
323    fn test_recovery_estimate() {
324        let est = RecoveryEstimate::new(make_id(1, 5), 1200, 45.5);
325        assert_eq!(est.reprocess_frames, 1200);
326        assert!((est.estimated_recovery_secs - 45.5).abs() < 1e-9);
327    }
328
329    #[test]
330    fn test_checkpoint_with_metadata() {
331        let cp = Checkpoint::new(make_id(2, 1), "job-meta", 2048, 500)
332            .with_metadata("encoder", "av1")
333            .with_metadata("pass", "2");
334        assert_eq!(cp.metadata.get("encoder").map(String::as_str), Some("av1"));
335        assert_eq!(cp.metadata.get("pass").map(String::as_str), Some("2"));
336    }
337}