1#![allow(dead_code)]
7
8use crate::consensus::NodeId;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct CheckpointId {
14 pub node_id: NodeId,
16 pub sequence: u64,
18}
19
20impl CheckpointId {
21 #[must_use]
23 pub fn new(node_id: NodeId, sequence: u64) -> Self {
24 Self { node_id, sequence }
25 }
26
27 #[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#[derive(Debug, Clone)]
42pub struct Checkpoint {
43 pub id: CheckpointId,
45 pub job_id: String,
47 pub state_size_bytes: u64,
49 pub created_at_ms: u64,
51 pub metadata: HashMap<String, String>,
53}
54
55impl Checkpoint {
56 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 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#[derive(Debug, Default)]
81pub struct CheckpointStore {
82 checkpoints: HashMap<String, Checkpoint>,
83}
84
85impl CheckpointStore {
86 #[must_use]
88 pub fn new() -> Self {
89 Self {
90 checkpoints: HashMap::new(),
91 }
92 }
93
94 pub fn save(&mut self, checkpoint: Checkpoint) {
96 let key = checkpoint.id.to_string();
97 self.checkpoints.insert(key, checkpoint);
98 }
99
100 #[must_use]
102 pub fn load(&self, id: &CheckpointId) -> Option<&Checkpoint> {
103 self.checkpoints.get(&id.to_string())
104 }
105
106 #[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 pub fn purge_old(&mut self, keep_last_n: usize) {
117 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 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 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 #[must_use]
149 pub fn len(&self) -> usize {
150 self.checkpoints.len()
151 }
152
153 #[must_use]
155 pub fn is_empty(&self) -> bool {
156 self.checkpoints.is_empty()
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct CheckpointPolicy {
163 pub frequency_secs: u32,
165 pub keep_last_n: usize,
167 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
181pub struct RecoveryPlanner;
183
184impl RecoveryPlanner {
185 #[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#[derive(Debug, Clone)]
199pub struct RecoveryEstimate {
200 pub checkpoint: CheckpointId,
202 pub reprocess_frames: u64,
204 pub estimated_recovery_secs: f64,
206}
207
208impl RecoveryEstimate {
209 #[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); }
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}