Skip to main content

ras_commander_fast_map/
cache.rs

1//! Shared, immutable, byte-bounded decoded-tile cache with single-flight loading.
2//!
3//! - Values are `Arc<[f32]>`; readers never mutate them.
4//! - A key is decoded at most once while resident: concurrent requests wait for the loader.
5//! - Eviction is least-recently-used among *unpinned* entries (no outside `Arc` holder), so the
6//!   resident byte total, including in-use and loading tiles, never exceeds the budget. If the
7//!   budget cannot be met because everything is pinned, the request fails with a budget error
8//!   rather than exceeding it. The session validates `threads * max_tiles_per_item * tile_bytes
9//!   <= budget`, which makes that failure unreachable for planned work.
10//! - The budget covers decoded *resident* slots only. Decoder transients of a concurrent miss
11//!   (terrio window buffer, tiff chunk buffer, compressed bytes, chunk offset tables and the
12//!   `Arc<[f32]>` copy) are outside it; `peak_concurrent_loads` lets callers estimate them.
13//! - Unwind safety: a loader that returns an error *or panics* releases its `Loading` slot and
14//!   its bytes, counts a load error and wakes every waiter (drop guard). A panic is caught and
15//!   returned as a typed `MapError::Terrain`; waiters then retry the load themselves.
16use crate::MapError;
17use serde::Serialize;
18use std::collections::{HashMap, HashSet};
19use std::panic::{AssertUnwindSafe, catch_unwind};
20use std::sync::{Arc, Condvar, Mutex};
21
22pub type Key = (u32, u32);
23
24enum Slot {
25    Loading,
26    Ready {
27        data: Arc<[f32]>,
28        bytes: u64,
29        last: u64,
30    },
31}
32
33#[derive(Default, Clone, Serialize, Debug, PartialEq)]
34pub struct CacheStats {
35    pub budget_bytes: u64,
36    /// Successful decodes (cache misses that loaded a tile).
37    pub tile_reads: u64,
38    pub hits: u64,
39    /// Requests that waited for a concurrent loader of the same tile (duplicate decode avoided).
40    pub single_flight_waits: u64,
41    pub evictions: u64,
42    pub peak_bytes: u64,
43    pub resident_bytes: u64,
44    pub decoded_bytes_total: u64,
45    pub distinct_tiles_loaded: u64,
46    /// tile_reads - distinct_tiles_loaded: re-decodes caused by eviction.
47    pub reloads_after_eviction: u64,
48    /// Failed loads, including caught loader panics.
49    pub load_errors: u64,
50    /// Loader panics caught and converted to errors (subset of `load_errors`).
51    pub loader_panics: u64,
52    /// Maximum number of loaders running at once (each holds decoder transients).
53    pub peak_concurrent_loads: u64,
54}
55
56struct State {
57    map: HashMap<Key, Slot>,
58    bytes: u64,
59    tick: u64,
60    seen: HashSet<Key>,
61    loading: u64,
62    stats: CacheStats,
63}
64
65/// Releases a `Loading` slot unless disarmed; runs on error return and on unwind.
66struct LoadGuard<'a> {
67    cache: &'a TileCache,
68    key: Key,
69    bytes: u64,
70    armed: bool,
71}
72
73impl Drop for LoadGuard<'_> {
74    fn drop(&mut self) {
75        if !self.armed {
76            return;
77        }
78        let mut st = self.cache.lock();
79        if matches!(st.map.get(&self.key), Some(Slot::Loading)) {
80            st.map.remove(&self.key);
81            st.bytes -= self.bytes;
82        }
83        st.loading -= 1;
84        st.stats.load_errors += 1;
85        drop(st);
86        self.cache.cv.notify_all();
87    }
88}
89
90fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
91    p.downcast_ref::<&str>()
92        .map(|s| s.to_string())
93        .or_else(|| p.downcast_ref::<String>().cloned())
94        .unwrap_or_else(|| "non-string panic payload".into())
95}
96
97pub struct TileCache {
98    state: Mutex<State>,
99    cv: Condvar,
100    budget: u64,
101}
102
103impl TileCache {
104    pub fn new(budget: u64) -> Self {
105        Self {
106            state: Mutex::new(State {
107                map: HashMap::new(),
108                bytes: 0,
109                tick: 0,
110                seen: HashSet::new(),
111                loading: 0,
112                stats: CacheStats {
113                    budget_bytes: budget,
114                    ..Default::default()
115                },
116            }),
117            cv: Condvar::new(),
118            budget,
119        }
120    }
121
122    pub fn budget(&self) -> u64 {
123        self.budget
124    }
125
126    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
127        // Loaders never run under the lock, and every critical section completes its accounting
128        // before it can panic, so a poisoned lock still holds consistent state. Recover it so
129        // other workers report errors instead of panicking.
130        self.state.lock().unwrap_or_else(|p| p.into_inner())
131    }
132
133    /// Returns the tile, loading it with `load` on a miss. `bytes` must equal 4 * loaded length.
134    pub fn get_or_load(
135        &self,
136        key: Key,
137        bytes: u64,
138        load: impl FnOnce() -> Result<Vec<f32>, MapError>,
139    ) -> Result<Arc<[f32]>, MapError> {
140        if bytes > self.budget {
141            return Err(MapError::Budget(format!(
142                "one tile of {bytes} bytes exceeds the cache budget {}",
143                self.budget
144            )));
145        }
146        let mut st = self.lock();
147        let mut waited = false;
148        loop {
149            st.tick += 1;
150            let tick = st.tick;
151            match st.map.get_mut(&key) {
152                Some(Slot::Ready { data, last, .. }) => {
153                    *last = tick;
154                    let data = data.clone();
155                    st.stats.hits += 1;
156                    return Ok(data);
157                }
158                Some(Slot::Loading) => {
159                    if !waited {
160                        st.stats.single_flight_waits += 1;
161                        waited = true;
162                    }
163                    st = self.cv.wait(st).unwrap_or_else(|p| p.into_inner());
164                }
165                None => break,
166            }
167        }
168        self.make_room(&mut st, bytes)?;
169        st.map.insert(key, Slot::Loading);
170        st.bytes += bytes;
171        st.stats.peak_bytes = st.stats.peak_bytes.max(st.bytes);
172        st.loading += 1;
173        st.stats.peak_concurrent_loads = st.stats.peak_concurrent_loads.max(st.loading);
174        drop(st);
175        // From here until the slot is Ready, every exit (error or unwind) goes through the guard.
176        let mut guard = LoadGuard {
177            cache: self,
178            key,
179            bytes,
180            armed: true,
181        };
182
183        let result = match catch_unwind(AssertUnwindSafe(load)) {
184            Ok(r) => r,
185            Err(p) => {
186                self.lock().stats.loader_panics += 1;
187                Err(MapError::Terrain(format!(
188                    "tile {key:?} loader panicked: {}",
189                    panic_message(p.as_ref())
190                )))
191            }
192        };
193        let data = result.and_then(|v| {
194            if (v.len() as u64).checked_mul(4) != Some(bytes) {
195                Err(MapError::Terrain(format!(
196                    "decoded tile has {} values, expected {} bytes",
197                    v.len(),
198                    bytes
199                )))
200            } else {
201                Ok(Arc::<[f32]>::from(v))
202            }
203        })?;
204        let mut st = self.lock();
205        guard.armed = false;
206        st.loading -= 1;
207        st.tick += 1;
208        let last = st.tick;
209        st.map.insert(
210            key,
211            Slot::Ready {
212                data: data.clone(),
213                bytes,
214                last,
215            },
216        );
217        st.stats.tile_reads += 1;
218        st.stats.decoded_bytes_total += bytes;
219        if st.seen.insert(key) {
220            st.stats.distinct_tiles_loaded += 1;
221        }
222        drop(st);
223        self.cv.notify_all();
224        Ok(data)
225    }
226
227    fn make_room(&self, st: &mut State, need: u64) -> Result<(), MapError> {
228        while st.bytes + need > self.budget {
229            let victim = st
230                .map
231                .iter()
232                .filter_map(|(k, s)| match s {
233                    Slot::Ready { data, last, .. } if Arc::strong_count(data) == 1 => {
234                        Some((*last, *k))
235                    }
236                    _ => None,
237                })
238                .min();
239            let Some((_, k)) = victim else {
240                return Err(MapError::Budget(format!(
241                    "terrain cache budget {} exhausted by pinned/loading tiles ({} resident, {} needed)",
242                    self.budget, st.bytes, need
243                )));
244            };
245            if let Some(Slot::Ready { bytes, .. }) = st.map.remove(&k) {
246                st.bytes -= bytes;
247                st.stats.evictions += 1;
248            }
249        }
250        Ok(())
251    }
252
253    pub fn stats(&self) -> CacheStats {
254        let st = self.lock();
255        let mut s = st.stats.clone();
256        s.resident_bytes = st.bytes;
257        s.reloads_after_eviction = s.tile_reads - s.distinct_tiles_loaded;
258        s
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::sync::atomic::{AtomicUsize, Ordering};
266
267    #[test]
268    fn hits_evictions_and_budget() {
269        let c = TileCache::new(32);
270        let a = c.get_or_load((0, 0), 16, || Ok(vec![1.0; 4])).unwrap();
271        drop(a);
272        let _ = c.get_or_load((0, 0), 16, || panic!("must hit")).unwrap();
273        c.get_or_load((0, 1), 16, || Ok(vec![2.0; 4])).unwrap();
274        c.get_or_load((0, 2), 16, || Ok(vec![3.0; 4])).unwrap();
275        let s = c.stats();
276        assert_eq!((s.tile_reads, s.hits, s.evictions), (3, 1, 1));
277        assert!(s.peak_bytes <= 32);
278        // Evicted LRU key (0,0) reloads.
279        c.get_or_load((0, 0), 16, || Ok(vec![1.0; 4])).unwrap();
280        assert_eq!(c.stats().reloads_after_eviction, 1);
281    }
282
283    #[test]
284    fn pinned_tiles_are_not_evicted() {
285        let c = TileCache::new(32);
286        let _a = c.get_or_load((0, 0), 16, || Ok(vec![1.0; 4])).unwrap();
287        let _b = c.get_or_load((0, 1), 16, || Ok(vec![1.0; 4])).unwrap();
288        let e = c.get_or_load((0, 2), 16, || Ok(vec![1.0; 4])).unwrap_err();
289        assert_eq!(e.kind(), "budget");
290        assert!(c.stats().peak_bytes <= 32);
291    }
292
293    #[test]
294    fn single_flight_and_errors() {
295        let c = Arc::new(TileCache::new(1 << 20));
296        let loads = Arc::new(AtomicUsize::new(0));
297        std::thread::scope(|s| {
298            for _ in 0..8 {
299                let (c, loads) = (c.clone(), loads.clone());
300                s.spawn(move || {
301                    c.get_or_load((5, 5), 4, || {
302                        loads.fetch_add(1, Ordering::SeqCst);
303                        std::thread::sleep(std::time::Duration::from_millis(20));
304                        Ok(vec![7.0])
305                    })
306                    .unwrap();
307                });
308            }
309        });
310        assert_eq!(loads.load(Ordering::SeqCst), 1);
311        assert!(c.get_or_load((1, 1), 8, || Ok(vec![1.0])).is_err());
312        assert_eq!(c.stats().resident_bytes, 4);
313    }
314
315    /// QA repro R1 as a regression test: a panicking loader must not strand its waiters or its
316    /// bytes. Every wait is bounded by `recv_timeout`, so a regression fails instead of hanging.
317    #[test]
318    fn loader_panic_releases_slot_and_wakes_waiters() {
319        use std::sync::mpsc;
320        use std::time::Duration;
321        let c = Arc::new(TileCache::new(1 << 20));
322        let (started_tx, started_rx) = mpsc::channel();
323        let (loader_tx, loader_rx) = mpsc::channel();
324        let c1 = c.clone();
325        std::thread::spawn(move || {
326            let r = c1.get_or_load((3, 4), 1024, || {
327                started_tx.send(()).unwrap();
328                std::thread::sleep(Duration::from_millis(100));
329                panic!("simulated decoder panic");
330            });
331            let _ = loader_tx.send(r);
332        });
333        started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
334        // Concurrent waiter on the same key, started while the loader runs.
335        let (waiter_tx, waiter_rx) = mpsc::channel();
336        let c2 = c.clone();
337        std::thread::spawn(move || {
338            let _ = waiter_tx.send(c2.get_or_load((3, 4), 1024, || Ok(vec![1.0f32; 256])));
339        });
340        let loader = loader_rx
341            .recv_timeout(Duration::from_secs(5))
342            .expect("loader returned");
343        let e = loader.expect_err("panic must become an error");
344        assert_eq!(e.kind(), "terrain");
345        assert!(e.to_string().contains("simulated decoder panic"), "{e}");
346        let waiter = waiter_rx
347            .recv_timeout(Duration::from_secs(5))
348            .expect("waiter must not hang");
349        assert_eq!(waiter.unwrap().len(), 256);
350        // A later request is a hit on the waiter's successful reload.
351        let later = c.get_or_load((3, 4), 1024, || panic!("must hit")).unwrap();
352        assert_eq!(later[0], 1.0);
353        let s = c.stats();
354        assert_eq!((s.load_errors, s.loader_panics, s.tile_reads), (1, 1, 1));
355        assert_eq!(s.resident_bytes, 1024);
356        assert!(s.peak_bytes <= 2048);
357    }
358
359    #[test]
360    fn loader_panic_without_waiters_leaves_no_bytes() {
361        let c = TileCache::new(64);
362        let e = c
363            .get_or_load((0, 0), 64, || panic!("boom"))
364            .expect_err("typed error");
365        assert_eq!(e.kind(), "terrain");
366        let s = c.stats();
367        assert_eq!(
368            (s.resident_bytes, s.load_errors, s.loader_panics),
369            (0, 1, 1)
370        );
371        // The full budget is available again.
372        c.get_or_load((0, 1), 64, || Ok(vec![0.0; 16])).unwrap();
373        assert_eq!(c.stats().peak_concurrent_loads, 1);
374    }
375}