Skip to main content

pdfboss_aio/
cache.rs

1//! Chunked LRU read cache over any backend: many small reads become few
2//! chunk-sized fetches, and hot chunks stay resident up to a byte budget.
3//! Default 64 KiB chunks, 32 MiB cap. Misses batch adaptively: a miss
4//! landing near the previous one doubles the batch (up to 8 MiB), a far
5//! one halves it, and each miss fetches its uncached neighborhood in one
6//! inner read, so dense access over a high-latency backend collapses into
7//! few large requests while scattered access stays at one chunk per miss.
8
9use std::collections::HashMap;
10use std::io;
11use std::sync::{Arc, Mutex};
12
13use crate::backend::{Backend, BoxFuture};
14
15/// Default chunk size: 64 KiB.
16pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024;
17/// Default total cache capacity: 32 MiB.
18pub const DEFAULT_MAX_BYTES: usize = 32 * 1024 * 1024;
19/// Largest run of chunks one miss may fetch in a single inner read.
20const MAX_BATCH_BYTES: usize = 8 * 1024 * 1024;
21
22/// Chunked LRU read cache over any backend.
23///
24/// Reads are served chunk-by-chunk from an in-memory map; misses fetch a
25/// batched run of chunks from the inner backend (see the module doc).
26/// Concurrent misses on the same chunk may fetch it twice (both results
27/// are identical; one wins the cache slot) — correctness is unaffected.
28pub struct CachedBackend<B: Backend> {
29    inner: B,
30    chunk_size: usize,
31    max_bytes: usize,
32    state: Mutex<CacheState>,
33    len: tokio::sync::OnceCell<u64>,
34    fetch: Option<Arc<dyn Fn(u64, u64) + Send + Sync>>,
35}
36
37struct CacheState {
38    chunks: HashMap<u64, CacheEntry>,
39    bytes: usize,
40    clock: u64,
41    /// Current batch: how many consecutive chunks the next miss fetches.
42    batch_chunks: usize,
43    /// Chunk index of the previous miss, the density reference point.
44    previous_miss: Option<u64>,
45}
46
47struct CacheEntry {
48    data: Vec<u8>,
49    stamp: u64,
50}
51
52impl<B: Backend> CachedBackend<B> {
53    /// Wraps `inner` with the default 64 KiB chunks and 32 MiB capacity.
54    pub fn new(inner: B) -> Self {
55        Self::with_capacity(inner, DEFAULT_CHUNK_SIZE, DEFAULT_MAX_BYTES)
56    }
57
58    /// Wraps `inner` with an explicit chunk size and total byte capacity.
59    ///
60    /// # Panics
61    /// Panics if `chunk_size` is zero.
62    pub fn with_capacity(inner: B, chunk_size: usize, max_bytes: usize) -> Self {
63        assert!(chunk_size > 0, "chunk_size must be nonzero");
64        CachedBackend {
65            inner,
66            chunk_size,
67            max_bytes,
68            state: Mutex::new(CacheState {
69                chunks: HashMap::new(),
70                bytes: 0,
71                clock: 0,
72                batch_chunks: 1,
73                previous_miss: None,
74            }),
75            len: tokio::sync::OnceCell::new(),
76            fetch: None,
77        }
78    }
79
80    /// Registers a fetch observer: `fetch(offset, len)` is called after
81    /// every inner read a cache miss triggers, with the byte offset and
82    /// length actually fetched. Cache hits never call it. The callback runs
83    /// on the async runtime, so it must not block.
84    pub fn on_fetch(mut self, fetch: impl Fn(u64, u64) + Send + Sync + 'static) -> Self {
85        self.fetch = Some(Arc::new(fetch));
86        self
87    }
88
89    /// The chunk at `index`: from cache when resident (touching its LRU
90    /// stamp), otherwise fetched from the inner backend as part of a
91    /// batched run of chunks (all inserted, evicting least-recently-used
92    /// chunks beyond the capacity).
93    async fn chunk(&self, index: u64, file_len: u64) -> io::Result<Vec<u8>> {
94        if let Some(hit) = self.lookup(index) {
95            return Ok(hit);
96        }
97        let (first, run) = self.batch_run(index, file_len);
98        let start = first * self.chunk_size as u64;
99        let size = usize::try_from((file_len - start).min((run * self.chunk_size) as u64))
100            .expect("batch size fits usize");
101        let mut data = vec![0u8; size];
102        let mut filled = 0;
103        while filled < size {
104            let count = self
105                .inner
106                .read_at(start + filled as u64, &mut data[filled..])
107                .await?;
108            if count == 0 {
109                break;
110            }
111            filled += count;
112        }
113        data.truncate(filled);
114        if let Some(fetch) = &self.fetch {
115            fetch(start, filled as u64);
116        }
117        let wanted = (index - first) as usize;
118        let mut result = Vec::new();
119        for (step, piece) in data.chunks(self.chunk_size).enumerate() {
120            if step == wanted {
121                result = piece.to_vec();
122            }
123            self.insert(first + step as u64, piece.to_vec());
124        }
125        if data.is_empty() {
126            // The inner source produced nothing (shorter than declared):
127            // record the empty chunk so the read loop sees a short read.
128            self.insert(index, Vec::new());
129        }
130        Ok(result)
131    }
132
133    /// Decides the run of consecutive chunks fetched for the miss at
134    /// `index`, as `(first_chunk, length)`. A miss within four times the
135    /// current batch of the previous one is dense access and doubles the
136    /// batch, up
137    /// to [`MAX_BATCH_BYTES`] and a quarter of the cache budget (so one run
138    /// can never evict what the reader is actively using); anything farther
139    /// halves it, so an excursion does not throw away an established
140    /// density estimate. The run is the uncached neighborhood of `index`:
141    /// it grows downward first and then upward with the leftover budget,
142    /// each direction stopping at a resident chunk or a file bound, so
143    /// nothing is fetched twice and the run adapts by itself to walks that
144    /// march backward (page objects laid out in descending file order are
145    /// common), forward, or jitter around a moving locality.
146    fn batch_run(&self, index: u64, file_len: u64) -> (u64, usize) {
147        let max_chunks = (MAX_BATCH_BYTES.min(self.max_bytes / 4) / self.chunk_size).max(1);
148        let mut state = self.state.lock().expect("cache mutex");
149        let dense = state
150            .previous_miss
151            .is_some_and(|previous| index.abs_diff(previous) <= 4 * state.batch_chunks as u64);
152        state.batch_chunks = if dense {
153            (state.batch_chunks * 2).min(max_chunks)
154        } else {
155            (state.batch_chunks / 2).max(1)
156        };
157        state.previous_miss = Some(index);
158        let chunks_total = file_len.div_ceil(self.chunk_size as u64);
159        let mut budget = state.batch_chunks - 1;
160        let mut first = index;
161        while budget > 0 && first > 0 && !state.chunks.contains_key(&(first - 1)) {
162            first -= 1;
163            budget -= 1;
164        }
165        let mut last = index;
166        while budget > 0 && last + 1 < chunks_total && !state.chunks.contains_key(&(last + 1)) {
167            last += 1;
168            budget -= 1;
169        }
170        (first, (last - first + 1) as usize)
171    }
172
173    /// Cache lookup, refreshing the entry's recency stamp on a hit.
174    fn lookup(&self, index: u64) -> Option<Vec<u8>> {
175        let mut state = self.state.lock().expect("cache mutex");
176        state.clock += 1;
177        let stamp = state.clock;
178        let entry = state.chunks.get_mut(&index)?;
179        entry.stamp = stamp;
180        Some(entry.data.clone())
181    }
182
183    /// Inserts a chunk, evicting the least-recently-used entries until the
184    /// total stays within `max_bytes`.
185    fn insert(&self, index: u64, data: Vec<u8>) {
186        let mut state = self.state.lock().expect("cache mutex");
187        state.clock += 1;
188        let stamp = state.clock;
189        state.bytes += data.len();
190        let old_entry = state.chunks.insert(index, CacheEntry { data, stamp });
191        if let Some(old) = old_entry {
192            state.bytes -= old.data.len();
193        }
194        while state.bytes > self.max_bytes && state.chunks.len() > 1 {
195            let coldest = state
196                .chunks
197                .iter()
198                .filter(|(candidate, _)| **candidate != index)
199                .min_by_key(|(_, entry)| entry.stamp)
200                .map(|(candidate, _)| *candidate);
201            match coldest {
202                Some(victim) => {
203                    if let Some(gone) = state.chunks.remove(&victim) {
204                        state.bytes -= gone.data.len();
205                    }
206                }
207                None => break,
208            }
209        }
210    }
211}
212
213impl<B: Backend> Backend for CachedBackend<B> {
214    fn len(&self) -> BoxFuture<'_, io::Result<u64>> {
215        Box::pin(async move { self.len.get_or_try_init(|| self.inner.len()).await.copied() })
216    }
217
218    fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>> {
219        Box::pin(async move {
220            let file_len = self.len().await?;
221            if offset >= file_len || buf.is_empty() {
222                return Ok(0);
223            }
224            let available = usize::try_from(file_len - offset).unwrap_or(usize::MAX);
225            let want = buf.len().min(available);
226            let mut done = 0;
227            while done < want {
228                let pos = offset + done as u64;
229                let index = pos / self.chunk_size as u64;
230                let within = (pos % self.chunk_size as u64) as usize;
231                let chunk = self.chunk(index, file_len).await?;
232                if within >= chunk.len() {
233                    break; // inner source shorter than its declared length
234                }
235                let count = (want - done).min(chunk.len() - within);
236                buf[done..done + count].copy_from_slice(&chunk[within..within + count]);
237                done += count;
238            }
239            Ok(done)
240        })
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::backend::MemBackend;
248    use std::sync::atomic::{AtomicUsize, Ordering};
249    use std::sync::Arc;
250
251    /// Inner-fetch tallies: how many read_at calls, and how many bytes they
252    /// actually returned.
253    #[derive(Default)]
254    struct Counts {
255        calls: AtomicUsize,
256        bytes: AtomicUsize,
257    }
258
259    /// Delegates to a MemBackend while tallying inner fetches.
260    struct CountingBackend {
261        inner: MemBackend,
262        counts: Arc<Counts>,
263    }
264
265    impl Backend for CountingBackend {
266        fn len(&self) -> BoxFuture<'_, std::io::Result<u64>> {
267            self.inner.len()
268        }
269        fn read_at<'a>(
270            &'a self,
271            offset: u64,
272            buf: &'a mut [u8],
273        ) -> BoxFuture<'a, std::io::Result<usize>> {
274            self.counts.calls.fetch_add(1, Ordering::SeqCst);
275            Box::pin(async move {
276                let count = self.inner.read_at(offset, buf).await?;
277                self.counts.bytes.fetch_add(count, Ordering::SeqCst);
278                Ok(count)
279            })
280        }
281    }
282
283    fn counting(data: Vec<u8>) -> (CountingBackend, Arc<Counts>) {
284        let counts = Arc::new(Counts::default());
285        (
286            CountingBackend {
287                inner: MemBackend::from(data),
288                counts: Arc::clone(&counts),
289            },
290            counts,
291        )
292    }
293
294    #[tokio::test]
295    async fn repeated_reads_fetch_each_chunk_once() {
296        let (inner, fetches) = counting((0u8..=255).collect());
297        let cached = CachedBackend::with_capacity(inner, 64, 1024);
298        let mut buf = [0u8; 8];
299        assert_eq!(cached.read_at(10, &mut buf).await.unwrap(), 8);
300        assert_eq!(&buf, &[10, 11, 12, 13, 14, 15, 16, 17]);
301        assert_eq!(cached.read_at(20, &mut buf).await.unwrap(), 8);
302        assert_eq!(&buf, &[20, 21, 22, 23, 24, 25, 26, 27]);
303        // Both reads live in chunk 0: exactly one inner fetch.
304        assert_eq!(fetches.calls.load(Ordering::SeqCst), 1);
305    }
306
307    #[tokio::test]
308    async fn reads_spanning_chunks_and_eof_are_stitched() {
309        let (inner, fetches) = counting((0u8..=255).collect());
310        let cached = CachedBackend::with_capacity(inner, 64, 1024);
311        let mut buf = [0u8; 100];
312        // Bytes 30..=129 span chunks 0 (0..63), 1 (64..127) and 2 (128..191).
313        // The spanning read is dense by construction: the second miss
314        // batches chunks 1 and 2 into one inner fetch.
315        assert_eq!(cached.read_at(30, &mut buf).await.unwrap(), 100);
316        assert_eq!(buf[0], 30);
317        assert_eq!(buf[99], 129);
318        assert_eq!(fetches.calls.load(Ordering::SeqCst), 2);
319        // Short read at EOF (len 256).
320        assert_eq!(cached.read_at(250, &mut buf).await.unwrap(), 6);
321        assert_eq!(&buf[..6], &[250, 251, 252, 253, 254, 255]);
322        assert_eq!(cached.read_at(256, &mut buf).await.unwrap(), 0);
323    }
324
325    #[tokio::test]
326    async fn lru_evicts_the_coldest_chunk() {
327        let (inner, fetches) = counting((0u8..=255).collect());
328        // Capacity for exactly two 64-byte chunks.
329        let cached = CachedBackend::with_capacity(inner, 64, 128);
330        let mut buf = [0u8; 4];
331        cached.read_at(0, &mut buf).await.unwrap(); // chunk 0
332        cached.read_at(64, &mut buf).await.unwrap(); // chunk 1
333        cached.read_at(0, &mut buf).await.unwrap(); // touch chunk 0
334        cached.read_at(128, &mut buf).await.unwrap(); // chunk 2 evicts chunk 1
335        assert_eq!(fetches.calls.load(Ordering::SeqCst), 3);
336        cached.read_at(0, &mut buf).await.unwrap(); // still cached
337        assert_eq!(fetches.calls.load(Ordering::SeqCst), 3);
338        cached.read_at(64, &mut buf).await.unwrap(); // refetched
339        assert_eq!(fetches.calls.load(Ordering::SeqCst), 4);
340        assert_eq!(&buf, &[64, 65, 66, 67]);
341    }
342
343    #[tokio::test]
344    async fn default_capacity_uses_64_kib_chunks() {
345        let (inner, fetches) = counting(vec![7u8; 200_000]);
346        let cached = CachedBackend::new(inner);
347        let mut buf = [0u8; 16];
348        cached.read_at(0, &mut buf).await.unwrap();
349        cached.read_at(70_000, &mut buf).await.unwrap();
350        // 0 lives in chunk 0, 70_000 (past the 65_536-byte boundary) in
351        // chunk 1 of the 64 KiB grid: two inner fetches, and a re-read of
352        // either offset adds none.
353        assert_eq!(fetches.calls.load(Ordering::SeqCst), 2);
354        cached.read_at(1000, &mut buf).await.unwrap();
355        assert_eq!(fetches.calls.load(Ordering::SeqCst), 2);
356    }
357
358    #[tokio::test]
359    async fn dense_scan_escalates_batches_and_collapses_fetches() {
360        // 64 chunks of 64 bytes, read end to end in chunk-sized steps.
361        let data: Vec<u8> = (0..4096u32).map(|v| v as u8).collect();
362        let (inner, fetches) = counting(data.clone());
363        let cached = CachedBackend::with_capacity(inner, 64, 1 << 20);
364        let mut got = Vec::new();
365        let mut buf = [0u8; 64];
366        for step in 0..64 {
367            let count = cached.read_at(step * 64, &mut buf).await.unwrap();
368            got.extend_from_slice(&buf[..count]);
369        }
370        assert_eq!(got, data, "batched fetches must not corrupt the bytes");
371        // Miss runs double while the scan stays dense: 1, 1, 2, 4, 8, 16,
372        // 32, then the tail. 64 chunks arrive in 7 fetches instead of 64.
373        assert_eq!(fetches.calls.load(Ordering::SeqCst), 7);
374    }
375
376    #[tokio::test]
377    async fn backward_scan_escalates_batches_toward_lower_offsets() {
378        // The same 64 chunks read back to front, the layout the Tafsir
379        // trace showed live: runs must extend backward, ending at the
380        // missed chunk, or every escalated fetch covers the wrong side.
381        let data: Vec<u8> = (0..4096u32).map(|v| v as u8).collect();
382        let (inner, fetches) = counting(data.clone());
383        let cached = CachedBackend::with_capacity(inner, 64, 1 << 20);
384        let mut got = Vec::new();
385        let mut buf = [0u8; 64];
386        for step in (0..64u64).rev() {
387            let count = cached.read_at(step * 64, &mut buf).await.unwrap();
388            got.splice(0..0, buf[..count].iter().copied());
389        }
390        assert_eq!(got, data, "backward batched fetches must not corrupt");
391        // Mirror of the forward ladder: 1, 1, 2, 4, 8, 16, 32, tail.
392        assert_eq!(fetches.calls.load(Ordering::SeqCst), 7);
393        assert_eq!(fetches.bytes.load(Ordering::SeqCst), 4096, "each byte once");
394    }
395
396    #[tokio::test]
397    async fn far_jumps_reset_the_batch_to_one_chunk() {
398        let (inner, fetches) = counting(vec![9u8; 8192]);
399        let cached = CachedBackend::with_capacity(inner, 64, 1 << 20);
400        let mut buf = [0u8; 16];
401        // Three scattered reads, each far outside twice the current batch:
402        // every one is a single-chunk fetch, no overshoot.
403        cached.read_at(0, &mut buf).await.unwrap();
404        cached.read_at(4096, &mut buf).await.unwrap();
405        cached.read_at(1024, &mut buf).await.unwrap();
406        assert_eq!(fetches.calls.load(Ordering::SeqCst), 3);
407        // Re-reads of those offsets stay hits: nothing beyond one chunk
408        // per miss was fetched, so the neighbors were never pulled in.
409        cached.read_at(64, &mut buf).await.unwrap();
410        assert_eq!(fetches.calls.load(Ordering::SeqCst), 4);
411    }
412
413    #[tokio::test]
414    async fn batch_runs_trim_at_cached_chunks_and_eof() {
415        let data: Vec<u8> = (0..4096u32).map(|v| v as u8).collect();
416        let (inner, fetches) = counting(data);
417        let cached = CachedBackend::with_capacity(inner, 64, 1 << 20);
418        let mut buf = [0u8; 16];
419        cached.read_at(5 * 64, &mut buf).await.unwrap(); // miss 5, run [5]
420        cached.read_at(2 * 64, &mut buf).await.unwrap(); // dense: run [1,2]
421                                                         // Dense miss at 4 with batch 4: the neighborhood is walled in by
422                                                         // the resident chunks 2 and 5, so one fetch covers exactly [3, 4]
423                                                         // and the next read is a hit.
424        cached.read_at(4 * 64, &mut buf).await.unwrap();
425        cached.read_at(3 * 64, &mut buf).await.unwrap();
426        assert_eq!(fetches.calls.load(Ordering::SeqCst), 3);
427        assert_eq!(
428            fetches.bytes.load(Ordering::SeqCst),
429            5 * 64,
430            "chunks 5, 1-2 and 3-4 fetched exactly once, nothing beyond"
431        );
432        // A run near the end of the file trims at EOF and stays correct.
433        let mut tail = [0u8; 64];
434        assert_eq!(cached.read_at(4032, &mut tail).await.unwrap(), 64);
435        assert_eq!(tail[63], 4095u32 as u8);
436    }
437
438    #[tokio::test]
439    async fn fetch_observer_reports_offset_and_length_of_each_inner_fetch() {
440        let data: Vec<u8> = (0..4096u32).map(|v| v as u8).collect();
441        let (inner, _) = counting(data);
442        let seen: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
443        let sink = Arc::clone(&seen);
444        let cached = CachedBackend::with_capacity(inner, 64, 1 << 20)
445            .on_fetch(move |offset, len| sink.lock().unwrap().push((offset, len)));
446        let mut buf = [0u8; 16];
447        cached.read_at(5 * 64, &mut buf).await.unwrap(); // miss: run [5]
448        cached.read_at(4 * 64, &mut buf).await.unwrap(); // dense miss: run [3, 4]
449        cached.read_at(5 * 64, &mut buf).await.unwrap(); // hit: no report
450        assert_eq!(*seen.lock().unwrap(), vec![(320, 64), (192, 128)]);
451    }
452
453    #[test]
454    fn duplicate_insert_does_not_double_count() {
455        // Create a cache to test byte accounting
456        let (inner, _) = counting(vec![0u8; 200]);
457        let cached = CachedBackend::with_capacity(inner, 64, 256);
458
459        // Insert the same chunk twice directly
460        cached.insert(0, vec![0u8; 64]);
461        cached.insert(0, vec![0u8; 64]);
462
463        // Check that bytes is still 64, not 128
464        let state = cached.state.lock().unwrap();
465        assert_eq!(
466            state.bytes, 64,
467            "Duplicate insert should not double-count bytes"
468        );
469        assert_eq!(state.chunks.len(), 1, "Should have exactly one chunk");
470    }
471}