use async_trait::async_trait;
use super::StorageBackend;
use crate::CacheError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PgTable {
Narinfo,
Nar,
}
impl PgTable {
#[must_use]
pub const fn table_name(self) -> &'static str {
match self {
PgTable::Narinfo => "sui_cache_narinfo",
PgTable::Nar => "sui_cache_nar",
}
}
}
#[async_trait]
pub trait PgCacheConn: Send + Sync {
async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), CacheError>;
async fn delete(&self, table: PgTable, key: &str) -> Result<(), CacheError>;
async fn keys(&self, table: PgTable) -> Result<Vec<String>, CacheError>;
async fn clear(&self, table: PgTable) -> Result<u64, CacheError>;
}
pub struct PgStorageBackend<C: PgCacheConn> {
conn: C,
}
impl<C: PgCacheConn> PgStorageBackend<C> {
pub fn new(conn: C) -> Self {
Self { conn }
}
pub fn conn(&self) -> &C {
&self.conn
}
}
#[async_trait]
impl<C: PgCacheConn> StorageBackend for PgStorageBackend<C> {
async fn get_narinfo(&self, hash: &str) -> Result<Option<String>, CacheError> {
match self.conn.select(PgTable::Narinfo, hash).await? {
Some(bytes) => {
let text = String::from_utf8(bytes).map_err(|e| {
CacheError::NarInfo(format!("invalid utf-8 in pg narinfo {hash}: {e}"))
})?;
Ok(Some(text))
}
None => Ok(None),
}
}
async fn put_narinfo(&self, hash: &str, content: &str) -> Result<(), CacheError> {
self.conn.upsert(PgTable::Narinfo, hash, content.as_bytes()).await
}
async fn get_nar(&self, path: &str) -> Result<Option<Vec<u8>>, CacheError> {
self.conn.select(PgTable::Nar, path).await
}
async fn put_nar(&self, path: &str, data: &[u8]) -> Result<(), CacheError> {
self.conn.upsert(PgTable::Nar, path, data).await
}
async fn delete(&self, hash: &str) -> Result<(), CacheError> {
self.conn.delete(PgTable::Narinfo, hash).await?;
for ext in ["nar.xz", "nar.zst", "nar"] {
self.conn.delete(PgTable::Nar, &format!("nar/{hash}.{ext}")).await?;
}
Ok(())
}
async fn list_narinfos(&self) -> Result<Vec<String>, CacheError> {
self.conn.keys(PgTable::Narinfo).await
}
async fn wipe_all(&self) -> Result<usize, CacheError> {
let narinfos = self.conn.clear(PgTable::Narinfo).await? as usize;
self.conn.clear(PgTable::Nar).await?;
Ok(narinfos)
}
}
#[cfg(feature = "postgres")]
mod sqlx_conn {
use super::{CacheError, PgCacheConn, PgStorageBackend, PgTable};
use async_trait::async_trait;
use sqlx::postgres::{PgPool, PgPoolOptions};
use sqlx::Row;
fn to_cache_err(e: sqlx::Error) -> CacheError {
CacheError::Io(std::io::Error::other(format!("postgres: {e}")))
}
impl PgTable {
const fn ddl(self) -> &'static str {
match self {
PgTable::Narinfo => {
"CREATE TABLE IF NOT EXISTS sui_cache_narinfo (key TEXT PRIMARY KEY, value BYTEA NOT NULL)"
}
PgTable::Nar => {
"CREATE TABLE IF NOT EXISTS sui_cache_nar (key TEXT PRIMARY KEY, value BYTEA NOT NULL)"
}
}
}
const fn select_sql(self) -> &'static str {
match self {
PgTable::Narinfo => "SELECT value FROM sui_cache_narinfo WHERE key = $1",
PgTable::Nar => "SELECT value FROM sui_cache_nar WHERE key = $1",
}
}
const fn upsert_sql(self) -> &'static str {
match self {
PgTable::Narinfo => {
"INSERT INTO sui_cache_narinfo (key, value) VALUES ($1, $2) \
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
}
PgTable::Nar => {
"INSERT INTO sui_cache_nar (key, value) VALUES ($1, $2) \
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
}
}
}
const fn delete_sql(self) -> &'static str {
match self {
PgTable::Narinfo => "DELETE FROM sui_cache_narinfo WHERE key = $1",
PgTable::Nar => "DELETE FROM sui_cache_nar WHERE key = $1",
}
}
const fn clear_sql(self) -> &'static str {
match self {
PgTable::Narinfo => "DELETE FROM sui_cache_narinfo",
PgTable::Nar => "DELETE FROM sui_cache_nar",
}
}
const fn keys_sql(self) -> &'static str {
match self {
PgTable::Narinfo => "SELECT key FROM sui_cache_narinfo",
PgTable::Nar => "SELECT key FROM sui_cache_nar",
}
}
}
pub struct SqlxPgCacheConn {
pool: PgPool,
}
impl SqlxPgCacheConn {
pub async fn connect(url: &str, max_conns: u32) -> Result<Self, CacheError> {
let pool = PgPoolOptions::new()
.max_connections(max_conns)
.connect(url)
.await
.map_err(to_cache_err)?;
let this = Self { pool };
this.ensure_schema().await?;
Ok(this)
}
async fn ensure_schema(&self) -> Result<(), CacheError> {
for t in [PgTable::Narinfo, PgTable::Nar] {
sqlx::query(t.ddl()).execute(&self.pool).await.map_err(to_cache_err)?;
}
Ok(())
}
}
#[async_trait]
impl PgCacheConn for SqlxPgCacheConn {
async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
let row = sqlx::query(table.select_sql())
.bind(key)
.fetch_optional(&self.pool)
.await
.map_err(to_cache_err)?;
match row {
Some(r) => {
let v: Vec<u8> = r.try_get("value").map_err(to_cache_err)?;
Ok(Some(v))
}
None => Ok(None),
}
}
async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), CacheError> {
sqlx::query(table.upsert_sql())
.bind(key)
.bind(value)
.execute(&self.pool)
.await
.map_err(to_cache_err)?;
Ok(())
}
async fn delete(&self, table: PgTable, key: &str) -> Result<(), CacheError> {
sqlx::query(table.delete_sql())
.bind(key)
.execute(&self.pool)
.await
.map_err(to_cache_err)?;
Ok(())
}
async fn keys(&self, table: PgTable) -> Result<Vec<String>, CacheError> {
let rows = sqlx::query(table.keys_sql())
.fetch_all(&self.pool)
.await
.map_err(to_cache_err)?;
rows.into_iter()
.map(|r| r.try_get::<String, _>("key").map_err(to_cache_err))
.collect()
}
async fn clear(&self, table: PgTable) -> Result<u64, CacheError> {
let res = sqlx::query(table.clear_sql())
.execute(&self.pool)
.await
.map_err(to_cache_err)?;
Ok(res.rows_affected())
}
}
impl PgStorageBackend<SqlxPgCacheConn> {
pub async fn connect(url: &str, max_conns: u32) -> Result<Self, CacheError> {
Ok(Self::new(SqlxPgCacheConn::connect(url, max_conns).await?))
}
}
}
#[cfg(feature = "postgres")]
pub use sqlx_conn::SqlxPgCacheConn;
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
struct MockPg {
narinfo: Mutex<HashMap<String, Vec<u8>>>,
nar: Mutex<HashMap<String, Vec<u8>>>,
}
impl MockPg {
fn table(&self, t: PgTable) -> &Mutex<HashMap<String, Vec<u8>>> {
match t {
PgTable::Narinfo => &self.narinfo,
PgTable::Nar => &self.nar,
}
}
}
#[async_trait]
impl PgCacheConn for MockPg {
async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
Ok(self.table(table).lock().unwrap().get(key).cloned())
}
async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), CacheError> {
self.table(table).lock().unwrap().insert(key.to_string(), value.to_vec());
Ok(())
}
async fn delete(&self, table: PgTable, key: &str) -> Result<(), CacheError> {
self.table(table).lock().unwrap().remove(key);
Ok(())
}
async fn keys(&self, table: PgTable) -> Result<Vec<String>, CacheError> {
Ok(self.table(table).lock().unwrap().keys().cloned().collect())
}
async fn clear(&self, table: PgTable) -> Result<u64, CacheError> {
let mut m = self.table(table).lock().unwrap();
let n = m.len() as u64;
m.clear();
Ok(n)
}
}
const NARINFO: &str = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
#[test]
fn table_names_are_distinct() {
assert_ne!(PgTable::Narinfo.table_name(), PgTable::Nar.table_name());
}
#[tokio::test]
async fn get_missing_narinfo_returns_none() {
let backend = PgStorageBackend::new(MockPg::default());
assert!(backend.get_narinfo("nope").await.unwrap().is_none());
}
#[tokio::test]
async fn put_then_get_narinfo_roundtrips() {
let backend = PgStorageBackend::new(MockPg::default());
backend.put_narinfo("abc", NARINFO).await.unwrap();
assert_eq!(backend.get_narinfo("abc").await.unwrap().unwrap(), NARINFO);
}
#[tokio::test]
async fn put_then_get_nar_roundtrips() {
let backend = PgStorageBackend::new(MockPg::default());
let data = b"\x00\x01\x02 fake nar bytes";
backend.put_nar("nar/abc.nar.xz", data).await.unwrap();
assert_eq!(backend.get_nar("nar/abc.nar.xz").await.unwrap().unwrap(), data);
}
#[tokio::test]
async fn narinfo_and_nar_keyspaces_do_not_collide() {
let backend = PgStorageBackend::new(MockPg::default());
backend.put_narinfo("dead", "the-narinfo").await.unwrap();
backend.put_nar("dead", b"the-nar").await.unwrap();
assert_eq!(backend.get_narinfo("dead").await.unwrap().unwrap(), "the-narinfo");
assert_eq!(backend.get_nar("dead").await.unwrap().unwrap(), b"the-nar");
}
#[tokio::test]
async fn delete_removes_narinfo_and_common_nar_patterns() {
let backend = PgStorageBackend::new(MockPg::default());
backend.put_narinfo("xyz", NARINFO).await.unwrap();
backend.put_nar("nar/xyz.nar.xz", b"nar-xz").await.unwrap();
backend.put_nar("nar/xyz.nar.zst", b"nar-zst").await.unwrap();
backend.put_nar("nar/xyz.nar", b"nar-plain").await.unwrap();
backend.delete("xyz").await.unwrap();
assert!(backend.get_narinfo("xyz").await.unwrap().is_none());
assert!(backend.get_nar("nar/xyz.nar.xz").await.unwrap().is_none());
assert!(backend.get_nar("nar/xyz.nar.zst").await.unwrap().is_none());
assert!(backend.get_nar("nar/xyz.nar").await.unwrap().is_none());
}
#[tokio::test]
async fn wipe_all_truncates_both_tables_incl_narhash_keyed_nar() {
let backend = PgStorageBackend::new(MockPg::default());
backend.put_narinfo("storehash", NARINFO).await.unwrap();
backend.put_nar("nar/0narhashXXXXXXXXXXXXXXXXXXXXXXXXXX.nar", b"blob").await.unwrap();
backend.put_narinfo("other", NARINFO).await.unwrap();
let removed = backend.wipe_all().await.unwrap();
assert_eq!(removed, 2, "wipe_all should report the narinfo count");
assert!(backend.list_narinfos().await.unwrap().is_empty());
assert!(backend.get_narinfo("storehash").await.unwrap().is_none());
assert!(backend.get_narinfo("other").await.unwrap().is_none());
assert!(backend
.get_nar("nar/0narhashXXXXXXXXXXXXXXXXXXXXXXXXXX.nar")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn delete_absent_is_idempotent() {
let backend = PgStorageBackend::new(MockPg::default());
backend.delete("ghost").await.unwrap();
}
#[tokio::test]
async fn list_narinfos_is_authoritative_and_full() {
let backend = PgStorageBackend::new(MockPg::default());
backend.put_narinfo("aaa", "1").await.unwrap();
backend.put_narinfo("bbb", "2").await.unwrap();
backend.put_nar("nar/ccc.nar.xz", b"3").await.unwrap();
let mut hashes = backend.list_narinfos().await.unwrap();
hashes.sort();
assert_eq!(hashes, vec!["aaa".to_string(), "bbb".to_string()]);
}
#[tokio::test]
async fn overwrite_narinfo_takes_latest() {
let backend = PgStorageBackend::new(MockPg::default());
backend.put_narinfo("h", "v1").await.unwrap();
backend.put_narinfo("h", "v2").await.unwrap();
assert_eq!(backend.get_narinfo("h").await.unwrap().unwrap(), "v2");
}
#[tokio::test]
async fn invalid_utf8_narinfo_surfaces_typed_error() {
let mock = MockPg::default();
mock.narinfo.lock().unwrap().insert("bad".to_string(), vec![0xff, 0xfe, 0xfd]);
let backend = PgStorageBackend::new(mock);
let err = backend.get_narinfo("bad").await.unwrap_err();
assert!(matches!(err, CacheError::NarInfo(_)));
}
}