use std::collections::HashMap;
use std::path::{Path, PathBuf};
use katra_core::{KatraError, Result, fnv1a_parts};
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct CacheKey {
pub layer: u8,
pub content_hash: u64,
pub meta_hash: u64,
}
impl CacheKey {
pub fn new(layer: u8, content_hash: u64, meta_hash: u64) -> Self {
CacheKey { layer, content_hash, meta_hash }
}
pub fn file_name(&self) -> String {
format!("{:02x}_{:016x}_{:016x}.bin", self.layer, self.content_hash, self.meta_hash)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CacheEntry {
pub key: CacheKey,
pub size: u64,
pub created_wall_ns: u64,
pub hits: u64,
pub path: PathBuf,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CacheMetrics {
pub lookups: u64,
pub hits: u64,
pub inserts: u64,
pub evictions: u64,
pub invalidations: u64,
pub bytes_stored: u64,
}
pub struct CacheStore {
root: PathBuf,
max_bytes: u64,
index: HashMap<CacheKey, CacheEntry>,
lru: Vec<CacheKey>,
metrics: CacheMetrics,
}
impl CacheStore {
pub fn open(root: &Path, max_bytes: u64) -> Result<Self> {
std::fs::create_dir_all(root).map_err(KatraError::from)?;
let index_path = root.join("index.bin");
let index: HashMap<CacheKey, CacheEntry> = match std::fs::read(&index_path) {
Ok(bytes) => bincode::deserialize(&bytes).unwrap_or_default(),
Err(_) => HashMap::new(),
};
let mut store = CacheStore {
root: root.to_path_buf(),
max_bytes,
index,
lru: Vec::new(),
metrics: CacheMetrics::default(),
};
let mut entries: Vec<&CacheEntry> = store.index.values().collect();
entries.sort_by_key(|e| e.created_wall_ns);
store.lru = entries.iter().map(|e| e.key).collect();
store.metrics.bytes_stored = entries.iter().map(|e| e.size).sum();
Ok(store)
}
pub fn lookup(&mut self, key: &CacheKey) -> Option<Vec<u8>> {
self.metrics.lookups += 1;
let entry = self.index.get(key)?.clone();
let data = std::fs::read(&entry.path).ok()?;
self.metrics.hits += 1;
self.index.get_mut(key).expect("entry present").hits += 1;
self.lru.retain(|k| k != key);
self.lru.push(*key);
Some(data)
}
pub fn contains(&self, key: &CacheKey) -> bool {
self.index.contains_key(key)
}
pub fn insert(&mut self, key: &CacheKey, data: &[u8]) -> Result<()> {
if let Some(old) = self.index.get(key) {
let _ = std::fs::remove_file(&old.path);
self.metrics.bytes_stored = self.metrics.bytes_stored.saturating_sub(old.size);
self.lru.retain(|k| k != key);
}
let layer_dir = self.root.join(format!("L{}", key.layer));
std::fs::create_dir_all(&layer_dir).map_err(KatraError::from)?;
let final_path = layer_dir.join(key.file_name());
let tmp_path = layer_dir.join(format!("{}.tmp", key.file_name()));
std::fs::write(&tmp_path, data).map_err(KatraError::from)?;
std::fs::rename(&tmp_path, &final_path).map_err(KatraError::from)?;
let entry = CacheEntry {
key: *key,
size: data.len() as u64,
created_wall_ns: katra_core::wall_now_ns(),
hits: 0,
path: final_path,
};
self.index.insert(*key, entry.clone());
self.lru.push(*key);
self.metrics.inserts += 1;
self.metrics.bytes_stored += entry.size;
self.evict_lru();
self.flush_index()?;
Ok(())
}
pub fn invalidate_from(&mut self, from_layer: u8) -> usize {
let doomed: Vec<CacheKey> =
self.index.keys().filter(|k| k.layer >= from_layer).copied().collect();
let mut n = 0;
for key in doomed {
if let Some(e) = self.index.remove(&key) {
let _ = std::fs::remove_file(&e.path);
self.metrics.bytes_stored = self.metrics.bytes_stored.saturating_sub(e.size);
self.metrics.invalidations += 1;
n += 1;
}
self.lru.retain(|k| *k != key);
}
let _ = self.flush_index();
n
}
fn evict_lru(&mut self) -> usize {
let mut evicted = 0;
while self.metrics.bytes_stored > self.max_bytes && !self.lru.is_empty() {
let key = self.lru.remove(0);
if let Some(e) = self.index.remove(&key) {
let _ = std::fs::remove_file(&e.path);
self.metrics.bytes_stored = self.metrics.bytes_stored.saturating_sub(e.size);
self.metrics.evictions += 1;
evicted += 1;
}
}
evicted
}
pub fn flush_index(&mut self) -> Result<()> {
let bytes = bincode::serialize(&self.index)
.map_err(|e| KatraError::Protocol(format!("cache index: {e}")))?;
let final_path = self.root.join("index.bin");
let tmp_path = self.root.join("index.bin.tmp");
std::fs::write(&tmp_path, &bytes).map_err(KatraError::from)?;
std::fs::rename(&tmp_path, &final_path).map_err(KatraError::from)?;
Ok(())
}
pub fn metrics(&self) -> &CacheMetrics {
&self.metrics
}
pub fn bytes_stored(&self) -> u64 {
self.metrics.bytes_stored
}
pub fn entry_count(&self) -> usize {
self.index.len()
}
pub fn index_fingerprint(&self) -> u64 {
let mut keys: Vec<u64> = self
.index
.keys()
.map(|k| fnv1a_parts(&[k.layer as u64, k.content_hash, k.meta_hash]))
.collect();
keys.sort();
fnv1a_parts(&keys)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_cache(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("katra-cache-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
p
}
#[test]
fn roundtrip_persists() {
let root = temp_cache("roundtrip");
let k = CacheKey::new(3, 0xabc, 0xdef);
{
let mut c = CacheStore::open(&root, 1 << 20).unwrap();
c.insert(&k, b"spirv-bytes").unwrap();
assert_eq!(c.lookup(&k).unwrap(), b"spirv-bytes");
}
let mut c = CacheStore::open(&root, 1 << 20).unwrap();
assert!(c.contains(&k));
assert_eq!(c.lookup(&k).unwrap(), b"spirv-bytes");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn invalidate_cascade_keeps_upper_layers() {
let root = temp_cache("cascade");
let mut c = CacheStore::open(&root, 1 << 20).unwrap();
for layer in 1..=4u8 {
c.insert(&CacheKey::new(layer, layer as u64, 0), b"x").unwrap();
}
let n = c.invalidate_from(4);
assert_eq!(n, 1); assert!(c.contains(&CacheKey::new(1, 1, 0)));
assert!(c.contains(&CacheKey::new(2, 2, 0)));
assert!(c.contains(&CacheKey::new(3, 3, 0)));
assert!(!c.contains(&CacheKey::new(4, 4, 0)));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn lru_eviction_respects_budget() {
let root = temp_cache("lru");
let mut c = CacheStore::open(&root, 32).unwrap(); for i in 0..10u64 {
c.insert(&CacheKey::new(1, i, 0), b"12345678901234567890").unwrap(); }
assert!(c.bytes_stored() <= 32);
assert!(c.entry_count() <= 2);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn corrupt_index_tolerated() {
let root = temp_cache("corrupt");
let index_path = root.join("index.bin");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(&index_path, b"garbage").unwrap();
let c = CacheStore::open(&root, 1 << 20).unwrap();
assert_eq!(c.entry_count(), 0);
let _ = std::fs::remove_dir_all(&root);
}
}