Skip to main content

sui_castore/storage/
pg.rs

1//! Postgres-backed **L2 durable cache tier** `StorageBackend`.
2//!
3//! This is the shared, durable middle tier of the tiered super-cache resolver
4//! (`Redis L1 → Postgres L2 → object L3`). Where [`RedisBackend`](super::RedisBackend)
5//! is an ephemeral hot cache whose keys may vanish under `maxmemory` LRU, the
6//! Postgres tier is **authoritative**: a narinfo/NAR written here survives a pod
7//! roll, and [`PgStorageBackend::list_narinfos`] returns the *full* set of keys,
8//! not a hot subset.
9//!
10//! # Two Postgres axes, one crate, do not confuse them
11//!
12//! There are two Postgres-backed content-addressed surfaces in the sui workspace,
13//! on **different traits**:
14//!
15//! - **This** `PgStorageBackend` — a [`StorageBackend`] (the binary-**cache** blob
16//!   axis: narinfo strings keyed by store-path hash, NAR blobs keyed by relative
17//!   URL). It is the L2 tier of [`TieredBackend`](super::TieredBackend).
18//! - [`sui_store::PgStore`] — a `sui_store::Store` (the durable **nix-store** axis:
19//!   `StorePath → PathInfo` + NAR data, content-addressed by `GraphHash`). It is
20//!   the sibling that the on-disk graph store migrates onto.
21//!
22//! Both are Postgres, both content-addressed, **different traits, different key
23//! shapes**. This module is the *cache* one.
24//!
25//! # The connection seam (Environment / testability contract)
26//!
27//! [`PgStorageBackend`] is generic over [`PgCacheConn`] — the minimal typed
28//! row-verb surface it needs (`select` / `upsert` / `delete` / `keys` over a typed
29//! [`PgTable`]). Unit tests inject an in-memory mock; production injects
30//! [`SqlxPgCacheConn`] (a real `sqlx` Postgres pool, behind the `postgres`
31//! feature). The full L2 mapping — the two-table split, the content-addressed
32//! keying, typed UTF-8 handling, the `delete` NAR-pattern fan-out — is proven
33//! against the mock with **no live Postgres required**.
34
35use async_trait::async_trait;
36use bytes::Bytes;
37use futures::stream::{self, StreamExt};
38
39use super::nar_refs::{referrer_of, NarRefIndex, NarRefKey, NarRefScan};
40use super::nar_stream::{self, NarSource, NarStream, NAR_CHUNK_BYTES};
41use super::{NarResidency, StorageBackend};
42use crate::StoreError;
43
44/// Sequence number of the **completeness marker** row of a chunked NAR.
45///
46/// Real chunks are `0..n`. The marker is written **last** and read **first**,
47/// and its value is the chunk count as 8 little-endian bytes.
48///
49/// This is what makes a streamed Postgres write safe. A whole-value `INSERT` was
50/// atomic for free; N chunk inserts are not, so a process killed halfway (which
51/// is *precisely* the failure being engineered against — the pod was OOMKilled
52/// six times in a day) would leave chunks `0..k` readable as a complete NAR.
53/// Serving a truncated NAR is silent corruption, strictly worse than the OOM.
54/// With the marker, a partial write has no marker, so it reads as a clean
55/// **miss** and the client rebuilds. Bad state made unreachable by ordering
56/// rather than by a runtime check.
57const CHUNK_MARKER_SEQ: i32 = -1;
58
59/// Encode a chunk count into the marker row's value.
60fn encode_marker(chunks: u64) -> [u8; 8] {
61    chunks.to_le_bytes()
62}
63
64/// Decode a marker row's value, rejecting anything malformed.
65///
66/// A marker that is not exactly 8 bytes is a corrupt row, not a short NAR: it
67/// must surface rather than be coerced into a plausible chunk count.
68fn decode_marker(raw: &[u8]) -> Result<u64, StoreError> {
69    <[u8; 8]>::try_from(raw)
70        .map(u64::from_le_bytes)
71        .map_err(|_| StoreError::NarInfo(format!("corrupt NAR chunk marker: {} bytes", raw.len())))
72}
73
74/// The three logical tables the cache tier keeps: narinfo text, NAR blobs, and
75/// the reverse edges between them.
76///
77/// A typed discriminant (never a stringly-typed table name at a call site) so the
78/// SQL for each table is chosen by an exhaustive `match` — a new table is a
79/// non-exhaustive-match compile error, and a typo'd table name cannot exist.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum PgTable {
82    /// narinfo metadata, keyed by the 32-char store-path hash.
83    Narinfo,
84    /// compressed NAR blobs, keyed by relative URL path (`nar/<hash>.nar.xz`).
85    Nar,
86    /// Reverse index edges, keyed by [`NarRefKey`] — one zero-value row per
87    /// `(NAR path, store hash)` pair, so recording an edge is a blind upsert of
88    /// a key that names its own content and two concurrent pushes cannot lose
89    /// each other's edge.
90    NarRef,
91}
92
93impl PgTable {
94    /// The physical table name (for diagnostics / the real adapter's DDL).
95    #[must_use]
96    pub const fn table_name(self) -> &'static str {
97        match self {
98            PgTable::Narinfo => "sui_cache_narinfo",
99            PgTable::Nar => "sui_cache_nar",
100            PgTable::NarRef => "sui_cache_nar_ref",
101        }
102    }
103}
104
105/// The minimal typed Postgres row-verb surface [`PgStorageBackend`] depends on.
106///
107/// This is the injectable **Environment seam**: a real implementation
108/// ([`SqlxPgCacheConn`], `postgres` feature) talks to a live Postgres pool; tests
109/// substitute an in-memory mock. Keeping the surface this small means the whole L2
110/// mapping is proven against a mock, and the only unmocked code is the thin
111/// SQL translation.
112#[async_trait]
113pub trait PgCacheConn: Send + Sync {
114    /// `SELECT value FROM <table> WHERE key = $1` — raw bytes, or `Ok(None)` on a
115    /// missing row.
116    async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, StoreError>;
117
118    /// Upsert (`INSERT … ON CONFLICT (key) DO UPDATE`) — idempotent by key; a
119    /// re-`put` of a content-addressed key overwrites with identical bytes.
120    async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), StoreError>;
121
122    /// `DELETE FROM <table> WHERE key = $1` — idempotent; deleting an absent key
123    /// is `Ok(())`.
124    async fn delete(&self, table: PgTable, key: &str) -> Result<(), StoreError>;
125
126    /// `SELECT key FROM <table>` — the **authoritative** full key set (this is a
127    /// durable tier, not a partial hot cache).
128    async fn keys(&self, table: PgTable) -> Result<Vec<String>, StoreError>;
129
130    /// `SELECT key FROM <table> WHERE starts_with(key, $1)` — the key set under
131    /// one prefix.
132    ///
133    /// REQUIRED, not defaulted, and not "fetch [`keys`](Self::keys) and filter
134    /// in Rust": the reverse-index lookup runs once per delete, and filtering
135    /// client-side would make it O(every edge in the cache) per call while the
136    /// primary-key btree can answer it as a range scan.
137    async fn keys_with_prefix(
138        &self,
139        table: PgTable,
140        prefix: &str,
141    ) -> Result<Vec<String>, StoreError>;
142
143    /// `DELETE FROM <table>` — clear the whole table, returning the row count
144    /// removed. The typed whole-store wipe primitive (the inverse of a warm
145    /// push); reaches NAR rows a per-key `delete` cannot.
146    async fn clear(&self, table: PgTable) -> Result<u64, StoreError>;
147
148    // ── chunked NAR verbs ──────────────────────────────────────────────────
149    //
150    // A NAR is stored as N bounded rows in `sui_cache_nar_chunk` rather than one
151    // BYTEA in `sui_cache_nar`, because a whole-value bind holds the entire NAR
152    // in this process's heap for the duration of the statement — measured at
153    // 12.712 s for one production INSERT. These five verbs are the smallest
154    // surface that makes both directions O(chunk).
155    //
156    // They are REQUIRED, not defaulted. A default would let a new connection
157    // silently keep the whole-value path and re-introduce exactly the resident
158    // buffer this change removes.
159
160    /// Upsert one bounded chunk of a NAR. `seq` is `0..n`, or
161    /// [`CHUNK_MARKER_SEQ`] for the completeness marker.
162    async fn upsert_nar_chunk(&self, key: &str, seq: i32, value: &[u8]) -> Result<(), StoreError>;
163
164    /// Read one chunk back. `Ok(None)` when that `(key, seq)` row is absent.
165    async fn select_nar_chunk(&self, key: &str, seq: i32) -> Result<Option<Vec<u8>>, StoreError>;
166
167    /// Delete every chunk (and the marker) of `key`. Idempotent.
168    async fn delete_nar_chunks(&self, key: &str) -> Result<(), StoreError>;
169
170    /// `DELETE FROM sui_cache_nar_chunk` — clear the chunk table, returning the
171    /// row count removed.
172    async fn clear_nar_chunks(&self) -> Result<u64, StoreError>;
173
174    /// Read a bounded window of a **legacy whole-value** NAR row, returning
175    /// `(window_bytes, total_byte_length)`; `Ok(None)` when the row is absent.
176    ///
177    /// `offset` is 1-based (Postgres `substr` semantics). This exists so rows
178    /// written by the pre-streaming build — every NAR already in the production
179    /// database — can be *served* without materializing them. Without it the
180    /// read path would stay unbounded until the cache happened to turn over.
181    async fn select_nar_window(
182        &self,
183        key: &str,
184        offset: i64,
185        len: i32,
186    ) -> Result<Option<(Vec<u8>, i64)>, StoreError>;
187
188    /// Idempotently (re-)create every table this connection serves.
189    ///
190    /// This is the **self-heal** verb. [`PgStorageBackend`] calls it when a row
191    /// verb reports [`StoreError::SchemaMissing`], then retries the verb once —
192    /// so a durable tier that comes back on an empty volume repairs itself on
193    /// the next request instead of erroring until someone restarts the process.
194    ///
195    /// It must be safe to call **at any time, any number of times**
196    /// (`CREATE TABLE IF NOT EXISTS`), including concurrently.
197    ///
198    /// The default is a no-op, so a backend whose schema cannot go missing (an
199    /// in-memory mock) needs no implementation. A backend that *does* return
200    /// `SchemaMissing` must override this — otherwise the retry re-runs against
201    /// the same absent schema and fails identically, which is still correct, just
202    /// unhealed.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the DDL cannot be executed.
207    async fn ensure_schema(&self) -> Result<(), StoreError> {
208        Ok(())
209    }
210}
211
212/// L2 durable cache tier: content-addressed key → value over Postgres, shared
213/// across pods, survives a roll.
214///
215/// Generic over the [`PgCacheConn`] seam so it is fully testable against a mock.
216pub struct PgStorageBackend<C: PgCacheConn> {
217    /// `Arc` rather than a bare `C` so a lazily-pulled chunk stream can own a
218    /// handle to the connection. A [`NarStream`] is `'static` — it outlives the
219    /// `&self` that produced it — so without a shared handle the read path could
220    /// not be lazy, and "streaming" would collapse back into "collect it all
221    /// first".
222    conn: std::sync::Arc<C>,
223}
224
225impl<C: PgCacheConn> PgStorageBackend<C> {
226    /// Wrap a [`PgCacheConn`].
227    pub fn new(conn: C) -> Self {
228        Self { conn: std::sync::Arc::new(conn) }
229    }
230
231    /// Borrow the underlying connection (for composition / diagnostics).
232    pub fn conn(&self) -> &C {
233        &self.conn
234    }
235
236    /// Run a row verb; if it reports [`StoreError::SchemaMissing`], re-run the
237    /// idempotent DDL via [`PgCacheConn::ensure_schema`] and retry **once**.
238    ///
239    /// This lives here — in the generic layer over the [`PgCacheConn`] seam —
240    /// rather than inside the sqlx adapter, so the self-heal is provable against
241    /// the in-memory mock with no live Postgres.
242    ///
243    /// Exactly one retry: a schema that is still missing after its own DDL ran
244    /// is a real failure (permissions, wrong database, a dropped role), not a
245    /// transient, and must surface rather than spin.
246    async fn healing<T, F, Fut>(&self, op: F) -> Result<T, StoreError>
247    where
248        F: Fn() -> Fut,
249        Fut: std::future::Future<Output = Result<T, StoreError>>,
250    {
251        match op().await {
252            Err(StoreError::SchemaMissing(detail)) => {
253                tracing::warn!(
254                    detail = %detail,
255                    "pg L2: schema absent — re-running idempotent DDL and retrying once \
256                     (a durable tier came back on an empty volume?)",
257                );
258                self.conn.ensure_schema().await?;
259                op().await
260            }
261            other => other,
262        }
263    }
264}
265
266impl<C: PgCacheConn + 'static> PgStorageBackend<C> {
267    /// Lazily pull chunks `0..chunks` as a bounded stream.
268    ///
269    /// **No `healing` retry inside the stream, deliberately.** Re-running the
270    /// DDL mid-read would restart against an empty schema *after* bytes have
271    /// already been handed to the caller — the reader would splice two different
272    /// values together. A fault mid-stream is surfaced and the stream ends; the
273    /// next request heals at the top, where it is safe.
274    fn chunked_stream(&self, path: &str, chunks: u64) -> NarStream {
275        let conn = std::sync::Arc::clone(&self.conn);
276        let key = path.to_string();
277        stream::unfold((conn, key, 0u64), move |(conn, key, seq)| async move {
278            if seq >= chunks {
279                return None;
280            }
281            match conn.select_nar_chunk(&key, seq as i32).await {
282                Ok(Some(v)) => Some((Ok(Bytes::from(v)), (conn, key, seq + 1))),
283                // A gap under a published marker is corruption, never a short
284                // read: the marker is the promise that `chunks` rows exist.
285                Ok(None) => {
286                    let e = StoreError::NarInfo(format!(
287                        "NAR {key}: chunk {seq} of {chunks} is missing though the \
288                         completeness marker claims a whole value",
289                    ));
290                    Some((Err(e), (conn, key, chunks)))
291                }
292                Err(e) => Some((Err(e), (conn, key, chunks))),
293            }
294        })
295        .boxed()
296    }
297
298    /// Window a **legacy whole-value** row out in bounded `substr` slices.
299    ///
300    /// The first window is already in hand (it is what proved the row exists),
301    /// so it is emitted directly rather than re-queried.
302    fn legacy_window_stream(&self, path: &str, first: Vec<u8>, total: i64) -> NarStream {
303        let conn = std::sync::Arc::clone(&self.conn);
304        let key = path.to_string();
305        let next_offset = 1 + first.len() as i64;
306        let head = stream::once(async move { Ok(Bytes::from(first)) });
307        let tail = stream::unfold((conn, key, next_offset), move |(conn, key, off)| async move {
308            if off > total {
309                return None;
310            }
311            match conn.select_nar_window(&key, off, chunk_len()).await {
312                Ok(Some((v, _))) if !v.is_empty() => {
313                    let n = v.len() as i64;
314                    Some((Ok(Bytes::from(v)), (conn, key, off + n)))
315                }
316                // Row vanished or ran short mid-read (a concurrent delete): stop
317                // cleanly rather than spinning on an offset that never advances.
318                Ok(_) => None,
319                Err(e) => Some((Err(e), (conn, key, total + 1))),
320            }
321        });
322        head.chain(tail).boxed()
323    }
324}
325
326#[async_trait]
327impl<C: PgCacheConn + 'static> StorageBackend for PgStorageBackend<C> {
328    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, StoreError> {
329        match self.healing(|| self.conn.select(PgTable::Narinfo, hash)).await? {
330            Some(bytes) => {
331                let text = String::from_utf8(bytes).map_err(|e| {
332                    StoreError::NarInfo(format!("invalid utf-8 in pg narinfo {hash}: {e}"))
333                })?;
334                Ok(Some(text))
335            }
336            None => Ok(None),
337        }
338    }
339
340    async fn put_narinfo_record(&self, hash: &str, content: &str) -> Result<(), StoreError> {
341        self.healing(|| self.conn.upsert(PgTable::Narinfo, hash, content.as_bytes())).await
342    }
343
344    async fn delete_narinfo_record(&self, hash: &str) -> Result<(), StoreError> {
345        self.healing(|| self.conn.delete(PgTable::Narinfo, hash)).await
346    }
347
348    /// Remove a NAR by key, clearing **both generations**.
349    ///
350    /// A key may exist as a legacy whole-value row (written before the streaming
351    /// path) or as chunk rows, and a NAR that was re-pushed across the change can
352    /// be both. Dropping only one leaves the other still readable, so a
353    /// "deleted" NAR would keep serving.
354    async fn delete_nar_record(&self, nar_path: &str) -> Result<(), StoreError> {
355        self.healing(|| self.conn.delete(PgTable::Nar, nar_path)).await?;
356        self.healing(|| self.conn.delete_nar_chunks(nar_path)).await
357    }
358
359    fn nar_ref_index(&self) -> &dyn NarRefIndex {
360        self
361    }
362
363    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, StoreError> {
364        // ONE code path: the whole-value verb is the streaming verb drained.
365        match self.get_nar_stream(path).await? {
366            Some(s) => Ok(Some(nar_stream::collect_nar(s, None).await?)),
367            None => Ok(None),
368        }
369    }
370
371    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), StoreError> {
372        self.put_nar_stream(path, &nar_stream::BytesNarSource::from(data)).await
373    }
374
375    /// **O(chunk).** A NAR crosses the wire as [`NAR_CHUNK_BYTES`] rows in both
376    /// directions; no statement ever binds or returns the whole value.
377    fn nar_residency(&self) -> NarResidency {
378        NarResidency::Streaming
379    }
380
381    /// Read a NAR back as bounded chunks, from either storage generation.
382    ///
383    /// Chunked rows are checked first (via the marker); a key with no marker
384    /// falls back to windowing a **legacy whole-value row** with `substr`. Both
385    /// arms are O(chunk) — the fallback exists so the NARs already in the
386    /// production database are servable without materializing them, not as a
387    /// buffered escape hatch.
388    async fn get_nar_stream(&self, path: &str) -> Result<Option<NarStream>, StoreError> {
389        // The marker is the authority on "a complete chunked value exists".
390        if let Some(raw) = self
391            .healing(|| self.conn.select_nar_chunk(path, CHUNK_MARKER_SEQ))
392            .await?
393        {
394            let chunks = decode_marker(&raw)?;
395            return Ok(Some(self.chunked_stream(path, chunks)));
396        }
397        // No marker: either absent, or a legacy whole-value row.
398        match self.healing(|| self.conn.select_nar_window(path, 1, chunk_len())).await? {
399            Some((first, total)) => Ok(Some(self.legacy_window_stream(path, first, total))),
400            None => Ok(None),
401        }
402    }
403
404    /// Write a NAR as bounded chunk rows, publishing with a marker written last.
405    ///
406    /// Order is the invariant, not a convention:
407    /// 1. drop any prior chunks **and the legacy whole row** — a stale marker or
408    ///    a stale legacy row would otherwise shadow the new value;
409    /// 2. write chunks `0..n`, each a bounded bind;
410    /// 3. write the marker.
411    ///
412    /// A failure anywhere in (1)–(2) leaves no marker, so the key reads as a
413    /// clean miss. There is no window in which a reader can observe a truncated
414    /// NAR.
415    async fn put_nar_stream(&self, path: &str, src: &dyn NarSource) -> Result<(), StoreError> {
416        self.healing(|| self.conn.delete_nar_chunks(path)).await?;
417        self.healing(|| self.conn.delete(PgTable::Nar, path)).await?;
418
419        let mut stream = src.open().await?;
420        let mut seq: i32 = 0;
421        while let Some(chunk) = stream.next().await {
422            let chunk: Bytes = chunk?;
423            self.healing(|| self.conn.upsert_nar_chunk(path, seq, &chunk)).await?;
424            seq = seq.checked_add(1).ok_or_else(|| {
425                // 2^31 chunks at 4 MiB is ~8 EiB. Unreachable in practice, but a
426                // silent wrap here would corrupt ordering, so it is typed.
427                StoreError::NarInfo(format!("NAR {path} exceeds the addressable chunk count"))
428            })?;
429        }
430
431        let marker = encode_marker(seq as u64);
432        self.healing(|| self.conn.upsert_nar_chunk(path, CHUNK_MARKER_SEQ, &marker)).await
433    }
434
435    async fn list_narinfos(&self) -> Result<Vec<String>, StoreError> {
436        self.healing(|| self.conn.keys(PgTable::Narinfo)).await
437    }
438
439    /// Complete L2 wipe: truncate the narinfo, legacy NAR, NAR-chunk and
440    /// reverse-edge tables. Unlike the per-hash `delete`, this reclaims NAR rows
441    /// wholesale. Returns the narinfo row count.
442    async fn wipe_all(&self) -> Result<usize, StoreError> {
443        let narinfos = self.healing(|| self.conn.clear(PgTable::Narinfo)).await? as usize;
444        self.healing(|| self.conn.clear(PgTable::Nar)).await?;
445        self.healing(|| self.conn.clear_nar_chunks()).await?;
446        self.healing(|| self.conn.clear(PgTable::NarRef)).await?;
447        Ok(narinfos)
448    }
449}
450
451/// The reverse index as one zero-value row per edge in `sui_cache_nar_ref`.
452///
453/// The key is the canonical [`NarRefKey`], so the referrer lookup is a
454/// primary-key range scan under [`NarRefScan`] rather than a table sweep.
455#[async_trait]
456impl<C: PgCacheConn + 'static> NarRefIndex for PgStorageBackend<C> {
457    async fn record(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
458        let key = NarRefKey { nar_path, hash }.to_string();
459        self.healing(|| self.conn.upsert(PgTable::NarRef, &key, b"")).await
460    }
461
462    async fn forget(&self, nar_path: &str, hash: &str) -> Result<(), StoreError> {
463        let key = NarRefKey { nar_path, hash }.to_string();
464        self.healing(|| self.conn.delete(PgTable::NarRef, &key)).await
465    }
466
467    async fn referrers(&self, nar_path: &str) -> Result<Vec<String>, StoreError> {
468        let scan = NarRefScan { nar_path };
469        let prefix = scan.to_string();
470        let keys = self
471            .healing(|| self.conn.keys_with_prefix(PgTable::NarRef, &prefix))
472            .await?;
473        let mut hashes: Vec<String> = keys
474            .iter()
475            .filter_map(|k| referrer_of(&scan, k))
476            .map(str::to_string)
477            .collect();
478        hashes.sort();
479        hashes.dedup();
480        Ok(hashes)
481    }
482}
483
484/// The chunk window size as the `i32` Postgres `substr` takes.
485///
486/// [`NAR_CHUNK_BYTES`] is a `usize` and this conversion is infallible for any
487/// sane constant; the clamp is here so a future edit that makes it huge
488/// degrades to a smaller window rather than wrapping into a negative length.
489fn chunk_len() -> i32 {
490    i32::try_from(NAR_CHUNK_BYTES).unwrap_or(i32::MAX)
491}
492
493// ---------------------------------------------------------------------------
494// Production transport — real sqlx Postgres pool, gated behind the `postgres`
495// feature so the default build + unit tests pull zero driver surface. The L2
496// mapping above is proven against the in-memory mock; this is the thin SQL layer.
497// ---------------------------------------------------------------------------
498
499#[cfg(feature = "postgres")]
500mod sqlx_conn {
501    use super::{StoreError, PgCacheConn, PgStorageBackend, PgTable};
502    use async_trait::async_trait;
503    use sqlx::postgres::{PgPool, PgPoolOptions};
504    use sqlx::Row;
505
506    /// Postgres SQLSTATE `42P01` — `undefined_table`. The one code that means
507    /// "your schema is gone", and therefore the one that is self-healable.
508    const UNDEFINED_TABLE: &str = "42P01";
509
510    fn to_store_err(e: sqlx::Error) -> StoreError {
511        // Classify BEFORE flattening into an opaque io::Error, so the healable
512        // case keeps its own typed variant. Everything else stays `Io` — a
513        // connection reset, an OOM-killed backend mid-query, a protocol error
514        // are all real failures, never rounded up into "just re-run the DDL".
515        if let sqlx::Error::Database(db) = &e {
516            if db.code().as_deref() == Some(UNDEFINED_TABLE) {
517                return StoreError::SchemaMissing(format!("postgres: {e}"));
518            }
519        }
520        StoreError::Io(std::io::Error::other(format!("postgres: {e}")))
521    }
522
523    /// `CREATE TABLE IF NOT EXISTS` for the chunked-NAR table.
524    ///
525    /// Deliberately **not** a third [`PgTable`] arm: every other `PgTable` verb
526    /// is keyed by a single `key`, and this table's key is `(key, seq)`. Adding
527    /// an arm would force four `unreachable!()` branches into the SQL matches —
528    /// a worse trade than one clearly-named constant beside them.
529    const NAR_CHUNK_DDL: &str = "CREATE TABLE IF NOT EXISTS sui_cache_nar_chunk (\
530         key TEXT NOT NULL, seq INTEGER NOT NULL, value BYTEA NOT NULL, \
531         PRIMARY KEY (key, seq))";
532
533    const NAR_CHUNK_SELECT: &str =
534        "SELECT value FROM sui_cache_nar_chunk WHERE key = $1 AND seq = $2";
535    const NAR_CHUNK_UPSERT: &str = "INSERT INTO sui_cache_nar_chunk (key, seq, value) \
536         VALUES ($1, $2, $3) ON CONFLICT (key, seq) DO UPDATE SET value = EXCLUDED.value";
537    const NAR_CHUNK_DELETE_KEY: &str = "DELETE FROM sui_cache_nar_chunk WHERE key = $1";
538    const NAR_CHUNK_CLEAR: &str = "DELETE FROM sui_cache_nar_chunk";
539    /// One round trip for both the window and the total, so a legacy read costs
540    /// the same number of queries as a chunked one.
541    const NAR_LEGACY_WINDOW: &str = "SELECT substr(value, $2, $3) AS chunk, \
542         octet_length(value) AS total FROM sui_cache_nar WHERE key = $1";
543
544    impl PgTable {
545        /// `CREATE TABLE IF NOT EXISTS` DDL — a full static SQL string per arm
546        /// (typed emission: no runtime string assembly of the table name).
547        const fn ddl(self) -> &'static str {
548            match self {
549                PgTable::Narinfo => {
550                    "CREATE TABLE IF NOT EXISTS sui_cache_narinfo (key TEXT PRIMARY KEY, value BYTEA NOT NULL)"
551                }
552                PgTable::Nar => {
553                    "CREATE TABLE IF NOT EXISTS sui_cache_nar (key TEXT PRIMARY KEY, value BYTEA NOT NULL)"
554                }
555                PgTable::NarRef => {
556                    "CREATE TABLE IF NOT EXISTS sui_cache_nar_ref (key TEXT PRIMARY KEY, value BYTEA NOT NULL)"
557                }
558            }
559        }
560
561        const fn select_sql(self) -> &'static str {
562            match self {
563                PgTable::Narinfo => "SELECT value FROM sui_cache_narinfo WHERE key = $1",
564                PgTable::Nar => "SELECT value FROM sui_cache_nar WHERE key = $1",
565                PgTable::NarRef => "SELECT value FROM sui_cache_nar_ref WHERE key = $1",
566            }
567        }
568
569        const fn upsert_sql(self) -> &'static str {
570            match self {
571                PgTable::Narinfo => {
572                    "INSERT INTO sui_cache_narinfo (key, value) VALUES ($1, $2) \
573                     ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
574                }
575                PgTable::Nar => {
576                    "INSERT INTO sui_cache_nar (key, value) VALUES ($1, $2) \
577                     ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
578                }
579                PgTable::NarRef => {
580                    "INSERT INTO sui_cache_nar_ref (key, value) VALUES ($1, $2) \
581                     ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
582                }
583            }
584        }
585
586        const fn delete_sql(self) -> &'static str {
587            match self {
588                PgTable::Narinfo => "DELETE FROM sui_cache_narinfo WHERE key = $1",
589                PgTable::Nar => "DELETE FROM sui_cache_nar WHERE key = $1",
590                PgTable::NarRef => "DELETE FROM sui_cache_nar_ref WHERE key = $1",
591            }
592        }
593
594        const fn clear_sql(self) -> &'static str {
595            match self {
596                PgTable::Narinfo => "DELETE FROM sui_cache_narinfo",
597                PgTable::Nar => "DELETE FROM sui_cache_nar",
598                PgTable::NarRef => "DELETE FROM sui_cache_nar_ref",
599            }
600        }
601
602        const fn keys_sql(self) -> &'static str {
603            match self {
604                PgTable::Narinfo => "SELECT key FROM sui_cache_narinfo",
605                PgTable::Nar => "SELECT key FROM sui_cache_nar",
606                PgTable::NarRef => "SELECT key FROM sui_cache_nar_ref",
607            }
608        }
609
610        /// `starts_with(key, $1)` rather than `key LIKE $1 || '%'`: a prefix that
611        /// happens to contain `%` or `_` is a wildcard to `LIKE` and would match
612        /// keys that are not under it — an over-report onto the wrong NAR.
613        /// `starts_with` has no metacharacters and is still index-sargable.
614        const fn keys_with_prefix_sql(self) -> &'static str {
615            match self {
616                PgTable::Narinfo => {
617                    "SELECT key FROM sui_cache_narinfo WHERE starts_with(key, $1)"
618                }
619                PgTable::Nar => "SELECT key FROM sui_cache_nar WHERE starts_with(key, $1)",
620                PgTable::NarRef => {
621                    "SELECT key FROM sui_cache_nar_ref WHERE starts_with(key, $1)"
622                }
623            }
624        }
625    }
626
627    /// Production [`PgCacheConn`] over a `sqlx` Postgres connection pool.
628    pub struct SqlxPgCacheConn {
629        pool: PgPool,
630    }
631
632    impl SqlxPgCacheConn {
633        /// Connect to `url` (e.g. `postgres://user@postgres.super-cache-ci.svc:5432/sui`),
634        /// bounding the pool at `max_conns`, and ensure the two cache tables exist.
635        ///
636        /// # Schema lifecycle (three independent nets — read this before changing it)
637        ///
638        /// The DDL is `CREATE TABLE IF NOT EXISTS`, so running it is always safe.
639        /// It runs at three moments, each covering a failure the others do not:
640        ///
641        /// 1. **Process start** (the explicit [`create_tables`](Self::create_tables)
642        ///    below) — a first-ever deploy against an empty database.
643        /// 2. **Every new physical connection** (the `after_connect` hook) — this
644        ///    is the one that matters when the *database* restarts while this
645        ///    process keeps running. `PgPool` transparently replaces dead
646        ///    connections; without this hook those replacements land on a
647        ///    schemaless database and every query fails forever, with no restart
648        ///    of *this* process to re-trigger step 1. That is exactly how a
649        ///    Postgres pod on an `emptyDir` takes the cache down permanently.
650        /// 3. **On a `42P01` at query time** (the `PgStorageBackend::healing`
651        ///    retry) — covers the schema vanishing under an *already-established,
652        ///    still-live* connection, which neither of the above can see.
653        ///
654        /// # Errors
655        ///
656        /// Returns [`StoreError::Io`] if the pool cannot be built or the schema
657        /// DDL fails.
658        pub async fn connect(url: &str, max_conns: u32) -> Result<Self, StoreError> {
659            let pool = PgPoolOptions::new()
660                .max_connections(max_conns)
661                // Net 2: re-assert the schema on EVERY physical connection, so a
662                // pool reconnect to a rebuilt/wiped database repairs itself.
663                .after_connect(|conn, _meta| {
664                    Box::pin(async move {
665                        for t in [PgTable::Narinfo, PgTable::Nar, PgTable::NarRef] {
666                            sqlx::query(t.ddl()).execute(&mut *conn).await?;
667                        }
668                        sqlx::query(NAR_CHUNK_DDL).execute(&mut *conn).await?;
669                        Ok(())
670                    })
671                })
672                .connect(url)
673                .await
674                .map_err(to_store_err)?;
675            let this = Self { pool };
676            // Net 1: explicit, so a first deploy fails loudly at startup rather
677            // than on the first request.
678            this.create_tables().await?;
679            Ok(this)
680        }
681
682        /// Run the idempotent `CREATE TABLE IF NOT EXISTS` DDL for both tables.
683        ///
684        /// Named distinctly from the [`PgCacheConn::ensure_schema`] trait method
685        /// that delegates to it — an inherent method of the same name would make
686        /// that delegation resolve to itself and recurse forever.
687        async fn create_tables(&self) -> Result<(), StoreError> {
688            for t in [PgTable::Narinfo, PgTable::Nar, PgTable::NarRef] {
689                sqlx::query(t.ddl()).execute(&self.pool).await.map_err(to_store_err)?;
690            }
691            // The chunk table is additive: `sui_cache_nar` keeps every row a
692            // pre-streaming build wrote, and the read path still serves them
693            // (windowed). Nothing migrates, nothing is dropped — a rollback to
694            // the previous binary still finds its data.
695            sqlx::query(NAR_CHUNK_DDL).execute(&self.pool).await.map_err(to_store_err)?;
696            Ok(())
697        }
698    }
699
700    #[async_trait]
701    impl PgCacheConn for SqlxPgCacheConn {
702        async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
703            let row = sqlx::query(table.select_sql())
704                .bind(key)
705                .fetch_optional(&self.pool)
706                .await
707                .map_err(to_store_err)?;
708            match row {
709                Some(r) => {
710                    let v: Vec<u8> = r.try_get("value").map_err(to_store_err)?;
711                    Ok(Some(v))
712                }
713                None => Ok(None),
714            }
715        }
716
717        async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), StoreError> {
718            sqlx::query(table.upsert_sql())
719                .bind(key)
720                .bind(value)
721                .execute(&self.pool)
722                .await
723                .map_err(to_store_err)?;
724            Ok(())
725        }
726
727        async fn delete(&self, table: PgTable, key: &str) -> Result<(), StoreError> {
728            sqlx::query(table.delete_sql())
729                .bind(key)
730                .execute(&self.pool)
731                .await
732                .map_err(to_store_err)?;
733            Ok(())
734        }
735
736        async fn keys(&self, table: PgTable) -> Result<Vec<String>, StoreError> {
737            let rows = sqlx::query(table.keys_sql())
738                .fetch_all(&self.pool)
739                .await
740                .map_err(to_store_err)?;
741            rows.into_iter()
742                .map(|r| r.try_get::<String, _>("key").map_err(to_store_err))
743                .collect()
744        }
745
746        async fn keys_with_prefix(
747            &self,
748            table: PgTable,
749            prefix: &str,
750        ) -> Result<Vec<String>, StoreError> {
751            let rows = sqlx::query(table.keys_with_prefix_sql())
752                .bind(prefix)
753                .fetch_all(&self.pool)
754                .await
755                .map_err(to_store_err)?;
756            rows.into_iter()
757                .map(|r| r.try_get::<String, _>("key").map_err(to_store_err))
758                .collect()
759        }
760
761        async fn clear(&self, table: PgTable) -> Result<u64, StoreError> {
762            let res = sqlx::query(table.clear_sql())
763                .execute(&self.pool)
764                .await
765                .map_err(to_store_err)?;
766            Ok(res.rows_affected())
767        }
768
769        async fn upsert_nar_chunk(
770            &self,
771            key: &str,
772            seq: i32,
773            value: &[u8],
774        ) -> Result<(), StoreError> {
775            sqlx::query(NAR_CHUNK_UPSERT)
776                .bind(key)
777                .bind(seq)
778                .bind(value)
779                .execute(&self.pool)
780                .await
781                .map_err(to_store_err)?;
782            Ok(())
783        }
784
785        async fn select_nar_chunk(
786            &self,
787            key: &str,
788            seq: i32,
789        ) -> Result<Option<Vec<u8>>, StoreError> {
790            let row = sqlx::query(NAR_CHUNK_SELECT)
791                .bind(key)
792                .bind(seq)
793                .fetch_optional(&self.pool)
794                .await
795                .map_err(to_store_err)?;
796            match row {
797                Some(r) => Ok(Some(r.try_get::<Vec<u8>, _>("value").map_err(to_store_err)?)),
798                None => Ok(None),
799            }
800        }
801
802        async fn delete_nar_chunks(&self, key: &str) -> Result<(), StoreError> {
803            sqlx::query(NAR_CHUNK_DELETE_KEY)
804                .bind(key)
805                .execute(&self.pool)
806                .await
807                .map_err(to_store_err)?;
808            Ok(())
809        }
810
811        async fn clear_nar_chunks(&self) -> Result<u64, StoreError> {
812            let res = sqlx::query(NAR_CHUNK_CLEAR)
813                .execute(&self.pool)
814                .await
815                .map_err(to_store_err)?;
816            Ok(res.rows_affected())
817        }
818
819        async fn select_nar_window(
820            &self,
821            key: &str,
822            offset: i64,
823            len: i32,
824        ) -> Result<Option<(Vec<u8>, i64)>, StoreError> {
825            let row = sqlx::query(NAR_LEGACY_WINDOW)
826                .bind(key)
827                .bind(offset)
828                .bind(len)
829                .fetch_optional(&self.pool)
830                .await
831                .map_err(to_store_err)?;
832            match row {
833                Some(r) => {
834                    let chunk: Vec<u8> = r.try_get("chunk").map_err(to_store_err)?;
835                    let total: i32 = r.try_get("total").map_err(to_store_err)?;
836                    Ok(Some((chunk, i64::from(total))))
837                }
838                None => Ok(None),
839            }
840        }
841
842        /// Net 3: the self-heal the `healing` retry drives.
843        async fn ensure_schema(&self) -> Result<(), StoreError> {
844            self.create_tables().await
845        }
846    }
847
848    impl PgStorageBackend<SqlxPgCacheConn> {
849        /// Connect an L2 backend to a Postgres `url`, pool-bounded at `max_conns`.
850        ///
851        /// # Errors
852        ///
853        /// Propagates a connection/schema failure from [`SqlxPgCacheConn::connect`].
854        pub async fn connect(url: &str, max_conns: u32) -> Result<Self, StoreError> {
855            Ok(Self::new(SqlxPgCacheConn::connect(url, max_conns).await?))
856        }
857    }
858}
859
860#[cfg(feature = "postgres")]
861pub use sqlx_conn::SqlxPgCacheConn;
862
863// ---------------------------------------------------------------------------
864// Unit tests — the L2 mapping proven against an in-memory mock PgCacheConn.
865// No live Postgres required.
866// ---------------------------------------------------------------------------
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use std::collections::HashMap;
872    use std::sync::Mutex;
873
874    /// In-memory [`PgCacheConn`] mock: a per-table `HashMap`. Durable within the
875    /// process (unlike the Redis mock, there is no `evict`) — this tier is
876    /// authoritative.
877    #[derive(Default)]
878    struct MockPg {
879        narinfo: Mutex<HashMap<String, Vec<u8>>>,
880        nar: Mutex<HashMap<String, Vec<u8>>>,
881        /// The reverse-edge table: one zero-value row per `NarRefKey`.
882        nar_ref: Mutex<HashMap<String, Vec<u8>>>,
883        /// The chunked-NAR table: `(key, seq) -> bytes`, including the
884        /// `CHUNK_MARKER_SEQ` completeness marker.
885        nar_chunk: Mutex<HashMap<(String, i32), Vec<u8>>>,
886        /// Whether the tables "exist". Models the real failure: a Postgres that
887        /// is up and connectable but whose relations are gone (an `emptyDir`
888        /// PGDATA destroyed by a pod roll).
889        schema_missing: Mutex<bool>,
890        /// How many times the idempotent DDL has been run — proves both that
891        /// the self-heal fires and that re-running it is harmless.
892        ensure_schema_calls: Mutex<usize>,
893    }
894
895    impl MockPg {
896        fn table(&self, t: PgTable) -> &Mutex<HashMap<String, Vec<u8>>> {
897            match t {
898                PgTable::Narinfo => &self.narinfo,
899                PgTable::Nar => &self.nar,
900                PgTable::NarRef => &self.nar_ref,
901            }
902        }
903        /// Drop the schema out from under a live connection.
904        ///
905        /// Clears the rows as well, because that is what actually happens: an
906        /// `emptyDir` PGDATA destroyed by a pod roll takes the data with it, and
907        /// so does a `DROP TABLE`. Losing the schema and keeping the rows is not
908        /// a reachable state, so the mock must not model one.
909        fn drop_schema(&self) {
910            *self.schema_missing.lock().unwrap() = true;
911            self.narinfo.lock().unwrap().clear();
912            self.nar.lock().unwrap().clear();
913            self.nar_ref.lock().unwrap().clear();
914            self.nar_chunk.lock().unwrap().clear();
915        }
916        /// Rows currently in the chunk table — proves a write really chunked
917        /// (and that a re-put does not leave a stale tail behind).
918        fn chunk_rows(&self) -> usize {
919            self.nar_chunk.lock().unwrap().len()
920        }
921        /// Seed a **legacy whole-value** NAR row directly, bypassing the chunked
922        /// write path — the shape of every NAR already in production.
923        fn seed_legacy_nar(&self, key: &str, value: &[u8]) {
924            self.nar.lock().unwrap().insert(key.to_string(), value.to_vec());
925        }
926        fn ddl_runs(&self) -> usize {
927            *self.ensure_schema_calls.lock().unwrap()
928        }
929        fn guard(&self) -> Result<(), StoreError> {
930            if *self.schema_missing.lock().unwrap() {
931                // Mirrors the real sqlx mapping of SQLSTATE 42P01.
932                Err(StoreError::SchemaMissing(
933                    "postgres: relation \"sui_cache_narinfo\" does not exist".to_string(),
934                ))
935            } else {
936                Ok(())
937            }
938        }
939    }
940
941    #[async_trait]
942    impl PgCacheConn for MockPg {
943        async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
944            self.guard()?;
945            Ok(self.table(table).lock().unwrap().get(key).cloned())
946        }
947
948        async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), StoreError> {
949            self.guard()?;
950            self.table(table).lock().unwrap().insert(key.to_string(), value.to_vec());
951            Ok(())
952        }
953
954        async fn delete(&self, table: PgTable, key: &str) -> Result<(), StoreError> {
955            self.guard()?;
956            self.table(table).lock().unwrap().remove(key);
957            Ok(())
958        }
959
960        async fn keys(&self, table: PgTable) -> Result<Vec<String>, StoreError> {
961            self.guard()?;
962            Ok(self.table(table).lock().unwrap().keys().cloned().collect())
963        }
964
965        /// Mirrors Postgres `starts_with(key, $1)` — a literal prefix, never a
966        /// `LIKE` pattern, so `%`/`_` in the prefix match themselves.
967        async fn keys_with_prefix(
968            &self,
969            table: PgTable,
970            prefix: &str,
971        ) -> Result<Vec<String>, StoreError> {
972            self.guard()?;
973            Ok(self
974                .table(table)
975                .lock()
976                .unwrap()
977                .keys()
978                .filter(|k| k.starts_with(prefix))
979                .cloned()
980                .collect())
981        }
982
983        async fn clear(&self, table: PgTable) -> Result<u64, StoreError> {
984            self.guard()?;
985            let mut m = self.table(table).lock().unwrap();
986            let n = m.len() as u64;
987            m.clear();
988            Ok(n)
989        }
990
991        async fn upsert_nar_chunk(&self, key: &str, seq: i32, value: &[u8]) -> Result<(), StoreError> {
992            self.guard()?;
993            self.nar_chunk
994                .lock()
995                .unwrap()
996                .insert((key.to_string(), seq), value.to_vec());
997            Ok(())
998        }
999
1000        async fn select_nar_chunk(&self, key: &str, seq: i32) -> Result<Option<Vec<u8>>, StoreError> {
1001            self.guard()?;
1002            Ok(self.nar_chunk.lock().unwrap().get(&(key.to_string(), seq)).cloned())
1003        }
1004
1005        async fn delete_nar_chunks(&self, key: &str) -> Result<(), StoreError> {
1006            self.guard()?;
1007            self.nar_chunk.lock().unwrap().retain(|(k, _), _| k != key);
1008            Ok(())
1009        }
1010
1011        async fn clear_nar_chunks(&self) -> Result<u64, StoreError> {
1012            self.guard()?;
1013            let mut m = self.nar_chunk.lock().unwrap();
1014            let n = m.len() as u64;
1015            m.clear();
1016            Ok(n)
1017        }
1018
1019        /// Mirrors Postgres `substr(value, offset, len)` — 1-based offset,
1020        /// silently clamped at the end of the value (NOT an error), which is
1021        /// exactly what the windowing read path relies on to terminate.
1022        async fn select_nar_window(
1023            &self,
1024            key: &str,
1025            offset: i64,
1026            len: i32,
1027        ) -> Result<Option<(Vec<u8>, i64)>, StoreError> {
1028            self.guard()?;
1029            let map = self.nar.lock().unwrap();
1030            let Some(v) = map.get(key) else { return Ok(None) };
1031            let total = v.len() as i64;
1032            let start = (offset - 1).clamp(0, total) as usize;
1033            let end = (start + len.max(0) as usize).min(v.len());
1034            Ok(Some((v[start..end].to_vec(), total)))
1035        }
1036
1037        /// Idempotent, exactly like `CREATE TABLE IF NOT EXISTS`: running it
1038        /// when the schema already exists is a no-op, never an error.
1039        async fn ensure_schema(&self) -> Result<(), StoreError> {
1040            *self.ensure_schema_calls.lock().unwrap() += 1;
1041            *self.schema_missing.lock().unwrap() = false;
1042            Ok(())
1043        }
1044    }
1045
1046    /// A connection whose schema can never be repaired — proves the retry is
1047    /// bounded at one and a permanent fault still surfaces.
1048    #[derive(Default)]
1049    struct UnhealablePg {
1050        attempts: Mutex<usize>,
1051    }
1052
1053    #[async_trait]
1054    impl PgCacheConn for UnhealablePg {
1055        async fn select(&self, _t: PgTable, _k: &str) -> Result<Option<Vec<u8>>, StoreError> {
1056            *self.attempts.lock().unwrap() += 1;
1057            Err(StoreError::SchemaMissing("still gone".to_string()))
1058        }
1059        async fn upsert(&self, _t: PgTable, _k: &str, _v: &[u8]) -> Result<(), StoreError> {
1060            Err(StoreError::SchemaMissing("still gone".to_string()))
1061        }
1062        async fn delete(&self, _t: PgTable, _k: &str) -> Result<(), StoreError> {
1063            Err(StoreError::SchemaMissing("still gone".to_string()))
1064        }
1065        async fn keys(&self, _t: PgTable) -> Result<Vec<String>, StoreError> {
1066            Err(StoreError::SchemaMissing("still gone".to_string()))
1067        }
1068        async fn keys_with_prefix(
1069            &self,
1070            _t: PgTable,
1071            _p: &str,
1072        ) -> Result<Vec<String>, StoreError> {
1073            Err(StoreError::SchemaMissing("still gone".to_string()))
1074        }
1075        async fn clear(&self, _t: PgTable) -> Result<u64, StoreError> {
1076            Err(StoreError::SchemaMissing("still gone".to_string()))
1077        }
1078        async fn upsert_nar_chunk(&self, _k: &str, _s: i32, _v: &[u8]) -> Result<(), StoreError> {
1079            Err(StoreError::SchemaMissing("still gone".to_string()))
1080        }
1081        async fn select_nar_chunk(&self, _k: &str, _s: i32) -> Result<Option<Vec<u8>>, StoreError> {
1082            *self.attempts.lock().unwrap() += 1;
1083            Err(StoreError::SchemaMissing("still gone".to_string()))
1084        }
1085        async fn delete_nar_chunks(&self, _k: &str) -> Result<(), StoreError> {
1086            Err(StoreError::SchemaMissing("still gone".to_string()))
1087        }
1088        async fn clear_nar_chunks(&self) -> Result<u64, StoreError> {
1089            Err(StoreError::SchemaMissing("still gone".to_string()))
1090        }
1091        async fn select_nar_window(
1092            &self,
1093            _k: &str,
1094            _o: i64,
1095            _l: i32,
1096        ) -> Result<Option<(Vec<u8>, i64)>, StoreError> {
1097            Err(StoreError::SchemaMissing("still gone".to_string()))
1098        }
1099        // `ensure_schema` keeps the no-op default: the DDL "runs" but the schema
1100        // stays absent (no permission to create, wrong database, …).
1101    }
1102
1103    const NARINFO: &str = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
1104
1105    #[test]
1106    fn table_names_are_distinct() {
1107        assert_ne!(PgTable::Narinfo.table_name(), PgTable::Nar.table_name());
1108    }
1109
1110    #[tokio::test]
1111    async fn get_missing_narinfo_returns_none() {
1112        let backend = PgStorageBackend::new(MockPg::default());
1113        assert!(backend.get_narinfo("nope").await.unwrap().is_none());
1114    }
1115
1116    #[tokio::test]
1117    async fn put_then_get_narinfo_roundtrips() {
1118        let backend = PgStorageBackend::new(MockPg::default());
1119        backend.put_narinfo("abc", NARINFO).await.unwrap();
1120        assert_eq!(backend.get_narinfo("abc").await.unwrap().unwrap(), NARINFO);
1121    }
1122
1123    #[tokio::test]
1124    async fn put_then_get_nar_roundtrips() {
1125        let backend = PgStorageBackend::new(MockPg::default());
1126        let data = b"\x00\x01\x02 fake nar bytes";
1127        backend.put_nar("nar/abc.nar.xz", data).await.unwrap();
1128        assert_eq!(backend.get_nar("nar/abc.nar.xz").await.unwrap().unwrap(), data);
1129    }
1130
1131    #[tokio::test]
1132    async fn narinfo_and_nar_keyspaces_do_not_collide() {
1133        // Same bare id used as both a narinfo hash and a nar path fragment: the
1134        // two-table split keeps them apart.
1135        let backend = PgStorageBackend::new(MockPg::default());
1136        backend.put_narinfo("dead", "the-narinfo").await.unwrap();
1137        backend.put_nar("dead", b"the-nar").await.unwrap();
1138        assert_eq!(backend.get_narinfo("dead").await.unwrap().unwrap(), "the-narinfo");
1139        assert_eq!(backend.get_nar("dead").await.unwrap().unwrap(), b"the-nar");
1140    }
1141
1142    /// A narinfo for store hash `_hash` advertising exactly `url`.
1143    fn narinfo_for(url: &str) -> String {
1144        format!(
1145            "StorePath: /nix/store/pkg\nURL: {url}\nCompression: xz\nFileHash: sha256:aaa\n\
1146             FileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n"
1147        )
1148    }
1149
1150    /// `delete` removes **the NAR the narinfo names** — not three guesses built
1151    /// from the store hash.
1152    ///
1153    /// The old fan-out deleted `nar/{store-hash}.{xz,zst,nar}`. A NAR is keyed
1154    /// by *narhash*, so all three of those are normally other paths' keys or
1155    /// nothing at all, and the real NAR survived. Here `narhash != storehash`,
1156    /// which is the ordinary case, and the old code would have deleted the
1157    /// store-hash-shaped decoys and left the real blob.
1158    #[tokio::test]
1159    async fn delete_resolves_the_nar_from_the_narinfo_instead_of_guessing() {
1160        let backend = PgStorageBackend::new(MockPg::default());
1161        backend.put_narinfo("storehash", &narinfo_for("nar/narhash.nar.xz")).await.unwrap();
1162        backend.put_nar("nar/narhash.nar.xz", b"the real nar").await.unwrap();
1163        // Decoys the extension-guessing fan-out would have taken instead.
1164        backend.put_nar("nar/storehash.nar.zst", b"someone else's nar").await.unwrap();
1165
1166        backend.delete("storehash").await.unwrap();
1167
1168        assert!(backend.get_narinfo("storehash").await.unwrap().is_none());
1169        assert!(
1170            backend.get_nar("nar/narhash.nar.xz").await.unwrap().is_none(),
1171            "the advertised NAR must be the one that goes",
1172        );
1173        assert_eq!(
1174            backend.get_nar("nar/storehash.nar.zst").await.unwrap().unwrap(),
1175            b"someone else's nar",
1176            "a store-hash-shaped key this narinfo never named must be untouched",
1177        );
1178    }
1179
1180    /// Two store paths with identical contents share one narhash and therefore
1181    /// one `URL:`. Deleting one must not take the NAR the other advertises — a
1182    /// narinfo whose advertised NAR 404s is a hard Nix failure.
1183    #[tokio::test]
1184    async fn deleting_one_of_two_paths_sharing_a_nar_leaves_the_nar() {
1185        let backend = PgStorageBackend::new(MockPg::default());
1186        let shared = "nar/sharednarhash.nar.xz";
1187        backend.put_narinfo("pathA", &narinfo_for(shared)).await.unwrap();
1188        backend.put_narinfo("pathB", &narinfo_for(shared)).await.unwrap();
1189        backend.put_nar(shared, b"shared contents").await.unwrap();
1190
1191        backend.delete("pathA").await.unwrap();
1192        assert!(backend.get_narinfo("pathA").await.unwrap().is_none());
1193        assert!(
1194            backend.get_nar(shared).await.unwrap().is_some(),
1195            "pathB still advertises this NAR",
1196        );
1197
1198        backend.delete("pathB").await.unwrap();
1199        assert!(
1200            backend.get_nar(shared).await.unwrap().is_none(),
1201            "the last referrer gone means the NAR is reclaimable",
1202        );
1203    }
1204
1205    #[tokio::test]
1206    async fn wipe_all_truncates_both_tables_incl_narhash_keyed_nar() {
1207        let backend = PgStorageBackend::new(MockPg::default());
1208        // Real keying: narinfo by store-hash, NAR by a DIFFERENT narhash — the
1209        // orphan class a per-hash `delete` cannot reach.
1210        backend.put_narinfo("storehash", NARINFO).await.unwrap();
1211        backend.put_nar("nar/0narhashXXXXXXXXXXXXXXXXXXXXXXXXXX.nar", b"blob").await.unwrap();
1212        backend.put_narinfo("other", NARINFO).await.unwrap();
1213
1214        let removed = backend.wipe_all().await.unwrap();
1215        assert_eq!(removed, 2, "wipe_all should report the narinfo count");
1216
1217        // Both tables fully cleared — including the narhash-keyed NAR.
1218        assert!(backend.list_narinfos().await.unwrap().is_empty());
1219        assert!(backend.get_narinfo("storehash").await.unwrap().is_none());
1220        assert!(backend.get_narinfo("other").await.unwrap().is_none());
1221        assert!(backend
1222            .get_nar("nar/0narhashXXXXXXXXXXXXXXXXXXXXXXXXXX.nar")
1223            .await
1224            .unwrap()
1225            .is_none());
1226    }
1227
1228    #[tokio::test]
1229    async fn delete_absent_is_idempotent() {
1230        let backend = PgStorageBackend::new(MockPg::default());
1231        backend.delete("ghost").await.unwrap();
1232    }
1233
1234    #[tokio::test]
1235    async fn list_narinfos_is_authoritative_and_full() {
1236        let backend = PgStorageBackend::new(MockPg::default());
1237        backend.put_narinfo("aaa", "1").await.unwrap();
1238        backend.put_narinfo("bbb", "2").await.unwrap();
1239        // A NAR write must not leak into the narinfo listing.
1240        backend.put_nar("nar/ccc.nar.xz", b"3").await.unwrap();
1241        let mut hashes = backend.list_narinfos().await.unwrap();
1242        hashes.sort();
1243        assert_eq!(hashes, vec!["aaa".to_string(), "bbb".to_string()]);
1244    }
1245
1246    #[tokio::test]
1247    async fn overwrite_narinfo_takes_latest() {
1248        let backend = PgStorageBackend::new(MockPg::default());
1249        backend.put_narinfo("h", "v1").await.unwrap();
1250        backend.put_narinfo("h", "v2").await.unwrap();
1251        assert_eq!(backend.get_narinfo("h").await.unwrap().unwrap(), "v2");
1252    }
1253
1254    // ── schema self-heal (the incident's root cause) ───────────────────────
1255
1256    #[tokio::test]
1257    async fn schema_vanishing_under_a_live_connection_self_heals_on_the_next_read() {
1258        // THE incident. The sui-cache process kept running while the Postgres
1259        // pod rolled and its emptyDir PGDATA was destroyed. `sqlx`'s pool
1260        // transparently reconnected — to a database with no tables — and every
1261        // query failed from then on, with no restart of THIS process to
1262        // re-trigger the connect-time DDL. It 500ed for over an hour.
1263        let backend = PgStorageBackend::new(MockPg::default());
1264        backend.put_narinfo("h", NARINFO).await.unwrap();
1265        assert_eq!(backend.conn().ddl_runs(), 0, "no heal needed while healthy");
1266
1267        backend.conn().drop_schema();
1268
1269        // The read must succeed rather than erroring: the DDL is re-run and the
1270        // query retried. The row itself is gone (the volume was wiped), so the
1271        // honest answer is a clean MISS — which is precisely the harmless case:
1272        // the client records a cache miss and builds.
1273        let got = backend.get_narinfo("h").await.expect("must self-heal, not error");
1274        assert!(got.is_none(), "the data really is gone — a miss, not a 500");
1275        assert_eq!(backend.conn().ddl_runs(), 1, "the idempotent DDL must have re-run");
1276
1277        // The tier is now fully functional again — writes land and read back.
1278        backend.put_narinfo("h2", NARINFO).await.unwrap();
1279        assert_eq!(backend.get_narinfo("h2").await.unwrap().unwrap(), NARINFO);
1280    }
1281
1282    #[tokio::test]
1283    async fn ensure_schema_is_idempotent_run_twice_no_error() {
1284        // Requirement: schema creation is safe to run on every startup, and any
1285        // number of times after. (`CREATE TABLE IF NOT EXISTS` in the real
1286        // adapter; the mock mirrors that contract.)
1287        let conn = MockPg::default();
1288        conn.ensure_schema().await.expect("first run");
1289        conn.ensure_schema().await.expect("second run must be a harmless no-op");
1290        conn.ensure_schema().await.expect("third run must be a harmless no-op");
1291        assert_eq!(conn.ddl_runs(), 3);
1292
1293        // …and it is equally safe when the schema is currently absent.
1294        conn.drop_schema();
1295        conn.ensure_schema().await.expect("run against an absent schema");
1296        conn.ensure_schema().await.expect("and again once it exists");
1297        let backend = PgStorageBackend::new(conn);
1298        backend.put_narinfo("x", NARINFO).await.expect("usable after repeated DDL");
1299    }
1300
1301    #[tokio::test]
1302    async fn every_verb_self_heals_not_just_reads() {
1303        // A write arriving first must repair the schema too — otherwise the
1304        // cache stays unfillable until something happens to read.
1305        for_each_verb_self_heals().await;
1306    }
1307
1308    async fn for_each_verb_self_heals() {
1309        // put_narinfo
1310        let b = PgStorageBackend::new(MockPg::default());
1311        b.conn().drop_schema();
1312        b.put_narinfo("h", NARINFO).await.expect("put_narinfo self-heals");
1313        assert_eq!(b.get_narinfo("h").await.unwrap().unwrap(), NARINFO);
1314
1315        // put_nar
1316        let b = PgStorageBackend::new(MockPg::default());
1317        b.conn().drop_schema();
1318        b.put_nar("nar/h.nar.xz", b"blob").await.expect("put_nar self-heals");
1319        assert_eq!(b.get_nar("nar/h.nar.xz").await.unwrap().unwrap(), b"blob");
1320
1321        // list_narinfos
1322        let b = PgStorageBackend::new(MockPg::default());
1323        b.conn().drop_schema();
1324        assert!(b.list_narinfos().await.expect("list self-heals").is_empty());
1325
1326        // delete
1327        let b = PgStorageBackend::new(MockPg::default());
1328        b.conn().drop_schema();
1329        b.delete("h").await.expect("delete self-heals");
1330
1331        // wipe_all
1332        let b = PgStorageBackend::new(MockPg::default());
1333        b.conn().drop_schema();
1334        assert_eq!(b.wipe_all().await.expect("wipe self-heals"), 0);
1335    }
1336
1337    #[tokio::test]
1338    async fn an_unrepairable_schema_surfaces_after_exactly_one_retry() {
1339        // No infinite retry loop: a schema that is still missing after its own
1340        // DDL ran is a real fault (permissions, wrong database) and must
1341        // surface. Exactly two attempts — the original and one retry.
1342        let backend = PgStorageBackend::new(UnhealablePg::default());
1343        let err = backend.get_narinfo("h").await.unwrap_err();
1344        assert!(matches!(err, StoreError::SchemaMissing(_)));
1345        assert_eq!(
1346            *backend.conn().attempts.lock().unwrap(),
1347            2,
1348            "exactly one retry after the heal attempt — never a spin",
1349        );
1350    }
1351
1352    #[tokio::test]
1353    async fn a_non_schema_error_is_never_retried_as_a_schema_problem() {
1354        // Only `SchemaMissing` triggers the DDL path. A connection reset or an
1355        // OOM-killed backend mid-query must not be rounded up into "just
1356        // re-create the tables".
1357        struct BrokenPg;
1358        #[async_trait]
1359        impl PgCacheConn for BrokenPg {
1360            async fn select(&self, _t: PgTable, _k: &str) -> Result<Option<Vec<u8>>, StoreError> {
1361                Err(StoreError::Io(std::io::Error::other(
1362                    "postgres: expected to read 5 bytes, got 0 bytes at EOF",
1363                )))
1364            }
1365            async fn upsert(&self, _t: PgTable, _k: &str, _v: &[u8]) -> Result<(), StoreError> {
1366                unreachable!()
1367            }
1368            async fn delete(&self, _t: PgTable, _k: &str) -> Result<(), StoreError> {
1369                unreachable!()
1370            }
1371            async fn keys(&self, _t: PgTable) -> Result<Vec<String>, StoreError> {
1372                unreachable!()
1373            }
1374            async fn keys_with_prefix(
1375                &self,
1376                _t: PgTable,
1377                _p: &str,
1378            ) -> Result<Vec<String>, StoreError> {
1379                unreachable!()
1380            }
1381            async fn clear(&self, _t: PgTable) -> Result<u64, StoreError> {
1382                unreachable!()
1383            }
1384            async fn upsert_nar_chunk(&self, _k: &str, _s: i32, _v: &[u8]) -> Result<(), StoreError> {
1385                unreachable!()
1386            }
1387            async fn select_nar_chunk(&self, _k: &str, _s: i32) -> Result<Option<Vec<u8>>, StoreError> {
1388                unreachable!()
1389            }
1390            async fn delete_nar_chunks(&self, _k: &str) -> Result<(), StoreError> {
1391                unreachable!()
1392            }
1393            async fn clear_nar_chunks(&self) -> Result<u64, StoreError> {
1394                unreachable!()
1395            }
1396            async fn select_nar_window(
1397                &self,
1398                _k: &str,
1399                _o: i64,
1400                _l: i32,
1401            ) -> Result<Option<(Vec<u8>, i64)>, StoreError> {
1402                unreachable!()
1403            }
1404            async fn ensure_schema(&self) -> Result<(), StoreError> {
1405                panic!("a non-schema error must never trigger the DDL path");
1406            }
1407        }
1408        let backend = PgStorageBackend::new(BrokenPg);
1409        assert!(matches!(backend.get_narinfo("h").await.unwrap_err(), StoreError::Io(_)));
1410    }
1411
1412    #[tokio::test]
1413    async fn invalid_utf8_narinfo_surfaces_typed_error() {
1414        let mock = MockPg::default();
1415        mock.narinfo.lock().unwrap().insert("bad".to_string(), vec![0xff, 0xfe, 0xfd]);
1416        let backend = PgStorageBackend::new(mock);
1417        let err = backend.get_narinfo("bad").await.unwrap_err();
1418        assert!(matches!(err, StoreError::NarInfo(_)));
1419    }
1420
1421    // ── Fault injection ────────────────────────────────────────────────
1422    //
1423    // Every mock above is INFALLIBLE: `MockPg` returns `Ok` from all five
1424    // trait methods, so no test in this file could observe what the backend
1425    // does when Postgres refuses. That is not a small gap — it is why the
1426    // 2026-07-26 camelot outage class was invisible to a green suite.
1427    //
1428    // What happened: the `sui-cache-pg` pod was rescheduled at 19:00:06Z with
1429    // `pgdata: emptyDir`, so its database came up EMPTY. sui had connected
1430    // ~24h earlier (pod start 2026-07-25T18:23:55Z) and `ensure_schema` is
1431    // called from exactly ONE place — inside `connect` — and never again. So
1432    // the process held a live pool to a blank database and every read hit
1433    // `relation "sui_cache_narinfo" does not exist`. `get_narinfo` propagated
1434    // that with `?`, the HTTP layer mapped `Err` to 500, and Nix treats a 500
1435    // from a substituter as a HARD FAILURE rather than a cache miss — so every
1436    // Nix build on the cluster failed.
1437    //
1438    // Two separable defects, and these tests pin the boundary between them:
1439    //   1. STORAGE CONTRACT (here): a backend fault must surface truthfully as
1440    //      `Err`, and must remain DISTINGUISHABLE from `Ok(None)`. Collapsing
1441    //      them here would make the storage layer lie, and a genuinely broken
1442    //      cache would then look permanently empty with no signal anywhere.
1443    //   2. PROTOCOL SEMANTICS (sui-cache/src/server.rs): for a *substituter*,
1444    //      "I cannot answer" and "I do not have it" are the same answer to the
1445    //      client — both mean "build it yourself". That collapse belongs at the
1446    //      HTTP boundary, where it is a deliberate protocol decision, NOT in
1447    //      the storage layer where it would be data loss dressed as resilience.
1448    //
1449    // So these tests deliberately assert that the storage layer KEEPS erroring.
1450    // The degradation is tested on the server side.
1451
1452    /// A [`PgCacheConn`] that fails after `ok_calls` successful selects,
1453    /// reproducing the incident's timeline (works, then the schema vanishes
1454    /// underneath a live pool) rather than a backend that was never healthy.
1455    struct FaultyPg {
1456        inner:     MockPg,
1457        ok_calls:  Mutex<usize>,
1458        fail_with: String,
1459    }
1460
1461    impl FaultyPg {
1462        /// Fails every call — a backend that is broken from the start.
1463        fn always(msg: &str) -> Self {
1464            Self {
1465                inner:     MockPg::default(),
1466                ok_calls:  Mutex::new(0),
1467                fail_with: msg.to_string(),
1468            }
1469        }
1470
1471        /// Serves `n` calls normally, then fails every call after — the
1472        /// schema-vanished-under-a-live-pool shape.
1473        fn after(n: usize, msg: &str) -> Self {
1474            Self {
1475                inner:     MockPg::default(),
1476                ok_calls:  Mutex::new(n),
1477                fail_with: msg.to_string(),
1478            }
1479        }
1480
1481        /// Consume one budgeted success, or fail. Returns `Err` once spent.
1482        fn tick(&self) -> Result<(), StoreError> {
1483            let mut left = self.ok_calls.lock().unwrap();
1484            if *left == 0 {
1485                return Err(StoreError::Io(std::io::Error::other(format!(
1486                    "postgres: {}",
1487                    self.fail_with
1488                ))));
1489            }
1490            *left -= 1;
1491            Ok(())
1492        }
1493    }
1494
1495    /// The exact string Postgres returns for the missing relation, so the
1496    /// fixture cannot drift from the incident it encodes.
1497    const RELATION_MISSING: &str = "error returned from database: \
1498                                    relation \"sui_cache_narinfo\" does not exist";
1499
1500    #[async_trait]
1501    impl PgCacheConn for FaultyPg {
1502        async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
1503            self.tick()?;
1504            self.inner.select(table, key).await
1505        }
1506        async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), StoreError> {
1507            self.tick()?;
1508            self.inner.upsert(table, key, value).await
1509        }
1510        async fn delete(&self, table: PgTable, key: &str) -> Result<(), StoreError> {
1511            self.tick()?;
1512            self.inner.delete(table, key).await
1513        }
1514        async fn keys(&self, table: PgTable) -> Result<Vec<String>, StoreError> {
1515            self.tick()?;
1516            self.inner.keys(table).await
1517        }
1518        async fn keys_with_prefix(
1519            &self,
1520            table: PgTable,
1521            prefix: &str,
1522        ) -> Result<Vec<String>, StoreError> {
1523            self.tick()?;
1524            self.inner.keys_with_prefix(table, prefix).await
1525        }
1526        async fn clear(&self, table: PgTable) -> Result<u64, StoreError> {
1527            self.tick()?;
1528            self.inner.clear(table).await
1529        }
1530        async fn upsert_nar_chunk(&self, key: &str, seq: i32, value: &[u8]) -> Result<(), StoreError> {
1531            self.tick()?;
1532            self.inner.upsert_nar_chunk(key, seq, value).await
1533        }
1534        async fn select_nar_chunk(&self, key: &str, seq: i32) -> Result<Option<Vec<u8>>, StoreError> {
1535            self.tick()?;
1536            self.inner.select_nar_chunk(key, seq).await
1537        }
1538        async fn delete_nar_chunks(&self, key: &str) -> Result<(), StoreError> {
1539            self.tick()?;
1540            self.inner.delete_nar_chunks(key).await
1541        }
1542        async fn clear_nar_chunks(&self) -> Result<u64, StoreError> {
1543            self.tick()?;
1544            self.inner.clear_nar_chunks().await
1545        }
1546        async fn select_nar_window(
1547            &self,
1548            key: &str,
1549            offset: i64,
1550            len: i32,
1551        ) -> Result<Option<(Vec<u8>, i64)>, StoreError> {
1552            self.tick()?;
1553            self.inner.select_nar_window(key, offset, len).await
1554        }
1555    }
1556
1557    /// THE INCIDENT, as a test. A backend fault must NOT be reported as a miss
1558    /// by the storage layer — the two must stay distinguishable, because the
1559    /// caller's correct response differs (a miss means "not cached"; a fault
1560    /// means "this cache is broken, page someone").
1561    #[tokio::test]
1562    async fn backend_fault_is_an_error_never_a_silent_miss() {
1563        let backend = PgStorageBackend::new(FaultyPg::always(RELATION_MISSING));
1564        let err = backend.get_narinfo("abc").await.unwrap_err();
1565        assert!(
1566            err.to_string().contains("does not exist"),
1567            "the underlying cause must survive to the caller, got: {err}"
1568        );
1569
1570        // The discriminating assertion: a HEALTHY backend with no such key
1571        // returns Ok(None). If a fault also returned Ok(None), these two would
1572        // be indistinguishable and a broken cache would masquerade as an empty
1573        // one — invisible, permanently.
1574        let healthy = PgStorageBackend::new(MockPg::default());
1575        assert!(healthy.get_narinfo("abc").await.unwrap().is_none());
1576    }
1577
1578    /// Writes must not silently succeed against a broken backend — a swallowed
1579    /// `put` would report a populated cache that holds nothing.
1580    #[tokio::test]
1581    async fn backend_fault_on_write_is_an_error() {
1582        let backend = PgStorageBackend::new(FaultyPg::always(RELATION_MISSING));
1583        assert!(backend.put_narinfo("h", NARINFO).await.is_err());
1584        assert!(backend.put_nar("nar/h.nar.xz", b"bytes").await.is_err());
1585    }
1586
1587    /// Every read path, not just narinfo — parity matters because `get_nar`
1588    /// serves the actual build artifacts and has its own code path.
1589    #[tokio::test]
1590    async fn every_read_path_propagates_a_backend_fault() {
1591        let backend = PgStorageBackend::new(FaultyPg::always(RELATION_MISSING));
1592        assert!(backend.get_narinfo("h").await.is_err(), "get_narinfo");
1593        assert!(backend.get_nar("nar/h.nar.xz").await.is_err(), "get_nar");
1594        assert!(backend.list_narinfos().await.is_err(), "list_narinfos");
1595        assert!(backend.delete("h").await.is_err(), "delete");
1596    }
1597
1598    /// Concurrency: parallel writers must not lose writes. The mock is
1599    /// `Mutex`-guarded per table, so this pins the backend's own key handling
1600    /// rather than the DB's — a regression that mangled keys (e.g. a shared
1601    /// buffer) would show up as a count mismatch.
1602    #[tokio::test]
1603    async fn concurrent_puts_do_not_lose_writes() {
1604        use std::sync::Arc;
1605        let backend = Arc::new(PgStorageBackend::new(MockPg::default()));
1606        let mut set = tokio::task::JoinSet::new();
1607        for i in 0..64 {
1608            let b = Arc::clone(&backend);
1609            set.spawn(async move { b.put_narinfo(&format!("k{i}"), &format!("v{i}")).await });
1610        }
1611        while let Some(r) = set.join_next().await {
1612            r.expect("task panicked").expect("put failed");
1613        }
1614        assert_eq!(backend.list_narinfos().await.unwrap().len(), 64);
1615        for i in 0..64 {
1616            assert_eq!(
1617                backend.get_narinfo(&format!("k{i}")).await.unwrap().unwrap(),
1618                format!("v{i}"),
1619                "key k{i} round-tripped wrong under concurrency"
1620            );
1621        }
1622    }
1623
1624    /// Idempotence under repeat: a content-addressed cache re-`put`s the same
1625    /// key with identical bytes constantly, and that must be a no-op, not a
1626    /// duplicate or an error.
1627    #[tokio::test]
1628    async fn repeated_identical_put_is_idempotent() {
1629        let backend = PgStorageBackend::new(MockPg::default());
1630        for _ in 0..10 {
1631            backend.put_narinfo("same", NARINFO).await.unwrap();
1632        }
1633        assert_eq!(backend.list_narinfos().await.unwrap().len(), 1);
1634        assert_eq!(backend.get_narinfo("same").await.unwrap().unwrap(), NARINFO);
1635    }
1636
1637    // ── chunked NAR storage (the 12.712 s resident INSERT, removed) ────────
1638    //
1639    // The whole-value `bind` held the entire NAR in this process's heap for the
1640    // duration of the statement. These pin the replacement: bounded rows, a
1641    // completeness marker so a killed process cannot publish a truncated NAR,
1642    // and a windowed read for the rows the previous build already wrote.
1643
1644    const NAR_KEY: &str = "nar/deadbeef.nar.xz";
1645
1646    /// A NAR spanning more than one chunk, with position-derived bytes so a
1647    /// reordered or spliced reassembly fails on content, not just on length.
1648    fn multi_chunk_nar() -> Vec<u8> {
1649        (0..NAR_CHUNK_BYTES * 2 + 4096).map(|i| (i % 251) as u8).collect()
1650    }
1651
1652    #[tokio::test]
1653    async fn a_nar_is_stored_as_bounded_chunks_never_one_whole_row() {
1654        let backend = PgStorageBackend::new(MockPg::default());
1655        let nar = multi_chunk_nar();
1656        backend.put_nar(NAR_KEY, &nar).await.unwrap();
1657
1658        // 3 data chunks + 1 marker. If this is ever 1, the whole-value bind is
1659        // back and so is the OOM.
1660        assert_eq!(backend.conn().chunk_rows(), 4, "expected 3 chunks + a marker");
1661        // …and nothing landed in the legacy whole-value table.
1662        assert!(
1663            backend.conn().nar.lock().unwrap().is_empty(),
1664            "a streamed write must not also write the legacy whole-value row",
1665        );
1666    }
1667
1668    #[tokio::test]
1669    async fn a_chunked_nar_round_trips_byte_identically() {
1670        let backend = PgStorageBackend::new(MockPg::default());
1671        let nar = multi_chunk_nar();
1672        backend.put_nar(NAR_KEY, &nar).await.unwrap();
1673        assert_eq!(backend.get_nar(NAR_KEY).await.unwrap().unwrap(), nar);
1674    }
1675
1676    #[tokio::test]
1677    async fn an_empty_nar_round_trips_as_empty_not_as_a_miss() {
1678        // Degenerate but reachable: zero chunks plus a marker of 0.
1679        let backend = PgStorageBackend::new(MockPg::default());
1680        backend.put_nar(NAR_KEY, b"").await.unwrap();
1681        assert_eq!(backend.get_nar(NAR_KEY).await.unwrap().unwrap(), Vec::<u8>::new());
1682    }
1683
1684    #[tokio::test]
1685    async fn a_write_killed_before_the_marker_reads_as_a_miss_not_a_truncated_nar() {
1686        // THE reason the marker exists. A whole-value INSERT was atomic for
1687        // free; N chunk inserts are not, and this process's defining failure
1688        // mode is being killed mid-write. Chunks with no marker must be
1689        // invisible: a client that gets a miss rebuilds, a client that gets
1690        // half a NAR is silently corrupted.
1691        let backend = PgStorageBackend::new(MockPg::default());
1692        backend.conn().upsert_nar_chunk(NAR_KEY, 0, b"first half").await.unwrap();
1693        backend.conn().upsert_nar_chunk(NAR_KEY, 1, b"second half").await.unwrap();
1694        // No marker written — the process died here.
1695        assert!(
1696            backend.get_nar(NAR_KEY).await.unwrap().is_none(),
1697            "orphan chunks must not be servable",
1698        );
1699    }
1700
1701    #[tokio::test]
1702    async fn a_gap_under_a_published_marker_is_an_error_never_a_short_read() {
1703        // The marker is a promise that N chunks exist. If one is gone, the
1704        // honest answer is a fault — returning the bytes that remain would hand
1705        // the client a truncated NAR under a valid-looking response.
1706        let backend = PgStorageBackend::new(MockPg::default());
1707        backend.conn().upsert_nar_chunk(NAR_KEY, 0, b"present").await.unwrap();
1708        backend.conn().upsert_nar_chunk(NAR_KEY, CHUNK_MARKER_SEQ, &encode_marker(2)).await.unwrap();
1709        let err = backend.get_nar(NAR_KEY).await.unwrap_err();
1710        assert!(
1711            err.to_string().contains("missing"),
1712            "expected a typed corruption error, got: {err}",
1713        );
1714    }
1715
1716    #[tokio::test]
1717    async fn a_corrupt_marker_surfaces_rather_than_being_coerced() {
1718        let backend = PgStorageBackend::new(MockPg::default());
1719        backend.conn().upsert_nar_chunk(NAR_KEY, CHUNK_MARKER_SEQ, b"nope").await.unwrap();
1720        assert!(matches!(
1721            backend.get_nar(NAR_KEY).await.unwrap_err(),
1722            StoreError::NarInfo(_),
1723        ));
1724    }
1725
1726    #[tokio::test]
1727    async fn re_putting_a_shorter_nar_leaves_no_stale_tail() {
1728        // Chunks are keyed by (key, seq), so a shorter re-put would otherwise
1729        // leave the old high-seq rows behind — and the new marker would not
1730        // reach them, but a LATER longer re-put would splice them in.
1731        let backend = PgStorageBackend::new(MockPg::default());
1732        backend.put_nar(NAR_KEY, &multi_chunk_nar()).await.unwrap();
1733        backend.put_nar(NAR_KEY, b"short").await.unwrap();
1734        assert_eq!(backend.get_nar(NAR_KEY).await.unwrap().unwrap(), b"short");
1735        assert_eq!(backend.conn().chunk_rows(), 2, "1 chunk + a marker; the tail is gone");
1736    }
1737
1738    #[tokio::test]
1739    async fn a_legacy_whole_value_row_is_still_served_windowed() {
1740        // Every NAR already in the production database is a whole-value row.
1741        // They must keep serving — and must be read back in bounded windows
1742        // rather than materialized, or the read path stays unbounded until the
1743        // cache happens to turn over.
1744        let backend = PgStorageBackend::new(MockPg::default());
1745        let legacy = multi_chunk_nar();
1746        backend.conn().seed_legacy_nar(NAR_KEY, &legacy);
1747        assert_eq!(backend.conn().chunk_rows(), 0, "the fixture is pre-streaming by construction");
1748        assert_eq!(backend.get_nar(NAR_KEY).await.unwrap().unwrap(), legacy);
1749    }
1750
1751    #[tokio::test]
1752    async fn a_chunked_write_shadows_a_legacy_row_for_the_same_key() {
1753        // Both generations can exist for one key only transiently. A re-put
1754        // must drop the legacy row, or a later rollback-era reader would find
1755        // stale bytes under a live marker.
1756        let backend = PgStorageBackend::new(MockPg::default());
1757        backend.conn().seed_legacy_nar(NAR_KEY, b"old whole-value bytes");
1758        backend.put_nar(NAR_KEY, b"new chunked bytes").await.unwrap();
1759        assert_eq!(backend.get_nar(NAR_KEY).await.unwrap().unwrap(), b"new chunked bytes");
1760        assert!(
1761            backend.conn().nar.lock().unwrap().is_empty(),
1762            "the legacy row must be dropped, not shadowed",
1763        );
1764    }
1765
1766    /// One key can exist as a legacy whole-value row *and* as chunk rows.
1767    /// Dropping only one leaves the other readable, so a "deleted" NAR would
1768    /// keep serving.
1769    #[tokio::test]
1770    async fn delete_clears_both_storage_generations() {
1771        let backend = PgStorageBackend::new(MockPg::default());
1772        let key = "nar/xyz.nar.xz";
1773        backend.put_narinfo("storehash", &narinfo_for(key)).await.unwrap();
1774        backend.put_nar(key, b"chunked").await.unwrap();
1775        backend.conn().seed_legacy_nar(key, b"legacy");
1776
1777        backend.delete("storehash").await.unwrap();
1778
1779        assert!(backend.get_nar(key).await.unwrap().is_none());
1780        assert!(backend.conn().nar.lock().unwrap().is_empty(), "the legacy row must go too");
1781        assert_eq!(backend.conn().chunk_rows(), 0);
1782    }
1783
1784    #[tokio::test]
1785    async fn wipe_all_reclaims_the_chunk_table_too() {
1786        let backend = PgStorageBackend::new(MockPg::default());
1787        backend.put_narinfo("h", NARINFO).await.unwrap();
1788        backend.put_nar(NAR_KEY, &multi_chunk_nar()).await.unwrap();
1789        assert!(backend.conn().chunk_rows() > 1);
1790        assert_eq!(backend.wipe_all().await.unwrap(), 1);
1791        assert_eq!(backend.conn().chunk_rows(), 0, "wipe must reach the chunk table");
1792        assert!(backend.get_nar(NAR_KEY).await.unwrap().is_none());
1793    }
1794
1795    #[tokio::test]
1796    async fn the_chunked_write_path_self_heals_a_vanished_schema() {
1797        // The 2026-07-26 outage shape, on the new verbs: the write must repair
1798        // the schema and land, not error until someone restarts the process.
1799        let backend = PgStorageBackend::new(MockPg::default());
1800        backend.conn().drop_schema();
1801        backend.put_nar(NAR_KEY, b"bytes").await.expect("put_nar self-heals");
1802        assert_eq!(backend.get_nar(NAR_KEY).await.unwrap().unwrap(), b"bytes");
1803    }
1804
1805    #[tokio::test]
1806    async fn a_backend_fault_partway_through_a_chunked_read_surfaces() {
1807        // `FaultyPg::after` serves n calls then fails — the shape of a pool that
1808        // dies mid-transfer. The stream must end in an error, never quietly
1809        // yield the prefix it managed to read.
1810        let backend = PgStorageBackend::new(FaultyPg::after(6, RELATION_MISSING));
1811        // 3 chunk upserts + 1 marker + 2 spare = the write consumes the budget.
1812        let err = backend.put_nar(NAR_KEY, &multi_chunk_nar()).await;
1813        // Either the write itself trips the budget or the following read does;
1814        // what must never happen is a silent success with missing bytes.
1815        if err.is_ok() {
1816            assert!(backend.get_nar(NAR_KEY).await.is_err(), "a mid-read fault must surface");
1817        }
1818    }
1819
1820    #[tokio::test]
1821    async fn residency_is_streaming() {
1822        let backend = PgStorageBackend::new(MockPg::default());
1823        assert_eq!(backend.nar_residency(), NarResidency::Streaming);
1824    }
1825}