use std::future::Future;
use std::pin::Pin;
use redis::AsyncCommands;
use redis::aio::ConnectionManager;
use crate::graph::checkpoint::{
CheckpointError, CheckpointStore, GraphRunRecord, NodeVisitOutcome, check_segment,
};
use crate::tenant::TenantContext;
const GRAPH_TTL_SECS: u64 = 7 * 24 * 60 * 60;
fn run_key(t: &TenantContext, run_id: &str) -> String {
format!("{}:{}:graph", t.key_prefix(), run_id)
}
fn visit_key(t: &TenantContext, run_id: &str, node_id: &str, attempt: u32) -> String {
format!(
"{}:{}:graph:visit:{}:{}",
t.key_prefix(),
run_id,
node_id,
attempt
)
}
pub struct RedisCheckpointStore {
manager: ConnectionManager,
}
impl RedisCheckpointStore {
pub fn new(manager: ConnectionManager) -> Self {
Self { manager }
}
pub fn manager(&self) -> ConnectionManager {
self.manager.clone()
}
pub async fn connect(url: &str) -> Result<Self, CheckpointError> {
let client =
redis::Client::open(url).map_err(|e| CheckpointError::Backend(format!("open: {e}")))?;
let manager = ConnectionManager::new(client)
.await
.map_err(|e| CheckpointError::Backend(format!("connect: {e}")))?;
Ok(Self { manager })
}
}
impl CheckpointStore for RedisCheckpointStore {
fn load<'a>(
&'a self,
tenant: &'a TenantContext,
run_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<GraphRunRecord>, CheckpointError>> + Send + 'a>>
{
Box::pin(async move {
let key = run_key(tenant, run_id);
let mut conn = self.manager.clone();
let raw: Option<String> = conn
.get(&key)
.await
.map_err(|e| CheckpointError::Backend(format!("get: {e}")))?;
let Some(json) = raw else {
return Ok(None);
};
let record: GraphRunRecord =
serde_json::from_str(&json).map_err(CheckpointError::Serde)?;
Ok(Some(record))
})
}
fn save<'a>(
&'a self,
tenant: &'a TenantContext,
rec: &'a GraphRunRecord,
) -> Pin<Box<dyn Future<Output = Result<(), CheckpointError>> + Send + 'a>> {
Box::pin(async move {
check_segment("run_id", &rec.run_id)?;
let key = run_key(tenant, &rec.run_id);
let json = serde_json::to_string(rec).map_err(CheckpointError::Serde)?;
let mut conn = self.manager.clone();
let _: () = conn
.set_ex(&key, json, GRAPH_TTL_SECS)
.await
.map_err(|e| CheckpointError::Backend(format!("set_ex: {e}")))?;
Ok(())
})
}
fn record_node_visit<'a>(
&'a self,
tenant: &'a TenantContext,
run_id: &'a str,
node_id: &'a str,
attempt: u32,
result: &'a serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<NodeVisitOutcome, CheckpointError>> + Send + 'a>> {
Box::pin(async move {
check_segment("run_id", run_id)?;
check_segment("node_id", node_id)?;
let key = visit_key(tenant, run_id, node_id, attempt);
let value = serde_json::to_string(result).map_err(CheckpointError::Serde)?;
let mut conn = self.manager.clone();
let res: Option<String> = redis::cmd("SET")
.arg(&key)
.arg(&value)
.arg("NX")
.arg("EX")
.arg(GRAPH_TTL_SECS)
.query_async(&mut conn)
.await
.map_err(|e| CheckpointError::Backend(format!("set nx: {e}")))?;
if res.is_some() {
return Ok(NodeVisitOutcome::Recorded);
}
let existing_raw: Option<String> = conn
.get(&key)
.await
.map_err(|e| CheckpointError::Backend(format!("get after nx miss: {e}")))?;
let existing_json = existing_raw.ok_or_else(|| {
CheckpointError::Backend(
"record_node_visit: NX reported key exists but GET returned nil \
(race or eviction)"
.into(),
)
})?;
let existing: serde_json::Value =
serde_json::from_str(&existing_json).map_err(CheckpointError::Serde)?;
Ok(NodeVisitOutcome::Replayed(existing))
})
}
fn load_node_visit<'a>(
&'a self,
tenant: &'a TenantContext,
run_id: &'a str,
node_id: &'a str,
attempt: u32,
) -> Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, CheckpointError>> + Send + 'a>>
{
Box::pin(async move {
let key = visit_key(tenant, run_id, node_id, attempt);
let mut conn = self.manager.clone();
let raw: Option<String> = conn
.get(&key)
.await
.map_err(|e| CheckpointError::Backend(format!("get visit: {e}")))?;
let Some(json) = raw else {
return Ok(None);
};
let val: serde_json::Value =
serde_json::from_str(&json).map_err(CheckpointError::Serde)?;
Ok(Some(val))
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::graph::checkpoint::{GraphRunRecord, RunStatus};
fn make_record(run_id: &str) -> GraphRunRecord {
GraphRunRecord {
run_id: run_id.into(),
graph_json: "{}".into(),
cursor: "agent".into(),
state_json: "{}".into(),
status: RunStatus::Running,
visits_json: "{}".into(),
frontier_json: None,
}
}
async fn connect(url: &str) -> RedisCheckpointStore {
RedisCheckpointStore::connect(url)
.await
.expect("redis connect")
}
#[tokio::test]
async fn redis_checkpoint_round_trip_and_nx_semantics() {
let Ok(url) = std::env::var("REDIS_URL") else {
eprintln!("skipped: no REDIS_URL");
return;
};
let store = connect(&url).await;
let unique = uuid::Uuid::new_v4().to_string();
let run_id = format!("test-run-{unique}");
let node_id = "agent__node";
let tenant = TenantContext::new("test-tenant", "test-env");
let loaded = store.load(&tenant, &run_id).await.unwrap();
assert!(loaded.is_none(), "expected None for absent run");
let mut rec = make_record(&run_id);
rec.cursor = "router".into();
rec.status = RunStatus::Running;
store.save(&tenant, &rec).await.unwrap();
let loaded = store.load(&tenant, &run_id).await.unwrap().unwrap();
assert_eq!(loaded.cursor, "router");
assert_eq!(loaded.status, RunStatus::Running);
let mut rec2 = rec.clone();
rec2.cursor = "end".into();
rec2.status = RunStatus::Succeeded;
store.save(&tenant, &rec2).await.unwrap();
let loaded2 = store.load(&tenant, &run_id).await.unwrap().unwrap();
assert_eq!(loaded2.cursor, "end");
assert_eq!(loaded2.status, RunStatus::Succeeded);
let first_val = serde_json::json!({"reply": "first-answer"});
let second_val = serde_json::json!({"reply": "second-answer-should-not-win"});
let outcome1 = store
.record_node_visit(&tenant, &run_id, node_id, 0, &first_val)
.await
.unwrap();
assert_eq!(
outcome1,
NodeVisitOutcome::Recorded,
"first write must be Recorded"
);
let outcome2 = store
.record_node_visit(&tenant, &run_id, node_id, 0, &second_val)
.await
.unwrap();
assert_eq!(
outcome2,
NodeVisitOutcome::Replayed(first_val.clone()),
"second write must replay the first value"
);
let loaded_visit = store
.load_node_visit(&tenant, &run_id, node_id, 0)
.await
.unwrap()
.unwrap();
assert_eq!(
loaded_visit, first_val,
"load_node_visit must return the first-written value"
);
let other_tenant = TenantContext::new("test-tenant", "other-env");
let isolated = store.load(&other_tenant, &run_id).await.unwrap();
assert!(
isolated.is_none(),
"different env_id must not see the run from the original tenant"
);
let bad_rec = make_record(&format!("bad:run:{unique}"));
let err = store.save(&tenant, &bad_rec).await.unwrap_err();
assert!(
matches!(err, CheckpointError::Backend(ref msg) if msg.contains("run_id")),
"expected Backend error mentioning run_id, got {err:?}"
);
}
}