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