use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use redis::AsyncCommands;
use redis::aio::ConnectionManager;
use crate::error::StateError;
use crate::state::{
AgentStateStore, ConversationState, STATE_SCHEMA_VERSION, SessionLock, SessionLockInner,
};
use crate::tenant::TenantContext;
const STATE_TTL_SECS: u64 = 7 * 24 * 60 * 60; const LOCK_TTL_SECS: i64 = 90;
const LOCK_POLL_MS: u64 = 50;
const REFRESH_LUA: &str = r#"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end
"#;
const RELEASE_LUA: &str = r#"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"#;
pub struct RedisAgentStateStore {
manager: ConnectionManager,
}
impl RedisAgentStateStore {
pub fn new(manager: ConnectionManager) -> Self {
Self { manager }
}
pub async fn connect(url: &str) -> Result<Self, StateError> {
let client =
redis::Client::open(url).map_err(|e| StateError::Redis(format!("open: {e}")))?;
let manager = ConnectionManager::new(client)
.await
.map_err(|e| StateError::Redis(format!("connect: {e}")))?;
Ok(Self { manager })
}
pub fn manager(&self) -> ConnectionManager {
self.manager.clone()
}
fn state_key(tenant: &TenantContext, session_id: &str) -> String {
format!("{}:{session_id}:state", tenant.key_prefix())
}
fn lock_key(tenant: &TenantContext, session_id: &str) -> String {
format!("{}:{session_id}:lock", tenant.key_prefix())
}
}
impl AgentStateStore for RedisAgentStateStore {
fn load<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<ConversationState, StateError>> + Send + 'a>> {
Box::pin(async move {
let key = Self::state_key(tenant, session_id);
let mut conn = self.manager.clone();
let raw: Option<String> = conn
.get(&key)
.await
.map_err(|e| StateError::Redis(format!("get: {e}")))?;
let Some(json) = raw else {
return Ok(ConversationState::empty(tenant, session_id));
};
let state: ConversationState = serde_json::from_str(&json)
.map_err(|e| StateError::Decode(format!("state json: {e}")))?;
if state.schema_version > STATE_SCHEMA_VERSION {
return Err(StateError::SchemaIncompatible {
found: state.schema_version,
supported: STATE_SCHEMA_VERSION,
});
}
Ok(state)
})
}
fn save<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
state: &'a ConversationState,
) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>> {
Box::pin(async move {
let key = Self::state_key(tenant, session_id);
let json = serde_json::to_string(state)
.map_err(|e| StateError::Decode(format!("state json: {e}")))?;
let mut conn = self.manager.clone();
let _: () = conn
.set_ex(&key, json, STATE_TTL_SECS)
.await
.map_err(|e| StateError::Redis(format!("set_ex: {e}")))?;
Ok(())
})
}
fn acquire_lock<'a>(
&'a self,
tenant: &'a TenantContext,
session_id: &'a str,
wait: Duration,
) -> Pin<Box<dyn Future<Output = Result<SessionLock, StateError>> + Send + 'a>> {
Box::pin(async move {
let key = Self::lock_key(tenant, session_id);
let value = uuid::Uuid::new_v4().to_string();
let deadline = std::time::Instant::now() + wait;
loop {
let mut conn = self.manager.clone();
let res: Option<String> = redis::cmd("SET")
.arg(&key)
.arg(&value)
.arg("NX")
.arg("EX")
.arg(LOCK_TTL_SECS)
.query_async(&mut conn)
.await
.map_err(|e| StateError::Redis(format!("set nx: {e}")))?;
if res.is_some() {
let inner = RedisSessionLock {
manager: self.manager.clone(),
key,
value,
};
return Ok(SessionLock::new(Box::new(inner)));
}
if std::time::Instant::now() >= deadline {
return Err(StateError::LockTimeout(wait));
}
tokio::time::sleep(Duration::from_millis(LOCK_POLL_MS)).await;
}
})
}
}
struct RedisSessionLock {
manager: ConnectionManager,
key: String,
value: String,
}
impl SessionLockInner for RedisSessionLock {
fn refresh<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<(), StateError>> + Send + 'a>> {
Box::pin(async move {
let mut conn = self.manager.clone();
let refreshed: i64 = redis::Script::new(REFRESH_LUA)
.key(&self.key)
.arg(&self.value)
.arg(LOCK_TTL_SECS)
.invoke_async(&mut conn)
.await
.map_err(|e| StateError::Redis(format!("refresh eval: {e}")))?;
if refreshed == 1 {
Ok(())
} else {
Err(StateError::Redis(
"lock no longer owned by this holder".into(),
))
}
})
}
fn release(&self) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let mut conn = self.manager.clone();
let key = self.key.clone();
let value = self.value.clone();
handle.spawn(async move {
let _: Result<i64, _> = redis::Script::new(RELEASE_LUA)
.key(&key)
.arg(&value)
.invoke_async(&mut conn)
.await;
});
}
}
}