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        Self::from_pool(pool).await
209    }
210
211    /// Create a SQLite checkpointer from an existing pool.
212    ///
213    /// Use this to share one connection pool with the rest of an application
214    /// instead of opening a second one. The checkpointer writes through the pool
215    /// it is given, so the caller's own queries see its rows.
216    ///
217    /// The schema is applied to the pool's database on every call, so adopting an
218    /// already-initialized database is safe.
219    ///
220    /// `SqlitePool` comes from `sqlx`, so a caller has to depend on a
221    /// semver-compatible `sqlx` to construct one:
222    ///
223    /// ```toml
224    /// sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
225    /// ```
226    ///
227    /// # Errors
228    ///
229    /// Returns `GraphError::CheckpointError` when the table or the index cannot be
230    /// created on the pool's database.
231    pub async fn from_pool(pool: sqlx::SqlitePool) -> Result<Self> {
232        // Create table
233        sqlx::query(
234            r#"
235            CREATE TABLE IF NOT EXISTS graph_checkpoints (
236                id TEXT PRIMARY KEY,
237                thread_id TEXT NOT NULL,
238                state TEXT NOT NULL,
239                step INTEGER NOT NULL,
240                pending_nodes TEXT NOT NULL,
241                metadata TEXT,
242                created_at TEXT NOT NULL,
243                cleared_interrupt TEXT,
244                attempts TEXT,
245                child_ledger TEXT
246            )
247            "#,
248        )
249        .execute(&pool)
250        .await
251        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
252
253        // A database created before `cleared_interrupt` existed keeps its old
254        // shape under CREATE TABLE IF NOT EXISTS, so add the column separately
255        // and ignore the duplicate-column error on a database that already has it.
256        for column in ["cleared_interrupt", "attempts", "child_ledger"] {
257            let _ = sqlx::query(&format!("ALTER TABLE graph_checkpoints ADD COLUMN {column} TEXT"))
258                .execute(&pool)
259                .await;
260        }
261
262        sqlx::query(
263            r#"
264            CREATE INDEX IF NOT EXISTS idx_graph_checkpoints_thread
265            ON graph_checkpoints(thread_id, created_at DESC)
266            "#,
267        )
268        .execute(&pool)
269        .await
270        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
271
272        Ok(Self { pool })
273    }
274
275    /// Create an in-memory SQLite checkpointer (for testing)
276    pub async fn in_memory() -> Result<Self> {
277        Self::new(":memory:").await
278    }
279}
280
281#[cfg(feature = "sqlite")]
282#[async_trait]
283impl Checkpointer for SqliteCheckpointer {
284    async fn save(&self, checkpoint: &Checkpoint) -> Result<String> {
285        let state_json = serde_json::to_string(&checkpoint.state)?;
286        let pending_json = serde_json::to_string(&checkpoint.pending_nodes)?;
287        let metadata_json = serde_json::to_string(&checkpoint.metadata)?;
288        let created_at = checkpoint.created_at.to_rfc3339();
289
290        sqlx::query(
291            r#"
292            INSERT INTO graph_checkpoints (id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger)
293            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
294            "#,
295        )
296        .bind(&checkpoint.checkpoint_id)
297        .bind(&checkpoint.thread_id)
298        .bind(&state_json)
299        .bind(checkpoint.step as i64)
300        .bind(&pending_json)
301        .bind(&metadata_json)
302        .bind(&created_at)
303        .bind(checkpoint.cleared_interrupt.as_deref())
304        .bind(serde_json::to_string(&checkpoint.attempts).unwrap_or_else(|_| "{}".to_string()))
305        .bind(serde_json::to_string(&checkpoint.child_ledger).unwrap_or_else(|_| "{}".to_string()))
306        .execute(&self.pool)
307        .await
308        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
309
310        Ok(checkpoint.checkpoint_id.clone())
311    }
312
313    async fn load(&self, thread_id: &str) -> Result<Option<Checkpoint>> {
314        let row: Option<(String, String, String, i64, String, String, String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
315            r#"
316            SELECT id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger
317            FROM graph_checkpoints
318            WHERE thread_id = ?
319            ORDER BY created_at DESC
320            LIMIT 1
321            "#,
322        )
323        .bind(thread_id)
324        .fetch_optional(&self.pool)
325        .await
326        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
327
328        match row {
329            Some((
330                id,
331                thread_id,
332                state,
333                step,
334                pending_nodes,
335                metadata,
336                created_at,
337                cleared_interrupt,
338                attempts,
339                child_ledger,
340            )) => {
341                let checkpoint = Checkpoint {
342                    checkpoint_id: id,
343                    thread_id,
344                    state: serde_json::from_str(&state)?,
345                    step: step as usize,
346                    pending_nodes: serde_json::from_str(&pending_nodes)?,
347                    metadata: serde_json::from_str(&metadata)?,
348                    created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
349                        .map_err(|e| GraphError::CheckpointError(e.to_string()))?
350                        .with_timezone(&chrono::Utc),
351                    cleared_interrupt,
352                    attempts: attempts
353                        .and_then(|raw| serde_json::from_str(&raw).ok())
354                        .unwrap_or_default(),
355                    child_ledger: child_ledger
356                        .and_then(|raw| serde_json::from_str(&raw).ok())
357                        .unwrap_or_default(),
358                };
359                Ok(Some(checkpoint))
360            }
361            None => Ok(None),
362        }
363    }
364
365    async fn load_by_id(&self, checkpoint_id: &str) -> Result<Option<Checkpoint>> {
366        let row: Option<(String, String, String, i64, String, String, String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
367            r#"
368            SELECT id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger
369            FROM graph_checkpoints
370            WHERE id = ?
371            "#,
372        )
373        .bind(checkpoint_id)
374        .fetch_optional(&self.pool)
375        .await
376        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
377
378        match row {
379            Some((
380                id,
381                thread_id,
382                state,
383                step,
384                pending_nodes,
385                metadata,
386                created_at,
387                cleared_interrupt,
388                attempts,
389                child_ledger,
390            )) => {
391                let checkpoint = Checkpoint {
392                    checkpoint_id: id,
393                    thread_id,
394                    state: serde_json::from_str(&state)?,
395                    step: step as usize,
396                    pending_nodes: serde_json::from_str(&pending_nodes)?,
397                    metadata: serde_json::from_str(&metadata)?,
398                    created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
399                        .map_err(|e| GraphError::CheckpointError(e.to_string()))?
400                        .with_timezone(&chrono::Utc),
401                    cleared_interrupt,
402                    attempts: attempts
403                        .and_then(|raw| serde_json::from_str(&raw).ok())
404                        .unwrap_or_default(),
405                    child_ledger: child_ledger
406                        .and_then(|raw| serde_json::from_str(&raw).ok())
407                        .unwrap_or_default(),
408                };
409                Ok(Some(checkpoint))
410            }
411            None => Ok(None),
412        }
413    }
414
415    async fn list(&self, thread_id: &str) -> Result<Vec<Checkpoint>> {
416        let rows: Vec<(String, String, String, i64, String, String, String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
417            r#"
418            SELECT id, thread_id, state, step, pending_nodes, metadata, created_at, cleared_interrupt, attempts, child_ledger
419            FROM graph_checkpoints
420            WHERE thread_id = ?
421            ORDER BY created_at ASC
422            "#,
423        )
424        .bind(thread_id)
425        .fetch_all(&self.pool)
426        .await
427        .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
428
429        let mut checkpoints = Vec::with_capacity(rows.len());
430        for (
431            id,
432            thread_id,
433            state,
434            step,
435            pending_nodes,
436            metadata,
437            created_at,
438            cleared_interrupt,
439            attempts,
440            child_ledger,
441        ) in rows
442        {
443            checkpoints.push(Checkpoint {
444                checkpoint_id: id,
445                thread_id,
446                state: serde_json::from_str(&state)?,
447                step: step as usize,
448                pending_nodes: serde_json::from_str(&pending_nodes)?,
449                metadata: serde_json::from_str(&metadata)?,
450                created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
451                    .map_err(|e| GraphError::CheckpointError(e.to_string()))?
452                    .with_timezone(&chrono::Utc),
453                cleared_interrupt,
454                attempts: attempts
455                    .and_then(|raw| serde_json::from_str(&raw).ok())
456                    .unwrap_or_default(),
457                child_ledger: child_ledger
458                    .and_then(|raw| serde_json::from_str(&raw).ok())
459                    .unwrap_or_default(),
460            });
461        }
462        Ok(checkpoints)
463    }
464
465    async fn delete(&self, thread_id: &str) -> Result<()> {
466        sqlx::query("DELETE FROM graph_checkpoints WHERE thread_id = ?")
467            .bind(thread_id)
468            .execute(&self.pool)
469            .await
470            .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
471        Ok(())
472    }
473
474    async fn prune(&self, thread_id: &str, policy: &RetentionPolicy) -> Result<usize> {
475        // The policy decides, not the SQL, so both backends keep the same set.
476        let expired = policy.expired(&self.list(thread_id).await?);
477        if expired.is_empty() {
478            return Ok(0);
479        }
480        let mut removed = 0usize;
481        for checkpoint_id in &expired {
482            let result = sqlx::query("DELETE FROM graph_checkpoints WHERE id = ?")
483                .bind(checkpoint_id)
484                .execute(&self.pool)
485                .await
486                .map_err(|e| GraphError::CheckpointError(e.to_string()))?;
487            removed += result.rows_affected() as usize;
488        }
489        Ok(removed)
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::state::State;
497
498    #[tokio::test]
499    async fn test_memory_checkpointer() {
500        let cp = MemoryCheckpointer::new();
501
502        // Create and save checkpoint
503        let checkpoint = Checkpoint::new("thread_1", State::new(), 0, vec!["node_a".to_string()]);
504        let id = cp.save(&checkpoint).await.unwrap();
505        assert!(!id.is_empty());
506
507        // Load latest
508        let loaded = cp.load("thread_1").await.unwrap();
509        assert!(loaded.is_some());
510        assert_eq!(loaded.unwrap().step, 0);
511
512        // Save another checkpoint
513        let checkpoint2 = Checkpoint::new("thread_1", State::new(), 1, vec!["node_b".to_string()]);
514        cp.save(&checkpoint2).await.unwrap();
515
516        // Load latest should return step 1
517        let loaded = cp.load("thread_1").await.unwrap();
518        assert_eq!(loaded.unwrap().step, 1);
519
520        // List should return both
521        let all = cp.list("thread_1").await.unwrap();
522        assert_eq!(all.len(), 2);
523
524        // Delete
525        cp.delete("thread_1").await.unwrap();
526        let loaded = cp.load("thread_1").await.unwrap();
527        assert!(loaded.is_none());
528    }
529}