Skip to main content

oxigeo_streaming/v2/
checkpoint.rs

1//! Checkpoint-based stream recovery for fault tolerance.
2//!
3//! A checkpoint captures the processing state at a given sequence number.
4//! On restart, processing resumes from the last checkpoint, replaying only
5//! the events since that point (minimal-replay guarantee).
6//!
7//! # Components
8//!
9//! - [`CheckpointId`]: unique identity of a checkpoint (stream + sequence number).
10//! - [`CheckpointState`]: serialisable snapshot of all operator states, source
11//!   offsets, watermark, and event count.
12//! - [`CheckpointStore`]: storage abstraction over a durable (or in-memory)
13//!   backend, so [`CheckpointManager`] can be parameterized over where
14//!   checkpoints live.
15//! - [`InMemoryCheckpointStore`]: bounded in-memory store (useful for testing
16//!   and for single-process use).
17//! - [`FileCheckpointStore`]: durable file-system-backed store that survives a
18//!   process restart — the scenario checkpointing exists to recover from.
19//! - [`CheckpointManager`]: drives periodic checkpointing and recovery.
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::time::SystemTime;
24
25use crate::error::StreamingError;
26
27// ─── CheckpointId ─────────────────────────────────────────────────────────────
28
29/// Unique identifier for a checkpoint within a named stream.
30#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct CheckpointId {
32    /// Logical stream name.
33    pub stream_id: String,
34    /// Sequence number of the last event included in this checkpoint.
35    pub sequence: u64,
36    /// Wall-clock time at which the checkpoint was created.
37    pub created_at: SystemTime,
38}
39
40impl CheckpointId {
41    /// Construct a new `CheckpointId` timestamped *now*.
42    pub fn new(stream_id: impl Into<String>, sequence: u64) -> Self {
43        Self {
44            stream_id: stream_id.into(),
45            sequence,
46            created_at: SystemTime::now(),
47        }
48    }
49}
50
51// ─── CheckpointState ─────────────────────────────────────────────────────────
52
53/// Serialisable snapshot captured at a checkpoint.
54///
55/// # Binary format
56///
57/// ```text
58/// [8 bytes]  sequence          (little-endian u64)
59/// [8 bytes]  watermark_ns      (little-endian u64)
60/// [8 bytes]  event_count       (little-endian u64)
61/// [4 bytes]  n_operators       (little-endian u32)
62/// for each operator:
63///   [4 bytes] name_len         (little-endian u32)
64///   [name_len bytes] name      (UTF-8)
65///   [4 bytes] state_len        (little-endian u32)
66///   [state_len bytes] state    (opaque bytes)
67/// [4 bytes]  n_sources         (little-endian u32)
68/// for each source:
69///   [4 bytes] name_len         (little-endian u32)
70///   [name_len bytes] name      (UTF-8)
71///   [8 bytes] offset           (little-endian u64)
72/// ```
73#[derive(Debug, Clone)]
74pub struct CheckpointState {
75    /// Identity of this checkpoint.
76    pub id: CheckpointId,
77    /// Per-operator opaque state blobs.  Key: operator name.
78    pub operator_states: HashMap<String, Vec<u8>>,
79    /// Per-source byte/record offsets.  Key: source identifier.
80    pub source_offsets: HashMap<String, u64>,
81    /// Maximum processed event time expressed as nanoseconds since the Unix epoch.
82    pub watermark_ns: u64,
83    /// Number of events processed up to and including this checkpoint.
84    pub event_count: u64,
85    /// Arbitrary key→value metadata.
86    pub metadata: HashMap<String, String>,
87}
88
89impl CheckpointState {
90    /// Create an empty state for the given checkpoint ID.
91    pub fn new(id: CheckpointId) -> Self {
92        Self {
93            id,
94            operator_states: HashMap::new(),
95            source_offsets: HashMap::new(),
96            watermark_ns: 0,
97            event_count: 0,
98            metadata: HashMap::new(),
99        }
100    }
101
102    /// Store an operator's serialised state.
103    pub fn set_operator_state(&mut self, operator: impl Into<String>, state: Vec<u8>) {
104        self.operator_states.insert(operator.into(), state);
105    }
106
107    /// Record the byte/record offset for a source.
108    pub fn set_source_offset(&mut self, source: impl Into<String>, offset: u64) {
109        self.source_offsets.insert(source.into(), offset);
110    }
111
112    /// Serialise this state to a compact binary representation.
113    pub fn serialize(&self) -> Vec<u8> {
114        let mut buf = Vec::new();
115
116        buf.extend_from_slice(&self.id.sequence.to_le_bytes());
117        buf.extend_from_slice(&self.watermark_ns.to_le_bytes());
118        buf.extend_from_slice(&self.event_count.to_le_bytes());
119
120        // Operator states
121        buf.extend_from_slice(&(self.operator_states.len() as u32).to_le_bytes());
122        for (name, state) in &self.operator_states {
123            let name_bytes = name.as_bytes();
124            buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
125            buf.extend_from_slice(name_bytes);
126            buf.extend_from_slice(&(state.len() as u32).to_le_bytes());
127            buf.extend_from_slice(state);
128        }
129
130        // Source offsets
131        buf.extend_from_slice(&(self.source_offsets.len() as u32).to_le_bytes());
132        for (name, offset) in &self.source_offsets {
133            let name_bytes = name.as_bytes();
134            buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
135            buf.extend_from_slice(name_bytes);
136            buf.extend_from_slice(&offset.to_le_bytes());
137        }
138
139        buf
140    }
141
142    /// Deserialise a `CheckpointState` from bytes previously produced by [`Self::serialize`].
143    ///
144    /// Returns [`StreamingError::DeserializationError`] if the data is truncated
145    /// or otherwise malformed.
146    pub fn deserialize(stream_id: &str, data: &[u8]) -> Result<Self, StreamingError> {
147        const HEADER: usize = 24; // sequence(8) + watermark_ns(8) + event_count(8)
148        if data.len() < HEADER {
149            return Err(StreamingError::DeserializationError(
150                "checkpoint data too short for header".into(),
151            ));
152        }
153
154        let sequence = Self::read_u64(data, 0)?;
155        let watermark_ns = Self::read_u64(data, 8)?;
156        let event_count = Self::read_u64(data, 16)?;
157
158        let id = CheckpointId::new(stream_id, sequence);
159        let mut state = Self::new(id);
160        state.watermark_ns = watermark_ns;
161        state.event_count = event_count;
162
163        let mut cursor = HEADER;
164
165        // ── operator states ──
166        let n_ops = Self::read_u32(data, cursor)? as usize;
167        cursor += 4;
168        for _ in 0..n_ops {
169            let (name, advance) = Self::read_string(data, cursor)?;
170            cursor += advance;
171            let state_len = Self::read_u32(data, cursor)? as usize;
172            cursor += 4;
173            if cursor + state_len > data.len() {
174                return Err(StreamingError::DeserializationError(
175                    "truncated operator state bytes".into(),
176                ));
177            }
178            let op_state = data[cursor..cursor + state_len].to_vec();
179            cursor += state_len;
180            state.operator_states.insert(name, op_state);
181        }
182
183        // ── source offsets ──
184        if cursor + 4 > data.len() {
185            // No source-offsets section present — treat as empty.
186            return Ok(state);
187        }
188        let n_src = Self::read_u32(data, cursor)? as usize;
189        cursor += 4;
190        for _ in 0..n_src {
191            let (name, advance) = Self::read_string(data, cursor)?;
192            cursor += advance;
193            let offset = Self::read_u64(data, cursor)?;
194            cursor += 8;
195            state.source_offsets.insert(name, offset);
196        }
197
198        Ok(state)
199    }
200
201    // ── byte-reading helpers ─────────────────────────────────────────────────
202
203    fn read_u64(data: &[u8], offset: usize) -> Result<u64, StreamingError> {
204        data.get(offset..offset + 8)
205            .and_then(|b| b.try_into().ok())
206            .map(u64::from_le_bytes)
207            .ok_or_else(|| {
208                StreamingError::DeserializationError(format!("cannot read u64 at offset {offset}"))
209            })
210    }
211
212    fn read_u32(data: &[u8], offset: usize) -> Result<u32, StreamingError> {
213        data.get(offset..offset + 4)
214            .and_then(|b| b.try_into().ok())
215            .map(u32::from_le_bytes)
216            .ok_or_else(|| {
217                StreamingError::DeserializationError(format!("cannot read u32 at offset {offset}"))
218            })
219    }
220
221    /// Read a length-prefixed UTF-8 string from `data[cursor..]`.
222    ///
223    /// Returns `(string, bytes_consumed)` where `bytes_consumed` includes the
224    /// 4-byte length prefix.
225    fn read_string(data: &[u8], cursor: usize) -> Result<(String, usize), StreamingError> {
226        let name_len = Self::read_u32(data, cursor)? as usize;
227        let name_start = cursor + 4;
228        let name_end = name_start + name_len;
229        if name_end > data.len() {
230            return Err(StreamingError::DeserializationError(
231                "truncated string bytes".into(),
232            ));
233        }
234        let name = String::from_utf8(data[name_start..name_end].to_vec()).map_err(|e| {
235            StreamingError::DeserializationError(format!("invalid UTF-8 in field name: {e}"))
236        })?;
237        Ok((name, 4 + name_len))
238    }
239}
240
241// ─── CheckpointStore ──────────────────────────────────────────────────────────
242
243/// Storage backend for stream checkpoints.
244///
245/// Implementations decide *where* checkpoint state lives. [`CheckpointManager`]
246/// is generic over this trait so the same driver can target an in-memory store
247/// ([`InMemoryCheckpointStore`]) for tests/single-process use or a durable
248/// backend ([`FileCheckpointStore`]) that survives a crash/restart.
249pub trait CheckpointStore {
250    /// Persist a checkpoint. Later checkpoints for the same stream supersede
251    /// earlier ones.
252    fn save(&mut self, state: CheckpointState) -> Result<(), StreamingError>;
253
254    /// Return the most recent checkpoint for `stream_id`, or `None` if the
255    /// stream has no checkpoints.
256    fn latest(&self, stream_id: &str) -> Result<Option<CheckpointState>, StreamingError>;
257}
258
259// ─── FileCheckpointStore ──────────────────────────────────────────────────────
260
261/// Durable, file-system-backed checkpoint store.
262///
263/// Each checkpoint is written to `root/{stream}/checkpoint-{sequence}.ckpt`
264/// using [`CheckpointState::serialize`] and an atomic temp-file + rename, so a
265/// checkpoint that has been acknowledged is guaranteed to survive a process
266/// crash or restart. Recovery scans the stream directory for the highest
267/// sequence number. Pure Rust, no external services.
268pub struct FileCheckpointStore {
269    root: PathBuf,
270    /// Maximum checkpoint files retained per stream (oldest pruned on save).
271    max_per_stream: usize,
272}
273
274impl FileCheckpointStore {
275    /// Create a store rooted at `root`, retaining at most `max_per_stream`
276    /// checkpoint files per stream. The root directory is created if missing.
277    pub fn new(root: impl Into<PathBuf>, max_per_stream: usize) -> Result<Self, StreamingError> {
278        if max_per_stream == 0 {
279            return Err(StreamingError::ConfigError(
280                "max_per_stream must be at least 1".into(),
281            ));
282        }
283        let root = root.into();
284        std::fs::create_dir_all(&root)?;
285        Ok(Self {
286            root,
287            max_per_stream,
288        })
289    }
290
291    /// Sanitize a stream id so it is safe as a single path component.
292    fn sanitize(stream_id: &str) -> String {
293        stream_id
294            .chars()
295            .map(|c| {
296                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
297                    c
298                } else {
299                    '_'
300                }
301            })
302            .collect()
303    }
304
305    fn stream_dir(&self, stream_id: &str) -> PathBuf {
306        self.root.join(Self::sanitize(stream_id))
307    }
308
309    fn checkpoint_file(&self, stream_id: &str, sequence: u64) -> PathBuf {
310        // Zero-pad so lexical order matches numeric order.
311        self.stream_dir(stream_id)
312            .join(format!("checkpoint-{sequence:020}.ckpt"))
313    }
314
315    /// Parse the sequence number out of a stored checkpoint file name.
316    fn parse_sequence(path: &Path) -> Option<u64> {
317        let name = path.file_name()?.to_str()?;
318        let rest = name.strip_prefix("checkpoint-")?;
319        let seq = rest.strip_suffix(".ckpt")?;
320        seq.parse::<u64>().ok()
321    }
322
323    /// All (sequence, path) pairs for a stream, sorted ascending by sequence.
324    fn entries(&self, stream_id: &str) -> Result<Vec<(u64, PathBuf)>, StreamingError> {
325        let dir = self.stream_dir(stream_id);
326        let mut out = Vec::new();
327        match std::fs::read_dir(&dir) {
328            Ok(rd) => {
329                for entry in rd {
330                    let entry = entry?;
331                    let path = entry.path();
332                    if let Some(seq) = Self::parse_sequence(&path) {
333                        out.push((seq, path));
334                    }
335                }
336            }
337            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
338            Err(e) => return Err(e.into()),
339        }
340        out.sort_by_key(|(seq, _)| *seq);
341        Ok(out)
342    }
343}
344
345impl CheckpointStore for FileCheckpointStore {
346    fn save(&mut self, state: CheckpointState) -> Result<(), StreamingError> {
347        let stream_id = state.id.stream_id.clone();
348        let dir = self.stream_dir(&stream_id);
349        std::fs::create_dir_all(&dir)?;
350
351        let final_path = self.checkpoint_file(&stream_id, state.id.sequence);
352        let tmp_path = final_path.with_extension("ckpt.tmp");
353        let bytes = state.serialize();
354        std::fs::write(&tmp_path, &bytes)?;
355        std::fs::rename(&tmp_path, &final_path)?;
356
357        // Prune oldest checkpoints beyond the retention bound.
358        let entries = self.entries(&stream_id)?;
359        if entries.len() > self.max_per_stream {
360            let excess = entries.len() - self.max_per_stream;
361            for (_, path) in entries.into_iter().take(excess) {
362                std::fs::remove_file(&path).ok();
363            }
364        }
365        Ok(())
366    }
367
368    fn latest(&self, stream_id: &str) -> Result<Option<CheckpointState>, StreamingError> {
369        let entries = self.entries(stream_id)?;
370        let Some((_, path)) = entries.into_iter().next_back() else {
371            return Ok(None);
372        };
373        let bytes = std::fs::read(&path)?;
374        let state = CheckpointState::deserialize(stream_id, &bytes)?;
375        Ok(Some(state))
376    }
377}
378
379// ─── InMemoryCheckpointStore ──────────────────────────────────────────────────
380
381/// A bounded in-memory checkpoint store.
382///
383/// Each stream maintains its own sorted list of checkpoints.  When the number
384/// of checkpoints for a stream exceeds `max_per_stream`, the **oldest** ones are
385/// evicted automatically.
386pub struct InMemoryCheckpointStore {
387    /// stream_id → list of checkpoints, sorted ascending by sequence number.
388    checkpoints: HashMap<String, Vec<CheckpointState>>,
389    /// Maximum checkpoints retained per stream.
390    max_per_stream: usize,
391}
392
393impl InMemoryCheckpointStore {
394    /// Create a store that retains at most `max_per_stream` checkpoints per stream.
395    pub fn new(max_per_stream: usize) -> Self {
396        assert!(max_per_stream > 0, "max_per_stream must be at least 1");
397        Self {
398            checkpoints: HashMap::new(),
399            max_per_stream,
400        }
401    }
402
403    /// Save a checkpoint state.  The list is kept sorted by sequence number.
404    pub fn save(&mut self, state: CheckpointState) -> Result<(), StreamingError> {
405        let stream_id = state.id.stream_id.clone();
406        let entry = self.checkpoints.entry(stream_id).or_default();
407        entry.push(state);
408        entry.sort_by_key(|s| s.id.sequence);
409        // Trim to max_per_stream, evicting oldest (lowest sequence)
410        if entry.len() > self.max_per_stream {
411            let excess = entry.len() - self.max_per_stream;
412            entry.drain(0..excess);
413        }
414        Ok(())
415    }
416
417    /// Return the most recent checkpoint for the given stream, or `None`.
418    pub fn latest(&self, stream_id: &str) -> Option<&CheckpointState> {
419        self.checkpoints.get(stream_id)?.last()
420    }
421
422    /// Return all checkpoints for the given stream, sorted ascending by sequence.
423    pub fn list(&self, stream_id: &str) -> Vec<&CheckpointState> {
424        self.checkpoints
425            .get(stream_id)
426            .map(|v| v.iter().collect())
427            .unwrap_or_default()
428    }
429
430    /// Remove all checkpoints for `stream_id` with sequence number **less than** `sequence`.
431    pub fn delete_before(&mut self, stream_id: &str, sequence: u64) {
432        if let Some(entry) = self.checkpoints.get_mut(stream_id) {
433            entry.retain(|s| s.id.sequence >= sequence);
434        }
435    }
436
437    /// Number of checkpoints currently stored for the given stream.
438    pub fn checkpoint_count(&self, stream_id: &str) -> usize {
439        self.checkpoints
440            .get(stream_id)
441            .map(|v| v.len())
442            .unwrap_or(0)
443    }
444}
445
446impl CheckpointStore for InMemoryCheckpointStore {
447    fn save(&mut self, state: CheckpointState) -> Result<(), StreamingError> {
448        InMemoryCheckpointStore::save(self, state)
449    }
450
451    fn latest(&self, stream_id: &str) -> Result<Option<CheckpointState>, StreamingError> {
452        Ok(InMemoryCheckpointStore::latest(self, stream_id).cloned())
453    }
454}
455
456// ─── CheckpointManager ────────────────────────────────────────────────────────
457
458/// Drives periodic checkpointing and provides recovery support.
459///
460/// Call [`Self::on_event`] after processing each event.  When the cumulative sequence
461/// number reaches the next scheduled checkpoint, a new [`CheckpointState`] is
462/// automatically saved to the underlying store.
463pub struct CheckpointManager<S: CheckpointStore = InMemoryCheckpointStore> {
464    store: S,
465    /// Checkpoint every `checkpoint_interval` events.
466    checkpoint_interval: u64,
467    next_checkpoint_at: u64,
468    total_checkpoints: u64,
469}
470
471impl<S: CheckpointStore> CheckpointManager<S> {
472    /// Create a manager with the given store and interval.
473    ///
474    /// Use a durable store such as [`FileCheckpointStore`] for recovery that
475    /// survives a process restart, or [`InMemoryCheckpointStore`] for tests and
476    /// single-process, non-recoverable use.
477    pub fn new(store: S, checkpoint_interval: u64) -> Self {
478        assert!(
479            checkpoint_interval > 0,
480            "checkpoint_interval must be positive"
481        );
482        Self {
483            store,
484            checkpoint_interval,
485            next_checkpoint_at: checkpoint_interval,
486            total_checkpoints: 0,
487        }
488    }
489
490    /// Called after each processed event.
491    ///
492    /// Returns `Ok(true)` if a checkpoint was taken, `Ok(false)` otherwise.
493    pub fn on_event(
494        &mut self,
495        stream_id: &str,
496        sequence: u64,
497        watermark_ns: u64,
498    ) -> Result<bool, StreamingError> {
499        if sequence >= self.next_checkpoint_at {
500            let id = CheckpointId::new(stream_id, sequence);
501            let mut state = CheckpointState::new(id);
502            state.watermark_ns = watermark_ns;
503            state.event_count = sequence;
504            self.store.save(state)?;
505            self.next_checkpoint_at = sequence + self.checkpoint_interval;
506            self.total_checkpoints += 1;
507            return Ok(true);
508        }
509        Ok(false)
510    }
511
512    /// Return the sequence number from which to resume, or `None` if no
513    /// checkpoint exists for the stream.
514    ///
515    /// With a durable store this reads the last checkpoint persisted before a
516    /// crash, enabling minimal-replay recovery.
517    pub fn recover(&self, stream_id: &str) -> Result<Option<u64>, StreamingError> {
518        Ok(self.store.latest(stream_id)?.map(|s| s.id.sequence))
519    }
520
521    /// Total checkpoints taken since this manager was created.
522    pub fn total_checkpoints(&self) -> u64 {
523        self.total_checkpoints
524    }
525
526    /// Read-only access to the underlying store (for inspection / testing).
527    pub fn store(&self) -> &S {
528        &self.store
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    // ── CheckpointState serialisation ────────────────────────────────────────
537
538    #[test]
539    fn test_serialize_deserialize_round_trip_empty() {
540        let id = CheckpointId::new("stream-a", 42);
541        let mut state = CheckpointState::new(id);
542        state.watermark_ns = 999_000_000;
543        state.event_count = 42;
544
545        let bytes = state.serialize();
546        let decoded = CheckpointState::deserialize("stream-a", &bytes)
547            .expect("deserialization should succeed");
548
549        assert_eq!(decoded.id.sequence, 42);
550        assert_eq!(decoded.watermark_ns, 999_000_000);
551        assert_eq!(decoded.event_count, 42);
552        assert!(decoded.operator_states.is_empty());
553        assert!(decoded.source_offsets.is_empty());
554    }
555
556    #[test]
557    fn test_serialize_deserialize_with_operator_states() {
558        let id = CheckpointId::new("s", 1);
559        let mut state = CheckpointState::new(id);
560        state.set_operator_state("agg_op", vec![1, 2, 3, 4]);
561        state.set_operator_state("filter_op", vec![9, 8]);
562
563        let bytes = state.serialize();
564        let decoded = CheckpointState::deserialize("s", &bytes).expect("should succeed");
565        assert_eq!(
566            decoded.operator_states.get("agg_op"),
567            Some(&vec![1, 2, 3, 4])
568        );
569        assert_eq!(decoded.operator_states.get("filter_op"), Some(&vec![9, 8]));
570    }
571
572    #[test]
573    fn test_serialize_deserialize_with_source_offsets() {
574        let id = CheckpointId::new("s", 7);
575        let mut state = CheckpointState::new(id);
576        state.set_source_offset("kafka-topic-0", 1_234_567);
577        state.set_source_offset("file-source", 4_096);
578
579        let bytes = state.serialize();
580        let decoded = CheckpointState::deserialize("s", &bytes).expect("should succeed");
581        assert_eq!(
582            decoded.source_offsets.get("kafka-topic-0"),
583            Some(&1_234_567)
584        );
585        assert_eq!(decoded.source_offsets.get("file-source"), Some(&4_096));
586    }
587
588    #[test]
589    fn test_deserialize_truncated_data_returns_error() {
590        let result = CheckpointState::deserialize("s", &[0u8; 10]);
591        assert!(result.is_err());
592    }
593
594    #[test]
595    fn test_deserialize_empty_slice_returns_error() {
596        let result = CheckpointState::deserialize("s", &[]);
597        assert!(result.is_err());
598    }
599
600    // ── InMemoryCheckpointStore ───────────────────────────────────────────────
601
602    #[test]
603    fn test_store_save_and_latest() {
604        let mut store = InMemoryCheckpointStore::new(5);
605        let id = CheckpointId::new("stream-x", 10);
606        let state = CheckpointState::new(id);
607        store.save(state).expect("save should succeed");
608        let latest = store.latest("stream-x").expect("should be present");
609        assert_eq!(latest.id.sequence, 10);
610    }
611
612    #[test]
613    fn test_store_latest_none_when_empty() {
614        let store = InMemoryCheckpointStore::new(5);
615        assert!(store.latest("unknown").is_none());
616    }
617
618    #[test]
619    fn test_store_trims_to_max_per_stream() {
620        let mut store = InMemoryCheckpointStore::new(3);
621        for i in 0u64..6 {
622            let id = CheckpointId::new("s", i);
623            store.save(CheckpointState::new(id)).expect("save ok");
624        }
625        assert_eq!(store.checkpoint_count("s"), 3);
626        // The oldest should have been evicted; latest should be seq=5
627        assert_eq!(
628            store
629                .latest("s")
630                .expect("latest checkpoint for stream 's'")
631                .id
632                .sequence,
633            5
634        );
635    }
636
637    #[test]
638    fn test_store_delete_before() {
639        let mut store = InMemoryCheckpointStore::new(10);
640        for i in 0u64..5 {
641            let id = CheckpointId::new("s", i * 10);
642            store.save(CheckpointState::new(id)).expect("save ok");
643        }
644        // Delete all checkpoints with sequence < 20
645        store.delete_before("s", 20);
646        let remaining = store.list("s");
647        assert!(remaining.iter().all(|c| c.id.sequence >= 20));
648    }
649
650    #[test]
651    fn test_store_multiple_streams_independent() {
652        let mut store = InMemoryCheckpointStore::new(5);
653        for seq in [1u64, 2, 3] {
654            store
655                .save(CheckpointState::new(CheckpointId::new("stream-a", seq)))
656                .expect("ok");
657            store
658                .save(CheckpointState::new(CheckpointId::new(
659                    "stream-b",
660                    seq * 10,
661                )))
662                .expect("ok");
663        }
664        assert_eq!(store.checkpoint_count("stream-a"), 3);
665        assert_eq!(store.checkpoint_count("stream-b"), 3);
666        assert_eq!(
667            store
668                .latest("stream-a")
669                .expect("latest checkpoint for stream-a")
670                .id
671                .sequence,
672            3
673        );
674        assert_eq!(
675            store
676                .latest("stream-b")
677                .expect("latest checkpoint for stream-b")
678                .id
679                .sequence,
680            30
681        );
682    }
683
684    // ── CheckpointManager ────────────────────────────────────────────────────
685
686    #[test]
687    fn test_manager_triggers_checkpoint_at_interval() {
688        let store = InMemoryCheckpointStore::new(10);
689        let mut mgr = CheckpointManager::new(store, 100);
690        // Events 0-98: no checkpoint yet
691        for seq in 0u64..99 {
692            let triggered = mgr.on_event("s", seq, 0).expect("on_event ok");
693            assert!(!triggered);
694        }
695        // Event 100: checkpoint fires
696        let triggered = mgr.on_event("s", 100, 0).expect("on_event ok");
697        assert!(triggered);
698        assert_eq!(mgr.total_checkpoints(), 1);
699    }
700
701    #[test]
702    fn test_manager_recover_returns_last_sequence() {
703        let store = InMemoryCheckpointStore::new(10);
704        let mut mgr = CheckpointManager::new(store, 50);
705        mgr.on_event("s", 50, 0).expect("ok");
706        mgr.on_event("s", 100, 0).expect("ok");
707        let seq = mgr
708            .recover("s")
709            .expect("recover ok")
710            .expect("should recover");
711        assert_eq!(seq, 100);
712    }
713
714    #[test]
715    fn test_manager_recover_none_before_first_checkpoint() {
716        let store = InMemoryCheckpointStore::new(5);
717        let mgr = CheckpointManager::new(store, 100);
718        assert!(mgr.recover("s").expect("recover ok").is_none());
719    }
720
721    // ── FileCheckpointStore durability ────────────────────────────────────────
722
723    fn unique_dir(tag: &str) -> std::path::PathBuf {
724        std::env::temp_dir().join(format!(
725            "oxigeo_streaming_ckpt_{}_{}_{}",
726            tag,
727            std::process::id(),
728            SystemTime::now()
729                .duration_since(std::time::UNIX_EPOCH)
730                .map(|d| d.as_nanos())
731                .unwrap_or(0)
732        ))
733    }
734
735    #[test]
736    fn test_file_store_save_and_latest_roundtrip() {
737        let dir = unique_dir("roundtrip");
738        let mut store = FileCheckpointStore::new(&dir, 5).expect("store");
739        assert!(store.latest("s").expect("latest").is_none());
740
741        let mut state = CheckpointState::new(CheckpointId::new("s", 42));
742        state.watermark_ns = 123_456_789;
743        state.event_count = 42;
744        state.set_operator_state("agg", vec![1, 2, 3]);
745        state.set_source_offset("src-0", 9_999);
746        store.save(state).expect("save");
747
748        let loaded = store.latest("s").expect("latest").expect("present");
749        assert_eq!(loaded.id.sequence, 42);
750        assert_eq!(loaded.watermark_ns, 123_456_789);
751        assert_eq!(loaded.event_count, 42);
752        assert_eq!(loaded.operator_states.get("agg"), Some(&vec![1, 2, 3]));
753        assert_eq!(loaded.source_offsets.get("src-0"), Some(&9_999));
754
755        std::fs::remove_dir_all(&dir).ok();
756    }
757
758    #[test]
759    fn test_file_checkpoint_store_survives_restart() {
760        let dir = unique_dir("restart");
761        // First "process": drive events through a manager backed by the file store.
762        {
763            let store = FileCheckpointStore::new(&dir, 10).expect("store");
764            let mut mgr = CheckpointManager::new(store, 10);
765            for seq in 0u64..=25 {
766                mgr.on_event("stream-a", seq, seq * 1_000)
767                    .expect("on_event");
768            }
769            // Checkpoints fired at sequence 10 and 20.
770            assert_eq!(mgr.total_checkpoints(), 2);
771            // Manager (and its in-RAM counters) dropped here — simulating a crash.
772        }
773
774        // Second "process": a brand-new store + manager over the same directory
775        // must recover the last durably-written checkpoint.
776        let store = FileCheckpointStore::new(&dir, 10).expect("store");
777        let mgr = CheckpointManager::new(store, 10);
778        let resume = mgr
779            .recover("stream-a")
780            .expect("recover ok")
781            .expect("should recover a checkpoint that survived the restart");
782        assert_eq!(resume, 20);
783
784        std::fs::remove_dir_all(&dir).ok();
785    }
786
787    #[test]
788    fn test_file_store_prunes_to_max_per_stream() {
789        let dir = unique_dir("prune");
790        let mut store = FileCheckpointStore::new(&dir, 3).expect("store");
791        for seq in [10u64, 20, 30, 40, 50] {
792            store
793                .save(CheckpointState::new(CheckpointId::new("s", seq)))
794                .expect("save");
795        }
796        // Only the 3 newest survive; latest is 50.
797        let entries = store.entries("s").expect("entries");
798        assert_eq!(entries.len(), 3);
799        assert_eq!(
800            store
801                .latest("s")
802                .expect("latest")
803                .expect("present")
804                .id
805                .sequence,
806            50
807        );
808        std::fs::remove_dir_all(&dir).ok();
809    }
810
811    #[test]
812    fn test_manager_total_checkpoints_counter() {
813        let store = InMemoryCheckpointStore::new(10);
814        let mut mgr = CheckpointManager::new(store, 10);
815        for seq in (0u64..=50).step_by(1) {
816            mgr.on_event("s", seq, 0).expect("ok");
817        }
818        // Checkpoints at seq 10, 20, 30, 40, 50 = 5
819        assert_eq!(mgr.total_checkpoints(), 5);
820    }
821
822    #[test]
823    fn test_checkpoint_state_full_round_trip() {
824        let id = CheckpointId::new("full-test", 77);
825        let mut state = CheckpointState::new(id);
826        state.watermark_ns = 1_700_000_000_000_000_000;
827        state.event_count = 77;
828        state.set_operator_state("window_op", b"window_state_data".to_vec());
829        state.set_source_offset("source-0", 8192);
830        state.metadata.insert("app_version".into(), "1.2.3".into());
831
832        let bytes = state.serialize();
833        let decoded =
834            CheckpointState::deserialize("full-test", &bytes).expect("round-trip should succeed");
835
836        assert_eq!(decoded.id.sequence, 77);
837        assert_eq!(decoded.watermark_ns, 1_700_000_000_000_000_000);
838        assert_eq!(decoded.event_count, 77);
839        assert_eq!(
840            decoded.operator_states.get("window_op"),
841            Some(&b"window_state_data".to_vec())
842        );
843        assert_eq!(decoded.source_offsets.get("source-0"), Some(&8192u64));
844    }
845}