writer: v2 B-trees with internal nodes (no 65 535-record limit)

Dense link and attribute indexes and the chunk index for several
unlimited dimensions were single leaves, capping them at 65 535
records. btree_v2_write builds trees of any depth, with node capacities
and pointer widths from libhdf5's H5B2__hdr_init arithmetic (now shared
with the reader as btree_v2::node_info) and libhdf5's node sizes (512
dense, 2048 chunks). Indexes that fit the old one-leaf layout are
written byte for byte as before (compared for 10..65 535 links, attrs
and chunks, tracked and filtered).

Tests: 100 000 links (short names; long names with creation order),
70 000 attributes, 200 000 chunks (and 80 000 deflated), read by h5py,
h5dump and clawhdf5 and edited by h5py r+; h5rs check on the same
shapes, asserting depths 2-3.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 10:12:07 -05:00
co-authored by Claude Opus 5.5
parent 7acfb79584
commit d63c76e7ab
11 changed files with 1005 additions and 185 deletions
@@ -1024,3 +1024,75 @@ fn check_files_with_big_dense_storage() {
assert_eq!(code(&o), 0, "{p}:\n{}", stdout(&o));
assert!(stdout(&o).contains("no problems found"), "{}", stdout(&o));
}
/// `(type, depth)` of every v2 B-tree header in a file written with 8-byte
/// offsets and lengths (found by signature and checksum).
fn btree_v2_depths(data: &[u8]) -> Vec<(u8, u16)> {
const LEN: usize = 4 + 1 + 1 + 4 + 2 + 2 + 1 + 1 + 8 + 2 + 8;
let mut out = Vec::new();
for at in 0..data.len().saturating_sub(LEN + 4) {
if &data[at..at + 4] != b"BTHD" {
continue;
}
let stored = u32::from_le_bytes(data[at + LEN..at + LEN + 4].try_into().unwrap());
if jenkins_lookup3(&data[at..at + LEN]) == stored {
out.push((
data[at + 5],
u16::from_le_bytes([data[at + 12], data[at + 13]]),
));
}
}
out
}
#[test]
fn check_files_with_deep_btrees() {
// Dense indexes and a chunk index too big for one leaf: the writer then
// builds internal nodes, whose child pointers carry record counts in
// widths derived from the node size. `check` reads every record through
// them and compares the count with the header's.
use clawhdf5::{AttrValue, FileBuilder};
const U: u64 = u64::MAX;
let dir = tempfile::tempdir().unwrap();
let mut b = FileBuilder::new();
let x = b.create_dataset("x");
x.with_i32_data(&[7]);
for i in 0..70_000 {
x.set_attr(&format!("attr_{i}"), AttrValue::I64(i));
}
let mut g = b.create_group("g");
g.track_order(true);
for i in 0..100_000 {
g.add_hard_link(&format!("k{i}"), "/x");
}
b.add_group(g.finish());
let p = dir.path().join("deep.h5").to_string_lossy().into_owned();
b.write(&p).unwrap();
let o = h5rs(&["check", &p]);
assert_eq!(code(&o), 0, "{p}:\n{}", stdout(&o));
assert!(stdout(&o).contains("no problems found"), "{}", stdout(&o));
let mut depths = btree_v2_depths(&std::fs::read(&p).unwrap());
depths.sort();
assert_eq!(depths, [(5, 3), (6, 3), (8, 3)]);
let mut b = FileBuilder::new();
b.create_dataset("d")
.with_i32_data(&(0..200_000).collect::<Vec<i32>>())
.with_shape(&[400, 500])
.with_chunks(&[1, 1])
.with_maxshape(&[U, U]);
b.create_dataset("z")
.with_i32_data(&(0..70_000).collect::<Vec<i32>>())
.with_shape(&[70, 1000])
.with_chunks(&[1, 1])
.with_maxshape(&[U, U])
.with_deflate(1);
let p = dir.path().join("chunks.h5").to_string_lossy().into_owned();
b.write(&p).unwrap();
let o = h5rs(&["check", "--data", &p]);
assert_eq!(code(&o), 0, "{p}:\n{}", stdout(&o));
assert!(stdout(&o).contains("no problems found"), "{}", stdout(&o));
let mut depths = btree_v2_depths(&std::fs::read(&p).unwrap());
depths.sort();
assert_eq!(depths, [(10, 2), (11, 2)]);
}