Skip to main content

sui_cache/storage/
mod.rs

1//! Storage backend trait and implementations.
2//!
3//! The `StorageBackend` trait abstracts over where narinfo metadata and
4//! compressed NAR blobs are persisted. Three implementations are provided:
5//!
6//! - [`LocalStorage`] — local filesystem (default)
7//! - [`S3Storage`] — S3-compatible object storage (AWS, MinIO, R2, RustFS)
8//! - [`RedisBackend`] — Redis L1 hot cache (sub-ms, TTL/eviction-aware)
9//! - [`PgStorageBackend`] — Postgres L2 durable cache tier (shared, authoritative)
10//! - [`TieredBackend`] — L1→L2→L3 read-through/write-through resolver
11//! - [`StorageIndex`] — redb ephemeral metadata index (accelerates S3 lookups)
12//!
13//! [`build_backend`] is the typed config-select factory: it dispatches a
14//! [`BackendConfig`](crate::config::BackendConfig) to its concrete backend
15//! (recursing for the tiered arm), so a deployment picks `{disk | s3 | redis | pg
16//! | tiered}` by configuration — never a silent hard-coded constructor.
17
18pub mod index;
19pub mod local;
20pub mod pg;
21pub mod redis;
22pub mod s3;
23pub mod tiered;
24
25use std::sync::Arc;
26
27pub use index::StorageIndex;
28pub use local::LocalStorage;
29pub use pg::{PgCacheConn, PgStorageBackend, PgTable};
30pub use redis::{RedisBackend, RedisConn};
31pub use s3::S3Storage;
32pub use tiered::{TieredBackend, TieredTier, WritePolicy, TIERED_BACKEND_TIER};
33
34#[cfg(feature = "redis-client")]
35pub use redis::RedisConnectionManager;
36
37#[cfg(feature = "postgres")]
38pub use pg::SqlxPgCacheConn;
39
40use async_trait::async_trait;
41use futures::future::BoxFuture;
42
43use crate::config::BackendConfig;
44use crate::CacheError;
45
46/// Abstraction over binary cache storage.
47///
48/// Narinfo files are keyed by the 32-character store path hash.
49/// NAR blobs are keyed by their relative URL path (e.g. `nar/<hash>.nar.xz`).
50#[async_trait]
51pub trait StorageBackend: Send + Sync {
52    /// Retrieve narinfo text by store path hash.
53    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, CacheError>;
54
55    /// Store narinfo text keyed by store path hash.
56    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError>;
57
58    /// Retrieve a NAR blob by its relative path.
59    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError>;
60
61    /// Store a NAR blob at the given relative path.
62    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError>;
63
64    /// Delete a store path's narinfo and associated NAR blob.
65    async fn delete(&self, hash: &str) -> Result<(), CacheError>;
66
67    /// List all stored narinfo hashes.
68    async fn list_narinfos(&self) -> Result<Vec<String>, CacheError>;
69
70    /// Clear EVERY narinfo and NAR blob from this backend (all tiers, for a
71    /// [`TieredBackend`]). Returns the number of narinfos removed.
72    ///
73    /// This is the inverse of a warm push: the cache is regenerable by
74    /// construction — a wipe merely forces the next build to run COLD — so it is
75    /// a safe GC-class operation, never a correctness one. Its purpose is a clean
76    /// cold baseline for repeatable cold/warm benchmarking (the operator's
77    /// cache-wipe lever, the enjulho inverse of pre-warm).
78    ///
79    /// The default lists every narinfo and best-effort `delete`s it (mirroring
80    /// [`delete`](StorageBackend::delete)). Because NAR blobs are keyed by
81    /// *narhash* (from the narinfo `URL:`), not the store-path hash, per-hash
82    /// delete cannot reach the NAR bytes — so a **complete** wipe requires a
83    /// whole-store clear. The concrete durable tiers override this with a real
84    /// truncation (Postgres `DELETE FROM`, Redis prefix-scan, local-dir removal)
85    /// that reclaims the NAR bytes as well; the default remains an honest
86    /// narinfo-only clear (which still forces a cold miss, since Nix asks for the
87    /// narinfo first) for backends without one.
88    async fn wipe_all(&self) -> Result<usize, CacheError> {
89        let hashes = self.list_narinfos().await?;
90        let n = hashes.len();
91        for hash in hashes {
92            self.delete(&hash).await?;
93        }
94        Ok(n)
95    }
96}
97
98/// Config-select factory: build the concrete [`StorageBackend`] a
99/// [`BackendConfig`] names.
100///
101/// This is **typed dispatch, not stringly** — a new backend kind is a
102/// non-exhaustive-`match` compile error, and the [`Tiered`](BackendConfig::Tiered)
103/// arm recurses, composing each sub-backend into a [`TieredBackend`]. The result
104/// is an `Arc<dyn StorageBackend>` that drops straight into
105/// `AppState.storage` — the server never changes shape whether the backend is one
106/// tier or three.
107///
108/// The `Redis` and `Pg` arms require their production transports; without the
109/// corresponding Cargo feature (`redis-client` / `postgres`) they return a typed
110/// [`CacheError::NotImplemented`] rather than silently falling back to disk — the
111/// never-silent-hardcode rule.
112///
113/// Returns a boxed future because the `Tiered` arm is recursive.
114///
115/// # Errors
116///
117/// Propagates any backend construction failure, or [`CacheError::NotImplemented`]
118/// when a config selects a backend whose feature is not compiled in.
119pub fn build_backend(config: &BackendConfig) -> BoxFuture<'_, Result<Arc<dyn StorageBackend>, CacheError>> {
120    Box::pin(async move {
121        match config {
122            BackendConfig::Local { path } => {
123                Ok(Arc::new(LocalStorage::new(path.clone())) as Arc<dyn StorageBackend>)
124            }
125            BackendConfig::S3 { bucket, region, endpoint } => {
126                let s3 = S3Storage::new(bucket.clone(), region.clone(), endpoint.clone())?;
127                Ok(Arc::new(s3) as Arc<dyn StorageBackend>)
128            }
129            BackendConfig::Redis { url, ttl_secs } => build_redis(url, *ttl_secs).await,
130            BackendConfig::Pg { url, max_conns } => build_pg(url, *max_conns).await,
131            BackendConfig::Tiered { l1, l2, l3, write_policy } => {
132                let l1 = build_backend(l1).await?;
133                let l2 = build_backend(l2).await?;
134                let l3 = build_backend(l3).await?;
135                Ok(Arc::new(TieredBackend::with_write_policy(l1, l2, l3, *write_policy))
136                    as Arc<dyn StorageBackend>)
137            }
138        }
139    })
140}
141
142#[cfg(feature = "redis-client")]
143async fn build_redis(url: &str, ttl_secs: Option<u64>) -> Result<Arc<dyn StorageBackend>, CacheError> {
144    let backend = match ttl_secs {
145        Some(t) => RedisBackend::connect_with_ttl(url, t).await?,
146        None => RedisBackend::connect(url).await?,
147    };
148    Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
149}
150
151#[cfg(not(feature = "redis-client"))]
152async fn build_redis(_url: &str, _ttl_secs: Option<u64>) -> Result<Arc<dyn StorageBackend>, CacheError> {
153    Err(CacheError::NotImplemented(
154        "redis L1 backend requires building sui-cache with --features redis-client",
155    ))
156}
157
158#[cfg(feature = "postgres")]
159async fn build_pg(url: &str, max_conns: u32) -> Result<Arc<dyn StorageBackend>, CacheError> {
160    let backend = PgStorageBackend::connect(url, max_conns).await?;
161    Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
162}
163
164#[cfg(not(feature = "postgres"))]
165async fn build_pg(_url: &str, _max_conns: u32) -> Result<Arc<dyn StorageBackend>, CacheError> {
166    Err(CacheError::NotImplemented(
167        "postgres L2 backend requires building sui-cache with --features postgres",
168    ))
169}