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