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