Skip to main content

adk_graph/
checkpoint.rs

1//! Checkpointing for persistent graph state
2
3#[cfg(feature = "sqlite")]
4use crate::error::GraphError;
5use crate::error::Result;
6use crate::state::Checkpoint;
7use async_trait::async_trait;
8use std::collections::HashMap;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12/// How many checkpoints to keep for a thread, and for how long.
13///
14/// A long-running thread accumulates one checkpoint per super-step. Without a
15/// policy that grows without bound, which costs storage and slows a `list`.
16///
17/// The newest checkpoint is never removed, whatever the policy says: it is the
18/// one a resume loads, so discarding it would end the thread.
19///
20/// # Example
21///
22/// ```
23/// use adk_graph::checkpoint::RetentionPolicy;
24/// use std::time::Duration;
25///
26/// // Keep the last 50 steps, and nothing older than a week.
27/// let policy = RetentionPolicy::keep_last(50).with_max_age(Duration::from_secs(7 * 24 * 3600));
28/// # let _ = policy;
29/// ```
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct RetentionPolicy {
32    /// Keep at most this many checkpoints per thread, newest first.
33    pub max_per_thread: Option<usize>,
34    /// Remove checkpoints older than this.
35    pub max_age: Option<std::time::Duration>,
36}
37
38impl RetentionPolicy {
39    /// Keeps the newest `count` checkpoints for a thread.
40    ///
41    /// A count of zero is raised to one, because the newest is always kept.
42    pub fn keep_last(count: usize) -> Self {
43        Self { max_per_thread: Some(count.max(1)), max_age: None }
44    }
45
46    /// Removes checkpoints older than `age`.
47    pub fn max_age(age: std::time::Duration) -> Self {
48        Self { max_per_thread: None, max_age: Some(age) }
49    }
50
51    /// Adds an age limit to a count limit.
52    pub fn with_max_age(mut self, age: std::time::Duration) -> Self {
53        self.max_age = Some(age);
54        self
55    }
56
57    /// Adds a count limit to an age limit.
58    pub fn with_max_per_thread(mut self, count: usize) -> Self {
59        self.max_per_thread = Some(count.max(1));
60        self
61    }
62
63    /// Whether this policy would remove anything.
64    pub fn is_unlimited(&self) -> bool {
65        self.max_per_thread.is_none() && self.max_age.is_none()
66    }
67
68    /// Selects the checkpoint ids this policy discards, newest always kept.
69    ///
70    /// Shared by every backend so they cannot disagree about what to keep.
71    pub fn expired(&self, checkpoints: &[Checkpoint]) -> Vec<String> {
72        if self.is_unlimited() || checkpoints.len() <= 1 {
73            return Vec::new();
74        }
75        let mut ordered: Vec<&Checkpoint> = checkpoints.iter().collect();
76        // Newest first, so the one a resume loads is at index 0.
77        ordered.sort_by_key(|checkpoint| std::cmp::Reverse(checkpoint.created_at));
78
79        let cutoff = self.max_age.and_then(|age| {
80            chrono::Duration::from_std(age).ok().map(|age| chrono::Utc::now() - age)
81        });
82
83        ordered
84            .iter()
85            .enumerate()
86            .filter(|(index, checkpoint)| {
87                // Index 0 is the newest and is never discarded.
88                *index > 0
89                    && (self.max_per_thread.is_some_and(|max| *index >= max)
90                        || cutoff.is_some_and(|cutoff| checkpoint.created_at < cutoff))
91            })
92            .map(|(_, checkpoint)| checkpoint.checkpoint_id.clone())
93            .collect()
94    }
95}
96
97/// Checkpointer trait for persistence
98#[async_trait]
99pub trait Checkpointer: Send + Sync {
100    /// Save a checkpoint
101    async fn save(&self, checkpoint: &Checkpoint) -> Result<String>;
102
103    /// Load the latest checkpoint for a thread
104    async fn load(&self, thread_id: &str) -> Result<Option<Checkpoint>>;
105
106    /// Load a specific checkpoint by ID
107    async fn load_by_id(&self, checkpoint_id: &str) -> Result<Option<Checkpoint>>;
108
109    /// List all checkpoints for a thread (for time travel)
110    async fn list(&self, thread_id: &str) -> Result<Vec<Checkpoint>>;
111
112    /// Delete checkpoints for a thread
113    async fn delete(&self, thread_id: &str) -> Result<()>;
114
115    /// Removes the checkpoints a retention policy discards, keeping the newest.
116    ///
117    /// Returns how many were removed. The default keeps everything, so a backend
118    /// written before this existed is unaffected and a thread grows as before.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error when the backend cannot be read or written.
123    async fn prune(&self, _thread_id: &str, _policy: &RetentionPolicy) -> Result<usize> {
124        Ok(0)
125    }
126}
127
128/// In-memory checkpointer for development and testing
129#[derive(Default)]
130pub struct MemoryCheckpointer {
131    checkpoints: Arc<RwLock<HashMap<String, Vec<Checkpoint>>>>,
132}
133
134impl MemoryCheckpointer {
135    /// Create a new in-memory checkpointer
136    pub fn new() -> Self {
137        Self::default()
138    }
139}
140
141#[async_trait]
142impl Checkpointer for MemoryCheckpointer {
143    async fn save(&self, checkpoint: &Checkpoint) -> Result<String> {
144        let mut store = self.checkpoints.write().await;
145        let thread_checkpoints = store.entry(checkpoint.thread_id.clone()).or_insert_with(Vec::new);
146
147        let checkpoint_id = checkpoint.checkpoint_id.clone();
148        thread_checkpoints.push(checkpoint.clone());
149
150        Ok(checkpoint_id)
151    }
152
153    async fn load(&self, thread_id: &str) -> Result<Option<Checkpoint>> {
154        let store = self.checkpoints.read().await;
155        Ok(store.get(thread_id).and_then(|checkpoints| checkpoints.last()).cloned())
156    }
157
158    async fn load_by_id(&self, checkpoint_id: &str) -> Result<Option<Checkpoint>> {
159        let store = self.checkpoints.read().await;
160        for checkpoints in store.values() {
161            for checkpoint in checkpoints {
162                if checkpoint.checkpoint_id == checkpoint_id {
163                    return Ok(Some(checkpoint.clone()));
164                }
165            }
166        }
167        Ok(None)
168    }
169
170    async fn list(&self, thread_id: &str) -> Result<Vec<Checkpoint>> {
171        let store = self.checkpoints.read().await;
172        Ok(store.get(thread_id).cloned().unwrap_or_default())
173    }
174
175    async fn delete(&self, thread_id: &str) -> Result<()> {
176        let mut store = self.checkpoints.write().await;
177        store.remove(thread_id);
178        Ok(())
179    }
180
181    async fn prune(&self, thread_id: &str, policy: &RetentionPolicy) -> Result<usize> {
182        let mut store = self.checkpoints.write().await;
183        let Some(thread) = store.get_mut(thread_id) else { return Ok(0) };
184        let expired = policy.expired(thread);
185        if expired.is_empty() {
186            return Ok(0);
187        }
188        let before = thread.len();
189        thread.retain(|checkpoint| !expired.contains(&checkpoint.checkpoint_id));
190        Ok(before - thread.len())
191    }
192}
193
194/// SQLite checkpointer for production use
195#[cfg(feature = "sqlite")]
196pub struct SqliteCheckpointer {
197    pool: sqlx::SqlitePool,
198}
199
200#[cfg(feature = "sqlite")]
201impl SqliteCheckpointer {
202    /// Create a new SQLite checkpointer
203    pub async fn new(database_url: &str) -> Result<Self> {
204        let pool = sqlx::SqlitePool::connect(database_url)
205            .await
206            .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
207
208        // Create table
209        sqlx::query(
210            r#"
211            CREATE TABLE IF NOT EXISTS graph_checkpoints (
212                id TEXT PRIMARY KEY,
213                thread_id TEXT NOT NULL,
214                state TEXT NOT NULL,
215                step INTEGER NOT NULL,
216                pending_nodes TEXT NOT NULL,
217                metadata TEXT,
218                created_at TEXT NOT NULL,
219                cleared_interrupt TEXT,
220                attempts TEXT,
221                child_ledger TEXT
222            )
223            "#,
224        )
225        .execute(&pool)
226        .await
227        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
228
229        // A database created before `cleared_interrupt` existed keeps its old
230        // shape under CREATE TABLE IF NOT EXISTS, so add the column separately
231        // and ignore the duplicate-column error on a database that already has it.
232        for column in ["cleared_interrupt", "attempts", "child_ledger"] {
233            let _ = sqlx::query(&format!("ALTER TABLE graph_checkpoints ADD COLUMN {column} TEXT"))
234                .execute(&pool)
235                .await;
236        }
237
238        sqlx::query(
239            r#"
240            CREATE INDEX IF NOT EXISTS idx_graph_checkpoints_thread
241            ON graph_checkpoints(thread_id, created_at DESC)
242            "#,
243        )
244        .execute(&pool)
245        .await
246        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
247
248        Ok(Self { pool })
249    }
250
251    /// Create an in-memory SQLite checkpointer (for testing)
252    pub async fn in_memory() -> Result<Self> {
253        Self::new(":memory:").await
254    }
255}
256
257#[cfg(feature = "sqlite")]
258#[async_trait]
259impl Checkpointer for SqliteCheckpointer {
260    async fn save(&self, checkpoint: &Checkpoint) -> Result<String> {
261        let state_json = serde_json::to_string(&checkpoint.state)?;
262        let pending_json = serde_json::to_string(&checkpoint.pending_nodes)?;
263        let metadata_json = serde_json::to_string(&checkpoint.metadata)?;
264        let created_at = checkpoint.created_at.to_rfc3339();
265
266        sqlx::query(
267            r#"
268            INSERT INTO graph_checkpoints (id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger)
269            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
270            "#,
271        )
272        .bind(&checkpoint.checkpoint_id)
273        .bind(&checkpoint.thread_id)
274        .bind(&state_json)
275        .bind(checkpoint.step as i64)
276        .bind(&pending_json)
277        .bind(&metadata_json)
278        .bind(&created_at)
279        .bind(checkpoint.cleared_interrupt.as_deref())
280        .bind(serde_json::to_string(&checkpoint.attempts).unwrap_or_else(|_| "{}".to_string()))
281        .bind(serde_json::to_string(&checkpoint.child_ledger).unwrap_or_else(|_| "{}".to_string()))
282        .execute(&self.pool)
283        .await
284        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
285
286        Ok(checkpoint.checkpoint_id.clone())
287    }
288
289    async fn load(&self, thread_id: &str) -> Result<Option<Checkpoint>> {
290        let row: Option<(String, String, String, i64, String, String, String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
291            r#"
292            SELECT id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger
293            FROM graph_checkpoints
294            WHERE thread_id = ?
295            ORDER BY created_at DESC
296            LIMIT 1
297            "#,
298        )
299        .bind(thread_id)
300        .fetch_optional(&self.pool)
301        .await
302        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
303
304        match row {
305            Some((
306                id,
307                thread_id,
308                state,
309                step,
310                pending_nodes,
311                metadata,
312                created_at,
313                cleared_interrupt,
314                attempts,
315                child_ledger,
316            )) => {
317                let checkpoint = Checkpoint {
318                    checkpoint_id: id,
319                    thread_id,
320                    state: serde_json::from_str(&state)?,
321                    step: step as usize,
322                    pending_nodes: serde_json::from_str(&pending_nodes)?,
323                    metadata: serde_json::from_str(&metadata)?,
324                    created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
325                        .map_err(|e| GraphError::CheckpointError(e.to_string()))?
326                        .with_timezone(&chrono::Utc),
327                    cleared_interrupt,
328                    attempts: attempts
329                        .and_then(|raw| serde_json::from_str(&raw).ok())
330                        .unwrap_or_default(),
331                    child_ledger: child_ledger
332                        .and_then(|raw| serde_json::from_str(&raw).ok())
333                        .unwrap_or_default(),
334                };
335                Ok(Some(checkpoint))
336            }
337            None => Ok(None),
338        }
339    }
340
341    async fn load_by_id(&self, checkpoint_id: &str) -> Result<Option<Checkpoint>> {
342        let row: Option<(String, String, String, i64, String, String, String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
343            r#"
344            SELECT id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger
345            FROM graph_checkpoints
346            WHERE id = ?
347            "#,
348        )
349        .bind(checkpoint_id)
350        .fetch_optional(&self.pool)
351        .await
352        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
353
354        match row {
355            Some((
356                id,
357                thread_id,
358                state,
359                step,
360                pending_nodes,
361                metadata,
362                created_at,
363                cleared_interrupt,
364                attempts,
365                child_ledger,
366            )) => {
367                let checkpoint = Checkpoint {
368                    checkpoint_id: id,
369                    thread_id,
370                    state: serde_json::from_str(&state)?,
371                    step: step as usize,
372                    pending_nodes: serde_json::from_str(&pending_nodes)?,
373                    metadata: serde_json::from_str(&metadata)?,
374                    created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
375                        .map_err(|e| GraphError::CheckpointError(e.to_string()))?
376                        .with_timezone(&chrono::Utc),
377                    cleared_interrupt,
378                    attempts: attempts
379                        .and_then(|raw| serde_json::from_str(&raw).ok())
380                        .unwrap_or_default(),
381                    child_ledger: child_ledger
382                        .and_then(|raw| serde_json::from_str(&raw).ok())
383                        .unwrap_or_default(),
384                };
385                Ok(Some(checkpoint))
386            }
387            None => Ok(None),
388        }
389    }
390
391    async fn list(&self, thread_id: &str) -> Result<Vec<Checkpoint>> {
392        let rows: Vec<(String, String, String, i64, String, String, String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
393            r#"
394            SELECT id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger
395            FROM graph_checkpoints
396            WHERE thread_id = ?
397            ORDER BY created_at ASC
398            "#,
399        )
400        .bind(thread_id)
401        .fetch_all(&self.pool)
402        .await
403        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
404
405        let mut checkpoints = Vec::with_capacity(rows.len());
406        for (
407            id,
408            thread_id,
409            state,
410            step,
411            pending_nodes,
412            metadata,
413            created_at,
414            cleared_interrupt,
415            attempts,
416            child_ledger,
417        ) in rows
418        {
419            checkpoints.push(Checkpoint {
420                checkpoint_id: id,
421                thread_id,
422                state: serde_json::from_str(&state)?,
423                step: step as usize,
424                pending_nodes: serde_json::from_str(&pending_nodes)?,
425                metadata: serde_json::from_str(&metadata)?,
426                created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
427                    .map_err(|e| GraphError::CheckpointError(e.to_string()))?
428                    .with_timezone(&chrono::Utc),
429                cleared_interrupt,
430                attempts: attempts
431                    .and_then(|raw| serde_json::from_str(&raw).ok())
432                    .unwrap_or_default(),
433                child_ledger: child_ledger
434                    .and_then(|raw| serde_json::from_str(&raw).ok())
435                    .unwrap_or_default(),
436            });
437        }
438        Ok(checkpoints)
439    }
440
441    async fn delete(&self, thread_id: &str) -> Result<()> {
442        sqlx::query("DELETE FROM graph_checkpoints WHERE thread_id = ?")
443            .bind(thread_id)
444            .execute(&self.pool)
445            .await
446            .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
447        Ok(())
448    }
449
450    async fn prune(&self, thread_id: &str, policy: &RetentionPolicy) -> Result<usize> {
451        // The policy decides, not the SQL, so both backends keep the same set.
452        let expired = policy.expired(&self.list(thread_id).await?);
453        if expired.is_empty() {
454            return Ok(0);
455        }
456        let mut removed = 0usize;
457        for checkpoint_id in &expired {
458            let result = sqlx::query("DELETE FROM graph_checkpoints WHERE id = ?")
459                .bind(checkpoint_id)
460                .execute(&self.pool)
461                .await
462                .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
463            removed += result.rows_affected() as usize;
464        }
465        Ok(removed)
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::state::State;
473
474    #[tokio::test]
475    async fn test_memory_checkpointer() {
476        let cp = MemoryCheckpointer::new();
477
478        // Create and save checkpoint
479        let checkpoint = Checkpoint::new("thread_1", State::new(), 0, vec!["node_a".to_string()]);
480        let id = cp.save(&checkpoint).await.unwrap();
481        assert!(!id.is_empty());
482
483        // Load latest
484        let loaded = cp.load("thread_1").await.unwrap();
485        assert!(loaded.is_some());
486        assert_eq!(loaded.unwrap().step, 0);
487
488        // Save another checkpoint
489        let checkpoint2 = Checkpoint::new("thread_1", State::new(), 1, vec!["node_b".to_string()]);
490        cp.save(&checkpoint2).await.unwrap();
491
492        // Load latest should return step 1
493        let loaded = cp.load("thread_1").await.unwrap();
494        assert_eq!(loaded.unwrap().step, 1);
495
496        // List should return both
497        let all = cp.list("thread_1").await.unwrap();
498        assert_eq!(all.len(), 2);
499
500        // Delete
501        cp.delete("thread_1").await.unwrap();
502        let loaded = cp.load("thread_1").await.unwrap();
503        assert!(loaded.is_none());
504    }
505}