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}