1use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::time::SystemTime;
24
25use crate::error::StreamingError;
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct CheckpointId {
32 pub stream_id: String,
34 pub sequence: u64,
36 pub created_at: SystemTime,
38}
39
40impl CheckpointId {
41 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#[derive(Debug, Clone)]
74pub struct CheckpointState {
75 pub id: CheckpointId,
77 pub operator_states: HashMap<String, Vec<u8>>,
79 pub source_offsets: HashMap<String, u64>,
81 pub watermark_ns: u64,
83 pub event_count: u64,
85 pub metadata: HashMap<String, String>,
87}
88
89impl CheckpointState {
90 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 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 pub fn set_source_offset(&mut self, source: impl Into<String>, offset: u64) {
109 self.source_offsets.insert(source.into(), offset);
110 }
111
112 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 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 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 pub fn deserialize(stream_id: &str, data: &[u8]) -> Result<Self, StreamingError> {
147 const HEADER: usize = 24; 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 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 if cursor + 4 > data.len() {
185 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 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 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
241pub trait CheckpointStore {
250 fn save(&mut self, state: CheckpointState) -> Result<(), StreamingError>;
253
254 fn latest(&self, stream_id: &str) -> Result<Option<CheckpointState>, StreamingError>;
257}
258
259pub struct FileCheckpointStore {
269 root: PathBuf,
270 max_per_stream: usize,
272}
273
274impl FileCheckpointStore {
275 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 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 self.stream_dir(stream_id)
312 .join(format!("checkpoint-{sequence:020}.ckpt"))
313 }
314
315 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 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 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
379pub struct InMemoryCheckpointStore {
387 checkpoints: HashMap<String, Vec<CheckpointState>>,
389 max_per_stream: usize,
391}
392
393impl InMemoryCheckpointStore {
394 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 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 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 pub fn latest(&self, stream_id: &str) -> Option<&CheckpointState> {
419 self.checkpoints.get(stream_id)?.last()
420 }
421
422 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 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 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
456pub struct CheckpointManager<S: CheckpointStore = InMemoryCheckpointStore> {
464 store: S,
465 checkpoint_interval: u64,
467 next_checkpoint_at: u64,
468 total_checkpoints: u64,
469}
470
471impl<S: CheckpointStore> CheckpointManager<S> {
472 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 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 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 pub fn total_checkpoints(&self) -> u64 {
523 self.total_checkpoints
524 }
525
526 pub fn store(&self) -> &S {
528 &self.store
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[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 #[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 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 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 #[test]
687 fn test_manager_triggers_checkpoint_at_interval() {
688 let store = InMemoryCheckpointStore::new(10);
689 let mut mgr = CheckpointManager::new(store, 100);
690 for seq in 0u64..99 {
692 let triggered = mgr.on_event("s", seq, 0).expect("on_event ok");
693 assert!(!triggered);
694 }
695 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 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 {
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 assert_eq!(mgr.total_checkpoints(), 2);
771 }
773
774 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 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 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}