format, wasm: Storage::hint, fetched by the lazy reader with a pass's misses

A parser often learns where the next structures are (a node's children,
a structure's body once its prefix gives its length) before it reads
them one at a time. Over openUrl's restartable reader a structure only
reached after a miss costs a pass, and a round trip, of its own.

- clawhdf5-format: `Storage::hint(offset, len)`, "about to be read by
  this operation". Default: nothing (every backend that reads when
  asked); `&T`, `Box`, `Arc` and the facade's `FileData` forward it
  (shifted past a user block, clamped to the file).
- clawhdf5-wasm `LazyStorage` records hinted blocks it lacks. A pass
  that misses nothing ignores them (a hint never adds a round trip); a
  pass that misses also asks for them, in file order, while the pass
  stays within what is left of the operation's `maxFetch` budget (a
  hint never makes a call fail). At most 1 MiB (or a block) per hint
  and 65536 blocks per pass are recorded, whatever a file makes a
  parser hint. `Operation::attempt` follows hints; the plain
  `LazyStorage::attempt` does not. `run_blocking` and the browser's
  driver use the former. `LazyStats::hinted_blocks` counts them.

No parser hints yet: results, passes and requests are unchanged.
Tests: hinted blocks come with a miss and never alone (cached ones
skipped, a plain attempt ignores them), and stay within the fetch
budget, a hint past the end or longer than the file harmless.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-27 22:41:09 -05:00
co-authored by Claude Opus 5.5
parent 425585ee71
commit ca81c3ebfa
4 changed files with 239 additions and 7 deletions
+30
View File
@@ -89,6 +89,21 @@ pub trait Storage {
fn as_contiguous(&self) -> Option<&[u8]> {
None
}
/// A hint that `[offset, offset + len)` is about to be read by the
/// same operation: a parser that has just learnt where the next
/// structures are (a node's children, a structure's body) says so
/// before it reads them one at a time. Nothing is read and nothing
/// fails. The default ignores it, as does every backend that reads
/// when asked; the browser's restartable reader, which fetches over
/// the network between attempts, fetches hinted bytes along with the
/// bytes an attempt actually missed, so structures a parser only
/// reaches after a miss arrive in the same round trip. Results never
/// depend on hints.
#[inline]
fn hint(&self, offset: u64, len: usize) {
let _ = (offset, len);
}
}
impl Storage for [u8] {
@@ -148,6 +163,11 @@ impl<T: Storage + ?Sized> Storage for &T {
fn as_contiguous(&self) -> Option<&[u8]> {
(**self).as_contiguous()
}
#[inline]
fn hint(&self, offset: u64, len: usize) {
(**self).hint(offset, len)
}
}
impl<T: Storage + ?Sized> Storage for Box<T> {
@@ -170,6 +190,11 @@ impl<T: Storage + ?Sized> Storage for Box<T> {
fn as_contiguous(&self) -> Option<&[u8]> {
(**self).as_contiguous()
}
#[inline]
fn hint(&self, offset: u64, len: usize) {
(**self).hint(offset, len)
}
}
#[cfg(feature = "std")]
@@ -193,6 +218,11 @@ impl<T: Storage + ?Sized> Storage for std::sync::Arc<T> {
fn as_contiguous(&self) -> Option<&[u8]> {
(**self).as_contiguous()
}
#[inline]
fn hint(&self, offset: u64, len: usize) {
(**self).hint(offset, len)
}
}
/// `storage.len()` as the `usize` the parsers' end-of-file errors report
+193 -6
View File
@@ -25,6 +25,14 @@
//! pass per block it needs. In practice it is one pass per *wave* of
//! misses: a chunked read asks for all the chunks of a batch at once.
//!
//! Parsers also say what they are about to read ([`Storage::hint`]: a
//! node's children, a structure's body, a listed group's child headers).
//! A pass that misses fetches the hinted blocks it lacks too, as far as the
//! operation's fetch budget allows, so what the parser would only have
//! reached on the next pass arrives in the same round trip; a pass that
//! misses nothing ignores its hints, so they never add a round trip, and
//! results never depend on them.
//!
//! Blocks are kept in an LRU cache with a byte budget, trimmed only when no
//! operation is in flight. Blocks fetched for bulk reads (raw data: a
//! `read_ranges` call, or a read longer than a block) go first, so reading
@@ -50,6 +58,14 @@ pub const DEFAULT_MAX_FETCH: u64 = 512 << 20;
/// the caller of [`LazyStorage::attempt`]: a pass that missed is re-run.
pub const NEED_BYTES: &str = "bytes not fetched yet (restartable read)";
/// Most blocks one pass records as hinted (see [`Storage::hint`]).
const MAX_HINTED: usize = 1 << 16;
/// Total length of `ranges`.
fn ranges_len(ranges: &[Range<u64>]) -> u64 {
ranges.iter().map(|r| r.end - r.start).sum()
}
/// Settings of a [`LazyStorage`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LazyConfig {
@@ -94,6 +110,9 @@ pub struct LazyStats {
pub bytes_fetched: u64,
/// Blocks evicted to stay within the budget.
pub evictions: u64,
/// Blocks asked for because a parser hinted it would read them
/// ([`Storage::hint`]), not because a pass missed them.
pub hinted_blocks: u64,
/// Bytes cached now.
pub cached_bytes: u64,
}
@@ -125,6 +144,9 @@ struct State {
/// Blocks the current pass missed, and whether a small read wanted
/// them (metadata).
missing: HashMap<u64, bool>,
/// Blocks the current pass was told it is about to read and that are
/// not cached ([`Storage::hint`]), in file order.
hinted: BTreeSet<u64>,
/// Blocks a bulk read missed that have not been supplied yet: kept
/// as bulk when they arrive.
bulk_pending: BTreeSet<u64>,
@@ -155,6 +177,19 @@ pub struct Operation<'a> {
}
impl Operation<'_> {
/// One pass of `f`, as [`LazyStorage::attempt`], but a pass that misses
/// also asks for the blocks it was hinted it would read, as many as fit
/// in what is left of the operation's budget ([`LazyConfig::max_fetch`])
/// after the blocks it missed.
pub fn attempt<T>(&self, f: impl FnOnce() -> T) -> Step<T> {
let left = self
.storage
.config
.max_fetch
.saturating_sub(self.fetched.get());
self.storage.attempt_within(f, left)
}
/// Count `ranges` against the operation's budget
/// ([`LazyConfig::max_fetch`]) before they are fetched: an error, and
/// nothing counted, if they would take it past the budget.
@@ -225,20 +260,70 @@ impl LazyStorage {
/// Run one pass of `f` over this storage. `Done` when `f` read nothing
/// that is missing; otherwise `Need` with the ranges to fetch, and `f`'s
/// result is dropped (it may be an error caused by the miss, or a
/// result built around one).
/// result built around one). Hints are not followed; see
/// [`Operation::attempt`].
pub fn attempt<T>(&self, f: impl FnOnce() -> T) -> Step<T> {
self.attempt_within(f, 0)
}
/// [`attempt`](Self::attempt), adding to a pass that misses the hinted
/// blocks it lacks while everything asked for stays within `budget`
/// bytes.
fn attempt_within<T>(&self, f: impl FnOnce() -> T, budget: u64) -> Step<T> {
{
let mut st = lock(&self.state);
st.missing.clear();
st.hinted.clear();
st.stats.passes += 1;
}
let out = f();
let missing = std::mem::take(&mut lock(&self.state).missing);
let (missing, hinted) = {
let mut st = lock(&self.state);
(
std::mem::take(&mut st.missing),
std::mem::take(&mut st.hinted),
)
};
if missing.is_empty() {
return Step::Done(out);
}
drop(out);
Step::Need(self.runs(missing))
let need = self.runs(&missing);
if hinted.is_empty() || budget == 0 {
return Step::Need(need);
}
// The hinted blocks still missing, in file order, while they fit.
let bs = self.config.block_size;
let mut total = ranges_len(&need);
let mut wanted = missing.clone();
{
let st = lock(&self.state);
for i in hinted {
if wanted.contains_key(&i) || st.blocks.contains_key(&i) {
continue;
}
let n = (self.len - i * bs).min(bs);
if total + n > budget {
break;
}
total += n;
wanted.insert(i, true);
}
}
if wanted.len() == missing.len() {
return Step::Need(need);
}
let with_hints = self.runs(&wanted);
// Filling holes between runs can add blocks: never let the hints
// take the pass past the budget.
if ranges_len(&with_hints) > budget {
return Step::Need(need);
}
{
let mut st = lock(&self.state);
st.stats.hinted_blocks += (wanted.len() - missing.len()) as u64;
}
Step::Need(with_hints)
}
/// The bytes of the file at `offset`, fetched for a range a pass asked
@@ -309,7 +394,7 @@ impl LazyStorage {
) -> Result<T, String> {
let op = self.operation();
loop {
match self.attempt(&mut f) {
match op.attempt(&mut f) {
Step::Done(v) => return Ok(v),
Step::Need(ranges) => {
op.charge(&ranges)?;
@@ -350,14 +435,14 @@ impl LazyStorage {
/// blocks, a one-block hole between two runs filled so they merge
/// (unless the hole is cached: it would be fetched again), each at
/// most `max_request` long.
fn runs(&self, missing: HashMap<u64, bool>) -> Vec<Range<u64>> {
fn runs(&self, missing: &HashMap<u64, bool>) -> Vec<Range<u64>> {
let bs = self.config.block_size;
let mut wanted: Vec<u64> = missing.keys().copied().collect();
wanted.sort_unstable();
let mut st = lock(&self.state);
// Remember which blocks only bulk reads asked for: they are kept
// as bulk once supplied.
for (&i, &metadata) in &missing {
for (&i, &metadata) in missing {
if metadata {
st.bulk_pending.remove(&i);
} else {
@@ -499,6 +584,25 @@ impl Storage for LazyStorage {
self.len
}
fn hint(&self, offset: u64, len: usize) {
// A hint is about a structure, not bulk data: at most a block or
// 1 MiB of it is followed, and at most `MAX_HINTED` blocks a pass,
// whatever a hostile file makes a parser hint.
let len = (len as u64).min(self.config.block_size.max(1 << 20));
let Some(span) = self.span(offset, len) else {
return;
};
let mut st = lock(&self.state);
for i in span {
if st.hinted.len() >= MAX_HINTED {
return;
}
if !st.blocks.contains_key(&i) {
st.hinted.insert(i);
}
}
}
fn read_ranges(&self, ranges: &[Range<u64>]) -> Result<Vec<Cow<'_, [u8]>>, FormatError> {
let mut spans = Vec::with_capacity(ranges.len());
let mut total = 0u64;
@@ -797,6 +901,89 @@ mod tests {
}
}
#[test]
fn hinted_blocks_come_with_a_miss_and_never_alone() {
let data = file(16 * 1024);
let s = LazyStorage::new(data.len() as u64, config(1024, 1 << 20));
serve(&s, &data, &[3072..4096]);
let op = s.operation();
// Hints alone: the pass is done, nothing is fetched.
let step = op.attempt(|| {
s.hint(8 * 1024, 100);
owned(s.read_at(3072, 8))
});
assert!(matches!(step, Step::Done(Ok(_))), "{step:?}");
// With a miss, the hinted blocks not cached come too (block 3 is
// cached; 10..=11 is one run, 14 another).
let pass = || {
s.hint(3072, 10);
s.hint(10 * 1024 + 1000, 100);
s.hint(14 * 1024, 1);
owned(s.read_at(0, 8))
};
let Step::Need(need) = op.attempt(pass) else {
panic!("block 0 is missing");
};
assert_eq!(
need,
vec![0..1024, 10 * 1024..12 * 1024, 14 * 1024..15 * 1024]
);
// A plain attempt does not follow hints.
let Step::Need(plain) = s.attempt(pass) else {
panic!("block 0 is missing");
};
assert_eq!(plain, vec![0..1024]);
serve(&s, &data, &need);
let Step::Done(got) = op.attempt(pass) else {
panic!("everything was supplied");
};
assert_eq!(got.unwrap(), &data[..8]);
assert_eq!(s.stats().hinted_blocks, 3);
}
#[test]
fn hints_stay_within_the_fetch_budget() {
// A budget of 3 blocks: the missed block and the first two hinted
// ones fit, the rest are left out; a hint never makes a call fail.
// (Hinted blocks two apart would be merged with the hole between
// them, which would not fit: then no hint is followed.)
let data = file(64 * 1024);
let mut c = config(1024, 1 << 20);
c.max_fetch = 3 * 1024;
let s = LazyStorage::new(data.len() as u64, c);
let got = s
.run_blocking(
|| {
for i in 0..20 {
s.hint(20 * 1024 + i * 3072, 1);
}
owned(s.read_at(0, 8))
},
|r| Ok(data[r.start as usize..r.end as usize].to_vec()),
)
.unwrap()
.unwrap();
assert_eq!(got, &data[..8]);
let st = s.stats();
assert_eq!(
(st.bytes_fetched, st.hinted_blocks),
(3 * 1024, 2),
"{st:?}"
);
// A hint longer than the file, or past its end, is harmless.
let s = LazyStorage::new(data.len() as u64, config(1024, 1 << 20));
s.run_blocking(
|| {
s.hint(0, usize::MAX);
s.hint(u64::MAX, 10);
owned(s.read_at(0, 8))
},
|r| Ok(data[r.start as usize..r.end as usize].to_vec()),
)
.unwrap()
.unwrap();
}
#[test]
fn a_failed_fetch_is_an_error_not_data() {
let data = file(4096);
+1 -1
View File
@@ -391,7 +391,7 @@ impl Http {
) -> Result<T, JsError> {
let op = storage.operation();
loop {
match storage.attempt(&mut f) {
match op.attempt(&mut f) {
Step::Done(v) => return Ok(v),
Step::Need(ranges) => {
op.charge(&ranges).map_err(js_err)?;
+15
View File
@@ -372,6 +372,21 @@ impl Storage for FileData {
fn as_contiguous(&self) -> Option<&[u8]> {
self.contiguous()
}
fn hint(&self, offset: u64, len: usize) {
if self.contiguous().is_some() {
return;
}
if let Backing::Storage(s) = &self.backing {
// Within the HDF5 data, as a read would be clamped.
let size = Storage::len(self);
let start = offset.min(size);
let len = usize::try_from(size - start).map_or(len, |avail| avail.min(len));
if len > 0 {
s.hint(self.base + start, len);
}
}
}
}
/// `bytes`, a backend's answer to a read of `len` bytes, without anything