Skip to main content

pe_graph/
checkpointer.rs

1//! Checkpoint persistence — trait + in-memory implementation.
2//!
3//! The `Checkpointer` trait is storage-agnostic: it accepts and returns
4//! opaque bytes. The graph engine handles serialization (bincode).
5//! SurrealDB implementation lives in pe-memory (Plan 005).
6
7use pe_core::PeError;
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::SystemTime;
11use tokio::sync::RwLock;
12
13/// Metadata about a single checkpoint.
14///
15/// Fields may be added in future versions — construct via [`CheckpointMeta::new`].
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct CheckpointMeta {
19    /// Unique checkpoint identifier.
20    pub id: String,
21    /// Thread this checkpoint belongs to.
22    pub thread_id: String,
23    /// When this checkpoint was created.
24    pub created_at: SystemTime,
25    /// Which superstep this was taken at.
26    pub step: u32,
27    /// The checkpoint this one was derived from (lineage tracking).
28    /// `None` for the first checkpoint in a thread.
29    pub parent_id: Option<String>,
30}
31
32impl CheckpointMeta {
33    /// Create a new checkpoint metadata record.
34    ///
35    /// The `parent_id` defaults to `None`. Use [`with_parent`](Self::with_parent)
36    /// to set lineage.
37    pub fn new(id: impl Into<String>, thread_id: impl Into<String>, step: u32) -> Self {
38        Self {
39            id: id.into(),
40            thread_id: thread_id.into(),
41            created_at: SystemTime::now(),
42            step,
43            parent_id: None,
44        }
45    }
46
47    /// Set the parent checkpoint ID (the checkpoint this one was derived from).
48    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
49        self.parent_id = Some(parent_id.into());
50        self
51    }
52}
53
54/// A single node's write record within a superstep.
55///
56/// Fields may be added in future versions — construct via [`PendingWrite::new`].
57#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub struct PendingWrite {
60    /// Which node produced this write.
61    pub node_name: String,
62    /// Serialized update data.
63    pub data: Vec<u8>,
64    /// Whether the node completed successfully.
65    pub success: bool,
66}
67
68impl PendingWrite {
69    /// Create a new pending write record.
70    pub fn new(node_name: impl Into<String>, data: Vec<u8>, success: bool) -> Self {
71        Self {
72            node_name: node_name.into(),
73            data,
74            success,
75        }
76    }
77}
78
79/// Storage-agnostic checkpoint persistence.
80///
81/// Implementations store opaque bytes — the engine handles serialization.
82/// All methods are async to support network-backed stores.
83#[async_trait::async_trait]
84pub trait Checkpointer: Send + Sync {
85    /// Save a checkpoint. Returns nothing on success.
86    async fn save(
87        &self,
88        thread_id: &str,
89        checkpoint_id: &str,
90        data: &[u8],
91        meta: &CheckpointMeta,
92    ) -> Result<(), PeError>;
93
94    /// Load the most recent checkpoint for a thread.
95    async fn load_latest(
96        &self,
97        thread_id: &str,
98    ) -> Result<Option<(Vec<u8>, CheckpointMeta)>, PeError>;
99
100    /// Load a specific checkpoint by ID.
101    async fn load_by_id(
102        &self,
103        thread_id: &str,
104        checkpoint_id: &str,
105    ) -> Result<Option<Vec<u8>>, PeError>;
106
107    /// List all checkpoints for a thread, oldest first.
108    async fn list(&self, thread_id: &str) -> Result<Vec<CheckpointMeta>, PeError>;
109
110    /// Store pending writes alongside a checkpoint.
111    ///
112    /// **Not yet called by the engine.** The BSP loop tracks `PendingWrites`
113    /// internally but does not persist them via this method yet. Plan 006
114    /// (RetryPolicy) will activate this — failed nodes can be retried while
115    /// successful nodes' writes are loaded from the checkpointer instead of
116    /// re-executing. Implementors should store writes keyed by checkpoint_id.
117    async fn put_writes(
118        &self,
119        thread_id: &str,
120        checkpoint_id: &str,
121        writes: &[PendingWrite],
122    ) -> Result<(), PeError>;
123
124    /// Delete all checkpoints for a thread.
125    async fn delete_thread(&self, thread_id: &str) -> Result<(), PeError>;
126}
127
128type CheckpointEntry = (String, Vec<u8>, CheckpointMeta);
129
130/// In-memory checkpointer for testing and short-lived graphs.
131///
132/// Data lives only as long as the process. For durable persistence,
133/// use the SurrealDB checkpointer from pe-memory.
134#[derive(Debug, Clone)]
135pub struct InMemoryCheckpointer {
136    store: Arc<RwLock<HashMap<String, Vec<CheckpointEntry>>>>,
137}
138
139impl InMemoryCheckpointer {
140    /// Create a new empty in-memory checkpointer.
141    pub fn new() -> Self {
142        Self {
143            store: Arc::new(RwLock::new(HashMap::new())),
144        }
145    }
146}
147
148impl Default for InMemoryCheckpointer {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154#[async_trait::async_trait]
155impl Checkpointer for InMemoryCheckpointer {
156    async fn save(
157        &self,
158        thread_id: &str,
159        checkpoint_id: &str,
160        data: &[u8],
161        meta: &CheckpointMeta,
162    ) -> Result<(), PeError> {
163        let mut store = self.store.write().await;
164        store.entry(thread_id.to_string()).or_default().push((
165            checkpoint_id.to_string(),
166            data.to_vec(),
167            meta.clone(),
168        ));
169        Ok(())
170    }
171
172    async fn load_latest(
173        &self,
174        thread_id: &str,
175    ) -> Result<Option<(Vec<u8>, CheckpointMeta)>, PeError> {
176        let store = self.store.read().await;
177        Ok(store
178            .get(thread_id)
179            .and_then(|entries| entries.last())
180            .map(|(_, data, meta)| (data.clone(), meta.clone())))
181    }
182
183    async fn load_by_id(
184        &self,
185        thread_id: &str,
186        checkpoint_id: &str,
187    ) -> Result<Option<Vec<u8>>, PeError> {
188        let store = self.store.read().await;
189        Ok(store
190            .get(thread_id)
191            .and_then(|entries| entries.iter().find(|(id, _, _)| id == checkpoint_id))
192            .map(|(_, data, _)| data.clone()))
193    }
194
195    async fn list(&self, thread_id: &str) -> Result<Vec<CheckpointMeta>, PeError> {
196        let store = self.store.read().await;
197        Ok(store
198            .get(thread_id)
199            .map(|entries| entries.iter().map(|(_, _, meta)| meta.clone()).collect())
200            .unwrap_or_default())
201    }
202
203    async fn put_writes(
204        &self,
205        _thread_id: &str,
206        _checkpoint_id: &str,
207        _writes: &[PendingWrite],
208    ) -> Result<(), PeError> {
209        // In-memory impl: writes are already applied to state.
210        // Full write tracking is for durable stores (SurrealDB, Plan 005).
211        Ok(())
212    }
213
214    async fn delete_thread(&self, thread_id: &str) -> Result<(), PeError> {
215        let mut store = self.store.write().await;
216        store.remove(thread_id);
217        Ok(())
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn make_meta(id: &str, thread: &str, step: u32) -> CheckpointMeta {
226        CheckpointMeta::new(id, thread, step)
227    }
228
229    #[tokio::test]
230    async fn test_save_and_load_latest() {
231        let cp = InMemoryCheckpointer::new();
232        let meta = make_meta("cp-1", "t1", 1);
233        cp.save("t1", "cp-1", b"state-data", &meta).await.unwrap();
234
235        let (data, loaded_meta) = cp.load_latest("t1").await.unwrap().unwrap();
236        assert_eq!(data, b"state-data");
237        assert_eq!(loaded_meta.id, "cp-1");
238        assert_eq!(loaded_meta.step, 1);
239    }
240
241    #[tokio::test]
242    async fn test_load_latest_returns_most_recent() {
243        let cp = InMemoryCheckpointer::new();
244        cp.save("t1", "cp-1", b"first", &make_meta("cp-1", "t1", 1))
245            .await
246            .unwrap();
247        cp.save("t1", "cp-2", b"second", &make_meta("cp-2", "t1", 2))
248            .await
249            .unwrap();
250
251        let (data, meta) = cp.load_latest("t1").await.unwrap().unwrap();
252        assert_eq!(data, b"second");
253        assert_eq!(meta.id, "cp-2");
254    }
255
256    #[tokio::test]
257    async fn test_load_by_id() {
258        let cp = InMemoryCheckpointer::new();
259        cp.save("t1", "cp-1", b"first", &make_meta("cp-1", "t1", 1))
260            .await
261            .unwrap();
262        cp.save("t1", "cp-2", b"second", &make_meta("cp-2", "t1", 2))
263            .await
264            .unwrap();
265
266        let data = cp.load_by_id("t1", "cp-1").await.unwrap().unwrap();
267        assert_eq!(data, b"first");
268    }
269
270    #[tokio::test]
271    async fn test_empty_thread_returns_none() {
272        let cp = InMemoryCheckpointer::new();
273        assert!(cp.load_latest("nonexistent").await.unwrap().is_none());
274        assert!(cp.load_by_id("nope", "nope").await.unwrap().is_none());
275    }
276
277    #[tokio::test]
278    async fn test_list_checkpoints() {
279        let cp = InMemoryCheckpointer::new();
280        cp.save("t1", "cp-1", b"a", &make_meta("cp-1", "t1", 1))
281            .await
282            .unwrap();
283        cp.save("t1", "cp-2", b"b", &make_meta("cp-2", "t1", 2))
284            .await
285            .unwrap();
286
287        let metas = cp.list("t1").await.unwrap();
288        assert_eq!(metas.len(), 2);
289        assert_eq!(metas[0].id, "cp-1");
290        assert_eq!(metas[1].id, "cp-2");
291    }
292
293    #[tokio::test]
294    async fn test_checkpoint_meta_parent_id_default_none() {
295        let meta = CheckpointMeta::new("cp-1", "t1", 1);
296        assert!(meta.parent_id.is_none());
297    }
298
299    #[tokio::test]
300    async fn test_checkpoint_meta_with_parent() {
301        let meta = CheckpointMeta::new("cp-2", "t1", 2).with_parent("cp-1");
302        assert_eq!(meta.parent_id.as_deref(), Some("cp-1"));
303        assert_eq!(meta.id, "cp-2");
304        assert_eq!(meta.step, 2);
305    }
306
307    #[tokio::test]
308    async fn test_parent_id_preserved_through_save_load() {
309        let cp = InMemoryCheckpointer::new();
310
311        // First checkpoint — no parent
312        let meta1 = make_meta("cp-1", "t1", 1);
313        cp.save("t1", "cp-1", b"first", &meta1).await.unwrap();
314
315        // Second checkpoint — parent is cp-1
316        let meta2 = CheckpointMeta::new("cp-2", "t1", 2).with_parent("cp-1");
317        cp.save("t1", "cp-2", b"second", &meta2).await.unwrap();
318
319        // Load latest — should be cp-2 with parent cp-1
320        let (_data, loaded_meta) = cp.load_latest("t1").await.unwrap().unwrap();
321        assert_eq!(loaded_meta.id, "cp-2");
322        assert_eq!(loaded_meta.parent_id.as_deref(), Some("cp-1"));
323
324        // List — verify lineage chain
325        let metas = cp.list("t1").await.unwrap();
326        assert!(metas[0].parent_id.is_none()); // cp-1 has no parent
327        assert_eq!(metas[1].parent_id.as_deref(), Some("cp-1")); // cp-2 -> cp-1
328    }
329}