Skip to main content

sui_graph_store/
store.rs

1//! `GraphStore` — the public entry point.
2//!
3//! ## Wire model
4//!
5//! Two-tier:
6//!
7//! 1. **Blob** — the actual rkyv archive bytes, written to
8//!    `<root>/blobs/<kind>/<aa>/<bb>/<hash>.rkyv`, mmap'd on read.
9//! 2. **Index** — a redb B+ tree at `<root>/index.redb` mapping the
10//!    33-byte key `[kind_tag : u8, hash : 32 bytes]` to an [`IndexEntry`]
11//!    holding bookkeeping (blob length, created-at nanos). The entry is
12//!    bincode-style packed so a single `as` cast lifts it from
13//!    `&[u8]` — no per-row deserialization cost.
14//!
15//! Writes are atomic at the redb commit boundary: a blob is written to
16//! a `.tmp` sibling, fsync'd, renamed into place, *then* the index entry
17//! is inserted in a single redb transaction. A crash anywhere on the
18//! path leaves either no entry (blob may be orphaned, picked up by GC)
19//! or a complete entry pointing at a complete blob — never a half-state.
20//!
21//! Reads take a redb read transaction (cheap MVCC snapshot, doesn't
22//! block writers), look up the entry, and `mmap` the file. Callers can
23//! either trust the local hash they just computed (`get`) or pay the
24//! bytecheck pass on substituter-pulled bytes (`get_validated`).
25
26use std::fs::{self, File, OpenOptions};
27use std::io::Write;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use memmap2::Mmap;
33use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition};
34use tracing::{debug, trace};
35
36use crate::content_address::GraphHash;
37use crate::error::{Error, Result};
38use crate::layout::{GraphKind, StoreLayout};
39
40/// redb table: 33-byte composite key → 24-byte packed index entry.
41/// Key layout: `[kind_tag : u8, hash : 32 bytes]`.
42/// Value layout: `[len : u64 LE, created_unix_nanos : u128 LE]` truncated
43/// to 24 bytes (so the table is a flat memcpy on both sides).
44const INDEX_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("graph-index-v1");
45
46/// 24 bytes: u64 length + u128 created-at nanos.
47const ENTRY_PACKED_LEN: usize = 24;
48
49/// Bookkeeping payload stored alongside every blob's index entry.
50/// Kept tiny so the redb pages stay dense.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct IndexEntry {
53    /// Length of the blob in bytes (matches the on-disk file size).
54    pub blob_len: u64,
55    /// Wall-clock instant the entry was created, in unix nanoseconds.
56    /// Used by GC to evict old entries; not load-bearing for correctness.
57    pub created_at_nanos: u128,
58}
59
60impl IndexEntry {
61    fn pack(self) -> [u8; ENTRY_PACKED_LEN] {
62        let mut out = [0u8; ENTRY_PACKED_LEN];
63        out[0..8].copy_from_slice(&self.blob_len.to_le_bytes());
64        out[8..24].copy_from_slice(&self.created_at_nanos.to_le_bytes());
65        out
66    }
67
68    fn unpack(bytes: &[u8]) -> Result<Self> {
69        if bytes.len() != ENTRY_PACKED_LEN {
70            return Err(Error::Storage(redb::StorageError::Corrupted(format!(
71                "index entry has wrong length: expected {} got {}",
72                ENTRY_PACKED_LEN,
73                bytes.len()
74            ))));
75        }
76        let mut len_buf = [0u8; 8];
77        len_buf.copy_from_slice(&bytes[0..8]);
78        let mut ts_buf = [0u8; 16];
79        ts_buf.copy_from_slice(&bytes[8..24]);
80        Ok(Self {
81            blob_len: u64::from_le_bytes(len_buf),
82            created_at_nanos: u128::from_le_bytes(ts_buf),
83        })
84    }
85}
86
87fn index_key(kind: GraphKind, hash: GraphHash) -> [u8; 33] {
88    let mut k = [0u8; 33];
89    k[0] = kind.tag();
90    k[1..33].copy_from_slice(hash.as_bytes());
91    k
92}
93
94/// The store. Clone-able (the underlying [`redb::Database`] is held by
95/// `Arc` and is safe to share across threads — redb is internally
96/// concurrent). Construct one per process; pass clones into worker tasks.
97#[derive(Clone)]
98pub struct GraphStore {
99    layout: StoreLayout,
100    db: Arc<Database>,
101}
102
103impl GraphStore {
104    /// Open or create a store at `root`. Creates the root, blobs
105    /// subtree, and redb index on first call. Idempotent.
106    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
107        let layout = StoreLayout::at(root.into());
108        fs::create_dir_all(layout.root()).map_err(|e| Error::io(layout.root(), e))?;
109        fs::create_dir_all(layout.root().join("blobs"))
110            .map_err(|e| Error::io(layout.root().join("blobs"), e))?;
111
112        let db = Database::create(layout.index_db_path())?;
113
114        // Ensure the table exists so first-touch reads don't trip
115        // `TableDoesNotExist`.
116        let txn = db.begin_write()?;
117        {
118            let _t = txn.open_table(INDEX_TABLE)?;
119        }
120        txn.commit()?;
121
122        Ok(Self {
123            layout,
124            db: Arc::new(db),
125        })
126    }
127
128    /// Read-only view of the layout (paths, kinds). Useful for testing
129    /// and for GC sweeps that walk the filesystem directly.
130    #[must_use]
131    pub fn layout(&self) -> &StoreLayout {
132        &self.layout
133    }
134
135    /// Write a blob. Atomic: blob lands on disk + fsyncs *before* the
136    /// index transaction commits. Idempotent on the same `(kind, hash,
137    /// bytes)` triple. If the hash doesn't match the bytes the call
138    /// fails with [`Error::HashMismatch`] — protects against caller
139    /// mistakes upstream.
140    pub fn put(&self, kind: GraphKind, hash: GraphHash, bytes: &[u8]) -> Result<()> {
141        let actual = GraphHash::of(bytes);
142        if actual != hash {
143            return Err(Error::HashMismatch {
144                expected: hash,
145                actual,
146            });
147        }
148
149        let final_path = self.layout.blob_path(kind, hash);
150        if let Some(parent) = final_path.parent() {
151            fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
152        }
153
154        // Fast-path: blob already on disk (idempotent re-put). Just
155        // make sure the index entry exists.
156        if final_path.exists() {
157            trace!(target: "sui-graph-store", "blob already present, refreshing index entry only");
158            self.upsert_index(kind, hash, bytes.len() as u64)?;
159            return Ok(());
160        }
161
162        let tmp_path = final_path.with_extension("rkyv.tmp");
163        {
164            let mut f = OpenOptions::new()
165                .create(true)
166                .write(true)
167                .truncate(true)
168                .open(&tmp_path)
169                .map_err(|e| Error::io(&tmp_path, e))?;
170            f.write_all(bytes).map_err(|e| Error::io(&tmp_path, e))?;
171            f.sync_all().map_err(|e| Error::io(&tmp_path, e))?;
172        }
173
174        fs::rename(&tmp_path, &final_path).map_err(|e| Error::io(&final_path, e))?;
175        self.upsert_index(kind, hash, bytes.len() as u64)?;
176
177        debug!(
178            target: "sui-graph-store",
179            kind = %kind,
180            hash = %hash,
181            len = bytes.len(),
182            "stored blob"
183        );
184        Ok(())
185    }
186
187    /// Write a blob under a **query-derived lookup key** (not the
188    /// BLAKE3 of the bytes). Skips the content-hash validation that
189    /// [`Self::put`] enforces.
190    ///
191    /// Use only when the caller has a deterministic mapping from a
192    /// non-content query (e.g. an eval-cache `(source_hash, lock_hash)`
193    /// tuple) to a lookup hash, and the stored bytes are themselves
194    /// not the BLAKE3 preimage of that hash. The lookup-hash space
195    /// shares the same redb table as content-addressed entries, so
196    /// callers MUST ensure their query-derived hashes can't collide
197    /// with arbitrary content (the usual trick: domain-separate by
198    /// hashing `"my-tier::v1::" + serialized_query`).
199    ///
200    /// Most callers should prefer [`Self::put`].
201    pub fn put_unchecked(
202        &self,
203        kind: GraphKind,
204        lookup_hash: GraphHash,
205        bytes: &[u8],
206    ) -> Result<()> {
207        let final_path = self.layout.blob_path(kind, lookup_hash);
208        if let Some(parent) = final_path.parent() {
209            fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
210        }
211
212        let tmp_path = final_path.with_extension("rkyv.tmp");
213        {
214            let mut f = OpenOptions::new()
215                .create(true)
216                .write(true)
217                .truncate(true)
218                .open(&tmp_path)
219                .map_err(|e| Error::io(&tmp_path, e))?;
220            f.write_all(bytes).map_err(|e| Error::io(&tmp_path, e))?;
221            f.sync_all().map_err(|e| Error::io(&tmp_path, e))?;
222        }
223        fs::rename(&tmp_path, &final_path).map_err(|e| Error::io(&final_path, e))?;
224        self.upsert_index(kind, lookup_hash, bytes.len() as u64)?;
225
226        debug!(
227            target: "sui-graph-store",
228            kind = %kind,
229            lookup = %lookup_hash,
230            len = bytes.len(),
231            "stored blob via put_unchecked"
232        );
233        Ok(())
234    }
235
236    fn upsert_index(&self, kind: GraphKind, hash: GraphHash, blob_len: u64) -> Result<()> {
237        let entry = IndexEntry {
238            blob_len,
239            created_at_nanos: SystemTime::now()
240                .duration_since(UNIX_EPOCH)
241                .map(|d| d.as_nanos())
242                .unwrap_or(0),
243        };
244        let key = index_key(kind, hash);
245        let packed = entry.pack();
246        let txn = self.db.begin_write()?;
247        {
248            let mut table = txn.open_table(INDEX_TABLE)?;
249            table.insert(&key[..], &packed[..])?;
250        }
251        txn.commit()?;
252        Ok(())
253    }
254
255    /// Check whether the index knows about `(kind, hash)`. Cheap; does
256    /// not touch the filesystem.
257    pub fn contains(&self, kind: GraphKind, hash: GraphHash) -> Result<bool> {
258        let txn = self.db.begin_read()?;
259        let table = txn.open_table(INDEX_TABLE)?;
260        let key = index_key(kind, hash);
261        Ok(table.get(&key[..])?.is_some())
262    }
263
264    /// Look up an entry's bookkeeping without reading the blob. Used by
265    /// GC and inspection tools.
266    pub fn lookup(&self, kind: GraphKind, hash: GraphHash) -> Result<IndexEntry> {
267        let txn = self.db.begin_read()?;
268        let table = txn.open_table(INDEX_TABLE)?;
269        let key = index_key(kind, hash);
270        let raw = table
271            .get(&key[..])?
272            .ok_or(Error::NotFound { hash })?;
273        IndexEntry::unpack(raw.value())
274    }
275
276    /// `mmap` the blob bytes for `(kind, hash)`. Caller is trusted to
277    /// have already verified the hash (e.g. they just computed it). Use
278    /// [`Self::get_validated`] for substituter-pulled bytes that must
279    /// also pass rkyv's bytecheck pass.
280    pub fn get(&self, kind: GraphKind, hash: GraphHash) -> Result<MappedBlob> {
281        let entry = self.lookup(kind, hash)?;
282        let path = self.layout.blob_path(kind, hash);
283        let file = File::open(&path).map_err(|e| Error::io(&path, e))?;
284        // SAFETY: redb-tracked path; the file is locally written by
285        // `put` and not mutated afterward (overwrite is via rename of
286        // a fresh tmp file). mmap of immutable bytes is sound.
287        let mmap = unsafe { Mmap::map(&file) }.map_err(|e| Error::io(&path, e))?;
288
289        if mmap.len() as u64 != entry.blob_len {
290            return Err(Error::Storage(redb::StorageError::Corrupted(format!(
291                "blob length mismatch: index says {} bytes, file has {} bytes",
292                entry.blob_len,
293                mmap.len()
294            ))));
295        }
296
297        Ok(MappedBlob { mmap, path })
298    }
299
300    /// Same as [`Self::get`] but additionally recomputes the BLAKE3 of
301    /// the bytes and rejects on mismatch. Pay this cost for any blob
302    /// that arrived from an untrusted source (substituter pull,
303    /// cross-host fleet transfer).
304    pub fn get_validated(&self, kind: GraphKind, hash: GraphHash) -> Result<MappedBlob> {
305        let blob = self.get(kind, hash)?;
306        let actual = GraphHash::of(&blob);
307        if actual != hash {
308            return Err(Error::HashMismatch {
309                expected: hash,
310                actual,
311            });
312        }
313        Ok(blob)
314    }
315
316    /// Iterate every `(kind, hash)` pair in the index. Backed by a
317    /// redb read transaction so the snapshot is consistent at call
318    /// time. Used by GC and inspection.
319    pub fn iter_keys(&self) -> Result<Vec<(GraphKind, GraphHash)>> {
320        let txn = self.db.begin_read()?;
321        let table = txn.open_table(INDEX_TABLE)?;
322        let mut out = Vec::new();
323        for entry in table.iter()? {
324            let (k, _v) = entry?;
325            let key = k.value();
326            if key.len() == 33 {
327                if let Some(kind) = GraphKind::from_tag(key[0]) {
328                    let mut hash = [0u8; 32];
329                    hash.copy_from_slice(&key[1..33]);
330                    out.push((kind, GraphHash(hash)));
331                }
332            }
333        }
334        Ok(out)
335    }
336
337    /// Number of entries in the index. Cheap; one read txn + table scan.
338    pub fn len(&self) -> Result<u64> {
339        let txn = self.db.begin_read()?;
340        let table = txn.open_table(INDEX_TABLE)?;
341        Ok(table.len()?)
342    }
343
344    /// True when the store has no entries.
345    pub fn is_empty(&self) -> Result<bool> {
346        Ok(self.len()? == 0)
347    }
348}
349
350/// `mmap`-backed handle to a blob's bytes. Derefs to `&[u8]` so callers
351/// can either:
352///
353/// * Hand the slice to `rkyv::access::<ArchivedT, _>()` (validated)
354/// * Hand the slice to `rkyv::access_unchecked::<ArchivedT>()` (trusted)
355///
356/// Hold the `MappedBlob` for as long as you want the reference to live;
357/// the `mmap` is released when this drops.
358pub struct MappedBlob {
359    mmap: Mmap,
360    path: PathBuf,
361}
362
363impl std::fmt::Debug for MappedBlob {
364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365        f.debug_struct("MappedBlob")
366            .field("path", &self.path)
367            .field("len", &self.mmap.len())
368            .finish()
369    }
370}
371
372impl MappedBlob {
373    /// Filesystem path of the backing file. For diagnostics only.
374    #[must_use]
375    pub fn path(&self) -> &Path {
376        &self.path
377    }
378}
379
380impl AsRef<[u8]> for MappedBlob {
381    fn as_ref(&self) -> &[u8] {
382        &self.mmap
383    }
384}
385
386impl std::ops::Deref for MappedBlob {
387    type Target = [u8];
388    fn deref(&self) -> &Self::Target {
389        &self.mmap
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use pretty_assertions::assert_eq;
397    use tempfile::tempdir;
398
399    fn new_store() -> (tempfile::TempDir, GraphStore) {
400        let dir = tempdir().unwrap();
401        let store = GraphStore::open(dir.path().to_path_buf()).unwrap();
402        (dir, store)
403    }
404
405    #[test]
406    fn empty_store_reports_zero() {
407        let (_dir, store) = new_store();
408        assert!(store.is_empty().unwrap());
409        assert_eq!(store.len().unwrap(), 0);
410    }
411
412    #[test]
413    fn put_then_get_roundtrip() {
414        let (_dir, store) = new_store();
415        let payload = b"hello sui graph store".as_slice();
416        let h = GraphHash::of(payload);
417
418        store.put(GraphKind::Lockfile, h, payload).unwrap();
419        assert!(store.contains(GraphKind::Lockfile, h).unwrap());
420
421        let blob = store.get(GraphKind::Lockfile, h).unwrap();
422        assert_eq!(&*blob, payload);
423    }
424
425    #[test]
426    fn put_validates_caller_hash() {
427        let (_dir, store) = new_store();
428        let payload = b"correct payload".as_slice();
429        let wrong = GraphHash::of(b"different payload");
430        let err = store.put(GraphKind::Ast, wrong, payload).unwrap_err();
431        assert!(matches!(err, Error::HashMismatch { .. }));
432    }
433
434    #[test]
435    fn get_missing_returns_not_found() {
436        let (_dir, store) = new_store();
437        let h = GraphHash::of(b"nothing");
438        let err = store.get(GraphKind::Module, h).unwrap_err();
439        assert!(matches!(err, Error::NotFound { .. }));
440    }
441
442    #[test]
443    fn put_is_idempotent_on_same_bytes() {
444        let (_dir, store) = new_store();
445        let payload = b"idempotent".as_slice();
446        let h = GraphHash::of(payload);
447        store.put(GraphKind::Derivation, h, payload).unwrap();
448        store.put(GraphKind::Derivation, h, payload).unwrap();
449        assert_eq!(store.len().unwrap(), 1);
450    }
451
452    #[test]
453    fn different_kinds_with_same_hash_coexist() {
454        let (_dir, store) = new_store();
455        let payload = b"same bytes, different graph kinds".as_slice();
456        let h = GraphHash::of(payload);
457        store.put(GraphKind::Lockfile, h, payload).unwrap();
458        store.put(GraphKind::Ast, h, payload).unwrap();
459        assert_eq!(store.len().unwrap(), 2);
460        let keys = store.iter_keys().unwrap();
461        assert!(keys.contains(&(GraphKind::Lockfile, h)));
462        assert!(keys.contains(&(GraphKind::Ast, h)));
463    }
464
465    #[test]
466    fn get_validated_rejects_corruption() {
467        let (_dir, store) = new_store();
468        let payload = b"valid payload".as_slice();
469        let h = GraphHash::of(payload);
470        store.put(GraphKind::Lockfile, h, payload).unwrap();
471
472        // Corrupt the blob on disk to simulate bitrot / tampering.
473        let path = store.layout.blob_path(GraphKind::Lockfile, h);
474        std::fs::write(&path, b"tampered").unwrap();
475
476        // Length mismatch trips before bytecheck — index entry says 13
477        // bytes, file has 8.
478        let err = store.get(GraphKind::Lockfile, h).unwrap_err();
479        assert!(matches!(err, Error::Storage(_)));
480    }
481
482    #[test]
483    fn iter_keys_returns_all_inserted() {
484        let (_dir, store) = new_store();
485        let mut inserted = Vec::new();
486        for i in 0..10u32 {
487            let payload = format!("entry {i}");
488            let h = GraphHash::of(payload.as_bytes());
489            store
490                .put(GraphKind::EvalCacheEntry, h, payload.as_bytes())
491                .unwrap();
492            inserted.push((GraphKind::EvalCacheEntry, h));
493        }
494        let mut found = store.iter_keys().unwrap();
495        found.sort();
496        inserted.sort();
497        assert_eq!(found, inserted);
498    }
499
500    #[test]
501    fn store_clone_is_a_view_of_same_data() {
502        let (_dir, store_a) = new_store();
503        let store_b = store_a.clone();
504        let payload = b"shared via clone".as_slice();
505        let h = GraphHash::of(payload);
506        store_a.put(GraphKind::Lockfile, h, payload).unwrap();
507        assert!(store_b.contains(GraphKind::Lockfile, h).unwrap());
508    }
509}