Skip to main content

vyre_runtime/pipeline_cache/
disk.rs

1//! Disk-backed neutral-artifact cache.
2//!
3//! Each fingerprint maps to one versioned, digest-bound file under
4//! `<root>/<hex>.bin`. Readers reject stale schemas, torn writes, bit rot, and
5//! tampering before returning payload bytes.
6
7use std::fs::{self, File};
8use std::io::Read;
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::OnceLock;
13
14use dashmap::DashMap;
15
16use super::fingerprint::PipelineFingerprint;
17use super::metrics::{PipelineCacheCounters, PipelineCacheMetrics};
18use super::store::PipelineCacheStore;
19
20/// Disk-backed pipeline cache. Writes one file per fingerprint
21/// under `<root>/<hex>.bin`. Reads are stateless; writes are
22/// `write + rename` for atomicity. No eviction policy today
23/// (user decides)  -  the footprint is bounded by
24/// sum(artifact_size × unique_canonical_programs).
25#[derive(Debug)]
26pub struct DiskCache {
27    root: PathBuf,
28    pending_flushes: DashMap<PathBuf, ()>,
29    metrics: PipelineCacheCounters,
30}
31
32/// Crash-durability evidence for disk cache artifacts.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct DiskCacheDurabilityReport {
35    /// Entries installed by rename but not yet explicitly flushed.
36    pub pending_flushes: u64,
37    /// True when no installed artifacts are waiting on file and parent-dir
38    /// fsync evidence.
39    pub durable: bool,
40}
41
42/// Persistent process-crossing pipeline-cache store.
43///
44/// This is the default disk-backed store for callers that need compiled
45/// pipeline artifacts to survive process restarts.
46static DISK_CACHE_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
47
48// On-disk layout:
49//   "VPC0"  <u16 little-endian schema>  <payload bytes..>  <32-byte blake3 footer>
50// The checksum covers the versioned header and payload. Readers reject stale
51// schema versions before returning bytes, so persisted entries cannot revive an
52// earlier cache contract.
53pub(super) const PIPELINE_CACHE_SCHEMA_VERSION: u16 = 1;
54const PIPELINE_CACHE_MAGIC: &[u8; 4] = b"VPC0";
55const PIPELINE_CACHE_HEADER_LEN: usize = 6;
56const PIPELINE_CACHE_HEADER_LEN_U64: u64 = 6;
57pub(super) const CHECKSUM_LEN: usize = 32;
58pub(super) const CHECKSUM_LEN_U64: u64 = 32;
59pub(super) const MAX_PIPELINE_BLOB_BYTES: u64 = 64 * 1024 * 1024;
60pub(super) const MAX_ENCODED_PIPELINE_BLOB_BYTES: u64 =
61    MAX_PIPELINE_BLOB_BYTES + PIPELINE_CACHE_HEADER_LEN_U64 + CHECKSUM_LEN_U64;
62
63impl DiskCache {
64    /// Construct a cache rooted at `root`. Creates the directory if
65    /// it doesn't exist.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`DiskCacheError::Io`] when the directory can't be
70    /// created.
71    pub fn new(root: impl Into<PathBuf>) -> Result<Self, DiskCacheError> {
72        let root = root.into();
73        fs::create_dir_all(&root).map_err(DiskCacheError::Io)?;
74        Ok(Self {
75            root,
76            pending_flushes: DashMap::new(),
77            metrics: PipelineCacheCounters::default(),
78        })
79    }
80
81    /// Construct a cache rooted at `~/.cache/vyre/pipelines/` (or
82    /// `$XDG_CACHE_HOME/vyre/pipelines/` if set).
83    ///
84    /// # Errors
85    ///
86    /// Returns [`DiskCacheError::CacheDirUnknown`] when neither env
87    /// var resolves, or [`DiskCacheError::Io`] on mkdir failure.
88    pub fn in_user_cache() -> Result<Self, DiskCacheError> {
89        let base = std::env::var_os("XDG_CACHE_HOME")
90            .map(PathBuf::from)
91            .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".cache")))
92            .ok_or(DiskCacheError::CacheDirUnknown)?;
93        Self::new(base.join("vyre").join("pipelines"))
94    }
95
96    /// Root directory this cache operates on.
97    #[must_use]
98    pub fn root(&self) -> &Path {
99        &self.root
100    }
101
102    /// Snapshot whether installed artifacts have crossed the explicit
103    /// durability boundary.
104    #[must_use]
105    pub fn durability_report(&self) -> DiskCacheDurabilityReport {
106        let pending_flushes = match u64::try_from(self.pending_flushes.len()) {
107            Ok(pending_flushes) => pending_flushes,
108            Err(_) => u64::MAX,
109        };
110        DiskCacheDurabilityReport {
111            pending_flushes,
112            durable: pending_flushes == 0,
113        }
114    }
115
116    fn path_for(&self, fp: &PipelineFingerprint) -> PathBuf {
117        self.root.join(cache_file_name(fp))
118    }
119}
120
121fn cache_file_name(fp: &PipelineFingerprint) -> String {
122    let mut file_name = String::with_capacity(68);
123    fp.push_hex(&mut file_name);
124    file_name.push_str(".bin");
125    file_name
126}
127
128impl PipelineCacheStore for DiskCache {
129    fn get(&self, fp: &PipelineFingerprint) -> Option<Vec<u8>> {
130        self.metrics.lookups.fetch_add(1, Ordering::Relaxed);
131        let path = self.path_for(fp);
132        // FINDING-CACHE-1: reject symlinks before reading. `symlink_metadata`
133        // does NOT follow the symlink; regular-file check is strict.
134        let Some(meta) = fs::symlink_metadata(&path).ok() else {
135            self.metrics.misses.fetch_add(1, Ordering::Relaxed);
136            return None;
137        };
138        if !meta.file_type().is_file() {
139            self.metrics.misses.fetch_add(1, Ordering::Relaxed);
140            return None;
141        }
142        if meta.len() > MAX_ENCODED_PIPELINE_BLOB_BYTES {
143            self.metrics.misses.fetch_add(1, Ordering::Relaxed);
144            return None;
145        }
146        let Some(file) = File::open(&path).ok() else {
147            self.metrics.misses.fetch_add(1, Ordering::Relaxed);
148            return None;
149        };
150        let capacity = usize::try_from(meta.len()).ok()?;
151        let result = read_verified_cache_blob_with_capacity(file, capacity);
152        if result.is_some() {
153            self.metrics.hits.fetch_add(1, Ordering::Relaxed);
154        } else {
155            self.metrics.misses.fetch_add(1, Ordering::Relaxed);
156        }
157        result
158    }
159
160    fn put(&self, fp: PipelineFingerprint, artifact: Vec<u8>) {
161        let tmp_id = DISK_CACHE_TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
162        let mut tmp_name = String::with_capacity(85);
163        tmp_name.push('.');
164        fp.push_hex(&mut tmp_name);
165        tmp_name.push('-');
166        append_u64_decimal(&mut tmp_name, tmp_id);
167        tmp_name.push_str(".bin.tmp");
168        let tmp_path = self.root.join(&tmp_name);
169
170        let mut final_name = String::with_capacity(68);
171        fp.push_hex(&mut final_name);
172        final_name.push_str(".bin");
173        let final_path = self.root.join(&final_name);
174
175        // Write the versioned header, payload, and digest footer, then install by
176        // rename so readers see either the prior complete file or the new
177        // complete file. Durability is batched through `flush`; fsyncing every
178        // insertion turns steady-state cache population into a storage latency
179        // bottleneck.
180        let write_rename = || -> io::Result<()> {
181            let encoded = encode_cache_blob(&artifact);
182            let mut f = File::create(&tmp_path)?;
183            f.write_all(&encoded)?;
184            drop(f);
185            // FINDING-CACHE-1: if the final path is a symlink, unlink it
186            // first so rename replaces the symlink (not its target).
187            if let Ok(meta) = fs::symlink_metadata(&final_path) {
188                if meta.file_type().is_symlink() {
189                    fs::remove_file(&final_path)?;
190                }
191            }
192            fs::rename(&tmp_path, &final_path)?;
193            self.pending_flushes.insert(final_path, ());
194            Ok(())
195        };
196        if write_rename().is_err() {
197            self.metrics.rejected_puts.fetch_add(1, Ordering::Relaxed);
198            // Best-effort; caller falls back to recompile. Clean up
199            // the tmp file so it doesn't accumulate on failure.
200            match fs::remove_file(&tmp_path) {
201                Ok(()) => {}
202                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
203                Err(error) => tracing::warn!(
204                    tmp_path = %tmp_path.display(),
205                    error = %error,
206                    "failed to remove temporary disk-cache artifact after rejected put"
207                ),
208            }
209        } else {
210            self.metrics.puts.fetch_add(1, Ordering::Relaxed);
211        }
212    }
213
214    fn flush(&self) -> io::Result<()> {
215        self.metrics.flushes.fetch_add(1, Ordering::Relaxed);
216        let paths: Vec<PathBuf> = self
217            .pending_flushes
218            .iter()
219            .map(|entry| entry.key().clone())
220            .collect();
221        self.pending_flushes.clear();
222        if let Err(error) = flush_paths(&paths) {
223            self.metrics.flush_errors.fetch_add(1, Ordering::Relaxed);
224            for path in paths {
225                self.pending_flushes.insert(path, ());
226            }
227            return Err(error);
228        }
229        Ok(())
230    }
231
232    fn metrics(&self) -> PipelineCacheMetrics {
233        self.metrics.snapshot(0, 0)
234    }
235}
236
237fn flush_paths(paths: &[PathBuf]) -> io::Result<()> {
238    let mut parents = Vec::with_capacity(paths.len());
239    sync_paths_bounded(
240        paths,
241        File::sync_data,
242        "pipeline cache file sync worker panicked",
243    )?;
244    for path in paths {
245        if let Some(parent) = path.parent() {
246            parents.push(parent.to_path_buf());
247        }
248    }
249    parents.sort();
250    parents.dedup();
251    sync_parent_dirs(&parents)?;
252    Ok(())
253}
254
255#[cfg(unix)]
256fn sync_parent_dirs(parents: &[PathBuf]) -> io::Result<()> {
257    sync_paths_bounded(
258        parents,
259        File::sync_all,
260        "pipeline cache directory sync worker panicked",
261    )
262}
263
264#[cfg(not(unix))]
265fn sync_parent_dirs(_parents: &[PathBuf]) -> io::Result<()> {
266    Ok(())
267}
268
269fn sync_paths_bounded(
270    paths: &[PathBuf],
271    sync: fn(&File) -> io::Result<()>,
272    panic_message: &'static str,
273) -> io::Result<()> {
274    if paths.is_empty() {
275        return Ok(());
276    }
277    let workers = sync_worker_count();
278    for chunk in paths.chunks(workers) {
279        std::thread::scope(|scope| {
280            let mut handles = Vec::with_capacity(chunk.len());
281            for path in chunk {
282                handles.push(scope.spawn(move || {
283                    let file = File::open(path)?;
284                    sync(&file)
285                }));
286            }
287            for handle in handles {
288                handle
289                    .join()
290                    .map_err(|_| io::Error::other(panic_message))??;
291            }
292            Ok::<(), io::Error>(())
293        })?;
294    }
295    Ok(())
296}
297
298fn sync_worker_count() -> usize {
299    static WORKERS: OnceLock<usize> = OnceLock::new();
300    *WORKERS.get_or_init(|| {
301        std::thread::available_parallelism()
302            .map(usize::from)
303            .unwrap_or(1)
304            .clamp(1, 16)
305    })
306}
307
308/// Errors from disk-backed pipeline cache construction / use.
309#[derive(Debug, thiserror::Error)]
310#[non_exhaustive]
311pub enum DiskCacheError {
312    /// Neither `$XDG_CACHE_HOME` nor `$HOME` is set.
313    #[error(
314        "could not resolve a user cache directory  -  set XDG_CACHE_HOME or HOME, or call DiskCache::new() with an explicit path"
315    )]
316    CacheDirUnknown,
317    /// `std::io` failure (mkdir, read, write).
318    #[error("disk-cache I/O error: {0}")]
319    Io(#[from] io::Error),
320}
321
322#[cfg_attr(not(any(test, feature = "remote-cache")), allow(dead_code))]
323pub(super) fn read_verified_cache_blob(mut reader: impl Read) -> Option<Vec<u8>> {
324    read_verified_cache_blob_with_capacity(&mut reader, 0)
325}
326
327fn read_verified_cache_blob_with_capacity(
328    mut reader: impl Read,
329    capacity: usize,
330) -> Option<Vec<u8>> {
331    let max_encoded_capacity = usize::try_from(MAX_ENCODED_PIPELINE_BLOB_BYTES).ok()?;
332    let mut bytes = Vec::with_capacity(capacity.min(max_encoded_capacity));
333    reader
334        .by_ref()
335        .take(MAX_ENCODED_PIPELINE_BLOB_BYTES + 1)
336        .read_to_end(&mut bytes)
337        .ok()?;
338    verify_cache_blob(bytes)
339}
340
341pub(super) fn verify_cache_blob(mut bytes: Vec<u8>) -> Option<Vec<u8>> {
342    let byte_len = u64::try_from(bytes.len()).ok()?;
343    if byte_len > MAX_ENCODED_PIPELINE_BLOB_BYTES
344        || bytes.len() < PIPELINE_CACHE_HEADER_LEN + CHECKSUM_LEN
345    {
346        return None;
347    }
348    let signed_len = bytes.len() - CHECKSUM_LEN;
349    let (signed, footer) = bytes.split_at(signed_len);
350    let expected = ::blake3::hash(signed);
351    if footer != expected.as_bytes()
352        || signed.get(..4)? != PIPELINE_CACHE_MAGIC
353        || u16::from_le_bytes(signed.get(4..6)?.try_into().ok()?) != PIPELINE_CACHE_SCHEMA_VERSION
354    {
355        return None;
356    }
357    let payload_len = signed_len.checked_sub(PIPELINE_CACHE_HEADER_LEN)?;
358    if u64::try_from(payload_len).ok()? > MAX_PIPELINE_BLOB_BYTES {
359        return None;
360    }
361    bytes.truncate(signed_len);
362    bytes.drain(..PIPELINE_CACHE_HEADER_LEN);
363    Some(bytes)
364}
365
366fn encode_cache_blob(payload: &[u8]) -> Vec<u8> {
367    let mut encoded = Vec::with_capacity(PIPELINE_CACHE_HEADER_LEN + payload.len() + CHECKSUM_LEN);
368    encoded.extend_from_slice(PIPELINE_CACHE_MAGIC);
369    encoded.extend_from_slice(&PIPELINE_CACHE_SCHEMA_VERSION.to_le_bytes());
370    encoded.extend_from_slice(payload);
371    let checksum = ::blake3::hash(&encoded);
372    encoded.extend_from_slice(checksum.as_bytes());
373    encoded
374}
375
376fn append_u64_decimal(out: &mut String, mut value: u64) {
377    let mut digits = [0u8; 20];
378    let mut len = 0usize;
379    loop {
380        digits[len] = b'0' + (value % 10) as u8;
381        len += 1;
382        value /= 10;
383        if value == 0 {
384            break;
385        }
386    }
387    for digit in digits[..len].iter().rev() {
388        out.push(char::from(*digit));
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::pipeline_cache::test_helpers::tiny_artifact;
396
397    #[test]
398    fn disk_cache_persists_across_store_reopen() {
399        let temp = tempfile::TempDir::new().expect("Fix: tempdir required for disk cache test");
400        let fp = PipelineFingerprint::of(&tiny_artifact());
401        {
402            let cache = DiskCache::new(temp.path())
403                .expect("Fix: disk cache test must create isolated cache root");
404            cache.put(fp, b"driver-pipeline-blob".to_vec());
405        }
406        let reopened =
407            DiskCache::new(temp.path()).expect("Fix: disk cache must reopen an existing root");
408        assert_eq!(
409            reopened.get(&fp),
410            Some(b"driver-pipeline-blob".to_vec()),
411            "Fix: disk PipelineCacheStore must survive process/backend reconstruction"
412        );
413    }
414
415    #[test]
416    fn disk_cache_flush_is_explicit_durability_boundary() {
417        let temp = tempfile::TempDir::new().expect("Fix: tempdir required for disk cache test");
418        let fp = PipelineFingerprint::of(&tiny_artifact());
419        let cache = DiskCache::new(temp.path())
420            .expect("Fix: disk cache test must create isolated cache root");
421        cache.put(fp, b"driver-pipeline-blob".to_vec());
422        assert!(
423            !cache.pending_flushes.is_empty(),
424            "Fix: DiskCache::put must defer fsync work until explicit flush."
425        );
426        cache
427            .flush()
428            .expect("Fix: explicit disk cache flush must fsync pending entries.");
429        assert!(
430            cache.pending_flushes.is_empty(),
431            "Fix: explicit disk cache flush must drain pending entries."
432        );
433        assert_eq!(
434            cache.get(&fp),
435            Some(b"driver-pipeline-blob".to_vec()),
436            "Fix: explicit flush must preserve the installed cache artifact."
437        );
438    }
439
440    #[test]
441    fn cache_blob_verifier_accepts_current_versioned_frame() {
442        let payload = b"compiled-artifact".to_vec();
443        let encoded = encode_cache_blob(&payload);
444
445        assert_eq!(verify_cache_blob(encoded), Some(payload));
446    }
447
448    #[test]
449    fn cache_blob_verifier_rejects_corrupted_footer() {
450        let payload = b"compiled-artifact".to_vec();
451        let mut encoded = encode_cache_blob(&payload);
452        let footer_start = encoded.len() - CHECKSUM_LEN;
453        encoded[footer_start..].fill(0xA5);
454
455        assert!(
456            verify_cache_blob(encoded).is_none(),
457            "Fix: disk and remote cache readers must reject artifacts whose checksum footer does not match"
458        );
459    }
460
461    /// WHY: persisted cache framing must reject a stale schema even when its
462    /// digest is internally valid, or old cache semantics can survive upgrades.
463    #[test]
464    fn cache_blob_verifier_rejects_stale_schema_version() {
465        let mut encoded = encode_cache_blob(b"compiled-artifact");
466        encoded[4..6].copy_from_slice(&0_u16.to_le_bytes());
467        let footer_start = encoded.len() - CHECKSUM_LEN;
468        let digest = ::blake3::hash(&encoded[..footer_start]);
469        encoded[footer_start..].copy_from_slice(digest.as_bytes());
470
471        assert!(
472            verify_cache_blob(encoded).is_none(),
473            "Fix: stale persisted pipeline-cache schemas must miss and recompile"
474        );
475    }
476
477    #[test]
478    fn cache_blob_reader_rejects_oversized_encoded_blob() {
479        let oversized = std::io::repeat(0).take(MAX_ENCODED_PIPELINE_BLOB_BYTES + 1);
480
481        assert!(
482            read_verified_cache_blob(oversized).is_none(),
483            "Fix: disk and remote cache readers must cap encoded blob bytes before allocation"
484        );
485    }
486
487    #[test]
488    fn disk_cache_durability_report_tracks_pending_flush_boundary() {
489        let temp = tempfile::tempdir().expect("Fix: create temp disk cache root");
490        let fp = PipelineFingerprint::of(&tiny_artifact());
491        let cache = DiskCache::new(temp.path()).expect("Fix: create disk cache");
492
493        assert_eq!(
494            cache.durability_report(),
495            DiskCacheDurabilityReport {
496                pending_flushes: 0,
497                durable: true,
498            }
499        );
500
501        cache.put(fp, b"driver-pipeline-blob".to_vec());
502        let after_put = cache.durability_report();
503        assert_eq!(after_put.pending_flushes, 1);
504        assert!(
505            !after_put.durable,
506            "Fix: installed artifacts must not be reported durable before explicit flush"
507        );
508
509        cache
510            .flush()
511            .expect("Fix: disk cache flush must fsync pending file and parent dir");
512        assert_eq!(
513            cache.durability_report(),
514            DiskCacheDurabilityReport {
515                pending_flushes: 0,
516                durable: true,
517            }
518        );
519    }
520}