Skip to main content

rete_core/
block_cache.rs

1//! A read-through **block cache** over any [`RangeReader`] — the client-side
2//! half of lazy range serving (SPEC.md §9).
3//!
4//! The engine faults small, scattered byte ranges (a dictionary chunk here, an
5//! index tile there). Issued one-for-one against a high-latency remote, that is
6//! many round trips. This wrapper fetches fixed-size, **block-aligned** spans
7//! instead of exact ranges and caches them, so:
8//!
9//! - nearby reads coalesce into a few block fetches,
10//! - repeated reads (and the header re-read) are free, and
11//! - it needs only plain single-range `Range: bytes=a-b` support — so it speeds
12//!   up reads from **any** object store (S3, GCS, Azure, a CDN) that has no
13//!   multi-range / `multipart/byteranges` support at all.
14//!
15//! The trade is a little over-fetch (whole blocks) for far fewer requests — the
16//! right call on a latency-bound link. Missing blocks for one access are fetched
17//! through [`RangeReader::read_many`], so a backend that *does* support
18//! multi-range collapses them further still; the two optimizations compose.
19//!
20//! Residency is **bounded**: past a byte cap (default [`DEFAULT_CACHE_CAP`],
21//! overridable with [`BlockCacheReader::with_cache_cap`]) the least-recently
22//! touched blocks are evicted after each read. Without the cap, a wide scan of a
23//! multi-GB remote file would grow the cache toward the whole file — on 32-bit
24//! wasm (a 4 GiB address space shared with the decompressed tiles and dictionary
25//! chunks) that is an out-of-memory crash, not a slowdown.
26
27use crate::reader::RangeReader;
28use std::collections::{BTreeSet, HashMap, HashSet};
29use std::sync::{Arc, Condvar, Mutex};
30
31/// Default block size: 64 KiB — large enough to swallow a dictionary chunk or an
32/// index tile in one fetch, small enough to keep over-fetch modest.
33pub const DEFAULT_BLOCK: u64 = 64 * 1024;
34
35/// Default cap on resident cached bytes: 256 MiB. Large enough that a working
36/// set (the tiles + dictionary chunks a query family touches) stays warm; small
37/// enough that a full sweep of a multi-GB file leaves plenty of the 32-bit wasm
38/// address space for the decompressed structures built on top of these bytes.
39/// At the auto-tuned 128–512 KiB block sizes this is 512–2048 resident blocks,
40/// so the eviction scan is trivial.
41pub const DEFAULT_CACHE_CAP: u64 = 256 * 1024 * 1024;
42
43/// Pick a [`BlockCacheReader`] block size from the file length: bigger files get
44/// bigger blocks so a remote query makes far fewer (but larger) round trips —
45/// 128 KiB ≤ 10 MB, 256 KiB ≤ 100 MB, 512 KiB above. The over-fetch is modest
46/// next to the round-trip latency it saves on a high-latency link (S3/CDN). Shared
47/// by the CLI and the wasm client so both size identically; the file length is
48/// known for free from the opening `HEAD` / `Content-Range`.
49pub fn auto_block(len: u64) -> u64 {
50    const MB: u64 = 1 << 20;
51    // The reader coalesces byte-adjacent missing blocks into one request, so a
52    // large block buys little on a contiguous read — it only over-fetches the
53    // *scattered* faults that dominate a selective query (a dictionary chunk is
54    // 64 KiB; a 512 KiB block dragged in 8× the bytes). Keep the block near the
55    // chunk size; nearby-but-not-adjacent faults still merge when they share a
56    // block, and the bigger tier for huge files trades a little over-fetch for
57    // fewer round trips on the biggest scans.
58    let mult: u64 = if len > 100 * MB {
59        2 // 128 KiB
60    } else {
61        1 // 64 KiB
62    };
63    mult * DEFAULT_BLOCK
64}
65
66/// What a cached block costs against the cap: its allocation's real size
67/// (`Arc` header included, rounded to the allocator's class), not its length —
68/// a 128 KiB block occupies 160 KiB under jemalloc.
69fn charge(data: &Arc<[u8]>) -> u64 {
70    crate::chunk_cache::alloc_footprint(data.len() as u64 + 16)
71}
72
73/// One resident block: its bytes plus the last-touch stamp eviction orders by.
74struct CacheEntry {
75    data: Arc<[u8]>,
76    stamp: u64,
77}
78
79/// The cache state behind one mutex: resident blocks, their total byte size,
80/// and the monotonic access counter that makes eviction least-recently-used.
81struct CacheState {
82    map: HashMap<u64, CacheEntry>,
83    used: u64,
84    tick: u64,
85    /// Blocks a reader is fetching right now: a concurrent reader that wants
86    /// one waits for it ([`BlockCacheReader::landed`]) instead of fetching the
87    /// same bytes a second time.
88    in_flight: HashSet<u64>,
89}
90
91pub struct BlockCacheReader<R> {
92    inner: R,
93    block: u64,
94    len: u64,
95    cap: u64,
96    cache: Mutex<CacheState>,
97    /// Signalled whenever a fetch lands (or fails) and clears its in-flight
98    /// marks.
99    landed: Condvar,
100}
101
102impl<R: RangeReader> BlockCacheReader<R> {
103    /// Wrap `inner`, fetching `block`-aligned blocks (clamped to ≥ 4 KiB) and
104    /// keeping at most [`DEFAULT_CACHE_CAP`] bytes of them resident.
105    pub fn new(inner: R, block: u64) -> Self {
106        let len = inner.len();
107        Self {
108            inner,
109            block: block.max(4096),
110            len,
111            cap: DEFAULT_CACHE_CAP,
112            cache: Mutex::new(CacheState {
113                map: HashMap::new(),
114                used: 0,
115                tick: 0,
116                in_flight: HashSet::new(),
117            }),
118            landed: Condvar::new(),
119        }
120    }
121
122    /// Override the resident-byte cap. The cap is enforced *between* reads: a
123    /// single read spanning more than `cap` bytes of blocks is still served
124    /// exactly (its blocks are resident while it assembles) and the cache is
125    /// trimmed back under the cap right after. `u64::MAX` disables eviction.
126    pub fn with_cache_cap(mut self, cap: u64) -> Self {
127        self.cap = cap;
128        self
129    }
130
131    /// Bytes currently resident in the cache (for stats and tests).
132    pub fn cached_bytes(&self) -> u64 {
133        self.cache.lock().unwrap().used
134    }
135
136    fn bounds(&self, offset: u64, len: u64) -> std::io::Result<()> {
137        if offset.checked_add(len).is_none_or(|e| e > self.len) {
138            // Name the numbers: this fires when a read the file's own layout
139            // demands overruns the length the transport reported, and the two
140            // disagreeing values are the whole diagnosis (issue #95: a host
141            // advertised its gzip size, 58 MB, for a 71 MB file).
142            return Err(std::io::Error::new(
143                std::io::ErrorKind::UnexpectedEof,
144                format!(
145                    "range out of bounds: read {offset}+{len} exceeds the reported resource length {} — \
146                     the file's layout expects more bytes than the length probe found \
147                     (a host serving a compressed Content-Length can cause this)",
148                    self.len
149                ),
150            ));
151        }
152        Ok(())
153    }
154
155    /// Fetch and cache every block index in `want` that isn't resident, issuing
156    /// the missing ones as coalesced spans through `read_many`. Already-resident
157    /// wanted blocks get their recency stamp refreshed.
158    fn ensure(&self, want: &BTreeSet<u64>) -> std::io::Result<()> {
159        // Resident blocks are touched; blocks another reader is fetching are
160        // waited for, not fetched again (a parallel open walks two containers
161        // at once and both often want the next block); the rest are claimed.
162        let (missing, others): (Vec<u64>, Vec<u64>) = {
163            let mut st = self.cache.lock().unwrap();
164            st.tick += 1;
165            let tick = st.tick;
166            let mut missing = Vec::new();
167            let mut others = Vec::new();
168            for &b in want {
169                if let Some(e) = st.map.get_mut(&b) {
170                    e.stamp = tick;
171                } else if st.in_flight.contains(&b) {
172                    others.push(b);
173                } else {
174                    missing.push(b);
175                }
176            }
177            for &b in &missing {
178                st.in_flight.insert(b);
179            }
180            (missing, others)
181        };
182        let fetched = self.fetch_claimed(&missing);
183        if !others.is_empty() {
184            let mut st = self.cache.lock().unwrap();
185            while others.iter().any(|b| st.in_flight.contains(b)) {
186                st = self.landed.wait(st).unwrap();
187            }
188            // A block the other reader failed to fetch is read directly by
189            // `assemble`: correctness never depends on residency.
190        }
191        fetched
192    }
193
194    /// Fetch the blocks this reader claimed in [`ensure`](Self::ensure), cache
195    /// them, and clear their in-flight marks whatever the outcome.
196    fn fetch_claimed(&self, missing: &[u64]) -> std::io::Result<()> {
197        if missing.is_empty() {
198            return Ok(());
199        }
200        let r = self.fetch_missing(missing);
201        let mut st = self.cache.lock().unwrap();
202        for b in missing {
203            st.in_flight.remove(b);
204        }
205        drop(st);
206        self.landed.notify_all();
207        r
208    }
209
210    fn fetch_missing(&self, missing: &[u64]) -> std::io::Result<()> {
211        // Coalesce consecutive block indices into one span each.
212        let mut spans: Vec<(u64, u64)> = Vec::new();
213        let mut runs: Vec<(u64, u64)> = Vec::new();
214        let mut i = 0;
215        while i < missing.len() {
216            let first = missing[i];
217            let mut last = first;
218            let mut j = i + 1;
219            while j < missing.len() && missing[j] == last + 1 {
220                last = missing[j];
221                j += 1;
222            }
223            let off = first * self.block;
224            let end = ((last + 1) * self.block).min(self.len);
225            spans.push((off, end - off));
226            runs.push((first, last));
227            i = j;
228        }
229        let blobs = self.inner.read_many(&spans)?;
230        if blobs.len() != spans.len() {
231            return Err(std::io::Error::other("block fetch returned wrong count"));
232        }
233        // A blob of any other length than its span is not that span: caching
234        // a short one would hand the parsers a silently truncated block.
235        if let Some(((o, l), b)) = spans
236            .iter()
237            .zip(&blobs)
238            .find(|((_, l), b)| b.len() as u64 != *l)
239        {
240            return Err(std::io::Error::new(
241                std::io::ErrorKind::UnexpectedEof,
242                format!("block fetch at {o} returned {} bytes, not {l}", b.len()),
243            ));
244        }
245        let mut st = self.cache.lock().unwrap();
246        st.tick += 1;
247        let tick = st.tick;
248        for (&(first, last), blob) in runs.iter().zip(blobs.into_iter()) {
249            let span_start = first * self.block;
250            for b in first..=last {
251                let lo = (b * self.block - span_start) as usize;
252                let hi = ((((b + 1) * self.block).min(self.len)) - span_start) as usize;
253                let hi = hi.min(blob.len());
254                let lo = lo.min(hi);
255                let data: Arc<[u8]> = Arc::from(&blob[lo..hi]);
256                st.used += charge(&data);
257                if let Some(old) = st.map.insert(b, CacheEntry { data, stamp: tick }) {
258                    st.used -= charge(&old.data); // concurrent double-fetch
259                }
260            }
261        }
262        Ok(())
263    }
264
265    /// Evict least-recently-touched blocks until the resident total fits the
266    /// cap. Called after a read has assembled its bytes, so nothing in flight
267    /// depends on what gets dropped.
268    ///
269    /// Once over the cap it evicts down to 7/8 of it, not just under it:
270    /// finding the least-recent blocks sorts the whole map, and a full cache
271    /// (every read of a long export) used to pay that sort on every single
272    /// read. The slack amortizes one sort over ~1/8 of the cache's turnover
273    /// (only once the cache holds 32+ blocks, so a tiny cache stays exact LRU).
274    fn trim(&self) {
275        let mut st = self.cache.lock().unwrap();
276        if st.used <= self.cap {
277            return;
278        }
279        let slack = if self.cap / 8 >= 4 * self.block {
280            self.cap / 8
281        } else {
282            0
283        };
284        let target = self.cap - slack;
285        let mut order: Vec<(u64, u64)> = st.map.iter().map(|(&b, e)| (e.stamp, b)).collect();
286        order.sort_unstable();
287        for (_, b) in order {
288            if st.used <= target {
289                break;
290            }
291            if let Some(e) = st.map.remove(&b) {
292                st.used -= charge(&e.data);
293            }
294        }
295    }
296
297    /// Copy `[offset, offset+len)` out of the cache. The needed blocks are
298    /// cloned out under one short lock; a block missing here (evicted by a
299    /// concurrent reader's trim between `ensure` and this call) is re-read
300    /// directly from the inner reader — correctness never depends on residency.
301    fn assemble(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
302        let first = offset / self.block;
303        let last = (offset + len - 1) / self.block;
304        let resident: Vec<Option<Arc<[u8]>>> = {
305            let st = self.cache.lock().unwrap();
306            (first..=last)
307                .map(|b| st.map.get(&b).map(|e| e.data.clone()))
308                .collect()
309        };
310        let mut out = Vec::with_capacity(len as usize);
311        let mut pos = offset;
312        let end = offset + len;
313        while pos < end {
314            let b = pos / self.block;
315            let block_start = b * self.block;
316            let within = (pos - block_start) as usize;
317            let fetched: Vec<u8>;
318            let blk: &[u8] = match &resident[(b - first) as usize] {
319                Some(data) => data,
320                None => {
321                    let blen = ((b + 1) * self.block).min(self.len) - block_start;
322                    fetched = self.inner.read_at(block_start, blen)?;
323                    &fetched
324                }
325            };
326            let take = ((end - pos) as usize).min(blk.len().saturating_sub(within));
327            if take == 0 {
328                break;
329            }
330            out.extend_from_slice(&blk[within..within + take]);
331            pos += take as u64;
332        }
333        if out.len() as u64 != len {
334            return Err(std::io::Error::new(
335                std::io::ErrorKind::UnexpectedEof,
336                format!("short read: {} of {len} bytes at {offset}", out.len()),
337            ));
338        }
339        Ok(out)
340    }
341}
342
343impl<R: RangeReader> RangeReader for BlockCacheReader<R> {
344    fn len(&self) -> u64 {
345        self.len
346    }
347
348    fn concurrency(&self) -> usize {
349        self.inner.concurrency()
350    }
351
352    fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
353        if len == 0 {
354            return Ok(Vec::new());
355        }
356        self.bounds(offset, len)?;
357        let want: BTreeSet<u64> = (offset / self.block..=(offset + len - 1) / self.block).collect();
358        self.ensure(&want)?;
359        let out = self.assemble(offset, len)?;
360        self.trim();
361        Ok(out)
362    }
363
364    fn read_many(&self, ranges: &[(u64, u64)]) -> std::io::Result<Vec<Vec<u8>>> {
365        let mut want = BTreeSet::new();
366        for &(o, l) in ranges {
367            if l == 0 {
368                continue;
369            }
370            self.bounds(o, l)?;
371            for b in o / self.block..=(o + l - 1) / self.block {
372                want.insert(b);
373            }
374        }
375        self.ensure(&want)?;
376        let out = ranges
377            .iter()
378            .map(|&(o, l)| {
379                if l == 0 {
380                    Ok(Vec::new())
381                } else {
382                    self.assemble(o, l)
383                }
384            })
385            .collect::<std::io::Result<Vec<_>>>()?;
386        self.trim();
387        Ok(out)
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::reader::{CountingReader, SliceReader};
395
396    #[test]
397    fn caches_blocks_and_serves_exact_bytes() {
398        let data: Vec<u8> = (0..100_000u32).map(|i| i as u8).collect();
399        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
400        let r = BlockCacheReader::new(counting.clone(), 16 * 1024);
401
402        // many small scattered reads inside the first few blocks
403        for off in [10u64, 200, 5000, 16_500, 17_000, 33_000, 33_100] {
404            assert_eq!(
405                r.read_at(off, 32).unwrap(),
406                data[off as usize..off as usize + 32]
407            );
408        }
409        // exact bytes returned, but the 7 logical reads touched only 3 blocks
410        // (0,1,2) → at most 3 physical fetches, not 7.
411        assert!(
412            counting.requests() <= 3,
413            "physical fetches: {}",
414            counting.requests()
415        );
416
417        // a repeat read is fully cached → no new fetch
418        let before = counting.requests();
419        assert_eq!(r.read_at(200, 16).unwrap(), data[200..216]);
420        assert_eq!(counting.requests(), before);
421    }
422
423    #[test]
424    fn read_many_fetches_missing_blocks_once() {
425        let data: Vec<u8> = (0..200_000u32).map(|i| (i * 7) as u8).collect();
426        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
427        let r = BlockCacheReader::new(counting.clone(), 32 * 1024);
428        let ranges = [(0u64, 8u64), (40_000, 16), (40_050, 16), (130_000, 64)];
429        let out = r.read_many(&ranges).unwrap();
430        for (&(o, l), got) in ranges.iter().zip(&out) {
431            assert_eq!(got, &data[o as usize..(o + l) as usize]);
432        }
433        // 4 ranges over blocks {0, 1, 4} → ≤ 3 physical fetches.
434        assert!(
435            counting.requests() <= 3,
436            "physical: {}",
437            counting.requests()
438        );
439    }
440
441    /// A slow reader that counts its reads.
442    struct Slow(Vec<u8>, std::sync::atomic::AtomicUsize);
443    impl RangeReader for Slow {
444        fn len(&self) -> u64 {
445            self.0.len() as u64
446        }
447        fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
448            self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
449            std::thread::sleep(std::time::Duration::from_millis(30));
450            Ok(self.0[offset as usize..(offset + len) as usize].to_vec())
451        }
452    }
453
454    /// Readers that want the same block at the same time fetch it once: the
455    /// others wait for it to land (a parallel open walks two containers at
456    /// once, and both used to fetch, and count, the next block).
457    #[test]
458    fn concurrent_readers_fetch_a_block_once() {
459        let r = BlockCacheReader::new(Slow(vec![3u8; 100_000], Default::default()), 4096);
460        std::thread::scope(|s| {
461            for _ in 0..6 {
462                s.spawn(|| assert_eq!(r.read_at(5000, 100).unwrap(), vec![3u8; 100]));
463            }
464        });
465        assert_eq!(r.inner.1.load(std::sync::atomic::Ordering::SeqCst), 1);
466    }
467
468    /// A reader that answers every range one byte short (audit fix 9).
469    struct Short(Vec<u8>);
470    impl RangeReader for Short {
471        fn len(&self) -> u64 {
472            self.0.len() as u64
473        }
474        fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
475            let end = (offset + len).saturating_sub(1) as usize;
476            Ok(self.0[offset as usize..end].to_vec())
477        }
478    }
479
480    /// A short blob from the inner reader is an error, never a short block
481    /// cached and handed out as if it were the range.
482    #[test]
483    fn a_short_blob_is_refused_not_cached() {
484        let r = BlockCacheReader::new(Short(vec![9u8; 10_000]), 4096);
485        let err = r.read_at(100, 50).unwrap_err();
486        assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof, "{err}");
487        assert!(r.read_many(&[(0, 10), (5000, 10)]).is_err());
488    }
489
490    #[test]
491    fn out_of_bounds_errors() {
492        let data = vec![0u8; 1000];
493        let r = BlockCacheReader::new(SliceReader::new(&data), 8192);
494        assert!(r.read_at(990, 20).is_err());
495    }
496
497    /// A sweep far wider than the cap must stay under the cap after every read
498    /// while still returning exact bytes — the unbounded-growth regression.
499    #[test]
500    fn eviction_caps_resident_bytes_and_keeps_reads_exact() {
501        let data: Vec<u8> = (0..1_000_000u32).map(|i| (i * 31) as u8).collect();
502        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
503        // 64 KiB cap over 8 KiB blocks = at most 8 resident blocks.
504        let cap = 64 * 1024;
505        let r = BlockCacheReader::new(counting.clone(), 8192).with_cache_cap(cap);
506        for off in (0..1_000_000u64 - 64).step_by(37_777) {
507            assert_eq!(
508                r.read_at(off, 64).unwrap(),
509                data[off as usize..off as usize + 64]
510            );
511            assert!(
512                r.cached_bytes() <= cap,
513                "resident {} > cap {cap}",
514                r.cached_bytes()
515            );
516        }
517    }
518
519    /// Eviction is least-recently-used: re-touching a block protects it, the
520    /// stalest block goes first, and an evicted block re-fetches exactly once.
521    #[test]
522    fn eviction_is_lru() {
523        let data: Vec<u8> = (0..64 * 1024u32).map(|i| i as u8).collect();
524        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
525        // Cap = exactly 2 blocks of 8 KiB (as charged).
526        let two = 2 * charge(&Arc::from(vec![0u8; 8192]));
527        let r = BlockCacheReader::new(counting.clone(), 8192).with_cache_cap(two);
528        let block = |i: u64| i * 8192;
529        r.read_at(block(0), 16).unwrap(); // cache {0}
530        r.read_at(block(1), 16).unwrap(); // cache {0,1}
531        r.read_at(block(0), 16).unwrap(); // touch 0 → 1 is now the LRU
532
533        let before = counting.requests();
534        r.read_at(block(2), 16).unwrap(); // fetches 2, evicts 1, keeps 0
535        assert_eq!(counting.requests(), before + 1);
536
537        let before = counting.requests();
538        r.read_at(block(0), 16).unwrap(); // still resident → no fetch
539        assert_eq!(
540            counting.requests(),
541            before,
542            "recently-touched block evicted"
543        );
544
545        let before = counting.requests();
546        r.read_at(block(1), 16).unwrap(); // was evicted → exactly one refetch
547        assert_eq!(counting.requests(), before + 1);
548    }
549
550    /// One read spanning more blocks than the cap allows must still return the
551    /// exact bytes (blocks are resident while the read assembles) and the cache
552    /// must be trimmed back under the cap immediately after.
553    #[test]
554    fn request_larger_than_cap_reads_exactly_then_trims() {
555        let data: Vec<u8> = (0..256 * 1024u32).map(|i| (i * 7) as u8).collect();
556        let cap = 16 * 1024;
557        let r = BlockCacheReader::new(SliceReader::new(&data), 8192).with_cache_cap(cap);
558        let (off, len) = (1000usize, 96 * 1024usize); // 6× the cap
559        let out = r.read_at(off as u64, len as u64).unwrap();
560        assert_eq!(out, &data[off..off + len]);
561        assert!(
562            r.cached_bytes() <= cap,
563            "resident {} > cap {cap} after the read",
564            r.cached_bytes()
565        );
566    }
567}