use crate::error::{Result, StreamingError};
use crate::state::operator_state::DynOperatorState;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::sleep;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointMetadata {
pub id: u64,
pub timestamp: DateTime<Utc>,
pub size_bytes: usize,
pub operator_states: HashMap<String, Vec<u8>>,
pub success: bool,
pub duration: Duration,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CheckpointBarrier {
pub id: u64,
pub timestamp: DateTime<Utc>,
}
impl CheckpointBarrier {
pub fn new(id: u64) -> Self {
Self {
id,
timestamp: Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointConfig {
pub interval: Duration,
pub min_pause: Duration,
pub max_concurrent: usize,
pub unaligned: bool,
pub timeout: Duration,
pub storage_path: Option<PathBuf>,
}
impl Default for CheckpointConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(60),
min_pause: Duration::from_secs(10),
max_concurrent: 1,
unaligned: false,
timeout: Duration::from_secs(300),
storage_path: None,
}
}
}
pub trait CheckpointStorage: Send + Sync {
fn store(&self, checkpoint: &Checkpoint) -> Result<()>;
fn load(&self, checkpoint_id: u64) -> Result<Option<Checkpoint>>;
fn delete(&self, checkpoint_id: u64) -> Result<()>;
fn list(&self) -> Result<Vec<u64>>;
fn latest(&self) -> Result<Option<u64>>;
}
pub struct FileCheckpointStorage {
root: PathBuf,
}
impl FileCheckpointStorage {
pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
let root = root.into();
std::fs::create_dir_all(&root)?;
Ok(Self { root })
}
fn checkpoint_path(&self, checkpoint_id: u64) -> PathBuf {
self.root.join(format!("checkpoint-{checkpoint_id}.ckpt"))
}
fn parse_id(name: &Path) -> Option<u64> {
let name = name.file_name()?.to_str()?;
let rest = name.strip_prefix("checkpoint-")?;
let id = rest.strip_suffix(".ckpt")?;
id.parse::<u64>().ok()
}
}
impl CheckpointStorage for FileCheckpointStorage {
fn store(&self, checkpoint: &Checkpoint) -> Result<()> {
let bytes = serde_json::to_vec(checkpoint)?;
let final_path = self.checkpoint_path(checkpoint.id());
let tmp_path = final_path.with_extension("ckpt.tmp");
std::fs::write(&tmp_path, &bytes)?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(())
}
fn load(&self, checkpoint_id: u64) -> Result<Option<Checkpoint>> {
let path = self.checkpoint_path(checkpoint_id);
match std::fs::read(&path) {
Ok(bytes) => {
let checkpoint: Checkpoint = serde_json::from_slice(&bytes)?;
Ok(Some(checkpoint))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn delete(&self, checkpoint_id: u64) -> Result<()> {
let path = self.checkpoint_path(checkpoint_id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
fn list(&self) -> Result<Vec<u64>> {
let mut ids = Vec::new();
for entry in std::fs::read_dir(&self.root)? {
let entry = entry?;
if let Some(id) = Self::parse_id(&entry.path()) {
ids.push(id);
}
}
ids.sort_unstable();
Ok(ids)
}
fn latest(&self) -> Result<Option<u64>> {
Ok(self.list()?.into_iter().max())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub metadata: CheckpointMetadata,
pub data: Vec<u8>,
}
impl Checkpoint {
pub fn new(id: u64, data: Vec<u8>) -> Self {
let size_bytes = data.len();
Self {
metadata: CheckpointMetadata {
id,
timestamp: Utc::now(),
size_bytes,
operator_states: HashMap::new(),
success: true,
duration: Duration::ZERO,
},
data,
}
}
pub fn id(&self) -> u64 {
self.metadata.id
}
pub fn size(&self) -> usize {
self.metadata.size_bytes
}
}
pub struct CheckpointCoordinator {
config: CheckpointConfig,
next_checkpoint_id: Arc<RwLock<u64>>,
active_checkpoints: Arc<RwLock<HashMap<u64, CheckpointMetadata>>>,
completed_checkpoints: Arc<RwLock<Vec<u64>>>,
last_checkpoint_time: Arc<RwLock<Option<DateTime<Utc>>>>,
operators: Arc<RwLock<HashMap<String, Arc<dyn DynOperatorState>>>>,
storage: Option<Arc<dyn CheckpointStorage>>,
}
impl CheckpointCoordinator {
pub fn new(config: CheckpointConfig) -> Self {
Self {
config,
next_checkpoint_id: Arc::new(RwLock::new(0)),
active_checkpoints: Arc::new(RwLock::new(HashMap::new())),
completed_checkpoints: Arc::new(RwLock::new(Vec::new())),
last_checkpoint_time: Arc::new(RwLock::new(None)),
operators: Arc::new(RwLock::new(HashMap::new())),
storage: None,
}
}
pub fn with_storage(config: CheckpointConfig, storage: Arc<dyn CheckpointStorage>) -> Self {
let mut coordinator = Self::new(config);
coordinator.storage = Some(storage);
coordinator
}
pub fn from_config(config: CheckpointConfig) -> Result<Self> {
match &config.storage_path {
Some(path) => {
let storage = Arc::new(FileCheckpointStorage::new(path.clone())?);
Ok(Self::with_storage(config, storage))
}
None => Ok(Self::new(config)),
}
}
pub async fn register_operator(
&self,
name: impl Into<String>,
state: Arc<dyn DynOperatorState>,
) {
self.operators.write().await.insert(name.into(), state);
}
pub async fn operator_count(&self) -> usize {
self.operators.read().await.len()
}
pub async fn trigger_checkpoint(&self) -> Result<u64> {
let now = Utc::now();
let last_time = *self.last_checkpoint_time.read().await;
if let Some(last) = last_time {
let min_pause_chrono = match chrono::Duration::from_std(self.config.min_pause) {
Ok(duration) => duration,
Err(_) => chrono::Duration::zero(),
};
if now - last < min_pause_chrono {
return Err(StreamingError::CheckpointError(
"Minimum pause not elapsed".to_string(),
));
}
}
let active_count = self.active_checkpoints.read().await.len();
if active_count >= self.config.max_concurrent {
return Err(StreamingError::CheckpointError(
"Too many concurrent checkpoints".to_string(),
));
}
let handles: Vec<(String, Arc<dyn DynOperatorState>)> = {
let operators = self.operators.read().await;
operators
.iter()
.map(|(name, state)| (name.clone(), state.clone()))
.collect()
};
let mut operator_states = HashMap::with_capacity(handles.len());
let mut size_bytes = 0usize;
for (name, state) in handles {
let bytes = state.snapshot_boxed().await?;
size_bytes = size_bytes.saturating_add(bytes.len());
operator_states.insert(name, bytes);
}
let mut next_id = self.next_checkpoint_id.write().await;
let checkpoint_id = *next_id;
*next_id += 1;
let metadata = CheckpointMetadata {
id: checkpoint_id,
timestamp: now,
size_bytes,
operator_states,
success: false,
duration: Duration::ZERO,
};
self.active_checkpoints
.write()
.await
.insert(checkpoint_id, metadata);
*self.last_checkpoint_time.write().await = Some(now);
Ok(checkpoint_id)
}
async fn persist_checkpoint(&self, checkpoint_id: u64) -> Result<()> {
let Some(storage) = self.storage.clone() else {
return Ok(());
};
let metadata = {
let active = self.active_checkpoints.read().await;
active.get(&checkpoint_id).cloned().ok_or_else(|| {
StreamingError::CheckpointError(format!("Checkpoint {checkpoint_id} not found"))
})?
};
let data = oxicode::encode_to_vec(&metadata.operator_states)
.map_err(|e| StreamingError::SerializationError(e.to_string()))?;
let checkpoint = Checkpoint { metadata, data };
tokio::task::spawn_blocking(move || storage.store(&checkpoint))
.await
.map_err(|e| {
StreamingError::CheckpointError(format!("checkpoint store task failed: {e}"))
})??;
Ok(())
}
pub async fn checkpoint(&self) -> Result<u64> {
let checkpoint_id = self.trigger_checkpoint().await?;
if let Err(e) = self.persist_checkpoint(checkpoint_id).await {
let _ = self.complete_checkpoint(checkpoint_id, false).await;
return Err(e);
}
self.complete_checkpoint(checkpoint_id, true).await?;
Ok(checkpoint_id)
}
pub async fn restore_checkpoint(&self, checkpoint_id: u64) -> Result<()> {
let Some(storage) = self.storage.clone() else {
return Err(StreamingError::CheckpointError(
"no checkpoint storage configured".to_string(),
));
};
let checkpoint = tokio::task::spawn_blocking(move || storage.load(checkpoint_id))
.await
.map_err(|e| {
StreamingError::CheckpointError(format!("checkpoint load task failed: {e}"))
})??
.ok_or_else(|| {
StreamingError::CheckpointError(format!(
"Checkpoint {checkpoint_id} not found in storage"
))
})?;
let handles: Vec<(String, Arc<dyn DynOperatorState>)> = {
let operators = self.operators.read().await;
operators
.iter()
.map(|(name, state)| (name.clone(), state.clone()))
.collect()
};
for (name, state) in handles {
if let Some(bytes) = checkpoint.metadata.operator_states.get(&name) {
state.restore_boxed(bytes).await?;
}
}
Ok(())
}
pub async fn complete_checkpoint(&self, checkpoint_id: u64, success: bool) -> Result<()> {
let mut active = self.active_checkpoints.write().await;
if let Some(mut metadata) = active.remove(&checkpoint_id) {
metadata.success = success;
metadata.duration = match (Utc::now() - metadata.timestamp).to_std() {
Ok(duration) => duration,
Err(_) => Duration::ZERO,
};
if success {
self.completed_checkpoints.write().await.push(checkpoint_id);
}
Ok(())
} else {
Err(StreamingError::CheckpointError(format!(
"Checkpoint {} not found",
checkpoint_id
)))
}
}
pub async fn active_count(&self) -> usize {
self.active_checkpoints.read().await.len()
}
pub async fn active_metadata(&self, checkpoint_id: u64) -> Option<CheckpointMetadata> {
self.active_checkpoints
.read()
.await
.get(&checkpoint_id)
.cloned()
}
pub async fn completed_count(&self) -> usize {
self.completed_checkpoints.read().await.len()
}
pub async fn latest_checkpoint(&self) -> Option<u64> {
self.completed_checkpoints.read().await.last().copied()
}
pub async fn clear_old_checkpoints(&self, keep_count: usize) {
let mut completed = self.completed_checkpoints.write().await;
if completed.len() > keep_count {
let to_remove = completed.len() - keep_count;
completed.drain(0..to_remove);
}
}
pub async fn start_periodic_checkpointing(self: Arc<Self>) {
let interval = self.config.interval;
let timeout = self.config.timeout;
tokio::spawn(async move {
loop {
sleep(interval).await;
match tokio::time::timeout(timeout, self.checkpoint()).await {
Ok(Ok(id)) => {
tracing::info!("Completed checkpoint {}", id);
}
Ok(Err(e)) => {
tracing::warn!("Checkpoint failed: {}", e);
}
Err(_) => {
tracing::error!("Checkpoint timed out after {:?}", timeout);
}
}
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::operator_state::{BroadcastState, OperatorState};
struct FailingOperator;
impl OperatorState for FailingOperator {
async fn snapshot(&self) -> Result<Vec<u8>> {
Err(StreamingError::StateError("snapshot failed".to_string()))
}
async fn restore(&self, _snapshot: &[u8]) -> Result<()> {
Ok(())
}
}
struct FailingStorage;
impl CheckpointStorage for FailingStorage {
fn store(&self, _checkpoint: &Checkpoint) -> Result<()> {
Err(StreamingError::CheckpointError("store failed".to_string()))
}
fn load(&self, _checkpoint_id: u64) -> Result<Option<Checkpoint>> {
Ok(None)
}
fn delete(&self, _checkpoint_id: u64) -> Result<()> {
Ok(())
}
fn list(&self) -> Result<Vec<u64>> {
Ok(Vec::new())
}
fn latest(&self) -> Result<Option<u64>> {
Ok(None)
}
}
fn no_pause_config() -> CheckpointConfig {
CheckpointConfig {
min_pause: Duration::ZERO,
max_concurrent: 4,
..Default::default()
}
}
#[tokio::test]
async fn test_checkpoint_captures_operator_state() {
let coordinator = CheckpointCoordinator::new(no_pause_config());
let state = Arc::new(BroadcastState::new());
state.put(vec![1], vec![42]).await;
state.put(vec![2], vec![43]).await;
coordinator
.register_operator("op1", state.clone() as Arc<dyn DynOperatorState>)
.await;
assert_eq!(coordinator.operator_count().await, 1);
let id = coordinator
.trigger_checkpoint()
.await
.expect("trigger should succeed");
let metadata = coordinator
.active_metadata(id)
.await
.expect("active metadata should exist");
assert!(metadata.operator_states.contains_key("op1"));
assert!(!metadata.operator_states["op1"].is_empty());
assert!(metadata.size_bytes > 0);
}
#[tokio::test]
async fn test_checkpoint_persist_and_restore() {
let dir = tempfile::tempdir().expect("temp dir");
let storage: Arc<dyn CheckpointStorage> =
Arc::new(FileCheckpointStorage::new(dir.path().to_path_buf()).expect("storage"));
let coordinator = CheckpointCoordinator::with_storage(no_pause_config(), storage.clone());
let state = Arc::new(BroadcastState::new());
state.put(vec![7], vec![99]).await;
coordinator
.register_operator("opA", state as Arc<dyn DynOperatorState>)
.await;
let id = coordinator
.checkpoint()
.await
.expect("durable checkpoint should succeed");
assert_eq!(coordinator.completed_count().await, 1);
assert_eq!(coordinator.active_count().await, 0);
let loaded = storage.load(id).expect("load").expect("checkpoint present");
assert_eq!(loaded.metadata.operator_states["opA"], {
let tmp = BroadcastState::new();
tmp.put(vec![7], vec![99]).await;
tmp.snapshot().await.expect("snapshot")
});
let recovered = CheckpointCoordinator::with_storage(no_pause_config(), storage.clone());
let restored_state = Arc::new(BroadcastState::new());
recovered
.register_operator("opA", restored_state.clone() as Arc<dyn DynOperatorState>)
.await;
recovered
.restore_checkpoint(id)
.await
.expect("restore should succeed");
assert_eq!(restored_state.get(&[7]).await, Some(vec![99]));
}
#[tokio::test]
async fn test_checkpoint_store_failure_reports_failure() {
let coordinator =
CheckpointCoordinator::with_storage(no_pause_config(), Arc::new(FailingStorage));
let state = Arc::new(BroadcastState::new());
state.put(vec![1], vec![1]).await;
coordinator
.register_operator("op1", state as Arc<dyn DynOperatorState>)
.await;
let result = coordinator.checkpoint().await;
assert!(result.is_err(), "store failure must surface as an error");
assert_eq!(coordinator.completed_count().await, 0);
assert_eq!(coordinator.active_count().await, 0);
}
#[tokio::test]
async fn test_checkpoint_snapshot_failure_reports_failure() {
let coordinator = CheckpointCoordinator::new(no_pause_config());
coordinator
.register_operator(
"bad",
Arc::new(FailingOperator) as Arc<dyn DynOperatorState>,
)
.await;
let result = coordinator.checkpoint().await;
assert!(result.is_err(), "snapshot failure must surface as an error");
assert_eq!(coordinator.completed_count().await, 0);
assert_eq!(coordinator.active_count().await, 0);
}
#[tokio::test]
async fn test_file_checkpoint_storage_roundtrip() {
let dir = tempfile::tempdir().expect("temp dir");
let storage = FileCheckpointStorage::new(dir.path().to_path_buf()).expect("storage");
assert_eq!(storage.list().expect("list"), Vec::<u64>::new());
assert_eq!(storage.latest().expect("latest"), None);
assert!(storage.load(0).expect("load missing").is_none());
let checkpoint = Checkpoint::new(3, vec![1, 2, 3, 4]);
storage.store(&checkpoint).expect("store");
assert_eq!(storage.list().expect("list"), vec![3]);
assert_eq!(storage.latest().expect("latest"), Some(3));
let loaded = storage.load(3).expect("load").expect("present");
assert_eq!(loaded.id(), 3);
assert_eq!(loaded.data, vec![1, 2, 3, 4]);
storage.delete(3).expect("delete");
assert!(storage.load(3).expect("load after delete").is_none());
assert_eq!(storage.list().expect("list"), Vec::<u64>::new());
}
#[tokio::test]
async fn test_checkpoint_creation() {
let data = vec![1, 2, 3, 4];
let checkpoint = Checkpoint::new(1, data.clone());
assert_eq!(checkpoint.id(), 1);
assert_eq!(checkpoint.size(), 4);
assert_eq!(checkpoint.data, data);
}
#[tokio::test]
async fn test_checkpoint_barrier() {
let barrier = CheckpointBarrier::new(1);
assert_eq!(barrier.id, 1);
}
#[tokio::test]
async fn test_checkpoint_coordinator() {
let config = CheckpointConfig {
min_pause: Duration::ZERO, max_concurrent: 2, ..Default::default()
};
let coordinator = CheckpointCoordinator::new(config);
let id1 = coordinator
.trigger_checkpoint()
.await
.expect("First checkpoint trigger should succeed");
assert_eq!(id1, 0);
let id2 = coordinator
.trigger_checkpoint()
.await
.expect("Second checkpoint trigger should succeed");
assert_eq!(id2, 1);
assert_eq!(coordinator.active_count().await, 2);
coordinator
.complete_checkpoint(id1, true)
.await
.expect("Checkpoint completion should succeed");
assert_eq!(coordinator.active_count().await, 1);
assert_eq!(coordinator.completed_count().await, 1);
}
#[tokio::test]
async fn test_checkpoint_min_pause() {
let config = CheckpointConfig {
min_pause: Duration::from_secs(60),
..Default::default()
};
let coordinator = CheckpointCoordinator::new(config);
coordinator
.trigger_checkpoint()
.await
.expect("First checkpoint should trigger successfully");
let result = coordinator.trigger_checkpoint().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_clear_old_checkpoints() {
let config = CheckpointConfig {
min_pause: Duration::ZERO, ..Default::default()
};
let coordinator = CheckpointCoordinator::new(config);
for _ in 0..5 {
let id = coordinator
.trigger_checkpoint()
.await
.expect("Checkpoint trigger should succeed in loop");
coordinator
.complete_checkpoint(id, true)
.await
.expect("Checkpoint completion should succeed in loop");
}
assert_eq!(coordinator.completed_count().await, 5);
coordinator.clear_old_checkpoints(2).await;
assert_eq!(coordinator.completed_count().await, 2);
}
}