1use async_trait::async_trait;
35
36use super::StorageBackend;
37use crate::CacheError;
38
39const NARINFO_PREFIX: &str = "sui:narinfo:";
42const NAR_PREFIX: &str = "sui:nar:";
44
45#[async_trait]
53pub trait RedisConn: Send + Sync {
54 async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
56
57 async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), CacheError>;
61
62 async fn del(&self, key: &str) -> Result<(), CacheError>;
64
65 async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, CacheError>;
69}
70
71pub struct RedisBackend<C: RedisConn> {
75 conn: C,
76 ttl_secs: Option<u64>,
79}
80
81impl<C: RedisConn> RedisBackend<C> {
82 pub fn new(conn: C) -> Self {
85 Self { conn, ttl_secs: None }
86 }
87
88 pub fn with_ttl(conn: C, ttl_secs: u64) -> Self {
90 Self { conn, ttl_secs: Some(ttl_secs) }
91 }
92
93 #[must_use]
95 pub fn ttl_secs(&self) -> Option<u64> {
96 self.ttl_secs
97 }
98
99 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 self.conn.del(&Self::narinfo_key(hash)).await?;
145 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 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#[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 #[derive(Clone)]
197 pub struct RedisConnectionManager {
198 mgr: redis::aio::ConnectionManager,
199 }
200
201 impl RedisConnectionManager {
202 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 pub async fn connect(url: &str) -> Result<Self, CacheError> {
282 Ok(Self::new(RedisConnectionManager::connect(url).await?))
283 }
284
285 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#[cfg(test)]
305mod tests {
306 use super::*;
307 use std::collections::HashMap;
308 use std::sync::Mutex;
309
310 #[derive(Default)]
314 struct MockRedis {
315 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 fn evict(&self, key: &str) {
326 self.map.lock().unwrap().remove(key);
327 }
328
329 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 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 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 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 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 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 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}