pub mod index;
pub mod local;
pub mod nar_refs;
pub mod nar_stream;
pub mod pg;
pub mod redis;
pub mod s3;
pub mod tiered;
use std::sync::Arc;
pub use index::StorageIndex;
pub use local::LocalStorage;
pub use nar_refs::{
advertised_nar_url, advertised_url_line, is_addressable_nar_path, is_servable_narinfo,
referrer_of, MemNarRefIndex,
NarRefIndex, NarRefKey, NarRefScan, NAR_REF_PREFIX,
};
pub use nar_stream::{
bytes_stream, collect_nar, empty_stream, file_stream, spool_or_buffer, whole_value_stream,
BytesNarSource, FileNarSource, NarSource, NarStream, SpooledNarSource,
DEFAULT_INGEST_MEMORY_CAP, NAR_CHUNK_BYTES,
};
pub use pg::{PgCacheConn, PgStorageBackend, PgTable};
pub use redis::{RedisBackend, RedisConn};
pub use s3::S3Storage;
pub use tiered::{TieredBackend, TieredTier, WritePolicy, TIERED_BACKEND_TIER};
#[cfg(feature = "redis-client")]
pub use redis::RedisConnectionManager;
#[cfg(feature = "postgres")]
pub use pg::SqlxPgCacheConn;
use async_trait::async_trait;
use futures::future::BoxFuture;
use crate::config::BackendConfig;
use crate::StoreError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NarResidency {
Streaming,
Capped(usize),
WholeValue,
}
impl NarResidency {
#[must_use]
pub const fn is_bounded(self) -> bool {
!matches!(self, NarResidency::WholeValue)
}
#[must_use]
pub fn weaker(self, other: Self) -> Self {
match (self, other) {
(NarResidency::WholeValue, _) | (_, NarResidency::WholeValue) => {
NarResidency::WholeValue
}
(NarResidency::Capped(a), NarResidency::Capped(b)) => NarResidency::Capped(a.max(b)),
(NarResidency::Capped(a), NarResidency::Streaming)
| (NarResidency::Streaming, NarResidency::Capped(a)) => NarResidency::Capped(a),
(NarResidency::Streaming, NarResidency::Streaming) => NarResidency::Streaming,
}
}
}
#[async_trait]
pub trait StorageBackend: Send + Sync {
async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError>;
async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError>;
async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError>;
async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError>;
fn nar_ref_index(&self) -> &dyn NarRefIndex;
async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError>;
async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError>;
fn nar_residency(&self) -> NarResidency;
async fn get_nar_stream(&self, path: &str) -> Result<Option<NarStream>, StoreError> {
Ok(self.get_nar(path).await?.map(nar_stream::whole_value_stream))
}
async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
let data = nar_stream::collect_nar(src.open().await?, None).await?;
self.put_nar(path, &data).await
}
async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), StoreError> {
match nar_refs::advertised_url_line(content) {
Some(url) if nar_refs::is_addressable_nar_path(url) => {
self.nar_ref_index().record(url, hash).await?;
}
Some(url) => {
return Err(StoreError::NarInfo(format!(
"narinfo {hash} advertises an unaddressable URL: {url:?}",
)));
}
None => {}
}
self.put_narinfo_record(hash, content).await
}
async fn advertised_nar(&self, hash: &str) -> Result<Option<String>, StoreError> {
Ok(self.get_narinfo(hash).await?.as_deref().and_then(nar_refs::advertised_nar_url))
}
async fn delete(&self, hash: &str) -> Result<(), StoreError> {
let advertised = self.advertised_nar(hash).await?;
self.delete_narinfo_record(hash).await?;
let Some(nar_path) = advertised else { return Ok(()) };
self.nar_ref_index().forget(&nar_path, hash).await?;
let others = self.nar_ref_index().referrers(&nar_path).await?;
if others.is_empty() {
self.delete_nar_record(&nar_path).await?;
} else {
tracing::debug!(
hash = %hash,
nar_path = %nar_path,
referrers = others.len(),
"delete: NAR retained — another narinfo still advertises it; removing it \
would 404 an advertised URL, which Nix treats as a hard failure",
);
}
Ok(())
}
async fn reindex_nar_refs(&self) -> Result<usize, StoreError> {
let mut recorded = 0usize;
for hash in self.list_narinfos().await? {
if let Some(nar_path) = self.advertised_nar(&hash).await? {
self.nar_ref_index().record(&nar_path, &hash).await?;
recorded += 1;
}
}
Ok(recorded)
}
async fn list_narinfos(&self) -> Result<Vec<String>, StoreError>;
async fn wipe_all(&self) -> Result<usize, StoreError> {
let hashes = self.list_narinfos().await?;
let n = hashes.len();
for hash in hashes {
self.delete(&hash).await?;
}
Ok(n)
}
}
pub fn build_backend(
config: &BackendConfig,
) -> BoxFuture<'_, Result<Arc<dyn StorageBackend>, StoreError>> {
Box::pin(async move {
match config {
BackendConfig::Local { path } => {
Ok(Arc::new(LocalStorage::new(path.clone())) as Arc<dyn StorageBackend>)
}
BackendConfig::S3 { bucket, region, endpoint } => {
let s3 = S3Storage::new(bucket.clone(), region.clone(), endpoint.clone())?;
Ok(Arc::new(s3) as Arc<dyn StorageBackend>)
}
BackendConfig::Redis { url, ttl_secs } => build_redis(url, *ttl_secs).await,
BackendConfig::Pg { url, max_conns } => build_pg(url, *max_conns).await,
BackendConfig::Tiered { l1, l2, l3, write_policy } => {
let l1 = build_backend(l1).await?;
let l2 = build_backend(l2).await?;
let l3 = build_backend(l3).await?;
Ok(Arc::new(TieredBackend::with_write_policy(l1, l2, l3, *write_policy))
as Arc<dyn StorageBackend>)
}
}
})
}
#[cfg(feature = "redis-client")]
async fn build_redis(
url: &str,
ttl_secs: Option<u64>,
) -> Result<Arc<dyn StorageBackend>, StoreError> {
let backend = match ttl_secs {
Some(t) => RedisBackend::connect_with_ttl(url, t).await?,
None => RedisBackend::connect(url).await?,
};
Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
}
#[cfg(not(feature = "redis-client"))]
async fn build_redis(
_url: &str,
_ttl_secs: Option<u64>,
) -> Result<Arc<dyn StorageBackend>, StoreError> {
Err(StoreError::NotImplemented(
"redis L1 backend requires building sui-castore with --features redis-client",
))
}
#[cfg(feature = "postgres")]
async fn build_pg(url: &str, max_conns: u32) -> Result<Arc<dyn StorageBackend>, StoreError> {
let backend = PgStorageBackend::connect(url, max_conns).await?;
Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
}
#[cfg(not(feature = "postgres"))]
async fn build_pg(_url: &str, _max_conns: u32) -> Result<Arc<dyn StorageBackend>, StoreError> {
Err(StoreError::NotImplemented(
"postgres L2 backend requires building sui-castore with --features postgres",
))
}
#[cfg(test)]
mod residency_gate {
use super::*;
#[tokio::test]
async fn every_production_backend_bounds_its_nar_path() {
let dir = tempfile::tempdir().unwrap();
let local = build_backend(&BackendConfig::Local { path: dir.path().to_path_buf() })
.await
.unwrap();
assert_eq!(
local.nar_residency(),
NarResidency::Streaming,
"the local/L3 tier must stream — it is the durable object tier",
);
let s3 = build_backend(&BackendConfig::S3 {
bucket: "b".to_string(),
region: "us-east-1".to_string(),
endpoint: Some("http://127.0.0.1:9".to_string()),
})
.await
.unwrap();
assert_eq!(s3.nar_residency(), NarResidency::Streaming, "S3 must multipart-stream");
let tiered = build_backend(&BackendConfig::Tiered {
l1: Box::new(BackendConfig::Local { path: dir.path().join("l1") }),
l2: Box::new(BackendConfig::Local { path: dir.path().join("l2") }),
l3: Box::new(BackendConfig::Local { path: dir.path().join("l3") }),
write_policy: WritePolicy::WriteThrough,
})
.await
.unwrap();
assert_eq!(tiered.nar_residency(), NarResidency::Streaming);
assert!(tiered.nar_residency().is_bounded());
}
#[test]
fn residency_composes_to_the_weaker_side() {
use NarResidency::{Capped, Streaming, WholeValue};
assert_eq!(Streaming.weaker(Streaming), Streaming);
assert_eq!(Streaming.weaker(Capped(8)), Capped(8));
assert_eq!(Capped(8).weaker(Capped(64)), Capped(64), "the larger cap governs");
assert_eq!(Capped(8).weaker(WholeValue), WholeValue);
assert_eq!(WholeValue.weaker(Streaming), WholeValue);
}
#[test]
fn only_whole_value_is_unbounded() {
assert!(NarResidency::Streaming.is_bounded());
assert!(NarResidency::Capped(1).is_bounded());
assert!(!NarResidency::WholeValue.is_bounded());
}
}
#[cfg(test)]
mod nar_ref_gate {
use super::*;
const SHARED_NAR: &str = "nar/sharednarhash.nar.xz";
fn narinfo_for(url: &str) -> String {
format!(
"StorePath: /nix/store/pkg\nURL: {url}\nCompression: xz\nFileHash: sha256:aaa\n\
FileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n"
)
}
async fn assert_never_strands(name: &str, backend: &dyn StorageBackend) {
backend.put_narinfo("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
backend.put_narinfo("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
backend.put_nar(SHARED_NAR, b"shared contents").await.unwrap();
backend.put_nar("nar/pathA.nar.zst", b"unrelated").await.unwrap();
backend.delete("pathA").await.unwrap();
let surviving = backend
.get_narinfo("pathB")
.await
.unwrap()
.unwrap_or_else(|| panic!("{name}: pathB's narinfo vanished"));
let advertised = nar_refs::advertised_nar_url(&surviving)
.unwrap_or_else(|| panic!("{name}: pathB advertises nothing"));
assert!(
backend.get_nar(&advertised).await.unwrap().is_some(),
"{name}: STRANDED — pathB's narinfo advertises {advertised}, which is gone. \
A client would get 200 on the narinfo and 404 on the NAR, which nix treats \
as a hard build failure.",
);
assert_eq!(
backend.nar_ref_index().referrers(SHARED_NAR).await.unwrap(),
vec!["pathB".to_string()],
"{name}: the index must have dropped exactly pathA's edge",
);
let decoy = backend.get_nar("nar/pathA.nar.zst").await.unwrap().unwrap_or_else(|| {
panic!(
"{name}: GUESSED — delete removed nar/pathA.nar.zst, a key built from the \
STORE hash that no narinfo ever advertised. A NAR is keyed by narhash; \
delete must resolve the advertised URL, never guess an extension.",
)
});
assert_eq!(decoy, b"unrelated", "{name}: the decoy's bytes were altered");
backend.delete("pathB").await.unwrap();
assert!(
backend.get_nar(SHARED_NAR).await.unwrap().is_none(),
"{name}: nothing advertises the NAR any more; it must be reclaimed",
);
}
#[tokio::test]
async fn every_production_backend_pairs_its_nar_with_its_narinfo() {
let dir = tempfile::tempdir().unwrap();
let local = build_backend(&BackendConfig::Local { path: dir.path().join("solo") })
.await
.unwrap();
assert_never_strands("LocalStorage", local.as_ref()).await;
let tiered = build_backend(&BackendConfig::Tiered {
l1: Box::new(BackendConfig::Local { path: dir.path().join("l1") }),
l2: Box::new(BackendConfig::Local { path: dir.path().join("l2") }),
l3: Box::new(BackendConfig::Local { path: dir.path().join("l3") }),
write_policy: WritePolicy::WriteThrough,
})
.await
.unwrap();
assert_never_strands("TieredBackend", tiered.as_ref()).await;
}
#[tokio::test]
async fn an_unindexed_store_can_strand_until_reindexed() {
let dir = tempfile::tempdir().unwrap();
let stale = LocalStorage::new(dir.path().join("stale"));
stale.put_narinfo_record("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
stale.put_narinfo_record("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
stale.put_nar(SHARED_NAR, b"shared").await.unwrap();
stale.delete("pathA").await.unwrap();
assert!(
stale.get_narinfo("pathB").await.unwrap().is_some(),
"pathB's narinfo is still there…",
);
assert!(
stale.get_nar(SHARED_NAR).await.unwrap().is_none(),
"…and its NAR is gone: this IS the strand, and it is what an un-reindexed \
upgrade looks like",
);
let healed = LocalStorage::new(dir.path().join("healed"));
healed.put_narinfo_record("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
healed.put_narinfo_record("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
healed.put_nar(SHARED_NAR, b"shared").await.unwrap();
assert_eq!(healed.reindex_nar_refs().await.unwrap(), 2);
healed.delete("pathA").await.unwrap();
assert!(
healed.get_nar(SHARED_NAR).await.unwrap().is_some(),
"after a reindex the co-referrer is visible and the NAR is retained",
);
}
}