Skip to main content

lc_langgraph/
checkpointer.rs

1// crates/lc-langgraph/src/checkpointer.rs
2//! Checkpointing for state persistence
3
4use crate::errors::{GraphError, GraphResult};
5use crate::state::StateSchema;
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use tokio::sync::Mutex;
11use uuid::Uuid;
12
13/// Checkpointer trait for state persistence
14#[async_trait]
15pub trait Checkpointer<S: StateSchema>: Send + Sync {
16    /// Insert a new checkpoint, recording how much of the recursion budget had
17    /// been consumed by the run at that point (M6).
18    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String>;
19    async fn load(&self, checkpoint_id: &str) -> GraphResult<S>;
20    /// List checkpoint ids, ordered from oldest to most recent (H5).
21    async fn list(&self) -> GraphResult<Vec<String>>;
22    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()>;
23    /// State and recursion budget of the most recently saved checkpoint.
24    async fn last(&self) -> GraphResult<Option<(S, usize)>>;
25}
26
27/// Checkpoint data structure
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(bound = "S: StateSchema")]
30pub struct CheckpointData<S: StateSchema> {
31    pub id: String,
32    pub state: S,
33    pub timestamp: i64,
34    pub metadata: HashMap<String, serde_json::Value>,
35    /// Monotonic sequence number assigned by the checkpointer on save. Breaks
36    /// ties between checkpoints saved within the same `timestamp` second, so
37    /// "most recent" is well-defined even for fast back-to-back saves (H5).
38    #[serde(default)]
39    pub seq: u64,
40    /// Recursion budget consumed when this checkpoint was taken, so a resume
41    /// continues counting against the same `recursion_limit` instead of
42    /// restarting from zero (M6).
43    #[serde(default)]
44    pub recursion_count: usize,
45}
46
47impl<S: StateSchema> CheckpointData<S> {
48    pub fn new(state: S) -> Self {
49        Self {
50            id: Uuid::new_v4().to_string(),
51            state,
52            timestamp: chrono::Utc::now().timestamp(),
53            metadata: HashMap::new(),
54            seq: 0,
55            recursion_count: 0,
56        }
57    }
58
59    /// Construct with the checkpointer-assigned sequence and the run's current
60    /// recursion budget.
61    pub fn with_progress(state: S, seq: u64, recursion_count: usize) -> Self {
62        let mut data = Self::new(state);
63        data.seq = seq;
64        data.recursion_count = recursion_count;
65        data
66    }
67}
68
69/// In-memory checkpointer for development
70pub struct MemoryCheckpointer<S: StateSchema> {
71    checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
72    next_seq: AtomicU64,
73}
74
75impl<S: StateSchema> MemoryCheckpointer<S> {
76    pub fn new() -> Self {
77        Self {
78            checkpoints: Mutex::new(HashMap::new()),
79            next_seq: AtomicU64::new(0),
80        }
81    }
82}
83
84impl<S: StateSchema> Default for MemoryCheckpointer<S> {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[async_trait]
91impl<S: StateSchema> Checkpointer<S> for MemoryCheckpointer<S> {
92    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
93        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
94        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
95        let id = data.id.clone();
96        self.checkpoints.lock().await.insert(id.clone(), data);
97        Ok(id)
98    }
99
100    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
101        self.checkpoints
102            .lock()
103            .await
104            .get(checkpoint_id)
105            .map(|d| d.state.clone())
106            .ok_or_else(|| {
107                GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
108            })
109    }
110
111    async fn list(&self) -> GraphResult<Vec<String>> {
112        let guard = self.checkpoints.lock().await;
113        let mut items: Vec<(i64, u64, String)> = guard
114            .values()
115            .map(|d| (d.timestamp, d.seq, d.id.clone()))
116            .collect();
117        // H5: 旧的 HashMap.keys() 顺序不定;改为按 (timestamp, seq) 升序,
118        // 调用方取 .last() 即得到最近的 checkpoint。
119        items.sort();
120        Ok(items.into_iter().map(|(_, _, id)| id).collect())
121    }
122
123    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
124        let guard = self.checkpoints.lock().await;
125        Ok(guard
126            .values()
127            .max_by_key(|d| (d.timestamp, d.seq))
128            .map(|d| (d.state.clone(), d.recursion_count)))
129    }
130
131    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
132        self.checkpoints.lock().await.remove(checkpoint_id);
133        Ok(())
134    }
135}
136
137/// Thread-safe memory checkpointer
138pub struct ThreadSafeMemoryCheckpointer<S: StateSchema> {
139    checkpoints: Mutex<HashMap<String, CheckpointData<S>>>,
140    next_seq: AtomicU64,
141}
142
143impl<S: StateSchema> ThreadSafeMemoryCheckpointer<S> {
144    pub fn new() -> Self {
145        Self {
146            checkpoints: Mutex::new(HashMap::new()),
147            next_seq: AtomicU64::new(0),
148        }
149    }
150}
151
152impl<S: StateSchema> Default for ThreadSafeMemoryCheckpointer<S> {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158#[async_trait]
159impl<S: StateSchema> Checkpointer<S> for ThreadSafeMemoryCheckpointer<S> {
160    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
161        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
162        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
163        let id = data.id.clone();
164        self.checkpoints.lock().await.insert(id.clone(), data);
165        Ok(id)
166    }
167
168    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
169        let checkpoints = self.checkpoints.lock().await;
170        checkpoints
171            .get(checkpoint_id)
172            .map(|d| d.state.clone())
173            .ok_or_else(|| {
174                GraphError::CheckpointError(format!("Checkpoint '{}' not found", checkpoint_id))
175            })
176    }
177
178    async fn list(&self) -> GraphResult<Vec<String>> {
179        let guard = self.checkpoints.lock().await;
180        let mut items: Vec<(i64, u64, String)> = guard
181            .values()
182            .map(|d| (d.timestamp, d.seq, d.id.clone()))
183            .collect();
184        // H5: 按 (timestamp, seq) 升序,取 .last() 为最近 checkpoint。
185        items.sort();
186        Ok(items.into_iter().map(|(_, _, id)| id).collect())
187    }
188
189    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
190        let guard = self.checkpoints.lock().await;
191        Ok(guard
192            .values()
193            .max_by_key(|d| (d.timestamp, d.seq))
194            .map(|d| (d.state.clone(), d.recursion_count)))
195    }
196
197    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
198        self.checkpoints.lock().await.remove(checkpoint_id);
199        Ok(())
200    }
201}
202
203/// File-based checkpointer for persistent storage
204pub struct FileCheckpointer<S: StateSchema> {
205    directory: std::path::PathBuf,
206    next_seq: AtomicU64,
207    _phantom: std::marker::PhantomData<S>,
208}
209
210impl<S: StateSchema> FileCheckpointer<S> {
211    pub fn new(directory: impl Into<std::path::PathBuf>) -> GraphResult<Self> {
212        let dir = directory.into();
213        if !dir.exists() {
214            std::fs::create_dir_all(&dir).map_err(|e| {
215                GraphError::CheckpointError(format!(
216                    "Failed to create directory '{}': {}",
217                    dir.display(),
218                    e
219                ))
220            })?;
221        }
222        Ok(Self {
223            directory: dir,
224            next_seq: AtomicU64::new(0),
225            _phantom: std::marker::PhantomData,
226        })
227    }
228
229    fn checkpoint_path(&self, id: &str) -> GraphResult<std::path::PathBuf> {
230        // Sanitize id to prevent path traversal: reject ".." and absolute paths
231        if id.contains("..") || id.contains('/') || id.contains('\\') {
232            return Err(GraphError::CheckpointError(format!(
233                "Invalid checkpoint id '{}': path traversal detected",
234                id
235            )));
236        }
237        if std::path::Path::new(id).is_absolute() {
238            return Err(GraphError::CheckpointError(format!(
239                "Invalid checkpoint id '{}': absolute path not allowed",
240                id
241            )));
242        }
243        Ok(self.directory.join(format!("{}.json", id)))
244    }
245
246    /// Read every checkpoint file's `(timestamp, seq, id)` sort keys.
247    async fn sorted_ids(&self) -> GraphResult<Vec<(i64, u64, String)>> {
248        let mut items: Vec<(i64, u64, String)> = Vec::new();
249        let mut entries = tokio::fs::read_dir(&self.directory)
250            .await
251            .map_err(|e| GraphError::CheckpointError(format!("Read dir error: {}", e)))?;
252        while let Some(entry) = entries
253            .next_entry()
254            .await
255            .map_err(|e| GraphError::CheckpointError(format!("Read dir entry error: {}", e)))?
256        {
257            let path = entry.path();
258            if path.extension().is_some_and(|ext| ext == "json") {
259                let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from)
260                else {
261                    continue;
262                };
263                let json = tokio::fs::read_to_string(&path)
264                    .await
265                    .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
266                let data: CheckpointData<S> = serde_json::from_str(&json)
267                    .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
268                items.push((data.timestamp, data.seq, id));
269            }
270        }
271        // H5: 按 (timestamp, seq) 升序;seq 打破同一秒内的并列。
272        items.sort();
273        Ok(items)
274    }
275}
276
277// NOTE: `Default` is intentionally NOT implemented for `FileCheckpointer` (Q1).
278// The default constructor would have to create the `.checkpoints` directory, which
279// is I/O that can fail (read-only cwd, disk full, permissions) — `Default` cannot
280// report that failure, so it would have to panic. Use `FileCheckpointer::new(...)`
281// which returns a `GraphResult` and surfaces the error instead.
282
283#[async_trait]
284impl<S: StateSchema> Checkpointer<S> for FileCheckpointer<S> {
285    async fn save(&self, state: &S, recursion_count: usize) -> GraphResult<String> {
286        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
287        let data = CheckpointData::with_progress(state.clone(), seq, recursion_count);
288        let id = data.id.clone();
289        let path = self.checkpoint_path(&id)?;
290
291        let json = serde_json::to_string_pretty(&data)
292            .map_err(|e| GraphError::CheckpointError(format!("Serialize error: {}", e)))?;
293
294        tokio::fs::write(&path, json)
295            .await
296            .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
297
298        Ok(id)
299    }
300
301    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
302        let path = self.checkpoint_path(checkpoint_id)?;
303
304        if !path.exists() {
305            return Err(GraphError::CheckpointError(format!(
306                "Checkpoint '{}' not found",
307                checkpoint_id
308            )));
309        }
310
311        let json = tokio::fs::read_to_string(&path)
312            .await
313            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
314
315        let data: CheckpointData<S> = serde_json::from_str(&json)
316            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
317
318        Ok(data.state)
319    }
320
321    async fn list(&self) -> GraphResult<Vec<String>> {
322        Ok(self
323            .sorted_ids()
324            .await?
325            .into_iter()
326            .map(|(_, _, id)| id)
327            .collect())
328    }
329
330    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
331        let Some((_, _, last_id)) = self.sorted_ids().await?.into_iter().last() else {
332            return Ok(None);
333        };
334        let json = tokio::fs::read_to_string(&self.checkpoint_path(&last_id)?)
335            .await
336            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
337        let data: CheckpointData<S> = serde_json::from_str(&json)
338            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
339        Ok(Some((data.state, data.recursion_count)))
340    }
341
342    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
343        let path = self.checkpoint_path(checkpoint_id)?;
344
345        if path.exists() {
346            tokio::fs::remove_file(&path)
347                .await
348                .map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
349        }
350
351        Ok(())
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::state::AgentState;
359
360    #[tokio::test]
361    async fn test_thread_safe_checkpointer() {
362        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
363
364        let state = AgentState::new("test".to_string());
365        let id = checkpointer.save(&state, 0).await.unwrap();
366
367        let loaded = checkpointer.load(&id).await.unwrap();
368        assert_eq!(loaded.input, "test");
369
370        let list = checkpointer.list().await.unwrap();
371        assert_eq!(list.len(), 1);
372
373        checkpointer.delete(&id).await.unwrap();
374        let list = checkpointer.list().await.unwrap();
375        assert!(list.is_empty());
376    }
377
378    #[tokio::test]
379    async fn test_file_checkpointer() {
380        let temp_dir = tempfile::tempdir().unwrap();
381        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
382
383        let state = AgentState::new("file_test".to_string());
384        let id = checkpointer.save(&state, 0).await.unwrap();
385
386        let loaded = checkpointer.load(&id).await.unwrap();
387        assert_eq!(loaded.input, "file_test");
388
389        let list = checkpointer.list().await.unwrap();
390        assert_eq!(list.len(), 1);
391
392        checkpointer.delete(&id).await.unwrap();
393        let list = checkpointer.list().await.unwrap();
394        assert!(list.is_empty());
395    }
396
397    #[tokio::test]
398    async fn test_file_checkpointer_multiple() {
399        let temp_dir = tempfile::tempdir().unwrap();
400        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
401
402        let id1 = checkpointer
403            .save(&AgentState::new("state1".to_string()), 0)
404            .await
405            .unwrap();
406        let id2 = checkpointer
407            .save(&AgentState::new("state2".to_string()), 0)
408            .await
409            .unwrap();
410        let _id3 = checkpointer
411            .save(&AgentState::new("state3".to_string()), 0)
412            .await
413            .unwrap();
414
415        let list = checkpointer.list().await.unwrap();
416        assert_eq!(list.len(), 3);
417
418        let loaded = checkpointer.load(&id2).await.unwrap();
419        assert_eq!(loaded.input, "state2");
420
421        checkpointer.delete(&id1).await.unwrap();
422        let list = checkpointer.list().await.unwrap();
423        assert_eq!(list.len(), 2);
424    }
425
426    #[tokio::test]
427    async fn test_file_checkpointer_path_traversal() {
428        let temp_dir = tempfile::tempdir().unwrap();
429        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
430
431        // Path traversal should be rejected
432        let result = checkpointer.load("..").await;
433        assert!(result.is_err());
434
435        let result = checkpointer.load("../etc/passwd").await;
436        assert!(result.is_err());
437    }
438
439    #[tokio::test]
440    async fn test_list_orders_oldest_to_newest() {
441        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
442        checkpointer
443            .save(&AgentState::new("first".to_string()), 0)
444            .await
445            .unwrap();
446        checkpointer
447            .save(&AgentState::new("second".to_string()), 1)
448            .await
449            .unwrap();
450        checkpointer
451            .save(&AgentState::new("third".to_string()), 2)
452            .await
453            .unwrap();
454
455        let list = checkpointer.list().await.unwrap();
456        assert_eq!(list.len(), 3);
457        // H5: 最后一个 id 必须是最后一次 save 的(而非 HashMap 乱序)。
458        let (state, _) = checkpointer.last().await.unwrap().unwrap();
459        assert_eq!(state.input, "third");
460    }
461
462    #[tokio::test]
463    async fn test_last_returns_recursion_count() {
464        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
465        checkpointer
466            .save(&AgentState::new("a".to_string()), 7)
467            .await
468            .unwrap();
469        checkpointer
470            .save(&AgentState::new("b".to_string()), 12)
471            .await
472            .unwrap();
473
474        // M6: last() 返回最近一次 save 的 recursion_count
475        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
476        assert_eq!(state.input, "b");
477        assert_eq!(recursion_count, 12);
478    }
479
480    #[tokio::test]
481    async fn test_file_checkpointer_last_orders_by_save() {
482        let temp_dir = tempfile::tempdir().unwrap();
483        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
484        checkpointer
485            .save(&AgentState::new("one".to_string()), 1)
486            .await
487            .unwrap();
488        checkpointer
489            .save(&AgentState::new("two".to_string()), 2)
490            .await
491            .unwrap();
492
493        let list = checkpointer.list().await.unwrap();
494        assert_eq!(list.len(), 2);
495        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
496        assert_eq!(state.input, "two");
497        assert_eq!(recursion_count, 2);
498    }
499}