hdf5_pure/edit.rs
1//! In-place editing of an existing HDF5 file (issue #32, Group C).
2//!
3//! The in-place edit engine opens an existing file and adds objects, overwrites dataset
4//! values, or edits compact group attributes **in place**:
5//! new data and object headers are written at the end of the file, and the
6//! object headers of the touched groups (and their ancestors up to the root)
7//! are rewritten — also appended — so the superblock ends up pointing at the
8//! new root header. Nothing already in the file is moved, so the cost is
9//! proportional to what you add, not to the file size — unlike the
10//! read-everything-then-rebuild path through [`FileBuilder`](crate::FileBuilder).
11//!
12//! Both new datasets, new (sub)groups, and group attribute edits are supported,
13//! at any existing group path. Adding into a nested group `/a/b` rewrites `b`'s
14//! header (with the new link), then `a`'s header (repointing its link to `b`'s
15//! new location), then the root's — "relocation up the tree". This is always
16//! safe for *additions* because no surviving object is relocated except the
17//! groups on the path being edited, and those are reachable only through links
18//! this same commit rewrites (the root through the superblock); absolute
19//! object-reference addresses to other objects stay valid.
20//!
21//! Deletion ([`Group::delete`](crate::Group::delete), the HDF5 `H5Ldelete`) is the mirror image:
22//! the parent group's header is rebuilt without the removed link, relocated up
23//! the tree the same way, and the unlinked object (and its subtree) is freed —
24//! its blocks are returned to a session-local free list (see below). A deletion
25//! and an addition at the *same* path in one commit is a **replacement** (issue
26//! #305): the link is removed from the rebuilt parent before the new object's is
27//! appended, and the one superblock write publishes both, so a rotating store
28//! expresses a rotation as one commit and the path is never momentarily absent.
29//! The new object's storage is still appended rather than laid over the old
30//! one's — the original stays live until the superblock repoint, which is what
31//! makes a crash during the rotation land on one side or the other — so the
32//! space the deletion released is what a *later* commit draws on, by reuse or by
33//! truncation.
34//! Object copy ([`File::copy`](crate::File::copy), the HDF5 `H5Ocopy`) deep-copies
35//! a source subtree — appending fresh copies of every object, repointing internal
36//! links and the contiguous data address — and links the copy in like an
37//! addition; the headers are reproduced from their verbatim message bytes, so
38//! datatypes, dataspaces, and attributes stay byte-exact. A chunked (and filtered)
39//! dataset is copied with its chunk payloads and filter pipeline preserved
40//! byte-for-byte, its index rebuilt at the new location. The same machinery,
41//! [`File::copy_from`](crate::File::copy_from), copies an object **across two open files** — the
42//! source being a separate [`File`](crate::File) reader rather than the file being
43//! edited. Because the copy is byte-for-byte, the cross-file path refuses anything
44//! that embeds a source-file absolute address (variable-length or reference data,
45//! a committed datatype), which an in-file copy keeps valid by sharing the source
46//! file's heaps and objects.
47//!
48//! Value overwrite ([`Dataset::write`](crate::Dataset::write), the HDF5 `H5Dwrite`) replaces
49//! an **existing** dataset's values. The replacement's datatype and shape must
50//! match the on-disk dataset (an overwrite, not a reshape or retype); contiguous,
51//! compact, and chunked (including filtered) datasets are all supported, the chunk
52//! geometry and filter pipeline taken from the on-disk header. A same-length
53//! contiguous overwrite is the cheapest edit there is — the new bytes go straight
54//! into the existing data block, so no header is rewritten and the superblock root
55//! is not flipped, and the synced data write is the commit's linearization point.
56//! A chunked overwrite takes the same in-place path when every (re-encoded) chunk
57//! still fits its slot — always for unfiltered storage (chunk sizes are fixed by
58//! the unchanged shape), and for filtered storage when the re-encoded chunks match.
59//! When a length differs (a resized contiguous block, a filtered chunk that no
60//! longer fits, or a compact dataset) the dataset's storage is rebuilt and its
61//! header relocated like an addition: the new data and a rewritten header are
62//! appended, the data-layout message is repointed, the old storage is freed, and
63//! the parent group's link is patched. A relocating overwrite of a dataset
64//! reachable through more than one hard link is refused, since only the one named
65//! link could be repointed at the moved header.
66//!
67//! # Scope
68//!
69//! It is deliberately strict: rather than silently produce a degraded file, it
70//! refuses with [`Error::EditUnsupported`] any case it cannot reproduce
71//! faithfully. Requirements:
72//!
73//! - The file uses 8-byte offsets/lengths. A **userblock** (non-zero base
74//! address, as every MATLAB v7.3 `.mat` file has) is supported: addresses are
75//! read and written relative to the base and the userblock bytes are preserved
76//! verbatim. Every edit works on a userblock file — value overwrites, additions
77//! of contiguous and chunked/filtered datasets, in-place and relocating
78//! overwrites of every layout (with the old storage reclaimed), object deletion
79//! (with base-aware subtree reclaim), in-file copy, cross-file copy into a
80//! userblock destination, group creation, compact attributes, and free-space
81//! reuse. The one userblock-specific limitation left is cross-file copy *from* a
82//! userblock source (the source must have base 0; see [`copy_from`](crate::File::copy_from)).
83//! Any superblock version (0–3) is accepted: a version 0/1
84//! (symbol-table) file is edited by converting each group on the edited path
85//! to the latest format and repointing the superblock's root symbol-table
86//! entry.
87//! - A version 2/3 group on an edited path stores its links compactly (not in a
88//! dense fractal heap); headers split across continuation chunks (as the
89//! reference C library often writes) are collapsed into a single chunk when
90//! rewritten. A version 1 group is converted to a compact-link v2 header,
91//! carrying its links and attributes over (other group messages — symbol
92//! table, modification time — are dropped); an attribute it cannot reproduce
93//! is refused.
94//! - An object that tracks **attribute creation order** (`track_order=True`,
95//! and everything netCDF-4 writes) is edited like any other: its 6-byte
96//! message records are walked and re-emitted, a new attribute takes the
97//! object's next creation index, an overwrite keeps the one it had, and a
98//! deletion leaves a gap rather than renumbering. A group that tracks **link**
99//! creation order works the same way: a link added to it takes the next index
100//! from the running maximum its Link Info message records, and a deletion
101//! leaves a gap there too. Only an addition that would send such a group's
102//! links *dense* is refused, since the creation-order B-tree that indexes them
103//! is not written here.
104//! - Added datasets may be contiguous *or* chunked, with any filter the
105//! whole-file writer supports (deflate, shuffle, fletcher32, scale-offset,
106//! LZF, ZFP), and may declare extensible (maximum, optionally unlimited)
107//! dimensions. A chunked dataset's data and index — and any filtered chunks —
108//! are produced by the same builder the whole-file writer uses and appended at
109//! end-of-file, so its object header is byte-identical to a freshly written
110//! one. A dataset may be empty (zero-element) under either storage, which is
111//! how an extensible dataset is created before the first
112//! [`append_staged`](crate::Dataset::append_staged) fills it: a contiguous one
113//! gets the undefined data address, a chunked one an index over zero chunks.
114//! A provenance dataset (`with_provenance`) is
115//! supported, its attributes computed the same way the whole-file writer
116//! computes them. A contiguous dataset may carry a variable-length-string
117//! payload (`with_vlen_strings`) or per-element object-reference targets
118//! (`with_path_references`); chunking either is not supported. A
119//! path-resolved reference may target any object this commit is not itself
120//! still writing (an ancestor group, a same-depth sibling group ordered
121//! later in the same commit, a copy destination or its interior, a
122//! `write_dataset` target, or an object this commit deletes) — targeting
123//! one of those is refused, up front and before any byte of the commit is
124//! written, rather than resolved to a stale or wrong address; a path that
125//! resolves nowhere at all becomes an undefined reference, matching the
126//! whole-file writer. Every
127//! added dataset must have a fixed-size datatype. Group, root, and **dataset**
128//! attribute edits (`set_group_attr` / `set_dataset_attr`) may be fixed-size or
129//! variable-length. An attribute set larger than an object header holds
130//! compactly — more than [`MAX_COMPACT_ATTRS`] of them, or one whose message
131//! overflows the header's 2-byte size field — is stored in a fractal heap, on
132//! an object this engine adds and on one whose edit takes it past the
133//! threshold alike, and an object already storing its attributes densely is
134//! rebuilt the same way ([`plan_attr_ops`], issue #102). A dataset attribute
135//! edit relocates the dataset header and so requires a single hard link.
136//! - A new group's parent must already exist or be created in the same session
137//! (each level created explicitly); intermediate groups are not auto-created.
138//! - Rows can be appended to an existing chunked, unlimited, Extensible-Array
139//! dataset **immediately and in place** with an in-place append (amortized O(1),
140//! crash-atomic, no `commit`), interleaved with the staged edits above. A
141//! target the fast path cannot handle — a userblock or pre-v2 file, an
142//! unallocated index, a non-Extensible-Array or multi-hard-link dataset, a
143//! a filtered dataset sitting on a partial trailing chunk — is refused with
144//! [`Error::AppendInPlaceUnsupported`]; use the staged `append_dataset` instead.
145//!
146//! # Free-space reuse (issue #21)
147//!
148//! Each commit vacates space: the object headers it rewrites are superseded, and
149//! a deletion abandons its target's blocks. Those regions are recorded in a
150//! session-local free list and drawn on by later writes in the same session —
151//! a new object is written into a fitting freed region instead of growing the
152//! file, and when freed space forms a run reaching end-of-file the file is
153//! physically truncated. The reuse is crash-safe: it only ever overwrites space
154//! freed by an *earlier*, already-durable commit (never space the current commit
155//! is mid-way through freeing), and truncation happens only after the superblock
156//! recording the smaller end-of-file is itself durable. A commit that fails
157//! before its repoint gives back what it drew, so a failed attempt costs the
158//! session nothing.
159//!
160//! Both write paths draw on that list, through allocators of their own. The
161//! immediate [`Dataset::append`](crate::Dataset::append) allocates its chunks and
162//! index blocks through [`Store::alloc_raw`], which
163//! [`WriteEngine::immediate_reuse_allowed`] gates: an append has no superblock
164//! repoint to publish its allocations with, so it reuses only where the space it
165//! overwrites is dead on the disk as it stands (issue #349). On a file that
166//! **persists** its managers a hole in the list is one the disk still advertises,
167//! so the append does not spend it directly: it takes a batch out of the
168//! published managers first
169//! ([`WriteEngine::reserve_for_immediate_append`], issue #387) and spends only
170//! that, which makes the on-disk record true of every byte it writes at every
171//! instant. What the session does not spend goes back before the managers are
172//! next rewritten; a session that dies in between strands the remainder, which is
173//! accounted to nobody and so can never be handed out twice. The SWMR writer
174//! reuses nothing at all, for a reason publication does not reach: its readers
175//! may still be inside the region. Everything
176//! a *commit* places goes through the other allocator
177//! ([`WriteEngine::reserve`] and [`WriteEngine::place`]), including a chunked
178//! dataset's data region and a dense attribute heap. Those carry addresses of
179//! their own, so they are sized before they are placed and then built for the
180//! address they got — which is why they can land in a freed region at all
181//! (issue #261). A paged file (`H5F_FSPACE_STRATEGY_PAGE`) draws only from free
182//! space of the page type it is placing, so reuse cannot make metadata and raw
183//! data share a page — except from pages that are *wholly* free, which hold
184//! nothing of either type to be mixed with and so may be opened for whichever
185//! type asks ([`PagedEdit::alloc_typed`]). The free-space rewrite a commit
186//! performs is placed through the same allocator where anything fits, rather than
187//! at end-of-file (which on a paged file costs a page of its own), and that is
188//! what keeps a delete-and-recreate workload from growing by a tail per commit
189//! (issue #286 for the paged tail, issue #358 for the flat one).
190//!
191//! Reclaim is best-effort and conservative. Contiguous and chunked datasets
192//! (chunk index plus chunk data) and whole group subtrees are reclaimed; a
193//! deleted object whose blocks cannot be enumerated exhaustively —
194//! variable-length global-heap storage, dense attribute/link heaps, a
195//! non–version-2 header, a version 2 B-tree chunk index — is left as dead bytes
196//! rather than risk freeing a region that is still in use; under-reclaiming only
197//! wastes space, while over-reclaiming would corrupt.
198//!
199//! On a paged file that conservatism extends to anything whose *page type* is not
200//! established. A file another writer produced records free space this one cannot
201//! place — the reference library's generic-large manager holds metadata and raw
202//! alike, and it puts a chunk index among its metadata where this crate puts one
203//! beside the chunk data. Such space is kept and written back where it was found,
204//! but never handed to an allocation
205//! ([`PagedEdit::unclassified`], [`WriteEngine::index_is_provably_raw`]), because
206//! placing a byte of the wrong kind in a page is the one thing paging exists to
207//! prevent and no reader would report it.
208//!
209//! Space this editor vacates and cannot place is *dead* rather than lost
210//! ([`PagedEdit::dead`]): out of use, not yet reusable. A page whose every byte is
211//! free or dead holds nothing of either type, so it is promoted whole into the
212//! free lists ([`PagedEdit::promote_whole_free_pages`]) — the same rule that lets
213//! one page type claim a wholly free page from the other. That is what bounds a
214//! paged file under delete-and-recreate churn: reclaim lands at page granularity
215//! where the page type cannot be shown, rather than not at all (issue #388).
216//!
217//! Whether the free list outlives the session depends on how the file was
218//! created. For the default (non-persisting) file it is **not** persisted: it is
219//! forgotten on close, so reuse and shrinkage apply to churn within a session,
220//! and a single delete-then-close shrinks the file only when the freed bytes
221//! reach end-of-file. A file created with
222//! `H5Pset_file_space_strategy(persist = true)` instead **persists** its free
223//! space: `open` seeds the list from the on-disk free-space managers (the
224//! `FSHD`/`FSSE` blocks the superblock-extension File Space Info message points
225//! at), and each commit rewrites those managers, so freed regions survive
226//! close/reopen and are reused across sessions — by this crate and the reference
227//! C library alike. Such a commit *records* the freed space it keeps rather than
228//! forgetting it; the blocks holding the managers go into space an earlier commit
229//! freed, or past all live data when none fits, and the superblock is repointed
230//! last, so a crash before the repoint leaves the prior file wholly intact.
231//!
232//! Freed space that reaches the end of the file is given back there too
233//! (issue #418): the commit lowers the end-of-allocation to where the run starts,
234//! drops those sections from the managers it writes, and truncates the file once
235//! the smaller superblock is durable — page-aligned on a paged file, so the end
236//! of allocation stays a whole number of pages. Two things bound it. A few tails'
237//! worth of the run stays ([`release_trailing_run`]), because the manager blocks
238//! are rewritten by every commit and can never land in their own predecessor's
239//! extent, so a file trimmed closer than that sends its next tail past the end of
240//! the file instead of into the reserve; and a run that would give back less than
241//! it keeps is left alone, since
242//! trimming the top off a hole the next write was about to fill costs a whole
243//! object to return a fraction of one. A commit whose own tail had to be appended
244//! *above* the run it freed takes a second, tail-only rewrite to move the blocks
245//! down into it ([`WriteEngine::shrink_to_the_trailing_free_run`]) — the delete
246//! case, where the freed regions only become writable once the repoint that
247//! freed them is durable. Whole-file compaction that reclaims every hole at once
248//! is still the separate repack path.
249
250use std::borrow::Cow;
251use std::collections::{BTreeMap, HashMap, HashSet};
252use std::fs;
253use std::io::{Read, Seek, SeekFrom};
254use std::path::Path;
255
256use core::num::NonZeroUsize;
257
258use crate::access_mode::AccessMode;
259use crate::address::{BaseAddress, StoredAddress};
260use crate::attribute_info::AttributeInfoMessage;
261use crate::checksum::jenkins_lookup3;
262use crate::chunk_index_inplace::{Located, Store, apply_ea_append, plan_ea_append};
263use crate::chunked_read::{
264 chunk_index_spans_from_source, enumerate_chunks_from_source, plan_dense_grid,
265};
266use crate::chunked_write::{
267 ChunkMeta, ChunkOptions, ChunkProvider, StorageAllocation, WrittenChunk, assemble_chunked_at,
268 build_extensible_array_at, chunked_data_len, compress_chunks, emit_chunked_data_verbatim,
269 extensible_array_len, full_chunk_bytes, plan_chunked_data_verbatim,
270 serialize_v4_extensible_array, split_into_chunks,
271};
272use crate::convert::Narrow;
273use crate::data_layout::{ChunkIndexLayout, DataLayout};
274use crate::dataspace::{Dataspace, DataspaceType, Extent, MaxExtent};
275use crate::datatype::{
276 Datatype, datatype_holds_file_address, datatype_holds_object_address, embedded_reference_slots,
277 stored_object_references,
278};
279use crate::error::{Error, FormatError, OBJECT_HEADER_MESSAGE_MAX};
280use crate::extensible_array::ExtensibleArrayHeader;
281use crate::file_create_properties::FileCreateProperties;
282use crate::file_lock::{self, FileLocking};
283use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy, NUM_FILE_FSM_MANAGERS};
284use crate::file_writer::{
285 DenseAttrCreationOrder, LENGTH_SIZE, OFFSET_SIZE, build_chunked_dataset_oh, build_dataset_oh,
286 make_link,
287};
288use crate::filter_pipeline::{
289 FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
290 FilterPipeline,
291};
292use crate::filters::{ChunkContext, FilterScratch, compress_chunk_with, decompress_chunk};
293use crate::free_space::{FreeList, trailing_run_start};
294use crate::free_space_manager::{
295 self, FreeSection, FsmHeader, PageType, PagedManagerPlan, SECT_CLASS_SIMPLE, align_up,
296 file_fsm_blocks_len, fshd_len, plan_paged_managers, serialize_file_fsm,
297};
298use crate::group_v2::resolve_group_entries_from_source;
299use crate::image::{FileImage, HandleImage, MirrorImage, WriteBuffering};
300use crate::libver::LibVer;
301use crate::link_info::LinkInfoMessage;
302use crate::link_message::{LinkMessage, LinkTarget};
303use crate::message_flags::MessageFlags;
304use crate::message_type::MessageType;
305use crate::object_header::ObjectHeader;
306use crate::object_path::{LinkNameBuf, ObjectPathBuf};
307use crate::reader::FileAccessProperties;
308use crate::shared_message::DatatypeLocation;
309use crate::source::{BaseOffsetSource, BytesSource, MetadataCacheConfig, Source};
310use crate::superblock::Superblock;
311use crate::type_builders::{
312 AttrValue, DatasetBuilder, ObjectRefPatch, ObjectRefTarget, VlStringStaging,
313 build_attr_message, build_global_heap_collections, make_f32_type, make_f64_type, make_i8_type,
314 make_i16_type, make_i32_type, make_i64_type, make_u8_type, make_u16_type, make_u32_type,
315 make_u64_type, patch_vl_refs, patch_vl_refs_masked, write_reference_address,
316};
317use crate::width::UintWidth;
318use crate::{DatatypeByteOrder, signature};
319
320/// An undefined on-disk address (all bits set), HDF5's "no address" sentinel.
321const UNDEF: u64 = u64::MAX;
322
323/// The most free space an immediate in-place append takes out of a persisting
324/// file's on-disk managers in one draw, when the allocation in front of it needs
325/// less (issue #387).
326///
327/// Every draw costs a manager rewrite and a superblock repoint — a few kilobytes
328/// and two ordering barriers — so drawing per allocation would put a commit
329/// between every pair of chunks and make reuse cost more than the growth it
330/// avoids. A draw therefore takes as much as this of whatever holes can each hold
331/// the allocation, largest first, and a megabyte covers sixteen chunks at the
332/// 64 KiB scale the C library's own chunk-cache default is sized for, which puts
333/// the rewrite well outside the per-chunk path.
334///
335/// It is a cap, not a floor. A file whose holes are all smaller than this still
336/// has them drawn, one rewrite for however many of them fit under the cap: a
337/// floor here made every persisting file whose deleted objects were under a
338/// megabyte grow without bound, since no hole it left was ever spent
339/// (issue #413). What a draw refuses is only a hole too small for the allocation
340/// in hand, which nothing could be placed in. Every hole exists because a commit
341/// freed it, so the rewrites spent reusing them are bounded by the commits the
342/// caller already paid for.
343///
344/// It bounds one draw, not a session's total. A fresh draw is made whenever the
345/// largest reserved region is smaller than the allocation in hand, and the
346/// remainders of earlier draws are kept and still handed out, so what an abrupt
347/// end strands is the sum of every remainder still held: each one smaller than
348/// the allocation that forced the next draw, and one more of them possible per
349/// draw. Larger would amortize further and strand more; this is the point where
350/// the rewrite is already rare enough that making it rarer buys little.
351const APPEND_RESERVE_BYTES: u64 = 1 << 20;
352
353/// The refusal both address-side reference screens report: an object reference
354/// this commit writes names an object the same commit removes.
355///
356/// One constant because the screens differ only in where the address came from
357/// — [`WriteEngine::resolve_reference_target`] takes it from a builder's
358/// [`ObjectRefTarget::Raw`], [`screen_resolved_references`] reads it out of a
359/// dataset's or an attribute's element bytes — and a caller must not be able to
360/// tell them apart by the message. It is the address-side twin of the by-name refusal
361/// `resolve_reference_target` reports for a path, and shares its second clause.
362const REFERENCE_INTO_RECLAIMED_SPACE: &str = "a reference this commit writes holds the address of an object this commit deletes, or of \
363 one under it; the reference would be left pointing at storage the delete can reclaim";
364
365/// The refusal for the other half of [`InvalidatedAddresses`]: the object is
366/// still there, but not at that address any more.
367///
368/// It can offer a way out, which the removal refusal cannot: the target still
369/// exists, so a path names it. Whether the path *works* depends on which half of
370/// [`InvalidatedAddresses::moved`] the target came from, which is why the message
371/// names separate commits as well rather than promising the first.
372/// [`WriteEngine::resolve_reference_target`] resolves a **dirty group** once this
373/// commit has placed it, and reports "still writing" until then; a **relocating
374/// dataset write** it refuses outright, by exact match against `write_targets`,
375/// with no later point at which that changes.
376const REFERENCE_TO_A_MOVED_OBJECT: &str = "a reference this commit writes holds the pre-commit address of an object this commit \
377 rewrites elsewhere; name the target by path (`with_path_references`) so it resolves to \
378 where the object lands, or use separate commits";
379
380/// A shared (SOHM) attribute message is held in the file's shared-message table
381/// rather than in the object whose attribute it is, so rewriting the object's
382/// attributes would have to account for the table as well. Both attribute paths
383/// refuse it by the same name: the compact one in [`parse_compact_attr_name`],
384/// which walks the messages it is about to copy, and the dense one in
385/// [`plan_attr_ops`] — where the set is read through
386/// [`crate::attribute::extract_attributes_full_from_source`], which *resolves*
387/// such a message and would otherwise re-emit it into the new heap as a private
388/// one, leaving the table's reference count naming an attribute that no longer
389/// exists.
390///
391/// Since issue #417 the reader follows such a reference, so these are live
392/// refusals rather than backstops: what they hold back is the *table*, whose
393/// reference count this engine cannot yet decrement, not the read. `repack`
394/// rewrites the file with every shared message inline, which is the way through
395/// today.
396const SHARED_ATTRIBUTE_MESSAGE: &str =
397 "a target object has a shared attribute message (not editable in place yet)";
398
399/// A shared-message (SOHM) index record does not always name a heap entry: where
400/// the reference C library shares a message written into an object header it
401/// already holds open, the record names *that header* and the message stays
402/// where it is. Such a record is a stored address like any other, and it is the
403/// one no rewrite reaches — the index lives outside every object a commit
404/// rebuilds, and this engine writes no shared-message indexes. A commit that
405/// removed or moved the named header would leave the file's own index pointing
406/// at bytes that are no longer there, which the next library to share a message
407/// would follow.
408const SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT: &str = "this file's shared-message (SOHM) index names an object header this commit \
409 removes or rewrites elsewhere, and the index cannot be updated in place; \
410 rewrite the file with `repack` instead";
411
412/// Refusal for an attribute whose references a commit repoints today and could
413/// not repoint from a heap. See [`plan_attr_ops`].
414const REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER: &str = "an attribute holding an object reference cannot be moved to dense \
415 (fractal-heap) storage, where a later commit could no longer repoint it when \
416 its target moves; keep this object within compact attribute storage";
417
418/// Maximum number of attributes an object header keeps inline; past this, HDF5
419/// switches the object to dense (fractal-heap) attribute storage, which this
420/// engine emits through [`plan_attr_ops`] and [`WriteEngine::place_dense_attrs`].
421///
422/// Taken from the whole-file writer's threshold rather than restated beside it:
423/// the two decide the same thing about the same attribute set, and a file whose
424/// objects were written by one and edited by the other must not disagree about
425/// where the eighth attribute lives.
426const MAX_COMPACT_ATTRS: usize = crate::file_writer::DENSE_ATTR_THRESHOLD;
427
428/// Recursion-depth cap for object copy, guarding against a stack overflow on a
429/// pathological or cyclic hard-link graph (HDF5 hard links can form cycles).
430/// Far deeper than any real group hierarchy.
431const MAX_COPY_DEPTH: u32 = 1000;
432
433/// Upper bound on the number of object headers walked when counting hard links
434/// across the file (issue #77 / reclaim safety). Far beyond any real file; a
435/// graph larger than this aborts the count, and the commit then leaves deleted
436/// objects unreclaimed (a safe leak) rather than risk an unbounded walk.
437const MAX_LINK_GRAPH_NODES: u32 = 1 << 24;
438
439/// Maximum number of object-header chunks to follow when gathering a header that
440/// spans continuation blocks, guarding against a cyclic continuation chain.
441/// Matches the reader's continuation-depth cap.
442const MAX_OH_CHUNKS: usize = 256;
443
444/// Maximum length of a version 2 object header's fixed prefix: signature (4) +
445/// version (1) + flags (1) + optional access/modification/change/birth times
446/// (16) + optional attribute phase-change thresholds (4) + the chunk-0 size
447/// field (up to 8). Reading this many bytes always covers the prefix, so
448/// [`oh_region_at`] can be handed one bounded window instead of a whole-file
449/// image.
450const OH_PREFIX_MAX: usize = 34;
451
452/// A live [`BufferedAppender`](crate::BufferedAppender)'s hold on a dataset.
453///
454/// The appender accepts elements into memory and is the only thing that can
455/// write them, so any staged edit that would make its flush refuse turns
456/// accepted data into lost data at drop time. The claim lets the engine refuse
457/// that edit up front instead.
458struct AppenderClaim {
459 /// Identifies this claim for release; ids are never reused within a session.
460 token: u64,
461 /// The appender's dataset path, or `None` for a handle reached by object
462 /// reference. Such a handle is named by object-header address, which *any*
463 /// staged edit may move, so a path-less claim conflicts with everything —
464 /// exactly the rule `append_prepare` already applies to that target.
465 path: Option<ObjectPathBuf>,
466}
467
468impl AppenderClaim {
469 /// Returns `true` if this claim covers the dataset `path` identifies, or if either side has
470 /// no path.
471 fn conflicts_with(&self, path: Option<&ObjectPathBuf>) -> bool {
472 match (&self.path, path) {
473 (Some(claimed), Some(candidate)) => claimed == candidate,
474 _ => true,
475 }
476 }
477}
478
479/// A variable-length group/root attribute staged by [`apply_compact_attr_ops`]
480/// and resolved in the apply loop.
481#[derive(Clone)]
482struct PendingVlAttr {
483 /// The attribute message, its global-heap references still placeholders.
484 msg: crate::attribute::AttributeMessage,
485 /// The collections whose real addresses those references need.
486 collections: Vec<Vec<u8>>,
487 /// The creation index this attribute keeps, taken from the message it
488 /// replaces on a header that tracks attribute creation order. `None` asks
489 /// for the object's next unused index when the message is appended — which
490 /// is every case on a header that does not track the order.
491 creation_index: Option<u16>,
492}
493
494/// Variable-length group/root attributes staged by [`apply_compact_attr_ops`].
495type PendingVlAttrs = Vec<PendingVlAttr>;
496
497/// Accumulates elements to append to an existing chunked, unlimited dataset via
498/// [`Dataset::append_staged`](crate::Dataset::append_staged), in call order along the dataset's first
499/// (axis-0) dimension.
500///
501/// It mirrors [`DatasetBuilder`]'s typed/generic vocabulary. Repeated typed or
502/// [`append_raw`](Self::append_raw) calls concatenate; each typed method also
503/// records the element datatype it implies, which `commit` checks against the
504/// dataset's on-disk datatype (a mismatch — including a mix of element types in
505/// one builder — is refused with [`Error::AppendUnsupported`], never written as
506/// garbage).
507pub struct AppendBuilder {
508 /// Accumulated little-endian element bytes to append, in call order.
509 raw: Vec<u8>,
510 /// The element datatype implied by the typed `append_*` calls, if any were
511 /// used. `None` when only [`append_raw`](Self::append_raw) was called (a raw
512 /// append is checked structurally — element-size alignment and little-endian
513 /// on-disk order — rather than by datatype equality).
514 elem_dt: Option<Datatype>,
515 /// Set when two typed calls implied different element datatypes; `commit`
516 /// refuses such a builder rather than write a mix of encodings.
517 dt_conflict: bool,
518}
519
520impl AppendBuilder {
521 pub(crate) fn new() -> Self {
522 Self {
523 raw: Vec::new(),
524 elem_dt: None,
525 dt_conflict: false,
526 }
527 }
528
529 /// Accumulated little-endian element bytes (for the general append writer,
530 /// which reuses this builder to gather typed/generic appends).
531 pub(crate) fn raw(&self) -> &[u8] {
532 &self.raw
533 }
534
535 /// The element datatype implied by typed appends, if any.
536 pub(crate) fn elem_dt(&self) -> Option<&Datatype> {
537 self.elem_dt.as_ref()
538 }
539
540 /// Whether two typed appends implied conflicting element datatypes.
541 pub(crate) fn dt_conflict(&self) -> bool {
542 self.dt_conflict
543 }
544
545 /// A builder holding a copy of this one's first `byte_len` bytes, carrying
546 /// the same element datatype so the prefix is type-checked exactly as the
547 /// whole would have been. Used by [`BufferedAppender`](crate::BufferedAppender)
548 /// to write out the chunk-aligned prefix of its buffer; it copies rather
549 /// than splits so a failed write leaves the buffer intact and the appender
550 /// can report precisely which elements did not land.
551 pub(crate) fn head(&self, byte_len: usize) -> Self {
552 Self {
553 raw: self.raw[..byte_len.min(self.raw.len())].to_vec(),
554 elem_dt: self.elem_dt.clone(),
555 dt_conflict: self.dt_conflict,
556 }
557 }
558
559 /// Discard the first `byte_len` buffered bytes (a prefix that reached the
560 /// file), keeping the element datatype.
561 pub(crate) fn drop_front(&mut self, byte_len: usize) {
562 self.raw.drain(..byte_len.min(self.raw.len()));
563 }
564
565 /// Consume the builder, yielding its accumulated element bytes. Used by
566 /// [`BufferedAppender::discard`](crate::BufferedAppender::discard) to hand
567 /// abandoned elements back to the caller.
568 pub(crate) fn into_raw(self) -> Vec<u8> {
569 self.raw
570 }
571
572 /// Cut the buffer back to `byte_len` bytes, keeping the element datatype.
573 /// [`BufferedAppender`](crate::BufferedAppender) uses this to undo a call
574 /// whose write was refused before it touched the file, so the refusal leaves
575 /// nothing buffered and a retry cannot append the same elements twice.
576 pub(crate) fn truncate(&mut self, byte_len: usize) {
577 self.raw.truncate(byte_len);
578 }
579
580 /// Record the datatype a typed append implies, flagging a conflict if an
581 /// earlier typed call implied a different one.
582 fn set_dt(&mut self, dt: Datatype) {
583 match &self.elem_dt {
584 Some(prev) if *prev != dt => self.dt_conflict = true,
585 Some(_) => {}
586 None => self.elem_dt = Some(dt),
587 }
588 }
589
590 /// Append already-little-endian element bytes verbatim. The concatenated
591 /// length must be a whole multiple of the dataset's on-disk element size, and
592 /// the dataset's element datatype must be little-endian; no datatype is
593 /// otherwise inferred. Prefer the typed methods when the element type is known.
594 pub fn append_raw(&mut self, bytes: &[u8]) -> &mut Self {
595 self.raw.extend_from_slice(bytes);
596 self
597 }
598
599 /// Generic append of a flat slice of any supported scalar type — the
600 /// counterpart of [`DatasetBuilder::with_data`](crate::DatasetBuilder::with_data).
601 pub fn append<T: crate::element::H5Element>(&mut self, data: &[T]) -> &mut Self {
602 T::append_into(self, data);
603 self
604 }
605}
606
607/// Generate the typed `append_*` methods: serialize each value little-endian and
608/// record the implied element datatype.
609macro_rules! append_typed {
610 ($($method:ident, $ty:ty, $make:ident;)*) => {
611 impl AppendBuilder {
612 $(
613 #[doc = concat!("Append `", stringify!($ty), "` values to the dataset.")]
614 pub fn $method(&mut self, data: &[$ty]) -> &mut Self {
615 self.set_dt($make());
616 // Reserved up front: serializing element by element into a
617 // growing `Vec` re-allocated it ten times per call and copied
618 // the batch twice over, which for an append loop is the whole
619 // per-call cost (issue #228).
620 self.raw.reserve(data.len() * core::mem::size_of::<$ty>());
621 for &v in data {
622 self.raw.extend_from_slice(&v.to_le_bytes());
623 }
624 self
625 }
626 )*
627 }
628 };
629}
630
631append_typed! {
632 append_f64, f64, make_f64_type;
633 append_f32, f32, make_f32_type;
634 append_i8, i8, make_i8_type;
635 append_i16, i16, make_i16_type;
636 append_i32, i32, make_i32_type;
637 append_i64, i64, make_i64_type;
638 append_u8, u8, make_u8_type;
639 append_u16, u16, make_u16_type;
640 append_u32, u32, make_u32_type;
641 append_u64, u64, make_u64_type;
642}
643
644/// Every edit a session has staged and not yet committed, as one value.
645///
646/// [`WriteEngine::commit`] takes the whole set out for the duration of an
647/// attempt and puts it back if that attempt refuses, so a refused commit costs
648/// the session no staged work — the same guarantee [`FreeSnapshot`] gives the
649/// free lists, for the same reason (issue #316). Keeping the vectors together
650/// rather than beside the engine's other fields is what makes that total: a
651/// staged kind added later participates by construction, where a tenth field
652/// would have to be remembered in a hand-written restore.
653#[derive(Default)]
654struct StagedEdits {
655 /// Datasets staged by `create_dataset`, as (parent group path, dataset).
656 ///
657 /// Flattened at staging rather than at commit: [`flatten_dataset`] is the
658 /// one step of the commit's preflight that *consumes* what it validates, so
659 /// running it here is what lets the preflight read the staged set without
660 /// destroying it (issue #316). It is a pure function of the builder, so the
661 /// guards it raises — a missing shape, data that does not match it, a
662 /// feature this engine cannot reproduce — are answered at the call that
663 /// stages the dataset, where the caller still has the context to fix them.
664 datasets: Vec<(ObjectPathBuf, FlatDataset)>,
665 /// Value overwrites staged by `write_dataset`, as (full dataset path,
666 /// dataset). Each replaces an existing dataset's values in place; the new
667 /// datatype and shape must match the on-disk ones byte-exactly (this is a
668 /// value overwrite, not a reshape/retype). Applied on the next `commit`.
669 /// Flattened at staging, for the reason given on [`datasets`](Self::datasets);
670 /// the match against the on-disk dataset is a commit-time check and stays
671 /// one, since it reads the file.
672 writes: Vec<(ObjectPathBuf, FlatDataset)>,
673 /// Appends staged by `append_dataset`, as (full dataset path, builder). Each
674 /// grows an existing chunked, unlimited, Extensible-Array-indexed dataset
675 /// along axis 0 by keeping its existing chunk data in place and rebuilding the
676 /// index over the kept plus newly-appended (and any rewritten trailing) chunks.
677 /// Applied on the next `commit`.
678 appends: Vec<(ObjectPathBuf, AppendBuilder)>,
679 /// New groups staged by `create_group`, as full paths.
680 groups: Vec<ObjectPathBuf>,
681 /// Group attribute edits staged as (group path, operation). The path may be
682 /// a group created in this same session.
683 group_attrs: Vec<(ObjectPathBuf, AttrOp)>,
684 /// Dataset attribute edits staged as (full dataset path, operation), applied
685 /// on the next `commit`. Each relocates the dataset's object header (like a
686 /// relocating overwrite): the header is rebuilt with the compact-attribute
687 /// change, its single naming link is patched, and the old header freed — the
688 /// dataset's data and chunk index stay in place. The target must be an existing,
689 /// single-hard-link dataset using compact (not dense fractal-heap) attributes.
690 dataset_attrs: Vec<(ObjectPathBuf, AttrOp)>,
691 /// Links staged for removal by `delete`, as full paths.
692 deletes: Vec<ObjectPathBuf>,
693 /// Object copies staged by `copy`, as (source path, destination full path).
694 copies: Vec<(ObjectPathBuf, ObjectPathBuf)>,
695 /// Cross-file object copies staged by `copy_from`, as (destination full path,
696 /// the source subtree already read out of the other file). The subtree is read
697 /// — and foreign-address-screened — eagerly in `copy_from` (the source file is
698 /// borrowed only for that call), then linked in at the next `commit`.
699 cross_copies: Vec<(ObjectPathBuf, CopyTree)>,
700 /// Where the creation at each full path sits in [`datasets`](Self::datasets)
701 /// / [`groups`](Self::groups), so a by-name question about the staged set
702 /// costs a hash rather than a scan of everything staged.
703 ///
704 /// The scan is what made a writer that stages one dataset per column and
705 /// then binds a handle to each quadratic in the number of columns (issue
706 /// #392). Both maps are derived from the vectors beside them and are kept in
707 /// step at the three places those change: the push that stages a creation,
708 /// the truncation [`rewind`](Self::rewind) makes, and the rebuild
709 /// [`withdraw_at`](Self::withdraw_at) makes after removing from the middle.
710 /// A path staged twice keeps its *first* position, which is the entry a scan
711 /// would have found; the commit refuses such a pair anyway.
712 dataset_at: HashMap<ObjectPathBuf, usize>,
713 group_at: HashMap<ObjectPathBuf, usize>,
714}
715
716/// What a session has staged at a path, for a handle addressing an object the
717/// commit has not written yet.
718#[derive(Clone, Copy, PartialEq, Eq, Debug)]
719pub(crate) enum StagedKind {
720 Group,
721 Dataset,
722}
723
724/// A creation this session has staged at a path, and whether it takes that path
725/// over from the file.
726#[derive(Clone, Copy, PartialEq, Eq, Debug)]
727pub(crate) struct StagedObject {
728 pub(crate) kind: StagedKind,
729 /// Whether this same commit removes the link the file holds at that path.
730 ///
731 /// True makes the creation a *replacement* (issue #305). False means the
732 /// file holds no link there at all — a creation colliding with one that
733 /// survives is refused where it is staged, and would not be reported here
734 /// either, since the file's own object is what the name means.
735 pub(crate) replaces_link: bool,
736}
737
738/// One direct child a group gains from this session's staged creations.
739pub(crate) struct StagedChild {
740 pub(crate) name: String,
741 pub(crate) kind: StagedKind,
742 /// Whether a staged deletion removes the link this name has in the file, so
743 /// the creation supersedes it rather than colliding with it. See
744 /// [`StagedObject::replaces_link`].
745 pub(crate) replaces_link: bool,
746}
747
748/// What a staged dataset can say about itself before it is written.
749///
750/// Everything here is settled when the dataset is staged — [`flatten_dataset`]
751/// has already validated the builder — so a handle answers from it without
752/// reading the file, and the answers are what the commit goes on to write.
753pub(crate) struct StagedMeta {
754 pub(crate) datatype: Datatype,
755 pub(crate) dimensions: Vec<u64>,
756 /// `None` for a fixed-shape dataset, matching `Dataset::maxshape`.
757 pub(crate) maxshape: Option<Vec<MaxExtent>>,
758 pub(crate) chunked: bool,
759 /// Each staged filter's registered id and its `H5Z_FLAG_OPTIONAL` flag, in
760 /// pipeline order.
761 pub(crate) filters: Vec<(u16, bool)>,
762}
763
764/// Where a [`StagedEdits`] stood before a batch of staging calls, so a batch
765/// that fails partway can be undone (see [`StagedEdits::rewind`]).
766#[derive(Clone, Copy, Default, PartialEq, Eq)]
767struct StagedMark {
768 datasets: usize,
769 writes: usize,
770 appends: usize,
771 groups: usize,
772 group_attrs: usize,
773 dataset_attrs: usize,
774 deletes: usize,
775 copies: usize,
776 cross_copies: usize,
777}
778
779impl StagedEdits {
780 /// The length of every staged vector right now, for
781 /// [`rewind`](Self::rewind).
782 ///
783 /// Destructured rather than read field by field, here and in
784 /// [`rewind`](Self::rewind), so that the "a staged kind added later
785 /// participates by construction" claim on [`StagedEdits`] is enforced rather
786 /// than hoped for: a struct pattern naming fewer fields than the struct has
787 /// does not compile, and the unused binding a tenth kind would leave in
788 /// `rewind` is an error under the crate's `-D warnings`.
789 fn mark(&self) -> StagedMark {
790 let Self {
791 datasets,
792 writes,
793 appends,
794 groups,
795 group_attrs,
796 dataset_attrs,
797 deletes,
798 copies,
799 cross_copies,
800 // Derived from `datasets` and `groups`, and restored with them.
801 dataset_at: _,
802 group_at: _,
803 } = self;
804 StagedMark {
805 datasets: datasets.len(),
806 writes: writes.len(),
807 appends: appends.len(),
808 groups: groups.len(),
809 group_attrs: group_attrs.len(),
810 dataset_attrs: dataset_attrs.len(),
811 deletes: deletes.len(),
812 copies: copies.len(),
813 cross_copies: cross_copies.len(),
814 }
815 }
816
817 /// Drop everything staged since `mark`.
818 ///
819 /// Staging inside a batch only ever appends — every `stage_*` entry point
820 /// validates and then pushes — so truncating to the recorded lengths is an
821 /// exact undo of the calls made in between, and leaves anything staged
822 /// before them alone. The two operations that instead *change* what is
823 /// already staged — the fold
824 /// [`WriteEngine::stage_dataset_append_pending`] makes into a staged
825 /// creation, and the withdrawal [`withdraw_at`](Self::withdraw_at) makes for
826 /// [`WriteEngine::delete`] — would leave that untrue, so
827 /// [`WriteEngine::refuse_mid_batch`] refuses them while a batch is open
828 /// rather than leaving the claim to be hoped for.
829 ///
830 /// The by-path index is truncated with the vectors it describes, so a
831 /// rewound creation stops answering as well as stops existing.
832 fn rewind(&mut self, mark: StagedMark) {
833 for i in mark.datasets..self.datasets.len() {
834 let key = {
835 let (parent, fd) = &self.datasets[i];
836 parent.join(&fd.name)
837 };
838 // Only where it still points at the entry being dropped: a second
839 // creation at one path never displaced the first.
840 if self.dataset_at.get(&key) == Some(&i) {
841 self.dataset_at.remove(&key);
842 }
843 }
844 for i in mark.groups..self.groups.len() {
845 let key = self.groups[i].clone();
846 if self.group_at.get(&key) == Some(&i) {
847 self.group_at.remove(&key);
848 }
849 }
850 let StagedMark {
851 datasets,
852 writes,
853 appends,
854 groups,
855 group_attrs,
856 dataset_attrs,
857 deletes,
858 copies,
859 cross_copies,
860 } = mark;
861 self.datasets.truncate(datasets);
862 self.writes.truncate(writes);
863 self.appends.truncate(appends);
864 self.groups.truncate(groups);
865 self.group_attrs.truncate(group_attrs);
866 self.dataset_attrs.truncate(dataset_attrs);
867 self.deletes.truncate(deletes);
868 self.copies.truncate(copies);
869 self.cross_copies.truncate(cross_copies);
870 }
871
872 /// Stage a dataset creation, indexing it by its full path.
873 fn push_dataset(&mut self, parent: ObjectPathBuf, fd: FlatDataset) {
874 self.dataset_at
875 .entry(parent.join(&fd.name))
876 .or_insert(self.datasets.len());
877 self.datasets.push((parent, fd));
878 }
879
880 /// Stage a group creation, indexing it by its path.
881 fn push_group(&mut self, path: ObjectPathBuf) {
882 self.group_at
883 .entry(path.clone())
884 .or_insert(self.groups.len());
885 self.groups.push(path);
886 }
887
888 /// Where the staged dataset at `path` sits, if there is one.
889 ///
890 /// The position the index gives is checked against the entry it names
891 /// rather than trusted: an index that fell behind the vector then reads as a
892 /// miss — the answer a scan would give — instead of as another dataset.
893 fn dataset_position(&self, path: &ObjectPathBuf) -> Option<usize> {
894 let i = *self.dataset_at.get(path)?;
895 let (parent, fd) = self.datasets.get(i)?;
896 path.covers_child(parent, &fd.name).then_some(i)
897 }
898
899 /// The staged dataset at `path`, if there is one.
900 fn dataset_at(&self, path: &ObjectPathBuf) -> Option<&FlatDataset> {
901 let i = self.dataset_position(path)?;
902 self.datasets.get(i).map(|(_, fd)| fd)
903 }
904
905 /// The staged dataset at `path`, mutably.
906 fn dataset_at_mut(&mut self, path: &ObjectPathBuf) -> Option<&mut FlatDataset> {
907 let i = self.dataset_position(path)?;
908 self.datasets.get_mut(i).map(|(_, fd)| fd)
909 }
910
911 /// Whether a group creation is staged at `path`. Checked against the entry
912 /// the index names, for the reason [`dataset_position`](Self::dataset_position)
913 /// gives.
914 fn has_group_at(&self, path: &ObjectPathBuf) -> bool {
915 match self.group_at.get(path) {
916 Some(&i) => self.groups.get(i).is_some_and(|p| p == path),
917 None => false,
918 }
919 }
920
921 /// Whether a staged deletion removes the link at `path`, or the link to an
922 /// ancestor that carries it away.
923 fn deletes_cover(&self, path: &ObjectPathBuf) -> bool {
924 self.deletes.iter().any(|d| path.starts_with(d))
925 }
926
927 /// Whether a staged deletion hands `path` over to a creation staged there —
928 /// which is what makes the pair a *replacement* rather than a collision
929 /// (issue #305).
930 ///
931 /// Stricter than [`deletes_cover`](Self::deletes_cover), and deliberately:
932 /// a deletion of an *ancestor* only carries the name over when this same
933 /// session builds that ancestor again, which is exactly what the commit
934 /// requires of everything at or under a replaced path. Deleting `g` and
935 /// creating `g/x` without recreating `g` is a batch the commit refuses, so
936 /// the file's own `g/x` is still what that name means — reporting it as a
937 /// replacement would make a live, readable dataset answer
938 /// [`Error::NotCommitted`] and vanish from its group's listing until the
939 /// refusal.
940 ///
941 /// The exact deletion is the empty case of the same rule: with `d == path`
942 /// there is no ancestor between them to have been recreated.
943 fn deletes_hand_over(&self, path: &ObjectPathBuf) -> bool {
944 self.deletes.iter().any(|d| {
945 path.starts_with(d) && (d.len()..path.len()).all(|n| self.has_group_at(&path.prefix(n)))
946 })
947 }
948
949 /// Withdraw every edit staged at or under `path`, as though the calls that
950 /// staged them had never been made, and report whether a *creation* was
951 /// among them.
952 ///
953 /// This is what lets [`WriteEngine::delete`] cancel an object this session
954 /// staged and has not written: there is nothing in the file to unlink, so a
955 /// deletion of it is a withdrawal. Deletions are deliberately left alone —
956 /// one under a withdrawn creation still names a link the file holds, and two
957 /// overlapping deletions are the commit's refusal to make rather than this
958 /// call's.
959 ///
960 /// Destructured for the reason [`mark`](Self::mark) is: a staged kind added
961 /// later has to be classified here rather than silently survive a
962 /// withdrawal.
963 fn withdraw_at(&mut self, path: &ObjectPathBuf) -> bool {
964 let before = {
965 let Self {
966 datasets,
967 writes,
968 appends,
969 groups,
970 group_attrs,
971 dataset_attrs,
972 deletes,
973 copies,
974 cross_copies,
975 dataset_at: _,
976 group_at: _,
977 } = self;
978 let before = datasets.len() + groups.len();
979 datasets.retain(|(parent, fd)| !path.covers_child(parent, &fd.name));
980 writes.retain(|(p, _)| !p.starts_with(path));
981 appends.retain(|(p, _)| !p.starts_with(path));
982 groups.retain(|p| !p.starts_with(path));
983 group_attrs.retain(|(p, _)| !p.starts_with(path));
984 dataset_attrs.retain(|(p, _)| !p.starts_with(path));
985 copies.retain(|(_, dst)| !dst.starts_with(path));
986 cross_copies.retain(|(dst, _)| !dst.starts_with(path));
987 let _ = &deletes;
988 before
989 };
990 let withdrew = self.datasets.len() + self.groups.len() < before;
991 if withdrew {
992 self.reindex();
993 }
994 withdrew
995 }
996
997 /// Rebuild the by-path index from the vectors, after a removal from the
998 /// middle shifted every position after it.
999 fn reindex(&mut self) {
1000 self.dataset_at.clear();
1001 self.group_at.clear();
1002 for (i, (parent, fd)) in self.datasets.iter().enumerate() {
1003 self.dataset_at.entry(parent.join(&fd.name)).or_insert(i);
1004 }
1005 for (i, path) in self.groups.iter().enumerate() {
1006 self.group_at.entry(path.clone()).or_insert(i);
1007 }
1008 }
1009
1010 /// Whether nothing at all is staged.
1011 ///
1012 /// Asked as "is the mark the zero mark", which is exact — nothing is staged
1013 /// exactly when every vector has length zero — and leaves [`mark`](Self::mark)
1014 /// as the single place that names every vector. The two callers,
1015 /// [`WriteEngine::has_staged_edits`] and the commit's own no-op return, then
1016 /// cannot come to disagree, and a staged kind added later reaches both. (The
1017 /// commit's *fast path* asks a narrower question and spells its own subset
1018 /// out.)
1019 fn is_empty(&self) -> bool {
1020 self.mark() == StagedMark::default()
1021 }
1022}
1023
1024/// The in-place write engine behind the owned read-write [`File`](crate::File)
1025/// (its `Backend::Edit`).
1026///
1027/// Reads and edits the file through a [`FileImage`], which owns the writable
1028/// handle and decides how much of the file is resident. It carries two commit
1029/// models: staged tree edits applied by [`commit`](Self::commit), and immediate
1030/// crash-atomic in-place appends ([`append_inplace_gathered`](Self::append_inplace_gathered)).
1031pub(crate) struct WriteEngine {
1032 /// The file bytes this session reads and edits, behind the [`FileImage`]
1033 /// abstraction: reads go through its [`Source`] impl, and the write side —
1034 /// the end-of-file cursor, `append`, `write_at`, `truncate`, and the
1035 /// durability barriers — through its own primitives.
1036 ///
1037 /// Nothing in the engine assumes the whole file is resident, so one engine
1038 /// serves both a whole-file mirror and a file-backed image that holds only
1039 /// what it is reading (issue #198). [`image_slice`](Self::image_slice)
1040 /// exposes the mirror's buffer where a caller can exploit it.
1041 image: Box<dyn FileImage>,
1042 /// Absolute offset of the superblock signature in the file.
1043 sb_sig_off: u64,
1044 /// Parsed superblock. On-disk addresses are stored relative to `base_address`;
1045 /// the in-memory `root_group_address` is normalized to an absolute file offset
1046 /// on open and converted back to a base-relative address when serialized on
1047 /// commit. `base_address` equals the superblock's file location (`sb_sig_off`):
1048 /// 0 for a plain file, the userblock size for one with a userblock.
1049 superblock: Superblock,
1050 /// Every edit this session has staged and not yet applied. Held as one
1051 /// value so that [`commit`](WriteEngine::commit) can take the whole set out
1052 /// for the duration of an attempt and put it back when that attempt
1053 /// refuses; see [`StagedEdits`].
1054 staged: StagedEdits,
1055 /// Datasets with a live [`BufferedAppender`](crate::BufferedAppender), which
1056 /// holds accepted elements only it can write. A staged edit that would stop
1057 /// that appender from flushing is refused while the claim stands, rather
1058 /// than left to fail in the appender's `Drop`, where there is no caller to
1059 /// report it to and the buffer is simply lost. See `refuse_if_claimed`.
1060 appender_claims: Vec<AppenderClaim>,
1061 /// Monotonic id for the next claim, so releasing one is exact even when two
1062 /// appenders on different datasets are live at once.
1063 next_appender_token: u64,
1064 /// Session-local free-space tracker (issue #21). Holds regions vacated by
1065 /// prior commits in this session — superseded object headers and the blocks
1066 /// of deleted objects — so later commits reuse them instead of growing the
1067 /// file, and so a freed run reaching end-of-file can be truncated away. It
1068 /// starts empty on `open` for a non-persisting file: holes already present
1069 /// from earlier sessions or other tools are not tracked. When the file
1070 /// persists its free space (`persist` is `Some`), `open` instead seeds it
1071 /// from the on-disk free-space managers, so reuse spans sessions.
1072 free: FreeList,
1073 /// Space this session has taken *out* of the on-disk free-space managers so
1074 /// that an immediate in-place append may spend it (issue #387).
1075 ///
1076 /// Only a file that persists its free space uses this. Such a file records
1077 /// its holes on disk, and an append has no superblock repoint of its own to
1078 /// publish an allocation with — so an append that simply drew from
1079 /// [`free`](Self::free) would leave a manager advertising bytes a live chunk
1080 /// now occupies, through a clean close as much as a crash. The reserve is
1081 /// the answer: [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
1082 /// moves a batch of bytes out of `free` (or, on a paged file, out of
1083 /// [`PagedEdit`]'s raw list) and rewrites the managers *without* them, under
1084 /// the same crash-atomic superblock repoint every persisting commit uses.
1085 /// Only then may an append write there, because by then no durable record
1086 /// calls those bytes free.
1087 ///
1088 /// What is left unspent goes back — into `free` or `PagedEdit` — before the
1089 /// managers are next rewritten, which is [`release_reserve`](Self::release_reserve)
1090 /// at the next commit and at close. A session that dies in between strands
1091 /// the unspent remainder: it is accounted to nobody, so it is never handed
1092 /// out twice, and a later `H5repack` or whole-file rewrite recovers it. That
1093 /// is the same trade the module header records for
1094 /// [`finalize_persist`](Self::finalize_persist), on a surface bounded per
1095 /// draw — see [`APPEND_RESERVE_BYTES`], which is the most one draw takes,
1096 /// and note that this list holds the unspent remainder of every draw the
1097 /// session has made, not just the last.
1098 ///
1099 /// Empty for a non-persisting session, which reuses out of `free` directly:
1100 /// its list is in memory alone, so nothing on the disk claims those bytes
1101 /// are free and nothing has to be published before they are spent.
1102 reserved: FreeList,
1103 /// Whether this file has been *proved* to hold no object reference at all —
1104 /// the one thing that lets a commit skip the walk that repoints them
1105 /// (issue #324).
1106 ///
1107 /// Set by a walk that reaches every object and finds no reference-holding
1108 /// datatype; every later commit in this session then skips the walk
1109 /// outright, which matters because the walk is linear in the file's object
1110 /// count and the file that needs it is the exception.
1111 ///
1112 /// One bit rather than three states, because "not yet walked" and "walked
1113 /// and found one" are the same instruction to the caller: walk. Only the
1114 /// proof is worth carrying.
1115 ///
1116 /// Cleared by any commit that could introduce a reference the walk has not
1117 /// seen. Exactly one of [`StagedEdits`]' nine collections can: `datasets`,
1118 /// which carries a datatype and attributes the caller chose. The check
1119 /// covers four, and the other three are belt-and-braces rather than
1120 /// load-bearing — recorded here because a reason that does not hold is worse
1121 /// than no reason:
1122 ///
1123 /// - `writes` is a *value* overwrite, whose datatype must match the on-disk
1124 /// dataset's exactly. A reference-typed one therefore overwrites a dataset
1125 /// that already held references, which a file proved free of them does not
1126 /// have. Checked anyway, because that match is a commit-time check running
1127 /// after this one, so `fd.dt` here is still whatever the caller supplied.
1128 /// - `copies` re-emits an object *from this same file* — one the walk that
1129 /// granted the proof had already visited and found reference-free.
1130 /// - `cross_copies` reads from another file, where `reject_foreign_addresses`
1131 /// refuses a reference datatype outright.
1132 ///
1133 /// The remaining five cannot, and these reasons do hold:
1134 ///
1135 /// - `groups` stages a bare path: no datatype, no data, no attributes.
1136 /// - `group_attrs` and `dataset_attrs` carry an [`AttrValue`], which has no
1137 /// reference variant — the reason a reference-typed attribute can only
1138 /// have come from the reference C library.
1139 /// - `appends` grows an *existing* dataset along its own datatype, so it can
1140 /// only add references to a dataset that already held them.
1141 /// - `deletes` removes objects and adds nothing.
1142 ///
1143 /// The commit fast path returns before the check and needs no reason of its
1144 /// own: it is taken only when every staged edit is a same-length in-place
1145 /// overwrite, which is the `writes` case above.
1146 ///
1147 /// A tenth collection would have to be classified the same way; the check
1148 /// sits beside the reference screen in `commit`, which iterates the same
1149 /// datatype-carrying collections.
1150 proved_free_of_references: bool,
1151 /// Free-space persistence read from the file's superblock extension on
1152 /// `open` (the file-creation `H5Pset_file_space_strategy(persist = true)`
1153 /// setting). `None` for the default non-persisting file; when `Some`, every
1154 /// [`commit`](Self::commit) rewrites the on-disk free-space managers so the
1155 /// free list survives close/reopen.
1156 persist: Option<PersistState>,
1157 /// Per-dataset geometry cache for the immediate O(1) in-place append
1158 /// ([`append_inplace_gathered`](Self::append_inplace_gathered)), keyed by the dataset's resolved
1159 /// **object-header address** (not its path, so two hard links to one dataset
1160 /// share one entry). Populated on the first append to a dataset and maintained
1161 /// across appends; cleared wholesale at the entry of every non-trivial
1162 /// [`commit`](Self::commit), since a commit can relocate a cached header or
1163 /// free the region it points into (see `commit`).
1164 located: HashMap<u64, LocatedState>,
1165 /// Global heap collections **this session placed** for a variable-length
1166 /// value overwrite, by the overwritten dataset's path, as absolute
1167 /// `(address, length)` extents.
1168 ///
1169 /// This is what lets the *next* overwrite of the same dataset reclaim the
1170 /// strings the previous one left, instead of growing the file by its whole
1171 /// payload on every commit forever (issue #321). Nothing else in the editor
1172 /// reclaims a collection, and the reason is sound: a collection can be
1173 /// shared between objects, and **nothing in the format proves otherwise**.
1174 /// The per-object reference count does not: an in-file
1175 /// [`copy`](Self::copy) of a variable-length dataset re-emits its
1176 /// element references verbatim, so two datasets name one collection with
1177 /// every object's count still 1 — and files with that shape are already in
1178 /// the wild.
1179 ///
1180 /// Provenance is therefore the whole proof: these collections were placed by
1181 /// this session, for this dataset, and were named by nothing else at the
1182 /// moment they were written. `invalidate_heap_provenance` is what keeps that
1183 /// true afterwards — every entry is dropped as soon as this session does
1184 /// anything that could name one of them a second time.
1185 ///
1186 /// The collections a dataset held when the session *opened* are never in
1187 /// here and are never reclaimed: their provenance is whatever wrote the
1188 /// file. `repack` is what recovers those.
1189 vl_overwrite_heaps: HashMap<ObjectPathBuf, Vec<(u64, u64)>>,
1190 /// Heap collections superseded by a value overwrite this commit is applying,
1191 /// freed once the commit's superblock repoint has landed — never before, so
1192 /// a mid-commit crash leaves the prior root reaching bytes that are still
1193 /// there. Drained into the commit's `to_free` list; cleared at commit entry,
1194 /// so an attempt that fails partway frees nothing on the next one.
1195 superseded_heaps: Vec<(u64, u64)>,
1196 /// The bytes each same-length in-place overwrite of the commit in flight
1197 /// wrote over, so a commit that fails before its linearization point can put
1198 /// the dataset's values back (issue #344).
1199 ///
1200 /// Everything else a commit places is written where nothing reaches it until
1201 /// the superblock is repointed, so an attempt that stops short of the
1202 /// repoint is invisible. A same-length value overwrite is the exception:
1203 /// [`WritePlan::InPlace`] writes straight over the dataset's existing data
1204 /// block, which the *current* root already reaches, so there is no repoint
1205 /// to withhold and the new values are live the moment they land. This
1206 /// journal is how they are withheld anyway.
1207 ///
1208 /// Written only by
1209 /// [`write_inplace_journaled`](Self::write_inplace_journaled) and drained
1210 /// only by [`undo_inplace_writes`](Self::undo_inplace_writes). Every
1211 /// [`commit`](Self::commit) that returns leaves it empty, and one that
1212 /// unwinds does not, which is why the next commit clears it on entry rather
1213 /// than trusting it.
1214 ///
1215 /// **It costs a full copy of every value being replaced, read back off the
1216 /// file first.** Measured on the bounded backing, whose contract is not to
1217 /// hold the file in memory: a same-length overwrite of a 4 MB dataset made
1218 /// its commit read 4,000,197 bytes where the same commit without the
1219 /// journal read 197, and held the 4 MB until the commit ended. That is the
1220 /// price of the guarantee and it is charged on the success path too, since
1221 /// whether the bytes will be needed is not known until the attempt ends.
1222 /// `a_bounded_commit_reads_the_value_it_is_replacing` states the rule so
1223 /// the number is in the repository rather than a surprise.
1224 ///
1225 /// The alternative — planning a same-length overwrite as a relocating write,
1226 /// as a variable-length one already is — would cost nothing here and was
1227 /// rejected for a harder reason than cost: a relocating write is refused for
1228 /// a dataset with more than one hard link
1229 /// ([`count_incoming_hard_links`](Self::count_incoming_hard_links)), so it
1230 /// would start refusing overwrites this engine accepts today.
1231 inplace_undo: Vec<(u64, Vec<u8>)>,
1232 /// True when this engine was opened for SWMR writing
1233 /// ([`open_swmr_writer`](Self::open_swmr_writer)): the append engine then
1234 /// enforces the SWMR subset (unfiltered, chunk-aligned) so a concurrent
1235 /// reader never observes a torn view. `false` for an ordinary edit session.
1236 swmr_mode: bool,
1237 /// Paged-file state (`H5F_FSPACE_STRATEGY_PAGE`), read from the superblock
1238 /// extension at `open` regardless of the persist flag; `None` for the common
1239 /// non-paged file. When `Some`, [`commit`](Self::commit) takes a page-aware
1240 /// tail that keeps pages homogeneous and rewrites the per-page-type managers
1241 /// (issue #198). A paged file that does not *persist* its free space is still
1242 /// refused: see [`PagedEdit`].
1243 paged: Option<PagedEdit>,
1244 /// Set by the first [`commit`](Self::commit) that does any work. A commit can
1245 /// relocate an object header, and nothing on disk distinguishes a relocated
1246 /// header from the intact bytes it vacated — the old header still parses, and
1247 /// its data-layout message still points at the live chunk index. An
1248 /// [`AppendTarget::Header`] captured before that commit would therefore append
1249 /// successfully *into the dead header*, growing its dataspace while the live
1250 /// dataset stayed put, and report `Ok`. A path is re-resolved on every append
1251 /// and so survives a commit; a raw address does not, so it is refused once one
1252 /// has run.
1253 committed: bool,
1254 /// Object-header address for each path an in-place append has resolved in
1255 /// this session. A single `Dataset::append` asks for the target's geometry
1256 /// and then appends to it, and a loop of appends repeats that; without this
1257 /// the path would be walked from the root on every one of those steps.
1258 ///
1259 /// An in-place append never moves an object header — that is why
1260 /// [`located`](Self::located) can be keyed by address and survive appends —
1261 /// so only a commit can stale an entry, and it clears both together.
1262 resolved: HashMap<ObjectPathBuf, u64>,
1263 /// Whether this session splits a large in-place append into batches, trading
1264 /// whole-call crash atomicity for a peak memory that does not scale with the
1265 /// call. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy); see
1266 /// [`batch_elems`](Self::batch_elems).
1267 batched_appends: bool,
1268 /// Whether this session reads through a handle rather than a whole-file
1269 /// mirror. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy), and reported by
1270 /// [`File::edit_backing`](crate::File::edit_backing) so a caller who
1271 /// asked for [`MemoryStrategy::Auto`] can tell which one it got. Distinct
1272 /// from [`batched_appends`](Self::batched_appends), which is a crash-atomicity
1273 /// trade the bounded engine happens to make, not a statement about memory.
1274 bounded: bool,
1275 /// The on-disk format this session may write, resolved from the fapl's
1276 /// [`FileAccessProperties::with_libver_bounds`]. `None` — the default —
1277 /// means unconstrained: the session adds whatever the content needs, which
1278 /// is what lets a file the C library wrote under its own bounds be edited at
1279 /// all.
1280 ///
1281 /// Set below [`LibVer::V110`] it refuses content the older format cannot
1282 /// carry, the way the whole-file writer does. The file's *own* superblock
1283 /// version cannot stand in for this: a version 2 superblock says the file is
1284 /// 1.8-readable today, not that the caller wants it to stay that way, and
1285 /// deriving the ceiling from it would refuse the C-library-file edits of
1286 /// issue #101.
1287 ///
1288 /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
1289 libver_ceiling: Option<LibVer>,
1290 /// The file length when the on-disk free-space managers were last written,
1291 /// for a file that persists them. Every immediate in-place append grows the
1292 /// file past those managers and leaves them mid-file, so a session that ends
1293 /// with `image.len() != fsm_len` owes a rewrite; that is what
1294 /// [`finalize_persist`](Self::finalize_persist) settles at close. Meaningless
1295 /// (and untouched) when `persist` is `None`.
1296 fsm_len: u64,
1297 /// Whether the commit currently running has *asked* the disk to repoint the
1298 /// superblock at its new root. Set by whichever tail performs that write,
1299 /// immediately **before** issuing it, and read only by
1300 /// [`commit`](Self::commit) to decide whether a failure may roll anything
1301 /// back — the free lists (see [`FreeSnapshot`]), the staged set, and the
1302 /// values in [`inplace_undo`](Self::inplace_undo).
1303 ///
1304 /// Set before rather than after for the reason the whole rollback exists: a
1305 /// write that alters the file and then reports failure is a real device
1306 /// behaviour, so a write call returning an error does not mean its bytes
1307 /// are not on the disk. Once the publish has been issued, whether this
1308 /// commit is live is unknowable from here, and every rollback is unsound —
1309 /// undoing the values would tear a commit that did land, and re-offering
1310 /// the regions would hand out space that commit is using. Doing nothing is
1311 /// the only answer that is never actively wrong, and the error the caller
1312 /// gets is the write failure that says so (issue #344).
1313 ///
1314 /// It is a property of one publish, not of the session, so every publisher —
1315 /// `commit`, and the manager rewrite an append's draw makes — clears it on
1316 /// entry rather than trusting the previous run to have left it false.
1317 publish_attempted: bool,
1318 /// Whether a [`stage_atomically`](Self::stage_atomically) batch is open, so
1319 /// [`refuse_mid_batch`](Self::refuse_mid_batch) can keep
1320 /// [`StagedEdits::rewind`]'s truncation an exact undo.
1321 staging_batch: bool,
1322 /// How many times this session's staged set has been *consumed* by a commit
1323 /// rather than handed back to it.
1324 ///
1325 /// It is what tells a handle born onto a staged creation
1326 /// ([`staged_generation`](Self::staged_generation)) which of two things has
1327 /// happened since: the object it names was published, so it addresses the
1328 /// file now — or the staging was withdrawn under it, and it names nothing.
1329 /// Both leave the staged set without an entry at its path, and reading them
1330 /// the same way is what let such a handle silently retarget onto whatever
1331 /// the file held at that path.
1332 ///
1333 /// Advanced by [`commit`](Self::commit) alone, on exactly the path where the
1334 /// set does not go back: a commit that succeeds, and one that fails past its
1335 /// publish (whose objects are in the file). A commit refused before its
1336 /// first write restores the set and leaves this alone, so the handles it
1337 /// concerns stay pending and the batch can be committed again.
1338 staged_generation: u64,
1339 /// Superblock status flags this session raised on the file and holds for its
1340 /// lifetime — the SWMR pair, or the page buffer's crash mark — or `0` when it
1341 /// holds none.
1342 ///
1343 /// A clean commit publishes *this* rather than a literal zero. The zero was
1344 /// there to scrub a flag the file arrived carrying (one a crashed writer left
1345 /// behind), and it still does: this is `0` unless the session raised
1346 /// something itself. What it must not do is scrub a mark that is still
1347 /// standing for a reason, which a page-buffered session's is for every
1348 /// commit it makes (issue #308).
1349 ///
1350 /// Maintained only by [`set_consistency_flags`](Self::set_consistency_flags),
1351 /// which is the one place this crate writes that byte, so the field cannot
1352 /// drift from what is on the disk.
1353 held_status_flags: u32,
1354 /// Who owns this session's `fsync` cadence, from the fapl's
1355 /// [`FileAccessProperties::with_sync_policy`]. Consulted by
1356 /// [`barrier`](Self::barrier) and [`barrier_data`](Self::barrier_data) —
1357 /// every durability point the write paths define — and by nothing else, so
1358 /// [`force_sync`](Self::force_sync) can serve an explicit
1359 /// [`File::sync`](crate::File::sync) whatever it says.
1360 ///
1361 /// [`FileAccessProperties::with_sync_policy`]: crate::FileAccessProperties::with_sync_policy
1362 sync_policy: SyncPolicy,
1363}
1364
1365/// The free lists as they stood before a commit's apply loop drew from them.
1366///
1367/// A commit allocates as it goes — a free region it hands to an object header or
1368/// a chunk blob leaves the list immediately, so no two objects in the same commit
1369/// can be handed the same region. If the commit then fails, those regions are as
1370/// dead as they were before it started: nothing the commit wrote is reachable,
1371/// because the superblock still names the old root — and the one write that does
1372/// not wait for the repoint, a same-length value overwrite, is one
1373/// [`WriteEngine::undo_inplace_writes`] has already tried to put back by the
1374/// time this runs (issue #344). Restoring this snapshot gives them back instead
1375/// of leaking them for the rest of the session.
1376///
1377/// The restore is sound only *before* the repoint. After it, the objects written
1378/// into those regions are live, and handing the same addresses out again would
1379/// overwrite the tree the commit just published — which is why
1380/// [`WriteEngine::publish_attempted`] gates it.
1381struct FreeSnapshot {
1382 free: FreeList,
1383 /// The append reserve, which the persisting commit tail drains back into the
1384 /// lists above before it rewrites the managers. A commit that then fails
1385 /// before publishing leaves those managers as they were — still not listing
1386 /// the reserve — so restoring it is what keeps the session's picture of the
1387 /// disk exact (issue #387).
1388 reserved: FreeList,
1389 paged: Option<(FreeList, FreeList)>,
1390 /// The heap-collection provenance, rolled back with the free lists.
1391 ///
1392 /// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) records
1393 /// a placement in the *apply* phase, so a commit that fails after that and
1394 /// before its repoint hands the space back to the free list while leaving a
1395 /// record naming it. The next overwrite of that path would then free a
1396 /// region already handed out — the one piece of engine state naming file
1397 /// addresses that this rollback used to miss (issue #321).
1398 vl_overwrite_heaps: HashMap<ObjectPathBuf, Vec<(u64, u64)>>,
1399}
1400
1401/// How much memory a read-write open may use to hold the file being edited.
1402///
1403/// The two read-write backends differ in memory, not in what they can express: a
1404/// *bounded* session reads through a handle and holds only what a commit is
1405/// building, while a *mirrored* session materializes the whole file in memory.
1406/// Bounded is the better default when it applies, but it cannot yet edit every
1407/// file — a pre-v2 (non-latest-format) superblock or a userblock still needs the
1408/// mirror.
1409///
1410/// This is what a caller says about that trade-off, on
1411/// [`FileAccessProperties::with_memory_strategy`](crate::FileAccessProperties::with_memory_strategy).
1412/// Leaving it unset lets the entry point decide: [`File::open_rw`](crate::File::open_rw)
1413/// prefers the bounded engine and falls back to the mirror ([`Auto`](Self::Auto)).
1414/// Stating [`Bounded`](Self::Bounded) refuses instead of falling back.
1415///
1416/// This is a *request*, so it is deliberately not the type a file answers with:
1417/// [`File::edit_backing`](crate::File::edit_backing) returns an [`EditBacking`],
1418/// which cannot express [`Auto`](Self::Auto).
1419///
1420/// Sealed: unlike [`FileLocking`] or [`FileSpaceStrategy`], whose variant sets
1421/// mirror a closed C-library enum, this is a policy this crate invented, so a
1422/// fourth strategy must not be a breaking change.
1423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1424#[non_exhaustive]
1425pub enum MemoryStrategy {
1426 /// Never build a whole-file mirror. A file the bounded engine cannot edit is
1427 /// refused at open with [`Error::EditUnsupported`], before anything is
1428 /// staged.
1429 Bounded,
1430 /// Prefer the bounded engine, but fall back to the whole-file mirror for a
1431 /// file it cannot edit, rather than refusing. Memory then scales with the
1432 /// file, which is the cost of the file opening at all. What
1433 /// [`File::open_rw`](crate::File::open_rw) uses when nothing is asked for.
1434 ///
1435 /// Two files reach that fallback, and no others: one with a pre-v2
1436 /// (non-latest-format) superblock, and one with a userblock (a non-zero base
1437 /// address). Every other file a read-write open accepts is edited bounded,
1438 /// whatever its [`FileSpaceStrategy`], whether or
1439 /// not it persists its free space, and however large it is. A paged file with
1440 /// no persisted free space is refused by *both* backings rather than
1441 /// mirrored, so it is not a third case. The list being exhaustive is what
1442 /// makes "prefer bounded" a guarantee to budget against: wherever
1443 /// [`Bounded`](Self::Bounded) opens a file, `Auto` gives the same backing.
1444 Auto,
1445 /// Always build the whole-file mirror, whatever the file looks like. What
1446 /// [`File::open_rw`](crate::File::open_rw) did before it learned to dispatch.
1447 Mirrored,
1448}
1449
1450/// Which of the two read-write backends a file's editing session is actually
1451/// using, from [`File::edit_backing`](crate::File::edit_backing).
1452///
1453/// Deliberately a different type from [`MemoryStrategy`]: that one is what a
1454/// caller *asks* for and includes [`Auto`](MemoryStrategy::Auto), which is a
1455/// preference between these two rather than a third thing a session can be. A
1456/// single shared type would make `file.backing() == Auto` a comparison that
1457/// compiles and is false forever.
1458///
1459/// The two also evolve at different rates. A future `MemoryStrategy` may name a
1460/// new *policy* — a byte budget, a size threshold — without the set of backends
1461/// changing at all. Sealed for the rarer case that a third backend does appear.
1462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1463#[non_exhaustive]
1464pub enum EditBacking {
1465 /// Reads through a file handle, holding only what a commit is building.
1466 /// Memory does not scale with the file.
1467 Bounded,
1468 /// Holds the whole file in memory for the life of the session.
1469 Mirrored,
1470}
1471
1472impl From<EditBacking> for MemoryStrategy {
1473 /// Turns an outcome back into the request that pins it, so a caller can
1474 /// reopen a file onto the backing it got the first time:
1475 /// `with_memory_strategy(file.edit_backing().unwrap().into())`.
1476 fn from(backing: EditBacking) -> Self {
1477 match backing {
1478 EditBacking::Bounded => Self::Bounded,
1479 EditBacking::Mirrored => Self::Mirrored,
1480 }
1481 }
1482}
1483
1484/// When a read-write session forces its writes to durable storage — who owns the
1485/// `fsync` cadence, this crate or the application.
1486///
1487/// This is *not* about whether a write reaches the file. Every commit and every
1488/// append has gone to the operating system by the time it returns, whatever this
1489/// policy says, so a committed edit is visible to any other process on the same
1490/// machine and survives this process crashing. What it governs is the `fsync` on
1491/// top of that, which is what makes those bytes survive the *machine* losing
1492/// power.
1493///
1494/// A session does gather the many small writes *inside* one such operation and
1495/// issue them a page at a time (issue #288), and an explicit
1496/// [page buffer](crate::FileAccessProperties::with_page_buffer_size) extends that
1497/// across operations — trading exactly the guarantee in the paragraph above,
1498/// which is why it is opt-in and this is not.
1499///
1500/// The reference C library does not `fsync` on its normal path either: the
1501/// default `sec2` driver installs no flush callback at all, so `H5Fflush` drains
1502/// libhdf5's own caches with `write` and stops there, leaving power-loss
1503/// durability to the application. [`OnClose`](Self::OnClose) is that behavior;
1504/// [`Always`](Self::Always), the default here, is the stronger one.
1505///
1506/// The whole-file writer ([`FileBuilder`](crate::FileBuilder)) and
1507/// [`repack`](crate::repack()) are outside this: they never `fsync` under either
1508/// policy, since each writes a file and hands it over rather than holding an
1509/// editing session.
1510///
1511/// Sealed: a future policy (syncing on a timer, or once per N bytes) must not be
1512/// a breaking change.
1513#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1514#[non_exhaustive]
1515pub enum SyncPolicy {
1516 /// Force durability at every point the write paths define one: after each
1517 /// immediate [`Dataset::append`](crate::Dataset::append) batch, at each
1518 /// ordering barrier inside a [`File::commit`](crate::File::commit), and when
1519 /// the file is closed or dropped.
1520 ///
1521 /// This is what makes an append crash-atomic and a commit all-or-nothing
1522 /// against power loss: the barriers order the appended data before the
1523 /// superblock repoint that publishes it, so an interrupted write leaves the
1524 /// previous state rather than a torn one.
1525 #[default]
1526 Always,
1527 /// No `fsync` during the session; one when the file is finished. Nothing an
1528 /// append or a commit does is forced to durable storage — writes still reach
1529 /// the operating system immediately, so another process on the same machine
1530 /// sees them and a crash of *this* process loses nothing — and
1531 /// [`File::close`](crate::File::close), or dropping the last handle, issues
1532 /// a single barrier at the end. [`File::sync`](crate::File::sync) adds a
1533 /// checkpoint wherever the application wants one.
1534 ///
1535 /// One setting suspends the "reach the operating system immediately" half,
1536 /// and it is the one that requires this policy:
1537 /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)
1538 /// holds dirty pages in this process until the budget is spent, a
1539 /// [`File::sync`](crate::File::sync) is issued, or the session closes. Off by
1540 /// default, and it marks the file on disk for its lifetime so a session that
1541 /// dies holding pages leaves one every reader refuses.
1542 ///
1543 /// The terminal barrier is not an exception grafted onto "never sync": it is
1544 /// the point past which the application *cannot* act. `close` and `drop`
1545 /// both write — they apply staged edits, re-home the free-space managers of
1546 /// a file that persists them, and clear a SWMR writer's flag — and they
1547 /// destroy the handle that would have ordered those writes. A policy that
1548 /// skipped the barrier there would not hand the caller a cadence; it would
1549 /// take one away. The cost is one `fsync` per session against the five per
1550 /// append batch and two or three per commit that this policy removes.
1551 ///
1552 /// Three things are given up in exchange. **Power-loss ordering** during the
1553 /// session: with the barriers gone, a machine that loses power mid-commit
1554 /// can have the superblock repoint on disk without the data it points at.
1555 /// **Deferred write errors**: on a filesystem that allocates late, a write
1556 /// that will fail at writeback still returns success, and `fsync` is where
1557 /// the `ENOSPC`/`EIO` surfaces, so a commit the filesystem cannot complete
1558 /// returns `Ok` until the terminal barrier reports it. **Cross-host
1559 /// visibility**: "another process sees it" is the page cache, so under NFS's
1560 /// close-to-open semantics a reader on another host — a SWMR reader
1561 /// included — may not see writes a client is holding.
1562 OnClose,
1563}
1564
1565/// Why the bounded engine cannot edit a file, when the whole-file mirror can.
1566///
1567/// Kept separate from the refusals that apply to *both* engines: a fallback is
1568/// only ever worth taking for a limitation the mirror does not share. A paged
1569/// file with no persisted free-space managers, for instance, is refused by the
1570/// staged commit as well, so mirroring it would trade a clear error at open for
1571/// the same error later with work already staged.
1572fn bounded_only_limitation(session: &WriteEngine) -> Option<&'static str> {
1573 if session.superblock.version < 2 {
1574 return Some(
1575 "bounded read-write access requires a latest-format file (v2/v3 superblock); \
1576 leave MemoryStrategy unset, or pass MemoryStrategy::Auto, to fall back to \
1577 the whole-file mirror here",
1578 );
1579 }
1580 if !session.superblock.base_address.is_zero() {
1581 return Some(
1582 "bounded read-write access does not support a file with a userblock \
1583 (non-zero base address); leave MemoryStrategy unset, or pass \
1584 MemoryStrategy::Auto, to fall back to the whole-file mirror here",
1585 );
1586 }
1587 None
1588}
1589
1590/// Whether a file built from `create` could not then be opened read-write under
1591/// `access`, and why.
1592///
1593/// [`File::create_with_options`](crate::File::create_with_options) writes a file
1594/// and hands back an open read-write handle, so a creation/access pair that
1595/// cannot survive that second half must be caught *before* the write — otherwise
1596/// the call leaves a file on disk and returns `Err`, which reads like a failed
1597/// create but is not one.
1598///
1599/// This mirrors the open-time refusals above and must be kept in step with them:
1600/// [`bounded_only_limitation`] for the userblock, and the shared paged check in
1601/// [`open_rw_with_strategy`](WriteEngine::open_rw_with_strategy) for a paged file
1602/// with no persisted free space. Both are stated here in terms of the properties
1603/// that *cause* them, because the open-time wording tells the caller to recreate
1604/// the file — advice that is circular when the caller is creating it.
1605pub(crate) fn create_would_refuse_reopen(
1606 create: &FileCreateProperties,
1607 access: &FileAccessProperties,
1608) -> Option<&'static str> {
1609 if let Some((FileSpaceStrategy::Page, false, _)) = create.file_space_strategy() {
1610 return Some(
1611 "a paged file (FileSpaceStrategy::Page) with persist = false cannot be reopened \
1612 read-write, so creating one this way would write the file and then fail to open \
1613 it; pass persist = true to with_file_space_strategy, or build the file with \
1614 FileBuilder if it is only ever going to be read",
1615 );
1616 }
1617 if create.userblock() != 0 && access.memory_strategy() == Some(MemoryStrategy::Bounded) {
1618 return Some(
1619 "a userblock cannot be combined with MemoryStrategy::Bounded: the bounded engine \
1620 cannot edit a file with a non-zero base address, so creating one this way would \
1621 write the file and then refuse to open it; drop the userblock, or leave \
1622 MemoryStrategy unset to mirror this file",
1623 );
1624 }
1625 // A userblock on a *paged* file reaches the same open-time refusal as
1626 // `persist = false` above, by a different route: persisted free space is
1627 // declined for a non-zero base address, which leaves a paged file with no
1628 // seeded managers however its creation properties were written.
1629 if create.userblock() != 0
1630 && matches!(
1631 create.file_space_strategy(),
1632 Some((FileSpaceStrategy::Page, _, _))
1633 )
1634 {
1635 return Some(
1636 "a userblock cannot be combined with FileSpaceStrategy::Page: free space is not \
1637 persisted for a file with a non-zero base address, and a paged file without it \
1638 cannot be opened read-write — so creating one this way would write the file and \
1639 then fail to open it; drop the userblock, or choose another file-space strategy",
1640 );
1641 }
1642 // The page-buffer refusals `set_page_buffer_size` makes at open, restated as
1643 // the creation pair that would walk into them. They are phrased as something
1644 // to *add* to the properties in hand rather than as something to recreate the
1645 // file with, since the file does not exist yet (issues #288 and #308).
1646 if access.page_buffer_size() != 0 {
1647 if access.sync_policy() == SyncPolicy::Always {
1648 return Some(
1649 "a page buffer does nothing under the default SyncPolicy::Always, whose every \
1650 barrier is an fsync that flushes it, so creating one this way would write the \
1651 file and then fail to open it; add \
1652 with_sync_policy(SyncPolicy::OnClose) to the access properties, or drop the \
1653 page buffer",
1654 );
1655 }
1656 // The version-3 counterpart. It used to need none: a page buffer required
1657 // a paged file, and `FileSpaceStrategy::Page` below the 1.10 format is
1658 // refused by the builder itself before anything is written, so that pair
1659 // never reached an open. Issue #357 lifted the paged requirement, and an
1660 // *unpaged* file at `LibVer::V18` is an ordinary, buildable file with a
1661 // version-2 superblock — one whose status-flags byte no library reads
1662 // back, so the mark could not be raised on it. Without this the pair
1663 // would write the file and fail the open it promised.
1664 //
1665 // Unsatisfiable bounds are left to the builder, which reports which
1666 // format it can write; this is not the place to restate that.
1667 if matches!(
1668 crate::libver::LibVer::resolve_writable(create.libver_bounds()),
1669 Ok(v) if v < crate::libver::LibVer::V110
1670 ) {
1671 return Some(
1672 "a page buffer marks the file in a version-3 superblock, and these bounds \
1673 write the 1.8 format, so creating one this way would write the file and then \
1674 fail to open it; raise with_libver_bounds to admit LibVer::V110, or drop the \
1675 page buffer",
1676 );
1677 }
1678 // An unpaged file has no page size of its own and merges within
1679 // `DEFAULT_GATHER_PAGE`, which is the same figure the format defaults
1680 // `H5Pset_file_space_page_size` to. This arm decides on its own now that
1681 // a budget below the session's own gather budget is honored rather than
1682 // refused (issue #391): a 2 KiB buffer on an unpaged create clears every
1683 // other check and is caught only here. It is written out rather than
1684 // folded into the paged arm's `unwrap_or` because the two constants are
1685 // free to move apart, and because a reader comparing this function
1686 // against `set_page_buffer_size` should find the same rule in both.
1687 let page_size = match create.file_space_strategy() {
1688 Some((FileSpaceStrategy::Page, _, _)) => create
1689 .file_space_page_size()
1690 .unwrap_or(crate::file_space_info::DEFAULT_PAGE_SIZE),
1691 _ => DEFAULT_GATHER_PAGE,
1692 };
1693 if (access.page_buffer_size() as u64) < page_size {
1694 return Some(
1695 "a page buffer smaller than the file's file-space page size would be refused \
1696 at open — so creating one this way would write the file and then fail to \
1697 open it; raise with_page_buffer_size",
1698 );
1699 }
1700 }
1701 None
1702}
1703
1704/// What a commit knows about a region it is vacating, which is what decides
1705/// where the region may be recorded.
1706///
1707/// A flat file ignores the distinction — every vacated region is simply free —
1708/// and a paged one cannot, because a byte handed out for the wrong page type
1709/// mixes the page it lands in.
1710#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1711enum FreeClass {
1712 /// The region sits in pages of this type, so it becomes free space of that
1713 /// type and may serve the next allocation of it.
1714 Page(PageType),
1715 /// The region provably holds nothing live, but which page type surrounds it
1716 /// is not provable — a chunk index this crate cannot place
1717 /// ([`WriteEngine::index_is_provably_raw`]) is the case that arises.
1718 ///
1719 /// It may not be handed out as either type, so it is recorded as *dead*
1720 /// rather than free: bytes that are no longer in use and are not yet
1721 /// reusable. Once every other byte of the page around it is free or dead
1722 /// too, the page holds nothing of either type and
1723 /// [`PagedEdit::promote_whole_free_pages`] turns the whole page into free
1724 /// space, which is the same rule that lets one page type claim a wholly free
1725 /// page from the other ([`PagedEdit::alloc_typed`]).
1726 Dead,
1727}
1728
1729impl From<PageType> for FreeClass {
1730 fn from(ty: PageType) -> Self {
1731 FreeClass::Page(ty)
1732 }
1733}
1734
1735/// Paged-file bookkeeping for the whole-file editor (issue #198, step 1).
1736///
1737/// A paged file never mixes metadata and raw data within one page, so this tracks
1738/// free space per page *type* — the one distinction that governs where a byte may
1739/// be placed — and keeps the commit's appends homogeneous by padding a tail page
1740/// whenever the page type changes.
1741///
1742/// One list per type, not one per on-disk manager. A paged file records its free
1743/// space in three managers (metadata-small, raw-small, and a generic-large one for
1744/// whole free pages), but for space this session can place, that split is a size
1745/// classification of the same raw or metadata space, and [`plan_paged_managers`]
1746/// recomputes it from scratch at every commit. Tracking it here as well would only
1747/// stop a freed chunk-data run and the freed index that abuts it from coalescing —
1748/// leaving two neighboring holes neither of which fits the dataset that just
1749/// vacated both (issue #261).
1750///
1751/// The free lists are seeded only when the file *persists* its free space. A paged
1752/// non-persisting file has no on-disk record of which pages hold metadata and
1753/// which hold raw data, so there is nothing to seed and no way to stay segregated;
1754/// [`commit`](WriteEngine::commit) rejects it outright, exactly as the bounded
1755/// backend does.
1756struct PagedEdit {
1757 page_size: u64,
1758 /// Free space inside metadata pages, plus whole free pages this session last
1759 /// saw as metadata. A page holding nothing belongs to no type, so
1760 /// [`alloc_typed`](Self::alloc_typed) lets raw data claim one from here.
1761 meta: FreeList,
1762 /// Free space inside raw-data pages, plus whole free pages this session last
1763 /// saw as raw — including every one seeded from the generic-large manager,
1764 /// which records no type. Metadata claims from here on the same terms.
1765 raw: FreeList,
1766 /// Free space this session may record but must never hand out, because the
1767 /// page it sits in is of unknown type. Seeded at open — nothing this engine
1768 /// frees is ever of unknown type, so it only ever shrinks, and only where a
1769 /// commit released the end of the file over it (issue #418) — and written
1770 /// back to the generic-large manager verbatim, so the space stays available
1771 /// to the writer that recorded it.
1772 ///
1773 /// It exists because the reference C library's generic-large manager is
1774 /// exactly that: `H5F_MEM_PAGE_GENERIC` is *"large-sized generic: meta and
1775 /// raw"*, and under paged aggregation on a contiguous-address driver every
1776 /// allocation of a page or more lands there whatever its type. Its
1777 /// page-alignment tail (`H5MF__alloc_pagefs`) is then recorded as a free
1778 /// section in the same manager — a sub-page fragment sitting in a page whose
1779 /// earlier bytes are live. Handing such a fragment to raw data would put raw
1780 /// bytes in a metadata page, so a section that is not a whole run of aligned
1781 /// pages is kept here rather than guessed at.
1782 unclassified: FreeList,
1783 /// Space this session has vacated whose page type it could not prove
1784 /// ([`FreeClass::Dead`]): no longer in use, and not reusable as either type.
1785 ///
1786 /// It is held rather than discarded so that the page around it can still be
1787 /// reclaimed: a page every one of whose bytes is free or dead holds nothing
1788 /// of either type, and [`promote_whole_free_pages`](Self::promote_whole_free_pages)
1789 /// moves it into the typed lists whole. Without this list those bytes would
1790 /// simply be lost, and the page they sit in could never be shown to be empty
1791 /// — which is what made a paged file leak a chunk index per deleted empty
1792 /// resizable dataset (issue #388).
1793 ///
1794 /// Unlike the free lists this one is not written to disk: a dead sub-page
1795 /// fragment is not free space by the file's account, and recording it in the
1796 /// only manager that takes untyped space would offer it to the reference
1797 /// library for either type. A fragment that never completes a page is
1798 /// therefore forgotten at close, which wastes its bytes and nothing more.
1799 dead: FreeList,
1800 /// Page type of the current tail page. `None` until this session's first
1801 /// typed append; the file is page-aligned at open, so the first append never
1802 /// needs to pad regardless of this.
1803 last: Option<PageType>,
1804 /// Free tails left by padding a metadata page before a raw append.
1805 meta_pad: Vec<(u64, u64)>,
1806 /// Free tails left by padding a raw page before a metadata append.
1807 raw_pad: Vec<(u64, u64)>,
1808}
1809
1810impl PagedEdit {
1811 /// Ensure the next allocation on `image` begins in a page holding page type
1812 /// `ty`: when the tail page holds the *other* type and is only partially
1813 /// filled, pad it to a page boundary and record the padding as free space of
1814 /// the outgoing type.
1815 ///
1816 /// This is the whole of the paged-append rule, and it lives here so the two
1817 /// places that grow a paged file — the staged commit through
1818 /// [`WriteEngine::begin_page`](WriteEngine::begin_page), and the shared
1819 /// Extensible-Array append engine through [`EditStore`] — cannot drift. They
1820 /// used to keep separate copies of this state, one per engine, which is what
1821 /// made an in-place append to a paged file unsafe from the whole-file editor
1822 /// (issue #198).
1823 ///
1824 /// Call it **before** reading the image's end-of-file to compute an address
1825 /// that will be embedded in the bytes being built: several callers build
1826 /// content whose interior addresses assume it lands at the current
1827 /// end-of-file, and padding inserted after that read would shift the landing
1828 /// address out from under them.
1829 fn begin(&mut self, image: &mut dyn FileImage, ty: PageType) -> Result<(), Error> {
1830 let len = image.len();
1831 if len % self.page_size != 0 {
1832 let pad_len = self.page_size - len % self.page_size;
1833 // `prev` is the outgoing page type to record the padding under, or
1834 // `None` for a crash-recovery pad whose tail-page type is unknown.
1835 let pad = match self.last {
1836 // Normal case: the tail page holds a known type; pad only on a
1837 // type switch, recording the tail as free of the outgoing type.
1838 Some(prev) if prev != ty => Some(Some(prev)),
1839 Some(_) => None, // same type: keep packing the tail page
1840 // A previous session grew this paged file and was killed before
1841 // its tail was page-aligned, so the file opened non-page-aligned
1842 // with no known tail type. Pad it up (extending whatever the tail
1843 // page holds, so the page stays homogeneous) and leave the padding
1844 // untracked, since recording it under the wrong page type could
1845 // let a reader reuse it and mix the page.
1846 None => Some(None),
1847 };
1848 if let Some(prev) = pad {
1849 let pad_at = len;
1850 image.append(&vec![0u8; pad_len.to_usize()?])?;
1851 match prev {
1852 Some(PageType::Meta) => self.meta_pad.push((pad_at, pad_len)),
1853 Some(PageType::Raw) => self.raw_pad.push((pad_at, pad_len)),
1854 None => {} // crash-recovery pad: untracked (tail type unknown)
1855 }
1856 }
1857 }
1858 self.last = Some(ty);
1859 Ok(())
1860 }
1861
1862 fn new(page_size: u64) -> Self {
1863 PagedEdit {
1864 page_size,
1865 meta: FreeList::new(),
1866 raw: FreeList::new(),
1867 unclassified: FreeList::new(),
1868 dead: FreeList::new(),
1869 last: None,
1870 meta_pad: Vec::new(),
1871 raw_pad: Vec::new(),
1872 }
1873 }
1874
1875 /// Which list a persisted section from File Space Info `slot` belongs in.
1876 ///
1877 /// Slot 0 (SUPER) is small metadata and slot 2 (DRAW) is small raw: both name
1878 /// a page type outright. Slot 6 is the generic-large manager, which holds
1879 /// space of *either* type (see [`unclassified`](Self::unclassified)), so a
1880 /// section from it is only safe to reuse when it covers whole aligned pages —
1881 /// and such a section belongs to no type at all, since nothing lives in those
1882 /// pages to be mixed with. It is filed under raw and reached from either side
1883 /// through [`alloc_typed`](Self::alloc_typed), rather than kept in a third
1884 /// list, so that a run of free pages still coalesces with the sub-page
1885 /// fragment beside it. Everything else, including the per-type large managers
1886 /// a multi/split driver would populate, is left unclassified rather than
1887 /// guessed at.
1888 fn slot_list(slot: usize, addr: u64, size: u64, page_size: u64) -> Option<PageType> {
1889 match slot {
1890 0 => Some(PageType::Meta),
1891 2 => Some(PageType::Raw),
1892 6 if addr % page_size == 0 && size % page_size == 0 => Some(PageType::Raw),
1893 _ => None,
1894 }
1895 }
1896
1897 /// Record `(addr, size)` in the list its class names: the free space of one
1898 /// page type, or the dead list for space whose page type is unproven.
1899 /// [`plan_paged_managers`] splits and classes the free lists into the on-disk
1900 /// managers at serialization time, so this only has to route.
1901 ///
1902 /// Takes the lists rather than `&mut self` because a commit routes into
1903 /// *copies* of them — nothing is free until the superblock repoint — and the
1904 /// rule must not be restated at that call site.
1905 fn route_free(
1906 meta: &mut FreeList,
1907 raw: &mut FreeList,
1908 dead: &mut FreeList,
1909 addr: u64,
1910 size: u64,
1911 class: FreeClass,
1912 ) {
1913 match class {
1914 FreeClass::Page(PageType::Meta) => meta.free(addr, size),
1915 FreeClass::Page(PageType::Raw) => raw.free(addr, size),
1916 FreeClass::Dead => dead.free(addr, size),
1917 }
1918 }
1919
1920 /// Move every page that is *wholly* free or dead out of the three lists and
1921 /// into the raw list as one whole free page.
1922 ///
1923 /// A paged file may only place a byte in a page of its own type, which is why
1924 /// free space is tracked per type at all. A page holding nothing belongs to no
1925 /// type, so it may be opened for whichever type asks — the rule
1926 /// [`alloc_typed`](Self::alloc_typed) already applies to a whole page inside
1927 /// the other type's list, and [`slot_list`](Self::slot_list) to a whole page
1928 /// read back from the untyped manager. This applies the same rule one level
1929 /// up, to a page whose emptiness only the three lists *together* establish:
1930 /// part free metadata, part free raw data, part space vacated by an object
1931 /// whose page type could not be proven ([`FreeClass::Dead`]).
1932 ///
1933 /// That last part is what makes it necessary rather than a tidy-up. Dead
1934 /// space is never handed out on its own, so without this step a deleted chunk
1935 /// index this crate cannot place is lost for good, and a paged file under
1936 /// delete-and-recreate churn spends a page every few cycles that it can never
1937 /// take back (issue #388). Promoting the page is not a guess about what once
1938 /// sat in it: nothing live is left there to be mixed with.
1939 ///
1940 /// Filed under raw, which is where a whole free page belongs by the same
1941 /// convention [`slot_list`](Self::slot_list) reads them back under, and
1942 /// reachable from either side through `alloc_typed`.
1943 fn promote_whole_free_pages(
1944 meta: &mut FreeList,
1945 raw: &mut FreeList,
1946 dead: &mut FreeList,
1947 page_size: u64,
1948 ) {
1949 // The lists are individually coalesced and pairwise disjoint (a byte is
1950 // vacated once), so their union is a plain merge: sort by address and join
1951 // runs that touch. A page is promotable exactly when it lies inside one of
1952 // those runs, which is what a page split across two of the lists needs.
1953 let mut all = meta.sections();
1954 all.extend(raw.sections());
1955 all.extend(dead.sections());
1956 all.sort_unstable_by_key(|&(addr, _)| addr);
1957 let mut runs: Vec<(u64, u64)> = Vec::with_capacity(all.len());
1958 for (addr, len) in all {
1959 match runs.last_mut() {
1960 Some(run) if run.0 + run.1 >= addr => {
1961 let end = (run.0 + run.1).max(addr + len);
1962 run.1 = end - run.0;
1963 }
1964 _ => runs.push((addr, len)),
1965 }
1966 }
1967 for (addr, len) in runs {
1968 // The whole pages inside the run: its aligned interior, exactly as
1969 // `FreeList::alloc_whole_units` takes one. The partial edges sit in
1970 // pages whose other bytes may be live, so they keep whatever they are.
1971 let first = addr.next_multiple_of(page_size);
1972 let last = (addr + len) / page_size * page_size;
1973 if last <= first {
1974 continue;
1975 }
1976 let span = last - first;
1977 meta.take_range(first, span);
1978 raw.take_range(first, span);
1979 dead.take_range(first, span);
1980 raw.free(first, span);
1981 }
1982 }
1983
1984 /// Draw `len` bytes of page type `ty` from this file's free space, or `None`
1985 /// when nothing can serve it.
1986 ///
1987 /// Its own list first, which is the only place a *partly* free page can serve
1988 /// it. Failing that, whole pages inside the other type's free space: a page
1989 /// with nothing in it belongs to neither type, so opening it for `ty` cannot
1990 /// mix it. Enough whole pages to cover `len` are claimed, and the remainder
1991 /// joins `ty`'s list, since those pages now hold `ty`.
1992 ///
1993 /// The cross-type claim is what lets space survive a close. A whole free page
1994 /// is written to the generic-large manager, which records no page type, so
1995 /// every one of them comes back from disk as raw ([`slot_list`](Self::slot_list));
1996 /// without this, metadata could never reuse a page again after a reopen, and
1997 /// each session's commits would append past all of them (issue #286).
1998 fn alloc_typed(&mut self, len: u64, ty: PageType) -> Option<u64> {
1999 let (own, other) = match ty {
2000 PageType::Meta => (&mut self.meta, &mut self.raw),
2001 PageType::Raw => (&mut self.raw, &mut self.meta),
2002 };
2003 if let Some(addr) = own.alloc(len) {
2004 return Some(addr);
2005 }
2006 let span = align_up(len, self.page_size);
2007 let addr = other.alloc_whole_units(span, self.page_size)?;
2008 if span > len {
2009 own.free(addr + len, span - len);
2010 }
2011 Some(addr)
2012 }
2013
2014 /// The longest contiguous run [`alloc_typed`](Self::alloc_typed) could serve
2015 /// `ty` from in one call, or `0` when nothing could: the larger of its own
2016 /// list's longest region and the longest run of whole free pages in the
2017 /// other's.
2018 ///
2019 /// The same two sources in the same terms, so a caller sizing a draw on this
2020 /// figure gets exactly the run `alloc_typed` then hands it.
2021 fn largest_typed(&self, ty: PageType) -> u64 {
2022 let (own, other) = match ty {
2023 PageType::Meta => (&self.meta, &self.raw),
2024 PageType::Raw => (&self.raw, &self.meta),
2025 };
2026 own.largest().max(other.largest_whole_units(self.page_size))
2027 }
2028
2029 /// Every free region this session could still hand out, ascending by address.
2030 /// Used for space accounting, where the caller wants one total rather than a
2031 /// per-page-type breakdown.
2032 ///
2033 /// [`unclassified`](Self::unclassified) is excluded: it is free space, and
2034 /// [`File::persisted_free_space`](crate::File::persisted_free_space) reports
2035 /// it as such, but this session will never place anything in it, which is
2036 /// what the accounting field it feeds is about.
2037 fn reusable_sections(&self) -> Vec<(u64, u64)> {
2038 let mut out = self.meta.sections();
2039 out.extend(self.raw.sections());
2040 out.sort_unstable_by_key(|&(addr, _)| addr);
2041 debug_assert!(
2042 out.windows(2)
2043 .all(|w| w[0].0.saturating_add(w[0].1) <= w[1].0),
2044 "a region is free in both the metadata and the raw list, so the two \
2045 page-type lists have stopped being disjoint"
2046 );
2047 out
2048 }
2049}
2050
2051/// The free space a paged commit is about to write to disk, as
2052/// [`WriteEngine::paged_post_free`] computes it: the session's lists with this
2053/// commit's frees folded in, held apart from the session until the repoint makes
2054/// them true.
2055struct PagedPostFree {
2056 meta: FreeList,
2057 raw: FreeList,
2058 /// The session's dead space plus this commit's, with every page the three
2059 /// lists together empty already promoted out of it
2060 /// ([`PagedEdit::promote_whole_free_pages`]).
2061 dead: FreeList,
2062 /// Nothing in a commit adds to or draws from this one.
2063 unclassified: FreeList,
2064}
2065
2066impl PagedPostFree {
2067 /// Give the run of unreferenced space that reaches `eof` back to the
2068 /// filesystem: drop it from every list here and return the end-of-allocation
2069 /// the commit should publish, which the caller truncates the file to
2070 /// (issue #418). `eof` itself when nothing at the end of the file is free.
2071 ///
2072 /// The cut is rounded **up** to a page boundary, so a paged file's
2073 /// end-of-allocation stays a whole number of pages: a run that begins
2074 /// mid-page begins in a page whose earlier bytes are live, and only the whole
2075 /// pages above it can go. That is the same granularity the reference C
2076 /// library shrinks a paged file at — its `H5MF__sect_large_can_shrink` sees
2077 /// only the large (whole-page) sections, the small per-type managers having
2078 /// no shrink at all — so a partial page at the end is kept and recorded,
2079 /// exactly as it is there.
2080 ///
2081 /// Every list is cut, including the space this session may record but never
2082 /// place ([`PagedEdit::unclassified`]) and the space it has vacated but
2083 /// cannot type ([`PagedEdit::dead`]). Bytes past the end-of-allocation are
2084 /// not in the file at all, so a section describing them would point outside
2085 /// it — the one thing the persisted managers must never do.
2086 ///
2087 /// Whole pages holding [`TRAILING_RESERVE_TAILS`] tails stay in the file, and
2088 /// stay recorded, for the reason [`release_trailing_run`] gives on a flat
2089 /// file — with the same refusal to release a run that gives back less than it
2090 /// keeps.
2091 fn release_trailing(&mut self, eof: u64, page_size: u64, tail_len: u64) -> u64 {
2092 let start =
2093 trailing_run_start([&self.meta, &self.raw, &self.dead, &self.unclassified], eof);
2094 let cut = align_up(start, page_size);
2095 if cut >= eof {
2096 return eof;
2097 }
2098 // Whole pages are this file's unit, so the reserve is one too, and the
2099 // same "give back more than you keep" condition applies.
2100 let keep = align_up(TRAILING_RESERVE_TAILS * tail_len, page_size);
2101 if eof - cut < 2 * keep {
2102 return eof;
2103 }
2104 let eoa = cut + keep;
2105 let span = eof - eoa;
2106 // A section straddling the cut keeps the part that is still inside the
2107 // file, which is what `take_range` leaves of it.
2108 self.meta.take_range(eoa, span);
2109 self.raw.take_range(eoa, span);
2110 self.dead.take_range(eoa, span);
2111 self.unclassified.take_range(eoa, span);
2112 eoa
2113 }
2114}
2115
2116/// How much free space a released trailing run leaves behind, as a multiple of
2117/// the manager tail a persisting commit writes.
2118///
2119/// A persisting file rewrites those blocks on *every* commit, and a rewrite can
2120/// never land in its own predecessor's extent — the on-disk superblock still
2121/// points at it until the repoint, so overwriting it would destroy the file a
2122/// crash there falls back on. The end of the file therefore has to keep room for
2123/// the tails that follow this one, and a tail is not a fixed size: every
2124/// abandoned one adds a section to the managers, which makes the *next* tail
2125/// longer again.
2126///
2127/// Measured rather than chosen, on the shipped rule. At one,
2128/// `a_released_file_holds_its_length_across_many_tail_rewrites` leaves the flat
2129/// file alternating between 5,992 and 6,303 bytes instead of holding one length,
2130/// because a tail a section longer than the one that sized the reserve does not
2131/// fit it. At two, `persisting_churn_reaches_a_steady_size` has not settled by
2132/// its sixteenth round — 15,095 bytes after a middle third that held 14,685.
2133/// Three is the smallest value both accept; four is one tail of margin, the tail
2134/// length being itself a function of a section count that varies with the file.
2135const TRAILING_RESERVE_TAILS: u64 = 4;
2136
2137/// Release the run of free space reaching `eof` from a flat file's post-commit
2138/// free list, and return the end-of-allocation the commit should publish
2139/// (issue #418).
2140///
2141/// Not the whole run: [`TRAILING_RESERVE_TAILS`] tails' worth of it stays in the
2142/// file, and stays recorded, so the commits after this one still have somewhere
2143/// to write their own manager blocks.
2144///
2145/// And nothing at all unless the run gives back at least as much as it keeps.
2146/// Trimming the top off a hole that is *about* to be reused is the worst of both:
2147/// it returns a few hundred bytes and leaves the hole too small for the object
2148/// that would have filled it, which then goes past end-of-file instead, so the
2149/// file grows by a whole object to give back a fraction of one.
2150///
2151/// So `eof` — the file's length, unchanged — when it ends in live bytes and when
2152/// the run is not worth releasing; the list is then left exactly as it came in.
2153fn release_trailing_run(post: &mut FreeList, eof: u64, tail_len: u64) -> u64 {
2154 let run_start = trailing_run_start([&*post], eof);
2155 let keep = TRAILING_RESERVE_TAILS * tail_len;
2156 if eof - run_start < 2 * keep {
2157 return eof;
2158 }
2159 post.take_range(run_start + keep, eof - run_start - keep);
2160 run_start + keep
2161}
2162
2163/// Whether a persisting file's tail rewrite may grow the file to place its
2164/// manager blocks.
2165///
2166/// The two callers want opposite things from a tail that will not fit in free
2167/// space, and the difference is not a preference: a commit publishing a tree
2168/// *must* record its free space somewhere, while the shrink pass exists only to
2169/// make the file smaller and would defeat itself by appending.
2170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2171enum TailPlacement {
2172 /// Place the tail in free space where it fits and past end-of-file where it
2173 /// does not. Every commit that publishes a tree.
2174 Anywhere,
2175 /// Place the tail in free space or not at all: when nothing fits, the
2176 /// rewrite is abandoned before it writes a byte and the file is left exactly
2177 /// as the commit before it published.
2178 ReuseOnly,
2179}
2180
2181/// Where a commit has decided to put one allocation's bytes, handed out by
2182/// [`WriteEngine::reserve`] and consumed by [`WriteEngine::place`].
2183///
2184/// The two arms differ in how the bytes are written — over a dead interior region
2185/// or past the end of the file — and in nothing else the caller sees, so a caller
2186/// that needs the address before the bytes (a relocatable blob) never has to know
2187/// which it got.
2188#[derive(Clone, Copy, Debug)]
2189enum Placement {
2190 /// A region an earlier commit freed, already removed from its free list.
2191 Reused { addr: u64, len: u64 },
2192 /// The end-of-file, with the page for the requested type already open.
2193 Appended { addr: u64, len: u64 },
2194}
2195
2196impl Placement {
2197 /// The absolute file address the bytes will occupy.
2198 fn address(self) -> u64 {
2199 match self {
2200 Placement::Reused { addr, .. } | Placement::Appended { addr, .. } => addr,
2201 }
2202 }
2203
2204 /// The reserved byte count, which the placed bytes must match exactly.
2205 fn len(self) -> u64 {
2206 match self {
2207 Placement::Reused { len, .. } | Placement::Appended { len, .. } => len,
2208 }
2209 }
2210}
2211
2212/// What an in-place append names its target by.
2213///
2214/// A handle with a resolvable path uses it, which lets the session compare the
2215/// target against its own staged edits. A handle reached by object reference has
2216/// no path, so it names the dataset by the object-header address it was reached
2217/// through — the same key the geometry cache uses.
2218#[derive(Clone, Copy)]
2219pub(crate) enum AppendTarget<'a> {
2220 Path(&'a ObjectPathBuf),
2221 Header(u64),
2222}
2223
2224/// Byte budget for one append batch on a session that batches: a large append is
2225/// split into whole-chunk batches of at most this many raw bytes (always at least
2226/// one chunk), each applied as its own crash-atomic fsync-barriered sequence, so
2227/// peak append memory never scales with the caller's slice.
2228const APPEND_BATCH_BYTES: u64 = 1 << 20;
2229
2230/// The data-only durability barrier: an ordering point always, and an `fsync`
2231/// only when this session's [`SyncPolicy`] keeps the cadence.
2232///
2233/// One function rather than one per caller because the commit path
2234/// ([`WriteEngine::barrier_data`]) and the append engine's phase boundaries
2235/// ([`EditStore::sync`]) are the same point reached from two owners of the
2236/// image, and a barrier that orders at one and not the other is a
2237/// crash-consistency defect no test on the default policy can see (issue #288).
2238///
2239/// The match is exhaustive on purpose, as at [`WriteEngine::barrier`]:
2240/// `SyncPolicy` is sealed to the outside but not to this crate, so a policy
2241/// added later fails to compile here until someone decides what it means.
2242fn barrier_data(image: &mut dyn FileImage, sync_policy: SyncPolicy) -> Result<(), Error> {
2243 match sync_policy {
2244 SyncPolicy::Always => image.sync_data(),
2245 SyncPolicy::OnClose => image.ordering_barrier(),
2246 }
2247}
2248
2249/// Byte budget for the writes one operation may gather before they are issued
2250/// (see [`WriteBuffering::Operation`]): the default every exclusively locked
2251/// read-write session opens under.
2252///
2253/// A megabyte, the same figure as [`APPEND_BATCH_BYTES`] and for a related
2254/// reason: that is already what a bounded session spends holding one batch of
2255/// raw append data, so this is a familiar quantum for the write path rather than
2256/// a new one. The two are not derived from each other and either may be tuned
2257/// alone. An operation larger than this is flushed part-way, which costs it
2258/// little — its writes are long and contiguous by then.
2259///
2260/// # Choosing a page-buffer budget
2261///
2262/// An explicit [page buffer](crate::FileAccessProperties::with_page_buffer_size)
2263/// *replaces* this budget rather than adding to it, and holds what it gathers
2264/// across operations rather than releasing it at every ordering barrier. The two
2265/// figures are therefore not comparable as memory: this one is a per-operation
2266/// peak, a page buffer's is continuous residency. A budget below it asks for less
2267/// of that residency and pays in writes, wherever one long contiguous run has to
2268/// be flushed and restarted. Writes issued on a 4 KiB-paged file:
2269///
2270/// ```text
2271/// workload unset 4 KiB 64 KiB 1 MiB
2272/// 32 chunk appends into 8 + commit 188 25 4 4
2273/// one 4 MiB append 131 1094 74 10
2274/// ```
2275///
2276/// Nothing here bypasses the gatherer the way `H5PB_write` bypasses the C page
2277/// buffer for any I/O of a page or more (issue #357), so the second row is what
2278/// a small budget actually costs; `a_smaller_budget_issues_more_writes` asserts
2279/// that ordering rather than these counts. Which side of it to want is the
2280/// caller's trade, not this crate's (issue #391).
2281const WRITE_GATHER_BYTES: usize = 1 << 20;
2282
2283/// Page the write gatherer merges within on a file that is not paged.
2284///
2285/// A non-paged file has no page size of its own, and this is the same figure
2286/// HDF5 defaults `H5Pset_file_space_page_size` to, so the merge quantum matches
2287/// what the file *would* have used had it been paged.
2288const DEFAULT_GATHER_PAGE: u64 = crate::file_space_info::DEFAULT_PAGE_SIZE;
2289
2290/// One dataset's append geometry, handed to the public append path so it can
2291/// slice a large call into aligned batches without materializing the whole
2292/// call's bytes first.
2293pub(crate) struct AppendGeometry {
2294 /// Elements per chunk along axis 0 (>= 1).
2295 pub(crate) chunk_elems: u64,
2296 /// Bytes per on-disk element, proven non-zero.
2297 pub(crate) element_size: NonZeroUsize,
2298 /// Current length along the unlimited dimension.
2299 pub(crate) current_dim: u64,
2300 /// Whether this dataset's filter pipeline is **lossy** — ZFP, or float
2301 /// D-scale scale-offset. Such a dataset cannot have a partial trailing chunk
2302 /// grown, because that decodes and re-encodes already-committed values (see
2303 /// [`pipeline_lossless`]), so an append onto one has to start chunk-aligned.
2304 pub(crate) lossy_filters: bool,
2305 /// Whole-chunk elements in one full batch (>= one chunk's worth), or
2306 /// [`u64::MAX`] when the session does not batch.
2307 pub(crate) full_batch_elems: u64,
2308}
2309
2310/// Superblock consistency-flag bits raised while a SWMR writer is active: bit 0
2311/// (write access) | bit 2 (SWMR write access). Cleared on a clean close. Matches
2312/// the reference C library, h5py, and [`crate::File::open_swmr_writer`]. These
2313/// are the bits every open path checks — see [`file_lock::check_status_flags`].
2314const SWMR_WRITE_FLAGS: u32 = file_lock::WRITE_ACCESS | file_lock::SWMR_WRITE_ACCESS;
2315
2316/// A dataset located once for [`Dataset::append`](crate::Dataset::append) (or the bounded
2317/// backend's immediate append), then maintained across appends. Mirrors the
2318/// append writer's per-dataset state.
2319pub(crate) struct LocatedState {
2320 pub(crate) loc: Located,
2321 /// The dataset's on-disk element datatype (for the append type check and the
2322 /// filter chunk context).
2323 pub(crate) datatype: Datatype,
2324 /// Spatial (rank-length) chunk dimensions in elements: `[chunk_elems]`.
2325 pub(crate) spatial: Vec<u64>,
2326 /// Bytes per element (datatype size), proven non-zero.
2327 pub(crate) element_size: NonZeroUsize,
2328 /// The re-encodable filter pipeline, when the dataset is filtered.
2329 pub(crate) pipeline: Option<FilterPipeline>,
2330 /// What the slots past the new dimension must hold when an append completes
2331 /// a partial chunk (issue #296).
2332 pub(crate) fill: crate::fill_value::PaddingFill,
2333}
2334
2335/// State for a file that persists its free space on disk. Carries the file's
2336/// fixed file-space parameters and the extents of the free-space-manager blocks
2337/// (and superblock extension) the *current* on-disk file uses, so the next
2338/// persisting commit can reclaim them when it writes fresh ones.
2339struct PersistState {
2340 strategy: FileSpaceStrategy,
2341 threshold: u64,
2342 page_size: u64,
2343 /// `(addr, len)` of the on-disk superblock-extension header and every
2344 /// free-space-manager `FSHD`/`FSSE` block currently in use. Superseded — and
2345 /// therefore freed — by the next persisting commit.
2346 old_blocks: Vec<(u64, u64)>,
2347}
2348
2349/// A snapshot of a writable file's live space usage (issue #150).
2350///
2351/// This is the mutating-session counterpart of the read-only accounting on
2352/// [`File`](crate::File) ([`file_size`](crate::File::file_size) and
2353/// [`persisted_free_space`](crate::File::persisted_free_space)): it describes the
2354/// file *as the session currently holds it*, taken atomically at the moment of
2355/// the [`space_accounting`](crate::File::space_accounting) call.
2356///
2357/// It reflects the committed file plus any immediate in-place appends
2358/// ([`append`](crate::Dataset::append)), but **not** edits still
2359/// staged for the next [`commit`](crate::File::commit) — `create_group`,
2360/// `create_dataset`, `write_dataset`, `append_dataset`, `delete`, `copy`,
2361/// `copy_from`, and attribute edits change these figures only when they are
2362/// applied at commit. Use [`has_staged_edits`](crate::File::has_staged_edits) to
2363/// tell whether such pending work exists.
2364#[derive(Debug, Clone, PartialEq, Eq)]
2365#[non_exhaustive]
2366pub struct SpaceAccounting {
2367 /// The session's current logical size in bytes: the byte length of the file
2368 /// as the session holds it. It equals what
2369 /// [`File::file_size`](crate::File::file_size) reports for the file on disk
2370 /// right now (the HDF5 `H5Fget_filesize` value), because the session keeps its
2371 /// in-memory mirror byte-for-byte identical to the file — every committed
2372 /// write and every immediate in-place append
2373 /// ([`append`](crate::Dataset::append)) updates both together.
2374 ///
2375 /// It is not monotonic: [`commit`](crate::File::commit) can reclaim trailing
2376 /// free space and *shrink* the file. It can also exceed the superblock's
2377 /// recorded end-of-file address when the file was opened carrying unaccounted
2378 /// trailing bytes (the same slack [`File::file_size`](crate::File::file_size)
2379 /// surfaces), since opening does not rewrite that address.
2380 pub logical_size: u64,
2381 /// Total reusable free bytes this session can draw from before the file has to
2382 /// grow — the summed length of
2383 /// [`reusable_free_space`](Self::reusable_free_space).
2384 ///
2385 /// Counts holes left inside [`logical_size`](Self::logical_size) by this
2386 /// session's earlier commits (superseded object headers, the blocks of
2387 /// deleted objects) and, for a file created with
2388 /// `H5Pset_file_space_strategy(persist = true)` and no userblock, the regions
2389 /// seeded from the on-disk free-space managers when the session was opened (so
2390 /// reuse spans sessions). A fresh non-persisting session reports `0` even if
2391 /// the file contains holes left by other tools — those are never tracked. It
2392 /// is neither a lower bound on the next write's growth nor a promise of
2393 /// shrinkage: a region counted here may be truncated away at commit — rather
2394 /// than reused — if adjacent space is later freed and the coalesced run
2395 /// reaches end-of-file.
2396 ///
2397 /// **Which write can spend it is not uniform on a persisting file.** Such a
2398 /// session may write into a hole only once it has taken that hole *out* of
2399 /// the on-disk managers, so an in-place [`append`](crate::Dataset::append)
2400 /// holds a reserve of up to a megabyte per draw that this figure counts and
2401 /// the next [`commit`](crate::File::commit) cannot place into: the commit
2402 /// gives the unspent part back in the same tail that rewrites the managers,
2403 /// which runs *after* it has placed everything. The commit after that one
2404 /// can. So on a
2405 /// persisting file read this as "space this session will reuse rather than
2406 /// grow for", not as "space the very next commit can fill".
2407 ///
2408 /// It is also not the on-disk view. That reserve is deliberately absent from
2409 /// the free-space managers for as long as the session holds it, so
2410 /// [`File::persisted_free_space`](crate::File::persisted_free_space) — which
2411 /// reads those managers — reports less than this by whatever is reserved,
2412 /// and is the figure to compare against what another tool would find.
2413 pub reusable_free_bytes: u64,
2414 /// The reusable free regions as `(offset, length)` pairs, sorted ascending by
2415 /// offset and fully coalesced (no two regions touch or overlap).
2416 ///
2417 /// The offsets are **absolute** file offsets (from byte 0, including any
2418 /// userblock prefix), matching [`logical_size`](Self::logical_size). This
2419 /// differs from [`File::persisted_free_space`](crate::File::persisted_free_space),
2420 /// whose pairs are relative to the superblock base address; the two coincide
2421 /// for a file with no userblock (base address 0), which is the only kind whose
2422 /// persisted free space a session seeds. Empty when nothing is reusable.
2423 pub reusable_free_space: Vec<(u64, u64)>,
2424}
2425
2426impl WriteEngine {
2427 /// Open an existing HDF5 file for in-place editing under an explicit
2428 /// file-locking policy.
2429 ///
2430 /// Reads the file into memory and retains a read/write handle. Under
2431 /// [`FileLocking::Enabled`] it takes an exclusive OS advisory lock so the file
2432 /// cannot be opened concurrently by another writer or reader; the lock is
2433 /// released automatically when the session is dropped or the process exits
2434 /// (including on a crash). Fails with [`Error::FileLocked`] if the file is
2435 /// already locked, or [`Error::EditUnsupported`] if the file is not a
2436 /// supported target; its documentation enumerates the exact requirements.
2437 /// `HDF5_USE_FILE_LOCKING` overrides the requested policy, as in the C
2438 /// library.
2439 pub fn open_with_locking<P: AsRef<Path>>(path: P, locking: FileLocking) -> Result<Self, Error> {
2440 Self::open_inner(path.as_ref(), Some(locking))
2441 }
2442
2443 /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2444 /// behind an image that withholds its whole-file slice, so every read takes
2445 /// the [`Source`] path rather than the slice fast path.
2446 ///
2447 /// Distinct from [`open_rw_with_strategy`](Self::open_rw_with_strategy), which withholds the
2448 /// slice *and* the residency: this one still mirrors the file, so a test can
2449 /// compare the two read forms on a file the bounded open would refuse.
2450 #[cfg(test)]
2451 pub(crate) fn open_source_only(path: &Path) -> Result<Self, Error> {
2452 Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2453 Ok(Box::new(crate::image::SourceOnlyImage::new(
2454 Self::read_mirror(handle)?,
2455 )))
2456 })
2457 }
2458
2459 /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2460 /// behind an image whose writes into `fails` behave like a dying device:
2461 /// the first alters the file and then errors, the rest are refused
2462 /// outright. See [`crate::image::TornWriteImage`] for why it takes both to
2463 /// reach the state a refused commit cannot repair.
2464 #[cfg(test)]
2465 pub(crate) fn open_torn_writes(
2466 path: &Path,
2467 fails: core::ops::Range<u64>,
2468 ) -> Result<Self, Error> {
2469 Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2470 Ok(Box::new(crate::image::TornWriteImage::new(
2471 Self::read_mirror(handle)?,
2472 fails,
2473 )))
2474 })
2475 }
2476
2477 /// Open a bounded session whose image counts the bytes read through it, so a
2478 /// test can assert that an operation touches only a small part of the file.
2479 /// The counter is shared with the caller.
2480 #[cfg(test)]
2481 pub(crate) fn open_bounded_counting(
2482 path: &Path,
2483 read_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
2484 ) -> Result<Self, Error> {
2485 let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2486 Ok(Box::new(crate::image::CountingImage::new(
2487 Box::new(HandleImage::new(
2488 handle,
2489 len,
2490 crate::source::MetadataCacheConfig::disabled(),
2491 )),
2492 read_bytes,
2493 std::sync::Arc::default(),
2494 )))
2495 })?;
2496 session.batched_appends = true;
2497 Ok(session)
2498 }
2499
2500 /// Open a session under `policy` whose image counts the `fsync`s issued
2501 /// through it, so a test can assert what a write path actually costs. The
2502 /// counter is shared with the caller.
2503 ///
2504 /// It takes the bounded backing and its flags, so the session matches what
2505 /// [`File::open_rw`](crate::File::open_rw) builds for a latest-format file;
2506 /// the barrier sites live on the engine, above the choice of image, so one
2507 /// backing exercises them all.
2508 #[cfg(test)]
2509 pub(crate) fn open_sync_counting(
2510 path: &Path,
2511 policy: SyncPolicy,
2512 syncs: std::sync::Arc<std::sync::atomic::AtomicU64>,
2513 ) -> Result<Self, Error> {
2514 let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2515 Ok(Box::new(crate::image::CountingImage::new(
2516 Box::new(HandleImage::new(
2517 handle,
2518 len,
2519 crate::source::MetadataCacheConfig::disabled(),
2520 )),
2521 std::sync::Arc::default(),
2522 syncs,
2523 )))
2524 })?;
2525 session.batched_appends = true;
2526 session.bounded = true;
2527 session.set_sync_policy(policy);
2528 Ok(session)
2529 }
2530
2531 /// Open an existing file for read-write editing under `strategy`: the one
2532 /// place that picks between the bounded backing (a [`HandleImage`] keeping no
2533 /// whole-file mirror, so resident memory is the metadata-cache budget plus
2534 /// whatever is being parsed) and the whole-file mirror. Backs
2535 /// [`File::open_rw`](crate::File::open_rw), which selects between them by
2536 /// the strategy it is given.
2537 ///
2538 /// The eligibility rules are checked here rather than deferred, because a
2539 /// caller who asked for bounded memory cannot be silently given the mirror
2540 /// instead. Under [`MemoryStrategy::Bounded`] a file the bounded engine
2541 /// cannot edit is refused up front; [`MemoryStrategy::Auto`] opts in to
2542 /// falling back to the mirror instead (issue #198, steps 3 and 4).
2543 ///
2544 /// Only a *bounded-only* limitation is worth falling back for — see
2545 /// [`bounded_only_limitation`]. Non-8-byte offsets or lengths are refused by
2546 /// [`open_imaged`](Self::open_imaged) for both engines, and so is an
2547 /// unsupported superblock version; a paged file without persisted free space
2548 /// is refused below for both.
2549 pub(crate) fn open_rw_with_strategy(
2550 path: &Path,
2551 cache: MetadataCacheConfig,
2552 locking: FileLocking,
2553 strategy: MemoryStrategy,
2554 ) -> Result<Self, Error> {
2555 if strategy == MemoryStrategy::Mirrored {
2556 return Self::open_with_locking(path, locking);
2557 }
2558 let mut session = Self::open_imaged(path, Some(locking), |handle, len| {
2559 Ok(Box::new(HandleImage::new(handle, len, cache)))
2560 })?;
2561 session.batched_appends = true;
2562 session.bounded = true;
2563 // Refusals that apply to *both* backings come first, or falling back for a
2564 // bounded-only limitation would skip them and hand back a session that
2565 // cannot commit. A paged file with no persisted managers has no on-disk
2566 // record of which pages hold metadata and which hold raw data, so nothing
2567 // can keep the pages segregated; the staged commit refuses it too, so
2568 // deferring would only trade this error for the same one later, with work
2569 // already staged. A userblock is one way to reach this state without the
2570 // file saying `persist = false`: persisted free space is declined for a
2571 // non-zero base address, which leaves the managers unseeded all the same.
2572 if session.paged.is_some() && session.persist.is_none() {
2573 return Err(Error::EditUnsupported(
2574 "read-write access to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
2575 persisted free space; recreate the file with \
2576 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to grow it in place",
2577 ));
2578 }
2579 if let Some(reason) = bounded_only_limitation(&session) {
2580 if strategy == MemoryStrategy::Bounded {
2581 return Err(Error::EditUnsupported(reason));
2582 }
2583 // Release the handle and its exclusive lock before reopening, or the
2584 // mirrored open would contend with the probe we are discarding —
2585 // fatally so on Windows, where the OS lock is mandatory. Dropping a
2586 // bare `WriteEngine` writes nothing: the free-space finalize that a
2587 // dropped writer owes lives on `FileInner`, which this is not yet.
2588 // Another writer can take the lock in that window; the reopen then
2589 // reports `Error::FileLocked`, which is the truthful answer.
2590 drop(session);
2591 return Self::open_with_locking(path, locking);
2592 }
2593 Ok(session)
2594 }
2595
2596 /// Open an existing file for SWMR (single-writer/multiple-reader) writing:
2597 /// take **no** OS lock at all and raise the superblock's SWMR-write
2598 /// consistency flag. Backs [`File::open_swmr_writer`](crate::File::open_swmr_writer).
2599 ///
2600 /// The no-lock is unconditional — `lock = None` never reaches
2601 /// `acquire_exclusive`, so `HDF5_USE_FILE_LOCKING` cannot reintroduce a lock
2602 /// that would block the concurrent readers SWMR exists to permit (fatally so
2603 /// on Windows, where OS locks are mandatory). Requires a latest-format
2604 /// (version-3 superblock) file with no userblock and no persisted
2605 /// free-space, so the superblock can be rewritten in place.
2606 ///
2607 /// The version-3 requirement is the C library's (`H5F__super_read`: "superblock
2608 /// version for SWMR is less than 3"), and it is what keeps the SWMR-write flag
2609 /// meaningful: neither library reads the status-flags byte back on an older
2610 /// superblock, so a flag raised there would announce a live writer to nobody.
2611 /// This crate's writer emits version 3, so no file it produces is affected.
2612 pub(crate) fn open_swmr_writer<P: AsRef<Path>>(
2613 path: P,
2614 sync_policy: SyncPolicy,
2615 ) -> Result<Self, Error> {
2616 let mut session = Self::open_inner(path.as_ref(), None)?;
2617 // Before the flag write below, which is a durability point like any
2618 // other: a caller who asked for no `fsync` gets none, and the flag still
2619 // reaches every other process, which reads it from the operating system.
2620 session.set_sync_policy(sync_policy);
2621 if session.superblock.version < 3
2622 || !session.superblock.base_address.is_zero()
2623 || session.persist.is_some()
2624 {
2625 return Err(Error::SwmrAppendUnsupported(
2626 "SWMR writing requires a latest-format file (v3 superblock) with no userblock \
2627 and no persisted free-space",
2628 ));
2629 }
2630 session.swmr_mode = true;
2631 session.set_consistency_flags(SWMR_WRITE_FLAGS)?;
2632 Ok(session)
2633 }
2634
2635 /// Set the superblock's consistency flags in the mirror and on disk, then
2636 /// flush. The one place this crate writes that byte: the SWMR-write flag on
2637 /// open and off on close, and the page buffer's crash mark
2638 /// ([`raise_crash_mark`](Self::raise_crash_mark)).
2639 ///
2640 /// Requires a version-2/3 superblock, since [`Superblock::serialize`] emits
2641 /// that layout; `open_swmr_writer` and
2642 /// [`set_page_buffer_size`](Self::set_page_buffer_size) each check it.
2643 ///
2644 /// The root address is stored on disk *relative to the base address* while
2645 /// the session holds it absolute (see the normalization in `open_imaged`), so
2646 /// the serialized clone converts it back — as the commit tail does at the
2647 /// only other place this crate serializes a superblock. Without it, writing
2648 /// the absolute address here repoints the root past the end of the file while
2649 /// claiming only to have touched a flag, and the file stops reading.
2650 ///
2651 /// `open_swmr_writer` happens to hold a base of zero, since it turns a
2652 /// userblock away outright. Its sibling no longer does:
2653 /// [`set_page_buffer_size`](Self::set_page_buffer_size) once required
2654 /// persisted free space, which is declined for a non-zero base, and issue
2655 /// #357 scoped that refusal to paged files — so an unpaged userblock file
2656 /// now reaches here with a base to convert. It was never a property to build
2657 /// on, which is why `a_status_flag_write_leaves_a_userblock_files_root_alone`
2658 /// pins the conversion directly rather than through a caller.
2659 fn set_consistency_flags(&mut self, flags: u32) -> Result<(), Error> {
2660 self.superblock.consistency_flags = flags;
2661 self.held_status_flags = flags;
2662 let mut on_disk = self.superblock.clone();
2663 // `open_imaged` built the in-memory address by adding the base to the
2664 // stored one, so this cannot be below the base — but it is a subtraction
2665 // over two file-derived numbers, and `relative` reports rather than wraps.
2666 on_disk.root_group_address = on_disk
2667 .base_address
2668 .relative(on_disk.root_group_address)?
2669 .get();
2670 let bytes = on_disk.serialize();
2671 self.write_at(self.sb_sig_off, &bytes)?;
2672 self.barrier_data()?;
2673 Ok(())
2674 }
2675
2676 /// Raise the page buffer's crash mark: superblock status-flag bit 0
2677 /// (`H5F_SUPER_WRITE_ACCESS`), the byte the C library raises for any writer
2678 /// and this crate otherwise raises only for a SWMR one.
2679 ///
2680 /// Why a page buffer is not offered without one is
2681 /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)'s
2682 /// to explain; what matters here is that two properties of *this* function
2683 /// are load-bearing, and neither is the [`SyncPolicy`]'s to skip:
2684 ///
2685 /// - It runs **before** the buffering mode changes, so no buffered write can
2686 /// reach the disk ahead of it. Its caller states why nothing can fail in
2687 /// between.
2688 /// - It is **fsynced**, unconditionally — the half no test here can see.
2689 /// Reaching the operating system already covers a process that dies, and
2690 /// this suite reproduces no power loss, so deleting the `sync_all` fails
2691 /// nothing. It is there because power loss is exactly the case where the
2692 /// buffer's held writes may be partly on the platter while a mark still in
2693 /// the page cache is not. One `fsync` per session buys it.
2694 ///
2695 /// A failure rolls the byte back, best-effort. The alternative is a file
2696 /// marked in use by a session that never opened, which nothing can read until
2697 /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — a steep price
2698 /// for an open that failed. Best-effort because the rollback writes through
2699 /// the same handle that just failed; it is a chance, not a guarantee.
2700 fn raise_crash_mark(&mut self) -> Result<(), Error> {
2701 let raised = self
2702 .set_consistency_flags(file_lock::WRITE_ACCESS)
2703 .and_then(|()| self.image.sync_all());
2704 if raised.is_err() {
2705 let _ = self.set_consistency_flags(0);
2706 let _ = self.image.sync_all();
2707 }
2708 raised
2709 }
2710
2711 /// Take down whatever status flags this session raised, once everything they
2712 /// stood for is durable — the SWMR pair, or a page buffer's crash mark. A
2713 /// no-op for a session that raised none, which is every ordinary
2714 /// [`File::open_rw`](crate::File::open_rw) session.
2715 ///
2716 /// The whole byte goes to zero, so this cannot leave the half-set state
2717 /// [`check_status_flags`](crate::file_lock::check_status_flags) reports as
2718 /// flags in disagreement; that is why the guard asks whether anything is held
2719 /// rather than which bits are.
2720 ///
2721 /// Called from `File::close` and `FileInner::drop`, **after** their
2722 /// `force_sync`. The order is the whole point: clearing first and flushing
2723 /// second would leave a window in which the file's writes are still in memory
2724 /// and nothing on the disk says so, which is the state the flags exist to
2725 /// make unreachable. Nothing in this suite can tell the two orders apart —
2726 /// it takes a crash inside the window — so the argument is the reason, not a
2727 /// test.
2728 pub(crate) fn release_status_flags(&mut self) -> Result<(), Error> {
2729 if self.held_status_flags == 0 {
2730 return Ok(());
2731 }
2732 self.set_consistency_flags(0)?;
2733 self.image.sync_all()
2734 }
2735
2736 /// Shared open path. `lock = Some(policy)` acquires an exclusive OS lock under
2737 /// that policy (the ordinary read-write session); `lock = None` takes no lock
2738 /// at all (the SWMR writer — see [`open_swmr_writer`](Self::open_swmr_writer)).
2739 fn open_inner(path: &Path, lock: Option<FileLocking>) -> Result<Self, Error> {
2740 Self::open_imaged(path, lock, |handle, _len| {
2741 Ok(Box::new(Self::read_mirror(handle)?))
2742 })
2743 }
2744
2745 /// Read `handle` whole into a [`MirrorImage`]. The one place the engine
2746 /// still assumes it can hold the file, kept behind a named constructor so
2747 /// the mirrorless opens visibly do not call it.
2748 ///
2749 /// `read_to_end` reads from the handle's *current* cursor, and the open path
2750 /// has already read the superblock through it, so this rewinds first. Reading
2751 /// from wherever the last read landed would mirror a truncated file — with no
2752 /// error to say so, since a short mirror is a valid `Vec<u8>`.
2753 fn read_mirror(mut handle: fs::File) -> Result<MirrorImage, Error> {
2754 handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
2755 let mut data = Vec::new();
2756 handle.read_to_end(&mut data).map_err(Error::Io)?;
2757 Ok(MirrorImage::new(handle, data))
2758 }
2759
2760 /// Shared open path over any backing: acquire the handle (and, when asked,
2761 /// the exclusive lock), parse and validate the superblock through a borrowed
2762 /// view of the handle, and only then let `build` decide how the bytes are
2763 /// held.
2764 ///
2765 /// Every refusal comes before `build`, because `build` may read the whole
2766 /// file: reaching a refusal after it would spend `O(file size)` on a file
2767 /// that is then rejected — a 20 GB flagged file read into memory and thrown
2768 /// away. The superblock reads themselves are a few bounded windows either
2769 /// way, so nothing is read twice.
2770 ///
2771 /// `build` receives the file's length as well as the handle because a
2772 /// mirrorless image has to be told its end-of-file — it has no buffer whose
2773 /// length implies it.
2774 ///
2775 /// Nothing below this point reads the file as a slice, which is what lets
2776 /// one engine open a whole-file mirror, a mirrorless handle, and (in tests)
2777 /// a mirror that withholds its slice.
2778 fn open_imaged(
2779 path: &Path,
2780 lock: Option<FileLocking>,
2781 build: impl FnOnce(fs::File, u64) -> Result<Box<dyn FileImage>, Error>,
2782 ) -> Result<Self, Error> {
2783 let handle = fs::OpenOptions::new()
2784 .read(true)
2785 .write(true)
2786 .open(path)
2787 .map_err(Error::Io)?;
2788 // Acquire the exclusive lock before reading or mutating; the retained
2789 // `handle` holds it for the session's life. A `None` policy (SWMR) never
2790 // reaches `acquire_exclusive`, so no lock is ever taken.
2791 if let Some(policy) = lock {
2792 file_lock::acquire_exclusive(&handle, policy, path)?;
2793 }
2794 let len = handle.metadata().map_err(Error::Io)?.len();
2795 // Read the superblock through the handle itself, before any image owns
2796 // it. `probe` borrows, so it is gone by the time `build` takes the
2797 // handle; it leaves the handle's cursor wherever its last read ended,
2798 // which is why the mirror positions the handle before reading it whole.
2799 let probe = crate::image::BorrowedHandle::new(&handle, len);
2800 let sb_sig_off = signature::find_signature_in(&probe)?;
2801 let mut superblock = Superblock::parse_from_source(&probe, sb_sig_off)?;
2802
2803 if superblock.version > 3 {
2804 return Err(Error::EditUnsupported("unsupported superblock version"));
2805 }
2806 // Refuse a file a writer already holds, before anything is mutated. This
2807 // is the one exclusion the OS lock above cannot make: a SWMR writer
2808 // takes no lock, so its file is lock-free but flagged (issue #245).
2809 file_lock::check_status_flags(
2810 &superblock,
2811 file_lock::OpenIntent::Write,
2812 file_lock::OpenTarget::Path(path),
2813 )?;
2814 if superblock.offset_size != OFFSET_SIZE || superblock.length_size != LENGTH_SIZE {
2815 return Err(Error::EditUnsupported(
2816 "only 8-byte offsets and lengths are supported for in-place editing",
2817 ));
2818 }
2819 // A userblock shifts the whole HDF5 image forward by `base_address`: the
2820 // superblock sits at the base address and every stored address is relative
2821 // to it (the end-of-file address is the sole absolute field). The editor
2822 // supports this by reading at `stored + base` and writing back
2823 // `file_offset - base`. Only the canonical layout — superblock located
2824 // exactly at the base address (e.g. a MATLAB v7.3 `.mat` file's 512-byte
2825 // userblock) — is accepted; a base address that disagrees with the
2826 // superblock's location is a relocated or malformed file we will not rewrite.
2827 if superblock.base_address != BaseAddress::new(sb_sig_off) {
2828 return Err(Error::EditUnsupported(
2829 "a file whose superblock is not located at its base address is not editable in place",
2830 ));
2831 }
2832 // Normalize the root group address to an absolute file offset, exactly as
2833 // the reader does (`reader::parse_superblock`), so `resolve_path_any` and
2834 // the link-graph walk index the image correctly. It is converted back to a
2835 // stored (base-relative) address only when the superblock is serialized on
2836 // commit.
2837 superblock.root_group_address = superblock
2838 .base_address
2839 .absolute(StoredAddress::new(superblock.root_group_address))?;
2840
2841 // Everything that can refuse this file has run; only now is it worth
2842 // holding the bytes.
2843 let image = build(handle, len)?;
2844
2845 let mut session = Self {
2846 image,
2847 sb_sig_off,
2848 superblock,
2849 staged: StagedEdits::default(),
2850 appender_claims: Vec::new(),
2851 next_appender_token: 0,
2852 free: FreeList::new(),
2853 reserved: FreeList::new(),
2854 proved_free_of_references: false,
2855 persist: None,
2856 located: HashMap::new(),
2857 vl_overwrite_heaps: HashMap::new(),
2858 superseded_heaps: Vec::new(),
2859 inplace_undo: Vec::new(),
2860 swmr_mode: false,
2861 paged: None,
2862 committed: false,
2863 resolved: HashMap::new(),
2864 batched_appends: false,
2865 bounded: false,
2866 libver_ceiling: None,
2867 fsm_len: len,
2868 publish_attempted: false,
2869 staging_batch: false,
2870 staged_generation: 0,
2871 held_status_flags: 0,
2872 sync_policy: SyncPolicy::Always,
2873 };
2874 // If the file persists its free space, seed the free list from the
2875 // on-disk managers and arm persistence for future commits. Best-effort:
2876 // an unreadable or non-persisting extension simply leaves the session in
2877 // the default, non-persisting mode.
2878 session.load_persisted_free_space();
2879 // Gather this session's writes, now that the page size the file was laid
2880 // out on is known. Only an exclusively locked session: `lock = None` is
2881 // the SWMR writer, whose concurrent readers observe the order its ordered
2882 // phases become visible in, and so must see every write as it is made
2883 // (issue #288).
2884 //
2885 // `lock.is_some()` is a proxy for "not the SWMR writer", used because
2886 // `swmr_mode` is not set until after this. It is exact only while
2887 // `open_swmr_writer` is the sole lock-free entry point: a future one that
2888 // took a lock would silently *gain* gathering, which is the direction
2889 // that costs a reader. The other direction — a lock-free non-SWMR open
2890 // losing gathering — costs only writes. Whoever adds such an entry point
2891 // owns this condition.
2892 if lock.is_some() {
2893 let page_size = session.gather_page_size();
2894 session
2895 .image
2896 .set_write_buffering(WriteBuffering::Operation {
2897 page_size,
2898 max_bytes: WRITE_GATHER_BYTES,
2899 })?;
2900 }
2901 Ok(session)
2902 }
2903
2904 /// The page this session's writes are merged within: the file's own
2905 /// file-space page size when it is paged, and the format's default otherwise.
2906 fn gather_page_size(&self) -> u64 {
2907 self.paged
2908 .as_ref()
2909 .map_or(DEFAULT_GATHER_PAGE, |pg| pg.page_size)
2910 }
2911
2912 /// Let this session's writes span operations, up to `max_bytes` of them: the
2913 /// `H5Pset_page_buffer_size` analogue, requested through
2914 /// [`FileAccessProperties::with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size).
2915 ///
2916 /// Refused for a budget below the page this session merges within — the
2917 /// file's own file-space page size when it is paged and
2918 /// [`DEFAULT_GATHER_PAGE`] when it is not, which is what
2919 /// [`gather_page_size`](Self::gather_page_size) answers. A buffer that
2920 /// cannot hold one page flushes on every page it touches. The C library
2921 /// reaches the same conclusion and acts on it *silently*, rounding the budget
2922 /// up at `H5Fopen`, which is the one part of its behavior not worth copying.
2923 ///
2924 /// Every larger budget is honored, including one below the
2925 /// [`WRITE_GATHER_BYTES`] this session was already gathering under. That was
2926 /// refused until issue #391; see the constant for what a small one costs.
2927 ///
2928 /// It does **not** require a paged file, where `H5Pset_page_buffer_size`
2929 /// does. The C page buffer is a page *cache*, and `H5PB_create` rejects an
2930 /// unpaged file because its `min_meta_perc`/`min_raw_perc` reservations are
2931 /// counted in pages that the paged allocator keeps segregated by kind. This
2932 /// is a write gatherer instead: it merges runs within a page-sized window and
2933 /// flushes whole, so the window is all it needs, and `gather_page_size`
2934 /// supplies one on an unpaged file already — it is what every read-write
2935 /// session's default [`Operation`](WriteBuffering::Operation) gathering runs
2936 /// under. Lifting the requirement leaves the file byte-identical, which is
2937 /// what the unpaged arm of `a_page_buffer_holds_dirty_pages_across_operations`
2938 /// asserts (issue #357).
2939 ///
2940 /// A zero budget leaves the session's default gathering alone, matching the
2941 /// property's own "unset" value.
2942 pub(crate) fn set_page_buffer_size(&mut self, max_bytes: usize) -> Result<(), Error> {
2943 if max_bytes == 0 {
2944 return Ok(());
2945 }
2946 // A lock-free session's readers observe the order its writes become
2947 // visible in. `File::open_swmr_writer` refuses this property before it
2948 // raises the on-disk flag, which is the refusal a caller sees; this one is
2949 // here because the guarantee belongs to the engine that would break it,
2950 // and a SWMR session can otherwise clear every check below — an unpaged
2951 // version-3 file with an ample budget is exactly what SWMR writing wants.
2952 if self.swmr_mode {
2953 return Err(Error::EditUnsupported(
2954 "a SWMR writer cannot buffer its writes: its readers observe the order they \
2955 become visible in",
2956 ));
2957 }
2958 // One page is the whole of the size rule, and a buffer that cannot hold
2959 // one drains on every page it touches.
2960 //
2961 // A budget below `WRITE_GATHER_BYTES` was refused alongside it until
2962 // issue #391, on the grounds that a page buffer *replaces* the byte
2963 // budget the session was already gathering under rather than adding to
2964 // it, so a smaller one is also the point at which one long run is flushed
2965 // and restarted. That cost is real — nothing here bypasses the gatherer
2966 // the way `H5PB_write` bypasses the C page buffer for I/O of a page or
2967 // more (issue #357) — but it is not this crate's trade to make. The
2968 // budget it replaces is a per-operation peak released at every barrier,
2969 // while this one is held across operations, so a smaller number lowers
2970 // both the peak and the residency, and a writer inside a tight memory cap
2971 // is entitled to spend the writes for that. See `WRITE_GATHER_BYTES` for
2972 // the measurement.
2973 let page_size = self.gather_page_size();
2974 if (max_bytes as u64) < page_size {
2975 return Err(Error::EditUnsupported(
2976 "a page buffer must be at least the file's file-space page size",
2977 ));
2978 }
2979 // A page buffer holds dirty pages across ordering barriers, and under
2980 // `Always` every barrier is an `fsync` that flushes it — so it would hold
2981 // nothing, and the caller would have paid the mark below for nothing in
2982 // return. It lives here with its three siblings rather than at the fapl,
2983 // because a refusal kept apart from the others is one
2984 // `create_would_refuse_reopen` forgets to restate, which is how this one
2985 // came to write the file before refusing it.
2986 if self.sync_policy == SyncPolicy::Always {
2987 return Err(Error::EditUnsupported(
2988 "a page buffer does nothing under SyncPolicy::Always, whose every barrier is \
2989 an fsync that flushes it; pair with_page_buffer_size with \
2990 with_sync_policy(SyncPolicy::OnClose), or drop it",
2991 ));
2992 }
2993 // A paged file whose free space is not persisted can neither commit nor
2994 // append — both are refused — so a page buffer would have nothing to
2995 // hold, while its mark blocked every reader for the session's life.
2996 //
2997 // Scoped to a paged file: an *unpaged* one commits and appends whether it
2998 // persists its free space or not, so there is nothing here to refuse. The
2999 // base address the mark's superblock rewrite needs is not this check's to
3000 // settle — `set_consistency_flags` converts the root address itself, and
3001 // is tested for a userblock file directly rather than through a caller.
3002 if self.paged.is_some() && self.persist.is_none() {
3003 return Err(Error::EditUnsupported(
3004 "a page buffer on a paged file needs its free space persisted: without it \
3005 this session can neither commit nor append, so the buffer would hold nothing \
3006 while marking the file against every reader; recreate the file with \
3007 with_file_space_strategy(FileSpaceStrategy::Page, true, ..), or drop the \
3008 page buffer",
3009 ));
3010 }
3011 // The crash mark below is the whole reason this property is offered at
3012 // all, and only a version-3 superblock carries one that anybody reads:
3013 // `check_status_flags` is gated there, matching where the C library gates
3014 // it. A version-2 paged file is reachable — `LibVer::V18` writes a
3015 // version-2 superblock and nothing about paged file space rules it out —
3016 // so this is a refusal rather than an assertion (issue #308).
3017 if self.superblock.version < 3 {
3018 return Err(Error::EditUnsupported(
3019 "a page buffer marks the file for the life of the session, so that a session \
3020 that crashes leaves one every reader refuses rather than one that reads \
3021 clean; only a version-3 superblock carries a status-flags byte any library \
3022 reads back, and this file's is older. Rewrite it at the 1.10 format \
3023 (repack, or FileBuilder's default bounds), or drop the page buffer",
3024 ));
3025 }
3026 // Before the mode change, and durable: from here on a write may sit in
3027 // memory across a barrier, and the file has to say so on the disk first.
3028 //
3029 // Nothing can fail between the two and leave the file marked with no
3030 // session behind it. `raise_crash_mark` ends in a `sync_all`, which
3031 // flushes, so the gatherer is empty when `set_write_buffering` flushes it
3032 // again — the one fallible step it takes.
3033 self.raise_crash_mark()?;
3034 self.image.set_write_buffering(WriteBuffering::Session {
3035 page_size,
3036 max_bytes,
3037 })
3038 }
3039
3040 /// Read the superblock-extension File Space Info message; if it requests
3041 /// persistence, seed [`self.free`](Self::free) from the on-disk free-space
3042 /// managers and record the manager/extension block extents for reclamation on
3043 /// the next commit. Silent on any malformed or absent metadata — persistence
3044 /// is then simply off for this session.
3045 fn load_persisted_free_space(&mut self) {
3046 if self.superblock.version < 2 {
3047 return; // no superblock extension exists before v2
3048 }
3049 let Some(ext_rel) = self.superblock.superblock_extension_address else {
3050 return;
3051 };
3052 let ext_rel = StoredAddress::new(ext_rel);
3053 if ext_rel.is_undefined(self.superblock.offset_size) {
3054 return;
3055 }
3056 // The header is read at the extension's absolute file offset. That is the
3057 // same offset on the base-0 file every path below the userblock check
3058 // sees, but the check itself needs the strategy of a *userblock* file.
3059 let Ok(ext_addr) = self.superblock.base_address.absolute(ext_rel) else {
3060 return;
3061 };
3062 let Some(info) = self.extension_fsinfo(ext_addr) else {
3063 return;
3064 };
3065 // Free-space reuse and persistence are not yet base-address aware: the
3066 // persisted section addresses (and the extension/manager block walk below)
3067 // are read as absolute, so on a userblock file they would seed `self.free`
3068 // with wrong regions that a later allocation could hand out into live
3069 // data. Leave persistence off for such a file — the on-disk managers stay
3070 // untouched and valid, this session simply appends rather than reusing.
3071 //
3072 // A *paged* userblock file is a different matter: appending without page
3073 // awareness would mix metadata and raw data in its pages and leave its end
3074 // of allocation unaligned, quietly producing a file that still claims the
3075 // paged strategy but no longer satisfies it. Install the paged marker
3076 // without persistence so the commit refusal below catches it, which is the
3077 // same rule a paged non-persisting file already takes.
3078 if !self.superblock.base_address.is_zero() {
3079 if info.strategy == FileSpaceStrategy::Page && info.page_size > 0 {
3080 self.paged = Some(PagedEdit::new(info.page_size));
3081 }
3082 return;
3083 }
3084 // Record the paged strategy regardless of the persist flag: a paged commit
3085 // needs page-aware bookkeeping, and a paged file that does not persist its
3086 // free space is refused outright (see `PagedEdit` and the commit refusal).
3087 //
3088 // A zero page size is refused rather than installed: every page calculation
3089 // divides by it, so a corrupt or hostile file declaring `Page` with a page
3090 // size of 0 would panic the editor. Leaving `paged` unset makes the file
3091 // take the ordinary flat path, which needs no page geometry.
3092 let paged = info.strategy == FileSpaceStrategy::Page && info.page_size > 0;
3093 if paged {
3094 self.paged = Some(PagedEdit::new(info.page_size));
3095 }
3096 if !info.persist {
3097 return;
3098 }
3099 let os = self.superblock.offset_size;
3100 let file_len = self.image.len();
3101
3102 // Seed the free list(s) with every persisted section (addresses are stored
3103 // relative to the base address, which this editor requires to be 0).
3104 // Defensive against a malformed or corrupt manager: skip a section that is
3105 // empty, runs past end-of-file, or overlaps one already taken. A
3106 // well-formed file (this crate's or the C library's) has none of these;
3107 // tolerating them keeps a bad file from seeding a bogus or double-counted
3108 // free region that a later commit would hand out into live data.
3109 if paged {
3110 // A paged file's free space is segregated across per-page-type
3111 // managers, so read each slot on its own and keep the page type its
3112 // slot implies ([`PagedEdit::slot_list`]). Flattening them (as the
3113 // non-paged path below does) would lose exactly the distinction the
3114 // commit has to preserve. A section whose slot does not settle its page
3115 // type is recorded but never handed out.
3116 let page_size = info.page_size;
3117 let mut tagged: Vec<(FreeSection, Option<PageType>)> = Vec::new();
3118 for (slot, &m) in info.manager_addrs.iter().enumerate() {
3119 if StoredAddress::new(m).is_undefined(os) {
3120 continue;
3121 }
3122 let Ok(sections) = free_space_manager::read_persisted_sections_source(
3123 &self.image(),
3124 &[m],
3125 BaseAddress::ZERO,
3126 os,
3127 )
3128 .map(|(sections, _)| sections) else {
3129 continue;
3130 };
3131 for s in sections {
3132 let ty = PagedEdit::slot_list(slot, s.addr.get(), s.size, page_size);
3133 tagged.push((s, ty));
3134 }
3135 }
3136 // Distinct sections have distinct addresses in any well-formed file,
3137 // so the tie-break never arises; only a malformed manager can
3138 // advertise one address twice, and the overlap guard below already
3139 // discards the second of any such pair. No `debug_assert` here: this
3140 // parses untrusted bytes, which must not panic a debug build.
3141 tagged.sort_unstable_by_key(|(s, _)| s.addr);
3142 let mut prev_end = 0u64;
3143 for (s, ty) in tagged {
3144 let Some(end) = s.addr.get().checked_add(s.size) else {
3145 continue;
3146 };
3147 if s.size == 0 || end > file_len || s.addr.get() < prev_end {
3148 continue;
3149 }
3150 prev_end = end;
3151 let pg = self
3152 .paged
3153 .as_mut()
3154 .expect("the paged state was just installed");
3155 match ty {
3156 Some(ty) => PagedEdit::route_free(
3157 &mut pg.meta,
3158 &mut pg.raw,
3159 &mut pg.dead,
3160 s.addr.get(),
3161 s.size,
3162 ty.into(),
3163 ),
3164 None => pg.unclassified.free(s.addr.get(), s.size),
3165 }
3166 }
3167 // A page the seeded lists empty between them belongs to no type and
3168 // may serve either, so fold it in now rather than leaving it to the
3169 // first commit. Nothing this crate writes produces one — it files a
3170 // whole free page under a single manager — but a file another writer
3171 // laid out can, and the rule is the same wherever the page came from.
3172 let pg = self
3173 .paged
3174 .as_mut()
3175 .expect("the paged state was just installed");
3176 PagedEdit::promote_whole_free_pages(&mut pg.meta, &mut pg.raw, &mut pg.dead, page_size);
3177 } else if let Ok(mut sections) = free_space_manager::read_persisted_sections_source(
3178 &self.image(),
3179 &info.manager_addrs,
3180 BaseAddress::ZERO,
3181 os,
3182 )
3183 .map(|(sections, _)| sections)
3184 {
3185 // Unique addresses in any well-formed file; see the paged branch
3186 // above for why a duplicate is harmless and unasserted here.
3187 sections.sort_unstable_by_key(|s| s.addr);
3188 let mut prev_end = 0u64;
3189 for s in sections {
3190 let Some(end) = s.addr.get().checked_add(s.size) else {
3191 continue;
3192 };
3193 if s.size == 0 || end > file_len || s.addr.get() < prev_end {
3194 continue;
3195 }
3196 prev_end = end;
3197 self.free.free(s.addr.get(), s.size);
3198 }
3199 }
3200
3201 // Record the byte extents of the blocks the live file uses so the next
3202 // persisting commit frees them when it writes replacements: the
3203 // extension header, and each defined manager's FSHD + FSSE.
3204 let mut old_blocks = Vec::new();
3205 if let Ok(spans) = self.oh_chunk_spans(ext_addr) {
3206 old_blocks.extend(spans);
3207 }
3208 for &m in &info.manager_addrs {
3209 if StoredAddress::new(m).is_undefined(os) {
3210 continue;
3211 }
3212 let Ok(hdr_len) = fshd_len(os).to_usize() else {
3213 continue;
3214 };
3215 let Ok(fshd) = self.image().read_metadata_at(m, hdr_len) else {
3216 continue;
3217 };
3218 if let Ok(h) = FsmHeader::parse(&fshd, os) {
3219 // `FsmHeader::parse` succeeding guarantees the header's own bytes
3220 // are present, so the FSHD extent is in-bounds; validate the
3221 // section-info extent before recording it, so a malformed
3222 // `fsse_used` can't later free a region running past end-of-file.
3223 old_blocks.push((m, fshd_len(os)));
3224 if !h.fsse_addr.is_undefined(os)
3225 && h.fsse_addr
3226 .get()
3227 .checked_add(h.fsse_used)
3228 .is_some_and(|end| end <= file_len)
3229 {
3230 old_blocks.push((h.fsse_addr.get(), h.fsse_used));
3231 }
3232 }
3233 }
3234
3235 self.persist = Some(PersistState {
3236 strategy: info.strategy,
3237 threshold: info.threshold,
3238 page_size: info.page_size,
3239 old_blocks,
3240 });
3241 }
3242
3243 /// Parses the File Space Info message out of the superblock-extension object
3244 /// header at `ext_addr`, if present and readable.
3245 fn extension_fsinfo(&self, ext_addr: u64) -> Option<FileSpaceInfo> {
3246 let os = self.superblock.offset_size;
3247 let ls = self.superblock.length_size;
3248 let base = self.superblock.base_address;
3249 let oh = ObjectHeader::parse_from_source(
3250 &self.image(),
3251 AccessMode::ReadWrite,
3252 ext_addr,
3253 os,
3254 ls,
3255 base,
3256 )
3257 .ok()?;
3258 let msg = oh
3259 .messages
3260 .iter()
3261 .find(|m| m.msg_type == MessageType::FILE_SPACE_INFO)?;
3262 FileSpaceInfo::parse(&msg.data, os, ls).ok()
3263 }
3264
3265 /// Stage a new dataset, added on the next [`commit`](Self::commit).
3266 ///
3267 /// `path` is the dataset's full path; everything before the last component
3268 /// names the parent group, which must exist (or be created in this session).
3269 /// `builder` is the same [`DatasetBuilder`] [`FileBuilder`](crate::FileBuilder)
3270 /// uses, configured by the caller; its name is taken from `path`, so the two
3271 /// cannot disagree.
3272 ///
3273 /// The builder is passed in finished rather than handed out as a `&mut` into
3274 /// this engine. That is deliberate: a borrow into the engine forces the
3275 /// caller to hold it — and, above this layer, its lock — for as long as the
3276 /// builder is being configured, which is what let a user closure deadlock
3277 /// against the same file it was reading (issue #200).
3278 ///
3279 /// The dataset may be contiguous or chunked, and chunked datasets may be
3280 /// filtered (`with_deflate`, `with_shuffle`, `with_fletcher32`,
3281 /// `with_scale_offset`, `with_zfp`) and/or extensible (`with_maxshape`). An
3282 /// empty (zero-element) dataset is supported under either storage, a
3283 /// provenance dataset (`with_provenance`) is supported, and a
3284 /// contiguous dataset may carry variable-length attributes, a
3285 /// variable-length-string payload (`with_vlen_strings`), or path-resolved
3286 /// object-reference elements (`with_path_references`; chunking any of
3287 /// these is not supported). An attribute set too large for the object header
3288 /// is written to a fractal heap, as the whole-file writer does.
3289 pub(crate) fn stage_created_dataset(
3290 &mut self,
3291 path: &ObjectPathBuf,
3292 builder: DatasetBuilder,
3293 ) -> Result<(), Error> {
3294 self.refuse_if_claimed(path)?;
3295 let Some((parent, leaf)) = path.split_leaf() else {
3296 return Err(Error::EditUnsupported("dataset path has an empty name"));
3297 };
3298 self.refuse_creation_collision(path, StagedKind::Dataset)?;
3299 self.staged
3300 .push_dataset(parent, flatten_dataset(builder, leaf)?);
3301 Ok(())
3302 }
3303
3304 /// Stage an in-place overwrite of an **existing** dataset's values (the HDF5
3305 /// `H5Dwrite` whole-dataset write), applied on the next
3306 /// [`commit`](Self::commit).
3307 ///
3308 /// `path` is the full path of a dataset that must already exist; `builder`
3309 /// supplies the replacement data and is named from `path`, as in
3310 /// [`stage_created_dataset`](Self::stage_created_dataset).
3311 ///
3312 /// This is a *value* overwrite, not a reshape or retype: the new data's
3313 /// datatype and shape must match the on-disk dataset's exactly (byte-for-byte
3314 /// after serialization, so endianness and compound layout must agree), or
3315 /// `commit` reports [`Error::EditUnsupported`]. Contiguous, compact, and
3316 /// chunked (including filtered) datasets are all supported; the dataset's
3317 /// existing chunk geometry, filter pipeline, and chunk index are taken from the
3318 /// on-disk header. A chunk index this engine cannot enumerate (a version-2
3319 /// B-tree) is refused. Partial / sub-region writes are out of scope — the
3320 /// whole dataset is replaced.
3321 ///
3322 /// What the builder alone can rule out —
3323 /// [`refuse_unsupported_overwrite`](Self::refuse_unsupported_overwrite) —
3324 /// is refused *here* rather than at `commit`.
3325 ///
3326 /// When the new data is the same length as the existing contiguous data block
3327 /// (the common case), the bytes are written straight into that block: no
3328 /// object header is rewritten and the superblock root is not flipped, so the
3329 /// commit's linearization point is the synced data write itself. A chunked
3330 /// dataset is handled the same way when every (re-encoded) chunk is the same
3331 /// byte length as the slot it replaces — an unfiltered overwrite (chunk sizes
3332 /// are fixed by the unchanged shape) or a filtered one whose re-encoded chunks
3333 /// match — so it too writes straight into the existing chunk slots. When the
3334 /// length differs (a resized contiguous block, or a filtered chunk that no
3335 /// longer fits), the dataset's storage is rebuilt at end-of-file (or in
3336 /// reusable freed space), the old extent is freed, the data-layout message is
3337 /// repointed, the object header is rewritten, and the parent group's link is
3338 /// patched — exactly like an addition relocates the path up to the root. A
3339 /// relocating overwrite moves the object header, so it is refused unless the
3340 /// dataset has a single hard link.
3341 pub(crate) fn stage_dataset_write(
3342 &mut self,
3343 path: &ObjectPathBuf,
3344 builder: DatasetBuilder,
3345 ) -> Result<(), Error> {
3346 self.refuse_if_claimed(path)?;
3347 // The root group has no link to overwrite: the superblock holds its address.
3348 let Some((_, leaf)) = path.split_leaf() else {
3349 return Err(Error::EditUnsupported("cannot overwrite the root group"));
3350 };
3351 let fd = flatten_dataset(builder, leaf)?;
3352 Self::refuse_unsupported_overwrite(&fd)?;
3353 self.staged.writes.push((path.clone(), fd));
3354 Ok(())
3355 }
3356
3357 /// Stage an append of new elements to an **existing** chunked, unlimited
3358 /// dataset, applied on the next [`commit`](Self::commit).
3359 ///
3360 /// `path` names a dataset that must already exist; `builder` supplies the
3361 /// elements to add via its typed / generic / raw `append_*` methods.
3362 ///
3363 /// Unlike [`stage_dataset_write`](Self::stage_dataset_write) (a value
3364 /// overwrite that forbids any shape change) this **grows** the dataset along
3365 /// its first (axis-0) dimension. It works on **filtered** datasets: the
3366 /// appended chunks are compressed through the dataset's own on-disk filter
3367 /// pipeline (deflate / shuffle / fletcher32 / scale-offset / LZF, and ZFP
3368 /// with the `zfp` feature), and the pipeline, datatype, fill value, and attributes are
3369 /// preserved verbatim. Appends of any length are supported — when the
3370 /// dataset's current length is not a whole multiple of the chunk length, the
3371 /// single trailing partial chunk is read, extended, and re-encoded; every
3372 /// other existing chunk is carried by metadata alone, so the existing data is
3373 /// not rewritten and the file does not grow by the whole dataset per append.
3374 ///
3375 /// This does **not** use SWMR and sets no consistency flag. Like every other
3376 /// staged edit it commits by appending the new chunks and a rebuilt
3377 /// index at end-of-file and repointing the superblock last (under the
3378 /// session's exclusive lock), so a crash leaves either the original dataset or
3379 /// the fully-grown one, never a torn state.
3380 ///
3381 /// The first release supports the Extensible-Array chunk index (the index the
3382 /// reference C library and h5py select for a single unlimited dimension under
3383 /// the latest format, and the one this crate writes for every unlimited
3384 /// dataset), rank-1 datasets, and datasets with a single hard link. A dataset
3385 /// that is not chunked, not unlimited along axis 0, not Extensible-Array
3386 /// indexed, higher than rank 1, uses a filter this engine cannot re-encode,
3387 /// has a sparse chunk grid, or (for [`append_raw`](AppendBuilder::append_raw))
3388 /// has a big-endian element datatype is refused with
3389 /// [`Error::AppendUnsupported`]. Use [`Dataset::is_chunked`](crate::Dataset::is_chunked),
3390 /// [`maxshape`](crate::Dataset::maxshape), and [`filters`](crate::Dataset::filters)
3391 /// to check eligibility up front.
3392 pub(crate) fn stage_dataset_append(
3393 &mut self,
3394 path: &ObjectPathBuf,
3395 builder: AppendBuilder,
3396 ) -> Result<(), Error> {
3397 self.refuse_if_claimed(path)?;
3398 if self.staged.dataset_at(path).is_some() {
3399 // This call came through a handle onto the object *in the file*: a
3400 // handle onto the staged creation goes to
3401 // [`stage_dataset_append_pending`] instead. So the elements could
3402 // only be meant for the object the same commit replaces, which is
3403 // never what the caller wants and is what `commit` refuses two calls
3404 // later. Said here, where the handle that asked is still in hand.
3405 return Err(Error::EditUnsupported(
3406 "a dataset is staged at this path in the same commit, so an append through a \
3407 handle onto the object the file holds there could only grow the object being \
3408 replaced; append through the handle that staged the creation, or commit first",
3409 ));
3410 }
3411 self.refuse_lossy_partial_tail(path, &builder)?;
3412 self.staged.appends.push((path.clone(), builder));
3413 Ok(())
3414 }
3415
3416 /// Refuse, at the call that stages an append, a dataset whose lossy pipeline
3417 /// sits on a partial trailing chunk — the refusal
3418 /// [`prepare_append`](Self::prepare_append) raises during the commit's
3419 /// preflight, brought forward to where the caller can act on it (issue #407).
3420 ///
3421 /// Waiting for the commit makes that refusal permanent for the session and
3422 /// costs every edit staged beside it: a preflight failure restores the staged
3423 /// set, so the offending append is still there at the next
3424 /// [`commit`](crate::File::commit), and [`File::close`](crate::File::close)
3425 /// runs one and returns its `Err` — discarding the whole staged set on the way
3426 /// out. `BufferedAppender::new` refuses the same dataset eagerly for the same
3427 /// reason; this puts the staged path on the same footing.
3428 ///
3429 /// **Positive proof only.** Every reason the geometry cannot be read here — a
3430 /// path that does not resolve, a header this engine does not parse, a layout
3431 /// that is not chunked, a rank this release does not append to — leaves the
3432 /// answer to the preflight, which names each of them. Nothing about the bytes
3433 /// being appended enters into it: the on-disk length is what decides whether a
3434 /// trailing chunk has to be re-encoded, and staged appends never change that
3435 /// before the commit reads it. The commit-time check stays as the backstop,
3436 /// and is the only one that runs for an append staged through some other
3437 /// entry point.
3438 fn refuse_lossy_partial_tail(
3439 &self,
3440 path: &ObjectPathBuf,
3441 builder: &AppendBuilder,
3442 ) -> Result<(), Error> {
3443 // A zero-length append is dropped by the preflight before it ever reaches
3444 // `prepare_append`, so it stays a no-op here rather than becoming the one
3445 // append this refusal would newly reject.
3446 if builder.raw().is_empty() {
3447 return Ok(());
3448 }
3449 if self.appends_onto_a_lossy_partial_tail(path) {
3450 return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
3451 }
3452 Ok(())
3453 }
3454
3455 /// The geometry question behind [`refuse_lossy_partial_tail`](Self::refuse_lossy_partial_tail):
3456 /// does the file hold, at `path`, a rank-1 chunked dataset under a lossy
3457 /// filter pipeline whose length is not a whole multiple of its chunk length?
3458 /// `false` whenever that cannot be established, for whatever reason.
3459 fn appends_onto_a_lossy_partial_tail(&self, path: &ObjectPathBuf) -> bool {
3460 let Ok(addr) = crate::group_v2::resolve_path_any_from_source(
3461 &self.image(),
3462 AccessMode::ReadWrite,
3463 &self.superblock,
3464 &path.as_path(),
3465 ) else {
3466 return false;
3467 };
3468 let Ok(region) =
3469 Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address)
3470 else {
3471 return false;
3472 };
3473 let mut datatype: Option<(usize, usize)> = None;
3474 let mut dataspace: Option<(usize, usize)> = None;
3475 let mut layout: Option<(usize, usize)> = None;
3476 let mut filter: Option<(usize, usize)> = None;
3477 let mut p = 0;
3478 while let Ok(Some((msg_type, body, body_end))) = region.next_message(p) {
3479 match msg_type {
3480 MessageType::DATATYPE => datatype = Some((body, body_end)),
3481 MessageType::DATASPACE => dataspace = Some((body, body_end)),
3482 MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
3483 MessageType::FILTER_PIPELINE => filter = Some((body, body_end)),
3484 _ => {}
3485 }
3486 p = body_end;
3487 }
3488 // No pipeline at all is nothing to re-encode, and an unparseable one is
3489 // refused by the preflight on its own terms.
3490 let (Some((fb, fe)), Some((dt_b, dt_e)), Some((ds_b, ds_e)), Some((lb, le))) =
3491 (filter, datatype, dataspace, layout)
3492 else {
3493 return false;
3494 };
3495 let Ok(pipeline) = FilterPipeline::parse(®ion[fb..fe]) else {
3496 return false;
3497 };
3498 if pipeline_lossless(&pipeline) {
3499 return false;
3500 }
3501 let Ok((disk_dt, _)) = Datatype::parse(®ion[dt_b..dt_e]) else {
3502 return false;
3503 };
3504 let Ok(disk_ds) = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE) else {
3505 return false;
3506 };
3507 let Ok(dl) = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE) else {
3508 return false;
3509 };
3510 // Rank 1 is the shape the preflight's `current_dim0 % chunk_elems`
3511 // arithmetic is about; anything else it refuses before reaching that test.
3512 if disk_ds.dimensions.len() != 1 {
3513 return false;
3514 }
3515 let Ok(geometry) = chunked_geometry(&disk_dt, &disk_ds, &dl) else {
3516 return false;
3517 };
3518 match (geometry.spatial.first(), disk_ds.dimensions.first()) {
3519 (Some(&chunk_elems), Some(&dim0)) if chunk_elems != 0 => dim0 % chunk_elems != 0,
3520 _ => false,
3521 }
3522 }
3523
3524 /// Stage an append made through a handle onto a dataset **this session
3525 /// staged and has not committed**, folding the elements into the pending
3526 /// creation.
3527 ///
3528 /// The caller establishes that its handle is the pending one;
3529 /// [`stage_dataset_append`](Self::stage_dataset_append) is the entry point
3530 /// for a handle onto an object the file already holds, and refuses the same
3531 /// path. Between the caller's check and this lock another thread can commit,
3532 /// which leaves the dataset in the file and nothing staged at its path — so
3533 /// the append becomes an ordinary staged one rather than an error.
3534 pub(crate) fn stage_dataset_append_pending(
3535 &mut self,
3536 path: &ObjectPathBuf,
3537 builder: AppendBuilder,
3538 ) -> Result<(), Error> {
3539 self.refuse_if_claimed(path)?;
3540 if self.staged.dataset_at(path).is_none() {
3541 // Nothing staged here after all, so these elements grow the object the
3542 // file holds — including its trailing chunk, which the same eager
3543 // refusal applies to. The fold below reaches no on-disk chunk at all
3544 // and is untouched by it.
3545 self.refuse_lossy_partial_tail(path, &builder)?;
3546 self.staged.appends.push((path.clone(), builder));
3547 return Ok(());
3548 }
3549 self.refuse_mid_batch()?;
3550 self.extend_staged_dataset(path, &builder)
3551 }
3552
3553 /// Refuse an edit that changes or withdraws something already staged while a
3554 /// [`stage_atomically`](Self::stage_atomically) batch is open.
3555 ///
3556 /// [`StagedEdits::rewind`] undoes a batch by truncating the staged vectors,
3557 /// which is exact only while staging appends to them. The two operations
3558 /// that do otherwise — the fold
3559 /// [`stage_dataset_append_pending`](Self::stage_dataset_append_pending)
3560 /// makes, and the withdrawal [`delete`](Self::delete) makes — would leave a
3561 /// refused batch holding half of one. No caller reaches this today: a batch
3562 /// replays [`StagedOp`](crate::StagedGroup)s, which only ever create. It is
3563 /// here so the claim `rewind` rests on is enforced rather than remembered.
3564 fn refuse_mid_batch(&self) -> Result<(), Error> {
3565 if self.staging_batch {
3566 return Err(Error::EditUnsupported(
3567 "an edit that changes or withdraws an already-staged object cannot be made \
3568 inside an atomic staging batch, whose undo drops additions only",
3569 ));
3570 }
3571 Ok(())
3572 }
3573
3574 /// Fold an append onto a dataset this same session staged and has not
3575 /// committed into the pending creation: the elements are added to the
3576 /// bytes the commit will write and the leading dimension grows to match.
3577 ///
3578 /// There is no dataset in the file to grow, so this is the same edit as
3579 /// having handed the elements to the builder in the first place — which is
3580 /// why it asks for neither an unlimited dimension nor a chunked layout,
3581 /// where an append onto a *committed* dataset needs both. Anything the
3582 /// staged creation must resolve at commit time per element — a
3583 /// variable-length string payload, an object reference — is refused: those
3584 /// carry a side table indexed by element that this cannot extend.
3585 ///
3586 /// A provenance dataset (`with_provenance`) is grown rather than refused:
3587 /// its attributes are derived from the bytes, so they are rebuilt from the
3588 /// grown ones ([`FlatDataset::rebuild_provenance`]) and the digest the
3589 /// commit writes is the digest of what it writes.
3590 fn extend_staged_dataset(
3591 &mut self,
3592 path: &ObjectPathBuf,
3593 builder: &AppendBuilder,
3594 ) -> Result<(), Error> {
3595 if builder.dt_conflict() {
3596 return Err(Error::AppendUnsupported(
3597 "this append mixes element datatypes; use one element type per append",
3598 ));
3599 }
3600 let fd = self
3601 .staged
3602 .dataset_at_mut(path)
3603 .expect("caller checked a dataset is staged at this path");
3604 if fd.vl_string_staging.is_some() || fd.reference_targets.is_some() {
3605 return Err(Error::AppendUnsupported(
3606 "a staged variable-length-string or object-reference dataset cannot be appended \
3607 to before it is committed: its per-element side table is built with the \
3608 dataset. Supply every element through the builder that creates it",
3609 ));
3610 }
3611 if let Some(dt) = builder.elem_dt() {
3612 if *dt != fd.dt {
3613 return Err(Error::AppendUnsupported(
3614 "appended element datatype does not match the staged dataset's datatype",
3615 ));
3616 }
3617 }
3618 let Some((lead, inner)) = fd.ds.dimensions.split_first() else {
3619 return Err(Error::AppendUnsupported(
3620 "a scalar dataset has no dimension to append along",
3621 ));
3622 };
3623 // One row of the leading dimension, in bytes: the element size times
3624 // every trailing extent. A rank-1 dataset's row is one element.
3625 let mut row_bytes = u64::from(fd.dt.type_size());
3626 for &d in inner {
3627 row_bytes = row_bytes.checked_mul(d).ok_or(Error::AppendUnsupported(
3628 "staged dataset row size overflows",
3629 ))?;
3630 }
3631 if row_bytes == 0 {
3632 return Err(Error::AppendUnsupported(
3633 "a staged dataset whose rows hold no bytes cannot be appended to",
3634 ));
3635 }
3636 let bytes = builder.raw();
3637 if bytes.len() as u64 % row_bytes != 0 {
3638 return Err(Error::AppendUnsupported(
3639 "appended byte length is not a whole number of rows of the staged dataset",
3640 ));
3641 }
3642 let grown = lead
3643 .checked_add(bytes.len() as u64 / row_bytes)
3644 .ok_or(Error::AppendUnsupported("staged dataset length overflows"))?;
3645 // A finite maximum is a promise the commit would refuse to break.
3646 if let Some(max) = fd
3647 .ds
3648 .max_dimensions
3649 .as_ref()
3650 .and_then(|m| m.first().copied())
3651 && !max.admits(grown)
3652 {
3653 return Err(Error::AppendUnsupported(
3654 "appending would grow the staged dataset past its maximum shape",
3655 ));
3656 }
3657 fd.raw.extend_from_slice(bytes);
3658 fd.ds.dimensions[0] = grown;
3659 // The digest covers the bytes, so it is recomputed over the grown ones
3660 // rather than left describing the shorter dataset that was staged. Doing
3661 // it here, on the record the commit writes, is what keeps
3662 // `verify_provenance` passing on the file this produces.
3663 #[cfg(feature = "provenance")]
3664 fd.rebuild_provenance();
3665 Ok(())
3666 }
3667
3668 /// Register a live [`BufferedAppender`](crate::BufferedAppender) on `path`
3669 /// (`None` for a handle reached by object reference), returning the token
3670 /// that releases it.
3671 ///
3672 /// Refused when the claim could not be honored: a second appender on the
3673 /// same dataset would interleave the two buffers a chunk at a time, and a
3674 /// staged edit already pending on that path is one the appender's own flush
3675 /// would later refuse.
3676 pub(crate) fn claim_for_appender(
3677 &mut self,
3678 path: Option<&ObjectPathBuf>,
3679 ) -> Result<u64, Error> {
3680 if self
3681 .appender_claims
3682 .iter()
3683 .any(|claim| claim.conflicts_with(path))
3684 {
3685 return Err(Error::EditUnsupported(
3686 "this dataset already has a live buffered appender; two of them would interleave \
3687 their buffers a chunk at a time",
3688 ));
3689 }
3690 let blocked = match path {
3691 Some(p) => self.append_conflicts_with_pending(p),
3692 None => self.has_staged_edits() || self.committed,
3693 };
3694 if blocked {
3695 return Err(Error::EditUnsupported(
3696 "this session holds staged edits that would stop a buffered appender from \
3697 flushing; commit or discard them before opening one",
3698 ));
3699 }
3700 let token = self.next_appender_token;
3701 self.next_appender_token += 1;
3702 self.appender_claims.push(AppenderClaim {
3703 token,
3704 path: path.cloned(),
3705 });
3706 Ok(token)
3707 }
3708
3709 /// Drop a claim. Called from the appender's `Drop`, after its final flush.
3710 pub(crate) fn release_appender_claim(&mut self, token: u64) {
3711 self.appender_claims.retain(|c| c.token != token);
3712 }
3713
3714 /// Refuse a staged edit at `path` that a live appender could not survive.
3715 ///
3716 /// An appender holds elements a caller has already been told were accepted,
3717 /// and only its own flush can write them; that flush goes through the
3718 /// immediate append path, which refuses a dataset with a staged edit on it or
3719 /// an ancestor. Left unchecked, staging such an edit turns accepted data into
3720 /// data lost silently in `Drop`. Refusing here moves the failure to the call
3721 /// that creates the conflict, where there is someone to report it to.
3722 fn refuse_if_claimed(&self, path: &ObjectPathBuf) -> Result<(), Error> {
3723 let conflicts = self.appender_claims.iter().any(|c| match &c.path {
3724 Some(p) => p.overlaps(path),
3725 None => true,
3726 });
3727 if conflicts {
3728 return Err(Error::EditUnsupported(
3729 "this dataset has a live buffered appender holding elements only its own flush \
3730 can write, and this edit would stop that flush; finish or discard the appender \
3731 first",
3732 ));
3733 }
3734 Ok(())
3735 }
3736
3737 /// Whether this session is the SWMR writer, whose append rules are a strict
3738 /// subset of the ordinary ones (see `append_inplace_gathered`).
3739 pub(crate) fn is_swmr(&self) -> bool {
3740 self.swmr_mode
3741 }
3742
3743 /// Whether any staged tree edit is still uncommitted. In-place appends
3744 /// ([`append_inplace_gathered`](Self::append_inplace_gathered)) are applied immediately and are
3745 /// never staged, so they never affect this; it reflects only edits awaiting
3746 /// [`commit`](Self::commit) — `create_group`, `create_dataset`,
3747 /// `write_dataset`, `append_dataset`, group and dataset attribute edits,
3748 /// `delete`, `copy`, and `copy_from`. Dropping the session silently discards
3749 /// any staged edits.
3750 ///
3751 /// A [`commit`](Self::commit) that refuses leaves this answering `true`: the
3752 /// staged set it declined to apply is put back exactly as it was.
3753 pub fn has_staged_edits(&self) -> bool {
3754 !self.staged.is_empty()
3755 }
3756
3757 /// Run `f`, and if it fails, drop whatever it managed to stage.
3758 ///
3759 /// One caller-facing call can stage many edits — `create_group_with` stages
3760 /// a group, its attributes and its whole subtree — and each is validated as
3761 /// it is staged, so the fifth dataset can be refused after four have been
3762 /// recorded. Without this, such a call would return `Err` having changed the
3763 /// session anyway, which is the half of issue #316 that lives one level
3764 /// above `commit`: a refused operation must cost the session nothing.
3765 pub(crate) fn stage_atomically<R>(
3766 &mut self,
3767 f: impl FnOnce(&mut Self) -> Result<R, Error>,
3768 ) -> Result<R, Error> {
3769 let mark = self.staged.mark();
3770 // Saved and restored rather than simply cleared, so a nested batch does
3771 // not lift the outer one's guard on its way out.
3772 let outer = std::mem::replace(&mut self.staging_batch, true);
3773 let result = f(self);
3774 self.staging_batch = outer;
3775 if result.is_err() {
3776 self.staged.rewind(mark);
3777 }
3778 result
3779 }
3780
3781 /// This session's file image as one slice, when its backing holds the whole
3782 /// file in memory; `None` for a file-backed image.
3783 ///
3784 /// The slice reflects committed state plus immediate in-place appends, not
3785 /// edits still staged for `commit`. The owned read-write
3786 /// [`File`](crate::File) uses it to serve reads by borrowing rather than
3787 /// copying, and falls back to [`image`](Self::image) when it is absent.
3788 pub(crate) fn image_slice(&self) -> Option<&[u8]> {
3789 self.image.as_slice()
3790 }
3791
3792 /// A random-access [`Source`] view of this session's file image, for the
3793 /// parsers the edit engine drives.
3794 ///
3795 /// Every read the engine performs against the file goes through this, so the
3796 /// image needs to be no more than a source of bytes — whole-file mirror or
3797 /// not — without the parsers knowing which (issue #198).
3798 pub(crate) fn image(&self) -> &dyn Source {
3799 self.image.as_ref()
3800 }
3801
3802 /// This session's parsed superblock, with `root_group_address` normalized to
3803 /// an absolute file offset (the open-time convention) and `base_address` the
3804 /// userblock size. A relocating commit updates it, so a caller holding a
3805 /// clone from an earlier moment may be reading a stale root.
3806 pub(crate) fn superblock(&self) -> &Superblock {
3807 &self.superblock
3808 }
3809
3810 /// The file's shared-message (SOHM) master table, or `None` for a file that
3811 /// shares no messages — which is every file this crate writes and nearly
3812 /// every file it is handed.
3813 ///
3814 /// Costs one small object-header parse per commit on a file that has a
3815 /// superblock extension at all, and nothing on one that does not.
3816 fn shared_message_table(&self) -> Result<Option<crate::sohm::SohmTable>, Error> {
3817 let os = self.superblock.offset_size;
3818 let rel = match self
3819 .superblock
3820 .superblock_extension_address
3821 .map(StoredAddress::new)
3822 {
3823 Some(rel) if !rel.is_undefined(os) => rel,
3824 _ => return Ok(None),
3825 };
3826 let base = self.superblock.base_address;
3827 let ls = self.superblock.length_size;
3828 // Best-effort down to here, and strict past it. A superblock extension
3829 // this engine cannot parse is one nothing in this crate reads a shared
3830 // message through either, so it is treated as a file that shares none;
3831 // once the extension is readable and *says* the file shares messages,
3832 // failing to read the table is an error, because the screen below is
3833 // then the difference between a refusal and a corrupted index.
3834 let Ok(abs) = base.absolute(rel) else {
3835 return Ok(None);
3836 };
3837 let Ok(header) = ObjectHeader::parse_from_source(
3838 &self.image(),
3839 AccessMode::ReadWrite,
3840 abs,
3841 os,
3842 ls,
3843 base,
3844 ) else {
3845 return Ok(None);
3846 };
3847 let Some(msg) = header
3848 .messages
3849 .iter()
3850 .find(|m| m.msg_type == MessageType::SHARED_MESSAGE_TABLE)
3851 else {
3852 return Ok(None);
3853 };
3854 let message = crate::sohm::SharedMessageTableMessage::parse(&msg.data, os)?;
3855 let framed = BaseOffsetSource {
3856 inner: self.image(),
3857 base,
3858 };
3859 Ok(Some(crate::sohm::SohmTable::read_from_source(
3860 &framed, &message, os,
3861 )?))
3862 }
3863
3864 /// Refuse a commit that would strand a shared-message index record naming an
3865 /// object header. See [`SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT`].
3866 fn screen_shared_message_index(&self, invalidated: &InvalidatedAddresses) -> Result<(), Error> {
3867 if invalidated.is_empty() {
3868 return Ok(());
3869 }
3870 let Some(table) = self.shared_message_table()? else {
3871 return Ok(());
3872 };
3873 let base = self.superblock.base_address;
3874 let os = self.superblock.offset_size;
3875 let ls = self.superblock.length_size;
3876 let framed = BaseOffsetSource {
3877 inner: self.image(),
3878 base,
3879 };
3880 for index in &table.indexes {
3881 let records = crate::sohm::read_index_records_from_source(&framed, index, os, ls)?;
3882 screen_shared_message_records(&records, invalidated)?;
3883 }
3884 Ok(())
3885 }
3886
3887 /// The on-disk format this session writes, read from the file it opened
3888 /// rather than chosen fresh, since a commit preserves the superblock version
3889 /// it found.
3890 ///
3891 /// This decides the *contiguous* data-layout message version, where the
3892 /// older number costs nothing: versions 3 and 4 have identical bodies there,
3893 /// so a dataset added to a 1.8 file stays readable by a 1.8 library.
3894 ///
3895 /// It does not gate chunked storage. A chunked dataset needs the version 4
3896 /// layout and a 1.10 chunk index whatever the superblock says, so adding one
3897 /// to an older file does make that file need 1.10 — deliberately, since the
3898 /// alternative is a version 1 B-tree index this crate does not write, and
3899 /// refusing instead would take away in-place editing of every file the C
3900 /// library wrote with its own default bounds. `crates/crosscheck/tests/main/edit.rs`
3901 /// covers exactly that case (issue #101).
3902 ///
3903 /// That is a default, not a verdict: a caller who needs the file to stay
3904 /// loadable by an old reader asks for it with
3905 /// [`FileAccessProperties::with_libver_bounds`], which sets
3906 /// [`libver_ceiling`](Self::libver_ceiling) and turns the addition into a
3907 /// refusal at commit rather than a silent format bump.
3908 ///
3909 /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
3910 pub(crate) fn libver(&self) -> LibVer {
3911 LibVer::from_superblock_version(self.superblock.version)
3912 }
3913
3914 /// Constrain what this session may add, from the fapl's library-version
3915 /// bounds. See [`libver_ceiling`](Self::libver_ceiling).
3916 ///
3917 /// Resolved through [`LibVer::resolve_writable`], the same rule the
3918 /// whole-file writer applies, so bounds admitting no format this crate
3919 /// writes are refused here as they are there rather than silently ignored on
3920 /// the editing path.
3921 pub(crate) fn set_libver_bounds(
3922 &mut self,
3923 bounds: Option<(LibVer, LibVer)>,
3924 ) -> Result<(), Error> {
3925 self.libver_ceiling = match bounds {
3926 Some(_) => Some(LibVer::resolve_writable(bounds).map_err(Error::Format)?),
3927 None => None,
3928 };
3929 Ok(())
3930 }
3931
3932 /// Refuse staged content the session's [`libver_ceiling`](Self::libver_ceiling)
3933 /// cannot express, before the commit writes anything.
3934 ///
3935 /// Only chunked storage is at stake: `build_chunked_dataset_oh` writes a
3936 /// version 4 data-layout message and a 1.10 chunk index unconditionally,
3937 /// while everything else this session adds is expressible in both formats
3938 /// (`libver` already picks the contiguous layout version). A filter or an
3939 /// unlimited dimension arrives as chunked storage, so they are covered here
3940 /// too.
3941 fn check_libver_admits<'a>(
3942 &self,
3943 datasets: impl IntoIterator<Item = &'a FlatDataset>,
3944 ) -> Result<(), Error> {
3945 let Some(ceiling) = self.libver_ceiling else {
3946 return Ok(());
3947 };
3948 if ceiling >= LibVer::V110 {
3949 return Ok(());
3950 }
3951 let chunked = datasets
3952 .into_iter()
3953 .any(|fd| fd.chunk_options.is_chunked() || fd.maxshape.is_some());
3954 if chunked {
3955 return Err(Error::Format(FormatError::LibverTooOldForContent {
3956 content: "a chunked, filtered, or resizable dataset",
3957 needs: LibVer::V110.name(),
3958 writing: ceiling.name(),
3959 }));
3960 }
3961 Ok(())
3962 }
3963
3964 /// Which backend this session resolved to: [`Bounded`] when it reads through
3965 /// a handle, [`Mirrored`] when it holds a whole-file image.
3966 ///
3967 /// [`Bounded`]: EditBacking::Bounded
3968 /// [`Mirrored`]: EditBacking::Mirrored
3969 pub(crate) fn edit_backing(&self) -> EditBacking {
3970 if self.bounded {
3971 EditBacking::Bounded
3972 } else {
3973 EditBacking::Mirrored
3974 }
3975 }
3976
3977 /// A snapshot of this session's live space usage — the current file size and
3978 /// the free space it can reuse — as a [`SpaceAccounting`].
3979 ///
3980 /// This is the mutating-session analogue of the read-only accounting on
3981 /// [`File`](crate::File): it answers "how big is the file right now, and how
3982 /// much space can be reused before it must grow?" from the session's own live
3983 /// state. The snapshot reflects the committed file plus any immediate in-place
3984 /// appends ([`append_inplace_gathered`](Self::append_inplace_gathered)) but excludes edits still
3985 /// staged for the next [`commit`](Self::commit); see [`SpaceAccounting`] for
3986 /// the field-by-field semantics and [`has_staged_edits`](Self::has_staged_edits)
3987 /// for detecting pending work.
3988 ///
3989 /// On a paged file (`H5F_FSPACE_STRATEGY_PAGE`) the reported regions are the
3990 /// union of the per-page-type managers, which a commit draws on under the rule
3991 /// paging exists for: an allocation takes a hole of the page type it is placing,
3992 /// or whole pages that are free of both, so reuse never re-mixes a page
3993 /// (issue #261). Space whose page type this file does not settle is recorded
3994 /// and handed back to the reference library but never spent, and is excluded
3995 /// from the reusable figure.
3996 ///
3997 /// ```no_run
3998 /// use hdf5_pure::File;
3999 ///
4000 /// let file = File::open_rw("existing.h5")?;
4001 /// let acct = file.space_accounting()?;
4002 /// println!(
4003 /// "{} bytes on disk, {} reusable in {} free region(s)",
4004 /// acct.logical_size,
4005 /// acct.reusable_free_bytes,
4006 /// acct.reusable_free_space.len(),
4007 /// );
4008 /// # Ok::<(), hdf5_pure::Error>(())
4009 /// ```
4010 #[expect(
4011 rustdoc::private_doc_tests,
4012 reason = "rustdoc's test mode collects a private item, so this example is compiled and run by `cargo test --doc`"
4013 )]
4014 #[must_use]
4015 pub fn space_accounting(&self) -> SpaceAccounting {
4016 // The append reserve is free space this session can still spend — it is
4017 // out of the on-disk managers, not out of the file — so leaving it out
4018 // would make an in-place append look like it had consumed bytes it has
4019 // not yet placed anything in (issue #387).
4020 //
4021 // It is folded back through `FreeList::free` rather than appended and
4022 // sorted, because a reserve span very often *abuts* what is left of the
4023 // hole it was drawn from — and the spent end of the reserve walks toward
4024 // that remainder until the two touch exactly. Reported as two regions
4025 // they would break the field's documented contract in the one way that
4026 // misleads: a caller sizing an allocation against the largest region
4027 // would be told nothing that big fits when it does.
4028 //
4029 // It goes back into the list it came from and no other. On a paged file
4030 // that is the raw list, which is what `take_raw_span` drew it out of;
4031 // the two page-type lists stay reported side by side as they always
4032 // were, because a caller cannot allocate across a page-type boundary and
4033 // merging them would claim it could. `FreeList::free`'s debug assertion
4034 // comes along and is welcome: the reserve is drawn out of exactly this
4035 // list, so an overlap here would mean that invariant had already broken.
4036 //
4037 // A paged file tracks its free space per page type; report the union of
4038 // the two, since the caller wants one total rather than a per-manager
4039 // breakdown.
4040 let reusable_free_space = match (&self.paged, self.reserved.is_empty()) {
4041 // The common case, and every non-persisting session: no reserve to
4042 // fold, so nothing is cloned.
4043 (Some(pg), true) => pg.reusable_sections(),
4044 (None, true) => self.free.sections(),
4045 (Some(pg), false) => {
4046 let mut raw = pg.raw.clone();
4047 for (addr, len) in self.reserved.sections() {
4048 raw.free(addr, len);
4049 }
4050 let mut out = pg.meta.sections();
4051 out.extend(raw.sections());
4052 out.sort_unstable_by_key(|&(addr, _)| addr);
4053 debug_assert!(
4054 out.windows(2).all(|w| w[0].0 < w[1].0),
4055 "the same address is free in both the metadata and the raw \
4056 list, so the two page-type lists have stopped being disjoint"
4057 );
4058 out
4059 }
4060 (None, false) => {
4061 let mut free = self.free.clone();
4062 for (addr, len) in self.reserved.sections() {
4063 free.free(addr, len);
4064 }
4065 free.sections()
4066 }
4067 };
4068 let reusable_free_bytes = reusable_free_space.iter().map(|(_, len)| len).sum();
4069 SpaceAccounting {
4070 logical_size: self.image.len(),
4071 reusable_free_bytes,
4072 reusable_free_space,
4073 }
4074 }
4075
4076 /// The shared Extensible-Array append engine's view of this session: the
4077 /// image, the superblock, and the paged-file state, paired as [`EditStore`].
4078 ///
4079 /// Takes `&mut self`, so a caller that also needs [`located`](Self::located)
4080 /// borrowed at the same time must destructure the fields itself rather than
4081 /// call this.
4082 fn store(&mut self) -> EditStore<'_> {
4083 EditStore {
4084 image: self.image.as_mut(),
4085 superblock: &mut self.superblock,
4086 sb_sig_off: self.sb_sig_off,
4087 paged: self.paged.as_mut(),
4088 // This adapter's one caller only reads; nothing is allocated here.
4089 free: None,
4090 sync_policy: self.sync_policy,
4091 }
4092 }
4093
4094 /// Whether an immediate in-place append may allocate out of already-free
4095 /// space instead of growing the file (issue #349, issue #387).
4096 ///
4097 /// The staged [`commit`](Self::commit) always may: it publishes its
4098 /// allocations with the superblock repoint, so a crash before that leaves a
4099 /// file whose root never named them. An immediate append has no repoint, so
4100 /// what has to hold instead is that the bytes it overwrites are dead *on the
4101 /// disk as it stands*. Which list satisfies that depends on the session:
4102 ///
4103 /// - An ordinary [`File::open_rw`](crate::File::open_rw) session on a
4104 /// default-strategy file holds its free space in memory alone. A region in
4105 /// [`free`](Self::free) was freed by a commit this session already
4106 /// published, so nothing on the disk points at it and nothing on the disk
4107 /// claims it is free; a crash at any point of the append leaves it as dead
4108 /// as it was. It is spent directly.
4109 /// - A file that **persists** its free-space managers records its holes on
4110 /// disk, so a hole in `free` is one a durable manager still advertises and
4111 /// spending it would leave that manager describing bytes a live chunk
4112 /// occupies — through a clean close as much as a crash, since only a commit
4113 /// or [`finalize_persist`](Self::finalize_persist) rewrites the record.
4114 /// Such a session therefore spends [`reserved`](Self::reserved) instead:
4115 /// space [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
4116 /// has already taken *out* of the published managers.
4117 /// - A **paged** file keeps its free space per page type in [`PagedEdit`]
4118 /// rather than in [`free`](Self::free), so drawing from that list would be
4119 /// drawing from the wrong one and would put a byte of one kind in a page of
4120 /// the other. It draws its reserve through [`PagedEdit::alloc_typed`] with
4121 /// [`PageType::Raw`] instead, which is what keeps a page holding one kind.
4122 /// Named as its own term at the call site rather than left to the argument
4123 /// that the persist term already covers it (`append_prepare` refuses a
4124 /// paged file that does not persist): a page-type invariant proved from
4125 /// another function's refusal is exactly the shape that has gone wrong here
4126 /// before (issue #261). It costs nothing to name, because `reserved` is
4127 /// empty on a session that never reserved — the term declines reuse rather
4128 /// than misdirecting it.
4129 ///
4130 /// The **SWMR** writer is the one absolute bar, and it is not about
4131 /// publication at all: its concurrent readers may still be inside a region
4132 /// this session freed, and the format forbids reusing one while they are,
4133 /// which is why the C library's own SWMR writer allocates append-only. No
4134 /// test can reach this term today, because a SWMR session refuses every
4135 /// staged edit ([`Error::SwmrStagedUnsupported`]) and so never frees
4136 /// anything. It is here so that lifting that refusal cannot silently enable
4137 /// reuse.
4138 ///
4139 /// One window is inherited rather than introduced. A commit whose superblock
4140 /// write itself fails leaves [`publish_attempted`](Self::publish_attempted)
4141 /// set, which withholds the free-list rollback, so the regions that commit
4142 /// freed stay in the list although whether it published is unknowable
4143 /// (issue #344). An append can now spend them where only a later commit could
4144 /// before. That is the same trade, on a larger surface: doing nothing is
4145 /// still the only answer that is never actively wrong.
4146 fn immediate_reuse_allowed(&self) -> bool {
4147 !self.swmr_mode
4148 }
4149
4150 /// Take a batch of free space out of the on-disk free-space managers so an
4151 /// immediate in-place append may spend it, for a session that persists them
4152 /// (issue #387). Afterwards [`reserved`](Self::reserved) holds whatever could
4153 /// be drawn; the allocator that spends it is what says whether that serves
4154 /// the append, since the rewrite's own tail may have taken part of it.
4155 ///
4156 /// The draw and the publication are one step and in that order: the bytes
4157 /// leave [`free`](Self::free) (or [`PagedEdit`]'s raw list) first, and the
4158 /// managers are then rewritten through the ordinary persisting commit tail
4159 /// with nothing to free and the root unchanged — the same call
4160 /// [`finalize_persist`](Self::finalize_persist) makes, whose superblock
4161 /// repoint is the crash-atomic linearization point. Before that repoint the
4162 /// old managers stand and still list the batch, which is correct because
4163 /// nothing has been written into it; after it, no durable record calls those
4164 /// bytes free and the append may have them.
4165 ///
4166 /// Nothing here moves a live object: `to_free` is empty and the new root is
4167 /// the old one, so the tail places only itself, in a hole or past the live
4168 /// data. The [`located`](Self::located) append-geometry cache is therefore
4169 /// still valid across this call, which is what lets it run between an
4170 /// append's plan and its apply.
4171 ///
4172 /// Draws nothing — not an error — when the session persists nothing to draw
4173 /// from, or when no hole can hold the allocation in hand; the caller simply
4174 /// grows the file, as it always did. An error means the manager rewrite
4175 /// itself failed, and the lists are put back so they agree with whatever the
4176 /// disk now holds, on the rule [`commit`](Self::commit) applies: a rewrite that
4177 /// never reached its superblock write left the managers as they were, so the
4178 /// lists go back to exactly what they were, tail placement included; one that
4179 /// did may have published, so the draw is handed back — those bytes are
4180 /// genuinely free, nothing having been written into them, and the next commit
4181 /// records them again — while the span its tail took stays out, since that
4182 /// tail may now be the live one.
4183 fn reserve_for_immediate_append(&mut self, want: u64) -> Result<(), Error> {
4184 if want == 0 || self.persist.is_none() || self.swmr_mode || self.reserved.largest() >= want
4185 {
4186 return Ok(());
4187 }
4188 let snapshot = self.snapshot_free();
4189 if !self.take_raw_spans(want, APPEND_RESERVE_BYTES) {
4190 return Ok(());
4191 }
4192 self.publish_attempted = false;
4193 match self.commit_persisting(
4194 self.superblock.root_group_address,
4195 Vec::new(),
4196 TailPlacement::Anywhere,
4197 ) {
4198 Ok(()) => Ok(()),
4199 Err(e) => {
4200 if self.publish_attempted {
4201 self.release_reserve();
4202 } else {
4203 self.restore_free(snapshot);
4204 }
4205 Err(e)
4206 }
4207 }
4208 }
4209
4210 /// Move raw-appendable free space into [`reserved`](Self::reserved) for one
4211 /// manager rewrite to publish: runs that can each hold `want` bytes, until
4212 /// `cap` bytes are held or no hole that large is left. Reports whether
4213 /// anything was drawn, which is exactly whether the first hole could hold
4214 /// `want`.
4215 ///
4216 /// The draw is sized on what the file has rather than on a fixed batch, and
4217 /// that is the whole of the fix for issue #413 — [`APPEND_RESERVE_BYTES`]
4218 /// says why the cap is a cap and not a floor. The rewrite that publishes a
4219 /// draw is the expensive part, so one draw gathers as many holes as the cap
4220 /// allows and the rewrite is paid once for all of them: a file fragmented
4221 /// into chunk-sized holes costs one rewrite per cap's worth of them, not one
4222 /// per chunk.
4223 ///
4224 /// Each run is sized on the largest hole and taken best fit, so it is a whole
4225 /// hole or a `cap`-bounded prefix of one, and the reserve never holds a
4226 /// fragment too small for the allocation that drew it. `want` above `cap`
4227 /// draws one run of exactly `want`: the cap bounds the amortization, not
4228 /// what a single allocation may need.
4229 fn take_raw_spans(&mut self, want: u64, cap: u64) -> bool {
4230 debug_assert!(want > 0, "a draw is sized on a real allocation");
4231 // The cap is the budget, except that a single allocation larger than it
4232 // still draws one run for itself.
4233 let budget = cap.max(want);
4234 let mut drawn = 0u64;
4235 while budget - drawn >= want {
4236 let available = self.largest_raw_run();
4237 if available < want {
4238 break;
4239 }
4240 let len = available.min(budget - drawn);
4241 if !self.take_raw_span(len) {
4242 // Sized on the allocator's own figure, so it serves; a refusal
4243 // here means the two disagree, and drawing nothing further is
4244 // the answer that leaves the file merely larger.
4245 debug_assert!(false, "a run of {len} bytes was reported and not served");
4246 break;
4247 }
4248 drawn += len;
4249 }
4250 drawn > 0
4251 }
4252
4253 /// The longest contiguous run [`take_raw_span`](Self::take_raw_span) could
4254 /// move into the reserve right now, or `0` when nothing could.
4255 fn largest_raw_run(&self) -> u64 {
4256 match self.paged.as_ref() {
4257 Some(pg) => pg.largest_typed(PageType::Raw),
4258 None => self.free.largest(),
4259 }
4260 }
4261
4262 /// Move `len` bytes of raw-appendable free space into
4263 /// [`reserved`](Self::reserved), reporting whether a contiguous run that
4264 /// large was available.
4265 ///
4266 /// A paged file draws through [`PagedEdit::alloc_typed`] with
4267 /// [`PageType::Raw`], the same call the staged commit makes for a dataset's
4268 /// data, so the reserve is raw-typed space and an append spending it cannot
4269 /// mix a page. A flat file has one list and draws from it.
4270 fn take_raw_span(&mut self, len: u64) -> bool {
4271 let addr = match self.paged.as_mut() {
4272 Some(pg) => pg.alloc_typed(len, PageType::Raw),
4273 None => self.free.alloc(len),
4274 };
4275 match addr {
4276 Some(addr) => {
4277 self.reserved.free(addr, len);
4278 true
4279 }
4280 None => false,
4281 }
4282 }
4283
4284 /// Give the unspent append reserve back to the lists the managers are written
4285 /// from, so the next rewrite records it as the free space it is.
4286 ///
4287 /// Called immediately before every rewrite of the on-disk managers — the
4288 /// persisting commit tail and [`finalize_persist`](Self::finalize_persist) —
4289 /// and nowhere else, so a commit that does *not* rewrite them (the
4290 /// same-length-overwrite fast path) leaves the reserve intact for the appends
4291 /// that follow it.
4292 ///
4293 /// The reserve is raw-typed on a paged file, so it goes back to the raw list.
4294 /// A run that was claimed whole from the metadata list is raw space now, and
4295 /// a page of it that is wholly free can be claimed back by either type
4296 /// through [`PagedEdit::alloc_typed`], exactly as any other free page can.
4297 fn release_reserve(&mut self) {
4298 if self.reserved.is_empty() {
4299 return;
4300 }
4301 let spans = std::mem::replace(&mut self.reserved, FreeList::new()).sections();
4302 match self.paged.as_mut() {
4303 Some(pg) => {
4304 for (addr, len) in spans {
4305 pg.raw.free(addr, len);
4306 }
4307 }
4308 None => {
4309 for (addr, len) in spans {
4310 self.free.free(addr, len);
4311 }
4312 }
4313 }
4314 }
4315
4316 /// Rewrite the on-disk free-space managers for a persisting commit, giving
4317 /// the unspent append reserve back first so the managers record it, and then
4318 /// give any trailing free space the rewrite exposed back to the filesystem.
4319 ///
4320 /// Every caller that publishes a *tree* takes this rather than
4321 /// [`commit_persisting`](Self::commit_persisting) directly. The one caller
4322 /// that does not is [`reserve_for_immediate_append`](Self::reserve_for_immediate_append),
4323 /// whose whole purpose is to publish managers that leave the reserve out.
4324 fn commit_persisting_releasing_reserve(
4325 &mut self,
4326 new_root: u64,
4327 to_free: Vec<(u64, u64, FreeClass)>,
4328 ) -> Result<(), Error> {
4329 self.release_reserve();
4330 self.commit_persisting(new_root, to_free, TailPlacement::Anywhere)?;
4331 self.shrink_to_the_trailing_free_run(new_root)
4332 }
4333
4334 /// Move a commit's manager blocks down into the space that commit freed, and
4335 /// truncate the file to them (issue #418).
4336 ///
4337 /// A commit that frees a run reaching end-of-file cannot land its own tail in
4338 /// that run: the regions it is vacating are still referenced by the *on-disk*
4339 /// root until the superblock repoint, so writing over them would destroy the
4340 /// pre-commit file if the process died in between. The tail therefore goes
4341 /// past end-of-file, above the very space that should have been given back,
4342 /// and the file cannot shrink — the delete-then-commit case of issue #418.
4343 ///
4344 /// Once that repoint is durable those regions are genuinely free, so a second
4345 /// tail-only rewrite can place the blocks inside them. It publishes through
4346 /// the same crash-atomic tail as any other commit, with nothing to free and
4347 /// the root unchanged, and the truncation follows its superblock write.
4348 ///
4349 /// It runs under [`TailPlacement::ReuseOnly`], so it places the tail in free
4350 /// space or writes nothing at all. That is the safety property: a pass taken
4351 /// for the sake of a smaller file can never leave a larger one.
4352 ///
4353 /// Skipped unless the last commit's tail is the highest thing in the file and
4354 /// the free run reaching it is long enough for [`release_trailing_run`] to
4355 /// release any of — the shape a delete leaves, and the condition that bounds
4356 /// this to one extra rewrite on the commits that can actually use it. A
4357 /// non-persisting file never gets here (its commit truncates directly), and
4358 /// neither does the SWMR writer, which
4359 /// [`open_swmr_writer`](crate::File::open_swmr_writer) refuses to open on a
4360 /// file that persists its free space at all.
4361 fn shrink_to_the_trailing_free_run(&mut self, new_root: u64) -> Result<(), Error> {
4362 let Some(persist) = self.persist.as_ref() else {
4363 return Ok(());
4364 };
4365 let (Some(tail_start), Some(tail_end)) = (
4366 persist.old_blocks.iter().map(|&(a, _)| a).min(),
4367 persist.old_blocks.iter().map(|&(a, l)| a + l).max(),
4368 ) else {
4369 return Ok(());
4370 };
4371 // Only a tail at the very end of the file is standing in the way of one.
4372 if tail_end != self.image.len() {
4373 return Ok(());
4374 }
4375 // The free run reaching up to it, plus the tail the rewrite frees, is what
4376 // the rewrite would have to release from. Held to the same threshold
4377 // `release_trailing_run` applies, since below it that call returns the
4378 // file's length unchanged and the pass would cost a rewrite to publish
4379 // exactly what the commit before it did.
4380 let run_start = match self.paged.as_ref() {
4381 Some(pg) => {
4382 trailing_run_start([&pg.meta, &pg.raw, &pg.dead, &pg.unclassified], tail_start)
4383 }
4384 None => trailing_run_start([&self.free], tail_start),
4385 };
4386 if tail_end - run_start < 2 * TRAILING_RESERVE_TAILS * (tail_end - tail_start) {
4387 return Ok(());
4388 }
4389 self.commit_persisting(new_root, Vec::new(), TailPlacement::ReuseOnly)
4390 }
4391
4392 /// Adopt the fapl's `fsync` cadence. Called once, on the funnel every
4393 /// read-write open passes through, before the session is handed out.
4394 pub(crate) fn set_sync_policy(&mut self, policy: SyncPolicy) {
4395 self.sync_policy = policy;
4396 }
4397
4398 /// The cadence this session adopted. Nothing in the crate branches on this —
4399 /// [`barrier`](Self::barrier) does that — but the fapl reaching the engine is
4400 /// otherwise invisible from outside, and an entry point that forgot to pass
4401 /// it on would look exactly like one that did.
4402 #[cfg(test)]
4403 pub(crate) fn sync_policy(&self) -> SyncPolicy {
4404 self.sync_policy
4405 }
4406
4407 /// Override how long this session's writes may sit in memory.
4408 ///
4409 /// The image is private to this module, and the only caller outside it is
4410 /// [`crate::crash_replay`], which sweeps the *same* workload under gathering
4411 /// and without it. That comparison is the answer to the obvious worry about
4412 /// making writes larger, so it is worth an accessor; nothing outside a test
4413 /// build has one.
4414 #[cfg(test)]
4415 pub(crate) fn set_write_buffering(&mut self, mode: WriteBuffering) -> Result<(), Error> {
4416 self.image.set_write_buffering(mode)
4417 }
4418
4419 /// Every write this session issued, as `(offset, length)` in the order it
4420 /// went out. Same reasoning as [`set_write_buffering`](Self::set_write_buffering):
4421 /// the image is private, and a test that asks whether a publish left the
4422 /// engine as one write has nowhere else to look.
4423 #[cfg(test)]
4424 pub(crate) fn issued_write_order(&self) -> Vec<(u64, u64)> {
4425 self.image.issued_write_order()
4426 }
4427
4428 /// The durability barrier a write path calls for, data and metadata both —
4429 /// issued unless this session's [`SyncPolicy`] leaves the `fsync` cadence to
4430 /// the application.
4431 ///
4432 /// Every such point routes through here rather than touching the image, so
4433 /// the policy is honored by construction at sites yet to be written.
4434 ///
4435 /// A barrier is an **ordering** point first and a durability point second,
4436 /// and only the second half is the policy's to skip. The writes gathered
4437 /// before it are issued here whatever the policy says, because the image
4438 /// issues gathered writes in address order: leave them gathered across a
4439 /// barrier and a commit's superblock — address 0 — goes to the disk *before*
4440 /// the content it names, which is the one order the whole tail is built to
4441 /// avoid. A write that then fails, or a process that dies mid-flush, would
4442 /// leave a superblock naming bytes that are not in the file, where before it
4443 /// left the previous file intact (issue #288).
4444 ///
4445 /// [`WriteBuffering::Session`] — an explicit page buffer — is the deliberate
4446 /// exception, and [`ordering_barrier`](crate::image::FileImage::ordering_barrier)
4447 /// is what encodes that: it does nothing under that mode, which is precisely
4448 /// the guarantee such a caller is trading away. What that caller gets back is
4449 /// a file that *says* so — see [`raise_crash_mark`](Self::raise_crash_mark).
4450 ///
4451 /// The teardown barrier is deliberately *not* one of these points:
4452 /// [`File::close`](crate::File::close) and `FileInner::drop` write after the
4453 /// last barrier a caller could have asked for, so they take
4454 /// [`force_sync`](Self::force_sync) instead.
4455 fn barrier(&mut self) -> Result<(), Error> {
4456 // Exhaustive on purpose, here and at the two sites below: `SyncPolicy` is
4457 // sealed to the outside but not to this crate, so a policy added later
4458 // fails to compile at every durability point until someone decides what
4459 // it means there.
4460 match self.sync_policy {
4461 // `sync_all` issues the gathered writes on its way to the disk.
4462 SyncPolicy::Always => self.image.sync_all(),
4463 SyncPolicy::OnClose => self.image.ordering_barrier(),
4464 }
4465 }
4466
4467 /// The data-only counterpart to [`barrier`](Self::barrier), for a write that
4468 /// does not move end-of-file and so needs no metadata flush. It orders the
4469 /// gathered writes for the same reason.
4470 fn barrier_data(&mut self) -> Result<(), Error> {
4471 barrier_data(self.image.as_mut(), self.sync_policy)
4472 }
4473
4474 /// Force this session's writes to durable storage *whatever* the policy
4475 /// says. The counterpart to [`barrier`](Self::barrier), which the policy may
4476 /// skip.
4477 ///
4478 /// Two callers: [`File::sync`](crate::File::sync), the application naming
4479 /// its own cadence, and the teardown path — `File::close` and
4480 /// `FileInner::drop` — whose own writes no caller can order, because both
4481 /// destroy the handle that would have done it.
4482 pub(crate) fn force_sync(&mut self) -> Result<(), Error> {
4483 self.image.sync_all()
4484 }
4485
4486 /// Rewrite the on-disk free-space managers for a file that persists them, if
4487 /// this session left them stale.
4488 ///
4489 /// Immediate in-place appends grow the file past the managers, leaving them
4490 /// describing a length the file no longer has, or spend a reserve those
4491 /// managers no longer list. A staged
4492 /// [`commit`](Self::commit) rewrites them as part of its tail, but a session
4493 /// that only appends never runs one, so [`File::close`](crate::File::close)
4494 /// and `FileInner::drop` call this instead. It is the same tail the commit
4495 /// writes — placed in space an earlier commit freed, or appended past
4496 /// everything live when none fits, with the superblock repoint as the
4497 /// crash-atomic linearization point — with nothing to free and the root
4498 /// unchanged.
4499 ///
4500 /// Where those blocks end up is not something a reader has to care about, and
4501 /// after this change usually is not end-of-file: a file with free space to
4502 /// spend has its managers inside it, above and below live data alike. What has
4503 /// to hold is that they describe the file as it now stands, which is what makes
4504 /// this call's own writes the last the session issues.
4505 ///
4506 /// It is also where a session's last release of trailing free space happens,
4507 /// since it takes the same tail as a commit (issue #418).
4508 ///
4509 /// A no-op for a non-persisting file, and skipped when the file has not grown
4510 /// past the managers since they were last written *and* the session holds no
4511 /// unspent append reserve, so an unchanged session never grows the file. The
4512 /// reserve is the second condition because an append that spent a hole rather
4513 /// than the end of the file leaves the length alone, and what it did not
4514 /// spend is space the managers must be told about again (issue #387).
4515 ///
4516 /// If a session that grew the file ends without `close` or `drop` running (a
4517 /// true crash — `SIGKILL`, power loss), the managers are left describing the
4518 /// shorter file, and an unspent reserve is left out of them altogether. Every
4519 /// append was durable and crash-atomic, so no data is lost, and both this
4520 /// crate and the reference C library reopen the file and read it correctly;
4521 /// the managers simply under-report until a clean rewrite, which wastes those
4522 /// bytes and never hands them out twice.
4523 pub(crate) fn finalize_persist(&mut self) -> Result<(), Error> {
4524 if self.persist.is_none() || (self.image.len() == self.fsm_len && self.reserved.is_empty())
4525 {
4526 return Ok(());
4527 }
4528 self.commit_persisting_releasing_reserve(self.superblock.root_group_address, Vec::new())
4529 }
4530
4531 /// Resolve and locate an in-place append target, applying every rule that
4532 /// does not depend on the bytes being appended: the file-level eligibility
4533 /// guards, the staged-edit conflict check, and the geometry lookup that
4534 /// populates [`located`](Self::located). Returns the dataset's object-header
4535 /// address, which is that cache's key.
4536 ///
4537 /// Split out from the append itself so [`append_geometry`](Self::append_geometry)
4538 /// can report a dataset's batching geometry under exactly the same rules the
4539 /// append will apply — a caller slicing a large append into batches must be
4540 /// refused before the first batch, not part-way through.
4541 fn append_prepare(&mut self, target: AppendTarget<'_>) -> Result<u64, Error> {
4542 // The fast in-place append is only sound on a base-0 latest-format file:
4543 // the slot math assumes absolute addresses and the superblock is patched in
4544 // place per call. A userblock or pre-v2 file falls back to the staged
4545 // `append_dataset`, which rebuilds the index and repoints the superblock
4546 // last.
4547 if !self.superblock.base_address.is_zero() {
4548 return Err(Error::AppendInPlaceUnsupported(
4549 "in-place append does not support a file with a userblock (non-zero base \
4550 address); use Dataset::append_staged",
4551 ));
4552 }
4553 if self.superblock.version < 2 {
4554 return Err(Error::AppendInPlaceUnsupported(
4555 "in-place append requires a latest-format file (v2/v3 superblock); use \
4556 Dataset::append_staged",
4557 ));
4558 }
4559 // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
4560 // space has no on-disk record of which pages hold metadata and which hold
4561 // raw data, so neither this immediate append nor the staged commit can keep
4562 // the two segregated; refuse it outright. A paged *persisting* file appends
4563 // through the page-aware `EditStore`, which pads a tail page whenever the
4564 // page type changes, and has its managers rewritten at the next commit or
4565 // at close (issue #198).
4566 if self.paged.is_some() && self.persist.is_none() {
4567 return Err(Error::AppendInPlaceUnsupported(
4568 "in-place append is not supported on a paged file \
4569 (H5F_FSPACE_STRATEGY_PAGE) without persisted free space; recreate the \
4570 file with with_file_space_strategy(FileSpaceStrategy::Page, true, ..)",
4571 ));
4572 }
4573
4574 // Refuse an append against a dataset (or a subtree) that a still-staged edit
4575 // in this same session will relocate, replace, or delete — which would
4576 // strand the durably-appended rows or plan against a header the commit
4577 // moves. The caller must commit those edits first.
4578 //
4579 // A target named by object-header address cannot be compared against the
4580 // staged paths, so any staged edit at all disqualifies it. That is a
4581 // superset of the path check, and the remedy is the same one.
4582 match target {
4583 AppendTarget::Path(dataset) => {
4584 if self.append_conflicts_with_pending(dataset) {
4585 return Err(Error::AppendInPlaceUnsupported(
4586 "the dataset or an ancestor has a staged edit pending in this session; \
4587 commit the staged edits before appending in place, or use \
4588 Dataset::append_staged",
4589 ));
4590 }
4591 }
4592 AppendTarget::Header(_) if self.has_staged_edits() || self.committed => {
4593 return Err(Error::AppendInPlaceUnsupported(
4594 "this append target was reached by object reference, so it names a dataset \
4595 by object-header address, and this session has staged or committed edits \
4596 that can move that header; re-open the dataset by path to append to it",
4597 ));
4598 }
4599 AppendTarget::Header(_) => {}
4600 }
4601
4602 // Resolve the dataset's object-header address — the geometry cache key.
4603 // base == 0 here, so a resolved address is absolute; two hard links to
4604 // one dataset share the one entry.
4605 let oh_addr = match target {
4606 AppendTarget::Path(dataset) => match self.resolved.get(dataset) {
4607 Some(&addr) => addr,
4608 None => {
4609 let addr = crate::group_v2::resolve_path_any_from_source(
4610 &self.image(),
4611 AccessMode::ReadWrite,
4612 &self.superblock,
4613 &dataset.as_path(),
4614 )?;
4615 self.resolved.insert(dataset.clone(), addr);
4616 addr
4617 }
4618 },
4619 AppendTarget::Header(addr) => addr,
4620 };
4621
4622 // Locate the dataset on the first append (cache miss) against the session's
4623 // own image — no second lock, no second view of the file, no re-read.
4624 if !self.located.contains_key(&oh_addr) {
4625 let store = self.store();
4626 let state = locate_dataset_state(&store, oh_addr)?;
4627 self.located.insert(oh_addr, state);
4628 }
4629 Ok(oh_addr)
4630 }
4631
4632 /// The append geometry of the dataset `target` names, so a caller can slice a
4633 /// large append into aligned batches *before* materializing each batch's
4634 /// bytes — which is what keeps a bounded session's peak memory at one batch
4635 /// rather than the whole call.
4636 pub(crate) fn append_geometry(
4637 &mut self,
4638 target: AppendTarget<'_>,
4639 ) -> Result<AppendGeometry, Error> {
4640 let oh_addr = self.append_prepare(target)?;
4641 let st = &self.located[&oh_addr];
4642 let chunk_elems = st.loc.chunk_elems.max(1);
4643 Ok(AppendGeometry {
4644 chunk_elems,
4645 element_size: st.element_size,
4646 current_dim: st.loc.current_dim,
4647 lossy_filters: st.pipeline.as_ref().is_some_and(|p| !pipeline_lossless(p)),
4648 full_batch_elems: self.batch_elems(st.loc.chunk_bytes, chunk_elems),
4649 })
4650 }
4651
4652 /// Whole-chunk elements in one append batch.
4653 ///
4654 /// A bounded session caps a batch at [`APPEND_BATCH_BYTES`] of raw data (at
4655 /// least one chunk) so peak memory is independent of the call size, at the
4656 /// cost of splitting one crash-atomic append into several: a crash between
4657 /// batches leaves a valid shorter dataset, exactly as if the caller had
4658 /// looped. A mirror session already holds the whole file, so bounding the
4659 /// call buys nothing there and would trade that atomicity away for free;
4660 /// it takes the whole append as one batch.
4661 fn batch_elems(&self, chunk_bytes: usize, chunk_elems: u64) -> u64 {
4662 if !self.batched_appends {
4663 return u64::MAX;
4664 }
4665 (APPEND_BATCH_BYTES / (chunk_bytes.max(1) as u64)).max(1) * chunk_elems
4666 }
4667
4668 /// Apply a gathered in-place append (typed / generic / raw bytes) to the
4669 /// dataset `target` names, immediately and crash-atomically, driving the
4670 /// shared Extensible-Array engine against the session's own image through an
4671 /// [`EditStore`] adapter. Runs only the first `max_phase` durability phases;
4672 /// production callers pass 4, the crash-consistency tests stop at a boundary
4673 /// to simulate a crash.
4674 ///
4675 /// A bounded session splits the call into whole-chunk batches (see
4676 /// [`batch_elems`](Self::batch_elems)), each its own crash-atomic apply.
4677 /// Every predictable refusal is raised before the first batch, so a rejected
4678 /// append leaves the file untouched rather than partly grown.
4679 ///
4680 /// `Dataset::append` slices its own call the same way before it reaches here,
4681 /// so through the public API this loop runs once per call; it batches for the
4682 /// benefit of a caller that hands the engine one large builder directly, whose
4683 /// bytes are already materialized but whose plan need not be.
4684 pub(crate) fn append_inplace_gathered(
4685 &mut self,
4686 target: AppendTarget<'_>,
4687 b: &AppendBuilder,
4688 max_phase: u8,
4689 ) -> Result<(), Error> {
4690 if b.dt_conflict() {
4691 return Err(Error::AppendInPlaceUnsupported(
4692 "append mixes element types in one call; use one element type per append",
4693 ));
4694 }
4695 let oh_addr = self.append_prepare(target)?;
4696
4697 // Validate the appended bytes against the on-disk datatype.
4698 let raw = b.raw();
4699 let new_elems = validate_gathered_append(&self.located[&oh_addr], b)?;
4700 if new_elems == 0 {
4701 return Ok(());
4702 }
4703
4704 // In SWMR mode, hold to the subset a concurrent reader can follow safely:
4705 // unfiltered and chunk-aligned, so an append only ever inserts new,
4706 // not-yet-visible elements and never rewrites a visible trailing chunk
4707 // out from under a reader. Outside SWMR the session's exclusive lock is
4708 // what makes that rewrite safe; see `plan_ea_append`.
4709 if self.swmr_mode {
4710 let st = &self.located[&oh_addr];
4711 if st.pipeline.is_some() {
4712 return Err(Error::SwmrAppendUnsupported(
4713 "filtered datasets are not supported for SWMR append",
4714 ));
4715 }
4716 let chunk_elems = st.loc.chunk_elems;
4717 if chunk_elems == 0
4718 || st.loc.current_dim % chunk_elems != 0
4719 || new_elems % chunk_elems != 0
4720 {
4721 return Err(Error::SwmrAppendUnsupported(
4722 "SWMR append must be chunk-aligned: the current length and the appended \
4723 length must both be whole multiples of the chunk length",
4724 ));
4725 }
4726 }
4727
4728 let (chunk_elems, elem_bytes, full_batch_elems) = {
4729 let st = &self.located[&oh_addr];
4730 (
4731 st.loc.chunk_elems.max(1),
4732 st.element_size.get() as u64,
4733 self.batch_elems(st.loc.chunk_bytes, st.loc.chunk_elems.max(1)),
4734 )
4735 };
4736 // Outside SWMR the session holds an exclusive lock, so re-encoding a
4737 // filtered partial trailing chunk into a fresh allocation and repointing
4738 // its index element is a change no other reader is crossing; see
4739 // `plan_ea_append`, which owns that argument and keeps the refusal for
4740 // the SWMR writer.
4741 let grow_visible_tail = !self.swmr_mode;
4742
4743 let mut done = 0u64;
4744 while done < new_elems {
4745 // Fill the trailing partial chunk first (so later batches start
4746 // chunk-aligned and never rewrite it again), then whole-chunk batches.
4747 // That first batch is the only one that can re-encode a filtered
4748 // trailing chunk; every batch after it starts on a boundary.
4749 let current_dim = self.located[&oh_addr].loc.current_dim;
4750 let to_boundary = (chunk_elems - current_dim % chunk_elems) % chunk_elems;
4751 let take = (new_elems - done).min(to_boundary.saturating_add(full_batch_elems));
4752 let batch =
4753 &raw[(done * elem_bytes).to_usize()?..((done + take) * elem_bytes).to_usize()?];
4754
4755 // Read/plan phase (immutable borrows only, nothing published yet), then
4756 // the ordered, fsync-barriered write phase — both shared with
4757 // `Dataset::append` through the chunk-index engine. `EditStore` borrows
4758 // only the image-carrying fields, so `self.located` stays independently
4759 // borrowable.
4760 let plan_result = {
4761 let Self {
4762 image,
4763 superblock,
4764 sb_sig_off,
4765 paged,
4766 located,
4767 sync_policy,
4768 ..
4769 } = self;
4770 let st = &located[&oh_addr];
4771 let store = EditStore {
4772 image: image.as_mut(),
4773 superblock,
4774 sb_sig_off: *sb_sig_off,
4775 paged: paged.as_mut(),
4776 // Planning only reads; nothing is allocated here.
4777 free: None,
4778 sync_policy: *sync_policy,
4779 };
4780 plan_ea_append(
4781 &store,
4782 &st.loc,
4783 &st.datatype,
4784 &st.spatial,
4785 st.element_size,
4786 st.pipeline.as_ref(),
4787 grow_visible_tail,
4788 batch,
4789 take,
4790 st.fill.pattern(st.element_size),
4791 )
4792 };
4793 let plan = plan_result.map_err(as_inplace_error)?;
4794 // A persisting file's holes are advertised on disk, so this batch's
4795 // bytes have to be taken out of the published managers before any of
4796 // them is written into. Sized on the largest single blob, since the
4797 // allocator serves each one from a contiguous run: a reserve that
4798 // cannot hold the biggest is a reserve the biggest goes past. A
4799 // refusal here (nothing left to draw, or nothing to draw from) is not
4800 // an error — the append grows the file, as it always did. Placed
4801 // between the plan and the apply because the manager rewrite it makes
4802 // publishes a superblock, and nothing may be half-applied across that.
4803 let largest_blob = plan
4804 .new_chunk_bytes
4805 .iter()
4806 .map(|b| b.len() as u64)
4807 .max()
4808 .unwrap_or(0);
4809 self.reserve_for_immediate_append(largest_blob)?;
4810 {
4811 let reuse = self.immediate_reuse_allowed();
4812 // Two terms, not one. A **paged** session must never be handed
4813 // `free`: it keeps its space per page type in `PagedEdit`, so
4814 // that list is the wrong one to draw from and placing a byte out
4815 // of it would mix a page. Every paged session is a persisting one
4816 // (`append_prepare` refuses a paged file without persistence), so
4817 // the first term alone would pick `reserved` for it anyway — and
4818 // that is exactly the shape issue #261 was: a page-type invariant
4819 // proved from a refusal in another function. Named here so the
4820 // safety is by construction, and so deleting that refusal cannot
4821 // silently turn a paged append loose on the flat list. `reserved`
4822 // is empty on any session that never reserved, which makes the
4823 // extra term cost nothing but the reuse it correctly declines.
4824 let from_reserve = self.persist.is_some() || self.paged.is_some();
4825 let Self {
4826 image,
4827 superblock,
4828 sb_sig_off,
4829 paged,
4830 located,
4831 free,
4832 reserved,
4833 sync_policy,
4834 ..
4835 } = self;
4836 let st = located.get_mut(&oh_addr).expect("dataset located above");
4837 let mut store = EditStore {
4838 image: image.as_mut(),
4839 superblock,
4840 sb_sig_off: *sb_sig_off,
4841 paged: paged.as_mut(),
4842 // A persisting or paged session spends only what the reserve
4843 // took out of the managers; every other session spends its
4844 // in-memory list directly. See `immediate_reuse_allowed`.
4845 free: reuse.then_some(if from_reserve { reserved } else { free }),
4846 sync_policy: *sync_policy,
4847 };
4848 apply_ea_append(&mut store, &mut st.loc, &plan, max_phase)
4849 .map_err(as_inplace_error)?;
4850 }
4851 if max_phase < 4 {
4852 // Crash-consistency hook: the caller asked to stop inside the
4853 // first batch's durability sequence, so there is no next batch.
4854 // Each phase's own barrier has already issued the writes up to it
4855 // under either policy, which is what leaves a phase boundary a
4856 // real one for the tests that stop at it.
4857 return Ok(());
4858 }
4859 done += take;
4860 }
4861 Ok(())
4862 }
4863
4864 /// Test-only phased in-place append (stops after `max_phase` durability phases)
4865 /// used by the crash-consistency tests, mirroring `Dataset::append`'s harness.
4866 #[cfg(test)]
4867 fn append_inplace_i32_phased(
4868 &mut self,
4869 dataset: &str,
4870 values: &[i32],
4871 max_phase: u8,
4872 ) -> Result<(), Error> {
4873 let mut b = AppendBuilder::new();
4874 b.append_i32(values);
4875 self.append_inplace_gathered(
4876 AppendTarget::Path(&ObjectPathBuf::parse(dataset)),
4877 &b,
4878 max_phase,
4879 )
4880 }
4881
4882 /// Whether `target` (an [`append_inplace_gathered`](Self::append_inplace_gathered) dataset path)
4883 /// or any of its ancestors is named by a staged edit that a later
4884 /// [`commit`](Self::commit) would relocate, replace, or delete. `create_group`
4885 /// and group-attribute edits are excluded: they rewrite a group header without
4886 /// moving a descendant dataset's header or freeing its storage, so they cannot
4887 /// stale the append geometry cache.
4888 fn append_conflicts_with_pending(&self, target: &ObjectPathBuf) -> bool {
4889 let hits = |p: &ObjectPathBuf| target.overlaps(p);
4890 self.staged.writes.iter().any(|(p, _)| hits(p))
4891 || self.staged.appends.iter().any(|(p, _)| hits(p))
4892 || self.staged.deletes.iter().any(&hits)
4893 || self.staged.copies.iter().any(|(_, dst)| hits(dst))
4894 || self.staged.cross_copies.iter().any(|(dst, _)| hits(dst))
4895 || self.staged.dataset_attrs.iter().any(|(p, _)| hits(p))
4896 || self
4897 .staged
4898 .datasets
4899 .iter()
4900 .any(|(parent, fd)| target.overlaps(&parent.join(&fd.name)))
4901 }
4902
4903 /// What this session has staged at `path`, for a handle that wants to
4904 /// address a not-yet-committed object by name.
4905 ///
4906 /// Only a *creation* answers, and only when it owns the path — when the file
4907 /// holds no link there, or when this same commit removes the one it does.
4908 /// Both halves matter:
4909 ///
4910 /// - A staged **deletion** on its own hides nothing. The object is in the
4911 /// file and still readable, and a lookup that pretended otherwise would
4912 /// refuse a read the file can serve.
4913 /// - A creation **colliding** with a link the commit does not remove is one
4914 /// [`refuse_creation_collision`](Self::refuse_creation_collision) turns
4915 /// away where it is staged, so this arm is the same rule asked a second
4916 /// time rather than a state a caller can reach. Reporting such a creation
4917 /// would shadow a live, readable object with a handle that never becomes
4918 /// valid, so the file's own object stays the meaning of the name.
4919 /// - The two together are a **replacement** (issue #305), and the creation
4920 /// is what the path names from the moment it is staged, before the commit
4921 /// as after. "The two together" is
4922 /// [`StagedEdits::deletes_hand_over`]'s rule: the deletion names this path,
4923 /// or an ancestor this session builds again.
4924 ///
4925 /// Only a path this session staged is reported, and every group above a
4926 /// staged object must be one the file already holds or one this session
4927 /// stages at its own path. [`commit`](Self::commit) rejects the whole batch
4928 /// with [`Error::EditUnsupported`] when a group above a creation is neither,
4929 /// the state `a/b` is in when [`stage_created_dataset`](Self::stage_created_dataset) stages
4930 /// `a/b/col` alone.
4931 pub(crate) fn staged_object(&self, path: &ObjectPathBuf) -> Option<StagedObject> {
4932 if self.stages_no_creations() {
4933 return None;
4934 }
4935 if path.is_empty() {
4936 // The root always exists; nothing can stage it.
4937 return None;
4938 }
4939 let kind = if self.staged.dataset_at(path).is_some() {
4940 StagedKind::Dataset
4941 } else if self.staged.has_group_at(path) {
4942 StagedKind::Group
4943 } else {
4944 return None;
4945 };
4946 let replaces_link = self.staged.deletes_hand_over(path);
4947 // The file is asked only about a path a creation actually names, so an
4948 // ordinary open of an object nothing is staged at never pays for this.
4949 if !replaces_link && self.path_in_file(path) {
4950 return None;
4951 }
4952 Some(StagedObject {
4953 kind,
4954 replaces_link,
4955 })
4956 }
4957
4958 /// Refuse a creation at a path something already owns — a link the file
4959 /// holds and this session does not remove, or a creation this session
4960 /// already staged — at the call that stages it.
4961 ///
4962 /// The commit refuses the same batch (`a link with this name already
4963 /// exists`), and the file arm raises that refusal's own error so the two
4964 /// cannot disagree about what a collision is. Refusing here is what lets
4965 /// [`Group::create_dataset`](crate::Group::create_dataset) and its two
4966 /// siblings hand back a handle onto the object they stage: a creation that
4967 /// merely collides is not what its path names — the file's object still is
4968 /// — so a handle onto it would have addressed the *old* object while
4969 /// claiming to address the new one.
4970 ///
4971 /// A creation this session already stages at the path is refused for the
4972 /// same reason, one the commit's own refusal cannot stand in for. The staged
4973 /// set is indexed by path and the index keeps the *first* record at each one
4974 /// (`or_insert`), so a second creation is staged behind the first and every
4975 /// handle onto that path — the one this call would hand back included —
4976 /// answers `shape`, `dtype`, `filters` and `append_staged` from the record
4977 /// the caller did not just describe. No committed object is misaddressed,
4978 /// since the commit refuses the batch, but the window between the two is one
4979 /// where a handle reports another object's geometry as its own.
4980 ///
4981 /// Two *group* creations at one path are the exception and stay allowed:
4982 /// they name one node, the commit builds one group from them, and a handle
4983 /// onto either addresses that group. Re-staging a group is how a caller adds
4984 /// attributes or children to one it already staged.
4985 ///
4986 /// A [`copy`](Self::copy) destination is still the commit's collision to
4987 /// refuse: it hands back no handle, so nothing can be misaddressed by it.
4988 fn refuse_creation_collision(
4989 &self,
4990 path: &ObjectPathBuf,
4991 kind: StagedKind,
4992 ) -> Result<(), Error> {
4993 // An empty path identifies the root group, which no link reaches and
4994 // nothing creates. Both callers reject it with a message of their own
4995 // before this is reached, so this returns `Ok` and leaves the collision
4996 // below to a path that identifies a link.
4997 if path.is_empty() {
4998 return Ok(());
4999 }
5000 // Asked before the file, so that staging twice over a link this session
5001 // deletes — a replacement, which the file arm below lets through — is
5002 // caught by the same rule as staging twice over nothing.
5003 if self.staged.dataset_at(path).is_some()
5004 || (kind == StagedKind::Dataset && self.staged.has_group_at(path))
5005 {
5006 return Err(Error::EditUnsupported(
5007 "this session already stages an object with this name in the target group; \
5008 delete it to withdraw that staging before staging another in its place",
5009 ));
5010 }
5011 if self.staged.deletes_hand_over(path) || !self.path_in_file(path) {
5012 return Ok(());
5013 }
5014 Err(Error::EditUnsupported(
5015 "a link with this name already exists in the target group",
5016 ))
5017 }
5018
5019 /// Whether the file this session is editing holds a link at `path`, as of
5020 /// its committed state.
5021 ///
5022 /// Resolved against a borrowed slice where the backing can lend one, as the
5023 /// reader's own path resolution does: this is asked once per by-name lookup
5024 /// of a path a creation is staged at, so the copy a `Source` read would make
5025 /// per link block is worth avoiding.
5026 fn path_in_file(&self, path: &ObjectPathBuf) -> bool {
5027 match self.image.as_slice() {
5028 Some(data) => crate::group_v2::resolve_path_any(
5029 data,
5030 AccessMode::ReadWrite,
5031 &self.superblock,
5032 &path.as_path(),
5033 )
5034 .is_ok(),
5035 None => crate::group_v2::resolve_path_any_from_source(
5036 &self.image(),
5037 AccessMode::ReadWrite,
5038 &self.superblock,
5039 &path.as_path(),
5040 )
5041 .is_ok(),
5042 }
5043 }
5044
5045 /// Whether this session has staged no creations at all, which is the common
5046 /// case on the by-name lookup path. Checked before the path is owned, so a
5047 /// lookup of an object already in the file returns on this flag alone.
5048 fn stages_no_creations(&self) -> bool {
5049 self.staged.groups.is_empty() && self.staged.datasets.is_empty()
5050 }
5051
5052 /// How many times a commit has consumed this session's staged set, rather
5053 /// than handing it back to it. See the
5054 /// [`staged_generation`](Self::staged_generation) field for the rule, and
5055 /// [`crate::Error::StagingWithdrawn`] for what a handle does with it.
5056 pub(crate) fn staged_generation(&self) -> u64 {
5057 self.staged_generation
5058 }
5059
5060 /// What a staged dataset at `path` says about itself, for the introspection
5061 /// a handle onto it can answer before the commit writes any bytes.
5062 ///
5063 /// Cloned out of the staged [`FlatDataset`], which is the whole record of
5064 /// what the commit will write, so these answers cannot drift from the
5065 /// dataset that lands. Answers only for a creation that owns its path, on
5066 /// the terms [`staged_object`](Self::staged_object) sets out.
5067 pub(crate) fn staged_dataset_meta(&self, path: &ObjectPathBuf) -> Option<StagedMeta> {
5068 if self.staged_object(path)?.kind != StagedKind::Dataset {
5069 return None;
5070 }
5071 let fd = self.staged.dataset_at(path)?;
5072 Some(StagedMeta {
5073 datatype: fd.dt.clone(),
5074 dimensions: fd.ds.dimensions.clone(),
5075 // Reported the way `Dataset::maxshape` reports an on-disk one: a
5076 // maximum equal to the current shape is a fixed-shape dataset.
5077 maxshape: fd.ds.extensible_max_dimensions().map(<[MaxExtent]>::to_vec),
5078 // The rule the commit applies: chunk options or an extensible shape
5079 // select chunked storage, anything else contiguous.
5080 chunked: fd.chunk_options.is_chunked() || fd.maxshape.is_some(),
5081 filters: fd
5082 .chunk_options
5083 .filters
5084 .iter()
5085 .map(|f| (f.kind.filter_id(), f.optional))
5086 .collect(),
5087 })
5088 }
5089
5090 /// The direct children `parent` gains from this session's staged creations:
5091 /// the groups first and then the datasets, each in the order it was staged.
5092 ///
5093 /// `parent` may itself be staged, which is what lets a listing on a
5094 /// not-yet-committed group report its members. Only children the session
5095 /// named answer, for the reason [`staged_object`](Self::staged_object)
5096 /// gives.
5097 ///
5098 /// Unlike [`staged_object`](Self::staged_object) this does not ask the file
5099 /// whether each name is already taken: the caller is enumerating a group and
5100 /// already holds its on-disk links, so `replaces_link` is all it needs to
5101 /// apply the same rule without a path resolution per child.
5102 pub(crate) fn staged_children(&self, parent: &ObjectPathBuf) -> Vec<StagedChild> {
5103 if self.stages_no_creations() {
5104 return Vec::new();
5105 }
5106 // Whether this group is itself a replacement staged here — its on-disk
5107 // links go with the object being removed, so every creation under it
5108 // owns its name — and which links directly under it are removed by
5109 // name: the two ways a creation here is a replacement rather than a
5110 // collision. A deletion of the group that this session does *not* build
5111 // again is neither: the commit refuses that batch, so the file's own
5112 // children still own their names (see [`StagedEdits::deletes_hand_over`]).
5113 let base_deleted =
5114 self.staged.deletes_hand_over(parent) && self.staged.has_group_at(parent);
5115 let deleted_here: HashSet<&str> = self
5116 .staged
5117 .deletes
5118 .iter()
5119 .filter_map(|d| d.name_under(parent))
5120 .map(LinkNameBuf::as_str)
5121 .collect();
5122
5123 let mut seen: HashSet<&str> = HashSet::new();
5124 let mut out: Vec<StagedChild> = Vec::new();
5125 for path in &self.staged.groups {
5126 if let Some(name) = path.name_under(parent) {
5127 let name = name.as_str();
5128 if seen.insert(name) {
5129 out.push(StagedChild {
5130 name: name.to_string(),
5131 kind: StagedKind::Group,
5132 replaces_link: base_deleted || deleted_here.contains(name),
5133 });
5134 }
5135 }
5136 }
5137 for (p, fd) in &self.staged.datasets {
5138 if p == parent && seen.insert(fd.name.as_str()) {
5139 out.push(StagedChild {
5140 name: fd.name.as_str().to_string(),
5141 kind: StagedKind::Dataset,
5142 replaces_link: base_deleted || deleted_here.contains(fd.name.as_str()),
5143 });
5144 }
5145 }
5146 out
5147 }
5148
5149 /// Stage a new (empty) group at `path`, created on the next
5150 /// [`commit`](Self::commit). The parent must already exist or be created in
5151 /// the same session; populate the group with datasets via
5152 /// [`stage_created_dataset`](Self::stage_created_dataset) using a path under it.
5153 ///
5154 /// # Errors
5155 ///
5156 /// Returns [`Error::EditUnsupported`] if `path` is [`ObjectPathBuf::root`], which no link
5157 /// reaches, if a live buffered appender covers `path`, or if the name is taken on the terms
5158 /// [`refuse_creation_collision`](Self::refuse_creation_collision) sets out.
5159 pub fn create_group(&mut self, path: &ObjectPathBuf) -> Result<(), Error> {
5160 // Nothing links to the root group, and every file has one, so there is
5161 // no link here to create. Rejected here, as a deletion of the root is:
5162 // a creation staged at the root would mark it a node this commit builds
5163 // fresh, until the commit rejected the batch.
5164 if path.is_empty() {
5165 return Err(Error::EditUnsupported("cannot create the root group"));
5166 }
5167 self.refuse_if_claimed(path)?;
5168 self.refuse_creation_collision(path, StagedKind::Group)?;
5169 self.staged.push_group(path.clone());
5170 Ok(())
5171 }
5172
5173 /// Stage an attribute add or replacement on a group, applied on the next
5174 /// [`commit`](Self::commit).
5175 ///
5176 /// `path` identifies the group to edit, and [`ObjectPathBuf::root`] the root group. The
5177 /// group may already exist or may be created earlier in the same session
5178 /// with [`create_group`](Self::create_group). Attributes — fixed-size or
5179 /// variable-length — are stored compactly in the rebuilt group header while
5180 /// they fit it, and in a fractal heap past that, on the terms
5181 /// [`plan_attr_ops`] sets out.
5182 pub fn set_group_attr(
5183 &mut self,
5184 path: &ObjectPathBuf,
5185 name: &str,
5186 value: AttrValue,
5187 ) -> Result<(), Error> {
5188 self.refuse_if_claimed(path)?;
5189 self.staged.group_attrs.push((
5190 path.clone(),
5191 AttrOp::Set {
5192 name: name.to_string(),
5193 value,
5194 },
5195 ));
5196 Ok(())
5197 }
5198
5199 /// Stage removal of a compact attribute from a group, applied on the next
5200 /// [`commit`](Self::commit).
5201 ///
5202 /// `path` identifies the group to edit, and [`ObjectPathBuf::root`] the root group. The
5203 /// named attribute must exist in the committed group state after any earlier
5204 /// staged attribute operations for the same group have been applied.
5205 pub fn remove_group_attr(&mut self, path: &ObjectPathBuf, name: &str) -> Result<(), Error> {
5206 self.refuse_if_claimed(path)?;
5207 self.staged.group_attrs.push((
5208 path.clone(),
5209 AttrOp::Remove {
5210 name: name.to_string(),
5211 },
5212 ));
5213 Ok(())
5214 }
5215
5216 /// Stage an attribute add or replacement on an **existing dataset**, applied on
5217 /// the next [`commit`](Self::commit).
5218 ///
5219 /// `path` names the dataset to edit. Attributes — fixed-size or
5220 /// variable-length — are stored in the rebuilt dataset header
5221 /// while they fit it, and in a fractal heap past that, on the terms
5222 /// [`plan_attr_ops`] sets out. Applying it relocates the dataset's object
5223 /// header (the header is rewritten and its single naming link repointed; the
5224 /// dataset's data and chunk index stay in place), so it is supported only when
5225 /// the dataset has a **single hard link**. To set attributes on a dataset being *created* in this
5226 /// session, use the builder's [`set_attr`](crate::DatasetBuilder::set_attr)
5227 /// instead.
5228 pub fn set_dataset_attr(
5229 &mut self,
5230 path: &ObjectPathBuf,
5231 name: &str,
5232 value: AttrValue,
5233 ) -> Result<(), Error> {
5234 self.refuse_if_claimed(path)?;
5235 self.staged.dataset_attrs.push((
5236 path.clone(),
5237 AttrOp::Set {
5238 name: name.to_string(),
5239 value,
5240 },
5241 ));
5242 Ok(())
5243 }
5244
5245 /// Stage removal of a compact attribute from an **existing dataset**, applied on
5246 /// the next [`commit`](Self::commit).
5247 ///
5248 /// `path` names the dataset to edit; the named attribute must exist in the
5249 /// committed dataset state after any earlier staged attribute operations for the
5250 /// same dataset have been applied. Like [`set_dataset_attr`](Self::set_dataset_attr)
5251 /// it relocates the dataset header and requires a single hard link.
5252 pub fn remove_dataset_attr(&mut self, path: &ObjectPathBuf, name: &str) -> Result<(), Error> {
5253 self.refuse_if_claimed(path)?;
5254 self.staged.dataset_attrs.push((
5255 path.clone(),
5256 AttrOp::Remove {
5257 name: name.to_string(),
5258 },
5259 ));
5260 Ok(())
5261 }
5262
5263 /// Stage removal of the link at `path` (the HDF5 `H5Ldelete`), applied on the
5264 /// next [`commit`](Self::commit). The link's object — and, for a group, its
5265 /// whole subtree — becomes unreachable. The bytes it occupied are returned to
5266 /// this session's free list (issue #21): a later commit reuses them for new
5267 /// objects instead of growing the file, and if a freed run reaches
5268 /// end-of-file the file is truncated. Contiguous and chunked datasets (their
5269 /// chunk index and chunk data blocks) and whole group subtrees are all
5270 /// reclaimed. Reclaim is best-effort — an object whose blocks this engine
5271 /// cannot enumerate exhaustively (variable-length global-heap storage, dense
5272 /// attribute/link heaps, a version 2 B-tree chunk index) is left as dead
5273 /// bytes rather than risk freeing a region that is still in use. Freed space is
5274 /// reused within the open session; for a file created with
5275 /// `H5Pset_file_space_strategy(persist = true)` it is also recorded on disk so
5276 /// it survives reopen, otherwise it is forgotten
5277 /// on close. After reuse, an object reference to a deleted object may resolve
5278 /// to an unrelated object (deleting a referenced object is undefined in HDF5).
5279 ///
5280 /// The path must exist. The link's parent group must itself be editable in
5281 /// place (compact links, single-chunk header); the target being removed has
5282 /// no such restriction.
5283 ///
5284 /// # Replacing an object
5285 ///
5286 /// Deleting a path and creating a new object at that same path in one commit
5287 /// is a *replacement*, and is accepted (issue #305): the removal is applied
5288 /// before the addition, and the commit's single superblock write publishes
5289 /// both, so the path is occupied at every instant a crash could interrupt.
5290 /// The new object need not resemble the old one — a dataset may replace a
5291 /// group, or the reverse — and replacing a group discards its whole subtree
5292 /// rather than inheriting it.
5293 ///
5294 /// Everything this commit adds *below* a replaced path lands in the
5295 /// replacement, whatever order the calls were made in: staging
5296 /// `create_dataset("g/x")` and then replacing `g` puts `x` in the new group,
5297 /// not the one being removed. A staged edit that could only mean the
5298 /// *original* is refused instead — a group under the replaced path that this
5299 /// commit does not itself create, an attribute set on the object being
5300 /// removed, a value overwrite inside it, or a [`copy`](crate::File::copy)
5301 /// reading from it.
5302 ///
5303 /// See [`Group::delete`](crate::Group::delete) for the public form and a
5304 /// worked example.
5305 ///
5306 /// A deletion may not overlap a staged change at a *different* path
5307 /// (deleting `/a` while adding `/a/b`, unless `/a` is itself replaced in the
5308 /// same commit), nor an edit to the object being removed (an attribute set
5309 /// on it, or a value overwrite of something inside it); split those into
5310 /// separate commits.
5311 /// Deleting something this session **staged** and has not committed
5312 /// withdraws that staging instead: there is no link in the file to unlink,
5313 /// so the session is left as though the creation had never been made — its
5314 /// attributes, appends and staged children go with it. When the file *also*
5315 /// holds a link at the path (the creation was replacing it), the withdrawal
5316 /// leaves the plain deletion of the file's own object behind.
5317 ///
5318 /// The root group, [`ObjectPathBuf::root`], is rejected with [`Error::EditUnsupported`]:
5319 /// no link reaches it, so there is no link to remove.
5320 pub fn delete(&mut self, path: &ObjectPathBuf) -> Result<(), Error> {
5321 // The root is not linked from anywhere, so there is no link to remove —
5322 // and an empty path is a prefix of every other, so a deletion staged
5323 // here would make every staged creation in the session look like a
5324 // replacement of a file object until the commit refused the batch.
5325 // Refused by name, as creating the root is.
5326 if path.is_empty() {
5327 return Err(Error::EditUnsupported(
5328 "cannot delete the root group; delete its members instead",
5329 ));
5330 }
5331 self.refuse_if_claimed(path)?;
5332 if self.staged.dataset_at(path).is_some() || self.staged.has_group_at(path) {
5333 self.refuse_mid_batch()?;
5334 self.staged.withdraw_at(path);
5335 // Withdrawing is the whole deletion unless the file holds a link
5336 // here too — and if it does, a deletion of it may already be staged,
5337 // which is what made this a replacement in the first place. A second
5338 // one would be an overlapping deletion the commit refuses.
5339 if self.staged.deletes_cover(path) || !self.path_in_file(path) {
5340 return Ok(());
5341 }
5342 }
5343 self.staged.deletes.push(path.clone());
5344 Ok(())
5345 }
5346
5347 /// Stage a deep copy of the object at `src` to a new link at `dst` (the HDF5
5348 /// `H5Ocopy`), applied on the next [`commit`](Self::commit). The source — a
5349 /// dataset or a whole group subtree — is duplicated: fresh copies of every
5350 /// object's data and header are written, internal links and the contiguous
5351 /// data address are repointed to the copies, and a link named by `dst`'s last
5352 /// component is added to `dst`'s parent group. The original is untouched.
5353 ///
5354 /// The copy reflects the file's on-disk state at commit time. `src` must
5355 /// exist and `dst` must not (and may not lie inside `src`). A chunked (and
5356 /// filtered) dataset is copied with its chunk payloads and filter pipeline
5357 /// preserved byte-for-byte (the index is rebuilt at the new location, so a
5358 /// source using a B-tree-v1 or implicit index is reproduced with an equivalent
5359 /// v4 index). The source subtree must otherwise be copyable in place: compact
5360 /// links and attributes, single-chunk headers, and a chunk index this engine
5361 /// can enumerate (a version-2 B-tree, or a sparse/unallocated chunk grid, is
5362 /// refused) — otherwise `commit` reports [`Error::EditUnsupported`].
5363 ///
5364 /// A *contiguous* dataset whose storage was never allocated is copied as the
5365 /// storage it has — none — rather than as the fill value a read answers with,
5366 /// which is what keeps a schema-only source from arriving materialized at the
5367 /// size its shape declares. A dataset storing its elements in external files
5368 /// (`H5Pset_external`) carries that same empty storage over data this crate
5369 /// does not read, and is refused by name so the two do not share an answer
5370 /// (issue #336).
5371 pub fn copy(&mut self, src: &ObjectPathBuf, dst: &ObjectPathBuf) -> Result<(), Error> {
5372 // Both ends matter: the source is read and the destination is written,
5373 // and a commit relocates headers along either path.
5374 self.refuse_if_claimed(src)?;
5375 self.refuse_if_claimed(dst)?;
5376 self.staged.copies.push((src.clone(), dst.clone()));
5377 Ok(())
5378 }
5379
5380 /// Stage a deep copy of the object at `src` in another open file `source` to a
5381 /// new link at `dst` in this file — a *cross-file* HDF5 `H5Ocopy` — applied on
5382 /// the next [`commit`](Self::commit). Like [`copy`](Self::copy) but the source
5383 /// lives in a separate, independently-opened [`File`](crate::File) reader
5384 /// rather than the file being edited.
5385 ///
5386 /// The source — a dataset or a whole group subtree — is duplicated faithfully:
5387 /// fresh, byte-identical copies of every object's header and data are appended
5388 /// to this file, internal links repointed, and a link named by `dst`'s last
5389 /// component added to `dst`'s parent group (which must already exist or be
5390 /// created earlier in this session). Both files are left otherwise untouched;
5391 /// the destination only changes on `commit`.
5392 ///
5393 /// Unlike the same-file [`copy`](Self::copy), the source is read **eagerly**
5394 /// here (the `source` borrow need not outlive the call), so this returns
5395 /// `Result`: the source subtree is resolved, validated, and read out before
5396 /// returning, and only an already-validated copy is queued for `commit`.
5397 ///
5398 /// # Errors
5399 ///
5400 /// Returns [`Error::EditUnsupported`] if the copy cannot be reproduced exactly
5401 /// in another file. Because the copy is byte-for-byte verbatim, anything that
5402 /// embeds a *source-file* absolute address is refused (it would dangle here):
5403 /// **variable-length** or **reference** datasets and attributes (including a
5404 /// chunked dataset whose elements are variable-length or references, whose
5405 /// chunk payloads embed such addresses), and any **shared header message** (a
5406 /// committed datatype, or an SOHM-shared dataspace, fill value, or filter
5407 /// pipeline). As with [`copy`](Self::copy) a chunked/filtered source is copied
5408 /// with its chunk payloads and pipeline preserved (index rebuilt at the new
5409 /// location); the source must use compact links and attributes, single-chunk
5410 /// version-2 headers, and a chunk index this engine can enumerate (a
5411 /// version-2 B-tree, or a sparse chunk grid, is refused). The
5412 /// `source` must be a buffered file ([`File::open`](crate::File::open) or
5413 /// [`File::from_bytes`](crate::File::from_bytes), not
5414 /// [`open_streaming`](crate::File::open_streaming)) using 8-byte offsets and no
5415 /// userblock, and `src` must exist in it and not be the root group.
5416 ///
5417 /// # Note
5418 ///
5419 /// The source is read under the mode its own file is open under, read-only for
5420 /// every source this copy accepts, so an object header message of a type this
5421 /// crate cannot name that a decoder with write access must understand is read
5422 /// past. The copy reproduces an object's header records verbatim, so such a
5423 /// message on `src`'s own header reaches the destination. `H5Ocopy` copies a
5424 /// message of an unknown type into the destination header the same way, since a
5425 /// message without a `copy_file` callback is copied raw (`H5Ocopy.c`,
5426 /// HDF5 2.2.0).
5427 pub fn copy_from(
5428 &mut self,
5429 source: &crate::reader::File,
5430 src: &ObjectPathBuf,
5431 dst: &ObjectPathBuf,
5432 ) -> Result<(), Error> {
5433 // The source bytes must be addressable: a streaming file is refused.
5434 let src_data = source.in_memory_image().ok_or(Error::EditUnsupported(
5435 "cross-file copy requires a buffered source file (File::open or File::from_bytes), not a streaming one",
5436 ))?;
5437 let src_sb = source.superblock();
5438 if src_sb.offset_size != OFFSET_SIZE || src_sb.length_size != LENGTH_SIZE {
5439 return Err(Error::EditUnsupported(
5440 "cross-file copy requires the source file to use 8-byte offsets and lengths",
5441 ));
5442 }
5443 if !source.base_address().is_zero() {
5444 return Err(Error::EditUnsupported(
5445 "cross-file copy requires the source file to have no userblock (base address 0)",
5446 ));
5447 }
5448
5449 if src.is_empty() {
5450 return Err(Error::EditUnsupported("cannot copy the root group"));
5451 }
5452 if dst.is_empty() {
5453 return Err(Error::EditUnsupported("copy destination path is empty"));
5454 }
5455
5456 let src_addr = crate::group_v2::resolve_path_any(
5457 src_data,
5458 source.access_mode(),
5459 src_sb,
5460 &src.as_path(),
5461 )?;
5462 // Read (and foreign-address-screen) the whole subtree now, while `source`
5463 // is borrowed; the owned tree carries every byte the commit will write. The
5464 // source is gated to base 0 above, so its stored addresses are absolute.
5465 let tree = Self::read_copy_subtree(
5466 &BytesSource::new(src_data),
5467 source.access_mode(),
5468 src_addr,
5469 0,
5470 true,
5471 BaseAddress::ZERO,
5472 )?;
5473 self.refuse_if_claimed(dst)?;
5474 self.staged.cross_copies.push((dst.clone(), tree));
5475 Ok(())
5476 }
5477
5478 /// Apply all staged additions and deletions to the file in place and flush.
5479 ///
5480 /// Appends each new dataset (its data — a contiguous blob, or the chunk data
5481 /// and index for a chunked/filtered dataset — plus its object header) and
5482 /// each new group, then appends rewritten object headers for every touched
5483 /// group and its ancestors up to the root (omitting any deleted links), then
5484 /// repoints the superblock at the new root. On success the staged set is
5485 /// cleared and the session can be reused. On any [`Error::EditUnsupported`]
5486 /// the file on disk is left untouched: the checks that raise it — including
5487 /// each dataset's filter-pipeline and chunk-geometry validation — all run
5488 /// before the first byte is written. Should a later step fail mid-apply (an
5489 /// I/O error, or a residual build error), the superblock — repointed last —
5490 /// still names the prior root, so the file stays valid and the appended bytes
5491 /// are unreferenced slack.
5492 ///
5493 /// **A refused commit costs the session nothing it had staged.** The
5494 /// staged set is whole afterwards — so the batch can be committed again,
5495 /// and refuses again identically rather than applying the part of itself
5496 /// the refusal was not about — and the free regions the attempt drew from
5497 /// are given back, so an attempt that never became visible costs the
5498 /// session neither staged work nor reusable space (issue #316; see
5499 /// [`StagedEdits`] and [`FreeSnapshot`]).
5500 ///
5501 /// A failure *past* the first write is the other case, and it clears the
5502 /// staged set rather than restoring it: the file is valid but some of the
5503 /// batch is in it as slack, and a later `commit` must not re-issue the rest
5504 /// as if nothing had happened.
5505 ///
5506 /// **What the file holds is a separate promise: a commit that fails before
5507 /// its repoint leaves every dataset reading what it read before.** "Slack"
5508 /// describes all but one of the edits such a commit applies. The exception
5509 /// is a same-length value overwrite, which writes straight over the
5510 /// dataset's existing data block and so is live the moment it lands, with
5511 /// no repoint to withhold; the prior bytes are written back over every one
5512 /// of them on the way out (issue #344).
5513 ///
5514 /// Past the repoint the promise is the other one, and it has to be: the
5515 /// commit has published, so its overwrites are the file's values and are
5516 /// left standing. A failure there — `repoint_stored_references` is the step
5517 /// that can produce one — returns an error over a file that holds the whole
5518 /// batch, which is what
5519 /// `a_commit_that_fails_past_its_repoint_keeps_the_value_it_published`
5520 /// pins.
5521 ///
5522 /// # Errors
5523 ///
5524 /// Returns [`Error::CommitPartiallyApplied`] — carrying the write failure,
5525 /// not the refusal — when that restore itself failed, which is the one
5526 /// refusal after which a dataset the batch named may hold either value.
5527 /// Every other refusal leaves them all as they were.
5528 pub fn commit(&mut self) -> Result<(), Error> {
5529 let snapshot = self.snapshot_free();
5530 self.publish_attempted = false;
5531 // Cleared at entry, like `superseded_heaps` and for the same reason: a
5532 // previous attempt that unwound past this function — a panic caught by
5533 // the session lock, which recovers from poisoning — would otherwise
5534 // leave entries behind, and this commit's rollback would replay bytes
5535 // captured before it over whatever lives at those addresses now. The
5536 // clear on the way out is a separate duty: it stops a journal being
5537 // held across the idle time between commits.
5538 self.inplace_undo.clear();
5539 // The staged set comes out for the duration of the attempt and goes back
5540 // if the attempt refuses, so a refusal costs the session no staged work
5541 // (issue #316). What goes back is whatever the attempt did not take: its
5542 // preflight only reads, and its apply phase takes the whole set in one
5543 // move at the point of no return, so a preflight refusal restores every
5544 // edit and a failure past that point restores nothing.
5545 let mut staged = std::mem::take(&mut self.staged);
5546 let mut result = self.commit_inner(&mut staged);
5547 if result.is_err() && !self.publish_attempted {
5548 // The three legs of the rollback: the file's values, the free
5549 // lists, and the staged set. Nothing observes the order — the first
5550 // touches the file and the others only in-memory state, inside one
5551 // synchronous call — so it is written the way it reads: put the
5552 // file back, then re-offer what the attempt drew from (issue #344).
5553 let restored = self.undo_inplace_writes();
5554 self.restore_free(snapshot);
5555 self.staged = staged;
5556 // The set is back, so nothing this session staged has been consumed
5557 // and every handle onto one of those creations is still pending.
5558 if let Err(restore) = restored {
5559 // Both are worth carrying: the refusal is what the caller has to
5560 // fix, and the write failure is what proves the file changed.
5561 let refusal = result.expect_err("only a failed attempt is rolled back");
5562 result = Err(Error::CommitPartiallyApplied {
5563 refusal: Box::new(refusal),
5564 restore: Box::new(restore),
5565 });
5566 }
5567 } else {
5568 // The set is gone — published, or in the file as slack a later
5569 // commit must not re-issue — so a handle onto one of its creations
5570 // stops being pending and addresses the file from here on.
5571 self.staged_generation += 1;
5572 }
5573 // For the paths that took no rollback — a commit that succeeded, whose
5574 // overwrites are now the file's values, and one that failed with the
5575 // publish already issued, where undoing anything is unsound. A rollback
5576 // drained the journal on its way through, so it owes nothing here.
5577 self.inplace_undo.clear();
5578 result
5579 }
5580
5581 /// The free lists as they stand now, for [`commit`](Self::commit) to restore
5582 /// if its apply loop draws from them and then fails.
5583 fn snapshot_free(&self) -> FreeSnapshot {
5584 FreeSnapshot {
5585 free: self.free.clone(),
5586 reserved: self.reserved.clone(),
5587 paged: self
5588 .paged
5589 .as_ref()
5590 .map(|pg| (pg.meta.clone(), pg.raw.clone())),
5591 vl_overwrite_heaps: self.vl_overwrite_heaps.clone(),
5592 }
5593 }
5594
5595 /// Put the free lists back as [`snapshot_free`](Self::snapshot_free) found
5596 /// them. Called only for a commit that failed before publishing anything, so
5597 /// every region it restores is dead again.
5598 ///
5599 /// That rests on nothing the live tree reaches naming a span the attempt
5600 /// allocated, which takes two rules rather than one. Every object a commit
5601 /// *places* is unreachable until the superblock repoint; and the one write
5602 /// that does not wait for the repoint — a same-length value overwrite — is
5603 /// planned only over bytes that carry no allocated address, because
5604 /// [`prepare_write`](Self::prepare_write) relocates a staged
5605 /// variable-length overwrite instead of writing it in place, which is
5606 /// exactly the counterexample (issue #321).
5607 ///
5608 /// [`undo_inplace_writes`](Self::undo_inplace_writes) has nevertheless run
5609 /// by the time this does, so the file's values are back before its regions
5610 /// are offered again (issue #344).
5611 fn restore_free(&mut self, snapshot: FreeSnapshot) {
5612 self.vl_overwrite_heaps = snapshot.vl_overwrite_heaps;
5613 self.free = snapshot.free;
5614 self.reserved = snapshot.reserved;
5615 if let (Some(pg), Some((meta, raw))) = (self.paged.as_mut(), snapshot.paged) {
5616 pg.meta = meta;
5617 pg.raw = raw;
5618 }
5619 }
5620
5621 /// Overwrites `raw` at `at`, keeping the bytes it replaces so a commit that
5622 /// fails before its repoint can put them back.
5623 ///
5624 /// The read covers exactly the range about to be written, which the write
5625 /// preflight sized and bounds-checked against the on-disk dataset — a
5626 /// same-length overwrite by construction.
5627 fn write_inplace_journaled(&mut self, at: u64, raw: &[u8]) -> Result<(), Error> {
5628 let prior = self.image().read_exact_at(at, raw.len())?;
5629 self.inplace_undo.push((at, prior));
5630 self.write_at(at, raw)
5631 }
5632
5633 /// Put back the values the refused commit's same-length in-place overwrites
5634 /// replaced, and drop the journal.
5635 ///
5636 /// The third leg of a refused commit's rollback, beside
5637 /// [`restore_free`](Self::restore_free) and the staged set: those two give
5638 /// back what the attempt *consumed*, this gives back what it *changed*. It
5639 /// is the only one of the three that has to touch the file, because a
5640 /// same-length overwrite is the only edit a commit writes over bytes the
5641 /// live root already reaches (issue #344).
5642 ///
5643 /// # Errors
5644 ///
5645 /// Every entry is attempted even after one fails, so the file is restored as
5646 /// far as it can be, and the first failure met is what comes back. That is the
5647 /// one outcome leaving values from a batch that was refused, which is why
5648 /// [`commit`](Self::commit) reports it as
5649 /// [`Error::CommitPartiallyApplied`] rather than as the refusal.
5650 fn undo_inplace_writes(&mut self) -> Result<(), Error> {
5651 let journal = core::mem::take(&mut self.inplace_undo);
5652 if journal.is_empty() {
5653 return Ok(());
5654 }
5655 let mut failed = None;
5656 // Newest first, and load-bearing rather than conventional. The commit
5657 // refuses to overwrite the same *path* twice, which is not the same as
5658 // the same dataset: an object reachable through two hard links has two
5659 // paths and one data block, and a same-length overwrite through either
5660 // is allowed precisely because it rewrites the block every link sees.
5661 // Two such writes in one commit therefore journal the same address
5662 // twice, the second entry holding what the first write put there.
5663 // Replaying oldest-first would finish on that value — one from the very
5664 // batch being rolled back.
5665 for (at, prior) in journal.into_iter().rev() {
5666 if let Err(e) = self.write_at(at, &prior) {
5667 failed.get_or_insert(e);
5668 }
5669 }
5670 match failed {
5671 Some(e) => Err(e),
5672 // A restore that is not a barrier can be gathered and reordered
5673 // past a later write, which is the whole hazard write gathering
5674 // introduced (issue #288); order it here instead. The data-only
5675 // barrier is the one that fits, because putting bytes back into
5676 // blocks that already existed moves no end-of-file — it is a
5677 // narrower barrier than the `barrier()` the commit itself uses
5678 // after these writes, deliberately.
5679 None => self.barrier_data(),
5680 }
5681 }
5682
5683 fn commit_inner(&mut self, staged: &mut StagedEdits) -> Result<(), Error> {
5684 if staged.is_empty() {
5685 return Ok(());
5686 }
5687
5688 // An attempt that failed partway may have recorded superseded collections
5689 // it never freed; this one must not free them on its behalf, since it is
5690 // not the commit that replaced what they hold.
5691 self.superseded_heaps.clear();
5692 // Drop every heap-collection provenance record this batch could falsify,
5693 // before any of them is read below.
5694 self.invalidate_heap_provenance(staged);
5695
5696 // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
5697 // space has no on-disk record of which pages hold metadata and which hold
5698 // raw data, so this commit could not keep the two segregated and would
5699 // silently degrade the paging. Refuse up front, before any writes, exactly
5700 // as the bounded backend does. A paged *persisting* file is committed
5701 // through the page-aware tail below (issue #198).
5702 if self.paged.is_some() && self.persist.is_none() {
5703 return Err(Error::EditUnsupported(
5704 "committing an edit to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
5705 persisted free space; recreate the file with \
5706 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to edit it in place",
5707 ));
5708 }
5709
5710 // Invalidate the in-place-append geometry cache before doing any work. A
5711 // commit that reaches here rewrites and relocates object headers, frees
5712 // vacated regions into `self.free`, and may truncate the file — any of
5713 // which can leave a cached `Located` pointing at a moved header or into a
5714 // now-free-eligible region. Clearing at *entry* (rather than the success
5715 // tail) means a later failure — including one after the durable root flip,
5716 // which leaves the session reusable — never strands a stale cache. The
5717 // no-op fast return above does no such work, so it keeps the cache. The
5718 // The next in-place append re-locates against the fresh file.
5719 self.located.clear();
5720 self.resolved.clear();
5721 // Past this point the commit may relocate object headers, so every address
5722 // a caller captured earlier is suspect; see `committed`.
5723 self.committed = true;
5724
5725 // On a file with a userblock, stored addresses are relative to this base
5726 // and the editor converts at every disk boundary (read `stored + base`,
5727 // write `file_offset - base`). Userblock support covers value overwrites,
5728 // additions of contiguous and chunked/filtered datasets, in-place and
5729 // relocating overwrites of every layout (chunked, contiguous, compact) with
5730 // reclaim, object deletion (with base-aware subtree reclaim), object copy
5731 // (in-file, and cross-file into a userblock destination), group creation,
5732 // and compact group attributes. Cross-file copy still requires a base-0
5733 // *source* (see [`copy_from`](Self::copy_from)).
5734 let base = self.superblock.base_address;
5735
5736 // --- Preflight value overwrites (`write_dataset`) before any write, under
5737 // the same all-or-nothing contract as additions. Each is resolved,
5738 // validated (datatype and shape must match the on-disk dataset exactly),
5739 // and classified: a same-length contiguous overwrite is applied straight
5740 // in place (no header rewrite, no superblock flip), while a resize or
5741 // compact rewrite relocates the header and is staged against its parent
5742 // group so the commit below rebuilds it and patches the link. ---
5743 let mut inplace_writes: Vec<(u64, OverwriteBytes)> = Vec::new();
5744 let mut moving_writes: Vec<(ObjectPathBuf, LinkNameBuf, u64, MovingWrite)> = Vec::new();
5745 let mut write_targets: Vec<ObjectPathBuf> = Vec::new();
5746 // The file-wide hard-link count, computed lazily the first time a commit
5747 // relocates a header: such a write moves the object's header and patches
5748 // only the one parent link that names it, so an object reachable through
5749 // more than one hard link would have its other links left pointing at the
5750 // stale header. Refuse that rather than silently diverge the aliases (a
5751 // same-length in-place overwrite is unaffected — it rewrites the shared
5752 // data block, which every link sees).
5753 //
5754 // Read by four places, which is every way a header moves: a relocating
5755 // overwrite, a staged append, a dataset attribute edit, and — since
5756 // issue #327 — a rebuilt group, which had the same defect and none of
5757 // the guard.
5758 let mut incoming_links: Option<Option<HashMap<u64, u32>>> = None;
5759 for (full, fd) in &staged.writes {
5760 // A path named twice in one commit would write it twice (and double-
5761 // free a resized extent); require separate commits.
5762 if write_targets.contains(full) {
5763 return Err(Error::EditUnsupported(
5764 "the same dataset is overwritten twice in one commit; use separate commits",
5765 ));
5766 }
5767 let addr = crate::group_v2::resolve_path_any_from_source(
5768 &self.image(),
5769 AccessMode::ReadWrite,
5770 &self.superblock,
5771 &full.as_path(),
5772 )?;
5773 match Self::prepare_write(&self.image(), addr, fd, base, full)? {
5774 WritePlan::InPlace { data_addr, bytes } => {
5775 inplace_writes.push((data_addr, bytes));
5776 }
5777 // A chunked in-place overwrite never carries staging — a staged
5778 // one is a `ChunkPayload::Deferred`, which relocates.
5779 WritePlan::InPlaceChunks { writes } => inplace_writes.extend(
5780 writes
5781 .into_iter()
5782 .map(|(at, raw)| (at, OverwriteBytes::ready(raw))),
5783 ),
5784 WritePlan::Moving(mw) => {
5785 // A relocating overwrite rewrites the dataset's header and data
5786 // address. Every variant is base-aware on a userblock file: the
5787 // chunked one rebuilds the chunk blob with stored addresses and
5788 // reclaims the old storage base-relative, the contiguous one
5789 // stores the relocated data address base-relative (and frees the
5790 // old extent at its absolute offset), and the compact one carries
5791 // its data inline. The parent link to the rewritten header is
5792 // patched base-relative below.
5793 //
5794 // A relocating overwrite is safe only when this is the
5795 // dataset's sole hard link. Compute the link graph once.
5796 let counts = incoming_links
5797 .get_or_insert_with(|| self.count_incoming_hard_links())
5798 .as_ref();
5799 match counts.and_then(|c| c.get(&addr)) {
5800 Some(&1) => {}
5801 _ => {
5802 return Err(Error::EditUnsupported(
5803 "overwriting a dataset that resizes or relocates its header is \
5804 only supported when it has a single hard link",
5805 ));
5806 }
5807 }
5808 let Some((parent, leaf)) = full.split_leaf() else {
5809 return Err(Error::EditUnsupported("cannot overwrite the root group"));
5810 };
5811 moving_writes.push((parent, leaf, addr, mw));
5812 }
5813 }
5814 write_targets.push(full.clone());
5815 }
5816
5817 // --- Preflight appends (`append_dataset`) under the same all-or-nothing,
5818 // single-hard-link contract. Each plans a relocating append — existing
5819 // chunk data stays in place; the appended (and any rewritten trailing)
5820 // chunks and a rebuilt Extensible-Array index are staged, and the whole is
5821 // treated like a relocating overwrite of the dataset's header (staged
5822 // against its parent group so the commit patches the link). A zero-length
5823 // append is a no-op and is dropped here. ---
5824 for (full, ab) in &staged.appends {
5825 let Some((parent, leaf)) = full.split_leaf() else {
5826 return Err(Error::AppendUnsupported("cannot append to the root group"));
5827 };
5828 if ab.raw.is_empty() {
5829 continue; // nothing to append
5830 }
5831 // A dataset overwritten or appended earlier in this commit would be
5832 // planned against a stale header and its old storage double-freed;
5833 // require separate commits.
5834 if write_targets.contains(full) {
5835 return Err(Error::AppendUnsupported(
5836 "the same dataset is edited more than once in one commit; use separate commits",
5837 ));
5838 }
5839 let addr = crate::group_v2::resolve_path_any_from_source(
5840 &self.image(),
5841 AccessMode::ReadWrite,
5842 &self.superblock,
5843 &full.as_path(),
5844 )?;
5845 let mw = Self::prepare_append(&self.image(), addr, ab, base)?;
5846 // A relocating append moves the dataset's object header and patches only
5847 // the one parent link that names it, so it is safe only when this is the
5848 // dataset's sole hard link (same rule as a relocating overwrite).
5849 let counts = incoming_links
5850 .get_or_insert_with(|| self.count_incoming_hard_links())
5851 .as_ref();
5852 match counts.and_then(|c| c.get(&addr)) {
5853 Some(&1) => {}
5854 _ => {
5855 return Err(Error::AppendUnsupported(
5856 "appending relocates the dataset header; only supported when it \
5857 has a single hard link",
5858 ));
5859 }
5860 }
5861 moving_writes.push((parent, leaf, addr, mw));
5862 write_targets.push(full.clone());
5863 }
5864
5865 // --- Preflight dataset attribute edits (`set_dataset_attr` /
5866 // `remove_dataset_attr`) under the same all-or-nothing, single-hard-link
5867 // contract. Each gathers the dataset's verbatim object-header region,
5868 // applies the compact attribute ops to it, and stages a relocating
5869 // `AttrEdit` header rewrite against the parent group — like a value
5870 // overwrite, but the data-layout message (and thus the chunk data and index)
5871 // is preserved verbatim, so only the header moves. ---
5872 if !staged.dataset_attrs.is_empty() {
5873 // Collect the ops per dataset in first-seen path order, so multiple edits
5874 // to one dataset produce a single relocating header rewrite.
5875 let mut order: Vec<ObjectPathBuf> = Vec::new();
5876 let mut ops_by_path: HashMap<&ObjectPathBuf, Vec<&AttrOp>> = HashMap::new();
5877 for (path, op) in &staged.dataset_attrs {
5878 if !ops_by_path.contains_key(path) {
5879 order.push(path.clone());
5880 }
5881 ops_by_path.entry(path).or_default().push(op);
5882 }
5883 for full in order {
5884 let ops = ops_by_path.remove(&full).unwrap();
5885 let Some((parent, leaf)) = full.split_leaf() else {
5886 return Err(Error::EditUnsupported(
5887 "cannot set a dataset attribute on the root group; use set_group_attr",
5888 ));
5889 };
5890 // A dataset already overwritten or appended in this commit would be
5891 // planned against a stale header; require separate commits.
5892 if write_targets.contains(&full) {
5893 return Err(Error::EditUnsupported(
5894 "the same dataset is edited more than once in one commit (an attribute \
5895 edit plus another edit); use separate commits",
5896 ));
5897 }
5898 let addr = crate::group_v2::resolve_path_any_from_source(
5899 &self.image(),
5900 AccessMode::ReadWrite,
5901 &self.superblock,
5902 &full.as_path(),
5903 )?;
5904 // An attribute edit relocates the dataset's object header and patches
5905 // only the one naming link, so it is safe only when this is the
5906 // dataset's sole hard link (same rule as a relocating overwrite).
5907 let counts = incoming_links
5908 .get_or_insert_with(|| self.count_incoming_hard_links())
5909 .as_ref();
5910 match counts.and_then(|c| c.get(&addr)) {
5911 Some(&1) => {}
5912 _ => {
5913 return Err(Error::EditUnsupported(
5914 "editing a dataset attribute relocates its header; only supported \
5915 when it has a single hard link",
5916 ));
5917 }
5918 }
5919 let region = Self::gather_oh_messages(&self.image(), addr, base)?;
5920 let edits = plan_attr_ops(&self.image(), base, Some(addr), ®ion, &ops)?;
5921
5922 moving_writes.push((
5923 parent,
5924 leaf,
5925 addr,
5926 MovingWrite::AttrEdit {
5927 region: edits.region,
5928 attrs: edits.attrs,
5929 },
5930 ));
5931 write_targets.push(full);
5932 }
5933 }
5934
5935 // Pre-commit object-header addresses this commit rewrites elsewhere, for
5936 // `InvalidatedAddresses` below: a reference naming one of these keeps an
5937 // address the commit is vacating (issue #317). Read off the one list every
5938 // relocating write is staged on, so a write added later cannot be left out
5939 // of the screen without failing to compile.
5940 let mut moved_headers: Vec<u64> =
5941 moving_writes.iter().map(|&(_, _, addr, _)| addr).collect();
5942
5943 // Fast path: when the only staged edits are same-length in-place
5944 // overwrites, apply them straight to their data blocks and return without
5945 // rebuilding any header or flipping the superblock root. The commit's
5946 // linearization point is the synced data write — there is no tree to
5947 // repoint, so each overwrite stands alone. (A persisting file takes the
5948 // same path: no free-space change occurs.)
5949 //
5950 // Because this path never rewrites the superblock, it deliberately leaves
5951 // it untouched — including a pre-existing stale consistency flag (e.g. one
5952 // left by a crashed SWMR writer). A lone same-length value overwrite does
5953 // not introduce any inconsistency, so it does not clear one either; an edit
5954 // that takes the full path below (any header/root change) clears the flag
5955 // as usual.
5956 //
5957 // A staged variable-length overwrite cannot reach here at all: it
5958 // relocates, so `moving_writes` is non-empty for it. That is enforced
5959 // where the plan is chosen (`prepare_write`) rather than re-tested here,
5960 // and it has to hold for a second reason besides the one that made it
5961 // relocate — such an overwrite places a global heap collection, and an
5962 // append moves end-of-file, which only the superblock this path leaves
5963 // untouched records (issue #321).
5964 debug_assert!(
5965 inplace_writes.iter().all(|(_, b)| b.vlen.is_none()),
5966 "a staged variable-length overwrite must relocate, not write in place"
5967 );
5968 if moving_writes.is_empty()
5969 && staged.datasets.is_empty()
5970 && staged.groups.is_empty()
5971 && staged.group_attrs.is_empty()
5972 && staged.deletes.is_empty()
5973 && staged.copies.is_empty()
5974 && staged.cross_copies.is_empty()
5975 {
5976 // This path's own point of no return, and it takes the staged set
5977 // for the same reason the main one below does (issue #316): every
5978 // refusal that applies to these overwrites has already run, and a
5979 // batch some of which has been written is not one a later `commit`
5980 // may re-issue. It is also what this path already did, back when the
5981 // write preflight drained the staged set on its way here.
5982 //
5983 // Leaving the set in place would make a half-written batch
5984 // retryable — these overwrites are same-length writes to fixed
5985 // addresses, so repeating them is idempotent — but that reads the
5986 // rule the other way round for one path, on an argument nothing
5987 // enforces. What the file holds is settled separately and the same
5988 // way on both paths: a `write_at` that fails partway is undone
5989 // through the journal below, so the batch is dropped but the
5990 // dataset's values are the ones it had (issue #344).
5991 drop(std::mem::take(staged));
5992 for (data_addr, bytes) in &inplace_writes {
5993 // Every entry here stages nothing (the guard above), so this
5994 // hands back the staged bytes unchanged and unallocated.
5995 let raw = self.resolve_overwrite_bytes(bytes)?;
5996 self.write_inplace_journaled(*data_addr, &raw)?;
5997 }
5998 self.barrier()?;
5999 return Ok(());
6000 }
6001
6002 // --- Plan: build the tree of "dirty" groups (root plus every group on a
6003 // path to an addition or deletion), validating every target before any
6004 // write. `add_targets` records the full paths created this commit, used
6005 // to reject a deletion that overlaps an addition. ---
6006 let mut nodes: BTreeMap<ObjectPathBuf, Node> = BTreeMap::new();
6007 nodes.entry(ObjectPathBuf::root()).or_default(); // root is always dirty
6008 let mut add_targets: Vec<ObjectPathBuf> = Vec::new();
6009 // Where each in-file `copy` reads from. A copy takes its bytes from the
6010 // *pre-commit* file, so a source this same commit replaces would copy the
6011 // object being removed while the replacement lands at the same path — see
6012 // the delete-staging loop, which refuses that. Cross-file copies are not
6013 // tracked: their source is in another file, so no path here can name it.
6014 let mut copy_sources: Vec<ObjectPathBuf> = Vec::new();
6015 let mut attr_targets: Vec<ObjectPathBuf> = Vec::new();
6016
6017 // Mark explicitly-created new groups, ensuring their ancestor chain.
6018 for path in &staged.groups {
6019 if path.is_empty() {
6020 return Err(Error::EditUnsupported("cannot create the root group"));
6021 }
6022 ensure_ancestors(&mut nodes, path);
6023 nodes.entry(path.clone()).or_default().is_new = true;
6024 add_targets.push(path.clone());
6025 }
6026
6027 // Make each staged dataset's parent group a node (with its ancestor
6028 // chain). The datasets themselves stay in the staged set, which the
6029 // preflight reads but must not empty (issue #316); `datasets_by_group`
6030 // below is the grouped view both the preflight and the apply loop use.
6031 for (parent, fd) in &staged.datasets {
6032 add_targets.push(parent.join(&fd.name));
6033 ensure_ancestors(&mut nodes, parent);
6034 }
6035
6036 // Attach relocating value overwrites (resized contiguous or compact) to
6037 // their parent group nodes: the new header is written below and the
6038 // parent's existing link patched to it, like an existing child group.
6039 for (parent, leaf, old_oh, mw) in moving_writes {
6040 ensure_ancestors(&mut nodes, &parent);
6041 nodes
6042 .entry(parent)
6043 .or_default()
6044 .writes
6045 .push((leaf, old_oh, mw));
6046 }
6047
6048 // Stage group attribute edits against their target groups. A target may
6049 // be a newly-created group from this same commit, but not a copied
6050 // destination or a dataset being added in the same commit. The ops
6051 // themselves stay in the staged set and are looked up by path where they
6052 // are applied, so a refusal below still gives them back (issue #316).
6053 for (path, _) in &staged.group_attrs {
6054 ensure_ancestors(&mut nodes, path);
6055 attr_targets.push(path.clone());
6056 }
6057
6058 // Stage copies: validate the source subtree is copyable (read-only),
6059 // then treat the destination like an addition to its parent group.
6060 for (src, dst) in &staged.copies {
6061 if src.is_empty() {
6062 return Err(Error::EditUnsupported("cannot copy the root group"));
6063 }
6064 let Some((parent, leaf)) = dst.split_leaf() else {
6065 return Err(Error::EditUnsupported("copy destination path is empty"));
6066 };
6067 if dst.starts_with(src) {
6068 return Err(Error::EditUnsupported(
6069 "cannot copy an object into itself or its own subtree",
6070 ));
6071 }
6072 let src_addr = crate::group_v2::resolve_path_any_from_source(
6073 &self.image(),
6074 AccessMode::ReadWrite,
6075 &self.superblock,
6076 &src.as_path(),
6077 )?;
6078 // Read the source subtree from this file's own mirror (`cross_file`
6079 // false: same address space, so verbatim addresses stay valid). On a
6080 // userblock file the stored addresses are base-relative, so pass this
6081 // session's base for the read to absolutize them.
6082 let tree = Self::read_copy_subtree(
6083 &self.image(),
6084 AccessMode::ReadWrite,
6085 src_addr,
6086 0,
6087 false,
6088 base,
6089 )?;
6090 copy_sources.push(src.clone());
6091 add_targets.push(dst.clone());
6092 ensure_ancestors(&mut nodes, &parent);
6093 nodes.entry(parent).or_default().copies.push((leaf, tree));
6094 }
6095
6096 // Stage cross-file copies: their subtrees were already read out of the
6097 // source file (with foreign-address screening) when `copy_from` was
6098 // called, so here they are simply linked into the destination parent like
6099 // any other addition.
6100 for (dst, _) in &staged.cross_copies {
6101 let Some((parent, leaf)) = dst.split_leaf() else {
6102 return Err(Error::EditUnsupported("copy destination path is empty"));
6103 };
6104 add_targets.push(dst.clone());
6105 ensure_ancestors(&mut nodes, &parent);
6106 nodes.entry(parent).or_default().cross_copies.push(leaf);
6107 }
6108
6109 // Stage deletions: each must exist, must not overlap any other staged
6110 // change *unless* this commit replaces what it removes, and is recorded
6111 // against its parent group (which becomes dirty). `deleted_addrs` keeps
6112 // each removed object's header address so its owned blocks can be
6113 // reclaimed after the commit lands (issue #21).
6114 let delete_targets = &staged.deletes;
6115 let mut deleted_addrs: Vec<u64> = Vec::new();
6116 for (i, d) in delete_targets.iter().enumerate() {
6117 let Some((parent, leaf)) = d.split_leaf() else {
6118 return Err(Error::EditUnsupported("cannot delete the root group"));
6119 };
6120 let del_addr = crate::group_v2::resolve_path_any_from_source(
6121 &self.image(),
6122 AccessMode::ReadWrite,
6123 &self.superblock,
6124 &d.as_path(),
6125 )?;
6126 deleted_addrs.push(del_addr);
6127 // A deletion may overlap other staged work when this commit
6128 // *replaces* what it removes: an addition names exactly `d`, so the
6129 // removal and the new object at the same path are one rotation
6130 // rather than an edit of something being deleted (issue #305).
6131 let recreated = add_targets.iter().any(|t| t == d);
6132 // A replacement also requires that every group node at or below `d`
6133 // is one this commit builds fresh. A node that is *not* new is
6134 // rebuilt from the old object's on-disk header — the object being
6135 // replaced — and both ways that lands are wrong, in the two shapes
6136 // this guard was measured against:
6137 //
6138 // * At `d` itself (a group attribute set on a path this commit
6139 // replaces with a *dataset*), the node and the replacement share a
6140 // `path_addr` key. The replacement's address overwrites the group's,
6141 // the parent's link is then patched to the address it already had,
6142 // and the rebuilt group header is left orphaned — a commit that
6143 // returns `Ok` having **silently discarded** the staged attribute.
6144 // * Strictly below `d`, the parent is a freshly built region with no
6145 // existing link to patch, so `patch_link_target` reports a missing
6146 // child link partway through the apply. That leaves a valid file
6147 // (the superblock is never repointed) but appends dead bytes and
6148 // reports the wrong thing.
6149 //
6150 // Refusing here keeps both in the preflight, where the commit is
6151 // all-or-nothing and the message can name the actual conflict.
6152 if recreated
6153 && !nodes
6154 .iter()
6155 .all(|(key, node)| !key.starts_with(d) || node.is_new)
6156 {
6157 return Err(Error::EditUnsupported(
6158 "a staged edit names a group at or under a replaced path that this \
6159 commit does not itself create; create it in the same commit, or use \
6160 separate commits",
6161 ));
6162 }
6163 // A copy reading from a path this commit replaces is the same conflict
6164 // seen from the other side: `read_copy_subtree` already took its bytes
6165 // from the pre-commit file, so the commit would place the *original*
6166 // at the copy's destination while placing something else at `d` — two
6167 // different objects from one path, in one commit, with no error. A
6168 // source that is merely deleted and not replaced is unambiguous (it is
6169 // a move) and stays allowed.
6170 if recreated {
6171 for t in ©_sources {
6172 if t.starts_with(d) {
6173 return Err(Error::EditUnsupported(
6174 "a copy in this commit reads from a path the same commit \
6175 replaces; use separate commits",
6176 ));
6177 }
6178 }
6179 }
6180 // Past that return `recreated` means the whole touched subtree is
6181 // fresh, so an addition at or below `d` lands in the replacement. The
6182 // apply loop already removes a group's deleted links before appending
6183 // any new one (`remove_link_from_region` runs first), so a
6184 // replacement needs no further sequencing here.
6185 for t in &add_targets {
6186 if recreated && t.starts_with(d) {
6187 continue;
6188 }
6189 if t.starts_with(d) || d.starts_with(t) {
6190 return Err(Error::EditUnsupported(
6191 "a deletion overlaps an addition in the same commit; \
6192 replace the path instead, or use separate commits",
6193 ));
6194 }
6195 }
6196 for t in &attr_targets {
6197 if recreated && t.starts_with(d) {
6198 continue;
6199 }
6200 if t.starts_with(d) {
6201 return Err(Error::EditUnsupported(
6202 "a deletion overlaps a group-attribute edit in the same commit; use separate commits",
6203 ));
6204 }
6205 }
6206 for t in &write_targets {
6207 if t.starts_with(d) {
6208 // `write_targets` holds three kinds — a value overwrite, a
6209 // dataset-attribute edit, and a staged append — so the
6210 // message names what they have in common rather than only
6211 // the first of them.
6212 return Err(Error::EditUnsupported(
6213 "a deletion overlaps a staged edit to a dataset in the same \
6214 commit; use separate commits",
6215 ));
6216 }
6217 }
6218 for (j, d2) in delete_targets.iter().enumerate() {
6219 if i != j && d2.starts_with(d) {
6220 return Err(Error::EditUnsupported(
6221 "overlapping deletions in one commit; delete the common parent only",
6222 ));
6223 }
6224 }
6225 ensure_ancestors(&mut nodes, &parent);
6226 nodes.entry(parent).or_default().deletes.push(leaf);
6227 }
6228
6229 // Resolve / validate each node's base object-header region up front.
6230 // Every existing dirty group is rewritten to a freshly-appended header,
6231 // so its old header becomes dead bytes once the superblock is repointed;
6232 // `superseded_addrs` records those old headers for reclamation (#21),
6233 // paired with the path whose rebuilt header replaces each — the two
6234 // halves of one relocation, which is what an object reference stored
6235 // elsewhere in the file has to be repointed across (issue #324).
6236 let keys: Vec<ObjectPathBuf> = nodes.keys().cloned().collect();
6237 let mut superseded_addrs: Vec<(ObjectPathBuf, u64)> = Vec::new();
6238 for key in &keys {
6239 let is_new = nodes[key].is_new;
6240 if is_new {
6241 nodes.get_mut(key).unwrap().base_region = fresh_group_region();
6242 } else {
6243 let addr = crate::group_v2::resolve_path_any_from_source(
6244 &self.image(),
6245 AccessMode::ReadWrite,
6246 &self.superblock,
6247 &key.as_path(),
6248 )?;
6249 // Rebuilding this group moves its header and patches only the
6250 // link this commit resolved it through, so every other hard link
6251 // to it would be left naming the old header — which this commit
6252 // then frees. The aliases show the pre-commit group until
6253 // something reuses the span, and are unreadable after (issue
6254 // #327). Same rule, and the same lazily-computed count, as the
6255 // three relocating dataset writes above.
6256 //
6257 // The root group is exempt because it is named by the superblock
6258 // rather than by a link, so it has no entry in the count and
6259 // nothing to strand. Every other group was resolved by path and
6260 // therefore has at least one.
6261 if !key.is_empty() {
6262 let counts = incoming_links
6263 .get_or_insert_with(|| self.count_incoming_hard_links())
6264 .as_ref();
6265 match counts.and_then(|c| c.get(&addr).copied()) {
6266 Some(1) => {}
6267 // Known, and more than one: the aliases are the problem.
6268 Some(_) => {
6269 return Err(Error::EditUnsupported(
6270 "editing a group relocates its object header; only supported \
6271 when it has a single hard link",
6272 ));
6273 }
6274 // Not known: the file-wide walk that counts links gave up
6275 // — an object header it could not parse, a group it could
6276 // not enumerate, or a link graph past its bound. A
6277 // different refusal because it is a different problem, and
6278 // "it has more than one hard link" would send the reader
6279 // looking for a second link that may not exist.
6280 None => {
6281 return Err(Error::EditUnsupported(
6282 "editing a group relocates its object header, and this file's \
6283 links could not be walked to establish that nothing else \
6284 names it",
6285 ));
6286 }
6287 }
6288 }
6289 let info = self.inspect_group(addr)?;
6290 superseded_addrs.push((key.clone(), addr));
6291 let node = nodes.get_mut(key).unwrap();
6292 node.base_region = info.region;
6293 node.existing_links = info.link_names;
6294 }
6295 }
6296
6297 // A rebuilt group's old header is vacated just as a relocated dataset's is,
6298 // so the two join one list for the screen below.
6299 moved_headers.extend(superseded_addrs.iter().map(|&(_, a)| a));
6300
6301 // Apply and validate group attribute edits before any writes. This keeps
6302 // unsupported attribute edits under the same all-or-nothing preflight
6303 // contract as unsupported dataset additions. A variable-length attribute
6304 // is not fully resolved here — its global heap collection is built (it
6305 // is self-contained, no address needed yet) but placed and patched into
6306 // `base_region` only in the apply loop below, once its address is known.
6307 let attrs_by_group = group_by_parent(staged.group_attrs.iter().map(|(p, op)| (p, op)));
6308 // The pre-commit header address of each group this commit rewrites. An
6309 // edit that sends a group's attributes to a fractal heap reads the set it
6310 // rebuilds back out of the file, which needs the address; a group this
6311 // commit *creates* has neither an address nor any stored attribute, and
6312 // its `None` says so.
6313 let existing_group_addrs: HashMap<ObjectPathBuf, u64> =
6314 superseded_addrs.iter().cloned().collect();
6315 for key in &keys {
6316 if let Some(ops) = attrs_by_group.get(key) {
6317 let region = std::mem::take(&mut nodes.get_mut(key).unwrap().base_region);
6318 let edits = plan_attr_ops(
6319 &self.image(),
6320 base,
6321 existing_group_addrs.get(key).copied(),
6322 ®ion,
6323 ops,
6324 )?;
6325 let node = nodes.get_mut(key).unwrap();
6326 node.base_region = edits.region;
6327 node.attrs = edits.attrs;
6328 }
6329 }
6330
6331 // Map each node to its direct child group nodes, each with the link name
6332 // it has in that parent (for link wiring). The root group has no name in
6333 // a parent, so `split_leaf` gives `None` for it and the loop skips it.
6334 let mut children: BTreeMap<ObjectPathBuf, Vec<(ObjectPathBuf, LinkNameBuf)>> =
6335 BTreeMap::new();
6336 for key in &keys {
6337 if let Some((parent, leaf)) = key.split_leaf() {
6338 children
6339 .entry(parent)
6340 .or_default()
6341 .push((key.clone(), leaf));
6342 }
6343 }
6344
6345 // Each group's added datasets, in the order the apply loop will place
6346 // them, for the guards below to read. Borrowed from the staged set:
6347 // every one of those guards may still refuse, and a refusal gives the
6348 // caller back every edit it was holding (issue #316). The apply loop
6349 // rebuilds the same grouping, by the same rule, once it owns the set.
6350 let datasets_by_group = group_by_parent(staged.datasets.iter().map(|(p, fd)| (p, fd)));
6351
6352 // A group that tracks *link* creation order takes an addition — the
6353 // apply loop numbers each added link from the running maximum its Link
6354 // Info message records — up to the point the addition would send its
6355 // links dense, which is refused before anything is written. The count
6356 // this screens is the one the group would hold afterwards: the links it
6357 // has now, less the ones this commit deletes, plus the ones it adds.
6358 for key in &keys {
6359 let node = &nodes[key];
6360 let added = node.copies.len()
6361 + node.cross_copies.len()
6362 + datasets_by_group.get(key).map_or(0, Vec::len)
6363 + children.get(key).map_or(0, |kids| {
6364 kids.iter().filter(|(child, _)| nodes[child].is_new).count()
6365 });
6366 if added > 0 {
6367 let kept = node
6368 .existing_links
6369 .iter()
6370 .filter(|name| !node.deletes.iter().any(|d| d.as_str() == *name))
6371 .count();
6372 reject_dense_link_creation_order(&node.base_region, kept + added)?;
6373 }
6374 }
6375
6376 // Validate names: no addition may collide with an existing link or with
6377 // another addition under the same parent. A link this same commit
6378 // deletes is not one of the existing ones — the apply loop removes it
6379 // from the region before any addition is appended, so replacing an
6380 // object at its own path is a rotation, not a collision (issue #305).
6381 for key in &keys {
6382 let node = &nodes[key];
6383 let mut adding: Vec<&str> = Vec::new();
6384 for fd in datasets_by_group.get(key).into_iter().flatten() {
6385 adding.push(fd.name.as_str());
6386 }
6387 for (child, leaf) in children.get(key).into_iter().flatten() {
6388 if nodes[child].is_new {
6389 adding.push(leaf.as_str());
6390 }
6391 }
6392 for (leaf, _) in &node.copies {
6393 adding.push(leaf.as_str());
6394 }
6395 for leaf in &node.cross_copies {
6396 adding.push(leaf.as_str());
6397 }
6398 for (i, name) in adding.iter().enumerate() {
6399 let survives = node.existing_links.iter().any(|n| n == name)
6400 && !node.deletes.iter().any(|n| n.as_str() == *name);
6401 if survives || adding[..i].contains(name) {
6402 return Err(Error::EditUnsupported(
6403 "a link with this name already exists in the target group",
6404 ));
6405 }
6406 }
6407 }
6408
6409 // Content the caller's library-version bound cannot carry is refused
6410 // here, before any write, so a rejected addition leaves the commit
6411 // unapplied.
6412 self.check_libver_admits(staged.datasets.iter().map(|(_, fd)| fd))?;
6413
6414 // Enumerate what this commit's deletions reclaim, from the current
6415 // on-disk layout and before any byte moves. It is read here, ahead of
6416 // every remaining refusal, because two of them screen against it: an
6417 // object reference this commit writes must not name space the same
6418 // commit frees (issue #317). The spans are carried to `to_free` below
6419 // rather than walked a second time, so the screen and the allocator
6420 // always mean the same thing by "removed".
6421 //
6422 // An object's storage is reclaimed only when the link being removed is
6423 // its LAST hard link: HDF5 objects can have several hard links, and one
6424 // reachable through a surviving link is still live (freeing it would
6425 // corrupt the survivor). Count every hard link in the pre-commit file
6426 // and reclaim a deleted object only when its count is exactly 1.
6427 // `deleted_addrs` is de-duplicated first so two delete paths that are
6428 // hard links to the same object are not visited (and freed) twice. If
6429 // the link graph cannot be walked in full, no deleted object is
6430 // reclaimed (a safe leak) — and none is screened either, which is sound
6431 // in the same direction: nothing is freed, so no reference dangles.
6432 // Superseded group headers and relocated dataset headers are vacated too,
6433 // and are screened separately: `moved_headers` holds them, and they are
6434 // addresses rather than spans.
6435 let mut deleted_free: Vec<(u64, u64, FreeClass)> = Vec::new();
6436 deleted_addrs.sort_unstable();
6437 deleted_addrs.dedup();
6438 if !deleted_addrs.is_empty() {
6439 if let Some(incoming) = self.count_incoming_hard_links() {
6440 for &a in &deleted_addrs {
6441 self.collect_free_spans(a, 0, &incoming, &mut deleted_free);
6442 }
6443 }
6444 }
6445 // An in-file copy re-emits its source's element bytes verbatim, so a
6446 // copied object reference keeps naming whatever it named in the source —
6447 // including an object this same commit is removing (issue #317). The page
6448 // types the walk records are for the allocator, not for this.
6449 //
6450 // Removals only, and this is the one place the two halves part company. A
6451 // *supplied* address is a fresh claim about the file, and a commit that
6452 // moves the object falsifies it. A *copied* one repeats a claim the file
6453 // already made, and since issue #324 a move no longer falsifies either
6454 // one: the commit's last act walks the tree it published and repoints
6455 // every reachable stored address, the copy's among them
6456 // ([`crate::reference_patch`], and `a_copy_made_in_the_same_commit_that_
6457 // moves_its_target_is_repointed`). A removal is the case that stays,
6458 // because there is no new address to point at.
6459 //
6460 // Refusing on `moved` here would also cost what it cannot buy: every
6461 // commit that reaches here rebuilds its root group, so `moved` is never
6462 // empty, and a chunked object-reference dataset is refused on a non-empty
6463 // screen alone rather than on a matching address — so every such copy
6464 // would be refused, in every commit.
6465 let for_copied = InvalidatedAddresses {
6466 removed: deleted_free
6467 .iter()
6468 .map(|&(off, len, _)| (off, len))
6469 .collect(),
6470 moved: Vec::new(),
6471 base: self.superblock.base_address,
6472 };
6473 {
6474 // Framed at the base address: a shared-message address is stored
6475 // relative to it, and `SourceResolver` reads its references as
6476 // absolute within the view it is given.
6477 let image = self.image();
6478 let framed = BaseOffsetSource {
6479 inner: image,
6480 base: self.superblock.base_address,
6481 };
6482 for key in &keys {
6483 for (_, tree) in &nodes[key].copies {
6484 screen_copied_references(tree, &for_copied, &framed)?;
6485 }
6486 }
6487 }
6488
6489 // The same screen the copies just got, plus the half a supplied address
6490 // earns and a copied one does not.
6491 let for_supplied = InvalidatedAddresses {
6492 moved: moved_headers,
6493 ..for_copied
6494 };
6495
6496 // A shared-message index record can hold an object-header address too,
6497 // and it is the one stored address the commit's closing repoint walk
6498 // cannot reach. Screen it here, against the same two lists.
6499 self.screen_shared_message_index(&for_supplied)?;
6500
6501 // References a builder already resolved to addresses never reach
6502 // `resolve_reference_target`, so they are screened out of the element
6503 // bytes instead — additions and value overwrites alike, since neither
6504 // form's bytes are touched again before they are written. A slot still
6505 // holding a placeholder screens as the zero it is, and is screened for
6506 // real by `resolve_reference_target` when it resolves.
6507 //
6508 // Cross-file copies need no screen for the opposite reason:
6509 // `reject_foreign_addresses` refuses a reference datatype outright on
6510 // that path.
6511 for (_, fd) in staged.datasets.iter().chain(&staged.writes) {
6512 screen_resolved_references(&fd.dt, &fd.raw, &for_supplied)?;
6513 }
6514
6515 // Anything this commit adds could be a reference container the last walk
6516 // did not see, so it retires that walk's "no references in this file"
6517 // finding (issue #324). See `proved_free_of_references` for which of
6518 // these four clauses is load-bearing and which are belt-and-braces, and
6519 // for why the other five staged collections need no clause at all.
6520 //
6521 // A staged dataset's *attributes* are screened beside its element
6522 // datatype. Neither can carry a reference today — a committed one is
6523 // refused by `flatten_dataset`, and `AttrValue` has no reference variant
6524 // — but that is a refusal in another function rather than a property of
6525 // this collection, and the check that means what it says is the one that
6526 // looks.
6527 if staged.datasets.iter().chain(&staged.writes).any(|(_, fd)| {
6528 datatype_holds_object_address(&fd.dt)
6529 || fd
6530 .attrs
6531 .iter()
6532 .any(|a| datatype_holds_object_address(&a.datatype))
6533 }) || !staged.copies.is_empty()
6534 || !staged.cross_copies.is_empty()
6535 {
6536 self.proved_free_of_references = false;
6537 }
6538
6539 // Prove every object-reference target resolves before any write (see
6540 // `preflight_reference_targets`'s doc comment): otherwise a reference
6541 // resolution failure discovered mid-apply-loop would leave every
6542 // earlier-processed group's real writes (headers, data, copied
6543 // subtrees) orphaned in the file despite `commit()` returning `Err`.
6544 Self::preflight_reference_targets(
6545 &keys,
6546 &datasets_by_group,
6547 &nodes,
6548 &add_targets,
6549 &write_targets,
6550 delete_targets,
6551 &for_supplied,
6552 &self.image(),
6553 &self.superblock,
6554 )?;
6555
6556 // --- The point of no return. Every refusal is behind us, so the staged
6557 // set is taken here rather than at the top of this function: a refusal
6558 // above restores *every* edit the caller staged, and a failure below —
6559 // an I/O error mid-apply, which leaves the file valid because the
6560 // superblock is repointed last — restores none of them, so no later
6561 // `commit` can finish a batch this one abandoned (issue #316). Nothing
6562 // above this line may consume from `staged`. ---
6563 let mut taken = std::mem::take(staged);
6564 // The same paths as the borrow above, owned now that the set has moved.
6565 let delete_targets = std::mem::take(&mut taken.deletes);
6566 // The same grouping as `datasets_by_group` above, by the same rule and
6567 // so in the same order — which is what keeps the apply loop's placement
6568 // order the one `preflight_reference_targets` proved.
6569 let mut flat = group_by_parent(taken.datasets.drain(..));
6570 // A cross-file copy's subtree can move now, so it joins its destination
6571 // group's in-file copies. The parent node exists: the preflight made one
6572 // for every destination.
6573 for (dst, tree) in taken.cross_copies.drain(..) {
6574 // A destination that identifies no link is rejected above.
6575 let (parent, leaf) = dst.split_leaf().expect("a copy destination names a link");
6576 nodes.get_mut(&parent).unwrap().copies.push((leaf, tree));
6577 }
6578
6579 // Gather the regions this commit will vacate, read from the current
6580 // on-disk layout before any byte moves: every deleted object's owned
6581 // blocks plus every superseded group header. They stay out of the free
6582 // list until every append this commit makes is behind it, so none of
6583 // them is reused while it is still live. (On the non-persisting tail
6584 // that point is a few lines *before* the superblock write rather than
6585 // after it — the apply loop is where the appends happen, and it is long
6586 // done by then.) Enumeration is
6587 // best-effort — `collect_free_spans` simply omits anything it cannot
6588 // account for exhaustively, so the worst case is unreclaimed dead bytes,
6589 // never a freed-but-live region.
6590 // It starts as the deleted objects' blocks, enumerated above the preflight
6591 // because a refusal there screens against them.
6592 let mut to_free: Vec<(u64, u64, FreeClass)> = deleted_free;
6593
6594 // A superseded group header is dead once the root is repointed. Its chunk
6595 // spans are enumerated base-aware (`oh_chunk_spans` shifts continuation
6596 // addresses by the userblock base and returns absolute file offsets), as is
6597 // the delete path (`collect_free_spans`), so all of this reclamation works
6598 // on userblock files too.
6599 for &(_, a) in &superseded_addrs {
6600 if let Ok(spans) = self.oh_chunk_spans(a) {
6601 to_free.extend(meta_spans(spans));
6602 }
6603 }
6604
6605 // A relocating overwrite (`write_dataset` resize, or any compact rewrite)
6606 // vacates the dataset's old object header, and a resized contiguous one
6607 // also vacates its old data block: both become dead once the parent's
6608 // relinked header lands. `superseded_addrs` covers only the rebuilt group
6609 // headers, not the relocated dataset's own header, so record that here too.
6610 // The pre-commit dataset-header address rides on the write plan (`old_oh`),
6611 // recorded where the plan was made; its chunks and old data extent are
6612 // freed only after the superblock repoint.
6613
6614 // The single-hard-link guard in the write preflight makes freeing the old
6615 // header safe (no surviving link still points at it).
6616 for key in &keys {
6617 for (_leaf, old_oh, mw) in &nodes[key].writes {
6618 match mw {
6619 MovingWrite::Contiguous {
6620 old_extent: Some(extent),
6621 ..
6622 } => to_free.push((extent.0, extent.1, FreeClass::Page(PageType::Raw))),
6623 // A relocated chunked dataset vacates its old chunk index and
6624 // chunk data blocks. `chunked_storage_spans` returns `None` for
6625 // anything it cannot enumerate exhaustively (leaving dead bytes
6626 // rather than freeing a region still in use); the old header
6627 // chunks are freed generically below.
6628 MovingWrite::Chunked { old_addr, .. } => {
6629 if let Some(spans) = self.chunked_storage_spans(*old_addr) {
6630 to_free.extend(spans);
6631 }
6632 }
6633 // A relocating append keeps the existing chunk *data* in place
6634 // (shared by both indexes during the commit), so only the old
6635 // index structure and the relocated old trailing chunk are dead.
6636 // The old header chunks are freed by the generic path below.
6637 MovingWrite::AppendedChunks {
6638 old_addr,
6639 old_tail_extent,
6640 kept_chunks,
6641 ..
6642 } => {
6643 if let Some(spans) = self.chunked_index_spans(*old_addr) {
6644 // The old index is reclaimed as raw only where it
6645 // provably sits in a raw page, and recorded as dead
6646 // otherwise. See `index_is_provably_raw`. The
6647 // dataset's old chunk data is the kept chunks
6648 // (base-relative) plus the trailing partial chunk
6649 // this append relocated, both already in hand, so
6650 // the proof needs no second walk of the index.
6651 let data: Vec<(u64, u64)> = kept_chunks
6652 .iter()
6653 .filter_map(|c| {
6654 Some((base.absolute(c.address).ok()?, c.compressed_size))
6655 })
6656 .chain(*old_tail_extent)
6657 .collect();
6658 let class = if self.index_is_provably_raw(&data, &spans) {
6659 FreeClass::Page(PageType::Raw)
6660 } else {
6661 FreeClass::Dead
6662 };
6663 to_free.extend(spans.into_iter().map(|(a, l)| (a, l, class)));
6664 }
6665 if let Some(ext) = old_tail_extent {
6666 // The relocated old trailing chunk is raw data.
6667 to_free.push((ext.0, ext.1, FreeClass::Page(PageType::Raw)));
6668 }
6669 }
6670 _ => {}
6671 }
6672 // The relocated dataset's old header chunks are dead too.
6673 if let Ok(spans) = self.oh_chunk_spans(*old_oh) {
6674 to_free.extend(meta_spans(spans));
6675 }
6676 }
6677 }
6678
6679 // Defense in depth: never hand the free list an out-of-bounds or
6680 // overlapping span. The last-link guard plus the per-object checks
6681 // should already make the accumulated spans disjoint; this enforces it
6682 // as a whole-commit invariant against the pre-commit end-of-file. Any
6683 // dropped span (which should not occur for a well-formed file) only
6684 // leaks, never corrupts.
6685 retain_disjoint_in_bounds(&mut to_free, self.image.len());
6686
6687 // --- Apply: process deepest groups first so each parent sees its
6688 // children's new addresses, then repoint the superblock last.
6689 // `path_addr` accumulates every group's and dataset's address as it is
6690 // placed — read by `resolve_reference_target` to resolve a same-commit
6691 // object-reference target (see the dataset-placement loop below for the
6692 // group/dataset key convention: a group's own path, or a dataset's
6693 // full parent+name path). ---
6694 let mut path_addr: BTreeMap<ObjectPathBuf, u64> = BTreeMap::new();
6695 // Where each object-header address this commit vacates has been rewritten
6696 // to, for repointing the object references the rest of the file already
6697 // stores (issue #324). Filled from the two places a header moves: a
6698 // relocating value overwrite, recorded in the loop below as its plan is
6699 // applied, and a rebuilt group, joined from `superseded_addrs` once every
6700 // group's new address is known.
6701 let mut relocations: BTreeMap<u64, u64> = BTreeMap::new();
6702 let mut by_depth = keys.clone();
6703 // Stable on purpose: `keys` comes from a `BTreeMap`, so equal-depth
6704 // groups arrive in path order, and this order is the order they are
6705 // placed in — which fixes every address the commit writes. An unstable
6706 // sort may permute a depth group, and the file's layout with it.
6707 by_depth.sort_by_key(|k| std::cmp::Reverse(k.len())); // deepest first
6708 for key in &by_depth {
6709 let (mut region, deletes, copies, writes, attrs) = {
6710 let node = nodes.get_mut(key).unwrap();
6711 (
6712 std::mem::take(&mut node.base_region),
6713 std::mem::take(&mut node.deletes),
6714 std::mem::take(&mut node.copies),
6715 std::mem::take(&mut node.writes),
6716 std::mem::take(&mut node.attrs),
6717 )
6718 };
6719
6720 // Remove deleted links first (verbatim-preserving the rest).
6721 //
6722 // First is a correctness requirement rather than a convention, and
6723 // has been since a replacement became one commit (issue #305):
6724 // `remove_link_from_region` matches by *name*, so a removal running
6725 // after the additions below would take the replacement's link with
6726 // the original's and leave the path gone. Every link this loop could
6727 // collide with — a copy's, a dataset's, a new child group's — is
6728 // appended after it, which is what keeps that unreachable. (A
6729 // relocating write and an existing child group *patch* a link rather
6730 // than appending one, so neither can be a replacement's.)
6731 for name in &deletes {
6732 region = remove_link_from_region(®ion, name.as_str())?;
6733 }
6734
6735 // Every link appended below takes the next creation index from this
6736 // group's running maximum, on the groups that track link creation
6737 // order and on no others; `link_order.record` writes the bumped
6738 // maximum back once the last of them is placed. Read after the
6739 // deletions above because a deletion leaves the maximum alone — the
6740 // gap it opens is never handed out again.
6741 let mut link_order = LinkCreationOrder::for_region(®ion)?;
6742
6743 // Write each staged source subtree and link its root into this group.
6744 for (leaf, tree) in copies {
6745 let root = self.write_copy_subtree(&tree)?;
6746 region.push_link(leaf.as_str(), base.relative(root)?, link_order.take()?);
6747 }
6748
6749 // Datasets directly under this group. Appended addresses are absolute
6750 // file offsets, and the contiguous data-layout address is stored
6751 // relative to the base address. Placed
6752 // non-reference datasets first (recording each into `path_addr`), then
6753 // reference datasets — a reference to a *non-reference* sibling added
6754 // in the same group's batch resolves regardless of `staged.datasets`
6755 // call order (`Vec::sort_by_key` is stable, so within each of the two
6756 // groups the original order is preserved). Two reference datasets that
6757 // target each other in the same batch are still call-order-dependent —
6758 // whichever is placed first resolves the other, and the reverse
6759 // direction is safely refused as "still writing" (never corrupted),
6760 // caught up front by `preflight_reference_targets`.
6761 let mut group_datasets: Vec<FlatDataset> =
6762 flat.remove(key).into_iter().flatten().collect();
6763 // Stable on purpose: this is a partition, and the relative order
6764 // within each half is what the paragraph above promises.
6765 group_datasets.sort_by_key(|fd| fd.reference_targets.is_some());
6766 for mut fd in group_datasets {
6767 // Place each variable-length attribute's global heap collection
6768 // and patch its placeholder heap address. Unlike VL-string
6769 // *data* (`vl_string_staging`, refused when chunked below), a
6770 // chunked/extensible dataset can carry a VL *attribute* just
6771 // fine — attributes live in the object header, not inside a
6772 // chunk, so patching them here before either apply branch runs
6773 // covers both.
6774 for (idx, collections) in std::mem::take(&mut fd.vl_attrs) {
6775 let addrs = self.place_vl_collections(&collections)?;
6776 patch_vl_refs(&mut fd.attrs[idx].raw_data, &addrs);
6777 }
6778 // Resolve an object-reference dataset's per-element targets now
6779 // that every earlier-placed object in this commit is in
6780 // `path_addr` (chunked datasets never carry these —
6781 // `flatten_dataset` refuses that combination).
6782 if let Some(patches) = fd.reference_targets.take() {
6783 for patch in &patches {
6784 let addr = Self::resolve_reference_target(
6785 &patch.target,
6786 &path_addr,
6787 &nodes,
6788 &add_targets,
6789 &write_targets,
6790 &delete_targets,
6791 &for_supplied,
6792 &self.image(),
6793 &self.superblock,
6794 )?;
6795 write_reference_address(&mut fd.raw, patch.byte_offset, addr);
6796 }
6797 }
6798 let oh = if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
6799 self.build_chunked_dataset(&fd)?
6800 } else {
6801 // A staged variable-length-string dataset's element
6802 // references still carry a placeholder heap address; place
6803 // its collection and patch them before `raw` is appended
6804 // (chunked datasets never carry staging — refused above).
6805 if let Some(staging) = fd.vl_string_staging.take() {
6806 if !staging.collections.is_empty() {
6807 let addrs = self.place_vl_collections(&staging.collections)?;
6808 patch_vl_refs_masked(&mut fd.raw, &staging.patch_offsets, &addrs);
6809 }
6810 }
6811 // A zero-element dataset has no data block to allocate; its
6812 // layout address is the undefined-address sentinel (never
6813 // base-relative — see `build_dataset_oh`'s empty-data callers
6814 // in the whole-file writer), matching every reader's and the
6815 // reference C library's convention for "no storage allocated".
6816 let data_addr = if fd.raw.is_empty() {
6817 StoredAddress::new(UNDEF)
6818 } else {
6819 base.relative(self.alloc_or_append_typed(&fd.raw, PageType::Raw)?)?
6820 };
6821 // Attributes this dataset keeps in a fractal heap are placed
6822 // now — after the variable-length patching above, so the heap
6823 // holds resolved references — and the header names the heap
6824 // instead of carrying them inline.
6825 let attr_info = self.place_dense_attrs_if_needed(&fd)?;
6826 build_dataset_oh(
6827 &fd.dt,
6828 // Committed datatypes are refused when a dataset is
6829 // staged (`flatten_dataset`), so every type here is
6830 // written into the dataset's own header.
6831 &DatatypeLocation::Inline,
6832 &fd.ds,
6833 data_addr,
6834 fd.raw.len() as u64,
6835 &fd.attrs,
6836 attr_info.as_deref(),
6837 fd.fill.as_deref(),
6838 self.libver(),
6839 )?
6840 };
6841 let oh_addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
6842 region.push_link(
6843 fd.name.as_str(),
6844 base.relative(oh_addr)?,
6845 link_order.take()?,
6846 );
6847 path_addr.insert(key.join(&fd.name), oh_addr);
6848 }
6849
6850 // Relocating value overwrites under this group: write the new data and
6851 // rewritten header, then patch this group's existing link to it. On a
6852 // userblock file only the chunked variant reaches here (contiguous and
6853 // compact resizes are refused in the write preflight).
6854 for (leaf, old_oh, mw) in &writes {
6855 let new_oh = self.write_moving(mw)?;
6856 patch_link_target(&mut region, leaf.as_str(), base.relative(new_oh)?)?;
6857 relocations.insert(*old_oh, new_oh);
6858 }
6859
6860 // Wire links to dirty child groups (new → add a link; existing →
6861 // patch the existing link to the child's new address).
6862 for (child, leaf) in children.get(key).into_iter().flatten() {
6863 let child_name = leaf.as_str();
6864 let child_addr = base.relative(path_addr[child])?;
6865 if nodes[child].is_new {
6866 region.push_link(child_name, child_addr, link_order.take()?);
6867 } else {
6868 patch_link_target(&mut region, child_name, child_addr)?;
6869 }
6870 }
6871
6872 // Every addition to this group is placed, so its Link Info message
6873 // can record the maximum creation index it has now assigned. A group
6874 // that gained no link, or that does not track the order, is left
6875 // byte-identical.
6876 link_order.record(&mut region)?;
6877
6878 // Whatever this group's attribute edits left to place: a
6879 // variable-length attribute's heap collection, or a whole dense set.
6880 self.place_edited_attrs(&mut region, attrs)?;
6881
6882 let oh = build_v2_object_header(®ion)?;
6883 let addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
6884 path_addr.insert(key.clone(), addr);
6885 }
6886
6887 // Same-length in-place overwrites (`write_dataset`) write straight into
6888 // their existing, already-referenced data blocks. Those blocks are
6889 // reachable from both the old and the new root (the dataset's header is
6890 // unchanged), so the write is independent of the superblock flip; it is
6891 // ordered before the barrier sync below so the new bytes are durable
6892 // alongside everything else this commit appended.
6893 // Nothing resolved here allocates: `prepare_write` plans a staged
6894 // variable-length overwrite as a relocating write rather than an
6895 // in-place one (issue #321), so every entry hands its staged bytes back
6896 // untouched — which the `vlen.is_none()` assertion guarding the fast
6897 // path states for the whole list, this one included. The journal below
6898 // is what costs here, and it is not free: see `inplace_undo`.
6899 for (data_addr, bytes) in &inplace_writes {
6900 let raw = self.resolve_overwrite_bytes(bytes)?;
6901 self.write_inplace_journaled(*data_addr, &raw)?;
6902 }
6903
6904 // Every group's new header address is known now that the apply loop has
6905 // run, so the rebuilt groups can join the relocation map. A group whose
6906 // key is missing would be one the loop did not place, which cannot happen:
6907 // `superseded_addrs` is filled from `keys`, and the loop visits all of it.
6908 for (key, old) in &superseded_addrs {
6909 if let Some(&new) = path_addr.get(key) {
6910 relocations.insert(*old, new);
6911 }
6912 }
6913
6914 // Repoint the superblock at the new root last: this is the commit's
6915 // linearization point. Until it lands, the file on disk still points at
6916 // the old root (the appended objects are merely unreferenced trailing
6917 // bytes), so a failure here leaves a valid file.
6918 //
6919 // That ordering is only crash-safe if the appended objects are durable
6920 // before the root pointer is flipped; otherwise a power loss could
6921 // persist the flip ahead of the data it references, leaving the root
6922 // pointing at bytes that never reached disk. `flush` on a plain `File`
6923 // does not force a write-back, so sync the appended bytes to disk first
6924 // (the barrier), then flip the pointer, then sync the flip.
6925 let new_root = path_addr[&ObjectPathBuf::root()];
6926
6927 // The heap collections this commit's value overwrites superseded. They
6928 // join `to_free` rather than being handed to the free list here, which
6929 // is what keeps them out of reach of this commit's own allocations:
6930 // nothing draws on `to_free` until the commit has finished placing, so
6931 // the elements naming them are still readable from the prior root for
6932 // as long as that root is the live one.
6933 // Freed as metadata because that is the page type they were placed as
6934 // (`place_vl_collections`), which is the only claim about them this
6935 // session can make from its own record.
6936 to_free.extend(
6937 self.superseded_heaps
6938 .drain(..)
6939 .map(|(addr, len)| (addr, len, FreeClass::Page(PageType::Meta))),
6940 );
6941 // Re-run the whole-commit invariant over the set these just joined, since
6942 // the pass above ran before the in-place writes resolved and so never saw
6943 // them. This is defensive and nothing reaches it: no test fails when it
6944 // is deleted, and no sequence has been found that puts a superseded
6945 // collection out of bounds or across another freed span. It is kept
6946 // because a heap collection is freed on the strength of a *record*
6947 // rather than of a structure read back out of the file, which is the one
6948 // span where a stale answer would not be caught by anything else.
6949 retain_disjoint_in_bounds(&mut to_free, self.image.len());
6950
6951 // A persisting file keeps its freed space recorded on disk rather than
6952 // truncating it away, so its commit takes a different tail: one that
6953 // rewrites the on-disk managers rather than only repointing the superblock.
6954 if self.persist.is_some() {
6955 self.commit_persisting_releasing_reserve(new_root, to_free)?;
6956 return self.repoint_stored_references(&relocations);
6957 }
6958
6959 // The new tree is fully written, so the regions this commit vacated are
6960 // now dead: hand them to the session free list. If the resulting free
6961 // space forms a run reaching end-of-file, the file can be physically
6962 // truncated to where that run starts; otherwise the end-of-file is
6963 // unchanged. `take_trailing` removes the trimmed run so it is not also
6964 // counted as reusable interior space.
6965 // The class is not consulted: a non-persisting commit is only reached on a
6966 // flat file (a paged one is refused without persistence), which has no page
6967 // types to keep apart and so vacates nothing it cannot place.
6968 for (a, l, _) in to_free.drain(..) {
6969 self.free.free(a, l);
6970 }
6971 let cur_eof = self.image.len();
6972 let trunc_to = self.free.take_trailing(cur_eof);
6973 let new_eof = trunc_to.unwrap_or(cur_eof);
6974
6975 self.barrier()?;
6976 // The root address is stored relative to the base address; the end-of-file
6977 // address is absolute. After writing the relative root to disk, keep the
6978 // in-memory `root_group_address` absolute (the open-time convention).
6979 if self.superblock.version >= 2 {
6980 // Build the new superblock off a clone and adopt it only once the
6981 // write succeeds, so a failed write does not desync the in-memory
6982 // state. The v2/v3 superblock carries its own checksum.
6983 let mut new_sb = self.superblock.clone();
6984 new_sb.root_group_address = base.relative(new_root)?.get();
6985 new_sb.eof_address = new_eof;
6986 // Publish the flags this session *raised*, so a flag the source file
6987 // arrived carrying (left set by a crashed SWMR writer, say) is
6988 // scrubbed and this clean commit leaves the file properly closed for
6989 // the C library (issue #73). serialize() recomputes the v2/v3
6990 // checksum.
6991 //
6992 // Provably zero here: the only session that holds flags across a
6993 // commit is a page-buffered one, which requires a paged file, and a
6994 // paged file takes the tail below instead. Written as the field
6995 // rather than as `0` because "publish what this session holds" is the
6996 // rule, and a site that states it as a literal is one refactor away
6997 // from being wrong silently (issue #308).
6998 new_sb.consistency_flags = self.held_status_flags;
6999 let sb_bytes = new_sb.serialize();
7000 self.publish_attempted = true;
7001 self.write_at(self.sb_sig_off, &sb_bytes)?;
7002 self.barrier()?;
7003 new_sb.root_group_address = new_root;
7004 self.superblock = new_sb;
7005 } else {
7006 self.publish_attempted = true;
7007 self.repoint_v0v1_root(base.relative(new_root)?.get(), new_eof)?;
7008 self.barrier()?;
7009 self.superblock.root_group_address = new_root;
7010 self.superblock.eof_address = new_eof;
7011 }
7012
7013 // Physically shrink the file only after the superblock — now carrying the
7014 // smaller end-of-file — is durable. A crash between the two leaves a file
7015 // whose superblock end-of-file is correct and whose trailing bytes are
7016 // mere unreferenced slack, which the next open ignores; the reverse order
7017 // could advertise an end-of-file past the actual file length.
7018 if let Some(cut) = trunc_to {
7019 self.image.truncate(cut)?;
7020 self.barrier()?;
7021 }
7022 self.repoint_stored_references(&relocations)
7023 }
7024
7025 /// Repoint every object reference the file already stores at a header this
7026 /// commit moved (issue #324).
7027 ///
7028 /// Called at the end of each commit tail, *after* the superblock repoint,
7029 /// which is where it belongs on both counts. The commit is atomic at the
7030 /// repoint and this is a fixup derived from it, so it must not run earlier:
7031 /// a crash before the repoint has to leave the pre-commit file, its stored
7032 /// references included. And the tree it walks has to be the committed one —
7033 /// a rebuilt group's header is a fresh copy of the pre-commit one and
7034 /// carries the old address of anything it referenced, so patching the
7035 /// superseded header instead would correct bytes that are already dead.
7036 ///
7037 /// The writes are followed by a [`barrier`](Self::barrier), so a commit that
7038 /// returns under [`SyncPolicy::Always`] has put its references on disk and
7039 /// not merely its tree. That rests on `barrier`'s own contract rather than
7040 /// on a crash test — nothing here sweeps the window — and it costs nothing
7041 /// on the files that do not need it: a plan with nothing in it returns
7042 /// first, and on a file holding no object reference there is never anything
7043 /// in it.
7044 ///
7045 /// A crash between the repoint and that barrier leaves the commit standing
7046 /// with some references still stale, which is exactly the state every commit
7047 /// left before this existed.
7048 ///
7049 /// Bounded by [`MAX_LINK_GRAPH_NODES`], the same budget
7050 /// [`count_incoming_hard_links`](Self::count_incoming_hard_links) walks the
7051 /// link graph under.
7052 fn repoint_stored_references(&mut self, relocations: &BTreeMap<u64, u64>) -> Result<(), Error> {
7053 if self.proved_free_of_references {
7054 return Ok(());
7055 }
7056 let plan = crate::reference_patch::plan(
7057 &self.image(),
7058 &self.superblock,
7059 relocations,
7060 MAX_LINK_GRAPH_NODES,
7061 )?;
7062 // Record the walk's verdict before applying it, and only ever the
7063 // positive proof: a walk that found a reference, or could not read some
7064 // object, leaves the question open rather than answering "yes there are
7065 // references", because the next commit's answer could differ.
7066 if plan.proved_free_of_references() {
7067 self.proved_free_of_references = true;
7068 }
7069 if plan.is_empty() {
7070 return Ok(());
7071 }
7072 plan.apply(self)?;
7073 self.barrier()
7074 }
7075
7076 /// Commit tail for a file that persists its free space (issue #21). Unlike
7077 /// the non-persisting path, freed space is *retained* and recorded on disk —
7078 /// matching the reference library's persistent free-space strategy — so a
7079 /// later reopen (by this crate or the C library) recovers it.
7080 ///
7081 /// The post-commit free list (this commit's vacated regions plus the now-dead
7082 /// old free-space-manager and extension blocks) is serialized into a fresh
7083 /// `FSHD`/`FSSE` pair and a rewritten superblock-extension File Space Info
7084 /// message. The three are written as one contiguous tail, placed in space an
7085 /// *earlier* commit freed where it fits ([`flat_tail_layout`](Self::flat_tail_layout))
7086 /// and appended at end-of-file where it does not. Nothing live or
7087 /// still-referenced is overwritten either way, and the superblock — repointed
7088 /// last — is the linearization point, so a crash before it leaves the prior
7089 /// file (root, extension, and managers) wholly intact.
7090 ///
7091 /// The tail used to always append. Every commit then grew the file by one
7092 /// tail and freed its predecessor's, so a delete-and-recreate workload grew
7093 /// without bound while reporting all of that as reusable — the same defect
7094 /// [`commit_persisting_paged`](Self::commit_persisting_paged) had for its own
7095 /// tail (issue #286), on the strategy the paged one was measured against
7096 /// (issue #358).
7097 fn commit_persisting(
7098 &mut self,
7099 new_root: u64,
7100 to_free: Vec<(u64, u64, FreeClass)>,
7101 placement: TailPlacement,
7102 ) -> Result<(), Error> {
7103 // A paged file records its free space in per-page-type managers and keeps
7104 // its allocation page-aligned, so it takes its own tail (issue #198).
7105 if self.paged.is_some() {
7106 return self.commit_persisting_paged(new_root, to_free, placement);
7107 }
7108 let os = self.superblock.offset_size;
7109 let (strategy, threshold, page_size, old_blocks) = {
7110 // Copy what we need so no borrow of `self.persist` is held across the
7111 // `&mut self` writes below; the old state stays in place so a failure
7112 // leaves the session reusable.
7113 let ps = self
7114 .persist
7115 .as_ref()
7116 .expect("commit_persisting is only called when persistence is armed");
7117 (
7118 ps.strategy,
7119 ps.threshold,
7120 ps.page_size,
7121 ps.old_blocks.clone(),
7122 )
7123 };
7124 let base = self.superblock.base_address;
7125
7126 // The extension address is stored relative to the base address, and every
7127 // reader of the extension below indexes the image, so it is shifted once
7128 // here for all of them.
7129 let old_ext_addr = base.absolute(
7130 self.superblock
7131 .superblock_extension_address
7132 .map(StoredAddress::new)
7133 .filter(|a| !a.is_undefined(os))
7134 .ok_or(Error::EditUnsupported(
7135 "a persisting file has no superblock extension to update",
7136 ))?,
7137 )?;
7138
7139 // The persist File Space Info message is fixed-size, so the rewritten
7140 // extension's length is independent of the addresses it will carry: size
7141 // it with a placeholder to place the FSM blocks that follow it.
7142 let placeholder =
7143 FileSpaceInfo::persistent_single_manager(strategy, threshold, page_size, 0, 0);
7144 let ext_len =
7145 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7146 .len() as u64;
7147
7148 // Place the tail — the rewritten extension and the manager blocks — in a
7149 // hole an *earlier* commit freed where one fits, and at end-of-file where
7150 // none does. `self.free` holds only durable free space (this commit's own
7151 // frees stay in `to_free` until the repoint), so a reused span holds bytes
7152 // already unreachable from the on-disk root, which is the guarantee
7153 // [`reserve`](Self::reserve) rests on too.
7154 let (post, placed_at, tail_len, eoa) =
7155 self.flat_tail_layout(&to_free, &old_blocks, ext_len, os);
7156 if placed_at.is_none() && placement == TailPlacement::ReuseOnly {
7157 // The shrink pass appends nothing: growing the file by a tail is the
7158 // opposite of what it was called for. Nothing has been written and the
7159 // layout handed its reservation back, so the session is exactly as the
7160 // commit before it left it.
7161 return Ok(());
7162 }
7163 let reused = placed_at.is_some();
7164 let ext_addr = placed_at.unwrap_or_else(|| self.image.len());
7165 let sections = self.persisted_sections(&post);
7166 let fshd_addr = self.persisted_address(ext_addr + ext_len);
7167 let fsse_addr = self.persisted_address(ext_addr + ext_len + fshd_len(os));
7168 // A reused tail sits inside the file, which ends it at the end-of-allocation
7169 // the layout settled on — the current end-of-file, less any run of free
7170 // space reaching it, which `post` no longer records and the truncation
7171 // below gives back to the filesystem (issue #418). An appended tail ends
7172 // the file itself.
7173 let final_eof = if reused { eoa } else { ext_addr + tail_len };
7174 debug_assert!(
7175 ext_addr + tail_len <= final_eof,
7176 "the tail must end at or below the end-of-allocation it was placed under"
7177 );
7178
7179 // Build the real extension and the FSM blocks. With no free space to
7180 // record we still refresh the extension (persist on, managers undefined).
7181 let (ext_oh, fsm_blocks) = if sections.is_empty() {
7182 let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7183 let ext_oh =
7184 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7185 (ext_oh, None)
7186 } else {
7187 // `eoa_pre_fsm` is the end-of-allocation before the free-space-manager
7188 // section blocks (`FSHD`/`FSSE`) were allocated: a consumer may shrink
7189 // back to here and rebuild them. For an appended tail that is the FSHD,
7190 // not the extension — the extension sits below it and persists, so
7191 // shrinking leaves the superblock and its extension pointer valid (only
7192 // the manager blocks, which are rewritten every commit, are discarded).
7193 // A tail placed *inside* the file gave the allocation back nothing to
7194 // shrink, so it records the end-of-allocation itself. Either way it is
7195 // defined, which a persisting file's message must be or an
7196 // assertion-enabled libhdf5 aborts on open (issue #178), and is the
7197 // value `H5Fget_freespace` accounts for correctly (verified in the
7198 // crosscheck).
7199 let eoa_pre_fsm = if reused { final_eof } else { fshd_addr.get() };
7200 let info = FileSpaceInfo::persistent_single_manager(
7201 strategy,
7202 threshold,
7203 page_size,
7204 fshd_addr.get(),
7205 eoa_pre_fsm,
7206 );
7207 let ext_oh =
7208 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7209 let (fshd, fsse) =
7210 serialize_file_fsm(§ions, fshd_addr, fsse_addr, os, SECT_CLASS_SIMPLE);
7211 (ext_oh, Some((fshd, fsse)))
7212 };
7213 // Both forms of the message carry the same twelve manager slots, so either
7214 // one measures what the placeholder did. The tail was reserved for that
7215 // length, and the manager blocks are placed after it.
7216 debug_assert_eq!(
7217 ext_oh.len() as u64,
7218 ext_len,
7219 "extension length must be stable across the placeholder and real messages"
7220 );
7221
7222 // Write the extension, then the FSM blocks. Both forms are safe against a
7223 // crash here: an appended tail is past everything live, a reused one sits
7224 // in space an earlier commit freed, and neither is referenced until the
7225 // repoint below.
7226 let region = (ext_addr, tail_len);
7227 self.write_tail_block(region, ext_addr, &ext_oh)?;
7228 if let Some((fshd, fsse)) = fsm_blocks {
7229 self.write_tail_block(region, base.absolute(fshd_addr)?, &fshd)?;
7230 self.write_tail_block(region, base.absolute(fsse_addr)?, &fsse)?;
7231 }
7232 // Exactly the bytes written, contiguous from the extension, which is what
7233 // the next commit supersedes.
7234 let new_old_blocks = vec![(ext_addr, tail_len)];
7235
7236 // Barrier, then repoint the superblock (root, eof, and the new extension)
7237 // — the linearization point — and sync it.
7238 self.barrier()?;
7239 let mut new_sb = self.superblock.clone();
7240 new_sb.root_group_address = new_root;
7241 new_sb.eof_address = final_eof;
7242 new_sb.superblock_extension_address = Some(ext_addr);
7243 // As above, and zero for the same reason: this is the *unpaged* persisting
7244 // tail, which no page-buffered session reaches (issue #73, issue #308).
7245 new_sb.consistency_flags = self.held_status_flags;
7246 let sb_bytes = new_sb.serialize();
7247 self.publish_attempted = true;
7248 self.write_at(self.sb_sig_off, &sb_bytes)?;
7249 self.barrier()?;
7250 self.superblock = new_sb;
7251
7252 // Physically shrink the file only after the superblock — now carrying the
7253 // smaller end-of-file — is durable, exactly as the non-persisting tail
7254 // does: a crash between the two leaves trailing bytes the next open
7255 // ignores, where the reverse order would advertise an end-of-file past
7256 // the file's actual length. The managers just written describe `post`,
7257 // which no longer names anything above this cut.
7258 if final_eof < self.image.len() {
7259 self.image.truncate(final_eof)?;
7260 self.barrier()?;
7261 }
7262
7263 // The repoint is durable: the prior free list plus this commit's vacated
7264 // regions are now genuinely free, and the freshly written blocks become
7265 // the ones a future commit will supersede.
7266 self.free = post;
7267 self.persist = Some(PersistState {
7268 strategy,
7269 threshold,
7270 page_size,
7271 old_blocks: new_old_blocks,
7272 });
7273 // The managers describe the file at this length, so nothing is owed until
7274 // it grows past them again.
7275 self.fsm_len = self.image.len();
7276 Ok(())
7277 }
7278
7279 /// The free regions of `free` as the sections a free-space manager records.
7280 fn persisted_sections(&self, free: &FreeList) -> Vec<FreeSection> {
7281 free.sections()
7282 .into_iter()
7283 .map(|(addr, size)| FreeSection {
7284 addr: self.persisted_address(addr),
7285 size,
7286 })
7287 .collect()
7288 }
7289
7290 /// The address a persisting commit records for the image position `at`.
7291 ///
7292 /// The free lists hold positions in this session's image, and a manager block
7293 /// records the addresses the file stores. The two are the same number here,
7294 /// since [`load_persisted_free_space`](Self::load_persisted_free_space) arms
7295 /// persistence for a file whose image begins at byte zero alone, so this
7296 /// changes the frame and not the value.
7297 ///
7298 /// # Panics
7299 ///
7300 /// Panics if the file has a userblock, where the two frames differ and a
7301 /// manager block would record every free region a userblock's length below
7302 /// where it lies.
7303 fn persisted_address(&self, at: u64) -> StoredAddress {
7304 assert!(
7305 self.superblock.base_address.is_zero(),
7306 "a persisting commit requires a file whose image begins at byte zero"
7307 );
7308 StoredAddress::new(at)
7309 }
7310
7311 /// The free space a flat persisting commit is about to record: the session's
7312 /// durable list plus the regions this commit vacated and the superseded
7313 /// extension and manager blocks, which are dead once the superblock is
7314 /// repointed.
7315 ///
7316 /// Returned as a temporary rather than folded into the session, for the reason
7317 /// [`paged_post_free`](Self::paged_post_free) does the same: every region
7318 /// gathered here is still *live* until that repoint, and everything in between
7319 /// can fail, so a session that believed them free after a failed commit would
7320 /// hand them out over the objects still occupying them.
7321 ///
7322 /// Called once to *size* the tail and again for each round its placement takes
7323 /// to settle, since what remains free depends on the space the tail has drawn
7324 /// for itself ([`flat_tail_layout`](Self::flat_tail_layout)).
7325 fn flat_post_free(
7326 &self,
7327 to_free: &[(u64, u64, FreeClass)],
7328 old_blocks: &[(u64, u64)],
7329 ) -> FreeList {
7330 let mut post = self.free.clone();
7331 // A flat file keeps one list for the whole of it, so the class each freed
7332 // region carries for the paged path is not consulted here — and a flat
7333 // file produces no [`FreeClass::Dead`] region to begin with, since the
7334 // proof that classes one short-circuits where there are no page types to
7335 // keep apart.
7336 for &(a, l, _) in to_free {
7337 post.free(a, l);
7338 }
7339 for &(a, l) in old_blocks {
7340 post.free(a, l);
7341 }
7342 post
7343 }
7344
7345 /// Reserve free space for a flat persisting commit's tail, returning the free
7346 /// list the tail leaves behind, the address it got — `None` when nothing
7347 /// reusable fits, which is the caller's cue to append — its length, which the
7348 /// caller needs either way, and the end-of-allocation the commit should
7349 /// publish, which is the current end-of-file less any run of free space that
7350 /// reaches it (issue #418).
7351 ///
7352 /// The reservation and the length define each other, exactly as they do for the
7353 /// paged tail ([`tail_layout`](Self::tail_layout)): drawing bytes out of the
7354 /// free list changes the very sections the manager blocks record, and a
7355 /// different section set is a different length. This proposes a length, sizes
7356 /// the blocks against the list as it stands once that length is taken, and
7357 /// accepts any plan that *fits*. A longer plan is refused — it would write past
7358 /// the reservation into live bytes — and the proposal becomes the length that
7359 /// plan asked for, which is the natural next candidate.
7360 ///
7361 /// A plan **shorter** than its reservation is accepted, and the reservation
7362 /// rather than the plan is what the tail extent covers, so those spare bytes
7363 /// are freed with the rest of the tail when the next commit supersedes it.
7364 /// Rejecting them instead is what the paged tail does, and it can afford to:
7365 /// its fallback is a fresh page, recorded in full. Here the fallback is
7366 /// end-of-file, and the two lengths genuinely need not meet — best fit picks a
7367 /// different hole as the proposal crosses a hole's size, and the pair can
7368 /// oscillate by one section record indefinitely. Measured on the workload
7369 /// `persisting_churn_reaches_a_steady_size` runs, insisting on an exact fit sent
7370 /// about one commit in eight to end-of-file, which is the growth this exists to
7371 /// stop.
7372 ///
7373 /// What that costs is bounded and small: a commit that takes spare bytes and is
7374 /// then the last of its session leaves them behind, since a reopened session
7375 /// reconstructs the tail extent from the blocks themselves
7376 /// ([`load_persisted_free_space`](Self::load_persisted_free_space)) and cannot
7377 /// see a reservation that outlived the process. The spare is a section record
7378 /// and, where the reservation emptied a size group, that group's own header
7379 /// too — tens of bytes, once per session, against a whole tail on one commit in
7380 /// eight.
7381 ///
7382 /// Iteration is capped rather than trusted to converge: the length is a step
7383 /// function of the section set, so a proposal can in principle keep growing.
7384 /// Appending is always available and always correct, so giving up costs space
7385 /// rather than a guarantee — and the cap bounds the work, since each round
7386 /// clones the free list and sizes the section list for real.
7387 ///
7388 /// Draws from `self.free` before anything is written, which
7389 /// [`commit`](Self::commit) puts back if the attempt then fails. The one caller
7390 /// that does not snapshot is [`finalize_persist`](Self::finalize_persist), whose
7391 /// failure leaves this session's list short by the reservation — in memory only,
7392 /// on a session already being torn down, with the on-disk managers unchanged.
7393 ///
7394 /// When `self.free` has no hole for it, the tail may take one out of
7395 /// [`reserved`](Self::reserved) instead. Only the rewrite that publishes an
7396 /// append's draw ([`reserve_for_immediate_append`](Self::reserve_for_immediate_append))
7397 /// reaches this with anything there — every other caller releases the reserve
7398 /// first — and for that rewrite the fallback is what keeps the draw from
7399 /// growing the file: a draw takes every hole the append fits in, and on a
7400 /// flat file that is the one list the tail is placed from, so without it the
7401 /// rewrite appended its tail at end-of-file every time and the file grew by a
7402 /// tail per draw (issue #413). Space in the reserve is exactly what this
7403 /// rewrite publishes as *not* free, so a tail placed there is live in bytes
7404 /// the managers do not advertise, which is the same standing a tail placed
7405 /// from `self.free` has once the repoint lands. `self.free` first, so that an
7406 /// ordinary hole is spent before the append's own space is; best fit within
7407 /// the reserve then takes its smallest run, leaving the largest for the
7408 /// append that drew it. The paged tail ([`tail_layout`](Self::tail_layout))
7409 /// has no such fallback: it opens a page when nothing fits, and
7410 /// `paged_group_churn_with_populated_datasets_reaches_a_steady_size` holds it
7411 /// to not doing so once per draw.
7412 fn flat_tail_layout(
7413 &mut self,
7414 to_free: &[(u64, u64, FreeClass)],
7415 old_blocks: &[(u64, u64)],
7416 ext_len: u64,
7417 os: u8,
7418 ) -> (FreeList, Option<u64>, u64, u64) {
7419 /// Enough rounds for the section set to settle after a reservation shrinks
7420 /// it, without letting a proposal that keeps growing spin.
7421 const ROUNDS: usize = 4;
7422
7423 let eof = self.image.len();
7424 // The first proposal: what the tail would measure with nothing reserved.
7425 // A manager block's length depends only on the sections it records, never
7426 // on where it sits. It is also the answer for a tail that ends up appended,
7427 // since every round hands its reservation back before this returns.
7428 let probe = self.flat_post_free(to_free, old_blocks);
7429 let appended_len = ext_len + file_fsm_blocks_len(&self.persisted_sections(&probe), os);
7430 let mut proposed = appended_len;
7431
7432 for _ in 0..ROUNDS {
7433 let (at, from_reserve) = match self.free.alloc(proposed) {
7434 Some(at) => (at, false),
7435 None => match self.reserved.alloc(proposed) {
7436 Some(at) => (at, true),
7437 None => break,
7438 },
7439 };
7440 let mut post = self.flat_post_free(to_free, old_blocks);
7441 // Free space that reaches end-of-file is released rather than
7442 // recorded: the file is truncated to where the run starts, so the
7443 // sections these blocks are sized from must already leave it out
7444 // (issue #418). The reservation was taken before this list was built,
7445 // so the run can only begin at or above the tail's own end.
7446 let eoa = release_trailing_run(&mut post, eof, proposed);
7447 let len = ext_len + file_fsm_blocks_len(&self.persisted_sections(&post), os);
7448 if len <= proposed {
7449 debug_assert!(
7450 at + proposed <= eoa,
7451 "a reused tail at {at} of {proposed} bytes runs past the end-of-allocation \
7452 {eoa} it was placed under"
7453 );
7454 return (post, Some(at), proposed, eoa);
7455 }
7456 if from_reserve {
7457 self.reserved.free(at, proposed);
7458 } else {
7459 self.free.free(at, proposed);
7460 }
7461 proposed = len;
7462 }
7463 // Nothing reusable fits, so the tail is appended past end-of-file and no
7464 // trailing run is released: whatever free space reaches end-of-file was
7465 // too small for the tail, and stays recorded below it.
7466 (probe, None, appended_len, eof)
7467 }
7468
7469 /// Commit tail for a genuine paged file (`H5F_FSPACE_STRATEGY_PAGE`, issue
7470 /// #198). The paged counterpart of [`commit_persisting`](Self::commit_persisting).
7471 ///
7472 /// Two things differ from the flat tail. Free space is recorded in *per-page-type*
7473 /// managers — SUPER (slot 0) for metadata, DRAW (slot 2) for small raw, and the
7474 /// generic-large manager (slot 6) for whole free pages and large-raw fragments —
7475 /// rather than one generic manager, so a paged file reopened by the reference
7476 /// library still finds its free space segregated. And the file is padded to a
7477 /// page before the tail is laid down, so the end-of-allocation stays a whole
7478 /// number of pages, matching the paged file the from-scratch writer produces.
7479 ///
7480 /// The tail itself is placed like any other run of metadata, in free space
7481 /// where some fits ([`tail_layout`](Self::tail_layout)), and only opens a page
7482 /// at end-of-file when none does. It used to always append into a page of its
7483 /// own and pad the remainder out untracked, which cost a page per commit and
7484 /// recorded none of it — a paged file under delete-and-recreate churn grew
7485 /// without bound while reporting a fraction of that as reusable (issue #286).
7486 /// The reference library does not page-align these blocks either: its manager
7487 /// headers sit at whatever offset within a metadata page they are allocated at.
7488 ///
7489 /// Crash atomicity is identical to the flat path: the tail is either past the
7490 /// live file or inside space an earlier commit freed, and is unreferenced
7491 /// either way until the superblock repoint, which is the linearization point.
7492 fn commit_persisting_paged(
7493 &mut self,
7494 new_root: u64,
7495 to_free: Vec<(u64, u64, FreeClass)>,
7496 placement: TailPlacement,
7497 ) -> Result<(), Error> {
7498 let os = self.superblock.offset_size;
7499 let (strategy, threshold, page_size, old_blocks) = {
7500 let ps = self
7501 .persist
7502 .as_ref()
7503 .expect("commit_persisting is only called when persistence is armed");
7504 (
7505 ps.strategy,
7506 ps.threshold,
7507 ps.page_size,
7508 ps.old_blocks.clone(),
7509 )
7510 };
7511
7512 let base = self.superblock.base_address;
7513
7514 // Page-align the file before anything else, so the rewritten extension and
7515 // the manager blocks begin on a page boundary and stay in metadata pages.
7516 // The padded tail becomes free space of whatever type that page held.
7517 self.pad_to_page()?;
7518
7519 // As on the flat path: the extension address is stored relative to the base
7520 // address, and every reader of the extension below indexes the image.
7521 let old_ext_addr = base.absolute(
7522 self.superblock
7523 .superblock_extension_address
7524 .map(StoredAddress::new)
7525 .filter(|a| !a.is_undefined(os))
7526 .ok_or(Error::EditUnsupported(
7527 "a persisting file has no superblock extension to update",
7528 ))?,
7529 )?;
7530
7531 // The 12-slot persist message is fixed-size, so a placeholder sizes the
7532 // rewritten extension before its manager addresses are known — and before
7533 // its address is, which is what lets the tail be sized before it is placed.
7534 let placeholder = FileSpaceInfo::persistent_managers(
7535 strategy,
7536 threshold,
7537 page_size,
7538 [UNDEF; NUM_FILE_FSM_MANAGERS],
7539 0,
7540 );
7541 let ext_len =
7542 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7543 .len() as u64;
7544
7545 // Place the tail — the rewritten extension and the manager blocks — as an
7546 // ordinary run of metadata, in a hole an *earlier* commit freed where one
7547 // fits. That is what stops a file under delete-and-recreate churn from
7548 // growing: every commit frees its predecessor's tail exactly, so in the
7549 // steady state a tail lands in a hole an earlier one vacated — the one two
7550 // commits back, since a tail is freed into `pg`'s lists only at the repoint
7551 // that follows its successor — and the file never has to open a page for it
7552 // (issue #286). `pg`'s lists hold only
7553 // durable free space — this commit's own frees stay in `to_free` until the
7554 // repoint — so the bytes overwritten are already unreachable from the
7555 // on-disk root, the guarantee [`reserve`](Self::reserve) relies on.
7556 //
7557 // The tail is sized from the very lists it draws on, so its length and its
7558 // placement define each other: reserving space removes sections, and fewer
7559 // sections need fewer bytes to record. `tail_layout` settles that by
7560 // proposing a length, planning against it, and accepting the proposal only
7561 // when the plan comes out exactly that long — no shorter, which would leave
7562 // untracked bytes inside the reservation, and no longer, which would run
7563 // past it into whatever lives next. A few rounds settle it; a proposal that
7564 // will not converge falls through to the append below, which has no length
7565 // to satisfy.
7566 let placed = self.tail_layout(&to_free, &old_blocks, ext_len, page_size, os);
7567 if placed.is_none() && placement == TailPlacement::ReuseOnly {
7568 // As on the flat path: the shrink pass opens no page of its own.
7569 return Ok(());
7570 }
7571 let reused = placed.is_some();
7572 let (post, plan, ext_addr, blocks_len, eoa) = match placed {
7573 Some(layout) => layout,
7574 None => {
7575 // Nothing fits: open a metadata page at end-of-file. `pad_to_page`
7576 // above already left the file page-aligned, so this only records the
7577 // tail page's type. No trailing run is released here — whatever
7578 // free space reaches end-of-file was too small for the tail, and
7579 // stays recorded below it.
7580 self.begin_page(PageType::Meta)?;
7581 let at = self.image.len();
7582 let post = self.paged_post_free(&to_free, &old_blocks);
7583 let plan = plan_paged_managers(
7584 &self.persisted_sections(&post.meta),
7585 &self.persisted_sections(&post.raw),
7586 &self.persisted_sections(&post.unclassified),
7587 page_size,
7588 self.persisted_address(at + ext_len),
7589 os,
7590 );
7591 let blocks_len = plan.end_of_managers.get().max(at + ext_len) - at;
7592 (post, plan, at, blocks_len, at)
7593 }
7594 };
7595 let final_eof = if reused {
7596 // The tail landed inside the file; the end-of-allocation is where this
7597 // commit's appends left it — page-aligned by `pad_to_page`, less the
7598 // whole pages of free space reaching it, which `post` no longer
7599 // records and the truncation below gives back (issue #418).
7600 eoa
7601 } else {
7602 align_up(ext_addr + blocks_len, page_size)
7603 };
7604 debug_assert!(
7605 ext_addr + blocks_len <= final_eof,
7606 "the tail must end at or below the end-of-allocation it was placed under"
7607 );
7608
7609 let ext_oh = if plan.is_empty() {
7610 // No free space to track: an empty persist message, page-aligned.
7611 let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7612 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7613 } else {
7614 // Paged convention (matching the from-scratch writer): the managers are
7615 // ordinary metadata below a page-aligned end-of-allocation.
7616 let info = FileSpaceInfo::persistent_managers(
7617 strategy,
7618 threshold,
7619 page_size,
7620 plan.slots.map(StoredAddress::get),
7621 final_eof,
7622 );
7623 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7624 };
7625 debug_assert_eq!(
7626 ext_oh.len() as u64,
7627 ext_len,
7628 "extension length must be stable across the placeholder and real messages"
7629 );
7630
7631 // Write the extension, then every manager block. Both forms are safe against
7632 // a crash here: an appended tail is past everything live, and a reused one
7633 // sits in space an earlier commit freed. Neither is referenced until the
7634 // repoint below.
7635 let region = (ext_addr, blocks_len);
7636 self.write_tail_block(region, ext_addr, &ext_oh)?;
7637 for b in &plan.blocks {
7638 let (fshd, fsse) =
7639 serialize_file_fsm(&b.sections, b.fshd_addr, b.fsse_addr, os, b.class);
7640 self.write_tail_block(region, base.absolute(b.fshd_addr)?, &fshd)?;
7641 self.write_tail_block(region, base.absolute(b.fsse_addr)?, &fsse)?;
7642 }
7643 // An appended tail ends mid-page; pad it out, so the end-of-allocation stays
7644 // a whole number of pages. A reused tail is already inside the file, and
7645 // matched its reservation exactly, so there is nothing to pad — and where
7646 // it released a trailing run its end-of-allocation is *below* the current
7647 // end-of-file, which the truncation after the repoint gives back.
7648 if !reused {
7649 self.pad_zeros_to(final_eof)?;
7650 }
7651 // Exactly the bytes written, contiguous from the extension. A session that
7652 // reopens this file records the same extents from the message and the
7653 // manager headers, so nothing depends on remembering this across a close.
7654 let new_old_blocks = vec![(ext_addr, blocks_len)];
7655
7656 // Barrier, then repoint the superblock (root, eof, and the new extension)
7657 // — the linearization point — and sync it.
7658 self.barrier()?;
7659 let mut new_sb = self.superblock.clone();
7660 new_sb.root_group_address = new_root;
7661 new_sb.eof_address = final_eof;
7662 new_sb.superblock_extension_address = Some(ext_addr);
7663 // The one publish site a page-buffered session actually reaches, and so
7664 // the one where this must not be a literal zero: a crash mark has to
7665 // outlive every commit the session makes (issue #308).
7666 new_sb.consistency_flags = self.held_status_flags;
7667 let sb_bytes = new_sb.serialize();
7668 self.publish_attempted = true;
7669 self.write_at(self.sb_sig_off, &sb_bytes)?;
7670 self.barrier()?;
7671 self.superblock = new_sb;
7672
7673 // Physically shrink the file only after the superblock — now carrying the
7674 // smaller end-of-file — is durable, for the reason the flat tail gives:
7675 // a crash between the two leaves trailing bytes the next open ignores,
7676 // where the reverse order would advertise an end-of-file past the file's
7677 // actual length.
7678 if final_eof < self.image.len() {
7679 self.image.truncate(final_eof)?;
7680 self.barrier()?;
7681 }
7682
7683 // The repoint is durable. Only now are this commit's vacated regions
7684 // genuinely free, so adopt the lists built above and drop the padding tails
7685 // they already account for. The blocks just written become the ones the next
7686 // commit supersedes.
7687 if let Some(pg) = self.paged.as_mut() {
7688 pg.meta = post.meta;
7689 pg.raw = post.raw;
7690 pg.dead = post.dead;
7691 // This list is otherwise constant for the session, but a released
7692 // trailing run may have cut into it, and a section naming bytes the
7693 // file no longer has would be written back to the managers by the
7694 // next commit.
7695 pg.unclassified = post.unclassified;
7696 pg.meta_pad.clear();
7697 pg.raw_pad.clear();
7698 if !reused {
7699 // The tail page is fresh metadata: the managers sit in it, so a
7700 // following append of metadata may keep packing that page, and the
7701 // rest of it is free metadata this session can spend. The managers
7702 // just written cannot say so — they would have to describe space
7703 // whose size their own length decides — so the next commit records
7704 // it, from here. A reused tail leaves end-of-file where the data put
7705 // it, so the outgoing page type stands and there is no padding.
7706 pg.last = Some(PageType::Meta);
7707 pg.meta
7708 .free(ext_addr + blocks_len, final_eof - (ext_addr + blocks_len));
7709 }
7710 }
7711 self.persist = Some(PersistState {
7712 strategy,
7713 threshold,
7714 page_size,
7715 old_blocks: new_old_blocks,
7716 });
7717 // The managers describe the file at this length, so nothing is owed until
7718 // it grows past them again.
7719 self.fsm_len = self.image.len();
7720 Ok(())
7721 }
7722
7723 /// Pad a paged file to a page boundary if its tail page is partially filled,
7724 /// recording the padding as free space of the tail page's type. A no-op on a
7725 /// non-paged file or an already-aligned one.
7726 fn pad_to_page(&mut self) -> Result<(), Error> {
7727 let len = self.image.len();
7728 let pad = match &self.paged {
7729 Some(pg) if len % pg.page_size != 0 => {
7730 Some((pg.last, pg.page_size - len % pg.page_size))
7731 }
7732 _ => None,
7733 };
7734 if let Some((last, pad_len)) = pad {
7735 let pad_at = len;
7736 self.append(&vec![0u8; pad_len.to_usize()?])?;
7737 if let Some(pg) = self.paged.as_mut() {
7738 match last {
7739 // A partially-filled tail page at commit time is a raw page:
7740 // the last thing the apply loop writes for a dataset is its
7741 // header, but a commit that only wrote raw data ends on one.
7742 Some(PageType::Meta) => pg.meta_pad.push((pad_at, pad_len)),
7743 Some(PageType::Raw) => pg.raw_pad.push((pad_at, pad_len)),
7744 // No typed append this commit, so the tail page is one a
7745 // previous session left non-aligned — a crash, since a clean
7746 // close pads. Its type is unknown, and recording the padding
7747 // under a guess would advertise it for reuse of that type and
7748 // mix the page, so leave it untracked (see `PagedEdit::begin`,
7749 // which makes the same call for the same reason). Reuse made
7750 // this reachable: before it, every commit appended at least the
7751 // root group header, so `last` was always known here.
7752 None => {}
7753 }
7754 }
7755 }
7756 Ok(())
7757 }
7758
7759 /// The free lists a paged commit is about to persist: the session's durable
7760 /// lists plus the page-padding tails this commit's appends left behind, the
7761 /// regions it vacated, and the superseded extension and manager blocks (all
7762 /// metadata, dead once the superblock is repointed).
7763 ///
7764 /// Every page the result leaves wholly empty is promoted to a free page before
7765 /// it is returned ([`PagedEdit::promote_whole_free_pages`]), so the managers
7766 /// this commit writes already describe it. Deferring the promotion to the
7767 /// repoint would leave the session holding free space its own on-disk record
7768 /// does not name.
7769 ///
7770 /// Returned as temporaries rather than folded into the session, exactly as the
7771 /// flat path builds `post`: every region gathered here is still *live* until
7772 /// that repoint, so the session's own lists must not learn about it until the
7773 /// repoint succeeds. Everything in between can fail (the extension rewrite,
7774 /// each write, each barrier), and a session that survived a failed commit
7775 /// while believing live extents were free would hand them out on the next
7776 /// commit — silently destroying the objects still occupying them.
7777 ///
7778 /// Called once to *size* the tail and again for each round the tail's
7779 /// placement takes to settle, since what remains depends on the space the tail
7780 /// has drawn for itself ([`tail_layout`](Self::tail_layout)). Each call clones
7781 /// both lists; they hold one region per hole, not per byte, so the copies are
7782 /// small.
7783 fn paged_post_free(
7784 &self,
7785 to_free: &[(u64, u64, FreeClass)],
7786 old_blocks: &[(u64, u64)],
7787 ) -> PagedPostFree {
7788 let pg = self
7789 .paged
7790 .as_ref()
7791 .expect("commit_persisting_paged is only called on a paged file");
7792 let (mut meta, mut raw, mut dead) = (pg.meta.clone(), pg.raw.clone(), pg.dead.clone());
7793 // This engine never frees into that list, and nothing it places comes out
7794 // of it.
7795 let unclassified = pg.unclassified.clone();
7796 let mut free = |a: u64, l: u64, class: FreeClass| {
7797 PagedEdit::route_free(&mut meta, &mut raw, &mut dead, a, l, class);
7798 };
7799 for &(a, l) in &pg.meta_pad {
7800 free(a, l, PageType::Meta.into());
7801 }
7802 for &(a, l) in &pg.raw_pad {
7803 free(a, l, PageType::Raw.into());
7804 }
7805 for &(a, l, class) in to_free {
7806 free(a, l, class);
7807 }
7808 // The superseded extension and manager blocks, which are metadata wherever
7809 // they sat: the tail is placed as metadata, and a page it had to open for
7810 // itself was opened as metadata too.
7811 for &(a, l) in old_blocks {
7812 free(a, l, PageType::Meta.into());
7813 }
7814 PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, pg.page_size);
7815 PagedPostFree {
7816 meta,
7817 raw,
7818 dead,
7819 unclassified,
7820 }
7821 }
7822
7823 /// Reserve free metadata space for a paged commit's tail and plan the manager
7824 /// blocks for the address it got, or `None` when nothing reusable fits — the
7825 /// caller then appends instead.
7826 ///
7827 /// The reservation and the plan are mutually dependent: drawing `len` bytes out
7828 /// of the free lists changes the sections those very blocks record, and a
7829 /// different section set is a different length. This proposes a length, plans
7830 /// against the lists as they stand once that length is taken, and accepts only
7831 /// an exact agreement. Anything else hands the reservation back and proposes
7832 /// the length the plan just came out at, which is the natural next candidate.
7833 ///
7834 /// Both kinds of disagreement have to be rejected, not just the obvious one. A
7835 /// plan **longer** than its reservation would write past it into whatever lives
7836 /// after. A plan **shorter** would leave bytes inside the reservation that no
7837 /// manager records and the next commit does not free — the leak this whole
7838 /// change exists to remove, reintroduced a few bytes at a time.
7839 ///
7840 /// Iteration is capped rather than trusted to converge: the length is a
7841 /// step function of the section set, so a proposal can in principle oscillate
7842 /// between two values that each imply the other. Appending is always available
7843 /// and always correct, so giving up costs a page rather than a guarantee.
7844 fn tail_layout(
7845 &mut self,
7846 to_free: &[(u64, u64, FreeClass)],
7847 old_blocks: &[(u64, u64)],
7848 ext_len: u64,
7849 page_size: u64,
7850 os: u8,
7851 ) -> Option<(PagedPostFree, PagedManagerPlan, u64, u64, u64)> {
7852 /// Enough rounds for the section set to settle after a reservation shrinks
7853 /// it, without letting an oscillating proposal spin.
7854 const ROUNDS: usize = 4;
7855
7856 let eof = self.image.len();
7857
7858 // The first proposal: what the blocks would measure with nothing reserved.
7859 // A manager block's length depends only on the sections it records, never
7860 // on its address, so a start of 0 measures the blocks alone.
7861 let probe = self.paged_post_free(to_free, old_blocks);
7862 let mut proposed = ext_len
7863 + plan_paged_managers(
7864 &self.persisted_sections(&probe.meta),
7865 &self.persisted_sections(&probe.raw),
7866 &self.persisted_sections(&probe.unclassified),
7867 page_size,
7868 StoredAddress::new(0),
7869 os,
7870 )
7871 .end_of_managers
7872 .get();
7873
7874 for _ in 0..ROUNDS {
7875 let pg = self
7876 .paged
7877 .as_mut()
7878 .expect("commit_persisting_paged is only called on a paged file");
7879 let at = pg.alloc_typed(proposed, PageType::Meta)?;
7880 let mut post = self.paged_post_free(to_free, old_blocks);
7881 // Whole free pages at the end of the file are released rather than
7882 // recorded, so the sections these blocks are sized from must already
7883 // leave them out (issue #418). The reservation was taken before this
7884 // list was built, so the run can only begin at or above the tail's own
7885 // end.
7886 let eoa = post.release_trailing(eof, page_size, proposed);
7887 // Class the free space into its managers and place their blocks after
7888 // the extension. Shared with the bounded backend so both lay out
7889 // identically.
7890 let plan = plan_paged_managers(
7891 &self.persisted_sections(&post.meta),
7892 &self.persisted_sections(&post.raw),
7893 &self.persisted_sections(&post.unclassified),
7894 page_size,
7895 self.persisted_address(at + ext_len),
7896 os,
7897 );
7898 // An empty plan writes no blocks at all, leaving the tail the extension
7899 // alone; `end_of_managers` is then its own start.
7900 let blocks_len = plan.end_of_managers.get().max(at + ext_len) - at;
7901 if blocks_len == proposed {
7902 debug_assert!(
7903 at + blocks_len <= eoa,
7904 "a reused tail at {at} of {blocks_len} bytes runs past the end-of-allocation \
7905 {eoa} it was placed under"
7906 );
7907 return Some((post, plan, at, blocks_len, eoa));
7908 }
7909 let pg = self
7910 .paged
7911 .as_mut()
7912 .expect("the paged state outlives this loop");
7913 PagedEdit::route_free(
7914 &mut pg.meta,
7915 &mut pg.raw,
7916 &mut pg.dead,
7917 at,
7918 proposed,
7919 PageType::Meta.into(),
7920 );
7921 proposed = blocks_len;
7922 }
7923 None
7924 }
7925
7926 /// Write one block of a persisting commit's tail — paged or flat — at the
7927 /// address its layout gave it, which is either the current end-of-file (the
7928 /// tail is being appended) or inside `region`, the span reserved from the free
7929 /// lists, when the tail is being reused into space an earlier commit left.
7930 ///
7931 /// The region is checked rather than asserted, for the reason
7932 /// [`place`](Self::place) checks its own reservation: a reused span is followed
7933 /// by live bytes, so a block running past it would silently destroy a
7934 /// neighboring object, and this is the one write in a commit that lands in the
7935 /// middle of a live file. The region's length is derived from the same layout
7936 /// that placed these blocks, so the two cannot disagree today; the comparison
7937 /// is what keeps that true if a layout ever learns a length this sizing does
7938 /// not model.
7939 fn write_tail_block(
7940 &mut self,
7941 region: (u64, u64),
7942 addr: u64,
7943 bytes: &[u8],
7944 ) -> Result<(), Error> {
7945 let (start, len) = region;
7946 if addr < start || addr + bytes.len() as u64 > start + len {
7947 return Err(Error::Format(FormatError::SerializationError(format!(
7948 "a commit's tail reserved [{start}, {}) but placed {} bytes at {addr}",
7949 start + len,
7950 bytes.len()
7951 ))));
7952 }
7953 if addr == self.image.len() {
7954 let written = self.append(bytes)?;
7955 debug_assert_eq!(written, addr, "an appended block must land at end-of-file");
7956 return Ok(());
7957 }
7958 self.write_at(addr, bytes)
7959 }
7960
7961 /// Extend the file with zeros up to `target` (>= the current length), used by
7962 /// the paged tail to pad the final metadata page to its boundary.
7963 fn pad_zeros_to(&mut self, target: u64) -> Result<(), Error> {
7964 let len = self.image.len();
7965 if target > len {
7966 let pad = (target - len).to_usize()?;
7967 self.append(&vec![0u8; pad])?;
7968 }
7969 debug_assert_eq!(self.image.len(), target);
7970 Ok(())
7971 }
7972
7973 /// Rebuilds the superblock-extension object header's message region with its
7974 /// File Space Info message replaced by `info` (every other message preserved
7975 /// verbatim), ready to wrap with [`build_v2_object_header`]. The persisting
7976 /// message is fixed-size, so this never changes the region's length.
7977 ///
7978 /// `ext_addr` is the extension header's absolute file offset, which is where
7979 /// the messages are read from, and not the address the superblock stores.
7980 fn rewrite_extension_region(
7981 &self,
7982 ext_addr: u64,
7983 info: &FileSpaceInfo,
7984 ) -> Result<OhRegion, Error> {
7985 let region =
7986 Self::gather_oh_messages(&self.image(), ext_addr, self.superblock.base_address)?;
7987 rewrite_extension_region_bytes(®ion, info)
7988 }
7989
7990 /// Repoint a version 0/1 superblock at the rebuilt (now v2) root group and
7991 /// update its end-of-file field, patching the raw bytes in place — these
7992 /// superblocks carry no checksum. The root symbol-table entry is switched to
7993 /// cache type 0 (its scratch-pad B-tree / local-heap addresses, which
7994 /// describe the old symbol-table group, no longer apply). The
7995 /// object-header-address write is done last so it is the linearization point.
7996 fn repoint_v0v1_root(&mut self, new_root: u64, new_eof: u64) -> Result<(), Error> {
7997 let os = self.superblock.offset_size;
7998 let addr_width = u64::from(os);
7999 // Field layout after the fixed prefix: base / free-space / EOF / driver
8000 // addresses, then the root symbol-table entry (link-name offset, object
8001 // header address, cache type(4), reserved(4), scratch(16)). The prefix is
8002 // 24 bytes for v0 and 28 for v1 (the latter adds indexed-storage-K).
8003 let var_start = if self.superblock.version == 0 { 24 } else { 28 };
8004 let base = self.sb_sig_off + var_start;
8005 let eof_off = base + 2 * addr_width;
8006 let ste = base + 4 * addr_width;
8007 let oh_addr_off = ste + addr_width;
8008 let cache_off = ste + 2 * addr_width;
8009 self.write_at(eof_off, &new_eof.to_le_bytes()[..usize::from(os)])?;
8010 self.write_at(cache_off, &[0u8; 4])?; // cache type = none
8011 self.write_at(cache_off + 8, &[0u8; 16])?; // clear scratch-pad
8012 self.write_at(oh_addr_off, &new_root.to_le_bytes()[..usize::from(os)])?;
8013 Ok(())
8014 }
8015
8016 /// Collect every message of the object header at `addr` into one contiguous
8017 /// region, following continuation blocks across chunks and dropping the
8018 /// `Continuation` messages themselves. Re-emitting the result through
8019 /// [`build_v2_object_header`] collapses a multi-chunk header (as the
8020 /// reference C library often writes) into a single chunk, which is how this
8021 /// editor rebuilds headers. The chunk-0 prefix is validated by
8022 /// [`oh_region_at`]; each continuation block must be a well-formed `OCHK`
8023 /// block within the file.
8024 ///
8025 /// Reads each header chunk out of `src` as one bounded buffer rather than
8026 /// indexing a whole-file image, so this serves a session whose file is not
8027 /// mirrored in memory (issue #198).
8028 fn gather_oh_messages<S: Source + ?Sized>(
8029 src: &S,
8030 addr: u64,
8031 base: BaseAddress,
8032 ) -> Result<OhRegion, Error> {
8033 let chunks = read_oh_chunks(src, addr, base)?;
8034 // Every chunk of one header shares what chunk 0's prefix declared, which
8035 // is what makes concatenating their records into one region well defined,
8036 // and what carries the prefix's optional blocks through to the rebuild.
8037 let mut out = OhRegion::empty(chunks[0].props());
8038 for chunk in &chunks {
8039 let layout = chunk.layout();
8040 let (region, mut p) = chunk.message_region();
8041 while let Some((msg_type, _body, body_end)) = layout.next_message(region, p)? {
8042 if msg_type != MessageType::OBJECT_HEADER_CONTINUATION {
8043 out.push_bytes(®ion[p..body_end]);
8044 }
8045 p = body_end;
8046 }
8047 }
8048 Ok(out)
8049 }
8050
8051 /// Reconstructs a version-1 (symbol-table) group as a fresh v2 compact-link
8052 /// message region: a LinkInfo message, one Link message per existing child,
8053 /// and the group's existing attributes (re-wrapped as v2 messages). The
8054 /// symbol-table message and other non-link/non-attribute messages
8055 /// (modification time, comment, …) are dropped — editing a v0/v1 group
8056 /// converts it to the latest format. Refuses an attribute it cannot
8057 /// reproduce (shared, or larger than a v2 message can hold).
8058 fn reconstruct_v1_group(&self, addr: u64) -> Result<GroupInfo, Error> {
8059 let os = self.superblock.offset_size;
8060 let ls = self.superblock.length_size;
8061 let base = self.superblock.base_address;
8062 let oh = ObjectHeader::parse_from_source(
8063 &self.image(),
8064 AccessMode::ReadWrite,
8065 addr,
8066 os,
8067 ls,
8068 base,
8069 )?;
8070 if oh
8071 .messages
8072 .iter()
8073 .any(|m| m.msg_type == MessageType::DATA_LAYOUT)
8074 {
8075 return Err(Error::EditUnsupported(
8076 "a target path names a dataset, not a group",
8077 ));
8078 }
8079 let entries = resolve_group_entries_from_source(&self.image(), &oh, os, ls, base)?;
8080
8081 let mut region = fresh_group_region();
8082 let mut link_names = Vec::with_capacity(entries.len());
8083 for e in &entries {
8084 // A version 1 group has no link creation order, which the Link Info
8085 // message introduced, so these links get no creation index and the
8086 // Link Info message `fresh_group_region` writes tracks none.
8087 region.push_link(&e.name, e.object_header_address, None);
8088 link_names.push(e.name.clone());
8089 }
8090 for m in &oh.messages {
8091 if m.msg_type == MessageType::ATTRIBUTE {
8092 if m.data.len() > OBJECT_HEADER_MESSAGE_MAX {
8093 return Err(Error::EditUnsupported(
8094 "a v0/v1 group attribute is too large to convert in place",
8095 ));
8096 }
8097 if !m.flags.difference(MessageFlags::SHARED).is_empty() {
8098 // Every other flag bit says something about the message this
8099 // rewrite would have to reproduce and does not — that it is
8100 // constant, that it must not be shared, what a reader should
8101 // do if it cannot decode it. The shared bit is the one this
8102 // conversion knows how to carry.
8103 return Err(Error::EditUnsupported(
8104 "a v0/v1 group attribute message carries an object-header flag this \
8105 conversion cannot reproduce",
8106 ));
8107 }
8108 if m.flags.is_shared() {
8109 // The body is a reference, not an attribute; the reference is
8110 // what gets rewrapped, in the encoding a version 2 header
8111 // uses. Reachable only from a file whose version 1 objects
8112 // share messages, which the reference C library does not
8113 // write — it moves an object to a version 2 header before
8114 // sharing anything on it.
8115 region.push_shared(
8116 MessageType::ATTRIBUTE,
8117 &modernize_shared_reference(&m.data, os, ls)?,
8118 );
8119 continue;
8120 }
8121 // Re-wrap the attribute message body (it is self-describing) in a
8122 // v2 message record. The rebuilt header does not track creation
8123 // order — a version 1 header cannot have carried any — so the
8124 // attribute needs no creation index.
8125 region.push(MessageType::ATTRIBUTE, &m.data);
8126 }
8127 }
8128 Ok(GroupInfo { region, link_names })
8129 }
8130
8131 /// Parses and validates a group's object header, returning its message region
8132 /// — the bytes to copy when rewriting the header — and the names of its
8133 /// existing links. A version 2 header is rebuilt from its own message bytes
8134 /// (collapsing continuation chunks, preserving every message); a version 1
8135 /// symbol-table group is converted to v2 via [`reconstruct_v1_group`].
8136 ///
8137 /// [`reconstruct_v1_group`]: Self::reconstruct_v1_group
8138 fn inspect_group(&self, addr: u64) -> Result<GroupInfo, Error> {
8139 let sig = self.image().read_metadata_at(addr, 4);
8140 if sig.as_deref() != Ok(&b"OHDR"[..]) {
8141 return self.reconstruct_v1_group(addr);
8142 }
8143 let mut region =
8144 Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address)?;
8145 let mut p = 0;
8146 let mut has_link_info = false;
8147 let mut link_names = Vec::new();
8148 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8149 match msg_type {
8150 MessageType::LINK_INFO => {
8151 has_link_info = true;
8152 // LinkInfo: version(1) flags(1) [max_creation_index(8) if
8153 // flags&0x01] fractal_heap_addr(8) … — dense storage has a
8154 // defined fractal-heap address. Bound the read by this
8155 // message's own body, not just the region, so a short or
8156 // malformed LinkInfo can't make us read the next message.
8157 let mut q = body + 2;
8158 if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
8159 q += 8;
8160 }
8161 if q + 8 <= body_end {
8162 let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
8163 if heap_addr != u64::MAX {
8164 return Err(Error::EditUnsupported(
8165 "a target group uses dense (fractal-heap) link storage (not supported in place yet)",
8166 ));
8167 }
8168 }
8169 }
8170 MessageType::LINK => {
8171 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
8172 link_names.push(link.name);
8173 }
8174 }
8175 MessageType::DATA_LAYOUT => {
8176 return Err(Error::EditUnsupported(
8177 "a target path names a dataset, not a group",
8178 ));
8179 }
8180 _ => {}
8181 }
8182 p = body_end;
8183 }
8184 if !has_link_info {
8185 return Err(Error::EditUnsupported(
8186 "a target group's object header has no link-info message",
8187 ));
8188 }
8189 // Heal headers written by older hdf5-pure releases that omitted the
8190 // Group Info message, so the rewritten group stays writable by the C
8191 // library.
8192 ensure_group_info(&mut region)?;
8193 Ok(GroupInfo { region, link_names })
8194 }
8195
8196 /// The refusals a staged value overwrite (`write_dataset`) makes from the
8197 /// staged builder alone, with no on-disk header involved.
8198 ///
8199 /// [`stage_dataset_write`](Self::stage_dataset_write) applies these as the
8200 /// write is staged, so the call that configured the builder is the one that
8201 /// reports the mistake. A refusal that reads only `fd` belongs here; one that
8202 /// needs the target's header belongs in
8203 /// [`prepare_write`](Self::prepare_write).
8204 fn refuse_unsupported_overwrite(fd: &FlatDataset) -> Result<(), Error> {
8205 // A value overwrite never introduces chunking, filters, or an extensible
8206 // shape: those would change the storage layout, not just the bytes.
8207 if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
8208 return Err(Error::EditUnsupported(
8209 "write_dataset overwrites values only; it cannot make a dataset \
8210 chunked, filtered, or extensible",
8211 ));
8212 }
8213
8214 // `write_dataset` overwrites element bytes only; it does not touch the
8215 // object header's attribute messages. Attributes staged on the returned
8216 // builder would otherwise be silently dropped (the in-place path rewrites
8217 // only the data block, and the moving path reuses the verbatim on-disk
8218 // header), so refuse rather than degrade — set them in a separate edit.
8219 if !fd.attrs.is_empty() {
8220 return Err(Error::EditUnsupported(
8221 "write_dataset overwrites values only; it cannot set attributes \
8222 (set them with a separate edit)",
8223 ));
8224 }
8225
8226 // `write_dataset` overwrites element bytes only; it reuses the dataset's
8227 // existing Fill Value message (the in-place path rewrites only the data
8228 // block, and the moving path keeps every header message but the layout
8229 // verbatim). A fill value staged on the returned builder would otherwise
8230 // be silently ignored, so refuse rather than degrade — set the fill value
8231 // when the dataset is first created.
8232 if fd.fill.is_some() {
8233 return Err(Error::EditUnsupported(
8234 "write_dataset overwrites values only; it cannot change the fill \
8235 value (set it when the dataset is created)",
8236 ));
8237 }
8238
8239 // `with_vlen_strings` stages placeholder element references, resolved by
8240 // placing their global heap collections and patching the addresses in
8241 // (issue #321). That happens where each plan is written, not here: see
8242 // `OverwriteBytes`.
8243
8244 // `reference_targets` holds elements only the add path resolves, in
8245 // `preflight_reference_targets` and the apply loop after it; the
8246 // overwrite path never reads the field. Checked on the field rather than
8247 // on `with_path_references`, since every producer stages elements that
8248 // are equally unresolved. Left unrefused, a staged overwrite writes
8249 // address-zero placeholders over a working reference dataset and
8250 // `commit` reports `Ok` (issue #318). `with_reference_data` supplies
8251 // resolved addresses and overwrites like any other value.
8252 if fd.reference_targets.is_some() {
8253 return Err(Error::EditUnsupported(
8254 "write_dataset cannot overwrite an object-reference dataset's \
8255 data in place yet",
8256 ));
8257 }
8258
8259 Ok(())
8260 }
8261
8262 /// Preflight a staged value overwrite (`write_dataset`): resolve the dataset
8263 /// at `addr`, validate that the staged `fd` matches it byte-exactly in
8264 /// datatype and shape, and classify how the bytes will be applied. No file
8265 /// bytes are written here — this is part of the all-or-nothing preflight, so a
8266 /// rejected write leaves the commit unapplied.
8267 ///
8268 /// Contiguous, compact, and chunked (including filtered) datasets are all
8269 /// supported; the chunk geometry, filter pipeline, and chunk index come from
8270 /// the on-disk header (a chunk index this engine cannot enumerate — a
8271 /// version-2 B-tree — is refused). A datatype or shape that differs from the
8272 /// on-disk dataset's is likewise refused — this is a value overwrite, not a
8273 /// reshape or retype.
8274 ///
8275 /// Every refusal `fd` can decide on its own has been made by then, where the
8276 /// write is staged.
8277 fn prepare_write<S: Source + ?Sized>(
8278 src: &S,
8279 addr: u64,
8280 fd: &FlatDataset,
8281 base: BaseAddress,
8282 path: &ObjectPathBuf,
8283 ) -> Result<WritePlan, Error> {
8284 // Enforced by construction: `staged.writes` has one producer, and it
8285 // refuses there. Asserted rather than re-refused because a second caller
8286 // that skipped it would not fail here — a chunked builder would fall
8287 // through and be written as a plain contiguous overwrite, with
8288 // `chunk_options` silently dropped.
8289 debug_assert!(
8290 Self::refuse_unsupported_overwrite(fd).is_ok(),
8291 "a staged write reached prepare_write without refuse_unsupported_overwrite"
8292 );
8293
8294 let region = Self::gather_oh_messages(src, addr, base)?;
8295
8296 // Locate the datatype, dataspace, and data-layout messages, and detect a
8297 // filter pipeline (filtered storage is always chunked, never contiguous).
8298 let mut datatype: Option<(usize, usize)> = None;
8299 let mut dataspace: Option<(usize, usize)> = None;
8300 let mut layout: Option<(usize, usize)> = None;
8301 let mut filter: Option<(usize, usize)> = None;
8302 // The dataset's own fill value, which the edge overhang of a partial
8303 // chunk must hold (issue #296). Both message forms, newest wins.
8304 let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8305 let mut has_link = false;
8306 let mut p = 0;
8307 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8308 match msg_type {
8309 MessageType::DATATYPE => datatype = Some((body, body_end)),
8310 MessageType::DATASPACE => dataspace = Some((body, body_end)),
8311 MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
8312 MessageType::FILTER_PIPELINE => filter = Some((body, body_end)),
8313 // Versioned beats legacy, and within a type the first wins —
8314 // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8315 // apply, so all three agree on a header carrying more than one.
8316 MessageType::FILL_VALUE
8317 if !matches!(fill_msg, Some((MessageType::FILL_VALUE, ..))) =>
8318 {
8319 fill_msg = Some((msg_type, body, body_end));
8320 }
8321 MessageType::FILL_VALUE_OLD if fill_msg.is_none() => {
8322 fill_msg = Some((msg_type, body, body_end));
8323 }
8324 MessageType::LINK | MessageType::LINK_INFO | MessageType::SYMBOL_TABLE => {
8325 has_link = true;
8326 }
8327 _ => {}
8328 }
8329 p = body_end;
8330 }
8331
8332 if has_link {
8333 return Err(Error::EditUnsupported(
8334 "write_dataset target is a group, not a dataset",
8335 ));
8336 }
8337 let (dt_b, dt_e) =
8338 datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
8339 let (ds_b, ds_e) =
8340 dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
8341 let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
8342
8343 // Compare datatype and shape structurally against the staged data. A
8344 // value overwrite must keep both exactly: the datatype (including its
8345 // class, size, endianness, and any compound/array/enumeration layout) so
8346 // the bytes are interpreted the same, and the *current* dimensions so the
8347 // byte count is unchanged. Parsing both sides and comparing the decoded
8348 // values — rather than the raw message bytes — tolerates the harmless
8349 // encoding differences between this crate's writer and the reference C
8350 // library (e.g. the C library records a maximum-dimensions array equal to
8351 // the current dimensions, which this crate omits) while still refusing any
8352 // real retype or reshape.
8353 let (disk_dt, _) = Datatype::parse(®ion[dt_b..dt_e])?;
8354 if disk_dt != fd.dt {
8355 return Err(Error::EditUnsupported(
8356 "write_dataset datatype does not match the on-disk dataset (overwrite, not retype)",
8357 ));
8358 }
8359 let disk_ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE)?;
8360 if disk_ds.space_type != fd.ds.space_type
8361 || disk_ds.rank != fd.ds.rank
8362 || disk_ds.dimensions != fd.ds.dimensions
8363 {
8364 return Err(Error::EditUnsupported(
8365 "write_dataset shape does not match the on-disk dataset (overwrite, not reshape)",
8366 ));
8367 }
8368
8369 // Classify the layout. Version 3/4 compact (class 0), contiguous (class
8370 // 1), and chunked (class 2) are supported; an old-version layout or a
8371 // virtual layout (class 3) is refused.
8372 if le - lb < 2 {
8373 return Err(Error::EditUnsupported("malformed data-layout message"));
8374 }
8375 let version = region[lb];
8376 if version != 3 && version != 4 {
8377 return Err(Error::EditUnsupported(
8378 "an unsupported data-layout version cannot be overwritten in place yet",
8379 ));
8380 }
8381 match region[lb + 1] {
8382 // Compact: the data is inline in the header. Rebuild the header with
8383 // the new inline bytes (relocating it), patching the parent link.
8384 0 => Ok(WritePlan::Moving(MovingWrite::Compact {
8385 region,
8386 bytes: staged_bytes(fd, path),
8387 })),
8388 1 => {
8389 if le - lb < 18 {
8390 return Err(Error::EditUnsupported("malformed contiguous data layout"));
8391 }
8392 let addr_off = lb + 2;
8393 let data_addr = StoredAddress::new(u64::from_le_bytes(
8394 region[addr_off..addr_off + 8].try_into().unwrap(),
8395 ));
8396 let data_size = u64::from_le_bytes(region[lb + 10..lb + 18].try_into().unwrap());
8397
8398 // Same length and a defined, in-bounds data block: overwrite the
8399 // bytes straight in place. No header rewrite, no relink. The
8400 // in-place write targets the data block's absolute file offset.
8401 //
8402 // Never for a staged variable-length overwrite, which relocates
8403 // instead. Its resolution *allocates* — a global heap collection,
8404 // drawn from a freed region where one fits — and an in-place write
8405 // is a live mutation of a block the current root already reaches.
8406 // Together those put a reference to a just-allocated span inside
8407 // bytes the live tree names, so a commit failing after the write
8408 // and before its repoint hands the span back to the free list
8409 // (`restore_free`) while the image still points into it. The next
8410 // commit is then free to place something else there, and the
8411 // dataset reads that object's bytes with every checksum intact.
8412 //
8413 // A relocating write has no such window: its new data block is
8414 // reachable from nothing until the superblock repoint, so a
8415 // failed attempt leaves the region genuinely dead and re-offering
8416 // it is sound — which is the invariant `restore_free` states.
8417 if fd.vl_string_staging.is_none()
8418 && !data_addr.is_undefined(OFFSET_SIZE)
8419 && data_size == fd.raw.len() as u64
8420 {
8421 if let Ok(start) = base.absolute(data_addr) {
8422 if start.checked_add(data_size).is_some_and(|e| e <= src.len()) {
8423 return Ok(WritePlan::InPlace {
8424 data_addr: start,
8425 bytes: staged_bytes(fd, path),
8426 });
8427 }
8428 }
8429 }
8430
8431 // Length differs or the block was undefined/out of bounds: the new
8432 // data goes elsewhere and the old extent (if any) is freed. The
8433 // freed extent is recorded as an absolute file offset, which is the
8434 // frame the session free list uses.
8435 let old_extent = if !data_addr.is_undefined(OFFSET_SIZE) && data_size > 0 {
8436 Some((base.absolute(data_addr)?, data_size))
8437 } else {
8438 None
8439 };
8440 Ok(WritePlan::Moving(MovingWrite::Contiguous {
8441 region,
8442 addr_off,
8443 bytes: staged_bytes(fd, path),
8444 old_extent,
8445 }))
8446 }
8447 // Chunked: overwrite each chunk in place when every new (re-encoded)
8448 // chunk is the same byte length as its slot, else rebuild and relocate
8449 // the whole chunk storage. The chunk geometry, filter pipeline, and
8450 // index type all come from the existing on-disk header (the staged
8451 // builder carries none — chunked/filtered/extensible builders are
8452 // refused at the top of this function as "not a value overwrite").
8453 2 => {
8454 // Chunked overwrite (in-place or relocating). On a userblock file
8455 // every stored chunk-index and chunk address is relative to `base`:
8456 // the in-place path below walks the index on a base-relative view of
8457 // the file and shifts the resulting write offsets back by `base`,
8458 // and the relocating path rebuilds the chunk blob with stored
8459 // addresses (see `write_chunked_relocatable`).
8460 let dl = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE)?;
8461 let DataLayout::Chunked { index, .. } = dl else {
8462 return Err(Error::EditUnsupported("dataset is not chunked"));
8463 };
8464 if !index.enumerable() {
8465 return Err(Error::EditUnsupported(
8466 "a chunked dataset with a version-2 B-tree chunk index cannot be \
8467 overwritten in place yet",
8468 ));
8469 }
8470
8471 let ChunkedGeometry {
8472 spatial,
8473 element_size,
8474 raw_size,
8475 maxshape,
8476 } = chunked_geometry(&fd.dt, &disk_ds, &dl)?;
8477
8478 // Split the new value into full-size chunk buffers in dense
8479 // row-major grid order, then re-encode through the on-disk
8480 // pipeline when the dataset is filtered.
8481 // The overhang past the dataset's edge holds the dataset's own
8482 // fill value, not zeros: an allocated chunk is expected to carry
8483 // it wherever nothing was written, and those slots are what a
8484 // reader returns once the dataset is extended into them (#296).
8485 let padding = fill_msg
8486 .map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8487 crate::fill_value::PaddingFill::from_message(mt, ®ion[b..e])
8488 });
8489 let pipeline_message: Option<Vec<u8>> =
8490 filter.map(|(fb, fe)| region[fb..fe].to_vec());
8491
8492 // Refuse a pipeline this engine cannot re-encode before anything
8493 // is split or encoded, so the refusal reaches the preflight on
8494 // both branches below — including the staged one, which does its
8495 // splitting in the apply phase and so has no other chance to make
8496 // it.
8497 if let Some(pm) = &pipeline_message {
8498 let pipeline = FilterPipeline::parse(pm)?;
8499 if !pipeline_reencodable(&pipeline) {
8500 return Err(Error::EditUnsupported(
8501 "a chunked dataset using a filter this engine cannot re-encode \
8502 cannot be overwritten in place yet",
8503 ));
8504 }
8505 }
8506
8507 // Element bytes still carrying unresolved variable-length
8508 // references cannot be split here: a filtered chunk's compressed
8509 // length depends on the heap addresses patched into it, so the
8510 // split has to follow the patch, which follows a placement the
8511 // preflight may not make. See `ChunkPayload::Deferred`.
8512 if fd.vl_string_staging.is_some() {
8513 return Ok(WritePlan::Moving(MovingWrite::Chunked {
8514 region,
8515 shape: disk_ds.dimensions.clone(),
8516 chunk_dims: spatial,
8517 element_size,
8518 maxshape,
8519 pipeline_message,
8520 payload: ChunkPayload::Deferred {
8521 bytes: staged_bytes(fd, path),
8522 padding,
8523 dt: fd.dt.clone(),
8524 },
8525 old_addr: addr,
8526 }));
8527 }
8528
8529 let new_chunk_bytes = split_and_encode_chunks(
8530 &fd.raw,
8531 &disk_ds.dimensions,
8532 &spatial,
8533 element_size,
8534 &padding,
8535 pipeline_message.as_deref(),
8536 &fd.dt,
8537 )?;
8538
8539 // Fast path: overwrite each chunk straight in its slot when every
8540 // new chunk fits. No header rewrite and no superblock flip — the
8541 // chunk (and index) blocks are reachable from both roots. The index
8542 // is left untouched when chunks keep their size and rebuilt in place
8543 // when they shrink. The index walk runs on a base-relative view of
8544 // the file (so the layout's stored addresses index correctly), and
8545 // the returned write offsets are shifted back to absolute file
8546 // offsets by adding `base` (a no-op on a base-0 file).
8547 if let Some(writes) = try_inplace_chunk_writes(
8548 &BaseOffsetSource { inner: src, base },
8549 &dl,
8550 &disk_ds,
8551 &spatial,
8552 raw_size,
8553 &new_chunk_bytes,
8554 ) {
8555 let writes = writes
8556 .into_iter()
8557 .map(|(off, b)| Ok((base.absolute(StoredAddress::new(off))?, b)))
8558 .collect::<Result<Vec<_>, FormatError>>()?;
8559 return Ok(WritePlan::InPlaceChunks { writes });
8560 }
8561
8562 // Otherwise relocate: rebuild a fresh chunk blob + index at
8563 // end-of-file (carrying the re-encoded chunk bytes and the source
8564 // pipeline verbatim), swap the data-layout message in the verbatim
8565 // header, and free the old chunk storage after the commit lands.
8566 Ok(WritePlan::Moving(MovingWrite::Chunked {
8567 region,
8568 shape: disk_ds.dimensions.clone(),
8569 chunk_dims: spatial,
8570 element_size,
8571 maxshape,
8572 pipeline_message,
8573 payload: ChunkPayload::Encoded(new_chunk_bytes),
8574 old_addr: addr,
8575 }))
8576 }
8577 _ => Err(Error::EditUnsupported(
8578 "an unsupported data-layout class cannot be overwritten in place yet",
8579 )),
8580 }
8581 }
8582
8583 /// Plan a relocating append to an existing chunked, unlimited,
8584 /// Extensible-Array-indexed dataset at `addr`. Validates the target, splits
8585 /// the appended elements into new (and one rewritten trailing) chunks —
8586 /// compressed through the on-disk pipeline when filtered — and gathers the
8587 /// existing complete chunks by metadata alone. Returns the
8588 /// [`MovingWrite::AppendedChunks`] plan; the commit machinery appends the new
8589 /// chunks and a rebuilt index and repoints the header (see
8590 /// [`write_appended_chunks`](Self::write_appended_chunks)).
8591 ///
8592 /// Reads only; no bytes are written here. `src` is the file image and `base`
8593 /// its userblock base; the dataset's stored (base-relative) structures are read
8594 /// through a `base`-shifted view.
8595 fn prepare_append<S: Source + ?Sized>(
8596 src: &S,
8597 addr: u64,
8598 ab: &AppendBuilder,
8599 base: BaseAddress,
8600 ) -> Result<MovingWrite, Error> {
8601 if ab.dt_conflict {
8602 return Err(Error::AppendUnsupported(
8603 "append mixes element types in one builder; use one element type per \
8604 append_dataset call",
8605 ));
8606 }
8607
8608 let region = Self::gather_oh_messages(src, addr, base)?;
8609
8610 // Locate the datatype, dataspace, data-layout, and filter-pipeline
8611 // messages, and detect a group (link) header.
8612 let mut datatype: Option<(usize, usize)> = None;
8613 let mut dataspace: Option<(usize, usize)> = None;
8614 let mut layout: Option<(usize, usize)> = None;
8615 let mut filter: Option<(usize, usize)> = None;
8616 // The dataset's own fill value, which the edge overhang of a partial
8617 // chunk must hold (issue #296). Both message forms, newest wins.
8618 let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8619 let mut has_link = false;
8620 let mut p = 0;
8621 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8622 match msg_type {
8623 MessageType::DATATYPE => datatype = Some((body, body_end)),
8624 MessageType::DATASPACE => dataspace = Some((body, body_end)),
8625 MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
8626 MessageType::FILTER_PIPELINE => filter = Some((body, body_end)),
8627 // Versioned beats legacy, and within a type the first wins —
8628 // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8629 // apply, so all three agree on a header carrying more than one.
8630 MessageType::FILL_VALUE
8631 if !matches!(fill_msg, Some((MessageType::FILL_VALUE, ..))) =>
8632 {
8633 fill_msg = Some((msg_type, body, body_end));
8634 }
8635 MessageType::FILL_VALUE_OLD if fill_msg.is_none() => {
8636 fill_msg = Some((msg_type, body, body_end));
8637 }
8638 MessageType::LINK | MessageType::LINK_INFO | MessageType::SYMBOL_TABLE => {
8639 has_link = true;
8640 }
8641 _ => {}
8642 }
8643 p = body_end;
8644 }
8645 if has_link {
8646 return Err(Error::AppendUnsupported(
8647 "append target is a group, not a dataset",
8648 ));
8649 }
8650 let (dt_b, dt_e) =
8651 datatype.ok_or(Error::AppendUnsupported("dataset header has no datatype"))?;
8652 let (ds_b, ds_e) =
8653 dataspace.ok_or(Error::AppendUnsupported("dataset header has no dataspace"))?;
8654 let (lb, le) = layout.ok_or(Error::AppendUnsupported(
8655 "dataset header has no data layout",
8656 ))?;
8657
8658 let (disk_dt, _) = Datatype::parse(®ion[dt_b..dt_e])?;
8659 let disk_ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE)?;
8660 let dl = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE)?;
8661
8662 // Require a chunked dataset indexed by an Extensible Array, which only a
8663 // version 4 layout message stores.
8664 let DataLayout::Chunked { index, .. } = &dl else {
8665 return Err(Error::AppendUnsupported(
8666 "append requires a chunked dataset",
8667 ));
8668 };
8669 let ChunkIndexLayout::ExtensibleArray { address } = index else {
8670 return Err(Error::AppendUnsupported(
8671 "append requires an Extensible-Array-indexed chunked dataset (a single \
8672 unlimited dimension under the latest format)",
8673 ));
8674 };
8675
8676 // Require rank 1, unlimited along axis 0.
8677 if disk_ds.space_type != DataspaceType::Simple || disk_ds.dimensions.len() != 1 {
8678 return Err(Error::AppendUnsupported(
8679 "append requires a rank-1 dataset in this release",
8680 ));
8681 }
8682 if !matches!(
8683 disk_ds.max_dimensions.as_deref(),
8684 Some([MaxExtent::Unlimited, ..])
8685 ) {
8686 return Err(Error::AppendUnsupported(
8687 "append requires a dataset that is unlimited along its first dimension",
8688 ));
8689 }
8690
8691 let ChunkedGeometry {
8692 spatial,
8693 element_size,
8694 ..
8695 } = chunked_geometry(&disk_dt, &disk_ds, &dl)?;
8696 let chunk_elems = spatial[0];
8697 if chunk_elems == 0 {
8698 return Err(Error::AppendUnsupported(
8699 "append requires a nonzero chunk length",
8700 ));
8701 }
8702
8703 // Validate the appended bytes against the on-disk element type.
8704 if ab.raw.len() % element_size != 0 {
8705 return Err(Error::AppendUnsupported(
8706 "appended byte length is not a whole number of elements",
8707 ));
8708 }
8709 match &ab.elem_dt {
8710 // A typed append must match the on-disk datatype exactly (class, size,
8711 // and byte order) — this is a value append, not a retype.
8712 Some(expected) if *expected != disk_dt => {
8713 return Err(Error::AppendUnsupported(
8714 "append datatype does not match the on-disk dataset (wrong element \
8715 type or byte order)",
8716 ));
8717 }
8718 Some(_) => {}
8719 // A raw append trusts the caller's bytes but still refuses any datatype
8720 // whose flat little-endian bytes cannot be written verbatim: a
8721 // big-endian numeric leaf would silently misencode, and a
8722 // variable-length or reference leaf embeds heap/object addresses a byte
8723 // append cannot reproduce. A typed append is byte-order- and
8724 // class-checked by the datatype-equality arm above.
8725 None => {
8726 if !datatype_is_raw_appendable(&disk_dt) {
8727 return Err(Error::AppendUnsupported(
8728 "append_raw onto this dataset's datatype (non-little-endian, \
8729 variable-length, or reference) could misencode the bytes; use a \
8730 typed append",
8731 ));
8732 }
8733 }
8734 }
8735
8736 let new_elems = (ab.raw.len() / element_size) as u64;
8737 let current_dim0 = disk_ds.dimensions[0];
8738 let new_dim0 = current_dim0
8739 .checked_add(new_elems)
8740 .ok_or(Error::AppendUnsupported(
8741 "append would overflow the dataset dimension",
8742 ))?;
8743
8744 // The filter pipeline is preserved verbatim in the rebuilt header; parse it
8745 // to re-encode the new chunks. An engine-unencodable filter is refused.
8746 let pipeline_message: Option<Vec<u8>> = filter.map(|(fb, fe)| region[fb..fe].to_vec());
8747 let has_filters = pipeline_message.is_some();
8748 let pipeline = match &pipeline_message {
8749 Some(pm) => {
8750 let parsed = FilterPipeline::parse(pm)?;
8751 if !pipeline_reencodable(&parsed) {
8752 return Err(Error::AppendUnsupported(
8753 "dataset uses a filter this engine cannot re-encode",
8754 ));
8755 }
8756 Some(parsed)
8757 }
8758 None => None,
8759 };
8760
8761 if base.get() > src.len() {
8762 return Err(Error::AppendUnsupported(
8763 "userblock base address past end-of-file",
8764 ));
8765 }
8766 let view = BaseOffsetSource { inner: src, base };
8767
8768 // The rebuilt index's element format (bare address vs address+size+mask) is
8769 // chosen by `has_filters`; it must agree with the source index's client id,
8770 // or the kept chunks — carried by metadata into the new index — would be
8771 // re-encoded in the wrong element width.
8772 if let Some(idx_addr) = *address {
8773 let hdr = ExtensibleArrayHeader::parse_from_source(
8774 &view,
8775 idx_addr,
8776 OFFSET_SIZE,
8777 LENGTH_SIZE,
8778 )?;
8779 if (hdr.client_id == 1) != has_filters {
8780 return Err(Error::AppendUnsupported(
8781 "dataset filter metadata is inconsistent (chunk-index client id \
8782 disagrees with the filter pipeline)",
8783 ));
8784 }
8785 }
8786
8787 // Enumerate the existing chunks (base-relative addresses) and require a
8788 // dense grid: `plan_dense_grid` returns the chunks in index order and
8789 // `None` on any hole, duplicate, or count mismatch against the dimension.
8790 let infos = enumerate_chunks_from_source(&view, &dl, &disk_ds, OFFSET_SIZE, LENGTH_SIZE)?;
8791 let grid = plan_dense_grid(infos, &disk_ds.dimensions, &spatial).ok_or(
8792 Error::AppendUnsupported(
8793 "dataset has a sparse or inconsistent chunk grid; cannot append",
8794 ),
8795 )?;
8796 let grid_order = grid.grid_order;
8797
8798 // Complete chunks are kept by metadata; a trailing partial chunk (when the
8799 // current length is not chunk-aligned) is rewritten.
8800 let n_full = (current_dim0 / chunk_elems).to_usize()?;
8801 let has_partial = current_dim0 % chunk_elems != 0;
8802
8803 // Rewriting that chunk decodes and re-encodes committed values, which a
8804 // *lossy* pipeline does not reproduce: a ZFP block re-quantizes to fit
8805 // its new contents, and float D-scale packs offsets from a per-chunk
8806 // minimum that a smaller appended value moves (measured: 1.45 became 1.5
8807 // at one decimal). Refused here as on the immediate path (#407).
8808 if has_partial
8809 && let Some(pl) = &pipeline
8810 && !pipeline_lossless(pl)
8811 {
8812 return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
8813 }
8814
8815 let mut kept_chunks: Vec<WrittenChunk> = Vec::with_capacity(n_full);
8816 for ci in grid_order.iter().take(n_full) {
8817 kept_chunks.push(WrittenChunk {
8818 address: ci.address,
8819 compressed_size: ci.chunk_size.get(),
8820 // Preserve the source mask verbatim: a C/h5py file records a nonzero
8821 // mask for a chunk whose filter was skipped (e.g. deflate on
8822 // incompressible data), and forcing it to 0 would corrupt that chunk.
8823 filter_mask: ci.filter_mask,
8824 });
8825 }
8826
8827 // Build the raw byte region for the tail (from the last chunk boundary to
8828 // the new end): the live prefix of any rewritten partial chunk, then the
8829 // appended bytes.
8830 let mut tail_raw: Vec<u8> = Vec::new();
8831 let mut old_tail_extent: Option<(u64, u64)> = None;
8832 if has_partial {
8833 let partial = &grid_order[n_full];
8834 let len = partial.chunk_size.to_usize()?;
8835 partial
8836 .address
8837 .get()
8838 .checked_add(len as u64)
8839 .filter(|&e| e <= view.len())
8840 .ok_or(Error::AppendUnsupported(
8841 "trailing chunk extends past end-of-file",
8842 ))?;
8843 let stored = view.read_exact_at(partial.address.get(), len)?;
8844 let full = if let Some(pl) = &pipeline {
8845 let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8846 decompress_chunk(&stored, pl, ctx, partial.filter_mask).map_err(Error::Format)?
8847 } else {
8848 stored
8849 };
8850 let live_elems = (current_dim0 % chunk_elems).to_usize()?;
8851 let live_bytes = live_elems * element_size.get();
8852 if full.len() < live_bytes {
8853 return Err(Error::AppendUnsupported(
8854 "trailing chunk decoded shorter than its live element count",
8855 ));
8856 }
8857 tail_raw.extend_from_slice(&full[..live_bytes]);
8858 // The old partial chunk's data block is dead once the new index lands.
8859 old_tail_extent = Some((base.absolute(partial.address)?, partial.chunk_size.get()));
8860 }
8861 tail_raw.extend_from_slice(&ab.raw);
8862
8863 // Split the tail into full chunk buffers and compress each through the
8864 // pipeline when filtered. The final chunk's overhang takes the dataset's
8865 // fill value, which is what a reader returns from those slots once a
8866 // later append or resize reaches them (#296).
8867 let tail_len_elems = new_dim0 - (n_full as u64) * chunk_elems;
8868 let padding = fill_msg.map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8869 crate::fill_value::PaddingFill::from_message(mt, ®ion[b..e])
8870 });
8871 let split = split_into_chunks(
8872 &tail_raw,
8873 &[tail_len_elems],
8874 &spatial,
8875 element_size,
8876 padding.pattern(element_size),
8877 )
8878 .map_err(Error::Format)?;
8879 let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pl) = &pipeline {
8880 let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8881 let mut out = Vec::with_capacity(split.len());
8882 // One encoder across the appended tail; see `FilterScratch`.
8883 let mut scratch = FilterScratch::new();
8884 for buf in &split {
8885 out.push(compress_chunk_with(&mut scratch, buf, pl, ctx).map_err(Error::Format)?);
8886 }
8887 out
8888 } else {
8889 split
8890 };
8891
8892 // Grow the dataspace along axis 0, preserving the (unlimited) max-dims.
8893 let mut grown = disk_ds.clone();
8894 grown.dimensions[0] = new_dim0;
8895 let new_dataspace_body = grown.serialize(LENGTH_SIZE);
8896
8897 let chunk_dims_u32: Vec<u32> = spatial
8898 .iter()
8899 .map(|&dim| dim.narrow::<u32>())
8900 .collect::<Result<_, _>>()?;
8901
8902 Ok(MovingWrite::AppendedChunks {
8903 region,
8904 new_dataspace_body,
8905 chunk_dims_u32,
8906 element_size,
8907 has_filters,
8908 kept_chunks,
8909 new_chunk_bytes,
8910 old_addr: addr,
8911 old_tail_extent,
8912 })
8913 }
8914
8915 /// Parse the object header at `addr` into a copyable model, validating that
8916 /// every message can be reproduced faithfully (verbatim message bytes, with
8917 /// only the contiguous data address and child link targets repointed).
8918 /// Dense (fractal-heap) attribute storage is read out of the source heap into
8919 /// a parsed attribute set carried on the model (`dense_attrs`) and re-emitted
8920 /// into a fresh heap on write, within the bounds that heap declares (see
8921 /// `file_writer::dense_attrs_check`); an attribute too large to hold as a
8922 /// managed object is re-emitted as a *huge* object. Rejects multi-chunk
8923 /// headers, dense or soft/external links, old-version data layouts, external
8924 /// data storage ([`reject_external_storage`]), and headers that are neither a
8925 /// dataset nor a group. (A *chunked* layout is modelled, not rejected —
8926 /// [`ObjModel::DatasetChunked`].)
8927 fn read_object<S: Source + ?Sized>(
8928 src: &S,
8929 access_mode: AccessMode,
8930 addr: u64,
8931 base: BaseAddress,
8932 ) -> Result<ObjModel, Error> {
8933 let region = Self::gather_oh_messages(src, addr, base)?;
8934
8935 // Ahead of the layout classification below, where external storage and
8936 // never-allocated storage would otherwise share one undefined address
8937 // and one answer — and ahead of the dense-attribute read, so a header
8938 // this cannot copy says why rather than failing as something else.
8939 reject_external_storage(®ion)?;
8940
8941 // First pass: detect whether attributes are stored densely (a defined
8942 // fractal-heap address in the Attribute Info message). A dense object is
8943 // copied by reading its attributes out of the source heap and rebuilding
8944 // a fresh heap on write, so its Attribute Info message and any inline
8945 // Attribute messages are dropped from the verbatim region — the rebuilt
8946 // region carries neither, and `dense_attrs` carries the parsed set.
8947 let mut dense = false;
8948 let mut p = 0;
8949 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8950 if msg_type == MessageType::ATTRIBUTE_INFO {
8951 // An Attribute Info message does not by itself mean dense
8952 // storage: the reference C library and h5py emit one (with an
8953 // *undefined* fractal-heap address) even for compact, inline
8954 // attributes in the latest format, to carry attribute
8955 // creation-order metadata. Only a *defined* heap address is real
8956 // dense (fractal-heap) storage. A message that cannot be parsed
8957 // is refused conservatively.
8958 let ai = crate::attribute_info::AttributeInfoMessage::parse(
8959 ®ion[body..body_end],
8960 OFFSET_SIZE,
8961 )?;
8962 if ai.fractal_heap_address.is_some() {
8963 dense = true;
8964 }
8965 }
8966 p = body_end;
8967 }
8968
8969 // If dense, read the attribute set out of the source fractal heap now (so
8970 // the source buffer need not outlive the read) and validate it can be
8971 // re-emitted into a fresh heap on write. The read is [`read_object_attrs`],
8972 // shared with the attribute editor: both need the source framed past its
8973 // userblock before the heap walk, and one home for that framing is what
8974 // keeps the two from coming to disagree about it.
8975 let dense_attrs = if dense {
8976 let stored = read_object_attrs(src, access_mode, addr, base)?;
8977 let set = dense_attr_set(®ion, stored)?;
8978 // The typed error names the offending attribute, which the previous
8979 // blanket `EditUnsupported` message could not.
8980 crate::file_writer::dense_attrs_check(&set.attrs).map_err(Error::Format)?;
8981 set
8982 } else {
8983 DenseAttrSet::default()
8984 };
8985
8986 let mut layout: Option<(usize, usize)> = None; // (body offset in kept, size)
8987 let mut has_link_info = false;
8988 // (name, creation index, target address) per hard link. The creation
8989 // index is `None` unless the source group tracks link creation order,
8990 // and is carried so a copy of one that does reproduces its order.
8991 let mut children: Vec<(String, Option<u64>, StoredAddress)> = Vec::new();
8992 // The rebuilt chunk-0 region: every message kept verbatim except hard
8993 // Link messages (carried as `children`) and, when dense, the Attribute
8994 // Info message and inline Attribute messages (carried as `dense_attrs`).
8995 let mut kept = OhRegion::empty(region.props());
8996
8997 let mut p = 0;
8998 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8999 let mut keep = true;
9000 match msg_type {
9001 MessageType::ATTRIBUTE_INFO => {
9002 // Already parsed in the first pass; drop the dense Attribute
9003 // Info message so the rebuilt header references the fresh heap
9004 // (spliced in on write) rather than the source one. A compact
9005 // (undefined-heap) Attribute Info message is kept verbatim.
9006 if dense {
9007 keep = false;
9008 }
9009 }
9010 MessageType::ATTRIBUTE => {
9011 // A dense object should carry no inline Attribute messages,
9012 // but drop any defensively so the rebuilt header's only
9013 // attribute storage is the fresh heap.
9014 if dense {
9015 keep = false;
9016 }
9017 }
9018 MessageType::LINK_INFO => {
9019 has_link_info = true;
9020 let mut q = body + 2;
9021 if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
9022 q += 8;
9023 }
9024 if q + 8 <= body_end {
9025 let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
9026 if heap_addr != u64::MAX {
9027 return Err(Error::EditUnsupported(
9028 "a group uses dense (fractal-heap) link storage (not supported in place yet)",
9029 ));
9030 }
9031 }
9032 }
9033 MessageType::LINK => {
9034 keep = false;
9035 match LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
9036 Ok(LinkMessage {
9037 name,
9038 link_target:
9039 LinkTarget::Hard {
9040 object_header_address,
9041 },
9042 creation_order,
9043 ..
9044 }) => children.push((name, creation_order, object_header_address)),
9045 _ => {
9046 return Err(Error::EditUnsupported(
9047 "a group contains a soft/external link (not copyable in place yet)",
9048 ));
9049 }
9050 }
9051 }
9052 MessageType::DATA_LAYOUT => {
9053 // Record the layout body offset within the *kept* region so a
9054 // contiguous dataset's data-address field can be repointed
9055 // even after earlier messages were dropped.
9056 layout = Some((kept.len() + (body - p), body_end - body));
9057 }
9058 _ => {}
9059 }
9060 if keep {
9061 kept.push_bytes(®ion[p..body_end]);
9062 }
9063 p = body_end;
9064 }
9065
9066 if let Some((lbody, lsize)) = layout {
9067 let version = kept[lbody];
9068 if !(version == 3 || version == 4) || lsize < 2 {
9069 return Err(Error::EditUnsupported(
9070 "an unsupported data-layout version cannot be copied in place yet",
9071 ));
9072 }
9073 let class = kept[lbody + 1];
9074 match class {
9075 0 => Ok(ObjModel::DatasetVerbatim {
9076 region: kept,
9077 dense_attrs,
9078 }),
9079 1 => {
9080 if lbody + 18 > kept.len() {
9081 return Err(Error::EditUnsupported("malformed contiguous data layout"));
9082 }
9083 let data_addr = StoredAddress::new(u64::from_le_bytes(
9084 kept[lbody + 2..lbody + 10].try_into().unwrap(),
9085 ));
9086 let data_size =
9087 u64::from_le_bytes(kept[lbody + 10..lbody + 18].try_into().unwrap());
9088 Ok(ObjModel::DatasetContiguous {
9089 region: kept,
9090 addr_off: lbody + 2,
9091 data_addr,
9092 data_size,
9093 dense_attrs,
9094 })
9095 }
9096 // Chunked: the verbatim header carries the data-layout and filter-
9097 // pipeline messages; `read_copy_subtree` (which holds the source
9098 // buffer) enumerates and captures the chunk bytes and rebuilds the
9099 // index on write.
9100 2 => Ok(ObjModel::DatasetChunked {
9101 region: kept,
9102 dense_attrs,
9103 }),
9104 _ => Err(Error::EditUnsupported(
9105 "an unsupported data-layout class cannot be copied in place yet",
9106 )),
9107 }
9108 } else if has_link_info {
9109 // A copied group must carry a Group Info message so the copy stays
9110 // writable by the C library, even when the source omitted it.
9111 ensure_group_info(&mut kept)?;
9112 Ok(ObjModel::Group {
9113 non_link_region: kept,
9114 children,
9115 dense_attrs,
9116 })
9117 } else {
9118 Err(Error::EditUnsupported(
9119 "an object is neither a contiguous/compact dataset nor a group",
9120 ))
9121 }
9122 }
9123
9124 /// Read the object at `addr` in the source buffer `d` — and, for a group, its
9125 /// whole subtree — into an owned [`CopyTree`], the read half of an object copy.
9126 /// No bytes are written; this both validates that the subtree is copyable and
9127 /// captures the bytes the write half ([`write_copy_subtree`](Self::write_copy_subtree))
9128 /// later appends, so the source buffer need not outlive the read.
9129 ///
9130 /// `src` is the image the source object lives in: this session's own file image
9131 /// for an in-file [`copy`](Self::copy), or another file's image for a cross-file
9132 /// [`copy_from`](Self::copy_from). `base` is that image's userblock base (the
9133 /// session's own base for an in-file copy, always 0 for a cross-file copy, whose
9134 /// source is gated to base 0): the stored, base-relative addresses read out of
9135 /// the source headers are shifted by it to index `src`. When `cross_file` is set,
9136 /// every copied object header is additionally screened by
9137 /// [`reject_foreign_addresses`] — verbatim bytes that embed a *source-file*
9138 /// absolute address (variable-length or reference data, a committed datatype)
9139 /// would dangle in another file and are refused, whereas an in-file copy keeps
9140 /// them valid by sharing the source file's heaps and objects.
9141 fn read_copy_subtree<S: Source + ?Sized>(
9142 src: &S,
9143 access_mode: AccessMode,
9144 addr: u64,
9145 depth: u32,
9146 cross_file: bool,
9147 base: BaseAddress,
9148 ) -> Result<CopyTree, Error> {
9149 if depth >= MAX_COPY_DEPTH {
9150 return Err(Error::EditUnsupported(
9151 "copy source nests too deeply (possible hard-link cycle)",
9152 ));
9153 }
9154 // `base` is the userblock base of the image `src`: this session's own base
9155 // for an in-file copy, and always 0 for a cross-file copy (the source is
9156 // gated to base 0 in `copy_from`). `addr` is an absolute offset into `src`;
9157 // the stored (base-relative) addresses `read_object` returns for contiguous
9158 // data, chunk storage, and child links are converted to absolute offsets by
9159 // adding `base` before `src` is read or a child is descended into.
9160 match Self::read_object(src, access_mode, addr, base)? {
9161 ObjModel::DatasetVerbatim {
9162 region,
9163 dense_attrs,
9164 } => {
9165 if cross_file {
9166 reject_foreign_addresses(®ion)?;
9167 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9168 }
9169 Ok(CopyTree::DatasetVerbatim {
9170 region,
9171 dense_attrs,
9172 })
9173 }
9174 ObjModel::DatasetContiguous {
9175 region,
9176 addr_off,
9177 data_addr,
9178 data_size,
9179 dense_attrs,
9180 } => {
9181 if cross_file {
9182 reject_foreign_addresses(®ion)?;
9183 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9184 }
9185 // Storage the source never allocated is copied as storage, not as
9186 // the values reading it answers with. The reference library does
9187 // not allocate a contiguous dataset's data until something is
9188 // written to it, leaving the layout message's address undefined,
9189 // and reading one answers the fill value for every element (#292)
9190 // — so a copy that materialized what it read would turn a
9191 // schema-only dataset into a fully written one of the size its
9192 // shape declares. `repack` carries the same shape through as
9193 // unallocated (#293); this is the copy path's half of it (#336).
9194 //
9195 // Only reached once external storage is out of the way: it uses
9196 // this same undefined address for a dataset that *does* hold
9197 // data, and `read_object` refuses it by name above.
9198 let data = if data_addr.is_undefined(OFFSET_SIZE) {
9199 None
9200 } else {
9201 // The data block is read at its absolute offset into `src`.
9202 let start = base.absolute(data_addr)?;
9203 start
9204 .checked_add(data_size)
9205 .filter(|&e| e <= src.len())
9206 .ok_or(Error::EditUnsupported("dataset data is out of bounds"))?;
9207 let len = data_size.to_usize()?;
9208 Some(src.read_exact_at(start, len)?)
9209 };
9210 Ok(CopyTree::DatasetContiguous {
9211 region,
9212 addr_off,
9213 data,
9214 dense_attrs,
9215 })
9216 }
9217 ObjModel::DatasetChunked {
9218 region,
9219 dense_attrs,
9220 } => {
9221 // Screen the verbatim header on the cross-file path. This refuses a
9222 // variable-length or reference datatype (whose chunk payload embeds
9223 // source-file global-heap / object addresses that would dangle in
9224 // another file) and any shared message — exactly the forms repack
9225 // also refuses for a cross-file verbatim chunk copy. An in-file copy
9226 // keeps them valid by sharing the source file's heaps.
9227 if cross_file {
9228 reject_foreign_addresses(®ion)?;
9229 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9230 }
9231 let ChunkedHeaderParts {
9232 dt,
9233 ds,
9234 layout,
9235 pipeline_message,
9236 } = parse_chunked_header(®ion)?;
9237 let DataLayout::Chunked { index, .. } = layout else {
9238 return Err(Error::EditUnsupported("dataset is not chunked"));
9239 };
9240 if !index.enumerable() {
9241 return Err(Error::EditUnsupported(
9242 "a chunked dataset with a version-2 B-tree chunk index cannot be \
9243 copied in place yet",
9244 ));
9245 }
9246 let ChunkedGeometry {
9247 spatial: chunk_dims,
9248 element_size,
9249 raw_size: _,
9250 maxshape,
9251 } = chunked_geometry(&dt, &ds, &layout)?;
9252
9253 // The layout's chunk-index address and every chunk address it leads
9254 // to are stored base-relative, so enumerate and read on a
9255 // base-relative view of the source image (the identity on a base-0
9256 // file). The returned addresses are then offsets into `dview`.
9257 let dview = BaseOffsetSource { inner: src, base };
9258
9259 // Enumerate the source chunks and map them onto a dense grid; a
9260 // sparse (holed/unallocated) dataset cannot be reproduced by the
9261 // verbatim layout path, which needs every grid slot filled.
9262 let infos =
9263 enumerate_chunks_from_source(&dview, &layout, &ds, OFFSET_SIZE, LENGTH_SIZE)?;
9264 let grid = plan_dense_grid(infos, &ds.dimensions, &chunk_dims).ok_or(
9265 Error::EditUnsupported(
9266 "a chunked dataset with unallocated (sparse) chunks cannot be copied in place yet",
9267 ),
9268 )?;
9269 if grid.grid_order.is_empty() {
9270 return Err(Error::EditUnsupported(
9271 "an empty chunked dataset cannot be copied in place yet",
9272 ));
9273 }
9274
9275 // Capture each chunk's already-compressed bytes (no decode) into an
9276 // owned buffer, in dense row-major grid order, so the copy can be
9277 // written after the source buffer is gone (cross-file copy reads at
9278 // staging time). Sizes and masks are carried verbatim.
9279 let mut meta = Vec::with_capacity(grid.grid_order.len());
9280 let mut chunk_bytes = Vec::with_capacity(grid.grid_order.len());
9281 for ci in &grid.grid_order {
9282 let len = ci.chunk_size.to_usize()?;
9283 ci.address
9284 .get()
9285 .checked_add(len as u64)
9286 .filter(|&e| e <= dview.len())
9287 .ok_or(Error::EditUnsupported("chunk data is out of bounds"))?;
9288 chunk_bytes.push(dview.read_exact_at(ci.address.get(), len)?);
9289 meta.push(ChunkMeta {
9290 compressed_size: ci.chunk_size.get(),
9291 filter_mask: ci.filter_mask,
9292 });
9293 }
9294
9295 Ok(CopyTree::DatasetChunked {
9296 region,
9297 shape: ds.dimensions.clone(),
9298 chunk_dims,
9299 element_size,
9300 maxshape,
9301 pipeline_message,
9302 meta,
9303 chunk_bytes,
9304 dense_attrs,
9305 })
9306 }
9307 ObjModel::Group {
9308 non_link_region,
9309 children,
9310 dense_attrs,
9311 } => {
9312 if cross_file {
9313 reject_foreign_addresses(&non_link_region)?;
9314 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9315 }
9316 let mut kids = Vec::with_capacity(children.len());
9317 for (name, creation_order, child) in children {
9318 // `read_copy_subtree` reads `src` at absolute offsets.
9319 let child = base.absolute(child)?;
9320 kids.push((
9321 name,
9322 creation_order,
9323 Self::read_copy_subtree(
9324 src,
9325 access_mode,
9326 child,
9327 depth + 1,
9328 cross_file,
9329 base,
9330 )?,
9331 ));
9332 }
9333 Ok(CopyTree::Group {
9334 non_link_region,
9335 children: kids,
9336 dense_attrs,
9337 })
9338 }
9339 }
9340 }
9341
9342 /// Append the fresh copies described by `node` (data blobs and headers) into
9343 /// this session at end-of-file or into reusable freed regions, returning the
9344 /// new object-header address of the copied root. The write half of an object
9345 /// copy; children are written before their parent group so each parent links
9346 /// its children's new addresses, and a contiguous dataset's data-address field
9347 /// is repointed at the freshly-written copy. Every address the copy writes into
9348 /// a header (a contiguous data block, a child link) is stored relative to the
9349 /// userblock base (`- base`, a no-op on a base-0 file); the chunked storage and
9350 /// dense attribute heaps are laid out base-relative by their own builders.
9351 fn write_copy_subtree(&mut self, node: &CopyTree) -> Result<u64, Error> {
9352 let base = self.superblock.base_address;
9353 match node {
9354 CopyTree::DatasetVerbatim {
9355 region,
9356 dense_attrs,
9357 } => {
9358 let mut region = region.clone();
9359 self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9360 let oh = build_v2_object_header(®ion)?;
9361 self.alloc_or_append_typed(&oh, PageType::Meta)
9362 }
9363 CopyTree::DatasetContiguous {
9364 region,
9365 addr_off,
9366 data,
9367 dense_attrs,
9368 } => {
9369 let mut region = region.clone();
9370 // Storage the source never allocated is copied as storage: there
9371 // is no block to place, and the region already carries the
9372 // undefined address the source stored, so the copy declares the
9373 // same empty storage (issue #336).
9374 if let Some(data) = data {
9375 let new_data_addr = self.alloc_or_append_typed(data, PageType::Raw)?;
9376 // The placement is an absolute offset; the data-layout
9377 // address field stores it relative to the userblock base.
9378 let relative = base.relative(new_data_addr)?.get();
9379 region.bytes_mut()[*addr_off..*addr_off + 8]
9380 .copy_from_slice(&relative.to_le_bytes());
9381 }
9382 // The dense heap is placed independently of the data — it is
9383 // built for whatever address it gets (see `append_dense_attrs`),
9384 // so no ordering between the two is owed.
9385 self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9386 let oh = build_v2_object_header(®ion)?;
9387 self.alloc_or_append_typed(&oh, PageType::Meta)
9388 }
9389 CopyTree::DatasetChunked {
9390 region,
9391 shape,
9392 chunk_dims,
9393 element_size,
9394 maxshape,
9395 pipeline_message,
9396 meta,
9397 chunk_bytes,
9398 dense_attrs,
9399 } => self.write_chunked_relocatable(
9400 region,
9401 shape,
9402 chunk_dims,
9403 *element_size,
9404 maxshape.as_deref(),
9405 pipeline_message.as_deref(),
9406 meta,
9407 chunk_bytes,
9408 dense_attrs.clone(),
9409 ),
9410 CopyTree::Group {
9411 non_link_region,
9412 children,
9413 dense_attrs,
9414 } => {
9415 let mut region = non_link_region.clone();
9416 for (name, creation_order, child) in children {
9417 let new_child = self.write_copy_subtree(child)?;
9418 // The link target is stored relative to the userblock base.
9419 // A copied link keeps the creation index the source recorded
9420 // for it, so a copy of a group that tracks link creation
9421 // order carries the same order as its source — the Link Info
9422 // message naming that order is copied verbatim beside it.
9423 region.push_link(name, base.relative(new_child)?, *creation_order);
9424 }
9425 // The dense heap is built for whatever address it is placed at
9426 // (see `append_dense_attrs`), so it needs no ordering against the
9427 // children's headers and data.
9428 self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9429 let oh = build_v2_object_header(®ion)?;
9430 self.alloc_or_append_typed(&oh, PageType::Meta)
9431 }
9432 }
9433 }
9434
9435 /// Write a chunked dataset's storage and return its new object-header address
9436 /// — the shared write half of a chunked copy ([`CopyTree::DatasetChunked`])
9437 /// and a relocating chunked overwrite ([`MovingWrite::Chunked`]).
9438 ///
9439 /// A fresh chunk-data blob and index are laid out relocatably via
9440 /// [`plan_chunked_data_verbatim`] / [`emit_chunked_data_verbatim`], pulling
9441 /// each chunk's already-compressed bytes from `chunk_bytes` (in dense
9442 /// row-major grid order) and carrying `meta`'s sizes and filter masks and the
9443 /// source `pipeline_message` verbatim — no recompression, no filter-parameter
9444 /// reconstruction. Like [`build_chunked_dataset`](Self::build_chunked_dataset)
9445 /// the blob is sized from its plan before it is placed, so it can go into a
9446 /// freed region that fits it as readily as at end-of-file. The verbatim header
9447 /// `region`'s data-layout message is then swapped for the one the planner
9448 /// produced (every other message preserved), any dense attribute heap is
9449 /// placed, and the header is written into reusable freed space or at
9450 /// end-of-file.
9451 #[expect(
9452 clippy::too_many_arguments,
9453 reason = "the chunked rebuild needs the full geometry, \
9454 pipeline, and chunk payloads; bundling them into a struct would only move the list"
9455 )]
9456 fn write_chunked_relocatable(
9457 &mut self,
9458 region: &OhRegion,
9459 shape: &[u64],
9460 chunk_dims: &[u64],
9461 element_size: NonZeroUsize,
9462 maxshape: Option<&[MaxExtent]>,
9463 pipeline_message: Option<&[u8]>,
9464 meta: &[ChunkMeta],
9465 chunk_bytes: &[Vec<u8>],
9466 dense_attrs: DenseAttrSet,
9467 ) -> Result<u64, Error> {
9468 // Plan once at a provisional base purely to size the data region: the plan
9469 // walks chunk *sizes* and sizes the index from its layout, so it touches
9470 // no bytes at all, and every address it embeds sits in a fixed-width
9471 // field, so its total length is the same wherever the blob lands. That is
9472 // what lets the address be chosen — a freed region or end-of-file — before
9473 // the bytes exist. This plan is discarded; the one built at the real base
9474 // below is what the emit works from.
9475 let sizing = plan_chunked_data_verbatim(
9476 meta,
9477 shape,
9478 chunk_dims,
9479 element_size,
9480 pipeline_message,
9481 StoredAddress::new(0),
9482 maxshape,
9483 )?;
9484 let (_addr, layout_message) =
9485 self.place_relocatable(sizing.plan.total_len, PageType::Raw, |stored_base| {
9486 // Re-plan at the address the blob really occupies, so its embedded
9487 // addresses resolve to their real file offsets once the reader adds
9488 // the userblock base back (see `build_chunked_dataset`).
9489 let layout = plan_chunked_data_verbatim(
9490 meta,
9491 shape,
9492 chunk_dims,
9493 element_size,
9494 pipeline_message,
9495 stored_base,
9496 maxshape,
9497 )?;
9498 let mut buf =
9499 Vec::with_capacity(usize::try_from(layout.plan.total_len).unwrap_or(0));
9500 emit_chunked_data_verbatim(
9501 &mut buf,
9502 &layout.plan,
9503 &SliceChunkProvider {
9504 chunks: chunk_bytes,
9505 },
9506 )?;
9507 Ok((buf, layout.layout_message))
9508 })?;
9509 // Swap the data-layout message for the rebuilt one; keep every other header
9510 // message (datatype, dataspace, fill value, filter pipeline, attributes)
9511 // verbatim.
9512 let mut new_region = replace_layout_message(region, &layout_message)?;
9513 self.append_dense_attrs(&mut new_region, dense_attrs)?;
9514 let oh = build_v2_object_header(&new_region)?;
9515 self.alloc_or_append_typed(&oh, PageType::Meta)
9516 }
9517
9518 /// When `attrs` is non-empty, build a fresh dense (fractal-heap) attribute
9519 /// blob for it, place it, and splice the matching Attribute Info message onto
9520 /// `region`. A no-op for an empty set.
9521 ///
9522 /// The blob produced by [`DenseAttrPlan::build`] is fully
9523 /// relocatable: every address it embeds is `base + fixed offset`, and its
9524 /// length is the same for every base, so it can go into a freed metadata
9525 /// region as readily as at end-of-file — the base it is built for is whichever
9526 /// address it gets. The reservation comes from the plan the blob is then
9527 /// built from, so sizing it costs no bytes. The freshly built heap is always
9528 /// same-file, so it never aliases the source heap even for an in-file copy.
9529 /// The caller has already validated [`dense_attrs_check`].
9530 ///
9531 /// [`DenseAttrPlan::build`]: crate::file_writer::DenseAttrPlan::build
9532 /// [`dense_attrs_check`]: crate::file_writer::dense_attrs_check
9533 fn append_dense_attrs(
9534 &mut self,
9535 region: &mut OhRegion,
9536 set: DenseAttrSet,
9537 ) -> Result<(), Error> {
9538 if set.is_empty() {
9539 return Ok(());
9540 }
9541 let attr_info_message = self.place_dense_attrs(&set.attrs, set.creation)?;
9542 region.push(MessageType::ATTRIBUTE_INFO, &attr_info_message);
9543 Ok(())
9544 }
9545
9546 /// Place dense attribute storage for a staged dataset whose set needs it,
9547 /// returning the Attribute Info message naming the heap — or `None` when the
9548 /// set belongs in the object header, where the header builders write it
9549 /// inline. Which it is was decided and validated when the dataset was staged
9550 /// ([`FlatDataset::attrs_are_dense`]).
9551 fn place_dense_attrs_if_needed(&mut self, fd: &FlatDataset) -> Result<Option<Vec<u8>>, Error> {
9552 if !fd.attrs_are_dense {
9553 return Ok(None);
9554 }
9555 self.place_dense_attrs(&fd.attrs, DenseAttrCreationOrder::Untracked)
9556 .map(Some)
9557 }
9558
9559 /// Build a fresh dense attribute blob for `attrs`, place it, and return the
9560 /// Attribute Info message naming it — the header's whole share of dense
9561 /// storage. See [`append_dense_attrs`](Self::append_dense_attrs) for why the
9562 /// blob may be placed anywhere.
9563 fn place_dense_attrs(
9564 &mut self,
9565 attrs: &[crate::attribute::AttributeMessage],
9566 creation: DenseAttrCreationOrder,
9567 ) -> Result<Vec<u8>, Error> {
9568 let plan = crate::file_writer::dense_attrs_plan(attrs, creation);
9569 let (_addr, attr_info_message) =
9570 self.place_relocatable(plan.blob_len(), PageType::Meta, |stored_base| {
9571 let blob = plan.build(stored_base);
9572 Ok((blob.blob, blob.attr_info_message))
9573 })?;
9574 Ok(attr_info_message)
9575 }
9576
9577 /// Resolve what an attribute edit left for this phase ([`plan_attr_ops`]) and
9578 /// append what names it to the object's message `region`.
9579 ///
9580 /// Both arms place a variable-length attribute's global heap collection and
9581 /// patch the placeholder references in its message; where they differ is what
9582 /// carries the result. A compact attribute becomes an inline Attribute
9583 /// message; a dense set is built into a fresh heap the header then names.
9584 ///
9585 /// For the dense arm the patching has to come first: the heap stores each
9586 /// attribute's *message bytes*, references and all, so a heap built before
9587 /// them would hold the placeholders — the same ordering the whole-file writer
9588 /// keeps for the same reason.
9589 ///
9590 /// A no-op for an edit that resolved entirely in the preflight, which is what
9591 /// makes it safe to call on every rebuilt header.
9592 fn place_edited_attrs(
9593 &mut self,
9594 region: &mut OhRegion,
9595 attrs: EditedAttrs,
9596 ) -> Result<(), Error> {
9597 match attrs {
9598 EditedAttrs::Compact(pending) => {
9599 for pending in pending {
9600 let PendingVlAttr {
9601 mut msg,
9602 collections,
9603 creation_index,
9604 } = pending;
9605 let addrs = self.place_vl_collections(&collections)?;
9606 patch_vl_refs(&mut msg.raw_data, &addrs);
9607 *region = put_attr_message(
9608 region,
9609 &msg.name,
9610 &msg.serialize(LENGTH_SIZE),
9611 creation_index,
9612 )?;
9613 }
9614 Ok(())
9615 }
9616 EditedAttrs::Dense(mut dense) => {
9617 for (idx, collections) in std::mem::take(&mut dense.vl) {
9618 let addrs = self.place_vl_collections(&collections)?;
9619 patch_vl_refs(&mut dense.set.attrs[idx].raw_data, &addrs);
9620 }
9621 self.append_dense_attrs(region, dense.set)
9622 }
9623 }
9624 }
9625
9626 /// Apply a relocating value overwrite (`write_dataset` resize / compact
9627 /// rewrite): write the new data and a rewritten object header at end-of-file
9628 /// (or into reusable freed space) and return the new header address. The
9629 /// caller patches the parent group's link to this address. The old data
9630 /// extent (for a resized contiguous dataset) is freed separately, after the
9631 /// commit's superblock repoint, so it is never reused mid-commit.
9632 fn write_moving(&mut self, mw: &MovingWrite) -> Result<u64, Error> {
9633 let base = self.superblock.base_address;
9634 match mw {
9635 MovingWrite::Contiguous {
9636 region,
9637 addr_off,
9638 bytes,
9639 ..
9640 } => {
9641 let raw = self.resolve_overwrite_bytes(bytes)?;
9642 let new_data_addr = self.alloc_or_append_typed(&raw, PageType::Raw)?;
9643 let mut region = region.clone();
9644 // The placement is an absolute file offset; the contiguous
9645 // data-layout field stores it relative to the userblock base (`-
9646 // base`, a no-op on a base-0 file).
9647 let relative = base.relative(new_data_addr)?.get();
9648 region.bytes_mut()[*addr_off..*addr_off + 8]
9649 .copy_from_slice(&relative.to_le_bytes());
9650 // The data size field follows the 8-byte address in the contiguous
9651 // layout body; keep it in sync with the new length.
9652 let size_off = *addr_off + 8;
9653 let raw_len = raw.len() as u64;
9654 region.bytes_mut()[size_off..size_off + 8].copy_from_slice(&raw_len.to_le_bytes());
9655 let oh = build_v2_object_header(®ion)?;
9656 self.alloc_or_append_typed(&oh, PageType::Meta)
9657 }
9658 MovingWrite::Compact { region, bytes } => {
9659 let raw = self.resolve_overwrite_bytes(bytes)?;
9660 let region = rebuild_compact_layout_region(region, &raw)?;
9661 let oh = build_v2_object_header(®ion)?;
9662 self.alloc_or_append_typed(&oh, PageType::Meta)
9663 }
9664 MovingWrite::Chunked {
9665 region,
9666 shape,
9667 chunk_dims,
9668 element_size,
9669 maxshape,
9670 pipeline_message,
9671 payload,
9672 ..
9673 } => {
9674 let deferred;
9675 let chunk_bytes = match payload {
9676 ChunkPayload::Encoded(chunk_bytes) => chunk_bytes,
9677 ChunkPayload::Deferred { bytes, padding, dt } => {
9678 // Resolve before splitting: the heap collections have to
9679 // be placed, and their addresses patched into the element
9680 // references, before those references are cut into chunks
9681 // and — on a filtered dataset — compressed over.
9682 let raw = self.resolve_overwrite_bytes(bytes)?;
9683 deferred = split_and_encode_chunks(
9684 &raw,
9685 shape,
9686 chunk_dims,
9687 *element_size,
9688 padding,
9689 pipeline_message.as_deref(),
9690 dt,
9691 )?;
9692 &deferred
9693 }
9694 };
9695 let meta: Vec<ChunkMeta> = chunk_bytes
9696 .iter()
9697 .map(|c| ChunkMeta {
9698 compressed_size: c.len() as u64,
9699 filter_mask: 0,
9700 })
9701 .collect();
9702 self.write_chunked_relocatable(
9703 region,
9704 shape,
9705 chunk_dims,
9706 *element_size,
9707 maxshape.as_deref(),
9708 pipeline_message.as_deref(),
9709 &meta,
9710 chunk_bytes,
9711 DenseAttrSet::default(),
9712 )
9713 }
9714 MovingWrite::AppendedChunks {
9715 region,
9716 new_dataspace_body,
9717 chunk_dims_u32,
9718 element_size,
9719 has_filters,
9720 kept_chunks,
9721 new_chunk_bytes,
9722 ..
9723 } => self.write_appended_chunks(
9724 region,
9725 new_dataspace_body,
9726 chunk_dims_u32,
9727 *element_size,
9728 *has_filters,
9729 kept_chunks,
9730 new_chunk_bytes,
9731 ),
9732 MovingWrite::AttrEdit { region, attrs } => {
9733 // `region` already carries what the commit preflight could resolve;
9734 // `place_edited_attrs` places the rest, exactly as the
9735 // group-attribute apply loop does. Then build and place the
9736 // relocated dataset header: the data-layout message is untouched,
9737 // so the dataset's chunk data and index stay in place and only the
9738 // header moves.
9739 let mut region = region.clone();
9740 self.place_edited_attrs(&mut region, attrs.clone())?;
9741 let oh = build_v2_object_header(®ion)?;
9742 self.alloc_or_append_typed(&oh, PageType::Meta)
9743 }
9744 }
9745 }
9746
9747 /// Apply a relocating append ([`MovingWrite::AppendedChunks`]): place the new
9748 /// (and any rewritten trailing) chunk bytes, rebuild a fresh
9749 /// Extensible Array over the kept plus appended chunks, grow the dataspace and
9750 /// repoint the data layout in the verbatim header `region`, and write the
9751 /// relocated header. Returns the new header address; the caller patches the
9752 /// parent link. The kept chunk data is untouched (referenced by both the old
9753 /// and new index during the commit); the old index/header/trailing chunk are
9754 /// freed only after the superblock repoint.
9755 ///
9756 /// The new chunks and the new index go down together where one freed region
9757 /// holds them and a paged file is what makes that worth asking, and one at a
9758 /// time otherwise; the body says why.
9759 #[expect(
9760 clippy::too_many_arguments,
9761 reason = "the append rebuild needs the header region, grown dataspace, chunk \
9762 geometry, and both chunk sets; bundling them into a struct would only move the list"
9763 )]
9764 fn write_appended_chunks(
9765 &mut self,
9766 region: &OhRegion,
9767 new_dataspace_body: &[u8],
9768 chunk_dims_u32: &[u32],
9769 element_size: NonZeroUsize,
9770 has_filters: bool,
9771 kept_chunks: &[WrittenChunk],
9772 new_chunk_bytes: &[Vec<u8>],
9773 ) -> Result<u64, Error> {
9774 let base = self.superblock.base_address;
9775 // Where the appended chunks and the rebuilt index go, and whether they go
9776 // together.
9777 //
9778 // Contiguity is worth something on a *paged* file. The index goes in a raw
9779 // page, not a metadata one, because that is where this crate puts every
9780 // chunk index — and the reclaim side can only tell a raw index from the
9781 // metadata one the reference library writes by the chunk data it abuts
9782 // ([`index_is_provably_raw`](Self::index_is_provably_raw)). An index that
9783 // abuts none of its chunks cannot be placed by the commit that supersedes
9784 // it, so its bytes are held as dead until the page around them empties.
9785 // Laying the new chunks and the new index down as one blob — the way the
9786 // from-scratch writer and `write_chunked_relocatable` lay out chunked
9787 // storage — keeps that proof available (issue #388).
9788 //
9789 // It is worth nothing on a file that is not paged, which has no page types
9790 // to keep apart, and it must never be bought with reuse: one blob needs one
9791 // freed region big enough for the lot, where the chunks placed one at a
9792 // time fill several smaller ones. So the blob form is taken only when a
9793 // single region already holds it, and otherwise each chunk is placed on its
9794 // own and the index after them. That fallback keeps the proof only where
9795 // the last chunk placed ends up immediately below the index — which is what
9796 // happens when both append at end-of-file, and not what happens when the
9797 // chunks find holes to sit in. An index left abutting nothing is recorded
9798 // as dead rather than free, which costs space and never correctness.
9799 //
9800 // Sizing runs at a provisional base, exactly as `write_chunked_relocatable`
9801 // sizes its blob: an Extensible Array's length follows the slot layout and
9802 // every address it embeds sits in a fixed-width field, so the total is the
9803 // same wherever the array lands, and `place` rejects any disagreement.
9804 //
9805 // The element width comes from the chunk geometry rather than from the
9806 // chunk list, so a rebuild declares the same width the original index did —
9807 // including when the dataset it grows was created empty.
9808 let chunk_bytes =
9809 full_chunk_bytes(chunk_dims_u32.iter().map(|&d| u64::from(d)), element_size);
9810 // Rank 1 and unlimited along axis 0 (`prepare_append` refuses anything
9811 // else), so every chunk's index slot is its position in the grid and the
9812 // array is dense from zero.
9813 //
9814 // This engine applies every filter to a new chunk (no per-chunk skipping),
9815 // so an appended chunk's mask is always 0. Kept chunks carry their own
9816 // (possibly nonzero) mask.
9817 let chunk_total: u64 = new_chunk_bytes.iter().map(|cb| cb.len() as u64).sum();
9818 let placed_chunks = |blob_stored: StoredAddress| -> Vec<WrittenChunk> {
9819 let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9820 let mut next = blob_stored;
9821 for cb in new_chunk_bytes {
9822 combined.push(WrittenChunk {
9823 address: next,
9824 compressed_size: cb.len() as u64,
9825 filter_mask: 0,
9826 });
9827 next = next.offset(cb.len() as u64);
9828 }
9829 combined
9830 };
9831 let sizing = placed_chunks(StoredAddress::new(0));
9832 let ea_len = extensible_array_len(
9833 &crate::chunked_write::IndexSlots::dense(&sizing),
9834 chunk_bytes,
9835 OFFSET_SIZE,
9836 LENGTH_SIZE,
9837 has_filters,
9838 );
9839 let ea = |slots: &crate::chunked_write::IndexSlots<'_>,
9840 at: StoredAddress|
9841 -> Result<Vec<u8>, Error> {
9842 build_extensible_array_at(
9843 slots,
9844 chunk_bytes,
9845 OFFSET_SIZE,
9846 LENGTH_SIZE,
9847 has_filters,
9848 at,
9849 )
9850 .map_err(Error::Format)
9851 };
9852 // Reserved by hand rather than through `reserve`, which would fall back to
9853 // end-of-file: this asks only whether a freed region holds the whole blob,
9854 // and takes the per-chunk path when none does.
9855 let blob = match self.paged {
9856 Some(_) => self.alloc_free(chunk_total + ea_len, PageType::Raw),
9857 None => None,
9858 };
9859 let ea_stored = match blob {
9860 Some(addr) => {
9861 // The region is out of the free lists from here, so every way the
9862 // build can fail has to put it back: a `?` straight out of this arm
9863 // would leave it neither free nor written for the rest of the
9864 // session. Collected into a `Result` and handed back on the way out.
9865 let placed = (|| -> Result<StoredAddress, Error> {
9866 let blob_stored = base.relative(addr)?;
9867 let combined = placed_chunks(blob_stored);
9868 let mut buf =
9869 Vec::with_capacity(usize::try_from(chunk_total + ea_len).unwrap_or(0));
9870 for cb in new_chunk_bytes {
9871 buf.extend_from_slice(cb);
9872 }
9873 buf.extend_from_slice(&ea(
9874 &crate::chunked_write::IndexSlots::dense(&combined),
9875 blob_stored.offset(chunk_total),
9876 )?);
9877 self.place(
9878 Placement::Reused {
9879 addr,
9880 len: chunk_total + ea_len,
9881 },
9882 &buf,
9883 )?;
9884 Ok(blob_stored.offset(chunk_total))
9885 })();
9886 match placed {
9887 Ok(ea_stored) => ea_stored,
9888 Err(e) => {
9889 self.release_raw_alloc(addr, chunk_total + ea_len);
9890 return Err(e);
9891 }
9892 }
9893 }
9894 None => {
9895 // A chunk embeds no addresses of its own, so it can go anywhere and
9896 // the index below simply records where it went.
9897 let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9898 for cb in new_chunk_bytes {
9899 let abs = self.alloc_or_append_typed(cb, PageType::Raw)?;
9900 combined.push(WrittenChunk {
9901 address: base.relative(abs)?,
9902 compressed_size: cb.len() as u64,
9903 filter_mask: 0,
9904 });
9905 }
9906 let (ea_addr, ()) = self.place_relocatable(ea_len, PageType::Raw, |at| {
9907 Ok((
9908 ea(&crate::chunked_write::IndexSlots::dense(&combined), at)?,
9909 (),
9910 ))
9911 })?;
9912 base.relative(ea_addr)?
9913 }
9914 };
9915
9916 // Swap the dataspace (grown) and data-layout (repointed at the new index)
9917 // messages; every other header message is preserved verbatim.
9918 #[expect(
9919 clippy::cast_possible_truncation,
9920 reason = "element size is a datatype byte width that fits u32"
9921 )]
9922 let layout_body = serialize_v4_extensible_array(
9923 chunk_dims_u32,
9924 ea_stored,
9925 OFFSET_SIZE,
9926 element_size.get() as u32,
9927 );
9928 let region = replace_dataspace_message(region, new_dataspace_body)?;
9929 let region = replace_layout_message(®ion, &layout_body)?;
9930 let oh = build_v2_object_header(®ion)?;
9931 self.alloc_or_append_typed(&oh, PageType::Meta)
9932 }
9933
9934 /// Append `bytes` at end-of-file, returning the absolute address they were
9935 /// written at.
9936 fn append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
9937 self.image.append(bytes)
9938 }
9939
9940 /// Overwrites bytes in place at `offset`. The caller guarantees the range
9941 /// already exists.
9942 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
9943 self.image.write_at(offset, bytes)
9944 }
9945
9946 /// Ensure the next allocation begins in a page holding page type `ty`, on a
9947 /// paged file. A no-op on the common non-paged file.
9948 ///
9949 /// A paged file never mixes metadata and raw data within one page, so when the
9950 /// tail page holds the *other* type and is only partially filled it is first
9951 /// padded to a page boundary, the padding being recorded as free space of the
9952 /// outgoing type.
9953 ///
9954 /// Call this **before** reading the image's end-of-file ([`Source::len`]) to compute an
9955 /// address that will be embedded in the bytes being built: several callers
9956 /// (the chunk blob, the extensible-array index, the dense-attribute blob)
9957 /// build content whose interior addresses assume it lands at the current
9958 /// end-of-file, and padding inserted after that read would shift the landing
9959 /// address out from under them.
9960 fn begin_page(&mut self, ty: PageType) -> Result<(), Error> {
9961 // Destructure so the page state and the image are borrowed as the
9962 // separate fields they are.
9963 let Self { image, paged, .. } = self;
9964 match paged.as_mut() {
9965 Some(pg) => pg.begin(image.as_mut(), ty),
9966 None => Ok(()),
9967 }
9968 }
9969
9970 /// Place `bytes` as page type `ty`, reusing a free region where one fits.
9971 /// Equivalent to [`reserve`](Self::reserve) followed by [`place`](Self::place),
9972 /// for the callers whose bytes are already built.
9973 fn alloc_or_append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
9974 let at = self.reserve(bytes.len() as u64, ty)?;
9975 self.place(at, bytes)
9976 }
9977
9978 /// Choose where `len` bytes of page type `ty` will go: a reusable free region
9979 /// left by a prior commit, or the current end-of-file. The returned
9980 /// [`Placement`] must be handed to [`place`](Self::place) with exactly `len`
9981 /// bytes.
9982 ///
9983 /// Splitting the choice from the write is what lets a *relocatable* blob — one
9984 /// whose interior addresses are all `base + fixed offset`, like a chunked
9985 /// dataset's data region or a dense attribute heap — be built for a freed
9986 /// region rather than only for end-of-file: its size is known from its plan
9987 /// before its bytes exist, so the address can be settled first
9988 /// ([`place_relocatable`](Self::place_relocatable) is that sequence).
9989 ///
9990 /// Reuse only ever draws from free space vacated by *earlier* commits in this
9991 /// session (or seeded from the on-disk managers at open) — never space the
9992 /// current commit is about to free, which stays in its `to_free` list until
9993 /// after the superblock repoint. The bytes it overwrites are therefore already
9994 /// unreachable from the on-disk root, so a mid-commit crash cannot corrupt the
9995 /// live tree: the superblock still points at the prior, intact root.
9996 ///
9997 /// A paged file reuses within the matching page type, or out of a page holding
9998 /// nothing at all, which belongs to no type ([`PagedEdit::alloc_typed`]); every
9999 /// page stays homogeneous either way. It never opens a page for a reused region
10000 /// — the tail page is untouched by a write into the middle of the file.
10001 fn reserve(&mut self, len: u64, ty: PageType) -> Result<Placement, Error> {
10002 if let Some(addr) = self.alloc_free(len, ty) {
10003 return Ok(Placement::Reused { addr, len });
10004 }
10005 self.begin_page(ty)?;
10006 Ok(Placement::Appended {
10007 addr: self.image.len(),
10008 len,
10009 })
10010 }
10011
10012 /// Draw `len` bytes from the free space of page type `ty`, or `None` when no
10013 /// single region is large enough.
10014 ///
10015 /// A non-paged file keeps one list for the whole file. A paged file keeps one
10016 /// per page type and must be served from the list matching `ty`: handing a
10017 /// metadata hole to raw data (or the reverse) would mix the two within a page,
10018 /// which is the single invariant the paged strategy exists to hold. A page
10019 /// holding nothing at all is the exception, and
10020 /// [`alloc_typed`](PagedEdit::alloc_typed) is where it is spent.
10021 fn alloc_free(&mut self, len: u64, ty: PageType) -> Option<u64> {
10022 let Some(pg) = self.paged.as_mut() else {
10023 return self.free.alloc(len);
10024 };
10025 pg.alloc_typed(len, ty)
10026 }
10027
10028 /// Hand `[addr, addr + len)`, drawn from
10029 /// [`alloc_free`](Self::alloc_free) with [`PageType::Raw`], back to the list
10030 /// it came from — for a placement refused *after* the draw, which would
10031 /// otherwise hold the region for the rest of the session without writing
10032 /// anything into it.
10033 ///
10034 /// A paged file takes it back into the raw list whichever list served it: a
10035 /// whole page [`PagedEdit::alloc_typed`] claimed from the metadata side for
10036 /// raw data is raw from the moment it is claimed, and that call already
10037 /// returns its alignment tail there. A page that ends up wholly free is
10038 /// promoted out of either list by
10039 /// [`promote_whole_free_pages`](PagedEdit::promote_whole_free_pages) as usual.
10040 fn release_raw_alloc(&mut self, addr: u64, len: u64) {
10041 match self.paged.as_mut() {
10042 Some(pg) => pg.raw.free(addr, len),
10043 None => self.free.free(addr, len),
10044 }
10045 }
10046
10047 /// Write `bytes` at the address [`reserve`](Self::reserve) handed out,
10048 /// returning that address.
10049 ///
10050 /// The length is checked rather than asserted: a reused region is sized to the
10051 /// reservation and is followed by live bytes, so writing more than was reserved
10052 /// would silently destroy a neighboring object. That makes it the one internal
10053 /// miscount worth paying a comparison to catch in every build.
10054 fn place(&mut self, at: Placement, bytes: &[u8]) -> Result<u64, Error> {
10055 if bytes.len() as u64 != at.len() {
10056 return Err(Error::Format(FormatError::SerializationError(format!(
10057 "a placement reserved {} bytes but built {}",
10058 at.len(),
10059 bytes.len()
10060 ))));
10061 }
10062 match at {
10063 Placement::Reused { addr, .. } => {
10064 self.write_at(addr, bytes)?;
10065 Ok(addr)
10066 }
10067 Placement::Appended { addr, .. } => {
10068 let written = self.append(bytes)?;
10069 debug_assert_eq!(
10070 written, addr,
10071 "an appended placement must land at end-of-file"
10072 );
10073 Ok(written)
10074 }
10075 }
10076 }
10077
10078 /// Place a *relocatable* blob of `len` bytes as page type `ty`: pick its
10079 /// address first, then build it for that address with `build`, then write it
10080 /// there. Returns the address and whatever else `build` produced (typically the
10081 /// object-header message naming the blob, which embeds the same address).
10082 ///
10083 /// `build` receives the address the blob will occupy. Every address the blob
10084 /// embeds is written in that same form, which a reader resolves against the
10085 /// file's base address.
10086 ///
10087 /// `len` must be the length `build` will produce; [`place`](Self::place)
10088 /// rejects a mismatch. For every blob placed this way the length is a function
10089 /// of the content alone — the addresses sit in fixed-width fields — so every
10090 /// caller derives it, and none builds the blob to measure it:
10091 /// [`DenseAttrPlan::blob_len`](crate::file_writer::DenseAttrPlan::blob_len)
10092 /// for a dense attribute heap, [`extensible_array_len`] for an appended
10093 /// chunk index, and [`chunked_data_len`] / [`plan_chunked_data_verbatim`]
10094 /// for a whole chunked data region, both of which size their index through
10095 /// [`chunk_index_len`](crate::chunked_write::chunk_index_len).
10096 fn place_relocatable<T>(
10097 &mut self,
10098 len: u64,
10099 ty: PageType,
10100 build: impl FnOnce(StoredAddress) -> Result<(Vec<u8>, T), Error>,
10101 ) -> Result<(u64, T), Error> {
10102 let at = self.reserve(len, ty)?;
10103 let (bytes, extra) = build(self.superblock.base_address.relative(at.address())?)?;
10104 let addr = self.place(at, &bytes)?;
10105 Ok((addr, extra))
10106 }
10107
10108 /// Place one variable-length dataset's or attribute's already-built,
10109 /// self-contained global heap collections (from
10110 /// [`build_global_heap_collections`] or a
10111 /// [`VlStringStaging::collections`]) and return, in the same order, the
10112 /// base-relative addresses its variable-length references should be patched
10113 /// to. A `GCOL` blob embeds no addresses of its own, so it can be appended
10114 /// (or dropped into reused free space) at any point in the apply loop,
10115 /// unlike a group or dataset header, which must be built last so it can name
10116 /// its children's real addresses. Each collection is placed independently,
10117 /// so they need not land contiguously.
10118 fn place_vl_collections(&mut self, collections: &[Vec<u8>]) -> Result<Vec<u64>, Error> {
10119 collections
10120 .iter()
10121 .map(|collection| {
10122 let addr = self.alloc_or_append_typed(collection, PageType::Meta)?;
10123 Ok(self.superblock.base_address.relative(addr)?.get())
10124 })
10125 .collect()
10126 }
10127
10128 /// Resolve a staged value overwrite's element bytes: place any global heap
10129 /// collections its variable-length references name, patch their addresses
10130 /// in, and return the bytes ready to write (issue #321). An overwrite that
10131 /// stages nothing — every one but a `with_vlen_strings` one — is handed
10132 /// back untouched.
10133 ///
10134 /// This allocates, so every caller is in the apply phase — the preflight
10135 /// that chose the plan only reads.
10136 ///
10137 /// It also carries the reclaim. The collections a *previous* overwrite of
10138 /// this same path placed are handed to the commit's free list, and the ones
10139 /// placed here are recorded in their stead, so rotating a dataset's strings
10140 /// reaches a steady state instead of leaking a generation per commit. Only
10141 /// collections this session placed are ever reclaimed, and only while
10142 /// nothing has been able to name them twice — see
10143 /// [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) for why that provenance
10144 /// is the whole proof, and `repack` for the collections it cannot make.
10145 fn resolve_overwrite_bytes<'b>(
10146 &mut self,
10147 bytes: &'b OverwriteBytes,
10148 ) -> Result<Cow<'b, [u8]>, Error> {
10149 let Some(vlen) = &bytes.vlen else {
10150 // Nothing to resolve, and nothing to copy: every overwrite but a
10151 // variable-length one takes this path, and the in-place loop writes
10152 // straight through the borrow. Cloning here would charge each of
10153 // them a second copy of its whole payload, which the bounded
10154 // backing in particular is built not to pay.
10155 return Ok(Cow::Borrowed(&bytes.raw));
10156 };
10157 let staging = &vlen.staging;
10158 // Read rather than removed: the record is replaced (not consumed) when
10159 // the new collections land just below.
10160 let superseded = self
10161 .vl_overwrite_heaps
10162 .get(&vlen.path)
10163 .cloned()
10164 .unwrap_or_default();
10165 let mut raw = bytes.raw.clone();
10166 let base = self.superblock.base_address;
10167 // A staging with no collections patches nothing, which is the right
10168 // answer for it: an element with no heap object keeps the zero address
10169 // that reads back as null. It falls out of the general path rather than
10170 // being a case of its own — `patch_vl_refs_masked` has one offset per
10171 // object, so no offsets is no iterations — and it still records (an
10172 // empty record) and still supersedes.
10173 let addrs = self.place_vl_collections(&staging.collections)?;
10174 patch_vl_refs_masked(&mut raw, &staging.patch_offsets, &addrs);
10175 // `place_vl_collections` answers base-relative, since that is what an
10176 // element reference stores; the free list is absolute.
10177 let placed = addrs
10178 .iter()
10179 .zip(&staging.collections)
10180 .map(|(&a, c)| Ok((base.absolute(StoredAddress::new(a))?, c.len() as u64)))
10181 .collect::<Result<Vec<_>, FormatError>>()?;
10182 self.vl_overwrite_heaps.insert(vlen.path.clone(), placed);
10183 self.superseded_heaps.extend(superseded);
10184 Ok(Cow::Owned(raw))
10185 }
10186
10187 /// Forget every heap collection this session recorded as exclusively one
10188 /// dataset's, because `staged` contains an edit that could name one of them
10189 /// a second time (issue #321).
10190 ///
10191 /// The provenance in [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) says a
10192 /// collection was named once *when it was placed*. Two staged edits can
10193 /// falsify that afterwards, and both do it by re-emitting element bytes
10194 /// somebody else's references live in:
10195 ///
10196 /// - a **copy**, in-file or cross-file, which duplicates a dataset's element
10197 /// references verbatim. This is not hypothetical: an in-file copy of a
10198 /// variable-length dataset is exactly how two datasets come to name one
10199 /// collection.
10200 /// - a **raw-bytes write** over a datatype that reaches a heap address
10201 /// ([`datatype_holds_file_address`]). `with_raw_data` hands the engine
10202 /// element bytes it does not interpret, so a caller that read them from
10203 /// one dataset and wrote them to another has aliased whatever they named —
10204 /// the same unscreened door issue #317 recorded for object references.
10205 ///
10206 /// Wholesale rather than per-path: what a copy's source references is not
10207 /// known without decoding it, and a proof that has to be argued per edit is
10208 /// the kind that goes stale. Reclaim is an optimization, and giving all of
10209 /// it up on a rare edit costs a leak, where keeping one entry too long costs
10210 /// a freed collection somebody still reads.
10211 fn invalidate_heap_provenance(&mut self, staged: &StagedEdits) {
10212 if self.vl_overwrite_heaps.is_empty() {
10213 return;
10214 }
10215 let aliasing_edit = !staged.copies.is_empty()
10216 || !staged.cross_copies.is_empty()
10217 // A dataset staged with element bytes of its own (`with_raw_data`)
10218 // rather than staging its strings here, whether it is being created
10219 // or overwritten.
10220 || staged
10221 .writes
10222 .iter()
10223 .chain(&staged.datasets)
10224 .any(|(_, fd)| {
10225 fd.vl_string_staging.is_none()
10226 && datatype_holds_file_address(&fd.dt)
10227 });
10228 if aliasing_edit {
10229 self.vl_overwrite_heaps.clear();
10230 return;
10231 }
10232 // A deleted dataset's record would otherwise outlive it and be applied
10233 // to whatever later takes its path.
10234 for path in &staged.deletes {
10235 self.vl_overwrite_heaps
10236 .retain(|recorded, _| !recorded.overlaps(path));
10237 }
10238 }
10239
10240 /// Resolves one object-reference element's target to the address stored on
10241 /// disk. [`ObjectRefTarget::Raw`] is written back verbatim: a null or an
10242 /// undefined reference points at nothing, and the whole-file writer treats
10243 /// it the same way. [`ObjectRefTarget::Path`] resolves, in order:
10244 ///
10245 /// 1. Against `path_addr` — every group and dataset this commit has
10246 /// already placed (a sibling dataset placed earlier in the same
10247 /// group's batch — see the apply loop's non-reference-first ordering —
10248 /// or a descendant subtree fully processed earlier in the deepest-first
10249 /// walk).
10250 /// 2. Against the pre-commit on-disk file
10251 /// ([`resolve_path_any`](crate::group_v2::resolve_path_any)), but only
10252 /// when the path is untouched by this commit, so its pre-commit
10253 /// address is guaranteed to still be valid post-commit. "Touched"
10254 /// means: a dirty group (`nodes`, new or merely rewritten because an
10255 /// addition lives under it — its own address changes either way); a
10256 /// path this commit adds, or that lies under a subtree this commit
10257 /// copies in (`add_targets`, checked by prefix so a copy's interior is
10258 /// covered even though only its root is enumerated there); or a
10259 /// `write_dataset` target (`write_targets`) — conservatively refused
10260 /// even for a same-length overwrite that does not actually relocate,
10261 /// since resolving that distinction here is not worth the complexity.
10262 /// 3. If the path resolves nowhere at all (neither this commit nor the
10263 /// pre-commit file has ever heard of it), as an undefined reference
10264 /// (`HADDR_UNDEF`) — mirroring [`ObjectRefTarget::Path`]'s existing
10265 /// whole-file-writer resolution convention for the same builder type.
10266 ///
10267 /// A path that step 1 misses but step 2 identifies as commit-touched is
10268 /// refused with a clear [`Error::EditUnsupported`] rather than resolved to
10269 /// a stale or wrong address — the one case this engine cannot resolve
10270 /// without the whole-file writer's two-pass dummy/real-address scheme.
10271 ///
10272 /// A path this same commit *deletes* (`delete_targets`) is refused for a
10273 /// different reason, and so carries its own message: the address step 2
10274 /// would resolve is not stale but doomed, and the next commit to reuse the
10275 /// span leaves a reference that dereferenced cleanly reading whatever
10276 /// landed there. The test is by **prefix**, because a deletion takes the
10277 /// whole subtree with it (`collect_free_spans` walks it): a reference to a
10278 /// child of a deleted group dangles exactly as a reference to the group
10279 /// itself does (issue #314).
10280 ///
10281 /// It is a conservative test in the same sense `write_targets` is. A child
10282 /// whose object survives the delete through another hard link keeps its
10283 /// address — `collect_free_spans` reclaims nothing when the incoming count
10284 /// is not 1 — and is refused here anyway, so the message states what the
10285 /// commit does rather than what the allocator will conclude.
10286 ///
10287 /// The delete test runs *after* the other three so that a replacement this
10288 /// commit has merely not placed yet — a sibling group later in the same
10289 /// depth band — is reported as an ordering problem rather than as a
10290 /// deletion. That ordering is a better default, not a partition: because
10291 /// `add_targets` claims a replaced path's whole subtree by prefix, a child
10292 /// the replacement does *not* recreate is genuinely doomed and still
10293 /// reports "still writing". Distinguishing it would mean enumerating what
10294 /// a replacement actually rebuilds, which neither list holds. A path the
10295 /// commit puts back is resolved by step 1 before either test is reached.
10296 fn resolve_reference_target(
10297 target: &ObjectRefTarget,
10298 path_addr: &BTreeMap<ObjectPathBuf, u64>,
10299 nodes: &BTreeMap<ObjectPathBuf, Node>,
10300 add_targets: &[ObjectPathBuf],
10301 write_targets: &[ObjectPathBuf],
10302 delete_targets: &[ObjectPathBuf],
10303 invalidated: &InvalidatedAddresses,
10304 src: &(impl Source + ?Sized),
10305 superblock: &Superblock,
10306 ) -> Result<StoredAddress, Error> {
10307 let path = match target {
10308 // An address carries no name to test, so the delete check the path
10309 // arm makes by prefix is made here on the address itself.
10310 //
10311 // It fires on nothing today: every `Raw` this crate stages is a
10312 // sentinel, because `repack`'s faithful re-emit resolves a real
10313 // target to a `Path` and leaves only the null and undefined
10314 // references raw. It is here because the variant carries an
10315 // arbitrary address, and a producer that staged a real one would
10316 // otherwise write it past the screen its `Path` twin gets — which
10317 // is the shape of this bug in the first place (issue #317).
10318 ObjectRefTarget::Raw(addr) => {
10319 if let Some(refusal) = invalidated.refusal(*addr) {
10320 return Err(Error::EditUnsupported(refusal));
10321 }
10322 return Ok(*addr);
10323 }
10324 ObjectRefTarget::Path(path) => path,
10325 };
10326 let base = superblock.base_address;
10327 if let Some(&addr) = path_addr.get(path) {
10328 return Ok(base.relative(addr)?);
10329 }
10330 if nodes.contains_key(path)
10331 || add_targets.iter().any(|t| path.starts_with(t))
10332 || write_targets.contains(path)
10333 {
10334 return Err(Error::EditUnsupported(
10335 "an object-reference dataset targets a path this commit is still writing; \
10336 use separate commits",
10337 ));
10338 }
10339 // After the three above; see this function's doc for why, and for what
10340 // that ordering does and does not buy.
10341 if delete_targets.iter().any(|d| path.starts_with(d)) {
10342 return Err(Error::EditUnsupported(
10343 "an object-reference dataset targets an object this commit deletes, or one \
10344 under it; the reference would be left pointing at storage the delete can \
10345 reclaim",
10346 ));
10347 }
10348 Ok(
10349 match crate::group_v2::resolve_path_any_from_source(
10350 src,
10351 AccessMode::ReadWrite,
10352 superblock,
10353 &path.as_path(),
10354 ) {
10355 Ok(addr) => base.relative(addr)?,
10356 Err(_) => StoredAddress::new(UNDEF),
10357 },
10358 )
10359 }
10360
10361 /// Prove, before any byte of this commit is written, that every
10362 /// object-reference target across every staged dataset will resolve
10363 /// successfully — either against a pre-existing untouched object or
10364 /// against something this same commit places. [`resolve_reference_target`]
10365 /// classifies a target purely from *whether* an [`ObjectPathBuf`] has been placed
10366 /// yet (`path_addr.get`), never from the address *value*, so replaying the
10367 /// apply loop's placement order here with a placeholder address standing in
10368 /// for "already placed" reproduces the exact same verdict the apply loop's
10369 /// own calls will reach later, without writing anything.
10370 ///
10371 /// The placeholder is the file's **base address**, not zero. Any value
10372 /// reproduces the verdict, but it is also handed to the same `addr - base`
10373 /// that converts a real address into its stored form: a zero placeholder
10374 /// underflows that on every file with a userblock, panicking in a debug
10375 /// build on an otherwise legal edit. At the base address it converts to a
10376 /// stored `0`, which is exactly the "placed, real address not known yet"
10377 /// this stands for. If
10378 /// this preflight pass returns `Ok`, none of the apply loop's own
10379 /// `resolve_reference_target` calls can fail, so a reference-resolution
10380 /// error can no longer leave earlier-processed groups' real writes
10381 /// orphaned in the file (the failure surfaces here instead, before the
10382 /// apply loop's first `place`/`write_at`).
10383 ///
10384 /// [`resolve_reference_target`]: Self::resolve_reference_target
10385 fn preflight_reference_targets(
10386 keys: &[ObjectPathBuf],
10387 flat: &BTreeMap<&ObjectPathBuf, Vec<&FlatDataset>>,
10388 nodes: &BTreeMap<ObjectPathBuf, Node>,
10389 add_targets: &[ObjectPathBuf],
10390 write_targets: &[ObjectPathBuf],
10391 delete_targets: &[ObjectPathBuf],
10392 invalidated: &InvalidatedAddresses,
10393 src: &(impl Source + ?Sized),
10394 superblock: &Superblock,
10395 ) -> Result<(), Error> {
10396 let mut by_depth = keys.to_vec();
10397 // Stable on purpose, for the same reason as the apply loop's copy: this
10398 // simulation is only faithful if it walks the groups in that same order.
10399 by_depth.sort_by_key(|k| std::cmp::Reverse(k.len()));
10400 let mut sim_addr: BTreeMap<ObjectPathBuf, u64> = BTreeMap::new();
10401 for key in &by_depth {
10402 if let Some(datasets) = flat.get(key) {
10403 // Mirrors the apply loop's `group_datasets.sort_by_key(|fd|
10404 // fd.reference_targets.is_some())`: non-reference datasets are
10405 // placed (and so become resolvable) before any reference
10406 // dataset in the same group.
10407 let mut ordered: Vec<&FlatDataset> = datasets.to_vec();
10408 // Stable on purpose, like the loop it mirrors.
10409 ordered.sort_by_key(|fd| fd.reference_targets.is_some());
10410 for fd in ordered {
10411 if let Some(patches) = &fd.reference_targets {
10412 for patch in patches {
10413 Self::resolve_reference_target(
10414 &patch.target,
10415 &sim_addr,
10416 nodes,
10417 add_targets,
10418 write_targets,
10419 delete_targets,
10420 invalidated,
10421 src,
10422 superblock,
10423 )?;
10424 }
10425 }
10426 sim_addr.insert(key.join(&fd.name), superblock.base_address.get());
10427 }
10428 }
10429 sim_addr.insert(key.clone(), superblock.base_address.get());
10430 }
10431 Ok(())
10432 }
10433
10434 /// Lay out a chunked / filtered / extensible dataset and return its object
10435 /// header bytes (which the caller links into the parent group).
10436 ///
10437 /// The chunk data and index (fixed-array / extensible-array, with any filter
10438 /// pipeline applied) are produced as one relocatable blob, whose internal
10439 /// layout — and therefore total size — is independent of the base address it
10440 /// is given. That is what lets the dataset be *sized before it is placed*
10441 /// ([`chunked_data_len`]): the blob goes into a freed region big enough to
10442 /// hold it, or at end-of-file when there is none, and is then assembled for
10443 /// whichever address it got, so every absolute address it embeds (chunk
10444 /// addresses, index-structure addresses, the addresses in the data-layout
10445 /// message) lands exactly where the bytes are written. The header is then
10446 /// built with [`build_chunked_dataset_oh`] — the same function the whole-file
10447 /// writer uses — so the header is byte-identical to one written fresh.
10448 ///
10449 /// The dataset's single pass of the filter pipeline happens in
10450 /// [`compress_chunks`], before either decision; sizing and assembly work from
10451 /// the compressed set, so choosing an address never costs a recompression
10452 /// (issue #261).
10453 fn build_chunked_dataset(&mut self, fd: &FlatDataset) -> Result<Vec<u8>, Error> {
10454 let chunk_dims = fd.chunk_options.resolve_chunk_dims(&fd.ds.dimensions);
10455 let ctx = ChunkContext::from_datatype(&chunk_dims, &fd.dt)?;
10456 // The overhang of a partial edge chunk holds the staged fill value
10457 // (issue #296).
10458 let elem = ctx.element_size.narrow::<NonZeroUsize>()?;
10459 let fill = crate::fill_value::FillPattern::new(fd.fill.as_deref(), elem);
10460 let set = compress_chunks(
10461 &fd.raw,
10462 &fd.ds.dimensions,
10463 ctx,
10464 &fd.chunk_options,
10465 fd.maxshape.as_deref(),
10466 fill,
10467 // `flatten_dataset` refuses an unallocated dataset outright, so
10468 // every dataset this engine builds allocates its storage.
10469 StorageAllocation::Allocated,
10470 )?;
10471 // Chunk data and the index beside it are raw (see `chunked_storage_spans`,
10472 // which reclaims both as raw).
10473 let (_addr, (layout_message, pipeline_message)) =
10474 self.place_relocatable(chunked_data_len(&set)?, PageType::Raw, |stored_base| {
10475 let result = assemble_chunked_at(&set, stored_base)?;
10476 Ok((
10477 result.data_bytes,
10478 (result.layout_message, result.pipeline_message),
10479 ))
10480 })?;
10481 // As in the contiguous branch: a set too large for the header goes to a
10482 // fractal heap, which the header then names instead of carrying it.
10483 let attr_info = self.place_dense_attrs_if_needed(fd)?;
10484 Ok(build_chunked_dataset_oh(
10485 &fd.dt,
10486 &DatatypeLocation::Inline,
10487 &fd.ds,
10488 &layout_message,
10489 pipeline_message.as_deref(),
10490 &fd.attrs,
10491 attr_info.as_deref(),
10492 fd.fill.as_deref(),
10493 )?)
10494 }
10495
10496 /// Returns the on-disk byte spans `(addr, len)` of every chunk of the
10497 /// version 2 object header at `addr`: chunk 0 (signature, prefix, messages,
10498 /// checksum) plus each continuation (`OCHK`) block.
10499 ///
10500 /// Used to reclaim a header's storage when its object is deleted. An error
10501 /// (propagated from [`oh_region_at`] or a malformed continuation) means the
10502 /// header is not a plain v2 header this engine can fully account for, and
10503 /// the caller leaves it as dead bytes of unknown extent.
10504 fn oh_chunk_spans(&self, addr: u64) -> Result<Vec<(u64, u64)>, Error> {
10505 Ok(
10506 read_oh_chunks(&self.image(), addr, self.superblock.base_address)?
10507 .into_iter()
10508 .map(|chunk| chunk.span)
10509 .collect(),
10510 )
10511 }
10512
10513 /// Count, for every object-header address reachable from the root, how many
10514 /// hard links in the *pre-commit* file point to it. The result drives the
10515 /// last-hard-link reclaim guard in [`collect_free_spans`](Self::collect_free_spans):
10516 /// an object is freed only when its count is 1.
10517 ///
10518 /// Walks the whole link graph from the root, following hard links through
10519 /// groups of any on-disk format (v0/v1 symbol-table, v2 compact, v2 dense)
10520 /// via [`resolve_group_entries_from_source`], tallying each hard-link edge. Datasets and
10521 /// other leaves contribute no edges. Returns `None` — so the caller reclaims
10522 /// nothing for the deletions, a safe leak — if the graph cannot be walked in
10523 /// full: an unparseable header, a group whose links cannot be enumerated, or
10524 /// more than [`MAX_LINK_GRAPH_NODES`] objects. Cycles are handled by visiting
10525 /// each object once. Base-aware: stored child addresses are shifted by the
10526 /// userblock base, so the returned keys are absolute file offsets.
10527 fn count_incoming_hard_links(&self) -> Option<HashMap<u64, u32>> {
10528 let os = self.superblock.offset_size;
10529 let ls = self.superblock.length_size;
10530 let base = self.superblock.base_address;
10531 let mut counts: HashMap<u64, u32> = HashMap::new();
10532 let mut visited: HashSet<u64> = HashSet::new();
10533 let mut stack: Vec<u64> = vec![self.superblock.root_group_address];
10534 let mut budget = MAX_LINK_GRAPH_NODES;
10535 while let Some(addr) = stack.pop() {
10536 if !visited.insert(addr) {
10537 continue; // already expanded (also breaks hard-link cycles)
10538 }
10539 if budget == 0 {
10540 return None; // graph larger than we will walk; leak conservatively
10541 }
10542 budget -= 1;
10543 let header = ObjectHeader::parse_from_source(
10544 &self.image(),
10545 AccessMode::ReadWrite,
10546 addr,
10547 os,
10548 ls,
10549 base,
10550 )
10551 .ok()?;
10552 // Datasets and other leaves are not groups and own no links.
10553 let is_group = header.messages.iter().any(|m| {
10554 matches!(
10555 m.msg_type,
10556 MessageType::SYMBOL_TABLE | MessageType::LINK | MessageType::LINK_INFO
10557 )
10558 });
10559 if !is_group {
10560 continue;
10561 }
10562 // A group we cannot enumerate fully would undercount incoming links
10563 // and risk over-reclaim; bail to the safe-leak fallback instead.
10564 let entries =
10565 resolve_group_entries_from_source(&self.image(), &header, os, ls, base).ok()?;
10566 for e in entries {
10567 let child = base.absolute(e.object_header_address).ok()?;
10568 *counts.entry(child).or_insert(0) += 1;
10569 stack.push(child);
10570 }
10571 }
10572 Some(counts)
10573 }
10574
10575 /// Collects, as far as it can account for them, the span and free class of
10576 /// every on-disk block the object at `addr` owns, and for a group its whole
10577 /// subtree, into `out` for reclamation after a delete.
10578 ///
10579 /// Contiguous datasets (header + data block), chunked datasets (header +
10580 /// chunk index + chunk data, via [`chunked_storage_spans`](Self::chunked_storage_spans)),
10581 /// and whole group subtrees are reclaimed. Deliberately conservative: any
10582 /// object whose layout it cannot fully account for — a non-v2 header, an
10583 /// unsupported or only-partially-enumerable chunk index, a group holding a
10584 /// soft/external link, dense attribute storage — contributes nothing and is
10585 /// not descended into, so `out` never names a region that might still be in
10586 /// use. Bounded by [`MAX_COPY_DEPTH`] against a hard-link cycle.
10587 /// Variable-length data in global-heap collections is never reclaimed here (a
10588 /// collection can be shared between objects), so it is simply left behind.
10589 ///
10590 /// `incoming` is the file-wide hard-link count per object-header address
10591 /// (from [`count_incoming_hard_links`](Self::count_incoming_hard_links)). An
10592 /// object is reclaimed — and, for a group, descended into — only when its
10593 /// count is exactly 1, i.e. the link being removed is its last: an object
10594 /// still reachable through another hard link is live and is left untouched
10595 /// (so is everything below a surviving group), which is what keeps deleting
10596 /// one of several hard links from corrupting the survivor.
10597 fn collect_free_spans(
10598 &self,
10599 addr: u64,
10600 depth: u32,
10601 incoming: &HashMap<u64, u32>,
10602 out: &mut Vec<(u64, u64, FreeClass)>,
10603 ) {
10604 // `addr` is an absolute file offset (the caller resolves it from the live
10605 // file, and the group recursion below re-absolutizes each child). `incoming`
10606 // is keyed by absolute offset, and `oh_chunk_spans`/`chunked_storage_spans`
10607 // both take an absolute address and return absolute spans, so the whole
10608 // walk works in absolute file offsets. The one shift this method must apply
10609 // itself is on the stored addresses `read_object` returns for a contiguous
10610 // data block and a group's child links: each is converted to an absolute
10611 // offset (a no-op on a base-0 file) before it is bounds-checked, recorded,
10612 // or descended into.
10613 let base = self.superblock.base_address;
10614 let file_len = self.image().len();
10615 if depth >= MAX_COPY_DEPTH {
10616 return;
10617 }
10618 // Reclaim only when this delete removes the object's last hard link. A
10619 // count other than 1 (it has surviving links, or the graph walk could
10620 // not account for it) means the object — and a group's whole subtree —
10621 // stays live and must not be freed.
10622 if incoming.get(&addr) != Some(&1) {
10623 return;
10624 }
10625 // The header's own chunks. If they cannot be mapped, account for nothing.
10626 let spans = match self.oh_chunk_spans(addr) {
10627 Ok(s) => s,
10628 Err(_) => return,
10629 };
10630 match Self::read_object(
10631 &self.image(),
10632 AccessMode::ReadWrite,
10633 addr,
10634 self.superblock.base_address,
10635 ) {
10636 Ok(ObjModel::DatasetVerbatim { .. }) => out.extend(meta_spans(spans)),
10637 Ok(ObjModel::DatasetContiguous {
10638 data_addr,
10639 data_size,
10640 ..
10641 }) => {
10642 out.extend(meta_spans(spans));
10643 // A defined, in-bounds contiguous data block is owned outright;
10644 // an empty dataset stores the undefined address and owns none. Its
10645 // absolute file offset is what this bounds-checks and records.
10646 if !data_addr.is_undefined(OFFSET_SIZE) && data_size > 0 {
10647 if let Ok(abs) = base.absolute(data_addr) {
10648 if abs.checked_add(data_size).is_some_and(|e| e <= file_len) {
10649 // A contiguous data block is raw data.
10650 out.push((abs, data_size, FreeClass::Page(PageType::Raw)));
10651 }
10652 }
10653 }
10654 }
10655 Ok(ObjModel::Group { children, .. }) => {
10656 out.extend(meta_spans(spans));
10657 // The recursion works in absolute offsets, matching `incoming`'s
10658 // keys and `oh_chunk_spans`.
10659 for (_, _, child) in children {
10660 if let Ok(c) = base.absolute(child) {
10661 self.collect_free_spans(c, depth + 1, incoming, out);
10662 }
10663 }
10664 }
10665 // A chunked dataset: reclaim its chunk index and chunk data blocks
10666 // alongside its header. `chunked_storage_spans` returns `None` for
10667 // anything it cannot account for exhaustively (an index type with no
10668 // walker, an undefined index address, or spans that fail the
10669 // bounds/overlap check), leaving the whole dataset as dead bytes
10670 // rather than freeing a region that might still be in use.
10671 Ok(ObjModel::DatasetChunked { .. }) => {
10672 if let Some(storage) = self.chunked_storage_spans(addr) {
10673 out.extend(meta_spans(spans));
10674 // Already page-typed: chunk data raw, index structure metadata.
10675 out.extend(storage);
10676 }
10677 }
10678 // A truly unsupported object (one `read_object` cannot model): leave
10679 // its bytes in place rather than guess its extent.
10680 //
10681 // An externally stored dataset lands here as of #336, where before it
10682 // was modelled as the contiguous dataset it structurally is and had
10683 // its header chunks reclaimed. Deleting one therefore leaves those
10684 // chunks behind: measured on the fixture in
10685 // `crates/crosscheck/tests/main/external_storage.rs`, a commit that deletes it
10686 // reports 147 B reusable where it reported 431 B. That is a leak and
10687 // not a hazard — `oh_chunk_spans` never covered the local heap the
10688 // External Data Files message names either, so neither the old
10689 // behaviour nor this one accounted for the whole object — and it is
10690 // the price of stating the refusal once, in the one function both the
10691 // copy planner and this walk read objects through.
10692 Err(_) => {}
10693 }
10694 }
10695
10696 /// Returns every on-disk block a *chunked* dataset at `addr` owns: its
10697 /// chunk index structure (B-tree v1 nodes, or fixed- / extensible-array
10698 /// header, index, super, and data blocks) plus every allocated chunk data
10699 /// block.
10700 ///
10701 /// The object-header chunks are freed by the caller
10702 /// ([`collect_free_spans`](Self::collect_free_spans)). This returns only the
10703 /// storage the data-layout message points at.
10704 ///
10705 /// Returns `None` — contribute nothing, leave the object as dead bytes —
10706 /// whenever the dataset cannot be enumerated *exhaustively* and safely: a
10707 /// header that does not parse or is not a chunked dataset, a chunk index
10708 /// with no walker (a version 2 B-tree, index type 5), an undefined index
10709 /// address (an empty, never-written dataset), or any resulting span that
10710 /// falls outside the file image or overlaps another. This upholds the
10711 /// editor's invariant that reclaimed space is never a region still in use:
10712 /// under-reclaiming only wastes space, while over-reclaiming would corrupt.
10713 ///
10714 /// Chunk data addresses and sizes come from the same index walkers the
10715 /// reader uses, so they match the bytes the writer laid down exactly. The
10716 /// per-layout enumeration lives in
10717 /// [`chunked_read::collect_chunked_storage_spans`](crate::chunked_read::collect_chunked_storage_spans);
10718 /// this method only locates the layout and dataspace messages and validates
10719 /// the result. Variable-length data in global-heap collections is still
10720 /// never reclaimed (a collection can be shared between objects); see the
10721 /// [module docs](self).
10722 fn chunked_storage_spans(&self, addr: u64) -> Option<Vec<(u64, u64, FreeClass)>> {
10723 // Locate the data-layout and dataspace messages in the object header.
10724 let region =
10725 Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address).ok()?;
10726 let mut layout_msg: Option<(usize, usize)> = None;
10727 let mut dataspace_msg: Option<(usize, usize)> = None;
10728 let mut p = 0;
10729 loop {
10730 match region.next_message(p) {
10731 Ok(Some((msg_type, body, body_end))) => {
10732 match msg_type {
10733 MessageType::DATA_LAYOUT => layout_msg = Some((body, body_end)),
10734 MessageType::DATASPACE => dataspace_msg = Some((body, body_end)),
10735 _ => {}
10736 }
10737 p = body_end;
10738 }
10739 Ok(None) => break,
10740 Err(_) => return None,
10741 }
10742 }
10743 let (lb, le) = layout_msg?;
10744 let (db, de) = dataspace_msg?;
10745
10746 let layout = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10747 if !matches!(layout, DataLayout::Chunked { .. }) {
10748 return None;
10749 }
10750 let dataspace = Dataspace::parse(®ion[db..de], LENGTH_SIZE).ok()?;
10751
10752 // Delegate the per-index-type enumeration to the chunked reader (the
10753 // single owner of chunk-storage layout knowledge), then validate: every
10754 // span must lie inside the current file image and be pairwise disjoint,
10755 // or the free list would later hand out live bytes (and a debug build
10756 // would panic on the double-free). On any error or violation, leave the
10757 // whole dataset unreclaimed rather than free a region still in use.
10758 //
10759 // The layout's stored addresses are relative to the userblock base, so the
10760 // enumeration runs on a base-relative view of the file and each returned
10761 // span address is shifted back to an absolute file offset by adding `base`
10762 // (a no-op on a base-0 file). The free list and the bounds check below both
10763 // work in absolute file offsets.
10764 let base = self.superblock.base_address;
10765 let split = crate::chunked_read::collect_chunked_storage_spans(
10766 &BaseOffsetSource {
10767 inner: &self.image(),
10768 base,
10769 },
10770 &layout,
10771 &dataspace,
10772 OFFSET_SIZE,
10773 LENGTH_SIZE,
10774 )
10775 .ok()?;
10776 // Chunk data is raw under every writer, so its spans are raw outright. The
10777 // index is only raw where this crate placed it — see
10778 // [`index_is_provably_raw`](Self::index_is_provably_raw) — and is recorded
10779 // as dead rather than free otherwise.
10780 let mut data: Vec<(u64, u64)> = Vec::with_capacity(split.data.len());
10781 for (addr, len) in split.data {
10782 data.push((base.absolute(StoredAddress::new(addr)).ok()?, len));
10783 }
10784 let mut index: Vec<(u64, u64)> = Vec::with_capacity(split.index.len());
10785 for (addr, len) in split.index {
10786 index.push((base.absolute(StoredAddress::new(addr)).ok()?, len));
10787 }
10788 // Validate both halves together — they must be disjoint from each other as
10789 // well as internally — before either is trusted, including by the proof.
10790 let mut plain: Vec<(u64, u64)> = data.iter().chain(index.iter()).copied().collect();
10791 if !spans_disjoint_in_bounds(&mut plain, self.image.len()) {
10792 return None;
10793 }
10794 let index_class = if self.index_is_provably_raw(&data, &index) {
10795 FreeClass::Page(PageType::Raw)
10796 } else {
10797 FreeClass::Dead
10798 };
10799 let mut spans: Vec<(u64, u64, FreeClass)> = data
10800 .iter()
10801 .map(|&(a, l)| (a, l, FreeClass::Page(PageType::Raw)))
10802 .collect();
10803 spans.extend(index.into_iter().map(|(a, l)| (a, l, index_class)));
10804 Some(spans)
10805 }
10806
10807 /// Whether a chunked dataset's `index` provably occupies raw pages, given the
10808 /// `data` spans it accompanies. Both are absolute file offsets.
10809 ///
10810 /// A chunk index is *metadata* by the format's taxonomy, and the reference C
10811 /// library allocates one as such — out of metadata pages, nowhere near the
10812 /// chunk data. This crate instead lays a dataset's index down in the same run
10813 /// as its chunk data, inside raw pages, so that a reader following the layout
10814 /// message walks one contiguous blob. Both are valid; what matters on a paged
10815 /// file is that freeing an index records it under the page type it actually
10816 /// sits in, since that decides what a later allocation may overwrite there.
10817 ///
10818 /// Nothing in the file says which writer produced it, so the index is placed
10819 /// from the layout itself, by two tests it must pass together: some chunk-data
10820 /// span must **abut** it — ending exactly where the index begins, or beginning
10821 /// exactly where it ends — and no byte of it may lie in **page 0**.
10822 ///
10823 /// The abutment is this crate's single blob, and is not a layout the reference
10824 /// library sets out to produce. A chunk index is metadata to that library,
10825 /// allocated out of metadata pages — and those are allocated from the bottom
10826 /// of the file, while large raw data goes above, so its index lands far below
10827 /// the chunk data it indexes rather than against it. Measured across seven
10828 /// files it wrote (Extensible and Fixed Array indexes, page sizes
10829 /// 512/4096/8192, 8 to 16 chunks): the index began in page 0 every time, with
10830 /// the chunk data starting at page 9 or higher, so neither join address was
10831 /// within reach of it.
10832 ///
10833 /// The page-0 screen closes the one way that measured layout can nonetheless
10834 /// satisfy the abutment. An index block that ends exactly on a page boundary
10835 /// with a raw page after it — a Fixed Array data block that exactly fills its
10836 /// page, say — is abutted by the first chunk in that raw page, and the whole
10837 /// index run would then be filed as raw, its header included, *even though
10838 /// that header sits in page 0 beside the superblock*. A later raw allocation
10839 /// would land inside a metadata page, which is the page-mixing defect of
10840 /// issue #261. Page 0 of a paged file always begins with the superblock, so it
10841 /// is a metadata page by construction and an index with a byte in it cannot be
10842 /// raw whatever abuts it. The screen costs this crate nothing: it allocates
10843 /// chunk data and the indexes beside it out of raw pages, and page 0 — never
10844 /// wholly free, since the superblock lives there — is never one of them.
10845 ///
10846 /// The tighter rule considered instead was to require the join address to fall
10847 /// *strictly inside* a page, which proves rawness outright: the byte before
10848 /// the join and the byte after it are then in one page, and a page holding
10849 /// chunk data is raw. It is sound, and a paged file the reference library
10850 /// wrote cannot satisfy it at all, since such a file never puts raw and
10851 /// metadata bytes in one page and so can only ever join them on a boundary.
10852 /// It was rejected because it also refuses layouts *this* crate produces:
10853 /// whenever a blob's chunk bytes total a page multiple from a page-aligned
10854 /// start, its index begins on a boundary too. Measured over 100
10855 /// `append_staged` commits at a 4096-byte page with 2048-byte chunks, that
10856 /// stranded about 500 bytes per commit — 62 KB against the 10 KB the abutment
10857 /// rule left — because the two writers produce the same geometry there and
10858 /// nothing in the file distinguishes them.
10859 ///
10860 /// Both sides have to be admitted, not just the index that follows its data.
10861 /// A staged append keeps the existing chunks where they are and places the new
10862 /// ones past the index it is superseding, so from the second append onward the
10863 /// dataset has chunk data on both sides of that index and the highest data
10864 /// address is no longer the one beside it. Asking only whether the index
10865 /// begins where the *last* chunk ends refused every such index, and a
10866 /// repeatedly appended dataset dropped one per commit (issue #388).
10867 ///
10868 /// An index the test does not place is [dead](FreeClass::Dead) to the caller
10869 /// rather than free: its bytes are no longer in use, but advertising space
10870 /// inside a possible metadata page for raw reuse would mix the page
10871 /// (issue #261), so they are reusable only once the whole page around them is
10872 /// empty.
10873 ///
10874 /// A non-paged file has no page types to keep apart, so everything is
10875 /// reclaimable there; the tag is ignored by its commit tail entirely.
10876 fn index_is_provably_raw(&self, data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
10877 match &self.paged {
10878 None => true,
10879 Some(paged) => {
10880 index_abuts_chunk_data(data, index)
10881 && !index_touches_page_zero(index, paged.page_size)
10882 }
10883 }
10884 }
10885
10886 /// Returns every on-disk byte span of a chunked dataset's *index structure
10887 /// only* (not its chunk data), for reclaiming the old index after a
10888 /// relocating append ([`MovingWrite::AppendedChunks`]) that keeps the chunk
10889 /// data in place.
10890 ///
10891 /// This is the index-only counterpart of
10892 /// [`chunked_storage_spans`](Self::chunked_storage_spans), delegating to
10893 /// [`chunk_index_spans_from_source`], which enumerates the index structure's
10894 /// own blocks (B-tree v1 nodes, or fixed- / extensible-array header, index,
10895 /// super, and data blocks) and never a chunk-data address, so the shared
10896 /// kept chunk data is never freed. Base-aware and validated
10897 /// disjoint/in-bounds. Returns `None` (leave unreclaimed) on any error or
10898 /// violation.
10899 fn chunked_index_spans(&self, addr: u64) -> Option<Vec<(u64, u64)>> {
10900 let region =
10901 Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address).ok()?;
10902 let mut layout_msg: Option<(usize, usize)> = None;
10903 let mut p = 0;
10904 loop {
10905 match region.next_message(p) {
10906 Ok(Some((msg_type, body, body_end))) => {
10907 if msg_type == MessageType::DATA_LAYOUT {
10908 layout_msg = Some((body, body_end));
10909 }
10910 p = body_end;
10911 }
10912 Ok(None) => break,
10913 Err(_) => return None,
10914 }
10915 }
10916 let (lb, le) = layout_msg?;
10917 let layout = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10918 if !matches!(layout, DataLayout::Chunked { .. }) {
10919 return None;
10920 }
10921 let base = self.superblock.base_address;
10922 let mut spans = chunk_index_spans_from_source(
10923 &BaseOffsetSource {
10924 inner: &self.image(),
10925 base,
10926 },
10927 &layout,
10928 OFFSET_SIZE,
10929 LENGTH_SIZE,
10930 )
10931 .ok()?;
10932 for (a, _) in &mut spans {
10933 *a = base.absolute(StoredAddress::new(*a)).ok()?;
10934 }
10935 if !spans_disjoint_in_bounds(&mut spans, self.image.len()) {
10936 return None;
10937 }
10938 Some(spans)
10939 }
10940}
10941
10942/// A dirty group in the edit plan: its base object-header message region and the
10943/// additions targeting it.
10944#[derive(Default)]
10945struct Node {
10946 is_new: bool,
10947 /// Names of links to remove from this group (from `delete`).
10948 deletes: Vec<LinkNameBuf>,
10949 /// Copies to add to this group: (new link name, the source subtree read out
10950 /// for writing). Built at staging time from either this file (an in-file
10951 /// [`copy`](crate::File::copy)) or another open file (a cross-file
10952 /// [`copy_from`](crate::File::copy_from)).
10953 copies: Vec<(LinkNameBuf, CopyTree)>,
10954 /// New link names this commit adds to this group by cross-file copy. The
10955 /// subtrees themselves stay in the staged set — which the preflight may
10956 /// still refuse, and must not empty (issue #316) — and join `copies` at the
10957 /// point of no return.
10958 cross_copies: Vec<LinkNameBuf>,
10959 /// Value overwrites whose dataset header relocates (a resize or compact
10960 /// rewrite by `write_dataset`, a staged append, an attribute edit), as (child
10961 /// link name, the pre-commit object-header address, the relocation plan). On
10962 /// apply, the new data and header are written and this group's existing link
10963 /// to the moved header is patched to its new address — exactly like an
10964 /// existing child group's link. The old address is carried rather than
10965 /// re-resolved: the screen above and the reclaim below both need it, and one
10966 /// derivation cannot disagree with itself.
10967 writes: Vec<(LinkNameBuf, u64, MovingWrite)>,
10968 base_region: OhRegion,
10969 existing_links: Vec<String>,
10970 /// What this group's attribute edits left for the apply loop, staged by
10971 /// [`plan_attr_ops`] and resolved by [`WriteEngine::place_edited_attrs`]
10972 /// right before this node's header is built. Default (an empty compact set)
10973 /// for a group with no attribute edits.
10974 attrs: EditedAttrs,
10975}
10976
10977/// A staged compact attribute edit for a group or dataset (shared by
10978/// [`Group::set_attr`](crate::Group::set_attr)/`remove_group_attr` and
10979/// [`Dataset::set_attr`](crate::Dataset::set_attr)/`remove_dataset_attr`).
10980enum AttrOp {
10981 Set { name: String, value: AttrValue },
10982 Remove { name: String },
10983}
10984
10985/// A source object parsed for copying. Headers are reproduced from their
10986/// verbatim message bytes; only the contiguous data address and child link
10987/// targets are repointed to the freshly-written copies.
10988enum ObjModel {
10989 /// A compact dataset (data inline in the header): copy the region verbatim.
10990 /// `dense_attrs` is empty unless the source stored its attributes densely, in
10991 /// which case the Attribute Info message and inline Attribute messages have
10992 /// been stripped from `region` and the parsed set is carried here to be
10993 /// re-emitted into a fresh fractal heap on write.
10994 DatasetVerbatim {
10995 region: OhRegion,
10996 dense_attrs: DenseAttrSet,
10997 },
10998 /// A contiguous dataset: copy the region, repointing the data address at
10999 /// `addr_off` (region-relative) to a fresh copy of `[data_addr, +data_size)`.
11000 /// See [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11001 DatasetContiguous {
11002 region: OhRegion,
11003 addr_off: usize,
11004 data_addr: StoredAddress,
11005 data_size: u64,
11006 dense_attrs: DenseAttrSet,
11007 },
11008 /// A chunked (and possibly filtered) dataset: the verbatim header `region`
11009 /// (datatype, dataspace, fill value, data layout, and filter pipeline kept as
11010 /// written). The chunk data is not captured here — [`read_copy_subtree`](WriteEngine::read_copy_subtree)
11011 /// enumerates and reads the chunks (it holds the source buffer), repointing the
11012 /// rebuilt index on write. See [`DatasetVerbatim`](ObjModel::DatasetVerbatim)
11013 /// for `dense_attrs`.
11014 DatasetChunked {
11015 region: OhRegion,
11016 dense_attrs: DenseAttrSet,
11017 },
11018 /// A group: every non-link message verbatim, plus its hard-link children to
11019 /// copy and re-link as `(name, creation index, target address)`. See
11020 /// [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11021 Group {
11022 non_link_region: OhRegion,
11023 children: Vec<(String, Option<u64>, StoredAddress)>,
11024 dense_attrs: DenseAttrSet,
11025 },
11026}
11027
11028/// An object subtree fully read out of a source buffer and owning every byte it
11029/// will write, the read result of [`WriteEngine::read_copy_subtree`] and the
11030/// input to [`WriteEngine::write_copy_subtree`]. Unlike [`ObjModel`] (a single
11031/// object still referencing source addresses) it is recursive and self-contained:
11032/// a contiguous dataset owns its data bytes, and a group owns its children, so it
11033/// can be written into the destination without the source buffer still in hand —
11034/// which is what lets a cross-file copy read the source at staging time and apply
11035/// it at commit time.
11036enum CopyTree {
11037 /// A compact dataset: the header region is written verbatim (data is inline).
11038 /// `dense_attrs`, when non-empty, is re-emitted into a freshly built fractal
11039 /// heap appended just before the header, whose Attribute Info message is
11040 /// spliced into the region on write.
11041 DatasetVerbatim {
11042 region: OhRegion,
11043 dense_attrs: DenseAttrSet,
11044 },
11045 /// A contiguous dataset: `data` is written first and its new address patched
11046 /// into the header `region` at `addr_off` before the header is written. See
11047 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11048 ///
11049 /// `data` is `None` when the source allocated no storage at all: nothing is
11050 /// written and `addr_off` keeps the undefined address the source stored, so
11051 /// the copy declares the same absent storage rather than a block at a real
11052 /// address. That is a statement about storage, not about length (issue
11053 /// #336).
11054 DatasetContiguous {
11055 region: OhRegion,
11056 addr_off: usize,
11057 data: Option<Vec<u8>>,
11058 dense_attrs: DenseAttrSet,
11059 },
11060 /// A chunked (and possibly filtered) dataset. The header `region` is written
11061 /// verbatim except its data-layout message, which is swapped for one naming the
11062 /// freshly rebuilt index; `chunk_bytes` (each chunk's already-compressed bytes,
11063 /// in dense row-major grid order, with sizes/masks in `meta`) and the source
11064 /// `pipeline_message` are carried unchanged, so the copy preserves the filter
11065 /// pipeline and chunk payloads byte-for-byte. The on-disk index *type* is
11066 /// reselected from `maxshape`/chunk count (single / fixed-array / extensible-
11067 /// array), so a B-tree-v1 or implicit source is reproduced with a v4 index. See
11068 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11069 DatasetChunked {
11070 region: OhRegion,
11071 /// The dataset's current shape. Held because the index's element
11072 /// numbering is taken over the *maximum* chunk grid, which needs both
11073 /// extents (see [`crate::chunk_grid`]).
11074 shape: Vec<u64>,
11075 chunk_dims: Vec<u64>,
11076 element_size: NonZeroUsize,
11077 maxshape: Option<Vec<MaxExtent>>,
11078 pipeline_message: Option<Vec<u8>>,
11079 meta: Vec<ChunkMeta>,
11080 chunk_bytes: Vec<Vec<u8>>,
11081 dense_attrs: DenseAttrSet,
11082 },
11083 /// A group: every non-link message verbatim, plus the `(name, creation
11084 /// index, subtree)` children to write first and re-link by name. See
11085 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11086 Group {
11087 non_link_region: OhRegion,
11088 children: Vec<(String, Option<u64>, CopyTree)>,
11089 dense_attrs: DenseAttrSet,
11090 },
11091}
11092
11093/// The addresses this commit invalidates, against which a reference it writes is
11094/// screened (issue #317).
11095///
11096/// An object reference *is* an object-header address, so a commit can falsify one
11097/// two ways. It can **remove** the object: a deletion frees its header and
11098/// storage, and a group's whole subtree with it
11099/// ([`WriteEngine::collect_free_spans`] is the walk that enumerates it), so the
11100/// address survives the commit only until the next allocation reuses the span and
11101/// then reads as whatever landed there. Or it can **move** it: a dirty group and
11102/// a relocating dataset write are both rebuilt at a fresh address, and the old
11103/// header is freed once the superblock is repointed.
11104///
11105/// Both are screened here because on the *path* side neither can come out as a
11106/// pre-commit address: [`WriteEngine::resolve_reference_target`] refuses a deleted
11107/// path by name, and a dirty group or a write target it either refuses as "still
11108/// writing" or — once this commit has placed the target — resolves to the address
11109/// it lands on. An address that skipped either check would be the one form that
11110/// writes the value the commit is vacating, which is the shape of the defect this
11111/// exists to close.
11112///
11113/// The removal spans are the very ones the commit hands to the free-space
11114/// manager, taken from one walk rather than two, so the screen and the reclaimer
11115/// cannot come to disagree about what this commit removes.
11116struct InvalidatedAddresses {
11117 /// Absolute `(offset, length)` file spans this commit's deletions reclaim, as
11118 /// [`collect_free_spans`](WriteEngine::collect_free_spans) reports them.
11119 removed: Vec<(u64, u64)>,
11120 /// Absolute pre-commit object-header addresses this commit rewrites
11121 /// elsewhere: every existing group it dirties, and every dataset write that
11122 /// relocates (a resizing or compact overwrite, a staged append, an attribute
11123 /// edit). An in-place overwrite does not relocate and is not here. The path
11124 /// side refuses it anyway, deliberately conservatively — `write_targets`
11125 /// records that a dataset is written, not which plan it got — while this list
11126 /// is filled from the plans themselves.
11127 moved: Vec<u64>,
11128 /// The superblock base address. A stored object reference is *base-relative*
11129 /// and both lists above are absolute, so the comparison needs it; it is zero
11130 /// for every file without a userblock.
11131 base: BaseAddress,
11132}
11133
11134impl InvalidatedAddresses {
11135 /// Whether this commit invalidates anything at all, so a caller with nothing
11136 /// cheap to check can skip its own work. Worth testing on the copy screen,
11137 /// whose `moved` is empty by construction; the supplied screen's never is,
11138 /// since every commit that builds one rebuilds its root group.
11139 fn is_empty(&self) -> bool {
11140 self.removed.is_empty() && self.moved.is_empty()
11141 }
11142
11143 /// The refusal `stored` earns — one object-reference element exactly as it is
11144 /// written to disk — or `None` when it still names what it named.
11145 ///
11146 /// The null (`0`) and undefined ([`UNDEF`]) references name no object at all,
11147 /// so neither is screened: the same two values
11148 /// [`crate::repack()`] copies through verbatim without
11149 /// resolving, and the two [`crate::reader`] refuses to dereference.
11150 fn refusal(&self, stored: StoredAddress) -> Option<&'static str> {
11151 if stored.get() == 0 || stored.get() == UNDEF {
11152 return None;
11153 }
11154 // An address that cannot even be shifted into the file is not one of
11155 // ours; leave it to whatever reads it.
11156 let abs = self.base.absolute(stored).ok()?;
11157 if self
11158 .removed
11159 .iter()
11160 .any(|&(off, len)| abs >= off && abs - off < len)
11161 {
11162 return Some(REFERENCE_INTO_RECLAIMED_SPACE);
11163 }
11164 if self.moved.contains(&abs) {
11165 return Some(REFERENCE_TO_A_MOVED_OBJECT);
11166 }
11167 None
11168 }
11169}
11170
11171/// The validated, chunk-collapsed message region and existing link names of a
11172/// group header.
11173struct GroupInfo {
11174 region: OhRegion,
11175 link_names: Vec<String>,
11176}
11177
11178/// Element bytes staged for a value overwrite, together with the
11179/// variable-length staging (if any) whose global heap collections must be
11180/// placed — and whose addresses patched into the element references in `raw` —
11181/// before those bytes reach the file.
11182///
11183/// The two halves travel together because resolving them is an **apply-phase**
11184/// step while planning is a preflight one: placing a collection allocates, and
11185/// [`commit`](WriteEngine::commit)'s preflight only reads. So a plan carries
11186/// the bytes unresolved and every consumer resolves them where it writes, with
11187/// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) (issue
11188/// #321).
11189///
11190/// Patching never changes `raw`'s length — an element reference is fixed-width
11191/// whatever string it names — which is what lets the plan be *chosen* from the
11192/// unresolved bytes. The one place that is not enough is a filtered chunked
11193/// dataset, whose compressed chunk sizes do depend on the addresses: see
11194/// [`ChunkPayload::Deferred`].
11195struct OverwriteBytes {
11196 raw: Vec<u8>,
11197 /// `None` for every overwrite that stages no variable-length data, which is
11198 /// all of them but a `with_vlen_strings` one.
11199 vlen: Option<VlenOverwrite>,
11200}
11201
11202/// The variable-length half of an [`OverwriteBytes`]: the staged collections to
11203/// place, and the path they are recorded under once placed.
11204struct VlenOverwrite {
11205 staging: VlStringStaging,
11206 /// The overwritten dataset's path, the key its collections are recorded
11207 /// under in [`vl_overwrite_heaps`](WriteEngine::vl_overwrite_heaps) — and
11208 /// the key the ones a previous overwrite left are read back from, to be
11209 /// freed once this commit lands.
11210 path: ObjectPathBuf,
11211}
11212
11213impl OverwriteBytes {
11214 /// Bytes that need no resolving, and so name nothing to record or free.
11215 fn ready(raw: Vec<u8>) -> Self {
11216 Self { raw, vlen: None }
11217 }
11218}
11219
11220/// How a staged value overwrite (`write_dataset`) will be applied, decided by
11221/// [`WriteEngine::prepare_write`] during the all-or-nothing preflight.
11222// `allow` rather than `expect`, which is otherwise this module's habit: the lint
11223// fires on a 64-bit pointer width and not on i686, where the `Vec` and `usize`
11224// fields inside `MovingWrite` shrink and the variant gap falls back under
11225// clippy's threshold. An `expect` is therefore unfulfilled on exactly the two
11226// 32-bit CI jobs, both of which deny `unfulfilled_lint_expectations`.
11227#[allow(
11228 clippy::large_enum_variant,
11229 reason = "boxing the large variant would cost more than it saves: this enum is returned by \
11230 `prepare_write` and destructured at its one call site, which moves the `MovingWrite` \
11231 straight into a `Vec<MovingWrite>` that stores it unboxed either way -- so a box \
11232 here adds an allocation and a free without removing a single copy"
11233)]
11234enum WritePlan {
11235 /// A contiguous dataset whose new data is the same length as its existing,
11236 /// defined data block: overwrite the bytes straight in place at `data_addr`.
11237 /// No object header is rewritten.
11238 ///
11239 /// The superblock root is not flipped either — *unless* the bytes still
11240 /// carry variable-length staging, which appends a heap collection and so
11241 /// moves end-of-file, a figure only the superblock records. `commit`'s
11242 /// fast path excludes exactly that case (issue #321).
11243 InPlace {
11244 data_addr: u64,
11245 bytes: OverwriteBytes,
11246 },
11247 /// A chunked dataset overwritten chunk-by-chunk in place: each `(addr, bytes)`
11248 /// pair is written straight over an existing chunk slot. Used when every new
11249 /// (re-encoded) chunk is the same byte length as the slot it replaces — an
11250 /// unfiltered chunked overwrite (chunk sizes are fixed by the unchanged shape)
11251 /// or a filtered one whose re-encoded chunks happen to match. Like
11252 /// [`InPlace`](WritePlan::InPlace) it touches no header and no chunk index, so
11253 /// the superblock root is not flipped.
11254 InPlaceChunks { writes: Vec<(u64, Vec<u8>)> },
11255 /// The dataset's header relocates: a contiguous resize, a compact rewrite, or
11256 /// a chunked rebuild. The parent group is rebuilt and its link patched. See
11257 /// [`MovingWrite`].
11258 Moving(MovingWrite),
11259}
11260
11261/// A value overwrite that relocates the dataset's object header — a contiguous
11262/// dataset whose data length changed (or had no data block) or a compact dataset
11263/// whose inline bytes are replaced. On apply the new data and a rewritten header
11264/// are written at end-of-file (or into reusable freed space), and the parent
11265/// group's link is repointed at the new header address.
11266enum MovingWrite {
11267 /// A contiguous dataset: write `raw` elsewhere, patch the data-layout address
11268 /// at `addr_off` in the verbatim header `region`, rewrite the header, and free
11269 /// `old_extent` (the prior data block, if any) after the commit lands.
11270 Contiguous {
11271 region: OhRegion,
11272 addr_off: usize,
11273 bytes: OverwriteBytes,
11274 old_extent: Option<(u64, u64)>,
11275 },
11276 /// A compact dataset: rebuild the header `region` with the bytes inline.
11277 Compact {
11278 region: OhRegion,
11279 bytes: OverwriteBytes,
11280 },
11281 /// A chunked dataset whose new (re-encoded) chunks do not all fit their
11282 /// existing slots, so its whole storage is rebuilt and relocated. A fresh
11283 /// chunk-data blob and index are placed — in a freed region that fits, else at
11284 /// end-of-file (via the verbatim
11285 /// layout path, carrying the chunk bytes and the source filter `pipeline_message`
11286 /// unchanged — no recompression and no filter-parameter reconstruction), the
11287 /// data-layout message in the verbatim header `region` is swapped for the new
11288 /// one (every other header message — datatype, dataspace, fill value, filter
11289 /// pipeline, and attributes, including a dense attribute heap referenced by an
11290 /// untouched Attribute Info message — is preserved verbatim), and the old
11291 /// chunk storage at `old_addr` is freed after the commit lands.
11292 Chunked {
11293 region: OhRegion,
11294 /// See [`CopyTree::DatasetChunked::shape`].
11295 shape: Vec<u64>,
11296 chunk_dims: Vec<u64>,
11297 element_size: NonZeroUsize,
11298 maxshape: Option<Vec<MaxExtent>>,
11299 pipeline_message: Option<Vec<u8>>,
11300 payload: ChunkPayload,
11301 old_addr: u64,
11302 },
11303 /// A relocating **append** to a chunked, unlimited, Extensible-Array-indexed
11304 /// dataset (`append_dataset`). The dataset's existing chunk *data* stays in
11305 /// place; only the newly-appended chunks and any rewritten trailing partial
11306 /// chunk (`new_chunk_bytes`, already compressed through the on-disk pipeline)
11307 /// are placed (reusing freed raw space where it fits), a fresh Extensible
11308 /// Array is rebuilt over
11309 /// `kept_chunks ++ new_chunk_bytes`, the verbatim header `region`'s dataspace
11310 /// message is grown (`new_dataspace_body`) and its data-layout message
11311 /// repointed at the new index (every other message — datatype, filter
11312 /// pipeline, fill value, attributes — preserved verbatim), and the header is
11313 /// relocated. After the commit lands, only the old index structure at
11314 /// `old_addr`, the old header, and the relocated old trailing chunk
11315 /// (`old_tail_extent`) are freed — never the kept chunk data, which both the
11316 /// old and new index share during the commit.
11317 AppendedChunks {
11318 region: OhRegion,
11319 /// The grown dataspace message body (v2-serialized), current axis-0
11320 /// dimension increased, maximum dimensions (unlimited) preserved.
11321 new_dataspace_body: Vec<u8>,
11322 /// Rank-only spatial chunk dimensions, for the rebuilt v4 layout message.
11323 chunk_dims_u32: Vec<u32>,
11324 element_size: NonZeroUsize,
11325 /// Full (uncompressed) chunk byte size = product(spatial) * element_size.
11326 has_filters: bool,
11327 /// Existing complete chunks, in index order, carried by metadata alone —
11328 /// their base-relative addresses, on-disk stored sizes, and filter masks
11329 /// preserved exactly (a nonzero mask from a C/h5py-skipped filter is kept).
11330 kept_chunks: Vec<WrittenChunk>,
11331 /// The appended chunks in index order: the recompressed trailing partial
11332 /// chunk first (when present), then the remaining new full chunks.
11333 new_chunk_bytes: Vec<Vec<u8>>,
11334 /// The dataset header address, for old-index and old-header reclaim.
11335 old_addr: u64,
11336 /// The absolute `(addr, len)` of the old trailing partial chunk's data
11337 /// block when it was rewritten, freed after the commit lands. `None` when
11338 /// the append was chunk-aligned (no partial chunk to rewrite).
11339 old_tail_extent: Option<(u64, u64)>,
11340 },
11341 /// A dataset-attribute edit (`set_dataset_attr` / `remove_dataset_attr`).
11342 /// The verbatim header `region` already carries whatever the preflight could
11343 /// resolve; `attrs` is what [`WriteEngine::write_moving`] still has to place —
11344 /// a variable-length attribute's heap collection, or a whole dense set to
11345 /// rebuild. The rewritten header is relocated and the parent link repointed,
11346 /// exactly like the other relocating writes — but the data-layout message is
11347 /// preserved verbatim, so the dataset's chunk data and index stay in place;
11348 /// only the old header is freed.
11349 AttrEdit {
11350 region: OhRegion,
11351 attrs: EditedAttrs,
11352 },
11353}
11354
11355/// The chunk data a relocating chunked overwrite ([`MovingWrite::Chunked`])
11356/// will place, either already encoded or still to be.
11357enum ChunkPayload {
11358 /// Split and encoded by the preflight, which had to do that anyway to find
11359 /// out whether the chunks still fit their slots.
11360 Encoded(Vec<Vec<u8>>),
11361 /// Element bytes that still carry unresolved variable-length references
11362 /// ([`OverwriteBytes::vlen`]), so the split has to wait for the apply
11363 /// phase: a filtered chunk's compressed length depends on the heap
11364 /// addresses patched into it, which is why the preflight cannot split
11365 /// first and patch after (issue #321).
11366 ///
11367 /// Such an overwrite therefore always relocates, which is what keeps the
11368 /// single-hard-link refusal (the one every [`MovingWrite`] answers to) in
11369 /// the preflight, where it belongs: whether it could have stayed in its
11370 /// slots is not knowable until those lengths exist.
11371 Deferred {
11372 bytes: OverwriteBytes,
11373 /// What the edge overhang of a partial chunk must hold (issue #296).
11374 padding: crate::fill_value::PaddingFill,
11375 /// The dataset's datatype, for the filter pipeline's [`ChunkContext`].
11376 dt: crate::datatype::Datatype,
11377 },
11378}
11379
11380/// A staged dataset reduced to the pieces the writer needs.
11381struct FlatDataset {
11382 name: LinkNameBuf,
11383 dt: crate::datatype::Datatype,
11384 ds: Dataspace,
11385 raw: Vec<u8>,
11386 attrs: Vec<crate::attribute::AttributeMessage>,
11387 /// Chunked/filtered storage options. When [`ChunkOptions::is_chunked`] is
11388 /// false and `maxshape` is `None`, the dataset is written as contiguous,
11389 /// unfiltered storage; otherwise its chunk data and index are built by
11390 /// [`WriteEngine::build_chunked_dataset`].
11391 chunk_options: ChunkOptions,
11392 /// Maximum dimensions for an extensible dataset, mirrored into
11393 /// `ds.max_dimensions`. `None` for a fixed-shape dataset. A maxshape with an
11394 /// unlimited dimension selects the extensible-array chunk index, and a
11395 /// finite maxshape the fixed-array or single-chunk one.
11396 maxshape: Option<Vec<MaxExtent>>,
11397 /// Variable-length attributes still carrying a placeholder heap address:
11398 /// (index into `attrs`, that attribute's global heap collections).
11399 /// Resolved in the apply loop right before this dataset's header is built.
11400 vl_attrs: Vec<(usize, Vec<Vec<u8>>)>,
11401 /// Whether `attrs` goes in a fractal heap rather than the object header,
11402 /// decided by `file_writer::needs_dense_attrs` where the set was validated
11403 /// against what that heap can represent.
11404 ///
11405 /// Carried rather than asked again in the apply loop, so the set that was
11406 /// checked and the set that is placed cannot be decided differently — and
11407 /// because the question costs a serialization of every attribute to answer,
11408 /// which is most expensive for exactly the oversized attribute it selects
11409 /// for. Patching a variable-length attribute's references is
11410 /// length-preserving, so nothing between the two phases can change the
11411 /// answer.
11412 attrs_are_dense: bool,
11413 /// A staged variable-length-string dataset's element references (still
11414 /// carrying placeholder heap addresses in `raw`) and global heap
11415 /// collections. Resolved in the apply loop right before `raw` is appended.
11416 vl_string_staging: Option<VlStringStaging>,
11417 /// An object-reference dataset's per-element targets, still unresolved.
11418 /// Resolved (see [`WriteEngine::resolve_reference_target`]) and patched
11419 /// into `raw` in the apply loop, once every object this commit places has
11420 /// a known address. `None` for an ordinary dataset.
11421 reference_targets: Option<Vec<ObjectRefPatch>>,
11422 /// A user-defined fill value, encoded in the dataset's datatype, or `None`
11423 /// for the library default. Validated against the datatype element size in
11424 /// [`flatten_dataset`].
11425 fill: Option<Vec<u8>>,
11426 /// The provenance attributes' own inputs, kept so they can be rebuilt when
11427 /// `raw` changes before the commit writes it. `None` for a dataset staged
11428 /// without [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
11429 #[cfg(feature = "provenance")]
11430 provenance: Option<StagedProvenance>,
11431}
11432
11433/// A staged dataset's provenance metadata, and where in
11434/// [`FlatDataset::attrs`] the attributes it produced sit.
11435///
11436/// The hash is over the dataset's raw bytes, so it is only correct as of the
11437/// bytes it was taken from; [`FlatDataset::rebuild_provenance`] is what keeps
11438/// it that way when [`WriteEngine::extend_staged_dataset`] grows them.
11439#[cfg(feature = "provenance")]
11440struct StagedProvenance {
11441 /// What [`crate::provenance::Provenance::build_attrs`] was, and is, called
11442 /// with. Held rather than re-derived from the attributes it produced, which
11443 /// a caller may also have set by hand.
11444 inputs: crate::provenance::Provenance,
11445 /// The index in `attrs` where its attributes begin. They are appended last
11446 /// and are the only ones this may replace, so everything below it — the
11447 /// caller's own attributes, and the `vl_attrs` indices into them — is left
11448 /// exactly as it was.
11449 attrs_start: usize,
11450}
11451
11452#[cfg(feature = "provenance")]
11453impl FlatDataset {
11454 /// Recompute this dataset's provenance attributes over the bytes it now
11455 /// holds. A no-op for a dataset staged without provenance.
11456 ///
11457 /// The rebuilt set is the same attributes in the same order and of the same
11458 /// serialized length — the digest is 64 hex characters whatever it hashes —
11459 /// so `attrs_are_dense`, decided once in [`flatten_dataset`] over the
11460 /// original set, still describes this one.
11461 fn rebuild_provenance(&mut self) {
11462 let Some(prov) = &self.provenance else {
11463 return;
11464 };
11465 self.attrs.truncate(prov.attrs_start);
11466 let rebuilt = prov.inputs.build_attrs(&self.raw);
11467 self.attrs.extend(rebuilt);
11468 }
11469}
11470
11471/// A borrow adapter that drives the shared Extensible-Array append engine
11472/// ([`crate::chunk_index_inplace`]) against the engine's *own* image and
11473/// superblock, so a session runs an immediate O(1) in-place append without
11474/// constructing a second writable handle (which would take a second exclusive
11475/// lock and keep a divergent view of the file). It borrows only those two
11476/// fields, leaving [`WriteEngine::located`] independently borrowable.
11477///
11478/// [`Store`] is the append engine's view of a file — an image *plus* the
11479/// superblock, which the image itself knows nothing about. Pairing them here is
11480/// all this adapter does; every primitive delegates, so the image's own
11481/// write-ordering discipline is what applies.
11482///
11483/// It carries the session's paged-file state too, so `alloc_raw` keeps a paged
11484/// file's pages homogeneous through exactly the rule the staged commit uses
11485/// ([`PagedEdit::begin`]). Before issue #198 there were two copies of that state —
11486/// one per engine — and the whole-file editor's copy was reachable only from the
11487/// commit path, so it had to refuse an in-place append to a paged file outright.
11488struct EditStore<'a> {
11489 image: &'a mut dyn FileImage,
11490 superblock: &'a mut Superblock,
11491 sb_sig_off: u64,
11492 /// The session's paged state when the file is paged, `None` otherwise. A
11493 /// borrow rather than a copy: padding recorded here has to reach the manager
11494 /// rewrite at the next commit or at close.
11495 paged: Option<&'a mut PagedEdit>,
11496 /// The list an immediate append may draw from, and `None` when it may draw
11497 /// from none — [`WriteEngine::immediate_reuse_allowed`] states which. It is
11498 /// the session's own free list on a file that forgets its holes at close, and
11499 /// [`WriteEngine::reserved`] — space already taken out of the on-disk
11500 /// managers — on one that persists them.
11501 free: Option<&'a mut FreeList>,
11502 /// The session's `fsync` cadence, carried by value: the append engine's own
11503 /// ordered barriers ([`apply_ea_append`]) are durability points like the
11504 /// commit's, and answer to the same policy.
11505 sync_policy: SyncPolicy,
11506}
11507
11508impl EditStore<'_> {
11509 /// Append `bytes` into a raw page, padding the tail page first when a paged
11510 /// file's tail holds metadata. A plain append on the common non-paged file.
11511 ///
11512 /// Raw is the only page type this adapter allocates: see
11513 /// [`Store::alloc_raw`] for why an extensible-array index block belongs in a
11514 /// raw page here.
11515 fn append_into_raw_page(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11516 if let Some(pg) = self.paged.as_deref_mut() {
11517 pg.begin(self.image, PageType::Raw)?;
11518 }
11519 self.image.append(bytes)
11520 }
11521}
11522
11523impl crate::source::Source for EditStore<'_> {
11524 fn len(&self) -> u64 {
11525 self.image.len()
11526 }
11527 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), crate::error::FormatError> {
11528 self.image.read_at(offset, buf)
11529 }
11530 fn read_metadata_at(
11531 &self,
11532 offset: u64,
11533 len: usize,
11534 ) -> Result<Vec<u8>, crate::error::FormatError> {
11535 self.image.read_metadata_at(offset, len)
11536 }
11537
11538 // Forwarded because this is a `Source` wrapper over the image and the
11539 // trait requires it, not because anything asks yet: `File`'s own accessor
11540 // reaches the image directly. A wrapper that answered `None` for a live
11541 // cache is the failure that rule exists to prevent.
11542 fn metadata_cache_stats(&self) -> Option<crate::source::MetadataCacheStats> {
11543 self.image.metadata_cache_stats()
11544 }
11545
11546 fn reset_metadata_cache_stats(&self) {
11547 self.image.reset_metadata_cache_stats();
11548 }
11549}
11550
11551impl Store for EditStore<'_> {
11552 fn offset_size(&self) -> u8 {
11553 self.superblock.offset_size
11554 }
11555 fn length_size(&self) -> u8 {
11556 self.superblock.length_size
11557 }
11558 fn alloc_raw(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11559 if let Some(free) = self.free.as_deref_mut() {
11560 if let Some(addr) = free.alloc(bytes.len() as u64) {
11561 self.image.write_at(addr, bytes)?;
11562 return Ok(addr);
11563 }
11564 }
11565 self.append_into_raw_page(bytes)
11566 }
11567 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
11568 self.image.write_at(offset, bytes)
11569 }
11570 fn patch_superblock_eof(&mut self) -> Result<(), Error> {
11571 // Advance only the recorded end-of-file and re-serialize the superblock in
11572 // place. Unlike `WriteEngine::commit`, this deliberately does NOT clear the
11573 // consistency flags and does NOT repoint the root group: base_address is 0
11574 // for every in-place-append-eligible file, so the normalized-absolute root
11575 // address serializes back to the same stored value.
11576 let eof = self.image.len();
11577 self.superblock.eof_address = eof;
11578 let bytes = self.superblock.serialize();
11579 self.write_at(self.sb_sig_off, &bytes)
11580 }
11581 fn sync(&mut self) -> Result<(), Error> {
11582 barrier_data(self.image, self.sync_policy)
11583 }
11584}
11585
11586/// Re-tags a rejection from the shared append engine (`AppendUnsupported`) as the fast-path
11587/// [`Error::AppendInPlaceUnsupported`], which a caller catches to fall back to the staged
11588/// [`stage_dataset_append`](WriteEngine::stage_dataset_append). That path covers the filtered
11589/// partial-trailing-chunk case and index-geometry limits. A platform-width limit applies on both
11590/// paths: the engine reports it as `AppendUnsupported`, and the staged path as
11591/// [`FormatError::ValueTooLargeForPlatform`]. I/O and format errors pass through unchanged.
11592pub(crate) fn as_inplace_error(e: Error) -> Error {
11593 match e {
11594 Error::AppendUnsupported(m) => Error::AppendInPlaceUnsupported(m),
11595 other => other,
11596 }
11597}
11598
11599/// Validate a gathered append's bytes against a located dataset: the byte
11600/// length must be a whole number of elements, and the element datatype must
11601/// match the on-disk datatype (or, for a raw append, be raw-appendable).
11602/// Returns the appended element count (`0` = nothing to do). Shared by
11603/// [`WriteEngine::append_inplace_gathered`]'s path and the bounded backend's immediate
11604/// append so the acceptance rules stay identical.
11605pub(crate) fn validate_gathered_append(st: &LocatedState, b: &AppendBuilder) -> Result<u64, Error> {
11606 let raw = b.raw();
11607 if raw.len() % st.element_size != 0 {
11608 return Err(Error::AppendInPlaceUnsupported(
11609 "appended byte length is not a whole number of elements",
11610 ));
11611 }
11612 match b.elem_dt() {
11613 Some(expected) if *expected != st.datatype => {
11614 return Err(Error::AppendInPlaceUnsupported(
11615 "append datatype does not match the on-disk dataset (wrong element \
11616 type or byte order)",
11617 ));
11618 }
11619 Some(_) => {}
11620 None => {
11621 if !datatype_is_raw_appendable(&st.datatype) {
11622 return Err(Error::AppendInPlaceUnsupported(
11623 "append_raw onto this dataset's datatype (non-little-endian, \
11624 variable-length, or reference) could misencode the bytes; use a \
11625 typed append",
11626 ));
11627 }
11628 }
11629 }
11630 Ok((raw.len() / st.element_size) as u64)
11631}
11632
11633/// Locate the dataset at `oh_addr` in `file` and build its [`LocatedState`],
11634/// validating in-place append eligibility (rank-1 / unlimited / Extensible-Array
11635/// indexed, a nonzero chunk length, and a re-encodable filter pipeline). A
11636/// dataset this path cannot append to takes an
11637/// [`Error::AppendInPlaceUnsupported`]. A datatype or filter pipeline message
11638/// that does not read or parse returns the [`Error::Format`] the read or the
11639/// parse produced.
11640pub(crate) fn locate_dataset_state<F: Store>(
11641 file: &F,
11642 oh_addr: u64,
11643) -> Result<LocatedState, Error> {
11644 let result = Located::locate_at(file, oh_addr, Error::AppendInPlaceUnsupported)?;
11645 if result.located.chunk_elems == 0 {
11646 return Err(Error::AppendInPlaceUnsupported(
11647 "in-place append requires a nonzero chunk length",
11648 ));
11649 }
11650 let (dt_off, dt_size) = result.spans.datatype;
11651 let dt_bytes = file.read_metadata_at(dt_off, dt_size)?;
11652 let (datatype, _) = Datatype::parse(&dt_bytes)?;
11653 let pipeline = match result.spans.filter {
11654 Some((fb, fsize)) => {
11655 let fp_bytes = file.read_metadata_at(fb, fsize)?;
11656 let parsed = FilterPipeline::parse(&fp_bytes)?;
11657 if !pipeline_reencodable(&parsed) {
11658 return Err(Error::AppendInPlaceUnsupported(
11659 "dataset uses a filter this engine cannot re-encode",
11660 ));
11661 }
11662 Some(parsed)
11663 }
11664 None => None,
11665 };
11666 let element_size = result.located.elem_bytes;
11667 let spatial = vec![result.located.chunk_elems];
11668 // A fill message that cannot be read leaves `PaddingFill::Unknown`, which
11669 // fails only where a chunk actually needs padding — an append that lands on
11670 // a chunk boundary asks nothing of it.
11671 let fill = match result.spans.fill {
11672 Some((msg_type, off, size)) => match file.read_metadata_at(off, size) {
11673 Ok(body) => crate::fill_value::PaddingFill::from_message(msg_type, &body),
11674 Err(_) => crate::fill_value::PaddingFill::Unknown,
11675 },
11676 None => crate::fill_value::PaddingFill::Zero,
11677 };
11678 Ok(LocatedState {
11679 loc: result.located,
11680 datatype,
11681 spatial,
11682 element_size,
11683 pipeline,
11684 fill,
11685 })
11686}
11687
11688/// Group `(parent group, item)` pairs by their parent, preserving the input
11689/// order within each group.
11690///
11691/// The one rule by which a commit's staged datasets become per-group batches:
11692/// the preflight groups borrowed ones to prove its guards, and the apply loop
11693/// groups the owned ones it places, so the order
11694/// [`preflight_reference_targets`](WriteEngine::preflight_reference_targets)
11695/// replays is the order the apply loop uses by construction.
11696fn group_by_parent<K: Ord, T>(items: impl IntoIterator<Item = (K, T)>) -> BTreeMap<K, Vec<T>> {
11697 let mut out: BTreeMap<K, Vec<T>> = BTreeMap::new();
11698 for (parent, item) in items {
11699 out.entry(parent).or_default().push(item);
11700 }
11701 out
11702}
11703
11704/// Ensure a node exists for every ancestor prefix of `path` (so each is rebuilt
11705/// and can re-wire its child link). Does not set `is_new`.
11706fn ensure_ancestors(nodes: &mut BTreeMap<ObjectPathBuf, Node>, path: &ObjectPathBuf) {
11707 for len in 0..=path.len() {
11708 nodes.entry(path.prefix(len)).or_default();
11709 }
11710}
11711
11712/// Validate that every reclaim span `(addr, len)` is non-empty, ends at or
11713/// before `eof`, and that no two overlap; sorts `spans` by address as a side
11714/// effect. Returns `false` on any violation so the caller can decline to
11715/// reclaim the object rather than feed the free list an out-of-bounds or
11716/// overlapping (double-free) region. Touching spans are allowed — the free list
11717/// coalesces them.
11718fn spans_disjoint_in_bounds(spans: &mut [(u64, u64)], eof: u64) -> bool {
11719 for &(addr, len) in spans.iter() {
11720 match addr.checked_add(len) {
11721 Some(end) if len > 0 && end <= eof => {}
11722 _ => return false,
11723 }
11724 }
11725 spans.sort_unstable_by_key(|&(addr, _)| addr);
11726 spans.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
11727}
11728
11729/// Sanitize the accumulated free spans for a whole commit so the free list never
11730/// sees an out-of-bounds or overlapping (double-free) region: drop empty or
11731/// past-`eof` spans, sort by address, then drop any span overlapping one already
11732/// kept. Dropping only leaks (the bytes stay allocated); it never frees a live
11733/// region. With the last-hard-link guard in force nothing should be dropped for
11734/// a well-formed file — this is a backstop, not the primary defense.
11735fn retain_disjoint_in_bounds(spans: &mut Vec<(u64, u64, FreeClass)>, eof: u64) {
11736 spans.retain(|&(addr, len, _)| len > 0 && addr.checked_add(len).is_some_and(|e| e <= eof));
11737 spans.sort_unstable_by_key(|&(addr, _, _)| addr);
11738 let mut kept_end = 0u64;
11739 spans.retain(|&(addr, len, _)| {
11740 if addr >= kept_end {
11741 kept_end = addr + len;
11742 true
11743 } else {
11744 false // overlaps a span already kept; leak it rather than double-free
11745 }
11746 });
11747}
11748
11749/// Whether some `data` span abuts the run of `index` blocks — ends exactly where
11750/// the index begins, or begins exactly where it ends. The geometric half of
11751/// [`WriteEngine::index_is_provably_raw`], where the reasoning lives; split out so
11752/// the rule can be stated against spans alone.
11753///
11754/// An empty index is vacuously placed: there is nothing to file.
11755fn index_abuts_chunk_data(data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
11756 if index.is_empty() {
11757 return true;
11758 }
11759 // The index as a whole: this crate writes its blocks as one run beside the
11760 // chunk data, so a data span abutting either end places all of them.
11761 let (Some(index_start), Some(index_end)) = (
11762 index.iter().map(|&(a, _)| a).min(),
11763 index.iter().filter_map(|&(a, l)| a.checked_add(l)).max(),
11764 ) else {
11765 return false;
11766 };
11767 // A zero-length span touches an address without occupying a byte beside it,
11768 // so it places nothing.
11769 data.iter().any(|&(addr, len)| {
11770 len > 0 && (addr.checked_add(len) == Some(index_start) || addr == index_end)
11771 })
11772}
11773
11774/// Whether any byte of the `index` run lies in page 0 of a paged file, whose
11775/// first bytes are the superblock and which is therefore a metadata page. The
11776/// screening half of [`WriteEngine::index_is_provably_raw`], where the reasoning
11777/// lives.
11778///
11779/// A page size of zero is not a paged file's; it cannot be reasoned about, so it
11780/// screens everything out.
11781fn index_touches_page_zero(index: &[(u64, u64)], page_size: u64) -> bool {
11782 if page_size == 0 {
11783 return !index.is_empty();
11784 }
11785 index.iter().any(|&(addr, len)| len > 0 && addr < page_size)
11786}
11787
11788/// Tag object-header chunk spans as file metadata. Every span
11789/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) returns is part of an object
11790/// header, so the page type is the same for all of them.
11791fn meta_spans(spans: Vec<(u64, u64)>) -> impl Iterator<Item = (u64, u64, FreeClass)> {
11792 spans
11793 .into_iter()
11794 .map(|(a, l)| (a, l, FreeClass::Page(PageType::Meta)))
11795}
11796
11797/// Validate a staged dataset and reduce it to a [`FlatDataset`]. `name` is the link name the
11798/// dataset has in its parent group, taken off the staged path by the caller. Contiguous,
11799/// unfiltered datasets are emitted as such; chunked, filtered, or extensible
11800/// datasets carry their [`ChunkOptions`] and maxshape through to the commit,
11801/// where [`WriteEngine::build_chunked_dataset`] lays out their chunk data and
11802/// index. An empty (zero-element) shape is allowed under either storage,
11803/// mirroring the whole-file writer: a contiguous one takes the `HADDR_UNDEF`
11804/// data address (see the apply loop) and a chunked one an index over zero
11805/// chunks, which is what an extensible dataset is created as before the first
11806/// append fills it. The geometry validation below still requires explicit chunk
11807/// dimensions for it — auto-chunking has no shape to derive them from. A
11808/// `provenance` dataset has its SHA-256/creator/timestamp/source attributes
11809/// computed here from `raw`, exactly as the whole-file writer does. A
11810/// variable-length attribute's global heap collection is built here (it is
11811/// fully self-contained — no address of its own) but placed and patched later,
11812/// in the apply loop, once its final address is known; likewise a
11813/// variable-length-string dataset's staged references and collection
11814/// (`db.vl_string_staging`) are carried through unresolved. An object-reference
11815/// dataset's per-element targets (`db.reference_targets`) are likewise carried
11816/// through unresolved — resolving a path target requires knowing every other
11817/// object this commit places, which is only known well into the apply loop
11818/// (see [`WriteEngine::resolve_reference_target`]). Rejects any remaining
11819/// feature this engine cannot reproduce faithfully: a
11820/// chunked/extensible variable-length-string or object-reference dataset, or a
11821/// filter pipeline the build cannot construct.
11822fn flatten_dataset(db: DatasetBuilder, name: LinkNameBuf) -> Result<FlatDataset, Error> {
11823 let dt = db
11824 .datatype
11825 .ok_or(Error::EditUnsupported("dataset has no datatype/data"))?;
11826 let shape = db
11827 .shape
11828 .ok_or(Error::EditUnsupported("dataset has no shape"))?;
11829 let is_empty = shape.contains(&0);
11830 let chunked = db.chunk_options.is_chunked() || db.maxshape.is_some();
11831 // Storage this engine allocates for every dataset it places: it appends into
11832 // an existing layout, and the undefined data address the whole-file writer
11833 // gives an unallocated dataset (issue #293) has no equivalent here. Refused
11834 // by name rather than left to the "dataset has no data" arm below, which is
11835 // the same symptom from the opposite cause, and refused rather than ignored,
11836 // since ignoring it would write out the grid of fill values the caller asked
11837 // not to have.
11838 if db.allocation == StorageAllocation::Unallocated {
11839 return Err(Error::EditUnsupported(
11840 "a dataset with unallocated storage cannot be added to an existing file in place",
11841 ));
11842 }
11843 // Variable-length string element references live in the global heap, whose
11844 // address is only known once the apply loop places the collection. For
11845 // chunked/filtered/resizable storage the references sit inside chunks
11846 // written before that address exists, so patching them in is impossible.
11847 //
11848 // The whole-file writer lifted the same restriction by placing such a
11849 // dataset's collections ahead of everything else (issue #109); this engine
11850 // appends into an existing layout, where there is no "ahead" to place them
11851 // in, so the equivalent fix is a separate piece of work.
11852 if db.vl_string_staging.is_some() && chunked {
11853 return Err(Error::EditUnsupported(
11854 "chunked or extensible variable-length-string datasets cannot be added in place yet",
11855 ));
11856 }
11857 // Object-reference elements are resolved (see `resolve_reference_target`)
11858 // and patched into `raw` right before it is appended; for chunked storage
11859 // that patch would need to reach inside already-built chunk data, which
11860 // this engine does not support (mirrors the variable-length-string
11861 // refusal above — untested and unneeded combination for v1).
11862 if db.reference_targets.is_some() && chunked {
11863 return Err(Error::EditUnsupported(
11864 "chunked or extensible object-reference datasets cannot be added in place yet",
11865 ));
11866 }
11867 let raw = if is_empty {
11868 db.data.unwrap_or_default()
11869 } else {
11870 db.data
11871 .ok_or(Error::EditUnsupported("dataset has no data"))?
11872 };
11873
11874 // Refused for the same reason the whole-file writer refuses it: nothing
11875 // occupies zero bytes per element, the writers divide by the element size,
11876 // and a caller-built `Datatype` never passes through `Datatype::parse`.
11877 // Taking it as a `NonZeroUsize` hands the proof to the staging below rather
11878 // than leaving each step to re-derive it.
11879 let elem_size = dt.element_size_usize()?;
11880
11881 let elem = elem_size.get() as u64;
11882 // Multiply with checked arithmetic: an absurd shape whose element count
11883 // (or byte size) overflows `u64` is refused rather than panicking in a
11884 // debug build or silently wrapping in release (which could let a wrapped
11885 // product spuriously match `raw.len()`). For a zero-element shape this
11886 // expected length is always 0 (a `0` dimension makes every checked
11887 // multiplication `Some(0)` regardless of the other dimensions), so this
11888 // also catches data mistakenly supplied for a shape that holds nothing.
11889 let expected = shape
11890 .iter()
11891 .try_fold(1u64, |acc, &d| acc.checked_mul(d))
11892 .and_then(|n| n.checked_mul(elem));
11893 match expected {
11894 Some(expected) if raw.len() as u64 == expected => {}
11895 Some(_) => {
11896 return Err(Error::EditUnsupported(
11897 "dataset data length does not match its shape",
11898 ));
11899 }
11900 None => {
11901 return Err(Error::EditUnsupported(
11902 "dataset shape is too large to address on this platform",
11903 ));
11904 }
11905 }
11906
11907 if chunked {
11908 // Refuse malformed chunk geometry up front (the same validation the
11909 // whole-file writer applies), so a bad request — chunk dimensions of the
11910 // wrong rank, a zero chunk dimension, an inconsistent maximum shape, or
11911 // chunking a scalar — never reaches and panics the chunk splitter, nor
11912 // yields a dataset the reader cannot decode.
11913 Extent::new(&shape, db.maxshape.as_deref())
11914 .and_then(|extent| db.chunk_options.validate_geometry(extent))
11915 .map_err(Error::EditUnsupported)?;
11916 // A filter this build cannot apply is refused up front rather than
11917 // failing mid-apply when a chunk is compressed.
11918 db.chunk_options
11919 .refuse_unavailable_filters()
11920 .map_err(Error::EditUnsupported)?;
11921 // Validate the requested filter pipeline now — before any file bytes are
11922 // written — so an unsupported filter, an incompatible datatype, or a
11923 // fill value the filter cannot record is refused up front; the chunk
11924 // data itself is laid out in the commit's apply phase. A filter the
11925 // build compiled out is not among them: `build_pipeline` emits its
11926 // descriptor regardless, which is why the check above exists. Chunked/filtered
11927 // storage flows through the very builder the normal writer uses
11928 // ([`compress_chunks`] + [`assemble_chunked_at`] + [`build_chunked_dataset_oh`]),
11929 // so the
11930 // resulting object header is byte-identical to a freshly written one.
11931 //
11932 // The fill value goes in for the same reason the apply phase passes it:
11933 // scale-offset records it in the filter's parameters, so a validation
11934 // that left it out would be checking a pipeline the commit does not
11935 // build — and would pass a fill value the encoder later refuses.
11936 let chunk_dims = db.chunk_options.resolve_chunk_dims(&shape);
11937 let ctx = ChunkContext::from_datatype(&chunk_dims, &dt)?;
11938 db.chunk_options.build_pipeline(
11939 &ctx,
11940 crate::fill_value::FillPattern::new(
11941 db.fill.as_deref(),
11942 ctx.element_size.narrow::<NonZeroUsize>()?,
11943 ),
11944 )?;
11945 }
11946
11947 // The link message body (whose length is independent of the address) must
11948 // fit the object-header message's u16 size field; a pathologically long
11949 // name would otherwise overflow it into silent corruption. Measured with a
11950 // creation index present — the widest form, written into a group that tracks
11951 // link creation order — since the parent group is not known here.
11952 let mut sized = make_link(name.as_str(), StoredAddress::new(0));
11953 sized.creation_order = Some(0);
11954 if sized.serialize(OFFSET_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
11955 return Err(Error::EditUnsupported(
11956 "dataset name is too long to encode as a link message",
11957 ));
11958 }
11959
11960 let ds = Dataspace {
11961 space_type: if shape.is_empty() {
11962 DataspaceType::Scalar
11963 } else {
11964 DataspaceType::Simple
11965 },
11966 #[expect(
11967 clippy::cast_possible_truncation,
11968 reason = "dataspace rank fits the 1-byte dimensionality field (HDF5 caps rank at 32)"
11969 )]
11970 rank: shape.len() as u8,
11971 dimensions: shape,
11972 // A chunked, extensible dataset records its maximum dimensions. A
11973 // fixed-shape dataset has none.
11974 max_dimensions: db.maxshape.clone(),
11975 };
11976 let mut attrs: Vec<crate::attribute::AttributeMessage> = Vec::with_capacity(db.attrs.len());
11977 for (n, v) in &db.attrs {
11978 attrs.push(v.to_message(n));
11979 }
11980 // The message above already carries a placeholder (heap address 0) for each
11981 // element of a variable-length string attribute; stage its self-contained
11982 // global heap collections here (no address of their own to resolve yet) and
11983 // record which `attrs` slot they patch once the apply loop places them.
11984 let mut vl_attrs: Vec<(usize, Vec<Vec<u8>>)> = Vec::new();
11985 for (i, (_, v)) in db.attrs.iter().enumerate() {
11986 if let Some(strings) = v.var_len_strings() {
11987 vl_attrs.push((i, build_global_heap_collections(strings)));
11988 }
11989 }
11990 // Appended last, and recorded as such: `raw` can still grow before the
11991 // commit writes it (`WriteEngine::extend_staged_dataset`), and the digest
11992 // has to follow it.
11993 #[cfg(feature = "provenance")]
11994 let provenance = db.provenance.as_ref().map(|prov| {
11995 let inputs = crate::provenance::Provenance {
11996 creator: prov.creator.clone(),
11997 timestamp: prov.timestamp.clone(),
11998 source: prov.source.clone(),
11999 };
12000 let attrs_start = attrs.len();
12001 attrs.extend(inputs.build_attrs(&raw));
12002 StagedProvenance {
12003 inputs,
12004 attrs_start,
12005 }
12006 });
12007 // More attributes than an object header keeps compactly, or one whose message
12008 // overflows its 2-byte message-size field, sends the set to a fractal heap —
12009 // the same disjunction, and the same heap, the whole-file writer uses. What
12010 // that heap cannot represent is refused here, in the preflight, so a staged
12011 // dataset that cannot be written is refused before the commit places a byte.
12012 let attrs_are_dense = crate::file_writer::needs_dense_attrs(&attrs);
12013 if attrs_are_dense {
12014 crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
12015 }
12016
12017 // A committed datatype is an object of its own, which an in-place edit has no
12018 // way to place: it appends into an existing file rather than laying one out,
12019 // so there is nothing to resolve the named path against. Writing the type
12020 // inline instead would produce a dataset that reads correctly but no longer
12021 // shares the named type, so refuse by name. The whole-file writer places
12022 // them; [`crate::repack`] is the route from an edited file to one.
12023 if db.datatype_location.is_committed()
12024 || attrs.iter().any(|a| a.datatype_location.is_committed())
12025 {
12026 return Err(Error::EditUnsupported(
12027 "a dataset or attribute naming a committed (shared) datatype cannot be added in \
12028 place; write the file with FileBuilder instead",
12029 ));
12030 }
12031
12032 // A user-defined fill value is one element wide, so its byte length must
12033 // equal the datatype's element size (mirrors the whole-file writer's check).
12034 if let Some(fill) = &db.fill {
12035 let expected = elem.to_usize()?;
12036 if fill.len() != expected {
12037 return Err(Error::Format(FormatError::FillValueSizeMismatch {
12038 expected,
12039 actual: fill.len(),
12040 }));
12041 }
12042 }
12043
12044 Ok(FlatDataset {
12045 name,
12046 dt,
12047 ds,
12048 raw,
12049 attrs,
12050 chunk_options: db.chunk_options,
12051 maxshape: db.maxshape,
12052 vl_attrs,
12053 attrs_are_dense,
12054 vl_string_staging: db.vl_string_staging,
12055 reference_targets: db.reference_targets,
12056 fill: db.fill,
12057 #[cfg(feature = "provenance")]
12058 provenance,
12059 })
12060}
12061
12062/// A minimal Group Info message body (type 0x000A): version 0 with neither the
12063/// link-phase-change nor the estimated-entry fields stored. With both absent the
12064/// HDF5 C library fills `max_compact`/`min_dense` from its own defaults (8 and
12065/// 6). See [`ensure_group_info`] for why every group needs this message.
12066const GROUP_INFO_BODY: [u8; 2] = [0, 0];
12067
12068/// Whether every filter in `pipeline` is one this crate can *apply* (re-encode a
12069/// chunk through) — not merely decode. A pipeline with any other filter cannot be
12070/// re-encoded for an in-place overwrite, so the caller refuses with a typed error
12071/// rather than letting `compress_chunk` surface a raw `UnsupportedFilter`.
12072pub(crate) fn pipeline_reencodable(pipeline: &FilterPipeline) -> bool {
12073 pipeline.filters.iter().all(|f| match f.filter_id {
12074 FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_SCALEOFFSET | FILTER_LZF => {
12075 true
12076 }
12077 #[cfg(feature = "zfp")]
12078 crate::filter_pipeline::FILTER_ZFP => true,
12079 _ => false,
12080 })
12081}
12082
12083/// Whether re-encoding a chunk through `pipeline` reproduces the values it was
12084/// decoded from — the condition for rewriting a chunk that is **already
12085/// committed**, as growing a partial trailing chunk does.
12086///
12087/// Stricter than [`pipeline_reencodable`], and for a different question. That
12088/// one asks whether this crate can *apply* the filters at all, which is what a
12089/// brand-new chunk needs. This one asks whether decode-then-encode is the
12090/// identity, which is what a chunk somebody has already read needs. Deflate,
12091/// shuffle, fletcher32 and LZF are lossless by construction; scale-offset only
12092/// in its integer mode; ZFP fixed-rate quantizes every block to a bit budget, so
12093/// re-encoding it against a *different* set of neighbours in the same block
12094/// re-quantizes the values that were already there.
12095///
12096/// This is the line [`repack`](crate::repack())'s `check_pipeline` already draws
12097/// for its two re-encoding paths, stated once more here because the append
12098/// engine reaches it by a different route.
12099pub(crate) fn pipeline_lossless(pipeline: &FilterPipeline) -> bool {
12100 pipeline.filters.iter().all(|f| match f.filter_id {
12101 FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_LZF => true,
12102 // The integer mode subtracts a per-chunk minimum and packs the residuals
12103 // whole; float D-scale rounds to a decimal count and is documented lossy.
12104 // Anything else (float E-scale) this crate neither writes nor decodes.
12105 FILTER_SCALEOFFSET => matches!(
12106 crate::scaleoffset::scale_offset_mode(&f.client_data),
12107 Some((crate::scaleoffset::ScaleOffset::Integer(_), _))
12108 ),
12109 // Unknown ids included: a filter whose semantics are unknown is not one
12110 // to assume round-trips.
12111 _ => false,
12112 })
12113}
12114
12115/// The refusal both append paths raise for a lossy pipeline sitting on a partial
12116/// trailing chunk. Named here beside the predicate it goes with, and used from
12117/// [`chunk_index_inplace`](crate::chunk_index_inplace) as well as the staged
12118/// rebuild above.
12119pub(crate) const LOSSY_TAIL_REFUSAL: &str = "this dataset's filter pipeline is lossy (ZFP, or float D-scale scale-offset), and its \
12120 length is not a whole multiple of the chunk length: growing that trailing chunk would \
12121 decode and re-encode values that are already committed, changing them. Append whole \
12122 chunks from a chunk-aligned length instead";
12123
12124/// Rebuild a header message `region`, replacing the single Data Layout message's
12125/// record with one carrying `new_layout_body` and leaving every other message
12126/// (datatype, dataspace, fill value, filter pipeline, attributes, attribute info)
12127/// byte-for-byte. The replacement may differ in length from the original — a
12128/// chunked rebuild can change the index type and thus the layout message size — so
12129/// the record is rebuilt via [`OhRecordLayout::record`]. The chunked overwrite
12130/// and copy paths use this to relocate a dataset's chunk storage while preserving
12131/// the rest of its header exactly.
12132fn replace_layout_message(region: &OhRegion, new_layout_body: &[u8]) -> Result<OhRegion, Error> {
12133 let mut out = Vec::with_capacity(region.len());
12134 let mut p = 0;
12135 let mut replaced = false;
12136 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12137 if msg_type == MessageType::DATA_LAYOUT && !replaced {
12138 out.extend_from_slice(
12139 ®ion
12140 .layout()
12141 .record(MessageType::DATA_LAYOUT, new_layout_body),
12142 );
12143 replaced = true;
12144 } else {
12145 out.extend_from_slice(®ion[p..body_end]);
12146 }
12147 p = body_end;
12148 }
12149 if !replaced {
12150 return Err(Error::EditUnsupported(
12151 "chunked dataset header has no data-layout message to relocate",
12152 ));
12153 }
12154 Ok(region.with_bytes(out))
12155}
12156
12157/// Rebuild a header message `region`, replacing the single Dataspace message's
12158/// record with one carrying `new_dataspace_body` (the grown current dimensions,
12159/// v2-serialized, maximum dimensions preserved) and leaving every other message
12160/// byte-for-byte. Used by the append path to grow a dataset's axis-0 dimension.
12161/// The replacement may differ in length from the original (a v1 on-disk
12162/// dataspace is normalized to v2 in the rebuilt header), so the record is rebuilt
12163/// via [`OhRecordLayout::record`].
12164fn replace_dataspace_message(
12165 region: &OhRegion,
12166 new_dataspace_body: &[u8],
12167) -> Result<OhRegion, Error> {
12168 let mut out = Vec::with_capacity(region.len());
12169 let mut p = 0;
12170 let mut replaced = false;
12171 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12172 if msg_type == MessageType::DATASPACE && !replaced {
12173 out.extend_from_slice(
12174 ®ion
12175 .layout()
12176 .record(MessageType::DATASPACE, new_dataspace_body),
12177 );
12178 replaced = true;
12179 } else {
12180 out.extend_from_slice(®ion[p..body_end]);
12181 }
12182 p = body_end;
12183 }
12184 if !replaced {
12185 return Err(Error::AppendUnsupported(
12186 "dataset header has no dataspace message to grow",
12187 ));
12188 }
12189 Ok(region.with_bytes(out))
12190}
12191
12192/// Whether a datatype's raw on-disk bytes can be appended verbatim from a caller
12193/// via [`AppendBuilder::append_raw`]. True only when every scalar leaf is safe to
12194/// write as flat little-endian bytes:
12195///
12196/// - numeric leaves (fixed-point, floating-point, time, bit field) must be
12197/// little-endian, or the caller's little-endian bytes would silently misencode
12198/// into a big-endian (or VAX) field;
12199/// - string and opaque leaves are byte arrays with no numeric byte order, so they
12200/// are order-agnostic and safe;
12201/// - aggregates (enumeration, array, compound) are appendable iff every leaf is;
12202/// - variable-length and reference leaves embed global-heap or object addresses
12203/// that a flat byte append cannot reproduce, so they are never raw-appendable.
12204///
12205/// A typed `append_*` bypasses this: it checks full datatype equality instead, so
12206/// it already refuses every non-little-endian and non-scalar dataset.
12207pub(crate) fn datatype_is_raw_appendable(dt: &Datatype) -> bool {
12208 match dt {
12209 Datatype::FixedPoint { byte_order, .. }
12210 | Datatype::FloatingPoint { byte_order, .. }
12211 | Datatype::Time { byte_order, .. }
12212 | Datatype::BitField { byte_order, .. } => *byte_order == DatatypeByteOrder::LittleEndian,
12213 Datatype::String { .. } | Datatype::Opaque { .. } => true,
12214 Datatype::Enumeration { base_type, .. } | Datatype::Array { base_type, .. } => {
12215 datatype_is_raw_appendable(base_type)
12216 }
12217 Datatype::Compound { members, .. } => members
12218 .iter()
12219 .all(|m| datatype_is_raw_appendable(&m.datatype)),
12220 Datatype::VariableLength { .. } | Datatype::Reference { .. } => false,
12221 }
12222}
12223
12224/// The datatype, dataspace, parsed chunked data layout, and verbatim filter-
12225/// pipeline message bytes (if any) of a chunked dataset header, parsed by
12226/// [`parse_chunked_header`].
12227struct ChunkedHeaderParts {
12228 dt: crate::datatype::Datatype,
12229 ds: Dataspace,
12230 layout: DataLayout,
12231 pipeline_message: Option<Vec<u8>>,
12232}
12233
12234/// Parse the datatype, dataspace, chunked data layout, and verbatim filter-
12235/// pipeline message bytes (if any) from a chunked dataset header `region`. Used by
12236/// the chunked copy path to derive chunk geometry and the on-disk filter pipeline.
12237/// Errors if any required message is missing or the layout is not chunked.
12238fn parse_chunked_header(region: &OhRegion) -> Result<ChunkedHeaderParts, Error> {
12239 let mut datatype: Option<(usize, usize)> = None;
12240 let mut dataspace: Option<(usize, usize)> = None;
12241 let mut layout: Option<(usize, usize)> = None;
12242 let mut pipeline: Option<(usize, usize)> = None;
12243 let mut p = 0;
12244 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12245 match msg_type {
12246 MessageType::DATATYPE => datatype = Some((body, body_end)),
12247 MessageType::DATASPACE => dataspace = Some((body, body_end)),
12248 MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
12249 MessageType::FILTER_PIPELINE => pipeline = Some((body, body_end)),
12250 _ => {}
12251 }
12252 p = body_end;
12253 }
12254 let (dt_b, dt_e) = datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
12255 let (ds_b, ds_e) =
12256 dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
12257 let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
12258 let (dt, _) = Datatype::parse(®ion[dt_b..dt_e])?;
12259 let ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE)?;
12260 let dl = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE)?;
12261 if !matches!(dl, DataLayout::Chunked { .. }) {
12262 return Err(Error::EditUnsupported("dataset is not chunked"));
12263 }
12264 let pipeline_message = pipeline.map(|(b, e)| region[b..e].to_vec());
12265 Ok(ChunkedHeaderParts {
12266 dt,
12267 ds,
12268 layout: dl,
12269 pipeline_message,
12270 })
12271}
12272
12273/// The chunk geometry a verbatim chunked rebuild needs, derived by
12274/// [`chunked_geometry`] from a chunked dataset's datatype, dataspace, and parsed
12275/// [`DataLayout::Chunked`].
12276struct ChunkedGeometry {
12277 /// Rank-only spatial chunk dimensions.
12278 spatial: Vec<u64>,
12279 /// Element size in bytes, proven non-zero: the chunk splitter divides by
12280 /// it, and so does the append path's element-count arithmetic.
12281 element_size: NonZeroUsize,
12282 /// Full (uncompressed) chunk byte size, `product(spatial) * element_size`.
12283 /// This is what the chunk index's element width is derived from, so it has
12284 /// to be the geometry's product and not any written chunk's size.
12285 raw_size: u64,
12286 /// The on-disk maximum dimensions, when they differ from the current shape.
12287 /// An unlimited dimension selects the extensible-array index and a finite
12288 /// one the fixed-array index. `None` keeps the fixed-array or single-chunk
12289 /// index.
12290 maxshape: Option<Vec<MaxExtent>>,
12291}
12292
12293/// Derive the [`ChunkedGeometry`] for a chunked dataset from its datatype,
12294/// dataspace, and parsed [`DataLayout::Chunked`].
12295fn chunked_geometry(
12296 dt: &crate::datatype::Datatype,
12297 ds: &Dataspace,
12298 layout: &DataLayout,
12299) -> Result<ChunkedGeometry, Error> {
12300 let DataLayout::Chunked {
12301 chunk_dimensions, ..
12302 } = layout
12303 else {
12304 return Err(Error::EditUnsupported("dataset is not chunked"));
12305 };
12306 let rank = ds.dimensions.len();
12307 if chunk_dimensions.len() <= rank {
12308 return Err(Error::EditUnsupported(
12309 "chunked layout has malformed dimensions",
12310 ));
12311 }
12312 let spatial: Vec<u64> = chunk_dimensions[..rank].to_vec();
12313 let element_size = dt.element_size_usize()?;
12314 let raw_size = spatial
12315 .iter()
12316 .copied()
12317 .product::<u64>()
12318 .saturating_mul(element_size.get() as u64);
12319 let maxshape = ds.extensible_max_dimensions().map(<[MaxExtent]>::to_vec);
12320 Ok(ChunkedGeometry {
12321 spatial,
12322 element_size,
12323 raw_size,
12324 maxshape,
12325 })
12326}
12327
12328/// The element bytes a staged value overwrite will write, paired with the
12329/// variable-length staging that has still to be resolved into them and the
12330/// `path` they belong to.
12331///
12332/// Cloned rather than moved because the staged set must survive a refused
12333/// commit whole (issue #316), and this runs in the preflight that may refuse.
12334fn staged_bytes(fd: &FlatDataset, path: &ObjectPathBuf) -> OverwriteBytes {
12335 OverwriteBytes {
12336 raw: fd.raw.clone(),
12337 vlen: fd.vl_string_staging.clone().map(|staging| VlenOverwrite {
12338 staging,
12339 path: path.clone(),
12340 }),
12341 }
12342}
12343
12344/// Split `raw` into full-size chunk buffers in dense row-major grid order and
12345/// re-encode each through `pipeline_message`, the dataset's on-disk filter
12346/// pipeline.
12347///
12348/// The overhang past the dataset's edge holds the dataset's own fill value, not
12349/// zeros: an allocated chunk is expected to carry it wherever nothing was
12350/// written, and those slots are what a reader returns once the dataset is
12351/// extended into them (issue #296).
12352///
12353/// The caller has already refused a pipeline
12354/// [`pipeline_reencodable`] rejects, so the only errors here are a malformed
12355/// pipeline message (unreachable for the same reason) and the encoder's own.
12356fn split_and_encode_chunks(
12357 raw: &[u8],
12358 shape: &[u64],
12359 chunk_dims: &[u64],
12360 element_size: NonZeroUsize,
12361 padding: &crate::fill_value::PaddingFill,
12362 pipeline_message: Option<&[u8]>,
12363 dt: &crate::datatype::Datatype,
12364) -> Result<Vec<Vec<u8>>, Error> {
12365 let split = split_into_chunks(
12366 raw,
12367 shape,
12368 chunk_dims,
12369 element_size,
12370 padding.pattern(element_size),
12371 )
12372 .map_err(Error::Format)?;
12373 let Some(pm) = pipeline_message else {
12374 return Ok(split);
12375 };
12376 let pipeline = FilterPipeline::parse(pm)?;
12377 let ctx = ChunkContext::from_datatype(chunk_dims, dt)?;
12378 let mut encoded = Vec::with_capacity(split.len());
12379 // One encoder across the rewrite; see `FilterScratch`.
12380 let mut scratch = FilterScratch::new();
12381 for buf in &split {
12382 encoded.push(compress_chunk_with(&mut scratch, buf, &pipeline, ctx)?);
12383 }
12384 Ok(encoded)
12385}
12386
12387/// Tries to overwrite a chunked dataset's chunks in place. When the dataset's
12388/// on-disk chunks form a dense grid aligned with `new_bytes` (dense row-major
12389/// order), every slot is unmasked (`filter_mask == 0`), and every new chunk
12390/// **fits** the slot it replaces (`new_len <= slot`), returns the in-place
12391/// `(address, bytes)` writes:
12392///
12393/// - When every new chunk is **exactly** its slot's size, only the chunk data is
12394/// written; the index is untouched (so any enumerable index type works, and a
12395/// crash can tear at most a chunk's value bytes, not the structure).
12396/// - When some new chunks are **smaller** (fit with slack), the chunk index
12397/// records each chunk's stored size, so the index is rebuilt in place to record
12398/// the new sizes (see [`try_rebuild_index_in_place`]). This is supported only
12399/// for a v4 fixed-array or extensible-array index occupying a single contiguous
12400/// on-disk region; any other case returns `None` to relocate.
12401///
12402/// Returns `None` — so the caller relocates the dataset instead — when the index
12403/// cannot be enumerated, the grid is sparse, a slot is masked, a new chunk does
12404/// not fit, the index cannot be rebuilt in place, or any write would be out of
12405/// bounds or overlap another.
12406fn try_inplace_chunk_writes<S: Source + ?Sized>(
12407 src: &S,
12408 layout: &DataLayout,
12409 ds: &Dataspace,
12410 spatial: &[u64],
12411 raw_size: u64,
12412 new_bytes: &[Vec<u8>],
12413) -> Option<Vec<(u64, Vec<u8>)>> {
12414 let infos = enumerate_chunks_from_source(src, layout, ds, OFFSET_SIZE, LENGTH_SIZE).ok()?;
12415 let grid = plan_dense_grid(infos, &ds.dimensions, spatial)?;
12416 if grid.grid_order.len() != new_bytes.len() {
12417 return None;
12418 }
12419 let mut writes = Vec::with_capacity(new_bytes.len() + 1);
12420 let mut spans: Vec<(u64, u64)> = Vec::with_capacity(new_bytes.len() + 1);
12421 let mut any_shrunk = false;
12422 for (ci, bytes) in grid.grid_order.iter().zip(new_bytes.iter()) {
12423 // A nonzero filter mask means the source left some filter unapplied for
12424 // this chunk; re-encoding always applies every filter (mask 0), so an
12425 // in-place overwrite would desync the index-recorded mask. Relocate.
12426 if ci.filter_mask != 0 {
12427 return None;
12428 }
12429 let new_len = bytes.len() as u64;
12430 let slot = ci.chunk_size.get();
12431 // A chunk that no longer fits its slot must relocate.
12432 if new_len > slot {
12433 return None;
12434 }
12435 if new_len < slot {
12436 any_shrunk = true;
12437 }
12438 ci.address
12439 .get()
12440 .checked_add(new_len)
12441 .filter(|&e| e <= src.len())?;
12442 writes.push((ci.address.get(), bytes.clone()));
12443 spans.push((ci.address.get(), new_len));
12444 }
12445
12446 // A shrinking overwrite changes the index-recorded chunk sizes, so the index
12447 // must be rebuilt in place to match; an equal-size one leaves it untouched.
12448 if any_shrunk {
12449 let (index_addr, index_bytes) = try_rebuild_index_in_place(
12450 src,
12451 layout,
12452 ds,
12453 spatial,
12454 raw_size,
12455 &grid.grid_order,
12456 new_bytes,
12457 )?;
12458 spans.push((index_addr, index_bytes.len() as u64));
12459 writes.push((index_addr, index_bytes));
12460 }
12461
12462 // Refuse to perform overlapping in-place writes (a malformed source index, or
12463 // an index region that overlaps a chunk slot); relocate instead so two writes
12464 // never clobber each other.
12465 if !spans_disjoint_in_bounds(&mut spans, src.len()) {
12466 return None;
12467 }
12468 Some(writes)
12469}
12470
12471/// Rebuilds a chunked dataset's index **in place** so it records the new
12472/// (smaller) per-chunk stored sizes after a fits-with-slack overwrite, returning
12473/// the `(address, bytes)` write that replaces it. The chunks keep their existing
12474/// addresses (only their stored bytes shrank), so the rebuilt index points at the
12475/// same slots with the new sizes.
12476///
12477/// Supported only for a v4 **fixed-array** or **extensible-array** index whose
12478/// on-disk structure is a single contiguous region starting at the index address
12479/// — the layout this crate's own writer produces. The element width derives from
12480/// the unchanged raw chunk size, so the rebuilt structure is byte-for-byte the
12481/// same length as the original; this is required to match exactly, which rejects a
12482/// scattered or differently-laid-out (e.g. C-written) index, leaving the caller
12483/// to relocate. Single-chunk (size in the layout message) and B-tree-v1 (no
12484/// writer) indexes are not rebuilt here.
12485///
12486/// Like any in-place value overwrite (the HDF5 `H5Dwrite` model) this is not
12487/// atomic: a crash mid-write can tear the index and leave the dataset needing a
12488/// rewrite. It is used only on the in-place path, whose linearization point is the
12489/// synced data write.
12490#[allow(clippy::too_many_arguments)]
12491fn try_rebuild_index_in_place<S: Source + ?Sized>(
12492 src: &S,
12493 layout: &DataLayout,
12494 ds: &Dataspace,
12495 spatial: &[u64],
12496 raw_size: u64,
12497 grid_order: &[crate::chunked_read::ChunkInfo],
12498 new_bytes: &[Vec<u8>],
12499) -> Option<(u64, Vec<u8>)> {
12500 let DataLayout::Chunked { index, .. } = layout else {
12501 return None;
12502 };
12503 let index_addr = index.address()?;
12504 let written: Vec<crate::chunked_write::WrittenChunk> = grid_order
12505 .iter()
12506 .zip(new_bytes)
12507 .map(|(ci, b)| crate::chunked_write::WrittenChunk {
12508 address: ci.address,
12509 compressed_size: b.len() as u64,
12510 filter_mask: 0,
12511 })
12512 .collect();
12513 // `grid_order` is dense row-major over the *current* shape; where each of
12514 // those chunks sits in the index is the maximum grid's business, and the
12515 // rebuild has to reproduce the numbering the original index was written
12516 // with. Same rule, same function as the writer.
12517 let (_, slot_of_chunk, index_slots) = crate::chunked_write::plan_index_slots(
12518 &ds.dimensions,
12519 spatial,
12520 ds.max_dimensions.as_deref(),
12521 raw_size,
12522 true,
12523 // Rebuilding the index of a dataset whose chunks are in hand.
12524 crate::chunked_write::StorageAllocation::Allocated,
12525 )
12526 .ok()?;
12527 let slots =
12528 crate::chunked_write::IndexSlots::new(&written, &slot_of_chunk, index_slots).ok()?;
12529 let new_index = match index {
12530 // `raw_size` is the whole-chunk byte size, which is what the element
12531 // width derives from — the same value the original index was built with,
12532 // so the rebuilt structure matches its length. (An index written by a
12533 // version that derived the width from the written chunks instead can
12534 // disagree; the length check below then rejects it and the caller
12535 // relocates, which is the safe direction.)
12536 ChunkIndexLayout::FixedArray { .. } => crate::chunked_write::build_fixed_array_at(
12537 &slots,
12538 raw_size,
12539 OFFSET_SIZE,
12540 LENGTH_SIZE,
12541 true,
12542 index_addr,
12543 ),
12544 ChunkIndexLayout::ExtensibleArray { .. } => {
12545 crate::chunked_write::build_extensible_array_at(
12546 &slots,
12547 raw_size,
12548 OFFSET_SIZE,
12549 LENGTH_SIZE,
12550 true,
12551 index_addr,
12552 )
12553 .ok()?
12554 }
12555 // A single chunk's stored size sits in the layout message, so rebuilding
12556 // it would rewrite the header, an implicit index has no structure to
12557 // rebuild, and neither B-tree has a writer. All four relocate instead.
12558 ChunkIndexLayout::SingleChunk { .. }
12559 | ChunkIndexLayout::Implicit { .. }
12560 | ChunkIndexLayout::BTreeV1 { .. }
12561 | ChunkIndexLayout::BTreeV2 { .. } => return None,
12562 };
12563
12564 // The on-disk index must be a single contiguous region starting at the index
12565 // address, and the rebuilt structure must be exactly the same length (true for
12566 // an index this crate wrote). A scattered or different on-disk layout fails
12567 // the check and the caller relocates.
12568 let mut spans =
12569 crate::chunked_read::chunk_index_spans_from_source(src, layout, OFFSET_SIZE, LENGTH_SIZE)
12570 .ok()?;
12571 if spans.is_empty() {
12572 return None;
12573 }
12574 spans.sort_unstable_by_key(|&(a, _)| a);
12575 if spans[0].0 != index_addr.get() {
12576 return None;
12577 }
12578 let mut end = index_addr.get();
12579 for &(a, l) in &spans {
12580 if a != end {
12581 return None; // a gap means the index is not contiguous
12582 }
12583 end = a.checked_add(l)?;
12584 }
12585 if new_index.len() as u64 != end - index_addr.get() {
12586 return None;
12587 }
12588 index_addr
12589 .get()
12590 .checked_add(new_index.len() as u64)
12591 .filter(|&e| e <= src.len())?;
12592 Some((index_addr.get(), new_index))
12593}
12594
12595/// A [`ChunkProvider`] over chunk bytes already held in memory, in dense
12596/// row-major grid order. Used by the editor's chunked copy and relocating
12597/// overwrite, which own each chunk's bytes (a [`CopyTree`] or [`MovingWrite`]
12598/// captured them) rather than streaming from a source file like repack.
12599struct SliceChunkProvider<'a> {
12600 chunks: &'a [Vec<u8>],
12601}
12602
12603impl ChunkProvider for SliceChunkProvider<'_> {
12604 fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
12605 let chunk = self.chunks.get(index).ok_or_else(|| {
12606 FormatError::ChunkedReadError("chunk index out of range for in-memory provider".into())
12607 })?;
12608 out.extend_from_slice(chunk);
12609 Ok(())
12610 }
12611}
12612
12613/// The chunk-0 message region of a fresh, empty compact-link group: a LinkInfo
12614/// message advertising no dense storage, followed by a GroupInfo message.
12615/// Mirrors `build_group_oh`.
12616fn fresh_group_region() -> OhRegion {
12617 let mut li = Vec::with_capacity(18);
12618 li.push(0); // version
12619 li.push(0); // flags
12620 li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap addr = UNDEF
12621 li.extend_from_slice(&u64::MAX.to_le_bytes()); // btree name index addr = UNDEF
12622 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
12623 region.push(MessageType::LINK_INFO, &li);
12624 region.push(MessageType::GROUP_INFO, &GROUP_INFO_BODY);
12625 region
12626}
12627
12628/// Ensure a group's chunk-0 message `region` carries a Group Info message,
12629/// appending a minimal one when absent.
12630///
12631/// The HDF5 C library refuses to insert a link into a group whose object header
12632/// has a Link Info message but no Group Info message: on the new-format path
12633/// `H5G_obj_insert` reads the Group Info message unconditionally and fails with
12634/// "message type not found". Such a group round-trips for *reading* but cannot
12635/// be *modified* by the C library. Earlier hdf5-pure releases wrote groups that
12636/// way, so heal any such header whenever we rewrite one in place.
12637fn ensure_group_info(region: &mut OhRegion) -> Result<(), Error> {
12638 let mut p = 0;
12639 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12640 if msg_type == MessageType::GROUP_INFO {
12641 return Ok(());
12642 }
12643 p = body_end;
12644 }
12645 region.push(MessageType::GROUP_INFO, &GROUP_INFO_BODY);
12646 Ok(())
12647}
12648
12649/// Ensure a chunk-0 message `region` that carries inline Attribute messages also
12650/// carries an Attribute Info message, appending the compact-storage one when
12651/// absent.
12652///
12653/// On a version 2 object header the reference C library never counts attribute
12654/// messages: `H5O__attr_count_real` reads the count out of the Attribute Info
12655/// message, and reports zero when there is none, even though `H5Aiterate` and
12656/// `H5Aopen_by_name` still find every attribute. Tools that size their work by
12657/// that count then skip the attributes silently — `h5repack` copies such an
12658/// object with none of them. Releases through 0.33.0 wrote every compact
12659/// attribute set that way, so heal any such header whenever we rewrite one, the
12660/// same reason [`ensure_group_info`] exists.
12661///
12662/// A region already carrying an Attribute Info message is left alone, whichever
12663/// storage it names: a defined heap address means dense storage, whose count the
12664/// C library takes from the heap's B-tree instead.
12665fn ensure_attribute_info(region: &mut OhRegion) -> Result<(), Error> {
12666 let mut has_attrs = false;
12667 let mut p = 0;
12668 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12669 match msg_type {
12670 MessageType::ATTRIBUTE_INFO => return Ok(()),
12671 MessageType::ATTRIBUTE => has_attrs = true,
12672 _ => {}
12673 }
12674 p = body_end;
12675 }
12676 if has_attrs {
12677 let body = compact_attribute_info_body(region)?;
12678 region.push(MessageType::ATTRIBUTE_INFO, &body);
12679 }
12680 Ok(())
12681}
12682
12683/// Encode a complete object-header Link message (4-byte record header + body)
12684/// for a hard link `name -> addr`. The caller must have validated that the body
12685/// fits the u16 size field (see [`flatten_dataset`]); group names are short.
12686///
12687/// `creation_order` is the link's creation index, which a group tracking **link**
12688/// creation order carries on every link and no other group carries at all
12689/// ([`LinkCreationOrder`] is what hands one out). It is a flagged field of the
12690/// Link message *body*, quite separate from the object header's own per-message
12691/// creation index: that one records an *attribute*'s creation order, and the
12692/// reference C library writes it as the zero [`OhRecordLayout::record`] passes
12693/// for every other message type.
12694fn encode_link_message(
12695 layout: OhRecordLayout,
12696 name: &str,
12697 addr: StoredAddress,
12698 creation_order: Option<u64>,
12699) -> Vec<u8> {
12700 let mut link = make_link(name, addr);
12701 link.creation_order = creation_order;
12702 let body = link.serialize(OFFSET_SIZE);
12703 layout.record(MessageType::LINK, &body)
12704}
12705
12706/// Patch an existing hard Link message in a chunk-0 message `region`, retargeting
12707/// the link named `name` to `new_addr` (used to repoint a parent at a relocated
12708/// child group). The target address is the trailing `OFFSET_SIZE` bytes of the
12709/// link body for a hard link.
12710fn patch_link_target(
12711 region: &mut OhRegion,
12712 name: &str,
12713 new_addr: StoredAddress,
12714) -> Result<(), Error> {
12715 let mut p = 0;
12716 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12717 if msg_type == MessageType::LINK {
12718 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
12719 if link.name == name {
12720 return match link.link_target {
12721 LinkTarget::Hard { .. } => {
12722 let ofs = body_end - OFFSET_SIZE as usize;
12723 region.bytes_mut()[ofs..body_end]
12724 .copy_from_slice(&new_addr.get().to_le_bytes());
12725 Ok(())
12726 }
12727 _ => Err(Error::EditUnsupported(
12728 "a group on the edited path is reached by a soft/external link",
12729 )),
12730 };
12731 }
12732 }
12733 }
12734 p = body_end;
12735 }
12736 Err(Error::EditUnsupported(
12737 "expected child link not found in parent group",
12738 ))
12739}
12740
12741/// Bytes a compact Data Layout message carries ahead of its inline data:
12742/// version(1) + class(1) + the 2-byte inline size.
12743const COMPACT_LAYOUT_PREAMBLE: usize = 4;
12744
12745/// Copy a chunk-0 message `region`, replacing the single (compact) Data Layout
12746/// message's inline data with `raw` and preserving every other message verbatim.
12747/// Used by `write_dataset` to overwrite a compact dataset's values. The message
12748/// header (type and flags) and version byte are kept; only the inline data — and
12749/// the message size and 2-byte inline-size fields — change. `raw` must fit both
12750/// the compact layout's own 2-byte size field (HDF5's 64 KiB compact-storage
12751/// limit) and, once the 4-byte layout preamble is added, the object header's
12752/// 2-byte message-size field — the tighter of the two, which an overwrite of an
12753/// existing compact dataset always satisfies.
12754fn rebuild_compact_layout_region(region: &OhRegion, raw: &[u8]) -> Result<OhRegion, Error> {
12755 // The bound is on the *message body* the layout becomes — version, class,
12756 // and the 2-byte inline size ahead of the data — not on `raw` alone, or the
12757 // last four lengths below the limit would truncate the size field written
12758 // for them.
12759 if raw.len() > OBJECT_HEADER_MESSAGE_MAX - COMPACT_LAYOUT_PREAMBLE {
12760 return Err(Error::EditUnsupported(
12761 "compact dataset data is too large to overwrite in place",
12762 ));
12763 }
12764 let mut out = Vec::with_capacity(region.len() + raw.len());
12765 let mut p = 0;
12766 let mut replaced = false;
12767 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12768 if msg_type == MessageType::DATA_LAYOUT {
12769 if body_end - body < 2 || region[body + 1] != 0 {
12770 return Err(Error::EditUnsupported(
12771 "compact-layout overwrite found a non-compact data layout",
12772 ));
12773 }
12774 // New compact layout body: version (kept), class=0, 2-byte inline
12775 // size, then the data.
12776 let mut layout = Vec::with_capacity(COMPACT_LAYOUT_PREAMBLE + raw.len());
12777 layout.push(region[body]); // version (3 or 4)
12778 layout.push(0); // class = compact
12779 #[expect(
12780 clippy::cast_possible_truncation,
12781 reason = "raw.len() bounded below the u16 inline-size field above"
12782 )]
12783 layout.extend_from_slice(&(raw.len() as u16).to_le_bytes());
12784 layout.extend_from_slice(raw);
12785 // Message record: type byte, 2-byte size (LE), then the rest of
12786 // the prefix — flags, and a creation index where the header has one
12787 // — kept verbatim.
12788 out.push(region[p]);
12789 #[expect(
12790 clippy::cast_possible_truncation,
12791 reason = "the guard above bounds COMPACT_LAYOUT_PREAMBLE + raw.len(), this \
12792 body's exact length, to the 2-byte message-size field"
12793 )]
12794 out.extend_from_slice(&(layout.len() as u16).to_le_bytes());
12795 out.extend_from_slice(®ion[p + 3..p + region.layout().prefix_len()]);
12796 out.extend_from_slice(&layout);
12797 replaced = true;
12798 } else {
12799 out.extend_from_slice(®ion[p..body_end]);
12800 }
12801 p = body_end;
12802 }
12803 if p < region.len() {
12804 out.extend_from_slice(®ion[p..]);
12805 }
12806 if !replaced {
12807 return Err(Error::EditUnsupported(
12808 "compact dataset header has no data-layout message",
12809 ));
12810 }
12811 Ok(region.with_bytes(out))
12812}
12813
12814/// The link creation indexes one commit hands out to the links it adds to one
12815/// group, and the running maximum its Link Info message ends up recording.
12816///
12817/// Link creation order is a separate mechanism from the attribute creation order
12818/// the object header's own flags describe: a group that tracks it — h5py's
12819/// `track_order=True`, `H5Pset_link_creation_order`, and every group netCDF-4
12820/// writes — carries a creation index on every Link message and, in its Link Info
12821/// message, the maximum it has ever assigned. The reference C library hands out
12822/// that maximum and increments it (`H5G_obj_insert`), so the recorded value is
12823/// the *next* index rather than the highest in use, and deleting a link never
12824/// lowers it. This is the link-side counterpart of [`next_attr_creation_index`],
12825/// and it works the same way.
12826///
12827/// A group that does not track the order has no maximum recorded, hands out no
12828/// index, and records nothing back — which is every group this crate writes
12829/// itself.
12830struct LinkCreationOrder {
12831 /// The next index to assign, on a group that tracks link creation order.
12832 next: Option<u64>,
12833 /// Whether any index has been handed out. Nothing is recorded back
12834 /// otherwise, so a commit that adds no link to the group leaves its Link
12835 /// Info message byte-identical.
12836 assigned: bool,
12837}
12838
12839impl LinkCreationOrder {
12840 /// Read a group's counter out of its object-header message `region`.
12841 fn for_region(region: &OhRegion) -> Result<Self, Error> {
12842 let next = find_link_info(region)?.and_then(|(_, _, info)| info.max_creation_order);
12843 Ok(Self {
12844 next,
12845 assigned: false,
12846 })
12847 }
12848
12849 /// The creation index for one link being added, or `None` on a group that
12850 /// does not track the order. Consecutive calls return consecutive indexes,
12851 /// so links added in one commit are ordered by the order they are placed in.
12852 fn take(&mut self) -> Result<Option<u64>, Error> {
12853 let Some(index) = self.next else {
12854 return Ok(None);
12855 };
12856 self.next = Some(index.checked_add(1).ok_or(Error::EditUnsupported(
12857 "a group has assigned every link creation index its link-info message can record",
12858 ))?);
12859 self.assigned = true;
12860 Ok(Some(index))
12861 }
12862
12863 /// Write the running maximum back into the group's Link Info message, once
12864 /// every link this commit adds to that group has taken an index.
12865 ///
12866 /// The maximum is patched in place rather than re-encoded: it is present
12867 /// (that is what [`Self::for_region`] read) and it is the eight bytes
12868 /// following the message's version and flags whatever the file's offset
12869 /// size, so the record's length — and every offset into the region — is
12870 /// unchanged.
12871 fn record(&self, region: &mut OhRegion) -> Result<(), Error> {
12872 if !self.assigned {
12873 return Ok(());
12874 }
12875 let Some(next) = self.next else {
12876 return Ok(());
12877 };
12878 let (_, body, _) = find_link_info(region)?.ok_or(Error::EditUnsupported(
12879 "a group's link-info message went missing while its links were being added",
12880 ))?;
12881 let max = body.start + 2..body.start + 10;
12882 if max.end > body.end {
12883 return Err(Error::EditUnsupported(
12884 "a group's link-info message is too short to record its link creation order",
12885 ));
12886 }
12887 region.bytes_mut()[max].copy_from_slice(&next.to_le_bytes());
12888 Ok(())
12889 }
12890}
12891
12892/// The Link Info message in `region`, as `(record start, body range, parsed
12893/// message)`. An unparseable message is reported as absent, the same reading
12894/// [`find_attribute_info`] gives an unparseable Attribute Info message — safe
12895/// here because a group whose link storage this editor cannot account for is
12896/// refused by [`inspect_group`](WriteEngine::inspect_group) before it reaches
12897/// this.
12898fn find_link_info(
12899 region: &OhRegion,
12900) -> Result<Option<(usize, core::ops::Range<usize>, LinkInfoMessage)>, Error> {
12901 let mut p = 0;
12902 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12903 if msg_type == MessageType::LINK_INFO
12904 && let Ok(info) = LinkInfoMessage::parse(®ion[body..body_end], OFFSET_SIZE)
12905 {
12906 return Ok(Some((p, body..body_end, info)));
12907 }
12908 p = body_end;
12909 }
12910 Ok(None)
12911}
12912
12913/// The number of links a group keeps in its object header before they move into
12914/// a fractal heap: the "maximum compact value" its Group Info message (type
12915/// 0x000A) declares, or the reference C library's default of 8 where that
12916/// message stores no link-phase-change values.
12917///
12918/// Body: version(1), flags(1), then the maximum compact (2) and minimum dense
12919/// (2) values if bit 0 of the flags is set, then the estimated entry count (2)
12920/// and name length (2) if bit 1 is. An absent or truncated message reads as the
12921/// default, which is the value the C library itself would use for it.
12922///
12923/// Not to be confused with [`AttrPhaseChange`], the *attribute* thresholds
12924/// (`H5Pset_attr_phase_change`) that live in the object header's own prefix:
12925/// this is the *link* phase change, it lives in a message, and it is what
12926/// [`reject_dense_link_creation_order`] measures an addition against.
12927fn max_compact_links(region: &OhRegion) -> Result<u16, Error> {
12928 /// `H5G_CRT_GINFO_MAX_COMPACT`, the C library's default.
12929 const DEFAULT_MAX_COMPACT: u16 = 8;
12930 let mut p = 0;
12931 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12932 if msg_type == MessageType::GROUP_INFO {
12933 let stores_phase_change = body_end - body >= 2 && region[body + 1] & 0x01 != 0;
12934 if stores_phase_change && body + 4 <= body_end {
12935 return Ok(u16::from_le_bytes([region[body + 2], region[body + 3]]));
12936 }
12937 return Ok(DEFAULT_MAX_COMPACT);
12938 }
12939 p = body_end;
12940 }
12941 Ok(DEFAULT_MAX_COMPACT)
12942}
12943
12944/// Refuse to add a link to a group that tracks **link** creation order when the
12945/// addition would move that group's links into *dense* (fractal-heap) storage.
12946///
12947/// `links_after_commit` is how many links the group would hold once this commit's
12948/// deletions and additions are applied. Past the threshold its Group Info message
12949/// declares, the reference C library moves a tracked group's links into a fractal
12950/// heap indexed by *two* B-trees: one on name, and a type 6 one on creation
12951/// order. This crate emits neither, so the point where the group would stop
12952/// being compact is the point where an addition stops being reproducible — that
12953/// work belongs with dense link storage as a whole (issue #102).
12954///
12955/// Below the threshold an addition is written rather than refused
12956/// ([`LinkCreationOrder`] numbers it), as are the two things that write no
12957/// creation order at all: **removing** a link, which leaves a gap in the order
12958/// exactly as dropping an Attribute message does and copies the running maximum
12959/// through untouched (the C library does not lower it on a deletion either), and
12960/// **retargeting** one ([`patch_link_target`], how a relocated child is rewired),
12961/// which rewrites an address inside a Link message and leaves every other field
12962/// of it, creation index included, where it was.
12963///
12964/// A group whose links are *already* dense never reaches here at all:
12965/// [`inspect_group`](WriteEngine::inspect_group) refuses every dense-link group,
12966/// tracked or not, as it reads the header.
12967fn reject_dense_link_creation_order(
12968 region: &OhRegion,
12969 links_after_commit: usize,
12970) -> Result<(), Error> {
12971 let tracked =
12972 find_link_info(region)?.is_some_and(|(_, _, info)| info.max_creation_order.is_some());
12973 if tracked && links_after_commit > usize::from(max_compact_links(region)?) {
12974 return Err(Error::EditUnsupported(
12975 "a group on the edited path tracks link creation order, and the links this commit \
12976 adds to it would take it past the compact storage its group-info message allows; \
12977 dense (fractal-heap) link storage cannot be written in place yet",
12978 ));
12979 }
12980 Ok(())
12981}
12982
12983/// Copy a chunk-0 message `region`, dropping the single Link message named
12984/// `name` and preserving every other message verbatim (used by `delete`). Errors
12985/// if no such link is present.
12986fn remove_link_from_region(region: &OhRegion, name: &str) -> Result<OhRegion, Error> {
12987 let mut out = Vec::with_capacity(region.len());
12988 let mut p = 0;
12989 let mut removed = false;
12990 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12991 let mut skip = false;
12992 if msg_type == MessageType::LINK {
12993 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
12994 if link.name == name {
12995 skip = true;
12996 removed = true;
12997 }
12998 }
12999 }
13000 if !skip {
13001 out.extend_from_slice(®ion[p..body_end]);
13002 }
13003 p = body_end;
13004 }
13005 if p < region.len() {
13006 out.extend_from_slice(®ion[p..]);
13007 }
13008 if !removed {
13009 return Err(Error::EditUnsupported(
13010 "link to delete not found in its parent group",
13011 ));
13012 }
13013 Ok(region.with_bytes(out))
13014}
13015
13016/// Apply attribute edits to an object's header `region` *compactly*, preserving
13017/// every non-attribute message verbatim. A fixed-size `Set`/`Remove` is resolved
13018/// into `region` directly; a variable-length `Set` (one whose value reports
13019/// [`AttrValue::var_len_strings`]) is
13020/// instead collected into the returned `pending_vl_attrs` — its placeholder
13021/// heap address is only patched, and the message appended to the object's
13022/// header, by the apply loop once its global heap collection's real address
13023/// is known (see [`WriteEngine::place_vl_collections`]). A later op for the
13024/// same name (another `Set`, fixed-size or not, or a `Remove`) replaces or
13025/// cancels an earlier still-pending variable-length entry, keeping the net
13026/// effect the same regardless of op order within one commit.
13027///
13028/// Only [`plan_attr_ops`] calls this, and only for an object whose attributes
13029/// are stored compactly: appending an inline Attribute message to an object
13030/// whose set lives in a fractal heap would leave it carrying two storage forms,
13031/// so a dense object is routed to a heap rebuild there instead.
13032fn apply_compact_attr_ops(
13033 region: &OhRegion,
13034 ops: &[&AttrOp],
13035) -> Result<(OhRegion, PendingVlAttrs), Error> {
13036 let mut out = region.clone();
13037 let mut pending_vl: PendingVlAttrs = Vec::new();
13038 for op in ops {
13039 match op {
13040 AttrOp::Set { name, value } => {
13041 pending_vl.retain(|a| &a.msg.name != name);
13042 if let Some(strings) = value.var_len_strings() {
13043 // The message this replaces is dropped now and re-appended
13044 // in the apply phase, so the creation index it carried has
13045 // to be read before it goes: an overwrite keeps the index
13046 // the attribute already had.
13047 let creation_index = attr_creation_index(&out, name)?;
13048 // Nothing yet to remove from `region` if this name has
13049 // never been set as a fixed-size attribute.
13050 out = remove_attr_from_region(&out, name, false)?;
13051 let msg = build_attr_message(name, value);
13052 if msg.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
13053 return Err(Error::EditUnsupported(
13054 "attribute is too large to encode in place",
13055 ));
13056 }
13057 pending_vl.push(PendingVlAttr {
13058 msg,
13059 collections: build_global_heap_collections(strings),
13060 creation_index,
13061 });
13062 } else {
13063 out = set_attr_in_region(&out, name, value)?;
13064 }
13065 }
13066 AttrOp::Remove { name } => {
13067 let before = pending_vl.len();
13068 pending_vl.retain(|a| &a.msg.name != name);
13069 if pending_vl.len() == before {
13070 out = remove_attr_from_region(&out, name, true)?;
13071 }
13072 }
13073 }
13074 }
13075 Ok((out, pending_vl))
13076}
13077
13078/// The attribute storage an edit resolves to, and the header region that carries
13079/// it. Produced by [`plan_attr_ops`] in the commit preflight, so the apply phase
13080/// only places bytes.
13081struct AttrEdits {
13082 /// The object's chunk-0 message region with the edit applied. Compact: it
13083 /// already carries every fixed-size attribute. Dense: it carries no attribute
13084 /// message at all, and the Attribute Info message naming the heap is appended
13085 /// once that heap is placed.
13086 region: OhRegion,
13087 /// What the apply phase still has to place, and where the result is stored.
13088 attrs: EditedAttrs,
13089}
13090
13091/// What an attribute edit leaves for the apply phase, in the storage the result
13092/// belongs in. The two are alternatives rather than a pair of fields, because
13093/// every object has exactly one attribute storage: a compact result's
13094/// variable-length attributes are appended to the header as messages, a dense
13095/// one's are patched into the set the heap is then built over.
13096#[derive(Clone)]
13097enum EditedAttrs {
13098 /// Variable-length attributes still to be placed and appended as messages.
13099 /// Empty for an edit that resolved entirely in the preflight, and for one
13100 /// that left the object with no attributes at all.
13101 Compact(PendingVlAttrs),
13102 /// A set to rebuild into a fresh fractal heap.
13103 Dense(DenseAttrEdit),
13104}
13105
13106impl Default for EditedAttrs {
13107 /// An object with nothing left to place: what a group carries before an
13108 /// attribute edit reaches it, and what one without attribute edits keeps.
13109 fn default() -> Self {
13110 Self::Compact(Vec::new())
13111 }
13112}
13113
13114/// A dense (fractal-heap) attribute set and what its object records about
13115/// attribute creation order.
13116///
13117/// The two travel together because a dense set is never copied as bytes: the
13118/// heap is rebuilt from the parsed attributes wherever it lands, so the creation
13119/// indexes the old heap's indexes carried have to be carried alongside or they
13120/// are lost. An object that does not track the order leaves this
13121/// [`DenseAttrCreationOrder::Untracked`], which is every set this crate's
13122/// whole-file writer produces.
13123#[derive(Clone, Default)]
13124struct DenseAttrSet {
13125 attrs: Vec<crate::attribute::AttributeMessage>,
13126 creation: DenseAttrCreationOrder,
13127}
13128
13129impl DenseAttrSet {
13130 fn is_empty(&self) -> bool {
13131 self.attrs.is_empty()
13132 }
13133}
13134
13135/// A dense (fractal-heap) attribute set an edit rebuilds, staged by
13136/// [`plan_attr_ops`] and placed by the apply phase.
13137#[derive(Clone)]
13138struct DenseAttrEdit {
13139 /// Every attribute the object will carry: the ones it already had, in the
13140 /// order it had them, with this edit's applied over them by name — and what
13141 /// the object records about their creation order.
13142 set: DenseAttrSet,
13143 /// `(index into `attrs`, its global heap collections)` for each
13144 /// variable-length attribute this edit *sets*. The heap is built over the
13145 /// attribute message bytes, so each of these has to be patched with its
13146 /// collection's real address before the build rather than after it, which is
13147 /// why they are carried to the apply phase instead of resolved here.
13148 ///
13149 /// A variable-length attribute the object already had is not in this list and
13150 /// needs no patch: it was read back carrying the addresses it is stored with,
13151 /// and they name collections in this same file.
13152 vl: Vec<(usize, Vec<Vec<u8>>)>,
13153}
13154
13155/// Apply attribute edits to an object's header `region`, choosing the storage
13156/// the result belongs in (issue #102).
13157///
13158/// `addr` is the object's pre-commit object-header address, or `None` for a
13159/// group this commit creates, which has no stored attributes to read.
13160///
13161/// Three outcomes, in the order they are tried:
13162///
13163/// - **Compact.** The region is rewritten message by message, so every attribute
13164/// the edit does not name keeps its exact bytes. An ordinary `set_attr` on an
13165/// ordinary object takes this route.
13166/// - **Dense.** The object already stores its attributes in a fractal heap, or
13167/// this edit takes it past what an object header holds compactly — more than
13168/// [`MAX_COMPACT_ATTRS`] attributes, or one whose message overflows the
13169/// header's 2-byte message-size field. The object's whole attribute set is read
13170/// back, the edits are applied to it, and the apply phase builds a fresh heap
13171/// for the result. Those are the two halves of the disjunction
13172/// `file_writer::needs_dense_attrs` selects on, so an object an edit sends to a
13173/// heap is one the whole-file writer would have written to a heap — measured
13174/// here over the ops as staged, which is why setting an oversized attribute and
13175/// removing it again in one commit still sends the object to a heap.
13176/// - **Neither.** The edit removes the object's last attribute, so the rebuilt
13177/// header carries no attribute message and no Attribute Info message — what
13178/// this crate and the reference C library both write for an object with none.
13179///
13180/// A dense rebuild is a re-encoding rather than a byte copy: the attributes are
13181/// read into messages and re-serialized, as [`WriteEngine::read_object`] already
13182/// does for a copied object's heap. Name, datatype, dataspace and value survive
13183/// it; a version 1 or 2 message becomes the version 3 one dense storage holds,
13184/// and attribute creation order — which this crate indexes for neither storage
13185/// form — does not.
13186///
13187/// An object that is dense stays dense, even where the edit takes its set back
13188/// under the threshold. The reference C library keeps one dense down to its
13189/// `min_dense` (6 by default) as well, and re-encoding a set that is only losing
13190/// members buys nothing.
13191fn plan_attr_ops<S: Source + ?Sized>(
13192 src: &S,
13193 base: BaseAddress,
13194 addr: Option<u64>,
13195 region: &OhRegion,
13196 ops: &[&AttrOp],
13197) -> Result<AttrEdits, Error> {
13198 let dense_now = region_uses_dense_attrs(region)?;
13199 if !dense_now {
13200 // An attribute past the header's message-size field has no compact form
13201 // at all, so the compact pass would refuse it rather than report a count
13202 // this could act on. Ask before running it, not after.
13203 let oversized = ops.iter().any(|op| match op {
13204 AttrOp::Set { name, value } => {
13205 build_attr_message(name, value).serialize(LENGTH_SIZE).len()
13206 > OBJECT_HEADER_MESSAGE_MAX
13207 }
13208 AttrOp::Remove { .. } => false,
13209 });
13210 if !oversized {
13211 let (out, pending_vl) = apply_compact_attr_ops(region, ops)?;
13212 // An edit that only *removes* stays compact whatever the count is.
13213 // A region already holding more attributes than this writer would
13214 // emit came from another writer — the C library's `max_compact` is a
13215 // property list setting — and taking one away is no reason to
13216 // re-encode the ones that remain.
13217 let only_removes = !ops.iter().any(|op| matches!(op, AttrOp::Set { .. }));
13218 if only_removes || compact_attr_count(&out)? + pending_vl.len() <= MAX_COMPACT_ATTRS {
13219 return Ok(AttrEdits {
13220 region: out,
13221 attrs: EditedAttrs::Compact(pending_vl),
13222 });
13223 }
13224 }
13225 }
13226
13227 // The compact pass refuses a shared attribute message as it walks the
13228 // messages it copies; this path never walks them, so it asks here — before
13229 // the read below resolves one into a message indistinguishable from a private
13230 // attribute.
13231 if region_has_shared_attr(region)? {
13232 return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13233 }
13234
13235 // A heap is rebuilt from the object's whole attribute set — half of which may
13236 // live in a heap the header only names — so read that set back before
13237 // applying the edits to it. A group this commit creates has none.
13238 let existing = match addr {
13239 Some(addr) => read_object_attrs(src, AccessMode::ReadWrite, addr, base)?,
13240 None => Vec::new(),
13241 };
13242 // Each attribute travels with the global heap collections it still needs
13243 // placed (`Some` only for a variable-length attribute this edit sets) and
13244 // with the creation index its object records for it, so no removal can shift
13245 // one away from the bytes — or the index — it belongs to.
13246 let existing = dense_attr_set(region, existing)?;
13247 let tracked = region.layout().tracks_creation_order();
13248 // The next index the object would hand out: what a *new* attribute takes.
13249 // `dense_attr_set` has already raised it past every index in use.
13250 let mut next_index = match &existing.creation {
13251 DenseAttrCreationOrder::Tracked { max, .. } => *max,
13252 DenseAttrCreationOrder::Untracked => 0,
13253 };
13254 let mut set: Vec<DenseAttrSlot> = existing
13255 .attrs
13256 .into_iter()
13257 .enumerate()
13258 .map(|(i, msg)| DenseAttrSlot {
13259 msg,
13260 collections: None,
13261 creation_index: existing.creation.index_at(i),
13262 })
13263 .collect();
13264 for op in ops {
13265 match op {
13266 AttrOp::Set { name, value } => {
13267 let msg = build_attr_message(name, value);
13268 let collections = value.var_len_strings().map(build_global_heap_collections);
13269 match set.iter_mut().find(|slot| &slot.msg.name == name) {
13270 // Setting an attribute the object already has replaces it
13271 // where it stands, so a repeated `set_attr` does not reorder
13272 // the set — and it keeps the creation index it had, so an
13273 // iteration by creation order does not reorder it either.
13274 Some(slot) => {
13275 slot.msg = msg;
13276 slot.collections = collections;
13277 }
13278 None => {
13279 let creation_index = tracked.then_some(next_index);
13280 if tracked {
13281 next_index = bump_creation_index(next_index)?;
13282 }
13283 set.push(DenseAttrSlot {
13284 msg,
13285 collections,
13286 creation_index,
13287 });
13288 }
13289 }
13290 }
13291 AttrOp::Remove { name } => {
13292 let before = set.len();
13293 // A deletion leaves a gap in the creation order rather than
13294 // renumbering what is left, and does not lower the maximum:
13295 // the reference C library hands out indexes from a counter that
13296 // only ever rises.
13297 set.retain(|slot| &slot.msg.name != name);
13298 if set.len() == before {
13299 return Err(Error::EditUnsupported("attribute to remove was not found"));
13300 }
13301 }
13302 }
13303 }
13304
13305 // Every attribute message is rebuilt from the set above, so the region keeps
13306 // none of them, nor the Attribute Info message naming the storage they were
13307 // in.
13308 let region = strip_attr_messages(region)?;
13309 // No attributes left: the header carries neither an Attribute message nor an
13310 // Attribute Info one. `append_dense_attrs` would decline to build a heap for
13311 // an empty set anyway; saying `None` here is what makes that a property of
13312 // this decision rather than of the emitter that carries it out.
13313 if set.is_empty() {
13314 return Ok(AttrEdits {
13315 region,
13316 attrs: EditedAttrs::default(),
13317 });
13318 }
13319 let mut attrs = Vec::with_capacity(set.len());
13320 let mut indices = Vec::with_capacity(set.len());
13321 let mut vl = Vec::new();
13322 for (i, slot) in set.into_iter().enumerate() {
13323 if let Some(collections) = slot.collections {
13324 vl.push((i, collections));
13325 }
13326 // Every slot of a tracked object carries an index: one read from its
13327 // storage, or one this edit just handed out. `indices` is discarded for
13328 // an untracked object, where none of them do.
13329 debug_assert!(
13330 !tracked || slot.creation_index.is_some(),
13331 "a tracked object's attribute must carry a creation index"
13332 );
13333 indices.push(slot.creation_index.unwrap_or_default());
13334 attrs.push(slot.msg);
13335 }
13336 let creation = if tracked {
13337 DenseAttrCreationOrder::Tracked {
13338 indices,
13339 max: next_index,
13340 indexed: region.layout().indexes_creation_order(),
13341 }
13342 } else {
13343 DenseAttrCreationOrder::Untracked
13344 };
13345 // Moving a set into a heap takes it out of reach of the reference repointing
13346 // a commit does as its last act: `reference_patch::scan_object` reads an
13347 // object whose attributes are dense as unproven and collects no edit from it.
13348 // An object *already* dense is not this refusal's business — its attributes
13349 // stood outside that walk before this edit and still do — but converting one
13350 // would strand a reference the header was keeping reachable, and permanently,
13351 // since an object that goes dense stays dense (issue #324).
13352 if !dense_now {
13353 for attr in &attrs {
13354 if crate::reference_patch::attribute_references_are_repointable(&attr.datatype) {
13355 return Err(Error::EditUnsupported(
13356 REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER,
13357 ));
13358 }
13359 }
13360 }
13361 // What the heap itself cannot represent is the last refusal, and it belongs
13362 // in the preflight: past here the commit is placing bytes.
13363 crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
13364 Ok(AttrEdits {
13365 region,
13366 attrs: EditedAttrs::Dense(DenseAttrEdit {
13367 set: DenseAttrSet { attrs, creation },
13368 vl,
13369 }),
13370 })
13371}
13372
13373/// One attribute of the set a dense rebuild is assembled from.
13374struct DenseAttrSlot {
13375 msg: crate::attribute::AttributeMessage,
13376 /// Global heap collections still to be placed, for a variable-length
13377 /// attribute *this edit* sets.
13378 collections: Option<Vec<Vec<u8>>>,
13379 /// The creation index the object records for it, where it tracks the order.
13380 creation_index: Option<u16>,
13381}
13382
13383/// Whether an object's chunk-0 message `region` stores its attributes densely —
13384/// an Attribute Info message naming a fractal heap. See
13385/// [`attribute_info_is_dense`] for why the message's mere presence is not that.
13386fn region_uses_dense_attrs(region: &OhRegion) -> Result<bool, Error> {
13387 let mut p = 0;
13388 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13389 if msg_type == MessageType::ATTRIBUTE_INFO
13390 && attribute_info_is_dense(®ion[body..body_end])
13391 {
13392 return Ok(true);
13393 }
13394 p = body_end;
13395 }
13396 Ok(false)
13397}
13398
13399/// Whether any Attribute message in `region` carries a message flag — which for
13400/// an attribute means the body is a shared (SOHM) record rather than the
13401/// attribute itself. Reads the same byte [`parse_compact_attr_name`] does, so the
13402/// two attribute paths refuse exactly the same messages.
13403fn region_has_shared_attr(region: &OhRegion) -> Result<bool, Error> {
13404 let mut p = 0;
13405 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13406 if msg_type == MessageType::ATTRIBUTE && region[p + 3] != 0 {
13407 return Ok(true);
13408 }
13409 p = body_end;
13410 }
13411 Ok(false)
13412}
13413
13414/// Copy a chunk-0 message `region`, dropping every Attribute message and the
13415/// Attribute Info message that names their storage. What the object carries in
13416/// their place is [`plan_attr_ops`]'s decision, appended by the apply phase.
13417fn strip_attr_messages(region: &OhRegion) -> Result<OhRegion, Error> {
13418 let mut out = Vec::with_capacity(region.len());
13419 let mut p = 0;
13420 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13421 if !matches!(
13422 msg_type,
13423 MessageType::ATTRIBUTE | MessageType::ATTRIBUTE_INFO
13424 ) {
13425 out.extend_from_slice(®ion[p..body_end]);
13426 }
13427 p = body_end;
13428 }
13429 if p < region.len() {
13430 out.extend_from_slice(®ion[p..]);
13431 }
13432 Ok(region.with_bytes(out))
13433}
13434
13435/// Every attribute an object carries, compact or dense, parsed into messages.
13436///
13437/// This read is what makes a dense edit possible at all: the set an edit works
13438/// from is the object's whole attribute set, and for a dense object none of it
13439/// is in the header — `extract_attributes_full_from_source` walks the fractal
13440/// heap the Attribute Info message names. It reads both storage forms, so no
13441/// caller has to ask which one an object uses.
13442///
13443/// Shared with [`WriteEngine::read_object`], which needs the same set for the
13444/// same reason when it copies a dense object. That caller checks
13445/// [`crate::file_writer::dense_attrs_check`] on what it reads; the attribute
13446/// editor checks it after applying its edits, on the set it will actually write.
13447fn read_object_attrs<S: Source + ?Sized>(
13448 src: &S,
13449 access_mode: AccessMode,
13450 addr: u64,
13451 base: BaseAddress,
13452) -> Result<Vec<crate::attribute::StoredAttribute>, Error> {
13453 let header =
13454 ObjectHeader::parse_from_source(src, access_mode, addr, OFFSET_SIZE, LENGTH_SIZE, base)?;
13455 if base.get() > src.len() {
13456 return Err(Error::EditUnsupported(
13457 "this file's userblock is larger than the file itself",
13458 ));
13459 }
13460 // Heap addresses in the Attribute Info message are stored relative to the
13461 // base address, so the walk gets the file framed past its userblock — the
13462 // same view the reader uses. `base` is 0 for a plain file, where this is
13463 // `src` itself.
13464 let framed = BaseOffsetSource { inner: src, base };
13465 crate::attribute::extract_stored_attributes_from_source(
13466 &framed,
13467 access_mode,
13468 &header,
13469 OFFSET_SIZE,
13470 LENGTH_SIZE,
13471 // No shared-message table: this engine refuses an object carrying a
13472 // shared attribute message before it gets here ([`SHARED_ATTRIBUTE_MESSAGE`]),
13473 // and resolving one silently is exactly what that refusal prevents — the
13474 // resolved copy is indistinguishable from a private attribute, and would
13475 // be re-emitted as one, leaving the file's reference count naming an
13476 // attribute that no longer exists.
13477 None,
13478 )
13479 .map_err(Error::Format)
13480}
13481
13482/// Assemble a [`DenseAttrSet`] from an object's stored attributes and what its
13483/// header `region` says about attribute creation order.
13484///
13485/// An object that does not track the order gets
13486/// [`DenseAttrCreationOrder::Untracked`], which is the whole story for every
13487/// file this crate writes itself. One that does keeps each attribute's stored
13488/// index, and the maximum its Attribute Info message records — the next index it
13489/// would hand out, which a deletion does not lower, so it cannot be recomputed
13490/// from the attributes that remain. An attribute whose storage recorded no index
13491/// (a header that claims to track the order and then does not) is given the next
13492/// unused one rather than silently colliding with a real index.
13493fn dense_attr_set(
13494 region: &OhRegion,
13495 stored: Vec<crate::attribute::StoredAttribute>,
13496) -> Result<DenseAttrSet, Error> {
13497 let layout = region.layout();
13498 if !layout.tracks_creation_order() {
13499 return Ok(DenseAttrSet {
13500 attrs: stored.into_iter().map(|a| a.message).collect(),
13501 creation: DenseAttrCreationOrder::Untracked,
13502 });
13503 }
13504 let mut next = next_attr_creation_index(region)?;
13505 let mut attrs = Vec::with_capacity(stored.len());
13506 let mut indices = Vec::with_capacity(stored.len());
13507 for attr in stored {
13508 let index = match attr.creation_index {
13509 Some(index) => index,
13510 None => {
13511 let index = next;
13512 next = bump_creation_index(next)?;
13513 index
13514 }
13515 };
13516 next = next.max(bump_creation_index(index)?);
13517 attrs.push(attr.message);
13518 indices.push(index);
13519 }
13520 Ok(DenseAttrSet {
13521 attrs,
13522 creation: DenseAttrCreationOrder::Tracked {
13523 indices,
13524 max: next,
13525 indexed: layout.indexes_creation_order(),
13526 },
13527 })
13528}
13529
13530/// One past `index`, refusing the object that has exhausted the 2-byte creation
13531/// index its Attribute Info message records.
13532fn bump_creation_index(index: u16) -> Result<u16, Error> {
13533 index.checked_add(1).ok_or(Error::EditUnsupported(
13534 "an object has assigned every attribute creation index its header can record",
13535 ))
13536}
13537
13538/// Whether an Attribute Info (0x0015) message body denotes *dense* (fractal-heap)
13539/// attribute storage — a *defined* heap address. The reference C library and h5py
13540/// emit an Attribute Info message with an *undefined* heap address even for
13541/// compact, inline attributes in the latest format (to carry creation-order
13542/// metadata), so its mere presence is not dense storage; only a defined heap
13543/// address is. An unparseable message is treated as dense (refused conservatively).
13544/// Mirrors the copy path's dense detection so the compact-attribute editors accept
13545/// the undefined-address message that nearly every real-world object carries.
13546pub(crate) fn attribute_info_is_dense(body: &[u8]) -> bool {
13547 match crate::attribute_info::AttributeInfoMessage::parse(body, OFFSET_SIZE) {
13548 Ok(ai) => ai.fractal_heap_address.is_some(),
13549 Err(_) => true,
13550 }
13551}
13552
13553/// Copy a message region, dropping all Attribute messages named `name` and then
13554/// appending a fresh compact Attribute message for `value`.
13555fn set_attr_in_region(region: &OhRegion, name: &str, value: &AttrValue) -> Result<OhRegion, Error> {
13556 let body = encode_attr_body(name, value)?;
13557 let keep = attr_creation_index(region, name)?;
13558 put_attr_message(region, name, &body, keep)
13559}
13560
13561/// The creation index the Attribute message named `name` carries, or `None`
13562/// where the object has no such attribute — or does not track the order at all.
13563fn attr_creation_index(region: &OhRegion, name: &str) -> Result<Option<u16>, Error> {
13564 if !region.layout().tracks_creation_order() {
13565 return Ok(None);
13566 }
13567 let mut p = 0;
13568 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13569 if msg_type == MessageType::ATTRIBUTE
13570 && parse_compact_attr_name(region, p, body, body_end)? == name
13571 {
13572 return Ok(region.creation_index(p));
13573 }
13574 p = body_end;
13575 }
13576 Ok(None)
13577}
13578
13579/// The highest creation index any Attribute record in `region` carries.
13580fn highest_attr_creation_index(region: &OhRegion) -> Result<Option<u16>, Error> {
13581 let mut highest = None;
13582 let mut p = 0;
13583 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13584 if msg_type == MessageType::ATTRIBUTE {
13585 highest = highest.max(region.creation_index(p));
13586 }
13587 p = body_end;
13588 }
13589 Ok(highest)
13590}
13591
13592/// The next unused attribute creation index for an object whose messages are
13593/// `region`.
13594///
13595/// The reference C library hands out `ainfo.max_crt_idx` and increments it
13596/// (`H5O__attr_create`), so the recorded maximum is the *next* index rather
13597/// than the highest in use, and deleting an attribute never lowers it. Read it
13598/// from the Attribute Info message where there is one, and otherwise derive one
13599/// past the highest index the records carry — the best evidence a header with no
13600/// such message leaves.
13601fn next_attr_creation_index(region: &OhRegion) -> Result<u16, Error> {
13602 let recorded = find_attribute_info(region)?
13603 .and_then(|(_, _, info)| info.max_creation_index)
13604 .unwrap_or(0);
13605 let derived = highest_attr_creation_index(region)?.map_or(0, |i| u32::from(i) + 1);
13606 let next = u32::from(recorded).max(derived);
13607 next.narrow_or_else(|| {
13608 Error::EditUnsupported(
13609 "an object has assigned every attribute creation index its header can record",
13610 )
13611 })
13612}
13613
13614/// The Attribute Info message in `region`, as `(record start, body range, parsed
13615/// message)`. An unparseable message is reported as absent, which leaves it
13616/// untouched: `attribute_info_is_dense` reads the same message and refuses the
13617/// object rather than letting an edit rewrite one it did not understand.
13618fn find_attribute_info(
13619 region: &OhRegion,
13620) -> Result<Option<(usize, core::ops::Range<usize>, AttributeInfoMessage)>, Error> {
13621 let mut p = 0;
13622 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13623 if msg_type == MessageType::ATTRIBUTE_INFO
13624 && let Ok(info) = AttributeInfoMessage::parse(®ion[body..body_end], OFFSET_SIZE)
13625 {
13626 return Ok(Some((p, body..body_end, info)));
13627 }
13628 p = body_end;
13629 }
13630 Ok(None)
13631}
13632
13633/// The Attribute Info message body an object storing its attributes compactly
13634/// needs, in `region`'s layout.
13635///
13636/// A header that tracks attribute creation order records the maximum index ever
13637/// assigned here, and nowhere else, so a header carrying no such message leaves
13638/// only the records to derive it from: one past the highest index in use. That
13639/// under-counts an object whose most recently created attributes have since been
13640/// deleted, which is why an edit *patches* an existing message
13641/// ([`put_attr_message`]) rather than rebuilding one.
13642fn compact_attribute_info_body(region: &OhRegion) -> Result<Vec<u8>, Error> {
13643 let layout = region.layout();
13644 let info = AttributeInfoMessage {
13645 max_creation_index: layout
13646 .tracks_creation_order()
13647 .then(|| next_attr_creation_index(region))
13648 .transpose()?,
13649 indexes_creation_order: layout.indexes_creation_order(),
13650 fractal_heap_address: None,
13651 btree_name_index_address: None,
13652 btree_creation_order_address: None,
13653 };
13654 Ok(info.serialize(OFFSET_SIZE))
13655}
13656
13657/// Copy a message region, dropping every Attribute message named `name` and
13658/// appending one carrying `body`, then keep the object's Attribute Info message
13659/// in step with the creation index the new record takes.
13660///
13661/// `keep` is the index an *overwrite* preserves: the reference C library modifies
13662/// an attribute in place, so writing over one does not move it in an iteration
13663/// by creation order. `None` asks for the object's next unused index, which then
13664/// becomes the maximum the Attribute Info message records. Both are inert on a
13665/// header that does not track the order, where no record has the field.
13666fn put_attr_message(
13667 region: &OhRegion,
13668 name: &str,
13669 body: &[u8],
13670 keep: Option<u16>,
13671) -> Result<OhRegion, Error> {
13672 let layout = region.layout();
13673 let (creation_index, new_max) = if layout.tracks_creation_order() {
13674 match keep {
13675 Some(index) => (index, None),
13676 None => {
13677 let index = next_attr_creation_index(region)?;
13678 let next = index.checked_add(1).ok_or(Error::EditUnsupported(
13679 "an object has assigned every attribute creation index its header can record",
13680 ))?;
13681 (index, Some(next))
13682 }
13683 }
13684 } else {
13685 (0, None)
13686 };
13687 let new_msg = layout.record_with_creation_index(MessageType::ATTRIBUTE, body, creation_index);
13688
13689 let mut out = Vec::with_capacity(region.len() + new_msg.len());
13690 let mut p = 0;
13691 while let Some((msg_type, msg_body, body_end)) = region.next_message(p)? {
13692 match msg_type {
13693 MessageType::ATTRIBUTE
13694 if parse_compact_attr_name(region, p, msg_body, body_end)? == name =>
13695 {
13696 p = body_end;
13697 continue;
13698 }
13699 // Bumping the recorded maximum re-encodes the message, since a
13700 // header that was not recording one at all needs the field added.
13701 MessageType::ATTRIBUTE_INFO if new_max.is_some() => {
13702 if let Ok(mut info) =
13703 AttributeInfoMessage::parse(®ion[msg_body..body_end], OFFSET_SIZE)
13704 {
13705 info.max_creation_index = new_max;
13706 info.indexes_creation_order |= layout.indexes_creation_order();
13707 let encoded = info.serialize(OFFSET_SIZE);
13708 let len: u16 = encoded.len().narrow_or_else(|| {
13709 Error::EditUnsupported("an Attribute Info message is too large to record")
13710 })?;
13711 // Only the body and its length change: the record keeps its
13712 // own flags — the reference C library marks this message
13713 // "don't share" — and its creation index.
13714 out.push(region[p]);
13715 out.extend_from_slice(&len.to_le_bytes());
13716 out.extend_from_slice(®ion[p + 3..msg_body]);
13717 out.extend_from_slice(&encoded);
13718 p = body_end;
13719 continue;
13720 }
13721 out.extend_from_slice(®ion[p..body_end]);
13722 }
13723 _ => out.extend_from_slice(®ion[p..body_end]),
13724 }
13725 p = body_end;
13726 }
13727 out.extend_from_slice(&new_msg);
13728 if p < region.len() {
13729 out.extend_from_slice(®ion[p..]);
13730 }
13731 let mut out = region.with_bytes(out);
13732 // An object that had no Attribute Info message to bump gets the one
13733 // `build_v2_object_header` would add anyway, now, so the maximum this edit
13734 // just assigned is recorded rather than re-derived from the records.
13735 if new_max.is_some() && find_attribute_info(&out)?.is_none() {
13736 let body = compact_attribute_info_body(&out)?;
13737 out.push(MessageType::ATTRIBUTE_INFO, &body);
13738 }
13739 Ok(out)
13740}
13741
13742/// Copy a message region, dropping all Attribute messages named `name`. When
13743/// `required` is true, an absent `name` is an [`Error::EditUnsupported`] (a
13744/// `Remove` of a nonexistent attribute); when false, it is not an error (a
13745/// `Set` of a fresh variable-length attribute may have no fixed-size message
13746/// to remove from the region yet).
13747fn remove_attr_from_region(
13748 region: &OhRegion,
13749 name: &str,
13750 required: bool,
13751) -> Result<OhRegion, Error> {
13752 let mut out = Vec::with_capacity(region.len());
13753 let mut p = 0;
13754 let mut removed = false;
13755 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13756 let mut skip = false;
13757 if msg_type == MessageType::ATTRIBUTE {
13758 let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
13759 if attr_name == name {
13760 skip = true;
13761 removed = true;
13762 }
13763 }
13764 if !skip {
13765 out.extend_from_slice(®ion[p..body_end]);
13766 }
13767 p = body_end;
13768 }
13769 if p < region.len() {
13770 out.extend_from_slice(®ion[p..]);
13771 }
13772 if !removed && required {
13773 return Err(Error::EditUnsupported("attribute to remove was not found"));
13774 }
13775 Ok(region.with_bytes(out))
13776}
13777
13778/// How many attributes an object's message `region` carries inline. Only a
13779/// compact region is ever counted this way: a dense object carries no inline
13780/// Attribute message at all, and would count zero — which is why
13781/// [`plan_attr_ops`] decides an object's storage before it reaches here.
13782fn compact_attr_count(region: &OhRegion) -> Result<usize, Error> {
13783 let mut count = 0usize;
13784 let mut p = 0;
13785 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13786 if msg_type == MessageType::ATTRIBUTE {
13787 count += 1;
13788 }
13789 p = body_end;
13790 }
13791 Ok(count)
13792}
13793
13794fn parse_compact_attr_name(
13795 region: &[u8],
13796 msg_start: usize,
13797 body: usize,
13798 body_end: usize,
13799) -> Result<String, Error> {
13800 if region[msg_start + 3] != 0 {
13801 return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13802 }
13803 // Only the name is wanted here, and it is the one field that never depends on
13804 // the datatype or dataspace — either of which may be a reference to a
13805 // committed message this walk has no file context to follow. Reading the name
13806 // alone lets an edit pass over such an attribute instead of refusing the
13807 // whole object because one of its neighbours is committed.
13808 crate::attribute::message_name(®ion[body..body_end]).map_err(Error::Format)
13809}
13810
13811fn encode_attr_body(name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
13812 // `apply_compact_attr_ops`'s `Set` branch — this function's only caller —
13813 // handles a value that needs the global heap itself (staging it into
13814 // `pending_vl` instead of calling `set_attr_in_region`/here), so this value
13815 // is always inline by construction, not by a check made at this call site.
13816 debug_assert!(
13817 value.var_len_strings().is_none(),
13818 "a variable-length attribute must be intercepted by apply_compact_attr_ops before reaching encode_attr_message"
13819 );
13820 let body = build_attr_message(name, value).serialize(LENGTH_SIZE);
13821 if body.len() > OBJECT_HEADER_MESSAGE_MAX {
13822 return Err(Error::EditUnsupported(
13823 "group attribute is too large to encode in place",
13824 ));
13825 }
13826 Ok(body)
13827}
13828
13829/// Parse the version-2 object-header message record at `p` within a chunk-0
13830/// message region, returning `(message type, body start, body end)`; the next
13831/// record begins at `body end`. Returns `Ok(None)` once fewer than 4 bytes
13832/// remain (a clean end of the region), and `Err` if a record's declared body
13833/// runs past the region. Centralizes the bounds check shared by every walker.
13834/// Rebuild a superblock-extension object header's message region (as collapsed by
13835/// [`WriteEngine::gather_oh_messages`]) with
13836/// its File Space Info message replaced by `info`, preserving every other message
13837/// verbatim. The persisting message is fixed-size, so the region length is stable.
13838/// Shared by the whole-file mirror commit and the bounded finalize so both write
13839/// the same extension bytes.
13840pub(crate) fn rewrite_extension_region_bytes(
13841 region: &OhRegion,
13842 info: &FileSpaceInfo,
13843) -> Result<OhRegion, Error> {
13844 let new_body = info.serialize();
13845 // The message body is the fixed-size File Space Info record (≤ 125 bytes),
13846 // so it always fits the u16 size field.
13847 let new_len: u16 = new_body
13848 .len()
13849 .narrow_or_else(|| Error::EditUnsupported("File Space Info message too large"))?;
13850 let mut out = Vec::with_capacity(region.len());
13851 let mut p = 0;
13852 let mut replaced = false;
13853 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13854 if msg_type == MessageType::FILE_SPACE_INFO {
13855 out.push(region[p]); // message type byte
13856 out.extend_from_slice(&new_len.to_le_bytes());
13857 // Preserve the message flags (0x14) and, where the header carries
13858 // one, the record's creation index.
13859 out.extend_from_slice(®ion[p + 3..p + region.layout().prefix_len()]);
13860 out.extend_from_slice(&new_body);
13861 replaced = true;
13862 } else {
13863 out.extend_from_slice(®ion[p..body_end]);
13864 }
13865 p = body_end;
13866 }
13867 if !replaced {
13868 // Persistence is armed only when the extension already carries a File
13869 // Space Info message, so this is unreachable; refuse rather than
13870 // silently restructure an extension we did not understand.
13871 return Err(Error::EditUnsupported(
13872 "a persisting file's superblock extension has no File Space Info message",
13873 ));
13874 }
13875 Ok(region.with_bytes(out))
13876}
13877
13878/// Parse and validate a version 2 object header's prefix, returning the absolute
13879/// `[start, end)` byte range of its chunk-0 message region.
13880///
13881/// `prefix` holds the bytes at `[addr, addr + prefix.len())` — up to
13882/// [`OH_PREFIX_MAX`], fewer when the header sits near the end of the image — and
13883/// `file_len` is the length of the image the header lives in, which bounds the
13884/// region. Rejects headers that are not OHDR v2. Everything the prefix declares
13885/// ([`OhHeaderProps`]) is returned with the region: the record layout, because a
13886/// header that tracks attribute creation order carries 6-byte message records
13887/// and every walk of this region has to step by that width, and the two optional
13888/// blocks, because nothing below the prefix records them and a rebuild has to
13889/// put them back.
13890fn oh_region_at(
13891 prefix: &[u8],
13892 addr: u64,
13893 file_len: u64,
13894) -> Result<(u64, u64, OhHeaderProps), Error> {
13895 if prefix.len() < 6 || &prefix[..4] != b"OHDR" || prefix[4] != 2 {
13896 return Err(Error::EditUnsupported(
13897 "an object does not use a version 2 object header",
13898 ));
13899 }
13900 let flags = prefix[5];
13901 let layout = OhRecordLayout::from_header_flags(flags);
13902 let mut pos = 6usize;
13903 let mut take = |len: usize| -> Result<usize, Error> {
13904 let at = pos;
13905 pos += len;
13906 if prefix.len() < pos {
13907 return Err(Error::EditUnsupported("truncated object header"));
13908 }
13909 Ok(at)
13910 };
13911 let times = if flags & OH_FLAG_STORE_TIMES != 0 {
13912 let at = take(ObjectTimes::LEN)?;
13913 Some(ObjectTimes::parse(prefix, at))
13914 } else {
13915 None
13916 };
13917 let attr_phase_change = if flags & OH_FLAG_ATTR_PHASE_CHANGE != 0 {
13918 let at = take(AttrPhaseChange::LEN)?;
13919 Some(AttrPhaseChange::parse(prefix, at))
13920 } else {
13921 None
13922 };
13923 let props = OhHeaderProps {
13924 layout,
13925 times,
13926 attr_phase_change,
13927 };
13928 let size_width = usize::from(UintWidth::from_flags(flags).get());
13929 if prefix.len() < pos + size_width {
13930 return Err(Error::EditUnsupported("truncated object header"));
13931 }
13932 let chunk0_size = read_le(&prefix[pos..pos + size_width]) as u64;
13933 pos += size_width;
13934 let region_start = addr
13935 .checked_add(pos as u64)
13936 .ok_or(Error::EditUnsupported("truncated object header"))?;
13937 // The region is followed by a 4-byte checksum, which must also be present.
13938 let region_end = region_start
13939 .checked_add(chunk0_size)
13940 .filter(|e| e.checked_add(4).is_some_and(|end| end <= file_len))
13941 .ok_or(Error::EditUnsupported("truncated object header"))?;
13942 Ok((region_start, region_end, props))
13943}
13944
13945/// One chunk of a version 2 object header, read out of a file image.
13946///
13947/// The buffer stops at the end of the chunk's message region: the trailing
13948/// checksum is never walked, and it has already been confirmed present. `span`
13949/// covers the *whole* on-disk chunk including that checksum, so it can be handed
13950/// to the free list when the header is reclaimed.
13951pub(crate) struct OhChunk {
13952 /// Absolute file address and full on-disk length of the chunk.
13953 pub(crate) span: (u64, u64),
13954 /// The chunk's bytes, from `span.0` through the end of its message region.
13955 buf: Vec<u8>,
13956 /// Offset of the first message within [`buf`](Self::buf).
13957 messages_start: usize,
13958 /// What chunk 0 of this header declared in its prefix, which every chunk of
13959 /// the header shares.
13960 props: OhHeaderProps,
13961}
13962
13963impl OhChunk {
13964 /// The slice to walk messages in, and the offset to start at. The two are
13965 /// returned together because [`OhRecordLayout::next_message`] must not read
13966 /// past the end of the message region into the checksum.
13967 pub(crate) fn message_region(&self) -> (&[u8], usize) {
13968 (&self.buf, self.messages_start)
13969 }
13970
13971 /// The record layout to walk [`message_region`](Self::message_region) in.
13972 pub(crate) fn layout(&self) -> OhRecordLayout {
13973 self.props.layout
13974 }
13975
13976 /// Everything this header's chunk-0 prefix declared.
13977 pub(crate) fn props(&self) -> OhHeaderProps {
13978 self.props
13979 }
13980}
13981
13982/// Read chunk 0 of the version 2 object header at `addr` out of `src`.
13983fn read_oh_chunk0<S: Source + ?Sized>(src: &S, addr: u64) -> Result<OhChunk, Error> {
13984 let file_len = src.len();
13985 let window = file_len
13986 .saturating_sub(addr)
13987 .min(OH_PREFIX_MAX as u64)
13988 .to_usize()?;
13989 let prefix = src.read_metadata_at(addr, window)?;
13990 let (rs, re, props) = oh_region_at(&prefix, addr, file_len)?;
13991 // `re >= rs > addr`, so both differences are non-negative, and `oh_region_at`
13992 // has checked that the 4-byte checksum past `re` is present.
13993 let len = (re - addr).to_usize()?;
13994 Ok(OhChunk {
13995 span: (addr, len as u64 + 4),
13996 buf: src.read_metadata_at(addr, len)?,
13997 messages_start: (rs - addr).to_usize()?,
13998 props,
13999 })
14000}
14001
14002/// Read every chunk of the version 2 object header at `addr`, chunk 0 first,
14003/// following each `Continuation` message to its `OCHK` block.
14004///
14005/// This is the one traversal of a header's chunk chain: [`WriteEngine::gather_oh_messages`]
14006/// collects the messages out of the result and
14007/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) collects the extents, so the
14008/// two cannot disagree about what a header occupies.
14009pub(crate) fn read_oh_chunks<S: Source + ?Sized>(
14010 src: &S,
14011 addr: u64,
14012 base: BaseAddress,
14013) -> Result<Vec<OhChunk>, Error> {
14014 let mut chunks = vec![read_oh_chunk0(src, addr)?];
14015 let mut i = 0;
14016 while i < chunks.len() {
14017 if chunks.len() > MAX_OH_CHUNKS {
14018 return Err(Error::EditUnsupported(
14019 "object header has too many continuation chunks",
14020 ));
14021 }
14022 // Collect this chunk's continuations before extending the worklist, so the
14023 // borrow of `chunks[i]` ends first.
14024 let mut found = Vec::new();
14025 let props = chunks[i].props();
14026 let layout = props.layout;
14027 let (region, mut p) = chunks[i].message_region();
14028 while let Some((msg_type, body, body_end)) = layout.next_message(region, p)? {
14029 if msg_type == MessageType::OBJECT_HEADER_CONTINUATION {
14030 found.push(read_oh_continuation(
14031 src, region, body, body_end, base, props,
14032 )?);
14033 }
14034 p = body_end;
14035 }
14036 i += 1;
14037 chunks.extend(found);
14038 }
14039 Ok(chunks)
14040}
14041
14042/// Read the `OCHK` continuation block a continuation message points at.
14043///
14044/// `region[body..body_end]` is the continuation message's body: the block's
14045/// base-relative address followed by its length.
14046fn read_oh_continuation<S: Source + ?Sized>(
14047 src: &S,
14048 region: &[u8],
14049 body: usize,
14050 body_end: usize,
14051 base: BaseAddress,
14052 props: OhHeaderProps,
14053) -> Result<OhChunk, Error> {
14054 if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
14055 return Err(Error::EditUnsupported("malformed continuation message"));
14056 }
14057 let off = u64::from_le_bytes(region[body..body + 8].try_into().unwrap());
14058 let len = u64::from_le_bytes(region[body + 8..body + 16].try_into().unwrap());
14059 // The block address is stored relative to the base address; shift it to an
14060 // absolute file offset before reading.
14061 let off = base.absolute(StoredAddress::new(off))?;
14062 // An OCHK block is signature(4) + messages + checksum(4).
14063 let end = off
14064 .checked_add(len)
14065 .filter(|&e| e <= src.len() && len >= 8)
14066 .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
14067 let want = (end - off).to_usize()?;
14068 let mut buf = src.read_metadata_at(off, want)?;
14069 if buf[..4] != *b"OCHK" {
14070 return Err(Error::EditUnsupported(
14071 "invalid continuation block signature",
14072 ));
14073 }
14074 // Trim the trailing checksum so the message walk stops at the last message.
14075 buf.truncate(want - 4);
14076 Ok(OhChunk {
14077 span: (off, len),
14078 buf,
14079 messages_start: 4,
14080 props,
14081 })
14082}
14083
14084/// How a version 2 object header's message records are laid out, and what its
14085/// flags say about attribute creation order.
14086///
14087/// Every record opens with a type byte, a 2-byte body size and a flags byte. A
14088/// header that *tracks* attribute creation order — bit 2 of the object header's
14089/// own flags, what `H5Pset_attr_creation_order` and h5py's `track_order=True`
14090/// turn on, and what netCDF-4 sets on every object it writes — follows those
14091/// with a 2-byte creation index, so its records are 6 bytes wide rather than 4.
14092/// A header that also *indexes* that order (bit 3) carries a creation-order
14093/// B-tree beside the name index once its attributes go dense; the reference C
14094/// library reads that bit back out of the header when it builds an Attribute
14095/// Info message, so a rewrite that dropped it would quietly stop indexing.
14096///
14097/// Both bits are properties of the whole header, so chunk 0 and every
14098/// continuation block of one header share a layout. Carrying it beside the bytes
14099/// ([`OhRegion`]) is what keeps the two dozen walkers and the emitters in this
14100/// module from having to agree about it one by one.
14101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14102pub(crate) struct OhRecordLayout {
14103 /// Records carry a 2-byte creation index (object header flags bit 2).
14104 tracked: bool,
14105 /// Dense attribute storage indexes that order (object header flags bit 3).
14106 indexed: bool,
14107}
14108
14109/// Object header flags bit 2: message creation order is tracked, so every
14110/// message record carries a creation index.
14111const OH_FLAG_CREATION_ORDER_TRACKED: u8 = 0x04;
14112
14113/// Object header flags bit 3: attribute creation order is *indexed* as well as
14114/// tracked, so dense attribute storage carries a creation-order B-tree beside
14115/// its name index.
14116const OH_FLAG_CREATION_ORDER_INDEXED: u8 = 0x08;
14117
14118/// Object header flags bit 4: the header prefix carries the attribute
14119/// phase-change thresholds (`H5O_HDR_ATTR_STORE_PHASE_CHANGE`).
14120const OH_FLAG_ATTR_PHASE_CHANGE: u8 = 0x10;
14121
14122/// Object header flags bit 5: the header prefix carries the four access,
14123/// modification, change and birth timestamps (`H5O_HDR_STORE_TIMES`).
14124const OH_FLAG_STORE_TIMES: u8 = 0x20;
14125
14126/// The four timestamps a version 2 object header stores when
14127/// [`OH_FLAG_STORE_TIMES`] is set, each 4 bytes of seconds since the Unix epoch
14128/// and stored in this order.
14129///
14130/// The reference C library stores them on **every** version 2 header it writes:
14131/// `H5O_CRT_OHDR_FLAGS_DEF` is `H5O_HDR_STORE_TIMES`, so a header from libhdf5,
14132/// h5py or netCDF-4 carries all four, and `H5Oget_info` reads them straight out
14133/// of this block.
14134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14135pub(crate) struct ObjectTimes {
14136 access: u32,
14137 modification: u32,
14138 change: u32,
14139 birth: u32,
14140}
14141
14142impl ObjectTimes {
14143 /// Bytes this block occupies in a header prefix.
14144 const LEN: usize = 16;
14145
14146 /// Parse the block at `at`. The caller must have checked that 16 bytes are
14147 /// available there.
14148 fn parse(prefix: &[u8], at: usize) -> Self {
14149 let field =
14150 |i: usize| u32::from_le_bytes(prefix[at + 4 * i..at + 4 * i + 4].try_into().unwrap());
14151 Self {
14152 access: field(0),
14153 modification: field(1),
14154 change: field(2),
14155 birth: field(3),
14156 }
14157 }
14158
14159 /// The block's on-disk bytes.
14160 fn to_bytes(self) -> [u8; Self::LEN] {
14161 let mut out = [0u8; Self::LEN];
14162 out[0..4].copy_from_slice(&self.access.to_le_bytes());
14163 out[4..8].copy_from_slice(&self.modification.to_le_bytes());
14164 out[8..12].copy_from_slice(&self.change.to_le_bytes());
14165 out[12..16].copy_from_slice(&self.birth.to_le_bytes());
14166 out
14167 }
14168
14169 /// The same times with the modification and change times moved to `now`.
14170 /// The access and birth times are the object's own history and are left
14171 /// where they were.
14172 ///
14173 /// The reference C library's `H5O_touch_oh` reaches the same two-of-four
14174 /// shape by a different pair: on a version 2 header it writes
14175 /// `oh->atime = oh->ctime = now` and carries a source comment saying the
14176 /// modification time still needs code to update. A rewrite is a
14177 /// modification, and it is not an *access*, so this writes the field that
14178 /// says so; both agree on the change time, and neither disturbs the birth
14179 /// time.
14180 fn touched(self, now: u32) -> Self {
14181 Self {
14182 modification: now,
14183 change: now,
14184 ..self
14185 }
14186 }
14187}
14188
14189/// The attribute phase-change thresholds a version 2 object header stores when
14190/// [`OH_FLAG_ATTR_PHASE_CHANGE`] is set: `H5Pset_attr_phase_change`'s maximum
14191/// number of attributes kept compact (in the header) and minimum kept dense (in
14192/// a fractal heap).
14193///
14194/// The reference C library writes this block only when the pair differs from its
14195/// defaults of 8 and 6, so most headers carry no such block at all. Preserved
14196/// verbatim: this editor's own compact/dense decision still uses
14197/// [`MAX_COMPACT_ATTRS`], so a non-default pair survives a rewrite without yet
14198/// steering it.
14199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14200pub(crate) struct AttrPhaseChange {
14201 max_compact: u16,
14202 min_dense: u16,
14203}
14204
14205impl AttrPhaseChange {
14206 /// Bytes this block occupies in a header prefix.
14207 const LEN: usize = 4;
14208
14209 /// Parse the block at `at`. The caller must have checked that 4 bytes are
14210 /// available there.
14211 fn parse(prefix: &[u8], at: usize) -> Self {
14212 Self {
14213 max_compact: u16::from_le_bytes(prefix[at..at + 2].try_into().unwrap()),
14214 min_dense: u16::from_le_bytes(prefix[at + 2..at + 4].try_into().unwrap()),
14215 }
14216 }
14217
14218 /// The block's on-disk bytes.
14219 fn to_bytes(self) -> [u8; Self::LEN] {
14220 let mut out = [0u8; Self::LEN];
14221 out[0..2].copy_from_slice(&self.max_compact.to_le_bytes());
14222 out[2..4].copy_from_slice(&self.min_dense.to_le_bytes());
14223 out
14224 }
14225}
14226
14227/// Everything chunk 0's prefix declares about a version 2 object header: the
14228/// record layout its messages are written in, and the two optional blocks the
14229/// prefix itself may carry.
14230///
14231/// All of it is a property of the *header*, shared by chunk 0 and every
14232/// continuation block, and none of it can be re-derived from the message bytes —
14233/// so it travels beside them ([`OhRegion`]) from the parse right through to the
14234/// rebuild. A rewrite that dropped the optional blocks would silently zero every
14235/// timestamp `H5Oget_info` reports on a file the C library wrote, and reset the
14236/// phase-change thresholds a caller set with `H5Pset_attr_phase_change`.
14237#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14238pub(crate) struct OhHeaderProps {
14239 /// How wide a message record prefix is, and whether creation order is
14240 /// tracked and indexed.
14241 layout: OhRecordLayout,
14242 /// The access/modification/change/birth block, where the header stores one.
14243 times: Option<ObjectTimes>,
14244 /// The compact/dense attribute thresholds, where the header stores them.
14245 attr_phase_change: Option<AttrPhaseChange>,
14246}
14247
14248impl OhHeaderProps {
14249 /// 4-byte records, no creation order, and neither optional block: what this
14250 /// crate's whole-file writer emits, and what a header this editor creates
14251 /// from nothing uses.
14252 pub(crate) const PLAIN: Self = Self::with_layout(OhRecordLayout::PLAIN);
14253
14254 /// A header in `layout` carrying neither optional block.
14255 pub(crate) const fn with_layout(layout: OhRecordLayout) -> Self {
14256 Self {
14257 layout,
14258 times: None,
14259 attr_phase_change: None,
14260 }
14261 }
14262
14263 /// The object-header flag bits these properties imply, above the two size
14264 /// bits the emitter chooses from the region's length.
14265 const fn header_flags(self) -> u8 {
14266 let times = if self.times.is_some() {
14267 OH_FLAG_STORE_TIMES
14268 } else {
14269 0
14270 };
14271 let phase = if self.attr_phase_change.is_some() {
14272 OH_FLAG_ATTR_PHASE_CHANGE
14273 } else {
14274 0
14275 };
14276 self.layout.header_flags() | times | phase
14277 }
14278
14279 /// Bytes the optional blocks add to the header prefix.
14280 const fn optional_len(self) -> usize {
14281 let times = if self.times.is_some() {
14282 ObjectTimes::LEN
14283 } else {
14284 0
14285 };
14286 let phase = if self.attr_phase_change.is_some() {
14287 AttrPhaseChange::LEN
14288 } else {
14289 0
14290 };
14291 times + phase
14292 }
14293}
14294
14295impl OhRecordLayout {
14296 /// 4-byte record prefixes and no creation order at all: what this crate's
14297 /// whole-file writer emits, and what a header this editor creates from
14298 /// nothing uses.
14299 pub(crate) const PLAIN: Self = Self {
14300 tracked: false,
14301 indexed: false,
14302 };
14303
14304 /// The layout a version 2 object header's flags byte declares.
14305 pub(crate) const fn from_header_flags(flags: u8) -> Self {
14306 Self {
14307 tracked: flags & OH_FLAG_CREATION_ORDER_TRACKED != 0,
14308 indexed: flags & OH_FLAG_CREATION_ORDER_INDEXED != 0,
14309 }
14310 }
14311
14312 /// Bytes a message record spends before its body.
14313 pub(crate) const fn prefix_len(self) -> usize {
14314 if self.tracked { 6 } else { 4 }
14315 }
14316
14317 /// The object-header flag bits this layout implies.
14318 const fn header_flags(self) -> u8 {
14319 let tracked = if self.tracked {
14320 OH_FLAG_CREATION_ORDER_TRACKED
14321 } else {
14322 0
14323 };
14324 let indexed = if self.indexed {
14325 OH_FLAG_CREATION_ORDER_INDEXED
14326 } else {
14327 0
14328 };
14329 tracked | indexed
14330 }
14331
14332 /// Whether records carry a creation index at all.
14333 pub(crate) const fn tracks_creation_order(self) -> bool {
14334 self.tracked
14335 }
14336
14337 /// Whether dense attribute storage on this object indexes that order, with
14338 /// a creation-order B-tree beside the name index.
14339 pub(crate) const fn indexes_creation_order(self) -> bool {
14340 self.indexed
14341 }
14342
14343 /// Parse the message record at `p` within a chunk's message region,
14344 /// returning `(message type, body start, body end)`; the next record begins
14345 /// at `body end`. Returns `Ok(None)` once fewer bytes remain than a record
14346 /// prefix takes (a clean end of the region, or the gap the reference C
14347 /// library leaves when a chunk's free space is too small to hold a message),
14348 /// and `Err` if a record's declared body runs past the region. Centralizes
14349 /// the bounds check shared by every walker.
14350 pub(crate) fn next_message(
14351 self,
14352 region: &[u8],
14353 p: usize,
14354 ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14355 if p + self.prefix_len() > region.len() {
14356 return Ok(None);
14357 }
14358 let msg_type = MessageType::from_u16(region[p] as u16);
14359 let msg_size = u16::from_le_bytes([region[p + 1], region[p + 2]]) as usize;
14360 let body = p + self.prefix_len();
14361 let body_end = body + msg_size;
14362 if body_end > region.len() {
14363 return Err(Error::EditUnsupported("malformed object header message"));
14364 }
14365 Ok(Some((msg_type, body, body_end)))
14366 }
14367
14368 /// The creation index the record at `msg_start` carries, or `None` where the
14369 /// layout has no such field. The caller must have located `msg_start` with
14370 /// [`next_message`](Self::next_message), which bounds the read.
14371 fn creation_index(self, region: &[u8], msg_start: usize) -> Option<u16> {
14372 self.tracked
14373 .then(|| u16::from_le_bytes([region[msg_start + 4], region[msg_start + 5]]))
14374 }
14375
14376 /// Encode one message record: this layout's prefix, then `body`.
14377 ///
14378 /// `creation_index` is written only where the layout carries one. It is
14379 /// meaningful for an Attribute message, whose creation index *is* the
14380 /// attribute's creation order; the reference C library writes zero on every
14381 /// other message type, which is what [`Self::record`] passes.
14382 fn record_with_creation_index(
14383 self,
14384 msg_type: MessageType,
14385 body: &[u8],
14386 creation_index: u16,
14387 ) -> Vec<u8> {
14388 let mut m = Vec::with_capacity(self.prefix_len() + body.len());
14389 #[expect(
14390 clippy::cast_possible_truncation,
14391 reason = "message type ids are a small enum that fits the 1-byte v2 type field"
14392 )]
14393 m.push(msg_type.to_u16() as u8);
14394 #[expect(
14395 clippy::cast_possible_truncation,
14396 reason = "callers pass bodies that fit the 2-byte message-size field (see doc comment)"
14397 )]
14398 m.extend_from_slice(&(body.len() as u16).to_le_bytes());
14399 m.push(MessageFlags::NONE.get()); // message flags
14400 if self.tracks_creation_order() {
14401 m.extend_from_slice(&creation_index.to_le_bytes());
14402 }
14403 m.extend_from_slice(body);
14404 m
14405 }
14406
14407 /// Encode one message record whose creation index, if the layout has one, is
14408 /// the zero the reference C library writes for every non-attribute message.
14409 fn record(self, msg_type: MessageType, body: &[u8]) -> Vec<u8> {
14410 self.record_with_creation_index(msg_type, body, 0)
14411 }
14412}
14413
14414/// A version 2 object header's message records, in the layout the header
14415/// declares them in.
14416///
14417/// The editor's model of a header is one contiguous run of message records:
14418/// [`WriteEngine::gather_oh_messages`] collapses a multi-chunk header into it,
14419/// the rewriters copy it message by message, and
14420/// [`build_v2_object_header`] wraps it back up. Every one of those has to agree
14421/// with the header's own flags about how wide a record prefix is, so the layout
14422/// travels with the bytes rather than being re-derived — or assumed — at each
14423/// step.
14424#[derive(Clone, Debug, Default)]
14425pub(crate) struct OhRegion {
14426 bytes: Vec<u8>,
14427 props: OhHeaderProps,
14428}
14429
14430impl core::ops::Deref for OhRegion {
14431 type Target = [u8];
14432 fn deref(&self) -> &[u8] {
14433 &self.bytes
14434 }
14435}
14436
14437impl OhRegion {
14438 /// A region of `bytes` belonging to a header with these `props`.
14439 pub(crate) fn new(bytes: Vec<u8>, props: OhHeaderProps) -> Self {
14440 Self { bytes, props }
14441 }
14442
14443 /// An empty region for a header with these `props`, to be filled record by
14444 /// record.
14445 fn empty(props: OhHeaderProps) -> Self {
14446 Self::new(Vec::new(), props)
14447 }
14448
14449 /// The same header's properties over different bytes: what every rewriter
14450 /// that copies a region message by message returns.
14451 fn with_bytes(&self, bytes: Vec<u8>) -> Self {
14452 Self::new(bytes, self.props)
14453 }
14454
14455 pub(crate) fn layout(&self) -> OhRecordLayout {
14456 self.props.layout
14457 }
14458
14459 /// Everything the header's prefix declares, which a rebuild re-emits.
14460 pub(crate) fn props(&self) -> OhHeaderProps {
14461 self.props
14462 }
14463
14464 /// [`OhRecordLayout::next_message`] over this region's own bytes.
14465 pub(crate) fn next_message(
14466 &self,
14467 p: usize,
14468 ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14469 self.props.layout.next_message(&self.bytes, p)
14470 }
14471
14472 /// The creation index of the record at `msg_start`, or `None` for a header
14473 /// that does not track it.
14474 fn creation_index(&self, msg_start: usize) -> Option<u16> {
14475 self.props.layout.creation_index(&self.bytes, msg_start)
14476 }
14477
14478 /// Append one already-encoded record (or run of records) written in this
14479 /// region's layout.
14480 fn push_bytes(&mut self, record: &[u8]) {
14481 self.bytes.extend_from_slice(record);
14482 }
14483
14484 /// The bytes, for a patch that changes a field inside a record without
14485 /// changing any record's length.
14486 fn bytes_mut(&mut self) -> &mut [u8] {
14487 &mut self.bytes
14488 }
14489
14490 /// Append a hard Link message record for `name -> addr`, carrying
14491 /// `creation_order` where the group tracks link creation order and `None`
14492 /// where it does not (see [`LinkCreationOrder`], which is what decides
14493 /// which).
14494 fn push_link(&mut self, name: &str, addr: StoredAddress, creation_order: Option<u64>) {
14495 let record = encode_link_message(self.props.layout, name, addr, creation_order);
14496 self.push_bytes(&record);
14497 }
14498
14499 /// Append a message record for `body`, with the zero creation index the
14500 /// reference C library writes for every non-attribute message.
14501 fn push(&mut self, msg_type: MessageType, body: &[u8]) {
14502 let record = self.props.layout.record(msg_type, body);
14503 self.push_bytes(&record);
14504 }
14505
14506 /// Append a message whose body is a *reference* to the one copy of it stored
14507 /// elsewhere, rather than the message itself.
14508 ///
14509 /// The flag is what tells every reader to follow the body instead of
14510 /// decoding it, so it travels with the bytes: a rewrite that dropped it
14511 /// would leave a reference to be read as content.
14512 fn push_shared(&mut self, msg_type: MessageType, reference: &[u8]) {
14513 let mut record = self.props.layout.record(msg_type, reference);
14514 record[3] = MessageFlags::SHARED.get();
14515 self.push_bytes(&record);
14516 }
14517}
14518
14519/// Refuse to copy a dataset whose element bytes live in files outside this one
14520/// (`H5Pset_external`, the External Data Files header message, type 7), which
14521/// this crate does not follow (issue #336).
14522///
14523/// Such a dataset carries a *contiguous* layout message with an undefined data
14524/// address, the same encoding a never-written dataset uses — and
14525/// [`read_copy_subtree`](WriteEngine::read_copy_subtree) copies that encoding as
14526/// the storage it does not have. The layout alone cannot tell the two apart, so
14527/// naming this message is the only thing standing between a dataset that holds
14528/// data and a copy carrying its schema and none of it. [`crate::repack()`]
14529/// rejects the same shape for the same reason.
14530///
14531/// Applied on both copy paths rather than only the cross-file one: an in-file
14532/// copy would reproduce the header without its data just as readily. It is also
14533/// the only screen that reads this message at all — [`reject_foreign_addresses`]
14534/// inspects shared messages, datatypes, and attributes, not this body, which
14535/// carries a local-heap address that would dangle in another file.
14536fn reject_external_storage(region: &OhRegion) -> Result<(), Error> {
14537 let mut p = 0;
14538 while let Some((msg_type, _, body_end)) = region.next_message(p)? {
14539 if msg_type == MessageType::EXTERNAL_DATA_FILES {
14540 return Err(Error::EditUnsupported(
14541 "a dataset stores its elements in external files (H5Pset_external), \
14542 which a copy cannot reproduce -- its data lives in files this crate \
14543 does not read",
14544 ));
14545 }
14546 p = body_end;
14547 }
14548 Ok(())
14549}
14550
14551/// Refuse to copy an object whose header embeds a *source-file* absolute address
14552/// that a verbatim copy into another file cannot translate. An in-file copy keeps
14553/// these valid by sharing the source file's heaps and objects; a cross-file copy
14554/// cannot. Three things qualify:
14555///
14556/// - a **variable-length** datatype, whose element bytes are global-heap
14557/// references (collection address + index) into the source file's heap;
14558/// - a **reference** datatype (object or dataset-region), whose element bytes are
14559/// absolute object addresses in the source file;
14560/// - any **shared message** ([`MessageFlags::SHARED`] set): a committed datatype, but also a shared
14561/// dataspace, fill value, or filter-pipeline message, whose body is a reference into the source
14562/// file's shared-message storage.
14563///
14564/// The scan covers a copied object's whole message region (a dataset's or a
14565/// group's): it refuses any shared message outright, and inspects Datatype
14566/// messages (the element type) and Attribute messages (their own datatype),
14567/// recursing through compound members, array elements, and enumeration bases so a
14568/// nested variable-length or reference occurrence is caught too. It is applied
14569/// only on the cross-file path; the same-file [`copy`](crate::File::copy)
14570/// deliberately keeps these forms (their addresses stay valid in one file).
14571fn reject_foreign_addresses(region: &OhRegion) -> Result<(), Error> {
14572 let mut p = 0;
14573 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14574 // A *shared* message stores, in place of its real body, a reference into
14575 // the source file's shared-message storage — an object-header address or a
14576 // fractal-heap (SOHM) id — which means nothing in another file. This
14577 // catches committed (shared) datatypes and shared attributes as well as a
14578 // shared dataspace, fill value, or filter-pipeline message, all of which
14579 // HDF5 may place in the shared-message table. Refuse any of them, whatever
14580 // the message type. The flags byte is the 4th of the record header (type,
14581 // size, flags); `next_message` returning `Some` guarantees
14582 // `p + 4 <= region.len()`.
14583 if MessageFlags::new(region[p + 3]).is_shared() {
14584 return Err(Error::EditUnsupported(
14585 "a shared (committed/SOHM) object-header message cannot be copied to another file yet",
14586 ));
14587 }
14588 match msg_type {
14589 MessageType::DATATYPE => {
14590 let (dt, _) = Datatype::parse(®ion[body..body_end])?;
14591 if datatype_holds_file_address(&dt) {
14592 return Err(Error::EditUnsupported(
14593 "variable-length or reference datasets cannot be copied to another file yet",
14594 ));
14595 }
14596 }
14597 MessageType::ATTRIBUTE => {
14598 // An attribute's *own* datatype or dataspace field can be a
14599 // reference to a committed message, which the record's shared
14600 // flag above does not report: that flag describes the attribute
14601 // message, not the fields inside it. The reference addresses the
14602 // source file, so it cannot travel any more than a shared record
14603 // can — and the parse below cannot resolve it here in any case.
14604 if crate::attribute::message_shares_a_field(®ion[body..body_end]) {
14605 return Err(Error::EditUnsupported(
14606 "an attribute with a committed (shared) datatype cannot be copied to another file yet",
14607 ));
14608 }
14609 let attr = crate::attribute::AttributeMessage::parse(
14610 ®ion[body..body_end],
14611 LENGTH_SIZE,
14612 )?;
14613 if datatype_holds_file_address(&attr.datatype) {
14614 return Err(Error::EditUnsupported(
14615 "variable-length or reference attributes cannot be copied to another file yet",
14616 ));
14617 }
14618 }
14619 _ => {}
14620 }
14621 p = body_end;
14622 }
14623 Ok(())
14624}
14625
14626/// Cross-file screen for a dense (fractal-heap) attribute set. The bytes parsed
14627/// out of the source heap can embed source-file absolute addresses just as inline
14628/// attribute messages can — variable-length (global-heap) or reference attribute
14629/// data — which would dangle in another file. [`reject_foreign_addresses`] screens
14630/// the verbatim object-header region but not heap-resident attribute bytes, so a
14631/// dense attribute set is screened here instead. Same-file copies skip this (their
14632/// addresses stay valid); the fresh heap built on write is same-file by
14633/// construction, so only the source datatypes matter.
14634fn reject_foreign_dense_attrs(attrs: &[crate::attribute::AttributeMessage]) -> Result<(), Error> {
14635 for attr in attrs {
14636 if datatype_holds_file_address(&attr.datatype) {
14637 return Err(Error::EditUnsupported(
14638 "variable-length or reference dense (fractal-heap) attributes cannot be copied to another file yet",
14639 ));
14640 }
14641 }
14642 Ok(())
14643}
14644
14645/// Re-encode a shared-message reference in the modern form, for a message being
14646/// rewrapped from a version 1 object header into a version 2 one.
14647///
14648/// A version 1 header carries the oldest reference encoding: a symbol-table
14649/// entry, with the object-header address buried behind a local-heap address.
14650/// Copying those bytes into a version 2 header would put an encoding there that
14651/// no writer of such a header has produced since the format gained one; parsing
14652/// and re-encoding gives the shape every current reader and writer uses.
14653///
14654/// Which shape that is depends on where the message lives. An object header is
14655/// named by the version 2 form this crate already writes for a committed
14656/// datatype. The shared-message heap has no encoding before version 3, so a heap
14657/// reference gets that one. Both name exactly what the version 1 reference named,
14658/// so the message keeps its single copy and its reference count is unchanged.
14659fn modernize_shared_reference(
14660 body: &[u8],
14661 offset_size: u8,
14662 length_size: u8,
14663) -> Result<Vec<u8>, Error> {
14664 let reference = crate::shared_message::parse_shared_ref(body, offset_size, length_size)?;
14665 Ok(match reference.location {
14666 crate::shared_message::SharedLocation::ObjectHeader(addr) => {
14667 crate::shared_message::encode_committed_ref(addr, offset_size)
14668 }
14669 crate::shared_message::SharedLocation::SohmHeap(id) => {
14670 crate::shared_message::encode_sohm_ref(&id)
14671 }
14672 })
14673}
14674
14675/// Refuse a commit that removes or moves an object header the file's
14676/// shared-message (SOHM) index names.
14677///
14678/// A heap-stored record names no object, so it is not screened here: what a
14679/// commit does to it is a *reference count* that goes stale, which the engine
14680/// refuses at the point it would change one (see [`SHARED_ATTRIBUTE_MESSAGE`]).
14681/// This screens the other record shape, whose address would dangle.
14682fn screen_shared_message_records(
14683 records: &[crate::sohm::SohmRecord],
14684 invalidated: &InvalidatedAddresses,
14685) -> Result<(), Error> {
14686 for record in records {
14687 if let crate::sohm::SohmLocation::ObjectHeader { address, .. } = record.location
14688 && invalidated.refusal(address).is_some()
14689 {
14690 return Err(Error::EditUnsupported(
14691 SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT,
14692 ));
14693 }
14694 }
14695 Ok(())
14696}
14697
14698/// Refuse a staged dataset whose element bytes already hold *resolved* object
14699/// references naming space this commit reclaims (issue #317).
14700///
14701/// This is the address-side half of the rule
14702/// [`WriteEngine::resolve_reference_target`] enforces on the path side. A target
14703/// named as a path is screened there, by name; a target supplied as an address —
14704/// `DatasetBuilder::with_reference_data`, or `with_raw_data` over a datatype that
14705/// holds a reference — never reaches that function at all, and before this screen
14706/// existed it was written straight through to disk.
14707///
14708/// Element bytes still carrying placeholders are unaffected: an unresolved slot
14709/// holds zero, which [`InvalidatedAddresses::refusal`] passes, and the target
14710/// that replaces it is screened by `resolve_reference_target` instead — by name
14711/// for a path, by address for a raw one. So this runs over every staged
14712/// dataset's `raw` without asking which builder filled it: the rule is about the
14713/// bytes, not the door they came through.
14714fn screen_resolved_references(
14715 dt: &Datatype,
14716 raw: &[u8],
14717 invalidated: &InvalidatedAddresses,
14718) -> Result<(), Error> {
14719 if !datatype_holds_object_address(dt) {
14720 return Ok(());
14721 }
14722 // The datatype declares an object reference, so the walker must find one.
14723 // Two ways it does not: slots that do not fit the element size (`None`), and
14724 // a reference `embedded_reference_slots` does not map — it locates the
14725 // 8-byte object reference only, and reports any other width as no slots at
14726 // all. Both mean the addresses cannot be read, and an empty list taken for
14727 // "nothing to check" would wave through exactly the datatype
14728 // `datatype_holds_object_address` had just recognised. Neither shape is
14729 // one this crate builds; refuse rather than write references past a screen
14730 // that could not see them.
14731 let Some(slots) = embedded_reference_slots(dt).filter(|slots| !slots.is_empty()) else {
14732 // Unconditional, unlike the per-address checks below. Those refuse an
14733 // address that lands somewhere this commit vacates; this one cannot read
14734 // the addresses at all, so there is nothing to compare against and no
14735 // amount of screening would help. Every commit that reaches here rebuilds
14736 // at least the root group, so `for_supplied.moved` is never empty and
14737 // gating this would have changed nothing anyway. A commit whose only
14738 // staged edit is a same-length in-place overwrite never reaches here at
14739 // all — it takes the fast path above, which vacates nothing. An in-file
14740 // copy reaches this only beside a deletion, because it is screened
14741 // against `for_copied`, whose `moved` is empty by construction.
14742 //
14743 // The `filter` is belt-and-braces: `embedded_reference_slots` already
14744 // reports an unaddressable reference as `None` rather than as an empty
14745 // list, and reading an empty list as "nothing to check" is exactly the
14746 // hole this screen was found to have.
14747 return Err(Error::EditUnsupported(
14748 "a reference this commit writes sits in a datatype whose addresses this screen \
14749 cannot read — a width other than eight, a dataset-region reference, a \
14750 variable length of them, or a compound holding one beside a reference it can \
14751 read — so it cannot be checked against what this commit vacates; supply an \
14752 8-byte object reference (`with_reference_data`)",
14753 ));
14754 };
14755 // Non-empty slots mean the datatype has room for at least one 8-byte
14756 // address, so the element size is at least 8.
14757 for (_, stored) in stored_object_references(raw, dt.type_size() as usize, &slots) {
14758 if let Some(refusal) = invalidated.refusal(StoredAddress::new(stored)) {
14759 return Err(Error::EditUnsupported(refusal));
14760 }
14761 }
14762 Ok(())
14763}
14764
14765/// Screen an in-file copy's subtree against the space this commit reclaims
14766/// (issue #317).
14767///
14768/// An in-file copy re-emits its source's element bytes verbatim, which is what
14769/// keeps a copied variable-length or reference dataset valid: the addresses
14770/// still name the same file, so nothing has to be rewritten. A deletion in the
14771/// same commit takes that away for object references alone — a global heap
14772/// collection is never reclaimed by a delete, so variable-length elements keep
14773/// pointing at data that is still there.
14774///
14775/// Every element that can be read is screened by address, through the same
14776/// [`screen_resolved_references`] a staged dataset's bytes go through: a
14777/// contiguous dataset's data block, a compact dataset's inline data, and every
14778/// attribute, inline or dense. A committed (shared) datatype is resolved through
14779/// `src` first, so a copy of an object with a named type is screened like any
14780/// other rather than refused for carrying a type this could not read.
14781///
14782/// One form cannot be read at all and so is refused by *datatype*, and only when
14783/// it holds an object reference: a **chunked** dataset, whose addresses sit
14784/// inside chunks this path carries compressed and never decodes — the same
14785/// obstacle that makes [`crate::repack()`] reject a chunked object-reference
14786/// dataset outright.
14787///
14788/// `src` is the session's image framed at its base address, the view a stored
14789/// (base-relative) shared-message address indexes directly.
14790fn screen_copied_references(
14791 tree: &CopyTree,
14792 invalidated: &InvalidatedAddresses,
14793 src: &(impl Source + ?Sized),
14794) -> Result<(), Error> {
14795 if invalidated.is_empty() {
14796 return Ok(());
14797 }
14798 use crate::shared_message::SharedResolver as _;
14799 // No shared-message table: a heap-stored message is refused rather than
14800 // followed here, and this screen treats an unreadable datatype as one it
14801 // cannot clear, which is the conservative answer.
14802 let resolver = crate::shared_message::SourceResolver::new(
14803 src,
14804 AccessMode::ReadWrite,
14805 OFFSET_SIZE,
14806 LENGTH_SIZE,
14807 None,
14808 );
14809 let (region, dense_attrs) = match tree {
14810 CopyTree::DatasetVerbatim {
14811 region,
14812 dense_attrs,
14813 }
14814 | CopyTree::DatasetContiguous {
14815 region,
14816 dense_attrs,
14817 ..
14818 }
14819 | CopyTree::DatasetChunked {
14820 region,
14821 dense_attrs,
14822 ..
14823 }
14824 | CopyTree::Group {
14825 non_link_region: region,
14826 dense_attrs,
14827 ..
14828 } => (region, dense_attrs),
14829 };
14830 for attr in &dense_attrs.attrs {
14831 screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14832 }
14833
14834 // One walk of the header: the object's own element datatype, the compact
14835 // data the layout message may carry, and every inline attribute.
14836 let mut element_dt: Option<Datatype> = None;
14837 let mut compact: Option<Vec<u8>> = None;
14838 let mut p = 0;
14839 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14840 // The flags byte is the 4th of the record header (type, size, flags);
14841 // `next_message` returning `Some` guarantees it is in bounds.
14842 let shared = MessageFlags::new(region[p + 3]).is_shared();
14843 match msg_type {
14844 MessageType::DATATYPE => {
14845 // A committed datatype's message body is a pointer into the
14846 // file's shared-message storage rather than an encoded type, so
14847 // read the type it names before parsing.
14848 let committed;
14849 let encoded = if shared {
14850 committed = resolver
14851 .resolve(®ion[body..body_end], MessageType::DATATYPE)
14852 .map_err(|source| Error::CopyScreenUnreadable {
14853 message: MessageType::DATATYPE,
14854 source,
14855 })?;
14856 &committed[..]
14857 } else {
14858 ®ion[body..body_end]
14859 };
14860 let (dt, _) = Datatype::parse(encoded)?;
14861 element_dt = Some(dt);
14862 }
14863 MessageType::DATA_LAYOUT => {
14864 if let Ok(DataLayout::Compact { data }) =
14865 DataLayout::parse(®ion[body..body_end], OFFSET_SIZE, LENGTH_SIZE)
14866 {
14867 compact = Some(data);
14868 }
14869 }
14870 MessageType::ATTRIBUTE => {
14871 // A *shared record* is the whole attribute message held in the
14872 // file's shared-message table, which is a different indirection
14873 // from the committed datatype `parse_resolving` follows inside
14874 // the fields — and a rare one this path has never modelled. Its
14875 // elements cannot be reached here, so it is refused.
14876 if shared {
14877 return Err(Error::EditUnsupported(
14878 "a copy in this commit carries a shared (SOHM) attribute message, whose \
14879 elements cannot be screened against the same commit's deletions; use \
14880 separate commits",
14881 ));
14882 }
14883 // `parse_resolving` rather than `parse`: an attribute's own
14884 // datatype field can name a committed message, which the record's
14885 // shared flag does not report — that flag describes the attribute
14886 // message, not the fields inside it — and `parse` refuses one.
14887 let attr = crate::attribute::AttributeMessage::parse_resolving(
14888 ®ion[body..body_end],
14889 LENGTH_SIZE,
14890 &resolver,
14891 )
14892 .map_err(|source| Error::CopyScreenUnreadable {
14893 message: MessageType::ATTRIBUTE,
14894 source,
14895 })?;
14896 screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14897 }
14898 _ => {}
14899 }
14900 p = body_end;
14901 }
14902
14903 match tree {
14904 // Compact: the elements are inline in the data-layout message. A layout
14905 // that did not yield them leaves a reference datatype unscreened, so it
14906 // is refused for the same reason a chunked one is.
14907 CopyTree::DatasetVerbatim { .. } => match (&element_dt, &compact) {
14908 (Some(dt), Some(data)) => screen_resolved_references(dt, data, invalidated)?,
14909 (Some(dt), None) if datatype_holds_object_address(dt) => {
14910 return Err(Error::EditUnsupported(
14911 "a compact object-reference dataset's elements could not be read to screen \
14912 them against this commit's deletions; use separate commits",
14913 ));
14914 }
14915 _ => {}
14916 },
14917 // No Datatype message means no declared reference, here and in the
14918 // chunked arm below: such a header is not a dataset any reader can
14919 // interpret, so there is nothing in it to dangle. That is why only the
14920 // compact arm above refuses on a missing piece — there the datatype is
14921 // present and says a reference is in bytes it could not reach.
14922 CopyTree::DatasetContiguous { data, .. } => {
14923 // A dataset whose storage the source never allocated (`None`) stores
14924 // no elements, so it holds no address that could dangle. Skipped
14925 // rather than screened as an empty run of element bytes: when
14926 // `screen_resolved_references` cannot map a datatype's addresses — a
14927 // dataset-region reference, a variable length of references, one
14928 // wider than eight bytes — it refuses *unconditionally*, before it
14929 // reads a byte, so an empty run would refuse this dataset for
14930 // elements it does not have.
14931 if let (Some(dt), Some(data)) = (&element_dt, data) {
14932 screen_resolved_references(dt, data, invalidated)?;
14933 }
14934 }
14935 // Chunked: `chunk_bytes` are carried exactly as the source stored them,
14936 // filters and all, so there is nothing here to decode addresses out of.
14937 CopyTree::DatasetChunked { .. } => {
14938 if element_dt
14939 .as_ref()
14940 .is_some_and(datatype_holds_object_address)
14941 {
14942 return Err(Error::EditUnsupported(
14943 "a chunked object-reference dataset cannot be copied in a commit that also \
14944 deletes objects: its addresses live inside chunks this path does not \
14945 decode; use separate commits",
14946 ));
14947 }
14948 }
14949 CopyTree::Group { children, .. } => {
14950 for (_, _, child) in children {
14951 screen_copied_references(child, invalidated, src)?;
14952 }
14953 }
14954 }
14955 Ok(())
14956}
14957
14958/// Wrap a chunk-0 message region in a fresh single-chunk version 2 object header
14959/// (`OHDR` prefix + region + Jenkins checksum), first normalizing the region's
14960/// attribute storage with [`ensure_attribute_info`]. Mirrors the encoding in
14961/// [`crate::object_header_writer::ObjectHeaderWriter::serialize`].
14962///
14963/// The normalization belongs here rather than at the fifteen call sites because
14964/// carrying an Attribute Info message is a property of a version 2 header holding
14965/// inline attributes, not of any one edit operation — and a site that forgot it
14966/// would reintroduce the zero-count defect silently.
14967///
14968/// A region that cannot be walked is reported, not asserted away. Every region
14969/// reaching here should have been built by this crate or already walked
14970/// message-by-message on the way in, but "should" is a claim about a file this
14971/// session did not write: a header whose message size field overruns the region
14972/// is a malformed *file*, which is the caller's input and so takes the
14973/// [`Error::EditUnsupported`] the region walk reports. A debug assertion here
14974/// would make the two build profiles disagree about whether such a file is
14975/// writable at all: a panic in a test build, and in a release build a header
14976/// silently missing its Attribute Info message, which is the zero-count defect
14977/// this function exists to prevent.
14978pub(crate) fn build_v2_object_header(region: &OhRegion) -> Result<Vec<u8>, Error> {
14979 let mut owned = region.clone();
14980 ensure_attribute_info(&mut owned)?;
14981 Ok(build_v2_object_header_verbatim(&owned))
14982}
14983
14984/// [`build_v2_object_header`] without the attribute-storage normalization, for
14985/// the region that function has already normalized.
14986///
14987/// **The header's optional prefix blocks are re-emitted, and its timestamps are
14988/// stamped.** A region carries whatever chunk 0's prefix declared
14989/// ([`OhHeaderProps`]), so a rewrite of a header from the reference C library,
14990/// h5py or netCDF-4 puts its four timestamps and any attribute phase-change
14991/// thresholds back where it found them, with the flag bits that announce them.
14992///
14993/// Of those, the **modification and change times are moved to now**: this
14994/// function runs once per rebuilt header, and every rebuild is a modification.
14995/// Access and birth times are the object's own history and are copied verbatim
14996/// ([`ObjectTimes::touched`] says how that compares with `H5O_touch_oh`).
14997/// Under `no_std` there is no clock to read, so all four are preserved as they
14998/// were rather than zeroed — a stale modification time being the honest reading
14999/// of "this build cannot tell the time", where a zero would claim the epoch.
15000/// A header this crate creates from nothing stores no times at all, so nothing
15001/// here applies to it.
15002fn build_v2_object_header_verbatim(region: &OhRegion) -> Vec<u8> {
15003 let total = region.len();
15004 let width = UintWidth::smallest_for_len(total);
15005 let props = region.props();
15006 // The creation-order bits and the two optional-block bits are the header's
15007 // own claim about what follows, so they come from the properties the region
15008 // was parsed with.
15009 let flags = width.flag_bits() | props.header_flags();
15010 let mut buf = Vec::with_capacity(8 + props.optional_len() + total + 4);
15011 buf.extend_from_slice(b"OHDR");
15012 buf.push(2); // version
15013 buf.push(flags);
15014 if let Some(times) = props.times {
15015 let stamped = match unix_time_now() {
15016 Some(now) => times.touched(now),
15017 None => times,
15018 };
15019 buf.extend_from_slice(&stamped.to_bytes());
15020 }
15021 if let Some(phase) = props.attr_phase_change {
15022 buf.extend_from_slice(&phase.to_bytes());
15023 }
15024 buf.extend_from_slice(&(total as u64).to_le_bytes()[..usize::from(width.get())]);
15025 buf.extend_from_slice(region);
15026 let checksum = jenkins_lookup3(&buf);
15027 buf.extend_from_slice(&checksum.to_le_bytes());
15028 buf
15029}
15030
15031/// Seconds since the Unix epoch, for the object-header timestamps a rebuild
15032/// stamps, or `None` where this build has no clock.
15033///
15034/// The crate carries no other wall-clock reader: every other date it writes
15035/// comes from its caller. `no_std` builds have no `SystemTime` at all and return
15036/// `None`, and so does a `std` build whose clock is set before 1970 — neither is
15037/// a reason to fail a commit, so both leave the header's stored times alone (see
15038/// [`build_v2_object_header_verbatim`]). The 4-byte field saturates rather than
15039/// wrapping, which matters only past 2106.
15040#[cfg(feature = "std")]
15041fn unix_time_now() -> Option<u32> {
15042 std::time::SystemTime::now()
15043 .duration_since(std::time::UNIX_EPOCH)
15044 .ok()
15045 .map(|d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX))
15046}
15047
15048/// No wall clock outside `std`; see the `std` definition.
15049#[cfg(not(feature = "std"))]
15050fn unix_time_now() -> Option<u32> {
15051 None
15052}
15053
15054/// Read a little-endian unsigned integer of `bytes.len()` (≤ 8) bytes.
15055#[expect(
15056 clippy::cast_possible_truncation,
15057 reason = "callers parse in-file sizes/offsets bounded by the in-memory image; downstream \
15058 slicing is length-checked, so a malformed oversized field errors rather than reads OOB"
15059)]
15060fn read_le(bytes: &[u8]) -> usize {
15061 let mut v = 0u64;
15062 for (i, &b) in bytes.iter().enumerate() {
15063 v |= (b as u64) << (8 * i);
15064 }
15065 v as usize
15066}
15067
15068/// The engine as the target a [`reference_patch::Plan`] is applied to
15069/// (issue #324).
15070///
15071/// Deliberately the whole engine rather than the image alone: an object header
15072/// is republished as one checksummed write, so applying a plan reads as well as
15073/// writes, and both have to go through the same image the rest of the commit
15074/// used — the same write-gathering, the same pending-write overlay, the same
15075/// end-of-file bound.
15076///
15077/// [`reference_patch::Plan`]: crate::reference_patch::Plan
15078impl crate::reference_patch::PatchTarget for WriteEngine {
15079 fn read(&self, at: u64, len: usize) -> Result<Vec<u8>, Error> {
15080 self.image().read_exact_at(at, len).map_err(Error::Format)
15081 }
15082 fn write(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
15083 self.image.write_at(at, bytes)
15084 }
15085}
15086
15087#[cfg(test)]
15088mod tests {
15089 use rstest::rstest;
15090
15091 use super::*;
15092 use crate::{CompoundMember, datatype::layout::FloatingPointLayout, object_path::ObjectPath};
15093
15094 /// The rule that places a chunk index on a paged file: some chunk-data span
15095 /// abuts it. Both sides count, which is what a repeatedly appended dataset
15096 /// needs — its blobs leave chunk data above the index as well as below it, so
15097 /// the highest data address is not the one beside the index (issue #388).
15098 #[test]
15099 fn a_chunk_index_is_placed_by_the_chunk_data_that_abuts_it() {
15100 // Data below, index above: the from-scratch layout.
15101 assert!(index_abuts_chunk_data(&[(100, 60)], &[(160, 40)]));
15102 // Index below, data above: a blob placed in a hole under existing chunks.
15103 assert!(index_abuts_chunk_data(&[(200, 60)], &[(160, 40)]));
15104 // Data on both sides, with the abutting span not the highest one — the
15105 // shape every staged append after the first produces.
15106 assert!(index_abuts_chunk_data(
15107 &[(100, 60), (900, 60)],
15108 &[(160, 40)]
15109 ));
15110 // A multi-block index is placed as one run by the span beside its edge.
15111 assert!(index_abuts_chunk_data(
15112 &[(100, 60)],
15113 &[(180, 20), (160, 20)]
15114 ));
15115
15116 // Neither edge is touched: the reference library's layout, where the index
15117 // sits in a metadata page far below the chunk data.
15118 assert!(!index_abuts_chunk_data(&[(9000, 60)], &[(160, 40)]));
15119 // A gap of one byte is not an abutment.
15120 assert!(!index_abuts_chunk_data(&[(100, 59)], &[(160, 40)]));
15121 // No chunk data at all — an empty resizable dataset's eagerly built index
15122 // — places nothing.
15123 assert!(!index_abuts_chunk_data(&[], &[(160, 40)]));
15124 // Nothing to place.
15125 assert!(index_abuts_chunk_data(&[], &[]));
15126 // A zero-length data span touches the index without occupying a byte
15127 // beside it.
15128 assert!(!index_abuts_chunk_data(&[(160, 0)], &[(160, 40)]));
15129 }
15130
15131 /// The screen that keeps the abutment from admitting a reference-library
15132 /// layout: an index with a byte in page 0 sits beside the superblock, so it is
15133 /// in a metadata page however its far end lines up with chunk data.
15134 #[test]
15135 fn a_chunk_index_in_page_zero_is_never_raw() {
15136 const PAGE: u64 = 512;
15137 // The C-written shape this exists to refuse: the index header sits in
15138 // page 0 behind the superblock, its last block fills page 1 exactly, and
15139 // the first raw page begins where that block ends. The abutment alone
15140 // called the whole run raw.
15141 let c_written = [(48u64, 80u64), (512, 512)];
15142 assert!(index_abuts_chunk_data(&[(1024, 512)], &c_written));
15143 assert!(index_touches_page_zero(&c_written, PAGE));
15144 // An index wholly above page 0 — every one this crate writes, since it
15145 // places chunk data and the index beside it out of raw pages — is not
15146 // screened out.
15147 assert!(!index_touches_page_zero(&[(1024, 200)], PAGE));
15148 // The boundary: the last byte of page 0 is still page 0.
15149 assert!(index_touches_page_zero(&[(511, 200)], PAGE));
15150 assert!(!index_touches_page_zero(&[(512, 200)], PAGE));
15151 // A zero-length span occupies nothing, in page 0 as anywhere else.
15152 assert!(!index_touches_page_zero(&[(0, 0)], PAGE));
15153 // Nothing to screen.
15154 assert!(!index_touches_page_zero(&[], PAGE));
15155 // A page size a paged file never has cannot place anything.
15156 assert!(index_touches_page_zero(&[(1024, 200)], 0));
15157 }
15158
15159 /// A page every byte of which is free or dead belongs to no page type, so it
15160 /// is promoted whole into the raw list — the free-page convention the rest of
15161 /// the paged allocator already follows. The partial edges keep their type,
15162 /// since the pages they sit in may still hold something live.
15163 #[test]
15164 fn a_page_that_is_wholly_free_or_dead_is_promoted_whole() {
15165 const PAGE: u64 = 4096;
15166 let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15167 // Page 1 is half free metadata and half dead, so neither list can show it
15168 // empty on its own.
15169 meta.free(PAGE, 2048);
15170 dead.free(PAGE + 2048, 2048);
15171 // Page 2 is wholly dead.
15172 dead.free(2 * PAGE, PAGE);
15173 // Page 3 keeps something live at each end, so only its middle is free —
15174 // and not adjacent to page 2, so the promotion is visible on its own.
15175 raw.free(3 * PAGE + 1024, 1024);
15176 PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15177
15178 assert_eq!(
15179 raw.sections(),
15180 [(PAGE, 2 * PAGE), (3 * PAGE + 1024, 1024)],
15181 "the two empty pages join the raw list as one run; the partial page stays"
15182 );
15183 assert!(
15184 meta.sections().is_empty(),
15185 "the promoted page left the metadata list"
15186 );
15187 assert!(
15188 dead.sections().is_empty(),
15189 "every dead byte was inside a promoted page"
15190 );
15191 }
15192
15193 /// Dead space that does not complete a page stays dead: it is not free space,
15194 /// and handing it out as either page type would mix the page it sits in.
15195 #[test]
15196 fn dead_space_short_of_a_whole_page_is_not_promoted() {
15197 const PAGE: u64 = 4096;
15198 let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15199 dead.free(PAGE, 512);
15200 raw.free(PAGE + 512, 1024);
15201 PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15202 assert_eq!(dead.sections(), [(PAGE, 512)]);
15203 assert_eq!(raw.sections(), [(PAGE + 512, 1024)]);
15204 }
15205
15206 /// The shared-message flag is read from the message header's fourth byte, and
15207 /// the two attribute paths have to read the same one: the compact pass
15208 /// refuses such a message as it copies, the dense pass before it reads a set
15209 /// that would resolve it into something indistinguishable from a private
15210 /// attribute. A wrong offset here reads a *size* byte and refuses or accepts
15211 /// by accident.
15212 #[test]
15213 fn a_shared_attribute_message_is_told_from_a_private_one_by_its_flags() {
15214 let body = crate::type_builders::build_attr_message("a", &AttrValue::I64(1))
15215 .serialize(LENGTH_SIZE);
15216 let private = message_record(MessageType::ATTRIBUTE, &body);
15217 assert!(!region_has_shared_attr(&plain_region(private.clone())).unwrap());
15218
15219 let mut shared = private.clone();
15220 shared[3] = MessageFlags::SHARED.get();
15221 assert!(region_has_shared_attr(&plain_region(shared)).unwrap());
15222
15223 // The flag is only read on an Attribute message: the same byte set on a
15224 // neighbouring message says nothing about attribute storage.
15225 let mut other = message_record(MessageType::DATASPACE, &body);
15226 other[3] = MessageFlags::SHARED.get();
15227 assert!(!region_has_shared_attr(&plain_region(other)).unwrap());
15228 }
15229
15230 /// An object-reference attribute named `name` pointing at `address`.
15231 ///
15232 /// Built by taking a `u64` attribute — whose value is already the 8 little-
15233 /// endian bytes an object reference is stored as — and relabelling its
15234 /// datatype, because no public API stages one: [`AttrValue`] has no
15235 /// reference variant, so a file carrying such an attribute was written by
15236 /// the reference C library, and the copy path re-emits its bytes verbatim
15237 /// like any other attribute's.
15238 fn reference_attr(name: &str, address: u64) -> crate::attribute::AttributeMessage {
15239 let mut attr = crate::type_builders::build_attr_message(name, &AttrValue::U64(address));
15240 attr.datatype = Datatype::Reference {
15241 size: 8,
15242 ref_type: crate::datatype::ReferenceType::Object,
15243 };
15244 assert_eq!(
15245 attr.raw_data,
15246 address.to_le_bytes(),
15247 "the value is the address"
15248 );
15249 attr
15250 }
15251
15252 /// Wrap a message body in the object-header record a header region holds it
15253 /// in: type, body size, flags, body.
15254 fn message_record(msg_type: MessageType, body: &[u8]) -> Vec<u8> {
15255 OhRecordLayout::PLAIN.record(msg_type, body)
15256 }
15257
15258 /// A region of plain (4-byte-record) messages, the layout every writer in
15259 /// this crate emits.
15260 fn plain_region(bytes: Vec<u8>) -> OhRegion {
15261 OhRegion::new(bytes, OhHeaderProps::PLAIN)
15262 }
15263
15264 /// A header region holding one attribute inline.
15265 fn inline_attr_region(attr: &crate::attribute::AttributeMessage) -> OhRegion {
15266 plain_region(message_record(
15267 MessageType::ATTRIBUTE,
15268 &attr.serialize_v3(LENGTH_SIZE),
15269 ))
15270 }
15271
15272 /// The header region of a one-element *compact* object-reference dataset:
15273 /// its datatype, and a version 3 compact data-layout message carrying the
15274 /// element inline (version, class 0, a 2-byte inline size, then the data).
15275 fn compact_reference_region(address: u64) -> OhRegion {
15276 let mut region = message_record(
15277 MessageType::DATATYPE,
15278 &crate::type_builders::make_object_reference_type().serialize(),
15279 );
15280 let mut layout = vec![3u8, 0];
15281 layout.extend_from_slice(&8u16.to_le_bytes());
15282 layout.extend_from_slice(&address.to_le_bytes());
15283 region.extend_from_slice(&message_record(MessageType::DATA_LAYOUT, &layout));
15284 plain_region(region)
15285 }
15286
15287 /// A *compact* dataset keeps its elements inside the data-layout message
15288 /// rather than in a data block, so the copy screen reads them straight out
15289 /// of the header region (issue #317).
15290 ///
15291 /// Driven from a hand-built region because no writer in this crate emits a
15292 /// compact layout — the files that carry one come from the reference C
15293 /// library, which is also why the userblock suite notes that its
15294 /// compact-layout fixtures have to come from there.
15295 #[test]
15296 fn a_copied_compact_reference_dataset_is_screened() {
15297 let empty = BytesSource::new(Vec::new());
15298 let invalidated = InvalidatedAddresses {
15299 removed: vec![(248, 71)],
15300 moved: Vec::new(),
15301 base: BaseAddress::ZERO,
15302 };
15303 for (address, refused) in [(248u64, true), (318, true), (319, false)] {
15304 let tree = CopyTree::DatasetVerbatim {
15305 region: compact_reference_region(address),
15306 dense_attrs: DenseAttrSet::default(),
15307 };
15308 let got = screen_copied_references(&tree, &invalidated, &empty);
15309 assert_eq!(got.is_err(), refused, "compact element {address}: {got:?}");
15310 }
15311
15312 // A region whose layout message yields no inline data leaves a reference
15313 // datatype unscreened, so it is refused rather than waved through.
15314 // `read_object` builds this variant only from a compact layout, so this
15315 // is a header that did not parse as one — malformed input, not a shape
15316 // the copy path produces.
15317 let no_layout = CopyTree::DatasetVerbatim {
15318 region: plain_region(message_record(
15319 MessageType::DATATYPE,
15320 &crate::type_builders::make_object_reference_type().serialize(),
15321 )),
15322 dense_attrs: DenseAttrSet::default(),
15323 };
15324 let err = screen_copied_references(&no_layout, &invalidated, &empty).unwrap_err();
15325 assert!(
15326 err.to_string().contains("could not be read to screen"),
15327 "got: {err}"
15328 );
15329 }
15330
15331 /// The committed reference points at address 248, and the source is empty,
15332 /// so the four-byte signature read there runs to 252 over 0 bytes.
15333 #[test]
15334 fn a_copy_whose_committed_datatype_cannot_be_read_reports_the_read_failure() {
15335 let empty = BytesSource::new(Vec::new());
15336 let invalidated = InvalidatedAddresses {
15337 removed: vec![(248, 71)],
15338 moved: Vec::new(),
15339 base: BaseAddress::ZERO,
15340 };
15341 let mut bytes = message_record(
15342 MessageType::DATATYPE,
15343 &crate::shared_message::encode_committed_ref(StoredAddress::new(248), OFFSET_SIZE),
15344 );
15345 bytes[3] = MessageFlags::SHARED.get();
15346 let tree = CopyTree::DatasetVerbatim {
15347 region: plain_region(bytes),
15348 dense_attrs: DenseAttrSet::default(),
15349 };
15350
15351 let err = screen_copied_references(&tree, &invalidated, &empty).unwrap_err();
15352 let Error::CopyScreenUnreadable { message, source } = &err else {
15353 panic!("expected CopyScreenUnreadable, got {err:?}");
15354 };
15355 assert_eq!(*message, MessageType::DATATYPE);
15356 assert_eq!(
15357 *source,
15358 FormatError::UnexpectedEof {
15359 expected: 252,
15360 available: 0
15361 }
15362 );
15363 }
15364
15365 /// A datatype that declares an object reference the element bytes have no
15366 /// room for cannot be walked, so its addresses cannot be screened — and a
15367 /// commit that reclaims space refuses it rather than writing references past
15368 /// a screen that could not read them (issue #317).
15369 ///
15370 /// Reachable only from a malformed source header: every datatype this crate
15371 /// builds sizes its element to its members. Driven directly for that reason.
15372 #[test]
15373 fn a_datatype_whose_reference_slots_do_not_fit_is_refused() {
15374 use crate::datatype::{CompoundMember, ReferenceType};
15375 // An 8-byte compound declaring an 8-byte reference at offset 4: the slot
15376 // runs four bytes past the element.
15377 let dt = Datatype::Compound {
15378 size: 8,
15379 members: vec![CompoundMember {
15380 name: "r".to_string(),
15381 byte_offset: 4,
15382 datatype: Datatype::Reference {
15383 size: 8,
15384 ref_type: ReferenceType::Object,
15385 },
15386 }],
15387 };
15388 assert!(
15389 embedded_reference_slots(&dt).is_none(),
15390 "the fixture must be one the walker cannot map"
15391 );
15392
15393 let raw = [0u8; 8];
15394 let invalidated = InvalidatedAddresses {
15395 removed: vec![(248, 71)],
15396 moved: Vec::new(),
15397 base: BaseAddress::ZERO,
15398 };
15399 let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15400 assert!(
15401 err.to_string().contains("this screen cannot read"),
15402 "got: {err}"
15403 );
15404
15405 // Unconditional: a screen holding nothing still refuses, because the
15406 // refusal is about addresses that cannot be read rather than about what
15407 // the commit is vacating. `moved` is never empty in a real commit, so
15408 // there is no case this could have been made conditional on.
15409 let nothing = InvalidatedAddresses {
15410 removed: Vec::new(),
15411 moved: Vec::new(),
15412 base: BaseAddress::ZERO,
15413 };
15414 assert!(screen_resolved_references(&dt, &raw, ¬hing).is_err());
15415 }
15416
15417 /// A variable-length *of object references* is refused rather than skipped:
15418 /// its addresses live in the global heap the elements point at, not in the
15419 /// element bytes this screen reads (issue #317).
15420 ///
15421 /// The reference C library writes such a datatype (`H5T_VLEN` of
15422 /// `H5T_STD_REF_OBJ`) and an in-file copy carries it, so it reaches the
15423 /// screen through `copy` even though nothing here builds one. A
15424 /// variable-length *string* is unaffected — the heap it points at is never
15425 /// reclaimed by a delete, so its elements stay valid.
15426 #[test]
15427 fn a_variable_length_of_object_references_is_refused() {
15428 use crate::datatype::{CharacterSet, ReferenceType};
15429 let of_references = Datatype::VariableLength {
15430 is_string: false,
15431 padding: None,
15432 charset: None,
15433 base_type: Box::new(Datatype::Reference {
15434 size: 8,
15435 ref_type: ReferenceType::Object,
15436 }),
15437 };
15438 let of_strings = crate::type_builders::make_vlen_string_type(CharacterSet::Utf8);
15439 let raw = vec![0u8; 32];
15440 let invalidated = InvalidatedAddresses {
15441 removed: vec![(248, 71)],
15442 moved: Vec::new(),
15443 base: BaseAddress::ZERO,
15444 };
15445
15446 let err = screen_resolved_references(&of_references, &raw, &invalidated).unwrap_err();
15447 assert!(
15448 err.to_string().contains("this screen cannot read"),
15449 "got: {err}"
15450 );
15451 assert!(
15452 screen_resolved_references(&of_strings, &raw, &invalidated).is_ok(),
15453 "a variable-length string points at a heap no delete reclaims"
15454 );
15455 }
15456
15457 /// An object reference wider than the 8 bytes the slot walker maps is
15458 /// refused, not skipped (issue #317).
15459 ///
15460 /// [`datatype_holds_object_address`] answers for an object reference of
15461 /// *any* width, while [`embedded_reference_slots`] locates only the 8-byte
15462 /// one — so the walker reports a width it cannot address as unwalkable
15463 /// rather than as "no slots here", which a screen would read as "nothing to
15464 /// check". `Dataset::dereference` reads such an element, taking the address
15465 /// from its first eight bytes, so the reference is live.
15466 #[test]
15467 fn an_object_reference_wider_than_eight_bytes_is_refused() {
15468 use crate::datatype::ReferenceType;
15469 let dt = Datatype::Reference {
15470 size: 16,
15471 ref_type: ReferenceType::Object,
15472 };
15473 assert!(
15474 embedded_reference_slots(&dt).is_none(),
15475 "the walker must report a width it cannot map, not an empty slot list"
15476 );
15477
15478 let mut raw = vec![0u8; 16];
15479 raw[..8].copy_from_slice(&300u64.to_le_bytes());
15480 let invalidated = InvalidatedAddresses {
15481 removed: vec![(248, 71)],
15482 moved: Vec::new(),
15483 base: BaseAddress::ZERO,
15484 };
15485 let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15486 assert!(
15487 err.to_string().contains("this screen cannot read"),
15488 "got: {err}"
15489 );
15490
15491 // Unconditional, for the reason
15492 // `a_datatype_whose_reference_slots_do_not_fit_is_refused` states.
15493 let nothing = InvalidatedAddresses {
15494 removed: Vec::new(),
15495 moved: Vec::new(),
15496 base: BaseAddress::ZERO,
15497 };
15498 assert!(screen_resolved_references(&dt, &raw, ¬hing).is_err());
15499 }
15500
15501 /// A reference target supplied as a *raw address* is screened exactly as one
15502 /// supplied as a path (issue #317), and the address it would have resolved
15503 /// to is returned unchanged when it names space this commit keeps.
15504 ///
15505 /// Driven directly because nothing stages a `Raw` target carrying a real
15506 /// address: a builder reachable from a session produces only
15507 /// [`ObjectRefTarget::Path`], and [`crate::repack`]'s faithful re-emit
15508 /// resolves every real target to a `Path`, leaving `Raw` for the null and
15509 /// undefined references alone. The arm exists so that the two ways a target
15510 /// can name an object answer to the same rule rather than to whichever one
15511 /// a caller happened to use, and this is what holds it to that.
15512 /// A file with nothing a reference could live in is walked once and never
15513 /// again, and one that holds a reference is walked every time.
15514 ///
15515 /// The first half is the whole of `proved_free_of_references`, and it is
15516 /// invisible from outside the session: a build that never caches the proof
15517 /// writes byte-identical files and passes every other test here, having
15518 /// silently gone back to walking the file on every commit. The second half
15519 /// is what stops a too-eager proof from being the fix for the first.
15520 #[test]
15521 fn a_file_proved_free_of_references_is_walked_once_and_no_more() {
15522 use crate::reference_patch::{reset_walks, walks};
15523 use tempfile::tempdir;
15524 let dir = tempdir().unwrap();
15525
15526 let commit_three = |path: &std::path::Path| {
15527 let session = crate::File::open_rw(path).unwrap();
15528 for i in 0..3 {
15529 session
15530 .root()
15531 .create_dataset(&format!("added{i}"), |b| {
15532 b.with_i32_data(&[i]);
15533 })
15534 .unwrap();
15535 session.commit().unwrap();
15536 }
15537 };
15538
15539 let plain = dir.path().join("plain.h5");
15540 let mut b = crate::writer::FileBuilder::new();
15541 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15542 b.write(&plain).unwrap();
15543 reset_walks();
15544 commit_three(&plain);
15545 assert_eq!(
15546 walks(),
15547 1,
15548 "the first commit's walk proves the file reference-free; the rest \
15549 must take its word for it"
15550 );
15551
15552 let referencing = dir.path().join("referencing.h5");
15553 let mut b = crate::writer::FileBuilder::new();
15554 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15555 b.create_dataset("refs").with_path_references(&["d"]);
15556 b.write(&referencing).unwrap();
15557 reset_walks();
15558 commit_three(&referencing);
15559 assert_eq!(
15560 walks(),
15561 3,
15562 "a file that holds a reference is never proved free of one, so every \
15563 commit walks it"
15564 );
15565 }
15566
15567 #[test]
15568 fn a_raw_reference_target_is_screened_like_a_path_one() {
15569 use tempfile::tempdir;
15570 let dir = tempdir().unwrap();
15571 let path = dir.path().join("raw_target.h5");
15572 let mut b = crate::writer::FileBuilder::new();
15573 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15574 b.write(&path).unwrap();
15575 let engine = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
15576
15577 let nodes: BTreeMap<ObjectPathBuf, Node> = BTreeMap::new();
15578 let path_addr: BTreeMap<ObjectPathBuf, u64> = BTreeMap::new();
15579 let resolve = |address: u64, removed: Vec<(u64, u64)>, moved: Vec<u64>| {
15580 WriteEngine::resolve_reference_target(
15581 &ObjectRefTarget::Raw(StoredAddress::new(address)),
15582 &path_addr,
15583 &nodes,
15584 &[],
15585 &[],
15586 &[],
15587 &InvalidatedAddresses {
15588 removed,
15589 moved,
15590 base: BaseAddress::ZERO,
15591 },
15592 &engine.image(),
15593 engine.superblock(),
15594 )
15595 };
15596
15597 assert!(
15598 resolve(300, vec![(248, 71)], Vec::new()).is_err(),
15599 "an address inside a reclaimed span is refused"
15600 );
15601 assert!(
15602 resolve(300, Vec::new(), vec![300]).is_err(),
15603 "an address this commit rewrites elsewhere is refused"
15604 );
15605 assert_eq!(
15606 resolve(300, vec![(400, 71)], vec![299, 301]).unwrap(),
15607 StoredAddress::new(300),
15608 "an address outside every reclaimed span and every moved header is carried through"
15609 );
15610 assert_eq!(
15611 resolve(300, Vec::new(), Vec::new()).unwrap(),
15612 StoredAddress::new(300),
15613 "a commit that reclaims nothing screens nothing"
15614 );
15615 // The two sentinels name no object, so they are carried through even
15616 // when they fall inside a reclaimed span.
15617 assert_eq!(
15618 resolve(0, vec![(0, 4096)], vec![0]).unwrap(),
15619 StoredAddress::new(0)
15620 );
15621 assert_eq!(
15622 resolve(UNDEF, vec![(0, u64::MAX)], vec![UNDEF]).unwrap(),
15623 StoredAddress::new(UNDEF)
15624 );
15625 }
15626
15627 #[rstest]
15628 #[case("a/b")]
15629 #[case("/a/b")]
15630 #[case("a/./b")]
15631 #[case("a//b/")]
15632 fn a_reference_target_is_keyed_by_the_object_its_spelling_names(#[case] spelling: &str) {
15633 use tempfile::tempdir;
15634 let dir = tempdir().unwrap();
15635 let path = dir.path().join("path_target.h5");
15636 let mut b = crate::writer::FileBuilder::new();
15637 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15638 b.write(&path).unwrap();
15639 let engine = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
15640
15641 let nodes: BTreeMap<ObjectPathBuf, Node> = BTreeMap::new();
15642 let resolve = |path_addr: &BTreeMap<ObjectPathBuf, u64>, add_targets: &[ObjectPathBuf]| {
15643 WriteEngine::resolve_reference_target(
15644 &ObjectRefTarget::Path(ObjectPathBuf::parse(spelling)),
15645 path_addr,
15646 &nodes,
15647 add_targets,
15648 &[],
15649 &[],
15650 &InvalidatedAddresses {
15651 removed: Vec::new(),
15652 moved: Vec::new(),
15653 base: BaseAddress::ZERO,
15654 },
15655 &engine.image(),
15656 engine.superblock(),
15657 )
15658 };
15659
15660 let placed: BTreeMap<ObjectPathBuf, u64> =
15661 std::iter::once((ObjectPathBuf::parse("a/b"), 4096)).collect();
15662 assert_eq!(resolve(&placed, &[]).unwrap(), StoredAddress::new(4096));
15663
15664 let err = resolve(&BTreeMap::new(), &[ObjectPathBuf::parse("a/b")]).unwrap_err();
15665 let Error::EditUnsupported(reason) = &err else {
15666 panic!("expected EditUnsupported, got {err:?}");
15667 };
15668 assert_eq!(
15669 *reason,
15670 "an object-reference dataset targets a path this commit is still writing; \
15671 use separate commits"
15672 );
15673 }
15674
15675 /// A copied object's *attributes* are screened against the space the commit
15676 /// reclaims, in either storage an object can hold them in — inline in the
15677 /// header region, or in the fractal heap a dense object uses — and an
15678 /// address outside those spans passes in both (issue #317).
15679 #[test]
15680 fn a_copied_reference_attribute_is_screened_in_both_storages() {
15681 let empty = BytesSource::new(Vec::new());
15682 let invalidated = InvalidatedAddresses {
15683 removed: vec![(248, 71)],
15684 moved: Vec::new(),
15685 base: BaseAddress::ZERO,
15686 };
15687 // 248 is the first byte of the reclaimed span, 318 its last, 319 the
15688 // byte after it.
15689 for (address, refused) in [(248u64, true), (318, true), (319, false), (247, false)] {
15690 let attr = reference_attr("target", address);
15691 let dense = CopyTree::DatasetVerbatim {
15692 region: OhRegion::default(),
15693 dense_attrs: DenseAttrSet {
15694 attrs: vec![attr.clone()],
15695 creation: DenseAttrCreationOrder::Untracked,
15696 },
15697 };
15698 let inline = CopyTree::DatasetVerbatim {
15699 region: inline_attr_region(&attr),
15700 dense_attrs: DenseAttrSet::default(),
15701 };
15702 for (storage, tree) in [("dense", &dense), ("inline", &inline)] {
15703 let got = screen_copied_references(tree, &invalidated, &empty);
15704 assert_eq!(
15705 got.is_err(),
15706 refused,
15707 "{storage} attribute at {address}: {got:?}"
15708 );
15709 }
15710 }
15711 }
15712
15713 /// Collect the message types present in a chunk-0 region, in order.
15714 fn region_types(region: &OhRegion) -> Vec<MessageType> {
15715 let mut out = Vec::new();
15716 let mut p = 0;
15717 while let Some((mt, _, end)) = region.next_message(p).unwrap() {
15718 out.push(mt);
15719 p = end;
15720 }
15721 out
15722 }
15723
15724 /// Stopping an in-place append (`append_inplace`) at any phase boundary must
15725 /// leave the file readable as a consistent prefix — the old length until the
15726 /// phase-4 dimension commit, the new length after it — even though a
15727 /// partial-tail append repoints the visible trailing element in place. Mirrors
15728 /// `Dataset::append`'s crash-consistency harness, but driven through
15729 /// the in-place edit engine's own mirror (disk-before-mirror ordering) to prove the shared
15730 /// engine is crash-safe under both owners. Three starting layouts: the trailing
15731 /// element inline in the index block (chunk 4, n 6), in a data block
15732 /// (chunk 2, n 9, slot 0), and a *filtered* inline one (issue #393), whose
15733 /// repointed element is the three-field record rather than a bare address and
15734 /// whose relocated chunk has to decode to the old prefix at every phase
15735 /// before the dimension moves.
15736 #[test]
15737 fn append_inplace_crash_consistency_partial_tail_prefix() {
15738 use crate::reader::File as PureFile;
15739 use crate::writer::FileBuilder;
15740 use tempfile::tempdir;
15741
15742 let build = |path: &std::path::Path, n: i32, chunk: u64, deflate: bool| {
15743 let data: Vec<i32> = (0..n).collect();
15744 let mut b = FileBuilder::new();
15745 let d = b
15746 .create_dataset("d")
15747 .with_i32_data(&data)
15748 .with_shape(&[n as u64])
15749 .with_maxshape(&[MaxExtent::Unlimited])
15750 .with_chunks(&[chunk]);
15751 if deflate {
15752 d.with_deflate(6);
15753 }
15754 b.write(path).unwrap();
15755 };
15756
15757 for (n, chunk, add, deflate) in [
15758 (6i32, 4u64, 5i32, false),
15759 (9, 2, 6, false),
15760 (6, 4, 5, true),
15761 (9, 4, 3, true),
15762 ] {
15763 let dir = tempdir().unwrap();
15764 let base = dir.path().join("base.h5");
15765 build(&base, n, chunk, deflate);
15766
15767 for max_phase in 1u8..=4 {
15768 let p = dir
15769 .path()
15770 .join(format!("crash_{n}_{chunk}_{deflate}_{max_phase}.h5"));
15771 std::fs::copy(&base, &p).unwrap();
15772 {
15773 let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
15774 s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
15775 .unwrap();
15776 // session dropped here, simulating a crash after `max_phase`
15777 }
15778 let expected_len = if max_phase == 4 { n + add } else { n };
15779 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
15780 assert_eq!(
15781 f.dataset("d").unwrap().read_i32().unwrap(),
15782 (0..expected_len).collect::<Vec<_>>(),
15783 "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
15784 deflate={deflate})"
15785 );
15786 }
15787 }
15788 }
15789
15790 /// Growing a partial trailing chunk decodes and re-encodes elements that are
15791 /// already committed, so a **lossy** pipeline is refused on the immediate
15792 /// in-place path, with the file left exactly as it was.
15793 ///
15794 /// The values are the point rather than the error: measured on this fixture
15795 /// before the refusal existed, the committed pair `[4.5, 5.5]` read back as
15796 /// `[16.0, 0.0]` once two more elements joined their ZFP block. That is
15797 /// quantization following the block's new contents, not a decode that lost
15798 /// its place — a *fresh* chunk of `[99.0, 98.0, 97.0]` at this rate reads
15799 /// back as `[98.0, 102.0, 90.0]` with nothing decoded anywhere, which is what
15800 /// eight bits per value buys and is not this test's business.
15801 #[cfg(feature = "zfp")]
15802 #[test]
15803 fn append_onto_a_lossy_partial_tail_is_refused() {
15804 use crate::reader::File as PureFile;
15805 use crate::writer::FileBuilder;
15806 use tempfile::tempdir;
15807
15808 let dir = tempdir().unwrap();
15809 let committed: Vec<f64> = vec![0.5, 1.5, 2.5, 3.5, 4.5, 5.5];
15810
15811 // `d` is 6 of a chunk of 4: a partial trailing chunk holding 4.5 and 5.5.
15812 let build = |path: &std::path::Path| {
15813 let mut b = FileBuilder::new();
15814 b.create_dataset("d")
15815 .with_f64_data(&committed)
15816 .with_shape(&[6])
15817 .with_maxshape(&[MaxExtent::Unlimited])
15818 .with_chunks(&[4])
15819 .with_zfp(8.0);
15820 b.write(path).unwrap();
15821 };
15822
15823 // The immediate in-place path.
15824 let p = dir.path().join("inplace.h5");
15825 build(&p);
15826 let before = std::fs::read(&p).unwrap();
15827 {
15828 let f = crate::reader::File::open_rw(&p).unwrap();
15829 let err = f
15830 .dataset("d")
15831 .unwrap()
15832 .append(&[99.0f64, 98.0, 97.0])
15833 .expect_err("a lossy pipeline must not have its trailing chunk re-encoded");
15834 assert!(
15835 matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15836 "got: {err:?}"
15837 );
15838 }
15839 assert_eq!(
15840 std::fs::read(&p).unwrap(),
15841 before,
15842 "the refusal wrote bytes"
15843 );
15844 assert_eq!(
15845 PureFile::open(&p)
15846 .unwrap()
15847 .dataset("d")
15848 .unwrap()
15849 .read_f64()
15850 .unwrap(),
15851 committed
15852 );
15853
15854 // A chunk-aligned start needs no rewrite, so it is still accepted and the
15855 // committed values are still exactly what they were.
15856 let p = dir.path().join("aligned.h5");
15857 {
15858 let mut b = FileBuilder::new();
15859 b.create_dataset("d")
15860 .with_f64_data(&committed[..4])
15861 .with_shape(&[4])
15862 .with_maxshape(&[MaxExtent::Unlimited])
15863 .with_chunks(&[4])
15864 .with_zfp(8.0);
15865 b.write(&p).unwrap();
15866 }
15867 {
15868 let f = crate::reader::File::open_rw(&p).unwrap();
15869 f.dataset("d").unwrap().append(&[9.5f64, 8.5, 7.5]).unwrap();
15870 }
15871 let back = PureFile::open(&p)
15872 .unwrap()
15873 .dataset("d")
15874 .unwrap()
15875 .read_f64()
15876 .unwrap();
15877 assert_eq!(back[..4], committed[..4], "an untouched chunk changed");
15878
15879 // The staged path rebuilds the index at commit and rewrote the same chunk
15880 // on the way; it refuses at the call that stages the append, so nothing is
15881 // staged and the file is left alone.
15882 let p = dir.path().join("staged.h5");
15883 build(&p);
15884 let before = std::fs::read(&p).unwrap();
15885 {
15886 let f = crate::reader::File::open_rw(&p).unwrap();
15887 let err = f
15888 .dataset("d")
15889 .unwrap()
15890 .append_staged(|b| {
15891 b.append_f64(&[99.0, 98.0, 97.0]);
15892 })
15893 .expect_err("a staged append must not re-encode the committed tail either");
15894 assert!(
15895 matches!(&err, Error::AppendUnsupported(m) if m.contains("lossy")),
15896 "got: {err:?}"
15897 );
15898 f.close().unwrap();
15899 }
15900 assert_eq!(
15901 std::fs::read(&p).unwrap(),
15902 before,
15903 "the refusal wrote bytes"
15904 );
15905
15906 // A buffered appender over the unaligned shape is refused when it is
15907 // made, since its very first write would be the rewrite above.
15908 let p = dir.path().join("buffered.h5");
15909 build(&p);
15910 {
15911 let f = crate::reader::File::open_rw(&p).unwrap();
15912 let mut ds = f.dataset("d").unwrap();
15913 let err = ds
15914 .buffered_appender()
15915 .expect_err("its first write would re-encode the committed tail");
15916 assert!(
15917 matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15918 "got: {err:?}"
15919 );
15920 }
15921 }
15922
15923 /// Float D-scale scale-offset is the other lossy mode, and is refused for the
15924 /// same reason — while the **integer** mode, which is lossless, grows its
15925 /// partial trailing chunk with every value intact. Both halves matter: a
15926 /// predicate that refused all of scale-offset would pass the first assertion
15927 /// and take a working case away.
15928 #[test]
15929 fn a_lossy_scale_offset_mode_is_refused_and_a_lossless_one_is_not() {
15930 use crate::reader::File as PureFile;
15931 use crate::scaleoffset::ScaleOffset;
15932 use crate::writer::FileBuilder;
15933 use tempfile::tempdir;
15934
15935 let dir = tempdir().unwrap();
15936
15937 let p = dir.path().join("dscale.h5");
15938 {
15939 let mut b = FileBuilder::new();
15940 b.create_dataset("d")
15941 .with_f64_data(&[0.5, 1.5, 2.5, 3.5, 4.5, 5.5])
15942 .with_shape(&[6])
15943 .with_maxshape(&[MaxExtent::Unlimited])
15944 .with_chunks(&[4])
15945 .with_scale_offset(ScaleOffset::FloatDScale(1));
15946 b.write(&p).unwrap();
15947 }
15948 {
15949 let f = crate::reader::File::open_rw(&p).unwrap();
15950 let err = f
15951 .dataset("d")
15952 .unwrap()
15953 .append(&[99.0f64, 98.0, 97.0])
15954 .expect_err("float D-scale is lossy");
15955 assert!(
15956 matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15957 "got: {err:?}"
15958 );
15959 }
15960
15961 let p = dir.path().join("int.h5");
15962 {
15963 let mut b = FileBuilder::new();
15964 b.create_dataset("d")
15965 .with_i32_data(&(0..6).collect::<Vec<_>>())
15966 .with_shape(&[6])
15967 .with_maxshape(&[MaxExtent::Unlimited])
15968 .with_chunks(&[4])
15969 .with_scale_offset(ScaleOffset::Integer(0));
15970 b.write(&p).unwrap();
15971 }
15972 {
15973 let f = crate::reader::File::open_rw(&p).unwrap();
15974 let mut ds = f.dataset("d").unwrap();
15975 ds.append(&[6i32, 7, 8]).unwrap();
15976 ds.append(&[9i32]).unwrap();
15977 }
15978 assert_eq!(
15979 PureFile::open(&p)
15980 .unwrap()
15981 .dataset("d")
15982 .unwrap()
15983 .read_i32()
15984 .unwrap(),
15985 (0..10).collect::<Vec<_>>()
15986 );
15987 }
15988
15989 /// A *filtered* append whose length is not a whole number of chunks writes a
15990 /// partial last chunk. That chunk's index element is a fresh insert past the
15991 /// old dimension — the same position every whole chunk beside it occupies —
15992 /// so stopping anywhere in the durability sequence must still read back as
15993 /// the old prefix, exactly as an aligned filtered append does. This is the
15994 /// case the in-place refusal used to cover and no longer does; without the
15995 /// phase sweep, "it reads back fine" would only be testing phase 4.
15996 #[test]
15997 fn append_inplace_crash_consistency_filtered_partial_last_chunk() {
15998 use crate::reader::File as PureFile;
15999 use crate::writer::FileBuilder;
16000 use tempfile::tempdir;
16001
16002 let build = |path: &std::path::Path, n: i32, chunk: u64| {
16003 let data: Vec<i32> = (0..n).collect();
16004 let mut b = FileBuilder::new();
16005 b.create_dataset("d")
16006 .with_i32_data(&data)
16007 .with_shape(&[n as u64])
16008 .with_maxshape(&[MaxExtent::Unlimited])
16009 .with_chunks(&[chunk])
16010 .with_shuffle()
16011 .with_deflate(4);
16012 b.write(path).unwrap();
16013 };
16014
16015 // Aligned starts, unaligned lengths: one that stays inside a single new
16016 // chunk, and one that spans several and ends partway through the last.
16017 // (An unaligned *start* is the sibling sweep,
16018 // `append_inplace_crash_consistency_partial_tail_prefix`.)
16019 for (n, chunk, add) in [(8i32, 4u64, 2i32), (8, 4, 9), (0, 4, 3)] {
16020 let dir = tempdir().unwrap();
16021 let base = dir.path().join("base.h5");
16022 build(&base, n, chunk);
16023
16024 for max_phase in 1u8..=4 {
16025 let p = dir
16026 .path()
16027 .join(format!("crash_f_{n}_{chunk}_{max_phase}.h5"));
16028 std::fs::copy(&base, &p).unwrap();
16029 {
16030 let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
16031 s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
16032 .unwrap();
16033 // session dropped here, simulating a crash after `max_phase`
16034 }
16035 let expected_len = if max_phase == 4 { n + add } else { n };
16036 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16037 assert_eq!(
16038 f.dataset("d").unwrap().read_i32().unwrap(),
16039 (0..expected_len).collect::<Vec<_>>(),
16040 "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
16041 add={add})"
16042 );
16043 }
16044 }
16045 }
16046
16047 /// Build a one-element-per-chunk unlimited `d` holding `0..n`, the shape the
16048 /// crash-consistency harnesses below grow.
16049 fn build_unit_chunked(path: &std::path::Path, n: i32) {
16050 use crate::writer::FileBuilder;
16051 let data: Vec<i32> = (0..n).collect();
16052 let mut b = FileBuilder::new();
16053 b.create_dataset("d")
16054 .with_i32_data(&data)
16055 .with_shape(&[n as u64])
16056 .with_maxshape(&[MaxExtent::Unlimited])
16057 .with_chunks(&[1]);
16058 b.write(path).unwrap();
16059 }
16060
16061 /// Stop an append after `max_phase` durability phases and hand back the
16062 /// resulting file. Dropping the engine inside is the simulated crash: no
16063 /// further phases run and no close barrier is written.
16064 fn append_stopped_at(
16065 base: &std::path::Path,
16066 out: &std::path::Path,
16067 values: std::ops::Range<i32>,
16068 max_phase: u8,
16069 ) {
16070 std::fs::copy(base, out).unwrap();
16071 let mut s = WriteEngine::open_with_locking(out, FileLocking::Enabled).unwrap();
16072 s.append_inplace_i32_phased("d", &values.collect::<Vec<_>>(), max_phase)
16073 .unwrap();
16074 }
16075
16076 /// Growing an Extensible-Array index across its inline -> direct-block ->
16077 /// super-block boundaries touches far more index structure than the
16078 /// partial-tail case above. Stopping at any phase boundary must still read
16079 /// back as a consistent prefix.
16080 ///
16081 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
16082 /// owned path drives the same `apply_ea_append` engine.
16083 #[test]
16084 fn append_inplace_crash_consistency_across_ea_boundaries() {
16085 use crate::reader::File as PureFile;
16086 use tempfile::tempdir;
16087
16088 let dir = tempdir().unwrap();
16089 let base = dir.path().join("base.h5");
16090 let (n, target) = (50i32, 250i32);
16091 build_unit_chunked(&base, n);
16092
16093 for max_phase in 1u8..=4 {
16094 let p = dir.path().join(format!("crash_ea_{max_phase}.h5"));
16095 append_stopped_at(&base, &p, n..target, max_phase);
16096 let expected_len = if max_phase == 4 { target } else { n };
16097 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16098 assert_eq!(
16099 f.dataset("d").unwrap().read_i32().unwrap(),
16100 (0..expected_len).collect::<Vec<_>>(),
16101 "inconsistent view after crash at phase {max_phase}"
16102 );
16103 }
16104 }
16105
16106 /// The same guarantee for an append that crosses the paged-data-block
16107 /// boundary (~131,060 chunks), where phase 1 allocates a paged super block,
16108 /// paged data blocks, the per-page checksums, and the page-init bitmap. This
16109 /// is the most intricate in-place growth the engine performs, and truncating
16110 /// it partway is exactly what a power loss does.
16111 ///
16112 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202).
16113 /// Opening a paged persisting file must seed each free section into the list
16114 /// for the page type its *slot* names, not one derived from its size.
16115 ///
16116 /// The managers a paged file uses mean different things — SUPER (slot 0) is
16117 /// metadata, DRAW (slot 2) is raw — and a freed section's size says nothing
16118 /// about which it came from. Getting this wrong is invisible from the outside:
16119 /// the total free space is unchanged, the reference library still opens the
16120 /// file, and only a later allocation drawn from the wrong list would mix a
16121 /// page. So assert the routing directly.
16122 #[test]
16123 fn paged_open_seeds_each_manager_by_slot() {
16124 use crate::writer::FileBuilder;
16125 use tempfile::tempdir;
16126
16127 let dir = tempdir().unwrap();
16128 let path = dir.path().join("paged_seed.h5");
16129 let mut b = FileBuilder::new();
16130 b.create_dataset("d")
16131 .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16132 .with_shape(&[1000]);
16133 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16134 .with_file_space_page_size(4096);
16135 b.write(&path).unwrap();
16136
16137 // Read the file's recorded free space *before* opening the session: the
16138 // session holds an exclusive OS lock, and on Windows those locks are
16139 // mandatory, so a concurrent `File::open` would fail outright.
16140 let on_disk: u64 = crate::reader::File::open(&path)
16141 .unwrap()
16142 .persisted_free_space()
16143 .iter()
16144 .map(|&(_, l)| l)
16145 .sum();
16146
16147 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16148 let pg = s.paged.as_ref().expect("a paged file installs paged state");
16149 assert_eq!(pg.page_size, 4096);
16150
16151 // The from-scratch writer leaves a page tail free in both the metadata and
16152 // the raw pages, so both per-type managers are populated. If every slot
16153 // were funnelled into one list, one of these would be empty.
16154 assert!(
16155 !pg.meta.sections().is_empty(),
16156 "SUPER (slot 0) sections seed the metadata list"
16157 );
16158 assert!(
16159 !pg.raw.sections().is_empty(),
16160 "DRAW (slot 2) sections seed the raw list, not the metadata list"
16161 );
16162
16163 // Nothing is double-counted or dropped: the lists partition exactly the
16164 // free space the file records, and no two sections overlap. A file this
16165 // crate wrote has nothing unclassified — it only ever files a whole
16166 // aligned page under the generic-large manager — so summing it in is a
16167 // statement about that too.
16168 let mut all = pg.reusable_sections();
16169 all.extend(pg.unclassified.sections());
16170 assert!(
16171 pg.unclassified.sections().is_empty(),
16172 "our own paged writer files nothing whose page type is unknown"
16173 );
16174 let flat: u64 = all.iter().map(|&(_, l)| l).sum();
16175 assert_eq!(
16176 flat, on_disk,
16177 "the split lists hold exactly the file's free space"
16178 );
16179 all.sort_by_key(|&(a, _)| a);
16180 let mut prev_end = 0u64;
16181 for (addr, len) in all {
16182 assert!(addr >= prev_end, "the per-type lists do not overlap");
16183 prev_end = addr + len;
16184 }
16185 }
16186
16187 /// Deleting a chunked dataset from a paged file the *reference library* wrote
16188 /// must not offer its chunk index for raw reuse.
16189 ///
16190 /// A chunk index is metadata by the format's taxonomy, and the C library
16191 /// allocates one accordingly — out of metadata pages, which on a small file
16192 /// means page 0, alongside the superblock, the root group, and every other
16193 /// object's header. This crate places its own indexes in raw pages beside the
16194 /// chunk data instead, and the reclaim path used to assume that of every file.
16195 /// Freeing a C-written index under that assumption puts its bytes on the raw
16196 /// list, and the next commit writes a dataset's values into the middle of a
16197 /// live metadata page.
16198 ///
16199 /// Both libraries still read such a file — every address in it is still
16200 /// correct — so nothing downstream reports the damage. The assertion has to be
16201 /// about placement itself: no byte this commit writes may land in a page that
16202 /// held live metadata beforehand.
16203 #[test]
16204 // The file is a committed one the reference C library wrote, see
16205 // `crates/crosscheck/tests/main/c_test_data.rs`.
16206 fn a_c_written_chunk_index_is_not_reclaimed_as_raw() {
16207 use tempfile::tempdir;
16208
16209 const PAGE: u64 = 4096;
16210 let dir = tempdir().unwrap();
16211 let path = crate::test_data::copy("c/paged_index.h5", dir.path());
16212
16213 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16214 let page_size = s.paged.as_ref().expect("a paged file").page_size;
16215 assert_eq!(page_size, PAGE);
16216 // Where the C library put the index: every page it touches is a metadata
16217 // page, since the C library allocates an index as metadata.
16218 let victim_addr = crate::group_v2::resolve_path_any(
16219 s.image.as_slice().unwrap(),
16220 AccessMode::ReadWrite,
16221 &s.superblock,
16222 &ObjectPath::parse("victim"),
16223 )
16224 .unwrap();
16225 let index_spans = s
16226 .chunked_index_spans(victim_addr)
16227 .expect("the C library's extensible-array index is enumerable");
16228 assert!(!index_spans.is_empty());
16229 // Page 0 always holds the superblock and the root group header; the index
16230 // pages are what this test is really about.
16231 let mut meta_pages: Vec<u64> = vec![0];
16232 for (addr, len) in &index_spans {
16233 for p in (addr / PAGE)..=((addr + len - 1) / PAGE) {
16234 meta_pages.push(p);
16235 }
16236 }
16237 meta_pages.sort_unstable();
16238 meta_pages.dedup();
16239
16240 // Delete the dataset, then write a small dataset that would fit in the
16241 // index's freed bytes.
16242 s.delete(&ObjectPathBuf::parse("/victim")).unwrap();
16243 s.commit().unwrap();
16244 let mut db = crate::type_builders::DatasetBuilder::new("added");
16245 db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
16246 s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
16247 .unwrap();
16248 s.commit().unwrap();
16249
16250 // Release the session's exclusive OS lock before reading the file back;
16251 // those locks are mandatory on Windows.
16252 drop(s);
16253
16254 // Every raw byte of the new dataset must be outside those pages.
16255 let f = crate::reader::File::open(&path).unwrap();
16256 let added = f.dataset("added").unwrap();
16257 let crate::Layout::Contiguous {
16258 address: Some(addr),
16259 size,
16260 } = added.layout().unwrap()
16261 else {
16262 panic!("a small f64 dataset is stored contiguously");
16263 };
16264 for p in (addr / PAGE)..=((addr + size - 1) / PAGE) {
16265 assert!(
16266 !meta_pages.contains(&p),
16267 "the new dataset's raw data landed at ({addr}, {size}), in page {p}, \
16268 which held live metadata before the edit: {meta_pages:?}"
16269 );
16270 }
16271
16272 assert_eq!(added.read_f64().unwrap(), vec![2.5f64; 8]);
16273 assert_eq!(
16274 f.dataset("keep").unwrap().read_i32().unwrap(),
16275 vec![1, 2, 3, 4]
16276 );
16277 }
16278
16279 /// Padding a tail page whose type is unknown must leave the padding
16280 /// untracked, not guess a type for it.
16281 ///
16282 /// A commit pads the file to a page boundary before laying down its manager
16283 /// blocks. When this session has made no typed allocation, the tail page is
16284 /// one a previous session left non-aligned — only a crash does that, since a
16285 /// clean close pads — and nothing says what it holds. Recording the padding
16286 /// under a guess would advertise those bytes for reuse of that type, and half
16287 /// the time they sit in a page of the other one. Under-reporting is the safe
16288 /// direction, and it is the call [`PagedEdit::begin`] already makes for the
16289 /// same situation.
16290 ///
16291 /// Reuse is what made this reachable: before it, every commit appended at
16292 /// least the root group's header, so the tail type was always known by the
16293 /// time the padding ran.
16294 #[test]
16295 fn padding_a_tail_page_of_unknown_type_records_nothing() {
16296 use crate::writer::FileBuilder;
16297 use tempfile::tempdir;
16298
16299 const PAGE: u64 = 4096;
16300 let dir = tempdir().unwrap();
16301
16302 let build = |path: &std::path::Path| {
16303 let mut b = FileBuilder::new();
16304 b.create_dataset("d")
16305 .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16306 .with_shape(&[1000]);
16307 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16308 .with_file_space_page_size(PAGE);
16309 b.write(path).unwrap();
16310 };
16311
16312 // Unknown tail type: pad, but record nothing.
16313 let unknown = dir.path().join("unknown_tail.h5");
16314 build(&unknown);
16315 let mut s = WriteEngine::open_with_locking(&unknown, FileLocking::Enabled).unwrap();
16316 s.append(&[0u8; 100]).unwrap(); // leaves the image non-page-aligned
16317 assert!(s.paged.as_ref().unwrap().last.is_none());
16318 s.pad_to_page().unwrap();
16319 let pg = s.paged.as_ref().unwrap();
16320 assert_eq!(s.image.len() % PAGE, 0, "the file is padded to a page");
16321 assert!(
16322 pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
16323 "padding a tail page of unknown type must claim no page type"
16324 );
16325 drop(s);
16326
16327 // The control: a known tail type is recorded, so the test above is about
16328 // the unknown case and not about padding never being tracked at all.
16329 let known = dir.path().join("known_tail.h5");
16330 build(&known);
16331 let mut s = WriteEngine::open_with_locking(&known, FileLocking::Enabled).unwrap();
16332 s.begin_page(PageType::Meta).unwrap();
16333 s.append(&[0u8; 100]).unwrap();
16334 s.pad_to_page().unwrap();
16335 let pg = s.paged.as_ref().unwrap();
16336 assert_eq!(
16337 pg.meta_pad.len(),
16338 1,
16339 "a known metadata tail records its padding as metadata free space"
16340 );
16341 assert!(pg.raw_pad.is_empty());
16342 }
16343
16344 /// The reference C library's generic-large manager holds free space of *both*
16345 /// page types, so a section from it is only reusable when it covers whole
16346 /// aligned pages.
16347 ///
16348 /// `H5F_MEM_PAGE_GENERIC` is aliased to `H5F_MEM_PAGE_LARGE_SUPER` and
16349 /// commented in the C library's own header as *"large-sized generic: meta and
16350 /// raw"*. Under paged aggregation on a contiguous-address driver — the default
16351 /// — `H5MF__alloc_to_fs_type` sends **every** allocation of a page or more
16352 /// there whatever its type, and `H5MF__alloc_pagefs` then records that
16353 /// allocation's page-alignment tail as a free section in the same manager. The
16354 /// tail is smaller than a page and sits in a page whose earlier bytes are the
16355 /// live object, so filing it as raw and handing it to chunk data would put raw
16356 /// bytes inside a metadata page.
16357 ///
16358 /// This measures that the C library really does write such a section — the
16359 /// assertion is worthless if it does not — and then pins that this engine
16360 /// keeps it out of reach: it is recorded, so a rewrite gives it back to the
16361 /// manager it came from, but never offered to an allocation.
16362 #[test]
16363 // The file is a committed one the reference C library wrote, see
16364 // `crates/crosscheck/tests/main/c_test_data.rs`.
16365 fn a_generic_large_section_is_only_reusable_as_whole_pages() {
16366 use tempfile::tempdir;
16367
16368 const PAGE: u64 = 512;
16369 let dir = tempdir().unwrap();
16370 let path = crate::test_data::copy("c/generic_large.h5", dir.path());
16371
16372 // The premise: at least one section in the generic-large manager (slot 6)
16373 // is a sub-page fragment. Read the managers slot by slot, since the
16374 // flattened public view cannot say which manager a section came from.
16375 let opened = crate::reader::File::open(&path).unwrap();
16376 let info = opened.file_space_info().expect("a persisting file").clone();
16377 drop(opened);
16378 assert_eq!(info.page_size, PAGE);
16379 let bytes = std::fs::read(&path).unwrap();
16380 let src = crate::source::BytesSource::new(bytes.as_slice());
16381 let slot6 = info.manager_addrs[6];
16382 assert_ne!(slot6, UNDEF, "the C library populated the large manager");
16383 let (sections, _) = free_space_manager::read_persisted_sections_source(
16384 &src,
16385 &[slot6],
16386 BaseAddress::ZERO,
16387 8,
16388 )
16389 .unwrap();
16390 let fragments: Vec<(u64, u64)> = sections
16391 .iter()
16392 .map(|s| (s.addr.get(), s.size))
16393 .filter(|&(addr, size)| addr % PAGE != 0 || size % PAGE != 0)
16394 .collect();
16395 assert!(
16396 !fragments.is_empty(),
16397 "the premise of this test: the C library files sub-page fragments in \
16398 its generic-large manager, but this file has none ({sections:?})"
16399 );
16400
16401 // The behavior: every such fragment is recorded but unreachable, and only
16402 // whole aligned pages from that manager are placeable.
16403 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16404 let pg = s.paged.as_ref().expect("a paged file installs paged state");
16405 let unclassified = pg.unclassified.sections();
16406 let reusable = pg.reusable_sections();
16407 for &(addr, size) in &fragments {
16408 assert!(
16409 unclassified.contains(&(addr, size)),
16410 "fragment ({addr}, {size}) must be recorded as unclassified, not lost"
16411 );
16412 assert!(
16413 !reusable
16414 .iter()
16415 .any(|&(a, l)| a < addr + size && addr < a + l),
16416 "fragment ({addr}, {size}) must not be offered to any allocation: {reusable:?}"
16417 );
16418 }
16419 }
16420
16421 /// A paged allocation may be served from the other page type's free list only
16422 /// where that space covers whole free pages — never from a hole in a page the
16423 /// other type still occupies.
16424 ///
16425 /// This is the allocation-side half of the rule
16426 /// [`paged_open_seeds_each_manager_by_slot`] pins on the seeding side, and it
16427 /// is just as invisible from the outside: handing a raw allocation a hole in a
16428 /// live metadata page puts chunk bytes inside that page, which every reader —
16429 /// this crate's and the reference library's — resolves correctly, since the
16430 /// file's addresses all still point where they should. Only the paging
16431 /// degrades. So assert the choice directly, in both directions, with a
16432 /// same-type control proving the free region really was big enough to be
16433 /// taken, and the whole-page case proving the exception is reached rather than
16434 /// merely permitted.
16435 #[test]
16436 fn a_paged_allocation_only_crosses_page_types_over_whole_free_pages() {
16437 use crate::writer::FileBuilder;
16438 use tempfile::tempdir;
16439
16440 const PAGE: u64 = 4096;
16441
16442 /// A paged session whose free lists hold exactly one region each, of the
16443 /// page types named. Both regions are interior, so an allocation that
16444 /// takes one is distinguishable from one that appends.
16445 fn session(
16446 path: &std::path::Path,
16447 meta: Option<(u64, u64)>,
16448 raw: Option<(u64, u64)>,
16449 ) -> WriteEngine {
16450 let mut b = FileBuilder::new();
16451 b.create_dataset("d")
16452 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16453 .with_shape(&[4000]);
16454 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16455 .with_file_space_page_size(PAGE);
16456 b.write(path).unwrap();
16457 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
16458 let pg = s.paged.as_mut().expect("a paged file installs paged state");
16459 pg.meta = FreeList::new();
16460 pg.raw = FreeList::new();
16461 if let Some((addr, len)) = meta {
16462 pg.meta.free(addr, len);
16463 }
16464 if let Some((addr, len)) = raw {
16465 pg.raw.free(addr, len);
16466 }
16467 s
16468 }
16469
16470 let dir = tempdir().unwrap();
16471 // Two interior regions well inside the file the builder wrote (16 KiB of
16472 // raw data alone), each a *fragment* of a page whose other bytes are live,
16473 // so either could physically hold the request and only the page-type rule
16474 // decides. Deliberately not page-aligned and not a whole page: that is the
16475 // case the rule forbids outright.
16476 let hole_a = (PAGE + 512, 2048);
16477 let hole_b = (2 * PAGE + 512, 2048);
16478
16479 // Raw request, only a metadata fragment free: appends rather than mixing
16480 // the page.
16481 let mut s = session(&dir.path().join("a.h5"), Some(hole_a), None);
16482 assert!(
16483 matches!(
16484 s.reserve(1024, PageType::Raw).unwrap(),
16485 Placement::Appended { .. }
16486 ),
16487 "a raw allocation must not be served out of a live metadata page"
16488 );
16489 drop(s);
16490
16491 // Metadata request, only a raw fragment free: likewise.
16492 let mut s = session(&dir.path().join("b.h5"), None, Some(hole_b));
16493 assert!(
16494 matches!(
16495 s.reserve(1024, PageType::Meta).unwrap(),
16496 Placement::Appended { .. }
16497 ),
16498 "a metadata allocation must not be served out of a live raw page"
16499 );
16500 drop(s);
16501
16502 // The control: with the matching type free, the same request is reused —
16503 // so the two refusals above are the page-type rule, not a size failure.
16504 let mut s = session(&dir.path().join("c.h5"), Some(hole_a), Some(hole_b));
16505 assert!(
16506 matches!(
16507 s.reserve(1024, PageType::Raw).unwrap(),
16508 Placement::Reused { addr, .. } if addr == hole_b.0
16509 ),
16510 "a raw allocation takes the raw hole"
16511 );
16512 assert!(
16513 matches!(
16514 s.reserve(1024, PageType::Meta).unwrap(),
16515 Placement::Reused { addr, .. } if addr == hole_a.0
16516 ),
16517 "a metadata allocation takes the metadata hole"
16518 );
16519 drop(s);
16520
16521 // The exception, and the whole reason the file stops growing (issue #286):
16522 // a page with *nothing* in it holds no type to contradict, so either kind
16523 // may open it. The page is claimed whole and what the request does not use
16524 // becomes free space of the claiming type.
16525 let mut s = session(&dir.path().join("d.h5"), Some((PAGE, PAGE)), None);
16526 assert!(
16527 matches!(
16528 s.reserve(1024, PageType::Raw).unwrap(),
16529 Placement::Reused { addr, .. } if addr == PAGE
16530 ),
16531 "a raw allocation may open an empty metadata page"
16532 );
16533 let pg = s.paged.as_ref().expect("still paged");
16534 assert_eq!(
16535 pg.raw.sections(),
16536 [(PAGE + 1024, PAGE - 1024)],
16537 "the rest of the claimed page is free space of the claiming type"
16538 );
16539 assert!(
16540 pg.meta.sections().is_empty(),
16541 "the page left the list it was claimed from"
16542 );
16543 drop(s);
16544
16545 // And the claim starts at the empty page, not at the free run's own start.
16546 // A run that spans from mid-page into whole pages beyond it is the common
16547 // shape — the tail of a live page, then pages nothing is left in — and
16548 // taking it from the front would put the request in the live page.
16549 let mut s = session(
16550 &dir.path().join("e.h5"),
16551 None,
16552 Some((PAGE + 512, 3 * PAGE - 512)),
16553 );
16554 assert!(
16555 matches!(
16556 s.reserve(1024, PageType::Meta).unwrap(),
16557 Placement::Reused { addr, .. } if addr == 2 * PAGE
16558 ),
16559 "the claim must begin at the empty page, not at the run's start"
16560 );
16561 // Both edges the claim leaves behind survive it: they are ordinary free
16562 // space of the type that already held them, and dropping either is the same
16563 // silent leak this change exists to remove.
16564 let pg = s.paged.as_ref().expect("still paged");
16565 assert_eq!(
16566 pg.raw.sections(),
16567 [(PAGE + 512, PAGE - 512), (3 * PAGE, PAGE)],
16568 "the fragment below the claimed page and the page above it both stay free"
16569 );
16570 assert_eq!(
16571 pg.meta.sections(),
16572 [(2 * PAGE + 1024, PAGE - 1024)],
16573 "the rest of the claimed page is free space of the claiming type"
16574 );
16575 }
16576
16577 /// Every byte a paged session could still hand out, across both page types.
16578 fn free_total(s: &WriteEngine) -> u64 {
16579 let pg = s.paged.as_ref().expect("a paged session");
16580 pg.meta
16581 .sections()
16582 .into_iter()
16583 .chain(pg.raw.sections())
16584 .map(|(_, len)| len)
16585 .sum()
16586 }
16587
16588 /// A paged commit's tail removes from the free lists exactly the bytes it goes
16589 /// on to write — no more, and nothing at all when it declines to place itself.
16590 ///
16591 /// This is the invariant the whole change rests on, and the one an integration
16592 /// test cannot reach: the tail's length and the hole it lands in determine each
16593 /// other, so which settlement the arithmetic reaches depends on the shape of
16594 /// the free list, and a file the writer builds only ever produces some of them.
16595 /// In particular, best fit prefers the *smallest* region that fits, so a hole
16596 /// whose length is exactly the length the tail proposed is the one it will
16597 /// choose — and consuming a region outright drops a section from the managers,
16598 /// making the plan come out *shorter* than the space just reserved for it. A
16599 /// tail that accepted that would retire the difference from the free list with
16600 /// nothing recording it, which is issue #286 again, a few bytes at a time.
16601 ///
16602 /// Hence a contiguous sweep of hole sizes rather than a chosen one: the length
16603 /// the tail proposes is a property of the file and its free list, not something
16604 /// this test should have to know, and the sweep is certain to cross it.
16605 #[test]
16606 fn a_paged_tail_takes_exactly_the_space_it_fills() {
16607 use crate::writer::FileBuilder;
16608 use tempfile::tempdir;
16609
16610 const PAGE: u64 = 4096;
16611 /// Any plausible extension length exercises the same arithmetic, and the
16612 /// invariant holds for every one of them; the tail's real length is settled
16613 /// by the commit, which is not what is under test here.
16614 const EXT_LEN: u64 = 100;
16615
16616 let dir = tempdir().unwrap();
16617 let path = dir.path().join("tail_exact.h5");
16618 let mut b = FileBuilder::new();
16619 b.create_dataset("d")
16620 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16621 .with_shape(&[4000]);
16622 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16623 .with_file_space_page_size(PAGE);
16624 b.write(&path).unwrap();
16625 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16626 let os = s.superblock.offset_size;
16627
16628 let mut placed = 0usize;
16629 for hole in 120..420u64 {
16630 {
16631 let pg = s.paged.as_mut().expect("a paged file installs paged state");
16632 pg.meta = FreeList::new();
16633 pg.raw = FreeList::new();
16634 pg.unclassified = FreeList::new();
16635 pg.meta.free(PAGE, hole);
16636 }
16637 let free_before = free_total(&s);
16638 let layout = s.tail_layout(&[], &[], EXT_LEN, PAGE, os);
16639 let free_after = free_total(&s);
16640 match layout {
16641 Some((_, _, at, blocks_len, _)) => {
16642 placed += 1;
16643 assert_eq!(
16644 free_before - free_after,
16645 blocks_len,
16646 "hole {hole}: the tail took {} bytes at {at} to write {blocks_len}",
16647 free_before - free_after
16648 );
16649 }
16650 None => assert_eq!(
16651 free_after, free_before,
16652 "hole {hole}: a tail that declines to place itself must hand back \
16653 everything it tried"
16654 ),
16655 }
16656 }
16657 assert!(
16658 placed > 0,
16659 "the sweep must reach holes the tail can actually use, or it asserts \
16660 nothing about placement"
16661 );
16662 }
16663
16664 /// The shrink pass never grows the file: asked to rewrite the tail with
16665 /// nothing in the free list to put it in, it writes nothing at all
16666 /// (issue #418).
16667 ///
16668 /// That is the whole safety argument for running it. It exists to make the
16669 /// file smaller, and a tail it cannot place would otherwise go past
16670 /// end-of-file — a rewrite taken for the sake of a shorter file leaving a
16671 /// longer one. The second half of the test is what keeps the first half
16672 /// honest: under `TailPlacement::Anywhere` the same call on the same session
16673 /// does write a tail, so the fixture really did have one to write.
16674 #[test]
16675 fn a_reuse_only_tail_rewrite_that_cannot_place_itself_writes_nothing() {
16676 use crate::writer::FileBuilder;
16677 use tempfile::tempdir;
16678
16679 let dir = tempdir().unwrap();
16680 let path = dir.path().join("reuse_only.h5");
16681 let mut b = FileBuilder::new();
16682 b.create_dataset("d")
16683 .with_i32_data(&(0..400).collect::<Vec<i32>>())
16684 .with_shape(&[400]);
16685 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16686 b.write(&path).unwrap();
16687 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16688 // No hole anywhere: the only place a tail could go is past end-of-file.
16689 s.free = FreeList::new();
16690 let root = s.superblock.root_group_address;
16691 let len_before = s.image.len();
16692 let ext_before = s.superblock.superblock_extension_address;
16693
16694 s.commit_persisting(root, Vec::new(), TailPlacement::ReuseOnly)
16695 .unwrap();
16696 assert_eq!(
16697 s.image.len(),
16698 len_before,
16699 "a reuse-only rewrite with nothing to reuse must leave the file alone"
16700 );
16701 assert_eq!(
16702 s.superblock.superblock_extension_address, ext_before,
16703 "and must not have published a new extension either"
16704 );
16705
16706 s.commit_persisting(root, Vec::new(), TailPlacement::Anywhere)
16707 .unwrap();
16708 assert!(
16709 s.image.len() > len_before,
16710 "the same rewrite that may append does grow the file, so the case \
16711 above was a tail declined rather than a tail of no size"
16712 );
16713 }
16714
16715 /// A flat commit's tail removes from the free list exactly the bytes its
16716 /// recorded extent covers — no more, and nothing at all when it declines to
16717 /// place itself.
16718 ///
16719 /// The flat tail accepts a plan *shorter* than its reservation where the paged
16720 /// one refuses ([`WriteEngine::flat_tail_layout`] says why), so this is the
16721 /// invariant that keeps those spare bytes from leaking: what the layout hands
16722 /// back as the tail's length is the whole reservation, which the commit records
16723 /// as the extent to free when the next one supersedes it. Returning the plan's
16724 /// length instead would retire the difference from the free list with nothing
16725 /// recording it — issue #286 again, a few bytes at a time.
16726 ///
16727 /// Swept over hole sizes rather than a chosen one, for the reason the paged
16728 /// sweep gives: the length the tail proposes is a property of the file and its
16729 /// free list, and best fit takes a hole of exactly that length outright, which
16730 /// drops a section from the managers and is what makes the plan come out
16731 /// shorter than the reservation in the first place.
16732 #[test]
16733 fn a_flat_tail_takes_exactly_the_extent_it_records() {
16734 use crate::writer::FileBuilder;
16735 use tempfile::tempdir;
16736
16737 /// Any plausible extension length exercises the same arithmetic; the real
16738 /// one is settled by the commit, which is not what is under test here.
16739 const EXT_LEN: u64 = 100;
16740 /// Any address does: the layout only ever asks the free list for a length.
16741 const HOLE_AT: u64 = 512;
16742
16743 let dir = tempdir().unwrap();
16744 let path = dir.path().join("flat_tail_exact.h5");
16745 let mut b = FileBuilder::new();
16746 b.create_dataset("d")
16747 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16748 .with_shape(&[4000]);
16749 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16750 b.write(&path).unwrap();
16751 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16752 let os = s.superblock.offset_size;
16753
16754 let free_total =
16755 |s: &WriteEngine| -> u64 { s.free.sections().into_iter().map(|(_, len)| len).sum() };
16756
16757 let (mut placed, mut declined, mut with_slack) = (0usize, 0usize, 0usize);
16758 for hole in 120..420u64 {
16759 s.free = FreeList::new();
16760 s.free.free(HOLE_AT, hole);
16761 let free_before = free_total(&s);
16762 let (post, at, tail_len, _) = s.flat_tail_layout(&[], &[], EXT_LEN, os);
16763 let free_after = free_total(&s);
16764 // The blocks the commit will write into the extent it was handed. A hole
16765 // consumed outright drops a section from the managers, so this comes out
16766 // shorter than the extent for some hole sizes and the difference is what
16767 // the extent has to cover.
16768 let written = EXT_LEN + file_fsm_blocks_len(&s.persisted_sections(&post), os);
16769 match at {
16770 Some(at) => {
16771 placed += 1;
16772 assert_eq!(
16773 free_before - free_after,
16774 tail_len,
16775 "hole {hole}: the tail took {} bytes at {at} for an extent of \
16776 {tail_len}",
16777 free_before - free_after
16778 );
16779 if written < tail_len {
16780 with_slack += 1;
16781 }
16782 }
16783 None => {
16784 declined += 1;
16785 assert_eq!(
16786 free_after, free_before,
16787 "hole {hole}: a tail that declines to place itself must hand \
16788 back everything it tried"
16789 );
16790 assert_eq!(
16791 tail_len, written,
16792 "hole {hole}: a tail with nowhere to go is appended, and the \
16793 length it reports is what it will write there"
16794 );
16795 }
16796 }
16797 }
16798 assert!(
16799 placed > 0,
16800 "the sweep must reach holes the tail can actually use, or it asserts \
16801 nothing about placement"
16802 );
16803 assert!(
16804 declined > 0,
16805 "the sweep must reach holes too small for the tail, or the rule about \
16806 handing back a failed attempt is never exercised"
16807 );
16808 assert!(
16809 with_slack > 0,
16810 "the sweep must reach a hole the tail does not fill exactly, or the \
16811 rule about spare bytes is never exercised"
16812 );
16813 }
16814
16815 /// A paged commit whose tail finds no free space to sit in opens a page for it
16816 /// — and hands the rest of that page back as free metadata.
16817 ///
16818 /// Every paged file this crate writes has holes in it from the start, so the
16819 /// suite's ordinary fixtures always reuse and this branch is never taken. It is
16820 /// reachable in the wild, by a file whose free space is all spoken for, and the
16821 /// page it opens is the same page a page-per-commit leak used to strand. Emptying
16822 /// the free lists by hand is what puts the file in that state deliberately.
16823 #[test]
16824 fn a_paged_tail_with_nowhere_to_go_opens_a_page_and_frees_the_rest() {
16825 use crate::writer::FileBuilder;
16826 use tempfile::tempdir;
16827
16828 const PAGE: u64 = 4096;
16829 let dir = tempdir().unwrap();
16830 let path = dir.path().join("tail_appends.h5");
16831 let mut b = FileBuilder::new();
16832 b.create_dataset("d")
16833 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16834 .with_shape(&[4000]);
16835 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16836 .with_file_space_page_size(PAGE);
16837 b.write(&path).unwrap();
16838 let before = std::fs::metadata(&path).unwrap().len();
16839
16840 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16841 {
16842 let pg = s.paged.as_mut().expect("a paged file installs paged state");
16843 pg.meta = FreeList::new();
16844 pg.raw = FreeList::new();
16845 pg.unclassified = FreeList::new();
16846 }
16847 // A commit with nothing staged returns without writing, so give it one
16848 // small metadata object to place. It appends for want of anywhere else,
16849 // and the tail follows it into the same page.
16850 s.create_group(&ObjectPathBuf::parse("g")).unwrap();
16851 s.commit().unwrap();
16852
16853 let after = std::fs::metadata(&path).unwrap().len();
16854 assert!(
16855 after > before && (after - before) % PAGE == 0,
16856 "the commit opened whole pages ({before} -> {after})"
16857 );
16858 // What the tail did not fill of the page it opened is metadata this session
16859 // can still spend. Losing it is how the file used to give up most of a page
16860 // on every commit; the managers just written cannot record it, so the
16861 // session carries it to the next commit.
16862 let pg = s.paged.as_ref().expect("still paged");
16863 let (addr, len) = pg
16864 .meta
16865 .sections()
16866 .into_iter()
16867 .find(|&(addr, len)| addr + len == after)
16868 .expect("the page the tail opened leaves a free remainder at end-of-file");
16869 assert!(
16870 len > 0 && len < PAGE && addr >= before,
16871 "the tail's blocks take the front of the page it opened and the rest is \
16872 free ({len} of {PAGE} at {addr}, file {before} -> {after})"
16873 );
16874 }
16875
16876 /// Deleting a chunked dataset from a paged file must not record its freed
16877 /// chunk index in the *metadata* manager.
16878 ///
16879 /// Every writer in this crate emits a chunk index in the same run as the chunk
16880 /// data it indexes, so the index sits in a raw page. Recording it as metadata
16881 /// would advertise a metadata-sized hole inside a page that still holds another
16882 /// dataset's live chunk data, and the reference library placing metadata there
16883 /// would mix the page — the one thing a paged file forbids. Page homogeneity is
16884 /// preserved on disk either way, so the mis-filing is invisible to a signature
16885 /// scan of the file and to the C library; the manager a section lands in has to
16886 /// be checked directly.
16887 #[test]
16888 fn deleted_chunk_index_is_freed_into_a_raw_manager() {
16889 use crate::writer::FileBuilder;
16890 use tempfile::tempdir;
16891
16892 let dir = tempdir().unwrap();
16893 let path = dir.path().join("paged_chunk_index_free.h5");
16894 let page = 4096u64;
16895 let mut b = FileBuilder::new();
16896 for name in ["drop", "keep"] {
16897 b.create_dataset(name)
16898 .with_i32_data(&(0..200).collect::<Vec<i32>>())
16899 .with_shape(&[200])
16900 .with_chunks(&[50]);
16901 }
16902 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16903 .with_file_space_page_size(page);
16904 b.write(&path).unwrap();
16905
16906 {
16907 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16908 s.delete(&ObjectPathBuf::parse("/drop")).unwrap();
16909 s.commit().unwrap();
16910 }
16911
16912 // Pages still occupied by the surviving dataset's chunk data. Read with the
16913 // session closed: its lock is mandatory on Windows.
16914 let live_raw_pages: Vec<u64> = {
16915 let f = crate::reader::File::open(&path).unwrap();
16916 let ds = f.dataset("keep").unwrap();
16917 let mut pages: Vec<u64> = ds
16918 .chunks()
16919 .unwrap()
16920 .iter()
16921 .filter(|c| c.storage_size > 0)
16922 .flat_map(|c| (c.address / page)..=((c.address + c.storage_size - 1) / page))
16923 .collect();
16924 pages.sort_unstable();
16925 pages.dedup();
16926 pages
16927 };
16928 assert!(!live_raw_pages.is_empty(), "expected live raw pages");
16929
16930 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16931 let pg = s.paged.as_ref().expect("a paged file installs paged state");
16932 for (addr, len) in pg.meta.sections() {
16933 for p in (addr / page)..=((addr + len - 1) / page) {
16934 assert!(
16935 !live_raw_pages.contains(&p),
16936 "metadata free section ({addr}, {len}) sits in page {p}, which still \
16937 holds live raw chunk data"
16938 );
16939 }
16940 }
16941 // The index really was reclaimed somewhere, so this is not vacuous.
16942 let reclaimed: u64 = pg.reusable_sections().iter().map(|&(_, l)| l).sum();
16943 assert!(reclaimed > 0, "the delete reclaimed nothing");
16944 }
16945
16946 /// A paged commit that fails partway must leave the session's free lists
16947 /// exactly as it found them.
16948 ///
16949 /// Everything the commit gathers to free is still *live* until the superblock
16950 /// repoint: the objects occupying those regions are reachable from the old
16951 /// root, which a failed commit never replaces. A session that recorded them as
16952 /// free anyway would hand them out on the next commit, and the file would lose
16953 /// data with no error anywhere — in a release build, where the free list's
16954 /// double-free `debug_assert` is compiled out, silently.
16955 ///
16956 /// The failure is induced by pointing the superblock extension at a byte range
16957 /// that is not an object header, which fails the extension rewrite immediately
16958 /// after the regions are gathered.
16959 #[test]
16960 fn failed_paged_commit_leaves_the_free_lists_untouched() {
16961 use crate::writer::FileBuilder;
16962 use tempfile::tempdir;
16963
16964 let dir = tempdir().unwrap();
16965 let path = dir.path().join("paged_failed_commit.h5");
16966 let mut b = FileBuilder::new();
16967 b.create_dataset("keep")
16968 .with_i32_data(&(0..200).collect::<Vec<i32>>())
16969 .with_shape(&[200]);
16970 b.create_dataset("drop")
16971 .with_i32_data(&(0..200).collect::<Vec<i32>>())
16972 .with_shape(&[200]);
16973 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16974 .with_file_space_page_size(4096);
16975 b.write(&path).unwrap();
16976
16977 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16978 let before = s.space_accounting().reusable_free_space;
16979
16980 // Break the extension so the commit fails *after* it has gathered the
16981 // regions `drop` vacates and *before* the superblock repoint.
16982 let good_ext = s.superblock.superblock_extension_address;
16983 s.superblock.superblock_extension_address = Some(0);
16984 s.delete(&ObjectPathBuf::parse("/drop")).unwrap();
16985 assert!(
16986 s.commit().is_err(),
16987 "a commit with an unreadable extension must fail"
16988 );
16989
16990 assert_eq!(
16991 s.space_accounting().reusable_free_space,
16992 before,
16993 "a failed commit must not record still-live regions as free"
16994 );
16995
16996 // The session stays usable: repair the extension and commit for real. If
16997 // the failed commit had folded its regions in, this second commit would
16998 // double-free them (a debug assertion) and publish `keep`'s live extent.
16999 s.superblock.superblock_extension_address = good_ext;
17000 s.delete(&ObjectPathBuf::parse("/drop")).unwrap();
17001 s.commit()
17002 .expect("the session is usable after a failed commit");
17003
17004 // Release the session's exclusive OS lock before reading the file back.
17005 // Those locks are mandatory on Windows, so a `File::open` overlapping the
17006 // session fails outright there (advisory locks elsewhere would allow it).
17007 drop(s);
17008
17009 let f = crate::reader::File::open(&path).unwrap();
17010 let kept = f.dataset("keep").unwrap().read_i32().unwrap();
17011 assert_eq!(kept, (0..200).collect::<Vec<i32>>(), "keep survives intact");
17012 let freed: u64 = f.persisted_free_space().iter().map(|&(_, l)| l).sum();
17013 let live_end = f.file_size();
17014 assert!(
17015 freed < live_end,
17016 "the recorded free space cannot cover the whole file"
17017 );
17018 }
17019
17020 /// A commit that fails partway must roll back the heap-collection
17021 /// provenance along with the free lists (issue #321).
17022 ///
17023 /// `resolve_overwrite_bytes` places a collection and records it in the
17024 /// *apply* phase. A commit that then fails before its repoint gives that
17025 /// space back to the free list — and used to keep the record, which is the
17026 /// one piece of engine state naming file addresses that the rollback missed.
17027 /// The next overwrite of that path would free the region a *later* commit
17028 /// had since been handed, so the file's live variable-length data would sit
17029 /// in space the allocator considers free, and the next unrelated write would
17030 /// land on it.
17031 ///
17032 /// Induced the same way as
17033 /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17034 /// superblock extension fails the commit after the apply phase has run.
17035 #[test]
17036 fn a_failed_commit_rolls_back_the_heap_collection_provenance() {
17037 use crate::writer::FileBuilder;
17038 use tempfile::tempdir;
17039
17040 let dir = tempdir().unwrap();
17041 let path = dir.path().join("failed_commit_vl_provenance.h5");
17042 let mut b = FileBuilder::new();
17043 b.create_dataset("labels")
17044 .with_vlen_strings(&["seed-one", "seed-two"]);
17045 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17046 b.write(&path).unwrap();
17047
17048 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17049
17050 // One good round, so there is a record to roll back.
17051 s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17052 let mut db = crate::type_builders::DatasetBuilder::new("");
17053 db.with_vlen_strings(&["round-one-aaaa", "round-one-bbbb"]);
17054 db
17055 })
17056 .unwrap();
17057 s.commit().unwrap();
17058 let recorded = s.vl_overwrite_heaps.clone();
17059 assert!(!recorded.is_empty(), "the good round recorded nothing");
17060
17061 // Break the extension so the next commit fails after the apply phase.
17062 let good_ext = s.superblock.superblock_extension_address;
17063 s.superblock.superblock_extension_address = Some(0);
17064 s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17065 let mut db = crate::type_builders::DatasetBuilder::new("");
17066 db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17067 db
17068 })
17069 .unwrap();
17070 assert!(
17071 s.commit().is_err(),
17072 "a commit with an unreadable extension must fail"
17073 );
17074
17075 assert_eq!(
17076 s.vl_overwrite_heaps, recorded,
17077 "a failed commit must not leave a record naming space it gave back"
17078 );
17079
17080 // The session stays usable, and the collections the rolled-back record
17081 // names are still the live ones.
17082 s.superblock.superblock_extension_address = good_ext;
17083 s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17084 let mut db = crate::type_builders::DatasetBuilder::new("");
17085 db.with_vlen_strings(&["round-three-a", "round-three-b"]);
17086 db
17087 })
17088 .unwrap();
17089 s.commit()
17090 .expect("the session is usable after a failed commit");
17091 drop(s);
17092
17093 let f = crate::reader::File::open(&path).unwrap();
17094 assert_eq!(
17095 f.dataset("labels").unwrap().read_string().unwrap(),
17096 vec!["round-three-a".to_string(), "round-three-b".to_string()]
17097 );
17098 }
17099
17100 /// A failed commit must not leave the free list offering a span the image
17101 /// still points into (issue #321).
17102 ///
17103 /// Resolving a staged variable-length overwrite *allocates* — a global heap
17104 /// collection, drawn from a freed region where one fits. If the overwrite
17105 /// were applied in place, that allocation's address would be written into a
17106 /// data block the current root already reaches; a commit failing after the
17107 /// write and before its repoint then hands the span back to the free list,
17108 /// while the image still names it. The next commit places something else
17109 /// there and the dataset reads that object's heap bytes, with every checksum
17110 /// in the file intact.
17111 ///
17112 /// A relocating overwrite has no such window — its new block is reachable
17113 /// from nothing until the repoint — which is why `prepare_write` refuses to
17114 /// plan a staged variable-length overwrite in place at all. This is that
17115 /// rule's test.
17116 ///
17117 /// The freed hole has to exist *first*, or the failed commit's collection is
17118 /// appended past end-of-file where nothing reuses it and the bug hides. The
17119 /// filler datasets each need a metadata span of the size the rolled-back one
17120 /// occupied, which is what draws on it.
17121 #[test]
17122 fn a_failed_commit_leaves_no_reusable_span_the_image_names() {
17123 use crate::writer::FileBuilder;
17124 use tempfile::tempdir;
17125
17126 let dir = tempdir().unwrap();
17127 let path = dir.path().join("failed_commit_live_span.h5");
17128 let mut b = FileBuilder::new();
17129 b.create_dataset("labels")
17130 .with_vlen_strings(&["seed-one", "seed-two"]);
17131 b.create_dataset("big").with_u8_data(&vec![0x5A; 40960]);
17132 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17133 b.write(&path).unwrap();
17134
17135 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17136 // The hole the collections below are placed into by reuse.
17137 s.delete(&ObjectPathBuf::parse("/big")).unwrap();
17138 s.commit().unwrap();
17139
17140 let round_one = ["round-one-aaaa", "round-one-bbbb"];
17141 s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17142 let mut db = crate::type_builders::DatasetBuilder::new("");
17143 db.with_vlen_strings(&round_one);
17144 db
17145 })
17146 .unwrap();
17147 s.commit().unwrap();
17148
17149 // Fail a second overwrite after its apply phase has placed a collection.
17150 let good_ext = s.superblock.superblock_extension_address;
17151 s.superblock.superblock_extension_address = Some(0);
17152 s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17153 let mut db = crate::type_builders::DatasetBuilder::new("");
17154 db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17155 db
17156 })
17157 .unwrap();
17158 assert!(
17159 s.commit().is_err(),
17160 "a commit with an unreadable extension must fail"
17161 );
17162 s.superblock.superblock_extension_address = good_ext;
17163
17164 // Unrelated commits that each want a metadata span of the same size.
17165 // Whatever the failed attempt gave back, these are what would draw on it.
17166 for i in 0..4 {
17167 s.stage_created_dataset(&ObjectPathBuf::parse(&format!("/filler{i}")), {
17168 let mut db = crate::type_builders::DatasetBuilder::new("");
17169 db.with_vlen_strings(&["XXXXXXXXXXXXXXXXXXXX", "YYYYYYYYYYYYYYYYYYYY"]);
17170 db
17171 })
17172 .unwrap();
17173 s.commit().unwrap();
17174 }
17175
17176 // Read only once the session has released its lock on the file: those
17177 // locks are mandatory on Windows, so a `File::open` overlapping the
17178 // session fails outright there where advisory locks elsewhere allow it.
17179 drop(s);
17180
17181 let f = crate::reader::File::open(&path).unwrap();
17182 let got = f.dataset("labels").unwrap().read_string();
17183 assert!(
17184 got.as_ref()
17185 .is_ok_and(|v| v.iter().map(String::as_str).eq(round_one)),
17186 "/labels reads another dataset's heap objects: {got:?}"
17187 );
17188 }
17189
17190 /// A commit that writes into reused free space and then dies must leave the
17191 /// file exactly as it found it.
17192 ///
17193 /// This is the safety argument for reuse, stated as a test: a commit may
17194 /// overwrite a freed region *before* the superblock repoint precisely because
17195 /// nothing reachable from the on-disk root lives there any more, so an attempt
17196 /// that never reaches the repoint is invisible. A chunked dataset is the case
17197 /// worth pinning — it is the largest thing a commit places, so it overwrites
17198 /// the most, and it is the one that used only to append.
17199 ///
17200 /// The failure is induced the same way as
17201 /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17202 /// superblock extension, which the persisting tail hits *after* the apply loop
17203 /// has written every object.
17204 #[test]
17205 fn a_failed_commit_that_reused_free_space_leaves_the_file_intact() {
17206 use crate::writer::FileBuilder;
17207 use tempfile::tempdir;
17208
17209 let dir = tempdir().unwrap();
17210 let path = dir.path().join("reuse_failed_commit.h5");
17211 let victim: Vec<f64> = (0..4096).map(|i| (i % 13) as f64).collect();
17212 let ceiling: Vec<i32> = (0..500).collect();
17213 let mut b = FileBuilder::new();
17214 b.create_dataset("keep").with_i32_data(&[1, 2, 3]);
17215 b.create_dataset("victim")
17216 .with_f64_data(&victim)
17217 .with_shape(&[4096])
17218 .with_chunks(&[512]);
17219 // Above the victim, so the delete leaves an interior hole and the reuse
17220 // has live bytes on both sides of what it overwrites.
17221 b.create_dataset("ceiling")
17222 .with_i32_data(&ceiling)
17223 .with_shape(&[500]);
17224 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
17225 b.write(&path).unwrap();
17226
17227 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17228 s.delete(&ObjectPathBuf::parse("/victim")).unwrap();
17229 s.commit().unwrap();
17230 let free_before = s.space_accounting().reusable_free_space;
17231 let len_before = std::fs::metadata(&path).unwrap().len();
17232 assert!(
17233 free_before.iter().any(|&(_, l)| l > 4096 * 8 / 2),
17234 "the deleted chunked dataset left a hole worth reusing: {free_before:?}"
17235 );
17236
17237 // Break the extension so the commit fails in its persisting tail, after
17238 // the apply loop has written the new dataset into that hole.
17239 s.superblock.superblock_extension_address = Some(0);
17240 let mut db = crate::type_builders::DatasetBuilder::new("fresh");
17241 db.with_f64_data(&vec![7.5f64; 4096])
17242 .with_shape(&[4096])
17243 .with_chunks(&[512]);
17244 s.stage_created_dataset(&ObjectPathBuf::parse("/fresh"), db)
17245 .unwrap();
17246 assert!(
17247 s.commit().is_err(),
17248 "a commit with an unreadable extension must fail"
17249 );
17250 assert_eq!(
17251 s.space_accounting().reusable_free_space,
17252 free_before,
17253 "the failed commit gives back the region it drew from"
17254 );
17255 // Release the session's exclusive OS lock before reading the file back;
17256 // those locks are mandatory on Windows.
17257 drop(s);
17258
17259 // The file still describes the tree the last *successful* commit left: the
17260 // survivors read exactly, the half-written dataset is not linked, and the
17261 // superblock's end-of-file still matches the file.
17262 assert_eq!(std::fs::metadata(&path).unwrap().len(), len_before);
17263 let f = crate::reader::File::open(&path).unwrap();
17264 assert_eq!(f.file_size(), len_before);
17265 assert_eq!(
17266 f.dataset("keep").unwrap().read_i32().unwrap(),
17267 vec![1, 2, 3]
17268 );
17269 assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), ceiling);
17270 assert!(f.dataset("victim").is_err());
17271 assert!(f.dataset("fresh").is_err());
17272 }
17273
17274 /// A refused commit leaves every dataset reading what it read before —
17275 /// including the one whose values it had already written over (issue #344).
17276 ///
17277 /// Everything else a commit places lands where nothing reaches it until the
17278 /// superblock is repointed, so an attempt that stops short of the repoint is
17279 /// invisible. A same-length value overwrite is the exception: it writes
17280 /// straight over the data block the *current* root already reaches, so there
17281 /// is no repoint to withhold and the write is live the moment it lands. Both
17282 /// halves of one refused batch are asserted here, because the defect was
17283 /// that they disagreed: the overwrite survived and the object creation
17284 /// beside it did not.
17285 ///
17286 /// Closing the session is enough to expose it — no later commit is needed,
17287 /// since the bytes are in the file already and write gathering only delays
17288 /// when they reach the disk.
17289 ///
17290 /// A second staged edit is what keeps this on the full commit path; a batch
17291 /// of nothing but same-length overwrites takes the fast path, which
17292 /// `a_failed_fast_path_commit_puts_back_the_value_it_overwrote` covers. The
17293 /// failure is induced the same way as
17294 /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17295 /// superblock extension, which fails the commit in its tail, after the apply
17296 /// phase has done the in-place write.
17297 #[test]
17298 fn a_failed_commit_puts_back_the_value_it_overwrote() {
17299 use crate::writer::FileBuilder;
17300 use tempfile::tempdir;
17301
17302 let dir = tempdir().unwrap();
17303 let path = dir.path().join("inplace_partial.h5");
17304 let mut b = FileBuilder::new();
17305 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17306 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17307 b.write(&path).unwrap();
17308
17309 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17310
17311 let good_ext = s.superblock.superblock_extension_address;
17312 s.superblock.superblock_extension_address = Some(0);
17313 s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
17314 let mut db = crate::type_builders::DatasetBuilder::new("");
17315 db.with_i32_data(&[9, 9, 9]);
17316 db
17317 })
17318 .unwrap();
17319 s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
17320 let mut db = crate::type_builders::DatasetBuilder::new("");
17321 db.with_i32_data(&[42]);
17322 db
17323 })
17324 .unwrap();
17325 let refused = s.commit();
17326 assert!(
17327 matches!(refused, Err(Error::EditUnsupported(_))),
17328 "the refusal, not a rollback failure: {refused:?}"
17329 );
17330
17331 // The session stays usable, and its next commit is not carrying any of
17332 // the refused batch.
17333 s.superblock.superblock_extension_address = good_ext;
17334 s.stage_created_dataset(&ObjectPathBuf::parse("/later"), {
17335 let mut db = crate::type_builders::DatasetBuilder::new("");
17336 db.with_i32_data(&[7]);
17337 db
17338 })
17339 .unwrap();
17340 s.commit()
17341 .expect("the session is usable after a failed commit");
17342
17343 // Release the session's exclusive OS lock before reading the file back;
17344 // those locks are mandatory on Windows.
17345 drop(s);
17346
17347 let f = crate::reader::File::open(&path).unwrap();
17348 assert_eq!(
17349 f.dataset("nums").unwrap().read_i32().unwrap(),
17350 vec![1, 2, 3],
17351 "the refused batch's value overwrite is in the file"
17352 );
17353 assert!(
17354 f.dataset("extra").is_err(),
17355 "/extra was correctly discarded"
17356 );
17357 assert_eq!(f.dataset("later").unwrap().read_i32().unwrap(), vec![7]);
17358 }
17359
17360 /// The same guarantee on the commit fast path, where a batch of nothing but
17361 /// same-length overwrites skips the header rebuild and the superblock flip
17362 /// entirely (issue #344).
17363 ///
17364 /// That path has no tail to fail in, so the only failure it can suffer is
17365 /// one of its own writes — which is also the one failure the rollback cannot
17366 /// fully repair, since putting the prior bytes back means writing to the
17367 /// address that just refused them. Both outcomes are pinned here: the
17368 /// overwrite that *did* land is put back, the one that could not be is
17369 /// reported as [`Error::CommitPartiallyApplied`] rather than as a plain I/O
17370 /// error, and the values in the file are exactly what those two statements
17371 /// predict.
17372 ///
17373 /// `TornWriteImage` supplies the failure at a byte range rather than at a
17374 /// call ordinal, so the test names the block it means (`victim`'s) instead
17375 /// of depending on how many writes a commit happens to issue.
17376 ///
17377 /// `kept` is staged first on purpose. Staged writes are applied in the order
17378 /// they were staged (`StagedEdits::writes` is a `Vec`), so it is the entry
17379 /// already in the journal when `victim` fails — and restoring it is what
17380 /// shows the rollback carrying on past a failure rather than giving up at
17381 /// the first one. Stage them the other way round and that half goes quiet.
17382 #[test]
17383 fn a_failed_fast_path_commit_puts_back_the_value_it_overwrote() {
17384 use crate::writer::FileBuilder;
17385 use tempfile::tempdir;
17386
17387 let dir = tempdir().unwrap();
17388 let path = dir.path().join("inplace_fast_path_torn.h5");
17389 let mut b = FileBuilder::new();
17390 b.create_dataset("kept").with_i32_data(&[1, 2, 3]);
17391 b.create_dataset("victim").with_i32_data(&[4, 5, 6]);
17392 b.write(&path).unwrap();
17393
17394 // The data block the fake device refuses, named rather than guessed.
17395 let victim_block = {
17396 let f = crate::reader::File::open(&path).unwrap();
17397 match f.dataset("victim").unwrap().layout().unwrap() {
17398 crate::Layout::Contiguous {
17399 address: Some(a),
17400 size,
17401 } => a..a + size,
17402 other => panic!("expected a contiguous victim: {other:?}"),
17403 }
17404 };
17405
17406 let mut s = WriteEngine::open_torn_writes(&path, victim_block).unwrap();
17407 for (name, data) in [("/kept", [9, 9, 9]), ("/victim", [8, 8, 8])] {
17408 s.stage_dataset_write(&ObjectPathBuf::parse(name), {
17409 let mut db = crate::type_builders::DatasetBuilder::new("");
17410 db.with_i32_data(&data);
17411 db
17412 })
17413 .unwrap();
17414 }
17415 let refused = s.commit();
17416 assert!(
17417 matches!(refused, Err(Error::CommitPartiallyApplied { .. })),
17418 "a rollback that could not run is not an ordinary refusal: {refused:?}"
17419 );
17420 drop(s);
17421
17422 let f = crate::reader::File::open(&path).unwrap();
17423 assert_eq!(
17424 f.dataset("kept").unwrap().read_i32().unwrap(),
17425 vec![1, 2, 3],
17426 "the overwrite the rollback could reach is put back"
17427 );
17428 assert_eq!(
17429 f.dataset("victim").unwrap().read_i32().unwrap(),
17430 vec![8, 8, 8],
17431 "the overwrite it could not reach is the value the error is about"
17432 );
17433 }
17434
17435 /// Past the superblock repoint the overwrite is part of what the commit
17436 /// *published*, so a failure after that point must leave it standing —
17437 /// the same boundary [`FreeSnapshot`] draws for the free lists (issue #344).
17438 ///
17439 /// The window is real rather than theoretical: `repoint_stored_references`
17440 /// runs after the repoint and writes into live objects. A reference to the
17441 /// root group is what makes it run on any commit at all, since every commit
17442 /// rebuilds the root; the fake device then fails that write, so the commit
17443 /// returns an error having already committed.
17444 ///
17445 /// What the file holds afterwards is the new tree — the value overwrite
17446 /// included — with one stale reference. That is not a state a rollback can
17447 /// improve on: the root the reference would have to be re-pointed at is
17448 /// already the live one.
17449 #[test]
17450 fn a_commit_that_fails_past_its_repoint_keeps_the_value_it_published() {
17451 use crate::writer::FileBuilder;
17452 use tempfile::tempdir;
17453
17454 let dir = tempdir().unwrap();
17455 let path = dir.path().join("inplace_after_repoint.h5");
17456 let mut b = FileBuilder::new();
17457 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17458 b.create_dataset("refs").with_path_references(&[""]);
17459 b.write(&path).unwrap();
17460
17461 let refs_block = {
17462 let f = crate::reader::File::open(&path).unwrap();
17463 match f.dataset("refs").unwrap().layout().unwrap() {
17464 crate::Layout::Contiguous {
17465 address: Some(a),
17466 size,
17467 } => a..a + size,
17468 other => panic!("expected a contiguous reference dataset: {other:?}"),
17469 }
17470 };
17471
17472 let mut s = WriteEngine::open_torn_writes(&path, refs_block).unwrap();
17473 s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
17474 let mut db = crate::type_builders::DatasetBuilder::new("");
17475 db.with_i32_data(&[9, 9, 9]);
17476 db
17477 })
17478 .unwrap();
17479 // A second edit, so the batch takes the full commit path and reaches a
17480 // repoint at all.
17481 s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
17482 let mut db = crate::type_builders::DatasetBuilder::new("");
17483 db.with_i32_data(&[42]);
17484 db
17485 })
17486 .unwrap();
17487 let failed = s.commit();
17488 assert!(
17489 matches!(failed, Err(Error::Io(_))),
17490 "the reference write is what failed, after the commit published: {failed:?}"
17491 );
17492 assert!(
17493 s.publish_attempted,
17494 "the failure has to be past the publish for this test to mean anything"
17495 );
17496 drop(s);
17497
17498 let f = crate::reader::File::open(&path).unwrap();
17499 assert_eq!(
17500 f.dataset("nums").unwrap().read_i32().unwrap(),
17501 vec![9, 9, 9],
17502 "the published overwrite must not be rolled back under it"
17503 );
17504 assert_eq!(f.dataset("extra").unwrap().read_i32().unwrap(), vec![42]);
17505 }
17506
17507 #[test]
17508 fn append_inplace_crash_consistency_paged_prefix() {
17509 use crate::reader::File as PureFile;
17510 use tempfile::tempdir;
17511
17512 let dir = tempdir().unwrap();
17513 let base = dir.path().join("base.h5");
17514 let (start, target) = (131_000i32, 132_000i32);
17515 build_unit_chunked(&base, start);
17516
17517 for max_phase in 1u8..=4 {
17518 let p = dir.path().join(format!("crash_paged_{max_phase}.h5"));
17519 append_stopped_at(&base, &p, start..target, max_phase);
17520 let expected_len = if max_phase == 4 { target } else { start };
17521 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17522 assert_eq!(
17523 f.dataset("d").unwrap().read_i32().unwrap(),
17524 (0..expected_len).collect::<Vec<_>>(),
17525 "inconsistent paged view after crash at phase {max_phase}"
17526 );
17527 }
17528 }
17529
17530 /// After an append stopped at any phase, a reader sees the committed prefix
17531 /// and nothing of the rest, for both starting layouts: a partial trailing
17532 /// chunk and the EA-boundary growth above, which exercise different index
17533 /// writes.
17534 ///
17535 /// This reader is the lenient one. It bounds chunk reads by `min(EA count,
17536 /// dimension)`, so it tolerates a phase-3 state where the element count has
17537 /// advanced past the dimension, where the reference C library walks strictly
17538 /// by the dataspace dimension and re-validates block checksums. The C
17539 /// library read these states until it became a dependency of the crosscheck
17540 /// package alone; the phased append that produces them is private, so that
17541 /// coverage is gone, and a stale end-of-file, a half-grown index or a
17542 /// mis-checksummed block that this reader forgives is not caught here.
17543 ///
17544 /// Restores coverage lost with the deprecated `SwmrWriter` and `AppendWriter`
17545 /// (issue #202).
17546 #[test]
17547 fn append_inplace_crash_consistency_leaves_the_committed_prefix() {
17548 use tempfile::tempdir;
17549
17550 // (initial length, chunk length, appended length): a partial trailing
17551 // chunk, a chunk-aligned start, and the EA-boundary crossing.
17552 for (n, chunk, add) in [(6i32, 4u64, 5i32), (8, 2, 6), (50, 1, 200)] {
17553 let dir = tempdir().unwrap();
17554 let base = dir.path().join("base.h5");
17555 {
17556 use crate::writer::FileBuilder;
17557 let mut b = FileBuilder::new();
17558 b.create_dataset("d")
17559 .with_i32_data(&(0..n).collect::<Vec<i32>>())
17560 .with_shape(&[n as u64])
17561 .with_maxshape(&[MaxExtent::Unlimited])
17562 .with_chunks(&[chunk]);
17563 b.write(&base).unwrap();
17564 }
17565
17566 for max_phase in 1u8..=4 {
17567 let p = dir
17568 .path()
17569 .join(format!("crash_c_{n}_{chunk}_{max_phase}.h5"));
17570 append_stopped_at(&base, &p, n..n + add, max_phase);
17571 let expected_len = if max_phase == 4 { n + add } else { n };
17572 let pf = crate::reader::File::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17573 assert_eq!(
17574 pf.dataset("d").unwrap().read_i32().unwrap(),
17575 (0..expected_len).collect::<Vec<_>>(),
17576 "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk})"
17577 );
17578 }
17579 }
17580 }
17581
17582 /// Crash recovery across the phase-3/phase-4 gap.
17583 ///
17584 /// A writer that crashes after publishing the Extensible-Array element count
17585 /// (phase 3) but before publishing the dataspace dimension (phase 4) leaves
17586 /// the on-disk count ahead of the committed dimension. A fresh writer must
17587 /// roll forward from the *committed dimension*, overwriting the uncommitted
17588 /// slots, rather than appending past them and leaving a gap.
17589 ///
17590 /// The crashed and recovering appends deliberately write different values at
17591 /// the overlapping positions, so a regression that seeds the chunk count from
17592 /// the stale EA header surfaces the crashed writer's values rather than
17593 /// merely producing plausible-looking data.
17594 ///
17595 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
17596 /// surviving `recover_and_reappend_after_clean_phase4` covers only the clean
17597 /// case.
17598 #[test]
17599 fn append_inplace_recover_and_reappend_after_phase3_crash() {
17600 use crate::reader::File as PureFile;
17601 use tempfile::tempdir;
17602
17603 let dir = tempdir().unwrap();
17604 let path = dir.path().join("phase3_recover.h5");
17605 let n = 50i32;
17606 build_unit_chunked(&path, n);
17607
17608 // Writer 1 crashes after phase 3: the element count advances but the
17609 // dimension stays at `n`. Its values are far from the correct
17610 // continuation, so a leak is unmistakable.
17611 {
17612 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17613 s.append_inplace_i32_phased("d", &(1000..1200).collect::<Vec<_>>(), 3)
17614 .unwrap();
17615 }
17616 let committed: Vec<i32> = (0..n).collect();
17617 let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17618 assert_eq!(
17619 pf.dataset("d").unwrap().read_i32().unwrap(),
17620 committed,
17621 "phase-3 crash exposed uncommitted data to the pure reader"
17622 );
17623
17624 // Writer 2 recovers: roll forward from the committed dimension,
17625 // overwriting the uncommitted slots with the real continuation.
17626 {
17627 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17628 s.append_inplace_i32_phased("d", &(n..150).collect::<Vec<_>>(), 4)
17629 .unwrap();
17630 }
17631
17632 let expected: Vec<i32> = (0..150).collect();
17633 let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17634 assert_eq!(
17635 pf.dataset("d").unwrap().read_i32().unwrap(),
17636 expected,
17637 "recovery did not roll forward correctly (pure reader)"
17638 );
17639 }
17640
17641 #[test]
17642 fn raw_appendable_recurses_into_aggregates() {
17643 let f64_with = |byte_order| Datatype::FloatingPoint {
17644 size: 8,
17645 byte_order,
17646 layout: FloatingPointLayout {
17647 bit_offset: 0,
17648 bit_precision: 64,
17649 exponent_location: 52,
17650 exponent_size: 11,
17651 mantissa_location: 0,
17652 mantissa_size: 52,
17653 exponent_bias: 1023,
17654 },
17655 };
17656 let le_f64 = f64_with(DatatypeByteOrder::LittleEndian);
17657 let be_f64 = f64_with(DatatypeByteOrder::BigEndian);
17658
17659 // Little-endian scalar: appendable. Big-endian scalar: not.
17660 assert!(datatype_is_raw_appendable(&le_f64));
17661 assert!(!datatype_is_raw_appendable(&be_f64));
17662
17663 // The confirmed bug: a compound / array whose leaf is big-endian must be
17664 // refused (it was wrongly accepted before recursion was added).
17665 let be_member = Datatype::Compound {
17666 size: 8,
17667 members: vec![CompoundMember {
17668 name: "x".into(),
17669 byte_offset: 0,
17670 datatype: be_f64.clone(),
17671 }],
17672 };
17673 assert!(!datatype_is_raw_appendable(&be_member));
17674 let le_member = Datatype::Compound {
17675 size: 8,
17676 members: vec![CompoundMember {
17677 name: "x".into(),
17678 byte_offset: 0,
17679 datatype: le_f64.clone(),
17680 }],
17681 };
17682 assert!(datatype_is_raw_appendable(&le_member));
17683 assert!(!datatype_is_raw_appendable(&Datatype::Array {
17684 base_type: Box::new(be_f64.clone()),
17685 dimensions: vec![4],
17686 }));
17687
17688 // Variable-length / reference leaves are never raw-appendable, even LE.
17689 assert!(!datatype_is_raw_appendable(&Datatype::VariableLength {
17690 is_string: false,
17691 padding: None,
17692 charset: None,
17693 base_type: Box::new(le_f64.clone()),
17694 }));
17695 assert!(!datatype_is_raw_appendable(&Datatype::Reference {
17696 size: 8,
17697 ref_type: crate::datatype::ReferenceType::Object,
17698 }));
17699 }
17700
17701 #[test]
17702 fn fresh_group_region_pairs_link_info_with_group_info() {
17703 // A new-style group must carry both a Link Info and a Group Info message
17704 // (the C library requires the pair before it will insert a link).
17705 let types = region_types(&fresh_group_region());
17706 assert_eq!(types, vec![MessageType::LINK_INFO, MessageType::GROUP_INFO]);
17707 }
17708
17709 #[test]
17710 fn ensure_group_info_appends_when_missing() {
17711 // A region with a Link Info message but no Group Info message (how older
17712 // hdf5-pure releases wrote groups) gains exactly one Group Info message.
17713 let li_body = {
17714 let mut b = vec![0u8, 0];
17715 b.extend_from_slice(&u64::MAX.to_le_bytes());
17716 b.extend_from_slice(&u64::MAX.to_le_bytes());
17717 b
17718 };
17719 let mut region = plain_region(message_record(MessageType::LINK_INFO, &li_body));
17720 ensure_group_info(&mut region).unwrap();
17721 assert_eq!(
17722 region_types(®ion),
17723 vec![MessageType::LINK_INFO, MessageType::GROUP_INFO]
17724 );
17725
17726 // The appended message decodes as a minimal Group Info body.
17727 let mut p = 0;
17728 while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17729 if mt == MessageType::GROUP_INFO {
17730 assert_eq!(®ion[body..end], &GROUP_INFO_BODY);
17731 }
17732 p = end;
17733 }
17734 }
17735
17736 /// A group header region that tracks link creation order: a Link Info
17737 /// message recording `max` as the next index it would hand out (with the
17738 /// creation-order *index* declared when `indexed`, as h5py's
17739 /// `track_order=True` does), a Group Info message, and one compact Link
17740 /// message per name in `links`, numbered from zero.
17741 fn link_tracked_region(max: u64, indexed: bool, links: &[&str]) -> OhRegion {
17742 let mut li = vec![0u8, if indexed { 0x03 } else { 0x01 }];
17743 li.extend_from_slice(&max.to_le_bytes());
17744 li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap: compact
17745 li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree name index
17746 if indexed {
17747 li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree creation order
17748 }
17749 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17750 region.push(MessageType::LINK_INFO, &li);
17751 region.push(MessageType::GROUP_INFO, &GROUP_INFO_BODY);
17752 for (i, name) in links.iter().enumerate() {
17753 region.push_link(name, StoredAddress::new(0x100 + i as u64), Some(i as u64));
17754 }
17755 region
17756 }
17757
17758 /// Every Link message in `region`, as `(name, creation index)`.
17759 fn region_links(region: &OhRegion) -> Vec<(String, Option<u64>)> {
17760 let mut out = Vec::new();
17761 let mut p = 0;
17762 while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17763 if mt == MessageType::LINK {
17764 let link = LinkMessage::parse(®ion[body..end], OFFSET_SIZE).unwrap();
17765 out.push((link.name, link.creation_order));
17766 }
17767 p = end;
17768 }
17769 out
17770 }
17771
17772 /// The maximum creation index `region`'s Link Info message records.
17773 fn recorded_link_max(region: &OhRegion) -> Option<u64> {
17774 find_link_info(region)
17775 .unwrap()
17776 .and_then(|(_, _, info)| info.max_creation_order)
17777 }
17778
17779 #[test]
17780 fn links_added_to_a_tracked_group_take_consecutive_creation_indexes() {
17781 // A group that has handed out three indexes numbers the next two links
17782 // 3 and 4, in the order they are placed, and its Link Info message ends
17783 // up recording 5 — the next index, not the highest in use.
17784 let mut region = link_tracked_region(3, false, &["a", "b", "c"]);
17785 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17786 region.push_link("d", StoredAddress::new(0x200), order.take().unwrap());
17787 region.push_link("e", StoredAddress::new(0x300), order.take().unwrap());
17788 order.record(&mut region).unwrap();
17789
17790 assert_eq!(
17791 region_links(®ion),
17792 vec![
17793 ("a".to_string(), Some(0)),
17794 ("b".to_string(), Some(1)),
17795 ("c".to_string(), Some(2)),
17796 ("d".to_string(), Some(3)),
17797 ("e".to_string(), Some(4)),
17798 ],
17799 );
17800 assert_eq!(recorded_link_max(®ion), Some(5));
17801 }
17802
17803 #[test]
17804 fn a_tracked_group_resumes_past_a_gap_a_deletion_left() {
17805 // Two links of three deleted, so the surviving link is index 1 and the
17806 // recorded maximum still says 3: the addition takes 3, not one of the
17807 // indexes the deletions freed.
17808 let region = link_tracked_region(3, false, &["a", "b", "c"]);
17809 let mut region = remove_link_from_region(®ion, "a").unwrap();
17810 region = remove_link_from_region(®ion, "c").unwrap();
17811 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17812 region.push_link("d", StoredAddress::new(0x200), order.take().unwrap());
17813 order.record(&mut region).unwrap();
17814
17815 assert_eq!(
17816 region_links(®ion),
17817 vec![("b".to_string(), Some(1)), ("d".to_string(), Some(3))],
17818 );
17819 assert_eq!(recorded_link_max(®ion), Some(4));
17820 }
17821
17822 #[test]
17823 fn a_creation_order_index_shifts_no_field_the_maximum_is_written_into() {
17824 // A group that also declares the creation-order *index* (h5py's
17825 // `track_order=True`) has a longer Link Info body with a third address
17826 // in it. The maximum still precedes every address, so the patch lands on
17827 // it and leaves the three addresses undefined.
17828 let mut region = link_tracked_region(1, true, &["a"]);
17829 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17830 region.push_link("b", StoredAddress::new(0x200), order.take().unwrap());
17831 order.record(&mut region).unwrap();
17832
17833 let (_, _, info) = find_link_info(®ion).unwrap().unwrap();
17834 assert_eq!(info.max_creation_order, Some(2));
17835 assert_eq!(info.fractal_heap_address, None);
17836 assert_eq!(info.btree_name_index_address, None);
17837 assert_eq!(info.btree_creation_order_address, None);
17838 assert_eq!(region_links(®ion).last().unwrap().1, Some(1));
17839 }
17840
17841 #[test]
17842 fn an_untracked_group_numbers_none_of_its_links() {
17843 // The groups this crate writes itself: no maximum recorded, so no link
17844 // carries a creation index and the Link Info message is left as it was.
17845 let mut region = fresh_group_region();
17846 let before = region.clone();
17847 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17848 assert_eq!(order.take().unwrap(), None);
17849 region.push_link("d", StoredAddress::new(0x200), None);
17850 order.record(&mut region).unwrap();
17851
17852 assert_eq!(region_links(®ion), vec![("d".to_string(), None)]);
17853 assert_eq!(recorded_link_max(®ion), None);
17854 // Only the appended link is new; every message that was there is
17855 // untouched.
17856 assert_eq!(®ion[..before.len()], &*before);
17857 }
17858
17859 #[test]
17860 fn a_tracked_group_that_gains_no_link_keeps_its_maximum_byte_for_byte() {
17861 // A commit that only deletes, or only edits attributes, must not bump
17862 // the counter — the gap it leaves is never handed out again.
17863 let region = link_tracked_region(3, false, &["a", "b", "c"]);
17864 let mut after = remove_link_from_region(®ion, "b").unwrap();
17865 let order = LinkCreationOrder::for_region(&after).unwrap();
17866 let before = after.clone();
17867 order.record(&mut after).unwrap();
17868 assert_eq!(*after, *before);
17869 assert_eq!(recorded_link_max(&after), Some(3));
17870 }
17871
17872 #[test]
17873 fn max_compact_links_reads_the_declared_threshold_or_the_library_default() {
17874 // A minimal Group Info message stores no link-phase-change values, so
17875 // the C library's default of 8 applies.
17876 assert_eq!(max_compact_links(&fresh_group_region()).unwrap(), 8);
17877
17878 // One that stores them (flags bit 0) declares its own maximum compact
17879 // value in the two bytes after the flags.
17880 let mut gi = vec![0u8, 0x01];
17881 gi.extend_from_slice(&4u16.to_le_bytes()); // max compact
17882 gi.extend_from_slice(&2u16.to_le_bytes()); // min dense
17883 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17884 region.push(MessageType::GROUP_INFO, &gi);
17885 assert_eq!(max_compact_links(®ion).unwrap(), 4);
17886 }
17887
17888 #[test]
17889 fn a_tracked_group_is_refused_only_where_the_addition_would_go_dense() {
17890 // Eight links is the default threshold, so a group holding seven takes
17891 // one more and is refused the second.
17892 let region = link_tracked_region(7, false, &["a", "b", "c", "d", "e", "f", "g"]);
17893 reject_dense_link_creation_order(®ion, 8).expect("eight links still store compactly");
17894 let err = reject_dense_link_creation_order(®ion, 9).unwrap_err();
17895 assert!(
17896 matches!(&err, Error::EditUnsupported(m) if m.contains("link creation order")
17897 && m.contains("dense")),
17898 "got: {err}",
17899 );
17900
17901 // A group that does not track the order is not screened at all: this
17902 // crate writes more than eight compact links routinely.
17903 let mut untracked = fresh_group_region();
17904 untracked.push_link("a", StoredAddress::new(0x100), None);
17905 reject_dense_link_creation_order(&untracked, 99)
17906 .expect("an untracked group has no creation order to lose");
17907 }
17908
17909 #[test]
17910 fn ensure_group_info_is_idempotent() {
17911 // A region that already has a Group Info message is left untouched, so
17912 // re-editing a healed (or C-written) group does not duplicate it.
17913 let mut region = fresh_group_region();
17914 let before = region.clone();
17915 ensure_group_info(&mut region).unwrap();
17916 assert_eq!(*region, *before);
17917 }
17918
17919 // ---- shared (SOHM) messages (issue #417) ----
17920
17921 /// A shared-message index record naming an object header is a stored address
17922 /// like any other, and the one no repoint walk reaches: it lives outside
17923 /// every object a commit rebuilds. A commit that removes or moves that header
17924 /// is refused rather than leaving the index naming bytes that have gone.
17925 #[test]
17926 fn a_commit_that_strands_a_shared_message_record_is_refused() {
17927 let named = crate::sohm::SohmRecord {
17928 hash: 1,
17929 location: crate::sohm::SohmLocation::ObjectHeader {
17930 message_type: MessageType::ATTRIBUTE.to_u16() as u8,
17931 creation_index: 0,
17932 address: StoredAddress::new(0x400),
17933 },
17934 };
17935 let moved = InvalidatedAddresses {
17936 removed: Vec::new(),
17937 moved: vec![0x400],
17938 base: BaseAddress::ZERO,
17939 };
17940 let err = screen_shared_message_records(std::slice::from_ref(&named), &moved).unwrap_err();
17941 assert_eq!(
17942 err.to_string(),
17943 Error::EditUnsupported(SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT).to_string()
17944 );
17945
17946 let elsewhere = InvalidatedAddresses {
17947 removed: Vec::new(),
17948 moved: vec![0x800],
17949 base: BaseAddress::ZERO,
17950 };
17951 screen_shared_message_records(std::slice::from_ref(&named), &elsewhere).unwrap();
17952 }
17953
17954 /// A heap-stored record names no object header, so no address of it can go
17955 /// stale and the screen passes it. What a commit can spoil about one is its
17956 /// reference count, which the attribute paths refuse to change instead.
17957 #[test]
17958 fn a_heap_stored_shared_message_record_is_not_screened_for_addresses() {
17959 let heap = crate::sohm::SohmRecord {
17960 hash: 1,
17961 location: crate::sohm::SohmLocation::Heap {
17962 reference_count: 2,
17963 // The same eight bytes the address screen would refuse if it read
17964 // them as one.
17965 heap_id: 0x400u64.to_le_bytes(),
17966 },
17967 };
17968 let moved = InvalidatedAddresses {
17969 removed: Vec::new(),
17970 moved: vec![0x400],
17971 base: BaseAddress::ZERO,
17972 };
17973 screen_shared_message_records(&[heap], &moved).unwrap();
17974 }
17975
17976 /// A version 1 reference to a committed object becomes the version 2 form
17977 /// this crate writes everywhere else, naming the same address: the whole
17978 /// point of rewrapping is that the message still resolves to one copy.
17979 #[test]
17980 fn a_version_1_reference_is_rewrapped_as_the_modern_committed_form() {
17981 let mut v1 = vec![1u8, 0];
17982 v1.extend_from_slice(&[0u8; 6]); // reserved
17983 v1.extend_from_slice(&0x1111u64.to_le_bytes()); // local heap address
17984 v1.extend_from_slice(&0x5678u64.to_le_bytes()); // object header address
17985
17986 let modern = modernize_shared_reference(&v1, OFFSET_SIZE, LENGTH_SIZE).unwrap();
17987 assert_eq!(modern[0], 2, "the version this crate writes");
17988 assert_eq!(
17989 crate::shared_message::parse_shared_ref(&modern, OFFSET_SIZE, LENGTH_SIZE)
17990 .unwrap()
17991 .location,
17992 crate::shared_message::SharedLocation::ObjectHeader(StoredAddress::new(0x5678))
17993 );
17994 }
17995
17996 /// A reference into the shared-message heap keeps its heap ID and the only
17997 /// version that encodes one. Re-encoding it as a committed reference would
17998 /// turn eight bytes of heap ID into a file address.
17999 #[test]
18000 fn a_heap_reference_is_rewrapped_as_a_heap_reference() {
18001 let mut v3 = vec![3u8, 1];
18002 v3.extend_from_slice(&[9, 8, 7, 6, 5, 4, 3, 2]);
18003
18004 let modern = modernize_shared_reference(&v3, OFFSET_SIZE, LENGTH_SIZE).unwrap();
18005 assert_eq!(modern, v3);
18006 }
18007
18008 /// The rewrapped record keeps the shared flag. Without it every reader
18009 /// decodes the reference as the attribute it stands for.
18010 #[test]
18011 fn a_rewrapped_shared_message_keeps_its_flag() {
18012 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
18013 region.push_shared(MessageType::ATTRIBUTE, &[3, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
18014 assert_eq!(region[3], MessageFlags::SHARED.get());
18015 assert!(region_has_shared_attr(®ion).unwrap());
18016 }
18017
18018 #[test]
18019 fn reject_foreign_addresses_refuses_any_shared_message() {
18020 // A shared (SOHM) message of *any* type — here a Dataspace — stores a
18021 // source-file reference in place of its body, so a verbatim cross-file
18022 // copy must refuse it, not only shared datatypes/attributes. (A plain,
18023 // non-shared dataspace embeds no foreign address and is accepted.)
18024 let mut shared = message_record(MessageType::DATASPACE, &[0u8; 8]);
18025 shared[3] = MessageFlags::SHARED.get(); // set the message's shared flag
18026 let err = reject_foreign_addresses(&plain_region(shared)).unwrap_err();
18027 assert!(err.to_string().contains("shared"), "got: {err}");
18028
18029 let plain = message_record(MessageType::DATASPACE, &[0u8; 8]);
18030 reject_foreign_addresses(&plain_region(plain)).unwrap();
18031 }
18032
18033 // ---- version 2 object-header record layout (issue #416) ----
18034
18035 /// The layout of a header that tracks *and* indexes attribute creation
18036 /// order: object-header flag bits 2 and 3, and 6-byte message records.
18037 const TRACKED: OhRecordLayout = OhRecordLayout::from_header_flags(0x0C);
18038
18039 /// One compact Attribute message body for `name`.
18040 fn attr_body(name: &str, value: i64) -> Vec<u8> {
18041 crate::type_builders::build_attr_message(name, &AttrValue::I64(value))
18042 .serialize(LENGTH_SIZE)
18043 }
18044
18045 /// [`attr_region_in`] for a header carrying neither optional prefix block.
18046 fn attr_region(layout: OhRecordLayout, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18047 attr_region_in(OhHeaderProps::with_layout(layout), attrs, max)
18048 }
18049
18050 /// A region belonging to a header with `props`, holding one Attribute message
18051 /// per `(name, creation index)`, preceded by the Attribute Info message a
18052 /// tracked object carries.
18053 fn attr_region_in(props: OhHeaderProps, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18054 let layout = props.layout;
18055 let mut region = OhRegion::empty(props);
18056 let info = AttributeInfoMessage {
18057 max_creation_index: layout.tracks_creation_order().then_some(max),
18058 indexes_creation_order: layout.indexes_creation_order(),
18059 fractal_heap_address: None,
18060 btree_name_index_address: None,
18061 btree_creation_order_address: None,
18062 };
18063 let mut record = layout.record(MessageType::ATTRIBUTE_INFO, &info.serialize(OFFSET_SIZE));
18064 // The reference C library sets this flag on the Attribute Info message it writes
18065 // (`H5Oattribute.c`, HDF5 2.2.0), so a rewrite of that message has to keep it.
18066 record[3] = MessageFlags::FORBID_SHARING.get();
18067 region.push_bytes(&record);
18068 for (name, index) in attrs {
18069 let record = layout.record_with_creation_index(
18070 MessageType::ATTRIBUTE,
18071 &attr_body(name, 1),
18072 *index,
18073 );
18074 region.push_bytes(&record);
18075 }
18076 region
18077 }
18078
18079 /// The attribute names a region carries, in the order its records hold them,
18080 /// each with the creation index its record declares.
18081 fn walk_attrs(region: &OhRegion) -> Vec<(String, Option<u16>)> {
18082 let mut out = Vec::new();
18083 let mut p = 0;
18084 while let Some((msg_type, body, body_end)) = region.next_message(p).unwrap() {
18085 if msg_type == MessageType::ATTRIBUTE {
18086 let name = parse_compact_attr_name(region, p, body, body_end).unwrap();
18087 out.push((name, region.creation_index(p)));
18088 }
18089 p = body_end;
18090 }
18091 out
18092 }
18093
18094 /// The message walk steps by the width the header's flags declare: four
18095 /// bytes of record prefix without creation-order tracking, six with it.
18096 #[test]
18097 fn a_record_walk_steps_by_the_width_the_header_declares() {
18098 for layout in [OhRecordLayout::PLAIN, TRACKED] {
18099 let region = attr_region(layout, &[("alpha", 0), ("beta", 7)], 8);
18100 assert_eq!(
18101 walk_attrs(®ion),
18102 vec![
18103 (
18104 "alpha".to_string(),
18105 layout.tracks_creation_order().then_some(0)
18106 ),
18107 (
18108 "beta".to_string(),
18109 layout.tracks_creation_order().then_some(7)
18110 ),
18111 ],
18112 "layout {layout:?}",
18113 );
18114 }
18115
18116 // Non-vacuity: the two widths are not interchangeable. Reading a tracked
18117 // region four bytes at a time misparses it from the first record on,
18118 // which is the defect this width exists to prevent.
18119 let tracked = attr_region(TRACKED, &[("alpha", 0), ("beta", 7)], 8);
18120 let misread = OhRegion::new(tracked.to_vec(), OhHeaderProps::PLAIN);
18121 let mut p = 0;
18122 let mut agreed = true;
18123 while let Ok(Some((_, _, body_end))) = misread.next_message(p) {
18124 p = body_end;
18125 }
18126 agreed &= p == tracked.len();
18127 assert!(
18128 !agreed,
18129 "a four-byte walk of a six-byte-record region must not come out even"
18130 );
18131 }
18132
18133 /// An emitted record carries the creation index only where the layout has
18134 /// the field, and the flags byte stays where every reader looks for it.
18135 #[test]
18136 fn an_emitted_record_carries_a_creation_index_only_where_the_header_tracks_one() {
18137 let body = [0xABu8; 5];
18138 let plain =
18139 OhRecordLayout::PLAIN.record_with_creation_index(MessageType::ATTRIBUTE, &body, 9);
18140 assert_eq!(plain.len(), 4 + body.len());
18141 assert_eq!(&plain[4..], &body);
18142
18143 let tracked = TRACKED.record_with_creation_index(MessageType::ATTRIBUTE, &body, 0x1234);
18144 assert_eq!(tracked.len(), 6 + body.len());
18145 assert_eq!(
18146 &tracked[..4],
18147 &plain[..4],
18148 "type, size and flags are shared"
18149 );
18150 assert_eq!(&tracked[4..6], &0x1234u16.to_le_bytes());
18151 assert_eq!(&tracked[6..], &body);
18152
18153 // Every non-attribute message the reference C library writes carries a
18154 // zero creation index, which is what the plain emitter passes.
18155 let group_info = TRACKED.record(MessageType::GROUP_INFO, &body);
18156 assert_eq!(&group_info[4..6], &0u16.to_le_bytes());
18157 }
18158
18159 /// A rebuilt header declares the record layout its bytes are written in, so
18160 /// re-reading it walks them the same way.
18161 #[test]
18162 fn a_rebuilt_header_declares_the_record_layout_it_used() {
18163 for layout in [OhRecordLayout::PLAIN, TRACKED] {
18164 let region = attr_region(layout, &[("alpha", 3)], 4);
18165 let header = build_v2_object_header(®ion).unwrap();
18166 let (start, end, read_back) =
18167 oh_region_at(&header, 0, header.len() as u64).expect("the header parses");
18168 assert_eq!(
18169 read_back,
18170 OhHeaderProps::with_layout(layout),
18171 "the flags lost the layout"
18172 );
18173 let round_tripped =
18174 OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18175 assert_eq!(
18176 walk_attrs(&round_tripped),
18177 walk_attrs(®ion),
18178 "layout {layout:?}",
18179 );
18180 }
18181 }
18182
18183 // ---- the optional prefix blocks of a version 2 object header (PR #422) ----
18184
18185 /// Timestamps distinct enough that a field read at the wrong offset — or one
18186 /// copied from a neighbour — shows up as a different number.
18187 const FIXTURE_TIMES: ObjectTimes = ObjectTimes {
18188 access: 0x1111_1111,
18189 modification: 0x2222_2222,
18190 change: 0x3333_3333,
18191 birth: 0x4444_4444,
18192 };
18193
18194 /// A phase-change pair the reference C library would never write by default
18195 /// (its defaults are 8 and 6), so a rebuild that dropped the block and a
18196 /// rebuild that substituted the defaults both fail.
18197 const FIXTURE_PHASE: AttrPhaseChange = AttrPhaseChange {
18198 max_compact: 32,
18199 min_dense: 24,
18200 };
18201
18202 /// Hand-assemble the version 2 object header `props` describes around
18203 /// `region`'s bytes, with a 1-byte chunk-0 size field.
18204 ///
18205 /// Written out here rather than taken from [`build_v2_object_header`] so the
18206 /// parse is checked against bytes the emitter did not produce: an emitter and
18207 /// a parser that agreed on a wrong prefix layout would round-trip perfectly.
18208 fn v2_header_bytes(props: OhHeaderProps, region: &OhRegion) -> Vec<u8> {
18209 let mut buf = Vec::new();
18210 buf.extend_from_slice(b"OHDR");
18211 buf.push(2); // version
18212 buf.push(props.header_flags()); // size flags 0: a 1-byte length field
18213 if let Some(times) = props.times {
18214 buf.extend_from_slice(×.to_bytes());
18215 }
18216 if let Some(phase) = props.attr_phase_change {
18217 buf.extend_from_slice(&phase.to_bytes());
18218 }
18219 buf.push(u8::try_from(region.len()).expect("the fixture region is under 256 bytes"));
18220 buf.extend_from_slice(region);
18221 let checksum = jenkins_lookup3(&buf);
18222 buf.extend_from_slice(&checksum.to_le_bytes());
18223 buf
18224 }
18225
18226 /// Every combination of the two optional prefix blocks and the record layout:
18227 /// the parse finds the message region, and a rebuild puts both blocks back
18228 /// with the flag bits that announce them.
18229 ///
18230 /// A dropped block is not a lost field alone — the chunk-0 size field sits
18231 /// *after* both, so the message walk is checked too.
18232 #[test]
18233 fn a_headers_optional_prefix_blocks_survive_a_rebuild() {
18234 for layout in [OhRecordLayout::PLAIN, TRACKED] {
18235 for times in [None, Some(FIXTURE_TIMES)] {
18236 for attr_phase_change in [None, Some(FIXTURE_PHASE)] {
18237 let props = OhHeaderProps {
18238 layout,
18239 times,
18240 attr_phase_change,
18241 };
18242 let region = attr_region_in(props, &[("alpha", 3), ("beta", 5)], 6);
18243 let header = v2_header_bytes(props, ®ion);
18244
18245 let (start, end, read_back) = oh_region_at(&header, 0, header.len() as u64)
18246 .expect("the hand-built header parses");
18247 assert_eq!(read_back, props, "the prefix lost a block");
18248 let parsed =
18249 OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18250 assert_eq!(
18251 walk_attrs(&parsed),
18252 walk_attrs(®ion),
18253 "the message region was located wrongly for {props:?}",
18254 );
18255
18256 let rebuilt = build_v2_object_header(&parsed).unwrap();
18257 let (_, _, again) = oh_region_at(&rebuilt, 0, rebuilt.len() as u64)
18258 .expect("the rebuilt header parses");
18259 assert_eq!(
18260 again.attr_phase_change, attr_phase_change,
18261 "the rebuild lost the attribute phase-change thresholds",
18262 );
18263 assert_eq!(
18264 again.times.is_some(),
18265 times.is_some(),
18266 "the rebuild changed whether the header stores times",
18267 );
18268 assert_eq!(again.layout, layout, "the rebuild lost the record layout");
18269 }
18270 }
18271 }
18272 }
18273
18274 /// The flag bits the emitter sets are the ones the format assigns: bit 4 for
18275 /// the phase-change block, bit 5 for the timestamps.
18276 #[test]
18277 fn the_optional_blocks_are_announced_by_their_own_flag_bits() {
18278 let with_times = OhHeaderProps {
18279 times: Some(FIXTURE_TIMES),
18280 ..OhHeaderProps::PLAIN
18281 };
18282 let with_phase = OhHeaderProps {
18283 attr_phase_change: Some(FIXTURE_PHASE),
18284 ..OhHeaderProps::PLAIN
18285 };
18286 assert_eq!(OhHeaderProps::PLAIN.header_flags(), 0);
18287 assert_eq!(with_times.header_flags(), 0x20);
18288 assert_eq!(with_phase.header_flags(), 0x10);
18289 assert_eq!(OhHeaderProps::PLAIN.optional_len(), 0);
18290 assert_eq!(with_times.optional_len(), 16);
18291 assert_eq!(with_phase.optional_len(), 4);
18292 }
18293
18294 /// A rebuild of a header that stores times moves the modification and change
18295 /// times to now and leaves the access and birth times alone.
18296 #[cfg(feature = "std")]
18297 #[test]
18298 fn a_rebuild_stamps_the_modification_and_change_times() {
18299 let props = OhHeaderProps {
18300 times: Some(FIXTURE_TIMES),
18301 ..OhHeaderProps::PLAIN
18302 };
18303 let region = attr_region_in(props, &[("alpha", 0)], 1);
18304
18305 let before = unix_time_now().expect("a std test build reads the clock");
18306 let header = build_v2_object_header(®ion).unwrap();
18307 let after = unix_time_now().expect("a std test build reads the clock");
18308
18309 let (_, _, read_back) = oh_region_at(&header, 0, header.len() as u64).unwrap();
18310 let times = read_back.times.expect("the header still stores times");
18311 assert_eq!(
18312 (times.access, times.birth),
18313 (FIXTURE_TIMES.access, FIXTURE_TIMES.birth),
18314 "a rewrite is not an access and not a birth",
18315 );
18316 assert!(
18317 (before..=after).contains(×.modification),
18318 "the modification time {} is outside [{before}, {after}]",
18319 times.modification,
18320 );
18321 assert!(
18322 (before..=after).contains(×.change),
18323 "the change time {} is outside [{before}, {after}]",
18324 times.change,
18325 );
18326 }
18327
18328 /// A prefix that declares a block it does not carry is a malformed file, not
18329 /// a panic: the parse reads only what the buffer holds.
18330 #[test]
18331 fn a_prefix_truncated_inside_an_optional_block_is_refused() {
18332 let props = OhHeaderProps {
18333 times: Some(FIXTURE_TIMES),
18334 attr_phase_change: Some(FIXTURE_PHASE),
18335 ..OhHeaderProps::PLAIN
18336 };
18337 let region = attr_region_in(props, &[("alpha", 0)], 1);
18338 let header = v2_header_bytes(props, ®ion);
18339 // Every prefix length short of the chunk-0 size field, which is the last
18340 // thing the parse reads: 6 bytes of signature and flags, then 20 bytes of
18341 // optional blocks.
18342 for cut in 6..6 + 16 + 4 + 1 {
18343 assert!(
18344 oh_region_at(&header[..cut], 0, header.len() as u64).is_err(),
18345 "a {cut}-byte prefix must not parse as a whole header",
18346 );
18347 }
18348 }
18349
18350 /// A fresh attribute takes the object's next unused creation index, and the
18351 /// Attribute Info message records the one after it — the counter the
18352 /// reference C library hands out from.
18353 #[test]
18354 fn a_new_compact_attribute_takes_the_next_creation_index() {
18355 let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18356 let out = set_attr_in_region(®ion, "gamma", &AttrValue::I64(5)).unwrap();
18357 assert_eq!(
18358 walk_attrs(&out),
18359 vec![
18360 ("alpha".to_string(), Some(0)),
18361 ("beta".to_string(), Some(1)),
18362 ("gamma".to_string(), Some(2)),
18363 ],
18364 );
18365 let (record, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18366 assert_eq!(info.max_creation_index, Some(3));
18367 assert!(
18368 info.indexes_creation_order,
18369 "the index flag survives a rewrite"
18370 );
18371 assert_eq!(
18372 out[record + 3],
18373 MessageFlags::FORBID_SHARING.get(),
18374 "the rewritten record lost the message flags it carried"
18375 );
18376 }
18377
18378 /// Overwriting an attribute keeps the creation index it had, so an
18379 /// iteration by creation order does not reorder it.
18380 #[test]
18381 fn an_overwritten_compact_attribute_keeps_its_creation_index() {
18382 let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18383 assert_eq!(attr_creation_index(®ion, "alpha").unwrap(), Some(0));
18384 let out = set_attr_in_region(®ion, "alpha", &AttrValue::I64(9)).unwrap();
18385 // The rewritten message moves to the end of the region — the editor
18386 // rebuilds rather than patches — but its creation index does not move.
18387 assert_eq!(
18388 walk_attrs(&out),
18389 vec![
18390 ("beta".to_string(), Some(1)),
18391 ("alpha".to_string(), Some(0))
18392 ],
18393 );
18394 let (_, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18395 assert_eq!(
18396 info.max_creation_index,
18397 Some(2),
18398 "an overwrite must not advance the counter"
18399 );
18400 }
18401
18402 /// A deletion leaves a gap and does not lower the recorded maximum, so the
18403 /// next attribute created cannot land in the middle of the order.
18404 #[test]
18405 fn a_deletion_leaves_the_creation_index_counter_where_it_was() {
18406 let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1), ("gamma", 2)], 3);
18407 let after = remove_attr_from_region(®ion, "gamma", true).unwrap();
18408 assert_eq!(
18409 walk_attrs(&after),
18410 vec![
18411 ("alpha".to_string(), Some(0)),
18412 ("beta".to_string(), Some(1))
18413 ],
18414 );
18415 assert_eq!(
18416 next_attr_creation_index(&after).unwrap(),
18417 3,
18418 "the highest index still in use is 1, but the counter is 3"
18419 );
18420
18421 let out = put_attr_message(&after, "delta", &attr_body("delta", 1), None).unwrap();
18422 assert_eq!(attr_creation_index(&out, "delta").unwrap(), Some(3));
18423 }
18424
18425 /// A header that tracks the order but carries no Attribute Info message —
18426 /// which nothing this crate writes, and nothing the C library writes, but
18427 /// which the format permits — gets one recording a counter derived from the
18428 /// records themselves.
18429 #[test]
18430 fn a_tracked_header_without_an_info_message_gains_one() {
18431 let mut region = OhRegion::empty(OhHeaderProps::with_layout(TRACKED));
18432 for (name, index) in [("alpha", 0u16), ("beta", 4)] {
18433 let record = TRACKED.record_with_creation_index(
18434 MessageType::ATTRIBUTE,
18435 &attr_body(name, 1),
18436 index,
18437 );
18438 region.push_bytes(&record);
18439 }
18440 assert!(find_attribute_info(®ion).unwrap().is_none());
18441 ensure_attribute_info(&mut region).unwrap();
18442 let (_, _, info) = find_attribute_info(®ion)
18443 .unwrap()
18444 .expect("an info message");
18445 assert_eq!(info.max_creation_index, Some(5));
18446 assert!(info.indexes_creation_order);
18447 }
18448
18449 /// Build a compact data-layout message body: version, class=0, 2-byte inline
18450 /// size, then the data.
18451 fn compact_layout_body(version: u8, data: &[u8]) -> Vec<u8> {
18452 let mut b = vec![version, 0];
18453 b.extend_from_slice(&(data.len() as u16).to_le_bytes());
18454 b.extend_from_slice(data);
18455 b
18456 }
18457
18458 #[test]
18459 fn rebuild_compact_layout_replaces_inline_data_only() {
18460 // A region with a Dataspace message, a compact Data Layout, and a trailing
18461 // Attribute message: rewriting the inline data must replace exactly the
18462 // layout's bytes and leave every other message verbatim.
18463 let mut region = message_record(MessageType::DATASPACE, &[0xAB; 8]);
18464 region.extend_from_slice(&message_record(
18465 MessageType::DATA_LAYOUT,
18466 &compact_layout_body(3, &[1, 2, 3, 4]),
18467 ));
18468 region.extend_from_slice(&message_record(MessageType::ATTRIBUTE, &[0xCD; 5]));
18469
18470 let out = rebuild_compact_layout_region(&plain_region(region), &[9, 8, 7, 6]).unwrap();
18471
18472 // Same messages in the same order; only the layout's inline data changed.
18473 assert_eq!(
18474 region_types(&out),
18475 vec![
18476 MessageType::DATASPACE,
18477 MessageType::DATA_LAYOUT,
18478 MessageType::ATTRIBUTE,
18479 ]
18480 );
18481 let mut p = 0;
18482 while let Some((mt, body, end)) = out.next_message(p).unwrap() {
18483 match mt {
18484 MessageType::DATASPACE => assert_eq!(&out[body..end], &[0xAB; 8]),
18485 MessageType::DATA_LAYOUT => {
18486 assert_eq!(out[body], 3, "version preserved");
18487 assert_eq!(out[body + 1], 0, "still compact");
18488 let size = u16::from_le_bytes([out[body + 2], out[body + 3]]) as usize;
18489 assert_eq!(size, 4);
18490 assert_eq!(&out[body + 4..body + 4 + size], &[9, 8, 7, 6]);
18491 }
18492 MessageType::ATTRIBUTE => assert_eq!(&out[body..end], &[0xCD; 5]),
18493 other => panic!("unexpected message {other:?}"),
18494 }
18495 p = end;
18496 }
18497 }
18498
18499 #[test]
18500 fn rebuild_compact_layout_refuses_non_compact() {
18501 // A contiguous (class 1) data layout is not compact, so the rebuild refuses
18502 // rather than corrupt it.
18503 let mut region = message_record(MessageType::DATA_LAYOUT, &{
18504 let mut b = vec![3u8, 1]; // version 3, class 1 (contiguous)
18505 b.extend_from_slice(&0u64.to_le_bytes());
18506 b.extend_from_slice(&0u64.to_le_bytes());
18507 b
18508 });
18509 region.extend_from_slice(&message_record(MessageType::DATASPACE, &[0; 8]));
18510 let err = rebuild_compact_layout_region(&plain_region(region), &[1, 2]).unwrap_err();
18511 assert!(err.to_string().contains("non-compact"), "got: {err}");
18512 }
18513
18514 #[test]
18515 fn a_refused_open_never_builds_the_image() {
18516 // The image is what may cost `O(file size)` — the mirror reads the whole
18517 // file — so every refusal has to come first. Asserting on the build
18518 // closure states that directly; measuring memory or elapsed time would
18519 // only correlate with it.
18520 use crate::writer::FileBuilder;
18521
18522 let dir = tempfile::tempdir().unwrap();
18523
18524 // One refusal from each family: the superblock's status flags (issue
18525 // #245), and an unsupported superblock version, which has always been
18526 // refused after the read this reorders.
18527 let flagged = dir.path().join("flagged.h5");
18528 let mut b = FileBuilder::new();
18529 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18530 b.write(&flagged).unwrap();
18531 let ancient = dir.path().join("ancient.h5");
18532 std::fs::copy(&flagged, &ancient).unwrap();
18533 for (path, version, flags) in [(&flagged, 3, SWMR_WRITE_FLAGS), (&ancient, 9, 0)] {
18534 let mut data = std::fs::read(path).unwrap();
18535 let off = signature::find_signature(&data).unwrap();
18536 let mut sb = Superblock::parse(&data, off).unwrap();
18537 sb.version = version;
18538 sb.consistency_flags = flags;
18539 let bytes = sb.serialize();
18540 data[off..off + bytes.len()].copy_from_slice(&bytes);
18541 std::fs::write(path, &data).unwrap();
18542 }
18543
18544 for path in [&flagged, &ancient] {
18545 let built = std::cell::Cell::new(false);
18546 let err = match WriteEngine::open_imaged(path, Some(FileLocking::Enabled), |h, len| {
18547 built.set(true);
18548 Ok(Box::new(HandleImage::new(
18549 h,
18550 len,
18551 MetadataCacheConfig::disabled(),
18552 )))
18553 }) {
18554 Err(e) => e,
18555 Ok(_) => panic!("{} must be refused", path.display()),
18556 };
18557 assert!(
18558 !built.get(),
18559 "{} was refused with {err:?}, but the image was built first",
18560 path.display()
18561 );
18562 }
18563 }
18564
18565 #[test]
18566 fn a_stale_consistency_flag_is_refused_then_cleared_by_a_commit() {
18567 // A v3 file a crashed SWMR writer left flagged is refused by the editor
18568 // (issue #245) rather than edited under a writer the file still records.
18569 // On a v2 file, where the check is gated off to match the C library, the
18570 // editor opens — and the commit clears the stale flag rather than
18571 // re-emitting it, so the file stays properly closed for the C library
18572 // (issue #73).
18573 use crate::writer::FileBuilder;
18574
18575 let dir = tempfile::tempdir().unwrap();
18576 let path = dir.path().join("stale_flag.h5");
18577
18578 let mut b = FileBuilder::new();
18579 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18580 b.write(&path).unwrap();
18581
18582 // Simulate a crashed SWMR writer by stamping the on-disk write+SWMR flag
18583 // (0x05) into the superblock, recomputing its checksum.
18584 {
18585 let mut data = std::fs::read(&path).unwrap();
18586 let off = signature::find_signature(&data).unwrap();
18587 let mut sb = Superblock::parse(&data, off).unwrap();
18588 assert!(
18589 sb.version >= 2,
18590 "FileBuilder should emit a v2/v3 superblock"
18591 );
18592 sb.consistency_flags = 0x05;
18593 let bytes = sb.serialize();
18594 data[off..off + bytes.len()].copy_from_slice(&bytes);
18595 std::fs::write(&path, &data).unwrap();
18596 // Sanity: the stale flag is really set on disk now.
18597 assert_eq!(
18598 Superblock::parse(&data, off).unwrap().consistency_flags,
18599 0x05
18600 );
18601 }
18602
18603 // The editor refuses it while the flag stands.
18604 match WriteEngine::open_with_locking(&path, FileLocking::Enabled) {
18605 Err(Error::FileMarkedInUse(_)) => {}
18606 Err(e) => panic!("expected the flag refusal, got {e:?}"),
18607 Ok(_) => panic!("a flagged file must not be edited in place"),
18608 }
18609
18610 // The flag survives a *version-2* superblock, where the check is gated
18611 // off to match the C library — which is the one state that still carries
18612 // a stale flag into a commit, and so the one that keeps the healing below
18613 // load-bearing. (v2 and v3 superblocks share a byte layout, so restamping
18614 // the version is the whole difference.) A crashed C writer leaves plain
18615 // write access, without the SWMR bit.
18616 {
18617 let mut data = std::fs::read(&path).unwrap();
18618 let off = signature::find_signature(&data).unwrap();
18619 let mut sb = Superblock::parse(&data, off).unwrap();
18620 sb.version = 2;
18621 sb.consistency_flags = crate::file_lock::WRITE_ACCESS;
18622 let bytes = sb.serialize();
18623 data[off..off + bytes.len()].copy_from_slice(&bytes);
18624 std::fs::write(&path, &data).unwrap();
18625 }
18626
18627 // A clean edit-and-commit cycle heals it.
18628 {
18629 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18630 .expect("the gate skips a v2 superblock, so this opens");
18631 let mut b = DatasetBuilder::new("e");
18632 b.with_i32_data(&[4, 5]);
18633 s.stage_created_dataset(&ObjectPathBuf::parse("e"), b)
18634 .unwrap();
18635 s.commit().unwrap();
18636 }
18637
18638 let data = std::fs::read(&path).unwrap();
18639 let off = signature::find_signature(&data).unwrap();
18640 assert_eq!(
18641 Superblock::parse(&data, off).unwrap().consistency_flags,
18642 0,
18643 "commit must clear the stale consistency flag"
18644 );
18645 }
18646
18647 #[test]
18648 fn add_vlen_string_dataset_with_null_elements_via_edit_session() {
18649 // Regression test for a silent-corruption bug (issue #105): a
18650 // VL-string dataset added via the in-place edit engine used to commit `Ok(())`
18651 // without ever writing its global heap collection or patching its
18652 // placeholder references, so the dataset failed to read back. A null
18653 // element (no heap object at all, distinct from an empty string) must
18654 // stay untouched by the patch — only heap-backed elements'
18655 // placeholder addresses are resolved; exercising both keeps the mask
18656 // itself, not just the common all-`Bytes` case, under test.
18657 use crate::type_builders::VlStringElement;
18658 use crate::writer::FileBuilder;
18659
18660 let dir = tempfile::tempdir().unwrap();
18661 let path = dir.path().join("vlen_null.h5");
18662
18663 let mut b = FileBuilder::new();
18664 b.create_dataset("seed").with_i32_data(&[0]);
18665 b.write(&path).unwrap();
18666
18667 let datatype =
18668 crate::type_builders::make_vlen_string_type(crate::datatype::CharacterSet::Utf8);
18669 let elements = vec![
18670 VlStringElement::Bytes(b"alpha".to_vec()),
18671 VlStringElement::Null,
18672 VlStringElement::Bytes(b"gamma".to_vec()),
18673 ];
18674
18675 {
18676 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
18677 let mut b = DatasetBuilder::new("labels");
18678 b.with_vlen_string_elements(datatype, &elements).unwrap();
18679 s.stage_created_dataset(&ObjectPathBuf::parse("labels"), b)
18680 .unwrap();
18681 s.commit().unwrap();
18682 }
18683
18684 let file = crate::reader::File::open(&path).unwrap();
18685 let ds = file.dataset("labels").unwrap();
18686 assert_eq!(
18687 ds.read_string().unwrap(),
18688 vec!["alpha".to_string(), String::new(), "gamma".to_string()]
18689 );
18690 }
18691
18692 #[test]
18693 fn edit_session_root_group_base_address_overflow_is_rejected() {
18694 // The edit-path sibling of issue #137. A userblock file has a nonzero base
18695 // address that `WriteEngine::open` adds to the stored root-group address.
18696 // A crafted address of HADDR_UNDEF must be rejected rather than overflow
18697 // (panicking in debug, wrapping in release).
18698 use crate::writer::FileBuilder;
18699
18700 let dir = tempfile::tempdir().unwrap();
18701 let path = dir.path().join("edit_root_overflow.h5");
18702
18703 const UB: u64 = 512;
18704 let mut b = FileBuilder::new();
18705 b.with_userblock(UB);
18706 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18707 b.write(&path).unwrap();
18708
18709 // Rewrite the stored (base-relative) root-group address to HADDR_UNDEF,
18710 // recomputing the superblock checksum via `serialize`. The base address
18711 // still equals the superblock offset, so the file stays editable and the
18712 // editor reaches the `root_group_address + base` normalization.
18713 let mut data = std::fs::read(&path).unwrap();
18714 let off = signature::find_signature(&data).unwrap();
18715 let mut sb = Superblock::parse(&data, off).unwrap();
18716 assert_eq!(
18717 sb.base_address,
18718 BaseAddress::new(UB),
18719 "userblock file must have base == UB"
18720 );
18721 sb.root_group_address = u64::MAX;
18722 let bytes = sb.serialize();
18723 data[off..off + bytes.len()].copy_from_slice(&bytes);
18724 std::fs::write(&path, &data).unwrap();
18725
18726 let err = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18727 .err()
18728 .expect("open must fail");
18729 match err {
18730 Error::Format(FormatError::OffsetOverflow { offset, length }) => {
18731 assert_eq!(offset, u64::MAX);
18732 assert_eq!(length, UB);
18733 }
18734 other => panic!("expected root-group address overflow, got {other:?}"),
18735 }
18736 }
18737
18738 use tempfile::tempdir;
18739
18740 // -----------------------------------------------------------------------
18741 // Bounded sessions: the same engine over a `HandleImage`, which holds no
18742 // whole-file mirror. These came across from the standalone bounded engine
18743 // deleted in issue #198; what they cover is unchanged, but they now exercise
18744 // the shared code the mirror sessions use.
18745 // -----------------------------------------------------------------------
18746
18747 /// Build a rank-1 unlimited chunked i32 dataset `d` seeded with `0..n`.
18748 fn build_appendable(path: &Path, n: i32, chunk: u64) {
18749 let data: Vec<i32> = (0..n).collect();
18750 let mut b = crate::writer::FileBuilder::new();
18751 b.create_dataset("d")
18752 .with_i32_data(&data)
18753 .with_shape(&[n as u64])
18754 .with_maxshape(&[MaxExtent::Unlimited])
18755 .with_chunks(&[chunk]);
18756 b.write(path).unwrap();
18757 }
18758
18759 fn open_bounded_session(path: &Path) -> WriteEngine {
18760 WriteEngine::open_rw_with_strategy(
18761 path,
18762 crate::source::MetadataCacheConfig::disabled(),
18763 FileLocking::Enabled,
18764 MemoryStrategy::Bounded,
18765 )
18766 .unwrap()
18767 }
18768
18769 fn dataset_addr(engine: &WriteEngine) -> u64 {
18770 crate::group_v2::resolve_path_any_from_source(
18771 &engine.image(),
18772 AccessMode::ReadWrite,
18773 engine.superblock(),
18774 &ObjectPath::parse("d"),
18775 )
18776 .unwrap()
18777 }
18778
18779 /// Crash consistency on a bounded session: stop the append after only the
18780 /// first `max_phase` durability phases (simulating a crash at that boundary)
18781 /// and assert the reopened file reads either the old length (phases 1-3) or
18782 /// the new one (phase 4), never a torn view. Layouts cover a partial trailing
18783 /// chunk (relocated tail) and a chunk-aligned start.
18784 #[test]
18785 fn bounded_append_crash_consistency_partial_tail_prefix() {
18786 let dir = tempdir().unwrap();
18787 for (case, (n, chunk, add)) in [(0usize, (6i32, 4u64, 5i32)), (1, (8, 2, 6))] {
18788 let base = dir.path().join(std::format!("base_{case}.h5"));
18789 build_appendable(&base, n, chunk);
18790 for max_phase in 1u8..=4 {
18791 let p = dir.path().join(std::format!("crash_{case}_{max_phase}.h5"));
18792 std::fs::copy(&base, &p).unwrap();
18793 {
18794 let mut engine = open_bounded_session(&p);
18795 let addr = dataset_addr(&engine);
18796 let mut b = AppendBuilder::new();
18797 b.append_i32(&(n..n + add).collect::<Vec<_>>());
18798 engine
18799 .append_inplace_gathered(AppendTarget::Header(addr), &b, max_phase)
18800 .unwrap();
18801 // Dropping the engine simulates the crash: no further phases,
18802 // no close barrier.
18803 }
18804 let expected_len = if max_phase == 4 { n + add } else { n };
18805 let got = crate::File::open(&p)
18806 .unwrap()
18807 .dataset("d")
18808 .unwrap()
18809 .read_i32()
18810 .unwrap();
18811 assert_eq!(
18812 got,
18813 (0..expected_len).collect::<Vec<_>>(),
18814 "case {case} phase {max_phase}"
18815 );
18816 }
18817 }
18818 }
18819
18820 /// The batching loop only honors `max_phase < 4` on its first batch, and a
18821 /// full multi-batch append leaves every batch fully committed: after a large
18822 /// append the file reads the complete sequence.
18823 #[test]
18824 fn bounded_multi_batch_append_commits_every_batch() {
18825 let dir = tempdir().unwrap();
18826 let p = dir.path().join("multibatch.h5");
18827 build_appendable(&p, 5, 512);
18828 let total = 700_000i32;
18829 {
18830 let mut engine = open_bounded_session(&p);
18831 let addr = dataset_addr(&engine);
18832 let mut b = AppendBuilder::new();
18833 b.append_i32(&(5..total).collect::<Vec<_>>());
18834 engine
18835 .append_inplace_gathered(AppendTarget::Header(addr), &b, 4)
18836 .unwrap();
18837 }
18838 let got = crate::File::open(&p)
18839 .unwrap()
18840 .dataset("d")
18841 .unwrap()
18842 .read_i32()
18843 .unwrap();
18844 assert_eq!(got.len(), total as usize);
18845 assert!(got.iter().enumerate().all(|(i, &v)| v == i as i32));
18846 }
18847
18848 /// A bounded session batches; a mirror session does not. The distinction is
18849 /// a deliberate trade — bounded peak memory against whole-call crash
18850 /// atomicity — so it is asserted rather than left to the batching code's
18851 /// arithmetic.
18852 #[test]
18853 fn only_a_bounded_session_batches_a_large_append() {
18854 let dir = tempdir().unwrap();
18855 let p = dir.path().join("batching.h5");
18856 build_appendable(&p, 8, 4);
18857
18858 let bounded_batch = {
18859 let mut engine = open_bounded_session(&p);
18860 engine
18861 .append_geometry(AppendTarget::Path(&ObjectPathBuf::parse("d")))
18862 .unwrap()
18863 .full_batch_elems
18864 };
18865 let mirror_batch = {
18866 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
18867 engine
18868 .append_geometry(AppendTarget::Path(&ObjectPathBuf::parse("d")))
18869 .unwrap()
18870 .full_batch_elems
18871 };
18872
18873 assert_eq!(
18874 mirror_batch,
18875 u64::MAX,
18876 "a mirror session must take the whole append as one crash-atomic batch"
18877 );
18878 assert!(
18879 bounded_batch < u64::MAX,
18880 "a bounded session must cap a batch, got {bounded_batch}"
18881 );
18882 assert_eq!(
18883 bounded_batch % 4,
18884 0,
18885 "a batch must be a whole number of chunks"
18886 );
18887 }
18888
18889 /// A persisting file appended through a bounded session and dropped WITHOUT
18890 /// `finalize_persist` (the true-crash case) still reads back every durable
18891 /// append. Dropping the engine releases the exclusive lock, so the reopen is
18892 /// portable (no leaked lock). The finalize-at-close path is covered by the
18893 /// `tests/main/bounded_append.rs` integration tests.
18894 #[test]
18895 fn bounded_persist_append_without_finalize_is_readable() {
18896 let dir = tempdir().unwrap();
18897 let p = dir.path().join("persist_crash.h5");
18898 let mut b = crate::writer::FileBuilder::new();
18899 b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
18900 b.create_dataset("d")
18901 .with_i32_data(&(0..6).collect::<Vec<i32>>())
18902 .with_shape(&[6])
18903 .with_maxshape(&[MaxExtent::Unlimited])
18904 .with_chunks(&[4]);
18905 b.write(&p).unwrap();
18906 {
18907 let mut engine = open_bounded_session(&p);
18908 assert!(engine.persist.is_some(), "persist state is armed at open");
18909 let addr = dataset_addr(&engine);
18910 let mut ab = AppendBuilder::new();
18911 ab.append_i32(&(6..20).collect::<Vec<_>>());
18912 engine
18913 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18914 .unwrap();
18915 // Drop without finalizing: models a true crash and releases the lock.
18916 }
18917 let got = crate::File::open(&p)
18918 .unwrap()
18919 .dataset("d")
18920 .unwrap()
18921 .read_i32()
18922 .unwrap();
18923 assert_eq!(got, (0..20).collect::<Vec<_>>());
18924 }
18925
18926 /// A bounded session grows a PAGED persisting file and is killed before
18927 /// finalize (models a crash), leaving the file non-page-aligned. Reopening it
18928 /// must not panic, and the next append must re-align the crashed tail page
18929 /// before writing raw data (so no page mixes metadata and raw); a clean close
18930 /// then re-page-aligns the file and every row reads back.
18931 #[test]
18932 fn bounded_paged_reopen_after_crash_realigns_and_stays_readable() {
18933 let dir = tempdir().unwrap();
18934 let p = dir.path().join("paged_crash.h5");
18935 let mut b = crate::writer::FileBuilder::new();
18936 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
18937 .with_file_space_page_size(4096);
18938 b.create_dataset("d")
18939 .with_i32_data(&(0..64).collect::<Vec<i32>>())
18940 .with_shape(&[64])
18941 .with_maxshape(&[MaxExtent::Unlimited])
18942 .with_chunks(&[64]);
18943 b.write(&p).unwrap();
18944
18945 // Grow enough to force extensible-array index growth, so the last write of
18946 // the session is metadata and the tail page is a partial metadata page.
18947 {
18948 let mut engine = open_bounded_session(&p);
18949 let addr = dataset_addr(&engine);
18950 let mut ab = AppendBuilder::new();
18951 ab.append_i32(&(64..2000).collect::<Vec<_>>());
18952 engine
18953 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18954 .unwrap();
18955 // Drop without finalize: models a crash and releases the OS lock.
18956 }
18957 assert_ne!(
18958 std::fs::metadata(&p).unwrap().len() % 4096,
18959 0,
18960 "a crashed (un-finalized) paged session leaves the file non-page-aligned"
18961 );
18962
18963 // Reopen must not panic on the non-aligned file; the next append re-aligns
18964 // the crashed tail page, and finalize re-page-aligns the whole file.
18965 {
18966 let mut engine = open_bounded_session(&p);
18967 let addr = dataset_addr(&engine);
18968 let mut ab = AppendBuilder::new();
18969 ab.append_i32(&(2000..2500).collect::<Vec<_>>());
18970 engine
18971 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18972 .unwrap();
18973 engine.finalize_persist().unwrap();
18974 engine.barrier().unwrap();
18975 }
18976 assert_eq!(
18977 std::fs::metadata(&p).unwrap().len() % 4096,
18978 0,
18979 "reopen + append + finalize re-aligns the paged file"
18980 );
18981 let got = crate::File::open(&p)
18982 .unwrap()
18983 .dataset("d")
18984 .unwrap()
18985 .read_i32()
18986 .unwrap();
18987 assert_eq!(got, (0..2500).collect::<Vec<_>>());
18988 }
18989
18990 /// A staged commit on a bounded session must stay bounded: it may read the
18991 /// metadata it edits, but never the file's bulk. Measured rather than
18992 /// asserted from the design — the engine is shared with the mirror sessions
18993 /// now, and a single slice-taking read added anywhere on the commit path
18994 /// would silently make a bounded open cost as much as a mirrored one.
18995 #[test]
18996 fn a_bounded_commit_reads_far_less_than_the_file() {
18997 use std::sync::Arc;
18998 use std::sync::atomic::{AtomicU64, Ordering};
18999
19000 let dir = tempdir().unwrap();
19001 let p = dir.path().join("bulk.h5");
19002 // ~8 MiB of chunked data, so "reads the whole file" and "reads only the
19003 // metadata" differ by orders of magnitude rather than by a margin.
19004 let rows = 2_000_000i32;
19005 build_appendable(&p, rows, 8192);
19006 let file_len = std::fs::metadata(&p).unwrap().len();
19007 assert!(file_len > 4 << 20, "file is only {file_len} bytes");
19008
19009 let read_bytes = Arc::new(AtomicU64::new(0));
19010 {
19011 let mut engine =
19012 WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
19013 engine.create_group(&ObjectPathBuf::parse("g")).unwrap();
19014 engine.commit().unwrap();
19015 }
19016 let read = read_bytes.load(Ordering::Relaxed);
19017
19018 assert!(
19019 read > 0,
19020 "the commit read nothing, so the test proves nothing"
19021 );
19022 // Measured at 310 bytes here. The bound is loose enough to survive a
19023 // changed header layout and still orders of magnitude below the file.
19024 assert!(
19025 read < 64 << 10,
19026 "a bounded commit read {read} bytes of a {file_len}-byte file"
19027 );
19028 }
19029
19030 /// An in-place append leaves a partially-filled **raw** page, so the next
19031 /// commit's metadata must pad it rather than pack into it.
19032 ///
19033 /// This is what keeps [`PagedEdit::begin`] reachable from [`EditStore`] now
19034 /// that an append allocates raw pages only: the append's job is to record that
19035 /// the tail page turned raw, and the commit's job is to act on it. It is also
19036 /// the interleaving that a single session-level page tracker makes possible —
19037 /// with a tracker per engine, the commit path could not see what the append
19038 /// path had done, which is why the whole-file editor refused an in-place append
19039 /// to a paged file at all (issue #198).
19040 #[test]
19041 fn a_commit_after_an_append_pads_the_raw_page_the_append_left() {
19042 const PAGE: u64 = 4096;
19043 let dir = tempdir().unwrap();
19044 let p = dir.path().join("paged_interleave.h5");
19045 let mut b = crate::writer::FileBuilder::new();
19046 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19047 .with_file_space_page_size(PAGE);
19048 b.create_dataset("d")
19049 .with_i32_data(&(0..64).collect::<Vec<i32>>())
19050 .with_shape(&[64])
19051 .with_maxshape(&[MaxExtent::Unlimited])
19052 .with_chunks(&[64]);
19053 b.write(&p).unwrap();
19054
19055 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19056 let mut ab = AppendBuilder::new();
19057 ab.append_i32(&(64..2000).collect::<Vec<_>>());
19058 engine
19059 .append_inplace_gathered(AppendTarget::Path(&ObjectPathBuf::parse("d")), &ab, 4)
19060 .unwrap();
19061
19062 assert_eq!(
19063 engine.paged.as_ref().unwrap().last,
19064 Some(PageType::Raw),
19065 "the append must record that the tail page now holds raw data"
19066 );
19067 assert_ne!(
19068 engine.image.len() % PAGE,
19069 0,
19070 "the append must leave a partially-filled page for the commit to pad"
19071 );
19072
19073 engine.create_group(&ObjectPathBuf::parse("g")).unwrap();
19074 engine.commit().unwrap();
19075
19076 // The commit padded the raw tail before laying down metadata, and folded
19077 // that padding into the raw list (`meta_pad`/`raw_pad` are cleared into the
19078 // free lists as part of the paged tail).
19079 let pg = engine.paged.as_ref().expect("the file is paged");
19080 let raw_free = pg.raw.sections();
19081 assert!(
19082 !raw_free.is_empty(),
19083 "the commit packed metadata into the raw page the append left open"
19084 );
19085 for (addr, len) in raw_free {
19086 assert_eq!(
19087 (addr + len) % PAGE,
19088 0,
19089 "padding {addr}+{len} does not reach a page boundary"
19090 );
19091 }
19092
19093 drop(engine);
19094 assert_eq!(
19095 crate::File::open(&p)
19096 .unwrap()
19097 .dataset("d")
19098 .unwrap()
19099 .read_i32()
19100 .unwrap(),
19101 (0..2000).collect::<Vec<_>>()
19102 );
19103 }
19104
19105 /// An in-place append to a paged file must allocate **only raw pages** — the
19106 /// chunk data and the extensible-array blocks indexing it alike.
19107 ///
19108 /// The reclaim path (`chunked_storage_spans`) reports both halves of a chunked
19109 /// dataset as raw free space, because that is where this crate places them. An
19110 /// append that put its index blocks in a metadata page instead would make the
19111 /// reclaim advertise metadata-page bytes for raw reuse, mixing the page a paged
19112 /// file exists to keep homogeneous. Measured here rather than through the
19113 /// reference C library, which reads a mixed-page file without complaint: an
19114 /// interop test proves interop and says nothing about segregation.
19115 #[test]
19116 fn an_inplace_append_to_a_paged_file_allocates_only_raw_pages() {
19117 const PAGE: u64 = 4096;
19118 let dir = tempdir().unwrap();
19119 let p = dir.path().join("paged_raw.h5");
19120 let mut b = crate::writer::FileBuilder::new();
19121 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19122 .with_file_space_page_size(PAGE);
19123 b.create_dataset("d")
19124 .with_i32_data(&(0..64).collect::<Vec<i32>>())
19125 .with_shape(&[64])
19126 .with_maxshape(&[MaxExtent::Unlimited])
19127 .with_chunks(&[64]);
19128 b.write(&p).unwrap();
19129
19130 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19131 let before = engine.image().len();
19132 // Two appends, each large enough to grow the extensible-array index, so the
19133 // run allocates index blocks as well as chunk data.
19134 for range in [64..2000, 2000..4000] {
19135 let mut ab = AppendBuilder::new();
19136 ab.append_i32(&range.collect::<Vec<_>>());
19137 engine
19138 .append_inplace_gathered(AppendTarget::Path(&ObjectPathBuf::parse("d")), &ab, 4)
19139 .unwrap();
19140 }
19141
19142 let pg = engine.paged.as_ref().expect("the file is paged");
19143 assert_eq!(
19144 pg.last,
19145 Some(PageType::Raw),
19146 "the append left the tail page holding something other than raw data"
19147 );
19148 assert!(
19149 pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
19150 "an in-place append switched page type: meta_pad={:?} raw_pad={:?}",
19151 pg.meta_pad,
19152 pg.raw_pad
19153 );
19154
19155 // Not vacuous: the append really did allocate index structure above the
19156 // pre-append end-of-file, which is what would have opened a metadata page.
19157 let addr = crate::group_v2::resolve_path_any_from_source(
19158 &engine.image(),
19159 AccessMode::ReadWrite,
19160 engine.superblock(),
19161 &ObjectPath::parse("d"),
19162 )
19163 .unwrap();
19164 let spans = engine
19165 .chunked_storage_spans(addr)
19166 .expect("a chunked dataset has reclaimable spans");
19167 let fresh = spans.iter().filter(|&&(a, _, _)| a >= before).count();
19168 assert!(
19169 fresh > 0,
19170 "the append allocated nothing above {before}, so the assertion above proves nothing"
19171 );
19172 assert!(
19173 spans
19174 .iter()
19175 .all(|&(_, _, class)| class == FreeClass::Page(PageType::Raw)),
19176 "the reclaim tags every chunked span raw; a metadata or dead tag here would \
19177 need the placement rule above to change with it"
19178 );
19179 }
19180
19181 /// Write a small file of `tables` unlimited chunked datasets, paged when
19182 /// asked, for the write-gathering tests below.
19183 fn gather_fixture(path: &std::path::Path, tables: usize, paged: bool) {
19184 use crate::writer::FileBuilder;
19185 let mut b = FileBuilder::new();
19186 if paged {
19187 // Deliberately *not* DEFAULT_GATHER_PAGE: a paged fixture at the
19188 // default page size cannot tell a session that reads the file's page
19189 // size from one that assumes the default.
19190 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19191 .with_file_space_page_size(16 * 1024);
19192 }
19193 for t in 0..tables {
19194 b.create_dataset(&std::format!("t{t}"))
19195 .with_i32_data(&(0..256).collect::<Vec<_>>())
19196 .with_shape(&[256])
19197 .with_maxshape(&[MaxExtent::Unlimited])
19198 .with_chunks(&[64]);
19199 }
19200 b.write(path).unwrap();
19201 }
19202
19203 /// Run the same appends and the same commit on `session`, and report what
19204 /// each cost in writes: the in-place appends, then the staged commit that
19205 /// follows them. They are counted apart because the gathering earns its keep
19206 /// in only one of them — see the caller. The file the workload leaves is the
19207 /// other half of what the callers compare, and they read it off the path
19208 /// themselves.
19209 fn gather_workload(session: &mut WriteEngine) -> (u64, u64) {
19210 let before = session.image.issued_writes();
19211 for round in 0..4 {
19212 for t in 0..4 {
19213 session
19214 .append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19215 .unwrap();
19216 }
19217 }
19218 for t in 0..4 {
19219 let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19220 db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19221 session
19222 .stage_created_dataset(&ObjectPathBuf::parse(&std::format!("/n{t}")), db)
19223 .unwrap();
19224 }
19225 let after_appends = session.image.issued_writes();
19226 session.commit().unwrap();
19227 (
19228 after_appends - before,
19229 session.image.issued_writes() - after_appends,
19230 )
19231 }
19232
19233 /// Gathering a session's writes lowers what it costs and changes nothing
19234 /// about what it produces (issue #288).
19235 ///
19236 /// Both halves matter and neither implies the other. A gatherer that dropped
19237 /// a run, wrote one twice in the wrong order, or filled the space between two
19238 /// runs sharing a page with zeros would lower the count exactly as well — so
19239 /// the two files are compared **byte for byte**, which is the only assertion
19240 /// a wrong merge cannot pass. And a gatherer that merged nothing would keep
19241 /// them identical, which is what the count is for.
19242 ///
19243 /// The comparison is against this same engine with the gathering turned off,
19244 /// rather than against a recorded number: how many writes a commit needs is
19245 /// an implementation detail that should be free to fall further.
19246 #[test]
19247 fn gathering_writes_costs_fewer_of_them_and_changes_no_byte() {
19248 use tempfile::tempdir;
19249
19250 let dir = tempdir().unwrap();
19251 for paged in [false, true] {
19252 let straight = dir.path().join(std::format!("straight_{paged}.h5"));
19253 let gathered = dir.path().join(std::format!("gathered_{paged}.h5"));
19254 gather_fixture(&straight, 4, paged);
19255 gather_fixture(&gathered, 4, paged);
19256
19257 let mut a = WriteEngine::open_with_locking(&straight, FileLocking::Enabled).unwrap();
19258 a.set_sync_policy(SyncPolicy::OnClose);
19259 a.image
19260 .set_write_buffering(WriteBuffering::Unbuffered)
19261 .unwrap();
19262 let (straight_appends, straight_commit) = gather_workload(&mut a);
19263 a.force_sync().unwrap();
19264 drop(a);
19265
19266 let mut b = WriteEngine::open_with_locking(&gathered, FileLocking::Enabled).unwrap();
19267 b.set_sync_policy(SyncPolicy::OnClose);
19268 let (gathered_appends, gathered_commit) = gather_workload(&mut b);
19269 b.force_sync().unwrap();
19270 drop(b);
19271
19272 // The commit tail is where the gathering earns its keep, and the half
19273 // to assert a ratio on. Measured: 2 writes against 10 unpaged, 4
19274 // against 16 paged. A commit rebuilds a group, repoints a root and
19275 // re-homes the free-space managers, all inside one phase and all into
19276 // a handful of pages, which is what merging within a barrier is for.
19277 // Merging *across* barriers goes further and is what an explicit page
19278 // buffer does; `a_page_buffer_holds_dirty_pages_across_operations`
19279 // measures that.
19280 assert!(
19281 gathered_commit * 3 < straight_commit,
19282 "paged={paged}: gathering must cost meaningfully fewer writes for a \
19283 commit, but cost {gathered_commit} against {straight_commit}"
19284 );
19285 // The appends are the other half, and since issue #307 they are a
19286 // near-tie: 88 against 92 unpaged, 92 against 92 paged. Publishing a
19287 // checksummed structure is one write from the engine now rather than
19288 // two the gatherer had to rejoin, so the buffering has almost nothing
19289 // left to merge here — before that fix this half was 92 against 144.
19290 // What is still worth pinning is that it never costs *more*. Note the
19291 // limit of that: a publish write made wider still merges the same way,
19292 // so widening one to the whole structure it sits in changes no count
19293 // here or anywhere — `a_publish_writes_from_the_byte_it_changed` is
19294 // what holds that, by counting bytes rather than writes.
19295 assert!(
19296 gathered_appends <= straight_appends,
19297 "paged={paged}: gathering must not cost more writes for the appends, \
19298 but cost {gathered_appends} against {straight_appends}"
19299 );
19300 assert_eq!(
19301 std::fs::read(&straight).unwrap(),
19302 std::fs::read(&gathered).unwrap(),
19303 "paged={paged}: gathering changed the file it produced"
19304 );
19305 }
19306 }
19307
19308 /// A session merges writes within the page its *file* was laid out on, and
19309 /// falls back to the format default only when the file is not paged.
19310 ///
19311 /// Asserted directly because the fixtures cannot assert it indirectly: the
19312 /// merge quantum is only visible in which writes coalesce, and any fixture
19313 /// built at the default page size makes the two answers identical. Reading
19314 /// the file's page size is the whole point of resolving this after the open
19315 /// rather than at it.
19316 #[test]
19317 fn the_merge_page_follows_the_file_rather_than_the_default() {
19318 use tempfile::tempdir;
19319
19320 let dir = tempdir().unwrap();
19321 let paged = dir.path().join("paged.h5");
19322 let plain = dir.path().join("plain.h5");
19323 gather_fixture(&paged, 1, true);
19324 gather_fixture(&plain, 1, false);
19325
19326 let s = WriteEngine::open_with_locking(&paged, FileLocking::Enabled).unwrap();
19327 assert_eq!(
19328 s.gather_page_size(),
19329 16 * 1024,
19330 "a paged file merges within its own file-space page"
19331 );
19332 assert_ne!(
19333 16 * 1024,
19334 DEFAULT_GATHER_PAGE,
19335 "the fixture must not be built at the default, or the assertion above \
19336 holds for a session that ignores the file entirely"
19337 );
19338 drop(s);
19339
19340 let s = WriteEngine::open_with_locking(&plain, FileLocking::Enabled).unwrap();
19341 assert_eq!(
19342 s.gather_page_size(),
19343 DEFAULT_GATHER_PAGE,
19344 "an unpaged file has no page size of its own"
19345 );
19346 }
19347
19348 /// Writing the status-flags byte on a file with a userblock changes that byte
19349 /// and nothing else.
19350 ///
19351 /// `set_consistency_flags` serializes the whole superblock to rewrite one
19352 /// byte of it, and the root address it carries is held absolute in memory but
19353 /// stored relative to the base address. Getting that wrong repoints the root
19354 /// past the end of the file, so a call that promised to touch a flag silently
19355 /// makes the file unreadable.
19356 ///
19357 /// Driven directly rather than through a caller, because for a long time
19358 /// neither caller could reach a non-zero base: SWMR refuses a userblock
19359 /// outright, and a page buffer required persisted free space, which is
19360 /// declined for a non-zero base. Both were refusals about something else that
19361 /// settled this as a side effect — and the day one of them moved is the day a
19362 /// test routed through it would have gone quiet. Issue #357 moved the second,
19363 /// so `a_page_buffer_marks_a_userblock_file_without_repointing_its_root`
19364 /// exercises the same conversion through a real session; this one stays as
19365 /// the direct statement of the rule.
19366 #[test]
19367 fn a_status_flag_write_leaves_a_userblock_files_root_alone() {
19368 use crate::writer::FileBuilder;
19369 use tempfile::tempdir;
19370
19371 let dir = tempdir().unwrap();
19372 let path = dir.path().join("userblock.h5");
19373 let mut b = FileBuilder::new();
19374 b.with_userblock(4096);
19375 b.create_dataset("d")
19376 .with_i32_data(&[1, 2, 3, 4])
19377 .with_shape(&[4]);
19378 b.write(&path).unwrap();
19379 let before = std::fs::read(&path).unwrap();
19380
19381 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19382 assert_eq!(
19383 s.superblock.base_address,
19384 BaseAddress::new(4096),
19385 "the fixture must have a userblock, or this test holds for a file \
19386 whose absolute and relative roots are the same number"
19387 );
19388 // Up and back down, which is the sequence a marked session performs.
19389 s.set_consistency_flags(file_lock::WRITE_ACCESS).unwrap();
19390 s.set_consistency_flags(0).unwrap();
19391 s.force_sync().unwrap();
19392 drop(s);
19393
19394 assert_eq!(
19395 std::fs::read(&path).unwrap(),
19396 before,
19397 "raising and clearing the flags must leave the file as it was"
19398 );
19399 assert_eq!(
19400 crate::reader::File::open(&path)
19401 .unwrap()
19402 .dataset("d")
19403 .unwrap()
19404 .read_i32()
19405 .unwrap(),
19406 vec![1, 2, 3, 4],
19407 "and the file must still read"
19408 );
19409 }
19410
19411 /// The budget a caller asks for is the budget the gatherer is given, so a
19412 /// session that outruns it flushes rather than growing without bound.
19413 ///
19414 /// `set_page_buffer_size` spends most of its length on refusals, and the one
19415 /// line that does the work hands `max_bytes` on. Passing `usize::MAX` instead
19416 /// fails nothing else in the suite: every other page-buffer test runs a
19417 /// workload smaller than the budget, so it holds everything to the end either
19418 /// way and the file it leaves is identical. What that would cost is a
19419 /// long-running session holding the whole of what it wrote in memory, which
19420 /// is the one thing the budget is for.
19421 ///
19422 /// Asserted as "some writes went out before the session ended" rather than as
19423 /// a count, since how many a 4 MiB append needs is free to change.
19424 #[test]
19425 fn a_page_buffered_session_flushes_when_it_outruns_its_budget() {
19426 use tempfile::tempdir;
19427
19428 let dir = tempdir().unwrap();
19429 let path = dir.path().join("outrun.h5");
19430 gather_fixture(&path, 1, true);
19431
19432 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19433 s.set_sync_policy(SyncPolicy::OnClose);
19434 s.set_page_buffer_size(WRITE_GATHER_BYTES).unwrap();
19435 let before = s.image.issued_writes();
19436 // Four times the budget, in one append, so nothing but the budget can
19437 // account for a write going out before the teardown below.
19438 s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19439 .unwrap();
19440 let during = s.image.issued_writes() - before;
19441 s.force_sync().unwrap();
19442 s.release_status_flags().unwrap();
19443 drop(s);
19444
19445 assert!(
19446 during > 0,
19447 "a session that wrote four times its budget issued nothing until it \
19448 closed, so the budget it was given is not the one it asked for"
19449 );
19450 assert_eq!(
19451 crate::reader::File::open(&path)
19452 .unwrap()
19453 .dataset("t0")
19454 .unwrap()
19455 .read_i32()
19456 .unwrap()
19457 .len(),
19458 256 + (1 << 20),
19459 "and the flushed append must still read back whole"
19460 );
19461 }
19462
19463 /// A page-buffer budget below the 1 MiB a session already gathers under
19464 /// issues *more* writes — on the workload a page buffer is sold for, and on
19465 /// the one it is worst at.
19466 ///
19467 /// This is the price the public property quotes for a small budget, so it
19468 /// belongs in the suite rather than in a comment. Such a budget was refused
19469 /// outright until issue #391, on the strength of exactly this measurement;
19470 /// what changed is who decides, not what it costs. A claim about a cost that
19471 /// nothing checks is one that quietly stops being true, and a caller sizing a
19472 /// buffer against a memory cap is who pays for that.
19473 ///
19474 /// The C library charges nothing for a small buffer: `H5PB_write` sends any
19475 /// I/O of a page or more straight to the driver. Nothing bypasses this
19476 /// gatherer, so the budget is also the point at which a long run is flushed
19477 /// and restarted — which is why the second workload is one long run.
19478 ///
19479 /// Asserted as an **ordering** rather than as counts. The counts are
19480 /// deterministic on one target and are quoted on the public property, but how
19481 /// many writes a commit needs is free to fall; what must not change is which
19482 /// budget issues fewer (issues #357, #391).
19483 #[test]
19484 fn a_smaller_budget_issues_more_writes() {
19485 use tempfile::tempdir;
19486
19487 let dir = tempdir().unwrap();
19488 let fixture = |path: &std::path::Path, tables: usize| {
19489 use crate::writer::FileBuilder;
19490 let mut b = FileBuilder::new();
19491 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19492 .with_file_space_page_size(4096);
19493 for t in 0..tables {
19494 b.create_dataset(&std::format!("t{t}"))
19495 .with_i32_data(&(0..256).collect::<Vec<_>>())
19496 .with_shape(&[256])
19497 .with_maxshape(&[MaxExtent::Unlimited])
19498 .with_chunks(&[64]);
19499 }
19500 b.write(path).unwrap();
19501 };
19502
19503 // The public path, which now accepts every budget down to this fixture's
19504 // 4 KiB page — so the arms differ in the budget a caller asked for and in
19505 // nothing else.
19506 let run = |path: &std::path::Path, budget: usize, workload: &dyn Fn(&mut WriteEngine)| {
19507 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19508 s.set_sync_policy(SyncPolicy::OnClose);
19509 s.set_page_buffer_size(budget).unwrap();
19510 let before = s.image.issued_writes();
19511 workload(&mut s);
19512 s.force_sync().unwrap();
19513 s.release_status_flags().unwrap();
19514 let issued = s.image.issued_writes() - before;
19515 drop(s);
19516 issued
19517 };
19518
19519 // Many small scattered writes — what the property is for — and one long
19520 // contiguous run, which is where a small budget collapses.
19521 let workload = |scattered: bool| {
19522 move |s: &mut WriteEngine| {
19523 if scattered {
19524 for round in 0..4 {
19525 for t in 0..8 {
19526 s.append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19527 .unwrap();
19528 }
19529 }
19530 } else {
19531 s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19532 .unwrap();
19533 }
19534 }
19535 };
19536
19537 for (label, tables, scattered) in [("scattered", 8, true), ("one long run", 1, false)] {
19538 let workload = workload(scattered);
19539 let at = |budget: usize| {
19540 let path = dir
19541 .path()
19542 .join(std::format!("{}_{budget}.h5", label.replace(' ', "_")));
19543 fixture(&path, tables);
19544 run(&path, budget, &workload)
19545 };
19546 let ample = at(WRITE_GATHER_BYTES);
19547 let smallest = at(4096);
19548 for smaller in [4096, 64 * 1024] {
19549 let below = at(smaller);
19550 assert!(
19551 ample <= below,
19552 "{label}: a {smaller}-byte budget issued {below} writes against \
19553 {ample} at {WRITE_GATHER_BYTES}, so a smaller budget is not the \
19554 trade the property documents"
19555 );
19556 }
19557 // Not vacuous: a gatherer that ignored `max_bytes` outright would
19558 // report the same count at every budget and satisfy the ordering
19559 // above without honoring any of them. One page is a quarter of the
19560 // smallest step measured here, so a workload that does not separate
19561 // there is one this assertion cannot speak for.
19562 assert!(
19563 smallest > ample,
19564 "{label}: the budget changed nothing — {smallest} writes at 4 KiB \
19565 against {ample} at 1 MiB — so this workload cannot say which \
19566 budget is better"
19567 );
19568 }
19569 }
19570
19571 /// A page-buffered session on a file with a userblock raises and clears its
19572 /// crash mark without disturbing the root address that mark's superblock
19573 /// rewrite carries — and the userblock's own bytes stay put.
19574 ///
19575 /// The caller-side half of
19576 /// `a_status_flag_write_leaves_a_userblock_files_root_alone`, reachable only
19577 /// since issue #357 scoped the persisted-free-space refusal to paged files.
19578 /// A userblock file persists no free space whatever its creation properties
19579 /// asked for, so before that the mark could not be raised on one at all, and
19580 /// `set_consistency_flags`'s base conversion had no caller that needed it.
19581 #[test]
19582 fn a_page_buffer_marks_a_userblock_file_without_repointing_its_root() {
19583 use crate::writer::FileBuilder;
19584 use tempfile::tempdir;
19585
19586 let dir = tempdir().unwrap();
19587 let path = dir.path().join("ub_buffered.h5");
19588 let mut b = FileBuilder::new();
19589 b.with_userblock(4096);
19590 b.create_dataset("t0")
19591 .with_i32_data(&(0..256).collect::<Vec<_>>())
19592 .with_shape(&[256]);
19593 b.write(&path).unwrap();
19594 let userblock_before = std::fs::read(&path).unwrap()[..4096].to_vec();
19595
19596 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19597 assert_eq!(
19598 s.superblock.base_address,
19599 BaseAddress::new(4096),
19600 "the fixture must have a userblock, or this test says nothing about \
19601 the conversion it exists for"
19602 );
19603 s.set_sync_policy(SyncPolicy::OnClose);
19604 s.set_page_buffer_size(1 << 20)
19605 .expect("an unpaged userblock file must accept a page buffer");
19606 // The mark is the point: a session that accepted the property and raised
19607 // nothing would pass every assertion below, since the conversion under
19608 // test runs only when the flags byte is written, and `raise_crash_mark`
19609 // is the only thing that writes it here. Asserted in memory rather than
19610 // off the disk because the session holds an exclusive OS lock on the
19611 // file, which is mandatory on Windows.
19612 assert_eq!(
19613 s.held_status_flags,
19614 file_lock::WRITE_ACCESS,
19615 "a page-buffered session must hold the crash mark"
19616 );
19617 // A staged commit rather than an in-place append: in-place appending is
19618 // refused on a userblock file for reasons of its own, which is a
19619 // different subject and would only mask this one.
19620 for t in 0..3 {
19621 let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19622 db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19623 s.stage_created_dataset(&ObjectPathBuf::parse(&std::format!("/n{t}")), db)
19624 .unwrap();
19625 }
19626 s.commit().unwrap();
19627 s.force_sync().unwrap();
19628 s.release_status_flags().unwrap();
19629 drop(s);
19630
19631 assert_eq!(
19632 &std::fs::read(&path).unwrap()[..4096],
19633 &userblock_before[..],
19634 "the page-buffered session rewrote the userblock"
19635 );
19636 let f = crate::reader::File::open(&path).unwrap();
19637 assert_eq!(
19638 f.dataset("t0").unwrap().read_i32().unwrap(),
19639 (0..256).collect::<Vec<_>>(),
19640 "the commit must have left the original dataset alone"
19641 );
19642 for t in 0..3 {
19643 assert_eq!(
19644 f.dataset(&std::format!("n{t}"))
19645 .unwrap()
19646 .read_f64()
19647 .unwrap(),
19648 vec![2.5f64; 32],
19649 "n{t}: the committed dataset must read back"
19650 );
19651 }
19652 }
19653
19654 /// A page buffer keeps dirty pages across operations, so a workload that
19655 /// touches the same pages again and again pays for them once rather than
19656 /// once per operation (issue #288) — and still produces the same file.
19657 ///
19658 /// This is what the default gathering deliberately does *not* do, so the
19659 /// comparison is against that default rather than against no gathering at
19660 /// all: what is being measured is the second reduction, not the first.
19661 ///
19662 /// Run on a paged file **and an unpaged one**. `H5Pset_page_buffer_size`
19663 /// requires the paged allocator because the C page buffer is a page cache
19664 /// whose `min_meta_perc`/`min_raw_perc` reservations count pages that
19665 /// allocator keeps segregated by kind; this is a write gatherer, which needs
19666 /// only a window to merge within, and
19667 /// [`gather_page_size`](WriteEngine::gather_page_size) supplies one either
19668 /// way. The unpaged arm is what says so (issue #357), and it is the arm that
19669 /// matters most in practice: unpaged is the default strategy, so before this
19670 /// the property was reachable only from a file deliberately created paged.
19671 #[test]
19672 fn a_page_buffer_holds_dirty_pages_across_operations() {
19673 use tempfile::tempdir;
19674
19675 let dir = tempdir().unwrap();
19676 let run = |path: &std::path::Path, page_buffer: usize| {
19677 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19678 s.set_sync_policy(SyncPolicy::OnClose);
19679 if page_buffer != 0 {
19680 s.set_page_buffer_size(page_buffer).unwrap();
19681 }
19682 let (appends, commit) = gather_workload(&mut s);
19683 // The teardown `File::close` and `FileInner::drop` perform, in their
19684 // order. A bare engine owes it too — it is what takes the crash mark
19685 // down — and nothing does it for one: this crate deliberately keeps
19686 // teardown writes off `WriteEngine::drop`, so that dropping a probe
19687 // session (as the bounded/mirrored dispatch does) writes nothing.
19688 // Without it the buffered file ends still marked, and the byte
19689 // comparison below is what says so.
19690 s.force_sync().unwrap();
19691 s.release_status_flags().unwrap();
19692 drop(s);
19693 appends + commit
19694 };
19695
19696 for paged in [true, false] {
19697 let label = if paged { "paged" } else { "unpaged" };
19698 let per_op = dir.path().join(std::format!("{label}_per_op.h5"));
19699 let buffered = dir.path().join(std::format!("{label}_buffered.h5"));
19700 gather_fixture(&per_op, 4, paged);
19701 gather_fixture(&buffered, 4, paged);
19702
19703 let per_op_writes = run(&per_op, 0);
19704 let buffered_writes = run(&buffered, 1 << 20);
19705
19706 assert!(
19707 buffered_writes * 4 < per_op_writes,
19708 "{label}: a page buffer must cost meaningfully fewer writes than the \
19709 per-operation default, but cost {buffered_writes} against {per_op_writes}"
19710 );
19711 assert_eq!(
19712 std::fs::read(&per_op).unwrap(),
19713 std::fs::read(&buffered).unwrap(),
19714 "{label}: a page buffer changed the file it produced"
19715 );
19716 }
19717 }
19718
19719 /// A budget below the 1 MiB a session already gathers under is honored end
19720 /// to end, and leaves the same file 1 MiB leaves (issue #391).
19721 ///
19722 /// The floor this replaces was a write-count argument with no correctness
19723 /// claim behind it, so the assertion that carries the weight is the byte
19724 /// comparison rather than the read-back: a buffer that flushes and restarts
19725 /// more often must produce the *same* file, not merely a readable one.
19726 ///
19727 /// Run through the public API — create, append, commit, close, reopen —
19728 /// because `File::create_with_options` is where the refusal used to fire, on
19729 /// a pair it had already decided it could not reopen with. A 16 KiB-paged
19730 /// file at 256 KiB is the case the issue reported.
19731 #[test]
19732 fn a_page_buffer_below_the_gather_budget_writes_the_same_file() {
19733 use tempfile::tempdir;
19734
19735 let dir = tempdir().unwrap();
19736 let run = |path: &std::path::Path, budget: usize| -> Vec<i32> {
19737 let file = crate::reader::File::create_with_options(
19738 path,
19739 crate::FileCreateProperties::new()
19740 .with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19741 .with_file_space_page_size(16 * 1024),
19742 crate::FileAccessProperties::new()
19743 .with_sync_policy(SyncPolicy::OnClose)
19744 .with_page_buffer_size(budget),
19745 )
19746 .unwrap();
19747 file.root()
19748 .create_dataset("d", |b| {
19749 b.with_i32_data(&(0..64).collect::<Vec<i32>>())
19750 .with_shape(&[64])
19751 .with_maxshape(&[MaxExtent::Unlimited])
19752 .with_chunks(&[64]);
19753 })
19754 .unwrap();
19755 file.commit().unwrap();
19756 for round in 0..8i32 {
19757 let mut ds = file.dataset("d").unwrap();
19758 ds.append(&vec![round; 64]).unwrap();
19759 }
19760 file.root()
19761 .create_dataset("added", |b| {
19762 b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19763 })
19764 .unwrap();
19765 file.commit().unwrap();
19766 file.close().unwrap();
19767
19768 let reopened = crate::reader::File::open(path).unwrap();
19769 assert_eq!(
19770 reopened.dataset("added").unwrap().read_f64().unwrap(),
19771 vec![2.5f64; 32],
19772 "the dataset committed through the buffer must read back"
19773 );
19774 reopened.dataset("d").unwrap().read_i32().unwrap()
19775 };
19776
19777 let small = dir.path().join("small_budget.h5");
19778 let ample = dir.path().join("ample_budget.h5");
19779 let from_small = run(&small, 256 * 1024);
19780 let from_ample = run(&le, 1 << 20);
19781
19782 let mut expected: Vec<i32> = (0..64).collect();
19783 for round in 0..8i32 {
19784 expected.extend(std::iter::repeat_n(round, 64));
19785 }
19786 assert_eq!(
19787 from_small, expected,
19788 "a 256 KiB page buffer did not append what it was given"
19789 );
19790 assert_eq!(
19791 from_ample, expected,
19792 "and neither did the 1 MiB one, so the file comparison below would be \
19793 two arms agreeing on the wrong answer"
19794 );
19795 assert_eq!(
19796 std::fs::read(&small).unwrap(),
19797 std::fs::read(&le).unwrap(),
19798 "a 256 KiB page buffer produced a different file from a 1 MiB one"
19799 );
19800 }
19801
19802 /// A barrier issues what has been gathered under **every** policy, so the
19803 /// bytes a publish point names are on the disk before the publish point is.
19804 ///
19805 /// Gathered writes are issued in address order, and the superblock lives at
19806 /// address 0 — so a commit whose barriers issued nothing would put its new
19807 /// root pointer on the disk *first* and the content it names last. A write
19808 /// that then failed, or a process that died mid-flush, would leave a
19809 /// superblock naming bytes that are not in the file, where before this
19810 /// gathering existed it left the previous file intact.
19811 ///
19812 /// `SyncPolicy::Always` cannot see this: its barriers are `fsync`s, which
19813 /// flush on their way out. Every crash-consistency test in this crate runs on
19814 /// that default, which is exactly why nothing caught it (issue #288). So this
19815 /// runs on `OnClose`, and asserts the order rather than a count — a count
19816 /// would be satisfied by a session that issued everything at the wrong time.
19817 ///
19818 /// It covers two of the three ordering sites: `barrier` (the commit tail) and
19819 /// `EditStore::sync` (the append phases). The third is `barrier_data`, which
19820 /// `EditStore::sync` now shares rather than duplicates, so it is correct by
19821 /// identity rather than by argument. It is covered too, one test over:
19822 /// mutating its `OnClose` arm to `Ok(())` fails
19823 /// `sync_policy_governs_the_persisting_and_flag_barriers` and nothing else.
19824 #[test]
19825 fn a_barrier_orders_the_publish_point_last_under_every_policy() {
19826 use tempfile::tempdir;
19827
19828 let dir = tempdir().unwrap();
19829 for policy in [SyncPolicy::Always, SyncPolicy::OnClose] {
19830 let path = dir.path().join(std::format!("order_{policy:?}.h5"));
19831 gather_fixture(&path, 1, false);
19832
19833 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19834 s.set_sync_policy(policy);
19835
19836 let mut db = crate::type_builders::DatasetBuilder::new("added");
19837 db.with_f64_data(&[2.5f64; 64]).with_shape(&[64]);
19838 s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
19839 .unwrap();
19840 let before = s.image.issued_write_order().len();
19841 s.commit().unwrap();
19842
19843 let order = s.image.issued_write_order()[before..].to_vec();
19844 let superblock = order
19845 .iter()
19846 .position(|&(at, _)| at == s.sb_sig_off)
19847 .unwrap_or_else(|| panic!("{policy:?}: the commit never wrote the superblock"));
19848 let content = order
19849 .iter()
19850 .rposition(|&(at, _)| at != s.sb_sig_off)
19851 .unwrap_or_else(|| panic!("{policy:?}: the commit wrote nothing but a superblock"));
19852 assert!(
19853 superblock > content,
19854 "{policy:?}: the superblock was issued at position {superblock} of \
19855 {order:?}, ahead of content at {content} — a failure in that window \
19856 leaves a root pointing at bytes that are not in the file"
19857 );
19858
19859 // The same for the append engine's four phases, whose publish point is
19860 // the dataspace dimension in the object header — a *low* address, with
19861 // the chunk bytes it makes visible at end-of-file. Stated as "the last
19862 // write is not the highest one", which is precisely what a single
19863 // address-ordered flush of the whole append would make it.
19864 let before = s.image.issued_write_order().len();
19865 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19866 let order = s.image.issued_write_order()[before..].to_vec();
19867 let highest = order
19868 .iter()
19869 .map(|&(at, _)| at)
19870 .max()
19871 .expect("the append wrote");
19872 assert!(
19873 order.last().expect("the append wrote").0 < highest,
19874 "{policy:?}: the append's last write is its highest-addressed one, so \
19875 the whole append went out in address order and the dimension that \
19876 publishes the new rows preceded the chunk bytes: {order:?}"
19877 );
19878 }
19879 }
19880
19881 /// The page buffer produces the same bytes through the **bounded** backing,
19882 /// which is the one `File::open_rw` actually picks for a latest-format file.
19883 ///
19884 /// The test above drives the whole-file mirror, whose reads come from memory
19885 /// and so never meet the pending writes at all. The bounded image has no
19886 /// mirror: every read it serves goes to the disk and is then patched with
19887 /// whatever is still gathered, so it is the backing where a wrong overlay
19888 /// silently plans the next edit against bytes that are neither on the disk nor
19889 /// in the buffer. Byte identity against the same session without the buffer is
19890 /// the assertion a wrong overlay cannot pass.
19891 #[test]
19892 fn a_page_buffer_changes_no_byte_through_the_bounded_backing() {
19893 use crate::reader::File;
19894 use tempfile::tempdir;
19895
19896 let dir = tempdir().unwrap();
19897 let run = |name: &str, page_buffer: usize| {
19898 let path = dir.path().join(name);
19899 gather_fixture(&path, 4, true);
19900 {
19901 let props = crate::FileAccessProperties::new()
19902 .with_sync_policy(SyncPolicy::OnClose)
19903 .with_memory_strategy(MemoryStrategy::Bounded)
19904 .with_page_buffer_size(page_buffer);
19905 let file = File::open_rw_with_options(&path, props).unwrap();
19906 assert_eq!(
19907 file.edit_backing(),
19908 Some(crate::EditBacking::Bounded),
19909 "{name}: this must exercise the mirrorless backing"
19910 );
19911 let root = file.root();
19912 for round in 0..4u8 {
19913 for t in 0..4 {
19914 let mut ds = file.dataset(&std::format!("t{t}")).unwrap();
19915 ds.append(&[i32::from(round); 64]).unwrap();
19916 }
19917 }
19918 for t in 0..4 {
19919 root.create_dataset(&std::format!("n{t}"), |b| {
19920 b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19921 })
19922 .unwrap();
19923 }
19924 file.commit().unwrap();
19925 root.set_attr("tag", crate::AttrValue::I32(1)).unwrap();
19926 file.commit().unwrap();
19927 file.close().unwrap();
19928 }
19929 std::fs::read(&path).unwrap()
19930 };
19931
19932 assert_eq!(
19933 run("bounded_plain.h5", 0),
19934 run("bounded_buffered.h5", 1 << 20),
19935 "a page buffer changed the file the bounded backing produced"
19936 );
19937 }
19938
19939 /// An operation that has returned has put its bytes in the operating system,
19940 /// whatever the [`SyncPolicy`] says.
19941 ///
19942 /// This is what makes the default gathering free rather than a trade: the
19943 /// bytes are held only *inside* a commit or an append, never across one. It
19944 /// is asserted as "a forced sync afterwards finds nothing left to write",
19945 /// because the alternative — reading the file through a second handle — is
19946 /// what the session's own exclusive lock exists to prevent, mandatorily so on
19947 /// Windows.
19948 #[test]
19949 fn a_finished_operation_has_nothing_left_to_write() {
19950 use tempfile::tempdir;
19951
19952 let dir = tempdir().unwrap();
19953 let path = dir.path().join("finished_op.h5");
19954 gather_fixture(&path, 1, false);
19955
19956 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19957 // The policy that issues no `fsync` at all, so the ordering barriers are
19958 // the only thing that can be draining the buffer.
19959 s.set_sync_policy(SyncPolicy::OnClose);
19960
19961 let before_append = s.image.issued_writes();
19962 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19963 let after_append = s.image.issued_writes();
19964 assert!(
19965 after_append > before_append,
19966 "the append issued nothing at all, so the equality below holds for a \
19967 session that did no work"
19968 );
19969 s.force_sync().unwrap();
19970 assert_eq!(
19971 s.image.issued_writes(),
19972 after_append,
19973 "a finished append left writes in this process's memory"
19974 );
19975
19976 let mut db = crate::type_builders::DatasetBuilder::new("added");
19977 db.with_f64_data(&[1.5f64; 8]).with_shape(&[8]);
19978 s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
19979 .unwrap();
19980 s.commit().unwrap();
19981 let after_commit = s.image.issued_writes();
19982 assert!(
19983 after_commit > after_append,
19984 "the commit issued nothing at all"
19985 );
19986 s.force_sync().unwrap();
19987 assert_eq!(
19988 s.image.issued_writes(),
19989 after_commit,
19990 "a finished commit left writes in this process's memory"
19991 );
19992
19993 // And the bytes are the ones they were meant to be — an image that issued
19994 // writes at the right moments but the wrong contents passes everything
19995 // above.
19996 drop(s);
19997 let f = crate::reader::File::open(&path).unwrap();
19998 assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap().len(), 320);
19999 assert_eq!(
20000 f.dataset("added").unwrap().read_f64().unwrap(),
20001 vec![1.5f64; 8]
20002 );
20003 }
20004
20005 /// Overwriting the root group is refused by name.
20006 ///
20007 /// The refusal lives in `stage_dataset_write` rather than in the commit,
20008 /// because the staged dataset is flattened as it is staged and the root's
20009 /// empty path would otherwise be reported as a dataset with no name. No
20010 /// public entry point can reach it — `Dataset::write_staged` runs off a
20011 /// resolved dataset path — so this is where it stays covered.
20012 #[test]
20013 fn overwriting_the_root_group_is_refused_at_staging() {
20014 use crate::writer::FileBuilder;
20015 use tempfile::tempdir;
20016
20017 let dir = tempdir().unwrap();
20018 let path = dir.path().join("root_overwrite.h5");
20019 let mut b = FileBuilder::new();
20020 b.create_dataset("d").with_i32_data(&[1]);
20021 b.write(&path).unwrap();
20022
20023 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20024 let mut db = crate::type_builders::DatasetBuilder::new("whatever");
20025 db.with_i32_data(&[1]);
20026 let err = s
20027 .stage_dataset_write(&ObjectPathBuf::parse("/"), db)
20028 .unwrap_err();
20029 assert!(
20030 matches!(&err, Error::EditUnsupported(m) if m.contains("root group")),
20031 "unexpected error: {err:?}"
20032 );
20033 assert!(!s.has_staged_edits());
20034 }
20035
20036 /// Unallocated storage is refused by this engine rather than materialized.
20037 ///
20038 /// The whole-file writer leaves such a dataset's data address undefined
20039 /// (issue #293); this engine appends into an existing layout and has no
20040 /// equivalent, so the flag has to be answered rather than dropped. Dropping
20041 /// it would write out the grid of fill values the caller asked not to have,
20042 /// and every value assertion would pass.
20043 ///
20044 /// The message is checked, not just the failure: the arm below this one
20045 /// refuses a dataset with no data for the opposite reason — the caller forgot
20046 /// it — and reporting that one here would send a caller looking for a bug in
20047 /// their own code.
20048 #[test]
20049 fn staging_unallocated_storage_into_an_existing_file_is_refused() {
20050 use crate::writer::FileBuilder;
20051 use tempfile::tempdir;
20052
20053 let dir = tempdir().unwrap();
20054 let path = dir.path().join("unallocated.h5");
20055 let mut b = FileBuilder::new();
20056 b.create_dataset("d").with_i32_data(&[1]);
20057 b.write(&path).unwrap();
20058
20059 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20060 let mut db = crate::type_builders::DatasetBuilder::new("sparse");
20061 db.with_unallocated_storage(make_i32_type(), &[1000]);
20062 db.with_chunks(&[100]);
20063 let err = s
20064 .stage_created_dataset(&ObjectPathBuf::parse("/sparse"), db)
20065 .unwrap_err();
20066 assert!(
20067 matches!(&err, Error::EditUnsupported(m) if m.contains("unallocated storage")),
20068 "unexpected error: {err:?}"
20069 );
20070 assert!(!s.has_staged_edits());
20071 }
20072
20073 /// The same obligation on the one operation that does not go through
20074 /// [`commit`](WriteEngine::commit): the free-space finalize a session owes at
20075 /// teardown.
20076 ///
20077 /// Its two callers force a sync straight after it, so nothing observable
20078 /// breaks when it forgets — which is exactly why it needs its own test. It is
20079 /// `pub(crate)`, and the next caller would inherit the omission silently.
20080 #[test]
20081 fn finalize_persist_has_nothing_left_to_write() {
20082 use crate::writer::FileBuilder;
20083 use tempfile::tempdir;
20084
20085 let dir = tempdir().unwrap();
20086 let path = dir.path().join("persisting.h5");
20087 let mut b = FileBuilder::new();
20088 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20089 b.create_dataset("t0")
20090 .with_i32_data(&(0..256).collect::<Vec<_>>())
20091 .with_shape(&[256])
20092 .with_maxshape(&[MaxExtent::Unlimited])
20093 .with_chunks(&[64]);
20094 b.write(&path).unwrap();
20095
20096 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20097 s.set_sync_policy(SyncPolicy::OnClose);
20098 // Grow the file past the managers, which is what gives the finalize
20099 // something to re-home.
20100 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20101
20102 s.finalize_persist().unwrap();
20103 let after = s.image.issued_writes();
20104 s.force_sync().unwrap();
20105 assert_eq!(
20106 s.image.issued_writes(),
20107 after,
20108 "finalize_persist returned with writes still gathered"
20109 );
20110 }
20111
20112 /// [`SpaceAccounting::reusable_free_space`] stays coalesced once an in-place
20113 /// append holds a reserve (issue #387).
20114 ///
20115 /// The reserve is drawn *out of* the session's free list, so what is left of
20116 /// the hole it came from sits immediately beside it — and as the append
20117 /// spends the reserve down, the spent end walks toward that remainder until
20118 /// the two abut exactly. Reporting them as two regions would break the
20119 /// field's documented contract ("no two regions touch or overlap") in the one
20120 /// way that misleads: a caller sizing an allocation against the largest
20121 /// region would be told nothing that big fits when it does.
20122 ///
20123 /// Sixteen chunk-sized appends, checked after every one, because the abutment
20124 /// appears only at a particular point in spending a batch rather than at the
20125 /// first append.
20126 #[test]
20127 fn the_append_reserve_is_reported_as_one_coalesced_free_list() {
20128 use crate::writer::FileBuilder;
20129 use tempfile::tempdir;
20130
20131 /// Elements of the dataset the delete vacates: three reserve batches, so
20132 /// the hole outlives several draws.
20133 const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 3) / 4;
20134 /// Elements per chunk: 64 KiB, so sixteen appends spend a whole batch.
20135 const CHUNK: u64 = 16384;
20136
20137 let dir = tempdir().unwrap();
20138 let path = dir.path().join("reserve_coalesced.h5");
20139 let mut b = FileBuilder::new();
20140 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20141 b.create_dataset("t0")
20142 .with_i32_data(&[0i32])
20143 .with_shape(&[1])
20144 .with_maxshape(&[MaxExtent::Unlimited])
20145 .with_chunks(&[CHUNK]);
20146 b.create_dataset("victim")
20147 .with_i32_data(&vec![7i32; VICTIM]);
20148 b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20149 b.write(&path).unwrap();
20150
20151 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20152 s.set_sync_policy(SyncPolicy::OnClose);
20153 s.delete(&ObjectPathBuf::parse("victim")).unwrap();
20154 s.commit().unwrap();
20155
20156 let batch: Vec<i32> = (0..CHUNK as i32).collect();
20157 let mut reserved_ever = false;
20158 for round in 0..16 {
20159 s.append_inplace_i32_phased("t0", &batch, 4).unwrap();
20160 reserved_ever |= !s.reserved.is_empty();
20161 let acct = s.space_accounting();
20162 let regions = &acct.reusable_free_space;
20163 for w in regions.windows(2) {
20164 let ((a_addr, a_len), (b_addr, _)) = (w[0], w[1]);
20165 assert!(
20166 a_addr + a_len < b_addr,
20167 "round {round}: [{a_addr}, {}) and [{b_addr}, ..) touch or overlap, \
20168 though reusable_free_space is documented as fully coalesced: {regions:?}",
20169 a_addr + a_len
20170 );
20171 }
20172 assert_eq!(
20173 acct.reusable_free_bytes,
20174 regions.iter().map(|&(_, len)| len).sum::<u64>(),
20175 "round {round}: the total must be the summed length of the regions"
20176 );
20177 }
20178 assert!(
20179 reserved_ever,
20180 "no reserve was ever held, so this measured the plain free list"
20181 );
20182 }
20183
20184 /// A persisting session gives its **unspent** append reserve back to the
20185 /// on-disk managers before it closes (issue #387).
20186 ///
20187 /// An append on such a file takes a batch out of the managers before it may
20188 /// spend any of it, and takes a whole batch however little the append needs.
20189 /// What is left over is space nothing occupies, so leaving it out of the
20190 /// managers would turn every appending session into a leak of up to
20191 /// [`APPEND_RESERVE_BYTES`] — invisible from inside the session, since its own
20192 /// accounting still counts the reserve as reusable.
20193 ///
20194 /// Measured from outside the session for that reason: the persisted free
20195 /// space after the close, against what it was before the append.
20196 #[test]
20197 fn an_unspent_append_reserve_goes_back_to_the_managers() {
20198 use crate::writer::FileBuilder;
20199 use tempfile::tempdir;
20200
20201 /// Elements of the dataset the delete below vacates. Its blocks are the
20202 /// hole the append draws from, so it has to exceed one reserve batch or
20203 /// nothing is drawn and this measures the old behaviour.
20204 const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 2) / 4;
20205
20206 let dir = tempdir().unwrap();
20207 let path = dir.path().join("reserve_return.h5");
20208 let mut b = FileBuilder::new();
20209 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20210 b.create_dataset("t0")
20211 .with_i32_data(&(0..256).collect::<Vec<_>>())
20212 .with_shape(&[256])
20213 .with_maxshape(&[MaxExtent::Unlimited])
20214 .with_chunks(&[64]);
20215 b.create_dataset("victim")
20216 .with_i32_data(&vec![7i32; VICTIM]);
20217 // Above the hole, so the delete leaves an interior region rather than a
20218 // run reaching end-of-file that the commit truncates away.
20219 b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20220 b.write(&path).unwrap();
20221
20222 let persisted = |p: &std::path::Path| -> u64 {
20223 crate::reader::File::open(p)
20224 .unwrap()
20225 .persisted_free_space()
20226 .iter()
20227 .map(|&(_, len)| len)
20228 .sum()
20229 };
20230
20231 {
20232 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20233 s.set_sync_policy(SyncPolicy::OnClose);
20234 s.delete(&ObjectPathBuf::parse("victim")).unwrap();
20235 s.commit().unwrap();
20236 }
20237 let before = persisted(&path);
20238 assert!(
20239 before > APPEND_RESERVE_BYTES,
20240 "the fixture must leave more than one reserve batch on disk, not {before} bytes"
20241 );
20242
20243 // A few kilobytes of append against a megabyte of reserve.
20244 let appended: Vec<i32> = (0..1024).collect();
20245 {
20246 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20247 s.set_sync_policy(SyncPolicy::OnClose);
20248 s.append_inplace_i32_phased("t0", &appended, 4).unwrap();
20249 s.finalize_persist().unwrap();
20250 }
20251
20252 let after = persisted(&path);
20253 // What the append actually placed: its chunks and the index blocks beside
20254 // them, plus the manager rewrite's own churn. Generous, and still an order
20255 // of magnitude under the batch that would go missing.
20256 let spent = 64 * 1024;
20257 assert!(
20258 after + spent >= before,
20259 "the session reserved {APPEND_RESERVE_BYTES} bytes and spent a few of them, \
20260 so the managers should still describe nearly all of the {before} they did \
20261 before — they describe {after}"
20262 );
20263 assert!(
20264 after < before,
20265 "the append placed bytes inside the hole, so the managers must describe \
20266 less than the {before} they did (they describe {after})"
20267 );
20268
20269 let f = crate::reader::File::open(&path).unwrap();
20270 let mut want = (0..256).collect::<Vec<i32>>();
20271 want.extend_from_slice(&appended);
20272 assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap(), want);
20273 assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), [9, 9, 9]);
20274 }
20275
20276 /// A persisting session's fixture for the draw tests: `t0` to append onto,
20277 /// `victims` datasets of `victim_elems` `i32`s each, every one followed by a
20278 /// small keeper so that deleting the victims leaves that many *separate*
20279 /// holes, and a `ceiling` above them all so none of the holes is trailing.
20280 fn fragmented_persisting_fixture(
20281 path: &std::path::Path,
20282 victims: usize,
20283 victim_elems: usize,
20284 ) -> WriteEngine {
20285 use crate::writer::FileBuilder;
20286
20287 let mut b = FileBuilder::new();
20288 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20289 b.create_dataset("t0")
20290 .with_i32_data(&[0i32])
20291 .with_shape(&[1])
20292 .with_maxshape(&[MaxExtent::Unlimited])
20293 .with_chunks(&[64]);
20294 for v in 0..victims {
20295 b.create_dataset(&std::format!("victim{v}"))
20296 .with_i32_data(&vec![7i32; victim_elems]);
20297 b.create_dataset(&std::format!("keeper{v}"))
20298 .with_i32_data(&[v as i32]);
20299 }
20300 b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20301 b.write(path).unwrap();
20302
20303 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
20304 s.set_sync_policy(SyncPolicy::OnClose);
20305 for v in 0..victims {
20306 s.delete(&ObjectPathBuf::parse(&std::format!("victim{v}")))
20307 .unwrap();
20308 }
20309 s.commit().unwrap();
20310 s
20311 }
20312
20313 /// One draw takes every hole the append fits in, not only the largest, so a
20314 /// file fragmented into small holes pays one manager rewrite for all of them
20315 /// rather than one per hole (issue #413).
20316 ///
20317 /// Three holes of a few chunks each, each far under a batch, separated by
20318 /// live objects so they cannot coalesce. One chunk-sized append then has to
20319 /// leave the reserve holding runs from more than one of them: a draw that
20320 /// took the largest hole alone would have served the chunk just as well and
20321 /// held exactly one run, so the count is what tells the two apart.
20322 #[test]
20323 fn a_draw_gathers_every_hole_the_append_fits_in() {
20324 use tempfile::tempdir;
20325
20326 let dir = tempdir().unwrap();
20327 let path = dir.path().join("draw_gathers.h5");
20328 let mut s = fragmented_persisting_fixture(&path, 3, 4 * 64);
20329 assert!(
20330 s.free.sections().len() >= 3,
20331 "the fixture must leave three separate holes, not {:?}",
20332 s.free.sections()
20333 );
20334 let before = s.free.sections();
20335
20336 s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20337
20338 let held = s.reserved.sections();
20339 // A run per hole, less what the chunk and the rewrite's tail took out of
20340 // them: the chunk out of one, the tail out of at most one more.
20341 assert!(
20342 held.len() >= 2,
20343 "one draw should have gathered runs from several of the holes {before:?}, \
20344 not only {held:?}"
20345 );
20346 let drawn: u64 = held.iter().map(|&(_, len)| len).sum();
20347 assert!(
20348 drawn > before.iter().map(|&(_, len)| len).max().unwrap(),
20349 "the reserve holds {drawn} bytes, no more than the largest hole alone"
20350 );
20351 }
20352
20353 /// The manager rewrite that publishes a draw places its own tail inside the
20354 /// space the draw is taking when the draw has emptied the free list, rather
20355 /// than appending it (issue #413).
20356 ///
20357 /// On a flat file the draw and the tail are served from the same list, and
20358 /// a draw takes every hole the append fits in, so the case is the ordinary
20359 /// one, not a corner: without the fallback every draw appended one tail at
20360 /// end-of-file and freed the previous one, a persisting file's whole growth
20361 /// under churn once the floor on the draw was gone. Measured as the file's
20362 /// length across the append, which a tail landing anywhere inside leaves
20363 /// alone.
20364 ///
20365 /// Measured as the tail's own address rather than as an unchanged length: the
20366 /// rewrite supersedes the fixture's tail, which sat at end-of-file, so
20367 /// releasing that trailing run leaves the file *shorter* than it started
20368 /// (issue #418). An appended tail would land at exactly the pre-append
20369 /// end-of-file, which is the case both assertions rule out.
20370 #[test]
20371 fn a_draws_rewrite_places_its_tail_inside_the_draw() {
20372 use tempfile::tempdir;
20373
20374 let dir = tempdir().unwrap();
20375 let path = dir.path().join("draw_tail.h5");
20376 let mut s = fragmented_persisting_fixture(&path, 1, 8 * 64);
20377 let len_before = s.image.len();
20378
20379 s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20380
20381 assert!(
20382 !s.reserved.is_empty(),
20383 "no reserve was drawn, so this measured an ordinary end-of-file append"
20384 );
20385 let ext = s.superblock.superblock_extension_address.unwrap();
20386 assert!(
20387 ext < len_before,
20388 "the rewrite appended its tail at {ext}, the pre-append end-of-file \
20389 {len_before}, instead of placing it in the hole it was publishing as taken"
20390 );
20391 assert!(
20392 s.image.len() <= len_before,
20393 "a rewrite that placed its tail inside the file must not have grown it \
20394 (was {len_before}, now {})",
20395 s.image.len()
20396 );
20397 }
20398
20399 /// One in-place append costs a small constant number of writes even where
20400 /// nothing gathers them — the case the Extensible-Array header's six
20401 /// statistics were written separately for.
20402 ///
20403 /// Under gathering, six adjacent writes and one merge into the same page
20404 /// write, so the whole suite is blind to which one the engine made. The SWMR
20405 /// writer is the regime where it still shows: it gathers nothing by design,
20406 /// so every write the engine makes is a syscall. Stated as a ceiling on the
20407 /// count rather than as an exact figure, since what an append needs is free to
20408 /// fall — and it has: for the append this measures, the first into a dataset,
20409 /// 12 before issue #307 published each checksummed structure in one write and
20410 /// 8 after; an append that reuses the data block that one allocates costs 5.
20411 /// Six statistics written singly puts it five over; a checksum written apart
20412 /// from the value it covers, four.
20413 #[test]
20414 fn an_unbuffered_append_costs_a_small_constant_number_of_writes() {
20415 use tempfile::tempdir;
20416
20417 let dir = tempdir().unwrap();
20418 let path = dir.path().join("swmr_cost.h5");
20419 gather_fixture(&path, 1, false);
20420
20421 let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20422 let before = s.image.issued_writes();
20423 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20424 let cost = s.image.issued_writes() - before;
20425
20426 assert!(
20427 cost > 0,
20428 "the append issued nothing, so the ceiling below proves nothing"
20429 );
20430 assert!(
20431 cost <= 10,
20432 "an unbuffered append costs {cost} writes; the array header's six \
20433 statistics belong in one write, not six, and each checksum belongs in \
20434 the write that changed what it covers (measured at 8)"
20435 );
20436 }
20437
20438 /// A SWMR writer gathers nothing: its readers follow the ordered phases as
20439 /// they become visible, and a phase that has not reached the operating system
20440 /// is a phase the reader cannot see.
20441 ///
20442 /// Stopping inside the durability sequence is what makes this selective. A
20443 /// completed append ends with a barrier, so it would land on disk under
20444 /// either setting; only a stop *inside* the sequence distinguishes a writer
20445 /// that gathers from one that does not. The file is read through a second handle, which SWMR
20446 /// permits precisely because it takes no lock.
20447 #[test]
20448 fn the_swmr_writer_holds_no_write_back() {
20449 use tempfile::tempdir;
20450
20451 let dir = tempdir().unwrap();
20452 let path = dir.path().join("swmr.h5");
20453 gather_fixture(&path, 1, false);
20454
20455 let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20456 let before = std::fs::read(&path).unwrap().len();
20457 // Phase 1 only: the chunk bytes and the superblock's end-of-file.
20458 s.append_inplace_i32_phased("t0", &[7; 64], 1).unwrap();
20459
20460 assert!(
20461 std::fs::read(&path).unwrap().len() > before,
20462 "a SWMR reader must see the phase-1 chunk bytes as soon as they are written"
20463 );
20464 }
20465
20466 /// The `fsync` cadence belongs to whoever the fapl says: every durability
20467 /// point in the engine — an immediate append's ordered barriers, a commit's
20468 /// barrier and repoint, the barrier a commit issues after truncating, the
20469 /// same-length-overwrite fast path, and the barrier `close` issues — answers
20470 /// to the session's [`SyncPolicy`], while `force_sync` (the explicit
20471 /// `File::sync`) answers to nobody (issue #263).
20472 ///
20473 /// The counts are compared as a table across the two policies rather than
20474 /// pinned to literals: how many `fsync`s a commit costs is an implementation
20475 /// detail that may fall, but that `OnClose` costs *none* and `Always` costs
20476 /// some at each of those points is the contract. The file is read back
20477 /// under both, since a skipped barrier must cost durability and nothing else
20478 /// — the bytes have already reached the operating system.
20479 ///
20480 /// Every stage here is a *distinct* barrier site. A commit tail that syncs
20481 /// twice does not stand in for the fast path that syncs once, nor for the
20482 /// post-truncate barrier that only a shrinking commit reaches: each is its
20483 /// own `self.barrier()` call that a later edit could regress to a bare
20484 /// `self.image.sync_all()` on its own — verified by mutating each site
20485 /// separately and watching this fail. The persisting tails and the
20486 /// consistency-flag write are covered by the test below.
20487 ///
20488 /// One site is left uncovered: the version 0/1 repoint branch of the
20489 /// non-persisting tail. A pre-v2 file cannot be produced by this crate's own
20490 /// writer — the crosscheck tests get one from the C library — and the
20491 /// counting image is a bounded one, which such a file needs the mirror
20492 /// instead of. Covering it needs a checked-in fixture and a second counting
20493 /// opener; it is named here so the gap is a known one rather than an
20494 /// assumed-covered one.
20495 #[test]
20496 fn sync_policy_governs_every_barrier() {
20497 use crate::writer::FileBuilder;
20498 use std::sync::Arc;
20499 use std::sync::atomic::{AtomicU64, Ordering};
20500 use tempfile::tempdir;
20501
20502 let dir = tempdir().unwrap();
20503 // [after an immediate append, after a staged commit, after a same-length
20504 // overwrite, after a commit that truncates, after the close barrier,
20505 // after an explicit sync].
20506 let run = |name: &str, policy: SyncPolicy| -> [u64; 5] {
20507 let path = dir.path().join(name);
20508 let mut b = FileBuilder::new();
20509 b.create_dataset("d")
20510 .with_i32_data(&(0..8).collect::<Vec<_>>())
20511 .with_shape(&[8])
20512 .with_maxshape(&[MaxExtent::Unlimited])
20513 .with_chunks(&[4]);
20514 b.write(&path).unwrap();
20515
20516 let syncs = Arc::new(AtomicU64::new(0));
20517 let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20518 s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20519 .unwrap();
20520 let after_append = syncs.load(Ordering::Relaxed);
20521 let mut db = crate::type_builders::DatasetBuilder::new("added");
20522 db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
20523 s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
20524 .unwrap();
20525 s.commit().unwrap();
20526 let after_commit = syncs.load(Ordering::Relaxed);
20527
20528 // Same-length value overwrite: the commit fast path, which patches
20529 // the bytes where they lie and syncs without repointing anything.
20530 let mut ow = crate::type_builders::DatasetBuilder::new("added");
20531 ow.with_f64_data(&[4.5f64; 8]).with_shape(&[8]);
20532 s.stage_dataset_write(&ObjectPathBuf::parse("/added"), ow)
20533 .unwrap();
20534 s.commit().unwrap();
20535 let after_overwrite = syncs.load(Ordering::Relaxed);
20536
20537 // A delete whose freed run reaches end-of-file, so the commit
20538 // truncates and takes the barrier that only a shrinking commit does.
20539 s.delete(&ObjectPathBuf::parse("/added")).unwrap();
20540 s.commit().unwrap();
20541 let after_truncate = syncs.load(Ordering::Relaxed);
20542
20543 s.force_sync().unwrap();
20544 let after_forced = syncs.load(Ordering::Relaxed);
20545
20546 // Release the exclusive OS lock before reading the file back; those
20547 // locks are mandatory on Windows.
20548 drop(s);
20549 let f = crate::reader::File::open(&path).unwrap();
20550 assert_eq!(
20551 f.dataset("d").unwrap().read_i32().unwrap(),
20552 (0..12).collect::<Vec<_>>(),
20553 "the append must land under {policy:?}"
20554 );
20555 assert!(
20556 f.dataset("added").is_err(),
20557 "the deleting commit must land under {policy:?}"
20558 );
20559 [
20560 after_append,
20561 after_commit,
20562 after_overwrite,
20563 after_truncate,
20564 after_forced,
20565 ]
20566 };
20567
20568 let always = run("always.h5", SyncPolicy::Always);
20569 assert!(always[0] > 0, "an immediate append syncs under Always");
20570 assert!(always[1] > always[0], "so does a commit");
20571 assert!(
20572 always[2] > always[1],
20573 "so does the same-length-overwrite fast path"
20574 );
20575 assert!(
20576 always[3] > always[2],
20577 "so does a commit that truncates the file"
20578 );
20579 assert_eq!(
20580 always[4],
20581 always[3] + 1,
20582 "and a forced sync is exactly one more"
20583 );
20584
20585 let deferred = run("on_close.h5", SyncPolicy::OnClose);
20586 assert_eq!(
20587 &deferred[..4],
20588 &[0, 0, 0, 0],
20589 "OnClose must leave every one of those in-session barriers unissued"
20590 );
20591 assert_eq!(
20592 deferred[4], 1,
20593 "a forced sync is issued whatever the policy says — it is what the \
20594 teardown path and File::sync both take"
20595 );
20596 }
20597
20598 /// The two barrier sites the table above cannot reach: the commit tail of a
20599 /// file that *persists* its free space (a different tail, with its own
20600 /// barrier and repoint, plus the manager re-homing `close` owes), and the
20601 /// superblock consistency-flag write a SWMR session makes on open and close.
20602 ///
20603 /// Both are `self.barrier()`/`self.barrier_data()` calls on paths the
20604 /// ordinary non-persisting session never executes, so without this a
20605 /// regression at either would pass the whole suite (issue #263).
20606 #[test]
20607 fn sync_policy_governs_the_persisting_and_flag_barriers() {
20608 use crate::writer::FileBuilder;
20609 use std::sync::Arc;
20610 use std::sync::atomic::{AtomicU64, Ordering};
20611 use tempfile::tempdir;
20612
20613 let dir = tempdir().unwrap();
20614 // [after a commit on a persisting file, after the flag write, after the
20615 // close barrier and its manager re-homing].
20616 let run = |name: &str, strategy: FileSpaceStrategy, policy: SyncPolicy| -> [u64; 3] {
20617 let path = dir.path().join(name);
20618 let mut b = FileBuilder::new();
20619 b.create_dataset("d")
20620 .with_i32_data(&(0..8).collect::<Vec<_>>())
20621 .with_shape(&[8])
20622 .with_maxshape(&[MaxExtent::Unlimited])
20623 .with_chunks(&[4]);
20624 b.create_dataset("victim")
20625 .with_f64_data(&[1.5f64; 64])
20626 .with_shape(&[64]);
20627 // A paged file takes the page-aware tail, a different pair of barrier
20628 // calls from the plain persisting one; `0` asks for the default page
20629 // size, which the plain strategy ignores.
20630 b.with_file_space_strategy(
20631 strategy,
20632 true,
20633 if strategy == FileSpaceStrategy::Page {
20634 0
20635 } else {
20636 1
20637 },
20638 );
20639 b.write(&path).unwrap();
20640
20641 let syncs = Arc::new(AtomicU64::new(0));
20642 let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20643 assert!(
20644 s.persist.is_some(),
20645 "the fixture must persist its free space, or this tests the wrong tail"
20646 );
20647 // A delete on a persisting file takes `commit_persisting`: the free
20648 // space is recorded on disk rather than truncated away.
20649 s.delete(&ObjectPathBuf::parse("/victim")).unwrap();
20650 s.commit().unwrap();
20651 let after_commit = syncs.load(Ordering::Relaxed);
20652
20653 s.set_consistency_flags(0).unwrap();
20654 let after_flags = syncs.load(Ordering::Relaxed);
20655 // The flag write's barrier orders as well as syncs, like the other
20656 // two. Its production callers all force a sync straight after, which
20657 // is why nothing else would notice it stopping — the same reason
20658 // `finalize_persist_has_nothing_left_to_write` exists.
20659 let issued = s.image.issued_writes();
20660 s.force_sync().unwrap();
20661 assert_eq!(
20662 s.image.issued_writes(),
20663 issued,
20664 "{policy:?}: the consistency-flag write was left gathered"
20665 );
20666
20667 // An immediate append leaves the on-disk managers mid-file, which is
20668 // the debt `finalize_persist` settles with a second commit tail at
20669 // close — the writes no earlier `sync` can have covered.
20670 s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20671 .unwrap();
20672 let before_close = syncs.load(Ordering::Relaxed);
20673 s.finalize_persist().unwrap();
20674 let after_close = syncs.load(Ordering::Relaxed) - before_close;
20675
20676 drop(s);
20677 let f = crate::reader::File::open(&path).unwrap();
20678 assert!(
20679 f.dataset("victim").is_err(),
20680 "the delete must land under {policy:?}"
20681 );
20682 assert_eq!(
20683 f.dataset("d").unwrap().read_i32().unwrap(),
20684 (0..12).collect::<Vec<_>>(),
20685 "and so must the append under {policy:?}"
20686 );
20687 [after_commit, after_flags, after_close]
20688 };
20689
20690 // Both persisting tails: the plain one and the page-aware one, which is a
20691 // separate pair of barrier calls reached only by a paged file.
20692 for (label, strategy) in [
20693 ("fsmaggr", FileSpaceStrategy::FsmAggr),
20694 ("paged", FileSpaceStrategy::Page),
20695 ] {
20696 let always = run(&format!("{label}_always.h5"), strategy, SyncPolicy::Always);
20697 assert!(
20698 always[0] > 0,
20699 "the {label} persisting commit tail syncs under Always"
20700 );
20701 assert!(
20702 always[1] > always[0],
20703 "so does the consistency-flag write a SWMR session makes ({label})"
20704 );
20705 assert!(
20706 always[2] > 0,
20707 "so does the manager re-homing close owes ({label})"
20708 );
20709
20710 assert_eq!(
20711 run(
20712 &format!("{label}_on_close.h5"),
20713 strategy,
20714 SyncPolicy::OnClose
20715 ),
20716 [0, 0, 0],
20717 "OnClose must leave the {label} persisting tail, the flag write, and the \
20718 close-time manager re-homing with no fsync at all"
20719 );
20720 }
20721 }
20722
20723 /// A write that alters the file and *then* reports failure is a real device
20724 /// behaviour — it is the one `TornWriteImage` models — so a `write_at`
20725 /// returning an error does not mean its bytes missed the disk. When the
20726 /// write in question is the superblock repoint, that makes "did this commit
20727 /// publish?" unanswerable from inside the engine, and every rollback
20728 /// unsound: undoing the values would tear a commit that did land.
20729 ///
20730 /// So the flag that gates the rollback is raised *before* the publish is
20731 /// issued, not after it returns, and this pins that. The commit here does
20732 /// publish — `/extra` is readable afterwards, which is only possible
20733 /// through the new root — while its `commit` returns the write's error.
20734 /// Rolling back under it would leave `/nums` holding the pre-commit value
20735 /// inside a tree that is otherwise entirely the new one (issue #344).
20736 #[test]
20737 fn a_commit_whose_publish_write_fails_rolls_nothing_back() {
20738 use crate::writer::FileBuilder;
20739 use tempfile::tempdir;
20740
20741 let dir = tempdir().unwrap();
20742 let path = dir.path().join("torn_publish.h5");
20743 let mut b = FileBuilder::new();
20744 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20745 b.write(&path).unwrap();
20746
20747 // The superblock sits at offset 0 on a file with no userblock.
20748 let mut s = WriteEngine::open_torn_writes(&path, 0..48).unwrap();
20749 s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
20750 let mut db = crate::type_builders::DatasetBuilder::new("");
20751 db.with_i32_data(&[9, 9, 9]);
20752 db
20753 })
20754 .unwrap();
20755 s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20756 let mut db = crate::type_builders::DatasetBuilder::new("");
20757 db.with_i32_data(&[42]);
20758 db
20759 })
20760 .unwrap();
20761 let failed = s.commit();
20762 assert!(
20763 matches!(failed, Err(Error::Io(_))),
20764 "the publish write is what failed: {failed:?}"
20765 );
20766 drop(s);
20767
20768 let f = crate::reader::File::open(&path).unwrap();
20769 assert_eq!(
20770 f.dataset("extra").unwrap().read_i32().unwrap(),
20771 vec![42],
20772 "the publish landed despite reporting failure, so this commit is live"
20773 );
20774 assert_eq!(
20775 f.dataset("nums").unwrap().read_i32().unwrap(),
20776 vec![9, 9, 9],
20777 "a rollback under a commit that did publish would tear it"
20778 );
20779 }
20780
20781 /// Two hard links to one dataset are two paths over one data block, and the
20782 /// commit's duplicate guard is by path — so one commit can journal the same
20783 /// address twice, the second entry holding what the first write put there.
20784 /// The undo therefore has to replay newest-first; oldest-first finishes on a
20785 /// value from the very batch being rolled back (issue #344).
20786 ///
20787 /// A same-length overwrite through either link is deliberately allowed —
20788 /// it rewrites the block every link sees, so unlike a relocating write it
20789 /// needs no single-hard-link rule (`write_dataset_shared_hard_link_crosscheck`
20790 /// is the interop half of that). Which is what makes this reachable.
20791 ///
20792 /// The second link is made with the reference C library because this crate
20793 /// has no API that creates one: the file is a committed one, see
20794 /// `crates/crosscheck/tests/main/c_test_data.rs`.
20795 #[test]
20796 fn an_undo_replays_two_hard_links_to_one_block_newest_first() {
20797 use tempfile::tempdir;
20798
20799 let dir = tempdir().unwrap();
20800 let path = crate::test_data::copy("c/hard_link_undo.h5", dir.path());
20801
20802 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20803 // Fail the commit in its tail, after both writes have landed.
20804 s.superblock.superblock_extension_address = Some(0);
20805 for (name, data) in [("/aa", [9, 9, 9]), ("/bb", [8, 8, 8])] {
20806 s.stage_dataset_write(&ObjectPathBuf::parse(name), {
20807 let mut db = crate::type_builders::DatasetBuilder::new("");
20808 db.with_i32_data(&data);
20809 db
20810 })
20811 .unwrap();
20812 }
20813 // A second kind of edit, so the batch takes the full commit path and has
20814 // a tail to fail in.
20815 s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20816 let mut db = crate::type_builders::DatasetBuilder::new("");
20817 db.with_i32_data(&[42]);
20818 db
20819 })
20820 .unwrap();
20821 assert!(s.commit().is_err());
20822 drop(s);
20823
20824 let f = crate::reader::File::open(&path).unwrap();
20825 assert_eq!(
20826 f.dataset("aa").unwrap().read_i32().unwrap(),
20827 vec![1, 2, 3],
20828 "the block both links share must hold the value it held before"
20829 );
20830 assert_eq!(f.dataset("bb").unwrap().read_i32().unwrap(), vec![1, 2, 3]);
20831 }
20832
20833 /// The chunked in-place path (`WritePlan::InPlaceChunks`) journals one entry
20834 /// per chunk, and — when the chunks shrink — the rewritten chunk index
20835 /// beside them. A partial restore would leave the index recording the new
20836 /// sizes against the old bytes, so this is the case where the rollback has
20837 /// most to put back (issue #344).
20838 ///
20839 /// Filtered, and overwritten with data that compresses to almost nothing, so
20840 /// every chunk's recorded size changes and the index write is part of what
20841 /// is rolled back rather than an unchanged block.
20842 #[test]
20843 fn a_failed_commit_puts_back_every_chunk_it_overwrote() {
20844 use crate::writer::FileBuilder;
20845 use tempfile::tempdir;
20846
20847 let dir = tempdir().unwrap();
20848 let path = dir.path().join("chunked_undo.h5");
20849 let original: Vec<i32> = (0..4096i32)
20850 .map(|i| i.wrapping_mul(2_654_435_761u32 as i32) ^ i)
20851 .collect();
20852 let mut b = FileBuilder::new();
20853 b.create_dataset("grid")
20854 .with_i32_data(&original)
20855 .with_shape(&[4096])
20856 .with_chunks(&[512])
20857 .with_deflate(6);
20858 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20859 b.write(&path).unwrap();
20860
20861 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20862 s.superblock.superblock_extension_address = Some(0);
20863 s.stage_dataset_write(&ObjectPathBuf::parse("/grid"), {
20864 // Values only: an overwrite that restated the chunking or the
20865 // filter would be refused as asking for more than an overwrite
20866 // (issue #318). The layout comes from the dataset already there.
20867 let mut db = crate::type_builders::DatasetBuilder::new("");
20868 db.with_i32_data(&vec![0i32; 4096]);
20869 db
20870 })
20871 .unwrap();
20872 s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20873 let mut db = crate::type_builders::DatasetBuilder::new("");
20874 db.with_i32_data(&[42]);
20875 db
20876 })
20877 .unwrap();
20878 assert!(s.commit().is_err());
20879 drop(s);
20880
20881 let f = crate::reader::File::open(&path).unwrap();
20882 assert_eq!(
20883 f.dataset("grid").unwrap().read_i32().unwrap(),
20884 original,
20885 "every chunk, and the index sizing them, must read as it did before"
20886 );
20887 }
20888
20889 /// A commit clears the journal on entry, so it can never replay entries it
20890 /// did not write itself (issue #344).
20891 ///
20892 /// Only an attempt that *unwound* leaves any — a panic caught by the session
20893 /// lock, which recovers from poisoning — and the state it leaves is what
20894 /// this constructs directly rather than by finding something to panic in:
20895 /// an entry naming an address whose contents have moved on. Replaying it
20896 /// would write bytes captured before the unwind over whatever lives there
20897 /// now, and the entry clear on the way *out* cannot help, because that is
20898 /// the step the unwind skipped.
20899 ///
20900 /// `superseded_heaps` two fields up clears at entry for the same reason and
20901 /// says so; this is that rule applied to the journal.
20902 #[test]
20903 fn a_commit_does_not_replay_a_journal_it_did_not_write() {
20904 use crate::writer::FileBuilder;
20905 use tempfile::tempdir;
20906
20907 let dir = tempdir().unwrap();
20908 let path = dir.path().join("stale_journal.h5");
20909 let mut b = FileBuilder::new();
20910 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20911 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20912 b.write(&path).unwrap();
20913
20914 let nums_block = {
20915 let f = crate::reader::File::open(&path).unwrap();
20916 match f.dataset("nums").unwrap().layout().unwrap() {
20917 crate::Layout::Contiguous {
20918 address: Some(a), ..
20919 } => a,
20920 other => panic!("expected a contiguous dataset: {other:?}"),
20921 }
20922 };
20923
20924 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20925 // Exactly what an attempt that unwound past `commit` leaves behind.
20926 let stale: Vec<u8> = [7i32, 7, 7].iter().flat_map(|v| v.to_le_bytes()).collect();
20927 s.inplace_undo.push((nums_block, stale));
20928
20929 // An unrelated commit, failed in its tail so that it rolls back at all.
20930 s.superblock.superblock_extension_address = Some(0);
20931 s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20932 let mut db = crate::type_builders::DatasetBuilder::new("");
20933 db.with_i32_data(&[42]);
20934 db
20935 })
20936 .unwrap();
20937 assert!(s.commit().is_err());
20938 drop(s);
20939
20940 let f = crate::reader::File::open(&path).unwrap();
20941 assert_eq!(
20942 f.dataset("nums").unwrap().read_i32().unwrap(),
20943 vec![1, 2, 3],
20944 "the commit replayed a journal entry that was not its own"
20945 );
20946 }
20947
20948 /// Putting the values back is a write like any other, so it has to be
20949 /// ordered like one: a restore left sitting in the gathering buffer can be
20950 /// issued after a later write to the same block, which is the reordering
20951 /// hazard write gathering introduced (issue #288).
20952 ///
20953 /// Measured as a difference rather than a total, so it states the rule and
20954 /// not one commit's arithmetic: a refused commit with nothing to put back
20955 /// issues no barrier at all — it fails before the one it would have made —
20956 /// and the same refusal carrying a value overwrite issues exactly the one
20957 /// that orders the restore.
20958 #[test]
20959 fn a_rollback_orders_the_values_it_puts_back() {
20960 use crate::writer::FileBuilder;
20961 use std::sync::Arc;
20962 use std::sync::atomic::{AtomicU64, Ordering};
20963 use tempfile::tempdir;
20964
20965 let mut counted = Vec::new();
20966 for with_overwrite in [false, true] {
20967 let dir = tempdir().unwrap();
20968 let path = dir.path().join("rollback_syncs.h5");
20969 let mut b = FileBuilder::new();
20970 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20971 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20972 b.write(&path).unwrap();
20973
20974 let syncs = Arc::new(AtomicU64::new(0));
20975 let mut s =
20976 WriteEngine::open_sync_counting(&path, SyncPolicy::Always, Arc::clone(&syncs))
20977 .unwrap();
20978 // Fail the commit in its tail, after the apply phase has written.
20979 s.superblock.superblock_extension_address = Some(0);
20980 if with_overwrite {
20981 s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
20982 let mut db = crate::type_builders::DatasetBuilder::new("");
20983 db.with_i32_data(&[9, 9, 9]);
20984 db
20985 })
20986 .unwrap();
20987 }
20988 s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20989 let mut db = crate::type_builders::DatasetBuilder::new("");
20990 db.with_i32_data(&[42]);
20991 db
20992 })
20993 .unwrap();
20994 let before = syncs.load(Ordering::Relaxed);
20995 assert!(s.commit().is_err());
20996 counted.push(syncs.load(Ordering::Relaxed) - before);
20997 }
20998
20999 assert_eq!(
21000 counted[0], 0,
21001 "a refusal with nothing to put back reaches no barrier"
21002 );
21003 assert_eq!(
21004 counted[1],
21005 counted[0] + 1,
21006 "a refusal that put values back must order them: {counted:?}"
21007 );
21008 }
21009
21010 /// The journal reads back what it is about to overwrite, so a bounded
21011 /// commit's reads scale with the value being replaced rather than staying
21012 /// near the metadata-only figure
21013 /// [`a_bounded_commit_reads_far_less_than_the_file`] pins for a commit that
21014 /// overwrites nothing.
21015 ///
21016 /// This is the cost of the guarantee in issue #344 and it is charged even
21017 /// when the commit succeeds, since whether the bytes will be needed is not
21018 /// known until the attempt ends. Stated as a rule — reads land between the
21019 /// payload and a small multiple of it — rather than as one target's exact
21020 /// count, so it holds wherever the suite runs.
21021 #[test]
21022 fn a_bounded_commit_reads_the_value_it_is_replacing() {
21023 use crate::writer::FileBuilder;
21024 use std::sync::Arc;
21025 use std::sync::atomic::{AtomicU64, Ordering};
21026 use tempfile::tempdir;
21027
21028 let dir = tempdir().unwrap();
21029 let p = dir.path().join("journal_reads.h5");
21030 let data: Vec<i32> = (0..250_000).collect();
21031 let payload = (data.len() * 4) as u64;
21032 let mut b = FileBuilder::new();
21033 b.create_dataset("nums").with_i32_data(&data);
21034 b.write(&p).unwrap();
21035
21036 let read_bytes = Arc::new(AtomicU64::new(0));
21037 let mut engine = WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
21038 engine
21039 .stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
21040 let mut db = crate::type_builders::DatasetBuilder::new("");
21041 db.with_i32_data(&data);
21042 db
21043 })
21044 .unwrap();
21045 let before = read_bytes.load(Ordering::Relaxed);
21046 engine.commit().unwrap();
21047 let read = read_bytes.load(Ordering::Relaxed) - before;
21048
21049 assert!(
21050 read >= payload,
21051 "the journal must read the whole value it replaces: {read} of {payload}"
21052 );
21053 assert!(
21054 read < payload + (64 << 10),
21055 "and not much more than it: {read} against {payload}"
21056 );
21057 }
21058}
21059
21060#[cfg(test)]
21061mod staged_query_tests {
21062 //! The queries a handle asks about objects a session has staged and not
21063 //! committed (issue #392).
21064
21065 use rstest::rstest;
21066 use tempfile::tempdir;
21067
21068 use super::*;
21069 use crate::object_path::ObjectPath;
21070 use crate::type_builders::DatasetBuilder;
21071
21072 /// A session over a file holding one dataset, `existing`.
21073 fn open_session(path: &Path) -> WriteEngine {
21074 let mut b = crate::writer::FileBuilder::new();
21075 b.create_dataset("existing").with_i32_data(&[1, 2, 3]);
21076 b.write(path).unwrap();
21077 WriteEngine::open_rw_with_strategy(
21078 path,
21079 crate::source::MetadataCacheConfig::disabled(),
21080 FileLocking::Enabled,
21081 MemoryStrategy::Mirrored,
21082 )
21083 .unwrap()
21084 }
21085
21086 fn i32_dataset(data: &[i32]) -> DatasetBuilder {
21087 let mut b = DatasetBuilder::new("");
21088 b.with_i32_data(data);
21089 b
21090 }
21091
21092 fn kind(e: &WriteEngine, path: &str) -> Option<StagedKind> {
21093 e.staged_object(&ObjectPathBuf::parse(path)).map(|o| o.kind)
21094 }
21095
21096 fn child_names(e: &WriteEngine, parent: &str) -> Vec<(String, StagedKind)> {
21097 e.staged_children(&ObjectPathBuf::parse(parent))
21098 .into_iter()
21099 .map(|c| (c.name, c.kind))
21100 .collect()
21101 }
21102
21103 #[test]
21104 fn a_staged_creation_is_named_at_every_level_of_its_path() {
21105 let dir = tempdir().unwrap();
21106 let mut e = open_session(&dir.path().join("q.h5"));
21107 e.create_group(&ObjectPathBuf::parse("a")).unwrap();
21108 let mut col = DatasetBuilder::new("");
21109 col.with_i32_data(&[1, 2])
21110 .with_maxshape(&[MaxExtent::Unlimited])
21111 .with_chunks(&[2]);
21112 e.stage_created_dataset(&ObjectPathBuf::parse("a/b/col"), col)
21113 .unwrap();
21114
21115 assert_eq!(kind(&e, "a"), Some(StagedKind::Group));
21116 assert_eq!(kind(&e, "a/b/col"), Some(StagedKind::Dataset));
21117 // Nothing stages `a/b`, and a commit builds only what the session
21118 // staged, so a batch reaching through `a/b` needs it staged at its own
21119 // path.
21120 assert_eq!(kind(&e, "a/b"), None);
21121 // The root always exists, an on-disk object is not staged, and neither
21122 // is a name nothing reaches.
21123 assert_eq!(kind(&e, ""), None);
21124 assert_eq!(kind(&e, "existing"), None);
21125 assert_eq!(kind(&e, "a/missing"), None);
21126
21127 assert_eq!(
21128 child_names(&e, ""),
21129 vec![("a".to_string(), StagedKind::Group)]
21130 );
21131 assert_eq!(
21132 child_names(&e, "a/b"),
21133 vec![("col".to_string(), StagedKind::Dataset)]
21134 );
21135 // `b` under `a` is the unnamed intermediate again.
21136 assert!(child_names(&e, "a").is_empty());
21137 assert!(child_names(&e, "existing").is_empty());
21138
21139 // Named outright, it answers at every level.
21140 e.create_group(&ObjectPathBuf::parse("a/b")).unwrap();
21141 assert_eq!(kind(&e, "a/b"), Some(StagedKind::Group));
21142 assert_eq!(
21143 child_names(&e, "a"),
21144 vec![("b".to_string(), StagedKind::Group)]
21145 );
21146 }
21147
21148 #[test]
21149 fn a_staged_dataset_reports_what_its_builder_settled() {
21150 let dir = tempdir().unwrap();
21151 let mut e = open_session(&dir.path().join("q.h5"));
21152 let mut col = DatasetBuilder::new("");
21153 col.with_i32_data(&[1, 2, 3, 4])
21154 .with_maxshape(&[MaxExtent::Unlimited])
21155 .with_chunks(&[2])
21156 .with_deflate(4);
21157 e.stage_created_dataset(&ObjectPathBuf::parse("col"), col)
21158 .unwrap();
21159 e.stage_created_dataset(&ObjectPathBuf::parse("plain"), i32_dataset(&[5]))
21160 .unwrap();
21161
21162 let meta = e.staged_dataset_meta(&ObjectPathBuf::parse("col")).unwrap();
21163 assert_eq!(meta.dimensions, vec![4]);
21164 assert_eq!(meta.maxshape, Some(vec![MaxExtent::Unlimited]));
21165 assert_eq!(meta.datatype.type_size(), 4);
21166 assert!(meta.chunked);
21167 assert_eq!(meta.filters, vec![(1u16, false)]);
21168
21169 // A fixed-shape, unfiltered dataset is contiguous and reports no
21170 // maximum, the way `Dataset::maxshape` reports an on-disk one.
21171 let plain = e
21172 .staged_dataset_meta(&ObjectPathBuf::parse("plain"))
21173 .unwrap();
21174 assert_eq!(plain.dimensions, vec![1]);
21175 assert_eq!(plain.maxshape, None);
21176 assert!(!plain.chunked);
21177 assert!(plain.filters.is_empty());
21178
21179 // Only datasets answer.
21180 e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21181 assert!(e.staged_dataset_meta(&ObjectPathBuf::parse("g")).is_none());
21182 assert!(
21183 e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21184 .is_none()
21185 );
21186 }
21187
21188 #[test]
21189 fn a_deletion_hides_nothing_until_a_creation_replaces_it() {
21190 let dir = tempdir().unwrap();
21191 let mut e = open_session(&dir.path().join("q.h5"));
21192 e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21193 // The object is still in the file, and still readable, so a handle must
21194 // not be told it is staged.
21195 assert_eq!(kind(&e, "existing"), None);
21196 assert!(e.staged_children(&ObjectPathBuf::parse("")).is_empty());
21197
21198 e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21199 .unwrap();
21200 let staged = e.staged_object(&ObjectPathBuf::parse("existing")).unwrap();
21201 assert_eq!(staged.kind, StagedKind::Dataset);
21202 assert!(staged.replaces_link, "the same commit removes the link");
21203 assert_eq!(
21204 e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21205 .unwrap()
21206 .dimensions,
21207 vec![1]
21208 );
21209 assert!(e.staged_children(&ObjectPathBuf::parse(""))[0].replaces_link);
21210 }
21211
21212 #[test]
21213 fn a_creation_colliding_with_a_surviving_link_is_refused_where_it_is_staged() {
21214 let dir = tempdir().unwrap();
21215 let mut e = open_session(&dir.path().join("q.h5"));
21216 // No deletion beside it, so this creation collides with a link the
21217 // commit keeps. That is the commit's own refusal, raised where the call
21218 // is made so no handle onto the creation is ever handed back: such a
21219 // handle would have addressed the file's `existing` while claiming to
21220 // address the new one.
21221 let err = e
21222 .stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21223 .unwrap_err();
21224 assert!(
21225 matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21226 "got: {err}"
21227 );
21228 // The same rule for a group, which is also the kind change a listing
21229 // would otherwise misreport as turning a dataset into a group.
21230 assert!(e.create_group(&ObjectPathBuf::parse("existing")).is_err());
21231
21232 // Nothing was staged, so the file's own object is still what the name
21233 // means, and the session has nothing left to refuse at commit time.
21234 assert_eq!(kind(&e, "existing"), None);
21235 assert!(
21236 e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21237 .is_none()
21238 );
21239 assert!(e.staged_children(&ObjectPathBuf::parse("")).is_empty());
21240 e.commit().unwrap();
21241 }
21242
21243 #[test]
21244 fn a_second_creation_at_a_staged_path_is_refused_where_it_is_staged() {
21245 let dir = tempdir().unwrap();
21246 let mut e = open_session(&dir.path().join("q.h5"));
21247 e.stage_created_dataset(&ObjectPathBuf::parse("fresh"), i32_dataset(&[1, 2, 3]))
21248 .unwrap();
21249
21250 // The staged set is indexed by path and keeps the first record there, so
21251 // a second creation would be staged behind the first and the handle this
21252 // call hands back would answer for the first one's shape and datatype.
21253 let err = e
21254 .stage_created_dataset(&ObjectPathBuf::parse("fresh"), i32_dataset(&[9]))
21255 .unwrap_err();
21256 assert!(
21257 matches!(&err, Error::EditUnsupported(m) if m.contains("already stages")),
21258 "got: {err}"
21259 );
21260 // Refused, not replaced: the first creation is exactly as it was, and it
21261 // is still the only thing staged at that name.
21262 assert_eq!(
21263 e.staged_dataset_meta(&ObjectPathBuf::parse("fresh"))
21264 .unwrap()
21265 .dimensions,
21266 vec![3]
21267 );
21268 assert_eq!(
21269 child_names(&e, ""),
21270 vec![("fresh".to_string(), StagedKind::Dataset)]
21271 );
21272
21273 // The two kinds collide with each other for the same reason: one path
21274 // cannot name both, and whichever the index found first is what every
21275 // handle onto it would report.
21276 assert!(e.create_group(&ObjectPathBuf::parse("fresh")).is_err());
21277 e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21278 assert!(
21279 e.stage_created_dataset(&ObjectPathBuf::parse("g"), i32_dataset(&[9]))
21280 .is_err()
21281 );
21282 assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21283
21284 e.commit().unwrap();
21285 }
21286
21287 #[test]
21288 fn a_replacement_is_staged_once_and_a_withdrawal_frees_the_name_again() {
21289 let dir = tempdir().unwrap();
21290 let mut e = open_session(&dir.path().join("q.h5"));
21291 // A creation over a link this session deletes is a replacement, which
21292 // the file arm admits — but only one of them, since the second would
21293 // still be staged behind the first.
21294 e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21295 e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21296 .unwrap();
21297 assert!(
21298 e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[8, 8]))
21299 .is_err()
21300 );
21301 assert_eq!(
21302 e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21303 .unwrap()
21304 .dimensions,
21305 vec![1]
21306 );
21307
21308 // Deleting a staged creation withdraws it, which is what puts the name
21309 // back within reach of another creation.
21310 e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21311 e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[8, 8]))
21312 .unwrap();
21313 assert_eq!(
21314 e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21315 .unwrap()
21316 .dimensions,
21317 vec![2]
21318 );
21319 e.commit().unwrap();
21320 }
21321
21322 #[test]
21323 fn a_group_staged_twice_is_one_group_and_stays_allowed() {
21324 let dir = tempdir().unwrap();
21325 let mut e = open_session(&dir.path().join("q.h5"));
21326 // Two group creations at one path name one node: the commit builds a
21327 // single group from them, so a handle onto either addresses it and
21328 // nothing is misreported. Re-staging is how attributes and children are
21329 // added to a group already staged.
21330 e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21331 e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21332 assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21333 assert_eq!(
21334 child_names(&e, ""),
21335 vec![("g".to_string(), StagedKind::Group)]
21336 );
21337 e.stage_created_dataset(&ObjectPathBuf::parse("g/inner"), i32_dataset(&[7]))
21338 .unwrap();
21339 e.commit().unwrap();
21340 }
21341
21342 #[test]
21343 fn a_creation_under_a_deleted_group_the_session_does_not_rebuild_shadows_nothing() {
21344 // `delete("g")` carries `g/inner` away with it, but only a commit that
21345 // builds `g` again can put anything back under that path — and this one
21346 // does not, so the commit refuses the batch. Until then the file's own
21347 // `g/inner` is what the name means: calling the staged creation beside
21348 // it a replacement would make a live, readable dataset answer
21349 // `NotCommitted` and vanish from its group's listing (issue #392).
21350 let dir = tempdir().unwrap();
21351 let path = dir.path().join("prefix.h5");
21352 let mut b = crate::writer::FileBuilder::new();
21353 let mut g = b.create_group("g");
21354 g.create_dataset("inner").with_i32_data(&[1]);
21355 b.add_group(g.finish());
21356 b.write(&path).unwrap();
21357
21358 let mut e = WriteEngine::open_rw_with_strategy(
21359 &path,
21360 crate::source::MetadataCacheConfig::disabled(),
21361 FileLocking::Enabled,
21362 MemoryStrategy::Mirrored,
21363 )
21364 .unwrap();
21365 e.delete(&ObjectPathBuf::parse("g")).unwrap();
21366 // The name `g/inner` is still taken: a deletion of `g` alone hands it
21367 // over to nobody, so this is the collision it looks like rather than a
21368 // replacement.
21369 let err = e
21370 .stage_created_dataset(&ObjectPathBuf::parse("g/inner"), i32_dataset(&[9]))
21371 .unwrap_err();
21372 assert!(
21373 matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21374 "got: {err}"
21375 );
21376 assert_eq!(kind(&e, "g/inner"), None, "the file's own dataset");
21377
21378 // A name the file does not hold stages, and is not a replacement of
21379 // anything; the commit refuses the batch for the overlap itself.
21380 e.stage_created_dataset(&ObjectPathBuf::parse("g/other"), i32_dataset(&[9]))
21381 .unwrap();
21382 assert!(
21383 !e.staged_children(&ObjectPathBuf::parse("g"))[0].replaces_link,
21384 "nothing rebuilds `g`, so its names are not handed over"
21385 );
21386 assert!(e.commit().is_err(), "a deletion overlapping an addition");
21387 }
21388
21389 #[test]
21390 fn a_deleted_group_rebuilt_in_the_same_commit_hands_its_names_over() {
21391 // The other side of that rule: `g` is replaced, so a creation under it
21392 // owns its name from the moment it is staged and the object the commit
21393 // removes is no longer what the path means.
21394 let dir = tempdir().unwrap();
21395 let path = dir.path().join("replaced.h5");
21396 let mut b = crate::writer::FileBuilder::new();
21397 let mut g = b.create_group("g");
21398 g.create_dataset("inner").with_i32_data(&[1]);
21399 b.add_group(g.finish());
21400 b.write(&path).unwrap();
21401
21402 let mut e = WriteEngine::open_rw_with_strategy(
21403 &path,
21404 crate::source::MetadataCacheConfig::disabled(),
21405 FileLocking::Enabled,
21406 MemoryStrategy::Mirrored,
21407 )
21408 .unwrap();
21409 e.delete(&ObjectPathBuf::parse("g")).unwrap();
21410 e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21411 e.stage_created_dataset(&ObjectPathBuf::parse("g/inner"), i32_dataset(&[9]))
21412 .unwrap();
21413 assert_eq!(kind(&e, "g/inner"), Some(StagedKind::Dataset));
21414 assert!(
21415 e.staged_object(&ObjectPathBuf::parse("g/inner"))
21416 .unwrap()
21417 .replaces_link
21418 );
21419 assert!(e.staged_children(&ObjectPathBuf::parse("g"))[0].replaces_link);
21420 e.commit().unwrap();
21421 }
21422
21423 #[rstest]
21424 #[case("")]
21425 #[case("/")]
21426 #[case(".")]
21427 fn the_root_cannot_be_deleted(#[case] spelling: &str) {
21428 // Nothing links to the root, so there is no link to remove — and an
21429 // empty path is a prefix of every other, so a deletion staged there
21430 // would make every creation in the session look like a replacement.
21431 let dir = tempdir().unwrap();
21432 let mut e = open_session(&dir.path().join("q.h5"));
21433 let err = e.delete(&ObjectPathBuf::parse(spelling)).unwrap_err();
21434 let Error::EditUnsupported(message) = &err else {
21435 panic!("expected EditUnsupported, got {err:?}");
21436 };
21437 assert_eq!(
21438 *message,
21439 "cannot delete the root group; delete its members instead"
21440 );
21441 e.stage_created_dataset(&ObjectPathBuf::parse("fresh"), i32_dataset(&[1]))
21442 .unwrap();
21443 assert!(
21444 !e.staged_object(&ObjectPathBuf::parse("fresh"))
21445 .unwrap()
21446 .replaces_link,
21447 "the refused deletion staged nothing, so this replaces nothing"
21448 );
21449 }
21450
21451 #[rstest]
21452 #[case("")]
21453 #[case("/")]
21454 #[case(".")]
21455 fn the_root_group_cannot_be_created(#[case] spelling: &str) {
21456 let dir = tempdir().unwrap();
21457 let mut e = open_session(&dir.path().join("q.h5"));
21458 let err = e.create_group(&ObjectPathBuf::parse(spelling)).unwrap_err();
21459 let Error::EditUnsupported(message) = &err else {
21460 panic!("expected EditUnsupported, got {err:?}");
21461 };
21462 assert_eq!(*message, "cannot create the root group");
21463 assert!(
21464 !e.has_staged_edits(),
21465 "the refusal staged nothing for a later commit to find"
21466 );
21467 }
21468
21469 #[test]
21470 fn an_append_onto_a_staged_dataset_grows_the_pending_creation() {
21471 let dir = tempdir().unwrap();
21472 let mut e = open_session(&dir.path().join("q.h5"));
21473 e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21474 .unwrap();
21475
21476 let mut b = AppendBuilder::new();
21477 b.append_i32(&[3, 4]);
21478 e.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), b)
21479 .unwrap();
21480 assert_eq!(
21481 e.staged_dataset_meta(&ObjectPathBuf::parse("col"))
21482 .unwrap()
21483 .dimensions,
21484 vec![4]
21485 );
21486 // Folded into the creation rather than queued beside it, so the commit
21487 // has one dataset to write and no append to apply to it.
21488 assert!(e.staged.appends.is_empty());
21489
21490 e.commit().unwrap();
21491 let addr = crate::group_v2::resolve_path_any_from_source(
21492 &e.image(),
21493 AccessMode::ReadWrite,
21494 e.superblock(),
21495 &ObjectPath::parse("col"),
21496 )
21497 .unwrap();
21498 assert!(addr > 0);
21499 }
21500
21501 #[test]
21502 fn an_append_through_a_handle_onto_the_replaced_object_is_refused() {
21503 let dir = tempdir().unwrap();
21504 let mut e = open_session(&dir.path().join("q.h5"));
21505 e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21506 e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[100]))
21507 .unwrap();
21508
21509 // `stage_dataset_append` is the entry point a handle onto the object in
21510 // the file uses. Growing the replacement under it would silently move
21511 // the elements to another object.
21512 let mut b = AppendBuilder::new();
21513 b.append_i32(&[200]);
21514 assert!(matches!(
21515 e.stage_dataset_append(&ObjectPathBuf::parse("existing"), b),
21516 Err(Error::EditUnsupported(_))
21517 ));
21518 assert_eq!(
21519 e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21520 .unwrap()
21521 .dimensions,
21522 vec![1],
21523 "the refusal must leave the replacement alone"
21524 );
21525 assert!(e.staged.appends.is_empty());
21526 }
21527
21528 #[test]
21529 fn an_append_the_staged_dataset_cannot_carry_is_refused_without_changing_it() {
21530 let dir = tempdir().unwrap();
21531 let mut e = open_session(&dir.path().join("q.h5"));
21532 e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21533 .unwrap();
21534
21535 let mut wrong_type = AppendBuilder::new();
21536 wrong_type.append_f64(&[1.0]);
21537 assert!(matches!(
21538 e.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), wrong_type),
21539 Err(Error::AppendUnsupported(_))
21540 ));
21541
21542 let mut partial = AppendBuilder::new();
21543 partial.append_raw(&[0u8, 1, 2]);
21544 assert!(matches!(
21545 e.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), partial),
21546 Err(Error::AppendUnsupported(_))
21547 ));
21548
21549 // A finite maximum is a promise the commit would refuse to break.
21550 let mut capped = DatasetBuilder::new("");
21551 capped
21552 .with_i32_data(&[1, 2])
21553 .with_maxshape(&[MaxExtent::Fixed(3)])
21554 .with_chunks(&[2]);
21555 e.stage_created_dataset(&ObjectPathBuf::parse("capped"), capped)
21556 .unwrap();
21557 let mut over = AppendBuilder::new();
21558 over.append_i32(&[3, 4]);
21559 assert!(matches!(
21560 e.stage_dataset_append_pending(&ObjectPathBuf::parse("capped"), over),
21561 Err(Error::AppendUnsupported(_))
21562 ));
21563
21564 // Nothing the refusals touched changed.
21565 assert_eq!(
21566 e.staged_dataset_meta(&ObjectPathBuf::parse("col"))
21567 .unwrap()
21568 .dimensions,
21569 vec![2]
21570 );
21571 assert_eq!(
21572 e.staged_dataset_meta(&ObjectPathBuf::parse("capped"))
21573 .unwrap()
21574 .dimensions,
21575 vec![2]
21576 );
21577 }
21578
21579 #[test]
21580 fn an_append_onto_a_committed_dataset_still_stages_an_append() {
21581 let dir = tempdir().unwrap();
21582 let mut e = open_session(&dir.path().join("q.h5"));
21583 let mut b = AppendBuilder::new();
21584 b.append_i32(&[4]);
21585 e.stage_dataset_append(&ObjectPathBuf::parse("existing"), b)
21586 .unwrap();
21587 assert_eq!(e.staged.appends.len(), 1);
21588
21589 // And so does one made through a handle whose creation was committed
21590 // between the caller's check and this lock.
21591 let mut b = AppendBuilder::new();
21592 b.append_i32(&[5]);
21593 e.stage_dataset_append_pending(&ObjectPathBuf::parse("existing"), b)
21594 .unwrap();
21595 assert_eq!(e.staged.appends.len(), 2);
21596 }
21597
21598 #[test]
21599 fn deleting_a_staged_creation_withdraws_it() {
21600 let dir = tempdir().unwrap();
21601 let mut e = open_session(&dir.path().join("q.h5"));
21602 e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21603 .unwrap();
21604 e.delete(&ObjectPathBuf::parse("col")).unwrap();
21605 // Withdrawn, not queued for a deletion the commit could not perform.
21606 assert_eq!(kind(&e, "col"), None);
21607 assert!(e.staged.deletes.is_empty());
21608 assert!(!e.has_staged_edits());
21609 e.commit().unwrap();
21610 }
21611
21612 #[test]
21613 fn deleting_a_staged_group_withdraws_its_staged_subtree() {
21614 let dir = tempdir().unwrap();
21615 let mut e = open_session(&dir.path().join("q.h5"));
21616 e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21617 e.create_group(&ObjectPathBuf::parse("g/inner")).unwrap();
21618 e.stage_created_dataset(&ObjectPathBuf::parse("g/inner/col"), i32_dataset(&[1]))
21619 .unwrap();
21620 e.set_group_attr(&ObjectPathBuf::parse("g"), "kind", AttrValue::I64(1))
21621 .unwrap();
21622
21623 e.delete(&ObjectPathBuf::parse("g")).unwrap();
21624 assert_eq!(kind(&e, "g"), None);
21625 assert_eq!(kind(&e, "g/inner"), None);
21626 assert_eq!(kind(&e, "g/inner/col"), None);
21627 assert!(
21628 !e.has_staged_edits(),
21629 "the attribute and the subtree go with the group"
21630 );
21631 e.commit().unwrap();
21632 }
21633
21634 #[test]
21635 fn deleting_a_staged_replacement_leaves_the_plain_deletion() {
21636 let dir = tempdir().unwrap();
21637 let mut e = open_session(&dir.path().join("q.h5"));
21638 e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21639 e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21640 .unwrap();
21641 // Changing one's mind about the replacement leaves the deletion of the
21642 // object in the file, which is what was asked for first.
21643 e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21644 assert_eq!(kind(&e, "existing"), None);
21645 assert_eq!(e.staged.deletes.len(), 1);
21646 e.commit().unwrap();
21647 assert!(
21648 crate::group_v2::resolve_path_any_from_source(
21649 &e.image(),
21650 AccessMode::ReadWrite,
21651 e.superblock(),
21652 &ObjectPath::parse("existing"),
21653 )
21654 .is_err()
21655 );
21656 }
21657
21658 #[test]
21659 fn a_batch_that_fails_leaves_the_staged_index_matching_the_staged_set() {
21660 let dir = tempdir().unwrap();
21661 let mut e = open_session(&dir.path().join("q.h5"));
21662 e.stage_created_dataset(&ObjectPathBuf::parse("kept"), i32_dataset(&[1]))
21663 .unwrap();
21664 let refused: Result<(), Error> = e.stage_atomically(|s| {
21665 s.create_group(&ObjectPathBuf::parse("gone"))?;
21666 s.stage_created_dataset(&ObjectPathBuf::parse("gone/col"), i32_dataset(&[2]))?;
21667 Err(Error::EditUnsupported("refused on purpose"))
21668 });
21669 assert!(refused.is_err());
21670 // The index must forget what the rewind dropped, or a lookup would hand
21671 // back a handle onto an entry that is no longer there.
21672 assert_eq!(kind(&e, "gone"), None);
21673 assert_eq!(kind(&e, "gone/col"), None);
21674 assert_eq!(kind(&e, "kept"), Some(StagedKind::Dataset));
21675 assert!(e.staged_children(&ObjectPathBuf::parse("gone")).is_empty());
21676
21677 // And it must forget them for good: an index entry left behind would be
21678 // found by the *next* creation at that path and keep it pointing at the
21679 // position the rewind dropped, so the retry would stage a dataset no
21680 // lookup could see.
21681 // Staged behind an unrelated creation, so the retry lands at a
21682 // *different* position than the one the rewind dropped: an index entry
21683 // left behind would still name the old one.
21684 e.create_group(&ObjectPathBuf::parse("other")).unwrap();
21685 e.stage_created_dataset(&ObjectPathBuf::parse("other/col"), i32_dataset(&[3]))
21686 .unwrap();
21687 e.create_group(&ObjectPathBuf::parse("gone")).unwrap();
21688 e.stage_created_dataset(&ObjectPathBuf::parse("gone/col"), i32_dataset(&[2]))
21689 .unwrap();
21690 assert_eq!(kind(&e, "gone"), Some(StagedKind::Group));
21691 assert_eq!(kind(&e, "gone/col"), Some(StagedKind::Dataset));
21692 assert_eq!(
21693 e.staged_dataset_meta(&ObjectPathBuf::parse("gone/col"))
21694 .unwrap()
21695 .dimensions,
21696 vec![1]
21697 );
21698 e.commit().unwrap();
21699 }
21700
21701 #[test]
21702 fn an_edit_that_changes_staged_work_is_refused_inside_a_batch() {
21703 let dir = tempdir().unwrap();
21704 let mut e = open_session(&dir.path().join("q.h5"));
21705 e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21706 .unwrap();
21707 // `rewind` undoes a batch by truncating, which is exact only while
21708 // staging appends. Both operations that do otherwise say so here rather
21709 // than leaving half of themselves behind in a refused batch.
21710 let folded: Result<(), Error> = e.stage_atomically(|s| {
21711 let mut b = AppendBuilder::new();
21712 b.append_i32(&[3]);
21713 s.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), b)
21714 });
21715 assert!(matches!(folded, Err(Error::EditUnsupported(_))));
21716 let withdrawn: Result<(), Error> =
21717 e.stage_atomically(|s| s.delete(&ObjectPathBuf::parse("col")));
21718 assert!(matches!(withdrawn, Err(Error::EditUnsupported(_))));
21719 assert_eq!(
21720 e.staged_dataset_meta(&ObjectPathBuf::parse("col"))
21721 .unwrap()
21722 .dimensions,
21723 vec![2]
21724 );
21725 }
21726}
21727
21728#[cfg(test)]
21729mod object_header_wrap_tests {
21730 use super::*;
21731
21732 /// A region whose messages cannot be walked is reported, not asserted away.
21733 ///
21734 /// The `debug_assert!(false)` this replaced split the behavior by build
21735 /// profile: a test build panicked, and a release build wrote the header with
21736 /// no Attribute Info message — the zero-`num_attrs` defect the normalization
21737 /// exists to prevent. Asserted as an `Err` because that is the one answer
21738 /// both profiles can give.
21739 #[test]
21740 fn an_unwalkable_region_is_refused_rather_than_wrapped() {
21741 // One version 2 header message — type byte, 2-byte size, flags byte —
21742 // whose size field claims far more body than the region holds.
21743 let mut region = vec![0x0Cu8]; // Attribute
21744 region.extend_from_slice(&0xFFFFu16.to_le_bytes());
21745 region.push(0); // flags
21746 region.extend_from_slice(&[0u8; 4]); // a body far shorter than declared
21747
21748 let err = build_v2_object_header(&OhRegion::new(region, OhHeaderProps::PLAIN)).unwrap_err();
21749 assert!(
21750 matches!(err, Error::EditUnsupported(_)),
21751 "an unwalkable region gave {err:?}"
21752 );
21753 }
21754
21755 /// The walkable case still normalizes: a header carrying inline attributes
21756 /// comes back with the Attribute Info message that declares their count.
21757 #[test]
21758 fn a_walkable_region_still_gains_its_attribute_info() {
21759 let body = [0u8; 8];
21760 let mut region = vec![0x0Cu8]; // Attribute
21761 region.extend_from_slice(&(body.len() as u16).to_le_bytes());
21762 region.push(0); // flags
21763 region.extend_from_slice(&body);
21764
21765 let oh =
21766 build_v2_object_header(&OhRegion::new(region.clone(), OhHeaderProps::PLAIN)).unwrap();
21767 assert_eq!(&oh[..4], b"OHDR");
21768 assert!(
21769 oh.len() > 8 + region.len() + 4,
21770 "the wrapped header did not grow by an Attribute Info message"
21771 );
21772 }
21773}