Skip to main content

sui_castore/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. Implementations 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,
10//!   authoritative)
11//! - [`TieredBackend`] — L1→L2→L3 read-through/write-through resolver
12//! - [`StorageIndex`] — redb ephemeral metadata index (accelerates S3 lookups)
13//!
14//! [`build_backend`] is the typed config-select factory: it dispatches a
15//! [`BackendConfig`](crate::config::BackendConfig) to its concrete backend
16//! (recursing for the tiered arm), so a deployment picks `{disk | s3 | redis |
17//! pg | tiered}` by configuration — never a silent hard-coded constructor.
18
19pub mod index;
20pub mod local;
21pub mod nar_refs;
22pub mod nar_stream;
23pub mod pg;
24pub mod redis;
25pub mod s3;
26pub mod tiered;
27
28use std::sync::Arc;
29
30pub use index::StorageIndex;
31pub use local::LocalStorage;
32pub use nar_refs::{
33    advertised_nar_url, advertised_url_line, is_addressable_nar_path, is_servable_narinfo,
34    referrer_of, MemNarRefIndex,
35    NarRefIndex, NarRefKey, NarRefScan, NAR_REF_PREFIX,
36};
37pub use nar_stream::{
38    bytes_stream, collect_nar, empty_stream, file_stream, spool_or_buffer, whole_value_stream,
39    BytesNarSource, FileNarSource, NarSource, NarStream, SpooledNarSource,
40    DEFAULT_INGEST_MEMORY_CAP, NAR_CHUNK_BYTES,
41};
42pub use pg::{PgCacheConn, PgStorageBackend, PgTable};
43pub use redis::{RedisBackend, RedisConn};
44pub use s3::S3Storage;
45pub use tiered::{TieredBackend, TieredTier, WritePolicy, TIERED_BACKEND_TIER};
46
47#[cfg(feature = "redis-client")]
48pub use redis::RedisConnectionManager;
49
50#[cfg(feature = "postgres")]
51pub use pg::SqlxPgCacheConn;
52
53use async_trait::async_trait;
54use futures::future::BoxFuture;
55
56use crate::config::BackendConfig;
57use crate::StoreError;
58
59/// What a backend's NAR path costs in resident memory.
60///
61/// **Every [`StorageBackend`] implementor must state this — it has no default.**
62/// That is the mechanism, not decoration: the streaming verbs
63/// ([`get_nar_stream`](StorageBackend::get_nar_stream) /
64/// [`put_nar_stream`](StorageBackend::put_nar_stream)) *do* carry a buffering
65/// fallback so a test double stays a few lines, and without a required
66/// declaration a new production backend could silently inherit it and
67/// reintroduce the OOM. Making the declaration mandatory means adding a backend
68/// without deciding is a **compile error**, and shipping a production backend
69/// that declares [`WholeValue`](NarResidency::WholeValue) is caught by
70/// [`every_production_backend_bounds_its_nar_path`] in CI.
71///
72/// Tier-honest: this is *parse-time-rejected* (you cannot omit the decision) plus
73/// *CI-gate-caught* (you cannot ship the wrong one from the factory). It is
74/// **not** truly-unrepresentable — the buffering code path still exists and a
75/// hand-constructed backend may use it.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum NarResidency {
78    /// **O(chunk).** The backend never holds more than [`NAR_CHUNK_BYTES`] of a
79    /// NAR, whatever the NAR's size.
80    Streaming,
81    /// **O(min(nar, cap)).** Bounded by a configured cap; a NAR past the cap is
82    /// *refused* ([`StoreError::TooLarge`]), never buffered. The hot-tier shape:
83    /// a cap is a real bound because the durable tiers below still take the
84    /// write.
85    Capped(usize),
86    /// **O(nar).** The whole NAR is materialized. Legal only for in-memory test
87    /// doubles and small-value stores — never for a tier that serves real
88    /// builds.
89    WholeValue,
90}
91
92impl NarResidency {
93    /// Whether the peak is bounded independently of NAR size.
94    #[must_use]
95    pub const fn is_bounded(self) -> bool {
96        !matches!(self, NarResidency::WholeValue)
97    }
98
99    /// The **weaker** of two residencies — the honest answer for a composite
100    /// backend, whose real cost is its worst tier's.
101    ///
102    /// Order: `Streaming` (best) < `Capped` < `WholeValue` (worst); two
103    /// `Capped`s compose to the larger cap, because either one may be the one
104    /// that holds the bytes.
105    #[must_use]
106    pub fn weaker(self, other: Self) -> Self {
107        match (self, other) {
108            (NarResidency::WholeValue, _) | (_, NarResidency::WholeValue) => {
109                NarResidency::WholeValue
110            }
111            (NarResidency::Capped(a), NarResidency::Capped(b)) => NarResidency::Capped(a.max(b)),
112            (NarResidency::Capped(a), NarResidency::Streaming)
113            | (NarResidency::Streaming, NarResidency::Capped(a)) => NarResidency::Capped(a),
114            (NarResidency::Streaming, NarResidency::Streaming) => NarResidency::Streaming,
115        }
116    }
117}
118
119/// Abstraction over binary cache storage.
120///
121/// Narinfo files are keyed by the 32-character store path hash.
122/// NAR blobs are keyed by their relative URL path (e.g. `nar/<hash>.nar.xz`).
123///
124/// # NAR verbs come in two shapes; prefer the streaming pair
125///
126/// [`get_nar`](StorageBackend::get_nar) / [`put_nar`](StorageBackend::put_nar)
127/// hand whole `Vec<u8>` / `&[u8]` values across the boundary and are therefore
128/// **O(nar) resident by signature**. They remain for callers that genuinely have
129/// or want the whole thing (tests, small values, the GC).
130///
131/// [`get_nar_stream`](StorageBackend::get_nar_stream) /
132/// [`put_nar_stream`](StorageBackend::put_nar_stream) move the same content in
133/// [`NAR_CHUNK_BYTES`] chunks and are what the HTTP server and every tier-to-tier
134/// transfer use. `narinfo` is ~728 bytes and deliberately has no streaming pair.
135///
136/// # Record verbs vs. composed verbs
137///
138/// The `*_record` verbs ([`put_narinfo_record`](StorageBackend::put_narinfo_record),
139/// [`delete_narinfo_record`](StorageBackend::delete_narinfo_record),
140/// [`delete_nar_record`](StorageBackend::delete_nar_record)) each touch **exactly
141/// one key and maintain nothing**. They are what a backend implements.
142///
143/// [`put_narinfo`](StorageBackend::put_narinfo) and
144/// [`delete`](StorageBackend::delete) are *composed* on top: they keep the
145/// [`NarRefIndex`] in step and, in `delete`'s case, refuse to remove a NAR that
146/// another narinfo still advertises. They are provided, so a backend cannot
147/// forget to index — see [`nar_refs`] for what the two directions cost.
148#[async_trait]
149pub trait StorageBackend: Send + Sync {
150    /// Retrieve narinfo text by store path hash.
151    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError>;
152
153    /// Store narinfo text keyed by store path hash — **the record verb**: one
154    /// key, no index maintenance.
155    ///
156    /// Callers want [`put_narinfo`](Self::put_narinfo), which also records the
157    /// reverse edge.
158    async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError>;
159
160    /// Remove a narinfo record by store path hash. Idempotent; removing an
161    /// absent narinfo is `Ok(())`.
162    ///
163    /// **The record verb**: it does not touch the NAR the narinfo advertises and
164    /// does not maintain the index. Callers want [`delete`](Self::delete).
165    async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError>;
166
167    /// Remove one NAR blob by its relative path. Idempotent.
168    ///
169    /// **The record verb, and the one with teeth**: removing a NAR that a live
170    /// narinfo still advertises is the stranding hazard this module exists to
171    /// prevent, and *nothing here checks*. Callers want [`delete`](Self::delete);
172    /// reach for this directly only after consulting
173    /// [`nar_ref_index`](Self::nar_ref_index).
174    async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError>;
175
176    /// This backend's **narhash → store-hash reverse index**.
177    ///
178    /// **Required — no default.** A default would be an empty index, and an
179    /// empty index does not read as "unknown", it reads as "nobody advertises
180    /// this NAR" — which is precisely the answer that authorizes deleting a NAR
181    /// out from under a live narinfo. Same mechanism, and the same reason, as
182    /// [`nar_residency`](Self::nar_residency): the decision cannot be omitted.
183    fn nar_ref_index(&self) -> &dyn NarRefIndex;
184
185    /// Retrieve a NAR blob by its relative path.
186    ///
187    /// **O(nar) resident.** Prefer [`get_nar_stream`](Self::get_nar_stream) on
188    /// any path that serves real build artifacts.
189    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError>;
190
191    /// Store a NAR blob at the given relative path.
192    ///
193    /// **O(nar) resident.** Prefer [`put_nar_stream`](Self::put_nar_stream) on
194    /// any path that ingests real build artifacts.
195    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError>;
196
197    /// Declare what this backend's NAR path costs in resident memory.
198    ///
199    /// **Required — no default.** See [`NarResidency`] for why.
200    fn nar_residency(&self) -> NarResidency;
201
202    /// Retrieve a NAR blob as a bounded-chunk stream.
203    ///
204    /// The default materializes via [`get_nar`](Self::get_nar) — correct, and
205    /// **O(nar) resident**. A backend that declares
206    /// [`NarResidency::Streaming`] must override this.
207    ///
208    /// # Errors
209    ///
210    /// Propagates the backend's read failure. `Ok(None)` is a clean miss.
211    async fn get_nar_stream(&self, path: &str) -> Result<Option<NarStream>, StoreError> {
212        Ok(self.get_nar(path).await?.map(nar_stream::whole_value_stream))
213    }
214
215    /// Store a NAR blob from a re-openable bounded-chunk source.
216    ///
217    /// The default drains the source into one buffer and calls
218    /// [`put_nar`](Self::put_nar) — correct, and **O(nar) resident**. A backend
219    /// that declares [`NarResidency::Streaming`] must override this.
220    ///
221    /// # Errors
222    ///
223    /// Propagates the source's read failure or the backend's write failure.
224    async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
225        let data = nar_stream::collect_nar(src.open().await?, None).await?;
226        self.put_nar(path, &data).await
227    }
228
229    /// Store narinfo text **and record the reverse edge it creates**.
230    ///
231    /// # Ordering, and why it is this way round
232    ///
233    /// The edge is recorded **before** the narinfo record is written. A crash
234    /// between the two then leaves an edge with no narinfo — an over-report,
235    /// which costs a NAR that could have been reclaimed. The other order leaves
236    /// a narinfo with no edge, which is an under-report, and an under-report is
237    /// what lets a later `delete` take the NAR this narinfo advertises. Leak
238    /// over strand, every time.
239    ///
240    /// A narinfo whose `URL:` is not an addressable relative path is **refused**
241    /// (see [`is_addressable_nar_path`]): it arrives over the wire, it is used
242    /// as a key and joined onto a filesystem root, and there is no sanitizing it
243    /// safely at each of those uses. Text carrying no `URL:` at all is stored
244    /// as-is and indexes nothing — it advertises no NAR, so there is nothing to
245    /// strand.
246    ///
247    /// # Errors
248    ///
249    /// Propagates the index write or the record write, and returns
250    /// [`StoreError::NarInfo`] for an unaddressable `URL:`.
251    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), StoreError> {
252        match nar_refs::advertised_url_line(content) {
253            Some(url) if nar_refs::is_addressable_nar_path(url) => {
254                self.nar_ref_index().record(url, hash).await?;
255            }
256            Some(url) => {
257                return Err(StoreError::NarInfo(format!(
258                    "narinfo {hash} advertises an unaddressable URL: {url:?}",
259                )));
260            }
261            None => {}
262        }
263        self.put_narinfo_record(hash, content).await
264    }
265
266    /// The NAR path this store path's narinfo advertises — **resolved, never
267    /// guessed**.
268    ///
269    /// `None` means the narinfo is absent, unparseable, or advertises a URL this
270    /// store will not address. All three mean the same thing to a caller: there
271    /// is no NAR here it is entitled to touch.
272    ///
273    /// # Errors
274    ///
275    /// Propagates the narinfo read failure. A read failure is **not** flattened
276    /// into `None`: "the tier is down" must never be mistaken for "this path has
277    /// no NAR" by something about to delete.
278    async fn advertised_nar(&self, hash: &str) -> Result<Option<String>, StoreError> {
279        Ok(self.get_narinfo(hash).await?.as_deref().and_then(nar_refs::advertised_nar_url))
280    }
281
282    /// Delete a store path's narinfo, and its NAR **only if nothing else
283    /// advertises that NAR**.
284    ///
285    /// # What changed, and why it is not cosmetic
286    ///
287    /// This used to guess: every backend best-effort-deleted
288    /// `nar/{store-hash}.{xz,zst,nar}`. The NAR is keyed by *narhash*, not by
289    /// store hash, so the guess normally deleted three keys that were never this
290    /// path's NAR and left the real one behind. It now **resolves** the key from
291    /// the narinfo's own `URL:`.
292    ///
293    /// Resolving alone would be a regression: two store paths with identical
294    /// contents share one narhash and therefore one `URL:`, so deleting either
295    /// would take the NAR the other still advertises — and a narinfo whose
296    /// advertised NAR 404s is a hard Nix failure, not a cache miss. So the NAR
297    /// goes only when [`nar_ref_index`](Self::nar_ref_index) reports no other
298    /// referrer.
299    ///
300    /// # Ordering
301    ///
302    /// The narinfo record is removed **first**, then its edge. A crash between
303    /// the two leaves a stale edge — an over-report that costs a retained NAR.
304    /// The other order would leave a live narinfo with no edge, and the next
305    /// `delete` of a co-referrer would strand it.
306    ///
307    /// # Errors
308    ///
309    /// Propagates the narinfo read, the record delete, or the index update.
310    async fn delete(&self, hash: &str) -> Result<(), StoreError> {
311        let advertised = self.advertised_nar(hash).await?;
312
313        self.delete_narinfo_record(hash).await?;
314
315        let Some(nar_path) = advertised else { return Ok(()) };
316        self.nar_ref_index().forget(&nar_path, hash).await?;
317
318        let others = self.nar_ref_index().referrers(&nar_path).await?;
319        if others.is_empty() {
320            self.delete_nar_record(&nar_path).await?;
321        } else {
322            tracing::debug!(
323                hash = %hash,
324                nar_path = %nar_path,
325                referrers = others.len(),
326                "delete: NAR retained — another narinfo still advertises it; removing it \
327                 would 404 an advertised URL, which Nix treats as a hard failure",
328            );
329        }
330        Ok(())
331    }
332
333    /// Rebuild every reverse edge from the narinfos this backend holds, and
334    /// return the number of edges recorded.
335    ///
336    /// The index is maintained forward from [`put_narinfo`](Self::put_narinfo),
337    /// so a store filled **before** the index existed has none — and an absent
338    /// edge reads as "nobody advertises this NAR". Running this once after an
339    /// upgrade closes that gap; it is idempotent, so running it again is free.
340    ///
341    /// O(narinfos), one narinfo read each: a maintenance verb, not something on
342    /// a request path.
343    ///
344    /// # Errors
345    ///
346    /// Propagates the listing, a narinfo read, or an index write.
347    async fn reindex_nar_refs(&self) -> Result<usize, StoreError> {
348        let mut recorded = 0usize;
349        for hash in self.list_narinfos().await? {
350            if let Some(nar_path) = self.advertised_nar(&hash).await? {
351                self.nar_ref_index().record(&nar_path, &hash).await?;
352                recorded += 1;
353            }
354        }
355        Ok(recorded)
356    }
357
358    /// List all stored narinfo hashes.
359    async fn list_narinfos(&self) -> Result<Vec<String>, StoreError>;
360
361    /// Clear EVERY narinfo and NAR blob from this backend. Returns the number
362    /// of narinfos removed.
363    ///
364    /// The default lists every narinfo and best-effort `delete`s it (narinfo-only
365    /// clear; NAR blobs keyed by *narhash* are not reached). Concrete durable
366    /// tiers override with a real truncation that reclaims NAR bytes.
367    async fn wipe_all(&self) -> Result<usize, StoreError> {
368        let hashes = self.list_narinfos().await?;
369        let n = hashes.len();
370        for hash in hashes {
371            self.delete(&hash).await?;
372        }
373        Ok(n)
374    }
375}
376
377/// Config-select factory: build the concrete [`StorageBackend`] a
378/// [`BackendConfig`] names.
379///
380/// This is **typed dispatch, not stringly** — a new backend kind is a
381/// non-exhaustive-`match` compile error, and the [`Tiered`](BackendConfig::Tiered)
382/// arm recurses, composing each sub-backend into a [`TieredBackend`]. The result
383/// is an `Arc<dyn StorageBackend>` ready for injection into any consumer.
384///
385/// The `Redis` and `Pg` arms require their production transports; without the
386/// corresponding Cargo feature (`redis-client` / `postgres`) they return a typed
387/// [`StoreError::NotImplemented`] rather than silently falling back to disk.
388///
389/// Returns a boxed future because the `Tiered` arm is recursive.
390///
391/// # Errors
392///
393/// Propagates any backend construction failure, or [`StoreError::NotImplemented`]
394/// when a config selects a backend whose feature is not compiled in.
395pub fn build_backend(
396    config: &BackendConfig,
397) -> BoxFuture<'_, Result<Arc<dyn StorageBackend>, StoreError>> {
398    Box::pin(async move {
399        match config {
400            BackendConfig::Local { path } => {
401                Ok(Arc::new(LocalStorage::new(path.clone())) as Arc<dyn StorageBackend>)
402            }
403            BackendConfig::S3 { bucket, region, endpoint } => {
404                let s3 = S3Storage::new(bucket.clone(), region.clone(), endpoint.clone())?;
405                Ok(Arc::new(s3) as Arc<dyn StorageBackend>)
406            }
407            BackendConfig::Redis { url, ttl_secs } => build_redis(url, *ttl_secs).await,
408            BackendConfig::Pg { url, max_conns } => build_pg(url, *max_conns).await,
409            BackendConfig::Tiered { l1, l2, l3, write_policy } => {
410                let l1 = build_backend(l1).await?;
411                let l2 = build_backend(l2).await?;
412                let l3 = build_backend(l3).await?;
413                Ok(Arc::new(TieredBackend::with_write_policy(l1, l2, l3, *write_policy))
414                    as Arc<dyn StorageBackend>)
415            }
416        }
417    })
418}
419
420#[cfg(feature = "redis-client")]
421async fn build_redis(
422    url: &str,
423    ttl_secs: Option<u64>,
424) -> Result<Arc<dyn StorageBackend>, StoreError> {
425    let backend = match ttl_secs {
426        Some(t) => RedisBackend::connect_with_ttl(url, t).await?,
427        None => RedisBackend::connect(url).await?,
428    };
429    Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
430}
431
432#[cfg(not(feature = "redis-client"))]
433async fn build_redis(
434    _url: &str,
435    _ttl_secs: Option<u64>,
436) -> Result<Arc<dyn StorageBackend>, StoreError> {
437    Err(StoreError::NotImplemented(
438        "redis L1 backend requires building sui-castore with --features redis-client",
439    ))
440}
441
442#[cfg(feature = "postgres")]
443async fn build_pg(url: &str, max_conns: u32) -> Result<Arc<dyn StorageBackend>, StoreError> {
444    let backend = PgStorageBackend::connect(url, max_conns).await?;
445    Ok(Arc::new(backend) as Arc<dyn StorageBackend>)
446}
447
448#[cfg(not(feature = "postgres"))]
449async fn build_pg(_url: &str, _max_conns: u32) -> Result<Arc<dyn StorageBackend>, StoreError> {
450    Err(StoreError::NotImplemented(
451        "postgres L2 backend requires building sui-castore with --features postgres",
452    ))
453}
454
455#[cfg(test)]
456mod residency_gate {
457    use super::*;
458
459    /// **The gate.** Every backend a production [`BackendConfig`] can name must
460    /// bound its NAR path. A backend that regresses to
461    /// [`NarResidency::WholeValue`] fails here.
462    ///
463    /// The tripwire that keeps this honest as the fleet grows is
464    /// [`build_backend`]'s exhaustive `match`: a new [`BackendConfig`] arm is a
465    /// compile error there, and the author lands here next.
466    ///
467    /// Feature-gated arms (`Redis`, `Pg`) are exercised in their own modules
468    /// against their mock seams; this covers what a default build can construct.
469    #[tokio::test]
470    async fn every_production_backend_bounds_its_nar_path() {
471        let dir = tempfile::tempdir().unwrap();
472
473        let local = build_backend(&BackendConfig::Local { path: dir.path().to_path_buf() })
474            .await
475            .unwrap();
476        assert_eq!(
477            local.nar_residency(),
478            NarResidency::Streaming,
479            "the local/L3 tier must stream — it is the durable object tier",
480        );
481
482        let s3 = build_backend(&BackendConfig::S3 {
483            bucket: "b".to_string(),
484            region: "us-east-1".to_string(),
485            endpoint: Some("http://127.0.0.1:9".to_string()),
486        })
487        .await
488        .unwrap();
489        assert_eq!(s3.nar_residency(), NarResidency::Streaming, "S3 must multipart-stream");
490
491        // The composite: three streaming tiers compose to streaming.
492        let tiered = build_backend(&BackendConfig::Tiered {
493            l1: Box::new(BackendConfig::Local { path: dir.path().join("l1") }),
494            l2: Box::new(BackendConfig::Local { path: dir.path().join("l2") }),
495            l3: Box::new(BackendConfig::Local { path: dir.path().join("l3") }),
496            write_policy: WritePolicy::WriteThrough,
497        })
498        .await
499        .unwrap();
500        assert_eq!(tiered.nar_residency(), NarResidency::Streaming);
501        assert!(tiered.nar_residency().is_bounded());
502    }
503
504    #[test]
505    fn residency_composes_to_the_weaker_side() {
506        use NarResidency::{Capped, Streaming, WholeValue};
507        assert_eq!(Streaming.weaker(Streaming), Streaming);
508        assert_eq!(Streaming.weaker(Capped(8)), Capped(8));
509        assert_eq!(Capped(8).weaker(Capped(64)), Capped(64), "the larger cap governs");
510        assert_eq!(Capped(8).weaker(WholeValue), WholeValue);
511        assert_eq!(WholeValue.weaker(Streaming), WholeValue);
512    }
513
514    #[test]
515    fn only_whole_value_is_unbounded() {
516        assert!(NarResidency::Streaming.is_bounded());
517        assert!(NarResidency::Capped(1).is_bounded());
518        assert!(!NarResidency::WholeValue.is_bounded());
519    }
520}
521
522/// **The stranding gate.** Every backend a production [`BackendConfig`] can name
523/// must never leave a narinfo advertising a NAR it has deleted.
524///
525/// A narinfo is served 200 OK with `URL: nar/…`; a client then fetches that NAR.
526/// Nix treats a **missing advertised NAR** as a hard failure, not a cache miss —
527/// the same outage class as 2026-07-26, where 500s from a substituter failed
528/// every build on the cluster. So the property is not "delete frees bytes", it
529/// is "**every narinfo that survives a delete is still servable end to end**",
530/// and that is what these assert.
531///
532/// The per-backend equivalents for the feature-gated tiers live beside their
533/// mock seams (`pg::tests`, `redis::tests`); `s3::tests` runs the same scenario
534/// against an in-process object store. This module covers what a default build
535/// can construct through [`build_backend`], which is the factory a deployment
536/// actually goes through.
537#[cfg(test)]
538mod nar_ref_gate {
539    use super::*;
540
541    /// Two narinfos advertising ONE NAR — the case that makes the index
542    /// necessary. A NAR serializes a store path's *contents*, not its name, so
543    /// two paths with identical contents produce one narhash and one `URL:`.
544    const SHARED_NAR: &str = "nar/sharednarhash.nar.xz";
545
546    fn narinfo_for(url: &str) -> String {
547        format!(
548            "StorePath: /nix/store/pkg\nURL: {url}\nCompression: xz\nFileHash: sha256:aaa\n\
549             FileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n"
550        )
551    }
552
553    /// Run the whole scenario against one backend, naming it in every failure.
554    async fn assert_never_strands(name: &str, backend: &dyn StorageBackend) {
555        backend.put_narinfo("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
556        backend.put_narinfo("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
557        backend.put_nar(SHARED_NAR, b"shared contents").await.unwrap();
558        // A decoy shaped like the old extension guess, which the store hash
559        // would have produced. Nothing advertises it.
560        backend.put_nar("nar/pathA.nar.zst", b"unrelated").await.unwrap();
561
562        backend.delete("pathA").await.unwrap();
563
564        // The surviving narinfo is still servable END TO END: the narinfo is
565        // there AND the NAR it names is there. Checking only one of the two is
566        // how a strand hides.
567        let surviving = backend
568            .get_narinfo("pathB")
569            .await
570            .unwrap()
571            .unwrap_or_else(|| panic!("{name}: pathB's narinfo vanished"));
572        let advertised = nar_refs::advertised_nar_url(&surviving)
573            .unwrap_or_else(|| panic!("{name}: pathB advertises nothing"));
574        assert!(
575            backend.get_nar(&advertised).await.unwrap().is_some(),
576            "{name}: STRANDED — pathB's narinfo advertises {advertised}, which is gone. \
577             A client would get 200 on the narinfo and 404 on the NAR, which nix treats \
578             as a hard build failure.",
579        );
580        assert_eq!(
581            backend.nar_ref_index().referrers(SHARED_NAR).await.unwrap(),
582            vec!["pathB".to_string()],
583            "{name}: the index must have dropped exactly pathA's edge",
584        );
585        let decoy = backend.get_nar("nar/pathA.nar.zst").await.unwrap().unwrap_or_else(|| {
586            panic!(
587                "{name}: GUESSED — delete removed nar/pathA.nar.zst, a key built from the \
588                 STORE hash that no narinfo ever advertised. A NAR is keyed by narhash; \
589                 delete must resolve the advertised URL, never guess an extension.",
590            )
591        });
592        assert_eq!(decoy, b"unrelated", "{name}: the decoy's bytes were altered");
593
594        // With the last referrer gone the NAR is reclaimable — otherwise the
595        // gate above would pass trivially by never deleting anything.
596        backend.delete("pathB").await.unwrap();
597        assert!(
598            backend.get_nar(SHARED_NAR).await.unwrap().is_none(),
599            "{name}: nothing advertises the NAR any more; it must be reclaimed",
600        );
601    }
602
603    #[tokio::test]
604    async fn every_production_backend_pairs_its_nar_with_its_narinfo() {
605        let dir = tempfile::tempdir().unwrap();
606
607        let local = build_backend(&BackendConfig::Local { path: dir.path().join("solo") })
608            .await
609            .unwrap();
610        assert_never_strands("LocalStorage", local.as_ref()).await;
611
612        let tiered = build_backend(&BackendConfig::Tiered {
613            l1: Box::new(BackendConfig::Local { path: dir.path().join("l1") }),
614            l2: Box::new(BackendConfig::Local { path: dir.path().join("l2") }),
615            l3: Box::new(BackendConfig::Local { path: dir.path().join("l3") }),
616            write_policy: WritePolicy::WriteThrough,
617        })
618        .await
619        .unwrap();
620        assert_never_strands("TieredBackend", tiered.as_ref()).await;
621    }
622
623    /// The **migration gap**, stated as a test rather than a doc line.
624    ///
625    /// A store written by a pre-index binary has narinfos and no edges, and an
626    /// absent edge reads as "nobody advertises this NAR". Deleting one of two
627    /// co-referring paths therefore strands the other — until
628    /// [`reindex_nar_refs`](StorageBackend::reindex_nar_refs) has run once. Both
629    /// halves are asserted, so the gap cannot be quietly forgotten *or* quietly
630    /// claimed to be closed.
631    #[tokio::test]
632    async fn an_unindexed_store_can_strand_until_reindexed() {
633        let dir = tempfile::tempdir().unwrap();
634
635        // The gap: narinfos written the pre-index way, via the record verb.
636        let stale = LocalStorage::new(dir.path().join("stale"));
637        stale.put_narinfo_record("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
638        stale.put_narinfo_record("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
639        stale.put_nar(SHARED_NAR, b"shared").await.unwrap();
640        stale.delete("pathA").await.unwrap();
641        assert!(
642            stale.get_narinfo("pathB").await.unwrap().is_some(),
643            "pathB's narinfo is still there…",
644        );
645        assert!(
646            stale.get_nar(SHARED_NAR).await.unwrap().is_none(),
647            "…and its NAR is gone: this IS the strand, and it is what an un-reindexed \
648             upgrade looks like",
649        );
650
651        // The close: same fixture, reindexed before the delete.
652        let healed = LocalStorage::new(dir.path().join("healed"));
653        healed.put_narinfo_record("pathA", &narinfo_for(SHARED_NAR)).await.unwrap();
654        healed.put_narinfo_record("pathB", &narinfo_for(SHARED_NAR)).await.unwrap();
655        healed.put_nar(SHARED_NAR, b"shared").await.unwrap();
656        assert_eq!(healed.reindex_nar_refs().await.unwrap(), 2);
657        healed.delete("pathA").await.unwrap();
658        assert!(
659            healed.get_nar(SHARED_NAR).await.unwrap().is_some(),
660            "after a reindex the co-referrer is visible and the NAR is retained",
661        );
662    }
663}