Skip to main content

sui_cache/storage/
redis.rs

1//! Redis-backed **L1 hot cache** `StorageBackend`.
2//!
3//! This is the sub-millisecond top tier of the tiered super-cache resolver
4//! (`Redis L1 → Postgres L2 → object L3`). It maps a **content-addressed key**
5//! (the 32-char store-path hash for narinfo, or the relative NAR URL — which is
6//! itself content-derived; when the daemon addresses graph blobs the key is
7//! `GraphHash::display_short()`) to its stored value.
8//!
9//! # It is a cache, not a source of truth
10//!
11//! A key may vanish under Redis `maxmemory` LRU eviction at any moment, and
12//! [`RedisBackend::list_narinfos`] therefore returns only the currently-resident
13//! hot subset — *never* an authoritative listing. Durability/correctness comes
14//! from the durable tiers below it in a `TieredBackend`; a hot-only write that a
15//! pod roll loses must always be re-derivable from L2/L3. Because the key is
16//! content-derived, an L1 miss satisfied by a lower tier returns the same bytes
17//! for the same key — read-through transparency.
18//!
19//! # TTL / eviction awareness
20//!
21//! Writes are optionally stamped with a per-write TTL ([`RedisBackend::with_ttl`]);
22//! with no TTL, entries rely on the Redis `maxmemory` band's LRU policy (the
23//! super-cache controller derives `redis.maxmemory_mib` from the memory band).
24//! Either way the backend treats a missing key as a plain cache miss (`Ok(None)`).
25//!
26//! # The client seam (Environment / testability contract)
27//!
28//! [`RedisBackend`] is generic over [`RedisConn`] — the minimal async redis
29//! verb surface it needs. Unit tests inject an in-memory mock; production injects
30//! [`RedisConnectionManager`] (a multiplexed, auto-reconnecting
31//! `redis::aio::ConnectionManager`, behind the `redis-client` feature). The pure
32//! L1 semantics are proven against the mock with **no live Redis required**.
33
34use async_trait::async_trait;
35
36use super::StorageBackend;
37use crate::CacheError;
38
39/// Key namespace for narinfo strings, so they never collide with NAR blobs in a
40/// single Redis keyspace.
41const NARINFO_PREFIX: &str = "sui:narinfo:";
42/// Key namespace for NAR blobs.
43const NAR_PREFIX: &str = "sui:nar:";
44
45/// The minimal async redis verb surface [`RedisBackend`] depends on.
46///
47/// This is the injectable **Environment seam**: a real implementation
48/// ([`RedisConnectionManager`], `redis-client` feature) talks to a live Redis;
49/// tests substitute an in-memory mock. Keeping the surface this small means the
50/// L1 read-through / write-through / eviction semantics are all proven against a
51/// mock, and the only unmocked code is the thin verb translation.
52#[async_trait]
53pub trait RedisConn: Send + Sync {
54    /// `GET key` — raw bytes, or `Ok(None)` on a miss / evicted key.
55    async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
56
57    /// `SET key value [EX ttl_secs]` — store raw bytes, optionally with an
58    /// expiry. `ttl_secs == None` means no explicit expiry (LRU-evicted by the
59    /// `maxmemory` policy).
60    async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), CacheError>;
61
62    /// `DEL key` — idempotent; deleting an absent key is `Ok(())`.
63    async fn del(&self, key: &str) -> Result<(), CacheError>;
64
65    /// Non-blocking `SCAN MATCH prefix*` — every key currently resident under
66    /// `prefix`. Partial by nature (a cache), and must use `SCAN`, never the
67    /// O(N) blocking `KEYS`.
68    async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, CacheError>;
69}
70
71/// L1 hot cache: content-addressed key → value, sub-ms hits, TTL/eviction-aware.
72///
73/// Generic over the [`RedisConn`] seam so it is fully testable against a mock.
74pub struct RedisBackend<C: RedisConn> {
75    conn: C,
76    /// Optional TTL (seconds) applied to every write; `None` => rely on the
77    /// `maxmemory` LRU policy.
78    ttl_secs: Option<u64>,
79}
80
81impl<C: RedisConn> RedisBackend<C> {
82    /// Wrap a [`RedisConn`] with no per-write TTL (entries are LRU-evicted by
83    /// the `maxmemory` band).
84    pub fn new(conn: C) -> Self {
85        Self { conn, ttl_secs: None }
86    }
87
88    /// Wrap a [`RedisConn`], stamping every write with a `ttl_secs` expiry.
89    pub fn with_ttl(conn: C, ttl_secs: u64) -> Self {
90        Self { conn, ttl_secs: Some(ttl_secs) }
91    }
92
93    /// The per-write TTL, if any.
94    #[must_use]
95    pub fn ttl_secs(&self) -> Option<u64> {
96        self.ttl_secs
97    }
98
99    /// Borrow the underlying connection (for composition / diagnostics).
100    pub fn conn(&self) -> &C {
101        &self.conn
102    }
103
104    fn narinfo_key(hash: &str) -> String {
105        format!("{NARINFO_PREFIX}{hash}")
106    }
107
108    fn nar_key(path: &str) -> String {
109        format!("{NAR_PREFIX}{path}")
110    }
111}
112
113#[async_trait]
114impl<C: RedisConn> StorageBackend for RedisBackend<C> {
115    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, CacheError> {
116        let key = Self::narinfo_key(hash);
117        match self.conn.get_bytes(&key).await? {
118            Some(bytes) => {
119                let text = String::from_utf8(bytes)
120                    .map_err(|e| CacheError::NarInfo(format!("invalid utf-8 in redis narinfo {hash}: {e}")))?;
121                Ok(Some(text))
122            }
123            None => Ok(None),
124        }
125    }
126
127    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError> {
128        let key = Self::narinfo_key(hash);
129        self.conn.set_bytes(&key, content.as_bytes(), self.ttl_secs).await
130    }
131
132    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError> {
133        let key = Self::nar_key(path);
134        self.conn.get_bytes(&key).await
135    }
136
137    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError> {
138        let key = Self::nar_key(path);
139        self.conn.set_bytes(&key, data, self.ttl_secs).await
140    }
141
142    async fn delete(&self, hash: &str) -> Result<(), CacheError> {
143        // Narinfo is keyed directly by hash.
144        self.conn.del(&Self::narinfo_key(hash)).await?;
145        // NAR blobs are keyed by relative URL; we only have the hash here, so —
146        // mirroring `S3Storage::delete` — best-effort delete the common NAR path
147        // patterns. `del` is idempotent, so absent keys are harmless.
148        for ext in ["nar.xz", "nar.zst", "nar"] {
149            self.conn.del(&Self::nar_key(&format!("nar/{hash}.{ext}"))).await?;
150        }
151        Ok(())
152    }
153
154    async fn list_narinfos(&self) -> Result<Vec<String>, CacheError> {
155        let keys = self.conn.keys_with_prefix(NARINFO_PREFIX).await?;
156        Ok(keys
157            .into_iter()
158            .filter_map(|k| k.strip_prefix(NARINFO_PREFIX).map(str::to_string))
159            .collect())
160    }
161
162    /// Complete L1 wipe: `DEL` every key under BOTH the narinfo and NAR prefixes
163    /// (a scoped clear — never `FLUSHDB`, which would blow away an unrelated
164    /// co-tenant of the same Redis db). Returns the narinfo key count removed.
165    async fn wipe_all(&self) -> Result<usize, CacheError> {
166        let narinfos = self.conn.keys_with_prefix(NARINFO_PREFIX).await?;
167        let n = narinfos.len();
168        for key in &narinfos {
169            self.conn.del(key).await?;
170        }
171        for key in self.conn.keys_with_prefix(NAR_PREFIX).await? {
172            self.conn.del(&key).await?;
173        }
174        Ok(n)
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Production transport — real redis client, gated behind the `redis-client`
180// feature so the default build + unit tests pull zero redis dependency surface.
181// ---------------------------------------------------------------------------
182
183#[cfg(feature = "redis-client")]
184mod client {
185    use super::{CacheError, RedisBackend, RedisConn};
186    use async_trait::async_trait;
187
188    fn to_cache_err(e: redis::RedisError) -> CacheError {
189        CacheError::Io(std::io::Error::other(format!("redis: {e}")))
190    }
191
192    /// Production [`RedisConn`] over a multiplexed, auto-reconnecting
193    /// `redis::aio::ConnectionManager`. Cheap to clone (each verb clones the
194    /// manager handle), so a single `RedisConnectionManager` fans out across the
195    /// async runtime without a bespoke pool.
196    #[derive(Clone)]
197    pub struct RedisConnectionManager {
198        mgr: redis::aio::ConnectionManager,
199    }
200
201    impl RedisConnectionManager {
202        /// Connect to `url` (e.g. `redis://redis.super-cache-ci.svc:6379`).
203        ///
204        /// # Errors
205        ///
206        /// Returns [`CacheError::Io`] if the URL is invalid or the initial
207        /// connection cannot be established.
208        pub async fn connect(url: &str) -> Result<Self, CacheError> {
209            let client = redis::Client::open(url).map_err(to_cache_err)?;
210            let mgr = redis::aio::ConnectionManager::new(client)
211                .await
212                .map_err(to_cache_err)?;
213            Ok(Self { mgr })
214        }
215    }
216
217    #[async_trait]
218    impl RedisConn for RedisConnectionManager {
219        async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
220            let mut c = self.mgr.clone();
221            let v: Option<Vec<u8>> = redis::cmd("GET")
222                .arg(key)
223                .query_async(&mut c)
224                .await
225                .map_err(to_cache_err)?;
226            Ok(v)
227        }
228
229        async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), CacheError> {
230            let mut c = self.mgr.clone();
231            let mut cmd = redis::cmd("SET");
232            cmd.arg(key).arg(value);
233            if let Some(secs) = ttl_secs {
234                cmd.arg("EX").arg(secs);
235            }
236            let _: () = cmd.query_async(&mut c).await.map_err(to_cache_err)?;
237            Ok(())
238        }
239
240        async fn del(&self, key: &str) -> Result<(), CacheError> {
241            let mut c = self.mgr.clone();
242            let _: i64 = redis::cmd("DEL")
243                .arg(key)
244                .query_async(&mut c)
245                .await
246                .map_err(to_cache_err)?;
247            Ok(())
248        }
249
250        async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, CacheError> {
251            let mut c = self.mgr.clone();
252            let pattern = format!("{prefix}*");
253            let mut cursor: u64 = 0;
254            let mut out = Vec::new();
255            loop {
256                let (next, batch): (u64, Vec<String>) = redis::cmd("SCAN")
257                    .arg(cursor)
258                    .arg("MATCH")
259                    .arg(&pattern)
260                    .arg("COUNT")
261                    .arg(512)
262                    .query_async(&mut c)
263                    .await
264                    .map_err(to_cache_err)?;
265                out.extend(batch);
266                if next == 0 {
267                    break;
268                }
269                cursor = next;
270            }
271            Ok(out)
272        }
273    }
274
275    impl RedisBackend<RedisConnectionManager> {
276        /// Connect an L1 backend to `url` with no per-write TTL (LRU-evicted).
277        ///
278        /// # Errors
279        ///
280        /// Propagates a connection failure from [`RedisConnectionManager::connect`].
281        pub async fn connect(url: &str) -> Result<Self, CacheError> {
282            Ok(Self::new(RedisConnectionManager::connect(url).await?))
283        }
284
285        /// Connect an L1 backend to `url`, stamping every write with `ttl_secs`.
286        ///
287        /// # Errors
288        ///
289        /// Propagates a connection failure from [`RedisConnectionManager::connect`].
290        pub async fn connect_with_ttl(url: &str, ttl_secs: u64) -> Result<Self, CacheError> {
291            Ok(Self::with_ttl(RedisConnectionManager::connect(url).await?, ttl_secs))
292        }
293    }
294}
295
296#[cfg(feature = "redis-client")]
297pub use client::RedisConnectionManager;
298
299// ---------------------------------------------------------------------------
300// Unit tests — the L1 semantics proven against an in-memory mock RedisConn.
301// No live Redis required.
302// ---------------------------------------------------------------------------
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::collections::HashMap;
308    use std::sync::Mutex;
309
310    /// In-memory [`RedisConn`] mock. Records each write's TTL so tests can prove
311    /// TTL/eviction awareness, and exposes `evict` to simulate `maxmemory` LRU
312    /// dropping a hot key (or a pod roll losing the whole tier via `clear`).
313    #[derive(Default)]
314    struct MockRedis {
315        // key -> (value, ttl_secs seen on last write)
316        map: Mutex<HashMap<String, (Vec<u8>, Option<u64>)>>,
317    }
318
319    impl MockRedis {
320        fn ttl_of(&self, key: &str) -> Option<u64> {
321            self.map.lock().unwrap().get(key).and_then(|(_, t)| *t)
322        }
323
324        /// Simulate `maxmemory` LRU evicting a single hot key.
325        fn evict(&self, key: &str) {
326            self.map.lock().unwrap().remove(key);
327        }
328
329        /// Simulate a pod roll losing the entire hot tier.
330        fn clear(&self) {
331            self.map.lock().unwrap().clear();
332        }
333
334        fn len(&self) -> usize {
335            self.map.lock().unwrap().len()
336        }
337    }
338
339    #[async_trait]
340    impl RedisConn for MockRedis {
341        async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
342            Ok(self.map.lock().unwrap().get(key).map(|(v, _)| v.clone()))
343        }
344
345        async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), CacheError> {
346            self.map
347                .lock()
348                .unwrap()
349                .insert(key.to_string(), (value.to_vec(), ttl_secs));
350            Ok(())
351        }
352
353        async fn del(&self, key: &str) -> Result<(), CacheError> {
354            self.map.lock().unwrap().remove(key);
355            Ok(())
356        }
357
358        async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, CacheError> {
359            Ok(self
360                .map
361                .lock()
362                .unwrap()
363                .keys()
364                .filter(|k| k.starts_with(prefix))
365                .cloned()
366                .collect())
367        }
368    }
369
370    const NARINFO: &str = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
371
372    #[tokio::test]
373    async fn get_missing_narinfo_returns_none() {
374        let backend = RedisBackend::new(MockRedis::default());
375        assert!(backend.get_narinfo("nope").await.unwrap().is_none());
376    }
377
378    #[tokio::test]
379    async fn put_then_get_narinfo_roundtrips() {
380        let backend = RedisBackend::new(MockRedis::default());
381        backend.put_narinfo("abc", NARINFO).await.unwrap();
382        let got = backend.get_narinfo("abc").await.unwrap().unwrap();
383        assert_eq!(got, NARINFO);
384    }
385
386    #[tokio::test]
387    async fn get_missing_nar_returns_none() {
388        let backend = RedisBackend::new(MockRedis::default());
389        assert!(backend.get_nar("nar/missing.nar.xz").await.unwrap().is_none());
390    }
391
392    #[tokio::test]
393    async fn put_then_get_nar_roundtrips() {
394        let backend = RedisBackend::new(MockRedis::default());
395        let data = b"\x00\x01\x02 fake nar bytes";
396        backend.put_nar("nar/abc.nar.xz", data).await.unwrap();
397        let got = backend.get_nar("nar/abc.nar.xz").await.unwrap().unwrap();
398        assert_eq!(got, data);
399    }
400
401    #[tokio::test]
402    async fn narinfo_and_nar_keyspaces_do_not_collide() {
403        // Same bare id used for both a narinfo hash and a nar path fragment.
404        let backend = RedisBackend::new(MockRedis::default());
405        backend.put_narinfo("dead", "the-narinfo").await.unwrap();
406        backend.put_nar("dead", b"the-nar").await.unwrap();
407        assert_eq!(backend.get_narinfo("dead").await.unwrap().unwrap(), "the-narinfo");
408        assert_eq!(backend.get_nar("dead").await.unwrap().unwrap(), b"the-nar");
409    }
410
411    #[tokio::test]
412    async fn no_ttl_by_default() {
413        let mock = MockRedis::default();
414        let backend = RedisBackend::new(mock);
415        assert_eq!(backend.ttl_secs(), None);
416        backend.put_narinfo("abc", NARINFO).await.unwrap();
417        // The write carried no expiry.
418        assert_eq!(backend.conn().ttl_of("sui:narinfo:abc"), None);
419    }
420
421    #[tokio::test]
422    async fn with_ttl_stamps_every_write() {
423        let backend = RedisBackend::with_ttl(MockRedis::default(), 3600);
424        assert_eq!(backend.ttl_secs(), Some(3600));
425        backend.put_narinfo("abc", NARINFO).await.unwrap();
426        backend.put_nar("nar/abc.nar.xz", b"data").await.unwrap();
427        assert_eq!(backend.conn().ttl_of("sui:narinfo:abc"), Some(3600));
428        assert_eq!(backend.conn().ttl_of("sui:nar:nar/abc.nar.xz"), Some(3600));
429    }
430
431    #[tokio::test]
432    async fn eviction_of_a_hot_key_is_a_plain_miss() {
433        // A cache, not a source of truth: an evicted key reads back as Ok(None).
434        let backend = RedisBackend::new(MockRedis::default());
435        backend.put_narinfo("abc", NARINFO).await.unwrap();
436        assert!(backend.get_narinfo("abc").await.unwrap().is_some());
437        backend.conn().evict("sui:narinfo:abc");
438        assert!(backend.get_narinfo("abc").await.unwrap().is_none());
439    }
440
441    #[tokio::test]
442    async fn pod_roll_clears_the_whole_hot_tier() {
443        let backend = RedisBackend::new(MockRedis::default());
444        backend.put_narinfo("a", NARINFO).await.unwrap();
445        backend.put_nar("nar/a.nar.xz", b"x").await.unwrap();
446        backend.conn().clear();
447        assert!(backend.get_narinfo("a").await.unwrap().is_none());
448        assert!(backend.get_nar("nar/a.nar.xz").await.unwrap().is_none());
449    }
450
451    #[tokio::test]
452    async fn delete_removes_narinfo_and_common_nar_patterns() {
453        let backend = RedisBackend::new(MockRedis::default());
454        backend.put_narinfo("xyz", NARINFO).await.unwrap();
455        backend.put_nar("nar/xyz.nar.xz", b"nar-xz").await.unwrap();
456        backend.put_nar("nar/xyz.nar.zst", b"nar-zst").await.unwrap();
457        backend.put_nar("nar/xyz.nar", b"nar-plain").await.unwrap();
458
459        backend.delete("xyz").await.unwrap();
460
461        assert!(backend.get_narinfo("xyz").await.unwrap().is_none());
462        assert!(backend.get_nar("nar/xyz.nar.xz").await.unwrap().is_none());
463        assert!(backend.get_nar("nar/xyz.nar.zst").await.unwrap().is_none());
464        assert!(backend.get_nar("nar/xyz.nar").await.unwrap().is_none());
465    }
466
467    #[tokio::test]
468    async fn delete_absent_is_idempotent() {
469        let backend = RedisBackend::new(MockRedis::default());
470        // Must not error on a wholly-absent key.
471        backend.delete("ghost").await.unwrap();
472        assert_eq!(backend.conn().len(), 0);
473    }
474
475    #[tokio::test]
476    async fn list_narinfos_returns_hot_subset_stripped() {
477        let backend = RedisBackend::new(MockRedis::default());
478        backend.put_narinfo("aaa", "1").await.unwrap();
479        backend.put_narinfo("bbb", "2").await.unwrap();
480        // A NAR write must not leak into the narinfo listing.
481        backend.put_nar("nar/ccc.nar.xz", b"3").await.unwrap();
482        let mut hashes = backend.list_narinfos().await.unwrap();
483        hashes.sort();
484        assert_eq!(hashes, vec!["aaa".to_string(), "bbb".to_string()]);
485    }
486
487    #[tokio::test]
488    async fn list_narinfos_empty_when_cold() {
489        let backend = RedisBackend::new(MockRedis::default());
490        assert!(backend.list_narinfos().await.unwrap().is_empty());
491    }
492
493    #[tokio::test]
494    async fn overwrite_narinfo_takes_latest() {
495        let backend = RedisBackend::new(MockRedis::default());
496        backend.put_narinfo("h", "v1").await.unwrap();
497        backend.put_narinfo("h", "v2").await.unwrap();
498        assert_eq!(backend.get_narinfo("h").await.unwrap().unwrap(), "v2");
499    }
500
501    #[tokio::test]
502    async fn invalid_utf8_narinfo_surfaces_typed_error() {
503        // A corrupt hot entry must surface a typed NarInfo error, not silently
504        // fabricate bytes.
505        let mock = MockRedis::default();
506        mock.map
507            .lock()
508            .unwrap()
509            .insert("sui:narinfo:bad".to_string(), (vec![0xff, 0xfe, 0xfd], None));
510        let backend = RedisBackend::new(mock);
511        let err = backend.get_narinfo("bad").await.unwrap_err();
512        assert!(matches!(err, CacheError::NarInfo(_)));
513    }
514}