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