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};
29use std::sync::{Arc, 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}
86
87pub struct BlockCacheReader<R> {
88    inner: R,
89    block: u64,
90    len: u64,
91    cap: u64,
92    cache: Mutex<CacheState>,
93}
94
95impl<R: RangeReader> BlockCacheReader<R> {
96    /// Wrap `inner`, fetching `block`-aligned blocks (clamped to ≥ 4 KiB) and
97    /// keeping at most [`DEFAULT_CACHE_CAP`] bytes of them resident.
98    pub fn new(inner: R, block: u64) -> Self {
99        let len = inner.len();
100        Self {
101            inner,
102            block: block.max(4096),
103            len,
104            cap: DEFAULT_CACHE_CAP,
105            cache: Mutex::new(CacheState {
106                map: HashMap::new(),
107                used: 0,
108                tick: 0,
109            }),
110        }
111    }
112
113    /// Override the resident-byte cap. The cap is enforced *between* reads: a
114    /// single read spanning more than `cap` bytes of blocks is still served
115    /// exactly (its blocks are resident while it assembles) and the cache is
116    /// trimmed back under the cap right after. `u64::MAX` disables eviction.
117    pub fn with_cache_cap(mut self, cap: u64) -> Self {
118        self.cap = cap;
119        self
120    }
121
122    /// Bytes currently resident in the cache (for stats and tests).
123    pub fn cached_bytes(&self) -> u64 {
124        self.cache.lock().unwrap().used
125    }
126
127    fn bounds(&self, offset: u64, len: u64) -> std::io::Result<()> {
128        if offset.checked_add(len).is_none_or(|e| e > self.len) {
129            // Name the numbers: this fires when a read the file's own layout
130            // demands overruns the length the transport reported, and the two
131            // disagreeing values are the whole diagnosis (issue #95: a host
132            // advertised its gzip size, 58 MB, for a 71 MB file).
133            return Err(std::io::Error::new(
134                std::io::ErrorKind::UnexpectedEof,
135                format!(
136                    "range out of bounds: read {offset}+{len} exceeds the reported resource length {} — \
137                     the file's layout expects more bytes than the length probe found \
138                     (a host serving a compressed Content-Length can cause this)",
139                    self.len
140                ),
141            ));
142        }
143        Ok(())
144    }
145
146    /// Fetch and cache every block index in `want` that isn't resident, issuing
147    /// the missing ones as coalesced spans through `read_many`. Already-resident
148    /// wanted blocks get their recency stamp refreshed.
149    fn ensure(&self, want: &BTreeSet<u64>) -> std::io::Result<()> {
150        let missing: Vec<u64> = {
151            let mut st = self.cache.lock().unwrap();
152            st.tick += 1;
153            let tick = st.tick;
154            want.iter()
155                .copied()
156                .filter(|b| match st.map.get_mut(b) {
157                    Some(e) => {
158                        e.stamp = tick;
159                        false
160                    }
161                    None => true,
162                })
163                .collect()
164        };
165        if missing.is_empty() {
166            return Ok(());
167        }
168        // Coalesce consecutive block indices into one span each.
169        let mut spans: Vec<(u64, u64)> = Vec::new();
170        let mut runs: Vec<(u64, u64)> = Vec::new();
171        let mut i = 0;
172        while i < missing.len() {
173            let first = missing[i];
174            let mut last = first;
175            let mut j = i + 1;
176            while j < missing.len() && missing[j] == last + 1 {
177                last = missing[j];
178                j += 1;
179            }
180            let off = first * self.block;
181            let end = ((last + 1) * self.block).min(self.len);
182            spans.push((off, end - off));
183            runs.push((first, last));
184            i = j;
185        }
186        let blobs = self.inner.read_many(&spans)?;
187        if blobs.len() != spans.len() {
188            return Err(std::io::Error::other("block fetch returned wrong count"));
189        }
190        let mut st = self.cache.lock().unwrap();
191        st.tick += 1;
192        let tick = st.tick;
193        for (&(first, last), blob) in runs.iter().zip(blobs.into_iter()) {
194            let span_start = first * self.block;
195            for b in first..=last {
196                let lo = (b * self.block - span_start) as usize;
197                let hi = ((((b + 1) * self.block).min(self.len)) - span_start) as usize;
198                let hi = hi.min(blob.len());
199                let lo = lo.min(hi);
200                let data: Arc<[u8]> = Arc::from(&blob[lo..hi]);
201                st.used += charge(&data);
202                if let Some(old) = st.map.insert(b, CacheEntry { data, stamp: tick }) {
203                    st.used -= charge(&old.data); // concurrent double-fetch
204                }
205            }
206        }
207        Ok(())
208    }
209
210    /// Evict least-recently-touched blocks until the resident total fits the
211    /// cap. Called after a read has assembled its bytes, so nothing in flight
212    /// depends on what gets dropped.
213    ///
214    /// Once over the cap it evicts down to 7/8 of it, not just under it:
215    /// finding the least-recent blocks sorts the whole map, and a full cache
216    /// (every read of a long export) used to pay that sort on every single
217    /// read. The slack amortizes one sort over ~1/8 of the cache's turnover
218    /// (only once the cache holds 32+ blocks, so a tiny cache stays exact LRU).
219    fn trim(&self) {
220        let mut st = self.cache.lock().unwrap();
221        if st.used <= self.cap {
222            return;
223        }
224        let slack = if self.cap / 8 >= 4 * self.block {
225            self.cap / 8
226        } else {
227            0
228        };
229        let target = self.cap - slack;
230        let mut order: Vec<(u64, u64)> = st.map.iter().map(|(&b, e)| (e.stamp, b)).collect();
231        order.sort_unstable();
232        for (_, b) in order {
233            if st.used <= target {
234                break;
235            }
236            if let Some(e) = st.map.remove(&b) {
237                st.used -= charge(&e.data);
238            }
239        }
240    }
241
242    /// Copy `[offset, offset+len)` out of the cache. The needed blocks are
243    /// cloned out under one short lock; a block missing here (evicted by a
244    /// concurrent reader's trim between `ensure` and this call) is re-read
245    /// directly from the inner reader — correctness never depends on residency.
246    fn assemble(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
247        let first = offset / self.block;
248        let last = (offset + len - 1) / self.block;
249        let resident: Vec<Option<Arc<[u8]>>> = {
250            let st = self.cache.lock().unwrap();
251            (first..=last)
252                .map(|b| st.map.get(&b).map(|e| e.data.clone()))
253                .collect()
254        };
255        let mut out = Vec::with_capacity(len as usize);
256        let mut pos = offset;
257        let end = offset + len;
258        while pos < end {
259            let b = pos / self.block;
260            let block_start = b * self.block;
261            let within = (pos - block_start) as usize;
262            let fetched: Vec<u8>;
263            let blk: &[u8] = match &resident[(b - first) as usize] {
264                Some(data) => data,
265                None => {
266                    let blen = ((b + 1) * self.block).min(self.len) - block_start;
267                    fetched = self.inner.read_at(block_start, blen)?;
268                    &fetched
269                }
270            };
271            let take = ((end - pos) as usize).min(blk.len().saturating_sub(within));
272            if take == 0 {
273                break;
274            }
275            out.extend_from_slice(&blk[within..within + take]);
276            pos += take as u64;
277        }
278        Ok(out)
279    }
280}
281
282impl<R: RangeReader> RangeReader for BlockCacheReader<R> {
283    fn len(&self) -> u64 {
284        self.len
285    }
286
287    fn concurrency(&self) -> usize {
288        self.inner.concurrency()
289    }
290
291    fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
292        if len == 0 {
293            return Ok(Vec::new());
294        }
295        self.bounds(offset, len)?;
296        let want: BTreeSet<u64> = (offset / self.block..=(offset + len - 1) / self.block).collect();
297        self.ensure(&want)?;
298        let out = self.assemble(offset, len)?;
299        self.trim();
300        Ok(out)
301    }
302
303    fn read_many(&self, ranges: &[(u64, u64)]) -> std::io::Result<Vec<Vec<u8>>> {
304        let mut want = BTreeSet::new();
305        for &(o, l) in ranges {
306            if l == 0 {
307                continue;
308            }
309            self.bounds(o, l)?;
310            for b in o / self.block..=(o + l - 1) / self.block {
311                want.insert(b);
312            }
313        }
314        self.ensure(&want)?;
315        let out = ranges
316            .iter()
317            .map(|&(o, l)| {
318                if l == 0 {
319                    Ok(Vec::new())
320                } else {
321                    self.assemble(o, l)
322                }
323            })
324            .collect::<std::io::Result<Vec<_>>>()?;
325        self.trim();
326        Ok(out)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::reader::{CountingReader, SliceReader};
334
335    #[test]
336    fn caches_blocks_and_serves_exact_bytes() {
337        let data: Vec<u8> = (0..100_000u32).map(|i| i as u8).collect();
338        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
339        let r = BlockCacheReader::new(counting.clone(), 16 * 1024);
340
341        // many small scattered reads inside the first few blocks
342        for off in [10u64, 200, 5000, 16_500, 17_000, 33_000, 33_100] {
343            assert_eq!(
344                r.read_at(off, 32).unwrap(),
345                data[off as usize..off as usize + 32]
346            );
347        }
348        // exact bytes returned, but the 7 logical reads touched only 3 blocks
349        // (0,1,2) → at most 3 physical fetches, not 7.
350        assert!(
351            counting.requests() <= 3,
352            "physical fetches: {}",
353            counting.requests()
354        );
355
356        // a repeat read is fully cached → no new fetch
357        let before = counting.requests();
358        assert_eq!(r.read_at(200, 16).unwrap(), data[200..216]);
359        assert_eq!(counting.requests(), before);
360    }
361
362    #[test]
363    fn read_many_fetches_missing_blocks_once() {
364        let data: Vec<u8> = (0..200_000u32).map(|i| (i * 7) as u8).collect();
365        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
366        let r = BlockCacheReader::new(counting.clone(), 32 * 1024);
367        let ranges = [(0u64, 8u64), (40_000, 16), (40_050, 16), (130_000, 64)];
368        let out = r.read_many(&ranges).unwrap();
369        for (&(o, l), got) in ranges.iter().zip(&out) {
370            assert_eq!(got, &data[o as usize..(o + l) as usize]);
371        }
372        // 4 ranges over blocks {0, 1, 4} → ≤ 3 physical fetches.
373        assert!(
374            counting.requests() <= 3,
375            "physical: {}",
376            counting.requests()
377        );
378    }
379
380    #[test]
381    fn out_of_bounds_errors() {
382        let data = vec![0u8; 1000];
383        let r = BlockCacheReader::new(SliceReader::new(&data), 8192);
384        assert!(r.read_at(990, 20).is_err());
385    }
386
387    /// A sweep far wider than the cap must stay under the cap after every read
388    /// while still returning exact bytes — the unbounded-growth regression.
389    #[test]
390    fn eviction_caps_resident_bytes_and_keeps_reads_exact() {
391        let data: Vec<u8> = (0..1_000_000u32).map(|i| (i * 31) as u8).collect();
392        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
393        // 64 KiB cap over 8 KiB blocks = at most 8 resident blocks.
394        let cap = 64 * 1024;
395        let r = BlockCacheReader::new(counting.clone(), 8192).with_cache_cap(cap);
396        for off in (0..1_000_000u64 - 64).step_by(37_777) {
397            assert_eq!(
398                r.read_at(off, 64).unwrap(),
399                data[off as usize..off as usize + 64]
400            );
401            assert!(
402                r.cached_bytes() <= cap,
403                "resident {} > cap {cap}",
404                r.cached_bytes()
405            );
406        }
407    }
408
409    /// Eviction is least-recently-used: re-touching a block protects it, the
410    /// stalest block goes first, and an evicted block re-fetches exactly once.
411    #[test]
412    fn eviction_is_lru() {
413        let data: Vec<u8> = (0..64 * 1024u32).map(|i| i as u8).collect();
414        let counting = Arc::new(CountingReader::new(SliceReader::new(&data)));
415        // Cap = exactly 2 blocks of 8 KiB (as charged).
416        let two = 2 * charge(&Arc::from(vec![0u8; 8192]));
417        let r = BlockCacheReader::new(counting.clone(), 8192).with_cache_cap(two);
418        let block = |i: u64| i * 8192;
419        r.read_at(block(0), 16).unwrap(); // cache {0}
420        r.read_at(block(1), 16).unwrap(); // cache {0,1}
421        r.read_at(block(0), 16).unwrap(); // touch 0 → 1 is now the LRU
422
423        let before = counting.requests();
424        r.read_at(block(2), 16).unwrap(); // fetches 2, evicts 1, keeps 0
425        assert_eq!(counting.requests(), before + 1);
426
427        let before = counting.requests();
428        r.read_at(block(0), 16).unwrap(); // still resident → no fetch
429        assert_eq!(
430            counting.requests(),
431            before,
432            "recently-touched block evicted"
433        );
434
435        let before = counting.requests();
436        r.read_at(block(1), 16).unwrap(); // was evicted → exactly one refetch
437        assert_eq!(counting.requests(), before + 1);
438    }
439
440    /// One read spanning more blocks than the cap allows must still return the
441    /// exact bytes (blocks are resident while the read assembles) and the cache
442    /// must be trimmed back under the cap immediately after.
443    #[test]
444    fn request_larger_than_cap_reads_exactly_then_trims() {
445        let data: Vec<u8> = (0..256 * 1024u32).map(|i| (i * 7) as u8).collect();
446        let cap = 16 * 1024;
447        let r = BlockCacheReader::new(SliceReader::new(&data), 8192).with_cache_cap(cap);
448        let (off, len) = (1000usize, 96 * 1024usize); // 6× the cap
449        let out = r.read_at(off as u64, len as u64).unwrap();
450        assert_eq!(out, &data[off..off + len]);
451        assert!(
452            r.cached_bytes() <= cap,
453            "resident {} > cap {cap} after the read",
454            r.cached_bytes()
455        );
456    }
457}