use crate::MapError;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::{Arc, Condvar, Mutex};
pub type Key = (u32, u32);
enum Slot {
Loading,
Ready {
data: Arc<[f32]>,
bytes: u64,
last: u64,
},
}
#[derive(Default, Clone, Serialize, Debug, PartialEq)]
pub struct CacheStats {
pub budget_bytes: u64,
pub tile_reads: u64,
pub hits: u64,
pub single_flight_waits: u64,
pub evictions: u64,
pub peak_bytes: u64,
pub resident_bytes: u64,
pub decoded_bytes_total: u64,
pub distinct_tiles_loaded: u64,
pub reloads_after_eviction: u64,
pub load_errors: u64,
pub loader_panics: u64,
pub peak_concurrent_loads: u64,
}
struct State {
map: HashMap<Key, Slot>,
bytes: u64,
tick: u64,
seen: HashSet<Key>,
loading: u64,
stats: CacheStats,
}
struct LoadGuard<'a> {
cache: &'a TileCache,
key: Key,
bytes: u64,
armed: bool,
}
impl Drop for LoadGuard<'_> {
fn drop(&mut self) {
if !self.armed {
return;
}
let mut st = self.cache.lock();
if matches!(st.map.get(&self.key), Some(Slot::Loading)) {
st.map.remove(&self.key);
st.bytes -= self.bytes;
}
st.loading -= 1;
st.stats.load_errors += 1;
drop(st);
self.cache.cv.notify_all();
}
}
fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".into())
}
pub struct TileCache {
state: Mutex<State>,
cv: Condvar,
budget: u64,
}
impl TileCache {
pub fn new(budget: u64) -> Self {
Self {
state: Mutex::new(State {
map: HashMap::new(),
bytes: 0,
tick: 0,
seen: HashSet::new(),
loading: 0,
stats: CacheStats {
budget_bytes: budget,
..Default::default()
},
}),
cv: Condvar::new(),
budget,
}
}
pub fn budget(&self) -> u64 {
self.budget
}
fn lock(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn get_or_load(
&self,
key: Key,
bytes: u64,
load: impl FnOnce() -> Result<Vec<f32>, MapError>,
) -> Result<Arc<[f32]>, MapError> {
if bytes > self.budget {
return Err(MapError::Budget(format!(
"one tile of {bytes} bytes exceeds the cache budget {}",
self.budget
)));
}
let mut st = self.lock();
let mut waited = false;
loop {
st.tick += 1;
let tick = st.tick;
match st.map.get_mut(&key) {
Some(Slot::Ready { data, last, .. }) => {
*last = tick;
let data = data.clone();
st.stats.hits += 1;
return Ok(data);
}
Some(Slot::Loading) => {
if !waited {
st.stats.single_flight_waits += 1;
waited = true;
}
st = self.cv.wait(st).unwrap_or_else(|p| p.into_inner());
}
None => break,
}
}
self.make_room(&mut st, bytes)?;
st.map.insert(key, Slot::Loading);
st.bytes += bytes;
st.stats.peak_bytes = st.stats.peak_bytes.max(st.bytes);
st.loading += 1;
st.stats.peak_concurrent_loads = st.stats.peak_concurrent_loads.max(st.loading);
drop(st);
let mut guard = LoadGuard {
cache: self,
key,
bytes,
armed: true,
};
let result = match catch_unwind(AssertUnwindSafe(load)) {
Ok(r) => r,
Err(p) => {
self.lock().stats.loader_panics += 1;
Err(MapError::Terrain(format!(
"tile {key:?} loader panicked: {}",
panic_message(p.as_ref())
)))
}
};
let data = result.and_then(|v| {
if (v.len() as u64).checked_mul(4) != Some(bytes) {
Err(MapError::Terrain(format!(
"decoded tile has {} values, expected {} bytes",
v.len(),
bytes
)))
} else {
Ok(Arc::<[f32]>::from(v))
}
})?;
let mut st = self.lock();
guard.armed = false;
st.loading -= 1;
st.tick += 1;
let last = st.tick;
st.map.insert(
key,
Slot::Ready {
data: data.clone(),
bytes,
last,
},
);
st.stats.tile_reads += 1;
st.stats.decoded_bytes_total += bytes;
if st.seen.insert(key) {
st.stats.distinct_tiles_loaded += 1;
}
drop(st);
self.cv.notify_all();
Ok(data)
}
fn make_room(&self, st: &mut State, need: u64) -> Result<(), MapError> {
while st.bytes + need > self.budget {
let victim = st
.map
.iter()
.filter_map(|(k, s)| match s {
Slot::Ready { data, last, .. } if Arc::strong_count(data) == 1 => {
Some((*last, *k))
}
_ => None,
})
.min();
let Some((_, k)) = victim else {
return Err(MapError::Budget(format!(
"terrain cache budget {} exhausted by pinned/loading tiles ({} resident, {} needed)",
self.budget, st.bytes, need
)));
};
if let Some(Slot::Ready { bytes, .. }) = st.map.remove(&k) {
st.bytes -= bytes;
st.stats.evictions += 1;
}
}
Ok(())
}
pub fn stats(&self) -> CacheStats {
let st = self.lock();
let mut s = st.stats.clone();
s.resident_bytes = st.bytes;
s.reloads_after_eviction = s.tile_reads - s.distinct_tiles_loaded;
s
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn hits_evictions_and_budget() {
let c = TileCache::new(32);
let a = c.get_or_load((0, 0), 16, || Ok(vec![1.0; 4])).unwrap();
drop(a);
let _ = c.get_or_load((0, 0), 16, || panic!("must hit")).unwrap();
c.get_or_load((0, 1), 16, || Ok(vec![2.0; 4])).unwrap();
c.get_or_load((0, 2), 16, || Ok(vec![3.0; 4])).unwrap();
let s = c.stats();
assert_eq!((s.tile_reads, s.hits, s.evictions), (3, 1, 1));
assert!(s.peak_bytes <= 32);
c.get_or_load((0, 0), 16, || Ok(vec![1.0; 4])).unwrap();
assert_eq!(c.stats().reloads_after_eviction, 1);
}
#[test]
fn pinned_tiles_are_not_evicted() {
let c = TileCache::new(32);
let _a = c.get_or_load((0, 0), 16, || Ok(vec![1.0; 4])).unwrap();
let _b = c.get_or_load((0, 1), 16, || Ok(vec![1.0; 4])).unwrap();
let e = c.get_or_load((0, 2), 16, || Ok(vec![1.0; 4])).unwrap_err();
assert_eq!(e.kind(), "budget");
assert!(c.stats().peak_bytes <= 32);
}
#[test]
fn single_flight_and_errors() {
let c = Arc::new(TileCache::new(1 << 20));
let loads = Arc::new(AtomicUsize::new(0));
std::thread::scope(|s| {
for _ in 0..8 {
let (c, loads) = (c.clone(), loads.clone());
s.spawn(move || {
c.get_or_load((5, 5), 4, || {
loads.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
Ok(vec![7.0])
})
.unwrap();
});
}
});
assert_eq!(loads.load(Ordering::SeqCst), 1);
assert!(c.get_or_load((1, 1), 8, || Ok(vec![1.0])).is_err());
assert_eq!(c.stats().resident_bytes, 4);
}
#[test]
fn loader_panic_releases_slot_and_wakes_waiters() {
use std::sync::mpsc;
use std::time::Duration;
let c = Arc::new(TileCache::new(1 << 20));
let (started_tx, started_rx) = mpsc::channel();
let (loader_tx, loader_rx) = mpsc::channel();
let c1 = c.clone();
std::thread::spawn(move || {
let r = c1.get_or_load((3, 4), 1024, || {
started_tx.send(()).unwrap();
std::thread::sleep(Duration::from_millis(100));
panic!("simulated decoder panic");
});
let _ = loader_tx.send(r);
});
started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
let (waiter_tx, waiter_rx) = mpsc::channel();
let c2 = c.clone();
std::thread::spawn(move || {
let _ = waiter_tx.send(c2.get_or_load((3, 4), 1024, || Ok(vec![1.0f32; 256])));
});
let loader = loader_rx
.recv_timeout(Duration::from_secs(5))
.expect("loader returned");
let e = loader.expect_err("panic must become an error");
assert_eq!(e.kind(), "terrain");
assert!(e.to_string().contains("simulated decoder panic"), "{e}");
let waiter = waiter_rx
.recv_timeout(Duration::from_secs(5))
.expect("waiter must not hang");
assert_eq!(waiter.unwrap().len(), 256);
let later = c.get_or_load((3, 4), 1024, || panic!("must hit")).unwrap();
assert_eq!(later[0], 1.0);
let s = c.stats();
assert_eq!((s.load_errors, s.loader_panics, s.tile_reads), (1, 1, 1));
assert_eq!(s.resident_bytes, 1024);
assert!(s.peak_bytes <= 2048);
}
#[test]
fn loader_panic_without_waiters_leaves_no_bytes() {
let c = TileCache::new(64);
let e = c
.get_or_load((0, 0), 64, || panic!("boom"))
.expect_err("typed error");
assert_eq!(e.kind(), "terrain");
let s = c.stats();
assert_eq!(
(s.resident_bytes, s.load_errors, s.loader_panics),
(0, 1, 1)
);
c.get_or_load((0, 1), 64, || Ok(vec![0.0; 16])).unwrap();
assert_eq!(c.stats().peak_concurrent_loads, 1);
}
}