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