Skip to main content

synaptic_redis/
cache.rs

1use async_trait::async_trait;
2use redis::AsyncCommands;
3use synaptic_core::{ChatResponse, SynapticError};
4
5use crate::connection::{collect_matching_keys, RedisBackend, RedisConn};
6
7/// Configuration for [`RedisCache`].
8#[derive(Debug, Clone)]
9pub struct RedisCacheConfig {
10    /// Key prefix for all cache entries. Defaults to `"synaptic:cache:"`.
11    pub prefix: String,
12    /// Optional TTL in seconds. When set, cached entries expire automatically.
13    pub ttl: Option<u64>,
14}
15
16impl Default for RedisCacheConfig {
17    fn default() -> Self {
18        Self {
19            prefix: "synaptic:cache:".to_string(),
20            ttl: None,
21        }
22    }
23}
24
25/// Redis-backed implementation of the [`LlmCache`](synaptic_core::LlmCache) trait.
26///
27/// Stores serialized [`ChatResponse`] values under `{prefix}{key}` with
28/// optional TTL expiration managed by Redis itself.
29///
30/// Supports both standalone Redis and Redis Cluster (with the `cluster` feature).
31pub struct RedisCache {
32    backend: RedisBackend,
33    config: RedisCacheConfig,
34}
35
36impl RedisCache {
37    /// Create a new `RedisCache` from a Redis URL with default configuration.
38    pub fn from_url(url: &str) -> Result<Self, SynapticError> {
39        Ok(Self {
40            backend: RedisBackend::standalone(url)?,
41            config: RedisCacheConfig::default(),
42        })
43    }
44
45    /// Create a new `RedisCache` from a Redis URL with custom configuration.
46    pub fn from_url_with_config(
47        url: &str,
48        config: RedisCacheConfig,
49    ) -> Result<Self, SynapticError> {
50        Ok(Self {
51            backend: RedisBackend::standalone(url)?,
52            config,
53        })
54    }
55
56    /// Create a new `RedisCache` from an existing [`RedisBackend`].
57    #[allow(dead_code)]
58    pub(crate) fn from_backend(backend: RedisBackend, config: RedisCacheConfig) -> Self {
59        Self { backend, config }
60    }
61
62    /// Create a new `RedisCache` connecting to a Redis Cluster.
63    #[cfg(feature = "cluster")]
64    pub fn from_cluster_nodes(nodes: &[&str]) -> Result<Self, SynapticError> {
65        Ok(Self {
66            backend: RedisBackend::cluster(nodes)?,
67            config: RedisCacheConfig::default(),
68        })
69    }
70
71    /// Create a new `RedisCache` connecting to a Redis Cluster with custom configuration.
72    #[cfg(feature = "cluster")]
73    pub fn from_cluster_nodes_with_config(
74        nodes: &[&str],
75        config: RedisCacheConfig,
76    ) -> Result<Self, SynapticError> {
77        Ok(Self {
78            backend: RedisBackend::cluster(nodes)?,
79            config,
80        })
81    }
82
83    /// Build the full Redis key for a cache entry.
84    fn redis_key(&self, key: &str) -> String {
85        format!("{}{key}", self.config.prefix)
86    }
87
88    async fn get_connection(&self) -> Result<RedisConn, SynapticError> {
89        self.backend.get_connection().await
90    }
91}
92
93/// Helper to GET a key from Redis as an `Option<String>`.
94async fn redis_get_string(con: &mut RedisConn, key: &str) -> Result<Option<String>, SynapticError> {
95    let raw: Option<String> = con
96        .get(key)
97        .await
98        .map_err(|e| SynapticError::Cache(format!("Redis GET error: {e}")))?;
99    Ok(raw)
100}
101
102#[async_trait]
103impl synaptic_core::LlmCache for RedisCache {
104    async fn get(&self, key: &str) -> Result<Option<ChatResponse>, SynapticError> {
105        let mut con = self.get_connection().await?;
106        let redis_key = self.redis_key(key);
107
108        let raw = redis_get_string(&mut con, &redis_key).await?;
109
110        match raw {
111            Some(json_str) => {
112                let response: ChatResponse = serde_json::from_str(&json_str)
113                    .map_err(|e| SynapticError::Cache(format!("JSON deserialize error: {e}")))?;
114                Ok(Some(response))
115            }
116            None => Ok(None),
117        }
118    }
119
120    async fn put(&self, key: &str, response: &ChatResponse) -> Result<(), SynapticError> {
121        let mut con = self.get_connection().await?;
122        let redis_key = self.redis_key(key);
123
124        let json_str = serde_json::to_string(response)
125            .map_err(|e| SynapticError::Cache(format!("JSON serialize error: {e}")))?;
126
127        con.set::<_, _, ()>(&redis_key, &json_str)
128            .await
129            .map_err(|e| SynapticError::Cache(format!("Redis SET error: {e}")))?;
130
131        // Apply TTL if configured
132        if let Some(ttl_secs) = self.config.ttl {
133            con.expire::<_, ()>(&redis_key, ttl_secs as i64)
134                .await
135                .map_err(|e| SynapticError::Cache(format!("Redis EXPIRE error: {e}")))?;
136        }
137
138        Ok(())
139    }
140
141    async fn clear(&self) -> Result<(), SynapticError> {
142        let mut con = self.get_connection().await?;
143        let pattern = format!("{}*", self.config.prefix);
144
145        // Collect all matching keys (SCAN for standalone, KEYS for cluster)
146        let keys = collect_matching_keys(&mut con, &pattern).await?;
147
148        if !keys.is_empty() {
149            // Delete in batches to avoid issues with large key sets
150            for chunk in keys.chunks(100) {
151                con.del::<_, ()>(chunk)
152                    .await
153                    .map_err(|e| SynapticError::Cache(format!("Redis DEL error: {e}")))?;
154            }
155        }
156
157        Ok(())
158    }
159}