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 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 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 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 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 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 let loaded = cp.load("thread_1").await.unwrap();
485 assert!(loaded.is_some());
486 assert_eq!(loaded.unwrap().step, 0);
487
488 let checkpoint2 = Checkpoint::new("thread_1", State::new(), 1, vec!["node_b".to_string()]);
490 cp.save(&checkpoint2).await.unwrap();
491
492 let loaded = cp.load("thread_1").await.unwrap();
494 assert_eq!(loaded.unwrap().step, 1);
495
496 let all = cp.list("thread_1").await.unwrap();
498 assert_eq!(all.len(), 2);
499
500 cp.delete("thread_1").await.unwrap();
502 let loaded = cp.load("thread_1").await.unwrap();
503 assert!(loaded.is_none());
504 }
505}