Merge branch 'feat/p3-editor-coverage' into feat/p3-remote-editor

# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	docs/design/range-reads.md
This commit is contained in:
osobh
2026-09-26 19:17:46 -05:00
23 changed files with 7218 additions and 533 deletions
File diff suppressed because it is too large Load Diff
+154 -80
View File
@@ -113,7 +113,8 @@ impl Model {
.fold(0u64, |a, (&x, &d)| a * d + x) as usize
}
/// Grow to `shape`, new elements `fill`.
/// Change the extent to `shape`: elements inside both keep their
/// values, new ones are `fill`.
fn resize(&mut self, shape: &[u64], fill: i32) {
let old = self.clone();
*self = Self::new(shape, |_| fill);
@@ -125,8 +126,10 @@ impl Model {
c[d] = r % old.shape[d];
r /= old.shape[d];
}
let i = self.index(&c);
self.data[i] = old.data[flat as usize];
if c.iter().zip(shape).all(|(x, s)| x < s) {
let i = self.index(&c);
self.data[i] = old.data[flat as usize];
}
}
}
@@ -293,9 +296,24 @@ fn append_many_gzip() {
}
}
/// Random operations — grow, hyperslab writes, point writes, attributes —
/// on a 2-D dataset with one unlimited dimension, checked against a model
/// after every few operations.
/// A random attribute value: a scalar, an int64 array, a short string or
/// one larger than a heap's managed-object limit (a huge heap object once
/// the attributes are in dense storage).
fn random_attr(rng: &mut Rng) -> AttrValue {
match rng.below(8) {
0..=2 => AttrValue::I64(rng.next() as i64 >> 3),
3..=4 => AttrValue::I64Array((0..1 + rng.below(40)).map(|k| k as i64 * 7).collect()),
5..=6 => AttrValue::String("s".repeat(1 + rng.below(200) as usize)),
_ => AttrValue::String("h".repeat(5000 + rng.below(100) as usize)),
}
}
/// Random operations — growth and shrinking along any dimension, hyperslab
/// and point writes, attributes (enough names to move them to dense storage
/// on version-2 object headers, replaced with values of any size) — on a
/// 2-D dataset with one unlimited dimension and one with two (a version-2
/// B-tree chunk index under `v114`/`latest`), checked against a model (and
/// through h5py, numpy) after every few operations.
fn random_ops(libver: &str, h5dump: bool, extra: &str, tag: &str, seed: u64) {
let dir = tmpdir();
let path = dir.path().join(format!("rand_{tag}.h5"));
@@ -304,106 +322,159 @@ fn random_ops(libver: &str, h5dump: bool, extra: &str, tag: &str, seed: u64) {
with h5py.File({p:?}, 'w', libver={libver}) as f:\n\
\x20 f.create_dataset('m', shape=(4, 7), maxshape=(None, 7), chunks=(3, 4), \
dtype='<i4', fillvalue=-9{extra})\n\
\x20 f['m'][1:3, 2:6] = 5\n",
\x20 f['m'][1:3, 2:6] = 5\n\
\x20 f.create_dataset('b', shape=(5, 6), maxshape=(None, None), chunks=(2, 4), \
dtype='<i4', fillvalue=3{extra})\n\
\x20 f['b'][0:4, 1:5] = 8\n",
p = path.to_str().unwrap()
));
let mut m = Model::new(&[4, 7], |_| -9);
m.write_block(&[1, 2], &[2, 4], &[5; 8]);
let mut attrs: Vec<(String, i64)> = Vec::new();
let mut models = [Model::new(&[4, 7], |_| -9), Model::new(&[5, 6], |_| 3)];
models[0].write_block(&[1, 2], &[2, 4], &[5; 8]);
models[1].write_block(&[0, 1], &[4, 4], &[8; 16]);
let fills = [-9, 3];
let names = ["m", "b"];
let mut attrs: Vec<(String, AttrValue)> = Vec::new();
let mut rng = Rng(seed);
let mut ed = FileEditor::open(&path).unwrap();
for step in 0..120 {
match rng.below(10) {
0..=1 => {
let rows = m.shape[0] + 1 + rng.below(5);
ed.resize("m", &[rows, 7]).unwrap();
m.resize(&[rows, 7], -9);
for step in 0..160 {
let d = rng.below(2) as usize;
let name = names[d];
let m = &mut models[d];
match rng.below(12) {
0..=2 => {
// Grow or shrink: dimension 1 of "m" is fixed at 7.
let rows = rng.below(m.shape[0] + 6);
let cols = if d == 0 { 7 } else { rng.below(m.shape[1] + 5) };
ed.resize(name, &[rows, cols]).unwrap();
m.resize(&[rows, cols], fills[d]);
}
2..=6 => {
3..=7 if m.shape.iter().all(|&s| s > 0) => {
let r0 = rng.below(m.shape[0]);
let c0 = rng.below(7);
let c0 = rng.below(m.shape[1]);
let cnt = [
1 + rng.below((m.shape[0] - r0).min(6)),
1 + rng.below(7 - c0),
1 + rng.below((m.shape[1] - c0).min(6)),
];
let n = cnt[0] * cnt[1];
let vals: Vec<i32> = (0..n).map(|_| (rng.next() % 100_000) as i32).collect();
ed.write_values("m", &block(&[r0, c0], &cnt), &vals)
ed.write_values(name, &block(&[r0, c0], &cnt), &vals)
.unwrap();
m.write_block(&[r0, c0], &cnt, &vals);
}
7 => {
8 if m.shape.iter().all(|&s| s > 0) => {
let pts: Vec<Vec<u64>> = (0..1 + rng.below(4))
.map(|_| vec![rng.below(m.shape[0]), rng.below(7)])
.map(|_| vec![rng.below(m.shape[0]), rng.below(m.shape[1])])
.collect();
let vals: Vec<i32> = pts.iter().map(|_| rng.next() as i32).collect();
ed.write_values("m", &Selection::Points(pts.clone()), &vals)
ed.write_values(name, &Selection::Points(pts.clone()), &vals)
.unwrap();
for (p, v) in pts.iter().zip(&vals) {
let i = m.index(p);
m.data[i] = *v;
}
}
_ => {
let k = rng.below(6);
let name = format!("a{k}");
let v = rng.next() as i64;
match ed.set_attr("m", &name, &AttrValue::I64(v)) {
9..=11 => {
let k = rng.below(20);
let aname = format!("a{k}");
let v = random_attr(&mut rng);
match ed.set_attr("m", &aname, &v) {
Ok(()) => {
attrs.retain(|(n, _)| *n != name);
attrs.push((name, v));
attrs.retain(|(n, _)| *n != aname);
attrs.push((aname, v));
}
Err(e) => panic!("set_attr {name}: {e}"),
// Replacing the only attribute in a heap block with one
// of another size would have libhdf5 free the block.
Err(Error::Unsupported(msg)) if msg.contains("last object") => {}
Err(e) => panic!("set_attr {aname}: {e}"),
}
}
_ => {}
}
if step % 30 == 29 {
if step % 40 == 39 {
drop(ed);
verify(&path, "m", &m);
for (n, m) in names.iter().zip(&models) {
verify(&path, n, m);
}
check_tools(&path, h5dump);
check_attrs(&path, "m", &attrs);
ed = FileEditor::open(&path).unwrap();
}
}
drop(ed);
verify(&path, "m", &m);
for (n, m) in names.iter().zip(&models) {
verify(&path, n, m);
}
check_attrs(&path, "m", &attrs);
py(&format!(
"import h5py, numpy as np\n\
with h5py.File({p:?}, 'r+') as f:\n\
\x20 d = f['m']\n\
\x20 n = d.shape[0]\n\
\x20 d.resize((n + 3, 7))\n\
\x20 d[n:, :] = 42\n\
\x20 d.attrs['from_h5py'] = 1.5\n",
\x20 for name, cols in (('m', 7), ('b', None)):\n\
\x20 d = f[name]\n\
\x20 n, c = d.shape\n\
\x20 d.resize((n + 3, cols or c + 2))\n\
\x20 d[n:, :] = 42\n\
\x20 f['m'].attrs['from_h5py'] = 1.5\n",
p = path.to_str().unwrap()
));
let n = m.shape[0];
m.resize(&[n + 3, 7], -9);
m.write_block(&[n, 0], &[3, 7], &[42; 21]);
verify(&path, "m", &m);
for (d, m) in models.iter_mut().enumerate() {
let (n, c) = (m.shape[0], m.shape[1]);
let c2 = if d == 0 { 7 } else { c + 2 };
m.resize(&[n + 3, c2], fills[d]);
m.write_block(&[n, 0], &[3, c2], &vec![42; (3 * c2) as usize]);
}
for (n, m) in names.iter().zip(&models) {
verify(&path, n, m);
}
check_tools(&path, h5dump);
check_attrs(&path, "m", &attrs);
}
fn check_attrs(path: &Path, obj: &str, attrs: &[(String, i64)]) {
/// Our reader and h5py see `attrs` on dataset `obj` (and h5py's count of
/// its attributes agrees with libhdf5's object info).
fn check_attrs(path: &Path, obj: &str, attrs: &[(String, AttrValue)]) {
let f = File::open(path).unwrap();
let got = f.dataset(obj).unwrap().attrs().unwrap();
for (n, v) in attrs {
match got.get(n) {
Some(AttrValue::I64(g)) => assert_eq!(g, v, "attribute {n}"),
other => panic!("attribute {n}: {other:?}"),
}
let g = got
.get(n)
.unwrap_or_else(|| panic!("attribute {n} missing"));
// Our reader reports a one-element array as a scalar.
let v = match v {
AttrValue::I64Array(a) if a.len() == 1 => &AttrValue::I64(a[0]),
v => v,
};
assert_eq!(format!("{g:?}"), format!("{v:?}"), "attribute {n}");
}
let want: Vec<String> = attrs.iter().map(|(n, v)| format!("{n:?}: {v}")).collect();
py(&format!(
let want: Vec<String> = attrs
.iter()
.map(|(n, v)| {
let pv = match v {
AttrValue::I64(x) => format!("{x}"),
AttrValue::I64Array(a) => format!("{a:?}"),
AttrValue::String(s) => format!("{s:?}"),
other => panic!("{other:?}"),
};
format!("{n:?}: {pv}")
})
.collect();
let script = format!(
"import h5py\n\
f = h5py.File({p:?}, 'r')\n\
want = {{{w}}}\n\
got = {{k: int(v) for k, v in f[{obj:?}].attrs.items() if k in want}}\n\
assert got == want, (got, want)\n",
a = f[{obj:?}].attrs\n\
def norm(v):\n\
\x20 v = v.decode() if isinstance(v, bytes) else v\n\
\x20 return v.tolist() if hasattr(v, 'tolist') else v\n\
got = {{k: norm(v) for k, v in a.items() if k in want}}\n\
assert got == want, sorted(set(want) ^ set(got))\n\
assert len(a) == h5py.h5o.get_info(f[{obj:?}].id).num_attrs == len(list(a))\n",
p = path.to_str().unwrap(),
w = want.join(", ")
));
);
let sp = path.with_extension("attrs.py");
std::fs::write(&sp, script).unwrap();
let o = Command::new(python()).arg(&sp).output().unwrap();
assert!(o.status.success(), "attribute check failed:\n{}", text(&o));
}
#[test]
@@ -411,7 +482,11 @@ fn random_operations_match_a_model() {
if !tools_ok() {
return;
}
let mut seed = 1;
// CLAWHDF5_EDIT_SEED runs the same workloads with other random choices.
let mut seed = std::env::var("CLAWHDF5_EDIT_SEED")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(1);
for (i, (lv, dump)) in LIBVERS.iter().enumerate() {
// h5dump has no LZF decoder (h5py's own filter).
for (j, (extra, lzf)) in [
@@ -752,19 +827,20 @@ fn overwrite_every_layout() {
}
check_tools(&path, *dump);
}
// A version-2 B-tree index can take new chunks only from libhdf5 for
// now: growing works, writing the new chunks is refused and changes
// nothing.
// A version-2 B-tree index takes new chunks too.
if *lv != "'earliest'" {
let mut ed = FileEditor::open(&path).unwrap();
ed.resize("bt2", &[8, 6]).unwrap();
let before = std::fs::read(&path).unwrap();
unsupported(ed.write_values("bt2", &block(&[6, 0], &[2, 6]), &[5; 12]));
assert!(
std::fs::read(&path).unwrap() == before,
"a refused edit changed the file"
);
models[12].resize(&[8, 6], 0);
ed.resize("bt2", &[8, 7]).unwrap();
models[12].resize(&[8, 7], 0);
let vals: Vec<i32> = (0..23).collect();
ed.write_values("bt2", &block(&[6, 0], &[2, 7]), &vals[..14])
.unwrap();
models[12].write_block(&[6, 0], &[2, 7], &vals[..14]);
ed.write_values("bt2", &block(&[0, 6], &[6, 1]), &vals[14..20])
.unwrap();
models[12].write_block(&[0, 6], &[6, 1], &vals[14..20]);
drop(ed);
verify(&path, "bt2", &models[12]);
}
// libhdf5 goes on modifying what we wrote.
py(&format!(
@@ -835,19 +911,16 @@ fn attributes_in_place() {
.unwrap();
want.push(("d", "units".into(), AttrValue::String("km".into())));
if *lv != "'earliest'" {
// Up to the compact limit (8) and no further; attributes in
// dense storage and tracked creation order are refused, and a
// refused edit writes nothing.
// Up to the compact limit (8), then into dense storage; objects
// already in dense storage and ones tracking creation order.
ed.set_attr("g", "eighth", &AttrValue::I64(8)).unwrap();
want.push(("g", "eighth".into(), AttrValue::I64(8)));
let before = std::fs::read(&path).unwrap();
unsupported(ed.set_attr("g", "ninth", &AttrValue::I64(9)));
unsupported(ed.set_attr("dense", "k0", &AttrValue::I64(1)));
unsupported(ed.set_attr("tracked", "b", &AttrValue::I64(1)));
assert!(
std::fs::read(&path).unwrap() == before,
"a refused edit changed the file"
);
ed.set_attr("g", "ninth", &AttrValue::I64(9)).unwrap();
want.push(("g", "ninth".into(), AttrValue::I64(9)));
ed.set_attr("dense", "k0", &AttrValue::I64(1)).unwrap();
want.push(("dense", "k0".into(), AttrValue::I64(1)));
ed.set_attr("tracked", "b", &AttrValue::I64(1)).unwrap();
want.push(("tracked", "b".into(), AttrValue::I64(1)));
}
drop(ed);
check_tools(&path, *dump);
@@ -1027,7 +1100,7 @@ fn refused_edits_change_nothing() {
let before = std::fs::read(&path).unwrap();
let mut ed = FileEditor::open(&path).unwrap();
unsupported(ed.write_all("s", &[0u8; 32]));
unsupported(ed.resize("x", &[4]));
unsupported(ed.resize("c", &[4]));
unsupported(
ed.resize("c", &[6, 1])
.map_err(|_| Error::Unsupported(String::new())),
@@ -1124,9 +1197,10 @@ fn out_of_order_chunk_creation_matches_libhdf5() {
}
}
/// Not a check: prints how much space an append workload leaks (the editor
/// never reuses space), against libhdf5 doing the same appends and against
/// `h5repack` of each. Run with `--ignored --nocapture`.
/// Not a check: prints how much space an append workload leaks (one
/// editor for the whole workload, which reuses the space it frees but not
/// space it cannot fit a grown chunk into), against libhdf5 doing the same
/// appends and against `h5repack` of each. Run with `--ignored --nocapture`.
#[test]
#[ignore]
fn measure_append_waste() {