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}