vyre_runtime/pipeline_cache/
disk.rs1use 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#[derive(Debug)]
26pub struct DiskCache {
27 root: PathBuf,
28 pending_flushes: DashMap<PathBuf, ()>,
29 metrics: PipelineCacheCounters,
30}
31
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct DiskCacheDurabilityReport {
35 pub pending_flushes: u64,
37 pub durable: bool,
40}
41
42static DISK_CACHE_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
47
48pub(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 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 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 #[must_use]
98 pub fn root(&self) -> &Path {
99 &self.root
100 }
101
102 #[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 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 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 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 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#[derive(Debug, thiserror::Error)]
310#[non_exhaustive]
311pub enum DiskCacheError {
312 #[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 #[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 #[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}