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}