1use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5
6use chrono::{DateTime, Utc};
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use khive_types::Hash32;
12
13use crate::context::FoldContext;
14use crate::error::FoldError;
15
16#[derive(Debug, Clone)]
18#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19pub struct Checkpoint<S> {
20 pub id: String,
22
23 pub state: S,
25
26 pub uuid: Uuid,
28
29 pub hash: Hash32,
31
32 pub entries_processed: usize,
34
35 pub context: FoldContext,
37
38 pub fold_version: usize,
40
41 pub created_at: DateTime<Utc>,
43}
44
45impl<S: Serialize> Checkpoint<S> {
46 #[allow(clippy::too_many_arguments)]
51 pub fn new(
52 id: impl Into<String>,
53 state: S,
54 uuid: Uuid,
55 entries_processed: usize,
56 context: FoldContext,
57 fold_version: usize,
58 ) -> Result<Self, FoldError> {
59 let bytes = serde_json::to_vec(&state)?;
60 let hash = Hash32::from_blake3(&bytes);
61 Ok(Self {
62 id: id.into(),
63 state,
64 uuid,
65 hash,
66 entries_processed,
67 context,
68 fold_version,
69 created_at: DateTime::<Utc>::default(),
72 })
73 }
74
75 #[allow(clippy::too_many_arguments)]
79 pub fn with_hash(
80 id: impl Into<String>,
81 state: S,
82 uuid: Uuid,
83 hash: Hash32,
84 entries_processed: usize,
85 context: FoldContext,
86 fold_version: usize,
87 ) -> Self {
88 Self {
89 id: id.into(),
90 state,
91 uuid,
92 hash,
93 entries_processed,
94 context,
95 fold_version,
96 created_at: DateTime::<Utc>::default(),
98 }
99 }
100}
101
102pub trait CheckpointStore<S> {
104 fn save(&self, checkpoint: Checkpoint<S>) -> Result<(), FoldError>
106 where
107 S: Clone + Serialize;
108
109 fn load(&self, id: &str) -> Result<Option<Checkpoint<S>>, FoldError>
111 where
112 S: Clone + Serialize;
113
114 fn load_latest(&self, prefix: &str) -> Result<Option<Checkpoint<S>>, FoldError>
116 where
117 S: Clone + Serialize;
118
119 fn delete(&self, id: &str) -> Result<(), FoldError>;
121
122 fn list(&self) -> Result<Vec<String>, FoldError>;
124}
125
126pub struct InMemoryCheckpointStore<S> {
128 inner: Arc<RwLock<HashMap<String, Checkpoint<S>>>>,
129}
130
131impl<S> InMemoryCheckpointStore<S> {
132 pub fn new() -> Self {
134 Self {
135 inner: Arc::new(RwLock::new(HashMap::new())),
136 }
137 }
138}
139
140impl<S> Default for InMemoryCheckpointStore<S> {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146impl<S: Clone + Send + Sync + Serialize + 'static> CheckpointStore<S>
147 for InMemoryCheckpointStore<S>
148{
149 fn save(&self, checkpoint: Checkpoint<S>) -> Result<(), FoldError>
150 where
151 S: Clone + Serialize,
152 {
153 let bytes = serde_json::to_vec(&checkpoint.state)?;
155 let computed = Hash32::from_blake3(&bytes);
156 let mut stored = checkpoint;
157 stored.hash = computed;
158
159 let mut guard = self
160 .inner
161 .write()
162 .map_err(|e| FoldError::LockPoisoned(e.to_string()))?;
163 guard.insert(stored.id.clone(), stored);
164 Ok(())
165 }
166
167 fn load(&self, id: &str) -> Result<Option<Checkpoint<S>>, FoldError>
168 where
169 S: Clone + Serialize,
170 {
171 let guard = self
172 .inner
173 .read()
174 .map_err(|e| FoldError::LockPoisoned(e.to_string()))?;
175 let Some(checkpoint) = guard.get(id).cloned() else {
176 return Ok(None);
177 };
178
179 let bytes = serde_json::to_vec(&checkpoint.state)?;
181 let computed = Hash32::from_blake3(&bytes);
182 if !checkpoint.hash.eq_ct(&computed) {
183 return Err(FoldError::IntegrityMismatch {
184 id: id.to_owned(),
185 stored: checkpoint.hash.to_string(),
186 computed: computed.to_string(),
187 });
188 }
189
190 Ok(Some(checkpoint))
191 }
192
193 fn load_latest(&self, prefix: &str) -> Result<Option<Checkpoint<S>>, FoldError>
194 where
195 S: Clone + Serialize,
196 {
197 let guard = self
198 .inner
199 .read()
200 .map_err(|e| FoldError::LockPoisoned(e.to_string()))?;
201
202 let latest = guard
203 .values()
204 .filter(|c| c.id.starts_with(prefix))
205 .max_by_key(|c| (c.created_at, c.uuid));
207
208 Ok(latest.cloned())
209 }
210
211 fn delete(&self, id: &str) -> Result<(), FoldError> {
212 let mut guard = self
213 .inner
214 .write()
215 .map_err(|e| FoldError::LockPoisoned(e.to_string()))?;
216 if guard.remove(id).is_none() {
217 return Err(FoldError::CheckpointNotFound(id.to_owned()));
218 }
219 Ok(())
220 }
221
222 fn list(&self) -> Result<Vec<String>, FoldError> {
223 let guard = self
224 .inner
225 .read()
226 .map_err(|e| FoldError::LockPoisoned(e.to_string()))?;
227 let keys: Vec<String> = guard.keys().cloned().collect();
228 Ok(sort_checkpoint_keys(keys))
229 }
230}
231
232pub fn sort_checkpoint_keys(mut keys: Vec<String>) -> Vec<String> {
236 keys.sort();
237 keys
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn sample_checkpoint(id: &str, entries: usize) -> Checkpoint<String> {
245 Checkpoint::new(
246 id,
247 format!("state-{entries}"),
248 Uuid::new_v4(),
249 entries,
250 FoldContext::new(),
251 1,
252 )
253 .expect("sample_checkpoint should not fail serialization")
254 }
255
256 #[test]
257 fn save_and_load_roundtrip() {
258 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
259 let ckpt = sample_checkpoint("my-index:ckpt-1", 100);
260 store.save(ckpt).unwrap();
261 let loaded = store.load("my-index:ckpt-1").unwrap().unwrap();
262 assert_eq!(loaded.state, "state-100");
263 assert_eq!(loaded.entries_processed, 100);
264 }
265
266 #[test]
267 fn load_missing_returns_none() {
268 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
269 assert!(store.load("nonexistent").unwrap().is_none());
270 }
271
272 #[test]
273 fn load_latest_returns_most_recent() {
274 use chrono::Duration;
275
276 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
277 let base = DateTime::<Utc>::default();
278
279 let mut ckpt1 = sample_checkpoint("idx:ckpt-1", 10);
282 ckpt1.created_at = base;
283 let mut ckpt2 = sample_checkpoint("idx:ckpt-2", 20);
284 ckpt2.created_at = base + Duration::milliseconds(5);
285 let mut ckpt3 = sample_checkpoint("idx:ckpt-3", 30);
286 ckpt3.created_at = base + Duration::milliseconds(10);
287
288 store.save(ckpt1).unwrap();
289 store.save(ckpt2).unwrap();
290 store.save(ckpt3).unwrap();
291
292 let latest = store.load_latest("idx").unwrap().unwrap();
293 assert_eq!(latest.entries_processed, 30);
294 }
295
296 #[test]
297 fn load_latest_no_match_returns_none() {
298 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
299 store.save(sample_checkpoint("other:ckpt-1", 5)).unwrap();
300 assert!(store.load_latest("my-index").unwrap().is_none());
301 }
302
303 #[test]
304 fn load_latest_prefix_isolation() {
305 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
306 store.save(sample_checkpoint("alpha:ckpt-1", 10)).unwrap();
307 store.save(sample_checkpoint("beta:ckpt-1", 999)).unwrap();
308
309 let latest_alpha = store.load_latest("alpha").unwrap().unwrap();
310 assert_eq!(latest_alpha.entries_processed, 10);
311 }
312
313 #[test]
314 fn checkpoint_fields_accessible() {
315 let ckpt: Checkpoint<u32> =
316 Checkpoint::new("test:ckpt", 42u32, Uuid::new_v4(), 7, FoldContext::new(), 3).unwrap();
317 assert_eq!(ckpt.state, 42);
318 assert_eq!(ckpt.entries_processed, 7);
319 assert_eq!(ckpt.fold_version, 3);
320 }
321
322 #[cfg(feature = "serde")]
325 #[test]
326 fn serde_roundtrip() {
327 let ckpt = sample_checkpoint("serde:test", 42);
328 let json = serde_json::to_string(&ckpt).expect("serialize");
329 let restored: Checkpoint<String> = serde_json::from_str(&json).expect("deserialize");
330 assert_eq!(ckpt.id, restored.id);
331 assert_eq!(ckpt.state, restored.state);
332 assert_eq!(ckpt.entries_processed, restored.entries_processed);
333 assert_eq!(ckpt.fold_version, restored.fold_version);
334 assert_eq!(ckpt.uuid, restored.uuid);
335 assert_eq!(ckpt.hash.as_bytes(), restored.hash.as_bytes());
337 }
338
339 #[test]
340 fn delete_existing_succeeds() {
341 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
342 store.save(sample_checkpoint("del:ckpt-1", 1)).unwrap();
343 store.delete("del:ckpt-1").unwrap();
344 assert!(store.load("del:ckpt-1").unwrap().is_none());
345 }
346
347 #[test]
348 fn delete_nonexistent_returns_not_found() {
349 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
350 let err = store.delete("nope").unwrap_err();
351 assert!(
352 matches!(err, FoldError::CheckpointNotFound(ref id) if id == "nope"),
353 "expected CheckpointNotFound, got {err:?}"
354 );
355 }
356
357 #[test]
358 fn list_returns_all_ids() {
359 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
360 store.save(sample_checkpoint("a:ckpt-1", 1)).unwrap();
361 store.save(sample_checkpoint("b:ckpt-1", 2)).unwrap();
362 store.save(sample_checkpoint("c:ckpt-1", 3)).unwrap();
363 let mut ids = store.list().unwrap();
364 ids.sort();
365 assert_eq!(ids, vec!["a:ckpt-1", "b:ckpt-1", "c:ckpt-1"]);
366 }
367
368 #[test]
369 fn list_empty_store() {
370 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
371 assert!(store.list().unwrap().is_empty());
372 }
373
374 #[test]
375 fn save_overwrite_replaces_previous() {
376 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
377 let ckpt1 = sample_checkpoint("overwrite:ckpt-1", 10);
378 store.save(ckpt1).unwrap();
379
380 let ckpt2 = Checkpoint::new(
382 "overwrite:ckpt-1",
383 "new-state".to_string(),
384 Uuid::new_v4(),
385 99,
386 FoldContext::new(),
387 2,
388 )
389 .unwrap();
390 store.save(ckpt2).unwrap();
391
392 let loaded = store.load("overwrite:ckpt-1").unwrap().unwrap();
393 assert_eq!(loaded.state, "new-state");
394 assert_eq!(loaded.entries_processed, 99);
395 let ids = store.list().unwrap();
397 assert_eq!(ids.iter().filter(|id| *id == "overwrite:ckpt-1").count(), 1);
398 }
399
400 #[test]
401 fn integrity_mismatch_on_corrupted_hash() {
402 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
403 let ckpt = sample_checkpoint("integrity:ckpt-1", 5);
404 store.save(ckpt).unwrap();
405
406 {
408 let mut guard = store.inner.write().unwrap();
409 if let Some(c) = guard.get_mut("integrity:ckpt-1") {
410 c.hash = Hash32::ZERO;
411 }
412 }
413
414 let err = store.load("integrity:ckpt-1").unwrap_err();
415 assert!(
416 matches!(err, FoldError::IntegrityMismatch { .. }),
417 "expected IntegrityMismatch, got {err:?}"
418 );
419 }
420
421 #[test]
422 fn concurrent_saves_all_land() {
423 use std::sync::Arc;
424 use std::thread;
425
426 let store = Arc::new(InMemoryCheckpointStore::<String>::new());
427 let n = 20usize;
428 let handles: Vec<_> = (0..n)
429 .map(|i| {
430 let s = Arc::clone(&store);
431 thread::spawn(move || {
432 s.save(sample_checkpoint(&format!("concurrent:ckpt-{i}"), i))
433 .unwrap();
434 })
435 })
436 .collect();
437 for h in handles {
438 h.join().expect("thread panicked");
439 }
440 let ids = store.list().unwrap();
441 assert_eq!(ids.len(), n, "expected {n} checkpoints, got {}", ids.len());
442 }
443
444 #[test]
447 fn sort_checkpoint_keys_produces_lexicographic_order() {
448 let unsorted = vec![
450 "z:ckpt-3".to_string(),
451 "m:ckpt-2".to_string(),
452 "a:ckpt-1".to_string(),
453 ];
454 let sorted = sort_checkpoint_keys(unsorted);
455 assert_eq!(
456 sorted,
457 vec!["a:ckpt-1", "m:ckpt-2", "z:ckpt-3"],
458 "sort_checkpoint_keys must produce lexicographic order; got {sorted:?}"
459 );
460 }
461
462 #[test]
465 fn list_is_sorted() {
466 let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
467 store.save(sample_checkpoint("z:ckpt-1", 1)).unwrap();
469 store.save(sample_checkpoint("a:ckpt-1", 2)).unwrap();
470 store.save(sample_checkpoint("m:ckpt-1", 3)).unwrap();
471 let ids = store.list().unwrap();
472 assert_eq!(
473 ids,
474 vec!["a:ckpt-1", "m:ckpt-1", "z:ckpt-1"],
475 "list() must return sorted keys; got {ids:?}"
476 );
477 }
478}