Skip to main content

hdf5_pure/
source.rs

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