1use 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
41const NARINFO_PREFIX: &str = "sui:narinfo:";
44const NAR_PREFIX: &str = "sui:nar:";
46const NAR_REF_NAMESPACE: &str = "sui:";
54
55pub const DEFAULT_REDIS_MAX_VALUE_BYTES: usize = 64 * 1024 * 1024;
67
68#[async_trait]
76pub trait RedisConn: Send + Sync {
77 async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError>;
79
80 async fn set_bytes(&self, key: &str, value: &[u8], ttl_secs: Option<u64>) -> Result<(), StoreError>;
84
85 async fn del(&self, key: &str) -> Result<(), StoreError>;
87
88 async fn keys_with_prefix(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
92}
93
94pub struct RedisBackend<C: RedisConn> {
98 conn: C,
99 ttl_secs: Option<u64>,
102 max_value_bytes: usize,
104}
105
106impl<C: RedisConn> RedisBackend<C> {
107 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 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 #[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 #[must_use]
136 pub fn max_value_bytes(&self) -> usize {
137 self.max_value_bytes
138 }
139
140 #[must_use]
142 pub fn ttl_secs(&self) -> Option<u64> {
143 self.ttl_secs
144 }
145
146 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 fn nar_ref_key(nar_path: &str, hash: &str) -> String {
161 format!("{NAR_REF_NAMESPACE}{}", NarRefKey { nar_path, hash })
162 }
163
164 fn nar_ref_scan(nar_path: &str) -> String {
166 format!("{NAR_REF_NAMESPACE}{}", NarRefScan { nar_path })
167 }
168
169 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 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 fn nar_residency(&self) -> NarResidency {
232 NarResidency::Capped(self.max_value_bytes)
233 }
234
235 async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
243 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 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 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#[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#[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 #[derive(Clone)]
344 pub struct RedisConnectionManager {
345 mgr: redis::aio::ConnectionManager,
346 }
347
348 impl RedisConnectionManager {
349 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 pub async fn connect(url: &str) -> Result<Self, StoreError> {
429 Ok(Self::new(RedisConnectionManager::connect(url).await?))
430 }
431
432 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#[cfg(test)]
452mod tests {
453 use super::*;
454 use std::collections::HashMap;
455 use std::sync::Mutex;
456
457 #[derive(Default)]
461 struct MockRedis {
462 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 fn evict(&self, key: &str) {
473 self.map.lock().unwrap().remove(key);
474 }
475
476 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 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 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 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 #[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 #[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 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 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 #[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 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 struct CountingSource {
706 total: usize,
707 chunk: usize,
708 read: std::sync::Arc<std::sync::atomic::AtomicUsize>,
709 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 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 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 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}