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}