vole_document/field/
cache.rs1use 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
38pub 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 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 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 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 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 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 pub fn contains(&self, id: &NodeId) -> Result<bool> {
109 Ok(self.bytes_path(id).exists())
110 }
111
112 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 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 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 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 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}