scirs2_io/streaming/
checkpoint.rs1use std::collections::HashMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use crate::error::{IoError, Result};
28
29pub trait CheckpointableState: Sized {
35 fn serialize(&self) -> Vec<u8>;
37
38 fn deserialize(data: &[u8]) -> Result<Self>;
44}
45
46impl CheckpointableState for Vec<f64> {
51 fn serialize(&self) -> Vec<u8> {
52 let mut out = Vec::with_capacity(8 + self.len() * 8);
53 out.extend_from_slice(&(self.len() as u64).to_le_bytes());
54 for &v in self {
55 out.extend_from_slice(&v.to_le_bytes());
56 }
57 out
58 }
59
60 fn deserialize(data: &[u8]) -> Result<Self> {
61 if data.len() < 8 {
62 return Err(IoError::DeserializationError(
63 "Vec<f64> checkpoint too short".to_string(),
64 ));
65 }
66 let len = u64::from_le_bytes(
67 data[..8]
68 .try_into()
69 .map_err(|_| IoError::DeserializationError("bad length bytes".to_string()))?,
70 ) as usize;
71 let expected = 8 + len * 8;
72 if data.len() < expected {
73 return Err(IoError::DeserializationError(format!(
74 "Vec<f64> checkpoint: expected {expected} bytes, got {}",
75 data.len()
76 )));
77 }
78 let mut vec = Vec::with_capacity(len);
79 for i in 0..len {
80 let start = 8 + i * 8;
81 let bytes: [u8; 8] = data[start..start + 8]
82 .try_into()
83 .map_err(|_| IoError::DeserializationError("bad f64 bytes".to_string()))?;
84 vec.push(f64::from_le_bytes(bytes));
85 }
86 Ok(vec)
87 }
88}
89
90impl CheckpointableState for HashMap<String, f64> {
91 fn serialize(&self) -> Vec<u8> {
92 let mut out = Vec::new();
94 out.extend_from_slice(&(self.len() as u64).to_le_bytes());
95 let mut entries: Vec<(&String, &f64)> = self.iter().collect();
97 entries.sort_by_key(|(k, _)| k.as_str());
98 for (k, &v) in entries {
99 let kb = k.as_bytes();
100 out.extend_from_slice(&(kb.len() as u64).to_le_bytes());
101 out.extend_from_slice(kb);
102 out.extend_from_slice(&v.to_le_bytes());
103 }
104 out
105 }
106
107 fn deserialize(data: &[u8]) -> Result<Self> {
108 if data.len() < 8 {
109 return Err(IoError::DeserializationError(
110 "HashMap checkpoint too short".to_string(),
111 ));
112 }
113 let n = u64::from_le_bytes(
114 data[..8]
115 .try_into()
116 .map_err(|_| IoError::DeserializationError("bad n bytes".to_string()))?,
117 ) as usize;
118 let mut cursor = 8usize;
119 let mut map = HashMap::with_capacity(n);
120 for _ in 0..n {
121 if cursor + 8 > data.len() {
122 return Err(IoError::DeserializationError(
123 "HashMap checkpoint truncated (key len)".to_string(),
124 ));
125 }
126 let key_len = u64::from_le_bytes(
127 data[cursor..cursor + 8]
128 .try_into()
129 .map_err(|_| IoError::DeserializationError("bad key len".to_string()))?,
130 ) as usize;
131 cursor += 8;
132 if cursor + key_len + 8 > data.len() {
133 return Err(IoError::DeserializationError(
134 "HashMap checkpoint truncated (key/value)".to_string(),
135 ));
136 }
137 let key = String::from_utf8(data[cursor..cursor + key_len].to_vec()).map_err(|e| {
138 IoError::DeserializationError(format!("non-UTF8 key in HashMap checkpoint: {e}"))
139 })?;
140 cursor += key_len;
141 let value = f64::from_le_bytes(
142 data[cursor..cursor + 8]
143 .try_into()
144 .map_err(|_| IoError::DeserializationError("bad f64 value".to_string()))?,
145 );
146 cursor += 8;
147 map.insert(key, value);
148 }
149 Ok(map)
150 }
151}
152
153#[derive(Debug, Clone)]
159pub struct CheckpointConfig {
160 pub interval_ms: u64,
162 pub storage_path: String,
164 pub max_checkpoints: usize,
166}
167
168impl Default for CheckpointConfig {
169 fn default() -> Self {
170 Self {
171 interval_ms: 60_000,
172 storage_path: {
173 let mut p = std::env::temp_dir();
174 p.push("scirs2_checkpoint");
175 p.to_string_lossy().into_owned()
176 },
177 max_checkpoints: 3,
178 }
179 }
180}
181
182#[derive(Debug, Clone)]
188pub struct CheckpointManager {
189 config: CheckpointConfig,
190}
191
192impl CheckpointManager {
193 pub fn new(config: CheckpointConfig) -> Self {
195 Self { config }
196 }
197
198 pub fn with_defaults() -> Self {
200 Self::new(CheckpointConfig::default())
201 }
202
203 pub fn save(&self, state_id: &str, data: &[u8]) -> Result<String> {
211 let dir = Path::new(&self.config.storage_path);
212 fs::create_dir_all(dir).map_err(|e| {
213 IoError::FileError(format!(
214 "Cannot create checkpoint directory {}: {e}",
215 dir.display()
216 ))
217 })?;
218
219 let ts_ms = current_time_ms();
220 let filename = format!("{state_id}_{ts_ms}.ckpt");
221 let path = dir.join(&filename);
222
223 fs::write(&path, data).map_err(|e| {
224 IoError::FileError(format!(
225 "Failed to write checkpoint {}: {e}",
226 path.display()
227 ))
228 })?;
229
230 let abs = path
231 .canonicalize()
232 .map(|p| p.to_string_lossy().into_owned())
233 .unwrap_or_else(|_| path.to_string_lossy().into_owned());
234
235 self.cleanup_old(state_id);
236 Ok(abs)
237 }
238
239 pub fn load_latest(&self, state_id: &str) -> Result<Vec<u8>> {
245 let files = self.list_checkpoints(state_id);
246 let latest = files.last().ok_or_else(|| {
247 IoError::NotFound(format!("No checkpoint found for state_id '{state_id}'"))
248 })?;
249 fs::read(latest)
250 .map_err(|e| IoError::FileError(format!("Failed to read checkpoint {latest}: {e}")))
251 }
252
253 pub fn cleanup_old(&self, state_id: &str) {
255 let files = self.list_checkpoints(state_id);
256 if files.len() <= self.config.max_checkpoints {
257 return;
258 }
259 let to_delete = files.len() - self.config.max_checkpoints;
260 for path in files.iter().take(to_delete) {
261 let _ = fs::remove_file(path);
262 }
263 }
264
265 pub fn list_checkpoints(&self, state_id: &str) -> Vec<String> {
267 let dir = Path::new(&self.config.storage_path);
268 let prefix = format!("{state_id}_");
269 let suffix = ".ckpt";
270
271 let mut paths: Vec<PathBuf> = match fs::read_dir(dir) {
272 Ok(entries) => entries
273 .filter_map(|e| e.ok())
274 .map(|e| e.path())
275 .filter(|p| {
276 p.file_name()
277 .and_then(|n| n.to_str())
278 .map(|n| n.starts_with(&prefix) && n.ends_with(suffix))
279 .unwrap_or(false)
280 })
281 .collect(),
282 Err(_) => Vec::new(),
283 };
284
285 paths.sort_by_key(|p| extract_timestamp_ms(p));
287 paths
288 .into_iter()
289 .map(|p| p.to_string_lossy().into_owned())
290 .collect()
291 }
292}
293
294#[derive(Debug, Clone)]
301pub struct CheckpointBarrier {
302 pub checkpoint_id: u64,
304 pub timestamp_ms: u64,
306}
307
308impl CheckpointBarrier {
309 pub fn new(checkpoint_id: u64) -> Self {
311 Self {
312 checkpoint_id,
313 timestamp_ms: current_time_ms(),
314 }
315 }
316
317 pub fn with_timestamp(checkpoint_id: u64, timestamp_ms: u64) -> Self {
319 Self {
320 checkpoint_id,
321 timestamp_ms,
322 }
323 }
324}
325
326fn current_time_ms() -> u64 {
331 SystemTime::now()
332 .duration_since(UNIX_EPOCH)
333 .map(|d| d.as_millis() as u64)
334 .unwrap_or(0)
335}
336
337fn extract_timestamp_ms(path: &Path) -> u64 {
339 path.file_name()
340 .and_then(|n| n.to_str())
341 .and_then(|name| {
342 let without_ext = name.strip_suffix(".ckpt")?;
344 let pos = without_ext.rfind('_')?;
345 without_ext[pos + 1..].parse::<u64>().ok()
346 })
347 .unwrap_or(0)
348}
349
350#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::collections::HashMap;
358 use std::env;
359
360 fn temp_checkpoint_manager(suffix: &str) -> CheckpointManager {
361 let dir = env::temp_dir().join(format!("scirs2_ckpt_test_{suffix}"));
362 CheckpointManager::new(CheckpointConfig {
363 storage_path: dir.to_string_lossy().into_owned(),
364 max_checkpoints: 3,
365 ..Default::default()
366 })
367 }
368
369 #[test]
370 fn test_save_and_load_latest() {
371 let mgr = temp_checkpoint_manager("save_load");
372 let data = b"hello checkpoint world".to_vec();
373 mgr.save("job1", &data).expect("save should succeed");
374 let loaded = mgr.load_latest("job1").expect("load should succeed");
375 assert_eq!(loaded, data);
376 }
377
378 #[test]
379 fn test_cleanup_keeps_max_checkpoints() {
380 let mgr = temp_checkpoint_manager("cleanup");
381 for i in 0..5u64 {
383 std::thread::sleep(std::time::Duration::from_millis(2));
385 mgr.save("job2", &i.to_le_bytes()).expect("save");
386 }
387 let files = mgr.list_checkpoints("job2");
388 assert!(
389 files.len() <= mgr.config.max_checkpoints,
390 "Expected ≤ {} checkpoints, found {}",
391 mgr.config.max_checkpoints,
392 files.len()
393 );
394 }
395
396 #[test]
397 fn test_load_latest_no_checkpoint_returns_error() {
398 let mgr = temp_checkpoint_manager("missing");
399 assert!(mgr.load_latest("nonexistent_id").is_err());
400 }
401
402 #[test]
403 fn test_vec_f64_round_trip() {
404 let v = vec![1.0_f64, 2.5, std::f64::consts::PI, -42.0];
405 let bytes = v.serialize();
406 let restored = Vec::<f64>::deserialize(&bytes).expect("deserialize");
407 assert_eq!(v, restored);
408 }
409
410 #[test]
411 fn test_hashmap_string_f64_round_trip() {
412 let mut m = HashMap::new();
413 m.insert("alpha".to_string(), 1.5_f64);
414 m.insert("beta".to_string(), -0.5_f64);
415 m.insert("gamma".to_string(), std::f64::consts::E);
416 let bytes = m.serialize();
417 let restored = HashMap::<String, f64>::deserialize(&bytes).expect("deserialize");
418 assert_eq!(m, restored);
419 }
420
421 #[test]
422 fn test_checkpoint_barrier_fields() {
423 let barrier = CheckpointBarrier::with_timestamp(42, 999_000);
424 assert_eq!(barrier.checkpoint_id, 42);
425 assert_eq!(barrier.timestamp_ms, 999_000);
426 }
427}