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