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        // 原子写:先写 `{id}.json.tmp` 再 rename 到正式文件,避免崩溃/中断在
305        // 半截 JSON 时损坏 checkpoint(与 FileResumeStore 同一模式)。`.tmp`
306        // 扩展名不会被 sorted_ids 的 `.json` 过滤读到。
307        let tmp_path = self.directory.join(format!("{id}.json.tmp"));
308        tokio::fs::write(&tmp_path, &json)
309            .await
310            .map_err(|e| GraphError::CheckpointError(format!("Write error: {}", e)))?;
311        tokio::fs::rename(&tmp_path, &path)
312            .await
313            .map_err(|e| GraphError::CheckpointError(format!("Atomic rename error: {}", e)))?;
314
315        Ok(id)
316    }
317
318    async fn load(&self, checkpoint_id: &str) -> GraphResult<S> {
319        let path = self.checkpoint_path(checkpoint_id)?;
320
321        if !path.exists() {
322            return Err(GraphError::CheckpointError(format!(
323                "Checkpoint '{}' not found",
324                checkpoint_id
325            )));
326        }
327
328        let json = tokio::fs::read_to_string(&path)
329            .await
330            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
331
332        let data: CheckpointData<S> = serde_json::from_str(&json)
333            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
334
335        Ok(data.state)
336    }
337
338    async fn list(&self) -> GraphResult<Vec<String>> {
339        Ok(self
340            .sorted_ids()
341            .await?
342            .into_iter()
343            .map(|(_, _, id)| id)
344            .collect())
345    }
346
347    async fn last(&self) -> GraphResult<Option<(S, usize)>> {
348        let Some((_, _, last_id)) = self.sorted_ids().await?.into_iter().last() else {
349            return Ok(None);
350        };
351        let json = tokio::fs::read_to_string(&self.checkpoint_path(&last_id)?)
352            .await
353            .map_err(|e| GraphError::CheckpointError(format!("Read error: {}", e)))?;
354        let data: CheckpointData<S> = serde_json::from_str(&json)
355            .map_err(|e| GraphError::CheckpointError(format!("Deserialize error: {}", e)))?;
356        Ok(Some((data.state, data.recursion_count)))
357    }
358
359    async fn delete(&self, checkpoint_id: &str) -> GraphResult<()> {
360        let path = self.checkpoint_path(checkpoint_id)?;
361
362        if path.exists() {
363            tokio::fs::remove_file(&path)
364                .await
365                .map_err(|e| GraphError::CheckpointError(format!("Delete error: {}", e)))?;
366        }
367
368        Ok(())
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::state::AgentState;
376
377    #[tokio::test]
378    async fn test_thread_safe_checkpointer() {
379        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
380
381        let state = AgentState::new("test".to_string());
382        let id = checkpointer.save(&state, 0).await.unwrap();
383
384        let loaded = checkpointer.load(&id).await.unwrap();
385        assert_eq!(loaded.input, "test");
386
387        let list = checkpointer.list().await.unwrap();
388        assert_eq!(list.len(), 1);
389
390        checkpointer.delete(&id).await.unwrap();
391        let list = checkpointer.list().await.unwrap();
392        assert!(list.is_empty());
393    }
394
395    #[tokio::test]
396    async fn test_file_checkpointer() {
397        let temp_dir = tempfile::tempdir().unwrap();
398        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
399
400        let state = AgentState::new("file_test".to_string());
401        let id = checkpointer.save(&state, 0).await.unwrap();
402
403        let loaded = checkpointer.load(&id).await.unwrap();
404        assert_eq!(loaded.input, "file_test");
405
406        let list = checkpointer.list().await.unwrap();
407        assert_eq!(list.len(), 1);
408
409        checkpointer.delete(&id).await.unwrap();
410        let list = checkpointer.list().await.unwrap();
411        assert!(list.is_empty());
412    }
413
414    #[tokio::test]
415    async fn test_file_checkpointer_atomic_write() {
416        let temp_dir = tempfile::tempdir().unwrap();
417        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
418
419        let id = checkpointer
420            .save(&AgentState::new("atomic".to_string()), 0)
421            .await
422            .unwrap();
423
424        // 主文件完整且可解析;`.tmp` 无残留(rename 已清理)。
425        let main = temp_dir.path().join(format!("{id}.json"));
426        assert!(main.exists(), "checkpoint file must exist");
427        let json = tokio::fs::read_to_string(&main).await.unwrap();
428        assert!(
429            serde_json::from_str::<CheckpointData<AgentState>>(&json).is_ok(),
430            "checkpoint file must be complete JSON after atomic write"
431        );
432        assert!(
433            !temp_dir.path().join(format!("{id}.json.tmp")).exists(),
434            "tmp file must be renamed away, not left behind"
435        );
436
437        // 残留的 `.tmp` 文件不被 list() 读到(扩展名过滤)。
438        std::fs::write(temp_dir.path().join("stale.json.tmp"), b"{}").unwrap();
439        let list = checkpointer.list().await.unwrap();
440        assert_eq!(list, vec![id]);
441    }
442
443    #[tokio::test]
444    async fn test_file_checkpointer_multiple() {
445        let temp_dir = tempfile::tempdir().unwrap();
446        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
447
448        let id1 = checkpointer
449            .save(&AgentState::new("state1".to_string()), 0)
450            .await
451            .unwrap();
452        let id2 = checkpointer
453            .save(&AgentState::new("state2".to_string()), 0)
454            .await
455            .unwrap();
456        let _id3 = checkpointer
457            .save(&AgentState::new("state3".to_string()), 0)
458            .await
459            .unwrap();
460
461        let list = checkpointer.list().await.unwrap();
462        assert_eq!(list.len(), 3);
463
464        let loaded = checkpointer.load(&id2).await.unwrap();
465        assert_eq!(loaded.input, "state2");
466
467        checkpointer.delete(&id1).await.unwrap();
468        let list = checkpointer.list().await.unwrap();
469        assert_eq!(list.len(), 2);
470    }
471
472    #[tokio::test]
473    async fn test_file_checkpointer_path_traversal() {
474        let temp_dir = tempfile::tempdir().unwrap();
475        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
476
477        // Path traversal should be rejected
478        let result = checkpointer.load("..").await;
479        assert!(result.is_err());
480
481        let result = checkpointer.load("../etc/passwd").await;
482        assert!(result.is_err());
483    }
484
485    #[tokio::test]
486    async fn test_list_orders_oldest_to_newest() {
487        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
488        checkpointer
489            .save(&AgentState::new("first".to_string()), 0)
490            .await
491            .unwrap();
492        checkpointer
493            .save(&AgentState::new("second".to_string()), 1)
494            .await
495            .unwrap();
496        checkpointer
497            .save(&AgentState::new("third".to_string()), 2)
498            .await
499            .unwrap();
500
501        let list = checkpointer.list().await.unwrap();
502        assert_eq!(list.len(), 3);
503        // H5: 最后一个 id 必须是最后一次 save 的(而非 HashMap 乱序)。
504        let (state, _) = checkpointer.last().await.unwrap().unwrap();
505        assert_eq!(state.input, "third");
506    }
507
508    #[tokio::test]
509    async fn test_last_returns_recursion_count() {
510        let checkpointer = ThreadSafeMemoryCheckpointer::<AgentState>::new();
511        checkpointer
512            .save(&AgentState::new("a".to_string()), 7)
513            .await
514            .unwrap();
515        checkpointer
516            .save(&AgentState::new("b".to_string()), 12)
517            .await
518            .unwrap();
519
520        // M6: last() 返回最近一次 save 的 recursion_count
521        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
522        assert_eq!(state.input, "b");
523        assert_eq!(recursion_count, 12);
524    }
525
526    #[tokio::test]
527    async fn test_file_checkpointer_last_orders_by_save() {
528        let temp_dir = tempfile::tempdir().unwrap();
529        let checkpointer = FileCheckpointer::<AgentState>::new(temp_dir.path()).unwrap();
530        checkpointer
531            .save(&AgentState::new("one".to_string()), 1)
532            .await
533            .unwrap();
534        checkpointer
535            .save(&AgentState::new("two".to_string()), 2)
536            .await
537            .unwrap();
538
539        let list = checkpointer.list().await.unwrap();
540        assert_eq!(list.len(), 2);
541        let (state, recursion_count) = checkpointer.last().await.unwrap().unwrap();
542        assert_eq!(state.input, "two");
543        assert_eq!(recursion_count, 2);
544    }
545}