rust_hdf5/io/writer.rs
1//! HDF5 file writer.
2//!
3//! Produces a valid HDF5 file with superblock v3, a root group object header,
4//! and datasets with contiguous or chunked storage. The output is readable by `h5dump`.
5
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use crate::dataset::DatasetAccess;
10use crate::format::btree_v1::{BTreeV1Config, ChunkBTreeV1Node, ChunkBTreeV1Tree, ChunkKey};
11use crate::format::chunk_index::btree_v2::Bt2ChunkIndex;
12use crate::format::chunk_index::extensible_array::{
13 compute_chunk_size_len, compute_ndblk_addrs, compute_nsblk_addrs, EaDblkPath, EaGeometry,
14 EaLoc, ExtensibleArrayDataBlock, ExtensibleArrayHeader, ExtensibleArrayIndexBlock,
15 ExtensibleArraySuperBlock, FilteredChunkEntry, FilteredDataBlock, FilteredIndexBlock,
16 EA_CLS_CHUNK, EA_CLS_FILT_CHUNK,
17};
18use crate::format::chunk_index::fixed_array::{
19 decode_filtered_page, decode_unfiltered_page, encode_filtered_page, encode_unfiltered_page,
20 FixedArrayDataBlock, FixedArrayFilteredChunkElement, FixedArrayHeader, FixedArrayPagedPrefix,
21 FA_CLIENT_FILT_CHUNK,
22};
23use crate::format::creation_order::CreationOrder;
24use crate::format::dense_attr::build_dense_attributes;
25use crate::format::dense_link::build_dense_links;
26use crate::format::free_space::{
27 self, FreeSection, FreeSpaceClass, FreeSpaceHeader, FreeSpaceManager,
28};
29use crate::format::local_heap::{
30 local_heap_header_size, LocalHeapHeader, LocalHeapImage, LOCAL_HEAP_FREE_NULL,
31};
32use crate::format::messages::attr_info::{next_creation_index, AttributeInfoMessage};
33use crate::format::messages::attribute::{
34 AttributeEntry, AttributeMessage, ATTR_FLAG_SPACE_SHARED, ATTR_FLAG_TYPE_SHARED,
35};
36use crate::format::messages::data_layout::{
37 DataLayoutMessage, EarrayParams, FixedArrayParams, LAYOUT_VERSION_DEFAULT,
38};
39use crate::format::messages::dataspace::{DataspaceClass, DataspaceMessage};
40use crate::format::messages::datatype::{ByteOrder, DatatypeMessage, ReferenceKind};
41use crate::format::messages::external_file_list::{ExternalFileListMessage, UNLIMITED};
42use crate::format::messages::fill_value::{
43 FillValueMessage, FILL_TIME_ALLOC, FILL_TIME_IFSET, FILL_TIME_NEVER,
44};
45use crate::format::messages::filter::{self, FilterPipeline};
46use crate::format::messages::group_info::GroupInfoMessage;
47use crate::format::messages::link::{CharacterSet, LinkMessage, LinkTarget};
48use crate::format::messages::link_info::LinkInfoMessage;
49use crate::format::messages::mod_time::ModificationTime;
50use crate::format::messages::superblock_ext::{
51 FileSpaceInfoMessage, FileSpaceStrategy, SharedMessageTableMessage,
52 DEFAULT_FILE_SPACE_PAGE_SIZE, FS_ADDR_COUNT_V1, PAGE_SIZE_MAX, PAGE_SIZE_MIN,
53};
54use crate::format::messages::virtual_mapping::{
55 parse_source_name, VirtualMapping, VirtualMappingList,
56};
57use crate::format::messages::*;
58use crate::format::object_header::{ObjectHeader, ObjectTimes, MAX_MESSAGE_SIZE};
59use crate::format::reference::{
60 encode_reference_element, encode_revised_blob, ReferenceElementImage, ReferenceTarget,
61 REVISED_BLOB_TOKEN_OFFSET,
62};
63use crate::format::selection::Selection;
64use crate::format::sohm::{
65 type_flag, SharedMessagePointer, MAX_SOHM_INDEXES, SOHM_HEAP_ID_LEN, SOHM_POINTER_HEAP_ID_AT,
66};
67use crate::format::sohm_write::{
68 build_shared_messages, NestedShare, SharedMessage, SohmIndexContent, SohmIndexSpec,
69};
70use crate::format::superblock::*;
71use crate::format::{FormatContext, LibverBound, ObjectFormat, UNDEF_ADDR};
72
73use crate::format::selection::check_hyperslab;
74use crate::io::allocator::{FileAllocator, FreeBlock};
75use crate::io::file_handle::FileHandle;
76use crate::io::hyperslab::{for_each_contiguous_run, for_each_dual_run};
77use crate::io::symbol_table_io::{free_stab, write_stab, Stab, StabExtents, StabLink, StabTarget};
78use crate::io::{FileMeta, IoResult};
79
80/// On-disk size in bytes of a fixed-array data block, for the layout (paged or
81/// flat) implied by `hdr`.
82///
83/// Mirrors `H5FA_DBLOCK_SIZE` (`H5FApkg.h`):
84/// - non-paged: `prefix + nelmts * raw_elmt_size + checksum`
85/// - paged: `prefix + page_init_bitmap + nelmts * raw_elmt_size
86/// + npages * checksum`, where the prefix checksum covers the bitmap.
87///
88/// `raw_elmt_size` is `sizeof_addr` for an unfiltered array, and
89/// `sizeof_addr + chunk_size_len + 4` (the filtered element: address +
90/// compressed size + filter mask) for a filtered array. libhdf5 carries this
91/// value as `hdr->cparam.raw_elmt_size`, i.e. exactly `hdr.element_size`.
92fn fixed_array_dblk_disk_size(ctx: &FormatContext, hdr: &FixedArrayHeader) -> u64 {
93 let elem_size = hdr.element_size as u64;
94 let sa = ctx.sizeof_addr as u64;
95 let nelmts = hdr.num_elmts;
96 // Common metadata prefix: signature(4) + version(1) + client_id(1) + header_addr(sa).
97 let meta_prefix = 4 + 1 + 1 + sa;
98 if hdr.is_paged() {
99 let npages = hdr.npages();
100 let bitmap_size = npages.div_ceil(8);
101 // prefix (incl. its own 4-byte checksum) + elements + per-page checksums.
102 (meta_prefix + bitmap_size + 4) + nelmts * elem_size + npages * 4
103 } else {
104 // prefix + elements + single 4-byte checksum.
105 meta_prefix + nelmts * elem_size + 4
106 }
107}
108
109/// A walk of a v2 B-tree: the file and node geometry the descent reads
110/// through, and the two collections it fills — every node's raw record
111/// bytes and every node block's address, the latter because `open_append`
112/// needs it so the reconstructed [`Bt2DatasetInfo::node_addrs`] pool owns
113/// the on-disk nodes (the next flush re-serializes the tree over them, and
114/// a delete frees them).
115///
116/// `record_size`, `node_size` and `geo` are constant for the whole walk, so
117/// [`descend`](Self::descend) takes only what changes per level: the node's
118/// address, its depth, and how many records it holds.
119struct Bt2Walk<'a> {
120 handle: &'a FileHandle,
121 ctx: &'a FormatContext,
122 record_size: u16,
123 node_size: u32,
124 geo: &'a crate::format::chunk_index::btree_v2::Bt2Geometry,
125 records: Vec<u8>,
126 node_addrs: Vec<u64>,
127}
128
129impl<'a> Bt2Walk<'a> {
130 fn new(
131 handle: &'a FileHandle,
132 ctx: &'a FormatContext,
133 record_size: u16,
134 node_size: u32,
135 geo: &'a crate::format::chunk_index::btree_v2::Bt2Geometry,
136 ) -> Self {
137 Self {
138 handle,
139 ctx,
140 record_size,
141 node_size,
142 geo,
143 records: Vec::new(),
144 node_addrs: Vec::new(),
145 }
146 }
147
148 /// Walk the subtree rooted at `addr`, at depth `depth` with `nrec`
149 /// records, collecting every node's raw record bytes and every node
150 /// block's address.
151 fn descend(&mut self, addr: u64, depth: u16, nrec: u16) -> IoResult<()> {
152 use crate::format::chunk_index::btree_v2::{Bt2InternalNode, Bt2LeafNode};
153
154 self.node_addrs.push(addr);
155 let buf = self.handle.read_at_most(addr, self.node_size as usize)?;
156 if depth == 0 {
157 let leaf = Bt2LeafNode::decode(&buf, nrec, self.record_size)?;
158 self.records.extend_from_slice(&leaf.record_data);
159 } else {
160 let node = Bt2InternalNode::decode(
161 &buf,
162 self.ctx,
163 depth,
164 nrec,
165 self.record_size,
166 self.geo.max_nrec_size,
167 self.geo.child_total_size(depth),
168 )?;
169 // In-order: an internal node's records separate its children, so each
170 // one belongs between the subtrees on either side of it.
171 let children: Vec<(u64, u16)> = node
172 .child_addrs
173 .iter()
174 .zip(node.child_nrecords.iter())
175 .map(|(&a, &n)| (a, n))
176 .collect();
177 let rec = self.record_size as usize;
178 for (i, (child_addr, child_nrec)) in children.into_iter().enumerate() {
179 self.descend(child_addr, depth - 1, child_nrec)?;
180 if let Some(record) = node.record_data.get(i * rec..(i + 1) * rec) {
181 self.records.extend_from_slice(record);
182 }
183 }
184 }
185 Ok(())
186 }
187}
188
189/// A walk of a version-1 raw-data-chunk B-tree: the file and geometry the
190/// descent reads through, and the two collections it fills.
191///
192/// The v1 counterpart of [`Bt2Walk`], and for the same reason: the
193/// records are what [`BtreeV1DatasetInfo::build_tree`] bulk-loads on the next
194/// flush, and the addresses are the block pool that flush re-serializes over,
195/// so a reopened tree owns the nodes it found instead of leaking them and
196/// allocating a second set beside them.
197///
198/// One value rather than nine parameters threaded through the recursion: only
199/// `addr` and `depth` change between one level and the next, so they are what
200/// [`descend`](Self::descend) takes and everything else lives here.
201struct BtreeV1Walk<'a> {
202 handle: &'a FileHandle,
203 ctx: &'a FormatContext,
204 config: &'a BTreeV1Config,
205 /// The chunk edge lengths, *without* the trailing element-size dimension,
206 /// so `chunk_dims.len()` is the rank the node keys are decoded at.
207 chunk_dims: &'a [u64],
208 file_size: u64,
209 records: Vec<BtreeV1ChunkRecord>,
210 node_addrs: Vec<u64>,
211}
212
213impl<'a> BtreeV1Walk<'a> {
214 fn new(
215 handle: &'a FileHandle,
216 ctx: &'a FormatContext,
217 config: &'a BTreeV1Config,
218 chunk_dims: &'a [u64],
219 file_size: u64,
220 ) -> Self {
221 Self {
222 handle,
223 ctx,
224 config,
225 chunk_dims,
226 file_size,
227 records: Vec::new(),
228 node_addrs: Vec::new(),
229 }
230 }
231
232 /// Walk the subtree rooted at `addr`, collecting every leaf entry as a
233 /// [`BtreeV1ChunkRecord`] and every node block's address.
234 ///
235 /// Records come out in key order because a v1 B-tree's leaves are in key
236 /// order and this descends left to right, which is what
237 /// [`BtreeV1DatasetInfo::position`]'s binary search needs. The keys store
238 /// element offsets (`scaled * chunk_dim`, `H5D__btree_encode_key`), so the
239 /// grid position this records is the quotient.
240 fn descend(&mut self, addr: u64, depth: u32) -> IoResult<()> {
241 // The same bound the reader's walk uses: a node's level is one byte, so
242 // no honest tree is deeper than that, and a cyclic index stops here.
243 if depth > 256 {
244 return Err(crate::io::IoError::InvalidState(
245 "chunk B-tree v1 exceeds maximum depth".into(),
246 ));
247 }
248 if addr == UNDEF_ADDR || addr >= self.file_size {
249 return Ok(());
250 }
251 let rank = self.chunk_dims.len();
252 let sa = self.ctx.sizeof_addr as usize;
253 let node_size = self.config.chunk_btree_node_size(sa, rank);
254 let buf = self.handle.read_at_most(addr, node_size)?;
255 let node = ChunkBTreeV1Node::decode(&buf, sa, rank, self.config.chunk_max_entries())?;
256 self.node_addrs.push(addr);
257
258 if node.level == 0 {
259 for (i, &child_addr) in node.children.iter().enumerate() {
260 let key = &node.keys[i];
261 let scaled: Vec<u64> = key.offsets[..rank]
262 .iter()
263 .zip(self.chunk_dims)
264 .map(|(&offset, &dim)| offset.checked_div(dim).unwrap_or(0))
265 .collect();
266 self.records.push(BtreeV1ChunkRecord {
267 scaled,
268 address: child_addr,
269 nbytes: key.chunk_size,
270 filter_mask: key.filter_mask,
271 });
272 }
273 } else {
274 for &child_addr in &node.children {
275 self.descend(child_addr, depth + 1)?;
276 }
277 }
278 Ok(())
279 }
280}
281
282/// Encode a fixed-array data block for the layout implied by `hdr`, using the
283/// chunk addresses held in `dblk.elements` (unfiltered) or the filtered chunk
284/// entries in `dblk.filtered_elements` (filtered, `client_id == 1`).
285///
286/// For the paged layout (`hdr.is_paged()`), emits the `FADB` prefix with a
287/// page-init bitmap followed by `npages` checksummed element pages. A page is
288/// marked initialized iff at least one of its chunk addresses is defined,
289/// mirroring libhdf5's lazy `H5FA__dblk_page_create`. Uninitialized pages are
290/// still written (all `UNDEF_ADDR`, valid checksum) so the file contains no
291/// uninitialized bytes; the reader skips them via the bitmap.
292fn encode_fixed_array_dblk(
293 ctx: &FormatContext,
294 hdr: &FixedArrayHeader,
295 dblk: &FixedArrayDataBlock,
296) -> Vec<u8> {
297 let is_filtered = hdr.client_id == FA_CLIENT_FILT_CHUNK;
298 let sa = ctx.sizeof_addr as usize;
299 // chunk_size_len for filtered entries = element_size - sizeof_addr - 4.
300 // libhdf5 carries element_size = sizeof_addr + chunk_size_len + 4.
301 let chunk_size_len = (hdr.element_size as usize).saturating_sub(sa + 4);
302
303 if !hdr.is_paged() {
304 return if is_filtered {
305 dblk.encode_filtered(ctx, chunk_size_len)
306 } else {
307 dblk.encode_unfiltered(ctx)
308 };
309 }
310
311 let npages = hdr.npages() as usize;
312 let dblk_page_nelmts = hdr.dblk_page_nelmts() as usize;
313
314 // Build the page-init bitmap (MSB-first): a page is initialized iff any of
315 // its elements points at a defined address.
316 let mut bitmap = vec![0u8; npages.div_ceil(8)];
317 let nelmts = if is_filtered {
318 dblk.filtered_elements.len()
319 } else {
320 dblk.elements.len()
321 };
322 for p in 0..npages {
323 let start = p * dblk_page_nelmts;
324 let end = ((p + 1) * dblk_page_nelmts).min(nelmts);
325 let initialized = if is_filtered {
326 dblk.filtered_elements[start..end]
327 .iter()
328 .any(|e| e.address != UNDEF_ADDR)
329 } else {
330 dblk.elements[start..end].iter().any(|&a| a != UNDEF_ADDR)
331 };
332 if initialized {
333 bitmap[p / 8] |= 0x80u8 >> (p % 8);
334 }
335 }
336
337 let prefix = FixedArrayPagedPrefix {
338 client_id: hdr.client_id,
339 header_addr: dblk.header_addr,
340 page_init_bitmap: bitmap,
341 prefix_size: 4 + 1 + 1 + sa + npages.div_ceil(8) + 4,
342 };
343
344 let mut buf = prefix.encode(ctx);
345 debug_assert_eq!(buf.len(), prefix.prefix_size);
346
347 // Append each page: all pages use the full `dblk_page_nelmts` stride;
348 // only the last page holds fewer elements (libhdf5 H5FA.c).
349 for p in 0..npages {
350 let start = p * dblk_page_nelmts;
351 let end = ((p + 1) * dblk_page_nelmts).min(nelmts);
352 if is_filtered {
353 buf.extend_from_slice(&encode_filtered_page(
354 &dblk.filtered_elements[start..end],
355 ctx,
356 chunk_size_len,
357 ));
358 } else {
359 buf.extend_from_slice(&encode_unfiltered_page(&dblk.elements[start..end], ctx));
360 }
361 }
362 buf
363}
364
365/// Decode a fixed-array data block for the layout implied by `hdr` — the
366/// inverse of [`encode_fixed_array_dblk`], and the single decode dispatch
367/// over non-paged/paged × unfiltered/filtered.
368///
369/// For the paged layout, pages whose bitmap bit is clear are skipped, not
370/// decoded: libhdf5 never writes an uninitialized page, so its bytes are
371/// arbitrary and carry no valid checksum. Their elements stay at the
372/// undefined-address defaults, which is exactly what the bitmap means.
373fn decode_fixed_array_dblk(
374 ctx: &FormatContext,
375 hdr: &FixedArrayHeader,
376 buf: &[u8],
377 chunk_size_len: usize,
378) -> crate::format::FormatResult<FixedArrayDataBlock> {
379 let is_filtered = hdr.client_id == FA_CLIENT_FILT_CHUNK;
380 let num_elmts = hdr.num_elmts as usize;
381
382 if !hdr.is_paged() {
383 return if is_filtered {
384 FixedArrayDataBlock::decode_filtered(buf, ctx, num_elmts, chunk_size_len)
385 } else {
386 FixedArrayDataBlock::decode_unfiltered(buf, ctx, num_elmts)
387 };
388 }
389
390 let npages = hdr.npages() as usize;
391 let dblk_page_nelmts = hdr.dblk_page_nelmts() as usize;
392 let prefix = FixedArrayPagedPrefix::decode(buf, ctx, npages as u64)?;
393
394 let mut dblk = if is_filtered {
395 FixedArrayDataBlock::new_filtered(prefix.header_addr, num_elmts)
396 } else {
397 FixedArrayDataBlock::new_unfiltered(prefix.header_addr, num_elmts)
398 };
399 dblk.client_id = hdr.client_id;
400
401 // Pages follow the prefix back to back; every page spans the full
402 // `dblk_page_nelmts` stride except the last, which holds the remainder.
403 let mut pos = prefix.prefix_size;
404 for p in 0..npages {
405 let start = p * dblk_page_nelmts;
406 let end = ((p + 1) * dblk_page_nelmts).min(num_elmts);
407 let nelmts = end - start;
408 if prefix.page_initialized(p) {
409 let page_buf = buf.get(pos..).unwrap_or(&[]);
410 if is_filtered {
411 let elems = decode_filtered_page(page_buf, ctx, nelmts, chunk_size_len)?;
412 dblk.filtered_elements[start..end].clone_from_slice(&elems);
413 } else {
414 let addrs = decode_unfiltered_page(page_buf, ctx, nelmts)?;
415 dblk.elements[start..end].copy_from_slice(&addrs);
416 }
417 }
418 pos += nelmts * hdr.element_size as usize + 4;
419 }
420 Ok(dblk)
421}
422
423/// Interior-mutability cell for per-dataset write state, selected by feature.
424///
425/// This is the §5-B "cfg-selected interior types" from
426/// `docs/threadsafe-fine-grained-locking.md`: the single-threaded build uses a
427/// `RefCell` (zero overhead, no atomics), while the `threadsafe` build uses a
428/// `Mutex` so two threads can write *different* datasets concurrently while the
429/// same dataset's writes serialize. Call sites are identical across both via
430/// [`Slot::lock`].
431#[cfg(not(feature = "threadsafe"))]
432pub(crate) struct Slot<T>(std::cell::RefCell<T>);
433
434#[cfg(not(feature = "threadsafe"))]
435impl<T> Slot<T> {
436 pub(crate) fn new(value: T) -> Self {
437 Slot(std::cell::RefCell::new(value))
438 }
439 /// Borrow the contents mutably (an uncontended `RefCell` borrow).
440 pub(crate) fn lock(&self) -> std::cell::RefMut<'_, T> {
441 self.0.borrow_mut()
442 }
443}
444
445#[cfg(feature = "threadsafe")]
446pub(crate) struct Slot<T>(std::sync::Mutex<T>);
447
448#[cfg(feature = "threadsafe")]
449impl<T> Slot<T> {
450 pub(crate) fn new(value: T) -> Self {
451 Slot(std::sync::Mutex::new(value))
452 }
453 /// Lock the contents. Different datasets hold different slots, so this
454 /// only contends when two threads write the *same* dataset.
455 pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, T> {
456 self.0.lock().unwrap()
457 }
458}
459
460/// Proof that the create gate (`create_lock`) is held and the new dataset's
461/// name passed the uniqueness check. Only [`Hdf5Writer::begin_create`]
462/// constructs one and [`Hdf5Writer::push_dataset`] demands one, so a creator
463/// cannot reach the dataset registry while skipping either step. Carries
464/// the canonical (link-resolved) name the creator must store, so the
465/// registry only ever holds tree paths.
466pub(crate) struct CreateGuard<'a> {
467 #[cfg(not(feature = "threadsafe"))]
468 _gate: std::cell::RefMut<'a, ()>,
469 #[cfg(feature = "threadsafe")]
470 _gate: std::sync::MutexGuard<'a, ()>,
471 /// The dataset name with every group hard link in it resolved.
472 pub(crate) name: String,
473 /// The group that will hold the new dataset's link, resolved from the
474 /// path components of `name`; `None` is the root group. Carried here so
475 /// [`Hdf5Writer::push_dataset`] registers the child itself and no creator
476 /// can leave a dataset whose name says one thing and whose parent group
477 /// says another.
478 pub(crate) parent: Option<usize>,
479}
480
481/// Reference-counted shared pointer, feature-selected. The single-thread
482/// build uses `Rc` (no atomics); the `threadsafe` build uses `Arc` so a
483/// dataset/group slot can be cloned out of the registry and locked on its
484/// own — letting writes to *different* datasets proceed concurrently without
485/// holding the registry lock. See `docs/threadsafe-fine-grained-locking.md`
486/// (Stage 3).
487#[cfg(not(feature = "threadsafe"))]
488pub(crate) type Shared<T> = std::rc::Rc<T>;
489#[cfg(feature = "threadsafe")]
490pub(crate) type Shared<T> = std::sync::Arc<T>;
491
492/// One dataset's cell in the registry: its metadata slot plus the operation
493/// lock that serializes whole logical operations on it. Both live in one
494/// allocation so they cannot fall out of step — every dataset has its op
495/// lock by construction.
496pub(crate) struct DatasetCell {
497 /// Serializes one *whole* logical operation on this dataset.
498 ///
499 /// The metadata slot below serializes each individual acquisition, but a
500 /// multi-acquisition operation — take the append buffer → write chunks →
501 /// re-buffer the tail → extend, or flush-then-overwrite in a slice write
502 /// — would interleave with a concurrent same-dataset operation *between*
503 /// its acquisitions under `threadsafe`. Public write entries take this
504 /// lock and delegate to `_inner` variants; `_inner` variants and the
505 /// `pub(crate)` write helpers require the caller to hold it (or to hold
506 /// the writer exclusively via `&mut`, as close and the SWMR wrapper do).
507 ///
508 /// Not reentrant: the single-thread build's `RefCell` panics instantly
509 /// on a nested acquisition, so a missed entry/inner split fails loudly
510 /// in every test run rather than deadlocking only under `threadsafe`.
511 ///
512 /// Lock order: `create_lock → op → registry spine → metadata slot`. An
513 /// op lock is never held across another dataset's op lock, and no
514 /// op-lock holder takes `create_lock`, so the order is acyclic.
515 pub(crate) op: Slot<()>,
516 info: Slot<DatasetInfo>,
517}
518
519impl DatasetCell {
520 pub(crate) fn new(info: DatasetInfo) -> Self {
521 DatasetCell {
522 op: Slot::new(()),
523 info: Slot::new(info),
524 }
525 }
526
527 /// Borrow the metadata slot (a single acquisition; see [`Self::op`] for
528 /// whole-operation serialization).
529 #[cfg(not(feature = "threadsafe"))]
530 pub(crate) fn lock(&self) -> std::cell::RefMut<'_, DatasetInfo> {
531 self.info.lock()
532 }
533
534 /// Lock the metadata slot (a single acquisition; see [`Self::op`] for
535 /// whole-operation serialization).
536 #[cfg(feature = "threadsafe")]
537 pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, DatasetInfo> {
538 self.info.lock()
539 }
540}
541
542/// A single dataset's [`DatasetCell`], reference-counted so a writer can
543/// clone it out of the registry (releasing the registry lock) and then lock
544/// just this one dataset. Two threads writing different datasets take
545/// different `DatasetRef` locks and never contend; the same dataset's writes
546/// serialize, which is required because one chunk index is not concurrently
547/// mutable.
548pub(crate) type DatasetRef = Shared<DatasetCell>;
549
550/// A single group's metadata behind its own [`Slot`], reference-counted like
551/// [`DatasetRef`].
552pub(crate) type GroupRef = Shared<Slot<GroupInfo>>;
553
554/// Appended frames held back until they complete a chunk.
555///
556/// The buffer is the sole authority for rows `base .. base + frames`: the
557/// file's chunks do not hold them yet, and any operation that writes those
558/// rows must go through [`Hdf5Writer::flush_append_buffer`] first. `base` is
559/// recorded when the frames are buffered — never derived from the current
560/// extent, which an `extend_dataset` can move independently.
561pub struct AppendBuffer {
562 /// Absolute row of the first buffered frame.
563 pub base: u64,
564 /// Number of buffered frames.
565 pub frames: u64,
566 /// The frames' bytes, `frames` whole rows, row-major.
567 pub bytes: Vec<u8>,
568}
569
570/// One file a dataset's raw data lives in, as the writer holds it: the name
571/// the I/O path opens, together with the local-heap offset the External File
572/// List message stores that name as.
573///
574/// The two halves are one entry rather than two parallel lists because they
575/// describe one slot — the message encodes `name_offset`, and every read or
576/// write of the slot's bytes opens `name`; splitting them is what lets a
577/// rewrite pair a name with another slot's offset.
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub struct ExternalFile {
580 /// The file name exactly as the heap stores it. Resolved against
581 /// `HDF5_EXTFILE_PREFIX` at I/O time, never here — the same rule the read
582 /// side follows.
583 pub name: String,
584 /// Where `name` sits in the local heap at [`ExternalStorage::heap_addr`].
585 pub name_offset: u64,
586 /// Byte offset within `name` where this slot's region begins.
587 pub offset: u64,
588 /// Bytes of the dataset's raw data this slot holds.
589 pub size: u64,
590}
591
592/// A dataset whose contiguous raw data lives outside this file — the External
593/// File List message (`H5O_EFL_ID`) and the local heap its names are in.
594///
595/// The data layout message of such a dataset still says `Contiguous`, with
596/// its address left undefined: it is this message's presence that makes
597/// libhdf5 route the dataset's I/O through `H5D_LOPS_EFL` (H5Dlayout.c).
598#[derive(Debug, Clone)]
599pub struct ExternalStorage {
600 /// Address of the local heap header holding every slot's name.
601 pub heap_addr: u64,
602 /// The files, in the order their regions concatenate into the dataset's
603 /// logical byte range.
604 pub files: Vec<ExternalFile>,
605 /// The prefix every one of those names is joined against, and the open
606 /// that settled it. Lives here rather than on [`DatasetInfo`] so a
607 /// dataset with no external storage cannot carry a prefix and a dataset
608 /// with external storage cannot lack one.
609 prefix: EfilePrefix,
610}
611
612/// The expanded external file prefix in force for one dataset, and the open
613/// that decided it — libhdf5's `dset->shared->extfile_prefix`.
614///
615/// `H5D__build_file_prefix` runs it once per open of the shared info, from
616/// the dapl of `H5D__create` (H5Dint.c:1318) or of the `H5D__open` that
617/// found no shared info yet (:1537), and both `H5D__efl_read` and
618/// `H5D__efl_write` then join against that one answer (H5Defl.c:315-317,
619/// :429-431). Measured under libhdf5 1.14.6 and 2.0.0: `H5Dcreate2` with a
620/// dapl naming a directory creates the raw data file there at `H5Dwrite`,
621/// and `HDF5_EXTFILE_PREFIX` shadows that property on the write path exactly
622/// as it does on the read path.
623#[derive(Debug, Clone, Default)]
624struct EfilePrefix {
625 /// The expansion itself; `None` is "no prefix", which leaves a stored
626 /// name to resolve against the process's current directory.
627 expanded: Option<PathBuf>,
628 /// The open that decided [`expanded`](Self::expanded). An expired handle
629 /// means no open is holding the answer any more, so the next one settles
630 /// it afresh — which is the state a dataset this session reopened starts
631 /// in, `H5Fopen` opening no dataset of its own.
632 open: std::sync::Weak<()>,
633}
634
635impl ExternalStorage {
636 /// The message this storage encodes to (`H5O_efl_t`).
637 fn message(&self) -> ExternalFileListMessage {
638 ExternalFileListMessage {
639 heap_addr: self.heap_addr,
640 slots: self
641 .files
642 .iter()
643 .map(
644 |f| crate::format::messages::external_file_list::ExternalFileSlot {
645 name_offset: f.name_offset,
646 offset: f.offset,
647 size: f.size,
648 },
649 )
650 .collect(),
651 }
652 }
653
654 /// Bytes the slots reserve in total (`H5O_efl_total_size`), saturating
655 /// rather than wrapping so an overflowing list reads as "as large as it
656 /// gets" and passes any size check instead of failing one.
657 fn total_size(&self) -> u64 {
658 self.files
659 .iter()
660 .fold(0u64, |acc, f| acc.saturating_add(f.size))
661 }
662}
663
664/// A dataset whose elements are read out of other datasets — the virtual
665/// layout message (`H5D_VIRTUAL`) and the mapping list it points at.
666///
667/// The mappings live in one global heap object rather than in the header
668/// (`H5D__virtual_store_layout`), so the layout message carries only its
669/// address and index; the list itself is kept here so a rewrite of the header
670/// can re-emit the message pointing at the same object.
671#[derive(Debug, Clone, PartialEq, Eq)]
672pub struct VirtualStorage {
673 /// Address of the global heap collection holding the mapping list.
674 pub heap_addr: u64,
675 /// Index of the mapping-list object within that collection.
676 pub heap_index: u32,
677 /// The mappings themselves, in the order they were declared — which is
678 /// the order libhdf5 resolves overlapping ones in.
679 pub mappings: Vec<VirtualMapping>,
680}
681
682/// Where a contiguous dataset's raw bytes live, read off its registry entry
683/// so the write itself can run with the slot unlocked.
684///
685/// The one place the local-versus-external-versus-nowhere choice is made; see
686/// [`DatasetInfo::contiguous_target`].
687enum ContiguousTarget {
688 /// A block in this file, starting at this address.
689 Local(u64),
690 /// The files an External File List names, in dataset order, and the
691 /// prefix in force for the open doing the writing — carried together
692 /// because a slot name means nothing without it.
693 External {
694 files: Vec<ExternalFile>,
695 prefix: Option<PathBuf>,
696 },
697 /// Nowhere: the dataset is virtual, and every element of it is stored in
698 /// whichever source dataset its mappings send that element to.
699 Virtual,
700}
701
702/// What a writer-mode `H5Dataset` handle is built from — the shape and
703/// element width it answers questions with, the chunk index it writes
704/// through, and the open it holds.
705pub(crate) struct DatasetHandleParts {
706 pub(crate) shape: Vec<usize>,
707 pub(crate) element_size: usize,
708 /// `None` for storage that is not chunked.
709 pub(crate) chunk_index: Option<ChunkIndexKind>,
710 /// Keeps this open alive; see [`Hdf5Writer::bind_efile_prefix`].
711 pub(crate) open: Option<crate::io::reader::DatasetOpenToken>,
712}
713
714impl ContiguousTarget {
715 /// Whether this target is storage bytes can be written into at all —
716 /// false only for [`ContiguousTarget::Virtual`], which names sources
717 /// rather than storage.
718 fn is_storage(&self) -> bool {
719 !matches!(self, Self::Virtual)
720 }
721}
722
723/// The one refusal of a write into a virtual dataset, so the two paths that
724/// can reach one — [`Hdf5Writer::write_contiguous_bytes`] and the pre-insert
725/// gate of [`Hdf5Writer::write_vlen_strings_slice`] — say the same thing.
726///
727/// libhdf5 does take this write, pushing each element through the mapping
728/// that covers it into the source dataset holding it (`H5D__virtual_write`);
729/// this writer never opens a source file, so it refuses rather than dropping
730/// the bytes somewhere they cannot be read back from.
731/// The legality checks `H5Pset_virtual` runs over one mapping —
732/// `H5D_virtual_check_mapping_pre` and `H5D_virtual_check_mapping_post`
733/// (H5Dvirtual.c).
734///
735/// The two upstream checks that need the *source dataset's* own extent (the
736/// limited/limited element-count match, and a printf mapping's single-block
737/// match) are not run here for the same reason upstream skips them when the
738/// source space status is `H5O_VIRTUAL_STATUS_INVALID`: a mapping may name a
739/// source that does not exist yet, and nothing here opens one.
740fn check_virtual_mapping(dataset: &str, m: &VirtualMapping) -> IoResult<()> {
741 for (which, sel) in [
742 ("virtual", &m.virtual_selection),
743 ("source", &m.source_selection),
744 ] {
745 if matches!(sel, Selection::Points(_)) {
746 return Err(crate::io::IoError::Unsupported(format!(
747 "virtual dataset '{dataset}' has a point {which} selection, which \
748 H5D_virtual_check_mapping_pre refuses for every virtual dataset mapping \
749 (\"point selections not currently supported with virtual datasets\")"
750 )));
751 }
752 }
753
754 let unlim_virtual = m.virtual_selection.unlim_dim().is_some();
755 let unlim_source = m.source_selection.unlim_dim().is_some();
756
757 // Both sides unbounded: the mapping grows with its source, so the slices
758 // they exchange must be the same shape whatever either extent becomes.
759 if unlim_virtual && unlim_source {
760 if let (Some(v), Some(sr)) = (
761 regular_hyperslab(&m.virtual_selection),
762 regular_hyperslab(&m.source_selection),
763 ) {
764 let (nv, ns) = (v.num_elem_non_unlim(), sr.num_elem_non_unlim());
765 if nv != ns {
766 return Err(crate::io::IoError::InvalidState(format!(
767 "virtual dataset '{dataset}' maps an unlimited source selection onto an \
768 unlimited virtual selection, but a slice of the non-unlimited \
769 dimensions holds {ns:?} source elements and {nv:?} virtual ones"
770 )));
771 }
772 }
773 }
774
775 // `H5D_virtual_check_mapping_post`: an unlimited virtual selection over a
776 // limited source selection is the printf shape, where each block of the
777 // virtual selection is filled by a *different* source dataset named by
778 // substituting that block's index. It needs a `%b` to name them, and a
779 // hyperslab virtual selection to have blocks at all; every other shape
780 // needs the opposite, since a substitution with only one block to fill
781 // has nothing to vary over.
782 let nsubs = parse_source_name(&m.source_file_name)
783 .and_then(|f| Ok(f.nsubs() + parse_source_name(&m.source_dset_name)?.nsubs()))
784 .map_err(|e| {
785 crate::io::IoError::InvalidState(format!(
786 "virtual dataset '{dataset}' source name: {e}"
787 ))
788 })?;
789 if unlim_virtual && !unlim_source {
790 if nsubs == 0 {
791 return Err(crate::io::IoError::InvalidState(format!(
792 "virtual dataset '{dataset}' has an unlimited virtual selection, a limited \
793 source selection, and no printf specifiers in source names"
794 )));
795 }
796 if !matches!(m.virtual_selection, Selection::Hyperslab { .. }) {
797 return Err(crate::io::IoError::InvalidState(format!(
798 "virtual dataset '{dataset}' has a printf mapping whose virtual selection is \
799 not a hyperslab; the substitution runs over the blocks of that hyperslab"
800 )));
801 }
802 } else if nsubs > 0 {
803 return Err(crate::io::IoError::InvalidState(format!(
804 "virtual dataset '{dataset}' has printf specifier(s) in source name(s) without \
805 an unlimited virtual selection and limited source selection"
806 )));
807 }
808 Ok(())
809}
810
811/// The regular (start, stride, count, block) form behind a selection, or
812/// `None` — the only form that can carry `H5S_UNLIMITED`, so every unlimited
813/// check goes through it.
814fn regular_hyperslab(sel: &Selection) -> Option<&crate::format::selection::RegularHyperslab> {
815 match sel {
816 Selection::Hyperslab {
817 form: crate::format::selection::Hyperslab::Regular(r),
818 ..
819 } => Some(r),
820 _ => None,
821 }
822}
823
824fn virtual_write_refused() -> crate::io::IoError {
825 crate::io::IoError::Unsupported(
826 "cannot write into a virtual dataset: its elements live in the source datasets \
827 its mappings name, and this writer does not write through to them — write the \
828 source datasets themselves"
829 .into(),
830 )
831}
832
833/// Metadata for a dataset being written.
834///
835/// The whole struct lives behind a per-dataset [`Slot`] (via [`DatasetRef`]).
836/// The streaming write path locks it only briefly — compression runs *outside*
837/// the lock — so writes to different datasets do not contend, and a structural
838/// op (create/delete) that scans names only momentarily touches a sibling
839/// slot.
840pub struct DatasetInfo {
841 /// Link name within the root group.
842 pub name: String,
843 /// Element datatype.
844 pub datatype: DatatypeMessage,
845 /// The committed datatype this dataset shares, when it was created from
846 /// one. The type itself stays in [`datatype`](Self::datatype) — the
847 /// dataspace, the element width and every payload check need it — and
848 /// this says the header must store a pointer to that object instead of a
849 /// datatype message of its own.
850 pub committed_type: Option<CommittedTypeRef>,
851 /// Dataspace (dimensionality).
852 pub dataspace: DataspaceMessage,
853 /// The object format the reopen found this dataset's messages written in,
854 /// `None` for a dataset this session created.
855 ///
856 /// A rewrite re-encodes the whole header — the shared-message table is
857 /// laid out whole, so every heap ID moves and every header naming one has
858 /// to be written again. Re-deriving the message format from the reopened
859 /// session's bounds would upgrade messages the file already has, which
860 /// libhdf5 never does: it grows a header in place and leaves every
861 /// message it did not touch alone. The same rule the reopen already
862 /// applies to a group it found in a symbol table
863 /// ([`uses_symbol_table`](Hdf5Writer::uses_symbol_table)) — what the file
864 /// says governs, not what this session's bound would have chosen.
865 pub read_format: Option<ObjectFormat>,
866 /// File offset of the dataset's object header (set during finalize).
867 pub obj_header_addr: u64,
868 /// File offset of the raw data block (contiguous only).
869 pub data_addr: u64,
870 /// Size of the raw data in bytes (contiguous only).
871 pub data_size: u64,
872 /// The raw data itself, for a compact dataset — the whole image, which
873 /// [`build_dataset_header`](Hdf5Writer::build_dataset_header) puts inside
874 /// the data layout message rather than in a block of its own. `Some` is
875 /// what makes a dataset compact, and the buffer is created at its final
876 /// length (filled, as `H5D__compact_fill` does, before any write), so it
877 /// is also the dataset's byte count; `data_addr`/`data_size` stay at the
878 /// "no block in the file" values a compact dataset shares with a NULL one.
879 pub compact: Option<Vec<u8>>,
880 /// The files this dataset's contiguous raw data lives in, when it lives
881 /// outside this HDF5 file. `Some` is what makes a contiguous dataset
882 /// externally stored: its `data_addr` stays [`UNDEF_ADDR`] and every byte
883 /// goes to the files named here instead of to a block of this file's own.
884 pub external: Option<ExternalStorage>,
885 /// The source datasets this dataset's elements are read from, when it is
886 /// virtual. `Some` is what makes it virtual, and it stores nothing of its
887 /// own: `data_addr`/`data_size` keep the "no block in this file" values a
888 /// compact dataset also has.
889 pub virtual_storage: Option<VirtualStorage>,
890 /// Chunked storage info (None for contiguous).
891 pub chunked: Option<ChunkedDatasetInfo>,
892 /// Fixed array chunked storage info.
893 pub fixed_array: Option<FixedArrayDatasetInfo>,
894 /// B-tree v2 chunked storage info.
895 pub btree_v2: Option<Bt2DatasetInfo>,
896 /// Implicit (no structure) chunked storage info.
897 pub implicit: Option<ImplicitDatasetInfo>,
898 /// Single-chunk chunked storage info: the whole (fixed) dataspace is
899 /// exactly one chunk.
900 pub single_chunk: Option<SingleChunkDatasetInfo>,
901 /// Version-1 B-tree chunked storage info — the classic-format index.
902 pub btree_v1: Option<BtreeV1DatasetInfo>,
903 /// Appended frames not yet written to chunks, `None` when empty.
904 pub append: Option<AppendBuffer>,
905 /// Attributes attached to this dataset.
906 pub attributes: Vec<AttributeEntry>,
907 /// File offset where the dataset object header was written (for SWMR in-place rewrites).
908 pub obj_header_written_addr: Option<u64>,
909 /// Encoded size of the dataset object header (for verifying in-place rewrites fit).
910 /// Every block the object's on-disk header occupies, chunk 0 first, or
911 /// empty when it has none yet. A rewrite keeps chunk 0's block — its
912 /// address is what every reference to the object holds — and frees the
913 /// rest, so a continuation block left behind is space no free-space
914 /// manager records.
915 pub obj_header_blocks: crate::io::object_header_io::HeaderBlocks,
916 /// Filter pipeline for compressed chunks.
917 pub filter_pipeline: Option<FilterPipeline>,
918 /// Soft-deleted: excluded from finalize output.
919 pub deleted: bool,
920 /// The dataspace extent changed this session (`extend_dataset` /
921 /// `set_dataset_extent`). On a reopened dataset the finalize gate
922 /// otherwise infers "modified" from `chunks_written` alone, and a
923 /// session that only changed the extent would keep the old on-disk
924 /// header — silently dropping the new shape.
925 pub extent_dirty: bool,
926 /// Something the object header encodes changed this session without
927 /// touching the dataset's storage — an attribute set or removed, a fill
928 /// value defined. See [`header_stale`](DatasetInfo::header_stale).
929 pub header_dirty: bool,
930 /// The hard link count the on-disk header was written with, so finalize
931 /// can tell that this session changed it.
932 ///
933 /// A count, not a flag, because the count is what the header records and
934 /// the ways to change it are many: creating a link, unlinking one,
935 /// deleting a link's parent group, promoting a link to a primary name.
936 /// Comparing the value closes all of them at once, where a dirty flag
937 /// would have to be set at each and would be forgotten at the next one
938 /// added.
939 pub nlink_written: u32,
940 /// When the link naming this dataset was created; see
941 /// [`GroupInfo::creation_seq`].
942 pub creation_seq: u64,
943 /// How this dataset records creation order for its attributes — the
944 /// file's creation-order policy captured when the dataset was created,
945 /// the way libhdf5 captures the DCPL. A dataset holds no links, so only
946 /// the attribute half of [`TrackOrder`] applies to it.
947 pub track_attr_order: CreationOrder,
948 /// User-defined fill value bytes (exactly one element wide). `None`
949 /// means default zero-fill; `Some` is emitted as a `fill_defined = 2`
950 /// fill-value message in the dataset object header.
951 pub fill_value: Option<Vec<u8>>,
952 /// Fill value write time (`H5Pset_fill_time`'s `H5D_fill_time_t`, one of
953 /// [`FILL_TIME_ALLOC`], [`FILL_TIME_NEVER`], [`FILL_TIME_IFSET`]),
954 /// emitted verbatim into the fill-value message's write-time field.
955 /// Defaults to `FILL_TIME_IFSET`, `H5D_CRT_FILL_TIME_DEF` — what a fresh
956 /// dataset creation property list carries until `set_dataset_fill_time`
957 /// says otherwise.
958 pub fill_time: u8,
959 /// Layout message version for chunked storage: 4, or 5 when the chunk
960 /// index encodes stored chunk sizes in a fixed `sizeof_size` field
961 /// (libhdf5 2.0). Chosen at create by `Hdf5Writer::chunk_layout_version`,
962 /// preserved from the file on reopen, and emitted verbatim at finalize.
963 /// Contiguous datasets ignore it.
964 pub layout_version: u8,
965 /// The times this object tracks: `Some` exactly when it was created with
966 /// `H5Pset_obj_track_times(true)`, `None` when it was not.
967 ///
968 /// One meaning on both header versions, which store them differently and
969 /// store different amounts of them: a version-2 header keeps all four in
970 /// its prefix, and a version-1 dataset keeps one, in an `H5O_MTIME_NEW`
971 /// message. [`touch_oh`] is the single place that turns this into either
972 /// of those, so the four fields are here whichever version the object
973 /// has, exactly as `H5O_t` carries `atime`/`mtime`/`ctime`/`btime` for a
974 /// version-1 header it never serialises them from.
975 pub times: Option<ObjectTimes>,
976}
977
978impl DatasetInfo {
979 /// Which chunk index this dataset uses, `None` for storage that is not
980 /// chunked — the one place the index-carrying fields are turned into an
981 /// answer.
982 ///
983 /// INVARIANT: a chunk index added to this struct is added here. A site
984 /// that spells the disjunction out itself is what classifies a new index
985 /// as contiguous storage, and contiguous storage is read and written at
986 /// [`data_addr`](Self::data_addr) — which a chunked dataset leaves
987 /// undefined, so the misclassification is a read or a write at
988 /// `UNDEF_ADDR` rather than an error.
989 pub(crate) fn chunk_index_kind(&self) -> Option<ChunkIndexKind> {
990 if self.chunked.is_some() {
991 Some(ChunkIndexKind::ExtensibleArray)
992 } else if self.fixed_array.is_some() {
993 Some(ChunkIndexKind::FixedArray)
994 } else if self.btree_v2.is_some() {
995 Some(ChunkIndexKind::BtreeV2)
996 } else if self.implicit.is_some() {
997 Some(ChunkIndexKind::Implicit)
998 } else if self.single_chunk.is_some() {
999 Some(ChunkIndexKind::SingleChunk)
1000 } else if self.btree_v1.is_some() {
1001 Some(ChunkIndexKind::BtreeV1)
1002 } else {
1003 None
1004 }
1005 }
1006
1007 /// Whether this dataset's raw data is stored in chunks — the question
1008 /// every storage-form test asks, asked in one place.
1009 pub(crate) fn is_chunked(&self) -> bool {
1010 self.chunk_index_kind().is_some()
1011 }
1012
1013 /// Where this dataset's contiguous raw bytes live, or `None` when it has
1014 /// no contiguous storage to write into at all — a chunked dataset, a
1015 /// compact one (whose bytes *are* the layout message), or one whose block
1016 /// was never allocated.
1017 ///
1018 /// INVARIANT: every write of a contiguous dataset's raw bytes picks its
1019 /// destination here and reaches it through
1020 /// [`Hdf5Writer::write_contiguous_bytes`]. A site that read `data_addr`
1021 /// itself would write an externally-stored dataset's data into this file
1022 /// — at [`UNDEF_ADDR`], the far end of the address space — instead of into
1023 /// the files its header names, and would do the same to a virtual one,
1024 /// whose bytes are not this file's to write at all.
1025 ///
1026 /// Chunked storage is excluded through
1027 /// [`chunk_index_kind`](Self::chunk_index_kind) rather than by naming the
1028 /// index-carrying fields, so an index added to this struct cannot arrive
1029 /// here as contiguous storage: an implicit-indexed dataset reads
1030 /// `data_addr` as the base of its chunk grid, which as a contiguous
1031 /// destination would take a raw write meant for one chunk and lay it over
1032 /// the whole grid.
1033 fn contiguous_target(&self) -> Option<ContiguousTarget> {
1034 if self.is_chunked() || self.compact.is_some() {
1035 return None;
1036 }
1037 if self.virtual_storage.is_some() {
1038 return Some(ContiguousTarget::Virtual);
1039 }
1040 match &self.external {
1041 Some(ext) => Some(ContiguousTarget::External {
1042 files: ext.files.clone(),
1043 prefix: ext.prefix.expanded.clone(),
1044 }),
1045 None => {
1046 (self.data_addr != UNDEF_ADDR).then_some(ContiguousTarget::Local(self.data_addr))
1047 }
1048 }
1049 }
1050
1051 /// The one run of file bytes an implicitly indexed dataset's chunk grid
1052 /// is — its start and its length — or `None` when the dataset is indexed
1053 /// some other way or its space is not allocated yet.
1054 ///
1055 /// That index has no per-chunk structure to hold an address in: every
1056 /// chunk sits at `data_addr + linear_index * chunk_bytes` and the grid is
1057 /// allocated whole at create (`H5D__none_idx_get_addr`, H5Dnone.c). So the
1058 /// run is file space this writer allocated, and it is the *only* storage a
1059 /// chunk of such a dataset can occupy — the builder refuses external and
1060 /// virtual storage together with chunked storage, which is why
1061 /// [`allocated_storage_run`](Self::allocated_storage_run) can name it
1062 /// [`ContiguousTarget::Local`] and no chunk write can reach the other two.
1063 fn implicit_grid(&self) -> Option<(u64, u64)> {
1064 let imp = self.implicit.as_ref()?;
1065 (imp.data_addr != UNDEF_ADDR).then_some((imp.data_addr, imp.data_size))
1066 }
1067
1068 /// The run of raw storage this writer *allocated* for the dataset — the
1069 /// target to initialise it through and its size — or `None` when it
1070 /// allocated none.
1071 ///
1072 /// The two storage forms that are one run of bytes: a contiguous
1073 /// dataset's data block, and an implicitly indexed dataset's chunk grid.
1074 /// A compact dataset is excluded (its bytes are its layout message) and so
1075 /// is every other chunk index, whose chunks are placed one at a time.
1076 ///
1077 /// External storage is excluded because this writer does not allocate it:
1078 /// `H5D__alloc_storage` skips its whole body — the space reservation and
1079 /// the `H5D__init_storage` that would tile the fill value into it — for a
1080 /// dataset with an external file list or an empty extent, "we assume that
1081 /// external storage is already allocated by the caller, or at least will
1082 /// be before I/O is performed" (H5Dint.c:2270-2274). Measured under
1083 /// libhdf5 1.14.6 and 2.0.0: a user fill value, `H5D_FILL_TIME_ALLOC` and
1084 /// `H5D_ALLOC_TIME_EARLY` together leave the raw data file uncreated at
1085 /// `H5Dcreate2`, and a read before any write fails with "unable to open
1086 /// external raw data file" rather than reporting the fill.
1087 ///
1088 /// INVARIANT: only storage whose bytes this file owns is initialised as
1089 /// one run, so the allocate-time fill cannot reach the files an external
1090 /// file list names or the sources a virtual dataset maps.
1091 fn allocated_storage_run(&self) -> Option<(ContiguousTarget, u64)> {
1092 match self.implicit_grid() {
1093 Some((addr, size)) => Some((ContiguousTarget::Local(addr), size)),
1094 // Not a fallthrough for an unallocated implicit grid:
1095 // `contiguous_target` answers `None` for every chunked dataset.
1096 None => match self.contiguous_target() {
1097 Some(t @ ContiguousTarget::Local(_)) => Some((t, self.data_size)),
1098 _ => None,
1099 },
1100 }
1101 }
1102
1103 /// Whether this session wrote chunk data or changed the extent, so the
1104 /// dataset's index structures have to be re-flushed.
1105 fn storage_dirty(&self) -> bool {
1106 self.chunked.as_ref().is_some_and(|c| c.chunks_written > 0)
1107 || self
1108 .fixed_array
1109 .as_ref()
1110 .is_some_and(|f| f.chunks_written > 0)
1111 || self.btree_v2.as_ref().is_some_and(|b| b.chunks_written > 0)
1112 || self.btree_v1.as_ref().is_some_and(|b| b.chunks_written > 0)
1113 || self
1114 .single_chunk
1115 .as_ref()
1116 .is_some_and(|s| s.chunks_written > 0)
1117 || self.extent_dirty
1118 }
1119
1120 /// Whether a reopened dataset's on-disk object header no longer describes
1121 /// it.
1122 ///
1123 /// INVARIANT: every mutation of something `build_dataset_header` encodes
1124 /// must show up here. Finalize keeps the original header when this is
1125 /// false, so a change this misses is not deferred — it is discarded, with
1126 /// no error to say so. Attributes were the case that proved it: they are
1127 /// invisible to the chunk-write counters, so an attribute set on a
1128 /// reopened dataset vanished at close.
1129 fn header_stale(&self) -> bool {
1130 self.storage_dirty() || self.header_dirty
1131 }
1132
1133 /// The same question for the one thing the dataset itself cannot see: how
1134 /// many hard links resolve to it. That count lives in the header — an
1135 /// Object Reference Count message in a version-2 header, the `nlink`
1136 /// prefix field of a version-1 one — but it is a property of the file's
1137 /// link graph, so the caller supplies today's value.
1138 fn header_stale_with(&self, nlink: u32) -> bool {
1139 self.header_stale() || nlink != self.nlink_written
1140 }
1141
1142 /// Record that this dataset's on-disk object header was just written with
1143 /// `nlink` in it.
1144 ///
1145 /// INVARIANT: every write of a dataset object header passes through here.
1146 /// [`header_stale_with`](Self::header_stale_with) is the one authority for
1147 /// "does what is on disk still describe this dataset?", and it answers by
1148 /// comparing against [`nlink_written`](Self::nlink_written) — so a site
1149 /// that writes a header without saying so leaves that answer describing an
1150 /// older write. There are three writers: `finalize`, `finalize_for_swmr`
1151 /// and `write_dataset_header_inplace`. The last recorded nothing; it could
1152 /// not drift today only because a count it could write is a count that
1153 /// makes the header outgrow its block, which it refuses. That is a
1154 /// property of the reference-count message's size, not a rule anything
1155 /// states, and it is not what the field's definition rests on.
1156 fn header_written(&mut self, nlink: u32) {
1157 self.nlink_written = nlink;
1158 }
1159}
1160
1161/// Runtime metadata for a chunked dataset.
1162pub struct ChunkedDatasetInfo {
1163 /// Chunk dimension sizes.
1164 pub chunk_dims: Vec<u64>,
1165 /// Extensible array parameters.
1166 pub earray_params: EarrayParams,
1167 /// File offset of the EA header.
1168 pub ea_header_addr: u64,
1169 /// File offset of the EA index block.
1170 pub ea_iblk_addr: u64,
1171 /// In-memory copy of the EA header (for updating statistics).
1172 pub ea_header: ExtensibleArrayHeader,
1173 /// In-memory copy of the EA index block (for unfiltered datasets).
1174 pub ea_iblk: ExtensibleArrayIndexBlock,
1175 /// Number of chunks written so far.
1176 pub chunks_written: u64,
1177 /// Filtered index block (for compressed datasets).
1178 pub filt_iblk: Option<FilteredIndexBlock>,
1179 /// chunk_size_len for filtered entries.
1180 pub chunk_size_len: u8,
1181}
1182
1183/// Where a newly-created EA data block's address must be recorded.
1184enum DblkParent {
1185 /// Slot `index_block.dblk_addrs[idx]`.
1186 IndexBlock(usize),
1187 /// Slot `super_block.dblk_addrs[local_dblk]` of the super block at `sblk_addr`.
1188 SuperBlock {
1189 sblk_addr: u64,
1190 ndblks_in_sblk: usize,
1191 local_dblk: usize,
1192 },
1193}
1194
1195/// Which attribute list an attribute operation targets: the root group's,
1196/// a group's (by full path), or a dataset's (by writer index).
1197#[derive(Clone, Copy)]
1198pub enum AttrTarget<'a> {
1199 /// The root group's (file-level) attributes.
1200 Root,
1201 /// A group's attributes, by full path.
1202 Group(&'a str),
1203 /// A dataset's attributes, by writer index.
1204 Dataset(usize),
1205}
1206
1207/// Which chunk index a dataset uses.
1208///
1209/// The five above the line are what `H5D__layout_set_latest_indexing`
1210/// (H5Dlayout.c) picks between once the file format allows a version-4 data
1211/// layout message, in this precedence: a v2 B-tree for two or more unlimited
1212/// dimensions, an extensible array for exactly one, and — for a fixed shape —
1213/// the single-chunk index whenever exactly one chunk covers the whole
1214/// dataspace (`dims == max_dims == chunk_dims`, checked before either
1215/// alternative below and taken regardless of filter or allocation-time), else
1216/// the implicit index when nothing has to be recorded per chunk (no filter,
1217/// early allocation), else a fixed array. [`BtreeV1`](Self::BtreeV1) is not
1218/// one of them: it belongs to the version-3 layout message, and a file whose
1219/// superblock is older than version 2 can carry no other.
1220#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1221pub(crate) enum ChunkIndexKind {
1222 ExtensibleArray,
1223 FixedArray,
1224 BtreeV2,
1225 Implicit,
1226 SingleChunk,
1227 BtreeV1,
1228}
1229
1230/// A chunked dataset's grid geometry, snapshotted out of its slot.
1231///
1232/// The single owner of chunk-grid arithmetic: how many chunks span each
1233/// dimension, where a coordinate sits in the row-major order the array
1234/// indices record, and how many bytes one chunk holds.
1235struct ChunkGeometry {
1236 kind: ChunkIndexKind,
1237 dims: Vec<u64>,
1238 max_dims: Option<Vec<u64>>,
1239 chunk_dims: Vec<u64>,
1240 element_size: u64,
1241}
1242
1243impl ChunkGeometry {
1244 /// Unfiltered byte size of one whole chunk.
1245 fn chunk_bytes(&self) -> u64 {
1246 self.chunk_dims.iter().product::<u64>() * self.element_size
1247 }
1248
1249 /// Row-major position of `coords` in the chunk grid — the linear index an
1250 /// extensible or fixed array records the chunk under, computed against
1251 /// the maximum-extent grid by [`crate::io::chunk_grid::linear_index`].
1252 fn linear_index(&self, coords: &[u64]) -> IoResult<u64> {
1253 crate::io::chunk_grid::linear_index(
1254 &self.dims,
1255 self.max_dims.as_deref(),
1256 &self.chunk_dims,
1257 coords,
1258 )
1259 }
1260}
1261
1262/// The refusal every attribute mutation gets while SWMR streaming is
1263/// active, from the two owners of attribute-list change
1264/// ([`Hdf5Writer::set_attribute`] and `evict_attr`).
1265fn swmr_attr_error(name: &str) -> crate::io::IoError {
1266 crate::io::IoError::InvalidState(format!(
1267 "cannot add or modify attribute '{name}' during SWMR streaming: object \
1268 headers are frozen while readers stream, and a superseded variable-length \
1269 value's heap storage could never be reclaimed; set attributes before \
1270 start_swmr (libhdf5 forbids attribute changes during SWMR writes too)"
1271 ))
1272}
1273
1274/// Where an attribute arriving at [`Hdf5Writer::insert_attribute`] came from.
1275///
1276/// The variable-length setters have to evict before they allocate — the
1277/// free-before-alloc order — so by the time the replacement is inserted the
1278/// list no longer holds the entry it replaces, and the ordinary "already
1279/// present, so keep its index" test cannot see it. `H5A__attr_write` does not
1280/// create the attribute again, so the index travels with the eviction rather
1281/// than being stamped afresh; without it a rewritten attribute takes the set's
1282/// running maximum and moves to the end of the creation order.
1283#[derive(Debug, Clone, Copy)]
1284enum AttrOrigin {
1285 /// A new attribute, which takes the set's next creation index.
1286 Created,
1287 /// A value written over an attribute this writer has just evicted, which
1288 /// keeps that attribute's creation index — `None` when the object tracks
1289 /// no order, and so records none. An eviction that found nothing to remove
1290 /// answers `Created`: what follows it is a create like any other.
1291 Rewritten(Option<u16>),
1292}
1293use AttrOrigin::{Created, Rewritten};
1294
1295/// Take an object's attributes into the append session, or refuse the reopen.
1296///
1297/// Append mode rebuilds every object header it touches out of the attributes
1298/// read from it, so what this returns is what the object will still have when
1299/// the session finalizes. An attribute set that could not be read whole —
1300/// `ObjectAttributes::into_complete` refuses it — would come back as the part
1301/// that did read, silently deleting the rest.
1302///
1303/// Left to surface at `finalize`, that failure would land after this session's
1304/// chunk data and indices had already been written past the allocation point
1305/// the superblock still records, leaving a file libhdf5 reads as truncated.
1306/// Refusing the open leaves it untouched.
1307///
1308/// Size is no longer a reason to refuse: an attribute too large for a header
1309/// message goes back out through dense storage, the form libhdf5 read it from.
1310///
1311/// The set comes back in creation-index order, which is the order the registry
1312/// holds attributes in for an object made in this session too. A dense set is
1313/// read through the name index, so the order it arrives in is the order a hash
1314/// walk took; sorting here is what makes "the list is in creation order" true
1315/// of a reopened object as well, without any later stage having to know which
1316/// storage form the attributes came out of. Attributes of an untracked object
1317/// carry no index and keep the order they were read in.
1318fn take_reopened_attributes(
1319 attrs: crate::io::reader::ObjectAttributes,
1320 owner: &str,
1321) -> IoResult<Vec<AttributeEntry>> {
1322 let mut attrs = attrs.into_complete(owner)?;
1323 attrs.sort_by_key(|a| a.creation_index());
1324 Ok(attrs)
1325}
1326
1327/// The creation-order policy an on-disk object header declares — the single
1328/// owner of the recovery rule, used for the root group, every reopened group
1329/// and (through its attribute half) every reopened dataset.
1330///
1331/// The two halves come from two different places, and reading one for both is
1332/// how a file that sets only one of them came back with both or neither:
1333///
1334/// * links — the `Link Info` message's flag bits, which is what
1335/// `H5Pget_link_creation_order` reads (`H5G__get_create_plist`). A group
1336/// with no such message (or one this crate cannot decode) tracks nothing;
1337/// so does every dataset, which has no links to order.
1338/// * attributes — the object header's own flag bits, which is what
1339/// `H5Pget_attr_creation_order` reads (`H5Pocpl.c`). The `Attribute Info`
1340/// message carries the same two bits, but the header is the authority
1341/// libhdf5 consults, and it is present even when the object has no
1342/// attributes yet.
1343fn recover_track_order(
1344 header: &crate::format::object_header::ObjectHeader,
1345 ctx: &FormatContext,
1346) -> TrackOrder {
1347 let links = header
1348 .messages
1349 .iter()
1350 .find(|m| m.msg_type == crate::format::messages::MSG_LINK_INFO)
1351 .and_then(|m| LinkInfoMessage::decode(&m.data, ctx).ok())
1352 .map(|(info, _)| info.creation_order())
1353 .unwrap_or_default();
1354 TrackOrder {
1355 links,
1356 attrs: header.attribute_creation_order(),
1357 }
1358}
1359
1360/// `H5O_touch_oh` (H5Oint.c:1273): put an object's tracked times where its
1361/// header version keeps them.
1362///
1363/// INVARIANT: every object header this writer builds passes its times through
1364/// here. The version decides the storage and nothing else does — a caller that
1365/// set `ObjectHeader::times` itself would hand a version-1 encode a prefix
1366/// field that version has no room for, and one that added the message itself
1367/// would put a second copy in a version-2 header.
1368///
1369/// `force` is upstream's own parameter, and it is what splits datasets from
1370/// everything else: it creates the version-1 `H5O_MTIME_NEW` message when the
1371/// header has none, and only `H5D__update_oh_info` passes it true
1372/// (H5Dint.c:1022-1026). Every other caller passes false and so creates no
1373/// message at all, which is why a version-1 group or committed datatype
1374/// records no time even when it is tracking them. A version-2 header keeps all
1375/// four times in its prefix whatever `force` says.
1376fn touch_oh(
1377 header: &mut ObjectHeader,
1378 format: ObjectFormat,
1379 times: Option<ObjectTimes>,
1380 force: bool,
1381) {
1382 let Some(times) = touched_times(times) else {
1383 return;
1384 };
1385 match format {
1386 ObjectFormat::Modern => header.times = Some(times),
1387 ObjectFormat::Legacy if force => header.add_message(
1388 crate::format::messages::MSG_MOD_TIME,
1389 0x00,
1390 ModificationTime(times.change).encode(),
1391 ),
1392 ObjectFormat::Legacy => {}
1393 }
1394}
1395
1396/// The times a header being (re)written carries, given what the object had.
1397///
1398/// Every object header this writer emits is one it is writing *now*, which is
1399/// what `H5O_touch_oh` is called for: an object that stores times gets its
1400/// access and change time moved to now, and one that does not store them stays
1401/// that way — the flag belongs to the object's creation property list, and a
1402/// rewrite is not a creation.
1403fn touched_times(times: Option<ObjectTimes>) -> Option<ObjectTimes> {
1404 times.map(|t| t.touched(now_seconds()))
1405}
1406
1407/// Seconds since the epoch, as an object header stores them (`H5_now`).
1408///
1409/// Saturates rather than wrapping: the field is a 32-bit count, and a clock
1410/// past 2106 is better reported as the largest time the format can express
1411/// than as a time in 1970. A clock before the epoch yields 0, which is what
1412/// libhdf5 writes for "no time recorded".
1413fn now_seconds() -> u32 {
1414 std::time::SystemTime::now()
1415 .duration_since(std::time::UNIX_EPOCH)
1416 .map_or(0, |d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX))
1417}
1418
1419/// The dense storage an on-disk object header names: the fractal heap and the
1420/// indices its `Attribute Info` and `Link Info` messages point at.
1421///
1422/// A rewrite of that header lays fresh storage out and stops naming this, so
1423/// what this returns is exactly what the rewrite supersedes and must free.
1424/// Compact storage names no heap and yields `None` — there is nothing to free
1425/// and nothing that could be freed twice.
1426fn superseded_dense(
1427 header: &crate::format::object_header::ObjectHeader,
1428 ctx: &FormatContext,
1429) -> (Option<AttributeInfoMessage>, Option<LinkInfoMessage>) {
1430 let decode = |msg_type: u8| {
1431 header
1432 .messages
1433 .iter()
1434 .find(|m| m.msg_type == msg_type)
1435 .map(|m| m.data.as_slice())
1436 };
1437 let attrs = decode(crate::format::messages::MSG_ATTR_INFO)
1438 .and_then(|d| AttributeInfoMessage::decode(d, ctx).ok())
1439 .map(|(info, _)| info)
1440 .filter(|info| info.is_dense());
1441 let links = decode(crate::format::messages::MSG_LINK_INFO)
1442 .and_then(|d| LinkInfoMessage::decode(d, ctx).ok())
1443 .map(|(info, _)| info)
1444 .filter(|info| info.is_dense());
1445 (attrs, links)
1446}
1447
1448/// One collection block with free space that a later vlen insert may
1449/// fill — an entry in the writer's CWFS list (libhdf5 `f->shared->cwfs`).
1450struct CwfsEntry {
1451 /// Block address of the collection.
1452 addr: u64,
1453 /// Declared block size; never changes after allocation.
1454 size: usize,
1455 /// Bytes its free-space marker owns, per
1456 /// [`GlobalHeapCollection::free_space_at`](crate::format::global_heap::GlobalHeapCollection::free_space_at).
1457 free: usize,
1458}
1459
1460/// Maximum CWFS entries tracked — libhdf5's `H5HG_NCWFS` (H5HGpkg.h).
1461const H5HG_NCWFS: usize = 16;
1462
1463/// Record a collection with `free` bytes in the CWFS list: update its
1464/// entry if present, append while the list is short, and otherwise
1465/// replace the entry with the least free space when this one has more —
1466/// the retention rule of libhdf5's `H5HG_insert`.
1467fn cwfs_note(cwfs: &mut Vec<CwfsEntry>, addr: u64, size: usize, free: usize) {
1468 if let Some(p) = cwfs.iter().position(|e| e.addr == addr) {
1469 cwfs[p].free = free;
1470 return;
1471 }
1472 if cwfs.len() < H5HG_NCWFS {
1473 cwfs.insert(0, CwfsEntry { addr, size, free });
1474 return;
1475 }
1476 if let Some(p) = (0..cwfs.len()).min_by_key(|&p| cwfs[p].free) {
1477 if free > cwfs[p].free {
1478 cwfs[p] = CwfsEntry { addr, size, free };
1479 }
1480 }
1481}
1482
1483/// The uniform rejection for `delete_dataset` / `delete_group` while SWMR
1484/// streaming is active: deleting frees the object's blocks, and a live
1485/// reader may hold any of their addresses.
1486fn swmr_delete_error(name: &str) -> crate::io::IoError {
1487 crate::io::IoError::InvalidState(format!(
1488 "cannot delete '{name}' during SWMR streaming: a reader may hold the \
1489 object's header and storage addresses (libhdf5 forbids link deletion \
1490 during SWMR writes too)"
1491 ))
1492}
1493
1494/// Whether the chunk at grid `coords` lies entirely at or beyond `extent` in
1495/// some dimension — no element of it would survive a shrink to that extent.
1496fn chunk_outside_extent(coords: &[u64], chunk_dims: &[u64], extent: &[u64]) -> bool {
1497 coords
1498 .iter()
1499 .zip(chunk_dims)
1500 .zip(extent)
1501 .any(|((&c, &cd), &e)| c.saturating_mul(cd) >= e)
1502}
1503
1504/// Whether the chunk at grid `coords` keeps elements under `extent` but
1505/// extends past it in some dimension — a shrink must refill its
1506/// out-of-extent region with the fill value.
1507fn chunk_straddles_extent(coords: &[u64], chunk_dims: &[u64], extent: &[u64]) -> bool {
1508 !chunk_outside_extent(coords, chunk_dims, extent)
1509 && coords
1510 .iter()
1511 .zip(chunk_dims)
1512 .zip(extent)
1513 .any(|((&c, &cd), &e)| (c + 1).saturating_mul(cd) > e)
1514}
1515
1516/// Overwrite, in `data` (one whole chunk, unfiltered, row-major), every
1517/// element at or beyond `extent` with the matching bytes of `fill` — a
1518/// same-sized buffer tiled with the fill value. The caller guarantees the
1519/// chunk at `coords` straddles `extent`, so every dimension keeps at least
1520/// one element. Returns the replaced bytes, so a vlen dataset's dead
1521/// heap references can be released rather than stranded.
1522fn refill_chunk_beyond_extent(
1523 data: &mut [u8],
1524 fill: &[u8],
1525 coords: &[u64],
1526 chunk_dims: &[u64],
1527 extent: &[u64],
1528 element_size: usize,
1529) -> Vec<u8> {
1530 let ndims = chunk_dims.len();
1531 let keep: Vec<usize> = (0..ndims)
1532 .map(|d| {
1533 let origin = coords[d] * chunk_dims[d];
1534 chunk_dims[d].min(extent[d].saturating_sub(origin)) as usize
1535 })
1536 .collect();
1537 // Row-major walk: for every row (all dimensions but the last),
1538 // overwrite the whole row when its prefix is outside the keep box,
1539 // else only the row's out-of-extent tail.
1540 let row_elems = chunk_dims[ndims - 1] as usize;
1541 let keep_last = keep[ndims - 1];
1542 let nrows: u64 = chunk_dims[..ndims - 1].iter().product();
1543 let mut replaced = Vec::new();
1544 for r in 0..nrows {
1545 let mut rem = r;
1546 let mut in_keep = true;
1547 for d in (0..ndims - 1).rev() {
1548 let c = rem % chunk_dims[d];
1549 rem /= chunk_dims[d];
1550 if c as usize >= keep[d] {
1551 in_keep = false;
1552 }
1553 }
1554 let start = if in_keep { keep_last } else { 0 };
1555 if start == row_elems {
1556 continue;
1557 }
1558 let a = (r as usize * row_elems + start) * element_size;
1559 let b = (r as usize + 1) * row_elems * element_size;
1560 replaced.extend_from_slice(&data[a..b]);
1561 data[a..b].copy_from_slice(&fill[a..b]);
1562 }
1563 replaced
1564}
1565
1566/// Validate caller-supplied chunk geometry at dataset definition, the rule
1567/// libhdf5 applies in `H5D__chunk_construct` (H5Dchunk.c): the chunk rank
1568/// must match the dataspace rank, no chunk dimension may be zero, and a
1569/// chunk dimension may not exceed a fixed maximum dimension — except in a
1570/// dimension whose current size is zero, which libhdf5 exempts.
1571fn validate_chunk_geometry(dims: &[u64], max_dims: &[u64], chunk_dims: &[u64]) -> IoResult<()> {
1572 let ndims = dims.len();
1573 if chunk_dims.len() != ndims {
1574 return Err(crate::io::IoError::InvalidState(format!(
1575 "chunk shape has {} dimensions but the dataspace has {}",
1576 chunk_dims.len(),
1577 ndims
1578 )));
1579 }
1580 if max_dims.len() != ndims {
1581 return Err(crate::io::IoError::InvalidState(format!(
1582 "maximum shape has {} dimensions but the dataspace has {}",
1583 max_dims.len(),
1584 ndims
1585 )));
1586 }
1587 for d in 0..ndims {
1588 if chunk_dims[d] == 0 {
1589 return Err(crate::io::IoError::InvalidState(format!(
1590 "chunk dimension {d} is zero"
1591 )));
1592 }
1593 if dims[d] != 0 && max_dims[d] != u64::MAX && max_dims[d] < chunk_dims[d] {
1594 return Err(crate::io::IoError::InvalidState(format!(
1595 "chunk dimension {} is {} but the maximum dimension size is {}",
1596 d, chunk_dims[d], max_dims[d]
1597 )));
1598 }
1599 }
1600 Ok(())
1601}
1602
1603/// An extensible-array index requires at most one unlimited dimension —
1604/// `H5D__chunk_construct` (H5Dchunk.c) only selects this index for exactly
1605/// one — at any position: `chunk_grid::linear_index` seeds the unlimited
1606/// dimension into the slot no down-chunks multiplier touches, the same
1607/// address libhdf5 reaches by swizzling it to the slowest position
1608/// (`H5VM_swizzle_coords`, H5Dearray.c). Two or more unlimited dimensions
1609/// have no finite grid at all; that shape needs a v2 B-tree index instead.
1610fn ensure_at_most_one_unlimited(max_dims: &[u64]) -> IoResult<()> {
1611 let unlimited: Vec<usize> = max_dims
1612 .iter()
1613 .enumerate()
1614 .filter(|&(_, &m)| m == u64::MAX)
1615 .map(|(d, _)| d)
1616 .collect();
1617 if unlimited.len() > 1 {
1618 return Err(crate::io::IoError::InvalidState(format!(
1619 "an extensible-array index supports at most one unlimited dimension, \
1620 but dimensions {unlimited:?} are all unlimited; a v2 B-tree index \
1621 handles two or more"
1622 )));
1623 }
1624 Ok(())
1625}
1626
1627/// Reject strings the dataset's declared character set cannot label.
1628///
1629/// A Rust `&str` is always UTF-8, so only an ASCII declaration (charset 0)
1630/// can be violated. libhdf5 stores the bytes unvalidated — its vlen write
1631/// path has no cset check anywhere — which mislabels them for every reader
1632/// that trusts the declaration (h5py raises on the same mismatch).
1633fn ensure_vlen_charset(charset: u8, strings: &[&str]) -> IoResult<()> {
1634 if charset == 0 {
1635 if let Some((i, s)) = strings.iter().enumerate().find(|(_, s)| !s.is_ascii()) {
1636 return Err(crate::io::IoError::InvalidState(format!(
1637 "string {i} ({s:?}) is not ASCII, but the dataset's character set is"
1638 )));
1639 }
1640 }
1641 Ok(())
1642}
1643
1644/// Runtime metadata for a fixed-array-indexed chunked dataset.
1645pub struct FixedArrayDatasetInfo {
1646 /// Chunk dimension sizes.
1647 pub chunk_dims: Vec<u64>,
1648 /// File offset of the FA header.
1649 pub fa_header_addr: u64,
1650 /// File offset of the FA data block.
1651 pub fa_dblk_addr: u64,
1652 /// In-memory copy of the FA header.
1653 pub fa_header: FixedArrayHeader,
1654 /// In-memory copy of the FA data block.
1655 pub fa_dblk: FixedArrayDataBlock,
1656 /// Number of chunks written so far.
1657 pub chunks_written: u64,
1658}
1659
1660/// Runtime metadata for an implicitly indexed chunked dataset — the index
1661/// that is no structure at all (`H5Dnone.c`).
1662///
1663/// Every chunk of the maximum-extent grid is allocated at create in one
1664/// contiguous run, in the row-major order [`crate::io::chunk_grid`] defines,
1665/// so a chunk's address is `data_addr + linear_index * chunk_bytes` and
1666/// nothing has to be recorded when one is written. libhdf5 picks this index
1667/// only when that arithmetic is total: no filter (every chunk is exactly
1668/// `chunk_bytes` long), no unlimited dimension (the run has a finite length),
1669/// and early allocation (the run exists before any write).
1670pub struct ImplicitDatasetInfo {
1671 /// Chunk dimension sizes.
1672 pub chunk_dims: Vec<u64>,
1673 /// File offset of the first chunk — the layout message's index address.
1674 pub data_addr: u64,
1675 /// Byte length of the whole chunk run: `nchunks * chunk_bytes`.
1676 pub data_size: u64,
1677}
1678
1679/// Runtime metadata for a single-chunk indexed dataset (`H5Dsingle.c`): a
1680/// fixed dataspace exactly one chunk wide in every dimension
1681/// (`dims == max_dims == chunk_dims`), so there is exactly one chunk and its
1682/// address — and, when filtered, its stored size and filter mask — are held
1683/// directly in the layout message rather than in any index structure.
1684///
1685/// libhdf5 selects this index ahead of the implicit and fixed-array indexes
1686/// whenever the shape qualifies, whether or not the dataset is filtered or
1687/// early-allocated (`H5D__layout_set_latest_indexing`, H5Dlayout.c).
1688pub struct SingleChunkDatasetInfo {
1689 /// Chunk dimension sizes (equal to the dataspace's `dims`).
1690 pub chunk_dims: Vec<u64>,
1691 /// File offset of the chunk, [`UNDEF_ADDR`] until the chunk is written
1692 /// (or immediately, for an unfiltered dataset created with early
1693 /// allocation).
1694 pub data_addr: u64,
1695 /// The chunk's full unfiltered byte length — `chunk_dims.product() *
1696 /// element_size`, fixed for the dataset's lifetime.
1697 pub data_size: u64,
1698 /// Stored (on-disk) byte length: equal to `data_size` when the dataset
1699 /// carries no filter pipeline; the filtered length once the chunk has
1700 /// been written, 0 before then.
1701 pub nbytes: u64,
1702 /// Filter mask recorded for the stored chunk (bit *i* set means filter
1703 /// *i* was skipped); meaningful only when the dataset is filtered.
1704 pub filter_mask: u32,
1705 /// Chunks written this session (0 or 1) — `storage_dirty`'s signal that
1706 /// the layout message's address/size/mask fields must be re-flushed.
1707 pub chunks_written: u64,
1708 /// Whether this dataset was created with early allocation
1709 /// (`H5D_ALLOC_TIME_EARLY`) — distinct from `data_addr` being defined,
1710 /// which also becomes true the moment an incrementally allocated
1711 /// dataset's one chunk is written; `build_dataset_header` needs this to
1712 /// tell the two apart when it reports the fill-value message's
1713 /// allocation time. Only ever set for an unfiltered dataset: a filtered
1714 /// chunk's stored length is not known until it is compressed, so there
1715 /// is nothing to allocate ahead of that write regardless of alloc time
1716 /// (the same gap `create_fixed_array_dataset_with_pipeline` has).
1717 pub early_alloc: bool,
1718}
1719
1720/// One chunk as the version-1 B-tree records it — the key libhdf5 stores
1721/// (`H5D_btree_key_t`) plus the address it keys.
1722pub struct BtreeV1ChunkRecord {
1723 /// Grid position of the chunk. The key's element offsets are derived from
1724 /// it at encode time (`scaled * chunk_dim`), so this is the one place the
1725 /// position is stored and the sort order is over these coordinates.
1726 pub scaled: Vec<u64>,
1727 /// File offset of the chunk's bytes.
1728 pub address: u64,
1729 /// Stored byte length — the filtered length when the dataset is filtered,
1730 /// the full chunk otherwise. `u32` because the key's field is.
1731 pub nbytes: u32,
1732 /// Filter mask: bit `i` set means filter `i` was skipped for this chunk.
1733 pub filter_mask: u32,
1734}
1735
1736/// Runtime metadata for a chunked dataset indexed by a version-1 B-tree —
1737/// the classic-format chunk index (`H5Dbtree.c`), and the only one a
1738/// version-0/1 superblock file can carry.
1739pub struct BtreeV1DatasetInfo {
1740 /// Chunk dimension sizes.
1741 pub chunk_dims: Vec<u64>,
1742 /// Maximum dimensions (u64::MAX = unlimited).
1743 pub max_dims: Vec<u64>,
1744 /// The file's v1-B-tree "K" ranks. Every node's width is derived from
1745 /// them, and they are recorded only in the superblock this file was
1746 /// opened with — so they are carried rather than re-derived.
1747 pub config: BTreeV1Config,
1748 /// The chunks, in key order (`scaled` ascending, lexicographically).
1749 pub records: Vec<BtreeV1ChunkRecord>,
1750 /// Pool of node-size blocks holding the tree's nodes, on the same terms
1751 /// as [`Bt2DatasetInfo::node_addrs`]: a flush re-serializes the whole
1752 /// bulk-loaded tree over them and allocates only the shortfall, so no
1753 /// flush can orphan a block it replaced.
1754 pub node_addrs: Vec<u64>,
1755 /// Address of the tree's root node — what the version-3 data layout
1756 /// message carries. `UNDEF_ADDR` until a flush puts a node in the file,
1757 /// which is the state libhdf5 leaves a chunked dataset in until its first
1758 /// chunk is written.
1759 pub root_addr: u64,
1760 /// Number of chunks written so far.
1761 pub chunks_written: u64,
1762}
1763
1764impl BtreeV1DatasetInfo {
1765 /// The chunk shape a key's offsets are scaled by: the chunk dimensions
1766 /// with the element size appended, which is also what the layout message
1767 /// stores.
1768 fn key_dims(&self, element_size: u64) -> Vec<u64> {
1769 let mut dims = self.chunk_dims.clone();
1770 dims.push(element_size);
1771 dims
1772 }
1773
1774 /// Bulk-load the tree this index's records describe.
1775 fn build_tree(&self, element_size: u64, sizeof_addr: usize) -> ChunkBTreeV1Tree {
1776 let dims = self.key_dims(element_size);
1777 let entries: Vec<(ChunkKey, u64)> = self
1778 .records
1779 .iter()
1780 .map(|r| {
1781 (
1782 ChunkKey::for_chunk(&r.scaled, &dims, r.nbytes, r.filter_mask),
1783 r.address,
1784 )
1785 })
1786 .collect();
1787 // The right boundary closes the tree past its greatest key, which is
1788 // the last record's — the records are kept in key order.
1789 let last = self
1790 .records
1791 .last()
1792 .map_or_else(|| vec![0; self.chunk_dims.len()], |r| r.scaled.clone());
1793 ChunkBTreeV1Tree::build(
1794 &entries,
1795 ChunkKey::right_bound(&last, &dims),
1796 &self.config,
1797 sizeof_addr,
1798 )
1799 }
1800
1801 /// Where `scaled` sits in [`records`](Self::records): `Ok` at its record,
1802 /// `Err` at the position one would be inserted at.
1803 fn position(&self, scaled: &[u64]) -> Result<usize, usize> {
1804 self.records
1805 .binary_search_by(|r| r.scaled.as_slice().cmp(scaled))
1806 }
1807}
1808
1809/// Runtime metadata for a B-tree v2 indexed chunked dataset.
1810pub struct Bt2DatasetInfo {
1811 /// Chunk dimension sizes.
1812 pub chunk_dims: Vec<u64>,
1813 /// File offset of the BT2 header.
1814 pub bt2_header_addr: u64,
1815 /// Pool of node-size blocks (the index's
1816 /// [`node_size`](Bt2ChunkIndex::node_size) bytes each) holding the tree's
1817 /// nodes, in the order [`Bt2Tree::encode`] emits them.
1818 ///
1819 /// The single owner of the tree's node addresses: a flush re-serializes the
1820 /// whole tree over these blocks and allocates only the shortfall, so no
1821 /// flush can orphan a block it replaced. Every node is the same size, so a
1822 /// block stays usable however the tree reshapes.
1823 ///
1824 /// The pool holds exactly one block per node after every flush, in both
1825 /// directions: a taller tree allocates the shortfall, a smaller one frees
1826 /// the surplus. Nothing here depends on the record count only ever rising,
1827 /// so a record-removal path can be added to [`Bt2ChunkIndex`] without the
1828 /// blocks it drops going unreachable.
1829 pub node_addrs: Vec<u64>,
1830 /// In-memory chunk index.
1831 pub index: Bt2ChunkIndex,
1832 /// Number of chunks written so far.
1833 pub chunks_written: u64,
1834}
1835
1836/// Metadata for a group being written.
1837pub struct GroupInfo {
1838 /// Full path of this group (e.g. "/detector" or "/detector/raw").
1839 pub name: String,
1840 /// Index of the parent group in the groups vec, or None for root-level groups.
1841 pub parent: Option<usize>,
1842 /// Indices of child datasets (into `datasets` vec).
1843 pub child_datasets: Vec<usize>,
1844 /// Indices of child groups (into `groups` vec).
1845 pub child_groups: Vec<usize>,
1846 /// File offset of this group's object header (set during finalize).
1847 pub obj_header_addr: u64,
1848 /// File offset of the on-disk header a reopen found for this group, so
1849 /// finalize can free the block it supersedes.
1850 pub obj_header_written_addr: Option<u64>,
1851 /// Encoded size of that on-disk header (first block).
1852 /// Every block the object's on-disk header occupies, chunk 0 first, or
1853 /// empty when it has none yet. A rewrite keeps chunk 0's block — its
1854 /// address is what every reference to the object holds — and frees the
1855 /// rest, so a continuation block left behind is space no free-space
1856 /// manager records.
1857 pub obj_header_blocks: crate::io::object_header_io::HeaderBlocks,
1858 /// Soft-deleted: excluded from finalize output.
1859 pub deleted: bool,
1860 /// Attributes attached to this group (e.g. NeXus `NX_class`).
1861 pub attributes: Vec<AttributeEntry>,
1862 /// When the link naming this group was created, on the writer's single
1863 /// monotonic sequence. Groups, datasets and hard links share it, so a
1864 /// parent can order its links the way they were actually made.
1865 pub creation_seq: u64,
1866 /// How this group records creation order for its links and, separately,
1867 /// for its attributes. Creation-order tracking is a property of the
1868 /// object's creation property list in libhdf5, so it is captured here
1869 /// when the group is created rather than read from the writer at
1870 /// finalize: a later change of policy must not rewrite an object already
1871 /// made.
1872 pub track_order: TrackOrder,
1873 /// The times this group tracks, on the same terms as
1874 /// [`DatasetInfo::times`]. A version-1 group header records none of them:
1875 /// nothing calls `H5O_touch_oh` with `force` for a group, so the message a
1876 /// version-1 dataset gets is never created for one.
1877 pub times: Option<ObjectTimes>,
1878}
1879
1880/// One object's creation-order policy, with the two subsystems libhdf5 keeps
1881/// apart kept apart here too.
1882///
1883/// `H5Pset_link_creation_order` and `H5Pset_attr_creation_order` are separate
1884/// calls reading back out of separate places on disk — the Link Info message
1885/// and the object header's own flag bits — and a file may set either alone.
1886/// Carrying them as one flag made a reopen give a one-of-two file both or
1887/// neither.
1888#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1889pub struct TrackOrder {
1890 /// Creation order of the links this group holds. Meaningless for a
1891 /// dataset, which is why `DatasetInfo` keeps only the attribute half.
1892 pub links: CreationOrder,
1893 /// Creation order of the attributes attached to this object.
1894 pub attrs: CreationOrder,
1895}
1896
1897impl TrackOrder {
1898 /// The policy the crate's single `track_order` knob selects: both
1899 /// subsystems tracked *and* indexed, or neither — the pair h5py's
1900 /// `File(track_order=True)` writes.
1901 pub fn uniform(track: bool) -> Self {
1902 let order = if track {
1903 CreationOrder::Indexed
1904 } else {
1905 CreationOrder::Untracked
1906 };
1907 Self {
1908 links: order,
1909 attrs: order,
1910 }
1911 }
1912}
1913
1914/// The object a [`HardLink`] resolves to.
1915#[derive(Clone, Copy)]
1916pub enum HardLinkTarget {
1917 /// Index into the writer's `datasets` vec.
1918 Dataset(usize),
1919 /// Index into the writer's `groups` vec.
1920 Group(usize),
1921}
1922
1923/// A user-created hard link: an additional name, in some group, for an
1924/// object that already exists under its own name.
1925///
1926/// The HDF5 file format makes every group entry a `name -> object header
1927/// address` mapping, so a hard link is just a second such entry pointing at
1928/// an already-written object. No data is copied.
1929#[derive(Clone)]
1930pub struct HardLink {
1931 /// Parent group index (`None` = the root group).
1932 pub parent: Option<usize>,
1933 /// Leaf name of the link within the parent group.
1934 pub name: String,
1935 /// Object this link resolves to.
1936 pub target: HardLinkTarget,
1937 /// When this link was created; see [`GroupInfo::creation_seq`].
1938 pub creation_seq: u64,
1939}
1940
1941/// A user-created symbolic link: a name in a group whose value is a path
1942/// rather than an object header address.
1943///
1944/// A soft link holds a path within this file; an external link holds a file
1945/// name and a path within that file. Neither names an object this writer
1946/// owns, so — unlike [`HardLink`] — nothing about it is resolved: the link is
1947/// stored as written and answered at traversal time, exactly as `H5Lcreate_soft`
1948/// and `H5Lcreate_external` store theirs.
1949#[derive(Clone)]
1950pub struct SymbolicLink {
1951 /// Parent group index (`None` = the root group).
1952 pub parent: Option<usize>,
1953 /// Leaf name of the link within the parent group.
1954 pub name: String,
1955 /// The path (and, for an external link, the file) this link names.
1956 pub target: LinkTarget,
1957 /// When this link was created; see [`GroupInfo::creation_seq`].
1958 pub creation_seq: u64,
1959}
1960
1961/// A committed (named) datatype: an object header holding one datatype
1962/// message and nothing else, reached by a link like any other object.
1963///
1964/// `H5Tcommit2` makes the type an object in its own right so several datasets
1965/// can declare they share it; each of those datasets then stores a pointer to
1966/// this object header in place of its own datatype message. The object's
1967/// reference count is therefore the links naming it *plus* the datasets
1968/// sharing it — `H5O__shared_link_adj` counts a share as a link — and an
1969/// object no link and no dataset reaches is not written at all.
1970#[derive(Clone)]
1971pub struct CommittedDatatype {
1972 /// Full path with no leading `/`, the form dataset names take.
1973 pub name: String,
1974 /// Parent group index (`None` = the root group).
1975 pub parent: Option<usize>,
1976 /// The committed type.
1977 pub datatype: DatatypeMessage,
1978 /// When the link naming it was created; see [`GroupInfo::creation_seq`].
1979 pub creation_seq: u64,
1980 /// The times it tracks, on the same terms as [`DatasetInfo::times`]. A
1981 /// version-1 committed datatype header records none of them, for the same
1982 /// reason a version-1 group's does not.
1983 pub times: Option<ObjectTimes>,
1984 /// File offset of its object header (set during finalize).
1985 pub obj_header_addr: u64,
1986}
1987
1988/// Where the object header a dataset's shared datatype pointer must name
1989/// comes from.
1990///
1991/// A dataset built on a committed type stores no datatype message: it stores
1992/// the address of the type's object header. Only the address matters at
1993/// encode time, but it is knowable at two different moments — a type this
1994/// session commits has no address until finalize lays the file out, while one
1995/// a reopen found is already at an address this session will not move. Naming
1996/// both here keeps [`build_dataset_header`](Hdf5Writer::build_dataset_header)
1997/// the one place that turns a share into a pointer, whichever way the share
1998/// arrived.
1999#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2000pub enum CommittedTypeRef {
2001 /// A type committed in this session, by its index in
2002 /// [`committed_datatypes`](Hdf5Writer::committed_datatypes); its address
2003 /// is read from that registry once finalize has stamped one.
2004 Session(usize),
2005 /// A committed datatype a reopen kept by its bytes, at the object header
2006 /// address it already occupies.
2007 Preserved(u64),
2008}
2009
2010/// A link a reopened file already held that this writer cannot express.
2011///
2012/// Soft, external and user-defined links have no creation, retarget or delete
2013/// operation here — only hard links do — so a header rewrite that emits what
2014/// the registry models would erase them. Their encoded `Link` message rides
2015/// along instead and is written back byte for byte, which preserves every
2016/// field (name character set, creation order, the link value) without this
2017/// writer having to model any of them.
2018///
2019/// A *hard* link is preserved the same way when the object it names is one
2020/// the reopen could not model: writing the link back unchanged leaves that
2021/// object's header exactly where it is, which is the only way the rewrite can
2022/// keep what it cannot rebuild.
2023#[derive(Clone)]
2024pub struct PreservedLink {
2025 /// Parent group index (`None` = the root group).
2026 pub parent: Option<usize>,
2027 /// Leaf name of the link within the parent group.
2028 pub name: String,
2029 /// The link's class, decoded once at collection so listings can report
2030 /// it. Never the source of what gets written — `encoded` is.
2031 pub class: crate::io::reader::LinkClass,
2032 /// The decoded message, for the questions the emitters ask of every link
2033 /// (does it fit a symbol table entry, what does it name). Never the source
2034 /// of what gets written — `encoded` is.
2035 pub link: LinkMessage,
2036 /// The encoded `Link` message body, exactly as read from the file. The
2037 /// one byte range the rewrite touches is the creation order, which
2038 /// `group_links` renumbers together with the modelled links.
2039 pub encoded: Vec<u8>,
2040 /// When this link was created; see [`GroupInfo::creation_seq`].
2041 pub creation_seq: u64,
2042 /// Why the object this link names could not be modelled, for the callers
2043 /// that ask for it by name. `None` when the link's own class — not its
2044 /// target — is what this writer cannot express.
2045 pub reason: Option<String>,
2046 /// What the object this link names is, when the walk could tell. A
2047 /// listing asks this; `reason` is prose for the caller that asks why.
2048 pub kind: PreservedKind,
2049}
2050
2051/// One link of a group as the close emits it, in both forms the emitters
2052/// ask for: the message for what it says, the bytes for what gets written.
2053/// A modelled link's bytes are its encoding; a preserved link's are the ones
2054/// the reopen read, with only the creation order restamped.
2055pub(crate) struct GroupLink {
2056 pub(crate) link: LinkMessage,
2057 pub(crate) encoded: Vec<u8>,
2058 /// Carried through by its bytes from a reopen, so it pins the group to
2059 /// compact storage.
2060 pub(crate) preserved: bool,
2061}
2062
2063/// Every link a reopen walk met, split by what the writer can do with it.
2064/// A header rewrite emits both halves, so a link in neither half is a link
2065/// the close would destroy.
2066#[derive(Default)]
2067struct CollectedLinks {
2068 /// Hard links whose target the reopen modelled, with the plan that says
2069 /// how to rebuild it.
2070 hard: Vec<(HardEntry, CollectedObject)>,
2071 /// Links written back unchanged: the class this writer cannot express,
2072 /// and the hard links whose object it cannot model.
2073 preserved: Vec<PreservedEntry>,
2074 /// The creation order each link carried on disk (`H5O_LINK_STORE_CORDER`),
2075 /// by full link path without a leading `/`. Present only for groups that
2076 /// track link creation order, as every NetCDF-4 group does.
2077 link_corder: std::collections::HashMap<String, i64>,
2078}
2079
2080/// One hard link the reopen walk met: what it names, and the exact message
2081/// that names it.
2082#[derive(Clone)]
2083struct HardEntry {
2084 /// Full link path, in the no-leading-`/` form the registry uses.
2085 path: String,
2086 /// Object header address the link names.
2087 address: u64,
2088 /// The encoded `Link` message body, exactly as read from the file.
2089 encoded: Vec<u8>,
2090}
2091
2092/// A link the rewrite writes back exactly as it read it.
2093struct PreservedEntry {
2094 path: String,
2095 class: crate::io::reader::LinkClass,
2096 encoded: Vec<u8>,
2097 /// Why the object it names could not be modelled; `None` when the link's
2098 /// own class is what this writer cannot express.
2099 reason: Option<String>,
2100 /// What the object is, when the walk could tell.
2101 kind: PreservedKind,
2102}
2103
2104/// What a reopen can do with one object it reached.
2105///
2106/// A header rewrite emits a modelled object out of the registry, so the
2107/// registry may hold an object only when *every* message the model consumes
2108/// decoded. A partial read is not a smaller object, it is a different one:
2109/// before this rule a dataset whose datatype message did not decode was
2110/// registered as a group, and the close rewrote its header as one.
2111enum ObjectPlan {
2112 /// A dataset the rewrite can rebuild.
2113 Dataset(Box<DatasetParts>),
2114 /// A group the rewrite can rebuild, and the links it holds.
2115 Group(GroupParts),
2116 /// An object this writer cannot model, and why. Its header is never
2117 /// rewritten and never freed; the link naming it is written back byte for
2118 /// byte, so the object stays exactly as the file already had it — what
2119 /// libhdf5 does with the parts of a file it does not understand.
2120 ///
2121 /// `kind` is what the walk could still tell about the object it is
2122 /// keeping. Not modelling an object is not the same as not knowing what
2123 /// it is, and answering the second question with the first is what made
2124 /// `named_datatype_names` deny, in write mode, a datatype the same file
2125 /// reports in read mode.
2126 Preserve { why: String, kind: PreservedKind },
2127}
2128
2129/// What a preserved object is, as far as the reopen walk could tell.
2130///
2131/// Deliberately not a copy of the reader's `ObjectKind`: that one carries the
2132/// decoded object, and a preserved object is precisely the one whose contents
2133/// the writer does not decode. This says only what a listing needs.
2134#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2135pub enum PreservedKind {
2136 /// The walk did not classify it — or the link's own class, not its
2137 /// target, is what could not be expressed.
2138 Unclassified,
2139 /// A committed (named) datatype, by
2140 /// [`header_is_committed_datatype`](crate::io::reader::header_is_committed_datatype).
2141 NamedDatatype,
2142}
2143
2144impl ObjectPlan {
2145 /// An object kept by its bytes, of a kind the walk did not classify.
2146 ///
2147 /// Every reason that is a *failure* to read reaches this: a message that
2148 /// did not decode says nothing about what the object was.
2149 fn preserve(why: impl Into<String>) -> Self {
2150 ObjectPlan::Preserve {
2151 why: why.into(),
2152 kind: PreservedKind::Unclassified,
2153 }
2154 }
2155}
2156
2157/// The messages a dataset's rewrite is built from, all decoded.
2158struct DatasetParts {
2159 /// Every block the header chain occupies, chunk 0 first. All of them are
2160 /// superseded: the rewrite re-encodes the whole chain into one fresh
2161 /// chunk, so a continuation left unfreed is space nothing claims.
2162 header_blocks: crate::io::object_header_io::HeaderBlocks,
2163 datatype: DatatypeMessage,
2164 /// The committed datatype object header `datatype` was read *through*,
2165 /// when the header stores a pointer instead of a message of its own.
2166 ///
2167 /// The literal type is in `datatype` either way, because the read resolves
2168 /// the pointer before anything decodes it; this is what a rewrite needs to
2169 /// put the pointer back rather than inline a copy of the named type and
2170 /// leave `H5Tcommitted` false.
2171 committed_type: Option<u64>,
2172 dataspace: crate::format::messages::dataspace::DataspaceMessage,
2173 /// The object format the reopen found this dataset's messages written in,
2174 /// read from the dataspace message's own version byte.
2175 ///
2176 /// A version-2 superblock does not settle it: `H5F__super_init` raises the
2177 /// superblock for a shared-message table or non-default file-space
2178 /// properties without touching `H5F_LOW_BOUND` (H5Fsuper.c:1135, :1144), so
2179 /// a file created at the earliest bound with either can hold version-1
2180 /// messages under a version-2 superblock — which is what
2181 /// `tests/fixtures/sohm_*.h5` are.
2182 read_format: ObjectFormat,
2183 layout: crate::format::messages::data_layout::DataLayoutMessage,
2184 filter_pipeline: Option<FilterPipeline>,
2185 fill_value: Option<Vec<u8>>,
2186 /// The fill-value message's write-time byte, preserved across a
2187 /// rewrite the same way `fill_value` is — an appended-to dataset must
2188 /// keep the policy libhdf5 (or this writer) declared for it, not fall
2189 /// back to the `H5D_CRT_FILL_TIME_DEF` a fresh dataset gets.
2190 fill_write_time: u8,
2191 attributes: Vec<AttributeEntry>,
2192 /// The creation-order policy the on-disk header declares; a rewrite that
2193 /// read it from the writer instead would stamp this session's policy onto
2194 /// an object libhdf5 created under another.
2195 track_order: TrackOrder,
2196 /// The times the on-disk header records, for the same reason: whether an
2197 /// object tracks them is settled when it is created, not when it is
2198 /// rewritten. Recovered by [`ObjectHeader::recorded_times`].
2199 times: Option<ObjectTimes>,
2200 /// The dense storage the rewrite supersedes and must free.
2201 dense: DenseCarry,
2202 /// The External File List the header carries, with each slot's name
2203 /// already read back out of the local heap the message points at. `None`
2204 /// for a dataset whose raw data is in this file.
2205 ///
2206 /// Carried rather than re-derived because the rewrite has to re-emit the
2207 /// message: a contiguous layout with an undefined address and no EFL
2208 /// beside it is a dataset with no data at all, so dropping this on a
2209 /// header rewrite would silently unlink every external byte.
2210 external: Option<ExternalStorage>,
2211}
2212
2213/// The same for a group, plus the links it holds — decoded once, with the
2214/// bytes they came from, so the walk and the rewrite agree on its contents.
2215struct GroupParts {
2216 header_blocks: crate::io::object_header_io::HeaderBlocks,
2217 attributes: Vec<AttributeEntry>,
2218 links: Vec<(crate::format::messages::link::LinkMessage, Vec<u8>)>,
2219 track_order: TrackOrder,
2220 times: Option<ObjectTimes>,
2221 dense: DenseCarry,
2222 /// The symbol-table storage a classic group's header names — the blocks
2223 /// the rewrite supersedes. `None` for a link-message group, which has
2224 /// none. Its links are already in `links`: the walk turns each symbol
2225 /// table entry into the link message it stands for, so nothing downstream
2226 /// has to know which of the two forms the group was in.
2227 stab: Option<StabExtents>,
2228}
2229
2230/// The dense storage one reopened object's header names, which the rewrite of
2231/// that header stops naming and therefore has to free. Both halves are read
2232/// back before this is built — a heap that could not be read makes the object
2233/// [`ObjectPlan::Preserve`], so nothing here describes storage whose contents
2234/// were lost.
2235#[derive(Default)]
2236struct DenseCarry {
2237 attrs: Option<AttributeInfoMessage>,
2238 links: Option<LinkInfoMessage>,
2239}
2240
2241/// A modelled object, as the walk hands it to the registry rebuild. A group's
2242/// links are not here: the walk followed them, and each child is an entry of
2243/// its own.
2244enum CollectedObject {
2245 Dataset(Box<DatasetParts>),
2246 Group {
2247 header_blocks: crate::io::object_header_io::HeaderBlocks,
2248 attributes: Vec<AttributeEntry>,
2249 track_order: TrackOrder,
2250 times: Option<ObjectTimes>,
2251 dense: DenseCarry,
2252 stab: Option<StabExtents>,
2253 },
2254}
2255
2256/// The reopen's discovery pass: one walk that classifies every object it
2257/// reaches and descends into the groups among them.
2258///
2259/// Every object the close will touch is decided here and nowhere else, so
2260/// "modelled or preserved" is a property of the walk rather than of whatever
2261/// each later stage happened to be able to decode.
2262struct ReopenWalk<'a> {
2263 handle: &'a mut FileHandle,
2264 meta: &'a crate::io::FileMeta,
2265 out: CollectedLinks,
2266 /// Object headers already descended into, so hard-link cycles end.
2267 visited: std::collections::HashSet<u64>,
2268}
2269
2270impl<'a> ReopenWalk<'a> {
2271 fn new(handle: &'a mut FileHandle, meta: &'a crate::io::FileMeta) -> Self {
2272 Self {
2273 handle,
2274 meta,
2275 out: CollectedLinks::default(),
2276 visited: std::collections::HashSet::new(),
2277 }
2278 }
2279
2280 /// Everything the walk found.
2281 fn finish(self) -> CollectedLinks {
2282 self.out
2283 }
2284
2285 /// Decide what the reopen can do with the object at `addr`.
2286 ///
2287 /// The single gate: every object the rewrite touches is classified here,
2288 /// and an object is modelled only when each message the model consumes
2289 /// decoded. See [`ObjectPlan`] for why anything else must keep its bytes.
2290 fn plan(&mut self, addr: u64) -> IoResult<ObjectPlan> {
2291 let (handle, meta) = (&mut *self.handle, self.meta);
2292 let ctx = &meta.ctx;
2293 use crate::format::messages::data_layout::DataLayoutMessage;
2294 use crate::format::messages::dataspace::DataspaceMessage;
2295 use crate::format::messages::link::{CharacterSet, LinkMessage};
2296 use crate::format::messages::link_info::LinkInfoMessage;
2297 use crate::format::messages::shared::MSG_FLAG_SHARED;
2298 use crate::format::messages::{
2299 MSG_ATTRIBUTE, MSG_DATASPACE, MSG_DATATYPE, MSG_DATA_LAYOUT, MSG_EXTERNAL_FILE_LIST,
2300 MSG_FILL_VALUE, MSG_FILTER_PIPELINE, MSG_LINK, MSG_LINK_INFO, MSG_SYMBOL_TABLE,
2301 };
2302
2303 // The whole chain, messages and blocks alike: a filter pipeline or an
2304 // attribute that spilled into a continuation is one the rewrite would
2305 // otherwise drop, and a continuation block it does not know about is
2306 // one the rewrite would orphan.
2307 let (header, header_blocks) =
2308 match crate::io::object_header_io::read_object_header_with_blocks(handle, meta, addr) {
2309 Ok(h) => h,
2310 Err(e) => {
2311 return Ok(ObjectPlan::preserve(format!(
2312 "its object header chain does not read: {e}"
2313 )))
2314 }
2315 };
2316
2317 // The policy, the times and the storage the header declares, read once
2318 // from the whole chain: all three are properties of the object, not of
2319 // any one message the loop below happens to reach.
2320 let track_order = recover_track_order(&header, ctx);
2321 let times = header.recorded_times();
2322 let (dense_attrs, dense_links) = superseded_dense(&header, ctx);
2323
2324 // Attributes come from the reader's collector rather than from the
2325 // loop below, so compact, dense and shared attributes all reach the
2326 // rewrite by the one path that knows how to read each of them. An
2327 // object whose set did not read whole is preserved: a short set here
2328 // would be a rewrite deleting the attributes it could not read.
2329 let attributes = match take_reopened_attributes(
2330 crate::io::reader::collect_object_attributes(handle, ctx, &header),
2331 &format!("the object at {addr:#x}"),
2332 ) {
2333 Ok(a) => a,
2334 Err(e) => {
2335 return Ok(ObjectPlan::preserve(format!(
2336 "its attributes do not read back whole: {e}"
2337 )))
2338 }
2339 };
2340
2341 let mut datatype = None;
2342 let mut dataspace = None;
2343 let mut layout = None;
2344 let mut filter_pipeline = None;
2345 let mut fill_value = None;
2346 // No fill-value message at all is the library default, the same
2347 // convention the reader-side decode (`Hdf5Reader::dataset_info`)
2348 // uses for `fill_defined`.
2349 let mut fill_write_time: u8 = FILL_TIME_IFSET;
2350 let mut external = None;
2351 let mut links = Vec::new();
2352 let mut stab = None;
2353 // A datatype, dataspace or layout message says the object is not a
2354 // group, whether or not the three a dataset needs are all there.
2355 let mut dataset_shaped = false;
2356
2357 for msg in &header.messages {
2358 let consumed = matches!(
2359 msg.msg_type,
2360 MSG_DATATYPE
2361 | MSG_DATASPACE
2362 | MSG_DATA_LAYOUT
2363 | MSG_FILTER_PIPELINE
2364 | MSG_FILL_VALUE
2365 | MSG_EXTERNAL_FILE_LIST
2366 | MSG_ATTRIBUTE
2367 | MSG_LINK
2368 | MSG_LINK_INFO
2369 | MSG_SYMBOL_TABLE
2370 );
2371 // A shared message holds a reference to where its body lives, not
2372 // the body. Decoding those bytes as one does not fail loudly — the
2373 // reference's version byte reads as a version and a class of its
2374 // own — so the guard is the only thing between a shared datatype
2375 // and a rewrite that invents a type for it.
2376 if consumed && msg.flags & MSG_FLAG_SHARED != 0 {
2377 return Ok(ObjectPlan::preserve(format!(
2378 "its message of type {:#04x} is a shared-message reference, which this \
2379 writer does not resolve",
2380 msg.msg_type
2381 )));
2382 }
2383 macro_rules! consume {
2384 ($decode:expr, $what:literal) => {
2385 match $decode {
2386 Ok(v) => v,
2387 Err(e) => {
2388 return Ok(ObjectPlan::preserve(format!(
2389 "its {} message does not decode: {e}",
2390 $what
2391 )))
2392 }
2393 }
2394 };
2395 }
2396 match msg.msg_type {
2397 // The pre-1.6 modification time, a formatted date string
2398 // (`H5O_MTIME`, type 0x0E). `recorded_times` reads only the
2399 // modern form, and a rewrite emits only that, so an object
2400 // carrying this one would come back out with the time it
2401 // recorded gone. Keeping its bytes is the same answer an
2402 // undecodable message already gets.
2403 crate::format::messages::MSG_MOD_TIME_OLD => {
2404 return Ok(ObjectPlan::preserve(
2405 "it carries a pre-1.6 modification time message, which this writer \
2406 reads but does not write",
2407 ))
2408 }
2409 MSG_DATATYPE => {
2410 dataset_shaped = true;
2411 let (dt, _) = consume!(DatatypeMessage::decode(&msg.data, ctx), "datatype");
2412 datatype = Some(dt);
2413 }
2414 MSG_DATASPACE => {
2415 dataset_shaped = true;
2416 let version = msg.data.first().copied().unwrap_or(1);
2417 let (ds, _) = consume!(DataspaceMessage::decode(&msg.data, ctx), "dataspace");
2418 dataspace = Some((ds, version));
2419 }
2420 MSG_DATA_LAYOUT => {
2421 dataset_shaped = true;
2422 let (dl, _) =
2423 consume!(DataLayoutMessage::decode(&msg.data, ctx), "data layout");
2424 layout = Some(dl);
2425 }
2426 MSG_FILTER_PIPELINE => {
2427 let (p, _) = consume!(FilterPipeline::decode(&msg.data), "filter pipeline");
2428 if !p.filters.is_empty() {
2429 filter_pipeline = Some(p);
2430 }
2431 }
2432 MSG_FILL_VALUE => {
2433 let (fv, _) = consume!(FillValueMessage::decode(&msg.data), "fill value");
2434 if fv.fill_defined == 2 {
2435 fill_value = fv.fill_value;
2436 }
2437 fill_write_time = fv.fill_write_time;
2438 }
2439 MSG_EXTERNAL_FILE_LIST => {
2440 dataset_shaped = true;
2441 let (efl, _) = consume!(
2442 ExternalFileListMessage::decode(&msg.data, ctx),
2443 "external file list"
2444 );
2445 // The names live in a local heap of their own, so the
2446 // rewrite cannot re-emit the message from its bytes alone
2447 // — it has to be able to point at the same strings. A heap
2448 // that does not read back leaves the object preserved,
2449 // which is what keeps its data reachable.
2450 let resolved = match crate::io::reader::Hdf5Reader::resolve_external_file_slots(
2451 handle, ctx, &efl,
2452 ) {
2453 Ok(r) => r,
2454 Err(e) => {
2455 return Ok(ObjectPlan::preserve(format!(
2456 "its external file list names do not read back: {e}"
2457 )))
2458 }
2459 };
2460 external = Some(ExternalStorage {
2461 heap_addr: efl.heap_addr,
2462 // `H5Fopen` opens no dataset, so nothing has read a
2463 // dapl for this one yet; the first handle it hands
2464 // out settles the prefix.
2465 prefix: EfilePrefix::default(),
2466 files: efl
2467 .slots
2468 .iter()
2469 .zip(resolved)
2470 .map(|(slot, seg)| ExternalFile {
2471 name: seg.name,
2472 name_offset: slot.name_offset,
2473 offset: slot.offset,
2474 size: slot.size,
2475 })
2476 .collect(),
2477 });
2478 }
2479 MSG_LINK => {
2480 let (l, _) = consume!(LinkMessage::decode(&msg.data, ctx), "link");
2481 links.push((l, msg.data.clone()));
2482 }
2483 MSG_LINK_INFO => {
2484 let (li, _) = consume!(LinkInfoMessage::decode(&msg.data, ctx), "link info");
2485 // Once a group holds enough links libhdf5 moves them into
2486 // the fractal heap this message names and writes no `Link`
2487 // messages at all. Reading them back is what makes the
2488 // rewrite emit the group with its children; a rewrite from
2489 // the header messages alone emitted it empty, orphaning
2490 // every object below it.
2491 if li.fractal_heap_address != UNDEF_ADDR {
2492 let dense = match crate::io::reader::Hdf5Reader::read_dense_links(
2493 handle,
2494 ctx,
2495 li.fractal_heap_address,
2496 ) {
2497 Ok(l) => l,
2498 Err(e) => {
2499 return Ok(ObjectPlan::preserve(format!(
2500 "its dense link storage does not read: {e}"
2501 )))
2502 }
2503 };
2504 // Re-encoded rather than carried as bytes: a heap
2505 // object is not a header message, so there are no
2506 // message bytes to carry. The encoding round-trips
2507 // through the same decoder that just read it.
2508 links.extend(dense.into_iter().map(|l| {
2509 let bytes = l.encode(ctx);
2510 (l, bytes)
2511 }));
2512 }
2513 }
2514 MSG_SYMBOL_TABLE => {
2515 // A classic group keeps no link message at all: its links
2516 // are symbol table entries in the B-tree this message
2517 // names. Turning each into the link message it stands for
2518 // is what lets the rest of the reopen — the walk, the
2519 // registry, the preserve path — work on one link model
2520 // whichever form the group is in.
2521 let Some(s) = Stab::decode(&msg.data, ctx) else {
2522 return Ok(ObjectPlan::preserve(
2523 "its symbol table message is shorter than the two addresses it \
2524 must carry",
2525 ));
2526 };
2527 let contents = match crate::io::symbol_table_io::read_stab(handle, meta, s) {
2528 Ok(c) => c,
2529 Err(e) => {
2530 return Ok(ObjectPlan::preserve(format!(
2531 "its symbol table does not read: {e}"
2532 )))
2533 }
2534 };
2535 stab = Some(contents.extents);
2536 links.extend(contents.links.into_iter().map(|l| {
2537 let msg = match l.target {
2538 StabTarget::Hard { addr, .. } => LinkMessage::hard(&l.name, addr),
2539 StabTarget::Soft { value } => LinkMessage::soft(&l.name, &value),
2540 };
2541 // An entry carries no character set field, so the link
2542 // it stands for has the file default whatever its name
2543 // looks like (`H5G__ent_to_link`, H5Gent.c:372).
2544 // Deriving one from the name would take a group
2545 // libhdf5 wrote with a high-byte ASCII name out of its
2546 // symbol table on the rewrite.
2547 let msg = msg.with_cset(CharacterSet::Ascii);
2548 let bytes = msg.encode(ctx);
2549 (msg, bytes)
2550 }));
2551 }
2552 _ => {}
2553 }
2554 }
2555
2556 // libhdf5 refuses a layout that disagrees with its sibling dataspace
2557 // and datatype as the dataset opens (`H5O__layout_decode` for the
2558 // chunk rank, `H5D__compact_init` for the compact size); modelled
2559 // anyway, the disagreement would be read at the wrong rank or past
2560 // the compact payload, so the dataset keeps its bytes, exactly as
2561 // unreadable as the file already had it.
2562 if let (Some((ds, _)), Some(dt), Some(dl)) = (&dataspace, &datatype, &layout) {
2563 if let Err(e) = dl.check_against_dataset(ds, dt, ctx) {
2564 return Ok(ObjectPlan::preserve(format!(
2565 "its layout doesn't fit its dataspace and datatype: {e}"
2566 )));
2567 }
2568 }
2569
2570 match (datatype, dataspace, layout) {
2571 // A layout `rebuild_dataset` has no arm for leaves the registry
2572 // entry with an undefined data address, and the close then rewrites
2573 // the header as a contiguous, unallocated dataset — every element
2574 // gone, silently. Only the layouts that rebuild are modelled; the
2575 // rest keep their bytes, as an undecodable message already does.
2576 // The virtual layout is this.
2577 (Some(_), Some(_), Some(layout)) if !layout_rebuilds(&layout) => {
2578 Ok(ObjectPlan::preserve(format!(
2579 "its data layout is {}, which this writer reads but does not build",
2580 layout.describe()
2581 )))
2582 }
2583 (Some(datatype), Some((dataspace, dataspace_version)), Some(layout)) => {
2584 // Asked of the raw chain, not of `header`: the read above has
2585 // already put the named type's message in place of the pointer.
2586 let committed_type = match crate::io::object_header_io::committed_datatype_address(
2587 handle, meta, addr,
2588 ) {
2589 Ok(c) => c,
2590 Err(e) => {
2591 return Ok(ObjectPlan::preserve(format!(
2592 "its shared datatype pointer does not decode: {e}"
2593 )))
2594 }
2595 };
2596 Ok(ObjectPlan::Dataset(Box::new(DatasetParts {
2597 header_blocks,
2598 datatype,
2599 committed_type,
2600 dataspace,
2601 read_format: if dataspace_version <= 1 {
2602 ObjectFormat::Legacy
2603 } else {
2604 ObjectFormat::Modern
2605 },
2606 layout,
2607 filter_pipeline,
2608 fill_value,
2609 fill_write_time,
2610 attributes,
2611 track_order,
2612 times,
2613 dense: DenseCarry {
2614 attrs: dense_attrs,
2615 links: dense_links,
2616 },
2617 external,
2618 })))
2619 }
2620 // A committed (named) datatype has a datatype message and neither
2621 // of the other two; so does a dataset whose header this crate only
2622 // half understands. Neither is a group, and modelling either as
2623 // one is what rewrote them into empty groups. They part company
2624 // here and nowhere else: the datatype is kept by its bytes like
2625 // the other, but a listing can still name it.
2626 _ if crate::io::reader::header_is_committed_datatype(&header) => {
2627 Ok(ObjectPlan::Preserve {
2628 why: "it is a committed (named) datatype, which this writer carries by \
2629 its bytes rather than re-encoding"
2630 .into(),
2631 kind: PreservedKind::NamedDatatype,
2632 })
2633 }
2634 _ if dataset_shaped => Ok(ObjectPlan::preserve(
2635 "it carries a datatype, dataspace or layout message but not the three a \
2636 dataset is built from; this writer models only groups and datasets",
2637 )),
2638 _ => Ok(ObjectPlan::Group(GroupParts {
2639 header_blocks,
2640 attributes,
2641 links,
2642 track_order,
2643 times,
2644 dense: DenseCarry {
2645 attrs: dense_attrs,
2646 links: dense_links,
2647 },
2648 stab,
2649 })),
2650 }
2651 }
2652
2653 /// Walk `links` (one group's, already decoded), classifying every object
2654 /// they name and descending into the groups among them.
2655 fn group(
2656 &mut self,
2657 links: &[(crate::format::messages::link::LinkMessage, Vec<u8>)],
2658 prefix: &str,
2659 depth: usize,
2660 ) -> IoResult<()> {
2661 // Bound nesting depth so a pathologically deep group chain cannot
2662 // overflow the stack (the `visited` set bounds total work but not
2663 // recursion depth).
2664 if depth > 256 {
2665 return Ok(());
2666 }
2667 use crate::format::messages::link::LinkTarget;
2668 for (link, encoded) in links {
2669 let full_name = if prefix.is_empty() {
2670 link.name.clone()
2671 } else {
2672 format!("{}/{}", prefix, link.name)
2673 };
2674
2675 if let Some(corder) = link.creation_order {
2676 self.out.link_corder.insert(full_name.clone(), corder);
2677 }
2678 // Only a hard link names an object this writer can rebuild. Every
2679 // other class is kept by its bytes, because a close that emitted
2680 // only what the registry models would drop it from the file.
2681 let LinkTarget::Hard { address } = &link.target else {
2682 self.out.preserved.push(PreservedEntry {
2683 path: full_name,
2684 class: crate::io::reader::LinkClass::from_target(&link.target),
2685 encoded: encoded.clone(),
2686 reason: None,
2687 kind: PreservedKind::Unclassified,
2688 });
2689 continue;
2690 };
2691 let entry = HardEntry {
2692 path: full_name.clone(),
2693 address: *address,
2694 encoded: encoded.clone(),
2695 };
2696
2697 match self.plan(*address)? {
2698 // Kept by its bytes, exactly as a link class this writer
2699 // cannot express is: writing the link back unchanged is what
2700 // leaves the object's header where the file already has it.
2701 ObjectPlan::Preserve { why, kind } => self.out.preserved.push(PreservedEntry {
2702 path: full_name,
2703 class: crate::io::reader::LinkClass::Hard,
2704 encoded: entry.encoded,
2705 reason: Some(why),
2706 kind,
2707 }),
2708 ObjectPlan::Dataset(parts) => {
2709 self.out.hard.push((entry, CollectedObject::Dataset(parts)));
2710 }
2711 ObjectPlan::Group(parts) => {
2712 self.out.hard.push((
2713 entry,
2714 CollectedObject::Group {
2715 header_blocks: parts.header_blocks,
2716 attributes: parts.attributes,
2717 track_order: parts.track_order,
2718 times: parts.times,
2719 dense: parts.dense,
2720 stab: parts.stab,
2721 },
2722 ));
2723 // Recurse only into a group's header we have not entered
2724 // before — breaks hard-link cycles.
2725 if self.visited.insert(*address) {
2726 self.group(&parts.links, &full_name, depth + 1)?;
2727 }
2728 }
2729 }
2730 }
2731 Ok(())
2732 }
2733}
2734
2735/// Rebuild one reopened dataset's in-memory registry entry, storage and
2736/// all, from the header messages the walk decoded.
2737///
2738/// Fails when the chunk index the file names does not read back. The
2739/// caller answers that by preserving the object rather than registering
2740/// a dataset whose index has forgotten where its chunks are: the close
2741/// rewrites what the registry holds, so an index rebuilt from the part of
2742/// it that decoded would strand every chunk it could not read.
2743fn rebuild_dataset(
2744 handle: &mut FileHandle,
2745 meta: &FileMeta,
2746 file_size: u64,
2747 name: String,
2748 obj_addr: u64,
2749 parts: DatasetParts,
2750) -> IoResult<DatasetInfo> {
2751 let ctx = &meta.ctx;
2752 let DatasetParts {
2753 header_blocks,
2754 datatype: dt,
2755 committed_type,
2756 dataspace: ds,
2757 read_format,
2758 layout: dl,
2759 filter_pipeline: fp,
2760 fill_value,
2761 fill_write_time,
2762 attributes: attrs,
2763 track_order,
2764 times,
2765 dense: _,
2766 external,
2767 } = parts;
2768
2769 let mut info = DatasetInfo {
2770 name,
2771 datatype: dt,
2772 // The named type's own object is preserved by its bytes, so the
2773 // address the walk read the pointer from is the address it will still
2774 // be at when this header is written back.
2775 committed_type: committed_type.map(CommittedTypeRef::Preserved),
2776 read_format: Some(read_format),
2777 external,
2778 virtual_storage: None,
2779 dataspace: ds,
2780 obj_header_addr: obj_addr,
2781 data_addr: UNDEF_ADDR,
2782 data_size: 0,
2783 compact: None,
2784 chunked: None,
2785 fixed_array: None,
2786 implicit: None,
2787 single_chunk: None,
2788 btree_v1: None,
2789 btree_v2: None,
2790 append: None,
2791 attributes: attrs,
2792 obj_header_written_addr: Some(obj_addr),
2793 obj_header_blocks: header_blocks,
2794 filter_pipeline: fp,
2795 deleted: false,
2796 extent_dirty: false,
2797 header_dirty: false,
2798 // Stamped by the caller once the whole link graph is registered: it
2799 // is the count of links reaching this object, which one dataset's
2800 // parts cannot see.
2801 nlink_written: 1,
2802 // Stamped by the caller, which knows the order the walk met each
2803 // object; the rebuild sees one dataset at a time.
2804 creation_seq: 0,
2805 track_attr_order: track_order.attrs,
2806 fill_value,
2807 fill_time: fill_write_time,
2808 // Preserve the on-disk layout version so finalize re-encodes
2809 // what it read: a v5 file reopened and appended to must not be
2810 // silently downgraded to v4 (the filtered indexes keep their
2811 // 8-byte size fields, which v4 readers would mis-derive).
2812 layout_version: match &dl {
2813 DataLayoutMessage::ChunkedV4 { version, .. } => *version,
2814 // The classic index has no version above its own: a version-3
2815 // message is the whole of `H5D__chunk_set_info`'s MAX below the
2816 // version-4 gate, and re-encoding it any higher would name an
2817 // index the message cannot carry.
2818 DataLayoutMessage::ChunkedV3 { .. } => LAYOUT_VERSION_DEFAULT,
2819 _ => 4,
2820 },
2821 times,
2822 };
2823
2824 // Reconstruct storage-specific metadata
2825 debug_assert!(
2826 layout_rebuilds(&dl),
2827 "ReopenWalk::plan must preserve a layout this has no arm for"
2828 );
2829 match &dl {
2830 DataLayoutMessage::Contiguous { address, size } => {
2831 info.data_addr = *address;
2832 info.data_size = *size;
2833 }
2834 // The image is the layout message, so the rebuild carries it out of
2835 // the header it came from: anything that makes this dataset's header
2836 // stale rewrites the layout message from `compact`, and a rebuild
2837 // that left it empty would rewrite the dataset as an unallocated
2838 // contiguous one — dropping every byte.
2839 DataLayoutMessage::Compact { data } => {
2840 info.compact = Some(data.clone());
2841 }
2842 // The classic chunk index, reconstructed into the same
2843 // `BtreeV1DatasetInfo` a chunked dataset *created* in this format
2844 // gets, so the one set of machinery — `build_tree`, the flush's block
2845 // pool, `write_chunk`, `extend_dataset`, the prune a delete runs —
2846 // drives a reopened dataset and a fresh one alike. `root_addr` is what
2847 // the layout message carries and stays undefined for a dataset whose
2848 // chunks were never written, exactly as libhdf5 leaves it.
2849 DataLayoutMessage::ChunkedV3 {
2850 chunk_dims,
2851 b_tree_address,
2852 } => {
2853 let real_chunk_dims: Vec<u64> = chunk_dims[..chunk_dims.len() - 1].to_vec();
2854 let mut walk = BtreeV1Walk::new(handle, ctx, &meta.btree, &real_chunk_dims, file_size);
2855 walk.descend(*b_tree_address, 0)?;
2856 let BtreeV1Walk {
2857 records,
2858 node_addrs,
2859 ..
2860 } = walk;
2861 let max_dims = info
2862 .dataspace
2863 .max_dims
2864 .clone()
2865 .unwrap_or_else(|| info.dataspace.dims.clone());
2866 info.btree_v1 = Some(BtreeV1DatasetInfo {
2867 chunk_dims: real_chunk_dims,
2868 max_dims,
2869 // The file's own "K" ranks, not this session's defaults: they
2870 // set every node's width, so a tree bulk-loaded under the
2871 // wrong ones would re-serialize over blocks of the wrong size.
2872 config: meta.btree,
2873 records,
2874 node_addrs,
2875 root_addr: *b_tree_address,
2876 chunks_written: 0,
2877 });
2878 }
2879 DataLayoutMessage::ChunkedV4 {
2880 chunk_dims,
2881 index_address,
2882 index_type,
2883 earray_params,
2884 single_chunk_filter,
2885 ..
2886 } => {
2887 let real_chunk_dims: Vec<u64> = chunk_dims[..chunk_dims.len() - 1].to_vec();
2888
2889 if *index_type == crate::format::messages::data_layout::ChunkIndexType::ExtensibleArray
2890 {
2891 if let Some(params) = earray_params {
2892 let ep = EarrayParams {
2893 max_nelmts_bits: params.max_nelmts_bits,
2894 idx_blk_elmts: params.idx_blk_elmts,
2895 sup_blk_min_data_ptrs: params.sup_blk_min_data_ptrs,
2896 data_blk_min_elmts: params.data_blk_min_elmts,
2897 max_dblk_page_nelmts_bits: params.max_dblk_page_nelmts_bits,
2898 };
2899 let ndblk_addrs = compute_ndblk_addrs(ep.sup_blk_min_data_ptrs)?;
2900 let nsblk_addrs = compute_nsblk_addrs(
2901 ep.idx_blk_elmts,
2902 ep.data_blk_min_elmts,
2903 ep.sup_blk_min_data_ptrs,
2904 ep.max_nelmts_bits,
2905 )?;
2906
2907 // Read EA header
2908 let hdr_buf = handle.read_at_most(*index_address, 256)?;
2909 let ea_header = ExtensibleArrayHeader::decode(&hdr_buf, ctx)?;
2910
2911 let is_filtered = ea_header.class_id
2912 == crate::format::chunk_index::extensible_array::EA_CLS_FILT_CHUNK;
2913 let chunk_size_len = if is_filtered {
2914 ea_header.raw_elmt_size - ctx.sizeof_addr - 4
2915 } else {
2916 0
2917 };
2918
2919 // Read the EA index block. Filtered datasets
2920 // store a `FilteredIndexBlock`; unfiltered ones a
2921 // plain `ExtensibleArrayIndexBlock`. Both must be
2922 // reconstructed so a reopened dataset can append
2923 // (write_chunk consults whichever applies).
2924 let ea_iblk_addr = ea_header.idx_blk_addr;
2925 let (ea_iblk, filt_iblk) = if is_filtered {
2926 let placeholder = ExtensibleArrayIndexBlock::new(
2927 *index_address,
2928 ep.idx_blk_elmts,
2929 ndblk_addrs,
2930 nsblk_addrs,
2931 );
2932 let fib = if ea_iblk_addr != UNDEF_ADDR {
2933 let iblk_buf = handle.read_at_most(ea_iblk_addr, 65536)?;
2934 FilteredIndexBlock::decode(
2935 &iblk_buf,
2936 ctx,
2937 ep.idx_blk_elmts as usize,
2938 ndblk_addrs,
2939 nsblk_addrs,
2940 chunk_size_len,
2941 )?
2942 } else {
2943 FilteredIndexBlock::new(
2944 *index_address,
2945 ep.idx_blk_elmts,
2946 ndblk_addrs,
2947 nsblk_addrs,
2948 )
2949 };
2950 (placeholder, Some(fib))
2951 } else {
2952 let eib = if ea_iblk_addr != UNDEF_ADDR {
2953 let iblk_buf = handle.read_at_most(ea_iblk_addr, 65536)?;
2954 ExtensibleArrayIndexBlock::decode(
2955 &iblk_buf,
2956 ctx,
2957 ep.idx_blk_elmts as usize,
2958 ndblk_addrs,
2959 nsblk_addrs,
2960 )?
2961 } else {
2962 ExtensibleArrayIndexBlock::new(
2963 *index_address,
2964 ep.idx_blk_elmts,
2965 ndblk_addrs,
2966 nsblk_addrs,
2967 )
2968 };
2969 (eib, None)
2970 };
2971
2972 info.chunked = Some(ChunkedDatasetInfo {
2973 chunk_dims: real_chunk_dims,
2974 earray_params: ep,
2975 ea_header_addr: *index_address,
2976 ea_iblk_addr,
2977 ea_header,
2978 ea_iblk,
2979 chunks_written: 0,
2980 filt_iblk,
2981 chunk_size_len,
2982 });
2983 }
2984 } else if *index_type
2985 == crate::format::messages::data_layout::ChunkIndexType::FixedArray
2986 {
2987 // Read the FA header and data block back so a
2988 // reopened dataset is writable and deletable, not
2989 // re-link only — a placeholder made a delete free
2990 // just the header and leak every chunk plus the
2991 // index. Paged data blocks (any FA with more than
2992 // dblk_page_nelmts chunks, libhdf5 default 1024)
2993 // reconstruct through the same decode owner; only
2994 // pages the bitmap marks initialized are decoded.
2995 let hdr_buf = handle.read_at_most(*index_address, 256)?;
2996 let fa_header = FixedArrayHeader::decode(&hdr_buf, ctx)?;
2997 let is_filtered = fa_header.client_id == FA_CLIENT_FILT_CHUNK;
2998 let chunk_size_len = if is_filtered {
2999 (fa_header.element_size as usize)
3000 .checked_sub(ctx.sizeof_addr as usize + 4)
3001 .ok_or_else(|| {
3002 crate::io::IoError::InvalidState(
3003 "fixed array filtered element_size too small".into(),
3004 )
3005 })?
3006 } else {
3007 0
3008 };
3009 if fa_header.data_blk_addr != UNDEF_ADDR && chunk_size_len <= 8 {
3010 let dblk_size = fixed_array_dblk_disk_size(ctx, &fa_header) as usize;
3011 let dblk_buf = handle.read_at_most(fa_header.data_blk_addr, dblk_size)?;
3012 let fa_dblk =
3013 decode_fixed_array_dblk(ctx, &fa_header, &dblk_buf, chunk_size_len)?;
3014 info.fixed_array = Some(FixedArrayDatasetInfo {
3015 chunk_dims: real_chunk_dims,
3016 fa_header_addr: *index_address,
3017 fa_dblk_addr: fa_header.data_blk_addr,
3018 fa_header,
3019 fa_dblk,
3020 // Chunks written this session, matching the
3021 // EA reconstruction above.
3022 chunks_written: 0,
3023 });
3024 }
3025 } else if *index_type == crate::format::messages::data_layout::ChunkIndexType::BTreeV2 {
3026 use crate::format::chunk_index::btree_v2::{
3027 Bt2Geometry, Bt2Header, BT2_TYPE_CHUNK_FILT, BT2_TYPE_CHUNK_UNFILT,
3028 };
3029
3030 // Walk the tree back into the in-memory index and
3031 // adopt its node blocks as the flush pool. The pool
3032 // re-serializes at the header's node_size, whatever
3033 // it is — libhdf5 sizes every node from
3034 // hdr->node_size (H5B2leaf.c, H5B2internal.c) — so
3035 // a foreign size reopens too. Only a record type
3036 // that is not a chunk record, or a node size below
3037 // the bulk loader's few-records-per-node floor
3038 // (the same bound creation enforces), stays
3039 // re-link only.
3040 let hdr_buf = handle.read_at_most(*index_address, 256)?;
3041 let bt2_hdr = Bt2Header::decode(&hdr_buf, ctx)?;
3042 let ndims = real_chunk_dims.len();
3043 let is_filt = match bt2_hdr.record_type {
3044 BT2_TYPE_CHUNK_UNFILT => Some(false),
3045 BT2_TYPE_CHUNK_FILT => Some(true),
3046 _ => None,
3047 };
3048 if let (Some(is_filt), true) = (
3049 is_filt,
3050 bt2_hdr.node_size as usize >= 10 + 3 * bt2_hdr.record_size as usize,
3051 ) {
3052 let mut index = if is_filt {
3053 let csl = (bt2_hdr.record_size as usize)
3054 .checked_sub(ctx.sizeof_addr as usize + 4 + ndims * 8)
3055 .filter(|&c| c <= 8)
3056 .ok_or_else(|| {
3057 crate::io::IoError::InvalidState(
3058 "v2 B-tree filtered record size does not fit \
3059 its rank and address width"
3060 .into(),
3061 )
3062 })?;
3063 Bt2ChunkIndex::new_filtered(ndims, csl as u8)
3064 } else {
3065 Bt2ChunkIndex::new_unfiltered(ndims)
3066 };
3067 // Re-serialize with the creator's parameters:
3068 // node blocks keep their size and the rewritten
3069 // header keeps its declared split/merge.
3070 index.node_size = bt2_hdr.node_size;
3071 index.split_percent = bt2_hdr.split_percent;
3072 index.merge_percent = bt2_hdr.merge_percent;
3073 let mut node_addrs = Vec::new();
3074 if bt2_hdr.root_node_addr != UNDEF_ADDR && bt2_hdr.total_num_records > 0 {
3075 let geo = Bt2Geometry::new(
3076 bt2_hdr.node_size,
3077 bt2_hdr.record_size,
3078 bt2_hdr.depth,
3079 ctx.sizeof_addr,
3080 );
3081 let mut walk =
3082 Bt2Walk::new(handle, ctx, bt2_hdr.record_size, bt2_hdr.node_size, &geo);
3083 walk.descend(
3084 bt2_hdr.root_node_addr,
3085 bt2_hdr.depth,
3086 bt2_hdr.num_records_in_root,
3087 )?;
3088 node_addrs = walk.node_addrs;
3089 let record_bytes = walk.records;
3090 let total = if bt2_hdr.record_size > 0 {
3091 record_bytes.len() / bt2_hdr.record_size as usize
3092 } else {
3093 0
3094 };
3095 if is_filt {
3096 for r in Bt2ChunkIndex::decode_filtered_records(
3097 &record_bytes,
3098 total,
3099 ndims,
3100 bt2_hdr.record_size,
3101 ctx,
3102 )? {
3103 index.insert_filtered(
3104 r.scaled_offsets,
3105 r.chunk_address,
3106 r.chunk_size,
3107 r.filter_mask,
3108 );
3109 }
3110 } else {
3111 for r in Bt2ChunkIndex::decode_unfiltered_records(
3112 &record_bytes,
3113 total,
3114 ndims,
3115 ctx,
3116 )? {
3117 index.insert(r.scaled_offsets, r.chunk_address);
3118 }
3119 }
3120 }
3121 info.btree_v2 = Some(Bt2DatasetInfo {
3122 chunk_dims: real_chunk_dims,
3123 bt2_header_addr: *index_address,
3124 node_addrs,
3125 index,
3126 chunks_written: 0,
3127 });
3128 }
3129 } else if *index_type == crate::format::messages::data_layout::ChunkIndexType::Implicit
3130 {
3131 // Nothing to read back: the index *is* the run of chunk space
3132 // at `index_address`, and its length is the chunk grid times
3133 // the chunk size. Reconstructing that length is what lets a
3134 // delete free the storage and a write address it — a rebuild
3135 // that left this empty would rewrite the dataset as an
3136 // unallocated contiguous one, dropping every byte.
3137 let mut nchunks: u64 = 1;
3138 for g in crate::io::chunk_grid::index_grid(
3139 &info.dataspace.dims,
3140 info.dataspace.max_dims.as_deref(),
3141 &real_chunk_dims,
3142 )? {
3143 nchunks = nchunks.checked_mul(g).ok_or_else(|| {
3144 crate::io::IoError::InvalidState("chunk count overflows u64".into())
3145 })?;
3146 }
3147 let data_size = nchunks
3148 .checked_mul(chunk_dims.iter().product::<u64>())
3149 .ok_or_else(|| {
3150 crate::io::IoError::InvalidState(
3151 "implicit chunk storage overflows u64".into(),
3152 )
3153 })?;
3154 info.implicit = Some(ImplicitDatasetInfo {
3155 chunk_dims: real_chunk_dims,
3156 data_addr: *index_address,
3157 data_size,
3158 });
3159 } else if *index_type
3160 == crate::format::messages::data_layout::ChunkIndexType::SingleChunk
3161 {
3162 // No index structure to read back either: the one chunk's
3163 // address, and its stored size and filter mask if the
3164 // layout's filtered flag is set, are the whole of the
3165 // layout message. `chunk_dims` already includes the
3166 // trailing element-size dimension, so its product is the
3167 // chunk's unfiltered byte length directly (see `data_size`
3168 // in the Implicit arm above).
3169 let data_size = chunk_dims.iter().product::<u64>();
3170 let (nbytes, filter_mask) = match single_chunk_filter {
3171 Some(scf) => (scf.nbytes, scf.filter_mask),
3172 None => (data_size, 0),
3173 };
3174 info.single_chunk = Some(SingleChunkDatasetInfo {
3175 chunk_dims: real_chunk_dims,
3176 data_addr: *index_address,
3177 data_size,
3178 nbytes,
3179 filter_mask,
3180 chunks_written: 0,
3181 // Whether this was created with early allocation isn't
3182 // recoverable here: `fill_value` above is only the
3183 // decoded fill bytes, not the fill-value message's
3184 // `alloc_time` byte the layout was chosen under. A
3185 // reopened dataset that later gets a header rewrite
3186 // therefore reports incremental allocation regardless
3187 // of how it was actually created — the same
3188 // imprecision a reopened `fixed_array`/`btree_v2`
3189 // dataset already has, for the same reason.
3190 early_alloc: false,
3191 });
3192 }
3193 }
3194 // Unreachable by `layout_rebuilds`, which is the gate
3195 // `ReopenWalk::plan` consults before it ever calls this.
3196 _ => {}
3197 }
3198
3199 Ok(info)
3200}
3201
3202/// Write `data` at *dataset-relative* byte offset `skip` into an external file
3203/// list, walking slots by cumulative declared size exactly like
3204/// `H5D__efl_write` (H5Defl.c).
3205///
3206/// Each slot's file is opened create-if-missing and never truncated, so a
3207/// write touches only the byte range that slot owns. A write past the *total*
3208/// declared size of the list is an error, matching upstream's "write past
3209/// logical end of file" check.
3210fn write_external_file_bytes(
3211 files: &[ExternalFile],
3212 extfile_prefix: Option<&Path>,
3213 mut skip: u64,
3214 data: &[u8],
3215) -> IoResult<()> {
3216 // `H5D__efl_write`'s slot walk: an `H5O_EFL_UNLIMITED` slot matches every
3217 // remaining offset (`skip >= u64::MAX` is never true), so the search stops
3218 // there and the write below takes the whole rest of the data.
3219 let mut slot_idx = 0usize;
3220 while slot_idx < files.len() && skip >= files[slot_idx].size {
3221 skip -= files[slot_idx].size;
3222 slot_idx += 1;
3223 }
3224
3225 let mut written = 0usize;
3226 while written < data.len() {
3227 let Some(slot) = files.get(slot_idx) else {
3228 return Err(crate::io::IoError::InvalidState(
3229 "write past the logical end of the external file list".into(),
3230 ));
3231 };
3232 let full_path = crate::io::reader::combine_prefixed_path(extfile_prefix, &slot.name);
3233 let ext_handle = FileHandle::open_or_create_readwrite_with_locking(
3234 &full_path,
3235 crate::io::locking::FileLocking::Disabled,
3236 )
3237 .map_err(|e| {
3238 crate::io::IoError::InvalidState(format!(
3239 "unable to open external raw data file {} for writing: {e}",
3240 full_path.display()
3241 ))
3242 })?;
3243 let this_write = (slot.size - skip).min((data.len() - written) as u64) as usize;
3244 let at = slot.offset.checked_add(skip).ok_or_else(|| {
3245 crate::io::IoError::InvalidState(format!(
3246 "external file '{}' slot offset {} overflows {skip} bytes into the slot",
3247 slot.name, slot.offset
3248 ))
3249 })?;
3250 ext_handle.write_at(at, &data[written..written + this_write])?;
3251 // This handle is dropped at the end of the iteration, and `Drop` can
3252 // only print a flush failure. Empty the accumulator here instead, so a
3253 // full disk on an external raw-data file reaches the caller.
3254 ext_handle.flush()?;
3255
3256 written += this_write;
3257 skip = 0;
3258 slot_idx += 1;
3259 }
3260 Ok(())
3261}
3262
3263/// The directory the HDF5 file at `path` sits in — libhdf5's `H5F_t::extpath`,
3264/// which `H5D__build_file_prefix` expands `${ORIGIN}` to.
3265///
3266/// Canonicalized, so the value survives the process changing directory and so
3267/// a writer and a reader of the same file agree on it. Called once per open,
3268/// never per I/O, for exactly that reason.
3269fn source_dir_of(path: &Path) -> IoResult<PathBuf> {
3270 let canonical = std::fs::canonicalize(path)?;
3271 Ok(canonical
3272 .parent()
3273 .map(Path::to_path_buf)
3274 .unwrap_or_default())
3275}
3276
3277/// Whether [`rebuild_dataset`] has an arm that reconstructs this layout.
3278///
3279/// The single list: `ReopenWalk::plan` preserves an object whose layout this
3280/// says no to, so a layout added to one side and not the other cannot happen.
3281/// Keeping two lists is what would rewrite a modelled dataset as unallocated
3282/// contiguous storage, or preserve one the writer can now build.
3283fn layout_rebuilds(layout: &DataLayoutMessage) -> bool {
3284 matches!(
3285 layout,
3286 DataLayoutMessage::Contiguous { .. }
3287 | DataLayoutMessage::Compact { .. }
3288 | DataLayoutMessage::ChunkedV3 { .. }
3289 | DataLayoutMessage::ChunkedV4 { .. }
3290 )
3291}
3292
3293/// Encode an Object Reference Count message (type 0x16) body: a version
3294/// byte (`H5O_REFCOUNT_VERSION` = 0) followed by the little-endian u32
3295/// count. Emitted on objects reached by more than one hard link.
3296fn encode_refcount(refcount: u32) -> Vec<u8> {
3297 let mut v = Vec::with_capacity(5);
3298 v.push(0u8);
3299 v.extend_from_slice(&refcount.to_le_bytes());
3300 v
3301}
3302
3303/// The symbol-table storage of every group that has one, and the single owner
3304/// of which groups those are.
3305///
3306/// A group stores its links in a symbol table because the file was *made* that
3307/// way — `H5F_LIBVER_EARLIEST` is the one bound `H5G__obj_create_real`
3308/// (H5Gobj.c:179) writes them at — or because it already had one when the file
3309/// was reopened. The second is not the first: `H5G_obj_insert` inserts into
3310/// whatever storage the group is in and converts only when a link will not fit
3311/// an entry (H5Gobj.c:512), so a symbol table survives a reopen at any bound.
3312/// A file with shared messages is where the two come apart, because its
3313/// superblock extension forces a version-2 superblock over symbol-table groups
3314/// (H5Fsuper.c:1135) — a group the session adds is made as the session's
3315/// bound says while the groups already there stay symbol tables.
3316struct SymbolTables {
3317 /// The scopes the reopen found a Symbol Table message on. Fixed for the
3318 /// session: a group already in that storage stays in it, whatever bound
3319 /// the objects added beside it are written at.
3320 found: HashSet<LinkScope>,
3321 /// The symbol-table storage each group's header already names, by the
3322 /// scope whose rewrite supersedes it.
3323 ///
3324 /// INVARIANT: every entry is freed exactly once, by
3325 /// [`Hdf5Writer::prepare_symbol_tables`], which removes it as it frees.
3326 superseded: Slot<HashMap<LinkScope, StabExtents>>,
3327 /// The storage that same pass laid out, read by the header builders.
3328 ///
3329 /// INVARIANT: an entry exists here only after every block of that group's
3330 /// heap and B-tree is on disk. `build_group_header` reads it and never
3331 /// builds — a header is sized and then written by two separate calls, so a
3332 /// build that allocated would allocate twice.
3333 written: Slot<HashMap<LinkScope, Stab>>,
3334}
3335
3336impl SymbolTables {
3337 /// What a file being created starts from: no group found in a symbol table
3338 /// because none was read, and nothing on disk to free.
3339 fn none_found() -> Self {
3340 Self {
3341 found: HashSet::new(),
3342 superseded: Slot::new(HashMap::new()),
3343 written: Slot::new(HashMap::new()),
3344 }
3345 }
3346}
3347
3348/// Everything a version-0/1 (symbol-table) file carries that a version-2/3 one
3349/// does not.
3350///
3351/// Its presence *is* the generation switch — [`Hdf5Writer::message_format`]
3352/// reads nothing else: libhdf5 at `H5F_LIBVER_EARLIEST` writes a version-0/1
3353/// superblock over version-1 object headers over symbol-table groups. Which
3354/// groups are symbol tables is the separate question [`SymbolTables`] answers,
3355/// because a reopen at a newer bound keeps the ones it finds.
3356///
3357/// Two things put one here, and only two: reopening a file that already is in
3358/// that format, and creating one at that bound
3359/// ([`LegacyFile::created`]). Neither is distinguished afterwards — a file is
3360/// classic or it is not, and every encoder asks only that.
3361struct LegacyFile {
3362 /// The superblock as it was read, or as [`LegacyFile::created`] built it.
3363 /// The close re-emits it with only the end of file and the root symbol
3364 /// table entry recomputed: the "K" ranks in particular are recorded
3365 /// nowhere else, and every node width in the file is derived from them.
3366 superblock: SuperblockV0V1,
3367}
3368
3369impl LegacyFile {
3370 /// The classic-format state a file created at `H5F_LIBVER_EARLIEST`
3371 /// starts from.
3372 ///
3373 /// A new file has no symbol table on disk to free and none laid out, so
3374 /// its [`SymbolTables`] starts empty and every group it makes takes that
3375 /// storage from the bound rather than from what was found.
3376 ///
3377 /// The superblock is the one `H5F__super_init` writes at that bound: the
3378 /// library-default "K" ranks (`H5F_CRT_SYM_LEAF_DEF`,
3379 /// `HDF5_BTREE_SNODE_IK_DEF`), no free-space info and no driver info. The
3380 /// root entry's object header address and cached symbol table are stamped
3381 /// in by [`Hdf5Writer::write_superblock`] once the root group has one;
3382 /// its name offset is the empty string at the front of every local heap.
3383 ///
3384 /// Version 0, not 1: a version-1 superblock exists only to carry a
3385 /// non-default chunked-storage "K" value (H5Fsuper.c:1150), and this
3386 /// writer has no property to set one.
3387 fn created(ctx: FormatContext, base_address: u64) -> Self {
3388 let btree = BTreeV1Config::default();
3389 Self {
3390 superblock: SuperblockV0V1 {
3391 version: SUPERBLOCK_V0,
3392 sizeof_offsets: ctx.sizeof_addr,
3393 sizeof_lengths: ctx.sizeof_size,
3394 file_consistency_flags: 0,
3395 sym_leaf_k: btree.sym_leaf_k,
3396 btree_internal_k: btree.snode_internal_k,
3397 indexed_storage_k: None,
3398 base_address,
3399 superblock_extension_address: UNDEF_ADDR,
3400 end_of_file_address: 0,
3401 driver_info_address: UNDEF_ADDR,
3402 root_symbol_table_entry: SymbolTableEntry {
3403 name_offset: 0,
3404 obj_header_addr: UNDEF_ADDR,
3405 cache: SymbolTableCache::Nothing,
3406 },
3407 },
3408 }
3409 }
3410}
3411
3412/// The superblock extension a reopen found, and the single owner of the one
3413/// this file's close writes back.
3414///
3415/// The extension is external truth: it is where a file records the things its
3416/// superblock has no field for — non-default v1 B-tree "K" ranks, a driver's
3417/// settings, the file space strategy and its persisted free-space managers,
3418/// and the shared object header message table. `H5F__super_ext_write_msg`
3419/// modifies one message of it and leaves the rest alone, so a close that lays
3420/// a fresh extension out from what *this writer* models drops everything it
3421/// does not — and the K ranks are not decoration: a chunked dataset's version-1
3422/// B-tree nodes are sized from `chunk_internal_k`, so a reader that has lost
3423/// the message reads the tree at the default rank and fails outright.
3424///
3425/// INVARIANT: every message of the extension read is re-emitted by
3426/// [`Hdf5Writer::write_superblock_extension`], byte for byte, except the
3427/// shared-message table — the one message naming storage this session lays out
3428/// afresh, which [`SohmState`] recomputes. Nothing else here is interpreted,
3429/// so a message this crate does not model survives exactly as a modelled one
3430/// does.
3431struct CarriedExtension {
3432 /// Every block the extension header occupied — chunk 0 and each
3433 /// continuation it named — freed once the replacement is laid out. Empty
3434 /// for a file with no extension, and for one whose extension this session
3435 /// is the first to write. A rewrite re-encodes the whole chain into one
3436 /// chunk, so freeing only the first would leave the rest as space no
3437 /// free-space manager records and no object claims.
3438 superseded: crate::io::object_header_io::HeaderBlocks,
3439 /// Every message that header held — the shared-message table,
3440 /// continuations and null padding excepted. The first two are structure
3441 /// rather than content; the third is free space.
3442 carried: Vec<crate::io::object_header_io::ExtensionMessage>,
3443 /// Where [`Hdf5Writer::write_superblock_extension`] put the replacement,
3444 /// and the only value the superblock's extension address is read from.
3445 /// `None` until that pass runs, and for a file that needs no extension.
3446 addr: Slot<Option<u64>>,
3447}
3448
3449/// What a reopen learns from a file's free-space managers, split by who owns
3450/// it: the sections go to the allocator and the rest stays with the writer.
3451struct ReopenedFreeSpace {
3452 /// `None` for a file this writer records no free space for.
3453 state: Option<Box<FileSpaceState>>,
3454 /// Every section the managers held, each tagged with the manager it came
3455 /// out of and merged only within it, address-ordered. Empty whenever
3456 /// `state` is `None`.
3457 sections: Vec<FreeBlock>,
3458}
3459
3460/// The file-space info message this session is responsible for, and the
3461/// manager blocks it supersedes.
3462///
3463/// A file whose message says `persist` records the space its own edits
3464/// released in one free-space manager per allocation type: a header block
3465/// (`FSHD`) naming a sections block (`FSSE`) that lists every free region.
3466/// Nothing else in the file says those regions are free, so a session that
3467/// rewrites the file without reading them either leaks the space it frees or
3468/// hands out space a manager still claims.
3469///
3470/// Present for a file this writer *created* with non-default file-space
3471/// properties as well, where there is nothing to read and the message is this
3472/// session's to write. `None` — the field, not this struct — is the third
3473/// case: a reopened file whose message this session must not touch, which the
3474/// carried extension re-emits byte for byte.
3475///
3476/// INVARIANT: the sections read are handed to [`FileAllocator`] and tracked
3477/// there alone, so there is one account of the file's free space and not two.
3478/// What stays here is only what the allocator has no place for: the message to
3479/// write, and the managers' own blocks, which are not free space until the
3480/// close that replaces them frees them.
3481struct FileSpaceState {
3482 /// The message, as read or as the creation options declared it. It is the
3483 /// only place the manager addresses are recorded, so the close that moves
3484 /// them rewrites this message.
3485 info: FileSpaceInfoMessage,
3486 /// The manager blocks themselves — one header, and one sections block per
3487 /// manager that had any sections. Freed by the close that lays their
3488 /// replacements out, the rule every other superseded structure follows.
3489 /// Empty for a created file, which supersedes nothing.
3490 superseded: Vec<(u64, u64)>,
3491}
3492
3493impl FileSpaceState {
3494 /// Whether this file keeps free-space managers on disk. Both strategies
3495 /// that have managers do — paged aggregation has the same managers plus a
3496 /// large one — while the two aggregator-only strategies and
3497 /// `persist: false` still carry the message with nothing to write into it.
3498 fn records_free_space(&self) -> bool {
3499 self.info.persist
3500 && matches!(
3501 self.info.strategy,
3502 FileSpaceStrategy::FsmAggr | FileSpaceStrategy::Page
3503 )
3504 }
3505}
3506
3507/// One free-space manager that has been given its own two blocks, and the
3508/// sections it will write into them.
3509///
3510/// Produced by
3511/// [`settle_free_space_managers`](Hdf5Writer::settle_free_space_managers).
3512/// Both blocks are ordinary allocations out of the same [`FileAllocator`] the
3513/// rest of the file uses, because upstream's are too:
3514/// `H5FS_vfd_alloc_hdr_and_section_info_if_needed` calls `H5MF_alloc`
3515/// (H5FSsection.c:2352, 2406).
3516struct PlacedManager {
3517 /// Which of the file's managers this is; its message slot names it in the
3518 /// file-space info message.
3519 manager: FreeSpaceManager,
3520 /// Header block address.
3521 hdr_addr: u64,
3522 /// Sections block address.
3523 sect_addr: u64,
3524 /// Bytes the sections block occupies. What the header records as both
3525 /// `sect_size` and `alloc_sect_size`, so an image shorter than the block
3526 /// is padded rather than reported short.
3527 sect_size: u64,
3528 /// The sections this manager records, in serialization order. Filled on
3529 /// the settling round, once no allocation can change them.
3530 sections: Vec<FreeSection>,
3531}
3532
3533/// The manager header for `sections`, before its own blocks have addresses.
3534///
3535/// Every width the section encoding uses comes from here, and the only one
3536/// that varies with the content is `serial_sections` — it decides how many
3537/// bytes a per-size run count takes — so sizing a layout and encoding it must
3538/// go through this one function or the two disagree.
3539fn manager_header(sections: &[FreeSection]) -> FreeSpaceHeader {
3540 FreeSpaceHeader {
3541 client: free_space::CLIENT_FILE,
3542 total_space: sections.iter().map(|s| s.len).sum(),
3543 total_sections: sections.len() as u64,
3544 // Every class the file client registers is serializable; only a
3545 // fractal heap's manager has ghost sections.
3546 serial_sections: sections.len() as u64,
3547 ghost_sections: 0,
3548 nclasses: free_space::FILE_SECT_CLASSES,
3549 shrink_percent: free_space::SHRINK_PERCENT,
3550 expand_percent: free_space::EXPAND_PERCENT,
3551 max_sect_addr: free_space::SEC2_MAX_SECT_ADDR,
3552 max_sect_size: free_space::SEC2_MAXADDR,
3553 sect_addr: UNDEF_ADDR,
3554 sect_size: 0,
3555 alloc_sect_size: 0,
3556 }
3557}
3558
3559impl Default for CarriedExtension {
3560 /// What a file with no extension carries: nothing to free, nothing to
3561 /// re-emit, and no address until a shared-message table gives it one.
3562 fn default() -> Self {
3563 Self {
3564 superseded: Vec::new(),
3565 carried: Vec::new(),
3566 addr: Slot::new(None),
3567 }
3568 }
3569}
3570
3571/// Where a file's superblock version comes from — the two cases libhdf5 keeps
3572/// strictly apart, and this writer's single source for the version it writes
3573/// back.
3574///
3575/// INVARIANT: reopening a file never changes its superblock version.
3576///
3577/// libhdf5 splits the same way. `H5F__super_init` is the only place a version
3578/// is *decided* — content first, then `MAX(super_vers,
3579/// HDF5_superblock_ver_bounds[low_bound])` (H5Fsuper.c:1128-1154).
3580/// `H5F__super_read` never recomputes one. Nor does it bound anything by it:
3581/// the structures a session appends are written at the bound the caller
3582/// named, or the writer's default, whatever version the superblock has.
3583/// libhdf5 1.14 raised a reopened file's low bound to the row its superblock
3584/// version belongs to; libhdf5 2.0 dropped that (HDFGroup/hdf5#4939), and the
3585/// one raise left is SWMR write access, to `H5F_LIBVER_V110`
3586/// (H5Fsuper.c:453), which [`reject_swmr`](Hdf5Writer::reject_swmr) asks of
3587/// the caller instead. One direction only: the bound decides a created
3588/// file's version, the version never decides the bound.
3589///
3590/// Two variants rather than one number with a rule attached, because the
3591/// number means different things on the two paths — a floor to raise on the
3592/// create path, a fixed value on the reopen path — and a single field would
3593/// have every reader re-derive which.
3594#[derive(Debug, Clone, Copy)]
3595enum SuperblockVersion {
3596 /// A file this writer created. The version its creation options start
3597 /// from, which [`superblock_version_for`](Hdf5Writer::superblock_version_for)
3598 /// raises to what the content and the named bound need.
3599 Chosen(u8),
3600 /// A file this writer reopened: the version already in the file, written
3601 /// back unchanged. `Existing(0..=1)` and `Hdf5Writer::legacy` say the same
3602 /// thing from two directions and cannot disagree: `open_append_with_locking`
3603 /// builds the `LegacyFile` from exactly those versions.
3604 Existing(u8),
3605}
3606
3607/// A registry entry that has held some name.
3608///
3609/// Datasets, groups and committed datatypes keep stable indices — their
3610/// registries only grow, deletion being a flag — so the index can name the
3611/// exact entry. The link registries shrink as links are unlinked, and a
3612/// link's path is derived from its parent group's current name, so for those
3613/// the index records only that the kind once claimed the name and the (short)
3614/// list itself answers.
3615#[derive(Clone, Copy, PartialEq, Eq)]
3616enum NameHit {
3617 Dataset(usize),
3618 Group(usize),
3619 Datatype(usize),
3620 HardLink,
3621 SymbolicLink,
3622 PreservedLink,
3623}
3624
3625/// Which names the file model already holds, so creating an object does not
3626/// have to walk every registry to find out.
3627///
3628/// INVARIANT: while `map` is `Some`, every name a registry entry currently
3629/// holds has an entry in `map` covering that entry. The converse is not
3630/// required: a hit whose object was since deleted, or whose name has since
3631/// changed, stays in the map and is filtered out by
3632/// [`Hdf5Writer::name_holder`], which re-runs the very predicates the linear
3633/// scan used. The index may therefore answer "maybe", never "free" for a name
3634/// that is taken.
3635///
3636/// MUST NOT: no code may give a registry entry a name, or move the path a
3637/// link is emitted under, without either registering the new name through
3638/// [`Hdf5Writer::register_name`] or dropping the index through
3639/// [`Hdf5Writer::forget_name_index`]. State a constructor puts straight into
3640/// the registries needs neither — `map` starts `None`, and the first query
3641/// builds it from the registries as they then stand.
3642struct NameIndex {
3643 map: Option<HashMap<String, Vec<NameHit>>>,
3644 /// Bumped whenever the registries move under a build in flight, so that
3645 /// build's result is discarded instead of being installed stale.
3646 epoch: u64,
3647}
3648
3649impl NameIndex {
3650 fn new() -> Self {
3651 NameIndex {
3652 map: None,
3653 epoch: 0,
3654 }
3655 }
3656
3657 /// Record that `hit` holds `name`. With no map built there is nothing to
3658 /// record, but the registries have moved, so any build in flight is
3659 /// invalidated rather than trusted.
3660 fn insert(&mut self, name: &str, hit: NameHit) {
3661 match self.map.as_mut() {
3662 None => self.epoch += 1,
3663 Some(map) => {
3664 let hits = map.entry(name.to_string()).or_default();
3665 if !hits.contains(&hit) {
3666 hits.push(hit);
3667 }
3668 }
3669 }
3670 }
3671
3672 /// Throw the index away: the next query rebuilds it from the registries.
3673 fn forget(&mut self) {
3674 self.map = None;
3675 self.epoch += 1;
3676 }
3677}
3678
3679/// HDF5 file writer.
3680///
3681/// Usage:
3682/// 1. `Hdf5Writer::create(path)` to create a new file.
3683/// 2. `create_dataset(name, datatype, dims)` to define datasets.
3684/// 3. `write_dataset_raw(index, data)` to write raw data.
3685/// 4. `close()` to finalize the file (writes superblock, headers, etc.).
3686pub struct Hdf5Writer {
3687 handle: FileHandle,
3688 allocator: FileAllocator,
3689 ctx: FormatContext,
3690 /// Dataset registry. The outer [`Slot`] guards the spine (push on create,
3691 /// index/clone on access) and is held only briefly; each [`DatasetRef`]
3692 /// carries one dataset's metadata behind its own lock. A writer clones
3693 /// the `DatasetRef` out (releasing this lock) before doing the long
3694 /// per-dataset work, so a create never blocks an in-flight write.
3695 pub(crate) datasets: Slot<Vec<DatasetRef>>,
3696 /// Group registry, same shape as [`Self::datasets`].
3697 pub(crate) groups: Slot<Vec<GroupRef>>,
3698 /// User-created hard links (additional names for existing objects),
3699 /// resolved and emitted during finalize.
3700 pub(crate) hard_links: Slot<Vec<HardLink>>,
3701 /// User-created soft and external links. Held apart from
3702 /// [`Self::hard_links`] because they name a path rather than an object:
3703 /// nothing resolves them, and no object's reference count counts them.
3704 pub(crate) symbolic_links: Slot<Vec<SymbolicLink>>,
3705 /// Datatypes committed this session, each an object of its own; see
3706 /// [`CommittedDatatype`].
3707 pub(crate) committed_datatypes: Slot<Vec<CommittedDatatype>>,
3708 /// Links a reopened file held that this writer cannot express, carried
3709 /// through every header rewrite by their encoded bytes. Always empty for
3710 /// a freshly created file; see [`PreservedLink`].
3711 pub(crate) preserved_links: Slot<Vec<PreservedLink>>,
3712 /// Which names the registries above already hold; see [`NameIndex`].
3713 /// Boxed so this side table costs the writer one pointer: inline, its
3714 /// map shifted every field after it and cost the attribute path ~5%.
3715 name_index: Slot<Box<NameIndex>>,
3716 /// Attributes attached to the root group (file-level attributes).
3717 pub(crate) root_attributes: Slot<Vec<crate::format::messages::attribute::AttributeEntry>>,
3718 /// Serializes object creation so name-uniqueness check and registry insert
3719 /// happen atomically.
3720 ///
3721 /// INVARIANT: no two emitted links share a full-path name. Under
3722 /// `threadsafe`, create methods run on the shared read guard, so without
3723 /// this gate two threads could both pass the duplicate-name check (which
3724 /// snapshots a registry and drops its lock) and both push, writing an
3725 /// invalid HDF5 file with two same-named links. A create holds this lock
3726 /// across its check *and* its push; the streaming write path never takes
3727 /// it, so writes to existing datasets stay fully concurrent. It is the
3728 /// outermost lock a create acquires (create_lock → spine → slot), and no
3729 /// write path takes it, so it cannot deadlock with the registry locks.
3730 pub(crate) create_lock: Slot<()>,
3731 /// The low `H5Pset_libver_bounds` bound the *caller named*, or `None`
3732 /// when none was: the oldest libhdf5 the objects this writer creates must
3733 /// stay readable by. It is the one switch the version-bearing messages
3734 /// read — the datatype message version (`H5O_dtype_ver_bounds`), the data
3735 /// layout message version (`H5O_layout_ver_bounds`) and with it the chunk
3736 /// index, and the superblock floor (`HDF5_superblock_ver_bounds`) of a
3737 /// file this writer creates.
3738 ///
3739 /// `None` is not `Some(Earliest)`. No single libhdf5 bound describes this
3740 /// crate's default file: it takes the earliest row of the datatype and
3741 /// superblock tables (version-1 datatypes, a version-2 superblock raised
3742 /// to 3 only by what the content needs) over the v1.10 chunk indexes,
3743 /// which is the `H5F_LIBVER_V110` row of the layout table. Naming a bound
3744 /// asks for one whole libhdf5 generation instead, so the two cannot share
3745 /// a field.
3746 ///
3747 /// Nothing reads this directly:
3748 /// [`session_libver`](Hdf5Writer::session_libver) is the only reader, and
3749 /// it is where `None` becomes the default of the family asking, the same
3750 /// on a created file and a reopened one: the superblock a reopened file
3751 /// already has says nothing about the bound (see [`SuperblockVersion`]).
3752 libver: Option<LibverBound>,
3753 closed: bool,
3754 /// Set once `finalize_for_swmr` has published a readable file.
3755 ///
3756 /// A SWMR reader may hold a chunk index that still points at a block this
3757 /// writer has since replaced, so from that point on a relocated chunk's
3758 /// old block is kept rather than released for reuse — the same rule as
3759 /// libhdf5's `H5D__chunk_file_alloc`, which skips `H5MF_xfree` under
3760 /// `H5F_ACC_SWMR_WRITE`.
3761 swmr_active: bool,
3762 /// Collections with free space — libhdf5's `f->shared->cwfs` list. A
3763 /// vlen insert fills these partially-filled collection blocks before
3764 /// creating a new one, so many small writes share 4096-byte blocks
3765 /// instead of each taking their own. Entries hold `(addr, block size,
3766 /// free bytes)` hints; the block on disk stays the single truth for
3767 /// contents, and only the two functions that rewrite collection blocks
3768 /// ([`insert_vlen_objects`](Self::insert_vlen_objects) and
3769 /// [`release_vlen_references`](Self::release_vlen_references)) may
3770 /// update this list. In-memory only, like the allocator's free list:
3771 /// a reopened file's free space is rediscovered as releases touch its
3772 /// collections. Capped at [`H5HG_NCWFS`] entries.
3773 cwfs: Slot<Vec<CwfsEntry>>,
3774 /// Address of the root group object header (set after first finalize).
3775 root_group_addr: Option<u64>,
3776 /// Size of the encoded root group object header (for in-place rewrites).
3777 /// The on-disk root header block a reopen found, `(addr, len)`, so
3778 /// finalize can free the block its rewrite supersedes.
3779 superseded_root_header: crate::io::object_header_io::HeaderBlocks,
3780 /// Where this file's superblock version comes from. The single owner of
3781 /// the reopen invariant — see [`SuperblockVersion`] and
3782 /// [`superblock_version_for`](Self::superblock_version_for).
3783 superblock_version: SuperblockVersion,
3784 /// Objects whose attributes this finalize spilled to dense storage, and
3785 /// the `Attribute Info` message naming what was written for each.
3786 ///
3787 /// INVARIANT: an entry exists here only after every block of that
3788 /// object's heap and name index is on disk, and only
3789 /// [`prepare_dense_attributes`](Self::prepare_dense_attributes) may add
3790 /// one. `emit_attributes` reads it and never builds — a header is sized
3791 /// and then written by two separate `build_*_header` calls, so a build
3792 /// that allocated would allocate twice and leave the sized-for blocks
3793 /// stranded.
3794 dense_attributes: Slot<HashMap<AttrScope, AttributeInfoMessage>>,
3795 /// Groups whose links this finalize spilled to dense storage, and the
3796 /// `Link Info` message naming what was written for each.
3797 ///
3798 /// INVARIANT: an entry exists here only after every block of that group's
3799 /// heap and name index is on disk, and only
3800 /// [`prepare_dense_links`](Self::prepare_dense_links) may add one.
3801 dense_links: Slot<HashMap<LinkScope, LinkInfoMessage>>,
3802 /// The dense storage the reopened object headers already name — the heaps
3803 /// and indices this session's rewrites and deletes supersede.
3804 ///
3805 /// `None` for a file this session created: every block such a file will
3806 /// hold was allocated here, so there is nothing on disk to supersede and
3807 /// nothing to allocate for the bookkeeping either.
3808 ///
3809 /// INVARIANT: every entry is freed exactly once, by
3810 /// [`release_superseded_dense_attrs`](Self::release_superseded_dense_attrs)
3811 /// or [`release_superseded_dense_links`](Self::release_superseded_dense_links),
3812 /// which remove it as they free. Nothing else may remove one: an entry
3813 /// that leaves without reaching the allocator is a leaked heap, and one
3814 /// that reaches it twice hands the same blocks to two objects.
3815 superseded_dense: Slot<Option<Box<SupersededDense>>>,
3816 /// The creation-order policy in force: whether an object created from
3817 /// now on records creation order for its links and its attributes. The
3818 /// h5py `track_order` analogue; see
3819 /// [`set_track_order`](Self::set_track_order). Each object captures this
3820 /// at creation, so changing it never rewrites an object already made.
3821 track_order: TrackOrder,
3822 /// Whether an object created from now on records the times its header can
3823 /// hold — `H5Pset_obj_track_times`, whose default is on
3824 /// (`H5O_CRT_OHDR_FLAGS_DEF` is `H5O_HDR_STORE_TIMES`, H5Opkg.h:74).
3825 /// Captured by each object at creation for the same reason
3826 /// [`track_order`](Self::track_order) is: it belongs to the creation
3827 /// property list, so a later change must not rewrite an object already
3828 /// made.
3829 track_times: bool,
3830 /// The root group's own captured policy. The root is created with the
3831 /// file, so its value comes from
3832 /// [`create_with_options`](Self::create_with_options) — or, on reopen,
3833 /// from the header already on disk.
3834 root_track_order: TrackOrder,
3835 /// The root group's stored times, on the same terms as
3836 /// [`GroupInfo::times`]: whatever a reopened file's root header had, and
3837 /// `None` for a file this writer created.
3838 root_times: Option<ObjectTimes>,
3839 /// Hands out the creation sequence numbers that order a group's links.
3840 next_creation_seq: Slot<u64>,
3841 /// Object-reference elements waiting for their target's object header
3842 /// address, which only exists once finalize has placed every header.
3843 pending_object_references: Slot<Vec<PendingObjectReference>>,
3844 /// Heap-backed reference objects waiting for the same address — the
3845 /// pre-1.12 region form and every 1.12 form whose element is a blob id.
3846 pending_heap_references: Slot<Vec<PendingHeapReference>>,
3847 /// What each object-reference attribute's value *means*, so
3848 /// [`object_attributes`](Hdf5Writer::object_attributes) can say it in
3849 /// addresses every time an object header is built.
3850 attribute_references: Slot<Vec<AttributeReferenceValue>>,
3851 /// Set when this file is in the classic (version-0/1 superblock) format,
3852 /// whether it was reopened in it or created at `H5F_LIBVER_EARLIEST`.
3853 /// See [`LegacyFile`]; [`is_legacy`](Self::is_legacy) is the only reader
3854 /// of whether it is there.
3855 legacy: Option<Box<LegacyFile>>,
3856 /// Which groups keep their links in a symbol table, and the storage each
3857 /// of them has. Empty for a file whose groups all store links in messages;
3858 /// see [`SymbolTables`], which owns the question.
3859 symbol_tables: SymbolTables,
3860 /// The v1 B-tree "K" ranks every node width in this file is derived from,
3861 /// after the superblock extension has had its say. A property of the file
3862 /// rather than of its generation: a version-2 superblock records no ranks
3863 /// of its own but its extension may, and a rewrite that used the library
3864 /// defaults there would write nodes of the wrong width.
3865 /// [`btree_v1_config`](Hdf5Writer::btree_v1_config) is the only reader.
3866 btree: BTreeV1Config,
3867 /// The superblock extension this file carries, and where the replacement
3868 /// went; see [`CarriedExtension`].
3869 extension: Box<CarriedExtension>,
3870 /// The free-space managers a reopened `persist: true` file carries; see
3871 /// [`FileSpaceState`]. `None` for every other file — one with no
3872 /// file-space info message, one that does not persist, one under paged
3873 /// aggregation, and every file this session created — and those files get
3874 /// no free-space manager written either.
3875 free_space: Option<Box<FileSpaceState>>,
3876 /// The file's shared-message indexes, when it was created with any.
3877 /// `None` — the default — is a file with no shared-message table, where
3878 /// [`share_message`](Self::share_message) is the identity.
3879 sohm: Option<Box<SohmState>>,
3880 /// The directory holding this HDF5 file, resolved once when it was opened
3881 /// — libhdf5's `H5F_t::extpath`, and the same value the read side keeps.
3882 /// External raw-data file names are joined against it when
3883 /// `HDF5_EXTFILE_PREFIX` names `${ORIGIN}`, so a write and a later read of
3884 /// the same dataset must resolve a relative name identically; capturing it
3885 /// at open time rather than reading the process's current directory per
3886 /// write is what makes that hold.
3887 source_dir: PathBuf,
3888}
3889
3890/// A file's shared object header messages, from creation to the table on disk.
3891///
3892/// INVARIANT: a message body reaches the file either literally or as a pointer
3893/// to exactly one heap object, never both, and the reference count of that
3894/// object is the number of headers that hold the pointer.
3895/// [`share_message`](Hdf5Writer::share_message) is the only place a body is
3896/// offered to an index, and
3897/// [`prepare_shared_messages`](Hdf5Writer::prepare_shared_messages) is the
3898/// only place the phase changes — so counting and substituting are two passes
3899/// over the same call site rather than two pieces of logic that must agree.
3900struct SohmState {
3901 /// The indexes the file was created with, in table order.
3902 indexes: Vec<SohmIndexSpec>,
3903 /// What `share_message` does to an eligible message right now.
3904 phase: Slot<SohmPhase>,
3905 /// Address of the master table this session laid out, once it has one.
3906 /// Also the once-only latch on the layout: a second finalize keeps the
3907 /// table the first one published, and
3908 /// [`Hdf5Writer::write_superblock_extension`] reads it to name that table
3909 /// in the extension.
3910 table_addr: Slot<Option<u64>>,
3911 /// The blocks the table a reopen found occupies — the master table and
3912 /// each index's heap and index structure — taken by the finalize that
3913 /// replaces them. Empty for a file this session created.
3914 ///
3915 /// The table is laid out whole from the whole message set, so a reopen
3916 /// replaces it rather than inserting into it, and every header holding a
3917 /// pointer into the old one is rewritten in the same finalize
3918 /// ([`Hdf5Writer::rebuilds_shared_messages`]).
3919 superseded: Slot<Vec<(u64, u64)>>,
3920}
3921
3922/// The passes `share_message` runs in, and the state between them.
3923enum SohmPhase {
3924 /// Outside a finalize: every message stays literal.
3925 Idle,
3926 /// Measuring headers, before the bodies they will hold are final. A
3927 /// shareable message answers at the width of a heap pointer over a heap
3928 /// object that does not exist yet, which is the width the one it ends up
3929 /// pointing at has: a `H5O_shared_t` in heap form is the same size
3930 /// whatever it names. Nothing this pass produces is written — it exists so
3931 /// [`allocate_object_headers`](Hdf5Writer::allocate_object_headers) can
3932 /// reserve a block for a header whose messages are shared before the
3933 /// content phase has decided which heap object each one shares.
3934 ///
3935 /// The set is [`FirstCopies`], and it is why this pass has state at all:
3936 /// a message left literal is *wider* than a pointer, so a header can only
3937 /// be measured by making the same first-copy decision the substituting
3938 /// pass will make.
3939 Predict(FirstCopies),
3940 /// Counting the bodies the file will share. Messages still go in
3941 /// literally, so nothing this pass builds is written.
3942 Collect(SohmCollector),
3943 /// Substituting. A body the collect pass never saw stays literal, which
3944 /// is a valid file: the record it would have shared simply keeps a
3945 /// reference count one higher than the pointers that reach it.
3946 Resolve {
3947 /// Heap ID per body, from the table this finalize laid out.
3948 ids: HashMap<(u8, Vec<u8>), [u8; SOHM_HEAP_ID_LEN]>,
3949 /// The first copies this pass has already handed out; see
3950 /// [`FirstCopies`].
3951 first: FirstCopies,
3952 },
3953}
3954
3955/// The bodies a pass has already left literal in the header that offered them
3956/// first (`H5SM_IN_OH`, H5SM.c:1400-1417).
3957///
3958/// INVARIANT: the three passes walk the same object headers in the same order
3959/// — [`allocate_object_headers`](Hdf5Writer::allocate_object_headers),
3960/// [`prepare_shared_messages`](Hdf5Writer::prepare_shared_messages) and
3961/// [`write_object_headers`](Hdf5Writer::write_object_headers) each build every
3962/// dataset in `datasets` order, then every group, then the root — so "the
3963/// header that offered this body first" is the same header in all three. Each
3964/// pass keeps its own set rather than sharing one, so a pass that does not run
3965/// cannot leave a stale decision behind for the next one. A divergence would
3966/// make a header wider than the block reserved for it, which
3967/// [`check_header_size`] refuses rather than writing.
3968type FirstCopies = std::collections::HashSet<(u8, Vec<u8>)>;
3969
3970/// The object header a message is being written into — `H5SM_try_share`'s
3971/// `open_oh` argument, which is what decides whether a first copy has a header
3972/// to stay literal in at all.
3973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3974enum ShareOwner {
3975 /// `H5SM_try_share(f, NULL, ...)`: the message belongs to no object header
3976 /// of its own. An attribute's datatype and dataspace are offered this way
3977 /// (H5Aint.c:375-377) — they live inside the attribute's body, so there is
3978 /// no header message for a record to name and the body goes to the heap on
3979 /// first use however shareable its class is.
3980 Detached,
3981 /// `H5SM_try_share(f, oh, ...)`: the message is a message of the object
3982 /// header at this address (`H5O__msg_alloc`, H5Omessage.c:1735).
3983 Header(u64),
3984}
3985
3986impl SohmState {
3987 /// A file's indexes, plus the blocks of the table they were read out of
3988 /// when the file was reopened (empty when it was created this session).
3989 fn new(indexes: Vec<SohmIndexSpec>, superseded: Vec<(u64, u64)>) -> Self {
3990 Self {
3991 indexes,
3992 phase: Slot::new(SohmPhase::Idle),
3993 table_addr: Slot::new(None),
3994 superseded: Slot::new(superseded),
3995 }
3996 }
3997
3998 /// The index that would take a `msg_type` message of `body_len` bytes,
3999 /// as `H5SM_try_share` resolves one: the first index whose type mask
4000 /// covers the class, and then only if the message reaches that index's
4001 /// minimum. A message too small for its index is not offered to another —
4002 /// `H5SM__get_index` picks by type alone and the size check comes after.
4003 fn index_for(&self, msg_type: u8, body_len: usize) -> Option<usize> {
4004 let flag = type_flag(msg_type)?;
4005 let (at, spec) = self
4006 .indexes
4007 .iter()
4008 .enumerate()
4009 .find(|(_, spec)| spec.mesg_types & flag != 0)?;
4010 (body_len as u64 >= u64::from(spec.min_mesg_size)).then_some(at)
4011 }
4012
4013 /// Whether any index takes attribute messages, which is what makes the
4014 /// file record message creation indices — `H5SM_init` sets
4015 /// `store_msg_crt_idx` on exactly this condition (H5SM.c:220).
4016 fn shares_attributes(&self) -> bool {
4017 let Some(flag) = type_flag(MSG_ATTRIBUTE) else {
4018 return false;
4019 };
4020 self.indexes.iter().any(|spec| spec.mesg_types & flag != 0)
4021 }
4022}
4023
4024/// What decides whether two offers are the same shared message: the class,
4025/// the bytes, and the messages the bytes will end up pointing at.
4026type CollectedKey = (u8, Vec<u8>, Vec<NestedShare>);
4027
4028/// The shareable message bodies of one collect pass, in first-seen order.
4029struct SohmCollector {
4030 /// Per index, its bodies with the number of headers holding each.
4031 messages: Vec<Vec<SharedMessage>>,
4032 /// Where a body sits: `(index, position in that index's messages)`, keyed
4033 /// by everything that decides what will be stored — the class, the bytes,
4034 /// and the messages the bytes will end up pointing at.
4035 seen: HashMap<CollectedKey, (usize, usize)>,
4036}
4037
4038impl SohmCollector {
4039 fn new(nindexes: usize) -> Self {
4040 Self {
4041 messages: vec![Vec::new(); nindexes],
4042 seen: HashMap::new(),
4043 }
4044 }
4045
4046 /// Count one message against `index`, adding the body the first time it
4047 /// is seen, and say whether that body is new.
4048 ///
4049 /// `ohdr` is the header this offer would leave the body literal in when it
4050 /// is the first — `None` when the class cannot be shared in an object
4051 /// header or the offer names none. It is recorded only for a first copy:
4052 /// once a body is in the heap, later offers of it are pointers whatever
4053 /// header they come from.
4054 ///
4055 /// Two bodies are the same message only if their nesting agrees as well:
4056 /// the heap IDs a nesting body will hold are still zero here, so two
4057 /// attributes that differ only in their datatype are the same bytes at
4058 /// this point and different bytes on disk.
4059 fn record(
4060 &mut self,
4061 index: usize,
4062 msg_type: u8,
4063 body: &[u8],
4064 nested: &[NestedShare],
4065 ohdr: Option<u64>,
4066 ) -> bool {
4067 let key = (msg_type, body.to_vec(), nested.to_vec());
4068 match self.seen.get(&key) {
4069 Some(&(at, pos)) => {
4070 self.messages[at][pos].ref_count += 1;
4071 false
4072 }
4073 None => {
4074 let pos = self.messages[index].len();
4075 self.messages[index].push(SharedMessage {
4076 msg_type,
4077 body: body.to_vec(),
4078 nested: nested.to_vec(),
4079 ref_count: 1,
4080 ohdr_addr: ohdr,
4081 });
4082 self.seen.insert(key, (index, pos));
4083 true
4084 }
4085 }
4086 }
4087
4088 /// Give back the reference [`record`](Self::record) took for a body whose
4089 /// container turned out to be a copy of one already here.
4090 ///
4091 /// A body reached only through a shared container is referenced once per
4092 /// container *record*, not once per object that has one: the pointer to
4093 /// it lives in the container's heap object, which exists once however
4094 /// many headers name it. `H5O__attr_create` reaches the same count from
4095 /// the other side, by building each attribute's components shared and
4096 /// then calling `H5O__attr_delete` — which decrements exactly the
4097 /// datatype and dataspace (H5Oattr.c:568-585) — whenever the attribute it
4098 /// built was not the first copy (H5Oattribute.c:331-366).
4099 fn release(&mut self, msg_type: u8, body: &[u8]) {
4100 if let Some(&(at, pos)) = self.seen.get(&(msg_type, body.to_vec(), Vec::new())) {
4101 let count = &mut self.messages[at][pos].ref_count;
4102 *count = count.saturating_sub(1);
4103 }
4104 }
4105}
4106
4107/// The file-creation properties a brand-new file is made with.
4108///
4109/// libhdf5 splits these across the file creation and file access property
4110/// lists (`H5Pset_userblock`, `H5Pset_link_creation_order`,
4111/// `H5Pset_libver_bounds`, the locking property); what they have in common is
4112/// that they are read once, when the file is created, and cannot be changed
4113/// afterwards without rewriting it. Options that *can* change mid-session —
4114/// the bound for objects created later, the creation-order policy for later
4115/// objects — have their own setters.
4116#[derive(Debug, Clone, Copy, Default)]
4117pub struct FileCreateOptions {
4118 /// OS-level locking policy for the new file.
4119 pub locking: crate::io::locking::FileLocking,
4120 /// Creation-order policy for the root group, and the default for every
4121 /// object created afterwards; see [`Hdf5Writer::set_track_order`].
4122 pub track_order: bool,
4123 /// Time-tracking policy for the root group, and the default for every
4124 /// object created afterwards; see [`Hdf5Writer::set_track_times`].
4125 pub track_times: bool,
4126 /// The file's low library-version bound (`H5Pset_libver_bounds`'s `low`),
4127 /// or `None` when the caller named none.
4128 ///
4129 /// The distinction is not decoration. `Some(LibverBound::Earliest)` is a
4130 /// request for the format libhdf5 writes at `H5F_LIBVER_EARLIEST` — a
4131 /// version-0 superblock over symbol-table groups and version-1 object
4132 /// headers, which is what [`ObjectFormat::Legacy`] encodes. `None` keeps
4133 /// what this crate has always written for a file whose creator said
4134 /// nothing: the version-2 superblock and link-message groups of the v1.8
4135 /// format, with the earliest bound's message versions where they can
4136 /// express the content. That combination is this crate's own, not one
4137 /// libhdf5 writes, so it cannot be spelled as a bound.
4138 pub libver: Option<LibverBound>,
4139 /// Bytes reserved in front of the superblock for the application's own
4140 /// use (`H5Pset_userblock`). Zero, the default, places the superblock at
4141 /// offset 0; otherwise a power of two of at least
4142 /// [`MIN_USERBLOCK`] bytes, since a reader finds the
4143 /// superblock by doubling its search offset from there.
4144 pub userblock: u64,
4145 /// Shared object header message indexes; see [`SharedMessageConfig`].
4146 pub shared_messages: SharedMessageConfig,
4147 /// How the file manages its own space; see [`FileSpaceConfig`].
4148 pub file_space: FileSpaceConfig,
4149}
4150
4151/// The file-space handling properties a new file is created with — the three
4152/// arguments of `H5Pset_file_space_strategy` and the one of
4153/// `H5Pset_file_space_page_size`.
4154///
4155/// The four together are what `H5F__super_init` compares against the library
4156/// defaults to decide whether the file needs a file-space info message at all
4157/// (H5Fsuper.c:1092-1097), which is why the page size belongs here even though
4158/// only paged aggregation allocates by it: a file that names a page size and
4159/// nothing else still carries the message.
4160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4161pub struct FileSpaceConfig {
4162 /// `H5F_fspace_strategy_t`.
4163 pub strategy: FileSpaceStrategy,
4164 /// Whether the free-space managers are written to the file on close.
4165 pub persist: bool,
4166 /// The smallest section a manager records; a block freed below it is
4167 /// space the file leaks rather than tracks.
4168 pub threshold: u64,
4169 /// `H5Pset_file_space_page_size`: the file-space page every allocation of
4170 /// a paged file is shaped by, and the value the message carries whatever
4171 /// the strategy.
4172 pub page_size: u64,
4173}
4174
4175impl Default for FileSpaceConfig {
4176 /// `H5F_FILE_SPACE_STRATEGY_DEF`, `H5F_FREE_SPACE_PERSIST_DEF`,
4177 /// `H5F_FREE_SPACE_THRESHOLD_DEF` and `H5F_FILE_SPACE_PAGE_SIZE_DEF`
4178 /// (H5Fprivate.h:326-336).
4179 fn default() -> Self {
4180 Self {
4181 strategy: FileSpaceStrategy::FsmAggr,
4182 persist: false,
4183 threshold: 1,
4184 page_size: DEFAULT_FILE_SPACE_PAGE_SIZE,
4185 }
4186 }
4187}
4188
4189impl FileSpaceConfig {
4190 /// The properties as `H5P__set_file_space_strategy` (H5Pfcpl.c:1176)
4191 /// stores them: `persist` and `threshold` are set only for the two
4192 /// strategies that have free-space managers to persist, and keep their
4193 /// defaults for the two that do not.
4194 pub fn new(strategy: FileSpaceStrategy, persist: bool, threshold: u64) -> Self {
4195 let uses_managers = matches!(
4196 strategy,
4197 FileSpaceStrategy::FsmAggr | FileSpaceStrategy::Page
4198 );
4199 Self {
4200 strategy,
4201 persist: uses_managers && persist,
4202 threshold: if uses_managers {
4203 threshold
4204 } else {
4205 Self::default().threshold
4206 },
4207 ..Self::default()
4208 }
4209 }
4210
4211 /// `H5Pset_file_space_page_size`, the fourth file-space property and the
4212 /// one libhdf5 sets on its own call.
4213 ///
4214 /// Independent of the strategy, as upstream is: the value reaches the
4215 /// file-space info message whatever the strategy is, and only paged
4216 /// aggregation allocates by it. Out-of-range sizes are refused where the
4217 /// file is created ([`validate`](Self::validate)) rather than here, so a
4218 /// builder chain stays a builder chain.
4219 pub fn with_page_size(mut self, page_size: u64) -> Self {
4220 self.page_size = page_size;
4221 self
4222 }
4223
4224 /// Whether the file has to say any of this on disk. `H5F__super_init`
4225 /// writes the file-space info message only for a file that differs from
4226 /// the library defaults in one of the four properties (H5Fsuper.c:1092),
4227 /// and raises such a file's superblock to version 2 so it has an
4228 /// extension to write it into (H5Fsuper.c:1144).
4229 pub fn is_default(&self) -> bool {
4230 *self == Self::default()
4231 }
4232
4233 /// Refuse what this writer cannot make. `H5Pset_file_space_strategy`
4234 /// itself only refuses a strategy outside the enum (H5Pfcpl.c:1223), and
4235 /// `H5Pset_file_space_page_size` a page size outside `[512, 1 GiB]`
4236 /// (H5Pfcpl.c:1389-1393) — no power of two required, only the bounds.
4237 fn validate(&self) -> IoResult<()> {
4238 if !(PAGE_SIZE_MIN..=PAGE_SIZE_MAX).contains(&self.page_size) {
4239 return Err(crate::io::IoError::InvalidState(format!(
4240 "a file-space page size is between {PAGE_SIZE_MIN} bytes and \
4241 {PAGE_SIZE_MAX}, not {}",
4242 self.page_size
4243 )));
4244 }
4245 match self.strategy {
4246 FileSpaceStrategy::FsmAggr
4247 | FileSpaceStrategy::Aggr
4248 | FileSpaceStrategy::None
4249 | FileSpaceStrategy::Page => Ok(()),
4250 FileSpaceStrategy::Unknown(b) => Err(crate::io::IoError::InvalidState(format!(
4251 "invalid file-space strategy {b}"
4252 ))),
4253 }
4254 }
4255
4256 /// The message a created file carries, before anything is allocated:
4257 /// every manager address undefined and no end-of-allocation recorded,
4258 /// which is what `H5F__super_init` writes (H5Fsuper.c:1369-1382).
4259 fn message(&self) -> FileSpaceInfoMessage {
4260 FileSpaceInfoMessage {
4261 // `H5O_fsinfo_set_version` starts at version 1 and only ever
4262 // raises it, so a created file never carries the version-0 form
4263 // however low its version bounds are.
4264 version: 1,
4265 strategy: self.strategy,
4266 persist: self.persist,
4267 threshold: self.threshold,
4268 page_size: self.page_size,
4269 pgend_meta_thres: 0,
4270 eoa_pre_fsm_fsalloc: UNDEF_ADDR,
4271 fs_addr: vec![UNDEF_ADDR; FS_ADDR_COUNT_V1],
4272 }
4273 }
4274}
4275
4276/// The shared object header message indexes a new file is created with.
4277///
4278/// libhdf5 sets these with three calls on the file creation property list:
4279/// `H5Pset_shared_mesg_nindexes` fixes how many indexes there are,
4280/// `H5Pset_shared_mesg_index` gives each one the message types it covers and
4281/// the smallest message it will take, and `H5Pset_shared_mesg_phase_change`
4282/// sets the list/B-tree thresholds for all of them at once. The default —
4283/// no indexes — is a file with no shared-message table, which is what every
4284/// file this crate wrote before the option existed.
4285#[derive(Debug, Clone, Copy, PartialEq)]
4286pub struct SharedMessageConfig {
4287 /// Indexes in table order; only the first `count` are in use.
4288 indexes: [SohmIndexSpec; MAX_SOHM_INDEXES],
4289 /// How many indexes the caller asked for. Kept even when it is more than
4290 /// the array holds, so file creation can refuse the count the way
4291 /// `H5Pset_shared_mesg_nindexes` does rather than silently drop indexes.
4292 count: usize,
4293}
4294
4295impl Default for SharedMessageConfig {
4296 fn default() -> Self {
4297 Self {
4298 indexes: [SohmIndexSpec {
4299 mesg_types: 0,
4300 min_mesg_size: 0,
4301 list_max: DEFAULT_SOHM_LIST_MAX,
4302 btree_min: DEFAULT_SOHM_BTREE_MIN,
4303 }; MAX_SOHM_INDEXES],
4304 count: 0,
4305 }
4306 }
4307}
4308
4309impl SharedMessageConfig {
4310 /// One index per `(mesg_types, min_mesg_size)` pair — the arguments
4311 /// `H5Pset_shared_mesg_index` takes, where `mesg_types` is the bit mask
4312 /// [`type_flag`](crate::format::sohm::type_flag) builds — with the
4313 /// file-wide phase change `H5Pset_shared_mesg_phase_change` sets: above
4314 /// `list_max` an index is a v2 B-tree, below `btree_min` it is a list
4315 /// again, and `list_max == 0` makes it a B-tree from its first message.
4316 ///
4317 /// Nothing is validated here; [`Hdf5Writer::create_with_options`] refuses
4318 /// a configuration libhdf5 would refuse, so an invalid one is reported
4319 /// where the file is made rather than where the value is typed.
4320 pub fn new(indexes: &[(u16, u32)], list_max: u16, btree_min: u16) -> Self {
4321 let mut config = Self {
4322 count: indexes.len(),
4323 ..Self::default()
4324 };
4325 for (slot, &(mesg_types, min_mesg_size)) in config.indexes.iter_mut().zip(indexes) {
4326 *slot = SohmIndexSpec {
4327 mesg_types,
4328 min_mesg_size,
4329 list_max,
4330 btree_min,
4331 };
4332 }
4333 config
4334 }
4335
4336 /// The indexes in use, in table order.
4337 pub(crate) fn specs(&self) -> &[SohmIndexSpec] {
4338 &self.indexes[..self.count.min(MAX_SOHM_INDEXES)]
4339 }
4340
4341 /// Refuse a configuration `H5Pset_shared_mesg_nindexes` or
4342 /// `H5Pset_shared_mesg_phase_change` would refuse.
4343 fn validate(&self) -> IoResult<()> {
4344 if self.count > MAX_SOHM_INDEXES {
4345 return Err(crate::io::IoError::InvalidState(format!(
4346 "a file may declare at most {MAX_SOHM_INDEXES} shared-message \
4347 indexes, not {}",
4348 self.count
4349 )));
4350 }
4351 for spec in self.specs() {
4352 // The two thresholds must not overlap, or an index would convert
4353 // back and forth on every insert.
4354 if u32::from(spec.btree_min) > u32::from(spec.list_max) + 1 {
4355 return Err(crate::io::IoError::InvalidState(format!(
4356 "shared-message phase change needs btree_min ({}) at most one \
4357 past list_max ({}), or an index converts on every insert",
4358 spec.btree_min, spec.list_max
4359 )));
4360 }
4361 if spec.mesg_types == 0 {
4362 return Err(crate::io::IoError::InvalidState(
4363 "a shared-message index covering no message type would never \
4364 be used; give it a type mask or drop it"
4365 .into(),
4366 ));
4367 }
4368 }
4369 Ok(())
4370 }
4371}
4372
4373/// One object-reference element written before its value could be known.
4374///
4375/// An `H5R_OBJECT1` element is the target's object header address, and
4376/// addresses are assigned during finalize, so a write records the target by
4377/// path here and [`Hdf5Writer::write_object_reference_values`] puts the address
4378/// down once every header has one.
4379pub(crate) struct PendingObjectReference {
4380 /// Dataset holding the element.
4381 dataset: usize,
4382 /// Element index within that dataset.
4383 element: u64,
4384 /// Path of the object the element names; `/` is the root group.
4385 target: String,
4386}
4387
4388/// One heap-backed reference object written before its target's address could
4389/// be known.
4390///
4391/// The *element* of a `H5R_DATASET_REGION1`, and of every 1.12 reference whose
4392/// encoding does not fit inline, is final at write time — it is the global-heap
4393/// id of the object the write inserted. What waits is the `sizeof_addr` bytes
4394/// of that heap object holding the target's object header address, which
4395/// [`Hdf5Writer::write_heap_reference_values`] stamps in.
4396pub(crate) struct PendingHeapReference {
4397 /// Address of the global-heap collection holding the object.
4398 collection: u64,
4399 /// The object's index within that collection.
4400 index: u16,
4401 /// Where the target's token sits inside that object. The pre-1.12 region
4402 /// form leads with it (`H5R__encode_token_region_compat`); every 1.12 form
4403 /// puts the token's length byte first (`H5R__encode_obj_token`).
4404 token_offset: usize,
4405 /// What the reference names, and how strictly its path must resolve.
4406 target: PendingHeapTarget,
4407}
4408
4409/// What the path of a heap-backed reference must resolve to.
4410///
4411/// The two rules `H5R` applies: a region reference names a *dataset*, since
4412/// `H5Rcreate_region` takes one dataset's dataspace and every reader
4413/// dereferences it as one, while an attribute reference names the attribute's
4414/// owner, which `H5Rcreate_attr` lets be any object.
4415#[derive(Debug, Clone)]
4416pub(crate) enum PendingHeapTarget {
4417 Dataset(String),
4418 Object(String),
4419}
4420
4421/// The value of an attribute whose elements are object references, kept as
4422/// what it means rather than as what it encodes to.
4423///
4424/// An attribute's value is part of its object header message, so it cannot be
4425/// stamped after the fact the way a dataset element can — the header is one
4426/// block, written once. What is stored instead is the paths, and
4427/// [`Hdf5Writer::object_attributes`] turns them into addresses every time the
4428/// attribute set is built: the measuring pass reads the zeros of objects that
4429/// have no address yet, the content pass reads the addresses the file will
4430/// have, and the two agree in length because an address is a fixed-width
4431/// field. The entry in the object's attribute list carries an image with
4432/// zeros where the addresses go and is never itself written.
4433///
4434/// The address of `targets[i]` lands at byte `i * stride` of that image: the
4435/// whole element when the attribute is an array of references, the leading
4436/// member when each element is a compound that carries other fields beside
4437/// the reference (`REFERENCE_LIST`'s `dimension`), which the stored image
4438/// already holds.
4439pub(crate) struct AttributeReferenceValue {
4440 /// The object the attribute hangs on.
4441 scope: AttrScope,
4442 /// The attribute's name within that object.
4443 name: String,
4444 /// Paths of the objects the elements name, in element order; `/` is the
4445 /// root group.
4446 targets: Vec<String>,
4447 /// Bytes from one element's address to the next: the element size.
4448 stride: usize,
4449}
4450
4451/// The attribute naming the scales attached to each axis of a dataset.
4452pub(crate) const DIMENSION_LIST: &str = "DIMENSION_LIST";
4453/// The attribute naming every (dataset, axis) a dimension scale is attached to.
4454pub(crate) const REFERENCE_LIST: &str = "REFERENCE_LIST";
4455/// The `CLASS` a dimension scale carries.
4456const DIMENSION_SCALE_CLASS: &str = "DIMENSION_SCALE";
4457
4458/// A dataset's `CLASS` attribute as `H5DS` reads it.
4459enum ClassAttr {
4460 /// A fixed-length string, with what `H5DSis_scale` checks beside the text.
4461 Fixed {
4462 size: u32,
4463 null_terminated: bool,
4464 text: String,
4465 },
4466 /// A variable-length string.
4467 VarLen(String),
4468 /// Not a string at all.
4469 NotString,
4470}
4471
4472/// `bytes` read as a C string: everything before the first NUL.
4473fn c_string(bytes: &[u8]) -> String {
4474 let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
4475 String::from_utf8_lossy(&bytes[..end]).into_owned()
4476}
4477
4478/// Refuse an object header body that is not the length its block was reserved
4479/// at.
4480///
4481/// The one check standing behind
4482/// [`HeaderLayout`]'s premise that measuring a header before its content is
4483/// final gives the same length as encoding it after. `what` names the object
4484/// only when the check fails, so the caller pays for the lookup only then.
4485fn check_header_size(
4486 encoded: &[u8],
4487 reserved: usize,
4488 what: impl FnOnce() -> String,
4489) -> IoResult<()> {
4490 if encoded.len() == reserved {
4491 return Ok(());
4492 }
4493 Err(crate::io::IoError::InvalidState(format!(
4494 "the object header of {} encodes to {} bytes but was measured at {}; \
4495 a message in it changed length once the addresses it names were known",
4496 what(),
4497 encoded.len(),
4498 reserved
4499 )))
4500}
4501
4502/// Where one object header goes: chunk 0's block and, when the header does
4503/// not fit it, a continuation block of its own.
4504///
4505/// Produced by [`Hdf5Writer::place_header`] and consumed by
4506/// [`Hdf5Writer::encode_header_in`]; between the two, everything the header
4507/// names is built against the address it records. The sizes travel with the
4508/// addresses because they are what the blocks were reserved at: the writing
4509/// pass checks each image against them rather than trusting that the two
4510/// passes agreed.
4511#[derive(Debug, Clone, Copy)]
4512struct HeaderPlacement {
4513 /// Chunk 0's address.
4514 addr: u64,
4515 /// Bytes reserved at `addr`. For a fresh header that is the whole image,
4516 /// a continuation chunk included, since one is laid directly behind
4517 /// chunk 0 in the same block.
4518 size: usize,
4519 /// Whether the block is one the object's existing header already
4520 /// occupied, which chunk 0 is then held to the size of; a fresh block is
4521 /// an exact fit.
4522 kept: bool,
4523 /// A continuation block of its own, `(address, size)`: what a kept block
4524 /// too small for every message spills into.
4525 continuation: Option<(u64, usize)>,
4526}
4527
4528impl HeaderPlacement {
4529 /// A block of `size` bytes at `addr` holding the whole header.
4530 fn fresh(addr: u64, size: usize) -> Self {
4531 Self {
4532 addr,
4533 size,
4534 kept: false,
4535 continuation: None,
4536 }
4537 }
4538
4539 /// The placement as the registry records a written header: chunk 0's
4540 /// block, then the continuation block when there is one.
4541 fn blocks(&self) -> crate::io::object_header_io::HeaderBlocks {
4542 std::iter::once((self.addr, self.size as u64))
4543 .chain(self.continuation.map(|(a, s)| (a, s as u64)))
4544 .collect()
4545 }
4546
4547 /// The placement a written header's recorded blocks describe, to write
4548 /// it back over: chunk 0 held to its block, and the continuation chunk,
4549 /// if it has one, to its own.
4550 fn over(blocks: &[(u64, u64)]) -> Option<Self> {
4551 match blocks {
4552 [(addr, size)] => Some(Self {
4553 addr: *addr,
4554 size: *size as usize,
4555 kept: true,
4556 continuation: None,
4557 }),
4558 [(addr, size), (cont, cont_size)] => Some(Self {
4559 addr: *addr,
4560 size: *size as usize,
4561 kept: true,
4562 continuation: Some((*cont, *cont_size as usize)),
4563 }),
4564 _ => None,
4565 }
4566 }
4567}
4568
4569/// Where every object header this finalize writes goes.
4570///
4571/// Produced by [`Hdf5Writer::allocate_object_headers`] and consumed by
4572/// [`Hdf5Writer::write_object_headers`].
4573struct HeaderLayout {
4574 /// `(dataset index, placement)`, in write order.
4575 datasets: Vec<(usize, HeaderPlacement)>,
4576 /// `(group index, placement)`, in write order.
4577 groups: Vec<(usize, HeaderPlacement)>,
4578 /// The root group's placement.
4579 root: HeaderPlacement,
4580}
4581
4582/// The chunk-0 blocks existing object headers keep across a rewrite, by
4583/// object: `(address, length)` of each, as the open-time walk read it.
4584///
4585/// Filled by [`Hdf5Writer::supersede_headers`] from the registry's
4586/// `obj_header_blocks` and consumed by
4587/// [`Hdf5Writer::allocate_object_headers`].
4588#[derive(Default)]
4589struct KeptChunks {
4590 datasets: std::collections::HashMap<usize, (u64, u64)>,
4591 groups: std::collections::HashMap<usize, (u64, u64)>,
4592 root: Option<(u64, u64)>,
4593}
4594
4595/// Refuse a region-reference selection the target dataset's extent does not
4596/// admit — libhdf5's `H5S_select_valid`, which `H5Rcreate` applies before it
4597/// serializes anything.
4598///
4599/// The rank check comes from [`Selection::to_boxes`], which also refuses a
4600/// regular hyperslab with an unlimited count or block; a region reference has
4601/// no growable extent to resolve one against.
4602fn validate_region_selection(selection: &Selection, dims: &[u64], path: &str) -> IoResult<()> {
4603 let boxes = selection.to_boxes(dims).map_err(|e| {
4604 crate::io::IoError::InvalidState(format!("region reference over '{path}': {e}"))
4605 })?;
4606 for (start, count) in boxes {
4607 for (d, (&s, &c)) in start.iter().zip(&count).enumerate() {
4608 if s.checked_add(c).is_none_or(|end| end > dims[d]) {
4609 return Err(crate::io::IoError::InvalidState(format!(
4610 "region reference over '{path}' selects {s}..{} in dimension {d}, \
4611 outside the dataset's extent of {}",
4612 s.saturating_add(c),
4613 dims[d]
4614 )));
4615 }
4616 }
4617 }
4618 Ok(())
4619}
4620
4621/// What a reopen found already on disk in dense form, by the scope whose
4622/// header names it.
4623///
4624/// Both halves together because they are found together — one walk of the
4625/// reopened headers fills both — and released together only in the delete
4626/// path; a finalize supersedes attribute storage before it lays object
4627/// headers out and link storage after, so each half has its own owner.
4628#[derive(Debug, Default)]
4629struct SupersededDense {
4630 attrs: HashMap<AttrScope, AttributeInfoMessage>,
4631 links: HashMap<LinkScope, LinkInfoMessage>,
4632}
4633
4634/// Which object's attribute list a prepared dense layout belongs to.
4635#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4636pub(crate) enum AttrScope {
4637 Root,
4638 Group(usize),
4639 Dataset(usize),
4640}
4641
4642/// Which group's link list a prepared dense layout belongs to.
4643#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4644pub(crate) enum LinkScope {
4645 Root,
4646 Group(usize),
4647}
4648
4649/// Attributes an object header keeps before libhdf5 spills the whole set to
4650/// dense storage (`H5O_CRT_ATTR_MAX_COMPACT_DEF`).
4651const MAX_COMPACT_ATTRS: usize = 8;
4652
4653/// Links `H5G__obj_create_real` sizes a new group's object header for
4654/// (`H5G_CRT_GINFO_EST_NUM_ENTRIES`), and the name length it assumes for each
4655/// (`H5G_CRT_GINFO_EST_NAME_LEN`). Together with the link info and group info
4656/// messages they are the whole of chunk 0 — see
4657/// [`chunk0_capacity`](Hdf5Writer::chunk0_capacity).
4658const EST_LINK_COUNT: usize = 4;
4659/// See [`EST_LINK_COUNT`].
4660const EST_LINK_NAME_LEN: usize = 8;
4661
4662/// Messages a shared-message index keeps in list form before it becomes a v2
4663/// B-tree (`H5F_CRT_SHMSG_LIST_MAX_DEF`).
4664const DEFAULT_SOHM_LIST_MAX: u16 = 50;
4665
4666/// Messages a shared-message B-tree index drops to before it reverts to a
4667/// list (`H5F_CRT_SHMSG_BTREE_MIN_DEF`).
4668const DEFAULT_SOHM_BTREE_MIN: u16 = 40;
4669
4670/// Links a group header keeps before libhdf5 spills the whole set to dense
4671/// storage (`H5G_CRT_GINFO_MAX_COMPACT`). This writer emits no phase-change
4672/// values in the Group Info message, so the default is what applies.
4673const MAX_COMPACT_LINKS: usize = 8;
4674
4675/// Bytes a compact dataset's raw image may occupy.
4676///
4677/// `H5D__compact_construct` bounds it by `H5O_MESG_MAX_SIZE` less the layout
4678/// message's own four bytes (version, class, and the 2-byte data length).
4679/// The constant it subtracts from is 65536, one past what the object header's
4680/// 2-byte message size field can express, so the ceiling here is taken from
4681/// [`MAX_MESSAGE_SIZE`] — the largest message that actually encodes — and is
4682/// one byte below libhdf5's.
4683pub const MAX_COMPACT_DATA: usize = MAX_MESSAGE_SIZE - 4;
4684
4685/// Smallest userblock a file can be created with, and the granularity of
4686/// every larger one: `H5Pset_userblock` takes 0 or a power of two from here
4687/// up, because `H5FD_locate_signature` looks for the superblock at 0 and then
4688/// at this offset doubled repeatedly.
4689pub const MIN_USERBLOCK: u64 = 512;
4690
4691impl Hdf5Writer {
4692 /// Create a new HDF5 file at `path` using the env-var-derived locking
4693 /// policy (controlled by `HDF5_USE_FILE_LOCKING`).
4694 ///
4695 /// The superblock (48 bytes for v3 with 8-byte offsets) is reserved at
4696 /// offset 0 and written during `close()`.
4697 pub fn create(path: &Path) -> IoResult<Self> {
4698 Self::create_with_locking(
4699 path,
4700 crate::io::locking::FileLocking::from_env_or(Default::default()),
4701 )
4702 }
4703
4704 /// Create a new HDF5 file at `path` with an explicit locking policy.
4705 pub fn create_with_locking(
4706 path: &Path,
4707 locking: crate::io::locking::FileLocking,
4708 ) -> IoResult<Self> {
4709 Self::create_with_options(
4710 path,
4711 FileCreateOptions {
4712 locking,
4713 ..Default::default()
4714 },
4715 )
4716 }
4717
4718 /// Create a new HDF5 file at `path` with explicit file-creation options.
4719 pub fn create_with_options(path: &Path, options: FileCreateOptions) -> IoResult<Self> {
4720 let FileCreateOptions {
4721 locking,
4722 track_order,
4723 track_times,
4724 libver,
4725 userblock,
4726 shared_messages,
4727 file_space,
4728 } = options;
4729 shared_messages.validate()?;
4730 file_space.validate()?;
4731 if userblock != 0 && (userblock < MIN_USERBLOCK || !userblock.is_power_of_two()) {
4732 return Err(crate::io::IoError::InvalidState(format!(
4733 "a userblock is {MIN_USERBLOCK} bytes or a power of two above it, \
4734 not {userblock}: a reader locates the superblock by doubling its \
4735 search offset from {MIN_USERBLOCK}, so no other size can hold one"
4736 )));
4737 }
4738 let policy = free_space::SpacePolicy::for_message(&file_space.message());
4739 // `H5F__super_init` (H5Fsuper.c:1182-1192) refuses a userblock that is
4740 // not a whole number of allocation units, which for a paged file is
4741 // the file-space page: everything after the userblock is addressed
4742 // from its end, so a userblock that is not a page multiple would put
4743 // every page boundary off the file's own grid.
4744 if let Some(page) = policy.page() {
4745 if userblock != 0 && userblock % page != 0 {
4746 return Err(crate::io::IoError::InvalidState(format!(
4747 "a paged file's userblock is a multiple of its {page}-byte \
4748 file-space page, not {userblock}"
4749 )));
4750 }
4751 }
4752 let mut handle = FileHandle::create_with_locking(path, locking)?;
4753 if userblock != 0 {
4754 // Written while the handle is still unbased, so offset 0 is the
4755 // start of the file: the block belongs to the application, not to
4756 // the HDF5 address space that begins where it ends. libhdf5 zeroes
4757 // it the same way (`H5F__super_init`), leaving a file whose first
4758 // `userblock` bytes are the application's to overwrite.
4759 handle.write_at(0, &vec![0u8; userblock as usize])?;
4760 handle.set_base(userblock);
4761 }
4762 let ctx = FormatContext::default_v3();
4763
4764 // `H5F_LIBVER_EARLIEST` is the one bound under which libhdf5 writes
4765 // the classic generation — the version-1 rows of every
4766 // message-version table, the symbol-table group form
4767 // (`H5G__obj_create_real`, H5Gobj.c:179) and the version-0 superblock
4768 // row of `HDF5_superblock_ver_bounds`.
4769 //
4770 // Shared object header messages move the last of those three and
4771 // nothing else. Their master table lives in a superblock extension,
4772 // which only a version-2 superblock has, so `H5F__super_init` raises
4773 // the superblock to version 2 whatever the low bound says
4774 // (H5Fsuper.c:1135) — but it does not touch `H5F_LOW_BOUND`, which is
4775 // what every other rule reads. So such a file is a version-2
4776 // superblock over symbol-table groups and version-1 messages, which
4777 // is what the `tests/fixtures/sohm_*.h5` files libhdf5 itself wrote
4778 // are.
4779 let classic = libver == Some(LibverBound::Earliest);
4780 let legacy = classic.then(|| Box::new(LegacyFile::created(ctx, userblock)));
4781 // Non-default file-space properties raise the superblock the same way
4782 // a shared-message table does, and for the same reason: the message
4783 // that declares them lives in an extension, and only a version-2
4784 // superblock has one (H5Fsuper.c:1144).
4785 let superblock_version = SuperblockVersion::Chosen(
4786 if classic && shared_messages.specs().is_empty() && file_space.is_default() {
4787 SUPERBLOCK_V0
4788 } else {
4789 SUPERBLOCK_V2
4790 },
4791 );
4792
4793 // Reserve the superblock at offset 0. Which version it gets is only
4794 // known once the file's content is (see `superblock_version_for`),
4795 // but the two a version-2 file can reach — 2 and 3 — encode to the
4796 // same size, so the reservation follows the base version alone.
4797 let superblock_size = match legacy.as_deref() {
4798 Some(l) if matches!(superblock_version, SuperblockVersion::Chosen(v) if v < SUPERBLOCK_V2) => {
4799 l.superblock.encoded_size()
4800 }
4801 _ => SuperblockV2V3::size_for(ctx.sizeof_addr),
4802 };
4803 // The superblock is an ordinary allocation, not a reservation: under
4804 // paged aggregation it takes the whole of page zero and leaves the
4805 // rest of that page as a section of the metadata manager, which is
4806 // what `H5F__super_init` gets from `H5MF_alloc(f, H5FD_MEM_SUPER, ...)`
4807 // going through `H5MF__alloc_pagefs`. Unpaged it returns offset zero
4808 // and moves the end of the file to `superblock_size`, which is what
4809 // reserving it did.
4810 let allocator = FileAllocator::with_policy(0, policy);
4811 allocator.allocate(superblock_size as u64, FreeSpaceClass::Metadata);
4812
4813 Ok(Self {
4814 handle,
4815 allocator,
4816 ctx,
4817 datasets: Slot::new(Vec::new()),
4818 groups: Slot::new(Vec::new()),
4819 hard_links: Slot::new(Vec::new()),
4820 symbolic_links: Slot::new(Vec::new()),
4821 committed_datatypes: Slot::new(Vec::new()),
4822 preserved_links: Slot::new(Vec::new()),
4823 name_index: Slot::new(Box::new(NameIndex::new())),
4824 root_attributes: Slot::new(Vec::new()),
4825 create_lock: Slot::new(()),
4826 libver,
4827 closed: false,
4828 swmr_active: false,
4829 cwfs: Slot::new(Vec::new()),
4830 root_group_addr: None,
4831 superseded_root_header: Vec::new(),
4832 // A new file starts at the oldest superblock the generation it was
4833 // created in allows, and finalize raises it if the content needs a
4834 // newer one.
4835 superblock_version,
4836 dense_attributes: Slot::new(HashMap::new()),
4837 dense_links: Slot::new(HashMap::new()),
4838 superseded_dense: Slot::new(None),
4839 track_order: TrackOrder::uniform(track_order),
4840 track_times,
4841 root_track_order: TrackOrder::uniform(track_order),
4842 // The root group is created with the file, so it captures the
4843 // policy the same instant every other field of it is settled.
4844 root_times: track_times.then(|| ObjectTimes::created_at(now_seconds())),
4845 next_creation_seq: Slot::new(0),
4846 pending_object_references: Slot::new(Vec::new()),
4847 pending_heap_references: Slot::new(Vec::new()),
4848 attribute_references: Slot::new(Vec::new()),
4849 legacy,
4850 symbol_tables: SymbolTables::none_found(),
4851 // A created file has no extension to carry and no ranks but the
4852 // library defaults: `H5Pset_sym_k`/`H5Pset_istore_k` have no
4853 // equivalent on this writer's creation path.
4854 btree: BTreeV1Config::default(),
4855 extension: Box::default(),
4856 // A file created at the library defaults declares no file-space
4857 // strategy, so it has no message to write and no manager to keep;
4858 // one created with any other properties owns both.
4859 free_space: (!file_space.is_default()).then(|| {
4860 Box::new(FileSpaceState {
4861 info: file_space.message(),
4862 superseded: Vec::new(),
4863 })
4864 }),
4865 sohm: (!shared_messages.specs().is_empty())
4866 .then(|| Box::new(SohmState::new(shared_messages.specs().to_vec(), Vec::new()))),
4867 source_dir: source_dir_of(path)?,
4868 })
4869 }
4870
4871 /// Target the libhdf5 2.0 file format for datasets created after this
4872 /// call: filtered chunked datasets get layout message version 5, whose
4873 /// chunk indexes store chunk sizes in a fixed `sizeof_size`-byte field
4874 /// with no overflow limit (see [`Self::chunk_layout_version`]). Off by
4875 /// default, because readers older than libhdf5 2.0 — including the
4876 /// 1.14-based h5py wheels — reject version 5.
4877 ///
4878 /// `false` names `H5F_LIBVER_EARLIEST`, the far end of the same table,
4879 /// rather than un-naming the bound: it is `set_libver_bound`'s contract
4880 /// that applies, chunk index included.
4881 pub fn set_libver_latest(&mut self, latest: bool) -> IoResult<()> {
4882 self.set_libver_bound(if latest {
4883 LibverBound::V200
4884 } else {
4885 LibverBound::Earliest
4886 })
4887 }
4888
4889 /// Bytes this file reserves in front of its superblock
4890 /// (`H5Pget_userblock`).
4891 ///
4892 /// The same value for a file created with one and for a file reopened
4893 /// through [`open_append_with_locking`](Self::open_append_with_locking),
4894 /// which takes it from where the signature turned up: it is the base of
4895 /// the handle's address space either way.
4896 pub fn userblock_size(&self) -> u64 {
4897 self.handle.base()
4898 }
4899
4900 /// Set the file's low libver bound, the equivalent of
4901 /// `H5Pset_libver_bounds`'s `low` argument. Objects created after this
4902 /// call encode their messages at the versions that bound calls for.
4903 ///
4904 /// On a reopened file the bound is taken as named, above or below the row
4905 /// the file's superblock version belongs to, as `H5Fopen` takes a fapl's
4906 /// since libhdf5 2.0 (HDFGroup/hdf5#4939): a version-3 superblock opened
4907 /// at `Earliest` gets version-1 B-tree chunk indexes appended, as h5py on
4908 /// libhdf5 2.x appends them. Only a bound the file's format cannot express
4909 /// at all is refused.
4910 pub fn set_libver_bound(&mut self, libver: LibverBound) -> IoResult<()> {
4911 // A classic file cannot honour a newer bound: every encoder in it
4912 // reads `H5F_LOW_BOUND`, and raising that is what makes libhdf5 write
4913 // the version-2/3 superblock this file does not have. Refused rather
4914 // than pinned silently, so the caller learns the bound did not take.
4915 if libver != LibverBound::Earliest && self.is_legacy() {
4916 return Err(crate::io::IoError::Unsupported(format!(
4917 "cannot set the library-version bound to {libver:?} on this file: it is in the classic (version-0/1 superblock) format, which libhdf5 writes only at H5F_LIBVER_EARLIEST"
4918 )));
4919 }
4920 self.libver = Some(libver);
4921 Ok(())
4922 }
4923
4924 /// The generation the *message* encoders follow — dataspace, datatype,
4925 /// fill value, attribute.
4926 ///
4927 /// A property of the file, not of the object: `H5S__set_version`,
4928 /// `H5O__fill_set_version`, `H5A__set_version` and `H5T_set_version` all
4929 /// read `H5F_LOW_BOUND(f)` and nothing about the object they are encoding
4930 /// for. So a creation-order-tracking group in a classic file still gets
4931 /// version-1 dataspaces and version-1 attribute messages, even though its
4932 /// own header is version 2.
4933 fn message_format(&self) -> ObjectFormat {
4934 match self.legacy {
4935 Some(_) => ObjectFormat::Legacy,
4936 None => ObjectFormat::Modern,
4937 }
4938 }
4939
4940 /// The object header version an object with this creation-order policy
4941 /// gets — `H5O__set_version` (H5Oint.c:251).
4942 ///
4943 /// Version 1 is the floor a classic file's low bound sets, but tracking
4944 /// creation order of *either* kind raises the object past it: the link
4945 /// creation index lives in the message envelope and the attribute tracking
4946 /// flags live in the header prefix, and version 1 has neither. This is a
4947 /// per-object question in a classic file, which is why the format is not
4948 /// one switch for the whole file — libhdf5 writes version-2 headers inside
4949 /// a version-0 superblock whenever the creation property list asks for
4950 /// creation order.
4951 fn header_format(&self, track: TrackOrder) -> ObjectFormat {
4952 let attrs = self.header_attr_order(track.attrs);
4953 if self.legacy.is_some() && !track.links.is_tracked() && !attrs.is_tracked() {
4954 ObjectFormat::Legacy
4955 } else {
4956 ObjectFormat::Modern
4957 }
4958 }
4959
4960 /// The attribute creation-order policy an object header records, given
4961 /// what the object's creation property list asked for.
4962 ///
4963 /// A file whose shared-message configuration covers attributes records a
4964 /// creation index on every object header message: a shared attribute is
4965 /// found again through it, so `H5SM_init` sets `store_msg_crt_idx`
4966 /// (H5SM.c:220) and `H5O__create_ohdr` then raises every header it creates
4967 /// to version 2 and ORs `H5O_HDR_ATTR_CRT_ORDER_TRACKED` into its flags
4968 /// (H5Oint.c:364, H5Oint.c:442) whatever the property list says. So on
4969 /// such a file the floor is `Tracked` — this is the only place that floor
4970 /// is applied, and both the header version and the header flags come
4971 /// through here.
4972 fn header_attr_order(&self, requested: CreationOrder) -> CreationOrder {
4973 if requested.is_tracked() || !self.tracks_message_creation_index() {
4974 return requested;
4975 }
4976 CreationOrder::Tracked
4977 }
4978
4979 /// Whether this finalize replaces the file's shared-message table.
4980 ///
4981 /// It does whenever the file has indexes and no table has been published
4982 /// this session — every finalize of a file created with them, and the
4983 /// first finalize after a reopen. `build_shared_messages` lays a table out
4984 /// whole from the whole message set rather than inserting into an existing
4985 /// one, so a reopen's table is a *replacement*: every heap ID in the file
4986 /// is reassigned, which makes every object header that holds one stale
4987 /// however little else about it changed. A second finalize (a SWMR close)
4988 /// keeps the table the first published and answers `false`.
4989 fn rebuilds_shared_messages(&self) -> bool {
4990 self.sohm
4991 .as_deref()
4992 .is_some_and(|s| s.table_addr.lock().is_none())
4993 }
4994
4995 /// Whether every object header this writer emits records message creation
4996 /// indices.
4997 fn tracks_message_creation_index(&self) -> bool {
4998 self.sohm
4999 .as_deref()
5000 .is_some_and(SohmState::shares_attributes)
5001 }
5002
5003 /// Whether the group at `scope` stores its links in a symbol table —
5004 /// `H5G__obj_create_real` (H5Gobj.c:129) and the conversion
5005 /// `H5G_obj_insert` performs (H5Gobj.c:512).
5006 ///
5007 /// The new group format is used unconditionally from `H5F_LIBVER_V18` up
5008 /// *for a group being created*, and below it only when the group tracks
5009 /// link creation order: a symbol table entry has no room for a creation
5010 /// index. The two axes are independent — a group that tracks only
5011 /// *attribute* creation order gets a version-2 header over a symbol table,
5012 /// which is what libhdf5 writes for it.
5013 ///
5014 /// A group the reopen found in a symbol table is not being created, and
5015 /// `H5G_obj_insert` never moves an existing group to the new format for
5016 /// the bound's sake. So [`SymbolTables::found`] answers for it whatever
5017 /// generation the rest of this session writes at.
5018 ///
5019 /// The content of the group is the third axis. A symbol table entry has
5020 /// three cache types and no room for a fourth, so an external or
5021 /// user-defined link cannot go in one; libhdf5 answers by converting that
5022 /// one group to link messages the moment such a link is inserted, leaving
5023 /// the superblock version, the object header version and every other group
5024 /// in the file alone. This writer builds each group's storage once at
5025 /// finalize rather than link by link, so the same rule reads as a question
5026 /// about the finished set.
5027 fn uses_symbol_table(&self, scope: LinkScope, links: CreationOrder) -> bool {
5028 (self.legacy.is_some() || self.symbol_tables.found.contains(&scope))
5029 && !links.is_tracked()
5030 && self.links_fit_symbol_table(scope, links)
5031 }
5032
5033 /// Whether every link `scope` holds is one a symbol table entry can
5034 /// express — `H5G_obj_insert`'s `obj_lnk->cset != H5T_CSET_ASCII ||
5035 /// obj_lnk->type > H5L_TYPE_BUILTIN_MAX` test (H5Gobj.c:514), asked of the
5036 /// whole set.
5037 ///
5038 /// A link a reopen carried through verbatim counts too, by its decoded
5039 /// form: the entry would have to be built from that.
5040 fn links_fit_symbol_table(&self, scope: LinkScope, order: CreationOrder) -> bool {
5041 self.group_links(scope, order)
5042 .iter()
5043 .all(|l| l.link.fits_symbol_table())
5044 }
5045
5046 /// The header format of the registered dataset at `index`.
5047 ///
5048 /// A dataset has no links, so only the attribute half of the policy can
5049 /// raise it past version 1.
5050 fn dataset_header_format(&self, index: usize) -> ObjectFormat {
5051 let ds = self.ds(index);
5052 let attrs = ds.lock().track_attr_order;
5053 self.header_format(TrackOrder {
5054 links: CreationOrder::default(),
5055 attrs,
5056 })
5057 }
5058
5059 /// The header format of the registered group at `index`.
5060 fn group_header_format(&self, index: usize) -> ObjectFormat {
5061 let grp = self.grp(index);
5062 let track = grp.lock().track_order;
5063 self.header_format(track)
5064 }
5065
5066 /// The bound one family of encoders is written at, and the single reader
5067 /// of the `libver` field.
5068 ///
5069 /// A bound the caller named is the fapl's `low`, as it was named: since
5070 /// libhdf5 2.0 `H5F__super_read` raises nothing but an SWMR-write open
5071 /// (HDFGroup/hdf5#4939), so a reopened file's superblock version says
5072 /// which generation the file *is* and nothing about the generation this
5073 /// session appends in. With no bound named the answer is `create_default`,
5074 /// which differs per family because this crate's default file is two rows
5075 /// rather than one bound (see the `libver` field, [`encoding_libver`] and
5076 /// [`layout_version_bound`]) — the same default on a created file, whose
5077 /// superblock is then written to match, and on a reopened one, whose
5078 /// superblock is written back as found.
5079 ///
5080 /// [`encoding_libver`]: Self::encoding_libver
5081 /// [`layout_version_bound`]: Self::layout_version_bound
5082 fn session_libver(&self, create_default: LibverBound) -> LibverBound {
5083 let bound = self.libver.unwrap_or(create_default);
5084 match self.message_format() {
5085 // `H5F_LIBVER_EARLIEST` is the only low bound under which libhdf5
5086 // writes a version-0/1 superblock at all, so a newer structure
5087 // inside one is a combination no libhdf5 produces. Refused where
5088 // the caller asks for it (`set_libver_bound`) rather than silently
5089 // dropped; capping here is what keeps the encoders honest if a
5090 // path ever misses that gate.
5091 ObjectFormat::Legacy => bound.min(LibverBound::Earliest),
5092 ObjectFormat::Modern => bound,
5093 }
5094 }
5095
5096 /// The bound the message encoders see — dataspace, datatype, fill value,
5097 /// attribute.
5098 fn encoding_libver(&self) -> LibverBound {
5099 self.session_libver(LibverBound::Earliest)
5100 }
5101
5102 /// The data layout message version this file's bound calls for —
5103 /// `H5O_layout_ver_bounds[H5F_LOW_BOUND(f)]` (H5Dlayout.c:44), the term
5104 /// `H5D__chunk_set_info` weighs against the version a chunk *requires*
5105 /// (H5Dchunk.c:936, :1046).
5106 ///
5107 /// With no bound named the row is `H5F_LIBVER_V110`'s: this crate's
5108 /// default file uses the v1.10 chunk indexes, which is exactly what that
5109 /// row says and what no other row does (see the `libver` field for why the
5110 /// default is not `Earliest` here even though the datatype and superblock
5111 /// tables read it that way). A reopened file takes the same default: a
5112 /// version-2 superblock gets v1.10 indexes appended unless a bound below
5113 /// `V110` is named, whose layout version of 3 has no index-type field at
5114 /// all and puts the appended chunks on the version-1 B-tree.
5115 fn layout_version_bound(&self) -> u8 {
5116 self.session_libver(LibverBound::V110).layout_version()
5117 }
5118
5119 /// The data layout version a chunk of `chunk_bytes` *requires* whatever
5120 /// the bound says — `version_req` in `H5D__chunk_set_info` (H5Dchunk.c:909).
5121 ///
5122 /// Only one thing raises it: a chunk over 4 GiB does not fit the version-4
5123 /// message's 32-bit stored-size field. The floor is the default the
5124 /// creation property list carries, `H5O_LAYOUT_VERSION_DEFAULT`
5125 /// (H5Oprivate.h:451), which is why a classic file's chunked dataset is a
5126 /// version-3 message rather than the version-1 its bound's row names.
5127 fn required_chunk_layout_version(chunk_bytes: u64) -> u8 {
5128 if chunk_bytes > u32::MAX as u64 {
5129 5
5130 } else {
5131 LAYOUT_VERSION_DEFAULT
5132 }
5133 }
5134
5135 /// Whether a new chunked dataset of this chunk size is indexed by one of
5136 /// the v1.10 indexes — extensible array, fixed array, v2 B-tree, single
5137 /// chunk or implicit — rather than by the version-1 B-tree.
5138 ///
5139 /// The gate `H5D__chunk_set_info` puts in front of the whole
5140 /// index-selection block (H5Dchunk.c:936): the bound's layout version
5141 /// reaches 4, or the chunk requires a version that does. Only inside it
5142 /// does the dataspace get to pick between the five; below it the layout
5143 /// message has no index-type field and the chunks go on the version-1
5144 /// B-tree. So the format decides before the shape does — a fixed shape
5145 /// covered by exactly one chunk takes the single-chunk index only on the
5146 /// near side of this gate.
5147 pub(crate) fn uses_v110_chunk_indexing(&self, chunk_bytes: u64) -> bool {
5148 self.layout_version_bound() >= 4 || Self::required_chunk_layout_version(chunk_bytes) >= 4
5149 }
5150
5151 /// Refuse an SWMR session this file's format cannot record.
5152 ///
5153 /// The two checks `H5F__start_swmr_write` opens with: the superblock must
5154 /// be at least version 3 (H5Fint.c:3814, hdf5_1.14.6 H5Fint.c:3751) — the
5155 /// only version with the status-flags field that says a writer is attached
5156 /// — and the low bound must be at least `H5F_LIBVER_V110` (H5Fint.c:3818),
5157 /// the oldest bound whose `HDF5_superblock_ver_bounds` row reaches version
5158 /// 3.
5159 ///
5160 /// The first is the [`SuperblockVersion`] question: a reopened file
5161 /// already has its version and reopening never rewrites one, so it is
5162 /// checked as found; a file this writer created has no version on disk
5163 /// yet, and nothing in it says the superblock may not be version 3 — SWMR
5164 /// is what makes it one. The second is asked of the bound the caller
5165 /// named on either path, since a reopened file's superblock no longer
5166 /// raises it (HDFGroup/hdf5#4939); a caller who named none passes,
5167 /// because the default's layout row is `V110`'s, the row the v1.10 chunk
5168 /// indexes an SWMR reader follows belong to.
5169 ///
5170 /// Named, not silently upgraded. libhdf5 upgrades in the one case where
5171 /// SWMR is asked for at *create* time (`H5F_ACC_SWMR_WRITE` raises the
5172 /// bound to V110 in `H5F__super_init`, H5Fsuper.c:1131); on the reopen
5173 /// path it refuses instead, and so does this.
5174 fn reject_swmr(&self) -> IoResult<()> {
5175 let below_v110 = self.libver.is_some_and(|b| b < LibverBound::V110);
5176 let why = match self.superblock_version {
5177 SuperblockVersion::Existing(version) if version < SUPERBLOCK_V3 => format!(
5178 "its superblock is version {version}, and reopening a file never \
5179 rewrites that"
5180 ),
5181 SuperblockVersion::Chosen(_) if self.is_legacy() => {
5182 "it is in the classic (version-0/1 superblock) format that \
5183 H5F_LIBVER_EARLIEST selects"
5184 .to_string()
5185 }
5186 _ if below_v110 => "it was asked for at a library-version bound below \
5187 H5F_LIBVER_V110, whose superblock row is version 2"
5188 .to_string(),
5189 _ => return Ok(()),
5190 };
5191 Err(crate::io::IoError::Unsupported(format!(
5192 "cannot start an SWMR session on this file: {why}, and SWMR needs a \
5193 version-3 superblock to record that a writer is attached; create the \
5194 file at H5F_LIBVER_V110 or newer"
5195 )))
5196 }
5197
5198 /// Whether this file is in the classic (version-0/1 superblock) format,
5199 /// whose groups store their links in symbol tables — either because it
5200 /// was reopened in it or because it was created at
5201 /// `H5F_LIBVER_EARLIEST`.
5202 pub(crate) fn is_legacy(&self) -> bool {
5203 self.legacy.is_some()
5204 }
5205
5206 /// The v1-B-tree "K" ranks in force for this file, from which every v1
5207 /// node's width is derived.
5208 ///
5209 /// A version-0/1 superblock records them in a field of its own and a
5210 /// version-2/3 one in a B-tree-K message in its superblock extension, so
5211 /// the file's generation says nothing about whether they are the defaults
5212 /// — `H5F__super_read` reads both into the same `H5F_shared_t`, and so
5213 /// does the reopen, into `btree`.
5214 fn btree_v1_config(&self) -> BTreeV1Config {
5215 self.btree
5216 }
5217
5218 /// Track and index creation order for the links and the attributes of
5219 /// every object created after this call — the equivalent of setting
5220 /// `H5Pset_link_creation_order` and `H5Pset_attr_creation_order` to
5221 /// `H5P_CRT_ORDER_TRACKED | H5P_CRT_ORDER_INDEXED` on the creation
5222 /// property lists those objects are made with.
5223 ///
5224 /// Objects already created keep the policy they were made under, exactly
5225 /// as libhdf5 keeps what their creation property list said. The root
5226 /// group is created with the file, so its policy comes from
5227 /// [`create_with_options`](Self::create_with_options) instead.
5228 pub fn set_track_order(&mut self, track: bool) {
5229 self.track_order = TrackOrder::uniform(track);
5230 }
5231
5232 /// Record the times of every object created after this call —
5233 /// `H5Pset_obj_track_times` on the creation property lists those objects
5234 /// are made with.
5235 ///
5236 /// Off by default, which is h5py's default and not libhdf5's: h5py's
5237 /// high-level API sets `track_times=False` on every object it makes
5238 /// (`_hl/files.py:189`, `_hl/dataset.py:39`, `_hl/group.py:42`), while a
5239 /// bare creation property list leaves it on (`H5O_CRT_OHDR_FLAGS_DEF` is
5240 /// `H5O_HDR_STORE_TIMES`, H5Opkg.h:74). A caller after libhdf5's own
5241 /// bytes turns it on here.
5242 ///
5243 /// Objects already created keep the policy they were made under, and the
5244 /// root group takes its own from
5245 /// [`create_with_options`](Self::create_with_options) — the same split
5246 /// [`set_track_order`](Self::set_track_order) has, and for the same
5247 /// reason: this is a creation property, not a file-wide setting.
5248 pub fn set_track_times(&mut self, track: bool) {
5249 self.track_times = track;
5250 }
5251
5252 /// The times an object created right now records — all four set to the
5253 /// current time, as `H5O_apply_ohdr` initialises them (H5Oint.c:411-414),
5254 /// or `None` when this session is not tracking times.
5255 ///
5256 /// INVARIANT: every object this writer registers takes its `times` from
5257 /// here. The policy belongs to the creation property list, so reading
5258 /// [`track_times`](Self::track_times) at any later moment — a finalize, a
5259 /// header rewrite — would stamp a policy the object was not made under.
5260 fn created_object_times(&self) -> Option<ObjectTimes> {
5261 self.track_times
5262 .then(|| ObjectTimes::created_at(now_seconds()))
5263 }
5264
5265 /// Layout message version for a new chunked dataset on one of the v1.10
5266 /// indexes — `H5D__chunk_set_info`'s closing
5267 /// `MAX3(layout->version, version_req, MIN(bound, version_perf))`
5268 /// (H5Dchunk.c:1046).
5269 ///
5270 /// Version 5 is *required* for a chunk over 4 GiB (pre-2.0 readers cannot
5271 /// handle one even though the v4 wire format could express it) and
5272 /// *preferred* for filtered chunks, which is why it takes the file's
5273 /// bound to get there: the preference is capped by the bound's own row,
5274 /// so only the 2.0 format lets it through. Everything else stays at
5275 /// version 4, which every 1.10+ reader accepts.
5276 fn chunk_layout_version(&self, filtered: bool, chunk_bytes: u64) -> u8 {
5277 // `version_perf`: 4 for the v1.10 indexes as such, 5 when a filter
5278 // can make a chunk expand past what version 4 can record.
5279 let preferred = if filtered { 5 } else { 4 };
5280 Self::required_chunk_layout_version(chunk_bytes)
5281 .max(self.layout_version_bound().min(preferred))
5282 .max(LAYOUT_VERSION_DEFAULT)
5283 }
5284
5285 /// Width of the stored-chunk-size field in a filtered chunk index:
5286 /// version 5 uses the fixed `sizeof_size`; version 4 derives it from the
5287 /// uncompressed chunk byte count (one spare byte included), the
5288 /// `H5D_*_COMPUTE_CHUNK_SIZE_LEN` rule shared by the extensible-array,
5289 /// fixed-array and v2-B-tree indexes.
5290 fn chunk_size_len_for(&self, layout_version: u8, chunk_bytes: u64) -> u8 {
5291 if layout_version >= 5 {
5292 self.ctx.sizeof_size
5293 } else {
5294 compute_chunk_size_len(chunk_bytes)
5295 }
5296 }
5297
5298 /// Provide public access to the format context.
5299 pub fn ctx(&self) -> &FormatContext {
5300 &self.ctx
5301 }
5302
5303 /// Number of dataset slots in the registry (including soft-deleted ones).
5304 pub(crate) fn dataset_count(&self) -> usize {
5305 self.datasets.lock().len()
5306 }
5307
5308 /// Clone out the [`DatasetRef`] for `index`, releasing the registry lock
5309 /// immediately. Lock the returned ref to read or mutate that one dataset.
5310 ///
5311 /// Panics on an out-of-range index, exactly like the `Vec` indexing it
5312 /// replaces; bounds-checking callers consult [`Self::dataset_count`] first.
5313 ///
5314 /// MUST NOT be called while the registry [`Slot`] is already locked (it
5315 /// would deadlock the `threadsafe` mutex / panic the single-thread
5316 /// `RefCell`): collect the refs you need, drop the registry guard, then work.
5317 pub(crate) fn ds(&self, index: usize) -> DatasetRef {
5318 Shared::clone(&self.datasets.lock()[index])
5319 }
5320
5321 /// Number of group slots in the registry (including soft-deleted ones).
5322 pub(crate) fn group_count(&self) -> usize {
5323 self.groups.lock().len()
5324 }
5325
5326 /// Clone out the [`GroupRef`] for `index`. Same contract as [`Self::ds`].
5327 pub(crate) fn grp(&self, index: usize) -> GroupRef {
5328 Shared::clone(&self.groups.lock()[index])
5329 }
5330
5331 /// Enter the create gate: take `create_lock` and check that `name` is not
5332 /// already taken. The returned witness is what [`Self::push_dataset`]
5333 /// requires, so the uniqueness check and the registry push are atomic
5334 /// (see `create_lock`) at every creator by construction.
5335 pub(crate) fn begin_create(&self, name: &str) -> IoResult<CreateGuard<'_>> {
5336 let gate = self.create_lock.lock();
5337 // A creation path through hard links lands in the link's target
5338 // group, as HDF5 traversal does. Canonicalizing here — the one
5339 // entry every creator passes — keeps alias forms out of the
5340 // registry.
5341 let name = self.canonical_dataset_path(name);
5342 // A path that leaves this file, or that runs into an object the
5343 // reopen kept verbatim, is refused here rather than at each creator:
5344 // this is the one gate every creation passes, so a creator added
5345 // later cannot forget the check. Both run before the parent lookup,
5346 // which would otherwise report the group such a path names as absent
5347 // instead of naming what stops the path. Uniqueness comes first among
5348 // them: a name already in the file is taken whatever holds it.
5349 self.reject_external_traversal(&name)?;
5350 self.ensure_name_free(&name)?;
5351 self.reject_preserved_object(&name)?;
5352 let (parent, _leaf) = self.split_parent(&name)?;
5353 Ok(CreateGuard {
5354 _gate: gate,
5355 name,
5356 parent,
5357 })
5358 }
5359
5360 /// Split an object path into the group that will hold its link and the
5361 /// leaf link name, resolving every component through the group registry.
5362 ///
5363 /// `path` is the registry form — no leading `/`, e.g. `"grp/sub/late"`.
5364 /// This is what keeps a `/` out of a link name: HDF5 link names are
5365 /// single path components (`H5G_traverse` splits on `/` before it ever
5366 /// reaches `H5L_link`), so a name that carries a path must name a group
5367 /// that exists, or be refused.
5368 ///
5369 /// A missing component is an error rather than an implicit group: the
5370 /// default link creation property list has `H5Pset_create_intermediate_group`
5371 /// off, and this writer exposes no property list to turn it on with.
5372 fn split_parent(&self, path: &str) -> IoResult<(Option<usize>, String)> {
5373 let (parent_path, leaf) = path.rsplit_once('/').unwrap_or(("", path));
5374 if leaf.is_empty() {
5375 return Err(crate::io::IoError::InvalidState(format!(
5376 "'{path}' does not end in a link name"
5377 )));
5378 }
5379 if parent_path.is_empty() {
5380 return Ok((None, leaf.to_string()));
5381 }
5382 let abs = format!("/{parent_path}");
5383 let groups = self.group_refs();
5384 let idx = groups
5385 .iter()
5386 .position(|g| {
5387 let gg = g.lock();
5388 gg.name == abs && !gg.deleted
5389 })
5390 .ok_or_else(|| {
5391 crate::io::IoError::NotFound(format!(
5392 "cannot create '{path}': group '{abs}' does not exist"
5393 ))
5394 })?;
5395 Ok((Some(idx), leaf.to_string()))
5396 }
5397
5398 /// Push a freshly-built dataset into the registry and return its index.
5399 /// Takes the registry lock only for the push, so it does not block an
5400 /// in-flight write that already cloned its own [`DatasetRef`] out.
5401 /// The [`CreateGuard`] proves the caller entered through
5402 /// [`Self::begin_create`] and still holds the gate.
5403 pub(crate) fn push_dataset(&self, create: &CreateGuard<'_>, info: DatasetInfo) -> usize {
5404 let name = info.name.clone();
5405 let idx = {
5406 let mut reg = self.datasets.lock();
5407 let idx = reg.len();
5408 reg.push(Shared::new(DatasetCell::new(info)));
5409 idx
5410 };
5411 self.register_name(&name, NameHit::Dataset(idx));
5412 // The spine guard is dropped before the group slot is taken: the lock
5413 // order is spine -> slot and never the reverse.
5414 if let Some(pidx) = create.parent {
5415 self.grp(pidx).lock().child_datasets.push(idx);
5416 }
5417 idx
5418 }
5419
5420 /// Push a freshly-built group into the registry and return its index.
5421 pub(crate) fn push_group(&self, info: GroupInfo) -> usize {
5422 let name = info.name.trim_start_matches('/').to_string();
5423 let idx = {
5424 let mut reg = self.groups.lock();
5425 let idx = reg.len();
5426 reg.push(Shared::new(Slot::new(info)));
5427 idx
5428 };
5429 self.register_name(&name, NameHit::Group(idx));
5430 idx
5431 }
5432
5433 /// Snapshot every [`DatasetRef`] (spine lock held only for the clone).
5434 /// Iterate the snapshot to lock each dataset one at a time — this keeps
5435 /// the lock order *spine → slot* and never reacquires the spine while a
5436 /// slot is held, which is what makes the registry deadlock-free.
5437 pub(crate) fn dataset_refs(&self) -> Vec<DatasetRef> {
5438 self.datasets.lock().iter().map(Shared::clone).collect()
5439 }
5440
5441 /// Snapshot every [`GroupRef`]; see [`Self::dataset_refs`].
5442 pub(crate) fn group_refs(&self) -> Vec<GroupRef> {
5443 self.groups.lock().iter().map(Shared::clone).collect()
5444 }
5445
5446 /// Snapshot the hard-link list (the lock is held only for the clone), so
5447 /// callers can resolve each link's target/parent — which locks dataset and
5448 /// group slots — without holding the hard-link lock.
5449 /// The next creation sequence number.
5450 ///
5451 /// One monotonic counter for datasets, groups and hard links alike: a
5452 /// group orders its links by it, so an interleaved run of `create_group`
5453 /// and `create_dataset` comes back out in the order it was made rather
5454 /// than grouped by kind.
5455 fn take_creation_seq(&self) -> u64 {
5456 let mut next = self.next_creation_seq.lock();
5457 let seq = *next;
5458 *next += 1;
5459 seq
5460 }
5461
5462 pub(crate) fn hard_links_vec(&self) -> Vec<HardLink> {
5463 self.hard_links.lock().clone()
5464 }
5465
5466 /// Snapshot the symbolic-link list; see [`Self::hard_links_vec`].
5467 pub(crate) fn symbolic_links_vec(&self) -> Vec<SymbolicLink> {
5468 self.symbolic_links.lock().clone()
5469 }
5470
5471 /// Open an existing HDF5 file for appending new datasets, using the
5472 /// env-var-derived locking policy.
5473 ///
5474 /// Reads existing dataset object headers fully, reconstructing metadata
5475 /// for chunked datasets so that `write_chunk` and `extend_dataset` work
5476 /// on reopened datasets.
5477 pub fn open_append(path: &Path) -> IoResult<Self> {
5478 Self::open_append_with_locking(
5479 path,
5480 crate::io::locking::FileLocking::from_env_or(Default::default()),
5481 )
5482 }
5483
5484 /// Carry a reopened file's shared-message table into the writer's model:
5485 /// the index specifications the file was created with, and every block the
5486 /// table occupies so the finalize that replaces it can give them back.
5487 ///
5488 /// `H5SM_init` fixes the index count, each index's type mask, its minimum
5489 /// message size and the file-wide phase-change pair when the file is
5490 /// created, and nothing afterwards changes any of them — they are file
5491 /// creation properties. So the master table on disk *is* the
5492 /// [`SharedMessageConfig`] the file was made with, read back.
5493 ///
5494 /// Returns `None` for a file with no shared-message table, which is every
5495 /// file libhdf5 writes without `H5Pset_shared_mesg_nindexes`.
5496 /// Read the free-space managers a reopened file persists, if it does.
5497 ///
5498 /// `H5F__super_read` copies the file-space info message's addresses into
5499 /// `f->shared->fs_addr[]` and the library opens each manager lazily; this
5500 /// reads them all at once, because the writer needs the whole section set
5501 /// before it allocates anything.
5502 ///
5503 /// Returns `None` — nothing read, nothing to write back — for a file with
5504 /// no file-space info message, one that does not persist, and one whose
5505 /// strategy keeps no managers at all.
5506 fn reopen_free_space(
5507 handle: &mut FileHandle,
5508 meta: &crate::io::FileMeta,
5509 ext: &crate::io::reader::SuperblockExtension,
5510 ) -> IoResult<ReopenedFreeSpace> {
5511 let none = || ReopenedFreeSpace {
5512 state: None,
5513 sections: Vec::new(),
5514 };
5515 let Some(info) = ext.file_space_info.as_ref().filter(|i| i.persist) else {
5516 return Ok(none());
5517 };
5518 if !matches!(
5519 info.strategy,
5520 FileSpaceStrategy::FsmAggr | FileSpaceStrategy::Page
5521 ) {
5522 return Ok(none());
5523 }
5524 let found = crate::io::free_space_io::read_managers(handle, &meta.ctx, info)?;
5525 Ok(ReopenedFreeSpace {
5526 state: Some(Box::new(FileSpaceState {
5527 info: info.clone(),
5528 superseded: found.blocks,
5529 })),
5530 sections: found.sections,
5531 })
5532 }
5533
5534 fn reopen_shared_messages(
5535 handle: &mut FileHandle,
5536 meta: &crate::io::FileMeta,
5537 ext: &crate::io::reader::SuperblockExtension,
5538 ) -> IoResult<Option<Box<SohmState>>> {
5539 use crate::format::chunk_index::btree_v2::collect_btree_v2_extents;
5540 use crate::format::fractal_heap::collect_heap_extents;
5541 use crate::format::sohm::{list_size, SohmMasterTable, SOHM_INDEX_LIST};
5542
5543 let (Some(table), Some(smt)) = (
5544 meta.sohm.as_ref().filter(|t| !t.indexes.is_empty()),
5545 ext.shared_message_table.as_ref(),
5546 ) else {
5547 return Ok(None);
5548 };
5549 let ctx = &meta.ctx;
5550
5551 // The extension header itself is superseded by `CarriedExtension`,
5552 // which owns it whether or not the file has shared messages; what is
5553 // superseded here is only the storage the table message names.
5554 let mut superseded = Vec::new();
5555 superseded.push((
5556 smt.table_address,
5557 SohmMasterTable::encoded_size(ctx, smt.nindexes) as u64,
5558 ));
5559
5560 let mut specs = Vec::with_capacity(table.indexes.len());
5561 for index in &table.indexes {
5562 specs.push(SohmIndexSpec {
5563 mesg_types: index.mesg_types,
5564 min_mesg_size: index.min_mesg_size,
5565 list_max: index.list_max,
5566 btree_min: index.btree_min,
5567 });
5568 let mut reader = crate::io::reader::HandleBlockReader { handle };
5569 if index.heap_addr != UNDEF_ADDR {
5570 superseded.extend(collect_heap_extents(index.heap_addr, ctx, &mut reader)?);
5571 }
5572 if index.index_addr != UNDEF_ADDR {
5573 if index.index_type == SOHM_INDEX_LIST {
5574 // `H5SM_LIST_SIZE`: the block is sized for `list_max`
5575 // records however few are in it.
5576 superseded.push((index.index_addr, list_size(ctx, index.list_max) as u64));
5577 } else {
5578 superseded.extend(collect_btree_v2_extents(
5579 index.index_addr,
5580 ctx,
5581 &mut reader,
5582 )?);
5583 }
5584 }
5585 }
5586 Ok(Some(Box::new(SohmState::new(specs, superseded))))
5587 }
5588
5589 /// Open an existing HDF5 file for appending with an explicit locking
5590 /// policy.
5591 pub fn open_append_with_locking(
5592 path: &Path,
5593 locking: crate::io::locking::FileLocking,
5594 ) -> IoResult<Self> {
5595 let mut handle = FileHandle::open_readwrite_with_locking(path, locking)?;
5596 // The same `H5FD_locate_signature` search the read path makes, through
5597 // the same handle mechanism: the offset it finds is the file's base
5598 // address, so the allocator's end-of-file, every write and the
5599 // superblock rewrite all work in the HDF5 address space, and the
5600 // userblock in `[0, base)` is not addressable from this writer at all.
5601 let super_addr = handle
5602 .locate_signature()?
5603 .ok_or(crate::format::FormatError::InvalidSignature)?;
5604 handle.set_base(super_addr);
5605 let file_size = handle.file_size()?;
5606
5607 let sb_buf = handle.read_at_most(0, 256)?;
5608 // Which generation the file is decides everything the close then
5609 // writes back: version-1 object headers and symbol-table groups over a
5610 // version-0/1 superblock, or version-2 headers and link-message groups
5611 // over a version-2/3 one. libhdf5 writes those two combinations and no
5612 // mixture of them, so the branch is taken once, here, and carried as
5613 // `legacy`.
5614 let version = crate::format::superblock::detect_superblock_version(&sb_buf)?;
5615 let (ctx, sb_btree, root_addr, ext_addr, legacy) = if version <= 1 {
5616 let sb = SuperblockV0V1::decode(&sb_buf)?;
5617 let ctx = FormatContext {
5618 sizeof_addr: sb.sizeof_offsets,
5619 sizeof_size: sb.sizeof_lengths,
5620 };
5621 // Unlike a v2/v3 superblock, a classic one carries the "K" ranks
5622 // itself; every v1-B-tree and symbol-table node width in the file
5623 // comes from them.
5624 let btree = crate::format::btree_v1::BTreeV1Config {
5625 sym_leaf_k: sb.sym_leaf_k,
5626 snode_internal_k: sb.btree_internal_k,
5627 chunk_internal_k: sb.indexed_storage_k.unwrap_or(32),
5628 };
5629 let root = sb.root_symbol_table_entry.obj_header_addr;
5630 let ext = sb.superblock_extension_address;
5631 (ctx, btree, root, ext, Some(sb))
5632 } else {
5633 let sb = SuperblockV2V3::decode(&sb_buf)?;
5634 let ctx = FormatContext {
5635 sizeof_addr: sb.sizeof_offsets,
5636 sizeof_size: sb.sizeof_lengths,
5637 };
5638 (
5639 ctx,
5640 crate::format::btree_v1::BTreeV1Config::default(),
5641 sb.root_group_object_header_address,
5642 sb.superblock_extension_address,
5643 None,
5644 )
5645 };
5646
5647 // The reopen reads object headers exactly as the reader does, so it
5648 // needs the same file-level parameters: a v2/v3 superblock carries no
5649 // B-tree K values, and only the extension can override the defaults.
5650 let (meta, ext) = crate::io::reader::Hdf5Reader::read_extension_and_meta(
5651 &mut handle,
5652 ctx,
5653 sb_btree,
5654 ext_addr,
5655 )?;
5656
5657 // A file with shared object header messages keeps datatypes,
5658 // dataspaces and attributes in a fractal heap per index, and each
5659 // object header holds a heap ID pointing at one. The table is laid out
5660 // whole from the whole message set (`build_shared_messages`), never
5661 // grown insert by insert, so a reopen carries the indexes and the
5662 // bodies forward and the next finalize lays a new table out over the
5663 // old one's blocks — which is sound exactly while no header keeping
5664 // its bytes still points into the old heap. The walk below is what
5665 // settles that.
5666 let sohm = Self::reopen_shared_messages(&mut handle, &meta, &ext)?;
5667
5668 // The extension is external truth this close rewrites, so what it held
5669 // is captured whole here — before anything else reads the file — and
5670 // re-emitted by `write_superblock_extension`. Read from the raw chain
5671 // rather than from `ext`, which keeps only the messages this crate
5672 // models.
5673 let extension = if ext_addr == UNDEF_ADDR || ext_addr == 0 {
5674 Box::<CarriedExtension>::default()
5675 } else {
5676 let (carried, blocks) = crate::io::object_header_io::superblock_extension_messages(
5677 &mut handle,
5678 &meta,
5679 ext_addr,
5680 )?;
5681 Box::new(CarriedExtension {
5682 superseded: blocks,
5683 carried,
5684 addr: Slot::new(None),
5685 })
5686 };
5687
5688 // The managers that extension's file-space info message names, read
5689 // before anything allocates: the sections they hold are file space
5690 // this session may hand out, and the close rewrites them.
5691 let reopened_free_space = Self::reopen_free_space(&mut handle, &meta, &ext)?;
5692
5693 // Discover links from root group (and subgroups recursively). Every
5694 // object is classified before it is registered, and the root is the
5695 // one object with no alternative: its header must be rewritten to
5696 // hold anything new, so an unmodellable root is refused here rather
5697 // than rewritten into whatever this writer could read of it.
5698 let mut walk = ReopenWalk::new(&mut handle, &meta);
5699 let root = match walk.plan(root_addr)? {
5700 ObjectPlan::Group(parts) => parts,
5701 ObjectPlan::Dataset(_) => {
5702 return Err(crate::io::IoError::InvalidState(
5703 "cannot open this file for appending: its root object is a dataset, \
5704 not a group"
5705 .into(),
5706 ))
5707 }
5708 ObjectPlan::Preserve { why, .. } => {
5709 return Err(crate::io::IoError::Unsupported(format!(
5710 "cannot open this file for appending: {why}. Every append rewrites the \
5711 root group's header, and this writer will not rewrite it from the part \
5712 of it that it can read"
5713 )));
5714 }
5715 };
5716 let root_header_blocks = root.header_blocks;
5717 let root_attributes = root.attributes;
5718 let root_track_order = root.track_order;
5719 let root_times = root.times;
5720 let root_dense = root.dense;
5721 let root_stab = root.stab;
5722
5723 walk.group(&root.links, "", 0)?;
5724 let collected = walk.finish();
5725 let mut link_entries = collected.hard;
5726 let mut preserved = collected.preserved;
5727 let link_corder = collected.link_corder;
5728 // Objects the loop below could not rebuild, by header address, so the
5729 // other links to one are preserved with it rather than left pointing
5730 // at a registry entry that is no longer there.
5731 let mut unrebuilt: std::collections::HashMap<u64, String> = Default::default();
5732
5733 // Two link entries can share one object header — hard links. Only
5734 // the first-walked path becomes the object; the rest are rebuilt
5735 // as hard-link registry entries further down. Without this split
5736 // every alias came back as its own DatasetInfo carrying the same
5737 // storage addresses, so deleting (or finalizing) one freed blocks
5738 // the others still referenced.
5739 let mut seen_header_addrs = std::collections::HashSet::new();
5740 let mut alias_entries: Vec<HardEntry> = Vec::new();
5741 link_entries.retain(|(entry, _)| {
5742 if seen_header_addrs.insert(entry.address) {
5743 true
5744 } else {
5745 alias_entries.push(entry.clone());
5746 false
5747 }
5748 });
5749
5750 // The order the walk met each object, kept before the loop below
5751 // consumes the entries: `ensure_groups_for` needs parents to precede
5752 // children.
5753 let walk_order: Vec<String> = link_entries.iter().map(|(e, _)| e.path.clone()).collect();
5754
5755 let mut existing_datasets = Vec::new();
5756 // Non-dataset link targets (groups): the header's chunk-0 address and
5757 // every block its chain occupies, by link path — so finalize can free
5758 // the blocks its rewrite supersedes — plus the attributes the header
5759 // carries, which the group registry below must keep or finalize
5760 // rewrites the group without them.
5761 type GroupHeaderInfo = (
5762 u64,
5763 crate::io::object_header_io::HeaderBlocks,
5764 Vec<AttributeEntry>,
5765 TrackOrder,
5766 Option<ObjectTimes>,
5767 );
5768 let mut group_headers: std::collections::HashMap<String, GroupHeaderInfo> =
5769 Default::default();
5770 // The dense storage each rebuilt dataset's header named, by registry
5771 // index, so finalize frees exactly what its rewrite supersedes. Keyed
5772 // after the rebuild succeeded: a preserved dataset keeps its header,
5773 // and freeing the heap that header still names would strand it.
5774 let mut dataset_dense: Vec<(usize, AttributeInfoMessage)> = Vec::new();
5775 let mut group_dense: Vec<(String, DenseCarry)> = Vec::new();
5776 // The same, for the symbol-table storage a classic group's header
5777 // names: keyed by path here, by registry index once every group has
5778 // one.
5779 let mut group_stabs: Vec<(String, StabExtents)> = Vec::new();
5780 for (entry, object) in link_entries {
5781 let HardEntry {
5782 path: name,
5783 address: obj_addr,
5784 encoded,
5785 } = entry;
5786 let parts = match object {
5787 CollectedObject::Group {
5788 header_blocks,
5789 attributes,
5790 track_order,
5791 times,
5792 dense,
5793 stab,
5794 } => {
5795 group_dense.push((name.clone(), dense));
5796 if let Some(stab) = stab {
5797 group_stabs.push((name.clone(), stab));
5798 }
5799 group_headers.insert(
5800 name,
5801 (obj_addr, header_blocks, attributes, track_order, times),
5802 );
5803 continue;
5804 }
5805 CollectedObject::Dataset(parts) => *parts,
5806 };
5807 let dense_attrs = parts.dense.attrs.clone();
5808 match rebuild_dataset(&mut handle, &meta, file_size, name.clone(), obj_addr, parts) {
5809 Ok(info) => {
5810 if let Some(ainfo) = dense_attrs {
5811 dataset_dense.push((existing_datasets.len(), ainfo));
5812 }
5813 existing_datasets.push(info);
5814 }
5815 // Kept by its bytes for the same reason a header this walk
5816 // could not decode is: the rewrite would otherwise emit an
5817 // object whose chunk index no longer names its chunks.
5818 Err(e) => {
5819 let why = format!("this writer could not rebuild its chunk index: {e}");
5820 unrebuilt.insert(obj_addr, why.clone());
5821 preserved.push(PreservedEntry {
5822 path: name,
5823 class: crate::io::reader::LinkClass::Hard,
5824 encoded,
5825 reason: Some(why),
5826 // A dataset whose chunk index would not rebuild: the
5827 // walk classified it, and it is not a datatype.
5828 kind: PreservedKind::Unclassified,
5829 });
5830 }
5831 }
5832 }
5833
5834 // Reconstruct the group registry. Every group is a link entry of its
5835 // own, whether or not a dataset lives under it, so the registry is
5836 // built from the discovered links — rebuilding it from dataset paths
5837 // alone made attribute-only and empty groups vanish at close, and
5838 // dropped the attributes of the groups that survived.
5839 let mut groups: Vec<GroupInfo> = Vec::new();
5840 let mut group_index_map: std::collections::HashMap<String, usize> =
5841 std::collections::HashMap::new();
5842
5843 // Register the chain of groups "/a", "/a/b", … for the link-style
5844 // path `link_path` ("a/b"), taking each one's on-disk header block
5845 // and attributes out of `group_headers` when the link walk saw it.
5846 fn ensure_groups_for(
5847 link_path: &str,
5848 groups: &mut Vec<GroupInfo>,
5849 group_index_map: &mut std::collections::HashMap<String, usize>,
5850 group_headers: &mut std::collections::HashMap<String, GroupHeaderInfo>,
5851 ) {
5852 let mut path = String::new();
5853 for part in link_path.split('/') {
5854 let parent_path = if path.is_empty() {
5855 "/".to_string()
5856 } else {
5857 path.clone()
5858 };
5859 if path.is_empty() {
5860 path = format!("/{}", part);
5861 } else {
5862 path = format!("{}/{}", path, part);
5863 }
5864 if group_index_map.contains_key(&path) {
5865 continue;
5866 }
5867 let parent = if parent_path == "/" {
5868 None
5869 } else {
5870 group_index_map.get(&parent_path).copied()
5871 };
5872 let gidx = groups.len();
5873 let (obj_header_written_addr, obj_header_blocks, attributes, track_order, times) =
5874 group_headers.remove(path.trim_start_matches('/')).map_or(
5875 (None, Vec::new(), Vec::new(), TrackOrder::default(), None),
5876 |(addr, blocks, attrs, track, times)| {
5877 (Some(addr), blocks, attrs, track, times)
5878 },
5879 );
5880 groups.push(GroupInfo {
5881 name: path.clone(),
5882 parent,
5883 creation_seq: 0,
5884 track_order,
5885 times,
5886 child_datasets: Vec::new(),
5887 child_groups: Vec::new(),
5888 obj_header_addr: 0,
5889 obj_header_written_addr,
5890 obj_header_blocks,
5891 deleted: false,
5892 attributes,
5893 });
5894 if let Some(pidx) = parent {
5895 groups[pidx].child_groups.push(gidx);
5896 }
5897 group_index_map.insert(path.clone(), gidx);
5898 }
5899 }
5900
5901 // Every linked group, in link-walk order (parents precede children).
5902 for name in &walk_order {
5903 if group_headers.contains_key(name.as_str()) {
5904 ensure_groups_for(name, &mut groups, &mut group_index_map, &mut group_headers);
5905 }
5906 }
5907
5908 // Assign each dataset to its immediate parent group, creating any
5909 // group the link walk could not decode (its chain stays placeholder).
5910 for (di, ds) in existing_datasets.iter().enumerate() {
5911 let parts: Vec<&str> = ds.name.split('/').collect();
5912 if parts.len() <= 1 {
5913 continue; // root-level dataset, no group
5914 }
5915 let parent_link_path = parts[..parts.len() - 1].join("/");
5916 ensure_groups_for(
5917 &parent_link_path,
5918 &mut groups,
5919 &mut group_index_map,
5920 &mut group_headers,
5921 );
5922 let gidx = group_index_map[&format!("/{}", parent_link_path)];
5923 groups[gidx].child_datasets.push(di);
5924 }
5925
5926 // An object the rebuild above gave up on is preserved by its bytes,
5927 // so the other links to it are preserved too: there is no registry
5928 // entry for them to name.
5929 alias_entries.retain(|entry| match unrebuilt.get(&entry.address) {
5930 None => true,
5931 Some(why) => {
5932 preserved.push(PreservedEntry {
5933 path: entry.path.clone(),
5934 class: crate::io::reader::LinkClass::Hard,
5935 encoded: entry.encoded.clone(),
5936 reason: Some(why.clone()),
5937 kind: PreservedKind::Unclassified,
5938 });
5939 false
5940 }
5941 });
5942
5943 // The one thing a rebuilt shared-message table can break: an object
5944 // kept by its bytes keeps the heap IDs its header holds, and the
5945 // finalize gives the heap those IDs name back to the allocator. Every
5946 // object the registry holds is rewritten instead
5947 // ([`rebuilds_shared_messages`](Self::rebuilds_shared_messages)), so
5948 // this asks only the preserved ones, and names the object rather than
5949 // the feature — the file is appendable the moment nothing preserved
5950 // holds a heap ID or hides a subtree that might.
5951 if sohm.is_some() {
5952 for entry in &preserved {
5953 if !matches!(entry.class, crate::io::reader::LinkClass::Hard) {
5954 continue;
5955 }
5956 let Ok((link, _)) = LinkMessage::decode(&entry.encoded, &meta.ctx) else {
5957 continue;
5958 };
5959 let LinkTarget::Hard { address } = link.target else {
5960 continue;
5961 };
5962 if let Some(blocks) = crate::io::object_header_io::blocks_shared_message_rebuild(
5963 &mut handle,
5964 &meta,
5965 address,
5966 )? {
5967 let why = entry
5968 .reason
5969 .as_deref()
5970 .unwrap_or("this writer cannot model it");
5971 return Err(crate::io::IoError::Unsupported(format!(
5972 "cannot open this file for appending: '{}' {blocks}, but {why}, so \
5973 its header keeps the bytes it has while the append lays the \
5974 shared-message table out afresh",
5975 entry.path
5976 )));
5977 }
5978 }
5979 }
5980
5981 // Rebuild the hard-link registry from the alias entries set aside
5982 // above, so the H5Ldelete semantics survive a reopen. An alias whose
5983 // target the walk could not model is not here at all: it was
5984 // preserved by its own bytes, exactly as the first link to that
5985 // object was.
5986 let mut hard_links: Vec<HardLink> = Vec::new();
5987 for HardEntry {
5988 path,
5989 address: addr,
5990 ..
5991 } in alias_entries
5992 {
5993 let target = if let Some(di) = existing_datasets
5994 .iter()
5995 .position(|d| d.obj_header_addr == addr)
5996 {
5997 HardLinkTarget::Dataset(di)
5998 } else if let Some(gi) = groups
5999 .iter()
6000 .position(|g| g.obj_header_written_addr == Some(addr))
6001 {
6002 HardLinkTarget::Group(gi)
6003 } else {
6004 continue;
6005 };
6006 let (parent, link_name) = match path.rsplit_once('/') {
6007 None => (None, path),
6008 Some((dir, leaf)) => {
6009 ensure_groups_for(dir, &mut groups, &mut group_index_map, &mut group_headers);
6010 (
6011 group_index_map.get(&format!("/{dir}")).copied(),
6012 leaf.to_string(),
6013 )
6014 }
6015 };
6016 hard_links.push(HardLink {
6017 parent,
6018 name: link_name,
6019 target,
6020 creation_seq: 0,
6021 });
6022 }
6023
6024 // Attach every link the writer cannot express to the group that
6025 // holds it, so the rewrite of that group's header emits it again.
6026 // `ensure_groups_for` registers the parent chain, which matters for
6027 // a group whose only content is such a link: nothing else would put
6028 // it in the registry, and the close would drop group and link alike.
6029 let mut preserved_links: Vec<PreservedLink> = Vec::new();
6030 for PreservedEntry {
6031 path,
6032 class,
6033 encoded,
6034 reason,
6035 kind,
6036 } in preserved
6037 {
6038 let (parent, link_name) = match path.rsplit_once('/') {
6039 None => (None, path),
6040 Some((dir, leaf)) => {
6041 ensure_groups_for(dir, &mut groups, &mut group_index_map, &mut group_headers);
6042 (
6043 group_index_map.get(&format!("/{dir}")).copied(),
6044 leaf.to_string(),
6045 )
6046 }
6047 };
6048 let (link, _) = LinkMessage::decode(&encoded, &meta.ctx)?;
6049 preserved_links.push(PreservedLink {
6050 parent,
6051 name: link_name,
6052 class,
6053 link,
6054 encoded,
6055 reason,
6056 kind,
6057 creation_seq: 0,
6058 });
6059 }
6060
6061 // Stamp the creation sequence of every reopened link. A group that
6062 // tracks creation order (every NetCDF-4 group) recorded it on each
6063 // link, and `group_links` sorts by this sequence before renumbering,
6064 // so the stored order is what keeps an append session from reordering
6065 // the group — netcdf-c lists variables, and numbers them, in creation
6066 // order. Links with no stored order (untracked groups) fall back to
6067 // discovery order: datasets, then groups, then the hard links found
6068 // beside them, then the links kept by their bytes, all after every
6069 // stored order so new links stay last.
6070 let mut creation_seq = link_corder
6071 .values()
6072 .copied()
6073 .filter(|c| *c >= 0)
6074 .max()
6075 .map_or(0, |max| max as u64 + 1);
6076 let mut stamp = |path: &str| -> u64 {
6077 match link_corder.get(path.trim_start_matches('/')) {
6078 Some(&corder) if corder >= 0 => corder as u64,
6079 _ => {
6080 let seq = creation_seq;
6081 creation_seq += 1;
6082 seq
6083 }
6084 }
6085 };
6086 for d in &mut existing_datasets {
6087 d.creation_seq = stamp(&d.name);
6088 }
6089 for g in &mut groups {
6090 g.creation_seq = stamp(&g.name);
6091 }
6092 let link_path = |parent: Option<usize>, name: &str| match parent {
6093 Some(p) => format!("{}/{}", groups[p].name.trim_start_matches('/'), name),
6094 None => name.to_string(),
6095 };
6096 for l in &mut hard_links {
6097 l.creation_seq = stamp(&link_path(l.parent, &l.name));
6098 }
6099 for l in &mut preserved_links {
6100 l.creation_seq = stamp(&link_path(l.parent, &l.name));
6101 }
6102
6103 // The strategy is the file's, not this session's: a paged file
6104 // allocates on its own page grid however it was opened, `persist`
6105 // deciding only whether the managers survive the close.
6106 let allocator = FileAllocator::with_policy(
6107 file_size,
6108 ext.file_space_info
6109 .as_ref()
6110 .map_or(free_space::SpacePolicy::Aggr, |info| {
6111 free_space::SpacePolicy::for_message(info)
6112 }),
6113 );
6114 // The sections the file's own managers recorded are free space, so
6115 // they are what this session allocates from first — `H5MF_alloc` asks
6116 // the free-space manager before it bumps the end of the file, and a
6117 // reopen that skipped this would grow a file that had room.
6118 allocator.reset_free_list(&reopened_free_space.sections);
6119
6120 // Now that every object has its registry index, key the dense storage
6121 // found on disk by the scope that will supersede it. A group the link
6122 // walk saw but never registered is not rewritten either, so leaving it
6123 // out is what keeps its storage referenced.
6124 let mut superseded = SupersededDense {
6125 attrs: dataset_dense
6126 .into_iter()
6127 .map(|(di, ainfo)| (AttrScope::Dataset(di), ainfo))
6128 .collect(),
6129 links: HashMap::new(),
6130 };
6131 superseded
6132 .attrs
6133 .extend(root_dense.attrs.map(|a| (AttrScope::Root, a)));
6134 superseded
6135 .links
6136 .extend(root_dense.links.map(|l| (LinkScope::Root, l)));
6137 for (name, dense) in group_dense {
6138 let Some(&gidx) = group_index_map.get(&format!("/{name}")) else {
6139 continue;
6140 };
6141 superseded
6142 .attrs
6143 .extend(dense.attrs.map(|a| (AttrScope::Group(gidx), a)));
6144 superseded
6145 .links
6146 .extend(dense.links.map(|l| (LinkScope::Group(gidx), l)));
6147 }
6148 let superseded = (!superseded.attrs.is_empty() || !superseded.links.is_empty())
6149 .then(|| Box::new(superseded));
6150
6151 // The same keying for the symbol-table storage. Built from the headers
6152 // alone, not from the superblock version: a group whose header carried
6153 // no Symbol Table message contributes nothing — what happens to a group
6154 // libhdf5 wrote at a newer bound inside an otherwise classic file — and
6155 // one that carried it keeps its storage even where the superblock is
6156 // version 2, which is what a file with shared messages is.
6157 let mut stabs: HashMap<LinkScope, StabExtents> = HashMap::new();
6158 stabs.extend(root_stab.map(|s| (LinkScope::Root, s)));
6159 for (name, extents) in group_stabs {
6160 if let Some(&gidx) = group_index_map.get(&format!("/{name}")) {
6161 stabs.insert(LinkScope::Group(gidx), extents);
6162 }
6163 }
6164 let symbol_tables = SymbolTables {
6165 found: stabs.keys().copied().collect(),
6166 superseded: Slot::new(stabs),
6167 written: Slot::new(HashMap::new()),
6168 };
6169
6170 // The superblock the close re-emits, and the generation every message
6171 // this session encodes belongs to.
6172 let legacy = legacy.map(|superblock| Box::new(LegacyFile { superblock }));
6173
6174 // Wrap the reconstructed plain vecs into the per-slot registry. The
6175 // reconstruction logic above runs single-threaded on local `Vec`s;
6176 // only the final hand-off needs the `Shared<Slot<_>>` shape.
6177 let datasets = existing_datasets
6178 .into_iter()
6179 .map(|i| Shared::new(DatasetCell::new(i)))
6180 .collect();
6181 let groups = groups
6182 .into_iter()
6183 .map(|g| Shared::new(Slot::new(g)))
6184 .collect();
6185
6186 let writer = Self {
6187 handle,
6188 allocator,
6189 ctx,
6190 datasets: Slot::new(datasets),
6191 groups: Slot::new(groups),
6192 hard_links: Slot::new(hard_links),
6193 // A reopen carries the soft and external links it found as
6194 // `preserved_links`, byte for byte; this list holds only the ones
6195 // created in this session.
6196 symbolic_links: Slot::new(Vec::new()),
6197 committed_datatypes: Slot::new(Vec::new()),
6198 preserved_links: Slot::new(preserved_links),
6199 name_index: Slot::new(Box::new(NameIndex::new())),
6200 root_attributes: Slot::new(root_attributes),
6201 create_lock: Slot::new(()),
6202 // A reopen names no bound, so the session appends at the same
6203 // default a create uses. `set_libver_bound` is where a caller
6204 // names one, exactly as `H5Fopen` takes a fapl.
6205 libver: None,
6206 closed: false,
6207 swmr_active: false,
6208 cwfs: Slot::new(Vec::new()),
6209 root_group_addr: None,
6210 superseded_root_header: root_header_blocks,
6211 // The version the file already has, written back unchanged.
6212 superblock_version: SuperblockVersion::Existing(version),
6213 // The reopened file's own policy, so objects added in this
6214 // session are made the way the file already declares.
6215 root_track_order,
6216 root_times,
6217 dense_attributes: Slot::new(HashMap::new()),
6218 dense_links: Slot::new(HashMap::new()),
6219 superseded_dense: Slot::new(superseded),
6220 track_order: root_track_order,
6221 // Not recovered from the file the way the creation-order policy
6222 // is: a version-1 header leaves no trace of whether the object was
6223 // tracking times, so there is nothing on disk to read the policy
6224 // back from. An object added to a reopened file gets this writer's
6225 // own default, the same one a created file starts at.
6226 track_times: false,
6227 next_creation_seq: Slot::new(creation_seq),
6228 pending_object_references: Slot::new(Vec::new()),
6229 pending_heap_references: Slot::new(Vec::new()),
6230 attribute_references: Slot::new(Vec::new()),
6231 legacy,
6232 symbol_tables,
6233 // The ranks the superblock or its extension declared, which every
6234 // v1-B-tree and symbol-table node this session writes is sized by.
6235 btree: meta.btree,
6236 extension,
6237 free_space: reopened_free_space.state,
6238 // The indexes the file was created with, and the blocks its
6239 // current table occupies; the next finalize lays a new table out
6240 // over them from the whole message set.
6241 sohm,
6242 source_dir: source_dir_of(path)?,
6243 };
6244 // The link graph is complete only now, so this is the first point the
6245 // count each on-disk header was written with can be read off it: in a
6246 // well-formed file the links the walk found reaching an object *are*
6247 // that count, so nothing has to be decoded out of the headers.
6248 for i in 0..writer.dataset_count() {
6249 let nlink = writer.object_link_count(HardLinkTarget::Dataset(i));
6250 writer.ds(i).lock().nlink_written = nlink;
6251 }
6252 Ok(writer)
6253 }
6254
6255 /// Return the names of all datasets created so far.
6256 pub fn dataset_names(&self) -> Vec<String> {
6257 self.dataset_refs()
6258 .iter()
6259 .filter_map(|d| {
6260 let g = d.lock();
6261 (!g.deleted).then(|| g.name.clone())
6262 })
6263 .collect()
6264 }
6265
6266 /// Find a dataset index by name. Like `H5Dopen`, the name may be any
6267 /// link path to the dataset: a user hard link's path — or a path
6268 /// whose group components pass through such links — resolves to its
6269 /// target.
6270 pub fn dataset_index(&self, name: &str) -> Option<usize> {
6271 let name = self.canonical_dataset_path(name);
6272 self.dataset_refs()
6273 .iter()
6274 .position(|d| {
6275 let g = d.lock();
6276 g.name == name && !g.deleted
6277 })
6278 .or_else(|| {
6279 self.hard_links_vec().iter().find_map(|l| match l.target {
6280 HardLinkTarget::Dataset(i)
6281 if self.hard_link_emitted(l) && self.hard_link_full_path(l) == name =>
6282 {
6283 Some(i)
6284 }
6285 _ => None,
6286 })
6287 })
6288 }
6289
6290 /// Reconstruct the fields a writer-mode `H5Dataset` handle needs for the
6291 /// dataset at `index`, and open it under `access`. Single owner of this
6292 /// mapping so `H5File::dataset_writer`, `H5Group::dataset_writer`, and
6293 /// the vlen-string helpers all agree — including on
6294 /// [`bind_efile_prefix`](Self::bind_efile_prefix), which no handle site
6295 /// can then forget to run.
6296 pub(crate) fn dataset_handle_parts(
6297 &self,
6298 index: usize,
6299 access: &DatasetAccess,
6300 ) -> IoResult<DatasetHandleParts> {
6301 let open = self.bind_efile_prefix(index, access)?;
6302 let ds = self.ds(index);
6303 let g = ds.lock();
6304 Ok(DatasetHandleParts {
6305 shape: g.dataspace.dims.iter().map(|&d| d as usize).collect(),
6306 element_size: g.datatype.element_size() as usize,
6307 chunk_index: g.chunk_index_kind(),
6308 open,
6309 })
6310 }
6311
6312 /// Put `access`'s external file prefix in force for the dataset at
6313 /// `index`, or join the open that already settled one.
6314 ///
6315 /// INVARIANT: every write of an externally stored dataset's raw bytes
6316 /// joins its slot names against the prefix an *open* settled, and this is
6317 /// the only place that settles one. `write_contiguous_bytes` reads it and
6318 /// nothing else writes it, so a write cannot resolve a prefix of its own
6319 /// and land bytes where a read under the same properties would not look
6320 /// for them.
6321 ///
6322 /// First open wins, and a joining open may not disagree: `H5D__open_name`
6323 /// compares its own expanded prefix against the open dataset's and fails
6324 /// when they differ (H5Dint.c:1533-1545). Measured under libhdf5 1.14.6
6325 /// and 2.0.0, with a dataset created through a dapl naming a directory
6326 /// and its handle still alive: a second open naming another directory is
6327 /// refused, one naming the same directory joins, one naming none is
6328 /// refused too, and with `HDF5_EXTFILE_PREFIX` set — which shadows every
6329 /// property, so all three expand alike — none of them is. Dropping every
6330 /// handle releases the answer and the next open settles it afresh, which
6331 /// the same measurement confirms.
6332 ///
6333 /// Returns the token that keeps the open alive, `None` for a dataset
6334 /// whose raw data is in this file and which therefore has no prefix to
6335 /// agree about.
6336 pub(crate) fn bind_efile_prefix(
6337 &self,
6338 index: usize,
6339 access: &DatasetAccess,
6340 ) -> IoResult<Option<crate::io::reader::DatasetOpenToken>> {
6341 let ds = self.ds(index);
6342 let mut g = ds.lock();
6343 let source_dir = &self.source_dir;
6344 let Some(ext) = g.external.as_mut() else {
6345 return Ok(None);
6346 };
6347 let want =
6348 crate::io::reader::resolve_extfile_prefix(access.efile_prefix_value(), source_dir);
6349 if let Some(open) = ext.prefix.open.upgrade() {
6350 if ext.prefix.expanded != want {
6351 let name = g.name.clone();
6352 return Err(crate::io::IoError::InvalidState(format!(
6353 "dataset {name:?} is already open under a different external file prefix, and libhdf5 refuses to join an open that disagrees about one"
6354 )));
6355 }
6356 return Ok(Some(open));
6357 }
6358 let token: crate::io::reader::DatasetOpenToken = std::sync::Arc::new(());
6359 ext.prefix = EfilePrefix {
6360 expanded: want,
6361 open: std::sync::Arc::downgrade(&token),
6362 };
6363 Ok(Some(token))
6364 }
6365
6366 /// Reject a name some other link in the file already occupies.
6367 ///
6368 /// `name` is the registry's full-path form, with no leading `/`. HDF5
6369 /// requires link names to be unique within their group, and every kind of
6370 /// link this writer can emit competes for the same name: a dataset's own
6371 /// link, a group's, a user hard link, a soft or external link, and a link
6372 /// a reopen is carrying through verbatim. This is the one place that list
6373 /// is written down, so a creator cannot be blind to a kind it does not
6374 /// itself make — nor a kind added after it.
6375 fn ensure_name_free(&self, name: &str) -> IoResult<()> {
6376 let holder = self.name_holder(name);
6377 // The index is a filter over the registries, not a second copy of
6378 // them, so a debug build re-derives the answer on every create: a
6379 // name it failed to record surfaces as a failing assertion in the
6380 // suite rather than as two links of one name in somebody's file.
6381 #[cfg(debug_assertions)]
6382 assert_eq!(
6383 holder,
6384 self.scan_name_holder(name),
6385 "the name index disagrees with the registries for '{name}'"
6386 );
6387 match holder {
6388 None => Ok(()),
6389 Some(kind) => Err(crate::io::IoError::InvalidState(format!(
6390 "a {kind} named '{name}' already exists"
6391 ))),
6392 }
6393 }
6394
6395 /// What already holds `name`, or `None` if it is free.
6396 ///
6397 /// The kinds answer in a fixed order — dataset, group, committed
6398 /// datatype, hard link, symbolic link, preserved link — because the
6399 /// refusal names the first one that holds it. [`NameIndex`] narrows each
6400 /// kind to the entries that ever took this name; every candidate is then
6401 /// put through the same predicate the full scan used, so a hit left
6402 /// behind by a delete or a rename answers exactly as an absent one does.
6403 fn name_holder(&self, name: &str) -> Option<&'static str> {
6404 self.build_name_index();
6405 let hits: Vec<NameHit> = {
6406 let index = self.name_index.lock();
6407 index.map.as_ref().and_then(|m| m.get(name))?.clone()
6408 };
6409 for hit in &hits {
6410 if let NameHit::Dataset(i) = *hit {
6411 let ds = self.ds(i);
6412 let d = ds.lock();
6413 if !d.deleted && d.name == name {
6414 return Some("dataset");
6415 }
6416 }
6417 }
6418 for hit in &hits {
6419 if let NameHit::Group(i) = *hit {
6420 let grp = self.grp(i);
6421 let g = grp.lock();
6422 if !g.deleted && g.name.trim_start_matches('/') == name {
6423 return Some("group");
6424 }
6425 }
6426 }
6427 for hit in &hits {
6428 if let NameHit::Datatype(i) = *hit {
6429 // The registry lock goes before `parent_alive` takes a group
6430 // slot, never across it.
6431 let (parent, held) = {
6432 let reg = self.committed_datatypes.lock();
6433 (reg[i].parent, reg[i].name == name)
6434 };
6435 if held && self.parent_alive(parent) {
6436 return Some("committed datatype");
6437 }
6438 }
6439 }
6440 if hits.contains(&NameHit::HardLink)
6441 && self
6442 .hard_links_vec()
6443 .iter()
6444 .any(|l| self.hard_link_emitted(l) && self.hard_link_full_path(l) == name)
6445 {
6446 return Some("hard link");
6447 }
6448 if hits.contains(&NameHit::SymbolicLink)
6449 && self
6450 .symbolic_links_vec()
6451 .iter()
6452 .any(|l| self.symbolic_link_emitted(l) && self.symbolic_link_full_path(l) == name)
6453 {
6454 return Some("link");
6455 }
6456 // A preserved link occupies its name in the group just as a modelled
6457 // one does; both are emitted, and two link messages of one name in a
6458 // group is an invalid file.
6459 if hits.contains(&NameHit::PreservedLink)
6460 && self.preserved_link_paths().iter().any(|(p, _)| *p == name)
6461 {
6462 return Some("link");
6463 }
6464 None
6465 }
6466
6467 /// The same answer read straight off the registries, which is what the
6468 /// index is checked against in a debug build.
6469 #[cfg(debug_assertions)]
6470 fn scan_name_holder(&self, name: &str) -> Option<&'static str> {
6471 if self.dataset_refs().iter().any(|d| {
6472 let g = d.lock();
6473 !g.deleted && g.name == name
6474 }) {
6475 return Some("dataset");
6476 }
6477 if self.group_refs().iter().any(|g| {
6478 let gg = g.lock();
6479 !gg.deleted && gg.name.trim_start_matches('/') == name
6480 }) {
6481 return Some("group");
6482 }
6483 if self
6484 .committed_datatypes_vec()
6485 .iter()
6486 .any(|c| self.parent_alive(c.parent) && c.name == name)
6487 {
6488 return Some("committed datatype");
6489 }
6490 if self
6491 .hard_links_vec()
6492 .iter()
6493 .any(|l| self.hard_link_emitted(l) && self.hard_link_full_path(l) == name)
6494 {
6495 return Some("hard link");
6496 }
6497 if self
6498 .symbolic_links_vec()
6499 .iter()
6500 .any(|l| self.symbolic_link_emitted(l) && self.symbolic_link_full_path(l) == name)
6501 {
6502 return Some("link");
6503 }
6504 if self.preserved_link_paths().iter().any(|(p, _)| *p == name) {
6505 return Some("link");
6506 }
6507 None
6508 }
6509
6510 /// Build the name index unless it is already built.
6511 ///
6512 /// The walk takes the registry spines and their slots, so it runs with no
6513 /// index lock held — the writer never holds one lock across another — and
6514 /// the result is kept only if nothing renamed, created or unlinked
6515 /// anything while it ran.
6516 fn build_name_index(&self) {
6517 let epoch = {
6518 let index = self.name_index.lock();
6519 if index.map.is_some() {
6520 return;
6521 }
6522 index.epoch
6523 };
6524 let mut map: HashMap<String, Vec<NameHit>> = HashMap::new();
6525 for (i, ds) in self.dataset_refs().iter().enumerate() {
6526 let d = ds.lock();
6527 if !d.deleted {
6528 map.entry(d.name.clone())
6529 .or_default()
6530 .push(NameHit::Dataset(i));
6531 }
6532 }
6533 for (i, grp) in self.group_refs().iter().enumerate() {
6534 let g = grp.lock();
6535 if !g.deleted {
6536 map.entry(g.name.trim_start_matches('/').to_string())
6537 .or_default()
6538 .push(NameHit::Group(i));
6539 }
6540 }
6541 for (i, c) in self.committed_datatypes_vec().iter().enumerate() {
6542 map.entry(c.name.clone())
6543 .or_default()
6544 .push(NameHit::Datatype(i));
6545 }
6546 for l in self.hard_links_vec().iter() {
6547 map.entry(self.hard_link_full_path(l))
6548 .or_default()
6549 .push(NameHit::HardLink);
6550 }
6551 for l in self.symbolic_links_vec().iter() {
6552 map.entry(self.symbolic_link_full_path(l))
6553 .or_default()
6554 .push(NameHit::SymbolicLink);
6555 }
6556 for (path, _) in self.preserved_link_paths() {
6557 map.entry(path).or_default().push(NameHit::PreservedLink);
6558 }
6559 let mut index = self.name_index.lock();
6560 if index.map.is_none() && index.epoch == epoch {
6561 index.map = Some(map);
6562 }
6563 }
6564
6565 /// Record that `hit` now holds `name` — the one way a new name enters the
6566 /// index, called from every push that gives a registry entry a name.
6567 fn register_name(&self, name: &str, hit: NameHit) {
6568 self.name_index.lock().insert(name, hit);
6569 }
6570
6571 /// Drop the index because something moved names wholesale (a group
6572 /// rename carries its subtree and every link path under it).
6573 fn forget_name_index(&self) {
6574 self.name_index.lock().forget();
6575 }
6576
6577 /// Delete a dataset name, with libhdf5's `H5Ldelete` semantics: a name
6578 /// is only a link. If `name` is a user hard link's path, just that
6579 /// link is removed and the object is untouched. If it is the tree name
6580 /// and a user hard link still names the object, the object survives
6581 /// under it — the link becomes the primary name and nothing is freed.
6582 /// Only deleting the *last* name soft-deletes the object and frees the
6583 /// file space it owned: its chunk blocks and chunk-index structures
6584 /// (or contiguous data block), the global-heap objects of its
6585 /// variable-length data and attributes, and — on a reopened file — the
6586 /// on-disk object header block. The freed space is reused by later
6587 /// allocations in this session; the file does not shrink.
6588 ///
6589 /// Refused while SWMR streaming is active: a live reader may hold any
6590 /// of those addresses (libhdf5 forbids link deletion during SWMR
6591 /// writes too).
6592 pub fn delete_dataset(&self, name: &str) -> IoResult<()> {
6593 if self.swmr_active {
6594 return Err(swmr_delete_error(name));
6595 }
6596 self.reject_external_traversal(name)?;
6597 // The gate keeps the link list and child lists still while this
6598 // delete reads and rewrites them (create_lock → op → slot order,
6599 // the same as every creator).
6600 let _create = self.create_lock.lock();
6601 // `H5Ldelete` resolves the path through links only *up to* the
6602 // leaf — the leaf is what gets deleted, so a leaf naming a user
6603 // link must stay literal and be unlinked, not its target.
6604 let name = match name.rsplit_once('/') {
6605 None => name.to_string(),
6606 Some((dir, leaf)) => format!(
6607 "{}/{leaf}",
6608 self.canonical_group_path(&format!("/{dir}"))
6609 .trim_start_matches('/')
6610 ),
6611 };
6612 let refs = self.dataset_refs();
6613 let idx = match refs.iter().position(|d| {
6614 let g = d.lock();
6615 g.name == name && !g.deleted
6616 }) {
6617 Some(i) => i,
6618 None => {
6619 // Not a tree name — the path may name a user hard link,
6620 // and deleting a link path unlinks just that link (the
6621 // creation collision checks keep the two namespaces
6622 // disjoint, so the order of the lookups cannot matter).
6623 let link = self.hard_links_vec().iter().position(|l| {
6624 self.hard_link_emitted(l)
6625 && matches!(l.target, HardLinkTarget::Dataset(_))
6626 && self.hard_link_full_path(l) == name
6627 });
6628 let Some(pos) = link else {
6629 return Err(crate::io::IoError::NotFound(name));
6630 };
6631 self.hard_links.lock().remove(pos);
6632 return Ok(());
6633 }
6634 };
6635 // A surviving hard link keeps the object: promote the first one to
6636 // the primary name and delete nothing.
6637 let promote = self.hard_links_vec().iter().position(|l| {
6638 self.hard_link_emitted(l) && matches!(l.target, HardLinkTarget::Dataset(i) if i == idx)
6639 });
6640 if let Some(pos) = promote {
6641 self.promote_dataset_to_link(idx, pos);
6642 return Ok(());
6643 }
6644 refs[idx].lock().deleted = true;
6645 // Remove from parent group's child_datasets
6646 for grp in self.group_refs() {
6647 grp.lock().child_datasets.retain(|&di| di != idx);
6648 }
6649 self.purge_dead_links();
6650 let ds = self.ds(idx);
6651 let _op = ds.op.lock();
6652 self.release_dataset_storage(idx)
6653 }
6654
6655 /// Soft-delete a group and all its child datasets and sub-groups,
6656 /// freeing every deleted object's file space the way
6657 /// [`delete_dataset`](Self::delete_dataset) does — with the same
6658 /// `H5Ldelete` semantics: a `name` that is a user hard link's path
6659 /// unlinks just that link, and hard links from *outside* the subtree
6660 /// keep their targets. A dataset or group such a link names survives,
6661 /// re-homed under the link (a group brings its whole subtree with
6662 /// it); a link naming the deleted group itself turns the call into a
6663 /// pure rename and nothing is freed. Refused while SWMR streaming is
6664 /// active, same rule as `delete_dataset`.
6665 pub fn delete_group(&self, name: &str) -> IoResult<()> {
6666 if self.swmr_active {
6667 return Err(swmr_delete_error(name));
6668 }
6669 self.reject_external_traversal(name)?;
6670 // Same gate as `delete_dataset`: the pre-scan below and the
6671 // promotions must see a still link list and child lists.
6672 let _create = self.create_lock.lock();
6673 let name = if name.starts_with('/') {
6674 name.to_string()
6675 } else {
6676 format!("/{}", name)
6677 };
6678 // Leaf stays literal, directory resolves through links — the
6679 // same `H5Ldelete` rule as `delete_dataset`.
6680 let name = match name.rsplit_once('/') {
6681 Some((dir, leaf)) if !dir.is_empty() => {
6682 format!("{}/{leaf}", self.canonical_group_path(dir))
6683 }
6684 _ => name,
6685 };
6686 let groups = self.group_refs();
6687 let gidx = match groups.iter().position(|g| {
6688 let gg = g.lock();
6689 gg.name == name && !gg.deleted
6690 }) {
6691 Some(i) => i,
6692 None => {
6693 // Same `H5Ldelete` rule as `delete_dataset`: a path naming
6694 // a user hard link to a group unlinks just that link.
6695 let trimmed = name.trim_start_matches('/');
6696 let link = self.hard_links_vec().iter().position(|l| {
6697 self.hard_link_emitted(l)
6698 && matches!(l.target, HardLinkTarget::Group(_))
6699 && self.hard_link_full_path(l) == trimmed
6700 });
6701 let Some(pos) = link else {
6702 return Err(crate::io::IoError::NotFound(name.clone()));
6703 };
6704 self.hard_links.lock().remove(pos);
6705 return Ok(());
6706 }
6707 };
6708
6709 // A link is "outside" when its parent group does not die with the
6710 // subtree; only outside links can keep their targets alive.
6711 fn outside(parent: Option<usize>, doomed_gs: &[usize]) -> bool {
6712 match parent {
6713 None => true,
6714 Some(pi) => !doomed_gs.contains(&pi),
6715 }
6716 }
6717 // A group an outside link names survives, re-homed with its whole
6718 // subtree under the link. Each promotion moves that subtree out of
6719 // the doomed set — and can turn a link inside it into an outside
6720 // one — so rescan from scratch until no promotable group is left.
6721 // Promoting `gidx` itself makes the delete a pure rename: return.
6722 let mut doomed_ds = Vec::new();
6723 let mut doomed_gs = Vec::new();
6724 loop {
6725 doomed_ds.clear();
6726 doomed_gs.clear();
6727 self.collect_live_subtree(gidx, &mut doomed_ds, &mut doomed_gs);
6728 let promote = self
6729 .hard_links_vec()
6730 .iter()
6731 .enumerate()
6732 .find_map(|(pos, l)| match l.target {
6733 HardLinkTarget::Group(gi)
6734 if self.hard_link_emitted(l)
6735 && outside(l.parent, &doomed_gs)
6736 && doomed_gs.contains(&gi) =>
6737 {
6738 Some((pos, gi))
6739 }
6740 _ => None,
6741 });
6742 let Some((pos, gi)) = promote else { break };
6743 self.promote_group_to_link(gi, pos);
6744 if gi == gidx {
6745 return Ok(());
6746 }
6747 }
6748 // A dataset an outside link names survives its container: re-home
6749 // it under the link now, so the marking pass below never sees it.
6750 for di in doomed_ds {
6751 let promote = self.hard_links_vec().iter().position(|l| {
6752 self.hard_link_emitted(l)
6753 && outside(l.parent, &doomed_gs)
6754 && matches!(l.target, HardLinkTarget::Dataset(i) if i == di)
6755 });
6756 if let Some(pos) = promote {
6757 self.promote_dataset_to_link(di, pos);
6758 }
6759 }
6760
6761 let mut ds_deleted = Vec::new();
6762 let mut gs_deleted = Vec::new();
6763 self.delete_group_recursive(gidx, &mut ds_deleted, &mut gs_deleted);
6764 // Remove from parent's child_groups
6765 let parent = groups[gidx].lock().parent;
6766 if let Some(pidx) = parent {
6767 groups[pidx].lock().child_groups.retain(|&gi| gi != gidx);
6768 }
6769 self.purge_dead_links();
6770 // Free storage only after the whole subtree is marked: the lists
6771 // hold each object exactly once (the marking pass skips anything
6772 // already deleted), so nothing is freed twice.
6773 for di in ds_deleted {
6774 let ds = self.ds(di);
6775 let _op = ds.op.lock();
6776 self.release_dataset_storage(di)?;
6777 }
6778 for gi in gs_deleted {
6779 self.release_group_storage(gi)?;
6780 }
6781 Ok(())
6782 }
6783
6784 /// Collect the live (not soft-deleted) members of `gidx`'s subtree,
6785 /// each exactly once, without changing anything — the read-only twin
6786 /// of [`delete_group_recursive`](Self::delete_group_recursive), for
6787 /// the pre-scan that must run before any marking.
6788 fn collect_live_subtree(&self, gidx: usize, ds_out: &mut Vec<usize>, gs_out: &mut Vec<usize>) {
6789 if gs_out.contains(&gidx) {
6790 return;
6791 }
6792 let (child_ds, child_gs) = {
6793 let grp = self.grp(gidx);
6794 let g = grp.lock();
6795 if g.deleted {
6796 return;
6797 }
6798 (g.child_datasets.clone(), g.child_groups.clone())
6799 };
6800 gs_out.push(gidx);
6801 for di in child_ds {
6802 if !self.ds(di).lock().deleted && !ds_out.contains(&di) {
6803 ds_out.push(di);
6804 }
6805 }
6806 for gi in child_gs {
6807 self.collect_live_subtree(gi, ds_out, gs_out);
6808 }
6809 }
6810
6811 /// Re-home dataset `idx` under the hard link at `pos` in the link
6812 /// list — the surviving half of `H5Ldelete`: the link leaves the user
6813 /// list and becomes the dataset's primary (tree) name, in the link's
6814 /// parent group. Storage is untouched; any further links to the
6815 /// dataset stay in the list and keep resolving.
6816 fn promote_dataset_to_link(&self, idx: usize, pos: usize) {
6817 let link = self.hard_links.lock().remove(pos);
6818 let new_name = self.hard_link_full_path(&link);
6819 for grp in self.group_refs() {
6820 grp.lock().child_datasets.retain(|&di| di != idx);
6821 }
6822 if let Some(pi) = link.parent {
6823 self.grp(pi).lock().child_datasets.push(idx);
6824 }
6825 self.ds(idx).lock().name = new_name.clone();
6826 self.register_name(&new_name, NameHit::Dataset(idx));
6827 }
6828
6829 /// The group counterpart of
6830 /// [`promote_dataset_to_link`](Self::promote_dataset_to_link): re-home
6831 /// group `gidx` under the hard link at `pos`, bringing its whole
6832 /// subtree with it. Names are stored as full paths, so every live
6833 /// descendant is renamed by prefix.
6834 fn promote_group_to_link(&self, gidx: usize, pos: usize) {
6835 let link = self.hard_links.lock().remove(pos);
6836 let new_name = format!("/{}", self.hard_link_full_path(&link));
6837 let old_name = self.grp(gidx).lock().name.clone();
6838 for grp in self.group_refs() {
6839 grp.lock().child_groups.retain(|&g| g != gidx);
6840 }
6841 {
6842 let grp = self.grp(gidx);
6843 let mut g = grp.lock();
6844 g.parent = link.parent;
6845 g.name = new_name.clone();
6846 }
6847 if let Some(pi) = link.parent {
6848 self.grp(pi).lock().child_groups.push(gidx);
6849 }
6850
6851 let mut ds_in = Vec::new();
6852 let mut gs_in = Vec::new();
6853 self.collect_live_subtree(gidx, &mut ds_in, &mut gs_in);
6854 // Group names carry a leading '/' ("/a/b"), dataset names none
6855 // ("a/b/ds") — two prefix forms of the same rename.
6856 let old_grp_prefix = format!("{old_name}/");
6857 let new_grp_prefix = format!("{new_name}/");
6858 let old_ds_prefix = old_grp_prefix.trim_start_matches('/').to_string();
6859 let new_ds_prefix = new_grp_prefix.trim_start_matches('/').to_string();
6860 for gi in gs_in {
6861 if gi == gidx {
6862 continue;
6863 }
6864 let grp = self.grp(gi);
6865 let mut g = grp.lock();
6866 let renamed = g
6867 .name
6868 .strip_prefix(&old_grp_prefix)
6869 .map(|rest| format!("{new_grp_prefix}{rest}"));
6870 if let Some(n) = renamed {
6871 g.name = n;
6872 }
6873 }
6874 for di in ds_in {
6875 let ds = self.ds(di);
6876 let mut d = ds.lock();
6877 let renamed = d
6878 .name
6879 .strip_prefix(&old_ds_prefix)
6880 .map(|rest| format!("{new_ds_prefix}{rest}"));
6881 if let Some(n) = renamed {
6882 d.name = n;
6883 }
6884 }
6885 // A group carries its subtree and every link path under it, so far
6886 // more names moved than this function can enumerate: start over.
6887 self.forget_name_index();
6888 }
6889
6890 /// Drop link entries that can no longer be emitted — their parent group
6891 /// or, for a hard link, their target object was just deleted — so the
6892 /// lists mirror what the file will hold instead of carrying suppressed
6893 /// zombies. Both kinds are purged here so a delete cannot clear one list
6894 /// and leave the other holding a name in a group that is gone.
6895 fn purge_dead_links(&self) {
6896 let dead: Vec<usize> = self
6897 .hard_links_vec()
6898 .iter()
6899 .enumerate()
6900 .filter(|(_, l)| !self.hard_link_emitted(l))
6901 .map(|(p, _)| p)
6902 .collect();
6903 let mut links = self.hard_links.lock();
6904 for p in dead.into_iter().rev() {
6905 links.remove(p);
6906 }
6907 drop(links);
6908
6909 let dead: Vec<usize> = self
6910 .symbolic_links_vec()
6911 .iter()
6912 .enumerate()
6913 .filter(|(_, l)| !self.symbolic_link_emitted(l))
6914 .map(|(p, _)| p)
6915 .collect();
6916 let mut links = self.symbolic_links.lock();
6917 for p in dead.into_iter().rev() {
6918 links.remove(p);
6919 }
6920 }
6921
6922 /// Mark `gidx` and its subtree deleted, appending each newly-deleted
6923 /// object's index to `ds_out` / `gs_out` exactly once — the caller
6924 /// frees their storage, and an object reachable twice (or a subtree
6925 /// already deleted) must not be freed twice.
6926 fn delete_group_recursive(
6927 &self,
6928 gidx: usize,
6929 ds_out: &mut Vec<usize>,
6930 gs_out: &mut Vec<usize>,
6931 ) {
6932 // Mark deleted and snapshot the child lists, releasing the group lock
6933 // before locking any dataset/child-group slot (spine → slot order).
6934 let (child_ds, child_gs) = {
6935 let grp = self.grp(gidx);
6936 let mut g = grp.lock();
6937 if g.deleted {
6938 return;
6939 }
6940 g.deleted = true;
6941 (g.child_datasets.clone(), g.child_groups.clone())
6942 };
6943 gs_out.push(gidx);
6944 for di in child_ds {
6945 let ds = self.ds(di);
6946 let mut d = ds.lock();
6947 if !d.deleted {
6948 d.deleted = true;
6949 ds_out.push(di);
6950 }
6951 }
6952 for gi in child_gs {
6953 self.delete_group_recursive(gi, ds_out, gs_out);
6954 }
6955 }
6956
6957 /// Free everything a soft-deleted dataset owned. The single owner of
6958 /// delete-time reclamation, called only from the two delete paths with
6959 /// the dataset already marked deleted and its op lock held.
6960 ///
6961 /// A deleted dataset contributes nothing to finalize (the header,
6962 /// index-flush and append-flush loops all skip it), so nothing in the
6963 /// finalized file can reference the blocks freed here. Never runs under
6964 /// SWMR — the delete entry points refuse first.
6965 fn release_dataset_storage(&self, index: usize) -> IoResult<()> {
6966 use crate::format::messages::datatype::DatatypeMessage;
6967 let (indexed, ndims, contiguous, is_vlen, attrs, header_blocks, mapping_list) = {
6968 let ds = self.ds(index);
6969 let mut m = ds.lock();
6970 // Buffered rows were never written to a chunk; they die with
6971 // the dataset instead of being flushed at close.
6972 m.append = None;
6973 let indexed = m.is_chunked();
6974 let contiguous = (!indexed && m.data_addr != UNDEF_ADDR && m.data_size > 0)
6975 .then_some((m.data_addr, m.data_size));
6976 m.data_addr = UNDEF_ADDR;
6977 m.data_size = 0;
6978 // The external files themselves are the application's, not this
6979 // file's, and neither is the name heap freed: `H5O_MSG_EFL`
6980 // installs no file-delete method, so libhdf5 leaves the heap block
6981 // behind too. Dropping the list is what stops a deleted dataset
6982 // still claiming storage.
6983 m.external = None;
6984 // The mapping list is this file's own metadata, so unlike the
6985 // external files above it *is* freed — `H5D__virtual_delete`
6986 // removes the heap object. The source datasets it named are
6987 // another file's and are left alone.
6988 let mapping_list = m
6989 .virtual_storage
6990 .take()
6991 .and_then(|v| u16::try_from(v.heap_index).ok().map(|i| (v.heap_addr, i)));
6992 let is_vlen = matches!(
6993 m.datatype,
6994 DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
6995 );
6996 let attrs = std::mem::take(&mut m.attributes);
6997 m.obj_header_written_addr = None;
6998 let header_blocks = std::mem::take(&mut m.obj_header_blocks);
6999 (
7000 indexed,
7001 m.dataspace.dims.len(),
7002 contiguous,
7003 is_vlen,
7004 attrs,
7005 header_blocks,
7006 mapping_list,
7007 )
7008 };
7009 if let Some((addr, idx)) = mapping_list {
7010 self.remove_heap_objects([(addr, vec![idx])].into_iter().collect())?;
7011 }
7012 if indexed {
7013 // Prune to a zero extent: every stored chunk is entirely beyond
7014 // it, so the walk frees each chunk block and collects the vlen
7015 // references its bytes held (released inside).
7016 self.prune_chunks_beyond(index, &vec![0; ndims])?;
7017 self.free_chunk_index(index)?;
7018 } else if let Some((addr, size)) = contiguous {
7019 if is_vlen {
7020 let data = self.handle.read_at(addr, size as usize)?;
7021 self.release_vlen_references(&data)?;
7022 }
7023 self.allocator.free(addr, size, FreeSpaceClass::RawData);
7024 }
7025 for attr in &attrs {
7026 self.release_attr_vlen(attr)?;
7027 }
7028 self.release_superseded_dense_attrs(AttrScope::Dataset(index))?;
7029 for (addr, size) in header_blocks {
7030 self.allocator.free(addr, size, FreeSpaceClass::Metadata);
7031 }
7032 Ok(())
7033 }
7034
7035 /// Free a deleted group's file space: its attributes' global-heap
7036 /// objects and, on a reopened file, the on-disk header block. The
7037 /// group counterpart of
7038 /// [`release_dataset_storage`](Self::release_dataset_storage).
7039 fn release_group_storage(&self, gidx: usize) -> IoResult<()> {
7040 let (attrs, header_blocks) = {
7041 let grp = self.grp(gidx);
7042 let mut g = grp.lock();
7043 let attrs = std::mem::take(&mut g.attributes);
7044 g.obj_header_written_addr = None;
7045 (attrs, std::mem::take(&mut g.obj_header_blocks))
7046 };
7047 for attr in &attrs {
7048 self.release_attr_vlen(attr)?;
7049 }
7050 self.release_superseded_dense_attrs(AttrScope::Group(gidx))?;
7051 self.release_superseded_dense_links(LinkScope::Group(gidx))?;
7052 for (addr, size) in header_blocks {
7053 self.allocator.free(addr, size, FreeSpaceClass::Metadata);
7054 }
7055 Ok(())
7056 }
7057
7058 /// Free the dense attribute storage a reopened header names, once, when
7059 /// this session stops naming it — because the header is being rewritten
7060 /// around fresh storage, or because the object was deleted.
7061 ///
7062 /// The single owner of that transition: nothing else removes an attribute
7063 /// entry from [`superseded_dense`](Self::superseded_dense), and this
7064 /// removes it as it frees, so no heap is freed twice or left half freed.
7065 /// An object whose storage was compact, or whose header this session
7066 /// keeps, has no entry and nothing happens.
7067 ///
7068 /// Never under SWMR: a live reader may still be walking the storage the
7069 /// published headers name, the same rule the superseded-header and
7070 /// relocated-chunk paths follow. The entry stays in place, unfreed.
7071 fn release_superseded_dense_attrs(&self, scope: AttrScope) -> IoResult<()> {
7072 if self.swmr_active {
7073 return Ok(());
7074 }
7075 let taken = self
7076 .superseded_dense
7077 .lock()
7078 .as_mut()
7079 .and_then(|s| s.attrs.remove(&scope));
7080 let Some(ainfo) = taken else {
7081 return Ok(());
7082 };
7083 self.release_dense_storage(
7084 ainfo.fractal_heap_address,
7085 ainfo.name_btree_address,
7086 ainfo.creation_order_btree_address,
7087 )
7088 }
7089
7090 /// The link counterpart of
7091 /// [`release_superseded_dense_attrs`](Self::release_superseded_dense_attrs),
7092 /// under the same invariant and the same SWMR rule. Split from it because
7093 /// the two are superseded at different points of a finalize: attribute
7094 /// storage before the object headers are laid out, link storage after
7095 /// every one of them has an address.
7096 fn release_superseded_dense_links(&self, scope: LinkScope) -> IoResult<()> {
7097 if self.swmr_active {
7098 return Ok(());
7099 }
7100 let taken = self
7101 .superseded_dense
7102 .lock()
7103 .as_mut()
7104 .and_then(|s| s.links.remove(&scope));
7105 let Some(linfo) = taken else {
7106 return Ok(());
7107 };
7108 self.release_dense_storage(
7109 linfo.fractal_heap_address,
7110 linfo.name_btree_address,
7111 linfo.creation_order_btree_address,
7112 )
7113 }
7114
7115 /// Return one dense storage's file space to the allocator: the fractal
7116 /// heap in full, its name index, and the creation-order index when the
7117 /// object had one.
7118 ///
7119 /// The extents come from walking the structures themselves rather than
7120 /// from re-deriving what a writer would have allocated, so storage
7121 /// libhdf5 laid out is freed as accurately as storage this crate wrote.
7122 /// Every walk here already ran once this session — the reopen read every
7123 /// attribute out of this heap through the same index — so a failure means
7124 /// the file changed underneath us, and surfacing it beats freeing a
7125 /// partial extent list.
7126 fn release_dense_storage(
7127 &self,
7128 heap_addr: u64,
7129 name_bt2_addr: u64,
7130 corder_bt2_addr: Option<u64>,
7131 ) -> IoResult<()> {
7132 use crate::format::chunk_index::btree_v2::collect_btree_v2_extents;
7133 use crate::format::fractal_heap::collect_heap_extents;
7134
7135 let mut reader = crate::io::reader::HandleBlockReader {
7136 handle: &self.handle,
7137 };
7138 let mut extents = Vec::new();
7139 if heap_addr != UNDEF_ADDR {
7140 extents.extend(collect_heap_extents(heap_addr, &self.ctx, &mut reader)?);
7141 }
7142 for addr in [Some(name_bt2_addr), corder_bt2_addr]
7143 .into_iter()
7144 .flatten()
7145 .filter(|&a| a != UNDEF_ADDR)
7146 {
7147 extents.extend(collect_btree_v2_extents(addr, &self.ctx, &mut reader)?);
7148 }
7149 for (addr, len) in extents {
7150 self.allocator.free(addr, len, FreeSpaceClass::Metadata);
7151 }
7152 Ok(())
7153 }
7154
7155 /// Free a deleted dataset's chunk-index structures, after the chunks
7156 /// themselves were freed by a zero-extent prune. Takes the index info
7157 /// out of the slot, so the dataset no longer claims chunked storage.
7158 ///
7159 /// Every block's size is recovered the way its allocation computed it:
7160 /// re-encoding the in-memory copy (EA header and index block, FA
7161 /// header and data block, BT2 header) or sizing a same-shape dummy
7162 /// from the array geometry (EA data blocks, whose element counts come
7163 /// from [`EaGeometry`]; BT2 nodes are all `node_size`).
7164 fn free_chunk_index(&self, index: usize) -> IoResult<()> {
7165 let ds = self.ds(index);
7166 let mut m = ds.lock();
7167 let is_filtered = m.filter_pipeline.is_some();
7168 if let Some(c) = m.chunked.take() {
7169 let p = &c.earray_params;
7170 let bits = p.max_nelmts_bits;
7171 let csl = c.chunk_size_len;
7172 let geo = EaGeometry::new(
7173 p.idx_blk_elmts,
7174 p.data_blk_min_elmts,
7175 p.sup_blk_min_data_ptrs,
7176 bits,
7177 p.max_dblk_page_nelmts_bits,
7178 )?;
7179 let dblk_size = |nelmts: u64| -> u64 {
7180 if is_filtered {
7181 FilteredDataBlock::new(c.ea_header_addr, 0, nelmts as usize)
7182 .encode(&self.ctx, bits, csl)
7183 .len() as u64
7184 } else {
7185 ExtensibleArrayDataBlock::new(c.ea_header_addr, 0, nelmts as usize)
7186 .encoded_size(&self.ctx, bits) as u64
7187 }
7188 };
7189 let (dblk_addrs, sblk_addrs, iblk_size) = if is_filtered {
7190 let f = c.filt_iblk.as_ref().unwrap();
7191 (
7192 f.dblk_addrs.clone(),
7193 f.sblk_addrs.clone(),
7194 f.encode(&self.ctx, csl).len() as u64,
7195 )
7196 } else {
7197 (
7198 c.ea_iblk.dblk_addrs.clone(),
7199 c.ea_iblk.sblk_addrs.clone(),
7200 c.ea_iblk.encoded_size(&self.ctx) as u64,
7201 )
7202 };
7203 // Data blocks addressed from the index block belong to the
7204 // first `iblock_nsblks` super blocks; each of those defines the
7205 // element count (and so the disk size) of its data blocks.
7206 let mut g = 0usize;
7207 'direct: for s in geo.sblk.iter().take(geo.iblock_nsblks) {
7208 for _ in 0..s.ndblks {
7209 let Some(&a) = dblk_addrs.get(g) else {
7210 break 'direct;
7211 };
7212 g += 1;
7213 if a == UNDEF_ADDR {
7214 continue;
7215 }
7216 if s.dblk_nelmts > geo.dblk_page_nelmts {
7217 return Err(crate::io::IoError::InvalidState(
7218 "cannot free a paged extensible-array data block, \
7219 which is not yet supported"
7220 .into(),
7221 ));
7222 }
7223 self.allocator
7224 .free(a, dblk_size(s.dblk_nelmts), FreeSpaceClass::Metadata);
7225 }
7226 }
7227 for (off, &sa) in sblk_addrs.iter().enumerate() {
7228 if sa == UNDEF_ADDR {
7229 continue;
7230 }
7231 let s = geo.sblk[geo.iblock_nsblks + off];
7232 if s.dblk_nelmts > geo.dblk_page_nelmts {
7233 return Err(crate::io::IoError::InvalidState(
7234 "cannot free a paged extensible-array data block, \
7235 which is not yet supported"
7236 .into(),
7237 ));
7238 }
7239 let buf = self.handle.read_at_most(sa, 65536)?;
7240 let sb =
7241 ExtensibleArraySuperBlock::decode(&buf, &self.ctx, bits, s.ndblks as usize, 0)?;
7242 for &da in &sb.dblk_addrs {
7243 if da != UNDEF_ADDR {
7244 self.allocator
7245 .free(da, dblk_size(s.dblk_nelmts), FreeSpaceClass::Metadata);
7246 }
7247 }
7248 self.allocator.free(
7249 sa,
7250 sb.encode(&self.ctx, bits).len() as u64,
7251 FreeSpaceClass::Metadata,
7252 );
7253 }
7254 self.allocator
7255 .free(c.ea_iblk_addr, iblk_size, FreeSpaceClass::Metadata);
7256 self.allocator.free(
7257 c.ea_header_addr,
7258 c.ea_header.encoded_size(&self.ctx) as u64,
7259 FreeSpaceClass::Metadata,
7260 );
7261 return Ok(());
7262 }
7263 if let Some(fa) = m.fixed_array.take() {
7264 self.allocator.free(
7265 fa.fa_dblk_addr,
7266 fixed_array_dblk_disk_size(&self.ctx, &fa.fa_header),
7267 FreeSpaceClass::Metadata,
7268 );
7269 self.allocator.free(
7270 fa.fa_header_addr,
7271 fa.fa_header.encode(&self.ctx).len() as u64,
7272 FreeSpaceClass::Metadata,
7273 );
7274 return Ok(());
7275 }
7276 // The implicit index has no structure to free, only the one run of
7277 // chunk space it was given at create — which is the whole of its
7278 // storage, so nothing else can be leaked or double-freed here.
7279 if let Some(imp) = m.implicit.take() {
7280 self.allocator
7281 .free(imp.data_addr, imp.data_size, FreeSpaceClass::RawData);
7282 return Ok(());
7283 }
7284 // The single-chunk index has no structure of its own either: its one
7285 // chunk is the whole of its storage, addressed directly from the
7286 // layout message rather than any index this function's doc comment's
7287 // "chunks already freed by a zero-extent prune" applies to — so
7288 // freeing it here, if it was ever allocated, is the only place it
7289 // happens.
7290 if let Some(sc) = m.single_chunk.take() {
7291 if sc.data_addr != UNDEF_ADDR {
7292 let len = if is_filtered { sc.nbytes } else { sc.data_size };
7293 self.allocator
7294 .free(sc.data_addr, len, FreeSpaceClass::RawData);
7295 }
7296 return Ok(());
7297 }
7298 // The version-1 B-tree owns nothing but its node blocks: the header
7299 // every other index has is, here, the root pointer inside the layout
7300 // message.
7301 if let Some(bt1) = m.btree_v1.take() {
7302 let element_size = m.datatype.element_size() as u64;
7303 let node_size = bt1
7304 .build_tree(element_size, self.ctx.sizeof_addr as usize)
7305 .node_size();
7306 for &a in &bt1.node_addrs {
7307 self.allocator
7308 .free(a, node_size as u64, FreeSpaceClass::Metadata);
7309 }
7310 return Ok(());
7311 }
7312 if let Some(bt2) = m.btree_v2.take() {
7313 let tree = bt2.index.build_tree(&self.ctx);
7314 for &a in &bt2.node_addrs {
7315 self.allocator
7316 .free(a, tree.node_size as u64, FreeSpaceClass::Metadata);
7317 }
7318 self.allocator.free(
7319 bt2.bt2_header_addr,
7320 tree.header(UNDEF_ADDR).encode(&self.ctx).len() as u64,
7321 FreeSpaceClass::Metadata,
7322 );
7323 }
7324 Ok(())
7325 }
7326
7327 /// Return the chunk dimensions for a dataset, if chunked.
7328 ///
7329 /// Returns an owned `Vec` because the chunk geometry now lives behind the
7330 /// per-dataset [`Slot`]; it cannot be borrowed past the guard.
7331 pub fn dataset_chunk_dims(&self, index: usize) -> Option<Vec<u64>> {
7332 let ds = self.ds(index);
7333 let m = ds.lock();
7334 m.chunk_index_kind().map(|kind| match kind {
7335 ChunkIndexKind::ExtensibleArray => m.chunked.as_ref().unwrap().chunk_dims.clone(),
7336 ChunkIndexKind::FixedArray => m.fixed_array.as_ref().unwrap().chunk_dims.clone(),
7337 ChunkIndexKind::BtreeV2 => m.btree_v2.as_ref().unwrap().chunk_dims.clone(),
7338 ChunkIndexKind::Implicit => m.implicit.as_ref().unwrap().chunk_dims.clone(),
7339 ChunkIndexKind::SingleChunk => m.single_chunk.as_ref().unwrap().chunk_dims.clone(),
7340 ChunkIndexKind::BtreeV1 => m.btree_v1.as_ref().unwrap().chunk_dims.clone(),
7341 })
7342 }
7343
7344 /// Return the current dimensions of a dataset.
7345 ///
7346 /// Returns an owned `Vec` because the dataspace now lives behind the
7347 /// per-dataset [`Slot`]; it cannot be borrowed past the guard.
7348 pub fn dataset_dims(&self, index: usize) -> Vec<u64> {
7349 self.ds(index).lock().dataspace.dims.clone()
7350 }
7351
7352 /// Return the maximum extent a dataset declares, per dimension.
7353 ///
7354 /// An absent maximum shape means the shape is fixed at its current extent
7355 /// (libhdf5 defaults maxdims to dims at creation), so the current
7356 /// dimensions are returned; `H5S_UNLIMITED` is `u64::MAX`.
7357 pub fn dataset_max_dims(&self, index: usize) -> Vec<u64> {
7358 let ds = self.ds(index);
7359 let m = ds.lock();
7360 m.dataspace
7361 .max_dims
7362 .clone()
7363 .unwrap_or_else(|| m.dataspace.dims.clone())
7364 }
7365
7366 /// Whether a dataset stores its raw data through a filter pipeline.
7367 ///
7368 /// The write paths ask before choosing how to hand a chunk over: an
7369 /// unfiltered chunk's bytes go to the file exactly as the caller holds
7370 /// them, while a filtered one has to be compressed first.
7371 pub(crate) fn dataset_is_filtered(&self, index: usize) -> bool {
7372 self.ds(index).lock().filter_pipeline.is_some()
7373 }
7374
7375 /// Return the datatype a dataset declares on disk.
7376 ///
7377 /// The typed write paths need it to store bytes in the declared byte
7378 /// order; a reopened dataset handle has no copy of its own, and a cached
7379 /// one could disagree with what the header will say.
7380 pub fn dataset_datatype(&self, index: usize) -> DatatypeMessage {
7381 self.ds(index).lock().datatype.clone()
7382 }
7383
7384 /// Create a group in the file hierarchy.
7385 ///
7386 /// `parent_path` is the full path of the parent group (e.g., "/" for root).
7387 /// `name` is the name of the new group (e.g., "detector").
7388 ///
7389 /// Returns the group index in the writer's group list.
7390 pub fn create_group(&self, parent_path: &str, name: &str) -> IoResult<usize> {
7391 // Hold the create gate across the uniqueness check and the registry
7392 // push so the two are atomic (see `create_lock`).
7393 let _create = self.create_lock.lock();
7394 // A parent path through hard links creates in the link's target,
7395 // as HDF5 traversal does.
7396 let parent_path = self.canonical_group_path(parent_path);
7397 let parent_path = parent_path.as_str();
7398 let full_name = if parent_path == "/" {
7399 format!("/{}", name)
7400 } else {
7401 format!("{}/{}", parent_path, name)
7402 };
7403 // Same rule as dataset creation: a path through a carried external
7404 // link names a group in the other file, which this writer cannot make.
7405 self.reject_external_traversal(&full_name)?;
7406 // `name` may itself carry path components; resolving the whole thing
7407 // is what keeps a '/' out of the link this group will be reached by.
7408 let (parent_idx, _leaf) = self.split_parent(full_name.trim_start_matches('/'))?;
7409
7410 self.ensure_name_free(full_name.trim_start_matches('/'))?;
7411
7412 let group_idx = self.push_group(GroupInfo {
7413 name: full_name,
7414 parent: parent_idx,
7415 creation_seq: self.take_creation_seq(),
7416 track_order: self.track_order,
7417 times: self.created_object_times(),
7418 child_datasets: Vec::new(),
7419 child_groups: Vec::new(),
7420 obj_header_addr: 0,
7421 obj_header_written_addr: None,
7422 obj_header_blocks: Vec::new(),
7423 deleted: false,
7424 attributes: Vec::new(),
7425 });
7426
7427 // Register this group as a child of its parent
7428 if let Some(pidx) = parent_idx {
7429 self.grp(pidx).lock().child_groups.push(group_idx);
7430 }
7431
7432 Ok(group_idx)
7433 }
7434
7435 /// Register a dataset as belonging to a group.
7436 ///
7437 /// `group_path` is the full path of the group (e.g., "/detector").
7438 /// `ds_index` is the dataset index returned by `create_dataset`.
7439 pub fn assign_dataset_to_group(&self, group_path: &str, ds_index: usize) -> IoResult<()> {
7440 let group_path = self.canonical_group_path(group_path);
7441 let group_path = group_path.as_str();
7442 let groups = self.group_refs();
7443 let group_idx = groups
7444 .iter()
7445 .position(|g| {
7446 let gg = g.lock();
7447 gg.name == group_path && !gg.deleted
7448 })
7449 .ok_or_else(|| {
7450 crate::io::IoError::NotFound(format!("group '{}' not found", group_path))
7451 })?;
7452 // A move, not an addition: the create gate has already placed every
7453 // dataset from the path components of its name, so appending here
7454 // would leave one dataset linked from two groups at once.
7455 for g in &groups {
7456 g.lock().child_datasets.retain(|&d| d != ds_index);
7457 }
7458 groups[group_idx].lock().child_datasets.push(ds_index);
7459 Ok(())
7460 }
7461
7462 /// Create a hard link: an additional name for an object that already
7463 /// exists in the file.
7464 ///
7465 /// No data is copied — the link and its target share one object header,
7466 /// exactly as `h5py` / libhdf5 hard links do.
7467 ///
7468 /// * `parent_group_path` — full path of the group that will hold the
7469 /// link (`"/"` for the root group).
7470 /// * `link_name` — leaf name of the new link within that group.
7471 /// * `target_path` — full path of an existing dataset or group, with or
7472 /// without a leading `/`.
7473 pub fn create_hard_link(
7474 &self,
7475 parent_group_path: &str,
7476 link_name: &str,
7477 target_path: &str,
7478 ) -> IoResult<()> {
7479 if link_name.is_empty() || link_name.contains('/') {
7480 return Err(crate::io::IoError::InvalidState(format!(
7481 "hard link name '{link_name}' must be a non-empty leaf name"
7482 )));
7483 }
7484
7485 // Neither end may sit across a carried external link: the target
7486 // would be an object in the other file, and the link itself would be
7487 // a name in a group this writer does not own.
7488 self.reject_external_traversal(target_path)?;
7489 self.reject_external_traversal(&format!(
7490 "{}/{link_name}",
7491 parent_group_path.trim_end_matches('/')
7492 ))?;
7493
7494 // Hold the create gate across the collision check and the hard-link
7495 // push so the two are atomic (see `create_lock`).
7496 let _create = self.create_lock.lock();
7497 // Both paths resolve through hard links, as HDF5 traversal does.
7498 let parent_group_path = self.canonical_group_path(parent_group_path);
7499 let parent_group_path = parent_group_path.as_str();
7500
7501 // Resolve the parent group (None == root).
7502 let parent = if parent_group_path == "/" {
7503 None
7504 } else {
7505 Some(
7506 self.group_refs()
7507 .iter()
7508 .position(|g| {
7509 let gg = g.lock();
7510 gg.name == parent_group_path && !gg.deleted
7511 })
7512 .ok_or_else(|| {
7513 crate::io::IoError::NotFound(format!(
7514 "parent group '{parent_group_path}' not found"
7515 ))
7516 })?,
7517 )
7518 };
7519
7520 // Resolve the target. Dataset names are stored without a leading
7521 // '/', group names with one — compare on the trimmed form. A
7522 // trailing '/' is tolerated too.
7523 let target_rel = self.canonical_dataset_path(target_path.trim_matches('/'));
7524 let target_rel = target_rel.as_str();
7525 if target_rel.is_empty() {
7526 return Err(crate::io::IoError::InvalidState(
7527 "cannot hard-link the root group".into(),
7528 ));
7529 }
7530 let target = self.resolve_object(target_rel).ok_or_else(|| {
7531 crate::io::IoError::NotFound(format!("hard link target '{target_path}' not found"))
7532 })?;
7533
7534 // Reject a name already taken in the parent group.
7535 self.ensure_name_free(&self.link_full_path(parent, link_name))?;
7536
7537 self.hard_links.lock().push(HardLink {
7538 parent,
7539 name: link_name.to_string(),
7540 target,
7541 creation_seq: self.take_creation_seq(),
7542 });
7543 self.register_name(&self.link_full_path(parent, link_name), NameHit::HardLink);
7544 Ok(())
7545 }
7546
7547 /// Whether a hard link will actually be emitted: both its parent group
7548 /// and its target object must still be present (not soft-deleted).
7549 fn hard_link_emitted(&self, link: &HardLink) -> bool {
7550 let parent_ok = self.parent_alive(link.parent);
7551 let target_ok = match link.target {
7552 HardLinkTarget::Dataset(i) => !self.ds(i).lock().deleted,
7553 HardLinkTarget::Group(i) => !self.grp(i).lock().deleted,
7554 };
7555 parent_ok && target_ok
7556 }
7557
7558 /// The full path a link occupies, with no leading `/` — the same form
7559 /// dataset names are stored in. The one place a parent index and a leaf
7560 /// name become a path, so every link kind answers the collision check in
7561 /// the same spelling.
7562 fn link_full_path(&self, parent: Option<usize>, name: &str) -> String {
7563 match parent {
7564 None => name.to_string(),
7565 Some(pi) => format!(
7566 "{}/{name}",
7567 self.grp(pi).lock().name.trim_start_matches('/')
7568 ),
7569 }
7570 }
7571
7572 /// The full path a hard link occupies; see [`Self::link_full_path`].
7573 fn hard_link_full_path(&self, link: &HardLink) -> String {
7574 self.link_full_path(link.parent, &link.name)
7575 }
7576
7577 /// Whether a symbolic link will actually be emitted: its parent group
7578 /// must still be present. There is no target to check — a soft or
7579 /// external link is allowed to dangle, and `H5Lcreate_soft` does not look
7580 /// at the path it stores.
7581 fn symbolic_link_emitted(&self, link: &SymbolicLink) -> bool {
7582 self.parent_alive(link.parent)
7583 }
7584
7585 /// Whether the group that would hold a link still exists; `None` is the
7586 /// root group, which cannot be deleted.
7587 ///
7588 /// A deleted group's header is never written, so nothing it would have
7589 /// held is in the file — and the name is free again. Every registry
7590 /// decides that the same way, through here.
7591 fn parent_alive(&self, parent: Option<usize>) -> bool {
7592 match parent {
7593 None => true,
7594 Some(pi) => !self.grp(pi).lock().deleted,
7595 }
7596 }
7597
7598 /// The full path a symbolic link occupies; see [`Self::link_full_path`].
7599 fn symbolic_link_full_path(&self, link: &SymbolicLink) -> String {
7600 self.link_full_path(link.parent, &link.name)
7601 }
7602
7603 /// Create a soft or external link: a name in a group whose value is a
7604 /// path rather than an object.
7605 ///
7606 /// The single owner of symbolic-link creation — `H5Lcreate_soft` and
7607 /// `H5Lcreate_external` differ only in the value they store, and the
7608 /// name, parent and collision rules they share are all here.
7609 ///
7610 /// * `parent_group_path` — full path of the group that will hold the
7611 /// link (`"/"` for the root group).
7612 /// * `link_name` — leaf name of the new link within that group.
7613 /// * `target` — the path this link names, and for an external link the
7614 /// file holding it. Neither is resolved or required to exist: HDF5
7615 /// answers a symbolic link at traversal time, so a dangling one is a
7616 /// legal file.
7617 pub fn create_symbolic_link(
7618 &self,
7619 parent_group_path: &str,
7620 link_name: &str,
7621 target: LinkTarget,
7622 ) -> IoResult<()> {
7623 if link_name.is_empty() || link_name.contains('/') {
7624 return Err(crate::io::IoError::InvalidState(format!(
7625 "link name '{link_name}' must be a non-empty leaf name"
7626 )));
7627 }
7628 // `H5Lcreate_external` refuses an empty file or object name, and
7629 // stores the object path normalized; a link written here and one
7630 // libhdf5 writes from the same arguments then hold the same bytes.
7631 let target = match target {
7632 LinkTarget::External { file, path } => {
7633 if file.is_empty() || path.is_empty() {
7634 return Err(crate::io::IoError::InvalidState(
7635 "an external link needs both a file name and an object path".into(),
7636 ));
7637 }
7638 LinkTarget::External {
7639 file,
7640 path: crate::format::messages::link::normalize_object_path(&path),
7641 }
7642 }
7643 other => other,
7644 };
7645 // The link itself would be a name in a group that lives in another
7646 // file; its *value* may name anything, including a path this writer
7647 // cannot follow, because nothing follows it here.
7648 self.reject_external_traversal(&format!(
7649 "{}/{link_name}",
7650 parent_group_path.trim_end_matches('/')
7651 ))?;
7652
7653 let _create = self.create_lock.lock();
7654 let parent_group_path = self.canonical_group_path(parent_group_path);
7655 let parent_group_path = parent_group_path.as_str();
7656 let parent = if parent_group_path == "/" {
7657 None
7658 } else {
7659 Some(
7660 self.group_refs()
7661 .iter()
7662 .position(|g| {
7663 let gg = g.lock();
7664 gg.name == parent_group_path && !gg.deleted
7665 })
7666 .ok_or_else(|| {
7667 crate::io::IoError::NotFound(format!(
7668 "parent group '{parent_group_path}' not found"
7669 ))
7670 })?,
7671 )
7672 };
7673
7674 self.ensure_name_free(&self.link_full_path(parent, link_name))?;
7675 self.symbolic_links.lock().push(SymbolicLink {
7676 parent,
7677 name: link_name.to_string(),
7678 target,
7679 creation_seq: self.take_creation_seq(),
7680 });
7681 self.register_name(
7682 &self.link_full_path(parent, link_name),
7683 NameHit::SymbolicLink,
7684 );
7685 Ok(())
7686 }
7687
7688 // ---------------------------------------------------------- committed types
7689
7690 /// Snapshot the committed-datatype list; see [`Self::hard_links_vec`].
7691 pub(crate) fn committed_datatypes_vec(&self) -> Vec<CommittedDatatype> {
7692 self.committed_datatypes.lock().clone()
7693 }
7694
7695 /// The paths of every committed datatype a name still reaches, in
7696 /// creation order. One inside a deleted group is not among them: no link
7697 /// to it is emitted, so the file will not hold that name.
7698 ///
7699 /// Both halves of the file answer. A datatype an earlier session
7700 /// committed is carried by its bytes, not re-encoded, so it lives in the
7701 /// preserved-link list rather than the registry — and listing only the
7702 /// registry is what made this answer `[]` for a file whose every named
7703 /// type was committed before it was opened, while a reader of the same
7704 /// file named them all.
7705 pub(crate) fn committed_datatype_names(&self) -> Vec<String> {
7706 let mut out: Vec<String> = self
7707 .committed_datatypes_vec()
7708 .iter()
7709 .filter(|c| self.parent_alive(c.parent))
7710 .map(|c| c.name.clone())
7711 .collect();
7712 out.extend(
7713 self.preserved_links
7714 .lock()
7715 .iter()
7716 .filter(|l| l.kind == PreservedKind::NamedDatatype)
7717 .map(|l| self.preserved_link_full_path(l)),
7718 );
7719 out
7720 }
7721
7722 /// Commit `datatype` as an object of its own under `name` —
7723 /// `H5Tcommit2`. Returns its index in the committed-datatype registry.
7724 ///
7725 /// The object holds one datatype message and nothing else. It goes
7726 /// through [`begin_create`](Self::begin_create) like a dataset, so its
7727 /// name is resolved to a real parent group, refused if taken, and refused
7728 /// if it would cross a carried external link.
7729 pub fn commit_datatype(&self, name: &str, datatype: DatatypeMessage) -> IoResult<usize> {
7730 let create = self.begin_create(name.trim_start_matches('/'))?;
7731 let entry = CommittedDatatype {
7732 name: create.name.clone(),
7733 parent: create.parent,
7734 datatype,
7735 creation_seq: self.take_creation_seq(),
7736 times: self.created_object_times(),
7737 obj_header_addr: 0,
7738 };
7739 let name = entry.name.clone();
7740 let idx = {
7741 let mut reg = self.committed_datatypes.lock();
7742 let idx = reg.len();
7743 reg.push(entry);
7744 idx
7745 };
7746 self.register_name(&name, NameHit::Datatype(idx));
7747 Ok(idx)
7748 }
7749
7750 /// Resolve a committed datatype's path to its registry index and the type
7751 /// it holds — the pair a dataset needs to be built on it.
7752 ///
7753 /// Returned together so the caller cannot pair one committed type's index
7754 /// with another's datatype: the dataset's element width, dataspace and
7755 /// payload checks all come from the type, and its header names the index.
7756 pub(crate) fn committed_datatype_for_share(
7757 &self,
7758 name: &str,
7759 ) -> IoResult<(usize, DatatypeMessage)> {
7760 let name = self.canonical_dataset_path(name.trim_start_matches('/'));
7761 let all = self.committed_datatypes_vec();
7762 all.iter()
7763 .position(|c| self.parent_alive(c.parent) && c.name == name)
7764 .map(|i| (i, all[i].datatype.clone()))
7765 .ok_or_else(|| {
7766 crate::io::IoError::NotFound(format!("no committed datatype named '{name}'"))
7767 })
7768 }
7769
7770 /// Record that dataset `dataset` stores its datatype as a pointer to the
7771 /// committed datatype `committed`.
7772 ///
7773 /// Takes an index [`committed_datatype_for_share`](Self::committed_datatype_for_share)
7774 /// produced, alongside the datatype from the same call, so the two cannot
7775 /// disagree and there is nothing here that can fail after the dataset
7776 /// exists.
7777 pub(crate) fn share_committed_type(&self, dataset: usize, committed: usize) {
7778 debug_assert!(committed < self.committed_datatypes.lock().len());
7779 self.ds(dataset).lock().committed_type = Some(CommittedTypeRef::Session(committed));
7780 }
7781
7782 /// How many names reach the committed datatype `index`: the link that
7783 /// gave it its name, plus every live dataset that shares it.
7784 ///
7785 /// `H5O__shared_link_adj` counts a share as a link, which is why a type
7786 /// h5py commits and then builds one dataset on reports `rc == 2`. Zero
7787 /// means nothing reaches it at all — the group holding its name was
7788 /// deleted and no dataset shares it — and then it is not written.
7789 fn committed_datatype_refcount(&self, index: usize) -> u32 {
7790 let linked = {
7791 let parent = self.committed_datatypes.lock()[index].parent;
7792 u32::from(self.parent_alive(parent))
7793 };
7794 let shares = self
7795 .dataset_refs()
7796 .iter()
7797 .filter(|d| {
7798 let m = d.lock();
7799 !m.deleted && m.committed_type == Some(CommittedTypeRef::Session(index))
7800 })
7801 .count() as u32;
7802 linked + shares
7803 }
7804
7805 /// Append the link naming each committed datatype whose parent group is
7806 /// `parent`. A committed datatype is reached by an ordinary hard link —
7807 /// what makes it a datatype rather than a group or a dataset is the one
7808 /// message in the header it points at.
7809 ///
7810 /// Only a live group's links are collected, and a live parent is itself a
7811 /// reference, so every address named here belongs to a header
7812 /// `write_committed_datatype_headers` wrote.
7813 fn push_committed_datatypes(&self, links: &mut Vec<(u64, LinkMessage)>, parent: Option<usize>) {
7814 for cd in self.committed_datatypes_vec() {
7815 if cd.parent != parent {
7816 continue;
7817 }
7818 let leaf = cd.name.rsplit('/').next().unwrap_or(&cd.name);
7819 links.push((cd.creation_seq, LinkMessage::hard(leaf, cd.obj_header_addr)));
7820 }
7821 }
7822
7823 /// Rewrite a group path that passes through hard links into the tree
7824 /// path of the group it reaches — HDF5 traversal, where any link in a
7825 /// path component resolves to its target. Group-name form (leading
7826 /// `/`). Repeats because a substituted target's subtree can hold
7827 /// further links; bounded like libhdf5's link-traversal limit, so a
7828 /// link cycle cannot loop forever. A path with no link components
7829 /// (including one naming nothing at all) comes back unchanged.
7830 pub(crate) fn canonical_group_path(&self, path: &str) -> String {
7831 let mut path = path.to_string();
7832 for _ in 0..64 {
7833 // The longest emitted group-link path that is the whole of
7834 // `path` or a '/'-boundary prefix of it.
7835 let mut best: Option<(usize, usize)> = None; // (prefix len, target)
7836 for l in self.hard_links_vec() {
7837 let HardLinkTarget::Group(gi) = l.target else {
7838 continue;
7839 };
7840 if !self.hard_link_emitted(&l) {
7841 continue;
7842 }
7843 let lp = format!("/{}", self.hard_link_full_path(&l));
7844 let covers = path == lp || path.starts_with(&format!("{lp}/"));
7845 if covers && best.is_none_or(|(len, _)| lp.len() > len) {
7846 best = Some((lp.len(), gi));
7847 }
7848 }
7849 let Some((len, gi)) = best else { break };
7850 let target_name = self.grp(gi).lock().name.clone();
7851 path = format!("{}{}", target_name, &path[len..]);
7852 }
7853 path
7854 }
7855
7856 /// [`canonical_group_path`](Self::canonical_group_path) in the
7857 /// dataset-name form (no leading `/`): the leaf is a dataset, so only
7858 /// group links can appear as components and the whole path can go
7859 /// through the group rewrite unchanged.
7860 fn canonical_dataset_path(&self, name: &str) -> String {
7861 self.canonical_group_path(&format!("/{name}"))
7862 .trim_start_matches('/')
7863 .to_string()
7864 }
7865
7866 /// Total number of hard links resolving to an object: its own tree link
7867 /// plus every emitted user-created hard link pointing at it.
7868 fn object_link_count(&self, target: HardLinkTarget) -> u32 {
7869 let same = |a: HardLinkTarget, b: HardLinkTarget| -> bool {
7870 matches!(
7871 (a, b),
7872 (HardLinkTarget::Dataset(x), HardLinkTarget::Dataset(y))
7873 | (HardLinkTarget::Group(x), HardLinkTarget::Group(y))
7874 if x == y
7875 )
7876 };
7877 1 + self
7878 .hard_links_vec()
7879 .iter()
7880 .filter(|l| self.hard_link_emitted(l) && same(l.target, target))
7881 .count() as u32
7882 }
7883
7884 /// The object a path names, or `None` when nothing in the file does.
7885 ///
7886 /// `path` is the trimmed, hard-link-canonical form (no leading or
7887 /// trailing `/`) that dataset and group names compare against. The single
7888 /// owner of path→object resolution on the write side: hard links and
7889 /// object references must agree on what a path means, including that a
7890 /// path may itself be a user hard link — links have no chain (each points
7891 /// straight at the object header, as in libhdf5), so the existing link's
7892 /// target is the answer.
7893 pub(crate) fn resolve_object(&self, path: &str) -> Option<HardLinkTarget> {
7894 if let Some(idx) = self.dataset_refs().iter().position(|d| {
7895 let g = d.lock();
7896 !g.deleted && g.name.trim_start_matches('/') == path
7897 }) {
7898 return Some(HardLinkTarget::Dataset(idx));
7899 }
7900 if let Some(idx) = self.group_refs().iter().position(|g| {
7901 let gg = g.lock();
7902 !gg.deleted && gg.name.trim_start_matches('/') == path
7903 }) {
7904 return Some(HardLinkTarget::Group(idx));
7905 }
7906 self.hard_links_vec().iter().find_map(|l| {
7907 (self.hard_link_emitted(l) && self.hard_link_full_path(l) == path).then_some(l.target)
7908 })
7909 }
7910
7911 /// The address of dataset `index`'s own contiguous block, for the two
7912 /// writers that stamp single elements into it by file offset — object and
7913 /// region references, whose values are only known once finalize has placed
7914 /// every object header.
7915 ///
7916 /// Refuses, rather than handing back an address that is not one, every
7917 /// dataset that has no such block: chunked, compact, unallocated, or with
7918 /// its raw data in files outside this one.
7919 fn local_element_block(&self, index: usize, what: &str) -> IoResult<u64> {
7920 let ds = self.ds(index);
7921 let m = ds.lock();
7922 match m.contiguous_target() {
7923 Some(ContiguousTarget::Local(addr)) => Ok(addr),
7924 Some(ContiguousTarget::External { .. }) => {
7925 Err(crate::io::IoError::InvalidState(format!(
7926 "{what} are stamped into the dataset's own contiguous block, and \
7927 dataset '{}' has none: its raw data lives in external files",
7928 m.name
7929 )))
7930 }
7931 Some(ContiguousTarget::Virtual) => Err(crate::io::IoError::InvalidState(format!(
7932 "{what} are stamped into the dataset's own contiguous block, and \
7933 dataset '{}' has none: it is virtual, and its elements come from \
7934 the source datasets its mappings name",
7935 m.name
7936 ))),
7937 None => Err(crate::io::IoError::InvalidState(format!(
7938 "{what} are stamped into contiguous storage; create the dataset \
7939 without chunking"
7940 ))),
7941 }
7942 }
7943
7944 /// Store object references naming `paths` into the elements of dataset
7945 /// `index` starting at `start`.
7946 ///
7947 /// The value of an `H5R_OBJECT1` element is its target's object header
7948 /// address, which finalize assigns, so what lands here is the target path;
7949 /// [`Self::write_object_reference_values`] writes the addresses. Elements
7950 /// never written keep the zero image libhdf5 reads back as a null
7951 /// reference.
7952 pub fn write_object_references(
7953 &self,
7954 index: usize,
7955 start: u64,
7956 paths: &[&str],
7957 ) -> IoResult<()> {
7958 let elements = {
7959 let ds = self.ds(index);
7960 let m = ds.lock();
7961 match &m.datatype {
7962 // Both generations of object reference: `H5T_STD_REF_OBJ` and
7963 // the 1.12 `H5T_STD_REF`. They differ only in the element
7964 // image, which `encode_reference_element` owns.
7965 DatatypeMessage::Reference {
7966 kind: ReferenceKind::Object1 | ReferenceKind::Object2,
7967 ..
7968 } => {}
7969 other => {
7970 return Err(crate::io::IoError::InvalidState(format!(
7971 "dataset '{}' has datatype {other}, not an object reference",
7972 m.name
7973 )))
7974 }
7975 }
7976 m.dataspace
7977 .dims
7978 .iter()
7979 .fold(1u64, |a, &d| a.saturating_mul(d))
7980 };
7981 // Refused here as well as at fixup time, so a dataset whose storage
7982 // cannot hold stamped elements is reported at the call that chose it.
7983 self.local_element_block(index, "object references")?;
7984 let end = start.saturating_add(paths.len() as u64);
7985 if end > elements {
7986 return Err(crate::io::IoError::InvalidState(format!(
7987 "elements {start}..{end} are outside the dataset's {elements}"
7988 )));
7989 }
7990 // Resolve now as well as at fixup time, so a path that names nothing
7991 // is reported at the call that got it wrong.
7992 for path in paths {
7993 self.object_reference_target(path)?;
7994 }
7995 let mut pending = self.pending_object_references.lock();
7996 for (i, path) in paths.iter().enumerate() {
7997 pending.push(PendingObjectReference {
7998 dataset: index,
7999 element: start + i as u64,
8000 target: (*path).to_string(),
8001 });
8002 }
8003 Ok(())
8004 }
8005
8006 /// Record a hard link count of `rc` in `header`, if this file's format
8007 /// needs a message to carry it.
8008 ///
8009 /// A version-2 header carries the count in an Object Reference Count
8010 /// message, and only when more than one link reaches the object. A
8011 /// version-1 header carries it in its prefix and gets no message at all —
8012 /// `H5O_link_oh` gates every refcount-message operation on
8013 /// `oh->version > H5O_VERSION_1` (H5Oint.c:851), so a version-1 header
8014 /// holding one is a shape libhdf5 never writes.
8015 ///
8016 /// The message carries `H5O_MSG_FLAG_DONTSHARE`, which both refcount
8017 /// operations pass (H5Oint.c:874 append, H5Oint.c:864 write): the count is
8018 /// a property of this one object header, so a shared-message index that
8019 /// pointed several headers at one copy would make every object with the
8020 /// same link count share a single number.
8021 fn emit_refcount(&self, header: &mut ObjectHeader, rc: u32, format: ObjectFormat) {
8022 if rc > 1 && format == ObjectFormat::Modern {
8023 header.add_message(MSG_OBJ_REF_COUNT, MSG_FLAG_DONTSHARE, encode_refcount(rc));
8024 }
8025 }
8026
8027 /// Encode `header` as `placement` lays it out, at the version this file's
8028 /// format calls for and with `rc` as the object's hard link count: every
8029 /// `(address, image)` pair to write, chunk 0 first.
8030 ///
8031 /// The count is passed rather than read off the header because the two
8032 /// versions carry it in different places — the version-1 prefix's `nlink`
8033 /// field, the version-2 Reference Count message
8034 /// [`emit_refcount`](Self::emit_refcount) already added — and only the
8035 /// caller knows it.
8036 ///
8037 /// INVARIANT: an object header's chunk 0 never moves once something in the
8038 /// file has named its address. A written header is rewritten over the
8039 /// chunk-0 block it already has, padded when the messages shrank and
8040 /// spilling into a continuation block of its own when they grew — the way
8041 /// `H5O__alloc_new_chunk` (H5Oalloc.c) grows a header libhdf5 cannot
8042 /// extend in place. That is what keeps every object reference already in
8043 /// the file — in a reference dataset, an attribute, a `REFERENCE_LIST`,
8044 /// whoever wrote them — resolving after this session. The one exception is
8045 /// a block too small to hold even the message naming a continuation, which
8046 /// [`place_header`](Self::place_header) gives up and replaces.
8047 ///
8048 /// A fresh header lives in the one block its address and encoded size
8049 /// describe: one whose messages overflow chunk 0 gets its continuation
8050 /// chunk immediately behind it in that same block, so the address is
8051 /// enough to free or supersede the whole header. libhdf5 would have grown
8052 /// chunk 0 into space that free rather than chaining onto it, but it reads
8053 /// a continuation chunk by the address and length its message states and
8054 /// cares nothing for where that lands.
8055 fn encode_header_in(
8056 &self,
8057 header: &ObjectHeader,
8058 rc: u32,
8059 format: ObjectFormat,
8060 placement: &HeaderPlacement,
8061 ) -> IoResult<Vec<(u64, Vec<u8>)>> {
8062 let plan = if placement.kept {
8063 header.plan_chunks_in(format, placement.size, &self.ctx)?
8064 } else {
8065 header.plan_chunks(format, self.chunk0_capacity(header, format), &self.ctx)?
8066 };
8067 let continuation_addr = match placement.continuation {
8068 Some((addr, _)) => addr,
8069 None => placement.addr + plan.chunk0_size as u64,
8070 };
8071 let (mut chunk0, continuation) =
8072 header.encode_chunked(&plan, format, &self.ctx, continuation_addr, rc)?;
8073 match (placement.continuation, continuation) {
8074 (Some((addr, _)), Some(image)) => Ok(vec![(placement.addr, chunk0), (addr, image)]),
8075 (None, Some(image)) => {
8076 chunk0.extend_from_slice(&image);
8077 Ok(vec![(placement.addr, chunk0)])
8078 }
8079 (None, None) => Ok(vec![(placement.addr, chunk0)]),
8080 (Some((addr, size)), None) => Err(crate::io::IoError::InvalidState(format!(
8081 "an object header was placed with a {size}-byte continuation block at \
8082 {addr:#x} that it no longer needs; a message in it changed length \
8083 once the addresses it names were known"
8084 ))),
8085 }
8086 }
8087
8088 /// Reserve the blocks `header` will be written over, keeping `kept` — the
8089 /// chunk-0 block the object's existing header occupies — when there is
8090 /// one it can be written over.
8091 ///
8092 /// A header's layout does not depend on the addresses it carries, which is
8093 /// what lets the group pass hand every group header an address before it
8094 /// writes any of their content: every address is a fixed-width field.
8095 ///
8096 /// A kept block is given up only when it cannot describe the header at
8097 /// all: too narrow for the message naming a continuation chunk, or not a
8098 /// shape the header's version can pad (see `ObjectHeader::plan_chunks_in`).
8099 /// Then it is freed and the header gets a fresh block, exactly as a new
8100 /// object does — and the references naming it are the caller's to
8101 /// restamp, which the writer does for every one it registered.
8102 fn place_header(
8103 &mut self,
8104 header: &ObjectHeader,
8105 format: ObjectFormat,
8106 kept: Option<(u64, u64)>,
8107 ) -> IoResult<HeaderPlacement> {
8108 if let Some((addr, len)) = kept {
8109 let plan = usize::try_from(len)
8110 .ok()
8111 .and_then(|len| header.plan_chunks_in(format, len, &self.ctx).ok());
8112 match plan {
8113 Some(plan) => {
8114 let continuation = (plan.continuation_size > 0).then(|| {
8115 let size = plan.continuation_size;
8116 let addr = self
8117 .allocator
8118 .allocate(size as u64, FreeSpaceClass::Metadata);
8119 (addr, size)
8120 });
8121 return Ok(HeaderPlacement {
8122 addr,
8123 size: len as usize,
8124 kept: true,
8125 continuation,
8126 });
8127 }
8128 // A block a SWMR reader may be walking stays allocated, as
8129 // everywhere else under `swmr_active`.
8130 None if !self.swmr_active => {
8131 self.allocator.free(addr, len, FreeSpaceClass::Metadata);
8132 }
8133 None => {}
8134 }
8135 }
8136 let plan = header.plan_chunks(format, self.chunk0_capacity(header, format), &self.ctx)?;
8137 let size = plan.chunk0_size + plan.continuation_size;
8138 let addr = self
8139 .allocator
8140 .allocate(size as u64, FreeSpaceClass::Metadata);
8141 Ok(HeaderPlacement::fresh(addr, size))
8142 }
8143
8144 /// How many bytes of messages `header`'s chunk 0 holds before the rest
8145 /// spill into a continuation chunk.
8146 ///
8147 /// libhdf5 sizes chunk 0 once, when the object header is created, and can
8148 /// only grow it while the space behind it is still free — so an object
8149 /// whose creation-time estimate covered every message it would ever hold
8150 /// keeps one chunk, and one whose estimate was a guess does not. A dataset
8151 /// or a committed datatype is created from messages already in hand
8152 /// (`H5D__update_oh_info`, `H5T__commit`), so its estimate is exact and
8153 /// this writer's exact fit is the same answer.
8154 ///
8155 /// A group is the exception: `H5G__obj_create_real` (H5Gobj.c:219) sizes
8156 /// its header for the link info and group info messages plus
8157 /// `H5G_CRT_GINFO_EST_NUM_ENTRIES` links of `H5G_CRT_GINFO_EST_NAME_LEN`
8158 /// characters, and nothing else — attributes above all — is in that
8159 /// estimate. The Link Info message is what identifies one: it is the
8160 /// message that makes an object a new-format group, and
8161 /// `H5G__obj_get_linfo` uses it for exactly this question.
8162 ///
8163 /// A version-1 header is written as one chunk whatever it holds: its
8164 /// groups keep their links in a symbol table, not in the header, so the
8165 /// estimate that makes a version-2 group spill never applies to one.
8166 fn chunk0_capacity(&self, header: &ObjectHeader, format: ObjectFormat) -> usize {
8167 if format == ObjectFormat::Legacy {
8168 return usize::MAX;
8169 }
8170 let envelope = header.message_envelope_size();
8171 let sized = |msg_type: u8| {
8172 header
8173 .messages
8174 .iter()
8175 .find(|m| m.msg_type == msg_type)
8176 .map(|m| envelope + m.data.len())
8177 };
8178 let Some(link_info) = sized(MSG_LINK_INFO) else {
8179 return usize::MAX;
8180 };
8181 // One estimated hard link: version, flags, a one-byte name length for
8182 // a name this short, the name, and the object header address.
8183 let link = envelope + 1 + 1 + 1 + EST_LINK_NAME_LEN + self.ctx.sizeof_addr as usize;
8184 link_info + sized(MSG_GROUP_INFO).unwrap_or(0) + EST_LINK_COUNT * link
8185 }
8186
8187 /// The object an object reference's path names, as a hard-link target;
8188 /// `None` for the root group, which has no registry slot.
8189 fn object_reference_target(&self, path: &str) -> IoResult<Option<HardLinkTarget>> {
8190 let rel = self.canonical_dataset_path(path.trim_matches('/'));
8191 if rel.is_empty() {
8192 return Ok(None);
8193 }
8194 self.resolve_object(&rel)
8195 .map(Some)
8196 .ok_or_else(|| crate::io::IoError::NotFound(format!("reference target '{path}'")))
8197 }
8198
8199 /// The object header address an object reference's `path` names, or zero
8200 /// when that object has not been given one yet.
8201 ///
8202 /// Zero is where the superblock sits, so it is never an object header's
8203 /// address. It is what every object reads as before
8204 /// [`allocate_object_headers`](Self::allocate_object_headers) runs, which
8205 /// is what lets the pass that measures a header stand in for the pass that
8206 /// writes it: an address is a fixed-width field, so the placeholder is the
8207 /// same size as the answer.
8208 fn object_reference_address(&self, path: &str) -> IoResult<u64> {
8209 Ok(match self.object_reference_target(path)? {
8210 Some(HardLinkTarget::Dataset(i)) => self.ds(i).lock().obj_header_addr,
8211 Some(HardLinkTarget::Group(i)) => self.grp(i).lock().obj_header_addr,
8212 None => self.root_group_addr.unwrap_or(0),
8213 })
8214 }
8215
8216 /// `scope`'s attributes as this finalize will write them: the stored set,
8217 /// with every object-reference attribute's value said in the object header
8218 /// addresses assigned so far.
8219 ///
8220 /// The single owner of a reference attribute's value, and the only source
8221 /// an object header build may take an attribute set from. Nothing stored
8222 /// is mutated, so the pass that measures a header and the pass that writes
8223 /// it cannot disagree about anything but the addresses — which they cannot
8224 /// disagree about in length.
8225 ///
8226 /// INVARIANT: the stored attribute list is what says which attributes
8227 /// exist; a recorded reference value can only give a value to one already
8228 /// in it. So a value left behind by an object whose list was emptied — a
8229 /// deleted group or dataset — cannot put the attribute back, and a value
8230 /// whose attribute was replaced by one of another type is dropped at the
8231 /// replacement instead of reaching it (see
8232 /// [`forget_attribute_reference`](Self::forget_attribute_reference)).
8233 fn object_attributes(&self, scope: AttrScope) -> IoResult<Vec<AttributeEntry>> {
8234 let mut attrs = match scope {
8235 AttrScope::Root => self.root_attributes.lock().clone(),
8236 AttrScope::Group(gi) => self.grp(gi).lock().attributes.clone(),
8237 AttrScope::Dataset(i) => self.ds(i).lock().attributes.clone(),
8238 };
8239 // Snapshot first: resolving a path locks group and dataset slots.
8240 let values: Vec<(String, Vec<String>, usize)> = self
8241 .attribute_references
8242 .lock()
8243 .iter()
8244 .filter(|r| r.scope == scope)
8245 .map(|r| (r.name.clone(), r.targets.clone(), r.stride))
8246 .collect();
8247 let width = self.ctx.sizeof_addr as usize;
8248 for (name, targets, stride) in values {
8249 let Some(pos) = attrs.iter().position(|a| a.name() == name) else {
8250 continue;
8251 };
8252 let Some(msg) = attrs[pos].readable() else {
8253 continue;
8254 };
8255 let mut msg = msg.clone();
8256 for (i, target) in targets.iter().enumerate() {
8257 let at = i * stride;
8258 let held = msg.data.len();
8259 let slot = msg.data.get_mut(at..at + width).ok_or_else(|| {
8260 crate::io::IoError::InvalidState(format!(
8261 "attribute '{name}' holds {held} bytes, too few for reference {i} at {at}"
8262 ))
8263 })?;
8264 slot.copy_from_slice(
8265 &self.object_reference_address(target)?.to_le_bytes()[..width],
8266 );
8267 }
8268 attrs[pos] = AttributeEntry::from(msg).with_creation_index(attrs[pos].creation_index());
8269 }
8270 Ok(attrs)
8271 }
8272
8273 /// The registry scope `target` names — the same object
8274 /// [`with_attr_list`](Self::with_attr_list) reaches, as the key the
8275 /// reference-value registry is indexed by. Refuses what that accessor
8276 /// refuses, and for the same reasons.
8277 fn attr_scope(&self, target: AttrTarget<'_>) -> IoResult<AttrScope> {
8278 match target {
8279 AttrTarget::Root => Ok(AttrScope::Root),
8280 AttrTarget::Group(path) => {
8281 let path = self.canonical_group_path(path);
8282 self.group_refs()
8283 .iter()
8284 .position(|g| {
8285 let gg = g.lock();
8286 gg.name == path && !gg.deleted
8287 })
8288 .map(AttrScope::Group)
8289 .ok_or_else(|| {
8290 crate::io::IoError::NotFound(format!("group '{path}' not found"))
8291 })
8292 }
8293 AttrTarget::Dataset(index) => {
8294 let count = self.dataset_count();
8295 if index >= count {
8296 return Err(crate::io::IoError::InvalidState(format!(
8297 "dataset index {index} out of range (have {count})"
8298 )));
8299 }
8300 Ok(AttrScope::Dataset(index))
8301 }
8302 }
8303 }
8304
8305 /// Drop the reference value recorded for `scope`'s attribute `name`.
8306 ///
8307 /// Called by both owners of attribute-list mutation —
8308 /// [`insert_attribute`](Self::insert_attribute) and
8309 /// [`evict_attr`](Self::evict_attr) — so an attribute that is replaced or
8310 /// removed cannot leave its value behind for whatever takes its name next.
8311 /// A string attribute written over a reference attribute is the case that
8312 /// needs it: without this the string's bytes would be overwritten with
8313 /// addresses at finalize.
8314 fn forget_attribute_reference(&self, scope: AttrScope, name: &str) {
8315 self.attribute_references
8316 .lock()
8317 .retain(|r| !(r.scope == scope && r.name == name));
8318 }
8319
8320 /// Write every pending object reference element as its target's object
8321 /// header address.
8322 ///
8323 /// INVARIANT: a reference element on disk holds its target's header
8324 /// address. Reached through [`write_reference_values`](Self::write_reference_values),
8325 /// which places it after every header has an address; a target that no
8326 /// longer resolves fails the finalize rather than leaving a placeholder
8327 /// behind.
8328 fn write_object_reference_values(&mut self) -> IoResult<()> {
8329 // Snapshot rather than drain: a SWMR session finalizes twice, and the
8330 // close-time finalize rebuilds every header at a fresh address, so the
8331 // elements must be stamped again with the addresses that survive.
8332 let pending: Vec<(usize, u64, String)> = self
8333 .pending_object_references
8334 .lock()
8335 .iter()
8336 .map(|p| (p.dataset, p.element, p.target.clone()))
8337 .collect();
8338 for (dataset, element, target) in &pending {
8339 let addr = match self.object_reference_target(target)? {
8340 Some(HardLinkTarget::Dataset(i)) => self.ds(i).lock().obj_header_addr,
8341 Some(HardLinkTarget::Group(i)) => self.grp(i).lock().obj_header_addr,
8342 None => self.root_group_addr.ok_or_else(|| {
8343 crate::io::IoError::InvalidState(
8344 "root group header address is not assigned yet".into(),
8345 )
8346 })?,
8347 };
8348 // The element image is the dataset's own datatype's business: the
8349 // pre-1.12 and 1.12 forms differ in width and in layout, and the
8350 // dataset says which it holds.
8351 let (kind, width) = {
8352 let ds = self.ds(*dataset);
8353 let m = ds.lock();
8354 let DatatypeMessage::Reference { kind, size } = &m.datatype else {
8355 return Err(crate::io::IoError::InvalidState(format!(
8356 "dataset '{}' is no longer a reference dataset",
8357 m.name
8358 )));
8359 };
8360 (*kind, *size as usize)
8361 };
8362 let image = match kind {
8363 ReferenceKind::Object1 => ReferenceElementImage::Legacy(addr),
8364 ReferenceKind::Object2 => ReferenceElementImage::Inline(addr),
8365 other => {
8366 return Err(crate::io::IoError::InvalidState(format!(
8367 "dataset {dataset} now holds {other:?} elements, not object references"
8368 )))
8369 }
8370 };
8371 let image = encode_reference_element(&image, width, &self.ctx)?;
8372 let data_addr = self.local_element_block(*dataset, "object references")?;
8373 let at = data_addr + element * width as u64;
8374 self.handle.write_at(at, &image)?;
8375 }
8376 Ok(())
8377 }
8378
8379 /// Store region references over `targets` into the elements of dataset
8380 /// `index` starting at `start`.
8381 ///
8382 /// Each target is the path of a dataset and a selection over it. What the
8383 /// element holds is a global-heap id — collection address then object index
8384 /// (`H5R__encode_heap`) — and the heap object it names is the target's
8385 /// object header address followed by the serialized selection
8386 /// (`H5R__encode_token_region_compat`). Both the object and the element are
8387 /// written here; only the address inside the object waits for
8388 /// [`Self::write_heap_reference_values`]. Elements never written keep the
8389 /// zero image libhdf5 reads back as a null reference.
8390 pub fn write_region_references(
8391 &self,
8392 index: usize,
8393 start: u64,
8394 targets: &[(&str, Selection)],
8395 ) -> IoResult<()> {
8396 let elements = {
8397 let ds = self.ds(index);
8398 let m = ds.lock();
8399 match &m.datatype {
8400 DatatypeMessage::Reference {
8401 kind: ReferenceKind::DatasetRegion1,
8402 ..
8403 } => {}
8404 other => {
8405 return Err(crate::io::IoError::InvalidState(format!(
8406 "dataset '{}' has datatype {other}, not a region reference",
8407 m.name
8408 )))
8409 }
8410 }
8411 m.dataspace
8412 .dims
8413 .iter()
8414 .fold(1u64, |a, &d| a.saturating_mul(d))
8415 };
8416 let data_addr = self.local_element_block(index, "region references")?;
8417 let end = start.saturating_add(targets.len() as u64);
8418 if end > elements {
8419 return Err(crate::io::IoError::InvalidState(format!(
8420 "elements {start}..{end} are outside the dataset's {elements}"
8421 )));
8422 }
8423
8424 // Build every heap object before inserting any: a path that names no
8425 // dataset, or a selection its extent does not admit, is reported at the
8426 // call that got it wrong rather than after half the batch is on disk.
8427 let sa = self.ctx.sizeof_addr as usize;
8428 let mut blobs = Vec::with_capacity(targets.len());
8429 for (path, selection) in targets {
8430 let target = self.region_reference_target(path)?;
8431 let dims = self.ds(target).lock().dataspace.dims.clone();
8432 validate_region_selection(selection, &dims, path)?;
8433 let mut blob = vec![0u8; sa];
8434 blob.extend_from_slice(&selection.encode()?);
8435 blobs.push(blob);
8436 }
8437 let items: Vec<&[u8]> = blobs.iter().map(Vec::as_slice).collect();
8438 let placements = self.insert_vlen_objects(&items)?;
8439
8440 let width = (sa + 4) as u64;
8441 let mut pending = self.pending_heap_references.lock();
8442 for (i, &(collection, obj_index)) in placements.iter().enumerate() {
8443 let mut elem = Vec::with_capacity(width as usize);
8444 elem.extend_from_slice(&collection.to_le_bytes()[..sa]);
8445 elem.extend_from_slice(&u32::from(obj_index).to_le_bytes());
8446 self.handle
8447 .write_at(data_addr + (start + i as u64) * width, &elem)?;
8448 pending.push(PendingHeapReference {
8449 collection,
8450 index: obj_index,
8451 token_offset: 0,
8452 target: PendingHeapTarget::Dataset(targets[i].0.to_string()),
8453 });
8454 }
8455 Ok(())
8456 }
8457
8458 /// Store 1.12 references over `targets` into the elements of dataset
8459 /// `index` starting at `start` — the `H5T_STD_REF` trio.
8460 ///
8461 /// One datatype holds all three kinds, because a 1.12 element leads with
8462 /// the kind it holds; which is why this takes a [`ReferenceTarget`] per
8463 /// element rather than a fixed kind. `H5R_OBJECT2` needs nothing but the
8464 /// target's address, so its element is written inline by the same finalize
8465 /// pass every object reference goes through. The other two encode a
8466 /// selection or an attribute name alongside the token, which does not fit
8467 /// an element, so what is stored is a global-heap blob and the element is
8468 /// its id (`H5T__ref_disk_write`). Elements never written keep the zero
8469 /// image `H5T__ref_disk_isnull` reads back as a null reference.
8470 pub fn write_revised_references(
8471 &self,
8472 index: usize,
8473 start: u64,
8474 targets: &[(&str, ReferenceTarget)],
8475 ) -> IoResult<()> {
8476 let (width, elements) = {
8477 let ds = self.ds(index);
8478 let m = ds.lock();
8479 match &m.datatype {
8480 DatatypeMessage::Reference {
8481 kind: ReferenceKind::Object2,
8482 size,
8483 } => (
8484 *size as u64,
8485 m.dataspace
8486 .dims
8487 .iter()
8488 .fold(1u64, |a, &d| a.saturating_mul(d)),
8489 ),
8490 other => {
8491 return Err(crate::io::IoError::InvalidState(format!(
8492 "dataset '{}' has datatype {other}, not the 1.12 H5T_STD_REF",
8493 m.name
8494 )))
8495 }
8496 }
8497 };
8498 let data_addr = self.local_element_block(index, "references")?;
8499 let end = start.saturating_add(targets.len() as u64);
8500 if end > elements {
8501 return Err(crate::io::IoError::InvalidState(format!(
8502 "elements {start}..{end} are outside the dataset's {elements}"
8503 )));
8504 }
8505
8506 // Build every blob before inserting any, so a path that names nothing,
8507 // a selection an extent does not admit or an attribute that does not
8508 // exist is reported at the call that got it wrong rather than after
8509 // half the batch is on disk.
8510 let mut blobs: Vec<(u64, ReferenceKind, PendingHeapTarget, Vec<u8>)> = Vec::new();
8511 let mut inline: Vec<(u64, String)> = Vec::new();
8512 for (i, (path, target)) in targets.iter().enumerate() {
8513 let element = start + i as u64;
8514 // The rank of the extent the selection is over, which only a region
8515 // reference encodes and takes from the target's dataspace.
8516 let mut extent_rank = 0;
8517 let (kind, pending) = match target {
8518 ReferenceTarget::Object => {
8519 self.object_reference_target(path)?;
8520 inline.push((element, (*path).to_string()));
8521 continue;
8522 }
8523 ReferenceTarget::Region(selection) => {
8524 let ds = self.region_reference_target(path)?;
8525 let dims = self.ds(ds).lock().dataspace.dims.clone();
8526 validate_region_selection(selection, &dims, path)?;
8527 extent_rank = dims.len();
8528 (
8529 ReferenceKind::DatasetRegion2,
8530 PendingHeapTarget::Dataset((*path).to_string()),
8531 )
8532 }
8533 ReferenceTarget::Attribute(name) => {
8534 let scope = match self.object_reference_target(path)? {
8535 Some(HardLinkTarget::Dataset(i)) => AttrScope::Dataset(i),
8536 Some(HardLinkTarget::Group(i)) => AttrScope::Group(i),
8537 None => AttrScope::Root,
8538 };
8539 if !self
8540 .object_attributes(scope)?
8541 .iter()
8542 .any(|a| a.name() == name)
8543 {
8544 return Err(crate::io::IoError::NotFound(format!(
8545 "attribute '{name}' of reference target '{path}'"
8546 )));
8547 }
8548 (
8549 ReferenceKind::Attr,
8550 PendingHeapTarget::Object((*path).to_string()),
8551 )
8552 }
8553 };
8554 blobs.push((
8555 element,
8556 kind,
8557 pending,
8558 encode_revised_blob(0, target, extent_rank, &self.ctx)?,
8559 ));
8560 }
8561
8562 let items: Vec<&[u8]> = blobs.iter().map(|(_, _, _, b)| b.as_slice()).collect();
8563 let placements = self.insert_vlen_objects(&items)?;
8564
8565 let mut pending = self.pending_heap_references.lock();
8566 for ((element, kind, target, blob), &(collection, obj_index)) in
8567 blobs.iter().zip(&placements)
8568 {
8569 // The size the element declares is the heap object's own byte
8570 // count: `H5VL__native_blob_get` refuses to read one whose size
8571 // does not match what the element says.
8572 let image = encode_reference_element(
8573 &ReferenceElementImage::Blob {
8574 kind: *kind,
8575 size: blob.len() as u32,
8576 collection,
8577 index: u32::from(obj_index),
8578 },
8579 width as usize,
8580 &self.ctx,
8581 )?;
8582 self.handle.write_at(data_addr + element * width, &image)?;
8583 pending.push(PendingHeapReference {
8584 collection,
8585 index: obj_index,
8586 token_offset: REVISED_BLOB_TOKEN_OFFSET,
8587 target: target.clone(),
8588 });
8589 }
8590 drop(pending);
8591
8592 let mut pending = self.pending_object_references.lock();
8593 for (element, path) in inline {
8594 pending.push(PendingObjectReference {
8595 dataset: index,
8596 element,
8597 target: path,
8598 });
8599 }
8600 Ok(())
8601 }
8602
8603 /// The dataset a region reference's path names.
8604 ///
8605 /// A region reference names a *dataset*: `H5Rcreate` with
8606 /// `H5R_DATASET_REGION` takes the dataspace of one, and every reader
8607 /// dereferences it as one. A path that resolves to a group — or to the root
8608 /// group, which has no registry slot — is refused here rather than stored
8609 /// as a reference nothing can dereference.
8610 fn region_reference_target(&self, path: &str) -> IoResult<usize> {
8611 match self.object_reference_target(path)? {
8612 Some(HardLinkTarget::Dataset(i)) => Ok(i),
8613 _ => Err(crate::io::IoError::InvalidState(format!(
8614 "region reference target '{path}' is not a dataset"
8615 ))),
8616 }
8617 }
8618
8619 /// Stamp every pending heap-backed reference's object with its target's
8620 /// object header address.
8621 ///
8622 /// The references that are still stamped rather than written once: the
8623 /// *element* is a global-heap id, so the heap object has to exist at the
8624 /// call that stores the reference, long before any address does. The object
8625 /// was inserted with its token zeroed, so its size does not change here:
8626 /// each collection is read once, patched, and rewritten at its own declared
8627 /// size, which leaves every element's heap id valid — and leaves the
8628 /// object's byte count equal to the size the 1.12 element declares, which
8629 /// `H5VL__native_blob_get` refuses to read past.
8630 fn write_heap_reference_values(&mut self) -> IoResult<()> {
8631 use crate::format::global_heap::GlobalHeapCollection;
8632
8633 // Snapshot rather than drain, for the same reason the object-reference
8634 // pass does: a SWMR session finalizes twice and the close-time finalize
8635 // rebuilds every header at a fresh address.
8636 let pending: Vec<(u64, u16, usize, PendingHeapTarget)> = self
8637 .pending_heap_references
8638 .lock()
8639 .iter()
8640 .map(|p| (p.collection, p.index, p.token_offset, p.target.clone()))
8641 .collect();
8642 if pending.is_empty() {
8643 return Ok(());
8644 }
8645 let sa = self.ctx.sizeof_addr as usize;
8646 // Group by collection so one holding several references is read and
8647 // rewritten once.
8648 let mut per_collection: std::collections::BTreeMap<u64, Vec<(u16, usize, u64)>> =
8649 Default::default();
8650 for (collection, index, token_offset, target) in &pending {
8651 let addr = match target {
8652 PendingHeapTarget::Dataset(path) => {
8653 let ds = self.region_reference_target(path)?;
8654 self.ds(ds).lock().obj_header_addr
8655 }
8656 PendingHeapTarget::Object(path) => self.object_reference_address(path)?,
8657 };
8658 per_collection
8659 .entry(*collection)
8660 .or_default()
8661 .push((*index, *token_offset, addr));
8662 }
8663 for (collection, patches) in per_collection {
8664 // A collection is at least 4096 bytes (H5HG_MINALLOC) and most are
8665 // exactly that, so one read usually covers the whole image.
8666 let mut image = self.handle.read_at_most(collection, 4096)?;
8667 let declared = GlobalHeapCollection::decode_size(&image, &self.ctx)?;
8668 if declared > image.len() {
8669 image = self.handle.read_at(collection, declared)?;
8670 }
8671 let (mut gcol, _) = GlobalHeapCollection::decode(&image[..declared], &self.ctx)?;
8672 for (index, token_offset, addr) in patches {
8673 let token = gcol
8674 .objects
8675 .iter_mut()
8676 .find(|o| o.index == index)
8677 .and_then(|o| o.data.get_mut(token_offset..token_offset + sa))
8678 .ok_or_else(|| {
8679 crate::io::IoError::InvalidState(format!(
8680 "object {index} of global heap collection {collection:#x} is no \
8681 longer the reference written into it"
8682 ))
8683 })?;
8684 token.copy_from_slice(&addr.to_le_bytes()[..sa]);
8685 }
8686 let rewritten = gcol.encode_at_size(&self.ctx, declared)?;
8687 self.handle.write_at(collection, &rewritten)?;
8688 }
8689 Ok(())
8690 }
8691
8692 /// Give every reference written this session its target's object header
8693 /// address.
8694 ///
8695 /// INVARIANT: no file is closed holding a reference whose target address is
8696 /// still the placeholder its write left. Both finalize paths call this in
8697 /// the content phase — after
8698 /// [`allocate_object_headers`](Self::allocate_object_headers), so every
8699 /// address exists, and before any object header is written — and this is
8700 /// the only caller of the per-kind passes, so a reference kind added later
8701 /// is written at both finalize sites or at neither. A target that no longer
8702 /// resolves fails the finalize rather than leaving a placeholder behind.
8703 ///
8704 /// This covers the two reference kinds whose value lives outside an object
8705 /// header. An attribute's value lives *inside* one, so it has no pass here:
8706 /// [`object_attributes`](Self::object_attributes) says it in addresses as
8707 /// the header is built.
8708 fn write_reference_values(&mut self) -> IoResult<()> {
8709 self.write_object_reference_values()?;
8710 self.write_heap_reference_values()
8711 }
8712
8713 /// Append every user-created hard link whose parent group is `parent`
8714 /// (`None` == the root group). Called while collecting a group's links,
8715 /// once every object's header address has been assigned.
8716 fn push_hard_links(&self, links: &mut Vec<(u64, LinkMessage)>, parent: Option<usize>) {
8717 for link in self.hard_links_vec() {
8718 if link.parent != parent || !self.hard_link_emitted(&link) {
8719 continue;
8720 }
8721 let addr = match link.target {
8722 HardLinkTarget::Dataset(i) => self.ds(i).lock().obj_header_addr,
8723 HardLinkTarget::Group(i) => self.grp(i).lock().obj_header_addr,
8724 };
8725 links.push((link.creation_seq, LinkMessage::hard(&link.name, addr)));
8726 }
8727 }
8728
8729 /// Append every user-created symbolic link whose parent group is `parent`
8730 /// (`None` == the root group).
8731 ///
8732 /// Nothing here waits on the layout pass — the link's value is a path, not
8733 /// an address — but it is collected with the rest so it takes its place in
8734 /// creation order and counts toward the phase change.
8735 fn push_symbolic_links(&self, links: &mut Vec<(u64, LinkMessage)>, parent: Option<usize>) {
8736 for link in self.symbolic_links_vec() {
8737 if link.parent != parent || !self.symbolic_link_emitted(&link) {
8738 continue;
8739 }
8740 links.push((
8741 link.creation_seq,
8742 LinkMessage {
8743 name: link.name.clone(),
8744 target: link.target.clone(),
8745 creation_order: None,
8746 cset: CharacterSet::for_name(&link.name),
8747 },
8748 ));
8749 }
8750 }
8751
8752 /// Refuse a caller path that would have to leave this file through one of
8753 /// the external links a reopened file brought in.
8754 ///
8755 /// The reader follows such a path into the file the link names; the writer
8756 /// cannot, because it models one file and would have to write into
8757 /// another. Saying which link stops the path — rather than reporting the
8758 /// name as absent, or worse, creating a second link of that name beside
8759 /// it — is the whole of what write mode does here.
8760 pub(crate) fn reject_external_traversal(&self, path: &str) -> IoResult<()> {
8761 let path = path.trim_start_matches('/');
8762 let crossing = self.preserved_link_paths().into_iter().find(|(p, class)| {
8763 matches!(class, crate::io::reader::LinkClass::External { .. })
8764 && (path == p || path.starts_with(&format!("{p}/")))
8765 });
8766 match crossing {
8767 None => Ok(()),
8768 Some((link, crate::io::reader::LinkClass::External { file, path: target })) => {
8769 Err(crate::io::IoError::Unsupported(format!(
8770 "'{path}' resolves through the external link '{link}' to '{target}' in \
8771 '{file}'; this writer carries external links through a rewrite but does \
8772 not open the file they name"
8773 )))
8774 }
8775 // `find` matched on the External arm, so no other class reaches here.
8776 Some(_) => Ok(()),
8777 }
8778 }
8779
8780 /// Resolve `name` to a live dataset index, reporting *why* it does not
8781 /// resolve rather than collapsing every cause into absence.
8782 ///
8783 /// The write-mode counterpart of [`Hdf5Reader::open_dataset`]: the single
8784 /// gate every by-name dataset lookup in write mode goes through.
8785 ///
8786 /// [`Hdf5Reader::open_dataset`]: crate::io::reader::Hdf5Reader::open_dataset
8787 pub(crate) fn open_dataset_index(&self, name: &str) -> IoResult<usize> {
8788 self.reject_external_traversal(name)?;
8789 self.reject_preserved_object(name)?;
8790 self.dataset_index(name)
8791 .ok_or_else(|| crate::io::IoError::NotFound(name.to_string()))
8792 }
8793
8794 /// Refuse a caller path that names an object the reopen kept by its bytes
8795 /// rather than modelling.
8796 ///
8797 /// Such an object is in the file and stays in it, but this writer holds
8798 /// none of what it would need to read or rewrite it. Saying so — with the
8799 /// reason the classification recorded — is the difference between an
8800 /// object the writer will not touch and a name the file does not have.
8801 pub(crate) fn reject_preserved_object(&self, path: &str) -> IoResult<()> {
8802 let path = path.trim_start_matches('/');
8803 let objects: Vec<(String, String)> = {
8804 let preserved = self.preserved_links.lock();
8805 preserved
8806 .iter()
8807 .filter_map(|l| {
8808 l.reason
8809 .as_ref()
8810 .map(|why| (self.preserved_link_full_path(l), why.clone()))
8811 })
8812 .collect()
8813 };
8814 match objects
8815 .into_iter()
8816 .find(|(full, _)| path == full || path.starts_with(&format!("{full}/")))
8817 {
8818 None => Ok(()),
8819 Some((link, why)) => Err(crate::io::IoError::Unsupported(format!(
8820 "'{path}' is, or is inside, the object '{link}', which this file's reopen \
8821 kept exactly as it found it because {why}"
8822 ))),
8823 }
8824 }
8825
8826 /// Every link this writer will emit that names a *path* rather than an
8827 /// object, with the class a listing reports for it: the soft and external
8828 /// links created this session, and the ones a reopen is carrying through.
8829 ///
8830 /// The object listings answer for hard links, so a write-mode link
8831 /// listing is this plus those; keeping both sources in one place is what
8832 /// stops a listing from seeing a kind the class lookup does not, or the
8833 /// reverse.
8834 pub(crate) fn path_link_classes(&self) -> Vec<(String, crate::io::reader::LinkClass)> {
8835 let mut out: Vec<(String, crate::io::reader::LinkClass)> = self
8836 .symbolic_links_vec()
8837 .iter()
8838 .filter(|l| self.symbolic_link_emitted(l))
8839 .map(|l| {
8840 (
8841 self.symbolic_link_full_path(l),
8842 crate::io::reader::LinkClass::from_target(&l.target),
8843 )
8844 })
8845 .collect();
8846 out.extend(self.preserved_link_paths());
8847 out
8848 }
8849
8850 /// Every link this writer is carrying but cannot express, by full path.
8851 pub(crate) fn preserved_link_paths(&self) -> Vec<(String, crate::io::reader::LinkClass)> {
8852 self.preserved_links
8853 .lock()
8854 .iter()
8855 .map(|l| (self.preserved_link_full_path(l), l.class.clone()))
8856 .collect()
8857 }
8858
8859 /// The full path of a preserved link: its parent group's path plus its
8860 /// leaf name, in the no-leading-`/` form the registry uses.
8861 fn preserved_link_full_path(&self, link: &PreservedLink) -> String {
8862 match link.parent {
8863 None => link.name.clone(),
8864 Some(gi) => {
8865 let group = self.grp(gi).lock().name.clone();
8866 format!("{}/{}", group.trim_start_matches('/'), link.name)
8867 }
8868 }
8869 }
8870
8871 /// The single owner of "which links does this group hold", in the order
8872 /// they were created and, when the file tracks creation order, stamped
8873 /// with it.
8874 ///
8875 /// Both the compact form (one `MSG_LINK` per link) and the dense form (the
8876 /// same messages inside a fractal heap) are built from this one list, so
8877 /// the phase-change decision, the storage it selects and the creation
8878 /// order recorded in either can never disagree about what the group
8879 /// contains. The links a reopen kept by their bytes are in it too: they
8880 /// take their rank in the same numbering, or a group that tracks
8881 /// creation order would hold two links with one order.
8882 fn group_links(&self, scope: LinkScope, order: CreationOrder) -> Vec<GroupLink> {
8883 let mut links: Vec<(u64, LinkMessage)> = Vec::new();
8884 match scope {
8885 LinkScope::Root => {
8886 // Datasets that belong to a subgroup are that group's links,
8887 // not the root's. Each group slot is locked one at a time.
8888 let mut datasets_in_subgroups: std::collections::HashSet<usize> =
8889 std::collections::HashSet::new();
8890 for grp in self.group_refs() {
8891 let g = grp.lock();
8892 if g.deleted {
8893 continue;
8894 }
8895 datasets_in_subgroups.extend(g.child_datasets.iter().copied());
8896 }
8897 // `dataset_refs` preserves registry order, so `enumerate`
8898 // yields each dataset's true index.
8899 for (i, ds) in self.dataset_refs().into_iter().enumerate() {
8900 let m = ds.lock();
8901 if m.deleted || datasets_in_subgroups.contains(&i) {
8902 continue;
8903 }
8904 // The leaf, never the registry path: a link name is one
8905 // path component, and `H5G_traverse` would split a '/'
8906 // in it before `H5L_link` ever saw the name.
8907 let leaf_name = m.name.rsplit('/').next().unwrap_or(&m.name);
8908 links.push((
8909 m.creation_seq,
8910 LinkMessage::hard(leaf_name, m.obj_header_addr),
8911 ));
8912 }
8913 for grp in self.group_refs() {
8914 let g = grp.lock();
8915 if g.deleted || g.parent.is_some() {
8916 continue;
8917 }
8918 let leaf_name = g.name.rsplit('/').next().unwrap_or(&g.name);
8919 links.push((
8920 g.creation_seq,
8921 LinkMessage::hard(leaf_name, g.obj_header_addr),
8922 ));
8923 }
8924 self.push_hard_links(&mut links, None);
8925 self.push_symbolic_links(&mut links, None);
8926 self.push_committed_datatypes(&mut links, None);
8927 }
8928 LinkScope::Group(group_idx) => {
8929 // Snapshot the child lists, then drop the slot guard: the
8930 // per-child reads below re-lock dataset and group slots
8931 // (including this one).
8932 let (child_datasets, child_groups) = {
8933 let grp = self.grp(group_idx);
8934 let g = grp.lock();
8935 (g.child_datasets.clone(), g.child_groups.clone())
8936 };
8937 for ds_idx in child_datasets {
8938 let ds = self.ds(ds_idx);
8939 let m = ds.lock();
8940 if m.deleted {
8941 continue;
8942 }
8943 let leaf_name = m.name.rsplit('/').next().unwrap_or(&m.name);
8944 links.push((
8945 m.creation_seq,
8946 LinkMessage::hard(leaf_name, m.obj_header_addr),
8947 ));
8948 }
8949 for child_idx in child_groups {
8950 let child_grp = self.grp(child_idx);
8951 let g = child_grp.lock();
8952 if g.deleted {
8953 continue;
8954 }
8955 let leaf_name = g.name.rsplit('/').next().unwrap_or(&g.name);
8956 links.push((
8957 g.creation_seq,
8958 LinkMessage::hard(leaf_name, g.obj_header_addr),
8959 ));
8960 }
8961 self.push_hard_links(&mut links, Some(group_idx));
8962 self.push_symbolic_links(&mut links, Some(group_idx));
8963 self.push_committed_datatypes(&mut links, Some(group_idx));
8964 }
8965 }
8966 let parent = match scope {
8967 LinkScope::Root => None,
8968 LinkScope::Group(i) => Some(i),
8969 };
8970 let mut links: Vec<(u64, LinkMessage, Option<Vec<u8>>)> = links
8971 .into_iter()
8972 .map(|(seq, link)| (seq, link, None))
8973 .collect();
8974 for l in self.preserved_links.lock().iter() {
8975 if l.parent == parent {
8976 links.push((l.creation_seq, l.link.clone(), Some(l.encoded.clone())));
8977 }
8978 }
8979 // Creation order, not order by kind: a run of create_group and
8980 // create_dataset draws from one counter, so this is the order the
8981 // caller made them in. `H5G_obj_insert` numbers from zero within the
8982 // group, so the rank here is the link's creation order.
8983 links.sort_by_key(|(seq, ..)| *seq);
8984 links
8985 .into_iter()
8986 .enumerate()
8987 .map(|(rank, (_, mut link, preserved))| {
8988 if order.is_tracked() {
8989 link.creation_order = Some(rank as i64);
8990 }
8991 let is_preserved = preserved.is_some();
8992 let encoded = match preserved {
8993 None => link.encode(&self.ctx),
8994 Some(mut bytes) => {
8995 // Only the creation order field changes. Bytes that
8996 // have none in a group that tracks it are not a
8997 // libhdf5 group's; re-encoding is what gives them one.
8998 if let Some(corder) = link.creation_order {
8999 if !LinkMessage::restamp_encoded_creation_order(&mut bytes, corder) {
9000 bytes = link.encode(&self.ctx);
9001 }
9002 }
9003 bytes
9004 }
9005 };
9006 GroupLink {
9007 link,
9008 encoded,
9009 preserved: is_preserved,
9010 }
9011 })
9012 .collect()
9013 }
9014
9015 /// Whether `links` must live in dense storage rather than in the group's
9016 /// object header — the `H5G_obj_insert` phase-change rule, applied to the
9017 /// whole set at once because this writer builds each header from scratch
9018 /// rather than inserting one link at a time.
9019 ///
9020 /// libhdf5 converts when the count *reaches* `max_compact` and another
9021 /// link arrives, so a set of exactly `max_compact` is still compact; and
9022 /// separately when one message would not fit the 16-bit size field an
9023 /// object header message has.
9024 ///
9025 /// The answer depends only on the link names and kinds, never on the
9026 /// addresses they point at, which is what lets a group header be sized
9027 /// before [`prepare_dense_links`](Self::prepare_dense_links) has run.
9028 ///
9029 /// A link a reopen carried through by its bytes pins the group to compact
9030 /// storage: dense storage would have to re-encode each link into the
9031 /// heap, which is exactly the byte fidelity preserving them is for.
9032 fn links_need_dense(&self, links: &[GroupLink]) -> bool {
9033 !links.iter().any(|l| l.preserved)
9034 && (links.len() > MAX_COMPACT_LINKS
9035 || links.iter().any(|l| l.encoded.len() > MAX_MESSAGE_SIZE))
9036 }
9037
9038 /// The single owner of link emission into a group object header: the Link
9039 /// Info and Group Info messages, and then either one `MSG_LINK` per link
9040 /// or nothing at all when the set has spilled to dense storage.
9041 ///
9042 /// The two storage forms are exclusive (`H5G_obj_insert` moves the whole
9043 /// set at once), and a header carrying both would report every link twice.
9044 ///
9045 /// A group whose links are dense but not yet laid out gets a compact Link
9046 /// Info message here. That is deliberate: the message encodes to the same
9047 /// length either way — two addresses, defined or not — so the sizing pass
9048 /// that runs before `prepare_dense_links` still reserves the right number
9049 /// of bytes, and the write pass that runs after it emits the real heap and
9050 /// index addresses. It is the same two-pass rule the child link addresses
9051 /// already follow.
9052 fn emit_links(
9053 &self,
9054 header: &mut ObjectHeader,
9055 scope: LinkScope,
9056 links: &[GroupLink],
9057 order: CreationOrder,
9058 ) {
9059 // A symbol-table group holds no link messages at all: its links are the
9060 // entries of the symbol table `prepare_symbol_tables` laid out, and
9061 // the header carries only the two addresses naming it. Link Info and
9062 // Group Info are version-1.8 messages and have no business in a
9063 // version-1 header — `H5G__stab_valid` reads the Symbol Table message
9064 // and nothing else.
9065 if self.uses_symbol_table(scope, order) {
9066 // Sizing runs before the tables are laid out; the message is the
9067 // same two addresses wide either way, so the placeholder reserves
9068 // exactly what the real one needs. Same two-pass rule the child
9069 // link addresses already follow.
9070 let stab = self
9071 .symbol_tables
9072 .written
9073 .lock()
9074 .get(&scope)
9075 .copied()
9076 .unwrap_or(Stab {
9077 btree_addr: UNDEF_ADDR,
9078 heap_addr: UNDEF_ADDR,
9079 });
9080 header.add_message(MSG_SYMBOL_TABLE, 0x00, stab.encode(&self.ctx));
9081 return;
9082 }
9083 let dense = self.links_need_dense(links);
9084 let link_info = self.dense_links.lock().get(&scope).cloned();
9085 let link_info = link_info.unwrap_or_else(|| {
9086 let mut info = LinkInfoMessage::compact();
9087 if order.is_tracked() {
9088 // `H5G__obj_insert` post-increments `max_corder`, so a group
9089 // holding n links reports n.
9090 info.max_creation_order = Some(links.len() as u64);
9091 }
9092 if order.is_indexed() {
9093 // The index address stays undefined while the links live in
9094 // the header, but the message must still carry the field:
9095 // `H5Pget_link_creation_order` reads INDEXED off this flag,
9096 // not off the address.
9097 info.creation_order_btree_address = Some(UNDEF_ADDR);
9098 }
9099 info
9100 });
9101 header.add_message(MSG_LINK_INFO, 0x00, link_info.encode(&self.ctx));
9102 // The link info message takes no flags and the group info message
9103 // takes `H5O_MSG_FLAG_CONSTANT`, exactly as `H5G__obj_create_real`
9104 // creates the pair (H5Gobj.c:255, :259) and as
9105 // `H5G__obj_insert`'s phase change re-creates it (H5Gobj.c:526). The
9106 // asymmetry is real: the link info message records the group's
9107 // storage and its creation-order counter, both of which change as
9108 // links come and go, while the group info message holds the phase
9109 // change and estimated-name-length constants of the creation property
9110 // list, which nothing after creation rewrites.
9111 header.add_message(
9112 MSG_GROUP_INFO,
9113 MSG_FLAG_CONSTANT,
9114 GroupInfoMessage::default().encode(),
9115 );
9116 if dense {
9117 return;
9118 }
9119 for link in links {
9120 header.add_message(MSG_LINK, 0x00, link.encoded.clone());
9121 }
9122 }
9123
9124 /// Lay out and write dense link storage for every group that needs it,
9125 /// recording the resulting `Link Info` message per group.
9126 ///
9127 /// The sole owner of that transition. It must run after every object
9128 /// header address is assigned — the heap holds encoded link messages, and
9129 /// those name their targets — and before any group header is written.
9130 ///
9131 /// Every group whose header this finalize rewrites passes through here,
9132 /// dense or not: the storage a reopened header named is superseded by the
9133 /// rewrite whichever form the new link set takes, and freeing it first is
9134 /// what lets the replacement reuse those blocks.
9135 fn prepare_dense_links(&self) -> IoResult<()> {
9136 let mut scopes: Vec<(LinkScope, Vec<GroupLink>, CreationOrder)> = Vec::new();
9137 for gi in 0..self.group_count() {
9138 let (deleted, order) = {
9139 let grp = self.grp(gi);
9140 let g = grp.lock();
9141 (g.deleted, g.track_order.links)
9142 };
9143 // A symbol-table group is `prepare_symbol_tables`' business; it
9144 // has no Link Info message to hold a fractal heap address, and it
9145 // never had dense storage to release.
9146 if deleted || self.uses_symbol_table(LinkScope::Group(gi), order) {
9147 continue;
9148 }
9149 self.release_superseded_dense_links(LinkScope::Group(gi))?;
9150 let links = self.group_links(LinkScope::Group(gi), order);
9151 if self.links_need_dense(&links) {
9152 scopes.push((LinkScope::Group(gi), links, order));
9153 }
9154 }
9155 let root_order = self.root_track_order.links;
9156 if !self.uses_symbol_table(LinkScope::Root, root_order) {
9157 self.release_superseded_dense_links(LinkScope::Root)?;
9158 let root_links = self.group_links(LinkScope::Root, root_order);
9159 if self.links_need_dense(&root_links) {
9160 scopes.push((LinkScope::Root, root_links, root_order));
9161 }
9162 }
9163
9164 for (scope, links, order) in scopes {
9165 // `close` after `start_swmr` finalizes a second time over the same
9166 // groups, so rebuilding here would allocate a whole second heap
9167 // and strand the one the published headers already name.
9168 if self.dense_links.lock().contains_key(&scope) {
9169 continue;
9170 }
9171 let messages: Vec<LinkMessage> = links.into_iter().map(|l| l.link).collect();
9172 let dense = build_dense_links(&messages, &self.ctx, order, &mut |len| {
9173 self.allocator.allocate(len, FreeSpaceClass::Metadata)
9174 })?;
9175 for block in &dense.blocks {
9176 self.handle.write_at(block.addr, &block.image)?;
9177 }
9178 self.dense_links.lock().insert(scope, dense.linfo);
9179 }
9180 Ok(())
9181 }
9182
9183 /// Lay out whichever of the two forms of link storage this file uses,
9184 /// before any group header is written.
9185 ///
9186 /// The two are exclusive because the formats are: a classic group has no
9187 /// Link Info message to put a fractal heap address in, and a link-message
9188 /// group has no symbol table.
9189 fn prepare_link_storage(&self) -> IoResult<()> {
9190 self.prepare_dense_links()?;
9191 self.prepare_symbol_tables()
9192 }
9193
9194 /// Lay out and write the symbol table of every classic group, and free the
9195 /// storage each rewrite supersedes. A no-op on a link-message file.
9196 ///
9197 /// The classic counterpart of [`prepare_dense_links`](Self::prepare_dense_links),
9198 /// and the sole owner of that transition. The same two placement rules
9199 /// apply for the same two reasons: it runs after every object header has
9200 /// an address, because a symbol table entry names its target's header, and
9201 /// before any group header is written, because the header carries the
9202 /// Symbol Table message naming what this laid out.
9203 ///
9204 /// Deepest group first, root last. A hard link to a group caches that
9205 /// group's own B-tree and heap in the entry's scratch pad
9206 /// (`H5G__link_to_ent`), so the child's table must exist before the
9207 /// parent's is built; `H5G__stab_valid` checks the root entry's cache
9208 /// against the root header's Symbol Table message, so a stale pair there
9209 /// is not a slow lookup but a file `H5Fopen` rejects.
9210 ///
9211 /// Every classic group is rebuilt on every pass — there is no "already
9212 /// done" short-circuit like the dense one, because the only way this runs
9213 /// twice is a `Drop` retry after a failed `close`, and the entries of the
9214 /// first pass name header addresses the second pass has moved. (A SWMR
9215 /// session, the other double-finalize, cannot reach here: SWMR needs a
9216 /// version-3 superblock, so `start_swmr` refuses a classic file.)
9217 fn prepare_symbol_tables(&self) -> IoResult<()> {
9218 // Depth by parent chain, not by counting separators in the registry
9219 // path: the chain is what actually says which table has to exist first.
9220 let mut scopes: Vec<(usize, LinkScope, CreationOrder)> = Vec::new();
9221 for gi in 0..self.group_count() {
9222 let (deleted, order, mut parent) = {
9223 let grp = self.grp(gi);
9224 let g = grp.lock();
9225 (g.deleted, g.track_order.links, g.parent)
9226 };
9227 if deleted || !self.uses_symbol_table(LinkScope::Group(gi), order) {
9228 continue;
9229 }
9230 let mut depth = 1usize;
9231 while let Some(p) = parent {
9232 depth += 1;
9233 parent = self.grp(p).lock().parent;
9234 }
9235 scopes.push((depth, LinkScope::Group(gi), order));
9236 }
9237 scopes.sort_by_key(|&(depth, ..)| std::cmp::Reverse(depth));
9238 let root_order = self.root_track_order.links;
9239 if self.uses_symbol_table(LinkScope::Root, root_order) {
9240 scopes.push((0, LinkScope::Root, root_order));
9241 }
9242
9243 let meta = self.stab_meta();
9244 for (_, scope, order) in scopes {
9245 // Freed before the replacement is laid out, so a rewrite reuses
9246 // the same blocks instead of growing the file on every open/close
9247 // cycle — the rule `prepare_dense_links` and the header rewrite
9248 // already follow. Removed as it is freed, so no second pass can
9249 // free it twice.
9250 let superseded = self.symbol_tables.superseded.lock().remove(&scope);
9251 if let Some(extents) = superseded {
9252 free_stab(&self.allocator, &extents);
9253 }
9254 let links = self.stab_links_for(scope, order)?;
9255 let stab = write_stab(&self.handle, &self.allocator, &meta, &links)?;
9256 self.symbol_tables.written.lock().insert(scope, stab);
9257 }
9258 Ok(())
9259 }
9260
9261 /// The file-level parameters every symbol-table node width is derived from
9262 /// — the address/length widths and the B-tree "K" ranks. Only a version-0/1
9263 /// superblock records ranks of its own; [`btree_v1_config`] is the one
9264 /// place that decides whether this file has any.
9265 ///
9266 /// [`btree_v1_config`]: Self::btree_v1_config
9267 fn stab_meta(&self) -> FileMeta {
9268 FileMeta {
9269 ctx: self.ctx,
9270 btree: self.btree_v1_config(),
9271 sohm: None,
9272 }
9273 }
9274
9275 /// `scope`'s links as symbol table entries.
9276 ///
9277 /// A link a reopen carried through verbatim goes in by its decoded form,
9278 /// because a classic group has no link message to preserve it into.
9279 /// Nothing is lost in the round trip: the walk built that message from a
9280 /// symbol table entry in the first place, and the two forms carry the same
9281 /// three facts.
9282 fn stab_links_for(&self, scope: LinkScope, order: CreationOrder) -> IoResult<Vec<StabLink>> {
9283 let groups = self.group_header_scopes();
9284 let mut out = Vec::new();
9285 for link in self.group_links(scope, order) {
9286 out.push(self.stab_link(&link.link, &groups)?);
9287 }
9288 Ok(out)
9289 }
9290
9291 /// Where each group's object header now sits, so a hard link that lands on
9292 /// one can cache that group's symbol table in its scratch pad.
9293 fn group_header_scopes(&self) -> HashMap<u64, LinkScope> {
9294 let mut map = HashMap::new();
9295 for gi in 0..self.group_count() {
9296 let grp = self.grp(gi);
9297 let g = grp.lock();
9298 if !g.deleted {
9299 map.insert(g.obj_header_addr, LinkScope::Group(gi));
9300 }
9301 }
9302 map
9303 }
9304
9305 /// One link as a symbol table entry.
9306 ///
9307 /// The scratch pad caches the target group's B-tree and heap when the
9308 /// target is a group this pass has already laid out — what
9309 /// `H5G__link_to_ent` does, and what lets `H5G__stab_lookup` walk a path
9310 /// without opening each header on the way. For anything else the pad stays
9311 /// `H5G_NOTHING_CACHED`, the value libhdf5 itself writes whenever the
9312 /// target has no Symbol Table message to read.
9313 fn stab_link(
9314 &self,
9315 link: &LinkMessage,
9316 groups: &HashMap<u64, LinkScope>,
9317 ) -> IoResult<StabLink> {
9318 let target = match &link.target {
9319 LinkTarget::Hard { address } => {
9320 let cached = groups
9321 .get(address)
9322 .and_then(|scope| self.symbol_tables.written.lock().get(scope).copied());
9323 StabTarget::Hard {
9324 addr: *address,
9325 cached,
9326 }
9327 }
9328 LinkTarget::Soft { target } => StabTarget::Soft {
9329 value: target.clone(),
9330 },
9331 // Unreachable by construction: a group holding one of these is
9332 // not a symbol-table group at all
9333 // ([`LinkMessage::fits_symbol_table`] is what
9334 // [`Hdf5Writer::uses_symbol_table`] asks), so this pass never
9335 // visits it. Reported rather than panicked so a future caller
9336 // that skips that gate learns which link it lost.
9337 LinkTarget::External { .. } | LinkTarget::UserDefined { .. } => {
9338 return Err(crate::io::IoError::InvalidState(format!(
9339 "cannot store the link {:?} in a symbol table: it holds only \
9340 hard and soft links, and this group was not converted to link \
9341 messages the way `H5G_obj_insert` converts it",
9342 link.name
9343 )))
9344 }
9345 };
9346 Ok(StabLink {
9347 name: link.name.clone(),
9348 target,
9349 })
9350 }
9351
9352 /// The single owner of attribute emission into an object header: appends
9353 /// the Attribute Info message and then one `MSG_ATTRIBUTE` per attribute.
9354 ///
9355 /// On a version-2 object header the two are inseparable.
9356 /// `H5O__attr_count_real` derives `H5Oget_info().num_attrs` from the
9357 /// Attribute Info message alone — with no such message the count reads as
9358 /// zero however many attribute messages follow, which is what made every
9359 /// rust-written file report `num_attrs == 0` to libhdf5 while
9360 /// `H5Aiterate2` still yielded the attributes. The message carries no
9361 /// count of its own: `H5A__get_ainfo` fills `nattrs` from the attribute
9362 /// messages the header loader actually saw, so compact storage needs
9363 /// nothing but the message's presence.
9364 ///
9365 /// When [`prepare_dense_attributes`](Self::prepare_dense_attributes) has
9366 /// spilled `scope`'s attributes to a fractal heap, the same message names
9367 /// that heap instead and *no* attribute message follows: the two storage
9368 /// forms are exclusive (`H5O__attr_create` moves the whole set at once),
9369 /// and a header carrying both would report every attribute twice.
9370 fn emit_attributes(
9371 &self,
9372 header: &mut ObjectHeader,
9373 scope: AttrScope,
9374 attributes: &[AttributeEntry],
9375 order: CreationOrder,
9376 format: ObjectFormat,
9377 owner: ShareOwner,
9378 ) {
9379 // `H5Pget_attr_creation_order` reads the object header's own flags,
9380 // not the Attribute Info message, so this is what makes the object
9381 // report creation-ordered attributes — and tracking widens every
9382 // message envelope by the creation index below.
9383 let order = self.header_attr_order(order);
9384 header.set_attribute_creation_order(order);
9385 if attributes.is_empty() {
9386 return;
9387 }
9388 // A version-1 object header gets the attribute messages alone.
9389 // `H5O__attr_create` gates every mention of the Attribute Info message
9390 // on `oh->version > H5O_VERSION_1` (H5Oattribute.c:218), and so does
9391 // `H5O__attr_count_real`, which is why the count still reads correctly
9392 // without it: on a version-1 header libhdf5 counts the messages.
9393 if format == ObjectFormat::Legacy {
9394 for attr in attributes {
9395 header.add_message(MSG_ATTRIBUTE, 0x00, self.encode_attribute(attr));
9396 }
9397 return;
9398 }
9399 // Whether the set spills is a property of the set alone, so it is the
9400 // same answer in the pass that measures this header and in the pass
9401 // that writes it — even though the storage itself is laid out between
9402 // the two, because it can only be laid out once every object header
9403 // has an address. Sizing therefore falls back to a placeholder message
9404 // of the same width: only the creation-order flags change the
9405 // Attribute Info message's length, so the header measured here holds
9406 // the header written against the storage that replaces it. Same
9407 // two-pass rule `emit_links` follows for dense links and symbol
9408 // tables.
9409 let dense = self.attributes_need_dense(attributes, format);
9410 let stored = self.dense_attributes.lock().get(&scope).cloned();
9411 let ainfo = stored.unwrap_or_else(|| {
9412 let mut ainfo = AttributeInfoMessage::compact();
9413 if order.is_tracked() {
9414 ainfo.max_creation_index = Some(next_creation_index(attributes));
9415 }
9416 if order.is_indexed() {
9417 // Compact storage has no index B-tree, but the message still
9418 // announces one so that its flags match the header's
9419 // (`H5O__attr_create` asserts they agree).
9420 ainfo.creation_order_btree_address = Some(UNDEF_ADDR);
9421 }
9422 ainfo
9423 });
9424 header.add_message(MSG_ATTR_INFO, MSG_FLAG_DONTSHARE, ainfo.encode(&self.ctx));
9425 if dense {
9426 return;
9427 }
9428 // Each attribute states its own creation index — the one it was
9429 // created with here, or the one the file it was read from records. An
9430 // attribute with none belongs to an object that tracks no order, where
9431 // the field is not encoded at all.
9432 for attr in attributes {
9433 let (flags, body) = self.share_attribute(attr, format, owner);
9434 header.add_message_indexed(
9435 MSG_ATTRIBUTE,
9436 flags,
9437 body,
9438 attr.creation_index().unwrap_or(0),
9439 );
9440 }
9441 }
9442
9443 /// One attribute message body, at the version this file's low library
9444 /// bound calls for (`H5A__set_version`, which reads the bound and nothing
9445 /// about the object the attribute hangs on).
9446 fn encode_attribute(&self, attr: &AttributeEntry) -> Vec<u8> {
9447 attr.encode_for(&self.ctx, self.encoding_libver(), self.message_format())
9448 }
9449
9450 /// What a header stores for one attribute: the message flags and the body,
9451 /// with the attribute's own datatype and dataspace shared wherever an
9452 /// index covers them.
9453 ///
9454 /// `H5A__create` offers both to `H5SM_try_share` (H5Aint.c:375-377) before
9455 /// `H5O__attr_create` offers the attribute itself (H5Oattribute.c:726), so
9456 /// the attribute body that reaches the heap already holds their pointers
9457 /// and says which fields they are in its own flags byte
9458 /// (`H5O_ATTR_FLAG_TYPE_SHARED` / `H5O_ATTR_FLAG_SPACE_SHARED`,
9459 /// H5Oattr.c:358-359). Both offers go through
9460 /// [`share_message`](Self::share_message) like any other, so the pass that
9461 /// counts references and the pass that substitutes see the same three
9462 /// messages.
9463 fn share_attribute(
9464 &self,
9465 attr: &AttributeEntry,
9466 format: ObjectFormat,
9467 owner: ShareOwner,
9468 ) -> (u8, Vec<u8>) {
9469 let libver = self.encoding_libver();
9470 // Only a readable attribute has pieces to offer: an unreadable one is
9471 // the bytes it was read from, put back as they were. Version 1 has no
9472 // flags byte to record a shared field in — `H5O__attr_encode` writes a
9473 // reserved zero there — so a classic file shares the attribute whole
9474 // or not at all.
9475 let Some(message) = attr.readable().filter(|_| format.attribute_version() >= 2) else {
9476 return self.share_message(
9477 owner,
9478 MSG_ATTRIBUTE,
9479 0x00,
9480 attr.encode_for(&self.ctx, libver, format),
9481 );
9482 };
9483
9484 let datatype = message.datatype.encode_at(&self.ctx, libver);
9485 let dataspace = message.dataspace.encode_for(&self.ctx, format);
9486 // `H5A__create` passes no open header for either (H5Aint.c:375-377):
9487 // both live inside the attribute's body, so neither has a header
9488 // message a `H5SM_IN_OH` record could name and both reach the heap on
9489 // first use.
9490 let (dt_flags, dt_field) =
9491 self.share_message(ShareOwner::Detached, MSG_DATATYPE, 0x00, datatype.clone());
9492 let (ds_flags, ds_field) =
9493 self.share_message(ShareOwner::Detached, MSG_DATASPACE, 0x00, dataspace.clone());
9494
9495 let mut attr_flags = 0u8;
9496 if dt_flags & MSG_FLAG_SHARED != 0 {
9497 attr_flags |= ATTR_FLAG_TYPE_SHARED;
9498 }
9499 if ds_flags & MSG_FLAG_SHARED != 0 {
9500 attr_flags |= ATTR_FLAG_SPACE_SHARED;
9501 }
9502 let encoded = message.encode_with_fields(attr_flags, &dt_field, &ds_field);
9503
9504 // Each shared field's heap ID sits two bytes into the pointer that
9505 // replaced it; the body offered below carries whatever
9506 // `share_message` just produced, which is a zeroed ID in the pass that
9507 // counts and the real one in the pass that substitutes.
9508 let mut nested = Vec::new();
9509 if attr_flags & ATTR_FLAG_TYPE_SHARED != 0 {
9510 nested.push(NestedShare {
9511 heap_id_at: encoded.datatype_at + SOHM_POINTER_HEAP_ID_AT,
9512 target: (MSG_DATATYPE, datatype),
9513 });
9514 }
9515 if attr_flags & ATTR_FLAG_SPACE_SHARED != 0 {
9516 nested.push(NestedShare {
9517 heap_id_at: encoded.dataspace_at + SOHM_POINTER_HEAP_ID_AT,
9518 target: (MSG_DATASPACE, dataspace),
9519 });
9520 }
9521 self.share_nesting_message(owner, MSG_ATTRIBUTE, 0x00, encoded.body, nested)
9522 }
9523
9524 /// Whether `attributes` must live in dense storage rather than in the
9525 /// object header — the `H5O__attr_create` phase-change rule, applied to
9526 /// the whole set at once because this writer builds each header from
9527 /// scratch rather than inserting one attribute at a time.
9528 ///
9529 /// libhdf5 converts when the count *reaches* `max_compact` and another
9530 /// attribute arrives, so a set of exactly `max_compact` is still compact;
9531 /// and separately when one message would not fit the 16-bit size field an
9532 /// object header message has.
9533 ///
9534 /// Never in a classic file. Dense attribute storage is a fractal heap
9535 /// reached through an Attribute Info message, both introduced in the 1.8
9536 /// format; at `H5F_LIBVER_EARLIEST` libhdf5 keeps every attribute in the
9537 /// header however many there are (`H5O__attr_create` reaches the phase
9538 /// change only when the object header version allows it). An attribute
9539 /// too large for the 16-bit size field is then an error, which
9540 /// `ObjectHeader::encode_v1` raises, rather than a reason to spill.
9541 fn attributes_need_dense(&self, attributes: &[AttributeEntry], format: ObjectFormat) -> bool {
9542 if format == ObjectFormat::Legacy {
9543 return false;
9544 }
9545 attributes.len() > MAX_COMPACT_ATTRS
9546 || attributes
9547 .iter()
9548 .any(|a| self.encode_attribute(a).len() > MAX_MESSAGE_SIZE)
9549 }
9550
9551 /// Every object whose attributes this finalize re-lays-out, with the
9552 /// creation-order policy each one's storage must follow.
9553 ///
9554 /// `datasets` lists the datasets whose headers this finalize will
9555 /// actually write. A reopened dataset that took no writes keeps its
9556 /// original header — and with it whatever storage that header already
9557 /// names — so touching its attribute storage would strand every block of
9558 /// it.
9559 ///
9560 /// The policy is the one the *header* records, not the one the object's
9561 /// creation property list asked for: those differ on a file whose
9562 /// shared-message configuration covers attributes, where
9563 /// [`header_attr_order`](Self::header_attr_order) raises every object to
9564 /// tracked. Storage laid out against the property list would then omit the
9565 /// creation indices the header says are there — and, since the Attribute
9566 /// Info message carries a maximum creation index only when tracked, would
9567 /// be two bytes shorter than the message the sizing pass measured.
9568 fn attribute_scopes(&self, datasets: &[usize]) -> Vec<(AttrScope, CreationOrder)> {
9569 let order_of = |requested| self.header_attr_order(requested);
9570 let mut scopes = vec![(AttrScope::Root, order_of(self.root_track_order.attrs))];
9571 for gi in 0..self.group_count() {
9572 if self.grp(gi).lock().deleted {
9573 continue;
9574 }
9575 let order = self.grp(gi).lock().track_order.attrs;
9576 scopes.push((AttrScope::Group(gi), order_of(order)));
9577 }
9578 for &i in datasets {
9579 let order = self.ds(i).lock().track_attr_order;
9580 scopes.push((AttrScope::Dataset(i), order_of(order)));
9581 }
9582 scopes
9583 }
9584
9585 /// Lay out and write dense attribute storage for every object that needs
9586 /// it, recording the resulting `Attribute Info` message per object.
9587 ///
9588 /// The sole owner of that transition. It runs after every object header
9589 /// has an address — an attribute may hold an object reference, and the
9590 /// heap holds the encoded attribute messages — and before any object
9591 /// header is written, because the header carries the Attribute Info
9592 /// message naming what this laid out. Every block is on disk before the
9593 /// map naming it is populated, so a header written from that map can only
9594 /// point at bytes that exist. The same placement rule, for the same two
9595 /// reasons, as [`prepare_dense_links`](Self::prepare_dense_links).
9596 ///
9597 /// Which objects spill is not decided here: `emit_attributes` asks
9598 /// [`attributes_need_dense`](Self::attributes_need_dense) itself, so the
9599 /// header measured before this ran and the header written after it agree
9600 /// without either consulting the other.
9601 fn prepare_dense_attributes(&self, datasets: &[usize]) -> IoResult<()> {
9602 for (scope, order) in self.attribute_scopes(datasets) {
9603 // Every scope here has its header rewritten, so the storage a
9604 // reopen found on it is superseded whether or not the new set is
9605 // dense again — a free driven by "the new set needs a heap" would
9606 // never reach an object that dropped back to compact. Freed
9607 // immediately before its replacement is laid out, so the rewrite
9608 // lands in the blocks it just gave back instead of growing the
9609 // file on every open/close cycle.
9610 self.release_superseded_dense_attrs(scope)?;
9611 // `close` after `start_swmr` finalizes a second time over the same
9612 // attribute sets — SWMR refuses every attribute mutation — so
9613 // rebuilding here would allocate a whole second heap and strand
9614 // the one the published headers already name.
9615 if self.dense_attributes.lock().contains_key(&scope) {
9616 continue;
9617 }
9618 let attributes = self.object_attributes(scope)?;
9619 if !self.attributes_need_dense(&attributes, self.attr_scope_format(scope)) {
9620 continue;
9621 }
9622 let dense = build_dense_attributes(&attributes, &self.ctx, order, &mut |len| {
9623 self.allocator.allocate(len, FreeSpaceClass::Metadata)
9624 })?;
9625 for block in &dense.blocks {
9626 self.handle.write_at(block.addr, &block.image)?;
9627 }
9628 self.dense_attributes.lock().insert(scope, dense.ainfo);
9629 }
9630 Ok(())
9631 }
9632
9633 /// The object header format `scope`'s owner is written at, which is what
9634 /// decides whether its attributes may spill at all.
9635 fn attr_scope_format(&self, scope: AttrScope) -> ObjectFormat {
9636 match scope {
9637 AttrScope::Root => self.header_format(self.root_track_order),
9638 AttrScope::Group(gi) => self.group_header_format(gi),
9639 AttrScope::Dataset(i) => self.dataset_header_format(i),
9640 }
9641 }
9642
9643 /// Whether a dataset's datatype message may be offered to a
9644 /// shared-message index at all.
9645 ///
9646 /// The datatype is the one message class carrying a `can_share` callback
9647 /// (`H5O__dtype_can_share`, H5Odtype.c:99), and `H5SM__can_share_common`
9648 /// asks it before any index is consulted (H5SM.c:895-899). It refuses an
9649 /// immutable type and a committed one (H5Odtype.c:1893-1901); the
9650 /// committed half is already answered by address at the call site.
9651 ///
9652 /// A dataset's type reaches that predicate still immutable only when
9653 /// `H5D__init_type` kept the caller's own `H5T_t` rather than copying it,
9654 /// which it does exactly when the type is immutable, is not relocatable,
9655 /// and the low bound this dataset's messages are written at is below
9656 /// `H5F_LIBVER_V18` (H5Dint.c:569-572) — the bound the dataset was
9657 /// *created* under, which for a dataset a reopen found is not this
9658 /// session's.
9659 /// Any of the three failing produces an `H5T_COPY_ALL` copy, which is
9660 /// `H5T_STATE_RDONLY` rather than immutable (H5T.c:4461-4462) and so is
9661 /// shareable — which is why `H5Tcopy(H5T_STD_I32LE)` shares where
9662 /// `H5T_STD_I32LE` itself does not (tests/fixtures/gen_sohm.c).
9663 ///
9664 /// An attribute has no such branch: `H5A__create` copies unconditionally
9665 /// (H5Aint.c:341), so its datatype is always eligible and
9666 /// [`share_attribute`](Self::share_attribute) offers it without asking.
9667 fn dataset_datatype_shareable(&self, datatype: &DatatypeMessage, libver: LibverBound) -> bool {
9668 !datatype.is_predefined() || datatype.is_relocatable() || libver >= LibverBound::V18
9669 }
9670
9671 /// Whether the first copy of a `msg_type` message may stay literal in the
9672 /// object header that writes it.
9673 ///
9674 /// `H5O_msg_can_share_in_ohdr` reads the class's `H5O_SHARE_IN_OHDR` flag
9675 /// (H5Omessage.c:1426); the five classes that carry it are datatype
9676 /// (H5Odtype.c:89), dataspace (H5Osdspace.c:61), both fill value messages
9677 /// (H5Ofill.c:106 and :130) and the filter pipeline (H5Opline.c:65). The
9678 /// attribute class does not, which is why an attribute reaches the heap on
9679 /// its first use.
9680 const fn shares_in_ohdr(msg_type: u8) -> bool {
9681 matches!(
9682 msg_type,
9683 MSG_DATASPACE
9684 | MSG_DATATYPE
9685 | MSG_FILL_VALUE
9686 | MSG_FILL_VALUE_OLD
9687 | MSG_FILTER_PIPELINE
9688 )
9689 }
9690
9691 /// What a header stores for a message a shared-message index may cover:
9692 /// the body itself, or a pointer into the shared-message heap.
9693 ///
9694 /// The single point at which a message is offered to an index. Every
9695 /// header builder routes its shareable messages through here, so the pass
9696 /// that counts references and the pass that substitutes pointers walk
9697 /// exactly the same set — the counting and the substituting cannot drift
9698 /// apart, because they are one call site in two phases.
9699 ///
9700 /// `owner` is `H5SM_try_share`'s `open_oh`: the header this message
9701 /// belongs to, or [`ShareOwner::Detached`] for a body that is part of
9702 /// another message rather than a message of a header.
9703 ///
9704 /// Outside a finalize, and in any file created without indexes, this is
9705 /// the identity.
9706 fn share_message(
9707 &self,
9708 owner: ShareOwner,
9709 msg_type: u8,
9710 flags: u8,
9711 body: Vec<u8>,
9712 ) -> (u8, Vec<u8>) {
9713 self.share_nesting_message(owner, msg_type, flags, body, Vec::new())
9714 }
9715
9716 /// [`share_message`](Self::share_message) for a body that itself holds
9717 /// shared-message pointers.
9718 ///
9719 /// `nested` names each heap ID inside `body`, which is zero until the
9720 /// table is laid out. Two bodies that differ only in what they point at
9721 /// are the same bytes here and different bytes on disk, so the count and
9722 /// the substitute are keyed on the pair.
9723 fn share_nesting_message(
9724 &self,
9725 owner: ShareOwner,
9726 msg_type: u8,
9727 flags: u8,
9728 body: Vec<u8>,
9729 nested: Vec<NestedShare>,
9730 ) -> (u8, Vec<u8>) {
9731 let Some(sohm) = self.sohm.as_deref() else {
9732 return (flags, body);
9733 };
9734 // A message already carrying a pointer — a committed datatype — is
9735 // shared by address and must not be shared again, and the message
9736 // classes libhdf5 marks `H5O_MSG_FLAG_DONTSHARE` never reach an index.
9737 if flags & (MSG_FLAG_SHARED | MSG_FLAG_DONTSHARE) != 0 {
9738 return (flags, body);
9739 }
9740 let Some(index) = sohm.index_for(msg_type, body.len()) else {
9741 return (flags, body);
9742 };
9743 // `share_in_ohdr && open_oh` (H5SM.c:1400): the first copy of one of
9744 // these classes stays where it was written, marked shareable, and only
9745 // a second use moves the body to the heap.
9746 let ohdr = match owner {
9747 ShareOwner::Header(addr) if Self::shares_in_ohdr(msg_type) => Some(addr),
9748 _ => None,
9749 };
9750 // What a pointer to this body looks like: a zeroed heap ID until the
9751 // table exists, which is the width the real one has.
9752 let pointer = |id| {
9753 (
9754 flags | MSG_FLAG_SHARED,
9755 SharedMessagePointer::encode_sohm(id),
9756 )
9757 };
9758 match &mut *sohm.phase.lock() {
9759 SohmPhase::Idle => (flags, body),
9760 SohmPhase::Predict(first) => {
9761 if ohdr.is_some() && first.insert((msg_type, body.clone())) {
9762 return (flags | MSG_FLAG_SHAREABLE, body);
9763 }
9764 pointer([0u8; SOHM_HEAP_ID_LEN])
9765 }
9766 // The same substitution `Predict` makes, so that what the collect
9767 // pass builds around a shared message is the width the resolve
9768 // pass will build — which is what lets an attribute body assembled
9769 // in this pass be the body assembled in that one, bar the heap IDs
9770 // it is here recording a need for.
9771 SohmPhase::Collect(collector) => {
9772 let first = collector.record(index, msg_type, &body, &nested, ohdr);
9773 if !first && !nested.is_empty() {
9774 // This body is already here, so the pointers it holds
9775 // already exist in the heap and the offers that built
9776 // this copy of it must not count a second time.
9777 for share in &nested {
9778 collector.release(share.target.0, &share.target.1);
9779 }
9780 }
9781 if ohdr.is_some() && first {
9782 return (flags | MSG_FLAG_SHAREABLE, body);
9783 }
9784 pointer([0u8; SOHM_HEAP_ID_LEN])
9785 }
9786 SohmPhase::Resolve { ids, first } => {
9787 if ohdr.is_some() && first.insert((msg_type, body.clone())) {
9788 return (flags | MSG_FLAG_SHAREABLE, body);
9789 }
9790 let key = (msg_type, body);
9791 match ids.get(&key) {
9792 Some(&id) => pointer(id),
9793 // The collect pass never saw this body — a dataspace a
9794 // SWMR extend changed after the table was laid out, say.
9795 // Left literal, which leaves a heap object counted for one
9796 // reference more than reaches it and nothing else.
9797 None => (flags, key.1),
9798 }
9799 }
9800 }
9801 }
9802
9803 /// Answer every shareable message at a heap pointer's width for the rest
9804 /// of this finalize's allocation phase.
9805 ///
9806 /// Half of the bracket [`prepare_shared_messages`](Self::prepare_shared_messages)
9807 /// closes, and the reason the two can sit on opposite sides of the
9808 /// allocation: a header cannot be measured until it is known which of its
9809 /// messages are pointers, and a body cannot be counted until every address
9810 /// it names exists. Only the width is knowable in the first phase, and the
9811 /// width is all the measurement needs.
9812 ///
9813 /// A finalize that will not lay a table out — a `finalize_for_swmr`, a
9814 /// second finalize over a table already published — leaves the phase where
9815 /// it found it, so what that pass measures is what it writes.
9816 fn begin_shared_message_layout(&self) {
9817 let Some(sohm) = self.sohm.as_deref() else {
9818 return;
9819 };
9820 let mut phase = sohm.phase.lock();
9821 if matches!(*phase, SohmPhase::Idle) && sohm.table_addr.lock().is_none() {
9822 *phase = SohmPhase::Predict(FirstCopies::default());
9823 }
9824 }
9825
9826 /// Lay out the file's shared-message table: count the bodies every header
9827 /// this finalize writes would share, put them in their index's heap, and
9828 /// arm the substitution the header builders then apply.
9829 ///
9830 /// The sole owner of the transition to `Resolve`. It runs last in the
9831 /// content phase, after
9832 /// [`prepare_dense_attributes`](Self::prepare_dense_attributes),
9833 /// [`prepare_link_storage`](Self::prepare_link_storage) and
9834 /// [`write_reference_values`](Self::write_reference_values), because a
9835 /// body is only counted once it is the body the file will hold: an
9836 /// attribute that spilled into dense storage is not in a header to be
9837 /// shared at all, and one holding an object reference says an object
9838 /// header address that exists only after the allocation phase. Counting
9839 /// either of them earlier would count a body no header ends up carrying,
9840 /// and leave the header that carries the real one literal — which
9841 /// [`check_header_size`] would then refuse, the block having been
9842 /// reserved at a pointer's width.
9843 ///
9844 /// Once per file: a second finalize (a SWMR session's close) keeps the
9845 /// table the first one published rather than allocating a second one and
9846 /// stranding the first.
9847 fn prepare_shared_messages(&self, datasets: &[usize]) -> IoResult<()> {
9848 let Some(sohm) = self.sohm.as_deref() else {
9849 return Ok(());
9850 };
9851 if sohm.table_addr.lock().is_some() {
9852 return Ok(());
9853 }
9854
9855 // Collect: build every header this finalize will write and throw it
9856 // away, keeping only what its shareable messages were.
9857 *sohm.phase.lock() = SohmPhase::Collect(SohmCollector::new(sohm.indexes.len()));
9858 for &i in datasets {
9859 self.build_dataset_header(i)?;
9860 }
9861 for gi in 0..self.group_count() {
9862 if self.grp(gi).lock().deleted {
9863 continue;
9864 }
9865 self.build_group_header(gi)?;
9866 }
9867 self.build_root_group_header()?;
9868 let SohmPhase::Collect(collector) =
9869 std::mem::replace(&mut *sohm.phase.lock(), SohmPhase::Idle)
9870 else {
9871 return Err(crate::io::IoError::InvalidState(
9872 "the shared-message collect pass did not finish in the collect phase".into(),
9873 ));
9874 };
9875
9876 let indexes: Vec<SohmIndexContent> = sohm
9877 .indexes
9878 .iter()
9879 .zip(collector.messages)
9880 .map(|(&spec, messages)| SohmIndexContent { spec, messages })
9881 .collect();
9882 // The table a reopen found is superseded whole by the one below, and
9883 // every header that pointed into it is in this finalize's rewrite set
9884 // — so its blocks go back immediately before the replacement is laid
9885 // out, and the new table lands in them instead of growing the file on
9886 // every open/close cycle. Taken, not read: a second finalize must not
9887 // free the same blocks twice.
9888 for (addr, len) in std::mem::take(&mut *sohm.superseded.lock()) {
9889 self.allocator.free(addr, len, FreeSpaceClass::Metadata);
9890 }
9891 let built = build_shared_messages(&indexes, &self.ctx, &mut |len| {
9892 self.allocator.allocate(len, FreeSpaceClass::Metadata)
9893 })?;
9894 for block in &built.blocks {
9895 self.handle.write_at(block.addr, &block.image)?;
9896 }
9897
9898 // Only now, with every block on disk: from here the header builders
9899 // substitute pointers, and `write_superblock_extension` names the
9900 // table this laid out.
9901 *sohm.phase.lock() = SohmPhase::Resolve {
9902 ids: built.heap_ids,
9903 first: FirstCopies::default(),
9904 };
9905 *sohm.table_addr.lock() = Some(built.table_addr);
9906 Ok(())
9907 }
9908
9909 /// Write the file's free-space managers over the space this close leaves
9910 /// free, and return the file-space info message body naming them.
9911 ///
9912 /// Called from [`write_superblock_extension`](Self::write_superblock_extension)
9913 /// once every other block of the file has an address, which is what makes
9914 /// the allocator's free list the file's *final* free space: a block
9915 /// allocated after this point would land in space a manager still claims.
9916 ///
9917 /// INVARIANT: from the moment this returns, every byte the allocator holds
9918 /// free is a byte some sections block records, and the two blocks each
9919 /// manager itself occupies are held by neither. Nothing may allocate
9920 /// between here and the superblock write; `write_object_headers` writes
9921 /// over blocks reserved in an earlier phase and is the only thing that
9922 /// runs in between.
9923 ///
9924 /// Returns `None` for a file with no message of its own to write — a
9925 /// reopen whose carried message this session must not touch, and a file
9926 /// created at the library defaults — which leaves both byte-identical to
9927 /// what the same close wrote before free space was recorded at all. A file
9928 /// that carries the message but keeps no managers (either non-manager
9929 /// strategy, or `persist: false`) gets the message back with every address
9930 /// undefined, which is what `H5F__super_init` writes for it.
9931 fn write_free_space_managers(&self) -> IoResult<Option<Vec<u8>>> {
9932 let Some(fs) = self.free_space.as_deref() else {
9933 return Ok(None);
9934 };
9935 if !fs.records_free_space() {
9936 return Ok(Some(fs.info.encode(&self.ctx)?));
9937 }
9938 // The managers a reopen found are superseded whole by the ones below,
9939 // so their blocks go back before anything is laid out: the space the
9940 // old manager occupied is free space the new one records, and the new
9941 // one may be laid out in it.
9942 for &(addr, len) in &fs.superseded {
9943 self.allocator.free(addr, len, FreeSpaceClass::Metadata);
9944 }
9945
9946 let hdr_size = FreeSpaceHeader::encoded_size(&self.ctx) as u64;
9947 let settled = self.settle_free_space_managers(hdr_size, fs.info.threshold)?;
9948
9949 let mut info = fs.info.clone();
9950 info.fs_addr = vec![UNDEF_ADDR; info.fs_addr.len()];
9951 for placed in &settled {
9952 let mut header = manager_header(&placed.sections);
9953 // The settle loop sized the block; that the encode agrees is the
9954 // invariant that makes `sect_size` a length a reader can trust.
9955 let needed = free_space::sinfo_encoded_size(&header, &placed.sections, &self.ctx);
9956 if needed > placed.sect_size {
9957 return Err(crate::io::IoError::InvalidState(format!(
9958 "the free-space sections need {needed} bytes, not the {} laid out",
9959 placed.sect_size
9960 )));
9961 }
9962 header.sect_addr = placed.sect_addr;
9963 header.sect_size = placed.sect_size;
9964 header.alloc_sect_size = placed.sect_size;
9965 self.handle.write_at(
9966 placed.sect_addr,
9967 &free_space::encode_sections(
9968 &header,
9969 placed.hdr_addr,
9970 &placed.sections,
9971 placed.sect_size as usize,
9972 &self.ctx,
9973 ),
9974 )?;
9975 self.handle
9976 .write_at(placed.hdr_addr, &header.encode(&self.ctx))?;
9977 // `H5MF__close_delete_fstype` leaves a manager with no sections
9978 // without an address, so only the ones written name themselves.
9979 info.fs_addr[placed.manager.message_slot()] = placed.hdr_addr;
9980 }
9981 // The end of the file *after* the settle above, not before it, which
9982 // the field's name denies: it is 1.10 vintage, where two EOAs were
9983 // kept — one taken before the self-referential managers were placed
9984 // and one after (H5MF.c:3305 and 3382 in 1.10.11) — and the message
9985 // carried the first (1.10.11 H5MF.c:1833, 1999). 1.14 keeps one,
9986 // `f->shared->eoa_fsm_fsalloc`, read once the allocation loop has run
9987 // (H5MF.c:3234-3240) and encoded into this field by both close paths
9988 // (H5MF.c:1759, 1923); H5Fsuper.c:826 names it "the final eoa". A
9989 // 1.10 reader wants that value and not the older one: equal EOAs are
9990 // the case `H5MF_tidy_self_referential_fsm_hack` returns on
9991 // (1.10.11 H5MF.c:3620-3622), which is what leaves the managers this
9992 // close wrote in place.
9993 info.eoa_pre_fsm_fsalloc = self.allocator.eof();
9994 Ok(Some(info.encode(&self.ctx)?))
9995 }
9996
9997 /// The file's free space as each manager will record it: address-ordered
9998 /// per manager, tagged with the section class that manager writes, and
9999 /// with everything below `threshold` left out.
10000 ///
10001 /// The allocator is the single owner of merging — `H5FS__sect_merge`'s
10002 /// rules, per manager and, on a paged file, per page — so nothing merges
10003 /// here; overlap is checked because two overlapping sections would be a
10004 /// manager claiming space another structure holds.
10005 fn free_sections(&self, threshold: u64) -> IoResult<Vec<(FreeSpaceManager, Vec<FreeSection>)>> {
10006 let policy = self.allocator.policy();
10007 let extents = self.allocator.free_extents();
10008 let mut sets = Vec::new();
10009 for manager in FreeSpaceManager::ALL {
10010 let mut sections: Vec<FreeSection> = extents
10011 .iter()
10012 .filter(|b| b.manager == manager)
10013 // `H5FS_sect_add` refuses a section below the file's
10014 // threshold, so a block smaller than it is space the file
10015 // leaks rather than records — the same trade the threshold is
10016 // there to make.
10017 .filter(|b| b.len >= threshold)
10018 .map(|b| FreeSection {
10019 addr: b.addr,
10020 len: b.len,
10021 class: policy.section_class(manager),
10022 })
10023 .collect();
10024 sections.sort_unstable_by_key(|s| s.addr);
10025 if let Some(bad) = sections
10026 .windows(2)
10027 .find(|w| w[0].addr + w[0].len > w[1].addr)
10028 {
10029 return Err(crate::io::IoError::InvalidState(format!(
10030 "this session freed overlapping blocks: {:#x}+{} overlaps {:#x}",
10031 bad[0].addr, bad[0].len, bad[1].addr
10032 )));
10033 }
10034 sets.push((manager, sections));
10035 }
10036 Ok(sets)
10037 }
10038
10039 /// Give every manager that records anything its own header and sections
10040 /// blocks, and return what each will write.
10041 ///
10042 /// Self-referential, which is the whole difficulty: a manager's two blocks
10043 /// come out of the free space the managers record, and taking them changes
10044 /// that space, which changes how many bytes the sections block needs.
10045 /// Upstream reruns the allocation pass until no manager allocates anything
10046 /// further — the `do { ... } while (continue_alloc_fsm)` loop in
10047 /// `H5MF_settle_meta_data_fsm` (H5MF.c:3213-3247) around
10048 /// `H5FS_vfd_alloc_hdr_and_section_info_if_needed`, which allocates
10049 /// through `H5MF_alloc` like everything else. So does this: the blocks
10050 /// come out of the same [`FileAllocator`], under the same strategy, so a
10051 /// paged file's manager blocks land in pages and their page remainders are
10052 /// recorded like any others.
10053 ///
10054 /// Two rules make it terminate. A manager, once placed, stays placed: were
10055 /// its blocks released because its sections had been consumed, freeing
10056 /// them would put those sections back and the next round would place it
10057 /// again. And a sections block only ever grows: upstream frees a block
10058 /// that turned out too small and reallocates it next round
10059 /// (H5FSsection.c:2418-2423), and a size that only rises reaches its
10060 /// bound.
10061 fn settle_free_space_managers(
10062 &self,
10063 hdr_size: u64,
10064 threshold: u64,
10065 ) -> IoResult<Vec<PlacedManager>> {
10066 /// Rounds before the layout is called divergent. A round either places
10067 /// a manager or grows one sections block, and there are three
10068 /// managers, so a file that needs more than this is not converging.
10069 const ROUNDS: usize = 16;
10070
10071 // Raw data first and metadata last, in `H5MF_settle_raw_data_fsm`'s
10072 // order (H5C.c:689-696): every manager's own blocks are metadata
10073 // allocations, so the metadata manager funds all of them and is the
10074 // one whose section set the others change.
10075 const ORDER: [FreeSpaceManager; 3] = [
10076 FreeSpaceManager::RawData,
10077 FreeSpaceManager::Large,
10078 FreeSpaceManager::Metadata,
10079 ];
10080
10081 let size_of = |sections: &[FreeSection]| {
10082 let ordered = free_space::serialization_order(sections);
10083 free_space::sinfo_encoded_size(&manager_header(&ordered), &ordered, &self.ctx)
10084 };
10085 let mut placed: Vec<PlacedManager> = Vec::new();
10086 for _ in 0..ROUNDS {
10087 let sets = self.free_sections(threshold)?;
10088 let sections_of = |manager: FreeSpaceManager| {
10089 sets.iter()
10090 .find(|(m, _)| *m == manager)
10091 .map(|(_, s)| s.as_slice())
10092 .unwrap_or_default()
10093 };
10094
10095 let mut changed = false;
10096 for manager in ORDER {
10097 let sections = sections_of(manager);
10098 if sections.is_empty() || placed.iter().any(|p| p.manager == manager) {
10099 continue;
10100 }
10101 let sect_size = size_of(sections);
10102 let hdr_addr = self.allocator.allocate(hdr_size, FreeSpaceClass::Metadata);
10103 let sect_addr = self.allocator.allocate(sect_size, FreeSpaceClass::Metadata);
10104 placed.push(PlacedManager {
10105 manager,
10106 hdr_addr,
10107 sect_addr,
10108 sect_size,
10109 sections: Vec::new(),
10110 });
10111 changed = true;
10112 }
10113 if !changed {
10114 for p in &mut placed {
10115 let needed = size_of(sections_of(p.manager));
10116 if needed > p.sect_size {
10117 self.allocator
10118 .free(p.sect_addr, p.sect_size, FreeSpaceClass::Metadata);
10119 p.sect_size = needed;
10120 p.sect_addr = self.allocator.allocate(needed, FreeSpaceClass::Metadata);
10121 changed = true;
10122 }
10123 }
10124 }
10125 if !changed {
10126 for p in &mut placed {
10127 p.sections = free_space::serialization_order(sections_of(p.manager));
10128 }
10129 return Ok(placed);
10130 }
10131 }
10132 Err(crate::io::IoError::InvalidState(format!(
10133 "the free-space managers did not settle in {ROUNDS} rounds"
10134 )))
10135 }
10136
10137 /// Write the file's superblock extension, and the sole owner of that
10138 /// object header.
10139 ///
10140 /// Runs after [`prepare_shared_messages`](Self::prepare_shared_messages),
10141 /// whose table it names, and before the superblock that names it. What it
10142 /// writes is [`CarriedExtension`] — every message the reopened file's
10143 /// extension held — plus the shared-message table message, which is the
10144 /// one message whose content this session owns: the table moved, so the
10145 /// message read is stale and the message written names the new address.
10146 ///
10147 /// A file with neither carried messages nor shared messages gets no
10148 /// extension, which is what libhdf5 writes for it: `H5F__super_ext_create`
10149 /// is called only when there is a message to put in one.
10150 ///
10151 /// Version 1, holding its messages in one chunk: the extension is created
10152 /// before anything raises the file's object header version
10153 /// (`H5F__super_ext_create` passes `H5O_HDR_STORE_TIMES` off and takes the
10154 /// version-1 path), so an extension of any generation of file looks the
10155 /// same.
10156 fn write_superblock_extension(&self) -> IoResult<()> {
10157 if self.extension.addr.lock().is_some() {
10158 return Ok(());
10159 }
10160 let table = self.sohm.as_deref().and_then(|sohm| {
10161 sohm.table_addr
10162 .lock()
10163 .map(|addr| (sohm.indexes.len(), addr))
10164 });
10165 // A file with file-space properties of its own needs an extension
10166 // too: the message that declares them is the only place they are
10167 // recorded, and a file created with them carries nothing else.
10168 if self.extension.carried.is_empty() && table.is_none() && self.free_space.is_none() {
10169 return Ok(());
10170 }
10171
10172 let mut messages: Vec<crate::io::object_header_io::ExtensionMessage> =
10173 self.extension.carried.clone();
10174 if let Some(fs) = self.free_space.as_deref() {
10175 // The declared message, at exactly the length the one written
10176 // below will have — every field of it is fixed-width, and only
10177 // `persist` and the message version change the count of
10178 // addresses, neither of which the close alters. The image is sized
10179 // and its block allocated before the managers can be laid out, so
10180 // the message has to reach its final *length* here even though its
10181 // content is settled later.
10182 let declared = fs.info.encode(&self.ctx)?;
10183 match messages
10184 .iter_mut()
10185 .find(|m| m.msg_type == MSG_FILE_SPACE_INFO)
10186 {
10187 Some(msg) => msg.body = declared,
10188 None => messages.push(crate::io::object_header_io::ExtensionMessage {
10189 msg_type: MSG_FILE_SPACE_INFO,
10190 flags: MSG_FLAG_DONTSHARE | MSG_FLAG_MARK_IF_UNKNOWN,
10191 body: declared,
10192 }),
10193 }
10194 }
10195 if let Some((nindexes, table_addr)) = table {
10196 let nindexes = u8::try_from(nindexes).map_err(|_| {
10197 crate::io::IoError::InvalidState(format!("{nindexes} shared-message indexes"))
10198 })?;
10199 messages.push(crate::io::object_header_io::ExtensionMessage {
10200 msg_type: MSG_SHARED_MESSAGE_TABLE,
10201 flags: MSG_FLAG_CONSTANT | MSG_FLAG_DONTSHARE,
10202 body: SharedMessageTableMessage {
10203 version: 0,
10204 table_address: table_addr,
10205 nindexes,
10206 }
10207 .encode(&self.ctx),
10208 });
10209 }
10210 let encode = |messages: &[crate::io::object_header_io::ExtensionMessage]| {
10211 let mut extension = ObjectHeader::new();
10212 for msg in messages {
10213 extension.add_message(msg.msg_type, msg.flags, msg.body.clone());
10214 }
10215 extension.encode_v1(1)
10216 };
10217 let image = encode(&messages)?;
10218 // Freed before the replacement is placed, so a reopen reuses the block
10219 // instead of stranding one per open/close cycle — the rule every other
10220 // superseded structure follows.
10221 for &(addr, len) in &self.extension.superseded {
10222 self.allocator.free(addr, len, FreeSpaceClass::Metadata);
10223 }
10224 let addr = self
10225 .allocator
10226 .allocate(image.len() as u64, FreeSpaceClass::Metadata);
10227
10228 // Every block of this file now has an address, so the allocator holds
10229 // exactly the file's free space: settle the free-space managers over
10230 // it and say in this extension where they went.
10231 let image = match self.write_free_space_managers()? {
10232 None => image,
10233 Some(body) => {
10234 let msg = messages
10235 .iter_mut()
10236 .find(|m| m.msg_type == MSG_FILE_SPACE_INFO)
10237 .ok_or_else(|| {
10238 crate::io::IoError::InvalidState(
10239 "a persisting file lost its file-space info message".into(),
10240 )
10241 })?;
10242 // Same length as the declared body put in above, so the
10243 // image measured before the block was allocated still fits.
10244 if body.len() != msg.body.len() {
10245 return Err(crate::io::IoError::InvalidState(format!(
10246 "the file-space info message was laid out at {} bytes and \
10247 written back at {}",
10248 msg.body.len(),
10249 body.len()
10250 )));
10251 }
10252 msg.body = body;
10253 encode(&messages)?
10254 }
10255 };
10256 self.handle.write_at(addr, &image)?;
10257 *self.extension.addr.lock() = Some(addr);
10258 Ok(())
10259 }
10260
10261 /// Define a new contiguous dataset. Returns the dataset index (used with
10262 /// `write_dataset_raw`).
10263 ///
10264 /// The raw-data region is allocated immediately so that
10265 /// `write_dataset_raw` can be called at any time before `close()`.
10266 pub fn create_dataset(
10267 &self,
10268 name: &str,
10269 datatype: DatatypeMessage,
10270 dims: &[u64],
10271 ) -> IoResult<usize> {
10272 let create = self.begin_create(name)?;
10273 let name = create.name.as_str();
10274 let total_elements: u64 = if dims.is_empty() {
10275 1
10276 } else {
10277 dims.iter().product()
10278 };
10279 let element_size = datatype.element_size() as u64;
10280 let data_size = total_elements * element_size;
10281
10282 // Allocate space for the raw data.
10283 let data_addr = if data_size > 0 {
10284 self.allocator.allocate(data_size, FreeSpaceClass::RawData)
10285 } else {
10286 UNDEF_ADDR
10287 };
10288
10289 let dataspace = if dims.is_empty() {
10290 DataspaceMessage::scalar()
10291 } else {
10292 DataspaceMessage::simple(dims)
10293 };
10294
10295 let idx = self.push_dataset(
10296 &create,
10297 DatasetInfo {
10298 name: name.to_string(),
10299 datatype,
10300 committed_type: None,
10301 external: None,
10302 virtual_storage: None,
10303 dataspace,
10304 read_format: None,
10305 obj_header_addr: 0, // set during finalize
10306 data_addr,
10307 data_size,
10308 compact: None,
10309 chunked: None,
10310 fixed_array: None,
10311 implicit: None,
10312 single_chunk: None,
10313 btree_v1: None,
10314 btree_v2: None,
10315 append: None,
10316 attributes: Vec::new(),
10317 obj_header_written_addr: None,
10318 obj_header_blocks: Vec::new(),
10319 filter_pipeline: None,
10320 deleted: false,
10321 extent_dirty: false,
10322 header_dirty: false,
10323 nlink_written: 1,
10324 creation_seq: self.take_creation_seq(),
10325 track_attr_order: self.track_order.attrs,
10326 fill_value: None,
10327 fill_time: FILL_TIME_IFSET,
10328 layout_version: 4,
10329 times: self.created_object_times(),
10330 },
10331 );
10332
10333 Ok(idx)
10334 }
10335
10336 /// Define a new dataset whose raw data lives in files outside this one —
10337 /// `H5Pset_external`, h5py's `external=[(name, offset, size)]`.
10338 ///
10339 /// Each entry names a file, the byte offset in it where that entry's
10340 /// region starts, and how many bytes of the dataset the region holds; the
10341 /// entries concatenate, in order, into the dataset's logical byte range,
10342 /// and together must cover it. Nothing is allocated in this file: the data
10343 /// layout message says contiguous storage at an undefined address, and it
10344 /// is the External File List beside it that says where the bytes are
10345 /// (`H5D__layout_oh_create`).
10346 ///
10347 /// A named file is created on first write and never truncated, so several
10348 /// slots — or several datasets — may own disjoint ranges of one file, the
10349 /// way `H5D__efl_write` opens them.
10350 ///
10351 /// The last slot may take the unlimited size `H5O_EFL_UNLIMITED`, which
10352 /// makes it absorb however many bytes the dataset comes to hold; a
10353 /// dataset whose dataspace is unlimited must have one, since nothing
10354 /// finite could cover it (`H5D__efl_construct`: "unlimited dataspace but
10355 /// finite storage"). Only the first dimension may be extendible, which is
10356 /// the same function's other rule.
10357 pub fn create_external_dataset(
10358 &self,
10359 name: &str,
10360 datatype: DatatypeMessage,
10361 dims: &[u64],
10362 max_dims: Option<&[u64]>,
10363 files: &[(&str, u64, u64)],
10364 ) -> IoResult<usize> {
10365 if files.is_empty() {
10366 return Err(crate::io::IoError::InvalidState(format!(
10367 "external dataset '{name}' names no files; external storage is defined by \
10368 the files it lives in, so at least one is required"
10369 )));
10370 }
10371 let create = self.begin_create(name)?;
10372 let name = create.name.as_str();
10373 let total_elements: u64 = if dims.is_empty() {
10374 1
10375 } else {
10376 dims.iter().product()
10377 };
10378 let data_size = total_elements * datatype.element_size() as u64;
10379
10380 let mut heap = LocalHeapImage::with_empty_string();
10381 let mut entries = Vec::with_capacity(files.len());
10382 for (i, &(file_name, offset, size)) in files.iter().enumerate() {
10383 if file_name.is_empty() {
10384 return Err(crate::io::IoError::InvalidState(format!(
10385 "external dataset '{name}' has a slot with an empty file name"
10386 )));
10387 }
10388 // `H5Pset_external` refuses to add a slot behind an unlimited one
10389 // ("previous file size is unlimited"): the unlimited slot already
10390 // owns every byte from its own start onwards, so nothing after it
10391 // could ever be reached.
10392 if size == UNLIMITED && i + 1 != files.len() {
10393 return Err(crate::io::IoError::InvalidState(format!(
10394 "external dataset '{name}' gives slot {i} ('{file_name}') the unlimited \
10395 size H5O_EFL_UNLIMITED with {} slot(s) behind it; an unlimited slot \
10396 absorbs the rest of the dataset, so it can only be the last",
10397 files.len() - i - 1
10398 )));
10399 }
10400 if offset.checked_add(size).is_none() {
10401 return Err(crate::io::IoError::InvalidState(format!(
10402 "external dataset '{name}' slot '{file_name}' spans offset {offset} \
10403 plus {size} bytes, past the end of the 64-bit address space"
10404 )));
10405 }
10406 entries.push(ExternalFile {
10407 name: file_name.to_string(),
10408 name_offset: heap.insert_str(file_name),
10409 offset,
10410 size,
10411 });
10412 }
10413 let external = ExternalStorage {
10414 // Filled in below, once the heap the names went into has an
10415 // address; the names' offsets within it are already final.
10416 heap_addr: UNDEF_ADDR,
10417 files: entries,
10418 // Settled by the open this create hands a handle out for, which
10419 // is `H5D__create` reading the dapl at H5Dint.c:1318.
10420 prefix: EfilePrefix::default(),
10421 };
10422 // `H5D__efl_construct`, over the dataset's *maximum* extent: the
10423 // slots must reserve at least every byte the dataset could come to
10424 // hold, and an unlimited extent can only be covered by an unlimited
10425 // last slot ("unlimited dataspace but finite storage").
10426 let max_dims = max_dims.unwrap_or(dims);
10427 if max_dims.len() != dims.len() {
10428 return Err(crate::io::IoError::InvalidState(format!(
10429 "external dataset '{name}' has {} dimensions but {} maximum ones",
10430 dims.len(),
10431 max_dims.len()
10432 )));
10433 }
10434 for (d, (&max, &cur)) in max_dims.iter().zip(dims).enumerate().skip(1) {
10435 if max > cur {
10436 return Err(crate::io::IoError::InvalidState(format!(
10437 "external dataset '{name}' makes dimension {d} extendible ({cur} of \
10438 {max}); only the first dimension can be extendible for external storage"
10439 )));
10440 }
10441 }
10442 let reserved = external.total_size();
10443 if max_dims.contains(&u64::MAX) {
10444 if reserved != UNLIMITED {
10445 return Err(crate::io::IoError::InvalidState(format!(
10446 "external dataset '{name}' has an unlimited dataspace but its files \
10447 reserve only {reserved} bytes; the last slot must take the unlimited \
10448 size H5O_EFL_UNLIMITED"
10449 )));
10450 }
10451 } else {
10452 let max_bytes = max_dims
10453 .iter()
10454 .try_fold(datatype.element_size() as u64, |acc, &d| acc.checked_mul(d))
10455 .ok_or_else(|| {
10456 crate::io::IoError::InvalidState(format!(
10457 "external dataset '{name}' maximum extent times its element size \
10458 overflows 64 bits"
10459 ))
10460 })?;
10461 if reserved < max_bytes {
10462 return Err(crate::io::IoError::InvalidState(format!(
10463 "external dataset '{name}' needs {max_bytes} bytes but its files reserve \
10464 only {reserved}"
10465 )));
10466 }
10467 }
10468
10469 // The names' heap, written now: it is ordinary metadata of this file,
10470 // and the message the header carries is only an address into it.
10471 let sa = self.ctx.sizeof_addr as usize;
10472 let ss = self.ctx.sizeof_size as usize;
10473 let heap_bytes = heap.as_bytes().to_vec();
10474 let heap_addr = self.allocator.allocate(
10475 local_heap_header_size(sa, ss) as u64,
10476 FreeSpaceClass::Metadata,
10477 );
10478 let heap_data_addr = self
10479 .allocator
10480 .allocate(heap_bytes.len() as u64, FreeSpaceClass::Metadata);
10481 let heap_hdr = LocalHeapHeader {
10482 data_size: heap_bytes.len() as u64,
10483 // Sized to hold exactly these names, so no block of it is free.
10484 free_list_offset: LOCAL_HEAP_FREE_NULL,
10485 data_addr: heap_data_addr,
10486 };
10487 self.handle.write_at(heap_addr, &heap_hdr.encode(sa, ss))?;
10488 self.handle.write_at(heap_data_addr, &heap_bytes)?;
10489 let external = ExternalStorage {
10490 heap_addr,
10491 ..external
10492 };
10493
10494 let dataspace = if dims.is_empty() {
10495 DataspaceMessage::scalar()
10496 } else {
10497 let mut ds = DataspaceMessage::simple(dims);
10498 if max_dims != dims {
10499 ds.max_dims = Some(max_dims.to_vec());
10500 }
10501 ds
10502 };
10503
10504 let idx = self.push_dataset(
10505 &create,
10506 DatasetInfo {
10507 name: name.to_string(),
10508 datatype,
10509 committed_type: None,
10510 external: Some(external),
10511 virtual_storage: None,
10512 dataspace,
10513 read_format: None,
10514 obj_header_addr: 0, // set during finalize
10515 // No block of this file's own: the layout message declares
10516 // contiguous storage at an undefined address, which is what
10517 // sends a reader to the external file list instead.
10518 data_addr: UNDEF_ADDR,
10519 data_size,
10520 compact: None,
10521 chunked: None,
10522 fixed_array: None,
10523 btree_v2: None,
10524 implicit: None,
10525 single_chunk: None,
10526 btree_v1: None,
10527 append: None,
10528 attributes: Vec::new(),
10529 obj_header_written_addr: None,
10530 obj_header_blocks: Vec::new(),
10531 filter_pipeline: None,
10532 deleted: false,
10533 extent_dirty: false,
10534 header_dirty: false,
10535 nlink_written: 1,
10536 creation_seq: self.take_creation_seq(),
10537 track_attr_order: self.track_order.attrs,
10538 fill_value: None,
10539 fill_time: FILL_TIME_IFSET,
10540 layout_version: 4,
10541 times: self.created_object_times(),
10542 },
10543 );
10544
10545 Ok(idx)
10546 }
10547
10548 /// Define a new virtual dataset — `H5Pset_virtual`, h5py's
10549 /// `create_virtual_dataset(name, VirtualLayout)`.
10550 ///
10551 /// Each mapping says which elements of this dataset (`virtual_selection`)
10552 /// are read from which elements (`source_selection`) of a dataset in
10553 /// another file; the sources are never opened here, and a mapping naming
10554 /// one that does not exist yet is perfectly legal — libhdf5 resolves each
10555 /// at read time, filling from the fill value where nothing maps.
10556 ///
10557 /// The mappings do not live in the object header: they are serialized
10558 /// into one global heap object and the layout message carries only its
10559 /// address and index (`H5D__virtual_store_layout`), which is why this
10560 /// allocates a heap object and nothing else.
10561 ///
10562 /// An unlimited (`H5S_UNLIMITED`) selection is written as one: the
10563 /// mapping grows with its source, and the virtual dataset's extent in
10564 /// that dimension is whatever the sources reachable at read time supply
10565 /// (`H5D__virtual_set_extent_unlim`). A `printf`-style source name is
10566 /// written as one too: `%b` substitutes the block index, so one mapping
10567 /// stands for the family of source datasets that fill the successive
10568 /// blocks of an unlimited virtual selection.
10569 pub fn create_virtual_dataset(
10570 &self,
10571 name: &str,
10572 datatype: DatatypeMessage,
10573 dims: &[u64],
10574 max_dims: Option<&[u64]>,
10575 mappings: &[VirtualMapping],
10576 ) -> IoResult<usize> {
10577 if mappings.is_empty() {
10578 return Err(crate::io::IoError::InvalidState(format!(
10579 "virtual dataset '{name}' names no mappings; a virtual dataset is defined \
10580 by the source datasets it maps, so at least one is required"
10581 )));
10582 }
10583 for m in mappings {
10584 check_virtual_mapping(name, m)?;
10585 }
10586
10587 let create = self.begin_create(name)?;
10588 let name = create.name.as_str();
10589
10590 // The mapping list is ordinary file metadata, written now: the header
10591 // built at finalize carries only the heap address and object index it
10592 // lands at.
10593 let block = VirtualMappingList {
10594 mappings: mappings.to_vec(),
10595 }
10596 .encode(&self.ctx)?;
10597 let (heap_addr, heap_index) = self.insert_vlen_objects(&[&block])?[0];
10598
10599 let dataspace = if dims.is_empty() {
10600 DataspaceMessage::scalar()
10601 } else {
10602 let mut ds = DataspaceMessage::simple(dims);
10603 // A caller that named no maximum gets the current dimensions, the
10604 // maximum `simple` already filled in: `H5Screate_simple(rank,
10605 // dims, NULL)` reaches the encoder with `extent.max` set
10606 // (H5S.c:1293-1299), so leaving it absent here would write a
10607 // message no upstream API call can produce.
10608 if let Some(max) = max_dims {
10609 ds.max_dims = Some(max.to_vec());
10610 }
10611 ds
10612 };
10613
10614 let idx = self.push_dataset(
10615 &create,
10616 DatasetInfo {
10617 name: name.to_string(),
10618 datatype,
10619 committed_type: None,
10620 external: None,
10621 virtual_storage: Some(VirtualStorage {
10622 heap_addr,
10623 heap_index: heap_index as u32,
10624 mappings: mappings.to_vec(),
10625 }),
10626 dataspace,
10627 read_format: None,
10628 obj_header_addr: 0, // set during finalize
10629 // Not a block of this file at all: every element is read out
10630 // of a source dataset, so there is nothing here to allocate
10631 // and nothing to free when the dataset is deleted.
10632 data_addr: UNDEF_ADDR,
10633 data_size: 0,
10634 compact: None,
10635 chunked: None,
10636 fixed_array: None,
10637 btree_v2: None,
10638 implicit: None,
10639 single_chunk: None,
10640 btree_v1: None,
10641 append: None,
10642 attributes: Vec::new(),
10643 obj_header_written_addr: None,
10644 obj_header_blocks: Vec::new(),
10645 filter_pipeline: None,
10646 deleted: false,
10647 extent_dirty: false,
10648 header_dirty: false,
10649 nlink_written: 1,
10650 creation_seq: self.take_creation_seq(),
10651 track_attr_order: self.track_order.attrs,
10652 fill_value: None,
10653 fill_time: FILL_TIME_IFSET,
10654 layout_version: 4,
10655 times: self.created_object_times(),
10656 },
10657 );
10658
10659 Ok(idx)
10660 }
10661
10662 /// Define a new compact dataset — `H5Pset_layout(dcpl, H5D_COMPACT)`.
10663 ///
10664 /// The raw data lives inside the data layout message in the dataset's own
10665 /// object header, so it costs no block of its own and no extra seek to
10666 /// read; the price is the ceiling, and that the whole image is rewritten
10667 /// whenever the header is. The buffer is created at its final length and
10668 /// zero-filled, which is what `H5D__compact_fill` does at create time, so
10669 /// a dataset never written still reads back as its fill value.
10670 ///
10671 /// Errors when the image exceeds [`MAX_COMPACT_DATA`].
10672 pub fn create_compact_dataset(
10673 &self,
10674 name: &str,
10675 datatype: DatatypeMessage,
10676 dims: &[u64],
10677 ) -> IoResult<usize> {
10678 let total_elements: u64 = if dims.is_empty() {
10679 1
10680 } else {
10681 dims.iter().product()
10682 };
10683 let data_size = total_elements * datatype.element_size() as u64;
10684 if data_size > MAX_COMPACT_DATA as u64 {
10685 return Err(crate::io::IoError::InvalidState(format!(
10686 "compact dataset '{name}' needs {data_size} bytes, above the \
10687 {MAX_COMPACT_DATA}-byte ceiling a data layout message can hold; \
10688 use contiguous or chunked storage"
10689 )));
10690 }
10691
10692 let create = self.begin_create(name)?;
10693 let name = create.name.as_str();
10694 let dataspace = if dims.is_empty() {
10695 DataspaceMessage::scalar()
10696 } else {
10697 DataspaceMessage::simple(dims)
10698 };
10699
10700 let idx = self.push_dataset(
10701 &create,
10702 DatasetInfo {
10703 name: name.to_string(),
10704 datatype,
10705 committed_type: None,
10706 external: None,
10707 virtual_storage: None,
10708 dataspace,
10709 read_format: None,
10710 obj_header_addr: 0, // set during finalize
10711 data_addr: UNDEF_ADDR,
10712 data_size: 0,
10713 compact: Some(vec![0u8; data_size as usize]),
10714 chunked: None,
10715 fixed_array: None,
10716 implicit: None,
10717 single_chunk: None,
10718 btree_v1: None,
10719 btree_v2: None,
10720 append: None,
10721 attributes: Vec::new(),
10722 obj_header_written_addr: None,
10723 obj_header_blocks: Vec::new(),
10724 filter_pipeline: None,
10725 deleted: false,
10726 extent_dirty: false,
10727 header_dirty: false,
10728 nlink_written: 1,
10729 creation_seq: self.take_creation_seq(),
10730 track_attr_order: self.track_order.attrs,
10731 fill_value: None,
10732 fill_time: FILL_TIME_IFSET,
10733 layout_version: 4,
10734 times: self.created_object_times(),
10735 },
10736 );
10737
10738 Ok(idx)
10739 }
10740
10741 /// Define a new dataset with the NULL dataspace: no elements at all.
10742 ///
10743 /// Distinct from a scalar dataset (`create_dataset` with `dims == []`),
10744 /// which holds exactly one element — a NULL dataspace holds zero, so
10745 /// there is no raw image to allocate: `data_addr` stays `UNDEF_ADDR` and
10746 /// `data_size` stays 0 permanently, the same terminal state
10747 /// `create_dataset` already reaches for a zero-length dimension.
10748 pub fn create_null_dataset(&self, name: &str, datatype: DatatypeMessage) -> IoResult<usize> {
10749 let create = self.begin_create(name)?;
10750 let name = create.name.as_str();
10751
10752 let idx = self.push_dataset(
10753 &create,
10754 DatasetInfo {
10755 name: name.to_string(),
10756 datatype,
10757 committed_type: None,
10758 external: None,
10759 virtual_storage: None,
10760 dataspace: DataspaceMessage::null(),
10761 read_format: None,
10762 obj_header_addr: 0, // set during finalize
10763 data_addr: UNDEF_ADDR,
10764 data_size: 0,
10765 compact: None,
10766 chunked: None,
10767 fixed_array: None,
10768 implicit: None,
10769 single_chunk: None,
10770 btree_v1: None,
10771 btree_v2: None,
10772 append: None,
10773 attributes: Vec::new(),
10774 obj_header_written_addr: None,
10775 obj_header_blocks: Vec::new(),
10776 filter_pipeline: None,
10777 deleted: false,
10778 extent_dirty: false,
10779 header_dirty: false,
10780 nlink_written: 1,
10781 creation_seq: self.take_creation_seq(),
10782 track_attr_order: self.track_order.attrs,
10783 fill_value: None,
10784 fill_time: FILL_TIME_IFSET,
10785 layout_version: 4,
10786 times: self.created_object_times(),
10787 },
10788 );
10789
10790 Ok(idx)
10791 }
10792
10793 /// Define a new chunked dataset with an extensible array index.
10794 ///
10795 /// Returns the dataset index. The dataset starts empty (dims[0] = 0 if
10796 /// the first dimension is unlimited). Use `write_chunk` and
10797 /// `extend_dataset` to add data.
10798 pub fn create_chunked_dataset(
10799 &self,
10800 name: &str,
10801 datatype: DatatypeMessage,
10802 dims: &[u64],
10803 max_dims: &[u64],
10804 chunk_dims: &[u64],
10805 ) -> IoResult<usize> {
10806 let create = self.begin_create(name)?;
10807 let name = create.name.as_str();
10808 validate_chunk_geometry(dims, max_dims, chunk_dims)?;
10809 ensure_at_most_one_unlimited(max_dims)?;
10810 let chunk_bytes = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
10811 let layout_version = self.chunk_layout_version(false, chunk_bytes);
10812 let earray_params = EarrayParams::default_params();
10813 let ndblk_addrs = compute_ndblk_addrs(earray_params.sup_blk_min_data_ptrs)?;
10814 let nsblk_addrs = compute_nsblk_addrs(
10815 earray_params.idx_blk_elmts,
10816 earray_params.data_blk_min_elmts,
10817 earray_params.sup_blk_min_data_ptrs,
10818 earray_params.max_nelmts_bits,
10819 )?;
10820
10821 // Create EA header
10822 let mut ea_header = ExtensibleArrayHeader::new_for_chunks(&self.ctx);
10823 ea_header.max_nelmts_bits = earray_params.max_nelmts_bits;
10824 ea_header.idx_blk_elmts = earray_params.idx_blk_elmts;
10825 ea_header.data_blk_min_elmts = earray_params.data_blk_min_elmts;
10826 ea_header.sup_blk_min_data_ptrs = earray_params.sup_blk_min_data_ptrs;
10827 ea_header.max_dblk_page_nelmts_bits = earray_params.max_dblk_page_nelmts_bits;
10828
10829 // Allocate and write EA header (placeholder, will be updated)
10830 let hdr_encoded = ea_header.encode(&self.ctx);
10831 let ea_header_addr = self
10832 .allocator
10833 .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
10834
10835 // Create EA index block with pre-allocated super block address slots
10836 let ea_iblk = ExtensibleArrayIndexBlock::new(
10837 ea_header_addr,
10838 earray_params.idx_blk_elmts,
10839 ndblk_addrs,
10840 nsblk_addrs,
10841 );
10842
10843 // Allocate and write EA index block
10844 let iblk_encoded = ea_iblk.encode(&self.ctx);
10845 let ea_iblk_addr = self
10846 .allocator
10847 .allocate(iblk_encoded.len() as u64, FreeSpaceClass::Metadata);
10848
10849 // Update header with index block address
10850 ea_header.idx_blk_addr = ea_iblk_addr;
10851
10852 // Write both to disk
10853 let hdr_encoded = ea_header.encode(&self.ctx);
10854 self.handle.write_at(ea_header_addr, &hdr_encoded)?;
10855 self.handle.write_at(ea_iblk_addr, &iblk_encoded)?;
10856
10857 // Build dataspace with max dims
10858 let dataspace = DataspaceMessage {
10859 // Chunked storage always requires at least one dimension, so
10860 // this is never Scalar or Null.
10861 class: DataspaceClass::Simple,
10862 dims: dims.to_vec(),
10863 max_dims: Some(max_dims.to_vec()),
10864 };
10865
10866 let idx = self.push_dataset(
10867 &create,
10868 DatasetInfo {
10869 name: name.to_string(),
10870 datatype,
10871 committed_type: None,
10872 external: None,
10873 virtual_storage: None,
10874 dataspace,
10875 read_format: None,
10876 obj_header_addr: 0,
10877 data_addr: UNDEF_ADDR,
10878 data_size: 0,
10879 compact: None,
10880 attributes: Vec::new(),
10881 obj_header_written_addr: None,
10882 obj_header_blocks: Vec::new(),
10883 filter_pipeline: None,
10884 deleted: false,
10885 extent_dirty: false,
10886 header_dirty: false,
10887 nlink_written: 1,
10888 creation_seq: self.take_creation_seq(),
10889 track_attr_order: self.track_order.attrs,
10890 fill_value: None,
10891 fill_time: FILL_TIME_IFSET,
10892 layout_version,
10893 times: self.created_object_times(),
10894 fixed_array: None,
10895 implicit: None,
10896 single_chunk: None,
10897 btree_v1: None,
10898 btree_v2: None,
10899 chunked: Some(ChunkedDatasetInfo {
10900 chunk_dims: chunk_dims.to_vec(),
10901 earray_params,
10902 ea_header_addr,
10903 ea_iblk_addr,
10904 ea_header,
10905 ea_iblk,
10906 chunks_written: 0,
10907 filt_iblk: None,
10908 chunk_size_len: 0,
10909 }),
10910 append: None,
10911 },
10912 );
10913
10914 Ok(idx)
10915 }
10916
10917 /// Write `data` into a contiguous dataset's raw storage at *dataset-
10918 /// relative* byte offset `off`.
10919 ///
10920 /// The single owner of a contiguous raw-data write. Which storage that is
10921 /// — a block of this file, or the files an External File List names — is
10922 /// decided once, by [`DatasetInfo::contiguous_target`], and never at a
10923 /// call site.
10924 fn write_contiguous_bytes(
10925 &self,
10926 target: &ContiguousTarget,
10927 off: u64,
10928 data: &[u8],
10929 ) -> IoResult<()> {
10930 match target {
10931 ContiguousTarget::Local(addr) => Ok(self.handle.write_at(addr + off, data)?),
10932 ContiguousTarget::External { files, prefix } => {
10933 // The prefix the open settled, not one resolved here:
10934 // `H5D__efl_write` joins against `dset->shared->extfile_prefix`
10935 // (H5Defl.c:429-431), the same field `H5D__efl_read` joins
10936 // against, so a relative name lands where a later read looks.
10937 write_external_file_bytes(files, prefix.as_deref(), off, data)
10938 }
10939 ContiguousTarget::Virtual => Err(virtual_write_refused()),
10940 }
10941 }
10942
10943 /// Write raw bytes to a contiguous dataset identified by `index`.
10944 ///
10945 /// The caller is responsible for providing data in the correct byte order
10946 /// and layout. The length must match the total data size declared at
10947 /// creation time.
10948 pub fn write_dataset_raw(&self, index: usize, data: &[u8]) -> IoResult<()> {
10949 let ds = self.ds(index);
10950 let _op = ds.op.lock();
10951 let target = {
10952 let mut g = ds.lock();
10953 if g.is_chunked() {
10954 return Err(crate::io::IoError::InvalidState(
10955 "use write_chunk for chunked datasets".into(),
10956 ));
10957 }
10958 // A compact dataset's raw image is its layout message, so the
10959 // write lands in the buffer the header is built from rather than
10960 // at a file offset, and the header it is built into is now stale.
10961 if let Some(image) = g.compact.as_mut() {
10962 if data.len() != image.len() {
10963 return Err(crate::io::IoError::InvalidState(format!(
10964 "data size mismatch: expected {} bytes, got {}",
10965 image.len(),
10966 data.len()
10967 )));
10968 }
10969 image.copy_from_slice(data);
10970 g.header_dirty = true;
10971 return Ok(());
10972 }
10973 let Some(target) = g.contiguous_target() else {
10974 return Err(crate::io::IoError::InvalidState(
10975 "dataset has no data allocated".into(),
10976 ));
10977 };
10978 // A dataset that stores nothing of its own has no byte count to
10979 // check a write against — `write_contiguous_bytes` refuses it by
10980 // name below, which is the answer the caller needs.
10981 if target.is_storage() && data.len() as u64 != g.data_size {
10982 return Err(crate::io::IoError::InvalidState(format!(
10983 "data size mismatch: expected {} bytes, got {}",
10984 g.data_size,
10985 data.len()
10986 )));
10987 }
10988 target
10989 };
10990 self.write_contiguous_bytes(&target, 0, data)
10991 }
10992
10993 /// Write a chunk of data to a chunked dataset.
10994 ///
10995 /// `chunk_offset` is the chunk coordinates (e.g., [frame_idx] for a 1D-chunked
10996 /// streaming dataset where chunk_dims = [1, H, W]).
10997 /// Only the first (unlimited) dimension index is used for EA indexing.
10998 ///
10999 /// `data` must be exactly chunk_size bytes (product of chunk_dims * element_size).
11000 pub fn write_chunk(&self, index: usize, chunk_idx: u64, data: &[u8]) -> IoResult<()> {
11001 let ds = self.ds(index);
11002 let _op = ds.op.lock();
11003 self.write_chunk_inner(index, chunk_idx, data)
11004 }
11005
11006 /// [`Self::write_chunk`] body; the caller holds the dataset's op lock or
11007 /// the writer exclusively.
11008 pub(crate) fn write_chunk_inner(
11009 &self,
11010 index: usize,
11011 chunk_idx: u64,
11012 data: &[u8],
11013 ) -> IoResult<()> {
11014 let ds = self.ds(index);
11015 // Read the chunk geometry and filter pipeline under one brief lock,
11016 // then drop it: compression runs *outside* the lock, and
11017 // `record_ea_chunk` re-locks the same slot, so the guard must not be
11018 // held across either.
11019 let (chunk_bytes, pipeline) = {
11020 let g = ds.lock();
11021 let element_size = g.datatype.element_size() as u64;
11022 let chunked = g
11023 .chunked
11024 .as_ref()
11025 .ok_or_else(|| crate::io::IoError::InvalidState("not a chunked dataset".into()))?;
11026 (
11027 chunked.chunk_dims.iter().product::<u64>() * element_size,
11028 g.filter_pipeline.clone(),
11029 )
11030 };
11031
11032 if data.len() as u64 != chunk_bytes {
11033 return Err(crate::io::IoError::InvalidState(format!(
11034 "chunk data size mismatch: expected {} bytes, got {}",
11035 chunk_bytes,
11036 data.len()
11037 )));
11038 }
11039
11040 // Apply compression if filter pipeline is set
11041 let compressed;
11042 let write_data = if let Some(ref pipeline) = pipeline {
11043 compressed = filter::apply_filters(pipeline, data)?;
11044 &compressed
11045 } else {
11046 data
11047 };
11048 // filter_mask = 0: this path runs the whole pipeline, so no filter is
11049 // skipped for the chunk.
11050 self.record_ea_chunk(index, chunk_idx, write_data, 0)
11051 }
11052
11053 /// Decide where a chunk's bytes belong and put them there, returning the
11054 /// address to record in the index.
11055 ///
11056 /// `old` is the chunk's current `(address, stored length)` if the index
11057 /// already holds an entry for it. This is the single owner of the
11058 /// rewrite-placement rule, mirroring libhdf5's `H5D__chunk_file_alloc`
11059 /// (`H5Dchunk.c`): a chunk whose stored size is unchanged is overwritten
11060 /// where it already lives, and only a chunk that no longer fits moves,
11061 /// releasing its old block. Without this every rewrite would abandon the
11062 /// old block and grow the file.
11063 fn place_chunk(&self, old: Option<(u64, u64)>, new_len: u64) -> u64 {
11064 match old {
11065 // Same stored size: overwrite in place. This is every unfiltered
11066 // rewrite (the stored size is fixed by the chunk shape) and every
11067 // filtered rewrite that compressed to the same length.
11068 Some((addr, len)) if addr != UNDEF_ADDR && len == new_len => addr,
11069 Some((addr, len)) if addr != UNDEF_ADDR => {
11070 // The chunk has to move. Under SWMR a reader may still hold an
11071 // index that points at the old block, so libhdf5 keeps it
11072 // (H5D__chunk_file_alloc skips H5MF_xfree when the file is
11073 // open for SWMR writing); do the same.
11074 if !self.swmr_active {
11075 self.allocator.free(addr, len, FreeSpaceClass::RawData);
11076 }
11077 self.allocator.allocate(new_len, FreeSpaceClass::RawData)
11078 }
11079 _ => self.allocator.allocate(new_len, FreeSpaceClass::RawData),
11080 }
11081 }
11082
11083 /// Place a chunk's already-final bytes (filtered if the dataset is
11084 /// filtered) in the file and record them in the extensible-array index —
11085 /// in the index block, a data block, or a super block per the EA geometry.
11086 /// Shared by write_chunk and write_compressed_chunk.
11087 ///
11088 /// The index lookup happens *before* the bytes are placed, because the
11089 /// entry it finds is what tells [`place_chunk`](Self::place_chunk) whether
11090 /// this is a rewrite that can stay put.
11091 fn record_ea_chunk(
11092 &self,
11093 index: usize,
11094 chunk_idx: u64,
11095 final_bytes: &[u8],
11096 filter_mask: u32,
11097 ) -> IoResult<()> {
11098 let compressed_size = final_bytes.len() as u64;
11099 let ds = self.ds(index);
11100 // Hold one slot guard for the whole method: every dataset-state access
11101 // below goes through `m`, while `self.handle`/`self.allocator`/`self.ctx`
11102 // are disjoint fields safe to touch with the guard held.
11103 let mut m = ds.lock();
11104 let is_filtered = m.filter_pipeline.is_some();
11105 // For a filtered dataset the chunk's stored size is encoded in the
11106 // `chunk_size_len`-byte field of each filtered EA entry
11107 // (`FilteredChunkEntry::encode` writes `nbytes[..chunk_size_len]`,
11108 // which truncates silently). Reject a size that would not fit, the way
11109 // libhdf5's H5D_CHUNK_ENCODE_SIZE_CHECK does, instead of corrupting the
11110 // index. The compress path never exceeds this (chunk_size_len holds the
11111 // uncompressed chunk size); a direct/raw write with caller-supplied
11112 // bytes can.
11113 if is_filtered {
11114 let chunk_size_len = m.chunked.as_ref().unwrap().chunk_size_len as usize;
11115 if chunk_size_len < 8 && compressed_size >= (1u64 << (chunk_size_len * 8)) {
11116 return Err(crate::io::IoError::InvalidState(format!(
11117 "filtered chunk size {compressed_size} does not fit in the \
11118 {chunk_size_len}-byte extensible-array chunk-size field"
11119 )));
11120 }
11121 }
11122 let idx_blk_elmts = {
11123 let c = m.chunked.as_ref().unwrap();
11124 c.earray_params.idx_blk_elmts as u64
11125 };
11126
11127 if chunk_idx < idx_blk_elmts {
11128 let chunked = m.chunked.as_mut().unwrap();
11129 if is_filtered {
11130 if let Some(ref mut fiblk) = chunked.filt_iblk {
11131 let old = fiblk.elements[chunk_idx as usize];
11132 let chunk_addr =
11133 self.place_chunk(Some((old.addr, old.nbytes)), compressed_size);
11134 self.handle.write_at(chunk_addr, final_bytes)?;
11135 fiblk.elements[chunk_idx as usize] = FilteredChunkEntry {
11136 addr: chunk_addr,
11137 nbytes: compressed_size,
11138 filter_mask,
11139 };
11140 }
11141 } else {
11142 // An unfiltered chunk's stored size is fixed by the chunk
11143 // shape, so a rewrite always fits where it already is.
11144 let old = chunked.ea_iblk.elements[chunk_idx as usize];
11145 let chunk_addr = self.place_chunk(Some((old, compressed_size)), compressed_size);
11146 self.handle.write_at(chunk_addr, final_bytes)?;
11147 chunked.ea_iblk.elements[chunk_idx as usize] = chunk_addr;
11148 }
11149 chunked.chunks_written += 1;
11150 if chunk_idx + 1 > chunked.ea_header.max_idx_set {
11151 chunked.ea_header.max_idx_set = chunk_idx + 1;
11152 }
11153 if chunked.ea_header.num_elmts_realized < idx_blk_elmts {
11154 chunked.ea_header.num_elmts_realized = idx_blk_elmts;
11155 }
11156 } else {
11157 // chunk_idx >= idx_blk_elmts: place the chunk through the EA
11158 // data-block / super-block hierarchy (libhdf5-compatible geometry).
11159 let (geo, max_nelmts_bits, chunk_size_len, ea_header_addr) = {
11160 let c = m.chunked.as_ref().unwrap();
11161 let p = &c.earray_params;
11162 (
11163 EaGeometry::new(
11164 p.idx_blk_elmts,
11165 p.data_blk_min_elmts,
11166 p.sup_blk_min_data_ptrs,
11167 p.max_nelmts_bits,
11168 p.max_dblk_page_nelmts_bits,
11169 )?,
11170 p.max_nelmts_bits,
11171 c.chunk_size_len,
11172 c.ea_header_addr,
11173 )
11174 };
11175 let loc = match geo.locate(chunk_idx)? {
11176 EaLoc::Dblk(l) => l,
11177 EaLoc::Index { .. } => unreachable!("chunk_idx >= idx_blk_elmts"),
11178 };
11179 if loc.paged {
11180 return Err(crate::io::IoError::InvalidState(format!(
11181 "chunk index {} needs a paged extensible-array data block, \
11182 which is not yet supported",
11183 chunk_idx
11184 )));
11185 }
11186 let class_id = if is_filtered {
11187 EA_CLS_FILT_CHUNK
11188 } else {
11189 EA_CLS_CHUNK
11190 };
11191 let dblk_nelmts = loc.dblk_nelmts as usize;
11192
11193 // Resolve the data block's current address and its parent slot,
11194 // creating the owning super block on demand.
11195 let parent: DblkParent;
11196 let mut dblk_addr: u64;
11197 match loc.path {
11198 EaDblkPath::Direct { idx: di } => {
11199 let c = m.chunked.as_ref().unwrap();
11200 dblk_addr = if is_filtered {
11201 c.filt_iblk.as_ref().unwrap().dblk_addrs[di]
11202 } else {
11203 c.ea_iblk.dblk_addrs[di]
11204 };
11205 parent = DblkParent::IndexBlock(di);
11206 }
11207 EaDblkPath::ViaSblk {
11208 sblk_off,
11209 local_dblk,
11210 ndblks_in_sblk,
11211 sblk_block_offset,
11212 } => {
11213 let mut sblk_addr = {
11214 let c = m.chunked.as_ref().unwrap();
11215 if is_filtered {
11216 c.filt_iblk.as_ref().unwrap().sblk_addrs[sblk_off]
11217 } else {
11218 c.ea_iblk.sblk_addrs[sblk_off]
11219 }
11220 };
11221 if sblk_addr == UNDEF_ADDR {
11222 let sb = ExtensibleArraySuperBlock::new(
11223 class_id,
11224 ea_header_addr,
11225 sblk_block_offset,
11226 ndblks_in_sblk,
11227 );
11228 let enc = sb.encode(&self.ctx, max_nelmts_bits);
11229 sblk_addr = self
11230 .allocator
11231 .allocate(enc.len() as u64, FreeSpaceClass::Metadata);
11232 self.handle.write_at(sblk_addr, &enc)?;
11233 let c = m.chunked.as_mut().unwrap();
11234 if is_filtered {
11235 c.filt_iblk.as_mut().unwrap().sblk_addrs[sblk_off] = sblk_addr;
11236 } else {
11237 c.ea_iblk.sblk_addrs[sblk_off] = sblk_addr;
11238 }
11239 c.ea_header.num_sblks_created += 1;
11240 c.ea_header.size_sblks_created += enc.len() as u64;
11241 }
11242 let sb_buf = self.handle.read_at_most(sblk_addr, 65536)?;
11243 // The writer never creates paged super blocks (it errors
11244 // before the paging threshold), so page_init_total is 0.
11245 let sb = ExtensibleArraySuperBlock::decode(
11246 &sb_buf,
11247 &self.ctx,
11248 max_nelmts_bits,
11249 ndblks_in_sblk,
11250 0,
11251 )?;
11252 dblk_addr = sb.dblk_addrs[local_dblk];
11253 parent = DblkParent::SuperBlock {
11254 sblk_addr,
11255 ndblks_in_sblk,
11256 local_dblk,
11257 };
11258 }
11259 }
11260
11261 // Create or update the data block holding this chunk's entry.
11262 let created = dblk_addr == UNDEF_ADDR;
11263 if is_filtered {
11264 let mut dblk = if created {
11265 FilteredDataBlock::new(ea_header_addr, loc.dblk_block_offset, dblk_nelmts)
11266 } else {
11267 let buf = self.handle.read_at_most(dblk_addr, 65536)?;
11268 FilteredDataBlock::decode(
11269 &buf,
11270 &self.ctx,
11271 max_nelmts_bits,
11272 dblk_nelmts,
11273 chunk_size_len,
11274 )?
11275 };
11276 // A freshly created data block holds only undefined addresses,
11277 // so this reads as "no previous chunk" without a special case.
11278 let old = dblk.elements[loc.offset_in_dblk as usize];
11279 let chunk_addr = self.place_chunk(Some((old.addr, old.nbytes)), compressed_size);
11280 self.handle.write_at(chunk_addr, final_bytes)?;
11281 let entry = FilteredChunkEntry {
11282 addr: chunk_addr,
11283 nbytes: compressed_size,
11284 filter_mask,
11285 };
11286 dblk.elements[loc.offset_in_dblk as usize] = entry;
11287 let enc = dblk.encode(&self.ctx, max_nelmts_bits, chunk_size_len);
11288 if created {
11289 dblk_addr = self
11290 .allocator
11291 .allocate(enc.len() as u64, FreeSpaceClass::Metadata);
11292 }
11293 self.handle.write_at(dblk_addr, &enc)?;
11294 if created {
11295 let c = m.chunked.as_mut().unwrap();
11296 c.ea_header.num_dblks_created += 1;
11297 c.ea_header.size_dblks_created += enc.len() as u64;
11298 }
11299 } else {
11300 let mut dblk = if created {
11301 ExtensibleArrayDataBlock::new(
11302 ea_header_addr,
11303 loc.dblk_block_offset,
11304 dblk_nelmts,
11305 )
11306 } else {
11307 let buf = self.handle.read_at_most(dblk_addr, 65536)?;
11308 ExtensibleArrayDataBlock::decode(&buf, &self.ctx, max_nelmts_bits, dblk_nelmts)?
11309 };
11310 // Unfiltered: the stored size is fixed by the chunk shape, so
11311 // a rewrite always fits its old block. A freshly created data
11312 // block holds undefined addresses and falls through to a new
11313 // allocation.
11314 let old = dblk.elements[loc.offset_in_dblk as usize];
11315 let chunk_addr = self.place_chunk(Some((old, compressed_size)), compressed_size);
11316 self.handle.write_at(chunk_addr, final_bytes)?;
11317 dblk.elements[loc.offset_in_dblk as usize] = chunk_addr;
11318 let enc = dblk.encode(&self.ctx, max_nelmts_bits);
11319 if created {
11320 dblk_addr = self
11321 .allocator
11322 .allocate(enc.len() as u64, FreeSpaceClass::Metadata);
11323 }
11324 self.handle.write_at(dblk_addr, &enc)?;
11325 if created {
11326 let c = m.chunked.as_mut().unwrap();
11327 c.ea_header.num_dblks_created += 1;
11328 c.ea_header.size_dblks_created += enc.len() as u64;
11329 }
11330 }
11331
11332 // Record a newly-created data block's address in its parent.
11333 if created {
11334 match parent {
11335 DblkParent::IndexBlock(di) => {
11336 let c = m.chunked.as_mut().unwrap();
11337 if is_filtered {
11338 c.filt_iblk.as_mut().unwrap().dblk_addrs[di] = dblk_addr;
11339 } else {
11340 c.ea_iblk.dblk_addrs[di] = dblk_addr;
11341 }
11342 }
11343 DblkParent::SuperBlock {
11344 sblk_addr,
11345 ndblks_in_sblk,
11346 local_dblk,
11347 } => {
11348 let buf = self.handle.read_at_most(sblk_addr, 65536)?;
11349 let mut sb = ExtensibleArraySuperBlock::decode(
11350 &buf,
11351 &self.ctx,
11352 max_nelmts_bits,
11353 ndblks_in_sblk,
11354 0,
11355 )?;
11356 sb.dblk_addrs[local_dblk] = dblk_addr;
11357 let enc = sb.encode(&self.ctx, max_nelmts_bits);
11358 self.handle.write_at(sblk_addr, &enc)?;
11359 }
11360 }
11361 }
11362
11363 // Statistics.
11364 let c = m.chunked.as_mut().unwrap();
11365 c.chunks_written += 1;
11366 if chunk_idx + 1 > c.ea_header.max_idx_set {
11367 c.ea_header.max_idx_set = chunk_idx + 1;
11368 }
11369 if created {
11370 c.ea_header.num_elmts_realized += loc.dblk_nelmts;
11371 }
11372 }
11373 Ok(())
11374 }
11375
11376 /// Write a slice (hyperslab) of data to a dataset, contiguous or chunked.
11377 ///
11378 /// `starts` and `counts` define the N-dimensional selection.
11379 /// `data` must be exactly `product(counts) * element_size` bytes.
11380 ///
11381 /// The selection is validated once here and then handed to the layout's
11382 /// own writer, so a caller never has to know which storage the dataset
11383 /// uses.
11384 pub fn write_slice(
11385 &self,
11386 index: usize,
11387 starts: &[u64],
11388 counts: &[u64],
11389 data: &[u8],
11390 ) -> IoResult<()> {
11391 let ds = self.ds(index);
11392 let _op = ds.op.lock();
11393 self.write_slice_inner(index, starts, counts, data)
11394 }
11395
11396 /// [`Self::write_slice`] body; the caller holds the dataset's op lock or
11397 /// the writer exclusively.
11398 pub(crate) fn write_slice_inner(
11399 &self,
11400 index: usize,
11401 starts: &[u64],
11402 counts: &[u64],
11403 data: &[u8],
11404 ) -> IoResult<()> {
11405 let ds_ref = self.ds(index);
11406 let ds = ds_ref.lock();
11407 let is_chunked = ds.is_chunked();
11408
11409 let dims = &ds.dataspace.dims;
11410 let element_size = ds.datatype.element_size() as u64;
11411 let ndims = dims.len();
11412
11413 // Every hyperslab edge must stay inside the dataset; without this an
11414 // out-of-bounds selection writes raw bytes over neighbouring data.
11415 check_hyperslab(dims, starts, counts)?;
11416 if ndims == 0 {
11417 return Err(crate::io::IoError::InvalidState(
11418 "write_slice does not support scalar datasets; use write_dataset_raw".into(),
11419 ));
11420 }
11421
11422 let out_elems: u64 = counts.iter().product();
11423 if data.len() as u64 != out_elems * element_size {
11424 return Err(crate::io::IoError::InvalidState(format!(
11425 "data size mismatch: expected {} bytes, got {}",
11426 out_elems * element_size,
11427 data.len()
11428 )));
11429 }
11430
11431 // `dims` borrows the dataset slot; collect what the writers below need
11432 // so the guard can be dropped before they re-lock it.
11433 let dims = dims.clone();
11434 let target = ds.contiguous_target();
11435 drop(ds);
11436
11437 if is_chunked {
11438 // Rows the append buffer holds are not in the chunks yet; writing
11439 // them there anyway would be undone when the buffer flushes at
11440 // close. Hand them to the chunks first.
11441 self.flush_append_buffer_if_intersecting(index, starts[0], starts[0] + counts[0])?;
11442 return self.write_slice_chunked(index, starts, counts, data);
11443 }
11444 let Some(target) = target else {
11445 return Err(crate::io::IoError::InvalidState(
11446 "dataset has no data allocated".into(),
11447 ));
11448 };
11449
11450 // Write each maximal contiguous run in one write. Trailing
11451 // full-selected dimensions coalesce, mirroring the read path: a slice
11452 // with a full last axis becomes one write per outer index instead of
11453 // one write per last-axis row.
11454 for_each_contiguous_run(
11455 &dims,
11456 starts,
11457 counts,
11458 element_size,
11459 |dst_off, src_off, len| {
11460 self.write_contiguous_bytes(&target, dst_off, &data[src_off..src_off + len])
11461 },
11462 )?;
11463
11464 Ok(())
11465 }
11466
11467 /// Write a hyperslab into a chunked dataset, one chunk at a time.
11468 ///
11469 /// The selection is already validated by [`write_slice`](Self::write_slice).
11470 /// For each chunk the selection touches, the chunk's share of `data` is
11471 /// scattered into a whole-chunk buffer and the chunk is rewritten:
11472 ///
11473 /// - a chunk the selection covers completely is built from `data` alone —
11474 /// nothing needs reading back (libhdf5 takes the same shortcut with the
11475 /// `relax` flag of `H5D__chunk_lock`);
11476 /// - a chunk covered only in part starts from what is already stored, or
11477 /// from a fill-value buffer when the chunk has never been written, so
11478 /// neighbouring elements survive and untouched ones read as fill.
11479 ///
11480 /// An edge chunk that hangs past the dataset extent is always the partial
11481 /// case, so the region beyond the extent keeps its fill value.
11482 fn write_slice_chunked(
11483 &self,
11484 index: usize,
11485 starts: &[u64],
11486 counts: &[u64],
11487 data: &[u8],
11488 ) -> IoResult<()> {
11489 if counts.contains(&0) {
11490 return Ok(());
11491 }
11492 let geo = self.chunk_geometry(index)?;
11493 let ndims = geo.dims.len();
11494 if geo.chunk_dims.len() != ndims {
11495 return Err(crate::io::IoError::InvalidState(format!(
11496 "dataset chunk shape has {} dimensions but the dataspace has {}",
11497 geo.chunk_dims.len(),
11498 ndims
11499 )));
11500 }
11501 if geo.chunk_dims.contains(&0) {
11502 return Err(crate::io::IoError::InvalidState(
11503 "chunk shape has a zero-length dimension".into(),
11504 ));
11505 }
11506 let chunk_bytes = geo.chunk_bytes() as usize;
11507
11508 // Grid range the selection touches, inclusive on both ends.
11509 let first: Vec<u64> = (0..ndims).map(|d| starts[d] / geo.chunk_dims[d]).collect();
11510 let last: Vec<u64> = (0..ndims)
11511 .map(|d| (starts[d] + counts[d] - 1) / geo.chunk_dims[d])
11512 .collect();
11513
11514 let mut coords = first.clone();
11515 loop {
11516 // Intersect the selection with this chunk. `in_chunk` is the
11517 // region's origin inside the chunk, `in_data` its origin inside
11518 // the caller's counts-shaped buffer, `extent` its size.
11519 let mut in_chunk = vec![0u64; ndims];
11520 let mut in_data = vec![0u64; ndims];
11521 let mut extent = vec![0u64; ndims];
11522 let mut covers_whole_chunk = true;
11523 for d in 0..ndims {
11524 let chunk_origin = coords[d] * geo.chunk_dims[d];
11525 let lo = starts[d].max(chunk_origin);
11526 let hi = (starts[d] + counts[d]).min(chunk_origin + geo.chunk_dims[d]);
11527 in_chunk[d] = lo - chunk_origin;
11528 in_data[d] = lo - starts[d];
11529 extent[d] = hi - lo;
11530 if in_chunk[d] != 0 || extent[d] != geo.chunk_dims[d] {
11531 covers_whole_chunk = false;
11532 }
11533 }
11534
11535 let mut buf = if covers_whole_chunk {
11536 // Every byte is overwritten below.
11537 vec![0u8; chunk_bytes]
11538 } else {
11539 match self.read_chunk_at_coords(index, &coords)? {
11540 Some(existing) => {
11541 if existing.len() != chunk_bytes {
11542 return Err(crate::io::IoError::InvalidState(format!(
11543 "stored chunk at {coords:?} is {} bytes but the chunk shape \
11544 needs {chunk_bytes}",
11545 existing.len()
11546 )));
11547 }
11548 existing
11549 }
11550 None => self.new_write_chunk_buffer(index, chunk_bytes),
11551 }
11552 };
11553
11554 for_each_dual_run(
11555 &geo.chunk_dims,
11556 &in_chunk,
11557 counts,
11558 &in_data,
11559 &extent,
11560 geo.element_size,
11561 |dst_off, src_off, len| {
11562 let dst = dst_off as usize;
11563 let src = src_off as usize;
11564 buf[dst..dst + len].copy_from_slice(&data[src..src + len]);
11565 Ok(())
11566 },
11567 )?;
11568 self.write_chunk_at_coords(index, &coords, &buf)?;
11569
11570 // Odometer over the touched grid range.
11571 let mut d = ndims;
11572 loop {
11573 if d == 0 {
11574 return Ok(());
11575 }
11576 d -= 1;
11577 if coords[d] < last[d] {
11578 coords[d] += 1;
11579 break;
11580 }
11581 coords[d] = first[d];
11582 }
11583 }
11584 }
11585
11586 /// Add an attribute to the root group (file-level attribute), replacing
11587 /// a same-name attribute. See [`set_attribute`](Self::set_attribute).
11588 pub fn add_root_attribute(&self, attr: AttributeMessage) -> IoResult<()> {
11589 self.set_attribute(AttrTarget::Root, attr)
11590 }
11591
11592 /// Insert `attr` into the attribute list `target` names, replacing a
11593 /// same-name attribute.
11594 ///
11595 /// The single owner of attribute-list mutation: an `AttributeMessage`
11596 /// that leaves a list here has its vlen global-heap objects released, so
11597 /// no replacement — vlen over vlen, numeric over vlen — can strand heap
11598 /// space (the attribute counterpart of issue #10's dataset fix).
11599 ///
11600 /// Under SWMR every attribute mutation is refused, matching libhdf5's
11601 /// rule for SWMR writes. Object headers are frozen once streaming
11602 /// starts — a change was committed at close only when the header
11603 /// happened to be rebuilt (group attrs always, dataset attrs only if
11604 /// the dataset also got chunk writes) and silently dropped otherwise —
11605 /// and a replacement's superseded vlen value could never be reclaimed,
11606 /// since a streaming reader may hold its heap references.
11607 pub fn set_attribute(&self, target: AttrTarget<'_>, attr: AttributeMessage) -> IoResult<()> {
11608 self.insert_attribute(target, attr, Created)
11609 }
11610
11611 /// The body of [`set_attribute`](Self::set_attribute), told whether the
11612 /// attribute it is inserting is genuinely new — see [`AttrOrigin`].
11613 fn insert_attribute(
11614 &self,
11615 target: AttrTarget<'_>,
11616 attr: AttributeMessage,
11617 origin: AttrOrigin,
11618 ) -> IoResult<()> {
11619 if self.swmr_active {
11620 return Err(swmr_attr_error(&attr.name));
11621 }
11622 // Whatever this name meant before, it means the incoming message now.
11623 self.forget_attribute_reference(self.attr_scope(target)?, &attr.name);
11624 // No size gate: an attribute whose message is too large for the
11625 // 16-bit size field an object header message has spills the object's
11626 // whole attribute set to dense storage at finalize, exactly as
11627 // `H5O__attr_create` does. See `attributes_need_dense`.
11628 let mut entry = AttributeEntry::from(attr);
11629 let old = self.with_attr_list(target, |attrs| {
11630 if let Some(pos) = attrs.iter().position(|a| a.name() == entry.name()) {
11631 // `H5O__attr_write` replaces an existing attribute's value and
11632 // leaves its `crt_idx` alone: the attribute was not created
11633 // again, so its creation index does not move.
11634 entry.set_creation_index(attrs[pos].creation_index());
11635 Some(std::mem::replace(&mut attrs[pos], entry))
11636 } else {
11637 // `H5O__attr_create` stamps the set's running maximum onto the
11638 // new attribute and post-increments it — but only a create
11639 // reaches for it.
11640 entry.set_creation_index(match origin {
11641 Created => Some(next_creation_index(attrs)),
11642 Rewritten(kept) => kept,
11643 });
11644 attrs.push(entry);
11645 None
11646 }
11647 })?;
11648 match old {
11649 Some(old) => self.release_attr_vlen(&old),
11650 None => Ok(()),
11651 }
11652 }
11653
11654 /// Set a variable-length string attribute on `target`, replacing any
11655 /// same-name attribute.
11656 ///
11657 /// Owns the whole replacement sequence: the superseded attribute is
11658 /// removed and its heap objects released *before* the new value's
11659 /// collection is allocated — the free-before-alloc order (issue #10)
11660 /// that lets a reopen-replace loop land in the block it just freed
11661 /// instead of growing the file every session. The cost, as on the
11662 /// dataset path: a failure between the eviction and the insert below
11663 /// loses the attribute rather than leaking its heap space.
11664 pub fn set_vlen_string_attribute(
11665 &self,
11666 target: AttrTarget<'_>,
11667 name: &str,
11668 value: &str,
11669 ) -> IoResult<()> {
11670 let origin = self.evict_attr(target, name)?;
11671 let attr = self.vlen_string_attribute(name, value)?;
11672 self.insert_attribute(target, attr, origin)
11673 }
11674
11675 /// The array counterpart of
11676 /// [`set_vlen_string_attribute`](Self::set_vlen_string_attribute).
11677 pub fn set_vlen_string_array_attribute(
11678 &self,
11679 target: AttrTarget<'_>,
11680 name: &str,
11681 values: &[&str],
11682 dims: &[u64],
11683 ) -> IoResult<()> {
11684 let origin = self.evict_attr(target, name)?;
11685 let attr = self.vlen_string_array_attribute(name, values, dims)?;
11686 self.insert_attribute(target, attr, origin)
11687 }
11688
11689 /// Set an attribute on `target` whose value is the object references
11690 /// naming `paths` — h5py's `obj.attrs['ref'] = f['/target'].ref`.
11691 ///
11692 /// `dims` is the attribute's dataspace: empty for the scalar shape a
11693 /// single reference takes, `&[n]` for an array of them. Each path names a
11694 /// dataset or a group (`/` is the root group) and must already exist. What
11695 /// reaches the file is each target's object header address, which finalize
11696 /// assigns — so the paths are what is stored, and the attribute's message
11697 /// is built from them every time an object header is
11698 /// ([`object_attributes`](Self::object_attributes)). The message carries a
11699 /// zero image of the final width until then.
11700 pub fn set_object_reference_attribute(
11701 &self,
11702 target: AttrTarget<'_>,
11703 name: &str,
11704 paths: &[&str],
11705 dims: &[u64],
11706 ) -> IoResult<()> {
11707 let scope = self.attr_scope(target)?;
11708 // An empty `dims` is the scalar shape, whose one element the empty
11709 // product already reports.
11710 let elements: u64 = dims.iter().product();
11711 if elements != paths.len() as u64 {
11712 return Err(crate::io::IoError::InvalidState(format!(
11713 "attribute '{name}' shape {dims:?} needs {elements} references, got {}",
11714 paths.len()
11715 )));
11716 }
11717 // Resolve now as well as at finalize, so a path that names nothing is
11718 // reported at the call that got it wrong.
11719 for path in paths {
11720 self.object_reference_target(path)?;
11721 }
11722 let datatype = DatatypeMessage::object_reference(&self.ctx);
11723 let image = vec![0u8; paths.len() * datatype.element_size() as usize];
11724 let attr = if dims.is_empty() {
11725 AttributeMessage::scalar_numeric(name, datatype, image)
11726 } else {
11727 AttributeMessage::array_numeric(name, datatype, dims, image)
11728 };
11729 // Through the same owner as every other attribute, which is also what
11730 // drops any value this name carried before.
11731 self.set_attribute(target, attr)?;
11732 self.attribute_references
11733 .lock()
11734 .push(AttributeReferenceValue {
11735 scope,
11736 name: name.to_string(),
11737 targets: paths.iter().map(|p| (*p).to_string()).collect(),
11738 stride: self.ctx.sizeof_addr as usize,
11739 });
11740 Ok(())
11741 }
11742
11743 // -----------------------------------------------------------------------
11744 // Dimension scales — the H5DS high-level API (hl/src/H5DS.c)
11745 // -----------------------------------------------------------------------
11746
11747 /// Mark dataset `dsid` as a dimension scale — `H5DSset_scale`.
11748 ///
11749 /// Writes `CLASS` as the fixed-length null-terminated ASCII string
11750 /// `DIMENSION_SCALE` and, when `name` is given, `NAME` the same way: the
11751 /// `H5LT_set_attribute_string` form, one byte longer than the text so the
11752 /// terminator is stored, which is what `H5DSis_scale` requires of a scale
11753 /// (a 16-byte null-terminated `CLASS`). Either attribute already there is
11754 /// deleted and created anew, as `H5LT_set_attribute_string` does, so it
11755 /// takes a fresh creation index. A dataset with scales of its own
11756 /// (`DIMENSION_LIST`) is refused, as upstream refuses it.
11757 pub fn set_dimension_scale(&self, dsid: usize, name: Option<&str>) -> IoResult<()> {
11758 let scale_path = self.dataset_name(dsid)?;
11759 if self.dataset_attribute(dsid, DIMENSION_LIST)?.is_some() {
11760 return Err(crate::io::IoError::InvalidState(format!(
11761 "dataset '{scale_path}' has dimension scales attached and cannot become one"
11762 )));
11763 }
11764 self.set_fixed_string_attribute(dsid, "CLASS", DIMENSION_SCALE_CLASS)?;
11765 if let Some(name) = name {
11766 self.set_fixed_string_attribute(dsid, "NAME", name)?;
11767 }
11768 Ok(())
11769 }
11770
11771 /// Attach dataset `dsid` as a dimension scale of axis `idx` of dataset
11772 /// `did` — `H5DSattach_scale`.
11773 ///
11774 /// Two attributes record the attachment: `DIMENSION_LIST` on `did`, one
11775 /// variable-length sequence of object references per axis (a scalar
11776 /// dataset counts as rank 1), and `REFERENCE_LIST` on `dsid`, an array
11777 /// of `{dataset: H5T_STD_REF_OBJ, dimension: uint}` compounds naming
11778 /// every (dataset, axis) the scale is attached to. `dsid` is then made a
11779 /// scale if it is not one already ([`set_dimension_scale`] with no name).
11780 /// Both lists are rewritten whole; what an existing list holds is read
11781 /// back as paths (registered this session, or resolved from the file's
11782 /// addresses), so an attach in an append session keeps earlier
11783 /// attachments and every reference is stamped with the address its
11784 /// target ends up at.
11785 ///
11786 /// Refused, as upstream refuses them: `did == dsid`; a `did` that is a
11787 /// scale or carries a reserved `CLASS` (`IMAGE`, `PALETTE`, `TABLE`); a
11788 /// `dsid` that has scales of its own; an axis beyond `did`'s rank.
11789 ///
11790 /// Attaching a scale already attached to that axis changes nothing. This
11791 /// is stricter than upstream, which leaves `DIMENSION_LIST` as it is but
11792 /// still appends a duplicate `REFERENCE_LIST` entry; a second entry for
11793 /// the same (dataset, axis) tells `H5DSis_attached` nothing the first
11794 /// does not.
11795 ///
11796 /// [`set_dimension_scale`]: Self::set_dimension_scale
11797 pub fn attach_dimension_scale(&self, did: usize, dsid: usize, idx: usize) -> IoResult<()> {
11798 let data_path = self.dataset_name(did)?;
11799 let scale_path = self.dataset_name(dsid)?;
11800 if did == dsid {
11801 return Err(crate::io::IoError::InvalidState(format!(
11802 "dataset '{data_path}' cannot be its own dimension scale"
11803 )));
11804 }
11805 if self.is_dimension_scale(did)? {
11806 return Err(crate::io::IoError::InvalidState(format!(
11807 "dataset '{data_path}' is a dimension scale and cannot have scales attached"
11808 )));
11809 }
11810 if self.dataset_attribute(dsid, DIMENSION_LIST)?.is_some() {
11811 return Err(crate::io::IoError::InvalidState(format!(
11812 "dataset '{scale_path}' has dimension scales attached and cannot be one"
11813 )));
11814 }
11815 if self.has_reserved_class(did)? {
11816 return Err(crate::io::IoError::InvalidState(format!(
11817 "dataset '{data_path}' holds an image, palette or table and cannot have \
11818 dimension scales"
11819 )));
11820 }
11821 let rank = self.ds(did).lock().dataspace.dims.len().max(1);
11822 if idx >= rank {
11823 return Err(crate::io::IoError::InvalidState(format!(
11824 "axis {idx} is out of range for the rank-{rank} dataset '{data_path}'"
11825 )));
11826 }
11827
11828 let mut lists = match self.dimension_list(did)? {
11829 Some(lists) => lists,
11830 None => vec![Vec::new(); rank],
11831 };
11832 if lists.len() != rank {
11833 return Err(crate::io::IoError::InvalidState(format!(
11834 "DIMENSION_LIST of '{data_path}' has {} entries for a rank-{rank} dataset",
11835 lists.len()
11836 )));
11837 }
11838 if lists[idx].contains(&scale_path) {
11839 return Ok(());
11840 }
11841 lists[idx].push(scale_path);
11842 self.write_dimension_list(did, &lists)?;
11843
11844 let mut entries = self.reference_list(dsid)?;
11845 entries.push((data_path, idx as u32));
11846 self.write_reference_list(dsid, &entries)?;
11847
11848 if !self.is_dimension_scale(dsid)? {
11849 self.set_dimension_scale(dsid, None)?;
11850 }
11851 Ok(())
11852 }
11853
11854 /// The registry name of live dataset `index`, or why there is none.
11855 fn dataset_name(&self, index: usize) -> IoResult<String> {
11856 let count = self.dataset_count();
11857 if index >= count {
11858 return Err(crate::io::IoError::InvalidState(format!(
11859 "dataset index {index} out of range (have {count})"
11860 )));
11861 }
11862 let ds = self.ds(index);
11863 let m = ds.lock();
11864 if m.deleted {
11865 return Err(crate::io::IoError::NotFound(format!(
11866 "dataset '{}' has been deleted",
11867 m.name
11868 )));
11869 }
11870 Ok(m.name.clone())
11871 }
11872
11873 /// The stored attribute `name` of dataset `index`, without marking the
11874 /// header dirty the way [`with_attr_list`](Self::with_attr_list) must.
11875 fn dataset_attribute(&self, index: usize, name: &str) -> IoResult<Option<AttributeEntry>> {
11876 self.dataset_name(index)?;
11877 Ok(self
11878 .ds(index)
11879 .lock()
11880 .attributes
11881 .iter()
11882 .find(|a| a.name() == name)
11883 .cloned())
11884 }
11885
11886 /// Write the scalar fixed-length string attribute `name` = `value` on
11887 /// dataset `index` — `H5LT_set_attribute_string`: the string is stored
11888 /// null-terminated in `strlen + 1` bytes, and an attribute of that name
11889 /// is deleted first rather than written over.
11890 fn set_fixed_string_attribute(&self, index: usize, name: &str, value: &str) -> IoResult<()> {
11891 if value.as_bytes().contains(&0) {
11892 return Err(crate::io::IoError::InvalidState(format!(
11893 "attribute '{name}' value holds an interior NUL"
11894 )));
11895 }
11896 let size = u32::try_from(value.len() + 1).map_err(|_| {
11897 crate::io::IoError::InvalidState(format!(
11898 "attribute '{name}' value of {} bytes exceeds the fixed-string width field",
11899 value.len()
11900 ))
11901 })?;
11902 let mut data = value.as_bytes().to_vec();
11903 data.push(0);
11904 let attr =
11905 AttributeMessage::scalar_numeric(name, DatatypeMessage::fixed_string(size), data);
11906 let target = AttrTarget::Dataset(index);
11907 self.evict_attr(target, name)?;
11908 self.insert_attribute(target, attr, Created)
11909 }
11910
11911 /// The `CLASS` attribute of dataset `index`, read the way `H5DS` reads
11912 /// it: as a C string, up to the first NUL.
11913 fn class_attribute(&self, index: usize) -> IoResult<Option<ClassAttr>> {
11914 use crate::format::global_heap::decode_vlen_reference;
11915
11916 let Some(entry) = self.dataset_attribute(index, "CLASS")? else {
11917 return Ok(None);
11918 };
11919 let msg = entry.decoded().map_err(|reason| {
11920 crate::io::IoError::InvalidState(format!(
11921 "CLASS attribute of '{}' cannot be decoded: {reason}",
11922 self.ds(index).lock().name
11923 ))
11924 })?;
11925 Ok(Some(match &msg.datatype {
11926 DatatypeMessage::FixedString { size, padding, .. } => {
11927 let avail = (*size as usize).min(msg.data.len());
11928 ClassAttr::Fixed {
11929 size: *size,
11930 null_terminated: *padding == 0,
11931 text: c_string(&msg.data[..avail]),
11932 }
11933 }
11934 DatatypeMessage::VarLenString { .. } => {
11935 let (_, addr, obj_idx) = decode_vlen_reference(&msg.data, &self.ctx)?;
11936 let bytes = if addr == 0 || addr == UNDEF_ADDR {
11937 Vec::new()
11938 } else {
11939 let obj_idx = u16::try_from(obj_idx).map_err(|_| {
11940 crate::io::IoError::InvalidState(format!(
11941 "global heap object index {obj_idx} does not fit the 16-bit on-disk \
11942 field"
11943 ))
11944 })?;
11945 self.read_heap_object(addr, obj_idx)?
11946 };
11947 ClassAttr::VarLen(c_string(&bytes))
11948 }
11949 _ => ClassAttr::NotString,
11950 }))
11951 }
11952
11953 /// `H5DSis_scale`: a `CLASS` that is a string saying `DIMENSION_SCALE` —
11954 /// and, for a fixed-length string, null-terminated and exactly 16 bytes
11955 /// wide, the width the spec gives the attribute.
11956 fn is_dimension_scale(&self, index: usize) -> IoResult<bool> {
11957 Ok(match self.class_attribute(index)? {
11958 None | Some(ClassAttr::NotString) => false,
11959 Some(ClassAttr::Fixed {
11960 size,
11961 null_terminated,
11962 text,
11963 }) => null_terminated && size == 16 && text == DIMENSION_SCALE_CLASS,
11964 Some(ClassAttr::VarLen(text)) => text == DIMENSION_SCALE_CLASS,
11965 })
11966 }
11967
11968 /// `H5DS_is_reserved`: a `CLASS` naming an image, palette or table — the
11969 /// datasets the other high-level APIs own. A `CLASS` that is not a string
11970 /// is an error here, where [`is_dimension_scale`](Self::is_dimension_scale)
11971 /// reads it as "not a scale", because that is how upstream splits them.
11972 fn has_reserved_class(&self, index: usize) -> IoResult<bool> {
11973 Ok(match self.class_attribute(index)? {
11974 None => false,
11975 Some(ClassAttr::NotString) => {
11976 return Err(crate::io::IoError::InvalidState(format!(
11977 "CLASS attribute of '{}' is not a string",
11978 self.ds(index).lock().name
11979 )))
11980 }
11981 Some(ClassAttr::Fixed { text, .. }) | Some(ClassAttr::VarLen(text)) => {
11982 matches!(text.as_str(), "IMAGE" | "PALETTE" | "TABLE")
11983 }
11984 })
11985 }
11986
11987 /// The bytes of object `index` in the global heap collection at
11988 /// `collection` — `H5HG_read`.
11989 fn read_heap_object(&self, collection: u64, index: u16) -> IoResult<Vec<u8>> {
11990 use crate::format::global_heap::GlobalHeapCollection;
11991
11992 let mut image = self.handle.read_at_most(collection, 4096)?;
11993 let declared = GlobalHeapCollection::decode_size(&image, &self.ctx)?;
11994 if declared > image.len() {
11995 image = self.handle.read_at(collection, declared)?;
11996 }
11997 let (gcol, _) = GlobalHeapCollection::decode(&image[..declared], &self.ctx)?;
11998 gcol.get_object(index).map(<[u8]>::to_vec).ok_or_else(|| {
11999 crate::io::IoError::InvalidState(format!(
12000 "global heap collection {collection:#x} has no object {index}"
12001 ))
12002 })
12003 }
12004
12005 /// The path of the object whose header is at `addr` in the file as it
12006 /// was opened — what an object reference read back from an append
12007 /// session's existing attributes names.
12008 fn path_of_header_address(&self, addr: u64) -> IoResult<String> {
12009 for ds in self.dataset_refs() {
12010 let m = ds.lock();
12011 if !m.deleted && m.obj_header_written_addr == Some(addr) {
12012 return Ok(m.name.clone());
12013 }
12014 }
12015 for grp in self.group_refs() {
12016 let g = grp.lock();
12017 if !g.deleted && g.obj_header_written_addr == Some(addr) {
12018 return Ok(g.name.clone());
12019 }
12020 }
12021 Err(crate::io::IoError::InvalidState(format!(
12022 "object reference to header {addr:#x} names no dataset or group of this file"
12023 )))
12024 }
12025
12026 /// The path a reference slot inside a global heap object names: the one
12027 /// registered for stamping when this session wrote the slot, else the
12028 /// one the address on disk resolves to.
12029 fn heap_reference_path(
12030 &self,
12031 collection: u64,
12032 index: u16,
12033 token_offset: usize,
12034 on_disk: &[u8],
12035 ) -> IoResult<String> {
12036 let registered = self
12037 .pending_heap_references
12038 .lock()
12039 .iter()
12040 .find(|p| {
12041 p.collection == collection && p.index == index && p.token_offset == token_offset
12042 })
12043 .map(|p| match &p.target {
12044 PendingHeapTarget::Dataset(path) | PendingHeapTarget::Object(path) => path.clone(),
12045 });
12046 if let Some(path) = registered {
12047 return Ok(path);
12048 }
12049 let mut raw = [0u8; 8];
12050 raw[..on_disk.len()].copy_from_slice(on_disk);
12051 self.path_of_header_address(u64::from_le_bytes(raw))
12052 }
12053
12054 /// Dataset `did`'s `DIMENSION_LIST` as the paths of the scales on each
12055 /// axis, or `None` when it has no such attribute.
12056 fn dimension_list(&self, did: usize) -> IoResult<Option<Vec<Vec<String>>>> {
12057 use crate::format::global_heap::{decode_vlen_reference, vlen_reference_size};
12058
12059 let Some(entry) = self.dataset_attribute(did, DIMENSION_LIST)? else {
12060 return Ok(None);
12061 };
12062 let name = || self.ds(did).lock().name.clone();
12063 let msg = entry.decoded().map_err(|reason| {
12064 crate::io::IoError::InvalidState(format!(
12065 "DIMENSION_LIST of '{}' cannot be decoded: {reason}",
12066 name()
12067 ))
12068 })?;
12069 match &msg.datatype {
12070 DatatypeMessage::VarLenSequence { base }
12071 if matches!(
12072 **base,
12073 DatatypeMessage::Reference {
12074 kind: ReferenceKind::Object1,
12075 ..
12076 }
12077 ) => {}
12078 other => {
12079 return Err(crate::io::IoError::InvalidState(format!(
12080 "DIMENSION_LIST of '{}' is {other}; only a sequence of H5T_STD_REF_OBJ \
12081 references is supported",
12082 name()
12083 )))
12084 }
12085 }
12086 let sa = self.ctx.sizeof_addr as usize;
12087 let ref_size = vlen_reference_size(&self.ctx);
12088 let mut lists = Vec::new();
12089 for elem in msg.data.chunks_exact(ref_size) {
12090 let (seq_len, addr, obj_idx) = decode_vlen_reference(elem, &self.ctx)?;
12091 let seq_len = seq_len as usize;
12092 let mut paths = Vec::with_capacity(seq_len);
12093 if seq_len > 0 {
12094 let index = u16::try_from(obj_idx).map_err(|_| {
12095 crate::io::IoError::InvalidState(format!(
12096 "global heap object index {obj_idx} does not fit the 16-bit on-disk field"
12097 ))
12098 })?;
12099 let bytes = self.read_heap_object(addr, index)?;
12100 if bytes.len() < seq_len * sa {
12101 return Err(crate::io::IoError::InvalidState(format!(
12102 "DIMENSION_LIST of '{}' names {seq_len} scales in a {}-byte heap object",
12103 name(),
12104 bytes.len()
12105 )));
12106 }
12107 for k in 0..seq_len {
12108 paths.push(self.heap_reference_path(
12109 addr,
12110 index,
12111 k * sa,
12112 &bytes[k * sa..(k + 1) * sa],
12113 )?);
12114 }
12115 }
12116 lists.push(paths);
12117 }
12118 Ok(Some(lists))
12119 }
12120
12121 /// Store `lists` — the scales attached to each axis — as dataset `did`'s
12122 /// `DIMENSION_LIST`, replacing the one it has.
12123 ///
12124 /// Each axis is one global heap object of `sizeof_addr` bytes per scale,
12125 /// zero until finalize stamps the scale's header address in through
12126 /// [`write_heap_reference_values`](Self::write_heap_reference_values);
12127 /// an axis with no scale is an empty heap object, as libhdf5's
12128 /// `H5VL__native_blob_put` stores an empty sequence. The attribute's
12129 /// value is the vlen reference to each object, final at write time.
12130 fn write_dimension_list(&self, did: usize, lists: &[Vec<String>]) -> IoResult<()> {
12131 use crate::format::global_heap::{
12132 encode_vlen_reference, vlen_reference_size, vlen_seq_len,
12133 };
12134
12135 let target = AttrTarget::Dataset(did);
12136 let origin = self.evict_attr(target, DIMENSION_LIST)?;
12137 let sa = self.ctx.sizeof_addr as usize;
12138 let blobs: Vec<Vec<u8>> = lists.iter().map(|l| vec![0u8; l.len() * sa]).collect();
12139 let items: Vec<&[u8]> = blobs.iter().map(Vec::as_slice).collect();
12140 let placements = self.insert_vlen_objects(&items)?;
12141
12142 let mut data = Vec::with_capacity(lists.len() * vlen_reference_size(&self.ctx));
12143 let mut pending = self.pending_heap_references.lock();
12144 for (axis, &(collection, index)) in placements.iter().enumerate() {
12145 for (k, path) in lists[axis].iter().enumerate() {
12146 pending.push(PendingHeapReference {
12147 collection,
12148 index,
12149 token_offset: k * sa,
12150 target: PendingHeapTarget::Object(path.clone()),
12151 });
12152 }
12153 data.extend_from_slice(&encode_vlen_reference(
12154 vlen_seq_len(lists[axis].len())?,
12155 collection,
12156 u32::from(index),
12157 &self.ctx,
12158 ));
12159 }
12160 drop(pending);
12161
12162 let attr = AttributeMessage {
12163 name: DIMENSION_LIST.to_string(),
12164 datatype: DatatypeMessage::VarLenSequence {
12165 base: Box::new(DatatypeMessage::object_reference(&self.ctx)),
12166 },
12167 dataspace: DataspaceMessage::simple(&[lists.len() as u64]),
12168 data,
12169 };
12170 self.insert_attribute(target, attr, origin)
12171 }
12172
12173 /// Scale `dsid`'s `REFERENCE_LIST` as (dataset path, axis) pairs; empty
12174 /// when it has no such attribute.
12175 fn reference_list(&self, dsid: usize) -> IoResult<Vec<(String, u32)>> {
12176 let Some(entry) = self.dataset_attribute(dsid, REFERENCE_LIST)? else {
12177 return Ok(Vec::new());
12178 };
12179 let name = || self.ds(dsid).lock().name.clone();
12180 let msg = entry.decoded().map_err(|reason| {
12181 crate::io::IoError::InvalidState(format!(
12182 "REFERENCE_LIST of '{}' cannot be decoded: {reason}",
12183 name()
12184 ))
12185 })?;
12186 let unsupported = |why: String| {
12187 crate::io::IoError::InvalidState(format!(
12188 "REFERENCE_LIST of '{}' is {}; {why}",
12189 name(),
12190 msg.datatype
12191 ))
12192 };
12193 let DatatypeMessage::Compound { size, members } = &msg.datatype else {
12194 return Err(unsupported("a compound is required".into()));
12195 };
12196 let member = |m: &str| {
12197 members
12198 .iter()
12199 .find(|c| c.name == m)
12200 .ok_or_else(|| unsupported(format!("member '{m}' is missing")))
12201 };
12202 let dataset = member("dataset")?;
12203 let dimension = member("dimension")?;
12204 let sa = self.ctx.sizeof_addr as usize;
12205 if !matches!(
12206 dataset.datatype,
12207 DatatypeMessage::Reference {
12208 kind: ReferenceKind::Object1,
12209 ..
12210 }
12211 ) {
12212 return Err(unsupported(
12213 "only an H5T_STD_REF_OBJ 'dataset' member is supported".into(),
12214 ));
12215 }
12216 let DatatypeMessage::FixedPoint {
12217 size: 4,
12218 byte_order,
12219 ..
12220 } = dimension.datatype
12221 else {
12222 return Err(unsupported(
12223 "a 4-byte integer 'dimension' member is required".into(),
12224 ));
12225 };
12226 let stride = *size as usize;
12227 let registered: Option<Vec<String>> = self
12228 .attribute_references
12229 .lock()
12230 .iter()
12231 .find(|r| r.scope == AttrScope::Dataset(dsid) && r.name == REFERENCE_LIST)
12232 .map(|r| r.targets.clone());
12233 let mut entries = Vec::with_capacity(msg.data.len() / stride);
12234 for (i, elem) in msg.data.chunks_exact(stride).enumerate() {
12235 let at = |offset: u32, len: usize| {
12236 elem.get(offset as usize..offset as usize + len)
12237 .ok_or_else(|| unsupported(format!("element {i} is too short for its members")))
12238 };
12239 let path = match ®istered {
12240 Some(targets) => targets.get(i).cloned().ok_or_else(|| {
12241 crate::io::IoError::InvalidState(format!(
12242 "REFERENCE_LIST of '{}' entry {i} has no registered target",
12243 name()
12244 ))
12245 })?,
12246 None => {
12247 let mut raw = [0u8; 8];
12248 raw[..sa].copy_from_slice(at(dataset.offset, sa)?);
12249 self.path_of_header_address(u64::from_le_bytes(raw))?
12250 }
12251 };
12252 let dim: [u8; 4] = at(dimension.offset, 4)?.try_into().expect("4 bytes");
12253 let dim = match byte_order {
12254 ByteOrder::LittleEndian => u32::from_le_bytes(dim),
12255 ByteOrder::BigEndian => u32::from_be_bytes(dim),
12256 };
12257 entries.push((path, dim));
12258 }
12259 Ok(entries)
12260 }
12261
12262 /// Store `entries` as scale `dsid`'s `REFERENCE_LIST`, replacing the one
12263 /// it has — deleted and created anew, as upstream does, so it takes a
12264 /// fresh creation index.
12265 ///
12266 /// The element is libhdf5's `ds_list_t` as it lands on disk: the
12267 /// reference at offset 0, `dimension` right after it, and the struct's
12268 /// trailing padding — 16 bytes over 8-byte addresses. The addresses are
12269 /// stamped at finalize through [`object_attributes`](Self::object_attributes)
12270 /// like any reference attribute's; the `dimension` fields are final here.
12271 fn write_reference_list(&self, dsid: usize, entries: &[(String, u32)]) -> IoResult<()> {
12272 use crate::format::messages::datatype::CompoundMember;
12273
12274 let target = AttrTarget::Dataset(dsid);
12275 self.evict_attr(target, REFERENCE_LIST)?;
12276 let sa = self.ctx.sizeof_addr as usize;
12277 let stride = sa + 8;
12278 let datatype = DatatypeMessage::compound(
12279 stride as u32,
12280 vec![
12281 CompoundMember {
12282 name: "dataset".to_string(),
12283 offset: 0,
12284 datatype: DatatypeMessage::object_reference(&self.ctx),
12285 },
12286 CompoundMember {
12287 name: "dimension".to_string(),
12288 offset: sa as u32,
12289 datatype: DatatypeMessage::u32_type(),
12290 },
12291 ],
12292 );
12293 let mut data = vec![0u8; entries.len() * stride];
12294 for (i, (_, dim)) in entries.iter().enumerate() {
12295 data[i * stride + sa..i * stride + sa + 4].copy_from_slice(&dim.to_le_bytes());
12296 }
12297 let attr = AttributeMessage::array_numeric(
12298 REFERENCE_LIST,
12299 datatype,
12300 &[entries.len() as u64],
12301 data,
12302 );
12303 self.insert_attribute(target, attr, Created)?;
12304 self.attribute_references
12305 .lock()
12306 .push(AttributeReferenceValue {
12307 scope: AttrScope::Dataset(dsid),
12308 name: REFERENCE_LIST.to_string(),
12309 targets: entries.iter().map(|(p, _)| p.clone()).collect(),
12310 stride,
12311 });
12312 Ok(())
12313 }
12314
12315 /// Take the attribute `name` off `target`'s list, releasing its heap
12316 /// objects. No-op when absent. Refused under SWMR — see
12317 /// [`set_attribute`](Self::set_attribute).
12318 ///
12319 /// What it answers is what the insert that follows it must be told: an
12320 /// attribute that was there is being rewritten and keeps its creation
12321 /// index, and one that was not is created.
12322 fn evict_attr(&self, target: AttrTarget<'_>, name: &str) -> IoResult<AttrOrigin> {
12323 if self.swmr_active {
12324 return Err(swmr_attr_error(name));
12325 }
12326 self.forget_attribute_reference(self.attr_scope(target)?, name);
12327 let old = self.with_attr_list(target, |attrs| {
12328 attrs
12329 .iter()
12330 .position(|a| a.name() == name)
12331 .map(|pos| attrs.remove(pos))
12332 })?;
12333 match old {
12334 Some(old) => {
12335 let origin = Rewritten(old.creation_index());
12336 self.release_attr_vlen(&old)?;
12337 Ok(origin)
12338 }
12339 None => Ok(Created),
12340 }
12341 }
12342
12343 /// Release the global-heap objects a superseded attribute owned.
12344 /// Recognizes top-level vlen datatypes only: a *compound* attribute
12345 /// with vlen members — which this crate cannot write, only a foreign
12346 /// file can carry — keeps its members' heap objects when replaced or
12347 /// deleted, the storage cost the foreign writer accepted. Every other
12348 /// class stores its value inline in the message. Per-object removal
12349 /// keeps collections shared with other refs (libhdf5-written files)
12350 /// intact.
12351 fn release_attr_vlen(&self, old: &AttributeEntry) -> IoResult<()> {
12352 use crate::format::messages::datatype::DatatypeMessage;
12353 // An attribute whose message this crate could not decode keeps
12354 // whatever heap space it references: releasing objects named by bytes
12355 // we cannot interpret would free storage that is still live.
12356 let Some(old) = old.readable() else {
12357 return Ok(());
12358 };
12359 if matches!(
12360 old.datatype,
12361 DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
12362 ) {
12363 self.release_vlen_references(&old.data)?;
12364 }
12365 Ok(())
12366 }
12367
12368 /// Run `f` on the attribute list `target` names — the accessor every
12369 /// attribute mutation shares.
12370 fn with_attr_list<R>(
12371 &self,
12372 target: AttrTarget<'_>,
12373 f: impl FnOnce(&mut Vec<AttributeEntry>) -> R,
12374 ) -> IoResult<R> {
12375 match target {
12376 AttrTarget::Root => Ok(f(&mut self.root_attributes.lock())),
12377 AttrTarget::Group(path) => {
12378 let path = self.canonical_group_path(path);
12379 for grp in self.group_refs() {
12380 let mut g = grp.lock();
12381 if g.name == path && !g.deleted {
12382 return Ok(f(&mut g.attributes));
12383 }
12384 }
12385 Err(crate::io::IoError::NotFound(format!(
12386 "group '{path}' not found"
12387 )))
12388 }
12389 AttrTarget::Dataset(index) => {
12390 let count = self.dataset_count();
12391 if index >= count {
12392 return Err(crate::io::IoError::InvalidState(format!(
12393 "dataset index {index} out of range (have {count})"
12394 )));
12395 }
12396 let ds = self.ds(index);
12397 let mut m = ds.lock();
12398 // Every caller of this mutates the list, and a reopened
12399 // dataset's header is rewritten only when it is marked stale.
12400 m.header_dirty = true;
12401 Ok(f(&mut m.attributes))
12402 }
12403 }
12404 }
12405
12406 /// Store each of `items` as a global heap object and return its
12407 /// placement `(collection address, object index)`, in input order —
12408 /// the writer side of libhdf5's `H5HG_insert`.
12409 ///
12410 /// Placement follows libhdf5: a collection from the CWFS list takes an
12411 /// item when its free space holds the object *and* a residual
12412 /// free-space marker header (`encode_at_size` always emits the
12413 /// marker); what no listed collection can take goes into a fresh
12414 /// collection, spilling into another at the 65535-object index cap.
12415 /// One batch may therefore span several collections — invisible to
12416 /// readers, which resolve each reference's own collection address. An
12417 /// empty batch allocates nothing: an empty collection still encodes
12418 /// to the 4096-byte `H5HG_MINALLOC` minimum, a block nothing would
12419 /// reference. libhdf5 additionally tries to extend a nearly-full
12420 /// collection's block in place (`H5MF_try_extend`); this writer does
12421 /// not — an oversized item always starts a fresh collection.
12422 ///
12423 /// The `cwfs` lock is held across every read-modify-rewrite of a
12424 /// listed collection block: it serializes concurrent inserts (two
12425 /// datasets' writers can pack the same block) and inserts against
12426 /// [`release_vlen_references`](Self::release_vlen_references), which
12427 /// rewrites the same blocks when objects are freed.
12428 ///
12429 /// Under SWMR the CWFS list is neither consulted nor updated and every
12430 /// batch gets fresh collections: packing rewrites a block a streaming
12431 /// reader may be mid-walk on — the same reason `place_chunk` keeps a
12432 /// relocated chunk's old block.
12433 fn insert_vlen_objects(&self, items: &[&[u8]]) -> IoResult<Vec<(u64, u16)>> {
12434 use crate::format::global_heap::{GlobalHeapCollection, GlobalHeapObject};
12435
12436 if items.is_empty() {
12437 return Ok(Vec::new());
12438 }
12439 let objhdr = GlobalHeapCollection::object_disk_size(&self.ctx, 0);
12440 let mut placements = Vec::with_capacity(items.len());
12441 let mut i = 0;
12442
12443 // Pack into listed collections while one can take the next item.
12444 if !self.swmr_active {
12445 let mut cwfs = self.cwfs.lock();
12446 while i < items.len() {
12447 let need = GlobalHeapCollection::object_disk_size(&self.ctx, items[i].len());
12448 let Some(pos) = cwfs.iter().position(|e| e.free >= need + objhdr) else {
12449 // Second pass of libhdf5's H5F_cwfs_find_free_heap: no
12450 // listed collection has room, so try to grow one in
12451 // place before falling back to a fresh collection.
12452 if self.extend_listed_collection(&mut cwfs, need + objhdr)? {
12453 continue;
12454 }
12455 break;
12456 };
12457 let (addr, size) = (cwfs[pos].addr, cwfs[pos].size);
12458 let image = self.handle.read_at(addr, size)?;
12459 let (mut gcol, _) = GlobalHeapCollection::decode(&image[..size], &self.ctx)?;
12460 // The disk is the truth for free space; the entry is a hint.
12461 let Some(mut free) = gcol.free_space_at(&self.ctx, size) else {
12462 cwfs.remove(pos);
12463 continue;
12464 };
12465 let mut next_idx = gcol.max_index();
12466 let mut took = false;
12467 while i < items.len() && next_idx < u16::MAX {
12468 let need = GlobalHeapCollection::object_disk_size(&self.ctx, items[i].len());
12469 if free < need + objhdr {
12470 break;
12471 }
12472 next_idx += 1;
12473 gcol.objects.push(GlobalHeapObject {
12474 index: next_idx,
12475 ref_count: 0,
12476 data: items[i].to_vec(),
12477 });
12478 placements.push((addr, next_idx));
12479 free -= need;
12480 took = true;
12481 i += 1;
12482 }
12483 if took {
12484 let rewritten = gcol.encode_at_size(&self.ctx, size)?;
12485 self.handle.write_at(addr, &rewritten)?;
12486 // Correct the entry to the measured free space and move
12487 // it to the front — libhdf5 keeps `cwfs` in
12488 // most-recently-used order.
12489 let mut e = cwfs.remove(pos);
12490 e.free = free;
12491 cwfs.insert(0, e);
12492 } else if next_idx == u16::MAX {
12493 // At the index cap nothing can be inserted no matter the
12494 // free space; drop the entry or the scan re-picks it
12495 // forever. (A removal can lower the top index again, and
12496 // the release side re-lists the collection then.)
12497 cwfs.remove(pos);
12498 } else {
12499 // The hint overstated the block's free space — shrink it
12500 // to the measured value so the scan moves on.
12501 cwfs[pos].free = free;
12502 }
12503 }
12504 }
12505
12506 // What remains goes into fresh collections.
12507 while i < items.len() {
12508 let mut gcol = GlobalHeapCollection::new();
12509 // Objects are pushed with a running index: `add_object` rescans
12510 // for the max index per call, O(n²) across a spill-sized batch.
12511 let mut next_idx: u16 = 0;
12512 while i < items.len() && next_idx < u16::MAX {
12513 next_idx += 1;
12514 gcol.objects.push(GlobalHeapObject {
12515 index: next_idx,
12516 ref_count: 0,
12517 data: items[i].to_vec(),
12518 });
12519 i += 1;
12520 }
12521 let encoded = gcol.encode(&self.ctx);
12522 let addr = self
12523 .allocator
12524 .allocate(encoded.len() as u64, FreeSpaceClass::RawData);
12525 self.handle.write_at(addr, &encoded)?;
12526 for idx in 1..=next_idx {
12527 placements.push((addr, idx));
12528 }
12529 // List the block's leftover free space for later inserts — the
12530 // minimum-size padding of a small batch is most of 4096 bytes.
12531 // Below two object headers not even an empty object fits.
12532 if !self.swmr_active {
12533 if let Some(free) = gcol.free_space_at(&self.ctx, encoded.len()) {
12534 if free >= 2 * objhdr {
12535 cwfs_note(&mut self.cwfs.lock(), addr, encoded.len(), free);
12536 }
12537 }
12538 }
12539 }
12540 Ok(placements)
12541 }
12542
12543 /// Try to extend one listed collection in place so it can take an
12544 /// object needing `want` bytes of free space — the second pass of
12545 /// libhdf5's `H5F_cwfs_find_free_heap`: grow the file allocation
12546 /// ([`FileAllocator::try_extend`], mirroring `H5MF_try_extend`) and then
12547 /// the collection itself (`H5HG_extend`: a larger declared size and a
12548 /// free-space marker covering the new tail — here by re-encoding at the
12549 /// grown size, which writes exactly those two things).
12550 ///
12551 /// Extension size is `max(collection_size, shortfall)` — at least a
12552 /// doubling — capped so the result stays within [`GCOL_MAX_SIZE`], both
12553 /// as upstream computes them. On success the grown entry moves to the
12554 /// front of the list and the caller's scan re-picks it; the free-space
12555 /// measurement is taken from the block on disk, not the list's hint, so
12556 /// the rewrite and the entry agree.
12557 ///
12558 /// Caller holds the `cwfs` lock (it passes the guarded list), which is
12559 /// what serializes this read-modify-rewrite against concurrent inserts
12560 /// and releases.
12561 fn extend_listed_collection(&self, cwfs: &mut Vec<CwfsEntry>, want: usize) -> IoResult<bool> {
12562 use crate::format::global_heap::{GlobalHeapCollection, GCOL_MAX_SIZE};
12563
12564 let mut pos = 0;
12565 while pos < cwfs.len() {
12566 let (addr, size) = (cwfs[pos].addr, cwfs[pos].size);
12567 let image = self.handle.read_at(addr, size)?;
12568 let (gcol, _) = GlobalHeapCollection::decode(&image[..size], &self.ctx)?;
12569 // The disk is the truth for free space; the entry is a hint.
12570 let Some(free) = gcol.free_space_at(&self.ctx, size) else {
12571 cwfs.remove(pos);
12572 continue;
12573 };
12574 // A hint can understate the block (upstream's FREE_SIZE is its
12575 // in-memory truth and cannot): if the block already has room,
12576 // correct the hint instead of doubling the collection.
12577 if free >= want {
12578 cwfs[pos].free = free;
12579 return Ok(true);
12580 }
12581 let new_need = size.max(want.saturating_sub(free));
12582 if size + new_need > GCOL_MAX_SIZE
12583 || !self.allocator.try_extend(
12584 addr,
12585 size as u64,
12586 new_need as u64,
12587 FreeSpaceClass::RawData,
12588 )
12589 {
12590 pos += 1;
12591 continue;
12592 }
12593 let new_size = size + new_need;
12594 let rewritten = gcol.encode_at_size(&self.ctx, new_size)?;
12595 self.handle.write_at(addr, &rewritten)?;
12596 let mut e = cwfs.remove(pos);
12597 e.size = new_size;
12598 e.free = free + new_need;
12599 cwfs.insert(0, e);
12600 return Ok(true);
12601 }
12602 Ok(false)
12603 }
12604
12605 /// Create a variable-length string dataset and write string data.
12606 ///
12607 /// Stores strings in the global heap. The dataset raw data consists of
12608 /// vlen references (collection_addr + object_index pairs).
12609 ///
12610 /// `charset` is the datatype's declared character set (0 = ASCII,
12611 /// 1 = UTF-8); the strings are checked against it before anything is
12612 /// written, so the type never misdescribes the bytes under it.
12613 pub fn create_vlen_string_dataset(
12614 &self,
12615 name: &str,
12616 strings: &[&str],
12617 charset: u8,
12618 ) -> IoResult<usize> {
12619 use crate::format::global_heap::encode_vlen_reference;
12620 use crate::format::messages::datatype::DatatypeMessage;
12621
12622 ensure_vlen_charset(charset, strings)?;
12623
12624 let create = self.begin_create(name)?;
12625 let name = create.name.as_str();
12626 let num_strings = strings.len() as u64;
12627
12628 // Store the strings as heap objects; a batch that fits an earlier
12629 // collection's free space shares its block.
12630 let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
12631 let placements = self.insert_vlen_objects(&items)?;
12632
12633 // Build raw data: vlen references
12634 let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
12635 let data_size = (num_strings as usize) * ref_size;
12636 let mut raw_data = Vec::with_capacity(data_size);
12637 for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
12638 let seq_len = crate::format::global_heap::vlen_seq_len(strings[i].len())?;
12639 raw_data.extend_from_slice(&encode_vlen_reference(
12640 seq_len,
12641 gcol_addr,
12642 obj_idx as u32,
12643 &self.ctx,
12644 ));
12645 }
12646
12647 // Allocate and write raw data
12648 let data_addr = self
12649 .allocator
12650 .allocate(data_size as u64, FreeSpaceClass::RawData);
12651 self.handle.write_at(data_addr, &raw_data)?;
12652
12653 // Create the dataset with vlen string datatype
12654 let datatype = DatatypeMessage::VarLenString {
12655 padding: 0,
12656 charset,
12657 };
12658 let dataspace =
12659 crate::format::messages::dataspace::DataspaceMessage::simple(&[num_strings]);
12660
12661 let idx = self.push_dataset(
12662 &create,
12663 DatasetInfo {
12664 name: name.to_string(),
12665 datatype,
12666 committed_type: None,
12667 external: None,
12668 virtual_storage: None,
12669 dataspace,
12670 read_format: None,
12671 obj_header_addr: 0,
12672 data_addr,
12673 data_size: data_size as u64,
12674 compact: None,
12675 attributes: Vec::new(),
12676 obj_header_written_addr: None,
12677 obj_header_blocks: Vec::new(),
12678 filter_pipeline: None,
12679 deleted: false,
12680 extent_dirty: false,
12681 header_dirty: false,
12682 nlink_written: 1,
12683 creation_seq: self.take_creation_seq(),
12684 track_attr_order: self.track_order.attrs,
12685 fill_value: None,
12686 fill_time: FILL_TIME_IFSET,
12687 layout_version: 4,
12688 times: self.created_object_times(),
12689 chunked: None,
12690 fixed_array: None,
12691 implicit: None,
12692 single_chunk: None,
12693 btree_v1: None,
12694 btree_v2: None,
12695 append: None,
12696 },
12697 );
12698
12699 Ok(idx)
12700 }
12701
12702 /// Create a 1-D variable-length byte-array dataset.
12703 ///
12704 /// The `u8` case of [`create_vlen_sequence_dataset`], where an item's
12705 /// byte image and its element count are the same number.
12706 ///
12707 /// [`create_vlen_sequence_dataset`]: Self::create_vlen_sequence_dataset
12708 ///
12709 /// Superseded in production by [`write_vlen_numeric`](crate::H5File::write_vlen_numeric)
12710 /// (`H5Group::write_vlen_bytes` routes through it, not through here);
12711 /// kept as a direct entry point for this crate's own white-box tests.
12712 #[cfg(test)]
12713 pub fn create_vlen_bytes_dataset(&self, name: &str, items: &[&[u8]]) -> IoResult<usize> {
12714 use crate::format::messages::datatype::DatatypeMessage;
12715
12716 self.create_vlen_sequence_dataset(name, DatatypeMessage::u8_type(), items)
12717 }
12718
12719 /// Create a 1-D variable-length sequence dataset over `base`.
12720 ///
12721 /// Each item is the encoded image of one sequence — `n * base.element_size()`
12722 /// bytes in the base type's own byte order — and is stored as a global-heap
12723 /// object; the dataset holds one vlen reference per item, the same on-disk
12724 /// shape a vlen string dataset has. The `H5T_VLEN` length field counts base
12725 /// elements rather than bytes, so an image whose length is not a whole
12726 /// number of elements is refused here rather than stored under a length
12727 /// that misreads it.
12728 pub fn create_vlen_sequence_dataset(
12729 &self,
12730 name: &str,
12731 base: DatatypeMessage,
12732 items: &[&[u8]],
12733 ) -> IoResult<usize> {
12734 use crate::format::global_heap::encode_vlen_reference;
12735 use crate::format::messages::datatype::DatatypeMessage;
12736
12737 let elem_size = base.element_size() as usize;
12738 if elem_size == 0 {
12739 return Err(crate::io::IoError::InvalidState(format!(
12740 "vlen base datatype {base} has no element size"
12741 )));
12742 }
12743 for (i, item) in items.iter().enumerate() {
12744 if !item.len().is_multiple_of(elem_size) {
12745 return Err(crate::io::IoError::InvalidState(format!(
12746 "sequence {i} is {} bytes, not a whole number of {elem_size}-byte elements",
12747 item.len()
12748 )));
12749 }
12750 }
12751
12752 let create = self.begin_create(name)?;
12753 let name = create.name.as_str();
12754 let num_items = items.len() as u64;
12755
12756 // Store the sequence images as heap objects, sharing collection
12757 // blocks as `create_vlen_string_dataset` does.
12758 let placements = self.insert_vlen_objects(items)?;
12759
12760 // Build raw data: one vlen reference per item.
12761 let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
12762 let data_size = (num_items as usize) * ref_size;
12763 let mut raw_data = Vec::with_capacity(data_size);
12764 for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
12765 let seq_len = crate::format::global_heap::vlen_seq_len(items[i].len() / elem_size)?;
12766 raw_data.extend_from_slice(&encode_vlen_reference(
12767 seq_len,
12768 gcol_addr,
12769 obj_idx as u32,
12770 &self.ctx,
12771 ));
12772 }
12773
12774 // Allocate and write raw data.
12775 let data_addr = self
12776 .allocator
12777 .allocate(data_size as u64, FreeSpaceClass::RawData);
12778 self.handle.write_at(data_addr, &raw_data)?;
12779
12780 let datatype = DatatypeMessage::VarLenSequence {
12781 base: Box::new(base),
12782 };
12783 let dataspace = crate::format::messages::dataspace::DataspaceMessage::simple(&[num_items]);
12784
12785 let idx = self.push_dataset(
12786 &create,
12787 DatasetInfo {
12788 name: name.to_string(),
12789 datatype,
12790 committed_type: None,
12791 external: None,
12792 virtual_storage: None,
12793 dataspace,
12794 read_format: None,
12795 obj_header_addr: 0,
12796 data_addr,
12797 data_size: data_size as u64,
12798 compact: None,
12799 attributes: Vec::new(),
12800 obj_header_written_addr: None,
12801 obj_header_blocks: Vec::new(),
12802 filter_pipeline: None,
12803 deleted: false,
12804 extent_dirty: false,
12805 header_dirty: false,
12806 nlink_written: 1,
12807 creation_seq: self.take_creation_seq(),
12808 track_attr_order: self.track_order.attrs,
12809 fill_value: None,
12810 fill_time: FILL_TIME_IFSET,
12811 layout_version: 4,
12812 times: self.created_object_times(),
12813 chunked: None,
12814 fixed_array: None,
12815 implicit: None,
12816 single_chunk: None,
12817 btree_v1: None,
12818 btree_v2: None,
12819 append: None,
12820 },
12821 );
12822
12823 Ok(idx)
12824 }
12825
12826 /// Create a chunked, compressed variable-length string dataset.
12827 ///
12828 /// Strings are stored in the global heap (same as `create_vlen_string_dataset`),
12829 /// but the vlen references are stored in chunked layout with the given filter
12830 /// pipeline (e.g., deflate, zstd). `chunk_size` is the number of strings per chunk.
12831 pub fn create_vlen_string_dataset_compressed(
12832 &self,
12833 name: &str,
12834 strings: &[&str],
12835 chunk_size: usize,
12836 pipeline: FilterPipeline,
12837 ) -> IoResult<usize> {
12838 use crate::format::global_heap::encode_vlen_reference;
12839 use crate::format::messages::datatype::DatatypeMessage;
12840
12841 let create = self.begin_create(name)?;
12842 let name = create.name.as_str();
12843 let num_strings = strings.len() as u64;
12844 validate_chunk_geometry(&[num_strings], &[num_strings], &[chunk_size as u64])?;
12845
12846 // Store the strings as heap objects; the geometry validation above
12847 // must precede this so a refused call allocates nothing.
12848 let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
12849 let placements = self.insert_vlen_objects(&items)?;
12850
12851 // Build raw data: vlen references
12852 let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
12853 let data_size = (num_strings as usize) * ref_size;
12854 let mut raw_data = Vec::with_capacity(data_size);
12855 for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
12856 let seq_len = crate::format::global_heap::vlen_seq_len(strings[i].len())?;
12857 raw_data.extend_from_slice(&encode_vlen_reference(
12858 seq_len,
12859 gcol_addr,
12860 obj_idx as u32,
12861 &self.ctx,
12862 ));
12863 }
12864
12865 // Set up chunked compressed layout
12866 let datatype = DatatypeMessage::vlen_string_utf8();
12867 let element_size = datatype.element_size_ctx(&self.ctx) as u64;
12868 let chunk_dims: Vec<u64> = vec![chunk_size as u64];
12869 let dims: Vec<u64> = vec![num_strings];
12870 let max_dims: Vec<u64> = vec![num_strings];
12871 let chunk_bytes = chunk_size as u64 * element_size;
12872 let layout_version = self.chunk_layout_version(true, chunk_bytes);
12873 let chunk_size_len = self.chunk_size_len_for(layout_version, chunk_bytes);
12874
12875 let earray_params = EarrayParams::default_params();
12876 let ndblk_addrs = compute_ndblk_addrs(earray_params.sup_blk_min_data_ptrs)?;
12877 let nsblk_addrs = compute_nsblk_addrs(
12878 earray_params.idx_blk_elmts,
12879 earray_params.data_blk_min_elmts,
12880 earray_params.sup_blk_min_data_ptrs,
12881 earray_params.max_nelmts_bits,
12882 )?;
12883
12884 // Create filtered EA header
12885 let mut ea_header =
12886 ExtensibleArrayHeader::new_for_filtered_chunks(&self.ctx, chunk_size_len);
12887 ea_header.max_nelmts_bits = earray_params.max_nelmts_bits;
12888 ea_header.idx_blk_elmts = earray_params.idx_blk_elmts;
12889 ea_header.data_blk_min_elmts = earray_params.data_blk_min_elmts;
12890 ea_header.sup_blk_min_data_ptrs = earray_params.sup_blk_min_data_ptrs;
12891 ea_header.max_dblk_page_nelmts_bits = earray_params.max_dblk_page_nelmts_bits;
12892
12893 let hdr_encoded = ea_header.encode(&self.ctx);
12894 let ea_header_addr = self
12895 .allocator
12896 .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
12897
12898 // Create filtered index block
12899 let filt_iblk = FilteredIndexBlock::new(
12900 ea_header_addr,
12901 earray_params.idx_blk_elmts,
12902 ndblk_addrs,
12903 nsblk_addrs,
12904 );
12905 let iblk_encoded = filt_iblk.encode(&self.ctx, chunk_size_len);
12906 let ea_iblk_addr = self
12907 .allocator
12908 .allocate(iblk_encoded.len() as u64, FreeSpaceClass::Metadata);
12909
12910 ea_header.idx_blk_addr = ea_iblk_addr;
12911
12912 let hdr_encoded = ea_header.encode(&self.ctx);
12913 self.handle.write_at(ea_header_addr, &hdr_encoded)?;
12914 self.handle.write_at(ea_iblk_addr, &iblk_encoded)?;
12915
12916 let dataspace = DataspaceMessage {
12917 // Chunked storage always requires at least one dimension, so
12918 // this is never Scalar or Null.
12919 class: DataspaceClass::Simple,
12920 dims: dims.to_vec(),
12921 max_dims: Some(max_dims.to_vec()),
12922 };
12923
12924 let ea_iblk = ExtensibleArrayIndexBlock::new(
12925 ea_header_addr,
12926 earray_params.idx_blk_elmts,
12927 ndblk_addrs,
12928 nsblk_addrs,
12929 );
12930
12931 let idx = self.push_dataset(
12932 &create,
12933 DatasetInfo {
12934 name: name.to_string(),
12935 datatype,
12936 committed_type: None,
12937 external: None,
12938 virtual_storage: None,
12939 dataspace,
12940 read_format: None,
12941 obj_header_addr: 0,
12942 data_addr: UNDEF_ADDR,
12943 data_size: 0,
12944 compact: None,
12945 attributes: Vec::new(),
12946 obj_header_written_addr: None,
12947 obj_header_blocks: Vec::new(),
12948 filter_pipeline: Some(pipeline),
12949 deleted: false,
12950 extent_dirty: false,
12951 header_dirty: false,
12952 nlink_written: 1,
12953 creation_seq: self.take_creation_seq(),
12954 track_attr_order: self.track_order.attrs,
12955 fill_value: None,
12956 fill_time: FILL_TIME_IFSET,
12957 layout_version,
12958 times: self.created_object_times(),
12959 fixed_array: None,
12960 implicit: None,
12961 single_chunk: None,
12962 btree_v1: None,
12963 btree_v2: None,
12964 chunked: Some(ChunkedDatasetInfo {
12965 chunk_dims: chunk_dims.clone(),
12966 earray_params,
12967 ea_header_addr,
12968 ea_iblk_addr,
12969 ea_header,
12970 ea_iblk,
12971 chunks_written: 0,
12972 filt_iblk: Some(filt_iblk),
12973 chunk_size_len,
12974 }),
12975 append: None,
12976 },
12977 );
12978
12979 // Write chunks of vlen references with compression
12980 let chunk_byte_size = chunk_bytes as usize;
12981 let num_chunks = raw_data.len().div_ceil(chunk_byte_size);
12982 for chunk_i in 0..num_chunks {
12983 let start = chunk_i * chunk_byte_size;
12984 let end = (start + chunk_byte_size).min(raw_data.len());
12985 let chunk_data = if end - start < chunk_byte_size {
12986 // Pad last chunk to full size (vlen datasets carry no user
12987 // fill value, so this resolves to zero = null vlen reference).
12988 let mut padded = self.new_chunk_buffer(idx, chunk_byte_size);
12989 padded[..end - start].copy_from_slice(&raw_data[start..end]);
12990 padded
12991 } else {
12992 raw_data[start..end].to_vec()
12993 };
12994 self.write_chunk(idx, chunk_i as u64, &chunk_data)?;
12995 }
12996
12997 Ok(idx)
12998 }
12999
13000 /// Create an empty chunked vlen string dataset ready for incremental appends.
13001 ///
13002 /// The dataset starts with `dims = [0]` and `max_dims = [unlimited]`.
13003 /// Use `append_vlen_strings` to add data.
13004 pub fn create_appendable_vlen_string_dataset(
13005 &self,
13006 name: &str,
13007 chunk_size: usize,
13008 pipeline: Option<FilterPipeline>,
13009 ) -> IoResult<usize> {
13010 let datatype = DatatypeMessage::vlen_string_utf8();
13011 let chunk_dims: Vec<u64> = vec![chunk_size as u64];
13012 let dims: Vec<u64> = vec![0];
13013 let max_dims: Vec<u64> = vec![u64::MAX];
13014
13015 if let Some(ref pl) = pipeline {
13016 self.create_chunked_dataset_with_pipeline(
13017 name,
13018 datatype,
13019 &dims,
13020 &max_dims,
13021 &chunk_dims,
13022 pl.clone(),
13023 )
13024 } else {
13025 self.create_chunked_dataset(name, datatype, &dims, &max_dims, &chunk_dims)
13026 }
13027 }
13028
13029 /// Append variable-length strings to an existing chunked vlen string dataset.
13030 ///
13031 /// Creates a new global heap collection for the strings, builds vlen
13032 /// references, and appends them as new chunks to the dataset.
13033 pub fn append_vlen_strings(&self, ds_index: usize, strings: &[&str]) -> IoResult<()> {
13034 use crate::format::global_heap::encode_vlen_reference;
13035 use crate::format::messages::datatype::DatatypeMessage;
13036
13037 if strings.is_empty() {
13038 return Ok(());
13039 }
13040
13041 // Whole-operation guard: buffer take, frame writes, re-buffer and
13042 // extend below are separate slot acquisitions that a concurrent
13043 // same-dataset append must not interleave with.
13044 let cell = self.ds(ds_index);
13045 let _op = cell.op.lock();
13046
13047 // The elements about to be written are vlen references; any other
13048 // element type would be overwritten with them as raw bytes.
13049 let charset = {
13050 let ds = self.ds(ds_index);
13051 let m = ds.lock();
13052 match m.datatype {
13053 DatatypeMessage::VarLenString { charset, .. } => charset,
13054 _ => {
13055 return Err(crate::io::IoError::InvalidState(
13056 "append_vlen_strings is only for variable-length string datasets".into(),
13057 ))
13058 }
13059 }
13060 };
13061 ensure_vlen_charset(charset, strings)?;
13062
13063 // Every deterministic rejection must precede the heap write below:
13064 // a collection written for a batch the append then refuses (a
13065 // contiguous dataset, or a reopened dataset whose chunk index was
13066 // not reconstructed) is a 4096-byte orphan nothing references.
13067 let chunk_dims = self
13068 .dataset_chunk_dims(ds_index)
13069 .ok_or_else(|| crate::io::IoError::InvalidState("not a chunked dataset".into()))?
13070 .to_vec();
13071 let dims = self.dataset_dims(ds_index).to_vec();
13072
13073 // Store the batch's strings as heap objects; a batch that fits an
13074 // earlier collection's free space shares its block.
13075 let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
13076 let placements = self.insert_vlen_objects(&items)?;
13077
13078 // Build raw vlen reference bytes
13079 let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
13080 let mut raw = Vec::with_capacity(strings.len() * ref_size);
13081 for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
13082 let seq_len = crate::format::global_heap::vlen_seq_len(strings[i].len())?;
13083 raw.extend_from_slice(&encode_vlen_reference(
13084 seq_len,
13085 gcol_addr,
13086 obj_idx as u32,
13087 &self.ctx,
13088 ));
13089 }
13090
13091 let n_new_frames = strings.len();
13092 let current_dim0 = dims[0] as usize;
13093 let chunk_dim0 = chunk_dims[0] as usize;
13094 let frame_bytes = ref_size;
13095
13096 // Merge the buffer with the new frames when it is the dataset's tail;
13097 // a buffer left mid-extent (the extent moved past it) keeps its
13098 // recorded place — flush it and start fresh at the current end.
13099 let taken = { self.ds(ds_index).lock().append.take() };
13100 let (base_dim0, buffered_frames, mut combined) = match taken {
13101 Some(b) if b.base + b.frames == current_dim0 as u64 => {
13102 (b.base as usize, b.frames as usize, b.bytes)
13103 }
13104 Some(b) => {
13105 self.write_append_frames(ds_index, b.base, b.frames, &b.bytes)?;
13106 (current_dim0, 0, Vec::new())
13107 }
13108 None => (current_dim0, 0, Vec::new()),
13109 };
13110 combined.extend_from_slice(&raw);
13111
13112 let total_frames = buffered_frames + n_new_frames;
13113
13114 // Rows up to the last chunk boundary are written now; the tail that
13115 // does not complete a chunk goes back in the buffer for the next
13116 // append (or the flush at close). The boundary can precede
13117 // `base_dim0` — a reopened file's flushed partial chunk leaves the
13118 // base mid-chunk — in which case everything is tail.
13119 let last_boundary = ((base_dim0 + total_frames) / chunk_dim0) * chunk_dim0;
13120 let write_frames = last_boundary.saturating_sub(base_dim0);
13121 let tail_frames = total_frames - write_frames;
13122 if write_frames > 0 {
13123 self.write_append_frames(
13124 ds_index,
13125 base_dim0 as u64,
13126 write_frames as u64,
13127 &combined[..write_frames * frame_bytes],
13128 )?;
13129 }
13130 if tail_frames > 0 {
13131 let ds = self.ds(ds_index);
13132 let mut m = ds.lock();
13133 m.append = Some(AppendBuffer {
13134 base: (base_dim0 + write_frames) as u64,
13135 frames: tail_frames as u64,
13136 bytes: combined[write_frames * frame_bytes..].to_vec(),
13137 });
13138 }
13139
13140 // Extend dims
13141 let logical_dim0 = base_dim0 + total_frames;
13142 let mut new_dims = dims;
13143 new_dims[0] = logical_dim0 as u64;
13144 self.extend_dataset_inner(ds_index, &new_dims)?;
13145
13146 Ok(())
13147 }
13148
13149 /// Replace elements `start .. start + strings.len()` of a 1-D
13150 /// variable-length string dataset, leaving its extent and every other
13151 /// element alone.
13152 ///
13153 /// The replacements go into the global heap and only the vlen
13154 /// references of the named elements are rewritten, so the cost is the
13155 /// new strings plus the chunks those references live in — not the column.
13156 /// The objects the old references pointed at are freed *before* the
13157 /// replacement is allocated, so repeated updates reuse space instead of
13158 /// growing the file — including across close/reopen cycles, where the
13159 /// in-memory free list starts empty and only this free-first order lets
13160 /// the session reuse the block it just released. This is what libhdf5
13161 /// does: `H5T__vlen_disk_write` deletes the reference it read into the
13162 /// conversion background buffer before storing the new one.
13163 ///
13164 /// Elements the append buffer still holds are flushed to their chunks
13165 /// first, so the whole range is on disk and one write path covers it.
13166 pub fn write_vlen_strings_slice(
13167 &self,
13168 ds_index: usize,
13169 start: u64,
13170 strings: &[&str],
13171 ) -> IoResult<()> {
13172 use crate::format::global_heap::{encode_vlen_reference, vlen_reference_size};
13173 use crate::format::messages::datatype::DatatypeMessage;
13174
13175 // An empty batch is a no-op: nothing to replace, nothing to free.
13176 if strings.is_empty() {
13177 return Ok(());
13178 }
13179
13180 // Whole-operation guard: the flush, the old-reference reads and the
13181 // slice write below must not interleave with a concurrent
13182 // same-dataset operation.
13183 let cell = self.ds(ds_index);
13184 let _op = cell.op.lock();
13185
13186 // Snapshot what the write needs, then drop the guard: `write_slice`
13187 // below re-locks the same slot.
13188 let (charset, dims, writable) = {
13189 let ds = self.ds(ds_index);
13190 let m = ds.lock();
13191 let charset = match m.datatype {
13192 DatatypeMessage::VarLenString { charset, .. } => charset,
13193 _ => {
13194 return Err(crate::io::IoError::InvalidState(
13195 "write_vlen_strings_slice is only for variable-length string datasets"
13196 .into(),
13197 ))
13198 }
13199 };
13200 let writable = if m.is_chunked() {
13201 Ok(())
13202 } else {
13203 match m.contiguous_target() {
13204 Some(ContiguousTarget::Virtual) => Err(virtual_write_refused()),
13205 Some(_) => Ok(()),
13206 None => Err(crate::io::IoError::InvalidState(
13207 "dataset has no data allocated".into(),
13208 )),
13209 }
13210 };
13211 (charset, m.dataspace.dims.clone(), writable)
13212 };
13213
13214 // `write_slice_inner` rejects a dataset with neither chunk machinery
13215 // nor allocated data (a reopened dataset whose index was not
13216 // reconstructed), and refuses a virtual one outright — those
13217 // rejections must come before the heap write below, or every failed
13218 // call orphans a 4096-byte collection.
13219 writable?;
13220
13221 if dims.len() != 1 {
13222 return Err(crate::io::IoError::InvalidState(format!(
13223 "write_vlen_strings_slice is only for 1-dimension datasets, this one has {}",
13224 dims.len()
13225 )));
13226 }
13227 let end = start + strings.len() as u64;
13228 if end > dims[0] {
13229 return Err(crate::io::IoError::InvalidState(format!(
13230 "elements {start}..{end} are outside the dataset's {} elements",
13231 dims[0]
13232 )));
13233 }
13234 ensure_vlen_charset(charset, strings)?;
13235
13236 let ref_size = vlen_reference_size(&self.ctx);
13237
13238 // Elements the append buffer holds are not in the chunks yet: hand
13239 // them to the chunks first so the whole range is on disk and the one
13240 // write path below covers it.
13241 self.flush_append_buffer_if_intersecting(ds_index, start, end)?;
13242
13243 // The on-disk references about to be overwritten, read before anything
13244 // moves. libhdf5 reads the same bytes into the conversion background
13245 // buffer (`H5D__scatgath_write` gathers the file's current elements
13246 // when `need_bkg` is set) and hands them to `H5T__vlen_disk_write`,
13247 // which deletes them before storing the new reference.
13248 let superseded = self.current_element_bytes(ds_index, start, end - start, ref_size)?;
13249
13250 // Free the superseded objects *before* allocating the replacement,
13251 // the order `H5T__vlen_disk_write` uses. The freed block satisfies
13252 // the allocation below within this same session, so a reopen-and-
13253 // replace loop keeps the file flat — no persisted free-space
13254 // information exists to carry it across sessions (issue #10). The
13255 // cost, shared with libhdf5: a failure between here and the ref
13256 // write below leaves the dataset's old references dangling.
13257 self.release_vlen_references(&superseded)?;
13258
13259 // The insert comes after the release above so the space the release
13260 // recovered — a freed block, or in-collection bytes the release just
13261 // listed in `cwfs` — can satisfy this batch.
13262 let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
13263 let placements = self.insert_vlen_objects(&items)?;
13264
13265 let mut refs = Vec::with_capacity(strings.len() * ref_size);
13266 for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
13267 refs.extend_from_slice(&encode_vlen_reference(
13268 crate::format::global_heap::vlen_seq_len(strings[i].len())?,
13269 gcol_addr,
13270 obj_idx as u32,
13271 &self.ctx,
13272 ));
13273 }
13274
13275 self.write_slice_inner(ds_index, &[start], &[strings.len() as u64], &refs)?;
13276
13277 Ok(())
13278 }
13279
13280 /// The bytes elements `start .. start + count` of a 1-D dataset currently
13281 /// hold, whichever layout stores them.
13282 ///
13283 /// Elements no write has reached yet read as zeros — for a vlen dataset
13284 /// that is the nil reference, which names no heap object.
13285 fn current_element_bytes(
13286 &self,
13287 ds_index: usize,
13288 start: u64,
13289 count: u64,
13290 element_size: usize,
13291 ) -> IoResult<Vec<u8>> {
13292 let mut out = vec![0u8; count as usize * element_size];
13293 if count == 0 {
13294 return Ok(out);
13295 }
13296
13297 let (is_chunked, data_addr) = {
13298 let ds = self.ds(ds_index);
13299 let m = ds.lock();
13300 (m.is_chunked(), m.data_addr)
13301 };
13302
13303 if !is_chunked {
13304 if data_addr != UNDEF_ADDR {
13305 // `read_at_most`, not `read_at`: a contiguous dataset's block is
13306 // reserved when it is created, so the file can still be shorter
13307 // than the block until something writes it. What is missing has
13308 // never been written, which is the zeros above.
13309 let at = data_addr + start * element_size as u64;
13310 let got = self.handle.read_at_most(at, out.len())?;
13311 out[..got.len()].copy_from_slice(&got);
13312 }
13313 return Ok(out);
13314 }
13315
13316 let geo = self.chunk_geometry(ds_index)?;
13317 let per_chunk = geo.chunk_dims[0];
13318 // Only a corrupt or crafted file declares a zero-length chunk
13319 // dimension; the divisions below must reject it the way
13320 // `write_slice` does, not panic.
13321 if per_chunk == 0 {
13322 return Err(crate::io::IoError::InvalidState(
13323 "chunk shape has a zero-length dimension".into(),
13324 ));
13325 }
13326 let end = start + count;
13327 for c in (start / per_chunk)..=((end - 1) / per_chunk) {
13328 let origin = c * per_chunk;
13329 let lo = start.max(origin);
13330 let hi = end.min(origin + per_chunk);
13331 // A chunk with no block yet leaves this span as the zeros above.
13332 let Some(chunk) = self.read_chunk_at_coords(ds_index, &[c])? else {
13333 continue;
13334 };
13335 let src = ((lo - origin) as usize) * element_size;
13336 let dst = ((lo - start) as usize) * element_size;
13337 let len = ((hi - lo) as usize) * element_size;
13338 if src + len > chunk.len() {
13339 return Err(crate::io::IoError::InvalidState(format!(
13340 "chunk {c} is {} bytes, too short for elements {lo}..{hi}",
13341 chunk.len()
13342 )));
13343 }
13344 out[dst..dst + len].copy_from_slice(&chunk[src..src + len]);
13345 }
13346 Ok(out)
13347 }
13348
13349 /// Free the global heap objects `refs` names, so replacing a vlen element
13350 /// does not strand what it used to point at.
13351 ///
13352 /// Callers pass refs only for *top-level* vlen datatypes (the
13353 /// `collect_refs` / `is_vlen` decisions at the prune, delete and
13354 /// attribute-release sites all match `VarLenString`/`VarLenSequence`).
13355 /// A compound datatype with vlen members — writable only by a foreign
13356 /// library, never by this crate — keeps its members' heap objects when
13357 /// its storage is pruned, deleted or replaced.
13358 ///
13359 /// This is libhdf5's `H5HG_remove` reached through `H5T__vlen_disk_delete`:
13360 /// the object leaves its collection, the collection is rewritten at its
13361 /// existing size with the recovered bytes given to the free-space marker,
13362 /// and a collection that ends up empty returns its block to the allocator.
13363 /// A rewritten collection's recovered space is listed in `cwfs` for
13364 /// [`insert_vlen_objects`](Self::insert_vlen_objects) to pack into; a
13365 /// freed block leaves the list.
13366 /// A nil reference (address 0 or `UNDEF_ADDR`) names no object. The
13367 /// address decides, not the sequence length: this crate's writers store
13368 /// even the empty string as a real heap object, so a zero-length reference
13369 /// with a defined address still holds one that must be released. libhdf5
13370 /// diverges here against itself — `H5T__vlen_disk_delete` returns before
13371 /// `H5HG_remove` when the sequence length is zero, yet its write path
13372 /// (`H5VL__native_blob_put`) inserts a heap object even for an empty
13373 /// sequence, stranding it forever. The address rule frees those objects.
13374 ///
13375 /// Heap objects carry no reference count on this path, matching libhdf5:
13376 /// its vlen code never calls `H5HG_link` (only the virtual-dataset layer
13377 /// does). Releasing the same reference twice is absorbed by the
13378 /// missing-index check below, but a crafted file in which two elements
13379 /// share one heap object would lose it for the survivor when either is
13380 /// replaced — the same exposure the file has under libhdf5. This crate's
13381 /// writers never share: each element write inserts its own object.
13382 ///
13383 /// Under SWMR nothing is freed and no collection is rewritten: a reader may
13384 /// be following those references, the same reason `place_chunk` keeps a
13385 /// relocated chunk's old block.
13386 fn release_vlen_references(&self, refs: &[u8]) -> IoResult<()> {
13387 use crate::format::global_heap::{decode_vlen_reference, vlen_reference_size};
13388
13389 let ref_size = vlen_reference_size(&self.ctx);
13390 if ref_size == 0 || refs.len() < ref_size {
13391 return Ok(());
13392 }
13393
13394 // Group by collection so one holding several replaced objects is read,
13395 // rewritten and judged empty exactly once.
13396 let mut per_collection: std::collections::BTreeMap<u64, Vec<u16>> = Default::default();
13397 for r in refs.chunks_exact(ref_size) {
13398 let (_seq_len, addr, obj_idx) = decode_vlen_reference(r, &self.ctx)?;
13399 if addr == 0 || addr == UNDEF_ADDR {
13400 continue;
13401 }
13402 let Ok(idx) = u16::try_from(obj_idx) else {
13403 return Err(crate::io::IoError::InvalidState(format!(
13404 "global heap object index {obj_idx} does not fit the 16-bit on-disk field"
13405 )));
13406 };
13407 per_collection.entry(addr).or_default().push(idx);
13408 }
13409 self.remove_heap_objects(per_collection)
13410 }
13411
13412 /// Remove global heap objects — `H5HG_remove` — given the object indices
13413 /// grouped by the collection they live in.
13414 ///
13415 /// The single owner of heap-object removal: the vlen release path above
13416 /// reaches it with the objects a replaced element used to name, and
13417 /// [`release_dataset_storage`](Self::release_dataset_storage) with the
13418 /// one mapping-list object a deleted virtual dataset owned, which is what
13419 /// `H5D__virtual_delete` frees the same way.
13420 fn remove_heap_objects(
13421 &self,
13422 per_collection: std::collections::BTreeMap<u64, Vec<u16>>,
13423 ) -> IoResult<()> {
13424 use crate::format::global_heap::GlobalHeapCollection;
13425
13426 if self.swmr_active {
13427 return Ok(());
13428 }
13429
13430 // An object on its way out can hold no stamp: a reference this
13431 // session wrote into it would otherwise be stamped into whatever a
13432 // later insert puts at the same index. Pruned here, by the one owner
13433 // of removal, so no release path — attribute replacement, element
13434 // rewrite, dataset deletion — can leave one behind.
13435 self.pending_heap_references.lock().retain(|p| {
13436 !per_collection
13437 .get(&p.collection)
13438 .is_some_and(|indices| indices.contains(&p.index))
13439 });
13440
13441 // The `cwfs` lock is held across the sweep: it serializes these
13442 // collection-block rewrites (and frees) against
13443 // `insert_vlen_objects`, which may be packing new objects into the
13444 // same blocks.
13445 let objhdr = GlobalHeapCollection::object_disk_size(&self.ctx, 0);
13446 let mut cwfs = self.cwfs.lock();
13447 for (addr, indices) in per_collection {
13448 // A collection is at least 4096 bytes (H5HG_MINALLOC) and most are
13449 // exactly that, so one read usually covers the whole image; only
13450 // an oversized collection needs a second read at its declared size.
13451 let mut image = self.handle.read_at_most(addr, 4096)?;
13452 let declared = GlobalHeapCollection::decode_size(&image, &self.ctx)?;
13453 if declared > image.len() {
13454 image = self.handle.read_at(addr, declared)?;
13455 }
13456 let (mut gcol, _) = GlobalHeapCollection::decode(&image[..declared], &self.ctx)?;
13457 let mut removed_any = false;
13458 for idx in indices {
13459 removed_any |= gcol.remove_object(idx);
13460 }
13461 // Every index already gone (a stale or duplicate reference):
13462 // leave the image alone. Rewriting is not just wasted I/O — a
13463 // 100%-full collection written by libhdf5 has no free-space
13464 // marker, so re-encoding it at its declared size cannot fit one
13465 // and the whole element update would fail.
13466 if !removed_any {
13467 continue;
13468 }
13469 if gcol.is_empty() {
13470 self.allocator
13471 .free(addr, declared as u64, FreeSpaceClass::RawData);
13472 // The block is gone; a lingering entry would let an insert
13473 // pack into space the allocator can hand to anything.
13474 cwfs.retain(|e| e.addr != addr);
13475 } else {
13476 let rewritten = gcol.encode_at_size(&self.ctx, declared)?;
13477 self.handle.write_at(addr, &rewritten)?;
13478 // The recovered bytes are packable now — list them, the way
13479 // libhdf5's `H5HG_remove` adds the heap to `cwfs`.
13480 if let Some(free) = gcol.free_space_at(&self.ctx, declared) {
13481 if free >= 2 * objhdr {
13482 cwfs_note(&mut cwfs, addr, declared, free);
13483 }
13484 }
13485 }
13486 }
13487 Ok(())
13488 }
13489
13490 /// Add an attribute to a dataset.
13491 ///
13492 /// The attribute will be written as a message in the dataset's object
13493 /// header when the file is finalized.
13494 pub fn add_dataset_attribute(&self, ds_index: usize, attr: AttributeMessage) -> IoResult<()> {
13495 self.set_attribute(AttrTarget::Dataset(ds_index), attr)
13496 }
13497
13498 /// Build a variable-length UTF-8 string attribute message.
13499 ///
13500 /// The string is stored as one object in a global heap collection and the
13501 /// returned [`AttributeMessage`] carries the vlen reference as its data,
13502 /// with a vlen-string datatype and scalar dataspace. h5py reads the value
13503 /// back as a Python `str` (not `bytes`).
13504 ///
13505 /// This is the single owner of vlen-string-attribute construction: every
13506 /// public string-attribute setter (dataset, group, root, and the SWMR
13507 /// equivalents) routes through it, so a `VarLenUnicode` /
13508 /// `set_attr_string` value is always stored as a true variable-length
13509 /// string rather than the fixed-length string it used to be.
13510 ///
13511 /// The string's heap object is placed by
13512 /// [`insert_vlen_objects`](Self::insert_vlen_objects), so consecutive
13513 /// attributes pack into a shared collection instead of each paying the
13514 /// 4096-byte `H5HG_MINALLOC` minimum for a block that holds one string.
13515 fn vlen_string_attribute(&self, name: &str, value: &str) -> IoResult<AttributeMessage> {
13516 use crate::format::global_heap::encode_vlen_reference;
13517 use crate::format::messages::dataspace::DataspaceMessage;
13518 use crate::format::messages::datatype::DatatypeMessage;
13519
13520 let (gcol_addr, obj_idx) = self.insert_vlen_objects(&[value.as_bytes()])?[0];
13521 let seq_len = crate::format::global_heap::vlen_seq_len(value.len())?;
13522 let data = encode_vlen_reference(seq_len, gcol_addr, obj_idx as u32, &self.ctx);
13523 Ok(AttributeMessage {
13524 name: name.to_string(),
13525 datatype: DatatypeMessage::vlen_string_utf8(),
13526 dataspace: DataspaceMessage::scalar(),
13527 data,
13528 })
13529 }
13530
13531 /// Build a variable-length UTF-8 string **array** attribute message.
13532 ///
13533 /// The N-dimensional counterpart of
13534 /// [`vlen_string_attribute`](Self::vlen_string_attribute): every element
13535 /// string is stored as one object in a single global heap collection, and
13536 /// the attribute data is the row-major concatenation of one vlen reference
13537 /// per element. The datatype is the same vlen-string datatype; the dataspace
13538 /// is the simple dataspace described by `shape` (an empty `shape` is a
13539 /// scalar). h5py reads the value back as a numpy array of Python `str` with
13540 /// that shape.
13541 ///
13542 /// The caller owns the invariant that `values.len()` equals the product of
13543 /// `shape` (the public setters validate it before calling). The element
13544 /// objects are placed by
13545 /// [`insert_vlen_objects`](Self::insert_vlen_objects) — a zero-element
13546 /// array allocates nothing, and each reference carries its element's
13547 /// own collection address.
13548 fn vlen_string_array_attribute(
13549 &self,
13550 name: &str,
13551 values: &[&str],
13552 shape: &[u64],
13553 ) -> IoResult<AttributeMessage> {
13554 use crate::format::global_heap::encode_vlen_reference;
13555 use crate::format::messages::dataspace::DataspaceMessage;
13556 use crate::format::messages::datatype::DatatypeMessage;
13557
13558 debug_assert_eq!(
13559 values.len() as u64,
13560 shape.iter().product::<u64>(),
13561 "vlen_string_array_attribute values.len() must equal product(shape)"
13562 );
13563
13564 let items: Vec<&[u8]> = values.iter().map(|v| v.as_bytes()).collect();
13565 let placements = self.insert_vlen_objects(&items)?;
13566
13567 let mut data = Vec::with_capacity(values.len() * 16);
13568 for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
13569 data.extend_from_slice(&encode_vlen_reference(
13570 crate::format::global_heap::vlen_seq_len(values[i].len())?,
13571 gcol_addr,
13572 obj_idx as u32,
13573 &self.ctx,
13574 ));
13575 }
13576 Ok(AttributeMessage {
13577 name: name.to_string(),
13578 datatype: DatatypeMessage::vlen_string_utf8(),
13579 dataspace: DataspaceMessage::simple(shape),
13580 data,
13581 })
13582 }
13583
13584 /// Set a user-defined fill value for a dataset.
13585 ///
13586 /// `bytes` must be exactly one element wide (matching the dataset's
13587 /// datatype). The value is emitted as a `fill_defined = 2` fill-value
13588 /// message in the dataset object header when the file is finalized.
13589 ///
13590 /// IMPORTANT: for a *contiguous* dataset this also immediately writes
13591 /// the tiled fill value across the whole data block, so it must be
13592 /// called BEFORE any `write_dataset_raw` / `write_slice` — otherwise the
13593 /// fill write clobbers data already written. (The high-level builder
13594 /// always calls this right after creating the dataset.)
13595 pub fn set_dataset_fill_value(&self, ds_index: usize, bytes: Vec<u8>) -> IoResult<()> {
13596 let count = self.dataset_count();
13597 if ds_index >= count {
13598 return Err(crate::io::IoError::InvalidState(format!(
13599 "dataset index {} out of range",
13600 ds_index
13601 )));
13602 }
13603 let ds_ref = self.ds(ds_index);
13604 let mut ds = ds_ref.lock();
13605 let es = ds.datatype.element_size() as usize;
13606 if bytes.len() != es {
13607 return Err(crate::io::IoError::InvalidState(format!(
13608 "fill value is {} bytes but dataset element size is {}",
13609 bytes.len(),
13610 es
13611 )));
13612 }
13613 // For a dataset with no per-chunk fill path the fill-value message
13614 // only declares fill-on-allocation — tile the fill value across the
13615 // storage itself now, so unwritten elements read back as the fill
13616 // value. Which storage that is depends on the layout: a compact
13617 // dataset's is the image inside its layout message, a contiguous
13618 // one's is its data block. (The high-level builder calls this
13619 // immediately after create, before any data is written; a subsequent
13620 // write_raw/write_slice overwrites its region.)
13621 // An implicitly indexed dataset is filled here too, and for the same
13622 // reason: that index has no per-chunk fill path because it has no
13623 // per-chunk anything — its whole chunk grid is one run of space,
13624 // allocated and filled at create like a contiguous block. So the test
13625 // is not "is it chunked" but "does something else fill its chunks".
13626 let fills_per_chunk = ds
13627 .chunk_index_kind()
13628 .is_some_and(|k| k != ChunkIndexKind::Implicit);
13629 // `H5D_FILL_TIME_NEVER` means exactly this: the library never writes
13630 // the fill value into allocated storage. Call `set_dataset_fill_time`
13631 // before this method to have it observed here — the storage this
13632 // would otherwise tile keeps whatever zero bytes its allocation
13633 // already gave it.
13634 if !fills_per_chunk && ds.fill_time != FILL_TIME_NEVER {
13635 if let Some(len) = ds.compact.as_ref().map(Vec::len) {
13636 ds.compact = Some(crate::format::messages::fill_value::tiled_fill(
13637 len,
13638 Some(&bytes),
13639 ));
13640 } else {
13641 // An implicit index's chunk grid is filled as one run, the
13642 // same way a contiguous block is, and storage this file did
13643 // not allocate is not filled at all; `allocated_storage_run`
13644 // is where both of those are decided.
13645 let run = ds.allocated_storage_run();
13646 if let Some((target, data_size)) = run.filter(|&(_, size)| size > 0) {
13647 let filled = crate::format::messages::fill_value::tiled_fill(
13648 data_size as usize,
13649 Some(&bytes),
13650 );
13651 self.write_contiguous_bytes(&target, 0, &filled)?;
13652 }
13653 }
13654 }
13655
13656 ds.fill_value = Some(bytes);
13657 ds.header_dirty = true;
13658 Ok(())
13659 }
13660
13661 /// Set when the fill value is written into allocated storage —
13662 /// `H5Pset_fill_time`. `time` is one of [`FILL_TIME_ALLOC`],
13663 /// [`FILL_TIME_NEVER`], [`FILL_TIME_IFSET`]; anything else is rejected
13664 /// the way `H5Pset_fill_time` rejects an out-of-range `H5D_fill_time_t`.
13665 ///
13666 /// Call this before [`set_dataset_fill_value`](Self::set_dataset_fill_value)
13667 /// so that a `FILL_TIME_NEVER` policy is in place before that call
13668 /// decides whether to eager-tile the value into storage. (The
13669 /// high-level builder always calls it first.)
13670 pub fn set_dataset_fill_time(&self, ds_index: usize, time: u8) -> IoResult<()> {
13671 if !matches!(time, FILL_TIME_ALLOC | FILL_TIME_NEVER | FILL_TIME_IFSET) {
13672 return Err(crate::io::IoError::InvalidState(format!(
13673 "invalid fill time {time}; must be {FILL_TIME_ALLOC} (alloc), \
13674 {FILL_TIME_NEVER} (never) or {FILL_TIME_IFSET} (if-set)"
13675 )));
13676 }
13677 let count = self.dataset_count();
13678 if ds_index >= count {
13679 return Err(crate::io::IoError::InvalidState(format!(
13680 "dataset index {} out of range",
13681 ds_index
13682 )));
13683 }
13684 let ds_ref = self.ds(ds_index);
13685 let mut ds = ds_ref.lock();
13686 ds.fill_time = time;
13687 ds.header_dirty = true;
13688 Ok(())
13689 }
13690
13691 /// Allocate a `chunk_bytes`-sized buffer pre-filled with dataset
13692 /// `ds_index`'s fill value (tiled one element wide), or zeros when no
13693 /// user-defined fill value exists.
13694 ///
13695 /// Every partial chunk the writer emits must be built on top of a
13696 /// buffer from this method, so that the unwritten element region of an
13697 /// allocated chunk reads back as the fill value rather than zero.
13698 ///
13699 /// Unconditional: a shrink's straddler refill
13700 /// (`refill_chunk_beyond_extent`) calls this to repair data about to
13701 /// become reachable again, which libhdf5's `H5D__chunk_prune_fill` does
13702 /// regardless of the fill-time policy. [`new_write_chunk_buffer`](Self::new_write_chunk_buffer)
13703 /// is the gated counterpart for a chunk touched for the first time
13704 /// during a write, where the policy does apply.
13705 pub(crate) fn new_chunk_buffer(&self, ds_index: usize, chunk_bytes: usize) -> Vec<u8> {
13706 let ds = self.ds(ds_index);
13707 let m = ds.lock();
13708 let fv = m.fill_value.as_deref();
13709 crate::format::messages::fill_value::tiled_fill(chunk_bytes, fv)
13710 }
13711
13712 /// The buffer a chunk gets the first time a write touches it — this
13713 /// dataset's allocation-time fill gate. `H5D__chunk_lock`'s cache-miss
13714 /// path (H5Dchunk.c:4894) fills such a buffer only for `ALLOC`, or for
13715 /// `IFSET` with a fill value defined; `NEVER` leaves it as the zeros a
13716 /// fresh buffer already has. Everything else about the buffer is
13717 /// [`new_chunk_buffer`](Self::new_chunk_buffer)'s.
13718 fn new_write_chunk_buffer(&self, ds_index: usize, chunk_bytes: usize) -> Vec<u8> {
13719 let never = {
13720 let ds = self.ds(ds_index);
13721 let m = ds.lock();
13722 m.fill_time == FILL_TIME_NEVER
13723 };
13724 if never {
13725 vec![0u8; chunk_bytes]
13726 } else {
13727 self.new_chunk_buffer(ds_index, chunk_bytes)
13728 }
13729 }
13730
13731 /// Write `n_frames` whole frames whose first row is `base_frame`, for
13732 /// whichever chunk index the dataset uses and whatever its chunk shape.
13733 ///
13734 /// The single owner of an append's chunk writes. The frames are one
13735 /// hyperslab — rows `base_frame .. base_frame + n_frames` over the full
13736 /// row shape — so the write goes through
13737 /// [`write_slice_chunked`](Self::write_slice_chunked), the same engine
13738 /// `write_slice` uses: a chunk the span covers completely is written
13739 /// straight through, a partial one is read-modify-write on top of what
13740 /// is stored (or the fill value), and a chunk row narrower or wider
13741 /// than the frame row is scattered at the chunk stride. The previous
13742 /// owner required the extensible-array index and packed rows at the
13743 /// frame stride, so appends to a fixed-array or v2 B-tree dataset
13744 /// failed at close and lost the buffered rows.
13745 ///
13746 /// The caller holds the dataset's op lock or the writer exclusively.
13747 pub(crate) fn write_append_frames(
13748 &self,
13749 ds_index: usize,
13750 base_frame: u64,
13751 n_frames: u64,
13752 frames: &[u8],
13753 ) -> IoResult<()> {
13754 if n_frames == 0 {
13755 return Ok(());
13756 }
13757 let geo = self.chunk_geometry(ds_index)?;
13758 let mut starts = vec![0u64; geo.dims.len()];
13759 starts[0] = base_frame;
13760 let mut counts = geo.dims.clone();
13761 counts[0] = n_frames;
13762 let expected = counts.iter().product::<u64>() * geo.element_size;
13763 if frames.len() as u64 != expected {
13764 return Err(crate::io::IoError::InvalidState(format!(
13765 "{n_frames} frames at rows {base_frame}.. need {expected} bytes, got {}",
13766 frames.len()
13767 )));
13768 }
13769 self.write_slice_chunked(ds_index, &starts, &counts, frames)
13770 }
13771
13772 /// Write the dataset's append buffer (if any) into its chunks and clear
13773 /// it. The single owner of the buffer-to-chunks transition: the flush at
13774 /// close, an append meeting a non-contiguous buffer, and any operation
13775 /// about to write rows the buffer holds all come through here.
13776 ///
13777 /// The caller holds the dataset's op lock or the writer exclusively —
13778 /// the take and the frame writes are separate acquisitions.
13779 pub(crate) fn flush_append_buffer(&self, ds_index: usize) -> IoResult<()> {
13780 let taken = { self.ds(ds_index).lock().append.take() };
13781 match taken {
13782 Some(b) => self.write_append_frames(ds_index, b.base, b.frames, &b.bytes),
13783 None => Ok(()),
13784 }
13785 }
13786
13787 /// Flush the append buffer when rows `start_row .. end_row` intersect
13788 /// the buffered range — those rows' current content is the buffer, and
13789 /// writing them on disk while the buffer still holds them would be
13790 /// undone by the flush at close.
13791 ///
13792 /// The caller holds the dataset's op lock or the writer exclusively.
13793 pub(crate) fn flush_append_buffer_if_intersecting(
13794 &self,
13795 ds_index: usize,
13796 start_row: u64,
13797 end_row: u64,
13798 ) -> IoResult<()> {
13799 let intersects = {
13800 let ds = self.ds(ds_index);
13801 let m = ds.lock();
13802 m.append
13803 .as_ref()
13804 .is_some_and(|b| start_row < b.base + b.frames && end_row > b.base)
13805 };
13806 if intersects {
13807 self.flush_append_buffer(ds_index)
13808 } else {
13809 Ok(())
13810 }
13811 }
13812
13813 /// Read an already-written chunk's *decompressed* bytes when the chunk
13814 /// is allocated and resolvable from the in-memory extensible-array
13815 /// index. Handles index-block and data-block chunks, filtered and
13816 /// unfiltered.
13817 ///
13818 /// Returns `Ok(None)` only when the chunk has never been written
13819 /// (address `UNDEF`) or the index genuinely does not reach it, which for
13820 /// a read-modify-write means the chunk's content is the fill value.
13821 pub(crate) fn read_chunk_if_present(
13822 &self,
13823 ds_index: usize,
13824 chunk_idx: u64,
13825 ) -> IoResult<Option<Vec<u8>>> {
13826 // Phase 1: resolve the chunk's location from the in-memory index.
13827 // Hold the slot guard through Phase 1: `chunked` borrows it, while the
13828 // `self.handle`/`self.ctx` reads below touch disjoint fields.
13829 let ds = self.ds(ds_index);
13830 let m = ds.lock();
13831 let element_size = m.datatype.element_size() as u64;
13832 let pipeline = m.filter_pipeline.clone();
13833 let Some(chunked) = m.chunked.as_ref() else {
13834 return Ok(None);
13835 };
13836 let chunk_bytes = chunked.chunk_dims.iter().product::<u64>() * element_size;
13837 let max_nelmts_bits = chunked.earray_params.max_nelmts_bits;
13838 let chunk_size_len = chunked.chunk_size_len;
13839 let is_filtered = chunked.filt_iblk.is_some();
13840
13841 // The chunk entry is either read straight from an index block, or
13842 // located via a data block that must itself be read from disk.
13843 enum Loc {
13844 Direct(u64, u64, u32),
13845 DataBlock {
13846 dblk_addr: u64,
13847 offset: usize,
13848 nelmts: usize,
13849 },
13850 }
13851
13852 // Resolve the chunk's location with the libhdf5-compatible EA
13853 // geometry (super-block-grouped data blocks), matching `record_ea_chunk`.
13854 let ea_loc = {
13855 let p = &chunked.earray_params;
13856 EaGeometry::new(
13857 p.idx_blk_elmts,
13858 p.data_blk_min_elmts,
13859 p.sup_blk_min_data_ptrs,
13860 p.max_nelmts_bits,
13861 p.max_dblk_page_nelmts_bits,
13862 )?
13863 .locate(chunk_idx)?
13864 };
13865 let loc = match ea_loc {
13866 EaLoc::Index { elem } => {
13867 if is_filtered {
13868 let e = &chunked.filt_iblk.as_ref().unwrap().elements[elem];
13869 Loc::Direct(e.addr, e.nbytes, e.filter_mask)
13870 } else {
13871 Loc::Direct(chunked.ea_iblk.elements[elem], chunk_bytes, 0)
13872 }
13873 }
13874 EaLoc::Dblk(l) => {
13875 if l.paged {
13876 return Err(crate::io::IoError::InvalidState(format!(
13877 "chunk index {} lives in a paged extensible-array data \
13878 block, which is not yet supported for read-modify-write",
13879 chunk_idx
13880 )));
13881 }
13882 let dblk_addr = match l.path {
13883 EaDblkPath::Direct { idx } => {
13884 if is_filtered {
13885 chunked.filt_iblk.as_ref().unwrap().dblk_addrs[idx]
13886 } else {
13887 chunked.ea_iblk.dblk_addrs[idx]
13888 }
13889 }
13890 EaDblkPath::ViaSblk {
13891 sblk_off,
13892 local_dblk,
13893 ndblks_in_sblk,
13894 ..
13895 } => {
13896 let sblk_addr = if is_filtered {
13897 chunked.filt_iblk.as_ref().unwrap().sblk_addrs[sblk_off]
13898 } else {
13899 chunked.ea_iblk.sblk_addrs[sblk_off]
13900 };
13901 if sblk_addr == UNDEF_ADDR {
13902 return Ok(None);
13903 }
13904 let sb_buf = self.handle.read_at_most(sblk_addr, 65536)?;
13905 let sb = ExtensibleArraySuperBlock::decode(
13906 &sb_buf,
13907 &self.ctx,
13908 max_nelmts_bits,
13909 ndblks_in_sblk,
13910 0,
13911 )?;
13912 sb.dblk_addrs[local_dblk]
13913 }
13914 };
13915 if dblk_addr == UNDEF_ADDR {
13916 return Ok(None);
13917 }
13918 Loc::DataBlock {
13919 dblk_addr,
13920 offset: l.offset_in_dblk as usize,
13921 nelmts: l.dblk_nelmts as usize,
13922 }
13923 }
13924 };
13925
13926 // Phase 2: resolve through the data block (if needed) and read. The
13927 // mask is the chunk's filter mask (0 for unfiltered), so a chunk
13928 // written via a direct chunk write with a skipped filter is reversed
13929 // correctly during read-modify-write.
13930 let (addr, nbytes, mask) = match loc {
13931 Loc::Direct(a, n, m) => (a, n, m),
13932 Loc::DataBlock {
13933 dblk_addr,
13934 offset,
13935 nelmts,
13936 } => {
13937 let buf = self.handle.read_at_most(dblk_addr, 65536)?;
13938 if is_filtered {
13939 let dblk = FilteredDataBlock::decode(
13940 &buf,
13941 &self.ctx,
13942 max_nelmts_bits,
13943 nelmts,
13944 chunk_size_len,
13945 )?;
13946 let e = &dblk.elements[offset];
13947 (e.addr, e.nbytes, e.filter_mask)
13948 } else {
13949 let dblk =
13950 ExtensibleArrayDataBlock::decode(&buf, &self.ctx, max_nelmts_bits, nelmts)?;
13951 (dblk.elements[offset], chunk_bytes, 0)
13952 }
13953 }
13954 };
13955 self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
13956 }
13957
13958 /// Read one stored chunk block and undo its filters.
13959 ///
13960 /// `nbytes` is the *stored* length and `mask` the chunk's filter mask, so
13961 /// a chunk written by a direct chunk write with a skipped filter is
13962 /// reversed correctly. `Ok(None)` means the chunk has no block yet — the
13963 /// single place that judgement is made, shared by every chunk index.
13964 fn read_chunk_block(
13965 &self,
13966 pipeline: Option<&FilterPipeline>,
13967 addr: u64,
13968 nbytes: u64,
13969 mask: u32,
13970 ) -> IoResult<Option<Vec<u8>>> {
13971 if addr == UNDEF_ADDR || nbytes == 0 {
13972 return Ok(None);
13973 }
13974 let raw = self.handle.read_at(addr, nbytes as usize)?;
13975 match pipeline {
13976 Some(pl) => Ok(Some(filter::reverse_filters_masked(pl, &raw, mask)?)),
13977 None => Ok(Some(raw)),
13978 }
13979 }
13980
13981 /// Read the *decompressed* bytes of the chunk at `chunk_coords`, whichever
13982 /// chunk index the dataset uses, or `Ok(None)` when that chunk has never
13983 /// been written.
13984 ///
13985 /// This is the read half of a partial-chunk read-modify-write: a hyperslab
13986 /// write that covers only part of a chunk must start from what is already
13987 /// there. Keeping one entry point for all three index types is what lets
13988 /// [`write_slice`](Self::write_slice) stay index-agnostic.
13989 pub(crate) fn read_chunk_at_coords(
13990 &self,
13991 ds_index: usize,
13992 chunk_coords: &[u64],
13993 ) -> IoResult<Option<Vec<u8>>> {
13994 let geo = self.chunk_geometry(ds_index)?;
13995 // Only the linearly-addressed indexes compute a slot; a v2 B-tree is
13996 // keyed by the coordinates themselves (and may hold unlimited inner
13997 // dimensions, which have no linear slot).
13998 match geo.kind {
13999 ChunkIndexKind::ExtensibleArray => {
14000 let linear = geo.linear_index(chunk_coords)?;
14001 self.read_chunk_if_present(ds_index, linear)
14002 }
14003 ChunkIndexKind::FixedArray => {
14004 let linear = geo.linear_index(chunk_coords)?;
14005 let ds = self.ds(ds_index);
14006 let m = ds.lock();
14007 let pipeline = m.filter_pipeline.clone();
14008 let fa = m.fixed_array.as_ref().unwrap();
14009 let lidx = linear as usize;
14010 let (addr, nbytes, mask) = if pipeline.is_some() {
14011 match fa.fa_dblk.filtered_elements.get(lidx) {
14012 Some(e) => (e.address, e.chunk_size, e.filter_mask),
14013 None => return Ok(None),
14014 }
14015 } else {
14016 match fa.fa_dblk.elements.get(lidx) {
14017 Some(&a) => (a, geo.chunk_bytes(), 0),
14018 None => return Ok(None),
14019 }
14020 };
14021 drop(m);
14022 self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14023 }
14024 ChunkIndexKind::BtreeV2 => {
14025 let ds = self.ds(ds_index);
14026 let m = ds.lock();
14027 let pipeline = m.filter_pipeline.clone();
14028 let bt2 = m.btree_v2.as_ref().unwrap();
14029 // A filtered index records the stored size and mask per chunk;
14030 // an unfiltered one stores whole chunks, so their size is the
14031 // chunk shape and no filter ran.
14032 let found = if bt2.index.filtered {
14033 bt2.index
14034 .lookup_filtered(chunk_coords)
14035 .map(|r| (r.chunk_address, r.chunk_size, r.filter_mask))
14036 } else {
14037 bt2.index
14038 .lookup(chunk_coords)
14039 .map(|r| (r.chunk_address, geo.chunk_bytes(), 0))
14040 };
14041 drop(m);
14042 match found {
14043 Some((addr, nbytes, mask)) => {
14044 self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14045 }
14046 None => Ok(None),
14047 }
14048 }
14049 // Every chunk of an implicitly indexed dataset exists from the
14050 // moment the dataset does, so there is no "never written" answer
14051 // to give: an untouched chunk reads back as the fill value the
14052 // create wrote there.
14053 ChunkIndexKind::Implicit => {
14054 let (grid, offset) = self.implicit_chunk_slot(ds_index, &geo, chunk_coords)?;
14055 self.read_chunk_block(None, grid + offset, geo.chunk_bytes(), 0)
14056 }
14057 // A single-chunk dataset's one chunk is never written until its
14058 // first write (unless the dataset was early-allocated and
14059 // unfiltered, in which case create already gave it an address) —
14060 // unlike Implicit, `UNDEF_ADDR` here is a real "never written".
14061 ChunkIndexKind::SingleChunk => {
14062 let ds = self.ds(ds_index);
14063 let m = ds.lock();
14064 let pipeline = m.filter_pipeline.clone();
14065 let sc = m.single_chunk.as_ref().unwrap();
14066 if sc.data_addr == UNDEF_ADDR {
14067 return Ok(None);
14068 }
14069 let (addr, nbytes, mask) = if pipeline.is_some() {
14070 (sc.data_addr, sc.nbytes, sc.filter_mask)
14071 } else {
14072 (sc.data_addr, geo.chunk_bytes(), 0)
14073 };
14074 drop(m);
14075 self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14076 }
14077 ChunkIndexKind::BtreeV1 => {
14078 let ds = self.ds(ds_index);
14079 let m = ds.lock();
14080 let pipeline = m.filter_pipeline.clone();
14081 let bt1 = m.btree_v1.as_ref().unwrap();
14082 let found = bt1
14083 .position(chunk_coords)
14084 .ok()
14085 .map(|i| &bt1.records[i])
14086 .map(|r| (r.address, r.nbytes as u64, r.filter_mask));
14087 drop(m);
14088 match found {
14089 Some((addr, nbytes, mask)) => {
14090 self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14091 }
14092 None => Ok(None),
14093 }
14094 }
14095 }
14096 }
14097
14098 /// The slot one chunk of an implicitly indexed dataset occupies: the
14099 /// address its whole chunk grid starts at, and the chunk's offset within
14100 /// that grid. `data_addr + linear_index * chunk_bytes` is the whole of
14101 /// that index (`H5D__none_idx_get_addr`, H5Dnone.c).
14102 ///
14103 /// The one place a chunk of such a dataset is placed — read and write both
14104 /// come through here, so the bounds check below covers both. The grid it
14105 /// names is [`DatasetInfo::implicit_grid`], which is why the write side
14106 /// can hand [`ContiguousTarget::Local`] to
14107 /// [`write_contiguous_bytes`](Self::write_contiguous_bytes) without asking
14108 /// anything: the external and virtual destinations that owner also knows
14109 /// about are unreachable from a chunked dataset.
14110 fn implicit_chunk_slot(
14111 &self,
14112 ds_index: usize,
14113 geo: &ChunkGeometry,
14114 chunk_coords: &[u64],
14115 ) -> IoResult<(u64, u64)> {
14116 let linear = geo.linear_index(chunk_coords)?;
14117 let ds = self.ds(ds_index);
14118 let m = ds.lock();
14119 let (grid, grid_size) = m.implicit_grid().ok_or_else(|| {
14120 crate::io::IoError::InvalidState("no implicitly indexed chunk grid".into())
14121 })?;
14122 let offset = linear.checked_mul(geo.chunk_bytes()).ok_or_else(|| {
14123 crate::io::IoError::InvalidState("implicit chunk offset overflows u64".into())
14124 })?;
14125 if offset + geo.chunk_bytes() > grid_size {
14126 return Err(crate::io::IoError::InvalidState(format!(
14127 "chunk {chunk_coords:?} lies outside the {grid_size} bytes of chunk space \
14128 this implicitly indexed dataset was created with"
14129 )));
14130 }
14131 Ok((grid, offset))
14132 }
14133
14134 /// Write one whole chunk addressed by its grid coordinates, whichever
14135 /// chunk index the dataset uses. `data` is the chunk's unfiltered bytes;
14136 /// the dataset's filter pipeline (if any) runs here.
14137 ///
14138 /// The write half of the pair with
14139 /// [`read_chunk_at_coords`](Self::read_chunk_at_coords). Unlike the
14140 /// dataset-level `write_chunk_at`, this never grows the dataspace — a
14141 /// hyperslab write is bounded by the current extent by definition.
14142 ///
14143 /// The caller holds the dataset's op lock or the writer exclusively.
14144 pub(crate) fn write_chunk_at_coords(
14145 &self,
14146 ds_index: usize,
14147 chunk_coords: &[u64],
14148 data: &[u8],
14149 ) -> IoResult<()> {
14150 let geo = self.chunk_geometry(ds_index)?;
14151 match geo.kind {
14152 ChunkIndexKind::ExtensibleArray => {
14153 let linear = geo.linear_index(chunk_coords)?;
14154 self.write_chunk_inner(ds_index, linear, data)
14155 }
14156 ChunkIndexKind::FixedArray => {
14157 self.write_chunk_fixed_array_inner(ds_index, chunk_coords, data)
14158 }
14159 ChunkIndexKind::BtreeV2 => {
14160 self.write_chunk_btree_v2_inner(ds_index, chunk_coords, data)
14161 }
14162 ChunkIndexKind::Implicit => {
14163 self.write_chunk_implicit_inner(ds_index, chunk_coords, data)
14164 }
14165 ChunkIndexKind::SingleChunk => {
14166 self.write_chunk_single_chunk_inner(ds_index, chunk_coords, data)
14167 }
14168 ChunkIndexKind::BtreeV1 => {
14169 self.write_chunk_btree_v1_inner(ds_index, chunk_coords, data)
14170 }
14171 }
14172 }
14173
14174 /// Write one whole chunk to a dataset indexed by a version-1 B-tree.
14175 ///
14176 /// `chunk_coords` is the chunk's grid position. `data` is the chunk's
14177 /// unfiltered bytes; the dataset's filter pipeline runs here if it has
14178 /// one, and the key records the stored size and mask the way libhdf5's
14179 /// does (`H5D__btree_new_node`).
14180 ///
14181 /// The caller holds the dataset's op lock or the writer exclusively.
14182 pub(crate) fn write_chunk_btree_v1_inner(
14183 &self,
14184 ds_index: usize,
14185 chunk_coords: &[u64],
14186 data: &[u8],
14187 ) -> IoResult<()> {
14188 // Read what the write needs under a brief guard, then filter OUTSIDE
14189 // the lock, as every other index's write path does.
14190 let ds = self.ds(ds_index);
14191 let (chunk_bytes, pipeline) = {
14192 let m = ds.lock();
14193 let element_size = m.datatype.element_size() as u64;
14194 let bt1 = m.btree_v1.as_ref().ok_or_else(|| {
14195 crate::io::IoError::InvalidState("not a version-1 B-tree dataset".into())
14196 })?;
14197 (
14198 bt1.chunk_dims.iter().product::<u64>() * element_size,
14199 m.filter_pipeline.clone(),
14200 )
14201 };
14202 if data.len() as u64 != chunk_bytes {
14203 return Err(crate::io::IoError::InvalidState(format!(
14204 "chunk data size mismatch: expected {} bytes, got {}",
14205 chunk_bytes,
14206 data.len()
14207 )));
14208 }
14209
14210 let filtered;
14211 let stored = match pipeline {
14212 Some(ref pl) => {
14213 filtered = filter::apply_filters(pl, data)?;
14214 &filtered[..]
14215 }
14216 None => data,
14217 };
14218 self.record_btree_v1_chunk(ds_index, chunk_coords, stored, 0)
14219 }
14220
14221 /// Write a pre-filtered chunk verbatim to a version-1 B-tree dataset,
14222 /// recording the caller-supplied `filter_mask` — the classic-index half
14223 /// of the HDF5 "direct chunk write" (`H5Dwrite_chunk`).
14224 ///
14225 /// The caller holds the dataset's op lock or the writer exclusively.
14226 pub(crate) fn write_compressed_chunk_btree_v1_inner(
14227 &self,
14228 ds_index: usize,
14229 chunk_coords: &[u64],
14230 data: &[u8],
14231 filter_mask: u32,
14232 ) -> IoResult<()> {
14233 if self.ds(ds_index).lock().filter_pipeline.is_none() {
14234 return Err(crate::io::IoError::InvalidState(
14235 "write_chunk_raw requires a filtered dataset (an unfiltered chunk \
14236 is stored at its full size, so there is nothing for a stored size \
14237 or a filter mask to say)"
14238 .into(),
14239 ));
14240 }
14241 self.record_btree_v1_chunk(ds_index, chunk_coords, data, filter_mask)
14242 }
14243
14244 /// Place a chunk's already-final bytes in the file and record them in the
14245 /// version-1 B-tree under the caller-supplied `filter_mask`.
14246 ///
14247 /// Shared by the two writes above, so both reach the index through one
14248 /// placement rule. The records are kept in key order here — the bulk load
14249 /// at flush walks them in that order and a lookup bisects them.
14250 fn record_btree_v1_chunk(
14251 &self,
14252 ds_index: usize,
14253 chunk_coords: &[u64],
14254 final_bytes: &[u8],
14255 filter_mask: u32,
14256 ) -> IoResult<()> {
14257 let stored_len = final_bytes.len() as u64;
14258 // The key's size field is 32 bits wide (`H5D_btree_key_t::nbytes`),
14259 // which is also libhdf5's limit on a chunk in this index.
14260 let Ok(nbytes) = u32::try_from(stored_len) else {
14261 return Err(crate::io::IoError::InvalidState(format!(
14262 "stored chunk size {stored_len} does not fit in the 32-bit size \
14263 field of a version-1 B-tree chunk key"
14264 )));
14265 };
14266 let ds = self.ds(ds_index);
14267 let mut m = ds.lock();
14268 let bt1 = m.btree_v1.as_ref().ok_or_else(|| {
14269 crate::io::IoError::InvalidState("not a version-1 B-tree dataset".into())
14270 })?;
14271 if chunk_coords.len() != bt1.chunk_dims.len() {
14272 return Err(crate::io::IoError::InvalidState(format!(
14273 "chunk_coords has {} entries but the dataset has {} dimensions",
14274 chunk_coords.len(),
14275 bt1.chunk_dims.len()
14276 )));
14277 }
14278 // A coordinate past the maximum extent has no chunk to be: unlike the
14279 // array indexes there is no slot to run out of, so the bound is
14280 // checked here or not at all. An unlimited dimension has none.
14281 for (d, ((&c, &cd), &max)) in chunk_coords
14282 .iter()
14283 .zip(&bt1.chunk_dims)
14284 .zip(&bt1.max_dims)
14285 .enumerate()
14286 {
14287 if max != u64::MAX && c.saturating_mul(cd) >= max {
14288 return Err(crate::io::IoError::InvalidState(format!(
14289 "chunk coordinate {c} in dimension {d} is outside the maximum \
14290 extent {max}"
14291 )));
14292 }
14293 }
14294 let slot = bt1.position(chunk_coords);
14295 let old = slot.ok().map(|i| {
14296 let r = &bt1.records[i];
14297 (r.address, r.nbytes as u64)
14298 });
14299 // A rewrite whose stored size is unchanged stays where it is (always
14300 // so when unfiltered), one that no longer fits moves. See `place_chunk`.
14301 let address = self.place_chunk(old, stored_len);
14302 self.handle.write_at(address, final_bytes)?;
14303
14304 let bt1 = m.btree_v1.as_mut().unwrap();
14305 let record = BtreeV1ChunkRecord {
14306 scaled: chunk_coords.to_vec(),
14307 address,
14308 nbytes,
14309 filter_mask,
14310 };
14311 match slot {
14312 Ok(i) => bt1.records[i] = record,
14313 Err(i) => bt1.records.insert(i, record),
14314 }
14315 bt1.chunks_written += 1;
14316 Ok(())
14317 }
14318
14319 /// Write one whole chunk of an implicitly indexed dataset into the slot
14320 /// its coordinates name. There is no index to record anything in — the
14321 /// slot is where it always was — so this is the write in full.
14322 ///
14323 /// The bytes go through [`write_contiguous_bytes`](Self::write_contiguous_bytes),
14324 /// the one owner of a raw-byte write, against the grid
14325 /// [`implicit_chunk_slot`](Self::implicit_chunk_slot) names.
14326 ///
14327 /// The caller holds the dataset's op lock or the writer exclusively.
14328 pub(crate) fn write_chunk_implicit_inner(
14329 &self,
14330 ds_index: usize,
14331 chunk_coords: &[u64],
14332 data: &[u8],
14333 ) -> IoResult<()> {
14334 let geo = self.chunk_geometry(ds_index)?;
14335 let chunk_bytes = geo.chunk_bytes();
14336 if data.len() as u64 != chunk_bytes {
14337 return Err(crate::io::IoError::InvalidState(format!(
14338 "chunk data size mismatch: expected {} bytes, got {}",
14339 chunk_bytes,
14340 data.len()
14341 )));
14342 }
14343 let (grid, offset) = self.implicit_chunk_slot(ds_index, &geo, chunk_coords)?;
14344 self.write_contiguous_bytes(&ContiguousTarget::Local(grid), offset, data)
14345 }
14346
14347 /// Snapshot the geometry needed to address a chunked dataset's grid.
14348 ///
14349 /// Taken under one brief slot guard so the callers below — which re-lock
14350 /// the slot through `write_chunk`/`read_chunk_*` — never hold it across
14351 /// compression or I/O.
14352 fn chunk_geometry(&self, ds_index: usize) -> IoResult<ChunkGeometry> {
14353 let ds = self.ds(ds_index);
14354 let m = ds.lock();
14355 let Some(kind) = m.chunk_index_kind() else {
14356 return Err(crate::io::IoError::InvalidState(
14357 "not a chunked dataset".into(),
14358 ));
14359 };
14360 let chunk_dims = match kind {
14361 ChunkIndexKind::ExtensibleArray => m.chunked.as_ref().unwrap().chunk_dims.clone(),
14362 ChunkIndexKind::FixedArray => m.fixed_array.as_ref().unwrap().chunk_dims.clone(),
14363 ChunkIndexKind::BtreeV2 => m.btree_v2.as_ref().unwrap().chunk_dims.clone(),
14364 ChunkIndexKind::Implicit => m.implicit.as_ref().unwrap().chunk_dims.clone(),
14365 ChunkIndexKind::SingleChunk => m.single_chunk.as_ref().unwrap().chunk_dims.clone(),
14366 ChunkIndexKind::BtreeV1 => m.btree_v1.as_ref().unwrap().chunk_dims.clone(),
14367 };
14368 Ok(ChunkGeometry {
14369 kind,
14370 dims: m.dataspace.dims.clone(),
14371 max_dims: m.dataspace.max_dims.clone(),
14372 chunk_dims,
14373 element_size: m.datatype.element_size() as u64,
14374 })
14375 }
14376
14377 /// Index-grid slot of the chunk at grid `coords` (see
14378 /// [`crate::io::chunk_grid`]).
14379 pub(crate) fn chunk_slot(&self, ds_index: usize, coords: &[u64]) -> IoResult<u64> {
14380 self.chunk_geometry(ds_index)?.linear_index(coords)
14381 }
14382
14383 /// Grid coordinates of the chunk recorded under index-grid slot `linear`
14384 /// — the inverse of [`Self::chunk_slot`].
14385 pub(crate) fn chunk_coords_from_slot(
14386 &self,
14387 ds_index: usize,
14388 linear: u64,
14389 ) -> IoResult<Vec<u64>> {
14390 let geo = self.chunk_geometry(ds_index)?;
14391 crate::io::chunk_grid::coords_of(
14392 &geo.dims,
14393 geo.max_dims.as_deref(),
14394 &geo.chunk_dims,
14395 linear,
14396 )
14397 }
14398
14399 /// Define a chunked dataset indexed by a fixed array, fixed at its
14400 /// current shape (`max_dims == dims`). `chunk_dims` defines the chunk
14401 /// shape. Returns the dataset index.
14402 pub fn create_fixed_array_dataset(
14403 &self,
14404 name: &str,
14405 datatype: DatatypeMessage,
14406 dims: &[u64],
14407 chunk_dims: &[u64],
14408 ) -> IoResult<usize> {
14409 self.create_fixed_array_dataset_with_max(name, datatype, dims, dims, chunk_dims, None)
14410 }
14411
14412 /// Define a fixed-shape compressed chunked dataset indexed by a
14413 /// *filtered* Fixed Array (`max_dims == dims`).
14414 ///
14415 /// Like `create_fixed_array_dataset`, but the FA header carries the filtered
14416 /// client id and a `chunk_size_len`-wide compressed-size field per chunk
14417 /// (`FixedArrayFilteredChunkElement`), and the dataset gets a filter
14418 /// pipeline. Chunks written via `write_chunk_fixed_array` are compressed and
14419 /// their compressed size + filter mask are recorded in the data block.
14420 ///
14421 /// A convenience over [`create_fixed_array_dataset_with_max`]'s own
14422 /// pipeline argument; production dataset creation calls that directly,
14423 /// so this is kept as a direct entry point for this crate's own
14424 /// white-box tests.
14425 ///
14426 /// [`create_fixed_array_dataset_with_max`]: Self::create_fixed_array_dataset_with_max
14427 #[cfg(all(test, feature = "deflate"))]
14428 pub fn create_fixed_array_dataset_with_pipeline(
14429 &self,
14430 name: &str,
14431 datatype: DatatypeMessage,
14432 dims: &[u64],
14433 chunk_dims: &[u64],
14434 pipeline: FilterPipeline,
14435 ) -> IoResult<usize> {
14436 self.create_fixed_array_dataset_with_max(
14437 name,
14438 datatype,
14439 dims,
14440 dims,
14441 chunk_dims,
14442 Some(pipeline),
14443 )
14444 }
14445
14446 /// Define a chunked dataset indexed by a fixed array, growable up to
14447 /// `max_dims` (every maximum finite — libhdf5 picks this index exactly
14448 /// when no dimension is unlimited).
14449 ///
14450 /// The array is sized for the chunk grid of the *maximum* extent, the
14451 /// libhdf5 rule (`H5D__farray_idx_create` uses `max_nchunks`), so the
14452 /// dataset can be extended to `max_dims` without re-indexing chunks.
14453 pub fn create_fixed_array_dataset_with_max(
14454 &self,
14455 name: &str,
14456 datatype: DatatypeMessage,
14457 dims: &[u64],
14458 max_dims: &[u64],
14459 chunk_dims: &[u64],
14460 pipeline: Option<FilterPipeline>,
14461 ) -> IoResult<usize> {
14462 let create = self.begin_create(name)?;
14463 let name = create.name.as_str();
14464 validate_chunk_geometry(dims, max_dims, chunk_dims)?;
14465 if max_dims.contains(&u64::MAX) {
14466 return Err(crate::io::IoError::InvalidState(
14467 "a fixed-array index requires a fixed maximum shape (no unlimited dimension)"
14468 .into(),
14469 ));
14470 }
14471 let mut num_chunks: u64 = 1;
14472 for g in crate::io::chunk_grid::index_grid(dims, Some(max_dims), chunk_dims)? {
14473 num_chunks = num_chunks.checked_mul(g).ok_or_else(|| {
14474 crate::io::IoError::InvalidState("chunk count overflows u64".into())
14475 })?;
14476 }
14477
14478 let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14479 let layout_version = self.chunk_layout_version(pipeline.is_some(), chunk_bytes);
14480
14481 // Create the FA header. For a filtered FA, chunk_size_len is sized
14482 // the same way the filtered Extensible Array path computes it:
14483 // derived from the uncompressed chunk byte count under layout v4,
14484 // the fixed `sizeof_size` under layout v5.
14485 let mut fa_header = if pipeline.is_some() {
14486 let chunk_size_len = self.chunk_size_len_for(layout_version, chunk_bytes);
14487 FixedArrayHeader::new_for_filtered_chunks(&self.ctx, num_chunks, chunk_size_len)
14488 } else {
14489 FixedArrayHeader::new_for_chunks(&self.ctx, num_chunks)
14490 };
14491 let hdr_encoded = fa_header.encode(&self.ctx);
14492 let fa_header_addr = self
14493 .allocator
14494 .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
14495
14496 // Create the FA data block. libhdf5 switches to a paged layout once
14497 // num_elmts exceeds dblk_page_nelmts; both layouts allocate space
14498 // for `num_chunks` entries up front, but the paged layout also
14499 // reserves the page-init bitmap and a per-page checksum.
14500 let fa_dblk = if pipeline.is_some() {
14501 FixedArrayDataBlock::new_filtered(fa_header_addr, num_chunks as usize)
14502 } else {
14503 FixedArrayDataBlock::new_unfiltered(fa_header_addr, num_chunks as usize)
14504 };
14505 let dblk_size = fixed_array_dblk_disk_size(&self.ctx, &fa_header);
14506 let fa_dblk_addr = self.allocator.allocate(dblk_size, FreeSpaceClass::Metadata);
14507
14508 // Update header with data block address
14509 fa_header.data_blk_addr = fa_dblk_addr;
14510
14511 // Write both. The data block content is finalized in `flush_dataset`
14512 // once all chunk addresses are known; here we just reserve space and
14513 // write the header so the file is structurally consistent.
14514 let hdr_encoded = fa_header.encode(&self.ctx);
14515 self.handle.write_at(fa_header_addr, &hdr_encoded)?;
14516 let dblk_encoded = encode_fixed_array_dblk(&self.ctx, &fa_header, &fa_dblk);
14517 debug_assert_eq!(dblk_encoded.len() as u64, dblk_size);
14518 self.handle.write_at(fa_dblk_addr, &dblk_encoded)?;
14519
14520 // The maximum is stored even when it equals the dims: it is what
14521 // `extend_dataset` checks growth against, and the FA capacity above
14522 // is exactly its chunk grid.
14523 let dataspace = DataspaceMessage {
14524 // Chunked storage always requires at least one dimension, so
14525 // this is never Scalar or Null.
14526 class: DataspaceClass::Simple,
14527 dims: dims.to_vec(),
14528 max_dims: Some(max_dims.to_vec()),
14529 };
14530
14531 let idx = self.push_dataset(
14532 &create,
14533 DatasetInfo {
14534 name: name.to_string(),
14535 datatype,
14536 committed_type: None,
14537 external: None,
14538 virtual_storage: None,
14539 dataspace,
14540 read_format: None,
14541 obj_header_addr: 0,
14542 data_addr: UNDEF_ADDR,
14543 data_size: 0,
14544 compact: None,
14545 attributes: Vec::new(),
14546 obj_header_written_addr: None,
14547 obj_header_blocks: Vec::new(),
14548 filter_pipeline: pipeline,
14549 deleted: false,
14550 extent_dirty: false,
14551 header_dirty: false,
14552 nlink_written: 1,
14553 creation_seq: self.take_creation_seq(),
14554 track_attr_order: self.track_order.attrs,
14555 fill_value: None,
14556 fill_time: FILL_TIME_IFSET,
14557 layout_version,
14558 times: self.created_object_times(),
14559 chunked: None,
14560 btree_v2: None,
14561 implicit: None,
14562 single_chunk: None,
14563 btree_v1: None,
14564 fixed_array: Some(FixedArrayDatasetInfo {
14565 chunk_dims: chunk_dims.to_vec(),
14566 fa_header_addr,
14567 fa_dblk_addr,
14568 fa_header,
14569 fa_dblk,
14570 chunks_written: 0,
14571 }),
14572 append: None,
14573 },
14574 );
14575
14576 Ok(idx)
14577 }
14578
14579 /// Define a chunked dataset with the *implicit* index: no index structure
14580 /// at all, every chunk of the grid allocated at create in one contiguous
14581 /// run, addressed by arithmetic (`H5Dnone.c`).
14582 ///
14583 /// libhdf5 picks this index only where that arithmetic is total, and this
14584 /// enforces the same three conditions
14585 /// (`H5D__layout_set_latest_indexing`, H5Dlayout.c): no filter — a
14586 /// filtered chunk is not `chunk_bytes` long, so the run would not be a
14587 /// grid; no unlimited dimension — the run has to have a length; and early
14588 /// allocation, which is what this creator *does* rather than something it
14589 /// checks. The dataset's fill-value message says so
14590 /// (`build_dataset_header`), because a file claiming incremental
14591 /// allocation is one libhdf5 would never have chosen this index for.
14592 pub fn create_implicit_dataset(
14593 &self,
14594 name: &str,
14595 datatype: DatatypeMessage,
14596 dims: &[u64],
14597 chunk_dims: &[u64],
14598 ) -> IoResult<usize> {
14599 let create = self.begin_create(name)?;
14600 let name = create.name.as_str();
14601 validate_chunk_geometry(dims, dims, chunk_dims)?;
14602 let mut num_chunks: u64 = 1;
14603 for g in crate::io::chunk_grid::index_grid(dims, None, chunk_dims)? {
14604 num_chunks = num_chunks.checked_mul(g).ok_or_else(|| {
14605 crate::io::IoError::InvalidState("chunk count overflows u64".into())
14606 })?;
14607 }
14608 let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14609 let data_size = num_chunks.checked_mul(chunk_bytes).ok_or_else(|| {
14610 crate::io::IoError::InvalidState("implicit chunk storage overflows u64".into())
14611 })?;
14612 let layout_version = self.chunk_layout_version(false, chunk_bytes);
14613
14614 // Early allocation is the whole of this index: the run exists, and
14615 // holds the fill value, before any chunk is written. It is written
14616 // out rather than merely reserved because the file's end-of-file
14617 // address is what libhdf5 checks a file's completeness against — a
14618 // reserved-but-absent tail is a truncated file to it.
14619 let data_addr = self.allocator.allocate(data_size, FreeSpaceClass::RawData);
14620 self.handle.write_at(
14621 data_addr,
14622 &crate::format::messages::fill_value::tiled_fill(data_size as usize, None),
14623 )?;
14624
14625 let dataspace = DataspaceMessage {
14626 // Chunked storage always requires at least one dimension, so
14627 // this is never Scalar or Null.
14628 class: DataspaceClass::Simple,
14629 dims: dims.to_vec(),
14630 max_dims: Some(dims.to_vec()),
14631 };
14632
14633 let idx = self.push_dataset(
14634 &create,
14635 DatasetInfo {
14636 name: name.to_string(),
14637 datatype,
14638 committed_type: None,
14639 external: None,
14640 virtual_storage: None,
14641 dataspace,
14642 read_format: None,
14643 obj_header_addr: 0,
14644 data_addr: UNDEF_ADDR,
14645 data_size: 0,
14646 compact: None,
14647 attributes: Vec::new(),
14648 obj_header_written_addr: None,
14649 obj_header_blocks: Vec::new(),
14650 filter_pipeline: None,
14651 deleted: false,
14652 extent_dirty: false,
14653 header_dirty: false,
14654 nlink_written: 1,
14655 creation_seq: self.take_creation_seq(),
14656 track_attr_order: self.track_order.attrs,
14657 fill_value: None,
14658 fill_time: FILL_TIME_IFSET,
14659 layout_version,
14660 times: self.created_object_times(),
14661 chunked: None,
14662 btree_v2: None,
14663 fixed_array: None,
14664 implicit: Some(ImplicitDatasetInfo {
14665 chunk_dims: chunk_dims.to_vec(),
14666 data_addr,
14667 data_size,
14668 }),
14669 single_chunk: None,
14670 btree_v1: None,
14671 append: None,
14672 },
14673 );
14674
14675 Ok(idx)
14676 }
14677
14678 /// Define a chunked dataset indexed by the single-chunk index: a fixed
14679 /// shape covered by exactly one whole chunk (`chunk_dims == dims`), its
14680 /// address — and, once written, size and filter mask if filtered — held
14681 /// directly in the layout message instead of any index structure
14682 /// (`H5Dsingle.c`). libhdf5 selects this index ahead of both Implicit and
14683 /// Fixed Array whenever the shape qualifies, filtered or not, early
14684 /// allocation or not (`H5D__layout_set_latest_indexing`).
14685 ///
14686 /// `early_alloc` mirrors [`create_implicit_dataset`](Self::create_implicit_dataset):
14687 /// when true, the chunk's storage is allocated and filled with the fill
14688 /// value immediately, matching an early-allocated unfiltered dataset
14689 /// whose one chunk covers the whole shape. When false, the chunk has no
14690 /// address until its first write, the same as an unfiltered Fixed Array
14691 /// element.
14692 pub fn create_single_chunk_dataset(
14693 &self,
14694 name: &str,
14695 datatype: DatatypeMessage,
14696 dims: &[u64],
14697 chunk_dims: &[u64],
14698 early_alloc: bool,
14699 ) -> IoResult<usize> {
14700 let create = self.begin_create(name)?;
14701 let name = create.name.as_str();
14702 validate_chunk_geometry(dims, dims, chunk_dims)?;
14703 let data_size = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14704 let layout_version = self.chunk_layout_version(false, data_size);
14705
14706 let data_addr = if early_alloc {
14707 // Same reasoning as `create_implicit_dataset`: the fill-value
14708 // bytes are written now, not merely reserved, because the
14709 // file's end-of-file address is what libhdf5 checks a file's
14710 // completeness against.
14711 let addr = self.allocator.allocate(data_size, FreeSpaceClass::RawData);
14712 self.handle.write_at(
14713 addr,
14714 &crate::format::messages::fill_value::tiled_fill(data_size as usize, None),
14715 )?;
14716 addr
14717 } else {
14718 UNDEF_ADDR
14719 };
14720
14721 let dataspace = DataspaceMessage {
14722 // Chunked storage always requires at least one dimension, so
14723 // this is never Scalar or Null.
14724 class: DataspaceClass::Simple,
14725 dims: dims.to_vec(),
14726 max_dims: Some(dims.to_vec()),
14727 };
14728
14729 let idx = self.push_dataset(
14730 &create,
14731 DatasetInfo {
14732 name: name.to_string(),
14733 datatype,
14734 committed_type: None,
14735 external: None,
14736 virtual_storage: None,
14737 dataspace,
14738 read_format: None,
14739 obj_header_addr: 0,
14740 data_addr: UNDEF_ADDR,
14741 data_size: 0,
14742 compact: None,
14743 attributes: Vec::new(),
14744 obj_header_written_addr: None,
14745 obj_header_blocks: Vec::new(),
14746 filter_pipeline: None,
14747 deleted: false,
14748 extent_dirty: false,
14749 header_dirty: false,
14750 nlink_written: 1,
14751 creation_seq: self.take_creation_seq(),
14752 track_attr_order: self.track_order.attrs,
14753 fill_value: None,
14754 fill_time: FILL_TIME_IFSET,
14755 layout_version,
14756 times: self.created_object_times(),
14757 chunked: None,
14758 btree_v2: None,
14759 fixed_array: None,
14760 implicit: None,
14761 single_chunk: Some(SingleChunkDatasetInfo {
14762 chunk_dims: chunk_dims.to_vec(),
14763 data_addr,
14764 data_size,
14765 nbytes: if early_alloc { data_size } else { 0 },
14766 filter_mask: 0,
14767 chunks_written: 0,
14768 early_alloc,
14769 }),
14770 btree_v1: None,
14771 append: None,
14772 },
14773 );
14774
14775 Ok(idx)
14776 }
14777
14778 /// Define a fixed-shape compressed chunked dataset — of exactly one
14779 /// whole chunk — indexed by a *filtered* single-chunk index
14780 /// (`H5O_LAYOUT_CHUNK_SINGLE_INDEX_WITH_FILTER`, H5Dsingle.c). The
14781 /// chunk's stored size and filter mask are recorded inline in the
14782 /// layout message once the chunk is written.
14783 ///
14784 /// Like [`create_fixed_array_dataset_with_pipeline`](Self::create_fixed_array_dataset_with_pipeline),
14785 /// there is nothing to allocate ahead of that first write — a filtered
14786 /// chunk's stored length isn't known until it is compressed — so this
14787 /// dataset is always incrementally allocated regardless of the caller's
14788 /// requested allocation time.
14789 pub fn create_single_chunk_dataset_with_pipeline(
14790 &self,
14791 name: &str,
14792 datatype: DatatypeMessage,
14793 dims: &[u64],
14794 chunk_dims: &[u64],
14795 pipeline: FilterPipeline,
14796 ) -> IoResult<usize> {
14797 let create = self.begin_create(name)?;
14798 let name = create.name.as_str();
14799 validate_chunk_geometry(dims, dims, chunk_dims)?;
14800 let data_size = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14801 let layout_version = self.chunk_layout_version(true, data_size);
14802
14803 let dataspace = DataspaceMessage {
14804 // Chunked storage always requires at least one dimension, so
14805 // this is never Scalar or Null.
14806 class: DataspaceClass::Simple,
14807 dims: dims.to_vec(),
14808 max_dims: Some(dims.to_vec()),
14809 };
14810
14811 let idx = self.push_dataset(
14812 &create,
14813 DatasetInfo {
14814 name: name.to_string(),
14815 datatype,
14816 committed_type: None,
14817 external: None,
14818 virtual_storage: None,
14819 dataspace,
14820 read_format: None,
14821 obj_header_addr: 0,
14822 data_addr: UNDEF_ADDR,
14823 data_size: 0,
14824 compact: None,
14825 attributes: Vec::new(),
14826 obj_header_written_addr: None,
14827 obj_header_blocks: Vec::new(),
14828 filter_pipeline: Some(pipeline),
14829 deleted: false,
14830 extent_dirty: false,
14831 header_dirty: false,
14832 nlink_written: 1,
14833 creation_seq: self.take_creation_seq(),
14834 track_attr_order: self.track_order.attrs,
14835 fill_value: None,
14836 fill_time: FILL_TIME_IFSET,
14837 layout_version,
14838 times: self.created_object_times(),
14839 chunked: None,
14840 btree_v2: None,
14841 fixed_array: None,
14842 implicit: None,
14843 single_chunk: Some(SingleChunkDatasetInfo {
14844 chunk_dims: chunk_dims.to_vec(),
14845 data_addr: UNDEF_ADDR,
14846 data_size,
14847 nbytes: 0,
14848 filter_mask: 0,
14849 chunks_written: 0,
14850 early_alloc: false,
14851 }),
14852 btree_v1: None,
14853 append: None,
14854 },
14855 );
14856
14857 Ok(idx)
14858 }
14859
14860 /// Define a chunked dataset indexed by a version-1 B-tree — the classic
14861 /// chunk index, and the only one a version-0/1 superblock file can carry.
14862 ///
14863 /// The tree itself is not created here: libhdf5 leaves the layout
14864 /// message's address undefined until the first chunk is inserted
14865 /// (`H5D__btree_idx_create` runs on that insert), and so does this — the
14866 /// flush that bulk-loads the records is what puts a node in the file.
14867 ///
14868 /// Unlike the array indexes this one has no grid to size, so it takes any
14869 /// number of unlimited dimensions: a key *is* the chunk's position, and
14870 /// the tree is ordered by it.
14871 pub fn create_btree_v1_dataset(
14872 &self,
14873 name: &str,
14874 datatype: DatatypeMessage,
14875 dims: &[u64],
14876 max_dims: &[u64],
14877 chunk_dims: &[u64],
14878 pipeline: Option<FilterPipeline>,
14879 ) -> IoResult<usize> {
14880 let create = self.begin_create(name)?;
14881 let name = create.name.as_str();
14882 validate_chunk_geometry(dims, max_dims, chunk_dims)?;
14883 let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14884 if chunk_bytes > u32::MAX as u64 {
14885 return Err(crate::io::IoError::InvalidState(format!(
14886 "a {chunk_bytes}-byte chunk does not fit the 32-bit size field of a \
14887 version-1 B-tree chunk key"
14888 )));
14889 }
14890
14891 let dataspace = DataspaceMessage {
14892 // Chunked storage always requires at least one dimension, so
14893 // this is never Scalar or Null.
14894 class: DataspaceClass::Simple,
14895 dims: dims.to_vec(),
14896 max_dims: Some(max_dims.to_vec()),
14897 };
14898
14899 let idx = self.push_dataset(
14900 &create,
14901 DatasetInfo {
14902 name: name.to_string(),
14903 datatype,
14904 committed_type: None,
14905 external: None,
14906 virtual_storage: None,
14907 dataspace,
14908 read_format: None,
14909 obj_header_addr: 0,
14910 data_addr: UNDEF_ADDR,
14911 data_size: 0,
14912 compact: None,
14913 attributes: Vec::new(),
14914 obj_header_written_addr: None,
14915 obj_header_blocks: Vec::new(),
14916 filter_pipeline: pipeline,
14917 deleted: false,
14918 extent_dirty: false,
14919 header_dirty: false,
14920 nlink_written: 1,
14921 creation_seq: self.take_creation_seq(),
14922 track_attr_order: self.track_order.attrs,
14923 fill_value: None,
14924 fill_time: FILL_TIME_IFSET,
14925 // The version-3 data layout message this index encodes as:
14926 // `H5O_LAYOUT_VERSION_DEFAULT`, which is the floor of
14927 // `H5D__chunk_set_info`'s final MAX and the whole of it below
14928 // the version-4 gate — a bound whose row is lower does not
14929 // push the message down, it only keeps the v1.10 indexes out.
14930 layout_version: LAYOUT_VERSION_DEFAULT,
14931 times: self.created_object_times(),
14932 chunked: None,
14933 fixed_array: None,
14934 btree_v2: None,
14935 implicit: None,
14936 single_chunk: None,
14937 btree_v1: Some(BtreeV1DatasetInfo {
14938 chunk_dims: chunk_dims.to_vec(),
14939 max_dims: max_dims.to_vec(),
14940 config: self.btree_v1_config(),
14941 records: Vec::new(),
14942 node_addrs: Vec::new(),
14943 root_addr: UNDEF_ADDR,
14944 chunks_written: 0,
14945 }),
14946 append: None,
14947 },
14948 );
14949
14950 Ok(idx)
14951 }
14952
14953 /// Define a chunked dataset indexed by a B-tree v2 (multiple unlimited dimensions).
14954 ///
14955 /// Returns the dataset index.
14956 pub fn create_btree_v2_dataset(
14957 &self,
14958 name: &str,
14959 datatype: DatatypeMessage,
14960 dims: &[u64],
14961 max_dims: &[u64],
14962 chunk_dims: &[u64],
14963 ) -> IoResult<usize> {
14964 self.create_btree_v2_dataset_inner(name, datatype, dims, max_dims, chunk_dims, None)
14965 }
14966
14967 /// Define a *filtered* chunked dataset indexed by a B-tree v2.
14968 ///
14969 /// The v2 B-tree counterpart of
14970 /// [`create_chunked_dataset_with_pipeline`](Self::create_chunked_dataset_with_pipeline):
14971 /// chunks are compressed on write and the index records each chunk's
14972 /// stored size and filter mask (record type 11), the same shape libhdf5
14973 /// builds when a multi-unlimited-dimension dataset has a filter pipeline
14974 /// (`H5Dbtree2.c`, `H5D_BT2_FILT`).
14975 pub fn create_btree_v2_dataset_with_pipeline(
14976 &self,
14977 name: &str,
14978 datatype: DatatypeMessage,
14979 dims: &[u64],
14980 max_dims: &[u64],
14981 chunk_dims: &[u64],
14982 pipeline: FilterPipeline,
14983 ) -> IoResult<usize> {
14984 self.create_btree_v2_dataset_inner(
14985 name,
14986 datatype,
14987 dims,
14988 max_dims,
14989 chunk_dims,
14990 Some(pipeline),
14991 )
14992 }
14993
14994 fn create_btree_v2_dataset_inner(
14995 &self,
14996 name: &str,
14997 datatype: DatatypeMessage,
14998 dims: &[u64],
14999 max_dims: &[u64],
15000 chunk_dims: &[u64],
15001 pipeline: Option<FilterPipeline>,
15002 ) -> IoResult<usize> {
15003 use crate::format::chunk_index::btree_v2::Bt2Header;
15004
15005 let create = self.begin_create(name)?;
15006 let name = create.name.as_str();
15007 validate_chunk_geometry(dims, max_dims, chunk_dims)?;
15008 let ndims = dims.len();
15009 let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
15010 let layout_version = self.chunk_layout_version(pipeline.is_some(), chunk_bytes);
15011
15012 // The filtered record's size field is as wide as libhdf5 will
15013 // recompute it — from the uncompressed chunk size under layout v4,
15014 // the fixed `sizeof_size` under layout v5 — exactly as the
15015 // extensible- and fixed-array filtered paths size theirs.
15016 let bt2_index = match pipeline {
15017 Some(_) => {
15018 let len = self.chunk_size_len_for(layout_version, chunk_bytes);
15019 Bt2ChunkIndex::new_filtered(ndims, len)
15020 }
15021 None => Bt2ChunkIndex::new_unfiltered(ndims),
15022 };
15023
15024 // The bulk loader spreads a level's records evenly over its nodes, one
15025 // separator between adjacent siblings, which needs room for a few
15026 // records per node. HDF5's rank limit of 32 leaves room for seven; a
15027 // wider rank than that has no valid geometry, so reject it here rather
15028 // than emit a tree no reader can walk.
15029 let record_size = bt2_index.record_size(&self.ctx) as usize;
15030 let node_size = bt2_index.node_size as usize;
15031 if node_size < 10 + 3 * record_size {
15032 return Err(crate::io::IoError::InvalidState(format!(
15033 "a {ndims}-dimension v2 B-tree record is {record_size} bytes, too wide \
15034 for a {node_size}-byte node"
15035 )));
15036 }
15037
15038 // Only the header gets a home now: it names an empty tree, whose root
15039 // is undefined until the first flush bulk-loads the index into nodes.
15040 let hdr = if bt2_index.filtered {
15041 Bt2Header::new_for_filtered_chunks(&self.ctx, ndims, bt2_index.chunk_size_len)
15042 } else {
15043 Bt2Header::new_for_chunks(&self.ctx, ndims)
15044 };
15045 let hdr_encoded = hdr.encode(&self.ctx);
15046 let bt2_header_addr = self
15047 .allocator
15048 .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
15049 self.handle.write_at(bt2_header_addr, &hdr_encoded)?;
15050
15051 let dataspace = DataspaceMessage {
15052 // Chunked storage always requires at least one dimension, so
15053 // this is never Scalar or Null.
15054 class: DataspaceClass::Simple,
15055 dims: dims.to_vec(),
15056 max_dims: Some(max_dims.to_vec()),
15057 };
15058
15059 let idx = self.push_dataset(
15060 &create,
15061 DatasetInfo {
15062 name: name.to_string(),
15063 datatype,
15064 committed_type: None,
15065 external: None,
15066 virtual_storage: None,
15067 dataspace,
15068 read_format: None,
15069 obj_header_addr: 0,
15070 data_addr: UNDEF_ADDR,
15071 data_size: 0,
15072 compact: None,
15073 attributes: Vec::new(),
15074 obj_header_written_addr: None,
15075 obj_header_blocks: Vec::new(),
15076 filter_pipeline: pipeline,
15077 deleted: false,
15078 extent_dirty: false,
15079 header_dirty: false,
15080 nlink_written: 1,
15081 creation_seq: self.take_creation_seq(),
15082 track_attr_order: self.track_order.attrs,
15083 fill_value: None,
15084 fill_time: FILL_TIME_IFSET,
15085 layout_version,
15086 times: self.created_object_times(),
15087 chunked: None,
15088 fixed_array: None,
15089 implicit: None,
15090 single_chunk: None,
15091 btree_v1: None,
15092 btree_v2: Some(Bt2DatasetInfo {
15093 chunk_dims: chunk_dims.to_vec(),
15094 bt2_header_addr,
15095 node_addrs: Vec::new(),
15096 index: bt2_index,
15097 chunks_written: 0,
15098 }),
15099 append: None,
15100 },
15101 );
15102
15103 Ok(idx)
15104 }
15105
15106 /// Create a chunked dataset with a custom filter pipeline.
15107 pub fn create_chunked_dataset_with_pipeline(
15108 &self,
15109 name: &str,
15110 datatype: DatatypeMessage,
15111 dims: &[u64],
15112 max_dims: &[u64],
15113 chunk_dims: &[u64],
15114 pipeline: FilterPipeline,
15115 ) -> IoResult<usize> {
15116 let create = self.begin_create(name)?;
15117 let name = create.name.as_str();
15118 validate_chunk_geometry(dims, max_dims, chunk_dims)?;
15119 ensure_at_most_one_unlimited(max_dims)?;
15120 let element_size = datatype.element_size() as u64;
15121 let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * element_size;
15122 let layout_version = self.chunk_layout_version(true, chunk_bytes);
15123 let chunk_size_len = self.chunk_size_len_for(layout_version, chunk_bytes);
15124
15125 let earray_params = EarrayParams::default_params();
15126 let ndblk_addrs = compute_ndblk_addrs(earray_params.sup_blk_min_data_ptrs)?;
15127 let nsblk_addrs = compute_nsblk_addrs(
15128 earray_params.idx_blk_elmts,
15129 earray_params.data_blk_min_elmts,
15130 earray_params.sup_blk_min_data_ptrs,
15131 earray_params.max_nelmts_bits,
15132 )?;
15133
15134 let mut ea_header =
15135 ExtensibleArrayHeader::new_for_filtered_chunks(&self.ctx, chunk_size_len);
15136 ea_header.max_nelmts_bits = earray_params.max_nelmts_bits;
15137 ea_header.idx_blk_elmts = earray_params.idx_blk_elmts;
15138 ea_header.data_blk_min_elmts = earray_params.data_blk_min_elmts;
15139 ea_header.sup_blk_min_data_ptrs = earray_params.sup_blk_min_data_ptrs;
15140 ea_header.max_dblk_page_nelmts_bits = earray_params.max_dblk_page_nelmts_bits;
15141
15142 let hdr_encoded = ea_header.encode(&self.ctx);
15143 let ea_header_addr = self
15144 .allocator
15145 .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
15146
15147 let filt_iblk = FilteredIndexBlock::new(
15148 ea_header_addr,
15149 earray_params.idx_blk_elmts,
15150 ndblk_addrs,
15151 nsblk_addrs,
15152 );
15153 let iblk_encoded = filt_iblk.encode(&self.ctx, chunk_size_len);
15154 let ea_iblk_addr = self
15155 .allocator
15156 .allocate(iblk_encoded.len() as u64, FreeSpaceClass::Metadata);
15157
15158 ea_header.idx_blk_addr = ea_iblk_addr;
15159 let hdr_encoded = ea_header.encode(&self.ctx);
15160 self.handle.write_at(ea_header_addr, &hdr_encoded)?;
15161 self.handle.write_at(ea_iblk_addr, &iblk_encoded)?;
15162
15163 let dataspace = DataspaceMessage {
15164 // Chunked storage always requires at least one dimension, so
15165 // this is never Scalar or Null.
15166 class: DataspaceClass::Simple,
15167 dims: dims.to_vec(),
15168 max_dims: Some(max_dims.to_vec()),
15169 };
15170 let ea_iblk = ExtensibleArrayIndexBlock::new(
15171 ea_header_addr,
15172 earray_params.idx_blk_elmts,
15173 ndblk_addrs,
15174 nsblk_addrs,
15175 );
15176
15177 let idx = self.push_dataset(
15178 &create,
15179 DatasetInfo {
15180 name: name.to_string(),
15181 datatype,
15182 committed_type: None,
15183 external: None,
15184 virtual_storage: None,
15185 dataspace,
15186 read_format: None,
15187 obj_header_addr: 0,
15188 data_addr: UNDEF_ADDR,
15189 data_size: 0,
15190 compact: None,
15191 attributes: Vec::new(),
15192 obj_header_written_addr: None,
15193 obj_header_blocks: Vec::new(),
15194 filter_pipeline: Some(pipeline),
15195 deleted: false,
15196 extent_dirty: false,
15197 header_dirty: false,
15198 nlink_written: 1,
15199 creation_seq: self.take_creation_seq(),
15200 track_attr_order: self.track_order.attrs,
15201 fill_value: None,
15202 fill_time: FILL_TIME_IFSET,
15203 layout_version,
15204 times: self.created_object_times(),
15205 fixed_array: None,
15206 implicit: None,
15207 single_chunk: None,
15208 btree_v1: None,
15209 btree_v2: None,
15210 chunked: Some(ChunkedDatasetInfo {
15211 chunk_dims: chunk_dims.to_vec(),
15212 earray_params,
15213 ea_header_addr,
15214 ea_iblk_addr,
15215 ea_header,
15216 ea_iblk,
15217 chunks_written: 0,
15218 filt_iblk: Some(filt_iblk),
15219 chunk_size_len,
15220 }),
15221 append: None,
15222 },
15223 );
15224 Ok(idx)
15225 }
15226
15227 /// Write a chunk to a fixed-array-indexed dataset.
15228 ///
15229 /// `chunk_coords` is the multidimensional chunk index (e.g., [row_chunk, col_chunk]).
15230 /// The uncompressed `data` must be exactly one chunk wide; the filter
15231 /// pipeline (if any) runs here before the bytes reach the index.
15232 pub fn write_chunk_fixed_array(
15233 &self,
15234 index: usize,
15235 chunk_coords: &[u64],
15236 data: &[u8],
15237 ) -> IoResult<()> {
15238 let ds = self.ds(index);
15239 let _op = ds.op.lock();
15240 self.write_chunk_fixed_array_inner(index, chunk_coords, data)
15241 }
15242
15243 /// [`Self::write_chunk_fixed_array`] body; the caller holds the dataset's
15244 /// op lock or the writer exclusively.
15245 pub(crate) fn write_chunk_fixed_array_inner(
15246 &self,
15247 index: usize,
15248 chunk_coords: &[u64],
15249 data: &[u8],
15250 ) -> IoResult<()> {
15251 // Read what we need under one brief slot guard, then compress
15252 // OUTSIDE the lock: `record_fixed_array_chunk` re-locks the same slot,
15253 // so the guard must be dropped before it (and before apply_filters).
15254 let ds = self.ds(index);
15255 let (chunk_bytes, pipeline) = {
15256 let m = ds.lock();
15257 let element_size = m.datatype.element_size() as u64;
15258 let fa = m.fixed_array.as_ref().ok_or_else(|| {
15259 crate::io::IoError::InvalidState("not a fixed-array dataset".into())
15260 })?;
15261 (
15262 fa.chunk_dims.iter().product::<u64>() * element_size,
15263 m.filter_pipeline.clone(),
15264 )
15265 };
15266
15267 if data.len() as u64 != chunk_bytes {
15268 return Err(crate::io::IoError::InvalidState(format!(
15269 "chunk data size mismatch: expected {} bytes, got {}",
15270 chunk_bytes,
15271 data.len()
15272 )));
15273 }
15274 let write_data;
15275 let data_to_write = if let Some(ref pipeline) = pipeline {
15276 write_data = filter::apply_filters(pipeline, data)?;
15277 &write_data[..]
15278 } else {
15279 data
15280 };
15281 // filter_mask = 0: the whole pipeline ran (or the dataset is
15282 // unfiltered), so no filter is skipped for this chunk.
15283 self.record_fixed_array_chunk(index, chunk_coords, data_to_write, 0)
15284 }
15285
15286 /// Write a pre-filtered chunk verbatim to a fixed-array dataset, recording
15287 /// the caller-supplied `filter_mask`.
15288 ///
15289 /// The bytes are stored exactly as given (no filter pipeline is run); this
15290 /// is the fixed-array half of the HDF5 "direct chunk write"
15291 /// (`H5Dwrite_chunk`) operation. `filter_mask` is a bitfield: bit *i* set
15292 /// means filter *i* of the pipeline was **not** applied to this chunk and
15293 /// must be skipped on read; pass 0 when the full pipeline was applied
15294 /// upstream.
15295 ///
15296 /// Requires a filtered dataset — only the filtered FA element carries the
15297 /// size+mask slot.
15298 ///
15299 /// The caller holds the dataset's op lock or the writer exclusively.
15300 pub(crate) fn write_compressed_chunk_fixed_array_inner(
15301 &self,
15302 index: usize,
15303 chunk_coords: &[u64],
15304 data: &[u8],
15305 filter_mask: u32,
15306 ) -> IoResult<()> {
15307 if self.ds(index).lock().filter_pipeline.is_none() {
15308 return Err(crate::io::IoError::InvalidState(
15309 "write_compressed_chunk_fixed_array requires a filtered dataset \
15310 (no slot for a compressed size or filter mask on an unfiltered \
15311 chunk index)"
15312 .into(),
15313 ));
15314 }
15315 self.record_fixed_array_chunk(index, chunk_coords, data, filter_mask)
15316 }
15317
15318 /// Place an already-final chunk (`final_bytes` is whatever goes to disk —
15319 /// filtered if the dataset is filtered, raw otherwise) into a fixed-array
15320 /// dataset's data block, recording the caller-supplied `filter_mask`.
15321 /// Shared by [`write_chunk_fixed_array`](Self::write_chunk_fixed_array)
15322 /// and [`write_compressed_chunk_fixed_array`](Self::write_compressed_chunk_fixed_array).
15323 fn record_fixed_array_chunk(
15324 &self,
15325 index: usize,
15326 chunk_coords: &[u64],
15327 final_bytes: &[u8],
15328 filter_mask: u32,
15329 ) -> IoResult<()> {
15330 // Hold one slot guard for the whole method; `self.allocator`/`self.handle`/
15331 // `self.ctx` below touch disjoint fields safe to use with the guard held.
15332 let ds = self.ds(index);
15333 let mut m = ds.lock();
15334 let is_filtered = m.filter_pipeline.is_some();
15335 let fa = m
15336 .fixed_array
15337 .as_ref()
15338 .ok_or_else(|| crate::io::IoError::InvalidState("not a fixed-array dataset".into()))?;
15339
15340 // Linear chunk index in the maximum-extent grid — the slot the fixed
15341 // array (sized from that grid at create) records the chunk under.
15342 let linear_idx = crate::io::chunk_grid::linear_index(
15343 &m.dataspace.dims,
15344 m.dataspace.max_dims.as_deref(),
15345 &fa.chunk_dims,
15346 chunk_coords,
15347 )?;
15348
15349 // Update the fixed array data block. The slot is read before the bytes
15350 // are placed so a rewrite can stay where it is (see `place_chunk`).
15351 let fa = m.fixed_array.as_mut().unwrap();
15352 let lidx = linear_idx as usize;
15353 if is_filtered {
15354 // Filtered FA: store address + stored size + filter mask. A
15355 // non-zero mask bit means "filter i was skipped for this chunk".
15356 let stored_size = final_bytes.len();
15357 // The stored size is encoded in the FA header's `chunk_size_len`-byte
15358 // field; libhdf5 errors if it does not fit (H5D_CHUNK_ENCODE_SIZE_CHECK)
15359 // rather than truncating silently. element_size = sizeof_addr +
15360 // chunk_size_len + 4 by construction.
15361 let chunk_size_len = (fa.fa_header.element_size as usize)
15362 .checked_sub(self.ctx.sizeof_addr as usize + 4)
15363 .ok_or_else(|| {
15364 crate::io::IoError::InvalidState(
15365 "filtered fixed-array element size is too small".into(),
15366 )
15367 })?;
15368 if chunk_size_len < 8 && stored_size >= (1usize << (chunk_size_len * 8)) {
15369 return Err(crate::io::IoError::InvalidState(format!(
15370 "compressed chunk size {stored_size} does not fit in the \
15371 {chunk_size_len}-byte fixed-array chunk-size field"
15372 )));
15373 }
15374 if lidx < fa.fa_dblk.filtered_elements.len() {
15375 let old = &fa.fa_dblk.filtered_elements[lidx];
15376 let chunk_addr =
15377 self.place_chunk(Some((old.address, old.chunk_size)), stored_size as u64);
15378 self.handle.write_at(chunk_addr, final_bytes)?;
15379 fa.fa_dblk.filtered_elements[lidx] = FixedArrayFilteredChunkElement {
15380 address: chunk_addr,
15381 chunk_size: stored_size as u64,
15382 filter_mask,
15383 };
15384 fa.chunks_written += 1;
15385 } else {
15386 return Err(crate::io::IoError::InvalidState(format!(
15387 "chunk index {} out of range (max {})",
15388 linear_idx,
15389 fa.fa_dblk.filtered_elements.len()
15390 )));
15391 }
15392 } else {
15393 // An unfiltered fixed array stores only addresses — there is no
15394 // slot for a filter mask, so a non-zero mask cannot be honored.
15395 if filter_mask != 0 {
15396 return Err(crate::io::IoError::InvalidState(
15397 "filter_mask is non-zero but the dataset is unfiltered".into(),
15398 ));
15399 }
15400 if lidx < fa.fa_dblk.elements.len() {
15401 // Unfiltered: the stored size is fixed by the chunk shape, so
15402 // a rewrite always fits its old block.
15403 let old = fa.fa_dblk.elements[lidx];
15404 let len = final_bytes.len() as u64;
15405 let chunk_addr = self.place_chunk(Some((old, len)), len);
15406 self.handle.write_at(chunk_addr, final_bytes)?;
15407 fa.fa_dblk.elements[lidx] = chunk_addr;
15408 fa.chunks_written += 1;
15409 } else {
15410 return Err(crate::io::IoError::InvalidState(format!(
15411 "chunk index {} out of range (max {})",
15412 linear_idx,
15413 fa.fa_dblk.elements.len()
15414 )));
15415 }
15416 }
15417
15418 Ok(())
15419 }
15420
15421 /// Write the one chunk of a single-chunk indexed dataset.
15422 ///
15423 /// `chunk_coords` is validated against the grid the same way every other
15424 /// coordinate-addressed index does (`ChunkGeometry::linear_index`), even
15425 /// though the grid holds exactly one slot — this is what rejects an
15426 /// out-of-range coordinate instead of silently writing to that slot.
15427 /// `data` is the chunk's unfiltered bytes; the dataset's filter pipeline
15428 /// runs here if it has one.
15429 ///
15430 /// The caller holds the dataset's op lock or the writer exclusively.
15431 pub(crate) fn write_chunk_single_chunk_inner(
15432 &self,
15433 index: usize,
15434 chunk_coords: &[u64],
15435 data: &[u8],
15436 ) -> IoResult<()> {
15437 let geo = self.chunk_geometry(index)?;
15438 geo.linear_index(chunk_coords)?;
15439 let chunk_bytes = geo.chunk_bytes();
15440 if data.len() as u64 != chunk_bytes {
15441 return Err(crate::io::IoError::InvalidState(format!(
15442 "chunk data size mismatch: expected {} bytes, got {}",
15443 chunk_bytes,
15444 data.len()
15445 )));
15446 }
15447 let pipeline = self.ds(index).lock().filter_pipeline.clone();
15448 let write_data;
15449 let data_to_write = if let Some(ref pipeline) = pipeline {
15450 write_data = filter::apply_filters(pipeline, data)?;
15451 &write_data[..]
15452 } else {
15453 data
15454 };
15455 // filter_mask = 0: the whole pipeline ran (or the dataset is
15456 // unfiltered), so no filter is skipped for this chunk.
15457 self.record_single_chunk(index, data_to_write, 0)
15458 }
15459
15460 /// Write a pre-filtered chunk verbatim to a single-chunk dataset,
15461 /// recording the caller-supplied `filter_mask`.
15462 ///
15463 /// The bytes are stored exactly as given (no filter pipeline is run); this
15464 /// is the single-chunk half of the HDF5 "direct chunk write"
15465 /// (`H5Dwrite_chunk`) operation. `filter_mask` is a bitfield: bit *i* set
15466 /// means filter *i* of the pipeline was **not** applied to this chunk and
15467 /// must be skipped on read; pass 0 when the full pipeline was applied
15468 /// upstream.
15469 ///
15470 /// Requires a filtered dataset — only the filtered single-chunk layout
15471 /// carries a size+mask slot.
15472 ///
15473 /// The caller holds the dataset's op lock or the writer exclusively.
15474 pub(crate) fn write_compressed_chunk_single_chunk_inner(
15475 &self,
15476 index: usize,
15477 chunk_coords: &[u64],
15478 data: &[u8],
15479 filter_mask: u32,
15480 ) -> IoResult<()> {
15481 if self.ds(index).lock().filter_pipeline.is_none() {
15482 return Err(crate::io::IoError::InvalidState(
15483 "write_compressed_chunk_single_chunk requires a filtered dataset \
15484 (no slot for a compressed size or filter mask on an unfiltered \
15485 chunk index)"
15486 .into(),
15487 ));
15488 }
15489 let geo = self.chunk_geometry(index)?;
15490 geo.linear_index(chunk_coords)?;
15491 self.record_single_chunk(index, data, filter_mask)
15492 }
15493
15494 /// Place an already-final chunk (`final_bytes` is whatever goes to disk —
15495 /// filtered if the dataset is filtered, raw otherwise) into a single-chunk
15496 /// dataset's layout message fields, recording the caller-supplied
15497 /// `filter_mask`. Shared by
15498 /// [`write_chunk_single_chunk_inner`](Self::write_chunk_single_chunk_inner)
15499 /// and
15500 /// [`write_compressed_chunk_single_chunk_inner`](Self::write_compressed_chunk_single_chunk_inner).
15501 ///
15502 /// Unlike the array indexes there is no per-chunk slot to look up — the
15503 /// dataset has exactly one chunk, and its address/size/mask live directly
15504 /// in the layout message (`H5Dsingle.c`) — so this only ever rewrites the
15505 /// one chunk in place, via [`place_chunk`](Self::place_chunk) the same as
15506 /// every other index's rewrite path.
15507 fn record_single_chunk(
15508 &self,
15509 index: usize,
15510 final_bytes: &[u8],
15511 filter_mask: u32,
15512 ) -> IoResult<()> {
15513 let ds = self.ds(index);
15514 let mut m = ds.lock();
15515 let is_filtered = m.filter_pipeline.is_some();
15516 if !is_filtered && filter_mask != 0 {
15517 return Err(crate::io::IoError::InvalidState(
15518 "filter_mask is non-zero but the dataset is unfiltered".into(),
15519 ));
15520 }
15521 let sc = m
15522 .single_chunk
15523 .as_ref()
15524 .ok_or_else(|| crate::io::IoError::InvalidState("not a single-chunk dataset".into()))?;
15525
15526 // A rewrite whose stored size is unchanged stays where it is (always
15527 // so when unfiltered), one that no longer fits moves. See `place_chunk`.
15528 let old = if sc.data_addr == UNDEF_ADDR {
15529 None
15530 } else {
15531 Some((
15532 sc.data_addr,
15533 if is_filtered { sc.nbytes } else { sc.data_size },
15534 ))
15535 };
15536 let stored_size = final_bytes.len() as u64;
15537 let addr = self.place_chunk(old, stored_size);
15538 self.handle.write_at(addr, final_bytes)?;
15539
15540 let sc = m.single_chunk.as_mut().unwrap();
15541 sc.data_addr = addr;
15542 sc.nbytes = stored_size;
15543 sc.filter_mask = filter_mask;
15544 sc.chunks_written = 1;
15545 Ok(())
15546 }
15547
15548 /// Write a chunk to a B-tree v2 indexed dataset.
15549 ///
15550 /// `chunk_coords` is the scaled chunk coordinates (one per dimension).
15551 /// `data` is the chunk's unfiltered bytes; if the dataset has a filter
15552 /// pipeline it runs here and the index records the stored size and mask.
15553 ///
15554 /// Production writes call [`write_chunk_btree_v2_inner`](Self::write_chunk_btree_v2_inner)
15555 /// directly (they already hold the dataset's op lock); this self-locking
15556 /// form is kept as a direct entry point for this crate's own white-box
15557 /// tests.
15558 #[cfg(test)]
15559 pub fn write_chunk_btree_v2(
15560 &self,
15561 index: usize,
15562 chunk_coords: &[u64],
15563 data: &[u8],
15564 ) -> IoResult<()> {
15565 let ds = self.ds(index);
15566 let _op = ds.op.lock();
15567 self.write_chunk_btree_v2_inner(index, chunk_coords, data)
15568 }
15569
15570 /// [`Self::write_chunk_btree_v2`] body; the caller holds the dataset's op
15571 /// lock or the writer exclusively.
15572 pub(crate) fn write_chunk_btree_v2_inner(
15573 &self,
15574 index: usize,
15575 chunk_coords: &[u64],
15576 data: &[u8],
15577 ) -> IoResult<()> {
15578 // Read what the write needs under a brief guard, then compress OUTSIDE
15579 // the lock — filtering a chunk must not hold the dataset slot.
15580 let ds = self.ds(index);
15581 let (chunk_bytes, pipeline) = {
15582 let m = ds.lock();
15583 let element_size = m.datatype.element_size() as u64;
15584 let bt2 = m.btree_v2.as_ref().ok_or_else(|| {
15585 crate::io::IoError::InvalidState("not a B-tree v2 dataset".into())
15586 })?;
15587 (
15588 bt2.chunk_dims.iter().product::<u64>() * element_size,
15589 m.filter_pipeline.clone(),
15590 )
15591 };
15592
15593 if data.len() as u64 != chunk_bytes {
15594 return Err(crate::io::IoError::InvalidState(format!(
15595 "chunk data size mismatch: expected {} bytes, got {}",
15596 chunk_bytes,
15597 data.len()
15598 )));
15599 }
15600
15601 let filtered;
15602 let stored = match pipeline {
15603 Some(ref pl) => {
15604 filtered = filter::apply_filters(pl, data)?;
15605 &filtered[..]
15606 }
15607 None => data,
15608 };
15609
15610 // filter_mask = 0: the whole pipeline ran (or the dataset is
15611 // unfiltered), so no filter is skipped.
15612 self.record_btree_v2_chunk(index, chunk_coords, stored, 0)
15613 }
15614
15615 /// Write a pre-filtered chunk verbatim to a BT2-indexed dataset, recording
15616 /// the caller-supplied `filter_mask`.
15617 ///
15618 /// The v2-B-tree half of the HDF5 "direct chunk write" (`H5Dwrite_chunk`).
15619 /// The bytes are stored exactly as given; `filter_mask` bit *i* set means
15620 /// filter *i* of the pipeline was **not** applied and must be skipped on
15621 /// read. Requires a filtered dataset — only a type-11 record has a slot for
15622 /// a stored size and mask.
15623 ///
15624 /// The caller holds the dataset's op lock or the writer exclusively.
15625 pub(crate) fn write_compressed_chunk_btree_v2_inner(
15626 &self,
15627 index: usize,
15628 chunk_coords: &[u64],
15629 data: &[u8],
15630 filter_mask: u32,
15631 ) -> IoResult<()> {
15632 if self.ds(index).lock().filter_pipeline.is_none() {
15633 return Err(crate::io::IoError::InvalidState(
15634 "write_compressed_chunk_btree_v2 requires a filtered dataset (no \
15635 slot for a compressed size or filter mask on an unfiltered chunk \
15636 index)"
15637 .into(),
15638 ));
15639 }
15640 self.record_btree_v2_chunk(index, chunk_coords, data, filter_mask)
15641 }
15642
15643 /// Place a chunk's already-final bytes (filtered if the dataset is
15644 /// filtered, raw otherwise) in the file and record them in the v2 B-tree,
15645 /// under the caller-supplied `filter_mask`.
15646 ///
15647 /// Shared by [`write_chunk_btree_v2`](Self::write_chunk_btree_v2) and
15648 /// [`write_compressed_chunk_btree_v2`](Self::write_compressed_chunk_btree_v2),
15649 /// so both reach the index through one placement rule.
15650 fn record_btree_v2_chunk(
15651 &self,
15652 index: usize,
15653 chunk_coords: &[u64],
15654 final_bytes: &[u8],
15655 filter_mask: u32,
15656 ) -> IoResult<()> {
15657 let stored_len = final_bytes.len() as u64;
15658 let ds = self.ds(index);
15659 let mut m = ds.lock();
15660 let element_size = m.datatype.element_size() as u64;
15661 let bt2 = m
15662 .btree_v2
15663 .as_ref()
15664 .ok_or_else(|| crate::io::IoError::InvalidState("not a B-tree v2 dataset".into()))?;
15665 let chunk_bytes = bt2.chunk_dims.iter().product::<u64>() * element_size;
15666 // A filtered record encodes the stored size in a `chunk_size_len`-byte
15667 // field that truncates silently. Reject a size that would not fit, as
15668 // the extensible-array path does — the compress path never exceeds it,
15669 // but a direct write with caller-supplied bytes can.
15670 if bt2.index.filtered {
15671 let chunk_size_len = bt2.index.chunk_size_len as usize;
15672 if chunk_size_len < 8 && stored_len >= (1u64 << (chunk_size_len * 8)) {
15673 return Err(crate::io::IoError::InvalidState(format!(
15674 "filtered chunk size {stored_len} does not fit in the \
15675 {chunk_size_len}-byte v2 B-tree chunk-size field"
15676 )));
15677 }
15678 }
15679 // Place the bytes: a rewrite whose stored size is unchanged stays
15680 // where it is (always so when unfiltered — the size is fixed by the
15681 // chunk shape), and one that no longer fits moves, releasing its old
15682 // block. See `place_chunk`.
15683 let old = if bt2.index.filtered {
15684 bt2.index
15685 .lookup_filtered(chunk_coords)
15686 .map(|r| (r.chunk_address, r.chunk_size))
15687 } else {
15688 bt2.index
15689 .lookup(chunk_coords)
15690 .map(|r| (r.chunk_address, chunk_bytes))
15691 };
15692 let chunk_addr = self.place_chunk(old, stored_len);
15693 self.handle.write_at(chunk_addr, final_bytes)?;
15694
15695 let bt2 = m.btree_v2.as_mut().unwrap();
15696 if bt2.index.filtered {
15697 bt2.index
15698 .insert_filtered(chunk_coords.to_vec(), chunk_addr, stored_len, filter_mask);
15699 } else {
15700 bt2.index.insert(chunk_coords.to_vec(), chunk_addr);
15701 }
15702 bt2.chunks_written += 1;
15703
15704 Ok(())
15705 }
15706
15707 /// Write multiple chunks in a batch, optionally compressing in parallel.
15708 ///
15709 /// `chunks` is a list of (chunk_idx, data) pairs for an EA-indexed dataset.
15710 pub fn write_chunks_batch(&self, ds_index: usize, chunks: &[(u64, &[u8])]) -> IoResult<()> {
15711 let ds = self.ds(ds_index);
15712 let _op = ds.op.lock();
15713 self.write_chunks_batch_inner(ds_index, chunks)
15714 }
15715
15716 /// [`Self::write_chunks_batch`] body; the caller holds the dataset's op
15717 /// lock or the writer exclusively.
15718 pub(crate) fn write_chunks_batch_inner(
15719 &self,
15720 ds_index: usize,
15721 chunks: &[(u64, &[u8])],
15722 ) -> IoResult<()> {
15723 #[cfg(feature = "parallel")]
15724 {
15725 // If filter pipeline is set, compress all chunks in parallel.
15726 // Clone the pipeline out under a brief slot guard so the parallel
15727 // compression below runs off the lock.
15728 let pipeline = self.ds(ds_index).lock().filter_pipeline.clone();
15729 if let Some(ref pipeline) = pipeline {
15730 let chunk_data: Vec<&[u8]> = chunks.iter().map(|&(_, d)| d).collect();
15731 // Propagate a filter error rather than storing raw bytes under a
15732 // filter_mask that claims the pipeline ran (see
15733 // apply_filters_parallel). Ok reaching here means every chunk
15734 // compressed fully, so filter_mask = 0 is truthful.
15735 let compressed = filter::apply_filters_parallel(pipeline, &chunk_data)?;
15736 for ((idx, _), compressed_data) in chunks.iter().zip(compressed.iter()) {
15737 self.write_compressed_chunk_inner(ds_index, *idx, compressed_data, 0)?;
15738 }
15739 return Ok(());
15740 }
15741 }
15742 // Fallback: sequential
15743 for (idx, data) in chunks {
15744 self.write_chunk_inner(ds_index, *idx, data)?;
15745 }
15746 Ok(())
15747 }
15748
15749 /// Write multiple fixed-array chunks in a batch, compressing them in
15750 /// parallel when a filter pipeline is set and the `parallel` feature is on.
15751 ///
15752 /// The fixed-array analogue of [`write_chunks_batch`](Self::write_chunks_batch):
15753 /// chunks are addressed by grid coordinates rather than a linear index.
15754 /// `record_fixed_array_chunk` writes already-compressed bytes verbatim, so
15755 /// the parallel compressor is the only place a filter runs. Falls back to
15756 /// per-chunk [`write_chunk_fixed_array`](Self::write_chunk_fixed_array) when
15757 /// unfiltered or when `parallel` is off.
15758 ///
15759 /// The caller holds the dataset's op lock or the writer exclusively.
15760 pub(crate) fn write_chunks_fixed_array_batch_inner(
15761 &self,
15762 ds_index: usize,
15763 chunks: &[(&[u64], &[u8])],
15764 ) -> IoResult<()> {
15765 #[cfg(feature = "parallel")]
15766 {
15767 // Clone the pipeline out under a brief slot guard so the parallel
15768 // compression below runs off the lock.
15769 let pipeline = self.ds(ds_index).lock().filter_pipeline.clone();
15770 if let Some(ref pipeline) = pipeline {
15771 let chunk_data: Vec<&[u8]> = chunks.iter().map(|&(_, d)| d).collect();
15772 // Same single owner as the EA batch: apply_filters_parallel
15773 // propagates a filter error instead of storing raw bytes under a
15774 // filter_mask that claims the pipeline ran. Ok here means every
15775 // chunk compressed fully, so filter_mask = 0 is truthful.
15776 let compressed = filter::apply_filters_parallel(pipeline, &chunk_data)?;
15777 for ((coords, _), compressed_data) in chunks.iter().zip(compressed.iter()) {
15778 self.record_fixed_array_chunk(ds_index, coords, compressed_data, 0)?;
15779 }
15780 return Ok(());
15781 }
15782 }
15783 // Fallback: sequential (write_chunk_fixed_array_inner compresses per
15784 // chunk).
15785 for (coords, data) in chunks {
15786 self.write_chunk_fixed_array_inner(ds_index, coords, data)?;
15787 }
15788 Ok(())
15789 }
15790
15791 /// Write a pre-filtered chunk verbatim to an EA-indexed dataset, recording
15792 /// the caller-supplied `filter_mask`.
15793 ///
15794 /// The bytes are stored exactly as given (no filter pipeline is run); this
15795 /// is the extensible-array half of the HDF5 "direct chunk write"
15796 /// (`H5Dwrite_chunk`) operation. `filter_mask` is a bitfield: bit *i* set
15797 /// means filter *i* of the pipeline was **not** applied to this chunk and
15798 /// must be skipped on read; pass 0 when the full pipeline was applied
15799 /// upstream.
15800 ///
15801 /// Requires a filtered dataset — only the filtered EA entry carries the
15802 /// size+mask slot. An unfiltered dataset has nowhere to record either.
15803 ///
15804 /// The caller holds the dataset's op lock or the writer exclusively.
15805 pub(crate) fn write_compressed_chunk_inner(
15806 &self,
15807 index: usize,
15808 chunk_idx: u64,
15809 compressed_data: &[u8],
15810 filter_mask: u32,
15811 ) -> IoResult<()> {
15812 if self.ds(index).lock().filter_pipeline.is_none() {
15813 return Err(crate::io::IoError::InvalidState(
15814 "write_compressed_chunk requires a filtered dataset (no slot for \
15815 a compressed size or filter mask on an unfiltered chunk index)"
15816 .into(),
15817 ));
15818 }
15819 self.record_ea_chunk(index, chunk_idx, compressed_data, filter_mask)
15820 }
15821
15822 /// Extend the dimensions of a chunked dataset.
15823 pub fn extend_dataset(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15824 let ds = self.ds(index);
15825 let _op = ds.op.lock();
15826 self.extend_dataset_inner(index, new_dims)
15827 }
15828
15829 /// [`Self::extend_dataset`] body; the caller holds the dataset's op lock
15830 /// or the writer exclusively.
15831 pub(crate) fn extend_dataset_inner(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15832 let ds = self.ds(index);
15833 let mut m = ds.lock();
15834 if !m.is_chunked() {
15835 return Err(crate::io::IoError::InvalidState(
15836 "can only extend chunked datasets".into(),
15837 ));
15838 }
15839 if new_dims.len() != m.dataspace.dims.len() {
15840 return Err(crate::io::IoError::InvalidState(format!(
15841 "extend_dataset rank mismatch: dataset has {} dimensions, got {}",
15842 m.dataspace.dims.len(),
15843 new_dims.len()
15844 )));
15845 }
15846 // The chunk index and append buffers assume the logical size only
15847 // grows; shrinking below already-written data desynchronizes them.
15848 for (d, (&new, &cur)) in new_dims.iter().zip(&m.dataspace.dims).enumerate() {
15849 if new < cur {
15850 return Err(crate::io::IoError::InvalidState(format!(
15851 "extend_dataset cannot shrink dimension {d} from {cur} to {new}"
15852 )));
15853 }
15854 // An absent maximum shape means the shape is fixed (libhdf5
15855 // defaults maxdims to dims at creation), so any growth exceeds it.
15856 match m.dataspace.max_dims {
15857 Some(ref max) if new > max[d] => {
15858 return Err(crate::io::IoError::InvalidState(format!(
15859 "extend_dataset dimension {d} ({new}) exceeds the maximum {}",
15860 max[d]
15861 )));
15862 }
15863 None if new > cur => {
15864 return Err(crate::io::IoError::InvalidState(format!(
15865 "extend_dataset dimension {d} ({new}) exceeds the maximum {cur}: \
15866 a dataset without a stored maximum shape is fixed at its extent"
15867 )));
15868 }
15869 _ => {}
15870 }
15871 }
15872 if m.dataspace.dims != new_dims {
15873 m.dataspace.dims = new_dims.to_vec();
15874 m.extent_dirty = true;
15875 }
15876 Ok(())
15877 }
15878
15879 /// Set the logical extent of a chunked dataset, growing **or shrinking**
15880 /// any dimension (unlike [`extend_dataset`](Self::extend_dataset), which
15881 /// only grows).
15882 ///
15883 /// A shrink prunes the stored chunks the way libhdf5's
15884 /// `H5D__chunk_prune_by_extent` (H5Dchunk.c) does: a chunk entirely
15885 /// beyond the new extent leaves the chunk index and its block is freed
15886 /// for reuse (kept under SWMR, where a live reader may still hold its
15887 /// address — the rule `H5Dearray.c` applies in `idx_remove`), and a
15888 /// chunk the new extent cuts through has its out-of-extent region
15889 /// overwritten with the fill value, so growing the extent back exposes
15890 /// fill values rather than the stale data.
15891 pub fn set_dataset_extent(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15892 let ds = self.ds(index);
15893 let _op = ds.op.lock();
15894 let old_dims = {
15895 let m = ds.lock();
15896 if !m.is_chunked() {
15897 return Err(crate::io::IoError::InvalidState(
15898 "can only set the extent of chunked datasets".into(),
15899 ));
15900 }
15901 if new_dims.len() != m.dataspace.dims.len() {
15902 return Err(crate::io::IoError::InvalidState(format!(
15903 "set_extent rank mismatch: dataset has {} dimensions, got {}",
15904 m.dataspace.dims.len(),
15905 new_dims.len()
15906 )));
15907 }
15908 // A shrink can cut into buffered rows, whose recorded base would
15909 // then point past the extent; refuse rather than reconcile.
15910 if m.append.is_some() {
15911 return Err(crate::io::IoError::InvalidState(
15912 "set_extent cannot run while the dataset has buffered appends; \
15913 flush them first"
15914 .into(),
15915 ));
15916 }
15917 // An absent maximum shape means the shape is fixed (libhdf5
15918 // defaults maxdims to dims at creation), so growth is bounded by
15919 // the extent.
15920 match m.dataspace.max_dims {
15921 Some(ref max) => {
15922 for (d, (&new, &mx)) in new_dims.iter().zip(max).enumerate() {
15923 if new > mx {
15924 return Err(crate::io::IoError::InvalidState(format!(
15925 "set_extent dimension {d} ({new}) exceeds the maximum {mx}"
15926 )));
15927 }
15928 }
15929 }
15930 None => {
15931 for (d, (&new, &cur)) in new_dims.iter().zip(&m.dataspace.dims).enumerate() {
15932 if new > cur {
15933 return Err(crate::io::IoError::InvalidState(format!(
15934 "set_extent dimension {d} ({new}) exceeds the maximum {cur}: \
15935 a dataset without a stored maximum shape is fixed at its extent"
15936 )));
15937 }
15938 }
15939 }
15940 }
15941 m.dataspace.dims.clone()
15942 };
15943 // A shrink strands chunks; prune them (and refill the straddlers)
15944 // *before* the dims update — chunk addressing uses the
15945 // maximum-extent grid, which the update does not change, and the
15946 // helpers re-lock the slot themselves.
15947 if new_dims.iter().zip(&old_dims).any(|(&n, &o)| n < o) {
15948 self.prune_chunks_beyond(index, new_dims)?;
15949 }
15950 let mut m = ds.lock();
15951 if m.dataspace.dims != new_dims {
15952 m.dataspace.dims = new_dims.to_vec();
15953 m.extent_dirty = true;
15954 }
15955 Ok(())
15956 }
15957
15958 /// Remove and refill the chunks a shrink to `new_dims` strands — the
15959 /// libhdf5 `H5D__chunk_prune_by_extent` behavior. A chunk entirely
15960 /// beyond the new extent leaves the index and its block is freed (kept
15961 /// under SWMR, where a live reader may still hold its address); a chunk
15962 /// the extent cuts through gets its out-of-extent region refilled with
15963 /// the fill value, so a later regrow reads fill, not stale elements.
15964 ///
15965 /// Runs *before* the dims update: the index grid chunks are addressed in
15966 /// comes from the maximum extent, which a shrink never changes, so every
15967 /// stored entry still resolves. The caller holds the dataset's op lock.
15968 fn prune_chunks_beyond(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15969 let geo = self.chunk_geometry(index)?;
15970 // A vlen dataset's elements are global-heap IDs: the pruned chunks
15971 // still reference live heap objects, so the walkers read each dead
15972 // chunk's bytes before freeing its block and the heap objects are
15973 // released here — otherwise every shrink strands its strings in the
15974 // file. `release_vlen_references` is a SWMR no-op, so the reads are
15975 // skipped under SWMR too.
15976 let collect_refs = !self.swmr_active && {
15977 let ds = self.ds(index);
15978 let m = ds.lock();
15979 matches!(
15980 m.datatype,
15981 DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
15982 )
15983 };
15984 let (straddlers, dead_refs) = match geo.kind {
15985 ChunkIndexKind::ExtensibleArray => {
15986 self.prune_ea_chunks(index, &geo, new_dims, collect_refs)?
15987 }
15988 ChunkIndexKind::FixedArray => {
15989 self.prune_fa_chunks(index, &geo, new_dims, collect_refs)?
15990 }
15991 ChunkIndexKind::BtreeV2 => {
15992 self.prune_bt2_chunks(index, &geo, new_dims, collect_refs)?
15993 }
15994 // Removing a chunk from the implicit index is
15995 // `H5D__none_idx_remove`: a no-op, because the chunk's space is
15996 // the dataset's space and stays allocated either way. Only the
15997 // straddlers matter, and they are refilled by the caller.
15998 ChunkIndexKind::Implicit => (self.implicit_straddlers(&geo, new_dims)?, Vec::new()),
15999 // A single-chunk index has no per-chunk remove either — its one
16000 // chunk's address lives in the layout message, not an index
16001 // structure, and stays exactly where it is; a shrink only ever
16002 // straddles that one chunk (`H5D__single_idx_remove` is likewise
16003 // a no-op).
16004 ChunkIndexKind::SingleChunk => (self.implicit_straddlers(&geo, new_dims)?, Vec::new()),
16005 ChunkIndexKind::BtreeV1 => {
16006 self.prune_btree_v1_chunks(index, &geo, new_dims, collect_refs)?
16007 }
16008 };
16009 if !dead_refs.is_empty() {
16010 self.release_vlen_references(&dead_refs)?;
16011 }
16012 // Whole-chunk read-modify-write per straddler: an unfiltered chunk
16013 // rewrites in place, a filtered one re-places through `place_chunk`.
16014 let chunk_bytes = geo.chunk_bytes() as usize;
16015 for coords in straddlers {
16016 let Some(mut data) = self.read_chunk_at_coords(index, &coords)? else {
16017 continue;
16018 };
16019 let fill = self.new_chunk_buffer(index, chunk_bytes);
16020 let replaced = refill_chunk_beyond_extent(
16021 &mut data,
16022 &fill,
16023 &coords,
16024 &geo.chunk_dims,
16025 new_dims,
16026 geo.element_size as usize,
16027 );
16028 // Release before the write-back: a filtered straddler re-places
16029 // its block, and freed heap space must be visible to that
16030 // allocation (free-before-alloc, as everywhere else).
16031 if collect_refs && !replaced.is_empty() {
16032 self.release_vlen_references(&replaced)?;
16033 }
16034 self.write_chunk_at_coords(index, &coords, &data)?;
16035 }
16036 Ok(())
16037 }
16038
16039 /// Extensible-array half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16040 /// walk every slot the array has ever set, free and clear the entries of
16041 /// chunks entirely beyond `new_dims`, and return the grid coordinates of
16042 /// the chunks that straddle it, plus — when `collect_refs` — the dead
16043 /// chunks' element bytes so the caller can release their heap objects.
16044 fn prune_ea_chunks(
16045 &self,
16046 index: usize,
16047 geo: &ChunkGeometry,
16048 new_dims: &[u64],
16049 collect_refs: bool,
16050 ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16051 let ds = self.ds(index);
16052 // One slot guard for the whole walk, the `record_ea_chunk` pattern:
16053 // `self.handle`/`self.allocator`/`self.ctx` are disjoint fields.
16054 let mut m = ds.lock();
16055 let is_filtered = m.filter_pipeline.is_some();
16056 let pipeline = m.filter_pipeline.clone();
16057 let chunk_bytes = geo.chunk_bytes();
16058 let (ea_geo, max_nelmts_bits, chunk_size_len, max_idx) = {
16059 let c = m.chunked.as_ref().unwrap();
16060 let p = &c.earray_params;
16061 (
16062 EaGeometry::new(
16063 p.idx_blk_elmts,
16064 p.data_blk_min_elmts,
16065 p.sup_blk_min_data_ptrs,
16066 p.max_nelmts_bits,
16067 p.max_dblk_page_nelmts_bits,
16068 )?,
16069 p.max_nelmts_bits,
16070 c.chunk_size_len,
16071 c.ea_header.max_idx_set,
16072 )
16073 };
16074
16075 let mut straddlers = Vec::new();
16076 let mut dead_refs = Vec::new();
16077
16078 // The decoded data block the walk is currently inside, written back
16079 // when the walk leaves it (or ends) having cleared an entry.
16080 enum Dblk {
16081 Unfiltered(ExtensibleArrayDataBlock),
16082 Filtered(FilteredDataBlock),
16083 }
16084 let mut cache: Option<(u64, Dblk, bool)> = None;
16085 let flush = |cache: &mut Option<(u64, Dblk, bool)>| -> IoResult<()> {
16086 if let Some((addr, blk, dirty)) = cache.take() {
16087 if dirty {
16088 let enc = match &blk {
16089 Dblk::Unfiltered(d) => d.encode(&self.ctx, max_nelmts_bits),
16090 Dblk::Filtered(d) => d.encode(&self.ctx, max_nelmts_bits, chunk_size_len),
16091 };
16092 self.handle.write_at(addr, &enc)?;
16093 }
16094 }
16095 Ok(())
16096 };
16097 // Consecutive slots resolve through the same super block, so keep
16098 // the last decode. Super blocks are only read here — clearing a
16099 // data-block element never moves the block — so it never dirties.
16100 let mut sblk_cache: Option<(usize, ExtensibleArraySuperBlock)> = None;
16101
16102 let mut slot = 0u64;
16103 while slot < max_idx {
16104 let coords = crate::io::chunk_grid::coords_of(
16105 &geo.dims,
16106 geo.max_dims.as_deref(),
16107 &geo.chunk_dims,
16108 slot,
16109 )?;
16110 if !chunk_outside_extent(&coords, &geo.chunk_dims, new_dims) {
16111 if chunk_straddles_extent(&coords, &geo.chunk_dims, new_dims) {
16112 straddlers.push(coords);
16113 }
16114 slot += 1;
16115 continue;
16116 }
16117 match ea_geo.locate(slot)? {
16118 EaLoc::Index { elem } => {
16119 let c = m.chunked.as_mut().unwrap();
16120 if is_filtered {
16121 let fiblk = c.filt_iblk.as_mut().unwrap();
16122 let e = fiblk.elements[elem];
16123 if e.addr != UNDEF_ADDR {
16124 if collect_refs {
16125 if let Some(bytes) = self.read_chunk_block(
16126 pipeline.as_ref(),
16127 e.addr,
16128 e.nbytes,
16129 e.filter_mask,
16130 )? {
16131 dead_refs.extend_from_slice(&bytes);
16132 }
16133 }
16134 if !self.swmr_active {
16135 self.allocator
16136 .free(e.addr, e.nbytes, FreeSpaceClass::RawData);
16137 }
16138 fiblk.elements[elem] = FilteredChunkEntry {
16139 addr: UNDEF_ADDR,
16140 nbytes: 0,
16141 filter_mask: 0,
16142 };
16143 }
16144 } else {
16145 let a = c.ea_iblk.elements[elem];
16146 if a != UNDEF_ADDR {
16147 if collect_refs {
16148 if let Some(bytes) =
16149 self.read_chunk_block(pipeline.as_ref(), a, chunk_bytes, 0)?
16150 {
16151 dead_refs.extend_from_slice(&bytes);
16152 }
16153 }
16154 if !self.swmr_active {
16155 self.allocator.free(a, chunk_bytes, FreeSpaceClass::RawData);
16156 }
16157 c.ea_iblk.elements[elem] = UNDEF_ADDR;
16158 }
16159 }
16160 slot += 1;
16161 }
16162 EaLoc::Dblk(l) => {
16163 if l.paged {
16164 return Err(crate::io::IoError::InvalidState(format!(
16165 "chunk index {slot} lives in a paged extensible-array \
16166 data block, which is not yet supported"
16167 )));
16168 }
16169 let dblk_start = slot - l.offset_in_dblk;
16170 let dblk_end = dblk_start + l.dblk_nelmts;
16171 // Resolve the data block's address; an undefined super or
16172 // data block means nothing in its whole element range was
16173 // ever written, so the walk skips the range.
16174 let dblk_addr = {
16175 let c = m.chunked.as_ref().unwrap();
16176 match l.path {
16177 EaDblkPath::Direct { idx } => {
16178 if is_filtered {
16179 c.filt_iblk.as_ref().unwrap().dblk_addrs[idx]
16180 } else {
16181 c.ea_iblk.dblk_addrs[idx]
16182 }
16183 }
16184 EaDblkPath::ViaSblk {
16185 sblk_off,
16186 local_dblk,
16187 ndblks_in_sblk,
16188 ..
16189 } => {
16190 let sblk_addr = if is_filtered {
16191 c.filt_iblk.as_ref().unwrap().sblk_addrs[sblk_off]
16192 } else {
16193 c.ea_iblk.sblk_addrs[sblk_off]
16194 };
16195 if sblk_addr == UNDEF_ADDR {
16196 UNDEF_ADDR
16197 } else {
16198 if sblk_cache.as_ref().map(|&(o, _)| o) != Some(sblk_off) {
16199 let buf = self.handle.read_at_most(sblk_addr, 65536)?;
16200 let sb = ExtensibleArraySuperBlock::decode(
16201 &buf,
16202 &self.ctx,
16203 max_nelmts_bits,
16204 ndblks_in_sblk,
16205 0,
16206 )?;
16207 sblk_cache = Some((sblk_off, sb));
16208 }
16209 sblk_cache.as_ref().unwrap().1.dblk_addrs[local_dblk]
16210 }
16211 }
16212 }
16213 };
16214 if dblk_addr == UNDEF_ADDR {
16215 slot = dblk_end;
16216 continue;
16217 }
16218 if cache.as_ref().map(|&(a, _, _)| a) != Some(dblk_addr) {
16219 flush(&mut cache)?;
16220 let buf = self.handle.read_at_most(dblk_addr, 65536)?;
16221 let blk = if is_filtered {
16222 Dblk::Filtered(FilteredDataBlock::decode(
16223 &buf,
16224 &self.ctx,
16225 max_nelmts_bits,
16226 l.dblk_nelmts as usize,
16227 chunk_size_len,
16228 )?)
16229 } else {
16230 Dblk::Unfiltered(ExtensibleArrayDataBlock::decode(
16231 &buf,
16232 &self.ctx,
16233 max_nelmts_bits,
16234 l.dblk_nelmts as usize,
16235 )?)
16236 };
16237 cache = Some((dblk_addr, blk, false));
16238 }
16239 let (_, blk, dirty) = cache.as_mut().unwrap();
16240 match blk {
16241 Dblk::Filtered(d) => {
16242 let e = d.elements[l.offset_in_dblk as usize];
16243 if e.addr != UNDEF_ADDR {
16244 if collect_refs {
16245 if let Some(bytes) = self.read_chunk_block(
16246 pipeline.as_ref(),
16247 e.addr,
16248 e.nbytes,
16249 e.filter_mask,
16250 )? {
16251 dead_refs.extend_from_slice(&bytes);
16252 }
16253 }
16254 if !self.swmr_active {
16255 self.allocator
16256 .free(e.addr, e.nbytes, FreeSpaceClass::RawData);
16257 }
16258 d.elements[l.offset_in_dblk as usize] = FilteredChunkEntry {
16259 addr: UNDEF_ADDR,
16260 nbytes: 0,
16261 filter_mask: 0,
16262 };
16263 *dirty = true;
16264 }
16265 }
16266 Dblk::Unfiltered(d) => {
16267 let a = d.elements[l.offset_in_dblk as usize];
16268 if a != UNDEF_ADDR {
16269 if collect_refs {
16270 if let Some(bytes) =
16271 self.read_chunk_block(pipeline.as_ref(), a, chunk_bytes, 0)?
16272 {
16273 dead_refs.extend_from_slice(&bytes);
16274 }
16275 }
16276 if !self.swmr_active {
16277 self.allocator.free(a, chunk_bytes, FreeSpaceClass::RawData);
16278 }
16279 d.elements[l.offset_in_dblk as usize] = UNDEF_ADDR;
16280 *dirty = true;
16281 }
16282 }
16283 }
16284 slot += 1;
16285 }
16286 }
16287 }
16288 flush(&mut cache)?;
16289 Ok((straddlers, dead_refs))
16290 }
16291
16292 /// Fixed-array half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16293 /// the whole element array is in memory and flushed at close, so
16294 /// clearing an entry is pure bookkeeping.
16295 fn prune_fa_chunks(
16296 &self,
16297 index: usize,
16298 geo: &ChunkGeometry,
16299 new_dims: &[u64],
16300 collect_refs: bool,
16301 ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16302 let ds = self.ds(index);
16303 let mut m = ds.lock();
16304 let is_filtered = m.filter_pipeline.is_some();
16305 let pipeline = m.filter_pipeline.clone();
16306 let chunk_bytes = geo.chunk_bytes();
16307 let mut straddlers = Vec::new();
16308 let mut dead_refs = Vec::new();
16309 let fa = m.fixed_array.as_mut().unwrap();
16310 let nslots = if is_filtered {
16311 fa.fa_dblk.filtered_elements.len()
16312 } else {
16313 fa.fa_dblk.elements.len()
16314 };
16315 for lidx in 0..nslots {
16316 let (addr, stored, mask) = if is_filtered {
16317 let e = &fa.fa_dblk.filtered_elements[lidx];
16318 (e.address, e.chunk_size, e.filter_mask)
16319 } else {
16320 (fa.fa_dblk.elements[lidx], chunk_bytes, 0)
16321 };
16322 if addr == UNDEF_ADDR {
16323 continue;
16324 }
16325 let coords = crate::io::chunk_grid::coords_of(
16326 &geo.dims,
16327 geo.max_dims.as_deref(),
16328 &geo.chunk_dims,
16329 lidx as u64,
16330 )?;
16331 if chunk_outside_extent(&coords, &geo.chunk_dims, new_dims) {
16332 if collect_refs {
16333 if let Some(bytes) =
16334 self.read_chunk_block(pipeline.as_ref(), addr, stored, mask)?
16335 {
16336 dead_refs.extend_from_slice(&bytes);
16337 }
16338 }
16339 if !self.swmr_active {
16340 self.allocator.free(addr, stored, FreeSpaceClass::RawData);
16341 }
16342 if is_filtered {
16343 fa.fa_dblk.filtered_elements[lidx] = FixedArrayFilteredChunkElement {
16344 address: UNDEF_ADDR,
16345 chunk_size: 0,
16346 filter_mask: 0,
16347 };
16348 } else {
16349 fa.fa_dblk.elements[lidx] = UNDEF_ADDR;
16350 }
16351 } else if chunk_straddles_extent(&coords, &geo.chunk_dims, new_dims) {
16352 straddlers.push(coords);
16353 }
16354 }
16355 Ok((straddlers, dead_refs))
16356 }
16357
16358 /// Implicit half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16359 /// the grid coordinates of the chunks a shrink to `new_dims` cuts
16360 /// through. Nothing is freed or cleared — this index has no per-chunk
16361 /// state to clear and no per-chunk block to free — so the chunks wholly
16362 /// beyond the extent keep their bytes, exactly as `H5D__none_idx_remove`
16363 /// leaves them. That also means their elements stay reachable, so a
16364 /// variable-length dataset's heap objects must *not* be released here.
16365 fn implicit_straddlers(
16366 &self,
16367 geo: &ChunkGeometry,
16368 new_dims: &[u64],
16369 ) -> IoResult<Vec<Vec<u64>>> {
16370 let mut nchunks: u64 = 1;
16371 for g in
16372 crate::io::chunk_grid::index_grid(&geo.dims, geo.max_dims.as_deref(), &geo.chunk_dims)?
16373 {
16374 nchunks = nchunks.checked_mul(g).ok_or_else(|| {
16375 crate::io::IoError::InvalidState("chunk count overflows u64".into())
16376 })?;
16377 }
16378 let mut straddlers = Vec::new();
16379 for lidx in 0..nchunks {
16380 let coords = crate::io::chunk_grid::coords_of(
16381 &geo.dims,
16382 geo.max_dims.as_deref(),
16383 &geo.chunk_dims,
16384 lidx,
16385 )?;
16386 if chunk_straddles_extent(&coords, &geo.chunk_dims, new_dims) {
16387 straddlers.push(coords);
16388 }
16389 }
16390 Ok(straddlers)
16391 }
16392
16393 /// V2-B-tree half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16394 /// drop the records of chunks beyond the extent — the next flush
16395 /// re-serializes the smaller tree over the node pool and releases the
16396 /// surplus node blocks.
16397 fn prune_bt2_chunks(
16398 &self,
16399 index: usize,
16400 geo: &ChunkGeometry,
16401 new_dims: &[u64],
16402 collect_refs: bool,
16403 ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16404 let ds = self.ds(index);
16405 let mut m = ds.lock();
16406 let pipeline = m.filter_pipeline.clone();
16407 let chunk_bytes = geo.chunk_bytes();
16408 let swmr = self.swmr_active;
16409 let mut straddlers = Vec::new();
16410 let mut dead_refs = Vec::new();
16411 let bt2 = m.btree_v2.as_mut().unwrap();
16412 if bt2.index.filtered {
16413 let records = std::mem::take(&mut bt2.index.filtered_records);
16414 let mut kept = Vec::with_capacity(records.len());
16415 for r in records {
16416 if chunk_outside_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16417 if collect_refs {
16418 if let Some(bytes) = self.read_chunk_block(
16419 pipeline.as_ref(),
16420 r.chunk_address,
16421 r.chunk_size,
16422 r.filter_mask,
16423 )? {
16424 dead_refs.extend_from_slice(&bytes);
16425 }
16426 }
16427 if !swmr {
16428 self.allocator
16429 .free(r.chunk_address, r.chunk_size, FreeSpaceClass::RawData);
16430 }
16431 } else {
16432 if chunk_straddles_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16433 straddlers.push(r.scaled_offsets.clone());
16434 }
16435 kept.push(r);
16436 }
16437 }
16438 bt2.index.filtered_records = kept;
16439 } else {
16440 let records = std::mem::take(&mut bt2.index.records);
16441 let mut kept = Vec::with_capacity(records.len());
16442 for r in records {
16443 if chunk_outside_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16444 if collect_refs {
16445 if let Some(bytes) = self.read_chunk_block(
16446 pipeline.as_ref(),
16447 r.chunk_address,
16448 chunk_bytes,
16449 0,
16450 )? {
16451 dead_refs.extend_from_slice(&bytes);
16452 }
16453 }
16454 if !swmr {
16455 self.allocator
16456 .free(r.chunk_address, chunk_bytes, FreeSpaceClass::RawData);
16457 }
16458 } else {
16459 if chunk_straddles_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16460 straddlers.push(r.scaled_offsets.clone());
16461 }
16462 kept.push(r);
16463 }
16464 }
16465 bt2.index.records = kept;
16466 }
16467 Ok((straddlers, dead_refs))
16468 }
16469
16470 /// Version-1-B-tree half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16471 /// drop the records of chunks beyond the extent — the next flush
16472 /// re-serializes the smaller tree over the node pool and releases the
16473 /// surplus node blocks.
16474 fn prune_btree_v1_chunks(
16475 &self,
16476 index: usize,
16477 geo: &ChunkGeometry,
16478 new_dims: &[u64],
16479 collect_refs: bool,
16480 ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16481 let ds = self.ds(index);
16482 let mut m = ds.lock();
16483 let pipeline = m.filter_pipeline.clone();
16484 let swmr = self.swmr_active;
16485 let mut straddlers = Vec::new();
16486 let mut dead_refs = Vec::new();
16487 let bt1 = m.btree_v1.as_mut().unwrap();
16488 let records = std::mem::take(&mut bt1.records);
16489 let mut kept = Vec::with_capacity(records.len());
16490 for r in records {
16491 if chunk_outside_extent(&r.scaled, &geo.chunk_dims, new_dims) {
16492 if collect_refs {
16493 if let Some(bytes) = self.read_chunk_block(
16494 pipeline.as_ref(),
16495 r.address,
16496 r.nbytes as u64,
16497 r.filter_mask,
16498 )? {
16499 dead_refs.extend_from_slice(&bytes);
16500 }
16501 }
16502 if !swmr {
16503 self.allocator
16504 .free(r.address, r.nbytes as u64, FreeSpaceClass::RawData);
16505 }
16506 } else {
16507 if chunk_straddles_extent(&r.scaled, &geo.chunk_dims, new_dims) {
16508 straddlers.push(r.scaled.clone());
16509 }
16510 kept.push(r);
16511 }
16512 }
16513 m.btree_v1.as_mut().unwrap().records = kept;
16514 Ok((straddlers, dead_refs))
16515 }
16516
16517 /// Flush a chunked dataset's index structures to disk (durable).
16518 ///
16519 /// Writes the index blocks and issues an `fdatasync` so the data is
16520 /// durable — the guarantee SWMR readers and standalone callers rely on.
16521 pub fn flush_dataset(&self, index: usize) -> IoResult<()> {
16522 let ds = self.ds(index);
16523 let _op = ds.op.lock();
16524 self.flush_dataset_synced(index, true)
16525 }
16526
16527 /// Flush a chunked dataset's index structures, syncing only if `sync`.
16528 ///
16529 /// `finalize` threads its own durability choice here so that a
16530 /// [`close_no_sync`](Self::close_no_sync) skips this per-dataset
16531 /// `sync_data` too — otherwise gating only the final `sync_all` would
16532 /// leave one `fdatasync` per indexed dataset and defeat the fast close.
16533 fn flush_dataset_synced(&self, index: usize, sync: bool) -> IoResult<()> {
16534 // Hold one slot guard for the whole method; `self.handle`/`self.ctx`/
16535 // `self.allocator` below touch disjoint fields.
16536 let ds = self.ds(index);
16537 let mut m = ds.lock();
16538
16539 // EA-indexed dataset
16540 if let Some(ref chunked) = m.chunked {
16541 if let Some(ref fiblk) = chunked.filt_iblk {
16542 // Filtered EA
16543 let iblk_encoded = fiblk.encode(&self.ctx, chunked.chunk_size_len);
16544 self.handle.write_at(chunked.ea_iblk_addr, &iblk_encoded)?;
16545 } else {
16546 // Unfiltered EA
16547 let iblk_encoded = chunked.ea_iblk.encode(&self.ctx);
16548 self.handle.write_at(chunked.ea_iblk_addr, &iblk_encoded)?;
16549 }
16550 let hdr_encoded = chunked.ea_header.encode(&self.ctx);
16551 self.handle.write_at(chunked.ea_header_addr, &hdr_encoded)?;
16552 if sync {
16553 self.handle.sync_data()?;
16554 }
16555 return Ok(());
16556 }
16557
16558 // Fixed-array-indexed dataset
16559 if let Some(ref fa) = m.fixed_array {
16560 let dblk_encoded = encode_fixed_array_dblk(&self.ctx, &fa.fa_header, &fa.fa_dblk);
16561 self.handle.write_at(fa.fa_dblk_addr, &dblk_encoded)?;
16562 let hdr_encoded = fa.fa_header.encode(&self.ctx);
16563 self.handle.write_at(fa.fa_header_addr, &hdr_encoded)?;
16564 if sync {
16565 self.handle.sync_data()?;
16566 }
16567 return Ok(());
16568 }
16569
16570 // BT2-indexed dataset
16571 if let Some(ref bt2) = m.btree_v2 {
16572 // Bulk-load the index into fixed-size nodes and lay them over the
16573 // dataset's block pool. Because every node is the same size, the
16574 // blocks already on disk are reused in place and only the shortfall
16575 // is allocated — the pool is the single owner of these addresses,
16576 // so no flush leaves a block behind. The addresses a reader already
16577 // holds stay valid, which is also what SWMR needs.
16578 let tree = bt2.index.build_tree(&self.ctx);
16579 let mut node_addrs = bt2.node_addrs.clone();
16580 while node_addrs.len() < tree.nodes.len() {
16581 node_addrs.push(
16582 self.allocator
16583 .allocate(tree.node_size as u64, FreeSpaceClass::Metadata),
16584 );
16585 }
16586 // A tree with fewer nodes than last flush releases the surplus
16587 // rather than leaving it recorded and unreachable, so the pool is
16588 // exactly one block per node whichever way the count moved. Under
16589 // SWMR a reader may still hold a header naming those blocks, so
16590 // keep them out of the free list — the same rule `place_chunk`
16591 // applies to a relocated chunk.
16592 for addr in node_addrs.split_off(tree.nodes.len()) {
16593 if !self.swmr_active {
16594 self.allocator
16595 .free(addr, tree.node_size as u64, FreeSpaceClass::Metadata);
16596 }
16597 }
16598
16599 for (image, &addr) in tree.encode(&self.ctx, &node_addrs).iter().zip(&node_addrs) {
16600 self.handle.write_at(addr, image)?;
16601 }
16602
16603 // The root is the last node the bulk load emits.
16604 let root_addr = match tree.nodes.len() {
16605 0 => UNDEF_ADDR,
16606 n => node_addrs[n - 1],
16607 };
16608 let hdr_encoded = tree.header(root_addr).encode(&self.ctx);
16609 self.handle.write_at(bt2.bt2_header_addr, &hdr_encoded)?;
16610
16611 m.btree_v2.as_mut().unwrap().node_addrs = node_addrs;
16612
16613 if sync {
16614 self.handle.sync_data()?;
16615 }
16616 return Ok(());
16617 }
16618
16619 // Version-1-B-tree-indexed dataset
16620 if let Some(ref bt1) = m.btree_v1 {
16621 // Bulk-loaded over the same block pool the v2 B-tree above uses,
16622 // and for the same reason: every node of a v1 tree is the width
16623 // its "K" value gives, so a block stays usable however the tree
16624 // reshapes, and only the shortfall is ever allocated.
16625 let element_size = m.datatype.element_size() as u64;
16626 let tree = bt1.build_tree(element_size, self.ctx.sizeof_addr as usize);
16627 let node_size = tree.node_size() as u64;
16628 let mut node_addrs = bt1.node_addrs.clone();
16629 while node_addrs.len() < tree.node_count() {
16630 node_addrs.push(self.allocator.allocate(node_size, FreeSpaceClass::Metadata));
16631 }
16632 // A tree with fewer nodes than last flush releases the surplus
16633 // straight away, where the v2 B-tree has to keep it out of the
16634 // free list for a live SWMR reader: this index lives only in a
16635 // classic file, which `start_swmr` refuses outright (and upstream
16636 // says the same in `H5D_COPS_BTREE`).
16637 for addr in node_addrs.split_off(tree.node_count()) {
16638 self.allocator
16639 .free(addr, node_size, FreeSpaceClass::Metadata);
16640 }
16641 for (image, &addr) in tree.encode(&node_addrs)?.iter().zip(&node_addrs) {
16642 self.handle.write_at(addr, image)?;
16643 }
16644 // The root is the last node the bulk load emits, and is undefined
16645 // while the dataset has no chunks — what the version-3 data
16646 // layout message then carries, exactly as libhdf5 leaves it.
16647 let root_addr = tree.root_address(&node_addrs);
16648 let bt1 = m.btree_v1.as_mut().unwrap();
16649 bt1.node_addrs = node_addrs;
16650 bt1.root_addr = root_addr;
16651
16652 if sync {
16653 self.handle.sync_data()?;
16654 }
16655 return Ok(());
16656 }
16657
16658 Ok(())
16659 }
16660
16661 /// Finalize and close the file.
16662 ///
16663 /// Writes the dataset object headers, root group object header, and
16664 /// superblock. After this call the file is a valid HDF5 file.
16665 pub fn close(mut self) -> IoResult<()> {
16666 self.close_in_place()
16667 }
16668
16669 /// [`close`](Self::close) for a holder that cannot give the writer up by
16670 /// value because it has a `Drop` of its own ([`SwmrWriter`]): the same
16671 /// one-shot commit, after which this writer's `Drop` is a no-op.
16672 ///
16673 /// [`SwmrWriter`]: crate::io::swmr::SwmrWriter
16674 pub(crate) fn close_in_place(&mut self) -> IoResult<()> {
16675 // Mark closed BEFORE finalizing: finalize writes external truth
16676 // (object headers + superblock) and must run exactly once. If we
16677 // finalized first and it failed, the `?` would return with `closed`
16678 // still false, and dropping `self` would re-run `finalize` a second
16679 // time over a half-written file (and print the "call close()" notice
16680 // the caller already heeded). Committing to the close path first makes
16681 // `Drop` (the only other finalize site) a no-op regardless of outcome,
16682 // so the error is reported exactly once via this `Result`.
16683 self.closed = true;
16684 self.finalize(true)
16685 }
16686
16687 /// Finalize and close the file without a final `fsync`.
16688 ///
16689 /// Identical to [`close`](Self::close) — the same object headers and
16690 /// superblock are written, so on return the file is a complete, valid HDF5
16691 /// file readable by any process — except that the trailing `sync_all`
16692 /// (fsync) is skipped. The bytes are handed to the OS but are not
16693 /// guaranteed durable against power loss or an OS crash until the OS
16694 /// flushes its page cache; a normal process exit or a same-machine reader
16695 /// sees the full file regardless.
16696 ///
16697 /// This trades durability for speed: `sync_all` typically dominates close
16698 /// latency, so bulk writers that do not need crash durability (the file can
16699 /// be regenerated) can use this to avoid that cost. Use [`close`](Self::close)
16700 /// when durability matters. `Drop` always finalizes durably, so a writer
16701 /// finalized this way must reach `close_no_sync` explicitly.
16702 pub fn close_no_sync(mut self) -> IoResult<()> {
16703 // Same close-once discipline as `close`: commit to the close path
16704 // before finalizing so `Drop` cannot re-run `finalize` on failure.
16705 self.closed = true;
16706 self.finalize(false)
16707 }
16708
16709 /// Provide mutable access to the underlying file handle.
16710 pub fn handle(&mut self) -> &mut FileHandle {
16711 &mut self.handle
16712 }
16713
16714 /// The superblock version this file will be written with.
16715 ///
16716 /// `H5F__super_init` takes the oldest version that can describe the file
16717 /// and raises it to the one the file's library-version low bound implies:
16718 /// `super_vers = MAX(super_vers, HDF5_superblock_ver_bounds[low_bound])`,
16719 /// with the bounds table reading 0, 2, 3, 3, 3, 3, 3 for EARLIEST, V18,
16720 /// V110, V112, V114, V200, LATEST (H5Fsuper.c:68, :1128-1154). A file
16721 /// created at `H5F_LIBVER_EARLIEST` takes that bound's entry directly
16722 /// ([`SuperblockVersion::Chosen`], and the classic branch below) — version
16723 /// 0, or version 2 when the file carries shared messages, whose master
16724 /// table needs the superblock extension only a version-2 superblock has
16725 /// (H5Fsuper.c:1135). For every other file the bound is read back from
16726 /// what this crate writes:
16727 ///
16728 /// * The floor is `H5F_LIBVER_V18`, hence version 2. Every group such a
16729 /// file holds is a link-message group, which libhdf5 only writes at a
16730 /// low bound of V18 or newer (`use_at_least_v18`, H5Gobj.c:179), and
16731 /// every object header in it is version 2, which `H5O_obj_ver_bounds`
16732 /// likewise puts at V18 (H5Oint.c:125). A version-0 superblock over
16733 /// this content would claim a file libhdf5 1.6 can read, and no libhdf5
16734 /// writes that combination.
16735 /// * A chunked dataset — extensible array, fixed array or version-2
16736 /// B-tree, all reached through a version-4 or -5 data layout message —
16737 /// reads back as V110 (`H5O_layout_ver_bounds`, H5Dlayout.c:44), hence
16738 /// version 3.
16739 /// * SWMR writes version 3 outright (H5Fsuper.c:1129).
16740 ///
16741 /// A file whose caller *named* a bound skips the read-back and takes that
16742 /// bound's row directly, so `V18` stays at version 2 however its chunked
16743 /// datasets are indexed — which is what libhdf5 does, the layout version
16744 /// being no input to `H5F__super_init` at all.
16745 ///
16746 /// None of that applies to a reopened file. `H5F__super_read` validates
16747 /// the version it finds and never recomputes one, so the version written
16748 /// back is the version read, whatever this session appends — see
16749 /// [`SuperblockVersion`].
16750 fn superblock_version_for(&self, flags: u8) -> u8 {
16751 let chosen = match self.superblock_version {
16752 SuperblockVersion::Existing(version) => return version,
16753 SuperblockVersion::Chosen(version) => version,
16754 };
16755 if self.is_legacy() {
16756 // A classic file keeps the version it was created at — 0, or 2
16757 // when its shared messages needed the extension. Nothing a session
16758 // can add reaches past that: its objects get symbol-table links,
16759 // its chunked datasets the version-1 B-tree behind a version-3
16760 // layout message, and the two features that would raise the bound
16761 // — SWMR and the 2.0 format — are refused where the caller asks
16762 // for them.
16763 return chosen;
16764 }
16765 let mut version = chosen
16766 .max(SUPERBLOCK_V2)
16767 .max(self.effective_libver().superblock_version());
16768 if self.swmr_active || flags & FLAG_SWMR_WRITE != 0 {
16769 version = version.max(SUPERBLOCK_V3);
16770 }
16771 version
16772 }
16773
16774 /// The low bound a modern file this writer *created* is effectively
16775 /// written at: the one the caller named, or — with none named — the one
16776 /// its content reads back as. A reopened file never reaches here; its
16777 /// superblock version is not derived from its content at all.
16778 ///
16779 /// The read-back is what `superblock_version_for` needs and the field
16780 /// alone cannot give: this crate's default file names no bound, and the
16781 /// generation it writes is not one bound but two rows (see the `libver`
16782 /// field). The floor is `V18`, the oldest bound under which libhdf5 writes
16783 /// link-message groups (`use_at_least_v18`, H5Gobj.c:179) and version-2
16784 /// object headers (`H5O_obj_ver_bounds`, H5Oint.c:125), which is all such
16785 /// a file holds; a v1.10 chunk index in it raises that to `V110`, the
16786 /// oldest bound whose `H5O_layout_ver_bounds` row reaches the version-4
16787 /// layout message that index is written behind.
16788 fn effective_libver(&self) -> LibverBound {
16789 self.libver.unwrap_or_else(|| {
16790 if self.has_v110_chunk_index() {
16791 LibverBound::V110
16792 } else {
16793 LibverBound::V18
16794 }
16795 })
16796 }
16797
16798 /// Whether any dataset still in the file is indexed by a v1.10 chunk
16799 /// index — the markers `build_dataset_header` turns into a version-4/5
16800 /// data layout message, and nothing else it can emit reaches that
16801 /// version.
16802 ///
16803 /// Not "is any dataset chunked": the version-1 B-tree is a chunk index
16804 /// that encodes as a *version-3* layout message, the version
16805 /// `H5O_layout_ver_bounds` gives the earliest bound, so a dataset using
16806 /// it asks nothing of the superblock.
16807 fn has_v110_chunk_index(&self) -> bool {
16808 self.dataset_refs().iter().any(|d| {
16809 let m = d.lock();
16810 !m.deleted
16811 && m.chunk_index_kind()
16812 .is_some_and(|k| k != ChunkIndexKind::BtreeV1)
16813 })
16814 }
16815
16816 /// Write the superblock at offset 0 with the given flags.
16817 ///
16818 /// Requires that the root group has already been written (via `finalize`
16819 /// or `finalize_for_swmr`).
16820 pub fn write_superblock(&mut self, flags: u8) -> IoResult<()> {
16821 let root_addr = self
16822 .root_group_addr
16823 .ok_or_else(|| crate::io::IoError::InvalidState("root group not yet written".into()))?;
16824 // The userblock this file was opened with. `H5F__super_read` prefers
16825 // the located address over this field, but `H5Pget_userblock` reports
16826 // it, so a rewrite that zeroed it would hide the block from every
16827 // reader that asks for its size.
16828 let base = self.handle.base();
16829 // The end of file is the one address in the superblock measured from
16830 // the start of the *file* rather than from the base: `H5F__super_read`
16831 // sets the EOA to `stored_eof - base_addr` (H5Fsuper.c:635) and calls
16832 // the file truncated when `eof + base_addr < stored_eof` (:573). The
16833 // allocator counts in the based space, so the userblock is added back.
16834 let eof = self.allocator.eof() + base;
16835 let version = self.superblock_version_for(flags);
16836 // Which of the two images is written follows the version, not the
16837 // generation: a classic file carrying shared messages is a version-2
16838 // superblock over version-1 messages and symbol-table groups
16839 // (H5Fsuper.c:1135), and only the version-2/3 image has the extension
16840 // address that table is reached through. Below version 2 the file is
16841 // always a classic one — the other branch floors at 2.
16842 if let Some(legacy) = self.legacy.as_deref().filter(|_| version < SUPERBLOCK_V2) {
16843 // Re-emitted, not rebuilt: the "K" ranks, the userblock size and
16844 // the driver info address are recorded nowhere else in the file,
16845 // and every node width in it is derived from the ranks. Only the
16846 // three things this session can have changed are recomputed.
16847 let root_stab = self
16848 .symbol_tables
16849 .written
16850 .lock()
16851 .get(&LinkScope::Root)
16852 .copied();
16853 let mut sb = legacy.superblock.clone();
16854 sb.version = version;
16855 sb.file_consistency_flags = flags as u32;
16856 sb.end_of_file_address = eof;
16857 sb.root_symbol_table_entry.obj_header_addr = root_addr;
16858 // `H5G__stab_valid` (H5Groot.c) reads this pair back and compares
16859 // it against the root header's Symbol Table message, repairing the
16860 // superblock when they disagree. Writing the pair that message now
16861 // names is what keeps the file from needing that repair. A root
16862 // that keeps its links in messages has no such pair and no entry
16863 // in `written`, and gets `H5G_NOTHING_CACHED` — what libhdf5
16864 // writes for the same root.
16865 sb.root_symbol_table_entry.cache = match root_stab {
16866 Some(s) => SymbolTableCache::SymbolTable {
16867 btree_addr: s.btree_addr,
16868 heap_addr: s.heap_addr,
16869 },
16870 None => SymbolTableCache::Nothing,
16871 };
16872 self.handle.write_at(0, &sb.encode())?;
16873 return Ok(());
16874 }
16875 let sb = SuperblockV2V3 {
16876 version,
16877 sizeof_offsets: self.ctx.sizeof_addr,
16878 sizeof_lengths: self.ctx.sizeof_size,
16879 file_consistency_flags: flags,
16880 base_address: base,
16881 // Whatever `write_superblock_extension` put there, which is the
16882 // only place an extension is written.
16883 superblock_extension_address: self.extension.addr.lock().unwrap_or(UNDEF_ADDR),
16884 end_of_file_address: eof,
16885 root_group_object_header_address: root_addr,
16886 };
16887 self.handle.write_at(0, &sb.encode())?;
16888 Ok(())
16889 }
16890
16891 /// Re-write a dataset's object header in place (SWMR update).
16892 ///
16893 /// The header must have been written by `finalize_for_swmr`, and goes
16894 /// back over the same blocks: chunk 0 held to its block and the
16895 /// continuation chunk, when it has one, to its own. Only the dataspace
16896 /// dimensions are meant to change; a header that no longer fits is
16897 /// refused rather than moved, since a reader holds its address.
16898 pub fn write_dataset_header_inplace(&mut self, index: usize) -> IoResult<()> {
16899 // Scope the slot guard: `build_dataset_header` re-locks the same slot.
16900 let placement = {
16901 let ds = self.ds(index);
16902 let m = ds.lock();
16903 HeaderPlacement::over(&m.obj_header_blocks).ok_or_else(|| {
16904 crate::io::IoError::InvalidState("dataset header not yet written".into())
16905 })?
16906 };
16907
16908 let header = self.build_dataset_header(index)?;
16909 let nlink = self.object_link_count(HardLinkTarget::Dataset(index));
16910 let format = self.dataset_header_format(index);
16911 let images = self.encode_header_in(&header, nlink, format, &placement)?;
16912 let reserved = placement.blocks();
16913 let fits = images.len() == reserved.len()
16914 && images
16915 .iter()
16916 .zip(&reserved)
16917 .all(|((_, image), &(_, size))| image.len() as u64 == size);
16918 if !fits {
16919 return Err(crate::io::IoError::InvalidState(format!(
16920 "dataset header grew from {} to {} bytes; cannot rewrite in place",
16921 reserved.iter().map(|&(_, size)| size).sum::<u64>(),
16922 images.iter().map(|(_, image)| image.len()).sum::<usize>()
16923 )));
16924 }
16925 for (addr, image) in &images {
16926 self.handle.write_at(*addr, image)?;
16927 }
16928 // Only after the bytes are down: a failed write leaves the registry
16929 // describing the header the file still holds.
16930 self.ds(index).lock().header_written(nlink);
16931 Ok(())
16932 }
16933
16934 /// Perform a full finalize for SWMR mode.
16935 ///
16936 /// This writes all dataset object headers, the root group header, and the
16937 /// superblock with SWMR flags. After this call, the file is valid for
16938 /// SWMR readers. Subsequent writes use in-place updates.
16939 pub fn finalize_for_swmr(&mut self) -> IoResult<()> {
16940 self.reject_swmr()?;
16941 // 0. Flush all chunked dataset index structures.
16942 for i in 0..self.dataset_count() {
16943 let is_indexed = {
16944 let ds = self.ds(i);
16945 let m = ds.lock();
16946 !m.deleted && m.is_chunked()
16947 };
16948 if is_indexed {
16949 self.flush_dataset(i)?;
16950 }
16951 }
16952
16953 // 1. Allocate every object header (none for a dataset deleted before
16954 // start_swmr — its storage was freed at delete time). Same three
16955 // phases as the full finalize, and for the same reason: nothing a
16956 // header names can be laid out until every object has an address.
16957 let live: Vec<usize> = (0..self.dataset_count())
16958 .filter(|&i| !self.ds(i).lock().deleted)
16959 .collect();
16960 let kept = self.supersede_headers(&live);
16961 // Before any dataset header: a sharing dataset's header names the
16962 // committed type's address.
16963 self.write_committed_datatype_headers()?;
16964 let layout = self.allocate_object_headers(&live, &kept)?;
16965
16966 // 2. Build content against those addresses.
16967 self.prepare_dense_attributes(&live)?;
16968 self.prepare_link_storage()?;
16969 self.write_reference_values()?;
16970
16971 // 3. Write every object header.
16972 self.write_object_headers(&layout)?;
16973 // Where each header is published and how much room it has: what
16974 // `write_dataset_header_inplace` rewrites within, and what the
16975 // closing finalize writes over, since a reader may by then hold any
16976 // of these addresses.
16977 for &(i, placement) in &layout.datasets {
16978 let ds = self.ds(i);
16979 let mut m = ds.lock();
16980 m.obj_header_written_addr = Some(placement.addr);
16981 m.obj_header_blocks = placement.blocks();
16982 }
16983 for &(gi, placement) in &layout.groups {
16984 let grp = self.grp(gi);
16985 let mut g = grp.lock();
16986 g.obj_header_written_addr = Some(placement.addr);
16987 g.obj_header_blocks = placement.blocks();
16988 }
16989 self.superseded_root_header = layout.root.blocks();
16990
16991 // 4. Write superblock with SWMR flags.
16992 self.write_superblock(FLAG_WRITE_ACCESS | FLAG_SWMR_WRITE)?;
16993 self.handle.set_eof(self.allocator.eof())?;
16994
16995 self.handle.sync_all()?;
16996 // Readers can now be following this file, so a chunk that moves must
16997 // leave its old block intact for whoever is still holding the previous
16998 // index (see `swmr_active`).
16999 self.swmr_active = true;
17000 Ok(())
17001 }
17002
17003 // ------------------------------------------------------------------
17004 // Internal helpers
17005 // ------------------------------------------------------------------
17006
17007 /// Flush every dataset's append buffer into the chunks it belongs to,
17008 /// through [`flush_append_buffer`](Self::flush_append_buffer): frames
17009 /// already in the chunk survive, and the rest of it reads back as the
17010 /// dataset's fill value (zeros when none is defined).
17011 fn flush_append_buffers(&mut self) -> IoResult<()> {
17012 for i in 0..self.dataset_count() {
17013 if self.ds(i).lock().deleted {
17014 continue;
17015 }
17016 self.flush_append_buffer(i)?;
17017 }
17018 Ok(())
17019 }
17020
17021 /// Write all object headers and the superblock, producing a complete,
17022 /// valid HDF5 file.
17023 ///
17024 /// `sync == true` issues a final `sync_all` (fsync) so the bytes are
17025 /// durable against power loss / OS crash before returning. `sync == false`
17026 /// skips that fsync: the file is still fully written to the OS and readable
17027 /// by any process, but durability is left to the OS page-cache flush. This
17028 /// is the only difference between [`close`](Self::close) (durable) and
17029 /// [`close_no_sync`](Self::close_no_sync) (fast).
17030 fn finalize(&mut self, sync: bool) -> IoResult<()> {
17031 // Flush any partial append buffers before finalizing
17032 self.flush_append_buffers()?;
17033
17034 // A SWMR session (`finalize_for_swmr` already ran, so
17035 // `root_group_addr` is `Some`) is closed by the same full finalize as
17036 // a fresh write: every object header is rebuilt over its chunk 0 and
17037 // the superblock is written with clean-close flags. A full rebuild —
17038 // rather than the in-place header rewrite used by the live
17039 // `SwmrWriter::flush` path — is required so any structural change made
17040 // after `start_swmr` is committed to the final file. A hard link, in
17041 // particular, both grows its target's header with an object
17042 // reference-count message and adds a `MSG_LINK` record to a group
17043 // header; an in-place rewrite cannot accommodate the grown header and
17044 // never re-emits group/root headers. The fall-through below already
17045 // handles datasets whose header was written by `finalize_for_swmr`
17046 // (`obj_header_written_addr.is_some()`).
17047
17048 // 0. Flush chunked dataset index structures (only modified datasets).
17049 for i in 0..self.dataset_count() {
17050 let ds = self.ds(i);
17051 {
17052 let m = ds.lock();
17053 if m.deleted {
17054 continue;
17055 }
17056 if m.obj_header_written_addr.is_some() && !m.storage_dirty() {
17057 continue;
17058 }
17059 let is_indexed = m.is_chunked();
17060 if !is_indexed {
17061 continue;
17062 }
17063 }
17064 self.flush_dataset_synced(i, sync)?;
17065 }
17066
17067 // 1. Plan. Which datasets get a header (deleted datasets get none —
17068 // their storage was already freed at delete time) is settled first,
17069 // because everything the next phases lay out is laid out only for the
17070 // headers this finalize actually rewrites; and every header those
17071 // phases supersede is taken here, before the first allocation, so
17072 // the rewrite lands over it.
17073 let mut rewritten: Vec<usize> = Vec::new();
17074 // A finalize that lays the shared-message table out afresh reassigns
17075 // every heap ID in the file, so no existing header can keep its bytes:
17076 // the pointers in them name heap objects the new table does not have.
17077 let table_replaced = self.rebuilds_shared_messages();
17078 for i in 0..self.dataset_count() {
17079 // Before the slot guard: `object_link_count` re-locks every
17080 // dataset and group slot, this one included.
17081 let nlink = self.object_link_count(HardLinkTarget::Dataset(i));
17082 let ds = self.ds(i);
17083 let mut m = ds.lock();
17084 if m.deleted {
17085 continue;
17086 }
17087 // An existing dataset from append mode keeps its header — and
17088 // everything that header names — unless this session changed
17089 // what the header says.
17090 if let Some(written) = m.obj_header_written_addr {
17091 if !table_replaced && !m.header_stale_with(nlink) {
17092 // Keep the original object header address for the root group link.
17093 m.obj_header_addr = written;
17094 continue;
17095 }
17096 }
17097 rewritten.push(i);
17098 }
17099 let kept = self.supersede_headers(&rewritten);
17100
17101 // 2. Allocate. Committed datatype headers go down whole: a header of
17102 // theirs holds a datatype and a reference count, so it waits on
17103 // nothing, while a dataset sharing the type and the group naming it
17104 // both store its address. They are written before the shared-message
17105 // phase opens, so a committed type reaches the file as itself.
17106 self.write_committed_datatype_headers()?;
17107 self.begin_shared_message_layout();
17108 let layout = self.allocate_object_headers(&rewritten, &kept)?;
17109
17110 // 3. Build content, with every object header's address known. Dense
17111 // attribute storage holds the attribute messages themselves — an
17112 // object reference among them is a header address; dense links and
17113 // symbol tables name header addresses; a reference dataset's elements
17114 // are header addresses. Nothing here is a fixup: each is written once,
17115 // with the value the file keeps. The shared-message table comes last:
17116 // it counts the bodies the headers will hold, and the three above are
17117 // what settle them.
17118 self.prepare_dense_attributes(&rewritten)?;
17119 self.prepare_link_storage()?;
17120 self.write_reference_values()?;
17121 self.prepare_shared_messages(&rewritten)?;
17122 self.write_superblock_extension()?;
17123
17124 // 4. Write every object header over the block phase 2 reserved for it.
17125 self.write_object_headers(&layout)?;
17126
17127 // 5. Write superblock at offset 0.
17128 self.write_superblock(0)?;
17129
17130 // 6. End the file where its address space ends (`H5FD_truncate`, which
17131 // `H5F__dest` calls on every close). Allocated-but-unwritten space at
17132 // the end would otherwise leave the file shorter than the end-of-file
17133 // address the superblock just recorded, which libhdf5 reads as a
17134 // truncated file.
17135 self.handle.set_eof(self.allocator.eof())?;
17136
17137 // Durability is opt-in per call: `close` passes `true`, `close_no_sync`
17138 // passes `false`, and `Drop` passes `true` so an un-`close`d writer is
17139 // still finalized durably by default.
17140 if sync {
17141 self.handle.sync_all()?;
17142 }
17143 Ok(())
17144 }
17145
17146 /// Take the on-disk header of every object this finalize rewrites — the
17147 /// datasets in `datasets`, every group, and the root — and hand each
17148 /// chunk-0 block to [`allocate_object_headers`](Self::allocate_object_headers)
17149 /// to be written over.
17150 ///
17151 /// Chunk 0 stays where it is: its address is what every reference in the
17152 /// file holds. The continuation blocks behind it go back to the free
17153 /// list, so the rewrite reuses them instead of growing the file on every
17154 /// open/close cycle — nothing names one but its own header. Hard links
17155 /// can alias one header under several names; the set keeps an aliased
17156 /// chain from being taken twice. The registry forgets each header here,
17157 /// so a finalize that fails later describes none the file no longer holds.
17158 fn supersede_headers(&mut self, datasets: &[usize]) -> KeptChunks {
17159 let mut kept = KeptChunks::default();
17160 let mut taken = std::collections::HashSet::new();
17161 for &i in datasets {
17162 let ds = self.ds(i);
17163 let mut m = ds.lock();
17164 let Some(old) = m.obj_header_written_addr.take() else {
17165 continue;
17166 };
17167 let blocks = std::mem::take(&mut m.obj_header_blocks);
17168 if !blocks.is_empty() && taken.insert(old) {
17169 kept.datasets.insert(i, self.keep_chunk0(blocks));
17170 }
17171 }
17172 for gi in 0..self.group_count() {
17173 let grp = self.grp(gi);
17174 let mut g = grp.lock();
17175 let Some(old) = g.obj_header_written_addr.take() else {
17176 continue;
17177 };
17178 let blocks = std::mem::take(&mut g.obj_header_blocks);
17179 if !blocks.is_empty() && taken.insert(old) {
17180 kept.groups.insert(gi, self.keep_chunk0(blocks));
17181 }
17182 }
17183 let root_blocks = std::mem::take(&mut self.superseded_root_header);
17184 if root_blocks
17185 .first()
17186 .is_some_and(|&(addr, _)| taken.insert(addr))
17187 {
17188 kept.root = Some(self.keep_chunk0(root_blocks));
17189 }
17190 kept
17191 }
17192
17193 /// Keep `blocks`' chunk 0 for a rewrite and free the continuation blocks
17194 /// behind it — never under SWMR, where a live reader may be walking them,
17195 /// the same rule `release_vlen_references` and `place_chunk` follow.
17196 fn keep_chunk0(&self, blocks: crate::io::object_header_io::HeaderBlocks) -> (u64, u64) {
17197 let mut blocks = blocks.into_iter();
17198 let chunk0 = blocks.next().expect("a written header has a chunk 0");
17199 if !self.swmr_active {
17200 for (addr, len) in blocks {
17201 self.allocator.free(addr, len, FreeSpaceClass::Metadata);
17202 }
17203 }
17204 chunk0
17205 }
17206
17207 /// Give every object header this finalize writes an address, before
17208 /// anything that names one is built.
17209 ///
17210 /// INVARIANT: from the moment this returns until the file is closed, every
17211 /// object in it has the object header address it will be found at. That is
17212 /// what lets the phase after this one say an address wherever the format
17213 /// wants one — in a link message, in a symbol table entry, in a reference
17214 /// dataset's elements, and in an attribute's value, which is the one of the
17215 /// four that cannot be revisited after its header is written.
17216 ///
17217 /// An object in `kept` is placed over the chunk-0 block it already has, so
17218 /// its address is the one every reference in the file already holds. A
17219 /// header is measured before its content is final, which is sound because
17220 /// no address changes its length: every address is a fixed-width field,
17221 /// and an object that has none yet reads as zero, which is the same width.
17222 /// The storage a header names is laid out between the two passes for the
17223 /// same reason and answers the same way — `emit_attributes` and
17224 /// `emit_links` each fall back to a size-equal placeholder message. It is
17225 /// [`write_object_headers`](Self::write_object_headers) that checks this
17226 /// held, rather than either pass assuming it.
17227 fn allocate_object_headers(
17228 &mut self,
17229 datasets: &[usize],
17230 kept: &KeptChunks,
17231 ) -> IoResult<HeaderLayout> {
17232 let mut layout = HeaderLayout {
17233 datasets: Vec::with_capacity(datasets.len()),
17234 groups: Vec::new(),
17235 root: HeaderPlacement::fresh(0, 0),
17236 };
17237 for &i in datasets {
17238 let header = self.build_dataset_header(i)?;
17239 let format = self.dataset_header_format(i);
17240 let placement = self.place_header(&header, format, kept.datasets.get(&i).copied())?;
17241 self.ds(i).lock().obj_header_addr = placement.addr;
17242 layout.datasets.push((i, placement));
17243 }
17244 for gi in 0..self.group_count() {
17245 if self.grp(gi).lock().deleted {
17246 continue;
17247 }
17248 let header = self.build_group_header(gi)?;
17249 let format = self.group_header_format(gi);
17250 let placement = self.place_header(&header, format, kept.groups.get(&gi).copied())?;
17251 self.grp(gi).lock().obj_header_addr = placement.addr;
17252 layout.groups.push((gi, placement));
17253 }
17254 let header = self.build_root_group_header()?;
17255 let format = self.header_format(self.root_track_order);
17256 let placement = self.place_header(&header, format, kept.root)?;
17257 self.root_group_addr = Some(placement.addr);
17258 layout.root = placement;
17259 Ok(layout)
17260 }
17261
17262 /// Write every object header over the blocks
17263 /// [`allocate_object_headers`](Self::allocate_object_headers) reserved for
17264 /// it.
17265 ///
17266 /// The single owner of object header writing in both finalize paths, and
17267 /// the only place a header's body meets its block: a body that does not
17268 /// fill its measurement exactly fails the finalize here rather than
17269 /// overrunning the next object or leaving a tail of the previous one, which
17270 /// is how a message whose length turns out to depend on an address would
17271 /// show up.
17272 fn write_object_headers(&mut self, layout: &HeaderLayout) -> IoResult<()> {
17273 for &(i, placement) in &layout.datasets {
17274 let rc = self.object_link_count(HardLinkTarget::Dataset(i));
17275 let header = self.build_dataset_header(i)?;
17276 let format = self.dataset_header_format(i);
17277 let what = format!("dataset '{}'", self.ds(i).lock().name);
17278 self.write_header_in(&header, rc, format, &placement, &what)?;
17279 // Only after the bytes are down: a failed write leaves the registry
17280 // describing the header the file still holds.
17281 self.ds(i).lock().header_written(rc);
17282 }
17283 for &(gi, placement) in &layout.groups {
17284 let rc = self.object_link_count(HardLinkTarget::Group(gi));
17285 let header = self.build_group_header(gi)?;
17286 let format = self.group_header_format(gi);
17287 let what = format!("group '{}'", self.grp(gi).lock().name);
17288 self.write_header_in(&header, rc, format, &placement, &what)?;
17289 }
17290 let header = self.build_root_group_header()?;
17291 let format = self.header_format(self.root_track_order);
17292 self.write_header_in(&header, 1, format, &layout.root, "the root group")
17293 }
17294
17295 /// Encode `header` into `placement` and write it, after checking each
17296 /// image against the block reserved for it.
17297 fn write_header_in(
17298 &mut self,
17299 header: &ObjectHeader,
17300 rc: u32,
17301 format: ObjectFormat,
17302 placement: &HeaderPlacement,
17303 what: &str,
17304 ) -> IoResult<()> {
17305 let images = self.encode_header_in(header, rc, format, placement)?;
17306 let reserved =
17307 std::iter::once(placement.size).chain(placement.continuation.map(|(_, s)| s));
17308 for ((addr, image), size) in images.iter().zip(reserved) {
17309 check_header_size(image, size, || what.to_string())?;
17310 self.handle.write_at(*addr, image)?;
17311 }
17312 Ok(())
17313 }
17314
17315 fn build_dataset_header(&self, index: usize) -> IoResult<ObjectHeader> {
17316 // Compute the link count first: object_link_count re-locks dataset and
17317 // group slots (including this one), so it must run before we take this
17318 // dataset's slot guard — otherwise it would deadlock on the same slot.
17319 let rc = self.object_link_count(HardLinkTarget::Dataset(index));
17320 // Same reason: reading the committed type's address locks the
17321 // committed-datatype registry, which the slot guard below must not be
17322 // held across.
17323 let committed = self.ds(index).lock().committed_type;
17324 let committed_addr = committed.map(|r| match r {
17325 CommittedTypeRef::Session(ci) => self.committed_datatypes.lock()[ci].obj_header_addr,
17326 CommittedTypeRef::Preserved(addr) => addr,
17327 });
17328 // And again: an attribute holding an object reference is said in the
17329 // target's header address, which is read off that object's slot.
17330 let attributes = self.object_attributes(AttrScope::Dataset(index))?;
17331
17332 // Hold one slot guard for the whole header build.
17333 let ds = self.ds(index);
17334 let m = ds.lock();
17335 let mut header = ObjectHeader::new();
17336
17337 // Every message below is written in the format this dataset already
17338 // has, not the one this session would pick. libhdf5 grows a header in
17339 // place and never re-encodes a message it did not touch, so a reopen
17340 // at a newer bound leaves the version-1 dataspaces an EARLIEST-bound
17341 // creating session wrote exactly as they are. This writer has to lay
17342 // the whole header out again whenever the shared-message heap moves,
17343 // so preserving the encoding is the only way to land on the same
17344 // bytes.
17345 let format = m.read_format.unwrap_or_else(|| self.message_format());
17346 let libver = match format {
17347 ObjectFormat::Legacy => LibverBound::Earliest,
17348 ObjectFormat::Modern => self.encoding_libver(),
17349 };
17350
17351 // Dataspace message (type 0x01)
17352 let ds_msg = m.dataspace.encode_for(&self.ctx, format);
17353 let owner = ShareOwner::Header(m.obj_header_addr);
17354 let (flags, ds_msg) = self.share_message(owner, MSG_DATASPACE, 0x00, ds_msg);
17355 header.add_message(MSG_DATASPACE, flags, ds_msg);
17356
17357 // Datatype message (type 0x03). A dataset built on a committed type
17358 // stores a pointer to that object header in place of the message, and
17359 // the shared flag is what says the body is a pointer — the two are one
17360 // statement, so they are written together.
17361 match committed_addr {
17362 Some(addr) => header.add_message(
17363 MSG_DATATYPE,
17364 MSG_FLAG_CONSTANT | MSG_FLAG_SHARED,
17365 SharedMessagePointer::encode_committed(addr, &self.ctx),
17366 ),
17367 None => {
17368 let body = m.datatype.encode_at(&self.ctx, libver);
17369 let (flags, body) = if self.dataset_datatype_shareable(&m.datatype, libver) {
17370 self.share_message(owner, MSG_DATATYPE, MSG_FLAG_CONSTANT, body)
17371 } else {
17372 (MSG_FLAG_CONSTANT, body)
17373 };
17374 header.add_message(MSG_DATATYPE, flags, body)
17375 }
17376 }
17377
17378 // Fill Value message (type 0x05)
17379 let is_chunked = m.is_chunked();
17380 // `H5P__init_def_layout` gives each storage class its own default
17381 // allocation time: incremental for chunked and for virtual (whose
17382 // source datasets are allocated as they are written), early for
17383 // compact (the space is the header, so it exists as soon as the
17384 // dataset does), late for contiguous. An implicitly indexed dataset is
17385 // the one chunked exception, and not by default but by definition:
17386 // early allocation is a *condition* of that index
17387 // (`H5D__layout_set_latest_indexing`), so a header claiming
17388 // incremental would describe a file libhdf5 would never have chosen
17389 // this index for. A single-chunk dataset can go either way — unlike
17390 // Implicit, early allocation is not one of its selection conditions
17391 // — so its `early_alloc` flag (set only for an unfiltered dataset
17392 // created that way) is what this checks instead.
17393 let alloc_time = if m.compact.is_some()
17394 || m.implicit.is_some()
17395 || m.single_chunk.as_ref().is_some_and(|s| s.early_alloc)
17396 {
17397 1 // early
17398 } else if is_chunked || m.virtual_storage.is_some() {
17399 3 // incremental
17400 } else {
17401 2 // late
17402 };
17403 // `H5D__update_oh_info` (H5Dint.c:927-943): a variable-length
17404 // datatype with no explicit fill value forces ALLOC regardless of
17405 // the declared policy — its heap-reference encoding has no safe
17406 // all-zero "no fill" representation, so libhdf5 always writes the
17407 // (empty) fill value at allocation for such a dataset. `IFSET` is
17408 // the only declared policy this touches: an explicit `ALLOC` is
17409 // already what it forces, and upstream rejects `NEVER` for a
17410 // VL-typed dataset at `H5Dcreate` outright — this crate's
17411 // VL-typed datasets have no builder path to declare `NEVER` in the
17412 // first place, so that branch cannot be reached here.
17413 let is_vlen = matches!(
17414 m.datatype,
17415 DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
17416 );
17417 let fill_write_time = if is_vlen && m.fill_value.is_none() && m.fill_time == FILL_TIME_IFSET
17418 {
17419 FILL_TIME_ALLOC
17420 } else {
17421 m.fill_time
17422 };
17423 let fv = if let Some(ref bytes) = m.fill_value {
17424 // User-defined fill value (fill_defined = 2).
17425 FillValueMessage {
17426 alloc_time,
17427 fill_write_time,
17428 fill_defined: 2,
17429 fill_value: Some(bytes.clone()),
17430 }
17431 } else {
17432 // No fill value of the dataset's own (fill_defined = 1, the
17433 // implicit default zero fill) — `alloc_time` above already
17434 // carries the per-layout-class default (`H5P__set_layout`,
17435 // H5Pdcpl.c:1864-1877), so this branch must use it too instead
17436 // of `FillValueMessage::default()`'s hardcoded LATE: that was
17437 // wrong for a compact (EARLY) or virtual (INCR) dataset with no
17438 // fill value, only coincidentally right for contiguous.
17439 FillValueMessage {
17440 alloc_time,
17441 fill_write_time,
17442 fill_defined: 1, // default value (zeros)
17443 fill_value: None,
17444 }
17445 };
17446 // `H5O_MSG_FLAG_CONSTANT`, as `H5D__update_oh_info` appends it
17447 // (H5Dint.c:965) — the same flag the datatype message beside it
17448 // carries (H5Dint.c:961) and the old fill value below (H5Dint.c:981).
17449 // A dataset's fill value is fixed at creation: `H5Pset_fill_value` is
17450 // a creation property, so nothing can rewrite the message in place and
17451 // libhdf5 tells the header so.
17452 let fv_msg = fv.encode_for(format);
17453 let (flags, fv_msg) = self.share_message(owner, MSG_FILL_VALUE, MSG_FLAG_CONSTANT, fv_msg);
17454 header.add_message(MSG_FILL_VALUE, flags, fv_msg);
17455
17456 // The "fill value (old)" message (type 0x04) beside the new one, for a
17457 // user-defined fill value below the v1.8 bound. `H5D__update_oh_info`
17458 // (H5Dint.c:1024-1035) appends `H5O_FILL_ID` whenever `fill_prop->buf`
17459 // is set and `use_at_least_v18` — `H5F_LOW_BOUND(file) >= V18`, which
17460 // here is exactly a non-`Legacy` message format — is false, so that a
17461 // reader that predates the new message still finds the value. The body
17462 // is the size and the bytes and nothing else: no allocation time, no
17463 // write time, no defined flag (`H5O__fill_old_encode`, H5Ofill.c:512).
17464 if matches!(format, ObjectFormat::Legacy) {
17465 if let Some(ref bytes) = m.fill_value {
17466 let mut old = Vec::with_capacity(4 + bytes.len());
17467 old.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
17468 old.extend_from_slice(bytes);
17469 let (flags, old) =
17470 self.share_message(owner, MSG_FILL_VALUE_OLD, MSG_FLAG_CONSTANT, old);
17471 header.add_message(MSG_FILL_VALUE_OLD, flags, old);
17472 }
17473 }
17474
17475 // External Data Files message (type 0x07), before the layout message
17476 // and marked constant, exactly where `H5D__layout_oh_create` puts it.
17477 // It is what makes a reader route the dataset's I/O through the files
17478 // it names rather than through the undefined address the layout
17479 // message below still declares.
17480 if let Some(ref ext) = m.external {
17481 header.add_message(
17482 MSG_EXTERNAL_FILE_LIST,
17483 MSG_FLAG_CONSTANT,
17484 ext.message().encode(&self.ctx),
17485 );
17486 }
17487
17488 // Data Layout message (type 0x08)
17489 let layout = if let Some(ref chunked) = m.chunked {
17490 let mut layout_dims = chunked.chunk_dims.clone();
17491 layout_dims.push(m.datatype.element_size() as u64);
17492 DataLayoutMessage::chunked_v4_earray(
17493 m.layout_version,
17494 layout_dims,
17495 chunked.earray_params.clone(),
17496 chunked.ea_header_addr,
17497 )
17498 } else if let Some(ref fa) = m.fixed_array {
17499 let mut layout_dims = fa.chunk_dims.clone();
17500 layout_dims.push(m.datatype.element_size() as u64);
17501 DataLayoutMessage::chunked_v4_farray(
17502 m.layout_version,
17503 layout_dims,
17504 FixedArrayParams::default_params(),
17505 fa.fa_header_addr,
17506 )
17507 } else if let Some(ref bt2) = m.btree_v2 {
17508 let mut layout_dims = bt2.chunk_dims.clone();
17509 layout_dims.push(m.datatype.element_size() as u64);
17510 DataLayoutMessage::chunked_v4_btree_v2(
17511 m.layout_version,
17512 layout_dims,
17513 crate::format::messages::data_layout::Bt2Params {
17514 node_size: bt2.index.node_size,
17515 split_percent: bt2.index.split_percent,
17516 merge_percent: bt2.index.merge_percent,
17517 },
17518 bt2.bt2_header_addr,
17519 )
17520 } else if let Some(ref imp) = m.implicit {
17521 let mut layout_dims = imp.chunk_dims.clone();
17522 layout_dims.push(m.datatype.element_size() as u64);
17523 DataLayoutMessage::chunked_v4_implicit(m.layout_version, layout_dims, imp.data_addr)
17524 } else if let Some(ref sc) = m.single_chunk {
17525 let mut layout_dims = sc.chunk_dims.clone();
17526 layout_dims.push(m.datatype.element_size() as u64);
17527 if m.filter_pipeline.is_some() {
17528 DataLayoutMessage::chunked_v4_single_filtered(
17529 layout_dims,
17530 sc.data_addr,
17531 sc.nbytes,
17532 sc.filter_mask,
17533 )
17534 } else {
17535 DataLayoutMessage::chunked_v4_single(layout_dims, sc.data_addr)
17536 }
17537 } else if let Some(ref bt1) = m.btree_v1 {
17538 // The classic index: a version-3 layout message carrying the
17539 // address of the tree's root node, which is undefined until a
17540 // chunk is written.
17541 let mut layout_dims = bt1.chunk_dims.clone();
17542 layout_dims.push(m.datatype.element_size() as u64);
17543 DataLayoutMessage::chunked_v3_btree_v1(layout_dims, bt1.root_addr)
17544 } else if let Some(ref image) = m.compact {
17545 DataLayoutMessage::compact(image.clone())
17546 } else if let Some(ref virt) = m.virtual_storage {
17547 // Version 4 always: the virtual layout class did not exist before
17548 // it, so the default virtual layout is created at version 4 and
17549 // `H5Pset_virtual` raises any lower one to it (H5Pdcpl.c),
17550 // whatever the file's library-version bounds say — which is why a
17551 // v0-superblock file can still hold one.
17552 DataLayoutMessage::virtual_layout(4, virt.heap_addr, virt.heap_index)
17553 } else {
17554 DataLayoutMessage::contiguous(m.data_addr, m.data_size)
17555 };
17556 // `H5D__layout_oh_create` (H5Dlayout.c:530-536) marks the layout
17557 // message constant only where the storage it names is certain to be
17558 // there already: allocation time is early, the class is not compact,
17559 // no filter can change a chunk's size, and the dataspace holds at
17560 // least one element. Anything else leaves the address undefined at
17561 // creation and rewrites the message when the space is allocated, so
17562 // the flag would be a lie. `H5S_GET_EXTENT_NPOINTS` is zero for a
17563 // NULL dataspace and for any extent with a zero-length dimension.
17564 let npoints: u64 = if m.dataspace.is_null() {
17565 0
17566 } else {
17567 m.dataspace.dims.iter().product()
17568 };
17569 let filtered = m
17570 .filter_pipeline
17571 .as_ref()
17572 .is_some_and(|p| !p.filters.is_empty());
17573 let layout_flags = if alloc_time == 1 && m.compact.is_none() && !filtered && npoints != 0 {
17574 MSG_FLAG_CONSTANT
17575 } else {
17576 0x00
17577 };
17578 let layout_msg = layout.encode(&self.ctx);
17579 header.add_message(MSG_DATA_LAYOUT, layout_flags, layout_msg);
17580
17581 // Filter Pipeline message (type 0x0B) -- only if filters are
17582 // configured. `H5D__layout_oh_create` appends it with
17583 // `H5O_MSG_FLAG_CONSTANT` (H5Dlayout.c:462), as does the group
17584 // pipeline for dense links (H5Gobj.c:264): the pipeline is a creation
17585 // property, and every chunk already written was filtered through it,
17586 // so it can never be rewritten in place.
17587 if let Some(ref pipeline) = m.filter_pipeline {
17588 if !pipeline.filters.is_empty() {
17589 let (flags, filter_msg) = self.share_message(
17590 owner,
17591 MSG_FILTER_PIPELINE,
17592 MSG_FLAG_CONSTANT,
17593 pipeline.encode_for(format),
17594 );
17595 header.add_message(MSG_FILTER_PIPELINE, flags, filter_msg);
17596 }
17597 }
17598
17599 // A dataset has no links, so only attribute creation order can raise
17600 // its header past version 1 (`H5O__set_version`).
17601 let format = self.header_format(TrackOrder {
17602 links: CreationOrder::default(),
17603 attrs: m.track_attr_order,
17604 });
17605
17606 // Modification time, here and not earlier: `H5D__update_oh_info` makes
17607 // this the last message it writes (H5Dint.c:1022-1026), and the
17608 // attributes below it are added by `H5A` calls that come after the
17609 // dataset exists.
17610 touch_oh(&mut header, format, m.times, true);
17611
17612 // Attribute Info (type 0x15) + attribute messages (type 0x0C).
17613 self.emit_attributes(
17614 &mut header,
17615 AttrScope::Dataset(index),
17616 &attributes,
17617 m.track_attr_order,
17618 format,
17619 owner,
17620 );
17621
17622 self.emit_refcount(&mut header, rc, format);
17623
17624 Ok(header)
17625 }
17626
17627 /// Write the object header of every committed datatype something still
17628 /// reaches, recording the address each one landed at.
17629 ///
17630 /// Runs before the dataset and group headers because both name these
17631 /// addresses — a sharing dataset in its datatype message, the parent
17632 /// group in the link. One pass is enough: the header holds a datatype
17633 /// message and at most a reference count, neither of which depends on an
17634 /// address.
17635 fn write_committed_datatype_headers(&mut self) -> IoResult<()> {
17636 // The count is bound first: a lock guard in the `for` iterator
17637 // expression would live for the whole loop body, which locks the same
17638 // registry again.
17639 let count = self.committed_datatypes.lock().len();
17640 for i in 0..count {
17641 let rc = self.committed_datatype_refcount(i);
17642 if rc == 0 {
17643 // Its name's group was deleted and no dataset shares it, so
17644 // nothing in the file could reach the header.
17645 continue;
17646 }
17647 let format = self.committed_datatype_header_format();
17648 let encoded = self
17649 .build_committed_datatype_header(i, rc, format)
17650 .encode_for(format, rc)?;
17651 let addr = self
17652 .allocator
17653 .allocate(encoded.len() as u64, FreeSpaceClass::Metadata);
17654 self.handle.write_at(addr, &encoded)?;
17655 self.committed_datatypes.lock()[i].obj_header_addr = addr;
17656 }
17657 Ok(())
17658 }
17659
17660 /// The header format a committed datatype gets.
17661 ///
17662 /// `H5T__commit` creates the header from the datatype creation property
17663 /// list (H5Tcommit.c:468), which carries no link order and, by default, no
17664 /// attribute order — so the version is the file's floor exactly as
17665 /// `H5O__set_version` computes it, and a committed datatype in a classic
17666 /// file is a version-1 header like every other object in it.
17667 fn committed_datatype_header_format(&self) -> ObjectFormat {
17668 self.header_format(TrackOrder::default())
17669 }
17670
17671 /// Build the object header for a committed datatype: the type, and the
17672 /// reference count when more than one name reaches it.
17673 fn build_committed_datatype_header(
17674 &self,
17675 index: usize,
17676 rc: u32,
17677 format: ObjectFormat,
17678 ) -> ObjectHeader {
17679 let (datatype, times) = {
17680 let reg = self.committed_datatypes.lock();
17681 (reg[index].datatype.clone(), reg[index].times)
17682 };
17683 let mut header = ObjectHeader::new();
17684 // No attributes to emit, so nothing else would apply the file-wide
17685 // floor to this header. `store_msg_crt_idx` is a property of the file,
17686 // not of the object: every header created under it records creation
17687 // indices, a committed datatype's included.
17688 header.set_attribute_creation_order(self.header_attr_order(CreationOrder::default()));
17689 // `H5T__commit` marks the message constant and unshareable: this
17690 // header is where shared datatype bodies are read *from*, so its own
17691 // message must never become a pointer into the shared-message heap.
17692 header.add_message(
17693 MSG_DATATYPE,
17694 MSG_FLAG_CONSTANT | MSG_FLAG_DONTSHARE,
17695 datatype.encode_at(&self.ctx, self.encoding_libver()),
17696 );
17697 touch_oh(&mut header, format, times, false);
17698 // Through the same owner as every other object's count: a dataset
17699 // sharing this type raises it (`H5O__shared_link_adj`, H5Oshared.c:249)
17700 // just as a second name does, and where that count is recorded is the
17701 // header version's business, not the caller's.
17702 self.emit_refcount(&mut header, rc, format);
17703 header
17704 }
17705
17706 /// Build the object header for a subgroup.
17707 fn build_group_header(&self, group_idx: usize) -> IoResult<ObjectHeader> {
17708 let mut header = ObjectHeader::new();
17709
17710 // Link Info (type 0x02) + Group Info (type 0x0A) + the links
17711 // themselves, compact or dense.
17712 // Snapshot what the header needs, then drop the slot guard: the calls
17713 // below re-lock group slots (including this one).
17714 let (track_order, times, owner) = {
17715 let grp = self.grp(group_idx);
17716 let g = grp.lock();
17717 (
17718 g.track_order,
17719 g.times,
17720 ShareOwner::Header(g.obj_header_addr),
17721 )
17722 };
17723 let attributes = self.object_attributes(AttrScope::Group(group_idx))?;
17724 touch_oh(&mut header, self.header_format(track_order), times, false);
17725
17726 let links = self.group_links(LinkScope::Group(group_idx), track_order.links);
17727 self.emit_links(
17728 &mut header,
17729 LinkScope::Group(group_idx),
17730 &links,
17731 track_order.links,
17732 );
17733
17734 // Attribute Info (type 0x15) + attributes (type 0x0C) -- e.g. NeXus
17735 // `NX_class`.
17736 let format = self.header_format(track_order);
17737 self.emit_attributes(
17738 &mut header,
17739 AttrScope::Group(group_idx),
17740 &attributes,
17741 track_order.attrs,
17742 format,
17743 owner,
17744 );
17745
17746 self.emit_refcount(
17747 &mut header,
17748 self.object_link_count(HardLinkTarget::Group(group_idx)),
17749 format,
17750 );
17751
17752 Ok(header)
17753 }
17754
17755 fn build_root_group_header(&self) -> IoResult<ObjectHeader> {
17756 let mut header = ObjectHeader::new();
17757 touch_oh(
17758 &mut header,
17759 self.header_format(self.root_track_order),
17760 self.root_times,
17761 false,
17762 );
17763
17764 // Link Info (type 0x02) + Group Info (type 0x0A) + the links
17765 // themselves, compact or dense.
17766 let links = self.group_links(LinkScope::Root, self.root_track_order.links);
17767 self.emit_links(
17768 &mut header,
17769 LinkScope::Root,
17770 &links,
17771 self.root_track_order.links,
17772 );
17773
17774 // Root-level attributes
17775 let root_attributes = self.object_attributes(AttrScope::Root)?;
17776 self.emit_attributes(
17777 &mut header,
17778 AttrScope::Root,
17779 &root_attributes,
17780 self.root_track_order.attrs,
17781 self.header_format(self.root_track_order),
17782 ShareOwner::Header(self.root_group_addr.unwrap_or(0)),
17783 );
17784
17785 Ok(header)
17786 }
17787}
17788
17789impl Drop for Hdf5Writer {
17790 fn drop(&mut self) {
17791 if !self.closed {
17792 // Best-effort finalize on drop. Drop cannot return a Result, so a
17793 // failure here is otherwise invisible: it would leave a truncated
17794 // or unflushed file on disk while the caller believes the write
17795 // succeeded. Surface it on stderr instead of swallowing it.
17796 // Callers that need to handle the error must call
17797 // `H5File::close()` explicitly, which returns the Result.
17798 if let Err(e) = self.finalize(true) {
17799 eprintln!(
17800 "rust-hdf5: failed to finalize HDF5 file on drop: {e}. \
17801 The file may be incomplete or corrupt; call \
17802 H5File::close() to handle this error explicitly."
17803 );
17804 }
17805 }
17806 }
17807}
17808
17809#[cfg(test)]
17810mod tests {
17811 use super::*;
17812 use crate::format::messages::datatype::DatatypeMessage;
17813 use crate::io::reader::Hdf5Reader;
17814
17815 fn fixture(name: &str) -> std::path::PathBuf {
17816 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
17817 .join("tests/fixtures")
17818 .join(name)
17819 }
17820
17821 /// Copy a fixture so a test that appends does not edit the checked-in file.
17822 fn fixture_copy(name: &str, tag: &str) -> std::path::PathBuf {
17823 let path = temp_path(tag);
17824 std::fs::copy(fixture(name), &path).unwrap();
17825 path
17826 }
17827
17828 fn temp_path(tag: &str) -> std::path::PathBuf {
17829 use std::sync::atomic::{AtomicU64, Ordering};
17830 static COUNTER: AtomicU64 = AtomicU64::new(0);
17831 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
17832 std::env::temp_dir().join(format!(
17833 "rust_hdf5_w_{}_{}_{}.h5",
17834 std::process::id(),
17835 tag,
17836 n
17837 ))
17838 }
17839
17840 /// A group past the link phase change keeps its links in a fractal heap
17841 /// with a v2 B-tree name index. The reopen that rewrites that group's
17842 /// header lays a fresh pair out, so both blocks the old header named must
17843 /// come back to the allocator — every block of the heap, and the index
17844 /// header with its nodes.
17845 ///
17846 /// Asserted on the free list rather than on the file size: a reopen does
17847 /// not yet carry dense links forward, so the rewritten group's links (and
17848 /// the datasets they name) are dropped, and the file size that follows
17849 /// says more about that than about this.
17850 #[test]
17851 fn a_reopen_frees_the_dense_link_storage_its_rewrite_supersedes() {
17852 let path = temp_path("dense_link_reclaim");
17853
17854 let writer = Hdf5Writer::create(&path).unwrap();
17855 writer.create_group("/", "run").unwrap();
17856 for i in 0..12 {
17857 writer
17858 .create_dataset(&format!("run/d{i:02}"), DatatypeMessage::i32_type(), &[2])
17859 .unwrap();
17860 }
17861 writer.close().unwrap();
17862
17863 let writer = Hdf5Writer::open_append(&path).unwrap();
17864 let gidx = (0..writer.group_count())
17865 .find(|&g| writer.grp(g).lock().name == "/run")
17866 .expect("the reopen registered the group");
17867 let linfo = writer
17868 .superseded_dense
17869 .lock()
17870 .as_ref()
17871 .and_then(|s| s.links.get(&LinkScope::Group(gidx)).cloned())
17872 .expect("the reopen recorded the group's dense link storage");
17873 assert_ne!(linfo.fractal_heap_address, UNDEF_ADDR);
17874 assert_ne!(linfo.name_btree_address, UNDEF_ADDR);
17875
17876 writer
17877 .release_superseded_dense_links(LinkScope::Group(gidx))
17878 .unwrap();
17879 let freed = writer.allocator.free_blocks();
17880 let covers = |addr: u64| {
17881 freed
17882 .iter()
17883 .any(|&(a, len)| addr >= a && addr < a.saturating_add(len))
17884 };
17885 assert!(covers(linfo.fractal_heap_address), "heap header: {freed:?}");
17886 assert!(covers(linfo.name_btree_address), "name index: {freed:?}");
17887
17888 // And exactly once: the entry is gone, so the finalize that follows
17889 // cannot hand the same blocks back a second time.
17890 assert!(writer
17891 .superseded_dense
17892 .lock()
17893 .as_ref()
17894 .is_none_or(|s| s.links.is_empty()));
17895 writer
17896 .release_superseded_dense_links(LinkScope::Group(gidx))
17897 .unwrap();
17898 assert_eq!(writer.allocator.free_blocks(), freed);
17899
17900 writer.close().unwrap();
17901 std::fs::remove_file(&path).ok();
17902 }
17903
17904 /// The rewrite frees what it supersedes even when the replacement is not
17905 /// dense at all. An attribute set that drops back under `max_compact`
17906 /// goes into the object header, so nothing names the old heap any more —
17907 /// and a free driven by "the new set needs dense storage" would never
17908 /// reach this one.
17909 #[test]
17910 fn a_rewrite_that_drops_out_of_dense_storage_still_frees_it() {
17911 let path = temp_path("dense_attr_to_compact");
17912 let numeric = |name: &str| {
17913 AttributeMessage::scalar_numeric(
17914 name,
17915 DatatypeMessage::i32_type(),
17916 7i32.to_le_bytes().to_vec(),
17917 )
17918 };
17919
17920 let writer = Hdf5Writer::create(&path).unwrap();
17921 for i in 0..12 {
17922 writer
17923 .add_root_attribute(numeric(&format!("a{i:02}")))
17924 .unwrap();
17925 }
17926 writer.close().unwrap();
17927
17928 let writer = Hdf5Writer::open_append(&path).unwrap();
17929 let ainfo = writer
17930 .superseded_dense
17931 .lock()
17932 .as_ref()
17933 .and_then(|s| s.attrs.get(&AttrScope::Root).cloned())
17934 .expect("the reopen recorded the root's dense attribute storage");
17935 for i in 0..10 {
17936 writer
17937 .evict_attr(AttrTarget::Root, &format!("a{i:02}"))
17938 .unwrap();
17939 }
17940 assert!(!writer.attributes_need_dense(&writer.root_attributes.lock(), ObjectFormat::Modern));
17941
17942 writer.prepare_dense_attributes(&[]).unwrap();
17943 let freed = writer.allocator.free_blocks();
17944 let covers = |addr: u64| {
17945 freed
17946 .iter()
17947 .any(|&(a, len)| addr >= a && addr < a.saturating_add(len))
17948 };
17949 assert!(covers(ainfo.fractal_heap_address), "heap header: {freed:?}");
17950 assert!(covers(ainfo.name_btree_address), "name index: {freed:?}");
17951 assert!(writer
17952 .superseded_dense
17953 .lock()
17954 .as_ref()
17955 .is_none_or(|s| s.attrs.is_empty()));
17956
17957 writer.close().unwrap();
17958 std::fs::remove_file(&path).ok();
17959 }
17960
17961 /// Deleting a reopened object supersedes its dense storage as surely as
17962 /// rewriting one does: nothing in the finalized file names the heap, so
17963 /// the delete owner frees it through the same entry.
17964 #[test]
17965 fn deleting_a_reopened_group_frees_its_dense_attribute_storage() {
17966 let path = temp_path("dense_attr_delete");
17967 let numeric = |name: &str| {
17968 AttributeMessage::scalar_numeric(
17969 name,
17970 DatatypeMessage::i32_type(),
17971 7i32.to_le_bytes().to_vec(),
17972 )
17973 };
17974
17975 let writer = Hdf5Writer::create(&path).unwrap();
17976 writer.create_group("/", "run").unwrap();
17977 for i in 0..12 {
17978 writer
17979 .set_attribute(AttrTarget::Group("/run"), numeric(&format!("a{i:02}")))
17980 .unwrap();
17981 }
17982 writer.close().unwrap();
17983
17984 let writer = Hdf5Writer::open_append(&path).unwrap();
17985 let gidx = (0..writer.group_count())
17986 .find(|&g| writer.grp(g).lock().name == "/run")
17987 .expect("the reopen registered the group");
17988 let ainfo = writer
17989 .superseded_dense
17990 .lock()
17991 .as_ref()
17992 .and_then(|s| s.attrs.get(&AttrScope::Group(gidx)).cloned())
17993 .expect("the reopen recorded the group's dense attribute storage");
17994
17995 writer.delete_group("/run").unwrap();
17996 let freed = writer.allocator.free_blocks();
17997 let covers = |addr: u64| {
17998 freed
17999 .iter()
18000 .any(|&(a, len)| addr >= a && addr < a.saturating_add(len))
18001 };
18002 assert!(covers(ainfo.fractal_heap_address), "heap header: {freed:?}");
18003 assert!(covers(ainfo.name_btree_address), "name index: {freed:?}");
18004 assert!(writer
18005 .superseded_dense
18006 .lock()
18007 .as_ref()
18008 .is_none_or(|s| s.attrs.is_empty()));
18009
18010 writer.close().unwrap();
18011 std::fs::remove_file(&path).ok();
18012 }
18013
18014 /// The charset rule is one owner shared by every vlen string writer:
18015 /// appends into an ASCII-declared dataset reject non-ASCII strings the
18016 /// same way the slice writer does, and a dataset whose elements are not
18017 /// vlen references at all is refused instead of overwritten with them.
18018 #[test]
18019 fn append_vlen_strings_checks_the_datatype_and_charset() {
18020 let path = temp_path("append_vlen_charset");
18021
18022 let writer = Hdf5Writer::create(&path).unwrap();
18023 let idx = writer
18024 .create_appendable_vlen_string_dataset("d", 4, None)
18025 .unwrap();
18026 writer.ds(idx).lock().datatype = DatatypeMessage::vlen_string_ascii();
18027 let err = writer
18028 .append_vlen_strings(idx, &["ok", "안녕"])
18029 .unwrap_err();
18030 assert!(
18031 err.to_string().contains("is not ASCII"),
18032 "unexpected error: {err}"
18033 );
18034 writer.append_vlen_strings(idx, &["ok", "fine"]).unwrap();
18035
18036 let nums = writer
18037 .create_chunked_dataset("n", DatatypeMessage::i32_type(), &[0], &[u64::MAX], &[4])
18038 .unwrap();
18039 let err = writer.append_vlen_strings(nums, &["x"]).unwrap_err();
18040 assert!(
18041 err.to_string()
18042 .contains("only for variable-length string datasets"),
18043 "unexpected error: {err}"
18044 );
18045
18046 writer.close().unwrap();
18047 std::fs::remove_file(&path).ok();
18048 }
18049
18050 /// `create_chunked_dataset` builds an extensible-array index unconditionally
18051 /// (the caller — the high-level dataset API — is the one that decides when
18052 /// two-or-more unlimited dimensions should go to a v2 B-tree instead), so
18053 /// its own guard is the last line of defense against a shape that index
18054 /// can't represent at all.
18055 #[test]
18056 fn create_chunked_dataset_rejects_two_unlimited_dimensions() {
18057 let path = temp_path("earray_two_unlimited");
18058 let writer = Hdf5Writer::create(&path).unwrap();
18059 let err = writer
18060 .create_chunked_dataset(
18061 "d",
18062 DatatypeMessage::i32_type(),
18063 &[4, 4],
18064 &[u64::MAX, u64::MAX],
18065 &[2, 2],
18066 )
18067 .unwrap_err();
18068 assert!(err.to_string().contains("at most one unlimited"), "{err}");
18069 writer.close().unwrap();
18070 std::fs::remove_file(&path).ok();
18071 }
18072
18073 /// Every creator must enter through `begin_create`; the four that used
18074 /// to bypass it could push a second dataset under an existing name and
18075 /// emit an invalid file with two same-named links.
18076 #[test]
18077 fn every_creator_rejects_an_existing_dataset_name() {
18078 let path = temp_path("create_gate");
18079
18080 let writer = Hdf5Writer::create(&path).unwrap();
18081 writer
18082 .create_dataset("d", DatatypeMessage::i32_type(), &[2])
18083 .unwrap();
18084
18085 let attempts: [(&str, IoResult<usize>); 4] = [
18086 (
18087 "vlen_string",
18088 writer.create_vlen_string_dataset("d", &["x"], 1),
18089 ),
18090 ("vlen_bytes", writer.create_vlen_bytes_dataset("d", &[b"x"])),
18091 (
18092 "vlen_string_compressed",
18093 writer.create_vlen_string_dataset_compressed(
18094 "d",
18095 &["x"],
18096 1,
18097 FilterPipeline::deflate(6),
18098 ),
18099 ),
18100 (
18101 "chunked_with_pipeline",
18102 writer.create_chunked_dataset_with_pipeline(
18103 "d",
18104 DatatypeMessage::i32_type(),
18105 &[0],
18106 &[u64::MAX],
18107 &[4],
18108 FilterPipeline::deflate(6),
18109 ),
18110 ),
18111 ];
18112 for (which, res) in attempts {
18113 match res {
18114 Ok(_) => panic!("{which} accepted a duplicate name"),
18115 Err(e) => assert!(
18116 e.to_string().contains("already exists"),
18117 "{which}: unexpected error: {e}"
18118 ),
18119 }
18120 }
18121
18122 writer.close().unwrap();
18123 std::fs::remove_file(&path).ok();
18124 }
18125
18126 /// Every creator and every kind of name meet at `ensure_name_free`.
18127 ///
18128 /// The gate's whole value is that it is one list: a creator must be
18129 /// blind neither to a name kind it does not itself make nor to one added
18130 /// after it. This crosses the two — six names, one of each kind the
18131 /// writer can put in a group, against every creator — so a creator that
18132 /// grows its own check, or a name kind that stops being on the list,
18133 /// fails here rather than in a file holding two links of one name.
18134 #[test]
18135 fn every_creator_refuses_every_kind_of_taken_name() {
18136 let path = temp_path("create_gate_matrix");
18137 let writer = Hdf5Writer::create(&path).unwrap();
18138
18139 let i32t = || DatatypeMessage::i32_type();
18140 writer.create_dataset("d", i32t(), &[2]).unwrap();
18141 writer.create_compact_dataset("c", i32t(), &[2]).unwrap();
18142 writer.create_group("/", "g").unwrap();
18143 writer.commit_datatype("t", i32t()).unwrap();
18144 writer.create_hard_link("/", "h", "d").unwrap();
18145 writer
18146 .create_symbolic_link(
18147 "/",
18148 "s",
18149 LinkTarget::Soft {
18150 target: "/d".into(),
18151 },
18152 )
18153 .unwrap();
18154 writer
18155 .create_symbolic_link(
18156 "/",
18157 "e",
18158 LinkTarget::External {
18159 file: "other.h5".into(),
18160 path: "/x".into(),
18161 },
18162 )
18163 .unwrap();
18164
18165 for taken in ["d", "c", "g", "t", "h", "s", "e"] {
18166 let attempts: [(&str, IoResult<()>); 8] = [
18167 (
18168 "dataset",
18169 writer.create_dataset(taken, i32t(), &[2]).map(|_| ()),
18170 ),
18171 (
18172 "compact",
18173 writer
18174 .create_compact_dataset(taken, i32t(), &[2])
18175 .map(|_| ()),
18176 ),
18177 (
18178 "chunked",
18179 writer
18180 .create_chunked_dataset(taken, i32t(), &[0], &[u64::MAX], &[4])
18181 .map(|_| ()),
18182 ),
18183 (
18184 "vlen_string",
18185 writer
18186 .create_vlen_string_dataset(taken, &["x"], 1)
18187 .map(|_| ()),
18188 ),
18189 (
18190 "committed datatype",
18191 writer.commit_datatype(taken, i32t()).map(|_| ()),
18192 ),
18193 ("group", writer.create_group("/", taken).map(|_| ())),
18194 ("hard link", writer.create_hard_link("/", taken, "d")),
18195 (
18196 "soft link",
18197 writer.create_symbolic_link(
18198 "/",
18199 taken,
18200 LinkTarget::Soft {
18201 target: "/d".into(),
18202 },
18203 ),
18204 ),
18205 ];
18206 for (which, res) in attempts {
18207 match res {
18208 Ok(()) => panic!("{which} accepted the taken name '{taken}'"),
18209 Err(e) => assert!(
18210 e.to_string().contains("already exists"),
18211 "{which} on '{taken}': unexpected error: {e}"
18212 ),
18213 }
18214 }
18215 }
18216
18217 writer.close().unwrap();
18218 std::fs::remove_file(&path).ok();
18219 }
18220
18221 /// The `H5T_VLEN` length field counts base elements, so an image that is
18222 /// not a whole number of them has no length that reads back as what was
18223 /// handed over; it is refused at the call rather than stored truncated.
18224 #[test]
18225 fn vlen_sequence_refuses_a_partial_element() {
18226 let path = temp_path("vlen_partial_element");
18227
18228 let writer = Hdf5Writer::create(&path).unwrap();
18229 let err = writer
18230 .create_vlen_sequence_dataset("d", DatatypeMessage::i32_type(), &[&[1u8, 2, 3, 4, 5]])
18231 .unwrap_err()
18232 .to_string();
18233 assert!(err.contains("5 bytes"), "unexpected error: {err}");
18234 assert!(err.contains("4-byte elements"), "unexpected error: {err}");
18235
18236 // The refusal is the length rule alone: the same base takes a whole
18237 // number of elements, and an empty sequence is a legal one.
18238 writer
18239 .create_vlen_sequence_dataset(
18240 "d",
18241 DatatypeMessage::i32_type(),
18242 &[&[1u8, 2, 3, 4], &[][..]],
18243 )
18244 .unwrap();
18245
18246 writer.close().unwrap();
18247 std::fs::remove_file(&path).ok();
18248 }
18249
18250 /// A corrupt file can declare a zero-length chunk dimension; the
18251 /// superseded-reference read must reject it the way `write_slice` does,
18252 /// not divide by it.
18253 #[test]
18254 fn vlen_slice_rejects_a_zero_chunk_dimension() {
18255 let path = temp_path("vlen_slice_zero_chunk");
18256
18257 let writer = Hdf5Writer::create(&path).unwrap();
18258 let idx = writer
18259 .create_appendable_vlen_string_dataset("d", 2, None)
18260 .unwrap();
18261 writer.append_vlen_strings(idx, &["a", "b"]).unwrap();
18262 writer.ds(idx).lock().chunked.as_mut().unwrap().chunk_dims[0] = 0;
18263 let err = writer.write_vlen_strings_slice(idx, 0, &["x"]).unwrap_err();
18264 assert!(
18265 err.to_string().contains("zero-length dimension"),
18266 "unexpected error: {err}"
18267 );
18268
18269 writer.ds(idx).lock().chunked.as_mut().unwrap().chunk_dims[0] = 2;
18270 writer.close().unwrap();
18271 std::fs::remove_file(&path).ok();
18272 }
18273
18274 /// A libhdf5-written collection can be 100% full — no free-space marker,
18275 /// content exactly the declared size. When a stale reference names an
18276 /// index that is not there, nothing is removed, and the collection must
18277 /// be left alone: re-encoding it at its declared size cannot fit the
18278 /// free-space marker and would fail the whole update.
18279 #[test]
18280 fn release_leaves_a_full_collection_it_removed_nothing_from() {
18281 use crate::format::global_heap::encode_vlen_reference;
18282
18283 let path = temp_path("release_full_gcol");
18284 let writer = Hdf5Writer::create(&path).unwrap();
18285
18286 // Hand-built full collection: 16-byte header + one 16+8-byte object,
18287 // declared size exactly 40, no free-space marker.
18288 let mut img = Vec::new();
18289 img.extend_from_slice(b"GCOL");
18290 img.push(1);
18291 img.extend_from_slice(&[0u8; 3]);
18292 img.extend_from_slice(&40u64.to_le_bytes());
18293 img.extend_from_slice(&1u16.to_le_bytes()); // object index 1
18294 img.extend_from_slice(&1u16.to_le_bytes()); // ref_count
18295 img.extend_from_slice(&0u32.to_le_bytes()); // reserved
18296 img.extend_from_slice(&8u64.to_le_bytes()); // data size
18297 img.extend_from_slice(b"deadbeef");
18298 assert_eq!(img.len(), 40);
18299 let addr = writer
18300 .allocator
18301 .allocate(img.len() as u64, FreeSpaceClass::RawData);
18302 writer.handle.write_at(addr, &img).unwrap();
18303
18304 // The superseded reference names index 2, which the collection does
18305 // not hold — a no-op removal.
18306 let refs = encode_vlen_reference(3, addr, 2, &writer.ctx);
18307 writer.release_vlen_references(&refs).unwrap();
18308 assert_eq!(writer.handle.read_at(addr, 40).unwrap(), img);
18309
18310 writer.close().unwrap();
18311 std::fs::remove_file(&path).ok();
18312 }
18313
18314 /// The CWFS second pass (`H5F_cwfs_find_free_heap`): an object too big
18315 /// for the listed collection's remaining free space extends the
18316 /// collection in place — the file allocation grows off the end of the
18317 /// file (`H5MF_try_extend`) and the collection's declared size and
18318 /// free-space marker grow with it (`H5HG_extend`) — instead of opening
18319 /// a second collection.
18320 #[test]
18321 fn an_oversized_vlen_insert_extends_the_listed_collection() {
18322 use crate::format::global_heap::GlobalHeapCollection;
18323
18324 let path = temp_path("cwfs_extend_tail");
18325 let writer = Hdf5Writer::create(&path).unwrap();
18326 // A small object opens a minimum-size (4096) listed collection —
18327 // the file's last allocation, so the extension grows the file end.
18328 let p1 = writer.insert_vlen_objects(&[b"hello".as_slice()]).unwrap();
18329 let big = vec![0x41u8; 5000]; // more than the ~4 KiB remaining
18330 let p2 = writer.insert_vlen_objects(&[big.as_slice()]).unwrap();
18331 assert_eq!(
18332 p2[0].0, p1[0].0,
18333 "the big object opened a second collection"
18334 );
18335
18336 // The block on disk is one grown collection holding both objects.
18337 let img = writer.handle.read_at_most(p1[0].0, 65536).unwrap();
18338 let (gcol, csize) = GlobalHeapCollection::decode(&img, &writer.ctx).unwrap();
18339 assert!(csize > 4096, "declared size did not grow: {csize}");
18340 assert_eq!(gcol.objects.len(), 2);
18341 assert_eq!(gcol.objects[1].data, big);
18342
18343 writer.close().unwrap();
18344 let bytes = std::fs::read(&path).unwrap();
18345 assert_eq!(
18346 bytes.windows(4).filter(|w| *w == b"GCOL").count(),
18347 1,
18348 "a second collection signature is in the file"
18349 );
18350 std::fs::remove_file(&path).ok();
18351 }
18352
18353 /// The non-tail counterpart: the collection is pinned away from the end
18354 /// of the file, but a released block starts right after it, so the
18355 /// extension consumes the front of that block (`H5MF_try_extend`'s
18356 /// free-section path) and the remainder stays reusable.
18357 #[test]
18358 fn extension_consumes_a_freed_block_after_the_collection() {
18359 use crate::format::global_heap::GlobalHeapCollection;
18360
18361 let path = temp_path("cwfs_extend_freed");
18362 let writer = Hdf5Writer::create(&path).unwrap();
18363 let p1 = writer.insert_vlen_objects(&[b"hello".as_slice()]).unwrap();
18364 let addr = p1[0].0;
18365 // Land a block right after the collection, pin the file end past
18366 // it, then release it: extension must use the released space.
18367 let spacer = writer.allocator.allocate(8192, FreeSpaceClass::RawData);
18368 assert_eq!(spacer, addr + 4096, "spacer not adjacent; layout changed");
18369 writer.allocator.allocate(8, FreeSpaceClass::RawData);
18370 writer.allocator.free(spacer, 8192, FreeSpaceClass::RawData);
18371
18372 let big = vec![0x42u8; 5000];
18373 let p2 = writer.insert_vlen_objects(&[big.as_slice()]).unwrap();
18374 assert_eq!(p2[0].0, addr, "the big object opened a second collection");
18375
18376 let img = writer.handle.read_at_most(addr, 65536).unwrap();
18377 let (gcol, csize) = GlobalHeapCollection::decode(&img, &writer.ctx).unwrap();
18378 assert_eq!(csize, 8192, "grew by max(size, shortfall) = 4096");
18379 assert_eq!(gcol.objects.len(), 2);
18380
18381 // The remainder of the released block is still allocatable.
18382 assert_eq!(
18383 writer.allocator.allocate(4096, FreeSpaceClass::RawData),
18384 addr + 8192,
18385 "the freed block's tail was lost"
18386 );
18387 writer.close().unwrap();
18388 std::fs::remove_file(&path).ok();
18389 }
18390
18391 /// Issue #10: a reopen-and-replace loop on a vlen string must not grow
18392 /// the file. The superseded heap objects are freed *before* the
18393 /// replacement is allocated, so each session reuses the block it just
18394 /// released even though the free list starts empty on reopen. The old
18395 /// free-after-alloc order failed this by one collection per session.
18396 #[test]
18397 fn vlen_replace_across_reopen_keeps_the_file_flat() {
18398 let path = temp_path("vlen_reopen_flat");
18399 let payload_a = "a".repeat(64 * 1024);
18400 let payload_b = "b".repeat(64 * 1024);
18401
18402 let writer = Hdf5Writer::create(&path).unwrap();
18403 writer
18404 .create_vlen_string_dataset("notes", &["initial"], 1)
18405 .unwrap();
18406 writer.close().unwrap();
18407
18408 let mut sizes = Vec::new();
18409 for i in 0..8 {
18410 let writer = Hdf5Writer::open_append(&path).unwrap();
18411 let payload = if i % 2 == 0 { &payload_a } else { &payload_b };
18412 writer
18413 .write_vlen_strings_slice(0, 0, &[payload.as_str()])
18414 .unwrap();
18415 writer.close().unwrap();
18416 sizes.push(std::fs::metadata(&path).unwrap().len());
18417 }
18418 // The first replacement grows the file once (the initial collection
18419 // cannot hold 64 KiB); every later equal-size replacement must land
18420 // in the block its own session just freed.
18421 assert_eq!(&sizes[1..], &vec![sizes[0]; 7][..], "sizes: {sizes:?}");
18422
18423 // The reused blocks still form a valid file holding the last value.
18424 let mut reader = Hdf5Reader::open(&path).unwrap();
18425 assert_eq!(
18426 reader.read_vlen_strings("notes").unwrap(),
18427 vec![payload_b.clone()]
18428 );
18429
18430 std::fs::remove_file(&path).ok();
18431 }
18432
18433 /// Replacing a vlen string attribute must release the superseded
18434 /// global-heap collection *before* the replacement's collection is
18435 /// allocated, so a reopen-replace loop lands each new value in the block
18436 /// it just freed instead of growing the file by one collection per
18437 /// session — the attribute counterpart of
18438 /// [`vlen_replace_across_reopen_keeps_the_file_flat`].
18439 #[test]
18440 fn vlen_attr_replace_across_reopen_keeps_the_file_flat() {
18441 let path = temp_path("vlen_attr_reopen_flat");
18442 let payload_a = "a".repeat(8 * 1024);
18443 let payload_b = "b".repeat(8 * 1024);
18444
18445 let writer = Hdf5Writer::create(&path).unwrap();
18446 writer
18447 .set_vlen_string_attribute(AttrTarget::Root, "note", &payload_a)
18448 .unwrap();
18449 writer.close().unwrap();
18450
18451 let mut sizes = Vec::new();
18452 for i in 0..8 {
18453 let writer = Hdf5Writer::open_append(&path).unwrap();
18454 let payload = if i % 2 == 0 { &payload_b } else { &payload_a };
18455 writer
18456 .set_vlen_string_attribute(AttrTarget::Root, "note", payload)
18457 .unwrap();
18458 writer.close().unwrap();
18459 sizes.push(std::fs::metadata(&path).unwrap().len());
18460 }
18461 assert_eq!(&sizes[1..], &vec![sizes[0]; 7][..], "sizes: {sizes:?}");
18462
18463 // The reused blocks still hold the last value.
18464 let reader = Hdf5Reader::open(&path).unwrap();
18465 let attr = reader.root_attr("note").unwrap().clone();
18466 let mut reader = reader;
18467 assert_eq!(reader.attr_string_value(&attr).unwrap(), payload_a);
18468
18469 std::fs::remove_file(&path).ok();
18470 }
18471
18472 /// A numeric attribute replacing a vlen one goes through the same list
18473 /// owner, so the superseded collection is released even though the new
18474 /// value holds no heap reference: a later same-size vlen attribute must
18475 /// land in the freed block, making the file exactly as large as one that
18476 /// never stored the replaced value.
18477 #[test]
18478 fn numeric_replacing_a_vlen_attr_releases_its_collection() {
18479 let payload = "x".repeat(8 * 1024);
18480 let numeric = || {
18481 AttributeMessage::scalar_numeric(
18482 "x",
18483 DatatypeMessage::i32_type(),
18484 7i32.to_le_bytes().to_vec(),
18485 )
18486 };
18487
18488 let path_a = temp_path("vlen_attr_cross_a");
18489 let writer = Hdf5Writer::create(&path_a).unwrap();
18490 writer
18491 .set_vlen_string_attribute(AttrTarget::Root, "x", &payload)
18492 .unwrap();
18493 writer.add_root_attribute(numeric()).unwrap();
18494 writer
18495 .set_vlen_string_attribute(AttrTarget::Root, "y", &payload)
18496 .unwrap();
18497 writer.close().unwrap();
18498
18499 // The same end state written without the replaced vlen value.
18500 let path_b = temp_path("vlen_attr_cross_b");
18501 let writer = Hdf5Writer::create(&path_b).unwrap();
18502 writer.add_root_attribute(numeric()).unwrap();
18503 writer
18504 .set_vlen_string_attribute(AttrTarget::Root, "y", &payload)
18505 .unwrap();
18506 writer.close().unwrap();
18507
18508 assert_eq!(
18509 std::fs::metadata(&path_a).unwrap().len(),
18510 std::fs::metadata(&path_b).unwrap().len()
18511 );
18512
18513 let reader = Hdf5Reader::open(&path_a).unwrap();
18514 let y = reader.root_attr("y").unwrap().clone();
18515 let mut reader = reader;
18516 assert_eq!(reader.attr_string_value(&y).unwrap(), payload);
18517
18518 std::fs::remove_file(&path_a).ok();
18519 std::fs::remove_file(&path_b).ok();
18520 }
18521
18522 /// Reopen/write/close cycles must not leak the object-header blocks
18523 /// finalize rewrites: the reopened root header, the reopened group
18524 /// header, and the modified chunked dataset's header are each freed
18525 /// before their replacements are allocated. The chunk rewrite itself is
18526 /// in place (unfiltered chunks never move), so a leak of any header
18527 /// block shows up as monotonic growth here.
18528 #[test]
18529 fn reopen_cycles_reuse_superseded_header_blocks() {
18530 let path = temp_path("header_reuse");
18531 {
18532 let writer = Hdf5Writer::create(&path).unwrap();
18533 writer.create_group("/", "g").unwrap();
18534 let idx = writer
18535 .create_chunked_dataset(
18536 "g/data",
18537 DatatypeMessage::i32_type(),
18538 &[4],
18539 &[u64::MAX],
18540 &[4],
18541 )
18542 .unwrap();
18543 let seed: Vec<u8> = [1i32, 2, 3, 4]
18544 .iter()
18545 .flat_map(|v| v.to_le_bytes())
18546 .collect();
18547 writer.write_chunk(idx, 0, &seed).unwrap();
18548 writer.close().unwrap();
18549 }
18550
18551 let mut sizes = Vec::new();
18552 for i in 0..6i32 {
18553 let writer = Hdf5Writer::open_append(&path).unwrap();
18554 let data: Vec<u8> = [i; 4].iter().flat_map(|v| v.to_le_bytes()).collect();
18555 writer.write_chunk(0, 0, &data).unwrap();
18556 writer.close().unwrap();
18557 sizes.push(std::fs::metadata(&path).unwrap().len());
18558 }
18559 assert_eq!(&sizes[1..], &vec![sizes[0]; 5][..], "sizes: {sizes:?}");
18560
18561 // The reused header blocks still form a valid file.
18562 let mut reader = Hdf5Reader::open(&path).unwrap();
18563 let raw = reader.read_dataset_raw("g/data").unwrap();
18564 let values: Vec<i32> = raw
18565 .chunks(4)
18566 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
18567 .collect();
18568 assert_eq!(values, vec![5, 5, 5, 5]);
18569
18570 std::fs::remove_file(&path).ok();
18571 }
18572
18573 #[test]
18574 fn create_empty_file() {
18575 let path = temp_path("empty");
18576
18577 let writer = Hdf5Writer::create(&path).unwrap();
18578 writer.close().unwrap();
18579
18580 // Verify we can read it back
18581 let reader = Hdf5Reader::open(&path).unwrap();
18582 assert!(reader.dataset_names().is_empty());
18583
18584 std::fs::remove_file(&path).ok();
18585 }
18586
18587 #[test]
18588 fn create_single_dataset() {
18589 let path = temp_path("single");
18590
18591 let writer = Hdf5Writer::create(&path).unwrap();
18592 let idx = writer
18593 .create_dataset("data", DatatypeMessage::f64_type(), &[4])
18594 .unwrap();
18595 let values: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0];
18596 let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
18597 writer.write_dataset_raw(idx, &raw).unwrap();
18598 writer.close().unwrap();
18599
18600 // Read back
18601 let mut reader = Hdf5Reader::open(&path).unwrap();
18602 assert_eq!(reader.dataset_names(), vec!["data"]);
18603 assert_eq!(reader.dataset_shape("data").unwrap(), vec![4]);
18604 let readback = reader.read_dataset_raw("data").unwrap();
18605 assert_eq!(readback, raw);
18606
18607 std::fs::remove_file(&path).ok();
18608 }
18609
18610 #[test]
18611 fn create_multiple_datasets() {
18612 let path = temp_path("multi");
18613
18614 let writer = Hdf5Writer::create(&path).unwrap();
18615
18616 let idx0 = writer
18617 .create_dataset("ints", DatatypeMessage::i32_type(), &[3])
18618 .unwrap();
18619 let i_data: Vec<u8> = [10i32, 20, 30]
18620 .iter()
18621 .flat_map(|v| v.to_le_bytes())
18622 .collect();
18623 writer.write_dataset_raw(idx0, &i_data).unwrap();
18624
18625 let idx1 = writer
18626 .create_dataset("floats", DatatypeMessage::f32_type(), &[2, 2])
18627 .unwrap();
18628 let f_data: Vec<u8> = [1.0f32, 2.0, 3.0, 4.0]
18629 .iter()
18630 .flat_map(|v| v.to_le_bytes())
18631 .collect();
18632 writer.write_dataset_raw(idx1, &f_data).unwrap();
18633
18634 writer.close().unwrap();
18635
18636 let mut reader = Hdf5Reader::open(&path).unwrap();
18637 let names = reader.dataset_names();
18638 assert!(names.contains(&"ints"));
18639 assert!(names.contains(&"floats"));
18640 assert_eq!(reader.dataset_shape("ints").unwrap(), vec![3]);
18641 assert_eq!(reader.dataset_shape("floats").unwrap(), vec![2, 2]);
18642 assert_eq!(reader.read_dataset_raw("ints").unwrap(), i_data);
18643 assert_eq!(reader.read_dataset_raw("floats").unwrap(), f_data);
18644
18645 std::fs::remove_file(&path).ok();
18646 }
18647
18648 #[test]
18649 fn data_size_mismatch() {
18650 let path = temp_path("mismatch");
18651
18652 let writer = Hdf5Writer::create(&path).unwrap();
18653 let idx = writer
18654 .create_dataset("x", DatatypeMessage::u8_type(), &[4])
18655 .unwrap();
18656 let err = writer.write_dataset_raw(idx, &[1, 2, 3]); // 3 bytes instead of 4
18657 assert!(err.is_err());
18658
18659 std::fs::remove_file(&path).ok();
18660 }
18661
18662 #[test]
18663 fn create_chunked_dataset_simple() {
18664 let path = temp_path("chunked_simple");
18665
18666 let writer = Hdf5Writer::create(&path).unwrap();
18667 let idx = writer
18668 .create_chunked_dataset(
18669 "data",
18670 DatatypeMessage::f64_type(),
18671 &[0, 4], // start empty
18672 &[u64::MAX, 4], // unlimited first dim
18673 &[1, 4], // chunk = [1, 4]
18674 )
18675 .unwrap();
18676
18677 // Write 3 frames (chunks)
18678 for frame in 0..3u64 {
18679 let values: Vec<f64> = (0..4).map(|i| (frame * 4 + i) as f64).collect();
18680 let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
18681 writer.write_chunk(idx, frame, &raw).unwrap();
18682 }
18683
18684 // Extend dimensions
18685 writer.extend_dataset(idx, &[3, 4]).unwrap();
18686
18687 writer.close().unwrap();
18688
18689 // Read back
18690 let mut reader = Hdf5Reader::open(&path).unwrap();
18691 assert_eq!(reader.dataset_names(), vec!["data"]);
18692 assert_eq!(reader.dataset_shape("data").unwrap(), vec![3, 4]);
18693
18694 let raw = reader.read_dataset_raw("data").unwrap();
18695 let values: Vec<f64> = raw
18696 .chunks(8)
18697 .map(|chunk| f64::from_le_bytes(chunk.try_into().unwrap()))
18698 .collect();
18699 assert_eq!(values.len(), 12);
18700 for (i, val) in values.iter().enumerate() {
18701 assert_eq!(*val, i as f64);
18702 }
18703
18704 std::fs::remove_file(&path).ok();
18705 }
18706
18707 #[test]
18708 fn chunked_dataset_many_frames() {
18709 let path = temp_path("chunked_many");
18710
18711 let writer = Hdf5Writer::create(&path).unwrap();
18712 let idx = writer
18713 .create_chunked_dataset(
18714 "frames",
18715 DatatypeMessage::i32_type(),
18716 &[0, 2],
18717 &[u64::MAX, 2],
18718 &[1, 2],
18719 )
18720 .unwrap();
18721
18722 let n_frames = 10u64;
18723 for frame in 0..n_frames {
18724 let values = [(frame * 2) as i32, (frame * 2 + 1) as i32];
18725 let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
18726 writer.write_chunk(idx, frame, &raw).unwrap();
18727 }
18728
18729 writer.extend_dataset(idx, &[n_frames, 2]).unwrap();
18730 writer.close().unwrap();
18731
18732 // Read back
18733 let mut reader = Hdf5Reader::open(&path).unwrap();
18734 assert_eq!(reader.dataset_shape("frames").unwrap(), vec![10, 2]);
18735
18736 let raw = reader.read_dataset_raw("frames").unwrap();
18737 let values: Vec<i32> = raw
18738 .chunks(4)
18739 .map(|chunk| i32::from_le_bytes(chunk.try_into().unwrap()))
18740 .collect();
18741 assert_eq!(values.len(), 20);
18742 for (i, val) in values.iter().enumerate() {
18743 assert_eq!(*val, i as i32);
18744 }
18745
18746 std::fs::remove_file(&path).ok();
18747 }
18748
18749 #[test]
18750 fn create_fixed_array_dataset_roundtrip() {
18751 let path = temp_path("fixed_array");
18752
18753 let writer = Hdf5Writer::create(&path).unwrap();
18754 let idx = writer
18755 .create_fixed_array_dataset(
18756 "grid",
18757 DatatypeMessage::i32_type(),
18758 &[4, 6], // 4x6 grid
18759 &[2, 3], // chunk = 2x3
18760 )
18761 .unwrap();
18762
18763 // Write all chunks: 2x2 = 4 chunks
18764 // chunk (0,0): rows 0-1, cols 0-2
18765 let c00: Vec<u8> = [0i32, 1, 2, 6, 7, 8]
18766 .iter()
18767 .flat_map(|v| v.to_le_bytes())
18768 .collect();
18769 writer.write_chunk_fixed_array(idx, &[0, 0], &c00).unwrap();
18770
18771 // chunk (0,1): rows 0-1, cols 3-5
18772 let c01: Vec<u8> = [3i32, 4, 5, 9, 10, 11]
18773 .iter()
18774 .flat_map(|v| v.to_le_bytes())
18775 .collect();
18776 writer.write_chunk_fixed_array(idx, &[0, 1], &c01).unwrap();
18777
18778 // chunk (1,0): rows 2-3, cols 0-2
18779 let c10: Vec<u8> = [12i32, 13, 14, 18, 19, 20]
18780 .iter()
18781 .flat_map(|v| v.to_le_bytes())
18782 .collect();
18783 writer.write_chunk_fixed_array(idx, &[1, 0], &c10).unwrap();
18784
18785 // chunk (1,1): rows 2-3, cols 3-5
18786 let c11: Vec<u8> = [15i32, 16, 17, 21, 22, 23]
18787 .iter()
18788 .flat_map(|v| v.to_le_bytes())
18789 .collect();
18790 writer.write_chunk_fixed_array(idx, &[1, 1], &c11).unwrap();
18791
18792 writer.close().unwrap();
18793
18794 // Read back
18795 let mut reader = Hdf5Reader::open(&path).unwrap();
18796 assert_eq!(reader.dataset_names(), vec!["grid"]);
18797 assert_eq!(reader.dataset_shape("grid").unwrap(), vec![4, 6]);
18798
18799 let raw = reader.read_dataset_raw("grid").unwrap();
18800 let values: Vec<i32> = raw
18801 .chunks(4)
18802 .map(|chunk| i32::from_le_bytes(chunk.try_into().unwrap()))
18803 .collect();
18804 assert_eq!(values.len(), 24);
18805 for (i, val) in values.iter().enumerate() {
18806 assert_eq!(*val, i as i32);
18807 }
18808
18809 std::fs::remove_file(&path).ok();
18810 }
18811
18812 #[test]
18813 fn fixed_array_paged_dblk_disk_size() {
18814 let ctx = FormatContext {
18815 sizeof_addr: 8,
18816 sizeof_size: 8,
18817 };
18818 // 1024 elements per page (bits=10). 3000 chunks => 3 pages.
18819 let hdr = FixedArrayHeader::new_for_chunks(&ctx, 3000);
18820 assert!(hdr.is_paged());
18821 assert_eq!(hdr.npages(), 3);
18822 // prefix: 4+1+1+8 + bitmap(1) + cksum(4) = 19
18823 // elements: 3000 * 8 = 24000 ; per-page cksum: 3 * 4 = 12
18824 assert_eq!(fixed_array_dblk_disk_size(&ctx, &hdr), 19 + 24000 + 12);
18825
18826 // Non-paged: 1000 elements. prefix(14) + 1000*8 + cksum(4).
18827 let small = FixedArrayHeader::new_for_chunks(&ctx, 1000);
18828 assert!(!small.is_paged());
18829 assert_eq!(fixed_array_dblk_disk_size(&ctx, &small), 14 + 8000 + 4);
18830 }
18831
18832 #[test]
18833 fn fixed_array_paged_encode_matches_reader_layout() {
18834 let ctx = FormatContext {
18835 sizeof_addr: 8,
18836 sizeof_size: 8,
18837 };
18838 let mut hdr = FixedArrayHeader::new_for_chunks(&ctx, 2500);
18839 hdr.data_blk_addr = 0x9000;
18840 let npages = hdr.npages() as usize; // ceil(2500/1024) = 3
18841
18842 let mut dblk = FixedArrayDataBlock::new_unfiltered(0x1000, 2500);
18843 for (i, e) in dblk.elements.iter_mut().enumerate() {
18844 *e = 0x10000 + (i as u64) * 0x100;
18845 }
18846
18847 let encoded = encode_fixed_array_dblk(&ctx, &hdr, &dblk);
18848 assert_eq!(encoded.len() as u64, fixed_array_dblk_disk_size(&ctx, &hdr));
18849
18850 // Decode the prefix and pages exactly as the reader does.
18851 let prefix = FixedArrayPagedPrefix::decode(&encoded, &ctx, npages as u64).unwrap();
18852 assert_eq!(prefix.header_addr, 0x1000);
18853 for p in 0..npages {
18854 assert!(prefix.page_initialized(p), "page {p} should be initialized");
18855 }
18856
18857 let dblk_page_nelmts = hdr.dblk_page_nelmts() as usize;
18858 let page_stride = dblk_page_nelmts * 8 + 4;
18859 let mut recovered = Vec::new();
18860 for p in 0..npages {
18861 let page_nelmts = if p + 1 == npages {
18862 2500 - p * dblk_page_nelmts
18863 } else {
18864 dblk_page_nelmts
18865 };
18866 let off = prefix.prefix_size + p * page_stride;
18867 let page_buf = &encoded[off..];
18868 let addrs = crate::format::chunk_index::fixed_array::decode_unfiltered_page(
18869 page_buf,
18870 &ctx,
18871 page_nelmts,
18872 )
18873 .unwrap();
18874 recovered.extend(addrs);
18875 }
18876 assert_eq!(recovered, dblk.elements);
18877 }
18878
18879 #[test]
18880 fn fixed_array_paged_decode_roundtrip_with_uninitialized_page() {
18881 let ctx = FormatContext {
18882 sizeof_addr: 8,
18883 sizeof_size: 8,
18884 };
18885 let hdr = FixedArrayHeader::new_for_chunks(&ctx, 2500);
18886 let npages = hdr.npages() as usize; // 3
18887 let page = hdr.dblk_page_nelmts() as usize; // 1024
18888
18889 // Populate pages 0 and 2; leave page 1 entirely undefined so its
18890 // bitmap bit stays clear on encode.
18891 let mut dblk = FixedArrayDataBlock::new_unfiltered(0x1000, 2500);
18892 for i in (0..page).chain(2 * page..2500) {
18893 dblk.elements[i] = 0x10000 + (i as u64) * 0x100;
18894 }
18895
18896 let mut encoded = encode_fixed_array_dblk(&ctx, &hdr, &dblk);
18897 let prefix = FixedArrayPagedPrefix::decode(&encoded, &ctx, npages as u64).unwrap();
18898 assert!(prefix.page_initialized(0));
18899 assert!(!prefix.page_initialized(1));
18900 assert!(prefix.page_initialized(2));
18901
18902 // Corrupt the uninitialized page's bytes the way libhdf5 leaves
18903 // them: arbitrary, no valid checksum. Decode must not look at it.
18904 let page_stride = page * 8 + 4;
18905 let p1 = prefix.prefix_size + page_stride;
18906 for b in &mut encoded[p1..p1 + page_stride] {
18907 *b = 0x5A;
18908 }
18909
18910 let decoded = decode_fixed_array_dblk(&ctx, &hdr, &encoded, 0).unwrap();
18911 assert_eq!(decoded.elements, dblk.elements);
18912 assert_eq!(decoded.header_addr, 0x1000);
18913 }
18914
18915 #[test]
18916 fn fixed_array_paged_decode_filtered_roundtrip() {
18917 let ctx = FormatContext {
18918 sizeof_addr: 8,
18919 sizeof_size: 8,
18920 };
18921 let chunk_size_len = 4usize;
18922 let hdr = FixedArrayHeader::new_for_filtered_chunks(&ctx, 1500, chunk_size_len as u8);
18923 assert!(hdr.is_paged());
18924
18925 let mut dblk = FixedArrayDataBlock::new_filtered(0x2000, 1500);
18926 for (i, e) in dblk.filtered_elements.iter_mut().enumerate() {
18927 e.address = 0x8000 + (i as u64) * 0x40;
18928 e.chunk_size = 100 + i as u64;
18929 e.filter_mask = (i % 3) as u32;
18930 }
18931
18932 let encoded = encode_fixed_array_dblk(&ctx, &hdr, &dblk);
18933 assert_eq!(encoded.len() as u64, fixed_array_dblk_disk_size(&ctx, &hdr));
18934 let decoded = decode_fixed_array_dblk(&ctx, &hdr, &encoded, chunk_size_len).unwrap();
18935 assert_eq!(decoded.filtered_elements, dblk.filtered_elements);
18936 assert_eq!(decoded.client_id, FA_CLIENT_FILT_CHUNK);
18937 }
18938
18939 #[test]
18940 fn create_fixed_array_paged_dataset_roundtrip() {
18941 let path = temp_path("fixed_array_paged");
18942
18943 // 1D dataset of 3000 elements, chunk size 1 => 3000 chunks.
18944 // 3000 > 1024 (one page) => the FA data block must be paged.
18945 let n: usize = 3000;
18946 let writer = Hdf5Writer::create(&path).unwrap();
18947 let idx = writer
18948 .create_fixed_array_dataset("paged", DatatypeMessage::i32_type(), &[n as u64], &[1])
18949 .unwrap();
18950
18951 for i in 0..n {
18952 let v = (i as i32).to_le_bytes();
18953 writer
18954 .write_chunk_fixed_array(idx, &[i as u64], &v)
18955 .unwrap();
18956 }
18957 writer.close().unwrap();
18958
18959 let mut reader = Hdf5Reader::open(&path).unwrap();
18960 assert_eq!(reader.dataset_shape("paged").unwrap(), vec![n as u64]);
18961 let raw = reader.read_dataset_raw("paged").unwrap();
18962 let values: Vec<i32> = raw
18963 .chunks(4)
18964 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
18965 .collect();
18966 assert_eq!(values.len(), n);
18967 for (i, v) in values.iter().enumerate() {
18968 assert_eq!(*v, i as i32, "element {i}");
18969 }
18970
18971 std::fs::remove_file(&path).ok();
18972 }
18973
18974 #[cfg(feature = "deflate")]
18975 #[test]
18976 fn create_filtered_fixed_array_dataset_roundtrip() {
18977 // Small compressed fixed-shape chunked dataset: flat filtered FA.
18978 let path = temp_path("fixed_array_filt");
18979
18980 let writer = Hdf5Writer::create(&path).unwrap();
18981 let idx = writer
18982 .create_fixed_array_dataset_with_pipeline(
18983 "grid",
18984 DatatypeMessage::i32_type(),
18985 &[4, 6], // 4x6 grid
18986 &[2, 3], // chunk = 2x3 => 2x2 = 4 chunks
18987 FilterPipeline::deflate(6),
18988 )
18989 .unwrap();
18990
18991 let c00: Vec<u8> = [0i32, 1, 2, 6, 7, 8]
18992 .iter()
18993 .flat_map(|v| v.to_le_bytes())
18994 .collect();
18995 writer.write_chunk_fixed_array(idx, &[0, 0], &c00).unwrap();
18996 let c01: Vec<u8> = [3i32, 4, 5, 9, 10, 11]
18997 .iter()
18998 .flat_map(|v| v.to_le_bytes())
18999 .collect();
19000 writer.write_chunk_fixed_array(idx, &[0, 1], &c01).unwrap();
19001 let c10: Vec<u8> = [12i32, 13, 14, 18, 19, 20]
19002 .iter()
19003 .flat_map(|v| v.to_le_bytes())
19004 .collect();
19005 writer.write_chunk_fixed_array(idx, &[1, 0], &c10).unwrap();
19006 let c11: Vec<u8> = [15i32, 16, 17, 21, 22, 23]
19007 .iter()
19008 .flat_map(|v| v.to_le_bytes())
19009 .collect();
19010 writer.write_chunk_fixed_array(idx, &[1, 1], &c11).unwrap();
19011
19012 writer.close().unwrap();
19013
19014 let mut reader = Hdf5Reader::open(&path).unwrap();
19015 assert_eq!(reader.dataset_shape("grid").unwrap(), vec![4, 6]);
19016 let raw = reader.read_dataset_raw("grid").unwrap();
19017 let values: Vec<i32> = raw
19018 .chunks(4)
19019 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19020 .collect();
19021 assert_eq!(values.len(), 24);
19022 for (i, v) in values.iter().enumerate() {
19023 assert_eq!(*v, i as i32, "element {i}");
19024 }
19025
19026 std::fs::remove_file(&path).ok();
19027 }
19028
19029 #[cfg(feature = "deflate")]
19030 #[test]
19031 fn create_filtered_fixed_array_paged_dataset_roundtrip() {
19032 // Large compressed fixed-shape chunked dataset (>1024 chunks): the
19033 // filtered FA data block must be paged.
19034 let path = temp_path("fixed_array_filt_paged");
19035
19036 let n: usize = 3000;
19037 let writer = Hdf5Writer::create(&path).unwrap();
19038 let idx = writer
19039 .create_fixed_array_dataset_with_pipeline(
19040 "paged",
19041 DatatypeMessage::i32_type(),
19042 &[n as u64],
19043 &[1],
19044 FilterPipeline::deflate(6),
19045 )
19046 .unwrap();
19047
19048 for i in 0..n {
19049 let v = (i as i32).to_le_bytes();
19050 writer
19051 .write_chunk_fixed_array(idx, &[i as u64], &v)
19052 .unwrap();
19053 }
19054 writer.close().unwrap();
19055
19056 let mut reader = Hdf5Reader::open(&path).unwrap();
19057 assert_eq!(reader.dataset_shape("paged").unwrap(), vec![n as u64]);
19058 let raw = reader.read_dataset_raw("paged").unwrap();
19059 let values: Vec<i32> = raw
19060 .chunks(4)
19061 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19062 .collect();
19063 assert_eq!(values.len(), n);
19064 for (i, v) in values.iter().enumerate() {
19065 assert_eq!(*v, i as i32, "element {i}");
19066 }
19067
19068 std::fs::remove_file(&path).ok();
19069 }
19070
19071 #[test]
19072 fn filtered_fixed_array_dblk_disk_size_and_encode() {
19073 // Cross-check filtered FA data-block sizing against the encoded length,
19074 // for both flat and paged layouts.
19075 let ctx = FormatContext {
19076 sizeof_addr: 8,
19077 sizeof_size: 8,
19078 };
19079 let csl = 3u8; // chunk_size_len
19080 let elem_size = 8 + csl as usize + 4; // addr + size + filter_mask
19081
19082 // Flat: 100 chunks. prefix(14) + 100*elem_size + cksum(4).
19083 let mut flat = FixedArrayHeader::new_for_filtered_chunks(&ctx, 100, csl);
19084 flat.data_blk_addr = 0x4000;
19085 assert!(!flat.is_paged());
19086 assert_eq!(
19087 fixed_array_dblk_disk_size(&ctx, &flat),
19088 (14 + 100 * elem_size + 4) as u64
19089 );
19090 let flat_dblk = FixedArrayDataBlock::new_filtered(0x1000, 100);
19091 assert_eq!(
19092 encode_fixed_array_dblk(&ctx, &flat, &flat_dblk).len() as u64,
19093 fixed_array_dblk_disk_size(&ctx, &flat)
19094 );
19095
19096 // Paged: 2500 chunks => 3 pages. prefix(4+1+1+8+1+4=19)
19097 // + 2500*elem_size + 3*cksum(4).
19098 let mut paged = FixedArrayHeader::new_for_filtered_chunks(&ctx, 2500, csl);
19099 paged.data_blk_addr = 0x9000;
19100 assert!(paged.is_paged());
19101 assert_eq!(paged.npages(), 3);
19102 assert_eq!(
19103 fixed_array_dblk_disk_size(&ctx, &paged),
19104 (19 + 2500 * elem_size + 12) as u64
19105 );
19106 let mut paged_dblk = FixedArrayDataBlock::new_filtered(0x1000, 2500);
19107 for (i, e) in paged_dblk.filtered_elements.iter_mut().enumerate() {
19108 e.address = 0x10000 + (i as u64) * 0x100;
19109 e.chunk_size = (i % 200) as u64;
19110 }
19111 let encoded = encode_fixed_array_dblk(&ctx, &paged, &paged_dblk);
19112 assert_eq!(
19113 encoded.len() as u64,
19114 fixed_array_dblk_disk_size(&ctx, &paged)
19115 );
19116
19117 // Decode the paged prefix + pages as the reader does.
19118 let npages = paged.npages() as usize;
19119 let prefix = FixedArrayPagedPrefix::decode(&encoded, &ctx, npages as u64).unwrap();
19120 for p in 0..npages {
19121 assert!(prefix.page_initialized(p), "page {p}");
19122 }
19123 let dblk_page_nelmts = paged.dblk_page_nelmts() as usize;
19124 let page_stride = dblk_page_nelmts * elem_size + 4;
19125 let mut recovered = Vec::new();
19126 for p in 0..npages {
19127 let page_nelmts = if p + 1 == npages {
19128 2500 - p * dblk_page_nelmts
19129 } else {
19130 dblk_page_nelmts
19131 };
19132 let off = prefix.prefix_size + p * page_stride;
19133 let elems = crate::format::chunk_index::fixed_array::decode_filtered_page(
19134 &encoded[off..],
19135 &ctx,
19136 page_nelmts,
19137 csl as usize,
19138 )
19139 .unwrap();
19140 recovered.extend(elems);
19141 }
19142 assert_eq!(recovered, paged_dblk.filtered_elements);
19143 }
19144
19145 #[test]
19146 fn create_btree_v2_dataset_roundtrip() {
19147 let path = temp_path("btree_v2");
19148
19149 let writer = Hdf5Writer::create(&path).unwrap();
19150 let idx = writer
19151 .create_btree_v2_dataset(
19152 "data",
19153 DatatypeMessage::f64_type(),
19154 &[0, 0], // start empty
19155 &[u64::MAX, u64::MAX], // both dims unlimited
19156 &[2, 3], // chunk = 2x3
19157 )
19158 .unwrap();
19159
19160 // Write chunks for a 4x6 dataset
19161 // chunk (0,0)
19162 let c00: Vec<u8> = [0.0f64, 1.0, 2.0, 6.0, 7.0, 8.0]
19163 .iter()
19164 .flat_map(|v| v.to_le_bytes())
19165 .collect();
19166 writer.write_chunk_btree_v2(idx, &[0, 0], &c00).unwrap();
19167
19168 // chunk (0,1)
19169 let c01: Vec<u8> = [3.0f64, 4.0, 5.0, 9.0, 10.0, 11.0]
19170 .iter()
19171 .flat_map(|v| v.to_le_bytes())
19172 .collect();
19173 writer.write_chunk_btree_v2(idx, &[0, 1], &c01).unwrap();
19174
19175 // chunk (1,0)
19176 let c10: Vec<u8> = [12.0f64, 13.0, 14.0, 18.0, 19.0, 20.0]
19177 .iter()
19178 .flat_map(|v| v.to_le_bytes())
19179 .collect();
19180 writer.write_chunk_btree_v2(idx, &[1, 0], &c10).unwrap();
19181
19182 // chunk (1,1)
19183 let c11: Vec<u8> = [15.0f64, 16.0, 17.0, 21.0, 22.0, 23.0]
19184 .iter()
19185 .flat_map(|v| v.to_le_bytes())
19186 .collect();
19187 writer.write_chunk_btree_v2(idx, &[1, 1], &c11).unwrap();
19188
19189 writer.extend_dataset(idx, &[4, 6]).unwrap();
19190 writer.close().unwrap();
19191
19192 // Read back
19193 let mut reader = Hdf5Reader::open(&path).unwrap();
19194 assert_eq!(reader.dataset_names(), vec!["data"]);
19195 assert_eq!(reader.dataset_shape("data").unwrap(), vec![4, 6]);
19196
19197 let raw = reader.read_dataset_raw("data").unwrap();
19198 let values: Vec<f64> = raw
19199 .chunks(8)
19200 .map(|chunk| f64::from_le_bytes(chunk.try_into().unwrap()))
19201 .collect();
19202 assert_eq!(values.len(), 24);
19203 for (i, val) in values.iter().enumerate() {
19204 assert_eq!(*val, i as f64);
19205 }
19206
19207 std::fs::remove_file(&path).ok();
19208 }
19209
19210 /// Bytes one chunk of [`btree_v2_flush_probe`]'s dataset occupies — an
19211 /// f64 element, so the allocator's alignment neither pads nor merges it and
19212 /// the file's growth is exactly the bytes asked for.
19213 const BT2_PROBE_CHUNK: u64 = 8;
19214
19215 /// Write chunks of a 1x1-chunked 2-D BT2 dataset, flushing at each batch
19216 /// boundary, and report `(node addresses, file length)` after every flush.
19217 /// Chunks are addressed down column 0 so the record count — and hence the
19218 /// tree's shape — grows one record at a time.
19219 fn btree_v2_flush_probe(path: &std::path::Path, batches: &[u64]) -> Vec<(Vec<u64>, u64)> {
19220 let writer = Hdf5Writer::create(path).unwrap();
19221 let idx = writer
19222 .create_btree_v2_dataset(
19223 "data",
19224 DatatypeMessage::f64_type(),
19225 &[0, 0],
19226 &[u64::MAX, u64::MAX],
19227 &[1, 1],
19228 )
19229 .unwrap();
19230 let mut written = 0u64;
19231 let mut out = Vec::new();
19232 for &upto in batches {
19233 while written < upto {
19234 writer
19235 .write_chunk_btree_v2(idx, &[written, 0], &(written as f64).to_le_bytes())
19236 .unwrap();
19237 written += 1;
19238 }
19239 writer.flush_dataset(idx).unwrap();
19240 let addrs = writer
19241 .ds(idx)
19242 .lock()
19243 .btree_v2
19244 .as_ref()
19245 .unwrap()
19246 .node_addrs
19247 .clone();
19248 out.push((addrs, std::fs::metadata(path).unwrap().len()));
19249 }
19250 writer.extend_dataset(idx, &[written.max(1), 1]).unwrap();
19251 writer.close().unwrap();
19252 out
19253 }
19254
19255 /// The node pool tracks the tree in both directions. Dropping records is
19256 /// what a removal path would do — [`Bt2ChunkIndex`] has none today, so the
19257 /// test drops them itself — and the flush that follows must hand the blocks
19258 /// its smaller tree no longer needs back to the allocator instead of
19259 /// leaving them recorded and unreachable.
19260 #[test]
19261 fn a_btree_v2_flush_frees_the_node_blocks_its_tree_gave_up() {
19262 use crate::format::chunk_index::btree_v2::BT2_NODE_SIZE;
19263
19264 let path = temp_path("bt2_node_shrink");
19265 let writer = Hdf5Writer::create(&path).unwrap();
19266 let idx = writer
19267 .create_btree_v2_dataset(
19268 "data",
19269 DatatypeMessage::f64_type(),
19270 &[0, 0],
19271 &[u64::MAX, u64::MAX],
19272 &[1, 1],
19273 )
19274 .unwrap();
19275 // 85 records is one past a leaf, so the tree is two leaves and a root.
19276 for i in 0..85u64 {
19277 writer
19278 .write_chunk_btree_v2(idx, &[i, 0], &(i as f64).to_le_bytes())
19279 .unwrap();
19280 }
19281 writer.flush_dataset(idx).unwrap();
19282 let grown = writer
19283 .ds(idx)
19284 .lock()
19285 .btree_v2
19286 .as_ref()
19287 .unwrap()
19288 .node_addrs
19289 .clone();
19290 assert_eq!(grown.len(), 3, "expected two leaves and a root");
19291
19292 // Back to 84 records: one leaf, so two of the three blocks are surplus.
19293 writer
19294 .ds(idx)
19295 .lock()
19296 .btree_v2
19297 .as_mut()
19298 .unwrap()
19299 .index
19300 .records
19301 .truncate(84);
19302 writer.flush_dataset(idx).unwrap();
19303 let shrunk = writer
19304 .ds(idx)
19305 .lock()
19306 .btree_v2
19307 .as_ref()
19308 .unwrap()
19309 .node_addrs
19310 .clone();
19311 assert_eq!(
19312 shrunk,
19313 grown[..1],
19314 "the pool still records the surplus blocks"
19315 );
19316
19317 // The surplus went back to the allocator, not on the floor: the next
19318 // node-sized allocation lands inside the region the two blocks covered.
19319 let reused = writer
19320 .allocator
19321 .allocate(BT2_NODE_SIZE as u64, FreeSpaceClass::Metadata);
19322 assert!(
19323 (grown[1]..grown[1] + 2 * BT2_NODE_SIZE as u64).contains(&reused),
19324 "a node block allocated at {reused:#x}, outside the freed \
19325 [{:#x}, {:#x}) the flush gave up",
19326 grown[1],
19327 grown[1] + 2 * BT2_NODE_SIZE as u64
19328 );
19329
19330 writer.extend_dataset(idx, &[85, 1]).unwrap();
19331 writer.close().unwrap();
19332 std::fs::remove_file(&path).ok();
19333 }
19334
19335 /// A v2 B-tree whose header declares a non-default node size — libhdf5
19336 /// built with a different `H5D_BT2_NODE_SIZE`, or any other writer —
19337 /// reopens for append: the reconstruction adopts the header's node_size,
19338 /// split and merge instead of refusing everything but 2048, and the next
19339 /// flush re-serializes at that size (upstream allocates every node at
19340 /// `hdr->node_size`, H5B2leaf.c / H5B2internal.c).
19341 #[test]
19342 fn a_btree_v2_with_a_foreign_node_size_reopens_and_grows() {
19343 let path = temp_path("bt2_foreign_node_size");
19344 {
19345 let writer = Hdf5Writer::create(&path).unwrap();
19346 let idx = writer
19347 .create_btree_v2_dataset(
19348 "data",
19349 DatatypeMessage::f64_type(),
19350 &[0, 0],
19351 &[u64::MAX, u64::MAX],
19352 &[1, 1],
19353 )
19354 .unwrap();
19355 // Act as a foreign writer: 512-byte nodes, non-default tuning.
19356 // record_size 24 => a 512-byte leaf holds 20 records, so 85
19357 // records make a depth-1 tree of 512-byte blocks.
19358 {
19359 let ds = writer.ds(idx);
19360 let mut m = ds.lock();
19361 let index = &mut m.btree_v2.as_mut().unwrap().index;
19362 index.node_size = 512;
19363 index.split_percent = 90;
19364 index.merge_percent = 30;
19365 }
19366 for i in 0..85u64 {
19367 writer
19368 .write_chunk_btree_v2(idx, &[i, 0], &(i as f64).to_le_bytes())
19369 .unwrap();
19370 }
19371 writer.extend_dataset(idx, &[85, 1]).unwrap();
19372 writer.close().unwrap();
19373 }
19374 {
19375 let writer = Hdf5Writer::open_append(&path).unwrap();
19376 let idx = writer.dataset_index("data").unwrap();
19377 {
19378 let ds = writer.ds(idx);
19379 let m = ds.lock();
19380 let index = &m.btree_v2.as_ref().unwrap().index;
19381 assert_eq!(index.node_size, 512, "header node_size not adopted");
19382 assert_eq!(index.split_percent, 90);
19383 assert_eq!(index.merge_percent, 30);
19384 assert_eq!(index.records.len(), 85, "records not walked back");
19385 }
19386 for i in 85..115u64 {
19387 writer
19388 .write_chunk_btree_v2(idx, &[i, 0], &(i as f64).to_le_bytes())
19389 .unwrap();
19390 }
19391 writer.extend_dataset(idx, &[115, 1]).unwrap();
19392 writer.close().unwrap();
19393 }
19394
19395 let mut reader = Hdf5Reader::open(&path).unwrap();
19396 let raw = reader.read_dataset_raw("data").unwrap();
19397 let values: Vec<f64> = raw
19398 .chunks(8)
19399 .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
19400 .collect();
19401 assert_eq!(values.len(), 115);
19402 for (i, v) in values.iter().enumerate() {
19403 assert_eq!(*v, i as f64, "element {i}");
19404 }
19405 std::fs::remove_file(&path).ok();
19406 }
19407
19408 /// A node's record count falls as well as rises: the tree's first leaf goes
19409 /// from a full 84 records to 42 when 85 records force it to split. The node
19410 /// image is padded to the whole block so re-serializing overwrites the
19411 /// block, not a prefix of it — otherwise that leaf keeps the tail of its
19412 /// 84-record self, stale records sitting in a live node block.
19413 #[test]
19414 fn a_shrinking_btree_v2_node_leaves_no_stale_records_behind() {
19415 use crate::format::chunk_index::btree_v2::{Bt2ChunkIndex, BT2_NODE_SIZE};
19416
19417 let path = temp_path("bt2_node_blocks");
19418 let probe = btree_v2_flush_probe(&path, &[84, 85]);
19419 let node0 = probe.last().unwrap().0[0];
19420
19421 // What the first leaf holds once the tree has split.
19422 let ctx = FormatContext {
19423 sizeof_addr: 8,
19424 sizeof_size: 8,
19425 };
19426 let mut index = Bt2ChunkIndex::new_unfiltered(2);
19427 for i in 0..85u64 {
19428 index.insert(vec![i, 0], 0);
19429 }
19430 let tree = index.build_tree(&ctx);
19431 assert!(
19432 tree.nodes[0].num_records < 84,
19433 "this test needs the first leaf to shrink, got {}",
19434 tree.nodes[0].num_records
19435 );
19436 // signature(4) + version(1) + type(1) + records + checksum(4)
19437 let used = 10 + tree.nodes[0].num_records as usize * tree.record_size as usize;
19438
19439 let bytes = std::fs::read(&path).unwrap();
19440 let block = &bytes[node0 as usize..node0 as usize + BT2_NODE_SIZE as usize];
19441 assert!(
19442 block[used..].iter().all(|&b| b == 0),
19443 "leaf block at {node0:#x} still holds {} bytes of its previous, larger image",
19444 block[used..].iter().rposition(|&b| b != 0).unwrap_or(0) + 1
19445 );
19446 std::fs::remove_file(&path).ok();
19447 }
19448
19449 /// The node pool is the single owner of the tree's block addresses: a flush
19450 /// reuses every block already in it and allocates only the shortfall. So
19451 /// re-flushing an unchanged index must cost nothing, and a flush that grows
19452 /// the tree must cost exactly the blocks it added — anything more means a
19453 /// block was stranded.
19454 #[test]
19455 fn a_btree_v2_flush_allocates_only_the_node_blocks_it_adds() {
19456 use crate::format::chunk_index::btree_v2::BT2_NODE_SIZE;
19457
19458 let path = temp_path("bt2_pool_growth");
19459 // Re-flush at 84 (still one leaf), then cross into a three-node depth-1
19460 // tree, then keep growing.
19461 let batches = [84u64, 84, 85, 200, 200];
19462 let probe = btree_v2_flush_probe(&path, &batches);
19463 for i in 1..probe.len() {
19464 let (prev_addrs, prev_len) = &probe[i - 1];
19465 let (addrs, len) = &probe[i];
19466 assert!(
19467 addrs.starts_with(prev_addrs),
19468 "flush {i} moved a node block instead of reusing it"
19469 );
19470 let new_blocks = (addrs.len() - prev_addrs.len()) as u64 * BT2_NODE_SIZE as u64;
19471 let new_chunks = (batches[i] - batches[i - 1]) * BT2_PROBE_CHUNK;
19472 assert_eq!(
19473 len - prev_len,
19474 new_blocks + new_chunks,
19475 "flush {i} grew the file by more than the blocks it added"
19476 );
19477 }
19478 // The unchanged re-flushes must be free.
19479 assert_eq!(probe[1].1, probe[0].1);
19480 assert_eq!(probe[4].1, probe[3].1);
19481 std::fs::remove_file(&path).ok();
19482 }
19483
19484 #[cfg(feature = "parallel")]
19485 #[test]
19486 fn parallel_batch_write_roundtrip() {
19487 let path = temp_path("parallel_batch");
19488
19489 let writer = Hdf5Writer::create(&path).unwrap();
19490 let idx = writer
19491 .create_chunked_dataset(
19492 "data",
19493 DatatypeMessage::i32_type(),
19494 &[0, 4],
19495 &[u64::MAX, 4],
19496 &[1, 4],
19497 )
19498 .unwrap();
19499
19500 // Prepare chunks
19501 let chunks_data: Vec<(u64, Vec<u8>)> = (0..8u64)
19502 .map(|frame| {
19503 let values: Vec<i32> = (0..4).map(|i| (frame * 4 + i) as i32).collect();
19504 let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
19505 (frame, raw)
19506 })
19507 .collect();
19508
19509 let batch: Vec<(u64, &[u8])> = chunks_data
19510 .iter()
19511 .map(|(idx, data)| (*idx, data.as_slice()))
19512 .collect();
19513
19514 writer.write_chunks_batch(idx, &batch).unwrap();
19515 writer.extend_dataset(idx, &[8, 4]).unwrap();
19516 writer.close().unwrap();
19517
19518 // Read back
19519 let mut reader = Hdf5Reader::open(&path).unwrap();
19520 assert_eq!(reader.dataset_shape("data").unwrap(), vec![8, 4]);
19521 let raw = reader.read_dataset_raw("data").unwrap();
19522 let values: Vec<i32> = raw
19523 .chunks(4)
19524 .map(|chunk| i32::from_le_bytes(chunk.try_into().unwrap()))
19525 .collect();
19526 assert_eq!(values.len(), 32);
19527 for (i, val) in values.iter().enumerate() {
19528 assert_eq!(*val, i as i32);
19529 }
19530
19531 std::fs::remove_file(&path).ok();
19532 }
19533
19534 #[test]
19535 fn swmr_writer_append_frames() {
19536 use crate::io::swmr::SwmrWriter;
19537
19538 // Per-call unique path so concurrent cargo invocations and
19539 // kernel-side flock release races cannot collide.
19540 use std::sync::atomic::{AtomicU64, Ordering};
19541 static COUNTER: AtomicU64 = AtomicU64::new(0);
19542 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19543 let path = std::env::temp_dir().join(format!(
19544 "rust_hdf5_swmr_append_{}_{}.h5",
19545 std::process::id(),
19546 n
19547 ));
19548
19549 let mut swmr = SwmrWriter::create(&path).unwrap();
19550 let idx = swmr
19551 .create_streaming_dataset("detector", DatatypeMessage::u16_type(), &[4, 4])
19552 .unwrap();
19553
19554 swmr.start_swmr().unwrap();
19555
19556 // Append 5 frames
19557 for frame in 0..5u16 {
19558 let data: Vec<u16> = (0..16).map(|i| frame * 16 + i).collect();
19559 let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19560 swmr.append_frame(idx, &raw).unwrap();
19561 }
19562
19563 swmr.flush().unwrap();
19564 swmr.close().unwrap();
19565
19566 // Read back
19567 let mut reader = Hdf5Reader::open(&path).unwrap();
19568 assert_eq!(reader.dataset_shape("detector").unwrap(), vec![5, 4, 4]);
19569
19570 let raw = reader.read_dataset_raw("detector").unwrap();
19571 let values: Vec<u16> = raw
19572 .chunks(2)
19573 .map(|chunk| u16::from_le_bytes(chunk.try_into().unwrap()))
19574 .collect();
19575 assert_eq!(values.len(), 80); // 5 * 4 * 4
19576 // Verify first frame
19577 for (i, val) in values.iter().enumerate().take(16) {
19578 assert_eq!(*val, i as u16);
19579 }
19580 // Verify last frame
19581 for (i, val) in values[64..80].iter().enumerate() {
19582 assert_eq!(*val, 4 * 16 + i as u16);
19583 }
19584
19585 std::fs::remove_file(&path).ok();
19586 }
19587
19588 #[test]
19589 fn swmr_writer_tiled_frames() {
19590 use crate::io::swmr::SwmrWriter;
19591 use std::sync::atomic::{AtomicU64, Ordering};
19592 static COUNTER: AtomicU64 = AtomicU64::new(0);
19593 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19594 let path = std::env::temp_dir().join(format!(
19595 "rust_hdf5_swmr_tiled_{}_{}.h5",
19596 std::process::id(),
19597 n
19598 ));
19599
19600 let mut swmr = SwmrWriter::create(&path).unwrap();
19601 // 4x4 frames, tiled into 2x2 chunks -> 4 chunks per frame.
19602 let idx = swmr
19603 .create_streaming_dataset_tiled("det", DatatypeMessage::u16_type(), &[4, 4], &[2, 2])
19604 .unwrap();
19605 swmr.start_swmr().unwrap();
19606
19607 for frame in 0..3u16 {
19608 let data: Vec<u16> = (0..16).map(|i| frame * 100 + i).collect();
19609 let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19610 swmr.append_frame(idx, &raw).unwrap();
19611 }
19612 swmr.flush().unwrap();
19613 swmr.close().unwrap();
19614
19615 let mut reader = Hdf5Reader::open(&path).unwrap();
19616 assert_eq!(reader.dataset_shape("det").unwrap(), vec![3, 4, 4]);
19617 let raw = reader.read_dataset_raw("det").unwrap();
19618 let values: Vec<u16> = raw
19619 .chunks(2)
19620 .map(|c| u16::from_le_bytes(c.try_into().unwrap()))
19621 .collect();
19622 assert_eq!(values.len(), 48);
19623 // Every element must survive the frame -> tile split and the
19624 // tile -> frame reassembly on read.
19625 for frame in 0..3u16 {
19626 for i in 0..16usize {
19627 assert_eq!(values[frame as usize * 16 + i], frame * 100 + i as u16);
19628 }
19629 }
19630 std::fs::remove_file(&path).ok();
19631 }
19632
19633 /// A chunk tile larger than the frame is geometry libhdf5 refuses to
19634 /// create (`H5D__chunk_construct`: chunk must not exceed a fixed maximum
19635 /// dimension), so no libhdf5-based writer — including the NDFileHDF5
19636 /// tiling controls this API mirrors — can produce such a file. Until
19637 /// 0.4.1 we accepted it and zero-padded the frame up to the tile; now
19638 /// the create is rejected like every other creator's.
19639 #[test]
19640 fn swmr_writer_tiled_chunk_larger_than_frame_is_rejected() {
19641 use crate::io::swmr::SwmrWriter;
19642 use std::sync::atomic::{AtomicU64, Ordering};
19643 static COUNTER: AtomicU64 = AtomicU64::new(0);
19644 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19645 let path = std::env::temp_dir().join(format!(
19646 "rust_hdf5_swmr_bigchunk_{}_{}.h5",
19647 std::process::id(),
19648 n
19649 ));
19650
19651 let mut swmr = SwmrWriter::create(&path).unwrap();
19652 let err = swmr
19653 .create_streaming_dataset_tiled("det", DatatypeMessage::u16_type(), &[3, 3], &[8, 8])
19654 .unwrap_err();
19655 assert!(
19656 err.to_string().contains("maximum dimension size"),
19657 "unexpected error: {err}"
19658 );
19659 swmr.close().unwrap();
19660 std::fs::remove_file(&path).ok();
19661 }
19662
19663 #[test]
19664 fn swmr_writer_multi_frame_chunks() {
19665 use crate::io::swmr::SwmrWriter;
19666 use std::sync::atomic::{AtomicU64, Ordering};
19667 static COUNTER: AtomicU64 = AtomicU64::new(0);
19668 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19669 let path = std::env::temp_dir().join(format!(
19670 "rust_hdf5_swmr_mfc_{}_{}.h5",
19671 std::process::id(),
19672 n
19673 ));
19674
19675 // 3x3 frames, chunk = 4 frames x full frame. 10 frames -> 3 bands
19676 // of 4, 4, 2 (the last band partial).
19677 let mut swmr = SwmrWriter::create(&path).unwrap();
19678 let idx = swmr
19679 .create_streaming_dataset_chunked(
19680 "det",
19681 DatatypeMessage::u16_type(),
19682 &[3, 3],
19683 &[4, 3, 3],
19684 )
19685 .unwrap();
19686 swmr.start_swmr().unwrap();
19687 for frame in 0..10u16 {
19688 let data: Vec<u16> = (0..9).map(|i| frame * 100 + i).collect();
19689 let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19690 swmr.append_frame(idx, &raw).unwrap();
19691 }
19692 swmr.flush().unwrap();
19693 swmr.close().unwrap();
19694
19695 let mut reader = Hdf5Reader::open(&path).unwrap();
19696 // The partial last band must not over-extend the frame count.
19697 assert_eq!(reader.dataset_shape("det").unwrap(), vec![10, 3, 3]);
19698 let raw = reader.read_dataset_raw("det").unwrap();
19699 let values: Vec<u16> = raw
19700 .chunks(2)
19701 .map(|c| u16::from_le_bytes(c.try_into().unwrap()))
19702 .collect();
19703 assert_eq!(values.len(), 90);
19704 for frame in 0..10u16 {
19705 for i in 0..9usize {
19706 assert_eq!(values[frame as usize * 9 + i], frame * 100 + i as u16);
19707 }
19708 }
19709 std::fs::remove_file(&path).ok();
19710 }
19711
19712 #[test]
19713 fn swmr_writer_multi_frame_tiled_chunks() {
19714 use crate::io::swmr::SwmrWriter;
19715 use std::sync::atomic::{AtomicU64, Ordering};
19716 static COUNTER: AtomicU64 = AtomicU64::new(0);
19717 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19718 let path = std::env::temp_dir().join(format!(
19719 "rust_hdf5_swmr_mftc_{}_{}.h5",
19720 std::process::id(),
19721 n
19722 ));
19723
19724 // 4x4 frames, chunk = 2 frames x 2x2 tiles. 5 frames -> bands of
19725 // 2, 2, 1; every frame is also split into a 2x2 tile grid.
19726 let mut swmr = SwmrWriter::create(&path).unwrap();
19727 let idx = swmr
19728 .create_streaming_dataset_chunked(
19729 "det",
19730 DatatypeMessage::u16_type(),
19731 &[4, 4],
19732 &[2, 2, 2],
19733 )
19734 .unwrap();
19735 swmr.start_swmr().unwrap();
19736 for frame in 0..5u16 {
19737 let data: Vec<u16> = (0..16).map(|i| frame * 100 + i).collect();
19738 let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19739 swmr.append_frame(idx, &raw).unwrap();
19740 }
19741 swmr.flush().unwrap();
19742 swmr.close().unwrap();
19743
19744 let mut reader = Hdf5Reader::open(&path).unwrap();
19745 assert_eq!(reader.dataset_shape("det").unwrap(), vec![5, 4, 4]);
19746 let raw = reader.read_dataset_raw("det").unwrap();
19747 let values: Vec<u16> = raw
19748 .chunks(2)
19749 .map(|c| u16::from_le_bytes(c.try_into().unwrap()))
19750 .collect();
19751 assert_eq!(values.len(), 80);
19752 for frame in 0..5u16 {
19753 for i in 0..16usize {
19754 assert_eq!(values[frame as usize * 16 + i], frame * 100 + i as u16);
19755 }
19756 }
19757 std::fs::remove_file(&path).ok();
19758 }
19759
19760 #[cfg(feature = "deflate")]
19761 #[test]
19762 fn swmr_writer_compressed_frames() {
19763 use crate::io::swmr::SwmrWriter;
19764 use std::sync::atomic::{AtomicU64, Ordering};
19765 static COUNTER: AtomicU64 = AtomicU64::new(0);
19766 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19767 let path = std::env::temp_dir().join(format!(
19768 "rust_hdf5_swmr_comp_{}_{}.h5",
19769 std::process::id(),
19770 n
19771 ));
19772
19773 let mut swmr = SwmrWriter::create(&path).unwrap();
19774 let pipeline = crate::format::messages::filter::FilterPipeline::deflate(4);
19775 let idx = swmr
19776 .create_streaming_dataset_compressed(
19777 "detector",
19778 DatatypeMessage::i32_type(),
19779 &[8],
19780 pipeline,
19781 )
19782 .unwrap();
19783 swmr.start_swmr().unwrap();
19784
19785 for frame in 0..40i32 {
19786 let raw: Vec<u8> = (0..8).flat_map(|i| (frame * 8 + i).to_le_bytes()).collect();
19787 swmr.append_frame(idx, &raw).unwrap();
19788 if frame % 7 == 0 {
19789 swmr.flush().unwrap();
19790 }
19791 }
19792 swmr.flush().unwrap();
19793 swmr.close().unwrap();
19794
19795 let mut reader = Hdf5Reader::open(&path).unwrap();
19796 assert_eq!(reader.dataset_shape("detector").unwrap(), vec![40, 8]);
19797 let raw = reader.read_dataset_raw("detector").unwrap();
19798 let values: Vec<i32> = raw
19799 .chunks(4)
19800 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19801 .collect();
19802 assert_eq!(values, (0..320).collect::<Vec<i32>>());
19803
19804 std::fs::remove_file(&path).ok();
19805 }
19806
19807 #[test]
19808 fn group_hierarchy_writer_reader() {
19809 let path = temp_path("group_hierarchy");
19810
19811 let writer = Hdf5Writer::create(&path).unwrap();
19812
19813 // Create groups
19814 let g0 = writer.create_group("/", "group1").unwrap();
19815 let g1 = writer.create_group("/group1", "sub").unwrap();
19816 assert_eq!(g0, 0);
19817 assert_eq!(g1, 1);
19818
19819 // Create datasets
19820 let ds_root = writer
19821 .create_dataset("root_data", DatatypeMessage::f64_type(), &[2])
19822 .unwrap();
19823 let raw_root: Vec<u8> = [1.0f64, 2.0].iter().flat_map(|v| v.to_le_bytes()).collect();
19824 writer.write_dataset_raw(ds_root, &raw_root).unwrap();
19825
19826 let ds_g0 = writer
19827 .create_dataset("group1/data", DatatypeMessage::i32_type(), &[3])
19828 .unwrap();
19829 let raw_g0: Vec<u8> = [10i32, 20, 30]
19830 .iter()
19831 .flat_map(|v| v.to_le_bytes())
19832 .collect();
19833 writer.write_dataset_raw(ds_g0, &raw_g0).unwrap();
19834
19835 let ds_g1 = writer
19836 .create_dataset("group1/sub/values", DatatypeMessage::u8_type(), &[4])
19837 .unwrap();
19838 writer.write_dataset_raw(ds_g1, &[1u8, 2, 3, 4]).unwrap();
19839
19840 writer.close().unwrap();
19841
19842 // Read back
19843 let mut reader = Hdf5Reader::open(&path).unwrap();
19844 let names = reader.dataset_names();
19845 assert!(names.contains(&"root_data"), "names: {:?}", names);
19846 assert!(names.contains(&"group1/data"), "names: {:?}", names);
19847 assert!(names.contains(&"group1/sub/values"), "names: {:?}", names);
19848
19849 let raw = reader.read_dataset_raw("root_data").unwrap();
19850 let vals: Vec<f64> = raw
19851 .chunks(8)
19852 .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
19853 .collect();
19854 assert_eq!(vals, vec![1.0, 2.0]);
19855
19856 let raw = reader.read_dataset_raw("group1/data").unwrap();
19857 let vals: Vec<i32> = raw
19858 .chunks(4)
19859 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19860 .collect();
19861 assert_eq!(vals, vec![10, 20, 30]);
19862
19863 let raw = reader.read_dataset_raw("group1/sub/values").unwrap();
19864 assert_eq!(raw, vec![1, 2, 3, 4]);
19865
19866 std::fs::remove_file(&path).ok();
19867 }
19868
19869 /// libhdf5 (`H5D__chunk_construct`) rejects a chunk dimension that
19870 /// exceeds a fixed maximum dimension. Before this check, such a dataset
19871 /// was created and appends landed rows at the chunk stride instead of
19872 /// the row stride, reading back [1, 2, 0, 0] for [1, 2, 3, 4].
19873 #[test]
19874 fn create_rejects_a_chunk_wider_than_a_fixed_max_dimension() {
19875 let path = temp_path("chunk_wider_than_max");
19876
19877 let writer = Hdf5Writer::create(&path).unwrap();
19878 let err = writer
19879 .create_chunked_dataset(
19880 "data",
19881 DatatypeMessage::f64_type(),
19882 &[0, 2],
19883 &[u64::MAX, 2],
19884 &[2, 4],
19885 )
19886 .unwrap_err();
19887 assert!(
19888 err.to_string().contains("maximum dimension size"),
19889 "unexpected error: {err}"
19890 );
19891
19892 // The fixed-array creators derive the maximum from the fixed dims.
19893 let err = writer
19894 .create_fixed_array_dataset("fa", DatatypeMessage::f64_type(), &[3], &[5])
19895 .unwrap_err();
19896 assert!(
19897 err.to_string().contains("maximum dimension size"),
19898 "unexpected error: {err}"
19899 );
19900
19901 writer.close().unwrap();
19902 std::fs::remove_file(&path).ok();
19903 }
19904
19905 /// libhdf5 exempts a dimension whose *current* size is zero from the
19906 /// chunk-vs-maximum check (`curr_dims[u] &&` in `H5D__chunk_construct`),
19907 /// and rejects a zero chunk dimension on every path.
19908 #[test]
19909 fn create_mirrors_the_libhdf5_chunk_geometry_exemptions() {
19910 let path = temp_path("chunk_geometry_exemptions");
19911
19912 let writer = Hdf5Writer::create(&path).unwrap();
19913 // dims[1] == 0: chunk 4 > max 2 is allowed, as libhdf5 allows it.
19914 writer
19915 .create_chunked_dataset(
19916 "exempt",
19917 DatatypeMessage::f64_type(),
19918 &[0, 0],
19919 &[u64::MAX, 2],
19920 &[2, 4],
19921 )
19922 .unwrap();
19923
19924 let err = writer
19925 .create_chunked_dataset("zero", DatatypeMessage::f64_type(), &[0], &[u64::MAX], &[0])
19926 .unwrap_err();
19927 assert!(
19928 err.to_string().contains("chunk dimension 0 is zero"),
19929 "unexpected error: {err}"
19930 );
19931
19932 writer.close().unwrap();
19933 std::fs::remove_file(&path).ok();
19934 }
19935
19936 /// A file written by 0.4.0 can carry a chunk row wider than the frame
19937 /// row — create now rejects that geometry, but reopened files keep it.
19938 /// Appends must scatter frames at the chunk stride, not pack them at
19939 /// the frame stride (which read back `[1, 2, 0, 0]` for `[1, 2, 3, 4]`).
19940 /// The wide shape is simulated by widening the registered chunk dims
19941 /// after create, which also lands in the layout message at close.
19942 #[test]
19943 fn append_scatters_into_a_legacy_wider_than_row_chunk() {
19944 let path = temp_path("legacy_wide_chunk_append");
19945
19946 let writer = Hdf5Writer::create(&path).unwrap();
19947 let idx = writer
19948 .create_chunked_dataset(
19949 "data",
19950 DatatypeMessage::i32_type(),
19951 &[0, 2],
19952 &[u64::MAX, 2],
19953 &[2, 2],
19954 )
19955 .unwrap();
19956 writer.ds(idx).lock().chunked.as_mut().unwrap().chunk_dims = vec![2, 4];
19957
19958 let frames: Vec<u8> = [1i32, 2, 3, 4]
19959 .iter()
19960 .flat_map(|v| v.to_le_bytes())
19961 .collect();
19962 writer.write_append_frames(idx, 0, 2, &frames).unwrap();
19963 writer.extend_dataset(idx, &[2, 2]).unwrap();
19964 writer.close().unwrap();
19965
19966 let mut reader = Hdf5Reader::open(&path).unwrap();
19967 assert_eq!(reader.dataset_shape("data").unwrap(), vec![2, 2]);
19968 let raw = reader.read_dataset_raw("data").unwrap();
19969 let values: Vec<i32> = raw
19970 .chunks(4)
19971 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19972 .collect();
19973 assert_eq!(values, vec![1, 2, 3, 4]);
19974 std::fs::remove_file(&path).ok();
19975 }
19976
19977 /// The compressed vlen creator sizes its chunked layout from a
19978 /// caller-supplied chunk size; it goes through the same geometry
19979 /// validation as every other creator (empty inputs are exempt because
19980 /// their current size is zero).
19981 #[test]
19982 #[cfg(feature = "deflate")]
19983 fn compressed_vlen_create_validates_its_chunk_size() {
19984 use crate::format::messages::filter::FilterPipeline;
19985 let path = temp_path("vlen_compressed_chunk");
19986
19987 let writer = Hdf5Writer::create(&path).unwrap();
19988 let err = writer
19989 .create_vlen_string_dataset_compressed(
19990 "texts",
19991 &["a", "b", "c"],
19992 100,
19993 FilterPipeline::deflate(6),
19994 )
19995 .unwrap_err();
19996 assert!(
19997 err.to_string().contains("maximum dimension size"),
19998 "unexpected error: {err}"
19999 );
20000
20001 writer
20002 .create_vlen_string_dataset_compressed("empty", &[], 16, FilterPipeline::deflate(6))
20003 .unwrap();
20004
20005 writer.close().unwrap();
20006 std::fs::remove_file(&path).ok();
20007 }
20008
20009 /// `set_libver_latest` moves *filtered* chunked datasets to layout v5 with
20010 /// fixed 8-byte chunk-size fields; unfiltered chunked and pre-opt-in
20011 /// datasets keep v4 with the derived width, matching libhdf5's
20012 /// `version_perf` rule (only the filtered index arms bump to 5).
20013 #[cfg(feature = "deflate")]
20014 #[test]
20015 fn libver_latest_selects_v5_for_filtered_chunks_only() {
20016 let path = temp_path("libver_v5_select");
20017
20018 let mut writer = Hdf5Writer::create(&path).unwrap();
20019 let before = writer
20020 .create_chunked_dataset_with_pipeline(
20021 "d4",
20022 DatatypeMessage::i32_type(),
20023 &[0],
20024 &[u64::MAX],
20025 &[16],
20026 FilterPipeline::deflate(4),
20027 )
20028 .unwrap();
20029 writer.set_libver_latest(true).unwrap();
20030 let ea5 = writer
20031 .create_chunked_dataset_with_pipeline(
20032 "ea5",
20033 DatatypeMessage::i32_type(),
20034 &[0],
20035 &[u64::MAX],
20036 &[16],
20037 FilterPipeline::deflate(4),
20038 )
20039 .unwrap();
20040 let plain = writer
20041 .create_chunked_dataset(
20042 "plain",
20043 DatatypeMessage::i32_type(),
20044 &[0],
20045 &[u64::MAX],
20046 &[16],
20047 )
20048 .unwrap();
20049 let fa5 = writer
20050 .create_fixed_array_dataset_with_pipeline(
20051 "fa5",
20052 DatatypeMessage::i32_type(),
20053 &[4, 6],
20054 &[2, 3],
20055 FilterPipeline::deflate(6),
20056 )
20057 .unwrap();
20058 let bt5 = writer
20059 .create_btree_v2_dataset_with_pipeline(
20060 "bt5",
20061 DatatypeMessage::i32_type(),
20062 &[0, 0],
20063 &[u64::MAX, u64::MAX],
20064 &[2, 3],
20065 FilterPipeline::deflate(6),
20066 )
20067 .unwrap();
20068
20069 {
20070 let d4 = writer.ds(before);
20071 let d4 = d4.lock();
20072 assert_eq!(d4.layout_version, 4);
20073 assert_eq!(
20074 d4.chunked.as_ref().unwrap().chunk_size_len,
20075 compute_chunk_size_len(16 * 4)
20076 );
20077 let e5 = writer.ds(ea5);
20078 let e5 = e5.lock();
20079 assert_eq!(e5.layout_version, 5);
20080 assert_eq!(e5.chunked.as_ref().unwrap().chunk_size_len, 8);
20081 assert_eq!(writer.ds(plain).lock().layout_version, 4);
20082 assert_eq!(writer.ds(fa5).lock().layout_version, 5);
20083 assert_eq!(writer.ds(bt5).lock().layout_version, 5);
20084 }
20085
20086 // Write through the FA and BT2 v5 indexes so their 8-byte chunk-size
20087 // fields are exercised end to end, not just selected.
20088 for (coords, vals) in [
20089 ([0u64, 0], [0i32, 1, 2, 6, 7, 8]),
20090 ([0, 1], [3, 4, 5, 9, 10, 11]),
20091 ([1, 0], [12, 13, 14, 18, 19, 20]),
20092 ([1, 1], [15, 16, 17, 21, 22, 23]),
20093 ] {
20094 let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
20095 writer
20096 .write_chunk_fixed_array(fa5, &coords, &bytes)
20097 .unwrap();
20098 writer.write_chunk_btree_v2(bt5, &coords, &bytes).unwrap();
20099 }
20100 writer.extend_dataset(bt5, &[4, 6]).unwrap();
20101 writer.close().unwrap();
20102
20103 let mut reader = Hdf5Reader::open(&path).unwrap();
20104 for name in ["fa5", "bt5"] {
20105 let raw = reader.read_dataset_raw(name).unwrap();
20106 let values: Vec<i32> = raw
20107 .chunks(4)
20108 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
20109 .collect();
20110 assert_eq!(values, (0..24).collect::<Vec<i32>>(), "dataset {name}");
20111 }
20112
20113 std::fs::remove_file(&path).ok();
20114 }
20115
20116 /// A v5 file reopened for append must stay v5: the decode → `DatasetInfo`
20117 /// → finalize path carries the version through, so the re-encoded layout
20118 /// message matches the 8-byte size fields the filtered index was built
20119 /// with. A silent v4 downgrade here would make libhdf5 derive a narrower
20120 /// field width than the index uses.
20121 #[cfg(feature = "deflate")]
20122 #[test]
20123 fn v5_layout_survives_reopen_and_append() {
20124 let path = temp_path("libver_v5_reopen");
20125 let chunk: usize = 8;
20126
20127 let mut writer = Hdf5Writer::create(&path).unwrap();
20128 writer.set_libver_latest(true).unwrap();
20129 let idx = writer
20130 .create_chunked_dataset_with_pipeline(
20131 "d",
20132 DatatypeMessage::i32_type(),
20133 &[0],
20134 &[u64::MAX],
20135 &[chunk as u64],
20136 FilterPipeline::deflate(4),
20137 )
20138 .unwrap();
20139 for c in 0..2u64 {
20140 let data: Vec<u8> = (0..chunk as i32)
20141 .flat_map(|i| (c as i32 * chunk as i32 + i).to_le_bytes())
20142 .collect();
20143 writer.write_chunk(idx, c, &data).unwrap();
20144 }
20145 writer.extend_dataset(idx, &[2 * chunk as u64]).unwrap();
20146 writer.close().unwrap();
20147
20148 // Reopen: the decoded layout version must be preserved, and appends
20149 // must keep working against the 8-byte-size-field index.
20150 let writer = Hdf5Writer::open_append(&path).unwrap();
20151 assert_eq!(writer.ds(0).lock().layout_version, 5);
20152 for c in 2..4u64 {
20153 let data: Vec<u8> = (0..chunk as i32)
20154 .flat_map(|i| (c as i32 * chunk as i32 + i).to_le_bytes())
20155 .collect();
20156 writer.write_chunk(0, c, &data).unwrap();
20157 }
20158 writer.extend_dataset(0, &[4 * chunk as u64]).unwrap();
20159 writer.close().unwrap();
20160
20161 // Still v5 after the second finalize, and fully readable.
20162 let writer = Hdf5Writer::open_append(&path).unwrap();
20163 assert_eq!(writer.ds(0).lock().layout_version, 5);
20164 writer.close().unwrap();
20165
20166 let mut reader = Hdf5Reader::open(&path).unwrap();
20167 let raw = reader.read_dataset_raw("d").unwrap();
20168 let values: Vec<i32> = raw
20169 .chunks(4)
20170 .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
20171 .collect();
20172 assert_eq!(values, (0..4 * chunk as i32).collect::<Vec<i32>>());
20173
20174 std::fs::remove_file(&path).ok();
20175 }
20176
20177 /// A chunk strictly larger than `u32::MAX` bytes forces layout v5 with no
20178 /// opt-in — v4's size field cannot represent it — while a chunk of exactly
20179 /// `u32::MAX` bytes stays v4, matching libhdf5's `version_req` boundary
20180 /// (`> 0xffffffff`, filtered or not).
20181 #[test]
20182 fn oversized_chunk_forces_v5_without_opt_in() {
20183 let path = temp_path("libver_4gib_force");
20184
20185 let writer = Hdf5Writer::create(&path).unwrap();
20186 let at_limit = writer
20187 .create_chunked_dataset_with_pipeline(
20188 "at_limit",
20189 DatatypeMessage::u8_type(),
20190 &[0],
20191 &[u64::MAX],
20192 &[u32::MAX as u64],
20193 FilterPipeline::deflate(4),
20194 )
20195 .unwrap();
20196 let over = writer
20197 .create_chunked_dataset_with_pipeline(
20198 "over",
20199 DatatypeMessage::u8_type(),
20200 &[0],
20201 &[u64::MAX],
20202 &[u32::MAX as u64 + 1],
20203 FilterPipeline::deflate(4),
20204 )
20205 .unwrap();
20206 let over_unfiltered = writer
20207 .create_chunked_dataset(
20208 "over_plain",
20209 DatatypeMessage::u8_type(),
20210 &[0],
20211 &[u64::MAX],
20212 &[u32::MAX as u64 + 1],
20213 )
20214 .unwrap();
20215
20216 assert_eq!(writer.ds(at_limit).lock().layout_version, 4);
20217 {
20218 let ds = writer.ds(over);
20219 let ds = ds.lock();
20220 assert_eq!(ds.layout_version, 5);
20221 assert_eq!(ds.chunked.as_ref().unwrap().chunk_size_len, 8);
20222 }
20223 assert_eq!(writer.ds(over_unfiltered).lock().layout_version, 5);
20224 writer.close().unwrap();
20225 std::fs::remove_file(&path).ok();
20226 }
20227
20228 /// SWMR reaches version 3 on its own, without a chunked dataset to raise
20229 /// the bound — through the flags `finalize_for_swmr` passes, and then
20230 /// through `swmr_active` for every superblock written after it. Only a
20231 /// file with nothing else newer in it can tell the two arms apart, and
20232 /// the public SWMR API always creates a chunked streaming dataset.
20233 #[test]
20234 fn swmr_reaches_version_3_with_no_chunked_dataset_in_the_file() {
20235 let path = temp_path("swmr_superblock");
20236
20237 let mut writer = Hdf5Writer::create(&path).unwrap();
20238 writer
20239 .create_dataset("d", DatatypeMessage::i32_type(), &[2])
20240 .unwrap();
20241 assert_eq!(writer.superblock_version_for(0), SUPERBLOCK_V2);
20242
20243 writer.finalize_for_swmr().unwrap();
20244 // What `start_swmr` does after finalizing, and what lets a second
20245 // handle read the file while this writer lives — the writer's
20246 // exclusive lock is mandatory on Windows.
20247 writer.handle().release_lock().unwrap();
20248 assert_eq!(std::fs::read(&path).unwrap()[8], SUPERBLOCK_V3);
20249
20250 // The close-time finalize carries no SWMR flag; the file is still an
20251 // SWMR file and must not be handed back a version older than the one
20252 // its readers attached to.
20253 writer.close().unwrap();
20254 assert_eq!(std::fs::read(&path).unwrap()[8], SUPERBLOCK_V3);
20255 std::fs::remove_file(&path).ok();
20256 }
20257
20258 /// A named bound below `H5F_LIBVER_V110` refuses the session instead —
20259 /// the two checks `H5F__start_swmr_write` opens with, a version-3
20260 /// superblock (H5Fint.c:3814) and a low bound of at least V110
20261 /// (H5Fint.c:3818). Naming no bound at all is what the test above does,
20262 /// and that file is free to become version 3.
20263 #[test]
20264 fn a_named_bound_below_v110_refuses_an_swmr_session() {
20265 for bound in [LibverBound::Earliest, LibverBound::V18] {
20266 let path = temp_path(&format!("swmr_refused_{bound:?}"));
20267 let mut writer = Hdf5Writer::create_with_options(
20268 &path,
20269 FileCreateOptions {
20270 libver: Some(bound),
20271 ..Default::default()
20272 },
20273 )
20274 .unwrap();
20275 writer
20276 .create_dataset("d", DatatypeMessage::i32_type(), &[2])
20277 .unwrap();
20278
20279 let err = writer.finalize_for_swmr().unwrap_err().to_string();
20280 assert!(err.contains("SWMR"), "{bound:?}: {err}");
20281 assert!(err.contains("H5F_LIBVER_V110"), "{bound:?}: {err}");
20282
20283 // Refused, not half-done: nothing was published, and the close
20284 // writes the file the bound asked for.
20285 writer.close().unwrap();
20286 let version = std::fs::read(&path).unwrap()[8];
20287 assert_eq!(version, bound.superblock_version(), "{bound:?}");
20288 std::fs::remove_file(&path).ok();
20289 }
20290 }
20291
20292 /// A dataset header the SWMR publish could not fit into the chunk 0 it
20293 /// already had chains into a continuation block, and the in-place rewrite
20294 /// goes back over both: chunk 0 stays at the address the file's readers
20295 /// hold, and the continuation chunk at the one chunk 0 names.
20296 #[test]
20297 fn inplace_rewrite_goes_over_a_chained_header() {
20298 let path = temp_path("inplace_rewrite_chained");
20299 let writer = Hdf5Writer::create_with_options(
20300 &path,
20301 FileCreateOptions {
20302 libver: Some(LibverBound::V110),
20303 ..Default::default()
20304 },
20305 )
20306 .unwrap();
20307 writer
20308 .create_chunked_dataset("d", DatatypeMessage::i32_type(), &[0], &[u64::MAX], &[4])
20309 .unwrap();
20310 writer.close().unwrap();
20311
20312 let mut writer = Hdf5Writer::open_append(&path).unwrap();
20313 let idx = 0;
20314 let published = writer.ds(idx).lock().obj_header_written_addr.unwrap();
20315 for i in 0..4 {
20316 writer
20317 .add_dataset_attribute(
20318 idx,
20319 AttributeMessage::array_numeric(
20320 &format!("wide{i}"),
20321 DatatypeMessage::f64_type(),
20322 &[32],
20323 vec![0u8; 256],
20324 ),
20325 )
20326 .unwrap();
20327 }
20328 writer.finalize_for_swmr().unwrap();
20329 let blocks = writer.ds(idx).lock().obj_header_blocks.clone();
20330 assert_eq!(blocks.len(), 2, "chunk 0 and a continuation: {blocks:?}");
20331 assert_eq!(blocks[0].0, published, "chunk 0 stayed where it was");
20332
20333 writer.write_dataset_header_inplace(idx).unwrap();
20334 assert_eq!(writer.ds(idx).lock().obj_header_blocks, blocks);
20335 writer.close().unwrap();
20336
20337 // The closing finalize wrote over the same chunk 0, and the chained
20338 // header reads back whole.
20339 let writer = Hdf5Writer::open_append(&path).unwrap();
20340 assert_eq!(writer.ds(0).lock().obj_header_written_addr, Some(published));
20341 assert_eq!(writer.ds(0).lock().attributes.len(), 4);
20342 std::fs::remove_file(&path).ok();
20343 }
20344
20345 /// `H5F__start_swmr_write` refuses a low bound below `H5F_LIBVER_V110`
20346 /// (H5Fint.c:3818) on a reopened file as on a created one, now that the
20347 /// superblock no longer raises it: an SWMR reader follows the v1.10 chunk
20348 /// indexes, which a lower bound's layout version cannot name. No bound
20349 /// named passes, the default's layout row being `V110`'s.
20350 #[test]
20351 fn swmr_on_a_reopened_file_refuses_a_named_bound_below_v110() {
20352 let path = temp_path("swmr_reopen_bound");
20353 let writer = Hdf5Writer::create_with_options(
20354 &path,
20355 FileCreateOptions {
20356 libver: Some(LibverBound::V110),
20357 ..Default::default()
20358 },
20359 )
20360 .unwrap();
20361 writer
20362 .create_dataset("d", DatatypeMessage::i32_type(), &[2])
20363 .unwrap();
20364 writer.close().unwrap();
20365 assert_eq!(std::fs::read(&path).unwrap()[8], SUPERBLOCK_V3);
20366
20367 for bound in [LibverBound::Earliest, LibverBound::V18] {
20368 let mut writer = Hdf5Writer::open_append(&path).unwrap();
20369 writer.set_libver_bound(bound).unwrap();
20370 let err = writer.finalize_for_swmr().unwrap_err().to_string();
20371 assert!(err.contains("H5F_LIBVER_V110"), "{bound:?}: {err}");
20372 writer.close().unwrap();
20373 }
20374 let mut writer = Hdf5Writer::open_append(&path).unwrap();
20375 writer.finalize_for_swmr().unwrap();
20376 writer.close().unwrap();
20377 std::fs::remove_file(&path).ok();
20378 }
20379
20380 /// After every writer of a dataset object header, `nlink_written` is the
20381 /// count that writer encoded.
20382 ///
20383 /// `header_stale_with` is the one authority for "does the on-disk header
20384 /// still describe this dataset?", and it reads `nlink_written`; the three
20385 /// writers — `finalize`, `finalize_for_swmr` and
20386 /// `write_dataset_header_inplace` — therefore all record through
20387 /// `DatasetInfo::header_written`. This walks the SWMR sequence, where the
20388 /// in-place writer is the one that could drift, and pins why it does not:
20389 /// a name added after the publish grows the header past the block it was
20390 /// published into, so the rewrite is refused rather than half-applied and
20391 /// the count on disk stays the one the registry names.
20392 #[test]
20393 fn every_dataset_header_write_records_its_link_count() {
20394 let path = temp_path("header_write_records_nlink");
20395 let writer = Hdf5Writer::create(&path).unwrap();
20396 let idx = writer
20397 .create_chunked_dataset("d", DatatypeMessage::i32_type(), &[0], &[u64::MAX], &[4])
20398 .unwrap();
20399 let mut writer = writer;
20400 writer.finalize_for_swmr().unwrap();
20401 assert_eq!(
20402 writer.ds(idx).lock().nlink_written,
20403 1,
20404 "the SWMR publish put one name in the header"
20405 );
20406 writer.write_dataset_header_inplace(idx).unwrap();
20407 assert_eq!(writer.ds(idx).lock().nlink_written, 1);
20408
20409 // A second name after the publish: the reference-count message it
20410 // adds does not fit the published block.
20411 writer.create_hard_link("/", "alias", "d").unwrap();
20412 assert_eq!(writer.object_link_count(HardLinkTarget::Dataset(idx)), 2);
20413 let grew = writer
20414 .write_dataset_header_inplace(idx)
20415 .unwrap_err()
20416 .to_string();
20417 assert!(
20418 grew.contains("cannot rewrite in place"),
20419 "a header that outgrew its block must be refused: {grew}"
20420 );
20421 assert_eq!(
20422 writer.ds(idx).lock().nlink_written,
20423 1,
20424 "a refused rewrite leaves the registry describing the header the file holds"
20425 );
20426
20427 // The close-time finalize is the writer that commits the second name,
20428 // and a reopen reads the same count back off the link graph.
20429 writer.close().unwrap();
20430 let writer = Hdf5Writer::open_append(&path).unwrap();
20431 assert_eq!(
20432 writer.ds(0).lock().nlink_written,
20433 2,
20434 "finalize wrote two names and the reopen reads two"
20435 );
20436 writer.close().unwrap();
20437 std::fs::remove_file(&path).ok();
20438 }
20439
20440 /// `H5D__chunk_set_info`'s `version_req` (H5Dchunk.c:909, :936): version 5
20441 /// is required for a chunk over 4 GiB — the version-4 layout message's
20442 /// stored-size field is 32 bits and cannot record one — and
20443 /// `LAYOUT_VERSION_DEFAULT` (3, `H5O_LAYOUT_VERSION_DEFAULT`) is the floor
20444 /// for everything at or under that limit. Pure arithmetic on the byte
20445 /// count: no chunk is ever allocated.
20446 #[test]
20447 fn required_chunk_layout_version_pins_5_past_4_gib() {
20448 assert_eq!(
20449 Hdf5Writer::required_chunk_layout_version(u32::MAX as u64),
20450 LAYOUT_VERSION_DEFAULT
20451 );
20452 assert_eq!(
20453 Hdf5Writer::required_chunk_layout_version(u32::MAX as u64 + 1),
20454 5
20455 );
20456 }
20457
20458 /// `H5D__chunk_set_info`'s index-selection gate (H5Dchunk.c:936): a chunk
20459 /// over 4 GiB reaches the v1.10 chunk indexes even under a bound whose
20460 /// `H5O_layout_ver_bounds` row (`LibverBound::layout_version`) is below
20461 /// 4 — `V18` (row 3) and `Earliest` (row 1) both normally keep an
20462 /// ordinary chunk on the version-1 B-tree, but
20463 /// `required_chunk_layout_version`'s own escape to 5 overrides that row
20464 /// for this one chunk. The default bound (`V110`, row 4) already crosses
20465 /// the threshold on its own, so it is asserted only as the baseline, not
20466 /// as a distinguishing case for the escape.
20467 #[test]
20468 fn uses_v110_chunk_indexing_escapes_past_4_gib_at_every_bound() {
20469 let over_4gib = u32::MAX as u64 + 1;
20470 let small = 1024u64;
20471
20472 let path = temp_path("uses_v110_default");
20473 let writer = Hdf5Writer::create(&path).unwrap();
20474 assert!(writer.uses_v110_chunk_indexing(small));
20475 assert!(writer.uses_v110_chunk_indexing(over_4gib));
20476 writer.close().unwrap();
20477 std::fs::remove_file(&path).ok();
20478
20479 let path = temp_path("uses_v110_v18");
20480 let mut writer = Hdf5Writer::create(&path).unwrap();
20481 writer.set_libver_bound(LibverBound::V18).unwrap();
20482 assert!(
20483 !writer.uses_v110_chunk_indexing(small),
20484 "V18's layout row (3) stays below the v1.10 gate for an ordinary chunk"
20485 );
20486 assert!(
20487 writer.uses_v110_chunk_indexing(over_4gib),
20488 "the >4 GiB escape reaches v1.10 indexing despite V18's row"
20489 );
20490 writer.close().unwrap();
20491 std::fs::remove_file(&path).ok();
20492
20493 let path = temp_path("uses_v110_earliest");
20494 let mut writer = Hdf5Writer::create(&path).unwrap();
20495 writer.set_libver_bound(LibverBound::Earliest).unwrap();
20496 assert!(
20497 !writer.uses_v110_chunk_indexing(small),
20498 "Earliest's layout row (1) stays below the v1.10 gate for an ordinary chunk"
20499 );
20500 assert!(
20501 writer.uses_v110_chunk_indexing(over_4gib),
20502 "the >4 GiB escape reaches v1.10 indexing despite Earliest's row"
20503 );
20504 writer.close().unwrap();
20505 std::fs::remove_file(&path).ok();
20506 }
20507
20508 /// `H5D__chunk_set_info`'s closing `MAX3` (H5Dchunk.c:1046): the same
20509 /// escape pins the layout message itself at version 5 for a chunk over
20510 /// 4 GiB regardless of bound — `required_chunk_layout_version` dominates
20511 /// the max chain ahead of both the bound-derived preference and
20512 /// `LAYOUT_VERSION_DEFAULT`.
20513 #[test]
20514 fn chunk_layout_version_pins_5_past_4_gib_at_every_bound() {
20515 let over_4gib = u32::MAX as u64 + 1;
20516 let small = 1024u64;
20517
20518 let path = temp_path("chunk_ver_default");
20519 let writer = Hdf5Writer::create(&path).unwrap();
20520 assert_eq!(writer.chunk_layout_version(false, small), 4);
20521 assert_eq!(writer.chunk_layout_version(false, over_4gib), 5);
20522 writer.close().unwrap();
20523 std::fs::remove_file(&path).ok();
20524
20525 let path = temp_path("chunk_ver_v18");
20526 let mut writer = Hdf5Writer::create(&path).unwrap();
20527 writer.set_libver_bound(LibverBound::V18).unwrap();
20528 assert_eq!(writer.chunk_layout_version(false, small), 3);
20529 assert_eq!(writer.chunk_layout_version(false, over_4gib), 5);
20530 writer.close().unwrap();
20531 std::fs::remove_file(&path).ok();
20532
20533 let path = temp_path("chunk_ver_earliest");
20534 let mut writer = Hdf5Writer::create(&path).unwrap();
20535 writer.set_libver_bound(LibverBound::Earliest).unwrap();
20536 assert_eq!(
20537 writer.chunk_layout_version(false, small),
20538 LAYOUT_VERSION_DEFAULT
20539 );
20540 assert_eq!(writer.chunk_layout_version(false, over_4gib), 5);
20541 writer.close().unwrap();
20542 std::fs::remove_file(&path).ok();
20543 }
20544 /// `fsm_persist.h5` persists two managers — metadata and raw data. The
20545 /// reopen reads both, hands their merged sections to the allocator, and
20546 /// claims the four blocks the managers themselves occupy.
20547 #[test]
20548 fn a_persisting_file_reopens_with_its_free_sections() {
20549 let path = fixture_copy("fsm_persist.h5", "fsm_read");
20550 let writer = Hdf5Writer::open_append(&path).unwrap();
20551 let fs = writer.free_space.as_deref().expect("managers were read");
20552
20553 assert!(fs.info.persist);
20554 assert_eq!(fs.info.strategy, FileSpaceStrategy::FsmAggr);
20555 assert_eq!(fs.info.threshold, 1);
20556
20557 let sections = writer.allocator.free_blocks();
20558 // h5stat -S reports 1910 bytes of tracked free space for this file.
20559 assert_eq!(sections.iter().map(|s| s.1).sum::<u64>(), 1910);
20560 // Address-ordered, and no two sections touch: what the two managers
20561 // held separately came out coalesced.
20562 for w in sections.windows(2) {
20563 assert!(w[0].0 + w[0].1 < w[1].0, "{sections:?}");
20564 }
20565 // Two headers plus the two sections blocks they name.
20566 assert_eq!(fs.superseded.len(), 4);
20567 for &(addr, len) in &fs.superseded {
20568 assert!(len > 0);
20569 assert!(
20570 !sections
20571 .iter()
20572 .any(|&(a, l)| addr < a + l && a < addr + len),
20573 "manager block {addr:#x}+{len} sits in a free section"
20574 );
20575 }
20576 drop(writer);
20577 let _ = std::fs::remove_file(&path);
20578 }
20579
20580 /// A file created with non-default file-space properties carries the
20581 /// message that declares them, and one created to persist gets real
20582 /// managers as soon as anything is freed.
20583 #[test]
20584 fn a_created_file_declares_the_strategy_it_was_made_with() {
20585 let path = temp_path("fsm_create");
20586 {
20587 let w = Hdf5Writer::create_with_options(
20588 &path,
20589 FileCreateOptions {
20590 file_space: FileSpaceConfig::new(FileSpaceStrategy::FsmAggr, true, 1),
20591 ..Default::default()
20592 },
20593 )
20594 .unwrap();
20595 let i = w
20596 .create_dataset("keep", DatatypeMessage::i32_type(), &[8])
20597 .unwrap();
20598 w.write_dataset_raw(i, &[0u8; 32]).unwrap();
20599 w.close().unwrap();
20600 }
20601
20602 let info = read_only_append(&path)
20603 .free_space
20604 .as_deref()
20605 .expect("the created file declares a strategy")
20606 .info
20607 .clone();
20608 assert_eq!(info.strategy, FileSpaceStrategy::FsmAggr);
20609 assert!(info.persist);
20610 assert_eq!(info.threshold, 1);
20611 assert_eq!(info.page_size, 4096);
20612 // The alignment fragments the creation left behind are the file's
20613 // first free space, so the metadata manager already has an address
20614 // and the raw-data one, which nothing freed into, does not.
20615 assert_ne!(info.fs_addr[0], UNDEF_ADDR);
20616 assert!(info.fs_addr.iter().skip(1).all(|&a| a == UNDEF_ADDR));
20617
20618 // An append supersedes the root header and the extension, and that
20619 // freed space is what the managers now record.
20620 append_one(&path, "added", false);
20621 assert!(
20622 tracked_free_space(&path) > 0,
20623 "the append recorded no free space"
20624 );
20625 let _ = std::fs::remove_file(&path);
20626 }
20627
20628 /// The two strategies without managers, and the default. All three are
20629 /// `H5Pset_file_space_strategy` settings; only the default leaves the file
20630 /// without the message.
20631 #[test]
20632 fn a_strategy_without_managers_still_declares_itself() {
20633 for (strategy, persist) in [
20634 (FileSpaceStrategy::Aggr, true),
20635 (FileSpaceStrategy::None, false),
20636 ] {
20637 let path = temp_path("fsm_nomgr");
20638 {
20639 let w = Hdf5Writer::create_with_options(
20640 &path,
20641 FileCreateOptions {
20642 file_space: FileSpaceConfig::new(strategy, persist, 7),
20643 ..Default::default()
20644 },
20645 )
20646 .unwrap();
20647 w.create_dataset("d", DatatypeMessage::f64_type(), &[4])
20648 .unwrap();
20649 w.close().unwrap();
20650 }
20651 // Read through the reader, not the writer: a reopen only builds
20652 // free-space state for a file it will rewrite managers for, and
20653 // these two have none.
20654 let info = declared_file_space(&path).expect("the strategy is declared");
20655 assert_eq!(info.strategy, strategy);
20656 // `H5P__set_file_space_strategy` stores neither for a strategy
20657 // that has no managers, so both keep the library defaults.
20658 assert!(!info.persist);
20659 assert_eq!(info.threshold, 1);
20660 let _ = std::fs::remove_file(&path);
20661 }
20662 }
20663
20664 /// The library defaults are what a file says by saying nothing.
20665 #[test]
20666 fn the_default_strategy_writes_no_message() {
20667 let path = temp_path("fsm_default");
20668 {
20669 let w = Hdf5Writer::create_with_options(
20670 &path,
20671 FileCreateOptions {
20672 file_space: FileSpaceConfig::new(FileSpaceStrategy::FsmAggr, false, 1),
20673 ..Default::default()
20674 },
20675 )
20676 .unwrap();
20677 w.create_dataset("d", DatatypeMessage::f64_type(), &[4])
20678 .unwrap();
20679 w.close().unwrap();
20680 }
20681 assert!(declared_file_space(&path).is_none());
20682 let _ = std::fs::remove_file(&path);
20683 }
20684
20685 /// The file-space info message a file carries, read back the way any
20686 /// reader sees it.
20687 fn declared_file_space(path: &std::path::Path) -> Option<FileSpaceInfoMessage> {
20688 crate::io::reader::Hdf5Reader::open(path)
20689 .unwrap()
20690 .superblock_extension()
20691 .file_space_info
20692 .clone()
20693 }
20694
20695 /// A created paged file is laid out on its page grid: the superblock takes
20696 /// the whole of page zero and the rest of that page is the metadata
20697 /// manager's first section, which is what `H5MF__alloc_pagefs` gives
20698 /// `H5F__super_init`'s `H5MF_alloc(f, H5FD_MEM_SUPER, ...)`.
20699 #[test]
20700 fn a_created_paged_file_lays_its_pages_out() {
20701 let path = temp_path("fsm_paged_created");
20702 {
20703 let w = Hdf5Writer::create_with_options(
20704 &path,
20705 FileCreateOptions {
20706 file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1),
20707 ..Default::default()
20708 },
20709 )
20710 .unwrap();
20711 let i = w
20712 .create_dataset("keep", DatatypeMessage::i32_type(), &[8])
20713 .unwrap();
20714 w.write_dataset_raw(i, &[0u8; 32]).unwrap();
20715 w.close().unwrap();
20716 }
20717 let info = read_only_append(&path)
20718 .free_space
20719 .as_deref()
20720 .expect("the created file declares a strategy")
20721 .info
20722 .clone();
20723 assert_eq!(info.strategy, FileSpaceStrategy::Page);
20724 assert!(info.persist);
20725 assert_eq!(info.page_size, 4096);
20726 assert_eq!(
20727 std::fs::metadata(&path).unwrap().len() % info.page_size,
20728 0,
20729 "a paged file ends on a page boundary"
20730 );
20731 let _ = std::fs::remove_file(&path);
20732 }
20733
20734 /// A userblock has to be a whole number of pages, or every page boundary
20735 /// after it is off the file's own grid — `H5F__super_init` refuses one
20736 /// that is not (H5Fsuper.c:1182-1192).
20737 #[test]
20738 fn a_paged_file_refuses_a_userblock_smaller_than_its_page() {
20739 let path = temp_path("fsm_paged_userblock");
20740 let Err(err) = Hdf5Writer::create_with_options(
20741 &path,
20742 FileCreateOptions {
20743 file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1),
20744 userblock: 512,
20745 ..Default::default()
20746 },
20747 ) else {
20748 panic!("a 512-byte userblock was accepted on a 4096-byte page");
20749 };
20750 assert!(
20751 format!("{err}").contains("multiple of its 4096-byte"),
20752 "{err}"
20753 );
20754 let _ = std::fs::remove_file(&path);
20755 }
20756
20757 /// A page size the builder names is the page the file is actually laid
20758 /// out in, not just a number the message repeats: every allocation is
20759 /// shaped by it and the file ends on one of its boundaries.
20760 #[test]
20761 fn a_file_created_at_a_non_default_page_size_allocates_by_it() {
20762 let path = temp_path("fsm_page_size_8k");
20763 {
20764 let w = Hdf5Writer::create_with_options(
20765 &path,
20766 FileCreateOptions {
20767 file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1)
20768 .with_page_size(8192),
20769 ..Default::default()
20770 },
20771 )
20772 .unwrap();
20773 let i = w
20774 .create_dataset("keep", DatatypeMessage::i32_type(), &[8])
20775 .unwrap();
20776 w.write_dataset_raw(i, &[0u8; 32]).unwrap();
20777 w.close().unwrap();
20778 }
20779 let info = read_only_append(&path)
20780 .free_space
20781 .as_deref()
20782 .expect("the created file declares a strategy")
20783 .info
20784 .clone();
20785 assert_eq!(info.page_size, 8192);
20786 assert_eq!(
20787 std::fs::metadata(&path).unwrap().len() % 8192,
20788 0,
20789 "the file ends on one of the pages it was created with"
20790 );
20791 let _ = std::fs::remove_file(&path);
20792 }
20793
20794 /// The page size is the fourth of the four properties `H5F__super_init`
20795 /// compares against the library defaults (H5Fsuper.c:1092-1097), so
20796 /// naming it is on its own enough to give a file the message — under the
20797 /// default strategy, which allocates without it.
20798 #[test]
20799 fn a_non_default_page_size_alone_gives_the_file_a_message() {
20800 let path = temp_path("fsm_page_size_only");
20801 {
20802 let w = Hdf5Writer::create_with_options(
20803 &path,
20804 FileCreateOptions {
20805 file_space: FileSpaceConfig::default().with_page_size(1024),
20806 ..Default::default()
20807 },
20808 )
20809 .unwrap();
20810 w.close().unwrap();
20811 }
20812 let info = declared_file_space(&path)
20813 .expect("a file naming only a page size still carries the message");
20814 assert_eq!(info.strategy, FileSpaceStrategy::FsmAggr);
20815 assert!(!info.persist);
20816 assert_eq!(info.page_size, 1024);
20817 let _ = std::fs::remove_file(&path);
20818 }
20819
20820 /// `H5Pset_file_space_page_size` refuses anything below 512 or above
20821 /// 1 GiB (H5Pfcpl.c:1389-1393), and nothing between: no power of two is
20822 /// required, so a size the bounds admit is one the file may carry.
20823 #[test]
20824 fn a_page_size_outside_the_library_bounds_is_refused() {
20825 for size in [0, 1, 511, PAGE_SIZE_MAX + 1] {
20826 let path = temp_path(&format!("fsm_page_size_bad_{size}"));
20827 let Err(err) = Hdf5Writer::create_with_options(
20828 &path,
20829 FileCreateOptions {
20830 file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1)
20831 .with_page_size(size),
20832 ..Default::default()
20833 },
20834 ) else {
20835 panic!("a {size}-byte file-space page was accepted");
20836 };
20837 assert!(
20838 format!("{err}").contains("between 512 bytes and 1073741824"),
20839 "{err}"
20840 );
20841 let _ = std::fs::remove_file(&path);
20842 }
20843 let path = temp_path("fsm_page_size_odd");
20844 let w = Hdf5Writer::create_with_options(
20845 &path,
20846 FileCreateOptions {
20847 file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1)
20848 .with_page_size(513),
20849 ..Default::default()
20850 },
20851 )
20852 .expect("513 is inside the bounds, and no power of two is required");
20853 w.close().unwrap();
20854 let _ = std::fs::remove_file(&path);
20855 }
20856
20857 /// A paged file's managers are read on reopen, the same as any other
20858 /// file's: paged aggregation changes which manager a request maps to, not
20859 /// whether the file has managers to rewrite.
20860 #[test]
20861 fn a_paged_file_reports_the_managers_it_persists() {
20862 let path = fixture_copy("fsm_persist_page.h5", "fsm_read_paged");
20863 let writer = Hdf5Writer::open_append(&path).unwrap();
20864 let fs = writer.free_space.as_deref().expect("no managers read");
20865 assert_eq!(fs.info.strategy, FileSpaceStrategy::Page);
20866 assert!(
20867 !writer.allocator.free_extents().is_empty(),
20868 "the sections the file records were not put back in circulation"
20869 );
20870 drop(writer);
20871 let _ = std::fs::remove_file(&path);
20872 }
20873
20874 /// A file with no file-space info message at all — every file this crate
20875 /// creates — has nothing to read and nothing to write back.
20876 #[test]
20877 fn a_file_without_a_strategy_has_no_managers() {
20878 let path = temp_path("fsm_none");
20879 {
20880 let w = Hdf5Writer::create(&path).unwrap();
20881 w.create_dataset("d", DatatypeMessage::f64_type(), &[4])
20882 .unwrap();
20883 w.close().unwrap();
20884 }
20885 let writer = Hdf5Writer::open_append(&path).unwrap();
20886 assert!(writer.free_space.is_none());
20887 drop(writer);
20888 let _ = std::fs::remove_file(&path);
20889 }
20890 /// Sum of the sections the managers a file names actually hold — what
20891 /// `h5stat -S` prints as "Amount of tracked free space", read back through
20892 /// this crate's own decoder so a test can assert on it. A reopen seeds the
20893 /// allocator with exactly those sections, so its free list is the number.
20894 fn tracked_free_space(path: &std::path::Path) -> u64 {
20895 read_only_append(path)
20896 .allocator
20897 .free_blocks()
20898 .iter()
20899 .map(|b| b.1)
20900 .sum()
20901 }
20902
20903 /// Open for append and mark the writer closed, so dropping it releases the
20904 /// file lock instead of finalizing and rewriting what is being inspected.
20905 fn read_only_append(path: &std::path::Path) -> Hdf5Writer {
20906 let mut w = Hdf5Writer::open_append(path).unwrap();
20907 w.closed = true;
20908 w
20909 }
20910
20911 /// Add one small dataset, the smallest append that still rewrites the root
20912 /// header, the superblock extension and — on a persisting file — the
20913 /// free-space manager.
20914 fn append_one(path: &std::path::Path, name: &str, disable_managers: bool) {
20915 let mut w = Hdf5Writer::open_append(path).unwrap();
20916 if disable_managers {
20917 // Both halves of the change, so the control is the file as this
20918 // crate wrote it before: the session neither allocates from the
20919 // recorded sections nor writes any back.
20920 w.free_space = None;
20921 w.allocator.reset_free_list(&[]);
20922 }
20923 let i = w
20924 .create_dataset(name, DatatypeMessage::i32_type(), &[8])
20925 .unwrap();
20926 w.write_dataset_raw(
20927 i,
20928 &(0..8i32).flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>(),
20929 )
20930 .unwrap();
20931 w.close().unwrap();
20932 }
20933
20934 /// The block list a reopen carries for the superblock extension covers
20935 /// every chunk of the header, not just the first. The fixture's extension
20936 /// is a two-chunk header — libhdf5 put the file-space info message in a
20937 /// continuation — and freeing chunk zero alone left the continuation
20938 /// allocated with nothing naming it.
20939 #[test]
20940 fn a_reopen_carries_every_chunk_of_the_superblock_extension() {
20941 let path = fixture_copy("fsm_persist.h5", "fsm_ext_chunks");
20942 let blocks = read_only_append(&path).extension.superseded.clone();
20943 assert!(
20944 blocks.len() > 1,
20945 "the fixture's extension is one chunk, so this proves nothing: {blocks:?}"
20946 );
20947 let _ = std::fs::remove_file(&path);
20948 }
20949
20950 /// An append on a persisting file both spends and records the space its
20951 /// managers track: the new dataset comes out of the sections the file
20952 /// already had, and what the rewrite frees goes back into them.
20953 #[test]
20954 fn an_append_reuses_and_records_the_space_the_managers_track() {
20955 let path = fixture_copy("fsm_persist.h5", "fsm_write");
20956 let original = std::fs::metadata(&path).unwrap().len();
20957 let before = tracked_free_space(&path);
20958 assert_eq!(before, 1910, "the fixture's own managers");
20959
20960 append_one(&path, "added", false);
20961 let size = std::fs::metadata(&path).unwrap().len();
20962 let tracked = tracked_free_space(&path);
20963
20964 // Negative control: the same append with both halves of this off — no
20965 // allocating out of the recorded sections and no writing any back —
20966 // which is what this crate did before it read free space at all.
20967 let control = fixture_copy("fsm_persist.h5", "fsm_write_control");
20968 append_one(&control, "added", true);
20969 let control_size = std::fs::metadata(&control).unwrap().len();
20970 assert_eq!(
20971 tracked_free_space(&control),
20972 before,
20973 "with the manager rewrite disabled the number must not move"
20974 );
20975
20976 // The new dataset's raw data comes out of the raw-data sections the
20977 // file already recorded, so the append grows the file by less than the
20978 // same append with the reuse off. It does not stop the growth:
20979 // `H5MF_alloc` asks one manager and no other, and of this fixture's
20980 // 1910 free bytes 1848 are raw-data ones, so the metadata the append
20981 // writes still comes from the end of the file.
20982 assert!(
20983 size < control_size,
20984 "the append took nothing from the {before} bytes free: \
20985 {original} grew to {size}, the control to {control_size}"
20986 );
20987 assert!(
20988 control_size > original,
20989 "the control has to grow or it proves nothing"
20990 );
20991 // Space no manager and no object claims — `h5stat -S`'s "unaccounted
20992 // space" — is what the leak was, and it is smaller now.
20993 assert!(
20994 size - tracked < control_size - before,
20995 "unaccounted space went from {} to {}",
20996 control_size - before,
20997 size - tracked
20998 );
20999
21000 for p in [&path, &control] {
21001 let _ = std::fs::remove_file(p);
21002 }
21003 }
21004
21005 /// The set the writer holds free when it finishes is exactly the set the
21006 /// manager it just wrote records — the invariant that makes the on-disk
21007 /// managers a faithful account of the file's free space.
21008 #[test]
21009 fn the_manager_records_the_free_list_the_close_ends_with() {
21010 let path = fixture_copy("fsm_persist.h5", "fsm_roundtrip");
21011 let internal = {
21012 let mut w = Hdf5Writer::open_append(&path).unwrap();
21013 let i = w
21014 .create_dataset("added", DatatypeMessage::i32_type(), &[8])
21015 .unwrap();
21016 w.write_dataset_raw(i, &[0u8; 32]).unwrap();
21017 w.finalize(true).unwrap();
21018 let blocks = w.allocator.free_extents();
21019 w.closed = true;
21020 blocks
21021 };
21022 assert!(!internal.is_empty(), "the append freed nothing");
21023
21024 // Classes included: a section read back out of the wrong manager is a
21025 // section libhdf5 would offer to the wrong kind of allocation.
21026 let reread = {
21027 let w = read_only_append(&path);
21028 assert!(w.free_space.is_some(), "managers were written");
21029 w.allocator.free_extents()
21030 };
21031 assert_eq!(internal, reread);
21032 let _ = std::fs::remove_file(&path);
21033 }
21034
21035 /// The paged half of
21036 /// [`the_manager_records_the_free_list_the_close_ends_with`]: a paged
21037 /// file's sections carry a page and a class as well as an address, and a
21038 /// section written into the wrong manager or split across a page boundary
21039 /// would come back different.
21040 #[test]
21041 fn the_manager_records_the_free_list_a_paged_close_ends_with() {
21042 let path = fixture_copy("fsm_persist_page.h5", "fsm_paged_roundtrip");
21043 let internal = {
21044 let mut w = Hdf5Writer::open_append(&path).unwrap();
21045 let i = w
21046 .create_dataset("added", DatatypeMessage::i32_type(), &[8])
21047 .unwrap();
21048 w.write_dataset_raw(i, &[0u8; 32]).unwrap();
21049 w.finalize(true).unwrap();
21050 let blocks = w.allocator.free_extents();
21051 w.closed = true;
21052 blocks
21053 };
21054 assert!(!internal.is_empty(), "the append freed nothing");
21055
21056 let reread = {
21057 let w = read_only_append(&path);
21058 assert!(w.free_space.is_some(), "managers were written");
21059 w.allocator.free_extents()
21060 };
21061 assert_eq!(internal, reread);
21062 let _ = std::fs::remove_file(&path);
21063 }
21064
21065 /// Negative control for the paged managers: with the read and the rewrite
21066 /// both off — the file as this crate handled a paged file before — the
21067 /// space the append frees is recorded nowhere, and the number this crate
21068 /// reads back is the fixture's own.
21069 #[test]
21070 fn a_paged_append_records_nothing_without_the_manager_rewrite() {
21071 let path = fixture_copy("fsm_persist_page.h5", "fsm_paged_measured");
21072 let control = fixture_copy("fsm_persist_page.h5", "fsm_paged_control");
21073 let before = tracked_free_space(&path);
21074 let original = std::fs::metadata(&path).unwrap().len();
21075
21076 append_one(&path, "added", false);
21077 append_one(&control, "added", true);
21078
21079 assert_eq!(
21080 tracked_free_space(&control),
21081 before,
21082 "the control moved the number it is there to hold still"
21083 );
21084 assert_eq!(
21085 std::fs::metadata(&path).unwrap().len(),
21086 original,
21087 "the append grew a paged file with {before} bytes recorded free"
21088 );
21089 assert!(
21090 std::fs::metadata(&control).unwrap().len() > original,
21091 "the control has to grow or it proves nothing"
21092 );
21093 assert_ne!(
21094 tracked_free_space(&path),
21095 before,
21096 "the managers came back holding what the fixture wrote"
21097 );
21098 for p in [&path, &control] {
21099 let _ = std::fs::remove_file(p);
21100 }
21101 }
21102
21103 /// A block released from a dataset's raw data is recorded by the manager
21104 /// `H5MF_ALLOC_TO_FS_AGGR_TYPE` maps `H5FD_MEM_DRAW` to, and nothing else
21105 /// is: the dichotomy the sec2 driver installs is what decides, and the two
21106 /// managers it collapses to are the file-space info message's slots 0 and
21107 /// 2.
21108 #[test]
21109 fn a_released_raw_block_lands_in_the_raw_data_manager() {
21110 let path = temp_path("fsm_dichotomy");
21111 {
21112 let w = Hdf5Writer::create_with_options(
21113 &path,
21114 FileCreateOptions {
21115 file_space: FileSpaceConfig::new(FileSpaceStrategy::FsmAggr, true, 1),
21116 ..Default::default()
21117 },
21118 )
21119 .unwrap();
21120 let i = w
21121 .create_dataset("bulk", DatatypeMessage::i32_type(), &[256])
21122 .unwrap();
21123 w.write_dataset_raw(i, &vec![0u8; 1024]).unwrap();
21124 w.create_dataset("keep", DatatypeMessage::i32_type(), &[8])
21125 .unwrap();
21126 w.close().unwrap();
21127 }
21128 let (raw_addr, raw_len) = {
21129 let w = read_only_append(&path);
21130 let i = w.dataset_index("bulk").unwrap();
21131 let ds = w.ds(i);
21132 let m = ds.lock();
21133 (m.data_addr, m.data_size)
21134 };
21135 assert!(raw_len >= 1024, "the raw block is {raw_len} bytes");
21136 {
21137 let w = Hdf5Writer::open_append(&path).unwrap();
21138 w.delete_dataset("bulk").unwrap();
21139 w.close().unwrap();
21140 }
21141
21142 let mut w = read_only_append(&path);
21143 let info = w
21144 .free_space
21145 .as_deref()
21146 .expect("the file persists managers")
21147 .info
21148 .clone();
21149 assert_ne!(info.fs_addr[0], UNDEF_ADDR, "no metadata manager");
21150 assert_ne!(info.fs_addr[2], UNDEF_ADDR, "no raw-data manager");
21151 for (slot, &addr) in info.fs_addr.iter().enumerate() {
21152 if slot != 0 && slot != 2 {
21153 assert_eq!(addr, UNDEF_ADDR, "slot {slot} names a manager");
21154 }
21155 }
21156
21157 let found = crate::io::free_space_io::read_managers(&mut w.handle, &w.ctx, &info).unwrap();
21158 let inside = |b: &FreeBlock| b.addr >= raw_addr && b.addr + b.len <= raw_addr + raw_len;
21159 let raw: Vec<&FreeBlock> = found
21160 .sections
21161 .iter()
21162 .filter(|b| b.manager == FreeSpaceManager::RawData)
21163 .collect();
21164 assert!(
21165 !raw.is_empty(),
21166 "the deleted dataset's bytes were not recorded"
21167 );
21168 assert!(
21169 raw.iter().all(|b| inside(b)),
21170 "a raw-data section is outside the deleted dataset's block: {raw:?}"
21171 );
21172 assert!(
21173 found
21174 .sections
21175 .iter()
21176 .filter(|b| b.manager == FreeSpaceManager::Metadata)
21177 .all(|b| !inside(b)),
21178 "raw-data bytes were recorded by the metadata manager"
21179 );
21180 drop(w);
21181 let _ = std::fs::remove_file(&path);
21182 }
21183
21184 /// A reopened paged file's managers are this writer's to rewrite, and the
21185 /// three the sec2 driver can reach are the only ones it names.
21186 ///
21187 /// `H5MF__alloc_to_fs_type` (H5MF.c:265) sends a request of at least one
21188 /// page to `H5F_MEM_PAGE_GENERIC` unless the driver declares
21189 /// `H5FD_FEAT_PAGED_AGGR`, which only the multi and split drivers do, so a
21190 /// sec2 file has the dichotomy's two small managers and that one large
21191 /// one: message slots 0, 2 and 6.
21192 #[test]
21193 fn a_paged_file_names_only_the_managers_sec2_can_reach() {
21194 let path = fixture_copy("fsm_persist_page.h5", "fsm_write_paged");
21195 assert!(
21196 read_only_append(&path).free_space.is_some(),
21197 "the paged fixture's managers were not read"
21198 );
21199 append_one(&path, "added", false);
21200
21201 let mut w = read_only_append(&path);
21202 let info = w
21203 .free_space
21204 .as_deref()
21205 .expect("the file persists managers")
21206 .info
21207 .clone();
21208 assert_eq!(info.strategy, FileSpaceStrategy::Page);
21209 for (slot, &addr) in info.fs_addr.iter().enumerate() {
21210 if !matches!(slot, 0 | 2 | 6) {
21211 assert_eq!(addr, UNDEF_ADDR, "slot {slot} names a manager");
21212 }
21213 }
21214 assert!(
21215 info.fs_addr.iter().any(|&a| a != UNDEF_ADDR),
21216 "the rewritten file records nothing free"
21217 );
21218 crate::io::free_space_io::read_managers(&mut w.handle, &w.ctx, &info).unwrap();
21219 drop(w);
21220 let _ = std::fs::remove_file(&path);
21221 }
21222
21223 /// Every section a paged file records sits inside one page, and the pages
21224 /// its small managers use are pages of their own kind — the invariant
21225 /// `H5MF__alloc_pagefs` maintains by giving each small request a whole
21226 /// page of its class and recording the rest of it in that class's manager.
21227 #[test]
21228 fn a_paged_files_small_sections_stay_inside_one_page_of_one_kind() {
21229 let path = fixture_copy("fsm_persist_page.h5", "fsm_paged_pages");
21230 append_one(&path, "added", false);
21231
21232 let mut w = read_only_append(&path);
21233 let info = w
21234 .free_space
21235 .as_deref()
21236 .expect("the file persists managers")
21237 .info
21238 .clone();
21239 let page = info.page_size;
21240 let found = crate::io::free_space_io::read_managers(&mut w.handle, &w.ctx, &info).unwrap();
21241 let mut kind_of_page: std::collections::HashMap<u64, FreeSpaceManager> =
21242 std::collections::HashMap::new();
21243 for section in &found.sections {
21244 if section.manager == FreeSpaceManager::Large {
21245 continue;
21246 }
21247 assert_eq!(
21248 section.addr / page,
21249 (section.addr + section.len - 1) / page,
21250 "the section at {:#x} crosses a page boundary",
21251 section.addr
21252 );
21253 let owner = kind_of_page
21254 .entry(section.addr / page)
21255 .or_insert(section.manager);
21256 assert_eq!(
21257 *owner,
21258 section.manager,
21259 "page {} holds sections of two kinds",
21260 section.addr / page
21261 );
21262 }
21263 drop(w);
21264 let _ = std::fs::remove_file(&path);
21265 }
21266}