feat(format): nested groups, soft/hard/external links and creation order in the writer

FileWriter wrote the root group plus one level of groups, and refused
path-like names. The writer now flattens its builders into a group tree
(writer_tree.rs) before layout:

- A name may be a path ("a/b/x", "/a/b/x" at the root); missing
  intermediate groups are created as h5py does, and GroupBuilder gains
  create_group/add_group so builders nest to any depth. A group added at a
  path that already holds a group is merged into it (require_group);
  any other repeated name, an empty or "." component, or an absolute path
  below the root is an error.
- add_soft_link, add_hard_link and add_external_link on FileWriter,
  FileBuilder and GroupBuilder. Hard-link targets are resolved to objects
  at finish (through other hard links; a missing target, a soft link on the
  way or a cycle of paths is an error). Objects with several hard links get
  an Object Reference Count message so libhdf5 can delete one link without
  freeing the object.
- track_order(true) per group, or as the file default, tracks and indexes
  link creation order: Link Info flags and max order, the order in each
  Link message, and a type-6 creation-order B-tree for dense groups.
- A group's link index is one B-tree leaf; more than 65535 links is an
  error.

Groups are laid out depth-first from the root, datasets group by group,
and untracked groups keep writing datasets, then groups, then other links:
files with one level of groups are byte-identical to before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 08:33:46 -05:00
co-authored by Claude Opus 5.5
parent 8cbbef3fae
commit d102c06306
11 changed files with 1694 additions and 433 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -130,6 +130,7 @@ mod test_fuzz;
pub mod type_builders;
pub mod vds;
pub mod vl_data;
mod writer_tree;
#[cfg(feature = "provenance")]
pub mod provenance;
+98 -24
View File
@@ -903,34 +903,117 @@ impl DatasetBuilder {
// ---- Group builder ----
/// Builder for groups.
/// One entry of a [`GroupBuilder`], kept in the order it was added (the
/// order a group that tracks creation order lists its links in).
pub(crate) enum GroupItem {
Dataset(Box<DatasetBuilder>),
Group(GroupBuilder),
/// A soft link: `name` resolves to whatever `target` names when read.
Soft {
name: String,
target: String,
},
/// An extra hard link to the object at `target` (a path in this file).
Hard {
name: String,
target: String,
},
/// An external link to `path` in the file `file`.
External {
name: String,
file: String,
path: String,
},
}
/// Builder for a group: its datasets, subgroups, links and attributes.
///
/// Names are paths relative to the group: `create_dataset("a/b/x")` creates
/// the groups `a` and `a/b` as needed, as h5py does. A group added where a
/// group of the same path already exists (added by another builder, or
/// created as an intermediate group) is merged into it, like h5py's
/// `require_group`; any other name used twice in a group is an error when the
/// file is written. A path component must not be empty or `"."`.
pub struct GroupBuilder {
pub(crate) name: String,
pub(crate) datasets: Vec<DatasetBuilder>,
pub(crate) items: Vec<GroupItem>,
pub(crate) attrs: Vec<(String, AttrValue)>,
/// (link_name, target_file, target_path)
pub(crate) external_links: Vec<(String, String, String)>,
/// Track (and index) link creation order; `None` follows the file's
/// default (`FileWriter::track_order`).
pub(crate) track_order: Option<bool>,
}
impl GroupBuilder {
pub(crate) fn new(name: &str) -> Self {
Self {
name: name.to_string(),
datasets: Vec::new(),
items: Vec::new(),
attrs: Vec::new(),
external_links: Vec::new(),
track_order: None,
}
}
/// Create a dataset in this group. `name` may be a relative path
/// (`"a/b/x"`); missing intermediate groups are created.
pub fn create_dataset(&mut self, name: &str) -> &mut DatasetBuilder {
self.datasets.push(DatasetBuilder::new(name));
self.datasets.last_mut().unwrap()
self.items
.push(GroupItem::Dataset(Box::new(DatasetBuilder::new(name))));
match self.items.last_mut() {
Some(GroupItem::Dataset(d)) => d,
_ => unreachable!("just pushed a dataset"),
}
}
/// Start a subgroup of this group. Like `FileWriter::create_group`, the
/// builder is detached: fill it, then pass `finish()`'s result to
/// [`Self::add_group`]. `name` may be a relative path.
pub fn create_group(&self, name: &str) -> GroupBuilder {
GroupBuilder::new(name)
}
/// Add a finished subgroup to this group.
pub fn add_group(&mut self, group: FinishedGroup) -> &mut Self {
self.items.push(GroupItem::Group(group.group));
self
}
pub fn set_attr(&mut self, name: &str, value: AttrValue) {
self.attrs.push((name.to_string(), value));
}
/// Track the creation order of this group's links, and index it, as
/// h5py's `track_order=True` does: libhdf5 (and h5py) then list the
/// group's members in the order they were added rather than by name.
/// Applies to links only, not to attributes.
pub fn track_order(&mut self, track: bool) -> &mut Self {
self.track_order = Some(track);
self
}
/// Add a soft link `name` to the path `target` (absolute, or relative to
/// this group), like h5py's `grp[name] = h5py.SoftLink(target)`. The
/// target need not exist.
pub fn add_soft_link(&mut self, name: &str, target: &str) -> &mut Self {
self.items.push(GroupItem::Soft {
name: name.to_string(),
target: target.to_string(),
});
self
}
/// Add another hard link `name` to the group or dataset at `target`
/// (absolute, or relative to this group), like h5py's
/// `grp[name] = f[target]`. The target must be written in the same file;
/// its path may go through other hard links, but not through soft or
/// external links.
pub fn add_hard_link(&mut self, name: &str, target: &str) -> &mut Self {
self.items.push(GroupItem::Hard {
name: name.to_string(),
target: target.to_string(),
});
self
}
/// Add an external link: a named pointer to an object in another HDF5 file.
pub fn add_external_link(
&mut self,
@@ -938,30 +1021,21 @@ impl GroupBuilder {
target_file: &str,
target_path: &str,
) -> &mut Self {
self.external_links.push((
name.to_string(),
target_file.to_string(),
target_path.to_string(),
));
self.items.push(GroupItem::External {
name: name.to_string(),
file: target_file.to_string(),
path: target_path.to_string(),
});
self
}
/// Consume the builder, returning a FinishedGroup to add to FileWriter.
pub fn finish(self) -> FinishedGroup {
FinishedGroup {
name: self.name,
datasets: self.datasets,
attrs: self.attrs,
external_links: self.external_links,
}
FinishedGroup { group: self }
}
}
/// A finished group ready for the file writer.
pub struct FinishedGroup {
pub(crate) name: String,
pub(crate) datasets: Vec<DatasetBuilder>,
pub(crate) attrs: Vec<(String, AttrValue)>,
/// (link_name, target_file, target_path)
pub(crate) external_links: Vec<(String, String, String)>,
pub(crate) group: GroupBuilder,
}
+446
View File
@@ -0,0 +1,446 @@
//! The group hierarchy `FileWriter` writes: builders flattened into a tree
//! of groups, datasets and links, with path names expanded into
//! intermediate groups, hard links resolved to objects, reference counts
//! counted, and everything put in layout order.
#[cfg(not(feature = "std"))]
use alloc::{
collections::BTreeMap,
format,
string::{String, ToString},
vec,
vec::Vec,
};
#[cfg(feature = "std")]
use std::collections::BTreeMap;
use crate::error::FormatError;
use crate::type_builders::{AttrValue, DatasetBuilder, GroupBuilder, GroupItem};
/// Hard links followed while resolving one hard-link target path. Guards
/// against hard links whose targets name each other.
const MAX_LINK_DEPTH: usize = 64;
fn err(msg: String) -> FormatError {
FormatError::SerializationError(msg)
}
/// A link name must be one path component: not empty, not ".", and without
/// '/' (a '/' separates components, so it cannot be part of a name).
fn check_link_name(name: &str, path: &str) -> Result<(), FormatError> {
if name.is_empty() || name == "." || name.contains('/') {
return Err(err(format!(
"invalid object name {path:?}: every path component must be a \
non-empty name other than \".\""
)));
}
Ok(())
}
/// What a link in the final tree points at.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum LinkTo {
/// A group, by index into [`Tree::groups`] (layout order).
Group(usize),
/// A dataset, by index into [`Tree::datasets`] (layout order).
Dataset(usize),
Soft(String),
External {
file: String,
path: String,
},
}
pub(crate) struct Link {
pub(crate) name: String,
pub(crate) to: LinkTo,
/// Set when the group tracks creation order.
pub(crate) creation_order: Option<u64>,
}
pub(crate) struct Group {
pub(crate) attrs: Vec<(String, AttrValue)>,
/// Links in the order they are written.
pub(crate) links: Vec<Link>,
pub(crate) track_order: bool,
/// Number of hard links to this group (the root counts one for the
/// superblock's reference).
pub(crate) refcount: u32,
}
/// The flattened file: groups (root first) and datasets, both in the order
/// they are laid out in the file.
pub(crate) struct Tree {
pub(crate) groups: Vec<Group>,
pub(crate) datasets: Vec<(DatasetBuilder, u32)>,
}
// ---- construction ----
enum Target {
Group(usize),
Dataset(usize),
Soft(String),
Hard(String),
External { file: String, path: String },
}
struct BuildGroup {
/// Full path, for messages.
path: String,
attrs: Vec<(String, AttrValue)>,
links: Vec<(String, Target)>,
by_name: BTreeMap<String, usize>,
track_order: Option<bool>,
}
struct Builder {
groups: Vec<BuildGroup>,
datasets: Vec<DatasetBuilder>,
}
fn join(parent: &str, name: &str) -> String {
if parent == "/" {
format!("/{name}")
} else {
format!("{parent}/{name}")
}
}
impl Builder {
fn new_group(&mut self, path: String) -> usize {
self.groups.push(BuildGroup {
path,
attrs: Vec::new(),
links: Vec::new(),
by_name: BTreeMap::new(),
track_order: None,
});
self.groups.len() - 1
}
/// Split `path` (relative to group `g`) into the group holding its last
/// component, creating missing intermediate groups, and that component.
fn parent_of<'p>(&mut self, g: usize, path: &'p str) -> Result<(usize, &'p str), FormatError> {
// An absolute path is accepted at the root only.
let rel = match path.strip_prefix('/') {
Some(rest) if g == 0 => rest,
Some(_) => {
return Err(err(format!(
"invalid object name {path:?} in {}: absolute paths are accepted \
only at the root",
self.groups[g].path
)));
}
None => path,
};
let mut comps: Vec<&str> = rel.split('/').collect();
let last = comps.pop().unwrap_or("");
check_link_name(last, path)?;
let mut cur = g;
for c in comps {
check_link_name(c, path)?;
cur = match self.groups[cur].by_name.get(c).copied() {
Some(i) => match self.groups[cur].links[i].1 {
Target::Group(child) => child,
_ => {
return Err(err(format!(
"cannot create {path:?} in {}: {c:?} exists and is not a group",
self.groups[g].path
)));
}
},
None => {
let child = self.new_group(join(&self.groups[cur].path, c));
self.push_link(cur, c, Target::Group(child))?;
child
}
};
}
Ok((cur, last))
}
fn push_link(&mut self, g: usize, name: &str, to: Target) -> Result<(), FormatError> {
let grp = &mut self.groups[g];
if grp.by_name.contains_key(name) {
return Err(err(format!("{:?} already exists", join(&grp.path, name))));
}
grp.by_name.insert(name.to_string(), grp.links.len());
grp.links.push((name.to_string(), to));
Ok(())
}
/// Add `item` to group `g`.
fn add_item(&mut self, g: usize, item: GroupItem) -> Result<(), FormatError> {
match item {
GroupItem::Dataset(db) => {
let (parent, name) = self.parent_of(g, &db.name)?;
let name = name.to_string();
self.push_link(parent, &name, Target::Dataset(self.datasets.len()))?;
self.datasets.push(*db);
}
GroupItem::Group(gb) => self.add_group(g, gb)?,
GroupItem::Soft { name, target } => {
if target.is_empty() {
return Err(err(format!("soft link {name:?} has an empty target")));
}
let (parent, last) = self.parent_of(g, &name)?;
self.push_link(parent, last, Target::Soft(target))?;
}
GroupItem::Hard { name, target } => {
let (parent, last) = self.parent_of(g, &name)?;
self.push_link(parent, last, Target::Hard(target))?;
}
GroupItem::External { name, file, path } => {
if file.is_empty() || path.is_empty() {
return Err(err(format!(
"external link {name:?} needs a file name and an object path"
)));
}
let (parent, last) = self.parent_of(g, &name)?;
self.push_link(parent, last, Target::External { file, path })?;
}
}
Ok(())
}
/// Add the group `gb` (named by a path relative to group `g`), merging it
/// into a group already at that path.
fn add_group(&mut self, g: usize, gb: GroupBuilder) -> Result<(), FormatError> {
let (parent, last) = self.parent_of(g, &gb.name)?;
let idx = match self.groups[parent].by_name.get(last).copied() {
Some(i) => match self.groups[parent].links[i].1 {
Target::Group(child) => child,
_ => {
return Err(err(format!(
"{:?} already exists and is not a group",
join(&self.groups[parent].path, last)
)));
}
},
None => {
let child = self.new_group(join(&self.groups[parent].path, last));
self.push_link(parent, last, Target::Group(child))?;
child
}
};
self.merge_into(idx, gb)
}
/// Merge a builder's attributes, setting and items into group `idx`.
fn merge_into(&mut self, idx: usize, gb: GroupBuilder) -> Result<(), FormatError> {
for (name, value) in gb.attrs {
if self.groups[idx].attrs.iter().any(|(n, _)| *n == name) {
return Err(err(format!(
"attribute {name:?} set twice on {}",
self.groups[idx].path
)));
}
self.groups[idx].attrs.push((name, value));
}
if let Some(t) = gb.track_order {
match self.groups[idx].track_order {
Some(old) if old != t => {
return Err(err(format!(
"conflicting track_order settings for {}",
self.groups[idx].path
)));
}
_ => self.groups[idx].track_order = Some(t),
}
}
for item in gb.items {
self.add_item(idx, item)?;
}
Ok(())
}
/// The object a hard link's `target` path names, from group `from`.
fn resolve(&self, from: usize, target: &str, depth: usize) -> Result<Obj, FormatError> {
if depth > MAX_LINK_DEPTH {
return Err(err(format!(
"hard link target {target:?}: too many hard links to follow (a cycle?)"
)));
}
let (mut cur, rest) = match target.strip_prefix('/') {
Some(rest) => (0, rest),
None => (from, target),
};
if target.is_empty() {
return Err(err("a hard link needs a target path".to_string()));
}
let comps: Vec<&str> = rest
.split('/')
.filter(|c| !c.is_empty() && *c != ".")
.collect();
let mut obj = Obj::Group(cur);
for (i, c) in comps.iter().enumerate() {
let Obj::Group(g) = obj else {
return Err(err(format!(
"hard link target {target:?}: {:?} is not a group",
comps[..i].join("/")
)));
};
cur = g;
let grp = &self.groups[cur];
let Some(&li) = grp.by_name.get(*c) else {
return Err(err(format!(
"hard link target {target:?} does not exist in the file"
)));
};
obj = match &grp.links[li].1 {
Target::Group(child) => Obj::Group(*child),
Target::Dataset(d) => Obj::Dataset(*d),
Target::Hard(p) => self.resolve(cur, p, depth + 1)?,
Target::Soft(_) | Target::External { .. } => {
return Err(err(format!(
"hard link target {target:?} goes through a soft or external \
link ({:?}); name the object by its hard-link path",
join(&grp.path, c)
)));
}
};
}
Ok(obj)
}
}
#[derive(Clone, Copy)]
enum Obj {
Group(usize),
Dataset(usize),
}
/// Flatten the root group builder into a [`Tree`]. `default_track_order`
/// applies to every group that does not set its own.
pub(crate) fn build(root: GroupBuilder, default_track_order: bool) -> Result<Tree, FormatError> {
let mut b = Builder {
groups: Vec::new(),
datasets: Vec::new(),
};
b.new_group("/".to_string());
b.merge_into(0, root)?;
// Resolve hard links and count references.
let mut group_refs = vec![0u32; b.groups.len()];
let mut ds_refs = vec![0u32; b.datasets.len()];
group_refs[0] = 1; // the superblock's reference to the root
let mut resolved: Vec<Vec<Option<Obj>>> = Vec::with_capacity(b.groups.len());
for (gi, g) in b.groups.iter().enumerate() {
let mut row = Vec::with_capacity(g.links.len());
for (_, t) in &g.links {
let obj = match t {
Target::Group(i) => Some(Obj::Group(*i)),
Target::Dataset(d) => Some(Obj::Dataset(*d)),
Target::Hard(p) => Some(b.resolve(gi, p, 0)?),
Target::Soft(_) | Target::External { .. } => None,
};
match obj {
Some(Obj::Group(i)) => group_refs[i] += 1,
Some(Obj::Dataset(d)) => ds_refs[d] += 1,
None => {}
}
row.push(obj);
}
resolved.push(row);
}
// The order each group's links are written in: creation order when
// tracked; otherwise datasets, then groups, then other links (the order
// earlier versions wrote, so one-level files keep their layout).
let tracked: Vec<bool> = b
.groups
.iter()
.map(|g| g.track_order.unwrap_or(default_track_order))
.collect();
let link_order: Vec<Vec<usize>> = b
.groups
.iter()
.enumerate()
.map(|(gi, g)| {
let mut idx: Vec<usize> = (0..g.links.len()).collect();
if !tracked[gi] {
idx.sort_by_key(|&i| match g.links[i].1 {
Target::Dataset(_) => 0,
Target::Group(_) => 1,
_ => 2,
});
}
idx
})
.collect();
// Layout order: groups depth-first from the root, following the links
// that created them; datasets group by group in that order.
let mut group_order = Vec::with_capacity(b.groups.len());
let mut stack = vec![0usize];
while let Some(g) = stack.pop() {
group_order.push(g);
let children: Vec<usize> = link_order[g]
.iter()
.filter_map(|&i| match b.groups[g].links[i].1 {
Target::Group(c) => Some(c),
_ => None,
})
.collect();
stack.extend(children.into_iter().rev());
}
let mut ds_order = Vec::with_capacity(b.datasets.len());
for &g in &group_order {
for &i in &link_order[g] {
if let Target::Dataset(d) = b.groups[g].links[i].1 {
ds_order.push(d);
}
}
}
let mut group_pos = vec![0usize; b.groups.len()];
for (pos, &g) in group_order.iter().enumerate() {
group_pos[g] = pos;
}
let mut ds_pos = vec![0usize; b.datasets.len()];
for (pos, &d) in ds_order.iter().enumerate() {
ds_pos[d] = pos;
}
let mut groups_by_id: Vec<Option<BuildGroup>> = b.groups.into_iter().map(Some).collect();
let mut groups = Vec::with_capacity(group_order.len());
for &g in &group_order {
let bg = groups_by_id[g].take().expect("each group is laid out once");
let mut targets: Vec<Option<(String, Target)>> = bg.links.into_iter().map(Some).collect();
let links = link_order[g]
.iter()
.map(|&i| {
let (name, t) = targets[i].take().expect("each link is written once");
let to = match (resolved[g][i], t) {
(Some(Obj::Group(c)), _) => LinkTo::Group(group_pos[c]),
(Some(Obj::Dataset(d)), _) => LinkTo::Dataset(ds_pos[d]),
(None, Target::Soft(s)) => LinkTo::Soft(s),
(None, Target::External { file, path }) => LinkTo::External { file, path },
(None, _) => unreachable!("hard links are resolved"),
};
Link {
name,
to,
creation_order: tracked[g].then_some(i as u64),
}
})
.collect();
groups.push(Group {
attrs: bg.attrs,
links,
track_order: tracked[g],
refcount: group_refs[g],
});
}
let mut ds_by_id: Vec<Option<DatasetBuilder>> = b.datasets.into_iter().map(Some).collect();
let datasets = ds_order
.iter()
.map(|&d| {
(
ds_by_id[d].take().expect("each dataset is laid out once"),
ds_refs[d],
)
})
.collect();
Ok(Tree { groups, datasets })
}