Skip to main content

coven_replication/sync/store/blob/
cache.rs

1//! The device-local cache implementation for **Remote** blobs: bytes on disk, keyed by the exact
2//! locator hash,
3//! with the folder the file lives in as the only retention truth.
4//!
5//! The cache holds copies of Remote blobs only — re-fetchable from the cloud,
6//! evictable to a size budget, kept-or-dropped per pin. A **Local** blob is not in
7//! the cache: a user-provided Local blob is the user's own file at a path (an
8//! external ref); a host-provided Local blob is in the local store (see
9//! the store directory's local-blob capability). So `CacheEager`/`CacheLazy`/pin/budget all
10//! describe a blob only while it is Remote. See the [blob concept tree](crate::blob)
11//! for where the cache sits in the whole storage model.
12//!
13//! There is no cache table. A cached Remote blob is in **exactly one** of two
14//! folders under the store dir, or in neither. Both are segmented by the blob's
15//! namespace, so each namespace's cache evicts against its own budget without
16//! touching another's:
17//!
18//! - `storage/pinned/<namespace>/{ab}/{cd}/<locator-hash>` — kept, budget-exempt. A Remote
19//!   blob's cache copy the user pinned for offline (kept from eviction).
20//! - `storage/cache/<namespace>/{ab}/{cd}/<locator-hash>` — opportunistic, evictable. A blob
21//!   fetched on read (`CacheLazy`) or eagerly on pull (`CacheEager`).
22//! - neither — not cached. No file; fetched from the cloud on the next read.
23//!
24//! Presence is the file on disk; kept-ness is which folder. Nothing the two
25//! `readdir`s can't answer, so no metadata sidecar to keep in sync with the disk.
26//! Whole reads verify both plaintext size and content hash against the exact
27//! row-bound locator before trusting cached bytes; ranged reads do not (see
28//! below — a stream cannot afford a whole-file scan per range). A corrupt
29//! occupied path fails loudly and is never replaced. Pin/unpin stage a verified copy, publish it without replacing
30//! an occupied destination, then remove the source.
31//!
32//! Both reads **dispatch on coven's own authoritative state** — they never probe
33//! every store and take the first hit. The discriminator is the **locality root**
34//! plus the blob's intrinsic **provenance**, not "is there a local file here." Coven
35//! resolves the blob's backing row (found in the table its `namespace` declares) up
36//! to its gated root or remote root (see
37//! `Gates::root_kept_of`, then dispatches:
38//!
39//! - **Remote** with an exact locator ⇒ the bytes live in the cloud fronted by
40//!   the device cache. The first legitimate probe runs per-device cache
41//!   materialization — which no shared state records — checking `pinned/` then
42//!   `cache/`, then fetching the exact cloud object.
43//! - **PendingRemote** ⇒ the row's audience is remote but its exact cloud object is
44//!   not published yet. Provenance selects the verified upload source: the external
45//!   file for a user-provided blob or the local store for a host-provided blob.
46//! - **Local** ⇒ the bytes are on-device; provenance picks the copy. A
47//!   **user-provided** blob is the user's own external file (`local_blob_refs`), read
48//!   straight from its path and validated by size + content hash — its ref MUST exist
49//!   ([`BlobCacheError::NoExternalRef`] otherwise). A **host-provided** blob is in the
50//!   **local store** (owned by [`StoreDir`]), its only copy — a miss is
51//!   fail-loud corruption ([`BlobCacheError::NoLocalCopy`]). Neither falls through to
52//!   the cloud: a Local blob has no cloud copy.
53//!
54//! `read_blob` returns the entire blob in one call; `open_blob_stream` returns a
55//! [`BlobStream`] a host reads ranges from while streaming or seeking. Both resolve
56//! the source the same way, and each verifies what its own shape allows:
57//!
58//! - `read_blob` reads every byte, so it checks the plaintext's size and content
59//!   hash against the exact row-bound locator — including on a **cache hit**. That
60//!   check is not about cloud authenticity, which the AEAD already settled when the
61//!   bytes were fetched: a cache file is unsealed plaintext sitting on local disk,
62//!   carrying no tags of its own, so the row's hash is the only thing that can
63//!   refuse a file that rotted, was truncated by a partial write, or was edited.
64//!   It is free here precisely because this read touches every byte anyway. A cloud
65//!   miss fetches + decrypts the exact object once and populates `cache/`.
66//! - `open_blob_stream` costs each range its own bytes, so it cannot make that
67//!   check — re-hashing per range is the whole-file scan the stream exists to
68//!   avoid. A **local** source (including a cache hit) is read plain: its current
69//!   bytes are the answer to a read of it, and the one place a blob's bytes are
70//!   checked against the row's hash is publication, where they become canonical
71//!   synced content. A **Remote uncached** blob is read from the cloud object a
72//!   chunk at a time: each sealed chunk's tag covers its bytes, its index, and the
73//!   header framing the blob, so a chunk that opens is authentic and verification
74//!   is per chunk rather than per object. A blob stored in the clear (a browsable
75//!   home) has no tags to check a range against, so it takes the whole-object path
76//!   instead — see `open_blob_stream`.
77//!
78//! A local stream holds the file it opened for its whole life. That is a property,
79//! not an optimization — a path can be swapped between two reads, a descriptor
80//! cannot, so the stream keeps serving the file it opened even after that file is
81//! evicted, renamed, or replaced.
82//!
83//! The cache has a **per-namespace** size budget the host sets per device (see
84//! [`Database::set_cache_budget`]), so a small namespace (`covers`) is never wiped by
85//! pressure from a big one (`release_files`). A namespace's budget counts **only**
86//! the files under `cache/<namespace>/` — `pinned/` is structurally exempt, and
87//! `storage/local` (the local store) is never walked at all. After every populate
88//! into a namespace (`read_blob`'s miss-write and `write_blob`),
89//! [`evict_to_budget`] sums that namespace's `cache/<namespace>/` files and, if their
90//! total exceeds its budget, deletes the oldest by modification time until the total
91//! is back under it — touching only that namespace's subtree. Modification time is
92//! the recency proxy — there is no `last_accessed` column, the same folder-truth
93//! trade-off the whole cache makes; pinning retains the Remote blobs the user chose
94//! to keep local. With a namespace's budget unset eviction is off for it and its
95//! cache grows without bound. Tests can reset all of `cache/` in one sweep; a pinned
96//! blob (in `pinned/`) survives because it lives in the other folder.
97
98use coven_database::DbError;
99use coven_foundation::atomic_file::FileError;
100use coven_foundation::local_file::CommitNewFileError;
101use coven_foundation::store_dir::{
102    CachedLocatorRemovalError, PathTokenError, RequiredLocalBlobPathError, StoreBlobFileError,
103};
104use coven_protocol::objects::StorageError;
105use coven_storage::CloudSyncObjectStorage;
106
107/// Closed cloud access for one exact Remote blob. Store code resolves the
108/// authority; the cache only reads bytes with the supplied protection.
109pub(crate) struct RemoteBlobAccess<'a> {
110    storage: &'a dyn CloudSyncObjectStorage,
111    protection: RemoteBlobProtection,
112}
113
114enum RemoteBlobProtection {
115    Store,
116    Circle(coven_protocol::objects::BlobSpoolProtection),
117}
118
119impl<'a> RemoteBlobAccess<'a> {
120    pub(crate) fn circle(
121        storage: &'a dyn CloudSyncObjectStorage,
122        protection: coven_protocol::objects::BlobSpoolProtection,
123    ) -> Self {
124        Self {
125            storage,
126            protection: RemoteBlobProtection::Circle(protection),
127        }
128    }
129
130    pub(crate) fn store(storage: &'a dyn CloudSyncObjectStorage) -> Self {
131        Self {
132            storage,
133            protection: RemoteBlobProtection::Store,
134        }
135    }
136
137    #[cfg(any(test, feature = "test-utils"))]
138    pub(super) fn key_fingerprint(
139        &self,
140    ) -> Result<Option<coven_keys::encryption::KeyFingerprint>, StorageError> {
141        match &self.protection {
142            RemoteBlobProtection::Store => self.storage.store_blob_key_fingerprint(),
143            RemoteBlobProtection::Circle(coven_protocol::objects::BlobSpoolProtection::Opaque(
144                encryption,
145            )) => Ok(Some(encryption.seal_key_fingerprint())),
146            RemoteBlobProtection::Circle(
147                coven_protocol::objects::BlobSpoolProtection::Browsable,
148            ) => Ok(None),
149        }
150    }
151
152    pub(super) async fn stage_verified_plaintext(
153        &self,
154        stored: &coven_protocol::blob::locator::StoredBlobRef,
155        stage: coven_foundation::local_file::AtomicStagedFile,
156        progress: coven_storage::cloud::DownloadProgress,
157    ) -> Result<coven_foundation::local_file::AtomicStagedFile, BlobCacheError> {
158        match &self.protection {
159            RemoteBlobProtection::Store => {
160                self.storage
161                    .stage_verified_store_blob_plaintext(stored, stage, progress)
162                    .await
163            }
164            RemoteBlobProtection::Circle(protection) => {
165                self.storage
166                    .stage_verified_blob_plaintext(stored, protection.clone(), stage, progress)
167                    .await
168            }
169        }
170        .map_err(Into::into)
171    }
172
173    pub(super) async fn open_range_reader(
174        &self,
175        stored: &coven_protocol::blob::locator::StoredBlobRef,
176    ) -> Result<coven_storage::BlobRangeReader, BlobCacheError> {
177        match &self.protection {
178            RemoteBlobProtection::Store => self.storage.open_store_blob_range_reader(stored).await,
179            RemoteBlobProtection::Circle(protection) => {
180                self.storage
181                    .open_blob_range_reader(stored, protection.clone())
182                    .await
183            }
184        }
185        .map_err(Into::into)
186    }
187}
188
189/// Why a blob-cache operation failed.
190#[derive(Debug)]
191pub enum BlobCacheError {
192    /// A blob `id`/`namespace`/`cloud_path` that can't form a safe path — bad data
193    /// that could escape the store dir or can't be partitioned. The blob is
194    /// refused before any path is built (the same gate the pull runs).
195    Path(PathTokenError),
196    /// A cloud read failed: the blob isn't in the cloud, or the backend errored
197    /// (surfaced from the exact blob operations on `CloudSyncObjectStorage`).
198    Storage(StorageError),
199    /// A Remote blob's bytes were needed from the cloud but no cloud home is
200    /// connected, so there is no storage to fetch them from. A home-less store
201    /// holds only Local blobs (external refs + the local store), which serve
202    /// straight off disk and never reach the cloud-miss path; reaching here means
203    /// a Remote blob was read with no provider connected — a real fault, surfaced
204    /// rather than masked.
205    NoCloudHome,
206    /// A local-disk failure: a cache write, a folder move, or a test cache reset.
207    File(FileError),
208    /// Publishing a staged cache file failed.
209    Commit(CommitNewFileError),
210    /// A blob-metadata query failed — resolving the blob's locality, looking up its
211    /// external ref, or reading its cache budget or expected size. A database read
212    /// the blob path depends on, distinct from a disk I/O failure.
213    Metadata(DbError),
214    /// Building the sync storage from config failed — missing credentials or cloud
215    /// configuration — when a Remote blob needed it. A configuration fault, not a
216    /// disk I/O error.
217    StorageSetup(coven_storage::cloud::setup::StorageSetupError),
218    /// The blob's declared authority cannot open its stored representation.
219    OpeningAuthority(coven_protocol::blob::BlobOpeningAuthorityError),
220    /// A registered external blob ref (a user-provided Local blob's user-owned
221    /// file) points at a file that is no longer there — the user moved, renamed, or
222    /// deleted it. Terminal: an external blob has no cloud copy to fall back to, so
223    /// this never re-fetches. The host surfaces a "files missing / moved" state
224    /// whose actions are relocate (pick the new folder, re-register) or re-import.
225    ExternalMissing {
226        id: String,
227        path: std::path::PathBuf,
228        /// The underlying read failure — a missing file or a real I/O error,
229        /// preserved rather than collapsed so the host sees why the read failed.
230        source: FileError,
231    },
232    /// A registered external blob's file is present but its length no longer matches
233    /// the registered `size` — the user truncated it or replaced it with a
234    /// different-length file. Terminal like [`Self::ExternalMissing`]: a mismatch
235    /// means this is not the exact file coven registered.
236    ExternalSizeMismatch {
237        id: String,
238        path: std::path::PathBuf,
239    },
240    /// A local-store blob has a different length from its stored declaration.
241    LocalSizeMismatch {
242        path: std::path::PathBuf,
243        expected_size: u64,
244        actual_size: u64,
245    },
246    /// A **Local** blob (its gated locality root's gate is off) has no copy in the
247    /// local store. A Local blob has no cloud copy, so there is nothing to fall back
248    /// to: the state is broken, not a cache miss. Surfaced loud rather than silently
249    /// fetching from the cloud — a make_local rollback leftover, an interrupted
250    /// materialize, or a lost local file would otherwise be papered over. The host
251    /// re-materializes or repairs.
252    NoLocalCopy { namespace: String, id: String },
253    /// A blob could not be resolved to a locality: its namespace declares no
254    /// blob-bearing table, or that table has no row with the id, or the row reaches no
255    /// gated root or remote root — so the source of Local-vs-Remote truth can't be
256    /// read. In a consistent store every readable blob has a locality root, so this
257    /// is a real fault — surfaced rather than guessing a source by probing.
258    LocalityUnresolved { id: String },
259    /// The gate resolved a blob to **Local + user-provided**, but no external-ref row
260    /// is registered for it. A user-provided Local blob's bytes live only at the user's
261    /// path, tracked by that ref; its absence is corruption (a lost or never-written
262    /// ref), not a cache miss to fall through — surfaced loud so the host repairs or
263    /// re-imports.
264    NoExternalRef { id: String },
265    /// An authoritative local plaintext file exists at the exact path but its
266    /// bytes differ from the row's signed size/hash.
267    LocalIntegrity {
268        path: std::path::PathBuf,
269        expected_size: u64,
270        actual_size: u64,
271        expected_hash: coven_protocol::store_commit::ObjectHash,
272        actual_hash: coven_protocol::store_commit::ObjectHash,
273    },
274    /// Adding the requested range length overflowed its offset.
275    RangeOverflow { id: String, offset: u64, len: u64 },
276    /// The requested range lies outside the opened blob.
277    RangeOutOfBounds {
278        id: String,
279        offset: u64,
280        end: u64,
281        size: u64,
282    },
283}
284
285/// A Remote blob read needs sync storage; if building it from config fails
286/// (missing credentials or cloud configuration) the read surfaces that as a
287/// configuration fault, not a disk I/O error. The cache error preserves the
288/// setup failure's message at this API boundary.
289impl From<coven_storage::cloud::setup::StorageSetupError> for BlobCacheError {
290    fn from(e: coven_storage::cloud::setup::StorageSetupError) -> Self {
291        BlobCacheError::StorageSetup(e)
292    }
293}
294
295impl std::fmt::Display for BlobCacheError {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        match self {
298            BlobCacheError::Path(e) => write!(f, "blob path error: {e}"),
299            BlobCacheError::Storage(e) => write!(f, "blob cache storage error: {e}"),
300            BlobCacheError::NoCloudHome => {
301                write!(f, "no cloud home connected to read a Remote blob")
302            }
303            BlobCacheError::File(e) => write!(f, "blob cache file error: {e}"),
304            BlobCacheError::Commit(e) => write!(f, "publish blob cache file: {e}"),
305            BlobCacheError::Metadata(e) => write!(f, "blob metadata error: {e}"),
306            BlobCacheError::StorageSetup(e) => write!(f, "sync storage setup failed: {e}"),
307            BlobCacheError::OpeningAuthority(e) => write!(f, "blob opening authority: {e}"),
308            BlobCacheError::ExternalMissing { id, path, source } => write!(
309                f,
310                "external blob {id} could not be read at {}: {source}",
311                path.display()
312            ),
313            BlobCacheError::ExternalSizeMismatch { id, path } => write!(
314                f,
315                "external blob {id} at {} no longer matches its registered size",
316                path.display()
317            ),
318            BlobCacheError::LocalSizeMismatch {
319                path,
320                expected_size,
321                actual_size,
322            } => write!(
323                f,
324                "local blob {} has {actual_size} bytes, expected {expected_size}",
325                path.display()
326            ),
327            BlobCacheError::NoLocalCopy { namespace, id } => write!(
328                f,
329                "local blob {namespace}/{id} is gated Local but absent from the local store"
330            ),
331            BlobCacheError::LocalityUnresolved { id } => write!(
332                f,
333                "cannot resolve locality for blob {id}: no locality root determines where it lives"
334            ),
335            BlobCacheError::NoExternalRef { id } => write!(
336                f,
337                "user-provided Local blob {id} has no registered external ref"
338            ),
339            BlobCacheError::LocalIntegrity {
340                path,
341                expected_size,
342                actual_size,
343                expected_hash,
344                actual_hash,
345            } => write!(
346                f,
347                "local blob {} has size/hash {actual_size}/{actual_hash}, expected {expected_size}/{expected_hash}",
348                path.display()
349            ),
350            BlobCacheError::RangeOverflow { id, offset, len } => write!(
351                f,
352                "blob range overflow for {id}: offset={offset}, len={len}"
353            ),
354            BlobCacheError::RangeOutOfBounds {
355                id,
356                offset,
357                end,
358                size,
359            } => write!(
360                f,
361                "blob range {offset}..{end} for {id} exceeds blob size {size}"
362            ),
363        }
364    }
365}
366
367impl std::error::Error for BlobCacheError {
368    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
369        match self {
370            Self::Path(source) => Some(source),
371            Self::Storage(source) => Some(source),
372            Self::File(source) => Some(source),
373            Self::Commit(source) => Some(source),
374            Self::Metadata(source) => Some(source),
375            Self::StorageSetup(source) => Some(source),
376            Self::OpeningAuthority(source) => Some(source),
377            Self::ExternalMissing { source, .. } => Some(source),
378            Self::NoCloudHome
379            | Self::ExternalSizeMismatch { .. }
380            | Self::LocalSizeMismatch { .. }
381            | Self::NoLocalCopy { .. }
382            | Self::LocalityUnresolved { .. }
383            | Self::NoExternalRef { .. }
384            | Self::LocalIntegrity { .. }
385            | Self::RangeOverflow { .. }
386            | Self::RangeOutOfBounds { .. } => None,
387        }
388    }
389}
390
391impl From<PathTokenError> for BlobCacheError {
392    fn from(e: PathTokenError) -> Self {
393        BlobCacheError::Path(e)
394    }
395}
396
397impl From<RequiredLocalBlobPathError> for BlobCacheError {
398    fn from(error: RequiredLocalBlobPathError) -> Self {
399        match error {
400            RequiredLocalBlobPathError::Path(error) => Self::Path(error),
401            RequiredLocalBlobPathError::Missing { namespace, id } => {
402                Self::NoLocalCopy { namespace, id }
403            }
404            RequiredLocalBlobPathError::File(error) => Self::File(error),
405        }
406    }
407}
408
409impl From<CachedLocatorRemovalError> for BlobCacheError {
410    fn from(error: CachedLocatorRemovalError) -> Self {
411        match error {
412            CachedLocatorRemovalError::Path(error) => Self::Path(error),
413            CachedLocatorRemovalError::File(error) => Self::File(error),
414        }
415    }
416}
417
418impl From<StoreBlobFileError> for BlobCacheError {
419    fn from(error: StoreBlobFileError) -> Self {
420        match error {
421            StoreBlobFileError::Path(error) => Self::Path(error),
422            StoreBlobFileError::File(error) => Self::File(error),
423            StoreBlobFileError::Commit(error) => Self::Commit(error),
424            StoreBlobFileError::Integrity {
425                path,
426                expected_size,
427                actual_size,
428                expected_hash,
429                actual_hash,
430            } => Self::LocalIntegrity {
431                path,
432                expected_size,
433                actual_size,
434                expected_hash,
435                actual_hash,
436            },
437        }
438    }
439}
440
441impl From<DbError> for BlobCacheError {
442    fn from(error: DbError) -> Self {
443        Self::Metadata(error)
444    }
445}
446
447impl From<StorageError> for BlobCacheError {
448    fn from(e: StorageError) -> Self {
449        BlobCacheError::Storage(e)
450    }
451}
452
453impl From<coven_foundation::store_dir::LocalBlobStoreError> for BlobCacheError {
454    fn from(e: coven_foundation::store_dir::LocalBlobStoreError) -> Self {
455        use coven_foundation::store_dir::LocalBlobStoreError;
456        match e {
457            LocalBlobStoreError::Path(p) => BlobCacheError::Path(p),
458            LocalBlobStoreError::File(error) => BlobCacheError::File(error),
459            LocalBlobStoreError::SizeMismatch {
460                path,
461                expected_size,
462                actual_size,
463            } => BlobCacheError::LocalSizeMismatch {
464                path,
465                expected_size,
466                actual_size,
467            },
468        }
469    }
470}
471
472/// One opened blob, ready to serve ranges. Held by a host that is streaming or
473/// seeking a blob (playback probing a codec header, then a tail, then decoding
474/// forward) rather than loading it whole.
475///
476/// A range costs the bytes it returns, from either source, but for different
477/// reasons:
478///
479/// - **Local** (an external file, the local store, or a cache copy) — the stream
480///   holds the open file and every range is one positioned read of it. No
481///   hashing: a local file's current bytes are the answer to a read of it, and a
482///   blob's bytes are checked against the hash its row declares at publication,
483///   which is where they become canonical synced content.
484/// - **Remote, uncached** — the stream fetches only the sealed chunks covering
485///   the range and opens them. A chunk that opens is authentic: the provider
486///   holds no key and cannot forge a tag, and the tag covers the chunk's bytes,
487///   its index, and the header framing the blob. So verification is per chunk,
488///   which is what lets a range cost a range rather than the object.
489///
490/// Holding the local descriptor is a property, not an optimization: a path can be
491/// replaced between two reads, a descriptor cannot, so the stream keeps serving
492/// the file it opened even if that file is later evicted, renamed, or replaced.
493/// An **in-place** rewrite of that same file does reach the stream — that is a
494/// file the user owns and edits; coven's own copies are published by rename or
495/// hard link and never written in place.
496pub struct BlobStream {
497    blob: coven_protocol::blob::BlobRef,
498    source: BlobStreamSource,
499}
500
501/// Where an open stream's ranges come from.
502pub(super) enum BlobStreamSource {
503    /// A file on this device: the user's own external file, the local store, or
504    /// a cache copy of a Remote blob.
505    Local(coven_foundation::local_file::OpenFile),
506    /// A Remote blob with no cache copy: ranges are served from the cloud object
507    /// a chunk at a time.
508    Remote(coven_storage::BlobRangeReader),
509}
510
511impl BlobStream {
512    pub(super) fn from_source(
513        blob: coven_protocol::blob::BlobRef,
514        source: BlobStreamSource,
515    ) -> Self {
516        Self { blob, source }
517    }
518
519    /// The blob's whole plaintext length. Every range must lie inside it.
520    pub fn plaintext_size(&self) -> u64 {
521        match &self.source {
522            BlobStreamSource::Local(file) => file.size(),
523            BlobStreamSource::Remote(reader) => reader.plaintext_size(),
524        }
525    }
526
527    /// Serve `len` plaintext bytes starting at `offset`.
528    ///
529    /// `len == 0` is an empty result, and an `offset + len` past the blob's
530    /// plaintext size (or an overflow) is an error, never a short read.
531    pub async fn read_at(&self, offset: u64, len: u64) -> Result<Vec<u8>, BlobCacheError> {
532        if len == 0 {
533            return Ok(Vec::new());
534        }
535        let end = offset
536            .checked_add(len)
537            .ok_or_else(|| BlobCacheError::RangeOverflow {
538                id: self.blob.id.clone(),
539                offset,
540                len,
541            })?;
542        let source_size = self.plaintext_size();
543        if end > source_size {
544            return Err(BlobCacheError::RangeOutOfBounds {
545                id: self.blob.id.clone(),
546                offset,
547                end,
548                size: source_size,
549            });
550        }
551        match &self.source {
552            BlobStreamSource::Local(file) => file
553                .read_at(offset, len)
554                .await
555                .map_err(BlobCacheError::File),
556            BlobStreamSource::Remote(reader) => reader
557                .read_at(offset, len)
558                .await
559                .map_err(BlobCacheError::Storage),
560        }
561    }
562}