Skip to main content

corium_store/
lib.rs

1//! Content-addressed blob and fenced root stores for immutable index segments.
2//!
3//! Enable the `postgres`, `turso`, or `s3` Cargo feature to use
4//! [`PostgresBlobStore`], [`TursoBlobStore`], or [`S3BlobStore`], respectively.
5
6use std::{
7    collections::{BTreeMap, HashMap, HashSet},
8    fmt,
9    fs::{self, File, OpenOptions},
10    io,
11    path::{Path, PathBuf},
12    pin::Pin,
13    sync::{Arc, RwLock},
14    time::{Duration, SystemTime},
15};
16
17use async_trait::async_trait;
18use fs2::FileExt;
19use thiserror::Error;
20use tokio_stream::{Stream, StreamExt, wrappers::ReceiverStream};
21
22mod snapshot;
23pub use snapshot::{
24    INDEX_MANIFEST_MAGIC, chunk_segment_keys, decode_index_manifest, decode_segment_keys,
25    encode_index_manifest, index_blob_children, is_index_manifest,
26};
27
28#[cfg(feature = "postgres")]
29mod postgres_store;
30#[cfg(feature = "postgres")]
31pub use postgres_store::PostgresBlobStore;
32#[cfg(feature = "turso")]
33mod turso_store;
34#[cfg(feature = "turso")]
35pub use turso_store::TursoBlobStore;
36#[cfg(feature = "s3")]
37mod s3_store;
38#[cfg(feature = "s3")]
39pub use s3_store::S3BlobStore;
40
41/// A content identifier for immutable blobs.
42#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
43pub struct BlobId(String);
44
45impl BlobId {
46    /// Returns the hexadecimal digest string.
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51
52    /// Parses a stored 64-character hexadecimal digest.
53    #[must_use]
54    pub fn from_hex(text: &str) -> Option<Self> {
55        (text.len() == 64 && text.bytes().all(|byte| byte.is_ascii_hexdigit()))
56            .then(|| Self(text.to_owned()))
57    }
58}
59
60impl fmt::Display for BlobId {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str(&self.0)
63    }
64}
65
66/// Errors raised by store implementations.
67#[derive(Debug, Error)]
68pub enum StoreError {
69    /// I/O failure.
70    #[error("store I/O failed: {0}")]
71    Io(#[from] io::Error),
72    /// Root compare-and-swap failed because the current fence differed.
73    #[error("root CAS failed: expected {expected:?}, actual {actual:?}")]
74    CasFailed {
75        /// Expected root bytes supplied by the caller.
76        expected: Option<Vec<u8>>,
77        /// Actual root bytes currently stored.
78        actual: Option<Vec<u8>>,
79    },
80    /// Blob digest did not match its content.
81    #[error("blob content did not match digest {0}")]
82    CorruptBlob(BlobId),
83    /// A live graph references a blob that is not present.
84    #[error("reachable blob is missing: {0}")]
85    MissingBlob(BlobId),
86    /// Root name cannot be safely represented on the filesystem.
87    #[error("invalid root name {0:?}")]
88    InvalidRootName(String),
89    /// A blocking store worker failed before returning its result.
90    #[error("store blocking task failed: {0}")]
91    BlockingTask(String),
92    /// `PostgreSQL` database failure.
93    #[cfg(feature = "postgres")]
94    #[error("PostgreSQL store failed: {0}")]
95    Postgres(#[from] deadpool_postgres::tokio_postgres::Error),
96    /// `PostgreSQL` connection-pool checkout failure.
97    #[cfg(feature = "postgres")]
98    #[error("PostgreSQL connection pool failed: {0}")]
99    PostgresPool(#[from] deadpool_postgres::PoolError),
100    /// `PostgreSQL` connection-pool configuration failure.
101    #[cfg(feature = "postgres")]
102    #[error("PostgreSQL connection pool configuration failed: {0}")]
103    PostgresPoolCreate(#[from] deadpool_postgres::CreatePoolError),
104    /// No native certificate roots were available for `PostgreSQL` TLS.
105    #[cfg(feature = "postgres")]
106    #[error("cannot load native certificate roots for PostgreSQL TLS: {0}")]
107    PostgresTlsRoots(String),
108    /// `PostgreSQL` returned invalid store data.
109    #[cfg(feature = "postgres")]
110    #[error("PostgreSQL store contains invalid data: {0}")]
111    InvalidPostgresData(String),
112    /// Turso database failure.
113    #[cfg(feature = "turso")]
114    #[error("Turso blob store failed: {0}")]
115    Turso(#[from] turso::Error),
116    /// A filesystem path cannot be passed to Turso.
117    #[cfg(feature = "turso")]
118    #[error("Turso database path is not valid UTF-8: {0:?}")]
119    InvalidTursoPath(PathBuf),
120    /// Turso returned invalid blob-store data.
121    #[cfg(feature = "turso")]
122    #[error("Turso blob store contains invalid data: {0}")]
123    InvalidTursoData(String),
124    /// S3 request failure.
125    #[cfg(feature = "s3")]
126    #[error("S3 store failed: {0}")]
127    S3(String),
128    /// S3 returned invalid store data.
129    #[cfg(feature = "s3")]
130    #[error("S3 store contains invalid data: {0}")]
131    InvalidS3Data(String),
132}
133
134/// Converts any S3 SDK operation error into [`StoreError::S3`]. Generic over
135/// the operation's modeled error type so every `?` on an S3 SDK call
136/// converts without a per-operation `From` impl.
137///
138/// Formats with [`DisplayErrorContext`](aws_sdk_s3::error::DisplayErrorContext)
139/// rather than `SdkError`'s own terse `Display` (e.g. "service error"), which
140/// drops the underlying S3 error code and message (`AccessDenied`,
141/// `NoSuchBucket`, etc.) that operators need to diagnose a failure.
142#[cfg(feature = "s3")]
143impl<E> From<aws_sdk_s3::error::SdkError<E>> for StoreError
144where
145    E: std::error::Error + Send + Sync + 'static,
146{
147    fn from(error: aws_sdk_s3::error::SdkError<E>) -> Self {
148        StoreError::S3(aws_sdk_s3::error::DisplayErrorContext(error).to_string())
149    }
150}
151
152/// Asynchronous stream of blob identifiers produced by [`BlobStore::list`].
153pub type BlobIdStream = Pin<Box<dyn Stream<Item = Result<BlobId, StoreError>> + Send + 'static>>;
154
155async fn run_blocking<T>(
156    operation: impl FnOnce() -> Result<T, StoreError> + Send + 'static,
157) -> Result<T, StoreError>
158where
159    T: Send + 'static,
160{
161    tokio::task::spawn_blocking(operation)
162        .await
163        .map_err(|error| StoreError::BlockingTask(error.to_string()))?
164}
165
166/// Immutable content-addressed blob storage.
167#[async_trait]
168pub trait BlobStore: Send + Sync {
169    /// Stores bytes and returns their content id.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the backend cannot persist the blob.
174    async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError>;
175    /// Loads bytes by id, returning `None` when missing.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the backend cannot read or verify the blob.
180    async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError>;
181    /// Reports whether a blob is present.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if the backend cannot inspect the blob.
186    async fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
187        Ok(self.get(id).await?.is_some())
188    }
189    /// Stores bytes only when their content id is absent, skipping the
190    /// upload for blobs the store already holds.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if the backend cannot inspect or persist the blob.
195    async fn put_if_absent(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
196        let id = digest(bytes);
197        if self.contains(&id).await? {
198            Ok(id)
199        } else {
200            self.put(bytes).await
201        }
202    }
203    /// Deletes a blob during garbage collection. Missing blobs are ignored.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the backend cannot delete the blob.
208    async fn delete(&self, id: &BlobId) -> Result<(), StoreError>;
209    /// Lists all blob identifiers known to this backend.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the backend cannot enumerate blobs.
214    async fn list(&self) -> Result<BlobIdStream, StoreError>;
215    /// Returns the blob's creation/last-modification time when available.
216    /// Backends without timestamps return `None`, which conservatively keeps
217    /// the blob whenever a non-zero retention window is active.
218    ///
219    /// # Errors
220    /// Returns an error if the backend cannot inspect blob metadata.
221    async fn modified_at(&self, _id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
222        Ok(None)
223    }
224}
225
226/// Named root pointer storage with compare-and-swap fencing.
227#[async_trait]
228pub trait RootStore: Send + Sync {
229    /// Reads a root pointer.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the backend cannot read the root.
234    async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError>;
235    /// Publishes a root only if the stored pointer equals `expected`.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if the fence does not match or the backend cannot publish.
240    async fn cas_root(
241        &self,
242        name: &str,
243        expected: Option<&[u8]>,
244        new: &[u8],
245    ) -> Result<(), StoreError>;
246    /// Removes a root pointer. Missing roots are ignored.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the backend cannot delete the root.
251    async fn delete_root(&self, name: &str) -> Result<(), StoreError>;
252    /// Lists root names beginning with `prefix`, in sorted order.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if the backend cannot enumerate roots.
257    async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
258}
259
260/// In-memory blob and root store for tests and embedded use.
261#[derive(Clone, Default)]
262pub struct MemoryStore {
263    inner: Arc<RwLock<MemoryInner>>,
264}
265#[derive(Default)]
266struct MemoryInner {
267    blobs: HashMap<BlobId, Vec<u8>>,
268    roots: BTreeMap<String, Vec<u8>>,
269}
270
271#[async_trait]
272impl BlobStore for MemoryStore {
273    async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
274        let inner = Arc::clone(&self.inner);
275        let bytes = bytes.to_vec();
276        run_blocking(move || {
277            let id = digest(&bytes);
278            inner
279                .write()
280                .expect("poisoned store lock")
281                .blobs
282                .insert(id.clone(), bytes);
283            Ok(id)
284        })
285        .await
286    }
287    async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
288        let inner = Arc::clone(&self.inner);
289        let id = id.clone();
290        run_blocking(move || {
291            Ok(inner
292                .read()
293                .expect("poisoned store lock")
294                .blobs
295                .get(&id)
296                .cloned())
297        })
298        .await
299    }
300    async fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
301        let inner = Arc::clone(&self.inner);
302        let id = id.clone();
303        run_blocking(move || {
304            inner
305                .write()
306                .expect("poisoned store lock")
307                .blobs
308                .remove(&id);
309            Ok(())
310        })
311        .await
312    }
313    async fn list(&self) -> Result<BlobIdStream, StoreError> {
314        let inner = Arc::clone(&self.inner);
315        let ids = run_blocking(move || {
316            Ok(inner
317                .read()
318                .expect("poisoned store lock")
319                .blobs
320                .keys()
321                .cloned()
322                .collect::<Vec<_>>())
323        })
324        .await?;
325        Ok(Box::pin(tokio_stream::iter(ids.into_iter().map(Ok))))
326    }
327}
328#[async_trait]
329impl RootStore for MemoryStore {
330    async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
331        let inner = Arc::clone(&self.inner);
332        let name = name.to_owned();
333        run_blocking(move || {
334            Ok(inner
335                .read()
336                .expect("poisoned store lock")
337                .roots
338                .get(&name)
339                .cloned())
340        })
341        .await
342    }
343    async fn cas_root(
344        &self,
345        name: &str,
346        expected: Option<&[u8]>,
347        new: &[u8],
348    ) -> Result<(), StoreError> {
349        let inner = Arc::clone(&self.inner);
350        let name = name.to_owned();
351        let expected = expected.map(<[u8]>::to_vec);
352        let new = new.to_vec();
353        run_blocking(move || {
354            let mut inner = inner.write().expect("poisoned store lock");
355            let actual = inner.roots.get(&name).cloned();
356            if actual != expected {
357                return Err(StoreError::CasFailed { expected, actual });
358            }
359            inner.roots.insert(name, new);
360            Ok(())
361        })
362        .await
363    }
364    async fn delete_root(&self, name: &str) -> Result<(), StoreError> {
365        let inner = Arc::clone(&self.inner);
366        let name = name.to_owned();
367        run_blocking(move || {
368            inner
369                .write()
370                .expect("poisoned store lock")
371                .roots
372                .remove(&name);
373            Ok(())
374        })
375        .await
376    }
377    async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
378        let inner = Arc::clone(&self.inner);
379        let prefix = prefix.to_owned();
380        run_blocking(move || {
381            Ok(inner
382                .read()
383                .expect("poisoned store lock")
384                .roots
385                .keys()
386                .filter(|name| name.starts_with(&prefix))
387                .cloned()
388                .collect())
389        })
390        .await
391    }
392}
393
394/// Filesystem-backed content-addressed blob and fenced root store.
395#[derive(Clone)]
396pub struct FsStore {
397    root: PathBuf,
398}
399impl FsStore {
400    /// Opens or creates a store below `root`.
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if the directory layout cannot be created.
405    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
406        let root = root.as_ref().to_path_buf();
407        fs::create_dir_all(root.join("blobs"))?;
408        fs::create_dir_all(root.join("roots"))?;
409        Ok(Self { root })
410    }
411    fn blob_path(&self, id: &BlobId) -> PathBuf {
412        self.root.join("blobs").join(id.as_str())
413    }
414    fn root_path(&self, name: &str) -> Result<PathBuf, StoreError> {
415        if name.is_empty()
416            || name == "."
417            || name == ".."
418            || name.contains('/')
419            || name.contains('\\')
420        {
421            return Err(StoreError::InvalidRootName(name.to_owned()));
422        }
423        Ok(self.root.join("roots").join(name))
424    }
425
426    fn root_lock(&self, name: &str) -> Result<RootLock, StoreError> {
427        let root_path = self.root_path(name)?;
428        RootLock::acquire(&root_path.with_extension("lock"))
429    }
430}
431#[async_trait]
432impl BlobStore for FsStore {
433    async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
434        let store = self.clone();
435        let bytes = bytes.to_vec();
436        run_blocking(move || {
437            let id = digest(&bytes);
438            let path = store.blob_path(&id);
439            if !path.exists() {
440                let tmp = path.with_extension("tmp");
441                fs::write(&tmp, &bytes)?;
442                fs::rename(tmp, path)?;
443            }
444            Ok(id)
445        })
446        .await
447    }
448    async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
449        let store = self.clone();
450        let id = id.clone();
451        run_blocking(move || {
452            let path = store.blob_path(&id);
453            if !path.exists() {
454                return Ok(None);
455            }
456            let bytes = fs::read(path)?;
457            if digest(&bytes) != id {
458                return Err(StoreError::CorruptBlob(id));
459            }
460            Ok(Some(bytes))
461        })
462        .await
463    }
464    async fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
465        let store = self.clone();
466        let id = id.clone();
467        run_blocking(move || Ok(store.blob_path(&id).is_file())).await
468    }
469    async fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
470        let store = self.clone();
471        let id = id.clone();
472        run_blocking(move || match fs::remove_file(store.blob_path(&id)) {
473            Ok(()) => Ok(()),
474            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
475            Err(error) => Err(error.into()),
476        })
477        .await
478    }
479    async fn list(&self) -> Result<BlobIdStream, StoreError> {
480        let path = self.root.join("blobs");
481        let entries = run_blocking(move || Ok(fs::read_dir(path)?)).await?;
482        let (tx, rx) = tokio::sync::mpsc::channel(64);
483        let failure_tx = tx.clone();
484        tokio::spawn(async move {
485            let result = tokio::task::spawn_blocking(move || {
486                for entry in entries {
487                    let id = (|| {
488                        let entry = entry?;
489                        if !entry.file_type()?.is_file() {
490                            return Ok(None);
491                        }
492                        Ok(entry.file_name().to_str().and_then(BlobId::from_hex))
493                    })();
494                    match id {
495                        Ok(Some(id)) => {
496                            if tx.blocking_send(Ok(id)).is_err() {
497                                return;
498                            }
499                        }
500                        Ok(None) => {}
501                        Err(error) => {
502                            let _ = tx.blocking_send(Err(StoreError::Io(error)));
503                            return;
504                        }
505                    }
506                }
507            })
508            .await;
509            if let Err(error) = result {
510                let _ = failure_tx
511                    .send(Err(StoreError::BlockingTask(error.to_string())))
512                    .await;
513            }
514        });
515        Ok(Box::pin(ReceiverStream::new(rx)))
516    }
517    async fn modified_at(&self, id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
518        let store = self.clone();
519        let id = id.clone();
520        run_blocking(move || match fs::metadata(store.blob_path(&id)) {
521            Ok(metadata) => Ok(Some(metadata.modified()?)),
522            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
523            Err(error) => Err(error.into()),
524        })
525        .await
526    }
527}
528#[async_trait]
529impl RootStore for FsStore {
530    async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
531        let store = self.clone();
532        let name = name.to_owned();
533        run_blocking(move || match fs::read(store.root_path(&name)?) {
534            Ok(value) => Ok(Some(value)),
535            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
536            Err(error) => Err(error.into()),
537        })
538        .await
539    }
540    async fn cas_root(
541        &self,
542        name: &str,
543        expected: Option<&[u8]>,
544        new: &[u8],
545    ) -> Result<(), StoreError> {
546        let store = self.clone();
547        let name = name.to_owned();
548        let expected = expected.map(<[u8]>::to_vec);
549        let new = new.to_vec();
550        run_blocking(move || {
551            let _lock = store.root_lock(&name)?;
552            let path = store.root_path(&name)?;
553            let actual = match fs::read(&path) {
554                Ok(value) => Some(value),
555                Err(error) if error.kind() == io::ErrorKind::NotFound => None,
556                Err(error) => return Err(error.into()),
557            };
558            if actual != expected {
559                return Err(StoreError::CasFailed { expected, actual });
560            }
561            let tmp = path.with_extension("tmp");
562            fs::write(&tmp, new)?;
563            fs::rename(tmp, path)?;
564            Ok(())
565        })
566        .await
567    }
568    async fn delete_root(&self, name: &str) -> Result<(), StoreError> {
569        let store = self.clone();
570        let name = name.to_owned();
571        run_blocking(move || {
572            let _lock = store.root_lock(&name)?;
573            match fs::remove_file(store.root_path(&name)?) {
574                Ok(()) => Ok(()),
575                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
576                Err(error) => Err(error.into()),
577            }
578        })
579        .await
580    }
581    async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
582        let root = self.root.clone();
583        let prefix = prefix.to_owned();
584        run_blocking(move || {
585            let mut names = Vec::new();
586            for entry in fs::read_dir(root.join("roots"))? {
587                let entry = entry?;
588                if !entry.file_type()?.is_file() {
589                    continue;
590                }
591                if let Some(name) = entry.file_name().to_str() {
592                    let auxiliary = Path::new(name).extension().is_some_and(|ext| {
593                        ext.eq_ignore_ascii_case("lock") || ext.eq_ignore_ascii_case("tmp")
594                    });
595                    if name.starts_with(&prefix) && !auxiliary {
596                        names.push(name.to_owned());
597                    }
598                }
599            }
600            names.sort();
601            Ok(names)
602        })
603        .await
604    }
605}
606
607/// Computes the content id [`BlobStore::put`] would assign to `bytes`.
608#[must_use]
609pub fn digest(bytes: &[u8]) -> BlobId {
610    BlobId(blake3::hash(bytes).to_hex().to_string())
611}
612
613/// Root-store key for a database's published index root.
614#[must_use]
615pub fn db_root_name(db: &str) -> String {
616    format!("db:{db}")
617}
618
619/// Root-store key for a database's durable schema and naming metadata.
620#[must_use]
621pub fn meta_root_name(db: &str) -> String {
622    format!("meta:{db}")
623}
624
625/// Storage format written by this release.
626///
627/// Format 2 (M7) folds the write lease into the root record so lease
628/// ownership and index publication are fenced by one atomic CAS; format 1
629/// roots (separate `lease:` record) decode with an unowned lease.
630///
631/// Format 3 publishes each covering index as a manifest blob naming
632/// content-defined leaf chunks (see [`snapshot`](self::snapshot)-module
633/// items such as [`chunk_segment_keys`]), so consecutive publications share
634/// unchanged chunks instead of rewriting the whole index; format-2 flat
635/// single-blob snapshots remain readable.
636pub const FORMAT_VERSION: u32 = 3;
637
638/// Published durable index-root metadata carrying the write lease
639/// (see `docs/design/log-and-transactor.md`).
640///
641/// The lease fields and the index fields live in one record on purpose:
642/// every mutation — lease acquisition, renewal, release, index publication —
643/// is a CAS on these bytes, so a writer whose ownership has changed hands
644/// always fails its next CAS and can never install a root. No cross-record
645/// atomicity is required of the store.
646#[derive(Clone, Debug, Eq, PartialEq)]
647pub struct DbRoot {
648    /// On-disk format version. Readers reject roots from newer formats.
649    pub format_version: u32,
650    /// Fencing version; increments on every change of lease ownership.
651    pub lease_version: u64,
652    /// Owning transactor id; empty when the lease has never been acquired
653    /// under format 2.
654    pub owner: String,
655    /// Lease expiry as Unix milliseconds; `0` when released/never held.
656    pub lease_expires_unix_ms: i64,
657    /// Client endpoint advertised by the owner (for peer lease-holder
658    /// rediscovery); empty when the owner does not advertise one.
659    pub owner_endpoint: String,
660    /// Highest indexed transaction.
661    pub index_basis_t: u64,
662    /// EAVT, AEVT, AVET, and VAET blob ids; `None` before the first index
663    /// publication (a bare fence bump).
664    pub roots: Option<[BlobId; 4]>,
665    /// Next unallocated user-partition entity id as of `index_basis_t`.
666    ///
667    /// A transactor recovering from the index root replays only the log
668    /// tail, so entities created *and* fully retracted before the snapshot
669    /// carry no live datom and are invisible to it. Persisting the writer's
670    /// allocation high-water lets recovery resume past those ids instead of
671    /// reusing them. `0` in roots written before this field existed (and in
672    /// a bare fence bump with no published snapshot); such roots force
673    /// full-log replay, which reconstructs the allocator exactly.
674    pub next_entity_id: u64,
675    /// Largest `:db/txInstant` (Unix ms) committed through `index_basis_t`.
676    ///
677    /// Preserves `:db/txInstant` monotonicity across a recovery whose log
678    /// tail is empty (the snapshot alone does not carry the last commit's
679    /// instant). `i64::MIN` when absent, which is dominated by any real
680    /// instant and so is a safe floor.
681    pub last_tx_instant: i64,
682}
683
684/// Encodes a possibly empty single-line field.
685fn field_line(out: &mut String, value: &str) {
686    if value.is_empty() {
687        out.push('-');
688    } else {
689        out.push_str(value);
690    }
691    out.push('\n');
692}
693
694fn parse_field(line: &str) -> String {
695    if line == "-" {
696        String::new()
697    } else {
698        line.to_owned()
699    }
700}
701
702impl DbRoot {
703    /// Encodes the root for the root store.
704    #[must_use]
705    pub fn encode(&self) -> Vec<u8> {
706        let mut out = format!(
707            "corium-root-v{}\n{}\n{}\n",
708            self.format_version, self.lease_version, self.index_basis_t
709        );
710        match &self.roots {
711            Some(roots) => {
712                for root in roots {
713                    out.push_str(root.as_str());
714                    out.push('\n');
715                }
716            }
717            None => out.push_str("-\n-\n-\n-\n"),
718        }
719        field_line(&mut out, &self.owner);
720        out.push_str(&self.lease_expires_unix_ms.to_string());
721        out.push('\n');
722        field_line(&mut out, &self.owner_endpoint);
723        // Recovery hints (appended after the format-2 lease fields, so older
724        // binaries that stop reading at `owner_endpoint` ignore them and this
725        // stays a plain trailing extension of the same record).
726        out.push_str(&self.next_entity_id.to_string());
727        out.push('\n');
728        out.push_str(&self.last_tx_instant.to_string());
729        out.into_bytes()
730    }
731
732    /// Decodes stored root bytes (any format up to [`FORMAT_VERSION`];
733    /// newer formats still yield their fence fields so old binaries fence
734    /// correctly, and callers reject them via `format_version`).
735    #[must_use]
736    pub fn decode(bytes: &[u8]) -> Option<Self> {
737        let text = std::str::from_utf8(bytes).ok()?;
738        let mut lines = text.lines();
739        let first = lines.next()?;
740        let (format_version, lease_version) =
741            if let Some(version) = first.strip_prefix("corium-root-v") {
742                let format_version = version.parse().ok()?;
743                let lease_version = lines.next()?.parse().ok()?;
744                (format_version, lease_version)
745            } else {
746                // M1-M5 roots had no header. Keep them readable as format v1 so
747                // an existing database can be upgraded in place.
748                (1, first.parse().ok()?)
749            };
750        let index_basis_t = lines.next()?.parse().ok()?;
751        let ids: Vec<&str> = lines.by_ref().take(4).collect();
752        if ids.len() != 4 {
753            return None;
754        }
755        let roots = if ids.iter().all(|id| *id == "-") {
756            None
757        } else {
758            Some([
759                BlobId::from_hex(ids[0])?,
760                BlobId::from_hex(ids[1])?,
761                BlobId::from_hex(ids[2])?,
762                BlobId::from_hex(ids[3])?,
763            ])
764        };
765        // Lease fields; absent in format-1 roots.
766        let owner = lines.next().map(parse_field).unwrap_or_default();
767        let lease_expires_unix_ms = lines.next().and_then(|l| l.parse().ok()).unwrap_or(0);
768        let owner_endpoint = lines.next().map(parse_field).unwrap_or_default();
769        // Recovery hints; absent in roots written before index-root recovery.
770        // A missing `next_entity_id` (0) signals "no hint", forcing full-log
771        // replay, so the default must never look like a valid allocator id.
772        let next_entity_id = lines.next().and_then(|l| l.parse().ok()).unwrap_or(0);
773        let last_tx_instant = lines
774            .next()
775            .and_then(|l| l.parse().ok())
776            .unwrap_or(i64::MIN);
777        Some(Self {
778            format_version,
779            lease_version,
780            owner,
781            lease_expires_unix_ms,
782            owner_endpoint,
783            index_basis_t,
784            roots,
785            next_entity_id,
786            last_tx_instant,
787        })
788    }
789}
790
791/// Result counters from a mark-and-sweep garbage collection pass.
792#[derive(Clone, Copy, Debug, Eq, PartialEq)]
793pub struct GcReport {
794    /// Number of blobs reachable from the supplied roots.
795    pub marked: usize,
796    /// Number of unreachable blobs deleted.
797    pub swept: usize,
798    /// Number of unreachable blobs kept because they are inside retention.
799    pub retained: usize,
800}
801
802/// Marks blobs reachable from `live_roots` and deletes every unmarked blob.
803///
804/// `children` decodes references from each present blob. Callers are responsible for
805/// supplying every currently live root and for applying any desired retention window.
806///
807/// # Errors
808///
809/// Returns an error if a blob operation or child-reference decode fails.
810pub async fn mark_and_sweep(
811    store: &dyn BlobStore,
812    live_roots: impl IntoIterator<Item = BlobId>,
813    mut children: impl FnMut(&BlobId, &[u8]) -> Result<Vec<BlobId>, StoreError>,
814) -> Result<GcReport, StoreError> {
815    mark_and_sweep_retained(
816        store,
817        live_roots,
818        &mut children,
819        Duration::ZERO,
820        SystemTime::now(),
821    )
822    .await
823}
824
825/// Marks reachable blobs and deletes only unreachable blobs older than
826/// `retention` relative to `now`.
827///
828/// # Errors
829/// Returns an error if a blob operation or child-reference decode fails.
830pub async fn mark_and_sweep_retained(
831    store: &dyn BlobStore,
832    live_roots: impl IntoIterator<Item = BlobId>,
833    mut children: impl FnMut(&BlobId, &[u8]) -> Result<Vec<BlobId>, StoreError>,
834    retention: Duration,
835    now: SystemTime,
836) -> Result<GcReport, StoreError> {
837    let mut marked = HashSet::new();
838    let mut pending = live_roots.into_iter().collect::<Vec<_>>();
839    while let Some(id) = pending.pop() {
840        if !marked.insert(id.clone()) {
841            continue;
842        }
843        let bytes = store
844            .get(&id)
845            .await?
846            .ok_or_else(|| StoreError::MissingBlob(id.clone()))?;
847        pending.extend(children(&id, &bytes)?);
848    }
849
850    let mut swept = 0;
851    let mut retained = 0;
852    let mut ids = store.list().await?;
853    while let Some(id) = ids.next().await {
854        let id = id?;
855        if !marked.contains(&id) {
856            // A zero window is the explicit immediate-sweep escape hatch and
857            // does not require backend timestamp support. Otherwise, unknown
858            // timestamps fail safe by retaining the blob.
859            let old_enough = retention.is_zero()
860                || store.modified_at(&id).await?.is_some_and(|modified| {
861                    now.duration_since(modified).unwrap_or_default() >= retention
862                });
863            if old_enough {
864                store.delete(&id).await?;
865                swept += 1;
866            } else {
867                retained += 1;
868            }
869        }
870    }
871    Ok(GcReport {
872        marked: marked.len(),
873        swept,
874        retained,
875    })
876}
877
878struct RootLock {
879    file: File,
880}
881
882impl RootLock {
883    fn acquire(path: &Path) -> Result<Self, StoreError> {
884        let file = OpenOptions::new()
885            .read(true)
886            .write(true)
887            .create(true)
888            .truncate(false)
889            .open(path)?;
890        file.lock_exclusive()?;
891        Ok(Self { file })
892    }
893}
894
895impl Drop for RootLock {
896    fn drop(&mut self) {
897        let _ = FileExt::unlock(&self.file);
898        // Keep the lock file in place so every contender locks the same inode.
899        // Unlinking it here would let a new opener lock a replacement file while
900        // a waiter still holds a descriptor for the unlinked original.
901    }
902}
903
904/// Small read-through segment cache keyed by blob id.
905#[derive(Default)]
906pub struct SegmentCache {
907    entries: RwLock<HashMap<BlobId, Arc<[u8]>>>,
908}
909impl SegmentCache {
910    /// Returns cached bytes, loading from `store` on miss.
911    ///
912    /// # Errors
913    ///
914    /// Returns an error if the backing store cannot load the blob.
915    ///
916    /// # Panics
917    ///
918    /// Panics if the internal cache lock is poisoned.
919    pub async fn get_or_load(
920        &self,
921        store: &dyn BlobStore,
922        id: &BlobId,
923    ) -> Result<Option<Arc<[u8]>>, StoreError> {
924        if let Some(v) = self.entries.read().expect("poisoned cache lock").get(id) {
925            return Ok(Some(v.clone()));
926        }
927        let Some(bytes) = store.get(id).await? else {
928            return Ok(None);
929        };
930        let bytes: Arc<[u8]> = bytes.into();
931        self.entries
932            .write()
933            .expect("poisoned cache lock")
934            .insert(id.clone(), bytes.clone());
935        Ok(Some(bytes))
936    }
937}