Skip to main content

oxigdal_streaming/state/
checkpoint.rs

1//! Checkpointing for fault tolerance.
2
3use crate::error::{Result, StreamingError};
4use crate::state::operator_state::DynOperatorState;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::RwLock;
12use tokio::time::sleep;
13
14/// Checkpoint metadata.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CheckpointMetadata {
17    /// Checkpoint ID
18    pub id: u64,
19
20    /// Checkpoint timestamp
21    pub timestamp: DateTime<Utc>,
22
23    /// Checkpoint size in bytes
24    pub size_bytes: usize,
25
26    /// State of operators
27    pub operator_states: HashMap<String, Vec<u8>>,
28
29    /// Success status
30    pub success: bool,
31
32    /// Duration to complete
33    pub duration: Duration,
34}
35
36/// Checkpoint barrier.
37#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
38pub struct CheckpointBarrier {
39    /// Checkpoint ID
40    pub id: u64,
41
42    /// Timestamp
43    pub timestamp: DateTime<Utc>,
44}
45
46impl CheckpointBarrier {
47    /// Create a new checkpoint barrier.
48    pub fn new(id: u64) -> Self {
49        Self {
50            id,
51            timestamp: Utc::now(),
52        }
53    }
54}
55
56/// Checkpoint configuration.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CheckpointConfig {
59    /// Checkpoint interval
60    pub interval: Duration,
61
62    /// Minimum pause between checkpoints
63    pub min_pause: Duration,
64
65    /// Maximum concurrent checkpoints
66    pub max_concurrent: usize,
67
68    /// Enable unaligned checkpoints
69    pub unaligned: bool,
70
71    /// Checkpoint timeout
72    pub timeout: Duration,
73
74    /// Storage path
75    pub storage_path: Option<PathBuf>,
76}
77
78impl Default for CheckpointConfig {
79    fn default() -> Self {
80        Self {
81            interval: Duration::from_secs(60),
82            min_pause: Duration::from_secs(10),
83            max_concurrent: 1,
84            unaligned: false,
85            timeout: Duration::from_secs(300),
86            storage_path: None,
87        }
88    }
89}
90
91/// Checkpoint storage.
92pub trait CheckpointStorage: Send + Sync {
93    /// Store a checkpoint.
94    fn store(&self, checkpoint: &Checkpoint) -> Result<()>;
95
96    /// Load a checkpoint.
97    fn load(&self, checkpoint_id: u64) -> Result<Option<Checkpoint>>;
98
99    /// Delete a checkpoint.
100    fn delete(&self, checkpoint_id: u64) -> Result<()>;
101
102    /// List all checkpoints.
103    fn list(&self) -> Result<Vec<u64>>;
104
105    /// Get the latest checkpoint ID.
106    fn latest(&self) -> Result<Option<u64>>;
107}
108
109/// File-system backed checkpoint storage.
110///
111/// Persists each [`Checkpoint`] to its own file under a root directory using
112/// the Pure-Rust `oxicode` binary codec (no C FFI, no external services). This
113/// is a real, durable [`CheckpointStorage`] implementation suitable for local
114/// fault-tolerant recovery.
115pub struct FileCheckpointStorage {
116    root: PathBuf,
117}
118
119impl FileCheckpointStorage {
120    /// Create a new file-backed checkpoint store rooted at `root`.
121    ///
122    /// The directory (and any missing parents) is created if it does not exist.
123    pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
124        let root = root.into();
125        std::fs::create_dir_all(&root)?;
126        Ok(Self { root })
127    }
128
129    /// Path of the on-disk file for a given checkpoint id.
130    fn checkpoint_path(&self, checkpoint_id: u64) -> PathBuf {
131        self.root.join(format!("checkpoint-{checkpoint_id}.ckpt"))
132    }
133
134    /// Parse a checkpoint id out of a stored file name, if it is one of ours.
135    fn parse_id(name: &Path) -> Option<u64> {
136        let name = name.file_name()?.to_str()?;
137        let rest = name.strip_prefix("checkpoint-")?;
138        let id = rest.strip_suffix(".ckpt")?;
139        id.parse::<u64>().ok()
140    }
141}
142
143impl CheckpointStorage for FileCheckpointStorage {
144    fn store(&self, checkpoint: &Checkpoint) -> Result<()> {
145        // serde_json (Pure Rust) is used here rather than the oxicode binary
146        // codec because the checkpoint carries `chrono::DateTime`/`Duration`
147        // fields that lack `oxicode::Encode` impls; JSON round-trips them via
148        // their serde implementations.
149        let bytes = serde_json::to_vec(checkpoint)?;
150        // Write to a temp file then rename for atomic replacement.
151        let final_path = self.checkpoint_path(checkpoint.id());
152        let tmp_path = final_path.with_extension("ckpt.tmp");
153        std::fs::write(&tmp_path, &bytes)?;
154        std::fs::rename(&tmp_path, &final_path)?;
155        Ok(())
156    }
157
158    fn load(&self, checkpoint_id: u64) -> Result<Option<Checkpoint>> {
159        let path = self.checkpoint_path(checkpoint_id);
160        match std::fs::read(&path) {
161            Ok(bytes) => {
162                let checkpoint: Checkpoint = serde_json::from_slice(&bytes)?;
163                Ok(Some(checkpoint))
164            }
165            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
166            Err(e) => Err(e.into()),
167        }
168    }
169
170    fn delete(&self, checkpoint_id: u64) -> Result<()> {
171        let path = self.checkpoint_path(checkpoint_id);
172        match std::fs::remove_file(&path) {
173            Ok(()) => Ok(()),
174            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
175            Err(e) => Err(e.into()),
176        }
177    }
178
179    fn list(&self) -> Result<Vec<u64>> {
180        let mut ids = Vec::new();
181        for entry in std::fs::read_dir(&self.root)? {
182            let entry = entry?;
183            if let Some(id) = Self::parse_id(&entry.path()) {
184                ids.push(id);
185            }
186        }
187        ids.sort_unstable();
188        Ok(ids)
189    }
190
191    fn latest(&self) -> Result<Option<u64>> {
192        Ok(self.list()?.into_iter().max())
193    }
194}
195
196/// In-memory checkpoint implementation.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct Checkpoint {
199    /// Metadata
200    pub metadata: CheckpointMetadata,
201
202    /// Actual checkpoint data
203    pub data: Vec<u8>,
204}
205
206impl Checkpoint {
207    /// Create a new checkpoint.
208    pub fn new(id: u64, data: Vec<u8>) -> Self {
209        let size_bytes = data.len();
210        Self {
211            metadata: CheckpointMetadata {
212                id,
213                timestamp: Utc::now(),
214                size_bytes,
215                operator_states: HashMap::new(),
216                success: true,
217                duration: Duration::ZERO,
218            },
219            data,
220        }
221    }
222
223    /// Get the checkpoint ID.
224    pub fn id(&self) -> u64 {
225        self.metadata.id
226    }
227
228    /// Get the checkpoint size.
229    pub fn size(&self) -> usize {
230        self.metadata.size_bytes
231    }
232}
233
234/// Checkpoint coordinator.
235pub struct CheckpointCoordinator {
236    config: CheckpointConfig,
237    next_checkpoint_id: Arc<RwLock<u64>>,
238    active_checkpoints: Arc<RwLock<HashMap<u64, CheckpointMetadata>>>,
239    completed_checkpoints: Arc<RwLock<Vec<u64>>>,
240    last_checkpoint_time: Arc<RwLock<Option<DateTime<Utc>>>>,
241    /// Registered operator states captured on every checkpoint, keyed by name.
242    operators: Arc<RwLock<HashMap<String, Arc<dyn DynOperatorState>>>>,
243    /// Optional durable storage for persisting checkpoints.
244    storage: Option<Arc<dyn CheckpointStorage>>,
245}
246
247impl CheckpointCoordinator {
248    /// Create a new checkpoint coordinator without durable storage.
249    ///
250    /// Checkpoints still capture registered operator state into their metadata
251    /// but are not persisted to disk. Use [`CheckpointCoordinator::with_storage`]
252    /// (or a `storage_path` in the config) for durable, recoverable checkpoints.
253    pub fn new(config: CheckpointConfig) -> Self {
254        Self {
255            config,
256            next_checkpoint_id: Arc::new(RwLock::new(0)),
257            active_checkpoints: Arc::new(RwLock::new(HashMap::new())),
258            completed_checkpoints: Arc::new(RwLock::new(Vec::new())),
259            last_checkpoint_time: Arc::new(RwLock::new(None)),
260            operators: Arc::new(RwLock::new(HashMap::new())),
261            storage: None,
262        }
263    }
264
265    /// Create a coordinator backed by a durable [`CheckpointStorage`].
266    pub fn with_storage(config: CheckpointConfig, storage: Arc<dyn CheckpointStorage>) -> Self {
267        let mut coordinator = Self::new(config);
268        coordinator.storage = Some(storage);
269        coordinator
270    }
271
272    /// Create a coordinator whose storage is derived from `config.storage_path`.
273    ///
274    /// When `config.storage_path` is set, a [`FileCheckpointStorage`] rooted at
275    /// that path is created (the directory is created if missing). When it is
276    /// `None`, this is equivalent to [`CheckpointCoordinator::new`].
277    pub fn from_config(config: CheckpointConfig) -> Result<Self> {
278        match &config.storage_path {
279            Some(path) => {
280                let storage = Arc::new(FileCheckpointStorage::new(path.clone())?);
281                Ok(Self::with_storage(config, storage))
282            }
283            None => Ok(Self::new(config)),
284        }
285    }
286
287    /// Register an operator state to be captured on every checkpoint.
288    ///
289    /// The `name` uniquely identifies the operator across snapshot/restore; the
290    /// same name must be used when restoring so the correct bytes are routed
291    /// back to the operator. Registering an existing name replaces it.
292    pub async fn register_operator(
293        &self,
294        name: impl Into<String>,
295        state: Arc<dyn DynOperatorState>,
296    ) {
297        self.operators.write().await.insert(name.into(), state);
298    }
299
300    /// Number of registered operators.
301    pub async fn operator_count(&self) -> usize {
302        self.operators.read().await.len()
303    }
304
305    /// Trigger a new checkpoint, capturing the state of all registered operators.
306    ///
307    /// This snapshots every registered [`DynOperatorState`], aggregating the
308    /// captured bytes into the checkpoint metadata (`operator_states`) and
309    /// recording the real total `size_bytes`. The checkpoint is left *active*
310    /// (not yet completed/persisted); call
311    /// [`CheckpointCoordinator::complete_checkpoint`] to finalize it, or use the
312    /// combined [`CheckpointCoordinator::checkpoint`] which triggers, persists,
313    /// and completes in one step.
314    ///
315    /// If any operator snapshot fails, no active checkpoint is registered and
316    /// the error is propagated — a failed capture never masquerades as success.
317    pub async fn trigger_checkpoint(&self) -> Result<u64> {
318        let now = Utc::now();
319        let last_time = *self.last_checkpoint_time.read().await;
320
321        if let Some(last) = last_time {
322            let min_pause_chrono = match chrono::Duration::from_std(self.config.min_pause) {
323                Ok(duration) => duration,
324                Err(_) => chrono::Duration::zero(),
325            };
326
327            if now - last < min_pause_chrono {
328                return Err(StreamingError::CheckpointError(
329                    "Minimum pause not elapsed".to_string(),
330                ));
331            }
332        }
333
334        let active_count = self.active_checkpoints.read().await.len();
335        if active_count >= self.config.max_concurrent {
336            return Err(StreamingError::CheckpointError(
337                "Too many concurrent checkpoints".to_string(),
338            ));
339        }
340
341        // Capture the state of every registered operator. Clone the handles out
342        // of the registry first so we do not hold the lock across the snapshot
343        // awaits (which could block operator registration or deadlock).
344        let handles: Vec<(String, Arc<dyn DynOperatorState>)> = {
345            let operators = self.operators.read().await;
346            operators
347                .iter()
348                .map(|(name, state)| (name.clone(), state.clone()))
349                .collect()
350        };
351
352        let mut operator_states = HashMap::with_capacity(handles.len());
353        let mut size_bytes = 0usize;
354        for (name, state) in handles {
355            let bytes = state.snapshot_boxed().await?;
356            size_bytes = size_bytes.saturating_add(bytes.len());
357            operator_states.insert(name, bytes);
358        }
359
360        let mut next_id = self.next_checkpoint_id.write().await;
361        let checkpoint_id = *next_id;
362        *next_id += 1;
363
364        let metadata = CheckpointMetadata {
365            id: checkpoint_id,
366            timestamp: now,
367            size_bytes,
368            operator_states,
369            success: false,
370            duration: Duration::ZERO,
371        };
372
373        self.active_checkpoints
374            .write()
375            .await
376            .insert(checkpoint_id, metadata);
377
378        *self.last_checkpoint_time.write().await = Some(now);
379
380        Ok(checkpoint_id)
381    }
382
383    /// Persist a currently-active checkpoint via the configured storage.
384    ///
385    /// Serializes the captured operator states into a [`Checkpoint`] and writes
386    /// it through [`CheckpointStorage`]. When no storage is configured this is a
387    /// no-op (in-memory checkpointing). The blocking store call runs on a
388    /// blocking thread so it never stalls the async runtime.
389    async fn persist_checkpoint(&self, checkpoint_id: u64) -> Result<()> {
390        let Some(storage) = self.storage.clone() else {
391            return Ok(());
392        };
393
394        let metadata = {
395            let active = self.active_checkpoints.read().await;
396            active.get(&checkpoint_id).cloned().ok_or_else(|| {
397                StreamingError::CheckpointError(format!("Checkpoint {checkpoint_id} not found"))
398            })?
399        };
400
401        let data = oxicode::encode_to_vec(&metadata.operator_states)
402            .map_err(|e| StreamingError::SerializationError(e.to_string()))?;
403        let checkpoint = Checkpoint { metadata, data };
404
405        tokio::task::spawn_blocking(move || storage.store(&checkpoint))
406            .await
407            .map_err(|e| {
408                StreamingError::CheckpointError(format!("checkpoint store task failed: {e}"))
409            })??;
410
411        Ok(())
412    }
413
414    /// Trigger, persist, and complete a checkpoint in one durable step.
415    ///
416    /// Captures registered operator state, persists it through the configured
417    /// storage, and only marks the checkpoint successful once the state has been
418    /// durably written. If capture or persistence fails, the checkpoint is
419    /// completed as a failure (removed from the active set, *not* added to the
420    /// completed set) and the error is returned — there is no silent success.
421    pub async fn checkpoint(&self) -> Result<u64> {
422        let checkpoint_id = self.trigger_checkpoint().await?;
423
424        if let Err(e) = self.persist_checkpoint(checkpoint_id).await {
425            // Best-effort mark-as-failed; ignore the (only possible) "not found"
426            // error so the original cause is what surfaces to the caller.
427            let _ = self.complete_checkpoint(checkpoint_id, false).await;
428            return Err(e);
429        }
430
431        self.complete_checkpoint(checkpoint_id, true).await?;
432        Ok(checkpoint_id)
433    }
434
435    /// Restore registered operators from a persisted checkpoint.
436    ///
437    /// Loads the checkpoint through the configured storage and routes each
438    /// stored operator-state blob back to the operator registered under the
439    /// same name. Operators without a stored blob are left untouched.
440    pub async fn restore_checkpoint(&self, checkpoint_id: u64) -> Result<()> {
441        let Some(storage) = self.storage.clone() else {
442            return Err(StreamingError::CheckpointError(
443                "no checkpoint storage configured".to_string(),
444            ));
445        };
446
447        let checkpoint = tokio::task::spawn_blocking(move || storage.load(checkpoint_id))
448            .await
449            .map_err(|e| {
450                StreamingError::CheckpointError(format!("checkpoint load task failed: {e}"))
451            })??
452            .ok_or_else(|| {
453                StreamingError::CheckpointError(format!(
454                    "Checkpoint {checkpoint_id} not found in storage"
455                ))
456            })?;
457
458        let handles: Vec<(String, Arc<dyn DynOperatorState>)> = {
459            let operators = self.operators.read().await;
460            operators
461                .iter()
462                .map(|(name, state)| (name.clone(), state.clone()))
463                .collect()
464        };
465
466        for (name, state) in handles {
467            if let Some(bytes) = checkpoint.metadata.operator_states.get(&name) {
468                state.restore_boxed(bytes).await?;
469            }
470        }
471
472        Ok(())
473    }
474
475    /// Complete a checkpoint.
476    pub async fn complete_checkpoint(&self, checkpoint_id: u64, success: bool) -> Result<()> {
477        let mut active = self.active_checkpoints.write().await;
478
479        if let Some(mut metadata) = active.remove(&checkpoint_id) {
480            metadata.success = success;
481            metadata.duration = match (Utc::now() - metadata.timestamp).to_std() {
482                Ok(duration) => duration,
483                Err(_) => Duration::ZERO,
484            };
485
486            if success {
487                self.completed_checkpoints.write().await.push(checkpoint_id);
488            }
489
490            Ok(())
491        } else {
492            Err(StreamingError::CheckpointError(format!(
493                "Checkpoint {} not found",
494                checkpoint_id
495            )))
496        }
497    }
498
499    /// Get active checkpoint count.
500    pub async fn active_count(&self) -> usize {
501        self.active_checkpoints.read().await.len()
502    }
503
504    /// Get a copy of an active (triggered-but-not-completed) checkpoint's
505    /// metadata, if present.
506    pub async fn active_metadata(&self, checkpoint_id: u64) -> Option<CheckpointMetadata> {
507        self.active_checkpoints
508            .read()
509            .await
510            .get(&checkpoint_id)
511            .cloned()
512    }
513
514    /// Get completed checkpoint count.
515    pub async fn completed_count(&self) -> usize {
516        self.completed_checkpoints.read().await.len()
517    }
518
519    /// Get the latest completed checkpoint ID.
520    pub async fn latest_checkpoint(&self) -> Option<u64> {
521        self.completed_checkpoints.read().await.last().copied()
522    }
523
524    /// Clear old checkpoints.
525    pub async fn clear_old_checkpoints(&self, keep_count: usize) {
526        let mut completed = self.completed_checkpoints.write().await;
527
528        if completed.len() > keep_count {
529            let to_remove = completed.len() - keep_count;
530            completed.drain(0..to_remove);
531        }
532    }
533
534    /// Start periodic checkpointing.
535    ///
536    /// On every `config.interval`, this runs the full capture → persist →
537    /// complete pipeline via [`CheckpointCoordinator::checkpoint`], bounded by
538    /// `config.timeout`. A checkpoint is only reported as successful once the
539    /// registered operator state has actually been captured (and, when storage
540    /// is configured, durably written). Failures and timeouts are logged and
541    /// the loop continues; a checkpoint is never marked successful without real
542    /// state having been captured.
543    pub async fn start_periodic_checkpointing(self: Arc<Self>) {
544        let interval = self.config.interval;
545        let timeout = self.config.timeout;
546
547        tokio::spawn(async move {
548            loop {
549                sleep(interval).await;
550
551                match tokio::time::timeout(timeout, self.checkpoint()).await {
552                    Ok(Ok(id)) => {
553                        tracing::info!("Completed checkpoint {}", id);
554                    }
555                    Ok(Err(e)) => {
556                        tracing::warn!("Checkpoint failed: {}", e);
557                    }
558                    Err(_) => {
559                        tracing::error!("Checkpoint timed out after {:?}", timeout);
560                    }
561                }
562            }
563        });
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::state::operator_state::{BroadcastState, OperatorState};
571
572    /// Operator state whose snapshot always fails.
573    struct FailingOperator;
574
575    impl OperatorState for FailingOperator {
576        async fn snapshot(&self) -> Result<Vec<u8>> {
577            Err(StreamingError::StateError("snapshot failed".to_string()))
578        }
579
580        async fn restore(&self, _snapshot: &[u8]) -> Result<()> {
581            Ok(())
582        }
583    }
584
585    /// Storage whose `store` always fails.
586    struct FailingStorage;
587
588    impl CheckpointStorage for FailingStorage {
589        fn store(&self, _checkpoint: &Checkpoint) -> Result<()> {
590            Err(StreamingError::CheckpointError("store failed".to_string()))
591        }
592        fn load(&self, _checkpoint_id: u64) -> Result<Option<Checkpoint>> {
593            Ok(None)
594        }
595        fn delete(&self, _checkpoint_id: u64) -> Result<()> {
596            Ok(())
597        }
598        fn list(&self) -> Result<Vec<u64>> {
599            Ok(Vec::new())
600        }
601        fn latest(&self) -> Result<Option<u64>> {
602            Ok(None)
603        }
604    }
605
606    fn no_pause_config() -> CheckpointConfig {
607        CheckpointConfig {
608            min_pause: Duration::ZERO,
609            max_concurrent: 4,
610            ..Default::default()
611        }
612    }
613
614    #[tokio::test]
615    async fn test_checkpoint_captures_operator_state() {
616        let coordinator = CheckpointCoordinator::new(no_pause_config());
617
618        let state = Arc::new(BroadcastState::new());
619        state.put(vec![1], vec![42]).await;
620        state.put(vec![2], vec![43]).await;
621        coordinator
622            .register_operator("op1", state.clone() as Arc<dyn DynOperatorState>)
623            .await;
624
625        assert_eq!(coordinator.operator_count().await, 1);
626
627        let id = coordinator
628            .trigger_checkpoint()
629            .await
630            .expect("trigger should succeed");
631
632        let metadata = coordinator
633            .active_metadata(id)
634            .await
635            .expect("active metadata should exist");
636
637        // Real state must have been captured — not an empty map / zero bytes.
638        assert!(metadata.operator_states.contains_key("op1"));
639        assert!(!metadata.operator_states["op1"].is_empty());
640        assert!(metadata.size_bytes > 0);
641    }
642
643    #[tokio::test]
644    async fn test_checkpoint_persist_and_restore() {
645        let dir = tempfile::tempdir().expect("temp dir");
646        let storage: Arc<dyn CheckpointStorage> =
647            Arc::new(FileCheckpointStorage::new(dir.path().to_path_buf()).expect("storage"));
648
649        let coordinator = CheckpointCoordinator::with_storage(no_pause_config(), storage.clone());
650
651        let state = Arc::new(BroadcastState::new());
652        state.put(vec![7], vec![99]).await;
653        coordinator
654            .register_operator("opA", state as Arc<dyn DynOperatorState>)
655            .await;
656
657        let id = coordinator
658            .checkpoint()
659            .await
660            .expect("durable checkpoint should succeed");
661
662        // Success is only reported after real persistence.
663        assert_eq!(coordinator.completed_count().await, 1);
664        assert_eq!(coordinator.active_count().await, 0);
665
666        // The checkpoint must be on disk and loadable.
667        let loaded = storage.load(id).expect("load").expect("checkpoint present");
668        assert_eq!(loaded.metadata.operator_states["opA"], {
669            let tmp = BroadcastState::new();
670            tmp.put(vec![7], vec![99]).await;
671            tmp.snapshot().await.expect("snapshot")
672        });
673
674        // Restore into a fresh coordinator + empty operator.
675        let recovered = CheckpointCoordinator::with_storage(no_pause_config(), storage.clone());
676        let restored_state = Arc::new(BroadcastState::new());
677        recovered
678            .register_operator("opA", restored_state.clone() as Arc<dyn DynOperatorState>)
679            .await;
680        recovered
681            .restore_checkpoint(id)
682            .await
683            .expect("restore should succeed");
684
685        assert_eq!(restored_state.get(&[7]).await, Some(vec![99]));
686    }
687
688    #[tokio::test]
689    async fn test_checkpoint_store_failure_reports_failure() {
690        let coordinator =
691            CheckpointCoordinator::with_storage(no_pause_config(), Arc::new(FailingStorage));
692
693        let state = Arc::new(BroadcastState::new());
694        state.put(vec![1], vec![1]).await;
695        coordinator
696            .register_operator("op1", state as Arc<dyn DynOperatorState>)
697            .await;
698
699        let result = coordinator.checkpoint().await;
700        assert!(result.is_err(), "store failure must surface as an error");
701
702        // No silent success: nothing recorded as completed, nothing left active.
703        assert_eq!(coordinator.completed_count().await, 0);
704        assert_eq!(coordinator.active_count().await, 0);
705    }
706
707    #[tokio::test]
708    async fn test_checkpoint_snapshot_failure_reports_failure() {
709        let coordinator = CheckpointCoordinator::new(no_pause_config());
710        coordinator
711            .register_operator(
712                "bad",
713                Arc::new(FailingOperator) as Arc<dyn DynOperatorState>,
714            )
715            .await;
716
717        let result = coordinator.checkpoint().await;
718        assert!(result.is_err(), "snapshot failure must surface as an error");
719
720        assert_eq!(coordinator.completed_count().await, 0);
721        assert_eq!(coordinator.active_count().await, 0);
722    }
723
724    #[tokio::test]
725    async fn test_file_checkpoint_storage_roundtrip() {
726        let dir = tempfile::tempdir().expect("temp dir");
727        let storage = FileCheckpointStorage::new(dir.path().to_path_buf()).expect("storage");
728
729        assert_eq!(storage.list().expect("list"), Vec::<u64>::new());
730        assert_eq!(storage.latest().expect("latest"), None);
731        assert!(storage.load(0).expect("load missing").is_none());
732
733        let checkpoint = Checkpoint::new(3, vec![1, 2, 3, 4]);
734        storage.store(&checkpoint).expect("store");
735
736        assert_eq!(storage.list().expect("list"), vec![3]);
737        assert_eq!(storage.latest().expect("latest"), Some(3));
738
739        let loaded = storage.load(3).expect("load").expect("present");
740        assert_eq!(loaded.id(), 3);
741        assert_eq!(loaded.data, vec![1, 2, 3, 4]);
742
743        storage.delete(3).expect("delete");
744        assert!(storage.load(3).expect("load after delete").is_none());
745        assert_eq!(storage.list().expect("list"), Vec::<u64>::new());
746    }
747
748    #[tokio::test]
749    async fn test_checkpoint_creation() {
750        let data = vec![1, 2, 3, 4];
751        let checkpoint = Checkpoint::new(1, data.clone());
752
753        assert_eq!(checkpoint.id(), 1);
754        assert_eq!(checkpoint.size(), 4);
755        assert_eq!(checkpoint.data, data);
756    }
757
758    #[tokio::test]
759    async fn test_checkpoint_barrier() {
760        let barrier = CheckpointBarrier::new(1);
761        assert_eq!(barrier.id, 1);
762    }
763
764    #[tokio::test]
765    async fn test_checkpoint_coordinator() {
766        let config = CheckpointConfig {
767            min_pause: Duration::ZERO, // Allow immediate consecutive checkpoints
768            max_concurrent: 2,         // Allow 2 concurrent checkpoints
769            ..Default::default()
770        };
771        let coordinator = CheckpointCoordinator::new(config);
772
773        let id1 = coordinator
774            .trigger_checkpoint()
775            .await
776            .expect("First checkpoint trigger should succeed");
777        assert_eq!(id1, 0);
778
779        let id2 = coordinator
780            .trigger_checkpoint()
781            .await
782            .expect("Second checkpoint trigger should succeed");
783        assert_eq!(id2, 1);
784
785        assert_eq!(coordinator.active_count().await, 2);
786
787        coordinator
788            .complete_checkpoint(id1, true)
789            .await
790            .expect("Checkpoint completion should succeed");
791        assert_eq!(coordinator.active_count().await, 1);
792        assert_eq!(coordinator.completed_count().await, 1);
793    }
794
795    #[tokio::test]
796    async fn test_checkpoint_min_pause() {
797        let config = CheckpointConfig {
798            min_pause: Duration::from_secs(60),
799            ..Default::default()
800        };
801
802        let coordinator = CheckpointCoordinator::new(config);
803
804        coordinator
805            .trigger_checkpoint()
806            .await
807            .expect("First checkpoint should trigger successfully");
808        let result = coordinator.trigger_checkpoint().await;
809
810        assert!(result.is_err());
811    }
812
813    #[tokio::test]
814    async fn test_clear_old_checkpoints() {
815        let config = CheckpointConfig {
816            min_pause: Duration::ZERO, // Allow rapid consecutive checkpoints
817            ..Default::default()
818        };
819        let coordinator = CheckpointCoordinator::new(config);
820
821        for _ in 0..5 {
822            let id = coordinator
823                .trigger_checkpoint()
824                .await
825                .expect("Checkpoint trigger should succeed in loop");
826            coordinator
827                .complete_checkpoint(id, true)
828                .await
829                .expect("Checkpoint completion should succeed in loop");
830        }
831
832        assert_eq!(coordinator.completed_count().await, 5);
833
834        coordinator.clear_old_checkpoints(2).await;
835        assert_eq!(coordinator.completed_count().await, 2);
836    }
837}