hdf5_pure/source.rs
1//! Random-access byte sources for the reader: the [`Source`] trait and its backends.
2//!
3//! [`BytesSource`] reads from a buffer that holds the whole file, and [`ReadSeekSource`] from a
4//! reader that seeks. [`MetadataCachingSource`] wraps either with a bounded cache of the metadata
5//! reads and passes a dataset's payload through to the inner source.
6
7#[cfg(not(feature = "std"))]
8use alloc::{vec, vec::Vec};
9
10#[cfg(feature = "std")]
11use std::collections::BTreeMap;
12
13use crate::address::{BaseAddress, StoredAddress};
14use crate::convert::Narrow;
15use crate::error::FormatError;
16
17/// Default maximum size of one entry admitted to a streaming metadata cache.
18pub const DEFAULT_METADATA_CACHE_MAX_ENTRY_BYTES: usize = 64 * 1024;
19
20/// Initial metadata-cache settings for streaming file access.
21///
22/// This is the `hdf5-pure` counterpart to the memory-budget portion of HDF5's
23/// `H5Pset_mdc_config`: it bounds the bytes retained for parsed metadata reads
24/// while a file is opened through [`crate::File::open_streaming_with_options`].
25/// Raw dataset payload reads use `Source::read_exact_at` and are not
26/// admitted to this cache.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct MetadataCacheConfig {
29 max_bytes: usize,
30 max_entry_bytes: usize,
31}
32
33impl MetadataCacheConfig {
34 /// Create a metadata cache with the given total byte budget.
35 ///
36 /// Individual cached reads are capped at
37 /// `DEFAULT_METADATA_CACHE_MAX_ENTRY_BYTES` (64 KiB) by default so one large
38 /// heap or index block cannot monopolize the cache. Use
39 /// [`with_max_entry_bytes`](Self::with_max_entry_bytes) to change that.
40 pub const fn new(max_bytes: usize) -> Self {
41 let max_entry_bytes = if max_bytes < DEFAULT_METADATA_CACHE_MAX_ENTRY_BYTES {
42 max_bytes
43 } else {
44 DEFAULT_METADATA_CACHE_MAX_ENTRY_BYTES
45 };
46 Self {
47 max_bytes,
48 max_entry_bytes,
49 }
50 }
51
52 /// Disable metadata read caching.
53 pub const fn disabled() -> Self {
54 Self {
55 max_bytes: 0,
56 max_entry_bytes: 0,
57 }
58 }
59
60 /// Set the maximum size of a single metadata read admitted to the cache.
61 pub const fn with_max_entry_bytes(mut self, max_entry_bytes: usize) -> Self {
62 self.max_entry_bytes = max_entry_bytes;
63 self
64 }
65
66 /// Return the total metadata-cache byte budget.
67 pub const fn max_bytes(&self) -> usize {
68 self.max_bytes
69 }
70
71 /// Return the maximum size of one cached metadata entry.
72 pub const fn max_entry_bytes(&self) -> usize {
73 self.max_entry_bytes
74 }
75
76 /// Whether metadata read caching is enabled.
77 pub const fn is_enabled(&self) -> bool {
78 self.max_bytes > 0 && self.max_entry_bytes > 0
79 }
80}
81
82impl Default for MetadataCacheConfig {
83 fn default() -> Self {
84 Self::disabled()
85 }
86}
87
88/// What a file's metadata cache has done, and what it is holding.
89///
90/// Returned by [`crate::File::metadata_cache_stats`]. This is the `hdf5-pure`
91/// counterpart to HDF5's `H5Fget_mdc_hit_rate` and `H5Fget_mdc_size`:
92/// [`entries`](Self::entries) and [`bytes`](Self::bytes) are a point-in-time
93/// view of occupancy, and the counters are cumulative since the file was
94/// opened or since the last
95/// [`reset_metadata_cache_stats`](crate::File::reset_metadata_cache_stats).
96///
97/// The reason to look is that [`MetadataCacheConfig`] is a budget chosen before
98/// a single read has happened, and nothing else reports whether it was the
99/// right one:
100///
101/// - [`hit_rate`](Self::hit_rate) says whether the cache is earning its memory.
102/// - [`evictions`](Self::evictions) says whether the budget is the binding
103/// constraint. A hit rate below expectations with no evictions is not a
104/// budget problem, and raising it will not help.
105/// - [`oversize_reads`](Self::oversize_reads) says whether
106/// [`max_entry_bytes`](MetadataCacheConfig::max_entry_bytes) is turning reads
107/// away before they reach the cache at all.
108/// - [`invalidations`](Self::invalidations) says how much of the cache a
109/// read-write session is throwing away with its own writes.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub struct MetadataCacheStats {
112 hits: u64,
113 misses: u64,
114 oversize_reads: u64,
115 evictions: u64,
116 invalidations: u64,
117 entries: usize,
118 bytes: usize,
119}
120
121impl MetadataCacheStats {
122 /// Metadata reads served from the cache.
123 pub const fn hits(&self) -> u64 {
124 self.hits
125 }
126
127 /// Metadata reads eligible for the cache that were not in it.
128 pub const fn misses(&self) -> u64 {
129 self.misses
130 }
131
132 /// Metadata reads that bypassed the cache because they exceed
133 /// [`MetadataCacheConfig::max_entry_bytes`] (or the whole budget).
134 ///
135 /// These are counted apart from [`misses`](Self::misses) rather than folded
136 /// into them: the cache was never offered the read, so charging it as a miss
137 /// would report a failure at work it could not have done. They still show up
138 /// in [`reads`](Self::reads).
139 pub const fn oversize_reads(&self) -> u64 {
140 self.oversize_reads
141 }
142
143 /// Entries dropped to stay inside [`MetadataCacheConfig::max_bytes`].
144 pub const fn evictions(&self) -> u64 {
145 self.evictions
146 }
147
148 /// Entries dropped because an in-place write overlapped them.
149 ///
150 /// Only a read-write session invalidates; this stays zero on a read-only
151 /// open. Invalidations approaching [`misses`](Self::misses) mean the session
152 /// is rewriting the metadata it is caching, and a larger budget will not
153 /// change that.
154 pub const fn invalidations(&self) -> u64 {
155 self.invalidations
156 }
157
158 /// Entries currently held.
159 ///
160 /// Against [`bytes`](Self::bytes) this is the mean entry size, which is what
161 /// says whether a few large reads are spending the budget; pair it with
162 /// [`oversize_reads`](Self::oversize_reads) to see the ones already refused.
163 pub const fn entries(&self) -> usize {
164 self.entries
165 }
166
167 /// Bytes currently held, to compare against
168 /// [`MetadataCacheConfig::max_bytes`].
169 pub const fn bytes(&self) -> usize {
170 self.bytes
171 }
172
173 /// Every metadata read through this source: hits, misses, and reads too
174 /// large to admit.
175 ///
176 /// The last of those three is not in [`hit_rate`](Self::hit_rate)'s
177 /// denominator, so `hits() / reads()` is a different figure and a lower one.
178 pub const fn reads(&self) -> u64 {
179 self.hits
180 .saturating_add(self.misses)
181 .saturating_add(self.oversize_reads)
182 }
183
184 /// The fraction of *eligible* metadata reads served from the cache, or
185 /// `None` before any eligible read has happened.
186 ///
187 /// `None` rather than C's `0.0`, which `H5Fget_mdc_hit_rate` also returns
188 /// for a cache that has missed every access: the two mean opposite things to
189 /// a caller deciding whether to raise the budget, and only one of them is a
190 /// reason to.
191 pub fn hit_rate(&self) -> Option<f64> {
192 let eligible = self.hits.saturating_add(self.misses);
193 if eligible == 0 {
194 return None;
195 }
196 #[expect(
197 clippy::cast_precision_loss,
198 reason = "a hit rate is a ratio; f64 holds these counts exactly far past any \
199 read count a process will reach"
200 )]
201 Some(self.hits as f64 / eligible as f64)
202 }
203}
204
205/// A random-access, read-only source of the bytes of an HDF5 file.
206///
207/// Offsets are `u64` (HDF5's native address width); lengths of individual reads
208/// are `usize` (they must fit in a caller-provided buffer). Implementations must
209/// either fill the whole request or return an error — a short read is always an
210/// error, never silently truncated.
211///
212/// # Implementing one
213///
214/// Two methods carry the whole trait: [`len`](Source::len) and
215/// [`read_at`](Source::read_at). The rest are conveniences with default bodies,
216/// and every method added later will have one too, so an implementation written
217/// today keeps compiling.
218///
219/// The reader treats a source as an immutable file for as long as it holds one:
220/// [`len`](Source::len) is expected to stay put and to be true — the reader
221/// bounds allocations with it — and bytes already read are expected to read
222/// back the same. A source over something that grows underneath it — a file a
223/// writer is appending to — is what [`File::open_swmr`](crate::File::open_swmr)
224/// is for instead. `read_at` takes `&self` so a `File` can be shared, so an
225/// implementation over a mutable handle owns its own synchronisation, the way
226/// [`ReadSeekSource`] wraps its reader in a mutex.
227///
228/// Leave the last three defaulted unless you mean to cache.
229/// [`read_metadata_at`](Source::read_metadata_at),
230/// [`metadata_cache_stats`](Source::metadata_cache_stats) and
231/// [`reset_metadata_cache_stats`](Source::reset_metadata_cache_stats) are the
232/// seam this crate's own bounded metadata cache hangs on, and they come as a
233/// set: overriding the first without the other two reports *no* cache where
234/// there is a full one. To have metadata reads cached, ask for it with a
235/// [`MetadataCacheConfig`] through
236/// [`File::from_source_with_options`](crate::File::from_source_with_options),
237/// which wraps the source in an implementation of all three.
238#[expect(
239 clippy::len_without_is_empty,
240 reason = "`len` here is a file's byte length, not a container's count — the shape of \
241 `std::fs::Metadata::len`, which ships without an `is_empty` for the same \
242 reason. An HDF5 file is never empty (the signature alone is eight bytes), so \
243 an `is_empty` on this trait would be a public method with no caller, and one \
244 an implementation could contradict its own `len` with"
245)]
246pub trait Source {
247 /// Total number of bytes the source can supply.
248 ///
249 /// Report the true length: the reader bounds allocations with it. Metadata
250 /// lengths come out of the file being read, and
251 /// [`read_exact_at`](Source::read_exact_at) rejects one that runs past the
252 /// end *before* reserving a buffer for it, so a malformed file cannot name
253 /// a multi-gigabyte read and have it reserved. A `len` larger than what the
254 /// source can actually serve passes that check and the reservation happens,
255 /// which leaves the file choosing the size of an allocation. A length that
256 /// arrives over a channel the caller does not control — a `Content-Length`
257 /// header, a size a host reports — should be held to what the source can
258 /// really serve before it is reported here.
259 fn len(&self) -> u64;
260
261 /// Read exactly `buf.len()` bytes starting at absolute offset `offset`,
262 /// filling `buf`.
263 ///
264 /// Returns [`FormatError::UnexpectedEof`] if fewer than `buf.len()` bytes are
265 /// available at `offset`, [`FormatError::OffsetOverflow`] if
266 /// `offset + buf.len()` overflows, [`FormatError::ValueTooLargeForPlatform`]
267 /// if `offset` does not fit this platform's `usize` (for in-memory
268 /// backends), or [`FormatError::Source`] for a backend I/O failure.
269 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError>;
270
271 /// Read `len` bytes starting at `offset` into a freshly allocated `Vec`.
272 ///
273 /// Convenience wrapper over [`read_at`](Source::read_at) for callers that
274 /// want an owned buffer; the lazy backends keep no more than this resident.
275 ///
276 /// The request is bounds-checked against [`len`](Source::len) *before* the
277 /// buffer is allocated. The metadata parsers feed `len` values straight from
278 /// the file (a chunk-0 body size, a continuation-block length, a heap object
279 /// size), so a malformed file could otherwise name a multi-gigabyte length
280 /// and make this reserve `vec![0u8; len]` up front only for the read to fail
281 /// EOF anyway — a cheap denial of service. Rejecting an out-of-range request
282 /// before allocating avoids that; the error returned is identical to the one
283 /// the underlying [`read_at`](Source::read_at) would have produced.
284 fn read_exact_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
285 let end = offset
286 .checked_add(len as u64)
287 .ok_or(FormatError::OffsetOverflow {
288 offset,
289 length: len as u64,
290 })?;
291 if end > self.len() {
292 return Err(FormatError::UnexpectedEof {
293 expected: end.to_usize().unwrap_or(usize::MAX),
294 available: self.len().to_usize().unwrap_or(usize::MAX),
295 });
296 }
297 let mut buf = vec![0u8; len];
298 self.read_at(offset, &mut buf)?;
299 Ok(buf)
300 }
301
302 /// Read metadata bytes, allowing source implementations to apply a bounded
303 /// metadata cache.
304 ///
305 /// The default implementation performs an uncached exact read. Raw dataset
306 /// payload readers intentionally call [`read_exact_at`](Self::read_exact_at)
307 /// instead, so a metadata cache does not retain user data chunks.
308 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
309 self.read_exact_at(offset, len)
310 }
311
312 /// What the metadata cache in front of this source has done, or `None` when
313 /// it has none.
314 ///
315 /// The observation half of [`read_metadata_at`](Self::read_metadata_at):
316 /// that method exists so an implementation *may* cache a metadata read, and
317 /// this one reports whether doing so paid. The default is `None`, since most
318 /// sources cache nothing.
319 ///
320 /// A wrapper that forwards `read_metadata_at` to an inner source must
321 /// forward this too. Leaving it defaulted would have it report *no* cache
322 /// where there is a full one, which reads as "caching is off" rather than as
323 /// the missing forward it is.
324 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
325 None
326 }
327
328 /// Zero that cache's cumulative counters, leaving its contents alone.
329 ///
330 /// The counterpart of HDF5's `H5Freset_mdc_hit_rate_stats`, for measuring
331 /// one phase of a program rather than a whole run. A no-op where there is no
332 /// cache, and it evicts nothing: occupancy is not a counter.
333 fn reset_metadata_cache_stats(&self) {}
334}
335
336// Forward `Source` through references and boxes so `&S`, `&dyn Source`,
337// and `Box<dyn Source>` are all usable wherever an `S: Source` is.
338impl<S: Source + ?Sized> Source for &S {
339 fn len(&self) -> u64 {
340 (**self).len()
341 }
342 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
343 (**self).read_at(offset, buf)
344 }
345
346 fn read_exact_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
347 (**self).read_exact_at(offset, len)
348 }
349
350 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
351 (**self).read_metadata_at(offset, len)
352 }
353
354 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
355 (**self).metadata_cache_stats()
356 }
357
358 fn reset_metadata_cache_stats(&self) {
359 (**self).reset_metadata_cache_stats();
360 }
361}
362
363#[cfg(feature = "std")]
364impl<S: Source + ?Sized> Source for std::boxed::Box<S> {
365 fn len(&self) -> u64 {
366 (**self).len()
367 }
368 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
369 (**self).read_at(offset, buf)
370 }
371
372 fn read_exact_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
373 (**self).read_exact_at(offset, len)
374 }
375
376 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
377 (**self).read_metadata_at(offset, len)
378 }
379
380 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
381 (**self).metadata_cache_stats()
382 }
383
384 fn reset_metadata_cache_stats(&self) {
385 (**self).reset_metadata_cache_stats();
386 }
387}
388
389// ---------------------------------------------------------------------------
390// Caller-supplied sources
391// ---------------------------------------------------------------------------
392
393/// Holds a caller-supplied [`Source`] to the part of the trait's contract the
394/// parsers above it cannot check for themselves.
395///
396/// [`Source::read_exact_at`] and [`Source::read_metadata_at`] have default
397/// bodies that return exactly the bytes asked for, and every source this crate
398/// builds either uses those bodies or forwards to one that does. An
399/// implementation from outside may override them, and has a reason to — a
400/// remote source that batches or coalesces its reads is the case
401/// [`crate::File::from_source`] exists for. The parsers then index the returned
402/// buffer at offsets derived from the length they *requested*, so a buffer that
403/// comes back short is a slice panic inside a header parser, blaming a file
404/// format for what the source did.
405///
406/// One length comparison per read turns that into [`FormatError::Source`],
407/// which names the source instead. This wraps only what a caller hands in;
408/// the crate's own sources have no override to check.
409#[cfg(feature = "std")]
410pub(crate) struct ValidatedSource<S>(S);
411
412#[cfg(feature = "std")]
413impl<S> ValidatedSource<S> {
414 pub(crate) fn new(inner: S) -> Self {
415 Self(inner)
416 }
417
418 /// Refuse a buffer whose length is not the one that was asked for.
419 fn check(offset: u64, len: usize, bytes: Vec<u8>) -> Result<Vec<u8>, FormatError> {
420 if bytes.len() == len {
421 return Ok(bytes);
422 }
423 Err(FormatError::Source(std::format!(
424 "the source returned {} bytes for a {len}-byte read at offset {offset}",
425 bytes.len()
426 )))
427 }
428}
429
430#[cfg(feature = "std")]
431impl<S: Source> Source for ValidatedSource<S> {
432 fn len(&self) -> u64 {
433 self.0.len()
434 }
435
436 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
437 self.0.read_at(offset, buf)
438 }
439
440 fn read_exact_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
441 Self::check(offset, len, self.0.read_exact_at(offset, len)?)
442 }
443
444 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
445 Self::check(offset, len, self.0.read_metadata_at(offset, len)?)
446 }
447
448 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
449 self.0.metadata_cache_stats()
450 }
451
452 fn reset_metadata_cache_stats(&self) {
453 self.0.reset_metadata_cache_stats();
454 }
455}
456
457// ---------------------------------------------------------------------------
458// Base-relative view
459// ---------------------------------------------------------------------------
460
461/// A [`Source`] view shifted forward by a base address: every read at a
462/// base-relative `offset` is served from `inner` at `offset + base`.
463///
464/// Used wherever on-disk addresses are stored relative to the superblock's base
465/// address rather than absolutely — the data layout's contiguous-data, chunk-index,
466/// and chunk addresses on a file with a userblock, and the fractal-heap address in
467/// an Attribute Info message. Presenting this shifted view lets those relative
468/// addresses index it directly, exactly as an in-memory path slices the buffer at
469/// `base`. For a plain (base-0) file it is the identity.
470///
471/// `len`/`read_at` shift by the base; `read_metadata_at` forwards to the inner
472/// source at the *absolute* offset so the inner source's metadata cache is shared
473/// (a chunk-index walk on a streaming userblock file would otherwise re-read every
474/// node), while payload reads keep the default uncached `read_exact_at` so user
475/// data does not evict metadata.
476pub(crate) struct BaseOffsetSource<'a, S: Source + ?Sized> {
477 pub(crate) inner: &'a S,
478 pub(crate) base: BaseAddress,
479}
480
481/// A base-relative view of an in-memory file: `bytes` with its first `base` bytes
482/// (the userblock) cut off, so every address stored relative to the base address
483/// indexes it directly. The in-memory counterpart of [`BaseOffsetSource`], and the
484/// identity for a plain file.
485pub(crate) fn frame(bytes: &[u8], base: BaseAddress) -> Result<&[u8], FormatError> {
486 if base.is_zero() {
487 return Ok(bytes);
488 }
489 let start = base.get().to_usize()?;
490 bytes.get(start..).ok_or(FormatError::UnexpectedEof {
491 expected: start,
492 available: bytes.len(),
493 })
494}
495
496impl<S: Source + ?Sized> Source for BaseOffsetSource<'_, S> {
497 fn len(&self) -> u64 {
498 self.inner.len().saturating_sub(self.base.get())
499 }
500
501 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
502 self.inner
503 .read_at(self.base.absolute(StoredAddress::new(offset))?, buf)
504 }
505
506 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
507 self.inner
508 .read_metadata_at(self.base.absolute(StoredAddress::new(offset))?, len)
509 }
510
511 // The metadata reads above are the inner source's, so its cache is the one
512 // to report on. A base-relative view holds none of its own.
513 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
514 self.inner.metadata_cache_stats()
515 }
516
517 fn reset_metadata_cache_stats(&self) {
518 self.inner.reset_metadata_cache_stats();
519 }
520}
521
522// ---------------------------------------------------------------------------
523// In-memory backend
524// ---------------------------------------------------------------------------
525
526/// A [`Source`] over an in-memory byte buffer: anything that is
527/// `AsRef<[u8]>` (`Vec<u8>`, `&[u8]`, `Box<[u8]>`, `Arc<[u8]>`, …).
528///
529/// This is the always-available backend that mirrors the crate's current
530/// in-memory model, usable on WASM and `no_std`.
531#[derive(Debug, Clone, Copy)]
532pub struct BytesSource<T>(pub T);
533
534impl<T: AsRef<[u8]>> BytesSource<T> {
535 /// Wrap an in-memory byte buffer.
536 pub fn new(bytes: T) -> Self {
537 BytesSource(bytes)
538 }
539}
540
541impl<T: AsRef<[u8]>> Source for BytesSource<T> {
542 fn len(&self) -> u64 {
543 self.0.as_ref().len() as u64
544 }
545
546 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
547 let bytes = self.0.as_ref();
548 let start = offset.to_usize()?;
549 let end = start
550 .checked_add(buf.len())
551 .ok_or(FormatError::OffsetOverflow {
552 offset,
553 length: buf.len() as u64,
554 })?;
555 if end > bytes.len() {
556 return Err(FormatError::UnexpectedEof {
557 expected: end,
558 available: bytes.len(),
559 });
560 }
561 buf.copy_from_slice(&bytes[start..end]);
562 Ok(())
563 }
564}
565
566// ---------------------------------------------------------------------------
567// Metadata-caching wrapper (std)
568// ---------------------------------------------------------------------------
569
570#[cfg(feature = "std")]
571struct CachedMetadataRead {
572 bytes: Vec<u8>,
573 last_access: u64,
574}
575
576/// The bounded LRU store behind [`MetadataCachingSource`], also embedded
577/// directly by the mirrorless write image (`crate::image::HandleImage`), which
578/// must invalidate entries that overlap an in-place write.
579///
580/// # Why this is indexed rather than scanned (issue #367)
581///
582/// It held a `Vec` walked end to end by every operation, which made a *hit*
583/// cost O(entries) and put the budget's useful range at a few thousand of them.
584/// Measured against the positioned read a hit replaces: 9x faster at 64
585/// entries, 3.2x at 1,024, then 1.2x **slower** at 4,096 and 21.9x slower at
586/// 65,536. An 8 MiB budget, the figure `README.md` recommends, admits over
587/// 100,000 metadata-sized reads, so the knob documented as a way to make a file
588/// of many datasets read faster made one read about 30% slower.
589///
590/// Both maps below are therefore keyed, not searched, and the budget is a dial
591/// over its whole range rather than only below a cliff.
592#[cfg(feature = "std")]
593pub(crate) struct MetadataReadCache {
594 /// Entries by the `(offset, len)` the caller asked for. Two reads may share
595 /// an offset at different lengths, so the length is part of the key.
596 ///
597 /// Ordered by offset first, which is what lets
598 /// [`invalidate_overlapping`](Self::invalidate_overlapping) look at one
599 /// bounded key range instead of every entry.
600 entries: BTreeMap<(u64, usize), CachedMetadataRead>,
601 /// `last_access` -> the key stamped with it, one row per entry. Its first
602 /// row is the least recently used entry, which is what eviction wants.
603 by_access: BTreeMap<u64, (u64, usize)>,
604 current_bytes: usize,
605 tick: u64,
606 /// The longest `len` ever admitted, bounding how far *before* a given
607 /// offset an entry that overlaps it can start. It only grows, so the window
608 /// it gives can be wider than needed but never too narrow.
609 longest_entry: usize,
610 /// Cumulative counters, reported as [`MetadataCacheStats`].
611 hits: u64,
612 misses: u64,
613 oversize_reads: u64,
614 evictions: u64,
615 invalidations: u64,
616}
617
618#[cfg(feature = "std")]
619impl MetadataReadCache {
620 pub(crate) fn new() -> Self {
621 Self {
622 entries: BTreeMap::new(),
623 by_access: BTreeMap::new(),
624 current_bytes: 0,
625 tick: 0,
626 longest_entry: 0,
627 hits: 0,
628 misses: 0,
629 oversize_reads: 0,
630 evictions: 0,
631 invalidations: 0,
632 }
633 }
634
635 /// Take the cache's lock, treating a poisoned one as held rather than
636 /// panicking: a cache is a performance aid, and a reader that panicked
637 /// elsewhere leaves no invariant here for a later caller to trip over.
638 pub(crate) fn locked(lock: &std::sync::Mutex<Self>) -> std::sync::MutexGuard<'_, Self> {
639 lock.lock()
640 .unwrap_or_else(std::sync::PoisonError::into_inner)
641 }
642
643 /// Serve one [`Source::read_metadata_at`] through the cache behind `lock`,
644 /// falling back to `read` and recording what happened.
645 ///
646 /// Both call sites that have a metadata cache — [`MetadataCachingSource`]
647 /// and `crate::image::HandleImage` — go through here rather than each
648 /// repeating the admission rule and its five counters; they differ only in
649 /// what `read` does, which is why it is a closure.
650 ///
651 /// The lock is taken up to twice and never held across `read`. A metadata
652 /// read is file I/O, and serializing every one of them behind this mutex
653 /// would cost more than the cache saves.
654 pub(crate) fn read_through(
655 lock: &std::sync::Mutex<Self>,
656 config: MetadataCacheConfig,
657 offset: u64,
658 len: usize,
659 read: impl FnOnce() -> Result<Vec<u8>, FormatError>,
660 ) -> Result<Vec<u8>, FormatError> {
661 // A zero-length read is not a read of anything, and a disabled cache has
662 // no counters worth keeping; neither is worth a lock.
663 if len == 0 || !config.is_enabled() {
664 return read();
665 }
666 if len > config.max_entry_bytes() || len > config.max_bytes() {
667 Self::locked(lock).oversize_reads += 1;
668 return read();
669 }
670 if let Some(bytes) = Self::locked(lock).get(offset, len) {
671 return Ok(bytes);
672 }
673 let bytes = read()?;
674 Self::locked(lock).insert(offset, len, bytes.clone(), config.max_bytes());
675 Ok(bytes)
676 }
677
678 /// Snapshot the counters and the current occupancy.
679 pub(crate) fn stats(&self) -> MetadataCacheStats {
680 MetadataCacheStats {
681 hits: self.hits,
682 misses: self.misses,
683 oversize_reads: self.oversize_reads,
684 evictions: self.evictions,
685 invalidations: self.invalidations,
686 entries: self.entries.len(),
687 bytes: self.current_bytes,
688 }
689 }
690
691 /// Zero the counters, keeping every entry. Occupancy is a measurement of the
692 /// cache's contents rather than a tally of its history, so resetting the
693 /// history must not disturb it.
694 pub(crate) fn reset_stats(&mut self) {
695 self.hits = 0;
696 self.misses = 0;
697 self.oversize_reads = 0;
698 self.evictions = 0;
699 self.invalidations = 0;
700 }
701
702 /// Drop one entry and its access row together, keeping the two maps and the
703 /// byte total in step. Every removal goes through here for that reason.
704 fn remove(&mut self, key: (u64, usize)) {
705 if let Some(entry) = self.entries.remove(&key) {
706 self.by_access.remove(&entry.last_access);
707 self.current_bytes -= entry.bytes.len();
708 }
709 debug_assert_eq!(
710 self.entries.len(),
711 self.by_access.len(),
712 "every entry holds exactly one access row"
713 );
714 }
715
716 /// Drop every cached entry that overlaps `[offset, offset + len)`, so a
717 /// read after an in-place write never observes stale bytes.
718 pub(crate) fn invalidate_overlapping(&mut self, offset: u64, len: usize) {
719 if len == 0 {
720 return;
721 }
722 let end = offset.saturating_add(len as u64);
723 // An entry starting before this cannot reach `offset` at any length the
724 // cache has admitted, so the search starts here rather than at the map's
725 // first key. It is never above `end`, which is what `BTreeMap::range`
726 // requires of its bounds: it is at most `offset`, and `end` is at least
727 // `offset` even where the addition above saturates.
728 let first = offset.saturating_sub(self.longest_entry as u64);
729 let doomed: Vec<(u64, usize)> = self
730 .entries
731 .range((first, 0)..(end, 0))
732 // The range settles `entry_offset < end`; this settles the other
733 // half, that the entry reaches forward as far as `offset`.
734 .filter(|((entry_offset, entry_len), _)| {
735 entry_offset.saturating_add(*entry_len as u64) > offset
736 })
737 .map(|(key, _)| *key)
738 .collect();
739 self.invalidations += doomed.len() as u64;
740 for key in doomed {
741 self.remove(key);
742 }
743 }
744
745 pub(crate) fn get(&mut self, offset: u64, len: usize) -> Option<Vec<u8>> {
746 let key = (offset, len);
747 // One tick per cached read, so a `u64` outlasts any process that could
748 // run. The counter formerly wrapped, which would have inverted the very
749 // ordering it exists to record.
750 let tick = self.tick + 1;
751 let Some(entry) = self.entries.get_mut(&key) else {
752 self.misses += 1;
753 return None;
754 };
755 let previous = core::mem::replace(&mut entry.last_access, tick);
756 let bytes = entry.bytes.clone();
757 // Only past the lookup is this a hit, so only here does the clock move.
758 self.tick = tick;
759 self.hits += 1;
760 self.by_access.remove(&previous);
761 self.by_access.insert(tick, key);
762 Some(bytes)
763 }
764
765 pub(crate) fn insert(&mut self, offset: u64, len: usize, bytes: Vec<u8>, max_bytes: usize) {
766 if len == 0 || bytes.len() > max_bytes {
767 return;
768 }
769
770 let key = (offset, len);
771 // Re-reading a key replaces it. Removing first means the byte total and
772 // the access index never keep a row for the value being displaced.
773 self.remove(key);
774
775 self.tick += 1;
776 let tick = self.tick;
777 self.longest_entry = self.longest_entry.max(len);
778 self.current_bytes += bytes.len();
779 self.entries.insert(
780 key,
781 CachedMetadataRead {
782 bytes,
783 last_access: tick,
784 },
785 );
786 self.by_access.insert(tick, key);
787 debug_assert_eq!(
788 self.entries.len(),
789 self.by_access.len(),
790 "every entry holds exactly one access row"
791 );
792 self.evict_to_budget(max_bytes);
793 }
794
795 fn evict_to_budget(&mut self, max_bytes: usize) {
796 while self.current_bytes > max_bytes {
797 let Some((_, &key)) = self.by_access.first_key_value() else {
798 break;
799 };
800 // Counted here rather than in `remove`, which also serves
801 // invalidation and replacement. Only a drop the *budget* forced is
802 // an eviction, and that is the one that says to raise it.
803 self.evictions += 1;
804 self.remove(key);
805 }
806 }
807}
808
809/// A [`Source`] wrapper with a bounded cache for metadata reads.
810///
811/// The wrapper only caches calls to [`Source::read_metadata_at`]. Plain
812/// [`Source::read_exact_at`] calls still go directly to the inner source,
813/// which keeps raw dataset payloads out of the metadata cache.
814#[cfg(feature = "std")]
815pub struct MetadataCachingSource<S> {
816 inner: S,
817 config: MetadataCacheConfig,
818 cache: std::sync::Mutex<MetadataReadCache>,
819}
820
821#[cfg(feature = "std")]
822impl<S> MetadataCachingSource<S> {
823 /// Wrap a source with the supplied metadata-cache configuration.
824 pub fn new(inner: S, config: MetadataCacheConfig) -> Self {
825 Self {
826 inner,
827 config,
828 cache: std::sync::Mutex::new(MetadataReadCache::new()),
829 }
830 }
831}
832
833#[cfg(feature = "std")]
834impl<S: Source> Source for MetadataCachingSource<S> {
835 fn len(&self) -> u64 {
836 self.inner.len()
837 }
838
839 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
840 self.inner.read_at(offset, buf)
841 }
842
843 fn read_exact_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
844 self.inner.read_exact_at(offset, len)
845 }
846
847 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
848 MetadataReadCache::read_through(&self.cache, self.config, offset, len, || {
849 self.inner.read_metadata_at(offset, len)
850 })
851 }
852
853 /// `None` when the configuration disabled the cache, which is what the
854 /// wrapper being present but inert means to a caller.
855 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
856 self.config
857 .is_enabled()
858 .then(|| MetadataReadCache::locked(&self.cache).stats())
859 }
860
861 fn reset_metadata_cache_stats(&self) {
862 MetadataReadCache::locked(&self.cache).reset_stats();
863 }
864}
865
866// ---------------------------------------------------------------------------
867// Read + Seek backend (std)
868// ---------------------------------------------------------------------------
869
870/// A lazy [`Source`] over any [`std::io::Read`] + [`std::io::Seek`] (a
871/// [`std::fs::File`], an in-memory `Cursor`, etc.).
872///
873/// Each [`read_at`](Source::read_at) performs a `seek` + `read_exact`, so no
874/// more than the requested bytes are ever held in memory. This is the backend
875/// that lets a 32-bit host read a file larger than its address space: the
876/// metadata and one working chunk fit even when the whole file does not.
877///
878/// The reader is wrapped in a [`std::sync::Mutex`] so the source is `Sync` and
879/// `read_at` can take `&self` (seeking needs `&mut` access). This serializes
880/// concurrent reads, which is correct though not maximally parallel; a future
881/// backend can use positioned reads (`pread`/`seek_read`) to avoid the lock.
882#[cfg(feature = "std")]
883pub struct ReadSeekSource<R> {
884 inner: std::sync::Mutex<R>,
885 len: u64,
886}
887
888#[cfg(feature = "std")]
889impl<R: std::io::Read + std::io::Seek> ReadSeekSource<R> {
890 /// Wrap a `Read + Seek`, measuring its length by seeking to the end (then
891 /// restoring nothing — every `read_at` seeks absolutely anyway).
892 pub fn new(mut reader: R) -> Result<Self, FormatError> {
893 let len = reader
894 .seek(std::io::SeekFrom::End(0))
895 .map_err(|e| FormatError::Source(format_io(&e)))?;
896 Ok(ReadSeekSource {
897 inner: std::sync::Mutex::new(reader),
898 len,
899 })
900 }
901}
902
903#[cfg(feature = "std")]
904impl<R: std::io::Read + std::io::Seek> Source for ReadSeekSource<R> {
905 fn len(&self) -> u64 {
906 self.len
907 }
908
909 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
910 // Bound-check up front so a request past EOF is a clean error rather
911 // than a backend-specific short read.
912 let end = offset
913 .checked_add(buf.len() as u64)
914 .ok_or(FormatError::OffsetOverflow {
915 offset,
916 length: buf.len() as u64,
917 })?;
918 if end > self.len {
919 return Err(FormatError::UnexpectedEof {
920 // `expected`/`available` are byte counts; report them as the
921 // best `usize` we can without truncating on a 32-bit host.
922 expected: end.to_usize().unwrap_or(usize::MAX),
923 available: self.len.to_usize().unwrap_or(usize::MAX),
924 });
925 }
926 let mut guard = self
927 .inner
928 .lock()
929 .unwrap_or_else(std::sync::PoisonError::into_inner);
930 guard
931 .seek(std::io::SeekFrom::Start(offset))
932 .map_err(|e| FormatError::Source(format_io(&e)))?;
933 guard
934 .read_exact(buf)
935 .map_err(|e| FormatError::Source(format_io(&e)))?;
936 Ok(())
937 }
938}
939
940/// Render an `std::io::Error` to a short owned string for [`FormatError::Source`]
941/// (which is `no_std`-friendly and cannot hold the error itself).
942#[cfg(feature = "std")]
943fn format_io(e: &std::io::Error) -> std::string::String {
944 std::format!("{e}")
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950
951 #[cfg(not(feature = "std"))]
952 use alloc::vec;
953
954 #[test]
955 fn bytes_source_reads_and_reports_len() {
956 let data = (0u8..=255).collect::<Vec<u8>>();
957 let src = BytesSource::new(data.clone());
958 assert_eq!(src.len(), 256);
959
960 let mut buf = [0u8; 4];
961 src.read_at(10, &mut buf).unwrap();
962 assert_eq!(buf, [10, 11, 12, 13]);
963
964 let owned = src.read_exact_at(250, 6).unwrap();
965 assert_eq!(owned, vec![250, 251, 252, 253, 254, 255]);
966 }
967
968 #[test]
969 fn bytes_source_short_read_is_eof() {
970 let src = BytesSource::new(vec![1u8, 2, 3]);
971 let mut buf = [0u8; 4];
972 let err = src.read_at(0, &mut buf).unwrap_err();
973 assert!(matches!(err, FormatError::UnexpectedEof { .. }));
974 // Reading exactly to the end is fine.
975 let mut ok = [0u8; 3];
976 src.read_at(0, &mut ok).unwrap();
977 assert_eq!(ok, [1, 2, 3]);
978 }
979
980 #[test]
981 fn bytes_source_offset_past_end_is_eof() {
982 let src = BytesSource::new(vec![0u8; 8]);
983 let mut buf = [0u8; 1];
984 assert!(matches!(
985 src.read_at(8, &mut buf).unwrap_err(),
986 FormatError::UnexpectedEof { .. }
987 ));
988 // Zero-length read at EOF succeeds.
989 src.read_at(8, &mut []).unwrap();
990 }
991
992 #[test]
993 fn read_exact_at_rejects_oversized_len_without_allocating() {
994 // A length far larger than the source must error cleanly rather than
995 // attempt to reserve the buffer first. Before the pre-allocation bounds
996 // check, this called `vec![0u8; usize::MAX]` and aborted the process.
997 let src = BytesSource::new(vec![1u8, 2, 3, 4]);
998 assert!(matches!(
999 src.read_exact_at(0, usize::MAX).unwrap_err(),
1000 FormatError::UnexpectedEof { .. }
1001 ));
1002 // A read that fits is unaffected.
1003 assert_eq!(src.read_exact_at(1, 3).unwrap(), vec![2, 3, 4]);
1004 }
1005
1006 #[test]
1007 fn empty_source() {
1008 let src = BytesSource::new(Vec::<u8>::new());
1009 assert_eq!(src.len(), 0);
1010 }
1011
1012 #[test]
1013 fn forwarding_through_reference() {
1014 let src = BytesSource::new(vec![9u8, 8, 7]);
1015 let r: &dyn Source = &src;
1016 let mut buf = [0u8; 2];
1017 r.read_at(1, &mut buf).unwrap();
1018 assert_eq!(buf, [8, 7]);
1019 }
1020
1021 #[test]
1022 fn forwarding_through_reference_preserves_metadata_reads() {
1023 use core::cell::Cell;
1024
1025 struct MetadataSource {
1026 metadata_reads: Cell<usize>,
1027 }
1028
1029 impl Source for MetadataSource {
1030 fn len(&self) -> u64 {
1031 16
1032 }
1033
1034 fn read_at(&self, _offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
1035 buf.fill(0);
1036 Ok(())
1037 }
1038
1039 fn read_metadata_at(&self, _offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
1040 self.metadata_reads.set(self.metadata_reads.get() + 1);
1041 Ok(vec![0xAB; len])
1042 }
1043 }
1044
1045 fn read_metadata_via_trait<T: Source>(source: T) -> Vec<u8> {
1046 source.read_metadata_at(4, 3).unwrap()
1047 }
1048
1049 let source = MetadataSource {
1050 metadata_reads: Cell::new(0),
1051 };
1052
1053 assert_eq!(read_metadata_via_trait(&source), vec![0xAB; 3]);
1054 assert_eq!(source.metadata_reads.get(), 1);
1055 }
1056
1057 #[cfg(feature = "std")]
1058 #[test]
1059 fn metadata_cache_caches_only_metadata_reads() {
1060 use std::sync::{
1061 Arc,
1062 atomic::{AtomicUsize, Ordering},
1063 };
1064
1065 struct CountingSource {
1066 data: Vec<u8>,
1067 reads: Arc<AtomicUsize>,
1068 }
1069
1070 impl Source for CountingSource {
1071 fn len(&self) -> u64 {
1072 self.data.len() as u64
1073 }
1074
1075 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
1076 self.reads.fetch_add(1, Ordering::SeqCst);
1077 BytesSource::new(&self.data).read_at(offset, buf)
1078 }
1079 }
1080
1081 let reads = Arc::new(AtomicUsize::new(0));
1082 let source = MetadataCachingSource::new(
1083 CountingSource {
1084 data: (0u8..16).collect(),
1085 reads: Arc::clone(&reads),
1086 },
1087 MetadataCacheConfig::new(16),
1088 );
1089
1090 assert_eq!(source.read_metadata_at(4, 4).unwrap(), vec![4, 5, 6, 7]);
1091 assert_eq!(source.read_metadata_at(4, 4).unwrap(), vec![4, 5, 6, 7]);
1092 assert_eq!(reads.load(Ordering::SeqCst), 1);
1093
1094 assert_eq!(source.read_exact_at(4, 4).unwrap(), vec![4, 5, 6, 7]);
1095 assert_eq!(source.read_exact_at(4, 4).unwrap(), vec![4, 5, 6, 7]);
1096 assert_eq!(reads.load(Ordering::SeqCst), 3);
1097 }
1098
1099 #[cfg(feature = "std")]
1100 #[test]
1101 fn read_seek_source_matches_in_memory() {
1102 use std::io::Cursor;
1103 let data = (0u8..200).collect::<Vec<u8>>();
1104 let mem = BytesSource::new(data.clone());
1105 let seek = ReadSeekSource::new(Cursor::new(data.clone())).unwrap();
1106 assert_eq!(seek.len(), mem.len());
1107
1108 // Every read_at against the lazy source matches the in-memory source.
1109 for &(off, len) in &[(0u64, 1usize), (5, 10), (199, 1), (100, 50)] {
1110 let a = mem.read_exact_at(off, len).unwrap();
1111 let b = seek.read_exact_at(off, len).unwrap();
1112 assert_eq!(a, b, "mismatch at offset {off} len {len}");
1113 }
1114 }
1115
1116 #[cfg(feature = "std")]
1117 #[test]
1118 fn read_seek_source_past_end_is_error() {
1119 use std::io::Cursor;
1120 let seek = ReadSeekSource::new(Cursor::new(vec![1u8, 2, 3, 4])).unwrap();
1121 let mut buf = [0u8; 3];
1122 assert!(matches!(
1123 seek.read_at(2, &mut buf).unwrap_err(),
1124 FormatError::UnexpectedEof { .. }
1125 ));
1126 }
1127
1128 #[cfg(feature = "std")]
1129 #[test]
1130 fn read_seek_source_is_sync() {
1131 // Compile-time assertion that the std backend is Send + Sync so it can
1132 // back a parallel reader.
1133 fn assert_send_sync<T: Send + Sync>() {}
1134 assert_send_sync::<ReadSeekSource<std::io::Cursor<Vec<u8>>>>();
1135 }
1136
1137 // -----------------------------------------------------------------------
1138 // The bounded metadata store (issue #367)
1139 // -----------------------------------------------------------------------
1140
1141 #[test]
1142 fn eviction_drops_the_least_recently_used_entry_not_the_oldest() {
1143 // Room for three ten-byte entries, so the fourth displaces exactly one.
1144 let budget = 30;
1145 let mut cache = MetadataReadCache::new();
1146 cache.insert(0, 10, vec![0u8; 10], budget);
1147 cache.insert(100, 10, vec![1u8; 10], budget);
1148 cache.insert(200, 10, vec![2u8; 10], budget);
1149
1150 // Reading the first entry makes the *second* the least recently used,
1151 // which is what separates an LRU from a queue.
1152 assert!(cache.get(0, 10).is_some());
1153 cache.insert(300, 10, vec![3u8; 10], budget);
1154
1155 assert!(
1156 cache.get(0, 10).is_some(),
1157 "read most recently, must survive"
1158 );
1159 assert!(cache.get(100, 10).is_none(), "least recently used, must go");
1160 assert!(cache.get(200, 10).is_some());
1161 assert!(cache.get(300, 10).is_some());
1162 }
1163
1164 #[test]
1165 fn invalidation_takes_every_overlap_and_spares_the_neighbours() {
1166 let budget = 1024;
1167 let mut cache = MetadataReadCache::new();
1168 for offset in [0u64, 10, 20, 30] {
1169 cache.insert(offset, 10, vec![offset as u8; 10], budget);
1170 }
1171 // One long entry starting well before the write below and reaching well
1172 // past it. Nothing but its own length says it can be reached from there.
1173 cache.insert(5, 40, vec![9u8; 40], budget);
1174
1175 cache.invalidate_overlapping(20, 5);
1176
1177 assert!(cache.get(0, 10).is_some(), "ends at 10, short of the write");
1178 assert!(
1179 cache.get(10, 10).is_some(),
1180 "ends exactly where the write starts, so it shares no byte with it"
1181 );
1182 assert!(cache.get(20, 10).is_none(), "the write lands inside it");
1183 assert!(cache.get(30, 10).is_some(), "starts after the write ends");
1184 assert!(
1185 cache.get(5, 40).is_none(),
1186 "starts before the write and spans it, so a search beginning at the \
1187 write's own offset would walk straight past it"
1188 );
1189 }
1190
1191 #[test]
1192 fn re_inserting_a_key_replaces_it_rather_than_counting_it_twice() {
1193 // Exactly two ten-byte entries fit.
1194 let budget = 20;
1195 let mut cache = MetadataReadCache::new();
1196 cache.insert(0, 10, vec![0u8; 10], budget);
1197 cache.insert(0, 10, vec![1u8; 10], budget);
1198 cache.insert(100, 10, vec![2u8; 10], budget);
1199
1200 assert_eq!(
1201 cache.get(0, 10).as_deref(),
1202 Some(&[1u8; 10][..]),
1203 "the later value replaces the earlier one"
1204 );
1205 assert!(
1206 cache.get(100, 10).is_some(),
1207 "a replacement that was counted twice would have evicted to make room"
1208 );
1209 }
1210
1211 #[test]
1212 fn one_offset_at_two_lengths_holds_two_entries() {
1213 let budget = 1024;
1214 let mut cache = MetadataReadCache::new();
1215 cache.insert(64, 4, vec![1u8; 4], budget);
1216 cache.insert(64, 8, vec![2u8; 8], budget);
1217
1218 assert_eq!(cache.get(64, 4).as_deref(), Some(&[1u8; 4][..]));
1219 assert_eq!(cache.get(64, 8).as_deref(), Some(&[2u8; 8][..]));
1220 }
1221
1222 /// A hit must not get slower as the cache gets bigger (issue #367).
1223 ///
1224 /// The store this replaced walked a `Vec`, so a hit cost O(entries), which
1225 /// put it above the cost of the positioned read it exists to avoid from
1226 /// about 3,000 entries on. Measured in release against that read: 9x faster
1227 /// at 64 entries, 3.2x at 1,024, then 1.2x *slower* at 4,096 and 21.9x
1228 /// slower at 65,536.
1229 ///
1230 /// Across the pair below, the scanning store measured 16.6 in release
1231 /// (369 ns to 6,134) and 36.8 unoptimized. Indexed, the same pair measures
1232 /// 1.10 and 1.30. The allowance sits between those two groups with room on
1233 /// either side: six times what an unoptimized build measures here, and a
1234 /// fifth of what a return to scanning would.
1235 #[test]
1236 fn a_hit_does_not_get_slower_as_the_cache_grows() {
1237 /// Entry counts either side of the growth, and the factor the cost is
1238 /// allowed to move across it. Named so the failure message cannot drift
1239 /// from what was measured.
1240 const SMALL: usize = 1_024;
1241 const LARGE: usize = 16_384;
1242 const ALLOWED_GROWTH: f64 = 8.0;
1243
1244 fn nanos_per_hit(entries: usize) -> f64 {
1245 const ENTRY_LEN: usize = 64;
1246 let budget = entries * ENTRY_LEN * 2;
1247 let mut cache = MetadataReadCache::new();
1248 for i in 0..entries {
1249 cache.insert(
1250 (i * ENTRY_LEN) as u64,
1251 ENTRY_LEN,
1252 vec![7u8; ENTRY_LEN],
1253 budget,
1254 );
1255 }
1256 // Warm the caches the machine has, then time a pass that is all hits.
1257 for i in 0..entries {
1258 assert!(cache.get((i * ENTRY_LEN) as u64, ENTRY_LEN).is_some());
1259 }
1260 let started = std::time::Instant::now();
1261 for i in 0..entries {
1262 assert!(cache.get((i * ENTRY_LEN) as u64, ENTRY_LEN).is_some());
1263 }
1264 started.elapsed().as_secs_f64() * 1e9 / entries as f64
1265 }
1266
1267 let small = nanos_per_hit(SMALL);
1268 let large = nanos_per_hit(LARGE);
1269 assert!(
1270 large < small * ALLOWED_GROWTH,
1271 "a hit cost {large:.0} ns with {LARGE} entries against {small:.0} ns with \
1272 {SMALL} -- growing the cache should not move it, and a cost that tracks \
1273 its size is the shape of a store being searched rather than indexed"
1274 );
1275 }
1276
1277 // -----------------------------------------------------------------------
1278 // What the cache reports about itself (issue #353)
1279 // -----------------------------------------------------------------------
1280
1281 /// A source of `len` bytes that serves every metadata read, so a cache in
1282 /// front of it is the only thing that can make a read not happen.
1283 #[cfg(feature = "std")]
1284 fn ramp(len: usize) -> BytesSource<Vec<u8>> {
1285 BytesSource::new((0..len).map(|i| i as u8).collect::<Vec<u8>>())
1286 }
1287
1288 #[cfg(feature = "std")]
1289 #[test]
1290 fn a_read_too_large_to_admit_is_not_charged_as_a_miss() {
1291 // 64-byte entries are eligible; anything above that is turned away
1292 // before it reaches the cache.
1293 let config = MetadataCacheConfig::new(4096).with_max_entry_bytes(64);
1294 let source = MetadataCachingSource::new(ramp(4096), config);
1295
1296 source.read_metadata_at(0, 64).unwrap(); // miss, then admitted
1297 source.read_metadata_at(0, 64).unwrap(); // hit
1298 source.read_metadata_at(128, 256).unwrap(); // too large to admit
1299 source.read_metadata_at(128, 256).unwrap(); // and so, still too large
1300
1301 let stats = source.metadata_cache_stats().unwrap();
1302 assert_eq!(stats.hits(), 1);
1303 assert_eq!(stats.misses(), 1);
1304 assert_eq!(stats.oversize_reads(), 2);
1305 assert_eq!(stats.reads(), 4);
1306 // The two oversize reads are the caller's to fix by raising
1307 // `max_entry_bytes`, and folding them in would report the cache at 25%
1308 // rather than saying which knob is turning them away.
1309 assert_eq!(stats.hit_rate(), Some(0.5));
1310
1311 // The other half of the same rule. `with_max_entry_bytes` can name a cap
1312 // above the whole budget, and a read between the two would pass the entry
1313 // check only for `insert` to refuse it every time -- a permanent miss
1314 // reported as an ordinary one. The budget turns it away up front instead.
1315 let lopsided = MetadataCacheConfig::new(128).with_max_entry_bytes(512);
1316 let source = MetadataCachingSource::new(ramp(4096), lopsided);
1317 source.read_metadata_at(0, 256).unwrap();
1318 source.read_metadata_at(0, 256).unwrap();
1319 let stats = source.metadata_cache_stats().unwrap();
1320 assert_eq!(stats.oversize_reads(), 2);
1321 assert_eq!(stats.misses(), 0);
1322 assert_eq!(stats.hit_rate(), None);
1323 }
1324
1325 #[cfg(feature = "std")]
1326 #[test]
1327 fn no_eligible_read_yet_is_not_a_hit_rate_of_zero() {
1328 let config = MetadataCacheConfig::new(4096).with_max_entry_bytes(64);
1329 let source = MetadataCachingSource::new(ramp(4096), config);
1330
1331 let fresh = source.metadata_cache_stats().unwrap();
1332 assert_eq!(fresh.hit_rate(), None, "nothing has been read");
1333
1334 source.read_metadata_at(0, 256).unwrap();
1335 assert_eq!(
1336 source.metadata_cache_stats().unwrap().hit_rate(),
1337 None,
1338 "a read the cache never saw does not make a rate out of it"
1339 );
1340
1341 source.read_metadata_at(0, 64).unwrap();
1342 assert_eq!(
1343 source.metadata_cache_stats().unwrap().hit_rate(),
1344 Some(0.0),
1345 "one eligible read that missed *is* a rate, and the opposite reading"
1346 );
1347 }
1348
1349 #[cfg(feature = "std")]
1350 #[test]
1351 fn the_budget_and_a_write_drop_entries_for_different_reasons() {
1352 // Two 64-byte entries fit; a third forces one out.
1353 const BUDGET: usize = 128;
1354 let mut cache = MetadataReadCache::new();
1355 cache.insert(0, 64, vec![1u8; 64], BUDGET);
1356 cache.insert(64, 64, vec![2u8; 64], BUDGET);
1357
1358 // Re-reading a key replaces it. Nothing was dropped for want of room or
1359 // because the bytes changed, so neither counter moves.
1360 cache.insert(0, 64, vec![1u8; 64], BUDGET);
1361 let replaced = cache.stats();
1362 assert_eq!(replaced.entries(), 2);
1363 assert_eq!(replaced.evictions(), 0);
1364 assert_eq!(replaced.invalidations(), 0);
1365
1366 cache.insert(128, 64, vec![3u8; 64], BUDGET);
1367 let evicted = cache.stats();
1368 assert_eq!(evicted.evictions(), 1, "the budget forced this one");
1369 assert_eq!(evicted.invalidations(), 0);
1370 // The least recently used of the three went, leaving [0, 64) and
1371 // [128, 192).
1372 assert_eq!(evicted.entries(), 2);
1373
1374 // A write across [32, 160) reaches into both survivors: one starts
1375 // before it, the other after.
1376 cache.invalidate_overlapping(32, 128);
1377 let invalidated = cache.stats();
1378 assert_eq!(invalidated.invalidations(), 2, "the write overlapped both");
1379 assert_eq!(
1380 invalidated.evictions(),
1381 1,
1382 "a write is not the budget, and a caller told to raise the budget \
1383 because of one would be raising it for nothing"
1384 );
1385 assert_eq!(invalidated.entries(), 0);
1386 assert_eq!(invalidated.bytes(), 0);
1387 }
1388
1389 #[cfg(feature = "std")]
1390 #[test]
1391 fn resetting_the_counters_keeps_the_entries() {
1392 const BUDGET: usize = 4096;
1393 let mut cache = MetadataReadCache::new();
1394 cache.insert(0, 64, vec![1u8; 64], BUDGET);
1395 assert!(cache.get(0, 64).is_some());
1396 assert!(cache.get(512, 64).is_none());
1397
1398 cache.reset_stats();
1399
1400 let stats = cache.stats();
1401 assert_eq!(stats.hits(), 0);
1402 assert_eq!(stats.misses(), 0);
1403 assert_eq!(stats.hit_rate(), None);
1404 // Occupancy measures the cache rather than tallying its history, so a
1405 // reset that emptied it would answer a different question than the one
1406 // `H5Freset_mdc_hit_rate_stats` asks.
1407 assert_eq!(stats.entries(), 1);
1408 assert_eq!(stats.bytes(), 64);
1409 assert!(cache.get(0, 64).is_some(), "the entry is still servable");
1410 }
1411
1412 #[cfg(feature = "std")]
1413 #[test]
1414 fn a_disabled_cache_reports_nothing_rather_than_zeroes() {
1415 let source = MetadataCachingSource::new(ramp(4096), MetadataCacheConfig::disabled());
1416 assert_eq!(
1417 source.read_metadata_at(0, 64).unwrap(),
1418 (0..64u8).collect::<Vec<u8>>()
1419 );
1420 assert_eq!(
1421 source.metadata_cache_stats(),
1422 None,
1423 "an all-zero snapshot would read as a cache that is on and idle"
1424 );
1425 source.reset_metadata_cache_stats();
1426 assert_eq!(
1427 source.metadata_cache_stats(),
1428 None,
1429 "and resetting one there is nothing to reset does not conjure one"
1430 );
1431 }
1432
1433 #[cfg(feature = "std")]
1434 #[test]
1435 fn a_wrapper_reports_the_cache_it_reads_through() {
1436 let config = MetadataCacheConfig::new(4096);
1437 let source = MetadataCachingSource::new(ramp(4096), config);
1438 // The base-relative view a userblock file reads through forwards its
1439 // metadata reads to the inner source, so it must forward the account of
1440 // them too.
1441 let framed = BaseOffsetSource {
1442 inner: &source,
1443 base: BaseAddress::new(512),
1444 };
1445 framed.read_metadata_at(0, 64).unwrap();
1446 framed.read_metadata_at(0, 64).unwrap();
1447
1448 let stats = framed.metadata_cache_stats().expect("forwarded");
1449 assert_eq!((stats.hits(), stats.misses()), (1, 1));
1450 assert_eq!(stats, source.metadata_cache_stats().unwrap());
1451
1452 framed.reset_metadata_cache_stats();
1453 assert_eq!(source.metadata_cache_stats().unwrap().hits(), 0);
1454 assert_eq!(
1455 source.metadata_cache_stats().unwrap().entries(),
1456 1,
1457 "reset through the view is a reset of counters, not a flush"
1458 );
1459 }
1460}