edit: reuse space freed earlier in the editing session

A FileEditor now keeps the space its edits free — a filtered chunk that
moved, chunks a shrink removed, B-tree nodes merged away, a heap's
replaced root indirect block or free-space section info, huge objects
replaced — and later edits allocate from it (best fit, lowest address
among equals, zeroed) before growing the file. An edit never reuses what
it frees itself: until it is committed the file still refers to that
space. Reused blocks are written in the commit's first phase with the
space past the old end of file (nothing on disk refers to them yet),
before any existing byte changes, so the crash-safety ordering holds.
Space still free when the editor is dropped is leaked, as libhdf5 leaks
it without a persistent free-space manager (files that have one, or use
paged aggregation, are still refused at open).
FileEditor::reusable_bytes reports what is left to reuse.

Tests: FreeList merging and best fit, the edit-local rule and the commit
split (image unit tests); a shrink followed by regrowth writing the same
data reuses every removed chunk and leaves the file size unchanged,
while one editor per edit grows the file, h5py/h5dump/h5rs check read
both and h5py continues (freed_space_is_reused_within_a_session).

measure_append_waste (edit_interop, ignored), same workload, one editor,
before -> after (bytes; libhdf5 in brackets), on tank 2026-09-26:
gzip chunks 1024, 1000 appends of 100: 307210 -> 306780 (306058);
gzip chunks 4096, 2000 appends of 10: 119684 -> 79829 (50292);
unfiltered unchanged (no space is freed).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 17:04:15 -05:00
co-authored by Claude Opus 5.5
parent 773f427f16
commit dc9cfba6bb
6 changed files with 317 additions and 63 deletions
@@ -1323,3 +1323,76 @@ fn dense_attribute_refusals_change_nothing() {
));
check_tools(&path, true);
}
/// Space one edit frees is reused by later edits of the same editor: the
/// chunks a shrink removes are where the chunks of the following growth
/// go, so the file does not grow; with a new editor per edit (nothing to
/// reuse) it does. h5py, h5dump and `h5rs check` read the result, and
/// h5py goes on.
#[test]
fn freed_space_is_reused_within_a_session() {
if !tools_ok() {
return;
}
let dir = tmpdir();
let mut sizes = Vec::new();
for session in [true, false] {
let path = dir.path().join(format!("reuse_{session}.h5"));
py(&format!(
"import h5py, numpy as np\n\
with h5py.File({p:?}, 'w', libver='v110') as f:\n\
\x20 f.create_dataset('x', data=np.zeros(2000, dtype='<i4'), maxshape=(None,), \
chunks=(100,), compression='gzip')\n",
p = path.to_str().unwrap()
));
let mut rng = Rng(5);
let vals: Vec<i32> = (0..2000).map(|_| rng.next() as i32).collect();
let mut ed = FileEditor::open(&path).unwrap();
ed.write_values("x", &Selection::All, &vals).unwrap();
drop(ed);
let len0 = std::fs::metadata(&path).unwrap().len();
let mut ed = FileEditor::open(&path).unwrap();
ed.resize("x", &[1000]).unwrap();
if session {
assert!(ed.reusable_bytes() > 0);
} else {
ed = {
drop(ed);
FileEditor::open(&path).unwrap()
};
}
ed.resize("x", &[2000]).unwrap();
ed.write_values("x", &block(&[1000], &[1000]), &vals[1000..])
.unwrap();
if session {
assert_eq!(ed.reusable_bytes(), 0, "every freed chunk is reused");
}
drop(ed);
let len1 = std::fs::metadata(&path).unwrap().len();
sizes.push((len0, len1));
let m = Model {
shape: vec![2000],
data: vals.clone(),
};
verify(&path, "x", &m);
check_tools(&path, true);
py(&format!(
"import h5py, numpy as np\n\
with h5py.File({p:?}, 'r+') as f:\n\
\x20 f['x'].resize((2100,))\n\
\x20 f['x'][2000:] = 9\n",
p = path.to_str().unwrap()
));
let mut m = m;
m.resize(&[2100], 0);
m.write_block(&[2000], &[100], &[9; 100]);
verify(&path, "x", &m);
check_tools(&path, true);
}
let (reuse, fresh) = (sizes[0], sizes[1]);
assert_eq!(
reuse.1, reuse.0,
"a session reusing freed chunks does not grow the file"
);
assert!(fresh.1 > fresh.0, "without reuse the file grows");
}
+4 -3
View File
@@ -1122,9 +1122,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() {