Skip to main content

pleme_redis/
cache.rs

1//! General-purpose caching with TTL
2//!
3//! Features:
4//! - Set/get/delete cached values
5//! - Product-scoped keys (multi-tenant safe)
6//! - JSON serialization support
7//! - TTL-based expiration
8//!
9//! Uses ConnectionManager for efficient multiplexed connections.
10//! See `.claude/skills/connection-pooling-architecture` for patterns.
11
12use redis::aio::ConnectionManager;
13use redis::{AsyncCommands, Client};
14use serde::{de::DeserializeOwned, Serialize};
15use tokio::sync::OnceCell;
16use uuid::Uuid;
17
18use crate::error::{RedisError, Result};
19use crate::RedisConfig;
20
21/// Cache manager for general-purpose caching
22///
23/// Uses ConnectionManager for multiplexed connections instead of creating
24/// a new connection per operation. This provides:
25/// - Automatic reconnection on failure
26/// - Efficient connection reuse
27/// - No pool configuration complexity
28#[derive(Clone)]
29pub struct CacheManager {
30    client: Client,
31    cache_ttl_seconds: u64,
32    /// Lazily initialized multiplexed connection
33    conn: OnceCell<ConnectionManager>,
34}
35
36impl CacheManager {
37    /// Create a new cache manager
38    pub fn new(config: &RedisConfig) -> Result<Self> {
39        let client = Client::open(config.url.as_str())
40            .map_err(|e| RedisError::Connection(format!("Failed to create Redis client: {}", e)))?;
41
42        Ok(Self {
43            client,
44            cache_ttl_seconds: config.cache_ttl_seconds,
45            conn: OnceCell::new(),
46        })
47    }
48
49    /// Get multiplexed Redis connection (lazy initialized)
50    ///
51    /// ConnectionManager provides:
52    /// - Single multiplexed connection for all operations
53    /// - Automatic reconnection on failure
54    /// - Cheaply cloneable (Arc-based internally)
55    async fn get_connection(&self) -> Result<ConnectionManager> {
56        let conn = self.conn.get_or_try_init(|| async {
57            self.client
58                .get_connection_manager()
59                .await
60                .map_err(|e| RedisError::Connection(format!("Failed to get Redis connection manager: {}", e)))
61        }).await?;
62
63        Ok(conn.clone())
64    }
65
66    /// Set a string value in cache
67    pub async fn set_string(&self, key: &str, value: &str) -> Result<()> {
68        let mut conn = self.get_connection().await?;
69
70        conn.set_ex::<_, _, ()>(key, value, self.cache_ttl_seconds)
71            .await
72            .map_err(|e| RedisError::Operation(format!("Failed to set cache: {}", e)))?;
73
74        Ok(())
75    }
76
77    /// Get a string value from cache
78    pub async fn get_string(&self, key: &str) -> Result<Option<String>> {
79        let mut conn = self.get_connection().await?;
80
81        let value: Option<String> = conn
82            .get(key)
83            .await
84            .map_err(|e| RedisError::Operation(format!("Failed to get cache: {}", e)))?;
85
86        Ok(value)
87    }
88
89    /// Set a JSON-serializable value in cache
90    pub async fn set_json<T: Serialize>(&self, key: &str, value: &T) -> Result<()> {
91        let json = serde_json::to_string(value)?;
92        self.set_string(key, &json).await
93    }
94
95    /// Get a JSON-deserializable value from cache
96    pub async fn get_json<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
97        match self.get_string(key).await? {
98            Some(json) => {
99                let value = serde_json::from_str(&json)?;
100                Ok(Some(value))
101            }
102            None => Ok(None),
103        }
104    }
105
106    /// Delete a cached value
107    pub async fn delete(&self, key: &str) -> Result<()> {
108        let mut conn = self.get_connection().await?;
109
110        conn.del::<_, ()>(key)
111            .await
112            .map_err(|e| RedisError::Operation(format!("Failed to delete cache: {}", e)))?;
113
114        Ok(())
115    }
116
117    /// Delete multiple cached values
118    pub async fn delete_many(&self, keys: &[&str]) -> Result<()> {
119        if keys.is_empty() {
120            return Ok(());
121        }
122
123        let mut conn = self.get_connection().await?;
124
125        conn.del::<_, ()>(keys)
126            .await
127            .map_err(|e| RedisError::Operation(format!("Failed to delete caches: {}", e)))?;
128
129        Ok(())
130    }
131
132    /// Check if a key exists
133    pub async fn exists(&self, key: &str) -> Result<bool> {
134        let mut conn = self.get_connection().await?;
135
136        let exists: bool = conn
137            .exists(key)
138            .await
139            .map_err(|e| RedisError::Operation(format!("Failed to check key existence: {}", e)))?;
140
141        Ok(exists)
142    }
143
144    /// Set with custom TTL
145    pub async fn set_string_with_ttl(&self, key: &str, value: &str, ttl_seconds: u64) -> Result<()> {
146        let mut conn = self.get_connection().await?;
147
148        conn.set_ex::<_, _, ()>(key, value, ttl_seconds)
149            .await
150            .map_err(|e| RedisError::Operation(format!("Failed to set cache with TTL: {}", e)))?;
151
152        Ok(())
153    }
154
155    /// Helper: Create product-scoped key
156    ///
157    /// Example: cache_key("conversations", user_id, "novaskyn") -> "cache:novaskyn:conversations:{user_id}"
158    pub fn scoped_key(&self, namespace: &str, id: Uuid, product: &str) -> String {
159        format!("cache:{}:{}:{}", product, namespace, id)
160    }
161
162    /// Helper: Create simple product-scoped key
163    ///
164    /// Example: simple_key("config", "novaskyn") -> "cache:novaskyn:config"
165    pub fn simple_key(&self, namespace: &str, product: &str) -> String {
166        format!("cache:{}:{}", product, namespace)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_scoped_key_format() {
176        let config = RedisConfig::default();
177        let manager = CacheManager::new(&config).unwrap();
178
179        let user_id = Uuid::new_v4();
180        let key = manager.scoped_key("conversations", user_id, "novaskyn");
181        assert_eq!(key, format!("cache:novaskyn:conversations:{}", user_id));
182    }
183
184    #[test]
185    fn test_simple_key_format() {
186        let config = RedisConfig::default();
187        let manager = CacheManager::new(&config).unwrap();
188
189        let key = manager.simple_key("config", "novaskyn");
190        assert_eq!(key, "cache:novaskyn:config");
191    }
192
193    #[test]
194    fn test_product_scoping() {
195        let config = RedisConfig::default();
196        let manager = CacheManager::new(&config).unwrap();
197
198        let user_id = Uuid::new_v4();
199        let key1 = manager.scoped_key("conversations", user_id, "novaskyn");
200        let key2 = manager.scoped_key("conversations", user_id, "lilitu");
201
202        assert_ne!(key1, key2);
203        assert!(key1.contains("novaskyn"));
204        assert!(key2.contains("lilitu"));
205    }
206}