//! Equivalence harness for the range-read migration //! (`docs/design/range-reads.md`, milestones M1 and M2). //! //! Every parser converted to [`Storage`] must give exactly what its `&[u8]` //! form gives. This walks real files — the fixtures, files h5py writes to //! exercise the less common structures, and optionally the conformance //! corpus — and, for every object, runs each converted parser twice: over //! the file as a slice, and over a [`CountingStorage`] that serves the same //! bytes through `read_at` only (`as_contiguous()` is `None`, so no parser //! can fall back to the whole slice). The results must be identical, value //! for value and error for error. //! //! Nothing may answer [`FormatError::ContiguousStorageRequired`] any more: //! since milestone M2 every read path works over `read_at` alone, the v2 //! B-tree structures (dense groups and attributes, a SOHM B-tree index, //! huge fractal-heap objects) included. //! //! - `CLAWHDF5_STORAGE_CORPUS=dir[:dir...]` adds every `.h5`/`.hdf5`/`.he5`/ //! `.nc`/`.h5ad` file under those directories (the conformance corpus is //! `conformance/.cache/corpus`); `CLAWHDF5_STORAGE_REPORT=1` prints the //! per-file read counts. //! - The h5py-written files honour `CLAWHDF5_PYTHON` and //! `CLAWHDF5_REQUIRE_INTEROP` like the facade's interop tests. use std::collections::{HashSet, VecDeque}; use std::fmt::Debug; use std::path::{Path, PathBuf}; use std::process::Command; use clawhdf5_format::attribute::{ extract_attributes_full, extract_attributes_full_in, extract_attributes_tolerant, extract_attributes_tolerant_in, }; use clawhdf5_format::attribute_info::AttributeInfoMessage; use clawhdf5_format::btree_v1::{collect_symbol_table_nodes, collect_symbol_table_nodes_in}; use clawhdf5_format::btree_v2::{ BTreeV2Header, collect_btree_v2_records, collect_btree_v2_records_in, find_btree_v2_records, find_btree_v2_records_in, }; use clawhdf5_format::chunk_cache::ChunkCache; use clawhdf5_format::chunked_read::{list_chunks, list_chunks_in}; use clawhdf5_format::data_layout::DataLayout; use clawhdf5_format::data_read::{ read_raw_data_cached, read_raw_data_cached_in, read_raw_data_full, read_raw_data_full_in, read_raw_data_indexed, read_raw_data_indexed_in, read_raw_data_selection, read_raw_data_selection_in, }; use clawhdf5_format::dataspace::Dataspace; use clawhdf5_format::datatype::Datatype; use clawhdf5_format::error::FormatError; use clawhdf5_format::extensible_array::{ ExtensibleArrayHeader, read_extensible_array_chunks, read_extensible_array_chunks_in, }; use clawhdf5_format::fill_value::{ dataset_fill_value_from_storage, dataset_fill_value_in, read_full_with_fill, read_full_with_fill_in, }; use clawhdf5_format::filter_pipeline::FilterPipeline; use clawhdf5_format::fixed_array::{ FixedArrayHeader, read_fixed_array_chunks, read_fixed_array_chunks_in, }; use clawhdf5_format::fractal_heap::FractalHeapHeader; use clawhdf5_format::group_v2; use clawhdf5_format::link_info::LinkInfoMessage; use clawhdf5_format::local_heap::LocalHeap; use clawhdf5_format::message_type::MessageType; use clawhdf5_format::object_header::ObjectHeader; use clawhdf5_format::selection::Selection; use clawhdf5_format::shared_message::{ self, load_sohm_table, load_sohm_table_in, message_data_with_sohm, message_data_with_sohm_in, parse_sohm_btree_entries, parse_sohm_btree_entries_in, parse_sohm_list, parse_sohm_list_in, }; use clawhdf5_format::signature::split_user_block; use clawhdf5_format::storage::{CountingStorage, Storage}; use clawhdf5_format::superblock::Superblock; use clawhdf5_format::superblock_ext::{ cache_image_state, cache_image_state_in, read_superblock_extension, read_superblock_extension_in, }; use clawhdf5_format::symbol_table::{SymbolTableMessage, SymbolTableNode}; use clawhdf5_format::vds::{ read_virtual_dataset, read_virtual_dataset_in, virtual_dataset_extent, virtual_dataset_extent_in, }; use clawhdf5_format::vl_data::{VlResolver, read_vl_bytes, read_vl_bytes_in}; /// Objects visited per file, heap objects read per heap: enough to cover /// every structure kind while keeping a 35 000-group file fast. const MAX_OBJECTS: usize = 1500; const MAX_HEAP_IDS: usize = 200; /// Datasets larger than this are not read (their chunk indexes still are). const MAX_DATA_BYTES: u64 = 16 << 20; #[derive(Default, Debug)] struct Tally { files: usize, objects: usize, checks: usize, reads: u64, bytes: u64, /// Chunk indexes (fixed and extensible arrays) read, and the most bytes /// one of them took through the storage. chunk_indexes: usize, max_chunk_index_bytes: u64, } struct Walk<'a> { slice: &'a [u8], storage: &'a CountingStorage, name: String, /// The file's directory, for external VDS sources. dir: Option, tally: &'a mut Tally, } impl Walk<'_> { /// The storage result must equal the slice result; no parser may ask /// for the whole file. fn same( &mut self, what: &str, want: &Result, got: &Result, ) { self.tally.checks += 1; if let Err(FormatError::ContiguousStorageRequired(site)) = got { panic!( "{}: {what} fell back to the whole file ({site}); every read path \ must work through read_at", self.name ); } let (w, g) = (format!("{want:?}"), format!("{got:?}")); assert!( w == g, "{}: {what} differs\n slice: {}\n storage: {}", self.name, &w[..w.len().min(600)], &g[..g.len().min(600)] ); } fn index_read(&mut self, bytes_before: u64) { self.tally.chunk_indexes += 1; let bytes = self.storage.bytes_read() - bytes_before; self.tally.max_chunk_index_bytes = self.tally.max_chunk_index_bytes.max(bytes); } fn st(&self) -> &dyn Storage { self.storage } fn run(&mut self) { let slice = self.slice; let sb = Superblock::parse(slice, 0); self.same("superblock", &sb, &Superblock::parse_in(self.st(), 0)); let Ok(sb) = sb else { return }; let (os, ls) = (sb.offset_size, sb.length_size); let want = read_superblock_extension(slice, &sb); self.same( "superblock extension", &want, &read_superblock_extension_in(self.st(), &sb), ); let want = cache_image_state(slice, &sb); self.same("cache image", &want, &cache_image_state_in(self.st(), &sb)); let table = load_sohm_table(slice, os, ls); self.same("SOHM table", &table, &load_sohm_table_in(self.st(), os, ls)); if let Ok(Some(table)) = &table { for idx in &table.indexes { if idx.index_type == 0 { let want = parse_sohm_list(slice, idx.index_addr as usize, idx.num_messages, os); let got = parse_sohm_list_in(self.st(), idx.index_addr, idx.num_messages, os); self.same("SOHM list", &want, &got); } else { let want = parse_sohm_btree_entries(slice, idx.index_addr as usize, os, ls); let got = parse_sohm_btree_entries_in(self.st(), idx.index_addr, os, ls); self.same("SOHM B-tree", &want, &got); } } } let mut seen = HashSet::new(); let mut queue = VecDeque::from([sb.root_group_address]); while let Some(addr) = queue.pop_front() { if seen.len() >= MAX_OBJECTS || !seen.insert(addr) { continue; } self.tally.objects += 1; self.check_object(&sb, addr); let children = group_v2::resolve_group_children(slice, &sb, addr); let got = group_v2::resolve_group_children_in(self.st(), &sb, addr); self.same("group listing", &children, &got); if let Ok(children) = children { for c in children.iter().take(MAX_HEAP_IDS) { let want = group_v2::resolve_child(slice, &sb, addr, &c.name); let got = group_v2::resolve_child_in(self.st(), &sb, addr, &c.name); self.same("child lookup", &want, &got); } // A name no group has: the lookup's not-found path. let want = group_v2::resolve_child(slice, &sb, addr, "no such child"); let got = group_v2::resolve_child_in(self.st(), &sb, addr, "no such child"); self.same("child lookup (missing)", &want, &got); queue.extend(children.iter().map(|c| c.object_header_address)); } } // Paths: every listed name from the root, and one that is missing. if let Ok(children) = group_v2::resolve_group_children(slice, &sb, sb.root_group_address) { for c in children.iter().take(MAX_HEAP_IDS) { let path = format!("/{}", c.name); let want = group_v2::resolve_path_any(slice, &sb, &path); let got = group_v2::resolve_path_any_in(self.st(), &sb, &path); self.same("path", &want, &got); } } let want = group_v2::resolve_path_any(slice, &sb, "/no/such/path"); let got = group_v2::resolve_path_any_in(self.st(), &sb, "/no/such/path"); self.same("path (missing)", &want, &got); } fn check_object(&mut self, sb: &Superblock, addr: u64) { let slice = self.slice; let (os, ls) = (sb.offset_size, sb.length_size); let header = ObjectHeader::parse(slice, addr as usize, os, ls); self.same( "object header", &header, &ObjectHeader::parse_in(self.st(), addr, os, ls), ); let Ok(header) = header else { return }; let want = extract_attributes_full(slice, &header, os, ls); self.same( "attributes", &want, &extract_attributes_full_in(self.st(), &header, os, ls), ); let want = extract_attributes_tolerant(slice, &header, os, ls); let got = extract_attributes_tolerant_in(self.st(), &header, os, ls); self.same("attributes (tolerant)", &want, &got); let want = dataset_fill_value_in(slice, &header.messages, os, ls); self.same( "fill value", &want, &dataset_fill_value_from_storage(self.st(), &header.messages, os, ls), ); for msg in &header.messages { if shared_message::is_shared(msg.flags) { let want = message_data_with_sohm(slice, msg, os, ls); let got = message_data_with_sohm_in(self.st(), msg, os, ls); self.same("shared message", &want, &got); } match msg.msg_type { MessageType::SymbolTable => { if let Ok(stm) = SymbolTableMessage::parse(&msg.data, os) { self.check_v1_group(&stm, os, ls); } } MessageType::LinkInfo => { if let Ok(li) = LinkInfoMessage::parse(&msg.data, os) { self.check_heap( li.fractal_heap_address, li.btree_name_index_address, 4, os, ls, ); } } MessageType::AttributeInfo => { if let Ok(ai) = AttributeInfoMessage::parse(&msg.data, os) { self.check_heap( ai.fractal_heap_address, ai.btree_name_index_address, 0, os, ls, ); } } _ => {} } } self.check_layout(&header, os, ls); self.check_data(&header, os, ls); } /// A symbol-table group: its local heap, B-tree, nodes and names. fn check_v1_group(&mut self, stm: &SymbolTableMessage, os: u8, ls: u8) { let slice = self.slice; let heap = LocalHeap::parse(slice, stm.local_heap_address as usize, os, ls); self.same( "local heap", &heap, &LocalHeap::parse_in(self.st(), stm.local_heap_address, os, ls), ); let nodes = collect_symbol_table_nodes(slice, stm.btree_address, os, ls); let got = collect_symbol_table_nodes_in(self.st(), stm.btree_address, os, ls); self.same("group B-tree", &nodes, &got); let Ok(heap) = heap else { return }; let want = heap.validate_free_list(slice, ls); self.same( "local heap free list", &want, &heap.validate_free_list_in(self.st(), ls), ); let Ok(nodes) = nodes else { return }; for &node in nodes.iter().take(MAX_HEAP_IDS) { let snod = SymbolTableNode::parse(slice, node as usize, os); self.same( "symbol table node", &snod, &SymbolTableNode::parse_in(self.st(), node, os), ); let Ok(snod) = snod else { continue }; for e in &snod.entries { let want = heap.read_string(slice, e.link_name_offset); self.same( "link name", &want, &heap.read_string_in(self.st(), e.link_name_offset), ); } } } /// A dense group's or dense attributes' fractal heap: the header, and /// the objects its name index points at. `id_at` is where the heap ID /// starts in a name-index record (after the hash for links). fn check_heap(&mut self, heap: Option, index: Option, id_at: usize, os: u8, ls: u8) { let slice = self.slice; let Some(heap_addr) = heap else { return }; let fh = FractalHeapHeader::parse(slice, heap_addr as usize, os, ls); self.same( "fractal heap", &fh, &FractalHeapHeader::parse_in(self.st(), heap_addr, os, ls), ); let (Ok(fh), Some(index)) = (fh, index) else { return; }; let bt = BTreeV2Header::parse(slice, index as usize, os, ls); self.same( "v2 B-tree header", &bt, &BTreeV2Header::parse_in(self.st(), index, os, ls), ); let Ok(bt) = bt else { return }; let records = collect_btree_v2_records(slice, &bt, os, ls); let got = collect_btree_v2_records_in(self.st(), &bt, os, ls); self.same("v2 B-tree records", &records, &got); let Ok(records) = records else { return }; // Descents to single records (by their bytes), as name lookups do. for rec in records.iter().take(8) { let key = rec.data.clone(); let want = find_btree_v2_records(slice, &bt, os, &mut |r| r.cmp(&key[..])); let got = find_btree_v2_records_in(self.st(), &bt, os, &mut |r| r.cmp(&key[..])); self.same("v2 B-tree descent", &want, &got); } let id_len = fh.heap_id_length as usize; for rec in records.iter().take(MAX_HEAP_IDS) { let Some(id) = rec.data.get(id_at..id_at + id_len) else { continue; }; let want = fh.read_managed_object(slice, id, os); self.same( "heap object", &want, &fh.read_managed_object_in(self.st(), id, os), ); } } /// A dataset's values through every raw-data path: whole reads (plain, /// cached, indexed, fill-aware, virtual), chunk listings, selections /// (a box, a strided hyperslab, points), VL strings and sequences. fn check_data(&mut self, header: &ObjectHeader, os: u8, ls: u8) { let slice = self.slice; let find = |t: MessageType| { header .messages .iter() .find(|m| m.msg_type == t) .and_then(|m| shared_message::message_data_with_sohm(slice, m, os, ls).ok()) }; let (Some(dt), Some(ds), Some(dl)) = ( find(MessageType::Datatype), find(MessageType::Dataspace), find(MessageType::DataLayout), ) else { return; }; let (Ok((dt, _)), Ok(ds), Ok(dl)) = ( Datatype::parse(&dt), Dataspace::parse(&ds, ls), DataLayout::parse(&dl, os, ls), ) else { return; }; let pipeline = match find(MessageType::FilterPipeline).map(|p| FilterPipeline::parse(&p)) { Some(Ok(p)) => Some(p), Some(Err(_)) => return, None => None, }; let pl = pipeline.as_ref(); let elem = dt.type_size() as u64; let bytes = ds .dimensions .iter() .try_fold(elem, |a, &d| a.checked_mul(d)); if bytes.is_none_or(|b| b > MAX_DATA_BYTES) { return; } if matches!(dl, DataLayout::Virtual { .. }) { let resolver = self.resolver(); let r: &clawhdf5_format::vds::VdsFileResolver = &resolver; let want = virtual_dataset_extent(slice, &dl, &ds, os, ls, Some(r)); let got = virtual_dataset_extent_in(self.st(), &dl, &ds, os, ls, Some(r)); self.same("VDS extent", &want, &got); let want = read_virtual_dataset(slice, &dl, &ds, &dt, None, os, ls, Some(r)); let got = read_virtual_dataset_in(self.st(), &dl, &ds, &dt, None, os, ls, Some(r)); self.same("VDS read", &want, &got); return; } let want = read_raw_data_full(slice, &dl, &ds, &dt, pl, os, ls); let got = read_raw_data_full_in(self.st(), &dl, &ds, &dt, pl, os, ls); self.same("raw data", &want, &got); let want_fill = read_full_with_fill( &header.messages, slice, &dl, &ds, elem as usize, os, ls, || read_raw_data_full(slice, &dl, &ds, &dt, pl, os, ls), ); let got_fill = read_full_with_fill_in( &header.messages, self.st(), &dl, &ds, elem as usize, os, ls, || read_raw_data_full_in(self.st(), &dl, &ds, &dt, pl, os, ls), ); self.same("raw data with fill", &want_fill, &got_fill); if matches!(dl, DataLayout::Chunked { .. }) { let want = list_chunks(slice, &dl, &ds, elem as usize, os, ls); let got = list_chunks_in(self.st(), &dl, &ds, elem as usize, os, ls); self.same("chunk list", &want, &got); // Through a chunk cache, twice (the second read is served from // it), and through the indexed path. let (c1, c2) = (ChunkCache::new(), ChunkCache::new()); for _ in 0..2 { let want = read_raw_data_cached(slice, &dl, &ds, &dt, pl, os, ls, &c1); let got = read_raw_data_cached_in(self.st(), &dl, &ds, &dt, pl, os, ls, &c2); // A cache lists the chunks in hash-map order (see below). if want.is_err() && got.is_err() { self.tally.checks += 1; } else { self.same("raw data (cached)", &want, &got); } } let (c1, c2) = (ChunkCache::new(), ChunkCache::new()); let want = read_raw_data_indexed(slice, &dl, &ds, &dt, pl, os, ls, &c1); let got = read_raw_data_indexed_in(self.st(), &dl, &ds, &dt, pl, os, ls, &c2); // The indexed path decodes chunks in hash-map order, so which // failing chunk it reports varies between two caches (with the // slice alone, too): only whether it fails must agree. if want.is_err() && got.is_err() { self.tally.checks += 1; } else { self.same("raw data (indexed)", &want, &got); } } let dims = &ds.dimensions; if !dims.is_empty() && dims.iter().all(|&d| d > 0) { let rank = dims.len(); let ones = vec![1u64; rank]; let quarter = Selection::Hyperslab { start: dims.iter().map(|&d| d / 4).collect(), stride: ones.clone(), count: dims.iter().map(|&d| (d / 3).max(1)).collect(), block: ones.clone(), }; let mut stride = ones.clone(); stride[rank - 1] = 2; let mut count = dims.clone(); count[rank - 1] = dims[rank - 1].div_ceil(2); let strided = Selection::Hyperslab { start: vec![0; rank], stride, count, block: ones.clone(), }; let points = Selection::Points(vec![ dims.iter().map(|&d| d - 1).collect(), vec![0; rank], dims.iter().map(|&d| d / 2).collect(), ]); for (what, sel) in [ ("selection (box)", &quarter), ("selection (strided)", &strided), ("selection (points)", &points), ] { let want = read_raw_data_selection(slice, &dl, &ds, &dt, pl, os, ls, sel); let got = read_raw_data_selection_in(self.st(), &dl, &ds, &dt, pl, os, ls, sel); self.same(what, &want, &got); } } // Variable-length strings and sequences, resolved in the global heap. if let (Datatype::VariableLength { base_type, .. }, Ok(raw)) = (&dt, &want) { let n = raw.len() / clawhdf5_format::vl_data::element_size(os).max(1); let raw = &raw[..n * clawhdf5_format::vl_data::element_size(os)]; let want = VlResolver::new(slice, os, ls).string_bytes(raw); let got = VlResolver::new_in(self.st(), os, ls).string_bytes(raw); self.same("VL strings", &want, &got); let base = base_type.type_size() as usize; let want = VlResolver::new(slice, os, ls).sequences(raw, base); let got = VlResolver::new_in(self.st(), os, ls).sequences(raw, base); self.same("VL sequences", &want, &got); let want = read_vl_bytes(slice, raw, n as u64, os, ls); let got = read_vl_bytes_in(self.st(), raw, n as u64, os, ls); self.same("VL bytes", &want, &got); } } /// External VDS source files: siblings of the file being walked. fn resolver(&self) -> impl Fn(&str) -> Result>, FormatError> + use<> { let dir = self.dir.clone(); move |name: &str| { let (Some(dir), false) = (dir.as_ref(), name.contains("..") || name.starts_with('/')) else { return Ok(None); }; Ok(std::fs::read(dir.join(name)).ok()) } } /// A dataset's layout: VDS mappings, and fixed/extensible array chunk /// indexes. fn check_layout(&mut self, header: &ObjectHeader, os: u8, ls: u8) { let slice = self.slice; let find = |t: MessageType| { header .messages .iter() .find(|m| m.msg_type == t) .and_then(|m| shared_message::message_data_with_sohm(slice, m, os, ls).ok()) }; let Some(layout) = find(MessageType::DataLayout) else { return; }; let Ok(layout) = DataLayout::parse(&layout, os, ls) else { return; }; match &layout { DataLayout::Virtual { .. } => { let (mut want, mut got) = (layout.clone(), layout.clone()); let w = want.resolve_vds_mappings(slice, ls).map(|()| want); let g = got.resolve_vds_mappings_in(self.st(), ls).map(|()| got); self.same("VDS mappings", &w, &g); } DataLayout::Chunked { chunk_dimensions, btree_address: Some(addr), version: 4, chunk_index_type: Some(kind @ (3 | 4)), .. } => { let (Some(ds), Some(dt)) = (find(MessageType::Dataspace), find(MessageType::Datatype)) else { return; }; let (Ok(ds), Ok((dt, _))) = (Dataspace::parse(&ds, ls), Datatype::parse(&dt)) else { return; }; let rank = ds.dimensions.len(); if chunk_dimensions.len() < rank { return; } let dims = &chunk_dimensions[..rank]; let max = ds.max_dimensions.as_deref(); let es = dt.type_size(); if *kind == 3 { let h = FixedArrayHeader::parse(slice, *addr as usize, os, ls); self.same( "fixed array header", &h, &FixedArrayHeader::parse_in(self.st(), *addr, os, ls), ); let Ok(h) = h else { return }; let want = read_fixed_array_chunks(slice, &h, &ds.dimensions, max, dims, es, os, ls); let before = self.storage.bytes_read(); let got = read_fixed_array_chunks_in( self.st(), &h, &ds.dimensions, max, dims, es, os, ls, ); self.index_read(before); self.same("fixed array chunks", &want, &got); } else { let h = ExtensibleArrayHeader::parse(slice, *addr as usize, os, ls); let got = ExtensibleArrayHeader::parse_in(self.st(), *addr, os, ls); self.same("extensible array header", &h, &got); let Ok(h) = h else { return }; let want = read_extensible_array_chunks( slice, &h, &ds.dimensions, max, dims, es, os, ls, ); let before = self.storage.bytes_read(); let got = read_extensible_array_chunks_in( self.st(), &h, &ds.dimensions, max, dims, es, os, ls, ); self.index_read(before); self.same("extensible array chunks", &want, &got); } } _ => {} } } } fn check_file(path: &Path, tally: &mut Tally) { let Ok(bytes) = std::fs::read(path) else { return; }; check_bytes_in( &path.display().to_string(), &bytes, path.parent().map(Path::to_path_buf), tally, ); } fn check_bytes(name: &str, bytes: &[u8], tally: &mut Tally) { check_bytes_in(name, bytes, None, tally); } fn check_bytes_in(name: &str, bytes: &[u8], dir: Option, tally: &mut Tally) { let Ok((_, hdf5)) = split_user_block(bytes) else { return; }; let storage = CountingStorage::new(hdf5.to_vec()); let before = (tally.checks, tally.objects); let mut walk = Walk { slice: hdf5, storage: &storage, name: name.to_string(), dir, tally, }; walk.run(); tally.files += 1; tally.reads += storage.reads(); tally.bytes += storage.bytes_read(); if std::env::var("CLAWHDF5_STORAGE_REPORT").is_ok_and(|v| v == "1") { eprintln!( "{:>6} objects {:>7} checks {:>8} reads {:>12} bytes {}", tally.objects - before.1, tally.checks - before.0, storage.reads(), storage.bytes_read(), name ); } } fn hdf5_files(dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for e in entries.flatten() { let p = e.path(); if p.is_dir() { hdf5_files(&p, out); } else if p .extension() .and_then(|x| x.to_str()) .is_some_and(|x| matches!(x, "h5" | "hdf5" | "he5" | "nc" | "h5ad" | "hdf")) { out.push(p); } } } #[test] fn fixtures_parse_identically_through_storage() { let mut files = Vec::new(); hdf5_files( &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"), &mut files, ); files.sort(); assert!(files.len() >= 40, "{} fixtures", files.len()); let mut tally = Tally::default(); for f in &files { check_file(f, &mut tally); } eprintln!("fixtures: {tally:?}"); assert!(tally.objects >= 150 && tally.checks >= 1000, "{tally:?}"); // Reads really went through read_at. assert!(tally.reads > tally.objects as u64); } #[test] fn corpus_parses_identically_through_storage() { let Ok(dirs) = std::env::var("CLAWHDF5_STORAGE_CORPUS") else { eprintln!("CLAWHDF5_STORAGE_CORPUS not set; skipping the corpus"); return; }; let mut files = Vec::new(); for d in std::env::split_paths(&dirs) { hdf5_files(&d, &mut files); } files.sort(); let mut tally = Tally::default(); for f in &files { check_file(f, &mut tally); } eprintln!("corpus: {tally:?}"); assert!(tally.files > 0); } /// A storage that misbehaves: fails its `fail_at`-th read (1-based, `0` /// never), and, with `short`, serves one byte less than asked for inside /// the file (a truncated response). struct Adversary { data: Vec, reads: std::sync::atomic::AtomicUsize, fail_at: usize, short: bool, } impl Storage for Adversary { fn read_at(&self, offset: u64, len: usize) -> Result, FormatError> { let n = self .reads .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; if n == self.fail_at { return Err(FormatError::Storage(format!( "injected failure of read {n}" ))); } let got = self.data.as_slice().read_at(offset, len)?; let mut v = got.into_owned(); if self.short && v.len() > 1 { v.pop(); } Ok(std::borrow::Cow::Owned(v)) } fn len(&self) -> u64 { self.data.len() as u64 } } /// Every group listing and every dataset's values (whole, fill-aware and /// through a selection) read through a storage that fails or serves short /// reads: each result is an error or exactly the in-memory result, never /// other data; and a failing read is reported as that failure. #[test] fn misbehaving_storage_never_returns_wrong_data() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); let mut files = Vec::new(); hdf5_files(&dir, &mut files); files.sort(); let (mut compared, mut failures_seen) = (0usize, 0usize); for path in &files { let Ok(bytes) = std::fs::read(path) else { continue; }; let Ok((_, hdf5)) = split_user_block(&bytes) else { continue; }; let Ok(sb) = Superblock::parse(hdf5, 0) else { continue; }; // The whole read of every object, as one result to compare. let everything = |file: &dyn Storage| -> Result { let (os, ls) = (sb.offset_size, sb.length_size); let mut out = String::new(); let mut queue = VecDeque::from([sb.root_group_address]); let mut seen = HashSet::new(); while let Some(addr) = queue.pop_front() { if seen.len() > 200 || !seen.insert(addr) { continue; } let header = ObjectHeader::parse_in(file, addr, os, ls)?; let find = |t: MessageType| { header .messages .iter() .find(|m| m.msg_type == t) .map(|m| message_data_with_sohm_in(file, m, os, ls)) .transpose() }; if let (Some(dt), Some(ds), Some(dl)) = ( find(MessageType::Datatype)?, find(MessageType::Dataspace)?, find(MessageType::DataLayout)?, ) { let dt = Datatype::parse(&dt)?.0; let ds = Dataspace::parse(&ds, ls)?; let dl = DataLayout::parse(&dl, os, ls)?; let pl = find(MessageType::FilterPipeline)? .map(|p| FilterPipeline::parse(&p)) .transpose()?; let data = read_full_with_fill_in( &header.messages, file, &dl, &ds, dt.type_size() as usize, os, ls, || read_raw_data_full_in(file, &dl, &ds, &dt, pl.as_ref(), os, ls), ); out.push_str(&format!("{addr}: {data:?}\n")); if let Some(&d0) = ds.dimensions.first() { let rank = ds.dimensions.len(); let sel = Selection::Hyperslab { start: vec![0; rank], stride: vec![1; rank], count: std::iter::once(d0.div_ceil(2)) .chain(ds.dimensions[1..].iter().copied()) .collect(), block: vec![1; rank], }; let part = read_raw_data_selection_in( file, &dl, &ds, &dt, pl.as_ref(), os, ls, &sel, ); out.push_str(&format!("{addr} half: {part:?}\n")); } } let children = group_v2::resolve_group_children_in(file, &sb, addr); out.push_str(&format!("{addr} children: {children:?}\n")); if let Ok(c) = children { queue.extend(c.iter().map(|c| c.object_header_address)); } } Ok(out) }; let want = everything(&hdf5); let counting = CountingStorage::new(hdf5.to_vec()); assert_eq!( format!("{:?}", everything(&counting)), format!("{want:?}"), "{}", path.display() ); let total = counting.reads() as usize; let step = (total / 25).max(1); for fail_at in (1..=total).step_by(step) { for short in [false, true] { if short && fail_at != 1 { continue; } let adv = Adversary { data: hdf5.to_vec(), reads: Default::default(), fail_at: if short { 0 } else { fail_at }, short, }; let got = everything(&adv); compared += 1; match (&got, &want) { (Ok(g), Ok(w)) => { // Per-object results inside may be errors; values // that were read must be the right ones. for (gl, wl) in g.lines().zip(w.lines()) { if gl != wl { assert!( gl.contains("Err("), "{}: fail_at {fail_at} short {short}:\n got {gl}\n want {wl}", path.display() ); failures_seen += 1; // A listing that failed ends the walk // differently from here on. if gl.contains("children: Err(") { break; } } } } (Err(FormatError::Storage(_)), _) => failures_seen += 1, (Err(e), Ok(_)) => panic!( "{}: fail_at {fail_at} short {short}: {e:?} instead of a storage error", path.display() ), (Err(_), Err(_)) => {} // A listing failed, so the walk never reached the // object that fails in memory. (Ok(g), Err(e)) => assert!( g.contains("children: Err(Storage"), "{}: fail_at {fail_at} short {short}: read where memory fails ({e:?})", path.display() ), } } } } eprintln!("misbehaving storage: {compared} runs, {failures_seen} failures reported"); assert!( compared > 500 && failures_seen > 100, "{compared} {failures_seen}" ); } /// A read_at-only storage that also counts `read_ranges` calls and ranges. struct BatchCounting { inner: CountingStorage, batches: std::sync::atomic::AtomicUsize, ranges: std::sync::atomic::AtomicUsize, } impl Storage for BatchCounting { fn read_at(&self, offset: u64, len: usize) -> Result, FormatError> { self.inner.read_at(offset, len) } fn len(&self) -> u64 { self.inner.len() } fn read_ranges( &self, ranges: &[std::ops::Range], ) -> Result>, FormatError> { use std::sync::atomic::Ordering::Relaxed; self.batches.fetch_add(1, Relaxed); self.ranges.fetch_add(ranges.len(), Relaxed); ranges .iter() .map(|r| self.inner.read_at(r.start, (r.end - r.start) as usize)) .collect() } } /// A chunked read lists its chunks, then fetches all their bytes with one /// `read_ranges` call (a remote backend coalesces and parallelises it), and /// a selection fetches only the chunks it overlaps, in one call too. #[test] fn chunked_reads_fetch_their_chunks_in_one_batch() { use std::sync::atomic::Ordering::Relaxed; let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); let mut datasets = 0; for name in [ "chunked_large.h5", "chunked_deflate.h5", "chunked_2d.h5", "v4_fixed_array.h5", ] { let bytes = std::fs::read(dir.join(name)).unwrap(); let sb = Superblock::parse(&bytes, 0).unwrap(); let (os, ls) = (sb.offset_size, sb.length_size); let st = BatchCounting { inner: CountingStorage::new(bytes.clone()), batches: Default::default(), ranges: Default::default(), }; 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 dl = DataLayout::parse(&dl, os, ls).unwrap(); if !matches!(dl, DataLayout::Chunked { .. }) { continue; } let dt = Datatype::parse(&msg(MessageType::Datatype).unwrap()) .unwrap() .0; let ds = Dataspace::parse(&msg(MessageType::Dataspace).unwrap(), ls).unwrap(); let pl = msg(MessageType::FilterPipeline).map(|p| FilterPipeline::parse(&p).unwrap()); let es = dt.type_size() as usize; let (chunks, _) = list_chunks(&bytes, &dl, &ds, es, os, ls).unwrap(); let want = read_raw_data_full(&bytes, &dl, &ds, &dt, pl.as_ref(), os, ls).unwrap(); st.batches.store(0, Relaxed); st.ranges.store(0, Relaxed); let got = read_raw_data_full_in(&st, &dl, &ds, &dt, pl.as_ref(), os, ls).unwrap(); assert_eq!(got, want, "{name} {}", child.name); assert_eq!(st.batches.load(Relaxed), 1, "{name} {}", child.name); assert_eq!( st.ranges.load(Relaxed), chunks.len(), "{name} {}", child.name ); // The first chunk only. let rank = ds.dimensions.len(); let sel = Selection::Hyperslab { start: vec![0; rank], stride: vec![1; rank], count: vec![1; rank], block: vec![1; rank], }; let want = read_raw_data_selection(&bytes, &dl, &ds, &dt, pl.as_ref(), os, ls, &sel); st.batches.store(0, Relaxed); st.ranges.store(0, Relaxed); let got = read_raw_data_selection_in(&st, &dl, &ds, &dt, pl.as_ref(), os, ls, &sel); assert_eq!(got, want, "{name} {}", child.name); if chunks.len() > 2 { assert_eq!(st.batches.load(Relaxed), 1, "{name} {}", child.name); assert_eq!(st.ranges.load(Relaxed), 1, "{name} {}", child.name); } datasets += 1; } } assert!(datasets >= 4, "{datasets}"); } fn python() -> String { std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string()) } fn interop_required() -> bool { std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1") } /// Files h5py writes to cover what the fixtures do not: extensible arrays /// deep enough for super blocks and paged data blocks, paged fixed arrays, /// big symbol-table and dense groups, dense and shared attributes, a SOHM /// list and a SOHM B-tree, committed datatypes, and a user block. const GENERATE: &str = r#" import ctypes, glob, os, sys, h5py, numpy as np out = sys.argv[1] def p(n): return os.path.join(out, n) with h5py.File(p('ea.h5'), 'w', libver='latest') as f: # One unlimited dimension: extensible array. 3000 chunks reach super # blocks; 2-element chunks of int8 keep data small. d = f.create_dataset('ea', shape=(6000,), maxshape=(None,), chunks=(2,), dtype='i1') d[:] = np.arange(6000) % 100 d2 = f.create_dataset('ea_deflate', shape=(4000, 3), maxshape=(None, 3), chunks=(2, 3), dtype='f4', compression='gzip') d2[:] = np.random.default_rng(1).random((4000, 3)) d3 = f.create_dataset('ea_sparse', shape=(100000,), maxshape=(None,), chunks=(4,), dtype='i2') d3[0:8] = 1; d3[50000:50004] = 2; d3[99996:] = 3 fa = f.create_dataset('fa_paged', shape=(5000,), chunks=(1,), dtype='u1') fa[::3] = 7 fa2 = f.create_dataset('fa', shape=(40, 40), chunks=(8, 8), dtype='f8', compression='gzip') fa2[:] = 1.5 for i in range(30): f.attrs[f'a{i}'] = np.arange(i + 1) g = f.create_group('dense') for i in range(300): g.create_group(f'child{i:04d}').attrs['i'] = i f['committed'] = np.dtype([('x', 'i4'), ('y', 'f8')]) f.create_dataset('uses_committed', shape=(3,), dtype=f['committed']) f['uses_committed'].attrs.create('ta', data=np.zeros(2, dtype=f['committed'].dtype), dtype=f['committed']) f.attrs['vl'] = ['alpha', 'beta', 'gamma'] with h5py.File(p('v1_groups.h5'), 'w', libver='earliest', userblock_size=512) as f: for i in range(400): g = f.create_group(f'g{i:04d}') g.attrs['n'] = i f.create_dataset('x', data=np.arange(10)) # Paged chunk indexes whose data blocks are bigger than the storage reads # in one piece (1 MiB), with two chunks written: a fixed array of 300 000 # chunks (a 2.4 MB data block) and an extensible array grown to 1.2e9 (its # last data block holds 131 072 chunks: over 1 MiB). with h5py.File(p('big_paged.h5'), 'w', libver='latest') as f: d = f.create_dataset('fa', shape=(300000,), chunks=(1,), dtype='u1') d[5] = 1; d[250000] = 2 e = f.create_dataset('ea', shape=(1,), maxshape=(None,), chunks=(1,), dtype='u1') e.resize((1200000000,)); e[10] = 1; e[1100000000] = 3 libs = glob.glob(os.path.join(os.path.dirname(h5py.__file__), '..', 'h5py.libs', 'libhdf5-*.so*')) if libs: lib = ctypes.CDLL(libs[0]) lib.H5Pset_shared_mesg_nindexes.argtypes = [ctypes.c_int64, ctypes.c_uint] lib.H5Pset_shared_mesg_index.argtypes = [ctypes.c_int64, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint] lib.H5Pset_shared_mesg_phase_change.argtypes = [ctypes.c_int64, ctypes.c_uint, ctypes.c_uint] for name, list_max in [('sohm_list.h5', 50), ('sohm_btree.h5', 0)]: fcpl = h5py.h5p.create(h5py.h5p.FILE_CREATE) assert lib.H5Pset_shared_mesg_nindexes(fcpl.id, 1) >= 0 # datatype, dataspace, fill value, filter pipeline, attribute assert lib.H5Pset_shared_mesg_index(fcpl.id, 0, 0x02 | 0x04 | 0x08 | 0x10 | 0x20, 1) >= 0 assert lib.H5Pset_shared_mesg_phase_change(fcpl.id, list_max, 0) >= 0 fapl = h5py.h5p.create(h5py.h5p.FILE_ACCESS) fapl.set_libver_bounds(h5py.h5f.LIBVER_LATEST, h5py.h5f.LIBVER_LATEST) fid = h5py.h5f.create(p(name).encode(), h5py.h5f.ACC_TRUNC, fcpl=fcpl, fapl=fapl) with h5py.File(fid) as f: for i in range(20): d = f.create_dataset(f'd{i}', shape=(10, i + 1), dtype='f4', fillvalue=-1.0, chunks=(5, 1), compression='gzip') d.attrs['units'] = 'metres per second, a long enough string to share' d.attrs['scale'] = np.arange(20, dtype='f8') print('ok') "#; #[test] fn h5py_files_parse_identically_through_storage() { let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("storage_equivalence"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let out = Command::new(python()) .args(["-c", GENERATE, dir.to_str().unwrap()]) .output(); match out { Ok(o) if o.status.success() => {} Ok(o) if interop_required() => panic!( "h5py generation failed:\n{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr) ), Err(e) if interop_required() => panic!("python not available: {e}"), _ => { eprintln!("python3 with h5py unavailable; skipping"); return; } } let mut files = Vec::new(); hdf5_files(&dir, &mut files); files.sort(); let names: Vec<_> = files .iter() .map(|f| f.file_name().unwrap().to_string_lossy().into_owned()) .collect(); for want in ["ea.h5", "v1_groups.h5", "big_paged.h5"] { assert!(names.iter().any(|n| n == want), "{names:?}"); } if interop_required() { assert!(names.iter().any(|n| n == "sohm_btree.h5"), "{names:?}"); } let mut tally = Tally::default(); for f in &files { if f.ends_with("big_paged.h5") { // Only the pages in use are read, not the whole data blocks: // read in one piece, the fixed array took 2.4 MB and the // extensible array 1.2 MB (its super block's page bitmap and // block addresses are most of what remains). let mut big = Tally::default(); check_file(f, &mut big); eprintln!("big paged blocks: {big:?}"); assert_eq!(big.chunk_indexes, 2, "{big:?}"); assert!(big.max_chunk_index_bytes < 256 << 10, "{big:?}"); // Truncated anywhere, the page-by-page reads still agree with // the slice reads (errors included). let bytes = std::fs::read(f).unwrap(); for cut in (0..bytes.len()).step_by(bytes.len() / 97) { check_bytes( &format!("big_paged.h5 cut at {cut}"), &bytes[..cut], &mut big, ); } eprintln!("big paged blocks, truncated: {big:?}"); assert!(big.chunk_indexes > 100, "{big:?}"); } check_file(f, &mut tally); } eprintln!("h5py files: {tally:?}"); assert!(tally.objects >= 700, "{tally:?}"); }