use pe_core::PeError;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CheckpointMeta {
pub id: String,
pub thread_id: String,
pub created_at: SystemTime,
pub step: u32,
pub parent_id: Option<String>,
}
impl CheckpointMeta {
pub fn new(id: impl Into<String>, thread_id: impl Into<String>, step: u32) -> Self {
Self {
id: id.into(),
thread_id: thread_id.into(),
created_at: SystemTime::now(),
step,
parent_id: None,
}
}
pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
self.parent_id = Some(parent_id.into());
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PendingWrite {
pub node_name: String,
pub data: Vec<u8>,
pub success: bool,
}
impl PendingWrite {
pub fn new(node_name: impl Into<String>, data: Vec<u8>, success: bool) -> Self {
Self {
node_name: node_name.into(),
data,
success,
}
}
}
#[async_trait::async_trait]
pub trait Checkpointer: Send + Sync {
async fn save(
&self,
thread_id: &str,
checkpoint_id: &str,
data: &[u8],
meta: &CheckpointMeta,
) -> Result<(), PeError>;
async fn load_latest(
&self,
thread_id: &str,
) -> Result<Option<(Vec<u8>, CheckpointMeta)>, PeError>;
async fn load_by_id(
&self,
thread_id: &str,
checkpoint_id: &str,
) -> Result<Option<Vec<u8>>, PeError>;
async fn list(&self, thread_id: &str) -> Result<Vec<CheckpointMeta>, PeError>;
async fn put_writes(
&self,
thread_id: &str,
checkpoint_id: &str,
writes: &[PendingWrite],
) -> Result<(), PeError>;
async fn delete_thread(&self, thread_id: &str) -> Result<(), PeError>;
}
type CheckpointEntry = (String, Vec<u8>, CheckpointMeta);
#[derive(Debug, Clone)]
pub struct InMemoryCheckpointer {
store: Arc<RwLock<HashMap<String, Vec<CheckpointEntry>>>>,
}
impl InMemoryCheckpointer {
pub fn new() -> Self {
Self {
store: Arc::new(RwLock::new(HashMap::new())),
}
}
}
impl Default for InMemoryCheckpointer {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl Checkpointer for InMemoryCheckpointer {
async fn save(
&self,
thread_id: &str,
checkpoint_id: &str,
data: &[u8],
meta: &CheckpointMeta,
) -> Result<(), PeError> {
let mut store = self.store.write().await;
store.entry(thread_id.to_string()).or_default().push((
checkpoint_id.to_string(),
data.to_vec(),
meta.clone(),
));
Ok(())
}
async fn load_latest(
&self,
thread_id: &str,
) -> Result<Option<(Vec<u8>, CheckpointMeta)>, PeError> {
let store = self.store.read().await;
Ok(store
.get(thread_id)
.and_then(|entries| entries.last())
.map(|(_, data, meta)| (data.clone(), meta.clone())))
}
async fn load_by_id(
&self,
thread_id: &str,
checkpoint_id: &str,
) -> Result<Option<Vec<u8>>, PeError> {
let store = self.store.read().await;
Ok(store
.get(thread_id)
.and_then(|entries| entries.iter().find(|(id, _, _)| id == checkpoint_id))
.map(|(_, data, _)| data.clone()))
}
async fn list(&self, thread_id: &str) -> Result<Vec<CheckpointMeta>, PeError> {
let store = self.store.read().await;
Ok(store
.get(thread_id)
.map(|entries| entries.iter().map(|(_, _, meta)| meta.clone()).collect())
.unwrap_or_default())
}
async fn put_writes(
&self,
_thread_id: &str,
_checkpoint_id: &str,
_writes: &[PendingWrite],
) -> Result<(), PeError> {
Ok(())
}
async fn delete_thread(&self, thread_id: &str) -> Result<(), PeError> {
let mut store = self.store.write().await;
store.remove(thread_id);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_meta(id: &str, thread: &str, step: u32) -> CheckpointMeta {
CheckpointMeta::new(id, thread, step)
}
#[tokio::test]
async fn test_save_and_load_latest() {
let cp = InMemoryCheckpointer::new();
let meta = make_meta("cp-1", "t1", 1);
cp.save("t1", "cp-1", b"state-data", &meta).await.unwrap();
let (data, loaded_meta) = cp.load_latest("t1").await.unwrap().unwrap();
assert_eq!(data, b"state-data");
assert_eq!(loaded_meta.id, "cp-1");
assert_eq!(loaded_meta.step, 1);
}
#[tokio::test]
async fn test_load_latest_returns_most_recent() {
let cp = InMemoryCheckpointer::new();
cp.save("t1", "cp-1", b"first", &make_meta("cp-1", "t1", 1))
.await
.unwrap();
cp.save("t1", "cp-2", b"second", &make_meta("cp-2", "t1", 2))
.await
.unwrap();
let (data, meta) = cp.load_latest("t1").await.unwrap().unwrap();
assert_eq!(data, b"second");
assert_eq!(meta.id, "cp-2");
}
#[tokio::test]
async fn test_load_by_id() {
let cp = InMemoryCheckpointer::new();
cp.save("t1", "cp-1", b"first", &make_meta("cp-1", "t1", 1))
.await
.unwrap();
cp.save("t1", "cp-2", b"second", &make_meta("cp-2", "t1", 2))
.await
.unwrap();
let data = cp.load_by_id("t1", "cp-1").await.unwrap().unwrap();
assert_eq!(data, b"first");
}
#[tokio::test]
async fn test_empty_thread_returns_none() {
let cp = InMemoryCheckpointer::new();
assert!(cp.load_latest("nonexistent").await.unwrap().is_none());
assert!(cp.load_by_id("nope", "nope").await.unwrap().is_none());
}
#[tokio::test]
async fn test_list_checkpoints() {
let cp = InMemoryCheckpointer::new();
cp.save("t1", "cp-1", b"a", &make_meta("cp-1", "t1", 1))
.await
.unwrap();
cp.save("t1", "cp-2", b"b", &make_meta("cp-2", "t1", 2))
.await
.unwrap();
let metas = cp.list("t1").await.unwrap();
assert_eq!(metas.len(), 2);
assert_eq!(metas[0].id, "cp-1");
assert_eq!(metas[1].id, "cp-2");
}
#[tokio::test]
async fn test_checkpoint_meta_parent_id_default_none() {
let meta = CheckpointMeta::new("cp-1", "t1", 1);
assert!(meta.parent_id.is_none());
}
#[tokio::test]
async fn test_checkpoint_meta_with_parent() {
let meta = CheckpointMeta::new("cp-2", "t1", 2).with_parent("cp-1");
assert_eq!(meta.parent_id.as_deref(), Some("cp-1"));
assert_eq!(meta.id, "cp-2");
assert_eq!(meta.step, 2);
}
#[tokio::test]
async fn test_parent_id_preserved_through_save_load() {
let cp = InMemoryCheckpointer::new();
let meta1 = make_meta("cp-1", "t1", 1);
cp.save("t1", "cp-1", b"first", &meta1).await.unwrap();
let meta2 = CheckpointMeta::new("cp-2", "t1", 2).with_parent("cp-1");
cp.save("t1", "cp-2", b"second", &meta2).await.unwrap();
let (_data, loaded_meta) = cp.load_latest("t1").await.unwrap().unwrap();
assert_eq!(loaded_meta.id, "cp-2");
assert_eq!(loaded_meta.parent_id.as_deref(), Some("cp-1"));
let metas = cp.list("t1").await.unwrap();
assert!(metas[0].parent_id.is_none()); assert_eq!(metas[1].parent_id.as_deref(), Some("cp-1")); }
}