Skip to main content

sui_cache/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;
36
37use super::StorageBackend;
38use crate::CacheError;
39
40/// The two logical tables the cache tier keeps: narinfo text and NAR blobs.
41///
42/// A typed discriminant (never a stringly-typed table name at a call site) so the
43/// SQL for each table is chosen by an exhaustive `match` — a new table is a
44/// non-exhaustive-match compile error, and a typo'd table name cannot exist.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PgTable {
47    /// narinfo metadata, keyed by the 32-char store-path hash.
48    Narinfo,
49    /// compressed NAR blobs, keyed by relative URL path (`nar/<hash>.nar.xz`).
50    Nar,
51}
52
53impl PgTable {
54    /// The physical table name (for diagnostics / the real adapter's DDL).
55    #[must_use]
56    pub const fn table_name(self) -> &'static str {
57        match self {
58            PgTable::Narinfo => "sui_cache_narinfo",
59            PgTable::Nar => "sui_cache_nar",
60        }
61    }
62}
63
64/// The minimal typed Postgres row-verb surface [`PgStorageBackend`] depends on.
65///
66/// This is the injectable **Environment seam**: a real implementation
67/// ([`SqlxPgCacheConn`], `postgres` feature) talks to a live Postgres pool; tests
68/// substitute an in-memory mock. Keeping the surface this small means the whole L2
69/// mapping is proven against a mock, and the only unmocked code is the thin
70/// SQL translation.
71#[async_trait]
72pub trait PgCacheConn: Send + Sync {
73    /// `SELECT value FROM <table> WHERE key = $1` — raw bytes, or `Ok(None)` on a
74    /// missing row.
75    async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
76
77    /// Upsert (`INSERT … ON CONFLICT (key) DO UPDATE`) — idempotent by key; a
78    /// re-`put` of a content-addressed key overwrites with identical bytes.
79    async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), CacheError>;
80
81    /// `DELETE FROM <table> WHERE key = $1` — idempotent; deleting an absent key
82    /// is `Ok(())`.
83    async fn delete(&self, table: PgTable, key: &str) -> Result<(), CacheError>;
84
85    /// `SELECT key FROM <table>` — the **authoritative** full key set (this is a
86    /// durable tier, not a partial hot cache).
87    async fn keys(&self, table: PgTable) -> Result<Vec<String>, CacheError>;
88
89    /// `DELETE FROM <table>` — clear the whole table, returning the row count
90    /// removed. The typed whole-store wipe primitive (the inverse of a warm
91    /// push); reaches NAR rows a per-key `delete` cannot.
92    async fn clear(&self, table: PgTable) -> Result<u64, CacheError>;
93}
94
95/// L2 durable cache tier: content-addressed key → value over Postgres, shared
96/// across pods, survives a roll.
97///
98/// Generic over the [`PgCacheConn`] seam so it is fully testable against a mock.
99pub struct PgStorageBackend<C: PgCacheConn> {
100    conn: C,
101}
102
103impl<C: PgCacheConn> PgStorageBackend<C> {
104    /// Wrap a [`PgCacheConn`].
105    pub fn new(conn: C) -> Self {
106        Self { conn }
107    }
108
109    /// Borrow the underlying connection (for composition / diagnostics).
110    pub fn conn(&self) -> &C {
111        &self.conn
112    }
113}
114
115#[async_trait]
116impl<C: PgCacheConn> StorageBackend for PgStorageBackend<C> {
117    async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, CacheError> {
118        match self.conn.select(PgTable::Narinfo, hash).await? {
119            Some(bytes) => {
120                let text = String::from_utf8(bytes).map_err(|e| {
121                    CacheError::NarInfo(format!("invalid utf-8 in pg narinfo {hash}: {e}"))
122                })?;
123                Ok(Some(text))
124            }
125            None => Ok(None),
126        }
127    }
128
129    async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError> {
130        self.conn.upsert(PgTable::Narinfo, hash, content.as_bytes()).await
131    }
132
133    async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError> {
134        self.conn.select(PgTable::Nar, path).await
135    }
136
137    async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError> {
138        self.conn.upsert(PgTable::Nar, path, data).await
139    }
140
141    async fn delete(&self, hash: &str) -> Result<(), CacheError> {
142        // narinfo keyed directly by hash.
143        self.conn.delete(PgTable::Narinfo, hash).await?;
144        // NAR blobs are keyed by relative URL; only the hash is in hand here, so —
145        // mirroring `RedisBackend`/`S3Storage::delete` — best-effort-delete the
146        // common NAR path patterns. `delete` is idempotent, so absent keys are
147        // harmless.
148        for ext in ["nar.xz", "nar.zst", "nar"] {
149            self.conn.delete(PgTable::Nar, &format!("nar/{hash}.{ext}")).await?;
150        }
151        Ok(())
152    }
153
154    async fn list_narinfos(&self) -> Result<Vec<String>, CacheError> {
155        self.conn.keys(PgTable::Narinfo).await
156    }
157
158    /// Complete L2 wipe: truncate BOTH the narinfo and NAR tables. Unlike the
159    /// per-hash `delete`, this reclaims NAR rows (keyed by narhash, unreachable
160    /// from a store-path hash). Returns the narinfo row count removed.
161    async fn wipe_all(&self) -> Result<usize, CacheError> {
162        let narinfos = self.conn.clear(PgTable::Narinfo).await? as usize;
163        self.conn.clear(PgTable::Nar).await?;
164        Ok(narinfos)
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Production transport — real sqlx Postgres pool, gated behind the `postgres`
170// feature so the default build + unit tests pull zero driver surface. The L2
171// mapping above is proven against the in-memory mock; this is the thin SQL layer.
172// ---------------------------------------------------------------------------
173
174#[cfg(feature = "postgres")]
175mod sqlx_conn {
176    use super::{CacheError, PgCacheConn, PgStorageBackend, PgTable};
177    use async_trait::async_trait;
178    use sqlx::postgres::{PgPool, PgPoolOptions};
179    use sqlx::Row;
180
181    fn to_cache_err(e: sqlx::Error) -> CacheError {
182        CacheError::Io(std::io::Error::other(format!("postgres: {e}")))
183    }
184
185    impl PgTable {
186        /// `CREATE TABLE IF NOT EXISTS` DDL — a full static SQL string per arm
187        /// (typed emission: no runtime string assembly of the table name).
188        const fn ddl(self) -> &'static str {
189            match self {
190                PgTable::Narinfo => {
191                    "CREATE TABLE IF NOT EXISTS sui_cache_narinfo (key TEXT PRIMARY KEY, value BYTEA NOT NULL)"
192                }
193                PgTable::Nar => {
194                    "CREATE TABLE IF NOT EXISTS sui_cache_nar (key TEXT PRIMARY KEY, value BYTEA NOT NULL)"
195                }
196            }
197        }
198
199        const fn select_sql(self) -> &'static str {
200            match self {
201                PgTable::Narinfo => "SELECT value FROM sui_cache_narinfo WHERE key = $1",
202                PgTable::Nar => "SELECT value FROM sui_cache_nar WHERE key = $1",
203            }
204        }
205
206        const fn upsert_sql(self) -> &'static str {
207            match self {
208                PgTable::Narinfo => {
209                    "INSERT INTO sui_cache_narinfo (key, value) VALUES ($1, $2) \
210                     ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
211                }
212                PgTable::Nar => {
213                    "INSERT INTO sui_cache_nar (key, value) VALUES ($1, $2) \
214                     ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
215                }
216            }
217        }
218
219        const fn delete_sql(self) -> &'static str {
220            match self {
221                PgTable::Narinfo => "DELETE FROM sui_cache_narinfo WHERE key = $1",
222                PgTable::Nar => "DELETE FROM sui_cache_nar WHERE key = $1",
223            }
224        }
225
226        const fn clear_sql(self) -> &'static str {
227            match self {
228                PgTable::Narinfo => "DELETE FROM sui_cache_narinfo",
229                PgTable::Nar => "DELETE FROM sui_cache_nar",
230            }
231        }
232
233        const fn keys_sql(self) -> &'static str {
234            match self {
235                PgTable::Narinfo => "SELECT key FROM sui_cache_narinfo",
236                PgTable::Nar => "SELECT key FROM sui_cache_nar",
237            }
238        }
239    }
240
241    /// Production [`PgCacheConn`] over a `sqlx` Postgres connection pool.
242    pub struct SqlxPgCacheConn {
243        pool: PgPool,
244    }
245
246    impl SqlxPgCacheConn {
247        /// Connect to `url` (e.g. `postgres://user@postgres.super-cache-ci.svc:5432/sui`),
248        /// bounding the pool at `max_conns`, and ensure the two cache tables exist.
249        ///
250        /// # Errors
251        ///
252        /// Returns [`CacheError::Io`] if the pool cannot be built or the schema
253        /// DDL fails.
254        pub async fn connect(url: &str, max_conns: u32) -> Result<Self, CacheError> {
255            let pool = PgPoolOptions::new()
256                .max_connections(max_conns)
257                .connect(url)
258                .await
259                .map_err(to_cache_err)?;
260            let this = Self { pool };
261            this.ensure_schema().await?;
262            Ok(this)
263        }
264
265        async fn ensure_schema(&self) -> Result<(), CacheError> {
266            for t in [PgTable::Narinfo, PgTable::Nar] {
267                sqlx::query(t.ddl()).execute(&self.pool).await.map_err(to_cache_err)?;
268            }
269            Ok(())
270        }
271    }
272
273    #[async_trait]
274    impl PgCacheConn for SqlxPgCacheConn {
275        async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
276            let row = sqlx::query(table.select_sql())
277                .bind(key)
278                .fetch_optional(&self.pool)
279                .await
280                .map_err(to_cache_err)?;
281            match row {
282                Some(r) => {
283                    let v: Vec<u8> = r.try_get("value").map_err(to_cache_err)?;
284                    Ok(Some(v))
285                }
286                None => Ok(None),
287            }
288        }
289
290        async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), CacheError> {
291            sqlx::query(table.upsert_sql())
292                .bind(key)
293                .bind(value)
294                .execute(&self.pool)
295                .await
296                .map_err(to_cache_err)?;
297            Ok(())
298        }
299
300        async fn delete(&self, table: PgTable, key: &str) -> Result<(), CacheError> {
301            sqlx::query(table.delete_sql())
302                .bind(key)
303                .execute(&self.pool)
304                .await
305                .map_err(to_cache_err)?;
306            Ok(())
307        }
308
309        async fn keys(&self, table: PgTable) -> Result<Vec<String>, CacheError> {
310            let rows = sqlx::query(table.keys_sql())
311                .fetch_all(&self.pool)
312                .await
313                .map_err(to_cache_err)?;
314            rows.into_iter()
315                .map(|r| r.try_get::<String, _>("key").map_err(to_cache_err))
316                .collect()
317        }
318
319        async fn clear(&self, table: PgTable) -> Result<u64, CacheError> {
320            let res = sqlx::query(table.clear_sql())
321                .execute(&self.pool)
322                .await
323                .map_err(to_cache_err)?;
324            Ok(res.rows_affected())
325        }
326    }
327
328    impl PgStorageBackend<SqlxPgCacheConn> {
329        /// Connect an L2 backend to a Postgres `url`, pool-bounded at `max_conns`.
330        ///
331        /// # Errors
332        ///
333        /// Propagates a connection/schema failure from [`SqlxPgCacheConn::connect`].
334        pub async fn connect(url: &str, max_conns: u32) -> Result<Self, CacheError> {
335            Ok(Self::new(SqlxPgCacheConn::connect(url, max_conns).await?))
336        }
337    }
338}
339
340#[cfg(feature = "postgres")]
341pub use sqlx_conn::SqlxPgCacheConn;
342
343// ---------------------------------------------------------------------------
344// Unit tests — the L2 mapping proven against an in-memory mock PgCacheConn.
345// No live Postgres required.
346// ---------------------------------------------------------------------------
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use std::collections::HashMap;
352    use std::sync::Mutex;
353
354    /// In-memory [`PgCacheConn`] mock: a per-table `HashMap`. Durable within the
355    /// process (unlike the Redis mock, there is no `evict`) — this tier is
356    /// authoritative.
357    #[derive(Default)]
358    struct MockPg {
359        narinfo: Mutex<HashMap<String, Vec<u8>>>,
360        nar: Mutex<HashMap<String, Vec<u8>>>,
361    }
362
363    impl MockPg {
364        fn table(&self, t: PgTable) -> &Mutex<HashMap<String, Vec<u8>>> {
365            match t {
366                PgTable::Narinfo => &self.narinfo,
367                PgTable::Nar => &self.nar,
368            }
369        }
370    }
371
372    #[async_trait]
373    impl PgCacheConn for MockPg {
374        async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
375            Ok(self.table(table).lock().unwrap().get(key).cloned())
376        }
377
378        async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), CacheError> {
379            self.table(table).lock().unwrap().insert(key.to_string(), value.to_vec());
380            Ok(())
381        }
382
383        async fn delete(&self, table: PgTable, key: &str) -> Result<(), CacheError> {
384            self.table(table).lock().unwrap().remove(key);
385            Ok(())
386        }
387
388        async fn keys(&self, table: PgTable) -> Result<Vec<String>, CacheError> {
389            Ok(self.table(table).lock().unwrap().keys().cloned().collect())
390        }
391
392        async fn clear(&self, table: PgTable) -> Result<u64, CacheError> {
393            let mut m = self.table(table).lock().unwrap();
394            let n = m.len() as u64;
395            m.clear();
396            Ok(n)
397        }
398    }
399
400    const NARINFO: &str = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
401
402    #[test]
403    fn table_names_are_distinct() {
404        assert_ne!(PgTable::Narinfo.table_name(), PgTable::Nar.table_name());
405    }
406
407    #[tokio::test]
408    async fn get_missing_narinfo_returns_none() {
409        let backend = PgStorageBackend::new(MockPg::default());
410        assert!(backend.get_narinfo("nope").await.unwrap().is_none());
411    }
412
413    #[tokio::test]
414    async fn put_then_get_narinfo_roundtrips() {
415        let backend = PgStorageBackend::new(MockPg::default());
416        backend.put_narinfo("abc", NARINFO).await.unwrap();
417        assert_eq!(backend.get_narinfo("abc").await.unwrap().unwrap(), NARINFO);
418    }
419
420    #[tokio::test]
421    async fn put_then_get_nar_roundtrips() {
422        let backend = PgStorageBackend::new(MockPg::default());
423        let data = b"\x00\x01\x02 fake nar bytes";
424        backend.put_nar("nar/abc.nar.xz", data).await.unwrap();
425        assert_eq!(backend.get_nar("nar/abc.nar.xz").await.unwrap().unwrap(), data);
426    }
427
428    #[tokio::test]
429    async fn narinfo_and_nar_keyspaces_do_not_collide() {
430        // Same bare id used as both a narinfo hash and a nar path fragment: the
431        // two-table split keeps them apart.
432        let backend = PgStorageBackend::new(MockPg::default());
433        backend.put_narinfo("dead", "the-narinfo").await.unwrap();
434        backend.put_nar("dead", b"the-nar").await.unwrap();
435        assert_eq!(backend.get_narinfo("dead").await.unwrap().unwrap(), "the-narinfo");
436        assert_eq!(backend.get_nar("dead").await.unwrap().unwrap(), b"the-nar");
437    }
438
439    #[tokio::test]
440    async fn delete_removes_narinfo_and_common_nar_patterns() {
441        let backend = PgStorageBackend::new(MockPg::default());
442        backend.put_narinfo("xyz", NARINFO).await.unwrap();
443        backend.put_nar("nar/xyz.nar.xz", b"nar-xz").await.unwrap();
444        backend.put_nar("nar/xyz.nar.zst", b"nar-zst").await.unwrap();
445        backend.put_nar("nar/xyz.nar", b"nar-plain").await.unwrap();
446
447        backend.delete("xyz").await.unwrap();
448
449        assert!(backend.get_narinfo("xyz").await.unwrap().is_none());
450        assert!(backend.get_nar("nar/xyz.nar.xz").await.unwrap().is_none());
451        assert!(backend.get_nar("nar/xyz.nar.zst").await.unwrap().is_none());
452        assert!(backend.get_nar("nar/xyz.nar").await.unwrap().is_none());
453    }
454
455    #[tokio::test]
456    async fn wipe_all_truncates_both_tables_incl_narhash_keyed_nar() {
457        let backend = PgStorageBackend::new(MockPg::default());
458        // Real keying: narinfo by store-hash, NAR by a DIFFERENT narhash — the
459        // orphan class a per-hash `delete` cannot reach.
460        backend.put_narinfo("storehash", NARINFO).await.unwrap();
461        backend.put_nar("nar/0narhashXXXXXXXXXXXXXXXXXXXXXXXXXX.nar", b"blob").await.unwrap();
462        backend.put_narinfo("other", NARINFO).await.unwrap();
463
464        let removed = backend.wipe_all().await.unwrap();
465        assert_eq!(removed, 2, "wipe_all should report the narinfo count");
466
467        // Both tables fully cleared — including the narhash-keyed NAR.
468        assert!(backend.list_narinfos().await.unwrap().is_empty());
469        assert!(backend.get_narinfo("storehash").await.unwrap().is_none());
470        assert!(backend.get_narinfo("other").await.unwrap().is_none());
471        assert!(backend
472            .get_nar("nar/0narhashXXXXXXXXXXXXXXXXXXXXXXXXXX.nar")
473            .await
474            .unwrap()
475            .is_none());
476    }
477
478    #[tokio::test]
479    async fn delete_absent_is_idempotent() {
480        let backend = PgStorageBackend::new(MockPg::default());
481        backend.delete("ghost").await.unwrap();
482    }
483
484    #[tokio::test]
485    async fn list_narinfos_is_authoritative_and_full() {
486        let backend = PgStorageBackend::new(MockPg::default());
487        backend.put_narinfo("aaa", "1").await.unwrap();
488        backend.put_narinfo("bbb", "2").await.unwrap();
489        // A NAR write must not leak into the narinfo listing.
490        backend.put_nar("nar/ccc.nar.xz", b"3").await.unwrap();
491        let mut hashes = backend.list_narinfos().await.unwrap();
492        hashes.sort();
493        assert_eq!(hashes, vec!["aaa".to_string(), "bbb".to_string()]);
494    }
495
496    #[tokio::test]
497    async fn overwrite_narinfo_takes_latest() {
498        let backend = PgStorageBackend::new(MockPg::default());
499        backend.put_narinfo("h", "v1").await.unwrap();
500        backend.put_narinfo("h", "v2").await.unwrap();
501        assert_eq!(backend.get_narinfo("h").await.unwrap().unwrap(), "v2");
502    }
503
504    #[tokio::test]
505    async fn invalid_utf8_narinfo_surfaces_typed_error() {
506        let mock = MockPg::default();
507        mock.narinfo.lock().unwrap().insert("bad".to_string(), vec![0xff, 0xfe, 0xfd]);
508        let backend = PgStorageBackend::new(mock);
509        let err = backend.get_narinfo("bad").await.unwrap_err();
510        assert!(matches!(err, CacheError::NarInfo(_)));
511    }
512}