use std::time::Duration;
use async_trait::async_trait;
use redis::{aio::ConnectionLike, aio::ConnectionManager, AsyncCommands};
use crate::schemas::{
memory::{BaseMemory, MemoryError},
messages::Message,
};
pub const DEFAULT_REDIS_KEY_PREFIX: &str = "langchain:message_history:";
pub struct RedisMessageHistory<C = ConnectionManager> {
connection: C,
session_id: String,
key: String,
ttl: Option<Duration>,
}
impl RedisMessageHistory<ConnectionManager> {
pub async fn from_url(url: &str, session_id: impl Into<String>) -> Result<Self, MemoryError> {
let client = redis::Client::open(url)?;
let connection = ConnectionManager::new(client).await?;
Ok(Self::new(connection, session_id))
}
}
impl<C> RedisMessageHistory<C>
where
C: ConnectionLike + Clone + Send + Sync,
{
pub fn new(connection: C, session_id: impl Into<String>) -> Self {
let session_id = session_id.into();
Self {
connection,
key: format!("{DEFAULT_REDIS_KEY_PREFIX}{session_id}"),
session_id,
ttl: None,
}
}
pub fn with_key_prefix(mut self, prefix: impl AsRef<str>) -> Self {
self.key = format!("{}{}", prefix.as_ref(), self.session_id);
self
}
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn key(&self) -> &str {
&self.key
}
}
#[async_trait]
impl<C> BaseMemory for RedisMessageHistory<C>
where
C: ConnectionLike + Clone + Send + Sync,
{
async fn messages(&self) -> Result<Vec<Message>, MemoryError> {
let mut connection = self.connection.clone();
let rows: Vec<String> = connection.lrange(&self.key, 0, -1).await?;
rows.iter()
.map(|raw| serde_json::from_str(raw).map_err(MemoryError::from))
.collect()
}
async fn add_message(&mut self, message: Message) -> Result<(), MemoryError> {
let raw = serde_json::to_string(&message)?;
match self.ttl {
None => {
let _: () = self.connection.rpush(&self.key, raw).await?;
}
Some(ttl) => {
let seconds = ttl.as_secs().max(1) as i64;
let _: () = redis::pipe()
.atomic()
.rpush(&self.key, raw)
.ignore()
.expire(&self.key, seconds)
.ignore()
.query_async(&mut self.connection)
.await?;
}
}
Ok(())
}
async fn clear(&mut self) -> Result<(), MemoryError> {
let _: () = self.connection.del(&self.key).await?;
Ok(())
}
}
impl<C> From<RedisMessageHistory<C>> for std::sync::Arc<tokio::sync::Mutex<dyn BaseMemory>>
where
C: ConnectionLike + Clone + Send + Sync + 'static,
{
fn from(memory: RedisMessageHistory<C>) -> Self {
std::sync::Arc::new(tokio::sync::Mutex::new(memory))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use redis::{cmd, pipe, ErrorKind, RedisError};
use redis_test::{MockCmd, MockRedisConnection};
use serde_json::json;
use tokio::sync::Mutex;
use super::*;
use crate::{
chain::{builder::ConversationalChainBuilder, Chain},
schemas::{FunctionCallResponse, MessageType},
test_utils::FakeLLM,
};
const KEY: &str = "langchain:message_history:s1";
fn json_of(message: &Message) -> String {
serde_json::to_string(message).unwrap()
}
fn list<const N: usize>(items: [String; N]) -> redis::Value {
redis::Value::Array(
items
.into_iter()
.map(|s| redis::Value::BulkString(s.into_bytes()))
.collect(),
)
}
#[tokio::test]
async fn add_appends_json_and_messages_reads_the_list_in_order() {
let human = Message::new_human_message("hi");
let ai = Message::new_ai_message("hello");
let conn = MockRedisConnection::new([
MockCmd::new(cmd("RPUSH").arg(KEY).arg(json_of(&human)), Ok(1)),
MockCmd::new(cmd("RPUSH").arg(KEY).arg(json_of(&ai)), Ok(2)),
MockCmd::new(
cmd("LRANGE").arg(KEY).arg(0).arg(-1),
Ok(list([json_of(&human), json_of(&ai)])),
),
])
.assert_all_commands_consumed();
let mut memory = RedisMessageHistory::new(conn, "s1");
assert_eq!(memory.key(), KEY);
assert_eq!(memory.session_id(), "s1");
memory.add_user_message(&"hi").await.unwrap();
memory.add_ai_message(&"hello").await.unwrap();
let messages = memory.messages().await.unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].message_type, MessageType::HumanMessage);
assert_eq!(messages[0].content, "hi");
assert_eq!(messages[1].message_type, MessageType::AIMessage);
assert_eq!(messages[1].content, "hello");
}
#[tokio::test]
async fn ttl_pushes_and_expires_atomically() {
let message = Message::new_human_message("hi");
let conn = MockRedisConnection::new([MockCmd::with_values(
pipe()
.atomic()
.cmd("RPUSH")
.arg("chat:s1")
.arg(json_of(&message))
.ignore()
.cmd("EXPIRE")
.arg("chat:s1")
.arg(90)
.ignore(),
Ok(vec![
redis::Value::Okay,
redis::Value::Okay,
redis::Value::Okay,
redis::Value::Array(vec![]),
]),
)])
.assert_all_commands_consumed();
let mut memory = RedisMessageHistory::new(conn, "s1")
.with_key_prefix("chat:")
.with_ttl(Duration::from_secs(90));
assert_eq!(memory.key(), "chat:s1");
memory.add_message(message).await.unwrap();
}
#[tokio::test]
async fn clear_deletes_the_key() {
let conn = MockRedisConnection::new([MockCmd::new(cmd("DEL").arg(KEY), Ok(1))])
.assert_all_commands_consumed();
RedisMessageHistory::new(conn, "s1").clear().await.unwrap();
}
#[tokio::test]
async fn redis_and_decoding_errors_surface_as_memory_errors() {
let conn = MockRedisConnection::new([
MockCmd::new(
cmd("LRANGE").arg(KEY).arg(0).arg(-1),
Err::<String, _>(RedisError::from((ErrorKind::Io, "connection refused"))),
),
MockCmd::new(
cmd("LRANGE").arg(KEY).arg(0).arg(-1),
Ok(list(["not json".to_string()])),
),
]);
let memory = RedisMessageHistory::new(conn, "s1");
let err = memory.messages().await.unwrap_err();
assert!(matches!(err, MemoryError::Storage(_)), "{err:?}");
assert!(err.to_string().contains("connection refused"), "{err}");
let err = memory.messages().await.unwrap_err();
assert!(matches!(err, MemoryError::Serialization(_)), "{err:?}");
}
#[tokio::test]
async fn tool_calls_round_trip_through_the_stored_json() {
let call: FunctionCallResponse = serde_json::from_value(json!({
"id": "call_1",
"type": "function",
"function": { "name": "search", "arguments": "{\"q\":\"rust\"}" }
}))
.unwrap();
let ai = Message::new_ai_message("").with_tool_calls(json!([call]));
let tool = Message::new_tool_message("42", "call_1");
let conn = MockRedisConnection::new([MockCmd::new(
cmd("LRANGE").arg(KEY).arg(0).arg(-1),
Ok(list([json_of(&ai), json_of(&tool)])),
)]);
let messages = RedisMessageHistory::new(conn, "s1")
.messages()
.await
.unwrap();
assert_eq!(messages[0].tool_calls, ai.tool_calls);
assert_eq!(messages[1].message_type, MessageType::ToolMessage);
assert_eq!(messages[1].id.as_deref(), Some("call_1"));
}
#[tokio::test]
async fn redis_history_drives_a_conversational_chain() {
let conn = MockRedisConnection::new([
MockCmd::new(cmd("LRANGE").arg(KEY).arg(0).arg(-1), Ok(list([]))),
MockCmd::new(
cmd("RPUSH")
.arg(KEY)
.arg(json_of(&Message::new_human_message("I'm Ana"))),
Ok(1),
),
MockCmd::new(
cmd("RPUSH")
.arg(KEY)
.arg(json_of(&Message::new_ai_message("Hi Ana"))),
Ok(2),
),
])
.assert_all_commands_consumed();
let memory: Arc<Mutex<dyn BaseMemory>> = RedisMessageHistory::new(conn, "s1").into();
let chain = ConversationalChainBuilder::new()
.llm(FakeLLM::new(["Hi Ana"]))
.memory(memory)
.build()
.unwrap();
let answer = chain
.invoke(crate::prompt_args! { "input" => "I'm Ana" })
.await
.unwrap();
assert_eq!(answer, "Hi Ana");
}
#[tokio::test]
#[ignore]
async fn redis_live_round_trip_sessions_and_ttl() {
let url = std::env::var("REDIS_TEST_URL").unwrap_or("redis://127.0.0.1:6379/".into());
let prefix = format!("langchain-rust-test:{}:", uuid_like());
let mut a = RedisMessageHistory::from_url(&url, "a")
.await
.unwrap()
.with_key_prefix(&prefix)
.with_ttl(Duration::from_secs(60));
let mut b = RedisMessageHistory::from_url(&url, "b")
.await
.unwrap()
.with_key_prefix(&prefix);
a.add_user_message(&"hi from a").await.unwrap();
a.add_ai_message(&"hello a").await.unwrap();
b.add_user_message(&"hi from b").await.unwrap();
let reopened = RedisMessageHistory::from_url(&url, "a")
.await
.unwrap()
.with_key_prefix(&prefix);
let contents: Vec<String> = reopened
.messages()
.await
.unwrap()
.into_iter()
.map(|m| m.content)
.collect();
assert_eq!(contents, ["hi from a", "hello a"]);
assert_eq!(b.messages().await.unwrap().len(), 1);
let client = redis::Client::open(url.as_str()).unwrap();
let mut conn = client.get_multiplexed_async_connection().await.unwrap();
let ttl_a: i64 = conn.ttl(a.key()).await.unwrap();
let ttl_b: i64 = conn.ttl(b.key()).await.unwrap();
assert!((1..=60).contains(&ttl_a), "{ttl_a}");
assert_eq!(ttl_b, -1, "no TTL configured for b");
a.clear().await.unwrap();
b.clear().await.unwrap();
assert!(a.messages().await.unwrap().is_empty());
let exists: bool = conn.exists(b.key()).await.unwrap();
assert!(!exists);
}
fn uuid_like() -> String {
format!(
"{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
)
}
}