1use 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#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
56pub struct BlobId(String);
57
58impl BlobId {
59 #[must_use]
61 pub fn as_str(&self) -> &str {
62 &self.0
63 }
64
65 #[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#[derive(Debug, Error)]
81pub enum StoreError {
82 #[error("store I/O failed: {0}")]
84 Io(#[from] io::Error),
85 #[error("root CAS failed: expected {expected:?}, actual {actual:?}")]
87 CasFailed {
88 expected: Option<Vec<u8>>,
90 actual: Option<Vec<u8>>,
92 },
93 #[error("blob content did not match digest {0}")]
95 CorruptBlob(BlobId),
96 #[error("reachable blob is missing: {0}")]
98 MissingBlob(BlobId),
99 #[error("encrypted blob requires unavailable storage-key epoch {0}")]
101 MissingEncryptionKey(u32),
102 #[error("blob store returned id {actual}, expected {expected}")]
104 BlobIdMismatch {
105 expected: BlobId,
107 actual: BlobId,
109 },
110 #[error("encrypted blob failed: {0}")]
112 Encryption(#[from] corium_crypt::CryptError),
113 #[error("storage key unavailable: {0}")]
115 Keyring(corium_crypt::KeyError),
116 #[error("invalid key manifest: {0}")]
118 InvalidKeyManifest(String),
119 #[error("key manifest format {found} is newer than supported format {supported}")]
121 UnsupportedKeyManifest {
122 found: u32,
124 supported: u32,
126 },
127 #[error("unsupported storage encryption algorithm {0:?}")]
129 UnsupportedKeyAlgorithm(String),
130 #[error("storage key epochs are exhausted")]
132 StorageEpochExhausted,
133 #[error("invalid root name {0:?}")]
135 InvalidRootName(String),
136 #[error("store blocking task failed: {0}")]
138 BlockingTask(String),
139 #[cfg(feature = "postgres")]
141 #[error("PostgreSQL store failed: {0}")]
142 Postgres(#[from] deadpool_postgres::tokio_postgres::Error),
143 #[cfg(feature = "postgres")]
145 #[error("PostgreSQL connection pool failed: {0}")]
146 PostgresPool(#[from] deadpool_postgres::PoolError),
147 #[cfg(feature = "postgres")]
149 #[error("PostgreSQL connection pool configuration failed: {0}")]
150 PostgresPoolCreate(#[from] deadpool_postgres::CreatePoolError),
151 #[cfg(feature = "postgres")]
153 #[error("cannot load native certificate roots for PostgreSQL TLS: {0}")]
154 PostgresTlsRoots(String),
155 #[cfg(feature = "postgres")]
157 #[error("PostgreSQL store contains invalid data: {0}")]
158 InvalidPostgresData(String),
159 #[cfg(feature = "turso")]
161 #[error("Turso blob store failed: {0}")]
162 Turso(#[from] turso::Error),
163 #[cfg(feature = "turso")]
165 #[error("Turso database path is not valid UTF-8: {0:?}")]
166 InvalidTursoPath(PathBuf),
167 #[cfg(feature = "turso")]
169 #[error("Turso blob store contains invalid data: {0}")]
170 InvalidTursoData(String),
171 #[cfg(feature = "s3")]
173 #[error("S3 store failed: {0}")]
174 S3(String),
175 #[cfg(feature = "s3")]
177 #[error("S3 store contains invalid data: {0}")]
178 InvalidS3Data(String),
179}
180
181#[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
199pub 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#[async_trait]
215pub trait BlobStore: Send + Sync {
216 async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError>;
222 async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError>;
228 async fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
234 Ok(self.get(id).await?.is_some())
235 }
236 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 async fn delete(&self, id: &BlobId) -> Result<(), StoreError>;
256 async fn list(&self) -> Result<BlobIdStream, StoreError>;
262 async fn modified_at(&self, _id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
269 Ok(None)
270 }
271}
272
273#[async_trait]
275pub trait RootStore: Send + Sync {
276 async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError>;
282 async fn cas_root(
288 &self,
289 name: &str,
290 expected: Option<&[u8]>,
291 new: &[u8],
292 ) -> Result<(), StoreError>;
293 async fn delete_root(&self, name: &str) -> Result<(), StoreError>;
299 async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
305}
306
307#[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#[derive(Clone)]
443pub struct FsStore {
444 root: PathBuf,
445}
446impl FsStore {
447 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#[must_use]
656pub fn digest(bytes: &[u8]) -> BlobId {
657 BlobId(blake3::hash(bytes).to_hex().to_string())
658}
659
660#[must_use]
662pub fn db_root_name(db: &str) -> String {
663 format!("db:{db}")
664}
665
666#[must_use]
668pub fn meta_root_name(db: &str) -> String {
669 format!("meta:{db}")
670}
671
672pub const FORMAT_VERSION: u32 = 4;
690
691#[derive(Clone, Debug, Eq, PartialEq)]
700pub struct DbRoot {
701 pub format_version: u32,
703 pub lease_version: u64,
705 pub owner: String,
708 pub lease_expires_unix_ms: i64,
710 pub owner_endpoint: String,
713 pub index_basis_t: u64,
715 pub roots: Option<[BlobId; 4]>,
718 pub next_entity_id: u64,
728 pub last_tx_instant: i64,
735 pub key_manifest_version: u64,
745}
746
747fn 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 #[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 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 #[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 (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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
862pub struct GcReport {
863 pub marked: usize,
865 pub swept: usize,
867 pub retained: usize,
869}
870
871pub 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
894pub 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 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 }
971}