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    async fn delete(&self, key: &str) -> CacheResult<()> {
163        let key = self.build_key(key);
164        let mut conn = self.connection.clone();
165        let _: () = self.with_op_timeout(conn.del(&key)).await?;
166        Ok(())
167    }
168
169    async fn exists(&self, key: &str) -> CacheResult<bool> {
170        let key = self.build_key(key);
171        let mut conn = self.connection.clone();
172        let exists: bool = self.with_op_timeout(conn.exists(&key)).await?;
173        Ok(exists)
174    }
175
176    /// Clear this cache's keys.
177    ///
178    /// When `key_prefix` is configured, this is **scoped** to that prefix: it
179    /// `SCAN`s for every key matching `{key_prefix}:*` and removes them with
180    /// batched `UNLINK` calls, so it only wipes keys this cache actually
181    /// wrote — not the whole Redis database/instance. `SCAN` is cursor-based
182    /// and non-blocking (unlike `KEYS`, which is O(N) and stalls the
183    /// single-threaded server for the entire keyspace); `UNLINK` reclaims
184    /// memory off the main thread instead of blocking on `DEL`.
185    ///
186    /// When no `key_prefix` is configured, this cache has no distinct slice
187    /// of the keyspace to scope to, so it falls back to the previous
188    /// unscoped `FLUSHDB` behavior — this remains destructive to the entire
189    /// Redis database/instance, so an unprefixed `RedisCache` sharing a
190    /// Redis instance with other services/tenants should not call `clear()`.
191    async fn clear(&self) -> CacheResult<()> {
192        match self.config.key_prefix.as_deref() {
193            Some(prefix) if !prefix.is_empty() => {
194                let pattern = format!("{prefix}:*");
195                let mut conn = self.connection.clone();
196                self.with_op_timeout(Self::scan_and_unlink(&mut conn, pattern))
197                    .await?;
198            }
199            _ => {
200                let mut conn = self.connection.clone();
201                let _: () = self
202                    .with_op_timeout(redis::cmd("FLUSHDB").query_async(&mut conn))
203                    .await?;
204            }
205        }
206        Ok(())
207    }
208
209    async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
210        let key = self.build_key(key);
211        let mut conn = self.connection.clone();
212
213        let ttl_seconds: i64 = self.with_op_timeout(conn.ttl(&key)).await?;
214
215        match ttl_seconds {
216            -2 => Ok(None), // Key doesn't exist
217            -1 => Ok(None), // Key has no expiration
218            seconds if seconds > 0 => Ok(Some(Duration::from_secs(seconds as u64))),
219            _ => Ok(None),
220        }
221    }
222
223    async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()> {
224        let key = self.build_key(key);
225        let mut conn = self.connection.clone();
226        let ttl_seconds = ttl.as_secs();
227        let _: () = self
228            .with_op_timeout(conn.expire(&key, ttl_seconds as i64))
229            .await?;
230        Ok(())
231    }
232
233    async fn increment(&self, key: &str, delta: i64) -> CacheResult<i64> {
234        let key = self.build_key(key);
235        let mut conn = self.connection.clone();
236        let new_value: i64 = self.with_op_timeout(conn.incr(&key, delta)).await?;
237        Ok(new_value)
238    }
239
240    async fn decrement(&self, key: &str, delta: i64) -> CacheResult<i64> {
241        let key = self.build_key(key);
242        let mut conn = self.connection.clone();
243        let new_value: i64 = self.with_op_timeout(conn.decr(&key, delta)).await?;
244        Ok(new_value)
245    }
246
247    /// Native multi-get: a single `MGET` round-trip instead of N `GET`s.
248    ///
249    /// `MGET` preserves argument order, so the returned vector matches `keys`
250    /// element-for-element, with `None` for missing keys.
251    async fn mget(&self, keys: &[&str]) -> CacheResult<Vec<Option<String>>> {
252        if keys.is_empty() {
253            return Ok(Vec::new());
254        }
255        let full_keys: Vec<String> = keys.iter().map(|k| self.build_key(k)).collect();
256        trace!("Cache MGET: {} keys", full_keys.len());
257        let mut conn = self.connection.clone();
258        let values: Vec<Option<String>> = self
259            .with_op_timeout(redis::cmd("MGET").arg(&full_keys).query_async(&mut conn))
260            .await?;
261        Ok(values)
262    }
263
264    /// Native multi-set in a single round-trip.
265    ///
266    /// Without a TTL this is a plain `MSET`. `MSET` cannot express per-key
267    /// expiry, so when a TTL applies we pipeline `SET ... EX` commands (still
268    /// one round-trip), preserving the exact per-key TTL semantics of
269    /// `set_json` (including the `default_ttl` fallback).
270    async fn mset(&self, items: &[(&str, String)], ttl: Option<Duration>) -> CacheResult<()> {
271        if items.is_empty() {
272            return Ok(());
273        }
274        let mut conn = self.connection.clone();
275        let ttl = ttl.or(self.config.default_ttl);
276
277        if let Some(ttl) = ttl {
278            let ttl_seconds = ttl.as_secs();
279            trace!("Cache MSET (pipelined SET EX): {} items", items.len());
280            let mut pipe = redis::pipe();
281            for (key, value) in items {
282                pipe.cmd("SET")
283                    .arg(self.build_key(key))
284                    .arg(value)
285                    .arg("EX")
286                    .arg(ttl_seconds)
287                    .ignore();
288            }
289            let _: () = self.with_op_timeout(pipe.query_async(&mut conn)).await?;
290        } else {
291            trace!("Cache MSET: {} items", items.len());
292            let mut cmd = redis::cmd("MSET");
293            for (key, value) in items {
294                cmd.arg(self.build_key(key)).arg(value);
295            }
296            let _: () = self.with_op_timeout(cmd.query_async(&mut conn)).await?;
297        }
298        Ok(())
299    }
300
301    /// Native multi-delete: a single variadic `DEL` round-trip instead of N.
302    async fn mdel(&self, keys: &[&str]) -> CacheResult<()> {
303        if keys.is_empty() {
304            return Ok(());
305        }
306        let full_keys: Vec<String> = keys.iter().map(|k| self.build_key(k)).collect();
307        trace!("Cache DEL: {} keys", full_keys.len());
308        let mut conn = self.connection.clone();
309        let _: () = self
310            .with_op_timeout(redis::cmd("DEL").arg(&full_keys).query_async(&mut conn))
311            .await?;
312        Ok(())
313    }
314
315    /// `RedisCache` backs `set_add`/`set_remove`/`set_members` with native
316    /// `SADD`/`SREM`/`SMEMBERS`, which are atomic — see [`CacheStore::supports_atomic_sets`].
317    fn supports_atomic_sets(&self) -> bool {
318        true
319    }
320
321    /// Native `SADD`: atomically adds `member` to a Redis Set, unlike the
322    /// trait default's non-atomic get/modify/set. This is what makes
323    /// [`crate::invalidation::TaggedCache`]'s tag index safe to update
324    /// concurrently from multiple instances sharing this backend.
325    async fn set_add(&self, set_key: &str, member: &str) -> CacheResult<()> {
326        let set_key = self.build_key(set_key);
327        let mut conn = self.connection.clone();
328        let _: () = self.with_op_timeout(conn.sadd(&set_key, member)).await?;
329        Ok(())
330    }
331
332    /// Native `SREM`: atomically removes `member` from a Redis Set.
333    async fn set_remove(&self, set_key: &str, member: &str) -> CacheResult<()> {
334        let set_key = self.build_key(set_key);
335        let mut conn = self.connection.clone();
336        let _: () = self.with_op_timeout(conn.srem(&set_key, member)).await?;
337        Ok(())
338    }
339
340    /// Native `SMEMBERS`.
341    async fn set_members(&self, set_key: &str) -> CacheResult<Vec<String>> {
342        let set_key = self.build_key(set_key);
343        let mut conn = self.connection.clone();
344        let members: Vec<String> = self.with_op_timeout(conn.smembers(&set_key)).await?;
345        Ok(members)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_build_key() {
355        let config = CacheConfig::redis("redis://localhost:6379")
356            .unwrap()
357            .with_key_prefix("test");
358
359        // Note: Can't easily test async without a real Redis instance
360        // This is just to verify the struct can be created
361        assert_eq!(config.build_key("key"), "test:key");
362    }
363}