1#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct RetentionPolicy {
32 pub max_per_thread: Option<usize>,
34 pub max_age: Option<std::time::Duration>,
36}
37
38impl RetentionPolicy {
39 pub fn keep_last(count: usize) -> Self {
43 Self { max_per_thread: Some(count.max(1)), max_age: None }
44 }
45
46 pub fn max_age(age: std::time::Duration) -> Self {
48 Self { max_per_thread: None, max_age: Some(age) }
49 }
50
51 pub fn with_max_age(mut self, age: std::time::Duration) -> Self {
53 self.max_age = Some(age);
54 self
55 }
56
57 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 pub fn is_unlimited(&self) -> bool {
65 self.max_per_thread.is_none() && self.max_age.is_none()
66 }
67
68 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 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
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#[async_trait]
99pub trait Checkpointer: Send + Sync {
100 async fn save(&self, checkpoint: &Checkpoint) -> Result<String>;
102
103 async fn load(&self, thread_id: &str) -> Result<Option<Checkpoint>>;
105
106 async fn load_by_id(&self, checkpoint_id: &str) -> Result<Option<Checkpoint>>;
108
109 async fn list(&self, thread_id: &str) -> Result<Vec<Checkpoint>>;
111
112 async fn delete(&self, thread_id: &str) -> Result<()>;
114
115 async fn prune(&self, _thread_id: &str, _policy: &RetentionPolicy) -> Result<usize> {
124 Ok(0)
125 }
126}
127
128#[derive(Default)]
130pub struct MemoryCheckpointer {
131 checkpoints: Arc<RwLock<HashMap<String, Vec<Checkpoint>>>>,
132}
133
134impl MemoryCheckpointer {
135 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#[cfg(feature = "sqlite")]
196pub struct SqliteCheckpointer {
197 pool: sqlx::SqlitePool,
198}
199
200#[cfg(feature = "sqlite")]
201impl SqliteCheckpointer {
202 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 pub async fn from_pool(pool: sqlx::SqlitePool) -> Result<Self> {
232 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 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 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 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 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 let loaded = cp.load("thread_1").await.unwrap();
509 assert!(loaded.is_some());
510 assert_eq!(loaded.unwrap().step, 0);
511
512 let checkpoint2 = Checkpoint::new("thread_1", State::new(), 1, vec!["node_b".to_string()]);
514 cp.save(&checkpoint2).await.unwrap();
515
516 let loaded = cp.load("thread_1").await.unwrap();
518 assert_eq!(loaded.unwrap().step, 1);
519
520 let all = cp.list("thread_1").await.unwrap();
522 assert_eq!(all.len(), 2);
523
524 cp.delete("thread_1").await.unwrap();
526 let loaded = cp.load("thread_1").await.unwrap();
527 assert!(loaded.is_none());
528 }
529}