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]>
333 lines
11 KiB
Rust
333 lines
11 KiB
Rust
//! 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);
|
|
}
|