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::address::BaseAddress;
259use crate::attribute_info::AttributeInfoMessage;
260use crate::checksum::jenkins_lookup3;
261use crate::chunk_index_inplace::{Located, Store, apply_ea_append, plan_ea_append};
262use crate::chunked_read::{
263 chunk_index_spans_from_source, enumerate_chunks_from_source, plan_dense_grid,
264};
265use crate::chunked_write::{
266 ChunkMeta, ChunkOptions, ChunkProvider, StorageAllocation, WrittenChunk, assemble_chunked_at,
267 build_extensible_array_at, chunked_data_len, compress_chunks, emit_chunked_data_verbatim,
268 extensible_array_len, full_chunk_bytes, plan_chunked_data_verbatim,
269 serialize_v4_extensible_array, split_into_chunks,
270};
271use crate::convert::TryToUsize;
272use crate::data_layout::DataLayout;
273use crate::dataspace::{Dataspace, DataspaceType};
274use crate::datatype::{
275 Datatype, DatatypeByteOrder, datatype_holds_file_address, datatype_holds_object_address,
276 embedded_reference_slots, stored_object_references,
277};
278use crate::error::{Error, FormatError, OBJECT_HEADER_MESSAGE_MAX};
279use crate::extensible_array::ExtensibleArrayHeader;
280use crate::file_create_properties::FileCreateProperties;
281use crate::file_lock::{self, FileLocking};
282use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy, NUM_FILE_FSM_MANAGERS};
283use crate::file_writer::{
284 DenseAttrCreationOrder, LENGTH_SIZE, OFFSET_SIZE, build_chunked_dataset_oh, build_dataset_oh,
285 make_link,
286};
287use crate::filter_pipeline::{
288 FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
289 FilterPipeline,
290};
291use crate::filters::{ChunkContext, FilterScratch, compress_chunk_with, decompress_chunk};
292use crate::free_space::{FreeList, trailing_run_start};
293use crate::free_space_manager::{
294 self, FreeSection, FsmHeader, PageType, PagedManagerPlan, SECT_CLASS_SIMPLE, align_up,
295 file_fsm_blocks_len, free_sections, fshd_len, plan_paged_managers, serialize_file_fsm,
296};
297use crate::group_v2::resolve_group_entries_from_source;
298use crate::image::{FileImage, HandleImage, MirrorImage, WriteBuffering};
299use crate::libver::LibVer;
300use crate::link_info::LinkInfoMessage;
301use crate::link_message::{LinkMessage, LinkTarget};
302use crate::message_type::MessageType;
303use crate::object_header::ObjectHeader;
304use crate::reader::FileAccessProperties;
305use crate::shared_message::DatatypeLocation;
306use crate::signature;
307use crate::source::{BaseOffsetSource, BytesSource, MetadataCacheConfig, Source};
308use crate::superblock::Superblock;
309use crate::type_builders::{
310 AttrValue, DatasetBuilder, ObjectRefPatch, ObjectRefTarget, VlStringStaging,
311 build_attr_message, build_global_heap_collections, make_f32_type, make_f64_type, make_i8_type,
312 make_i16_type, make_i32_type, make_i64_type, make_u8_type, make_u16_type, make_u32_type,
313 make_u64_type, patch_vl_refs, patch_vl_refs_masked, write_reference_address,
314};
315
316/// An undefined on-disk address (all bits set), HDF5's "no address" sentinel.
317const UNDEF: u64 = u64::MAX;
318
319/// The most free space an immediate in-place append takes out of a persisting
320/// file's on-disk managers in one draw, when the allocation in front of it needs
321/// less (issue #387).
322///
323/// Every draw costs a manager rewrite and a superblock repoint — a few kilobytes
324/// and two ordering barriers — so drawing per allocation would put a commit
325/// between every pair of chunks and make reuse cost more than the growth it
326/// avoids. A draw therefore takes as much as this of whatever holes can each hold
327/// the allocation, largest first, and a megabyte covers sixteen chunks at the
328/// 64 KiB scale the C library's own chunk-cache default is sized for, which puts
329/// the rewrite well outside the per-chunk path.
330///
331/// It is a cap, not a floor. A file whose holes are all smaller than this still
332/// has them drawn, one rewrite for however many of them fit under the cap: a
333/// floor here made every persisting file whose deleted objects were under a
334/// megabyte grow without bound, since no hole it left was ever spent
335/// (issue #413). What a draw refuses is only a hole too small for the allocation
336/// in hand, which nothing could be placed in. Every hole exists because a commit
337/// freed it, so the rewrites spent reusing them are bounded by the commits the
338/// caller already paid for.
339///
340/// It bounds one draw, not a session's total. A fresh draw is made whenever the
341/// largest reserved region is smaller than the allocation in hand, and the
342/// remainders of earlier draws are kept and still handed out, so what an abrupt
343/// end strands is the sum of every remainder still held: each one smaller than
344/// the allocation that forced the next draw, and one more of them possible per
345/// draw. Larger would amortize further and strand more; this is the point where
346/// the rewrite is already rare enough that making it rarer buys little.
347const APPEND_RESERVE_BYTES: u64 = 1 << 20;
348
349/// The refusal both address-side reference screens report: an object reference
350/// this commit writes names an object the same commit removes.
351///
352/// One constant because the screens differ only in where the address came from
353/// — [`WriteEngine::resolve_reference_target`] takes it from a builder's
354/// [`ObjectRefTarget::Raw`], [`screen_resolved_references`] reads it out of a
355/// dataset's or an attribute's element bytes — and a caller must not be able to
356/// tell them apart by the message. It is the address-side twin of the by-name refusal
357/// `resolve_reference_target` reports for a path, and shares its second clause.
358const REFERENCE_INTO_RECLAIMED_SPACE: &str = "a reference this commit writes holds the address of an object this commit deletes, or of \
359 one under it; the reference would be left pointing at storage the delete can reclaim";
360
361/// The refusal for the other half of [`InvalidatedAddresses`]: the object is
362/// still there, but not at that address any more.
363///
364/// It can offer a way out, which the removal refusal cannot: the target still
365/// exists, so a path names it. Whether the path *works* depends on which half of
366/// [`InvalidatedAddresses::moved`] the target came from, which is why the message
367/// names separate commits as well rather than promising the first.
368/// [`WriteEngine::resolve_reference_target`] resolves a **dirty group** once this
369/// commit has placed it, and reports "still writing" until then; a **relocating
370/// dataset write** it refuses outright, by exact match against `write_targets`,
371/// with no later point at which that changes.
372const REFERENCE_TO_A_MOVED_OBJECT: &str = "a reference this commit writes holds the pre-commit address of an object this commit \
373 rewrites elsewhere; name the target by path (`with_path_references`) so it resolves to \
374 where the object lands, or use separate commits";
375
376/// A shared (SOHM) attribute message is held in the file's shared-message table
377/// rather than in the object whose attribute it is, so rewriting the object's
378/// attributes would have to account for the table as well. Both attribute paths
379/// refuse it by the same name: the compact one in [`parse_compact_attr_name`],
380/// which walks the messages it is about to copy, and the dense one in
381/// [`plan_attr_ops`] — where the set is read through
382/// [`crate::attribute::extract_attributes_full_from_source`], which *resolves*
383/// such a message and would otherwise re-emit it into the new heap as a private
384/// one, leaving the table's reference count naming an attribute that no longer
385/// exists.
386///
387/// Since issue #417 the reader follows such a reference, so these are live
388/// refusals rather than backstops: what they hold back is the *table*, whose
389/// reference count this engine cannot yet decrement, not the read. `repack`
390/// rewrites the file with every shared message inline, which is the way through
391/// today.
392const SHARED_ATTRIBUTE_MESSAGE: &str =
393 "a target object has a shared attribute message (not editable in place yet)";
394
395/// A shared-message (SOHM) index record does not always name a heap entry: where
396/// the reference C library shares a message written into an object header it
397/// already holds open, the record names *that header* and the message stays
398/// where it is. Such a record is a stored address like any other, and it is the
399/// one no rewrite reaches — the index lives outside every object a commit
400/// rebuilds, and this engine writes no shared-message indexes. A commit that
401/// removed or moved the named header would leave the file's own index pointing
402/// at bytes that are no longer there, which the next library to share a message
403/// would follow.
404const SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT: &str = "this file's shared-message (SOHM) index names an object header this commit \
405 removes or rewrites elsewhere, and the index cannot be updated in place; \
406 rewrite the file with `repack` instead";
407
408/// Refusal for an attribute whose references a commit repoints today and could
409/// not repoint from a heap. See [`plan_attr_ops`].
410const REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER: &str = "an attribute holding an object reference cannot be moved to dense \
411 (fractal-heap) storage, where a later commit could no longer repoint it when \
412 its target moves; keep this object within compact attribute storage";
413
414/// Maximum number of attributes an object header keeps inline; past this, HDF5
415/// switches the object to dense (fractal-heap) attribute storage, which this
416/// engine emits through [`plan_attr_ops`] and [`WriteEngine::place_dense_attrs`].
417///
418/// Taken from the whole-file writer's threshold rather than restated beside it:
419/// the two decide the same thing about the same attribute set, and a file whose
420/// objects were written by one and edited by the other must not disagree about
421/// where the eighth attribute lives.
422const MAX_COMPACT_ATTRS: usize = crate::file_writer::DENSE_ATTR_THRESHOLD;
423
424/// Recursion-depth cap for object copy, guarding against a stack overflow on a
425/// pathological or cyclic hard-link graph (HDF5 hard links can form cycles).
426/// Far deeper than any real group hierarchy.
427const MAX_COPY_DEPTH: u32 = 1000;
428
429/// Upper bound on the number of object headers walked when counting hard links
430/// across the file (issue #77 / reclaim safety). Far beyond any real file; a
431/// graph larger than this aborts the count, and the commit then leaves deleted
432/// objects unreclaimed (a safe leak) rather than risk an unbounded walk.
433const MAX_LINK_GRAPH_NODES: u32 = 1 << 24;
434
435/// Maximum number of object-header chunks to follow when gathering a header that
436/// spans continuation blocks, guarding against a cyclic continuation chain.
437/// Matches the reader's continuation-depth cap.
438const MAX_OH_CHUNKS: usize = 256;
439
440/// Maximum length of a version 2 object header's fixed prefix: signature (4) +
441/// version (1) + flags (1) + optional access/modification/change/birth times
442/// (16) + optional attribute phase-change thresholds (4) + the chunk-0 size
443/// field (up to 8). Reading this many bytes always covers the prefix, so
444/// [`oh_region_at`] can be handed one bounded window instead of a whole-file
445/// image.
446const OH_PREFIX_MAX: usize = 34;
447
448/// A path identified by its components (no leading/trailing empties); the root
449/// group is the empty vector.
450type PathKey = Vec<String>;
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<PathKey>,
466}
467
468/// A variable-length group/root attribute staged by [`apply_compact_attr_ops`]
469/// and resolved in the apply loop.
470#[derive(Clone)]
471struct PendingVlAttr {
472 /// The attribute message, its global-heap references still placeholders.
473 msg: crate::attribute::AttributeMessage,
474 /// The collections whose real addresses those references need.
475 collections: Vec<Vec<u8>>,
476 /// The creation index this attribute keeps, taken from the message it
477 /// replaces on a header that tracks attribute creation order. `None` asks
478 /// for the object's next unused index when the message is appended — which
479 /// is every case on a header that does not track the order.
480 creation_index: Option<u16>,
481}
482
483/// Variable-length group/root attributes staged by [`apply_compact_attr_ops`].
484type PendingVlAttrs = Vec<PendingVlAttr>;
485
486/// Accumulates elements to append to an existing chunked, unlimited dataset via
487/// [`Dataset::append_staged`](crate::Dataset::append_staged), in call order along the dataset's first
488/// (axis-0) dimension.
489///
490/// It mirrors [`DatasetBuilder`]'s typed/generic vocabulary. Repeated typed or
491/// [`append_raw`](Self::append_raw) calls concatenate; each typed method also
492/// records the element datatype it implies, which `commit` checks against the
493/// dataset's on-disk datatype (a mismatch — including a mix of element types in
494/// one builder — is refused with [`Error::AppendUnsupported`], never written as
495/// garbage).
496pub struct AppendBuilder {
497 /// Accumulated little-endian element bytes to append, in call order.
498 raw: Vec<u8>,
499 /// The element datatype implied by the typed `append_*` calls, if any were
500 /// used. `None` when only [`append_raw`](Self::append_raw) was called (a raw
501 /// append is checked structurally — element-size alignment and little-endian
502 /// on-disk order — rather than by datatype equality).
503 elem_dt: Option<Datatype>,
504 /// Set when two typed calls implied different element datatypes; `commit`
505 /// refuses such a builder rather than write a mix of encodings.
506 dt_conflict: bool,
507}
508
509impl AppendBuilder {
510 pub(crate) fn new() -> Self {
511 Self {
512 raw: Vec::new(),
513 elem_dt: None,
514 dt_conflict: false,
515 }
516 }
517
518 /// Accumulated little-endian element bytes (for the general append writer,
519 /// which reuses this builder to gather typed/generic appends).
520 pub(crate) fn raw(&self) -> &[u8] {
521 &self.raw
522 }
523
524 /// The element datatype implied by typed appends, if any.
525 pub(crate) fn elem_dt(&self) -> Option<&Datatype> {
526 self.elem_dt.as_ref()
527 }
528
529 /// Whether two typed appends implied conflicting element datatypes.
530 pub(crate) fn dt_conflict(&self) -> bool {
531 self.dt_conflict
532 }
533
534 /// A builder holding a copy of this one's first `byte_len` bytes, carrying
535 /// the same element datatype so the prefix is type-checked exactly as the
536 /// whole would have been. Used by [`BufferedAppender`](crate::BufferedAppender)
537 /// to write out the chunk-aligned prefix of its buffer; it copies rather
538 /// than splits so a failed write leaves the buffer intact and the appender
539 /// can report precisely which elements did not land.
540 pub(crate) fn head(&self, byte_len: usize) -> Self {
541 Self {
542 raw: self.raw[..byte_len.min(self.raw.len())].to_vec(),
543 elem_dt: self.elem_dt.clone(),
544 dt_conflict: self.dt_conflict,
545 }
546 }
547
548 /// Discard the first `byte_len` buffered bytes (a prefix that reached the
549 /// file), keeping the element datatype.
550 pub(crate) fn drop_front(&mut self, byte_len: usize) {
551 self.raw.drain(..byte_len.min(self.raw.len()));
552 }
553
554 /// Consume the builder, yielding its accumulated element bytes. Used by
555 /// [`BufferedAppender::discard`](crate::BufferedAppender::discard) to hand
556 /// abandoned elements back to the caller.
557 pub(crate) fn into_raw(self) -> Vec<u8> {
558 self.raw
559 }
560
561 /// Cut the buffer back to `byte_len` bytes, keeping the element datatype.
562 /// [`BufferedAppender`](crate::BufferedAppender) uses this to undo a call
563 /// whose write was refused before it touched the file, so the refusal leaves
564 /// nothing buffered and a retry cannot append the same elements twice.
565 pub(crate) fn truncate(&mut self, byte_len: usize) {
566 self.raw.truncate(byte_len);
567 }
568
569 /// Record the datatype a typed append implies, flagging a conflict if an
570 /// earlier typed call implied a different one.
571 fn set_dt(&mut self, dt: Datatype) {
572 match &self.elem_dt {
573 Some(prev) if *prev != dt => self.dt_conflict = true,
574 Some(_) => {}
575 None => self.elem_dt = Some(dt),
576 }
577 }
578
579 /// Append already-little-endian element bytes verbatim. The concatenated
580 /// length must be a whole multiple of the dataset's on-disk element size, and
581 /// the dataset's element datatype must be little-endian; no datatype is
582 /// otherwise inferred. Prefer the typed methods when the element type is known.
583 pub fn append_raw(&mut self, bytes: &[u8]) -> &mut Self {
584 self.raw.extend_from_slice(bytes);
585 self
586 }
587
588 /// Generic append of a flat slice of any supported scalar type — the
589 /// counterpart of [`DatasetBuilder::with_data`](crate::DatasetBuilder::with_data).
590 pub fn append<T: crate::element::H5Element>(&mut self, data: &[T]) -> &mut Self {
591 T::append_into(self, data);
592 self
593 }
594}
595
596/// Generate the typed `append_*` methods: serialize each value little-endian and
597/// record the implied element datatype.
598macro_rules! append_typed {
599 ($($method:ident, $ty:ty, $make:ident;)*) => {
600 impl AppendBuilder {
601 $(
602 #[doc = concat!("Append `", stringify!($ty), "` values to the dataset.")]
603 pub fn $method(&mut self, data: &[$ty]) -> &mut Self {
604 self.set_dt($make());
605 // Reserved up front: serializing element by element into a
606 // growing `Vec` re-allocated it ten times per call and copied
607 // the batch twice over, which for an append loop is the whole
608 // per-call cost (issue #228).
609 self.raw.reserve(data.len() * core::mem::size_of::<$ty>());
610 for &v in data {
611 self.raw.extend_from_slice(&v.to_le_bytes());
612 }
613 self
614 }
615 )*
616 }
617 };
618}
619
620append_typed! {
621 append_f64, f64, make_f64_type;
622 append_f32, f32, make_f32_type;
623 append_i8, i8, make_i8_type;
624 append_i16, i16, make_i16_type;
625 append_i32, i32, make_i32_type;
626 append_i64, i64, make_i64_type;
627 append_u8, u8, make_u8_type;
628 append_u16, u16, make_u16_type;
629 append_u32, u32, make_u32_type;
630 append_u64, u64, make_u64_type;
631}
632
633/// Every edit a session has staged and not yet committed, as one value.
634///
635/// [`WriteEngine::commit`] takes the whole set out for the duration of an
636/// attempt and puts it back if that attempt refuses, so a refused commit costs
637/// the session no staged work — the same guarantee [`FreeSnapshot`] gives the
638/// free lists, for the same reason (issue #316). Keeping the vectors together
639/// rather than beside the engine's other fields is what makes that total: a
640/// staged kind added later participates by construction, where a tenth field
641/// would have to be remembered in a hand-written restore.
642#[derive(Default)]
643struct StagedEdits {
644 /// Datasets staged by `create_dataset`, as (parent group path, dataset).
645 ///
646 /// Flattened at staging rather than at commit: [`flatten_dataset`] is the
647 /// one step of the commit's preflight that *consumes* what it validates, so
648 /// running it here is what lets the preflight read the staged set without
649 /// destroying it (issue #316). It is a pure function of the builder, so the
650 /// guards it raises — a missing shape, data that does not match it, a
651 /// feature this engine cannot reproduce — are answered at the call that
652 /// stages the dataset, where the caller still has the context to fix them.
653 datasets: Vec<(PathKey, FlatDataset)>,
654 /// Value overwrites staged by `write_dataset`, as (full dataset path,
655 /// dataset). Each replaces an existing dataset's values in place; the new
656 /// datatype and shape must match the on-disk ones byte-exactly (this is a
657 /// value overwrite, not a reshape/retype). Applied on the next `commit`.
658 /// Flattened at staging, for the reason given on [`datasets`](Self::datasets);
659 /// the match against the on-disk dataset is a commit-time check and stays
660 /// one, since it reads the file.
661 writes: Vec<(PathKey, FlatDataset)>,
662 /// Appends staged by `append_dataset`, as (full dataset path, builder). Each
663 /// grows an existing chunked, unlimited, Extensible-Array-indexed dataset
664 /// along axis 0 by keeping its existing chunk data in place and rebuilding the
665 /// index over the kept plus newly-appended (and any rewritten trailing) chunks.
666 /// Applied on the next `commit`.
667 appends: Vec<(PathKey, AppendBuilder)>,
668 /// New groups staged by `create_group`, as full paths.
669 groups: Vec<PathKey>,
670 /// Group attribute edits staged as (group path, operation). The path may be
671 /// a group created in this same session.
672 group_attrs: Vec<(PathKey, AttrOp)>,
673 /// Dataset attribute edits staged as (full dataset path, operation), applied
674 /// on the next `commit`. Each relocates the dataset's object header (like a
675 /// relocating overwrite): the header is rebuilt with the compact-attribute
676 /// change, its single naming link is patched, and the old header freed — the
677 /// dataset's data and chunk index stay in place. The target must be an existing,
678 /// single-hard-link dataset using compact (not dense fractal-heap) attributes.
679 dataset_attrs: Vec<(PathKey, AttrOp)>,
680 /// Links staged for removal by `delete`, as full paths.
681 deletes: Vec<PathKey>,
682 /// Object copies staged by `copy`, as (source path, destination full path).
683 copies: Vec<(PathKey, PathKey)>,
684 /// Cross-file object copies staged by `copy_from`, as (destination full path,
685 /// the source subtree already read out of the other file). The subtree is read
686 /// — and foreign-address-screened — eagerly in `copy_from` (the source file is
687 /// borrowed only for that call), then linked in at the next `commit`.
688 cross_copies: Vec<(PathKey, CopyTree)>,
689 /// Where the creation at each full path sits in [`datasets`](Self::datasets)
690 /// / [`groups`](Self::groups), so a by-name question about the staged set
691 /// costs a hash rather than a scan of everything staged.
692 ///
693 /// The scan is what made a writer that stages one dataset per column and
694 /// then binds a handle to each quadratic in the number of columns (issue
695 /// #392). Both maps are derived from the vectors beside them and are kept in
696 /// step at the three places those change: the push that stages a creation,
697 /// the truncation [`rewind`](Self::rewind) makes, and the rebuild
698 /// [`withdraw_at`](Self::withdraw_at) makes after removing from the middle.
699 /// A path staged twice keeps its *first* position, which is the entry a scan
700 /// would have found; the commit refuses such a pair anyway.
701 dataset_at: HashMap<PathKey, usize>,
702 group_at: HashMap<PathKey, usize>,
703}
704
705/// The full path of a dataset staged under `parent` as `name`.
706fn child_key(parent: &[String], name: &str) -> PathKey {
707 let mut full = parent.to_vec();
708 full.push(name.to_string());
709 full
710}
711
712/// Whether the dataset staged under `parent` as `name` lies at or under
713/// `prefix`, without building its full path.
714fn dataset_under(parent: &[String], name: &str, prefix: &[String]) -> bool {
715 match prefix.len() {
716 n if n <= parent.len() => parent.starts_with(prefix),
717 n if n == parent.len() + 1 => {
718 parent[..] == prefix[..parent.len()] && prefix[parent.len()] == name
719 }
720 _ => false,
721 }
722}
723
724/// What a session has staged at a path, for a handle addressing an object the
725/// commit has not written yet.
726#[derive(Clone, Copy, PartialEq, Eq, Debug)]
727pub(crate) enum StagedKind {
728 Group,
729 Dataset,
730}
731
732/// A creation this session has staged at a path, and whether it takes that path
733/// over from the file.
734#[derive(Clone, Copy, PartialEq, Eq, Debug)]
735pub(crate) struct StagedObject {
736 pub(crate) kind: StagedKind,
737 /// Whether this same commit removes the link the file holds at that path.
738 ///
739 /// True makes the creation a *replacement* (issue #305). False means the
740 /// file holds no link there at all — a creation colliding with one that
741 /// survives is refused where it is staged, and would not be reported here
742 /// either, since the file's own object is what the name means.
743 pub(crate) replaces_link: bool,
744}
745
746/// One direct child a group gains from this session's staged creations.
747pub(crate) struct StagedChild {
748 pub(crate) name: String,
749 pub(crate) kind: StagedKind,
750 /// Whether a staged deletion removes the link this name has in the file, so
751 /// the creation supersedes it rather than colliding with it. See
752 /// [`StagedObject::replaces_link`].
753 pub(crate) replaces_link: bool,
754}
755
756/// What a staged dataset can say about itself before it is written.
757///
758/// Everything here is settled when the dataset is staged — [`flatten_dataset`]
759/// has already validated the builder — so a handle answers from it without
760/// reading the file, and the answers are what the commit goes on to write.
761pub(crate) struct StagedMeta {
762 pub(crate) datatype: Datatype,
763 pub(crate) dimensions: Vec<u64>,
764 /// `None` for a fixed-shape dataset, matching `Dataset::maxshape`.
765 pub(crate) maxshape: Option<Vec<u64>>,
766 pub(crate) chunked: bool,
767 /// Each staged filter's registered id and its `H5Z_FLAG_OPTIONAL` flag, in
768 /// pipeline order.
769 pub(crate) filters: Vec<(u16, bool)>,
770}
771
772/// Where a [`StagedEdits`] stood before a batch of staging calls, so a batch
773/// that fails partway can be undone (see [`StagedEdits::rewind`]).
774#[derive(Clone, Copy, Default, PartialEq, Eq)]
775struct StagedMark {
776 datasets: usize,
777 writes: usize,
778 appends: usize,
779 groups: usize,
780 group_attrs: usize,
781 dataset_attrs: usize,
782 deletes: usize,
783 copies: usize,
784 cross_copies: usize,
785}
786
787impl StagedEdits {
788 /// The length of every staged vector right now, for
789 /// [`rewind`](Self::rewind).
790 ///
791 /// Destructured rather than read field by field, here and in
792 /// [`rewind`](Self::rewind), so that the "a staged kind added later
793 /// participates by construction" claim on [`StagedEdits`] is enforced rather
794 /// than hoped for: a struct pattern naming fewer fields than the struct has
795 /// does not compile, and the unused binding a tenth kind would leave in
796 /// `rewind` is an error under the crate's `-D warnings`.
797 fn mark(&self) -> StagedMark {
798 let Self {
799 datasets,
800 writes,
801 appends,
802 groups,
803 group_attrs,
804 dataset_attrs,
805 deletes,
806 copies,
807 cross_copies,
808 // Derived from `datasets` and `groups`, and restored with them.
809 dataset_at: _,
810 group_at: _,
811 } = self;
812 StagedMark {
813 datasets: datasets.len(),
814 writes: writes.len(),
815 appends: appends.len(),
816 groups: groups.len(),
817 group_attrs: group_attrs.len(),
818 dataset_attrs: dataset_attrs.len(),
819 deletes: deletes.len(),
820 copies: copies.len(),
821 cross_copies: cross_copies.len(),
822 }
823 }
824
825 /// Drop everything staged since `mark`.
826 ///
827 /// Staging inside a batch only ever appends — every `stage_*` entry point
828 /// validates and then pushes — so truncating to the recorded lengths is an
829 /// exact undo of the calls made in between, and leaves anything staged
830 /// before them alone. The two operations that instead *change* what is
831 /// already staged — the fold
832 /// [`WriteEngine::stage_dataset_append_pending`] makes into a staged
833 /// creation, and the withdrawal [`withdraw_at`](Self::withdraw_at) makes for
834 /// [`WriteEngine::delete`] — would leave that untrue, so
835 /// [`WriteEngine::refuse_mid_batch`] refuses them while a batch is open
836 /// rather than leaving the claim to be hoped for.
837 ///
838 /// The by-path index is truncated with the vectors it describes, so a
839 /// rewound creation stops answering as well as stops existing.
840 fn rewind(&mut self, mark: StagedMark) {
841 for i in mark.datasets..self.datasets.len() {
842 let key = {
843 let (parent, fd) = &self.datasets[i];
844 child_key(parent, &fd.name)
845 };
846 // Only where it still points at the entry being dropped: a second
847 // creation at one path never displaced the first.
848 if self.dataset_at.get(&key) == Some(&i) {
849 self.dataset_at.remove(&key);
850 }
851 }
852 for i in mark.groups..self.groups.len() {
853 let key = self.groups[i].clone();
854 if self.group_at.get(&key) == Some(&i) {
855 self.group_at.remove(&key);
856 }
857 }
858 let StagedMark {
859 datasets,
860 writes,
861 appends,
862 groups,
863 group_attrs,
864 dataset_attrs,
865 deletes,
866 copies,
867 cross_copies,
868 } = mark;
869 self.datasets.truncate(datasets);
870 self.writes.truncate(writes);
871 self.appends.truncate(appends);
872 self.groups.truncate(groups);
873 self.group_attrs.truncate(group_attrs);
874 self.dataset_attrs.truncate(dataset_attrs);
875 self.deletes.truncate(deletes);
876 self.copies.truncate(copies);
877 self.cross_copies.truncate(cross_copies);
878 }
879
880 /// Stage a dataset creation, indexing it by its full path.
881 fn push_dataset(&mut self, parent: PathKey, fd: FlatDataset) {
882 self.dataset_at
883 .entry(child_key(&parent, &fd.name))
884 .or_insert(self.datasets.len());
885 self.datasets.push((parent, fd));
886 }
887
888 /// Stage a group creation, indexing it by its path.
889 fn push_group(&mut self, path: PathKey) {
890 self.group_at
891 .entry(path.clone())
892 .or_insert(self.groups.len());
893 self.groups.push(path);
894 }
895
896 /// Where the staged dataset at `path` sits, if there is one.
897 ///
898 /// The position the index gives is checked against the entry it names
899 /// rather than trusted: an index that fell behind the vector then reads as a
900 /// miss — the answer a scan would give — instead of as another dataset.
901 fn dataset_position(&self, path: &[String]) -> Option<usize> {
902 let i = *self.dataset_at.get(path)?;
903 let (parent, fd) = self.datasets.get(i)?;
904 dataset_under(parent, &fd.name, path).then_some(i)
905 }
906
907 /// The staged dataset at `path` (given as components), if there is one.
908 fn dataset_at(&self, path: &[String]) -> Option<&FlatDataset> {
909 let i = self.dataset_position(path)?;
910 self.datasets.get(i).map(|(_, fd)| fd)
911 }
912
913 /// The staged dataset at `path`, mutably.
914 fn dataset_at_mut(&mut self, path: &[String]) -> Option<&mut FlatDataset> {
915 let i = self.dataset_position(path)?;
916 self.datasets.get_mut(i).map(|(_, fd)| fd)
917 }
918
919 /// Whether a group creation is staged at `path`. Checked against the entry
920 /// the index names, for the reason [`dataset_position`](Self::dataset_position)
921 /// gives.
922 fn has_group_at(&self, path: &[String]) -> bool {
923 match self.group_at.get(path) {
924 Some(&i) => self.groups.get(i).is_some_and(|p| p[..] == *path),
925 None => false,
926 }
927 }
928
929 /// Whether a staged deletion removes the link at `path`, or the link to an
930 /// ancestor that carries it away.
931 fn deletes_cover(&self, path: &[String]) -> bool {
932 self.deletes.iter().any(|d| path.starts_with(&d[..]))
933 }
934
935 /// Whether a staged deletion hands `path` over to a creation staged there —
936 /// which is what makes the pair a *replacement* rather than a collision
937 /// (issue #305).
938 ///
939 /// Stricter than [`deletes_cover`](Self::deletes_cover), and deliberately:
940 /// a deletion of an *ancestor* only carries the name over when this same
941 /// session builds that ancestor again, which is exactly what the commit
942 /// requires of everything at or under a replaced path. Deleting `g` and
943 /// creating `g/x` without recreating `g` is a batch the commit refuses, so
944 /// the file's own `g/x` is still what that name means — reporting it as a
945 /// replacement would make a live, readable dataset answer
946 /// [`Error::NotCommitted`] and vanish from its group's listing until the
947 /// refusal.
948 ///
949 /// The exact deletion is the empty case of the same rule: with `d == path`
950 /// there is no ancestor between them to have been recreated.
951 fn deletes_hand_over(&self, path: &[String]) -> bool {
952 self.deletes.iter().any(|d| {
953 path.starts_with(&d[..]) && (d.len()..path.len()).all(|n| self.has_group_at(&path[..n]))
954 })
955 }
956
957 /// Withdraw every edit staged at or under `path`, as though the calls that
958 /// staged them had never been made, and report whether a *creation* was
959 /// among them.
960 ///
961 /// This is what lets [`WriteEngine::delete`] cancel an object this session
962 /// staged and has not written: there is nothing in the file to unlink, so a
963 /// deletion of it is a withdrawal. Deletions are deliberately left alone —
964 /// one under a withdrawn creation still names a link the file holds, and two
965 /// overlapping deletions are the commit's refusal to make rather than this
966 /// call's.
967 ///
968 /// Destructured for the reason [`mark`](Self::mark) is: a staged kind added
969 /// later has to be classified here rather than silently survive a
970 /// withdrawal.
971 fn withdraw_at(&mut self, path: &[String]) -> bool {
972 let before = {
973 let Self {
974 datasets,
975 writes,
976 appends,
977 groups,
978 group_attrs,
979 dataset_attrs,
980 deletes,
981 copies,
982 cross_copies,
983 dataset_at: _,
984 group_at: _,
985 } = self;
986 let before = datasets.len() + groups.len();
987 datasets.retain(|(parent, fd)| !dataset_under(parent, &fd.name, path));
988 writes.retain(|(p, _)| !p.starts_with(path));
989 appends.retain(|(p, _)| !p.starts_with(path));
990 groups.retain(|p| !p.starts_with(path));
991 group_attrs.retain(|(p, _)| !p.starts_with(path));
992 dataset_attrs.retain(|(p, _)| !p.starts_with(path));
993 copies.retain(|(_, dst)| !dst.starts_with(path));
994 cross_copies.retain(|(dst, _)| !dst.starts_with(path));
995 let _ = &deletes;
996 before
997 };
998 let withdrew = self.datasets.len() + self.groups.len() < before;
999 if withdrew {
1000 self.reindex();
1001 }
1002 withdrew
1003 }
1004
1005 /// Rebuild the by-path index from the vectors, after a removal from the
1006 /// middle shifted every position after it.
1007 fn reindex(&mut self) {
1008 self.dataset_at.clear();
1009 self.group_at.clear();
1010 for (i, (parent, fd)) in self.datasets.iter().enumerate() {
1011 self.dataset_at
1012 .entry(child_key(parent, &fd.name))
1013 .or_insert(i);
1014 }
1015 for (i, path) in self.groups.iter().enumerate() {
1016 self.group_at.entry(path.clone()).or_insert(i);
1017 }
1018 }
1019
1020 /// Whether nothing at all is staged.
1021 ///
1022 /// Asked as "is the mark the zero mark", which is exact — nothing is staged
1023 /// exactly when every vector has length zero — and leaves [`mark`](Self::mark)
1024 /// as the single place that names every vector. The two callers,
1025 /// [`WriteEngine::has_staged_edits`] and the commit's own no-op return, then
1026 /// cannot come to disagree, and a staged kind added later reaches both. (The
1027 /// commit's *fast path* asks a narrower question and spells its own subset
1028 /// out.)
1029 fn is_empty(&self) -> bool {
1030 self.mark() == StagedMark::default()
1031 }
1032}
1033
1034/// The in-place write engine behind the owned read-write [`File`](crate::File)
1035/// (its `Backend::Edit`).
1036///
1037/// Reads and edits the file through a [`FileImage`], which owns the writable
1038/// handle and decides how much of the file is resident. It carries two commit
1039/// models: staged tree edits applied by [`commit`](Self::commit), and immediate
1040/// crash-atomic in-place appends ([`append_inplace_gathered`](Self::append_inplace_gathered)).
1041pub(crate) struct WriteEngine {
1042 /// The file bytes this session reads and edits, behind the [`FileImage`]
1043 /// abstraction: reads go through its [`Source`] impl, and the write side —
1044 /// the end-of-file cursor, `append`, `write_at`, `truncate`, and the
1045 /// durability barriers — through its own primitives.
1046 ///
1047 /// Nothing in the engine assumes the whole file is resident, so one engine
1048 /// serves both a whole-file mirror and a file-backed image that holds only
1049 /// what it is reading (issue #198). [`image_slice`](Self::image_slice)
1050 /// exposes the mirror's buffer where a caller can exploit it.
1051 image: Box<dyn FileImage>,
1052 /// Absolute offset of the superblock signature in the file.
1053 sb_sig_off: usize,
1054 /// Parsed superblock. On-disk addresses are stored relative to `base_address`;
1055 /// the in-memory `root_group_address` is normalized to an absolute file offset
1056 /// on open and converted back to a base-relative address when serialized on
1057 /// commit. `base_address` equals the superblock's file location (`sb_sig_off`):
1058 /// 0 for a plain file, the userblock size for one with a userblock.
1059 superblock: Superblock,
1060 /// Every edit this session has staged and not yet applied. Held as one
1061 /// value so that [`commit`](WriteEngine::commit) can take the whole set out
1062 /// for the duration of an attempt and put it back when that attempt
1063 /// refuses; see [`StagedEdits`].
1064 staged: StagedEdits,
1065 /// Datasets with a live [`BufferedAppender`](crate::BufferedAppender), which
1066 /// holds accepted elements only it can write. A staged edit that would stop
1067 /// that appender from flushing is refused while the claim stands, rather
1068 /// than left to fail in the appender's `Drop`, where there is no caller to
1069 /// report it to and the buffer is simply lost. See `refuse_if_claimed`.
1070 appender_claims: Vec<AppenderClaim>,
1071 /// Monotonic id for the next claim, so releasing one is exact even when two
1072 /// appenders on different datasets are live at once.
1073 next_appender_token: u64,
1074 /// Session-local free-space tracker (issue #21). Holds regions vacated by
1075 /// prior commits in this session — superseded object headers and the blocks
1076 /// of deleted objects — so later commits reuse them instead of growing the
1077 /// file, and so a freed run reaching end-of-file can be truncated away. It
1078 /// starts empty on `open` for a non-persisting file: holes already present
1079 /// from earlier sessions or other tools are not tracked. When the file
1080 /// persists its free space (`persist` is `Some`), `open` instead seeds it
1081 /// from the on-disk free-space managers, so reuse spans sessions.
1082 free: FreeList,
1083 /// Space this session has taken *out* of the on-disk free-space managers so
1084 /// that an immediate in-place append may spend it (issue #387).
1085 ///
1086 /// Only a file that persists its free space uses this. Such a file records
1087 /// its holes on disk, and an append has no superblock repoint of its own to
1088 /// publish an allocation with — so an append that simply drew from
1089 /// [`free`](Self::free) would leave a manager advertising bytes a live chunk
1090 /// now occupies, through a clean close as much as a crash. The reserve is
1091 /// the answer: [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
1092 /// moves a batch of bytes out of `free` (or, on a paged file, out of
1093 /// [`PagedEdit`]'s raw list) and rewrites the managers *without* them, under
1094 /// the same crash-atomic superblock repoint every persisting commit uses.
1095 /// Only then may an append write there, because by then no durable record
1096 /// calls those bytes free.
1097 ///
1098 /// What is left unspent goes back — into `free` or `PagedEdit` — before the
1099 /// managers are next rewritten, which is [`release_reserve`](Self::release_reserve)
1100 /// at the next commit and at close. A session that dies in between strands
1101 /// the unspent remainder: it is accounted to nobody, so it is never handed
1102 /// out twice, and a later `H5repack` or whole-file rewrite recovers it. That
1103 /// is the same trade the module header records for
1104 /// [`finalize_persist`](Self::finalize_persist), on a surface bounded per
1105 /// draw — see [`APPEND_RESERVE_BYTES`], which is the most one draw takes,
1106 /// and note that this list holds the unspent remainder of every draw the
1107 /// session has made, not just the last.
1108 ///
1109 /// Empty for a non-persisting session, which reuses out of `free` directly:
1110 /// its list is in memory alone, so nothing on the disk claims those bytes
1111 /// are free and nothing has to be published before they are spent.
1112 reserved: FreeList,
1113 /// Whether this file has been *proved* to hold no object reference at all —
1114 /// the one thing that lets a commit skip the walk that repoints them
1115 /// (issue #324).
1116 ///
1117 /// Set by a walk that reaches every object and finds no reference-holding
1118 /// datatype; every later commit in this session then skips the walk
1119 /// outright, which matters because the walk is linear in the file's object
1120 /// count and the file that needs it is the exception.
1121 ///
1122 /// One bit rather than three states, because "not yet walked" and "walked
1123 /// and found one" are the same instruction to the caller: walk. Only the
1124 /// proof is worth carrying.
1125 ///
1126 /// Cleared by any commit that could introduce a reference the walk has not
1127 /// seen. Exactly one of [`StagedEdits`]' nine collections can: `datasets`,
1128 /// which carries a datatype and attributes the caller chose. The check
1129 /// covers four, and the other three are belt-and-braces rather than
1130 /// load-bearing — recorded here because a reason that does not hold is worse
1131 /// than no reason:
1132 ///
1133 /// - `writes` is a *value* overwrite, whose datatype must match the on-disk
1134 /// dataset's exactly. A reference-typed one therefore overwrites a dataset
1135 /// that already held references, which a file proved free of them does not
1136 /// have. Checked anyway, because that match is a commit-time check running
1137 /// after this one, so `fd.dt` here is still whatever the caller supplied.
1138 /// - `copies` re-emits an object *from this same file* — one the walk that
1139 /// granted the proof had already visited and found reference-free.
1140 /// - `cross_copies` reads from another file, where `reject_foreign_addresses`
1141 /// refuses a reference datatype outright.
1142 ///
1143 /// The remaining five cannot, and these reasons do hold:
1144 ///
1145 /// - `groups` stages a bare path: no datatype, no data, no attributes.
1146 /// - `group_attrs` and `dataset_attrs` carry an [`AttrValue`], which has no
1147 /// reference variant — the reason a reference-typed attribute can only
1148 /// have come from the reference C library.
1149 /// - `appends` grows an *existing* dataset along its own datatype, so it can
1150 /// only add references to a dataset that already held them.
1151 /// - `deletes` removes objects and adds nothing.
1152 ///
1153 /// The commit fast path returns before the check and needs no reason of its
1154 /// own: it is taken only when every staged edit is a same-length in-place
1155 /// overwrite, which is the `writes` case above.
1156 ///
1157 /// A tenth collection would have to be classified the same way; the check
1158 /// sits beside the reference screen in `commit`, which iterates the same
1159 /// datatype-carrying collections.
1160 proved_free_of_references: bool,
1161 /// Free-space persistence read from the file's superblock extension on
1162 /// `open` (the file-creation `H5Pset_file_space_strategy(persist = true)`
1163 /// setting). `None` for the default non-persisting file; when `Some`, every
1164 /// [`commit`](Self::commit) rewrites the on-disk free-space managers so the
1165 /// free list survives close/reopen.
1166 persist: Option<PersistState>,
1167 /// Per-dataset geometry cache for the immediate O(1) in-place append
1168 /// ([`append_inplace_gathered`](Self::append_inplace_gathered)), keyed by the dataset's resolved
1169 /// **object-header address** (not its path, so two hard links to one dataset
1170 /// share one entry). Populated on the first append to a dataset and maintained
1171 /// across appends; cleared wholesale at the entry of every non-trivial
1172 /// [`commit`](Self::commit), since a commit can relocate a cached header or
1173 /// free the region it points into (see `commit`).
1174 located: HashMap<u64, LocatedState>,
1175 /// Global heap collections **this session placed** for a variable-length
1176 /// value overwrite, by the overwritten dataset's path, as absolute
1177 /// `(address, length)` extents.
1178 ///
1179 /// This is what lets the *next* overwrite of the same dataset reclaim the
1180 /// strings the previous one left, instead of growing the file by its whole
1181 /// payload on every commit forever (issue #321). Nothing else in the editor
1182 /// reclaims a collection, and the reason is sound: a collection can be
1183 /// shared between objects, and **nothing in the format proves otherwise**.
1184 /// The per-object reference count does not: an in-file
1185 /// [`copy`](Self::copy) of a variable-length dataset re-emits its
1186 /// element references verbatim, so two datasets name one collection with
1187 /// every object's count still 1 — and files with that shape are already in
1188 /// the wild.
1189 ///
1190 /// Provenance is therefore the whole proof: these collections were placed by
1191 /// this session, for this dataset, and were named by nothing else at the
1192 /// moment they were written. `invalidate_heap_provenance` is what keeps that
1193 /// true afterwards — every entry is dropped as soon as this session does
1194 /// anything that could name one of them a second time.
1195 ///
1196 /// The collections a dataset held when the session *opened* are never in
1197 /// here and are never reclaimed: their provenance is whatever wrote the
1198 /// file. `repack` is what recovers those.
1199 vl_overwrite_heaps: HashMap<PathKey, Vec<(u64, u64)>>,
1200 /// Heap collections superseded by a value overwrite this commit is applying,
1201 /// freed once the commit's superblock repoint has landed — never before, so
1202 /// a mid-commit crash leaves the prior root reaching bytes that are still
1203 /// there. Drained into the commit's `to_free` list; cleared at commit entry,
1204 /// so an attempt that fails partway frees nothing on the next one.
1205 superseded_heaps: Vec<(u64, u64)>,
1206 /// The bytes each same-length in-place overwrite of the commit in flight
1207 /// wrote over, so a commit that fails before its linearization point can put
1208 /// the dataset's values back (issue #344).
1209 ///
1210 /// Everything else a commit places is written where nothing reaches it until
1211 /// the superblock is repointed, so an attempt that stops short of the
1212 /// repoint is invisible. A same-length value overwrite is the exception:
1213 /// [`WritePlan::InPlace`] writes straight over the dataset's existing data
1214 /// block, which the *current* root already reaches, so there is no repoint
1215 /// to withhold and the new values are live the moment they land. This
1216 /// journal is how they are withheld anyway.
1217 ///
1218 /// Written only by
1219 /// [`write_inplace_journaled`](Self::write_inplace_journaled) and drained
1220 /// only by [`undo_inplace_writes`](Self::undo_inplace_writes). Every
1221 /// [`commit`](Self::commit) that returns leaves it empty, and one that
1222 /// unwinds does not, which is why the next commit clears it on entry rather
1223 /// than trusting it.
1224 ///
1225 /// **It costs a full copy of every value being replaced, read back off the
1226 /// file first.** Measured on the bounded backing, whose contract is not to
1227 /// hold the file in memory: a same-length overwrite of a 4 MB dataset made
1228 /// its commit read 4,000,197 bytes where the same commit without the
1229 /// journal read 197, and held the 4 MB until the commit ended. That is the
1230 /// price of the guarantee and it is charged on the success path too, since
1231 /// whether the bytes will be needed is not known until the attempt ends.
1232 /// `a_bounded_commit_reads_the_value_it_is_replacing` states the rule so
1233 /// the number is in the repository rather than a surprise.
1234 ///
1235 /// The alternative — planning a same-length overwrite as a relocating write,
1236 /// as a variable-length one already is — would cost nothing here and was
1237 /// rejected for a harder reason than cost: a relocating write is refused for
1238 /// a dataset with more than one hard link
1239 /// ([`count_incoming_hard_links`](Self::count_incoming_hard_links)), so it
1240 /// would start refusing overwrites this engine accepts today.
1241 inplace_undo: Vec<(usize, Vec<u8>)>,
1242 /// True when this engine was opened for SWMR writing
1243 /// ([`open_swmr_writer`](Self::open_swmr_writer)): the append engine then
1244 /// enforces the SWMR subset (unfiltered, chunk-aligned) so a concurrent
1245 /// reader never observes a torn view. `false` for an ordinary edit session.
1246 swmr_mode: bool,
1247 /// Paged-file state (`H5F_FSPACE_STRATEGY_PAGE`), read from the superblock
1248 /// extension at `open` regardless of the persist flag; `None` for the common
1249 /// non-paged file. When `Some`, [`commit`](Self::commit) takes a page-aware
1250 /// tail that keeps pages homogeneous and rewrites the per-page-type managers
1251 /// (issue #198). A paged file that does not *persist* its free space is still
1252 /// refused: see [`PagedEdit`].
1253 paged: Option<PagedEdit>,
1254 /// Set by the first [`commit`](Self::commit) that does any work. A commit can
1255 /// relocate an object header, and nothing on disk distinguishes a relocated
1256 /// header from the intact bytes it vacated — the old header still parses, and
1257 /// its data-layout message still points at the live chunk index. An
1258 /// [`AppendTarget::Header`] captured before that commit would therefore append
1259 /// successfully *into the dead header*, growing its dataspace while the live
1260 /// dataset stayed put, and report `Ok`. A path is re-resolved on every append
1261 /// and so survives a commit; a raw address does not, so it is refused once one
1262 /// has run.
1263 committed: bool,
1264 /// Object-header address for each path an in-place append has resolved in
1265 /// this session. A single `Dataset::append` asks for the target's geometry
1266 /// and then appends to it, and a loop of appends repeats that; without this
1267 /// the path would be walked from the root on every one of those steps.
1268 ///
1269 /// An in-place append never moves an object header — that is why
1270 /// [`located`](Self::located) can be keyed by address and survive appends —
1271 /// so only a commit can stale an entry, and it clears both together.
1272 resolved: HashMap<String, u64>,
1273 /// Whether this session splits a large in-place append into batches, trading
1274 /// whole-call crash atomicity for a peak memory that does not scale with the
1275 /// call. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy); see
1276 /// [`batch_elems`](Self::batch_elems).
1277 batched_appends: bool,
1278 /// Whether this session reads through a handle rather than a whole-file
1279 /// mirror. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy), and reported by
1280 /// [`File::edit_backing`](crate::File::edit_backing) so a caller who
1281 /// asked for [`MemoryStrategy::Auto`] can tell which one it got. Distinct
1282 /// from [`batched_appends`](Self::batched_appends), which is a crash-atomicity
1283 /// trade the bounded engine happens to make, not a statement about memory.
1284 bounded: bool,
1285 /// The on-disk format this session may write, resolved from the fapl's
1286 /// [`FileAccessProperties::with_libver_bounds`]. `None` — the default —
1287 /// means unconstrained: the session adds whatever the content needs, which
1288 /// is what lets a file the C library wrote under its own bounds be edited at
1289 /// all.
1290 ///
1291 /// Set below [`LibVer::V110`] it refuses content the older format cannot
1292 /// carry, the way the whole-file writer does. The file's *own* superblock
1293 /// version cannot stand in for this: a version 2 superblock says the file is
1294 /// 1.8-readable today, not that the caller wants it to stay that way, and
1295 /// deriving the ceiling from it would refuse the C-library-file edits of
1296 /// issue #101.
1297 ///
1298 /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
1299 libver_ceiling: Option<LibVer>,
1300 /// The file length when the on-disk free-space managers were last written,
1301 /// for a file that persists them. Every immediate in-place append grows the
1302 /// file past those managers and leaves them mid-file, so a session that ends
1303 /// with `image.len() != fsm_len` owes a rewrite; that is what
1304 /// [`finalize_persist`](Self::finalize_persist) settles at close. Meaningless
1305 /// (and untouched) when `persist` is `None`.
1306 fsm_len: u64,
1307 /// Whether the commit currently running has *asked* the disk to repoint the
1308 /// superblock at its new root. Set by whichever tail performs that write,
1309 /// immediately **before** issuing it, and read only by
1310 /// [`commit`](Self::commit) to decide whether a failure may roll anything
1311 /// back — the free lists (see [`FreeSnapshot`]), the staged set, and the
1312 /// values in [`inplace_undo`](Self::inplace_undo).
1313 ///
1314 /// Set before rather than after for the reason the whole rollback exists: a
1315 /// write that alters the file and then reports failure is a real device
1316 /// behaviour, so a write call returning an error does not mean its bytes
1317 /// are not on the disk. Once the publish has been issued, whether this
1318 /// commit is live is unknowable from here, and every rollback is unsound —
1319 /// undoing the values would tear a commit that did land, and re-offering
1320 /// the regions would hand out space that commit is using. Doing nothing is
1321 /// the only answer that is never actively wrong, and the error the caller
1322 /// gets is the write failure that says so (issue #344).
1323 ///
1324 /// It is a property of one publish, not of the session, so every publisher —
1325 /// `commit`, and the manager rewrite an append's draw makes — clears it on
1326 /// entry rather than trusting the previous run to have left it false.
1327 publish_attempted: bool,
1328 /// Whether a [`stage_atomically`](Self::stage_atomically) batch is open, so
1329 /// [`refuse_mid_batch`](Self::refuse_mid_batch) can keep
1330 /// [`StagedEdits::rewind`]'s truncation an exact undo.
1331 staging_batch: bool,
1332 /// How many times this session's staged set has been *consumed* by a commit
1333 /// rather than handed back to it.
1334 ///
1335 /// It is what tells a handle born onto a staged creation
1336 /// ([`staged_generation`](Self::staged_generation)) which of two things has
1337 /// happened since: the object it names was published, so it addresses the
1338 /// file now — or the staging was withdrawn under it, and it names nothing.
1339 /// Both leave the staged set without an entry at its path, and reading them
1340 /// the same way is what let such a handle silently retarget onto whatever
1341 /// the file held at that path.
1342 ///
1343 /// Advanced by [`commit`](Self::commit) alone, on exactly the path where the
1344 /// set does not go back: a commit that succeeds, and one that fails past its
1345 /// publish (whose objects are in the file). A commit refused before its
1346 /// first write restores the set and leaves this alone, so the handles it
1347 /// concerns stay pending and the batch can be committed again.
1348 staged_generation: u64,
1349 /// Superblock status flags this session raised on the file and holds for its
1350 /// lifetime — the SWMR pair, or the page buffer's crash mark — or `0` when it
1351 /// holds none.
1352 ///
1353 /// A clean commit publishes *this* rather than a literal zero. The zero was
1354 /// there to scrub a flag the file arrived carrying (one a crashed writer left
1355 /// behind), and it still does: this is `0` unless the session raised
1356 /// something itself. What it must not do is scrub a mark that is still
1357 /// standing for a reason, which a page-buffered session's is for every
1358 /// commit it makes (issue #308).
1359 ///
1360 /// Maintained only by [`set_consistency_flags`](Self::set_consistency_flags),
1361 /// which is the one place this crate writes that byte, so the field cannot
1362 /// drift from what is on the disk.
1363 held_status_flags: u32,
1364 /// Who owns this session's `fsync` cadence, from the fapl's
1365 /// [`FileAccessProperties::with_sync_policy`]. Consulted by
1366 /// [`barrier`](Self::barrier) and [`barrier_data`](Self::barrier_data) —
1367 /// every durability point the write paths define — and by nothing else, so
1368 /// [`sync_now`](Self::sync_now) can serve an explicit
1369 /// [`File::sync`](crate::File::sync) whatever it says.
1370 ///
1371 /// [`FileAccessProperties::with_sync_policy`]: crate::FileAccessProperties::with_sync_policy
1372 sync_policy: SyncPolicy,
1373}
1374
1375/// The free lists as they stood before a commit's apply loop drew from them.
1376///
1377/// A commit allocates as it goes — a free region it hands to an object header or
1378/// a chunk blob leaves the list immediately, so no two objects in the same commit
1379/// can be handed the same region. If the commit then fails, those regions are as
1380/// dead as they were before it started: nothing the commit wrote is reachable,
1381/// because the superblock still names the old root — and the one write that does
1382/// not wait for the repoint, a same-length value overwrite, is one
1383/// [`WriteEngine::undo_inplace_writes`] has already tried to put back by the
1384/// time this runs (issue #344). Restoring this snapshot gives them back instead
1385/// of leaking them for the rest of the session.
1386///
1387/// The restore is sound only *before* the repoint. After it, the objects written
1388/// into those regions are live, and handing the same addresses out again would
1389/// overwrite the tree the commit just published — which is why
1390/// [`WriteEngine::publish_attempted`] gates it.
1391struct FreeSnapshot {
1392 free: FreeList,
1393 /// The append reserve, which the persisting commit tail drains back into the
1394 /// lists above before it rewrites the managers. A commit that then fails
1395 /// before publishing leaves those managers as they were — still not listing
1396 /// the reserve — so restoring it is what keeps the session's picture of the
1397 /// disk exact (issue #387).
1398 reserved: FreeList,
1399 paged: Option<(FreeList, FreeList)>,
1400 /// The heap-collection provenance, rolled back with the free lists.
1401 ///
1402 /// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) records
1403 /// a placement in the *apply* phase, so a commit that fails after that and
1404 /// before its repoint hands the space back to the free list while leaving a
1405 /// record naming it. The next overwrite of that path would then free a
1406 /// region already handed out — the one piece of engine state naming file
1407 /// addresses that this rollback used to miss (issue #321).
1408 vl_overwrite_heaps: HashMap<PathKey, Vec<(u64, u64)>>,
1409}
1410
1411/// How much memory a read-write open may use to hold the file being edited.
1412///
1413/// The two read-write backends differ in memory, not in what they can express: a
1414/// *bounded* session reads through a handle and holds only what a commit is
1415/// building, while a *mirrored* session materializes the whole file in memory.
1416/// Bounded is the better default when it applies, but it cannot yet edit every
1417/// file — a pre-v2 (non-latest-format) superblock or a userblock still needs the
1418/// mirror.
1419///
1420/// This is what a caller says about that trade-off, on
1421/// [`FileAccessProperties::with_memory_strategy`](crate::FileAccessProperties::with_memory_strategy).
1422/// Leaving it unset lets the entry point decide: [`File::open_rw`](crate::File::open_rw)
1423/// prefers the bounded engine and falls back to the mirror ([`Auto`](Self::Auto)).
1424/// Stating [`Bounded`](Self::Bounded) refuses instead of falling back.
1425///
1426/// This is a *request*, so it is deliberately not the type a file answers with:
1427/// [`File::edit_backing`](crate::File::edit_backing) returns an [`EditBacking`],
1428/// which cannot express [`Auto`](Self::Auto).
1429///
1430/// Sealed: unlike [`FileLocking`] or [`FileSpaceStrategy`](crate::FileSpaceStrategy),
1431/// whose variant sets mirror a closed C-library enum, this is a policy this crate
1432/// invented, so a fourth strategy must not be a breaking change.
1433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1434#[non_exhaustive]
1435pub enum MemoryStrategy {
1436 /// Never build a whole-file mirror. A file the bounded engine cannot edit is
1437 /// refused at open with [`Error::EditUnsupported`], before anything is
1438 /// staged.
1439 Bounded,
1440 /// Prefer the bounded engine, but fall back to the whole-file mirror for a
1441 /// file it cannot edit, rather than refusing. Memory then scales with the
1442 /// file, which is the cost of the file opening at all. What
1443 /// [`File::open_rw`](crate::File::open_rw) uses when nothing is asked for.
1444 ///
1445 /// Two files reach that fallback, and no others: one with a pre-v2
1446 /// (non-latest-format) superblock, and one with a userblock (a non-zero base
1447 /// address). Every other file a read-write open accepts is edited bounded,
1448 /// whatever its [`FileSpaceStrategy`](crate::FileSpaceStrategy), whether or
1449 /// not it persists its free space, and however large it is. A paged file with
1450 /// no persisted free space is refused by *both* backings rather than
1451 /// mirrored, so it is not a third case. The list being exhaustive is what
1452 /// makes "prefer bounded" a guarantee to budget against: wherever
1453 /// [`Bounded`](Self::Bounded) opens a file, `Auto` gives the same backing.
1454 Auto,
1455 /// Always build the whole-file mirror, whatever the file looks like. What
1456 /// [`File::open_rw`](crate::File::open_rw) did before it learned to dispatch.
1457 Mirrored,
1458}
1459
1460/// Which of the two read-write backends a file's editing session is actually
1461/// using, from [`File::edit_backing`](crate::File::edit_backing).
1462///
1463/// Deliberately a different type from [`MemoryStrategy`]: that one is what a
1464/// caller *asks* for and includes [`Auto`](MemoryStrategy::Auto), which is a
1465/// preference between these two rather than a third thing a session can be. A
1466/// single shared type would make `file.backing() == Auto` a comparison that
1467/// compiles and is false forever.
1468///
1469/// The two also evolve at different rates. A future `MemoryStrategy` may name a
1470/// new *policy* — a byte budget, a size threshold — without the set of backends
1471/// changing at all. Sealed for the rarer case that a third backend does appear.
1472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1473#[non_exhaustive]
1474pub enum EditBacking {
1475 /// Reads through a file handle, holding only what a commit is building.
1476 /// Memory does not scale with the file.
1477 Bounded,
1478 /// Holds the whole file in memory for the life of the session.
1479 Mirrored,
1480}
1481
1482impl From<EditBacking> for MemoryStrategy {
1483 /// Turns an outcome back into the request that pins it, so a caller can
1484 /// reopen a file onto the backing it got the first time:
1485 /// `with_memory_strategy(file.edit_backing().unwrap().into())`.
1486 fn from(backing: EditBacking) -> Self {
1487 match backing {
1488 EditBacking::Bounded => Self::Bounded,
1489 EditBacking::Mirrored => Self::Mirrored,
1490 }
1491 }
1492}
1493
1494/// When a read-write session forces its writes to durable storage — who owns the
1495/// `fsync` cadence, this crate or the application.
1496///
1497/// This is *not* about whether a write reaches the file. Every commit and every
1498/// append has gone to the operating system by the time it returns, whatever this
1499/// policy says, so a committed edit is visible to any other process on the same
1500/// machine and survives this process crashing. What it governs is the `fsync` on
1501/// top of that, which is what makes those bytes survive the *machine* losing
1502/// power.
1503///
1504/// A session does gather the many small writes *inside* one such operation and
1505/// issue them a page at a time (issue #288), and an explicit
1506/// [page buffer](crate::FileAccessProperties::with_page_buffer_size) extends that
1507/// across operations — trading exactly the guarantee in the paragraph above,
1508/// which is why it is opt-in and this is not.
1509///
1510/// The reference C library does not `fsync` on its normal path either: the
1511/// default `sec2` driver installs no flush callback at all, so `H5Fflush` drains
1512/// libhdf5's own caches with `write` and stops there, leaving power-loss
1513/// durability to the application. [`OnClose`](Self::OnClose) is that behavior;
1514/// [`Always`](Self::Always), the default here, is the stronger one.
1515///
1516/// The whole-file writer ([`FileBuilder`](crate::FileBuilder)) and
1517/// [`repack`](crate::repack) are outside this: they never `fsync` under either
1518/// policy, since each writes a file and hands it over rather than holding an
1519/// editing session.
1520///
1521/// Sealed: a future policy (syncing on a timer, or once per N bytes) must not be
1522/// a breaking change.
1523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1524#[non_exhaustive]
1525pub enum SyncPolicy {
1526 /// Force durability at every point the write paths define one: after each
1527 /// immediate [`Dataset::append`](crate::Dataset::append) batch, at each
1528 /// ordering barrier inside a [`File::commit`](crate::File::commit), and when
1529 /// the file is closed or dropped.
1530 ///
1531 /// This is what makes an append crash-atomic and a commit all-or-nothing
1532 /// against power loss: the barriers order the appended data before the
1533 /// superblock repoint that publishes it, so an interrupted write leaves the
1534 /// previous state rather than a torn one.
1535 #[default]
1536 Always,
1537 /// No `fsync` during the session; one when the file is finished. Nothing an
1538 /// append or a commit does is forced to durable storage — writes still reach
1539 /// the operating system immediately, so another process on the same machine
1540 /// sees them and a crash of *this* process loses nothing — and
1541 /// [`File::close`](crate::File::close), or dropping the last handle, issues
1542 /// a single barrier at the end. [`File::sync`](crate::File::sync) adds a
1543 /// checkpoint wherever the application wants one.
1544 ///
1545 /// One setting suspends the "reach the operating system immediately" half,
1546 /// and it is the one that requires this policy:
1547 /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)
1548 /// holds dirty pages in this process until the budget is spent, a
1549 /// [`File::sync`](crate::File::sync) is issued, or the session closes. Off by
1550 /// default, and it marks the file on disk for its lifetime so a session that
1551 /// dies holding pages leaves one every reader refuses.
1552 ///
1553 /// The terminal barrier is not an exception grafted onto "never sync": it is
1554 /// the point past which the application *cannot* act. `close` and `drop`
1555 /// both write — they apply staged edits, re-home the free-space managers of
1556 /// a file that persists them, and clear a SWMR writer's flag — and they
1557 /// destroy the handle that would have ordered those writes. A policy that
1558 /// skipped the barrier there would not hand the caller a cadence; it would
1559 /// take one away. The cost is one `fsync` per session against the five per
1560 /// append batch and two or three per commit that this policy removes.
1561 ///
1562 /// Three things are given up in exchange. **Power-loss ordering** during the
1563 /// session: with the barriers gone, a machine that loses power mid-commit
1564 /// can have the superblock repoint on disk without the data it points at.
1565 /// **Deferred write errors**: on a filesystem that allocates late, a write
1566 /// that will fail at writeback still returns success, and `fsync` is where
1567 /// the `ENOSPC`/`EIO` surfaces, so a commit the filesystem cannot complete
1568 /// returns `Ok` until the terminal barrier reports it. **Cross-host
1569 /// visibility**: "another process sees it" is the page cache, so under NFS's
1570 /// close-to-open semantics a reader on another host — a SWMR reader
1571 /// included — may not see writes a client is holding.
1572 OnClose,
1573}
1574
1575/// Why the bounded engine cannot edit a file, when the whole-file mirror can.
1576///
1577/// Kept separate from the refusals that apply to *both* engines: a fallback is
1578/// only ever worth taking for a limitation the mirror does not share. A paged
1579/// file with no persisted free-space managers, for instance, is refused by the
1580/// staged commit as well, so mirroring it would trade a clear error at open for
1581/// the same error later with work already staged.
1582fn bounded_only_limitation(session: &WriteEngine) -> Option<&'static str> {
1583 if session.superblock.version < 2 {
1584 return Some(
1585 "bounded read-write access requires a latest-format file (v2/v3 superblock); \
1586 leave MemoryStrategy unset, or pass MemoryStrategy::Auto, to fall back to \
1587 the whole-file mirror here",
1588 );
1589 }
1590 if !session.superblock.base_address.is_zero() {
1591 return Some(
1592 "bounded read-write access does not support a file with a userblock \
1593 (non-zero base address); leave MemoryStrategy unset, or pass \
1594 MemoryStrategy::Auto, to fall back to the whole-file mirror here",
1595 );
1596 }
1597 None
1598}
1599
1600/// Whether a file built from `create` could not then be opened read-write under
1601/// `access`, and why.
1602///
1603/// [`File::create_with_options`](crate::File::create_with_options) writes a file
1604/// and hands back an open read-write handle, so a creation/access pair that
1605/// cannot survive that second half must be caught *before* the write — otherwise
1606/// the call leaves a file on disk and returns `Err`, which reads like a failed
1607/// create but is not one.
1608///
1609/// This mirrors the open-time refusals above and must be kept in step with them:
1610/// [`bounded_only_limitation`] for the userblock, and the shared paged check in
1611/// [`open_rw_with_strategy`](WriteEngine::open_rw_with_strategy) for a paged file
1612/// with no persisted free space. Both are stated here in terms of the properties
1613/// that *cause* them, because the open-time wording tells the caller to recreate
1614/// the file — advice that is circular when the caller is creating it.
1615pub(crate) fn create_would_refuse_reopen(
1616 create: &FileCreateProperties,
1617 access: &FileAccessProperties,
1618) -> Option<&'static str> {
1619 if let Some((FileSpaceStrategy::Page, false, _)) = create.file_space_strategy() {
1620 return Some(
1621 "a paged file (FileSpaceStrategy::Page) with persist = false cannot be reopened \
1622 read-write, so creating one this way would write the file and then fail to open \
1623 it; pass persist = true to with_file_space_strategy, or build the file with \
1624 FileBuilder if it is only ever going to be read",
1625 );
1626 }
1627 if create.userblock() != 0 && access.memory_strategy() == Some(MemoryStrategy::Bounded) {
1628 return Some(
1629 "a userblock cannot be combined with MemoryStrategy::Bounded: the bounded engine \
1630 cannot edit a file with a non-zero base address, so creating one this way would \
1631 write the file and then refuse to open it; drop the userblock, or leave \
1632 MemoryStrategy unset to mirror this file",
1633 );
1634 }
1635 // A userblock on a *paged* file reaches the same open-time refusal as
1636 // `persist = false` above, by a different route: persisted free space is
1637 // declined for a non-zero base address, which leaves a paged file with no
1638 // seeded managers however its creation properties were written.
1639 if create.userblock() != 0
1640 && matches!(
1641 create.file_space_strategy(),
1642 Some((FileSpaceStrategy::Page, _, _))
1643 )
1644 {
1645 return Some(
1646 "a userblock cannot be combined with FileSpaceStrategy::Page: free space is not \
1647 persisted for a file with a non-zero base address, and a paged file without it \
1648 cannot be opened read-write — so creating one this way would write the file and \
1649 then fail to open it; drop the userblock, or choose another file-space strategy",
1650 );
1651 }
1652 // The page-buffer refusals `set_page_buffer_size` makes at open, restated as
1653 // the creation pair that would walk into them. They are phrased as something
1654 // to *add* to the properties in hand rather than as something to recreate the
1655 // file with, since the file does not exist yet (issues #288 and #308).
1656 if access.page_buffer_size() != 0 {
1657 if access.sync_policy() == SyncPolicy::Always {
1658 return Some(
1659 "a page buffer does nothing under the default SyncPolicy::Always, whose every \
1660 barrier is an fsync that flushes it, so creating one this way would write the \
1661 file and then fail to open it; add \
1662 with_sync_policy(SyncPolicy::OnClose) to the access properties, or drop the \
1663 page buffer",
1664 );
1665 }
1666 // The version-3 counterpart. It used to need none: a page buffer required
1667 // a paged file, and `FileSpaceStrategy::Page` below the 1.10 format is
1668 // refused by the builder itself before anything is written, so that pair
1669 // never reached an open. Issue #357 lifted the paged requirement, and an
1670 // *unpaged* file at `LibVer::V18` is an ordinary, buildable file with a
1671 // version-2 superblock — one whose status-flags byte no library reads
1672 // back, so the mark could not be raised on it. Without this the pair
1673 // would write the file and fail the open it promised.
1674 //
1675 // Unsatisfiable bounds are left to the builder, which reports which
1676 // format it can write; this is not the place to restate that.
1677 if matches!(
1678 crate::libver::LibVer::resolve_writable(create.libver_bounds()),
1679 Ok(v) if v < crate::libver::LibVer::V110
1680 ) {
1681 return Some(
1682 "a page buffer marks the file in a version-3 superblock, and these bounds \
1683 write the 1.8 format, so creating one this way would write the file and then \
1684 fail to open it; raise with_libver_bounds to admit LibVer::V110, or drop the \
1685 page buffer",
1686 );
1687 }
1688 // An unpaged file has no page size of its own and merges within
1689 // `DEFAULT_GATHER_PAGE`, which is the same figure the format defaults
1690 // `H5Pset_file_space_page_size` to. This arm decides on its own now that
1691 // a budget below the session's own gather budget is honored rather than
1692 // refused (issue #391): a 2 KiB buffer on an unpaged create clears every
1693 // other check and is caught only here. It is written out rather than
1694 // folded into the paged arm's `unwrap_or` because the two constants are
1695 // free to move apart, and because a reader comparing this function
1696 // against `set_page_buffer_size` should find the same rule in both.
1697 let page_size = match create.file_space_strategy() {
1698 Some((FileSpaceStrategy::Page, _, _)) => create
1699 .file_space_page_size()
1700 .unwrap_or(crate::file_space_info::DEFAULT_PAGE_SIZE),
1701 _ => DEFAULT_GATHER_PAGE,
1702 };
1703 if (access.page_buffer_size() as u64) < page_size {
1704 return Some(
1705 "a page buffer smaller than the file's file-space page size would be refused \
1706 at open — so creating one this way would write the file and then fail to \
1707 open it; raise with_page_buffer_size",
1708 );
1709 }
1710 }
1711 None
1712}
1713
1714/// What a commit knows about a region it is vacating, which is what decides
1715/// where the region may be recorded.
1716///
1717/// A flat file ignores the distinction — every vacated region is simply free —
1718/// and a paged one cannot, because a byte handed out for the wrong page type
1719/// mixes the page it lands in.
1720#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1721enum FreeClass {
1722 /// The region sits in pages of this type, so it becomes free space of that
1723 /// type and may serve the next allocation of it.
1724 Page(PageType),
1725 /// The region provably holds nothing live, but which page type surrounds it
1726 /// is not provable — a chunk index this crate cannot place
1727 /// ([`WriteEngine::index_is_provably_raw`]) is the case that arises.
1728 ///
1729 /// It may not be handed out as either type, so it is recorded as *dead*
1730 /// rather than free: bytes that are no longer in use and are not yet
1731 /// reusable. Once every other byte of the page around it is free or dead
1732 /// too, the page holds nothing of either type and
1733 /// [`PagedEdit::promote_whole_free_pages`] turns the whole page into free
1734 /// space, which is the same rule that lets one page type claim a wholly free
1735 /// page from the other ([`PagedEdit::alloc_typed`]).
1736 Dead,
1737}
1738
1739impl From<PageType> for FreeClass {
1740 fn from(ty: PageType) -> Self {
1741 FreeClass::Page(ty)
1742 }
1743}
1744
1745/// Paged-file bookkeeping for the whole-file editor (issue #198, step 1).
1746///
1747/// A paged file never mixes metadata and raw data within one page, so this tracks
1748/// free space per page *type* — the one distinction that governs where a byte may
1749/// be placed — and keeps the commit's appends homogeneous by padding a tail page
1750/// whenever the page type changes.
1751///
1752/// One list per type, not one per on-disk manager. A paged file records its free
1753/// space in three managers (metadata-small, raw-small, and a generic-large one for
1754/// whole free pages), but for space this session can place, that split is a size
1755/// classification of the same raw or metadata space, and [`plan_paged_managers`]
1756/// recomputes it from scratch at every commit. Tracking it here as well would only
1757/// stop a freed chunk-data run and the freed index that abuts it from coalescing —
1758/// leaving two neighboring holes neither of which fits the dataset that just
1759/// vacated both (issue #261).
1760///
1761/// The free lists are seeded only when the file *persists* its free space. A paged
1762/// non-persisting file has no on-disk record of which pages hold metadata and
1763/// which hold raw data, so there is nothing to seed and no way to stay segregated;
1764/// [`commit`](EditSession::commit) refuses it outright, exactly as the bounded
1765/// backend does.
1766struct PagedEdit {
1767 page_size: u64,
1768 /// Free space inside metadata pages, plus whole free pages this session last
1769 /// saw as metadata. A page holding nothing belongs to no type, so
1770 /// [`alloc_typed`](Self::alloc_typed) lets raw data claim one from here.
1771 meta: FreeList,
1772 /// Free space inside raw-data pages, plus whole free pages this session last
1773 /// saw as raw — including every one seeded from the generic-large manager,
1774 /// which records no type. Metadata claims from here on the same terms.
1775 raw: FreeList,
1776 /// Free space this session may record but must never hand out, because the
1777 /// page it sits in is of unknown type. Seeded at open — nothing this engine
1778 /// frees is ever of unknown type, so it only ever shrinks, and only where a
1779 /// commit released the end of the file over it (issue #418) — and written
1780 /// back to the generic-large manager verbatim, so the space stays available
1781 /// to the writer that recorded it.
1782 ///
1783 /// It exists because the reference C library's generic-large manager is
1784 /// exactly that: `H5F_MEM_PAGE_GENERIC` is *"large-sized generic: meta and
1785 /// raw"*, and under paged aggregation on a contiguous-address driver every
1786 /// allocation of a page or more lands there whatever its type. Its
1787 /// page-alignment tail (`H5MF__alloc_pagefs`) is then recorded as a free
1788 /// section in the same manager — a sub-page fragment sitting in a page whose
1789 /// earlier bytes are live. Handing such a fragment to raw data would put raw
1790 /// bytes in a metadata page, so a section that is not a whole run of aligned
1791 /// pages is kept here rather than guessed at.
1792 unclassified: FreeList,
1793 /// Space this session has vacated whose page type it could not prove
1794 /// ([`FreeClass::Dead`]): no longer in use, and not reusable as either type.
1795 ///
1796 /// It is held rather than discarded so that the page around it can still be
1797 /// reclaimed: a page every one of whose bytes is free or dead holds nothing
1798 /// of either type, and [`promote_whole_free_pages`](Self::promote_whole_free_pages)
1799 /// moves it into the typed lists whole. Without this list those bytes would
1800 /// simply be lost, and the page they sit in could never be shown to be empty
1801 /// — which is what made a paged file leak a chunk index per deleted empty
1802 /// resizable dataset (issue #388).
1803 ///
1804 /// Unlike the free lists this one is not written to disk: a dead sub-page
1805 /// fragment is not free space by the file's account, and recording it in the
1806 /// only manager that takes untyped space would offer it to the reference
1807 /// library for either type. A fragment that never completes a page is
1808 /// therefore forgotten at close, which wastes its bytes and nothing more.
1809 dead: FreeList,
1810 /// Page type of the current tail page. `None` until this session's first
1811 /// typed append; the file is page-aligned at open, so the first append never
1812 /// needs to pad regardless of this.
1813 last: Option<PageType>,
1814 /// Free tails left by padding a metadata page before a raw append.
1815 meta_pad: Vec<(u64, u64)>,
1816 /// Free tails left by padding a raw page before a metadata append.
1817 raw_pad: Vec<(u64, u64)>,
1818}
1819
1820impl PagedEdit {
1821 /// Ensure the next allocation on `image` begins in a page holding page type
1822 /// `ty`: when the tail page holds the *other* type and is only partially
1823 /// filled, pad it to a page boundary and record the padding as free space of
1824 /// the outgoing type.
1825 ///
1826 /// This is the whole of the paged-append rule, and it lives here so the two
1827 /// places that grow a paged file — the staged commit through
1828 /// [`WriteEngine::begin_page`](WriteEngine::begin_page), and the shared
1829 /// Extensible-Array append engine through [`EditStore`] — cannot drift. They
1830 /// used to keep separate copies of this state, one per engine, which is what
1831 /// made an in-place append to a paged file unsafe from the whole-file editor
1832 /// (issue #198).
1833 ///
1834 /// Call it **before** reading the image's end-of-file to compute an address
1835 /// that will be embedded in the bytes being built: several callers build
1836 /// content whose interior addresses assume it lands at the current
1837 /// end-of-file, and padding inserted after that read would shift the landing
1838 /// address out from under them.
1839 fn begin(&mut self, image: &mut dyn FileImage, ty: PageType) -> Result<(), Error> {
1840 let len = image.len();
1841 if len % self.page_size != 0 {
1842 let pad_len = self.page_size - len % self.page_size;
1843 // `prev` is the outgoing page type to record the padding under, or
1844 // `None` for a crash-recovery pad whose tail-page type is unknown.
1845 let pad = match self.last {
1846 // Normal case: the tail page holds a known type; pad only on a
1847 // type switch, recording the tail as free of the outgoing type.
1848 Some(prev) if prev != ty => Some(Some(prev)),
1849 Some(_) => None, // same type: keep packing the tail page
1850 // A previous session grew this paged file and was killed before
1851 // its tail was page-aligned, so the file opened non-page-aligned
1852 // with no known tail type. Pad it up (extending whatever the tail
1853 // page holds, so the page stays homogeneous) and leave the padding
1854 // untracked, since recording it under the wrong page type could
1855 // let a reader reuse it and mix the page.
1856 None => Some(None),
1857 };
1858 if let Some(prev) = pad {
1859 let pad_at = len;
1860 image.append(&vec![0u8; pad_len.to_usize()?])?;
1861 match prev {
1862 Some(PageType::Meta) => self.meta_pad.push((pad_at, pad_len)),
1863 Some(PageType::Raw) => self.raw_pad.push((pad_at, pad_len)),
1864 None => {} // crash-recovery pad: untracked (tail type unknown)
1865 }
1866 }
1867 }
1868 self.last = Some(ty);
1869 Ok(())
1870 }
1871
1872 fn new(page_size: u64) -> Self {
1873 PagedEdit {
1874 page_size,
1875 meta: FreeList::new(),
1876 raw: FreeList::new(),
1877 unclassified: FreeList::new(),
1878 dead: FreeList::new(),
1879 last: None,
1880 meta_pad: Vec::new(),
1881 raw_pad: Vec::new(),
1882 }
1883 }
1884
1885 /// Which list a persisted section from File Space Info `slot` belongs in.
1886 ///
1887 /// Slot 0 (SUPER) is small metadata and slot 2 (DRAW) is small raw: both name
1888 /// a page type outright. Slot 6 is the generic-large manager, which holds
1889 /// space of *either* type (see [`unclassified`](Self::unclassified)), so a
1890 /// section from it is only safe to reuse when it covers whole aligned pages —
1891 /// and such a section belongs to no type at all, since nothing lives in those
1892 /// pages to be mixed with. It is filed under raw and reached from either side
1893 /// through [`alloc_typed`](Self::alloc_typed), rather than kept in a third
1894 /// list, so that a run of free pages still coalesces with the sub-page
1895 /// fragment beside it. Everything else, including the per-type large managers
1896 /// a multi/split driver would populate, is left unclassified rather than
1897 /// guessed at.
1898 fn slot_list(slot: usize, addr: u64, size: u64, page_size: u64) -> Option<PageType> {
1899 match slot {
1900 0 => Some(PageType::Meta),
1901 2 => Some(PageType::Raw),
1902 6 if addr % page_size == 0 && size % page_size == 0 => Some(PageType::Raw),
1903 _ => None,
1904 }
1905 }
1906
1907 /// Record `(addr, size)` in the list its class names: the free space of one
1908 /// page type, or the dead list for space whose page type is unproven.
1909 /// [`plan_paged_managers`] splits and classes the free lists into the on-disk
1910 /// managers at serialization time, so this only has to route.
1911 ///
1912 /// Takes the lists rather than `&mut self` because a commit routes into
1913 /// *copies* of them — nothing is free until the superblock repoint — and the
1914 /// rule must not be restated at that call site.
1915 fn route_free(
1916 meta: &mut FreeList,
1917 raw: &mut FreeList,
1918 dead: &mut FreeList,
1919 addr: u64,
1920 size: u64,
1921 class: FreeClass,
1922 ) {
1923 match class {
1924 FreeClass::Page(PageType::Meta) => meta.free(addr, size),
1925 FreeClass::Page(PageType::Raw) => raw.free(addr, size),
1926 FreeClass::Dead => dead.free(addr, size),
1927 }
1928 }
1929
1930 /// Move every page that is *wholly* free or dead out of the three lists and
1931 /// into the raw list as one whole free page.
1932 ///
1933 /// A paged file may only place a byte in a page of its own type, which is why
1934 /// free space is tracked per type at all. A page holding nothing belongs to no
1935 /// type, so it may be opened for whichever type asks — the rule
1936 /// [`alloc_typed`](Self::alloc_typed) already applies to a whole page inside
1937 /// the other type's list, and [`slot_list`](Self::slot_list) to a whole page
1938 /// read back from the untyped manager. This applies the same rule one level
1939 /// up, to a page whose emptiness only the three lists *together* establish:
1940 /// part free metadata, part free raw data, part space vacated by an object
1941 /// whose page type could not be proven ([`FreeClass::Dead`]).
1942 ///
1943 /// That last part is what makes it necessary rather than a tidy-up. Dead
1944 /// space is never handed out on its own, so without this step a deleted chunk
1945 /// index this crate cannot place is lost for good, and a paged file under
1946 /// delete-and-recreate churn spends a page every few cycles that it can never
1947 /// take back (issue #388). Promoting the page is not a guess about what once
1948 /// sat in it: nothing live is left there to be mixed with.
1949 ///
1950 /// Filed under raw, which is where a whole free page belongs by the same
1951 /// convention [`slot_list`](Self::slot_list) reads them back under, and
1952 /// reachable from either side through `alloc_typed`.
1953 fn promote_whole_free_pages(
1954 meta: &mut FreeList,
1955 raw: &mut FreeList,
1956 dead: &mut FreeList,
1957 page_size: u64,
1958 ) {
1959 // The lists are individually coalesced and pairwise disjoint (a byte is
1960 // vacated once), so their union is a plain merge: sort by address and join
1961 // runs that touch. A page is promotable exactly when it lies inside one of
1962 // those runs, which is what a page split across two of the lists needs.
1963 let mut all = meta.sections();
1964 all.extend(raw.sections());
1965 all.extend(dead.sections());
1966 all.sort_unstable_by_key(|&(addr, _)| addr);
1967 let mut runs: Vec<(u64, u64)> = Vec::with_capacity(all.len());
1968 for (addr, len) in all {
1969 match runs.last_mut() {
1970 Some(run) if run.0 + run.1 >= addr => {
1971 let end = (run.0 + run.1).max(addr + len);
1972 run.1 = end - run.0;
1973 }
1974 _ => runs.push((addr, len)),
1975 }
1976 }
1977 for (addr, len) in runs {
1978 // The whole pages inside the run: its aligned interior, exactly as
1979 // `FreeList::alloc_whole_units` takes one. The partial edges sit in
1980 // pages whose other bytes may be live, so they keep whatever they are.
1981 let first = addr.next_multiple_of(page_size);
1982 let last = (addr + len) / page_size * page_size;
1983 if last <= first {
1984 continue;
1985 }
1986 let span = last - first;
1987 meta.take_range(first, span);
1988 raw.take_range(first, span);
1989 dead.take_range(first, span);
1990 raw.free(first, span);
1991 }
1992 }
1993
1994 /// Draw `len` bytes of page type `ty` from this file's free space, or `None`
1995 /// when nothing can serve it.
1996 ///
1997 /// Its own list first, which is the only place a *partly* free page can serve
1998 /// it. Failing that, whole pages inside the other type's free space: a page
1999 /// with nothing in it belongs to neither type, so opening it for `ty` cannot
2000 /// mix it. Enough whole pages to cover `len` are claimed, and the remainder
2001 /// joins `ty`'s list, since those pages now hold `ty`.
2002 ///
2003 /// The cross-type claim is what lets space survive a close. A whole free page
2004 /// is written to the generic-large manager, which records no page type, so
2005 /// every one of them comes back from disk as raw ([`slot_list`](Self::slot_list));
2006 /// without this, metadata could never reuse a page again after a reopen, and
2007 /// each session's commits would append past all of them (issue #286).
2008 fn alloc_typed(&mut self, len: u64, ty: PageType) -> Option<u64> {
2009 let (own, other) = match ty {
2010 PageType::Meta => (&mut self.meta, &mut self.raw),
2011 PageType::Raw => (&mut self.raw, &mut self.meta),
2012 };
2013 if let Some(addr) = own.alloc(len) {
2014 return Some(addr);
2015 }
2016 let span = align_up(len, self.page_size);
2017 let addr = other.alloc_whole_units(span, self.page_size)?;
2018 if span > len {
2019 own.free(addr + len, span - len);
2020 }
2021 Some(addr)
2022 }
2023
2024 /// The longest contiguous run [`alloc_typed`](Self::alloc_typed) could serve
2025 /// `ty` from in one call, or `0` when nothing could: the larger of its own
2026 /// list's longest region and the longest run of whole free pages in the
2027 /// other's.
2028 ///
2029 /// The same two sources in the same terms, so a caller sizing a draw on this
2030 /// figure gets exactly the run `alloc_typed` then hands it.
2031 fn largest_typed(&self, ty: PageType) -> u64 {
2032 let (own, other) = match ty {
2033 PageType::Meta => (&self.meta, &self.raw),
2034 PageType::Raw => (&self.raw, &self.meta),
2035 };
2036 own.largest().max(other.largest_whole_units(self.page_size))
2037 }
2038
2039 /// Every free region this session could still hand out, ascending by address.
2040 /// Used for space accounting, where the caller wants one total rather than a
2041 /// per-page-type breakdown.
2042 ///
2043 /// [`unclassified`](Self::unclassified) is excluded: it is free space, and
2044 /// [`File::persisted_free_space`](crate::File::persisted_free_space) reports
2045 /// it as such, but this session will never place anything in it, which is
2046 /// what the accounting field it feeds is about.
2047 fn reusable_sections(&self) -> Vec<(u64, u64)> {
2048 let mut out = self.meta.sections();
2049 out.extend(self.raw.sections());
2050 out.sort_unstable_by_key(|&(addr, _)| addr);
2051 debug_assert!(
2052 out.windows(2)
2053 .all(|w| w[0].0.saturating_add(w[0].1) <= w[1].0),
2054 "a region is free in both the metadata and the raw list, so the two \
2055 page-type lists have stopped being disjoint"
2056 );
2057 out
2058 }
2059}
2060
2061/// The free space a paged commit is about to write to disk, as
2062/// [`WriteEngine::paged_post_free`] computes it: the session's lists with this
2063/// commit's frees folded in, held apart from the session until the repoint makes
2064/// them true.
2065struct PagedPostFree {
2066 meta: FreeList,
2067 raw: FreeList,
2068 /// The session's dead space plus this commit's, with every page the three
2069 /// lists together empty already promoted out of it
2070 /// ([`PagedEdit::promote_whole_free_pages`]).
2071 dead: FreeList,
2072 /// Already flattened: nothing in a commit adds to or draws from this one.
2073 unclassified: Vec<FreeSection>,
2074}
2075
2076impl PagedPostFree {
2077 /// Give the run of unreferenced space that reaches `eof` back to the
2078 /// filesystem: drop it from every list here and return the end-of-allocation
2079 /// the commit should publish, which the caller truncates the file to
2080 /// (issue #418). `eof` itself when nothing at the end of the file is free.
2081 ///
2082 /// The cut is rounded **up** to a page boundary, so a paged file's
2083 /// end-of-allocation stays a whole number of pages: a run that begins
2084 /// mid-page begins in a page whose earlier bytes are live, and only the whole
2085 /// pages above it can go. That is the same granularity the reference C
2086 /// library shrinks a paged file at — its `H5MF__sect_large_can_shrink` sees
2087 /// only the large (whole-page) sections, the small per-type managers having
2088 /// no shrink at all — so a partial page at the end is kept and recorded,
2089 /// exactly as it is there.
2090 ///
2091 /// Every list is cut, including the space this session may record but never
2092 /// place ([`PagedEdit::unclassified`]) and the space it has vacated but
2093 /// cannot type ([`PagedEdit::dead`]). Bytes past the end-of-allocation are
2094 /// not in the file at all, so a section describing them would point outside
2095 /// it — the one thing the persisted managers must never do.
2096 ///
2097 /// Whole pages holding [`TRAILING_RESERVE_TAILS`] tails stay in the file, and
2098 /// stay recorded, for the reason [`release_trailing_run`] gives on a flat
2099 /// file — with the same refusal to release a run that gives back less than it
2100 /// keeps.
2101 fn release_trailing(&mut self, eof: u64, page_size: u64, tail_len: u64) -> u64 {
2102 let mut unclassified = FreeList::new();
2103 for s in &self.unclassified {
2104 unclassified.free(s.addr, s.size);
2105 }
2106 let start = trailing_run_start([&self.meta, &self.raw, &self.dead, &unclassified], eof);
2107 let cut = align_up(start, page_size);
2108 if cut >= eof {
2109 return eof;
2110 }
2111 // Whole pages are this file's unit, so the reserve is one too, and the
2112 // same "give back more than you keep" condition applies.
2113 let keep = align_up(TRAILING_RESERVE_TAILS * tail_len, page_size);
2114 if eof - cut < 2 * keep {
2115 return eof;
2116 }
2117 let eoa = cut + keep;
2118 let span = eof - eoa;
2119 self.meta.take_range(eoa, span);
2120 self.raw.take_range(eoa, span);
2121 self.dead.take_range(eoa, span);
2122 // Clamped rather than dropped: a section straddling the cut keeps the part
2123 // that is still inside the file.
2124 for s in &mut self.unclassified {
2125 s.size = s.size.min(eoa.saturating_sub(s.addr));
2126 }
2127 self.unclassified.retain(|s| s.size > 0);
2128 eoa
2129 }
2130}
2131
2132/// How much free space a released trailing run leaves behind, as a multiple of
2133/// the manager tail a persisting commit writes.
2134///
2135/// A persisting file rewrites those blocks on *every* commit, and a rewrite can
2136/// never land in its own predecessor's extent — the on-disk superblock still
2137/// points at it until the repoint, so overwriting it would destroy the file a
2138/// crash there falls back on. The end of the file therefore has to keep room for
2139/// the tails that follow this one, and a tail is not a fixed size: every
2140/// abandoned one adds a section to the managers, which makes the *next* tail
2141/// longer again.
2142///
2143/// Measured rather than chosen, on the shipped rule. At one,
2144/// `a_released_file_holds_its_length_across_many_tail_rewrites` leaves the flat
2145/// file alternating between 5,992 and 6,303 bytes instead of holding one length,
2146/// because a tail a section longer than the one that sized the reserve does not
2147/// fit it. At two, `persisting_churn_reaches_a_steady_size` has not settled by
2148/// its sixteenth round — 15,095 bytes after a middle third that held 14,685.
2149/// Three is the smallest value both accept; four is one tail of margin, the tail
2150/// length being itself a function of a section count that varies with the file.
2151const TRAILING_RESERVE_TAILS: u64 = 4;
2152
2153/// Release the run of free space reaching `eof` from a flat file's post-commit
2154/// free list, and return the end-of-allocation the commit should publish
2155/// (issue #418).
2156///
2157/// Not the whole run: [`TRAILING_RESERVE_TAILS`] tails' worth of it stays in the
2158/// file, and stays recorded, so the commits after this one still have somewhere
2159/// to write their own manager blocks.
2160///
2161/// And nothing at all unless the run gives back at least as much as it keeps.
2162/// Trimming the top off a hole that is *about* to be reused is the worst of both:
2163/// it returns a few hundred bytes and leaves the hole too small for the object
2164/// that would have filled it, which then goes past end-of-file instead, so the
2165/// file grows by a whole object to give back a fraction of one.
2166///
2167/// So `eof` — the file's length, unchanged — when it ends in live bytes and when
2168/// the run is not worth releasing; the list is then left exactly as it came in.
2169fn release_trailing_run(post: &mut FreeList, eof: u64, tail_len: u64) -> u64 {
2170 let run_start = trailing_run_start([&*post], eof);
2171 let keep = TRAILING_RESERVE_TAILS * tail_len;
2172 if eof - run_start < 2 * keep {
2173 return eof;
2174 }
2175 post.take_range(run_start + keep, eof - run_start - keep);
2176 run_start + keep
2177}
2178
2179/// Whether a persisting file's tail rewrite may grow the file to place its
2180/// manager blocks.
2181///
2182/// The two callers want opposite things from a tail that will not fit in free
2183/// space, and the difference is not a preference: a commit publishing a tree
2184/// *must* record its free space somewhere, while the shrink pass exists only to
2185/// make the file smaller and would defeat itself by appending.
2186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2187enum TailPlacement {
2188 /// Place the tail in free space where it fits and past end-of-file where it
2189 /// does not. Every commit that publishes a tree.
2190 Anywhere,
2191 /// Place the tail in free space or not at all: when nothing fits, the
2192 /// rewrite is abandoned before it writes a byte and the file is left exactly
2193 /// as the commit before it published.
2194 ReuseOnly,
2195}
2196
2197/// Where a commit has decided to put one allocation's bytes, handed out by
2198/// [`WriteEngine::reserve`] and consumed by [`WriteEngine::place`].
2199///
2200/// The two arms differ in how the bytes are written — over a dead interior region
2201/// or past the end of the file — and in nothing else the caller sees, so a caller
2202/// that needs the address before the bytes (a relocatable blob) never has to know
2203/// which it got.
2204#[derive(Clone, Copy, Debug)]
2205enum Placement {
2206 /// A region an earlier commit freed, already removed from its free list.
2207 Reused { addr: u64, len: u64 },
2208 /// The end-of-file, with the page for the requested type already open.
2209 Appended { addr: u64, len: u64 },
2210}
2211
2212impl Placement {
2213 /// The absolute file address the bytes will occupy.
2214 fn address(self) -> u64 {
2215 match self {
2216 Placement::Reused { addr, .. } | Placement::Appended { addr, .. } => addr,
2217 }
2218 }
2219
2220 /// The reserved byte count, which the placed bytes must match exactly.
2221 fn len(self) -> u64 {
2222 match self {
2223 Placement::Reused { len, .. } | Placement::Appended { len, .. } => len,
2224 }
2225 }
2226}
2227
2228/// What an in-place append names its target by.
2229///
2230/// A handle with a resolvable path uses it, which lets the session compare the
2231/// target against its own staged edits. A handle reached by object reference has
2232/// no path, so it names the dataset by the object-header address it was reached
2233/// through — the same key the geometry cache uses.
2234#[derive(Clone, Copy)]
2235pub(crate) enum AppendTarget<'a> {
2236 Path(&'a str),
2237 Header(u64),
2238}
2239
2240/// Byte budget for one append batch on a session that batches: a large append is
2241/// split into whole-chunk batches of at most this many raw bytes (always at least
2242/// one chunk), each applied as its own crash-atomic fsync-barriered sequence, so
2243/// peak append memory never scales with the caller's slice.
2244const APPEND_BATCH_BYTES: u64 = 1 << 20;
2245
2246/// The data-only durability barrier: an ordering point always, and an `fsync`
2247/// only when this session's [`SyncPolicy`] keeps the cadence.
2248///
2249/// One function rather than one per caller because the commit path
2250/// ([`WriteEngine::barrier_data`]) and the append engine's phase boundaries
2251/// ([`EditStore::sync`]) are the same point reached from two owners of the
2252/// image, and a barrier that orders at one and not the other is a
2253/// crash-consistency defect no test on the default policy can see (issue #288).
2254///
2255/// The match is exhaustive on purpose, as at [`WriteEngine::barrier`]:
2256/// `SyncPolicy` is sealed to the outside but not to this crate, so a policy
2257/// added later fails to compile here until someone decides what it means.
2258fn barrier_data(image: &mut dyn FileImage, sync_policy: SyncPolicy) -> Result<(), Error> {
2259 match sync_policy {
2260 SyncPolicy::Always => image.sync_data(),
2261 SyncPolicy::OnClose => image.ordering_barrier(),
2262 }
2263}
2264
2265/// Byte budget for the writes one operation may gather before they are issued
2266/// (see [`WriteBuffering::Operation`]): the default every exclusively locked
2267/// read-write session opens under.
2268///
2269/// A megabyte, the same figure as [`APPEND_BATCH_BYTES`] and for a related
2270/// reason: that is already what a bounded session spends holding one batch of
2271/// raw append data, so this is a familiar quantum for the write path rather than
2272/// a new one. The two are not derived from each other and either may be tuned
2273/// alone. An operation larger than this is flushed part-way, which costs it
2274/// little — its writes are long and contiguous by then.
2275///
2276/// # Choosing a page-buffer budget
2277///
2278/// An explicit [page buffer](crate::FileAccessProperties::with_page_buffer_size)
2279/// *replaces* this budget rather than adding to it, and holds what it gathers
2280/// across operations rather than releasing it at every ordering barrier. The two
2281/// figures are therefore not comparable as memory: this one is a per-operation
2282/// peak, a page buffer's is continuous residency. A budget below it asks for less
2283/// of that residency and pays in writes, wherever one long contiguous run has to
2284/// be flushed and restarted. Writes issued on a 4 KiB-paged file:
2285///
2286/// ```text
2287/// workload unset 4 KiB 64 KiB 1 MiB
2288/// 32 chunk appends into 8 + commit 188 25 4 4
2289/// one 4 MiB append 131 1094 74 10
2290/// ```
2291///
2292/// Nothing here bypasses the gatherer the way `H5PB_write` bypasses the C page
2293/// buffer for any I/O of a page or more (issue #357), so the second row is what
2294/// a small budget actually costs; `a_smaller_budget_issues_more_writes` asserts
2295/// that ordering rather than these counts. Which side of it to want is the
2296/// caller's trade, not this crate's (issue #391).
2297const WRITE_GATHER_BYTES: usize = 1 << 20;
2298
2299/// Page the write gatherer merges within on a file that is not paged.
2300///
2301/// A non-paged file has no page size of its own, and this is the same figure
2302/// HDF5 defaults `H5Pset_file_space_page_size` to, so the merge quantum matches
2303/// what the file *would* have used had it been paged.
2304const DEFAULT_GATHER_PAGE: u64 = crate::file_space_info::DEFAULT_PAGE_SIZE;
2305
2306/// One dataset's append geometry, handed to the public append path so it can
2307/// slice a large call into aligned batches without materializing the whole
2308/// call's bytes first.
2309pub(crate) struct AppendGeometry {
2310 /// Elements per chunk along axis 0 (>= 1).
2311 pub(crate) chunk_elems: u64,
2312 /// Bytes per on-disk element, proven non-zero.
2313 pub(crate) element_size: NonZeroUsize,
2314 /// Current length along the unlimited dimension.
2315 pub(crate) current_dim: u64,
2316 /// Whether this dataset's filter pipeline is **lossy** — ZFP, or float
2317 /// D-scale scale-offset. Such a dataset cannot have a partial trailing chunk
2318 /// grown, because that decodes and re-encodes already-committed values (see
2319 /// [`pipeline_lossless`]), so an append onto one has to start chunk-aligned.
2320 pub(crate) lossy_filters: bool,
2321 /// Whole-chunk elements in one full batch (>= one chunk's worth), or
2322 /// [`u64::MAX`] when the session does not batch.
2323 pub(crate) full_batch_elems: u64,
2324}
2325
2326/// Superblock consistency-flag bits raised while a SWMR writer is active: bit 0
2327/// (write access) | bit 2 (SWMR write access). Cleared on a clean close. Matches
2328/// the reference C library, h5py, and [`crate::File::open_swmr_writer`]. These
2329/// are the bits every open path checks — see [`file_lock::check_status_flags`].
2330const SWMR_WRITE_FLAGS: u32 = file_lock::WRITE_ACCESS | file_lock::SWMR_WRITE_ACCESS;
2331
2332/// A dataset located once for [`Dataset::append`](crate::Dataset::append) (or the bounded
2333/// backend's immediate append), then maintained across appends. Mirrors the
2334/// append writer's per-dataset state.
2335pub(crate) struct LocatedState {
2336 pub(crate) loc: Located,
2337 /// The dataset's on-disk element datatype (for the append type check and the
2338 /// filter chunk context).
2339 pub(crate) datatype: Datatype,
2340 /// Spatial (rank-length) chunk dimensions in elements: `[chunk_elems]`.
2341 pub(crate) spatial: Vec<u64>,
2342 /// Bytes per element (datatype size), proven non-zero.
2343 pub(crate) element_size: NonZeroUsize,
2344 /// The re-encodable filter pipeline, when the dataset is filtered.
2345 pub(crate) pipeline: Option<FilterPipeline>,
2346 /// What the slots past the new dimension must hold when an append completes
2347 /// a partial chunk (issue #296).
2348 pub(crate) fill: crate::fill_value::PaddingFill,
2349}
2350
2351/// State for a file that persists its free space on disk. Carries the file's
2352/// fixed file-space parameters and the extents of the free-space-manager blocks
2353/// (and superblock extension) the *current* on-disk file uses, so the next
2354/// persisting commit can reclaim them when it writes fresh ones.
2355struct PersistState {
2356 strategy: FileSpaceStrategy,
2357 threshold: u64,
2358 page_size: u64,
2359 /// `(addr, len)` of the on-disk superblock-extension header and every
2360 /// free-space-manager `FSHD`/`FSSE` block currently in use. Superseded — and
2361 /// therefore freed — by the next persisting commit.
2362 old_blocks: Vec<(u64, u64)>,
2363}
2364
2365/// A snapshot of a writable file's live space usage (issue #150).
2366///
2367/// This is the mutating-session counterpart of the read-only accounting on
2368/// [`File`](crate::File) ([`file_size`](crate::File::file_size) and
2369/// [`persisted_free_space`](crate::File::persisted_free_space)): it describes the
2370/// file *as the session currently holds it*, taken atomically at the moment of
2371/// the [`space_accounting`](crate::File::space_accounting) call.
2372///
2373/// It reflects the committed file plus any immediate in-place appends
2374/// ([`append`](crate::Dataset::append)), but **not** edits still
2375/// staged for the next [`commit`](crate::File::commit) — `create_group`,
2376/// `create_dataset`, `write_dataset`, `append_dataset`, `delete`, `copy`,
2377/// `copy_from`, and attribute edits change these figures only when they are
2378/// applied at commit. Use [`has_staged_edits`](crate::File::has_staged_edits) to
2379/// tell whether such pending work exists.
2380#[derive(Debug, Clone, PartialEq, Eq)]
2381#[non_exhaustive]
2382pub struct SpaceAccounting {
2383 /// The session's current logical size in bytes: the byte length of the file
2384 /// as the session holds it. It equals what
2385 /// [`File::file_size`](crate::File::file_size) reports for the file on disk
2386 /// right now (the HDF5 `H5Fget_filesize` value), because the session keeps its
2387 /// in-memory mirror byte-for-byte identical to the file — every committed
2388 /// write and every immediate in-place append
2389 /// ([`append`](crate::Dataset::append)) updates both together.
2390 ///
2391 /// It is not monotonic: [`commit`](crate::File::commit) can reclaim trailing
2392 /// free space and *shrink* the file. It can also exceed the superblock's
2393 /// recorded end-of-file address when the file was opened carrying unaccounted
2394 /// trailing bytes (the same slack [`File::file_size`](crate::File::file_size)
2395 /// surfaces), since opening does not rewrite that address.
2396 pub logical_size: u64,
2397 /// Total reusable free bytes this session can draw from before the file has to
2398 /// grow — the summed length of
2399 /// [`reusable_free_space`](Self::reusable_free_space).
2400 ///
2401 /// Counts holes left inside [`logical_size`](Self::logical_size) by this
2402 /// session's earlier commits (superseded object headers, the blocks of
2403 /// deleted objects) and, for a file created with
2404 /// `H5Pset_file_space_strategy(persist = true)` and no userblock, the regions
2405 /// seeded from the on-disk free-space managers when the session was opened (so
2406 /// reuse spans sessions). A fresh non-persisting session reports `0` even if
2407 /// the file contains holes left by other tools — those are never tracked. It
2408 /// is neither a lower bound on the next write's growth nor a promise of
2409 /// shrinkage: a region counted here may be truncated away at commit — rather
2410 /// than reused — if adjacent space is later freed and the coalesced run
2411 /// reaches end-of-file.
2412 ///
2413 /// **Which write can spend it is not uniform on a persisting file.** Such a
2414 /// session may write into a hole only once it has taken that hole *out* of
2415 /// the on-disk managers, so an in-place [`append`](crate::Dataset::append)
2416 /// holds a reserve of up to a megabyte per draw that this figure counts and
2417 /// the next [`commit`](crate::File::commit) cannot place into: the commit
2418 /// gives the unspent part back in the same tail that rewrites the managers,
2419 /// which runs *after* it has placed everything. The commit after that one
2420 /// can. So on a
2421 /// persisting file read this as "space this session will reuse rather than
2422 /// grow for", not as "space the very next commit can fill".
2423 ///
2424 /// It is also not the on-disk view. That reserve is deliberately absent from
2425 /// the free-space managers for as long as the session holds it, so
2426 /// [`File::persisted_free_space`](crate::File::persisted_free_space) — which
2427 /// reads those managers — reports less than this by whatever is reserved,
2428 /// and is the figure to compare against what another tool would find.
2429 pub reusable_free_bytes: u64,
2430 /// The reusable free regions as `(offset, length)` pairs, sorted ascending by
2431 /// offset and fully coalesced (no two regions touch or overlap).
2432 ///
2433 /// The offsets are **absolute** file offsets (from byte 0, including any
2434 /// userblock prefix), matching [`logical_size`](Self::logical_size). This
2435 /// differs from [`File::persisted_free_space`](crate::File::persisted_free_space),
2436 /// whose pairs are relative to the superblock base address; the two coincide
2437 /// for a file with no userblock (base address 0), which is the only kind whose
2438 /// persisted free space a session seeds. Empty when nothing is reusable.
2439 pub reusable_free_space: Vec<(u64, u64)>,
2440}
2441
2442impl WriteEngine {
2443 /// Open an existing HDF5 file for in-place editing under an explicit
2444 /// file-locking policy.
2445 ///
2446 /// Reads the file into memory and retains a read/write handle. Under
2447 /// [`FileLocking::Enabled`] it takes an exclusive OS advisory lock so the file
2448 /// cannot be opened concurrently by another writer or reader; the lock is
2449 /// released automatically when the session is dropped or the process exits
2450 /// (including on a crash). Fails with [`Error::FileLocked`] if the file is
2451 /// already locked, or [`Error::EditUnsupported`] if the file is not a
2452 /// supported target; its documentation enumerates the exact requirements.
2453 /// `HDF5_USE_FILE_LOCKING` overrides the requested policy, as in the C
2454 /// library.
2455 pub fn open_with_locking<P: AsRef<Path>>(path: P, locking: FileLocking) -> Result<Self, Error> {
2456 Self::open_inner(path.as_ref(), Some(locking))
2457 }
2458
2459 /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2460 /// behind an image that withholds its whole-file slice, so every read takes
2461 /// the [`Source`] path rather than the slice fast path.
2462 ///
2463 /// Distinct from [`open_rw_with_strategy`](Self::open_rw_with_strategy), which withholds the
2464 /// slice *and* the residency: this one still mirrors the file, so a test can
2465 /// compare the two read forms on a file the bounded open would refuse.
2466 #[cfg(test)]
2467 pub(crate) fn open_source_only(path: &Path) -> Result<Self, Error> {
2468 Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2469 Ok(Box::new(crate::image::SourceOnlyImage::new(
2470 Self::read_mirror(handle)?,
2471 )))
2472 })
2473 }
2474
2475 /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2476 /// behind an image whose writes into `fails` behave like a dying device:
2477 /// the first alters the file and then errors, the rest are refused
2478 /// outright. See [`crate::image::TornWriteImage`] for why it takes both to
2479 /// reach the state a refused commit cannot repair.
2480 #[cfg(test)]
2481 pub(crate) fn open_torn_writes(
2482 path: &Path,
2483 fails: core::ops::Range<u64>,
2484 ) -> Result<Self, Error> {
2485 Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2486 Ok(Box::new(crate::image::TornWriteImage::new(
2487 Self::read_mirror(handle)?,
2488 fails,
2489 )))
2490 })
2491 }
2492
2493 /// Open a bounded session whose image counts the bytes read through it, so a
2494 /// test can assert that an operation touches only a small part of the file.
2495 /// The counter is shared with the caller.
2496 #[cfg(test)]
2497 pub(crate) fn open_bounded_counting(
2498 path: &Path,
2499 read_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
2500 ) -> Result<Self, Error> {
2501 let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2502 Ok(Box::new(crate::image::CountingImage::new(
2503 Box::new(HandleImage::new(
2504 handle,
2505 len,
2506 crate::source::MetadataCacheConfig::disabled(),
2507 )),
2508 read_bytes,
2509 std::sync::Arc::default(),
2510 )))
2511 })?;
2512 session.batched_appends = true;
2513 Ok(session)
2514 }
2515
2516 /// Open a session under `policy` whose image counts the `fsync`s issued
2517 /// through it, so a test can assert what a write path actually costs. The
2518 /// counter is shared with the caller.
2519 ///
2520 /// It takes the bounded backing and its flags, so the session matches what
2521 /// [`File::open_rw`](crate::File::open_rw) builds for a latest-format file;
2522 /// the barrier sites live on the engine, above the choice of image, so one
2523 /// backing exercises them all.
2524 #[cfg(test)]
2525 pub(crate) fn open_sync_counting(
2526 path: &Path,
2527 policy: SyncPolicy,
2528 syncs: std::sync::Arc<std::sync::atomic::AtomicU64>,
2529 ) -> Result<Self, Error> {
2530 let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2531 Ok(Box::new(crate::image::CountingImage::new(
2532 Box::new(HandleImage::new(
2533 handle,
2534 len,
2535 crate::source::MetadataCacheConfig::disabled(),
2536 )),
2537 std::sync::Arc::default(),
2538 syncs,
2539 )))
2540 })?;
2541 session.batched_appends = true;
2542 session.bounded = true;
2543 session.set_sync_policy(policy);
2544 Ok(session)
2545 }
2546
2547 /// Open an existing file for read-write editing under `strategy`: the one
2548 /// place that picks between the bounded backing (a [`HandleImage`] keeping no
2549 /// whole-file mirror, so resident memory is the metadata-cache budget plus
2550 /// whatever is being parsed) and the whole-file mirror. Backs
2551 /// [`File::open_rw`](crate::File::open_rw), which selects between them by
2552 /// the strategy it is given.
2553 ///
2554 /// The eligibility rules are checked here rather than deferred, because a
2555 /// caller who asked for bounded memory cannot be silently given the mirror
2556 /// instead. Under [`MemoryStrategy::Bounded`] a file the bounded engine
2557 /// cannot edit is refused up front; [`MemoryStrategy::Auto`] opts in to
2558 /// falling back to the mirror instead (issue #198, steps 3 and 4).
2559 ///
2560 /// Only a *bounded-only* limitation is worth falling back for — see
2561 /// [`bounded_only_limitation`]. Non-8-byte offsets or lengths are refused by
2562 /// [`open_imaged`](Self::open_imaged) for both engines, and so is an
2563 /// unsupported superblock version; a paged file without persisted free space
2564 /// is refused below for both.
2565 pub(crate) fn open_rw_with_strategy(
2566 path: &Path,
2567 cache: MetadataCacheConfig,
2568 locking: FileLocking,
2569 strategy: MemoryStrategy,
2570 ) -> Result<Self, Error> {
2571 if strategy == MemoryStrategy::Mirrored {
2572 return Self::open_with_locking(path, locking);
2573 }
2574 let mut session = Self::open_imaged(path, Some(locking), |handle, len| {
2575 Ok(Box::new(HandleImage::new(handle, len, cache)))
2576 })?;
2577 session.batched_appends = true;
2578 session.bounded = true;
2579 // Refusals that apply to *both* backings come first, or falling back for a
2580 // bounded-only limitation would skip them and hand back a session that
2581 // cannot commit. A paged file with no persisted managers has no on-disk
2582 // record of which pages hold metadata and which hold raw data, so nothing
2583 // can keep the pages segregated; the staged commit refuses it too, so
2584 // deferring would only trade this error for the same one later, with work
2585 // already staged. A userblock is one way to reach this state without the
2586 // file saying `persist = false`: persisted free space is declined for a
2587 // non-zero base address, which leaves the managers unseeded all the same.
2588 if session.paged.is_some() && session.persist.is_none() {
2589 return Err(Error::EditUnsupported(
2590 "read-write access to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
2591 persisted free space; recreate the file with \
2592 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to grow it in place",
2593 ));
2594 }
2595 if let Some(reason) = bounded_only_limitation(&session) {
2596 if strategy == MemoryStrategy::Bounded {
2597 return Err(Error::EditUnsupported(reason));
2598 }
2599 // Release the handle and its exclusive lock before reopening, or the
2600 // mirrored open would contend with the probe we are discarding —
2601 // fatally so on Windows, where the OS lock is mandatory. Dropping a
2602 // bare `WriteEngine` writes nothing: the free-space finalize that a
2603 // dropped writer owes lives on `FileInner`, which this is not yet.
2604 // Another writer can take the lock in that window; the reopen then
2605 // reports `Error::FileLocked`, which is the truthful answer.
2606 drop(session);
2607 return Self::open_with_locking(path, locking);
2608 }
2609 Ok(session)
2610 }
2611
2612 /// Open an existing file for SWMR (single-writer/multiple-reader) writing:
2613 /// take **no** OS lock at all and raise the superblock's SWMR-write
2614 /// consistency flag. Backs [`File::open_swmr_writer`](crate::File::open_swmr_writer).
2615 ///
2616 /// The no-lock is unconditional — `lock = None` never reaches
2617 /// `acquire_exclusive`, so `HDF5_USE_FILE_LOCKING` cannot reintroduce a lock
2618 /// that would block the concurrent readers SWMR exists to permit (fatally so
2619 /// on Windows, where OS locks are mandatory). Requires a latest-format
2620 /// (version-3 superblock) file with no userblock and no persisted
2621 /// free-space, so the superblock can be rewritten in place.
2622 ///
2623 /// The version-3 requirement is the C library's (`H5F__super_read`: "superblock
2624 /// version for SWMR is less than 3"), and it is what keeps the SWMR-write flag
2625 /// meaningful: neither library reads the status-flags byte back on an older
2626 /// superblock, so a flag raised there would announce a live writer to nobody.
2627 /// This crate's writer emits version 3, so no file it produces is affected.
2628 pub(crate) fn open_swmr_writer<P: AsRef<Path>>(
2629 path: P,
2630 sync_policy: SyncPolicy,
2631 ) -> Result<Self, Error> {
2632 let mut session = Self::open_inner(path.as_ref(), None)?;
2633 // Before the flag write below, which is a durability point like any
2634 // other: a caller who asked for no `fsync` gets none, and the flag still
2635 // reaches every other process, which reads it from the operating system.
2636 session.set_sync_policy(sync_policy);
2637 if session.superblock.version < 3
2638 || !session.superblock.base_address.is_zero()
2639 || session.persist.is_some()
2640 {
2641 return Err(Error::SwmrAppendUnsupported(
2642 "SWMR writing requires a latest-format file (v3 superblock) with no userblock \
2643 and no persisted free-space",
2644 ));
2645 }
2646 session.swmr_mode = true;
2647 session.set_consistency_flags(SWMR_WRITE_FLAGS)?;
2648 Ok(session)
2649 }
2650
2651 /// Set the superblock's consistency flags in the mirror and on disk, then
2652 /// flush. The one place this crate writes that byte: the SWMR-write flag on
2653 /// open and off on close, and the page buffer's crash mark
2654 /// ([`raise_crash_mark`](Self::raise_crash_mark)).
2655 ///
2656 /// Requires a version-2/3 superblock, since [`Superblock::serialize`] emits
2657 /// that layout; `open_swmr_writer` and
2658 /// [`set_page_buffer_size`](Self::set_page_buffer_size) each check it.
2659 ///
2660 /// The root address is stored on disk *relative to the base address* while
2661 /// the session holds it absolute (see the normalization in `open_imaged`), so
2662 /// the serialized clone converts it back — as the commit tail does at the
2663 /// only other place this crate serializes a superblock. Without it, writing
2664 /// the absolute address here repoints the root past the end of the file while
2665 /// claiming only to have touched a flag, and the file stops reading.
2666 ///
2667 /// `open_swmr_writer` happens to hold a base of zero, since it turns a
2668 /// userblock away outright. Its sibling no longer does:
2669 /// [`set_page_buffer_size`](Self::set_page_buffer_size) once required
2670 /// persisted free space, which is declined for a non-zero base, and issue
2671 /// #357 scoped that refusal to paged files — so an unpaged userblock file
2672 /// now reaches here with a base to convert. It was never a property to build
2673 /// on, which is why `a_status_flag_write_leaves_a_userblock_files_root_alone`
2674 /// pins the conversion directly rather than through a caller.
2675 fn set_consistency_flags(&mut self, flags: u32) -> Result<(), Error> {
2676 self.superblock.consistency_flags = flags;
2677 self.held_status_flags = flags;
2678 let mut on_disk = self.superblock.clone();
2679 // `open_imaged` built the in-memory address by adding the base to the
2680 // stored one, so this cannot be below the base — but it is a subtraction
2681 // over two file-derived numbers, and `relative` reports rather than wraps.
2682 on_disk.root_group_address = on_disk.base_address.relative(on_disk.root_group_address)?;
2683 let bytes = on_disk.serialize();
2684 self.write_at(self.sb_sig_off, &bytes)?;
2685 self.barrier_data()?;
2686 Ok(())
2687 }
2688
2689 /// Raise the page buffer's crash mark: superblock status-flag bit 0
2690 /// (`H5F_SUPER_WRITE_ACCESS`), the byte the C library raises for any writer
2691 /// and this crate otherwise raises only for a SWMR one.
2692 ///
2693 /// Why a page buffer is not offered without one is
2694 /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)'s
2695 /// to explain; what matters here is that two properties of *this* function
2696 /// are load-bearing, and neither is the [`SyncPolicy`]'s to skip:
2697 ///
2698 /// - It runs **before** the buffering mode changes, so no buffered write can
2699 /// reach the disk ahead of it. Its caller states why nothing can fail in
2700 /// between.
2701 /// - It is **fsynced**, unconditionally — the half no test here can see.
2702 /// Reaching the operating system already covers a process that dies, and
2703 /// this suite reproduces no power loss, so deleting the `sync_all` fails
2704 /// nothing. It is there because power loss is exactly the case where the
2705 /// buffer's held writes may be partly on the platter while a mark still in
2706 /// the page cache is not. One `fsync` per session buys it.
2707 ///
2708 /// A failure rolls the byte back, best-effort. The alternative is a file
2709 /// marked in use by a session that never opened, which nothing can read until
2710 /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — a steep price
2711 /// for an open that failed. Best-effort because the rollback writes through
2712 /// the same handle that just failed; it is a chance, not a guarantee.
2713 fn raise_crash_mark(&mut self) -> Result<(), Error> {
2714 let raised = self
2715 .set_consistency_flags(file_lock::WRITE_ACCESS)
2716 .and_then(|()| self.image.sync_all());
2717 if raised.is_err() {
2718 let _ = self.set_consistency_flags(0);
2719 let _ = self.image.sync_all();
2720 }
2721 raised
2722 }
2723
2724 /// Take down whatever status flags this session raised, once everything they
2725 /// stood for is durable — the SWMR pair, or a page buffer's crash mark. A
2726 /// no-op for a session that raised none, which is every ordinary
2727 /// [`File::open_rw`](crate::File::open_rw) session.
2728 ///
2729 /// The whole byte goes to zero, so this cannot leave the half-set state
2730 /// [`check_status_flags`](crate::file_lock::check_status_flags) reports as
2731 /// flags in disagreement; that is why the guard asks whether anything is held
2732 /// rather than which bits are.
2733 ///
2734 /// Called from `File::close` and `FileInner::drop`, **after** their
2735 /// `force_sync`. The order is the whole point: clearing first and flushing
2736 /// second would leave a window in which the file's writes are still in memory
2737 /// and nothing on the disk says so, which is the state the flags exist to
2738 /// make unreachable. Nothing in this suite can tell the two orders apart —
2739 /// it takes a crash inside the window — so the argument is the reason, not a
2740 /// test.
2741 pub(crate) fn release_status_flags(&mut self) -> Result<(), Error> {
2742 if self.held_status_flags == 0 {
2743 return Ok(());
2744 }
2745 self.set_consistency_flags(0)?;
2746 self.image.sync_all()
2747 }
2748
2749 /// Shared open path. `lock = Some(policy)` acquires an exclusive OS lock under
2750 /// that policy (the ordinary read-write session); `lock = None` takes no lock
2751 /// at all (the SWMR writer — see [`open_swmr_writer`](Self::open_swmr_writer)).
2752 fn open_inner(path: &Path, lock: Option<FileLocking>) -> Result<Self, Error> {
2753 Self::open_imaged(path, lock, |handle, _len| {
2754 Ok(Box::new(Self::read_mirror(handle)?))
2755 })
2756 }
2757
2758 /// Read `handle` whole into a [`MirrorImage`]. The one place the engine
2759 /// still assumes it can hold the file, kept behind a named constructor so
2760 /// the mirrorless opens visibly do not call it.
2761 ///
2762 /// `read_to_end` reads from the handle's *current* cursor, and the open path
2763 /// has already read the superblock through it, so this rewinds first. Reading
2764 /// from wherever the last read landed would mirror a truncated file — with no
2765 /// error to say so, since a short mirror is a valid `Vec<u8>`.
2766 fn read_mirror(mut handle: fs::File) -> Result<MirrorImage, Error> {
2767 handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
2768 let mut data = Vec::new();
2769 handle.read_to_end(&mut data).map_err(Error::Io)?;
2770 Ok(MirrorImage::new(handle, data))
2771 }
2772
2773 /// Shared open path over any backing: acquire the handle (and, when asked,
2774 /// the exclusive lock), parse and validate the superblock through a borrowed
2775 /// view of the handle, and only then let `build` decide how the bytes are
2776 /// held.
2777 ///
2778 /// Every refusal comes before `build`, because `build` may read the whole
2779 /// file: reaching a refusal after it would spend `O(file size)` on a file
2780 /// that is then rejected — a 20 GB flagged file read into memory and thrown
2781 /// away. The superblock reads themselves are a few bounded windows either
2782 /// way, so nothing is read twice.
2783 ///
2784 /// `build` receives the file's length as well as the handle because a
2785 /// mirrorless image has to be told its end-of-file — it has no buffer whose
2786 /// length implies it.
2787 ///
2788 /// Nothing below this point reads the file as a slice, which is what lets
2789 /// one engine open a whole-file mirror, a mirrorless handle, and (in tests)
2790 /// a mirror that withholds its slice.
2791 fn open_imaged(
2792 path: &Path,
2793 lock: Option<FileLocking>,
2794 build: impl FnOnce(fs::File, u64) -> Result<Box<dyn FileImage>, Error>,
2795 ) -> Result<Self, Error> {
2796 let handle = fs::OpenOptions::new()
2797 .read(true)
2798 .write(true)
2799 .open(path)
2800 .map_err(Error::Io)?;
2801 // Acquire the exclusive lock before reading or mutating; the retained
2802 // `handle` holds it for the session's life. A `None` policy (SWMR) never
2803 // reaches `acquire_exclusive`, so no lock is ever taken.
2804 if let Some(policy) = lock {
2805 file_lock::acquire_exclusive(&handle, policy, path)?;
2806 }
2807 let len = handle.metadata().map_err(Error::Io)?.len();
2808 // Read the superblock through the handle itself, before any image owns
2809 // it. `probe` borrows, so it is gone by the time `build` takes the
2810 // handle; it leaves the handle's cursor wherever its last read ended,
2811 // which is why the mirror positions the handle before reading it whole.
2812 let probe = crate::image::BorrowedHandle::new(&handle, len);
2813 let sb_sig_off = signature::find_signature_in(&probe)?.to_usize()?;
2814 let mut superblock = Superblock::parse_from_source(&probe, sb_sig_off as u64)?;
2815
2816 if superblock.version > 3 {
2817 return Err(Error::EditUnsupported("unsupported superblock version"));
2818 }
2819 // Refuse a file a writer already holds, before anything is mutated. This
2820 // is the one exclusion the OS lock above cannot make: a SWMR writer
2821 // takes no lock, so its file is lock-free but flagged (issue #245).
2822 file_lock::check_status_flags(
2823 &superblock,
2824 file_lock::OpenIntent::Write,
2825 file_lock::OpenTarget::Path(path),
2826 )?;
2827 if superblock.offset_size != OFFSET_SIZE || superblock.length_size != LENGTH_SIZE {
2828 return Err(Error::EditUnsupported(
2829 "only 8-byte offsets and lengths are supported for in-place editing",
2830 ));
2831 }
2832 // A userblock shifts the whole HDF5 image forward by `base_address`: the
2833 // superblock sits at the base address and every stored address is relative
2834 // to it (the end-of-file address is the sole absolute field). The editor
2835 // supports this by reading at `stored + base` and writing back
2836 // `file_offset - base`. Only the canonical layout — superblock located
2837 // exactly at the base address (e.g. a MATLAB v7.3 `.mat` file's 512-byte
2838 // userblock) — is accepted; a base address that disagrees with the
2839 // superblock's location is a relocated or malformed file we will not rewrite.
2840 if superblock.base_address != BaseAddress::new(sb_sig_off as u64) {
2841 return Err(Error::EditUnsupported(
2842 "a file whose superblock is not located at its base address is not editable in place",
2843 ));
2844 }
2845 // Normalize the root group address to an absolute file offset, exactly as
2846 // the reader does (`reader::parse_superblock`), so `resolve_path_any` and
2847 // the link-graph walk index the image correctly. It is converted back to a
2848 // stored (base-relative) address only when the superblock is serialized on
2849 // commit.
2850 superblock.root_group_address = superblock
2851 .base_address
2852 .absolute(superblock.root_group_address)?;
2853
2854 // Everything that can refuse this file has run; only now is it worth
2855 // holding the bytes.
2856 let image = build(handle, len)?;
2857
2858 let mut session = Self {
2859 image,
2860 sb_sig_off,
2861 superblock,
2862 staged: StagedEdits::default(),
2863 appender_claims: Vec::new(),
2864 next_appender_token: 0,
2865 free: FreeList::new(),
2866 reserved: FreeList::new(),
2867 proved_free_of_references: false,
2868 persist: None,
2869 located: HashMap::new(),
2870 vl_overwrite_heaps: HashMap::new(),
2871 superseded_heaps: Vec::new(),
2872 inplace_undo: Vec::new(),
2873 swmr_mode: false,
2874 paged: None,
2875 committed: false,
2876 resolved: HashMap::new(),
2877 batched_appends: false,
2878 bounded: false,
2879 libver_ceiling: None,
2880 fsm_len: len,
2881 publish_attempted: false,
2882 staging_batch: false,
2883 staged_generation: 0,
2884 held_status_flags: 0,
2885 sync_policy: SyncPolicy::Always,
2886 };
2887 // If the file persists its free space, seed the free list from the
2888 // on-disk managers and arm persistence for future commits. Best-effort:
2889 // an unreadable or non-persisting extension simply leaves the session in
2890 // the default, non-persisting mode.
2891 session.load_persisted_free_space();
2892 // Gather this session's writes, now that the page size the file was laid
2893 // out on is known. Only an exclusively locked session: `lock = None` is
2894 // the SWMR writer, whose concurrent readers observe the order its ordered
2895 // phases become visible in, and so must see every write as it is made
2896 // (issue #288).
2897 //
2898 // `lock.is_some()` is a proxy for "not the SWMR writer", used because
2899 // `swmr_mode` is not set until after this. It is exact only while
2900 // `open_swmr_writer` is the sole lock-free entry point: a future one that
2901 // took a lock would silently *gain* gathering, which is the direction
2902 // that costs a reader. The other direction — a lock-free non-SWMR open
2903 // losing gathering — costs only writes. Whoever adds such an entry point
2904 // owns this condition.
2905 if lock.is_some() {
2906 let page_size = session.gather_page_size();
2907 session
2908 .image
2909 .set_write_buffering(WriteBuffering::Operation {
2910 page_size,
2911 max_bytes: WRITE_GATHER_BYTES,
2912 })?;
2913 }
2914 Ok(session)
2915 }
2916
2917 /// The page this session's writes are merged within: the file's own
2918 /// file-space page size when it is paged, and the format's default otherwise.
2919 fn gather_page_size(&self) -> u64 {
2920 self.paged
2921 .as_ref()
2922 .map_or(DEFAULT_GATHER_PAGE, |pg| pg.page_size)
2923 }
2924
2925 /// Let this session's writes span operations, up to `max_bytes` of them: the
2926 /// `H5Pset_page_buffer_size` analogue, requested through
2927 /// [`FileAccessProperties::with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size).
2928 ///
2929 /// Refused for a budget below the page this session merges within — the
2930 /// file's own file-space page size when it is paged and
2931 /// [`DEFAULT_GATHER_PAGE`] when it is not, which is what
2932 /// [`gather_page_size`](Self::gather_page_size) answers. A buffer that
2933 /// cannot hold one page flushes on every page it touches. The C library
2934 /// reaches the same conclusion and acts on it *silently*, rounding the budget
2935 /// up at `H5Fopen`, which is the one part of its behavior not worth copying.
2936 ///
2937 /// Every larger budget is honored, including one below the
2938 /// [`WRITE_GATHER_BYTES`] this session was already gathering under. That was
2939 /// refused until issue #391; see the constant for what a small one costs.
2940 ///
2941 /// It does **not** require a paged file, where `H5Pset_page_buffer_size`
2942 /// does. The C page buffer is a page *cache*, and `H5PB_create` rejects an
2943 /// unpaged file because its `min_meta_perc`/`min_raw_perc` reservations are
2944 /// counted in pages that the paged allocator keeps segregated by kind. This
2945 /// is a write gatherer instead: it merges runs within a page-sized window and
2946 /// flushes whole, so the window is all it needs, and `gather_page_size`
2947 /// supplies one on an unpaged file already — it is what every read-write
2948 /// session's default [`Operation`](WriteBuffering::Operation) gathering runs
2949 /// under. Lifting the requirement leaves the file byte-identical, which is
2950 /// what the unpaged arm of `a_page_buffer_holds_dirty_pages_across_operations`
2951 /// asserts (issue #357).
2952 ///
2953 /// A zero budget leaves the session's default gathering alone, matching the
2954 /// property's own "unset" value.
2955 pub(crate) fn set_page_buffer_size(&mut self, max_bytes: usize) -> Result<(), Error> {
2956 if max_bytes == 0 {
2957 return Ok(());
2958 }
2959 // A lock-free session's readers observe the order its writes become
2960 // visible in. `File::open_swmr_writer` refuses this property before it
2961 // raises the on-disk flag, which is the refusal a caller sees; this one is
2962 // here because the guarantee belongs to the engine that would break it,
2963 // and a SWMR session can otherwise clear every check below — an unpaged
2964 // version-3 file with an ample budget is exactly what SWMR writing wants.
2965 if self.swmr_mode {
2966 return Err(Error::EditUnsupported(
2967 "a SWMR writer cannot buffer its writes: its readers observe the order they \
2968 become visible in",
2969 ));
2970 }
2971 // One page is the whole of the size rule, and a buffer that cannot hold
2972 // one drains on every page it touches.
2973 //
2974 // A budget below `WRITE_GATHER_BYTES` was refused alongside it until
2975 // issue #391, on the grounds that a page buffer *replaces* the byte
2976 // budget the session was already gathering under rather than adding to
2977 // it, so a smaller one is also the point at which one long run is flushed
2978 // and restarted. That cost is real — nothing here bypasses the gatherer
2979 // the way `H5PB_write` bypasses the C page buffer for I/O of a page or
2980 // more (issue #357) — but it is not this crate's trade to make. The
2981 // budget it replaces is a per-operation peak released at every barrier,
2982 // while this one is held across operations, so a smaller number lowers
2983 // both the peak and the residency, and a writer inside a tight memory cap
2984 // is entitled to spend the writes for that. See `WRITE_GATHER_BYTES` for
2985 // the measurement.
2986 let page_size = self.gather_page_size();
2987 if (max_bytes as u64) < page_size {
2988 return Err(Error::EditUnsupported(
2989 "a page buffer must be at least the file's file-space page size",
2990 ));
2991 }
2992 // A page buffer holds dirty pages across ordering barriers, and under
2993 // `Always` every barrier is an `fsync` that flushes it — so it would hold
2994 // nothing, and the caller would have paid the mark below for nothing in
2995 // return. It lives here with its three siblings rather than at the fapl,
2996 // because a refusal kept apart from the others is one
2997 // `create_would_refuse_reopen` forgets to restate, which is how this one
2998 // came to write the file before refusing it.
2999 if self.sync_policy == SyncPolicy::Always {
3000 return Err(Error::EditUnsupported(
3001 "a page buffer does nothing under SyncPolicy::Always, whose every barrier is \
3002 an fsync that flushes it; pair with_page_buffer_size with \
3003 with_sync_policy(SyncPolicy::OnClose), or drop it",
3004 ));
3005 }
3006 // A paged file whose free space is not persisted can neither commit nor
3007 // append — both are refused — so a page buffer would have nothing to
3008 // hold, while its mark blocked every reader for the session's life.
3009 //
3010 // Scoped to a paged file: an *unpaged* one commits and appends whether it
3011 // persists its free space or not, so there is nothing here to refuse. The
3012 // base address the mark's superblock rewrite needs is not this check's to
3013 // settle — `set_consistency_flags` converts the root address itself, and
3014 // is tested for a userblock file directly rather than through a caller.
3015 if self.paged.is_some() && self.persist.is_none() {
3016 return Err(Error::EditUnsupported(
3017 "a page buffer on a paged file needs its free space persisted: without it \
3018 this session can neither commit nor append, so the buffer would hold nothing \
3019 while marking the file against every reader; recreate the file with \
3020 with_file_space_strategy(FileSpaceStrategy::Page, true, ..), or drop the \
3021 page buffer",
3022 ));
3023 }
3024 // The crash mark below is the whole reason this property is offered at
3025 // all, and only a version-3 superblock carries one that anybody reads:
3026 // `check_status_flags` is gated there, matching where the C library gates
3027 // it. A version-2 paged file is reachable — `LibVer::V18` writes a
3028 // version-2 superblock and nothing about paged file space rules it out —
3029 // so this is a refusal rather than an assertion (issue #308).
3030 if self.superblock.version < 3 {
3031 return Err(Error::EditUnsupported(
3032 "a page buffer marks the file for the life of the session, so that a session \
3033 that crashes leaves one every reader refuses rather than one that reads \
3034 clean; only a version-3 superblock carries a status-flags byte any library \
3035 reads back, and this file's is older. Rewrite it at the 1.10 format \
3036 (repack, or FileBuilder's default bounds), or drop the page buffer",
3037 ));
3038 }
3039 // Before the mode change, and durable: from here on a write may sit in
3040 // memory across a barrier, and the file has to say so on the disk first.
3041 //
3042 // Nothing can fail between the two and leave the file marked with no
3043 // session behind it. `raise_crash_mark` ends in a `sync_all`, which
3044 // flushes, so the gatherer is empty when `set_write_buffering` flushes it
3045 // again — the one fallible step it takes.
3046 self.raise_crash_mark()?;
3047 self.image.set_write_buffering(WriteBuffering::Session {
3048 page_size,
3049 max_bytes,
3050 })
3051 }
3052
3053 /// Read the superblock-extension File Space Info message; if it requests
3054 /// persistence, seed [`self.free`](Self::free) from the on-disk free-space
3055 /// managers and record the manager/extension block extents for reclamation on
3056 /// the next commit. Silent on any malformed or absent metadata — persistence
3057 /// is then simply off for this session.
3058 fn load_persisted_free_space(&mut self) {
3059 if self.superblock.version < 2 {
3060 return; // no superblock extension exists before v2
3061 }
3062 let Some(ext_rel) = self.superblock.superblock_extension_address else {
3063 return;
3064 };
3065 if ext_rel == UNDEF {
3066 return;
3067 }
3068 // The extension address is stored relative to the base address, so it is
3069 // shifted to an absolute file offset before the header is read. This is a
3070 // no-op on the base-0 file every path below the userblock check sees, but
3071 // that check itself needs the strategy of a *userblock* file.
3072 let Ok(ext_addr) = self
3073 .superblock
3074 .base_address
3075 .absolute(ext_rel)
3076 .map_err(|_| ())
3077 .and_then(|a| usize::try_from(a).map_err(|_| ()))
3078 else {
3079 return;
3080 };
3081 let Some(info) = self.extension_fsinfo(ext_addr) else {
3082 return;
3083 };
3084 // Free-space reuse and persistence are not yet base-address aware: the
3085 // persisted section addresses (and the extension/manager block walk below)
3086 // are read as absolute, so on a userblock file they would seed `self.free`
3087 // with wrong regions that a later allocation could hand out into live
3088 // data. Leave persistence off for such a file — the on-disk managers stay
3089 // untouched and valid, this session simply appends rather than reusing.
3090 //
3091 // A *paged* userblock file is a different matter: appending without page
3092 // awareness would mix metadata and raw data in its pages and leave its end
3093 // of allocation unaligned, quietly producing a file that still claims the
3094 // paged strategy but no longer satisfies it. Install the paged marker
3095 // without persistence so the commit refusal below catches it, which is the
3096 // same rule a paged non-persisting file already takes.
3097 if !self.superblock.base_address.is_zero() {
3098 if info.strategy == FileSpaceStrategy::Page && info.page_size > 0 {
3099 self.paged = Some(PagedEdit::new(info.page_size));
3100 }
3101 return;
3102 }
3103 // Record the paged strategy regardless of the persist flag: a paged commit
3104 // needs page-aware bookkeeping, and a paged file that does not persist its
3105 // free space is refused outright (see `PagedEdit` and the commit refusal).
3106 //
3107 // A zero page size is refused rather than installed: every page calculation
3108 // divides by it, so a corrupt or hostile file declaring `Page` with a page
3109 // size of 0 would panic the editor. Leaving `paged` unset makes the file
3110 // take the ordinary flat path, which needs no page geometry.
3111 let paged = info.strategy == FileSpaceStrategy::Page && info.page_size > 0;
3112 if paged {
3113 self.paged = Some(PagedEdit::new(info.page_size));
3114 }
3115 if !info.persist {
3116 return;
3117 }
3118 let os = self.superblock.offset_size;
3119 let file_len = self.image.len();
3120
3121 // Seed the free list(s) with every persisted section (addresses are stored
3122 // relative to the base address, which this editor requires to be 0).
3123 // Defensive against a malformed or corrupt manager: skip a section that is
3124 // empty, runs past end-of-file, or overlaps one already taken. A
3125 // well-formed file (this crate's or the C library's) has none of these;
3126 // tolerating them keeps a bad file from seeding a bogus or double-counted
3127 // free region that a later commit would hand out into live data.
3128 if paged {
3129 // A paged file's free space is segregated across per-page-type
3130 // managers, so read each slot on its own and keep the page type its
3131 // slot implies ([`PagedEdit::slot_list`]). Flattening them (as the
3132 // non-paged path below does) would lose exactly the distinction the
3133 // commit has to preserve. A section whose slot does not settle its page
3134 // type is recorded but never handed out.
3135 let page_size = info.page_size;
3136 let mut tagged: Vec<(FreeSection, Option<PageType>)> = Vec::new();
3137 for (slot, &m) in info.manager_addrs.iter().enumerate() {
3138 if m == UNDEF {
3139 continue;
3140 }
3141 let Ok(sections) = free_space_manager::read_persisted_sections_source(
3142 &self.image(),
3143 &[m],
3144 BaseAddress::ZERO,
3145 os,
3146 )
3147 .map(|(sections, _)| sections) else {
3148 continue;
3149 };
3150 for s in sections {
3151 let ty = PagedEdit::slot_list(slot, s.addr, s.size, page_size);
3152 tagged.push((s, ty));
3153 }
3154 }
3155 // Distinct sections have distinct addresses in any well-formed file,
3156 // so the tie-break never arises; only a malformed manager can
3157 // advertise one address twice, and the overlap guard below already
3158 // discards the second of any such pair. No `debug_assert` here: this
3159 // parses untrusted bytes, which must not panic a debug build.
3160 tagged.sort_unstable_by_key(|(s, _)| s.addr);
3161 let mut prev_end = 0u64;
3162 for (s, ty) in tagged {
3163 let Some(end) = s.addr.checked_add(s.size) else {
3164 continue;
3165 };
3166 if s.size == 0 || end > file_len || s.addr < prev_end {
3167 continue;
3168 }
3169 prev_end = end;
3170 let pg = self
3171 .paged
3172 .as_mut()
3173 .expect("the paged state was just installed");
3174 match ty {
3175 Some(ty) => PagedEdit::route_free(
3176 &mut pg.meta,
3177 &mut pg.raw,
3178 &mut pg.dead,
3179 s.addr,
3180 s.size,
3181 ty.into(),
3182 ),
3183 None => pg.unclassified.free(s.addr, s.size),
3184 }
3185 }
3186 // A page the seeded lists empty between them belongs to no type and
3187 // may serve either, so fold it in now rather than leaving it to the
3188 // first commit. Nothing this crate writes produces one — it files a
3189 // whole free page under a single manager — but a file another writer
3190 // laid out can, and the rule is the same wherever the page came from.
3191 let pg = self
3192 .paged
3193 .as_mut()
3194 .expect("the paged state was just installed");
3195 PagedEdit::promote_whole_free_pages(&mut pg.meta, &mut pg.raw, &mut pg.dead, page_size);
3196 } else if let Ok(mut sections) = free_space_manager::read_persisted_sections_source(
3197 &self.image(),
3198 &info.manager_addrs,
3199 BaseAddress::ZERO,
3200 os,
3201 )
3202 .map(|(sections, _)| sections)
3203 {
3204 // Unique addresses in any well-formed file; see the paged branch
3205 // above for why a duplicate is harmless and unasserted here.
3206 sections.sort_unstable_by_key(|s| s.addr);
3207 let mut prev_end = 0u64;
3208 for s in sections {
3209 let Some(end) = s.addr.checked_add(s.size) else {
3210 continue;
3211 };
3212 if s.size == 0 || end > file_len || s.addr < prev_end {
3213 continue;
3214 }
3215 prev_end = end;
3216 self.free.free(s.addr, s.size);
3217 }
3218 }
3219
3220 // Record the byte extents of the blocks the live file uses so the next
3221 // persisting commit frees them when it writes replacements: the
3222 // extension header, and each defined manager's FSHD + FSSE.
3223 let mut old_blocks = Vec::new();
3224 if let Ok(spans) = self.oh_chunk_spans(ext_addr) {
3225 old_blocks.extend(spans);
3226 }
3227 for &m in &info.manager_addrs {
3228 if m == UNDEF {
3229 continue;
3230 }
3231 let Ok(hdr_len) = fshd_len(os).to_usize() else {
3232 continue;
3233 };
3234 let Ok(fshd) = self.image().read_metadata_at(m, hdr_len) else {
3235 continue;
3236 };
3237 if let Ok(h) = FsmHeader::parse(&fshd, os) {
3238 // `FsmHeader::parse` succeeding guarantees the header's own bytes
3239 // are present, so the FSHD extent is in-bounds; validate the
3240 // section-info extent before recording it, so a malformed
3241 // `fsse_used` can't later free a region running past end-of-file.
3242 old_blocks.push((m, fshd_len(os)));
3243 if h.fsse_addr != UNDEF
3244 && h.fsse_addr
3245 .checked_add(h.fsse_used)
3246 .is_some_and(|end| end <= file_len)
3247 {
3248 old_blocks.push((h.fsse_addr, h.fsse_used));
3249 }
3250 }
3251 }
3252
3253 self.persist = Some(PersistState {
3254 strategy: info.strategy,
3255 threshold: info.threshold,
3256 page_size: info.page_size,
3257 old_blocks,
3258 });
3259 }
3260
3261 /// Parse the File Space Info message out of the superblock-extension object
3262 /// header at `ext_addr`, if present and readable.
3263 fn extension_fsinfo(&self, ext_addr: usize) -> Option<FileSpaceInfo> {
3264 let os = self.superblock.offset_size;
3265 let ls = self.superblock.length_size;
3266 let base = self.superblock.base_address;
3267 let oh =
3268 ObjectHeader::parse_from_source(&self.image(), ext_addr as u64, os, ls, base).ok()?;
3269 let msg = oh
3270 .messages
3271 .iter()
3272 .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
3273 FileSpaceInfo::parse(&msg.data, os, ls).ok()
3274 }
3275
3276 /// Stage a new dataset, added on the next [`commit`](Self::commit).
3277 ///
3278 /// `path` is the dataset's full path; everything before the last component
3279 /// names the parent group, which must exist (or be created in this session).
3280 /// `builder` is the same [`DatasetBuilder`] [`FileBuilder`](crate::FileBuilder)
3281 /// uses, configured by the caller; its name is taken from `path`, so the two
3282 /// cannot disagree.
3283 ///
3284 /// The builder is passed in finished rather than handed out as a `&mut` into
3285 /// this engine. That is deliberate: a borrow into the engine forces the
3286 /// caller to hold it — and, above this layer, its lock — for as long as the
3287 /// builder is being configured, which is what let a user closure deadlock
3288 /// against the same file it was reading (issue #200).
3289 ///
3290 /// The dataset may be contiguous or chunked, and chunked datasets may be
3291 /// filtered (`with_deflate`, `with_shuffle`, `with_fletcher32`,
3292 /// `with_scale_offset`, `with_zfp`) and/or extensible (`with_maxshape`). An
3293 /// empty (zero-element) dataset is supported under either storage, a
3294 /// provenance dataset (`with_provenance`) is supported, and a
3295 /// contiguous dataset may carry variable-length attributes, a
3296 /// variable-length-string payload (`with_vlen_strings`), or path-resolved
3297 /// object-reference elements (`with_path_references`; chunking any of
3298 /// these is not supported). An attribute set too large for the object header
3299 /// is written to a fractal heap, as the whole-file writer does.
3300 pub(crate) fn stage_created_dataset(
3301 &mut self,
3302 path: &str,
3303 mut builder: DatasetBuilder,
3304 ) -> Result<(), Error> {
3305 let mut comps = split_path(path);
3306 self.refuse_if_claimed(&comps)?;
3307 self.refuse_creation_collision(&comps, StagedKind::Dataset)?;
3308 builder.name = comps.pop().unwrap_or_default();
3309 self.staged.push_dataset(comps, flatten_dataset(builder)?);
3310 Ok(())
3311 }
3312
3313 /// Stage an in-place overwrite of an **existing** dataset's values (the HDF5
3314 /// `H5Dwrite` whole-dataset write), applied on the next
3315 /// [`commit`](Self::commit).
3316 ///
3317 /// `path` is the full path of a dataset that must already exist; `builder`
3318 /// supplies the replacement data and is named from `path`, as in
3319 /// [`stage_created_dataset`](Self::stage_created_dataset).
3320 ///
3321 /// This is a *value* overwrite, not a reshape or retype: the new data's
3322 /// datatype and shape must match the on-disk dataset's exactly (byte-for-byte
3323 /// after serialization, so endianness and compound layout must agree), or
3324 /// `commit` reports [`Error::EditUnsupported`]. Contiguous, compact, and
3325 /// chunked (including filtered) datasets are all supported; the dataset's
3326 /// existing chunk geometry, filter pipeline, and chunk index are taken from the
3327 /// on-disk header. A chunk index this engine cannot enumerate (a version-2
3328 /// B-tree) is refused. Partial / sub-region writes are out of scope — the
3329 /// whole dataset is replaced.
3330 ///
3331 /// What the builder alone can rule out —
3332 /// [`refuse_unsupported_overwrite`](Self::refuse_unsupported_overwrite) —
3333 /// is refused *here* rather than at `commit`.
3334 ///
3335 /// When the new data is the same length as the existing contiguous data block
3336 /// (the common case), the bytes are written straight into that block: no
3337 /// object header is rewritten and the superblock root is not flipped, so the
3338 /// commit's linearization point is the synced data write itself. A chunked
3339 /// dataset is handled the same way when every (re-encoded) chunk is the same
3340 /// byte length as the slot it replaces — an unfiltered overwrite (chunk sizes
3341 /// are fixed by the unchanged shape) or a filtered one whose re-encoded chunks
3342 /// match — so it too writes straight into the existing chunk slots. When the
3343 /// length differs (a resized contiguous block, or a filtered chunk that no
3344 /// longer fits), the dataset's storage is rebuilt at end-of-file (or in
3345 /// reusable freed space), the old extent is freed, the data-layout message is
3346 /// repointed, the object header is rewritten, and the parent group's link is
3347 /// patched — exactly like an addition relocates the path up to the root. A
3348 /// relocating overwrite moves the object header, so it is refused unless the
3349 /// dataset has a single hard link.
3350 pub(crate) fn stage_dataset_write(
3351 &mut self,
3352 path: &str,
3353 mut builder: DatasetBuilder,
3354 ) -> Result<(), Error> {
3355 self.refuse_if_claimed(&split_path(path))?;
3356 let comps = split_path(path);
3357 // Before the flatten below, which would otherwise report the root as a
3358 // dataset with an empty name: the leaf of an empty path is what names
3359 // the builder.
3360 let Some(leaf) = comps.last() else {
3361 return Err(Error::EditUnsupported("cannot overwrite the root group"));
3362 };
3363 builder.name = leaf.clone();
3364 let fd = flatten_dataset(builder)?;
3365 Self::refuse_unsupported_overwrite(&fd)?;
3366 self.staged.writes.push((comps, fd));
3367 Ok(())
3368 }
3369
3370 /// Stage an append of new elements to an **existing** chunked, unlimited
3371 /// dataset, applied on the next [`commit`](Self::commit).
3372 ///
3373 /// `path` names a dataset that must already exist; `builder` supplies the
3374 /// elements to add via its typed / generic / raw `append_*` methods.
3375 ///
3376 /// Unlike [`stage_dataset_write`](Self::stage_dataset_write) (a value
3377 /// overwrite that forbids any shape change) this **grows** the dataset along
3378 /// its first (axis-0) dimension. It works on **filtered** datasets: the
3379 /// appended chunks are compressed through the dataset's own on-disk filter
3380 /// pipeline (deflate / shuffle / fletcher32 / scale-offset / LZF, and ZFP
3381 /// with the `zfp` feature), and the pipeline, datatype, fill value, and attributes are
3382 /// preserved verbatim. Appends of any length are supported — when the
3383 /// dataset's current length is not a whole multiple of the chunk length, the
3384 /// single trailing partial chunk is read, extended, and re-encoded; every
3385 /// other existing chunk is carried by metadata alone, so the existing data is
3386 /// not rewritten and the file does not grow by the whole dataset per append.
3387 ///
3388 /// This does **not** use SWMR and sets no consistency flag. Like every other
3389 /// staged edit it commits by appending the new chunks and a rebuilt
3390 /// index at end-of-file and repointing the superblock last (under the
3391 /// session's exclusive lock), so a crash leaves either the original dataset or
3392 /// the fully-grown one, never a torn state.
3393 ///
3394 /// The first release supports the Extensible-Array chunk index (the index the
3395 /// reference C library and h5py select for a single unlimited dimension under
3396 /// the latest format, and the one this crate writes for every unlimited
3397 /// dataset), rank-1 datasets, and datasets with a single hard link. A dataset
3398 /// that is not chunked, not unlimited along axis 0, not Extensible-Array
3399 /// indexed, higher than rank 1, uses a filter this engine cannot re-encode,
3400 /// has a sparse chunk grid, or (for [`append_raw`](AppendBuilder::append_raw))
3401 /// has a big-endian element datatype is refused with
3402 /// [`Error::AppendUnsupported`]. Use [`Dataset::is_chunked`](crate::Dataset::is_chunked),
3403 /// [`maxshape`](crate::Dataset::maxshape), and [`filters`](crate::Dataset::filters)
3404 /// to check eligibility up front.
3405 pub(crate) fn stage_dataset_append(
3406 &mut self,
3407 path: &str,
3408 builder: AppendBuilder,
3409 ) -> Result<(), Error> {
3410 let comps = split_path(path);
3411 self.refuse_if_claimed(&comps)?;
3412 if self.staged.dataset_at(&comps).is_some() {
3413 // This call came through a handle onto the object *in the file*: a
3414 // handle onto the staged creation goes to
3415 // [`stage_dataset_append_pending`] instead. So the elements could
3416 // only be meant for the object the same commit replaces, which is
3417 // never what the caller wants and is what `commit` refuses two calls
3418 // later. Said here, where the handle that asked is still in hand.
3419 return Err(Error::EditUnsupported(
3420 "a dataset is staged at this path in the same commit, so an append through a \
3421 handle onto the object the file holds there could only grow the object being \
3422 replaced; append through the handle that staged the creation, or commit first",
3423 ));
3424 }
3425 self.refuse_lossy_partial_tail(path, &builder)?;
3426 self.staged.appends.push((comps, builder));
3427 Ok(())
3428 }
3429
3430 /// Refuse, at the call that stages an append, a dataset whose lossy pipeline
3431 /// sits on a partial trailing chunk — the refusal
3432 /// [`prepare_append`](Self::prepare_append) raises during the commit's
3433 /// preflight, brought forward to where the caller can act on it (issue #407).
3434 ///
3435 /// Waiting for the commit makes that refusal permanent for the session and
3436 /// costs every edit staged beside it: a preflight failure restores the staged
3437 /// set, so the offending append is still there at the next
3438 /// [`commit`](crate::File::commit), and [`File::close`](crate::File::close)
3439 /// runs one and returns its `Err` — discarding the whole staged set on the way
3440 /// out. `BufferedAppender::new` refuses the same dataset eagerly for the same
3441 /// reason; this puts the staged path on the same footing.
3442 ///
3443 /// **Positive proof only.** Every reason the geometry cannot be read here — a
3444 /// path that does not resolve, a header this engine does not parse, a layout
3445 /// that is not chunked, a rank this release does not append to — leaves the
3446 /// answer to the preflight, which names each of them. Nothing about the bytes
3447 /// being appended enters into it: the on-disk length is what decides whether a
3448 /// trailing chunk has to be re-encoded, and staged appends never change that
3449 /// before the commit reads it. The commit-time check stays as the backstop,
3450 /// and is the only one that runs for an append staged through some other
3451 /// entry point.
3452 fn refuse_lossy_partial_tail(&self, path: &str, builder: &AppendBuilder) -> Result<(), Error> {
3453 // A zero-length append is dropped by the preflight before it ever reaches
3454 // `prepare_append`, so it stays a no-op here rather than becoming the one
3455 // append this refusal would newly reject.
3456 if builder.raw().is_empty() {
3457 return Ok(());
3458 }
3459 if self.appends_onto_a_lossy_partial_tail(path) {
3460 return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
3461 }
3462 Ok(())
3463 }
3464
3465 /// The geometry question behind [`refuse_lossy_partial_tail`](Self::refuse_lossy_partial_tail):
3466 /// does the file hold, at `path`, a rank-1 chunked dataset under a lossy
3467 /// filter pipeline whose length is not a whole multiple of its chunk length?
3468 /// `false` whenever that cannot be established, for whatever reason.
3469 fn appends_onto_a_lossy_partial_tail(&self, path: &str) -> bool {
3470 let Ok(addr) =
3471 crate::group_v2::resolve_path_any_from_source(&self.image(), &self.superblock, path)
3472 else {
3473 return false;
3474 };
3475 let Ok(region) =
3476 Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address)
3477 else {
3478 return false;
3479 };
3480 let mut datatype: Option<(usize, usize)> = None;
3481 let mut dataspace: Option<(usize, usize)> = None;
3482 let mut layout: Option<(usize, usize)> = None;
3483 let mut filter: Option<(usize, usize)> = None;
3484 let mut p = 0;
3485 while let Ok(Some((msg_type, body, body_end))) = region.next_message(p) {
3486 match msg_type {
3487 MessageType::Datatype => datatype = Some((body, body_end)),
3488 MessageType::Dataspace => dataspace = Some((body, body_end)),
3489 MessageType::DataLayout => layout = Some((body, body_end)),
3490 MessageType::FilterPipeline => filter = Some((body, body_end)),
3491 _ => {}
3492 }
3493 p = body_end;
3494 }
3495 // No pipeline at all is nothing to re-encode, and an unparseable one is
3496 // refused by the preflight on its own terms.
3497 let (Some((fb, fe)), Some((dt_b, dt_e)), Some((ds_b, ds_e)), Some((lb, le))) =
3498 (filter, datatype, dataspace, layout)
3499 else {
3500 return false;
3501 };
3502 let Ok(pipeline) = FilterPipeline::parse(®ion[fb..fe]) else {
3503 return false;
3504 };
3505 if pipeline_lossless(&pipeline) {
3506 return false;
3507 }
3508 let Ok((disk_dt, _)) = Datatype::parse(®ion[dt_b..dt_e]) else {
3509 return false;
3510 };
3511 let Ok(disk_ds) = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE) else {
3512 return false;
3513 };
3514 let Ok(dl) = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE) else {
3515 return false;
3516 };
3517 // Rank 1 is the shape the preflight's `current_dim0 % chunk_elems`
3518 // arithmetic is about; anything else it refuses before reaching that test.
3519 if disk_ds.dimensions.len() != 1 {
3520 return false;
3521 }
3522 let Ok(geometry) = chunked_geometry(&disk_dt, &disk_ds, &dl) else {
3523 return false;
3524 };
3525 match (geometry.spatial.first(), disk_ds.dimensions.first()) {
3526 (Some(&chunk_elems), Some(&dim0)) if chunk_elems != 0 => dim0 % chunk_elems != 0,
3527 _ => false,
3528 }
3529 }
3530
3531 /// Stage an append made through a handle onto a dataset **this session
3532 /// staged and has not committed**, folding the elements into the pending
3533 /// creation.
3534 ///
3535 /// The caller establishes that its handle is the pending one;
3536 /// [`stage_dataset_append`](Self::stage_dataset_append) is the entry point
3537 /// for a handle onto an object the file already holds, and refuses the same
3538 /// path. Between the caller's check and this lock another thread can commit,
3539 /// which leaves the dataset in the file and nothing staged at its path — so
3540 /// the append becomes an ordinary staged one rather than an error.
3541 pub(crate) fn stage_dataset_append_pending(
3542 &mut self,
3543 path: &str,
3544 builder: AppendBuilder,
3545 ) -> Result<(), Error> {
3546 let comps = split_path(path);
3547 self.refuse_if_claimed(&comps)?;
3548 if self.staged.dataset_at(&comps).is_none() {
3549 // Nothing staged here after all, so these elements grow the object the
3550 // file holds — including its trailing chunk, which the same eager
3551 // refusal applies to. The fold below reaches no on-disk chunk at all
3552 // and is untouched by it.
3553 self.refuse_lossy_partial_tail(path, &builder)?;
3554 self.staged.appends.push((comps, builder));
3555 return Ok(());
3556 }
3557 self.refuse_mid_batch()?;
3558 self.extend_staged_dataset(&comps, &builder)
3559 }
3560
3561 /// Refuse an edit that changes or withdraws something already staged while a
3562 /// [`stage_atomically`](Self::stage_atomically) batch is open.
3563 ///
3564 /// [`StagedEdits::rewind`] undoes a batch by truncating the staged vectors,
3565 /// which is exact only while staging appends to them. The two operations
3566 /// that do otherwise — the fold
3567 /// [`stage_dataset_append_pending`](Self::stage_dataset_append_pending)
3568 /// makes, and the withdrawal [`delete`](Self::delete) makes — would leave a
3569 /// refused batch holding half of one. No caller reaches this today: a batch
3570 /// replays [`StagedOp`](crate::StagedGroup)s, which only ever create. It is
3571 /// here so the claim `rewind` rests on is enforced rather than remembered.
3572 fn refuse_mid_batch(&self) -> Result<(), Error> {
3573 if self.staging_batch {
3574 return Err(Error::EditUnsupported(
3575 "an edit that changes or withdraws an already-staged object cannot be made \
3576 inside an atomic staging batch, whose undo drops additions only",
3577 ));
3578 }
3579 Ok(())
3580 }
3581
3582 /// Fold an append onto a dataset this same session staged and has not
3583 /// committed into the pending creation: the elements are added to the
3584 /// bytes the commit will write and the leading dimension grows to match.
3585 ///
3586 /// There is no dataset in the file to grow, so this is the same edit as
3587 /// having handed the elements to the builder in the first place — which is
3588 /// why it asks for neither an unlimited dimension nor a chunked layout,
3589 /// where an append onto a *committed* dataset needs both. Anything the
3590 /// staged creation must resolve at commit time per element — a
3591 /// variable-length string payload, an object reference — is refused: those
3592 /// carry a side table indexed by element that this cannot extend.
3593 ///
3594 /// A provenance dataset (`with_provenance`) is grown rather than refused:
3595 /// its attributes are derived from the bytes, so they are rebuilt from the
3596 /// grown ones ([`FlatDataset::rebuild_provenance`]) and the digest the
3597 /// commit writes is the digest of what it writes.
3598 fn extend_staged_dataset(
3599 &mut self,
3600 comps: &[String],
3601 builder: &AppendBuilder,
3602 ) -> Result<(), Error> {
3603 if builder.dt_conflict() {
3604 return Err(Error::AppendUnsupported(
3605 "this append mixes element datatypes; use one element type per append",
3606 ));
3607 }
3608 let fd = self
3609 .staged
3610 .dataset_at_mut(comps)
3611 .expect("caller checked a dataset is staged at this path");
3612 if fd.vl_string_staging.is_some() || fd.reference_targets.is_some() {
3613 return Err(Error::AppendUnsupported(
3614 "a staged variable-length-string or object-reference dataset cannot be appended \
3615 to before it is committed: its per-element side table is built with the \
3616 dataset. Supply every element through the builder that creates it",
3617 ));
3618 }
3619 if let Some(dt) = builder.elem_dt() {
3620 if *dt != fd.dt {
3621 return Err(Error::AppendUnsupported(
3622 "appended element datatype does not match the staged dataset's datatype",
3623 ));
3624 }
3625 }
3626 let Some((lead, inner)) = fd.ds.dimensions.split_first() else {
3627 return Err(Error::AppendUnsupported(
3628 "a scalar dataset has no dimension to append along",
3629 ));
3630 };
3631 // One row of the leading dimension, in bytes: the element size times
3632 // every trailing extent. A rank-1 dataset's row is one element.
3633 let mut row_bytes = u64::from(fd.dt.type_size());
3634 for &d in inner {
3635 row_bytes = row_bytes.checked_mul(d).ok_or(Error::AppendUnsupported(
3636 "staged dataset row size overflows",
3637 ))?;
3638 }
3639 if row_bytes == 0 {
3640 return Err(Error::AppendUnsupported(
3641 "a staged dataset whose rows hold no bytes cannot be appended to",
3642 ));
3643 }
3644 let bytes = builder.raw();
3645 if bytes.len() as u64 % row_bytes != 0 {
3646 return Err(Error::AppendUnsupported(
3647 "appended byte length is not a whole number of rows of the staged dataset",
3648 ));
3649 }
3650 let grown = lead
3651 .checked_add(bytes.len() as u64 / row_bytes)
3652 .ok_or(Error::AppendUnsupported("staged dataset length overflows"))?;
3653 // A finite maximum is a promise the commit would refuse to break.
3654 if let Some(max) = fd
3655 .ds
3656 .max_dimensions
3657 .as_ref()
3658 .and_then(|m| m.first().copied())
3659 {
3660 if max != u64::MAX && grown > max {
3661 return Err(Error::AppendUnsupported(
3662 "appending would grow the staged dataset past its maximum shape",
3663 ));
3664 }
3665 }
3666 fd.raw.extend_from_slice(bytes);
3667 fd.ds.dimensions[0] = grown;
3668 // The digest covers the bytes, so it is recomputed over the grown ones
3669 // rather than left describing the shorter dataset that was staged. Doing
3670 // it here, on the record the commit writes, is what keeps
3671 // `verify_provenance` passing on the file this produces.
3672 #[cfg(feature = "provenance")]
3673 fd.rebuild_provenance();
3674 Ok(())
3675 }
3676
3677 /// Register a live [`BufferedAppender`](crate::BufferedAppender) on `path`
3678 /// (`None` for a handle reached by object reference), returning the token
3679 /// that releases it.
3680 ///
3681 /// Refused when the claim could not be honored: a second appender on the
3682 /// same dataset would interleave the two buffers a chunk at a time, and a
3683 /// staged edit already pending on that path is one the appender's own flush
3684 /// would later refuse.
3685 pub(crate) fn claim_for_appender(&mut self, path: Option<&str>) -> Result<u64, Error> {
3686 let path = path.map(split_path);
3687 if self
3688 .appender_claims
3689 .iter()
3690 .any(|c| claims_conflict(c.path.as_deref(), path.as_deref()))
3691 {
3692 return Err(Error::EditUnsupported(
3693 "this dataset already has a live buffered appender; two of them would interleave \
3694 their buffers a chunk at a time",
3695 ));
3696 }
3697 let blocked = match path.as_deref() {
3698 Some(p) => self.append_conflicts_with_pending(p),
3699 None => self.has_staged_edits() || self.committed,
3700 };
3701 if blocked {
3702 return Err(Error::EditUnsupported(
3703 "this session holds staged edits that would stop a buffered appender from \
3704 flushing; commit or discard them before opening one",
3705 ));
3706 }
3707 let token = self.next_appender_token;
3708 self.next_appender_token += 1;
3709 self.appender_claims.push(AppenderClaim { token, path });
3710 Ok(token)
3711 }
3712
3713 /// Drop a claim. Called from the appender's `Drop`, after its final flush.
3714 pub(crate) fn release_appender_claim(&mut self, token: u64) {
3715 self.appender_claims.retain(|c| c.token != token);
3716 }
3717
3718 /// Refuse a staged edit at `path` that a live appender could not survive.
3719 ///
3720 /// An appender holds elements a caller has already been told were accepted,
3721 /// and only its own flush can write them; that flush goes through the
3722 /// immediate append path, which refuses a dataset with a staged edit on it or
3723 /// an ancestor. Left unchecked, staging such an edit turns accepted data into
3724 /// data lost silently in `Drop`. Refusing here moves the failure to the call
3725 /// that creates the conflict, where there is someone to report it to.
3726 fn refuse_if_claimed(&self, path: &[String]) -> Result<(), Error> {
3727 let conflicts = self.appender_claims.iter().any(|c| match &c.path {
3728 Some(p) => paths_overlap(p, path),
3729 None => true,
3730 });
3731 if conflicts {
3732 return Err(Error::EditUnsupported(
3733 "this dataset has a live buffered appender holding elements only its own flush \
3734 can write, and this edit would stop that flush; finish or discard the appender \
3735 first",
3736 ));
3737 }
3738 Ok(())
3739 }
3740
3741 /// Whether this session is the SWMR writer, whose append rules are a strict
3742 /// subset of the ordinary ones (see `append_inplace_gathered`).
3743 pub(crate) fn is_swmr(&self) -> bool {
3744 self.swmr_mode
3745 }
3746
3747 /// Whether any staged tree edit is still uncommitted. In-place appends
3748 /// ([`append_inplace_gathered`](Self::append_inplace_gathered)) are applied immediately and are
3749 /// never staged, so they never affect this; it reflects only edits awaiting
3750 /// [`commit`](Self::commit) — `create_group`, `create_dataset`,
3751 /// `write_dataset`, `append_dataset`, group and dataset attribute edits,
3752 /// `delete`, `copy`, and `copy_from`. Dropping the session silently discards
3753 /// any staged edits.
3754 ///
3755 /// A [`commit`](Self::commit) that refuses leaves this answering `true`: the
3756 /// staged set it declined to apply is put back exactly as it was.
3757 pub fn has_staged_edits(&self) -> bool {
3758 !self.staged.is_empty()
3759 }
3760
3761 /// Run `f`, and if it fails, drop whatever it managed to stage.
3762 ///
3763 /// One caller-facing call can stage many edits — `create_group_with` stages
3764 /// a group, its attributes and its whole subtree — and each is validated as
3765 /// it is staged, so the fifth dataset can be refused after four have been
3766 /// recorded. Without this, such a call would return `Err` having changed the
3767 /// session anyway, which is the half of issue #316 that lives one level
3768 /// above `commit`: a refused operation must cost the session nothing.
3769 pub(crate) fn stage_atomically<R>(
3770 &mut self,
3771 f: impl FnOnce(&mut Self) -> Result<R, Error>,
3772 ) -> Result<R, Error> {
3773 let mark = self.staged.mark();
3774 // Saved and restored rather than simply cleared, so a nested batch does
3775 // not lift the outer one's guard on its way out.
3776 let outer = std::mem::replace(&mut self.staging_batch, true);
3777 let result = f(self);
3778 self.staging_batch = outer;
3779 if result.is_err() {
3780 self.staged.rewind(mark);
3781 }
3782 result
3783 }
3784
3785 /// This session's file image as one slice, when its backing holds the whole
3786 /// file in memory; `None` for a file-backed image.
3787 ///
3788 /// The slice reflects committed state plus immediate in-place appends, not
3789 /// edits still staged for `commit`. The owned read-write
3790 /// [`File`](crate::File) uses it to serve reads by borrowing rather than
3791 /// copying, and falls back to [`image`](Self::image) when it is absent.
3792 pub(crate) fn image_slice(&self) -> Option<&[u8]> {
3793 self.image.as_slice()
3794 }
3795
3796 /// A random-access [`Source`] view of this session's file image, for the
3797 /// parsers the edit engine drives.
3798 ///
3799 /// Every read the engine performs against the file goes through this, so the
3800 /// image needs to be no more than a source of bytes — whole-file mirror or
3801 /// not — without the parsers knowing which (issue #198).
3802 pub(crate) fn image(&self) -> &dyn Source {
3803 self.image.as_ref()
3804 }
3805
3806 /// This session's parsed superblock, with `root_group_address` normalized to
3807 /// an absolute file offset (the open-time convention) and `base_address` the
3808 /// userblock size. A relocating commit updates it, so a caller holding a
3809 /// clone from an earlier moment may be reading a stale root.
3810 pub(crate) fn superblock(&self) -> &Superblock {
3811 &self.superblock
3812 }
3813
3814 /// The file's shared-message (SOHM) master table, or `None` for a file that
3815 /// shares no messages — which is every file this crate writes and nearly
3816 /// every file it is handed.
3817 ///
3818 /// Costs one small object-header parse per commit on a file that has a
3819 /// superblock extension at all, and nothing on one that does not.
3820 fn shared_message_table(&self) -> Result<Option<crate::sohm::SohmTable>, Error> {
3821 let rel = match self.superblock.superblock_extension_address {
3822 Some(rel) if rel != UNDEF => rel,
3823 _ => return Ok(None),
3824 };
3825 let base = self.superblock.base_address;
3826 let os = self.superblock.offset_size;
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(&self.image(), abs, os, ls, base) else {
3838 return Ok(None);
3839 };
3840 let Some(msg) = header
3841 .messages
3842 .iter()
3843 .find(|m| m.msg_type == MessageType::SharedMessageTable)
3844 else {
3845 return Ok(None);
3846 };
3847 let message = crate::sohm::SharedMessageTableMessage::parse(&msg.data, os)?;
3848 let framed = BaseOffsetSource {
3849 inner: self.image(),
3850 base,
3851 };
3852 Ok(Some(crate::sohm::SohmTable::read_from_source(
3853 &framed, &message, os,
3854 )?))
3855 }
3856
3857 /// Refuse a commit that would strand a shared-message index record naming an
3858 /// object header. See [`SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT`].
3859 fn screen_shared_message_index(&self, invalidated: &InvalidatedAddresses) -> Result<(), Error> {
3860 if invalidated.is_empty() {
3861 return Ok(());
3862 }
3863 let Some(table) = self.shared_message_table()? else {
3864 return Ok(());
3865 };
3866 let base = self.superblock.base_address;
3867 let os = self.superblock.offset_size;
3868 let ls = self.superblock.length_size;
3869 let framed = BaseOffsetSource {
3870 inner: self.image(),
3871 base,
3872 };
3873 for index in &table.indexes {
3874 let records = crate::sohm::read_index_records_from_source(&framed, index, os, ls)?;
3875 screen_shared_message_records(&records, invalidated)?;
3876 }
3877 Ok(())
3878 }
3879
3880 /// The on-disk format this session writes, read from the file it opened
3881 /// rather than chosen fresh, since a commit preserves the superblock version
3882 /// it found.
3883 ///
3884 /// This decides the *contiguous* data-layout message version, where the
3885 /// older number costs nothing: versions 3 and 4 have identical bodies there,
3886 /// so a dataset added to a 1.8 file stays readable by a 1.8 library.
3887 ///
3888 /// It does not gate chunked storage. A chunked dataset needs the version 4
3889 /// layout and a 1.10 chunk index whatever the superblock says, so adding one
3890 /// to an older file does make that file need 1.10 — deliberately, since the
3891 /// alternative is a version 1 B-tree index this crate does not write, and
3892 /// refusing instead would take away in-place editing of every file the C
3893 /// library wrote with its own default bounds. `crates/crosscheck/tests/edit.rs`
3894 /// covers exactly that case (issue #101).
3895 ///
3896 /// That is a default, not a verdict: a caller who needs the file to stay
3897 /// loadable by an old reader asks for it with
3898 /// [`FileAccessProperties::with_libver_bounds`], which sets
3899 /// [`libver_ceiling`](Self::libver_ceiling) and turns the addition into a
3900 /// refusal at commit rather than a silent format bump.
3901 ///
3902 /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
3903 pub(crate) fn libver(&self) -> LibVer {
3904 LibVer::from_superblock_version(self.superblock.version)
3905 }
3906
3907 /// Constrain what this session may add, from the fapl's library-version
3908 /// bounds. See [`libver_ceiling`](Self::libver_ceiling).
3909 ///
3910 /// Resolved through [`LibVer::resolve_writable`], the same rule the
3911 /// whole-file writer applies, so bounds admitting no format this crate
3912 /// writes are refused here as they are there rather than silently ignored on
3913 /// the editing path.
3914 pub(crate) fn set_libver_bounds(
3915 &mut self,
3916 bounds: Option<(LibVer, LibVer)>,
3917 ) -> Result<(), Error> {
3918 self.libver_ceiling = match bounds {
3919 Some(_) => Some(LibVer::resolve_writable(bounds).map_err(Error::Format)?),
3920 None => None,
3921 };
3922 Ok(())
3923 }
3924
3925 /// Refuse staged content the session's [`libver_ceiling`](Self::libver_ceiling)
3926 /// cannot express, before the commit writes anything.
3927 ///
3928 /// Only chunked storage is at stake: `build_chunked_dataset_oh` writes a
3929 /// version 4 data-layout message and a 1.10 chunk index unconditionally,
3930 /// while everything else this session adds is expressible in both formats
3931 /// (`libver` already picks the contiguous layout version). A filter or an
3932 /// unlimited dimension arrives as chunked storage, so they are covered here
3933 /// too.
3934 fn check_libver_admits<'a>(
3935 &self,
3936 datasets: impl IntoIterator<Item = &'a FlatDataset>,
3937 ) -> Result<(), Error> {
3938 let Some(ceiling) = self.libver_ceiling else {
3939 return Ok(());
3940 };
3941 if ceiling >= LibVer::V110 {
3942 return Ok(());
3943 }
3944 let chunked = datasets
3945 .into_iter()
3946 .any(|fd| fd.chunk_options.is_chunked() || fd.maxshape.is_some());
3947 if chunked {
3948 return Err(Error::Format(FormatError::LibverTooOldForContent {
3949 content: "a chunked, filtered, or resizable dataset",
3950 needs: LibVer::V110.name(),
3951 writing: ceiling.name(),
3952 }));
3953 }
3954 Ok(())
3955 }
3956
3957 /// Which backend this session resolved to: [`Bounded`] when it reads through
3958 /// a handle, [`Mirrored`] when it holds a whole-file image.
3959 ///
3960 /// [`Bounded`]: EditBacking::Bounded
3961 /// [`Mirrored`]: EditBacking::Mirrored
3962 pub(crate) fn edit_backing(&self) -> EditBacking {
3963 if self.bounded {
3964 EditBacking::Bounded
3965 } else {
3966 EditBacking::Mirrored
3967 }
3968 }
3969
3970 /// A snapshot of this session's live space usage — the current file size and
3971 /// the free space it can reuse — as a [`SpaceAccounting`].
3972 ///
3973 /// This is the mutating-session analogue of the read-only accounting on
3974 /// [`File`](crate::File): it answers "how big is the file right now, and how
3975 /// much space can be reused before it must grow?" from the session's own live
3976 /// state. The snapshot reflects the committed file plus any immediate in-place
3977 /// appends ([`append_inplace_gathered`](Self::append_inplace_gathered)) but excludes edits still
3978 /// staged for the next [`commit`](Self::commit); see [`SpaceAccounting`] for
3979 /// the field-by-field semantics and [`has_staged_edits`](Self::has_staged_edits)
3980 /// for detecting pending work.
3981 ///
3982 /// On a paged file (`H5F_FSPACE_STRATEGY_PAGE`) the reported regions are the
3983 /// union of the per-page-type managers, which a commit draws on under the rule
3984 /// paging exists for: an allocation takes a hole of the page type it is placing,
3985 /// or whole pages that are free of both, so reuse never re-mixes a page
3986 /// (issue #261). Space whose page type this file does not settle is recorded
3987 /// and handed back to the reference library but never spent, and is excluded
3988 /// from the reusable figure.
3989 ///
3990 /// ```no_run
3991 /// use hdf5_pure::File;
3992 ///
3993 /// let file = File::open_rw("existing.h5")?;
3994 /// let acct = file.space_accounting()?;
3995 /// println!(
3996 /// "{} bytes on disk, {} reusable in {} free region(s)",
3997 /// acct.logical_size,
3998 /// acct.reusable_free_bytes,
3999 /// acct.reusable_free_space.len(),
4000 /// );
4001 /// # Ok::<(), hdf5_pure::Error>(())
4002 /// ```
4003 #[must_use]
4004 pub fn space_accounting(&self) -> SpaceAccounting {
4005 // The append reserve is free space this session can still spend — it is
4006 // out of the on-disk managers, not out of the file — so leaving it out
4007 // would make an in-place append look like it had consumed bytes it has
4008 // not yet placed anything in (issue #387).
4009 //
4010 // It is folded back through `FreeList::free` rather than appended and
4011 // sorted, because a reserve span very often *abuts* what is left of the
4012 // hole it was drawn from — and the spent end of the reserve walks toward
4013 // that remainder until the two touch exactly. Reported as two regions
4014 // they would break the field's documented contract in the one way that
4015 // misleads: a caller sizing an allocation against the largest region
4016 // would be told nothing that big fits when it does.
4017 //
4018 // It goes back into the list it came from and no other. On a paged file
4019 // that is the raw list, which is what `take_raw_span` drew it out of;
4020 // the two page-type lists stay reported side by side as they always
4021 // were, because a caller cannot allocate across a page-type boundary and
4022 // merging them would claim it could. `FreeList::free`'s debug assertion
4023 // comes along and is welcome: the reserve is drawn out of exactly this
4024 // list, so an overlap here would mean that invariant had already broken.
4025 //
4026 // A paged file tracks its free space per page type; report the union of
4027 // the two, since the caller wants one total rather than a per-manager
4028 // breakdown.
4029 let reusable_free_space = match (&self.paged, self.reserved.is_empty()) {
4030 // The common case, and every non-persisting session: no reserve to
4031 // fold, so nothing is cloned.
4032 (Some(pg), true) => pg.reusable_sections(),
4033 (None, true) => self.free.sections(),
4034 (Some(pg), false) => {
4035 let mut raw = pg.raw.clone();
4036 for (addr, len) in self.reserved.sections() {
4037 raw.free(addr, len);
4038 }
4039 let mut out = pg.meta.sections();
4040 out.extend(raw.sections());
4041 out.sort_unstable_by_key(|&(addr, _)| addr);
4042 debug_assert!(
4043 out.windows(2).all(|w| w[0].0 < w[1].0),
4044 "the same address is free in both the metadata and the raw \
4045 list, so the two page-type lists have stopped being disjoint"
4046 );
4047 out
4048 }
4049 (None, false) => {
4050 let mut free = self.free.clone();
4051 for (addr, len) in self.reserved.sections() {
4052 free.free(addr, len);
4053 }
4054 free.sections()
4055 }
4056 };
4057 let reusable_free_bytes = reusable_free_space.iter().map(|(_, len)| len).sum();
4058 SpaceAccounting {
4059 logical_size: self.image.len(),
4060 reusable_free_bytes,
4061 reusable_free_space,
4062 }
4063 }
4064
4065 /// The shared Extensible-Array append engine's view of this session: the
4066 /// image, the superblock, and the paged-file state, paired as [`EditStore`].
4067 ///
4068 /// Takes `&mut self`, so a caller that also needs [`located`](Self::located)
4069 /// borrowed at the same time must destructure the fields itself rather than
4070 /// call this.
4071 fn store(&mut self) -> EditStore<'_> {
4072 EditStore {
4073 image: self.image.as_mut(),
4074 superblock: &mut self.superblock,
4075 sb_sig_off: self.sb_sig_off,
4076 paged: self.paged.as_mut(),
4077 // This adapter's one caller only reads; nothing is allocated here.
4078 free: None,
4079 sync_policy: self.sync_policy,
4080 }
4081 }
4082
4083 /// Whether an immediate in-place append may allocate out of already-free
4084 /// space instead of growing the file (issue #349, issue #387).
4085 ///
4086 /// The staged [`commit`](Self::commit) always may: it publishes its
4087 /// allocations with the superblock repoint, so a crash before that leaves a
4088 /// file whose root never named them. An immediate append has no repoint, so
4089 /// what has to hold instead is that the bytes it overwrites are dead *on the
4090 /// disk as it stands*. Which list satisfies that depends on the session:
4091 ///
4092 /// - An ordinary [`File::open_rw`](crate::File::open_rw) session on a
4093 /// default-strategy file holds its free space in memory alone. A region in
4094 /// [`free`](Self::free) was freed by a commit this session already
4095 /// published, so nothing on the disk points at it and nothing on the disk
4096 /// claims it is free; a crash at any point of the append leaves it as dead
4097 /// as it was. It is spent directly.
4098 /// - A file that **persists** its free-space managers records its holes on
4099 /// disk, so a hole in `free` is one a durable manager still advertises and
4100 /// spending it would leave that manager describing bytes a live chunk
4101 /// occupies — through a clean close as much as a crash, since only a commit
4102 /// or [`finalize_persist`](Self::finalize_persist) rewrites the record.
4103 /// Such a session therefore spends [`reserved`](Self::reserved) instead:
4104 /// space [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
4105 /// has already taken *out* of the published managers.
4106 /// - A **paged** file keeps its free space per page type in [`PagedEdit`]
4107 /// rather than in [`free`](Self::free), so drawing from that list would be
4108 /// drawing from the wrong one and would put a byte of one kind in a page of
4109 /// the other. It draws its reserve through [`PagedEdit::alloc_typed`] with
4110 /// [`PageType::Raw`] instead, which is what keeps a page holding one kind.
4111 /// Named as its own term at the call site rather than left to the argument
4112 /// that the persist term already covers it (`append_prepare` refuses a
4113 /// paged file that does not persist): a page-type invariant proved from
4114 /// another function's refusal is exactly the shape that has gone wrong here
4115 /// before (issue #261). It costs nothing to name, because `reserved` is
4116 /// empty on a session that never reserved — the term declines reuse rather
4117 /// than misdirecting it.
4118 ///
4119 /// The **SWMR** writer is the one absolute bar, and it is not about
4120 /// publication at all: its concurrent readers may still be inside a region
4121 /// this session freed, and the format forbids reusing one while they are,
4122 /// which is why the C library's own SWMR writer allocates append-only. No
4123 /// test can reach this term today, because a SWMR session refuses every
4124 /// staged edit ([`Error::SwmrStagedUnsupported`]) and so never frees
4125 /// anything. It is here so that lifting that refusal cannot silently enable
4126 /// reuse.
4127 ///
4128 /// One window is inherited rather than introduced. A commit whose superblock
4129 /// write itself fails leaves [`publish_attempted`](Self::publish_attempted)
4130 /// set, which withholds the free-list rollback, so the regions that commit
4131 /// freed stay in the list although whether it published is unknowable
4132 /// (issue #344). An append can now spend them where only a later commit could
4133 /// before. That is the same trade, on a larger surface: doing nothing is
4134 /// still the only answer that is never actively wrong.
4135 fn immediate_reuse_allowed(&self) -> bool {
4136 !self.swmr_mode
4137 }
4138
4139 /// Take a batch of free space out of the on-disk free-space managers so an
4140 /// immediate in-place append may spend it, for a session that persists them
4141 /// (issue #387). Afterwards [`reserved`](Self::reserved) holds whatever could
4142 /// be drawn; the allocator that spends it is what says whether that serves
4143 /// the append, since the rewrite's own tail may have taken part of it.
4144 ///
4145 /// The draw and the publication are one step and in that order: the bytes
4146 /// leave [`free`](Self::free) (or [`PagedEdit`]'s raw list) first, and the
4147 /// managers are then rewritten through the ordinary persisting commit tail
4148 /// with nothing to free and the root unchanged — the same call
4149 /// [`finalize_persist`](Self::finalize_persist) makes, whose superblock
4150 /// repoint is the crash-atomic linearization point. Before that repoint the
4151 /// old managers stand and still list the batch, which is correct because
4152 /// nothing has been written into it; after it, no durable record calls those
4153 /// bytes free and the append may have them.
4154 ///
4155 /// Nothing here moves a live object: `to_free` is empty and the new root is
4156 /// the old one, so the tail places only itself, in a hole or past the live
4157 /// data. The [`located`](Self::located) append-geometry cache is therefore
4158 /// still valid across this call, which is what lets it run between an
4159 /// append's plan and its apply.
4160 ///
4161 /// Draws nothing — not an error — when the session persists nothing to draw
4162 /// from, or when no hole can hold the allocation in hand; the caller simply
4163 /// grows the file, as it always did. An error means the manager rewrite
4164 /// itself failed, and the lists are put back so they agree with whatever the
4165 /// disk now holds, on the rule [`commit`](Self::commit) applies: a rewrite that
4166 /// never reached its superblock write left the managers as they were, so the
4167 /// lists go back to exactly what they were, tail placement included; one that
4168 /// did may have published, so the draw is handed back — those bytes are
4169 /// genuinely free, nothing having been written into them, and the next commit
4170 /// records them again — while the span its tail took stays out, since that
4171 /// tail may now be the live one.
4172 fn reserve_for_immediate_append(&mut self, want: u64) -> Result<(), Error> {
4173 if want == 0 || self.persist.is_none() || self.swmr_mode || self.reserved.largest() >= want
4174 {
4175 return Ok(());
4176 }
4177 let snapshot = self.snapshot_free();
4178 if !self.take_raw_spans(want, APPEND_RESERVE_BYTES) {
4179 return Ok(());
4180 }
4181 self.publish_attempted = false;
4182 match self.commit_persisting(
4183 self.superblock.root_group_address,
4184 Vec::new(),
4185 TailPlacement::Anywhere,
4186 ) {
4187 Ok(()) => Ok(()),
4188 Err(e) => {
4189 if self.publish_attempted {
4190 self.release_reserve();
4191 } else {
4192 self.restore_free(snapshot);
4193 }
4194 Err(e)
4195 }
4196 }
4197 }
4198
4199 /// Move raw-appendable free space into [`reserved`](Self::reserved) for one
4200 /// manager rewrite to publish: runs that can each hold `want` bytes, until
4201 /// `cap` bytes are held or no hole that large is left. Reports whether
4202 /// anything was drawn, which is exactly whether the first hole could hold
4203 /// `want`.
4204 ///
4205 /// The draw is sized on what the file has rather than on a fixed batch, and
4206 /// that is the whole of the fix for issue #413 — [`APPEND_RESERVE_BYTES`]
4207 /// says why the cap is a cap and not a floor. The rewrite that publishes a
4208 /// draw is the expensive part, so one draw gathers as many holes as the cap
4209 /// allows and the rewrite is paid once for all of them: a file fragmented
4210 /// into chunk-sized holes costs one rewrite per cap's worth of them, not one
4211 /// per chunk.
4212 ///
4213 /// Each run is sized on the largest hole and taken best fit, so it is a whole
4214 /// hole or a `cap`-bounded prefix of one, and the reserve never holds a
4215 /// fragment too small for the allocation that drew it. `want` above `cap`
4216 /// draws one run of exactly `want`: the cap bounds the amortization, not
4217 /// what a single allocation may need.
4218 fn take_raw_spans(&mut self, want: u64, cap: u64) -> bool {
4219 debug_assert!(want > 0, "a draw is sized on a real allocation");
4220 // The cap is the budget, except that a single allocation larger than it
4221 // still draws one run for itself.
4222 let budget = cap.max(want);
4223 let mut drawn = 0u64;
4224 while budget - drawn >= want {
4225 let available = self.largest_raw_run();
4226 if available < want {
4227 break;
4228 }
4229 let len = available.min(budget - drawn);
4230 if !self.take_raw_span(len) {
4231 // Sized on the allocator's own figure, so it serves; a refusal
4232 // here means the two disagree, and drawing nothing further is
4233 // the answer that leaves the file merely larger.
4234 debug_assert!(false, "a run of {len} bytes was reported and not served");
4235 break;
4236 }
4237 drawn += len;
4238 }
4239 drawn > 0
4240 }
4241
4242 /// The longest contiguous run [`take_raw_span`](Self::take_raw_span) could
4243 /// move into the reserve right now, or `0` when nothing could.
4244 fn largest_raw_run(&self) -> u64 {
4245 match self.paged.as_ref() {
4246 Some(pg) => pg.largest_typed(PageType::Raw),
4247 None => self.free.largest(),
4248 }
4249 }
4250
4251 /// Move `len` bytes of raw-appendable free space into
4252 /// [`reserved`](Self::reserved), reporting whether a contiguous run that
4253 /// large was available.
4254 ///
4255 /// A paged file draws through [`PagedEdit::alloc_typed`] with
4256 /// [`PageType::Raw`], the same call the staged commit makes for a dataset's
4257 /// data, so the reserve is raw-typed space and an append spending it cannot
4258 /// mix a page. A flat file has one list and draws from it.
4259 fn take_raw_span(&mut self, len: u64) -> bool {
4260 let addr = match self.paged.as_mut() {
4261 Some(pg) => pg.alloc_typed(len, PageType::Raw),
4262 None => self.free.alloc(len),
4263 };
4264 match addr {
4265 Some(addr) => {
4266 self.reserved.free(addr, len);
4267 true
4268 }
4269 None => false,
4270 }
4271 }
4272
4273 /// Give the unspent append reserve back to the lists the managers are written
4274 /// from, so the next rewrite records it as the free space it is.
4275 ///
4276 /// Called immediately before every rewrite of the on-disk managers — the
4277 /// persisting commit tail and [`finalize_persist`](Self::finalize_persist) —
4278 /// and nowhere else, so a commit that does *not* rewrite them (the
4279 /// same-length-overwrite fast path) leaves the reserve intact for the appends
4280 /// that follow it.
4281 ///
4282 /// The reserve is raw-typed on a paged file, so it goes back to the raw list.
4283 /// A run that was claimed whole from the metadata list is raw space now, and
4284 /// a page of it that is wholly free can be claimed back by either type
4285 /// through [`PagedEdit::alloc_typed`], exactly as any other free page can.
4286 fn release_reserve(&mut self) {
4287 if self.reserved.is_empty() {
4288 return;
4289 }
4290 let spans = std::mem::replace(&mut self.reserved, FreeList::new()).sections();
4291 match self.paged.as_mut() {
4292 Some(pg) => {
4293 for (addr, len) in spans {
4294 pg.raw.free(addr, len);
4295 }
4296 }
4297 None => {
4298 for (addr, len) in spans {
4299 self.free.free(addr, len);
4300 }
4301 }
4302 }
4303 }
4304
4305 /// Rewrite the on-disk free-space managers for a persisting commit, giving
4306 /// the unspent append reserve back first so the managers record it, and then
4307 /// give any trailing free space the rewrite exposed back to the filesystem.
4308 ///
4309 /// Every caller that publishes a *tree* takes this rather than
4310 /// [`commit_persisting`](Self::commit_persisting) directly. The one caller
4311 /// that does not is [`reserve_for_immediate_append`](Self::reserve_for_immediate_append),
4312 /// whose whole purpose is to publish managers that leave the reserve out.
4313 fn commit_persisting_releasing_reserve(
4314 &mut self,
4315 new_root: u64,
4316 to_free: Vec<(u64, u64, FreeClass)>,
4317 ) -> Result<(), Error> {
4318 self.release_reserve();
4319 self.commit_persisting(new_root, to_free, TailPlacement::Anywhere)?;
4320 self.shrink_to_the_trailing_free_run(new_root)
4321 }
4322
4323 /// Move a commit's manager blocks down into the space that commit freed, and
4324 /// truncate the file to them (issue #418).
4325 ///
4326 /// A commit that frees a run reaching end-of-file cannot land its own tail in
4327 /// that run: the regions it is vacating are still referenced by the *on-disk*
4328 /// root until the superblock repoint, so writing over them would destroy the
4329 /// pre-commit file if the process died in between. The tail therefore goes
4330 /// past end-of-file, above the very space that should have been given back,
4331 /// and the file cannot shrink — the delete-then-commit case of issue #418.
4332 ///
4333 /// Once that repoint is durable those regions are genuinely free, so a second
4334 /// tail-only rewrite can place the blocks inside them. It publishes through
4335 /// the same crash-atomic tail as any other commit, with nothing to free and
4336 /// the root unchanged, and the truncation follows its superblock write.
4337 ///
4338 /// It runs under [`TailPlacement::ReuseOnly`], so it places the tail in free
4339 /// space or writes nothing at all. That is the safety property: a pass taken
4340 /// for the sake of a smaller file can never leave a larger one.
4341 ///
4342 /// Skipped unless the last commit's tail is the highest thing in the file and
4343 /// the free run reaching it is long enough for [`release_trailing_run`] to
4344 /// release any of — the shape a delete leaves, and the condition that bounds
4345 /// this to one extra rewrite on the commits that can actually use it. A
4346 /// non-persisting file never gets here (its commit truncates directly), and
4347 /// neither does the SWMR writer, which
4348 /// [`open_swmr_writer`](crate::File::open_swmr_writer) refuses to open on a
4349 /// file that persists its free space at all.
4350 fn shrink_to_the_trailing_free_run(&mut self, new_root: u64) -> Result<(), Error> {
4351 let Some(persist) = self.persist.as_ref() else {
4352 return Ok(());
4353 };
4354 let (Some(tail_start), Some(tail_end)) = (
4355 persist.old_blocks.iter().map(|&(a, _)| a).min(),
4356 persist.old_blocks.iter().map(|&(a, l)| a + l).max(),
4357 ) else {
4358 return Ok(());
4359 };
4360 // Only a tail at the very end of the file is standing in the way of one.
4361 if tail_end != self.image.len() {
4362 return Ok(());
4363 }
4364 // The free run reaching up to it, plus the tail the rewrite frees, is what
4365 // the rewrite would have to release from. Held to the same threshold
4366 // `release_trailing_run` applies, since below it that call returns the
4367 // file's length unchanged and the pass would cost a rewrite to publish
4368 // exactly what the commit before it did.
4369 let run_start = match self.paged.as_ref() {
4370 Some(pg) => {
4371 trailing_run_start([&pg.meta, &pg.raw, &pg.dead, &pg.unclassified], tail_start)
4372 }
4373 None => trailing_run_start([&self.free], tail_start),
4374 };
4375 if tail_end - run_start < 2 * TRAILING_RESERVE_TAILS * (tail_end - tail_start) {
4376 return Ok(());
4377 }
4378 self.commit_persisting(new_root, Vec::new(), TailPlacement::ReuseOnly)
4379 }
4380
4381 /// Adopt the fapl's `fsync` cadence. Called once, on the funnel every
4382 /// read-write open passes through, before the session is handed out.
4383 pub(crate) fn set_sync_policy(&mut self, policy: SyncPolicy) {
4384 self.sync_policy = policy;
4385 }
4386
4387 /// The cadence this session adopted. Nothing in the crate branches on this —
4388 /// [`barrier`](Self::barrier) does that — but the fapl reaching the engine is
4389 /// otherwise invisible from outside, and an entry point that forgot to pass
4390 /// it on would look exactly like one that did.
4391 #[cfg(test)]
4392 pub(crate) fn sync_policy(&self) -> SyncPolicy {
4393 self.sync_policy
4394 }
4395
4396 /// Override how long this session's writes may sit in memory.
4397 ///
4398 /// The image is private to this module, and the only caller outside it is
4399 /// [`crate::crash_replay`], which sweeps the *same* workload under gathering
4400 /// and without it. That comparison is the answer to the obvious worry about
4401 /// making writes larger, so it is worth an accessor; nothing outside a test
4402 /// build has one.
4403 #[cfg(test)]
4404 pub(crate) fn set_write_buffering(&mut self, mode: WriteBuffering) -> Result<(), Error> {
4405 self.image.set_write_buffering(mode)
4406 }
4407
4408 /// Every write this session issued, as `(offset, length)` in the order it
4409 /// went out. Same reasoning as [`set_write_buffering`](Self::set_write_buffering):
4410 /// the image is private, and a test that asks whether a publish left the
4411 /// engine as one write has nowhere else to look.
4412 #[cfg(test)]
4413 pub(crate) fn issued_write_order(&self) -> Vec<(u64, u64)> {
4414 self.image.issued_write_order()
4415 }
4416
4417 /// The durability barrier a write path calls for, data and metadata both —
4418 /// issued unless this session's [`SyncPolicy`] leaves the `fsync` cadence to
4419 /// the application.
4420 ///
4421 /// Every such point routes through here rather than touching the image, so
4422 /// the policy is honored by construction at sites yet to be written.
4423 ///
4424 /// A barrier is an **ordering** point first and a durability point second,
4425 /// and only the second half is the policy's to skip. The writes gathered
4426 /// before it are issued here whatever the policy says, because the image
4427 /// issues gathered writes in address order: leave them gathered across a
4428 /// barrier and a commit's superblock — address 0 — goes to the disk *before*
4429 /// the content it names, which is the one order the whole tail is built to
4430 /// avoid. A write that then fails, or a process that dies mid-flush, would
4431 /// leave a superblock naming bytes that are not in the file, where before it
4432 /// left the previous file intact (issue #288).
4433 ///
4434 /// [`WriteBuffering::Session`] — an explicit page buffer — is the deliberate
4435 /// exception, and [`ordering_barrier`](crate::image::FileImage::ordering_barrier)
4436 /// is what encodes that: it does nothing under that mode, which is precisely
4437 /// the guarantee such a caller is trading away. What that caller gets back is
4438 /// a file that *says* so — see [`raise_crash_mark`](Self::raise_crash_mark).
4439 ///
4440 /// The teardown barrier is deliberately *not* one of these points:
4441 /// [`File::close`](crate::File::close) and `FileInner::drop` write after the
4442 /// last barrier a caller could have asked for, so they take
4443 /// [`force_sync`](Self::force_sync) instead.
4444 fn barrier(&mut self) -> Result<(), Error> {
4445 // Exhaustive on purpose, here and at the two sites below: `SyncPolicy` is
4446 // sealed to the outside but not to this crate, so a policy added later
4447 // fails to compile at every durability point until someone decides what
4448 // it means there.
4449 match self.sync_policy {
4450 // `sync_all` issues the gathered writes on its way to the disk.
4451 SyncPolicy::Always => self.image.sync_all(),
4452 SyncPolicy::OnClose => self.image.ordering_barrier(),
4453 }
4454 }
4455
4456 /// The data-only counterpart to [`barrier`](Self::barrier), for a write that
4457 /// does not move end-of-file and so needs no metadata flush. It orders the
4458 /// gathered writes for the same reason.
4459 fn barrier_data(&mut self) -> Result<(), Error> {
4460 barrier_data(self.image.as_mut(), self.sync_policy)
4461 }
4462
4463 /// Force this session's writes to durable storage *whatever* the policy
4464 /// says. The counterpart to [`barrier`](Self::barrier), which the policy may
4465 /// skip.
4466 ///
4467 /// Two callers: [`File::sync`](crate::File::sync), the application naming
4468 /// its own cadence, and the teardown path — `File::close` and
4469 /// `FileInner::drop` — whose own writes no caller can order, because both
4470 /// destroy the handle that would have done it.
4471 pub(crate) fn force_sync(&mut self) -> Result<(), Error> {
4472 self.image.sync_all()
4473 }
4474
4475 /// Rewrite the on-disk free-space managers for a file that persists them, if
4476 /// this session left them stale.
4477 ///
4478 /// Immediate in-place appends grow the file past the managers, leaving them
4479 /// describing a length the file no longer has, or spend a reserve those
4480 /// managers no longer list. A staged
4481 /// [`commit`](Self::commit) rewrites them as part of its tail, but a session
4482 /// that only appends never runs one, so [`File::close`](crate::File::close)
4483 /// and `FileInner::drop` call this instead. It is the same tail the commit
4484 /// writes — placed in space an earlier commit freed, or appended past
4485 /// everything live when none fits, with the superblock repoint as the
4486 /// crash-atomic linearization point — with nothing to free and the root
4487 /// unchanged.
4488 ///
4489 /// Where those blocks end up is not something a reader has to care about, and
4490 /// after this change usually is not end-of-file: a file with free space to
4491 /// spend has its managers inside it, above and below live data alike. What has
4492 /// to hold is that they describe the file as it now stands, which is what makes
4493 /// this call's own writes the last the session issues.
4494 ///
4495 /// It is also where a session's last release of trailing free space happens,
4496 /// since it takes the same tail as a commit (issue #418).
4497 ///
4498 /// A no-op for a non-persisting file, and skipped when the file has not grown
4499 /// past the managers since they were last written *and* the session holds no
4500 /// unspent append reserve, so an unchanged session never grows the file. The
4501 /// reserve is the second condition because an append that spent a hole rather
4502 /// than the end of the file leaves the length alone, and what it did not
4503 /// spend is space the managers must be told about again (issue #387).
4504 ///
4505 /// If a session that grew the file ends without `close` or `drop` running (a
4506 /// true crash — `SIGKILL`, power loss), the managers are left describing the
4507 /// shorter file, and an unspent reserve is left out of them altogether. Every
4508 /// append was durable and crash-atomic, so no data is lost, and both this
4509 /// crate and the reference C library reopen the file and read it correctly;
4510 /// the managers simply under-report until a clean rewrite, which wastes those
4511 /// bytes and never hands them out twice.
4512 pub(crate) fn finalize_persist(&mut self) -> Result<(), Error> {
4513 if self.persist.is_none() || (self.image.len() == self.fsm_len && self.reserved.is_empty())
4514 {
4515 return Ok(());
4516 }
4517 self.commit_persisting_releasing_reserve(self.superblock.root_group_address, Vec::new())
4518 }
4519
4520 /// Resolve and locate an in-place append target, applying every rule that
4521 /// does not depend on the bytes being appended: the file-level eligibility
4522 /// guards, the staged-edit conflict check, and the geometry lookup that
4523 /// populates [`located`](Self::located). Returns the dataset's object-header
4524 /// address, which is that cache's key.
4525 ///
4526 /// Split out from the append itself so [`append_geometry`](Self::append_geometry)
4527 /// can report a dataset's batching geometry under exactly the same rules the
4528 /// append will apply — a caller slicing a large append into batches must be
4529 /// refused before the first batch, not part-way through.
4530 fn append_prepare(&mut self, target: AppendTarget<'_>) -> Result<u64, Error> {
4531 // The fast in-place append is only sound on a base-0 latest-format file:
4532 // the slot math assumes absolute addresses and the superblock is patched in
4533 // place per call. A userblock or pre-v2 file falls back to the staged
4534 // `append_dataset`, which rebuilds the index and repoints the superblock
4535 // last.
4536 if !self.superblock.base_address.is_zero() {
4537 return Err(Error::AppendInPlaceUnsupported(
4538 "in-place append does not support a file with a userblock (non-zero base \
4539 address); use Dataset::append_staged",
4540 ));
4541 }
4542 if self.superblock.version < 2 {
4543 return Err(Error::AppendInPlaceUnsupported(
4544 "in-place append requires a latest-format file (v2/v3 superblock); use \
4545 Dataset::append_staged",
4546 ));
4547 }
4548 // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
4549 // space has no on-disk record of which pages hold metadata and which hold
4550 // raw data, so neither this immediate append nor the staged commit can keep
4551 // the two segregated; refuse it outright. A paged *persisting* file appends
4552 // through the page-aware `EditStore`, which pads a tail page whenever the
4553 // page type changes, and has its managers rewritten at the next commit or
4554 // at close (issue #198).
4555 if self.paged.is_some() && self.persist.is_none() {
4556 return Err(Error::AppendInPlaceUnsupported(
4557 "in-place append is not supported on a paged file \
4558 (H5F_FSPACE_STRATEGY_PAGE) without persisted free space; recreate the \
4559 file with with_file_space_strategy(FileSpaceStrategy::Page, true, ..)",
4560 ));
4561 }
4562
4563 // Refuse an append against a dataset (or a subtree) that a still-staged edit
4564 // in this same session will relocate, replace, or delete — which would
4565 // strand the durably-appended rows or plan against a header the commit
4566 // moves. The caller must commit those edits first.
4567 //
4568 // A target named by object-header address cannot be compared against the
4569 // staged paths, so any staged edit at all disqualifies it. That is a
4570 // superset of the path check, and the remedy is the same one.
4571 match target {
4572 AppendTarget::Path(dataset) => {
4573 if self.append_conflicts_with_pending(&split_path(dataset)) {
4574 return Err(Error::AppendInPlaceUnsupported(
4575 "the dataset or an ancestor has a staged edit pending in this session; \
4576 commit the staged edits before appending in place, or use \
4577 Dataset::append_staged",
4578 ));
4579 }
4580 }
4581 AppendTarget::Header(_) if self.has_staged_edits() || self.committed => {
4582 return Err(Error::AppendInPlaceUnsupported(
4583 "this append target was reached by object reference, so it names a dataset \
4584 by object-header address, and this session has staged or committed edits \
4585 that can move that header; re-open the dataset by path to append to it",
4586 ));
4587 }
4588 AppendTarget::Header(_) => {}
4589 }
4590
4591 // Resolve the dataset's object-header address — the geometry cache key.
4592 // base == 0 here, so a resolved address is absolute; two hard links to
4593 // one dataset share the one entry.
4594 let oh_addr = match target {
4595 AppendTarget::Path(dataset) => match self.resolved.get(dataset) {
4596 Some(&addr) => addr,
4597 None => {
4598 let addr = crate::group_v2::resolve_path_any_from_source(
4599 &self.image(),
4600 &self.superblock,
4601 dataset,
4602 )
4603 .map_err(|_| {
4604 Error::AppendInPlaceUnsupported("nothing to append to at the given path")
4605 })?;
4606 self.resolved.insert(dataset.to_string(), addr);
4607 addr
4608 }
4609 },
4610 AppendTarget::Header(addr) => addr,
4611 };
4612
4613 // Locate the dataset on the first append (cache miss) against the session's
4614 // own image — no second lock, no second view of the file, no re-read.
4615 if !self.located.contains_key(&oh_addr) {
4616 let store = self.store();
4617 let state = locate_dataset_state(&store, oh_addr)?;
4618 self.located.insert(oh_addr, state);
4619 }
4620 Ok(oh_addr)
4621 }
4622
4623 /// The append geometry of the dataset `target` names, so a caller can slice a
4624 /// large append into aligned batches *before* materializing each batch's
4625 /// bytes — which is what keeps a bounded session's peak memory at one batch
4626 /// rather than the whole call.
4627 pub(crate) fn append_geometry(
4628 &mut self,
4629 target: AppendTarget<'_>,
4630 ) -> Result<AppendGeometry, Error> {
4631 let oh_addr = self.append_prepare(target)?;
4632 let st = &self.located[&oh_addr];
4633 let chunk_elems = st.loc.chunk_elems.max(1);
4634 Ok(AppendGeometry {
4635 chunk_elems,
4636 element_size: st.element_size,
4637 current_dim: st.loc.current_dim,
4638 lossy_filters: st.pipeline.as_ref().is_some_and(|p| !pipeline_lossless(p)),
4639 full_batch_elems: self.batch_elems(st.loc.chunk_bytes, chunk_elems),
4640 })
4641 }
4642
4643 /// Whole-chunk elements in one append batch.
4644 ///
4645 /// A bounded session caps a batch at [`APPEND_BATCH_BYTES`] of raw data (at
4646 /// least one chunk) so peak memory is independent of the call size, at the
4647 /// cost of splitting one crash-atomic append into several: a crash between
4648 /// batches leaves a valid shorter dataset, exactly as if the caller had
4649 /// looped. A mirror session already holds the whole file, so bounding the
4650 /// call buys nothing there and would trade that atomicity away for free;
4651 /// it takes the whole append as one batch.
4652 fn batch_elems(&self, chunk_bytes: usize, chunk_elems: u64) -> u64 {
4653 if !self.batched_appends {
4654 return u64::MAX;
4655 }
4656 (APPEND_BATCH_BYTES / (chunk_bytes.max(1) as u64)).max(1) * chunk_elems
4657 }
4658
4659 /// Apply a gathered in-place append (typed / generic / raw bytes) to the
4660 /// dataset `target` names, immediately and crash-atomically, driving the
4661 /// shared Extensible-Array engine against the session's own image through an
4662 /// [`EditStore`] adapter. Runs only the first `max_phase` durability phases;
4663 /// production callers pass 4, the crash-consistency tests stop at a boundary
4664 /// to simulate a crash.
4665 ///
4666 /// A bounded session splits the call into whole-chunk batches (see
4667 /// [`batch_elems`](Self::batch_elems)), each its own crash-atomic apply.
4668 /// Every predictable refusal is raised before the first batch, so a rejected
4669 /// append leaves the file untouched rather than partly grown.
4670 ///
4671 /// `Dataset::append` slices its own call the same way before it reaches here,
4672 /// so through the public API this loop runs once per call; it batches for the
4673 /// benefit of a caller that hands the engine one large builder directly, whose
4674 /// bytes are already materialized but whose plan need not be.
4675 pub(crate) fn append_inplace_gathered(
4676 &mut self,
4677 target: AppendTarget<'_>,
4678 b: &AppendBuilder,
4679 max_phase: u8,
4680 ) -> Result<(), Error> {
4681 if b.dt_conflict() {
4682 return Err(Error::AppendInPlaceUnsupported(
4683 "append mixes element types in one call; use one element type per append",
4684 ));
4685 }
4686 let oh_addr = self.append_prepare(target)?;
4687
4688 // Validate the appended bytes against the on-disk datatype.
4689 let raw = b.raw();
4690 let new_elems = validate_gathered_append(&self.located[&oh_addr], b)?;
4691 if new_elems == 0 {
4692 return Ok(());
4693 }
4694
4695 // In SWMR mode, hold to the subset a concurrent reader can follow safely:
4696 // unfiltered and chunk-aligned, so an append only ever inserts new,
4697 // not-yet-visible elements and never rewrites a visible trailing chunk
4698 // out from under a reader. Outside SWMR the session's exclusive lock is
4699 // what makes that rewrite safe; see `plan_ea_append`.
4700 if self.swmr_mode {
4701 let st = &self.located[&oh_addr];
4702 if st.pipeline.is_some() {
4703 return Err(Error::SwmrAppendUnsupported(
4704 "filtered datasets are not supported for SWMR append",
4705 ));
4706 }
4707 let chunk_elems = st.loc.chunk_elems;
4708 if chunk_elems == 0
4709 || st.loc.current_dim % chunk_elems != 0
4710 || new_elems % chunk_elems != 0
4711 {
4712 return Err(Error::SwmrAppendUnsupported(
4713 "SWMR append must be chunk-aligned: the current length and the appended \
4714 length must both be whole multiples of the chunk length",
4715 ));
4716 }
4717 }
4718
4719 let (chunk_elems, elem_bytes, full_batch_elems) = {
4720 let st = &self.located[&oh_addr];
4721 (
4722 st.loc.chunk_elems.max(1),
4723 st.element_size.get() as u64,
4724 self.batch_elems(st.loc.chunk_bytes, st.loc.chunk_elems.max(1)),
4725 )
4726 };
4727 // Outside SWMR the session holds an exclusive lock, so re-encoding a
4728 // filtered partial trailing chunk into a fresh allocation and repointing
4729 // its index element is a change no other reader is crossing; see
4730 // `plan_ea_append`, which owns that argument and keeps the refusal for
4731 // the SWMR writer.
4732 let grow_visible_tail = !self.swmr_mode;
4733
4734 let mut done = 0u64;
4735 while done < new_elems {
4736 // Fill the trailing partial chunk first (so later batches start
4737 // chunk-aligned and never rewrite it again), then whole-chunk batches.
4738 // That first batch is the only one that can re-encode a filtered
4739 // trailing chunk; every batch after it starts on a boundary.
4740 let current_dim = self.located[&oh_addr].loc.current_dim;
4741 let to_boundary = (chunk_elems - current_dim % chunk_elems) % chunk_elems;
4742 let take = (new_elems - done).min(to_boundary.saturating_add(full_batch_elems));
4743 let batch =
4744 &raw[(done * elem_bytes).to_usize()?..((done + take) * elem_bytes).to_usize()?];
4745
4746 // Read/plan phase (immutable borrows only, nothing published yet), then
4747 // the ordered, fsync-barriered write phase — both shared with
4748 // `Dataset::append` through the chunk-index engine. `EditStore` borrows
4749 // only the image-carrying fields, so `self.located` stays independently
4750 // borrowable.
4751 let plan_result = {
4752 let Self {
4753 image,
4754 superblock,
4755 sb_sig_off,
4756 paged,
4757 located,
4758 sync_policy,
4759 ..
4760 } = self;
4761 let st = &located[&oh_addr];
4762 let store = EditStore {
4763 image: image.as_mut(),
4764 superblock,
4765 sb_sig_off: *sb_sig_off,
4766 paged: paged.as_mut(),
4767 // Planning only reads; nothing is allocated here.
4768 free: None,
4769 sync_policy: *sync_policy,
4770 };
4771 plan_ea_append(
4772 &store,
4773 &st.loc,
4774 &st.datatype,
4775 &st.spatial,
4776 st.element_size,
4777 st.pipeline.as_ref(),
4778 grow_visible_tail,
4779 batch,
4780 take,
4781 st.fill.pattern(st.element_size),
4782 )
4783 };
4784 let plan = plan_result.map_err(as_inplace_error)?;
4785 // A persisting file's holes are advertised on disk, so this batch's
4786 // bytes have to be taken out of the published managers before any of
4787 // them is written into. Sized on the largest single blob, since the
4788 // allocator serves each one from a contiguous run: a reserve that
4789 // cannot hold the biggest is a reserve the biggest goes past. A
4790 // refusal here (nothing left to draw, or nothing to draw from) is not
4791 // an error — the append grows the file, as it always did. Placed
4792 // between the plan and the apply because the manager rewrite it makes
4793 // publishes a superblock, and nothing may be half-applied across that.
4794 let largest_blob = plan
4795 .new_chunk_bytes
4796 .iter()
4797 .map(|b| b.len() as u64)
4798 .max()
4799 .unwrap_or(0);
4800 self.reserve_for_immediate_append(largest_blob)?;
4801 {
4802 let reuse = self.immediate_reuse_allowed();
4803 // Two terms, not one. A **paged** session must never be handed
4804 // `free`: it keeps its space per page type in `PagedEdit`, so
4805 // that list is the wrong one to draw from and placing a byte out
4806 // of it would mix a page. Every paged session is a persisting one
4807 // (`append_prepare` refuses a paged file without persistence), so
4808 // the first term alone would pick `reserved` for it anyway — and
4809 // that is exactly the shape issue #261 was: a page-type invariant
4810 // proved from a refusal in another function. Named here so the
4811 // safety is by construction, and so deleting that refusal cannot
4812 // silently turn a paged append loose on the flat list. `reserved`
4813 // is empty on any session that never reserved, which makes the
4814 // extra term cost nothing but the reuse it correctly declines.
4815 let from_reserve = self.persist.is_some() || self.paged.is_some();
4816 let Self {
4817 image,
4818 superblock,
4819 sb_sig_off,
4820 paged,
4821 located,
4822 free,
4823 reserved,
4824 sync_policy,
4825 ..
4826 } = self;
4827 let st = located.get_mut(&oh_addr).expect("dataset located above");
4828 let mut store = EditStore {
4829 image: image.as_mut(),
4830 superblock,
4831 sb_sig_off: *sb_sig_off,
4832 paged: paged.as_mut(),
4833 // A persisting or paged session spends only what the reserve
4834 // took out of the managers; every other session spends its
4835 // in-memory list directly. See `immediate_reuse_allowed`.
4836 free: reuse.then_some(if from_reserve { reserved } else { free }),
4837 sync_policy: *sync_policy,
4838 };
4839 apply_ea_append(&mut store, &mut st.loc, &plan, max_phase)
4840 .map_err(as_inplace_error)?;
4841 }
4842 if max_phase < 4 {
4843 // Crash-consistency hook: the caller asked to stop inside the
4844 // first batch's durability sequence, so there is no next batch.
4845 // Each phase's own barrier has already issued the writes up to it
4846 // under either policy, which is what leaves a phase boundary a
4847 // real one for the tests that stop at it.
4848 return Ok(());
4849 }
4850 done += take;
4851 }
4852 Ok(())
4853 }
4854
4855 /// Test-only phased in-place append (stops after `max_phase` durability phases)
4856 /// used by the crash-consistency tests, mirroring `Dataset::append`'s harness.
4857 #[cfg(test)]
4858 fn append_inplace_i32_phased(
4859 &mut self,
4860 dataset: &str,
4861 values: &[i32],
4862 max_phase: u8,
4863 ) -> Result<(), Error> {
4864 let mut b = AppendBuilder::new();
4865 b.append_i32(values);
4866 self.append_inplace_gathered(AppendTarget::Path(dataset), &b, max_phase)
4867 }
4868
4869 /// Whether `target` (an [`append_inplace_gathered`](Self::append_inplace_gathered) dataset path)
4870 /// or any of its ancestors is named by a staged edit that a later
4871 /// [`commit`](Self::commit) would relocate, replace, or delete. `create_group`
4872 /// and group-attribute edits are excluded: they rewrite a group header without
4873 /// moving a descendant dataset's header or freeing its storage, so they cannot
4874 /// stale the append geometry cache.
4875 fn append_conflicts_with_pending(&self, target: &[String]) -> bool {
4876 let hits = |p: &[String]| paths_overlap(target, p);
4877 self.staged.writes.iter().any(|(p, _)| hits(p))
4878 || self.staged.appends.iter().any(|(p, _)| hits(p))
4879 || self.staged.deletes.iter().any(|p| hits(p))
4880 || self.staged.copies.iter().any(|(_, dst)| hits(dst))
4881 || self.staged.cross_copies.iter().any(|(dst, _)| hits(dst))
4882 || self.staged.dataset_attrs.iter().any(|(p, _)| hits(p))
4883 || self.staged.datasets.iter().any(|(parent, fd)| {
4884 let mut full = parent.clone();
4885 full.push(fd.name.clone());
4886 paths_overlap(target, &full)
4887 })
4888 }
4889
4890 /// What this session has staged at `path`, for a handle that wants to
4891 /// address a not-yet-committed object by name.
4892 ///
4893 /// Only a *creation* answers, and only when it owns the path — when the file
4894 /// holds no link there, or when this same commit removes the one it does.
4895 /// Both halves matter:
4896 ///
4897 /// - A staged **deletion** on its own hides nothing. The object is in the
4898 /// file and still readable, and a lookup that pretended otherwise would
4899 /// refuse a read the file can serve.
4900 /// - A creation **colliding** with a link the commit does not remove is one
4901 /// [`refuse_creation_collision`](Self::refuse_creation_collision) turns
4902 /// away where it is staged, so this arm is the same rule asked a second
4903 /// time rather than a state a caller can reach. Reporting such a creation
4904 /// would shadow a live, readable object with a handle that never becomes
4905 /// valid, so the file's own object stays the meaning of the name.
4906 /// - The two together are a **replacement** (issue #305), and the creation
4907 /// is what the path names from the moment it is staged, before the commit
4908 /// as after. "The two together" is
4909 /// [`StagedEdits::deletes_hand_over`]'s rule: the deletion names this path,
4910 /// or an ancestor this session builds again.
4911 ///
4912 /// Only a path the session **named** answers. A commit also creates the
4913 /// intermediate groups on the way to an addition — `create_dataset("a/b")`
4914 /// gets an `a` whether or not one was asked for — and those are *not*
4915 /// reported, because a session cannot tell from its staged set alone whether
4916 /// such a group is one it is adding or one already in the file. Reporting
4917 /// them would hand a caller a not-yet-committed handle onto a group they can
4918 /// read today, which is the worse of the two mistakes; stage the group by
4919 /// name to address it before the commit.
4920 pub(crate) fn staged_object(&self, path: &str) -> Option<StagedObject> {
4921 if self.stages_no_creations() {
4922 return None;
4923 }
4924 let comps = split_path(path);
4925 if comps.is_empty() {
4926 // The root always exists; nothing can stage it.
4927 return None;
4928 }
4929 let kind = if self.staged.dataset_at(&comps).is_some() {
4930 StagedKind::Dataset
4931 } else if self.staged.has_group_at(&comps) {
4932 StagedKind::Group
4933 } else {
4934 return None;
4935 };
4936 let replaces_link = self.staged.deletes_hand_over(&comps);
4937 // The file is asked only about a path a creation actually names, so an
4938 // ordinary open of an object nothing is staged at never pays for this.
4939 if !replaces_link && self.path_in_file(&comps) {
4940 return None;
4941 }
4942 Some(StagedObject {
4943 kind,
4944 replaces_link,
4945 })
4946 }
4947
4948 /// Refuse a creation at a path something already owns — a link the file
4949 /// holds and this session does not remove, or a creation this session
4950 /// already staged — at the call that stages it.
4951 ///
4952 /// The commit refuses the same batch (`a link with this name already
4953 /// exists`), and the file arm raises that refusal's own error so the two
4954 /// cannot disagree about what a collision is. Refusing here is what lets
4955 /// [`Group::create_dataset`](crate::Group::create_dataset) and its two
4956 /// siblings hand back a handle onto the object they stage: a creation that
4957 /// merely collides is not what its path names — the file's object still is
4958 /// — so a handle onto it would have addressed the *old* object while
4959 /// claiming to address the new one.
4960 ///
4961 /// A creation this session already stages at the path is refused for the
4962 /// same reason, one the commit's own refusal cannot stand in for. The staged
4963 /// set is indexed by path and the index keeps the *first* record at each one
4964 /// (`or_insert`), so a second creation is staged behind the first and every
4965 /// handle onto that path — the one this call would hand back included —
4966 /// answers `shape`, `dtype`, `filters` and `append_staged` from the record
4967 /// the caller did not just describe. No committed object is misaddressed,
4968 /// since the commit refuses the batch, but the window between the two is one
4969 /// where a handle reports another object's geometry as its own.
4970 ///
4971 /// Two *group* creations at one path are the exception and stay allowed:
4972 /// they name one node, the commit builds one group from them, and a handle
4973 /// onto either addresses that group. Re-staging a group is how a caller adds
4974 /// attributes or children to one it already staged.
4975 ///
4976 /// A [`copy`](Self::copy) destination is still the commit's collision to
4977 /// refuse: it hands back no handle, so nothing can be misaddressed by it.
4978 fn refuse_creation_collision(&self, comps: &[String], kind: StagedKind) -> Result<(), Error> {
4979 // An empty path names the root, which is not a link and cannot be
4980 // created. Both callers already refuse it by name — with a message that
4981 // says so — so this must not answer first with a collision.
4982 if comps.is_empty() {
4983 return Ok(());
4984 }
4985 // Asked before the file, so that staging twice over a link this session
4986 // deletes — a replacement, which the file arm below lets through — is
4987 // caught by the same rule as staging twice over nothing.
4988 if self.staged.dataset_at(comps).is_some()
4989 || (kind == StagedKind::Dataset && self.staged.has_group_at(comps))
4990 {
4991 return Err(Error::EditUnsupported(
4992 "this session already stages an object with this name in the target group; \
4993 delete it to withdraw that staging before staging another in its place",
4994 ));
4995 }
4996 if self.staged.deletes_hand_over(comps) || !self.path_in_file(comps) {
4997 return Ok(());
4998 }
4999 Err(Error::EditUnsupported(
5000 "a link with this name already exists in the target group",
5001 ))
5002 }
5003
5004 /// Whether the file this session is editing holds a link at `path`, as of
5005 /// its committed state.
5006 ///
5007 /// Resolved against a borrowed slice where the backing can lend one, as the
5008 /// reader's own path resolution does: this is asked once per by-name lookup
5009 /// of a path a creation is staged at, so the copy a `Source` read would make
5010 /// per link block is worth avoiding.
5011 fn path_in_file(&self, path: &[String]) -> bool {
5012 let joined = path.join("/");
5013 match self.image.as_slice() {
5014 Some(data) => {
5015 crate::group_v2::resolve_path_any(data, &self.superblock, &joined).is_ok()
5016 }
5017 None => crate::group_v2::resolve_path_any_from_source(
5018 &self.image(),
5019 &self.superblock,
5020 &joined,
5021 )
5022 .is_ok(),
5023 }
5024 }
5025
5026 /// Whether this session has staged no creations at all, which is the common
5027 /// case on the by-name lookup path. Checked before splitting a path into
5028 /// components, so an ordinary open of an existing object does not allocate
5029 /// one per call to ask a question whose answer is already known.
5030 fn stages_no_creations(&self) -> bool {
5031 self.staged.groups.is_empty() && self.staged.datasets.is_empty()
5032 }
5033
5034 /// How many times a commit has consumed this session's staged set, rather
5035 /// than handing it back to it. See the
5036 /// [`staged_generation`](Self::staged_generation) field for the rule, and
5037 /// [`crate::Error::StagingWithdrawn`] for what a handle does with it.
5038 pub(crate) fn staged_generation(&self) -> u64 {
5039 self.staged_generation
5040 }
5041
5042 /// What a staged dataset at `path` says about itself, for the introspection
5043 /// a handle onto it can answer before the commit writes any bytes.
5044 ///
5045 /// Cloned out of the staged [`FlatDataset`], which is the whole record of
5046 /// what the commit will write, so these answers cannot drift from the
5047 /// dataset that lands. Answers only for a creation that owns its path, on
5048 /// the terms [`staged_object`](Self::staged_object) sets out.
5049 pub(crate) fn staged_dataset_meta(&self, path: &str) -> Option<StagedMeta> {
5050 if self.staged_object(path)?.kind != StagedKind::Dataset {
5051 return None;
5052 }
5053 let fd = self.staged.dataset_at(&split_path(path))?;
5054 Some(StagedMeta {
5055 datatype: fd.dt.clone(),
5056 dimensions: fd.ds.dimensions.clone(),
5057 // Reported the way `Dataset::maxshape` reports an on-disk one: a
5058 // maximum equal to the current shape is a fixed-shape dataset.
5059 maxshape: match &fd.ds.max_dimensions {
5060 Some(md) if *md != fd.ds.dimensions => Some(md.clone()),
5061 _ => None,
5062 },
5063 // The rule the commit applies: chunk options or an extensible shape
5064 // select chunked storage, anything else contiguous.
5065 chunked: fd.chunk_options.is_chunked() || fd.maxshape.is_some(),
5066 filters: fd
5067 .chunk_options
5068 .filters
5069 .iter()
5070 .map(|f| (f.kind.filter_id(), f.optional))
5071 .collect(),
5072 })
5073 }
5074
5075 /// The direct children `parent` gains from this session's staged creations:
5076 /// the groups first and then the datasets, each in the order it was staged.
5077 ///
5078 /// `parent` may itself be staged, which is what lets a listing on a
5079 /// not-yet-committed group report its members. Only children the session
5080 /// named answer, for the reason [`staged_object`](Self::staged_object)
5081 /// gives.
5082 ///
5083 /// Unlike [`staged_object`](Self::staged_object) this does not ask the file
5084 /// whether each name is already taken: the caller is enumerating a group and
5085 /// already holds its on-disk links, so `replaces_link` is all it needs to
5086 /// apply the same rule without a path resolution per child.
5087 pub(crate) fn staged_children(&self, parent: &str) -> Vec<StagedChild> {
5088 if self.stages_no_creations() {
5089 return Vec::new();
5090 }
5091 let base = split_path(parent);
5092 // Whether this group is itself a replacement staged here — its on-disk
5093 // links go with the object being removed, so every creation under it
5094 // owns its name — and which links directly under it are removed by
5095 // name: the two ways a creation here is a replacement rather than a
5096 // collision. A deletion of the group that this session does *not* build
5097 // again is neither: the commit refuses that batch, so the file's own
5098 // children still own their names (see [`StagedEdits::deletes_hand_over`]).
5099 let base_deleted = self.staged.deletes_hand_over(&base) && self.staged.has_group_at(&base);
5100 let deleted_here: HashSet<&str> = self
5101 .staged
5102 .deletes
5103 .iter()
5104 .filter(|d| d.len() == base.len() + 1 && d[..base.len()] == base[..])
5105 .map(|d| d[base.len()].as_str())
5106 .collect();
5107
5108 let mut seen: HashSet<&str> = HashSet::new();
5109 let mut out: Vec<StagedChild> = Vec::new();
5110 for path in &self.staged.groups {
5111 if path.len() == base.len() + 1 && path[..base.len()] == base[..] {
5112 let name = path[base.len()].as_str();
5113 if seen.insert(name) {
5114 out.push(StagedChild {
5115 name: name.to_string(),
5116 kind: StagedKind::Group,
5117 replaces_link: base_deleted || deleted_here.contains(name),
5118 });
5119 }
5120 }
5121 }
5122 for (p, fd) in &self.staged.datasets {
5123 if p[..] == base[..] && seen.insert(fd.name.as_str()) {
5124 out.push(StagedChild {
5125 name: fd.name.clone(),
5126 kind: StagedKind::Dataset,
5127 replaces_link: base_deleted || deleted_here.contains(fd.name.as_str()),
5128 });
5129 }
5130 }
5131 out
5132 }
5133
5134 /// Stage a new (empty) group at `path`, created on the next
5135 /// [`commit`](Self::commit). The parent must already exist or be created in
5136 /// the same session; populate the group with datasets via
5137 /// [`create_dataset`](Self::create_dataset) using a path under it.
5138 pub fn create_group(&mut self, path: &str) -> Result<(), Error> {
5139 let comps = split_path(path);
5140 self.refuse_if_claimed(&comps)?;
5141 self.refuse_creation_collision(&comps, StagedKind::Group)?;
5142 self.staged.push_group(comps);
5143 Ok(())
5144 }
5145
5146 /// Stage an attribute add or replacement on a group, applied on the next
5147 /// [`commit`](Self::commit).
5148 ///
5149 /// `path` names the group to edit; `""` or `"/"` names the root group. The
5150 /// group may already exist or may be created earlier in the same session
5151 /// with [`create_group`](Self::create_group). Attributes — fixed-size or
5152 /// variable-length — are stored compactly in the rebuilt group header while
5153 /// they fit it, and in a fractal heap past that, on the terms
5154 /// [`plan_attr_ops`] sets out.
5155 pub fn set_group_attr(
5156 &mut self,
5157 path: &str,
5158 name: &str,
5159 value: AttrValue,
5160 ) -> Result<(), Error> {
5161 let comps = split_path(path);
5162 self.refuse_if_claimed(&comps)?;
5163 self.staged.group_attrs.push((
5164 comps,
5165 AttrOp::Set {
5166 name: name.to_string(),
5167 value,
5168 },
5169 ));
5170 Ok(())
5171 }
5172
5173 /// Stage removal of a compact attribute from a group, applied on the next
5174 /// [`commit`](Self::commit).
5175 ///
5176 /// `path` names the group to edit; `""` or `"/"` names the root group. The
5177 /// named attribute must exist in the committed group state after any earlier
5178 /// staged attribute operations for the same group have been applied.
5179 pub fn remove_group_attr(&mut self, path: &str, name: &str) -> Result<(), Error> {
5180 let comps = split_path(path);
5181 self.refuse_if_claimed(&comps)?;
5182 self.staged.group_attrs.push((
5183 comps,
5184 AttrOp::Remove {
5185 name: name.to_string(),
5186 },
5187 ));
5188 Ok(())
5189 }
5190
5191 /// Stage an attribute add or replacement on an **existing dataset**, applied on
5192 /// the next [`commit`](Self::commit).
5193 ///
5194 /// `path` names the dataset to edit. Attributes — fixed-size or
5195 /// variable-length — are stored in the rebuilt dataset header
5196 /// while they fit it, and in a fractal heap past that, on the terms
5197 /// [`plan_attr_ops`] sets out. Applying it relocates the dataset's object
5198 /// header (the header is rewritten and its single naming link repointed; the
5199 /// dataset's data and chunk index stay in place), so it is supported only when
5200 /// the dataset has a **single hard link**. To set attributes on a dataset being *created* in this
5201 /// session, use the builder's [`set_attr`](crate::DatasetBuilder::set_attr)
5202 /// instead.
5203 pub fn set_dataset_attr(
5204 &mut self,
5205 path: &str,
5206 name: &str,
5207 value: AttrValue,
5208 ) -> Result<(), Error> {
5209 let comps = split_path(path);
5210 self.refuse_if_claimed(&comps)?;
5211 self.staged.dataset_attrs.push((
5212 comps,
5213 AttrOp::Set {
5214 name: name.to_string(),
5215 value,
5216 },
5217 ));
5218 Ok(())
5219 }
5220
5221 /// Stage removal of a compact attribute from an **existing dataset**, applied on
5222 /// the next [`commit`](Self::commit).
5223 ///
5224 /// `path` names the dataset to edit; the named attribute must exist in the
5225 /// committed dataset state after any earlier staged attribute operations for the
5226 /// same dataset have been applied. Like [`set_dataset_attr`](Self::set_dataset_attr)
5227 /// it relocates the dataset header and requires a single hard link.
5228 pub fn remove_dataset_attr(&mut self, path: &str, name: &str) -> Result<(), Error> {
5229 let comps = split_path(path);
5230 self.refuse_if_claimed(&comps)?;
5231 self.staged.dataset_attrs.push((
5232 comps,
5233 AttrOp::Remove {
5234 name: name.to_string(),
5235 },
5236 ));
5237 Ok(())
5238 }
5239
5240 /// Stage removal of the link at `path` (the HDF5 `H5Ldelete`), applied on the
5241 /// next [`commit`](Self::commit). The link's object — and, for a group, its
5242 /// whole subtree — becomes unreachable. The bytes it occupied are returned to
5243 /// this session's free list (issue #21): a later commit reuses them for new
5244 /// objects instead of growing the file, and if a freed run reaches
5245 /// end-of-file the file is truncated. Contiguous and chunked datasets (their
5246 /// chunk index and chunk data blocks) and whole group subtrees are all
5247 /// reclaimed. Reclaim is best-effort — an object whose blocks this engine
5248 /// cannot enumerate exhaustively (variable-length global-heap storage, dense
5249 /// attribute/link heaps, a version 2 B-tree chunk index) is left as dead
5250 /// bytes rather than risk freeing a region that is still in use. Freed space is
5251 /// reused within the open session; for a file created with
5252 /// `H5Pset_file_space_strategy(persist = true)` it is also recorded on disk so
5253 /// it survives reopen, otherwise it is forgotten
5254 /// on close. After reuse, an object reference to a deleted object may resolve
5255 /// to an unrelated object (deleting a referenced object is undefined in HDF5).
5256 ///
5257 /// The path must exist. The link's parent group must itself be editable in
5258 /// place (compact links, single-chunk header); the target being removed has
5259 /// no such restriction.
5260 ///
5261 /// # Replacing an object
5262 ///
5263 /// Deleting a path and creating a new object at that same path in one commit
5264 /// is a *replacement*, and is accepted (issue #305): the removal is applied
5265 /// before the addition, and the commit's single superblock write publishes
5266 /// both, so the path is occupied at every instant a crash could interrupt.
5267 /// The new object need not resemble the old one — a dataset may replace a
5268 /// group, or the reverse — and replacing a group discards its whole subtree
5269 /// rather than inheriting it.
5270 ///
5271 /// Everything this commit adds *below* a replaced path lands in the
5272 /// replacement, whatever order the calls were made in: staging
5273 /// `create_dataset("g/x")` and then replacing `g` puts `x` in the new group,
5274 /// not the one being removed. A staged edit that could only mean the
5275 /// *original* is refused instead — a group under the replaced path that this
5276 /// commit does not itself create, an attribute set on the object being
5277 /// removed, a value overwrite inside it, or a [`copy`](crate::File::copy)
5278 /// reading from it.
5279 ///
5280 /// See [`Group::delete`](crate::Group::delete) for the public form and a
5281 /// worked example.
5282 ///
5283 /// A deletion may not overlap a staged change at a *different* path
5284 /// (deleting `/a` while adding `/a/b`, unless `/a` is itself replaced in the
5285 /// same commit), nor an edit to the object being removed (an attribute set
5286 /// on it, or a value overwrite of something inside it); split those into
5287 /// separate commits.
5288 /// Deleting something this session **staged** and has not committed
5289 /// withdraws that staging instead: there is no link in the file to unlink,
5290 /// so the session is left as though the creation had never been made — its
5291 /// attributes, appends and staged children go with it. When the file *also*
5292 /// holds a link at the path (the creation was replacing it), the withdrawal
5293 /// leaves the plain deletion of the file's own object behind.
5294 ///
5295 /// The root itself (`""` or `"/"`) is refused with [`Error::EditUnsupported`]:
5296 /// nothing links to it, so there is no link to remove.
5297 pub fn delete(&mut self, path: &str) -> Result<(), Error> {
5298 let comps = split_path(path);
5299 // The root is not linked from anywhere, so there is no link to remove —
5300 // and an empty path is a prefix of every other, so a deletion staged
5301 // here would make every staged creation in the session look like a
5302 // replacement of a file object until the commit refused the batch.
5303 // Refused by name, as creating the root is.
5304 if comps.is_empty() {
5305 return Err(Error::EditUnsupported(
5306 "cannot delete the root group; delete its members instead",
5307 ));
5308 }
5309 self.refuse_if_claimed(&comps)?;
5310 if self.staged.dataset_at(&comps).is_some() || self.staged.has_group_at(&comps) {
5311 self.refuse_mid_batch()?;
5312 self.staged.withdraw_at(&comps);
5313 // Withdrawing is the whole deletion unless the file holds a link
5314 // here too — and if it does, a deletion of it may already be staged,
5315 // which is what made this a replacement in the first place. A second
5316 // one would be an overlapping deletion the commit refuses.
5317 if self.staged.deletes_cover(&comps) || !self.path_in_file(&comps) {
5318 return Ok(());
5319 }
5320 }
5321 self.staged.deletes.push(comps);
5322 Ok(())
5323 }
5324
5325 /// Stage a deep copy of the object at `src` to a new link at `dst` (the HDF5
5326 /// `H5Ocopy`), applied on the next [`commit`](Self::commit). The source — a
5327 /// dataset or a whole group subtree — is duplicated: fresh copies of every
5328 /// object's data and header are written, internal links and the contiguous
5329 /// data address are repointed to the copies, and a link named by `dst`'s last
5330 /// component is added to `dst`'s parent group. The original is untouched.
5331 ///
5332 /// The copy reflects the file's on-disk state at commit time. `src` must
5333 /// exist and `dst` must not (and may not lie inside `src`). A chunked (and
5334 /// filtered) dataset is copied with its chunk payloads and filter pipeline
5335 /// preserved byte-for-byte (the index is rebuilt at the new location, so a
5336 /// source using a B-tree-v1 or implicit index is reproduced with an equivalent
5337 /// v4 index). The source subtree must otherwise be copyable in place: compact
5338 /// links and attributes, single-chunk headers, and a chunk index this engine
5339 /// can enumerate (a version-2 B-tree, or a sparse/unallocated chunk grid, is
5340 /// refused) — otherwise `commit` reports [`Error::EditUnsupported`].
5341 ///
5342 /// A *contiguous* dataset whose storage was never allocated is copied as the
5343 /// storage it has — none — rather than as the fill value a read answers with,
5344 /// which is what keeps a schema-only source from arriving materialized at the
5345 /// size its shape declares. A dataset storing its elements in external files
5346 /// (`H5Pset_external`) carries that same empty storage over data this crate
5347 /// does not read, and is refused by name so the two do not share an answer
5348 /// (issue #336).
5349 pub fn copy(&mut self, src: &str, dst: &str) -> Result<(), Error> {
5350 let (s, d) = (split_path(src), split_path(dst));
5351 // Both ends matter: the source is read and the destination is written,
5352 // and a commit relocates headers along either path.
5353 self.refuse_if_claimed(&s)?;
5354 self.refuse_if_claimed(&d)?;
5355 self.staged.copies.push((s, d));
5356 Ok(())
5357 }
5358
5359 /// Stage a deep copy of the object at `src` in another open file `source` to a
5360 /// new link at `dst` in this file — a *cross-file* HDF5 `H5Ocopy` — applied on
5361 /// the next [`commit`](Self::commit). Like [`copy`](Self::copy) but the source
5362 /// lives in a separate, independently-opened [`File`](crate::File) reader
5363 /// rather than the file being edited.
5364 ///
5365 /// The source — a dataset or a whole group subtree — is duplicated faithfully:
5366 /// fresh, byte-identical copies of every object's header and data are appended
5367 /// to this file, internal links repointed, and a link named by `dst`'s last
5368 /// component added to `dst`'s parent group (which must already exist or be
5369 /// created earlier in this session). Both files are left otherwise untouched;
5370 /// the destination only changes on `commit`.
5371 ///
5372 /// Unlike the same-file [`copy`](Self::copy), the source is read **eagerly**
5373 /// here (the `source` borrow need not outlive the call), so this returns
5374 /// `Result`: the source subtree is resolved, validated, and read out before
5375 /// returning, and only an already-validated copy is queued for `commit`.
5376 ///
5377 /// # Errors
5378 ///
5379 /// Returns [`Error::EditUnsupported`] if the copy cannot be reproduced exactly
5380 /// in another file. Because the copy is byte-for-byte verbatim, anything that
5381 /// embeds a *source-file* absolute address is refused (it would dangle here):
5382 /// **variable-length** or **reference** datasets and attributes (including a
5383 /// chunked dataset whose elements are variable-length or references, whose
5384 /// chunk payloads embed such addresses), and any **shared header message** (a
5385 /// committed datatype, or an SOHM-shared dataspace, fill value, or filter
5386 /// pipeline). As with [`copy`](Self::copy) a chunked/filtered source is copied
5387 /// with its chunk payloads and pipeline preserved (index rebuilt at the new
5388 /// location); the source must use compact links and attributes, single-chunk
5389 /// version-2 headers, and a chunk index this engine can enumerate (a
5390 /// version-2 B-tree, or a sparse chunk grid, is refused). The
5391 /// `source` must be a buffered file ([`File::open`](crate::File::open) or
5392 /// [`File::from_bytes`](crate::File::from_bytes), not
5393 /// [`open_streaming`](crate::File::open_streaming)) using 8-byte offsets and no
5394 /// userblock, and `src` must exist in it and not be the root group.
5395 pub fn copy_from(
5396 &mut self,
5397 source: &crate::reader::File,
5398 src: &str,
5399 dst: &str,
5400 ) -> Result<(), Error> {
5401 // The source bytes must be addressable: a streaming file is refused.
5402 let src_data = source.in_memory_image().ok_or(Error::EditUnsupported(
5403 "cross-file copy requires a buffered source file (File::open or File::from_bytes), not a streaming one",
5404 ))?;
5405 let src_sb = source.superblock();
5406 if src_sb.offset_size != OFFSET_SIZE || src_sb.length_size != LENGTH_SIZE {
5407 return Err(Error::EditUnsupported(
5408 "cross-file copy requires the source file to use 8-byte offsets and lengths",
5409 ));
5410 }
5411 if !source.base_address().is_zero() {
5412 return Err(Error::EditUnsupported(
5413 "cross-file copy requires the source file to have no userblock (base address 0)",
5414 ));
5415 }
5416
5417 let src = split_path(src);
5418 if src.is_empty() {
5419 return Err(Error::EditUnsupported("cannot copy the root group"));
5420 }
5421 let dst = split_path(dst);
5422 if dst.is_empty() {
5423 return Err(Error::EditUnsupported("copy destination path is empty"));
5424 }
5425
5426 let src_addr = crate::group_v2::resolve_path_any(src_data, src_sb, &src.join("/"))
5427 .map_err(|_| Error::EditUnsupported("copy source does not exist in the source file"))?;
5428 // Read (and foreign-address-screen) the whole subtree now, while `source`
5429 // is borrowed; the owned tree carries every byte the commit will write. The
5430 // source is gated to base 0 above, so its stored addresses are absolute.
5431 let tree = Self::read_copy_subtree(
5432 &BytesSource::new(src_data),
5433 src_addr,
5434 0,
5435 true,
5436 BaseAddress::ZERO,
5437 )?;
5438 self.refuse_if_claimed(&dst)?;
5439 self.staged.cross_copies.push((dst, tree));
5440 Ok(())
5441 }
5442
5443 /// Apply all staged additions and deletions to the file in place and flush.
5444 ///
5445 /// Appends each new dataset (its data — a contiguous blob, or the chunk data
5446 /// and index for a chunked/filtered dataset — plus its object header) and
5447 /// each new group, then appends rewritten object headers for every touched
5448 /// group and its ancestors up to the root (omitting any deleted links), then
5449 /// repoints the superblock at the new root. On success the staged set is
5450 /// cleared and the session can be reused. On any [`Error::EditUnsupported`]
5451 /// the file on disk is left untouched: the checks that raise it — including
5452 /// each dataset's filter-pipeline and chunk-geometry validation — all run
5453 /// before the first byte is written. Should a later step fail mid-apply (an
5454 /// I/O error, or a residual build error), the superblock — repointed last —
5455 /// still names the prior root, so the file stays valid and the appended bytes
5456 /// are unreferenced slack.
5457 ///
5458 /// **A refused commit costs the session nothing it had staged.** The
5459 /// staged set is whole afterwards — so the batch can be committed again,
5460 /// and refuses again identically rather than applying the part of itself
5461 /// the refusal was not about — and the free regions the attempt drew from
5462 /// are given back, so an attempt that never became visible costs the
5463 /// session neither staged work nor reusable space (issue #316; see
5464 /// [`StagedEdits`] and [`FreeSnapshot`]).
5465 ///
5466 /// A failure *past* the first write is the other case, and it clears the
5467 /// staged set rather than restoring it: the file is valid but some of the
5468 /// batch is in it as slack, and a later `commit` must not re-issue the rest
5469 /// as if nothing had happened.
5470 ///
5471 /// **What the file holds is a separate promise: a commit that fails before
5472 /// its repoint leaves every dataset reading what it read before.** "Slack"
5473 /// describes all but one of the edits such a commit applies. The exception
5474 /// is a same-length value overwrite, which writes straight over the
5475 /// dataset's existing data block and so is live the moment it lands, with
5476 /// no repoint to withhold; the prior bytes are written back over every one
5477 /// of them on the way out (issue #344).
5478 ///
5479 /// Past the repoint the promise is the other one, and it has to be: the
5480 /// commit has published, so its overwrites are the file's values and are
5481 /// left standing. A failure there — `repoint_stored_references` is the step
5482 /// that can produce one — returns an error over a file that holds the whole
5483 /// batch, which is what
5484 /// `a_commit_that_fails_past_its_repoint_keeps_the_value_it_published`
5485 /// pins.
5486 ///
5487 /// # Errors
5488 ///
5489 /// Returns [`Error::CommitPartiallyApplied`] — carrying the write failure,
5490 /// not the refusal — when that restore itself failed, which is the one
5491 /// refusal after which a dataset the batch named may hold either value.
5492 /// Every other refusal leaves them all as they were.
5493 pub fn commit(&mut self) -> Result<(), Error> {
5494 let snapshot = self.snapshot_free();
5495 self.publish_attempted = false;
5496 // Cleared at entry, like `superseded_heaps` and for the same reason: a
5497 // previous attempt that unwound past this function — a panic caught by
5498 // the session lock, which recovers from poisoning — would otherwise
5499 // leave entries behind, and this commit's rollback would replay bytes
5500 // captured before it over whatever lives at those addresses now. The
5501 // clear on the way out is a separate duty: it stops a journal being
5502 // held across the idle time between commits.
5503 self.inplace_undo.clear();
5504 // The staged set comes out for the duration of the attempt and goes back
5505 // if the attempt refuses, so a refusal costs the session no staged work
5506 // (issue #316). What goes back is whatever the attempt did not take: its
5507 // preflight only reads, and its apply phase takes the whole set in one
5508 // move at the point of no return, so a preflight refusal restores every
5509 // edit and a failure past that point restores nothing.
5510 let mut staged = std::mem::take(&mut self.staged);
5511 let mut result = self.commit_inner(&mut staged);
5512 if result.is_err() && !self.publish_attempted {
5513 // The three legs of the rollback: the file's values, the free
5514 // lists, and the staged set. Nothing observes the order — the first
5515 // touches the file and the others only in-memory state, inside one
5516 // synchronous call — so it is written the way it reads: put the
5517 // file back, then re-offer what the attempt drew from (issue #344).
5518 let restored = self.undo_inplace_writes();
5519 self.restore_free(snapshot);
5520 self.staged = staged;
5521 // The set is back, so nothing this session staged has been consumed
5522 // and every handle onto one of those creations is still pending.
5523 if let Err(restore) = restored {
5524 // Both are worth carrying: the refusal is what the caller has to
5525 // fix, and the write failure is what proves the file changed.
5526 let refusal = result.expect_err("only a failed attempt is rolled back");
5527 result = Err(Error::CommitPartiallyApplied {
5528 refusal: Box::new(refusal),
5529 restore: Box::new(restore),
5530 });
5531 }
5532 } else {
5533 // The set is gone — published, or in the file as slack a later
5534 // commit must not re-issue — so a handle onto one of its creations
5535 // stops being pending and addresses the file from here on.
5536 self.staged_generation += 1;
5537 }
5538 // For the paths that took no rollback — a commit that succeeded, whose
5539 // overwrites are now the file's values, and one that failed with the
5540 // publish already issued, where undoing anything is unsound. A rollback
5541 // drained the journal on its way through, so it owes nothing here.
5542 self.inplace_undo.clear();
5543 result
5544 }
5545
5546 /// The free lists as they stand now, for [`commit`](Self::commit) to restore
5547 /// if its apply loop draws from them and then fails.
5548 fn snapshot_free(&self) -> FreeSnapshot {
5549 FreeSnapshot {
5550 free: self.free.clone(),
5551 reserved: self.reserved.clone(),
5552 paged: self
5553 .paged
5554 .as_ref()
5555 .map(|pg| (pg.meta.clone(), pg.raw.clone())),
5556 vl_overwrite_heaps: self.vl_overwrite_heaps.clone(),
5557 }
5558 }
5559
5560 /// Put the free lists back as [`snapshot_free`](Self::snapshot_free) found
5561 /// them. Called only for a commit that failed before publishing anything, so
5562 /// every region it restores is dead again.
5563 ///
5564 /// That rests on nothing the live tree reaches naming a span the attempt
5565 /// allocated, which takes two rules rather than one. Every object a commit
5566 /// *places* is unreachable until the superblock repoint; and the one write
5567 /// that does not wait for the repoint — a same-length value overwrite — is
5568 /// planned only over bytes that carry no allocated address, because
5569 /// [`prepare_write`](Self::prepare_write) relocates a staged
5570 /// variable-length overwrite instead of writing it in place, which is
5571 /// exactly the counterexample (issue #321).
5572 ///
5573 /// [`undo_inplace_writes`](Self::undo_inplace_writes) has nevertheless run
5574 /// by the time this does, so the file's values are back before its regions
5575 /// are offered again (issue #344).
5576 fn restore_free(&mut self, snapshot: FreeSnapshot) {
5577 self.vl_overwrite_heaps = snapshot.vl_overwrite_heaps;
5578 self.free = snapshot.free;
5579 self.reserved = snapshot.reserved;
5580 if let (Some(pg), Some((meta, raw))) = (self.paged.as_mut(), snapshot.paged) {
5581 pg.meta = meta;
5582 pg.raw = raw;
5583 }
5584 }
5585
5586 /// Overwrite `raw` at `at`, keeping the bytes it replaces so a commit that
5587 /// fails before its repoint can put them back.
5588 ///
5589 /// The read covers exactly the range about to be written, which the write
5590 /// preflight sized and bounds-checked against the on-disk dataset — a
5591 /// same-length overwrite by construction.
5592 fn write_inplace_journaled(&mut self, at: usize, raw: &[u8]) -> Result<(), Error> {
5593 let prior = self.image().read_exact_at(at as u64, raw.len())?;
5594 self.inplace_undo.push((at, prior));
5595 self.write_at(at, raw)
5596 }
5597
5598 /// Put back the values the refused commit's same-length in-place overwrites
5599 /// replaced, and drop the journal.
5600 ///
5601 /// The third leg of a refused commit's rollback, beside
5602 /// [`restore_free`](Self::restore_free) and the staged set: those two give
5603 /// back what the attempt *consumed*, this gives back what it *changed*. It
5604 /// is the only one of the three that has to touch the file, because a
5605 /// same-length overwrite is the only edit a commit writes over bytes the
5606 /// live root already reaches (issue #344).
5607 ///
5608 /// # Errors
5609 ///
5610 /// Every entry is attempted even after one fails, so the file is restored as
5611 /// far as it can be, and the first failure met is what comes back. That is the
5612 /// one outcome leaving values from a batch that was refused, which is why
5613 /// [`commit`](Self::commit) reports it as
5614 /// [`Error::CommitPartiallyApplied`] rather than as the refusal.
5615 fn undo_inplace_writes(&mut self) -> Result<(), Error> {
5616 let journal = core::mem::take(&mut self.inplace_undo);
5617 if journal.is_empty() {
5618 return Ok(());
5619 }
5620 let mut failed = None;
5621 // Newest first, and load-bearing rather than conventional. The commit
5622 // refuses to overwrite the same *path* twice, which is not the same as
5623 // the same dataset: an object reachable through two hard links has two
5624 // paths and one data block, and a same-length overwrite through either
5625 // is allowed precisely because it rewrites the block every link sees.
5626 // Two such writes in one commit therefore journal the same address
5627 // twice, the second entry holding what the first write put there.
5628 // Replaying oldest-first would finish on that value — one from the very
5629 // batch being rolled back.
5630 for (at, prior) in journal.into_iter().rev() {
5631 if let Err(e) = self.write_at(at, &prior) {
5632 failed.get_or_insert(e);
5633 }
5634 }
5635 match failed {
5636 Some(e) => Err(e),
5637 // A restore that is not a barrier can be gathered and reordered
5638 // past a later write, which is the whole hazard write gathering
5639 // introduced (issue #288); order it here instead. The data-only
5640 // barrier is the one that fits, because putting bytes back into
5641 // blocks that already existed moves no end-of-file — it is a
5642 // narrower barrier than the `barrier()` the commit itself uses
5643 // after these writes, deliberately.
5644 None => self.barrier_data(),
5645 }
5646 }
5647
5648 fn commit_inner(&mut self, staged: &mut StagedEdits) -> Result<(), Error> {
5649 if staged.is_empty() {
5650 return Ok(());
5651 }
5652
5653 // An attempt that failed partway may have recorded superseded collections
5654 // it never freed; this one must not free them on its behalf, since it is
5655 // not the commit that replaced what they hold.
5656 self.superseded_heaps.clear();
5657 // Drop every heap-collection provenance record this batch could falsify,
5658 // before any of them is read below.
5659 self.invalidate_heap_provenance(staged);
5660
5661 // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
5662 // space has no on-disk record of which pages hold metadata and which hold
5663 // raw data, so this commit could not keep the two segregated and would
5664 // silently degrade the paging. Refuse up front, before any writes, exactly
5665 // as the bounded backend does. A paged *persisting* file is committed
5666 // through the page-aware tail below (issue #198).
5667 if self.paged.is_some() && self.persist.is_none() {
5668 return Err(Error::EditUnsupported(
5669 "committing an edit to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
5670 persisted free space; recreate the file with \
5671 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to edit it in place",
5672 ));
5673 }
5674
5675 // Invalidate the in-place-append geometry cache before doing any work. A
5676 // commit that reaches here rewrites and relocates object headers, frees
5677 // vacated regions into `self.free`, and may truncate the file — any of
5678 // which can leave a cached `Located` pointing at a moved header or into a
5679 // now-free-eligible region. Clearing at *entry* (rather than the success
5680 // tail) means a later failure — including one after the durable root flip,
5681 // which leaves the session reusable — never strands a stale cache. The
5682 // no-op fast return above does no such work, so it keeps the cache. The
5683 // The next in-place append re-locates against the fresh file.
5684 self.located.clear();
5685 self.resolved.clear();
5686 // Past this point the commit may relocate object headers, so every address
5687 // a caller captured earlier is suspect; see `committed`.
5688 self.committed = true;
5689
5690 // On a file with a userblock, stored addresses are relative to this base
5691 // and the editor converts at every disk boundary (read `stored + base`,
5692 // write `file_offset - base`). Userblock support covers value overwrites,
5693 // additions of contiguous and chunked/filtered datasets, in-place and
5694 // relocating overwrites of every layout (chunked, contiguous, compact) with
5695 // reclaim, object deletion (with base-aware subtree reclaim), object copy
5696 // (in-file, and cross-file into a userblock destination), group creation,
5697 // and compact group attributes. Cross-file copy still requires a base-0
5698 // *source* (see [`copy_from`](Self::copy_from)).
5699 let base = self.superblock.base_address;
5700
5701 // --- Preflight value overwrites (`write_dataset`) before any write, under
5702 // the same all-or-nothing contract as additions. Each is resolved,
5703 // validated (datatype and shape must match the on-disk dataset exactly),
5704 // and classified: a same-length contiguous overwrite is applied straight
5705 // in place (no header rewrite, no superblock flip), while a resize or
5706 // compact rewrite relocates the header and is staged against its parent
5707 // group so the commit below rebuilds it and patches the link. ---
5708 let mut inplace_writes: Vec<(usize, OverwriteBytes)> = Vec::new();
5709 let mut moving_writes: Vec<(PathKey, String, u64, MovingWrite)> = Vec::new();
5710 let mut write_targets: Vec<PathKey> = Vec::new();
5711 // The file-wide hard-link count, computed lazily the first time a commit
5712 // relocates a header: such a write moves the object's header and patches
5713 // only the one parent link that names it, so an object reachable through
5714 // more than one hard link would have its other links left pointing at the
5715 // stale header. Refuse that rather than silently diverge the aliases (a
5716 // same-length in-place overwrite is unaffected — it rewrites the shared
5717 // data block, which every link sees).
5718 //
5719 // Read by four places, which is every way a header moves: a relocating
5720 // overwrite, a staged append, a dataset attribute edit, and — since
5721 // issue #327 — a rebuilt group, which had the same defect and none of
5722 // the guard.
5723 let mut incoming_links: Option<Option<HashMap<u64, u32>>> = None;
5724 for (full, fd) in &staged.writes {
5725 // A path named twice in one commit would write it twice (and double-
5726 // free a resized extent); require separate commits.
5727 if write_targets.contains(full) {
5728 return Err(Error::EditUnsupported(
5729 "the same dataset is overwritten twice in one commit; use separate commits",
5730 ));
5731 }
5732 let path_str = full.join("/");
5733 let addr = crate::group_v2::resolve_path_any_from_source(
5734 &self.image(),
5735 &self.superblock,
5736 &path_str,
5737 )
5738 .map_err(|_| Error::EditUnsupported("nothing to overwrite at the given path"))?;
5739 let addr = usize::try_from(addr)
5740 .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
5741 match Self::prepare_write(&self.image(), addr as u64, fd, base, full)? {
5742 WritePlan::InPlace { data_addr, bytes } => {
5743 inplace_writes.push((data_addr, bytes));
5744 }
5745 // A chunked in-place overwrite never carries staging — a staged
5746 // one is a `ChunkPayload::Deferred`, which relocates.
5747 WritePlan::InPlaceChunks { writes } => inplace_writes.extend(
5748 writes
5749 .into_iter()
5750 .map(|(at, raw)| (at, OverwriteBytes::ready(raw))),
5751 ),
5752 WritePlan::Moving(mw) => {
5753 // A relocating overwrite rewrites the dataset's header and data
5754 // address. Every variant is base-aware on a userblock file: the
5755 // chunked one rebuilds the chunk blob with stored addresses and
5756 // reclaims the old storage base-relative, the contiguous one
5757 // stores the relocated data address base-relative (and frees the
5758 // old extent at its absolute offset), and the compact one carries
5759 // its data inline. The parent link to the rewritten header is
5760 // patched base-relative below.
5761 //
5762 // A relocating overwrite is safe only when this is the
5763 // dataset's sole hard link. Compute the link graph once.
5764 let counts = incoming_links
5765 .get_or_insert_with(|| self.count_incoming_hard_links())
5766 .as_ref();
5767 match counts.and_then(|c| c.get(&(addr as u64))) {
5768 Some(&1) => {}
5769 _ => {
5770 return Err(Error::EditUnsupported(
5771 "overwriting a dataset that resizes or relocates its header is \
5772 only supported when it has a single hard link",
5773 ));
5774 }
5775 }
5776 let leaf = full.last().unwrap().clone();
5777 let parent = full[..full.len() - 1].to_vec();
5778 moving_writes.push((parent, leaf, addr as u64, mw));
5779 }
5780 }
5781 write_targets.push(full.clone());
5782 }
5783
5784 // --- Preflight appends (`append_dataset`) under the same all-or-nothing,
5785 // single-hard-link contract. Each plans a relocating append — existing
5786 // chunk data stays in place; the appended (and any rewritten trailing)
5787 // chunks and a rebuilt Extensible-Array index are staged, and the whole is
5788 // treated like a relocating overwrite of the dataset's header (staged
5789 // against its parent group so the commit patches the link). A zero-length
5790 // append is a no-op and is dropped here. ---
5791 for (full, ab) in &staged.appends {
5792 if full.is_empty() {
5793 return Err(Error::AppendUnsupported("cannot append to the root group"));
5794 }
5795 if ab.raw.is_empty() {
5796 continue; // nothing to append
5797 }
5798 // A dataset overwritten or appended earlier in this commit would be
5799 // planned against a stale header and its old storage double-freed;
5800 // require separate commits.
5801 if write_targets.contains(full) {
5802 return Err(Error::AppendUnsupported(
5803 "the same dataset is edited more than once in one commit; use separate commits",
5804 ));
5805 }
5806 let path_str = full.join("/");
5807 let addr = crate::group_v2::resolve_path_any_from_source(
5808 &self.image(),
5809 &self.superblock,
5810 &path_str,
5811 )
5812 .map_err(|_| Error::AppendUnsupported("nothing to append to at the given path"))?;
5813 let addr = usize::try_from(addr)
5814 .map_err(|_| Error::AppendUnsupported("dataset address exceeds this platform"))?;
5815 let mw = Self::prepare_append(&self.image(), addr as u64, ab, base)?;
5816 // A relocating append moves the dataset's object header and patches only
5817 // the one parent link that names it, so it is safe only when this is the
5818 // dataset's sole hard link (same rule as a relocating overwrite).
5819 let counts = incoming_links
5820 .get_or_insert_with(|| self.count_incoming_hard_links())
5821 .as_ref();
5822 match counts.and_then(|c| c.get(&(addr as u64))) {
5823 Some(&1) => {}
5824 _ => {
5825 return Err(Error::AppendUnsupported(
5826 "appending relocates the dataset header; only supported when it \
5827 has a single hard link",
5828 ));
5829 }
5830 }
5831 let leaf = full.last().unwrap().clone();
5832 let parent = full[..full.len() - 1].to_vec();
5833 moving_writes.push((parent, leaf, addr as u64, mw));
5834 write_targets.push(full.clone());
5835 }
5836
5837 // --- Preflight dataset attribute edits (`set_dataset_attr` /
5838 // `remove_dataset_attr`) under the same all-or-nothing, single-hard-link
5839 // contract. Each gathers the dataset's verbatim object-header region,
5840 // applies the compact attribute ops to it, and stages a relocating
5841 // `AttrEdit` header rewrite against the parent group — like a value
5842 // overwrite, but the data-layout message (and thus the chunk data and index)
5843 // is preserved verbatim, so only the header moves. ---
5844 if !staged.dataset_attrs.is_empty() {
5845 // Collect the ops per dataset in first-seen path order, so multiple edits
5846 // to one dataset produce a single relocating header rewrite.
5847 let mut order: Vec<PathKey> = Vec::new();
5848 let mut ops_by_path: HashMap<&PathKey, Vec<&AttrOp>> = HashMap::new();
5849 for (path, op) in &staged.dataset_attrs {
5850 if !ops_by_path.contains_key(path) {
5851 order.push(path.clone());
5852 }
5853 ops_by_path.entry(path).or_default().push(op);
5854 }
5855 for full in order {
5856 let ops = ops_by_path.remove(&full).unwrap();
5857 if full.is_empty() {
5858 return Err(Error::EditUnsupported(
5859 "cannot set a dataset attribute on the root group; use set_group_attr",
5860 ));
5861 }
5862 // A dataset already overwritten or appended in this commit would be
5863 // planned against a stale header; require separate commits.
5864 if write_targets.contains(&full) {
5865 return Err(Error::EditUnsupported(
5866 "the same dataset is edited more than once in one commit (an attribute \
5867 edit plus another edit); use separate commits",
5868 ));
5869 }
5870 let path_str = full.join("/");
5871 let addr = crate::group_v2::resolve_path_any_from_source(
5872 &self.image(),
5873 &self.superblock,
5874 &path_str,
5875 )
5876 .map_err(|_| {
5877 Error::EditUnsupported("nothing to set an attribute on at the given path")
5878 })?;
5879 let addr = usize::try_from(addr)
5880 .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
5881 // An attribute edit relocates the dataset's object header and patches
5882 // only the one naming link, so it is safe only when this is the
5883 // dataset's sole hard link (same rule as a relocating overwrite).
5884 let counts = incoming_links
5885 .get_or_insert_with(|| self.count_incoming_hard_links())
5886 .as_ref();
5887 match counts.and_then(|c| c.get(&(addr as u64))) {
5888 Some(&1) => {}
5889 _ => {
5890 return Err(Error::EditUnsupported(
5891 "editing a dataset attribute relocates its header; only supported \
5892 when it has a single hard link",
5893 ));
5894 }
5895 }
5896 let region = Self::gather_oh_messages(&self.image(), addr as u64, base)?;
5897 let edits = plan_attr_ops(&self.image(), base, Some(addr as u64), ®ion, &ops)?;
5898 let leaf = full.last().unwrap().clone();
5899 let parent = full[..full.len() - 1].to_vec();
5900 moving_writes.push((
5901 parent,
5902 leaf,
5903 addr as u64,
5904 MovingWrite::AttrEdit {
5905 region: edits.region,
5906 attrs: edits.attrs,
5907 },
5908 ));
5909 write_targets.push(full);
5910 }
5911 }
5912
5913 // Pre-commit object-header addresses this commit rewrites elsewhere, for
5914 // `InvalidatedAddresses` below: a reference naming one of these keeps an
5915 // address the commit is vacating (issue #317). Read off the one list every
5916 // relocating write is staged on, so a write added later cannot be left out
5917 // of the screen without failing to compile.
5918 let mut moved_headers: Vec<u64> =
5919 moving_writes.iter().map(|&(_, _, addr, _)| addr).collect();
5920
5921 // Fast path: when the only staged edits are same-length in-place
5922 // overwrites, apply them straight to their data blocks and return without
5923 // rebuilding any header or flipping the superblock root. The commit's
5924 // linearization point is the synced data write — there is no tree to
5925 // repoint, so each overwrite stands alone. (A persisting file takes the
5926 // same path: no free-space change occurs.)
5927 //
5928 // Because this path never rewrites the superblock, it deliberately leaves
5929 // it untouched — including a pre-existing stale consistency flag (e.g. one
5930 // left by a crashed SWMR writer). A lone same-length value overwrite does
5931 // not introduce any inconsistency, so it does not clear one either; an edit
5932 // that takes the full path below (any header/root change) clears the flag
5933 // as usual.
5934 //
5935 // A staged variable-length overwrite cannot reach here at all: it
5936 // relocates, so `moving_writes` is non-empty for it. That is enforced
5937 // where the plan is chosen (`prepare_write`) rather than re-tested here,
5938 // and it has to hold for a second reason besides the one that made it
5939 // relocate — such an overwrite places a global heap collection, and an
5940 // append moves end-of-file, which only the superblock this path leaves
5941 // untouched records (issue #321).
5942 debug_assert!(
5943 inplace_writes.iter().all(|(_, b)| b.vlen.is_none()),
5944 "a staged variable-length overwrite must relocate, not write in place"
5945 );
5946 if moving_writes.is_empty()
5947 && staged.datasets.is_empty()
5948 && staged.groups.is_empty()
5949 && staged.group_attrs.is_empty()
5950 && staged.deletes.is_empty()
5951 && staged.copies.is_empty()
5952 && staged.cross_copies.is_empty()
5953 {
5954 // This path's own point of no return, and it takes the staged set
5955 // for the same reason the main one below does (issue #316): every
5956 // refusal that applies to these overwrites has already run, and a
5957 // batch some of which has been written is not one a later `commit`
5958 // may re-issue. It is also what this path already did, back when the
5959 // write preflight drained the staged set on its way here.
5960 //
5961 // Leaving the set in place would make a half-written batch
5962 // retryable — these overwrites are same-length writes to fixed
5963 // addresses, so repeating them is idempotent — but that reads the
5964 // rule the other way round for one path, on an argument nothing
5965 // enforces. What the file holds is settled separately and the same
5966 // way on both paths: a `write_at` that fails partway is undone
5967 // through the journal below, so the batch is dropped but the
5968 // dataset's values are the ones it had (issue #344).
5969 drop(std::mem::take(staged));
5970 for (data_addr, bytes) in &inplace_writes {
5971 // Every entry here stages nothing (the guard above), so this
5972 // hands back the staged bytes unchanged and unallocated.
5973 let raw = self.resolve_overwrite_bytes(bytes)?;
5974 self.write_inplace_journaled(*data_addr, &raw)?;
5975 }
5976 self.barrier()?;
5977 return Ok(());
5978 }
5979
5980 // --- Plan: build the tree of "dirty" groups (root plus every group on a
5981 // path to an addition or deletion), validating every target before any
5982 // write. `add_targets` records the full paths created this commit, used
5983 // to reject a deletion that overlaps an addition. ---
5984 let mut nodes: BTreeMap<PathKey, Node> = BTreeMap::new();
5985 nodes.entry(PathKey::new()).or_default(); // root is always dirty
5986 let mut add_targets: Vec<PathKey> = Vec::new();
5987 // Where each in-file `copy` reads from. A copy takes its bytes from the
5988 // *pre-commit* file, so a source this same commit replaces would copy the
5989 // object being removed while the replacement lands at the same path — see
5990 // the delete-staging loop, which refuses that. Cross-file copies are not
5991 // tracked: their source is in another file, so no path here can name it.
5992 let mut copy_sources: Vec<PathKey> = Vec::new();
5993 let mut attr_targets: Vec<PathKey> = Vec::new();
5994
5995 // Mark explicitly-created new groups, ensuring their ancestor chain.
5996 for path in &staged.groups {
5997 if path.is_empty() {
5998 return Err(Error::EditUnsupported("cannot create the root group"));
5999 }
6000 ensure_ancestors(&mut nodes, path);
6001 nodes.entry(path.clone()).or_default().is_new = true;
6002 add_targets.push(path.clone());
6003 }
6004
6005 // Make each staged dataset's parent group a node (with its ancestor
6006 // chain). The datasets themselves stay in the staged set, which the
6007 // preflight reads but must not empty (issue #316); `datasets_by_group`
6008 // below is the grouped view both the preflight and the apply loop use.
6009 for (parent, fd) in &staged.datasets {
6010 let mut full = parent.clone();
6011 full.push(fd.name.clone());
6012 add_targets.push(full);
6013 ensure_ancestors(&mut nodes, parent);
6014 }
6015
6016 // Attach relocating value overwrites (resized contiguous or compact) to
6017 // their parent group nodes: the new header is written below and the
6018 // parent's existing link patched to it, like an existing child group.
6019 for (parent, leaf, old_oh, mw) in moving_writes {
6020 ensure_ancestors(&mut nodes, &parent);
6021 nodes
6022 .entry(parent)
6023 .or_default()
6024 .writes
6025 .push((leaf, old_oh, mw));
6026 }
6027
6028 // Stage group attribute edits against their target groups. A target may
6029 // be a newly-created group from this same commit, but not a copied
6030 // destination or a dataset being added in the same commit. The ops
6031 // themselves stay in the staged set and are looked up by path where they
6032 // are applied, so a refusal below still gives them back (issue #316).
6033 for (path, _) in &staged.group_attrs {
6034 ensure_ancestors(&mut nodes, path);
6035 attr_targets.push(path.clone());
6036 }
6037
6038 // Stage copies: validate the source subtree is copyable (read-only),
6039 // then treat the destination like an addition to its parent group.
6040 for (src, dst) in &staged.copies {
6041 if src.is_empty() {
6042 return Err(Error::EditUnsupported("cannot copy the root group"));
6043 }
6044 if dst.is_empty() {
6045 return Err(Error::EditUnsupported("copy destination path is empty"));
6046 }
6047 if is_prefix(src, dst) {
6048 return Err(Error::EditUnsupported(
6049 "cannot copy an object into itself or its own subtree",
6050 ));
6051 }
6052 let src_str = src.join("/");
6053 let src_addr = crate::group_v2::resolve_path_any_from_source(
6054 &self.image(),
6055 &self.superblock,
6056 &src_str,
6057 )
6058 .map_err(|_| Error::EditUnsupported("copy source does not exist"))?;
6059 let src_addr = usize::try_from(src_addr)
6060 .map_err(|_| Error::EditUnsupported("source address exceeds this platform"))?;
6061 // Read the source subtree from this file's own mirror (`cross_file`
6062 // false: same address space, so verbatim addresses stay valid). On a
6063 // userblock file the stored addresses are base-relative, so pass this
6064 // session's base for the read to absolutize them.
6065 let tree = Self::read_copy_subtree(&self.image(), src_addr as u64, 0, false, base)?;
6066 copy_sources.push(src.clone());
6067 add_targets.push(dst.clone());
6068 let leaf = dst.last().unwrap().clone();
6069 let parent = dst[..dst.len() - 1].to_vec();
6070 ensure_ancestors(&mut nodes, &parent);
6071 nodes.entry(parent).or_default().copies.push((leaf, tree));
6072 }
6073
6074 // Stage cross-file copies: their subtrees were already read out of the
6075 // source file (with foreign-address screening) when `copy_from` was
6076 // called, so here they are simply linked into the destination parent like
6077 // any other addition.
6078 for (dst, _) in &staged.cross_copies {
6079 if dst.is_empty() {
6080 return Err(Error::EditUnsupported("copy destination path is empty"));
6081 }
6082 add_targets.push(dst.clone());
6083 let leaf = dst.last().unwrap().clone();
6084 let parent = dst[..dst.len() - 1].to_vec();
6085 ensure_ancestors(&mut nodes, &parent);
6086 nodes.entry(parent).or_default().cross_copies.push(leaf);
6087 }
6088
6089 // Stage deletions: each must exist, must not overlap any other staged
6090 // change *unless* this commit replaces what it removes, and is recorded
6091 // against its parent group (which becomes dirty). `deleted_addrs` keeps
6092 // each removed object's header address so its owned blocks can be
6093 // reclaimed after the commit lands (issue #21).
6094 let delete_targets = &staged.deletes;
6095 let mut deleted_addrs: Vec<usize> = Vec::new();
6096 for (i, d) in delete_targets.iter().enumerate() {
6097 if d.is_empty() {
6098 return Err(Error::EditUnsupported("cannot delete the root group"));
6099 }
6100 let path_str = d.join("/");
6101 let del_addr = crate::group_v2::resolve_path_any_from_source(
6102 &self.image(),
6103 &self.superblock,
6104 &path_str,
6105 )
6106 .map_err(|_| Error::EditUnsupported("nothing to delete at the given path"))?;
6107 if let Ok(a) = usize::try_from(del_addr) {
6108 deleted_addrs.push(a);
6109 }
6110 // A deletion may overlap other staged work when this commit
6111 // *replaces* what it removes: an addition names exactly `d`, so the
6112 // removal and the new object at the same path are one rotation
6113 // rather than an edit of something being deleted (issue #305).
6114 let recreated = add_targets.iter().any(|t| t == d);
6115 // A replacement also requires that every group node at or below `d`
6116 // is one this commit builds fresh. A node that is *not* new is
6117 // rebuilt from the old object's on-disk header — the object being
6118 // replaced — and both ways that lands are wrong, in the two shapes
6119 // this guard was measured against:
6120 //
6121 // * At `d` itself (a group attribute set on a path this commit
6122 // replaces with a *dataset*), the node and the replacement share a
6123 // `path_addr` key. The replacement's address overwrites the group's,
6124 // the parent's link is then patched to the address it already had,
6125 // and the rebuilt group header is left orphaned — a commit that
6126 // returns `Ok` having **silently discarded** the staged attribute.
6127 // * Strictly below `d`, the parent is a freshly built region with no
6128 // existing link to patch, so `patch_link_target` reports a missing
6129 // child link partway through the apply. That leaves a valid file
6130 // (the superblock is never repointed) but appends dead bytes and
6131 // reports the wrong thing.
6132 //
6133 // Refusing here keeps both in the preflight, where the commit is
6134 // all-or-nothing and the message can name the actual conflict.
6135 if recreated
6136 && !nodes
6137 .iter()
6138 .all(|(key, node)| !is_prefix(d, key) || node.is_new)
6139 {
6140 return Err(Error::EditUnsupported(
6141 "a staged edit names a group at or under a replaced path that this \
6142 commit does not itself create; create it in the same commit, or use \
6143 separate commits",
6144 ));
6145 }
6146 // A copy reading from a path this commit replaces is the same conflict
6147 // seen from the other side: `read_copy_subtree` already took its bytes
6148 // from the pre-commit file, so the commit would place the *original*
6149 // at the copy's destination while placing something else at `d` — two
6150 // different objects from one path, in one commit, with no error. A
6151 // source that is merely deleted and not replaced is unambiguous (it is
6152 // a move) and stays allowed.
6153 if recreated {
6154 for t in ©_sources {
6155 if is_prefix(d, t) {
6156 return Err(Error::EditUnsupported(
6157 "a copy in this commit reads from a path the same commit \
6158 replaces; use separate commits",
6159 ));
6160 }
6161 }
6162 }
6163 // Past that return `recreated` means the whole touched subtree is
6164 // fresh, so an addition at or below `d` lands in the replacement. The
6165 // apply loop already removes a group's deleted links before appending
6166 // any new one (`remove_link_from_region` runs first), so a
6167 // replacement needs no further sequencing here.
6168 for t in &add_targets {
6169 if recreated && is_prefix(d, t) {
6170 continue;
6171 }
6172 if is_prefix(d, t) || is_prefix(t, d) {
6173 return Err(Error::EditUnsupported(
6174 "a deletion overlaps an addition in the same commit; \
6175 replace the path instead, or use separate commits",
6176 ));
6177 }
6178 }
6179 for t in &attr_targets {
6180 if recreated && is_prefix(d, t) {
6181 continue;
6182 }
6183 if is_prefix(d, t) {
6184 return Err(Error::EditUnsupported(
6185 "a deletion overlaps a group-attribute edit in the same commit; use separate commits",
6186 ));
6187 }
6188 }
6189 for t in &write_targets {
6190 if is_prefix(d, t) {
6191 // `write_targets` holds three kinds — a value overwrite, a
6192 // dataset-attribute edit, and a staged append — so the
6193 // message names what they have in common rather than only
6194 // the first of them.
6195 return Err(Error::EditUnsupported(
6196 "a deletion overlaps a staged edit to a dataset in the same \
6197 commit; use separate commits",
6198 ));
6199 }
6200 }
6201 for (j, d2) in delete_targets.iter().enumerate() {
6202 if i != j && is_prefix(d, d2) {
6203 return Err(Error::EditUnsupported(
6204 "overlapping deletions in one commit; delete the common parent only",
6205 ));
6206 }
6207 }
6208 let parent = d[..d.len() - 1].to_vec();
6209 ensure_ancestors(&mut nodes, &parent);
6210 nodes
6211 .entry(parent)
6212 .or_default()
6213 .deletes
6214 .push(d.last().unwrap().clone());
6215 }
6216
6217 // Resolve / validate each node's base object-header region up front.
6218 // Every existing dirty group is rewritten to a freshly-appended header,
6219 // so its old header becomes dead bytes once the superblock is repointed;
6220 // `superseded_addrs` records those old headers for reclamation (#21),
6221 // paired with the path whose rebuilt header replaces each — the two
6222 // halves of one relocation, which is what an object reference stored
6223 // elsewhere in the file has to be repointed across (issue #324).
6224 let keys: Vec<PathKey> = nodes.keys().cloned().collect();
6225 let mut superseded_addrs: Vec<(PathKey, usize)> = Vec::new();
6226 for key in &keys {
6227 let is_new = nodes[key].is_new;
6228 if is_new {
6229 nodes.get_mut(key).unwrap().base_region = fresh_group_region();
6230 } else {
6231 let path_str = key.join("/");
6232 let addr = crate::group_v2::resolve_path_any_from_source(
6233 &self.image(),
6234 &self.superblock,
6235 &path_str,
6236 )
6237 .map_err(|_| {
6238 Error::EditUnsupported(
6239 "a target group does not exist; create it first in this session",
6240 )
6241 })?;
6242 let addr = usize::try_from(addr)
6243 .map_err(|_| Error::EditUnsupported("group address exceeds this platform"))?;
6244 // Rebuilding this group moves its header and patches only the
6245 // link this commit resolved it through, so every other hard link
6246 // to it would be left naming the old header — which this commit
6247 // then frees. The aliases show the pre-commit group until
6248 // something reuses the span, and are unreadable after (issue
6249 // #327). Same rule, and the same lazily-computed count, as the
6250 // three relocating dataset writes above.
6251 //
6252 // The root group is exempt because it is named by the superblock
6253 // rather than by a link, so it has no entry in the count and
6254 // nothing to strand. Every other group was resolved by path and
6255 // therefore has at least one.
6256 if !key.is_empty() {
6257 let counts = incoming_links
6258 .get_or_insert_with(|| self.count_incoming_hard_links())
6259 .as_ref();
6260 match counts.and_then(|c| c.get(&(addr as u64)).copied()) {
6261 Some(1) => {}
6262 // Known, and more than one: the aliases are the problem.
6263 Some(_) => {
6264 return Err(Error::EditUnsupported(
6265 "editing a group relocates its object header; only supported \
6266 when it has a single hard link",
6267 ));
6268 }
6269 // Not known: the file-wide walk that counts links gave up
6270 // — an object header it could not parse, a group it could
6271 // not enumerate, or a link graph past its bound. A
6272 // different refusal because it is a different problem, and
6273 // "it has more than one hard link" would send the reader
6274 // looking for a second link that may not exist.
6275 None => {
6276 return Err(Error::EditUnsupported(
6277 "editing a group relocates its object header, and this file's \
6278 links could not be walked to establish that nothing else \
6279 names it",
6280 ));
6281 }
6282 }
6283 }
6284 let info = self.inspect_group(addr)?;
6285 superseded_addrs.push((key.clone(), addr));
6286 let node = nodes.get_mut(key).unwrap();
6287 node.base_region = info.region;
6288 node.existing_links = info.link_names;
6289 }
6290 }
6291
6292 // A rebuilt group's old header is vacated just as a relocated dataset's is,
6293 // so the two join one list for the screen below.
6294 moved_headers.extend(superseded_addrs.iter().map(|&(_, a)| a as u64));
6295
6296 // Apply and validate group attribute edits before any writes. This keeps
6297 // unsupported attribute edits under the same all-or-nothing preflight
6298 // contract as unsupported dataset additions. A variable-length attribute
6299 // is not fully resolved here — its global heap collection is built (it
6300 // is self-contained, no address needed yet) but placed and patched into
6301 // `base_region` only in the apply loop below, once its address is known.
6302 let attrs_by_group = group_by_parent(staged.group_attrs.iter().map(|(p, op)| (p, op)));
6303 // The pre-commit header address of each group this commit rewrites. An
6304 // edit that sends a group's attributes to a fractal heap reads the set it
6305 // rebuilds back out of the file, which needs the address; a group this
6306 // commit *creates* has neither an address nor any stored attribute, and
6307 // its `None` says so.
6308 let existing_group_addrs: HashMap<PathKey, u64> = superseded_addrs
6309 .iter()
6310 .map(|(key, addr)| (key.clone(), *addr as u64))
6311 .collect();
6312 for key in &keys {
6313 if let Some(ops) = attrs_by_group.get(key) {
6314 let region = std::mem::take(&mut nodes.get_mut(key).unwrap().base_region);
6315 let edits = plan_attr_ops(
6316 &self.image(),
6317 base,
6318 existing_group_addrs.get(key).copied(),
6319 ®ion,
6320 ops,
6321 )?;
6322 let node = nodes.get_mut(key).unwrap();
6323 node.base_region = edits.region;
6324 node.attrs = edits.attrs;
6325 }
6326 }
6327
6328 // Map each node to its direct child group nodes (for link wiring).
6329 let mut children: BTreeMap<PathKey, Vec<PathKey>> = BTreeMap::new();
6330 for key in &keys {
6331 if !key.is_empty() {
6332 let parent = key[..key.len() - 1].to_vec();
6333 children.entry(parent).or_default().push(key.clone());
6334 }
6335 }
6336
6337 // Each group's added datasets, in the order the apply loop will place
6338 // them, for the guards below to read. Borrowed from the staged set:
6339 // every one of those guards may still refuse, and a refusal gives the
6340 // caller back every edit it was holding (issue #316). The apply loop
6341 // rebuilds the same grouping, by the same rule, once it owns the set.
6342 let datasets_by_group = group_by_parent(staged.datasets.iter().map(|(p, fd)| (p, fd)));
6343
6344 // A group that tracks *link* creation order takes an addition — the
6345 // apply loop numbers each added link from the running maximum its Link
6346 // Info message records — up to the point the addition would send its
6347 // links dense, which is refused before anything is written. The count
6348 // this screens is the one the group would hold afterwards: the links it
6349 // has now, less the ones this commit deletes, plus the ones it adds.
6350 for key in &keys {
6351 let node = &nodes[key];
6352 let added = node.copies.len()
6353 + node.cross_copies.len()
6354 + datasets_by_group.get(key).map_or(0, Vec::len)
6355 + children.get(key).map_or(0, |kids| {
6356 kids.iter().filter(|child| nodes[*child].is_new).count()
6357 });
6358 if added > 0 {
6359 let kept = node
6360 .existing_links
6361 .iter()
6362 .filter(|name| !node.deletes.contains(name))
6363 .count();
6364 reject_dense_link_creation_order(&node.base_region, kept + added)?;
6365 }
6366 }
6367
6368 // Validate names: no addition may collide with an existing link or with
6369 // another addition under the same parent. A link this same commit
6370 // deletes is not one of the existing ones — the apply loop removes it
6371 // from the region before any addition is appended, so replacing an
6372 // object at its own path is a rotation, not a collision (issue #305).
6373 for key in &keys {
6374 let node = &nodes[key];
6375 let mut adding: Vec<&str> = Vec::new();
6376 for fd in datasets_by_group.get(key).into_iter().flatten() {
6377 adding.push(&fd.name);
6378 }
6379 for child in children.get(key).into_iter().flatten() {
6380 if nodes[child].is_new {
6381 adding.push(child.last().unwrap());
6382 }
6383 }
6384 for (leaf, _) in &node.copies {
6385 adding.push(leaf);
6386 }
6387 for leaf in &node.cross_copies {
6388 adding.push(leaf);
6389 }
6390 for (i, name) in adding.iter().enumerate() {
6391 let survives = node.existing_links.iter().any(|n| n == name)
6392 && !node.deletes.iter().any(|n| n == name);
6393 if survives || adding[..i].contains(name) {
6394 return Err(Error::EditUnsupported(
6395 "a link with this name already exists in the target group",
6396 ));
6397 }
6398 }
6399 }
6400
6401 // Content the caller's library-version bound cannot carry is refused
6402 // here, before any write, so a rejected addition leaves the commit
6403 // unapplied.
6404 self.check_libver_admits(staged.datasets.iter().map(|(_, fd)| fd))?;
6405
6406 // Enumerate what this commit's deletions reclaim, from the current
6407 // on-disk layout and before any byte moves. It is read here, ahead of
6408 // every remaining refusal, because two of them screen against it: an
6409 // object reference this commit writes must not name space the same
6410 // commit frees (issue #317). The spans are carried to `to_free` below
6411 // rather than walked a second time, so the screen and the allocator
6412 // always mean the same thing by "removed".
6413 //
6414 // An object's storage is reclaimed only when the link being removed is
6415 // its LAST hard link: HDF5 objects can have several hard links, and one
6416 // reachable through a surviving link is still live (freeing it would
6417 // corrupt the survivor). Count every hard link in the pre-commit file
6418 // and reclaim a deleted object only when its count is exactly 1.
6419 // `deleted_addrs` is de-duplicated first so two delete paths that are
6420 // hard links to the same object are not visited (and freed) twice. If
6421 // the link graph cannot be walked in full, no deleted object is
6422 // reclaimed (a safe leak) — and none is screened either, which is sound
6423 // in the same direction: nothing is freed, so no reference dangles.
6424 // Superseded group headers and relocated dataset headers are vacated too,
6425 // and are screened separately: `moved_headers` holds them, and they are
6426 // addresses rather than spans.
6427 let mut deleted_free: Vec<(u64, u64, FreeClass)> = Vec::new();
6428 deleted_addrs.sort_unstable();
6429 deleted_addrs.dedup();
6430 if !deleted_addrs.is_empty() {
6431 if let Some(incoming) = self.count_incoming_hard_links() {
6432 for &a in &deleted_addrs {
6433 self.collect_free_spans(a, 0, &incoming, &mut deleted_free);
6434 }
6435 }
6436 }
6437 // An in-file copy re-emits its source's element bytes verbatim, so a
6438 // copied object reference keeps naming whatever it named in the source —
6439 // including an object this same commit is removing (issue #317). The page
6440 // types the walk records are for the allocator, not for this.
6441 //
6442 // Removals only, and this is the one place the two halves part company. A
6443 // *supplied* address is a fresh claim about the file, and a commit that
6444 // moves the object falsifies it. A *copied* one repeats a claim the file
6445 // already made, and since issue #324 a move no longer falsifies either
6446 // one: the commit's last act walks the tree it published and repoints
6447 // every reachable stored address, the copy's among them
6448 // ([`crate::reference_patch`], and `a_copy_made_in_the_same_commit_that_
6449 // moves_its_target_is_repointed`). A removal is the case that stays,
6450 // because there is no new address to point at.
6451 //
6452 // Refusing on `moved` here would also cost what it cannot buy: every
6453 // commit that reaches here rebuilds its root group, so `moved` is never
6454 // empty, and a chunked object-reference dataset is refused on a non-empty
6455 // screen alone rather than on a matching address — so every such copy
6456 // would be refused, in every commit.
6457 let for_copied = InvalidatedAddresses {
6458 removed: deleted_free
6459 .iter()
6460 .map(|&(off, len, _)| (off, len))
6461 .collect(),
6462 moved: Vec::new(),
6463 base: self.superblock.base_address,
6464 };
6465 {
6466 // Framed at the base address: a shared-message address is stored
6467 // relative to it, and `SourceResolver` reads its references as
6468 // absolute within the view it is given.
6469 let image = self.image();
6470 let framed = BaseOffsetSource {
6471 inner: image,
6472 base: self.superblock.base_address,
6473 };
6474 for key in &keys {
6475 for (_, tree) in &nodes[key].copies {
6476 screen_copied_references(tree, &for_copied, &framed)?;
6477 }
6478 }
6479 }
6480
6481 // The same screen the copies just got, plus the half a supplied address
6482 // earns and a copied one does not.
6483 let for_supplied = InvalidatedAddresses {
6484 moved: moved_headers,
6485 ..for_copied
6486 };
6487
6488 // A shared-message index record can hold an object-header address too,
6489 // and it is the one stored address the commit's closing repoint walk
6490 // cannot reach. Screen it here, against the same two lists.
6491 self.screen_shared_message_index(&for_supplied)?;
6492
6493 // References a builder already resolved to addresses never reach
6494 // `resolve_reference_target`, so they are screened out of the element
6495 // bytes instead — additions and value overwrites alike, since neither
6496 // form's bytes are touched again before they are written. A slot still
6497 // holding a placeholder screens as the zero it is, and is screened for
6498 // real by `resolve_reference_target` when it resolves.
6499 //
6500 // Cross-file copies need no screen for the opposite reason:
6501 // `reject_foreign_addresses` refuses a reference datatype outright on
6502 // that path.
6503 for (_, fd) in staged.datasets.iter().chain(&staged.writes) {
6504 screen_resolved_references(&fd.dt, &fd.raw, &for_supplied)?;
6505 }
6506
6507 // Anything this commit adds could be a reference container the last walk
6508 // did not see, so it retires that walk's "no references in this file"
6509 // finding (issue #324). See `proved_free_of_references` for which of
6510 // these four clauses is load-bearing and which are belt-and-braces, and
6511 // for why the other five staged collections need no clause at all.
6512 //
6513 // A staged dataset's *attributes* are screened beside its element
6514 // datatype. Neither can carry a reference today — a committed one is
6515 // refused by `flatten_dataset`, and `AttrValue` has no reference variant
6516 // — but that is a refusal in another function rather than a property of
6517 // this collection, and the check that means what it says is the one that
6518 // looks.
6519 if staged.datasets.iter().chain(&staged.writes).any(|(_, fd)| {
6520 datatype_holds_object_address(&fd.dt)
6521 || fd
6522 .attrs
6523 .iter()
6524 .any(|a| datatype_holds_object_address(&a.datatype))
6525 }) || !staged.copies.is_empty()
6526 || !staged.cross_copies.is_empty()
6527 {
6528 self.proved_free_of_references = false;
6529 }
6530
6531 // Prove every object-reference target resolves before any write (see
6532 // `preflight_reference_targets`'s doc comment): otherwise a reference
6533 // resolution failure discovered mid-apply-loop would leave every
6534 // earlier-processed group's real writes (headers, data, copied
6535 // subtrees) orphaned in the file despite `commit()` returning `Err`.
6536 Self::preflight_reference_targets(
6537 &keys,
6538 &datasets_by_group,
6539 &nodes,
6540 &add_targets,
6541 &write_targets,
6542 delete_targets,
6543 &for_supplied,
6544 &self.image(),
6545 &self.superblock,
6546 )?;
6547
6548 // --- The point of no return. Every refusal is behind us, so the staged
6549 // set is taken here rather than at the top of this function: a refusal
6550 // above restores *every* edit the caller staged, and a failure below —
6551 // an I/O error mid-apply, which leaves the file valid because the
6552 // superblock is repointed last — restores none of them, so no later
6553 // `commit` can finish a batch this one abandoned (issue #316). Nothing
6554 // above this line may consume from `staged`. ---
6555 let mut taken = std::mem::take(staged);
6556 // The same paths as the borrow above, owned now that the set has moved.
6557 let delete_targets = std::mem::take(&mut taken.deletes);
6558 // The same grouping as `datasets_by_group` above, by the same rule and
6559 // so in the same order — which is what keeps the apply loop's placement
6560 // order the one `preflight_reference_targets` proved.
6561 let mut flat = group_by_parent(taken.datasets.drain(..));
6562 // A cross-file copy's subtree can move now, so it joins its destination
6563 // group's in-file copies. The parent node exists: the preflight made one
6564 // for every destination.
6565 for (dst, tree) in taken.cross_copies.drain(..) {
6566 let leaf = dst.last().unwrap().clone();
6567 let parent = dst[..dst.len() - 1].to_vec();
6568 nodes.get_mut(&parent).unwrap().copies.push((leaf, tree));
6569 }
6570
6571 // Gather the regions this commit will vacate, read from the current
6572 // on-disk layout before any byte moves: every deleted object's owned
6573 // blocks plus every superseded group header. They stay out of the free
6574 // list until every append this commit makes is behind it, so none of
6575 // them is reused while it is still live. (On the non-persisting tail
6576 // that point is a few lines *before* the superblock write rather than
6577 // after it — the apply loop is where the appends happen, and it is long
6578 // done by then.) Enumeration is
6579 // best-effort — `collect_free_spans` simply omits anything it cannot
6580 // account for exhaustively, so the worst case is unreclaimed dead bytes,
6581 // never a freed-but-live region.
6582 // It starts as the deleted objects' blocks, enumerated above the preflight
6583 // because a refusal there screens against them.
6584 let mut to_free: Vec<(u64, u64, FreeClass)> = deleted_free;
6585
6586 // A superseded group header is dead once the root is repointed. Its chunk
6587 // spans are enumerated base-aware (`oh_chunk_spans` shifts continuation
6588 // addresses by the userblock base and returns absolute file offsets), as is
6589 // the delete path (`collect_free_spans`), so all of this reclamation works
6590 // on userblock files too.
6591 for &(_, a) in &superseded_addrs {
6592 if let Ok(spans) = self.oh_chunk_spans(a) {
6593 to_free.extend(meta_spans(spans));
6594 }
6595 }
6596
6597 // A relocating overwrite (`write_dataset` resize, or any compact rewrite)
6598 // vacates the dataset's old object header, and a resized contiguous one
6599 // also vacates its old data block: both become dead once the parent's
6600 // relinked header lands. `superseded_addrs` covers only the rebuilt group
6601 // headers, not the relocated dataset's own header, so record that here too.
6602 // The pre-commit dataset-header address rides on the write plan (`old_oh`),
6603 // recorded where the plan was made; its chunks and old data extent are
6604 // freed only after the superblock repoint.
6605
6606 // The single-hard-link guard in the write preflight makes freeing the old
6607 // header safe (no surviving link still points at it).
6608 for key in &keys {
6609 for (_leaf, old_oh, mw) in &nodes[key].writes {
6610 match mw {
6611 MovingWrite::Contiguous {
6612 old_extent: Some(extent),
6613 ..
6614 } => to_free.push((extent.0, extent.1, FreeClass::Page(PageType::Raw))),
6615 // A relocated chunked dataset vacates its old chunk index and
6616 // chunk data blocks. `chunked_storage_spans` returns `None` for
6617 // anything it cannot enumerate exhaustively (leaving dead bytes
6618 // rather than freeing a region still in use); the old header
6619 // chunks are freed generically below.
6620 MovingWrite::Chunked { old_addr, .. } => {
6621 if let Ok(a) = usize::try_from(*old_addr) {
6622 if let Some(spans) = self.chunked_storage_spans(a) {
6623 to_free.extend(spans);
6624 }
6625 }
6626 }
6627 // A relocating append keeps the existing chunk *data* in place
6628 // (shared by both indexes during the commit), so only the old
6629 // index structure and the relocated old trailing chunk are dead.
6630 // The old header chunks are freed by the generic path below.
6631 MovingWrite::AppendedChunks {
6632 old_addr,
6633 old_tail_extent,
6634 kept_chunks,
6635 ..
6636 } => {
6637 if let Ok(a) = usize::try_from(*old_addr) {
6638 if let Some(spans) = self.chunked_index_spans(a) {
6639 // The old index is reclaimed as raw only where it
6640 // provably sits in a raw page, and recorded as dead
6641 // otherwise; see `index_is_provably_raw`. The
6642 // dataset's old chunk data is the kept chunks
6643 // (base-relative) plus the trailing partial chunk
6644 // this append relocated — both already in hand, so
6645 // the proof needs no second walk of the index.
6646 let data: Vec<(u64, u64)> = kept_chunks
6647 .iter()
6648 .filter_map(|c| {
6649 Some((base.absolute(c.address).ok()?, c.compressed_size))
6650 })
6651 .chain(*old_tail_extent)
6652 .collect();
6653 let class = if self.index_is_provably_raw(&data, &spans) {
6654 FreeClass::Page(PageType::Raw)
6655 } else {
6656 FreeClass::Dead
6657 };
6658 to_free.extend(spans.into_iter().map(|(a, l)| (a, l, class)));
6659 }
6660 }
6661 if let Some(ext) = old_tail_extent {
6662 // The relocated old trailing chunk is raw data.
6663 to_free.push((ext.0, ext.1, FreeClass::Page(PageType::Raw)));
6664 }
6665 }
6666 _ => {}
6667 }
6668 // The relocated dataset's old header chunks are dead too.
6669 if let Ok(a) = usize::try_from(*old_oh) {
6670 if let Ok(spans) = self.oh_chunk_spans(a) {
6671 to_free.extend(meta_spans(spans));
6672 }
6673 }
6674 }
6675 }
6676
6677 // Defense in depth: never hand the free list an out-of-bounds or
6678 // overlapping span. The last-link guard plus the per-object checks
6679 // should already make the accumulated spans disjoint; this enforces it
6680 // as a whole-commit invariant against the pre-commit end-of-file. Any
6681 // dropped span (which should not occur for a well-formed file) only
6682 // leaks, never corrupts.
6683 retain_disjoint_in_bounds(&mut to_free, self.image.len());
6684
6685 // --- Apply: process deepest groups first so each parent sees its
6686 // children's new addresses, then repoint the superblock last.
6687 // `path_addr` accumulates every group's and dataset's address as it is
6688 // placed — read by `resolve_reference_target` to resolve a same-commit
6689 // object-reference target (see the dataset-placement loop below for the
6690 // group/dataset key convention: a group's own path, or a dataset's
6691 // full parent+name path). ---
6692 let mut path_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
6693 // Where each object-header address this commit vacates has been rewritten
6694 // to, for repointing the object references the rest of the file already
6695 // stores (issue #324). Filled from the two places a header moves: a
6696 // relocating value overwrite, recorded in the loop below as its plan is
6697 // applied, and a rebuilt group, joined from `superseded_addrs` once every
6698 // group's new address is known.
6699 let mut relocations: BTreeMap<u64, u64> = BTreeMap::new();
6700 let mut by_depth = keys.clone();
6701 // Stable on purpose: `keys` comes from a `BTreeMap`, so equal-depth
6702 // groups arrive in path order, and this order is the order they are
6703 // placed in — which fixes every address the commit writes. An unstable
6704 // sort may permute a depth group, and the file's layout with it.
6705 by_depth.sort_by_key(|k| std::cmp::Reverse(k.len())); // deepest first
6706 for key in &by_depth {
6707 let (mut region, deletes, copies, writes, attrs) = {
6708 let node = nodes.get_mut(key).unwrap();
6709 (
6710 std::mem::take(&mut node.base_region),
6711 std::mem::take(&mut node.deletes),
6712 std::mem::take(&mut node.copies),
6713 std::mem::take(&mut node.writes),
6714 std::mem::take(&mut node.attrs),
6715 )
6716 };
6717
6718 // Remove deleted links first (verbatim-preserving the rest).
6719 //
6720 // First is a correctness requirement rather than a convention, and
6721 // has been since a replacement became one commit (issue #305):
6722 // `remove_link_from_region` matches by *name*, so a removal running
6723 // after the additions below would take the replacement's link with
6724 // the original's and leave the path gone. Every link this loop could
6725 // collide with — a copy's, a dataset's, a new child group's — is
6726 // appended after it, which is what keeps that unreachable. (A
6727 // relocating write and an existing child group *patch* a link rather
6728 // than appending one, so neither can be a replacement's.)
6729 for name in &deletes {
6730 region = remove_link_from_region(®ion, name)?;
6731 }
6732
6733 // Every link appended below takes the next creation index from this
6734 // group's running maximum, on the groups that track link creation
6735 // order and on no others; `link_order.record` writes the bumped
6736 // maximum back once the last of them is placed. Read after the
6737 // deletions above because a deletion leaves the maximum alone — the
6738 // gap it opens is never handed out again.
6739 let mut link_order = LinkCreationOrder::for_region(®ion)?;
6740
6741 // Write each staged source subtree and link its root into this group.
6742 // `write_copy_subtree` returns an absolute header address; the parent
6743 // link stores it relative to the userblock base.
6744 for (leaf, tree) in copies {
6745 let root = self.write_copy_subtree(&tree)?;
6746 region.push_link(&leaf, base.relative(root)?, link_order.take()?);
6747 }
6748
6749 // Datasets directly under this group. Appended addresses are absolute
6750 // file offsets; the contiguous data-layout address and the parent link
6751 // target are stored relative to the base address (`- base`). 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 u64::MAX
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(&fd.name, base.relative(oh_addr)?, link_order.take()?);
6843 let mut full = key.clone();
6844 full.push(fd.name.clone());
6845 path_addr.insert(full, oh_addr);
6846 }
6847
6848 // Relocating value overwrites under this group: write the new data and
6849 // rewritten header, then patch this group's existing link to it. The
6850 // link target is stored relative to the base address (`- base`); on a
6851 // userblock file only the chunked variant reaches here (contiguous and
6852 // compact resizes are refused in the write preflight).
6853 for (leaf, old_oh, mw) in &writes {
6854 let new_oh = self.write_moving(mw)?;
6855 patch_link_target(&mut region, leaf, base.relative(new_oh)?)?;
6856 relocations.insert(*old_oh, new_oh);
6857 }
6858
6859 // Wire links to dirty child groups (new → add a link; existing →
6860 // patch the existing link to the child's new address). Link targets are
6861 // stored relative to the base address.
6862 for child in children.get(key).into_iter().flatten() {
6863 let child_name = child.last().unwrap();
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 as u64, 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[&PathKey::new()];
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)?;
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)?, 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
7125 let old_ext_rel = self
7126 .superblock
7127 .superblock_extension_address
7128 .filter(|&a| a != UNDEF)
7129 .ok_or(Error::EditUnsupported(
7130 "a persisting file has no superblock extension to update",
7131 ))?;
7132 let old_ext_addr = usize::try_from(old_ext_rel)
7133 .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
7134
7135 // The persist File Space Info message is fixed-size, so the rewritten
7136 // extension's length is independent of the addresses it will carry: size
7137 // it with a placeholder to place the FSM blocks that follow it.
7138 let placeholder =
7139 FileSpaceInfo::persistent_single_manager(strategy, threshold, page_size, 0, 0);
7140 let ext_len =
7141 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7142 .len() as u64;
7143
7144 // Place the tail — the rewritten extension and the manager blocks — in a
7145 // hole an *earlier* commit freed where one fits, and at end-of-file where
7146 // none does. `self.free` holds only durable free space (this commit's own
7147 // frees stay in `to_free` until the repoint), so a reused span holds bytes
7148 // already unreachable from the on-disk root, which is the guarantee
7149 // [`reserve`](Self::reserve) rests on too.
7150 let (post, placed_at, tail_len, eoa) =
7151 self.flat_tail_layout(&to_free, &old_blocks, ext_len, os);
7152 if placed_at.is_none() && placement == TailPlacement::ReuseOnly {
7153 // The shrink pass appends nothing: growing the file by a tail is the
7154 // opposite of what it was called for. Nothing has been written and the
7155 // layout handed its reservation back, so the session is exactly as the
7156 // commit before it left it.
7157 return Ok(());
7158 }
7159 let reused = placed_at.is_some();
7160 let ext_addr = placed_at.unwrap_or_else(|| self.image.len());
7161 let sections = free_sections(&post);
7162 let fshd_addr = ext_addr + ext_len;
7163 let fsse_addr = fshd_addr + fshd_len(os);
7164 // A reused tail sits inside the file, which ends it at the end-of-allocation
7165 // the layout settled on — the current end-of-file, less any run of free
7166 // space reaching it, which `post` no longer records and the truncation
7167 // below gives back to the filesystem (issue #418). An appended tail ends
7168 // the file itself.
7169 let final_eof = if reused { eoa } else { ext_addr + tail_len };
7170 debug_assert!(
7171 ext_addr + tail_len <= final_eof,
7172 "the tail must end at or below the end-of-allocation it was placed under"
7173 );
7174
7175 // Build the real extension and the FSM blocks. With no free space to
7176 // record we still refresh the extension (persist on, managers undefined).
7177 let (ext_oh, fsm_blocks) = if sections.is_empty() {
7178 let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7179 let ext_oh =
7180 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7181 (ext_oh, None)
7182 } else {
7183 // `eoa_pre_fsm` is the end-of-allocation before the free-space-manager
7184 // section blocks (`FSHD`/`FSSE`) were allocated: a consumer may shrink
7185 // back to here and rebuild them. For an appended tail that is the FSHD,
7186 // not the extension — the extension sits below it and persists, so
7187 // shrinking leaves the superblock and its extension pointer valid (only
7188 // the manager blocks, which are rewritten every commit, are discarded).
7189 // A tail placed *inside* the file gave the allocation back nothing to
7190 // shrink, so it records the end-of-allocation itself. Either way it is
7191 // defined, which a persisting file's message must be or an
7192 // assertion-enabled libhdf5 aborts on open (issue #178), and is the
7193 // value `H5Fget_freespace` accounts for correctly (verified in the
7194 // crosscheck).
7195 let eoa_pre_fsm = if reused { final_eof } else { fshd_addr };
7196 let info = FileSpaceInfo::persistent_single_manager(
7197 strategy,
7198 threshold,
7199 page_size,
7200 fshd_addr,
7201 eoa_pre_fsm,
7202 );
7203 let ext_oh =
7204 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7205 let (fshd, fsse) =
7206 serialize_file_fsm(§ions, fshd_addr, fsse_addr, os, SECT_CLASS_SIMPLE);
7207 (ext_oh, Some((fshd, fsse)))
7208 };
7209 // Both forms of the message carry the same twelve manager slots, so either
7210 // one measures what the placeholder did. The tail was reserved for that
7211 // length, and the manager blocks are placed after it.
7212 debug_assert_eq!(
7213 ext_oh.len() as u64,
7214 ext_len,
7215 "extension length must be stable across the placeholder and real messages"
7216 );
7217
7218 // Write the extension, then the FSM blocks. Both forms are safe against a
7219 // crash here: an appended tail is past everything live, a reused one sits
7220 // in space an earlier commit freed, and neither is referenced until the
7221 // repoint below.
7222 let region = (ext_addr, tail_len);
7223 self.write_tail_block(region, ext_addr, &ext_oh)?;
7224 if let Some((fshd, fsse)) = fsm_blocks {
7225 self.write_tail_block(region, fshd_addr, &fshd)?;
7226 self.write_tail_block(region, fsse_addr, &fsse)?;
7227 }
7228 // Exactly the bytes written, contiguous from the extension, which is what
7229 // the next commit supersedes.
7230 let new_old_blocks = vec![(ext_addr, tail_len)];
7231
7232 // Barrier, then repoint the superblock (root, eof, and the new extension)
7233 // — the linearization point — and sync it.
7234 self.barrier()?;
7235 let mut new_sb = self.superblock.clone();
7236 new_sb.root_group_address = new_root;
7237 new_sb.eof_address = final_eof;
7238 new_sb.superblock_extension_address = Some(ext_addr);
7239 // As above, and zero for the same reason: this is the *unpaged* persisting
7240 // tail, which no page-buffered session reaches (issue #73, issue #308).
7241 new_sb.consistency_flags = self.held_status_flags;
7242 let sb_bytes = new_sb.serialize();
7243 self.publish_attempted = true;
7244 self.write_at(self.sb_sig_off, &sb_bytes)?;
7245 self.barrier()?;
7246 self.superblock = new_sb;
7247
7248 // Physically shrink the file only after the superblock — now carrying the
7249 // smaller end-of-file — is durable, exactly as the non-persisting tail
7250 // does: a crash between the two leaves trailing bytes the next open
7251 // ignores, where the reverse order would advertise an end-of-file past
7252 // the file's actual length. The managers just written describe `post`,
7253 // which no longer names anything above this cut.
7254 if final_eof < self.image.len() {
7255 self.image.truncate(final_eof)?;
7256 self.barrier()?;
7257 }
7258
7259 // The repoint is durable: the prior free list plus this commit's vacated
7260 // regions are now genuinely free, and the freshly written blocks become
7261 // the ones a future commit will supersede.
7262 self.free = post;
7263 self.persist = Some(PersistState {
7264 strategy,
7265 threshold,
7266 page_size,
7267 old_blocks: new_old_blocks,
7268 });
7269 // The managers describe the file at this length, so nothing is owed until
7270 // it grows past them again.
7271 self.fsm_len = self.image.len();
7272 Ok(())
7273 }
7274
7275 /// The free space a flat persisting commit is about to record: the session's
7276 /// durable list plus the regions this commit vacated and the superseded
7277 /// extension and manager blocks, which are dead once the superblock is
7278 /// repointed.
7279 ///
7280 /// Returned as a temporary rather than folded into the session, for the reason
7281 /// [`paged_post_free`](Self::paged_post_free) does the same: every region
7282 /// gathered here is still *live* until that repoint, and everything in between
7283 /// can fail, so a session that believed them free after a failed commit would
7284 /// hand them out over the objects still occupying them.
7285 ///
7286 /// Called once to *size* the tail and again for each round its placement takes
7287 /// to settle, since what remains free depends on the space the tail has drawn
7288 /// for itself ([`flat_tail_layout`](Self::flat_tail_layout)).
7289 fn flat_post_free(
7290 &self,
7291 to_free: &[(u64, u64, FreeClass)],
7292 old_blocks: &[(u64, u64)],
7293 ) -> FreeList {
7294 let mut post = self.free.clone();
7295 // A flat file keeps one list for the whole of it, so the class each freed
7296 // region carries for the paged path is not consulted here — and a flat
7297 // file produces no [`FreeClass::Dead`] region to begin with, since the
7298 // proof that classes one short-circuits where there are no page types to
7299 // keep apart.
7300 for &(a, l, _) in to_free {
7301 post.free(a, l);
7302 }
7303 for &(a, l) in old_blocks {
7304 post.free(a, l);
7305 }
7306 post
7307 }
7308
7309 /// Reserve free space for a flat persisting commit's tail, returning the free
7310 /// list the tail leaves behind, the address it got — `None` when nothing
7311 /// reusable fits, which is the caller's cue to append — its length, which the
7312 /// caller needs either way, and the end-of-allocation the commit should
7313 /// publish, which is the current end-of-file less any run of free space that
7314 /// reaches it (issue #418).
7315 ///
7316 /// The reservation and the length define each other, exactly as they do for the
7317 /// paged tail ([`tail_layout`](Self::tail_layout)): drawing bytes out of the
7318 /// free list changes the very sections the manager blocks record, and a
7319 /// different section set is a different length. This proposes a length, sizes
7320 /// the blocks against the list as it stands once that length is taken, and
7321 /// accepts any plan that *fits*. A longer plan is refused — it would write past
7322 /// the reservation into live bytes — and the proposal becomes the length that
7323 /// plan asked for, which is the natural next candidate.
7324 ///
7325 /// A plan **shorter** than its reservation is accepted, and the reservation
7326 /// rather than the plan is what the tail extent covers, so those spare bytes
7327 /// are freed with the rest of the tail when the next commit supersedes it.
7328 /// Rejecting them instead is what the paged tail does, and it can afford to:
7329 /// its fallback is a fresh page, recorded in full. Here the fallback is
7330 /// end-of-file, and the two lengths genuinely need not meet — best fit picks a
7331 /// different hole as the proposal crosses a hole's size, and the pair can
7332 /// oscillate by one section record indefinitely. Measured on the workload
7333 /// `persisting_churn_reaches_a_steady_size` runs, insisting on an exact fit sent
7334 /// about one commit in eight to end-of-file, which is the growth this exists to
7335 /// stop.
7336 ///
7337 /// What that costs is bounded and small: a commit that takes spare bytes and is
7338 /// then the last of its session leaves them behind, since a reopened session
7339 /// reconstructs the tail extent from the blocks themselves
7340 /// ([`load_persisted_free_space`](Self::load_persisted_free_space)) and cannot
7341 /// see a reservation that outlived the process. The spare is a section record
7342 /// and, where the reservation emptied a size group, that group's own header
7343 /// too — tens of bytes, once per session, against a whole tail on one commit in
7344 /// eight.
7345 ///
7346 /// Iteration is capped rather than trusted to converge: the length is a step
7347 /// function of the section set, so a proposal can in principle keep growing.
7348 /// Appending is always available and always correct, so giving up costs space
7349 /// rather than a guarantee — and the cap bounds the work, since each round
7350 /// clones the free list and sizes the section list for real.
7351 ///
7352 /// Draws from `self.free` before anything is written, which
7353 /// [`commit`](Self::commit) puts back if the attempt then fails. The one caller
7354 /// that does not snapshot is [`finalize_persist`](Self::finalize_persist), whose
7355 /// failure leaves this session's list short by the reservation — in memory only,
7356 /// on a session already being torn down, with the on-disk managers unchanged.
7357 ///
7358 /// When `self.free` has no hole for it, the tail may take one out of
7359 /// [`reserved`](Self::reserved) instead. Only the rewrite that publishes an
7360 /// append's draw ([`reserve_for_immediate_append`](Self::reserve_for_immediate_append))
7361 /// reaches this with anything there — every other caller releases the reserve
7362 /// first — and for that rewrite the fallback is what keeps the draw from
7363 /// growing the file: a draw takes every hole the append fits in, and on a
7364 /// flat file that is the one list the tail is placed from, so without it the
7365 /// rewrite appended its tail at end-of-file every time and the file grew by a
7366 /// tail per draw (issue #413). Space in the reserve is exactly what this
7367 /// rewrite publishes as *not* free, so a tail placed there is live in bytes
7368 /// the managers do not advertise, which is the same standing a tail placed
7369 /// from `self.free` has once the repoint lands. `self.free` first, so that an
7370 /// ordinary hole is spent before the append's own space is; best fit within
7371 /// the reserve then takes its smallest run, leaving the largest for the
7372 /// append that drew it. The paged tail ([`tail_layout`](Self::tail_layout))
7373 /// has no such fallback: it opens a page when nothing fits, and
7374 /// `paged_group_churn_with_populated_datasets_reaches_a_steady_size` holds it
7375 /// to not doing so once per draw.
7376 fn flat_tail_layout(
7377 &mut self,
7378 to_free: &[(u64, u64, FreeClass)],
7379 old_blocks: &[(u64, u64)],
7380 ext_len: u64,
7381 os: u8,
7382 ) -> (FreeList, Option<u64>, u64, u64) {
7383 /// Enough rounds for the section set to settle after a reservation shrinks
7384 /// it, without letting a proposal that keeps growing spin.
7385 const ROUNDS: usize = 4;
7386
7387 let eof = self.image.len();
7388 // The first proposal: what the tail would measure with nothing reserved.
7389 // A manager block's length depends only on the sections it records, never
7390 // on where it sits. It is also the answer for a tail that ends up appended,
7391 // since every round hands its reservation back before this returns.
7392 let probe = self.flat_post_free(to_free, old_blocks);
7393 let appended_len = ext_len + file_fsm_blocks_len(&free_sections(&probe), os);
7394 let mut proposed = appended_len;
7395
7396 for _ in 0..ROUNDS {
7397 let (at, from_reserve) = match self.free.alloc(proposed) {
7398 Some(at) => (at, false),
7399 None => match self.reserved.alloc(proposed) {
7400 Some(at) => (at, true),
7401 None => break,
7402 },
7403 };
7404 let mut post = self.flat_post_free(to_free, old_blocks);
7405 // Free space that reaches end-of-file is released rather than
7406 // recorded: the file is truncated to where the run starts, so the
7407 // sections these blocks are sized from must already leave it out
7408 // (issue #418). The reservation was taken before this list was built,
7409 // so the run can only begin at or above the tail's own end.
7410 let eoa = release_trailing_run(&mut post, eof, proposed);
7411 let len = ext_len + file_fsm_blocks_len(&free_sections(&post), os);
7412 if len <= proposed {
7413 debug_assert!(
7414 at + proposed <= eoa,
7415 "a reused tail at {at} of {proposed} bytes runs past the end-of-allocation \
7416 {eoa} it was placed under"
7417 );
7418 return (post, Some(at), proposed, eoa);
7419 }
7420 if from_reserve {
7421 self.reserved.free(at, proposed);
7422 } else {
7423 self.free.free(at, proposed);
7424 }
7425 proposed = len;
7426 }
7427 // Nothing reusable fits, so the tail is appended past end-of-file and no
7428 // trailing run is released: whatever free space reaches end-of-file was
7429 // too small for the tail, and stays recorded below it.
7430 (probe, None, appended_len, eof)
7431 }
7432
7433 /// Commit tail for a genuine paged file (`H5F_FSPACE_STRATEGY_PAGE`, issue
7434 /// #198). The paged counterpart of [`commit_persisting`](Self::commit_persisting).
7435 ///
7436 /// Two things differ from the flat tail. Free space is recorded in *per-page-type*
7437 /// managers — SUPER (slot 0) for metadata, DRAW (slot 2) for small raw, and the
7438 /// generic-large manager (slot 6) for whole free pages and large-raw fragments —
7439 /// rather than one generic manager, so a paged file reopened by the reference
7440 /// library still finds its free space segregated. And the file is padded to a
7441 /// page before the tail is laid down, so the end-of-allocation stays a whole
7442 /// number of pages, matching the paged file the from-scratch writer produces.
7443 ///
7444 /// The tail itself is placed like any other run of metadata, in free space
7445 /// where some fits ([`tail_layout`](Self::tail_layout)), and only opens a page
7446 /// at end-of-file when none does. It used to always append into a page of its
7447 /// own and pad the remainder out untracked, which cost a page per commit and
7448 /// recorded none of it — a paged file under delete-and-recreate churn grew
7449 /// without bound while reporting a fraction of that as reusable (issue #286).
7450 /// The reference library does not page-align these blocks either: its manager
7451 /// headers sit at whatever offset within a metadata page they are allocated at.
7452 ///
7453 /// Crash atomicity is identical to the flat path: the tail is either past the
7454 /// live file or inside space an earlier commit freed, and is unreferenced
7455 /// either way until the superblock repoint, which is the linearization point.
7456 fn commit_persisting_paged(
7457 &mut self,
7458 new_root: u64,
7459 to_free: Vec<(u64, u64, FreeClass)>,
7460 placement: TailPlacement,
7461 ) -> Result<(), Error> {
7462 let os = self.superblock.offset_size;
7463 let (strategy, threshold, page_size, old_blocks) = {
7464 let ps = self
7465 .persist
7466 .as_ref()
7467 .expect("commit_persisting is only called when persistence is armed");
7468 (
7469 ps.strategy,
7470 ps.threshold,
7471 ps.page_size,
7472 ps.old_blocks.clone(),
7473 )
7474 };
7475
7476 // Page-align the file before anything else, so the rewritten extension and
7477 // the manager blocks begin on a page boundary and stay in metadata pages.
7478 // The padded tail becomes free space of whatever type that page held.
7479 self.pad_to_page()?;
7480
7481 let old_ext_rel = self
7482 .superblock
7483 .superblock_extension_address
7484 .filter(|&a| a != UNDEF)
7485 .ok_or(Error::EditUnsupported(
7486 "a persisting file has no superblock extension to update",
7487 ))?;
7488 let old_ext_addr = usize::try_from(old_ext_rel)
7489 .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
7490
7491 // The 12-slot persist message is fixed-size, so a placeholder sizes the
7492 // rewritten extension before its manager addresses are known — and before
7493 // its address is, which is what lets the tail be sized before it is placed.
7494 let placeholder = FileSpaceInfo::persistent_managers(
7495 strategy,
7496 threshold,
7497 page_size,
7498 [UNDEF; NUM_FILE_FSM_MANAGERS],
7499 0,
7500 );
7501 let ext_len =
7502 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7503 .len() as u64;
7504
7505 // Place the tail — the rewritten extension and the manager blocks — as an
7506 // ordinary run of metadata, in a hole an *earlier* commit freed where one
7507 // fits. That is what stops a file under delete-and-recreate churn from
7508 // growing: every commit frees its predecessor's tail exactly, so in the
7509 // steady state a tail lands in a hole an earlier one vacated — the one two
7510 // commits back, since a tail is freed into `pg`'s lists only at the repoint
7511 // that follows its successor — and the file never has to open a page for it
7512 // (issue #286). `pg`'s lists hold only
7513 // durable free space — this commit's own frees stay in `to_free` until the
7514 // repoint — so the bytes overwritten are already unreachable from the
7515 // on-disk root, the guarantee [`reserve`](Self::reserve) relies on.
7516 //
7517 // The tail is sized from the very lists it draws on, so its length and its
7518 // placement define each other: reserving space removes sections, and fewer
7519 // sections need fewer bytes to record. `tail_layout` settles that by
7520 // proposing a length, planning against it, and accepting the proposal only
7521 // when the plan comes out exactly that long — no shorter, which would leave
7522 // untracked bytes inside the reservation, and no longer, which would run
7523 // past it into whatever lives next. A few rounds settle it; a proposal that
7524 // will not converge falls through to the append below, which has no length
7525 // to satisfy.
7526 let placed = self.tail_layout(&to_free, &old_blocks, ext_len, page_size, os);
7527 if placed.is_none() && placement == TailPlacement::ReuseOnly {
7528 // As on the flat path: the shrink pass opens no page of its own.
7529 return Ok(());
7530 }
7531 let reused = placed.is_some();
7532 let (post, plan, ext_addr, blocks_len, eoa) = match placed {
7533 Some(layout) => layout,
7534 None => {
7535 // Nothing fits: open a metadata page at end-of-file. `pad_to_page`
7536 // above already left the file page-aligned, so this only records the
7537 // tail page's type. No trailing run is released here — whatever
7538 // free space reaches end-of-file was too small for the tail, and
7539 // stays recorded below it.
7540 self.begin_page(PageType::Meta)?;
7541 let at = self.image.len();
7542 let post = self.paged_post_free(&to_free, &old_blocks);
7543 let plan = plan_paged_managers(
7544 &free_sections(&post.meta),
7545 &free_sections(&post.raw),
7546 &post.unclassified,
7547 page_size,
7548 at + ext_len,
7549 os,
7550 );
7551 let blocks_len = plan.end_of_managers.max(at + ext_len) - at;
7552 (post, plan, at, blocks_len, at)
7553 }
7554 };
7555 let final_eof = if reused {
7556 // The tail landed inside the file; the end-of-allocation is where this
7557 // commit's appends left it — page-aligned by `pad_to_page`, less the
7558 // whole pages of free space reaching it, which `post` no longer
7559 // records and the truncation below gives back (issue #418).
7560 eoa
7561 } else {
7562 align_up(ext_addr + blocks_len, page_size)
7563 };
7564 debug_assert!(
7565 ext_addr + blocks_len <= final_eof,
7566 "the tail must end at or below the end-of-allocation it was placed under"
7567 );
7568
7569 let ext_oh = if plan.is_empty() {
7570 // No free space to track: an empty persist message, page-aligned.
7571 let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7572 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7573 } else {
7574 // Paged convention (matching the from-scratch writer): the managers are
7575 // ordinary metadata below a page-aligned end-of-allocation.
7576 let info = FileSpaceInfo::persistent_managers(
7577 strategy, threshold, page_size, plan.slots, final_eof,
7578 );
7579 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7580 };
7581 debug_assert_eq!(
7582 ext_oh.len() as u64,
7583 ext_len,
7584 "extension length must be stable across the placeholder and real messages"
7585 );
7586
7587 // Write the extension, then every manager block. Both forms are safe against
7588 // a crash here: an appended tail is past everything live, and a reused one
7589 // sits in space an earlier commit freed. Neither is referenced until the
7590 // repoint below.
7591 let region = (ext_addr, blocks_len);
7592 self.write_tail_block(region, ext_addr, &ext_oh)?;
7593 for b in &plan.blocks {
7594 let (fshd, fsse) =
7595 serialize_file_fsm(&b.sections, b.fshd_addr, b.fsse_addr, os, b.class);
7596 self.write_tail_block(region, b.fshd_addr, &fshd)?;
7597 self.write_tail_block(region, b.fsse_addr, &fsse)?;
7598 }
7599 // An appended tail ends mid-page; pad it out, so the end-of-allocation stays
7600 // a whole number of pages. A reused tail is already inside the file, and
7601 // matched its reservation exactly, so there is nothing to pad — and where
7602 // it released a trailing run its end-of-allocation is *below* the current
7603 // end-of-file, which the truncation after the repoint gives back.
7604 if !reused {
7605 self.pad_zeros_to(final_eof)?;
7606 }
7607 // Exactly the bytes written, contiguous from the extension. A session that
7608 // reopens this file records the same extents from the message and the
7609 // manager headers, so nothing depends on remembering this across a close.
7610 let new_old_blocks = vec![(ext_addr, blocks_len)];
7611
7612 // Barrier, then repoint the superblock (root, eof, and the new extension)
7613 // — the linearization point — and sync it.
7614 self.barrier()?;
7615 let mut new_sb = self.superblock.clone();
7616 new_sb.root_group_address = new_root;
7617 new_sb.eof_address = final_eof;
7618 new_sb.superblock_extension_address = Some(ext_addr);
7619 // The one publish site a page-buffered session actually reaches, and so
7620 // the one where this must not be a literal zero: a crash mark has to
7621 // outlive every commit the session makes (issue #308).
7622 new_sb.consistency_flags = self.held_status_flags;
7623 let sb_bytes = new_sb.serialize();
7624 self.publish_attempted = true;
7625 self.write_at(self.sb_sig_off, &sb_bytes)?;
7626 self.barrier()?;
7627 self.superblock = new_sb;
7628
7629 // Physically shrink the file only after the superblock — now carrying the
7630 // smaller end-of-file — is durable, for the reason the flat tail gives:
7631 // a crash between the two leaves trailing bytes the next open ignores,
7632 // where the reverse order would advertise an end-of-file past the file's
7633 // actual length.
7634 if final_eof < self.image.len() {
7635 self.image.truncate(final_eof)?;
7636 self.barrier()?;
7637 }
7638
7639 // The repoint is durable. Only now are this commit's vacated regions
7640 // genuinely free, so adopt the lists built above and drop the padding tails
7641 // they already account for. The blocks just written become the ones the next
7642 // commit supersedes.
7643 if let Some(pg) = self.paged.as_mut() {
7644 pg.meta = post.meta;
7645 pg.raw = post.raw;
7646 pg.dead = post.dead;
7647 // Rebuilt rather than left alone: this list is otherwise constant for
7648 // the session, but a released trailing run may have cut into it, and a
7649 // section naming bytes the file no longer has would be written back to
7650 // the managers by the next commit.
7651 let mut unclassified = FreeList::new();
7652 for s in &post.unclassified {
7653 unclassified.free(s.addr, s.size);
7654 }
7655 pg.unclassified = unclassified;
7656 pg.meta_pad.clear();
7657 pg.raw_pad.clear();
7658 if !reused {
7659 // The tail page is fresh metadata: the managers sit in it, so a
7660 // following append of metadata may keep packing that page, and the
7661 // rest of it is free metadata this session can spend. The managers
7662 // just written cannot say so — they would have to describe space
7663 // whose size their own length decides — so the next commit records
7664 // it, from here. A reused tail leaves end-of-file where the data put
7665 // it, so the outgoing page type stands and there is no padding.
7666 pg.last = Some(PageType::Meta);
7667 pg.meta
7668 .free(ext_addr + blocks_len, final_eof - (ext_addr + blocks_len));
7669 }
7670 }
7671 self.persist = Some(PersistState {
7672 strategy,
7673 threshold,
7674 page_size,
7675 old_blocks: new_old_blocks,
7676 });
7677 // The managers describe the file at this length, so nothing is owed until
7678 // it grows past them again.
7679 self.fsm_len = self.image.len();
7680 Ok(())
7681 }
7682
7683 /// Pad a paged file to a page boundary if its tail page is partially filled,
7684 /// recording the padding as free space of the tail page's type. A no-op on a
7685 /// non-paged file or an already-aligned one.
7686 fn pad_to_page(&mut self) -> Result<(), Error> {
7687 let len = self.image.len();
7688 let pad = match &self.paged {
7689 Some(pg) if len % pg.page_size != 0 => {
7690 Some((pg.last, pg.page_size - len % pg.page_size))
7691 }
7692 _ => None,
7693 };
7694 if let Some((last, pad_len)) = pad {
7695 let pad_at = len;
7696 self.append(&vec![0u8; pad_len.to_usize()?])?;
7697 if let Some(pg) = self.paged.as_mut() {
7698 match last {
7699 // A partially-filled tail page at commit time is a raw page:
7700 // the last thing the apply loop writes for a dataset is its
7701 // header, but a commit that only wrote raw data ends on one.
7702 Some(PageType::Meta) => pg.meta_pad.push((pad_at, pad_len)),
7703 Some(PageType::Raw) => pg.raw_pad.push((pad_at, pad_len)),
7704 // No typed append this commit, so the tail page is one a
7705 // previous session left non-aligned — a crash, since a clean
7706 // close pads. Its type is unknown, and recording the padding
7707 // under a guess would advertise it for reuse of that type and
7708 // mix the page, so leave it untracked (see `PagedEdit::begin`,
7709 // which makes the same call for the same reason). Reuse made
7710 // this reachable: before it, every commit appended at least the
7711 // root group header, so `last` was always known here.
7712 None => {}
7713 }
7714 }
7715 }
7716 Ok(())
7717 }
7718
7719 /// The free lists a paged commit is about to persist: the session's durable
7720 /// lists plus the page-padding tails this commit's appends left behind, the
7721 /// regions it vacated, and the superseded extension and manager blocks (all
7722 /// metadata, dead once the superblock is repointed).
7723 ///
7724 /// Every page the result leaves wholly empty is promoted to a free page before
7725 /// it is returned ([`PagedEdit::promote_whole_free_pages`]), so the managers
7726 /// this commit writes already describe it. Deferring the promotion to the
7727 /// repoint would leave the session holding free space its own on-disk record
7728 /// does not name.
7729 ///
7730 /// Returned as temporaries rather than folded into the session, exactly as the
7731 /// flat path builds `post`: every region gathered here is still *live* until
7732 /// that repoint, so the session's own lists must not learn about it until the
7733 /// repoint succeeds. Everything in between can fail (the extension rewrite,
7734 /// each write, each barrier), and a session that survived a failed commit
7735 /// while believing live extents were free would hand them out on the next
7736 /// commit — silently destroying the objects still occupying them.
7737 ///
7738 /// Called once to *size* the tail and again for each round the tail's
7739 /// placement takes to settle, since what remains depends on the space the tail
7740 /// has drawn for itself ([`tail_layout`](Self::tail_layout)). Each call clones
7741 /// both lists; they hold one region per hole, not per byte, so the copies are
7742 /// small.
7743 fn paged_post_free(
7744 &self,
7745 to_free: &[(u64, u64, FreeClass)],
7746 old_blocks: &[(u64, u64)],
7747 ) -> PagedPostFree {
7748 let pg = self
7749 .paged
7750 .as_ref()
7751 .expect("commit_persisting_paged is only called on a paged file");
7752 let (mut meta, mut raw, mut dead) = (pg.meta.clone(), pg.raw.clone(), pg.dead.clone());
7753 // Carried through untouched: this engine never frees into that list, and
7754 // nothing it places comes out of it.
7755 let unclassified = free_sections(&pg.unclassified);
7756 let mut free = |a: u64, l: u64, class: FreeClass| {
7757 PagedEdit::route_free(&mut meta, &mut raw, &mut dead, a, l, class);
7758 };
7759 for &(a, l) in &pg.meta_pad {
7760 free(a, l, PageType::Meta.into());
7761 }
7762 for &(a, l) in &pg.raw_pad {
7763 free(a, l, PageType::Raw.into());
7764 }
7765 for &(a, l, class) in to_free {
7766 free(a, l, class);
7767 }
7768 // The superseded extension and manager blocks, which are metadata wherever
7769 // they sat: the tail is placed as metadata, and a page it had to open for
7770 // itself was opened as metadata too.
7771 for &(a, l) in old_blocks {
7772 free(a, l, PageType::Meta.into());
7773 }
7774 PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, pg.page_size);
7775 PagedPostFree {
7776 meta,
7777 raw,
7778 dead,
7779 unclassified,
7780 }
7781 }
7782
7783 /// Reserve free metadata space for a paged commit's tail and plan the manager
7784 /// blocks for the address it got, or `None` when nothing reusable fits — the
7785 /// caller then appends instead.
7786 ///
7787 /// The reservation and the plan are mutually dependent: drawing `len` bytes out
7788 /// of the free lists changes the sections those very blocks record, and a
7789 /// different section set is a different length. This proposes a length, plans
7790 /// against the lists as they stand once that length is taken, and accepts only
7791 /// an exact agreement. Anything else hands the reservation back and proposes
7792 /// the length the plan just came out at, which is the natural next candidate.
7793 ///
7794 /// Both kinds of disagreement have to be rejected, not just the obvious one. A
7795 /// plan **longer** than its reservation would write past it into whatever lives
7796 /// after. A plan **shorter** would leave bytes inside the reservation that no
7797 /// manager records and the next commit does not free — the leak this whole
7798 /// change exists to remove, reintroduced a few bytes at a time.
7799 ///
7800 /// Iteration is capped rather than trusted to converge: the length is a
7801 /// step function of the section set, so a proposal can in principle oscillate
7802 /// between two values that each imply the other. Appending is always available
7803 /// and always correct, so giving up costs a page rather than a guarantee.
7804 fn tail_layout(
7805 &mut self,
7806 to_free: &[(u64, u64, FreeClass)],
7807 old_blocks: &[(u64, u64)],
7808 ext_len: u64,
7809 page_size: u64,
7810 os: u8,
7811 ) -> Option<(PagedPostFree, PagedManagerPlan, u64, u64, u64)> {
7812 /// Enough rounds for the section set to settle after a reservation shrinks
7813 /// it, without letting an oscillating proposal spin.
7814 const ROUNDS: usize = 4;
7815
7816 let eof = self.image.len();
7817
7818 // The first proposal: what the blocks would measure with nothing reserved.
7819 // A manager block's length depends only on the sections it records, never
7820 // on its address, so a start of 0 measures the blocks alone.
7821 let probe = self.paged_post_free(to_free, old_blocks);
7822 let mut proposed = ext_len
7823 + plan_paged_managers(
7824 &free_sections(&probe.meta),
7825 &free_sections(&probe.raw),
7826 &probe.unclassified,
7827 page_size,
7828 0,
7829 os,
7830 )
7831 .end_of_managers;
7832
7833 for _ in 0..ROUNDS {
7834 let pg = self
7835 .paged
7836 .as_mut()
7837 .expect("commit_persisting_paged is only called on a paged file");
7838 let at = pg.alloc_typed(proposed, PageType::Meta)?;
7839 let mut post = self.paged_post_free(to_free, old_blocks);
7840 // Whole free pages at the end of the file are released rather than
7841 // recorded, so the sections these blocks are sized from must already
7842 // leave them out (issue #418). The reservation was taken before this
7843 // list was built, so the run can only begin at or above the tail's own
7844 // end.
7845 let eoa = post.release_trailing(eof, page_size, proposed);
7846 // Class the free space into its managers and place their blocks after
7847 // the extension. Shared with the bounded backend so both lay out
7848 // identically.
7849 let plan = plan_paged_managers(
7850 &free_sections(&post.meta),
7851 &free_sections(&post.raw),
7852 &post.unclassified,
7853 page_size,
7854 at + ext_len,
7855 os,
7856 );
7857 // An empty plan writes no blocks at all, leaving the tail the extension
7858 // alone; `end_of_managers` is then its own start.
7859 let blocks_len = plan.end_of_managers.max(at + ext_len) - at;
7860 if blocks_len == proposed {
7861 debug_assert!(
7862 at + blocks_len <= eoa,
7863 "a reused tail at {at} of {blocks_len} bytes runs past the end-of-allocation \
7864 {eoa} it was placed under"
7865 );
7866 return Some((post, plan, at, blocks_len, eoa));
7867 }
7868 let pg = self
7869 .paged
7870 .as_mut()
7871 .expect("the paged state outlives this loop");
7872 PagedEdit::route_free(
7873 &mut pg.meta,
7874 &mut pg.raw,
7875 &mut pg.dead,
7876 at,
7877 proposed,
7878 PageType::Meta.into(),
7879 );
7880 proposed = blocks_len;
7881 }
7882 None
7883 }
7884
7885 /// Write one block of a persisting commit's tail — paged or flat — at the
7886 /// address its layout gave it, which is either the current end-of-file (the
7887 /// tail is being appended) or inside `region`, the span reserved from the free
7888 /// lists, when the tail is being reused into space an earlier commit left.
7889 ///
7890 /// The region is checked rather than asserted, for the reason
7891 /// [`place`](Self::place) checks its own reservation: a reused span is followed
7892 /// by live bytes, so a block running past it would silently destroy a
7893 /// neighboring object, and this is the one write in a commit that lands in the
7894 /// middle of a live file. The region's length is derived from the same layout
7895 /// that placed these blocks, so the two cannot disagree today; the comparison
7896 /// is what keeps that true if a layout ever learns a length this sizing does
7897 /// not model.
7898 fn write_tail_block(
7899 &mut self,
7900 region: (u64, u64),
7901 addr: u64,
7902 bytes: &[u8],
7903 ) -> Result<(), Error> {
7904 let (start, len) = region;
7905 if addr < start || addr + bytes.len() as u64 > start + len {
7906 return Err(Error::Format(FormatError::SerializationError(format!(
7907 "a commit's tail reserved [{start}, {}) but placed {} bytes at {addr}",
7908 start + len,
7909 bytes.len()
7910 ))));
7911 }
7912 if addr == self.image.len() {
7913 let written = self.append(bytes)?;
7914 debug_assert_eq!(written, addr, "an appended block must land at end-of-file");
7915 return Ok(());
7916 }
7917 self.write_at(
7918 usize::try_from(addr)
7919 .map_err(|_| Error::EditUnsupported("tail address exceeds this platform"))?,
7920 bytes,
7921 )
7922 }
7923
7924 /// Extend the file with zeros up to `target` (>= the current length), used by
7925 /// the paged tail to pad the final metadata page to its boundary.
7926 fn pad_zeros_to(&mut self, target: u64) -> Result<(), Error> {
7927 let len = self.image.len();
7928 if target > len {
7929 let pad = (target - len).to_usize()?;
7930 self.append(&vec![0u8; pad])?;
7931 }
7932 debug_assert_eq!(self.image.len(), target);
7933 Ok(())
7934 }
7935
7936 /// Rebuild the superblock-extension object header's message region with its
7937 /// File Space Info message replaced by `info` (every other message preserved
7938 /// verbatim), ready to wrap with [`build_v2_object_header`]. The persisting
7939 /// message is fixed-size, so this never changes the region's length.
7940 fn rewrite_extension_region(
7941 &self,
7942 ext_addr: usize,
7943 info: &FileSpaceInfo,
7944 ) -> Result<OhRegion, Error> {
7945 let region =
7946 Self::gather_oh_messages(&self.image(), ext_addr as u64, self.superblock.base_address)?;
7947 rewrite_extension_region_bytes(®ion, info)
7948 }
7949
7950 /// Repoint a version 0/1 superblock at the rebuilt (now v2) root group and
7951 /// update its end-of-file field, patching the raw bytes in place — these
7952 /// superblocks carry no checksum. The root symbol-table entry is switched to
7953 /// cache type 0 (its scratch-pad B-tree / local-heap addresses, which
7954 /// describe the old symbol-table group, no longer apply). The
7955 /// object-header-address write is done last so it is the linearization point.
7956 fn repoint_v0v1_root(&mut self, new_root: u64, new_eof: u64) -> Result<(), Error> {
7957 let os = self.superblock.offset_size as usize;
7958 // Field layout after the fixed prefix: base / free-space / EOF / driver
7959 // addresses, then the root symbol-table entry (link-name offset, object
7960 // header address, cache type(4), reserved(4), scratch(16)). The prefix is
7961 // 24 bytes for v0 and 28 for v1 (the latter adds indexed-storage-K).
7962 let var_start = if self.superblock.version == 0 { 24 } else { 28 };
7963 let base = self.sb_sig_off + var_start;
7964 let eof_off = base + 2 * os;
7965 let ste = base + 4 * os;
7966 let oh_addr_off = ste + os;
7967 let cache_off = ste + 2 * os;
7968 self.write_at(eof_off, &new_eof.to_le_bytes()[..os])?;
7969 self.write_at(cache_off, &[0u8; 4])?; // cache type = none
7970 self.write_at(cache_off + 8, &[0u8; 16])?; // clear scratch-pad
7971 self.write_at(oh_addr_off, &new_root.to_le_bytes()[..os])?;
7972 Ok(())
7973 }
7974
7975 /// Collect every message of the object header at `addr` into one contiguous
7976 /// region, following continuation blocks across chunks and dropping the
7977 /// `Continuation` messages themselves. Re-emitting the result through
7978 /// [`build_v2_object_header`] collapses a multi-chunk header (as the
7979 /// reference C library often writes) into a single chunk, which is how this
7980 /// editor rebuilds headers. The chunk-0 prefix is validated by
7981 /// [`oh_region_at`]; each continuation block must be a well-formed `OCHK`
7982 /// block within the file.
7983 ///
7984 /// Reads each header chunk out of `src` as one bounded buffer rather than
7985 /// indexing a whole-file image, so this serves a session whose file is not
7986 /// mirrored in memory (issue #198).
7987 fn gather_oh_messages<S: Source + ?Sized>(
7988 src: &S,
7989 addr: u64,
7990 base: BaseAddress,
7991 ) -> Result<OhRegion, Error> {
7992 let chunks = read_oh_chunks(src, addr, base)?;
7993 // Every chunk of one header shares what chunk 0's prefix declared, which
7994 // is what makes concatenating their records into one region well defined,
7995 // and what carries the prefix's optional blocks through to the rebuild.
7996 let mut out = OhRegion::empty(chunks[0].props());
7997 for chunk in &chunks {
7998 let layout = chunk.layout();
7999 let (region, mut p) = chunk.message_region();
8000 while let Some((msg_type, _body, body_end)) = layout.next_message(region, p)? {
8001 if msg_type != MessageType::ObjectHeaderContinuation {
8002 out.push_bytes(®ion[p..body_end]);
8003 }
8004 p = body_end;
8005 }
8006 }
8007 Ok(out)
8008 }
8009
8010 /// Reconstruct a version-1 (symbol-table) group as a fresh v2 compact-link
8011 /// message region: a LinkInfo message, one Link message per existing child,
8012 /// and the group's existing attributes (re-wrapped as v2 messages). The
8013 /// symbol-table message and other non-link/non-attribute messages
8014 /// (modification time, comment, …) are dropped — editing a v0/v1 group
8015 /// converts it to the latest format. Refuses an attribute it cannot
8016 /// reproduce (shared, or larger than a v2 message can hold).
8017 fn reconstruct_v1_group(&self, addr: usize) -> Result<GroupInfo, Error> {
8018 let os = self.superblock.offset_size;
8019 let ls = self.superblock.length_size;
8020 let base = self.superblock.base_address;
8021 let oh = ObjectHeader::parse_from_source(&self.image(), addr as u64, os, ls, base)?;
8022 if oh
8023 .messages
8024 .iter()
8025 .any(|m| m.msg_type == MessageType::DataLayout)
8026 {
8027 return Err(Error::EditUnsupported(
8028 "a target path names a dataset, not a group",
8029 ));
8030 }
8031 let entries = resolve_group_entries_from_source(&self.image(), &oh, os, ls, base)?;
8032
8033 let mut region = fresh_group_region();
8034 let mut link_names = Vec::with_capacity(entries.len());
8035 for e in &entries {
8036 // Group-entry addresses are already stored relative to the base address,
8037 // matching how `encode_link_message` stores link targets — so they are
8038 // re-emitted verbatim, no base conversion needed. A version 1 group
8039 // records no link creation order — the mechanism arrived with the
8040 // Link Info message — so these links carry no creation index, and
8041 // `fresh_group_region` declares none.
8042 region.push_link(&e.name, e.object_header_address, None);
8043 link_names.push(e.name.clone());
8044 }
8045 for m in &oh.messages {
8046 if m.msg_type == MessageType::Attribute {
8047 if m.data.len() > OBJECT_HEADER_MESSAGE_MAX {
8048 return Err(Error::EditUnsupported(
8049 "a v0/v1 group attribute is too large to convert in place",
8050 ));
8051 }
8052 if m.flags & !MSG_FLAG_SHARED != 0 {
8053 // Every other flag bit says something about the message this
8054 // rewrite would have to reproduce and does not — that it is
8055 // constant, that it must not be shared, what a reader should
8056 // do if it cannot decode it. The shared bit is the one this
8057 // conversion knows how to carry.
8058 return Err(Error::EditUnsupported(
8059 "a v0/v1 group attribute message carries an object-header flag this \
8060 conversion cannot reproduce",
8061 ));
8062 }
8063 if m.flags & MSG_FLAG_SHARED != 0 {
8064 // The body is a reference, not an attribute; the reference is
8065 // what gets rewrapped, in the encoding a version 2 header
8066 // uses. Reachable only from a file whose version 1 objects
8067 // share messages, which the reference C library does not
8068 // write — it moves an object to a version 2 header before
8069 // sharing anything on it.
8070 region.push_shared(
8071 MessageType::Attribute,
8072 &modernize_shared_reference(&m.data, os, ls)?,
8073 );
8074 continue;
8075 }
8076 // Re-wrap the attribute message body (it is self-describing) in a
8077 // v2 message record. The rebuilt header does not track creation
8078 // order — a version 1 header cannot have carried any — so the
8079 // attribute needs no creation index.
8080 region.push(MessageType::Attribute, &m.data);
8081 }
8082 }
8083 Ok(GroupInfo { region, link_names })
8084 }
8085
8086 /// Parse and validate a group's object header, returning its message region
8087 /// — the bytes to copy when rewriting the header — and the names of its
8088 /// existing links. A version 2 header is rebuilt from its own message bytes
8089 /// (collapsing continuation chunks, preserving every message); a version 1
8090 /// symbol-table group is converted to v2 via [`reconstruct_v1_group`].
8091 fn inspect_group(&self, addr: usize) -> Result<GroupInfo, Error> {
8092 let sig = self.image().read_metadata_at(addr as u64, 4);
8093 if sig.as_deref() != Ok(&b"OHDR"[..]) {
8094 return self.reconstruct_v1_group(addr);
8095 }
8096 let mut region =
8097 Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)?;
8098 let mut p = 0;
8099 let mut has_link_info = false;
8100 let mut link_names = Vec::new();
8101 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8102 match msg_type {
8103 MessageType::LinkInfo => {
8104 has_link_info = true;
8105 // LinkInfo: version(1) flags(1) [max_creation_index(8) if
8106 // flags&0x01] fractal_heap_addr(8) … — dense storage has a
8107 // defined fractal-heap address. Bound the read by this
8108 // message's own body, not just the region, so a short or
8109 // malformed LinkInfo can't make us read the next message.
8110 let mut q = body + 2;
8111 if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
8112 q += 8;
8113 }
8114 if q + 8 <= body_end {
8115 let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
8116 if heap_addr != u64::MAX {
8117 return Err(Error::EditUnsupported(
8118 "a target group uses dense (fractal-heap) link storage (not supported in place yet)",
8119 ));
8120 }
8121 }
8122 }
8123 MessageType::Link => {
8124 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
8125 link_names.push(link.name);
8126 }
8127 }
8128 MessageType::DataLayout => {
8129 return Err(Error::EditUnsupported(
8130 "a target path names a dataset, not a group",
8131 ));
8132 }
8133 _ => {}
8134 }
8135 p = body_end;
8136 }
8137 if !has_link_info {
8138 return Err(Error::EditUnsupported(
8139 "a target group's object header has no link-info message",
8140 ));
8141 }
8142 // Heal headers written by older hdf5-pure releases that omitted the
8143 // Group Info message, so the rewritten group stays writable by the C
8144 // library.
8145 ensure_group_info(&mut region)?;
8146 Ok(GroupInfo { region, link_names })
8147 }
8148
8149 /// The refusals a staged value overwrite (`write_dataset`) makes from the
8150 /// staged builder alone, with no on-disk header involved.
8151 ///
8152 /// [`stage_dataset_write`](Self::stage_dataset_write) applies these as the
8153 /// write is staged, so the call that configured the builder is the one that
8154 /// reports the mistake. A refusal that reads only `fd` belongs here; one that
8155 /// needs the target's header belongs in
8156 /// [`prepare_write`](Self::prepare_write).
8157 fn refuse_unsupported_overwrite(fd: &FlatDataset) -> Result<(), Error> {
8158 // A value overwrite never introduces chunking, filters, or an extensible
8159 // shape: those would change the storage layout, not just the bytes.
8160 if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
8161 return Err(Error::EditUnsupported(
8162 "write_dataset overwrites values only; it cannot make a dataset \
8163 chunked, filtered, or extensible",
8164 ));
8165 }
8166
8167 // `write_dataset` overwrites element bytes only; it does not touch the
8168 // object header's attribute messages. Attributes staged on the returned
8169 // builder would otherwise be silently dropped (the in-place path rewrites
8170 // only the data block, and the moving path reuses the verbatim on-disk
8171 // header), so refuse rather than degrade — set them in a separate edit.
8172 if !fd.attrs.is_empty() {
8173 return Err(Error::EditUnsupported(
8174 "write_dataset overwrites values only; it cannot set attributes \
8175 (set them with a separate edit)",
8176 ));
8177 }
8178
8179 // `write_dataset` overwrites element bytes only; it reuses the dataset's
8180 // existing Fill Value message (the in-place path rewrites only the data
8181 // block, and the moving path keeps every header message but the layout
8182 // verbatim). A fill value staged on the returned builder would otherwise
8183 // be silently ignored, so refuse rather than degrade — set the fill value
8184 // when the dataset is first created.
8185 if fd.fill.is_some() {
8186 return Err(Error::EditUnsupported(
8187 "write_dataset overwrites values only; it cannot change the fill \
8188 value (set it when the dataset is created)",
8189 ));
8190 }
8191
8192 // `with_vlen_strings` stages placeholder element references, resolved by
8193 // placing their global heap collections and patching the addresses in
8194 // (issue #321). That happens where each plan is written, not here: see
8195 // `OverwriteBytes`.
8196
8197 // `reference_targets` holds elements only the add path resolves, in
8198 // `preflight_reference_targets` and the apply loop after it; the
8199 // overwrite path never reads the field. Checked on the field rather than
8200 // on `with_path_references`, since every producer stages elements that
8201 // are equally unresolved. Left unrefused, a staged overwrite writes
8202 // address-zero placeholders over a working reference dataset and
8203 // `commit` reports `Ok` (issue #318). `with_reference_data` supplies
8204 // resolved addresses and overwrites like any other value.
8205 if fd.reference_targets.is_some() {
8206 return Err(Error::EditUnsupported(
8207 "write_dataset cannot overwrite an object-reference dataset's \
8208 data in place yet",
8209 ));
8210 }
8211
8212 Ok(())
8213 }
8214
8215 /// Preflight a staged value overwrite (`write_dataset`): resolve the dataset
8216 /// at `addr`, validate that the staged `fd` matches it byte-exactly in
8217 /// datatype and shape, and classify how the bytes will be applied. No file
8218 /// bytes are written here — this is part of the all-or-nothing preflight, so a
8219 /// rejected write leaves the commit unapplied.
8220 ///
8221 /// Contiguous, compact, and chunked (including filtered) datasets are all
8222 /// supported; the chunk geometry, filter pipeline, and chunk index come from
8223 /// the on-disk header (a chunk index this engine cannot enumerate — a
8224 /// version-2 B-tree — is refused). A datatype or shape that differs from the
8225 /// on-disk dataset's is likewise refused — this is a value overwrite, not a
8226 /// reshape or retype.
8227 ///
8228 /// Every refusal `fd` can decide on its own has been made by then, where the
8229 /// write is staged.
8230 fn prepare_write<S: Source + ?Sized>(
8231 src: &S,
8232 addr: u64,
8233 fd: &FlatDataset,
8234 base: BaseAddress,
8235 path: &PathKey,
8236 ) -> Result<WritePlan, Error> {
8237 // Enforced by construction: `staged.writes` has one producer, and it
8238 // refuses there. Asserted rather than re-refused because a second caller
8239 // that skipped it would not fail here — a chunked builder would fall
8240 // through and be written as a plain contiguous overwrite, with
8241 // `chunk_options` silently dropped.
8242 debug_assert!(
8243 Self::refuse_unsupported_overwrite(fd).is_ok(),
8244 "a staged write reached prepare_write without refuse_unsupported_overwrite"
8245 );
8246
8247 let region = Self::gather_oh_messages(src, addr, base)?;
8248
8249 // Locate the datatype, dataspace, and data-layout messages, and detect a
8250 // filter pipeline (filtered storage is always chunked, never contiguous).
8251 let mut datatype: Option<(usize, usize)> = None;
8252 let mut dataspace: Option<(usize, usize)> = None;
8253 let mut layout: Option<(usize, usize)> = None;
8254 let mut filter: Option<(usize, usize)> = None;
8255 // The dataset's own fill value, which the edge overhang of a partial
8256 // chunk must hold (issue #296). Both message forms, newest wins.
8257 let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8258 let mut has_link = false;
8259 let mut p = 0;
8260 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8261 match msg_type {
8262 MessageType::Datatype => datatype = Some((body, body_end)),
8263 MessageType::Dataspace => dataspace = Some((body, body_end)),
8264 MessageType::DataLayout => layout = Some((body, body_end)),
8265 MessageType::FilterPipeline => filter = Some((body, body_end)),
8266 // Versioned beats legacy, and within a type the first wins —
8267 // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8268 // apply, so all three agree on a header carrying more than one.
8269 MessageType::FillValue
8270 if !matches!(fill_msg, Some((MessageType::FillValue, ..))) =>
8271 {
8272 fill_msg = Some((msg_type, body, body_end));
8273 }
8274 MessageType::FillValueOld if fill_msg.is_none() => {
8275 fill_msg = Some((msg_type, body, body_end));
8276 }
8277 MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
8278 has_link = true;
8279 }
8280 _ => {}
8281 }
8282 p = body_end;
8283 }
8284
8285 if has_link {
8286 return Err(Error::EditUnsupported(
8287 "write_dataset target is a group, not a dataset",
8288 ));
8289 }
8290 let (dt_b, dt_e) =
8291 datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
8292 let (ds_b, ds_e) =
8293 dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
8294 let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
8295
8296 // Compare datatype and shape structurally against the staged data. A
8297 // value overwrite must keep both exactly: the datatype (including its
8298 // class, size, endianness, and any compound/array/enumeration layout) so
8299 // the bytes are interpreted the same, and the *current* dimensions so the
8300 // byte count is unchanged. Parsing both sides and comparing the decoded
8301 // values — rather than the raw message bytes — tolerates the harmless
8302 // encoding differences between this crate's writer and the reference C
8303 // library (e.g. the C library records a maximum-dimensions array equal to
8304 // the current dimensions, which this crate omits) while still refusing any
8305 // real retype or reshape.
8306 let (disk_dt, _) = crate::datatype::Datatype::parse(®ion[dt_b..dt_e])
8307 .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
8308 if disk_dt != fd.dt {
8309 return Err(Error::EditUnsupported(
8310 "write_dataset datatype does not match the on-disk dataset (overwrite, not retype)",
8311 ));
8312 }
8313 let disk_ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE)
8314 .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
8315 if disk_ds.space_type != fd.ds.space_type
8316 || disk_ds.rank != fd.ds.rank
8317 || disk_ds.dimensions != fd.ds.dimensions
8318 {
8319 return Err(Error::EditUnsupported(
8320 "write_dataset shape does not match the on-disk dataset (overwrite, not reshape)",
8321 ));
8322 }
8323
8324 // Classify the layout. Version 3/4 compact (class 0), contiguous (class
8325 // 1), and chunked (class 2) are supported; an old-version layout or a
8326 // virtual layout (class 3) is refused.
8327 if le - lb < 2 {
8328 return Err(Error::EditUnsupported("malformed data-layout message"));
8329 }
8330 let version = region[lb];
8331 if version != 3 && version != 4 {
8332 return Err(Error::EditUnsupported(
8333 "an unsupported data-layout version cannot be overwritten in place yet",
8334 ));
8335 }
8336 match region[lb + 1] {
8337 // Compact: the data is inline in the header. Rebuild the header with
8338 // the new inline bytes (relocating it), patching the parent link.
8339 0 => Ok(WritePlan::Moving(MovingWrite::Compact {
8340 region,
8341 bytes: staged_bytes(fd, path),
8342 })),
8343 1 => {
8344 if le - lb < 18 {
8345 return Err(Error::EditUnsupported("malformed contiguous data layout"));
8346 }
8347 let addr_off = lb + 2;
8348 let data_addr =
8349 u64::from_le_bytes(region[addr_off..addr_off + 8].try_into().unwrap());
8350 let data_size = u64::from_le_bytes(region[lb + 10..lb + 18].try_into().unwrap());
8351
8352 // Same length and a defined, in-bounds data block: overwrite the
8353 // bytes straight in place. No header rewrite, no relink. The stored
8354 // address is base-relative; the in-place write targets the absolute
8355 // file offset `data_addr + base`.
8356 //
8357 // Never for a staged variable-length overwrite, which relocates
8358 // instead. Its resolution *allocates* — a global heap collection,
8359 // drawn from a freed region where one fits — and an in-place write
8360 // is a live mutation of a block the current root already reaches.
8361 // Together those put a reference to a just-allocated span inside
8362 // bytes the live tree names, so a commit failing after the write
8363 // and before its repoint hands the span back to the free list
8364 // (`restore_free`) while the image still points into it. The next
8365 // commit is then free to place something else there, and the
8366 // dataset reads that object's bytes with every checksum intact.
8367 //
8368 // A relocating write has no such window: its new data block is
8369 // reachable from nothing until the superblock repoint, so a
8370 // failed attempt leaves the region genuinely dead and re-offering
8371 // it is sound — which is the invariant `restore_free` states.
8372 if fd.vl_string_staging.is_none()
8373 && data_addr != UNDEF
8374 && data_size == fd.raw.len() as u64
8375 {
8376 if let Some(start) = base
8377 .absolute(data_addr)
8378 .ok()
8379 .and_then(|a| usize::try_from(a).ok())
8380 {
8381 if start
8382 .checked_add(fd.raw.len())
8383 .is_some_and(|e| e as u64 <= src.len())
8384 {
8385 return Ok(WritePlan::InPlace {
8386 data_addr: start,
8387 bytes: staged_bytes(fd, path),
8388 });
8389 }
8390 }
8391 }
8392
8393 // Length differs or the block was undefined/out of bounds: the new
8394 // data goes elsewhere and the old extent (if any) is freed. The
8395 // freed extent is recorded as an absolute file offset (`+ base`) to
8396 // match the session free list.
8397 let old_extent = if data_addr != UNDEF && data_size > 0 {
8398 Some((base.absolute(data_addr)?, data_size))
8399 } else {
8400 None
8401 };
8402 Ok(WritePlan::Moving(MovingWrite::Contiguous {
8403 region,
8404 addr_off,
8405 bytes: staged_bytes(fd, path),
8406 old_extent,
8407 }))
8408 }
8409 // Chunked: overwrite each chunk in place when every new (re-encoded)
8410 // chunk is the same byte length as its slot, else rebuild and relocate
8411 // the whole chunk storage. The chunk geometry, filter pipeline, and
8412 // index type all come from the existing on-disk header (the staged
8413 // builder carries none — chunked/filtered/extensible builders are
8414 // refused at the top of this function as "not a value overwrite").
8415 2 => {
8416 // Chunked overwrite (in-place or relocating). On a userblock file
8417 // every stored chunk-index and chunk address is relative to `base`:
8418 // the in-place path below walks the index on a base-relative view of
8419 // the file and shifts the resulting write offsets back by `base`,
8420 // and the relocating path rebuilds the chunk blob with stored
8421 // addresses (see `write_chunked_relocatable`).
8422 let dl =
8423 DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
8424 Error::EditUnsupported("dataset header data layout could not be parsed")
8425 })?;
8426 let DataLayout::Chunked {
8427 version: lversion,
8428 chunk_index_type,
8429 ..
8430 } = dl
8431 else {
8432 return Err(Error::EditUnsupported("dataset is not chunked"));
8433 };
8434 if !chunk_index_enumerable(lversion, chunk_index_type) {
8435 return Err(Error::EditUnsupported(
8436 "a chunked dataset with a version-2 B-tree or unknown chunk index \
8437 cannot be overwritten in place yet",
8438 ));
8439 }
8440
8441 let ChunkedGeometry {
8442 spatial,
8443 element_size,
8444 raw_size,
8445 maxshape,
8446 } = chunked_geometry(&fd.dt, &disk_ds, &dl)?;
8447
8448 // Split the new value into full-size chunk buffers in dense
8449 // row-major grid order, then re-encode through the on-disk
8450 // pipeline when the dataset is filtered.
8451 // The overhang past the dataset's edge holds the dataset's own
8452 // fill value, not zeros: an allocated chunk is expected to carry
8453 // it wherever nothing was written, and those slots are what a
8454 // reader returns once the dataset is extended into them (#296).
8455 let padding = fill_msg
8456 .map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8457 crate::fill_value::PaddingFill::from_message(mt, ®ion[b..e])
8458 });
8459 let pipeline_message: Option<Vec<u8>> =
8460 filter.map(|(fb, fe)| region[fb..fe].to_vec());
8461
8462 // Refuse a pipeline this engine cannot re-encode before anything
8463 // is split or encoded, so the refusal reaches the preflight on
8464 // both branches below — including the staged one, which does its
8465 // splitting in the apply phase and so has no other chance to make
8466 // it.
8467 if let Some(pm) = &pipeline_message {
8468 let pipeline = FilterPipeline::parse(pm).map_err(|_| {
8469 Error::EditUnsupported("dataset filter pipeline could not be parsed")
8470 })?;
8471 if !pipeline_reencodable(&pipeline) {
8472 return Err(Error::EditUnsupported(
8473 "a chunked dataset using a filter this engine cannot re-encode \
8474 cannot be overwritten in place yet",
8475 ));
8476 }
8477 }
8478
8479 // Element bytes still carrying unresolved variable-length
8480 // references cannot be split here: a filtered chunk's compressed
8481 // length depends on the heap addresses patched into it, so the
8482 // split has to follow the patch, which follows a placement the
8483 // preflight may not make. See `ChunkPayload::Deferred`.
8484 if fd.vl_string_staging.is_some() {
8485 return Ok(WritePlan::Moving(MovingWrite::Chunked {
8486 region,
8487 shape: disk_ds.dimensions.clone(),
8488 chunk_dims: spatial,
8489 element_size,
8490 maxshape,
8491 pipeline_message,
8492 payload: ChunkPayload::Deferred {
8493 bytes: staged_bytes(fd, path),
8494 padding,
8495 dt: fd.dt.clone(),
8496 },
8497 old_addr: addr,
8498 }));
8499 }
8500
8501 let new_chunk_bytes = split_and_encode_chunks(
8502 &fd.raw,
8503 &disk_ds.dimensions,
8504 &spatial,
8505 element_size,
8506 &padding,
8507 pipeline_message.as_deref(),
8508 &fd.dt,
8509 )?;
8510
8511 // Fast path: overwrite each chunk straight in its slot when every
8512 // new chunk fits. No header rewrite and no superblock flip — the
8513 // chunk (and index) blocks are reachable from both roots. The index
8514 // is left untouched when chunks keep their size and rebuilt in place
8515 // when they shrink. The index walk runs on a base-relative view of
8516 // the file (so the layout's stored addresses index correctly), and
8517 // the returned write offsets are shifted back to absolute file
8518 // offsets by adding `base` (a no-op on a base-0 file).
8519 let base_off = usize::try_from(base.get()).map_err(|_| {
8520 Error::EditUnsupported("userblock base address exceeds this platform")
8521 })?;
8522 if let Some(writes) = try_inplace_chunk_writes(
8523 &BaseOffsetSource { inner: src, base },
8524 &dl,
8525 &disk_ds,
8526 &spatial,
8527 raw_size,
8528 &new_chunk_bytes,
8529 ) {
8530 let writes = writes
8531 .into_iter()
8532 .map(|(off, b)| (off + base_off, b))
8533 .collect();
8534 return Ok(WritePlan::InPlaceChunks { writes });
8535 }
8536
8537 // Otherwise relocate: rebuild a fresh chunk blob + index at
8538 // end-of-file (carrying the re-encoded chunk bytes and the source
8539 // pipeline verbatim), swap the data-layout message in the verbatim
8540 // header, and free the old chunk storage after the commit lands.
8541 Ok(WritePlan::Moving(MovingWrite::Chunked {
8542 region,
8543 shape: disk_ds.dimensions.clone(),
8544 chunk_dims: spatial,
8545 element_size,
8546 maxshape,
8547 pipeline_message,
8548 payload: ChunkPayload::Encoded(new_chunk_bytes),
8549 old_addr: addr,
8550 }))
8551 }
8552 _ => Err(Error::EditUnsupported(
8553 "an unsupported data-layout class cannot be overwritten in place yet",
8554 )),
8555 }
8556 }
8557
8558 /// Plan a relocating append to an existing chunked, unlimited,
8559 /// Extensible-Array-indexed dataset at `addr`. Validates the target, splits
8560 /// the appended elements into new (and one rewritten trailing) chunks —
8561 /// compressed through the on-disk pipeline when filtered — and gathers the
8562 /// existing complete chunks by metadata alone. Returns the
8563 /// [`MovingWrite::AppendedChunks`] plan; the commit machinery appends the new
8564 /// chunks and a rebuilt index and repoints the header (see
8565 /// [`write_appended_chunks`](Self::write_appended_chunks)).
8566 ///
8567 /// Reads only; no bytes are written here. `src` is the file image and `base`
8568 /// its userblock base; the dataset's stored (base-relative) structures are read
8569 /// through a `base`-shifted view.
8570 fn prepare_append<S: Source + ?Sized>(
8571 src: &S,
8572 addr: u64,
8573 ab: &AppendBuilder,
8574 base: BaseAddress,
8575 ) -> Result<MovingWrite, Error> {
8576 if ab.dt_conflict {
8577 return Err(Error::AppendUnsupported(
8578 "append mixes element types in one builder; use one element type per \
8579 append_dataset call",
8580 ));
8581 }
8582
8583 let region = Self::gather_oh_messages(src, addr, base)?;
8584
8585 // Locate the datatype, dataspace, data-layout, and filter-pipeline
8586 // messages, and detect a group (link) header.
8587 let mut datatype: Option<(usize, usize)> = None;
8588 let mut dataspace: Option<(usize, usize)> = None;
8589 let mut layout: Option<(usize, usize)> = None;
8590 let mut filter: Option<(usize, usize)> = None;
8591 // The dataset's own fill value, which the edge overhang of a partial
8592 // chunk must hold (issue #296). Both message forms, newest wins.
8593 let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8594 let mut has_link = false;
8595 let mut p = 0;
8596 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8597 match msg_type {
8598 MessageType::Datatype => datatype = Some((body, body_end)),
8599 MessageType::Dataspace => dataspace = Some((body, body_end)),
8600 MessageType::DataLayout => layout = Some((body, body_end)),
8601 MessageType::FilterPipeline => filter = Some((body, body_end)),
8602 // Versioned beats legacy, and within a type the first wins —
8603 // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8604 // apply, so all three agree on a header carrying more than one.
8605 MessageType::FillValue
8606 if !matches!(fill_msg, Some((MessageType::FillValue, ..))) =>
8607 {
8608 fill_msg = Some((msg_type, body, body_end));
8609 }
8610 MessageType::FillValueOld if fill_msg.is_none() => {
8611 fill_msg = Some((msg_type, body, body_end));
8612 }
8613 MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
8614 has_link = true;
8615 }
8616 _ => {}
8617 }
8618 p = body_end;
8619 }
8620 if has_link {
8621 return Err(Error::AppendUnsupported(
8622 "append target is a group, not a dataset",
8623 ));
8624 }
8625 let (dt_b, dt_e) =
8626 datatype.ok_or(Error::AppendUnsupported("dataset header has no datatype"))?;
8627 let (ds_b, ds_e) =
8628 dataspace.ok_or(Error::AppendUnsupported("dataset header has no dataspace"))?;
8629 let (lb, le) = layout.ok_or(Error::AppendUnsupported(
8630 "dataset header has no data layout",
8631 ))?;
8632
8633 let (disk_dt, _) = Datatype::parse(®ion[dt_b..dt_e])
8634 .map_err(|_| Error::AppendUnsupported("dataset header datatype could not be parsed"))?;
8635 let disk_ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE).map_err(|_| {
8636 Error::AppendUnsupported("dataset header dataspace could not be parsed")
8637 })?;
8638 let dl = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
8639 Error::AppendUnsupported("dataset header data layout could not be parsed")
8640 })?;
8641
8642 // Require chunked, data-layout version 4, Extensible-Array index (type 4).
8643 let DataLayout::Chunked {
8644 version: lversion,
8645 chunk_index_type,
8646 btree_address,
8647 ..
8648 } = &dl
8649 else {
8650 return Err(Error::AppendUnsupported(
8651 "append requires a chunked dataset",
8652 ));
8653 };
8654 if *lversion != 4 || *chunk_index_type != Some(4) {
8655 return Err(Error::AppendUnsupported(
8656 "append requires an Extensible-Array-indexed chunked dataset (a single \
8657 unlimited dimension under the latest format)",
8658 ));
8659 }
8660
8661 // Require rank 1, unlimited along axis 0.
8662 if disk_ds.space_type != DataspaceType::Simple || disk_ds.dimensions.len() != 1 {
8663 return Err(Error::AppendUnsupported(
8664 "append requires a rank-1 dataset in this release",
8665 ));
8666 }
8667 match &disk_ds.max_dimensions {
8668 Some(md) if md.first() == Some(&u64::MAX) => {}
8669 _ => {
8670 return Err(Error::AppendUnsupported(
8671 "append requires a dataset that is unlimited along its first dimension",
8672 ));
8673 }
8674 }
8675
8676 let ChunkedGeometry {
8677 spatial,
8678 element_size,
8679 ..
8680 } = chunked_geometry(&disk_dt, &disk_ds, &dl)?;
8681 let chunk_elems = spatial[0];
8682 if chunk_elems == 0 {
8683 return Err(Error::AppendUnsupported(
8684 "append requires a nonzero chunk length",
8685 ));
8686 }
8687
8688 // Validate the appended bytes against the on-disk element type.
8689 if ab.raw.len() % element_size != 0 {
8690 return Err(Error::AppendUnsupported(
8691 "appended byte length is not a whole number of elements",
8692 ));
8693 }
8694 match &ab.elem_dt {
8695 // A typed append must match the on-disk datatype exactly (class, size,
8696 // and byte order) — this is a value append, not a retype.
8697 Some(expected) if *expected != disk_dt => {
8698 return Err(Error::AppendUnsupported(
8699 "append datatype does not match the on-disk dataset (wrong element \
8700 type or byte order)",
8701 ));
8702 }
8703 Some(_) => {}
8704 // A raw append trusts the caller's bytes but still refuses any datatype
8705 // whose flat little-endian bytes cannot be written verbatim: a
8706 // big-endian numeric leaf would silently misencode, and a
8707 // variable-length or reference leaf embeds heap/object addresses a byte
8708 // append cannot reproduce. A typed append is byte-order- and
8709 // class-checked by the datatype-equality arm above.
8710 None => {
8711 if !datatype_is_raw_appendable(&disk_dt) {
8712 return Err(Error::AppendUnsupported(
8713 "append_raw onto this dataset's datatype (non-little-endian, \
8714 variable-length, or reference) could misencode the bytes; use a \
8715 typed append",
8716 ));
8717 }
8718 }
8719 }
8720
8721 let new_elems = (ab.raw.len() / element_size) as u64;
8722 let current_dim0 = disk_ds.dimensions[0];
8723 let new_dim0 = current_dim0
8724 .checked_add(new_elems)
8725 .ok_or(Error::AppendUnsupported(
8726 "append would overflow the dataset dimension",
8727 ))?;
8728
8729 // The filter pipeline is preserved verbatim in the rebuilt header; parse it
8730 // to re-encode the new chunks. An engine-unencodable filter is refused.
8731 let pipeline_message: Option<Vec<u8>> = filter.map(|(fb, fe)| region[fb..fe].to_vec());
8732 let has_filters = pipeline_message.is_some();
8733 let pipeline = match &pipeline_message {
8734 Some(pm) => {
8735 let parsed = FilterPipeline::parse(pm).map_err(|_| {
8736 Error::AppendUnsupported("dataset filter pipeline could not be parsed")
8737 })?;
8738 if !pipeline_reencodable(&parsed) {
8739 return Err(Error::AppendUnsupported(
8740 "dataset uses a filter this engine cannot re-encode",
8741 ));
8742 }
8743 Some(parsed)
8744 }
8745 None => None,
8746 };
8747
8748 if base.get() > src.len() {
8749 return Err(Error::AppendUnsupported(
8750 "userblock base address past end-of-file",
8751 ));
8752 }
8753 let view = BaseOffsetSource { inner: src, base };
8754
8755 // The rebuilt index's element format (bare address vs address+size+mask) is
8756 // chosen by `has_filters`; it must agree with the source index's client id,
8757 // or the kept chunks — carried by metadata into the new index — would be
8758 // re-encoded in the wrong element width.
8759 if let Some(idx_addr) = *btree_address {
8760 let hdr =
8761 ExtensibleArrayHeader::parse_from_source(&view, idx_addr, OFFSET_SIZE, LENGTH_SIZE)
8762 .map_err(|_| {
8763 Error::AppendUnsupported(
8764 "dataset extensible-array header could not be parsed",
8765 )
8766 })?;
8767 if (hdr.client_id == 1) != has_filters {
8768 return Err(Error::AppendUnsupported(
8769 "dataset filter metadata is inconsistent (chunk-index client id \
8770 disagrees with the filter pipeline)",
8771 ));
8772 }
8773 }
8774
8775 // Enumerate the existing chunks (base-relative addresses) and require a
8776 // dense grid: `plan_dense_grid` returns the chunks in index order and
8777 // `None` on any hole, duplicate, or count mismatch against the dimension.
8778 let infos = enumerate_chunks_from_source(&view, &dl, &disk_ds, OFFSET_SIZE, LENGTH_SIZE)
8779 .map_err(|_| Error::AppendUnsupported("dataset chunk index could not be enumerated"))?;
8780 let grid = plan_dense_grid(infos, &disk_ds.dimensions, &spatial).ok_or(
8781 Error::AppendUnsupported(
8782 "dataset has a sparse or inconsistent chunk grid; cannot append",
8783 ),
8784 )?;
8785 let grid_order = grid.grid_order;
8786
8787 // Complete chunks are kept by metadata; a trailing partial chunk (when the
8788 // current length is not chunk-aligned) is rewritten.
8789 let n_full = usize::try_from(current_dim0 / chunk_elems)
8790 .map_err(|_| Error::AppendUnsupported("chunk count exceeds this platform"))?;
8791 let has_partial = current_dim0 % chunk_elems != 0;
8792
8793 // Rewriting that chunk decodes and re-encodes committed values, which a
8794 // *lossy* pipeline does not reproduce: a ZFP block re-quantizes to fit
8795 // its new contents, and float D-scale packs offsets from a per-chunk
8796 // minimum that a smaller appended value moves (measured: 1.45 became 1.5
8797 // at one decimal). Refused here as on the immediate path (#407).
8798 if has_partial
8799 && let Some(pl) = &pipeline
8800 && !pipeline_lossless(pl)
8801 {
8802 return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
8803 }
8804
8805 let mut kept_chunks: Vec<WrittenChunk> = Vec::with_capacity(n_full);
8806 for ci in grid_order.iter().take(n_full) {
8807 kept_chunks.push(WrittenChunk {
8808 address: ci.address,
8809 compressed_size: u64::from(ci.chunk_size),
8810 // Preserve the source mask verbatim: a C/h5py file records a nonzero
8811 // mask for a chunk whose filter was skipped (e.g. deflate on
8812 // incompressible data), and forcing it to 0 would corrupt that chunk.
8813 filter_mask: ci.filter_mask,
8814 });
8815 }
8816
8817 // Build the raw byte region for the tail (from the last chunk boundary to
8818 // the new end): the live prefix of any rewritten partial chunk, then the
8819 // appended bytes.
8820 let mut tail_raw: Vec<u8> = Vec::new();
8821 let mut old_tail_extent: Option<(u64, u64)> = None;
8822 if has_partial {
8823 let partial = &grid_order[n_full];
8824 let len = partial.chunk_size as usize;
8825 partial
8826 .address
8827 .checked_add(len as u64)
8828 .filter(|&e| e <= view.len())
8829 .ok_or(Error::AppendUnsupported(
8830 "trailing chunk extends past end-of-file",
8831 ))?;
8832 let stored = view
8833 .read_exact_at(partial.address, len)
8834 .map_err(|_| Error::AppendUnsupported("trailing chunk could not be read"))?;
8835 let full = if let Some(pl) = &pipeline {
8836 let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8837 decompress_chunk(&stored, pl, ctx, partial.filter_mask).map_err(Error::Format)?
8838 } else {
8839 stored
8840 };
8841 let live_elems = usize::try_from(current_dim0 % chunk_elems)
8842 .map_err(|_| Error::AppendUnsupported("chunk length exceeds this platform"))?;
8843 let live_bytes = live_elems * element_size.get();
8844 if full.len() < live_bytes {
8845 return Err(Error::AppendUnsupported(
8846 "trailing chunk decoded shorter than its live element count",
8847 ));
8848 }
8849 tail_raw.extend_from_slice(&full[..live_bytes]);
8850 // The old partial chunk's data block is dead once the new index lands.
8851 old_tail_extent = Some((
8852 base.absolute(partial.address)?,
8853 u64::from(partial.chunk_size),
8854 ));
8855 }
8856 tail_raw.extend_from_slice(&ab.raw);
8857
8858 // Split the tail into full chunk buffers and compress each through the
8859 // pipeline when filtered. The final chunk's overhang takes the dataset's
8860 // fill value, which is what a reader returns from those slots once a
8861 // later append or resize reaches them (#296).
8862 let tail_len_elems = new_dim0 - (n_full as u64) * chunk_elems;
8863 let padding = fill_msg.map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8864 crate::fill_value::PaddingFill::from_message(mt, ®ion[b..e])
8865 });
8866 let split = split_into_chunks(
8867 &tail_raw,
8868 &[tail_len_elems],
8869 &spatial,
8870 element_size,
8871 padding.pattern(element_size),
8872 )
8873 .map_err(Error::Format)?;
8874 let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pl) = &pipeline {
8875 let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8876 let mut out = Vec::with_capacity(split.len());
8877 // One encoder across the appended tail; see `FilterScratch`.
8878 let mut scratch = FilterScratch::new();
8879 for buf in &split {
8880 out.push(compress_chunk_with(&mut scratch, buf, pl, ctx).map_err(Error::Format)?);
8881 }
8882 out
8883 } else {
8884 split
8885 };
8886
8887 // Grow the dataspace along axis 0, preserving the (unlimited) max-dims.
8888 let mut grown = disk_ds.clone();
8889 grown.dimensions[0] = new_dim0;
8890 let new_dataspace_body = grown.serialize(LENGTH_SIZE);
8891
8892 #[expect(
8893 clippy::cast_possible_truncation,
8894 reason = "spatial chunk dims come from the on-disk u32 chunk_dimensions, so they fit u32"
8895 )]
8896 let chunk_dims_u32: Vec<u32> = spatial.iter().map(|&dm| dm as u32).collect();
8897
8898 Ok(MovingWrite::AppendedChunks {
8899 region,
8900 new_dataspace_body,
8901 chunk_dims_u32,
8902 element_size,
8903 has_filters,
8904 kept_chunks,
8905 new_chunk_bytes,
8906 old_addr: addr,
8907 old_tail_extent,
8908 })
8909 }
8910
8911 /// Parse the object header at `addr` into a copyable model, validating that
8912 /// every message can be reproduced faithfully (verbatim message bytes, with
8913 /// only the contiguous data address and child link targets repointed).
8914 /// Dense (fractal-heap) attribute storage is read out of the source heap into
8915 /// a parsed attribute set carried on the model (`dense_attrs`) and re-emitted
8916 /// into a fresh heap on write, within the bounds that heap declares (see
8917 /// `file_writer::dense_attrs_check`); an attribute too large to hold as a
8918 /// managed object is re-emitted as a *huge* object. Rejects multi-chunk
8919 /// headers, dense or soft/external links, old-version data layouts, external
8920 /// data storage ([`reject_external_storage`]), and headers that are neither a
8921 /// dataset nor a group. (A *chunked* layout is modelled, not rejected —
8922 /// [`ObjModel::DatasetChunked`].)
8923 fn read_object<S: Source + ?Sized>(
8924 src: &S,
8925 addr: u64,
8926 base: BaseAddress,
8927 ) -> Result<ObjModel, Error> {
8928 let region = Self::gather_oh_messages(src, addr, base)?;
8929
8930 // Ahead of the layout classification below, where external storage and
8931 // never-allocated storage would otherwise share one undefined address
8932 // and one answer — and ahead of the dense-attribute read, so a header
8933 // this cannot copy says why rather than failing as something else.
8934 reject_external_storage(®ion)?;
8935
8936 // First pass: detect whether attributes are stored densely (a defined
8937 // fractal-heap address in the Attribute Info message). A dense object is
8938 // copied by reading its attributes out of the source heap and rebuilding
8939 // a fresh heap on write, so its Attribute Info message and any inline
8940 // Attribute messages are dropped from the verbatim region — the rebuilt
8941 // region carries neither, and `dense_attrs` carries the parsed set.
8942 let mut dense = false;
8943 let mut p = 0;
8944 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8945 if msg_type == MessageType::AttributeInfo {
8946 // An Attribute Info message does not by itself mean dense
8947 // storage: the reference C library and h5py emit one (with an
8948 // *undefined* fractal-heap address) even for compact, inline
8949 // attributes in the latest format, to carry attribute
8950 // creation-order metadata. Only a *defined* heap address is real
8951 // dense (fractal-heap) storage. A message that cannot be parsed
8952 // is refused conservatively.
8953 let ai = crate::attribute_info::AttributeInfoMessage::parse(
8954 ®ion[body..body_end],
8955 OFFSET_SIZE,
8956 )
8957 .map_err(|_| {
8958 Error::EditUnsupported(
8959 "a source attribute-info message could not be parsed for copying",
8960 )
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, 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>, u64)> = 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::AttributeInfo => {
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::LinkInfo => {
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::DataLayout => {
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 =
9084 u64::from_le_bytes(kept[lbody + 2..lbody + 10].try_into().unwrap());
9085 let data_size =
9086 u64::from_le_bytes(kept[lbody + 10..lbody + 18].try_into().unwrap());
9087 Ok(ObjModel::DatasetContiguous {
9088 region: kept,
9089 addr_off: lbody + 2,
9090 data_addr,
9091 data_size,
9092 dense_attrs,
9093 })
9094 }
9095 // Chunked: the verbatim header carries the data-layout and filter-
9096 // pipeline messages; `read_copy_subtree` (which holds the source
9097 // buffer) enumerates and captures the chunk bytes and rebuilds the
9098 // index on write.
9099 2 => Ok(ObjModel::DatasetChunked {
9100 region: kept,
9101 dense_attrs,
9102 }),
9103 _ => Err(Error::EditUnsupported(
9104 "an unsupported data-layout class cannot be copied in place yet",
9105 )),
9106 }
9107 } else if has_link_info {
9108 // A copied group must carry a Group Info message so the copy stays
9109 // writable by the C library, even when the source omitted it.
9110 ensure_group_info(&mut kept)?;
9111 Ok(ObjModel::Group {
9112 non_link_region: kept,
9113 children,
9114 dense_attrs,
9115 })
9116 } else {
9117 Err(Error::EditUnsupported(
9118 "an object is neither a contiguous/compact dataset nor a group",
9119 ))
9120 }
9121 }
9122
9123 /// Read the object at `addr` in the source buffer `d` — and, for a group, its
9124 /// whole subtree — into an owned [`CopyTree`], the read half of an object copy.
9125 /// No bytes are written; this both validates that the subtree is copyable and
9126 /// captures the bytes the write half ([`write_copy_subtree`](Self::write_copy_subtree))
9127 /// later appends, so the source buffer need not outlive the read.
9128 ///
9129 /// `src` is the image the source object lives in: this session's own file image
9130 /// for an in-file [`copy`](Self::copy), or another file's image for a cross-file
9131 /// [`copy_from`](Self::copy_from). `base` is that image's userblock base (the
9132 /// session's own base for an in-file copy, always 0 for a cross-file copy, whose
9133 /// source is gated to base 0): the stored, base-relative addresses read out of
9134 /// the source headers are shifted by it to index `src`. When `cross_file` is set,
9135 /// every copied object header is additionally screened by
9136 /// [`reject_foreign_addresses`] — verbatim bytes that embed a *source-file*
9137 /// absolute address (variable-length or reference data, a committed datatype)
9138 /// would dangle in another file and are refused, whereas an in-file copy keeps
9139 /// them valid by sharing the source file's heaps and objects.
9140 fn read_copy_subtree<S: Source + ?Sized>(
9141 src: &S,
9142 addr: u64,
9143 depth: u32,
9144 cross_file: bool,
9145 base: BaseAddress,
9146 ) -> Result<CopyTree, Error> {
9147 if depth >= MAX_COPY_DEPTH {
9148 return Err(Error::EditUnsupported(
9149 "copy source nests too deeply (possible hard-link cycle)",
9150 ));
9151 }
9152 // `base` is the userblock base of the image `src`: this session's own base
9153 // for an in-file copy, and always 0 for a cross-file copy (the source is
9154 // gated to base 0 in `copy_from`). `addr` is an absolute offset into `src`;
9155 // the stored (base-relative) addresses `read_object` returns for contiguous
9156 // data, chunk storage, and child links are converted to absolute offsets by
9157 // adding `base` before `src` is read or a child is descended into.
9158 match Self::read_object(src, addr, base)? {
9159 ObjModel::DatasetVerbatim {
9160 region,
9161 dense_attrs,
9162 } => {
9163 if cross_file {
9164 reject_foreign_addresses(®ion)?;
9165 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9166 }
9167 Ok(CopyTree::DatasetVerbatim {
9168 region,
9169 dense_attrs,
9170 })
9171 }
9172 ObjModel::DatasetContiguous {
9173 region,
9174 addr_off,
9175 data_addr,
9176 data_size,
9177 dense_attrs,
9178 } => {
9179 if cross_file {
9180 reject_foreign_addresses(®ion)?;
9181 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9182 }
9183 // Storage the source never allocated is copied as storage, not as
9184 // the values reading it answers with. The reference library does
9185 // not allocate a contiguous dataset's data until something is
9186 // written to it, leaving the layout message's address undefined,
9187 // and reading one answers the fill value for every element (#292)
9188 // — so a copy that materialized what it read would turn a
9189 // schema-only dataset into a fully written one of the size its
9190 // shape declares. `repack` carries the same shape through as
9191 // unallocated (#293); this is the copy path's half of it (#336).
9192 //
9193 // Only reached once external storage is out of the way: it uses
9194 // this same undefined address for a dataset that *does* hold
9195 // data, and `read_object` refuses it by name above.
9196 let data = if data_addr == UNDEF {
9197 None
9198 } else {
9199 // The stored data address is base-relative; shift it to an absolute
9200 // offset into `src` before reading the data block out.
9201 let start = base.absolute(data_addr).map_err(|_| {
9202 Error::EditUnsupported("data address exceeds this platform")
9203 })?;
9204 let len = usize::try_from(data_size)
9205 .map_err(|_| Error::EditUnsupported("data size exceeds this platform"))?;
9206 start
9207 .checked_add(len as u64)
9208 .filter(|&e| e <= src.len())
9209 .ok_or(Error::EditUnsupported("dataset data is out of bounds"))?;
9210 Some(
9211 src.read_exact_at(start, len)
9212 .map_err(|_| Error::EditUnsupported("dataset data is out of bounds"))?,
9213 )
9214 };
9215 Ok(CopyTree::DatasetContiguous {
9216 region,
9217 addr_off,
9218 data,
9219 dense_attrs,
9220 })
9221 }
9222 ObjModel::DatasetChunked {
9223 region,
9224 dense_attrs,
9225 } => {
9226 // Screen the verbatim header on the cross-file path. This refuses a
9227 // variable-length or reference datatype (whose chunk payload embeds
9228 // source-file global-heap / object addresses that would dangle in
9229 // another file) and any shared message — exactly the forms repack
9230 // also refuses for a cross-file verbatim chunk copy. An in-file copy
9231 // keeps them valid by sharing the source file's heaps.
9232 if cross_file {
9233 reject_foreign_addresses(®ion)?;
9234 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9235 }
9236 let ChunkedHeaderParts {
9237 dt,
9238 ds,
9239 layout,
9240 pipeline_message,
9241 } = parse_chunked_header(®ion)?;
9242 let DataLayout::Chunked {
9243 version: lversion,
9244 chunk_index_type,
9245 ..
9246 } = layout
9247 else {
9248 return Err(Error::EditUnsupported("dataset is not chunked"));
9249 };
9250 if !chunk_index_enumerable(lversion, chunk_index_type) {
9251 return Err(Error::EditUnsupported(
9252 "a chunked dataset with a version-2 B-tree or unknown chunk index \
9253 cannot be copied in place yet",
9254 ));
9255 }
9256 let ChunkedGeometry {
9257 spatial: chunk_dims,
9258 element_size,
9259 raw_size: _,
9260 maxshape,
9261 } = chunked_geometry(&dt, &ds, &layout)?;
9262
9263 // The layout's chunk-index address and every chunk address it leads
9264 // to are stored base-relative, so enumerate and read on a
9265 // base-relative view of the source image (the identity on a base-0
9266 // file). The returned addresses are then offsets into `dview`.
9267 let dview = BaseOffsetSource { inner: src, base };
9268
9269 // Enumerate the source chunks and map them onto a dense grid; a
9270 // sparse (holed/unallocated) dataset cannot be reproduced by the
9271 // verbatim layout path, which needs every grid slot filled.
9272 let infos =
9273 enumerate_chunks_from_source(&dview, &layout, &ds, OFFSET_SIZE, LENGTH_SIZE)?;
9274 let grid = plan_dense_grid(infos, &ds.dimensions, &chunk_dims).ok_or(
9275 Error::EditUnsupported(
9276 "a chunked dataset with unallocated (sparse) chunks cannot be copied in place yet",
9277 ),
9278 )?;
9279 if grid.grid_order.is_empty() {
9280 return Err(Error::EditUnsupported(
9281 "an empty chunked dataset cannot be copied in place yet",
9282 ));
9283 }
9284
9285 // Capture each chunk's already-compressed bytes (no decode) into an
9286 // owned buffer, in dense row-major grid order, so the copy can be
9287 // written after the source buffer is gone (cross-file copy reads at
9288 // staging time). Sizes and masks are carried verbatim.
9289 let mut meta = Vec::with_capacity(grid.grid_order.len());
9290 let mut chunk_bytes = Vec::with_capacity(grid.grid_order.len());
9291 for ci in &grid.grid_order {
9292 let len = ci.chunk_size as usize;
9293 ci.address
9294 .checked_add(len as u64)
9295 .filter(|&e| e <= dview.len())
9296 .ok_or(Error::EditUnsupported("chunk data is out of bounds"))?;
9297 chunk_bytes.push(
9298 dview
9299 .read_exact_at(ci.address, len)
9300 .map_err(|_| Error::EditUnsupported("chunk data is out of bounds"))?,
9301 );
9302 meta.push(ChunkMeta {
9303 compressed_size: ci.chunk_size as u64,
9304 filter_mask: ci.filter_mask,
9305 });
9306 }
9307
9308 Ok(CopyTree::DatasetChunked {
9309 region,
9310 shape: ds.dimensions.clone(),
9311 chunk_dims,
9312 element_size,
9313 maxshape,
9314 pipeline_message,
9315 meta,
9316 chunk_bytes,
9317 dense_attrs,
9318 })
9319 }
9320 ObjModel::Group {
9321 non_link_region,
9322 children,
9323 dense_attrs,
9324 } => {
9325 if cross_file {
9326 reject_foreign_addresses(&non_link_region)?;
9327 reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9328 }
9329 let mut kids = Vec::with_capacity(children.len());
9330 for (name, creation_order, child) in children {
9331 // Child link targets are stored base-relative; re-absolutize
9332 // before descending so `addr` stays an absolute offset into `src`.
9333 let child = base.absolute(child).map_err(|_| {
9334 Error::EditUnsupported("child address exceeds this platform")
9335 })?;
9336 kids.push((
9337 name,
9338 creation_order,
9339 Self::read_copy_subtree(src, child, depth + 1, cross_file, base)?,
9340 ));
9341 }
9342 Ok(CopyTree::Group {
9343 non_link_region,
9344 children: kids,
9345 dense_attrs,
9346 })
9347 }
9348 }
9349 }
9350
9351 /// Append the fresh copies described by `node` (data blobs and headers) into
9352 /// this session at end-of-file or into reusable freed regions, returning the
9353 /// new object-header address of the copied root. The write half of an object
9354 /// copy; children are written before their parent group so each parent links
9355 /// its children's new addresses, and a contiguous dataset's data-address field
9356 /// is repointed at the freshly-written copy. Every address the copy writes into
9357 /// a header (a contiguous data block, a child link) is stored relative to the
9358 /// userblock base (`- base`, a no-op on a base-0 file); the chunked storage and
9359 /// dense attribute heaps are laid out base-relative by their own builders.
9360 fn write_copy_subtree(&mut self, node: &CopyTree) -> Result<u64, Error> {
9361 let base = self.superblock.base_address;
9362 match node {
9363 CopyTree::DatasetVerbatim {
9364 region,
9365 dense_attrs,
9366 } => {
9367 let mut region = region.clone();
9368 self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9369 let oh = build_v2_object_header(®ion)?;
9370 self.alloc_or_append_typed(&oh, PageType::Meta)
9371 }
9372 CopyTree::DatasetContiguous {
9373 region,
9374 addr_off,
9375 data,
9376 dense_attrs,
9377 } => {
9378 let mut region = region.clone();
9379 // Storage the source never allocated is copied as storage: there
9380 // is no block to place, and the region already carries the
9381 // undefined address the source stored, so the copy declares the
9382 // same empty storage (issue #336).
9383 if let Some(data) = data {
9384 let new_data_addr = self.alloc_or_append_typed(data, PageType::Raw)?;
9385 // The placement is an absolute offset; the data-layout
9386 // address field stores it relative to the userblock base.
9387 let relative = base.relative(new_data_addr)?;
9388 region.bytes_mut()[*addr_off..*addr_off + 8]
9389 .copy_from_slice(&relative.to_le_bytes());
9390 }
9391 // The dense heap is placed independently of the data — it is
9392 // built for whatever address it gets (see `append_dense_attrs`),
9393 // so no ordering between the two is owed.
9394 self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9395 let oh = build_v2_object_header(®ion)?;
9396 self.alloc_or_append_typed(&oh, PageType::Meta)
9397 }
9398 CopyTree::DatasetChunked {
9399 region,
9400 shape,
9401 chunk_dims,
9402 element_size,
9403 maxshape,
9404 pipeline_message,
9405 meta,
9406 chunk_bytes,
9407 dense_attrs,
9408 } => self.write_chunked_relocatable(
9409 region,
9410 shape,
9411 chunk_dims,
9412 *element_size,
9413 maxshape.as_deref(),
9414 pipeline_message.as_deref(),
9415 meta,
9416 chunk_bytes,
9417 dense_attrs.clone(),
9418 ),
9419 CopyTree::Group {
9420 non_link_region,
9421 children,
9422 dense_attrs,
9423 } => {
9424 let mut region = non_link_region.clone();
9425 for (name, creation_order, child) in children {
9426 let new_child = self.write_copy_subtree(child)?;
9427 // The link target is stored relative to the userblock base.
9428 // A copied link keeps the creation index the source recorded
9429 // for it, so a copy of a group that tracks link creation
9430 // order carries the same order as its source — the Link Info
9431 // message naming that order is copied verbatim beside it.
9432 region.push_link(name, base.relative(new_child)?, *creation_order);
9433 }
9434 // The dense heap is built for whatever address it is placed at
9435 // (see `append_dense_attrs`), so it needs no ordering against the
9436 // children's headers and data.
9437 self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9438 let oh = build_v2_object_header(®ion)?;
9439 self.alloc_or_append_typed(&oh, PageType::Meta)
9440 }
9441 }
9442 }
9443
9444 /// Write a chunked dataset's storage and return its new object-header address
9445 /// — the shared write half of a chunked copy ([`CopyTree::DatasetChunked`])
9446 /// and a relocating chunked overwrite ([`MovingWrite::Chunked`]).
9447 ///
9448 /// A fresh chunk-data blob and index are laid out relocatably via
9449 /// [`plan_chunked_data_verbatim`] / [`emit_chunked_data_verbatim`], pulling
9450 /// each chunk's already-compressed bytes from `chunk_bytes` (in dense
9451 /// row-major grid order) and carrying `meta`'s sizes and filter masks and the
9452 /// source `pipeline_message` verbatim — no recompression, no filter-parameter
9453 /// reconstruction. Like [`build_chunked_dataset`](Self::build_chunked_dataset)
9454 /// the blob is sized from its plan before it is placed, so it can go into a
9455 /// freed region that fits it as readily as at end-of-file. The verbatim header
9456 /// `region`'s data-layout message is then swapped for the one the planner
9457 /// produced (every other message preserved), any dense attribute heap is
9458 /// placed, and the header is written into reusable freed space or at
9459 /// end-of-file.
9460 #[expect(
9461 clippy::too_many_arguments,
9462 reason = "the chunked rebuild needs the full geometry, \
9463 pipeline, and chunk payloads; bundling them into a struct would only move the list"
9464 )]
9465 fn write_chunked_relocatable(
9466 &mut self,
9467 region: &OhRegion,
9468 shape: &[u64],
9469 chunk_dims: &[u64],
9470 element_size: NonZeroUsize,
9471 maxshape: Option<&[u64]>,
9472 pipeline_message: Option<&[u8]>,
9473 meta: &[ChunkMeta],
9474 chunk_bytes: &[Vec<u8>],
9475 dense_attrs: DenseAttrSet,
9476 ) -> Result<u64, Error> {
9477 // Plan once at a provisional base purely to size the data region: the plan
9478 // walks chunk *sizes* and sizes the index from its layout, so it touches
9479 // no bytes at all, and every address it embeds sits in a fixed-width
9480 // field, so its total length is the same wherever the blob lands. That is
9481 // what lets the address be chosen — a freed region or end-of-file — before
9482 // the bytes exist. This plan is discarded; the one built at the real base
9483 // below is what the emit works from.
9484 let sizing = plan_chunked_data_verbatim(
9485 meta,
9486 shape,
9487 chunk_dims,
9488 element_size,
9489 pipeline_message,
9490 0,
9491 maxshape,
9492 )?;
9493 let (_addr, layout_message) =
9494 self.place_relocatable(sizing.plan.total_len, PageType::Raw, |stored_base| {
9495 // Re-plan at the address the blob really occupies, so its embedded
9496 // addresses resolve to their real file offsets once the reader adds
9497 // the userblock base back (see `build_chunked_dataset`).
9498 let layout = plan_chunked_data_verbatim(
9499 meta,
9500 shape,
9501 chunk_dims,
9502 element_size,
9503 pipeline_message,
9504 stored_base,
9505 maxshape,
9506 )?;
9507 let mut buf =
9508 Vec::with_capacity(usize::try_from(layout.plan.total_len).unwrap_or(0));
9509 emit_chunked_data_verbatim(
9510 &mut buf,
9511 &layout.plan,
9512 &SliceChunkProvider {
9513 chunks: chunk_bytes,
9514 },
9515 )?;
9516 Ok((buf, layout.layout_message))
9517 })?;
9518 // Swap the data-layout message for the rebuilt one; keep every other header
9519 // message (datatype, dataspace, fill value, filter pipeline, attributes)
9520 // verbatim.
9521 let mut new_region = replace_layout_message(region, &layout_message)?;
9522 self.append_dense_attrs(&mut new_region, dense_attrs)?;
9523 let oh = build_v2_object_header(&new_region)?;
9524 self.alloc_or_append_typed(&oh, PageType::Meta)
9525 }
9526
9527 /// When `attrs` is non-empty, build a fresh dense (fractal-heap) attribute
9528 /// blob for it, place it, and splice the matching Attribute Info message onto
9529 /// `region`. A no-op for an empty set.
9530 ///
9531 /// The blob produced by [`file_writer::DenseAttrPlan::build`] is fully
9532 /// relocatable: every address it embeds is `base + fixed offset`, and its
9533 /// length is the same for every base, so it can go into a freed metadata
9534 /// region as readily as at end-of-file — the base it is built for is whichever
9535 /// address it gets. The reservation comes from the plan the blob is then
9536 /// built from, so sizing it costs no bytes. The freshly built heap is always
9537 /// same-file, so it never aliases the source heap even for an in-file copy.
9538 /// The caller has already validated [`file_writer::dense_attrs_check`].
9539 fn append_dense_attrs(
9540 &mut self,
9541 region: &mut OhRegion,
9542 set: DenseAttrSet,
9543 ) -> Result<(), Error> {
9544 if set.is_empty() {
9545 return Ok(());
9546 }
9547 let attr_info_message = self.place_dense_attrs(&set.attrs, set.creation)?;
9548 region.push(MessageType::AttributeInfo, &attr_info_message);
9549 Ok(())
9550 }
9551
9552 /// Place dense attribute storage for a staged dataset whose set needs it,
9553 /// returning the Attribute Info message naming the heap — or `None` when the
9554 /// set belongs in the object header, where the header builders write it
9555 /// inline. Which it is was decided and validated when the dataset was staged
9556 /// ([`FlatDataset::attrs_are_dense`]).
9557 fn place_dense_attrs_if_needed(&mut self, fd: &FlatDataset) -> Result<Option<Vec<u8>>, Error> {
9558 if !fd.attrs_are_dense {
9559 return Ok(None);
9560 }
9561 self.place_dense_attrs(&fd.attrs, DenseAttrCreationOrder::Untracked)
9562 .map(Some)
9563 }
9564
9565 /// Build a fresh dense attribute blob for `attrs`, place it, and return the
9566 /// Attribute Info message naming it — the header's whole share of dense
9567 /// storage. See [`append_dense_attrs`](Self::append_dense_attrs) for why the
9568 /// blob may be placed anywhere.
9569 fn place_dense_attrs(
9570 &mut self,
9571 attrs: &[crate::attribute::AttributeMessage],
9572 creation: DenseAttrCreationOrder,
9573 ) -> Result<Vec<u8>, Error> {
9574 let plan = crate::file_writer::dense_attrs_plan(attrs, creation);
9575 let (_addr, attr_info_message) =
9576 self.place_relocatable(plan.blob_len(), PageType::Meta, |stored_base| {
9577 let blob = plan.build(stored_base);
9578 Ok((blob.blob, blob.attr_info_message))
9579 })?;
9580 Ok(attr_info_message)
9581 }
9582
9583 /// Resolve what an attribute edit left for this phase ([`plan_attr_ops`]) and
9584 /// append what names it to the object's message `region`.
9585 ///
9586 /// Both arms place a variable-length attribute's global heap collection and
9587 /// patch the placeholder references in its message; where they differ is what
9588 /// carries the result. A compact attribute becomes an inline Attribute
9589 /// message; a dense set is built into a fresh heap the header then names.
9590 ///
9591 /// For the dense arm the patching has to come first: the heap stores each
9592 /// attribute's *message bytes*, references and all, so a heap built before
9593 /// them would hold the placeholders — the same ordering the whole-file writer
9594 /// keeps for the same reason.
9595 ///
9596 /// A no-op for an edit that resolved entirely in the preflight, which is what
9597 /// makes it safe to call on every rebuilt header.
9598 fn place_edited_attrs(
9599 &mut self,
9600 region: &mut OhRegion,
9601 attrs: EditedAttrs,
9602 ) -> Result<(), Error> {
9603 match attrs {
9604 EditedAttrs::Compact(pending) => {
9605 for pending in pending {
9606 let PendingVlAttr {
9607 mut msg,
9608 collections,
9609 creation_index,
9610 } = pending;
9611 let addrs = self.place_vl_collections(&collections)?;
9612 patch_vl_refs(&mut msg.raw_data, &addrs);
9613 *region = put_attr_message(
9614 region,
9615 &msg.name,
9616 &msg.serialize(LENGTH_SIZE),
9617 creation_index,
9618 )?;
9619 }
9620 Ok(())
9621 }
9622 EditedAttrs::Dense(mut dense) => {
9623 for (idx, collections) in std::mem::take(&mut dense.vl) {
9624 let addrs = self.place_vl_collections(&collections)?;
9625 patch_vl_refs(&mut dense.set.attrs[idx].raw_data, &addrs);
9626 }
9627 self.append_dense_attrs(region, dense.set)
9628 }
9629 }
9630 }
9631
9632 /// Apply a relocating value overwrite (`write_dataset` resize / compact
9633 /// rewrite): write the new data and a rewritten object header at end-of-file
9634 /// (or into reusable freed space) and return the new header address. The
9635 /// caller patches the parent group's link to this address. The old data
9636 /// extent (for a resized contiguous dataset) is freed separately, after the
9637 /// commit's superblock repoint, so it is never reused mid-commit.
9638 fn write_moving(&mut self, mw: &MovingWrite) -> Result<u64, Error> {
9639 let base = self.superblock.base_address;
9640 match mw {
9641 MovingWrite::Contiguous {
9642 region,
9643 addr_off,
9644 bytes,
9645 ..
9646 } => {
9647 let raw = self.resolve_overwrite_bytes(bytes)?;
9648 let new_data_addr = self.alloc_or_append_typed(&raw, PageType::Raw)?;
9649 let mut region = region.clone();
9650 // The placement is an absolute file offset; the contiguous
9651 // data-layout field stores it relative to the userblock base (`-
9652 // base`, a no-op on a base-0 file).
9653 let relative = base.relative(new_data_addr)?;
9654 region.bytes_mut()[*addr_off..*addr_off + 8]
9655 .copy_from_slice(&relative.to_le_bytes());
9656 // The data size field follows the 8-byte address in the contiguous
9657 // layout body; keep it in sync with the new length.
9658 let size_off = *addr_off + 8;
9659 let raw_len = raw.len() as u64;
9660 region.bytes_mut()[size_off..size_off + 8].copy_from_slice(&raw_len.to_le_bytes());
9661 let oh = build_v2_object_header(®ion)?;
9662 self.alloc_or_append_typed(&oh, PageType::Meta)
9663 }
9664 MovingWrite::Compact { region, bytes } => {
9665 let raw = self.resolve_overwrite_bytes(bytes)?;
9666 let region = rebuild_compact_layout_region(region, &raw)?;
9667 let oh = build_v2_object_header(®ion)?;
9668 self.alloc_or_append_typed(&oh, PageType::Meta)
9669 }
9670 MovingWrite::Chunked {
9671 region,
9672 shape,
9673 chunk_dims,
9674 element_size,
9675 maxshape,
9676 pipeline_message,
9677 payload,
9678 ..
9679 } => {
9680 let deferred;
9681 let chunk_bytes = match payload {
9682 ChunkPayload::Encoded(chunk_bytes) => chunk_bytes,
9683 ChunkPayload::Deferred { bytes, padding, dt } => {
9684 // Resolve before splitting: the heap collections have to
9685 // be placed, and their addresses patched into the element
9686 // references, before those references are cut into chunks
9687 // and — on a filtered dataset — compressed over.
9688 let raw = self.resolve_overwrite_bytes(bytes)?;
9689 deferred = split_and_encode_chunks(
9690 &raw,
9691 shape,
9692 chunk_dims,
9693 *element_size,
9694 padding,
9695 pipeline_message.as_deref(),
9696 dt,
9697 )?;
9698 &deferred
9699 }
9700 };
9701 let meta: Vec<ChunkMeta> = chunk_bytes
9702 .iter()
9703 .map(|c| ChunkMeta {
9704 compressed_size: c.len() as u64,
9705 filter_mask: 0,
9706 })
9707 .collect();
9708 self.write_chunked_relocatable(
9709 region,
9710 shape,
9711 chunk_dims,
9712 *element_size,
9713 maxshape.as_deref(),
9714 pipeline_message.as_deref(),
9715 &meta,
9716 chunk_bytes,
9717 DenseAttrSet::default(),
9718 )
9719 }
9720 MovingWrite::AppendedChunks {
9721 region,
9722 new_dataspace_body,
9723 chunk_dims_u32,
9724 element_size,
9725 has_filters,
9726 kept_chunks,
9727 new_chunk_bytes,
9728 ..
9729 } => self.write_appended_chunks(
9730 region,
9731 new_dataspace_body,
9732 chunk_dims_u32,
9733 *element_size,
9734 *has_filters,
9735 kept_chunks,
9736 new_chunk_bytes,
9737 ),
9738 MovingWrite::AttrEdit { region, attrs } => {
9739 // `region` already carries what the commit preflight could resolve;
9740 // `place_edited_attrs` places the rest, exactly as the
9741 // group-attribute apply loop does. Then build and place the
9742 // relocated dataset header: the data-layout message is untouched,
9743 // so the dataset's chunk data and index stay in place and only the
9744 // header moves.
9745 let mut region = region.clone();
9746 self.place_edited_attrs(&mut region, attrs.clone())?;
9747 let oh = build_v2_object_header(®ion)?;
9748 self.alloc_or_append_typed(&oh, PageType::Meta)
9749 }
9750 }
9751 }
9752
9753 /// Apply a relocating append ([`MovingWrite::AppendedChunks`]): place the new
9754 /// (and any rewritten trailing) chunk bytes, rebuild a fresh
9755 /// Extensible Array over the kept plus appended chunks, grow the dataspace and
9756 /// repoint the data layout in the verbatim header `region`, and write the
9757 /// relocated header. Returns the new header address; the caller patches the
9758 /// parent link. The kept chunk data is untouched (referenced by both the old
9759 /// and new index during the commit); the old index/header/trailing chunk are
9760 /// freed only after the superblock repoint.
9761 ///
9762 /// The new chunks and the new index go down together where one freed region
9763 /// holds them and a paged file is what makes that worth asking, and one at a
9764 /// time otherwise; the body says why.
9765 #[expect(
9766 clippy::too_many_arguments,
9767 reason = "the append rebuild needs the header region, grown dataspace, chunk \
9768 geometry, and both chunk sets; bundling them into a struct would only move the list"
9769 )]
9770 fn write_appended_chunks(
9771 &mut self,
9772 region: &OhRegion,
9773 new_dataspace_body: &[u8],
9774 chunk_dims_u32: &[u32],
9775 element_size: NonZeroUsize,
9776 has_filters: bool,
9777 kept_chunks: &[WrittenChunk],
9778 new_chunk_bytes: &[Vec<u8>],
9779 ) -> Result<u64, Error> {
9780 let base = self.superblock.base_address;
9781 // Where the appended chunks and the rebuilt index go, and whether they go
9782 // together.
9783 //
9784 // Contiguity is worth something on a *paged* file. The index goes in a raw
9785 // page, not a metadata one, because that is where this crate puts every
9786 // chunk index — and the reclaim side can only tell a raw index from the
9787 // metadata one the reference library writes by the chunk data it abuts
9788 // ([`index_is_provably_raw`](Self::index_is_provably_raw)). An index that
9789 // abuts none of its chunks cannot be placed by the commit that supersedes
9790 // it, so its bytes are held as dead until the page around them empties.
9791 // Laying the new chunks and the new index down as one blob — the way the
9792 // from-scratch writer and `write_chunked_relocatable` lay out chunked
9793 // storage — keeps that proof available (issue #388).
9794 //
9795 // It is worth nothing on a file that is not paged, which has no page types
9796 // to keep apart, and it must never be bought with reuse: one blob needs one
9797 // freed region big enough for the lot, where the chunks placed one at a
9798 // time fill several smaller ones. So the blob form is taken only when a
9799 // single region already holds it, and otherwise each chunk is placed on its
9800 // own and the index after them. That fallback keeps the proof only where
9801 // the last chunk placed ends up immediately below the index — which is what
9802 // happens when both append at end-of-file, and not what happens when the
9803 // chunks find holes to sit in. An index left abutting nothing is recorded
9804 // as dead rather than free, which costs space and never correctness.
9805 //
9806 // Sizing runs at a provisional base, exactly as `write_chunked_relocatable`
9807 // sizes its blob: an Extensible Array's length follows the slot layout and
9808 // every address it embeds sits in a fixed-width field, so the total is the
9809 // same wherever the array lands, and `place` rejects any disagreement.
9810 //
9811 // The element width comes from the chunk geometry rather than from the
9812 // chunk list, so a rebuild declares the same width the original index did —
9813 // including when the dataset it grows was created empty.
9814 let chunk_bytes =
9815 full_chunk_bytes(chunk_dims_u32.iter().map(|&d| u64::from(d)), element_size);
9816 // Rank 1 and unlimited along axis 0 (`prepare_append` refuses anything
9817 // else), so every chunk's index slot is its position in the grid and the
9818 // array is dense from zero.
9819 //
9820 // This engine applies every filter to a new chunk (no per-chunk skipping),
9821 // so an appended chunk's mask is always 0. Kept chunks carry their own
9822 // (possibly nonzero) mask.
9823 let chunk_total: u64 = new_chunk_bytes.iter().map(|cb| cb.len() as u64).sum();
9824 let placed_chunks = |blob_stored: u64| -> Vec<WrittenChunk> {
9825 let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9826 let mut offset = blob_stored;
9827 for cb in new_chunk_bytes {
9828 combined.push(WrittenChunk {
9829 address: offset,
9830 compressed_size: cb.len() as u64,
9831 filter_mask: 0,
9832 });
9833 offset += cb.len() as u64;
9834 }
9835 combined
9836 };
9837 let sizing = placed_chunks(0);
9838 let ea_len = extensible_array_len(
9839 &crate::chunked_write::IndexSlots::dense(&sizing),
9840 chunk_bytes,
9841 OFFSET_SIZE,
9842 LENGTH_SIZE,
9843 has_filters,
9844 );
9845 let ea =
9846 |slots: &crate::chunked_write::IndexSlots<'_>, at: u64| -> Result<Vec<u8>, Error> {
9847 build_extensible_array_at(
9848 slots,
9849 chunk_bytes,
9850 OFFSET_SIZE,
9851 LENGTH_SIZE,
9852 has_filters,
9853 at,
9854 )
9855 .map_err(Error::Format)
9856 };
9857 // Reserved by hand rather than through `reserve`, which would fall back to
9858 // end-of-file: this asks only whether a freed region holds the whole blob,
9859 // and takes the per-chunk path when none does.
9860 let blob = match self.paged {
9861 Some(_) => self.alloc_free(chunk_total + ea_len, PageType::Raw),
9862 None => None,
9863 };
9864 let ea_stored = match blob {
9865 Some(addr) => {
9866 // The region is out of the free lists from here, so every way the
9867 // build can fail has to put it back: a `?` straight out of this arm
9868 // would leave it neither free nor written for the rest of the
9869 // session. Collected into a `Result` and handed back on the way out.
9870 let placed = (|| -> Result<u64, Error> {
9871 let blob_stored = base.relative(addr)?;
9872 let combined = placed_chunks(blob_stored);
9873 let mut buf =
9874 Vec::with_capacity(usize::try_from(chunk_total + ea_len).unwrap_or(0));
9875 for cb in new_chunk_bytes {
9876 buf.extend_from_slice(cb);
9877 }
9878 buf.extend_from_slice(&ea(
9879 &crate::chunked_write::IndexSlots::dense(&combined),
9880 blob_stored + chunk_total,
9881 )?);
9882 self.place(
9883 Placement::Reused {
9884 addr,
9885 len: chunk_total + ea_len,
9886 },
9887 &buf,
9888 )?;
9889 Ok(blob_stored + chunk_total)
9890 })();
9891 match placed {
9892 Ok(ea_stored) => ea_stored,
9893 Err(e) => {
9894 self.release_raw_alloc(addr, chunk_total + ea_len);
9895 return Err(e);
9896 }
9897 }
9898 }
9899 None => {
9900 // A chunk embeds no addresses of its own, so it can go anywhere and
9901 // the index below simply records where it went.
9902 let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9903 for cb in new_chunk_bytes {
9904 let abs = self.alloc_or_append_typed(cb, PageType::Raw)?;
9905 combined.push(WrittenChunk {
9906 address: base.relative(abs)?,
9907 compressed_size: cb.len() as u64,
9908 filter_mask: 0,
9909 });
9910 }
9911 let (ea_addr, ()) = self.place_relocatable(ea_len, PageType::Raw, |at| {
9912 Ok((
9913 ea(&crate::chunked_write::IndexSlots::dense(&combined), at)?,
9914 (),
9915 ))
9916 })?;
9917 base.relative(ea_addr)?
9918 }
9919 };
9920
9921 // Swap the dataspace (grown) and data-layout (repointed at the new index)
9922 // messages; every other header message is preserved verbatim.
9923 #[expect(
9924 clippy::cast_possible_truncation,
9925 reason = "element size is a datatype byte width that fits u32"
9926 )]
9927 let layout_body = serialize_v4_extensible_array(
9928 chunk_dims_u32,
9929 ea_stored,
9930 OFFSET_SIZE,
9931 element_size.get() as u32,
9932 );
9933 let region = replace_dataspace_message(region, new_dataspace_body)?;
9934 let region = replace_layout_message(®ion, &layout_body)?;
9935 let oh = build_v2_object_header(®ion)?;
9936 self.alloc_or_append_typed(&oh, PageType::Meta)
9937 }
9938
9939 /// Append `bytes` at end-of-file, returning the absolute address they were
9940 /// written at.
9941 fn append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
9942 self.image.append(bytes)
9943 }
9944
9945 /// Overwrite bytes in place at `offset`. The caller guarantees the range
9946 /// already exists.
9947 fn write_at(&mut self, offset: usize, bytes: &[u8]) -> Result<(), Error> {
9948 self.image.write_at(offset as u64, bytes)
9949 }
9950
9951 /// Ensure the next allocation begins in a page holding page type `ty`, on a
9952 /// paged file. A no-op on the common non-paged file.
9953 ///
9954 /// A paged file never mixes metadata and raw data within one page, so when the
9955 /// tail page holds the *other* type and is only partially filled it is first
9956 /// padded to a page boundary, the padding being recorded as free space of the
9957 /// outgoing type.
9958 ///
9959 /// Call this **before** reading the image's end-of-file ([`Source::len`]) to compute an
9960 /// address that will be embedded in the bytes being built: several callers
9961 /// (the chunk blob, the extensible-array index, the dense-attribute blob)
9962 /// build content whose interior addresses assume it lands at the current
9963 /// end-of-file, and padding inserted after that read would shift the landing
9964 /// address out from under them.
9965 fn begin_page(&mut self, ty: PageType) -> Result<(), Error> {
9966 // Destructure so the page state and the image are borrowed as the
9967 // separate fields they are.
9968 let Self { image, paged, .. } = self;
9969 match paged.as_mut() {
9970 Some(pg) => pg.begin(image.as_mut(), ty),
9971 None => Ok(()),
9972 }
9973 }
9974
9975 /// Place `bytes` as page type `ty`, reusing a free region where one fits.
9976 /// Equivalent to [`reserve`](Self::reserve) followed by [`place`](Self::place),
9977 /// for the callers whose bytes are already built.
9978 fn alloc_or_append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
9979 let at = self.reserve(bytes.len() as u64, ty)?;
9980 self.place(at, bytes)
9981 }
9982
9983 /// Choose where `len` bytes of page type `ty` will go: a reusable free region
9984 /// left by a prior commit, or the current end-of-file. The returned
9985 /// [`Placement`] must be handed to [`place`](Self::place) with exactly `len`
9986 /// bytes.
9987 ///
9988 /// Splitting the choice from the write is what lets a *relocatable* blob — one
9989 /// whose interior addresses are all `base + fixed offset`, like a chunked
9990 /// dataset's data region or a dense attribute heap — be built for a freed
9991 /// region rather than only for end-of-file: its size is known from its plan
9992 /// before its bytes exist, so the address can be settled first
9993 /// ([`place_relocatable`](Self::place_relocatable) is that sequence).
9994 ///
9995 /// Reuse only ever draws from free space vacated by *earlier* commits in this
9996 /// session (or seeded from the on-disk managers at open) — never space the
9997 /// current commit is about to free, which stays in its `to_free` list until
9998 /// after the superblock repoint. The bytes it overwrites are therefore already
9999 /// unreachable from the on-disk root, so a mid-commit crash cannot corrupt the
10000 /// live tree: the superblock still points at the prior, intact root.
10001 ///
10002 /// A paged file reuses within the matching page type, or out of a page holding
10003 /// nothing at all, which belongs to no type ([`PagedEdit::alloc_typed`]); every
10004 /// page stays homogeneous either way. It never opens a page for a reused region
10005 /// — the tail page is untouched by a write into the middle of the file.
10006 fn reserve(&mut self, len: u64, ty: PageType) -> Result<Placement, Error> {
10007 if let Some(addr) = self.alloc_free(len, ty) {
10008 return Ok(Placement::Reused { addr, len });
10009 }
10010 self.begin_page(ty)?;
10011 Ok(Placement::Appended {
10012 addr: self.image.len(),
10013 len,
10014 })
10015 }
10016
10017 /// Draw `len` bytes from the free space of page type `ty`, or `None` when no
10018 /// single region is large enough.
10019 ///
10020 /// A non-paged file keeps one list for the whole file. A paged file keeps one
10021 /// per page type and must be served from the list matching `ty`: handing a
10022 /// metadata hole to raw data (or the reverse) would mix the two within a page,
10023 /// which is the single invariant the paged strategy exists to hold. A page
10024 /// holding nothing at all is the exception, and
10025 /// [`alloc_typed`](PagedEdit::alloc_typed) is where it is spent.
10026 fn alloc_free(&mut self, len: u64, ty: PageType) -> Option<u64> {
10027 let Some(pg) = self.paged.as_mut() else {
10028 return self.free.alloc(len);
10029 };
10030 pg.alloc_typed(len, ty)
10031 }
10032
10033 /// Hand `[addr, addr + len)`, drawn from
10034 /// [`alloc_free`](Self::alloc_free) with [`PageType::Raw`], back to the list
10035 /// it came from — for a placement refused *after* the draw, which would
10036 /// otherwise hold the region for the rest of the session without writing
10037 /// anything into it.
10038 ///
10039 /// A paged file takes it back into the raw list whichever list served it: a
10040 /// whole page [`PagedEdit::alloc_typed`] claimed from the metadata side for
10041 /// raw data is raw from the moment it is claimed, and that call already
10042 /// returns its alignment tail there. A page that ends up wholly free is
10043 /// promoted out of either list by
10044 /// [`promote_whole_free_pages`](PagedEdit::promote_whole_free_pages) as usual.
10045 fn release_raw_alloc(&mut self, addr: u64, len: u64) {
10046 match self.paged.as_mut() {
10047 Some(pg) => pg.raw.free(addr, len),
10048 None => self.free.free(addr, len),
10049 }
10050 }
10051
10052 /// Write `bytes` at the address [`reserve`](Self::reserve) handed out,
10053 /// returning that address.
10054 ///
10055 /// The length is checked rather than asserted: a reused region is sized to the
10056 /// reservation and is followed by live bytes, so writing more than was reserved
10057 /// would silently destroy a neighboring object. That makes it the one internal
10058 /// miscount worth paying a comparison to catch in every build.
10059 fn place(&mut self, at: Placement, bytes: &[u8]) -> Result<u64, Error> {
10060 if bytes.len() as u64 != at.len() {
10061 return Err(Error::Format(FormatError::SerializationError(format!(
10062 "a placement reserved {} bytes but built {}",
10063 at.len(),
10064 bytes.len()
10065 ))));
10066 }
10067 match at {
10068 Placement::Reused { addr, .. } => {
10069 self.write_at(
10070 usize::try_from(addr).map_err(|_| {
10071 Error::EditUnsupported("free-region address exceeds this platform")
10072 })?,
10073 bytes,
10074 )?;
10075 Ok(addr)
10076 }
10077 Placement::Appended { addr, .. } => {
10078 let written = self.append(bytes)?;
10079 debug_assert_eq!(
10080 written, addr,
10081 "an appended placement must land at end-of-file"
10082 );
10083 Ok(written)
10084 }
10085 }
10086 }
10087
10088 /// Place a *relocatable* blob of `len` bytes as page type `ty`: pick its
10089 /// address first, then build it for that address with `build`, then write it
10090 /// there. Returns the address and whatever else `build` produced (typically the
10091 /// object-header message naming the blob, which embeds the same address).
10092 ///
10093 /// `build` receives the *stored* (base-relative) address the blob will occupy,
10094 /// since every address a blob embeds is stored base-relative and the reader
10095 /// recovers it as `stored + base_address`. On a file without a userblock the
10096 /// two are equal.
10097 ///
10098 /// `len` must be the length `build` will produce; [`place`](Self::place)
10099 /// rejects a mismatch. For every blob placed this way the length is a function
10100 /// of the content alone — the addresses sit in fixed-width fields — so every
10101 /// caller derives it, and none builds the blob to measure it:
10102 /// [`DenseAttrPlan::blob_len`](crate::file_writer::DenseAttrPlan::blob_len)
10103 /// for a dense attribute heap, [`extensible_array_len`] for an appended
10104 /// chunk index, and [`chunked_data_len`] / [`plan_chunked_data_verbatim`]
10105 /// for a whole chunked data region, both of which size their index through
10106 /// [`chunk_index_len`](crate::chunked_write::chunk_index_len).
10107 fn place_relocatable<T>(
10108 &mut self,
10109 len: u64,
10110 ty: PageType,
10111 build: impl FnOnce(u64) -> Result<(Vec<u8>, T), Error>,
10112 ) -> Result<(u64, T), Error> {
10113 let at = self.reserve(len, ty)?;
10114 let (bytes, extra) = build(self.superblock.base_address.relative(at.address())?)?;
10115 let addr = self.place(at, &bytes)?;
10116 Ok((addr, extra))
10117 }
10118
10119 /// Place one variable-length dataset's or attribute's already-built,
10120 /// self-contained global heap collections (from
10121 /// [`build_global_heap_collections`] or a
10122 /// [`VlStringStaging::collections`]) and return, in the same order, the
10123 /// base-relative addresses its variable-length references should be patched
10124 /// to. A `GCOL` blob embeds no addresses of its own, so it can be appended
10125 /// (or dropped into reused free space) at any point in the apply loop,
10126 /// unlike a group or dataset header, which must be built last so it can name
10127 /// its children's real addresses. Each collection is placed independently,
10128 /// so they need not land contiguously.
10129 fn place_vl_collections(&mut self, collections: &[Vec<u8>]) -> Result<Vec<u64>, Error> {
10130 collections
10131 .iter()
10132 .map(|collection| {
10133 let addr = self.alloc_or_append_typed(collection, PageType::Meta)?;
10134 self.superblock
10135 .base_address
10136 .relative(addr)
10137 .map_err(Error::from)
10138 })
10139 .collect()
10140 }
10141
10142 /// Resolve a staged value overwrite's element bytes: place any global heap
10143 /// collections its variable-length references name, patch their addresses
10144 /// in, and return the bytes ready to write (issue #321). An overwrite that
10145 /// stages nothing — every one but a `with_vlen_strings` one — is handed
10146 /// back untouched.
10147 ///
10148 /// This allocates, so every caller is in the apply phase — the preflight
10149 /// that chose the plan only reads.
10150 ///
10151 /// It also carries the reclaim. The collections a *previous* overwrite of
10152 /// this same path placed are handed to the commit's free list, and the ones
10153 /// placed here are recorded in their stead, so rotating a dataset's strings
10154 /// reaches a steady state instead of leaking a generation per commit. Only
10155 /// collections this session placed are ever reclaimed, and only while
10156 /// nothing has been able to name them twice — see
10157 /// [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) for why that provenance
10158 /// is the whole proof, and `repack` for the collections it cannot make.
10159 fn resolve_overwrite_bytes<'b>(
10160 &mut self,
10161 bytes: &'b OverwriteBytes,
10162 ) -> Result<Cow<'b, [u8]>, Error> {
10163 let Some(vlen) = &bytes.vlen else {
10164 // Nothing to resolve, and nothing to copy: every overwrite but a
10165 // variable-length one takes this path, and the in-place loop writes
10166 // straight through the borrow. Cloning here would charge each of
10167 // them a second copy of its whole payload, which the bounded
10168 // backing in particular is built not to pay.
10169 return Ok(Cow::Borrowed(&bytes.raw));
10170 };
10171 let staging = &vlen.staging;
10172 // Read rather than removed: the record is replaced (not consumed) when
10173 // the new collections land just below.
10174 let superseded = self
10175 .vl_overwrite_heaps
10176 .get(&vlen.path)
10177 .cloned()
10178 .unwrap_or_default();
10179 let mut raw = bytes.raw.clone();
10180 let base = self.superblock.base_address;
10181 // A staging with no collections patches nothing, which is the right
10182 // answer for it: an element with no heap object keeps the zero address
10183 // that reads back as null. It falls out of the general path rather than
10184 // being a case of its own — `patch_vl_refs_masked` has one offset per
10185 // object, so no offsets is no iterations — and it still records (an
10186 // empty record) and still supersedes.
10187 let addrs = self.place_vl_collections(&staging.collections)?;
10188 patch_vl_refs_masked(&mut raw, &staging.patch_offsets, &addrs);
10189 // `place_vl_collections` answers base-relative, since that is what an
10190 // element reference stores; the free list is absolute.
10191 let placed = addrs
10192 .iter()
10193 .zip(&staging.collections)
10194 .map(|(&a, c)| Ok((base.absolute(a)?, c.len() as u64)))
10195 .collect::<Result<Vec<_>, FormatError>>()?;
10196 self.vl_overwrite_heaps.insert(vlen.path.clone(), placed);
10197 self.superseded_heaps.extend(superseded);
10198 Ok(Cow::Owned(raw))
10199 }
10200
10201 /// Forget every heap collection this session recorded as exclusively one
10202 /// dataset's, because `staged` contains an edit that could name one of them
10203 /// a second time (issue #321).
10204 ///
10205 /// The provenance in [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) says a
10206 /// collection was named once *when it was placed*. Two staged edits can
10207 /// falsify that afterwards, and both do it by re-emitting element bytes
10208 /// somebody else's references live in:
10209 ///
10210 /// - a **copy**, in-file or cross-file, which duplicates a dataset's element
10211 /// references verbatim. This is not hypothetical: an in-file copy of a
10212 /// variable-length dataset is exactly how two datasets come to name one
10213 /// collection.
10214 /// - a **raw-bytes write** over a datatype that reaches a heap address
10215 /// ([`datatype_holds_file_address`]). `with_raw_data` hands the engine
10216 /// element bytes it does not interpret, so a caller that read them from
10217 /// one dataset and wrote them to another has aliased whatever they named —
10218 /// the same unscreened door issue #317 recorded for object references.
10219 ///
10220 /// Wholesale rather than per-path: what a copy's source references is not
10221 /// known without decoding it, and a proof that has to be argued per edit is
10222 /// the kind that goes stale. Reclaim is an optimization, and giving all of
10223 /// it up on a rare edit costs a leak, where keeping one entry too long costs
10224 /// a freed collection somebody still reads.
10225 fn invalidate_heap_provenance(&mut self, staged: &StagedEdits) {
10226 if self.vl_overwrite_heaps.is_empty() {
10227 return;
10228 }
10229 let aliasing_edit = !staged.copies.is_empty()
10230 || !staged.cross_copies.is_empty()
10231 // A dataset staged with element bytes of its own (`with_raw_data`)
10232 // rather than staging its strings here, whether it is being created
10233 // or overwritten.
10234 || staged
10235 .writes
10236 .iter()
10237 .chain(&staged.datasets)
10238 .any(|(_, fd)| {
10239 fd.vl_string_staging.is_none()
10240 && datatype_holds_file_address(&fd.dt)
10241 });
10242 if aliasing_edit {
10243 self.vl_overwrite_heaps.clear();
10244 return;
10245 }
10246 // A deleted dataset's record would otherwise outlive it and be applied
10247 // to whatever later takes its path.
10248 for path in &staged.deletes {
10249 self.vl_overwrite_heaps
10250 .retain(|recorded, _| !paths_overlap(recorded, path));
10251 }
10252 }
10253
10254 /// Resolve one object-reference element's target to the base-relative
10255 /// address that should be stored on disk. [`ObjectRefTarget::Raw`] is
10256 /// written back verbatim (a null or undefined reference is a sentinel, not
10257 /// a real address, so it needs no base adjustment — mirrors the whole-file
10258 /// writer). [`ObjectRefTarget::Path`] resolves, in order:
10259 ///
10260 /// 1. Against `path_addr` — every group and dataset this commit has
10261 /// already placed (a sibling dataset placed earlier in the same
10262 /// group's batch — see the apply loop's non-reference-first ordering —
10263 /// or a descendant subtree fully processed earlier in the deepest-first
10264 /// walk).
10265 /// 2. Against the pre-commit on-disk file
10266 /// ([`resolve_path_any`](crate::group_v2::resolve_path_any)), but only
10267 /// when the path is untouched by this commit, so its pre-commit
10268 /// address is guaranteed to still be valid post-commit. "Touched"
10269 /// means: a dirty group (`nodes`, new or merely rewritten because an
10270 /// addition lives under it — its own address changes either way); a
10271 /// path this commit adds, or that lies under a subtree this commit
10272 /// copies in (`add_targets`, checked by prefix so a copy's interior is
10273 /// covered even though only its root is enumerated there); or a
10274 /// `write_dataset` target (`write_targets`) — conservatively refused
10275 /// even for a same-length overwrite that does not actually relocate,
10276 /// since resolving that distinction here is not worth the complexity.
10277 /// 3. If the path resolves nowhere at all (neither this commit nor the
10278 /// pre-commit file has ever heard of it), as an undefined reference
10279 /// (`HADDR_UNDEF`) — mirroring [`ObjectRefTarget::Path`]'s existing
10280 /// whole-file-writer resolution convention for the same builder type.
10281 ///
10282 /// A path that step 1 misses but step 2 identifies as commit-touched is
10283 /// refused with a clear [`Error::EditUnsupported`] rather than resolved to
10284 /// a stale or wrong address — the one case this engine cannot resolve
10285 /// without the whole-file writer's two-pass dummy/real-address scheme.
10286 ///
10287 /// A path this same commit *deletes* (`delete_targets`) is refused for a
10288 /// different reason, and so carries its own message: the address step 2
10289 /// would resolve is not stale but doomed, and the next commit to reuse the
10290 /// span leaves a reference that dereferenced cleanly reading whatever
10291 /// landed there. The test is by **prefix**, because a deletion takes the
10292 /// whole subtree with it (`collect_free_spans` walks it): a reference to a
10293 /// child of a deleted group dangles exactly as a reference to the group
10294 /// itself does (issue #314).
10295 ///
10296 /// It is a conservative test in the same sense `write_targets` is. A child
10297 /// whose object survives the delete through another hard link keeps its
10298 /// address — `collect_free_spans` reclaims nothing when the incoming count
10299 /// is not 1 — and is refused here anyway, so the message states what the
10300 /// commit does rather than what the allocator will conclude.
10301 ///
10302 /// The delete test runs *after* the other three so that a replacement this
10303 /// commit has merely not placed yet — a sibling group later in the same
10304 /// depth band — is reported as an ordering problem rather than as a
10305 /// deletion. That ordering is a better default, not a partition: because
10306 /// `add_targets` claims a replaced path's whole subtree by prefix, a child
10307 /// the replacement does *not* recreate is genuinely doomed and still
10308 /// reports "still writing". Distinguishing it would mean enumerating what
10309 /// a replacement actually rebuilds, which neither list holds. A path the
10310 /// commit puts back is resolved by step 1 before either test is reached.
10311 fn resolve_reference_target(
10312 target: &ObjectRefTarget,
10313 path_addr: &BTreeMap<PathKey, u64>,
10314 nodes: &BTreeMap<PathKey, Node>,
10315 add_targets: &[PathKey],
10316 write_targets: &[PathKey],
10317 delete_targets: &[PathKey],
10318 invalidated: &InvalidatedAddresses,
10319 src: &(impl Source + ?Sized),
10320 superblock: &Superblock,
10321 ) -> Result<u64, Error> {
10322 let path = match target {
10323 // An address carries no name to test, so the delete check the path
10324 // arm makes by prefix is made here on the address itself.
10325 //
10326 // It fires on nothing today: every `Raw` this crate stages is a
10327 // sentinel, because `repack`'s faithful re-emit resolves a real
10328 // target to a `Path` and leaves only the null and undefined
10329 // references raw. It is here because the variant carries an
10330 // arbitrary address, and a producer that staged a real one would
10331 // otherwise write it past the screen its `Path` twin gets — which
10332 // is the shape of this bug in the first place (issue #317).
10333 ObjectRefTarget::Raw(addr) => {
10334 if let Some(refusal) = invalidated.refusal(*addr) {
10335 return Err(Error::EditUnsupported(refusal));
10336 }
10337 return Ok(*addr);
10338 }
10339 ObjectRefTarget::Path(path) => path,
10340 };
10341 let base = superblock.base_address;
10342 let key = split_path(path);
10343 if let Some(&addr) = path_addr.get(&key) {
10344 return base.relative(addr).map_err(Error::from);
10345 }
10346 if nodes.contains_key(&key)
10347 || add_targets.iter().any(|t| is_prefix(t, &key))
10348 || write_targets.contains(&key)
10349 {
10350 return Err(Error::EditUnsupported(
10351 "an object-reference dataset targets a path this commit is still writing; \
10352 use separate commits",
10353 ));
10354 }
10355 // After the three above; see this function's doc for why, and for what
10356 // that ordering does and does not buy.
10357 if delete_targets.iter().any(|d| is_prefix(d, &key)) {
10358 return Err(Error::EditUnsupported(
10359 "an object-reference dataset targets an object this commit deletes, or one \
10360 under it; the reference would be left pointing at storage the delete can \
10361 reclaim",
10362 ));
10363 }
10364 match crate::group_v2::resolve_path_any_from_source(src, superblock, path) {
10365 Ok(addr) => base.relative(addr).map_err(Error::from),
10366 Err(_) => Ok(UNDEF),
10367 }
10368 }
10369
10370 /// Prove, before any byte of this commit is written, that every
10371 /// object-reference target across every staged dataset will resolve
10372 /// successfully — either against a pre-existing untouched object or
10373 /// against something this same commit places. [`resolve_reference_target`]
10374 /// classifies a target purely from *whether* a `PathKey` has been placed
10375 /// yet (`path_addr.get`), never from the address *value*, so replaying the
10376 /// apply loop's placement order here with a placeholder address standing in
10377 /// for "already placed" reproduces the exact same verdict the apply loop's
10378 /// own calls will reach later, without writing anything.
10379 ///
10380 /// The placeholder is the file's **base address**, not zero. Any value
10381 /// reproduces the verdict, but it is also handed to the same `addr - base`
10382 /// that converts a real address into its stored form: a zero placeholder
10383 /// underflows that on every file with a userblock, panicking in a debug
10384 /// build on an otherwise legal edit. At the base address it converts to a
10385 /// stored `0`, which is exactly the "placed, real address not known yet"
10386 /// this stands for. If
10387 /// this preflight pass returns `Ok`, none of the apply loop's own
10388 /// `resolve_reference_target` calls can fail, so a reference-resolution
10389 /// error can no longer leave earlier-processed groups' real writes
10390 /// orphaned in the file (the failure surfaces here instead, before the
10391 /// apply loop's first `place`/`write_at`).
10392 fn preflight_reference_targets(
10393 keys: &[PathKey],
10394 flat: &BTreeMap<&PathKey, Vec<&FlatDataset>>,
10395 nodes: &BTreeMap<PathKey, Node>,
10396 add_targets: &[PathKey],
10397 write_targets: &[PathKey],
10398 delete_targets: &[PathKey],
10399 invalidated: &InvalidatedAddresses,
10400 src: &(impl Source + ?Sized),
10401 superblock: &Superblock,
10402 ) -> Result<(), Error> {
10403 let mut by_depth = keys.to_vec();
10404 // Stable on purpose, for the same reason as the apply loop's copy: this
10405 // simulation is only faithful if it walks the groups in that same order.
10406 by_depth.sort_by_key(|k| std::cmp::Reverse(k.len()));
10407 let mut sim_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
10408 for key in &by_depth {
10409 if let Some(datasets) = flat.get(key) {
10410 // Mirrors the apply loop's `group_datasets.sort_by_key(|fd|
10411 // fd.reference_targets.is_some())`: non-reference datasets are
10412 // placed (and so become resolvable) before any reference
10413 // dataset in the same group.
10414 let mut ordered: Vec<&FlatDataset> = datasets.to_vec();
10415 // Stable on purpose, like the loop it mirrors.
10416 ordered.sort_by_key(|fd| fd.reference_targets.is_some());
10417 for fd in ordered {
10418 if let Some(patches) = &fd.reference_targets {
10419 for patch in patches {
10420 Self::resolve_reference_target(
10421 &patch.target,
10422 &sim_addr,
10423 nodes,
10424 add_targets,
10425 write_targets,
10426 delete_targets,
10427 invalidated,
10428 src,
10429 superblock,
10430 )?;
10431 }
10432 }
10433 let mut full = key.clone();
10434 full.push(fd.name.clone());
10435 sim_addr.insert(full, superblock.base_address.get());
10436 }
10437 }
10438 sim_addr.insert(key.clone(), superblock.base_address.get());
10439 }
10440 Ok(())
10441 }
10442
10443 /// Lay out a chunked / filtered / extensible dataset and return its object
10444 /// header bytes (which the caller links into the parent group).
10445 ///
10446 /// The chunk data and index (fixed-array / extensible-array, with any filter
10447 /// pipeline applied) are produced as one relocatable blob, whose internal
10448 /// layout — and therefore total size — is independent of the base address it
10449 /// is given. That is what lets the dataset be *sized before it is placed*
10450 /// ([`chunked_data_len`]): the blob goes into a freed region big enough to
10451 /// hold it, or at end-of-file when there is none, and is then assembled for
10452 /// whichever address it got, so every absolute address it embeds (chunk
10453 /// addresses, index-structure addresses, the addresses in the data-layout
10454 /// message) lands exactly where the bytes are written. The header is then
10455 /// built with [`build_chunked_dataset_oh`] — the same function the whole-file
10456 /// writer uses — so the header is byte-identical to one written fresh.
10457 ///
10458 /// The dataset's single pass of the filter pipeline happens in
10459 /// [`compress_chunks`], before either decision; sizing and assembly work from
10460 /// the compressed set, so choosing an address never costs a recompression
10461 /// (issue #261).
10462 fn build_chunked_dataset(&mut self, fd: &FlatDataset) -> Result<Vec<u8>, Error> {
10463 let chunk_dims = fd.chunk_options.resolve_chunk_dims(&fd.ds.dimensions);
10464 let ctx = ChunkContext::from_datatype(&chunk_dims, &fd.dt)?;
10465 // The overhang of a partial edge chunk holds the staged fill value
10466 // (issue #296).
10467 let elem = crate::convert::nonzero_usize_from(ctx.element_size)?;
10468 let fill = crate::fill_value::FillPattern::new(fd.fill.as_deref(), elem);
10469 let set = compress_chunks(
10470 &fd.raw,
10471 &fd.ds.dimensions,
10472 ctx,
10473 &fd.chunk_options,
10474 fd.maxshape.as_deref(),
10475 fill,
10476 // `flatten_dataset` refuses an unallocated dataset outright, so
10477 // every dataset this engine builds allocates its storage.
10478 StorageAllocation::Allocated,
10479 )?;
10480 // Chunk data and the index beside it are raw (see `chunked_storage_spans`,
10481 // which reclaims both as raw).
10482 let (_addr, (layout_message, pipeline_message)) =
10483 self.place_relocatable(chunked_data_len(&set)?, PageType::Raw, |stored_base| {
10484 let result = assemble_chunked_at(&set, stored_base)?;
10485 Ok((
10486 result.data_bytes,
10487 (result.layout_message, result.pipeline_message),
10488 ))
10489 })?;
10490 // As in the contiguous branch: a set too large for the header goes to a
10491 // fractal heap, which the header then names instead of carrying it.
10492 let attr_info = self.place_dense_attrs_if_needed(fd)?;
10493 Ok(build_chunked_dataset_oh(
10494 &fd.dt,
10495 &DatatypeLocation::Inline,
10496 &fd.ds,
10497 &layout_message,
10498 pipeline_message.as_deref(),
10499 &fd.attrs,
10500 attr_info.as_deref(),
10501 fd.fill.as_deref(),
10502 )?)
10503 }
10504
10505 /// On-disk byte spans `(addr, len)` of every chunk of the version 2 object
10506 /// header at `addr`: chunk 0 (signature, prefix, messages, checksum) plus
10507 /// each continuation (`OCHK`) block. Used to reclaim a header's storage when
10508 /// its object is deleted. An error (propagated from [`oh_region_at`] or a
10509 /// malformed continuation) means the header is not a plain v2 header this
10510 /// engine can fully account for, and the caller leaves it as dead bytes
10511 /// rather than guess its extent.
10512 fn oh_chunk_spans(&self, addr: usize) -> Result<Vec<(u64, u64)>, Error> {
10513 Ok(
10514 read_oh_chunks(&self.image(), addr as u64, self.superblock.base_address)?
10515 .into_iter()
10516 .map(|chunk| chunk.span)
10517 .collect(),
10518 )
10519 }
10520
10521 /// Count, for every object-header address reachable from the root, how many
10522 /// hard links in the *pre-commit* file point to it. The result drives the
10523 /// last-hard-link reclaim guard in [`collect_free_spans`](Self::collect_free_spans):
10524 /// an object is freed only when its count is 1.
10525 ///
10526 /// Walks the whole link graph from the root, following hard links through
10527 /// groups of any on-disk format (v0/v1 symbol-table, v2 compact, v2 dense)
10528 /// via [`resolve_group_entries`], tallying each hard-link edge. Datasets and
10529 /// other leaves contribute no edges. Returns `None` — so the caller reclaims
10530 /// nothing for the deletions, a safe leak — if the graph cannot be walked in
10531 /// full: an unparseable header, a group whose links cannot be enumerated, or
10532 /// more than [`MAX_LINK_GRAPH_NODES`] objects. Cycles are handled by visiting
10533 /// each object once. Base-aware: stored child addresses are shifted by the
10534 /// userblock base, so the returned keys are absolute file offsets.
10535 fn count_incoming_hard_links(&self) -> Option<HashMap<u64, u32>> {
10536 let os = self.superblock.offset_size;
10537 let ls = self.superblock.length_size;
10538 let base = self.superblock.base_address;
10539 let mut counts: HashMap<u64, u32> = HashMap::new();
10540 let mut visited: HashSet<u64> = HashSet::new();
10541 let mut stack: Vec<u64> = vec![self.superblock.root_group_address];
10542 let mut budget = MAX_LINK_GRAPH_NODES;
10543 while let Some(addr) = stack.pop() {
10544 if !visited.insert(addr) {
10545 continue; // already expanded (also breaks hard-link cycles)
10546 }
10547 if budget == 0 {
10548 return None; // graph larger than we will walk; leak conservatively
10549 }
10550 budget -= 1;
10551 let off = usize::try_from(addr).ok()?;
10552 let header =
10553 ObjectHeader::parse_from_source(&self.image(), off as u64, os, ls, base).ok()?;
10554 // Datasets and other leaves are not groups and own no links.
10555 let is_group = header.messages.iter().any(|m| {
10556 matches!(
10557 m.msg_type,
10558 MessageType::SymbolTable | MessageType::Link | MessageType::LinkInfo
10559 )
10560 });
10561 if !is_group {
10562 continue;
10563 }
10564 // A group we cannot enumerate fully would undercount incoming links
10565 // and risk over-reclaim; bail to the safe-leak fallback instead.
10566 let entries =
10567 resolve_group_entries_from_source(&self.image(), &header, os, ls, base).ok()?;
10568 for e in entries {
10569 let child = base.absolute(e.object_header_address).ok()?;
10570 *counts.entry(child).or_insert(0) += 1;
10571 stack.push(child);
10572 }
10573 }
10574 Some(counts)
10575 }
10576
10577 /// Best-effort enumeration of every on-disk block owned by the object at
10578 /// `addr` (and, for a group, its whole subtree), accumulating `(addr, len)`
10579 /// spans into `out` for reclamation after a delete.
10580 ///
10581 /// Contiguous datasets (header + data block), chunked datasets (header +
10582 /// chunk index + chunk data, via [`chunked_storage_spans`](Self::chunked_storage_spans)),
10583 /// and whole group subtrees are reclaimed. Deliberately conservative: any
10584 /// object whose layout it cannot fully account for — a non-v2 header, an
10585 /// unsupported or only-partially-enumerable chunk index, a group holding a
10586 /// soft/external link, dense attribute storage — contributes nothing and is
10587 /// not descended into, so `out` never names a region that might still be in
10588 /// use. Bounded by [`MAX_COPY_DEPTH`] against a hard-link cycle.
10589 /// Variable-length data in global-heap collections is never reclaimed here (a
10590 /// collection can be shared between objects), so it is simply left behind.
10591 ///
10592 /// `incoming` is the file-wide hard-link count per object-header address
10593 /// (from [`count_incoming_hard_links`](Self::count_incoming_hard_links)). An
10594 /// object is reclaimed — and, for a group, descended into — only when its
10595 /// count is exactly 1, i.e. the link being removed is its last: an object
10596 /// still reachable through another hard link is live and is left untouched
10597 /// (so is everything below a surviving group), which is what keeps deleting
10598 /// one of several hard links from corrupting the survivor.
10599 fn collect_free_spans(
10600 &self,
10601 addr: usize,
10602 depth: u32,
10603 incoming: &HashMap<u64, u32>,
10604 out: &mut Vec<(u64, u64, FreeClass)>,
10605 ) {
10606 // `addr` is an absolute file offset (the caller resolves it from the live
10607 // file, and the group recursion below re-absolutizes each child). `incoming`
10608 // is keyed by absolute offset, and `oh_chunk_spans`/`chunked_storage_spans`
10609 // both take an absolute address and return absolute spans, so the whole
10610 // walk works in absolute file offsets. The one shift this method must apply
10611 // itself is on the *stored* (base-relative) addresses `read_object` returns
10612 // for a contiguous data block and a group's child links: each is converted
10613 // to an absolute offset by adding `base` (a no-op on a base-0 file) before
10614 // it is bounds-checked, recorded, or descended into.
10615 let base = self.superblock.base_address;
10616 let file_len = self.image().len();
10617 if depth >= MAX_COPY_DEPTH {
10618 return;
10619 }
10620 // Reclaim only when this delete removes the object's last hard link. A
10621 // count other than 1 (it has surviving links, or the graph walk could
10622 // not account for it) means the object — and a group's whole subtree —
10623 // stays live and must not be freed.
10624 if incoming.get(&(addr as u64)) != Some(&1) {
10625 return;
10626 }
10627 // The header's own chunks. If they cannot be mapped, account for nothing.
10628 let spans = match self.oh_chunk_spans(addr) {
10629 Ok(s) => s,
10630 Err(_) => return,
10631 };
10632 match Self::read_object(&self.image(), addr as u64, self.superblock.base_address) {
10633 Ok(ObjModel::DatasetVerbatim { .. }) => out.extend(meta_spans(spans)),
10634 Ok(ObjModel::DatasetContiguous {
10635 data_addr,
10636 data_size,
10637 ..
10638 }) => {
10639 out.extend(meta_spans(spans));
10640 // A defined, in-bounds contiguous data block is owned outright;
10641 // an empty dataset stores the undefined address and owns none. The
10642 // stored address is base-relative, so shift it to an absolute file
10643 // offset before bounds-checking and recording it.
10644 if data_addr != u64::MAX && data_size > 0 {
10645 if let (Some(abs), Ok(len)) =
10646 (base.absolute(data_addr).ok(), usize::try_from(data_size))
10647 {
10648 if let Ok(start) = usize::try_from(abs) {
10649 if start.checked_add(len).is_some_and(|e| e as u64 <= file_len) {
10650 // A contiguous data block is raw data.
10651 out.push((abs, data_size, FreeClass::Page(PageType::Raw)));
10652 }
10653 }
10654 }
10655 }
10656 }
10657 Ok(ObjModel::Group { children, .. }) => {
10658 out.extend(meta_spans(spans));
10659 // Child link targets are stored base-relative; re-absolutize each
10660 // before descending so the recursion keeps working in absolute
10661 // offsets (matching `incoming`'s keys and `oh_chunk_spans`).
10662 for (_, _, child) in children {
10663 if let Some(c) = base
10664 .absolute(child)
10665 .ok()
10666 .and_then(|a| usize::try_from(a).ok())
10667 {
10668 self.collect_free_spans(c, depth + 1, incoming, out);
10669 }
10670 }
10671 }
10672 // A chunked dataset: reclaim its chunk index and chunk data blocks
10673 // alongside its header. `chunked_storage_spans` returns `None` for
10674 // anything it cannot account for exhaustively (an index type with no
10675 // walker, an undefined index address, or spans that fail the
10676 // bounds/overlap check), leaving the whole dataset as dead bytes
10677 // rather than freeing a region that might still be in use.
10678 Ok(ObjModel::DatasetChunked { .. }) => {
10679 if let Some(storage) = self.chunked_storage_spans(addr) {
10680 out.extend(meta_spans(spans));
10681 // Already page-typed: chunk data raw, index structure metadata.
10682 out.extend(storage);
10683 }
10684 }
10685 // A truly unsupported object (one `read_object` cannot model): leave
10686 // its bytes in place rather than guess its extent.
10687 //
10688 // An externally stored dataset lands here as of #336, where before it
10689 // was modelled as the contiguous dataset it structurally is and had
10690 // its header chunks reclaimed. Deleting one therefore leaves those
10691 // chunks behind: measured on the fixture in
10692 // `crates/crosscheck/tests/external_storage.rs`, a commit that deletes it
10693 // reports 147 B reusable where it reported 431 B. That is a leak and
10694 // not a hazard — `oh_chunk_spans` never covered the local heap the
10695 // External Data Files message names either, so neither the old
10696 // behaviour nor this one accounted for the whole object — and it is
10697 // the price of stating the refusal once, in the one function both the
10698 // copy planner and this walk read objects through.
10699 Err(_) => {}
10700 }
10701 }
10702
10703 /// Best-effort enumeration of every on-disk block a *chunked* dataset at
10704 /// `addr` owns: its chunk index structure (B-tree v1 nodes, or fixed- /
10705 /// extensible-array header, index, super, and data blocks) plus every
10706 /// allocated chunk data block. The object-header chunks are freed by the
10707 /// caller ([`collect_free_spans`](Self::collect_free_spans)); this returns
10708 /// only the storage the data-layout message points at.
10709 ///
10710 /// Returns `None` — contribute nothing, leave the object as dead bytes —
10711 /// whenever the dataset cannot be enumerated *exhaustively* and safely: a
10712 /// header that does not parse or is not a chunked dataset, a chunk index
10713 /// with no walker (a version 2 B-tree, index type 5), an undefined index
10714 /// address (an empty, never-written dataset), or any resulting span that
10715 /// falls outside the file image or overlaps another. This upholds the
10716 /// editor's invariant that reclaimed space is never a region still in use:
10717 /// under-reclaiming only wastes space, while over-reclaiming would corrupt.
10718 ///
10719 /// Chunk data addresses and sizes come from the same index walkers the
10720 /// reader uses, so they match the bytes the writer laid down exactly. The
10721 /// per-layout enumeration lives in
10722 /// [`chunked_read::collect_chunked_storage_spans`](crate::chunked_read::collect_chunked_storage_spans);
10723 /// this method only locates the layout and dataspace messages and validates
10724 /// the result. Variable-length data in global-heap collections is still
10725 /// never reclaimed (a collection can be shared between objects); see the
10726 /// [module docs](self).
10727 fn chunked_storage_spans(&self, addr: usize) -> Option<Vec<(u64, u64, FreeClass)>> {
10728 // Locate the data-layout and dataspace messages in the object header.
10729 let region =
10730 Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
10731 .ok()?;
10732 let mut layout_msg: Option<(usize, usize)> = None;
10733 let mut dataspace_msg: Option<(usize, usize)> = None;
10734 let mut p = 0;
10735 loop {
10736 match region.next_message(p) {
10737 Ok(Some((msg_type, body, body_end))) => {
10738 match msg_type {
10739 MessageType::DataLayout => layout_msg = Some((body, body_end)),
10740 MessageType::Dataspace => dataspace_msg = Some((body, body_end)),
10741 _ => {}
10742 }
10743 p = body_end;
10744 }
10745 Ok(None) => break,
10746 Err(_) => return None,
10747 }
10748 }
10749 let (lb, le) = layout_msg?;
10750 let (db, de) = dataspace_msg?;
10751
10752 let layout = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10753 if !matches!(layout, DataLayout::Chunked { .. }) {
10754 return None;
10755 }
10756 let dataspace = Dataspace::parse(®ion[db..de], LENGTH_SIZE).ok()?;
10757
10758 // Delegate the per-index-type enumeration to the chunked reader (the
10759 // single owner of chunk-storage layout knowledge), then validate: every
10760 // span must lie inside the current file image and be pairwise disjoint,
10761 // or the free list would later hand out live bytes (and a debug build
10762 // would panic on the double-free). On any error or violation, leave the
10763 // whole dataset unreclaimed rather than free a region still in use.
10764 //
10765 // The layout's stored addresses are relative to the userblock base, so the
10766 // enumeration runs on a base-relative view of the file and each returned
10767 // span address is shifted back to an absolute file offset by adding `base`
10768 // (a no-op on a base-0 file). The free list and the bounds check below both
10769 // work in absolute file offsets.
10770 let base = self.superblock.base_address;
10771 let split = crate::chunked_read::collect_chunked_storage_spans(
10772 &BaseOffsetSource {
10773 inner: &self.image(),
10774 base,
10775 },
10776 &layout,
10777 &dataspace,
10778 OFFSET_SIZE,
10779 LENGTH_SIZE,
10780 )
10781 .ok()?;
10782 // Chunk data is raw under every writer, so its spans are raw outright. The
10783 // index is only raw where this crate placed it — see
10784 // [`index_is_provably_raw`](Self::index_is_provably_raw) — and is recorded
10785 // as dead rather than free otherwise.
10786 let mut data: Vec<(u64, u64)> = Vec::with_capacity(split.data.len());
10787 for (addr, len) in split.data {
10788 data.push((base.absolute(addr).ok()?, len));
10789 }
10790 let mut index: Vec<(u64, u64)> = Vec::with_capacity(split.index.len());
10791 for (addr, len) in split.index {
10792 index.push((base.absolute(addr).ok()?, len));
10793 }
10794 // Validate both halves together — they must be disjoint from each other as
10795 // well as internally — before either is trusted, including by the proof.
10796 let mut plain: Vec<(u64, u64)> = data.iter().chain(index.iter()).copied().collect();
10797 if !spans_disjoint_in_bounds(&mut plain, self.image.len()) {
10798 return None;
10799 }
10800 let index_class = if self.index_is_provably_raw(&data, &index) {
10801 FreeClass::Page(PageType::Raw)
10802 } else {
10803 FreeClass::Dead
10804 };
10805 let mut spans: Vec<(u64, u64, FreeClass)> = data
10806 .iter()
10807 .map(|&(a, l)| (a, l, FreeClass::Page(PageType::Raw)))
10808 .collect();
10809 spans.extend(index.into_iter().map(|(a, l)| (a, l, index_class)));
10810 Some(spans)
10811 }
10812
10813 /// Whether a chunked dataset's `index` provably occupies raw pages, given the
10814 /// `data` spans it accompanies. Both are absolute file offsets.
10815 ///
10816 /// A chunk index is *metadata* by the format's taxonomy, and the reference C
10817 /// library allocates one as such — out of metadata pages, nowhere near the
10818 /// chunk data. This crate instead lays a dataset's index down in the same run
10819 /// as its chunk data, inside raw pages, so that a reader following the layout
10820 /// message walks one contiguous blob. Both are valid; what matters on a paged
10821 /// file is that freeing an index records it under the page type it actually
10822 /// sits in, since that decides what a later allocation may overwrite there.
10823 ///
10824 /// Nothing in the file says which writer produced it, so the index is placed
10825 /// from the layout itself, by two tests it must pass together: some chunk-data
10826 /// span must **abut** it — ending exactly where the index begins, or beginning
10827 /// exactly where it ends — and no byte of it may lie in **page 0**.
10828 ///
10829 /// The abutment is this crate's single blob, and is not a layout the reference
10830 /// library sets out to produce. A chunk index is metadata to that library,
10831 /// allocated out of metadata pages — and those are allocated from the bottom
10832 /// of the file, while large raw data goes above, so its index lands far below
10833 /// the chunk data it indexes rather than against it. Measured across seven
10834 /// files it wrote (Extensible and Fixed Array indexes, page sizes
10835 /// 512/4096/8192, 8 to 16 chunks): the index began in page 0 every time, with
10836 /// the chunk data starting at page 9 or higher, so neither join address was
10837 /// within reach of it.
10838 ///
10839 /// The page-0 screen closes the one way that measured layout can nonetheless
10840 /// satisfy the abutment. An index block that ends exactly on a page boundary
10841 /// with a raw page after it — a Fixed Array data block that exactly fills its
10842 /// page, say — is abutted by the first chunk in that raw page, and the whole
10843 /// index run would then be filed as raw, its header included, *even though
10844 /// that header sits in page 0 beside the superblock*. A later raw allocation
10845 /// would land inside a metadata page, which is the page-mixing defect of
10846 /// issue #261. Page 0 of a paged file always begins with the superblock, so it
10847 /// is a metadata page by construction and an index with a byte in it cannot be
10848 /// raw whatever abuts it. The screen costs this crate nothing: it allocates
10849 /// chunk data and the indexes beside it out of raw pages, and page 0 — never
10850 /// wholly free, since the superblock lives there — is never one of them.
10851 ///
10852 /// The tighter rule considered instead was to require the join address to fall
10853 /// *strictly inside* a page, which proves rawness outright: the byte before
10854 /// the join and the byte after it are then in one page, and a page holding
10855 /// chunk data is raw. It is sound, and a paged file the reference library
10856 /// wrote cannot satisfy it at all, since such a file never puts raw and
10857 /// metadata bytes in one page and so can only ever join them on a boundary.
10858 /// It was rejected because it also refuses layouts *this* crate produces:
10859 /// whenever a blob's chunk bytes total a page multiple from a page-aligned
10860 /// start, its index begins on a boundary too. Measured over 100
10861 /// `append_staged` commits at a 4096-byte page with 2048-byte chunks, that
10862 /// stranded about 500 bytes per commit — 62 KB against the 10 KB the abutment
10863 /// rule left — because the two writers produce the same geometry there and
10864 /// nothing in the file distinguishes them.
10865 ///
10866 /// Both sides have to be admitted, not just the index that follows its data.
10867 /// A staged append keeps the existing chunks where they are and places the new
10868 /// ones past the index it is superseding, so from the second append onward the
10869 /// dataset has chunk data on both sides of that index and the highest data
10870 /// address is no longer the one beside it. Asking only whether the index
10871 /// begins where the *last* chunk ends refused every such index, and a
10872 /// repeatedly appended dataset dropped one per commit (issue #388).
10873 ///
10874 /// An index the test does not place is [dead](FreeClass::Dead) to the caller
10875 /// rather than free: its bytes are no longer in use, but advertising space
10876 /// inside a possible metadata page for raw reuse would mix the page
10877 /// (issue #261), so they are reusable only once the whole page around them is
10878 /// empty.
10879 ///
10880 /// A non-paged file has no page types to keep apart, so everything is
10881 /// reclaimable there; the tag is ignored by its commit tail entirely.
10882 fn index_is_provably_raw(&self, data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
10883 match &self.paged {
10884 None => true,
10885 Some(paged) => {
10886 index_abuts_chunk_data(data, index)
10887 && !index_touches_page_zero(index, paged.page_size)
10888 }
10889 }
10890 }
10891
10892 /// Every on-disk byte span of a chunked dataset's *index structure only* (not
10893 /// its chunk data), for reclaiming the old index after a relocating append
10894 /// ([`MovingWrite::AppendedChunks`]) that keeps the chunk data in place. Mirror
10895 /// of [`chunked_storage_spans`](Self::chunked_storage_spans) but delegating to
10896 /// [`chunk_index_spans_buffered`], which enumerates only the EA header/index/
10897 /// data/super blocks and never a chunk-data address, so the shared kept chunk
10898 /// data is never freed. Base-aware and validated disjoint/in-bounds; returns
10899 /// `None` (leave unreclaimed) on any error or violation.
10900 fn chunked_index_spans(&self, addr: usize) -> Option<Vec<(u64, u64)>> {
10901 let region =
10902 Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
10903 .ok()?;
10904 let mut layout_msg: Option<(usize, usize)> = None;
10905 let mut p = 0;
10906 loop {
10907 match region.next_message(p) {
10908 Ok(Some((msg_type, body, body_end))) => {
10909 if msg_type == MessageType::DataLayout {
10910 layout_msg = Some((body, body_end));
10911 }
10912 p = body_end;
10913 }
10914 Ok(None) => break,
10915 Err(_) => return None,
10916 }
10917 }
10918 let (lb, le) = layout_msg?;
10919 let layout = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10920 if !matches!(layout, DataLayout::Chunked { .. }) {
10921 return None;
10922 }
10923 let base = self.superblock.base_address;
10924 let mut spans = chunk_index_spans_from_source(
10925 &BaseOffsetSource {
10926 inner: &self.image(),
10927 base,
10928 },
10929 &layout,
10930 OFFSET_SIZE,
10931 LENGTH_SIZE,
10932 )
10933 .ok()?;
10934 for (a, _) in &mut spans {
10935 *a = base.absolute(*a).ok()?;
10936 }
10937 if !spans_disjoint_in_bounds(&mut spans, self.image.len()) {
10938 return None;
10939 }
10940 Some(spans)
10941 }
10942}
10943
10944/// A dirty group in the edit plan: its base object-header message region and the
10945/// additions targeting it.
10946#[derive(Default)]
10947struct Node {
10948 is_new: bool,
10949 /// Names of links to remove from this group (from `delete`).
10950 deletes: Vec<String>,
10951 /// Copies to add to this group: (new link name, the source subtree read out
10952 /// for writing). Built at staging time from either this file (an in-file
10953 /// [`copy`](crate::File::copy)) or another open file (a cross-file
10954 /// [`copy_from`](crate::File::copy_from)).
10955 copies: Vec<(String, CopyTree)>,
10956 /// New link names this commit adds to this group by cross-file copy. The
10957 /// subtrees themselves stay in the staged set — which the preflight may
10958 /// still refuse, and must not empty (issue #316) — and join `copies` at the
10959 /// point of no return.
10960 cross_copies: Vec<String>,
10961 /// Value overwrites whose dataset header relocates (a resize or compact
10962 /// rewrite by `write_dataset`, a staged append, an attribute edit), as (child
10963 /// link name, the pre-commit object-header address, the relocation plan). On
10964 /// apply, the new data and header are written and this group's existing link
10965 /// to the moved header is patched to its new address — exactly like an
10966 /// existing child group's link. The old address is carried rather than
10967 /// re-resolved: the screen above and the reclaim below both need it, and one
10968 /// derivation cannot disagree with itself.
10969 writes: Vec<(String, u64, MovingWrite)>,
10970 base_region: OhRegion,
10971 existing_links: Vec<String>,
10972 /// What this group's attribute edits left for the apply loop, staged by
10973 /// [`plan_attr_ops`] and resolved by [`WriteEngine::place_edited_attrs`]
10974 /// right before this node's header is built. Default (an empty compact set)
10975 /// for a group with no attribute edits.
10976 attrs: EditedAttrs,
10977}
10978
10979/// A staged compact attribute edit for a group or dataset (shared by
10980/// [`Group::set_attr`](crate::Group::set_attr)/`remove_group_attr` and
10981/// [`Dataset::set_attr`](crate::Dataset::set_attr)/`remove_dataset_attr`).
10982enum AttrOp {
10983 Set { name: String, value: AttrValue },
10984 Remove { name: String },
10985}
10986
10987/// A source object parsed for copying. Headers are reproduced from their
10988/// verbatim message bytes; only the contiguous data address and child link
10989/// targets are repointed to the freshly-written copies.
10990enum ObjModel {
10991 /// A compact dataset (data inline in the header): copy the region verbatim.
10992 /// `dense_attrs` is empty unless the source stored its attributes densely, in
10993 /// which case the Attribute Info message and inline Attribute messages have
10994 /// been stripped from `region` and the parsed set is carried here to be
10995 /// re-emitted into a fresh fractal heap on write.
10996 DatasetVerbatim {
10997 region: OhRegion,
10998 dense_attrs: DenseAttrSet,
10999 },
11000 /// A contiguous dataset: copy the region, repointing the data address at
11001 /// `addr_off` (region-relative) to a fresh copy of `[data_addr, +data_size)`.
11002 /// See [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11003 DatasetContiguous {
11004 region: OhRegion,
11005 addr_off: usize,
11006 data_addr: u64,
11007 data_size: u64,
11008 dense_attrs: DenseAttrSet,
11009 },
11010 /// A chunked (and possibly filtered) dataset: the verbatim header `region`
11011 /// (datatype, dataspace, fill value, data layout, and filter pipeline kept as
11012 /// written). The chunk data is not captured here — [`read_copy_subtree`](WriteEngine::read_copy_subtree)
11013 /// enumerates and reads the chunks (it holds the source buffer), repointing the
11014 /// rebuilt index on write. See [`DatasetVerbatim`](ObjModel::DatasetVerbatim)
11015 /// for `dense_attrs`.
11016 DatasetChunked {
11017 region: OhRegion,
11018 dense_attrs: DenseAttrSet,
11019 },
11020 /// A group: every non-link message verbatim, plus its hard-link children to
11021 /// copy and re-link as `(name, creation index, target address)`. See
11022 /// [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11023 Group {
11024 non_link_region: OhRegion,
11025 children: Vec<(String, Option<u64>, u64)>,
11026 dense_attrs: DenseAttrSet,
11027 },
11028}
11029
11030/// An object subtree fully read out of a source buffer and owning every byte it
11031/// will write, the read result of [`WriteEngine::read_copy_subtree`] and the
11032/// input to [`WriteEngine::write_copy_subtree`]. Unlike [`ObjModel`] (a single
11033/// object still referencing source addresses) it is recursive and self-contained:
11034/// a contiguous dataset owns its data bytes, and a group owns its children, so it
11035/// can be written into the destination without the source buffer still in hand —
11036/// which is what lets a cross-file copy read the source at staging time and apply
11037/// it at commit time.
11038enum CopyTree {
11039 /// A compact dataset: the header region is written verbatim (data is inline).
11040 /// `dense_attrs`, when non-empty, is re-emitted into a freshly built fractal
11041 /// heap appended just before the header, whose Attribute Info message is
11042 /// spliced into the region on write.
11043 DatasetVerbatim {
11044 region: OhRegion,
11045 dense_attrs: DenseAttrSet,
11046 },
11047 /// A contiguous dataset: `data` is written first and its new address patched
11048 /// into the header `region` at `addr_off` before the header is written. See
11049 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11050 ///
11051 /// `data` is `None` when the source allocated no storage at all: nothing is
11052 /// written and `addr_off` keeps the undefined address the source stored, so
11053 /// the copy declares the same absent storage rather than a block at a real
11054 /// address. That is a statement about storage, not about length (issue
11055 /// #336).
11056 DatasetContiguous {
11057 region: OhRegion,
11058 addr_off: usize,
11059 data: Option<Vec<u8>>,
11060 dense_attrs: DenseAttrSet,
11061 },
11062 /// A chunked (and possibly filtered) dataset. The header `region` is written
11063 /// verbatim except its data-layout message, which is swapped for one naming the
11064 /// freshly rebuilt index; `chunk_bytes` (each chunk's already-compressed bytes,
11065 /// in dense row-major grid order, with sizes/masks in `meta`) and the source
11066 /// `pipeline_message` are carried unchanged, so the copy preserves the filter
11067 /// pipeline and chunk payloads byte-for-byte. The on-disk index *type* is
11068 /// reselected from `maxshape`/chunk count (single / fixed-array / extensible-
11069 /// array), so a B-tree-v1 or implicit source is reproduced with a v4 index. See
11070 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11071 DatasetChunked {
11072 region: OhRegion,
11073 /// The dataset's current shape. Held because the index's element
11074 /// numbering is taken over the *maximum* chunk grid, which needs both
11075 /// extents (see [`crate::chunk_grid`]).
11076 shape: Vec<u64>,
11077 chunk_dims: Vec<u64>,
11078 element_size: NonZeroUsize,
11079 maxshape: Option<Vec<u64>>,
11080 pipeline_message: Option<Vec<u8>>,
11081 meta: Vec<ChunkMeta>,
11082 chunk_bytes: Vec<Vec<u8>>,
11083 dense_attrs: DenseAttrSet,
11084 },
11085 /// A group: every non-link message verbatim, plus the `(name, creation
11086 /// index, subtree)` children to write first and re-link by name. See
11087 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11088 Group {
11089 non_link_region: OhRegion,
11090 children: Vec<(String, Option<u64>, CopyTree)>,
11091 dense_attrs: DenseAttrSet,
11092 },
11093}
11094
11095/// The addresses this commit invalidates, against which a reference it writes is
11096/// screened (issue #317).
11097///
11098/// An object reference *is* an object-header address, so a commit can falsify one
11099/// two ways. It can **remove** the object: a deletion frees its header and
11100/// storage, and a group's whole subtree with it
11101/// ([`WriteEngine::collect_free_spans`] is the walk that enumerates it), so the
11102/// address survives the commit only until the next allocation reuses the span and
11103/// then reads as whatever landed there. Or it can **move** it: a dirty group and
11104/// a relocating dataset write are both rebuilt at a fresh address, and the old
11105/// header is freed once the superblock is repointed.
11106///
11107/// Both are screened here because on the *path* side neither can come out as a
11108/// pre-commit address: [`WriteEngine::resolve_reference_target`] refuses a deleted
11109/// path by name, and a dirty group or a write target it either refuses as "still
11110/// writing" or — once this commit has placed the target — resolves to the address
11111/// it lands on. An address that skipped either check would be the one form that
11112/// writes the value the commit is vacating, which is the shape of the defect this
11113/// exists to close.
11114///
11115/// The removal spans are the very ones the commit hands to the free-space
11116/// manager, taken from one walk rather than two, so the screen and the reclaimer
11117/// cannot come to disagree about what this commit removes.
11118struct InvalidatedAddresses {
11119 /// Absolute `(offset, length)` file spans this commit's deletions reclaim, as
11120 /// [`collect_free_spans`](WriteEngine::collect_free_spans) reports them.
11121 removed: Vec<(u64, u64)>,
11122 /// Absolute pre-commit object-header addresses this commit rewrites
11123 /// elsewhere: every existing group it dirties, and every dataset write that
11124 /// relocates (a resizing or compact overwrite, a staged append, an attribute
11125 /// edit). An in-place overwrite does not relocate and is not here. The path
11126 /// side refuses it anyway, deliberately conservatively — `write_targets`
11127 /// records that a dataset is written, not which plan it got — while this list
11128 /// is filled from the plans themselves.
11129 moved: Vec<u64>,
11130 /// The superblock base address. A stored object reference is *base-relative*
11131 /// and both lists above are absolute, so the comparison needs it; it is zero
11132 /// for every file without a userblock.
11133 base: BaseAddress,
11134}
11135
11136impl InvalidatedAddresses {
11137 /// Whether this commit invalidates anything at all, so a caller with nothing
11138 /// cheap to check can skip its own work. Worth testing on the copy screen,
11139 /// whose `moved` is empty by construction; the supplied screen's never is,
11140 /// since every commit that builds one rebuilds its root group.
11141 fn is_empty(&self) -> bool {
11142 self.removed.is_empty() && self.moved.is_empty()
11143 }
11144
11145 /// The refusal `stored` earns — one object-reference element exactly as it is
11146 /// written to disk — or `None` when it still names what it named.
11147 ///
11148 /// The null (`0`) and undefined ([`UNDEF`]) references name no object at all,
11149 /// so neither is screened: the same two values
11150 /// [`crate::repack`](crate::repack) carries through verbatim rather than
11151 /// resolving, and the two [`crate::reader`] refuses to dereference.
11152 fn refusal(&self, stored: u64) -> Option<&'static str> {
11153 if stored == 0 || stored == UNDEF {
11154 return None;
11155 }
11156 // An address that cannot even be shifted into the file is not one of
11157 // ours; leave it to whatever reads it.
11158 let abs = self.base.absolute(stored).ok()?;
11159 if self
11160 .removed
11161 .iter()
11162 .any(|&(off, len)| abs >= off && abs - off < len)
11163 {
11164 return Some(REFERENCE_INTO_RECLAIMED_SPACE);
11165 }
11166 if self.moved.contains(&abs) {
11167 return Some(REFERENCE_TO_A_MOVED_OBJECT);
11168 }
11169 None
11170 }
11171}
11172
11173/// The validated, chunk-collapsed message region and existing link names of a
11174/// group header.
11175struct GroupInfo {
11176 region: OhRegion,
11177 link_names: Vec<String>,
11178}
11179
11180/// Element bytes staged for a value overwrite, together with the
11181/// variable-length staging (if any) whose global heap collections must be
11182/// placed — and whose addresses patched into the element references in `raw` —
11183/// before those bytes reach the file.
11184///
11185/// The two halves travel together because resolving them is an **apply-phase**
11186/// step while planning is a preflight one: placing a collection allocates, and
11187/// [`commit`](WriteEngine::commit)'s preflight only reads. So a plan carries
11188/// the bytes unresolved and every consumer resolves them where it writes, with
11189/// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) (issue
11190/// #321).
11191///
11192/// Patching never changes `raw`'s length — an element reference is fixed-width
11193/// whatever string it names — which is what lets the plan be *chosen* from the
11194/// unresolved bytes. The one place that is not enough is a filtered chunked
11195/// dataset, whose compressed chunk sizes do depend on the addresses: see
11196/// [`ChunkPayload::Deferred`].
11197struct OverwriteBytes {
11198 raw: Vec<u8>,
11199 /// `None` for every overwrite that stages no variable-length data, which is
11200 /// all of them but a `with_vlen_strings` one.
11201 vlen: Option<VlenOverwrite>,
11202}
11203
11204/// The variable-length half of an [`OverwriteBytes`]: the staged collections to
11205/// place, and the path they are recorded under once placed.
11206struct VlenOverwrite {
11207 staging: VlStringStaging,
11208 /// The overwritten dataset's path, the key its collections are recorded
11209 /// under in [`vl_overwrite_heaps`](WriteEngine::vl_overwrite_heaps) — and
11210 /// the key the ones a previous overwrite left are read back from, to be
11211 /// freed once this commit lands.
11212 path: PathKey,
11213}
11214
11215impl OverwriteBytes {
11216 /// Bytes that need no resolving, and so name nothing to record or free.
11217 fn ready(raw: Vec<u8>) -> Self {
11218 Self { raw, vlen: None }
11219 }
11220}
11221
11222/// How a staged value overwrite (`write_dataset`) will be applied, decided by
11223/// [`WriteEngine::prepare_write`] during the all-or-nothing preflight.
11224// `allow` rather than `expect`, which is otherwise this module's habit: the lint
11225// fires on a 64-bit pointer width and not on i686, where the `Vec` and `usize`
11226// fields inside `MovingWrite` shrink and the variant gap falls back under
11227// clippy's threshold. An `expect` is therefore unfulfilled on exactly the two
11228// 32-bit CI jobs, both of which deny `unfulfilled_lint_expectations`.
11229#[allow(
11230 clippy::large_enum_variant,
11231 reason = "boxing the large variant would cost more than it saves: this enum is returned by \
11232 `prepare_write` and destructured at its one call site, which moves the `MovingWrite` \
11233 straight into a `Vec<MovingWrite>` that stores it unboxed either way -- so a box \
11234 here adds an allocation and a free without removing a single copy"
11235)]
11236enum WritePlan {
11237 /// A contiguous dataset whose new data is the same length as its existing,
11238 /// defined data block: overwrite the bytes straight in place at `data_addr`.
11239 /// No object header is rewritten.
11240 ///
11241 /// The superblock root is not flipped either — *unless* the bytes still
11242 /// carry variable-length staging, which appends a heap collection and so
11243 /// moves end-of-file, a figure only the superblock records. `commit`'s
11244 /// fast path excludes exactly that case (issue #321).
11245 InPlace {
11246 data_addr: usize,
11247 bytes: OverwriteBytes,
11248 },
11249 /// A chunked dataset overwritten chunk-by-chunk in place: each `(addr, bytes)`
11250 /// pair is written straight over an existing chunk slot. Used when every new
11251 /// (re-encoded) chunk is the same byte length as the slot it replaces — an
11252 /// unfiltered chunked overwrite (chunk sizes are fixed by the unchanged shape)
11253 /// or a filtered one whose re-encoded chunks happen to match. Like
11254 /// [`InPlace`](WritePlan::InPlace) it touches no header and no chunk index, so
11255 /// the superblock root is not flipped.
11256 InPlaceChunks { writes: Vec<(usize, Vec<u8>)> },
11257 /// The dataset's header relocates: a contiguous resize, a compact rewrite, or
11258 /// a chunked rebuild. The parent group is rebuilt and its link patched. See
11259 /// [`MovingWrite`].
11260 Moving(MovingWrite),
11261}
11262
11263/// A value overwrite that relocates the dataset's object header — a contiguous
11264/// dataset whose data length changed (or had no data block) or a compact dataset
11265/// whose inline bytes are replaced. On apply the new data and a rewritten header
11266/// are written at end-of-file (or into reusable freed space), and the parent
11267/// group's link is repointed at the new header address.
11268enum MovingWrite {
11269 /// A contiguous dataset: write `raw` elsewhere, patch the data-layout address
11270 /// at `addr_off` in the verbatim header `region`, rewrite the header, and free
11271 /// `old_extent` (the prior data block, if any) after the commit lands.
11272 Contiguous {
11273 region: OhRegion,
11274 addr_off: usize,
11275 bytes: OverwriteBytes,
11276 old_extent: Option<(u64, u64)>,
11277 },
11278 /// A compact dataset: rebuild the header `region` with the bytes inline.
11279 Compact {
11280 region: OhRegion,
11281 bytes: OverwriteBytes,
11282 },
11283 /// A chunked dataset whose new (re-encoded) chunks do not all fit their
11284 /// existing slots, so its whole storage is rebuilt and relocated. A fresh
11285 /// chunk-data blob and index are placed — in a freed region that fits, else at
11286 /// end-of-file (via the verbatim
11287 /// layout path, carrying the chunk bytes and the source filter `pipeline_message`
11288 /// unchanged — no recompression and no filter-parameter reconstruction), the
11289 /// data-layout message in the verbatim header `region` is swapped for the new
11290 /// one (every other header message — datatype, dataspace, fill value, filter
11291 /// pipeline, and attributes, including a dense attribute heap referenced by an
11292 /// untouched Attribute Info message — is preserved verbatim), and the old
11293 /// chunk storage at `old_addr` is freed after the commit lands.
11294 Chunked {
11295 region: OhRegion,
11296 /// See [`CopyTree::DatasetChunked::shape`].
11297 shape: Vec<u64>,
11298 chunk_dims: Vec<u64>,
11299 element_size: NonZeroUsize,
11300 maxshape: Option<Vec<u64>>,
11301 pipeline_message: Option<Vec<u8>>,
11302 payload: ChunkPayload,
11303 old_addr: u64,
11304 },
11305 /// A relocating **append** to a chunked, unlimited, Extensible-Array-indexed
11306 /// dataset (`append_dataset`). The dataset's existing chunk *data* stays in
11307 /// place; only the newly-appended chunks and any rewritten trailing partial
11308 /// chunk (`new_chunk_bytes`, already compressed through the on-disk pipeline)
11309 /// are placed (reusing freed raw space where it fits), a fresh Extensible
11310 /// Array is rebuilt over
11311 /// `kept_chunks ++ new_chunk_bytes`, the verbatim header `region`'s dataspace
11312 /// message is grown (`new_dataspace_body`) and its data-layout message
11313 /// repointed at the new index (every other message — datatype, filter
11314 /// pipeline, fill value, attributes — preserved verbatim), and the header is
11315 /// relocated. After the commit lands, only the old index structure at
11316 /// `old_addr`, the old header, and the relocated old trailing chunk
11317 /// (`old_tail_extent`) are freed — never the kept chunk data, which both the
11318 /// old and new index share during the commit.
11319 AppendedChunks {
11320 region: OhRegion,
11321 /// The grown dataspace message body (v2-serialized), current axis-0
11322 /// dimension increased, maximum dimensions (unlimited) preserved.
11323 new_dataspace_body: Vec<u8>,
11324 /// Rank-only spatial chunk dimensions, for the rebuilt v4 layout message.
11325 chunk_dims_u32: Vec<u32>,
11326 element_size: NonZeroUsize,
11327 /// Full (uncompressed) chunk byte size = product(spatial) * element_size.
11328 has_filters: bool,
11329 /// Existing complete chunks, in index order, carried by metadata alone —
11330 /// their base-relative addresses, on-disk stored sizes, and filter masks
11331 /// preserved exactly (a nonzero mask from a C/h5py-skipped filter is kept).
11332 kept_chunks: Vec<WrittenChunk>,
11333 /// The appended chunks in index order: the recompressed trailing partial
11334 /// chunk first (when present), then the remaining new full chunks.
11335 new_chunk_bytes: Vec<Vec<u8>>,
11336 /// The dataset header address, for old-index and old-header reclaim.
11337 old_addr: u64,
11338 /// The absolute `(addr, len)` of the old trailing partial chunk's data
11339 /// block when it was rewritten, freed after the commit lands. `None` when
11340 /// the append was chunk-aligned (no partial chunk to rewrite).
11341 old_tail_extent: Option<(u64, u64)>,
11342 },
11343 /// A dataset-attribute edit (`set_dataset_attr` / `remove_dataset_attr`).
11344 /// The verbatim header `region` already carries whatever the preflight could
11345 /// resolve; `attrs` is what [`WriteEngine::write_moving`] still has to place —
11346 /// a variable-length attribute's heap collection, or a whole dense set to
11347 /// rebuild. The rewritten header is relocated and the parent link repointed,
11348 /// exactly like the other relocating writes — but the data-layout message is
11349 /// preserved verbatim, so the dataset's chunk data and index stay in place;
11350 /// only the old header is freed.
11351 AttrEdit {
11352 region: OhRegion,
11353 attrs: EditedAttrs,
11354 },
11355}
11356
11357/// The chunk data a relocating chunked overwrite ([`MovingWrite::Chunked`])
11358/// will place, either already encoded or still to be.
11359enum ChunkPayload {
11360 /// Split and encoded by the preflight, which had to do that anyway to find
11361 /// out whether the chunks still fit their slots.
11362 Encoded(Vec<Vec<u8>>),
11363 /// Element bytes that still carry unresolved variable-length references
11364 /// ([`OverwriteBytes::vlen`]), so the split has to wait for the apply
11365 /// phase: a filtered chunk's compressed length depends on the heap
11366 /// addresses patched into it, which is why the preflight cannot split
11367 /// first and patch after (issue #321).
11368 ///
11369 /// Such an overwrite therefore always relocates, which is what keeps the
11370 /// single-hard-link refusal (the one every [`MovingWrite`] answers to) in
11371 /// the preflight, where it belongs: whether it could have stayed in its
11372 /// slots is not knowable until those lengths exist.
11373 Deferred {
11374 bytes: OverwriteBytes,
11375 /// What the edge overhang of a partial chunk must hold (issue #296).
11376 padding: crate::fill_value::PaddingFill,
11377 /// The dataset's datatype, for the filter pipeline's [`ChunkContext`].
11378 dt: crate::datatype::Datatype,
11379 },
11380}
11381
11382/// A staged dataset reduced to the pieces the writer needs.
11383struct FlatDataset {
11384 name: String,
11385 dt: crate::datatype::Datatype,
11386 ds: Dataspace,
11387 raw: Vec<u8>,
11388 attrs: Vec<crate::attribute::AttributeMessage>,
11389 /// Chunked/filtered storage options. When [`ChunkOptions::is_chunked`] is
11390 /// false and `maxshape` is `None`, the dataset is written as contiguous,
11391 /// unfiltered storage; otherwise its chunk data and index are built by
11392 /// [`WriteEngine::build_chunked_dataset`].
11393 chunk_options: ChunkOptions,
11394 /// Maximum dimensions for an extensible dataset (an unlimited dimension is
11395 /// `u64::MAX`), mirrored into `ds.max_dimensions`. `None` for a fixed-shape
11396 /// dataset. A maxshape with an unlimited dimension selects the
11397 /// extensible-array chunk index; a finite maxshape stays fixed-array/single.
11398 maxshape: Option<Vec<u64>>,
11399 /// Variable-length attributes still carrying a placeholder heap address:
11400 /// (index into `attrs`, that attribute's global heap collections).
11401 /// Resolved in the apply loop right before this dataset's header is built.
11402 vl_attrs: Vec<(usize, Vec<Vec<u8>>)>,
11403 /// Whether `attrs` goes in a fractal heap rather than the object header,
11404 /// decided by `file_writer::needs_dense_attrs` where the set was validated
11405 /// against what that heap can represent.
11406 ///
11407 /// Carried rather than asked again in the apply loop, so the set that was
11408 /// checked and the set that is placed cannot be decided differently — and
11409 /// because the question costs a serialization of every attribute to answer,
11410 /// which is most expensive for exactly the oversized attribute it selects
11411 /// for. Patching a variable-length attribute's references is
11412 /// length-preserving, so nothing between the two phases can change the
11413 /// answer.
11414 attrs_are_dense: bool,
11415 /// A staged variable-length-string dataset's element references (still
11416 /// carrying placeholder heap addresses in `raw`) and global heap
11417 /// collections. Resolved in the apply loop right before `raw` is appended.
11418 vl_string_staging: Option<VlStringStaging>,
11419 /// An object-reference dataset's per-element targets, still unresolved.
11420 /// Resolved (see [`WriteEngine::resolve_reference_target`]) and patched
11421 /// into `raw` in the apply loop, once every object this commit places has
11422 /// a known address. `None` for an ordinary dataset.
11423 reference_targets: Option<Vec<ObjectRefPatch>>,
11424 /// A user-defined fill value, encoded in the dataset's datatype, or `None`
11425 /// for the library default. Validated against the datatype element size in
11426 /// [`flatten_dataset`].
11427 fill: Option<Vec<u8>>,
11428 /// The provenance attributes' own inputs, kept so they can be rebuilt when
11429 /// `raw` changes before the commit writes it. `None` for a dataset staged
11430 /// without [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
11431 #[cfg(feature = "provenance")]
11432 provenance: Option<StagedProvenance>,
11433}
11434
11435/// A staged dataset's provenance metadata, and where in
11436/// [`FlatDataset::attrs`] the attributes it produced sit.
11437///
11438/// The hash is over the dataset's raw bytes, so it is only correct as of the
11439/// bytes it was taken from; [`FlatDataset::rebuild_provenance`] is what keeps
11440/// it that way when [`WriteEngine::extend_staged_dataset`] grows them.
11441#[cfg(feature = "provenance")]
11442struct StagedProvenance {
11443 /// What [`crate::provenance::Provenance::build_attrs`] was, and is, called
11444 /// with. Held rather than re-derived from the attributes it produced, which
11445 /// a caller may also have set by hand.
11446 inputs: crate::provenance::Provenance,
11447 /// The index in `attrs` where its attributes begin. They are appended last
11448 /// and are the only ones this may replace, so everything below it — the
11449 /// caller's own attributes, and the `vl_attrs` indices into them — is left
11450 /// exactly as it was.
11451 attrs_start: usize,
11452}
11453
11454#[cfg(feature = "provenance")]
11455impl FlatDataset {
11456 /// Recompute this dataset's provenance attributes over the bytes it now
11457 /// holds. A no-op for a dataset staged without provenance.
11458 ///
11459 /// The rebuilt set is the same attributes in the same order and of the same
11460 /// serialized length — the digest is 64 hex characters whatever it hashes —
11461 /// so `attrs_are_dense`, decided once in [`flatten_dataset`] over the
11462 /// original set, still describes this one.
11463 fn rebuild_provenance(&mut self) {
11464 let Some(prov) = &self.provenance else {
11465 return;
11466 };
11467 self.attrs.truncate(prov.attrs_start);
11468 let rebuilt = prov.inputs.build_attrs(&self.raw);
11469 self.attrs.extend(rebuilt);
11470 }
11471}
11472
11473/// A borrow adapter that drives the shared Extensible-Array append engine
11474/// ([`crate::chunk_index_inplace`]) against the engine's *own* image and
11475/// superblock, so a session runs an immediate O(1) in-place append without
11476/// constructing a second writable handle (which would take a second exclusive
11477/// lock and keep a divergent view of the file). It borrows only those two
11478/// fields, leaving [`WriteEngine::located`] independently borrowable.
11479///
11480/// [`Store`] is the append engine's view of a file — an image *plus* the
11481/// superblock, which the image itself knows nothing about. Pairing them here is
11482/// all this adapter does; every primitive delegates, so the image's own
11483/// write-ordering discipline is what applies.
11484///
11485/// It carries the session's paged-file state too, so `alloc_raw` keeps a paged
11486/// file's pages homogeneous through exactly the rule the staged commit uses
11487/// ([`PagedEdit::begin`]). Before issue #198 there were two copies of that state —
11488/// one per engine — and the whole-file editor's copy was reachable only from the
11489/// commit path, so it had to refuse an in-place append to a paged file outright.
11490struct EditStore<'a> {
11491 image: &'a mut dyn FileImage,
11492 superblock: &'a mut Superblock,
11493 sb_sig_off: usize,
11494 /// The session's paged state when the file is paged, `None` otherwise. A
11495 /// borrow rather than a copy: padding recorded here has to reach the manager
11496 /// rewrite at the next commit or at close.
11497 paged: Option<&'a mut PagedEdit>,
11498 /// The list an immediate append may draw from, and `None` when it may draw
11499 /// from none — [`WriteEngine::immediate_reuse_allowed`] states which. It is
11500 /// the session's own free list on a file that forgets its holes at close, and
11501 /// [`WriteEngine::reserved`] — space already taken out of the on-disk
11502 /// managers — on one that persists them.
11503 free: Option<&'a mut FreeList>,
11504 /// The session's `fsync` cadence, carried by value: the append engine's own
11505 /// ordered barriers ([`apply_ea_append`]) are durability points like the
11506 /// commit's, and answer to the same policy.
11507 sync_policy: SyncPolicy,
11508}
11509
11510impl EditStore<'_> {
11511 /// Append `bytes` into a raw page, padding the tail page first when a paged
11512 /// file's tail holds metadata. A plain append on the common non-paged file.
11513 ///
11514 /// Raw is the only page type this adapter allocates: see
11515 /// [`Store::alloc_raw`](crate::chunk_index_inplace::Store::alloc_raw) for why
11516 /// an extensible-array index block belongs in a raw page here.
11517 fn append_into_raw_page(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11518 if let Some(pg) = self.paged.as_deref_mut() {
11519 pg.begin(self.image, PageType::Raw)?;
11520 }
11521 self.image.append(bytes)
11522 }
11523}
11524
11525impl crate::source::Source for EditStore<'_> {
11526 fn len(&self) -> u64 {
11527 self.image.len()
11528 }
11529 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), crate::error::FormatError> {
11530 self.image.read_at(offset, buf)
11531 }
11532 fn read_metadata_at(
11533 &self,
11534 offset: u64,
11535 len: usize,
11536 ) -> Result<Vec<u8>, crate::error::FormatError> {
11537 self.image.read_metadata_at(offset, len)
11538 }
11539
11540 // Forwarded because this is a `Source` wrapper over the image and the
11541 // trait requires it, not because anything asks yet: `File`'s own accessor
11542 // reaches the image directly. A wrapper that answered `None` for a live
11543 // cache is the failure that rule exists to prevent.
11544 fn metadata_cache_stats(&self) -> Option<crate::source::MetadataCacheStats> {
11545 self.image.metadata_cache_stats()
11546 }
11547
11548 fn reset_metadata_cache_stats(&self) {
11549 self.image.reset_metadata_cache_stats();
11550 }
11551}
11552
11553impl Store for EditStore<'_> {
11554 fn offset_size(&self) -> u8 {
11555 self.superblock.offset_size
11556 }
11557 fn length_size(&self) -> u8 {
11558 self.superblock.length_size
11559 }
11560 fn alloc_raw(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11561 if let Some(free) = self.free.as_deref_mut() {
11562 if let Some(addr) = free.alloc(bytes.len() as u64) {
11563 self.image.write_at(addr, bytes)?;
11564 return Ok(addr);
11565 }
11566 }
11567 self.append_into_raw_page(bytes)
11568 }
11569 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
11570 self.image.write_at(offset, bytes)
11571 }
11572 fn patch_superblock_eof(&mut self) -> Result<(), Error> {
11573 // Advance only the recorded end-of-file and re-serialize the superblock in
11574 // place. Unlike `WriteEngine::commit`, this deliberately does NOT clear the
11575 // consistency flags and does NOT repoint the root group: base_address is 0
11576 // for every in-place-append-eligible file, so the normalized-absolute root
11577 // address serializes back to the same stored value.
11578 let eof = self.image.len();
11579 self.superblock.eof_address = eof;
11580 let bytes = self.superblock.serialize();
11581 self.write_at(self.sb_sig_off as u64, &bytes)
11582 }
11583 fn sync(&mut self) -> Result<(), Error> {
11584 barrier_data(self.image, self.sync_policy)
11585 }
11586}
11587
11588/// Whether two object paths are equal or one is an ancestor of the other.
11589fn paths_overlap(a: &[String], b: &[String]) -> bool {
11590 a.starts_with(b) || b.starts_with(a)
11591}
11592
11593/// Whether two appender claims cover the same dataset. A path-less claim (a
11594/// handle reached by object reference) names its dataset by an address nothing
11595/// else can compare against, so it conflicts with every other claim.
11596fn claims_conflict(a: Option<&[String]>, b: Option<&[String]>) -> bool {
11597 match (a, b) {
11598 (Some(x), Some(y)) => x == y,
11599 _ => true,
11600 }
11601}
11602
11603/// Re-tag a refusal from the shared append engine (`AppendUnsupported`) as the
11604/// fast-path [`Error::AppendInPlaceUnsupported`], so a caller can catch it and fall
11605/// back to the staged [`append_dataset`](WriteEngine::append_dataset) — which
11606/// handles the filtered partial-trailing-chunk case, index-geometry limits, and
11607/// platform-width limits that the engine reports this way. Genuine I/O and format
11608/// errors pass through unchanged.
11609pub(crate) fn as_inplace_error(e: Error) -> Error {
11610 match e {
11611 Error::AppendUnsupported(m) => Error::AppendInPlaceUnsupported(m),
11612 other => other,
11613 }
11614}
11615
11616/// Validate a gathered append's bytes against a located dataset: the byte
11617/// length must be a whole number of elements, and the element datatype must
11618/// match the on-disk datatype (or, for a raw append, be raw-appendable).
11619/// Returns the appended element count (`0` = nothing to do). Shared by
11620/// [`WriteEngine::append_inplace_gathered`]'s path and the bounded backend's immediate
11621/// append so the acceptance rules stay identical.
11622pub(crate) fn validate_gathered_append(st: &LocatedState, b: &AppendBuilder) -> Result<u64, Error> {
11623 let raw = b.raw();
11624 if raw.len() % st.element_size != 0 {
11625 return Err(Error::AppendInPlaceUnsupported(
11626 "appended byte length is not a whole number of elements",
11627 ));
11628 }
11629 match b.elem_dt() {
11630 Some(expected) if *expected != st.datatype => {
11631 return Err(Error::AppendInPlaceUnsupported(
11632 "append datatype does not match the on-disk dataset (wrong element \
11633 type or byte order)",
11634 ));
11635 }
11636 Some(_) => {}
11637 None => {
11638 if !datatype_is_raw_appendable(&st.datatype) {
11639 return Err(Error::AppendInPlaceUnsupported(
11640 "append_raw onto this dataset's datatype (non-little-endian, \
11641 variable-length, or reference) could misencode the bytes; use a \
11642 typed append",
11643 ));
11644 }
11645 }
11646 }
11647 Ok((raw.len() / st.element_size) as u64)
11648}
11649
11650/// Locate the dataset at `oh_addr` in `file` and build its [`LocatedState`],
11651/// validating in-place append eligibility (rank-1 / unlimited / Extensible-Array
11652/// indexed, a nonzero chunk length, and a re-encodable filter pipeline). Mirrors
11653/// the append writer's `ensure_located`, reporting through
11654/// [`Error::AppendInPlaceUnsupported`].
11655pub(crate) fn locate_dataset_state<F: Store>(
11656 file: &F,
11657 oh_addr: u64,
11658) -> Result<LocatedState, Error> {
11659 let result = Located::locate_at(file, oh_addr, Error::AppendInPlaceUnsupported)?;
11660 if result.located.chunk_elems == 0 {
11661 return Err(Error::AppendInPlaceUnsupported(
11662 "in-place append requires a nonzero chunk length",
11663 ));
11664 }
11665 let (dt_off, dt_size) = result.spans.datatype;
11666 let dt_bytes = file
11667 .read_metadata_at(dt_off, dt_size)
11668 .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
11669 let (datatype, _) = Datatype::parse(&dt_bytes)
11670 .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
11671 let pipeline = match result.spans.filter {
11672 Some((fb, fsize)) => {
11673 let fp_bytes = file.read_metadata_at(fb, fsize).map_err(|_| {
11674 Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
11675 })?;
11676 let parsed = FilterPipeline::parse(&fp_bytes).map_err(|_| {
11677 Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
11678 })?;
11679 if !pipeline_reencodable(&parsed) {
11680 return Err(Error::AppendInPlaceUnsupported(
11681 "dataset uses a filter this engine cannot re-encode",
11682 ));
11683 }
11684 Some(parsed)
11685 }
11686 None => None,
11687 };
11688 let element_size = result.located.elem_bytes;
11689 let spatial = vec![result.located.chunk_elems];
11690 // A fill message that cannot be read leaves `PaddingFill::Unknown`, which
11691 // fails only where a chunk actually needs padding — an append that lands on
11692 // a chunk boundary asks nothing of it.
11693 let fill = match result.spans.fill {
11694 Some((msg_type, off, size)) => match file.read_metadata_at(off, size) {
11695 Ok(body) => crate::fill_value::PaddingFill::from_message(msg_type, &body),
11696 Err(_) => crate::fill_value::PaddingFill::Unknown,
11697 },
11698 None => crate::fill_value::PaddingFill::Zero,
11699 };
11700 Ok(LocatedState {
11701 loc: result.located,
11702 datatype,
11703 spatial,
11704 element_size,
11705 pipeline,
11706 fill,
11707 })
11708}
11709
11710/// Split a path into non-empty components.
11711fn split_path(path: &str) -> PathKey {
11712 path.split('/')
11713 .filter(|s| !s.is_empty())
11714 .map(String::from)
11715 .collect()
11716}
11717
11718/// Group `(parent group, item)` pairs by their parent, preserving the input
11719/// order within each group.
11720///
11721/// The one rule by which a commit's staged datasets become per-group batches:
11722/// the preflight groups borrowed ones to prove its guards, and the apply loop
11723/// groups the owned ones it places, so the order
11724/// [`preflight_reference_targets`](WriteEngine::preflight_reference_targets)
11725/// replays is the order the apply loop uses by construction.
11726fn group_by_parent<K: Ord, T>(items: impl IntoIterator<Item = (K, T)>) -> BTreeMap<K, Vec<T>> {
11727 let mut out: BTreeMap<K, Vec<T>> = BTreeMap::new();
11728 for (parent, item) in items {
11729 out.entry(parent).or_default().push(item);
11730 }
11731 out
11732}
11733
11734/// Ensure a node exists for every ancestor prefix of `path` (so each is rebuilt
11735/// and can re-wire its child link). Does not set `is_new`.
11736fn ensure_ancestors(nodes: &mut BTreeMap<PathKey, Node>, path: &[String]) {
11737 for len in 0..=path.len() {
11738 nodes.entry(path[..len].to_vec()).or_default();
11739 }
11740}
11741
11742/// Validate that every reclaim span `(addr, len)` is non-empty, ends at or
11743/// before `eof`, and that no two overlap; sorts `spans` by address as a side
11744/// effect. Returns `false` on any violation so the caller can decline to
11745/// reclaim the object rather than feed the free list an out-of-bounds or
11746/// overlapping (double-free) region. Touching spans are allowed — the free list
11747/// coalesces them.
11748fn spans_disjoint_in_bounds(spans: &mut [(u64, u64)], eof: u64) -> bool {
11749 for &(addr, len) in spans.iter() {
11750 match addr.checked_add(len) {
11751 Some(end) if len > 0 && end <= eof => {}
11752 _ => return false,
11753 }
11754 }
11755 spans.sort_unstable_by_key(|&(addr, _)| addr);
11756 spans.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
11757}
11758
11759/// Sanitize the accumulated free spans for a whole commit so the free list never
11760/// sees an out-of-bounds or overlapping (double-free) region: drop empty or
11761/// past-`eof` spans, sort by address, then drop any span overlapping one already
11762/// kept. Dropping only leaks (the bytes stay allocated); it never frees a live
11763/// region. With the last-hard-link guard in force nothing should be dropped for
11764/// a well-formed file — this is a backstop, not the primary defense.
11765fn retain_disjoint_in_bounds(spans: &mut Vec<(u64, u64, FreeClass)>, eof: u64) {
11766 spans.retain(|&(addr, len, _)| len > 0 && addr.checked_add(len).is_some_and(|e| e <= eof));
11767 spans.sort_unstable_by_key(|&(addr, _, _)| addr);
11768 let mut kept_end = 0u64;
11769 spans.retain(|&(addr, len, _)| {
11770 if addr >= kept_end {
11771 kept_end = addr + len;
11772 true
11773 } else {
11774 false // overlaps a span already kept; leak it rather than double-free
11775 }
11776 });
11777}
11778
11779/// Whether some `data` span abuts the run of `index` blocks — ends exactly where
11780/// the index begins, or begins exactly where it ends. The geometric half of
11781/// [`WriteEngine::index_is_provably_raw`], where the reasoning lives; split out so
11782/// the rule can be stated against spans alone.
11783///
11784/// An empty index is vacuously placed: there is nothing to file.
11785fn index_abuts_chunk_data(data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
11786 if index.is_empty() {
11787 return true;
11788 }
11789 // The index as a whole: this crate writes its blocks as one run beside the
11790 // chunk data, so a data span abutting either end places all of them.
11791 let (Some(index_start), Some(index_end)) = (
11792 index.iter().map(|&(a, _)| a).min(),
11793 index.iter().filter_map(|&(a, l)| a.checked_add(l)).max(),
11794 ) else {
11795 return false;
11796 };
11797 // A zero-length span touches an address without occupying a byte beside it,
11798 // so it places nothing.
11799 data.iter().any(|&(addr, len)| {
11800 len > 0 && (addr.checked_add(len) == Some(index_start) || addr == index_end)
11801 })
11802}
11803
11804/// Whether any byte of the `index` run lies in page 0 of a paged file, whose
11805/// first bytes are the superblock and which is therefore a metadata page. The
11806/// screening half of [`WriteEngine::index_is_provably_raw`], where the reasoning
11807/// lives.
11808///
11809/// A page size of zero is not a paged file's; it cannot be reasoned about, so it
11810/// screens everything out.
11811fn index_touches_page_zero(index: &[(u64, u64)], page_size: u64) -> bool {
11812 if page_size == 0 {
11813 return !index.is_empty();
11814 }
11815 index.iter().any(|&(addr, len)| len > 0 && addr < page_size)
11816}
11817
11818/// Tag object-header chunk spans as file metadata. Every span
11819/// [`oh_chunk_spans`](EditSession::oh_chunk_spans) returns is part of an object
11820/// header, so the page type is the same for all of them.
11821fn meta_spans(spans: Vec<(u64, u64)>) -> impl Iterator<Item = (u64, u64, FreeClass)> {
11822 spans
11823 .into_iter()
11824 .map(|(a, l)| (a, l, FreeClass::Page(PageType::Meta)))
11825}
11826
11827/// Validate a staged dataset and reduce it to a [`FlatDataset`]. Contiguous,
11828/// unfiltered datasets are emitted as such; chunked, filtered, or extensible
11829/// datasets carry their [`ChunkOptions`] and maxshape through to the commit,
11830/// where [`WriteEngine::build_chunked_dataset`] lays out their chunk data and
11831/// index. An empty (zero-element) shape is allowed under either storage,
11832/// mirroring the whole-file writer: a contiguous one takes the `HADDR_UNDEF`
11833/// data address (see the apply loop) and a chunked one an index over zero
11834/// chunks, which is what an extensible dataset is created as before the first
11835/// append fills it. The geometry validation below still requires explicit chunk
11836/// dimensions for it — auto-chunking has no shape to derive them from. A
11837/// `provenance` dataset has its SHA-256/creator/timestamp/source attributes
11838/// computed here from `raw`, exactly as the whole-file writer does. A
11839/// variable-length attribute's global heap collection is built here (it is
11840/// fully self-contained — no address of its own) but placed and patched later,
11841/// in the apply loop, once its final address is known; likewise a
11842/// variable-length-string dataset's staged references and collection
11843/// (`db.vl_string_staging`) are carried through unresolved. An object-reference
11844/// dataset's per-element targets (`db.reference_targets`) are likewise carried
11845/// through unresolved — resolving a path target requires knowing every other
11846/// object this commit places, which is only known well into the apply loop
11847/// (see [`WriteEngine::resolve_reference_target`]). Rejects any remaining
11848/// feature this engine cannot reproduce faithfully: a
11849/// chunked/extensible variable-length-string or object-reference dataset, or a
11850/// filter pipeline the build cannot construct.
11851fn flatten_dataset(db: DatasetBuilder) -> Result<FlatDataset, Error> {
11852 if db.name.is_empty() {
11853 return Err(Error::EditUnsupported("dataset path has an empty name"));
11854 }
11855 let dt = db
11856 .datatype
11857 .ok_or(Error::EditUnsupported("dataset has no datatype/data"))?;
11858 let shape = db
11859 .shape
11860 .ok_or(Error::EditUnsupported("dataset has no shape"))?;
11861 let is_empty = shape.contains(&0);
11862 let chunked = db.chunk_options.is_chunked() || db.maxshape.is_some();
11863 // Storage this engine allocates for every dataset it places: it appends into
11864 // an existing layout, and the undefined data address the whole-file writer
11865 // gives an unallocated dataset (issue #293) has no equivalent here. Refused
11866 // by name rather than left to the "dataset has no data" arm below, which is
11867 // the same symptom from the opposite cause, and refused rather than ignored,
11868 // since ignoring it would write out the grid of fill values the caller asked
11869 // not to have.
11870 if db.allocation == StorageAllocation::Unallocated {
11871 return Err(Error::EditUnsupported(
11872 "a dataset with unallocated storage cannot be added to an existing file in place",
11873 ));
11874 }
11875 // Variable-length string element references live in the global heap, whose
11876 // address is only known once the apply loop places the collection. For
11877 // chunked/filtered/resizable storage the references sit inside chunks
11878 // written before that address exists, so patching them in is impossible.
11879 //
11880 // The whole-file writer lifted the same restriction by placing such a
11881 // dataset's collections ahead of everything else (issue #109); this engine
11882 // appends into an existing layout, where there is no "ahead" to place them
11883 // in, so the equivalent fix is a separate piece of work.
11884 if db.vl_string_staging.is_some() && chunked {
11885 return Err(Error::EditUnsupported(
11886 "chunked or extensible variable-length-string datasets cannot be added in place yet",
11887 ));
11888 }
11889 // Object-reference elements are resolved (see `resolve_reference_target`)
11890 // and patched into `raw` right before it is appended; for chunked storage
11891 // that patch would need to reach inside already-built chunk data, which
11892 // this engine does not support (mirrors the variable-length-string
11893 // refusal above — untested and unneeded combination for v1).
11894 if db.reference_targets.is_some() && chunked {
11895 return Err(Error::EditUnsupported(
11896 "chunked or extensible object-reference datasets cannot be added in place yet",
11897 ));
11898 }
11899 let raw = if is_empty {
11900 db.data.unwrap_or_default()
11901 } else {
11902 db.data
11903 .ok_or(Error::EditUnsupported("dataset has no data"))?
11904 };
11905
11906 // Refused for the same reason the whole-file writer refuses it: nothing
11907 // occupies zero bytes per element, the writers divide by the element size,
11908 // and a caller-built `Datatype` never passes through `Datatype::parse`.
11909 // Taking it as a `NonZeroUsize` hands the proof to the staging below rather
11910 // than leaving each step to re-derive it.
11911 let elem_size = dt.element_size_usize()?;
11912
11913 let elem = elem_size.get() as u64;
11914 // Multiply with checked arithmetic: an absurd shape whose element count
11915 // (or byte size) overflows `u64` is refused rather than panicking in a
11916 // debug build or silently wrapping in release (which could let a wrapped
11917 // product spuriously match `raw.len()`). For a zero-element shape this
11918 // expected length is always 0 (a `0` dimension makes every checked
11919 // multiplication `Some(0)` regardless of the other dimensions), so this
11920 // also catches data mistakenly supplied for a shape that holds nothing.
11921 let expected = shape
11922 .iter()
11923 .try_fold(1u64, |acc, &d| acc.checked_mul(d))
11924 .and_then(|n| n.checked_mul(elem));
11925 match expected {
11926 Some(expected) if raw.len() as u64 == expected => {}
11927 Some(_) => {
11928 return Err(Error::EditUnsupported(
11929 "dataset data length does not match its shape",
11930 ));
11931 }
11932 None => {
11933 return Err(Error::EditUnsupported(
11934 "dataset shape is too large to address on this platform",
11935 ));
11936 }
11937 }
11938
11939 if chunked {
11940 // Refuse malformed chunk geometry up front (the same validation the
11941 // whole-file writer applies), so a bad request — chunk dimensions of the
11942 // wrong rank, a zero chunk dimension, an inconsistent maximum shape, or
11943 // chunking a scalar — never reaches and panics the chunk splitter, nor
11944 // yields a dataset the reader cannot decode.
11945 db.chunk_options
11946 .validate_geometry(&shape, db.maxshape.as_deref())
11947 .map_err(Error::EditUnsupported)?;
11948 // A filter this build cannot apply is refused up front rather than
11949 // failing mid-apply when a chunk is compressed.
11950 db.chunk_options
11951 .refuse_unavailable_filters()
11952 .map_err(Error::EditUnsupported)?;
11953 // Validate the requested filter pipeline now — before any file bytes are
11954 // written — so an unsupported filter, an incompatible datatype, or a
11955 // fill value the filter cannot record is refused up front; the chunk
11956 // data itself is laid out in the commit's apply phase. A filter the
11957 // build compiled out is not among them: `build_pipeline` emits its
11958 // descriptor regardless, which is why the check above exists. Chunked/filtered
11959 // storage flows through the very builder the normal writer uses
11960 // ([`compress_chunks`] + [`assemble_chunked_at`] + [`build_chunked_dataset_oh`]),
11961 // so the
11962 // resulting object header is byte-identical to a freshly written one.
11963 //
11964 // The fill value goes in for the same reason the apply phase passes it:
11965 // scale-offset records it in the filter's parameters, so a validation
11966 // that left it out would be checking a pipeline the commit does not
11967 // build — and would pass a fill value the encoder later refuses.
11968 let chunk_dims = db.chunk_options.resolve_chunk_dims(&shape);
11969 let ctx = ChunkContext::from_datatype(&chunk_dims, &dt)?;
11970 db.chunk_options
11971 .build_pipeline(
11972 &ctx,
11973 crate::fill_value::FillPattern::new(
11974 db.fill.as_deref(),
11975 crate::convert::nonzero_usize_from(ctx.element_size)?,
11976 ),
11977 )
11978 .map_err(|_| {
11979 Error::EditUnsupported(
11980 "this dataset's filter pipeline cannot be added in place \
11981 (an unsupported filter, an incompatible datatype, or a \
11982 fill value the filter cannot record)",
11983 )
11984 })?;
11985 }
11986
11987 // The link message body (whose length is independent of the address) must
11988 // fit the object-header message's u16 size field; a pathologically long
11989 // name would otherwise overflow it into silent corruption. Measured with a
11990 // creation index present — the widest form, written into a group that tracks
11991 // link creation order — since the parent group is not known here.
11992 let mut sized = make_link(&db.name, 0);
11993 sized.creation_order = Some(0);
11994 if sized.serialize(OFFSET_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
11995 return Err(Error::EditUnsupported(
11996 "dataset name is too long to encode as a link message",
11997 ));
11998 }
11999
12000 let ds = Dataspace {
12001 space_type: if shape.is_empty() {
12002 DataspaceType::Scalar
12003 } else {
12004 DataspaceType::Simple
12005 },
12006 #[expect(
12007 clippy::cast_possible_truncation,
12008 reason = "dataspace rank fits the 1-byte dimensionality field (HDF5 caps rank at 32)"
12009 )]
12010 rank: shape.len() as u8,
12011 dimensions: shape,
12012 // A chunked, extensible dataset records its maximum dimensions (an
12013 // unlimited dimension is `u64::MAX`); a fixed-shape dataset has none.
12014 max_dimensions: db.maxshape.clone(),
12015 };
12016 let mut attrs: Vec<crate::attribute::AttributeMessage> = Vec::with_capacity(db.attrs.len());
12017 for (n, v) in &db.attrs {
12018 attrs.push(v.to_message(n));
12019 }
12020 // The message above already carries a placeholder (heap address 0) for each
12021 // element of a variable-length string attribute; stage its self-contained
12022 // global heap collections here (no address of their own to resolve yet) and
12023 // record which `attrs` slot they patch once the apply loop places them.
12024 let mut vl_attrs: Vec<(usize, Vec<Vec<u8>>)> = Vec::new();
12025 for (i, (_, v)) in db.attrs.iter().enumerate() {
12026 if let Some(strings) = v.var_len_strings() {
12027 vl_attrs.push((i, build_global_heap_collections(strings)));
12028 }
12029 }
12030 // Appended last, and recorded as such: `raw` can still grow before the
12031 // commit writes it (`WriteEngine::extend_staged_dataset`), and the digest
12032 // has to follow it.
12033 #[cfg(feature = "provenance")]
12034 let provenance = db.provenance.as_ref().map(|prov| {
12035 let inputs = crate::provenance::Provenance {
12036 creator: prov.creator.clone(),
12037 timestamp: prov.timestamp.clone(),
12038 source: prov.source.clone(),
12039 };
12040 let attrs_start = attrs.len();
12041 attrs.extend(inputs.build_attrs(&raw));
12042 StagedProvenance {
12043 inputs,
12044 attrs_start,
12045 }
12046 });
12047 // More attributes than an object header keeps compactly, or one whose message
12048 // overflows its 2-byte message-size field, sends the set to a fractal heap —
12049 // the same disjunction, and the same heap, the whole-file writer uses. What
12050 // that heap cannot represent is refused here, in the preflight, so a staged
12051 // dataset that cannot be written is refused before the commit places a byte.
12052 let attrs_are_dense = crate::file_writer::needs_dense_attrs(&attrs);
12053 if attrs_are_dense {
12054 crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
12055 }
12056
12057 // A committed datatype is an object of its own, which an in-place edit has no
12058 // way to place: it appends into an existing file rather than laying one out,
12059 // so there is nothing to resolve the named path against. Writing the type
12060 // inline instead would produce a dataset that reads correctly but no longer
12061 // shares the named type, so refuse by name. The whole-file writer places
12062 // them; [`crate::repack`] is the route from an edited file to one.
12063 if db.datatype_location.is_committed()
12064 || attrs.iter().any(|a| a.datatype_location.is_committed())
12065 {
12066 return Err(Error::EditUnsupported(
12067 "a dataset or attribute naming a committed (shared) datatype cannot be added in \
12068 place; write the file with FileBuilder instead",
12069 ));
12070 }
12071
12072 // A user-defined fill value is one element wide, so its byte length must
12073 // equal the datatype's element size (mirrors the whole-file writer's check).
12074 if let Some(fill) = &db.fill {
12075 let expected = elem.to_usize()?;
12076 if fill.len() != expected {
12077 return Err(Error::Format(FormatError::FillValueSizeMismatch {
12078 expected,
12079 actual: fill.len(),
12080 }));
12081 }
12082 }
12083
12084 Ok(FlatDataset {
12085 name: db.name,
12086 dt,
12087 ds,
12088 raw,
12089 attrs,
12090 chunk_options: db.chunk_options,
12091 maxshape: db.maxshape,
12092 vl_attrs,
12093 attrs_are_dense,
12094 vl_string_staging: db.vl_string_staging,
12095 reference_targets: db.reference_targets,
12096 fill: db.fill,
12097 #[cfg(feature = "provenance")]
12098 provenance,
12099 })
12100}
12101
12102/// A minimal Group Info message body (type 0x000A): version 0 with neither the
12103/// link-phase-change nor the estimated-entry fields stored. With both absent the
12104/// HDF5 C library fills `max_compact`/`min_dense` from its own defaults (8 and
12105/// 6). See [`ensure_group_info`] for why every group needs this message.
12106const GROUP_INFO_BODY: [u8; 2] = [0, 0];
12107
12108/// Frame one chunk-0 object-header message record: a 1-byte type, a 2-byte
12109/// little-endian body length, a 1-byte flags field (always 0 here), then the
12110/// body. This is the v2 message-record layout used throughout a group's chunk-0
12111/// message region. Callers pass bodies that fit the u16 length field: link
12112/// bodies are validated in [`flatten_dataset`], and the Link Info / Group Info
12113/// bodies are fixed and short.
12114/// Whether a chunked dataset with this data-layout version and chunk index type
12115/// can be enumerated chunk-by-chunk (and therefore overwritten or copied in
12116/// place). Mirrors the dispatch in
12117/// [`chunked_read::collect_chunks_for_layout_from_source`](crate::chunked_read):
12118/// version-3 B-tree v1 and the version-4 single / implicit / fixed-array /
12119/// extensible-array indexes have walkers; a version-2 B-tree (index type 5) or
12120/// any unknown index type does not.
12121fn chunk_index_enumerable(version: u8, chunk_index_type: Option<u8>) -> bool {
12122 matches!((version, chunk_index_type), (3, _) | (4, Some(1..=4)))
12123}
12124
12125/// Whether every filter in `pipeline` is one this crate can *apply* (re-encode a
12126/// chunk through) — not merely decode. A pipeline with any other filter cannot be
12127/// re-encoded for an in-place overwrite, so the caller refuses with a typed error
12128/// rather than letting `compress_chunk` surface a raw `UnsupportedFilter`.
12129pub(crate) fn pipeline_reencodable(pipeline: &FilterPipeline) -> bool {
12130 pipeline.filters.iter().all(|f| match f.filter_id {
12131 FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_SCALEOFFSET | FILTER_LZF => {
12132 true
12133 }
12134 #[cfg(feature = "zfp")]
12135 crate::filter_pipeline::FILTER_ZFP => true,
12136 _ => false,
12137 })
12138}
12139
12140/// Whether re-encoding a chunk through `pipeline` reproduces the values it was
12141/// decoded from — the condition for rewriting a chunk that is **already
12142/// committed**, as growing a partial trailing chunk does.
12143///
12144/// Stricter than [`pipeline_reencodable`], and for a different question. That
12145/// one asks whether this crate can *apply* the filters at all, which is what a
12146/// brand-new chunk needs. This one asks whether decode-then-encode is the
12147/// identity, which is what a chunk somebody has already read needs. Deflate,
12148/// shuffle, fletcher32 and LZF are lossless by construction; scale-offset only
12149/// in its integer mode; ZFP fixed-rate quantizes every block to a bit budget, so
12150/// re-encoding it against a *different* set of neighbours in the same block
12151/// re-quantizes the values that were already there.
12152///
12153/// This is the line [`repack`](crate::repack)'s `check_pipeline` already draws
12154/// for its two re-encoding paths, stated once more here because the append
12155/// engine reaches it by a different route.
12156pub(crate) fn pipeline_lossless(pipeline: &FilterPipeline) -> bool {
12157 pipeline.filters.iter().all(|f| match f.filter_id {
12158 FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_LZF => true,
12159 // The integer mode subtracts a per-chunk minimum and packs the residuals
12160 // whole; float D-scale rounds to a decimal count and is documented lossy.
12161 // Anything else (float E-scale) this crate neither writes nor decodes.
12162 FILTER_SCALEOFFSET => matches!(
12163 crate::scaleoffset::scale_offset_mode(&f.client_data),
12164 Some((crate::scaleoffset::ScaleOffset::Integer(_), _))
12165 ),
12166 // Unknown ids included: a filter whose semantics are unknown is not one
12167 // to assume round-trips.
12168 _ => false,
12169 })
12170}
12171
12172/// The refusal both append paths raise for a lossy pipeline sitting on a partial
12173/// trailing chunk. Named here beside the predicate it goes with, and used from
12174/// [`chunk_index_inplace`](crate::chunk_index_inplace) as well as the staged
12175/// rebuild above.
12176pub(crate) const LOSSY_TAIL_REFUSAL: &str = "this dataset's filter pipeline is lossy (ZFP, or float D-scale scale-offset), and its \
12177 length is not a whole multiple of the chunk length: growing that trailing chunk would \
12178 decode and re-encode values that are already committed, changing them. Append whole \
12179 chunks from a chunk-aligned length instead";
12180
12181/// Rebuild a header message `region`, replacing the single Data Layout message's
12182/// record with one carrying `new_layout_body` and leaving every other message
12183/// (datatype, dataspace, fill value, filter pipeline, attributes, attribute info)
12184/// byte-for-byte. The replacement may differ in length from the original — a
12185/// chunked rebuild can change the index type and thus the layout message size — so
12186/// the record is rebuilt via [`region_message`] rather than patched in place. The
12187/// chunked overwrite and copy paths use this to relocate a dataset's chunk storage
12188/// while preserving the rest of its header exactly.
12189fn replace_layout_message(region: &OhRegion, new_layout_body: &[u8]) -> Result<OhRegion, Error> {
12190 let mut out = Vec::with_capacity(region.len());
12191 let mut p = 0;
12192 let mut replaced = false;
12193 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12194 if msg_type == MessageType::DataLayout && !replaced {
12195 out.extend_from_slice(
12196 ®ion
12197 .layout()
12198 .record(MessageType::DataLayout, new_layout_body),
12199 );
12200 replaced = true;
12201 } else {
12202 out.extend_from_slice(®ion[p..body_end]);
12203 }
12204 p = body_end;
12205 }
12206 if !replaced {
12207 return Err(Error::EditUnsupported(
12208 "chunked dataset header has no data-layout message to relocate",
12209 ));
12210 }
12211 Ok(region.with_bytes(out))
12212}
12213
12214/// Rebuild a header message `region`, replacing the single Dataspace message's
12215/// record with one carrying `new_dataspace_body` (the grown current dimensions,
12216/// v2-serialized, maximum dimensions preserved) and leaving every other message
12217/// byte-for-byte. Used by the append path to grow a dataset's axis-0 dimension.
12218/// The replacement may differ in length from the original (a v1 on-disk
12219/// dataspace is normalized to v2 in the rebuilt header), so the record is rebuilt
12220/// via [`region_message`] rather than patched in place.
12221fn replace_dataspace_message(
12222 region: &OhRegion,
12223 new_dataspace_body: &[u8],
12224) -> Result<OhRegion, Error> {
12225 let mut out = Vec::with_capacity(region.len());
12226 let mut p = 0;
12227 let mut replaced = false;
12228 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12229 if msg_type == MessageType::Dataspace && !replaced {
12230 out.extend_from_slice(
12231 ®ion
12232 .layout()
12233 .record(MessageType::Dataspace, new_dataspace_body),
12234 );
12235 replaced = true;
12236 } else {
12237 out.extend_from_slice(®ion[p..body_end]);
12238 }
12239 p = body_end;
12240 }
12241 if !replaced {
12242 return Err(Error::AppendUnsupported(
12243 "dataset header has no dataspace message to grow",
12244 ));
12245 }
12246 Ok(region.with_bytes(out))
12247}
12248
12249/// Whether a datatype's raw on-disk bytes can be appended verbatim from a caller
12250/// via [`AppendBuilder::append_raw`]. True only when every scalar leaf is safe to
12251/// write as flat little-endian bytes:
12252///
12253/// - numeric leaves (fixed-point, floating-point, time, bit field) must be
12254/// little-endian, or the caller's little-endian bytes would silently misencode
12255/// into a big-endian (or VAX) field;
12256/// - string and opaque leaves are byte arrays with no numeric byte order, so they
12257/// are order-agnostic and safe;
12258/// - aggregates (enumeration, array, compound) are appendable iff every leaf is;
12259/// - variable-length and reference leaves embed global-heap or object addresses
12260/// that a flat byte append cannot reproduce, so they are never raw-appendable.
12261///
12262/// A typed `append_*` bypasses this: it checks full datatype equality instead, so
12263/// it already refuses every non-little-endian and non-scalar dataset.
12264pub(crate) fn datatype_is_raw_appendable(dt: &Datatype) -> bool {
12265 match dt {
12266 Datatype::FixedPoint { byte_order, .. }
12267 | Datatype::FloatingPoint { byte_order, .. }
12268 | Datatype::Time { byte_order, .. }
12269 | Datatype::BitField { byte_order, .. } => *byte_order == DatatypeByteOrder::LittleEndian,
12270 Datatype::String { .. } | Datatype::Opaque { .. } => true,
12271 Datatype::Enumeration { base_type, .. } | Datatype::Array { base_type, .. } => {
12272 datatype_is_raw_appendable(base_type)
12273 }
12274 Datatype::Compound { members, .. } => members
12275 .iter()
12276 .all(|m| datatype_is_raw_appendable(&m.datatype)),
12277 Datatype::VariableLength { .. } | Datatype::Reference { .. } => false,
12278 }
12279}
12280
12281/// The datatype, dataspace, parsed chunked data layout, and verbatim filter-
12282/// pipeline message bytes (if any) of a chunked dataset header, parsed by
12283/// [`parse_chunked_header`].
12284struct ChunkedHeaderParts {
12285 dt: crate::datatype::Datatype,
12286 ds: Dataspace,
12287 layout: DataLayout,
12288 pipeline_message: Option<Vec<u8>>,
12289}
12290
12291/// Parse the datatype, dataspace, chunked data layout, and verbatim filter-
12292/// pipeline message bytes (if any) from a chunked dataset header `region`. Used by
12293/// the chunked copy path to derive chunk geometry and the on-disk filter pipeline.
12294/// Errors if any required message is missing or the layout is not chunked.
12295fn parse_chunked_header(region: &OhRegion) -> Result<ChunkedHeaderParts, Error> {
12296 let mut datatype: Option<(usize, usize)> = None;
12297 let mut dataspace: Option<(usize, usize)> = None;
12298 let mut layout: Option<(usize, usize)> = None;
12299 let mut pipeline: Option<(usize, usize)> = None;
12300 let mut p = 0;
12301 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12302 match msg_type {
12303 MessageType::Datatype => datatype = Some((body, body_end)),
12304 MessageType::Dataspace => dataspace = Some((body, body_end)),
12305 MessageType::DataLayout => layout = Some((body, body_end)),
12306 MessageType::FilterPipeline => pipeline = Some((body, body_end)),
12307 _ => {}
12308 }
12309 p = body_end;
12310 }
12311 let (dt_b, dt_e) = datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
12312 let (ds_b, ds_e) =
12313 dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
12314 let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
12315 let (dt, _) = crate::datatype::Datatype::parse(®ion[dt_b..dt_e])
12316 .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
12317 let ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE)
12318 .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
12319 let dl = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE)
12320 .map_err(|_| Error::EditUnsupported("dataset header data layout could not be parsed"))?;
12321 if !matches!(dl, DataLayout::Chunked { .. }) {
12322 return Err(Error::EditUnsupported("dataset is not chunked"));
12323 }
12324 let pipeline_message = pipeline.map(|(b, e)| region[b..e].to_vec());
12325 Ok(ChunkedHeaderParts {
12326 dt,
12327 ds,
12328 layout: dl,
12329 pipeline_message,
12330 })
12331}
12332
12333/// The chunk geometry a verbatim chunked rebuild needs, derived by
12334/// [`chunked_geometry`] from a chunked dataset's datatype, dataspace, and parsed
12335/// [`DataLayout::Chunked`].
12336struct ChunkedGeometry {
12337 /// Rank-only spatial chunk dimensions.
12338 spatial: Vec<u64>,
12339 /// Element size in bytes, proven non-zero: the chunk splitter divides by
12340 /// it, and so does the append path's element-count arithmetic.
12341 element_size: NonZeroUsize,
12342 /// Full (uncompressed) chunk byte size, `product(spatial) * element_size`.
12343 /// This is what the chunk index's element width is derived from, so it has
12344 /// to be the geometry's product and not any written chunk's size.
12345 raw_size: u64,
12346 /// The on-disk maximum dimensions when they differ from the current shape; an
12347 /// unlimited dimension selects the extensible-array index, a finite one the
12348 /// fixed-array index. `None` keeps the fixed-array / single-chunk index.
12349 maxshape: Option<Vec<u64>>,
12350}
12351
12352/// Derive the [`ChunkedGeometry`] for a chunked dataset from its datatype,
12353/// dataspace, and parsed [`DataLayout::Chunked`].
12354fn chunked_geometry(
12355 dt: &crate::datatype::Datatype,
12356 ds: &Dataspace,
12357 layout: &DataLayout,
12358) -> Result<ChunkedGeometry, Error> {
12359 let DataLayout::Chunked {
12360 chunk_dimensions, ..
12361 } = layout
12362 else {
12363 return Err(Error::EditUnsupported("dataset is not chunked"));
12364 };
12365 let rank = ds.dimensions.len();
12366 if chunk_dimensions.len() <= rank {
12367 return Err(Error::EditUnsupported(
12368 "chunked layout has malformed dimensions",
12369 ));
12370 }
12371 let spatial: Vec<u64> = chunk_dimensions[..rank]
12372 .iter()
12373 .map(|&c| u64::from(c))
12374 .collect();
12375 let element_size = dt.element_size_usize()?;
12376 let raw_size = spatial
12377 .iter()
12378 .copied()
12379 .product::<u64>()
12380 .saturating_mul(element_size.get() as u64);
12381 let maxshape = ds
12382 .max_dimensions
12383 .as_ref()
12384 .filter(|ms| *ms != &ds.dimensions)
12385 .cloned();
12386 Ok(ChunkedGeometry {
12387 spatial,
12388 element_size,
12389 raw_size,
12390 maxshape,
12391 })
12392}
12393
12394/// The element bytes a staged value overwrite will write, paired with the
12395/// variable-length staging that has still to be resolved into them and the
12396/// `path` they belong to.
12397///
12398/// Cloned rather than moved because the staged set must survive a refused
12399/// commit whole (issue #316), and this runs in the preflight that may refuse.
12400fn staged_bytes(fd: &FlatDataset, path: &PathKey) -> OverwriteBytes {
12401 OverwriteBytes {
12402 raw: fd.raw.clone(),
12403 vlen: fd.vl_string_staging.clone().map(|staging| VlenOverwrite {
12404 staging,
12405 path: path.clone(),
12406 }),
12407 }
12408}
12409
12410/// Split `raw` into full-size chunk buffers in dense row-major grid order and
12411/// re-encode each through `pipeline_message`, the dataset's on-disk filter
12412/// pipeline.
12413///
12414/// The overhang past the dataset's edge holds the dataset's own fill value, not
12415/// zeros: an allocated chunk is expected to carry it wherever nothing was
12416/// written, and those slots are what a reader returns once the dataset is
12417/// extended into them (issue #296).
12418///
12419/// The caller has already refused a pipeline
12420/// [`pipeline_reencodable`] rejects, so the only errors here are a malformed
12421/// pipeline message (unreachable for the same reason) and the encoder's own.
12422fn split_and_encode_chunks(
12423 raw: &[u8],
12424 shape: &[u64],
12425 chunk_dims: &[u64],
12426 element_size: NonZeroUsize,
12427 padding: &crate::fill_value::PaddingFill,
12428 pipeline_message: Option<&[u8]>,
12429 dt: &crate::datatype::Datatype,
12430) -> Result<Vec<Vec<u8>>, Error> {
12431 let split = split_into_chunks(
12432 raw,
12433 shape,
12434 chunk_dims,
12435 element_size,
12436 padding.pattern(element_size),
12437 )
12438 .map_err(Error::Format)?;
12439 let Some(pm) = pipeline_message else {
12440 return Ok(split);
12441 };
12442 let pipeline = FilterPipeline::parse(pm)
12443 .map_err(|_| Error::EditUnsupported("dataset filter pipeline could not be parsed"))?;
12444 let ctx = ChunkContext::from_datatype(chunk_dims, dt)?;
12445 let mut encoded = Vec::with_capacity(split.len());
12446 // One encoder across the rewrite; see `FilterScratch`.
12447 let mut scratch = FilterScratch::new();
12448 for buf in &split {
12449 encoded.push(compress_chunk_with(&mut scratch, buf, &pipeline, ctx)?);
12450 }
12451 Ok(encoded)
12452}
12453
12454/// Try to overwrite a chunked dataset's chunks in place. When the dataset's
12455/// on-disk chunks form a dense grid aligned with `new_bytes` (dense row-major
12456/// order), every slot is unmasked (`filter_mask == 0`), and every new chunk
12457/// **fits** the slot it replaces (`new_len <= slot`), return the in-place
12458/// `(address, bytes)` writes:
12459///
12460/// - When every new chunk is **exactly** its slot's size, only the chunk data is
12461/// written; the index is untouched (so any enumerable index type works, and a
12462/// crash can tear at most a chunk's value bytes, not the structure).
12463/// - When some new chunks are **smaller** (fit with slack), the chunk index
12464/// records each chunk's stored size, so the index is rebuilt in place to record
12465/// the new sizes (see [`try_rebuild_index_in_place`]). This is supported only
12466/// for a v4 fixed-array or extensible-array index occupying a single contiguous
12467/// on-disk region; any other case returns `None` to relocate.
12468///
12469/// Returns `None` — so the caller relocates the dataset instead — when the index
12470/// cannot be enumerated, the grid is sparse, a slot is masked, a new chunk does
12471/// not fit, the index cannot be rebuilt in place, or any write would be out of
12472/// bounds or overlap another.
12473fn try_inplace_chunk_writes<S: Source + ?Sized>(
12474 src: &S,
12475 layout: &DataLayout,
12476 ds: &Dataspace,
12477 spatial: &[u64],
12478 raw_size: u64,
12479 new_bytes: &[Vec<u8>],
12480) -> Option<Vec<(usize, Vec<u8>)>> {
12481 let infos = enumerate_chunks_from_source(src, layout, ds, OFFSET_SIZE, LENGTH_SIZE).ok()?;
12482 let grid = plan_dense_grid(infos, &ds.dimensions, spatial)?;
12483 if grid.grid_order.len() != new_bytes.len() {
12484 return None;
12485 }
12486 let mut writes = Vec::with_capacity(new_bytes.len() + 1);
12487 let mut spans: Vec<(u64, u64)> = Vec::with_capacity(new_bytes.len() + 1);
12488 let mut any_shrunk = false;
12489 for (ci, bytes) in grid.grid_order.iter().zip(new_bytes.iter()) {
12490 // A nonzero filter mask means the source left some filter unapplied for
12491 // this chunk; re-encoding always applies every filter (mask 0), so an
12492 // in-place overwrite would desync the index-recorded mask. Relocate.
12493 if ci.filter_mask != 0 {
12494 return None;
12495 }
12496 let new_len = bytes.len() as u64;
12497 let slot = u64::from(ci.chunk_size);
12498 // A chunk that no longer fits its slot must relocate.
12499 if new_len > slot {
12500 return None;
12501 }
12502 if new_len < slot {
12503 any_shrunk = true;
12504 }
12505 let start = usize::try_from(ci.address).ok()?;
12506 start
12507 .checked_add(bytes.len())
12508 .filter(|&e| e as u64 <= src.len())?;
12509 writes.push((start, bytes.clone()));
12510 spans.push((ci.address, new_len));
12511 }
12512
12513 // A shrinking overwrite changes the index-recorded chunk sizes, so the index
12514 // must be rebuilt in place to match; an equal-size one leaves it untouched.
12515 if any_shrunk {
12516 let (index_addr, index_bytes) = try_rebuild_index_in_place(
12517 src,
12518 layout,
12519 ds,
12520 spatial,
12521 raw_size,
12522 &grid.grid_order,
12523 new_bytes,
12524 )?;
12525 spans.push((index_addr as u64, index_bytes.len() as u64));
12526 writes.push((index_addr, index_bytes));
12527 }
12528
12529 // Refuse to perform overlapping in-place writes (a malformed source index, or
12530 // an index region that overlaps a chunk slot); relocate instead so two writes
12531 // never clobber each other.
12532 if !spans_disjoint_in_bounds(&mut spans, src.len()) {
12533 return None;
12534 }
12535 Some(writes)
12536}
12537
12538/// Rebuild a chunked dataset's index **in place** so it records the new
12539/// (smaller) per-chunk stored sizes after a fits-with-slack overwrite, returning
12540/// the `(address, bytes)` write that replaces it. The chunks keep their existing
12541/// addresses (only their stored bytes shrank), so the rebuilt index points at the
12542/// same slots with the new sizes.
12543///
12544/// Supported only for a v4 **fixed-array** or **extensible-array** index whose
12545/// on-disk structure is a single contiguous region starting at the index address
12546/// — the layout this crate's own writer produces. The element width derives from
12547/// the unchanged raw chunk size, so the rebuilt structure is byte-for-byte the
12548/// same length as the original; this is required to match exactly, which rejects a
12549/// scattered or differently-laid-out (e.g. C-written) index, leaving the caller
12550/// to relocate. Single-chunk (size in the layout message) and B-tree-v1 (no
12551/// writer) indexes are not rebuilt here.
12552///
12553/// Like any in-place value overwrite (the HDF5 `H5Dwrite` model) this is not
12554/// atomic: a crash mid-write can tear the index and leave the dataset needing a
12555/// rewrite. It is used only on the in-place path, whose linearization point is the
12556/// synced data write.
12557#[allow(clippy::too_many_arguments)]
12558fn try_rebuild_index_in_place<S: Source + ?Sized>(
12559 src: &S,
12560 layout: &DataLayout,
12561 ds: &Dataspace,
12562 spatial: &[u64],
12563 raw_size: u64,
12564 grid_order: &[crate::chunked_read::ChunkInfo],
12565 new_bytes: &[Vec<u8>],
12566) -> Option<(usize, Vec<u8>)> {
12567 let DataLayout::Chunked {
12568 btree_address: Some(index_addr),
12569 chunk_index_type,
12570 version,
12571 ..
12572 } = layout
12573 else {
12574 return None;
12575 };
12576 let written: Vec<crate::chunked_write::WrittenChunk> = grid_order
12577 .iter()
12578 .zip(new_bytes)
12579 .map(|(ci, b)| crate::chunked_write::WrittenChunk {
12580 address: ci.address,
12581 compressed_size: b.len() as u64,
12582 filter_mask: 0,
12583 })
12584 .collect();
12585 // `grid_order` is dense row-major over the *current* shape; where each of
12586 // those chunks sits in the index is the maximum grid's business, and the
12587 // rebuild has to reproduce the numbering the original index was written
12588 // with. Same rule, same function as the writer.
12589 let (_, slot_of_chunk, index_slots) = crate::chunked_write::plan_index_slots(
12590 &ds.dimensions,
12591 spatial,
12592 ds.max_dimensions.as_deref(),
12593 raw_size,
12594 true,
12595 // Rebuilding the index of a dataset whose chunks are in hand.
12596 crate::chunked_write::StorageAllocation::Allocated,
12597 )
12598 .ok()?;
12599 let slots =
12600 crate::chunked_write::IndexSlots::new(&written, &slot_of_chunk, index_slots).ok()?;
12601 let new_index = match (version, chunk_index_type) {
12602 // `raw_size` is the whole-chunk byte size, which is what the element
12603 // width derives from — the same value the original index was built with,
12604 // so the rebuilt structure matches its length. (An index written by a
12605 // version that derived the width from the written chunks instead can
12606 // disagree; the length check below then rejects it and the caller
12607 // relocates, which is the safe direction.)
12608 (4, Some(3)) => crate::chunked_write::build_fixed_array_at(
12609 &slots,
12610 raw_size,
12611 OFFSET_SIZE,
12612 LENGTH_SIZE,
12613 true,
12614 *index_addr,
12615 ),
12616 (4, Some(4)) => crate::chunked_write::build_extensible_array_at(
12617 &slots,
12618 raw_size,
12619 OFFSET_SIZE,
12620 LENGTH_SIZE,
12621 true,
12622 *index_addr,
12623 )
12624 .ok()?,
12625 // Single-chunk records its size in the layout message (a header rewrite),
12626 // and a B-tree-v1 index has no writer; both relocate instead.
12627 _ => return None,
12628 };
12629
12630 // The on-disk index must be a single contiguous region starting at the index
12631 // address, and the rebuilt structure must be exactly the same length (true for
12632 // an index this crate wrote). A scattered or different on-disk layout fails
12633 // the check and the caller relocates.
12634 let mut spans =
12635 crate::chunked_read::chunk_index_spans_from_source(src, layout, OFFSET_SIZE, LENGTH_SIZE)
12636 .ok()?;
12637 if spans.is_empty() {
12638 return None;
12639 }
12640 spans.sort_unstable_by_key(|&(a, _)| a);
12641 if spans[0].0 != *index_addr {
12642 return None;
12643 }
12644 let mut end = *index_addr;
12645 for &(a, l) in &spans {
12646 if a != end {
12647 return None; // a gap means the index is not contiguous
12648 }
12649 end = a.checked_add(l)?;
12650 }
12651 if new_index.len() as u64 != end - *index_addr {
12652 return None;
12653 }
12654 let start = usize::try_from(*index_addr).ok()?;
12655 start
12656 .checked_add(new_index.len())
12657 .filter(|&e| e as u64 <= src.len())?;
12658 Some((start, new_index))
12659}
12660
12661/// A [`ChunkProvider`] over chunk bytes already held in memory, in dense
12662/// row-major grid order. Used by the editor's chunked copy and relocating
12663/// overwrite, which own each chunk's bytes (a [`CopyTree`] or [`MovingWrite`]
12664/// captured them) rather than streaming from a source file like repack.
12665struct SliceChunkProvider<'a> {
12666 chunks: &'a [Vec<u8>],
12667}
12668
12669impl ChunkProvider for SliceChunkProvider<'_> {
12670 fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
12671 let chunk = self.chunks.get(index).ok_or_else(|| {
12672 FormatError::ChunkedReadError("chunk index out of range for in-memory provider".into())
12673 })?;
12674 out.extend_from_slice(chunk);
12675 Ok(())
12676 }
12677}
12678
12679/// The chunk-0 message region of a fresh, empty compact-link group: a LinkInfo
12680/// message advertising no dense storage, followed by a GroupInfo message.
12681/// Mirrors `build_group_oh`.
12682fn fresh_group_region() -> OhRegion {
12683 let mut li = Vec::with_capacity(18);
12684 li.push(0); // version
12685 li.push(0); // flags
12686 li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap addr = UNDEF
12687 li.extend_from_slice(&u64::MAX.to_le_bytes()); // btree name index addr = UNDEF
12688 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
12689 region.push(MessageType::LinkInfo, &li);
12690 region.push(MessageType::GroupInfo, &GROUP_INFO_BODY);
12691 region
12692}
12693
12694/// Ensure a group's chunk-0 message `region` carries a Group Info message,
12695/// appending a minimal one when absent.
12696///
12697/// The HDF5 C library refuses to insert a link into a group whose object header
12698/// has a Link Info message but no Group Info message: on the new-format path
12699/// `H5G_obj_insert` reads the Group Info message unconditionally and fails with
12700/// "message type not found". Such a group round-trips for *reading* but cannot
12701/// be *modified* by the C library. Earlier hdf5-pure releases wrote groups that
12702/// way, so heal any such header whenever we rewrite one in place.
12703fn ensure_group_info(region: &mut OhRegion) -> Result<(), Error> {
12704 let mut p = 0;
12705 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12706 if msg_type == MessageType::GroupInfo {
12707 return Ok(());
12708 }
12709 p = body_end;
12710 }
12711 region.push(MessageType::GroupInfo, &GROUP_INFO_BODY);
12712 Ok(())
12713}
12714
12715/// Ensure a chunk-0 message `region` that carries inline Attribute messages also
12716/// carries an Attribute Info message, appending the compact-storage one when
12717/// absent.
12718///
12719/// On a version 2 object header the reference C library never counts attribute
12720/// messages: `H5O__attr_count_real` reads the count out of the Attribute Info
12721/// message, and reports zero when there is none, even though `H5Aiterate` and
12722/// `H5Aopen_by_name` still find every attribute. Tools that size their work by
12723/// that count then skip the attributes silently — `h5repack` copies such an
12724/// object with none of them. Releases through 0.33.0 wrote every compact
12725/// attribute set that way, so heal any such header whenever we rewrite one, the
12726/// same reason [`ensure_group_info`] exists.
12727///
12728/// A region already carrying an Attribute Info message is left alone, whichever
12729/// storage it names: a defined heap address means dense storage, whose count the
12730/// C library takes from the heap's B-tree instead.
12731fn ensure_attribute_info(region: &mut OhRegion) -> Result<(), Error> {
12732 let mut has_attrs = false;
12733 let mut p = 0;
12734 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12735 match msg_type {
12736 MessageType::AttributeInfo => return Ok(()),
12737 MessageType::Attribute => has_attrs = true,
12738 _ => {}
12739 }
12740 p = body_end;
12741 }
12742 if has_attrs {
12743 let body = compact_attribute_info_body(region)?;
12744 region.push(MessageType::AttributeInfo, &body);
12745 }
12746 Ok(())
12747}
12748
12749/// Encode a complete object-header Link message (4-byte record header + body)
12750/// for a hard link `name -> addr`. The caller must have validated that the body
12751/// fits the u16 size field (see [`flatten_dataset`]); group names are short.
12752///
12753/// `creation_order` is the link's creation index, which a group tracking **link**
12754/// creation order carries on every link and no other group carries at all
12755/// ([`LinkCreationOrder`] is what hands one out). It is a flagged field of the
12756/// Link message *body*, quite separate from the object header's own per-message
12757/// creation index: that one records an *attribute*'s creation order, and the
12758/// reference C library writes it as the zero [`OhRecordLayout::record`] passes
12759/// for every other message type.
12760fn encode_link_message(
12761 layout: OhRecordLayout,
12762 name: &str,
12763 addr: u64,
12764 creation_order: Option<u64>,
12765) -> Vec<u8> {
12766 let mut link = make_link(name, addr);
12767 link.creation_order = creation_order;
12768 let body = link.serialize(OFFSET_SIZE);
12769 layout.record(MessageType::Link, &body)
12770}
12771
12772/// Patch an existing hard Link message in a chunk-0 message `region`, retargeting
12773/// the link named `name` to `new_addr` (used to repoint a parent at a relocated
12774/// child group). The target address is the trailing `OFFSET_SIZE` bytes of the
12775/// link body for a hard link.
12776fn patch_link_target(region: &mut OhRegion, name: &str, new_addr: u64) -> Result<(), Error> {
12777 let mut p = 0;
12778 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12779 if msg_type == MessageType::Link {
12780 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
12781 if link.name == name {
12782 return match link.link_target {
12783 LinkTarget::Hard { .. } => {
12784 let ofs = body_end - OFFSET_SIZE as usize;
12785 region.bytes_mut()[ofs..body_end]
12786 .copy_from_slice(&new_addr.to_le_bytes());
12787 Ok(())
12788 }
12789 _ => Err(Error::EditUnsupported(
12790 "a group on the edited path is reached by a soft/external link",
12791 )),
12792 };
12793 }
12794 }
12795 }
12796 p = body_end;
12797 }
12798 Err(Error::EditUnsupported(
12799 "expected child link not found in parent group",
12800 ))
12801}
12802
12803/// Bytes a compact Data Layout message carries ahead of its inline data:
12804/// version(1) + class(1) + the 2-byte inline size.
12805const COMPACT_LAYOUT_PREAMBLE: usize = 4;
12806
12807/// Copy a chunk-0 message `region`, replacing the single (compact) Data Layout
12808/// message's inline data with `raw` and preserving every other message verbatim.
12809/// Used by `write_dataset` to overwrite a compact dataset's values. The message
12810/// header (type and flags) and version byte are kept; only the inline data — and
12811/// the message size and 2-byte inline-size fields — change. `raw` must fit both
12812/// the compact layout's own 2-byte size field (HDF5's 64 KiB compact-storage
12813/// limit) and, once the 4-byte layout preamble is added, the object header's
12814/// 2-byte message-size field — the tighter of the two, which an overwrite of an
12815/// existing compact dataset always satisfies.
12816fn rebuild_compact_layout_region(region: &OhRegion, raw: &[u8]) -> Result<OhRegion, Error> {
12817 // The bound is on the *message body* the layout becomes — version, class,
12818 // and the 2-byte inline size ahead of the data — not on `raw` alone, or the
12819 // last four lengths below the limit would truncate the size field written
12820 // for them.
12821 if raw.len() > OBJECT_HEADER_MESSAGE_MAX - COMPACT_LAYOUT_PREAMBLE {
12822 return Err(Error::EditUnsupported(
12823 "compact dataset data is too large to overwrite in place",
12824 ));
12825 }
12826 let mut out = Vec::with_capacity(region.len() + raw.len());
12827 let mut p = 0;
12828 let mut replaced = false;
12829 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12830 if msg_type == MessageType::DataLayout {
12831 if body_end - body < 2 || region[body + 1] != 0 {
12832 return Err(Error::EditUnsupported(
12833 "compact-layout overwrite found a non-compact data layout",
12834 ));
12835 }
12836 // New compact layout body: version (kept), class=0, 2-byte inline
12837 // size, then the data.
12838 let mut layout = Vec::with_capacity(COMPACT_LAYOUT_PREAMBLE + raw.len());
12839 layout.push(region[body]); // version (3 or 4)
12840 layout.push(0); // class = compact
12841 #[expect(
12842 clippy::cast_possible_truncation,
12843 reason = "raw.len() bounded below the u16 inline-size field above"
12844 )]
12845 layout.extend_from_slice(&(raw.len() as u16).to_le_bytes());
12846 layout.extend_from_slice(raw);
12847 // Message record: type byte, 2-byte size (LE), then the rest of
12848 // the prefix — flags, and a creation index where the header has one
12849 // — kept verbatim.
12850 out.push(region[p]);
12851 #[expect(
12852 clippy::cast_possible_truncation,
12853 reason = "the guard above bounds COMPACT_LAYOUT_PREAMBLE + raw.len(), this \
12854 body's exact length, to the 2-byte message-size field"
12855 )]
12856 out.extend_from_slice(&(layout.len() as u16).to_le_bytes());
12857 out.extend_from_slice(®ion[p + 3..p + region.layout().prefix_len()]);
12858 out.extend_from_slice(&layout);
12859 replaced = true;
12860 } else {
12861 out.extend_from_slice(®ion[p..body_end]);
12862 }
12863 p = body_end;
12864 }
12865 if p < region.len() {
12866 out.extend_from_slice(®ion[p..]);
12867 }
12868 if !replaced {
12869 return Err(Error::EditUnsupported(
12870 "compact dataset header has no data-layout message",
12871 ));
12872 }
12873 Ok(region.with_bytes(out))
12874}
12875
12876/// The link creation indexes one commit hands out to the links it adds to one
12877/// group, and the running maximum its Link Info message ends up recording.
12878///
12879/// Link creation order is a separate mechanism from the attribute creation order
12880/// the object header's own flags describe: a group that tracks it — h5py's
12881/// `track_order=True`, `H5Pset_link_creation_order`, and every group netCDF-4
12882/// writes — carries a creation index on every Link message and, in its Link Info
12883/// message, the maximum it has ever assigned. The reference C library hands out
12884/// that maximum and increments it (`H5G_obj_insert`), so the recorded value is
12885/// the *next* index rather than the highest in use, and deleting a link never
12886/// lowers it. This is the link-side counterpart of [`next_attr_creation_index`],
12887/// and it works the same way.
12888///
12889/// A group that does not track the order has no maximum recorded, hands out no
12890/// index, and records nothing back — which is every group this crate writes
12891/// itself.
12892struct LinkCreationOrder {
12893 /// The next index to assign, on a group that tracks link creation order.
12894 next: Option<u64>,
12895 /// Whether any index has been handed out. Nothing is recorded back
12896 /// otherwise, so a commit that adds no link to the group leaves its Link
12897 /// Info message byte-identical.
12898 assigned: bool,
12899}
12900
12901impl LinkCreationOrder {
12902 /// Read a group's counter out of its object-header message `region`.
12903 fn for_region(region: &OhRegion) -> Result<Self, Error> {
12904 let next = find_link_info(region)?.and_then(|(_, _, info)| info.max_creation_order);
12905 Ok(Self {
12906 next,
12907 assigned: false,
12908 })
12909 }
12910
12911 /// The creation index for one link being added, or `None` on a group that
12912 /// does not track the order. Consecutive calls return consecutive indexes,
12913 /// so links added in one commit are ordered by the order they are placed in.
12914 fn take(&mut self) -> Result<Option<u64>, Error> {
12915 let Some(index) = self.next else {
12916 return Ok(None);
12917 };
12918 self.next = Some(index.checked_add(1).ok_or(Error::EditUnsupported(
12919 "a group has assigned every link creation index its link-info message can record",
12920 ))?);
12921 self.assigned = true;
12922 Ok(Some(index))
12923 }
12924
12925 /// Write the running maximum back into the group's Link Info message, once
12926 /// every link this commit adds to that group has taken an index.
12927 ///
12928 /// The maximum is patched in place rather than re-encoded: it is present
12929 /// (that is what [`Self::for_region`] read) and it is the eight bytes
12930 /// following the message's version and flags whatever the file's offset
12931 /// size, so the record's length — and every offset into the region — is
12932 /// unchanged.
12933 fn record(&self, region: &mut OhRegion) -> Result<(), Error> {
12934 if !self.assigned {
12935 return Ok(());
12936 }
12937 let Some(next) = self.next else {
12938 return Ok(());
12939 };
12940 let (_, body, _) = find_link_info(region)?.ok_or(Error::EditUnsupported(
12941 "a group's link-info message went missing while its links were being added",
12942 ))?;
12943 let max = body.start + 2..body.start + 10;
12944 if max.end > body.end {
12945 return Err(Error::EditUnsupported(
12946 "a group's link-info message is too short to record its link creation order",
12947 ));
12948 }
12949 region.bytes_mut()[max].copy_from_slice(&next.to_le_bytes());
12950 Ok(())
12951 }
12952}
12953
12954/// The Link Info message in `region`, as `(record start, body range, parsed
12955/// message)`. An unparseable message is reported as absent, the same reading
12956/// [`find_attribute_info`] gives an unparseable Attribute Info message — safe
12957/// here because a group whose link storage this editor cannot account for is
12958/// refused by [`inspect_group`](WriteEngine::inspect_group) before it reaches
12959/// this.
12960fn find_link_info(
12961 region: &OhRegion,
12962) -> Result<Option<(usize, core::ops::Range<usize>, LinkInfoMessage)>, Error> {
12963 let mut p = 0;
12964 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12965 if msg_type == MessageType::LinkInfo
12966 && let Ok(info) = LinkInfoMessage::parse(®ion[body..body_end], OFFSET_SIZE)
12967 {
12968 return Ok(Some((p, body..body_end, info)));
12969 }
12970 p = body_end;
12971 }
12972 Ok(None)
12973}
12974
12975/// The number of links a group keeps in its object header before they move into
12976/// a fractal heap: the "maximum compact value" its Group Info message (type
12977/// 0x000A) declares, or the reference C library's default of 8 where that
12978/// message stores no link-phase-change values.
12979///
12980/// Body: version(1), flags(1), then the maximum compact (2) and minimum dense
12981/// (2) values if bit 0 of the flags is set, then the estimated entry count (2)
12982/// and name length (2) if bit 1 is. An absent or truncated message reads as the
12983/// default, which is the value the C library itself would use for it.
12984///
12985/// Not to be confused with [`AttrPhaseChange`], the *attribute* thresholds
12986/// (`H5Pset_attr_phase_change`) that live in the object header's own prefix:
12987/// this is the *link* phase change, it lives in a message, and it is what
12988/// [`reject_dense_link_creation_order`] measures an addition against.
12989fn max_compact_links(region: &OhRegion) -> Result<u16, Error> {
12990 /// `H5G_CRT_GINFO_MAX_COMPACT`, the C library's default.
12991 const DEFAULT_MAX_COMPACT: u16 = 8;
12992 let mut p = 0;
12993 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12994 if msg_type == MessageType::GroupInfo {
12995 let stores_phase_change = body_end - body >= 2 && region[body + 1] & 0x01 != 0;
12996 if stores_phase_change && body + 4 <= body_end {
12997 return Ok(u16::from_le_bytes([region[body + 2], region[body + 3]]));
12998 }
12999 return Ok(DEFAULT_MAX_COMPACT);
13000 }
13001 p = body_end;
13002 }
13003 Ok(DEFAULT_MAX_COMPACT)
13004}
13005
13006/// Refuse to add a link to a group that tracks **link** creation order when the
13007/// addition would move that group's links into *dense* (fractal-heap) storage.
13008///
13009/// `links_after_commit` is how many links the group would hold once this commit's
13010/// deletions and additions are applied. Past the threshold its Group Info message
13011/// declares, the reference C library moves a tracked group's links into a fractal
13012/// heap indexed by *two* B-trees: one on name, and a type 6 one on creation
13013/// order. This crate emits neither, so the point where the group would stop
13014/// being compact is the point where an addition stops being reproducible — that
13015/// work belongs with dense link storage as a whole (issue #102).
13016///
13017/// Below the threshold an addition is written rather than refused
13018/// ([`LinkCreationOrder`] numbers it), as are the two things that write no
13019/// creation order at all: **removing** a link, which leaves a gap in the order
13020/// exactly as dropping an Attribute message does and copies the running maximum
13021/// through untouched (the C library does not lower it on a deletion either), and
13022/// **retargeting** one ([`patch_link_target`], how a relocated child is rewired),
13023/// which rewrites an address inside a Link message and leaves every other field
13024/// of it, creation index included, where it was.
13025///
13026/// A group whose links are *already* dense never reaches here at all:
13027/// [`inspect_group`](WriteEngine::inspect_group) refuses every dense-link group,
13028/// tracked or not, as it reads the header.
13029fn reject_dense_link_creation_order(
13030 region: &OhRegion,
13031 links_after_commit: usize,
13032) -> Result<(), Error> {
13033 let tracked =
13034 find_link_info(region)?.is_some_and(|(_, _, info)| info.max_creation_order.is_some());
13035 if tracked && links_after_commit > usize::from(max_compact_links(region)?) {
13036 return Err(Error::EditUnsupported(
13037 "a group on the edited path tracks link creation order, and the links this commit \
13038 adds to it would take it past the compact storage its group-info message allows; \
13039 dense (fractal-heap) link storage cannot be written in place yet",
13040 ));
13041 }
13042 Ok(())
13043}
13044
13045/// Copy a chunk-0 message `region`, dropping the single Link message named
13046/// `name` and preserving every other message verbatim (used by `delete`). Errors
13047/// if no such link is present.
13048fn remove_link_from_region(region: &OhRegion, name: &str) -> Result<OhRegion, Error> {
13049 let mut out = Vec::with_capacity(region.len());
13050 let mut p = 0;
13051 let mut removed = false;
13052 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13053 let mut skip = false;
13054 if msg_type == MessageType::Link {
13055 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
13056 if link.name == name {
13057 skip = true;
13058 removed = true;
13059 }
13060 }
13061 }
13062 if !skip {
13063 out.extend_from_slice(®ion[p..body_end]);
13064 }
13065 p = body_end;
13066 }
13067 if p < region.len() {
13068 out.extend_from_slice(®ion[p..]);
13069 }
13070 if !removed {
13071 return Err(Error::EditUnsupported(
13072 "link to delete not found in its parent group",
13073 ));
13074 }
13075 Ok(region.with_bytes(out))
13076}
13077
13078/// Apply attribute edits to an object's header `region` *compactly*, preserving
13079/// every non-attribute message verbatim. A fixed-size `Set`/`Remove` is resolved
13080/// into `region` directly; a variable-length `Set` (one whose value reports
13081/// [`AttrValue::var_len_strings`]) is
13082/// instead collected into the returned `pending_vl_attrs` — its placeholder
13083/// heap address is only patched, and the message appended to the object's
13084/// header, by the apply loop once its global heap collection's real address
13085/// is known (see [`WriteEngine::place_vl_collection`]). A later op for the
13086/// same name (another `Set`, fixed-size or not, or a `Remove`) replaces or
13087/// cancels an earlier still-pending variable-length entry, keeping the net
13088/// effect the same regardless of op order within one commit.
13089///
13090/// Only [`plan_attr_ops`] calls this, and only for an object whose attributes
13091/// are stored compactly: appending an inline Attribute message to an object
13092/// whose set lives in a fractal heap would leave it carrying two storage forms,
13093/// so a dense object is routed to a heap rebuild there instead.
13094fn apply_compact_attr_ops(
13095 region: &OhRegion,
13096 ops: &[&AttrOp],
13097) -> Result<(OhRegion, PendingVlAttrs), Error> {
13098 let mut out = region.clone();
13099 let mut pending_vl: PendingVlAttrs = Vec::new();
13100 for op in ops {
13101 match op {
13102 AttrOp::Set { name, value } => {
13103 pending_vl.retain(|a| &a.msg.name != name);
13104 if let Some(strings) = value.var_len_strings() {
13105 // The message this replaces is dropped now and re-appended
13106 // in the apply phase, so the creation index it carried has
13107 // to be read before it goes: an overwrite keeps the index
13108 // the attribute already had.
13109 let creation_index = attr_creation_index(&out, name)?;
13110 // Nothing yet to remove from `region` if this name has
13111 // never been set as a fixed-size attribute.
13112 out = remove_attr_from_region(&out, name, false)?;
13113 let msg = build_attr_message(name, value);
13114 if msg.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
13115 return Err(Error::EditUnsupported(
13116 "attribute is too large to encode in place",
13117 ));
13118 }
13119 pending_vl.push(PendingVlAttr {
13120 msg,
13121 collections: build_global_heap_collections(strings),
13122 creation_index,
13123 });
13124 } else {
13125 out = set_attr_in_region(&out, name, value)?;
13126 }
13127 }
13128 AttrOp::Remove { name } => {
13129 let before = pending_vl.len();
13130 pending_vl.retain(|a| &a.msg.name != name);
13131 if pending_vl.len() == before {
13132 out = remove_attr_from_region(&out, name, true)?;
13133 }
13134 }
13135 }
13136 }
13137 Ok((out, pending_vl))
13138}
13139
13140/// The attribute storage an edit resolves to, and the header region that carries
13141/// it. Produced by [`plan_attr_ops`] in the commit preflight, so the apply phase
13142/// only places bytes.
13143struct AttrEdits {
13144 /// The object's chunk-0 message region with the edit applied. Compact: it
13145 /// already carries every fixed-size attribute. Dense: it carries no attribute
13146 /// message at all, and the Attribute Info message naming the heap is appended
13147 /// once that heap is placed.
13148 region: OhRegion,
13149 /// What the apply phase still has to place, and where the result is stored.
13150 attrs: EditedAttrs,
13151}
13152
13153/// What an attribute edit leaves for the apply phase, in the storage the result
13154/// belongs in. The two are alternatives rather than a pair of fields, because
13155/// every object has exactly one attribute storage: a compact result's
13156/// variable-length attributes are appended to the header as messages, a dense
13157/// one's are patched into the set the heap is then built over.
13158#[derive(Clone)]
13159enum EditedAttrs {
13160 /// Variable-length attributes still to be placed and appended as messages.
13161 /// Empty for an edit that resolved entirely in the preflight, and for one
13162 /// that left the object with no attributes at all.
13163 Compact(PendingVlAttrs),
13164 /// A set to rebuild into a fresh fractal heap.
13165 Dense(DenseAttrEdit),
13166}
13167
13168impl Default for EditedAttrs {
13169 /// An object with nothing left to place: what a group carries before an
13170 /// attribute edit reaches it, and what one without attribute edits keeps.
13171 fn default() -> Self {
13172 Self::Compact(Vec::new())
13173 }
13174}
13175
13176/// A dense (fractal-heap) attribute set and what its object records about
13177/// attribute creation order.
13178///
13179/// The two travel together because a dense set is never copied as bytes: the
13180/// heap is rebuilt from the parsed attributes wherever it lands, so the creation
13181/// indexes the old heap's indexes carried have to be carried alongside or they
13182/// are lost. An object that does not track the order leaves this
13183/// [`DenseAttrCreationOrder::Untracked`], which is every set this crate's
13184/// whole-file writer produces.
13185#[derive(Clone, Default)]
13186struct DenseAttrSet {
13187 attrs: Vec<crate::attribute::AttributeMessage>,
13188 creation: DenseAttrCreationOrder,
13189}
13190
13191impl DenseAttrSet {
13192 fn is_empty(&self) -> bool {
13193 self.attrs.is_empty()
13194 }
13195}
13196
13197/// A dense (fractal-heap) attribute set an edit rebuilds, staged by
13198/// [`plan_attr_ops`] and placed by the apply phase.
13199#[derive(Clone)]
13200struct DenseAttrEdit {
13201 /// Every attribute the object will carry: the ones it already had, in the
13202 /// order it had them, with this edit's applied over them by name — and what
13203 /// the object records about their creation order.
13204 set: DenseAttrSet,
13205 /// `(index into `attrs`, its global heap collections)` for each
13206 /// variable-length attribute this edit *sets*. The heap is built over the
13207 /// attribute message bytes, so each of these has to be patched with its
13208 /// collection's real address before the build rather than after it, which is
13209 /// why they are carried to the apply phase instead of resolved here.
13210 ///
13211 /// A variable-length attribute the object already had is not in this list and
13212 /// needs no patch: it was read back carrying the addresses it is stored with,
13213 /// and they name collections in this same file.
13214 vl: Vec<(usize, Vec<Vec<u8>>)>,
13215}
13216
13217/// Apply attribute edits to an object's header `region`, choosing the storage
13218/// the result belongs in (issue #102).
13219///
13220/// `addr` is the object's pre-commit object-header address, or `None` for a
13221/// group this commit creates, which has no stored attributes to read.
13222///
13223/// Three outcomes, in the order they are tried:
13224///
13225/// - **Compact.** The region is rewritten message by message, so every attribute
13226/// the edit does not name keeps its exact bytes. An ordinary `set_attr` on an
13227/// ordinary object takes this route.
13228/// - **Dense.** The object already stores its attributes in a fractal heap, or
13229/// this edit takes it past what an object header holds compactly — more than
13230/// [`MAX_COMPACT_ATTRS`] attributes, or one whose message overflows the
13231/// header's 2-byte message-size field. The object's whole attribute set is read
13232/// back, the edits are applied to it, and the apply phase builds a fresh heap
13233/// for the result. Those are the two halves of the disjunction
13234/// `file_writer::needs_dense_attrs` selects on, so an object an edit sends to a
13235/// heap is one the whole-file writer would have written to a heap — measured
13236/// here over the ops as staged, which is why setting an oversized attribute and
13237/// removing it again in one commit still sends the object to a heap.
13238/// - **Neither.** The edit removes the object's last attribute, so the rebuilt
13239/// header carries no attribute message and no Attribute Info message — what
13240/// this crate and the reference C library both write for an object with none.
13241///
13242/// A dense rebuild is a re-encoding rather than a byte copy: the attributes are
13243/// read into messages and re-serialized, as [`WriteEngine::read_object`] already
13244/// does for a copied object's heap. Name, datatype, dataspace and value survive
13245/// it; a version 1 or 2 message becomes the version 3 one dense storage holds,
13246/// and attribute creation order — which this crate indexes for neither storage
13247/// form — does not.
13248///
13249/// An object that is dense stays dense, even where the edit takes its set back
13250/// under the threshold. The reference C library keeps one dense down to its
13251/// `min_dense` (6 by default) as well, and re-encoding a set that is only losing
13252/// members buys nothing.
13253fn plan_attr_ops<S: Source + ?Sized>(
13254 src: &S,
13255 base: BaseAddress,
13256 addr: Option<u64>,
13257 region: &OhRegion,
13258 ops: &[&AttrOp],
13259) -> Result<AttrEdits, Error> {
13260 let dense_now = region_uses_dense_attrs(region)?;
13261 if !dense_now {
13262 // An attribute past the header's message-size field has no compact form
13263 // at all, so the compact pass would refuse it rather than report a count
13264 // this could act on. Ask before running it, not after.
13265 let oversized = ops.iter().any(|op| match op {
13266 AttrOp::Set { name, value } => {
13267 build_attr_message(name, value).serialize(LENGTH_SIZE).len()
13268 > OBJECT_HEADER_MESSAGE_MAX
13269 }
13270 AttrOp::Remove { .. } => false,
13271 });
13272 if !oversized {
13273 let (out, pending_vl) = apply_compact_attr_ops(region, ops)?;
13274 // An edit that only *removes* stays compact whatever the count is.
13275 // A region already holding more attributes than this writer would
13276 // emit came from another writer — the C library's `max_compact` is a
13277 // property list setting — and taking one away is no reason to
13278 // re-encode the ones that remain.
13279 let only_removes = !ops.iter().any(|op| matches!(op, AttrOp::Set { .. }));
13280 if only_removes || compact_attr_count(&out)? + pending_vl.len() <= MAX_COMPACT_ATTRS {
13281 return Ok(AttrEdits {
13282 region: out,
13283 attrs: EditedAttrs::Compact(pending_vl),
13284 });
13285 }
13286 }
13287 }
13288
13289 // The compact pass refuses a shared attribute message as it walks the
13290 // messages it copies; this path never walks them, so it asks here — before
13291 // the read below resolves one into a message indistinguishable from a private
13292 // attribute.
13293 if region_has_shared_attr(region)? {
13294 return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13295 }
13296
13297 // A heap is rebuilt from the object's whole attribute set — half of which may
13298 // live in a heap the header only names — so read that set back before
13299 // applying the edits to it. A group this commit creates has none.
13300 let existing = match addr {
13301 Some(addr) => read_object_attrs(src, addr, base)?,
13302 None => Vec::new(),
13303 };
13304 // Each attribute travels with the global heap collections it still needs
13305 // placed (`Some` only for a variable-length attribute this edit sets) and
13306 // with the creation index its object records for it, so no removal can shift
13307 // one away from the bytes — or the index — it belongs to.
13308 let existing = dense_attr_set(region, existing)?;
13309 let tracked = region.layout().tracks_creation_order();
13310 // The next index the object would hand out: what a *new* attribute takes.
13311 // `dense_attr_set` has already raised it past every index in use.
13312 let mut next_index = match &existing.creation {
13313 DenseAttrCreationOrder::Tracked { max, .. } => *max,
13314 DenseAttrCreationOrder::Untracked => 0,
13315 };
13316 let mut set: Vec<DenseAttrSlot> = existing
13317 .attrs
13318 .into_iter()
13319 .enumerate()
13320 .map(|(i, msg)| DenseAttrSlot {
13321 msg,
13322 collections: None,
13323 creation_index: existing.creation.index_at(i),
13324 })
13325 .collect();
13326 for op in ops {
13327 match op {
13328 AttrOp::Set { name, value } => {
13329 let msg = build_attr_message(name, value);
13330 let collections = value.var_len_strings().map(build_global_heap_collections);
13331 match set.iter_mut().find(|slot| &slot.msg.name == name) {
13332 // Setting an attribute the object already has replaces it
13333 // where it stands, so a repeated `set_attr` does not reorder
13334 // the set — and it keeps the creation index it had, so an
13335 // iteration by creation order does not reorder it either.
13336 Some(slot) => {
13337 slot.msg = msg;
13338 slot.collections = collections;
13339 }
13340 None => {
13341 let creation_index = tracked.then_some(next_index);
13342 if tracked {
13343 next_index = bump_creation_index(next_index)?;
13344 }
13345 set.push(DenseAttrSlot {
13346 msg,
13347 collections,
13348 creation_index,
13349 });
13350 }
13351 }
13352 }
13353 AttrOp::Remove { name } => {
13354 let before = set.len();
13355 // A deletion leaves a gap in the creation order rather than
13356 // renumbering what is left, and does not lower the maximum:
13357 // the reference C library hands out indexes from a counter that
13358 // only ever rises.
13359 set.retain(|slot| &slot.msg.name != name);
13360 if set.len() == before {
13361 return Err(Error::EditUnsupported("attribute to remove was not found"));
13362 }
13363 }
13364 }
13365 }
13366
13367 // Every attribute message is rebuilt from the set above, so the region keeps
13368 // none of them, nor the Attribute Info message naming the storage they were
13369 // in.
13370 let region = strip_attr_messages(region)?;
13371 // No attributes left: the header carries neither an Attribute message nor an
13372 // Attribute Info one. `append_dense_attrs` would decline to build a heap for
13373 // an empty set anyway; saying `None` here is what makes that a property of
13374 // this decision rather than of the emitter that carries it out.
13375 if set.is_empty() {
13376 return Ok(AttrEdits {
13377 region,
13378 attrs: EditedAttrs::default(),
13379 });
13380 }
13381 let mut attrs = Vec::with_capacity(set.len());
13382 let mut indices = Vec::with_capacity(set.len());
13383 let mut vl = Vec::new();
13384 for (i, slot) in set.into_iter().enumerate() {
13385 if let Some(collections) = slot.collections {
13386 vl.push((i, collections));
13387 }
13388 // Every slot of a tracked object carries an index: one read from its
13389 // storage, or one this edit just handed out. `indices` is discarded for
13390 // an untracked object, where none of them do.
13391 debug_assert!(
13392 !tracked || slot.creation_index.is_some(),
13393 "a tracked object's attribute must carry a creation index"
13394 );
13395 indices.push(slot.creation_index.unwrap_or_default());
13396 attrs.push(slot.msg);
13397 }
13398 let creation = if tracked {
13399 DenseAttrCreationOrder::Tracked {
13400 indices,
13401 max: next_index,
13402 indexed: region.layout().indexes_creation_order(),
13403 }
13404 } else {
13405 DenseAttrCreationOrder::Untracked
13406 };
13407 // Moving a set into a heap takes it out of reach of the reference repointing
13408 // a commit does as its last act: `reference_patch::scan_object` reads an
13409 // object whose attributes are dense as unproven and collects no edit from it.
13410 // An object *already* dense is not this refusal's business — its attributes
13411 // stood outside that walk before this edit and still do — but converting one
13412 // would strand a reference the header was keeping reachable, and permanently,
13413 // since an object that goes dense stays dense (issue #324).
13414 if !dense_now {
13415 for attr in &attrs {
13416 if crate::reference_patch::attribute_references_are_repointable(&attr.datatype) {
13417 return Err(Error::EditUnsupported(
13418 REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER,
13419 ));
13420 }
13421 }
13422 }
13423 // What the heap itself cannot represent is the last refusal, and it belongs
13424 // in the preflight: past here the commit is placing bytes.
13425 crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
13426 Ok(AttrEdits {
13427 region,
13428 attrs: EditedAttrs::Dense(DenseAttrEdit {
13429 set: DenseAttrSet { attrs, creation },
13430 vl,
13431 }),
13432 })
13433}
13434
13435/// One attribute of the set a dense rebuild is assembled from.
13436struct DenseAttrSlot {
13437 msg: crate::attribute::AttributeMessage,
13438 /// Global heap collections still to be placed, for a variable-length
13439 /// attribute *this edit* sets.
13440 collections: Option<Vec<Vec<u8>>>,
13441 /// The creation index the object records for it, where it tracks the order.
13442 creation_index: Option<u16>,
13443}
13444
13445/// Whether an object's chunk-0 message `region` stores its attributes densely —
13446/// an Attribute Info message naming a fractal heap. See
13447/// [`attribute_info_is_dense`] for why the message's mere presence is not that.
13448fn region_uses_dense_attrs(region: &OhRegion) -> Result<bool, Error> {
13449 let mut p = 0;
13450 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13451 if msg_type == MessageType::AttributeInfo
13452 && attribute_info_is_dense(®ion[body..body_end])
13453 {
13454 return Ok(true);
13455 }
13456 p = body_end;
13457 }
13458 Ok(false)
13459}
13460
13461/// Whether any Attribute message in `region` carries a message flag — which for
13462/// an attribute means the body is a shared (SOHM) record rather than the
13463/// attribute itself. Reads the same byte [`parse_compact_attr_name`] does, so the
13464/// two attribute paths refuse exactly the same messages.
13465fn region_has_shared_attr(region: &OhRegion) -> Result<bool, Error> {
13466 let mut p = 0;
13467 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13468 if msg_type == MessageType::Attribute && region[p + 3] != 0 {
13469 return Ok(true);
13470 }
13471 p = body_end;
13472 }
13473 Ok(false)
13474}
13475
13476/// Copy a chunk-0 message `region`, dropping every Attribute message and the
13477/// Attribute Info message that names their storage. What the object carries in
13478/// their place is [`plan_attr_ops`]'s decision, appended by the apply phase.
13479fn strip_attr_messages(region: &OhRegion) -> Result<OhRegion, Error> {
13480 let mut out = Vec::with_capacity(region.len());
13481 let mut p = 0;
13482 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13483 if !matches!(
13484 msg_type,
13485 MessageType::Attribute | MessageType::AttributeInfo
13486 ) {
13487 out.extend_from_slice(®ion[p..body_end]);
13488 }
13489 p = body_end;
13490 }
13491 if p < region.len() {
13492 out.extend_from_slice(®ion[p..]);
13493 }
13494 Ok(region.with_bytes(out))
13495}
13496
13497/// Every attribute an object carries, compact or dense, parsed into messages.
13498///
13499/// This read is what makes a dense edit possible at all: the set an edit works
13500/// from is the object's whole attribute set, and for a dense object none of it
13501/// is in the header — `extract_attributes_full_from_source` walks the fractal
13502/// heap the Attribute Info message names. It reads both storage forms, so no
13503/// caller has to ask which one an object uses.
13504///
13505/// Shared with [`WriteEngine::read_object`], which needs the same set for the
13506/// same reason when it copies a dense object. That caller checks
13507/// [`crate::file_writer::dense_attrs_check`] on what it reads; the attribute
13508/// editor checks it after applying its edits, on the set it will actually write.
13509fn read_object_attrs<S: Source + ?Sized>(
13510 src: &S,
13511 addr: u64,
13512 base: BaseAddress,
13513) -> Result<Vec<crate::attribute::StoredAttribute>, Error> {
13514 let header = ObjectHeader::parse_from_source(src, addr, OFFSET_SIZE, LENGTH_SIZE, base)
13515 .map_err(|_| Error::EditUnsupported("an object header could not be parsed"))?;
13516 if base.get() > src.len() {
13517 return Err(Error::EditUnsupported(
13518 "this file's userblock is larger than the file itself",
13519 ));
13520 }
13521 // Heap addresses in the Attribute Info message are stored relative to the
13522 // base address, so the walk gets the file framed past its userblock — the
13523 // same view the reader uses. `base` is 0 for a plain file, where this is
13524 // `src` itself.
13525 let framed = BaseOffsetSource { inner: src, base };
13526 crate::attribute::extract_stored_attributes_from_source(
13527 &framed,
13528 &header,
13529 OFFSET_SIZE,
13530 LENGTH_SIZE,
13531 // No shared-message table: this engine refuses an object carrying a
13532 // shared attribute message before it gets here ([`SHARED_ATTRIBUTE_MESSAGE`]),
13533 // and resolving one silently is exactly what that refusal prevents — the
13534 // resolved copy is indistinguishable from a private attribute, and would
13535 // be re-emitted as one, leaving the file's reference count naming an
13536 // attribute that no longer exists.
13537 None,
13538 )
13539 .map_err(|_| {
13540 Error::EditUnsupported("an object's dense (fractal-heap) attributes could not be read")
13541 })
13542}
13543
13544/// Assemble a [`DenseAttrSet`] from an object's stored attributes and what its
13545/// header `region` says about attribute creation order.
13546///
13547/// An object that does not track the order gets
13548/// [`DenseAttrCreationOrder::Untracked`], which is the whole story for every
13549/// file this crate writes itself. One that does keeps each attribute's stored
13550/// index, and the maximum its Attribute Info message records — the next index it
13551/// would hand out, which a deletion does not lower, so it cannot be recomputed
13552/// from the attributes that remain. An attribute whose storage recorded no index
13553/// (a header that claims to track the order and then does not) is given the next
13554/// unused one rather than silently colliding with a real index.
13555fn dense_attr_set(
13556 region: &OhRegion,
13557 stored: Vec<crate::attribute::StoredAttribute>,
13558) -> Result<DenseAttrSet, Error> {
13559 let layout = region.layout();
13560 if !layout.tracks_creation_order() {
13561 return Ok(DenseAttrSet {
13562 attrs: stored.into_iter().map(|a| a.message).collect(),
13563 creation: DenseAttrCreationOrder::Untracked,
13564 });
13565 }
13566 let mut next = next_attr_creation_index(region)?;
13567 let mut attrs = Vec::with_capacity(stored.len());
13568 let mut indices = Vec::with_capacity(stored.len());
13569 for attr in stored {
13570 let index = match attr.creation_index {
13571 Some(index) => index,
13572 None => {
13573 let index = next;
13574 next = bump_creation_index(next)?;
13575 index
13576 }
13577 };
13578 next = next.max(bump_creation_index(index)?);
13579 attrs.push(attr.message);
13580 indices.push(index);
13581 }
13582 Ok(DenseAttrSet {
13583 attrs,
13584 creation: DenseAttrCreationOrder::Tracked {
13585 indices,
13586 max: next,
13587 indexed: layout.indexes_creation_order(),
13588 },
13589 })
13590}
13591
13592/// One past `index`, refusing the object that has exhausted the 2-byte creation
13593/// index its Attribute Info message records.
13594fn bump_creation_index(index: u16) -> Result<u16, Error> {
13595 index.checked_add(1).ok_or(Error::EditUnsupported(
13596 "an object has assigned every attribute creation index its header can record",
13597 ))
13598}
13599
13600/// Whether an Attribute Info (0x0015) message body denotes *dense* (fractal-heap)
13601/// attribute storage — a *defined* heap address. The reference C library and h5py
13602/// emit an Attribute Info message with an *undefined* heap address even for
13603/// compact, inline attributes in the latest format (to carry creation-order
13604/// metadata), so its mere presence is not dense storage; only a defined heap
13605/// address is. An unparseable message is treated as dense (refused conservatively).
13606/// Mirrors the copy path's dense detection so the compact-attribute editors accept
13607/// the undefined-address message that nearly every real-world object carries.
13608pub(crate) fn attribute_info_is_dense(body: &[u8]) -> bool {
13609 match crate::attribute_info::AttributeInfoMessage::parse(body, OFFSET_SIZE) {
13610 Ok(ai) => ai.fractal_heap_address.is_some(),
13611 Err(_) => true,
13612 }
13613}
13614
13615/// Copy a message region, dropping all Attribute messages named `name` and then
13616/// appending a fresh compact Attribute message for `value`.
13617fn set_attr_in_region(region: &OhRegion, name: &str, value: &AttrValue) -> Result<OhRegion, Error> {
13618 let body = encode_attr_body(name, value)?;
13619 let keep = attr_creation_index(region, name)?;
13620 put_attr_message(region, name, &body, keep)
13621}
13622
13623/// The creation index the Attribute message named `name` carries, or `None`
13624/// where the object has no such attribute — or does not track the order at all.
13625fn attr_creation_index(region: &OhRegion, name: &str) -> Result<Option<u16>, Error> {
13626 if !region.layout().tracks_creation_order() {
13627 return Ok(None);
13628 }
13629 let mut p = 0;
13630 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13631 if msg_type == MessageType::Attribute
13632 && parse_compact_attr_name(region, p, body, body_end)? == name
13633 {
13634 return Ok(region.creation_index(p));
13635 }
13636 p = body_end;
13637 }
13638 Ok(None)
13639}
13640
13641/// The highest creation index any Attribute record in `region` carries.
13642fn highest_attr_creation_index(region: &OhRegion) -> Result<Option<u16>, Error> {
13643 let mut highest = None;
13644 let mut p = 0;
13645 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13646 if msg_type == MessageType::Attribute {
13647 highest = highest.max(region.creation_index(p));
13648 }
13649 p = body_end;
13650 }
13651 Ok(highest)
13652}
13653
13654/// The next unused attribute creation index for an object whose messages are
13655/// `region`.
13656///
13657/// The reference C library hands out `ainfo.max_crt_idx` and increments it
13658/// (`H5O__attr_create`), so the recorded maximum is the *next* index rather
13659/// than the highest in use, and deleting an attribute never lowers it. Read it
13660/// from the Attribute Info message where there is one, and otherwise derive one
13661/// past the highest index the records carry — the best evidence a header with no
13662/// such message leaves.
13663fn next_attr_creation_index(region: &OhRegion) -> Result<u16, Error> {
13664 let recorded = find_attribute_info(region)?
13665 .and_then(|(_, _, info)| info.max_creation_index)
13666 .unwrap_or(0);
13667 let derived = highest_attr_creation_index(region)?.map_or(0, |i| u32::from(i) + 1);
13668 let next = u32::from(recorded).max(derived);
13669 u16::try_from(next).map_err(|_| {
13670 Error::EditUnsupported(
13671 "an object has assigned every attribute creation index its header can record",
13672 )
13673 })
13674}
13675
13676/// The Attribute Info message in `region`, as `(record start, body range, parsed
13677/// message)`. An unparseable message is reported as absent, which leaves it
13678/// untouched: `attribute_info_is_dense` reads the same message and refuses the
13679/// object rather than letting an edit rewrite one it did not understand.
13680fn find_attribute_info(
13681 region: &OhRegion,
13682) -> Result<Option<(usize, core::ops::Range<usize>, AttributeInfoMessage)>, Error> {
13683 let mut p = 0;
13684 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13685 if msg_type == MessageType::AttributeInfo
13686 && let Ok(info) = AttributeInfoMessage::parse(®ion[body..body_end], OFFSET_SIZE)
13687 {
13688 return Ok(Some((p, body..body_end, info)));
13689 }
13690 p = body_end;
13691 }
13692 Ok(None)
13693}
13694
13695/// The Attribute Info message body an object storing its attributes compactly
13696/// needs, in `region`'s layout.
13697///
13698/// A header that tracks attribute creation order records the maximum index ever
13699/// assigned here, and nowhere else, so a header carrying no such message leaves
13700/// only the records to derive it from: one past the highest index in use. That
13701/// under-counts an object whose most recently created attributes have since been
13702/// deleted, which is why an edit *patches* an existing message
13703/// ([`put_attr_message`]) rather than rebuilding one.
13704fn compact_attribute_info_body(region: &OhRegion) -> Result<Vec<u8>, Error> {
13705 let layout = region.layout();
13706 let info = AttributeInfoMessage {
13707 max_creation_index: layout
13708 .tracks_creation_order()
13709 .then(|| next_attr_creation_index(region))
13710 .transpose()?,
13711 indexes_creation_order: layout.indexes_creation_order(),
13712 fractal_heap_address: None,
13713 btree_name_index_address: None,
13714 btree_creation_order_address: None,
13715 };
13716 Ok(info.serialize(OFFSET_SIZE))
13717}
13718
13719/// Copy a message region, dropping every Attribute message named `name` and
13720/// appending one carrying `body`, then keep the object's Attribute Info message
13721/// in step with the creation index the new record takes.
13722///
13723/// `keep` is the index an *overwrite* preserves: the reference C library modifies
13724/// an attribute in place, so writing over one does not move it in an iteration
13725/// by creation order. `None` asks for the object's next unused index, which then
13726/// becomes the maximum the Attribute Info message records. Both are inert on a
13727/// header that does not track the order, where no record has the field.
13728fn put_attr_message(
13729 region: &OhRegion,
13730 name: &str,
13731 body: &[u8],
13732 keep: Option<u16>,
13733) -> Result<OhRegion, Error> {
13734 let layout = region.layout();
13735 let (creation_index, new_max) = if layout.tracks_creation_order() {
13736 match keep {
13737 Some(index) => (index, None),
13738 None => {
13739 let index = next_attr_creation_index(region)?;
13740 let next = index.checked_add(1).ok_or(Error::EditUnsupported(
13741 "an object has assigned every attribute creation index its header can record",
13742 ))?;
13743 (index, Some(next))
13744 }
13745 }
13746 } else {
13747 (0, None)
13748 };
13749 let new_msg = layout.record_with_creation_index(MessageType::Attribute, body, creation_index);
13750
13751 let mut out = Vec::with_capacity(region.len() + new_msg.len());
13752 let mut p = 0;
13753 while let Some((msg_type, msg_body, body_end)) = region.next_message(p)? {
13754 match msg_type {
13755 MessageType::Attribute
13756 if parse_compact_attr_name(region, p, msg_body, body_end)? == name =>
13757 {
13758 p = body_end;
13759 continue;
13760 }
13761 // Bumping the recorded maximum re-encodes the message, since a
13762 // header that was not recording one at all needs the field added.
13763 MessageType::AttributeInfo if new_max.is_some() => {
13764 if let Ok(mut info) =
13765 AttributeInfoMessage::parse(®ion[msg_body..body_end], OFFSET_SIZE)
13766 {
13767 info.max_creation_index = new_max;
13768 info.indexes_creation_order |= layout.indexes_creation_order();
13769 let encoded = info.serialize(OFFSET_SIZE);
13770 let len = u16::try_from(encoded.len()).map_err(|_| {
13771 Error::EditUnsupported("an Attribute Info message is too large to record")
13772 })?;
13773 // Only the body and its length change: the record keeps its
13774 // own flags — the reference C library marks this message
13775 // "don't share" — and its creation index.
13776 out.push(region[p]);
13777 out.extend_from_slice(&len.to_le_bytes());
13778 out.extend_from_slice(®ion[p + 3..msg_body]);
13779 out.extend_from_slice(&encoded);
13780 p = body_end;
13781 continue;
13782 }
13783 out.extend_from_slice(®ion[p..body_end]);
13784 }
13785 _ => out.extend_from_slice(®ion[p..body_end]),
13786 }
13787 p = body_end;
13788 }
13789 out.extend_from_slice(&new_msg);
13790 if p < region.len() {
13791 out.extend_from_slice(®ion[p..]);
13792 }
13793 let mut out = region.with_bytes(out);
13794 // An object that had no Attribute Info message to bump gets the one
13795 // `build_v2_object_header` would add anyway, now, so the maximum this edit
13796 // just assigned is recorded rather than re-derived from the records.
13797 if new_max.is_some() && find_attribute_info(&out)?.is_none() {
13798 let body = compact_attribute_info_body(&out)?;
13799 out.push(MessageType::AttributeInfo, &body);
13800 }
13801 Ok(out)
13802}
13803
13804/// Copy a message region, dropping all Attribute messages named `name`. When
13805/// `required` is true, an absent `name` is an [`Error::EditUnsupported`] (a
13806/// `Remove` of a nonexistent attribute); when false, it is not an error (a
13807/// `Set` of a fresh variable-length attribute may have no fixed-size message
13808/// to remove from the region yet).
13809fn remove_attr_from_region(
13810 region: &OhRegion,
13811 name: &str,
13812 required: bool,
13813) -> Result<OhRegion, Error> {
13814 let mut out = Vec::with_capacity(region.len());
13815 let mut p = 0;
13816 let mut removed = false;
13817 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13818 let mut skip = false;
13819 if msg_type == MessageType::Attribute {
13820 let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
13821 if attr_name == name {
13822 skip = true;
13823 removed = true;
13824 }
13825 }
13826 if !skip {
13827 out.extend_from_slice(®ion[p..body_end]);
13828 }
13829 p = body_end;
13830 }
13831 if p < region.len() {
13832 out.extend_from_slice(®ion[p..]);
13833 }
13834 if !removed && required {
13835 return Err(Error::EditUnsupported("attribute to remove was not found"));
13836 }
13837 Ok(region.with_bytes(out))
13838}
13839
13840/// How many attributes an object's message `region` carries inline. Only a
13841/// compact region is ever counted this way: a dense object carries no inline
13842/// Attribute message at all, and would count zero — which is why
13843/// [`plan_attr_ops`] decides an object's storage before it reaches here.
13844fn compact_attr_count(region: &OhRegion) -> Result<usize, Error> {
13845 let mut count = 0usize;
13846 let mut p = 0;
13847 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13848 if msg_type == MessageType::Attribute {
13849 count += 1;
13850 }
13851 p = body_end;
13852 }
13853 Ok(count)
13854}
13855
13856fn parse_compact_attr_name(
13857 region: &[u8],
13858 msg_start: usize,
13859 body: usize,
13860 body_end: usize,
13861) -> Result<String, Error> {
13862 if region[msg_start + 3] != 0 {
13863 return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13864 }
13865 // Only the name is wanted here, and it is the one field that never depends on
13866 // the datatype or dataspace — either of which may be a reference to a
13867 // committed message this walk has no file context to follow. Reading the name
13868 // alone lets an edit pass over such an attribute instead of refusing the
13869 // whole object because one of its neighbours is committed.
13870 crate::attribute::message_name(®ion[body..body_end])
13871 .map_err(|_| Error::EditUnsupported("a target object has an unreadable attribute message"))
13872}
13873
13874fn encode_attr_body(name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
13875 // `apply_compact_attr_ops`'s `Set` branch — this function's only caller —
13876 // handles a value that needs the global heap itself (staging it into
13877 // `pending_vl` instead of calling `set_attr_in_region`/here), so this value
13878 // is always inline by construction, not by a check made at this call site.
13879 debug_assert!(
13880 value.var_len_strings().is_none(),
13881 "a variable-length attribute must be intercepted by apply_compact_attr_ops before reaching encode_attr_message"
13882 );
13883 let body = build_attr_message(name, value).serialize(LENGTH_SIZE);
13884 if body.len() > OBJECT_HEADER_MESSAGE_MAX {
13885 return Err(Error::EditUnsupported(
13886 "group attribute is too large to encode in place",
13887 ));
13888 }
13889 Ok(body)
13890}
13891
13892/// Whether `a` is a path prefix of (or equal to) `b`.
13893fn is_prefix(a: &[String], b: &[String]) -> bool {
13894 a.len() <= b.len() && b[..a.len()] == *a
13895}
13896
13897/// Parse the version-2 object-header message record at `p` within a chunk-0
13898/// message region, returning `(message type, body start, body end)`; the next
13899/// record begins at `body end`. Returns `Ok(None)` once fewer than 4 bytes
13900/// remain (a clean end of the region), and `Err` if a record's declared body
13901/// runs past the region. Centralizes the bounds check shared by every walker.
13902/// Rebuild a superblock-extension object header's message region (as collapsed by
13903/// [`WriteEngine::gather_oh_messages`]) with
13904/// its File Space Info message replaced by `info`, preserving every other message
13905/// verbatim. The persisting message is fixed-size, so the region length is stable.
13906/// Shared by the whole-file mirror commit and the bounded finalize so both write
13907/// the same extension bytes.
13908pub(crate) fn rewrite_extension_region_bytes(
13909 region: &OhRegion,
13910 info: &FileSpaceInfo,
13911) -> Result<OhRegion, Error> {
13912 let new_body = info.serialize();
13913 // The message body is the fixed-size File Space Info record (≤ 125 bytes),
13914 // so it always fits the u16 size field; `try_from` keeps this off the
13915 // 32-bit narrowing-cast ledger.
13916 let new_len = u16::try_from(new_body.len())
13917 .map_err(|_| Error::EditUnsupported("File Space Info message too large"))?;
13918 let mut out = Vec::with_capacity(region.len());
13919 let mut p = 0;
13920 let mut replaced = false;
13921 while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13922 if msg_type == MessageType::FileSpaceInfo {
13923 out.push(region[p]); // message type byte
13924 out.extend_from_slice(&new_len.to_le_bytes());
13925 // Preserve the message flags (0x14) and, where the header carries
13926 // one, the record's creation index.
13927 out.extend_from_slice(®ion[p + 3..p + region.layout().prefix_len()]);
13928 out.extend_from_slice(&new_body);
13929 replaced = true;
13930 } else {
13931 out.extend_from_slice(®ion[p..body_end]);
13932 }
13933 p = body_end;
13934 }
13935 if !replaced {
13936 // Persistence is armed only when the extension already carries a File
13937 // Space Info message, so this is unreachable; refuse rather than
13938 // silently restructure an extension we did not understand.
13939 return Err(Error::EditUnsupported(
13940 "a persisting file's superblock extension has no File Space Info message",
13941 ));
13942 }
13943 Ok(region.with_bytes(out))
13944}
13945
13946/// Parse and validate a version 2 object header's prefix, returning the absolute
13947/// `[start, end)` byte range of its chunk-0 message region.
13948///
13949/// `prefix` holds the bytes at `[addr, addr + prefix.len())` — up to
13950/// [`OH_PREFIX_MAX`], fewer when the header sits near the end of the image — and
13951/// `file_len` is the length of the image the header lives in, which bounds the
13952/// region. Rejects headers that are not OHDR v2. Everything the prefix declares
13953/// ([`OhHeaderProps`]) is returned with the region: the record layout, because a
13954/// header that tracks attribute creation order carries 6-byte message records
13955/// and every walk of this region has to step by that width, and the two optional
13956/// blocks, because nothing below the prefix records them and a rebuild has to
13957/// put them back.
13958fn oh_region_at(
13959 prefix: &[u8],
13960 addr: u64,
13961 file_len: u64,
13962) -> Result<(u64, u64, OhHeaderProps), Error> {
13963 if prefix.len() < 6 || &prefix[..4] != b"OHDR" || prefix[4] != 2 {
13964 return Err(Error::EditUnsupported(
13965 "an object does not use a version 2 object header",
13966 ));
13967 }
13968 let flags = prefix[5];
13969 let layout = OhRecordLayout::from_header_flags(flags);
13970 let mut pos = 6usize;
13971 let mut take = |len: usize| -> Result<usize, Error> {
13972 let at = pos;
13973 pos += len;
13974 if prefix.len() < pos {
13975 return Err(Error::EditUnsupported("truncated object header"));
13976 }
13977 Ok(at)
13978 };
13979 let times = if flags & OH_FLAG_STORE_TIMES != 0 {
13980 let at = take(ObjectTimes::LEN)?;
13981 Some(ObjectTimes::parse(prefix, at))
13982 } else {
13983 None
13984 };
13985 let attr_phase_change = if flags & OH_FLAG_ATTR_PHASE_CHANGE != 0 {
13986 let at = take(AttrPhaseChange::LEN)?;
13987 Some(AttrPhaseChange::parse(prefix, at))
13988 } else {
13989 None
13990 };
13991 let props = OhHeaderProps {
13992 layout,
13993 times,
13994 attr_phase_change,
13995 };
13996 let size_width = match flags & 0x03 {
13997 0 => 1usize,
13998 1 => 2,
13999 2 => 4,
14000 _ => 8,
14001 };
14002 if prefix.len() < pos + size_width {
14003 return Err(Error::EditUnsupported("truncated object header"));
14004 }
14005 let chunk0_size = read_le(&prefix[pos..pos + size_width]) as u64;
14006 pos += size_width;
14007 let region_start = addr
14008 .checked_add(pos as u64)
14009 .ok_or(Error::EditUnsupported("truncated object header"))?;
14010 // The region is followed by a 4-byte checksum, which must also be present.
14011 let region_end = region_start
14012 .checked_add(chunk0_size)
14013 .filter(|e| e.checked_add(4).is_some_and(|end| end <= file_len))
14014 .ok_or(Error::EditUnsupported("truncated object header"))?;
14015 Ok((region_start, region_end, props))
14016}
14017
14018/// One chunk of a version 2 object header, read out of a file image.
14019///
14020/// The buffer stops at the end of the chunk's message region: the trailing
14021/// checksum is never walked, and it has already been confirmed present. `span`
14022/// covers the *whole* on-disk chunk including that checksum, so it can be handed
14023/// to the free list when the header is reclaimed.
14024pub(crate) struct OhChunk {
14025 /// Absolute file address and full on-disk length of the chunk.
14026 pub(crate) span: (u64, u64),
14027 /// The chunk's bytes, from `span.0` through the end of its message region.
14028 buf: Vec<u8>,
14029 /// Offset of the first message within [`buf`](Self::buf).
14030 messages_start: usize,
14031 /// What chunk 0 of this header declared in its prefix, which every chunk of
14032 /// the header shares.
14033 props: OhHeaderProps,
14034}
14035
14036impl OhChunk {
14037 /// The slice to walk messages in, and the offset to start at. The two are
14038 /// returned together because [`OhRecordLayout::next_message`] must not read
14039 /// past the end of the message region into the checksum.
14040 pub(crate) fn message_region(&self) -> (&[u8], usize) {
14041 (&self.buf, self.messages_start)
14042 }
14043
14044 /// The record layout to walk [`message_region`](Self::message_region) in.
14045 pub(crate) fn layout(&self) -> OhRecordLayout {
14046 self.props.layout
14047 }
14048
14049 /// Everything this header's chunk-0 prefix declared.
14050 pub(crate) fn props(&self) -> OhHeaderProps {
14051 self.props
14052 }
14053}
14054
14055/// Read chunk 0 of the version 2 object header at `addr` out of `src`.
14056fn read_oh_chunk0<S: Source + ?Sized>(src: &S, addr: u64) -> Result<OhChunk, Error> {
14057 let file_len = src.len();
14058 let window = file_len
14059 .saturating_sub(addr)
14060 .min(OH_PREFIX_MAX as u64)
14061 .to_usize()?;
14062 let prefix = src.read_metadata_at(addr, window)?;
14063 let (rs, re, props) = oh_region_at(&prefix, addr, file_len)?;
14064 // `re >= rs > addr`, so both differences are non-negative, and `oh_region_at`
14065 // has checked that the 4-byte checksum past `re` is present.
14066 let len = (re - addr).to_usize()?;
14067 Ok(OhChunk {
14068 span: (addr, len as u64 + 4),
14069 buf: src.read_metadata_at(addr, len)?,
14070 messages_start: (rs - addr).to_usize()?,
14071 props,
14072 })
14073}
14074
14075/// Read every chunk of the version 2 object header at `addr`, chunk 0 first,
14076/// following each `Continuation` message to its `OCHK` block.
14077///
14078/// This is the one traversal of a header's chunk chain: [`gather_oh_messages`]
14079/// collects the messages out of the result and
14080/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) collects the extents, so the
14081/// two cannot disagree about what a header occupies.
14082pub(crate) fn read_oh_chunks<S: Source + ?Sized>(
14083 src: &S,
14084 addr: u64,
14085 base: BaseAddress,
14086) -> Result<Vec<OhChunk>, Error> {
14087 let mut chunks = vec![read_oh_chunk0(src, addr)?];
14088 let mut i = 0;
14089 while i < chunks.len() {
14090 if chunks.len() > MAX_OH_CHUNKS {
14091 return Err(Error::EditUnsupported(
14092 "object header has too many continuation chunks",
14093 ));
14094 }
14095 // Collect this chunk's continuations before extending the worklist, so the
14096 // borrow of `chunks[i]` ends first.
14097 let mut found = Vec::new();
14098 let props = chunks[i].props();
14099 let layout = props.layout;
14100 let (region, mut p) = chunks[i].message_region();
14101 while let Some((msg_type, body, body_end)) = layout.next_message(region, p)? {
14102 if msg_type == MessageType::ObjectHeaderContinuation {
14103 found.push(read_oh_continuation(
14104 src, region, body, body_end, base, props,
14105 )?);
14106 }
14107 p = body_end;
14108 }
14109 i += 1;
14110 chunks.extend(found);
14111 }
14112 Ok(chunks)
14113}
14114
14115/// Read the `OCHK` continuation block a continuation message points at.
14116///
14117/// `region[body..body_end]` is the continuation message's body: the block's
14118/// base-relative address followed by its length.
14119fn read_oh_continuation<S: Source + ?Sized>(
14120 src: &S,
14121 region: &[u8],
14122 body: usize,
14123 body_end: usize,
14124 base: BaseAddress,
14125 props: OhHeaderProps,
14126) -> Result<OhChunk, Error> {
14127 if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
14128 return Err(Error::EditUnsupported("malformed continuation message"));
14129 }
14130 let off = u64::from_le_bytes(region[body..body + 8].try_into().unwrap());
14131 let len = u64::from_le_bytes(region[body + 8..body + 16].try_into().unwrap());
14132 // The block address is stored relative to the base address; shift it to an
14133 // absolute file offset before reading.
14134 let off = base
14135 .absolute(off)
14136 .map_err(|_| Error::EditUnsupported("continuation address overflow"))?;
14137 // An OCHK block is signature(4) + messages + checksum(4).
14138 let end = off
14139 .checked_add(len)
14140 .filter(|&e| e <= src.len() && len >= 8)
14141 .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
14142 let want = (end - off)
14143 .to_usize()
14144 .map_err(|_| Error::EditUnsupported("continuation length exceeds this platform"))?;
14145 let mut buf = src.read_metadata_at(off, want)?;
14146 if buf[..4] != *b"OCHK" {
14147 return Err(Error::EditUnsupported(
14148 "invalid continuation block signature",
14149 ));
14150 }
14151 // Trim the trailing checksum so the message walk stops at the last message.
14152 buf.truncate(want - 4);
14153 Ok(OhChunk {
14154 span: (off, len),
14155 buf,
14156 messages_start: 4,
14157 props,
14158 })
14159}
14160
14161/// How a version 2 object header's message records are laid out, and what its
14162/// flags say about attribute creation order.
14163///
14164/// Every record opens with a type byte, a 2-byte body size and a flags byte. A
14165/// header that *tracks* attribute creation order — bit 2 of the object header's
14166/// own flags, what `H5Pset_attr_creation_order` and h5py's `track_order=True`
14167/// turn on, and what netCDF-4 sets on every object it writes — follows those
14168/// with a 2-byte creation index, so its records are 6 bytes wide rather than 4.
14169/// A header that also *indexes* that order (bit 3) carries a creation-order
14170/// B-tree beside the name index once its attributes go dense; the reference C
14171/// library reads that bit back out of the header when it builds an Attribute
14172/// Info message, so a rewrite that dropped it would quietly stop indexing.
14173///
14174/// Both bits are properties of the whole header, so chunk 0 and every
14175/// continuation block of one header share a layout. Carrying it beside the bytes
14176/// ([`OhRegion`]) is what keeps the two dozen walkers and the emitters in this
14177/// module from having to agree about it one by one.
14178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14179pub(crate) struct OhRecordLayout {
14180 /// Records carry a 2-byte creation index (object header flags bit 2).
14181 tracked: bool,
14182 /// Dense attribute storage indexes that order (object header flags bit 3).
14183 indexed: bool,
14184}
14185
14186/// Object header flags bit 2: message creation order is tracked, so every
14187/// message record carries a creation index.
14188const OH_FLAG_CREATION_ORDER_TRACKED: u8 = 0x04;
14189
14190/// Object header flags bit 3: attribute creation order is *indexed* as well as
14191/// tracked, so dense attribute storage carries a creation-order B-tree beside
14192/// its name index.
14193const OH_FLAG_CREATION_ORDER_INDEXED: u8 = 0x08;
14194
14195/// Object header flags bit 4: the header prefix carries the attribute
14196/// phase-change thresholds (`H5O_HDR_ATTR_STORE_PHASE_CHANGE`).
14197const OH_FLAG_ATTR_PHASE_CHANGE: u8 = 0x10;
14198
14199/// Object header flags bit 5: the header prefix carries the four access,
14200/// modification, change and birth timestamps (`H5O_HDR_STORE_TIMES`).
14201const OH_FLAG_STORE_TIMES: u8 = 0x20;
14202
14203/// The four timestamps a version 2 object header stores when
14204/// [`OH_FLAG_STORE_TIMES`] is set, each 4 bytes of seconds since the Unix epoch
14205/// and stored in this order.
14206///
14207/// The reference C library stores them on **every** version 2 header it writes:
14208/// `H5O_CRT_OHDR_FLAGS_DEF` is `H5O_HDR_STORE_TIMES`, so a header from libhdf5,
14209/// h5py or netCDF-4 carries all four, and `H5Oget_info` reads them straight out
14210/// of this block.
14211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14212pub(crate) struct ObjectTimes {
14213 access: u32,
14214 modification: u32,
14215 change: u32,
14216 birth: u32,
14217}
14218
14219impl ObjectTimes {
14220 /// Bytes this block occupies in a header prefix.
14221 const LEN: usize = 16;
14222
14223 /// Parse the block at `at`. The caller must have checked that 16 bytes are
14224 /// available there.
14225 fn parse(prefix: &[u8], at: usize) -> Self {
14226 let field =
14227 |i: usize| u32::from_le_bytes(prefix[at + 4 * i..at + 4 * i + 4].try_into().unwrap());
14228 Self {
14229 access: field(0),
14230 modification: field(1),
14231 change: field(2),
14232 birth: field(3),
14233 }
14234 }
14235
14236 /// The block's on-disk bytes.
14237 fn to_bytes(self) -> [u8; Self::LEN] {
14238 let mut out = [0u8; Self::LEN];
14239 out[0..4].copy_from_slice(&self.access.to_le_bytes());
14240 out[4..8].copy_from_slice(&self.modification.to_le_bytes());
14241 out[8..12].copy_from_slice(&self.change.to_le_bytes());
14242 out[12..16].copy_from_slice(&self.birth.to_le_bytes());
14243 out
14244 }
14245
14246 /// The same times with the modification and change times moved to `now`.
14247 /// The access and birth times are the object's own history and are left
14248 /// where they were.
14249 ///
14250 /// The reference C library's `H5O_touch_oh` reaches the same two-of-four
14251 /// shape by a different pair: on a version 2 header it writes
14252 /// `oh->atime = oh->ctime = now` and carries a source comment saying the
14253 /// modification time still needs code to update. A rewrite is a
14254 /// modification, and it is not an *access*, so this writes the field that
14255 /// says so; both agree on the change time, and neither disturbs the birth
14256 /// time.
14257 fn touched(self, now: u32) -> Self {
14258 Self {
14259 modification: now,
14260 change: now,
14261 ..self
14262 }
14263 }
14264}
14265
14266/// The attribute phase-change thresholds a version 2 object header stores when
14267/// [`OH_FLAG_ATTR_PHASE_CHANGE`] is set: `H5Pset_attr_phase_change`'s maximum
14268/// number of attributes kept compact (in the header) and minimum kept dense (in
14269/// a fractal heap).
14270///
14271/// The reference C library writes this block only when the pair differs from its
14272/// defaults of 8 and 6, so most headers carry no such block at all. Preserved
14273/// verbatim: this editor's own compact/dense decision still uses
14274/// [`MAX_COMPACT_ATTRS`], so a non-default pair survives a rewrite without yet
14275/// steering it.
14276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14277pub(crate) struct AttrPhaseChange {
14278 max_compact: u16,
14279 min_dense: u16,
14280}
14281
14282impl AttrPhaseChange {
14283 /// Bytes this block occupies in a header prefix.
14284 const LEN: usize = 4;
14285
14286 /// Parse the block at `at`. The caller must have checked that 4 bytes are
14287 /// available there.
14288 fn parse(prefix: &[u8], at: usize) -> Self {
14289 Self {
14290 max_compact: u16::from_le_bytes(prefix[at..at + 2].try_into().unwrap()),
14291 min_dense: u16::from_le_bytes(prefix[at + 2..at + 4].try_into().unwrap()),
14292 }
14293 }
14294
14295 /// The block's on-disk bytes.
14296 fn to_bytes(self) -> [u8; Self::LEN] {
14297 let mut out = [0u8; Self::LEN];
14298 out[0..2].copy_from_slice(&self.max_compact.to_le_bytes());
14299 out[2..4].copy_from_slice(&self.min_dense.to_le_bytes());
14300 out
14301 }
14302}
14303
14304/// Everything chunk 0's prefix declares about a version 2 object header: the
14305/// record layout its messages are written in, and the two optional blocks the
14306/// prefix itself may carry.
14307///
14308/// All of it is a property of the *header*, shared by chunk 0 and every
14309/// continuation block, and none of it can be re-derived from the message bytes —
14310/// so it travels beside them ([`OhRegion`]) from the parse right through to the
14311/// rebuild. A rewrite that dropped the optional blocks would silently zero every
14312/// timestamp `H5Oget_info` reports on a file the C library wrote, and reset the
14313/// phase-change thresholds a caller set with `H5Pset_attr_phase_change`.
14314#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14315pub(crate) struct OhHeaderProps {
14316 /// How wide a message record prefix is, and whether creation order is
14317 /// tracked and indexed.
14318 layout: OhRecordLayout,
14319 /// The access/modification/change/birth block, where the header stores one.
14320 times: Option<ObjectTimes>,
14321 /// The compact/dense attribute thresholds, where the header stores them.
14322 attr_phase_change: Option<AttrPhaseChange>,
14323}
14324
14325impl OhHeaderProps {
14326 /// 4-byte records, no creation order, and neither optional block: what this
14327 /// crate's whole-file writer emits, and what a header this editor creates
14328 /// from nothing uses.
14329 pub(crate) const PLAIN: Self = Self::with_layout(OhRecordLayout::PLAIN);
14330
14331 /// A header in `layout` carrying neither optional block.
14332 pub(crate) const fn with_layout(layout: OhRecordLayout) -> Self {
14333 Self {
14334 layout,
14335 times: None,
14336 attr_phase_change: None,
14337 }
14338 }
14339
14340 /// The object-header flag bits these properties imply, above the two size
14341 /// bits the emitter chooses from the region's length.
14342 const fn header_flags(self) -> u8 {
14343 let times = if self.times.is_some() {
14344 OH_FLAG_STORE_TIMES
14345 } else {
14346 0
14347 };
14348 let phase = if self.attr_phase_change.is_some() {
14349 OH_FLAG_ATTR_PHASE_CHANGE
14350 } else {
14351 0
14352 };
14353 self.layout.header_flags() | times | phase
14354 }
14355
14356 /// Bytes the optional blocks add to the header prefix.
14357 const fn optional_len(self) -> usize {
14358 let times = if self.times.is_some() {
14359 ObjectTimes::LEN
14360 } else {
14361 0
14362 };
14363 let phase = if self.attr_phase_change.is_some() {
14364 AttrPhaseChange::LEN
14365 } else {
14366 0
14367 };
14368 times + phase
14369 }
14370}
14371
14372impl OhRecordLayout {
14373 /// 4-byte record prefixes and no creation order at all: what this crate's
14374 /// whole-file writer emits, and what a header this editor creates from
14375 /// nothing uses.
14376 pub(crate) const PLAIN: Self = Self {
14377 tracked: false,
14378 indexed: false,
14379 };
14380
14381 /// The layout a version 2 object header's flags byte declares.
14382 pub(crate) const fn from_header_flags(flags: u8) -> Self {
14383 Self {
14384 tracked: flags & OH_FLAG_CREATION_ORDER_TRACKED != 0,
14385 indexed: flags & OH_FLAG_CREATION_ORDER_INDEXED != 0,
14386 }
14387 }
14388
14389 /// Bytes a message record spends before its body.
14390 pub(crate) const fn prefix_len(self) -> usize {
14391 if self.tracked { 6 } else { 4 }
14392 }
14393
14394 /// The object-header flag bits this layout implies.
14395 const fn header_flags(self) -> u8 {
14396 let tracked = if self.tracked {
14397 OH_FLAG_CREATION_ORDER_TRACKED
14398 } else {
14399 0
14400 };
14401 let indexed = if self.indexed {
14402 OH_FLAG_CREATION_ORDER_INDEXED
14403 } else {
14404 0
14405 };
14406 tracked | indexed
14407 }
14408
14409 /// Whether records carry a creation index at all.
14410 pub(crate) const fn tracks_creation_order(self) -> bool {
14411 self.tracked
14412 }
14413
14414 /// Whether dense attribute storage on this object indexes that order, with
14415 /// a creation-order B-tree beside the name index.
14416 pub(crate) const fn indexes_creation_order(self) -> bool {
14417 self.indexed
14418 }
14419
14420 /// Parse the message record at `p` within a chunk's message region,
14421 /// returning `(message type, body start, body end)`; the next record begins
14422 /// at `body end`. Returns `Ok(None)` once fewer bytes remain than a record
14423 /// prefix takes (a clean end of the region, or the gap the reference C
14424 /// library leaves when a chunk's free space is too small to hold a message),
14425 /// and `Err` if a record's declared body runs past the region. Centralizes
14426 /// the bounds check shared by every walker.
14427 pub(crate) fn next_message(
14428 self,
14429 region: &[u8],
14430 p: usize,
14431 ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14432 if p + self.prefix_len() > region.len() {
14433 return Ok(None);
14434 }
14435 let msg_type = MessageType::from_u16(region[p] as u16);
14436 let msg_size = u16::from_le_bytes([region[p + 1], region[p + 2]]) as usize;
14437 let body = p + self.prefix_len();
14438 let body_end = body + msg_size;
14439 if body_end > region.len() {
14440 return Err(Error::EditUnsupported("malformed object header message"));
14441 }
14442 Ok(Some((msg_type, body, body_end)))
14443 }
14444
14445 /// The creation index the record at `msg_start` carries, or `None` where the
14446 /// layout has no such field. The caller must have located `msg_start` with
14447 /// [`next_message`](Self::next_message), which bounds the read.
14448 fn creation_index(self, region: &[u8], msg_start: usize) -> Option<u16> {
14449 self.tracked
14450 .then(|| u16::from_le_bytes([region[msg_start + 4], region[msg_start + 5]]))
14451 }
14452
14453 /// Encode one message record: this layout's prefix, then `body`.
14454 ///
14455 /// `creation_index` is written only where the layout carries one. It is
14456 /// meaningful for an Attribute message, whose creation index *is* the
14457 /// attribute's creation order; the reference C library writes zero on every
14458 /// other message type, which is what [`Self::record`] passes.
14459 fn record_with_creation_index(
14460 self,
14461 msg_type: MessageType,
14462 body: &[u8],
14463 creation_index: u16,
14464 ) -> Vec<u8> {
14465 let mut m = Vec::with_capacity(self.prefix_len() + body.len());
14466 #[expect(
14467 clippy::cast_possible_truncation,
14468 reason = "message type ids are a small enum that fits the 1-byte v2 type field"
14469 )]
14470 m.push(msg_type.to_u16() as u8);
14471 #[expect(
14472 clippy::cast_possible_truncation,
14473 reason = "callers pass bodies that fit the 2-byte message-size field (see doc comment)"
14474 )]
14475 m.extend_from_slice(&(body.len() as u16).to_le_bytes());
14476 m.push(0); // message flags
14477 if self.tracks_creation_order() {
14478 m.extend_from_slice(&creation_index.to_le_bytes());
14479 }
14480 m.extend_from_slice(body);
14481 m
14482 }
14483
14484 /// Encode one message record whose creation index, if the layout has one, is
14485 /// the zero the reference C library writes for every non-attribute message.
14486 fn record(self, msg_type: MessageType, body: &[u8]) -> Vec<u8> {
14487 self.record_with_creation_index(msg_type, body, 0)
14488 }
14489}
14490
14491/// A version 2 object header's message records, in the layout the header
14492/// declares them in.
14493///
14494/// The editor's model of a header is one contiguous run of message records:
14495/// [`WriteEngine::gather_oh_messages`] collapses a multi-chunk header into it,
14496/// the rewriters copy it message by message, and
14497/// [`build_v2_object_header`] wraps it back up. Every one of those has to agree
14498/// with the header's own flags about how wide a record prefix is, so the layout
14499/// travels with the bytes rather than being re-derived — or assumed — at each
14500/// step.
14501#[derive(Clone, Debug, Default)]
14502pub(crate) struct OhRegion {
14503 bytes: Vec<u8>,
14504 props: OhHeaderProps,
14505}
14506
14507impl core::ops::Deref for OhRegion {
14508 type Target = [u8];
14509 fn deref(&self) -> &[u8] {
14510 &self.bytes
14511 }
14512}
14513
14514impl OhRegion {
14515 /// A region of `bytes` belonging to a header with these `props`.
14516 pub(crate) fn new(bytes: Vec<u8>, props: OhHeaderProps) -> Self {
14517 Self { bytes, props }
14518 }
14519
14520 /// An empty region for a header with these `props`, to be filled record by
14521 /// record.
14522 fn empty(props: OhHeaderProps) -> Self {
14523 Self::new(Vec::new(), props)
14524 }
14525
14526 /// The same header's properties over different bytes: what every rewriter
14527 /// that copies a region message by message returns.
14528 fn with_bytes(&self, bytes: Vec<u8>) -> Self {
14529 Self::new(bytes, self.props)
14530 }
14531
14532 pub(crate) fn layout(&self) -> OhRecordLayout {
14533 self.props.layout
14534 }
14535
14536 /// Everything the header's prefix declares, which a rebuild re-emits.
14537 pub(crate) fn props(&self) -> OhHeaderProps {
14538 self.props
14539 }
14540
14541 /// [`OhRecordLayout::next_message`] over this region's own bytes.
14542 pub(crate) fn next_message(
14543 &self,
14544 p: usize,
14545 ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14546 self.props.layout.next_message(&self.bytes, p)
14547 }
14548
14549 /// The creation index of the record at `msg_start`, or `None` for a header
14550 /// that does not track it.
14551 fn creation_index(&self, msg_start: usize) -> Option<u16> {
14552 self.props.layout.creation_index(&self.bytes, msg_start)
14553 }
14554
14555 /// Append one already-encoded record (or run of records) written in this
14556 /// region's layout.
14557 fn push_bytes(&mut self, record: &[u8]) {
14558 self.bytes.extend_from_slice(record);
14559 }
14560
14561 /// The bytes, for a patch that changes a field inside a record without
14562 /// changing any record's length.
14563 fn bytes_mut(&mut self) -> &mut [u8] {
14564 &mut self.bytes
14565 }
14566
14567 /// Append a hard Link message record for `name -> addr`, carrying
14568 /// `creation_order` where the group tracks link creation order and `None`
14569 /// where it does not (see [`LinkCreationOrder`], which is what decides
14570 /// which).
14571 fn push_link(&mut self, name: &str, addr: u64, creation_order: Option<u64>) {
14572 let record = encode_link_message(self.props.layout, name, addr, creation_order);
14573 self.push_bytes(&record);
14574 }
14575
14576 /// Append a message record for `body`, with the zero creation index the
14577 /// reference C library writes for every non-attribute message.
14578 fn push(&mut self, msg_type: MessageType, body: &[u8]) {
14579 let record = self.props.layout.record(msg_type, body);
14580 self.push_bytes(&record);
14581 }
14582
14583 /// Append a message whose body is a *reference* to the one copy of it stored
14584 /// elsewhere, rather than the message itself.
14585 ///
14586 /// The flag is what tells every reader to follow the body instead of
14587 /// decoding it, so it travels with the bytes: a rewrite that dropped it
14588 /// would leave a reference to be read as content.
14589 fn push_shared(&mut self, msg_type: MessageType, reference: &[u8]) {
14590 let mut record = self.props.layout.record(msg_type, reference);
14591 record[3] = MSG_FLAG_SHARED;
14592 self.push_bytes(&record);
14593 }
14594}
14595
14596/// Version-2 object-header message flag bit marking a message as *shared* (stored
14597/// once in the shared-message table and referenced by an object-header address or
14598/// fractal-heap id) rather than inline. Whatever the message type, that reference
14599/// points into the source file and is meaningless after a cross-file copy.
14600pub(crate) const MSG_FLAG_SHARED: u8 = 0x02;
14601
14602/// Refuse to copy a dataset whose element bytes live in files outside this one
14603/// (`H5Pset_external`, the External Data Files header message, type 7), which
14604/// this crate does not follow (issue #336).
14605///
14606/// Such a dataset carries a *contiguous* layout message with an undefined data
14607/// address, the same encoding a never-written dataset uses — and
14608/// [`read_copy_subtree`](WriteEngine::read_copy_subtree) copies that encoding as
14609/// the storage it does not have. The layout alone cannot tell the two apart, so
14610/// naming this message is the only thing standing between a dataset that holds
14611/// data and a copy carrying its schema and none of it; [`crate::repack`] refuses
14612/// the same shape for the same reason.
14613///
14614/// Applied on both copy paths rather than only the cross-file one: an in-file
14615/// copy would reproduce the header without its data just as readily. It is also
14616/// the only screen that reads this message at all — [`reject_foreign_addresses`]
14617/// inspects shared messages, datatypes, and attributes, not this body, which
14618/// carries a local-heap address that would dangle in another file.
14619fn reject_external_storage(region: &OhRegion) -> Result<(), Error> {
14620 let mut p = 0;
14621 while let Some((msg_type, _, body_end)) = region.next_message(p)? {
14622 if msg_type == MessageType::ExternalDataFiles {
14623 return Err(Error::EditUnsupported(
14624 "a dataset stores its elements in external files (H5Pset_external), \
14625 which a copy cannot reproduce -- its data lives in files this crate \
14626 does not read",
14627 ));
14628 }
14629 p = body_end;
14630 }
14631 Ok(())
14632}
14633
14634/// Refuse to copy an object whose header embeds a *source-file* absolute address
14635/// that a verbatim copy into another file cannot translate. An in-file copy keeps
14636/// these valid by sharing the source file's heaps and objects; a cross-file copy
14637/// cannot. Three things qualify:
14638///
14639/// - a **variable-length** datatype, whose element bytes are global-heap
14640/// references (collection address + index) into the source file's heap;
14641/// - a **reference** datatype (object or dataset-region), whose element bytes are
14642/// absolute object addresses in the source file;
14643/// - any **shared message** (the `MSG_FLAG_SHARED` bit set) — a committed datatype,
14644/// but also a shared dataspace, fill value, or filter-pipeline message — whose
14645/// body is a reference into the source file's shared-message storage.
14646///
14647/// The scan covers a copied object's whole message region (a dataset's or a
14648/// group's): it refuses any shared message outright, and inspects Datatype
14649/// messages (the element type) and Attribute messages (their own datatype),
14650/// recursing through compound members, array elements, and enumeration bases so a
14651/// nested variable-length or reference occurrence is caught too. It is applied
14652/// only on the cross-file path; the same-file [`copy`](crate::File::copy)
14653/// deliberately keeps these forms (their addresses stay valid in one file).
14654fn reject_foreign_addresses(region: &OhRegion) -> Result<(), Error> {
14655 let mut p = 0;
14656 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14657 // A *shared* message stores, in place of its real body, a reference into
14658 // the source file's shared-message storage — an object-header address or a
14659 // fractal-heap (SOHM) id — which means nothing in another file. This
14660 // catches committed (shared) datatypes and shared attributes as well as a
14661 // shared dataspace, fill value, or filter-pipeline message, all of which
14662 // HDF5 may place in the shared-message table. Refuse any of them, whatever
14663 // the message type. The flags byte is the 4th of the record header (type,
14664 // size, flags); `next_message` returning `Some` guarantees
14665 // `p + 4 <= region.len()`.
14666 if region[p + 3] & MSG_FLAG_SHARED != 0 {
14667 return Err(Error::EditUnsupported(
14668 "a shared (committed/SOHM) object-header message cannot be copied to another file yet",
14669 ));
14670 }
14671 match msg_type {
14672 MessageType::Datatype => {
14673 let (dt, _) =
14674 crate::datatype::Datatype::parse(®ion[body..body_end]).map_err(|_| {
14675 Error::EditUnsupported("a source datatype could not be parsed for copying")
14676 })?;
14677 if datatype_holds_file_address(&dt) {
14678 return Err(Error::EditUnsupported(
14679 "variable-length or reference datasets cannot be copied to another file yet",
14680 ));
14681 }
14682 }
14683 MessageType::Attribute => {
14684 // An attribute's *own* datatype or dataspace field can be a
14685 // reference to a committed message, which the record's shared
14686 // flag above does not report: that flag describes the attribute
14687 // message, not the fields inside it. The reference addresses the
14688 // source file, so it cannot travel any more than a shared record
14689 // can — and the parse below cannot resolve it here in any case.
14690 if crate::attribute::message_shares_a_field(®ion[body..body_end]) {
14691 return Err(Error::EditUnsupported(
14692 "an attribute with a committed (shared) datatype cannot be copied to another file yet",
14693 ));
14694 }
14695 let attr =
14696 crate::attribute::AttributeMessage::parse(®ion[body..body_end], LENGTH_SIZE)
14697 .map_err(|_| {
14698 Error::EditUnsupported(
14699 "a source attribute could not be parsed for copying",
14700 )
14701 })?;
14702 if datatype_holds_file_address(&attr.datatype) {
14703 return Err(Error::EditUnsupported(
14704 "variable-length or reference attributes cannot be copied to another file yet",
14705 ));
14706 }
14707 }
14708 _ => {}
14709 }
14710 p = body_end;
14711 }
14712 Ok(())
14713}
14714
14715/// Cross-file screen for a dense (fractal-heap) attribute set. The bytes parsed
14716/// out of the source heap can embed source-file absolute addresses just as inline
14717/// attribute messages can — variable-length (global-heap) or reference attribute
14718/// data — which would dangle in another file. [`reject_foreign_addresses`] screens
14719/// the verbatim object-header region but not heap-resident attribute bytes, so a
14720/// dense attribute set is screened here instead. Same-file copies skip this (their
14721/// addresses stay valid); the fresh heap built on write is same-file by
14722/// construction, so only the source datatypes matter.
14723fn reject_foreign_dense_attrs(attrs: &[crate::attribute::AttributeMessage]) -> Result<(), Error> {
14724 for attr in attrs {
14725 if datatype_holds_file_address(&attr.datatype) {
14726 return Err(Error::EditUnsupported(
14727 "variable-length or reference dense (fractal-heap) attributes cannot be copied to another file yet",
14728 ));
14729 }
14730 }
14731 Ok(())
14732}
14733
14734/// Re-encode a shared-message reference in the modern form, for a message being
14735/// rewrapped from a version 1 object header into a version 2 one.
14736///
14737/// A version 1 header carries the oldest reference encoding: a symbol-table
14738/// entry, with the object-header address buried behind a local-heap address.
14739/// Copying those bytes into a version 2 header would put an encoding there that
14740/// no writer of such a header has produced since the format gained one; parsing
14741/// and re-encoding gives the shape every current reader and writer uses.
14742///
14743/// Which shape that is depends on where the message lives. An object header is
14744/// named by the version 2 form this crate already writes for a committed
14745/// datatype. The shared-message heap has no encoding before version 3, so a heap
14746/// reference gets that one. Both name exactly what the version 1 reference named,
14747/// so the message keeps its single copy and its reference count is unchanged.
14748fn modernize_shared_reference(
14749 body: &[u8],
14750 offset_size: u8,
14751 length_size: u8,
14752) -> Result<Vec<u8>, Error> {
14753 let reference = crate::shared_message::parse_shared_ref(body, offset_size, length_size)?;
14754 Ok(match reference.location {
14755 crate::shared_message::SharedLocation::ObjectHeader(addr) => {
14756 crate::shared_message::encode_committed_ref(addr, offset_size)
14757 }
14758 crate::shared_message::SharedLocation::SohmHeap(id) => {
14759 crate::shared_message::encode_sohm_ref(&id)
14760 }
14761 })
14762}
14763
14764/// Refuse a commit that removes or moves an object header the file's
14765/// shared-message (SOHM) index names.
14766///
14767/// A heap-stored record names no object, so it is not screened here: what a
14768/// commit does to it is a *reference count* that goes stale, which the engine
14769/// refuses at the point it would change one (see [`SHARED_ATTRIBUTE_MESSAGE`]).
14770/// This screens the other record shape, whose address would dangle.
14771fn screen_shared_message_records(
14772 records: &[crate::sohm::SohmRecord],
14773 invalidated: &InvalidatedAddresses,
14774) -> Result<(), Error> {
14775 for record in records {
14776 if let crate::sohm::SohmLocation::ObjectHeader { address, .. } = record.location
14777 && invalidated.refusal(address).is_some()
14778 {
14779 return Err(Error::EditUnsupported(
14780 SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT,
14781 ));
14782 }
14783 }
14784 Ok(())
14785}
14786
14787/// Refuse a staged dataset whose element bytes already hold *resolved* object
14788/// references naming space this commit reclaims (issue #317).
14789///
14790/// This is the address-side half of the rule
14791/// [`WriteEngine::resolve_reference_target`] enforces on the path side. A target
14792/// named as a path is screened there, by name; a target supplied as an address —
14793/// `DatasetBuilder::with_reference_data`, or `with_raw_data` over a datatype that
14794/// holds a reference — never reaches that function at all, and before this screen
14795/// existed it was written straight through to disk.
14796///
14797/// Element bytes still carrying placeholders are unaffected: an unresolved slot
14798/// holds zero, which [`InvalidatedAddresses::refusal`] passes, and the target
14799/// that replaces it is screened by `resolve_reference_target` instead — by name
14800/// for a path, by address for a raw one. So this runs over every staged
14801/// dataset's `raw` without asking which builder filled it: the rule is about the
14802/// bytes, not the door they came through.
14803fn screen_resolved_references(
14804 dt: &Datatype,
14805 raw: &[u8],
14806 invalidated: &InvalidatedAddresses,
14807) -> Result<(), Error> {
14808 if !datatype_holds_object_address(dt) {
14809 return Ok(());
14810 }
14811 // The datatype declares an object reference, so the walker must find one.
14812 // Two ways it does not: slots that do not fit the element size (`None`), and
14813 // a reference `embedded_reference_slots` does not map — it locates the
14814 // 8-byte object reference only, and reports any other width as no slots at
14815 // all. Both mean the addresses cannot be read, and an empty list taken for
14816 // "nothing to check" would wave through exactly the datatype
14817 // `datatype_holds_object_address` had just recognised. Neither shape is
14818 // one this crate builds; refuse rather than write references past a screen
14819 // that could not see them.
14820 let Some(slots) = embedded_reference_slots(dt).filter(|slots| !slots.is_empty()) else {
14821 // Unconditional, unlike the per-address checks below. Those refuse an
14822 // address that lands somewhere this commit vacates; this one cannot read
14823 // the addresses at all, so there is nothing to compare against and no
14824 // amount of screening would help. Every commit that reaches here rebuilds
14825 // at least the root group, so `for_supplied.moved` is never empty and
14826 // gating this would have changed nothing anyway. A commit whose only
14827 // staged edit is a same-length in-place overwrite never reaches here at
14828 // all — it takes the fast path above, which vacates nothing. An in-file
14829 // copy reaches this only beside a deletion, because it is screened
14830 // against `for_copied`, whose `moved` is empty by construction.
14831 //
14832 // The `filter` is belt-and-braces: `embedded_reference_slots` already
14833 // reports an unaddressable reference as `None` rather than as an empty
14834 // list, and reading an empty list as "nothing to check" is exactly the
14835 // hole this screen was found to have.
14836 return Err(Error::EditUnsupported(
14837 "a reference this commit writes sits in a datatype whose addresses this screen \
14838 cannot read — a width other than eight, a dataset-region reference, a \
14839 variable length of them, or a compound holding one beside a reference it can \
14840 read — so it cannot be checked against what this commit vacates; supply an \
14841 8-byte object reference (`with_reference_data`)",
14842 ));
14843 };
14844 // Non-empty slots mean the datatype has room for at least one 8-byte
14845 // address, so the element size is at least 8.
14846 for (_, stored) in stored_object_references(raw, dt.type_size() as usize, &slots) {
14847 if let Some(refusal) = invalidated.refusal(stored) {
14848 return Err(Error::EditUnsupported(refusal));
14849 }
14850 }
14851 Ok(())
14852}
14853
14854/// Screen an in-file copy's subtree against the space this commit reclaims
14855/// (issue #317).
14856///
14857/// An in-file copy re-emits its source's element bytes verbatim, which is what
14858/// keeps a copied variable-length or reference dataset valid: the addresses
14859/// still name the same file, so nothing has to be rewritten. A deletion in the
14860/// same commit takes that away for object references alone — a global heap
14861/// collection is never reclaimed by a delete, so variable-length elements keep
14862/// pointing at data that is still there.
14863///
14864/// Every element that can be read is screened by address, through the same
14865/// [`screen_resolved_references`] a staged dataset's bytes go through: a
14866/// contiguous dataset's data block, a compact dataset's inline data, and every
14867/// attribute, inline or dense. A committed (shared) datatype is resolved through
14868/// `src` first, so a copy of an object with a named type is screened like any
14869/// other rather than refused for carrying a type this could not read.
14870///
14871/// One form cannot be read at all and so is refused by *datatype*, and only when
14872/// it holds an object reference: a **chunked** dataset, whose addresses sit
14873/// inside chunks this path carries compressed and never decodes — the same
14874/// obstacle that makes [`crate::repack`] refuse a chunked object-reference
14875/// dataset outright.
14876///
14877/// `src` is the session's image framed at its base address, the view a stored
14878/// (base-relative) shared-message address indexes directly.
14879fn screen_copied_references(
14880 tree: &CopyTree,
14881 invalidated: &InvalidatedAddresses,
14882 src: &(impl Source + ?Sized),
14883) -> Result<(), Error> {
14884 if invalidated.is_empty() {
14885 return Ok(());
14886 }
14887 use crate::shared_message::SharedResolver as _;
14888 // No shared-message table: a heap-stored message is refused rather than
14889 // followed here, and this screen treats an unreadable datatype as one it
14890 // cannot clear, which is the conservative answer.
14891 let resolver = crate::shared_message::SourceResolver::new(src, OFFSET_SIZE, LENGTH_SIZE, None);
14892 let (region, dense_attrs) = match tree {
14893 CopyTree::DatasetVerbatim {
14894 region,
14895 dense_attrs,
14896 }
14897 | CopyTree::DatasetContiguous {
14898 region,
14899 dense_attrs,
14900 ..
14901 }
14902 | CopyTree::DatasetChunked {
14903 region,
14904 dense_attrs,
14905 ..
14906 }
14907 | CopyTree::Group {
14908 non_link_region: region,
14909 dense_attrs,
14910 ..
14911 } => (region, dense_attrs),
14912 };
14913 for attr in &dense_attrs.attrs {
14914 screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14915 }
14916
14917 // One walk of the header: the object's own element datatype, the compact
14918 // data the layout message may carry, and every inline attribute.
14919 let mut element_dt: Option<Datatype> = None;
14920 let mut compact: Option<Vec<u8>> = None;
14921 let mut p = 0;
14922 while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14923 // The flags byte is the 4th of the record header (type, size, flags);
14924 // `next_message` returning `Some` guarantees it is in bounds.
14925 let shared = region[p + 3] & MSG_FLAG_SHARED != 0;
14926 match msg_type {
14927 MessageType::Datatype => {
14928 // A committed datatype's message body is a pointer into the
14929 // file's shared-message storage rather than an encoded type, so
14930 // read the type it names before parsing.
14931 let committed;
14932 let encoded = if shared {
14933 committed = resolver
14934 .resolve(®ion[body..body_end], MessageType::Datatype)
14935 .map_err(|_| {
14936 Error::EditUnsupported(
14937 "a copy in this commit names a committed (shared) datatype that \
14938 could not be read, so its elements cannot be screened against \
14939 the same commit's deletions; use separate commits",
14940 )
14941 })?;
14942 &committed[..]
14943 } else {
14944 ®ion[body..body_end]
14945 };
14946 let (dt, _) = Datatype::parse(encoded).map_err(|_| {
14947 Error::EditUnsupported("a source datatype could not be parsed for copying")
14948 })?;
14949 element_dt = Some(dt);
14950 }
14951 MessageType::DataLayout => {
14952 if let Ok(DataLayout::Compact { data }) =
14953 DataLayout::parse(®ion[body..body_end], OFFSET_SIZE, LENGTH_SIZE)
14954 {
14955 compact = Some(data);
14956 }
14957 }
14958 MessageType::Attribute => {
14959 // A *shared record* is the whole attribute message held in the
14960 // file's shared-message table, which is a different indirection
14961 // from the committed datatype `parse_resolving` follows inside
14962 // the fields — and a rare one this path has never modelled. Its
14963 // elements cannot be reached here, so it is refused.
14964 if shared {
14965 return Err(Error::EditUnsupported(
14966 "a copy in this commit carries a shared (SOHM) attribute message, whose \
14967 elements cannot be screened against the same commit's deletions; use \
14968 separate commits",
14969 ));
14970 }
14971 // `parse_resolving` rather than `parse`: an attribute's own
14972 // datatype field can name a committed message, which the record's
14973 // shared flag does not report — that flag describes the attribute
14974 // message, not the fields inside it — and `parse` refuses one.
14975 let attr = crate::attribute::AttributeMessage::parse_resolving(
14976 ®ion[body..body_end],
14977 LENGTH_SIZE,
14978 &resolver,
14979 )
14980 .map_err(|_| {
14981 Error::EditUnsupported("a source attribute could not be parsed for copying")
14982 })?;
14983 screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14984 }
14985 _ => {}
14986 }
14987 p = body_end;
14988 }
14989
14990 match tree {
14991 // Compact: the elements are inline in the data-layout message. A layout
14992 // that did not yield them leaves a reference datatype unscreened, so it
14993 // is refused for the same reason a chunked one is.
14994 CopyTree::DatasetVerbatim { .. } => match (&element_dt, &compact) {
14995 (Some(dt), Some(data)) => screen_resolved_references(dt, data, invalidated)?,
14996 (Some(dt), None) if datatype_holds_object_address(dt) => {
14997 return Err(Error::EditUnsupported(
14998 "a compact object-reference dataset's elements could not be read to screen \
14999 them against this commit's deletions; use separate commits",
15000 ));
15001 }
15002 _ => {}
15003 },
15004 // No Datatype message means no declared reference, here and in the
15005 // chunked arm below: such a header is not a dataset any reader can
15006 // interpret, so there is nothing in it to dangle. That is why only the
15007 // compact arm above refuses on a missing piece — there the datatype is
15008 // present and says a reference is in bytes it could not reach.
15009 CopyTree::DatasetContiguous { data, .. } => {
15010 // A dataset whose storage the source never allocated (`None`) stores
15011 // no elements, so it holds no address that could dangle. Skipped
15012 // rather than screened as an empty run of element bytes: when
15013 // `screen_resolved_references` cannot map a datatype's addresses — a
15014 // dataset-region reference, a variable length of references, one
15015 // wider than eight bytes — it refuses *unconditionally*, before it
15016 // reads a byte, so an empty run would refuse this dataset for
15017 // elements it does not have.
15018 if let (Some(dt), Some(data)) = (&element_dt, data) {
15019 screen_resolved_references(dt, data, invalidated)?;
15020 }
15021 }
15022 // Chunked: `chunk_bytes` are carried exactly as the source stored them,
15023 // filters and all, so there is nothing here to decode addresses out of.
15024 CopyTree::DatasetChunked { .. } => {
15025 if element_dt
15026 .as_ref()
15027 .is_some_and(datatype_holds_object_address)
15028 {
15029 return Err(Error::EditUnsupported(
15030 "a chunked object-reference dataset cannot be copied in a commit that also \
15031 deletes objects: its addresses live inside chunks this path does not \
15032 decode; use separate commits",
15033 ));
15034 }
15035 }
15036 CopyTree::Group { children, .. } => {
15037 for (_, _, child) in children {
15038 screen_copied_references(child, invalidated, src)?;
15039 }
15040 }
15041 }
15042 Ok(())
15043}
15044
15045/// Wrap a chunk-0 message region in a fresh single-chunk version 2 object header
15046/// (`OHDR` prefix + region + Jenkins checksum), first normalizing the region's
15047/// attribute storage with [`ensure_attribute_info`]. Mirrors the encoding in
15048/// [`crate::object_header_writer::ObjectHeaderWriter::serialize`].
15049///
15050/// The normalization belongs here rather than at the fifteen call sites because
15051/// carrying an Attribute Info message is a property of a version 2 header holding
15052/// inline attributes, not of any one edit operation — and a site that forgot it
15053/// would reintroduce the zero-count defect silently.
15054///
15055/// A region that cannot be walked is reported, not asserted away. Every region
15056/// reaching here should have been built by this crate or already walked
15057/// message-by-message on the way in, but "should" is a claim about a file this
15058/// session did not write: a header whose message size field overruns the region
15059/// is a malformed *file*, which is the caller's input and so takes an
15060/// [`Error::EditUnsupported`], the way every other malformed-header path in this
15061/// module does. The `debug_assert!(false)` this replaced made the two build
15062/// profiles disagree about whether such a file was writable at all — a panic in
15063/// a test build, and in a release build a header silently missing its Attribute
15064/// Info message, which is the zero-count defect this function exists to prevent.
15065pub(crate) fn build_v2_object_header(region: &OhRegion) -> Result<Vec<u8>, Error> {
15066 let mut owned = region.clone();
15067 ensure_attribute_info(&mut owned)?;
15068 Ok(build_v2_object_header_verbatim(&owned))
15069}
15070
15071/// [`build_v2_object_header`] without the attribute-storage normalization, for
15072/// the region that function has already normalized.
15073///
15074/// **The header's optional prefix blocks are re-emitted, and its timestamps are
15075/// stamped.** A region carries whatever chunk 0's prefix declared
15076/// ([`OhHeaderProps`]), so a rewrite of a header from the reference C library,
15077/// h5py or netCDF-4 puts its four timestamps and any attribute phase-change
15078/// thresholds back where it found them, with the flag bits that announce them.
15079///
15080/// Of those, the **modification and change times are moved to now**: this
15081/// function runs once per rebuilt header, and every rebuild is a modification.
15082/// Access and birth times are the object's own history and are copied verbatim
15083/// ([`ObjectTimes::touched`] says how that compares with `H5O_touch_oh`).
15084/// Under `no_std` there is no clock to read, so all four are preserved as they
15085/// were rather than zeroed — a stale modification time being the honest reading
15086/// of "this build cannot tell the time", where a zero would claim the epoch.
15087/// A header this crate creates from nothing stores no times at all, so nothing
15088/// here applies to it.
15089fn build_v2_object_header_verbatim(region: &OhRegion) -> Vec<u8> {
15090 let total = region.len();
15091 let (size_flags, width) = if total <= 255 {
15092 (0u8, 1usize)
15093 } else if total <= 65535 {
15094 (1u8, 2)
15095 } else {
15096 (2u8, 4)
15097 };
15098 let props = region.props();
15099 // The creation-order bits and the two optional-block bits are the header's
15100 // own claim about what follows, so they come from the properties the region
15101 // was parsed with.
15102 let flags = size_flags | props.header_flags();
15103 let mut buf = Vec::with_capacity(8 + props.optional_len() + total + 4);
15104 buf.extend_from_slice(b"OHDR");
15105 buf.push(2); // version
15106 buf.push(flags);
15107 if let Some(times) = props.times {
15108 let stamped = match unix_time_now() {
15109 Some(now) => times.touched(now),
15110 None => times,
15111 };
15112 buf.extend_from_slice(&stamped.to_bytes());
15113 }
15114 if let Some(phase) = props.attr_phase_change {
15115 buf.extend_from_slice(&phase.to_bytes());
15116 }
15117 #[expect(
15118 clippy::cast_possible_truncation,
15119 reason = "width was selected just above to be the smallest field that holds total"
15120 )]
15121 match width {
15122 1 => buf.push(total as u8),
15123 2 => buf.extend_from_slice(&(total as u16).to_le_bytes()),
15124 _ => buf.extend_from_slice(&(total as u32).to_le_bytes()),
15125 }
15126 buf.extend_from_slice(region);
15127 let checksum = jenkins_lookup3(&buf);
15128 buf.extend_from_slice(&checksum.to_le_bytes());
15129 buf
15130}
15131
15132/// Seconds since the Unix epoch, for the object-header timestamps a rebuild
15133/// stamps, or `None` where this build has no clock.
15134///
15135/// The crate carries no other wall-clock reader: every other date it writes
15136/// comes from its caller. `no_std` builds have no `SystemTime` at all and return
15137/// `None`, and so does a `std` build whose clock is set before 1970 — neither is
15138/// a reason to fail a commit, so both leave the header's stored times alone (see
15139/// [`build_v2_object_header_verbatim`]). The 4-byte field saturates rather than
15140/// wrapping, which matters only past 2106.
15141#[cfg(feature = "std")]
15142fn unix_time_now() -> Option<u32> {
15143 std::time::SystemTime::now()
15144 .duration_since(std::time::UNIX_EPOCH)
15145 .ok()
15146 .map(|d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX))
15147}
15148
15149/// No wall clock outside `std`; see the `std` definition.
15150#[cfg(not(feature = "std"))]
15151fn unix_time_now() -> Option<u32> {
15152 None
15153}
15154
15155/// Read a little-endian unsigned integer of `bytes.len()` (≤ 8) bytes.
15156#[expect(
15157 clippy::cast_possible_truncation,
15158 reason = "callers parse in-file sizes/offsets bounded by the in-memory image; downstream \
15159 slicing is length-checked, so a malformed oversized field errors rather than reads OOB"
15160)]
15161fn read_le(bytes: &[u8]) -> usize {
15162 let mut v = 0u64;
15163 for (i, &b) in bytes.iter().enumerate() {
15164 v |= (b as u64) << (8 * i);
15165 }
15166 v as usize
15167}
15168
15169/// The engine as the target a [`reference_patch::Plan`] is applied to
15170/// (issue #324).
15171///
15172/// Deliberately the whole engine rather than the image alone: an object header
15173/// is republished as one checksummed write, so applying a plan reads as well as
15174/// writes, and both have to go through the same image the rest of the commit
15175/// used — the same write-gathering, the same pending-write overlay, the same
15176/// end-of-file bound.
15177///
15178/// [`reference_patch::Plan`]: crate::reference_patch::Plan
15179impl crate::reference_patch::PatchTarget for WriteEngine {
15180 fn read(&self, at: u64, len: usize) -> Result<Vec<u8>, Error> {
15181 self.image().read_exact_at(at, len).map_err(Error::Format)
15182 }
15183 fn write(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
15184 self.image.write_at(at, bytes)
15185 }
15186}
15187
15188#[cfg(test)]
15189mod tests {
15190 use super::*;
15191
15192 /// The rule that places a chunk index on a paged file: some chunk-data span
15193 /// abuts it. Both sides count, which is what a repeatedly appended dataset
15194 /// needs — its blobs leave chunk data above the index as well as below it, so
15195 /// the highest data address is not the one beside the index (issue #388).
15196 #[test]
15197 fn a_chunk_index_is_placed_by_the_chunk_data_that_abuts_it() {
15198 // Data below, index above: the from-scratch layout.
15199 assert!(index_abuts_chunk_data(&[(100, 60)], &[(160, 40)]));
15200 // Index below, data above: a blob placed in a hole under existing chunks.
15201 assert!(index_abuts_chunk_data(&[(200, 60)], &[(160, 40)]));
15202 // Data on both sides, with the abutting span not the highest one — the
15203 // shape every staged append after the first produces.
15204 assert!(index_abuts_chunk_data(
15205 &[(100, 60), (900, 60)],
15206 &[(160, 40)]
15207 ));
15208 // A multi-block index is placed as one run by the span beside its edge.
15209 assert!(index_abuts_chunk_data(
15210 &[(100, 60)],
15211 &[(180, 20), (160, 20)]
15212 ));
15213
15214 // Neither edge is touched: the reference library's layout, where the index
15215 // sits in a metadata page far below the chunk data.
15216 assert!(!index_abuts_chunk_data(&[(9000, 60)], &[(160, 40)]));
15217 // A gap of one byte is not an abutment.
15218 assert!(!index_abuts_chunk_data(&[(100, 59)], &[(160, 40)]));
15219 // No chunk data at all — an empty resizable dataset's eagerly built index
15220 // — places nothing.
15221 assert!(!index_abuts_chunk_data(&[], &[(160, 40)]));
15222 // Nothing to place.
15223 assert!(index_abuts_chunk_data(&[], &[]));
15224 // A zero-length data span touches the index without occupying a byte
15225 // beside it.
15226 assert!(!index_abuts_chunk_data(&[(160, 0)], &[(160, 40)]));
15227 }
15228
15229 /// The screen that keeps the abutment from admitting a reference-library
15230 /// layout: an index with a byte in page 0 sits beside the superblock, so it is
15231 /// in a metadata page however its far end lines up with chunk data.
15232 #[test]
15233 fn a_chunk_index_in_page_zero_is_never_raw() {
15234 const PAGE: u64 = 512;
15235 // The C-written shape this exists to refuse: the index header sits in
15236 // page 0 behind the superblock, its last block fills page 1 exactly, and
15237 // the first raw page begins where that block ends. The abutment alone
15238 // called the whole run raw.
15239 let c_written = [(48u64, 80u64), (512, 512)];
15240 assert!(index_abuts_chunk_data(&[(1024, 512)], &c_written));
15241 assert!(index_touches_page_zero(&c_written, PAGE));
15242 // An index wholly above page 0 — every one this crate writes, since it
15243 // places chunk data and the index beside it out of raw pages — is not
15244 // screened out.
15245 assert!(!index_touches_page_zero(&[(1024, 200)], PAGE));
15246 // The boundary: the last byte of page 0 is still page 0.
15247 assert!(index_touches_page_zero(&[(511, 200)], PAGE));
15248 assert!(!index_touches_page_zero(&[(512, 200)], PAGE));
15249 // A zero-length span occupies nothing, in page 0 as anywhere else.
15250 assert!(!index_touches_page_zero(&[(0, 0)], PAGE));
15251 // Nothing to screen.
15252 assert!(!index_touches_page_zero(&[], PAGE));
15253 // A page size a paged file never has cannot place anything.
15254 assert!(index_touches_page_zero(&[(1024, 200)], 0));
15255 }
15256
15257 /// A page every byte of which is free or dead belongs to no page type, so it
15258 /// is promoted whole into the raw list — the free-page convention the rest of
15259 /// the paged allocator already follows. The partial edges keep their type,
15260 /// since the pages they sit in may still hold something live.
15261 #[test]
15262 fn a_page_that_is_wholly_free_or_dead_is_promoted_whole() {
15263 const PAGE: u64 = 4096;
15264 let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15265 // Page 1 is half free metadata and half dead, so neither list can show it
15266 // empty on its own.
15267 meta.free(PAGE, 2048);
15268 dead.free(PAGE + 2048, 2048);
15269 // Page 2 is wholly dead.
15270 dead.free(2 * PAGE, PAGE);
15271 // Page 3 keeps something live at each end, so only its middle is free —
15272 // and not adjacent to page 2, so the promotion is visible on its own.
15273 raw.free(3 * PAGE + 1024, 1024);
15274 PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15275
15276 assert_eq!(
15277 raw.sections(),
15278 [(PAGE, 2 * PAGE), (3 * PAGE + 1024, 1024)],
15279 "the two empty pages join the raw list as one run; the partial page stays"
15280 );
15281 assert!(
15282 meta.sections().is_empty(),
15283 "the promoted page left the metadata list"
15284 );
15285 assert!(
15286 dead.sections().is_empty(),
15287 "every dead byte was inside a promoted page"
15288 );
15289 }
15290
15291 /// Dead space that does not complete a page stays dead: it is not free space,
15292 /// and handing it out as either page type would mix the page it sits in.
15293 #[test]
15294 fn dead_space_short_of_a_whole_page_is_not_promoted() {
15295 const PAGE: u64 = 4096;
15296 let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15297 dead.free(PAGE, 512);
15298 raw.free(PAGE + 512, 1024);
15299 PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15300 assert_eq!(dead.sections(), [(PAGE, 512)]);
15301 assert_eq!(raw.sections(), [(PAGE + 512, 1024)]);
15302 }
15303
15304 /// The shared-message flag is read from the message header's fourth byte, and
15305 /// the two attribute paths have to read the same one: the compact pass
15306 /// refuses such a message as it copies, the dense pass before it reads a set
15307 /// that would resolve it into something indistinguishable from a private
15308 /// attribute. A wrong offset here reads a *size* byte and refuses or accepts
15309 /// by accident.
15310 #[test]
15311 fn a_shared_attribute_message_is_told_from_a_private_one_by_its_flags() {
15312 let body = crate::type_builders::build_attr_message("a", &AttrValue::I64(1))
15313 .serialize(LENGTH_SIZE);
15314 let private = message_record(MessageType::Attribute, &body);
15315 assert!(!region_has_shared_attr(&plain_region(private.clone())).unwrap());
15316
15317 let mut shared = private.clone();
15318 shared[3] = 0x02; // H5O_MSG_FLAG_SHARED
15319 assert!(region_has_shared_attr(&plain_region(shared)).unwrap());
15320
15321 // The flag is only read on an Attribute message: the same byte set on a
15322 // neighbouring message says nothing about attribute storage.
15323 let mut other = message_record(MessageType::Dataspace, &body);
15324 other[3] = 0x02;
15325 assert!(!region_has_shared_attr(&plain_region(other)).unwrap());
15326 }
15327
15328 /// An object-reference attribute named `name` pointing at `address`.
15329 ///
15330 /// Built by taking a `u64` attribute — whose value is already the 8 little-
15331 /// endian bytes an object reference is stored as — and relabelling its
15332 /// datatype, because no public API stages one: [`AttrValue`] has no
15333 /// reference variant, so a file carrying such an attribute was written by
15334 /// the reference C library, and the copy path re-emits its bytes verbatim
15335 /// like any other attribute's.
15336 fn reference_attr(name: &str, address: u64) -> crate::attribute::AttributeMessage {
15337 let mut attr = crate::type_builders::build_attr_message(name, &AttrValue::U64(address));
15338 attr.datatype = Datatype::Reference {
15339 size: 8,
15340 ref_type: crate::datatype::ReferenceType::Object,
15341 };
15342 assert_eq!(
15343 attr.raw_data,
15344 address.to_le_bytes(),
15345 "the value is the address"
15346 );
15347 attr
15348 }
15349
15350 /// Wrap a message body in the object-header record a header region holds it
15351 /// in: type, body size, flags, body.
15352 fn message_record(msg_type: MessageType, body: &[u8]) -> Vec<u8> {
15353 OhRecordLayout::PLAIN.record(msg_type, body)
15354 }
15355
15356 /// A region of plain (4-byte-record) messages, the layout every writer in
15357 /// this crate emits.
15358 fn plain_region(bytes: Vec<u8>) -> OhRegion {
15359 OhRegion::new(bytes, OhHeaderProps::PLAIN)
15360 }
15361
15362 /// A header region holding one attribute inline.
15363 fn inline_attr_region(attr: &crate::attribute::AttributeMessage) -> OhRegion {
15364 plain_region(message_record(
15365 MessageType::Attribute,
15366 &attr.serialize_v3(LENGTH_SIZE),
15367 ))
15368 }
15369
15370 /// The header region of a one-element *compact* object-reference dataset:
15371 /// its datatype, and a version 3 compact data-layout message carrying the
15372 /// element inline (version, class 0, a 2-byte inline size, then the data).
15373 fn compact_reference_region(address: u64) -> OhRegion {
15374 let mut region = message_record(
15375 MessageType::Datatype,
15376 &crate::type_builders::make_object_reference_type().serialize(),
15377 );
15378 let mut layout = vec![3u8, 0];
15379 layout.extend_from_slice(&8u16.to_le_bytes());
15380 layout.extend_from_slice(&address.to_le_bytes());
15381 region.extend_from_slice(&message_record(MessageType::DataLayout, &layout));
15382 plain_region(region)
15383 }
15384
15385 /// A *compact* dataset keeps its elements inside the data-layout message
15386 /// rather than in a data block, so the copy screen reads them straight out
15387 /// of the header region (issue #317).
15388 ///
15389 /// Driven from a hand-built region because no writer in this crate emits a
15390 /// compact layout — the files that carry one come from the reference C
15391 /// library, which is also why the userblock suite notes that its
15392 /// compact-layout fixtures have to come from there.
15393 #[test]
15394 fn a_copied_compact_reference_dataset_is_screened() {
15395 let empty = BytesSource::new(Vec::new());
15396 let invalidated = InvalidatedAddresses {
15397 removed: vec![(248, 71)],
15398 moved: Vec::new(),
15399 base: BaseAddress::ZERO,
15400 };
15401 for (address, refused) in [(248u64, true), (318, true), (319, false)] {
15402 let tree = CopyTree::DatasetVerbatim {
15403 region: compact_reference_region(address),
15404 dense_attrs: DenseAttrSet::default(),
15405 };
15406 let got = screen_copied_references(&tree, &invalidated, &empty);
15407 assert_eq!(got.is_err(), refused, "compact element {address}: {got:?}");
15408 }
15409
15410 // A region whose layout message yields no inline data leaves a reference
15411 // datatype unscreened, so it is refused rather than waved through.
15412 // `read_object` builds this variant only from a compact layout, so this
15413 // is a header that did not parse as one — malformed input, not a shape
15414 // the copy path produces.
15415 let no_layout = CopyTree::DatasetVerbatim {
15416 region: plain_region(message_record(
15417 MessageType::Datatype,
15418 &crate::type_builders::make_object_reference_type().serialize(),
15419 )),
15420 dense_attrs: DenseAttrSet::default(),
15421 };
15422 let err = screen_copied_references(&no_layout, &invalidated, &empty).unwrap_err();
15423 assert!(
15424 err.to_string().contains("could not be read to screen"),
15425 "got: {err}"
15426 );
15427 }
15428
15429 /// A datatype that declares an object reference the element bytes have no
15430 /// room for cannot be walked, so its addresses cannot be screened — and a
15431 /// commit that reclaims space refuses it rather than writing references past
15432 /// a screen that could not read them (issue #317).
15433 ///
15434 /// Reachable only from a malformed source header: every datatype this crate
15435 /// builds sizes its element to its members. Driven directly for that reason.
15436 #[test]
15437 fn a_datatype_whose_reference_slots_do_not_fit_is_refused() {
15438 use crate::datatype::{CompoundMember, ReferenceType};
15439 // An 8-byte compound declaring an 8-byte reference at offset 4: the slot
15440 // runs four bytes past the element.
15441 let dt = Datatype::Compound {
15442 size: 8,
15443 members: vec![CompoundMember {
15444 name: "r".to_string(),
15445 byte_offset: 4,
15446 datatype: Datatype::Reference {
15447 size: 8,
15448 ref_type: ReferenceType::Object,
15449 },
15450 }],
15451 };
15452 assert!(
15453 embedded_reference_slots(&dt).is_none(),
15454 "the fixture must be one the walker cannot map"
15455 );
15456
15457 let raw = [0u8; 8];
15458 let invalidated = InvalidatedAddresses {
15459 removed: vec![(248, 71)],
15460 moved: Vec::new(),
15461 base: BaseAddress::ZERO,
15462 };
15463 let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15464 assert!(
15465 err.to_string().contains("this screen cannot read"),
15466 "got: {err}"
15467 );
15468
15469 // Unconditional: a screen holding nothing still refuses, because the
15470 // refusal is about addresses that cannot be read rather than about what
15471 // the commit is vacating. `moved` is never empty in a real commit, so
15472 // there is no case this could have been made conditional on.
15473 let nothing = InvalidatedAddresses {
15474 removed: Vec::new(),
15475 moved: Vec::new(),
15476 base: BaseAddress::ZERO,
15477 };
15478 assert!(screen_resolved_references(&dt, &raw, ¬hing).is_err());
15479 }
15480
15481 /// A variable-length *of object references* is refused rather than skipped:
15482 /// its addresses live in the global heap the elements point at, not in the
15483 /// element bytes this screen reads (issue #317).
15484 ///
15485 /// The reference C library writes such a datatype (`H5T_VLEN` of
15486 /// `H5T_STD_REF_OBJ`) and an in-file copy carries it, so it reaches the
15487 /// screen through `copy` even though nothing here builds one. A
15488 /// variable-length *string* is unaffected — the heap it points at is never
15489 /// reclaimed by a delete, so its elements stay valid.
15490 #[test]
15491 fn a_variable_length_of_object_references_is_refused() {
15492 use crate::datatype::{CharacterSet, ReferenceType};
15493 let of_references = Datatype::VariableLength {
15494 is_string: false,
15495 padding: None,
15496 charset: None,
15497 base_type: Box::new(Datatype::Reference {
15498 size: 8,
15499 ref_type: ReferenceType::Object,
15500 }),
15501 };
15502 let of_strings = crate::type_builders::make_vlen_string_type(CharacterSet::Utf8);
15503 let raw = vec![0u8; 32];
15504 let invalidated = InvalidatedAddresses {
15505 removed: vec![(248, 71)],
15506 moved: Vec::new(),
15507 base: BaseAddress::ZERO,
15508 };
15509
15510 let err = screen_resolved_references(&of_references, &raw, &invalidated).unwrap_err();
15511 assert!(
15512 err.to_string().contains("this screen cannot read"),
15513 "got: {err}"
15514 );
15515 assert!(
15516 screen_resolved_references(&of_strings, &raw, &invalidated).is_ok(),
15517 "a variable-length string points at a heap no delete reclaims"
15518 );
15519 }
15520
15521 /// An object reference wider than the 8 bytes the slot walker maps is
15522 /// refused, not skipped (issue #317).
15523 ///
15524 /// [`datatype_holds_object_address`] answers for an object reference of
15525 /// *any* width, while [`embedded_reference_slots`] locates only the 8-byte
15526 /// one — so the walker reports a width it cannot address as unwalkable
15527 /// rather than as "no slots here", which a screen would read as "nothing to
15528 /// check". `Dataset::dereference` reads such an element, taking the address
15529 /// from its first eight bytes, so the reference is live.
15530 #[test]
15531 fn an_object_reference_wider_than_eight_bytes_is_refused() {
15532 use crate::datatype::ReferenceType;
15533 let dt = Datatype::Reference {
15534 size: 16,
15535 ref_type: ReferenceType::Object,
15536 };
15537 assert!(
15538 embedded_reference_slots(&dt).is_none(),
15539 "the walker must report a width it cannot map, not an empty slot list"
15540 );
15541
15542 let mut raw = vec![0u8; 16];
15543 raw[..8].copy_from_slice(&300u64.to_le_bytes());
15544 let invalidated = InvalidatedAddresses {
15545 removed: vec![(248, 71)],
15546 moved: Vec::new(),
15547 base: BaseAddress::ZERO,
15548 };
15549 let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15550 assert!(
15551 err.to_string().contains("this screen cannot read"),
15552 "got: {err}"
15553 );
15554
15555 // Unconditional, for the reason
15556 // `a_datatype_whose_reference_slots_do_not_fit_is_refused` states.
15557 let nothing = InvalidatedAddresses {
15558 removed: Vec::new(),
15559 moved: Vec::new(),
15560 base: BaseAddress::ZERO,
15561 };
15562 assert!(screen_resolved_references(&dt, &raw, ¬hing).is_err());
15563 }
15564
15565 /// A reference target supplied as a *raw address* is screened exactly as one
15566 /// supplied as a path (issue #317), and the address it would have resolved
15567 /// to is returned unchanged when it names space this commit keeps.
15568 ///
15569 /// Driven directly because nothing stages a `Raw` target carrying a real
15570 /// address: a builder reachable from a session produces only
15571 /// [`ObjectRefTarget::Path`], and [`crate::repack`]'s faithful re-emit
15572 /// resolves every real target to a `Path`, leaving `Raw` for the null and
15573 /// undefined references alone. The arm exists so that the two ways a target
15574 /// can name an object answer to the same rule rather than to whichever one
15575 /// a caller happened to use, and this is what holds it to that.
15576 /// A file with nothing a reference could live in is walked once and never
15577 /// again, and one that holds a reference is walked every time.
15578 ///
15579 /// The first half is the whole of `proved_free_of_references`, and it is
15580 /// invisible from outside the session: a build that never caches the proof
15581 /// writes byte-identical files and passes every other test here, having
15582 /// silently gone back to walking the file on every commit. The second half
15583 /// is what stops a too-eager proof from being the fix for the first.
15584 #[test]
15585 fn a_file_proved_free_of_references_is_walked_once_and_no_more() {
15586 use crate::reference_patch::{reset_walks, walks};
15587 use tempfile::tempdir;
15588 let dir = tempdir().unwrap();
15589
15590 let commit_three = |path: &std::path::Path| {
15591 let session = crate::File::open_rw(path).unwrap();
15592 for i in 0..3 {
15593 session
15594 .root()
15595 .create_dataset(&format!("added{i}"), |b| {
15596 b.with_i32_data(&[i]);
15597 })
15598 .unwrap();
15599 session.commit().unwrap();
15600 }
15601 };
15602
15603 let plain = dir.path().join("plain.h5");
15604 let mut b = crate::writer::FileBuilder::new();
15605 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15606 b.write(&plain).unwrap();
15607 reset_walks();
15608 commit_three(&plain);
15609 assert_eq!(
15610 walks(),
15611 1,
15612 "the first commit's walk proves the file reference-free; the rest \
15613 must take its word for it"
15614 );
15615
15616 let referencing = dir.path().join("referencing.h5");
15617 let mut b = crate::writer::FileBuilder::new();
15618 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15619 b.create_dataset("refs").with_path_references(&["d"]);
15620 b.write(&referencing).unwrap();
15621 reset_walks();
15622 commit_three(&referencing);
15623 assert_eq!(
15624 walks(),
15625 3,
15626 "a file that holds a reference is never proved free of one, so every \
15627 commit walks it"
15628 );
15629 }
15630
15631 #[test]
15632 fn a_raw_reference_target_is_screened_like_a_path_one() {
15633 use tempfile::tempdir;
15634 let dir = tempdir().unwrap();
15635 let path = dir.path().join("raw_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<PathKey, Node> = BTreeMap::new();
15642 let path_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
15643 let resolve = |address: u64, removed: Vec<(u64, u64)>, moved: Vec<u64>| {
15644 WriteEngine::resolve_reference_target(
15645 &ObjectRefTarget::Raw(address),
15646 &path_addr,
15647 &nodes,
15648 &[],
15649 &[],
15650 &[],
15651 &InvalidatedAddresses {
15652 removed,
15653 moved,
15654 base: BaseAddress::ZERO,
15655 },
15656 &engine.image(),
15657 engine.superblock(),
15658 )
15659 };
15660
15661 assert!(
15662 resolve(300, vec![(248, 71)], Vec::new()).is_err(),
15663 "an address inside a reclaimed span is refused"
15664 );
15665 assert!(
15666 resolve(300, Vec::new(), vec![300]).is_err(),
15667 "an address this commit rewrites elsewhere is refused"
15668 );
15669 assert_eq!(
15670 resolve(300, vec![(400, 71)], vec![299, 301]).unwrap(),
15671 300,
15672 "an address outside every reclaimed span and every moved header is carried through"
15673 );
15674 assert_eq!(
15675 resolve(300, Vec::new(), Vec::new()).unwrap(),
15676 300,
15677 "a commit that reclaims nothing screens nothing"
15678 );
15679 // The two sentinels name no object, so they are carried through even
15680 // when they fall inside a reclaimed span.
15681 assert_eq!(resolve(0, vec![(0, 4096)], vec![0]).unwrap(), 0);
15682 assert_eq!(
15683 resolve(UNDEF, vec![(0, u64::MAX)], vec![UNDEF]).unwrap(),
15684 UNDEF
15685 );
15686 }
15687
15688 /// A copied object's *attributes* are screened against the space the commit
15689 /// reclaims, in either storage an object can hold them in — inline in the
15690 /// header region, or in the fractal heap a dense object uses — and an
15691 /// address outside those spans passes in both (issue #317).
15692 #[test]
15693 fn a_copied_reference_attribute_is_screened_in_both_storages() {
15694 let empty = BytesSource::new(Vec::new());
15695 let invalidated = InvalidatedAddresses {
15696 removed: vec![(248, 71)],
15697 moved: Vec::new(),
15698 base: BaseAddress::ZERO,
15699 };
15700 // 248 is the first byte of the reclaimed span, 318 its last, 319 the
15701 // byte after it.
15702 for (address, refused) in [(248u64, true), (318, true), (319, false), (247, false)] {
15703 let attr = reference_attr("target", address);
15704 let dense = CopyTree::DatasetVerbatim {
15705 region: OhRegion::default(),
15706 dense_attrs: DenseAttrSet {
15707 attrs: vec![attr.clone()],
15708 creation: DenseAttrCreationOrder::Untracked,
15709 },
15710 };
15711 let inline = CopyTree::DatasetVerbatim {
15712 region: inline_attr_region(&attr),
15713 dense_attrs: DenseAttrSet::default(),
15714 };
15715 for (storage, tree) in [("dense", &dense), ("inline", &inline)] {
15716 let got = screen_copied_references(tree, &invalidated, &empty);
15717 assert_eq!(
15718 got.is_err(),
15719 refused,
15720 "{storage} attribute at {address}: {got:?}"
15721 );
15722 }
15723 }
15724 }
15725
15726 /// Collect the message types present in a chunk-0 region, in order.
15727 fn region_types(region: &OhRegion) -> Vec<MessageType> {
15728 let mut out = Vec::new();
15729 let mut p = 0;
15730 while let Some((mt, _, end)) = region.next_message(p).unwrap() {
15731 out.push(mt);
15732 p = end;
15733 }
15734 out
15735 }
15736
15737 /// Stopping an in-place append (`append_inplace`) at any phase boundary must
15738 /// leave the file readable as a consistent prefix — the old length until the
15739 /// phase-4 dimension commit, the new length after it — even though a
15740 /// partial-tail append repoints the visible trailing element in place. Mirrors
15741 /// `Dataset::append`'s crash-consistency harness, but driven through
15742 /// the in-place edit engine's own mirror (disk-before-mirror ordering) to prove the shared
15743 /// engine is crash-safe under both owners. Three starting layouts: the trailing
15744 /// element inline in the index block (chunk 4, n 6), in a data block
15745 /// (chunk 2, n 9, slot 0), and a *filtered* inline one (issue #393), whose
15746 /// repointed element is the three-field record rather than a bare address and
15747 /// whose relocated chunk has to decode to the old prefix at every phase
15748 /// before the dimension moves.
15749 #[test]
15750 fn append_inplace_crash_consistency_partial_tail_prefix() {
15751 use crate::reader::File as PureFile;
15752 use crate::writer::FileBuilder;
15753 use tempfile::tempdir;
15754
15755 let build = |path: &std::path::Path, n: i32, chunk: u64, deflate: bool| {
15756 let data: Vec<i32> = (0..n).collect();
15757 let mut b = FileBuilder::new();
15758 let d = b
15759 .create_dataset("d")
15760 .with_i32_data(&data)
15761 .with_shape(&[n as u64])
15762 .with_maxshape(&[u64::MAX])
15763 .with_chunks(&[chunk]);
15764 if deflate {
15765 d.with_deflate(6);
15766 }
15767 b.write(path).unwrap();
15768 };
15769
15770 for (n, chunk, add, deflate) in [
15771 (6i32, 4u64, 5i32, false),
15772 (9, 2, 6, false),
15773 (6, 4, 5, true),
15774 (9, 4, 3, true),
15775 ] {
15776 let dir = tempdir().unwrap();
15777 let base = dir.path().join("base.h5");
15778 build(&base, n, chunk, deflate);
15779
15780 for max_phase in 1u8..=4 {
15781 let p = dir
15782 .path()
15783 .join(format!("crash_{n}_{chunk}_{deflate}_{max_phase}.h5"));
15784 std::fs::copy(&base, &p).unwrap();
15785 {
15786 let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
15787 s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
15788 .unwrap();
15789 // session dropped here, simulating a crash after `max_phase`
15790 }
15791 let expected_len = if max_phase == 4 { n + add } else { n };
15792 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
15793 assert_eq!(
15794 f.dataset("d").unwrap().read_i32().unwrap(),
15795 (0..expected_len).collect::<Vec<_>>(),
15796 "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
15797 deflate={deflate})"
15798 );
15799 }
15800 }
15801 }
15802
15803 /// Growing a partial trailing chunk decodes and re-encodes elements that are
15804 /// already committed, so a **lossy** pipeline is refused on the immediate
15805 /// in-place path, with the file left exactly as it was.
15806 ///
15807 /// The values are the point rather than the error: measured on this fixture
15808 /// before the refusal existed, the committed pair `[4.5, 5.5]` read back as
15809 /// `[16.0, 0.0]` once two more elements joined their ZFP block. That is
15810 /// quantization following the block's new contents, not a decode that lost
15811 /// its place — a *fresh* chunk of `[99.0, 98.0, 97.0]` at this rate reads
15812 /// back as `[98.0, 102.0, 90.0]` with nothing decoded anywhere, which is what
15813 /// eight bits per value buys and is not this test's business.
15814 #[cfg(feature = "zfp")]
15815 #[test]
15816 fn append_onto_a_lossy_partial_tail_is_refused() {
15817 use crate::reader::File as PureFile;
15818 use crate::writer::FileBuilder;
15819 use tempfile::tempdir;
15820
15821 let dir = tempdir().unwrap();
15822 let committed: Vec<f64> = vec![0.5, 1.5, 2.5, 3.5, 4.5, 5.5];
15823
15824 // `d` is 6 of a chunk of 4: a partial trailing chunk holding 4.5 and 5.5.
15825 let build = |path: &std::path::Path| {
15826 let mut b = FileBuilder::new();
15827 b.create_dataset("d")
15828 .with_f64_data(&committed)
15829 .with_shape(&[6])
15830 .with_maxshape(&[u64::MAX])
15831 .with_chunks(&[4])
15832 .with_zfp(8.0);
15833 b.write(path).unwrap();
15834 };
15835
15836 // The immediate in-place path.
15837 let p = dir.path().join("inplace.h5");
15838 build(&p);
15839 let before = std::fs::read(&p).unwrap();
15840 {
15841 let f = crate::reader::File::open_rw(&p).unwrap();
15842 let err = f
15843 .dataset("d")
15844 .unwrap()
15845 .append(&[99.0f64, 98.0, 97.0])
15846 .expect_err("a lossy pipeline must not have its trailing chunk re-encoded");
15847 assert!(
15848 matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15849 "got: {err:?}"
15850 );
15851 }
15852 assert_eq!(
15853 std::fs::read(&p).unwrap(),
15854 before,
15855 "the refusal wrote bytes"
15856 );
15857 assert_eq!(
15858 PureFile::open(&p)
15859 .unwrap()
15860 .dataset("d")
15861 .unwrap()
15862 .read_f64()
15863 .unwrap(),
15864 committed
15865 );
15866
15867 // A chunk-aligned start needs no rewrite, so it is still accepted and the
15868 // committed values are still exactly what they were.
15869 let p = dir.path().join("aligned.h5");
15870 {
15871 let mut b = FileBuilder::new();
15872 b.create_dataset("d")
15873 .with_f64_data(&committed[..4])
15874 .with_shape(&[4])
15875 .with_maxshape(&[u64::MAX])
15876 .with_chunks(&[4])
15877 .with_zfp(8.0);
15878 b.write(&p).unwrap();
15879 }
15880 {
15881 let f = crate::reader::File::open_rw(&p).unwrap();
15882 f.dataset("d").unwrap().append(&[9.5f64, 8.5, 7.5]).unwrap();
15883 }
15884 let back = PureFile::open(&p)
15885 .unwrap()
15886 .dataset("d")
15887 .unwrap()
15888 .read_f64()
15889 .unwrap();
15890 assert_eq!(back[..4], committed[..4], "an untouched chunk changed");
15891
15892 // The staged path rebuilds the index at commit and rewrote the same chunk
15893 // on the way; it refuses at the call that stages the append, so nothing is
15894 // staged and the file is left alone.
15895 let p = dir.path().join("staged.h5");
15896 build(&p);
15897 let before = std::fs::read(&p).unwrap();
15898 {
15899 let f = crate::reader::File::open_rw(&p).unwrap();
15900 let err = f
15901 .dataset("d")
15902 .unwrap()
15903 .append_staged(|b| {
15904 b.append_f64(&[99.0, 98.0, 97.0]);
15905 })
15906 .expect_err("a staged append must not re-encode the committed tail either");
15907 assert!(
15908 matches!(&err, Error::AppendUnsupported(m) if m.contains("lossy")),
15909 "got: {err:?}"
15910 );
15911 f.close().unwrap();
15912 }
15913 assert_eq!(
15914 std::fs::read(&p).unwrap(),
15915 before,
15916 "the refusal wrote bytes"
15917 );
15918
15919 // A buffered appender over the unaligned shape is refused when it is
15920 // made, since its very first write would be the rewrite above.
15921 let p = dir.path().join("buffered.h5");
15922 build(&p);
15923 {
15924 let f = crate::reader::File::open_rw(&p).unwrap();
15925 let mut ds = f.dataset("d").unwrap();
15926 let err = ds
15927 .buffered_appender()
15928 .expect_err("its first write would re-encode the committed tail");
15929 assert!(
15930 matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15931 "got: {err:?}"
15932 );
15933 }
15934 }
15935
15936 /// Float D-scale scale-offset is the other lossy mode, and is refused for the
15937 /// same reason — while the **integer** mode, which is lossless, grows its
15938 /// partial trailing chunk with every value intact. Both halves matter: a
15939 /// predicate that refused all of scale-offset would pass the first assertion
15940 /// and take a working case away.
15941 #[test]
15942 fn a_lossy_scale_offset_mode_is_refused_and_a_lossless_one_is_not() {
15943 use crate::reader::File as PureFile;
15944 use crate::scaleoffset::ScaleOffset;
15945 use crate::writer::FileBuilder;
15946 use tempfile::tempdir;
15947
15948 let dir = tempdir().unwrap();
15949
15950 let p = dir.path().join("dscale.h5");
15951 {
15952 let mut b = FileBuilder::new();
15953 b.create_dataset("d")
15954 .with_f64_data(&[0.5, 1.5, 2.5, 3.5, 4.5, 5.5])
15955 .with_shape(&[6])
15956 .with_maxshape(&[u64::MAX])
15957 .with_chunks(&[4])
15958 .with_scale_offset(ScaleOffset::FloatDScale(1));
15959 b.write(&p).unwrap();
15960 }
15961 {
15962 let f = crate::reader::File::open_rw(&p).unwrap();
15963 let err = f
15964 .dataset("d")
15965 .unwrap()
15966 .append(&[99.0f64, 98.0, 97.0])
15967 .expect_err("float D-scale is lossy");
15968 assert!(
15969 matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15970 "got: {err:?}"
15971 );
15972 }
15973
15974 let p = dir.path().join("int.h5");
15975 {
15976 let mut b = FileBuilder::new();
15977 b.create_dataset("d")
15978 .with_i32_data(&(0..6).collect::<Vec<_>>())
15979 .with_shape(&[6])
15980 .with_maxshape(&[u64::MAX])
15981 .with_chunks(&[4])
15982 .with_scale_offset(ScaleOffset::Integer(0));
15983 b.write(&p).unwrap();
15984 }
15985 {
15986 let f = crate::reader::File::open_rw(&p).unwrap();
15987 let mut ds = f.dataset("d").unwrap();
15988 ds.append(&[6i32, 7, 8]).unwrap();
15989 ds.append(&[9i32]).unwrap();
15990 }
15991 assert_eq!(
15992 PureFile::open(&p)
15993 .unwrap()
15994 .dataset("d")
15995 .unwrap()
15996 .read_i32()
15997 .unwrap(),
15998 (0..10).collect::<Vec<_>>()
15999 );
16000 }
16001
16002 /// A *filtered* append whose length is not a whole number of chunks writes a
16003 /// partial last chunk. That chunk's index element is a fresh insert past the
16004 /// old dimension — the same position every whole chunk beside it occupies —
16005 /// so stopping anywhere in the durability sequence must still read back as
16006 /// the old prefix, exactly as an aligned filtered append does. This is the
16007 /// case the in-place refusal used to cover and no longer does; without the
16008 /// phase sweep, "it reads back fine" would only be testing phase 4.
16009 #[test]
16010 fn append_inplace_crash_consistency_filtered_partial_last_chunk() {
16011 use crate::reader::File as PureFile;
16012 use crate::writer::FileBuilder;
16013 use tempfile::tempdir;
16014
16015 let build = |path: &std::path::Path, n: i32, chunk: u64| {
16016 let data: Vec<i32> = (0..n).collect();
16017 let mut b = FileBuilder::new();
16018 b.create_dataset("d")
16019 .with_i32_data(&data)
16020 .with_shape(&[n as u64])
16021 .with_maxshape(&[u64::MAX])
16022 .with_chunks(&[chunk])
16023 .with_shuffle()
16024 .with_deflate(4);
16025 b.write(path).unwrap();
16026 };
16027
16028 // Aligned starts, unaligned lengths: one that stays inside a single new
16029 // chunk, and one that spans several and ends partway through the last.
16030 // (An unaligned *start* is the sibling sweep,
16031 // `append_inplace_crash_consistency_partial_tail_prefix`.)
16032 for (n, chunk, add) in [(8i32, 4u64, 2i32), (8, 4, 9), (0, 4, 3)] {
16033 let dir = tempdir().unwrap();
16034 let base = dir.path().join("base.h5");
16035 build(&base, n, chunk);
16036
16037 for max_phase in 1u8..=4 {
16038 let p = dir
16039 .path()
16040 .join(format!("crash_f_{n}_{chunk}_{max_phase}.h5"));
16041 std::fs::copy(&base, &p).unwrap();
16042 {
16043 let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
16044 s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
16045 .unwrap();
16046 // session dropped here, simulating a crash after `max_phase`
16047 }
16048 let expected_len = if max_phase == 4 { n + add } else { n };
16049 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16050 assert_eq!(
16051 f.dataset("d").unwrap().read_i32().unwrap(),
16052 (0..expected_len).collect::<Vec<_>>(),
16053 "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
16054 add={add})"
16055 );
16056 }
16057 }
16058 }
16059
16060 /// Build a one-element-per-chunk unlimited `d` holding `0..n`, the shape the
16061 /// crash-consistency harnesses below grow.
16062 fn build_unit_chunked(path: &std::path::Path, n: i32) {
16063 use crate::writer::FileBuilder;
16064 let data: Vec<i32> = (0..n).collect();
16065 let mut b = FileBuilder::new();
16066 b.create_dataset("d")
16067 .with_i32_data(&data)
16068 .with_shape(&[n as u64])
16069 .with_maxshape(&[u64::MAX])
16070 .with_chunks(&[1]);
16071 b.write(path).unwrap();
16072 }
16073
16074 /// Stop an append after `max_phase` durability phases and hand back the
16075 /// resulting file. Dropping the engine inside is the simulated crash: no
16076 /// further phases run and no close barrier is written.
16077 fn append_stopped_at(
16078 base: &std::path::Path,
16079 out: &std::path::Path,
16080 values: std::ops::Range<i32>,
16081 max_phase: u8,
16082 ) {
16083 std::fs::copy(base, out).unwrap();
16084 let mut s = WriteEngine::open_with_locking(out, FileLocking::Enabled).unwrap();
16085 s.append_inplace_i32_phased("d", &values.collect::<Vec<_>>(), max_phase)
16086 .unwrap();
16087 }
16088
16089 /// Growing an Extensible-Array index across its inline -> direct-block ->
16090 /// super-block boundaries touches far more index structure than the
16091 /// partial-tail case above. Stopping at any phase boundary must still read
16092 /// back as a consistent prefix.
16093 ///
16094 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
16095 /// owned path drives the same `apply_ea_append` engine.
16096 #[test]
16097 fn append_inplace_crash_consistency_across_ea_boundaries() {
16098 use crate::reader::File as PureFile;
16099 use tempfile::tempdir;
16100
16101 let dir = tempdir().unwrap();
16102 let base = dir.path().join("base.h5");
16103 let (n, target) = (50i32, 250i32);
16104 build_unit_chunked(&base, n);
16105
16106 for max_phase in 1u8..=4 {
16107 let p = dir.path().join(format!("crash_ea_{max_phase}.h5"));
16108 append_stopped_at(&base, &p, n..target, max_phase);
16109 let expected_len = if max_phase == 4 { target } else { n };
16110 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16111 assert_eq!(
16112 f.dataset("d").unwrap().read_i32().unwrap(),
16113 (0..expected_len).collect::<Vec<_>>(),
16114 "inconsistent view after crash at phase {max_phase}"
16115 );
16116 }
16117 }
16118
16119 /// The same guarantee for an append that crosses the paged-data-block
16120 /// boundary (~131,060 chunks), where phase 1 allocates a paged super block,
16121 /// paged data blocks, the per-page checksums, and the page-init bitmap. This
16122 /// is the most intricate in-place growth the engine performs, and truncating
16123 /// it partway is exactly what a power loss does.
16124 ///
16125 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202).
16126 /// Opening a paged persisting file must seed each free section into the list
16127 /// for the page type its *slot* names, not one derived from its size.
16128 ///
16129 /// The managers a paged file uses mean different things — SUPER (slot 0) is
16130 /// metadata, DRAW (slot 2) is raw — and a freed section's size says nothing
16131 /// about which it came from. Getting this wrong is invisible from the outside:
16132 /// the total free space is unchanged, the reference library still opens the
16133 /// file, and only a later allocation drawn from the wrong list would mix a
16134 /// page. So assert the routing directly.
16135 #[test]
16136 fn paged_open_seeds_each_manager_by_slot() {
16137 use crate::writer::FileBuilder;
16138 use tempfile::tempdir;
16139
16140 let dir = tempdir().unwrap();
16141 let path = dir.path().join("paged_seed.h5");
16142 let mut b = FileBuilder::new();
16143 b.create_dataset("d")
16144 .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16145 .with_shape(&[1000]);
16146 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16147 .with_file_space_page_size(4096);
16148 b.write(&path).unwrap();
16149
16150 // Read the file's recorded free space *before* opening the session: the
16151 // session holds an exclusive OS lock, and on Windows those locks are
16152 // mandatory, so a concurrent `File::open` would fail outright.
16153 let on_disk: u64 = crate::reader::File::open(&path)
16154 .unwrap()
16155 .persisted_free_space()
16156 .iter()
16157 .map(|&(_, l)| l)
16158 .sum();
16159
16160 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16161 let pg = s.paged.as_ref().expect("a paged file installs paged state");
16162 assert_eq!(pg.page_size, 4096);
16163
16164 // The from-scratch writer leaves a page tail free in both the metadata and
16165 // the raw pages, so both per-type managers are populated. If every slot
16166 // were funnelled into one list, one of these would be empty.
16167 assert!(
16168 !pg.meta.sections().is_empty(),
16169 "SUPER (slot 0) sections seed the metadata list"
16170 );
16171 assert!(
16172 !pg.raw.sections().is_empty(),
16173 "DRAW (slot 2) sections seed the raw list, not the metadata list"
16174 );
16175
16176 // Nothing is double-counted or dropped: the lists partition exactly the
16177 // free space the file records, and no two sections overlap. A file this
16178 // crate wrote has nothing unclassified — it only ever files a whole
16179 // aligned page under the generic-large manager — so summing it in is a
16180 // statement about that too.
16181 let mut all = pg.reusable_sections();
16182 all.extend(pg.unclassified.sections());
16183 assert!(
16184 pg.unclassified.sections().is_empty(),
16185 "our own paged writer files nothing whose page type is unknown"
16186 );
16187 let flat: u64 = all.iter().map(|&(_, l)| l).sum();
16188 assert_eq!(
16189 flat, on_disk,
16190 "the split lists hold exactly the file's free space"
16191 );
16192 all.sort_by_key(|&(a, _)| a);
16193 let mut prev_end = 0u64;
16194 for (addr, len) in all {
16195 assert!(addr >= prev_end, "the per-type lists do not overlap");
16196 prev_end = addr + len;
16197 }
16198 }
16199
16200 /// Deleting a chunked dataset from a paged file the *reference library* wrote
16201 /// must not offer its chunk index for raw reuse.
16202 ///
16203 /// A chunk index is metadata by the format's taxonomy, and the C library
16204 /// allocates one accordingly — out of metadata pages, which on a small file
16205 /// means page 0, alongside the superblock, the root group, and every other
16206 /// object's header. This crate places its own indexes in raw pages beside the
16207 /// chunk data instead, and the reclaim path used to assume that of every file.
16208 /// Freeing a C-written index under that assumption puts its bytes on the raw
16209 /// list, and the next commit writes a dataset's values into the middle of a
16210 /// live metadata page.
16211 ///
16212 /// Both libraries still read such a file — every address in it is still
16213 /// correct — so nothing downstream reports the damage. The assertion has to be
16214 /// about placement itself: no byte this commit writes may land in a page that
16215 /// held live metadata beforehand.
16216 #[test]
16217 // The file is a committed one the reference C library wrote, see
16218 // `crates/crosscheck/tests/c_test_data.rs`.
16219 fn a_c_written_chunk_index_is_not_reclaimed_as_raw() {
16220 use tempfile::tempdir;
16221
16222 const PAGE: u64 = 4096;
16223 let dir = tempdir().unwrap();
16224 let path = crate::test_data::copy("c/paged_index.h5", dir.path());
16225
16226 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16227 let page_size = s.paged.as_ref().expect("a paged file").page_size;
16228 assert_eq!(page_size, PAGE);
16229 // Where the C library put the index: every page it touches is a metadata
16230 // page, since the C library allocates an index as metadata.
16231 let victim_addr =
16232 crate::group_v2::resolve_path_any(s.image.as_slice().unwrap(), &s.superblock, "victim")
16233 .unwrap();
16234 let index_spans = s
16235 .chunked_index_spans(usize::try_from(victim_addr).unwrap())
16236 .expect("the C library's extensible-array index is enumerable");
16237 assert!(!index_spans.is_empty());
16238 // Page 0 always holds the superblock and the root group header; the index
16239 // pages are what this test is really about.
16240 let mut meta_pages: Vec<u64> = vec![0];
16241 for (addr, len) in &index_spans {
16242 for p in (addr / PAGE)..=((addr + len - 1) / PAGE) {
16243 meta_pages.push(p);
16244 }
16245 }
16246 meta_pages.sort_unstable();
16247 meta_pages.dedup();
16248
16249 // Delete the dataset, then write a small dataset that would fit in the
16250 // index's freed bytes.
16251 s.delete("/victim").unwrap();
16252 s.commit().unwrap();
16253 let mut db = crate::type_builders::DatasetBuilder::new("added");
16254 db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
16255 s.stage_created_dataset("/added", db).unwrap();
16256 s.commit().unwrap();
16257
16258 // Release the session's exclusive OS lock before reading the file back;
16259 // those locks are mandatory on Windows.
16260 drop(s);
16261
16262 // Every raw byte of the new dataset must be outside those pages.
16263 let f = crate::reader::File::open(&path).unwrap();
16264 let added = f.dataset("added").unwrap();
16265 let crate::Layout::Contiguous {
16266 address: Some(addr),
16267 size,
16268 } = added.layout().unwrap()
16269 else {
16270 panic!("a small f64 dataset is stored contiguously");
16271 };
16272 for p in (addr / PAGE)..=((addr + size - 1) / PAGE) {
16273 assert!(
16274 !meta_pages.contains(&p),
16275 "the new dataset's raw data landed at ({addr}, {size}), in page {p}, \
16276 which held live metadata before the edit: {meta_pages:?}"
16277 );
16278 }
16279
16280 assert_eq!(added.read_f64().unwrap(), vec![2.5f64; 8]);
16281 assert_eq!(
16282 f.dataset("keep").unwrap().read_i32().unwrap(),
16283 vec![1, 2, 3, 4]
16284 );
16285 }
16286
16287 /// Padding a tail page whose type is unknown must leave the padding
16288 /// untracked, not guess a type for it.
16289 ///
16290 /// A commit pads the file to a page boundary before laying down its manager
16291 /// blocks. When this session has made no typed allocation, the tail page is
16292 /// one a previous session left non-aligned — only a crash does that, since a
16293 /// clean close pads — and nothing says what it holds. Recording the padding
16294 /// under a guess would advertise those bytes for reuse of that type, and half
16295 /// the time they sit in a page of the other one. Under-reporting is the safe
16296 /// direction, and it is the call [`PagedEdit::begin`] already makes for the
16297 /// same situation.
16298 ///
16299 /// Reuse is what made this reachable: before it, every commit appended at
16300 /// least the root group's header, so the tail type was always known by the
16301 /// time the padding ran.
16302 #[test]
16303 fn padding_a_tail_page_of_unknown_type_records_nothing() {
16304 use crate::writer::FileBuilder;
16305 use tempfile::tempdir;
16306
16307 const PAGE: u64 = 4096;
16308 let dir = tempdir().unwrap();
16309
16310 let build = |path: &std::path::Path| {
16311 let mut b = FileBuilder::new();
16312 b.create_dataset("d")
16313 .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16314 .with_shape(&[1000]);
16315 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16316 .with_file_space_page_size(PAGE);
16317 b.write(path).unwrap();
16318 };
16319
16320 // Unknown tail type: pad, but record nothing.
16321 let unknown = dir.path().join("unknown_tail.h5");
16322 build(&unknown);
16323 let mut s = WriteEngine::open_with_locking(&unknown, FileLocking::Enabled).unwrap();
16324 s.append(&[0u8; 100]).unwrap(); // leaves the image non-page-aligned
16325 assert!(s.paged.as_ref().unwrap().last.is_none());
16326 s.pad_to_page().unwrap();
16327 let pg = s.paged.as_ref().unwrap();
16328 assert_eq!(s.image.len() % PAGE, 0, "the file is padded to a page");
16329 assert!(
16330 pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
16331 "padding a tail page of unknown type must claim no page type"
16332 );
16333 drop(s);
16334
16335 // The control: a known tail type is recorded, so the test above is about
16336 // the unknown case and not about padding never being tracked at all.
16337 let known = dir.path().join("known_tail.h5");
16338 build(&known);
16339 let mut s = WriteEngine::open_with_locking(&known, FileLocking::Enabled).unwrap();
16340 s.begin_page(PageType::Meta).unwrap();
16341 s.append(&[0u8; 100]).unwrap();
16342 s.pad_to_page().unwrap();
16343 let pg = s.paged.as_ref().unwrap();
16344 assert_eq!(
16345 pg.meta_pad.len(),
16346 1,
16347 "a known metadata tail records its padding as metadata free space"
16348 );
16349 assert!(pg.raw_pad.is_empty());
16350 }
16351
16352 /// The reference C library's generic-large manager holds free space of *both*
16353 /// page types, so a section from it is only reusable when it covers whole
16354 /// aligned pages.
16355 ///
16356 /// `H5F_MEM_PAGE_GENERIC` is aliased to `H5F_MEM_PAGE_LARGE_SUPER` and
16357 /// commented in the C library's own header as *"large-sized generic: meta and
16358 /// raw"*. Under paged aggregation on a contiguous-address driver — the default
16359 /// — `H5MF__alloc_to_fs_type` sends **every** allocation of a page or more
16360 /// there whatever its type, and `H5MF__alloc_pagefs` then records that
16361 /// allocation's page-alignment tail as a free section in the same manager. The
16362 /// tail is smaller than a page and sits in a page whose earlier bytes are the
16363 /// live object, so filing it as raw and handing it to chunk data would put raw
16364 /// bytes inside a metadata page.
16365 ///
16366 /// This measures that the C library really does write such a section — the
16367 /// assertion is worthless if it does not — and then pins that this engine
16368 /// keeps it out of reach: it is recorded, so a rewrite gives it back to the
16369 /// manager it came from, but never offered to an allocation.
16370 #[test]
16371 // The file is a committed one the reference C library wrote, see
16372 // `crates/crosscheck/tests/c_test_data.rs`.
16373 fn a_generic_large_section_is_only_reusable_as_whole_pages() {
16374 use tempfile::tempdir;
16375
16376 const PAGE: u64 = 512;
16377 let dir = tempdir().unwrap();
16378 let path = crate::test_data::copy("c/generic_large.h5", dir.path());
16379
16380 // The premise: at least one section in the generic-large manager (slot 6)
16381 // is a sub-page fragment. Read the managers slot by slot, since the
16382 // flattened public view cannot say which manager a section came from.
16383 let opened = crate::reader::File::open(&path).unwrap();
16384 let info = opened.file_space_info().expect("a persisting file").clone();
16385 drop(opened);
16386 assert_eq!(info.page_size, PAGE);
16387 let bytes = std::fs::read(&path).unwrap();
16388 let src = crate::source::BytesSource::new(bytes.as_slice());
16389 let slot6 = info.manager_addrs[6];
16390 assert_ne!(slot6, UNDEF, "the C library populated the large manager");
16391 let (sections, _) = free_space_manager::read_persisted_sections_source(
16392 &src,
16393 &[slot6],
16394 BaseAddress::ZERO,
16395 8,
16396 )
16397 .unwrap();
16398 let fragments: Vec<&FreeSection> = sections
16399 .iter()
16400 .filter(|s| s.addr % PAGE != 0 || s.size % PAGE != 0)
16401 .collect();
16402 assert!(
16403 !fragments.is_empty(),
16404 "the premise of this test: the C library files sub-page fragments in \
16405 its generic-large manager, but this file has none ({sections:?})"
16406 );
16407
16408 // The behavior: every such fragment is recorded but unreachable, and only
16409 // whole aligned pages from that manager are placeable.
16410 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16411 let pg = s.paged.as_ref().expect("a paged file installs paged state");
16412 let unclassified = pg.unclassified.sections();
16413 let reusable = pg.reusable_sections();
16414 for f in &fragments {
16415 assert!(
16416 unclassified.contains(&(f.addr, f.size)),
16417 "fragment ({}, {}) must be recorded as unclassified, not lost",
16418 f.addr,
16419 f.size
16420 );
16421 assert!(
16422 !reusable
16423 .iter()
16424 .any(|&(a, l)| a < f.addr + f.size && f.addr < a + l),
16425 "fragment ({}, {}) must not be offered to any allocation: {reusable:?}",
16426 f.addr,
16427 f.size
16428 );
16429 }
16430 }
16431
16432 /// A paged allocation may be served from the other page type's free list only
16433 /// where that space covers whole free pages — never from a hole in a page the
16434 /// other type still occupies.
16435 ///
16436 /// This is the allocation-side half of the rule
16437 /// [`paged_open_seeds_each_manager_by_slot`] pins on the seeding side, and it
16438 /// is just as invisible from the outside: handing a raw allocation a hole in a
16439 /// live metadata page puts chunk bytes inside that page, which every reader —
16440 /// this crate's and the reference library's — resolves correctly, since the
16441 /// file's addresses all still point where they should. Only the paging
16442 /// degrades. So assert the choice directly, in both directions, with a
16443 /// same-type control proving the free region really was big enough to be
16444 /// taken, and the whole-page case proving the exception is reached rather than
16445 /// merely permitted.
16446 #[test]
16447 fn a_paged_allocation_only_crosses_page_types_over_whole_free_pages() {
16448 use crate::writer::FileBuilder;
16449 use tempfile::tempdir;
16450
16451 const PAGE: u64 = 4096;
16452
16453 /// A paged session whose free lists hold exactly one region each, of the
16454 /// page types named. Both regions are interior, so an allocation that
16455 /// takes one is distinguishable from one that appends.
16456 fn session(
16457 path: &std::path::Path,
16458 meta: Option<(u64, u64)>,
16459 raw: Option<(u64, u64)>,
16460 ) -> WriteEngine {
16461 let mut b = FileBuilder::new();
16462 b.create_dataset("d")
16463 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16464 .with_shape(&[4000]);
16465 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16466 .with_file_space_page_size(PAGE);
16467 b.write(path).unwrap();
16468 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
16469 let pg = s.paged.as_mut().expect("a paged file installs paged state");
16470 pg.meta = FreeList::new();
16471 pg.raw = FreeList::new();
16472 if let Some((addr, len)) = meta {
16473 pg.meta.free(addr, len);
16474 }
16475 if let Some((addr, len)) = raw {
16476 pg.raw.free(addr, len);
16477 }
16478 s
16479 }
16480
16481 let dir = tempdir().unwrap();
16482 // Two interior regions well inside the file the builder wrote (16 KiB of
16483 // raw data alone), each a *fragment* of a page whose other bytes are live,
16484 // so either could physically hold the request and only the page-type rule
16485 // decides. Deliberately not page-aligned and not a whole page: that is the
16486 // case the rule forbids outright.
16487 let hole_a = (PAGE + 512, 2048);
16488 let hole_b = (2 * PAGE + 512, 2048);
16489
16490 // Raw request, only a metadata fragment free: appends rather than mixing
16491 // the page.
16492 let mut s = session(&dir.path().join("a.h5"), Some(hole_a), None);
16493 assert!(
16494 matches!(
16495 s.reserve(1024, PageType::Raw).unwrap(),
16496 Placement::Appended { .. }
16497 ),
16498 "a raw allocation must not be served out of a live metadata page"
16499 );
16500 drop(s);
16501
16502 // Metadata request, only a raw fragment free: likewise.
16503 let mut s = session(&dir.path().join("b.h5"), None, Some(hole_b));
16504 assert!(
16505 matches!(
16506 s.reserve(1024, PageType::Meta).unwrap(),
16507 Placement::Appended { .. }
16508 ),
16509 "a metadata allocation must not be served out of a live raw page"
16510 );
16511 drop(s);
16512
16513 // The control: with the matching type free, the same request is reused —
16514 // so the two refusals above are the page-type rule, not a size failure.
16515 let mut s = session(&dir.path().join("c.h5"), Some(hole_a), Some(hole_b));
16516 assert!(
16517 matches!(
16518 s.reserve(1024, PageType::Raw).unwrap(),
16519 Placement::Reused { addr, .. } if addr == hole_b.0
16520 ),
16521 "a raw allocation takes the raw hole"
16522 );
16523 assert!(
16524 matches!(
16525 s.reserve(1024, PageType::Meta).unwrap(),
16526 Placement::Reused { addr, .. } if addr == hole_a.0
16527 ),
16528 "a metadata allocation takes the metadata hole"
16529 );
16530 drop(s);
16531
16532 // The exception, and the whole reason the file stops growing (issue #286):
16533 // a page with *nothing* in it holds no type to contradict, so either kind
16534 // may open it. The page is claimed whole and what the request does not use
16535 // becomes free space of the claiming type.
16536 let mut s = session(&dir.path().join("d.h5"), Some((PAGE, PAGE)), None);
16537 assert!(
16538 matches!(
16539 s.reserve(1024, PageType::Raw).unwrap(),
16540 Placement::Reused { addr, .. } if addr == PAGE
16541 ),
16542 "a raw allocation may open an empty metadata page"
16543 );
16544 let pg = s.paged.as_ref().expect("still paged");
16545 assert_eq!(
16546 pg.raw.sections(),
16547 [(PAGE + 1024, PAGE - 1024)],
16548 "the rest of the claimed page is free space of the claiming type"
16549 );
16550 assert!(
16551 pg.meta.sections().is_empty(),
16552 "the page left the list it was claimed from"
16553 );
16554 drop(s);
16555
16556 // And the claim starts at the empty page, not at the free run's own start.
16557 // A run that spans from mid-page into whole pages beyond it is the common
16558 // shape — the tail of a live page, then pages nothing is left in — and
16559 // taking it from the front would put the request in the live page.
16560 let mut s = session(
16561 &dir.path().join("e.h5"),
16562 None,
16563 Some((PAGE + 512, 3 * PAGE - 512)),
16564 );
16565 assert!(
16566 matches!(
16567 s.reserve(1024, PageType::Meta).unwrap(),
16568 Placement::Reused { addr, .. } if addr == 2 * PAGE
16569 ),
16570 "the claim must begin at the empty page, not at the run's start"
16571 );
16572 // Both edges the claim leaves behind survive it: they are ordinary free
16573 // space of the type that already held them, and dropping either is the same
16574 // silent leak this change exists to remove.
16575 let pg = s.paged.as_ref().expect("still paged");
16576 assert_eq!(
16577 pg.raw.sections(),
16578 [(PAGE + 512, PAGE - 512), (3 * PAGE, PAGE)],
16579 "the fragment below the claimed page and the page above it both stay free"
16580 );
16581 assert_eq!(
16582 pg.meta.sections(),
16583 [(2 * PAGE + 1024, PAGE - 1024)],
16584 "the rest of the claimed page is free space of the claiming type"
16585 );
16586 }
16587
16588 /// Every byte a paged session could still hand out, across both page types.
16589 fn free_total(s: &WriteEngine) -> u64 {
16590 let pg = s.paged.as_ref().expect("a paged session");
16591 pg.meta
16592 .sections()
16593 .into_iter()
16594 .chain(pg.raw.sections())
16595 .map(|(_, len)| len)
16596 .sum()
16597 }
16598
16599 /// A paged commit's tail removes from the free lists exactly the bytes it goes
16600 /// on to write — no more, and nothing at all when it declines to place itself.
16601 ///
16602 /// This is the invariant the whole change rests on, and the one an integration
16603 /// test cannot reach: the tail's length and the hole it lands in determine each
16604 /// other, so which settlement the arithmetic reaches depends on the shape of
16605 /// the free list, and a file the writer builds only ever produces some of them.
16606 /// In particular, best fit prefers the *smallest* region that fits, so a hole
16607 /// whose length is exactly the length the tail proposed is the one it will
16608 /// choose — and consuming a region outright drops a section from the managers,
16609 /// making the plan come out *shorter* than the space just reserved for it. A
16610 /// tail that accepted that would retire the difference from the free list with
16611 /// nothing recording it, which is issue #286 again, a few bytes at a time.
16612 ///
16613 /// Hence a contiguous sweep of hole sizes rather than a chosen one: the length
16614 /// the tail proposes is a property of the file and its free list, not something
16615 /// this test should have to know, and the sweep is certain to cross it.
16616 #[test]
16617 fn a_paged_tail_takes_exactly_the_space_it_fills() {
16618 use crate::writer::FileBuilder;
16619 use tempfile::tempdir;
16620
16621 const PAGE: u64 = 4096;
16622 /// Any plausible extension length exercises the same arithmetic, and the
16623 /// invariant holds for every one of them; the tail's real length is settled
16624 /// by the commit, which is not what is under test here.
16625 const EXT_LEN: u64 = 100;
16626
16627 let dir = tempdir().unwrap();
16628 let path = dir.path().join("tail_exact.h5");
16629 let mut b = FileBuilder::new();
16630 b.create_dataset("d")
16631 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16632 .with_shape(&[4000]);
16633 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16634 .with_file_space_page_size(PAGE);
16635 b.write(&path).unwrap();
16636 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16637 let os = s.superblock.offset_size;
16638
16639 let mut placed = 0usize;
16640 for hole in 120..420u64 {
16641 {
16642 let pg = s.paged.as_mut().expect("a paged file installs paged state");
16643 pg.meta = FreeList::new();
16644 pg.raw = FreeList::new();
16645 pg.unclassified = FreeList::new();
16646 pg.meta.free(PAGE, hole);
16647 }
16648 let free_before = free_total(&s);
16649 let layout = s.tail_layout(&[], &[], EXT_LEN, PAGE, os);
16650 let free_after = free_total(&s);
16651 match layout {
16652 Some((_, _, at, blocks_len, _)) => {
16653 placed += 1;
16654 assert_eq!(
16655 free_before - free_after,
16656 blocks_len,
16657 "hole {hole}: the tail took {} bytes at {at} to write {blocks_len}",
16658 free_before - free_after
16659 );
16660 }
16661 None => assert_eq!(
16662 free_after, free_before,
16663 "hole {hole}: a tail that declines to place itself must hand back \
16664 everything it tried"
16665 ),
16666 }
16667 }
16668 assert!(
16669 placed > 0,
16670 "the sweep must reach holes the tail can actually use, or it asserts \
16671 nothing about placement"
16672 );
16673 }
16674
16675 /// The shrink pass never grows the file: asked to rewrite the tail with
16676 /// nothing in the free list to put it in, it writes nothing at all
16677 /// (issue #418).
16678 ///
16679 /// That is the whole safety argument for running it. It exists to make the
16680 /// file smaller, and a tail it cannot place would otherwise go past
16681 /// end-of-file — a rewrite taken for the sake of a shorter file leaving a
16682 /// longer one. The second half of the test is what keeps the first half
16683 /// honest: under `TailPlacement::Anywhere` the same call on the same session
16684 /// does write a tail, so the fixture really did have one to write.
16685 #[test]
16686 fn a_reuse_only_tail_rewrite_that_cannot_place_itself_writes_nothing() {
16687 use crate::writer::FileBuilder;
16688 use tempfile::tempdir;
16689
16690 let dir = tempdir().unwrap();
16691 let path = dir.path().join("reuse_only.h5");
16692 let mut b = FileBuilder::new();
16693 b.create_dataset("d")
16694 .with_i32_data(&(0..400).collect::<Vec<i32>>())
16695 .with_shape(&[400]);
16696 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16697 b.write(&path).unwrap();
16698 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16699 // No hole anywhere: the only place a tail could go is past end-of-file.
16700 s.free = FreeList::new();
16701 let root = s.superblock.root_group_address;
16702 let len_before = s.image.len();
16703 let ext_before = s.superblock.superblock_extension_address;
16704
16705 s.commit_persisting(root, Vec::new(), TailPlacement::ReuseOnly)
16706 .unwrap();
16707 assert_eq!(
16708 s.image.len(),
16709 len_before,
16710 "a reuse-only rewrite with nothing to reuse must leave the file alone"
16711 );
16712 assert_eq!(
16713 s.superblock.superblock_extension_address, ext_before,
16714 "and must not have published a new extension either"
16715 );
16716
16717 s.commit_persisting(root, Vec::new(), TailPlacement::Anywhere)
16718 .unwrap();
16719 assert!(
16720 s.image.len() > len_before,
16721 "the same rewrite that may append does grow the file, so the case \
16722 above was a tail declined rather than a tail of no size"
16723 );
16724 }
16725
16726 /// A flat commit's tail removes from the free list exactly the bytes its
16727 /// recorded extent covers — no more, and nothing at all when it declines to
16728 /// place itself.
16729 ///
16730 /// The flat tail accepts a plan *shorter* than its reservation where the paged
16731 /// one refuses ([`WriteEngine::flat_tail_layout`] says why), so this is the
16732 /// invariant that keeps those spare bytes from leaking: what the layout hands
16733 /// back as the tail's length is the whole reservation, which the commit records
16734 /// as the extent to free when the next one supersedes it. Returning the plan's
16735 /// length instead would retire the difference from the free list with nothing
16736 /// recording it — issue #286 again, a few bytes at a time.
16737 ///
16738 /// Swept over hole sizes rather than a chosen one, for the reason the paged
16739 /// sweep gives: the length the tail proposes is a property of the file and its
16740 /// free list, and best fit takes a hole of exactly that length outright, which
16741 /// drops a section from the managers and is what makes the plan come out
16742 /// shorter than the reservation in the first place.
16743 #[test]
16744 fn a_flat_tail_takes_exactly_the_extent_it_records() {
16745 use crate::writer::FileBuilder;
16746 use tempfile::tempdir;
16747
16748 /// Any plausible extension length exercises the same arithmetic; the real
16749 /// one is settled by the commit, which is not what is under test here.
16750 const EXT_LEN: u64 = 100;
16751 /// Any address does: the layout only ever asks the free list for a length.
16752 const HOLE_AT: u64 = 512;
16753
16754 let dir = tempdir().unwrap();
16755 let path = dir.path().join("flat_tail_exact.h5");
16756 let mut b = FileBuilder::new();
16757 b.create_dataset("d")
16758 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16759 .with_shape(&[4000]);
16760 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16761 b.write(&path).unwrap();
16762 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16763 let os = s.superblock.offset_size;
16764
16765 let free_total =
16766 |s: &WriteEngine| -> u64 { s.free.sections().into_iter().map(|(_, len)| len).sum() };
16767
16768 let (mut placed, mut declined, mut with_slack) = (0usize, 0usize, 0usize);
16769 for hole in 120..420u64 {
16770 s.free = FreeList::new();
16771 s.free.free(HOLE_AT, hole);
16772 let free_before = free_total(&s);
16773 let (post, at, tail_len, _) = s.flat_tail_layout(&[], &[], EXT_LEN, os);
16774 let free_after = free_total(&s);
16775 // The blocks the commit will write into the extent it was handed. A hole
16776 // consumed outright drops a section from the managers, so this comes out
16777 // shorter than the extent for some hole sizes and the difference is what
16778 // the extent has to cover.
16779 let written = EXT_LEN + file_fsm_blocks_len(&free_sections(&post), os);
16780 match at {
16781 Some(at) => {
16782 placed += 1;
16783 assert_eq!(
16784 free_before - free_after,
16785 tail_len,
16786 "hole {hole}: the tail took {} bytes at {at} for an extent of \
16787 {tail_len}",
16788 free_before - free_after
16789 );
16790 if written < tail_len {
16791 with_slack += 1;
16792 }
16793 }
16794 None => {
16795 declined += 1;
16796 assert_eq!(
16797 free_after, free_before,
16798 "hole {hole}: a tail that declines to place itself must hand \
16799 back everything it tried"
16800 );
16801 assert_eq!(
16802 tail_len, written,
16803 "hole {hole}: a tail with nowhere to go is appended, and the \
16804 length it reports is what it will write there"
16805 );
16806 }
16807 }
16808 }
16809 assert!(
16810 placed > 0,
16811 "the sweep must reach holes the tail can actually use, or it asserts \
16812 nothing about placement"
16813 );
16814 assert!(
16815 declined > 0,
16816 "the sweep must reach holes too small for the tail, or the rule about \
16817 handing back a failed attempt is never exercised"
16818 );
16819 assert!(
16820 with_slack > 0,
16821 "the sweep must reach a hole the tail does not fill exactly, or the \
16822 rule about spare bytes is never exercised"
16823 );
16824 }
16825
16826 /// A paged commit whose tail finds no free space to sit in opens a page for it
16827 /// — and hands the rest of that page back as free metadata.
16828 ///
16829 /// Every paged file this crate writes has holes in it from the start, so the
16830 /// suite's ordinary fixtures always reuse and this branch is never taken. It is
16831 /// reachable in the wild, by a file whose free space is all spoken for, and the
16832 /// page it opens is the same page a page-per-commit leak used to strand. Emptying
16833 /// the free lists by hand is what puts the file in that state deliberately.
16834 #[test]
16835 fn a_paged_tail_with_nowhere_to_go_opens_a_page_and_frees_the_rest() {
16836 use crate::writer::FileBuilder;
16837 use tempfile::tempdir;
16838
16839 const PAGE: u64 = 4096;
16840 let dir = tempdir().unwrap();
16841 let path = dir.path().join("tail_appends.h5");
16842 let mut b = FileBuilder::new();
16843 b.create_dataset("d")
16844 .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16845 .with_shape(&[4000]);
16846 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16847 .with_file_space_page_size(PAGE);
16848 b.write(&path).unwrap();
16849 let before = std::fs::metadata(&path).unwrap().len();
16850
16851 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16852 {
16853 let pg = s.paged.as_mut().expect("a paged file installs paged state");
16854 pg.meta = FreeList::new();
16855 pg.raw = FreeList::new();
16856 pg.unclassified = FreeList::new();
16857 }
16858 // A commit with nothing staged returns without writing, so give it one
16859 // small metadata object to place. It appends for want of anywhere else,
16860 // and the tail follows it into the same page.
16861 s.create_group("g").unwrap();
16862 s.commit().unwrap();
16863
16864 let after = std::fs::metadata(&path).unwrap().len();
16865 assert!(
16866 after > before && (after - before) % PAGE == 0,
16867 "the commit opened whole pages ({before} -> {after})"
16868 );
16869 // What the tail did not fill of the page it opened is metadata this session
16870 // can still spend. Losing it is how the file used to give up most of a page
16871 // on every commit; the managers just written cannot record it, so the
16872 // session carries it to the next commit.
16873 let pg = s.paged.as_ref().expect("still paged");
16874 let (addr, len) = pg
16875 .meta
16876 .sections()
16877 .into_iter()
16878 .find(|&(addr, len)| addr + len == after)
16879 .expect("the page the tail opened leaves a free remainder at end-of-file");
16880 assert!(
16881 len > 0 && len < PAGE && addr >= before,
16882 "the tail's blocks take the front of the page it opened and the rest is \
16883 free ({len} of {PAGE} at {addr}, file {before} -> {after})"
16884 );
16885 }
16886
16887 /// Deleting a chunked dataset from a paged file must not record its freed
16888 /// chunk index in the *metadata* manager.
16889 ///
16890 /// Every writer in this crate emits a chunk index in the same run as the chunk
16891 /// data it indexes, so the index sits in a raw page. Recording it as metadata
16892 /// would advertise a metadata-sized hole inside a page that still holds another
16893 /// dataset's live chunk data, and the reference library placing metadata there
16894 /// would mix the page — the one thing a paged file forbids. Page homogeneity is
16895 /// preserved on disk either way, so the mis-filing is invisible to a signature
16896 /// scan of the file and to the C library; the manager a section lands in has to
16897 /// be checked directly.
16898 #[test]
16899 fn deleted_chunk_index_is_freed_into_a_raw_manager() {
16900 use crate::writer::FileBuilder;
16901 use tempfile::tempdir;
16902
16903 let dir = tempdir().unwrap();
16904 let path = dir.path().join("paged_chunk_index_free.h5");
16905 let page = 4096u64;
16906 let mut b = FileBuilder::new();
16907 for name in ["drop", "keep"] {
16908 b.create_dataset(name)
16909 .with_i32_data(&(0..200).collect::<Vec<i32>>())
16910 .with_shape(&[200])
16911 .with_chunks(&[50]);
16912 }
16913 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16914 .with_file_space_page_size(page);
16915 b.write(&path).unwrap();
16916
16917 {
16918 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16919 s.delete("/drop").unwrap();
16920 s.commit().unwrap();
16921 }
16922
16923 // Pages still occupied by the surviving dataset's chunk data. Read with the
16924 // session closed: its lock is mandatory on Windows.
16925 let live_raw_pages: Vec<u64> = {
16926 let f = crate::reader::File::open(&path).unwrap();
16927 let ds = f.dataset("keep").unwrap();
16928 let mut pages: Vec<u64> = ds
16929 .chunks()
16930 .unwrap()
16931 .iter()
16932 .filter(|c| c.storage_size > 0)
16933 .flat_map(|c| (c.address / page)..=((c.address + c.storage_size - 1) / page))
16934 .collect();
16935 pages.sort_unstable();
16936 pages.dedup();
16937 pages
16938 };
16939 assert!(!live_raw_pages.is_empty(), "expected live raw pages");
16940
16941 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16942 let pg = s.paged.as_ref().expect("a paged file installs paged state");
16943 for (addr, len) in pg.meta.sections() {
16944 for p in (addr / page)..=((addr + len - 1) / page) {
16945 assert!(
16946 !live_raw_pages.contains(&p),
16947 "metadata free section ({addr}, {len}) sits in page {p}, which still \
16948 holds live raw chunk data"
16949 );
16950 }
16951 }
16952 // The index really was reclaimed somewhere, so this is not vacuous.
16953 let reclaimed: u64 = pg.reusable_sections().iter().map(|&(_, l)| l).sum();
16954 assert!(reclaimed > 0, "the delete reclaimed nothing");
16955 }
16956
16957 /// A paged commit that fails partway must leave the session's free lists
16958 /// exactly as it found them.
16959 ///
16960 /// Everything the commit gathers to free is still *live* until the superblock
16961 /// repoint: the objects occupying those regions are reachable from the old
16962 /// root, which a failed commit never replaces. A session that recorded them as
16963 /// free anyway would hand them out on the next commit, and the file would lose
16964 /// data with no error anywhere — in a release build, where the free list's
16965 /// double-free `debug_assert` is compiled out, silently.
16966 ///
16967 /// The failure is induced by pointing the superblock extension at a byte range
16968 /// that is not an object header, which fails the extension rewrite immediately
16969 /// after the regions are gathered.
16970 #[test]
16971 fn failed_paged_commit_leaves_the_free_lists_untouched() {
16972 use crate::writer::FileBuilder;
16973 use tempfile::tempdir;
16974
16975 let dir = tempdir().unwrap();
16976 let path = dir.path().join("paged_failed_commit.h5");
16977 let mut b = FileBuilder::new();
16978 b.create_dataset("keep")
16979 .with_i32_data(&(0..200).collect::<Vec<i32>>())
16980 .with_shape(&[200]);
16981 b.create_dataset("drop")
16982 .with_i32_data(&(0..200).collect::<Vec<i32>>())
16983 .with_shape(&[200]);
16984 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16985 .with_file_space_page_size(4096);
16986 b.write(&path).unwrap();
16987
16988 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16989 let before = s.space_accounting().reusable_free_space;
16990
16991 // Break the extension so the commit fails *after* it has gathered the
16992 // regions `drop` vacates and *before* the superblock repoint.
16993 let good_ext = s.superblock.superblock_extension_address;
16994 s.superblock.superblock_extension_address = Some(0);
16995 s.delete("/drop").unwrap();
16996 assert!(
16997 s.commit().is_err(),
16998 "a commit with an unreadable extension must fail"
16999 );
17000
17001 assert_eq!(
17002 s.space_accounting().reusable_free_space,
17003 before,
17004 "a failed commit must not record still-live regions as free"
17005 );
17006
17007 // The session stays usable: repair the extension and commit for real. If
17008 // the failed commit had folded its regions in, this second commit would
17009 // double-free them (a debug assertion) and publish `keep`'s live extent.
17010 s.superblock.superblock_extension_address = good_ext;
17011 s.delete("/drop").unwrap();
17012 s.commit()
17013 .expect("the session is usable after a failed commit");
17014
17015 // Release the session's exclusive OS lock before reading the file back.
17016 // Those locks are mandatory on Windows, so a `File::open` overlapping the
17017 // session fails outright there (advisory locks elsewhere would allow it).
17018 drop(s);
17019
17020 let f = crate::reader::File::open(&path).unwrap();
17021 let kept = f.dataset("keep").unwrap().read_i32().unwrap();
17022 assert_eq!(kept, (0..200).collect::<Vec<i32>>(), "keep survives intact");
17023 let freed: u64 = f.persisted_free_space().iter().map(|&(_, l)| l).sum();
17024 let live_end = f.file_size();
17025 assert!(
17026 freed < live_end,
17027 "the recorded free space cannot cover the whole file"
17028 );
17029 }
17030
17031 /// A commit that fails partway must roll back the heap-collection
17032 /// provenance along with the free lists (issue #321).
17033 ///
17034 /// `resolve_overwrite_bytes` places a collection and records it in the
17035 /// *apply* phase. A commit that then fails before its repoint gives that
17036 /// space back to the free list — and used to keep the record, which is the
17037 /// one piece of engine state naming file addresses that the rollback missed.
17038 /// The next overwrite of that path would free the region a *later* commit
17039 /// had since been handed, so the file's live variable-length data would sit
17040 /// in space the allocator considers free, and the next unrelated write would
17041 /// land on it.
17042 ///
17043 /// Induced the same way as
17044 /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17045 /// superblock extension fails the commit after the apply phase has run.
17046 #[test]
17047 fn a_failed_commit_rolls_back_the_heap_collection_provenance() {
17048 use crate::writer::FileBuilder;
17049 use tempfile::tempdir;
17050
17051 let dir = tempdir().unwrap();
17052 let path = dir.path().join("failed_commit_vl_provenance.h5");
17053 let mut b = FileBuilder::new();
17054 b.create_dataset("labels")
17055 .with_vlen_strings(&["seed-one", "seed-two"]);
17056 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17057 b.write(&path).unwrap();
17058
17059 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17060
17061 // One good round, so there is a record to roll back.
17062 s.stage_dataset_write("/labels", {
17063 let mut db = crate::type_builders::DatasetBuilder::new("");
17064 db.with_vlen_strings(&["round-one-aaaa", "round-one-bbbb"]);
17065 db
17066 })
17067 .unwrap();
17068 s.commit().unwrap();
17069 let recorded = s.vl_overwrite_heaps.clone();
17070 assert!(!recorded.is_empty(), "the good round recorded nothing");
17071
17072 // Break the extension so the next commit fails after the apply phase.
17073 let good_ext = s.superblock.superblock_extension_address;
17074 s.superblock.superblock_extension_address = Some(0);
17075 s.stage_dataset_write("/labels", {
17076 let mut db = crate::type_builders::DatasetBuilder::new("");
17077 db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17078 db
17079 })
17080 .unwrap();
17081 assert!(
17082 s.commit().is_err(),
17083 "a commit with an unreadable extension must fail"
17084 );
17085
17086 assert_eq!(
17087 s.vl_overwrite_heaps, recorded,
17088 "a failed commit must not leave a record naming space it gave back"
17089 );
17090
17091 // The session stays usable, and the collections the rolled-back record
17092 // names are still the live ones.
17093 s.superblock.superblock_extension_address = good_ext;
17094 s.stage_dataset_write("/labels", {
17095 let mut db = crate::type_builders::DatasetBuilder::new("");
17096 db.with_vlen_strings(&["round-three-a", "round-three-b"]);
17097 db
17098 })
17099 .unwrap();
17100 s.commit()
17101 .expect("the session is usable after a failed commit");
17102 drop(s);
17103
17104 let f = crate::reader::File::open(&path).unwrap();
17105 assert_eq!(
17106 f.dataset("labels").unwrap().read_string().unwrap(),
17107 vec!["round-three-a".to_string(), "round-three-b".to_string()]
17108 );
17109 }
17110
17111 /// A failed commit must not leave the free list offering a span the image
17112 /// still points into (issue #321).
17113 ///
17114 /// Resolving a staged variable-length overwrite *allocates* — a global heap
17115 /// collection, drawn from a freed region where one fits. If the overwrite
17116 /// were applied in place, that allocation's address would be written into a
17117 /// data block the current root already reaches; a commit failing after the
17118 /// write and before its repoint then hands the span back to the free list,
17119 /// while the image still names it. The next commit places something else
17120 /// there and the dataset reads that object's heap bytes, with every checksum
17121 /// in the file intact.
17122 ///
17123 /// A relocating overwrite has no such window — its new block is reachable
17124 /// from nothing until the repoint — which is why `prepare_write` refuses to
17125 /// plan a staged variable-length overwrite in place at all. This is that
17126 /// rule's test.
17127 ///
17128 /// The freed hole has to exist *first*, or the failed commit's collection is
17129 /// appended past end-of-file where nothing reuses it and the bug hides. The
17130 /// filler datasets each need a metadata span of the size the rolled-back one
17131 /// occupied, which is what draws on it.
17132 #[test]
17133 fn a_failed_commit_leaves_no_reusable_span_the_image_names() {
17134 use crate::writer::FileBuilder;
17135 use tempfile::tempdir;
17136
17137 let dir = tempdir().unwrap();
17138 let path = dir.path().join("failed_commit_live_span.h5");
17139 let mut b = FileBuilder::new();
17140 b.create_dataset("labels")
17141 .with_vlen_strings(&["seed-one", "seed-two"]);
17142 b.create_dataset("big").with_u8_data(&[0x5A; 40960]);
17143 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17144 b.write(&path).unwrap();
17145
17146 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17147 // The hole the collections below are placed into by reuse.
17148 s.delete("/big").unwrap();
17149 s.commit().unwrap();
17150
17151 let round_one = ["round-one-aaaa", "round-one-bbbb"];
17152 s.stage_dataset_write("/labels", {
17153 let mut db = crate::type_builders::DatasetBuilder::new("");
17154 db.with_vlen_strings(&round_one);
17155 db
17156 })
17157 .unwrap();
17158 s.commit().unwrap();
17159
17160 // Fail a second overwrite after its apply phase has placed a collection.
17161 let good_ext = s.superblock.superblock_extension_address;
17162 s.superblock.superblock_extension_address = Some(0);
17163 s.stage_dataset_write("/labels", {
17164 let mut db = crate::type_builders::DatasetBuilder::new("");
17165 db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17166 db
17167 })
17168 .unwrap();
17169 assert!(
17170 s.commit().is_err(),
17171 "a commit with an unreadable extension must fail"
17172 );
17173 s.superblock.superblock_extension_address = good_ext;
17174
17175 // Unrelated commits that each want a metadata span of the same size.
17176 // Whatever the failed attempt gave back, these are what would draw on it.
17177 for i in 0..4 {
17178 s.stage_created_dataset(&format!("/filler{i}"), {
17179 let mut db = crate::type_builders::DatasetBuilder::new("");
17180 db.with_vlen_strings(&["XXXXXXXXXXXXXXXXXXXX", "YYYYYYYYYYYYYYYYYYYY"]);
17181 db
17182 })
17183 .unwrap();
17184 s.commit().unwrap();
17185 }
17186
17187 // Read only once the session has released its lock on the file: those
17188 // locks are mandatory on Windows, so a `File::open` overlapping the
17189 // session fails outright there where advisory locks elsewhere allow it.
17190 drop(s);
17191
17192 let f = crate::reader::File::open(&path).unwrap();
17193 let got = f.dataset("labels").unwrap().read_string();
17194 assert!(
17195 got.as_ref()
17196 .is_ok_and(|v| v.iter().map(String::as_str).eq(round_one)),
17197 "/labels reads another dataset's heap objects: {got:?}"
17198 );
17199 }
17200
17201 /// A commit that writes into reused free space and then dies must leave the
17202 /// file exactly as it found it.
17203 ///
17204 /// This is the safety argument for reuse, stated as a test: a commit may
17205 /// overwrite a freed region *before* the superblock repoint precisely because
17206 /// nothing reachable from the on-disk root lives there any more, so an attempt
17207 /// that never reaches the repoint is invisible. A chunked dataset is the case
17208 /// worth pinning — it is the largest thing a commit places, so it overwrites
17209 /// the most, and it is the one that used only to append.
17210 ///
17211 /// The failure is induced the same way as
17212 /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17213 /// superblock extension, which the persisting tail hits *after* the apply loop
17214 /// has written every object.
17215 #[test]
17216 fn a_failed_commit_that_reused_free_space_leaves_the_file_intact() {
17217 use crate::writer::FileBuilder;
17218 use tempfile::tempdir;
17219
17220 let dir = tempdir().unwrap();
17221 let path = dir.path().join("reuse_failed_commit.h5");
17222 let victim: Vec<f64> = (0..4096).map(|i| (i % 13) as f64).collect();
17223 let ceiling: Vec<i32> = (0..500).collect();
17224 let mut b = FileBuilder::new();
17225 b.create_dataset("keep").with_i32_data(&[1, 2, 3]);
17226 b.create_dataset("victim")
17227 .with_f64_data(&victim)
17228 .with_shape(&[4096])
17229 .with_chunks(&[512]);
17230 // Above the victim, so the delete leaves an interior hole and the reuse
17231 // has live bytes on both sides of what it overwrites.
17232 b.create_dataset("ceiling")
17233 .with_i32_data(&ceiling)
17234 .with_shape(&[500]);
17235 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
17236 b.write(&path).unwrap();
17237
17238 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17239 s.delete("/victim").unwrap();
17240 s.commit().unwrap();
17241 let free_before = s.space_accounting().reusable_free_space;
17242 let len_before = std::fs::metadata(&path).unwrap().len();
17243 assert!(
17244 free_before.iter().any(|&(_, l)| l > 4096 * 8 / 2),
17245 "the deleted chunked dataset left a hole worth reusing: {free_before:?}"
17246 );
17247
17248 // Break the extension so the commit fails in its persisting tail, after
17249 // the apply loop has written the new dataset into that hole.
17250 s.superblock.superblock_extension_address = Some(0);
17251 let mut db = crate::type_builders::DatasetBuilder::new("fresh");
17252 db.with_f64_data(&vec![7.5f64; 4096])
17253 .with_shape(&[4096])
17254 .with_chunks(&[512]);
17255 s.stage_created_dataset("/fresh", db).unwrap();
17256 assert!(
17257 s.commit().is_err(),
17258 "a commit with an unreadable extension must fail"
17259 );
17260 assert_eq!(
17261 s.space_accounting().reusable_free_space,
17262 free_before,
17263 "the failed commit gives back the region it drew from"
17264 );
17265 // Release the session's exclusive OS lock before reading the file back;
17266 // those locks are mandatory on Windows.
17267 drop(s);
17268
17269 // The file still describes the tree the last *successful* commit left: the
17270 // survivors read exactly, the half-written dataset is not linked, and the
17271 // superblock's end-of-file still matches the file.
17272 assert_eq!(std::fs::metadata(&path).unwrap().len(), len_before);
17273 let f = crate::reader::File::open(&path).unwrap();
17274 assert_eq!(f.file_size(), len_before);
17275 assert_eq!(
17276 f.dataset("keep").unwrap().read_i32().unwrap(),
17277 vec![1, 2, 3]
17278 );
17279 assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), ceiling);
17280 assert!(f.dataset("victim").is_err());
17281 assert!(f.dataset("fresh").is_err());
17282 }
17283
17284 /// A refused commit leaves every dataset reading what it read before —
17285 /// including the one whose values it had already written over (issue #344).
17286 ///
17287 /// Everything else a commit places lands where nothing reaches it until the
17288 /// superblock is repointed, so an attempt that stops short of the repoint is
17289 /// invisible. A same-length value overwrite is the exception: it writes
17290 /// straight over the data block the *current* root already reaches, so there
17291 /// is no repoint to withhold and the write is live the moment it lands. Both
17292 /// halves of one refused batch are asserted here, because the defect was
17293 /// that they disagreed: the overwrite survived and the object creation
17294 /// beside it did not.
17295 ///
17296 /// Closing the session is enough to expose it — no later commit is needed,
17297 /// since the bytes are in the file already and write gathering only delays
17298 /// when they reach the disk.
17299 ///
17300 /// A second staged edit is what keeps this on the full commit path; a batch
17301 /// of nothing but same-length overwrites takes the fast path, which
17302 /// `a_failed_fast_path_commit_puts_back_the_value_it_overwrote` covers. The
17303 /// failure is induced the same way as
17304 /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17305 /// superblock extension, which fails the commit in its tail, after the apply
17306 /// phase has done the in-place write.
17307 #[test]
17308 fn a_failed_commit_puts_back_the_value_it_overwrote() {
17309 use crate::writer::FileBuilder;
17310 use tempfile::tempdir;
17311
17312 let dir = tempdir().unwrap();
17313 let path = dir.path().join("inplace_partial.h5");
17314 let mut b = FileBuilder::new();
17315 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17316 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17317 b.write(&path).unwrap();
17318
17319 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17320
17321 let good_ext = s.superblock.superblock_extension_address;
17322 s.superblock.superblock_extension_address = Some(0);
17323 s.stage_dataset_write("/nums", {
17324 let mut db = crate::type_builders::DatasetBuilder::new("");
17325 db.with_i32_data(&[9, 9, 9]);
17326 db
17327 })
17328 .unwrap();
17329 s.stage_created_dataset("/extra", {
17330 let mut db = crate::type_builders::DatasetBuilder::new("");
17331 db.with_i32_data(&[42]);
17332 db
17333 })
17334 .unwrap();
17335 let refused = s.commit();
17336 assert!(
17337 matches!(refused, Err(Error::EditUnsupported(_))),
17338 "the refusal, not a rollback failure: {refused:?}"
17339 );
17340
17341 // The session stays usable, and its next commit is not carrying any of
17342 // the refused batch.
17343 s.superblock.superblock_extension_address = good_ext;
17344 s.stage_created_dataset("/later", {
17345 let mut db = crate::type_builders::DatasetBuilder::new("");
17346 db.with_i32_data(&[7]);
17347 db
17348 })
17349 .unwrap();
17350 s.commit()
17351 .expect("the session is usable after a failed commit");
17352
17353 // Release the session's exclusive OS lock before reading the file back;
17354 // those locks are mandatory on Windows.
17355 drop(s);
17356
17357 let f = crate::reader::File::open(&path).unwrap();
17358 assert_eq!(
17359 f.dataset("nums").unwrap().read_i32().unwrap(),
17360 vec![1, 2, 3],
17361 "the refused batch's value overwrite is in the file"
17362 );
17363 assert!(
17364 f.dataset("extra").is_err(),
17365 "/extra was correctly discarded"
17366 );
17367 assert_eq!(f.dataset("later").unwrap().read_i32().unwrap(), vec![7]);
17368 }
17369
17370 /// The same guarantee on the commit fast path, where a batch of nothing but
17371 /// same-length overwrites skips the header rebuild and the superblock flip
17372 /// entirely (issue #344).
17373 ///
17374 /// That path has no tail to fail in, so the only failure it can suffer is
17375 /// one of its own writes — which is also the one failure the rollback cannot
17376 /// fully repair, since putting the prior bytes back means writing to the
17377 /// address that just refused them. Both outcomes are pinned here: the
17378 /// overwrite that *did* land is put back, the one that could not be is
17379 /// reported as [`Error::CommitPartiallyApplied`] rather than as a plain I/O
17380 /// error, and the values in the file are exactly what those two statements
17381 /// predict.
17382 ///
17383 /// `TornWriteImage` supplies the failure at a byte range rather than at a
17384 /// call ordinal, so the test names the block it means (`victim`'s) instead
17385 /// of depending on how many writes a commit happens to issue.
17386 ///
17387 /// `kept` is staged first on purpose. Staged writes are applied in the order
17388 /// they were staged (`StagedEdits::writes` is a `Vec`), so it is the entry
17389 /// already in the journal when `victim` fails — and restoring it is what
17390 /// shows the rollback carrying on past a failure rather than giving up at
17391 /// the first one. Stage them the other way round and that half goes quiet.
17392 #[test]
17393 fn a_failed_fast_path_commit_puts_back_the_value_it_overwrote() {
17394 use crate::writer::FileBuilder;
17395 use tempfile::tempdir;
17396
17397 let dir = tempdir().unwrap();
17398 let path = dir.path().join("inplace_fast_path_torn.h5");
17399 let mut b = FileBuilder::new();
17400 b.create_dataset("kept").with_i32_data(&[1, 2, 3]);
17401 b.create_dataset("victim").with_i32_data(&[4, 5, 6]);
17402 b.write(&path).unwrap();
17403
17404 // The data block the fake device refuses, named rather than guessed.
17405 let victim_block = {
17406 let f = crate::reader::File::open(&path).unwrap();
17407 match f.dataset("victim").unwrap().layout().unwrap() {
17408 crate::Layout::Contiguous {
17409 address: Some(a),
17410 size,
17411 } => a..a + size,
17412 other => panic!("expected a contiguous victim: {other:?}"),
17413 }
17414 };
17415
17416 let mut s = WriteEngine::open_torn_writes(&path, victim_block).unwrap();
17417 for (name, data) in [("/kept", [9, 9, 9]), ("/victim", [8, 8, 8])] {
17418 s.stage_dataset_write(name, {
17419 let mut db = crate::type_builders::DatasetBuilder::new("");
17420 db.with_i32_data(&data);
17421 db
17422 })
17423 .unwrap();
17424 }
17425 let refused = s.commit();
17426 assert!(
17427 matches!(refused, Err(Error::CommitPartiallyApplied { .. })),
17428 "a rollback that could not run is not an ordinary refusal: {refused:?}"
17429 );
17430 drop(s);
17431
17432 let f = crate::reader::File::open(&path).unwrap();
17433 assert_eq!(
17434 f.dataset("kept").unwrap().read_i32().unwrap(),
17435 vec![1, 2, 3],
17436 "the overwrite the rollback could reach is put back"
17437 );
17438 assert_eq!(
17439 f.dataset("victim").unwrap().read_i32().unwrap(),
17440 vec![8, 8, 8],
17441 "the overwrite it could not reach is the value the error is about"
17442 );
17443 }
17444
17445 /// Past the superblock repoint the overwrite is part of what the commit
17446 /// *published*, so a failure after that point must leave it standing —
17447 /// the same boundary [`FreeSnapshot`] draws for the free lists (issue #344).
17448 ///
17449 /// The window is real rather than theoretical: `repoint_stored_references`
17450 /// runs after the repoint and writes into live objects. A reference to the
17451 /// root group is what makes it run on any commit at all, since every commit
17452 /// rebuilds the root; the fake device then fails that write, so the commit
17453 /// returns an error having already committed.
17454 ///
17455 /// What the file holds afterwards is the new tree — the value overwrite
17456 /// included — with one stale reference. That is not a state a rollback can
17457 /// improve on: the root the reference would have to be re-pointed at is
17458 /// already the live one.
17459 #[test]
17460 fn a_commit_that_fails_past_its_repoint_keeps_the_value_it_published() {
17461 use crate::writer::FileBuilder;
17462 use tempfile::tempdir;
17463
17464 let dir = tempdir().unwrap();
17465 let path = dir.path().join("inplace_after_repoint.h5");
17466 let mut b = FileBuilder::new();
17467 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17468 b.create_dataset("refs").with_path_references(&[""]);
17469 b.write(&path).unwrap();
17470
17471 let refs_block = {
17472 let f = crate::reader::File::open(&path).unwrap();
17473 match f.dataset("refs").unwrap().layout().unwrap() {
17474 crate::Layout::Contiguous {
17475 address: Some(a),
17476 size,
17477 } => a..a + size,
17478 other => panic!("expected a contiguous reference dataset: {other:?}"),
17479 }
17480 };
17481
17482 let mut s = WriteEngine::open_torn_writes(&path, refs_block).unwrap();
17483 s.stage_dataset_write("/nums", {
17484 let mut db = crate::type_builders::DatasetBuilder::new("");
17485 db.with_i32_data(&[9, 9, 9]);
17486 db
17487 })
17488 .unwrap();
17489 // A second edit, so the batch takes the full commit path and reaches a
17490 // repoint at all.
17491 s.stage_created_dataset("/extra", {
17492 let mut db = crate::type_builders::DatasetBuilder::new("");
17493 db.with_i32_data(&[42]);
17494 db
17495 })
17496 .unwrap();
17497 let failed = s.commit();
17498 assert!(
17499 matches!(failed, Err(Error::Io(_))),
17500 "the reference write is what failed, after the commit published: {failed:?}"
17501 );
17502 assert!(
17503 s.publish_attempted,
17504 "the failure has to be past the publish for this test to mean anything"
17505 );
17506 drop(s);
17507
17508 let f = crate::reader::File::open(&path).unwrap();
17509 assert_eq!(
17510 f.dataset("nums").unwrap().read_i32().unwrap(),
17511 vec![9, 9, 9],
17512 "the published overwrite must not be rolled back under it"
17513 );
17514 assert_eq!(f.dataset("extra").unwrap().read_i32().unwrap(), vec![42]);
17515 }
17516
17517 #[test]
17518 fn append_inplace_crash_consistency_paged_prefix() {
17519 use crate::reader::File as PureFile;
17520 use tempfile::tempdir;
17521
17522 let dir = tempdir().unwrap();
17523 let base = dir.path().join("base.h5");
17524 let (start, target) = (131_000i32, 132_000i32);
17525 build_unit_chunked(&base, start);
17526
17527 for max_phase in 1u8..=4 {
17528 let p = dir.path().join(format!("crash_paged_{max_phase}.h5"));
17529 append_stopped_at(&base, &p, start..target, max_phase);
17530 let expected_len = if max_phase == 4 { target } else { start };
17531 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17532 assert_eq!(
17533 f.dataset("d").unwrap().read_i32().unwrap(),
17534 (0..expected_len).collect::<Vec<_>>(),
17535 "inconsistent paged view after crash at phase {max_phase}"
17536 );
17537 }
17538 }
17539
17540 /// After an append stopped at any phase, a reader sees the committed prefix
17541 /// and nothing of the rest, for both starting layouts: a partial trailing
17542 /// chunk and the EA-boundary growth above, which exercise different index
17543 /// writes.
17544 ///
17545 /// This reader is the lenient one. It bounds chunk reads by `min(EA count,
17546 /// dimension)`, so it tolerates a phase-3 state where the element count has
17547 /// advanced past the dimension, where the reference C library walks strictly
17548 /// by the dataspace dimension and re-validates block checksums. The C
17549 /// library read these states until it became a dependency of the crosscheck
17550 /// package alone; the phased append that produces them is private, so that
17551 /// coverage is gone, and a stale end-of-file, a half-grown index or a
17552 /// mis-checksummed block that this reader forgives is not caught here.
17553 ///
17554 /// Restores coverage lost with the deprecated `SwmrWriter` and `AppendWriter`
17555 /// (issue #202).
17556 #[test]
17557 fn append_inplace_crash_consistency_leaves_the_committed_prefix() {
17558 use tempfile::tempdir;
17559
17560 // (initial length, chunk length, appended length): a partial trailing
17561 // chunk, a chunk-aligned start, and the EA-boundary crossing.
17562 for (n, chunk, add) in [(6i32, 4u64, 5i32), (8, 2, 6), (50, 1, 200)] {
17563 let dir = tempdir().unwrap();
17564 let base = dir.path().join("base.h5");
17565 {
17566 use crate::writer::FileBuilder;
17567 let mut b = FileBuilder::new();
17568 b.create_dataset("d")
17569 .with_i32_data(&(0..n).collect::<Vec<i32>>())
17570 .with_shape(&[n as u64])
17571 .with_maxshape(&[u64::MAX])
17572 .with_chunks(&[chunk]);
17573 b.write(&base).unwrap();
17574 }
17575
17576 for max_phase in 1u8..=4 {
17577 let p = dir
17578 .path()
17579 .join(format!("crash_c_{n}_{chunk}_{max_phase}.h5"));
17580 append_stopped_at(&base, &p, n..n + add, max_phase);
17581 let expected_len = if max_phase == 4 { n + add } else { n };
17582 let pf = crate::reader::File::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17583 assert_eq!(
17584 pf.dataset("d").unwrap().read_i32().unwrap(),
17585 (0..expected_len).collect::<Vec<_>>(),
17586 "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk})"
17587 );
17588 }
17589 }
17590 }
17591
17592 /// Crash recovery across the phase-3/phase-4 gap.
17593 ///
17594 /// A writer that crashes after publishing the Extensible-Array element count
17595 /// (phase 3) but before publishing the dataspace dimension (phase 4) leaves
17596 /// the on-disk count ahead of the committed dimension. A fresh writer must
17597 /// roll forward from the *committed dimension*, overwriting the uncommitted
17598 /// slots, rather than appending past them and leaving a gap.
17599 ///
17600 /// The crashed and recovering appends deliberately write different values at
17601 /// the overlapping positions, so a regression that seeds the chunk count from
17602 /// the stale EA header surfaces the crashed writer's values rather than
17603 /// merely producing plausible-looking data.
17604 ///
17605 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
17606 /// surviving `recover_and_reappend_after_clean_phase4` covers only the clean
17607 /// case.
17608 #[test]
17609 fn append_inplace_recover_and_reappend_after_phase3_crash() {
17610 use crate::reader::File as PureFile;
17611 use tempfile::tempdir;
17612
17613 let dir = tempdir().unwrap();
17614 let path = dir.path().join("phase3_recover.h5");
17615 let n = 50i32;
17616 build_unit_chunked(&path, n);
17617
17618 // Writer 1 crashes after phase 3: the element count advances but the
17619 // dimension stays at `n`. Its values are far from the correct
17620 // continuation, so a leak is unmistakable.
17621 {
17622 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17623 s.append_inplace_i32_phased("d", &(1000..1200).collect::<Vec<_>>(), 3)
17624 .unwrap();
17625 }
17626 let committed: Vec<i32> = (0..n).collect();
17627 let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17628 assert_eq!(
17629 pf.dataset("d").unwrap().read_i32().unwrap(),
17630 committed,
17631 "phase-3 crash exposed uncommitted data to the pure reader"
17632 );
17633
17634 // Writer 2 recovers: roll forward from the committed dimension,
17635 // overwriting the uncommitted slots with the real continuation.
17636 {
17637 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17638 s.append_inplace_i32_phased("d", &(n..150).collect::<Vec<_>>(), 4)
17639 .unwrap();
17640 }
17641
17642 let expected: Vec<i32> = (0..150).collect();
17643 let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17644 assert_eq!(
17645 pf.dataset("d").unwrap().read_i32().unwrap(),
17646 expected,
17647 "recovery did not roll forward correctly (pure reader)"
17648 );
17649 }
17650
17651 #[test]
17652 fn raw_appendable_recurses_into_aggregates() {
17653 use crate::datatype::{CompoundMember, DatatypeByteOrder};
17654
17655 let f64_with = |byte_order| Datatype::FloatingPoint {
17656 size: 8,
17657 byte_order,
17658 bit_offset: 0,
17659 bit_precision: 64,
17660 exponent_location: 52,
17661 exponent_size: 11,
17662 mantissa_location: 0,
17663 mantissa_size: 52,
17664 exponent_bias: 1023,
17665 };
17666 let le_f64 = f64_with(DatatypeByteOrder::LittleEndian);
17667 let be_f64 = f64_with(DatatypeByteOrder::BigEndian);
17668
17669 // Little-endian scalar: appendable. Big-endian scalar: not.
17670 assert!(datatype_is_raw_appendable(&le_f64));
17671 assert!(!datatype_is_raw_appendable(&be_f64));
17672
17673 // The confirmed bug: a compound / array whose leaf is big-endian must be
17674 // refused (it was wrongly accepted before recursion was added).
17675 let be_member = Datatype::Compound {
17676 size: 8,
17677 members: vec![CompoundMember {
17678 name: "x".into(),
17679 byte_offset: 0,
17680 datatype: be_f64.clone(),
17681 }],
17682 };
17683 assert!(!datatype_is_raw_appendable(&be_member));
17684 let le_member = Datatype::Compound {
17685 size: 8,
17686 members: vec![CompoundMember {
17687 name: "x".into(),
17688 byte_offset: 0,
17689 datatype: le_f64.clone(),
17690 }],
17691 };
17692 assert!(datatype_is_raw_appendable(&le_member));
17693 assert!(!datatype_is_raw_appendable(&Datatype::Array {
17694 base_type: Box::new(be_f64.clone()),
17695 dimensions: vec![4],
17696 }));
17697
17698 // Variable-length / reference leaves are never raw-appendable, even LE.
17699 assert!(!datatype_is_raw_appendable(&Datatype::VariableLength {
17700 is_string: false,
17701 padding: None,
17702 charset: None,
17703 base_type: Box::new(le_f64.clone()),
17704 }));
17705 assert!(!datatype_is_raw_appendable(&Datatype::Reference {
17706 size: 8,
17707 ref_type: crate::datatype::ReferenceType::Object,
17708 }));
17709 }
17710
17711 #[test]
17712 fn fresh_group_region_pairs_link_info_with_group_info() {
17713 // A new-style group must carry both a Link Info and a Group Info message
17714 // (the C library requires the pair before it will insert a link).
17715 let types = region_types(&fresh_group_region());
17716 assert_eq!(types, vec![MessageType::LinkInfo, MessageType::GroupInfo]);
17717 }
17718
17719 #[test]
17720 fn ensure_group_info_appends_when_missing() {
17721 // A region with a Link Info message but no Group Info message (how older
17722 // hdf5-pure releases wrote groups) gains exactly one Group Info message.
17723 let li_body = {
17724 let mut b = vec![0u8, 0];
17725 b.extend_from_slice(&u64::MAX.to_le_bytes());
17726 b.extend_from_slice(&u64::MAX.to_le_bytes());
17727 b
17728 };
17729 let mut region = plain_region(message_record(MessageType::LinkInfo, &li_body));
17730 ensure_group_info(&mut region).unwrap();
17731 assert_eq!(
17732 region_types(®ion),
17733 vec![MessageType::LinkInfo, MessageType::GroupInfo]
17734 );
17735
17736 // The appended message decodes as a minimal Group Info body.
17737 let mut p = 0;
17738 while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17739 if mt == MessageType::GroupInfo {
17740 assert_eq!(®ion[body..end], &GROUP_INFO_BODY);
17741 }
17742 p = end;
17743 }
17744 }
17745
17746 /// A group header region that tracks link creation order: a Link Info
17747 /// message recording `max` as the next index it would hand out (with the
17748 /// creation-order *index* declared when `indexed`, as h5py's
17749 /// `track_order=True` does), a Group Info message, and one compact Link
17750 /// message per name in `links`, numbered from zero.
17751 fn link_tracked_region(max: u64, indexed: bool, links: &[&str]) -> OhRegion {
17752 let mut li = vec![0u8, if indexed { 0x03 } else { 0x01 }];
17753 li.extend_from_slice(&max.to_le_bytes());
17754 li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap: compact
17755 li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree name index
17756 if indexed {
17757 li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree creation order
17758 }
17759 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17760 region.push(MessageType::LinkInfo, &li);
17761 region.push(MessageType::GroupInfo, &GROUP_INFO_BODY);
17762 for (i, name) in links.iter().enumerate() {
17763 region.push_link(name, 0x100 + i as u64, Some(i as u64));
17764 }
17765 region
17766 }
17767
17768 /// Every Link message in `region`, as `(name, creation index)`.
17769 fn region_links(region: &OhRegion) -> Vec<(String, Option<u64>)> {
17770 let mut out = Vec::new();
17771 let mut p = 0;
17772 while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17773 if mt == MessageType::Link {
17774 let link = LinkMessage::parse(®ion[body..end], OFFSET_SIZE).unwrap();
17775 out.push((link.name, link.creation_order));
17776 }
17777 p = end;
17778 }
17779 out
17780 }
17781
17782 /// The maximum creation index `region`'s Link Info message records.
17783 fn recorded_link_max(region: &OhRegion) -> Option<u64> {
17784 find_link_info(region)
17785 .unwrap()
17786 .and_then(|(_, _, info)| info.max_creation_order)
17787 }
17788
17789 #[test]
17790 fn links_added_to_a_tracked_group_take_consecutive_creation_indexes() {
17791 // A group that has handed out three indexes numbers the next two links
17792 // 3 and 4, in the order they are placed, and its Link Info message ends
17793 // up recording 5 — the next index, not the highest in use.
17794 let mut region = link_tracked_region(3, false, &["a", "b", "c"]);
17795 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17796 region.push_link("d", 0x200, order.take().unwrap());
17797 region.push_link("e", 0x300, order.take().unwrap());
17798 order.record(&mut region).unwrap();
17799
17800 assert_eq!(
17801 region_links(®ion),
17802 vec![
17803 ("a".to_string(), Some(0)),
17804 ("b".to_string(), Some(1)),
17805 ("c".to_string(), Some(2)),
17806 ("d".to_string(), Some(3)),
17807 ("e".to_string(), Some(4)),
17808 ],
17809 );
17810 assert_eq!(recorded_link_max(®ion), Some(5));
17811 }
17812
17813 #[test]
17814 fn a_tracked_group_resumes_past_a_gap_a_deletion_left() {
17815 // Two links of three deleted, so the surviving link is index 1 and the
17816 // recorded maximum still says 3: the addition takes 3, not one of the
17817 // indexes the deletions freed.
17818 let region = link_tracked_region(3, false, &["a", "b", "c"]);
17819 let mut region = remove_link_from_region(®ion, "a").unwrap();
17820 region = remove_link_from_region(®ion, "c").unwrap();
17821 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17822 region.push_link("d", 0x200, order.take().unwrap());
17823 order.record(&mut region).unwrap();
17824
17825 assert_eq!(
17826 region_links(®ion),
17827 vec![("b".to_string(), Some(1)), ("d".to_string(), Some(3))],
17828 );
17829 assert_eq!(recorded_link_max(®ion), Some(4));
17830 }
17831
17832 #[test]
17833 fn a_creation_order_index_shifts_no_field_the_maximum_is_written_into() {
17834 // A group that also declares the creation-order *index* (h5py's
17835 // `track_order=True`) has a longer Link Info body with a third address
17836 // in it. The maximum still precedes every address, so the patch lands on
17837 // it and leaves the three addresses undefined.
17838 let mut region = link_tracked_region(1, true, &["a"]);
17839 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17840 region.push_link("b", 0x200, order.take().unwrap());
17841 order.record(&mut region).unwrap();
17842
17843 let (_, _, info) = find_link_info(®ion).unwrap().unwrap();
17844 assert_eq!(info.max_creation_order, Some(2));
17845 assert_eq!(info.fractal_heap_address, None);
17846 assert_eq!(info.btree_name_index_address, None);
17847 assert_eq!(info.btree_creation_order_address, None);
17848 assert_eq!(region_links(®ion).last().unwrap().1, Some(1));
17849 }
17850
17851 #[test]
17852 fn an_untracked_group_numbers_none_of_its_links() {
17853 // The groups this crate writes itself: no maximum recorded, so no link
17854 // carries a creation index and the Link Info message is left as it was.
17855 let mut region = fresh_group_region();
17856 let before = region.clone();
17857 let mut order = LinkCreationOrder::for_region(®ion).unwrap();
17858 assert_eq!(order.take().unwrap(), None);
17859 region.push_link("d", 0x200, None);
17860 order.record(&mut region).unwrap();
17861
17862 assert_eq!(region_links(®ion), vec![("d".to_string(), None)]);
17863 assert_eq!(recorded_link_max(®ion), None);
17864 // Only the appended link is new; every message that was there is
17865 // untouched.
17866 assert_eq!(®ion[..before.len()], &*before);
17867 }
17868
17869 #[test]
17870 fn a_tracked_group_that_gains_no_link_keeps_its_maximum_byte_for_byte() {
17871 // A commit that only deletes, or only edits attributes, must not bump
17872 // the counter — the gap it leaves is never handed out again.
17873 let region = link_tracked_region(3, false, &["a", "b", "c"]);
17874 let mut after = remove_link_from_region(®ion, "b").unwrap();
17875 let order = LinkCreationOrder::for_region(&after).unwrap();
17876 let before = after.clone();
17877 order.record(&mut after).unwrap();
17878 assert_eq!(*after, *before);
17879 assert_eq!(recorded_link_max(&after), Some(3));
17880 }
17881
17882 #[test]
17883 fn max_compact_links_reads_the_declared_threshold_or_the_library_default() {
17884 // A minimal Group Info message stores no link-phase-change values, so
17885 // the C library's default of 8 applies.
17886 assert_eq!(max_compact_links(&fresh_group_region()).unwrap(), 8);
17887
17888 // One that stores them (flags bit 0) declares its own maximum compact
17889 // value in the two bytes after the flags.
17890 let mut gi = vec![0u8, 0x01];
17891 gi.extend_from_slice(&4u16.to_le_bytes()); // max compact
17892 gi.extend_from_slice(&2u16.to_le_bytes()); // min dense
17893 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17894 region.push(MessageType::GroupInfo, &gi);
17895 assert_eq!(max_compact_links(®ion).unwrap(), 4);
17896 }
17897
17898 #[test]
17899 fn a_tracked_group_is_refused_only_where_the_addition_would_go_dense() {
17900 // Eight links is the default threshold, so a group holding seven takes
17901 // one more and is refused the second.
17902 let region = link_tracked_region(7, false, &["a", "b", "c", "d", "e", "f", "g"]);
17903 reject_dense_link_creation_order(®ion, 8).expect("eight links still store compactly");
17904 let err = reject_dense_link_creation_order(®ion, 9).unwrap_err();
17905 assert!(
17906 matches!(&err, Error::EditUnsupported(m) if m.contains("link creation order")
17907 && m.contains("dense")),
17908 "got: {err}",
17909 );
17910
17911 // A group that does not track the order is not screened at all: this
17912 // crate writes more than eight compact links routinely.
17913 let mut untracked = fresh_group_region();
17914 untracked.push_link("a", 0x100, None);
17915 reject_dense_link_creation_order(&untracked, 99)
17916 .expect("an untracked group has no creation order to lose");
17917 }
17918
17919 #[test]
17920 fn ensure_group_info_is_idempotent() {
17921 // A region that already has a Group Info message is left untouched, so
17922 // re-editing a healed (or C-written) group does not duplicate it.
17923 let mut region = fresh_group_region();
17924 let before = region.clone();
17925 ensure_group_info(&mut region).unwrap();
17926 assert_eq!(*region, *before);
17927 }
17928
17929 // ---- shared (SOHM) messages (issue #417) ----
17930
17931 /// A shared-message index record naming an object header is a stored address
17932 /// like any other, and the one no repoint walk reaches: it lives outside
17933 /// every object a commit rebuilds. A commit that removes or moves that header
17934 /// is refused rather than leaving the index naming bytes that have gone.
17935 #[test]
17936 fn a_commit_that_strands_a_shared_message_record_is_refused() {
17937 let named = crate::sohm::SohmRecord {
17938 hash: 1,
17939 location: crate::sohm::SohmLocation::ObjectHeader {
17940 message_type: MessageType::Attribute.to_u16() as u8,
17941 creation_index: 0,
17942 address: 0x400,
17943 },
17944 };
17945 let moved = InvalidatedAddresses {
17946 removed: Vec::new(),
17947 moved: vec![0x400],
17948 base: BaseAddress::ZERO,
17949 };
17950 let err = screen_shared_message_records(std::slice::from_ref(&named), &moved).unwrap_err();
17951 assert_eq!(
17952 err.to_string(),
17953 Error::EditUnsupported(SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT).to_string()
17954 );
17955
17956 let elsewhere = InvalidatedAddresses {
17957 removed: Vec::new(),
17958 moved: vec![0x800],
17959 base: BaseAddress::ZERO,
17960 };
17961 screen_shared_message_records(std::slice::from_ref(&named), &elsewhere).unwrap();
17962 }
17963
17964 /// A heap-stored record names no object header, so no address of it can go
17965 /// stale and the screen passes it. What a commit can spoil about one is its
17966 /// reference count, which the attribute paths refuse to change instead.
17967 #[test]
17968 fn a_heap_stored_shared_message_record_is_not_screened_for_addresses() {
17969 let heap = crate::sohm::SohmRecord {
17970 hash: 1,
17971 location: crate::sohm::SohmLocation::Heap {
17972 reference_count: 2,
17973 // The same eight bytes the address screen would refuse if it read
17974 // them as one.
17975 heap_id: 0x400u64.to_le_bytes(),
17976 },
17977 };
17978 let moved = InvalidatedAddresses {
17979 removed: Vec::new(),
17980 moved: vec![0x400],
17981 base: BaseAddress::ZERO,
17982 };
17983 screen_shared_message_records(&[heap], &moved).unwrap();
17984 }
17985
17986 /// A version 1 reference to a committed object becomes the version 2 form
17987 /// this crate writes everywhere else, naming the same address: the whole
17988 /// point of rewrapping is that the message still resolves to one copy.
17989 #[test]
17990 fn a_version_1_reference_is_rewrapped_as_the_modern_committed_form() {
17991 let mut v1 = vec![1u8, 0];
17992 v1.extend_from_slice(&[0u8; 6]); // reserved
17993 v1.extend_from_slice(&0x1111u64.to_le_bytes()); // local heap address
17994 v1.extend_from_slice(&0x5678u64.to_le_bytes()); // object header address
17995
17996 let modern = modernize_shared_reference(&v1, OFFSET_SIZE, LENGTH_SIZE).unwrap();
17997 assert_eq!(modern[0], 2, "the version this crate writes");
17998 assert_eq!(
17999 crate::shared_message::parse_shared_ref(&modern, OFFSET_SIZE, LENGTH_SIZE)
18000 .unwrap()
18001 .location,
18002 crate::shared_message::SharedLocation::ObjectHeader(0x5678)
18003 );
18004 }
18005
18006 /// A reference into the shared-message heap keeps its heap ID and the only
18007 /// version that encodes one. Re-encoding it as a committed reference would
18008 /// turn eight bytes of heap ID into a file address.
18009 #[test]
18010 fn a_heap_reference_is_rewrapped_as_a_heap_reference() {
18011 let mut v3 = vec![3u8, 1];
18012 v3.extend_from_slice(&[9, 8, 7, 6, 5, 4, 3, 2]);
18013
18014 let modern = modernize_shared_reference(&v3, OFFSET_SIZE, LENGTH_SIZE).unwrap();
18015 assert_eq!(modern, v3);
18016 }
18017
18018 /// The rewrapped record keeps the shared flag. Without it every reader
18019 /// decodes the reference as the attribute it stands for.
18020 #[test]
18021 fn a_rewrapped_shared_message_keeps_its_flag() {
18022 let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
18023 region.push_shared(MessageType::Attribute, &[3, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
18024 assert_eq!(region[3], MSG_FLAG_SHARED);
18025 assert!(region_has_shared_attr(®ion).unwrap());
18026 }
18027
18028 #[test]
18029 fn reject_foreign_addresses_refuses_any_shared_message() {
18030 // A shared (SOHM) message of *any* type — here a Dataspace — stores a
18031 // source-file reference in place of its body, so a verbatim cross-file
18032 // copy must refuse it, not only shared datatypes/attributes. (A plain,
18033 // non-shared dataspace embeds no foreign address and is accepted.)
18034 let mut shared = message_record(MessageType::Dataspace, &[0u8; 8]);
18035 shared[3] = MSG_FLAG_SHARED; // set the message's shared flag
18036 let err = reject_foreign_addresses(&plain_region(shared)).unwrap_err();
18037 assert!(err.to_string().contains("shared"), "got: {err}");
18038
18039 let plain = message_record(MessageType::Dataspace, &[0u8; 8]);
18040 reject_foreign_addresses(&plain_region(plain)).unwrap();
18041 }
18042
18043 // ---- version 2 object-header record layout (issue #416) ----
18044
18045 /// Version-2 object-header message flag bit marking a message as one that
18046 /// must not be shared. The reference C library sets it on the Attribute
18047 /// Info message it writes, so a rewrite of that message has to keep it.
18048 const MSG_FLAG_DONT_SHARE: u8 = 0x04;
18049
18050 /// The layout of a header that tracks *and* indexes attribute creation
18051 /// order: object-header flag bits 2 and 3, and 6-byte message records.
18052 const TRACKED: OhRecordLayout = OhRecordLayout::from_header_flags(0x0C);
18053
18054 /// One compact Attribute message body for `name`.
18055 fn attr_body(name: &str, value: i64) -> Vec<u8> {
18056 crate::type_builders::build_attr_message(name, &AttrValue::I64(value))
18057 .serialize(LENGTH_SIZE)
18058 }
18059
18060 /// [`attr_region_in`] for a header carrying neither optional prefix block.
18061 fn attr_region(layout: OhRecordLayout, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18062 attr_region_in(OhHeaderProps::with_layout(layout), attrs, max)
18063 }
18064
18065 /// A region belonging to a header with `props`, holding one Attribute message
18066 /// per `(name, creation index)`, preceded by the Attribute Info message a
18067 /// tracked object carries.
18068 fn attr_region_in(props: OhHeaderProps, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18069 let layout = props.layout;
18070 let mut region = OhRegion::empty(props);
18071 let info = AttributeInfoMessage {
18072 max_creation_index: layout.tracks_creation_order().then_some(max),
18073 indexes_creation_order: layout.indexes_creation_order(),
18074 fractal_heap_address: None,
18075 btree_name_index_address: None,
18076 btree_creation_order_address: None,
18077 };
18078 let mut record = layout.record(MessageType::AttributeInfo, &info.serialize(OFFSET_SIZE));
18079 record[3] = MSG_FLAG_DONT_SHARE;
18080 region.push_bytes(&record);
18081 for (name, index) in attrs {
18082 let record = layout.record_with_creation_index(
18083 MessageType::Attribute,
18084 &attr_body(name, 1),
18085 *index,
18086 );
18087 region.push_bytes(&record);
18088 }
18089 region
18090 }
18091
18092 /// The attribute names a region carries, in the order its records hold them,
18093 /// each with the creation index its record declares.
18094 fn walk_attrs(region: &OhRegion) -> Vec<(String, Option<u16>)> {
18095 let mut out = Vec::new();
18096 let mut p = 0;
18097 while let Some((msg_type, body, body_end)) = region.next_message(p).unwrap() {
18098 if msg_type == MessageType::Attribute {
18099 let name = parse_compact_attr_name(region, p, body, body_end).unwrap();
18100 out.push((name, region.creation_index(p)));
18101 }
18102 p = body_end;
18103 }
18104 out
18105 }
18106
18107 /// The message walk steps by the width the header's flags declare: four
18108 /// bytes of record prefix without creation-order tracking, six with it.
18109 #[test]
18110 fn a_record_walk_steps_by_the_width_the_header_declares() {
18111 for layout in [OhRecordLayout::PLAIN, TRACKED] {
18112 let region = attr_region(layout, &[("alpha", 0), ("beta", 7)], 8);
18113 assert_eq!(
18114 walk_attrs(®ion),
18115 vec![
18116 (
18117 "alpha".to_string(),
18118 layout.tracks_creation_order().then_some(0)
18119 ),
18120 (
18121 "beta".to_string(),
18122 layout.tracks_creation_order().then_some(7)
18123 ),
18124 ],
18125 "layout {layout:?}",
18126 );
18127 }
18128
18129 // Non-vacuity: the two widths are not interchangeable. Reading a tracked
18130 // region four bytes at a time misparses it from the first record on,
18131 // which is the defect this width exists to prevent.
18132 let tracked = attr_region(TRACKED, &[("alpha", 0), ("beta", 7)], 8);
18133 let misread = OhRegion::new(tracked.to_vec(), OhHeaderProps::PLAIN);
18134 let mut p = 0;
18135 let mut agreed = true;
18136 while let Ok(Some((_, _, body_end))) = misread.next_message(p) {
18137 p = body_end;
18138 }
18139 agreed &= p == tracked.len();
18140 assert!(
18141 !agreed,
18142 "a four-byte walk of a six-byte-record region must not come out even"
18143 );
18144 }
18145
18146 /// An emitted record carries the creation index only where the layout has
18147 /// the field, and the flags byte stays where every reader looks for it.
18148 #[test]
18149 fn an_emitted_record_carries_a_creation_index_only_where_the_header_tracks_one() {
18150 let body = [0xABu8; 5];
18151 let plain =
18152 OhRecordLayout::PLAIN.record_with_creation_index(MessageType::Attribute, &body, 9);
18153 assert_eq!(plain.len(), 4 + body.len());
18154 assert_eq!(&plain[4..], &body);
18155
18156 let tracked = TRACKED.record_with_creation_index(MessageType::Attribute, &body, 0x1234);
18157 assert_eq!(tracked.len(), 6 + body.len());
18158 assert_eq!(
18159 &tracked[..4],
18160 &plain[..4],
18161 "type, size and flags are shared"
18162 );
18163 assert_eq!(&tracked[4..6], &0x1234u16.to_le_bytes());
18164 assert_eq!(&tracked[6..], &body);
18165
18166 // Every non-attribute message the reference C library writes carries a
18167 // zero creation index, which is what the plain emitter passes.
18168 let group_info = TRACKED.record(MessageType::GroupInfo, &body);
18169 assert_eq!(&group_info[4..6], &0u16.to_le_bytes());
18170 }
18171
18172 /// A rebuilt header declares the record layout its bytes are written in, so
18173 /// re-reading it walks them the same way.
18174 #[test]
18175 fn a_rebuilt_header_declares_the_record_layout_it_used() {
18176 for layout in [OhRecordLayout::PLAIN, TRACKED] {
18177 let region = attr_region(layout, &[("alpha", 3)], 4);
18178 let header = build_v2_object_header(®ion).unwrap();
18179 let (start, end, read_back) =
18180 oh_region_at(&header, 0, header.len() as u64).expect("the header parses");
18181 assert_eq!(
18182 read_back,
18183 OhHeaderProps::with_layout(layout),
18184 "the flags lost the layout"
18185 );
18186 let round_tripped =
18187 OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18188 assert_eq!(
18189 walk_attrs(&round_tripped),
18190 walk_attrs(®ion),
18191 "layout {layout:?}",
18192 );
18193 }
18194 }
18195
18196 // ---- the optional prefix blocks of a version 2 object header (PR #422) ----
18197
18198 /// Timestamps distinct enough that a field read at the wrong offset — or one
18199 /// copied from a neighbour — shows up as a different number.
18200 const FIXTURE_TIMES: ObjectTimes = ObjectTimes {
18201 access: 0x1111_1111,
18202 modification: 0x2222_2222,
18203 change: 0x3333_3333,
18204 birth: 0x4444_4444,
18205 };
18206
18207 /// A phase-change pair the reference C library would never write by default
18208 /// (its defaults are 8 and 6), so a rebuild that dropped the block and a
18209 /// rebuild that substituted the defaults both fail.
18210 const FIXTURE_PHASE: AttrPhaseChange = AttrPhaseChange {
18211 max_compact: 32,
18212 min_dense: 24,
18213 };
18214
18215 /// Hand-assemble the version 2 object header `props` describes around
18216 /// `region`'s bytes, with a 1-byte chunk-0 size field.
18217 ///
18218 /// Written out here rather than taken from [`build_v2_object_header`] so the
18219 /// parse is checked against bytes the emitter did not produce: an emitter and
18220 /// a parser that agreed on a wrong prefix layout would round-trip perfectly.
18221 fn v2_header_bytes(props: OhHeaderProps, region: &OhRegion) -> Vec<u8> {
18222 let mut buf = Vec::new();
18223 buf.extend_from_slice(b"OHDR");
18224 buf.push(2); // version
18225 buf.push(props.header_flags()); // size flags 0: a 1-byte length field
18226 if let Some(times) = props.times {
18227 buf.extend_from_slice(×.to_bytes());
18228 }
18229 if let Some(phase) = props.attr_phase_change {
18230 buf.extend_from_slice(&phase.to_bytes());
18231 }
18232 buf.push(u8::try_from(region.len()).expect("the fixture region is under 256 bytes"));
18233 buf.extend_from_slice(region);
18234 let checksum = jenkins_lookup3(&buf);
18235 buf.extend_from_slice(&checksum.to_le_bytes());
18236 buf
18237 }
18238
18239 /// Every combination of the two optional prefix blocks and the record layout:
18240 /// the parse finds the message region, and a rebuild puts both blocks back
18241 /// with the flag bits that announce them.
18242 ///
18243 /// A dropped block is not a lost field alone — the chunk-0 size field sits
18244 /// *after* both, so the message walk is checked too.
18245 #[test]
18246 fn a_headers_optional_prefix_blocks_survive_a_rebuild() {
18247 for layout in [OhRecordLayout::PLAIN, TRACKED] {
18248 for times in [None, Some(FIXTURE_TIMES)] {
18249 for attr_phase_change in [None, Some(FIXTURE_PHASE)] {
18250 let props = OhHeaderProps {
18251 layout,
18252 times,
18253 attr_phase_change,
18254 };
18255 let region = attr_region_in(props, &[("alpha", 3), ("beta", 5)], 6);
18256 let header = v2_header_bytes(props, ®ion);
18257
18258 let (start, end, read_back) = oh_region_at(&header, 0, header.len() as u64)
18259 .expect("the hand-built header parses");
18260 assert_eq!(read_back, props, "the prefix lost a block");
18261 let parsed =
18262 OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18263 assert_eq!(
18264 walk_attrs(&parsed),
18265 walk_attrs(®ion),
18266 "the message region was located wrongly for {props:?}",
18267 );
18268
18269 let rebuilt = build_v2_object_header(&parsed).unwrap();
18270 let (_, _, again) = oh_region_at(&rebuilt, 0, rebuilt.len() as u64)
18271 .expect("the rebuilt header parses");
18272 assert_eq!(
18273 again.attr_phase_change, attr_phase_change,
18274 "the rebuild lost the attribute phase-change thresholds",
18275 );
18276 assert_eq!(
18277 again.times.is_some(),
18278 times.is_some(),
18279 "the rebuild changed whether the header stores times",
18280 );
18281 assert_eq!(again.layout, layout, "the rebuild lost the record layout");
18282 }
18283 }
18284 }
18285 }
18286
18287 /// The flag bits the emitter sets are the ones the format assigns: bit 4 for
18288 /// the phase-change block, bit 5 for the timestamps.
18289 #[test]
18290 fn the_optional_blocks_are_announced_by_their_own_flag_bits() {
18291 let with_times = OhHeaderProps {
18292 times: Some(FIXTURE_TIMES),
18293 ..OhHeaderProps::PLAIN
18294 };
18295 let with_phase = OhHeaderProps {
18296 attr_phase_change: Some(FIXTURE_PHASE),
18297 ..OhHeaderProps::PLAIN
18298 };
18299 assert_eq!(OhHeaderProps::PLAIN.header_flags(), 0);
18300 assert_eq!(with_times.header_flags(), 0x20);
18301 assert_eq!(with_phase.header_flags(), 0x10);
18302 assert_eq!(OhHeaderProps::PLAIN.optional_len(), 0);
18303 assert_eq!(with_times.optional_len(), 16);
18304 assert_eq!(with_phase.optional_len(), 4);
18305 }
18306
18307 /// A rebuild of a header that stores times moves the modification and change
18308 /// times to now and leaves the access and birth times alone.
18309 #[cfg(feature = "std")]
18310 #[test]
18311 fn a_rebuild_stamps_the_modification_and_change_times() {
18312 let props = OhHeaderProps {
18313 times: Some(FIXTURE_TIMES),
18314 ..OhHeaderProps::PLAIN
18315 };
18316 let region = attr_region_in(props, &[("alpha", 0)], 1);
18317
18318 let before = unix_time_now().expect("a std test build reads the clock");
18319 let header = build_v2_object_header(®ion).unwrap();
18320 let after = unix_time_now().expect("a std test build reads the clock");
18321
18322 let (_, _, read_back) = oh_region_at(&header, 0, header.len() as u64).unwrap();
18323 let times = read_back.times.expect("the header still stores times");
18324 assert_eq!(
18325 (times.access, times.birth),
18326 (FIXTURE_TIMES.access, FIXTURE_TIMES.birth),
18327 "a rewrite is not an access and not a birth",
18328 );
18329 assert!(
18330 (before..=after).contains(×.modification),
18331 "the modification time {} is outside [{before}, {after}]",
18332 times.modification,
18333 );
18334 assert!(
18335 (before..=after).contains(×.change),
18336 "the change time {} is outside [{before}, {after}]",
18337 times.change,
18338 );
18339 }
18340
18341 /// A prefix that declares a block it does not carry is a malformed file, not
18342 /// a panic: the parse reads only what the buffer holds.
18343 #[test]
18344 fn a_prefix_truncated_inside_an_optional_block_is_refused() {
18345 let props = OhHeaderProps {
18346 times: Some(FIXTURE_TIMES),
18347 attr_phase_change: Some(FIXTURE_PHASE),
18348 ..OhHeaderProps::PLAIN
18349 };
18350 let region = attr_region_in(props, &[("alpha", 0)], 1);
18351 let header = v2_header_bytes(props, ®ion);
18352 // Every prefix length short of the chunk-0 size field, which is the last
18353 // thing the parse reads: 6 bytes of signature and flags, then 20 bytes of
18354 // optional blocks.
18355 for cut in 6..6 + 16 + 4 + 1 {
18356 assert!(
18357 oh_region_at(&header[..cut], 0, header.len() as u64).is_err(),
18358 "a {cut}-byte prefix must not parse as a whole header",
18359 );
18360 }
18361 }
18362
18363 /// A fresh attribute takes the object's next unused creation index, and the
18364 /// Attribute Info message records the one after it — the counter the
18365 /// reference C library hands out from.
18366 #[test]
18367 fn a_new_compact_attribute_takes_the_next_creation_index() {
18368 let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18369 let out = set_attr_in_region(®ion, "gamma", &AttrValue::I64(5)).unwrap();
18370 assert_eq!(
18371 walk_attrs(&out),
18372 vec![
18373 ("alpha".to_string(), Some(0)),
18374 ("beta".to_string(), Some(1)),
18375 ("gamma".to_string(), Some(2)),
18376 ],
18377 );
18378 let (record, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18379 assert_eq!(info.max_creation_index, Some(3));
18380 assert!(
18381 info.indexes_creation_order,
18382 "the index flag survives a rewrite"
18383 );
18384 assert_eq!(
18385 out[record + 3],
18386 MSG_FLAG_DONT_SHARE,
18387 "the rewritten record lost the message flags it carried"
18388 );
18389 }
18390
18391 /// Overwriting an attribute keeps the creation index it had, so an
18392 /// iteration by creation order does not reorder it.
18393 #[test]
18394 fn an_overwritten_compact_attribute_keeps_its_creation_index() {
18395 let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18396 assert_eq!(attr_creation_index(®ion, "alpha").unwrap(), Some(0));
18397 let out = set_attr_in_region(®ion, "alpha", &AttrValue::I64(9)).unwrap();
18398 // The rewritten message moves to the end of the region — the editor
18399 // rebuilds rather than patches — but its creation index does not move.
18400 assert_eq!(
18401 walk_attrs(&out),
18402 vec![
18403 ("beta".to_string(), Some(1)),
18404 ("alpha".to_string(), Some(0))
18405 ],
18406 );
18407 let (_, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18408 assert_eq!(
18409 info.max_creation_index,
18410 Some(2),
18411 "an overwrite must not advance the counter"
18412 );
18413 }
18414
18415 /// A deletion leaves a gap and does not lower the recorded maximum, so the
18416 /// next attribute created cannot land in the middle of the order.
18417 #[test]
18418 fn a_deletion_leaves_the_creation_index_counter_where_it_was() {
18419 let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1), ("gamma", 2)], 3);
18420 let after = remove_attr_from_region(®ion, "gamma", true).unwrap();
18421 assert_eq!(
18422 walk_attrs(&after),
18423 vec![
18424 ("alpha".to_string(), Some(0)),
18425 ("beta".to_string(), Some(1))
18426 ],
18427 );
18428 assert_eq!(
18429 next_attr_creation_index(&after).unwrap(),
18430 3,
18431 "the highest index still in use is 1, but the counter is 3"
18432 );
18433
18434 let out = put_attr_message(&after, "delta", &attr_body("delta", 1), None).unwrap();
18435 assert_eq!(attr_creation_index(&out, "delta").unwrap(), Some(3));
18436 }
18437
18438 /// A header that tracks the order but carries no Attribute Info message —
18439 /// which nothing this crate writes, and nothing the C library writes, but
18440 /// which the format permits — gets one recording a counter derived from the
18441 /// records themselves.
18442 #[test]
18443 fn a_tracked_header_without_an_info_message_gains_one() {
18444 let mut region = OhRegion::empty(OhHeaderProps::with_layout(TRACKED));
18445 for (name, index) in [("alpha", 0u16), ("beta", 4)] {
18446 let record = TRACKED.record_with_creation_index(
18447 MessageType::Attribute,
18448 &attr_body(name, 1),
18449 index,
18450 );
18451 region.push_bytes(&record);
18452 }
18453 assert!(find_attribute_info(®ion).unwrap().is_none());
18454 ensure_attribute_info(&mut region).unwrap();
18455 let (_, _, info) = find_attribute_info(®ion)
18456 .unwrap()
18457 .expect("an info message");
18458 assert_eq!(info.max_creation_index, Some(5));
18459 assert!(info.indexes_creation_order);
18460 }
18461
18462 /// Build a compact data-layout message body: version, class=0, 2-byte inline
18463 /// size, then the data.
18464 fn compact_layout_body(version: u8, data: &[u8]) -> Vec<u8> {
18465 let mut b = vec![version, 0];
18466 b.extend_from_slice(&(data.len() as u16).to_le_bytes());
18467 b.extend_from_slice(data);
18468 b
18469 }
18470
18471 #[test]
18472 fn rebuild_compact_layout_replaces_inline_data_only() {
18473 // A region with a Dataspace message, a compact Data Layout, and a trailing
18474 // Attribute message: rewriting the inline data must replace exactly the
18475 // layout's bytes and leave every other message verbatim.
18476 let mut region = message_record(MessageType::Dataspace, &[0xAB; 8]);
18477 region.extend_from_slice(&message_record(
18478 MessageType::DataLayout,
18479 &compact_layout_body(3, &[1, 2, 3, 4]),
18480 ));
18481 region.extend_from_slice(&message_record(MessageType::Attribute, &[0xCD; 5]));
18482
18483 let out = rebuild_compact_layout_region(&plain_region(region), &[9, 8, 7, 6]).unwrap();
18484
18485 // Same messages in the same order; only the layout's inline data changed.
18486 assert_eq!(
18487 region_types(&out),
18488 vec![
18489 MessageType::Dataspace,
18490 MessageType::DataLayout,
18491 MessageType::Attribute,
18492 ]
18493 );
18494 let mut p = 0;
18495 while let Some((mt, body, end)) = out.next_message(p).unwrap() {
18496 match mt {
18497 MessageType::Dataspace => assert_eq!(&out[body..end], &[0xAB; 8]),
18498 MessageType::DataLayout => {
18499 assert_eq!(out[body], 3, "version preserved");
18500 assert_eq!(out[body + 1], 0, "still compact");
18501 let size = u16::from_le_bytes([out[body + 2], out[body + 3]]) as usize;
18502 assert_eq!(size, 4);
18503 assert_eq!(&out[body + 4..body + 4 + size], &[9, 8, 7, 6]);
18504 }
18505 MessageType::Attribute => assert_eq!(&out[body..end], &[0xCD; 5]),
18506 other => panic!("unexpected message {other:?}"),
18507 }
18508 p = end;
18509 }
18510 }
18511
18512 #[test]
18513 fn rebuild_compact_layout_refuses_non_compact() {
18514 // A contiguous (class 1) data layout is not compact, so the rebuild refuses
18515 // rather than corrupt it.
18516 let mut region = message_record(MessageType::DataLayout, &{
18517 let mut b = vec![3u8, 1]; // version 3, class 1 (contiguous)
18518 b.extend_from_slice(&0u64.to_le_bytes());
18519 b.extend_from_slice(&0u64.to_le_bytes());
18520 b
18521 });
18522 region.extend_from_slice(&message_record(MessageType::Dataspace, &[0; 8]));
18523 let err = rebuild_compact_layout_region(&plain_region(region), &[1, 2]).unwrap_err();
18524 assert!(err.to_string().contains("non-compact"), "got: {err}");
18525 }
18526
18527 #[test]
18528 fn a_refused_open_never_builds_the_image() {
18529 // The image is what may cost `O(file size)` — the mirror reads the whole
18530 // file — so every refusal has to come first. Asserting on the build
18531 // closure states that directly; measuring memory or elapsed time would
18532 // only correlate with it.
18533 use crate::writer::FileBuilder;
18534
18535 let dir = tempfile::tempdir().unwrap();
18536
18537 // One refusal from each family: the superblock's status flags (issue
18538 // #245), and an unsupported superblock version, which has always been
18539 // refused after the read this reorders.
18540 let flagged = dir.path().join("flagged.h5");
18541 let mut b = FileBuilder::new();
18542 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18543 b.write(&flagged).unwrap();
18544 let ancient = dir.path().join("ancient.h5");
18545 std::fs::copy(&flagged, &ancient).unwrap();
18546 for (path, version, flags) in [(&flagged, 3, SWMR_WRITE_FLAGS), (&ancient, 9, 0)] {
18547 let mut data = std::fs::read(path).unwrap();
18548 let off = signature::find_signature(&data).unwrap();
18549 let mut sb = Superblock::parse(&data, off).unwrap();
18550 sb.version = version;
18551 sb.consistency_flags = flags;
18552 let bytes = sb.serialize();
18553 data[off..off + bytes.len()].copy_from_slice(&bytes);
18554 std::fs::write(path, &data).unwrap();
18555 }
18556
18557 for path in [&flagged, &ancient] {
18558 let built = std::cell::Cell::new(false);
18559 let err = match WriteEngine::open_imaged(path, Some(FileLocking::Enabled), |h, len| {
18560 built.set(true);
18561 Ok(Box::new(HandleImage::new(
18562 h,
18563 len,
18564 MetadataCacheConfig::disabled(),
18565 )))
18566 }) {
18567 Err(e) => e,
18568 Ok(_) => panic!("{} must be refused", path.display()),
18569 };
18570 assert!(
18571 !built.get(),
18572 "{} was refused with {err:?}, but the image was built first",
18573 path.display()
18574 );
18575 }
18576 }
18577
18578 #[test]
18579 fn a_stale_consistency_flag_is_refused_then_cleared_by_a_commit() {
18580 // A v3 file a crashed SWMR writer left flagged is refused by the editor
18581 // (issue #245) rather than edited under a writer the file still records.
18582 // On a v2 file, where the check is gated off to match the C library, the
18583 // editor opens — and the commit clears the stale flag rather than
18584 // re-emitting it, so the file stays properly closed for the C library
18585 // (issue #73).
18586 use crate::writer::FileBuilder;
18587
18588 let dir = tempfile::tempdir().unwrap();
18589 let path = dir.path().join("stale_flag.h5");
18590
18591 let mut b = FileBuilder::new();
18592 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18593 b.write(&path).unwrap();
18594
18595 // Simulate a crashed SWMR writer by stamping the on-disk write+SWMR flag
18596 // (0x05) into the superblock, recomputing its checksum.
18597 {
18598 let mut data = std::fs::read(&path).unwrap();
18599 let off = signature::find_signature(&data).unwrap();
18600 let mut sb = Superblock::parse(&data, off).unwrap();
18601 assert!(
18602 sb.version >= 2,
18603 "FileBuilder should emit a v2/v3 superblock"
18604 );
18605 sb.consistency_flags = 0x05;
18606 let bytes = sb.serialize();
18607 data[off..off + bytes.len()].copy_from_slice(&bytes);
18608 std::fs::write(&path, &data).unwrap();
18609 // Sanity: the stale flag is really set on disk now.
18610 assert_eq!(
18611 Superblock::parse(&data, off).unwrap().consistency_flags,
18612 0x05
18613 );
18614 }
18615
18616 // The editor refuses it while the flag stands.
18617 match WriteEngine::open_with_locking(&path, FileLocking::Enabled) {
18618 Err(Error::FileMarkedInUse(_)) => {}
18619 Err(e) => panic!("expected the flag refusal, got {e:?}"),
18620 Ok(_) => panic!("a flagged file must not be edited in place"),
18621 }
18622
18623 // The flag survives a *version-2* superblock, where the check is gated
18624 // off to match the C library — which is the one state that still carries
18625 // a stale flag into a commit, and so the one that keeps the healing below
18626 // load-bearing. (v2 and v3 superblocks share a byte layout, so restamping
18627 // the version is the whole difference.) A crashed C writer leaves plain
18628 // write access, without the SWMR bit.
18629 {
18630 let mut data = std::fs::read(&path).unwrap();
18631 let off = signature::find_signature(&data).unwrap();
18632 let mut sb = Superblock::parse(&data, off).unwrap();
18633 sb.version = 2;
18634 sb.consistency_flags = crate::file_lock::WRITE_ACCESS;
18635 let bytes = sb.serialize();
18636 data[off..off + bytes.len()].copy_from_slice(&bytes);
18637 std::fs::write(&path, &data).unwrap();
18638 }
18639
18640 // A clean edit-and-commit cycle heals it.
18641 {
18642 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18643 .expect("the gate skips a v2 superblock, so this opens");
18644 let mut b = DatasetBuilder::new("e");
18645 b.with_i32_data(&[4, 5]);
18646 s.stage_created_dataset("e", b).unwrap();
18647 s.commit().unwrap();
18648 }
18649
18650 let data = std::fs::read(&path).unwrap();
18651 let off = signature::find_signature(&data).unwrap();
18652 assert_eq!(
18653 Superblock::parse(&data, off).unwrap().consistency_flags,
18654 0,
18655 "commit must clear the stale consistency flag"
18656 );
18657 }
18658
18659 #[test]
18660 fn add_vlen_string_dataset_with_null_elements_via_edit_session() {
18661 // Regression test for a silent-corruption bug (issue #105): a
18662 // VL-string dataset added via the in-place edit engine used to commit `Ok(())`
18663 // without ever writing its global heap collection or patching its
18664 // placeholder references, so the dataset failed to read back. A null
18665 // element (no heap object at all, distinct from an empty string) must
18666 // stay untouched by the patch — only heap-backed elements'
18667 // placeholder addresses are resolved; exercising both keeps the mask
18668 // itself, not just the common all-`Bytes` case, under test.
18669 use crate::type_builders::VlStringElement;
18670 use crate::writer::FileBuilder;
18671
18672 let dir = tempfile::tempdir().unwrap();
18673 let path = dir.path().join("vlen_null.h5");
18674
18675 let mut b = FileBuilder::new();
18676 b.create_dataset("seed").with_i32_data(&[0]);
18677 b.write(&path).unwrap();
18678
18679 let datatype =
18680 crate::type_builders::make_vlen_string_type(crate::datatype::CharacterSet::Utf8);
18681 let elements = vec![
18682 VlStringElement::Bytes(b"alpha".to_vec()),
18683 VlStringElement::Null,
18684 VlStringElement::Bytes(b"gamma".to_vec()),
18685 ];
18686
18687 {
18688 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
18689 let mut b = DatasetBuilder::new("labels");
18690 b.with_vlen_string_elements(datatype, &elements).unwrap();
18691 s.stage_created_dataset("labels", b).unwrap();
18692 s.commit().unwrap();
18693 }
18694
18695 let file = crate::reader::File::open(&path).unwrap();
18696 let ds = file.dataset("labels").unwrap();
18697 assert_eq!(
18698 ds.read_string().unwrap(),
18699 vec!["alpha".to_string(), String::new(), "gamma".to_string()]
18700 );
18701 }
18702
18703 #[test]
18704 fn edit_session_root_group_base_address_overflow_is_rejected() {
18705 // The edit-path sibling of issue #137. A userblock file has a nonzero base
18706 // address that `WriteEngine::open` adds to the stored root-group address.
18707 // A crafted address of HADDR_UNDEF must be rejected rather than overflow
18708 // (panicking in debug, wrapping in release).
18709 use crate::writer::FileBuilder;
18710
18711 let dir = tempfile::tempdir().unwrap();
18712 let path = dir.path().join("edit_root_overflow.h5");
18713
18714 const UB: u64 = 512;
18715 let mut b = FileBuilder::new();
18716 b.with_userblock(UB);
18717 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18718 b.write(&path).unwrap();
18719
18720 // Rewrite the stored (base-relative) root-group address to HADDR_UNDEF,
18721 // recomputing the superblock checksum via `serialize`. The base address
18722 // still equals the superblock offset, so the file stays editable and the
18723 // editor reaches the `root_group_address + base` normalization.
18724 let mut data = std::fs::read(&path).unwrap();
18725 let off = signature::find_signature(&data).unwrap();
18726 let mut sb = Superblock::parse(&data, off).unwrap();
18727 assert_eq!(
18728 sb.base_address,
18729 BaseAddress::new(UB),
18730 "userblock file must have base == UB"
18731 );
18732 sb.root_group_address = u64::MAX;
18733 let bytes = sb.serialize();
18734 data[off..off + bytes.len()].copy_from_slice(&bytes);
18735 std::fs::write(&path, &data).unwrap();
18736
18737 let err = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18738 .err()
18739 .expect("open must fail");
18740 match err {
18741 Error::Format(FormatError::OffsetOverflow { offset, length }) => {
18742 assert_eq!(offset, u64::MAX);
18743 assert_eq!(length, UB);
18744 }
18745 other => panic!("expected root-group address overflow, got {other:?}"),
18746 }
18747 }
18748
18749 use tempfile::tempdir;
18750
18751 // -----------------------------------------------------------------------
18752 // Bounded sessions: the same engine over a `HandleImage`, which holds no
18753 // whole-file mirror. These came across from the standalone bounded engine
18754 // deleted in issue #198; what they cover is unchanged, but they now exercise
18755 // the shared code the mirror sessions use.
18756 // -----------------------------------------------------------------------
18757
18758 /// Build a rank-1 unlimited chunked i32 dataset `d` seeded with `0..n`.
18759 fn build_appendable(path: &Path, n: i32, chunk: u64) {
18760 let data: Vec<i32> = (0..n).collect();
18761 let mut b = crate::writer::FileBuilder::new();
18762 b.create_dataset("d")
18763 .with_i32_data(&data)
18764 .with_shape(&[n as u64])
18765 .with_maxshape(&[u64::MAX])
18766 .with_chunks(&[chunk]);
18767 b.write(path).unwrap();
18768 }
18769
18770 fn open_bounded_session(path: &Path) -> WriteEngine {
18771 WriteEngine::open_rw_with_strategy(
18772 path,
18773 crate::source::MetadataCacheConfig::disabled(),
18774 FileLocking::Enabled,
18775 MemoryStrategy::Bounded,
18776 )
18777 .unwrap()
18778 }
18779
18780 fn dataset_addr(engine: &WriteEngine) -> u64 {
18781 crate::group_v2::resolve_path_any_from_source(&engine.image(), engine.superblock(), "d")
18782 .unwrap()
18783 }
18784
18785 /// Crash consistency on a bounded session: stop the append after only the
18786 /// first `max_phase` durability phases (simulating a crash at that boundary)
18787 /// and assert the reopened file reads either the old length (phases 1-3) or
18788 /// the new one (phase 4), never a torn view. Layouts cover a partial trailing
18789 /// chunk (relocated tail) and a chunk-aligned start.
18790 #[test]
18791 fn bounded_append_crash_consistency_partial_tail_prefix() {
18792 let dir = tempdir().unwrap();
18793 for (case, (n, chunk, add)) in [(0usize, (6i32, 4u64, 5i32)), (1, (8, 2, 6))] {
18794 let base = dir.path().join(std::format!("base_{case}.h5"));
18795 build_appendable(&base, n, chunk);
18796 for max_phase in 1u8..=4 {
18797 let p = dir.path().join(std::format!("crash_{case}_{max_phase}.h5"));
18798 std::fs::copy(&base, &p).unwrap();
18799 {
18800 let mut engine = open_bounded_session(&p);
18801 let addr = dataset_addr(&engine);
18802 let mut b = AppendBuilder::new();
18803 b.append_i32(&(n..n + add).collect::<Vec<_>>());
18804 engine
18805 .append_inplace_gathered(AppendTarget::Header(addr), &b, max_phase)
18806 .unwrap();
18807 // Dropping the engine simulates the crash: no further phases,
18808 // no close barrier.
18809 }
18810 let expected_len = if max_phase == 4 { n + add } else { n };
18811 let got = crate::File::open(&p)
18812 .unwrap()
18813 .dataset("d")
18814 .unwrap()
18815 .read_i32()
18816 .unwrap();
18817 assert_eq!(
18818 got,
18819 (0..expected_len).collect::<Vec<_>>(),
18820 "case {case} phase {max_phase}"
18821 );
18822 }
18823 }
18824 }
18825
18826 /// The batching loop only honors `max_phase < 4` on its first batch, and a
18827 /// full multi-batch append leaves every batch fully committed: after a large
18828 /// append the file reads the complete sequence.
18829 #[test]
18830 fn bounded_multi_batch_append_commits_every_batch() {
18831 let dir = tempdir().unwrap();
18832 let p = dir.path().join("multibatch.h5");
18833 build_appendable(&p, 5, 512);
18834 let total = 700_000i32;
18835 {
18836 let mut engine = open_bounded_session(&p);
18837 let addr = dataset_addr(&engine);
18838 let mut b = AppendBuilder::new();
18839 b.append_i32(&(5..total).collect::<Vec<_>>());
18840 engine
18841 .append_inplace_gathered(AppendTarget::Header(addr), &b, 4)
18842 .unwrap();
18843 }
18844 let got = crate::File::open(&p)
18845 .unwrap()
18846 .dataset("d")
18847 .unwrap()
18848 .read_i32()
18849 .unwrap();
18850 assert_eq!(got.len(), total as usize);
18851 assert!(got.iter().enumerate().all(|(i, &v)| v == i as i32));
18852 }
18853
18854 /// A bounded session batches; a mirror session does not. The distinction is
18855 /// a deliberate trade — bounded peak memory against whole-call crash
18856 /// atomicity — so it is asserted rather than left to the batching code's
18857 /// arithmetic.
18858 #[test]
18859 fn only_a_bounded_session_batches_a_large_append() {
18860 let dir = tempdir().unwrap();
18861 let p = dir.path().join("batching.h5");
18862 build_appendable(&p, 8, 4);
18863
18864 let bounded_batch = {
18865 let mut engine = open_bounded_session(&p);
18866 engine
18867 .append_geometry(AppendTarget::Path("d"))
18868 .unwrap()
18869 .full_batch_elems
18870 };
18871 let mirror_batch = {
18872 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
18873 engine
18874 .append_geometry(AppendTarget::Path("d"))
18875 .unwrap()
18876 .full_batch_elems
18877 };
18878
18879 assert_eq!(
18880 mirror_batch,
18881 u64::MAX,
18882 "a mirror session must take the whole append as one crash-atomic batch"
18883 );
18884 assert!(
18885 bounded_batch < u64::MAX,
18886 "a bounded session must cap a batch, got {bounded_batch}"
18887 );
18888 assert_eq!(
18889 bounded_batch % 4,
18890 0,
18891 "a batch must be a whole number of chunks"
18892 );
18893 }
18894
18895 /// A persisting file appended through a bounded session and dropped WITHOUT
18896 /// `finalize_persist` (the true-crash case) still reads back every durable
18897 /// append. Dropping the engine releases the exclusive lock, so the reopen is
18898 /// portable (no leaked lock). The finalize-at-close path is covered by the
18899 /// `tests/bounded_append.rs` integration tests.
18900 #[test]
18901 fn bounded_persist_append_without_finalize_is_readable() {
18902 let dir = tempdir().unwrap();
18903 let p = dir.path().join("persist_crash.h5");
18904 let mut b = crate::writer::FileBuilder::new();
18905 b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
18906 b.create_dataset("d")
18907 .with_i32_data(&(0..6).collect::<Vec<i32>>())
18908 .with_shape(&[6])
18909 .with_maxshape(&[u64::MAX])
18910 .with_chunks(&[4]);
18911 b.write(&p).unwrap();
18912 {
18913 let mut engine = open_bounded_session(&p);
18914 assert!(engine.persist.is_some(), "persist state is armed at open");
18915 let addr = dataset_addr(&engine);
18916 let mut ab = AppendBuilder::new();
18917 ab.append_i32(&(6..20).collect::<Vec<_>>());
18918 engine
18919 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18920 .unwrap();
18921 // Drop without finalizing: models a true crash and releases the lock.
18922 }
18923 let got = crate::File::open(&p)
18924 .unwrap()
18925 .dataset("d")
18926 .unwrap()
18927 .read_i32()
18928 .unwrap();
18929 assert_eq!(got, (0..20).collect::<Vec<_>>());
18930 }
18931
18932 /// A bounded session grows a PAGED persisting file and is killed before
18933 /// finalize (models a crash), leaving the file non-page-aligned. Reopening it
18934 /// must not panic, and the next append must re-align the crashed tail page
18935 /// before writing raw data (so no page mixes metadata and raw); a clean close
18936 /// then re-page-aligns the file and every row reads back.
18937 #[test]
18938 fn bounded_paged_reopen_after_crash_realigns_and_stays_readable() {
18939 let dir = tempdir().unwrap();
18940 let p = dir.path().join("paged_crash.h5");
18941 let mut b = crate::writer::FileBuilder::new();
18942 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
18943 .with_file_space_page_size(4096);
18944 b.create_dataset("d")
18945 .with_i32_data(&(0..64).collect::<Vec<i32>>())
18946 .with_shape(&[64])
18947 .with_maxshape(&[u64::MAX])
18948 .with_chunks(&[64]);
18949 b.write(&p).unwrap();
18950
18951 // Grow enough to force extensible-array index growth, so the last write of
18952 // the session is metadata and the tail page is a partial metadata page.
18953 {
18954 let mut engine = open_bounded_session(&p);
18955 let addr = dataset_addr(&engine);
18956 let mut ab = AppendBuilder::new();
18957 ab.append_i32(&(64..2000).collect::<Vec<_>>());
18958 engine
18959 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18960 .unwrap();
18961 // Drop without finalize: models a crash and releases the OS lock.
18962 }
18963 assert_ne!(
18964 std::fs::metadata(&p).unwrap().len() % 4096,
18965 0,
18966 "a crashed (un-finalized) paged session leaves the file non-page-aligned"
18967 );
18968
18969 // Reopen must not panic on the non-aligned file; the next append re-aligns
18970 // the crashed tail page, and finalize re-page-aligns the whole file.
18971 {
18972 let mut engine = open_bounded_session(&p);
18973 let addr = dataset_addr(&engine);
18974 let mut ab = AppendBuilder::new();
18975 ab.append_i32(&(2000..2500).collect::<Vec<_>>());
18976 engine
18977 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18978 .unwrap();
18979 engine.finalize_persist().unwrap();
18980 engine.barrier().unwrap();
18981 }
18982 assert_eq!(
18983 std::fs::metadata(&p).unwrap().len() % 4096,
18984 0,
18985 "reopen + append + finalize re-aligns the paged file"
18986 );
18987 let got = crate::File::open(&p)
18988 .unwrap()
18989 .dataset("d")
18990 .unwrap()
18991 .read_i32()
18992 .unwrap();
18993 assert_eq!(got, (0..2500).collect::<Vec<_>>());
18994 }
18995
18996 /// A staged commit on a bounded session must stay bounded: it may read the
18997 /// metadata it edits, but never the file's bulk. Measured rather than
18998 /// asserted from the design — the engine is shared with the mirror sessions
18999 /// now, and a single slice-taking read added anywhere on the commit path
19000 /// would silently make a bounded open cost as much as a mirrored one.
19001 #[test]
19002 fn a_bounded_commit_reads_far_less_than_the_file() {
19003 use std::sync::Arc;
19004 use std::sync::atomic::{AtomicU64, Ordering};
19005
19006 let dir = tempdir().unwrap();
19007 let p = dir.path().join("bulk.h5");
19008 // ~8 MiB of chunked data, so "reads the whole file" and "reads only the
19009 // metadata" differ by orders of magnitude rather than by a margin.
19010 let rows = 2_000_000i32;
19011 build_appendable(&p, rows, 8192);
19012 let file_len = std::fs::metadata(&p).unwrap().len();
19013 assert!(file_len > 4 << 20, "file is only {file_len} bytes");
19014
19015 let read_bytes = Arc::new(AtomicU64::new(0));
19016 {
19017 let mut engine =
19018 WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
19019 engine.create_group("g").unwrap();
19020 engine.commit().unwrap();
19021 }
19022 let read = read_bytes.load(Ordering::Relaxed);
19023
19024 assert!(
19025 read > 0,
19026 "the commit read nothing, so the test proves nothing"
19027 );
19028 // Measured at 310 bytes here. The bound is loose enough to survive a
19029 // changed header layout and still orders of magnitude below the file.
19030 assert!(
19031 read < 64 << 10,
19032 "a bounded commit read {read} bytes of a {file_len}-byte file"
19033 );
19034 }
19035
19036 /// An in-place append leaves a partially-filled **raw** page, so the next
19037 /// commit's metadata must pad it rather than pack into it.
19038 ///
19039 /// This is what keeps [`PagedEdit::begin`] reachable from [`EditStore`] now
19040 /// that an append allocates raw pages only: the append's job is to record that
19041 /// the tail page turned raw, and the commit's job is to act on it. It is also
19042 /// the interleaving that a single session-level page tracker makes possible —
19043 /// with a tracker per engine, the commit path could not see what the append
19044 /// path had done, which is why the whole-file editor refused an in-place append
19045 /// to a paged file at all (issue #198).
19046 #[test]
19047 fn a_commit_after_an_append_pads_the_raw_page_the_append_left() {
19048 const PAGE: u64 = 4096;
19049 let dir = tempdir().unwrap();
19050 let p = dir.path().join("paged_interleave.h5");
19051 let mut b = crate::writer::FileBuilder::new();
19052 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19053 .with_file_space_page_size(PAGE);
19054 b.create_dataset("d")
19055 .with_i32_data(&(0..64).collect::<Vec<i32>>())
19056 .with_shape(&[64])
19057 .with_maxshape(&[u64::MAX])
19058 .with_chunks(&[64]);
19059 b.write(&p).unwrap();
19060
19061 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19062 let mut ab = AppendBuilder::new();
19063 ab.append_i32(&(64..2000).collect::<Vec<_>>());
19064 engine
19065 .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
19066 .unwrap();
19067
19068 assert_eq!(
19069 engine.paged.as_ref().unwrap().last,
19070 Some(PageType::Raw),
19071 "the append must record that the tail page now holds raw data"
19072 );
19073 assert_ne!(
19074 engine.image.len() % PAGE,
19075 0,
19076 "the append must leave a partially-filled page for the commit to pad"
19077 );
19078
19079 engine.create_group("g").unwrap();
19080 engine.commit().unwrap();
19081
19082 // The commit padded the raw tail before laying down metadata, and folded
19083 // that padding into the raw list (`meta_pad`/`raw_pad` are cleared into the
19084 // free lists as part of the paged tail).
19085 let pg = engine.paged.as_ref().expect("the file is paged");
19086 let raw_free = pg.raw.sections();
19087 assert!(
19088 !raw_free.is_empty(),
19089 "the commit packed metadata into the raw page the append left open"
19090 );
19091 for (addr, len) in raw_free {
19092 assert_eq!(
19093 (addr + len) % PAGE,
19094 0,
19095 "padding {addr}+{len} does not reach a page boundary"
19096 );
19097 }
19098
19099 drop(engine);
19100 assert_eq!(
19101 crate::File::open(&p)
19102 .unwrap()
19103 .dataset("d")
19104 .unwrap()
19105 .read_i32()
19106 .unwrap(),
19107 (0..2000).collect::<Vec<_>>()
19108 );
19109 }
19110
19111 /// An in-place append to a paged file must allocate **only raw pages** — the
19112 /// chunk data and the extensible-array blocks indexing it alike.
19113 ///
19114 /// The reclaim path (`chunked_storage_spans`) reports both halves of a chunked
19115 /// dataset as raw free space, because that is where this crate places them. An
19116 /// append that put its index blocks in a metadata page instead would make the
19117 /// reclaim advertise metadata-page bytes for raw reuse, mixing the page a paged
19118 /// file exists to keep homogeneous. Measured here rather than through the
19119 /// reference C library, which reads a mixed-page file without complaint: an
19120 /// interop test proves interop and says nothing about segregation.
19121 #[test]
19122 fn an_inplace_append_to_a_paged_file_allocates_only_raw_pages() {
19123 const PAGE: u64 = 4096;
19124 let dir = tempdir().unwrap();
19125 let p = dir.path().join("paged_raw.h5");
19126 let mut b = crate::writer::FileBuilder::new();
19127 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19128 .with_file_space_page_size(PAGE);
19129 b.create_dataset("d")
19130 .with_i32_data(&(0..64).collect::<Vec<i32>>())
19131 .with_shape(&[64])
19132 .with_maxshape(&[u64::MAX])
19133 .with_chunks(&[64]);
19134 b.write(&p).unwrap();
19135
19136 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19137 let before = engine.image().len();
19138 // Two appends, each large enough to grow the extensible-array index, so the
19139 // run allocates index blocks as well as chunk data.
19140 for range in [64..2000, 2000..4000] {
19141 let mut ab = AppendBuilder::new();
19142 ab.append_i32(&range.collect::<Vec<_>>());
19143 engine
19144 .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
19145 .unwrap();
19146 }
19147
19148 let pg = engine.paged.as_ref().expect("the file is paged");
19149 assert_eq!(
19150 pg.last,
19151 Some(PageType::Raw),
19152 "the append left the tail page holding something other than raw data"
19153 );
19154 assert!(
19155 pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
19156 "an in-place append switched page type: meta_pad={:?} raw_pad={:?}",
19157 pg.meta_pad,
19158 pg.raw_pad
19159 );
19160
19161 // Not vacuous: the append really did allocate index structure above the
19162 // pre-append end-of-file, which is what would have opened a metadata page.
19163 let addr = crate::group_v2::resolve_path_any_from_source(
19164 &engine.image(),
19165 engine.superblock(),
19166 "d",
19167 )
19168 .unwrap();
19169 let spans = engine
19170 .chunked_storage_spans(addr.to_usize().unwrap())
19171 .expect("a chunked dataset has reclaimable spans");
19172 let fresh = spans.iter().filter(|&&(a, _, _)| a >= before).count();
19173 assert!(
19174 fresh > 0,
19175 "the append allocated nothing above {before}, so the assertion above proves nothing"
19176 );
19177 assert!(
19178 spans
19179 .iter()
19180 .all(|&(_, _, class)| class == FreeClass::Page(PageType::Raw)),
19181 "the reclaim tags every chunked span raw; a metadata or dead tag here would \
19182 need the placement rule above to change with it"
19183 );
19184 }
19185
19186 /// Write a small file of `tables` unlimited chunked datasets, paged when
19187 /// asked, for the write-gathering tests below.
19188 fn gather_fixture(path: &std::path::Path, tables: usize, paged: bool) {
19189 use crate::writer::FileBuilder;
19190 let mut b = FileBuilder::new();
19191 if paged {
19192 // Deliberately *not* DEFAULT_GATHER_PAGE: a paged fixture at the
19193 // default page size cannot tell a session that reads the file's page
19194 // size from one that assumes the default.
19195 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19196 .with_file_space_page_size(16 * 1024);
19197 }
19198 for t in 0..tables {
19199 b.create_dataset(&std::format!("t{t}"))
19200 .with_i32_data(&(0..256).collect::<Vec<_>>())
19201 .with_shape(&[256])
19202 .with_maxshape(&[u64::MAX])
19203 .with_chunks(&[64]);
19204 }
19205 b.write(path).unwrap();
19206 }
19207
19208 /// Run the same appends and the same commit on `session`, and report what
19209 /// each cost in writes: the in-place appends, then the staged commit that
19210 /// follows them. They are counted apart because the gathering earns its keep
19211 /// in only one of them — see the caller. The file the workload leaves is the
19212 /// other half of what the callers compare, and they read it off the path
19213 /// themselves.
19214 fn gather_workload(session: &mut WriteEngine) -> (u64, u64) {
19215 let before = session.image.issued_writes();
19216 for round in 0..4 {
19217 for t in 0..4 {
19218 session
19219 .append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19220 .unwrap();
19221 }
19222 }
19223 for t in 0..4 {
19224 let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19225 db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19226 session
19227 .stage_created_dataset(&std::format!("/n{t}"), db)
19228 .unwrap();
19229 }
19230 let after_appends = session.image.issued_writes();
19231 session.commit().unwrap();
19232 (
19233 after_appends - before,
19234 session.image.issued_writes() - after_appends,
19235 )
19236 }
19237
19238 /// Gathering a session's writes lowers what it costs and changes nothing
19239 /// about what it produces (issue #288).
19240 ///
19241 /// Both halves matter and neither implies the other. A gatherer that dropped
19242 /// a run, wrote one twice in the wrong order, or filled the space between two
19243 /// runs sharing a page with zeros would lower the count exactly as well — so
19244 /// the two files are compared **byte for byte**, which is the only assertion
19245 /// a wrong merge cannot pass. And a gatherer that merged nothing would keep
19246 /// them identical, which is what the count is for.
19247 ///
19248 /// The comparison is against this same engine with the gathering turned off,
19249 /// rather than against a recorded number: how many writes a commit needs is
19250 /// an implementation detail that should be free to fall further.
19251 #[test]
19252 fn gathering_writes_costs_fewer_of_them_and_changes_no_byte() {
19253 use tempfile::tempdir;
19254
19255 let dir = tempdir().unwrap();
19256 for paged in [false, true] {
19257 let straight = dir.path().join(std::format!("straight_{paged}.h5"));
19258 let gathered = dir.path().join(std::format!("gathered_{paged}.h5"));
19259 gather_fixture(&straight, 4, paged);
19260 gather_fixture(&gathered, 4, paged);
19261
19262 let mut a = WriteEngine::open_with_locking(&straight, FileLocking::Enabled).unwrap();
19263 a.set_sync_policy(SyncPolicy::OnClose);
19264 a.image
19265 .set_write_buffering(WriteBuffering::Unbuffered)
19266 .unwrap();
19267 let (straight_appends, straight_commit) = gather_workload(&mut a);
19268 a.force_sync().unwrap();
19269 drop(a);
19270
19271 let mut b = WriteEngine::open_with_locking(&gathered, FileLocking::Enabled).unwrap();
19272 b.set_sync_policy(SyncPolicy::OnClose);
19273 let (gathered_appends, gathered_commit) = gather_workload(&mut b);
19274 b.force_sync().unwrap();
19275 drop(b);
19276
19277 // The commit tail is where the gathering earns its keep, and the half
19278 // to assert a ratio on. Measured: 2 writes against 10 unpaged, 4
19279 // against 16 paged. A commit rebuilds a group, repoints a root and
19280 // re-homes the free-space managers, all inside one phase and all into
19281 // a handful of pages, which is what merging within a barrier is for.
19282 // Merging *across* barriers goes further and is what an explicit page
19283 // buffer does; `a_page_buffer_holds_dirty_pages_across_operations`
19284 // measures that.
19285 assert!(
19286 gathered_commit * 3 < straight_commit,
19287 "paged={paged}: gathering must cost meaningfully fewer writes for a \
19288 commit, but cost {gathered_commit} against {straight_commit}"
19289 );
19290 // The appends are the other half, and since issue #307 they are a
19291 // near-tie: 88 against 92 unpaged, 92 against 92 paged. Publishing a
19292 // checksummed structure is one write from the engine now rather than
19293 // two the gatherer had to rejoin, so the buffering has almost nothing
19294 // left to merge here — before that fix this half was 92 against 144.
19295 // What is still worth pinning is that it never costs *more*. Note the
19296 // limit of that: a publish write made wider still merges the same way,
19297 // so widening one to the whole structure it sits in changes no count
19298 // here or anywhere — `a_publish_writes_from_the_byte_it_changed` is
19299 // what holds that, by counting bytes rather than writes.
19300 assert!(
19301 gathered_appends <= straight_appends,
19302 "paged={paged}: gathering must not cost more writes for the appends, \
19303 but cost {gathered_appends} against {straight_appends}"
19304 );
19305 assert_eq!(
19306 std::fs::read(&straight).unwrap(),
19307 std::fs::read(&gathered).unwrap(),
19308 "paged={paged}: gathering changed the file it produced"
19309 );
19310 }
19311 }
19312
19313 /// A session merges writes within the page its *file* was laid out on, and
19314 /// falls back to the format default only when the file is not paged.
19315 ///
19316 /// Asserted directly because the fixtures cannot assert it indirectly: the
19317 /// merge quantum is only visible in which writes coalesce, and any fixture
19318 /// built at the default page size makes the two answers identical. Reading
19319 /// the file's page size is the whole point of resolving this after the open
19320 /// rather than at it.
19321 #[test]
19322 fn the_merge_page_follows_the_file_rather_than_the_default() {
19323 use tempfile::tempdir;
19324
19325 let dir = tempdir().unwrap();
19326 let paged = dir.path().join("paged.h5");
19327 let plain = dir.path().join("plain.h5");
19328 gather_fixture(&paged, 1, true);
19329 gather_fixture(&plain, 1, false);
19330
19331 let s = WriteEngine::open_with_locking(&paged, FileLocking::Enabled).unwrap();
19332 assert_eq!(
19333 s.gather_page_size(),
19334 16 * 1024,
19335 "a paged file merges within its own file-space page"
19336 );
19337 assert_ne!(
19338 16 * 1024,
19339 DEFAULT_GATHER_PAGE,
19340 "the fixture must not be built at the default, or the assertion above \
19341 holds for a session that ignores the file entirely"
19342 );
19343 drop(s);
19344
19345 let s = WriteEngine::open_with_locking(&plain, FileLocking::Enabled).unwrap();
19346 assert_eq!(
19347 s.gather_page_size(),
19348 DEFAULT_GATHER_PAGE,
19349 "an unpaged file has no page size of its own"
19350 );
19351 }
19352
19353 /// Writing the status-flags byte on a file with a userblock changes that byte
19354 /// and nothing else.
19355 ///
19356 /// `set_consistency_flags` serializes the whole superblock to rewrite one
19357 /// byte of it, and the root address it carries is held absolute in memory but
19358 /// stored relative to the base address. Getting that wrong repoints the root
19359 /// past the end of the file, so a call that promised to touch a flag silently
19360 /// makes the file unreadable.
19361 ///
19362 /// Driven directly rather than through a caller, because for a long time
19363 /// neither caller could reach a non-zero base: SWMR refuses a userblock
19364 /// outright, and a page buffer required persisted free space, which is
19365 /// declined for a non-zero base. Both were refusals about something else that
19366 /// settled this as a side effect — and the day one of them moved is the day a
19367 /// test routed through it would have gone quiet. Issue #357 moved the second,
19368 /// so `a_page_buffer_marks_a_userblock_file_without_repointing_its_root`
19369 /// exercises the same conversion through a real session; this one stays as
19370 /// the direct statement of the rule.
19371 #[test]
19372 fn a_status_flag_write_leaves_a_userblock_files_root_alone() {
19373 use crate::writer::FileBuilder;
19374 use tempfile::tempdir;
19375
19376 let dir = tempdir().unwrap();
19377 let path = dir.path().join("userblock.h5");
19378 let mut b = FileBuilder::new();
19379 b.with_userblock(4096);
19380 b.create_dataset("d")
19381 .with_i32_data(&[1, 2, 3, 4])
19382 .with_shape(&[4]);
19383 b.write(&path).unwrap();
19384 let before = std::fs::read(&path).unwrap();
19385
19386 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19387 assert_eq!(
19388 s.superblock.base_address,
19389 BaseAddress::new(4096),
19390 "the fixture must have a userblock, or this test holds for a file \
19391 whose absolute and relative roots are the same number"
19392 );
19393 // Up and back down, which is the sequence a marked session performs.
19394 s.set_consistency_flags(file_lock::WRITE_ACCESS).unwrap();
19395 s.set_consistency_flags(0).unwrap();
19396 s.force_sync().unwrap();
19397 drop(s);
19398
19399 assert_eq!(
19400 std::fs::read(&path).unwrap(),
19401 before,
19402 "raising and clearing the flags must leave the file as it was"
19403 );
19404 assert_eq!(
19405 crate::reader::File::open(&path)
19406 .unwrap()
19407 .dataset("d")
19408 .unwrap()
19409 .read_i32()
19410 .unwrap(),
19411 vec![1, 2, 3, 4],
19412 "and the file must still read"
19413 );
19414 }
19415
19416 /// The budget a caller asks for is the budget the gatherer is given, so a
19417 /// session that outruns it flushes rather than growing without bound.
19418 ///
19419 /// `set_page_buffer_size` spends most of its length on refusals, and the one
19420 /// line that does the work hands `max_bytes` on. Passing `usize::MAX` instead
19421 /// fails nothing else in the suite: every other page-buffer test runs a
19422 /// workload smaller than the budget, so it holds everything to the end either
19423 /// way and the file it leaves is identical. What that would cost is a
19424 /// long-running session holding the whole of what it wrote in memory, which
19425 /// is the one thing the budget is for.
19426 ///
19427 /// Asserted as "some writes went out before the session ended" rather than as
19428 /// a count, since how many a 4 MiB append needs is free to change.
19429 #[test]
19430 fn a_page_buffered_session_flushes_when_it_outruns_its_budget() {
19431 use tempfile::tempdir;
19432
19433 let dir = tempdir().unwrap();
19434 let path = dir.path().join("outrun.h5");
19435 gather_fixture(&path, 1, true);
19436
19437 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19438 s.set_sync_policy(SyncPolicy::OnClose);
19439 s.set_page_buffer_size(WRITE_GATHER_BYTES).unwrap();
19440 let before = s.image.issued_writes();
19441 // Four times the budget, in one append, so nothing but the budget can
19442 // account for a write going out before the teardown below.
19443 s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19444 .unwrap();
19445 let during = s.image.issued_writes() - before;
19446 s.force_sync().unwrap();
19447 s.release_status_flags().unwrap();
19448 drop(s);
19449
19450 assert!(
19451 during > 0,
19452 "a session that wrote four times its budget issued nothing until it \
19453 closed, so the budget it was given is not the one it asked for"
19454 );
19455 assert_eq!(
19456 crate::reader::File::open(&path)
19457 .unwrap()
19458 .dataset("t0")
19459 .unwrap()
19460 .read_i32()
19461 .unwrap()
19462 .len(),
19463 256 + (1 << 20),
19464 "and the flushed append must still read back whole"
19465 );
19466 }
19467
19468 /// A page-buffer budget below the 1 MiB a session already gathers under
19469 /// issues *more* writes — on the workload a page buffer is sold for, and on
19470 /// the one it is worst at.
19471 ///
19472 /// This is the price the public property quotes for a small budget, so it
19473 /// belongs in the suite rather than in a comment. Such a budget was refused
19474 /// outright until issue #391, on the strength of exactly this measurement;
19475 /// what changed is who decides, not what it costs. A claim about a cost that
19476 /// nothing checks is one that quietly stops being true, and a caller sizing a
19477 /// buffer against a memory cap is who pays for that.
19478 ///
19479 /// The C library charges nothing for a small buffer: `H5PB_write` sends any
19480 /// I/O of a page or more straight to the driver. Nothing bypasses this
19481 /// gatherer, so the budget is also the point at which a long run is flushed
19482 /// and restarted — which is why the second workload is one long run.
19483 ///
19484 /// Asserted as an **ordering** rather than as counts. The counts are
19485 /// deterministic on one target and are quoted on the public property, but how
19486 /// many writes a commit needs is free to fall; what must not change is which
19487 /// budget issues fewer (issues #357, #391).
19488 #[test]
19489 fn a_smaller_budget_issues_more_writes() {
19490 use tempfile::tempdir;
19491
19492 let dir = tempdir().unwrap();
19493 let fixture = |path: &std::path::Path, tables: usize| {
19494 use crate::writer::FileBuilder;
19495 let mut b = FileBuilder::new();
19496 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19497 .with_file_space_page_size(4096);
19498 for t in 0..tables {
19499 b.create_dataset(&std::format!("t{t}"))
19500 .with_i32_data(&(0..256).collect::<Vec<_>>())
19501 .with_shape(&[256])
19502 .with_maxshape(&[u64::MAX])
19503 .with_chunks(&[64]);
19504 }
19505 b.write(path).unwrap();
19506 };
19507
19508 // The public path, which now accepts every budget down to this fixture's
19509 // 4 KiB page — so the arms differ in the budget a caller asked for and in
19510 // nothing else.
19511 let run = |path: &std::path::Path, budget: usize, workload: &dyn Fn(&mut WriteEngine)| {
19512 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19513 s.set_sync_policy(SyncPolicy::OnClose);
19514 s.set_page_buffer_size(budget).unwrap();
19515 let before = s.image.issued_writes();
19516 workload(&mut s);
19517 s.force_sync().unwrap();
19518 s.release_status_flags().unwrap();
19519 let issued = s.image.issued_writes() - before;
19520 drop(s);
19521 issued
19522 };
19523
19524 // Many small scattered writes — what the property is for — and one long
19525 // contiguous run, which is where a small budget collapses.
19526 let workload = |scattered: bool| {
19527 move |s: &mut WriteEngine| {
19528 if scattered {
19529 for round in 0..4 {
19530 for t in 0..8 {
19531 s.append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19532 .unwrap();
19533 }
19534 }
19535 } else {
19536 s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19537 .unwrap();
19538 }
19539 }
19540 };
19541
19542 for (label, tables, scattered) in [("scattered", 8, true), ("one long run", 1, false)] {
19543 let workload = workload(scattered);
19544 let at = |budget: usize| {
19545 let path = dir
19546 .path()
19547 .join(std::format!("{}_{budget}.h5", label.replace(' ', "_")));
19548 fixture(&path, tables);
19549 run(&path, budget, &workload)
19550 };
19551 let ample = at(WRITE_GATHER_BYTES);
19552 let smallest = at(4096);
19553 for smaller in [4096, 64 * 1024] {
19554 let below = at(smaller);
19555 assert!(
19556 ample <= below,
19557 "{label}: a {smaller}-byte budget issued {below} writes against \
19558 {ample} at {WRITE_GATHER_BYTES}, so a smaller budget is not the \
19559 trade the property documents"
19560 );
19561 }
19562 // Not vacuous: a gatherer that ignored `max_bytes` outright would
19563 // report the same count at every budget and satisfy the ordering
19564 // above without honoring any of them. One page is a quarter of the
19565 // smallest step measured here, so a workload that does not separate
19566 // there is one this assertion cannot speak for.
19567 assert!(
19568 smallest > ample,
19569 "{label}: the budget changed nothing — {smallest} writes at 4 KiB \
19570 against {ample} at 1 MiB — so this workload cannot say which \
19571 budget is better"
19572 );
19573 }
19574 }
19575
19576 /// A page-buffered session on a file with a userblock raises and clears its
19577 /// crash mark without disturbing the root address that mark's superblock
19578 /// rewrite carries — and the userblock's own bytes stay put.
19579 ///
19580 /// The caller-side half of
19581 /// `a_status_flag_write_leaves_a_userblock_files_root_alone`, reachable only
19582 /// since issue #357 scoped the persisted-free-space refusal to paged files.
19583 /// A userblock file persists no free space whatever its creation properties
19584 /// asked for, so before that the mark could not be raised on one at all, and
19585 /// `set_consistency_flags`'s base conversion had no caller that needed it.
19586 #[test]
19587 fn a_page_buffer_marks_a_userblock_file_without_repointing_its_root() {
19588 use crate::writer::FileBuilder;
19589 use tempfile::tempdir;
19590
19591 let dir = tempdir().unwrap();
19592 let path = dir.path().join("ub_buffered.h5");
19593 let mut b = FileBuilder::new();
19594 b.with_userblock(4096);
19595 b.create_dataset("t0")
19596 .with_i32_data(&(0..256).collect::<Vec<_>>())
19597 .with_shape(&[256]);
19598 b.write(&path).unwrap();
19599 let userblock_before = std::fs::read(&path).unwrap()[..4096].to_vec();
19600
19601 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19602 assert_eq!(
19603 s.superblock.base_address,
19604 BaseAddress::new(4096),
19605 "the fixture must have a userblock, or this test says nothing about \
19606 the conversion it exists for"
19607 );
19608 s.set_sync_policy(SyncPolicy::OnClose);
19609 s.set_page_buffer_size(1 << 20)
19610 .expect("an unpaged userblock file must accept a page buffer");
19611 // The mark is the point: a session that accepted the property and raised
19612 // nothing would pass every assertion below, since the conversion under
19613 // test runs only when the flags byte is written, and `raise_crash_mark`
19614 // is the only thing that writes it here. Asserted in memory rather than
19615 // off the disk because the session holds an exclusive OS lock on the
19616 // file, which is mandatory on Windows.
19617 assert_eq!(
19618 s.held_status_flags,
19619 file_lock::WRITE_ACCESS,
19620 "a page-buffered session must hold the crash mark"
19621 );
19622 // A staged commit rather than an in-place append: in-place appending is
19623 // refused on a userblock file for reasons of its own, which is a
19624 // different subject and would only mask this one.
19625 for t in 0..3 {
19626 let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19627 db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19628 s.stage_created_dataset(&std::format!("/n{t}"), db).unwrap();
19629 }
19630 s.commit().unwrap();
19631 s.force_sync().unwrap();
19632 s.release_status_flags().unwrap();
19633 drop(s);
19634
19635 assert_eq!(
19636 &std::fs::read(&path).unwrap()[..4096],
19637 &userblock_before[..],
19638 "the page-buffered session rewrote the userblock"
19639 );
19640 let f = crate::reader::File::open(&path).unwrap();
19641 assert_eq!(
19642 f.dataset("t0").unwrap().read_i32().unwrap(),
19643 (0..256).collect::<Vec<_>>(),
19644 "the commit must have left the original dataset alone"
19645 );
19646 for t in 0..3 {
19647 assert_eq!(
19648 f.dataset(&std::format!("n{t}"))
19649 .unwrap()
19650 .read_f64()
19651 .unwrap(),
19652 vec![2.5f64; 32],
19653 "n{t}: the committed dataset must read back"
19654 );
19655 }
19656 }
19657
19658 /// A page buffer keeps dirty pages across operations, so a workload that
19659 /// touches the same pages again and again pays for them once rather than
19660 /// once per operation (issue #288) — and still produces the same file.
19661 ///
19662 /// This is what the default gathering deliberately does *not* do, so the
19663 /// comparison is against that default rather than against no gathering at
19664 /// all: what is being measured is the second reduction, not the first.
19665 ///
19666 /// Run on a paged file **and an unpaged one**. `H5Pset_page_buffer_size`
19667 /// requires the paged allocator because the C page buffer is a page cache
19668 /// whose `min_meta_perc`/`min_raw_perc` reservations count pages that
19669 /// allocator keeps segregated by kind; this is a write gatherer, which needs
19670 /// only a window to merge within, and
19671 /// [`gather_page_size`](WriteEngine::gather_page_size) supplies one either
19672 /// way. The unpaged arm is what says so (issue #357), and it is the arm that
19673 /// matters most in practice: unpaged is the default strategy, so before this
19674 /// the property was reachable only from a file deliberately created paged.
19675 #[test]
19676 fn a_page_buffer_holds_dirty_pages_across_operations() {
19677 use tempfile::tempdir;
19678
19679 let dir = tempdir().unwrap();
19680 let run = |path: &std::path::Path, page_buffer: usize| {
19681 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19682 s.set_sync_policy(SyncPolicy::OnClose);
19683 if page_buffer != 0 {
19684 s.set_page_buffer_size(page_buffer).unwrap();
19685 }
19686 let (appends, commit) = gather_workload(&mut s);
19687 // The teardown `File::close` and `FileInner::drop` perform, in their
19688 // order. A bare engine owes it too — it is what takes the crash mark
19689 // down — and nothing does it for one: this crate deliberately keeps
19690 // teardown writes off `WriteEngine::drop`, so that dropping a probe
19691 // session (as the bounded/mirrored dispatch does) writes nothing.
19692 // Without it the buffered file ends still marked, and the byte
19693 // comparison below is what says so.
19694 s.force_sync().unwrap();
19695 s.release_status_flags().unwrap();
19696 drop(s);
19697 appends + commit
19698 };
19699
19700 for paged in [true, false] {
19701 let label = if paged { "paged" } else { "unpaged" };
19702 let per_op = dir.path().join(std::format!("{label}_per_op.h5"));
19703 let buffered = dir.path().join(std::format!("{label}_buffered.h5"));
19704 gather_fixture(&per_op, 4, paged);
19705 gather_fixture(&buffered, 4, paged);
19706
19707 let per_op_writes = run(&per_op, 0);
19708 let buffered_writes = run(&buffered, 1 << 20);
19709
19710 assert!(
19711 buffered_writes * 4 < per_op_writes,
19712 "{label}: a page buffer must cost meaningfully fewer writes than the \
19713 per-operation default, but cost {buffered_writes} against {per_op_writes}"
19714 );
19715 assert_eq!(
19716 std::fs::read(&per_op).unwrap(),
19717 std::fs::read(&buffered).unwrap(),
19718 "{label}: a page buffer changed the file it produced"
19719 );
19720 }
19721 }
19722
19723 /// A budget below the 1 MiB a session already gathers under is honored end
19724 /// to end, and leaves the same file 1 MiB leaves (issue #391).
19725 ///
19726 /// The floor this replaces was a write-count argument with no correctness
19727 /// claim behind it, so the assertion that carries the weight is the byte
19728 /// comparison rather than the read-back: a buffer that flushes and restarts
19729 /// more often must produce the *same* file, not merely a readable one.
19730 ///
19731 /// Run through the public API — create, append, commit, close, reopen —
19732 /// because `File::create_with_options` is where the refusal used to fire, on
19733 /// a pair it had already decided it could not reopen with. A 16 KiB-paged
19734 /// file at 256 KiB is the case the issue reported.
19735 #[test]
19736 fn a_page_buffer_below_the_gather_budget_writes_the_same_file() {
19737 use tempfile::tempdir;
19738
19739 let dir = tempdir().unwrap();
19740 let run = |path: &std::path::Path, budget: usize| -> Vec<i32> {
19741 let file = crate::reader::File::create_with_options(
19742 path,
19743 crate::FileCreateProperties::new()
19744 .with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19745 .with_file_space_page_size(16 * 1024),
19746 crate::FileAccessProperties::new()
19747 .with_sync_policy(SyncPolicy::OnClose)
19748 .with_page_buffer_size(budget),
19749 )
19750 .unwrap();
19751 file.root()
19752 .create_dataset("d", |b| {
19753 b.with_i32_data(&(0..64).collect::<Vec<i32>>())
19754 .with_shape(&[64])
19755 .with_maxshape(&[u64::MAX])
19756 .with_chunks(&[64]);
19757 })
19758 .unwrap();
19759 file.commit().unwrap();
19760 for round in 0..8i32 {
19761 let mut ds = file.dataset("d").unwrap();
19762 ds.append(&vec![round; 64]).unwrap();
19763 }
19764 file.root()
19765 .create_dataset("added", |b| {
19766 b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19767 })
19768 .unwrap();
19769 file.commit().unwrap();
19770 file.close().unwrap();
19771
19772 let reopened = crate::reader::File::open(path).unwrap();
19773 assert_eq!(
19774 reopened.dataset("added").unwrap().read_f64().unwrap(),
19775 vec![2.5f64; 32],
19776 "the dataset committed through the buffer must read back"
19777 );
19778 reopened.dataset("d").unwrap().read_i32().unwrap()
19779 };
19780
19781 let small = dir.path().join("small_budget.h5");
19782 let ample = dir.path().join("ample_budget.h5");
19783 let from_small = run(&small, 256 * 1024);
19784 let from_ample = run(&le, 1 << 20);
19785
19786 let mut expected: Vec<i32> = (0..64).collect();
19787 for round in 0..8i32 {
19788 expected.extend(std::iter::repeat_n(round, 64));
19789 }
19790 assert_eq!(
19791 from_small, expected,
19792 "a 256 KiB page buffer did not append what it was given"
19793 );
19794 assert_eq!(
19795 from_ample, expected,
19796 "and neither did the 1 MiB one, so the file comparison below would be \
19797 two arms agreeing on the wrong answer"
19798 );
19799 assert_eq!(
19800 std::fs::read(&small).unwrap(),
19801 std::fs::read(&le).unwrap(),
19802 "a 256 KiB page buffer produced a different file from a 1 MiB one"
19803 );
19804 }
19805
19806 /// A barrier issues what has been gathered under **every** policy, so the
19807 /// bytes a publish point names are on the disk before the publish point is.
19808 ///
19809 /// Gathered writes are issued in address order, and the superblock lives at
19810 /// address 0 — so a commit whose barriers issued nothing would put its new
19811 /// root pointer on the disk *first* and the content it names last. A write
19812 /// that then failed, or a process that died mid-flush, would leave a
19813 /// superblock naming bytes that are not in the file, where before this
19814 /// gathering existed it left the previous file intact.
19815 ///
19816 /// `SyncPolicy::Always` cannot see this: its barriers are `fsync`s, which
19817 /// flush on their way out. Every crash-consistency test in this crate runs on
19818 /// that default, which is exactly why nothing caught it (issue #288). So this
19819 /// runs on `OnClose`, and asserts the order rather than a count — a count
19820 /// would be satisfied by a session that issued everything at the wrong time.
19821 ///
19822 /// It covers two of the three ordering sites: `barrier` (the commit tail) and
19823 /// `EditStore::sync` (the append phases). The third is `barrier_data`, which
19824 /// `EditStore::sync` now shares rather than duplicates, so it is correct by
19825 /// identity rather than by argument. It is covered too, one test over:
19826 /// mutating its `OnClose` arm to `Ok(())` fails
19827 /// `sync_policy_governs_the_persisting_and_flag_barriers` and nothing else.
19828 #[test]
19829 fn a_barrier_orders_the_publish_point_last_under_every_policy() {
19830 use tempfile::tempdir;
19831
19832 let dir = tempdir().unwrap();
19833 for policy in [SyncPolicy::Always, SyncPolicy::OnClose] {
19834 let path = dir.path().join(std::format!("order_{policy:?}.h5"));
19835 gather_fixture(&path, 1, false);
19836
19837 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19838 s.set_sync_policy(policy);
19839
19840 let mut db = crate::type_builders::DatasetBuilder::new("added");
19841 db.with_f64_data(&[2.5f64; 64]).with_shape(&[64]);
19842 s.stage_created_dataset("/added", db).unwrap();
19843 let before = s.image.issued_write_order().len();
19844 s.commit().unwrap();
19845
19846 let order = s.image.issued_write_order()[before..].to_vec();
19847 let superblock = order
19848 .iter()
19849 .position(|&(at, _)| at == s.sb_sig_off as u64)
19850 .unwrap_or_else(|| panic!("{policy:?}: the commit never wrote the superblock"));
19851 let content = order
19852 .iter()
19853 .rposition(|&(at, _)| at != s.sb_sig_off as u64)
19854 .unwrap_or_else(|| panic!("{policy:?}: the commit wrote nothing but a superblock"));
19855 assert!(
19856 superblock > content,
19857 "{policy:?}: the superblock was issued at position {superblock} of \
19858 {order:?}, ahead of content at {content} — a failure in that window \
19859 leaves a root pointing at bytes that are not in the file"
19860 );
19861
19862 // The same for the append engine's four phases, whose publish point is
19863 // the dataspace dimension in the object header — a *low* address, with
19864 // the chunk bytes it makes visible at end-of-file. Stated as "the last
19865 // write is not the highest one", which is precisely what a single
19866 // address-ordered flush of the whole append would make it.
19867 let before = s.image.issued_write_order().len();
19868 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19869 let order = s.image.issued_write_order()[before..].to_vec();
19870 let highest = order
19871 .iter()
19872 .map(|&(at, _)| at)
19873 .max()
19874 .expect("the append wrote");
19875 assert!(
19876 order.last().expect("the append wrote").0 < highest,
19877 "{policy:?}: the append's last write is its highest-addressed one, so \
19878 the whole append went out in address order and the dimension that \
19879 publishes the new rows preceded the chunk bytes: {order:?}"
19880 );
19881 }
19882 }
19883
19884 /// The page buffer produces the same bytes through the **bounded** backing,
19885 /// which is the one `File::open_rw` actually picks for a latest-format file.
19886 ///
19887 /// The test above drives the whole-file mirror, whose reads come from memory
19888 /// and so never meet the pending writes at all. The bounded image has no
19889 /// mirror: every read it serves goes to the disk and is then patched with
19890 /// whatever is still gathered, so it is the backing where a wrong overlay
19891 /// silently plans the next edit against bytes that are neither on the disk nor
19892 /// in the buffer. Byte identity against the same session without the buffer is
19893 /// the assertion a wrong overlay cannot pass.
19894 #[test]
19895 fn a_page_buffer_changes_no_byte_through_the_bounded_backing() {
19896 use crate::reader::File;
19897 use tempfile::tempdir;
19898
19899 let dir = tempdir().unwrap();
19900 let run = |name: &str, page_buffer: usize| {
19901 let path = dir.path().join(name);
19902 gather_fixture(&path, 4, true);
19903 {
19904 let props = crate::FileAccessProperties::new()
19905 .with_sync_policy(SyncPolicy::OnClose)
19906 .with_memory_strategy(MemoryStrategy::Bounded)
19907 .with_page_buffer_size(page_buffer);
19908 let file = File::open_rw_with_options(&path, props).unwrap();
19909 assert_eq!(
19910 file.edit_backing(),
19911 Some(crate::EditBacking::Bounded),
19912 "{name}: this must exercise the mirrorless backing"
19913 );
19914 let root = file.root();
19915 for round in 0..4u8 {
19916 for t in 0..4 {
19917 let mut ds = file.dataset(&std::format!("t{t}")).unwrap();
19918 ds.append(&[i32::from(round); 64]).unwrap();
19919 }
19920 }
19921 for t in 0..4 {
19922 root.create_dataset(&std::format!("n{t}"), |b| {
19923 b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19924 })
19925 .unwrap();
19926 }
19927 file.commit().unwrap();
19928 root.set_attr("tag", crate::AttrValue::I32(1)).unwrap();
19929 file.commit().unwrap();
19930 file.close().unwrap();
19931 }
19932 std::fs::read(&path).unwrap()
19933 };
19934
19935 assert_eq!(
19936 run("bounded_plain.h5", 0),
19937 run("bounded_buffered.h5", 1 << 20),
19938 "a page buffer changed the file the bounded backing produced"
19939 );
19940 }
19941
19942 /// An operation that has returned has put its bytes in the operating system,
19943 /// whatever the [`SyncPolicy`] says.
19944 ///
19945 /// This is what makes the default gathering free rather than a trade: the
19946 /// bytes are held only *inside* a commit or an append, never across one. It
19947 /// is asserted as "a forced sync afterwards finds nothing left to write",
19948 /// because the alternative — reading the file through a second handle — is
19949 /// what the session's own exclusive lock exists to prevent, mandatorily so on
19950 /// Windows.
19951 #[test]
19952 fn a_finished_operation_has_nothing_left_to_write() {
19953 use tempfile::tempdir;
19954
19955 let dir = tempdir().unwrap();
19956 let path = dir.path().join("finished_op.h5");
19957 gather_fixture(&path, 1, false);
19958
19959 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19960 // The policy that issues no `fsync` at all, so the ordering barriers are
19961 // the only thing that can be draining the buffer.
19962 s.set_sync_policy(SyncPolicy::OnClose);
19963
19964 let before_append = s.image.issued_writes();
19965 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19966 let after_append = s.image.issued_writes();
19967 assert!(
19968 after_append > before_append,
19969 "the append issued nothing at all, so the equality below holds for a \
19970 session that did no work"
19971 );
19972 s.force_sync().unwrap();
19973 assert_eq!(
19974 s.image.issued_writes(),
19975 after_append,
19976 "a finished append left writes in this process's memory"
19977 );
19978
19979 let mut db = crate::type_builders::DatasetBuilder::new("added");
19980 db.with_f64_data(&[1.5f64; 8]).with_shape(&[8]);
19981 s.stage_created_dataset("/added", db).unwrap();
19982 s.commit().unwrap();
19983 let after_commit = s.image.issued_writes();
19984 assert!(
19985 after_commit > after_append,
19986 "the commit issued nothing at all"
19987 );
19988 s.force_sync().unwrap();
19989 assert_eq!(
19990 s.image.issued_writes(),
19991 after_commit,
19992 "a finished commit left writes in this process's memory"
19993 );
19994
19995 // And the bytes are the ones they were meant to be — an image that issued
19996 // writes at the right moments but the wrong contents passes everything
19997 // above.
19998 drop(s);
19999 let f = crate::reader::File::open(&path).unwrap();
20000 assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap().len(), 320);
20001 assert_eq!(
20002 f.dataset("added").unwrap().read_f64().unwrap(),
20003 vec![1.5f64; 8]
20004 );
20005 }
20006
20007 /// Overwriting the root group is refused by name.
20008 ///
20009 /// The refusal lives in `stage_dataset_write` rather than in the commit,
20010 /// because the staged dataset is flattened as it is staged and the root's
20011 /// empty path would otherwise be reported as a dataset with no name. No
20012 /// public entry point can reach it — `Dataset::write_staged` runs off a
20013 /// resolved dataset path — so this is where it stays covered.
20014 #[test]
20015 fn overwriting_the_root_group_is_refused_at_staging() {
20016 use crate::writer::FileBuilder;
20017 use tempfile::tempdir;
20018
20019 let dir = tempdir().unwrap();
20020 let path = dir.path().join("root_overwrite.h5");
20021 let mut b = FileBuilder::new();
20022 b.create_dataset("d").with_i32_data(&[1]);
20023 b.write(&path).unwrap();
20024
20025 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20026 let mut db = crate::type_builders::DatasetBuilder::new("whatever");
20027 db.with_i32_data(&[1]);
20028 let err = s.stage_dataset_write("/", db).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.stage_created_dataset("/sparse", db).unwrap_err();
20064 assert!(
20065 matches!(&err, Error::EditUnsupported(m) if m.contains("unallocated storage")),
20066 "unexpected error: {err:?}"
20067 );
20068 assert!(!s.has_staged_edits());
20069 }
20070
20071 /// The same obligation on the one operation that does not go through
20072 /// [`commit`](WriteEngine::commit): the free-space finalize a session owes at
20073 /// teardown.
20074 ///
20075 /// Its two callers force a sync straight after it, so nothing observable
20076 /// breaks when it forgets — which is exactly why it needs its own test. It is
20077 /// `pub(crate)`, and the next caller would inherit the omission silently.
20078 #[test]
20079 fn finalize_persist_has_nothing_left_to_write() {
20080 use crate::writer::FileBuilder;
20081 use tempfile::tempdir;
20082
20083 let dir = tempdir().unwrap();
20084 let path = dir.path().join("persisting.h5");
20085 let mut b = FileBuilder::new();
20086 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20087 b.create_dataset("t0")
20088 .with_i32_data(&(0..256).collect::<Vec<_>>())
20089 .with_shape(&[256])
20090 .with_maxshape(&[u64::MAX])
20091 .with_chunks(&[64]);
20092 b.write(&path).unwrap();
20093
20094 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20095 s.set_sync_policy(SyncPolicy::OnClose);
20096 // Grow the file past the managers, which is what gives the finalize
20097 // something to re-home.
20098 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20099
20100 s.finalize_persist().unwrap();
20101 let after = s.image.issued_writes();
20102 s.force_sync().unwrap();
20103 assert_eq!(
20104 s.image.issued_writes(),
20105 after,
20106 "finalize_persist returned with writes still gathered"
20107 );
20108 }
20109
20110 /// [`SpaceAccounting::reusable_free_space`] stays coalesced once an in-place
20111 /// append holds a reserve (issue #387).
20112 ///
20113 /// The reserve is drawn *out of* the session's free list, so what is left of
20114 /// the hole it came from sits immediately beside it — and as the append
20115 /// spends the reserve down, the spent end walks toward that remainder until
20116 /// the two abut exactly. Reporting them as two regions would break the
20117 /// field's documented contract ("no two regions touch or overlap") in the one
20118 /// way that misleads: a caller sizing an allocation against the largest
20119 /// region would be told nothing that big fits when it does.
20120 ///
20121 /// Sixteen chunk-sized appends, checked after every one, because the abutment
20122 /// appears only at a particular point in spending a batch rather than at the
20123 /// first append.
20124 #[test]
20125 fn the_append_reserve_is_reported_as_one_coalesced_free_list() {
20126 use crate::writer::FileBuilder;
20127 use tempfile::tempdir;
20128
20129 /// Elements of the dataset the delete vacates: three reserve batches, so
20130 /// the hole outlives several draws.
20131 const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 3) / 4;
20132 /// Elements per chunk: 64 KiB, so sixteen appends spend a whole batch.
20133 const CHUNK: u64 = 16384;
20134
20135 let dir = tempdir().unwrap();
20136 let path = dir.path().join("reserve_coalesced.h5");
20137 let mut b = FileBuilder::new();
20138 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20139 b.create_dataset("t0")
20140 .with_i32_data(&[0i32])
20141 .with_shape(&[1])
20142 .with_maxshape(&[u64::MAX])
20143 .with_chunks(&[CHUNK]);
20144 b.create_dataset("victim")
20145 .with_i32_data(&vec![7i32; VICTIM]);
20146 b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20147 b.write(&path).unwrap();
20148
20149 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20150 s.set_sync_policy(SyncPolicy::OnClose);
20151 s.delete("victim").unwrap();
20152 s.commit().unwrap();
20153
20154 let batch: Vec<i32> = (0..CHUNK as i32).collect();
20155 let mut reserved_ever = false;
20156 for round in 0..16 {
20157 s.append_inplace_i32_phased("t0", &batch, 4).unwrap();
20158 reserved_ever |= !s.reserved.is_empty();
20159 let acct = s.space_accounting();
20160 let regions = &acct.reusable_free_space;
20161 for w in regions.windows(2) {
20162 let ((a_addr, a_len), (b_addr, _)) = (w[0], w[1]);
20163 assert!(
20164 a_addr + a_len < b_addr,
20165 "round {round}: [{a_addr}, {}) and [{b_addr}, ..) touch or overlap, \
20166 though reusable_free_space is documented as fully coalesced: {regions:?}",
20167 a_addr + a_len
20168 );
20169 }
20170 assert_eq!(
20171 acct.reusable_free_bytes,
20172 regions.iter().map(|&(_, len)| len).sum::<u64>(),
20173 "round {round}: the total must be the summed length of the regions"
20174 );
20175 }
20176 assert!(
20177 reserved_ever,
20178 "no reserve was ever held, so this measured the plain free list"
20179 );
20180 }
20181
20182 /// A persisting session gives its **unspent** append reserve back to the
20183 /// on-disk managers before it closes (issue #387).
20184 ///
20185 /// An append on such a file takes a batch out of the managers before it may
20186 /// spend any of it, and takes a whole batch however little the append needs.
20187 /// What is left over is space nothing occupies, so leaving it out of the
20188 /// managers would turn every appending session into a leak of up to
20189 /// [`APPEND_RESERVE_BYTES`] — invisible from inside the session, since its own
20190 /// accounting still counts the reserve as reusable.
20191 ///
20192 /// Measured from outside the session for that reason: the persisted free
20193 /// space after the close, against what it was before the append.
20194 #[test]
20195 fn an_unspent_append_reserve_goes_back_to_the_managers() {
20196 use crate::writer::FileBuilder;
20197 use tempfile::tempdir;
20198
20199 /// Elements of the dataset the delete below vacates. Its blocks are the
20200 /// hole the append draws from, so it has to exceed one reserve batch or
20201 /// nothing is drawn and this measures the old behaviour.
20202 const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 2) / 4;
20203
20204 let dir = tempdir().unwrap();
20205 let path = dir.path().join("reserve_return.h5");
20206 let mut b = FileBuilder::new();
20207 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20208 b.create_dataset("t0")
20209 .with_i32_data(&(0..256).collect::<Vec<_>>())
20210 .with_shape(&[256])
20211 .with_maxshape(&[u64::MAX])
20212 .with_chunks(&[64]);
20213 b.create_dataset("victim")
20214 .with_i32_data(&vec![7i32; VICTIM]);
20215 // Above the hole, so the delete leaves an interior region rather than a
20216 // run reaching end-of-file that the commit truncates away.
20217 b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20218 b.write(&path).unwrap();
20219
20220 let persisted = |p: &std::path::Path| -> u64 {
20221 crate::reader::File::open(p)
20222 .unwrap()
20223 .persisted_free_space()
20224 .iter()
20225 .map(|&(_, len)| len)
20226 .sum()
20227 };
20228
20229 {
20230 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20231 s.set_sync_policy(SyncPolicy::OnClose);
20232 s.delete("victim").unwrap();
20233 s.commit().unwrap();
20234 }
20235 let before = persisted(&path);
20236 assert!(
20237 before > APPEND_RESERVE_BYTES,
20238 "the fixture must leave more than one reserve batch on disk, not {before} bytes"
20239 );
20240
20241 // A few kilobytes of append against a megabyte of reserve.
20242 let appended: Vec<i32> = (0..1024).collect();
20243 {
20244 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20245 s.set_sync_policy(SyncPolicy::OnClose);
20246 s.append_inplace_i32_phased("t0", &appended, 4).unwrap();
20247 s.finalize_persist().unwrap();
20248 }
20249
20250 let after = persisted(&path);
20251 // What the append actually placed: its chunks and the index blocks beside
20252 // them, plus the manager rewrite's own churn. Generous, and still an order
20253 // of magnitude under the batch that would go missing.
20254 let spent = 64 * 1024;
20255 assert!(
20256 after + spent >= before,
20257 "the session reserved {APPEND_RESERVE_BYTES} bytes and spent a few of them, \
20258 so the managers should still describe nearly all of the {before} they did \
20259 before — they describe {after}"
20260 );
20261 assert!(
20262 after < before,
20263 "the append placed bytes inside the hole, so the managers must describe \
20264 less than the {before} they did (they describe {after})"
20265 );
20266
20267 let f = crate::reader::File::open(&path).unwrap();
20268 let mut want = (0..256).collect::<Vec<i32>>();
20269 want.extend_from_slice(&appended);
20270 assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap(), want);
20271 assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), [9, 9, 9]);
20272 }
20273
20274 /// A persisting session's fixture for the draw tests: `t0` to append onto,
20275 /// `victims` datasets of `victim_elems` `i32`s each, every one followed by a
20276 /// small keeper so that deleting the victims leaves that many *separate*
20277 /// holes, and a `ceiling` above them all so none of the holes is trailing.
20278 fn fragmented_persisting_fixture(
20279 path: &std::path::Path,
20280 victims: usize,
20281 victim_elems: usize,
20282 ) -> WriteEngine {
20283 use crate::writer::FileBuilder;
20284
20285 let mut b = FileBuilder::new();
20286 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20287 b.create_dataset("t0")
20288 .with_i32_data(&[0i32])
20289 .with_shape(&[1])
20290 .with_maxshape(&[u64::MAX])
20291 .with_chunks(&[64]);
20292 for v in 0..victims {
20293 b.create_dataset(&std::format!("victim{v}"))
20294 .with_i32_data(&vec![7i32; victim_elems]);
20295 b.create_dataset(&std::format!("keeper{v}"))
20296 .with_i32_data(&[v as i32]);
20297 }
20298 b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20299 b.write(path).unwrap();
20300
20301 let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
20302 s.set_sync_policy(SyncPolicy::OnClose);
20303 for v in 0..victims {
20304 s.delete(&std::format!("victim{v}")).unwrap();
20305 }
20306 s.commit().unwrap();
20307 s
20308 }
20309
20310 /// One draw takes every hole the append fits in, not only the largest, so a
20311 /// file fragmented into small holes pays one manager rewrite for all of them
20312 /// rather than one per hole (issue #413).
20313 ///
20314 /// Three holes of a few chunks each, each far under a batch, separated by
20315 /// live objects so they cannot coalesce. One chunk-sized append then has to
20316 /// leave the reserve holding runs from more than one of them: a draw that
20317 /// took the largest hole alone would have served the chunk just as well and
20318 /// held exactly one run, so the count is what tells the two apart.
20319 #[test]
20320 fn a_draw_gathers_every_hole_the_append_fits_in() {
20321 use tempfile::tempdir;
20322
20323 let dir = tempdir().unwrap();
20324 let path = dir.path().join("draw_gathers.h5");
20325 let mut s = fragmented_persisting_fixture(&path, 3, 4 * 64);
20326 assert!(
20327 s.free.sections().len() >= 3,
20328 "the fixture must leave three separate holes, not {:?}",
20329 s.free.sections()
20330 );
20331 let before = s.free.sections();
20332
20333 s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20334
20335 let held = s.reserved.sections();
20336 // A run per hole, less what the chunk and the rewrite's tail took out of
20337 // them: the chunk out of one, the tail out of at most one more.
20338 assert!(
20339 held.len() >= 2,
20340 "one draw should have gathered runs from several of the holes {before:?}, \
20341 not only {held:?}"
20342 );
20343 let drawn: u64 = held.iter().map(|&(_, len)| len).sum();
20344 assert!(
20345 drawn > before.iter().map(|&(_, len)| len).max().unwrap(),
20346 "the reserve holds {drawn} bytes, no more than the largest hole alone"
20347 );
20348 }
20349
20350 /// The manager rewrite that publishes a draw places its own tail inside the
20351 /// space the draw is taking when the draw has emptied the free list, rather
20352 /// than appending it (issue #413).
20353 ///
20354 /// On a flat file the draw and the tail are served from the same list, and
20355 /// a draw takes every hole the append fits in, so the case is the ordinary
20356 /// one, not a corner: without the fallback every draw appended one tail at
20357 /// end-of-file and freed the previous one, a persisting file's whole growth
20358 /// under churn once the floor on the draw was gone. Measured as the file's
20359 /// length across the append, which a tail landing anywhere inside leaves
20360 /// alone.
20361 ///
20362 /// Measured as the tail's own address rather than as an unchanged length: the
20363 /// rewrite supersedes the fixture's tail, which sat at end-of-file, so
20364 /// releasing that trailing run leaves the file *shorter* than it started
20365 /// (issue #418). An appended tail would land at exactly the pre-append
20366 /// end-of-file, which is the case both assertions rule out.
20367 #[test]
20368 fn a_draws_rewrite_places_its_tail_inside_the_draw() {
20369 use tempfile::tempdir;
20370
20371 let dir = tempdir().unwrap();
20372 let path = dir.path().join("draw_tail.h5");
20373 let mut s = fragmented_persisting_fixture(&path, 1, 8 * 64);
20374 let len_before = s.image.len();
20375
20376 s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20377
20378 assert!(
20379 !s.reserved.is_empty(),
20380 "no reserve was drawn, so this measured an ordinary end-of-file append"
20381 );
20382 let ext = s.superblock.superblock_extension_address.unwrap();
20383 assert!(
20384 ext < len_before,
20385 "the rewrite appended its tail at {ext}, the pre-append end-of-file \
20386 {len_before}, instead of placing it in the hole it was publishing as taken"
20387 );
20388 assert!(
20389 s.image.len() <= len_before,
20390 "a rewrite that placed its tail inside the file must not have grown it \
20391 (was {len_before}, now {})",
20392 s.image.len()
20393 );
20394 }
20395
20396 /// One in-place append costs a small constant number of writes even where
20397 /// nothing gathers them — the case the Extensible-Array header's six
20398 /// statistics were written separately for.
20399 ///
20400 /// Under gathering, six adjacent writes and one merge into the same page
20401 /// write, so the whole suite is blind to which one the engine made. The SWMR
20402 /// writer is the regime where it still shows: it gathers nothing by design,
20403 /// so every write the engine makes is a syscall. Stated as a ceiling on the
20404 /// count rather than as an exact figure, since what an append needs is free to
20405 /// fall — and it has: for the append this measures, the first into a dataset,
20406 /// 12 before issue #307 published each checksummed structure in one write and
20407 /// 8 after; an append that reuses the data block that one allocates costs 5.
20408 /// Six statistics written singly puts it five over; a checksum written apart
20409 /// from the value it covers, four.
20410 #[test]
20411 fn an_unbuffered_append_costs_a_small_constant_number_of_writes() {
20412 use tempfile::tempdir;
20413
20414 let dir = tempdir().unwrap();
20415 let path = dir.path().join("swmr_cost.h5");
20416 gather_fixture(&path, 1, false);
20417
20418 let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20419 let before = s.image.issued_writes();
20420 s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20421 let cost = s.image.issued_writes() - before;
20422
20423 assert!(
20424 cost > 0,
20425 "the append issued nothing, so the ceiling below proves nothing"
20426 );
20427 assert!(
20428 cost <= 10,
20429 "an unbuffered append costs {cost} writes; the array header's six \
20430 statistics belong in one write, not six, and each checksum belongs in \
20431 the write that changed what it covers (measured at 8)"
20432 );
20433 }
20434
20435 /// A SWMR writer gathers nothing: its readers follow the ordered phases as
20436 /// they become visible, and a phase that has not reached the operating system
20437 /// is a phase the reader cannot see.
20438 ///
20439 /// Stopping inside the durability sequence is what makes this selective. A
20440 /// completed append ends with a barrier, so it would land on disk under
20441 /// either setting; only a stop *inside* the sequence distinguishes a writer
20442 /// that gathers from one that does not. The file is read through a second handle, which SWMR
20443 /// permits precisely because it takes no lock.
20444 #[test]
20445 fn the_swmr_writer_holds_no_write_back() {
20446 use tempfile::tempdir;
20447
20448 let dir = tempdir().unwrap();
20449 let path = dir.path().join("swmr.h5");
20450 gather_fixture(&path, 1, false);
20451
20452 let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20453 let before = std::fs::read(&path).unwrap().len();
20454 // Phase 1 only: the chunk bytes and the superblock's end-of-file.
20455 s.append_inplace_i32_phased("t0", &[7; 64], 1).unwrap();
20456
20457 assert!(
20458 std::fs::read(&path).unwrap().len() > before,
20459 "a SWMR reader must see the phase-1 chunk bytes as soon as they are written"
20460 );
20461 }
20462
20463 /// The `fsync` cadence belongs to whoever the fapl says: every durability
20464 /// point in the engine — an immediate append's ordered barriers, a commit's
20465 /// barrier and repoint, the barrier a commit issues after truncating, the
20466 /// same-length-overwrite fast path, and the barrier `close` issues — answers
20467 /// to the session's [`SyncPolicy`], while `force_sync` (the explicit
20468 /// `File::sync`) answers to nobody (issue #263).
20469 ///
20470 /// The counts are compared as a table across the two policies rather than
20471 /// pinned to literals: how many `fsync`s a commit costs is an implementation
20472 /// detail that may fall, but that `OnClose` costs *none* and `Always` costs
20473 /// some at each of those points is the contract. The file is read back
20474 /// under both, since a skipped barrier must cost durability and nothing else
20475 /// — the bytes have already reached the operating system.
20476 ///
20477 /// Every stage here is a *distinct* barrier site. A commit tail that syncs
20478 /// twice does not stand in for the fast path that syncs once, nor for the
20479 /// post-truncate barrier that only a shrinking commit reaches: each is its
20480 /// own `self.barrier()` call that a later edit could regress to a bare
20481 /// `self.image.sync_all()` on its own — verified by mutating each site
20482 /// separately and watching this fail. The persisting tails and the
20483 /// consistency-flag write are covered by the test below.
20484 ///
20485 /// One site is left uncovered: the version 0/1 repoint branch of the
20486 /// non-persisting tail. A pre-v2 file cannot be produced by this crate's own
20487 /// writer — the crosscheck tests get one from the C library — and the
20488 /// counting image is a bounded one, which such a file needs the mirror
20489 /// instead of. Covering it needs a checked-in fixture and a second counting
20490 /// opener; it is named here so the gap is a known one rather than an
20491 /// assumed-covered one.
20492 #[test]
20493 fn sync_policy_governs_every_barrier() {
20494 use crate::writer::FileBuilder;
20495 use std::sync::Arc;
20496 use std::sync::atomic::{AtomicU64, Ordering};
20497 use tempfile::tempdir;
20498
20499 let dir = tempdir().unwrap();
20500 // [after an immediate append, after a staged commit, after a same-length
20501 // overwrite, after a commit that truncates, after the close barrier,
20502 // after an explicit sync].
20503 let run = |name: &str, policy: SyncPolicy| -> [u64; 5] {
20504 let path = dir.path().join(name);
20505 let mut b = FileBuilder::new();
20506 b.create_dataset("d")
20507 .with_i32_data(&(0..8).collect::<Vec<_>>())
20508 .with_shape(&[8])
20509 .with_maxshape(&[u64::MAX])
20510 .with_chunks(&[4]);
20511 b.write(&path).unwrap();
20512
20513 let syncs = Arc::new(AtomicU64::new(0));
20514 let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20515 s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20516 .unwrap();
20517 let after_append = syncs.load(Ordering::Relaxed);
20518 let mut db = crate::type_builders::DatasetBuilder::new("added");
20519 db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
20520 s.stage_created_dataset("/added", db).unwrap();
20521 s.commit().unwrap();
20522 let after_commit = syncs.load(Ordering::Relaxed);
20523
20524 // Same-length value overwrite: the commit fast path, which patches
20525 // the bytes where they lie and syncs without repointing anything.
20526 let mut ow = crate::type_builders::DatasetBuilder::new("added");
20527 ow.with_f64_data(&[4.5f64; 8]).with_shape(&[8]);
20528 s.stage_dataset_write("/added", ow).unwrap();
20529 s.commit().unwrap();
20530 let after_overwrite = syncs.load(Ordering::Relaxed);
20531
20532 // A delete whose freed run reaches end-of-file, so the commit
20533 // truncates and takes the barrier that only a shrinking commit does.
20534 s.delete("/added").unwrap();
20535 s.commit().unwrap();
20536 let after_truncate = syncs.load(Ordering::Relaxed);
20537
20538 s.force_sync().unwrap();
20539 let after_forced = syncs.load(Ordering::Relaxed);
20540
20541 // Release the exclusive OS lock before reading the file back; those
20542 // locks are mandatory on Windows.
20543 drop(s);
20544 let f = crate::reader::File::open(&path).unwrap();
20545 assert_eq!(
20546 f.dataset("d").unwrap().read_i32().unwrap(),
20547 (0..12).collect::<Vec<_>>(),
20548 "the append must land under {policy:?}"
20549 );
20550 assert!(
20551 f.dataset("added").is_err(),
20552 "the deleting commit must land under {policy:?}"
20553 );
20554 [
20555 after_append,
20556 after_commit,
20557 after_overwrite,
20558 after_truncate,
20559 after_forced,
20560 ]
20561 };
20562
20563 let always = run("always.h5", SyncPolicy::Always);
20564 assert!(always[0] > 0, "an immediate append syncs under Always");
20565 assert!(always[1] > always[0], "so does a commit");
20566 assert!(
20567 always[2] > always[1],
20568 "so does the same-length-overwrite fast path"
20569 );
20570 assert!(
20571 always[3] > always[2],
20572 "so does a commit that truncates the file"
20573 );
20574 assert_eq!(
20575 always[4],
20576 always[3] + 1,
20577 "and a forced sync is exactly one more"
20578 );
20579
20580 let deferred = run("on_close.h5", SyncPolicy::OnClose);
20581 assert_eq!(
20582 &deferred[..4],
20583 &[0, 0, 0, 0],
20584 "OnClose must leave every one of those in-session barriers unissued"
20585 );
20586 assert_eq!(
20587 deferred[4], 1,
20588 "a forced sync is issued whatever the policy says — it is what the \
20589 teardown path and File::sync both take"
20590 );
20591 }
20592
20593 /// The two barrier sites the table above cannot reach: the commit tail of a
20594 /// file that *persists* its free space (a different tail, with its own
20595 /// barrier and repoint, plus the manager re-homing `close` owes), and the
20596 /// superblock consistency-flag write a SWMR session makes on open and close.
20597 ///
20598 /// Both are `self.barrier()`/`self.barrier_data()` calls on paths the
20599 /// ordinary non-persisting session never executes, so without this a
20600 /// regression at either would pass the whole suite (issue #263).
20601 #[test]
20602 fn sync_policy_governs_the_persisting_and_flag_barriers() {
20603 use crate::writer::FileBuilder;
20604 use std::sync::Arc;
20605 use std::sync::atomic::{AtomicU64, Ordering};
20606 use tempfile::tempdir;
20607
20608 let dir = tempdir().unwrap();
20609 // [after a commit on a persisting file, after the flag write, after the
20610 // close barrier and its manager re-homing].
20611 let run = |name: &str, strategy: FileSpaceStrategy, policy: SyncPolicy| -> [u64; 3] {
20612 let path = dir.path().join(name);
20613 let mut b = FileBuilder::new();
20614 b.create_dataset("d")
20615 .with_i32_data(&(0..8).collect::<Vec<_>>())
20616 .with_shape(&[8])
20617 .with_maxshape(&[u64::MAX])
20618 .with_chunks(&[4]);
20619 b.create_dataset("victim")
20620 .with_f64_data(&[1.5f64; 64])
20621 .with_shape(&[64]);
20622 // A paged file takes the page-aware tail, a different pair of barrier
20623 // calls from the plain persisting one; `0` asks for the default page
20624 // size, which the plain strategy ignores.
20625 b.with_file_space_strategy(
20626 strategy,
20627 true,
20628 if strategy == FileSpaceStrategy::Page {
20629 0
20630 } else {
20631 1
20632 },
20633 );
20634 b.write(&path).unwrap();
20635
20636 let syncs = Arc::new(AtomicU64::new(0));
20637 let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20638 assert!(
20639 s.persist.is_some(),
20640 "the fixture must persist its free space, or this tests the wrong tail"
20641 );
20642 // A delete on a persisting file takes `commit_persisting`: the free
20643 // space is recorded on disk rather than truncated away.
20644 s.delete("/victim").unwrap();
20645 s.commit().unwrap();
20646 let after_commit = syncs.load(Ordering::Relaxed);
20647
20648 s.set_consistency_flags(0).unwrap();
20649 let after_flags = syncs.load(Ordering::Relaxed);
20650 // The flag write's barrier orders as well as syncs, like the other
20651 // two. Its production callers all force a sync straight after, which
20652 // is why nothing else would notice it stopping — the same reason
20653 // `finalize_persist_has_nothing_left_to_write` exists.
20654 let issued = s.image.issued_writes();
20655 s.force_sync().unwrap();
20656 assert_eq!(
20657 s.image.issued_writes(),
20658 issued,
20659 "{policy:?}: the consistency-flag write was left gathered"
20660 );
20661
20662 // An immediate append leaves the on-disk managers mid-file, which is
20663 // the debt `finalize_persist` settles with a second commit tail at
20664 // close — the writes no earlier `sync` can have covered.
20665 s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20666 .unwrap();
20667 let before_close = syncs.load(Ordering::Relaxed);
20668 s.finalize_persist().unwrap();
20669 let after_close = syncs.load(Ordering::Relaxed) - before_close;
20670
20671 drop(s);
20672 let f = crate::reader::File::open(&path).unwrap();
20673 assert!(
20674 f.dataset("victim").is_err(),
20675 "the delete must land under {policy:?}"
20676 );
20677 assert_eq!(
20678 f.dataset("d").unwrap().read_i32().unwrap(),
20679 (0..12).collect::<Vec<_>>(),
20680 "and so must the append under {policy:?}"
20681 );
20682 [after_commit, after_flags, after_close]
20683 };
20684
20685 // Both persisting tails: the plain one and the page-aware one, which is a
20686 // separate pair of barrier calls reached only by a paged file.
20687 for (label, strategy) in [
20688 ("fsmaggr", FileSpaceStrategy::FsmAggr),
20689 ("paged", FileSpaceStrategy::Page),
20690 ] {
20691 let always = run(&format!("{label}_always.h5"), strategy, SyncPolicy::Always);
20692 assert!(
20693 always[0] > 0,
20694 "the {label} persisting commit tail syncs under Always"
20695 );
20696 assert!(
20697 always[1] > always[0],
20698 "so does the consistency-flag write a SWMR session makes ({label})"
20699 );
20700 assert!(
20701 always[2] > 0,
20702 "so does the manager re-homing close owes ({label})"
20703 );
20704
20705 assert_eq!(
20706 run(
20707 &format!("{label}_on_close.h5"),
20708 strategy,
20709 SyncPolicy::OnClose
20710 ),
20711 [0, 0, 0],
20712 "OnClose must leave the {label} persisting tail, the flag write, and the \
20713 close-time manager re-homing with no fsync at all"
20714 );
20715 }
20716 }
20717
20718 /// A write that alters the file and *then* reports failure is a real device
20719 /// behaviour — it is the one `TornWriteImage` models — so a `write_at`
20720 /// returning an error does not mean its bytes missed the disk. When the
20721 /// write in question is the superblock repoint, that makes "did this commit
20722 /// publish?" unanswerable from inside the engine, and every rollback
20723 /// unsound: undoing the values would tear a commit that did land.
20724 ///
20725 /// So the flag that gates the rollback is raised *before* the publish is
20726 /// issued, not after it returns, and this pins that. The commit here does
20727 /// publish — `/extra` is readable afterwards, which is only possible
20728 /// through the new root — while its `commit` returns the write's error.
20729 /// Rolling back under it would leave `/nums` holding the pre-commit value
20730 /// inside a tree that is otherwise entirely the new one (issue #344).
20731 #[test]
20732 fn a_commit_whose_publish_write_fails_rolls_nothing_back() {
20733 use crate::writer::FileBuilder;
20734 use tempfile::tempdir;
20735
20736 let dir = tempdir().unwrap();
20737 let path = dir.path().join("torn_publish.h5");
20738 let mut b = FileBuilder::new();
20739 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20740 b.write(&path).unwrap();
20741
20742 // The superblock sits at offset 0 on a file with no userblock.
20743 let mut s = WriteEngine::open_torn_writes(&path, 0..48).unwrap();
20744 s.stage_dataset_write("/nums", {
20745 let mut db = crate::type_builders::DatasetBuilder::new("");
20746 db.with_i32_data(&[9, 9, 9]);
20747 db
20748 })
20749 .unwrap();
20750 s.stage_created_dataset("/extra", {
20751 let mut db = crate::type_builders::DatasetBuilder::new("");
20752 db.with_i32_data(&[42]);
20753 db
20754 })
20755 .unwrap();
20756 let failed = s.commit();
20757 assert!(
20758 matches!(failed, Err(Error::Io(_))),
20759 "the publish write is what failed: {failed:?}"
20760 );
20761 drop(s);
20762
20763 let f = crate::reader::File::open(&path).unwrap();
20764 assert_eq!(
20765 f.dataset("extra").unwrap().read_i32().unwrap(),
20766 vec![42],
20767 "the publish landed despite reporting failure, so this commit is live"
20768 );
20769 assert_eq!(
20770 f.dataset("nums").unwrap().read_i32().unwrap(),
20771 vec![9, 9, 9],
20772 "a rollback under a commit that did publish would tear it"
20773 );
20774 }
20775
20776 /// Two hard links to one dataset are two paths over one data block, and the
20777 /// commit's duplicate guard is by path — so one commit can journal the same
20778 /// address twice, the second entry holding what the first write put there.
20779 /// The undo therefore has to replay newest-first; oldest-first finishes on a
20780 /// value from the very batch being rolled back (issue #344).
20781 ///
20782 /// A same-length overwrite through either link is deliberately allowed —
20783 /// it rewrites the block every link sees, so unlike a relocating write it
20784 /// needs no single-hard-link rule (`write_dataset_shared_hard_link_crosscheck`
20785 /// is the interop half of that). Which is what makes this reachable.
20786 ///
20787 /// The second link is made with the reference C library because this crate
20788 /// has no API that creates one: the file is a committed one, see
20789 /// `crates/crosscheck/tests/c_test_data.rs`.
20790 #[test]
20791 fn an_undo_replays_two_hard_links_to_one_block_newest_first() {
20792 use tempfile::tempdir;
20793
20794 let dir = tempdir().unwrap();
20795 let path = crate::test_data::copy("c/hard_link_undo.h5", dir.path());
20796
20797 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20798 // Fail the commit in its tail, after both writes have landed.
20799 s.superblock.superblock_extension_address = Some(0);
20800 for (name, data) in [("/aa", [9, 9, 9]), ("/bb", [8, 8, 8])] {
20801 s.stage_dataset_write(name, {
20802 let mut db = crate::type_builders::DatasetBuilder::new("");
20803 db.with_i32_data(&data);
20804 db
20805 })
20806 .unwrap();
20807 }
20808 // A second kind of edit, so the batch takes the full commit path and has
20809 // a tail to fail in.
20810 s.stage_created_dataset("/extra", {
20811 let mut db = crate::type_builders::DatasetBuilder::new("");
20812 db.with_i32_data(&[42]);
20813 db
20814 })
20815 .unwrap();
20816 assert!(s.commit().is_err());
20817 drop(s);
20818
20819 let f = crate::reader::File::open(&path).unwrap();
20820 assert_eq!(
20821 f.dataset("aa").unwrap().read_i32().unwrap(),
20822 vec![1, 2, 3],
20823 "the block both links share must hold the value it held before"
20824 );
20825 assert_eq!(f.dataset("bb").unwrap().read_i32().unwrap(), vec![1, 2, 3]);
20826 }
20827
20828 /// The chunked in-place path (`WritePlan::InPlaceChunks`) journals one entry
20829 /// per chunk, and — when the chunks shrink — the rewritten chunk index
20830 /// beside them. A partial restore would leave the index recording the new
20831 /// sizes against the old bytes, so this is the case where the rollback has
20832 /// most to put back (issue #344).
20833 ///
20834 /// Filtered, and overwritten with data that compresses to almost nothing, so
20835 /// every chunk's recorded size changes and the index write is part of what
20836 /// is rolled back rather than an unchanged block.
20837 #[test]
20838 fn a_failed_commit_puts_back_every_chunk_it_overwrote() {
20839 use crate::writer::FileBuilder;
20840 use tempfile::tempdir;
20841
20842 let dir = tempdir().unwrap();
20843 let path = dir.path().join("chunked_undo.h5");
20844 let original: Vec<i32> = (0..4096i32)
20845 .map(|i| i.wrapping_mul(2_654_435_761u32 as i32) ^ i)
20846 .collect();
20847 let mut b = FileBuilder::new();
20848 b.create_dataset("grid")
20849 .with_i32_data(&original)
20850 .with_shape(&[4096])
20851 .with_chunks(&[512])
20852 .with_deflate(6);
20853 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20854 b.write(&path).unwrap();
20855
20856 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20857 s.superblock.superblock_extension_address = Some(0);
20858 s.stage_dataset_write("/grid", {
20859 // Values only: an overwrite that restated the chunking or the
20860 // filter would be refused as asking for more than an overwrite
20861 // (issue #318). The layout comes from the dataset already there.
20862 let mut db = crate::type_builders::DatasetBuilder::new("");
20863 db.with_i32_data(&vec![0i32; 4096]);
20864 db
20865 })
20866 .unwrap();
20867 s.stage_created_dataset("/extra", {
20868 let mut db = crate::type_builders::DatasetBuilder::new("");
20869 db.with_i32_data(&[42]);
20870 db
20871 })
20872 .unwrap();
20873 assert!(s.commit().is_err());
20874 drop(s);
20875
20876 let f = crate::reader::File::open(&path).unwrap();
20877 assert_eq!(
20878 f.dataset("grid").unwrap().read_i32().unwrap(),
20879 original,
20880 "every chunk, and the index sizing them, must read as it did before"
20881 );
20882 }
20883
20884 /// A commit clears the journal on entry, so it can never replay entries it
20885 /// did not write itself (issue #344).
20886 ///
20887 /// Only an attempt that *unwound* leaves any — a panic caught by the session
20888 /// lock, which recovers from poisoning — and the state it leaves is what
20889 /// this constructs directly rather than by finding something to panic in:
20890 /// an entry naming an address whose contents have moved on. Replaying it
20891 /// would write bytes captured before the unwind over whatever lives there
20892 /// now, and the entry clear on the way *out* cannot help, because that is
20893 /// the step the unwind skipped.
20894 ///
20895 /// `superseded_heaps` two fields up clears at entry for the same reason and
20896 /// says so; this is that rule applied to the journal.
20897 #[test]
20898 fn a_commit_does_not_replay_a_journal_it_did_not_write() {
20899 use crate::writer::FileBuilder;
20900 use tempfile::tempdir;
20901
20902 let dir = tempdir().unwrap();
20903 let path = dir.path().join("stale_journal.h5");
20904 let mut b = FileBuilder::new();
20905 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20906 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20907 b.write(&path).unwrap();
20908
20909 let nums_block = {
20910 let f = crate::reader::File::open(&path).unwrap();
20911 match f.dataset("nums").unwrap().layout().unwrap() {
20912 crate::Layout::Contiguous {
20913 address: Some(a), ..
20914 } => a as usize,
20915 other => panic!("expected a contiguous dataset: {other:?}"),
20916 }
20917 };
20918
20919 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20920 // Exactly what an attempt that unwound past `commit` leaves behind.
20921 let stale: Vec<u8> = [7i32, 7, 7].iter().flat_map(|v| v.to_le_bytes()).collect();
20922 s.inplace_undo.push((nums_block, stale));
20923
20924 // An unrelated commit, failed in its tail so that it rolls back at all.
20925 s.superblock.superblock_extension_address = Some(0);
20926 s.stage_created_dataset("/extra", {
20927 let mut db = crate::type_builders::DatasetBuilder::new("");
20928 db.with_i32_data(&[42]);
20929 db
20930 })
20931 .unwrap();
20932 assert!(s.commit().is_err());
20933 drop(s);
20934
20935 let f = crate::reader::File::open(&path).unwrap();
20936 assert_eq!(
20937 f.dataset("nums").unwrap().read_i32().unwrap(),
20938 vec![1, 2, 3],
20939 "the commit replayed a journal entry that was not its own"
20940 );
20941 }
20942
20943 /// Putting the values back is a write like any other, so it has to be
20944 /// ordered like one: a restore left sitting in the gathering buffer can be
20945 /// issued after a later write to the same block, which is the reordering
20946 /// hazard write gathering introduced (issue #288).
20947 ///
20948 /// Measured as a difference rather than a total, so it states the rule and
20949 /// not one commit's arithmetic: a refused commit with nothing to put back
20950 /// issues no barrier at all — it fails before the one it would have made —
20951 /// and the same refusal carrying a value overwrite issues exactly the one
20952 /// that orders the restore.
20953 #[test]
20954 fn a_rollback_orders_the_values_it_puts_back() {
20955 use crate::writer::FileBuilder;
20956 use std::sync::Arc;
20957 use std::sync::atomic::{AtomicU64, Ordering};
20958 use tempfile::tempdir;
20959
20960 let mut counted = Vec::new();
20961 for with_overwrite in [false, true] {
20962 let dir = tempdir().unwrap();
20963 let path = dir.path().join("rollback_syncs.h5");
20964 let mut b = FileBuilder::new();
20965 b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20966 b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20967 b.write(&path).unwrap();
20968
20969 let syncs = Arc::new(AtomicU64::new(0));
20970 let mut s =
20971 WriteEngine::open_sync_counting(&path, SyncPolicy::Always, Arc::clone(&syncs))
20972 .unwrap();
20973 // Fail the commit in its tail, after the apply phase has written.
20974 s.superblock.superblock_extension_address = Some(0);
20975 if with_overwrite {
20976 s.stage_dataset_write("/nums", {
20977 let mut db = crate::type_builders::DatasetBuilder::new("");
20978 db.with_i32_data(&[9, 9, 9]);
20979 db
20980 })
20981 .unwrap();
20982 }
20983 s.stage_created_dataset("/extra", {
20984 let mut db = crate::type_builders::DatasetBuilder::new("");
20985 db.with_i32_data(&[42]);
20986 db
20987 })
20988 .unwrap();
20989 let before = syncs.load(Ordering::Relaxed);
20990 assert!(s.commit().is_err());
20991 counted.push(syncs.load(Ordering::Relaxed) - before);
20992 }
20993
20994 assert_eq!(
20995 counted[0], 0,
20996 "a refusal with nothing to put back reaches no barrier"
20997 );
20998 assert_eq!(
20999 counted[1],
21000 counted[0] + 1,
21001 "a refusal that put values back must order them: {counted:?}"
21002 );
21003 }
21004
21005 /// The journal reads back what it is about to overwrite, so a bounded
21006 /// commit's reads scale with the value being replaced rather than staying
21007 /// near the metadata-only figure
21008 /// [`a_bounded_commit_reads_far_less_than_the_file`] pins for a commit that
21009 /// overwrites nothing.
21010 ///
21011 /// This is the cost of the guarantee in issue #344 and it is charged even
21012 /// when the commit succeeds, since whether the bytes will be needed is not
21013 /// known until the attempt ends. Stated as a rule — reads land between the
21014 /// payload and a small multiple of it — rather than as one target's exact
21015 /// count, so it holds wherever the suite runs.
21016 #[test]
21017 fn a_bounded_commit_reads_the_value_it_is_replacing() {
21018 use crate::writer::FileBuilder;
21019 use std::sync::Arc;
21020 use std::sync::atomic::{AtomicU64, Ordering};
21021 use tempfile::tempdir;
21022
21023 let dir = tempdir().unwrap();
21024 let p = dir.path().join("journal_reads.h5");
21025 let data: Vec<i32> = (0..250_000).collect();
21026 let payload = (data.len() * 4) as u64;
21027 let mut b = FileBuilder::new();
21028 b.create_dataset("nums").with_i32_data(&data);
21029 b.write(&p).unwrap();
21030
21031 let read_bytes = Arc::new(AtomicU64::new(0));
21032 let mut engine = WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
21033 engine
21034 .stage_dataset_write("/nums", {
21035 let mut db = crate::type_builders::DatasetBuilder::new("");
21036 db.with_i32_data(&data);
21037 db
21038 })
21039 .unwrap();
21040 let before = read_bytes.load(Ordering::Relaxed);
21041 engine.commit().unwrap();
21042 let read = read_bytes.load(Ordering::Relaxed) - before;
21043
21044 assert!(
21045 read >= payload,
21046 "the journal must read the whole value it replaces: {read} of {payload}"
21047 );
21048 assert!(
21049 read < payload + (64 << 10),
21050 "and not much more than it: {read} against {payload}"
21051 );
21052 }
21053}
21054
21055#[cfg(test)]
21056mod staged_query_tests {
21057 //! The queries a handle asks about objects a session has staged and not
21058 //! committed (issue #392).
21059
21060 use super::*;
21061 use crate::type_builders::DatasetBuilder;
21062 use tempfile::tempdir;
21063
21064 /// A session over a file holding one dataset, `existing`.
21065 fn open_session(path: &Path) -> WriteEngine {
21066 let mut b = crate::writer::FileBuilder::new();
21067 b.create_dataset("existing").with_i32_data(&[1, 2, 3]);
21068 b.write(path).unwrap();
21069 WriteEngine::open_rw_with_strategy(
21070 path,
21071 crate::source::MetadataCacheConfig::disabled(),
21072 FileLocking::Enabled,
21073 MemoryStrategy::Mirrored,
21074 )
21075 .unwrap()
21076 }
21077
21078 fn i32_dataset(data: &[i32]) -> DatasetBuilder {
21079 let mut b = DatasetBuilder::new("");
21080 b.with_i32_data(data);
21081 b
21082 }
21083
21084 fn kind(e: &WriteEngine, path: &str) -> Option<StagedKind> {
21085 e.staged_object(path).map(|o| o.kind)
21086 }
21087
21088 fn child_names(e: &WriteEngine, parent: &str) -> Vec<(String, StagedKind)> {
21089 e.staged_children(parent)
21090 .into_iter()
21091 .map(|c| (c.name, c.kind))
21092 .collect()
21093 }
21094
21095 #[test]
21096 fn a_staged_creation_is_named_at_every_level_of_its_path() {
21097 let dir = tempdir().unwrap();
21098 let mut e = open_session(&dir.path().join("q.h5"));
21099 e.create_group("a").unwrap();
21100 let mut col = DatasetBuilder::new("");
21101 col.with_i32_data(&[1, 2])
21102 .with_maxshape(&[u64::MAX])
21103 .with_chunks(&[2]);
21104 e.stage_created_dataset("a/b/col", col).unwrap();
21105
21106 assert_eq!(kind(&e, "a"), Some(StagedKind::Group));
21107 assert_eq!(kind(&e, "a/b/col"), Some(StagedKind::Dataset));
21108 // `a/b` was never named: the commit creates it on the way to `col`, and
21109 // a session cannot tell from its staged set whether such a group is one
21110 // it is adding or one the file already holds. Naming it is the way to
21111 // address it.
21112 assert_eq!(kind(&e, "a/b"), None);
21113 // The root always exists, an on-disk object is not staged, and neither
21114 // is a name nothing reaches.
21115 assert_eq!(kind(&e, ""), None);
21116 assert_eq!(kind(&e, "existing"), None);
21117 assert_eq!(kind(&e, "a/missing"), None);
21118
21119 assert_eq!(
21120 child_names(&e, ""),
21121 vec![("a".to_string(), StagedKind::Group)]
21122 );
21123 assert_eq!(
21124 child_names(&e, "a/b"),
21125 vec![("col".to_string(), StagedKind::Dataset)]
21126 );
21127 // `b` under `a` is the unnamed intermediate again.
21128 assert!(child_names(&e, "a").is_empty());
21129 assert!(child_names(&e, "existing").is_empty());
21130
21131 // Named outright, it answers at every level.
21132 e.create_group("a/b").unwrap();
21133 assert_eq!(kind(&e, "a/b"), Some(StagedKind::Group));
21134 assert_eq!(
21135 child_names(&e, "a"),
21136 vec![("b".to_string(), StagedKind::Group)]
21137 );
21138 }
21139
21140 #[test]
21141 fn a_staged_dataset_reports_what_its_builder_settled() {
21142 let dir = tempdir().unwrap();
21143 let mut e = open_session(&dir.path().join("q.h5"));
21144 let mut col = DatasetBuilder::new("");
21145 col.with_i32_data(&[1, 2, 3, 4])
21146 .with_maxshape(&[u64::MAX])
21147 .with_chunks(&[2])
21148 .with_deflate(4);
21149 e.stage_created_dataset("col", col).unwrap();
21150 e.stage_created_dataset("plain", i32_dataset(&[5])).unwrap();
21151
21152 let meta = e.staged_dataset_meta("col").unwrap();
21153 assert_eq!(meta.dimensions, vec![4]);
21154 assert_eq!(meta.maxshape, Some(vec![u64::MAX]));
21155 assert_eq!(meta.datatype.type_size(), 4);
21156 assert!(meta.chunked);
21157 assert_eq!(meta.filters, vec![(1u16, false)]);
21158
21159 // A fixed-shape, unfiltered dataset is contiguous and reports no
21160 // maximum, the way `Dataset::maxshape` reports an on-disk one.
21161 let plain = e.staged_dataset_meta("plain").unwrap();
21162 assert_eq!(plain.dimensions, vec![1]);
21163 assert_eq!(plain.maxshape, None);
21164 assert!(!plain.chunked);
21165 assert!(plain.filters.is_empty());
21166
21167 // Only datasets answer.
21168 e.create_group("g").unwrap();
21169 assert!(e.staged_dataset_meta("g").is_none());
21170 assert!(e.staged_dataset_meta("existing").is_none());
21171 }
21172
21173 #[test]
21174 fn a_deletion_hides_nothing_until_a_creation_replaces_it() {
21175 let dir = tempdir().unwrap();
21176 let mut e = open_session(&dir.path().join("q.h5"));
21177 e.delete("existing").unwrap();
21178 // The object is still in the file, and still readable, so a handle must
21179 // not be told it is staged.
21180 assert_eq!(kind(&e, "existing"), None);
21181 assert!(e.staged_children("").is_empty());
21182
21183 e.stage_created_dataset("existing", i32_dataset(&[9]))
21184 .unwrap();
21185 let staged = e.staged_object("existing").unwrap();
21186 assert_eq!(staged.kind, StagedKind::Dataset);
21187 assert!(staged.replaces_link, "the same commit removes the link");
21188 assert_eq!(
21189 e.staged_dataset_meta("existing").unwrap().dimensions,
21190 vec![1]
21191 );
21192 assert!(e.staged_children("")[0].replaces_link);
21193 }
21194
21195 #[test]
21196 fn a_creation_colliding_with_a_surviving_link_is_refused_where_it_is_staged() {
21197 let dir = tempdir().unwrap();
21198 let mut e = open_session(&dir.path().join("q.h5"));
21199 // No deletion beside it, so this creation collides with a link the
21200 // commit keeps. That is the commit's own refusal, raised where the call
21201 // is made so no handle onto the creation is ever handed back: such a
21202 // handle would have addressed the file's `existing` while claiming to
21203 // address the new one.
21204 let err = e
21205 .stage_created_dataset("existing", i32_dataset(&[9]))
21206 .unwrap_err();
21207 assert!(
21208 matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21209 "got: {err}"
21210 );
21211 // The same rule for a group, which is also the kind change a listing
21212 // would otherwise misreport as turning a dataset into a group.
21213 assert!(e.create_group("existing").is_err());
21214
21215 // Nothing was staged, so the file's own object is still what the name
21216 // means, and the session has nothing left to refuse at commit time.
21217 assert_eq!(kind(&e, "existing"), None);
21218 assert!(e.staged_dataset_meta("existing").is_none());
21219 assert!(e.staged_children("").is_empty());
21220 e.commit().unwrap();
21221 }
21222
21223 #[test]
21224 fn a_second_creation_at_a_staged_path_is_refused_where_it_is_staged() {
21225 let dir = tempdir().unwrap();
21226 let mut e = open_session(&dir.path().join("q.h5"));
21227 e.stage_created_dataset("fresh", i32_dataset(&[1, 2, 3]))
21228 .unwrap();
21229
21230 // The staged set is indexed by path and keeps the first record there, so
21231 // a second creation would be staged behind the first and the handle this
21232 // call hands back would answer for the first one's shape and datatype.
21233 let err = e
21234 .stage_created_dataset("fresh", i32_dataset(&[9]))
21235 .unwrap_err();
21236 assert!(
21237 matches!(&err, Error::EditUnsupported(m) if m.contains("already stages")),
21238 "got: {err}"
21239 );
21240 // Refused, not replaced: the first creation is exactly as it was, and it
21241 // is still the only thing staged at that name.
21242 assert_eq!(e.staged_dataset_meta("fresh").unwrap().dimensions, vec![3]);
21243 assert_eq!(
21244 child_names(&e, ""),
21245 vec![("fresh".to_string(), StagedKind::Dataset)]
21246 );
21247
21248 // The two kinds collide with each other for the same reason: one path
21249 // cannot name both, and whichever the index found first is what every
21250 // handle onto it would report.
21251 assert!(e.create_group("fresh").is_err());
21252 e.create_group("g").unwrap();
21253 assert!(e.stage_created_dataset("g", i32_dataset(&[9])).is_err());
21254 assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21255
21256 e.commit().unwrap();
21257 }
21258
21259 #[test]
21260 fn a_replacement_is_staged_once_and_a_withdrawal_frees_the_name_again() {
21261 let dir = tempdir().unwrap();
21262 let mut e = open_session(&dir.path().join("q.h5"));
21263 // A creation over a link this session deletes is a replacement, which
21264 // the file arm admits — but only one of them, since the second would
21265 // still be staged behind the first.
21266 e.delete("existing").unwrap();
21267 e.stage_created_dataset("existing", i32_dataset(&[9]))
21268 .unwrap();
21269 assert!(
21270 e.stage_created_dataset("existing", i32_dataset(&[8, 8]))
21271 .is_err()
21272 );
21273 assert_eq!(
21274 e.staged_dataset_meta("existing").unwrap().dimensions,
21275 vec![1]
21276 );
21277
21278 // Deleting a staged creation withdraws it, which is what puts the name
21279 // back within reach of another creation.
21280 e.delete("existing").unwrap();
21281 e.stage_created_dataset("existing", i32_dataset(&[8, 8]))
21282 .unwrap();
21283 assert_eq!(
21284 e.staged_dataset_meta("existing").unwrap().dimensions,
21285 vec![2]
21286 );
21287 e.commit().unwrap();
21288 }
21289
21290 #[test]
21291 fn a_group_staged_twice_is_one_group_and_stays_allowed() {
21292 let dir = tempdir().unwrap();
21293 let mut e = open_session(&dir.path().join("q.h5"));
21294 // Two group creations at one path name one node: the commit builds a
21295 // single group from them, so a handle onto either addresses it and
21296 // nothing is misreported. Re-staging is how attributes and children are
21297 // added to a group already staged.
21298 e.create_group("g").unwrap();
21299 e.create_group("g").unwrap();
21300 assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21301 assert_eq!(
21302 child_names(&e, ""),
21303 vec![("g".to_string(), StagedKind::Group)]
21304 );
21305 e.stage_created_dataset("g/inner", i32_dataset(&[7]))
21306 .unwrap();
21307 e.commit().unwrap();
21308 }
21309
21310 #[test]
21311 fn a_creation_under_a_deleted_group_the_session_does_not_rebuild_shadows_nothing() {
21312 // `delete("g")` carries `g/inner` away with it, but only a commit that
21313 // builds `g` again can put anything back under that path — and this one
21314 // does not, so the commit refuses the batch. Until then the file's own
21315 // `g/inner` is what the name means: calling the staged creation beside
21316 // it a replacement would make a live, readable dataset answer
21317 // `NotCommitted` and vanish from its group's listing (issue #392).
21318 let dir = tempdir().unwrap();
21319 let path = dir.path().join("prefix.h5");
21320 let mut b = crate::writer::FileBuilder::new();
21321 let mut g = b.create_group("g");
21322 g.create_dataset("inner").with_i32_data(&[1]);
21323 b.add_group(g.finish());
21324 b.write(&path).unwrap();
21325
21326 let mut e = WriteEngine::open_rw_with_strategy(
21327 &path,
21328 crate::source::MetadataCacheConfig::disabled(),
21329 FileLocking::Enabled,
21330 MemoryStrategy::Mirrored,
21331 )
21332 .unwrap();
21333 e.delete("g").unwrap();
21334 // The name `g/inner` is still taken: a deletion of `g` alone hands it
21335 // over to nobody, so this is the collision it looks like rather than a
21336 // replacement.
21337 let err = e
21338 .stage_created_dataset("g/inner", i32_dataset(&[9]))
21339 .unwrap_err();
21340 assert!(
21341 matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21342 "got: {err}"
21343 );
21344 assert_eq!(kind(&e, "g/inner"), None, "the file's own dataset");
21345
21346 // A name the file does not hold stages, and is not a replacement of
21347 // anything; the commit refuses the batch for the overlap itself.
21348 e.stage_created_dataset("g/other", i32_dataset(&[9]))
21349 .unwrap();
21350 assert!(
21351 !e.staged_children("g")[0].replaces_link,
21352 "nothing rebuilds `g`, so its names are not handed over"
21353 );
21354 assert!(e.commit().is_err(), "a deletion overlapping an addition");
21355 }
21356
21357 #[test]
21358 fn a_deleted_group_rebuilt_in_the_same_commit_hands_its_names_over() {
21359 // The other side of that rule: `g` is replaced, so a creation under it
21360 // owns its name from the moment it is staged and the object the commit
21361 // removes is no longer what the path means.
21362 let dir = tempdir().unwrap();
21363 let path = dir.path().join("replaced.h5");
21364 let mut b = crate::writer::FileBuilder::new();
21365 let mut g = b.create_group("g");
21366 g.create_dataset("inner").with_i32_data(&[1]);
21367 b.add_group(g.finish());
21368 b.write(&path).unwrap();
21369
21370 let mut e = WriteEngine::open_rw_with_strategy(
21371 &path,
21372 crate::source::MetadataCacheConfig::disabled(),
21373 FileLocking::Enabled,
21374 MemoryStrategy::Mirrored,
21375 )
21376 .unwrap();
21377 e.delete("g").unwrap();
21378 e.create_group("g").unwrap();
21379 e.stage_created_dataset("g/inner", i32_dataset(&[9]))
21380 .unwrap();
21381 assert_eq!(kind(&e, "g/inner"), Some(StagedKind::Dataset));
21382 assert!(e.staged_object("g/inner").unwrap().replaces_link);
21383 assert!(e.staged_children("g")[0].replaces_link);
21384 e.commit().unwrap();
21385 }
21386
21387 #[test]
21388 fn the_root_cannot_be_deleted() {
21389 // Nothing links to the root, so there is no link to remove — and an
21390 // empty path is a prefix of every other, so a deletion staged there
21391 // would make every creation in the session look like a replacement.
21392 let dir = tempdir().unwrap();
21393 let mut e = open_session(&dir.path().join("q.h5"));
21394 for path in ["", "/"] {
21395 let err = e.delete(path).unwrap_err();
21396 assert!(
21397 matches!(&err, Error::EditUnsupported(m) if m.contains("root group")),
21398 "got: {err}"
21399 );
21400 }
21401 e.stage_created_dataset("fresh", i32_dataset(&[1])).unwrap();
21402 assert!(
21403 !e.staged_object("fresh").unwrap().replaces_link,
21404 "no deletion was staged, so this replaces nothing"
21405 );
21406 }
21407
21408 #[test]
21409 fn an_append_onto_a_staged_dataset_grows_the_pending_creation() {
21410 let dir = tempdir().unwrap();
21411 let mut e = open_session(&dir.path().join("q.h5"));
21412 e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21413 .unwrap();
21414
21415 let mut b = AppendBuilder::new();
21416 b.append_i32(&[3, 4]);
21417 e.stage_dataset_append_pending("col", b).unwrap();
21418 assert_eq!(e.staged_dataset_meta("col").unwrap().dimensions, vec![4]);
21419 // Folded into the creation rather than queued beside it, so the commit
21420 // has one dataset to write and no append to apply to it.
21421 assert!(e.staged.appends.is_empty());
21422
21423 e.commit().unwrap();
21424 let addr = crate::group_v2::resolve_path_any_from_source(&e.image(), e.superblock(), "col")
21425 .unwrap();
21426 assert!(addr > 0);
21427 }
21428
21429 #[test]
21430 fn an_append_through_a_handle_onto_the_replaced_object_is_refused() {
21431 let dir = tempdir().unwrap();
21432 let mut e = open_session(&dir.path().join("q.h5"));
21433 e.delete("existing").unwrap();
21434 e.stage_created_dataset("existing", i32_dataset(&[100]))
21435 .unwrap();
21436
21437 // `stage_dataset_append` is the entry point a handle onto the object in
21438 // the file uses. Growing the replacement under it would silently move
21439 // the elements to another object.
21440 let mut b = AppendBuilder::new();
21441 b.append_i32(&[200]);
21442 assert!(matches!(
21443 e.stage_dataset_append("existing", b),
21444 Err(Error::EditUnsupported(_))
21445 ));
21446 assert_eq!(
21447 e.staged_dataset_meta("existing").unwrap().dimensions,
21448 vec![1],
21449 "the refusal must leave the replacement alone"
21450 );
21451 assert!(e.staged.appends.is_empty());
21452 }
21453
21454 #[test]
21455 fn an_append_the_staged_dataset_cannot_carry_is_refused_without_changing_it() {
21456 let dir = tempdir().unwrap();
21457 let mut e = open_session(&dir.path().join("q.h5"));
21458 e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21459 .unwrap();
21460
21461 let mut wrong_type = AppendBuilder::new();
21462 wrong_type.append_f64(&[1.0]);
21463 assert!(matches!(
21464 e.stage_dataset_append_pending("col", wrong_type),
21465 Err(Error::AppendUnsupported(_))
21466 ));
21467
21468 let mut partial = AppendBuilder::new();
21469 partial.append_raw(&[0u8, 1, 2]);
21470 assert!(matches!(
21471 e.stage_dataset_append_pending("col", partial),
21472 Err(Error::AppendUnsupported(_))
21473 ));
21474
21475 // A finite maximum is a promise the commit would refuse to break.
21476 let mut capped = DatasetBuilder::new("");
21477 capped
21478 .with_i32_data(&[1, 2])
21479 .with_maxshape(&[3])
21480 .with_chunks(&[2]);
21481 e.stage_created_dataset("capped", capped).unwrap();
21482 let mut over = AppendBuilder::new();
21483 over.append_i32(&[3, 4]);
21484 assert!(matches!(
21485 e.stage_dataset_append_pending("capped", over),
21486 Err(Error::AppendUnsupported(_))
21487 ));
21488
21489 // Nothing the refusals touched changed.
21490 assert_eq!(e.staged_dataset_meta("col").unwrap().dimensions, vec![2]);
21491 assert_eq!(e.staged_dataset_meta("capped").unwrap().dimensions, vec![2]);
21492 }
21493
21494 #[test]
21495 fn an_append_onto_a_committed_dataset_still_stages_an_append() {
21496 let dir = tempdir().unwrap();
21497 let mut e = open_session(&dir.path().join("q.h5"));
21498 let mut b = AppendBuilder::new();
21499 b.append_i32(&[4]);
21500 e.stage_dataset_append("existing", b).unwrap();
21501 assert_eq!(e.staged.appends.len(), 1);
21502
21503 // And so does one made through a handle whose creation was committed
21504 // between the caller's check and this lock.
21505 let mut b = AppendBuilder::new();
21506 b.append_i32(&[5]);
21507 e.stage_dataset_append_pending("existing", b).unwrap();
21508 assert_eq!(e.staged.appends.len(), 2);
21509 }
21510
21511 #[test]
21512 fn deleting_a_staged_creation_withdraws_it() {
21513 let dir = tempdir().unwrap();
21514 let mut e = open_session(&dir.path().join("q.h5"));
21515 e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21516 .unwrap();
21517 e.delete("col").unwrap();
21518 // Withdrawn, not queued for a deletion the commit could not perform.
21519 assert_eq!(kind(&e, "col"), None);
21520 assert!(e.staged.deletes.is_empty());
21521 assert!(!e.has_staged_edits());
21522 e.commit().unwrap();
21523 }
21524
21525 #[test]
21526 fn deleting_a_staged_group_withdraws_its_staged_subtree() {
21527 let dir = tempdir().unwrap();
21528 let mut e = open_session(&dir.path().join("q.h5"));
21529 e.create_group("g").unwrap();
21530 e.create_group("g/inner").unwrap();
21531 e.stage_created_dataset("g/inner/col", i32_dataset(&[1]))
21532 .unwrap();
21533 e.set_group_attr("g", "kind", AttrValue::I64(1)).unwrap();
21534
21535 e.delete("g").unwrap();
21536 assert_eq!(kind(&e, "g"), None);
21537 assert_eq!(kind(&e, "g/inner"), None);
21538 assert_eq!(kind(&e, "g/inner/col"), None);
21539 assert!(
21540 !e.has_staged_edits(),
21541 "the attribute and the subtree go with the group"
21542 );
21543 e.commit().unwrap();
21544 }
21545
21546 #[test]
21547 fn deleting_a_staged_replacement_leaves_the_plain_deletion() {
21548 let dir = tempdir().unwrap();
21549 let mut e = open_session(&dir.path().join("q.h5"));
21550 e.delete("existing").unwrap();
21551 e.stage_created_dataset("existing", i32_dataset(&[9]))
21552 .unwrap();
21553 // Changing one's mind about the replacement leaves the deletion of the
21554 // object in the file, which is what was asked for first.
21555 e.delete("existing").unwrap();
21556 assert_eq!(kind(&e, "existing"), None);
21557 assert_eq!(e.staged.deletes.len(), 1);
21558 e.commit().unwrap();
21559 assert!(
21560 crate::group_v2::resolve_path_any_from_source(&e.image(), e.superblock(), "existing",)
21561 .is_err()
21562 );
21563 }
21564
21565 #[test]
21566 fn a_batch_that_fails_leaves_the_staged_index_matching_the_staged_set() {
21567 let dir = tempdir().unwrap();
21568 let mut e = open_session(&dir.path().join("q.h5"));
21569 e.stage_created_dataset("kept", i32_dataset(&[1])).unwrap();
21570 let refused: Result<(), Error> = e.stage_atomically(|s| {
21571 s.create_group("gone")?;
21572 s.stage_created_dataset("gone/col", i32_dataset(&[2]))?;
21573 Err(Error::EditUnsupported("refused on purpose"))
21574 });
21575 assert!(refused.is_err());
21576 // The index must forget what the rewind dropped, or a lookup would hand
21577 // back a handle onto an entry that is no longer there.
21578 assert_eq!(kind(&e, "gone"), None);
21579 assert_eq!(kind(&e, "gone/col"), None);
21580 assert_eq!(kind(&e, "kept"), Some(StagedKind::Dataset));
21581 assert!(e.staged_children("gone").is_empty());
21582
21583 // And it must forget them for good: an index entry left behind would be
21584 // found by the *next* creation at that path and keep it pointing at the
21585 // position the rewind dropped, so the retry would stage a dataset no
21586 // lookup could see.
21587 // Staged behind an unrelated creation, so the retry lands at a
21588 // *different* position than the one the rewind dropped: an index entry
21589 // left behind would still name the old one.
21590 e.create_group("other").unwrap();
21591 e.stage_created_dataset("other/col", i32_dataset(&[3]))
21592 .unwrap();
21593 e.create_group("gone").unwrap();
21594 e.stage_created_dataset("gone/col", i32_dataset(&[2]))
21595 .unwrap();
21596 assert_eq!(kind(&e, "gone"), Some(StagedKind::Group));
21597 assert_eq!(kind(&e, "gone/col"), Some(StagedKind::Dataset));
21598 assert_eq!(
21599 e.staged_dataset_meta("gone/col").unwrap().dimensions,
21600 vec![1]
21601 );
21602 e.commit().unwrap();
21603 }
21604
21605 #[test]
21606 fn an_edit_that_changes_staged_work_is_refused_inside_a_batch() {
21607 let dir = tempdir().unwrap();
21608 let mut e = open_session(&dir.path().join("q.h5"));
21609 e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21610 .unwrap();
21611 // `rewind` undoes a batch by truncating, which is exact only while
21612 // staging appends. Both operations that do otherwise say so here rather
21613 // than leaving half of themselves behind in a refused batch.
21614 let folded: Result<(), Error> = e.stage_atomically(|s| {
21615 let mut b = AppendBuilder::new();
21616 b.append_i32(&[3]);
21617 s.stage_dataset_append_pending("col", b)
21618 });
21619 assert!(matches!(folded, Err(Error::EditUnsupported(_))));
21620 let withdrawn: Result<(), Error> = e.stage_atomically(|s| s.delete("col"));
21621 assert!(matches!(withdrawn, Err(Error::EditUnsupported(_))));
21622 assert_eq!(e.staged_dataset_meta("col").unwrap().dimensions, vec![2]);
21623 }
21624}
21625
21626#[cfg(test)]
21627mod object_header_wrap_tests {
21628 use super::*;
21629
21630 /// A region whose messages cannot be walked is reported, not asserted away.
21631 ///
21632 /// The `debug_assert!(false)` this replaced split the behavior by build
21633 /// profile: a test build panicked, and a release build wrote the header with
21634 /// no Attribute Info message — the zero-`num_attrs` defect the normalization
21635 /// exists to prevent. Asserted as an `Err` because that is the one answer
21636 /// both profiles can give.
21637 #[test]
21638 fn an_unwalkable_region_is_refused_rather_than_wrapped() {
21639 // One version 2 header message — type byte, 2-byte size, flags byte —
21640 // whose size field claims far more body than the region holds.
21641 let mut region = vec![0x0Cu8]; // Attribute
21642 region.extend_from_slice(&0xFFFFu16.to_le_bytes());
21643 region.push(0); // flags
21644 region.extend_from_slice(&[0u8; 4]); // a body far shorter than declared
21645
21646 let err = build_v2_object_header(&OhRegion::new(region, OhHeaderProps::PLAIN)).unwrap_err();
21647 assert!(
21648 matches!(err, Error::EditUnsupported(_)),
21649 "an unwalkable region gave {err:?}"
21650 );
21651 }
21652
21653 /// The walkable case still normalizes: a header carrying inline attributes
21654 /// comes back with the Attribute Info message that declares their count.
21655 #[test]
21656 fn a_walkable_region_still_gains_its_attribute_info() {
21657 let body = [0u8; 8];
21658 let mut region = vec![0x0Cu8]; // Attribute
21659 region.extend_from_slice(&(body.len() as u16).to_le_bytes());
21660 region.push(0); // flags
21661 region.extend_from_slice(&body);
21662
21663 let oh =
21664 build_v2_object_header(&OhRegion::new(region.clone(), OhHeaderProps::PLAIN)).unwrap();
21665 assert_eq!(&oh[..4], b"OHDR");
21666 assert!(
21667 oh.len() > 8 + region.len() + 4,
21668 "the wrapped header did not grow by an Attribute Info message"
21669 );
21670 }
21671}