Skip to main content

katra_cache/
store.rs

1//! The content-addressed persistent store.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use katra_core::{KatraError, Result, fnv1a_parts};
7use serde::{Deserialize, Serialize};
8
9/// The layer of a cache entry (Katra3D §18).
10#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
11pub struct CacheKey {
12    /// Layer (1 DXIL, 2 IR, 3 SPIR-V, 4 pipeline).
13    pub layer: u8,
14    /// Content hash of the primary input at this layer.
15    pub content_hash: u64,
16    /// Secondary identity (e.g. PSO descriptor hash, target feature set).
17    pub meta_hash: u64,
18}
19
20impl CacheKey {
21    /// Build a key from parts.
22    pub fn new(layer: u8, content_hash: u64, meta_hash: u64) -> Self {
23        CacheKey { layer, content_hash, meta_hash }
24    }
25
26    /// A stable filename fragment for this key.
27    pub fn file_name(&self) -> String {
28        format!("{:02x}_{:016x}_{:016x}.bin", self.layer, self.content_hash, self.meta_hash)
29    }
30}
31
32/// A cached artifact.
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct CacheEntry {
35    /// The key.
36    pub key: CacheKey,
37    /// Artifact size in bytes.
38    pub size: u64,
39    /// Creation wall time (ns).
40    pub created_wall_ns: u64,
41    /// Hit count.
42    pub hits: u64,
43    /// File path of the artifact.
44    pub path: PathBuf,
45}
46
47/// Cache metrics.
48#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
49pub struct CacheMetrics {
50    /// Lookups performed.
51    pub lookups: u64,
52    /// Lookup hits.
53    pub hits: u64,
54    /// Artifacts inserted.
55    pub inserts: u64,
56    /// LRU evictions.
57    pub evictions: u64,
58    /// Entries invalidated.
59    pub invalidations: u64,
60    /// Bytes stored.
61    pub bytes_stored: u64,
62}
63
64/// The persistent layered cache.
65pub 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    /// Open (or create) a cache rooted at `root` with a byte budget.
75    /// A corrupt index is tolerated: it is rebuilt empty.
76    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        // Rebuild the LRU order (oldest first by creation time).
91        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    /// Look up an artifact; loads it from disk and bumps LRU + hits.
99    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        // Move to the back of LRU.
106        self.lru.retain(|k| k != key);
107        self.lru.push(*key);
108        Some(data)
109    }
110
111    /// Whether an artifact is present (without loading).
112    pub fn contains(&self, key: &CacheKey) -> bool {
113        self.index.contains_key(key)
114    }
115
116    /// Insert an artifact (atomic temp + rename).
117    pub fn insert(&mut self, key: &CacheKey, data: &[u8]) -> Result<()> {
118        if let Some(old) = self.index.get(key) {
119            // Replace: drop the old file.
120            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    /// Invalidate entries at `layer >= from_layer` (e.g. driver update →
148    /// `invalidate_from(4)` keeps layers 1–3).
149    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    /// Evict LRU entries until under budget.
167    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            // Evict the least-recently-used entry.
171            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    /// Persist the index.
183    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    /// Metrics snapshot.
194    pub fn metrics(&self) -> &CacheMetrics {
195        &self.metrics
196    }
197
198    /// Current byte usage.
199    pub fn bytes_stored(&self) -> u64 {
200        self.metrics.bytes_stored
201    }
202
203    /// Entry count.
204    pub fn entry_count(&self) -> usize {
205        self.index.len()
206    }
207
208    /// Stable identity hash of the whole index (for audits).
209    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        // Reopen: still there.
240        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); // only layer 4 removed
255        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(); // tiny budget
266        for i in 0..10u64 {
267            c.insert(&CacheKey::new(1, i, 0), b"12345678901234567890").unwrap(); // 20 bytes each
268        }
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}