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