1use 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
40const INDEX_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("graph-index-v1");
45
46const ENTRY_PACKED_LEN: usize = 24;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct IndexEntry {
53 pub blob_len: u64,
55 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#[derive(Clone)]
98pub struct GraphStore {
99 layout: StoreLayout,
100 db: Arc<Database>,
101}
102
103impl GraphStore {
104 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 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 #[must_use]
131 pub fn layout(&self) -> &StoreLayout {
132 &self.layout
133 }
134
135 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 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 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 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 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 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 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 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 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 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 pub fn is_empty(&self) -> Result<bool> {
346 Ok(self.len()? == 0)
347 }
348}
349
350pub 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 #[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 let path = store.layout.blob_path(GraphKind::Lockfile, h);
474 std::fs::write(&path, b"tampered").unwrap();
475
476 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}