Skip to main content

corium_store/
lib.rs

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