1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use katra_core::{KatraError, Result, fnv1a_parts};
7use serde::{Deserialize, Serialize};
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
11pub struct CacheKey {
12 pub layer: u8,
14 pub content_hash: u64,
16 pub meta_hash: u64,
18}
19
20impl CacheKey {
21 pub fn new(layer: u8, content_hash: u64, meta_hash: u64) -> Self {
23 CacheKey { layer, content_hash, meta_hash }
24 }
25
26 pub fn file_name(&self) -> String {
28 format!("{:02x}_{:016x}_{:016x}.bin", self.layer, self.content_hash, self.meta_hash)
29 }
30}
31
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct CacheEntry {
35 pub key: CacheKey,
37 pub size: u64,
39 pub created_wall_ns: u64,
41 pub hits: u64,
43 pub path: PathBuf,
45}
46
47#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
49pub struct CacheMetrics {
50 pub lookups: u64,
52 pub hits: u64,
54 pub inserts: u64,
56 pub evictions: u64,
58 pub invalidations: u64,
60 pub bytes_stored: u64,
62}
63
64pub struct CacheStore {
66 root: PathBuf,
67 max_bytes: u64,
68 index: HashMap<CacheKey, CacheEntry>,
69 lru: Vec<CacheKey>,
70 metrics: CacheMetrics,
71}
72
73impl CacheStore {
74 pub fn open(root: &Path, max_bytes: u64) -> Result<Self> {
77 std::fs::create_dir_all(root).map_err(KatraError::from)?;
78 let index_path = root.join("index.bin");
79 let index: HashMap<CacheKey, CacheEntry> = match std::fs::read(&index_path) {
80 Ok(bytes) => bincode::deserialize(&bytes).unwrap_or_default(),
81 Err(_) => HashMap::new(),
82 };
83 let mut store = CacheStore {
84 root: root.to_path_buf(),
85 max_bytes,
86 index,
87 lru: Vec::new(),
88 metrics: CacheMetrics::default(),
89 };
90 let mut entries: Vec<&CacheEntry> = store.index.values().collect();
92 entries.sort_by_key(|e| e.created_wall_ns);
93 store.lru = entries.iter().map(|e| e.key).collect();
94 store.metrics.bytes_stored = entries.iter().map(|e| e.size).sum();
95 Ok(store)
96 }
97
98 pub fn lookup(&mut self, key: &CacheKey) -> Option<Vec<u8>> {
100 self.metrics.lookups += 1;
101 let entry = self.index.get(key)?.clone();
102 let data = std::fs::read(&entry.path).ok()?;
103 self.metrics.hits += 1;
104 self.index.get_mut(key).expect("entry present").hits += 1;
105 self.lru.retain(|k| k != key);
107 self.lru.push(*key);
108 Some(data)
109 }
110
111 pub fn contains(&self, key: &CacheKey) -> bool {
113 self.index.contains_key(key)
114 }
115
116 pub fn insert(&mut self, key: &CacheKey, data: &[u8]) -> Result<()> {
118 if let Some(old) = self.index.get(key) {
119 let _ = std::fs::remove_file(&old.path);
121 self.metrics.bytes_stored = self.metrics.bytes_stored.saturating_sub(old.size);
122 self.lru.retain(|k| k != key);
123 }
124 let layer_dir = self.root.join(format!("L{}", key.layer));
125 std::fs::create_dir_all(&layer_dir).map_err(KatraError::from)?;
126 let final_path = layer_dir.join(key.file_name());
127 let tmp_path = layer_dir.join(format!("{}.tmp", key.file_name()));
128 std::fs::write(&tmp_path, data).map_err(KatraError::from)?;
129 std::fs::rename(&tmp_path, &final_path).map_err(KatraError::from)?;
130
131 let entry = CacheEntry {
132 key: *key,
133 size: data.len() as u64,
134 created_wall_ns: katra_core::wall_now_ns(),
135 hits: 0,
136 path: final_path,
137 };
138 self.index.insert(*key, entry.clone());
139 self.lru.push(*key);
140 self.metrics.inserts += 1;
141 self.metrics.bytes_stored += entry.size;
142 self.evict_lru();
143 self.flush_index()?;
144 Ok(())
145 }
146
147 pub fn invalidate_from(&mut self, from_layer: u8) -> usize {
150 let doomed: Vec<CacheKey> =
151 self.index.keys().filter(|k| k.layer >= from_layer).copied().collect();
152 let mut n = 0;
153 for key in doomed {
154 if let Some(e) = self.index.remove(&key) {
155 let _ = std::fs::remove_file(&e.path);
156 self.metrics.bytes_stored = self.metrics.bytes_stored.saturating_sub(e.size);
157 self.metrics.invalidations += 1;
158 n += 1;
159 }
160 self.lru.retain(|k| *k != key);
161 }
162 let _ = self.flush_index();
163 n
164 }
165
166 fn evict_lru(&mut self) -> usize {
168 let mut evicted = 0;
169 while self.metrics.bytes_stored > self.max_bytes && !self.lru.is_empty() {
170 let key = self.lru.remove(0);
172 if let Some(e) = self.index.remove(&key) {
173 let _ = std::fs::remove_file(&e.path);
174 self.metrics.bytes_stored = self.metrics.bytes_stored.saturating_sub(e.size);
175 self.metrics.evictions += 1;
176 evicted += 1;
177 }
178 }
179 evicted
180 }
181
182 pub fn flush_index(&mut self) -> Result<()> {
184 let bytes = bincode::serialize(&self.index)
185 .map_err(|e| KatraError::Protocol(format!("cache index: {e}")))?;
186 let final_path = self.root.join("index.bin");
187 let tmp_path = self.root.join("index.bin.tmp");
188 std::fs::write(&tmp_path, &bytes).map_err(KatraError::from)?;
189 std::fs::rename(&tmp_path, &final_path).map_err(KatraError::from)?;
190 Ok(())
191 }
192
193 pub fn metrics(&self) -> &CacheMetrics {
195 &self.metrics
196 }
197
198 pub fn bytes_stored(&self) -> u64 {
200 self.metrics.bytes_stored
201 }
202
203 pub fn entry_count(&self) -> usize {
205 self.index.len()
206 }
207
208 pub fn index_fingerprint(&self) -> u64 {
210 let mut keys: Vec<u64> = self
211 .index
212 .keys()
213 .map(|k| fnv1a_parts(&[k.layer as u64, k.content_hash, k.meta_hash]))
214 .collect();
215 keys.sort();
216 fnv1a_parts(&keys)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn temp_cache(name: &str) -> PathBuf {
225 let p = std::env::temp_dir().join(format!("katra-cache-{name}-{}", std::process::id()));
226 let _ = std::fs::remove_dir_all(&p);
227 p
228 }
229
230 #[test]
231 fn roundtrip_persists() {
232 let root = temp_cache("roundtrip");
233 let k = CacheKey::new(3, 0xabc, 0xdef);
234 {
235 let mut c = CacheStore::open(&root, 1 << 20).unwrap();
236 c.insert(&k, b"spirv-bytes").unwrap();
237 assert_eq!(c.lookup(&k).unwrap(), b"spirv-bytes");
238 }
239 let mut c = CacheStore::open(&root, 1 << 20).unwrap();
241 assert!(c.contains(&k));
242 assert_eq!(c.lookup(&k).unwrap(), b"spirv-bytes");
243 let _ = std::fs::remove_dir_all(&root);
244 }
245
246 #[test]
247 fn invalidate_cascade_keeps_upper_layers() {
248 let root = temp_cache("cascade");
249 let mut c = CacheStore::open(&root, 1 << 20).unwrap();
250 for layer in 1..=4u8 {
251 c.insert(&CacheKey::new(layer, layer as u64, 0), b"x").unwrap();
252 }
253 let n = c.invalidate_from(4);
254 assert_eq!(n, 1); assert!(c.contains(&CacheKey::new(1, 1, 0)));
256 assert!(c.contains(&CacheKey::new(2, 2, 0)));
257 assert!(c.contains(&CacheKey::new(3, 3, 0)));
258 assert!(!c.contains(&CacheKey::new(4, 4, 0)));
259 let _ = std::fs::remove_dir_all(&root);
260 }
261
262 #[test]
263 fn lru_eviction_respects_budget() {
264 let root = temp_cache("lru");
265 let mut c = CacheStore::open(&root, 32).unwrap(); for i in 0..10u64 {
267 c.insert(&CacheKey::new(1, i, 0), b"12345678901234567890").unwrap(); }
269 assert!(c.bytes_stored() <= 32);
270 assert!(c.entry_count() <= 2);
271 let _ = std::fs::remove_dir_all(&root);
272 }
273
274 #[test]
275 fn corrupt_index_tolerated() {
276 let root = temp_cache("corrupt");
277 let index_path = root.join("index.bin");
278 std::fs::create_dir_all(&root).unwrap();
279 std::fs::write(&index_path, b"garbage").unwrap();
280 let c = CacheStore::open(&root, 1 << 20).unwrap();
281 assert_eq!(c.entry_count(), 0);
282 let _ = std::fs::remove_dir_all(&root);
283 }
284}