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