format: bound and batch every chunk fetch over Storage

Only the full read split its chunk fetches into 64 MiB batches. The
selection path, the indexed read and the parallel_read decoders fetched
every chunk's stored bytes in one read_ranges call, each extent bounded only
by the file length, so a crafted chunk index pointing many chunks at one
large extent made File::open_storage hold chunks x extent bytes (3.3 GB from
a 16.8 MB file) before the first decode error.

- storage::for_each_extent_batch is now the one way raw-data reads fetch
  chunk bytes: batches of at most RAW_BATCH_BYTES (now pub), each decoded
  before the next is fetched. Used by the full, cached, indexed, selection
  and parallel_read paths; the sweep read uses read_extent per chunk.
- ExtentReq carries each chunk's claimed extent (bounds-checked as before,
  same errors) and the prefix actually fetched:
  filters::stored_chunk_limit — the chunk size if unfiltered, else each
  applied filter's worst-case growth (n + n/4 + 4096 per codec; unbounded
  only for an application-registered codec). The in-memory path cuts the
  slice it decodes the same way, so both paths still agree.
- tests/raw_fetch_bounds.rs: a crafted chunked_large.h5 (ten chunks all
  claiming 20 MiB at one padding blob) read through every path over a
  storage that records the largest single fetch; and 160 MiB of legitimate
  unfiltered chunks fetched batch by batch. Before: one 80 MiB fetch
  (selection) and one 160 MiB fetch; after: within the budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 18:30:30 -05:00
co-authored by Claude Opus 5.5
parent f191dc09d5
commit 7d629f49e3
10 changed files with 825 additions and 285 deletions
@@ -0,0 +1,332 @@
//! What a raw-data read fetches from a [`Storage`] without the file in
//! memory is bounded: by batch (at most `RAW_BATCH_BYTES` per
//! `read_ranges` call) and by chunk (never more of a chunk's stored bytes
//! than its decoded size can need), on every path that reads chunks — full,
//! cached, indexed, sweep, selection and the `parallel_read` decoders.
//!
//! A crafted chunk index can point every chunk at one huge extent. Slicing
//! an in-memory file costs nothing there, but a backend that fetches would
//! hold `chunks x extent` bytes before the first chunk failed to decode.
use std::borrow::Cow;
use std::ops::Range;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use clawhdf5_format::chunk_cache::ChunkCache;
use clawhdf5_format::chunked_read::{
ChunkInfo, SweepContext, list_chunks, read_chunked_data_sweep_in,
};
use clawhdf5_format::data_layout::DataLayout;
use clawhdf5_format::data_read::{
read_raw_data_cached_in, read_raw_data_full_in, read_raw_data_indexed_in,
read_raw_data_selection_in,
};
use clawhdf5_format::dataspace::Dataspace;
use clawhdf5_format::datatype::Datatype;
use clawhdf5_format::error::FormatError;
use clawhdf5_format::filter_pipeline::FilterPipeline;
use clawhdf5_format::group_v2;
use clawhdf5_format::message_type::MessageType;
use clawhdf5_format::object_header::ObjectHeader;
use clawhdf5_format::selection::Selection;
use clawhdf5_format::storage::{RAW_BATCH_BYTES, Storage};
use clawhdf5_format::superblock::Superblock;
/// A read_at-only storage that records the most bytes one call fetched
/// (a `read_ranges` call counts all its ranges together) and the total.
struct PeakStorage {
data: Vec<u8>,
peak: AtomicU64,
total: AtomicU64,
}
impl PeakStorage {
fn new(data: Vec<u8>) -> Self {
PeakStorage {
data,
peak: AtomicU64::new(0),
total: AtomicU64::new(0),
}
}
fn reset(&self) {
self.peak.store(0, Relaxed);
self.total.store(0, Relaxed);
}
fn served(&self, offset: u64, len: usize) -> Vec<u8> {
self.data
.as_slice()
.read_at(offset, len)
.unwrap()
.into_owned()
}
}
impl Storage for PeakStorage {
fn read_at(&self, offset: u64, len: usize) -> Result<Cow<'_, [u8]>, FormatError> {
let got = self.served(offset, len);
self.peak.fetch_max(got.len() as u64, Relaxed);
self.total.fetch_add(got.len() as u64, Relaxed);
Ok(Cow::Owned(got))
}
fn len(&self) -> u64 {
self.data.len() as u64
}
fn read_ranges(&self, ranges: &[Range<u64>]) -> Result<Vec<Cow<'_, [u8]>>, FormatError> {
let got: Vec<Vec<u8>> = ranges
.iter()
.map(|r| self.served(r.start, (r.end - r.start) as usize))
.collect();
let bytes: u64 = got.iter().map(|g| g.len() as u64).sum();
self.peak.fetch_max(bytes, Relaxed);
self.total.fetch_add(bytes, Relaxed);
Ok(got.into_iter().map(Cow::Owned).collect())
}
}
struct Chunked {
layout: DataLayout,
dataspace: Dataspace,
datatype: Datatype,
pipeline: Option<FilterPipeline>,
os: u8,
ls: u8,
}
/// The one chunked dataset of fixture `name`.
fn chunked(bytes: &[u8]) -> Chunked {
let sb = Superblock::parse(bytes, 0).unwrap();
let (os, ls) = (sb.offset_size, sb.length_size);
for child in group_v2::resolve_group_children(bytes, &sb, sb.root_group_address).unwrap() {
let header =
ObjectHeader::parse(bytes, child.object_header_address as usize, os, ls).unwrap();
let msg = |t: MessageType| {
header
.messages
.iter()
.find(|m| m.msg_type == t)
.map(|m| m.data.clone())
};
let Some(dl) = msg(MessageType::DataLayout) else {
continue;
};
let layout = DataLayout::parse(&dl, os, ls).unwrap();
if !matches!(layout, DataLayout::Chunked { .. }) {
continue;
}
return Chunked {
layout,
datatype: Datatype::parse(&msg(MessageType::Datatype).unwrap())
.unwrap()
.0,
dataspace: Dataspace::parse(&msg(MessageType::Dataspace).unwrap(), ls).unwrap(),
pipeline: msg(MessageType::FilterPipeline).map(|p| FilterPipeline::parse(&p).unwrap()),
os,
ls,
};
}
panic!("no chunked dataset");
}
/// Claimed stored size of every chunk in the crafted file.
const HUGE: u32 = 20 << 20;
/// `chunked_large.h5` (1000 `i32` in ten gzip chunks, a v1 B-tree index)
/// with `HUGE` bytes of padding appended and every chunk's index entry
/// rewritten to claim `HUGE` stored bytes at the padding: ten chunks, 200
/// MiB of extents, in a 20 MiB file.
fn crafted() -> (Vec<u8>, Chunked, Vec<ChunkInfo>) {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let mut bytes = std::fs::read(dir.join("chunked_large.h5")).unwrap();
let ds = chunked(&bytes);
let es = ds.datatype.type_size() as usize;
let (chunks, _) = list_chunks(&bytes, &ds.layout, &ds.dataspace, es, ds.os, ds.ls).unwrap();
assert_eq!(chunks.len(), 10);
let blob = bytes.len() as u64;
for c in &chunks {
// v1 B-tree key (size, filter mask, offsets + 0) then the child
// address.
let mut pat = Vec::new();
pat.extend_from_slice(&c.chunk_size.to_le_bytes());
pat.extend_from_slice(&c.filter_mask.to_le_bytes());
// The key holds one offset per dimension plus the element offset
// (0); `offsets` may or may not list that last one.
for d in 0..=ds.dataspace.dimensions.len() {
pat.extend_from_slice(&c.offsets.get(d).copied().unwrap_or(0).to_le_bytes());
}
pat.extend_from_slice(&c.address.to_le_bytes());
let at = bytes
.windows(pat.len())
.position(|w| w == pat.as_slice())
.expect("chunk key");
bytes[at..at + 4].copy_from_slice(&HUGE.to_le_bytes());
let a = at + pat.len() - 8;
bytes[a..a + 8].copy_from_slice(&blob.to_le_bytes());
}
bytes.resize(bytes.len() + HUGE as usize, 0x5a);
let ds = chunked(&bytes);
let (chunks, _) = list_chunks(&bytes, &ds.layout, &ds.dataspace, es, ds.os, ds.ls).unwrap();
assert!(
chunks
.iter()
.all(|c| c.chunk_size == HUGE && c.address == blob)
);
(bytes, ds, chunks)
}
/// Most a crafted chunk of the fixture may fetch: its decoded size (400
/// bytes) grown by one codec, generously.
const CHUNK_LIMIT: u64 = 400 + 100 + 4096;
#[test]
fn crafted_chunk_index_cannot_amplify_fetches() {
let (bytes, ds, chunks) = crafted();
let st = PeakStorage::new(bytes.clone());
let pl = ds.pipeline.as_ref();
let (dl, sp, dt, os, ls) = (&ds.layout, &ds.dataspace, &ds.datatype, ds.os, ds.ls);
let check =
|what: &str, got: Result<Vec<u8>, FormatError>, want: Result<Vec<u8>, FormatError>| {
// Same outcome as slicing the whole file.
assert_eq!(got, want, "{what}");
let (peak, total) = (st.peak.load(Relaxed), st.total.load(Relaxed));
assert!(
peak <= RAW_BATCH_BYTES as u64,
"{what}: one fetch of {peak} bytes"
);
// Every chunk's fetch is bounded by what it can need, whatever its
// index entry claims (plus the index and header reads).
assert!(
total <= chunks.len() as u64 * CHUNK_LIMIT + 64 * 1024,
"{what}: fetched {total} bytes"
);
st.reset();
};
let slice: &[u8] = &bytes;
let sel = Selection::Hyperslab {
start: vec![100],
stride: vec![1],
count: vec![400],
block: vec![1],
};
check(
"selection",
read_raw_data_selection_in(&st, dl, sp, dt, pl, os, ls, &sel),
read_raw_data_selection_in(slice, dl, sp, dt, pl, os, ls, &sel),
);
check(
"full",
read_raw_data_full_in(&st, dl, sp, dt, pl, os, ls),
read_raw_data_full_in(slice, dl, sp, dt, pl, os, ls),
);
check(
"cached",
read_raw_data_cached_in(&st, dl, sp, dt, pl, os, ls, &ChunkCache::new()),
read_raw_data_cached_in(slice, dl, sp, dt, pl, os, ls, &ChunkCache::new()),
);
check(
"indexed",
read_raw_data_indexed_in(&st, dl, sp, dt, pl, os, ls, &ChunkCache::new()),
read_raw_data_indexed_in(slice, dl, sp, dt, pl, os, ls, &ChunkCache::new()),
);
check(
"sweep",
read_chunked_data_sweep_in(
&st,
dl,
sp,
dt,
pl,
os,
ls,
&ChunkCache::new(),
&mut SweepContext::new(4, 2),
),
read_chunked_data_sweep_in(
slice,
dl,
sp,
dt,
pl,
os,
ls,
&ChunkCache::new(),
&mut SweepContext::new(4, 2),
),
);
#[cfg(feature = "parallel")]
{
use clawhdf5_format::parallel_read::{
decompress_chunks_lane_partitioned_in, decompress_chunks_parallel_in,
decompress_chunks_sequential_in,
};
let pl = pl.unwrap();
let flat = |r: Result<Vec<Vec<u8>>, FormatError>| r.map(|v| v.concat());
check(
"parallel",
flat(decompress_chunks_parallel_in(&st, &chunks, pl, 400, 4)),
flat(decompress_chunks_parallel_in(slice, &chunks, pl, 400, 4)),
);
check(
"sequential",
flat(decompress_chunks_sequential_in(
&st,
&chunks,
Some(pl),
400,
4,
)),
flat(decompress_chunks_sequential_in(
slice,
&chunks,
Some(pl),
400,
4,
)),
);
check(
"lane partitioned",
flat(
decompress_chunks_lane_partitioned_in(&st, &chunks, pl, 400, 4, 7, Some(3))
.map(|(v, _)| v),
),
flat(
decompress_chunks_lane_partitioned_in(slice, &chunks, pl, 400, 4, 7, Some(3))
.map(|(v, _)| v),
),
);
}
}
/// Legitimately large chunks (unfiltered, 4 MiB each, 160 MiB in all) are
/// fetched batch by batch: no call holds more than the batch budget, and
/// the data is right.
#[cfg(feature = "parallel")]
#[test]
fn large_reads_are_fetched_in_batches() {
use clawhdf5_format::parallel_read::decompress_chunks_sequential_in;
const CHUNK: usize = 4 << 20;
let data: Vec<u8> = (0..2 * CHUNK).map(|i| (i % 251) as u8).collect();
let chunks: Vec<ChunkInfo> = (0..40u64)
.map(|i| ChunkInfo {
chunk_size: CHUNK as u32,
filter_mask: 0,
offsets: vec![i * CHUNK as u64],
address: (i % 2) * CHUNK as u64,
})
.collect();
let st = PeakStorage::new(data.clone());
let got = decompress_chunks_sequential_in(&st, &chunks, None, CHUNK, 1).unwrap();
assert_eq!(got.len(), 40);
for (i, c) in got.iter().enumerate() {
let at = (i % 2) * CHUNK;
assert!(c == &data[at..at + CHUNK], "chunk {i}");
}
let peak = st.peak.load(Relaxed);
assert!(peak <= RAW_BATCH_BYTES as u64, "one fetch of {peak} bytes");
assert_eq!(st.total.load(Relaxed), 40 * CHUNK as u64);
}