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