1use 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
44const CHUNK_MARKER_SEQ: i32 = -1;
58
59fn encode_marker(chunks: u64) -> [u8; 8] {
61 chunks.to_le_bytes()
62}
63
64fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum PgTable {
82 Narinfo,
84 Nar,
86 NarRef,
91}
92
93impl PgTable {
94 #[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#[async_trait]
113pub trait PgCacheConn: Send + Sync {
114 async fn select(&self, table: PgTable, key: &str) -> Result<Option<Vec<u8>>, StoreError>;
117
118 async fn upsert(&self, table: PgTable, key: &str, value: &[u8]) -> Result<(), StoreError>;
121
122 async fn delete(&self, table: PgTable, key: &str) -> Result<(), StoreError>;
125
126 async fn keys(&self, table: PgTable) -> Result<Vec<String>, StoreError>;
129
130 async fn keys_with_prefix(
138 &self,
139 table: PgTable,
140 prefix: &str,
141 ) -> Result<Vec<String>, StoreError>;
142
143 async fn clear(&self, table: PgTable) -> Result<u64, StoreError>;
147
148 async fn upsert_nar_chunk(&self, key: &str, seq: i32, value: &[u8]) -> Result<(), StoreError>;
163
164 async fn select_nar_chunk(&self, key: &str, seq: i32) -> Result<Option<Vec<u8>>, StoreError>;
166
167 async fn delete_nar_chunks(&self, key: &str) -> Result<(), StoreError>;
169
170 async fn clear_nar_chunks(&self) -> Result<u64, StoreError>;
173
174 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 async fn ensure_schema(&self) -> Result<(), StoreError> {
208 Ok(())
209 }
210}
211
212pub struct PgStorageBackend<C: PgCacheConn> {
217 conn: std::sync::Arc<C>,
223}
224
225impl<C: PgCacheConn> PgStorageBackend<C> {
226 pub fn new(conn: C) -> Self {
228 Self { conn: std::sync::Arc::new(conn) }
229 }
230
231 pub fn conn(&self) -> &C {
233 &self.conn
234 }
235
236 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 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 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 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 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 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 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 fn nar_residency(&self) -> NarResidency {
378 NarResidency::Streaming
379 }
380
381 async fn get_nar_stream(&self, path: &str) -> Result<Option<NarStream>, StoreError> {
389 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 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 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 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 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#[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
484fn chunk_len() -> i32 {
490 i32::try_from(NAR_CHUNK_BYTES).unwrap_or(i32::MAX)
491}
492
493#[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 const UNDEFINED_TABLE: &str = "42P01";
509
510 fn to_store_err(e: sqlx::Error) -> StoreError {
511 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 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 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 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 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 pub struct SqlxPgCacheConn {
629 pool: PgPool,
630 }
631
632 impl SqlxPgCacheConn {
633 pub async fn connect(url: &str, max_conns: u32) -> Result<Self, StoreError> {
659 let pool = PgPoolOptions::new()
660 .max_connections(max_conns)
661 .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 this.create_tables().await?;
679 Ok(this)
680 }
681
682 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 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 async fn ensure_schema(&self) -> Result<(), StoreError> {
844 self.create_tables().await
845 }
846 }
847
848 impl PgStorageBackend<SqlxPgCacheConn> {
849 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#[cfg(test)]
869mod tests {
870 use super::*;
871 use std::collections::HashMap;
872 use std::sync::Mutex;
873
874 #[derive(Default)]
878 struct MockPg {
879 narinfo: Mutex<HashMap<String, Vec<u8>>>,
880 nar: Mutex<HashMap<String, Vec<u8>>>,
881 nar_ref: Mutex<HashMap<String, Vec<u8>>>,
883 nar_chunk: Mutex<HashMap<(String, i32), Vec<u8>>>,
886 schema_missing: Mutex<bool>,
890 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 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 fn chunk_rows(&self) -> usize {
919 self.nar_chunk.lock().unwrap().len()
920 }
921 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 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 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 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 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 #[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 }
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 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 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 #[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 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 #[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 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 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 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 #[tokio::test]
1257 async fn schema_vanishing_under_a_live_connection_self_heals_on_the_next_read() {
1258 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 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 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 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 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 for_each_verb_self_heals().await;
1306 }
1307
1308 async fn for_each_verb_self_heals() {
1309 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 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 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 let b = PgStorageBackend::new(MockPg::default());
1328 b.conn().drop_schema();
1329 b.delete("h").await.expect("delete self-heals");
1330
1331 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 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 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 struct FaultyPg {
1456 inner: MockPg,
1457 ok_calls: Mutex<usize>,
1458 fail_with: String,
1459 }
1460
1461 impl FaultyPg {
1462 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 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 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 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 #[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 let healthy = PgStorageBackend::new(MockPg::default());
1575 assert!(healthy.get_narinfo("abc").await.unwrap().is_none());
1576 }
1577
1578 #[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 #[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 #[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 #[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 const NAR_KEY: &str = "nar/deadbeef.nar.xz";
1645
1646 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 assert_eq!(backend.conn().chunk_rows(), 4, "expected 3 chunks + a marker");
1661 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 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 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 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 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 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 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 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 #[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 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 let backend = PgStorageBackend::new(FaultyPg::after(6, RELATION_MISSING));
1811 let err = backend.put_nar(NAR_KEY, &multi_chunk_nar()).await;
1813 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}