1use crate::{NodeId, Region, TileBuf};
21use std::collections::{BTreeMap, HashMap};
22use std::sync::Mutex;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub struct TileKey {
27 pub node: NodeId,
29 pub region: Region,
31}
32
33impl TileKey {
34 #[must_use]
36 pub const fn new(node: NodeId, region: Region) -> Self {
37 Self { node, region }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46#[non_exhaustive]
47pub struct CacheStats {
48 pub hits: u64,
50 pub misses: u64,
52 pub evictions: u64,
54 pub insertions: u64,
56 pub rejections: u64,
58}
59
60impl CacheStats {
61 #[must_use]
63 pub fn hit_rate(&self) -> Option<f64> {
64 let total = self.hits + self.misses;
65 (total > 0).then(|| self.hits as f64 / total as f64)
66 }
67}
68
69#[derive(Debug)]
71struct Entry {
72 tile: std::sync::Arc<TileBuf>,
73 bytes: usize,
74 tick: u64,
75}
76
77#[derive(Debug)]
79struct Inner {
80 entries: HashMap<TileKey, Entry>,
81 recency: BTreeMap<u64, TileKey>,
84 bytes: usize,
85 next_tick: u64,
86 stats: CacheStats,
87}
88
89#[derive(Debug)]
93pub struct TileCache {
94 inner: Mutex<Inner>,
95 budget: usize,
96}
97
98impl TileCache {
99 pub const DEFAULT_BUDGET: usize = 64 * 1024 * 1024;
101
102 #[must_use]
108 pub fn new(budget: usize) -> Self {
109 Self {
110 inner: Mutex::new(Inner {
111 entries: HashMap::new(),
112 recency: BTreeMap::new(),
113 bytes: 0,
114 next_tick: 0,
115 stats: CacheStats::default(),
116 }),
117 budget,
118 }
119 }
120
121 #[must_use]
123 pub const fn budget(&self) -> usize {
124 self.budget
125 }
126
127 #[must_use]
133 pub fn get(&self, key: &TileKey) -> Option<std::sync::Arc<TileBuf>> {
134 let mut inner = self.lock();
135 let tick = inner.next_tick;
136 let Some(entry) = inner.entries.get_mut(key) else {
137 inner.stats.misses += 1;
138 return None;
139 };
140 let previous = entry.tick;
141 entry.tick = tick;
142 let tile = std::sync::Arc::clone(&entry.tile);
143 inner.next_tick += 1;
144 inner.recency.remove(&previous);
145 inner.recency.insert(tick, *key);
146 inner.stats.hits += 1;
147 Some(tile)
148 }
149
150 pub fn insert(&self, key: TileKey, tile: std::sync::Arc<TileBuf>) -> std::sync::Arc<TileBuf> {
156 let bytes = tile.bytes().len();
157 let mut inner = self.lock();
158 inner.stats.insertions += 1;
159
160 if bytes > self.budget {
161 inner.stats.rejections += 1;
162 return tile;
163 }
164 if let Some(previous) = inner.entries.remove(&key) {
166 inner.bytes -= previous.bytes;
167 inner.recency.remove(&previous.tick);
168 }
169 let tick = inner.next_tick;
170 inner.next_tick += 1;
171 inner.bytes += bytes;
172 inner.entries.insert(
173 key,
174 Entry {
175 tile: std::sync::Arc::clone(&tile),
176 bytes,
177 tick,
178 },
179 );
180 inner.recency.insert(tick, key);
181 inner.evict_to_fit(self.budget);
182 tile
183 }
184
185 #[must_use]
187 pub fn bytes_used(&self) -> usize {
188 self.lock().bytes
189 }
190
191 #[must_use]
193 pub fn len(&self) -> usize {
194 self.lock().entries.len()
195 }
196
197 #[must_use]
199 pub fn is_empty(&self) -> bool {
200 self.len() == 0
201 }
202
203 #[must_use]
205 pub fn stats(&self) -> CacheStats {
206 self.lock().stats
207 }
208
209 pub fn clear(&self) {
211 let mut inner = self.lock();
212 inner.entries.clear();
213 inner.recency.clear();
214 inner.bytes = 0;
215 }
216
217 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
225 self.inner
226 .lock()
227 .unwrap_or_else(std::sync::PoisonError::into_inner)
228 }
229}
230
231impl Default for TileCache {
232 fn default() -> Self {
233 Self::new(Self::DEFAULT_BUDGET)
234 }
235}
236
237impl Inner {
238 fn evict_to_fit(&mut self, budget: usize) {
240 while self.bytes > budget {
241 let Some((&tick, &key)) = self.recency.iter().next() else {
243 break;
245 };
246 self.recency.remove(&tick);
247 if let Some(entry) = self.entries.remove(&key) {
248 self.bytes -= entry.bytes;
249 self.stats.evictions += 1;
250 }
251 }
252 }
253}
254
255#[cfg(test)]
256#[allow(
257 clippy::unwrap_used,
258 clippy::indexing_slicing,
259 clippy::panic,
260 reason = "tests operate on known-good values and assert shapes directly"
261)]
262mod tests {
263 use super::*;
264 use crate::PixelFormat;
265 use std::sync::Arc;
266
267 fn tile(size: u32) -> Arc<TileBuf> {
269 Arc::new(TileBuf::zeroed(Region::from_size(size, 1), PixelFormat::Gray8).unwrap())
270 }
271
272 fn key(region: Region) -> TileKey {
275 TileKey::new(fresh_node_id(), region)
276 }
277
278 fn fresh_node_id() -> NodeId {
280 use crate::testing::CountingProducer;
281 use crate::{Format, Image, ImageDescriptor};
282 let descriptor = ImageDescriptor::new(1, 1, PixelFormat::Gray8).unwrap();
283 let image = Image::from_producer(Arc::new(CountingProducer::new(descriptor)), Format::Raw);
284 image.node().id()
285 }
286
287 #[test]
288 fn a_tile_round_trips_through_the_cache() {
289 let cache = TileCache::new(1024);
290 let k = key(Region::from_size(4, 1));
291 assert!(cache.get(&k).is_none(), "empty cache misses");
292 cache.insert(k, tile(4));
293 let found = cache.get(&k).unwrap();
294 assert_eq!(found.bytes().len(), 4);
295 assert_eq!(cache.len(), 1);
296 assert_eq!(cache.bytes_used(), 4);
297 }
298
299 #[test]
300 fn the_budget_is_never_exceeded_by_retained_bytes() {
301 let cache = TileCache::new(100);
302 for _ in 0..50 {
303 cache.insert(key(Region::from_size(10, 1)), tile(10));
304 assert!(
305 cache.bytes_used() <= 100,
306 "retained {} bytes over a 100 byte budget",
307 cache.bytes_used()
308 );
309 }
310 assert!(cache.stats().evictions > 0, "nothing was ever evicted");
311 }
312
313 #[test]
314 fn eviction_removes_the_least_recently_used_entry() {
315 let cache = TileCache::new(30);
316 let (a, b, c) = (
317 key(Region::from_size(1, 1)),
318 key(Region::from_size(2, 1)),
319 key(Region::from_size(3, 1)),
320 );
321 cache.insert(a, tile(10));
322 cache.insert(b, tile(10));
323 assert!(cache.get(&a).is_some());
325 cache.insert(c, tile(10));
326 assert_eq!(cache.len(), 3);
328
329 let d = key(Region::from_size(4, 1));
331 cache.insert(d, tile(10));
332 assert!(cache.get(&b).is_none(), "LRU entry survived");
333 assert!(cache.get(&a).is_some(), "recently used entry was evicted");
334 assert!(cache.get(&c).is_some());
335 assert!(cache.get(&d).is_some());
336 }
337
338 #[test]
339 fn an_evicted_tile_stays_valid_for_whoever_holds_it() {
340 let cache = TileCache::new(10);
343 let k = key(Region::from_size(10, 1));
344 let held = cache.insert(k, tile(10));
345 cache.insert(key(Region::from_size(9, 1)), tile(10));
347 assert!(cache.get(&k).is_none(), "expected eviction");
348 assert_eq!(held.bytes().len(), 10);
350 assert!(held.as_tile().is_ok());
351 }
352
353 #[test]
354 fn a_tile_larger_than_the_budget_is_returned_but_not_retained() {
355 let cache = TileCache::new(10);
356 let k = key(Region::from_size(50, 1));
357 let returned = cache.insert(k, tile(50));
358 assert_eq!(returned.bytes().len(), 50, "the tile is still usable");
359 assert!(cache.get(&k).is_none());
360 assert_eq!(cache.len(), 0);
361 assert_eq!(cache.stats().rejections, 1);
362 assert_eq!(cache.stats().evictions, 0);
364 }
365
366 #[test]
367 fn a_zero_budget_disables_retention() {
368 let cache = TileCache::new(0);
369 let k = key(Region::from_size(4, 1));
370 let returned = cache.insert(k, tile(4));
371 assert_eq!(returned.bytes().len(), 4, "the tile is still returned");
372 assert!(cache.get(&k).is_none());
373 assert_eq!(cache.bytes_used(), 0);
374 }
375
376 #[test]
377 fn reinserting_a_key_replaces_it_without_double_counting() {
378 let cache = TileCache::new(1000);
379 let k = key(Region::from_size(4, 1));
380 cache.insert(k, tile(10));
381 cache.insert(k, tile(20));
382 assert_eq!(cache.len(), 1);
383 assert_eq!(cache.bytes_used(), 20, "old bytes were not released");
384 assert_eq!(cache.get(&k).unwrap().bytes().len(), 20);
385 }
386
387 #[test]
388 fn keys_distinguish_node_and_region() {
389 let cache = TileCache::new(1000);
390 let node = fresh_node_id();
391 let a = TileKey::new(node, Region::from_size(4, 1));
392 let b = TileKey::new(node, Region::new(4, 0, 4, 1));
393 cache.insert(a, tile(4));
394 assert!(
395 cache.get(&b).is_none(),
396 "different regions must not collide"
397 );
398
399 let other = TileKey::new(fresh_node_id(), Region::from_size(4, 1));
400 assert!(
401 cache.get(&other).is_none(),
402 "different nodes must not collide"
403 );
404 }
405
406 #[test]
407 fn statistics_track_lookups_and_evictions() {
408 let cache = TileCache::new(10);
409 let k = key(Region::from_size(4, 1));
410 assert!(cache.get(&k).is_none());
411 cache.insert(k, tile(4));
412 assert!(cache.get(&k).is_some());
413 let stats = cache.stats();
414 assert_eq!(stats.hits, 1);
415 assert_eq!(stats.misses, 1);
416 assert_eq!(stats.insertions, 1);
417 assert_eq!(stats.hit_rate(), Some(0.5));
418 assert_eq!(TileCache::new(1).stats().hit_rate(), None, "no lookups yet");
419 }
420
421 #[test]
422 fn clear_drops_entries_but_keeps_statistics() {
423 let cache = TileCache::new(1000);
424 cache.insert(key(Region::from_size(4, 1)), tile(4));
425 assert!(!cache.is_empty());
426 cache.clear();
427 assert!(cache.is_empty());
428 assert_eq!(cache.bytes_used(), 0);
429 assert_eq!(cache.stats().insertions, 1, "statistics are cumulative");
430 }
431
432 #[test]
433 fn the_cache_is_usable_from_many_threads() {
434 let cache = Arc::new(TileCache::new(4096));
435 let keys: Vec<TileKey> = (0..8).map(|i| key(Region::from_size(i + 1, 1))).collect();
436 std::thread::scope(|scope| {
437 for _ in 0..8 {
438 let cache = Arc::clone(&cache);
439 let keys = keys.clone();
440 scope.spawn(move || {
441 for _ in 0..200 {
442 for (i, k) in keys.iter().enumerate() {
443 cache.insert(*k, tile(i as u32 + 1));
444 let _ = cache.get(k);
445 }
446 }
447 });
448 }
449 });
450 let inner = cache.lock();
453 let actual: usize = inner.entries.values().map(|e| e.bytes).sum();
454 assert_eq!(inner.bytes, actual, "byte accounting drifted");
455 assert_eq!(
456 inner.recency.len(),
457 inner.entries.len(),
458 "recency index drifted"
459 );
460 assert!(inner.bytes <= 4096);
461 }
462
463 #[test]
464 fn a_poisoned_lock_does_not_disable_the_cache() {
465 let cache = Arc::new(TileCache::new(1000));
467 let k = key(Region::from_size(4, 1));
468 cache.insert(k, tile(4));
469 let poisoner = Arc::clone(&cache);
470 let handle = std::thread::spawn(move || {
471 let _guard = poisoner.lock();
472 panic!("poison the mutex");
473 });
474 assert!(handle.join().is_err(), "the thread was supposed to panic");
475 assert!(cache.get(&k).is_some());
477 cache.insert(key(Region::from_size(8, 1)), tile(8));
478 }
479}