fix(format): write paged files libhdf5 can open

FileWriter::with_page_size wrote a "version 4" superblock with an extra
page-size field. HDF5 has no superblock version 4, so libhdf5 refused
every such file ("bad superblock version number").

A paged file is now what libhdf5 itself writes for fs_strategy="page":
a v3 superblock whose extension object header holds a File Space Info
message (strategy PAGE, the page size, free space not persisted; same
bytes and flags as HDF5 2.0), with the file padded to a whole page.
h5py opens it, reports the strategy and page size, and can modify it in
r+ mode. Page sizes outside libhdf5's 512 B..1 GiB are an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-25 21:09:31 -05:00
co-authored by Claude Opus 5.5
parent be88e3fec7
commit 74fdf0582b
3 changed files with 163 additions and 21 deletions
+97 -18
View File
@@ -4,7 +4,7 @@
//! link messages, contiguous datasets, inline and dense attributes.
#[cfg(not(feature = "std"))]
use alloc::{string::String, string::ToString, vec, vec::Vec};
use alloc::{format, string::String, string::ToString, vec, vec::Vec};
use crate::attribute::AttributeMessage;
use crate::chunked_write::{
@@ -38,6 +38,31 @@ const SUPERBLOCK_SIZE: usize = 48;
/// field is 2 bytes. Bigger "compact" requests fall back to contiguous storage.
const MAX_COMPACT_DATA_SIZE: usize = crate::object_header_writer::MAX_MESSAGE_SIZE - 4;
/// libhdf5's bounds on a file space page size (`H5F_FILE_SPACE_PAGE_SIZE_MIN`
/// and `_MAX`).
const MIN_FILE_SPACE_PAGE_SIZE: u32 = 512;
const MAX_FILE_SPACE_PAGE_SIZE: u32 = 1024 * 1024 * 1024;
/// Superblock extension object header for a file using the paged file-space
/// strategy: a single File Space Info message (0x0017), as libhdf5 writes it
/// for `fs_strategy="page"` without persisted free space.
fn build_paged_superblock_extension(page_size: u32) -> Result<Vec<u8>, FormatError> {
let mut fsinfo = Vec::new();
fsinfo.push(1); // version
fsinfo.push(1); // strategy: H5F_FSPACE_STRATEGY_PAGE
fsinfo.push(0); // persisting free space: no
write_length(&mut fsinfo, 1, LENGTH_SIZE); // free-space section threshold
write_length(&mut fsinfo, u64::from(page_size), LENGTH_SIZE);
fsinfo.extend_from_slice(&0u16.to_le_bytes()); // page end metadata threshold
write_undef_offset(&mut fsinfo, OFFSET_SIZE); // EOA before free-space info
let mut w = ObjectHeaderWriter::new();
// Flags as libhdf5 sets them: bit 2 (never share) and bit 4 (mark if
// unknown). Not constant: libhdf5 rewrites the message when it closes a
// file it opened for writing.
w.add_message_with_flags(MessageType::Unknown(0x0017), fsinfo, 0x14);
w.serialize()
}
/// Threshold for switching from compact (inline) to dense attribute storage.
const DENSE_ATTR_THRESHOLD: usize = 8;
@@ -961,7 +986,9 @@ pub struct FileWriter {
alignment_threshold: usize,
/// Global alignment boundary in bytes (0 = disabled).
alignment_bytes: usize,
/// Page size for page-buffer mode. When set, a v4 superblock is written.
/// File space page size. When set, the file uses libhdf5's paged
/// file-space strategy (a File Space Info message in the superblock
/// extension).
page_size: Option<u32>,
}
@@ -993,9 +1020,16 @@ impl FileWriter {
self
}
/// Enable page-buffer mode with the given page size. Writing this causes
/// the file to be written with a v4 superblock (page_size field) instead
/// of the default v3.
/// Write the file with libhdf5's *paged* file-space strategy and the given
/// page size, as `H5Pset_file_space_strategy(H5F_FSPACE_STRATEGY_PAGE)` +
/// `H5Pset_file_space_page_size` (h5py: `fs_strategy="page"`,
/// `fs_page_size=...`) do: a v3 superblock with an extension holding a
/// File Space Info message, and the file padded to a whole number of
/// pages. Readers with a page buffer can then fetch metadata page by page.
///
/// `page_size` must be between 512 bytes and 1 GiB (libhdf5's limits);
/// [`Self::finish`] fails otherwise. This used to write a "version 4"
/// superblock, which does not exist and no HDF5 library can open.
pub fn with_page_size(&mut self, page_size: u32) -> &mut Self {
self.page_size = Some(page_size);
self
@@ -1020,6 +1054,14 @@ impl FileWriter {
pub fn finish(self) -> Result<Vec<u8>, FormatError> {
let page_size = self.page_size;
if let Some(ps) = page_size
&& !(MIN_FILE_SPACE_PAGE_SIZE..=MAX_FILE_SPACE_PAGE_SIZE).contains(&ps)
{
return Err(FormatError::SerializationError(format!(
"file space page size {ps} is outside libhdf5's \
{MIN_FILE_SPACE_PAGE_SIZE}..={MAX_FILE_SPACE_PAGE_SIZE} bytes"
)));
}
struct DsFlat {
name: String,
dt: Datatype,
@@ -1328,12 +1370,12 @@ impl FileWriter {
let actual_ds_oh_sizes: Vec<usize> = dummy_blobs.iter().map(|b| b.oh_bytes.len()).collect();
// Pass 2: compute real addresses
// v4 superblocks add a 4-byte page_size field before the checksum.
let superblock_size = if page_size.is_some() {
SUPERBLOCK_SIZE + 4
} else {
SUPERBLOCK_SIZE
};
// A paged file carries its File Space Info in a superblock extension
// object header, placed right after the superblock.
let sb_ext = page_size
.map(build_paged_superblock_extension)
.transpose()?;
let superblock_size = SUPERBLOCK_SIZE + sb_ext.as_ref().map_or(0, Vec::len);
let root_group_addr = superblock_size as u64;
let mut cursor2 = superblock_size + root_oh_size;
@@ -1507,11 +1549,16 @@ impl FileWriter {
let actual_ds_oh_sizes2: Vec<usize> = ds_blobs2.iter().map(|b| b.oh_bytes.len()).collect();
debug_assert_eq!(actual_ds_oh_sizes, actual_ds_oh_sizes2);
// libhdf5 ends a paged file on a page boundary.
let data_end = cursor2;
if let Some(ps) = page_size {
cursor2 = cursor2.next_multiple_of(ps as usize);
}
let eof_addr2 = cursor2 as u64;
let mut buf = Vec::with_capacity(cursor2);
let sb = Superblock {
version: if page_size.is_some() { 4 } else { 3 },
version: 3,
offset_size: OFFSET_SIZE,
length_size: LENGTH_SIZE,
base_address: 0,
@@ -1523,11 +1570,18 @@ impl FileWriter {
free_space_address: None,
driver_info_address: None,
consistency_flags: 0,
superblock_extension_address: Some(u64::MAX),
superblock_extension_address: Some(if sb_ext.is_some() {
SUPERBLOCK_SIZE as u64
} else {
u64::MAX
}),
checksum: None,
page_size,
page_size: None,
};
buf.extend_from_slice(&sb.serialize());
if let Some(ref ext) = sb_ext {
buf.extend_from_slice(ext);
}
// Root group OH
let mut root_links: Vec<LinkMessage> = Vec::new();
@@ -1595,7 +1649,8 @@ impl FileWriter {
buf.extend_from_slice(&blob.data);
}
debug_assert_eq!(buf.len(), cursor2);
debug_assert_eq!(buf.len(), data_end);
buf.resize(cursor2, 0);
Ok(buf)
}
}
@@ -2169,7 +2224,8 @@ mod tests {
}
#[test]
fn file_writer_v4_superblock() {
fn file_writer_paged_file_uses_v3_superblock_and_fsinfo_extension() {
// This used to write superblock "version 4", which does not exist.
let mut fw = FileWriter::new();
fw.with_page_size(4096);
fw.create_dataset("data").with_f64_data(&[1.0, 2.0]);
@@ -2177,8 +2233,31 @@ mod tests {
let sig = signature::find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap();
assert_eq!(sb.version, 4, "expected superblock v4");
assert_eq!(sb.page_size, Some(4096));
assert_eq!(sb.version, 3);
assert_eq!(sb.superblock_extension_address, Some(48));
assert_eq!(bytes.len() % 4096, 0);
assert_eq!(sb.eof_address, bytes.len() as u64);
let ext = ObjectHeader::parse(&bytes, 48, 8, 8).unwrap();
let fsinfo = &ext.messages[0];
assert_eq!(fsinfo.msg_type, MessageType::Unknown(0x0017));
// Byte-for-byte what HDF5 2.0 writes for fs_strategy="page",
// fs_page_size=4096.
let mut expected = vec![1u8, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0];
expected.extend_from_slice(&4096u64.to_le_bytes());
expected.extend_from_slice(&[0, 0]);
expected.extend_from_slice(&[0xff; 8]);
assert_eq!(fsinfo.data, expected);
assert_eq!(fsinfo.flags, 0x14);
assert_eq!(read_dataset_f64(&bytes, "data"), vec![1.0, 2.0]);
}
#[test]
fn file_writer_rejects_page_sizes_libhdf5_would() {
for ps in [0u32, 511, MAX_FILE_SPACE_PAGE_SIZE + 1] {
let mut fw = FileWriter::new();
fw.with_page_size(ps);
assert!(fw.finish().is_err(), "page size {ps}");
}
}
#[test]
+10 -3
View File
@@ -39,7 +39,13 @@ pub struct Superblock {
pub superblock_extension_address: Option<u64>,
/// CRC32C checksum (v2/v3 only).
pub checksum: Option<u32>,
/// Page size for page-buffer mode (v4 only). `None` for v0–v3.
/// Page size of the non-standard "version 4" superblock layout (v4 only).
/// `None` for v0–v3.
///
/// HDF5 has no superblock version 4 — libhdf5 refuses it. A real paged
/// file is a v2/v3 superblock whose extension holds a File Space Info
/// message (what `FileWriter::with_page_size` writes). This field is kept
/// only so such files written by older clawhdf5 versions still parse.
pub page_size: Option<u32>,
}
@@ -127,8 +133,9 @@ impl Superblock {
/// Serialize this superblock to bytes.
///
/// Writes v2/v3 format, or v4 (with `page_size`) when `self.version == 4`.
/// Computes and appends Jenkins lookup3 checksum.
/// Writes v2/v3 format, or the non-standard v4 (with `page_size`) when
/// `self.version == 4` — which no HDF5 library opens; see
/// [`Self::page_size`]. Computes and appends Jenkins lookup3 checksum.
pub fn serialize(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(48);
buf.extend_from_slice(&HDF5_SIGNATURE);
@@ -317,3 +317,59 @@ fn raw_attributes_copied_from_h5py_survive_a_rewrite() {
assert_eq!(out, r#"[["/", "/"], "abcdefgh", 1, [258, 772]]"#);
h5dump_ok(&path);
}
// ---- 3. paged file-space strategy ----
fn paged_file(page_size: u32) -> Vec<u8> {
let mut fw = FileWriter::new();
fw.with_page_size(page_size);
fw.create_dataset("d").with_f64_data(&[1.0, 2.0, 3.0]);
fw.create_dataset("c")
.with_i32_data(&(0..100).collect::<Vec<_>>())
.with_chunks(&[10]);
fw.set_root_attr("a", AttrValue::I64(7));
let mut g = fw.create_group("g");
g.create_dataset("e").with_u8_data(&[9; 5000]);
fw.add_group(g.finish());
fw.finish().unwrap()
}
#[test]
fn paged_file_has_a_real_superblock() {
// Measured: `with_page_size` wrote superblock version 4, which does not
// exist ("bad superblock version number" in libhdf5).
for ps in [512u32, 4096, 65536] {
let bytes = paged_file(ps);
let (sb, _) = header_at(&bytes, "/");
assert_eq!(sb.version, 3);
assert_eq!(bytes.len() % ps as usize, 0);
let (_, e) = header_at(&bytes, "g/e");
assert!(
e.messages
.iter()
.any(|m| m.msg_type == MessageType::Dataspace)
);
}
}
#[test]
#[ignore = "requires Python h5py module and h5dump"]
fn h5py_opens_paged_files() {
for ps in [512u32, 4096, 65536] {
let path = write_tmp(&format!("paged_{ps}"), &paged_file(ps));
let out = h5py(
&path,
"f = h5py.File(path, 'r')\n\
p = f.id.get_create_plist()\n\
print(json.dumps([p.get_file_space_strategy()[0], p.get_file_space_page_size(),\n\
\x20 f['d'][()].tolist(), int(f['c'][()].sum()), int(f.attrs['a']),\n\
\x20 int(f['g/e'][()].sum())]))",
);
assert_eq!(
out,
format!("[1, {ps}, [1.0, 2.0, 3.0], 4950, 7, 45000]"),
"page size {ps}"
);
h5dump_ok(&path);
}
}