Skip to main content

khive_fold/
checkpoint.rs

1//! Generic checkpoint envelope and in-memory store for fold-managed indexes.
2
3use 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/// Generic checkpoint envelope wrapping an arbitrary fold state snapshot.
17#[derive(Debug, Clone)]
18#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19pub struct Checkpoint<S> {
20    /// Human-readable checkpoint identifier (e.g. `"hnsw_idx:ckpt-1"`).
21    pub id: String,
22
23    /// The snapshot state captured at this checkpoint.
24    pub state: S,
25
26    /// Unique identifier for this checkpoint instance.
27    pub uuid: Uuid,
28
29    /// BLAKE3 content hash of the state; verified on load.
30    pub hash: Hash32,
31
32    /// Number of entries processed when this checkpoint was taken.
33    pub entries_processed: usize,
34
35    /// Fold context at checkpoint time.
36    pub context: FoldContext,
37
38    /// Monotonically increasing fold schema version.
39    pub fold_version: usize,
40
41    /// Wall-clock time when this checkpoint was created.
42    pub created_at: DateTime<Utc>,
43}
44
45impl<S: Serialize> Checkpoint<S> {
46    /// Create a new checkpoint, computing the BLAKE3 hash of the state.
47    // REASON: Checkpoint::new requires id, state, uuid, entries_processed, context, and
48    // fold_version — each is a semantically distinct field with no natural grouping into
49    // a builder or sub-struct without breaking the public API.
50    #[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            // Foundation layer does not call Utc::now() — epoch is the safe default.
70            // Callers that need the current time should set created_at after construction.
71            created_at: DateTime::<Utc>::default(),
72        })
73    }
74
75    /// Create a checkpoint with a pre-computed hash (for deserialization / testing).
76    // REASON: with_hash mirrors the new() parameter set (minus auto-computed hash) for
77    // deserialization and testing; same structural constraint as new() above.
78    #[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            // Foundation layer does not call Utc::now() — epoch is the safe default.
97            created_at: DateTime::<Utc>::default(),
98        }
99    }
100}
101
102/// Trait for checkpoint persistence backends.
103pub trait CheckpointStore<S> {
104    /// Persist a checkpoint, computing and storing an integrity hash.
105    fn save(&self, checkpoint: Checkpoint<S>) -> Result<(), FoldError>
106    where
107        S: Clone + Serialize;
108
109    /// Load a checkpoint by exact `id`, verifying the integrity hash.
110    fn load(&self, id: &str) -> Result<Option<Checkpoint<S>>, FoldError>
111    where
112        S: Clone + Serialize;
113
114    /// Load the most recently created checkpoint whose `id` starts with `prefix`.
115    fn load_latest(&self, prefix: &str) -> Result<Option<Checkpoint<S>>, FoldError>
116    where
117        S: Clone + Serialize;
118
119    /// Delete the checkpoint with the given `id`.
120    fn delete(&self, id: &str) -> Result<(), FoldError>;
121
122    /// List all checkpoint `id` strings currently stored.
123    fn list(&self) -> Result<Vec<String>, FoldError>;
124}
125
126/// In-memory checkpoint store backed by a `RwLock<HashMap>`.
127pub struct InMemoryCheckpointStore<S> {
128    inner: Arc<RwLock<HashMap<String, Checkpoint<S>>>>,
129}
130
131impl<S> InMemoryCheckpointStore<S> {
132    /// Create a new empty in-memory store.
133    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        // Recompute the hash from the state to ensure the stored hash is canonical.
154        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        // Verify integrity: recompute hash from state and compare.
180        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            // Tiebreak on uuid for determinism when created_at is equal.
206            .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
232/// Sort a `Vec<String>` of checkpoint IDs into lexicographic order.
233/// See crates/khive-fold/docs/api/checkpoint.md#ordering for why this is
234/// a standalone helper.
235pub 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        // Build checkpoints with explicit, strictly ordered created_at values
280        // so load_latest is deterministic without relying on wall-clock time.
281        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    // --- Additional tests (F-NEW-8) ---
323
324    #[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        // Hash bytes should survive the roundtrip unchanged.
336        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        // Save again with the same id but different state.
381        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        // Only one entry with that id.
396        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        // Directly corrupt the stored hash by replacing it with ZERO.
407        {
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    // Reverse-sorted input is the worst case for an unsorted implementation;
445    // see design.md#test-rationale-notes.
446    #[test]
447    fn sort_checkpoint_keys_produces_lexicographic_order() {
448        // Intentionally REVERSE alphabetical — worst case for unsorted implementations.
449        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    /// Integration: `InMemoryCheckpointStore::list` must return keys in
463    /// lexicographic order regardless of insertion order.
464    #[test]
465    fn list_is_sorted() {
466        let store: InMemoryCheckpointStore<String> = InMemoryCheckpointStore::new();
467        // Insert in non-alphabetical order.
468        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}