1use async_trait::async_trait;
36
37use super::StorageBackend;
38use crate::CacheError;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PgTable {
47 Narinfo,
49 Nar,
51}
52
53impl PgTable {
54 #[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#[async_trait]
72pub trait PgCacheConn: Send + Sync {
73 async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
76
77 async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), CacheError>;
80
81 async fn delete(&self, table: PgTable, key: &str) -> Result<(), CacheError>;
84
85 async fn keys(&self, table: PgTable) -> Result<Vec<String>, CacheError>;
88
89 async fn clear(&self, table: PgTable) -> Result<u64, CacheError>;
93}
94
95pub struct PgStorageBackend<C: PgCacheConn> {
100 conn: C,
101}
102
103impl<C: PgCacheConn> PgStorageBackend<C> {
104 pub fn new(conn: C) -> Self {
106 Self { conn }
107 }
108
109 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 self.conn.delete(PgTable::Narinfo, hash).await?;
144 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 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#[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 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 pub struct SqlxPgCacheConn {
243 pool: PgPool,
244 }
245
246 impl SqlxPgCacheConn {
247 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 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#[cfg(test)]
349mod tests {
350 use super::*;
351 use std::collections::HashMap;
352 use std::sync::Mutex;
353
354 #[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 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 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 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 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}