Skip to main content

vole_document/field/
cache.rs

1//! Disposable, closure-keyed derived observation cache (Phase 11.8).
2//!
3//! A seed node's output is a pure function of its canonical bytes, hence of its
4//! [`NodeId`]. A cache keyed by `NodeId` is therefore automatically
5//! **closure-keyed**: a changed dependency yields a different dependency id,
6//! hence a different node id, hence a miss; an unchanged closure yields a hit.
7//! This is the exact analogue of rustc's red-green validation, and it needs no
8//! invalidation pass (ADR-0025, ADR-0027).
9//!
10//! The cache is **disposable** and never normative. It is off the exactness path
11//! and can never influence `materialize`. Because [`DerivedCache::get`] only has
12//! the output bytes (it cannot re-derive the node), every entry carries a
13//! **sidecar integrity digest**: a mismatch fails closed with
14//! [`crate::ErrorClass::IntegrityMismatch`] rather than returning wrong bytes.
15//! The DAG layer treats any cache error as a miss, so a corrupt or missing entry
16//! simply falls back to recomputation.
17//!
18//! ## On-disk layout
19//!
20//! ```text
21//! <root>/<64-hex>        output bytes, verbatim
22//! <root>/<64-hex>.b3     BLAKE3-256 sidecar digest of those bytes
23//! ```
24//!
25//! Writes are atomic (`tmp -> fsync -> rename`); a torn write cannot publish a
26//! half-entry. Cache bytes are a fourth accounting universe and are never folded
27//! into the descriptor or store universes (ADR-0027).
28
29use std::fs;
30use std::path::{Path, PathBuf};
31
32use crate::error::{Error, Result};
33use crate::store::NodeId;
34
35use super::dag::OutputCache;
36use super::write_atomic;
37
38/// A disposable, closure-keyed derived observation cache rooted under a field
39/// store's `cache/` directory.
40pub struct DerivedCache {
41    root: PathBuf,
42}
43
44impl std::fmt::Debug for DerivedCache {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("DerivedCache")
47            .field("root", &self.root)
48            .finish_non_exhaustive()
49    }
50}
51
52impl DerivedCache {
53    /// Open (creating if needed) a derived cache rooted at `root`.
54    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
55        let root = root.as_ref().to_path_buf();
56        fs::create_dir_all(&root)?;
57        Ok(DerivedCache { root })
58    }
59
60    /// The cache root directory.
61    pub fn root(&self) -> &Path {
62        &self.root
63    }
64
65    fn bytes_path(&self, id: &NodeId) -> PathBuf {
66        self.root.join(id.to_hex())
67    }
68
69    fn digest_path(&self, id: &NodeId) -> PathBuf {
70        self.root.join(format!("{}.b3", id.to_hex()))
71    }
72
73    /// Fetch a cached output, verifying the sidecar digest.
74    ///
75    /// * absent bytes or absent sidecar -> `Ok(None)` (a miss; disposable).
76    /// * sidecar present but `BLAKE3(bytes) != sidecar` ->
77    ///   [`crate::ErrorClass::IntegrityMismatch`] (fail closed; never return
78    ///   wrong bytes).
79    pub fn get(&self, id: &NodeId) -> Result<Option<Vec<u8>>> {
80        let bytes = match fs::read(self.bytes_path(id)) {
81            Ok(b) => b,
82            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
83            Err(e) => return Err(Error::io(format!("reading cache entry {id}: {e}"))),
84        };
85        let sidecar = match fs::read(self.digest_path(id)) {
86            Ok(b) => b,
87            // A missing sidecar is an incomplete (hence disposable) entry.
88            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
89            Err(e) => return Err(Error::io(format!("reading cache sidecar {id}: {e}"))),
90        };
91        if sidecar.as_slice() != blake3::hash(&bytes).as_bytes() {
92            return Err(Error::integrity_mismatch(format!(
93                "cache entry {id} does not match its sidecar digest"
94            )));
95        }
96        Ok(Some(bytes))
97    }
98
99    /// Store an output atomically with its sidecar digest. Returns the number of
100    /// output bytes written.
101    pub fn put(&mut self, id: &NodeId, bytes: &[u8]) -> Result<u64> {
102        write_atomic(&self.bytes_path(id), bytes)?;
103        write_atomic(&self.digest_path(id), blake3::hash(bytes).as_bytes())?;
104        Ok(bytes.len() as u64)
105    }
106
107    /// Whether an output file exists for `id` (the sidecar is not checked).
108    pub fn contains(&self, id: &NodeId) -> Result<bool> {
109        Ok(self.bytes_path(id).exists())
110    }
111
112    /// Total physical cache bytes (outputs plus sidecars), excluding temp files.
113    pub fn total_bytes(&self) -> Result<u64> {
114        let mut total = 0u64;
115        for entry in fs::read_dir(&self.root)? {
116            let entry = entry?;
117            if entry.file_name().to_string_lossy().starts_with('.') {
118                continue;
119            }
120            let meta = entry.metadata()?;
121            if meta.is_file() {
122                total = total.saturating_add(meta.len());
123            }
124        }
125        Ok(total)
126    }
127
128    /// Remove every cache entry, returning the bytes reclaimed. Idempotent.
129    pub fn clear(&self) -> Result<u64> {
130        let mut reclaimed = 0u64;
131        for entry in fs::read_dir(&self.root)? {
132            let entry = entry?;
133            let path = entry.path();
134            if entry.file_type()?.is_file() {
135                if !entry.file_name().to_string_lossy().starts_with('.') {
136                    reclaimed = reclaimed.saturating_add(entry.metadata()?.len());
137                }
138                fs::remove_file(&path)?;
139            }
140        }
141        Ok(reclaimed)
142    }
143}
144
145impl OutputCache for DerivedCache {
146    fn get(&self, id: &NodeId) -> Result<Option<Vec<u8>>> {
147        DerivedCache::get(self, id)
148    }
149
150    fn put(&mut self, id: &NodeId, bytes: &[u8]) -> Result<()> {
151        DerivedCache::put(self, id, bytes).map(|_| ())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn temp_root(label: &str) -> PathBuf {
160        let mut p = std::env::temp_dir();
161        p.push(format!(
162            "vole-cache-{label}-{}-{}",
163            std::process::id(),
164            std::time::SystemTime::now()
165                .duration_since(std::time::UNIX_EPOCH)
166                .unwrap()
167                .as_nanos()
168        ));
169        p
170    }
171
172    #[test]
173    fn put_get_roundtrip_and_accounting() {
174        let root = temp_root("rt");
175        let mut cache = DerivedCache::open(&root).unwrap();
176        let id = NodeId::from_bytes([7u8; 32]);
177        assert!(!cache.contains(&id).unwrap());
178        assert_eq!(cache.get(&id).unwrap(), None);
179        assert_eq!(cache.put(&id, b"derived output").unwrap(), 14);
180        assert!(cache.contains(&id).unwrap());
181        assert_eq!(
182            cache.get(&id).unwrap().as_deref(),
183            Some(&b"derived output"[..])
184        );
185        // Physical bytes = output (14) + 32-byte sidecar.
186        assert_eq!(cache.total_bytes().unwrap(), 14 + 32);
187        let reclaimed = cache.clear().unwrap();
188        assert_eq!(reclaimed, 14 + 32);
189        assert_eq!(cache.total_bytes().unwrap(), 0);
190        assert_eq!(cache.get(&id).unwrap(), None);
191        fs::remove_dir_all(&root).ok();
192    }
193
194    #[test]
195    fn poisoned_bytes_fail_closed_never_wrong() {
196        let root = temp_root("poison");
197        let mut cache = DerivedCache::open(&root).unwrap();
198        let id = NodeId::from_bytes([3u8; 32]);
199        cache.put(&id, b"correct bytes").unwrap();
200        // Corrupt the output but keep the sidecar: get must fail closed.
201        fs::write(cache.bytes_path(&id), b"wrong!!").unwrap();
202        let err = cache.get(&id).unwrap_err();
203        assert_eq!(err.class(), crate::ErrorClass::IntegrityMismatch);
204        fs::remove_dir_all(&root).ok();
205    }
206
207    #[test]
208    fn poisoned_sidecar_fail_closed_and_missing_sidecar_is_a_miss() {
209        let root = temp_root("sidecar");
210        let mut cache = DerivedCache::open(&root).unwrap();
211        let id = NodeId::from_bytes([9u8; 32]);
212        cache.put(&id, b"payload").unwrap();
213        fs::write(cache.digest_path(&id), [0u8; 32]).unwrap();
214        assert_eq!(
215            cache.get(&id).unwrap_err().class(),
216            crate::ErrorClass::IntegrityMismatch
217        );
218        // Dropping the sidecar makes the entry disposable, not fatal.
219        fs::remove_file(cache.digest_path(&id)).unwrap();
220        assert_eq!(cache.get(&id).unwrap(), None);
221        fs::remove_dir_all(&root).ok();
222    }
223}