Skip to main content

armature_cache/
redis_cache.rs

1//! Redis cache implementation.
2
3use crate::config::CacheConfig;
4use crate::error::{CacheError, CacheResult};
5use crate::traits::CacheStore;
6use armature_log::{debug, trace};
7use async_trait::async_trait;
8use redis::{AsyncCommands, Client, aio::ConnectionManager, aio::ConnectionManagerConfig};
9use std::future::Future;
10use std::time::Duration;
11
12/// Redis cache store.
13#[derive(Clone)]
14pub struct RedisCache {
15    connection: ConnectionManager,
16    config: CacheConfig,
17}
18
19impl RedisCache {
20    /// Create a new Redis cache instance.
21    ///
22    /// # Arguments
23    ///
24    /// * `config` - Cache configuration
25    ///
26    /// # Examples
27    ///
28    /// ```no_run
29    /// use armature_cache::*;
30    ///
31    /// #[tokio::main]
32    /// async fn main() -> Result<(), CacheError> {
33    ///     let config = CacheConfig::redis("redis://localhost:6379")?;
34    ///     let cache = RedisCache::new(config).await?;
35    ///     Ok(())
36    /// }
37    /// ```
38    pub async fn new(config: CacheConfig) -> CacheResult<Self> {
39        debug!("Connecting to Redis cache: {}", config.url);
40        let client =
41            Client::open(config.url.as_str()).map_err(|e| CacheError::Connection(e.to_string()))?;
42
43        // Apply the connection tuning from `CacheConfig`:
44        //
45        // * `connection_timeout` bounds each attempt to (re)establish the TCP
46        //   connection to the server.
47        // * `max_connections` caps the number of commands the multiplexed
48        //   manager keeps in flight concurrently (the manager multiplexes over
49        //   a single socket, so this is the pool-equivalent back-pressure knob).
50        //
51        // The per-operation timeout is intentionally *not* wired into the
52        // manager's `response_timeout`: doing so would surface as an opaque
53        // `redis::RedisError`. We instead enforce `operation_timeout` ourselves
54        // (see `with_op_timeout`) so a slow op fails as `CacheError::Timeout`.
55        let mut manager_config =
56            ConnectionManagerConfig::new().set_connection_timeout(Some(config.connection_timeout));
57        if config.max_connections > 0 {
58            manager_config = manager_config.set_concurrency_limit(config.max_connections);
59        }
60
61        let connection = ConnectionManager::new_with_config(client, manager_config)
62            .await
63            .map_err(|e| CacheError::Connection(e.to_string()))?;
64
65        debug!("Redis cache connection established");
66        Ok(Self { connection, config })
67    }
68
69    /// Get the underlying connection manager.
70    pub fn connection(&self) -> &ConnectionManager {
71        &self.connection
72    }
73
74    /// Build the full key with prefix.
75    fn build_key(&self, key: &str) -> String {
76        self.config.build_key(key)
77    }
78
79    /// Run a Redis future under the configured `operation_timeout`.
80    ///
81    /// When the operation does not complete within `operation_timeout` the
82    /// future is dropped and the call resolves to [`CacheError::Timeout`]
83    /// rather than blocking indefinitely (or waiting out a much longer default
84    /// socket timeout). This is what makes `CacheError::Timeout` reachable.
85    async fn with_op_timeout<F, T>(&self, fut: F) -> CacheResult<T>
86    where
87        F: Future<Output = redis::RedisResult<T>>,
88    {
89        match tokio::time::timeout(self.config.operation_timeout, fut).await {
90            Ok(result) => Ok(result?),
91            Err(_) => Err(CacheError::Timeout),
92        }
93    }
94
95    /// `SCAN` for every key matching `pattern` and remove them all via
96    /// batched `UNLINK` calls. Used by [`CacheStore::clear`] to scope
97    /// clearing to `key_prefix` instead of `FLUSHDB`-ing the whole database.
98    ///
99    /// Keys are collected from the `SCAN` cursor first, then removed in
100    /// bounded-size `UNLINK` batches so a very large matching set doesn't
101    /// build one huge variadic command.
102    async fn scan_and_unlink(
103        conn: &mut ConnectionManager,
104        pattern: String,
105    ) -> redis::RedisResult<()> {
106        use futures::StreamExt;
107
108        /// Bound on how many keys go into a single `UNLINK` call.
109        const UNLINK_BATCH_SIZE: usize = 500;
110
111        let mut matched: Vec<String> = Vec::new();
112        {
113            let mut iter: redis::AsyncIter<'_, String> = conn.scan_match(pattern.as_str()).await?;
114            while let Some(key) = iter.next().await {
115                matched.push(key?);
116            }
117        }
118
119        for chunk in matched.chunks(UNLINK_BATCH_SIZE) {
120            let _: () = redis::cmd("UNLINK").arg(chunk).query_async(conn).await?;
121        }
122
123        Ok(())
124    }
125}
126
127#[async_trait]
128impl CacheStore for RedisCache {
129    async fn get_json(&self, key: &str) -> CacheResult<Option<String>> {
130        let key = self.build_key(key);
131        trace!("Cache GET: {}", key);
132        let mut conn = self.connection.clone();
133
134        let value: Option<String> = self.with_op_timeout(conn.get(&key)).await?;
135        trace!(
136            "Cache {} for: {}",
137            if value.is_some() { "HIT" } else { "MISS" },
138            key
139        );
140        Ok(value)
141    }
142
143    async fn set_json(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
144        let key = self.build_key(key);
145        trace!("Cache SET: {} (ttl: {:?})", key, ttl);
146        let mut conn = self.connection.clone();
147
148        let ttl = ttl.or(self.config.default_ttl);
149
150        if let Some(ttl) = ttl {
151            let ttl_seconds = ttl.as_secs();
152            let _: () = self
153                .with_op_timeout(conn.set_ex(&key, value, ttl_seconds))
154                .await?;
155        } else {
156            let _: () = self.with_op_timeout(conn.set(&key, value)).await?;
157        }
158
159        Ok(())
160    }
161
162    /// Plain `SET` with no expiry, skipping the `default_ttl` fallback that
163    /// `set_json` applies to a `None` TTL. See [`CacheStore::set_json_forever`].
164    async fn set_json_forever(&self, key: &str, value: String) -> CacheResult<()> {
165        let key = self.build_key(key);
166        trace!("Cache SET (no expiry): {}", key);
167        let mut conn = self.connection.clone();
168        let _: () = self.with_op_timeout(conn.set(&key, value)).await?;
169        Ok(())
170    }
171
172    async fn delete(&self, key: &str) -> CacheResult<()> {
173        let key = self.build_key(key);
174        let mut conn = self.connection.clone();
175        let _: () = self.with_op_timeout(conn.del(&key)).await?;
176        Ok(())
177    }
178
179    async fn exists(&self, key: &str) -> CacheResult<bool> {
180        let key = self.build_key(key);
181        let mut conn = self.connection.clone();
182        let exists: bool = self.with_op_timeout(conn.exists(&key)).await?;
183        Ok(exists)
184    }
185
186    /// Clear this cache's keys.
187    ///
188    /// When `key_prefix` is configured, this is **scoped** to that prefix: it
189    /// `SCAN`s for every key matching `{key_prefix}:*` and removes them with
190    /// batched `UNLINK` calls, so it only wipes keys this cache actually
191    /// wrote — not the whole Redis database/instance. `SCAN` is cursor-based
192    /// and non-blocking (unlike `KEYS`, which is O(N) and stalls the
193    /// single-threaded server for the entire keyspace); `UNLINK` reclaims
194    /// memory off the main thread instead of blocking on `DEL`.
195    ///
196    /// When no `key_prefix` is configured, this cache has no distinct slice
197    /// of the keyspace to scope to, so it falls back to the previous
198    /// unscoped `FLUSHDB` behavior — this remains destructive to the entire
199    /// Redis database/instance, so an unprefixed `RedisCache` sharing a
200    /// Redis instance with other services/tenants should not call `clear()`.
201    async fn clear(&self) -> CacheResult<()> {
202        match self.config.key_prefix.as_deref() {
203            Some(prefix) if !prefix.is_empty() => {
204                let pattern = format!("{prefix}:*");
205                let mut conn = self.connection.clone();
206                self.with_op_timeout(Self::scan_and_unlink(&mut conn, pattern))
207                    .await?;
208            }
209            _ => {
210                let mut conn = self.connection.clone();
211                let _: () = self
212                    .with_op_timeout(redis::cmd("FLUSHDB").query_async(&mut conn))
213                    .await?;
214            }
215        }
216        Ok(())
217    }
218
219    async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
220        let key = self.build_key(key);
221        let mut conn = self.connection.clone();
222
223        let ttl_seconds: i64 = self.with_op_timeout(conn.ttl(&key)).await?;
224
225        match ttl_seconds {
226            -2 => Ok(None), // Key doesn't exist
227            -1 => Ok(None), // Key has no expiration
228            seconds if seconds > 0 => Ok(Some(Duration::from_secs(seconds as u64))),
229            _ => Ok(None),
230        }
231    }
232
233    async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()> {
234        let key = self.build_key(key);
235        let mut conn = self.connection.clone();
236        let ttl_seconds = ttl.as_secs();
237        let _: () = self
238            .with_op_timeout(conn.expire(&key, ttl_seconds as i64))
239            .await?;
240        Ok(())
241    }
242
243    async fn increment(&self, key: &str, delta: i64) -> CacheResult<i64> {
244        let key = self.build_key(key);
245        let mut conn = self.connection.clone();
246        let new_value: i64 = self.with_op_timeout(conn.incr(&key, delta)).await?;
247        Ok(new_value)
248    }
249
250    async fn decrement(&self, key: &str, delta: i64) -> CacheResult<i64> {
251        let key = self.build_key(key);
252        let mut conn = self.connection.clone();
253        let new_value: i64 = self.with_op_timeout(conn.decr(&key, delta)).await?;
254        Ok(new_value)
255    }
256
257    /// Native multi-get: a single `MGET` round-trip instead of N `GET`s.
258    ///
259    /// `MGET` preserves argument order, so the returned vector matches `keys`
260    /// element-for-element, with `None` for missing keys.
261    async fn mget(&self, keys: &[&str]) -> CacheResult<Vec<Option<String>>> {
262        if keys.is_empty() {
263            return Ok(Vec::new());
264        }
265        let full_keys: Vec<String> = keys.iter().map(|k| self.build_key(k)).collect();
266        trace!("Cache MGET: {} keys", full_keys.len());
267        let mut conn = self.connection.clone();
268        let values: Vec<Option<String>> = self
269            .with_op_timeout(redis::cmd("MGET").arg(&full_keys).query_async(&mut conn))
270            .await?;
271        Ok(values)
272    }
273
274    /// Native multi-set in a single round-trip.
275    ///
276    /// Without a TTL this is a plain `MSET`. `MSET` cannot express per-key
277    /// expiry, so when a TTL applies we pipeline `SET ... EX` commands (still
278    /// one round-trip), preserving the exact per-key TTL semantics of
279    /// `set_json` (including the `default_ttl` fallback).
280    async fn mset(&self, items: &[(&str, String)], ttl: Option<Duration>) -> CacheResult<()> {
281        if items.is_empty() {
282            return Ok(());
283        }
284        let mut conn = self.connection.clone();
285        let ttl = ttl.or(self.config.default_ttl);
286
287        if let Some(ttl) = ttl {
288            let ttl_seconds = ttl.as_secs();
289            trace!("Cache MSET (pipelined SET EX): {} items", items.len());
290            let mut pipe = redis::pipe();
291            for (key, value) in items {
292                pipe.cmd("SET")
293                    .arg(self.build_key(key))
294                    .arg(value)
295                    .arg("EX")
296                    .arg(ttl_seconds)
297                    .ignore();
298            }
299            let _: () = self.with_op_timeout(pipe.query_async(&mut conn)).await?;
300        } else {
301            trace!("Cache MSET: {} items", items.len());
302            let mut cmd = redis::cmd("MSET");
303            for (key, value) in items {
304                cmd.arg(self.build_key(key)).arg(value);
305            }
306            let _: () = self.with_op_timeout(cmd.query_async(&mut conn)).await?;
307        }
308        Ok(())
309    }
310
311    /// Native multi-delete: a single variadic `DEL` round-trip instead of N.
312    async fn mdel(&self, keys: &[&str]) -> CacheResult<()> {
313        if keys.is_empty() {
314            return Ok(());
315        }
316        let full_keys: Vec<String> = keys.iter().map(|k| self.build_key(k)).collect();
317        trace!("Cache DEL: {} keys", full_keys.len());
318        let mut conn = self.connection.clone();
319        let _: () = self
320            .with_op_timeout(redis::cmd("DEL").arg(&full_keys).query_async(&mut conn))
321            .await?;
322        Ok(())
323    }
324
325    /// `RedisCache` backs `set_add`/`set_remove`/`set_members` with native
326    /// `SADD`/`SREM`/`SMEMBERS`, which are atomic — see [`CacheStore::supports_atomic_sets`].
327    fn supports_atomic_sets(&self) -> bool {
328        true
329    }
330
331    /// Native `SADD`: atomically adds `member` to a Redis Set, unlike the
332    /// trait default's non-atomic get/modify/set. This is what makes
333    /// [`crate::invalidation::TaggedCache`]'s tag index safe to update
334    /// concurrently from multiple instances sharing this backend.
335    async fn set_add(&self, set_key: &str, member: &str) -> CacheResult<()> {
336        let set_key = self.build_key(set_key);
337        let mut conn = self.connection.clone();
338        let _: () = self.with_op_timeout(conn.sadd(&set_key, member)).await?;
339        Ok(())
340    }
341
342    /// Native `SREM`: atomically removes `member` from a Redis Set.
343    async fn set_remove(&self, set_key: &str, member: &str) -> CacheResult<()> {
344        let set_key = self.build_key(set_key);
345        let mut conn = self.connection.clone();
346        let _: () = self.with_op_timeout(conn.srem(&set_key, member)).await?;
347        Ok(())
348    }
349
350    /// Variadic `SADD key m1 m2 ...`: one round-trip for the whole batch
351    /// rather than one per member.
352    async fn set_add_many(&self, set_key: &str, members: &[&str]) -> CacheResult<()> {
353        if members.is_empty() {
354            return Ok(());
355        }
356        let set_key = self.build_key(set_key);
357        let mut conn = self.connection.clone();
358        let _: () = self.with_op_timeout(conn.sadd(&set_key, members)).await?;
359        Ok(())
360    }
361
362    /// Variadic `SREM key m1 m2 ...`: one round-trip for the whole batch.
363    async fn set_remove_many(&self, set_key: &str, members: &[&str]) -> CacheResult<()> {
364        if members.is_empty() {
365            return Ok(());
366        }
367        let set_key = self.build_key(set_key);
368        let mut conn = self.connection.clone();
369        let _: () = self.with_op_timeout(conn.srem(&set_key, members)).await?;
370        Ok(())
371    }
372
373    /// Native `SMEMBERS`.
374    async fn set_members(&self, set_key: &str) -> CacheResult<Vec<String>> {
375        let set_key = self.build_key(set_key);
376        let mut conn = self.connection.clone();
377        let members: Vec<String> = self.with_op_timeout(conn.smembers(&set_key)).await?;
378        Ok(members)
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_build_key() {
388        let config = CacheConfig::redis("redis://localhost:6379")
389            .unwrap()
390            .with_key_prefix("test");
391
392        // Note: Can't easily test async without a real Redis instance
393        // This is just to verify the struct can be created
394        assert_eq!(config.build_key("key"), "test:key");
395    }
396}