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