coven_core/blob/cache.rs
1//! The device-local cache 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//! [`local_files`](super::local_files)). 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//! Reads verify both plaintext size and content hash against the exact row-bound
27//! locator before trusting cached bytes. A corrupt occupied path fails loudly and
28//! is never replaced. Pin/unpin stage a verified copy, publish it without replacing
29//! an occupied destination, then remove the source.
30//!
31//! Both reads **dispatch on coven's own authoritative state** — they never probe
32//! every store and take the first hit. The discriminator is the **locality root**
33//! plus the blob's intrinsic **provenance**, not "is there a local file here." Coven
34//! resolves the blob's backing row (found in the table its `namespace` declares) up
35//! to its gated root or remote root (see
36//! [`Gates::root_kept_of`](crate::sync::gate::Gates::root_kept_of)), then dispatches:
37//!
38//! - **Remote** with an exact locator ⇒ the bytes live in the cloud fronted by
39//! the device cache. The first legitimate probe runs per-device cache
40//! materialization — which no shared state records — checking `pinned/` then
41//! `cache/`, then fetching the exact cloud object.
42//! - **PendingRemote** ⇒ the row's audience is remote but its exact cloud object is
43//! not published yet. Provenance selects the verified upload source: the external
44//! file for a user-provided blob or the local store for a host-provided blob.
45//! - **Local** ⇒ the bytes are on-device; provenance picks the copy. A
46//! **user-provided** blob is the user's own external file (`local_blob_refs`), read
47//! straight from its path and validated by size + content hash — its ref MUST exist
48//! ([`BlobCacheError::NoExternalRef`] otherwise). A **host-provided** blob is in the
49//! **local store** ([`local_files`](super::local_files)), its only copy — a miss is
50//! fail-loud corruption ([`BlobCacheError::NoLocalCopy`]). Neither falls through to
51//! the cloud: a Local blob has no cloud copy.
52//!
53//! [`read_blob`] returns the entire blob (a cloud miss fetches + decrypts it and
54//! populates `cache/`); [`open_blob_stream`] serves a plaintext byte range for a host
55//! streaming or seeking (a cloud miss stages and verifies the whole plaintext, then
56//! returns the requested range without populating the cache).
57//!
58//! The cache has a **per-namespace** size budget the host sets per device (see
59//! [`Database::set_cache_budget`]), so a small namespace (`covers`) is never wiped by
60//! pressure from a big one (`release_files`). A namespace's budget counts **only**
61//! the files under `cache/<namespace>/` — `pinned/` is structurally exempt, and
62//! `storage/local` (the local store) is never walked at all. After every populate
63//! into a namespace ([`read_blob`]'s miss-write and [`write_blob`]),
64//! [`evict_to_budget`] sums that namespace's `cache/<namespace>/` files and, if their
65//! total exceeds its budget, deletes the oldest by modification time until the total
66//! is back under it — touching only that namespace's subtree. Modification time is
67//! the recency proxy — there is no `last_accessed` column, the same folder-truth
68//! trade-off the whole cache makes; pinning retains the Remote blobs the user chose
69//! to keep local. With a namespace's budget unset eviction is off for it and its
70//! cache grows without bound. Tests can reset all of `cache/` in one sweep; a pinned
71//! blob (in `pinned/`) survives because it lives in the other folder.
72
73use crate::blob::{Provenance, RowBlobAuthority, RowBlobRef};
74use crate::database::{Database, DbError};
75use crate::store_dir::{PathTokenError, StoreDir};
76use crate::sync::storage::{StorageError, SyncStorage};
77
78/// Closed cloud access for one exact Remote blob. Store code resolves the
79/// authority; the cache only reads bytes with the supplied protection.
80pub(crate) struct RemoteBlobAccess<'a> {
81 storage: &'a dyn SyncStorage,
82 protection: crate::sync::storage::BlobSpoolProtection,
83}
84
85impl<'a> RemoteBlobAccess<'a> {
86 pub(crate) fn new(
87 storage: &'a dyn SyncStorage,
88 protection: crate::sync::storage::BlobSpoolProtection,
89 ) -> Self {
90 Self {
91 storage,
92 protection,
93 }
94 }
95}
96
97/// Prefix for the `protocol_state` keys holding each namespace's device-local cache-size
98/// budget in bytes (a single decimal value per namespace, not per-blob accounting).
99/// The key for one namespace is [`cache_budget_state_key`]. A namespace with no such
100/// key has no budget ⇒ eviction off for it ⇒ that namespace's cache grows unbounded.
101/// Read/written through [`Database::get_cache_budget`] /
102/// [`Database::set_cache_budget`].
103pub const CACHE_BUDGET_STATE_KEY_PREFIX: &str = "cache_budget:";
104
105/// The `protocol_state` key holding `namespace`'s cache-size budget. Namespaces are safe
106/// path tokens (no `:`), so the `cache_budget:` prefix never collides with one.
107pub fn cache_budget_state_key(namespace: &str) -> String {
108 format!("{CACHE_BUDGET_STATE_KEY_PREFIX}{namespace}")
109}
110
111/// Why a blob-cache operation failed.
112#[derive(Debug)]
113pub enum BlobCacheError {
114 /// A blob `id`/`namespace`/`cloud_path` that can't form a safe path — bad data
115 /// that could escape the store dir or can't be partitioned. The blob is
116 /// refused before any path is built (the same gate the pull runs).
117 Path(PathTokenError),
118 /// A cloud read failed: the blob isn't in the cloud, or the backend errored
119 /// (surfaced from [`SyncStorage::get_blob`]).
120 Storage(StorageError),
121 /// A Remote blob's bytes were needed from the cloud but no cloud home is
122 /// connected, so there is no storage to fetch them from. A home-less store
123 /// holds only Local blobs (external refs + the local store), which serve
124 /// straight off disk and never reach the cloud-miss path; reaching here means
125 /// a Remote blob was read with no provider connected — a real fault, surfaced
126 /// rather than masked.
127 NoCloudHome,
128 /// A local-disk failure: a cache write, a folder move, or a test cache reset.
129 /// Carries a human-readable cause.
130 Io(String),
131 /// A blob-metadata query failed — resolving the blob's locality, looking up its
132 /// external ref, or reading its cache budget or expected size. A database read
133 /// the blob path depends on, distinct from a disk I/O failure.
134 Metadata(DbError),
135 /// Building the sync storage from config failed — missing credentials or cloud
136 /// configuration — when a Remote blob needed it. A configuration fault, not a
137 /// disk I/O error.
138 StorageSetup(String),
139 /// The old-value and new-value walks of one changeset disagreed on row count.
140 /// They are two views of the same changeset; a mismatch means cleanup cannot
141 /// pair updated rows with their previous blob ids.
142 ChangesetWalkMismatch { old_count: usize, new_count: usize },
143 /// A registered external blob ref (a user-provided Local blob's user-owned
144 /// file) points at a file that is no longer there — the user moved, renamed, or
145 /// deleted it. Terminal: an external blob has no cloud copy to fall back to, so
146 /// this never re-fetches. The host surfaces a "files missing / moved" state
147 /// whose actions are relocate (pick the new folder, re-register) or re-import.
148 ExternalMissing {
149 id: String,
150 path: std::path::PathBuf,
151 /// The underlying read failure — a missing file or a real I/O error,
152 /// preserved rather than collapsed so the host sees why the read failed.
153 source: String,
154 },
155 /// A registered external blob's file is present but its length no longer matches
156 /// the registered `size` — the user truncated it or replaced it with a
157 /// different-length file. Terminal like [`Self::ExternalMissing`]: a mismatch
158 /// means this is not the exact file coven registered.
159 ExternalSizeMismatch {
160 id: String,
161 path: std::path::PathBuf,
162 },
163 /// A **Local** blob (its gated locality root's gate is off) has no copy in the
164 /// local store. A Local blob has no cloud copy, so there is nothing to fall back
165 /// to: the state is broken, not a cache miss. Surfaced loud rather than silently
166 /// fetching from the cloud — a make_local rollback leftover, an interrupted
167 /// materialize, or a lost local file would otherwise be papered over. The host
168 /// re-materializes or repairs.
169 NoLocalCopy { namespace: String, id: String },
170 /// A blob could not be resolved to a locality: its namespace declares no
171 /// blob-bearing table, or that table has no row with the id, or the row reaches no
172 /// gated root or remote root — so the source of Local-vs-Remote truth can't be
173 /// read. In a consistent store every readable blob has a locality root, so this
174 /// is a real fault — surfaced rather than guessing a source by probing.
175 LocalityUnresolved { id: String },
176 /// The gate resolved a blob to **Local + user-provided**, but no external-ref row
177 /// is registered for it. A user-provided Local blob's bytes live only at the user's
178 /// path, tracked by that ref; its absence is corruption (a lost or never-written
179 /// ref), not a cache miss to fall through — surfaced loud so the host repairs or
180 /// re-imports.
181 NoExternalRef { id: String },
182 /// A Remote blob fetched whole from the cloud came back with a plaintext length
183 /// that disagrees with the row's declared size. The bytes are not what the row
184 /// describes, so they are refused before caching or returning: caching them would
185 /// fail the read's own length check on every later read, warning and refetching
186 /// the same wrong object forever. Terminal like the length checks the cache-hit
187 /// and file-download paths run.
188 CloudSizeMismatch {
189 namespace: String,
190 id: String,
191 expected: u64,
192 actual: u64,
193 },
194 /// A Remote blob fetched from the cloud decrypted to plaintext whose content
195 /// hash disagrees with the author-signed hash on the blob's row. The bytes are
196 /// not the ones the row's author pinned — a tampered object, a rolled-back
197 /// prior version, or a same-size object planted under a different uploader's
198 /// prefix — so they are refused before caching or returning. The row's hash is
199 /// the authority; a mismatch is tamper, never a cache miss to refetch.
200 CloudHashMismatch {
201 namespace: String,
202 id: String,
203 expected: String,
204 actual: String,
205 },
206 /// A blob's row carries no content hash, so a whole-blob download cannot be
207 /// verified against the author's signed value. The hash is a required column,
208 /// so an absent one is bad data (a row that predates the field, or a NULL where
209 /// a hash must be), surfaced rather than serving unverified bytes.
210 MissingContentHash { namespace: String, id: String },
211 /// An authoritative local plaintext file exists at the exact path but its
212 /// bytes differ from the row's signed size/hash.
213 LocalIntegrity {
214 path: std::path::PathBuf,
215 expected_size: u64,
216 actual_size: u64,
217 expected_hash: crate::sync::store_commit::ObjectHash,
218 actual_hash: crate::sync::store_commit::ObjectHash,
219 },
220}
221
222impl std::fmt::Display for BlobCacheError {
223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224 match self {
225 BlobCacheError::Path(e) => write!(f, "blob path error: {e}"),
226 BlobCacheError::Storage(e) => write!(f, "blob cache storage error: {e}"),
227 BlobCacheError::NoCloudHome => {
228 write!(f, "no cloud home connected to read a Remote blob")
229 }
230 BlobCacheError::Io(e) => write!(f, "blob cache I/O error: {e}"),
231 BlobCacheError::Metadata(e) => write!(f, "blob metadata error: {e}"),
232 BlobCacheError::StorageSetup(e) => write!(f, "sync storage setup failed: {e}"),
233 BlobCacheError::ChangesetWalkMismatch {
234 old_count,
235 new_count,
236 } => write!(
237 f,
238 "changeset old/new walks disagree: {old_count} old rows, {new_count} new rows"
239 ),
240 BlobCacheError::ExternalMissing { id, path, source } => write!(
241 f,
242 "external blob {id} could not be read at {}: {source}",
243 path.display()
244 ),
245 BlobCacheError::ExternalSizeMismatch { id, path } => write!(
246 f,
247 "external blob {id} at {} no longer matches its registered size",
248 path.display()
249 ),
250 BlobCacheError::NoLocalCopy { namespace, id } => write!(
251 f,
252 "local blob {namespace}/{id} is gated Local but absent from the local store"
253 ),
254 BlobCacheError::LocalityUnresolved { id } => write!(
255 f,
256 "cannot resolve locality for blob {id}: no locality root determines where it lives"
257 ),
258 BlobCacheError::NoExternalRef { id } => write!(
259 f,
260 "user-provided Local blob {id} has no registered external ref"
261 ),
262 BlobCacheError::CloudSizeMismatch {
263 namespace,
264 id,
265 expected,
266 actual,
267 } => write!(
268 f,
269 "cloud blob {namespace}/{id} is {actual} bytes, expected {expected}"
270 ),
271 BlobCacheError::CloudHashMismatch {
272 namespace,
273 id,
274 expected,
275 actual,
276 } => write!(
277 f,
278 "cloud blob {namespace}/{id} content hash is {actual}, expected {expected}"
279 ),
280 BlobCacheError::MissingContentHash { namespace, id } => write!(
281 f,
282 "blob {namespace}/{id} has no content hash to verify its bytes against"
283 ),
284 BlobCacheError::LocalIntegrity {
285 path,
286 expected_size,
287 actual_size,
288 expected_hash,
289 actual_hash,
290 } => write!(
291 f,
292 "local blob {} has size/hash {actual_size}/{actual_hash}, expected {expected_size}/{expected_hash}",
293 path.display()
294 ),
295 }
296 }
297}
298
299impl std::error::Error for BlobCacheError {}
300
301impl From<PathTokenError> for BlobCacheError {
302 fn from(e: PathTokenError) -> Self {
303 BlobCacheError::Path(e)
304 }
305}
306
307impl From<StorageError> for BlobCacheError {
308 fn from(e: StorageError) -> Self {
309 BlobCacheError::Storage(e)
310 }
311}
312
313impl From<crate::blob::local_files::LocalBlobError> for BlobCacheError {
314 fn from(e: crate::blob::local_files::LocalBlobError) -> Self {
315 use crate::blob::local_files::LocalBlobError;
316 match e {
317 LocalBlobError::Path(p) => BlobCacheError::Path(p),
318 LocalBlobError::Io(s) => BlobCacheError::Io(s),
319 }
320 }
321}
322
323/// Write a Remote blob's plaintext into the evictable cache under a synthetic exact
324/// locator hash, so a later test read serves it locally without a cloud round-trip and
325/// a pin can promote it to `pinned/`.
326///
327/// Used when a blob becomes Remote and coven already has its plaintext in hand —
328/// the inline push moving a just-uploaded host-provided blob's local-store copy
329/// into the cache — so the cache is populated on write rather than fetch-on-read.
330///
331/// After the bytes land, [`evict_to_budget`] runs so a write that pushes the blob's
332/// namespace cache over that namespace's budget evicts its oldest files back under
333/// budget (a no-op when the namespace has no budget set). The just-written file is
334/// passed as `protect`, so it is excluded from eviction — this write can never drop
335/// the very bytes it produced. An eviction failure is returned to the caller instead
336/// of reporting a budgeted write as complete while the cache could not be trimmed.
337#[cfg(test)]
338pub(crate) async fn write_blob(
339 db: &Database,
340 store_dir: &StoreDir,
341 namespace: &str,
342 locator_hash: crate::sync::store_commit::ObjectHash,
343 bytes: &[u8],
344) -> Result<(), BlobCacheError> {
345 let dest = store_dir.cache_blob_path(namespace, locator_hash)?;
346 crate::local_blob::write_atomic(&dest, bytes)
347 .await
348 .map_err(BlobCacheError::Io)?;
349 // The write into `cache/<namespace>/` may have pushed that namespace over its
350 // budget; evict its oldest files back under it, never the file just written
351 // (passed as `protect`). A no-op when the namespace has no budget set.
352 evict_to_budget(db, store_dir, namespace, Some(&dest)).await?;
353 Ok(())
354}
355
356/// Copy a Remote blob's plaintext source file into the evictable cache without
357/// holding the whole blob in memory. Uses the same cache placement and eviction
358/// contract as the byte-slice cache writer used by tests.
359pub(crate) async fn write_blob_from_file(
360 db: &Database,
361 store_dir: &StoreDir,
362 namespace: &str,
363 locator_hash: crate::sync::store_commit::ObjectHash,
364 plaintext_size: u64,
365 plaintext_hash: crate::sync::store_commit::ObjectHash,
366 src_path: &std::path::Path,
367) -> Result<(), BlobCacheError> {
368 let dest = store_dir.cache_blob_path(namespace, locator_hash)?;
369 let staged = crate::local_blob::stage_atomic_destination(&dest)
370 .await
371 .map_err(BlobCacheError::Io)?;
372 crate::local_blob::copy_atomic(src_path, staged.path())
373 .await
374 .map_err(BlobCacheError::Io)?;
375 verify_local_file_facts(staged.path(), plaintext_size, plaintext_hash).await?;
376 publish_exact_file(staged, plaintext_size, plaintext_hash).await?;
377 evict_to_budget(db, store_dir, namespace, Some(&dest)).await?;
378 Ok(())
379}
380
381/// Write a Remote blob's plaintext straight into the KEPT cache folder
382/// (`storage/pinned/<locator-hash>`), so a just-uploaded blob the user pinned for offline is
383/// kept local and budget-exempt with no later cloud round-trip. The kept sibling of
384/// [`write_blob`] (which writes into the evictable locator-keyed cache).
385///
386/// Called by the upload drain after a successful upload whose entry is
387/// `retain_pinned`: the same plaintext the drain already read to seal is written
388/// here, so the pin is populate-on-write rather than fetch-on-read. The bytes are
389/// the plaintext (what the cache stores and serves), not the sealed ciphertext in
390/// the cloud.
391///
392/// Unlike [`write_blob`] there is NO post-write eviction: `pinned/` is structurally
393/// exempt from the size budget (the sweep never walks it), so a kept populate can
394/// neither push the evictable cache over budget nor be trimmed. Later reads verify
395/// the file's exact size and hash before trusting the pinned bytes.
396pub(crate) async fn populate_pinned(
397 store_dir: &StoreDir,
398 stored: &crate::blob::locator::StoredBlobRef,
399 src_path: &std::path::Path,
400) -> Result<(), BlobCacheError> {
401 let locator = stored.locator();
402 populate_pinned_from_file(
403 store_dir,
404 locator.namespace(),
405 locator.locator_hash(),
406 locator.plaintext_size(),
407 locator.plaintext_hash(),
408 src_path,
409 )
410 .await
411}
412
413pub(crate) async fn populate_pinned_from_file(
414 store_dir: &StoreDir,
415 namespace: &str,
416 locator_hash: crate::sync::store_commit::ObjectHash,
417 plaintext_size: u64,
418 plaintext_hash: crate::sync::store_commit::ObjectHash,
419 src_path: &std::path::Path,
420) -> Result<(), BlobCacheError> {
421 let dest = store_dir.pinned_blob_path(namespace, locator_hash)?;
422 let staged = crate::local_blob::stage_atomic_destination(&dest)
423 .await
424 .map_err(BlobCacheError::Io)?;
425 crate::local_blob::copy_atomic(src_path, staged.path())
426 .await
427 .map_err(BlobCacheError::Io)?;
428 verify_local_file_facts(staged.path(), plaintext_size, plaintext_hash).await?;
429 publish_exact_file(staged, plaintext_size, plaintext_hash).await
430}
431
432/// Read a Remote blob's plaintext from the cache only — the locator-keyed pinned
433/// path then the locator-keyed evictable path, in order — returning `None` when it
434/// is in neither folder. No cloud
435/// fetch and no local-store check: just the two cache folders. A failure to even
436/// check existence (broken filesystem) is surfaced, never collapsed into `None`. Two
437/// callers share this probe:
438///
439/// - [`read_remote_whole`]: the Remote read's cache-hit check — a hit serves the file,
440/// a `None` means fetch from the cloud and populate the cache.
441/// - the inline push's crash-recovery read of a host-provided blob whose local-store
442/// copy a prior cycle already moved into the cache. The push's primary read is from
443/// the local store ([`local_files::read`](super::local_files::read)); this is the
444/// fallback, and a `None` from both tells the push the blob is not ready, so it
445/// aborts rather than publishing a row whose blob never reached the cloud.
446pub async fn read_staged(
447 store_dir: &StoreDir,
448 reference: &RowBlobRef,
449) -> Result<Option<Vec<u8>>, BlobCacheError> {
450 read_cached_exact(store_dir, reference).await
451}
452
453/// Drop one exact Remote blob cache copy from both locator-keyed folders, part of
454/// apply-side cleanup when an incoming changeset deletes
455/// a blob-bearing row (a gate retract or a genuine delete). A peer drops only its
456/// own cache copy here — it never writes a cloud tombstone, which belongs to the
457/// deleting / make-Local owner. The `pinned/` copy is budget-exempt, so without
458/// this it would leak forever once the row is gone. (The local store is dropped
459/// separately by the apply-side caller — a peer holds the blob in the cache, not
460/// the local store, but the caller drops both wherever the bytes are.)
461///
462/// An absent file in either folder is the expected case (a blob is in at most one
463/// folder, or neither), not an error. Every other I/O failure is surfaced.
464pub async fn drop_cached_blob(
465 db: &Database,
466 store_dir: &StoreDir,
467 reference: &RowBlobRef,
468) -> Result<(), BlobCacheError> {
469 validate_row_reference(db, reference).await?;
470 drop_cached_stored_blob(store_dir, remote_stored_ref(reference)?).await
471}
472
473pub(crate) async fn drop_cached_stored_blob(
474 store_dir: &StoreDir,
475 stored: &crate::blob::locator::StoredBlobRef,
476) -> Result<(), BlobCacheError> {
477 let locator = stored.locator();
478 drop_cached_locator(store_dir, locator.namespace(), locator.locator_hash()).await
479}
480
481/// Drop one exact locator's cache and pinned copies without touching the
482/// logical-id-keyed local source. The source and cache represent different
483/// ownership states and can be live independently when logical IDs are reused.
484pub(crate) async fn drop_cached_locator(
485 store_dir: &StoreDir,
486 namespace: &str,
487 locator_hash: crate::sync::store_commit::ObjectHash,
488) -> Result<(), BlobCacheError> {
489 let pinned = store_dir.pinned_blob_path(namespace, locator_hash)?;
490 let cache = store_dir.cache_blob_path(namespace, locator_hash)?;
491 for path in [pinned, cache] {
492 // An absent file in either folder is the expected case (`remove_file`
493 // reports it as `Ok(false)`, not an error); every real I/O failure surfaces.
494 crate::local_blob::remove_file(&path)
495 .await
496 .map_err(BlobCacheError::Io)?;
497 }
498 Ok(())
499}
500
501/// Whether a Remote blob's cache copy is currently pinned — present in
502/// `storage/pinned/<namespace>/<locator-hash>`. The pin truth is the folder a blob's file
503/// lives in, not a table (see the module docs), so this is a single existence
504/// check on the kept folder: a blob in `cache/` or in neither folder is not
505/// pinned. A failure to even check existence (broken filesystem) is surfaced,
506/// never collapsed into "not pinned".
507pub async fn is_pinned(
508 db: &Database,
509 store_dir: &StoreDir,
510 reference: &RowBlobRef,
511) -> Result<bool, BlobCacheError> {
512 validate_row_reference(db, reference).await?;
513 let (pinned, _) = remote_cache_paths(store_dir, reference)?;
514 match crate::local_blob::exists(&pinned).await {
515 Ok(true) => {
516 verify_exact_local_file(&pinned, reference).await?;
517 Ok(true)
518 }
519 Ok(false) => Ok(false),
520 Err(error) => Err(BlobCacheError::Io(error)),
521 }
522}
523
524/// Read a blob's whole contents, dispatching on coven's authoritative state — the
525/// blob's locality root, then its intrinsic provenance — rather than probing every
526/// store and taking the first hit.
527///
528/// [`resolve_source`] reads the row authority first: **Remote** with an exact
529/// locator ⇒ the bytes live in the cloud fronted by the device cache, so the first
530/// legitimate probe checks the exact locator's pinned path then cache path for a per-device cache
531/// copy and serves a hit. A miss resolves the blob's scope to its encryption key,
532/// downloads + decrypts it via [`SyncStorage::get_blob`], writes the
533/// whole blob to its locator-keyed cache path (evictable — a fetch-on-read populates the evictable
534/// cache, never the kept folder), and returns the bytes it just fetched. Later cache
535/// hits verify the file size and hash against the row before trusting it. The read reports
536/// success only after the post-populate [`evict_to_budget`] sweep succeeds.
537///
538/// **Local** or **PendingRemote** ⇒ the bytes are on-device, and provenance picks which copy:
539/// a **user-provided** blob is the user's own external file (`local_blob_refs` row),
540/// read straight from its path and validated by size + content hash — its ref MUST exist
541/// ([`BlobCacheError::NoExternalRef`] if not: a Local user-provided blob without its
542/// ref is corruption, not a fall-through), and a vanished/short file is
543/// [`BlobCacheError::ExternalMissing`] / [`BlobCacheError::ExternalSizeMismatch`]. A
544/// **host-provided** blob is in the **local store** (`storage/local/<namespace>/<id>`,
545/// see [`local_files`](super::local_files)), its only copy — a miss is
546/// [`BlobCacheError::NoLocalCopy`], fail-loud corruption, never a cloud fetch.
547pub(crate) async fn read_blob(
548 db: &Database,
549 store_dir: &StoreDir,
550 remote: Option<RemoteBlobAccess<'_>>,
551 reference: &RowBlobRef,
552) -> Result<Vec<u8>, BlobCacheError> {
553 validate_row_reference(db, reference).await?;
554 let blob = reference.blob();
555 let bytes = match resolve_source(reference)? {
556 // Remote: the bytes live in the cloud fronted by the device cache.
557 BlobSource::Cache => read_remote_whole(db, store_dir, remote, reference).await,
558 // Local + user-provided: the user's own external file. Its ref must be present
559 // — gate-resolved Local + UserProvided with no ref is corruption, not a miss.
560 BlobSource::External => {
561 let ext = lookup_external_ref(db, reference).await?.ok_or_else(|| {
562 BlobCacheError::NoExternalRef {
563 id: blob.id.clone(),
564 }
565 })?;
566 read_external_file(reference, ext, ExactRead::Whole).await
567 }
568 // Local + host-provided: the local store is the ONLY copy (a Local blob has no
569 // cloud copy). A miss is fail-loud corruption, not a cache miss to refetch.
570 BlobSource::LocalStore => {
571 let path = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
572 match crate::local_blob::exists(&path).await {
573 Ok(true) => {}
574 Ok(false) => {
575 return Err(BlobCacheError::NoLocalCopy {
576 namespace: blob.namespace.clone(),
577 id: blob.id.clone(),
578 });
579 }
580 Err(error) => return Err(BlobCacheError::Io(error)),
581 }
582 read_exact_local_file(&path, reference, ExactRead::Whole).await
583 }
584 }?;
585 validate_row_reference(db, reference).await?;
586 Ok(bytes)
587}
588
589/// Serve a Remote blob whole. The one legitimate probe — per-device cache
590/// materialization, a filesystem fact no shared state holds — checks the exact
591/// locator's pinned path then cache path and serves a hit, otherwise reading the
592/// exact cloud object. Split from [`read_blob`] so the whole-blob Remote path reads
593/// as one branch of the authority dispatch.
594async fn read_remote_whole(
595 db: &Database,
596 store_dir: &StoreDir,
597 remote: Option<RemoteBlobAccess<'_>>,
598 reference: &RowBlobRef,
599) -> Result<Vec<u8>, BlobCacheError> {
600 let blob = reference.blob();
601 // A cache hit (`pinned/` or `cache/`) serves the file straight off disk — the same
602 // pinned→cache probe [`read_staged`] runs. An existence-check failure there is
603 // surfaced, not collapsed into a miss: re-downloading over a present file would be
604 // wasteful and could mask a real fault.
605 if let Some(bytes) = read_cached_exact(store_dir, reference).await? {
606 return Ok(bytes);
607 }
608
609 // Miss: fetch from the cloud and populate the evictable cache. A home-less
610 // store reaches here only when a Remote blob is read with no provider
611 // connected — there is no storage to fetch it from, so surface that fault.
612 let (_, cache) = remote_cache_paths(store_dir, reference)?;
613 let remote = remote.ok_or(BlobCacheError::NoCloudHome)?;
614 let stored = remote_stored_ref(reference)?;
615 let staged = remote
616 .storage
617 .stage_verified_blob_plaintext(stored, remote.protection, &cache)
618 .await?;
619 let bytes = crate::local_blob::read(staged.path())
620 .await
621 .map_err(BlobCacheError::Io)?;
622 validate_row_reference(db, reference).await?;
623 publish_materialization(staged, reference).await?;
624 // The populate may have pushed `cache/` over budget; evict the oldest files
625 // back under it, never the file just written (passed as `protect`) — so this
626 // read's own sweep can't drop the bytes it just fetched, which it returns below.
627 // A no-op when no budget is set.
628 //
629 evict_to_budget(db, store_dir, &blob.namespace, Some(&cache)).await?;
630 Ok(bytes)
631}
632
633/// Serve `len` plaintext bytes of a blob starting at `offset`, for a host
634/// streaming or seeking it (playback) without loading the whole file. The ranged
635/// sibling of [`read_blob`]: same arguments plus `(source_size, offset, len)`,
636/// returning the plaintext slice.
637///
638/// `source_size` is the blob's plaintext length — the host knows it (the row that
639/// owns the blob carries it) and every serving path needs it to bound the range.
640/// The range is validated once here, against
641/// `source_size`, so a request behaves identically whether it is served from the
642/// local file or the cloud: `len == 0` is an empty result, and an `offset + len`
643/// past `source_size` (or an overflow) is an error, never a short read — the same
644/// contract [`crate::sync::cloud_storage::BlobRangeReader::read`] enforces.
645///
646/// The serving paths mirror [`read_blob`], dispatched the same way ([`resolve_source`]
647/// reads the gate, then provenance): **Remote** serves the range off a cache hit
648/// (the exact locator's pinned or cache path) — the whole-plaintext local file read at `offset`,
649/// no decryption, no cloud. A miss stages and verifies the whole cloud plaintext,
650/// returns the requested range, and **never writes a cache file**; only the
651/// whole-file read populates, and later cache hits must match the
652/// declared blob length before [`read_blob`] serves them. **Local +
653/// user-provided** reads the range off the user's external file (ref required —
654/// [`NoExternalRef`] otherwise; a vanished/short file is [`ExternalMissing`]).
655/// **Local + host-provided** reads the range off the local store ([`NoLocalCopy`] on
656/// a miss, never a cloud fetch).
657///
658/// As in [`read_blob`], a failure to even check a file's existence is surfaced,
659/// never collapsed into a miss (which would re-fetch over a present file and could
660/// mask a real fault).
661///
662/// [`NoExternalRef`]: BlobCacheError::NoExternalRef
663/// [`ExternalMissing`]: BlobCacheError::ExternalMissing
664/// [`NoLocalCopy`]: BlobCacheError::NoLocalCopy
665pub(crate) async fn open_blob_stream(
666 db: &Database,
667 store_dir: &StoreDir,
668 remote: Option<RemoteBlobAccess<'_>>,
669 reference: &RowBlobRef,
670 offset: u64,
671 len: u64,
672) -> Result<Vec<u8>, BlobCacheError> {
673 validate_row_reference(db, reference).await?;
674 let blob = reference.blob();
675 let source_size = reference.plaintext_size();
676 // The range contract, applied once for all serving paths. A zero-length read
677 // is empty without touching disk or cloud; an out-of-range read is an error
678 // before any path runs, so the local-file path can't silently short-read.
679 if len == 0 {
680 return Ok(Vec::new());
681 }
682 let end = offset.checked_add(len).ok_or_else(|| {
683 BlobCacheError::Io(format!(
684 "blob range overflow for {}: offset={offset}, len={len}",
685 blob.id
686 ))
687 })?;
688 if end > source_size {
689 return Err(BlobCacheError::Io(format!(
690 "blob range {offset}..{end} for {} exceeds blob size {source_size}",
691 blob.id
692 )));
693 }
694
695 let bytes = match resolve_source(reference)? {
696 // Remote: the cache copy, else a verified cloud stage (populating nothing).
697 BlobSource::Cache => read_remote_range(db, store_dir, remote, reference, offset, len).await,
698 // Local + user-provided: range-read the user's external file. The window was
699 // validated against `source_size`, and `read_range` reads exactly `len`
700 // (failing loud on a short file). The ref must be present — no fallback.
701 BlobSource::External => {
702 let ext = lookup_external_ref(db, reference).await?.ok_or_else(|| {
703 BlobCacheError::NoExternalRef {
704 id: blob.id.clone(),
705 }
706 })?;
707 read_external_file(reference, ext, ExactRead::Range { offset, len }).await
708 }
709 // Local + host-provided: range-read the local store, coven's only copy. A miss
710 // is fail-loud corruption, never a cloud fetch.
711 BlobSource::LocalStore => {
712 let path = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
713 match crate::local_blob::exists(&path).await {
714 Ok(true) => {}
715 Ok(false) => {
716 return Err(BlobCacheError::NoLocalCopy {
717 namespace: blob.namespace.clone(),
718 id: blob.id.clone(),
719 });
720 }
721 Err(error) => return Err(BlobCacheError::Io(error)),
722 }
723 read_exact_local_file(&path, reference, ExactRead::Range { offset, len }).await
724 }
725 }?;
726 validate_row_reference(db, reference).await?;
727 Ok(bytes)
728}
729
730/// Serve a Remote blob's plaintext range. A hit at either exact locator cache path
731/// reads the slice off the whole-plaintext local file. A miss stages and verifies
732/// the whole cloud plaintext, returns its requested range, and writes NO cache file. Split from
733/// [`open_blob_stream`] so the Remote path reads as one branch of the locality
734/// dispatch; the range was already validated by the caller against `source_size`.
735async fn read_remote_range(
736 db: &Database,
737 store_dir: &StoreDir,
738 remote: Option<RemoteBlobAccess<'_>>,
739 reference: &RowBlobRef,
740 offset: u64,
741 len: u64,
742) -> Result<Vec<u8>, BlobCacheError> {
743 // A hit in either folder serves the slice from the local plaintext file. The
744 // file must match the whole blob length, so the validated range is in bounds
745 // and `read_range` reads exactly `len` bytes. An existence-check failure is
746 // surfaced, not read as a miss.
747 if let Some(hit) = cached_blob_path(store_dir, reference).await? {
748 return read_exact_local_file(hit.path(), reference, ExactRead::Range { offset, len })
749 .await;
750 }
751
752 // Miss: serve the range from the cloud (range read + decrypt over the resolved
753 // scope) WITHOUT writing a cache file. Only `read_blob` populates the cache. A
754 // home-less store has no storage to range-read a Remote blob from; surface it.
755 let remote = remote.ok_or(BlobCacheError::NoCloudHome)?;
756 let stored = remote_stored_ref(reference)?;
757 let (_, destination) = remote_cache_paths(store_dir, reference)?;
758 let staged = remote
759 .storage
760 .stage_verified_blob_plaintext(stored, remote.protection, &destination)
761 .await?;
762 let bytes = crate::local_blob::read_range(staged.path(), offset, len)
763 .await
764 .map_err(BlobCacheError::Io)?;
765 validate_row_reference(db, reference).await?;
766 Ok(bytes)
767}
768
769/// Stage a Remote blob's whole verified plaintext beside `dest` without making
770/// `dest` visible. Uses an exact cache copy when present (`pinned/` or `cache/`),
771/// otherwise streams the exact cloud object. The returned stage is not visible at
772/// the destination; its caller chooses how to publish it.
773pub(crate) async fn stage_remote_blob_plaintext(
774 db: &Database,
775 store_dir: &StoreDir,
776 remote: Option<RemoteBlobAccess<'_>>,
777 reference: &RowBlobRef,
778 dest: &std::path::Path,
779) -> Result<crate::local_blob::AtomicStagedFile, BlobCacheError> {
780 validate_row_reference(db, reference).await?;
781 if let Some(hit) = cached_blob_path(store_dir, reference).await? {
782 let staged = stage_exact_local_copy(hit.path(), dest, reference).await?;
783 validate_row_reference(db, reference).await?;
784 return Ok(staged);
785 }
786
787 let remote = remote.ok_or(BlobCacheError::NoCloudHome)?;
788 let stored = remote_stored_ref(reference)?;
789 let staged = remote
790 .storage
791 .stage_verified_blob_plaintext(stored, remote.protection, dest)
792 .await?;
793 validate_row_reference(db, reference).await?;
794 Ok(staged)
795}
796
797/// Ensure the exact current row blob plaintext is durable on this device.
798/// Remote blobs publish to their locator-keyed evictable cache path. Local and
799/// pending-remote blobs already have an authoritative local source, which this
800/// operation exact-verifies without creating a remote cache entry.
801pub(crate) async fn materialize_row_blob(
802 db: &Database,
803 store_dir: &StoreDir,
804 remote: Option<RemoteBlobAccess<'_>>,
805 reference: &RowBlobRef,
806) -> Result<(), BlobCacheError> {
807 validate_row_reference(db, reference).await?;
808 match resolve_source(reference)? {
809 BlobSource::Cache => {
810 let (_, destination) = remote_cache_paths(store_dir, reference)?;
811 if cached_blob_path_with_facts(store_dir, reference)
812 .await?
813 .is_some()
814 {
815 validate_row_reference(db, reference).await?;
816 return Ok(());
817 }
818 let staged =
819 stage_remote_blob_plaintext(db, store_dir, remote, reference, &destination).await?;
820 verify_exact_local_file(staged.path(), reference).await?;
821 validate_row_reference(db, reference).await?;
822 publish_materialization(staged, reference).await
823 }
824 BlobSource::External => {
825 let external = lookup_external_ref(db, reference).await?.ok_or_else(|| {
826 BlobCacheError::NoExternalRef {
827 id: reference.blob().id.clone(),
828 }
829 })?;
830 verify_external_file(reference, &external).await?;
831 validate_row_reference(db, reference).await
832 }
833 BlobSource::LocalStore => {
834 let blob = reference.blob();
835 let path = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
836 match crate::local_blob::exists(&path).await {
837 Ok(true) => verify_exact_local_file(&path, reference).await?,
838 Ok(false) => {
839 return Err(BlobCacheError::NoLocalCopy {
840 namespace: blob.namespace.clone(),
841 id: blob.id.clone(),
842 });
843 }
844 Err(error) => return Err(BlobCacheError::Io(error)),
845 }
846 validate_row_reference(db, reference).await
847 }
848 }
849}
850
851async fn publish_materialization(
852 staged: crate::local_blob::AtomicStagedFile,
853 reference: &RowBlobRef,
854) -> Result<(), BlobCacheError> {
855 publish_exact_file(
856 staged,
857 reference.plaintext_size(),
858 reference.plaintext_hash(),
859 )
860 .await
861}
862
863async fn publish_exact_file(
864 staged: crate::local_blob::AtomicStagedFile,
865 expected_size: u64,
866 expected_hash: crate::sync::store_commit::ObjectHash,
867) -> Result<(), BlobCacheError> {
868 match staged.commit_new().await {
869 Ok(()) => Ok(()),
870 Err(crate::local_blob::CommitNewFileError::DestinationExists(path)) => {
871 verify_local_file_facts(&path, expected_size, expected_hash).await
872 }
873 Err(error) => Err(BlobCacheError::Io(error.to_string())),
874 }
875}
876
877/// Materialize a Remote blob's whole plaintext into a coven-owned destination
878/// without replacing an occupied path. An occupied exact file is idempotent; an
879/// occupied file with different size or bytes fails loudly.
880pub(crate) async fn materialize_remote_blob_to_file(
881 db: &Database,
882 store_dir: &StoreDir,
883 remote: Option<RemoteBlobAccess<'_>>,
884 reference: &RowBlobRef,
885 dest: &std::path::Path,
886) -> Result<u64, BlobCacheError> {
887 let staged = stage_remote_blob_plaintext(db, store_dir, remote, reference, dest).await?;
888 verify_exact_local_file(staged.path(), reference).await?;
889 validate_row_reference(db, reference).await?;
890 publish_materialization(staged, reference).await?;
891 Ok(reference.plaintext_size())
892}
893
894/// Ensure a blob is local AND protected: present at its locator-keyed pinned path, exempt
895/// from the evictable cache. A pin POPULATES — if the blob isn't cached it is
896/// fetched first — so it is not a flag flip. Idempotent.
897///
898/// Three cases per blob: already in `pinned/` (nothing to do); in `cache/` (stage
899/// and publish it into `pinned/`, so a read-populated or eagerly-pulled blob is promoted with no
900/// cloud fetch); in neither (fetch from the cloud and write straight to `pinned/`).
901/// `&[RowBlobRef]` rather than ids because every operation must use and revalidate
902/// the exact row version and locator.
903/// Pin one Remote blob into its locator-keyed pinned path: a no-op if already pinned, a verified move
904/// from the evictable cache if staged there, else a cloud fetch straight into
905/// `pinned/`. [`pin`] dispatches this per blob, up to its concurrency limit.
906pub(crate) async fn pin_one(
907 db: &Database,
908 store_dir: &StoreDir,
909 remote: Option<RemoteBlobAccess<'_>>,
910 reference: &RowBlobRef,
911) -> Result<(), BlobCacheError> {
912 validate_row_reference(db, reference).await?;
913 let (pinned, _) = remote_cache_paths(store_dir, reference)?;
914
915 // Already protected — idempotent no-op. A failure to even check existence
916 // (broken filesystem) is surfaced, not collapsed into "absent": fetching and
917 // overwriting a present pinned blob would be wasteful and could mask a real
918 // fault, the same posture `read_blob` takes on its hit check.
919 match crate::local_blob::exists(&pinned).await {
920 Ok(true) => {
921 verify_exact_local_file(&pinned, reference).await?;
922 return Ok(());
923 }
924 Ok(false) => {}
925 Err(e) => return Err(BlobCacheError::Io(e)),
926 }
927
928 // Staged or read-populated in the evictable cache — promote it from a staged
929 // verified copy (no cloud fetch). An `exists`
930 // failure here is surfaced too, never read as "not cached" (which would
931 // re-fetch over a present file).
932 match cached_blob_path_with_facts(store_dir, reference).await? {
933 Some(CachedBlobPath::Pinned(_)) => return Ok(()),
934 Some(CachedBlobPath::Cache(path)) => {
935 return move_exact_cache_file(db, &path, &pinned, reference).await;
936 }
937 None => {}
938 }
939
940 // In neither folder — fetch from the cloud straight into `pinned/`. A
941 // home-less store has no storage to fetch a Remote blob from; surface it.
942 materialize_remote_blob_to_file(db, store_dir, remote, reference, &pinned).await?;
943 Ok(())
944}
945
946/// Drop a Remote blob's pin: move its locator-keyed pinned file to the evictable path so
947/// the cache copy stays (still readable) but is now evictable. Not a delete.
948///
949/// A pin keeps a specific Remote blob's cache copy from eviction; unpin reverses it
950/// regardless of the blob's [`CacheFill`] — a `CacheEager` blob lands in the
951/// evictable cache on pull (it is not auto-pinned), so unpinning one that was never
952/// pinned is simply a no-op (it is already as-evictable-as-it-gets).
953pub async fn unpin(
954 db: &Database,
955 store_dir: &StoreDir,
956 blobs: &[RowBlobRef],
957) -> Result<(), BlobCacheError> {
958 for reference in blobs {
959 validate_row_reference(db, reference).await?;
960 let (pinned, cache) = remote_cache_paths(store_dir, reference)?;
961
962 // Move it into the evictable cache if it is currently pinned. If it isn't in
963 // `pinned/` (already in `cache/`, or remote), there is nothing to demote —
964 // the blob is already as-evictable-as-it-gets, so this is a no-op. A failure
965 // to even check existence is surfaced, never collapsed into "absent": unpin
966 // must not report success over a broken-filesystem check.
967 match crate::local_blob::exists(&pinned).await {
968 Ok(true) => move_exact_cache_file(db, &pinned, &cache, reference).await?,
969 Ok(false) => {}
970 Err(e) => return Err(BlobCacheError::Io(e)),
971 }
972 }
973 Ok(())
974}
975
976/// Drop everything in the evictable cache: delete all of `storage/cache/`, leaving
977/// `storage/pinned/` untouched. A whole-directory sweep, not a per-blob size-budget
978/// eviction — every unpinned blob goes, and a pinned blob (in `pinned/`) survives
979/// because it lives in the other folder.
980///
981/// An absent `cache/` is the only failure that is not an error: it means nothing has
982/// been cached yet, so there is nothing to clear. Every other I/O failure is
983/// returned — a swept directory must actually be gone, never reported clear over a
984/// failed delete.
985#[cfg(test)]
986pub async fn clear_cache(store_dir: &StoreDir) -> Result<(), BlobCacheError> {
987 let cache_dir = store_dir.cache_dir();
988 match crate::local_blob::remove_dir_all(&cache_dir).await {
989 Ok(true) => Ok(()),
990 // No cache dir yet — nothing has been cached, so it is already clear.
991 Ok(false) => {
992 tracing::debug!(
993 "clear_cache: no cache dir at {}, nothing to clear",
994 cache_dir.display()
995 );
996 Ok(())
997 }
998 Err(e) => Err(BlobCacheError::Io(e)),
999 }
1000}
1001
1002/// Evict the oldest files from `namespace`'s cache subtree
1003/// (`storage/cache/<namespace>/`) until its total size is back within that
1004/// namespace's [`Database::get_cache_budget`] budget. The cache layer's per-namespace
1005/// size enforcement, run synchronously after every populate into that namespace
1006/// ([`read_blob`]'s miss-write, [`write_blob`]).
1007///
1008/// Each namespace evicts independently against its own budget, walking **only** its
1009/// own subtree: evicting `release_files` (big) never touches `covers` (a small
1010/// reserved slice). The budget counts **only** the files under
1011/// `cache/<namespace>/` — `pinned/` is never walked (nor is the local store under
1012/// `storage/local/`), so a pinned blob is structurally exempt and can never be
1013/// evicted. With no budget set for this namespace this is a no-op: that namespace's
1014/// cache is unlimited until the host opts it into a budget.
1015///
1016/// Recency is the file's modification time. There is no `last_accessed` column —
1017/// the same folder-truth trade-off the whole cache makes — so the oldest-written
1018/// file is evicted first; pinning, not access tracking, is how a blob is kept.
1019///
1020/// `protect` is the file a just-finished populate wrote (the trigger passes its
1021/// locator-keyed cache path; a bare sweep passes `None`): it is **excluded from the
1022/// candidates outright**, never deleted, so the populate that triggered this sweep
1023/// can't evict the very bytes it just produced. Its size still counts toward the
1024/// total it must fit under, so if that one file alone exceeds the budget the cache
1025/// is left holding exactly it and over budget by that much — the caller still gets
1026/// its bytes, and the next populate's sweep is unaffected. This makes survival
1027/// structural rather than reliant on mtime granularity (two writes within one
1028/// filesystem mtime tick would otherwise be unordered).
1029///
1030/// If every evictable candidate is deleted and the total is still over budget — the
1031/// protected in-use file alone exceeds this namespace's budget — this returns
1032/// `Ok(())` (the file being served can't be evicted), but logs that the cache stays
1033/// over budget because a single in-use blob is larger than the whole budget. It is
1034/// surfaced, not silently reported as if the budget were met.
1035///
1036/// A file that has vanished by the time it is deleted (a concurrent sweep or test
1037/// cache reset already removed it) is the one legitimate skip — logged at debug,
1038/// its now-absent bytes dropped from the running total. Every other stat or delete
1039/// failure is surfaced, never swallowed: a cache that can't be measured or trimmed
1040/// must fail loudly, not silently drift over budget.
1041pub async fn evict_to_budget(
1042 db: &Database,
1043 store_dir: &StoreDir,
1044 namespace: &str,
1045 protect: Option<&std::path::Path>,
1046) -> Result<(), BlobCacheError> {
1047 let budget = match db
1048 .get_cache_budget(namespace)
1049 .await
1050 .map_err(BlobCacheError::Metadata)?
1051 {
1052 Some(budget) => budget,
1053 // This namespace has no budget set — its cache is unlimited, so there is
1054 // nothing to enforce. Another namespace's budget never reaches here.
1055 None => return Ok(()),
1056 };
1057
1058 let mut entries = crate::local_blob::walk_files(&store_dir.cache_namespace_dir(namespace)?)
1059 .await
1060 .map_err(BlobCacheError::Io)?;
1061 // The protected file's bytes count toward the total it must fit under, but it is
1062 // never a deletion candidate — drop it from the list, not the sum.
1063 let mut total: u64 = entries.iter().map(|(_, _, size)| size).sum();
1064 if let Some(protect) = protect {
1065 entries.retain(|(path, _, _)| path.as_path() != protect);
1066 }
1067 if total <= budget {
1068 return Ok(());
1069 }
1070
1071 // Oldest modification time first: that file is evicted first. The recency key is
1072 // milliseconds since the Unix epoch (file mtime), so the smallest sorts first. A
1073 // stable sort is fine — files with the same recency are interchangeable for the
1074 // budget, and the just-written file (the one survival depends on) is already
1075 // excluded above.
1076 entries.sort_by_key(|(_, recency, _)| *recency);
1077
1078 // Each `size` here was part of the `total` sum above, so subtracting it as its
1079 // file is evicted can't underflow as long as that invariant holds. `checked_sub`
1080 // rather than `saturating_sub`: flooring at 0 would mask a genuine accounting
1081 // miscount (a `size` not actually in the sum), so a violation panics loudly
1082 // instead of silently mis-measuring the cache.
1083 for (path, _recency, size) in entries {
1084 if total <= budget {
1085 break;
1086 }
1087 let subtract = |total: u64| {
1088 total.checked_sub(size).unwrap_or_else(|| {
1089 panic!(
1090 "evict accounting underflow at {}: size {size} > running total {total} \
1091 (invariant: every cache file's size was summed into the total)",
1092 path.display()
1093 )
1094 })
1095 };
1096 match crate::local_blob::remove_file(&path).await {
1097 Ok(true) => total = subtract(total),
1098 // The file is already gone (a concurrent sweep or test reset). Its bytes
1099 // are no longer on disk, so drop them from the total and move on — the
1100 // one legitimate skip, not a masked failure.
1101 Ok(false) => {
1102 tracing::debug!("evict: {} already gone, skipping", path.display());
1103 total = subtract(total);
1104 }
1105 Err(e) => return Err(BlobCacheError::Io(e)),
1106 }
1107 }
1108
1109 // Every evictable candidate is gone and the cache is still over budget: the
1110 // protected in-use file alone exceeds this namespace's budget. We can't evict the
1111 // file being served, so return Ok — but surface that the budget is unmet rather
1112 // than reporting success silently.
1113 if total > budget {
1114 tracing::warn!(
1115 "evict: cache stays {} bytes over budget ({total} > {budget}) — a single in-use blob exceeds the whole cache budget",
1116 total - budget
1117 );
1118 }
1119 Ok(())
1120}
1121
1122/// The single store a blob's bytes live in, resolved from coven's authoritative
1123/// state — the blob's locality root, then the blob's intrinsic provenance — the
1124/// [`read_blob`] / [`open_blob_stream`] dispatch key. Neither component is stored on
1125/// the [`BlobRef`]: gated locality is mutable shared state (a make_remote/make_local
1126/// flips it), remote-root locality is declared by the table, and provenance, though
1127/// intrinsic, is read from the row's declaration, not trusted off the address.
1128enum BlobSource {
1129 /// Remote (gate on, or remote root): the cloud, fronted by the device's evictable
1130 /// cache (`pinned/` or `cache/`, else fetched).
1131 Cache,
1132 /// Local (gate off) + user-provided: the user's own external file
1133 /// (`local_blob_refs`).
1134 External,
1135 /// Local (gate off) + host-provided: coven's local store
1136 /// (`storage/local/<namespace>/<id>`).
1137 LocalStore,
1138}
1139
1140/// Resolve the single store a blob's bytes live in from coven's own authoritative
1141/// state — never a probe. Reads the **locality root** first: the carrying row is found in the
1142/// table the blob's `namespace` declares ([`BlobDecls::row_for_blob_in_namespace`] —
1143/// the namespace is the blob's address, so an id colliding across namespaces still
1144/// reads the right table), then walked up to its gated root or remote root
1145/// ([`Gates::root_kept_of`]) using the database's open-time schema models — the same
1146/// row→root→gate resolution the make_remote drain runs ([`crate::blob::upload`]).
1147/// Gate on, or a remote root, ⇒ [`BlobSource::Cache`] (Remote); gate off ⇒ provenance
1148/// picks the Local copy: user-provided ⇒ [`BlobSource::External`], host-provided ⇒
1149/// [`BlobSource::LocalStore`]. A blob whose namespace declares no table, or whose row
1150/// reaches no locality root, has no determinable source —
1151/// [`BlobCacheError::LocalityUnresolved`], surfaced rather than guessed.
1152fn resolve_source(reference: &RowBlobRef) -> Result<BlobSource, BlobCacheError> {
1153 match reference.authority() {
1154 RowBlobAuthority::Remote(_) => Ok(BlobSource::Cache),
1155 RowBlobAuthority::Local | RowBlobAuthority::PendingRemote(_) => {
1156 Ok(match reference.blob().provenance {
1157 Provenance::UserProvided => BlobSource::External,
1158 Provenance::HostProvided => BlobSource::LocalStore,
1159 })
1160 }
1161 }
1162}
1163
1164async fn validate_row_reference(
1165 db: &Database,
1166 reference: &RowBlobRef,
1167) -> Result<(), BlobCacheError> {
1168 db.validate_row_blob_ref(reference)
1169 .await
1170 .map_err(BlobCacheError::Metadata)
1171}
1172
1173fn remote_stored_ref(
1174 reference: &RowBlobRef,
1175) -> Result<&crate::blob::locator::StoredBlobRef, BlobCacheError> {
1176 reference
1177 .stored()
1178 .ok_or_else(|| BlobCacheError::LocalityUnresolved {
1179 id: reference.blob().id.clone(),
1180 })
1181}
1182
1183fn remote_cache_paths(
1184 store_dir: &StoreDir,
1185 reference: &RowBlobRef,
1186) -> Result<(std::path::PathBuf, std::path::PathBuf), BlobCacheError> {
1187 let stored = remote_stored_ref(reference)?;
1188 let namespace = stored.locator().namespace();
1189 let locator_hash = stored.locator().locator_hash();
1190 Ok((
1191 store_dir.pinned_blob_path(namespace, locator_hash)?,
1192 store_dir.cache_blob_path(namespace, locator_hash)?,
1193 ))
1194}
1195
1196async fn verify_exact_local_file(
1197 path: &std::path::Path,
1198 reference: &RowBlobRef,
1199) -> Result<(), BlobCacheError> {
1200 verify_local_file_facts(path, reference.plaintext_size(), reference.plaintext_hash()).await
1201}
1202
1203async fn verify_local_file_facts(
1204 path: &std::path::Path,
1205 expected_size: u64,
1206 expected_hash: crate::sync::store_commit::ObjectHash,
1207) -> Result<(), BlobCacheError> {
1208 let (actual_size, actual_hash) = crate::local_blob::exact_file_facts(path)
1209 .await
1210 .map_err(BlobCacheError::Io)?;
1211 verify_local_file_identity_values(path, expected_size, expected_hash, actual_size, actual_hash)
1212}
1213
1214async fn read_exact_local_file(
1215 path: &std::path::Path,
1216 reference: &RowBlobRef,
1217 read: ExactRead,
1218) -> Result<Vec<u8>, BlobCacheError> {
1219 let (bytes, actual_size, actual_hash) = match read {
1220 ExactRead::Whole => crate::local_blob::read_with_facts(path).await,
1221 ExactRead::Range { offset, len } => {
1222 crate::local_blob::read_range_with_facts(path, offset, len).await
1223 }
1224 }
1225 .map_err(BlobCacheError::Io)?;
1226 verify_local_file_identity(path, reference, actual_size, actual_hash)?;
1227 Ok(bytes)
1228}
1229
1230fn verify_local_file_identity(
1231 path: &std::path::Path,
1232 reference: &RowBlobRef,
1233 actual_size: u64,
1234 actual_hash: crate::sync::store_commit::ObjectHash,
1235) -> Result<(), BlobCacheError> {
1236 verify_local_file_identity_values(
1237 path,
1238 reference.plaintext_size(),
1239 reference.plaintext_hash(),
1240 actual_size,
1241 actual_hash,
1242 )
1243}
1244
1245fn verify_local_file_identity_values(
1246 path: &std::path::Path,
1247 expected_size: u64,
1248 expected_hash: crate::sync::store_commit::ObjectHash,
1249 actual_size: u64,
1250 actual_hash: crate::sync::store_commit::ObjectHash,
1251) -> Result<(), BlobCacheError> {
1252 if actual_size != expected_size || actual_hash != expected_hash {
1253 return Err(BlobCacheError::LocalIntegrity {
1254 path: path.to_path_buf(),
1255 expected_size,
1256 actual_size,
1257 expected_hash,
1258 actual_hash,
1259 });
1260 }
1261 Ok(())
1262}
1263
1264/// Look up the external file ref for `id`, mapping the DB error into the cache's
1265/// error type. Used by the Local + user-provided dispatch arm of [`read_blob`] /
1266/// [`open_blob_stream`]: a `None` there is [`BlobCacheError::NoExternalRef`] (the gate
1267/// said Local + user-provided, so the ref must exist), not a fall-through.
1268async fn lookup_external_ref(
1269 db: &Database,
1270 reference: &RowBlobRef,
1271) -> Result<Option<crate::db::ExternalBlob>, BlobCacheError> {
1272 db.external_blob_for_row(reference)
1273 .await
1274 .map_err(BlobCacheError::Metadata)
1275}
1276
1277/// The cache folder path that currently holds a Remote blob's plaintext, checking
1278/// `pinned/` then `cache/`. A failure to check either path is surfaced, never
1279/// collapsed into a miss.
1280enum CachedBlobPath {
1281 Pinned(std::path::PathBuf),
1282 Cache(std::path::PathBuf),
1283}
1284
1285impl CachedBlobPath {
1286 fn path(&self) -> &std::path::Path {
1287 match self {
1288 CachedBlobPath::Pinned(path) | CachedBlobPath::Cache(path) => path,
1289 }
1290 }
1291}
1292
1293async fn cached_blob_path_with_facts(
1294 store_dir: &StoreDir,
1295 reference: &RowBlobRef,
1296) -> Result<Option<CachedBlobPath>, BlobCacheError> {
1297 let hit = cached_blob_path(store_dir, reference).await?;
1298 if let Some(hit) = &hit {
1299 verify_exact_local_file(hit.path(), reference).await?;
1300 }
1301 Ok(hit)
1302}
1303
1304async fn cached_blob_path(
1305 store_dir: &StoreDir,
1306 reference: &RowBlobRef,
1307) -> Result<Option<CachedBlobPath>, BlobCacheError> {
1308 let (pinned, cache) = remote_cache_paths(store_dir, reference)?;
1309 for hit in [CachedBlobPath::Pinned(pinned), CachedBlobPath::Cache(cache)] {
1310 match crate::local_blob::exists(hit.path()).await {
1311 Ok(true) => return Ok(Some(hit)),
1312 Ok(false) => {}
1313 Err(error) => return Err(BlobCacheError::Io(error)),
1314 }
1315 }
1316 Ok(None)
1317}
1318
1319async fn read_cached_exact(
1320 store_dir: &StoreDir,
1321 reference: &RowBlobRef,
1322) -> Result<Option<Vec<u8>>, BlobCacheError> {
1323 let Some(hit) = cached_blob_path(store_dir, reference).await? else {
1324 return Ok(None);
1325 };
1326 read_exact_local_file(hit.path(), reference, ExactRead::Whole)
1327 .await
1328 .map(Some)
1329}
1330
1331/// A whole-blob vs ranged read of an external (user-provided Local) file. The two
1332/// reads share the validate-on-read; only the local read primitive differs.
1333#[derive(Clone, Copy)]
1334enum ExactRead {
1335 Whole,
1336 Range { offset: u64, len: u64 },
1337}
1338
1339/// Read a user-provided Local blob from its registered external file `ext`. The
1340/// external file is the only copy — no fallback. A failed read surfaces its
1341/// underlying cause as [`BlobCacheError::ExternalMissing`] (the error is preserved,
1342/// not collapsed); for a whole read, a length that no longer matches the registered
1343/// `size` is [`BlobCacheError::ExternalSizeMismatch`].
1344async fn read_external_file(
1345 reference: &RowBlobRef,
1346 ext: crate::db::ExternalBlob,
1347 op: ExactRead,
1348) -> Result<Vec<u8>, BlobCacheError> {
1349 let id = &reference.blob().id;
1350 let read = match op {
1351 ExactRead::Whole => crate::local_blob::read_with_facts(&ext.path).await,
1352 ExactRead::Range { offset, len } => {
1353 crate::local_blob::read_range_with_facts(&ext.path, offset, len).await
1354 }
1355 };
1356 let (bytes, actual_size, actual_hash) =
1357 read.map_err(|source| BlobCacheError::ExternalMissing {
1358 id: id.to_string(),
1359 path: ext.path.clone(),
1360 source,
1361 })?;
1362 if actual_size != ext.size || actual_size != reference.plaintext_size() {
1363 return Err(BlobCacheError::ExternalSizeMismatch {
1364 id: id.clone(),
1365 path: ext.path,
1366 });
1367 }
1368 verify_local_file_identity(&ext.path, reference, actual_size, actual_hash)?;
1369 Ok(bytes)
1370}
1371
1372async fn verify_external_file(
1373 reference: &RowBlobRef,
1374 ext: &crate::db::ExternalBlob,
1375) -> Result<(), BlobCacheError> {
1376 let id = &reference.blob().id;
1377 let (actual_size, actual_hash) = crate::local_blob::exact_file_facts(&ext.path)
1378 .await
1379 .map_err(|source| BlobCacheError::ExternalMissing {
1380 id: id.clone(),
1381 path: ext.path.clone(),
1382 source,
1383 })?;
1384 if actual_size != ext.size || actual_size != reference.plaintext_size() {
1385 return Err(BlobCacheError::ExternalSizeMismatch {
1386 id: id.clone(),
1387 path: ext.path.clone(),
1388 });
1389 }
1390 let expected_hash = reference.plaintext_hash();
1391 if actual_hash != expected_hash {
1392 return Err(BlobCacheError::LocalIntegrity {
1393 path: ext.path.clone(),
1394 expected_size: reference.plaintext_size(),
1395 actual_size,
1396 expected_hash,
1397 actual_hash,
1398 });
1399 }
1400 Ok(())
1401}
1402
1403/// Move one exact locator file between cache folders. The source is copied into an
1404/// operation-owned stage and exact-verified before no-replace publication, so an
1405/// eviction cannot invalidate the bytes being published. The row reference is
1406/// revalidated immediately before publication.
1407async fn move_exact_cache_file(
1408 db: &Database,
1409 from: &std::path::Path,
1410 to: &std::path::Path,
1411 reference: &RowBlobRef,
1412) -> Result<(), BlobCacheError> {
1413 let staged = stage_exact_local_copy(from, to, reference).await?;
1414 validate_row_reference(db, reference).await?;
1415 publish_materialization(staged, reference).await?;
1416 crate::local_blob::remove_file(from)
1417 .await
1418 .map_err(BlobCacheError::Io)?;
1419 crate::local_blob::sync_parent_dir(from)
1420 .await
1421 .map_err(BlobCacheError::Io)
1422}
1423
1424async fn stage_exact_local_copy(
1425 from: &std::path::Path,
1426 to: &std::path::Path,
1427 reference: &RowBlobRef,
1428) -> Result<crate::local_blob::AtomicStagedFile, BlobCacheError> {
1429 let staged = crate::local_blob::stage_atomic_destination(to)
1430 .await
1431 .map_err(BlobCacheError::Io)?;
1432 let (actual_size, actual_hash) = crate::local_blob::copy_atomic_with_facts(from, staged.path())
1433 .await
1434 .map_err(BlobCacheError::Io)?;
1435 verify_local_file_identity(from, reference, actual_size, actual_hash)?;
1436 Ok(staged)
1437}