Skip to main content

sui_castore/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::nar_refs::{referrer_of, NarRefIndex, NarRefKey, NarRefScan};
37use super::nar_stream::{self, NarSource};
38use super::{NarResidency, StorageBackend};
39use crate::StoreError;
40
41/// Key namespace for narinfo strings, so they never collide with NAR blobs in a
42/// single Redis keyspace.
43const NARINFO_PREFIX: &str = "sui:narinfo:";
44/// Key namespace for NAR blobs.
45const NAR_PREFIX: &str = "sui:nar:";
46/// Key namespace this backend puts in front of the canonical reverse-edge key
47/// ([`NarRefKey`]), so an edge lands at `sui:nar-refs/<nar path>/<store hash>`.
48///
49/// The canonical form is reused verbatim rather than re-encoded into Redis's
50/// `a:b:c` house style, so the four key-value tiers agree on one edge encoding.
51/// `sui:nar-refs/` is disjoint from `sui:nar:` — an edge is never swept by a NAR
52/// scan, or vice versa.
53const NAR_REF_NAMESPACE: &str = "sui:";
54
55/// Default per-value byte cap for the hot tier.
56///
57/// Redis has no streaming `SET`: a value is one contiguous buffer on both sides
58/// of the wire, so this tier cannot be made O(chunk) — it can only be made
59/// **bounded**. 64 MiB is comfortably above a typical NAR and far below the
60/// point where a handful of concurrent warms matters against a 6 GiB pod.
61///
62/// Refusing is correct, not a degradation: L1 is best-effort by contract, the
63/// durable tiers below it stream the same content without a cap, and a
64/// [`TieredBackend`](super::TieredBackend) discards this tier's write result
65/// entirely. A refused warm cannot fail a build.
66pub const DEFAULT_REDIS_MAX_VALUE_BYTES: usize = 64 * 1024 * 1024;
67
68/// The minimal async redis verb surface [`RedisBackend`] depends on.
69///
70/// This is the injectable **Environment seam**: a real implementation
71/// ([`RedisConnectionManager`], `redis-client` feature) talks to a live Redis;
72/// tests substitute an in-memory mock. Keeping the surface this small means the
73/// L1 read-through / write-through / eviction semantics are all proven against a
74/// mock, and the only unmocked code is the thin verb translation.
75#[async_trait]
76pub trait RedisConn: Send + Sync {
77    /// `GET key` — raw bytes, or `Ok(None)` on a miss / evicted key.
78    async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError>;
79
80    /// `SET key value [EX ttl_secs]` — store raw bytes, optionally with an
81    /// expiry. `ttl_secs == None` means no explicit expiry (LRU-evicted by the
82    /// `maxmemory` policy).
83    async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), StoreError>;
84
85    /// `DEL key` — idempotent; deleting an absent key is `Ok(())`.
86    async fn del(&self, key: &str) -> Result<(), StoreError>;
87
88    /// Non-blocking `SCAN MATCH prefix*` — every key currently resident under
89    /// `prefix`. Partial by nature (a cache), and must use `SCAN`, never the
90    /// O(N) blocking `KEYS`.
91    async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
92}
93
94/// L1 hot cache: content-addressed key → value, sub-ms hits, TTL/eviction-aware.
95///
96/// Generic over the [`RedisConn`] seam so it is fully testable against a mock.
97pub struct RedisBackend<C: RedisConn> {
98    conn: C,
99    /// Optional TTL (seconds) applied to every write; `None` => rely on the
100    /// `maxmemory` LRU policy.
101    ttl_secs: Option<u64>,
102    /// Per-value byte cap. A NAR larger than this is refused, never buffered.
103    max_value_bytes: usize,
104}
105
106impl<C: RedisConn> RedisBackend<C> {
107    /// Wrap a [`RedisConn`] with no per-write TTL (entries are LRU-evicted by
108    /// the `maxmemory` band).
109    pub fn new(conn: C) -> Self {
110        Self {
111            conn,
112            ttl_secs: None,
113            max_value_bytes: DEFAULT_REDIS_MAX_VALUE_BYTES,
114        }
115    }
116
117    /// Wrap a [`RedisConn`], stamping every write with a `ttl_secs` expiry.
118    pub fn with_ttl(conn: C, ttl_secs: u64) -> Self {
119        Self {
120            conn,
121            ttl_secs: Some(ttl_secs),
122            max_value_bytes: DEFAULT_REDIS_MAX_VALUE_BYTES,
123        }
124    }
125
126    /// Override the per-value byte cap (default
127    /// [`DEFAULT_REDIS_MAX_VALUE_BYTES`]).
128    #[must_use]
129    pub fn with_max_value_bytes(mut self, max: usize) -> Self {
130        self.max_value_bytes = max;
131        self
132    }
133
134    /// The per-value byte cap this tier refuses beyond.
135    #[must_use]
136    pub fn max_value_bytes(&self) -> usize {
137        self.max_value_bytes
138    }
139
140    /// The per-write TTL, if any.
141    #[must_use]
142    pub fn ttl_secs(&self) -> Option<u64> {
143        self.ttl_secs
144    }
145
146    /// Borrow the underlying connection (for composition / diagnostics).
147    pub fn conn(&self) -> &C {
148        &self.conn
149    }
150
151    fn narinfo_key(hash: &str) -> String {
152        format!("{NARINFO_PREFIX}{hash}")
153    }
154
155    fn nar_key(path: &str) -> String {
156        format!("{NAR_PREFIX}{path}")
157    }
158
159    /// Redis key of one reverse edge.
160    fn nar_ref_key(nar_path: &str, hash: &str) -> String {
161        format!("{NAR_REF_NAMESPACE}{}", NarRefKey { nar_path, hash })
162    }
163
164    /// Redis `SCAN` prefix enumerating every edge into `nar_path`.
165    fn nar_ref_scan(nar_path: &str) -> String {
166        format!("{NAR_REF_NAMESPACE}{}", NarRefScan { nar_path })
167    }
168
169    /// Redis `SCAN` prefix covering every edge this backend holds.
170    fn nar_ref_namespace() -> String {
171        format!("{NAR_REF_NAMESPACE}{}", super::nar_refs::NAR_REF_PREFIX)
172    }
173}
174
175#[async_trait]
176impl<C: RedisConn> StorageBackend for RedisBackend<C> {
177    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError> {
178        let key = Self::narinfo_key(hash);
179        match self.conn.get_bytes(&key).await? {
180            Some(bytes) => {
181                let text = String::from_utf8(bytes)
182                    .map_err(|e| StoreError::NarInfo(format!("invalid utf-8 in redis narinfo {hash}: {e}")))?;
183                Ok(Some(text))
184            }
185            None => Ok(None),
186        }
187    }
188
189    async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError> {
190        let key = Self::narinfo_key(hash);
191        self.conn.set_bytes(&key, content.as_bytes(), self.ttl_secs).await
192    }
193
194    async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError> {
195        self.conn.del(&Self::narinfo_key(hash)).await
196    }
197
198    async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError> {
199        self.conn.del(&Self::nar_key(nar_path)).await
200    }
201
202    fn nar_ref_index(&self) -> &dyn NarRefIndex {
203        self
204    }
205
206    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError> {
207        let key = Self::nar_key(path);
208        self.conn.get_bytes(&key).await
209    }
210
211    /// Store a NAR in the hot tier, **refusing anything over the cap**.
212    ///
213    /// The cap is checked against the slice's length before the value ever
214    /// reaches the wire, so an oversized NAR costs this tier nothing.
215    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError> {
216        if data.len() > self.max_value_bytes {
217            return Err(StoreError::TooLarge {
218                limit: self.max_value_bytes as u64,
219                at_least: data.len() as u64,
220            });
221        }
222        let key = Self::nar_key(path);
223        self.conn.set_bytes(&key, data, self.ttl_secs).await
224    }
225
226    /// **O(min(nar, cap)).** Redis has no streaming `SET` — a value is one
227    /// contiguous buffer by protocol — so this tier is bounded by a cap rather
228    /// than by a chunk. That is a real bound: see
229    /// [`DEFAULT_REDIS_MAX_VALUE_BYTES`] for why refusing is the correct
230    /// behavior for a best-effort hot tier.
231    fn nar_residency(&self) -> NarResidency {
232        NarResidency::Capped(self.max_value_bytes)
233    }
234
235    /// Drain the source **only up to the cap**, refusing the moment it is
236    /// crossed.
237    ///
238    /// The refusal is the load-bearing part: collection stops at the cap and the
239    /// remainder of the NAR is never read, so a 2 GiB NAR costs this tier
240    /// `cap + one chunk` and not 2 GiB. Without it, "L1 refuses oversized
241    /// values" would be a claim the code does not make.
242    async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
243        // A known size lets the refusal happen before a single byte is read.
244        if let Some(n) = src.size_hint() {
245            if n > self.max_value_bytes as u64 {
246                return Err(StoreError::TooLarge {
247                    limit: self.max_value_bytes as u64,
248                    at_least: n,
249                });
250            }
251        }
252        let data =
253            nar_stream::collect_nar(src.open().await?, Some(self.max_value_bytes)).await?;
254        let key = Self::nar_key(path);
255        self.conn.set_bytes(&key, &data, self.ttl_secs).await
256    }
257
258    async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
259        let keys = self.conn.keys_with_prefix(NARINFO_PREFIX).await?;
260        Ok(keys
261            .into_iter()
262            .filter_map(|k| k.strip_prefix(NARINFO_PREFIX).map(str::to_string))
263            .collect())
264    }
265
266    /// Complete L1 wipe: `DEL` every key under BOTH the narinfo and NAR prefixes
267    /// (a scoped clear — never `FLUSHDB`, which would blow away an unrelated
268    /// co-tenant of the same Redis db). Returns the narinfo key count removed.
269    async fn wipe_all(&self) -> Result<usize, StoreError> {
270        let narinfos = self.conn.keys_with_prefix(NARINFO_PREFIX).await?;
271        let n = narinfos.len();
272        for key in &narinfos {
273            self.conn.del(key).await?;
274        }
275        for key in self.conn.keys_with_prefix(NAR_PREFIX).await? {
276            self.conn.del(&key).await?;
277        }
278        // `sui:nar-refs/…` is NOT under `sui:nar:`, so the reverse index needs
279        // its own sweep or a wipe would leave every edge pointing at nothing.
280        for key in self.conn.keys_with_prefix(&Self::nar_ref_namespace()).await? {
281            self.conn.del(&key).await?;
282        }
283        Ok(n)
284    }
285}
286
287/// The reverse index as one Redis key per edge.
288///
289/// **Edges carry no TTL, deliberately**, even when narinfo/NAR writes do. An
290/// edge that expired while the narinfo it describes is still live would be an
291/// under-report, and an under-report authorizes deleting a NAR out from under
292/// that narinfo. An edge that outlives its narinfo is an over-report, which
293/// costs a retained NAR. The whole tier is still best-effort — Redis
294/// `maxmemory` LRU can drop an edge regardless — which is why a
295/// [`TieredBackend`](super::TieredBackend) unions this tier's answer with the
296/// durable tiers' rather than trusting it alone.
297#[async_trait]
298impl<C: RedisConn> NarRefIndex for RedisBackend<C> {
299    async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
300        self.conn.set_bytes(&Self::nar_ref_key(nar_path, hash), b"", None).await
301    }
302
303    async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
304        self.conn.del(&Self::nar_ref_key(nar_path, hash)).await
305    }
306
307    async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError> {
308        let scan = NarRefScan { nar_path };
309        let prefix = Self::nar_ref_scan(nar_path);
310        let mut hashes: Vec<String> = self
311            .conn
312            .keys_with_prefix(&prefix)
313            .await?
314            .iter()
315            .filter_map(|k| k.strip_prefix(NAR_REF_NAMESPACE))
316            .filter_map(|k| referrer_of(&scan, k))
317            .map(str::to_string)
318            .collect();
319        hashes.sort();
320        hashes.dedup();
321        Ok(hashes)
322    }
323}
324
325// ---------------------------------------------------------------------------
326// Production transport — real redis client, gated behind the `redis-client`
327// feature so the default build + unit tests pull zero redis dependency surface.
328// ---------------------------------------------------------------------------
329
330#[cfg(feature = "redis-client")]
331mod client {
332    use super::{StoreError, RedisBackend, RedisConn};
333    use async_trait::async_trait;
334
335    fn to_store_err(e: redis::RedisError) -> StoreError {
336        StoreError::Io(std::io::Error::other(format!("redis: {e}")))
337    }
338
339    /// Production [`RedisConn`] over a multiplexed, auto-reconnecting
340    /// `redis::aio::ConnectionManager`. Cheap to clone (each verb clones the
341    /// manager handle), so a single `RedisConnectionManager` fans out across the
342    /// async runtime without a bespoke pool.
343    #[derive(Clone)]
344    pub struct RedisConnectionManager {
345        mgr: redis::aio::ConnectionManager,
346    }
347
348    impl RedisConnectionManager {
349        /// Connect to `url` (e.g. `redis://redis.super-cache-ci.svc:6379`).
350        ///
351        /// # Errors
352        ///
353        /// Returns [`StoreError::Io`] if the URL is invalid or the initial
354        /// connection cannot be established.
355        pub async fn connect(url: &str) -> Result<Self, StoreError> {
356            let client = redis::Client::open(url).map_err(to_store_err)?;
357            let mgr = redis::aio::ConnectionManager::new(client)
358                .await
359                .map_err(to_store_err)?;
360            Ok(Self { mgr })
361        }
362    }
363
364    #[async_trait]
365    impl RedisConn for RedisConnectionManager {
366        async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
367            let mut c = self.mgr.clone();
368            let v: Option<Vec<u8>> = redis::cmd("GET")
369                .arg(key)
370                .query_async(&mut c)
371                .await
372                .map_err(to_store_err)?;
373            Ok(v)
374        }
375
376        async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), StoreError> {
377            let mut c = self.mgr.clone();
378            let mut cmd = redis::cmd("SET");
379            cmd.arg(key).arg(value);
380            if let Some(secs) = ttl_secs {
381                cmd.arg("EX").arg(secs);
382            }
383            let _: () = cmd.query_async(&mut c).await.map_err(to_store_err)?;
384            Ok(())
385        }
386
387        async fn del(&self, key: &str) -> Result<(), StoreError> {
388            let mut c = self.mgr.clone();
389            let _: i64 = redis::cmd("DEL")
390                .arg(key)
391                .query_async(&mut c)
392                .await
393                .map_err(to_store_err)?;
394            Ok(())
395        }
396
397        async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
398            let mut c = self.mgr.clone();
399            let pattern = format!("{prefix}*");
400            let mut cursor: u64 = 0;
401            let mut out = Vec::new();
402            loop {
403                let (next, batch): (u64, Vec<String>) = redis::cmd("SCAN")
404                    .arg(cursor)
405                    .arg("MATCH")
406                    .arg(&pattern)
407                    .arg("COUNT")
408                    .arg(512)
409                    .query_async(&mut c)
410                    .await
411                    .map_err(to_store_err)?;
412                out.extend(batch);
413                if next == 0 {
414                    break;
415                }
416                cursor = next;
417            }
418            Ok(out)
419        }
420    }
421
422    impl RedisBackend<RedisConnectionManager> {
423        /// Connect an L1 backend to `url` with no per-write TTL (LRU-evicted).
424        ///
425        /// # Errors
426        ///
427        /// Propagates a connection failure from [`RedisConnectionManager::connect`].
428        pub async fn connect(url: &str) -> Result<Self, StoreError> {
429            Ok(Self::new(RedisConnectionManager::connect(url).await?))
430        }
431
432        /// Connect an L1 backend to `url`, stamping every write with `ttl_secs`.
433        ///
434        /// # Errors
435        ///
436        /// Propagates a connection failure from [`RedisConnectionManager::connect`].
437        pub async fn connect_with_ttl(url: &str, ttl_secs: u64) -> Result<Self, StoreError> {
438            Ok(Self::with_ttl(RedisConnectionManager::connect(url).await?, ttl_secs))
439        }
440    }
441}
442
443#[cfg(feature = "redis-client")]
444pub use client::RedisConnectionManager;
445
446// ---------------------------------------------------------------------------
447// Unit tests — the L1 semantics proven against an in-memory mock RedisConn.
448// No live Redis required.
449// ---------------------------------------------------------------------------
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use std::collections::HashMap;
455    use std::sync::Mutex;
456
457    /// In-memory [`RedisConn`] mock. Records each write's TTL so tests can prove
458    /// TTL/eviction awareness, and exposes `evict` to simulate `maxmemory` LRU
459    /// dropping a hot key (or a pod roll losing the whole tier via `clear`).
460    #[derive(Default)]
461    struct MockRedis {
462        // key -> (value, ttl_secs seen on last write)
463        map: Mutex<HashMap<String, (Vec<u8>, Option<u64>)>>,
464    }
465
466    impl MockRedis {
467        fn ttl_of(&self, key: &str) -> Option<u64> {
468            self.map.lock().unwrap().get(key).and_then(|(_, t)| *t)
469        }
470
471        /// Simulate `maxmemory` LRU evicting a single hot key.
472        fn evict(&self, key: &str) {
473            self.map.lock().unwrap().remove(key);
474        }
475
476        /// Simulate a pod roll losing the entire hot tier.
477        fn clear(&self) {
478            self.map.lock().unwrap().clear();
479        }
480
481        fn len(&self) -> usize {
482            self.map.lock().unwrap().len()
483        }
484    }
485
486    #[async_trait]
487    impl RedisConn for MockRedis {
488        async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
489            Ok(self.map.lock().unwrap().get(key).map(|(v, _)| v.clone()))
490        }
491
492        async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), StoreError> {
493            self.map
494                .lock()
495                .unwrap()
496                .insert(key.to_string(), (value.to_vec(), ttl_secs));
497            Ok(())
498        }
499
500        async fn del(&self, key: &str) -> Result<(), StoreError> {
501            self.map.lock().unwrap().remove(key);
502            Ok(())
503        }
504
505        async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
506            Ok(self
507                .map
508                .lock()
509                .unwrap()
510                .keys()
511                .filter(|k| k.starts_with(prefix))
512                .cloned()
513                .collect())
514        }
515    }
516
517    const NARINFO: &str = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
518
519    #[tokio::test]
520    async fn get_missing_narinfo_returns_none() {
521        let backend = RedisBackend::new(MockRedis::default());
522        assert!(backend.get_narinfo("nope").await.unwrap().is_none());
523    }
524
525    #[tokio::test]
526    async fn put_then_get_narinfo_roundtrips() {
527        let backend = RedisBackend::new(MockRedis::default());
528        backend.put_narinfo("abc", NARINFO).await.unwrap();
529        let got = backend.get_narinfo("abc").await.unwrap().unwrap();
530        assert_eq!(got, NARINFO);
531    }
532
533    #[tokio::test]
534    async fn get_missing_nar_returns_none() {
535        let backend = RedisBackend::new(MockRedis::default());
536        assert!(backend.get_nar("nar/missing.nar.xz").await.unwrap().is_none());
537    }
538
539    #[tokio::test]
540    async fn put_then_get_nar_roundtrips() {
541        let backend = RedisBackend::new(MockRedis::default());
542        let data = b"\x00\x01\x02 fake nar bytes";
543        backend.put_nar("nar/abc.nar.xz", data).await.unwrap();
544        let got = backend.get_nar("nar/abc.nar.xz").await.unwrap().unwrap();
545        assert_eq!(got, data);
546    }
547
548    #[tokio::test]
549    async fn narinfo_and_nar_keyspaces_do_not_collide() {
550        // Same bare id used for both a narinfo hash and a nar path fragment.
551        let backend = RedisBackend::new(MockRedis::default());
552        backend.put_narinfo("dead", "the-narinfo").await.unwrap();
553        backend.put_nar("dead", b"the-nar").await.unwrap();
554        assert_eq!(backend.get_narinfo("dead").await.unwrap().unwrap(), "the-narinfo");
555        assert_eq!(backend.get_nar("dead").await.unwrap().unwrap(), b"the-nar");
556    }
557
558    #[tokio::test]
559    async fn no_ttl_by_default() {
560        let mock = MockRedis::default();
561        let backend = RedisBackend::new(mock);
562        assert_eq!(backend.ttl_secs(), None);
563        backend.put_narinfo("abc", NARINFO).await.unwrap();
564        // The write carried no expiry.
565        assert_eq!(backend.conn().ttl_of("sui:narinfo:abc"), None);
566    }
567
568    #[tokio::test]
569    async fn with_ttl_stamps_every_write() {
570        let backend = RedisBackend::with_ttl(MockRedis::default(), 3600);
571        assert_eq!(backend.ttl_secs(), Some(3600));
572        backend.put_narinfo("abc", NARINFO).await.unwrap();
573        backend.put_nar("nar/abc.nar.xz", b"data").await.unwrap();
574        assert_eq!(backend.conn().ttl_of("sui:narinfo:abc"), Some(3600));
575        assert_eq!(backend.conn().ttl_of("sui:nar:nar/abc.nar.xz"), Some(3600));
576    }
577
578    #[tokio::test]
579    async fn eviction_of_a_hot_key_is_a_plain_miss() {
580        // A cache, not a source of truth: an evicted key reads back as Ok(None).
581        let backend = RedisBackend::new(MockRedis::default());
582        backend.put_narinfo("abc", NARINFO).await.unwrap();
583        assert!(backend.get_narinfo("abc").await.unwrap().is_some());
584        backend.conn().evict("sui:narinfo:abc");
585        assert!(backend.get_narinfo("abc").await.unwrap().is_none());
586    }
587
588    #[tokio::test]
589    async fn pod_roll_clears_the_whole_hot_tier() {
590        let backend = RedisBackend::new(MockRedis::default());
591        backend.put_narinfo("a", NARINFO).await.unwrap();
592        backend.put_nar("nar/a.nar.xz", b"x").await.unwrap();
593        backend.conn().clear();
594        assert!(backend.get_narinfo("a").await.unwrap().is_none());
595        assert!(backend.get_nar("nar/a.nar.xz").await.unwrap().is_none());
596    }
597
598    /// `delete` removes the NAR the narinfo names, not three store-hash-shaped
599    /// guesses. `NARINFO` advertises `nar/abc.nar.xz` while the store hash is
600    /// `xyz` — the ordinary case, since a NAR is keyed by narhash.
601    #[tokio::test]
602    async fn delete_resolves_the_nar_from_the_narinfo_instead_of_guessing() {
603        let backend = RedisBackend::new(MockRedis::default());
604        backend.put_narinfo("xyz", NARINFO).await.unwrap();
605        backend.put_nar("nar/abc.nar.xz", b"the real nar").await.unwrap();
606        backend.put_nar("nar/xyz.nar.zst", b"someone else's nar").await.unwrap();
607
608        backend.delete("xyz").await.unwrap();
609
610        assert!(backend.get_narinfo("xyz").await.unwrap().is_none());
611        assert!(backend.get_nar("nar/abc.nar.xz").await.unwrap().is_none());
612        assert_eq!(
613            backend.get_nar("nar/xyz.nar.zst").await.unwrap().unwrap(),
614            b"someone else's nar",
615            "a key this narinfo never named must be untouched",
616        );
617    }
618
619    /// The hot tier's own reverse index, round-tripped through the mock's
620    /// `SCAN` — proving the edge encoding and the prefix scan agree.
621    #[tokio::test]
622    async fn the_hot_tier_indexes_and_forgets_its_edges() {
623        let backend = RedisBackend::new(MockRedis::default());
624        backend.put_narinfo("xyz", NARINFO).await.unwrap();
625        backend.put_narinfo("second", NARINFO).await.unwrap();
626        assert_eq!(
627            backend.nar_ref_index().referrers("nar/abc.nar.xz").await.unwrap(),
628            vec!["second".to_string(), "xyz".to_string()],
629        );
630
631        backend.delete("xyz").await.unwrap();
632        assert_eq!(
633            backend.nar_ref_index().referrers("nar/abc.nar.xz").await.unwrap(),
634            vec!["second".to_string()],
635        );
636    }
637
638    #[tokio::test]
639    async fn delete_absent_is_idempotent() {
640        let backend = RedisBackend::new(MockRedis::default());
641        // Must not error on a wholly-absent key.
642        backend.delete("ghost").await.unwrap();
643        assert_eq!(backend.conn().len(), 0);
644    }
645
646    #[tokio::test]
647    async fn list_narinfos_returns_hot_subset_stripped() {
648        let backend = RedisBackend::new(MockRedis::default());
649        backend.put_narinfo("aaa", "1").await.unwrap();
650        backend.put_narinfo("bbb", "2").await.unwrap();
651        // A NAR write must not leak into the narinfo listing.
652        backend.put_nar("nar/ccc.nar.xz", b"3").await.unwrap();
653        let mut hashes = backend.list_narinfos().await.unwrap();
654        hashes.sort();
655        assert_eq!(hashes, vec!["aaa".to_string(), "bbb".to_string()]);
656    }
657
658    #[tokio::test]
659    async fn list_narinfos_empty_when_cold() {
660        let backend = RedisBackend::new(MockRedis::default());
661        assert!(backend.list_narinfos().await.unwrap().is_empty());
662    }
663
664    #[tokio::test]
665    async fn overwrite_narinfo_takes_latest() {
666        let backend = RedisBackend::new(MockRedis::default());
667        backend.put_narinfo("h", "v1").await.unwrap();
668        backend.put_narinfo("h", "v2").await.unwrap();
669        assert_eq!(backend.get_narinfo("h").await.unwrap().unwrap(), "v2");
670    }
671
672    // ── the cap: a bound, enforced by refusing rather than buffering ───────
673
674    #[tokio::test]
675    async fn residency_reports_the_configured_cap() {
676        let backend = RedisBackend::new(MockRedis::default()).with_max_value_bytes(1024);
677        assert_eq!(backend.max_value_bytes(), 1024);
678        assert_eq!(backend.nar_residency(), NarResidency::Capped(1024));
679        assert!(backend.nar_residency().is_bounded(), "a cap IS a bound");
680    }
681
682    #[tokio::test]
683    async fn an_over_cap_nar_is_refused_and_stores_nothing() {
684        let backend = RedisBackend::new(MockRedis::default()).with_max_value_bytes(16);
685        let err = backend.put_nar("nar/big.nar.xz", &[0u8; 64]).await.unwrap_err();
686        assert!(matches!(err, StoreError::TooLarge { limit: 16, at_least: 64 }));
687        assert!(
688            backend.get_nar("nar/big.nar.xz").await.unwrap().is_none(),
689            "a refused write must leave the tier untouched",
690        );
691    }
692
693    #[tokio::test]
694    async fn a_value_exactly_at_the_cap_is_accepted() {
695        // The boundary is inclusive; an off-by-one here would silently shrink
696        // the hot tier's usable range.
697        let backend = RedisBackend::new(MockRedis::default()).with_max_value_bytes(16);
698        backend.put_nar("nar/edge.nar.xz", &[7u8; 16]).await.unwrap();
699        assert_eq!(backend.get_nar("nar/edge.nar.xz").await.unwrap().unwrap(), vec![7u8; 16]);
700    }
701
702    /// A source that counts how many bytes were actually pulled out of it, so a
703    /// test can prove the refusal happened *early* rather than after reading
704    /// the whole NAR and then throwing it away.
705    struct CountingSource {
706        total: usize,
707        chunk: usize,
708        read: std::sync::Arc<std::sync::atomic::AtomicUsize>,
709        /// Whether the source advertises its length up front, as a spooled
710        /// upload does and a tier-to-tier promotion may not.
711        advertise_len: bool,
712    }
713
714    #[async_trait]
715    impl super::nar_stream::NarSource for CountingSource {
716        fn size_hint(&self) -> Option<u64> {
717            self.advertise_len.then_some(self.total as u64)
718        }
719        async fn open(&self) -> Result<super::nar_stream::NarStream, StoreError> {
720            use futures::StreamExt as _;
721            let (total, chunk, read) = (self.total, self.chunk, self.read.clone());
722            Ok(futures::stream::unfold(0usize, move |sent| {
723                let read = read.clone();
724                async move {
725                    if sent >= total {
726                        return None;
727                    }
728                    let n = (total - sent).min(chunk);
729                    read.fetch_add(n, std::sync::atomic::Ordering::Relaxed);
730                    Some((Ok(bytes::Bytes::from(vec![3u8; n])), sent + n))
731                }
732            })
733            .boxed())
734        }
735    }
736
737    #[tokio::test]
738    async fn an_over_cap_stream_is_refused_without_reading_a_single_byte() {
739        // The cheap path: a spooled upload knows its length, so the tier can
740        // decline before touching the source at all.
741        let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
742        let backend = RedisBackend::new(MockRedis::default()).with_max_value_bytes(1024);
743        let src = CountingSource {
744            total: 1_000_000,
745            chunk: 4096,
746            read: read.clone(),
747            advertise_len: true,
748        };
749        let err = backend.put_nar_stream("nar/big.nar.xz", &src).await.unwrap_err();
750        assert!(matches!(err, StoreError::TooLarge { .. }));
751        assert_eq!(read.load(std::sync::atomic::Ordering::Relaxed), 0, "nothing should be read");
752    }
753
754    #[tokio::test]
755    async fn an_over_cap_stream_of_unknown_length_stops_at_the_cap() {
756        // The important case, and the one the whole change turns on: with NO
757        // length advertised, collection must stop the instant the cap is
758        // crossed. Reading the whole 1 MB and then refusing would mean the cap
759        // bounds nothing at all.
760        let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
761        let backend = RedisBackend::new(MockRedis::default()).with_max_value_bytes(1024);
762        let src = CountingSource {
763            total: 1_000_000,
764            chunk: 4096,
765            read: read.clone(),
766            advertise_len: false,
767        };
768        let err = backend.put_nar_stream("nar/big.nar.xz", &src).await.unwrap_err();
769        assert!(matches!(err, StoreError::TooLarge { .. }));
770        let bytes_read = read.load(std::sync::atomic::Ordering::Relaxed);
771        assert!(
772            bytes_read <= 1024 + 4096,
773            "refusal must happen at the cap (+ at most one chunk), but {bytes_read} bytes \
774             were pulled — the cap is not bounding anything",
775        );
776    }
777
778    #[tokio::test]
779    async fn an_under_cap_stream_round_trips() {
780        let backend = RedisBackend::new(MockRedis::default()).with_max_value_bytes(1 << 20);
781        let src = super::nar_stream::BytesNarSource::new(vec![9u8; 5000]);
782        backend.put_nar_stream("nar/ok.nar.xz", &src).await.unwrap();
783        assert_eq!(backend.get_nar("nar/ok.nar.xz").await.unwrap().unwrap(), vec![9u8; 5000]);
784    }
785
786    #[tokio::test]
787    async fn invalid_utf8_narinfo_surfaces_typed_error() {
788        // A corrupt hot entry must surface a typed NarInfo error, not silently
789        // fabricate bytes.
790        let mock = MockRedis::default();
791        mock.map
792            .lock()
793            .unwrap()
794            .insert("sui:narinfo:bad".to_string(), (vec![0xff, 0xfe, 0xfd], None));
795        let backend = RedisBackend::new(mock);
796        let err = backend.get_narinfo("bad").await.unwrap_err();
797        assert!(matches!(err, StoreError::NarInfo(_)));
798    }
799}