Skip to main content

sui_castore/storage/
tiered.rs

1//! **Tiered** `StorageBackend` — the `Redis L1 → Postgres L2 → object L3`
2//! read-through / write-through cache resolver.
3//!
4//! [`TieredBackend`] composes three [`StorageBackend`]s into one. It is itself a
5//! `StorageBackend`, so it drops straight into `AppState.storage`
6//! (`Arc<dyn StorageBackend>`) with **zero server change** — the daemon consumes
7//! exactly one backend and does not care that it is three underneath.
8//!
9//! # Read path (read-through + promotion)
10//!
11//! `get_*` tries the tiers top-down and **promotes on a lower-tier hit**:
12//!
13//! ```text
14//! L1 (Redis, hot)  ── hit ─▶ return
15//!   │ miss OR ERROR
16//! L2 (Postgres)    ── hit ─▶ warm L1, return
17//!   │ miss OR ERROR
18//! L3 (object)      ── hit ─▶ warm L2, warm L1, return
19//!   │ miss OR ERROR
20//! Ok(None) if every tier cleanly missed / Err if any tier was broken
21//! ```
22//!
23//! **A broken tier is stepped over, not fatal.** A tier that errors is logged at
24//! `ERROR` and the resolver continues down — so Postgres being unreachable can
25//! never stop an object-store hit from being served. An error only surfaces when
26//! *no* tier produced the content AND at least one tier failed, because then the
27//! key genuinely cannot be ruled out (the broken tier might have held it). That
28//! is deliberately conservative: the caller is told "I could not answer", not
29//! "it is absent". Callers for whom a read is optional — the cache HTTP server —
30//! turn that into a miss; callers for whom it is not (GC, which deletes) keep
31//! treating it as an error.
32//!
33//! Promotion is **best-effort**: the read already succeeded, so a failed warm of
34//! an upper tier is logged (`tracing::warn!`) and swallowed — it never turns a
35//! successful read into an error. Because every key is content-derived, an L1
36//! miss satisfied by L2/L3 returns *the same bytes* for the same key
37//! (read-through transparency).
38//!
39//! # Write path (typed [`WritePolicy`])
40//!
41//! Every policy **attempts both durable tiers (L2 and L3) before returning** and
42//! succeeds if **at least one** accepted the write — so a pod roll that loses the
43//! ephemeral L1 loses nothing, and a later read (which falls through tiers) is
44//! satisfied by whichever durable tier holds it. The policies differ only in how
45//! they treat the hot L1 tier:
46//!
47//! - [`WritePolicy::WriteThrough`] (default) — durable tiers first, then warm L1.
48//! - [`WritePolicy::WriteBack`] — warm L1 first (immediate hot availability for a
49//!   racing read), then persist the durable tiers **before returning** (still
50//!   crash-safe: it does *not* acknowledge before the durable flush). See the
51//!   tier note on why fully-async deferred write-back is deliberately unshipped.
52//! - [`WritePolicy::WriteAround`] — durable tiers only, skip L1 (avoids polluting
53//!   the hot tier with write-once-read-never blobs; L1 fills lazily on read).
54//!
55//! A write fails only when **every** durable tier rejected it (nothing was
56//! stored anywhere). One durable tier failing is logged at `WARN` as lost
57//! redundancy, not an error — one broken durable tier must not zero out a
58//! healthy one. An L1 warm failure is best-effort (logged), for the same reason
59//! promotion is.
60//!
61//! # `delete` / `list_narinfos`
62//!
63//! `delete` fans out to all three tiers best-effort (content-addressed storage
64//! makes delete a GC operation, not a correctness one — a key always resolves to
65//! its content or to nothing; mirror [`S3Storage::delete`](super::S3Storage)).
66//! `list_narinfos` unions the **authoritative** durable tiers (L2 ∪ L3), deduped;
67//! L1 is skipped because it is only a partial hot subset.
68
69use std::collections::BTreeSet;
70use std::sync::Arc;
71
72use async_trait::async_trait;
73use tracing::warn;
74
75use super::nar_refs::NarRefIndex;
76use super::nar_stream::{self, NarSource, NarStream};
77use super::{NarResidency, StorageBackend};
78use crate::StoreError;
79
80/// A [`NarSource`] that re-reads one tier.
81///
82/// This is what makes a streamed promotion possible without a buffer: warming an
83/// upper tier is `upper.put_nar_stream(path, &TierNarSource::new(lower, path))`,
84/// and each `open()` is a fresh bounded read of the tier that already has the
85/// bytes.
86///
87/// # The cost, stated plainly
88///
89/// Each warm is an **extra full pass over the lower tier**. An L2 hit reads L2
90/// twice (once to warm L1, once to serve); an L3 hit reads L3 three times (warm
91/// L2, warm L1, serve). The old code got those passes "free" because it was
92/// already holding the whole NAR — which is precisely the thing that killed the
93/// pod. Read amplification on the *promotion* path is the honest price of a
94/// bounded peak, and promotion is the cold path by construction: it happens once
95/// per key, after which L1 answers.
96///
97/// Two alternatives were considered and rejected. Sourcing the L1 warm from the
98/// freshly-warmed L2 (2 passes instead of 3) makes the L1 warm silently depend
99/// on L2's health, and a broken L2 must not stop L1 from being warmed from a
100/// healthy L3 — the resolver's whole degrade-don't-fail posture. Backgrounding
101/// the warm removes the pass from the request entirely but breaks the contract
102/// that promotion has happened by the time `get` returns, which the resolver's
103/// tests assert and callers rely on.
104struct TierNarSource {
105    tier: Arc<dyn StorageBackend>,
106    path: String,
107}
108
109impl TierNarSource {
110    fn new(tier: &Arc<dyn StorageBackend>, path: &str) -> Self {
111        Self { tier: Arc::clone(tier), path: path.to_string() }
112    }
113}
114
115#[async_trait]
116impl NarSource for TierNarSource {
117    async fn open(&self) -> Result<NarStream, StoreError> {
118        self.tier.get_nar_stream(&self.path).await?.ok_or_else(|| {
119            // The content was there when the read resolved and is gone now —
120            // an eviction or a concurrent delete racing the promotion. The warm
121            // is best-effort, so the caller logs and moves on.
122            StoreError::PathNotFound(format!(
123                "{}: vanished from the source tier mid-promotion",
124                self.path
125            ))
126        })
127    }
128}
129
130/// How a `put` propagates across the tiers. See the module docs for the full
131/// contract; every policy persists **both durable tiers before returning**.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
133#[serde(rename_all = "kebab-case")]
134pub enum WritePolicy {
135    /// Durable tiers (L2, L3) first, then warm L1. Crash-safe. The default.
136    #[default]
137    WriteThrough,
138    /// Warm L1 first (immediate hot availability), then persist durable tiers
139    /// before returning. Crash-safe (no ack before the durable flush).
140    WriteBack,
141    /// Durable tiers only; skip L1 (it fills lazily on read-through).
142    WriteAround,
143}
144
145/// The honest self-description of what [`TieredBackend`] has been *proven*
146/// against — asserted by the honest gate so a claim cannot be silently rounded
147/// up.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum TieredTier {
150    /// Resolver semantics proven against in-memory mock tiers + a real on-disk
151    /// [`LocalStorage`](super::LocalStorage) L3 (the "real-shape L3"). **No live
152    /// Redis/Postgres/S3 exercised.**
153    MockParityProven,
154    /// Additionally proven end-to-end against a live Redis + Postgres + object
155    /// store in a cluster. (Not the shipped tier.)
156    LiveClusterProven,
157}
158
159/// The shipped tier of [`TieredBackend`]. Asserted by the honest gate; bumping it
160/// to [`LiveClusterProven`](TieredTier::LiveClusterProven) without a live
161/// integration test is a build-failing round-up.
162pub const TIERED_BACKEND_TIER: TieredTier = TieredTier::MockParityProven;
163
164/// Three-tier read-through / write-through cache resolver.
165///
166/// Holds `Arc<dyn StorageBackend>` per tier, so any backend composes — the real
167/// deployment injects `RedisBackend` (L1), `PgStorageBackend` (L2), `S3Storage`
168/// (L3); tests inject in-memory mocks + a `LocalStorage`.
169pub struct TieredBackend {
170    l1: Arc<dyn StorageBackend>,
171    l2: Arc<dyn StorageBackend>,
172    l3: Arc<dyn StorageBackend>,
173    write_policy: WritePolicy,
174}
175
176impl TieredBackend {
177    /// Compose three tiers with the default [`WritePolicy::WriteThrough`].
178    #[must_use]
179    pub fn new(
180        l1: Arc<dyn StorageBackend>,
181        l2: Arc<dyn StorageBackend>,
182        l3: Arc<dyn StorageBackend>,
183    ) -> Self {
184        Self::with_write_policy(l1, l2, l3, WritePolicy::default())
185    }
186
187    /// Compose three tiers with an explicit [`WritePolicy`].
188    #[must_use]
189    pub fn with_write_policy(
190        l1: Arc<dyn StorageBackend>,
191        l2: Arc<dyn StorageBackend>,
192        l3: Arc<dyn StorageBackend>,
193        write_policy: WritePolicy,
194    ) -> Self {
195        Self { l1, l2, l3, write_policy }
196    }
197
198    /// The active write policy.
199    #[must_use]
200    pub fn write_policy(&self) -> WritePolicy {
201        self.write_policy
202    }
203
204    /// The three tiers, named, in resolution order.
205    fn tiers(&self) -> [(&'static str, &Arc<dyn StorageBackend>); 3] {
206        [("l1", &self.l1), ("l2", &self.l2), ("l3", &self.l3)]
207    }
208
209    // ── best-effort warmers (promotion + hot write) ────────────────────────
210
211    async fn warm_narinfo(tier: &Arc<dyn StorageBackend>, hash: &str, content: &str) {
212        if let Err(e) = tier.put_narinfo(hash, content).await {
213            warn!(hash = %hash, error = %e, "tiered: best-effort narinfo warm failed");
214        }
215    }
216
217    /// Best-effort warm of `tier` from a re-openable source. **O(chunk).**
218    ///
219    /// The result is logged and discarded — including the
220    /// [`TooLarge`](StoreError::TooLarge) a capped hot tier returns for an
221    /// oversized NAR. That is the contract, not laxity: a refused L1 warm must
222    /// never fail a build.
223    async fn warm_nar_from(tier: &Arc<dyn StorageBackend>, path: &str, src: &dyn NarSource) {
224        if let Err(e) = tier.put_nar_stream(path, src).await {
225            warn!(path = %path, error = %e, "tiered: best-effort NAR warm failed");
226        }
227    }
228
229    /// Best-effort warm of `tier` by re-reading `from`. Used on the read path,
230    /// where the bytes live in a lower tier rather than in a caller's buffer.
231    async fn warm_nar_from_tier(
232        tier: &Arc<dyn StorageBackend>,
233        from: &Arc<dyn StorageBackend>,
234        path: &str,
235    ) {
236        Self::warm_nar_from(tier, path, &TierNarSource::new(from, path)).await;
237    }
238
239    // ── read/write failure accounting ──────────────────────────────────────
240
241    /// Log a tier's read failure loudly and hand the error back for the caller
242    /// to hold as "we could not rule this key out".
243    ///
244    /// Degrading is NOT the same as going quiet: a broken tier is an operational
245    /// fault that must be visible even though the request survives it. This is
246    /// the one place that guarantee lives, so it cannot be forgotten at a call
247    /// site.
248    fn note_tier_read_failure(tier: &'static str, key: &str, e: StoreError) -> StoreError {
249        tracing::error!(
250            tier = tier,
251            key = %key,
252            error = %e,
253            "tiered: READ FAILED on a tier — falling through to the next tier; \
254             this tier is degraded and needs attention",
255        );
256        e
257    }
258
259    /// Collapse the two durable tiers' write results into one outcome.
260    ///
261    /// **Succeeds if at least one durable tier accepted the write.** A single
262    /// broken durable tier must not zero out the other: with L2 (Postgres) down
263    /// and L3 (object/disk) healthy, the old first-`?` behavior meant the L3
264    /// write was never even attempted, so a push that could have half-landed
265    /// landed nowhere. Since reads now fall through across tiers, content held
266    /// by either durable tier is fully serveable.
267    ///
268    /// This deliberately weakens the old "both durable tiers always hold it"
269    /// invariant to "at least one durable tier holds it". What operators
270    /// actually depend on — *a read after a pod roll returns the bytes* — is
271    /// preserved; what is lost is per-tier redundancy, which is logged as a
272    /// partial write rather than hidden.
273    fn durable_write_outcome(
274        kind: &'static str,
275        key: &str,
276        l2: Result<(), StoreError>,
277        l3: Result<(), StoreError>,
278    ) -> Result<(), StoreError> {
279        match (l2, l3) {
280            (Ok(()), Ok(())) => Ok(()),
281            (Err(e), Ok(())) => {
282                warn!(
283                    kind = kind, key = %key, tier = "l2", error = %e,
284                    "tiered: durable write failed on ONE tier; the other durable tier \
285                     accepted it, so the content is still serveable — redundancy lost",
286                );
287                Ok(())
288            }
289            (Ok(()), Err(e)) => {
290                warn!(
291                    kind = kind, key = %key, tier = "l3", error = %e,
292                    "tiered: durable write failed on ONE tier; the other durable tier \
293                     accepted it, so the content is still serveable — redundancy lost",
294                );
295                Ok(())
296            }
297            (Err(e2), Err(e3)) => {
298                tracing::error!(
299                    kind = kind, key = %key, l2_error = %e2, l3_error = %e3,
300                    "tiered: durable write failed on EVERY durable tier — nothing was stored",
301                );
302                // Surface the L2 error; both are logged above.
303                Err(e2)
304            }
305        }
306    }
307}
308
309#[async_trait]
310impl StorageBackend for TieredBackend {
311    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError> {
312        let mut broken: Option<StoreError> = None;
313
314        // L1
315        match self.l1.get_narinfo(hash).await {
316            Ok(Some(v)) => return Ok(Some(v)),
317            Ok(None) => {}
318            Err(e) => broken = Some(Self::note_tier_read_failure("l1", hash, e)),
319        }
320        // L2 → promote to L1
321        match self.l2.get_narinfo(hash).await {
322            Ok(Some(v)) => {
323                Self::warm_narinfo(&self.l1, hash, &v).await;
324                return Ok(Some(v));
325            }
326            Ok(None) => {}
327            Err(e) => broken = Some(Self::note_tier_read_failure("l2", hash, e)),
328        }
329        // L3 → promote to L2 then L1
330        match self.l3.get_narinfo(hash).await {
331            Ok(Some(v)) => {
332                Self::warm_narinfo(&self.l2, hash, &v).await;
333                Self::warm_narinfo(&self.l1, hash, &v).await;
334                return Ok(Some(v));
335            }
336            Ok(None) => {}
337            Err(e) => broken = Some(Self::note_tier_read_failure("l3", hash, e)),
338        }
339
340        match broken {
341            Some(e) => Err(e),
342            None => Ok(None),
343        }
344    }
345
346    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError> {
347        // ONE code path: the whole-value verb is the streaming verb drained, so
348        // fall-through, promotion and error accounting cannot diverge between
349        // the two shapes.
350        match self.get_nar_stream(path).await? {
351            Some(s) => Ok(Some(nar_stream::collect_nar(s, None).await?)),
352            None => Ok(None),
353        }
354    }
355
356    /// The composite's residency is its **weakest tier's** — a streaming
357    /// resolver in front of a whole-value tier still materializes NARs.
358    /// Reporting `Streaming` here because the resolver itself streams would be
359    /// exactly the rounding-up this type exists to prevent.
360    fn nar_residency(&self) -> NarResidency {
361        self.l1
362            .nar_residency()
363            .weaker(self.l2.nar_residency())
364            .weaker(self.l3.nar_residency())
365    }
366
367    /// Fall through the tiers and promote, **without ever holding the NAR**.
368    ///
369    /// Identical resolution order and identical promotion targets to
370    /// `get_narinfo`; the only difference is that a promotion re-reads the tier
371    /// that answered (see [`TierNarSource`]) instead of copying a buffer the
372    /// caller is holding. The stream handed back is opened against the tier that
373    /// actually had the content, so the caller's bytes never depend on whether a
374    /// warm succeeded.
375    async fn get_nar_stream(&self, path: &str) -> Result<Option<NarStream>, StoreError> {
376        let mut broken: Option<StoreError> = None;
377
378        match self.l1.get_nar_stream(path).await {
379            Ok(Some(s)) => return Ok(Some(s)),
380            Ok(None) => {}
381            Err(e) => broken = Some(Self::note_tier_read_failure("l1", path, e)),
382        }
383        match self.l2.get_nar_stream(path).await {
384            Ok(Some(s)) => {
385                Self::warm_nar_from_tier(&self.l1, &self.l2, path).await;
386                return Ok(Some(s));
387            }
388            Ok(None) => {}
389            Err(e) => broken = Some(Self::note_tier_read_failure("l2", path, e)),
390        }
391        match self.l3.get_nar_stream(path).await {
392            Ok(Some(s)) => {
393                Self::warm_nar_from_tier(&self.l2, &self.l3, path).await;
394                Self::warm_nar_from_tier(&self.l1, &self.l3, path).await;
395                return Ok(Some(s));
396            }
397            Ok(None) => {}
398            Err(e) => broken = Some(Self::note_tier_read_failure("l3", path, e)),
399        }
400
401        match broken {
402            Some(e) => Err(e),
403            None => Ok(None),
404        }
405    }
406
407    async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError> {
408        if self.write_policy == WritePolicy::WriteBack {
409            Self::warm_narinfo(&self.l1, hash, content).await;
410        }
411
412        let l2 = self.l2.put_narinfo_record(hash, content).await;
413        let l3 = self.l3.put_narinfo_record(hash, content).await;
414        Self::durable_write_outcome("narinfo", hash, l2, l3)?;
415
416        if self.write_policy == WritePolicy::WriteThrough {
417            Self::warm_narinfo(&self.l1, hash, content).await;
418        }
419        Ok(())
420    }
421
422    /// Fan the narinfo removal out to **every** tier, then report whether every
423    /// tier actually removed it.
424    ///
425    /// # Why this is not best-effort, unlike the old `delete`
426    ///
427    /// Reads fall through, so a narinfo that ANY tier still holds is still
428    /// served. If this swallowed a per-tier failure and returned `Ok(())`, the
429    /// composed [`delete`](StorageBackend::delete) would go on to drop the edge
430    /// and remove the NAR — leaving a narinfo that is still served, advertising
431    /// a NAR that is gone. That is the strand, arrived at from the other side.
432    ///
433    /// Every tier is still *attempted* (one dead tier does not stop the others),
434    /// but a failure surfaces, so `delete` stops before touching the NAR. The
435    /// cost is that a GC pass against a degraded tier aborts instead of
436    /// half-completing — the right trade: a retained NAR is a leak, a stranded
437    /// narinfo is an outage.
438    async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError> {
439        let mut failed: Option<StoreError> = None;
440        for (name, tier) in self.tiers() {
441            if let Err(e) = tier.delete_narinfo_record(hash).await {
442                tracing::error!(
443                    hash = %hash, tier = name, error = %e,
444                    "tiered: narinfo delete FAILED on a tier — reads fall through, so this \
445                     narinfo is still servable; refusing to report the delete as complete",
446                );
447                failed = Some(e);
448            }
449        }
450        failed.map_or(Ok(()), Err)
451    }
452
453    /// Fan the NAR removal out to every tier and report any failure.
454    ///
455    /// A surviving copy on one tier is a leak, not an outage — but it is still
456    /// not a completed delete, and a caller that believes the bytes are gone
457    /// (a byte-accounting sweep) would drift.
458    async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError> {
459        let mut failed: Option<StoreError> = None;
460        for (name, tier) in self.tiers() {
461            if let Err(e) = tier.delete_nar_record(nar_path).await {
462                warn!(
463                    path = %nar_path, tier = name, error = %e,
464                    "tiered: NAR delete failed on a tier — a copy survives there",
465                );
466                failed = Some(e);
467            }
468        }
469        failed.map_or(Ok(()), Err)
470    }
471
472    fn nar_ref_index(&self) -> &dyn NarRefIndex {
473        self
474    }
475
476    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError> {
477        self.put_nar_stream(path, &nar_stream::BytesNarSource::from(data)).await
478    }
479
480    /// Fan a NAR out to the tiers **in the same order as before, from a
481    /// re-openable source**.
482    ///
483    /// Line for line the previous `put_nar`, with `data: &[u8]` replaced by
484    /// `src: &dyn NarSource` and each tier opening its own bounded stream. That
485    /// equivalence is the reason [`NarSource`] is re-openable rather than a
486    /// one-shot `Stream`: a one-shot stream can be consumed once, so it would
487    /// force either buffering the NAR to fan it out (the bug) or interleaving
488    /// chunks across tiers (a different order). The load-bearing properties are
489    /// unchanged:
490    ///
491    /// - L2 and L3 are **both attempted**, then gated by
492    ///   [`durable_write_outcome`](Self::durable_write_outcome);
493    /// - the L1 warm happens strictly **after** that gate under `WriteThrough`
494    ///   (strictly before it under `WriteBack`), and its result is discarded, so
495    ///   a refused L1 warm can never fail a build.
496    async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
497        if self.write_policy == WritePolicy::WriteBack {
498            Self::warm_nar_from(&self.l1, path, src).await;
499        }
500
501        let l2 = self.l2.put_nar_stream(path, src).await;
502        let l3 = self.l3.put_nar_stream(path, src).await;
503        Self::durable_write_outcome("nar", path, l2, l3)?;
504
505        if self.write_policy == WritePolicy::WriteThrough {
506            Self::warm_nar_from(&self.l1, path, src).await;
507        }
508        Ok(())
509    }
510
511    async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
512        // Authoritative tiers only (L2 ∪ L3); L1 is a partial hot subset. A
513        // durable-tier list failure surfaces (`?`).
514        let mut set = BTreeSet::new();
515        set.extend(self.l2.list_narinfos().await?);
516        set.extend(self.l3.list_narinfos().await?);
517        Ok(set.into_iter().collect())
518    }
519
520    /// Fan the wipe out to EVERY tier (L1 hot + L2/L3 durable), so a cache-wipe
521    /// clears all three at once. Best-effort per tier (mirrors `delete`): one
522    /// tier's failure is logged, never aborting the others — the whole point is
523    /// to return the store to cold. Reports the largest per-tier narinfo count
524    /// removed (the authoritative tiers' full set).
525    async fn wipe_all(&self) -> Result<usize, StoreError> {
526        let mut cleared = 0usize;
527        for (name, tier) in self.tiers() {
528            match tier.wipe_all().await {
529                Ok(n) => cleared = cleared.max(n),
530                Err(e) => warn!(tier = name, error = %e, "tiered: best-effort wipe failed"),
531            }
532        }
533        Ok(cleared)
534    }
535}
536
537/// The composite reverse index: each tier keeps its own edges, and the answer is
538/// their **union**.
539///
540/// # Why the union, and why it includes L1
541///
542/// [`list_narinfos`](StorageBackend::list_narinfos) reads only the authoritative
543/// tiers because a hot tier's partial view would *under*-report a listing. Here
544/// the asymmetry runs the other way: an extra referrer keeps a NAR that could
545/// have been reclaimed (a leak), a missing one deletes a NAR another narinfo
546/// still advertises (an outage). So every tier that answers is believed —
547/// including a stale L1 edge that outlived its narinfo.
548///
549/// A tier whose read *fails* is not silently treated as empty: the failure is
550/// logged and propagated, because "this tier is down" must never resolve to
551/// "nobody advertises this NAR" for something about to delete.
552#[async_trait]
553impl NarRefIndex for TieredBackend {
554    /// Record on both durable tiers (gated by
555    /// [`durable_write_outcome`](TieredBackend::durable_write_outcome)) and
556    /// best-effort on L1 — the same shape as a narinfo write, so an edge lands
557    /// wherever its narinfo does.
558    async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
559        let l2 = self.l2.nar_ref_index().record(nar_path, hash).await;
560        let l3 = self.l3.nar_ref_index().record(nar_path, hash).await;
561        Self::durable_write_outcome("nar-ref", nar_path, l2, l3)?;
562        if let Err(e) = self.l1.nar_ref_index().record(nar_path, hash).await {
563            warn!(path = %nar_path, error = %e, "tiered: best-effort nar-ref warm failed");
564        }
565        Ok(())
566    }
567
568    /// Forget on every tier, best-effort.
569    ///
570    /// A tier that keeps an edge it should have dropped over-reports, which
571    /// retains a NAR — the safe direction, and the reason this does not abort
572    /// the fan-out on the first failure.
573    async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
574        for (name, tier) in self.tiers() {
575            if let Err(e) = tier.nar_ref_index().forget(nar_path, hash).await {
576                warn!(
577                    path = %nar_path, tier = name, error = %e,
578                    "tiered: best-effort nar-ref forget failed — the edge survives, so the \
579                     NAR is retained rather than stranded",
580                );
581            }
582        }
583        Ok(())
584    }
585
586    async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError> {
587        let mut set = BTreeSet::new();
588        let mut broken: Option<StoreError> = None;
589        for (name, tier) in self.tiers() {
590            match tier.nar_ref_index().referrers(nar_path).await {
591                Ok(hashes) => set.extend(hashes),
592                Err(e) => {
593                    broken = Some(Self::note_tier_read_failure(name, nar_path, e));
594                }
595            }
596        }
597        match broken {
598            Some(e) => Err(e),
599            None => Ok(set.into_iter().collect()),
600        }
601    }
602}
603
604// ---------------------------------------------------------------------------
605// Unit tests — the resolver semantics (fallthrough, promotion, write policies,
606// delete fan-out, list dedup, durability) proven against in-memory mock tiers
607// plus a real-shape on-disk L3. No live Redis/PG/S3.
608// ---------------------------------------------------------------------------
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::storage::nar_refs::MemNarRefIndex;
614    use crate::storage::LocalStorage;
615    use std::collections::HashMap;
616    use std::sync::Mutex;
617
618    /// In-memory [`StorageBackend`] mock. Per-instance maps so a test can assert
619    /// *which tier* holds a key (proving promotion), `clear` a tier (simulate a
620    /// pod roll), and optionally fail all writes (prove best-effort warm).
621    #[derive(Default)]
622    struct MemBackend {
623        narinfo: Mutex<HashMap<String, String>>,
624        nar: Mutex<HashMap<String, Vec<u8>>>,
625        /// The tier's own reverse index. Shared semantics with production via
626        /// [`MemNarRefIndex`] rather than a hand-rolled map, so a double whose
627        /// index disagreed with a real backend's cannot exist.
628        refs: MemNarRefIndex,
629        writes_fail: Mutex<bool>,
630        reads_fail: Mutex<bool>,
631        /// A tier that accepts reads and writes but cannot remove — a read-only
632        /// object store, a revoked delete permission, a full transaction log.
633        deletes_fail: Mutex<bool>,
634    }
635
636    impl MemBackend {
637        fn has_narinfo(&self, hash: &str) -> bool {
638            self.narinfo.lock().unwrap().contains_key(hash)
639        }
640        fn has_nar(&self, path: &str) -> bool {
641            self.nar.lock().unwrap().contains_key(path)
642        }
643        fn clear(&self) {
644            self.narinfo.lock().unwrap().clear();
645            self.nar.lock().unwrap().clear();
646        }
647        fn set_writes_fail(&self, v: bool) {
648            *self.writes_fail.lock().unwrap() = v;
649        }
650        /// Simulate a tier that is up but broken — the shape of a Postgres whose
651        /// tables vanished, which errors on every query rather than returning
652        /// "not found".
653        fn set_reads_fail(&self, v: bool) {
654            *self.reads_fail.lock().unwrap() = v;
655        }
656        fn fail_if_configured(&self) -> Result<(), StoreError> {
657            if *self.writes_fail.lock().unwrap() {
658                Err(StoreError::NotImplemented("mock writes disabled"))
659            } else {
660                Ok(())
661            }
662        }
663        fn fail_reads_if_configured(&self) -> Result<(), StoreError> {
664            if *self.reads_fail.lock().unwrap() {
665                Err(StoreError::SchemaMissing("mock: relation does not exist".to_string()))
666            } else {
667                Ok(())
668            }
669        }
670        fn set_deletes_fail(&self, v: bool) {
671            *self.deletes_fail.lock().unwrap() = v;
672        }
673        fn fail_deletes_if_configured(&self) -> Result<(), StoreError> {
674            if *self.deletes_fail.lock().unwrap() {
675                Err(StoreError::NotImplemented("mock deletes disabled"))
676            } else {
677                Ok(())
678            }
679        }
680    }
681
682    #[async_trait]
683    impl StorageBackend for MemBackend {
684        async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError> {
685            self.fail_reads_if_configured()?;
686            Ok(self.narinfo.lock().unwrap().get(hash).cloned())
687        }
688        async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError> {
689            self.fail_if_configured()?;
690            self.narinfo.lock().unwrap().insert(hash.to_string(), content.to_string());
691            Ok(())
692        }
693        async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError> {
694            self.fail_deletes_if_configured()?;
695            self.narinfo.lock().unwrap().remove(hash);
696            Ok(())
697        }
698        async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError> {
699            self.fail_deletes_if_configured()?;
700            self.nar.lock().unwrap().remove(nar_path);
701            Ok(())
702        }
703        fn nar_ref_index(&self) -> &dyn NarRefIndex {
704            &self.refs
705        }
706        async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError> {
707            self.fail_reads_if_configured()?;
708            Ok(self.nar.lock().unwrap().get(path).cloned())
709        }
710        async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError> {
711            self.fail_if_configured()?;
712            self.nar.lock().unwrap().insert(path.to_string(), data.to_vec());
713            Ok(())
714        }
715        /// An in-memory double holds whole values by construction; declaring it
716        /// is what keeps a *production* backend from inheriting this path.
717        fn nar_residency(&self) -> NarResidency {
718            NarResidency::WholeValue
719        }
720        async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
721            Ok(self.narinfo.lock().unwrap().keys().cloned().collect())
722        }
723    }
724
725    const NARINFO: &str = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
726
727    /// The NAR key [`NARINFO`] actually advertises. Deliberately unrelated to
728    /// the store hashes the tests use, because that is the real relationship: a
729    /// NAR is keyed by *narhash*.
730    const ADVERTISED_NAR: &str = "nar/abc.nar.xz";
731
732    /// Build a tiered backend over three fresh mocks, returning the concrete
733    /// handles for inspection alongside the composed resolver.
734    fn mocks() -> (Arc<MemBackend>, Arc<MemBackend>, Arc<MemBackend>, TieredBackend) {
735        let l1 = Arc::new(MemBackend::default());
736        let l2 = Arc::new(MemBackend::default());
737        let l3 = Arc::new(MemBackend::default());
738        let tiered = TieredBackend::new(l1.clone(), l2.clone(), l3.clone());
739        (l1, l2, l3, tiered)
740    }
741
742    // ── read fallthrough + promotion ───────────────────────────────────────
743
744    #[tokio::test]
745    async fn l1_hit_returns_without_touching_lower_tiers() {
746        let (l1, l2, l3, tiered) = mocks();
747        l1.put_narinfo("h", "hot").await.unwrap();
748        assert_eq!(tiered.get_narinfo("h").await.unwrap().unwrap(), "hot");
749        // Lower tiers never populated.
750        assert!(!l2.has_narinfo("h"));
751        assert!(!l3.has_narinfo("h"));
752    }
753
754    #[tokio::test]
755    async fn l2_hit_promotes_into_l1() {
756        let (l1, l2, _l3, tiered) = mocks();
757        l2.put_narinfo("h", NARINFO).await.unwrap();
758        assert!(!l1.has_narinfo("h"));
759        let got = tiered.get_narinfo("h").await.unwrap().unwrap();
760        assert_eq!(got, NARINFO);
761        // Promotion warmed L1.
762        assert!(l1.has_narinfo("h"), "L2 hit must promote into L1");
763    }
764
765    #[tokio::test]
766    async fn l3_hit_promotes_into_l2_and_l1() {
767        let (l1, l2, l3, tiered) = mocks();
768        l3.put_narinfo("h", NARINFO).await.unwrap();
769        let got = tiered.get_narinfo("h").await.unwrap().unwrap();
770        assert_eq!(got, NARINFO);
771        assert!(l2.has_narinfo("h"), "L3 hit must promote into L2");
772        assert!(l1.has_narinfo("h"), "L3 hit must promote into L1");
773    }
774
775    #[tokio::test]
776    async fn nar_l3_hit_promotes_into_l2_and_l1() {
777        let (l1, l2, l3, tiered) = mocks();
778        l3.put_nar("nar/x.nar.xz", b"blob").await.unwrap();
779        let got = tiered.get_nar("nar/x.nar.xz").await.unwrap().unwrap();
780        assert_eq!(got, b"blob");
781        assert!(l2.has_nar("nar/x.nar.xz"));
782        assert!(l1.has_nar("nar/x.nar.xz"));
783    }
784
785    #[tokio::test]
786    async fn miss_at_all_tiers_is_none() {
787        let (_l1, _l2, _l3, tiered) = mocks();
788        assert!(tiered.get_narinfo("ghost").await.unwrap().is_none());
789        assert!(tiered.get_nar("nar/ghost.nar.xz").await.unwrap().is_none());
790    }
791
792    // ── a BROKEN tier is stepped over, never fatal (the incident) ──────────
793
794    #[tokio::test]
795    async fn l2_read_failure_falls_through_to_l3() {
796        // THE regression under test. Postgres (L2) came back on a wiped
797        // emptyDir, so every L2 query errored with `relation … does not exist`.
798        // The old code `?`d that error straight out, so L3 — which was healthy
799        // and held the content — was never even consulted.
800        let (l1, l2, l3, tiered) = mocks();
801        l3.put_narinfo("h", NARINFO).await.unwrap();
802        l3.put_nar("nar/h.nar.xz", b"blob").await.unwrap();
803        l2.set_reads_fail(true);
804
805        assert_eq!(
806            tiered.get_narinfo("h").await.unwrap().unwrap(),
807            NARINFO,
808            "a broken L2 must not hide a healthy L3",
809        );
810        assert_eq!(tiered.get_nar("nar/h.nar.xz").await.unwrap().unwrap(), b"blob");
811        // The hit still promotes into the tiers that can take it.
812        assert!(l1.has_narinfo("h"), "the L3 hit still warms the working hot tier");
813    }
814
815    #[tokio::test]
816    async fn broken_l1_and_l2_still_serve_from_l3() {
817        let (l1, l2, l3, tiered) = mocks();
818        l3.put_narinfo("h", NARINFO).await.unwrap();
819        // Both upper tiers up-but-broken.
820        l1.set_reads_fail(true);
821        l2.set_reads_fail(true);
822        assert_eq!(tiered.get_narinfo("h").await.unwrap().unwrap(), NARINFO);
823    }
824
825    #[tokio::test]
826    async fn every_tier_broken_and_no_hit_surfaces_an_error_not_a_false_absence() {
827        // Conservative + honest: when a tier that might have held the key is
828        // broken and nothing was found, the resolver must NOT claim the key is
829        // absent. It says "I could not answer"; the HTTP layer is what decides
830        // to serve that as a miss.
831        let (l1, l2, l3, tiered) = mocks();
832        for t in [&l1, &l2, &l3] {
833            t.set_reads_fail(true);
834        }
835        assert!(matches!(
836            tiered.get_narinfo("h").await.unwrap_err(),
837            StoreError::SchemaMissing(_),
838        ));
839        assert!(matches!(
840            tiered.get_nar("nar/h.nar.xz").await.unwrap_err(),
841            StoreError::SchemaMissing(_),
842        ));
843    }
844
845    #[tokio::test]
846    async fn all_tiers_healthy_and_empty_is_a_clean_miss_not_an_error() {
847        // The other side of the same coin: no tier failed, so `Ok(None)` is the
848        // truthful answer and must not be polluted into an error.
849        let (_l1, _l2, _l3, tiered) = mocks();
850        assert!(tiered.get_narinfo("ghost").await.unwrap().is_none());
851    }
852
853    #[tokio::test]
854    async fn promotion_failure_does_not_break_a_read() {
855        // A best-effort warm that fails must NOT turn a successful read into an
856        // error — the bytes were found.
857        let (l1, l2, _l3, tiered) = mocks();
858        l2.put_narinfo("h", NARINFO).await.unwrap();
859        l1.set_writes_fail(true); // L1 warm will error
860        let got = tiered.get_narinfo("h").await.unwrap();
861        assert_eq!(got.unwrap(), NARINFO);
862        assert!(!l1.has_narinfo("h"), "warm failed, so L1 stays empty — but the read still succeeded");
863    }
864
865    // ── write policies ─────────────────────────────────────────────────────
866
867    #[tokio::test]
868    async fn write_through_populates_all_tiers() {
869        let (l1, l2, l3, tiered) = mocks();
870        tiered.put_narinfo("h", NARINFO).await.unwrap();
871        assert!(l1.has_narinfo("h"), "write-through warms L1");
872        assert!(l2.has_narinfo("h"), "write-through persists L2");
873        assert!(l3.has_narinfo("h"), "write-through persists L3");
874    }
875
876    #[tokio::test]
877    async fn write_around_skips_l1_but_persists_durable() {
878        let l1 = Arc::new(MemBackend::default());
879        let l2 = Arc::new(MemBackend::default());
880        let l3 = Arc::new(MemBackend::default());
881        let tiered = TieredBackend::with_write_policy(
882            l1.clone(), l2.clone(), l3.clone(), WritePolicy::WriteAround,
883        );
884        tiered.put_narinfo("h", NARINFO).await.unwrap();
885        assert!(!l1.has_narinfo("h"), "write-around must NOT touch L1");
886        assert!(l2.has_narinfo("h"));
887        assert!(l3.has_narinfo("h"));
888        // …and a subsequent read lazily fills L1 (read-through).
889        let _ = tiered.get_narinfo("h").await.unwrap();
890        assert!(l1.has_narinfo("h"), "read-through fills L1 after a write-around");
891    }
892
893    #[tokio::test]
894    async fn write_back_populates_all_tiers_and_is_durable() {
895        let l1 = Arc::new(MemBackend::default());
896        let l2 = Arc::new(MemBackend::default());
897        let l3 = Arc::new(MemBackend::default());
898        let tiered = TieredBackend::with_write_policy(
899            l1.clone(), l2.clone(), l3.clone(), WritePolicy::WriteBack,
900        );
901        assert_eq!(tiered.write_policy(), WritePolicy::WriteBack);
902        tiered.put_nar("nar/x.nar.xz", b"blob").await.unwrap();
903        // Even write-back persists durable tiers before returning.
904        assert!(l1.has_nar("nar/x.nar.xz"));
905        assert!(l2.has_nar("nar/x.nar.xz"));
906        assert!(l3.has_nar("nar/x.nar.xz"));
907    }
908
909    #[tokio::test]
910    async fn one_broken_durable_tier_still_lands_the_write_on_the_other() {
911        // Regression: the old code `?`d on the FIRST durable tier, so an L2
912        // failure meant L3 was never even attempted and the push landed
913        // NOWHERE. With Postgres (L2) OOM-killed and a healthy L3, every push
914        // failed and the cache stopped filling entirely.
915        let l1 = Arc::new(MemBackend::default());
916        let l2 = Arc::new(MemBackend::default());
917        let l3 = Arc::new(MemBackend::default());
918        l2.set_writes_fail(true);
919        let tiered = TieredBackend::new(l1.clone(), l2.clone(), l3.clone());
920
921        tiered.put_narinfo("h", NARINFO).await.expect("one healthy durable tier must accept");
922        tiered.put_nar("nar/h.nar.xz", b"blob").await.expect("one healthy durable tier must accept");
923
924        assert!(!l2.has_narinfo("h"), "the broken tier holds nothing");
925        assert!(l3.has_narinfo("h"), "the healthy durable tier MUST have taken the write");
926        assert!(l3.has_nar("nar/h.nar.xz"));
927        // …and the content is fully serveable through the resolver.
928        assert_eq!(tiered.get_narinfo("h").await.unwrap().unwrap(), NARINFO);
929    }
930
931    #[tokio::test]
932    async fn write_fails_only_when_every_durable_tier_rejects() {
933        // The honest floor: nothing was stored anywhere, so the caller must be
934        // told. A 200 here would falsely acknowledge an upload that never landed.
935        let l1 = Arc::new(MemBackend::default());
936        let l2 = Arc::new(MemBackend::default());
937        let l3 = Arc::new(MemBackend::default());
938        l2.set_writes_fail(true);
939        l3.set_writes_fail(true);
940        let tiered = TieredBackend::new(l1, l2, l3);
941        let err = tiered.put_narinfo("h", NARINFO).await.unwrap_err();
942        assert!(matches!(err, StoreError::NotImplemented(_)));
943    }
944
945    // ── durability (the never-touch-disk claim's real proof) ───────────────
946
947    #[tokio::test]
948    async fn pod_roll_losing_l1_loses_nothing() {
949        let (l1, _l2, _l3, tiered) = mocks();
950        tiered.put_narinfo("h", NARINFO).await.unwrap();
951        tiered.put_nar("nar/h.nar.xz", b"blob").await.unwrap();
952        // Simulate a pod roll wiping the entire hot tier.
953        l1.clear();
954        // A read still returns byte-identical content, satisfied by a durable tier.
955        assert_eq!(tiered.get_narinfo("h").await.unwrap().unwrap(), NARINFO);
956        assert_eq!(tiered.get_nar("nar/h.nar.xz").await.unwrap().unwrap(), b"blob");
957    }
958
959    // ── delete + list ──────────────────────────────────────────────────────
960
961    #[tokio::test]
962    async fn delete_fans_out_to_all_tiers() {
963        let (l1, l2, l3, tiered) = mocks();
964        tiered.put_narinfo("h", NARINFO).await.unwrap();
965        tiered.put_nar(ADVERTISED_NAR, b"blob").await.unwrap();
966        tiered.delete("h").await.unwrap();
967        for t in [&l1, &l2, &l3] {
968            assert!(!t.has_narinfo("h"));
969            assert!(!t.has_nar(ADVERTISED_NAR));
970        }
971    }
972
973    /// The composite resolves the NAR from the narinfo, on every tier — so a
974    /// key merely *shaped* like the old store-hash guess survives.
975    #[tokio::test]
976    async fn delete_resolves_across_tiers_instead_of_guessing() {
977        let (l1, l2, l3, tiered) = mocks();
978        tiered.put_narinfo("h", NARINFO).await.unwrap();
979        tiered.put_nar(ADVERTISED_NAR, b"blob").await.unwrap();
980        tiered.put_nar("nar/h.nar.zst", b"someone else's nar").await.unwrap();
981
982        tiered.delete("h").await.unwrap();
983
984        for t in [&l1, &l2, &l3] {
985            assert!(!t.has_nar(ADVERTISED_NAR), "the advertised NAR must go");
986        }
987        assert_eq!(
988            tiered.get_nar("nar/h.nar.zst").await.unwrap().unwrap(),
989            b"someone else's nar",
990        );
991    }
992
993    /// Two store paths sharing one narhash: the first delete must leave the NAR
994    /// the second still advertises, on every tier.
995    #[tokio::test]
996    async fn a_co_referenced_nar_survives_the_first_delete_on_every_tier() {
997        let (l1, l2, l3, tiered) = mocks();
998        tiered.put_narinfo("pathA", NARINFO).await.unwrap();
999        tiered.put_narinfo("pathB", NARINFO).await.unwrap();
1000        tiered.put_nar(ADVERTISED_NAR, b"shared").await.unwrap();
1001
1002        tiered.delete("pathA").await.unwrap();
1003        for t in [&l1, &l2, &l3] {
1004            assert!(t.has_nar(ADVERTISED_NAR), "pathB still advertises it");
1005        }
1006
1007        tiered.delete("pathB").await.unwrap();
1008        for t in [&l1, &l2, &l3] {
1009            assert!(!t.has_nar(ADVERTISED_NAR), "the last referrer is gone");
1010        }
1011    }
1012
1013    /// The strand approached from the *other* side: the narinfo removal fails
1014    /// on one tier, so the narinfo is still served (reads fall through) — and
1015    /// the NAR must therefore NOT be removed.
1016    ///
1017    /// This is why the tiered record-deletes report failure instead of being
1018    /// best-effort. A `delete` that swallowed the per-tier failure would carry
1019    /// on, drop the edge, and take the NAR out from under a narinfo that is
1020    /// still being served.
1021    #[tokio::test]
1022    async fn a_failed_narinfo_delete_must_not_take_the_nar_with_it() {
1023        let (l1, l2, l3, tiered) = mocks();
1024        tiered.put_narinfo("h", NARINFO).await.unwrap();
1025        tiered.put_nar(ADVERTISED_NAR, b"blob").await.unwrap();
1026
1027        // L2 goes read-only-ish: its narinfo record delete will fail. Its copy
1028        // of the narinfo therefore survives, and a read still finds it.
1029        l2.set_deletes_fail(true);
1030
1031        let err = tiered.delete("h").await.expect_err("a partial delete must surface");
1032        assert!(
1033            matches!(err, StoreError::NotImplemented(_)),
1034            "expected the tier's own error, got {err:?}",
1035        );
1036
1037        assert!(l2.has_narinfo("h"), "L2 kept the narinfo — that is the premise");
1038        assert_eq!(
1039            tiered.get_narinfo("h").await.unwrap().unwrap(),
1040            NARINFO,
1041            "and a read still serves it, because reads fall through",
1042        );
1043        for t in [&l1, &l2, &l3] {
1044            assert!(
1045                t.has_nar(ADVERTISED_NAR),
1046                "the NAR must be untouched: its narinfo is still servable",
1047            );
1048        }
1049    }
1050
1051    #[tokio::test]
1052    async fn wipe_all_clears_every_tier() {
1053        let (l1, l2, l3, tiered) = mocks();
1054        // write-through seeds all three tiers.
1055        tiered.put_narinfo("h", NARINFO).await.unwrap();
1056        tiered.put_nar(ADVERTISED_NAR, b"blob").await.unwrap();
1057        // a durable-only key proves the list-driven clear, not just the hot one.
1058        l2.put_narinfo("only2", "x").await.unwrap();
1059
1060        let removed = tiered.wipe_all().await.unwrap();
1061        assert!(removed >= 1, "wipe reported nothing cleared");
1062
1063        for t in [&l1, &l2, &l3] {
1064            assert!(t.list_narinfos().await.unwrap().is_empty(), "a tier survived the wipe");
1065            assert!(!t.has_narinfo("h"));
1066            assert!(!t.has_nar(ADVERTISED_NAR));
1067        }
1068        assert!(tiered.list_narinfos().await.unwrap().is_empty(), "cache not cold after wipe");
1069        // and the cache is genuinely cold — a fresh get misses.
1070        assert!(tiered.get_narinfo("h").await.unwrap().is_none());
1071    }
1072
1073    #[tokio::test]
1074    async fn list_narinfos_unions_durable_tiers_deduped() {
1075        let (l1, l2, l3, tiered) = mocks();
1076        // A key present in BOTH durable tiers must appear once.
1077        l2.put_narinfo("shared", "x").await.unwrap();
1078        l3.put_narinfo("shared", "x").await.unwrap();
1079        l2.put_narinfo("only2", "y").await.unwrap();
1080        l3.put_narinfo("only3", "z").await.unwrap();
1081        // An L1-only key must NOT appear (L1 is a partial hot subset).
1082        l1.put_narinfo("hot-only", "w").await.unwrap();
1083        let listed = tiered.list_narinfos().await.unwrap();
1084        assert_eq!(listed, vec!["only2".to_string(), "only3".to_string(), "shared".to_string()]);
1085    }
1086
1087    // ── real-shape L3 (LocalStorage on disk), not a mock ───────────────────
1088
1089    #[tokio::test]
1090    async fn read_through_from_a_real_local_storage_l3() {
1091        let dir = tempfile::tempdir().unwrap();
1092        let l1 = Arc::new(MemBackend::default());
1093        let l2 = Arc::new(MemBackend::default());
1094        let l3_disk = Arc::new(LocalStorage::new(dir.path()));
1095        // Seed the real on-disk L3 directly.
1096        l3_disk.put_narinfo("h", NARINFO).await.unwrap();
1097        l3_disk.put_nar("nar/h.nar.xz", b"disk-blob").await.unwrap();
1098
1099        let tiered = TieredBackend::new(l1.clone(), l2.clone(), l3_disk);
1100        // Cold L1/L2 → served from the real disk L3, and promoted up.
1101        assert_eq!(tiered.get_narinfo("h").await.unwrap().unwrap(), NARINFO);
1102        assert_eq!(tiered.get_nar("nar/h.nar.xz").await.unwrap().unwrap(), b"disk-blob");
1103        assert!(l1.has_narinfo("h"));
1104        assert!(l2.has_narinfo("h"));
1105    }
1106
1107    // ── streamed NAR fan-out: the ordering is the contract ─────────────────
1108
1109    /// A tier that records the order in which it was written, into a log shared
1110    /// with its siblings. This is what turns "L2, then L3, then L1" from a
1111    /// comment into an assertion.
1112    struct RecordingTier {
1113        name: &'static str,
1114        log: Arc<Mutex<Vec<&'static str>>>,
1115        /// When set, every NAR write is refused — the capped-hot-tier shape.
1116        refuse: bool,
1117        refs: MemNarRefIndex,
1118    }
1119
1120    #[async_trait]
1121    impl StorageBackend for RecordingTier {
1122        async fn get_narinfo(&self, _h: &str) -> Result<Option<String>, StoreError> {
1123            Ok(None)
1124        }
1125        async fn put_narinfo_record(&self, _h: &str, _c: &str) -> Result<(), StoreError> {
1126            Ok(())
1127        }
1128        async fn delete_narinfo_record(&self, _h: &str) -> Result<(), StoreError> {
1129            Ok(())
1130        }
1131        async fn delete_nar_record(&self, _p: &str) -> Result<(), StoreError> {
1132            Ok(())
1133        }
1134        fn nar_ref_index(&self) -> &dyn NarRefIndex {
1135            &self.refs
1136        }
1137        async fn get_nar(&self, _p: &str) -> Result<Option<Vec<u8>>, StoreError> {
1138            Ok(None)
1139        }
1140        async fn put_nar(&self, _p: &str, _d: &[u8]) -> Result<(), StoreError> {
1141            self.log.lock().unwrap().push(self.name);
1142            if self.refuse {
1143                return Err(StoreError::TooLarge { limit: 1, at_least: 2 });
1144            }
1145            Ok(())
1146        }
1147        fn nar_residency(&self) -> NarResidency {
1148            NarResidency::WholeValue
1149        }
1150        async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
1151            Ok(vec![])
1152        }
1153    }
1154
1155    fn recording_tiers(
1156        refuse_l1: bool,
1157    ) -> (Arc<Mutex<Vec<&'static str>>>, TieredBackend) {
1158        let log = Arc::new(Mutex::new(Vec::new()));
1159        let mk = |name, refuse| {
1160            Arc::new(RecordingTier {
1161                name,
1162                log: Arc::clone(&log),
1163                refuse,
1164                refs: MemNarRefIndex::new(),
1165            }) as Arc<dyn StorageBackend>
1166        };
1167        let tiered = TieredBackend::new(mk("l1", refuse_l1), mk("l2", false), mk("l3", false));
1168        (log, tiered)
1169    }
1170
1171    #[tokio::test]
1172    async fn streamed_put_writes_l2_then_l3_then_warms_l1() {
1173        // The ordering the operator called load-bearing, asserted rather than
1174        // described. Moving to a streamed fan-out was only safe because a
1175        // `NarSource` re-opens: a one-shot stream would have forced chunk
1176        // interleaving and this order would have become l1/l2/l3 all at once.
1177        let (log, tiered) = recording_tiers(false);
1178        tiered.put_nar("nar/x.nar.xz", b"blob").await.unwrap();
1179        assert_eq!(*log.lock().unwrap(), vec!["l2", "l3", "l1"]);
1180    }
1181
1182    #[tokio::test]
1183    async fn a_refused_l1_warm_never_fails_the_write() {
1184        // The capped hot tier returns TooLarge for an oversized NAR. That is a
1185        // bound working as designed, and it must be swallowed: L1 is
1186        // best-effort, and a failed warm that 500s the push would fail a build
1187        // over a cache optimisation.
1188        let (log, tiered) = recording_tiers(true);
1189        tiered
1190            .put_nar("nar/x.nar.xz", b"blob")
1191            .await
1192            .expect("a refused L1 warm must not fail the write");
1193        assert_eq!(*log.lock().unwrap(), vec!["l2", "l3", "l1"], "L1 is still attempted, last");
1194    }
1195
1196    #[tokio::test]
1197    async fn write_back_warms_l1_before_the_durable_gate() {
1198        let log = Arc::new(Mutex::new(Vec::new()));
1199        let mk = |name| {
1200            Arc::new(RecordingTier {
1201                name,
1202                log: Arc::clone(&log),
1203                refuse: false,
1204                refs: MemNarRefIndex::new(),
1205            }) as Arc<dyn StorageBackend>
1206        };
1207        let tiered = TieredBackend::with_write_policy(
1208            mk("l1"), mk("l2"), mk("l3"), WritePolicy::WriteBack,
1209        );
1210        tiered.put_nar("nar/x.nar.xz", b"blob").await.unwrap();
1211        assert_eq!(*log.lock().unwrap(), vec!["l1", "l2", "l3"]);
1212    }
1213
1214    #[tokio::test]
1215    async fn a_multi_chunk_nar_round_trips_through_a_real_disk_tier() {
1216        // Over a chunk boundary, on real files, through the resolver — the
1217        // shape a NAR big enough to matter actually takes.
1218        let dir = tempfile::tempdir().unwrap();
1219        let l1 = Arc::new(MemBackend::default());
1220        let l2: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path().join("l2")));
1221        let l3: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path().join("l3")));
1222        let tiered = TieredBackend::new(l1.clone(), l2, l3);
1223
1224        let nar: Vec<u8> = (0..crate::storage::NAR_CHUNK_BYTES + 777).map(|i| (i % 251) as u8).collect();
1225        tiered.put_nar("nar/big.nar.xz", &nar).await.unwrap();
1226        assert_eq!(tiered.get_nar("nar/big.nar.xz").await.unwrap().unwrap(), nar);
1227    }
1228
1229    #[tokio::test]
1230    async fn residency_reports_the_weakest_tier_not_the_resolver() {
1231        // Two real streaming tiers behind one in-memory double: the composite
1232        // is NOT streaming, and saying otherwise would be exactly the round-up
1233        // NarResidency exists to prevent.
1234        let dir = tempfile::tempdir().unwrap();
1235        let disk1: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path().join("a")));
1236        let disk2: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path().join("b")));
1237        let disk3: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path().join("c")));
1238        let all_disk = TieredBackend::new(disk1.clone(), disk2.clone(), disk3);
1239        assert_eq!(all_disk.nar_residency(), NarResidency::Streaming);
1240
1241        let with_double =
1242            TieredBackend::new(Arc::new(MemBackend::default()), disk1, disk2);
1243        assert_eq!(with_double.nar_residency(), NarResidency::WholeValue);
1244    }
1245
1246    // ── the honest gate ────────────────────────────────────────────────────
1247
1248    #[test]
1249    fn honest_gate_tier_is_mock_parity_proven_not_live_cluster() {
1250        // The shipped tier is MockParityProven (in-mem tiers + real-shape disk
1251        // L3). Bumping TIERED_BACKEND_TIER to LiveClusterProven without an actual
1252        // live Redis/PG/S3 integration test fails HERE — the claim is not rounded
1253        // up.
1254        assert_eq!(TIERED_BACKEND_TIER, TieredTier::MockParityProven);
1255    }
1256}