Read HDF5 1.6-era files, user blocks, VDS, dense attributes and large groups #13
@@ -299,6 +299,12 @@
|
|||||||
flag; Fletcher32 ahead of deflate (NetCDF-4's order). Unknown-message flags
|
flag; Fletcher32 ahead of deflate (NetCDF-4's order). Unknown-message flags
|
||||||
follow libhdf5 (`tbogus.h5`): "fail if unknown" is refused, "fail if unknown
|
follow libhdf5 (`tbogus.h5`): "fail if unknown" is refused, "fail if unknown
|
||||||
and writing" is ignored by a reader.
|
and writing" is ignored by a reader.
|
||||||
|
- `clawhdf5-format` reader — dense groups and attributes (links or
|
||||||
|
attributes kept in a fractal heap indexed by a v2 B-tree):
|
||||||
|
- A link heap larger than the root indirect block's direct rows (512 KiB
|
||||||
|
with libhdf5's defaults: a few thousand long link names, or ~20 000 short
|
||||||
|
ones) could not be listed: child indirect blocks were given the wrong
|
||||||
|
number of rows, so every link stored in one was unreachable.
|
||||||
- `clawhdf5-format` writer — **files libhdf5 rejects or reads wrong:**
|
- `clawhdf5-format` writer — **files libhdf5 rejects or reads wrong:**
|
||||||
- Extensible Array (one unlimited dimension): chunks from index 244 on were
|
- Extensible Array (one unlimited dimension): chunks from index 244 on were
|
||||||
written but never indexed and read as 0, by libhdf5 and by us.
|
written but never indexed and read as 0, by libhdf5 and by us.
|
||||||
|
|||||||
@@ -418,23 +418,27 @@ impl FractalHeapHeader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we have indirect block rows
|
// If we have indirect block rows
|
||||||
|
// A child indirect block in row r spans exactly that row's block size
|
||||||
|
// of heap space, so it has as many rows as a table of that total size
|
||||||
|
// needs (not `row - start_indirect + 1`, which undercounts and makes
|
||||||
|
// every object past the root's direct rows unreachable).
|
||||||
for row in start_indirect..nrows_usize {
|
for row in start_indirect..nrows_usize {
|
||||||
let _block_size = self.block_size_for_row(row);
|
let child_space = self.block_size_for_row(row);
|
||||||
let child_nrows = row - start_indirect + 1;
|
let child_nrows = self.rows_for_size(child_space);
|
||||||
|
|
||||||
for _col in 0..tw {
|
for _col in 0..tw {
|
||||||
let child_addr = read_offset(file_data, pos, offset_size)?;
|
let child_addr = read_offset(file_data, pos, offset_size)?;
|
||||||
pos += offset_size as usize;
|
pos += offset_size as usize;
|
||||||
|
|
||||||
if !is_undefined(child_addr, offset_size) {
|
let block_end = current_heap_offset.saturating_add(child_space);
|
||||||
// Calculate total heap space covered by this indirect block child
|
if !is_undefined(child_addr, offset_size)
|
||||||
let total_child_space = self.indirect_block_heap_size(child_nrows);
|
&& target_offset >= current_heap_offset
|
||||||
let block_end = current_heap_offset + total_child_space;
|
&& target_offset < block_end
|
||||||
if target_offset >= current_heap_offset && target_offset < block_end {
|
{
|
||||||
return self.read_from_indirect_block(
|
return self.read_from_indirect_block(
|
||||||
file_data,
|
file_data,
|
||||||
child_addr as usize,
|
child_addr as usize,
|
||||||
child_nrows as u16,
|
child_nrows,
|
||||||
current_heap_offset,
|
current_heap_offset,
|
||||||
target_offset,
|
target_offset,
|
||||||
length,
|
length,
|
||||||
@@ -442,11 +446,7 @@ impl FractalHeapHeader {
|
|||||||
depth_remaining - 1,
|
depth_remaining - 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
current_heap_offset += total_child_space;
|
current_heap_offset = block_end;
|
||||||
} else {
|
|
||||||
let total_child_space = self.indirect_block_heap_size(child_nrows);
|
|
||||||
current_heap_offset += total_child_space;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,25 +475,24 @@ impl FractalHeapHeader {
|
|||||||
log2 + 2
|
log2 + 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rows an indirect block needs to span `size` bytes of heap space:
|
||||||
|
/// `log2(size) - log2(starting_block_size * table_width) + 1`, as
|
||||||
|
/// libhdf5's `H5HF__dtable_size_to_rows`.
|
||||||
|
fn rows_for_size(&self, size: u64) -> u16 {
|
||||||
|
let log2 = |v: u64| 63u32.saturating_sub(v.max(1).leading_zeros());
|
||||||
|
let first_row_bits = log2(self.starting_block_size) + log2(u64::from(self.table_width));
|
||||||
|
(log2(size).saturating_sub(first_row_bits) + 1) as u16
|
||||||
|
}
|
||||||
|
|
||||||
/// Get block size for a given row in the doubling table.
|
/// Get block size for a given row in the doubling table.
|
||||||
fn block_size_for_row(&self, row: usize) -> u64 {
|
fn block_size_for_row(&self, row: usize) -> u64 {
|
||||||
let sbs = self.starting_block_size;
|
let sbs = self.starting_block_size;
|
||||||
if row <= 1 {
|
if row <= 1 {
|
||||||
sbs
|
sbs
|
||||||
} else {
|
} else {
|
||||||
sbs * (1u64 << (row - 1))
|
sbs.saturating_mul(1u64.checked_shl((row - 1) as u32).unwrap_or(u64::MAX))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total heap space covered by an indirect block with the given number of rows.
|
|
||||||
fn indirect_block_heap_size(&self, nrows: usize) -> u64 {
|
|
||||||
let tw = self.table_width as u64;
|
|
||||||
let mut total = 0u64;
|
|
||||||
for row in 0..nrows {
|
|
||||||
total += self.block_size_for_row(row) * tw;
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Dense ("new-style") link and attribute storage written by libhdf5 (via
|
||||||
|
//! h5py): groups whose links live in a fractal heap indexed by a v2 B-tree,
|
||||||
|
//! and objects whose attributes do. Every listing and value is compared with
|
||||||
|
//! what h5py itself reports for the same file.
|
||||||
|
//!
|
||||||
|
//! Skipped when python3 with h5py is unavailable, unless
|
||||||
|
//! `CLAWHDF5_REQUIRE_INTEROP=1`.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use clawhdf5::File;
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn python_available() -> bool {
|
||||||
|
Command::new(python())
|
||||||
|
.args(["-c", "import h5py"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! skip_if_no_python {
|
||||||
|
() => {
|
||||||
|
if !python_available() {
|
||||||
|
assert!(
|
||||||
|
!interop_required(),
|
||||||
|
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
|
||||||
|
);
|
||||||
|
eprintln!("SKIP: python3 with h5py not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_python(script: &str) -> String {
|
||||||
|
let output = Command::new(python())
|
||||||
|
.args(["-c", script])
|
||||||
|
.output()
|
||||||
|
.expect("failed to run python");
|
||||||
|
if !output.status.success() {
|
||||||
|
panic!(
|
||||||
|
"Python script failed:\nSTDOUT: {}\nSTDERR: {}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Have h5py write a file with `body` (which sees `f`, `h5py` and `np`),
|
||||||
|
/// returning the temp dir holding it and its path.
|
||||||
|
fn h5py_file(body: &str) -> (tempfile::TempDir, String) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("dense.h5").display().to_string();
|
||||||
|
let script = format!(
|
||||||
|
"import h5py, numpy as np\n\
|
||||||
|
with h5py.File(r'{path}', 'w', libver='latest') as f:\n{}",
|
||||||
|
indent(body)
|
||||||
|
);
|
||||||
|
run_python(&script);
|
||||||
|
(dir, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indent(body: &str) -> String {
|
||||||
|
body.lines()
|
||||||
|
.map(|l| format!(" {l}\n"))
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The datasets and groups h5py lists in `group`, sorted: links h5py can
|
||||||
|
/// resolve (hard and soft), without dangling soft links or external links.
|
||||||
|
fn h5py_listing(path: &str, group: &str) -> (Vec<String>, Vec<String>) {
|
||||||
|
let out = run_python(&format!(
|
||||||
|
"import h5py\n\
|
||||||
|
ds, gs = [], []\n\
|
||||||
|
with h5py.File(r'{path}', 'r') as f:\n\
|
||||||
|
\x20 g = f[{group:?}]\n\
|
||||||
|
\x20 for k in g.keys():\n\
|
||||||
|
\x20 if isinstance(g.get(k, getlink=True), h5py.ExternalLink):\n\
|
||||||
|
\x20 continue\n\
|
||||||
|
\x20 try:\n\
|
||||||
|
\x20 o = g[k]\n\
|
||||||
|
\x20 except Exception:\n\
|
||||||
|
\x20 continue\n\
|
||||||
|
\x20 (ds if isinstance(o, h5py.Dataset) else gs).append(k)\n\
|
||||||
|
print('\\x1f'.join(sorted(ds)))\n\
|
||||||
|
print('\\x1f'.join(sorted(gs)))\n"
|
||||||
|
));
|
||||||
|
let mut lines = out.lines();
|
||||||
|
let split = |l: Option<&str>| -> Vec<String> {
|
||||||
|
l.unwrap_or("")
|
||||||
|
.split('\x1f')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let ds = split(lines.next());
|
||||||
|
let gs = split(lines.next());
|
||||||
|
(ds, gs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn our_listing(path: &str, group: &str) -> (Vec<String>, Vec<String>) {
|
||||||
|
let f = File::open(path).unwrap();
|
||||||
|
let g = f.group(group).unwrap();
|
||||||
|
let mut ds = g.datasets().unwrap();
|
||||||
|
let mut gs = g.groups().unwrap();
|
||||||
|
ds.sort();
|
||||||
|
gs.sort();
|
||||||
|
(ds, gs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_same_listing(path: &str, group: &str) {
|
||||||
|
let ours = our_listing(path, group);
|
||||||
|
let theirs = h5py_listing(path, group);
|
||||||
|
assert_eq!(ours.0.len(), theirs.0.len(), "dataset count in {group}");
|
||||||
|
assert_eq!(ours, theirs, "listing of {group}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_group_whose_heap_outgrows_the_root_direct_rows() {
|
||||||
|
skip_if_no_python!();
|
||||||
|
// Long link names make the link heap larger than the root indirect
|
||||||
|
// block's direct rows can hold (512 KiB with h5py's defaults), so links
|
||||||
|
// live in child indirect blocks. Those were sized from the wrong row
|
||||||
|
// count, and every link past the direct rows was unreachable.
|
||||||
|
let (_dir, path) = h5py_file(
|
||||||
|
"t = f.create_dataset('t', data=[1.0])\n\
|
||||||
|
g = f.create_group('g')\n\
|
||||||
|
for i in range(2500):\n\
|
||||||
|
\x20 g['n%05d_' % i + 'x' * 240] = t\n",
|
||||||
|
);
|
||||||
|
assert_same_listing(&path, "g");
|
||||||
|
let f = File::open(&path).unwrap();
|
||||||
|
let last = format!("g/n02499_{}", "x".repeat(240));
|
||||||
|
assert_eq!(f.dataset(&last).unwrap().read_f64().unwrap(), vec![1.0]);
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ the VDS item, which is marked.
|
|||||||
- **Groups and links:**
|
- **Groups and links:**
|
||||||
- Groups with a user-defined link type (e.g. 187) cannot be listed.
|
- Groups with a user-defined link type (e.g. 187) cannot be listed.
|
||||||
- Dense groups with more than about 22 000 links cannot be listed.
|
- Dense groups with more than about 22 000 links cannot be listed.
|
||||||
|
*Partly fixed 2026-09-25:* a link heap past its root block's direct rows
|
||||||
|
(child indirect blocks) is now read.
|
||||||
- Soft links are left out of `datasets()`.
|
- Soft links are left out of `datasets()`.
|
||||||
- **Dense attributes:** a large attribute stored as a fractal-heap "huge"
|
- **Dense attributes:** a large attribute stored as a fractal-heap "huge"
|
||||||
object makes every attribute on the object fail. This affects real NetCDF
|
object makes every attribute on the object fail. This affects real NetCDF
|
||||||
|
|||||||
Reference in New Issue
Block a user