Skip to main content

corium_store/
lib.rs

1//! Content-addressed blob and fenced root stores for immutable index segments.
2
3use std::{
4    collections::{BTreeMap, HashMap, HashSet},
5    fmt,
6    fs::{self, File, OpenOptions},
7    io,
8    path::{Path, PathBuf},
9    sync::{Arc, RwLock},
10    time::{Duration, SystemTime},
11};
12
13use fs2::FileExt;
14use thiserror::Error;
15
16/// A content identifier for immutable blobs.
17#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
18pub struct BlobId(String);
19
20impl BlobId {
21    /// Returns the hexadecimal digest string.
22    #[must_use]
23    pub fn as_str(&self) -> &str {
24        &self.0
25    }
26
27    /// Parses a stored 64-character hexadecimal digest.
28    #[must_use]
29    pub fn from_hex(text: &str) -> Option<Self> {
30        (text.len() == 64 && text.bytes().all(|byte| byte.is_ascii_hexdigit()))
31            .then(|| Self(text.to_owned()))
32    }
33}
34
35impl fmt::Display for BlobId {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(&self.0)
38    }
39}
40
41/// Errors raised by store implementations.
42#[derive(Debug, Error)]
43pub enum StoreError {
44    /// I/O failure.
45    #[error("store I/O failed: {0}")]
46    Io(#[from] io::Error),
47    /// Root compare-and-swap failed because the current fence differed.
48    #[error("root CAS failed: expected {expected:?}, actual {actual:?}")]
49    CasFailed {
50        /// Expected root bytes supplied by the caller.
51        expected: Option<Vec<u8>>,
52        /// Actual root bytes currently stored.
53        actual: Option<Vec<u8>>,
54    },
55    /// Blob digest did not match its content.
56    #[error("blob content did not match digest {0}")]
57    CorruptBlob(BlobId),
58    /// A live graph references a blob that is not present.
59    #[error("reachable blob is missing: {0}")]
60    MissingBlob(BlobId),
61    /// Root name cannot be safely represented on the filesystem.
62    #[error("invalid root name {0:?}")]
63    InvalidRootName(String),
64}
65
66/// Immutable content-addressed blob storage.
67pub trait BlobStore: Send + Sync {
68    /// Stores bytes and returns their content id.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the backend cannot persist the blob.
73    fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError>;
74    /// Loads bytes by id, returning `None` when missing.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the backend cannot read or verify the blob.
79    fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError>;
80    /// Reports whether a blob is present.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the backend cannot inspect the blob.
85    fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
86        Ok(self.get(id)?.is_some())
87    }
88    /// Deletes a blob during garbage collection. Missing blobs are ignored.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the backend cannot delete the blob.
93    fn delete(&self, id: &BlobId) -> Result<(), StoreError>;
94    /// Lists all blob identifiers known to this backend.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the backend cannot enumerate blobs.
99    fn list(&self) -> Result<Vec<BlobId>, StoreError>;
100    /// Returns the blob's creation/last-modification time when available.
101    /// Backends without timestamps return `None`, which conservatively keeps
102    /// the blob whenever a non-zero retention window is active.
103    ///
104    /// # Errors
105    /// Returns an error if the backend cannot inspect blob metadata.
106    fn modified_at(&self, _id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
107        Ok(None)
108    }
109}
110
111/// Named root pointer storage with compare-and-swap fencing.
112pub trait RootStore: Send + Sync {
113    /// Reads a root pointer.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the backend cannot read the root.
118    fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError>;
119    /// Publishes a root only if the stored pointer equals `expected`.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the fence does not match or the backend cannot publish.
124    fn cas_root(&self, name: &str, expected: Option<&[u8]>, new: &[u8]) -> Result<(), StoreError>;
125    /// Removes a root pointer. Missing roots are ignored.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the backend cannot delete the root.
130    fn delete_root(&self, name: &str) -> Result<(), StoreError>;
131    /// Lists root names beginning with `prefix`, in sorted order.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the backend cannot enumerate roots.
136    fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
137}
138
139/// In-memory blob and root store for tests and embedded use.
140#[derive(Clone, Default)]
141pub struct MemoryStore {
142    inner: Arc<RwLock<MemoryInner>>,
143}
144#[derive(Default)]
145struct MemoryInner {
146    blobs: HashMap<BlobId, Vec<u8>>,
147    roots: BTreeMap<String, Vec<u8>>,
148}
149
150impl BlobStore for MemoryStore {
151    fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
152        let id = digest(bytes);
153        self.inner
154            .write()
155            .expect("poisoned store lock")
156            .blobs
157            .insert(id.clone(), bytes.to_vec());
158        Ok(id)
159    }
160    fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
161        Ok(self
162            .inner
163            .read()
164            .expect("poisoned store lock")
165            .blobs
166            .get(id)
167            .cloned())
168    }
169    fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
170        self.inner
171            .write()
172            .expect("poisoned store lock")
173            .blobs
174            .remove(id);
175        Ok(())
176    }
177    fn list(&self) -> Result<Vec<BlobId>, StoreError> {
178        Ok(self
179            .inner
180            .read()
181            .expect("poisoned store lock")
182            .blobs
183            .keys()
184            .cloned()
185            .collect())
186    }
187}
188impl RootStore for MemoryStore {
189    fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
190        Ok(self
191            .inner
192            .read()
193            .expect("poisoned store lock")
194            .roots
195            .get(name)
196            .cloned())
197    }
198    fn cas_root(&self, name: &str, expected: Option<&[u8]>, new: &[u8]) -> Result<(), StoreError> {
199        let mut inner = self.inner.write().expect("poisoned store lock");
200        let actual = inner.roots.get(name).cloned();
201        if actual.as_deref() != expected {
202            return Err(StoreError::CasFailed {
203                expected: expected.map(<[u8]>::to_vec),
204                actual,
205            });
206        }
207        inner.roots.insert(name.to_owned(), new.to_vec());
208        Ok(())
209    }
210    fn delete_root(&self, name: &str) -> Result<(), StoreError> {
211        self.inner
212            .write()
213            .expect("poisoned store lock")
214            .roots
215            .remove(name);
216        Ok(())
217    }
218    fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
219        Ok(self
220            .inner
221            .read()
222            .expect("poisoned store lock")
223            .roots
224            .keys()
225            .filter(|name| name.starts_with(prefix))
226            .cloned()
227            .collect())
228    }
229}
230
231/// Filesystem-backed content-addressed blob and fenced root store.
232pub struct FsStore {
233    root: PathBuf,
234}
235impl FsStore {
236    /// Opens or creates a store below `root`.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if the directory layout cannot be created.
241    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
242        let root = root.as_ref().to_path_buf();
243        fs::create_dir_all(root.join("blobs"))?;
244        fs::create_dir_all(root.join("roots"))?;
245        Ok(Self { root })
246    }
247    fn blob_path(&self, id: &BlobId) -> PathBuf {
248        self.root.join("blobs").join(id.as_str())
249    }
250    fn root_path(&self, name: &str) -> Result<PathBuf, StoreError> {
251        if name.is_empty()
252            || name == "."
253            || name == ".."
254            || name.contains('/')
255            || name.contains('\\')
256        {
257            return Err(StoreError::InvalidRootName(name.to_owned()));
258        }
259        Ok(self.root.join("roots").join(name))
260    }
261
262    fn root_lock(&self, name: &str) -> Result<RootLock, StoreError> {
263        let root_path = self.root_path(name)?;
264        RootLock::acquire(&root_path.with_extension("lock"))
265    }
266}
267impl BlobStore for FsStore {
268    fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
269        let id = digest(bytes);
270        let path = self.blob_path(&id);
271        if !path.exists() {
272            let tmp = path.with_extension("tmp");
273            fs::write(&tmp, bytes)?;
274            fs::rename(tmp, path)?;
275        }
276        Ok(id)
277    }
278    fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
279        let path = self.blob_path(id);
280        if !path.exists() {
281            return Ok(None);
282        }
283        let bytes = fs::read(path)?;
284        if &digest(&bytes) != id {
285            return Err(StoreError::CorruptBlob(id.clone()));
286        }
287        Ok(Some(bytes))
288    }
289    fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
290        Ok(self.blob_path(id).is_file())
291    }
292    fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
293        match fs::remove_file(self.blob_path(id)) {
294            Ok(()) => Ok(()),
295            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
296            Err(error) => Err(error.into()),
297        }
298    }
299    fn list(&self) -> Result<Vec<BlobId>, StoreError> {
300        let mut ids = Vec::new();
301        for entry in fs::read_dir(self.root.join("blobs"))? {
302            let entry = entry?;
303            if entry.file_type()?.is_file() {
304                if let Some(name) = entry.file_name().to_str() {
305                    if name.len() == 64 && name.bytes().all(|byte| byte.is_ascii_hexdigit()) {
306                        ids.push(BlobId(name.to_owned()));
307                    }
308                }
309            }
310        }
311        Ok(ids)
312    }
313    fn modified_at(&self, id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
314        match fs::metadata(self.blob_path(id)) {
315            Ok(metadata) => Ok(Some(metadata.modified()?)),
316            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
317            Err(error) => Err(error.into()),
318        }
319    }
320}
321impl RootStore for FsStore {
322    fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
323        match fs::read(self.root_path(name)?) {
324            Ok(v) => Ok(Some(v)),
325            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
326            Err(e) => Err(e.into()),
327        }
328    }
329    fn cas_root(&self, name: &str, expected: Option<&[u8]>, new: &[u8]) -> Result<(), StoreError> {
330        let _lock = self.root_lock(name)?;
331        let path = self.root_path(name)?;
332        let actual = match fs::read(&path) {
333            Ok(v) => Some(v),
334            Err(e) if e.kind() == io::ErrorKind::NotFound => None,
335            Err(e) => return Err(e.into()),
336        };
337        if actual.as_deref() != expected {
338            return Err(StoreError::CasFailed {
339                expected: expected.map(<[u8]>::to_vec),
340                actual,
341            });
342        }
343        let tmp = path.with_extension("tmp");
344        fs::write(&tmp, new)?;
345        fs::rename(tmp, path)?;
346        Ok(())
347    }
348    fn delete_root(&self, name: &str) -> Result<(), StoreError> {
349        let _lock = self.root_lock(name)?;
350        match fs::remove_file(self.root_path(name)?) {
351            Ok(()) => Ok(()),
352            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
353            Err(error) => Err(error.into()),
354        }
355    }
356    fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
357        let mut names = Vec::new();
358        for entry in fs::read_dir(self.root.join("roots"))? {
359            let entry = entry?;
360            if !entry.file_type()?.is_file() {
361                continue;
362            }
363            if let Some(name) = entry.file_name().to_str() {
364                let auxiliary = Path::new(name).extension().is_some_and(|ext| {
365                    ext.eq_ignore_ascii_case("lock") || ext.eq_ignore_ascii_case("tmp")
366                });
367                if name.starts_with(prefix) && !auxiliary {
368                    names.push(name.to_owned());
369                }
370            }
371        }
372        names.sort();
373        Ok(names)
374    }
375}
376
377fn digest(bytes: &[u8]) -> BlobId {
378    BlobId(blake3::hash(bytes).to_hex().to_string())
379}
380
381/// Root-store key for a database's published index root.
382#[must_use]
383pub fn db_root_name(db: &str) -> String {
384    format!("db:{db}")
385}
386
387/// Storage format written by this release.
388///
389/// Format 2 (M7) folds the write lease into the root record so lease
390/// ownership and index publication are fenced by one atomic CAS; format 1
391/// roots (separate `lease:` record) decode with an unowned lease.
392pub const FORMAT_VERSION: u32 = 2;
393
394/// Published durable index-root metadata carrying the write lease
395/// (see `docs/design/log-and-transactor.md`).
396///
397/// The lease fields and the index fields live in one record on purpose:
398/// every mutation — lease acquisition, renewal, release, index publication —
399/// is a CAS on these bytes, so a writer whose ownership has changed hands
400/// always fails its next CAS and can never install a root. No cross-record
401/// atomicity is required of the store.
402#[derive(Clone, Debug, Eq, PartialEq)]
403pub struct DbRoot {
404    /// On-disk format version. Readers reject roots from newer formats.
405    pub format_version: u32,
406    /// Fencing version; increments on every change of lease ownership.
407    pub lease_version: u64,
408    /// Owning transactor id; empty when the lease has never been acquired
409    /// under format 2.
410    pub owner: String,
411    /// Lease expiry as Unix milliseconds; `0` when released/never held.
412    pub lease_expires_unix_ms: i64,
413    /// Client endpoint advertised by the owner (for peer lease-holder
414    /// rediscovery); empty when the owner does not advertise one.
415    pub owner_endpoint: String,
416    /// Highest indexed transaction.
417    pub index_basis_t: u64,
418    /// EAVT, AEVT, AVET, and VAET blob ids; `None` before the first index
419    /// publication (a bare fence bump).
420    pub roots: Option<[BlobId; 4]>,
421}
422
423/// Encodes a possibly empty single-line field.
424fn field_line(out: &mut String, value: &str) {
425    if value.is_empty() {
426        out.push('-');
427    } else {
428        out.push_str(value);
429    }
430    out.push('\n');
431}
432
433fn parse_field(line: &str) -> String {
434    if line == "-" {
435        String::new()
436    } else {
437        line.to_owned()
438    }
439}
440
441impl DbRoot {
442    /// Encodes the root for the root store.
443    #[must_use]
444    pub fn encode(&self) -> Vec<u8> {
445        let mut out = format!(
446            "corium-root-v{}\n{}\n{}\n",
447            self.format_version, self.lease_version, self.index_basis_t
448        );
449        match &self.roots {
450            Some(roots) => {
451                for root in roots {
452                    out.push_str(root.as_str());
453                    out.push('\n');
454                }
455            }
456            None => out.push_str("-\n-\n-\n-\n"),
457        }
458        field_line(&mut out, &self.owner);
459        out.push_str(&self.lease_expires_unix_ms.to_string());
460        out.push('\n');
461        field_line(&mut out, &self.owner_endpoint);
462        out.into_bytes()
463    }
464
465    /// Decodes stored root bytes (any format up to [`FORMAT_VERSION`];
466    /// newer formats still yield their fence fields so old binaries fence
467    /// correctly, and callers reject them via `format_version`).
468    #[must_use]
469    pub fn decode(bytes: &[u8]) -> Option<Self> {
470        let text = std::str::from_utf8(bytes).ok()?;
471        let mut lines = text.lines();
472        let first = lines.next()?;
473        let (format_version, lease_version) =
474            if let Some(version) = first.strip_prefix("corium-root-v") {
475                let format_version = version.parse().ok()?;
476                let lease_version = lines.next()?.parse().ok()?;
477                (format_version, lease_version)
478            } else {
479                // M1-M5 roots had no header. Keep them readable as format v1 so
480                // an existing database can be upgraded in place.
481                (1, first.parse().ok()?)
482            };
483        let index_basis_t = lines.next()?.parse().ok()?;
484        let ids: Vec<&str> = lines.by_ref().take(4).collect();
485        if ids.len() != 4 {
486            return None;
487        }
488        let roots = if ids.iter().all(|id| *id == "-") {
489            None
490        } else {
491            Some([
492                BlobId::from_hex(ids[0])?,
493                BlobId::from_hex(ids[1])?,
494                BlobId::from_hex(ids[2])?,
495                BlobId::from_hex(ids[3])?,
496            ])
497        };
498        // Lease fields; absent in format-1 roots.
499        let owner = lines.next().map(parse_field).unwrap_or_default();
500        let lease_expires_unix_ms = lines.next().and_then(|l| l.parse().ok()).unwrap_or(0);
501        let owner_endpoint = lines.next().map(parse_field).unwrap_or_default();
502        Some(Self {
503            format_version,
504            lease_version,
505            owner,
506            lease_expires_unix_ms,
507            owner_endpoint,
508            index_basis_t,
509            roots,
510        })
511    }
512}
513
514/// Result counters from a mark-and-sweep garbage collection pass.
515#[derive(Clone, Copy, Debug, Eq, PartialEq)]
516pub struct GcReport {
517    /// Number of blobs reachable from the supplied roots.
518    pub marked: usize,
519    /// Number of unreachable blobs deleted.
520    pub swept: usize,
521    /// Number of unreachable blobs kept because they are inside retention.
522    pub retained: usize,
523}
524
525/// Marks blobs reachable from `live_roots` and deletes every unmarked blob.
526///
527/// `children` decodes references from each present blob. Callers are responsible for
528/// supplying every currently live root and for applying any desired retention window.
529///
530/// # Errors
531///
532/// Returns an error if a blob operation or child-reference decode fails.
533pub fn mark_and_sweep(
534    store: &dyn BlobStore,
535    live_roots: impl IntoIterator<Item = BlobId>,
536    mut children: impl FnMut(&BlobId, &[u8]) -> Result<Vec<BlobId>, StoreError>,
537) -> Result<GcReport, StoreError> {
538    mark_and_sweep_retained(
539        store,
540        live_roots,
541        &mut children,
542        Duration::ZERO,
543        SystemTime::now(),
544    )
545}
546
547/// Marks reachable blobs and deletes only unreachable blobs older than
548/// `retention` relative to `now`.
549///
550/// # Errors
551/// Returns an error if a blob operation or child-reference decode fails.
552pub fn mark_and_sweep_retained(
553    store: &dyn BlobStore,
554    live_roots: impl IntoIterator<Item = BlobId>,
555    mut children: impl FnMut(&BlobId, &[u8]) -> Result<Vec<BlobId>, StoreError>,
556    retention: Duration,
557    now: SystemTime,
558) -> Result<GcReport, StoreError> {
559    let mut marked = HashSet::new();
560    let mut pending = live_roots.into_iter().collect::<Vec<_>>();
561    while let Some(id) = pending.pop() {
562        if !marked.insert(id.clone()) {
563            continue;
564        }
565        let bytes = store
566            .get(&id)?
567            .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
568        pending.extend(children(&id, &bytes)?);
569    }
570
571    let mut swept = 0;
572    let mut retained = 0;
573    for id in store.list()? {
574        if !marked.contains(&id) {
575            // A zero window is the explicit immediate-sweep escape hatch and
576            // does not require backend timestamp support. Otherwise, unknown
577            // timestamps fail safe by retaining the blob.
578            let old_enough = retention.is_zero()
579                || store.modified_at(&id)?.is_some_and(|modified| {
580                    now.duration_since(modified).unwrap_or_default() >= retention
581                });
582            if old_enough {
583                store.delete(&id)?;
584                swept += 1;
585            } else {
586                retained += 1;
587            }
588        }
589    }
590    Ok(GcReport {
591        marked: marked.len(),
592        swept,
593        retained,
594    })
595}
596
597struct RootLock {
598    file: File,
599}
600
601impl RootLock {
602    fn acquire(path: &Path) -> Result<Self, StoreError> {
603        let file = OpenOptions::new()
604            .read(true)
605            .write(true)
606            .create(true)
607            .truncate(false)
608            .open(path)?;
609        file.lock_exclusive()?;
610        Ok(Self { file })
611    }
612}
613
614impl Drop for RootLock {
615    fn drop(&mut self) {
616        let _ = FileExt::unlock(&self.file);
617        // Keep the lock file in place so every contender locks the same inode.
618        // Unlinking it here would let a new opener lock a replacement file while
619        // a waiter still holds a descriptor for the unlinked original.
620    }
621}
622
623/// Small read-through segment cache keyed by blob id.
624#[derive(Default)]
625pub struct SegmentCache {
626    entries: RwLock<HashMap<BlobId, Arc<[u8]>>>,
627}
628impl SegmentCache {
629    /// Returns cached bytes, loading from `store` on miss.
630    ///
631    /// # Errors
632    ///
633    /// Returns an error if the backing store cannot load the blob.
634    ///
635    /// # Panics
636    ///
637    /// Panics if the internal cache lock is poisoned.
638    pub fn get_or_load(
639        &self,
640        store: &dyn BlobStore,
641        id: &BlobId,
642    ) -> Result<Option<Arc<[u8]>>, StoreError> {
643        if let Some(v) = self.entries.read().expect("poisoned cache lock").get(id) {
644            return Ok(Some(v.clone()));
645        }
646        let Some(bytes) = store.get(id)? else {
647            return Ok(None);
648        };
649        let bytes: Arc<[u8]> = bytes.into();
650        self.entries
651            .write()
652            .expect("poisoned cache lock")
653            .insert(id.clone(), bytes.clone());
654        Ok(Some(bytes))
655    }
656}