1use pe_core::PeError;
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::SystemTime;
11use tokio::sync::RwLock;
12
13#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct CheckpointMeta {
19 pub id: String,
21 pub thread_id: String,
23 pub created_at: SystemTime,
25 pub step: u32,
27 pub parent_id: Option<String>,
30}
31
32impl CheckpointMeta {
33 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 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#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub struct PendingWrite {
60 pub node_name: String,
62 pub data: Vec<u8>,
64 pub success: bool,
66}
67
68impl PendingWrite {
69 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#[async_trait::async_trait]
84pub trait Checkpointer: Send + Sync {
85 async fn save(
87 &self,
88 thread_id: &str,
89 checkpoint_id: &str,
90 data: &[u8],
91 meta: &CheckpointMeta,
92 ) -> Result<(), PeError>;
93
94 async fn load_latest(
96 &self,
97 thread_id: &str,
98 ) -> Result<Option<(Vec<u8>, CheckpointMeta)>, PeError>;
99
100 async fn load_by_id(
102 &self,
103 thread_id: &str,
104 checkpoint_id: &str,
105 ) -> Result<Option<Vec<u8>>, PeError>;
106
107 async fn list(&self, thread_id: &str) -> Result<Vec<CheckpointMeta>, PeError>;
109
110 async fn put_writes(
118 &self,
119 thread_id: &str,
120 checkpoint_id: &str,
121 writes: &[PendingWrite],
122 ) -> Result<(), PeError>;
123
124 async fn delete_thread(&self, thread_id: &str) -> Result<(), PeError>;
126}
127
128type CheckpointEntry = (String, Vec<u8>, CheckpointMeta);
129
130#[derive(Debug, Clone)]
135pub struct InMemoryCheckpointer {
136 store: Arc<RwLock<HashMap<String, Vec<CheckpointEntry>>>>,
137}
138
139impl InMemoryCheckpointer {
140 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 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 let meta1 = make_meta("cp-1", "t1", 1);
313 cp.save("t1", "cp-1", b"first", &meta1).await.unwrap();
314
315 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 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 let metas = cp.list("t1").await.unwrap();
326 assert!(metas[0].parent_id.is_none()); assert_eq!(metas[1].parent_id.as_deref(), Some("cp-1")); }
329}