hdf5_pure/reader.rs
1//! Reading API: File, Dataset, and Group handles for reading HDF5 files.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::io::{Read, Seek, SeekFrom};
6use std::num::{NonZeroU64, NonZeroUsize};
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex, PoisonError, RwLock};
9
10use crate::access_mode::AccessMode;
11use crate::address::{BaseAddress, StoredAddress};
12use crate::edit::{
13 AppendBuilder, AppendGeometry, AppendTarget, EditBacking, MemoryStrategy, SpaceAccounting,
14 StagedChild, StagedKind, StagedMeta, StagedObject, SyncPolicy, WriteEngine,
15};
16use crate::element::H5Element;
17use crate::type_builders::{DatasetBuilder, VL_REF_SIZE};
18
19use crate::appender::BufferedAppender;
20use crate::attribute::{extract_attributes_full, extract_attributes_full_from_source};
21use crate::chunk_cache::{CachePass, ChunkCache, ChunkCacheConfig, ChunkCacheStats};
22use crate::compound::CompoundType;
23use crate::convert::Narrow;
24use crate::data_layout::DataLayout;
25use crate::data_read;
26use crate::dataspace::{Dataspace, MaxExtent};
27use crate::datatype::{Datatype, ReferenceType};
28use crate::error::{Error, FormatError};
29use crate::file_create_properties::FileCreateProperties;
30use crate::file_lock::{self, FileLocking, OpenIntent, OpenTarget, WriteMarkPolicy};
31use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy};
32use crate::fill_value::FillPattern;
33use crate::filter_pipeline::FilterPipeline;
34use crate::free_space_manager;
35use crate::group_v1::GroupEntry;
36use crate::group_v2::{self, ChildLookup, is_group};
37use crate::layout_info::{Chunk, ChunkIndex, Filter, Layout};
38use crate::libver::LibVer;
39use crate::message_type::MessageType;
40use crate::object_header::ObjectHeader;
41use crate::object_path::{LinkName, LinkNameBuf, ObjectPath, ObjectPathBuf};
42use crate::read_spec::RawReadSpec;
43use crate::shared_message::{self, BufferedResolver, SharedResolver, SourceResolver};
44use crate::signature;
45use crate::source::{
46 BaseOffsetSource, BytesSource, MetadataCacheConfig, MetadataCacheStats, MetadataCachingSource,
47 ReadSeekSource, Source, ValidatedSource, frame,
48};
49use crate::superblock::Superblock;
50use crate::vl_data::{self, VlenStringReadOptions};
51
52use crate::types::{AttrValue, DType, attrs_to_map, classify_datatype};
53
54// ---------------------------------------------------------------------------
55// File
56// ---------------------------------------------------------------------------
57
58/// Backing store for a [`File`]: either the whole file buffered in memory, or a
59/// lazy [`Source`] that reads regions on demand (see [`File::open_streaming`]).
60enum Backend {
61 InMemory(Vec<u8>),
62 Streaming(Box<dyn Source + Send + Sync>),
63 /// A read-write file opened with [`File::open_rw`]: a [`WriteEngine`] (exclusive OS lock + staged
64 /// edit queues + append geometry cache) behind a lock, so owned handles can
65 /// both read and mutate in place. Handle write methods route to the engine,
66 /// and `File::commit` applies staged structural edits.
67 ///
68 /// Either backing — a whole-file mirror, or positioned I/O against the
69 /// handle — appears here as the same `WriteEngine`; which one an open
70 /// resolved to is the engine's own business rather than the backend's
71 /// (issue #198). Reads
72 /// borrow the mirror's slice when there is one and go through the image's
73 /// `Source` otherwise; see [`with_engine`](FileInner::with_engine). Boxed to
74 /// keep the `Backend` enum small (a `WriteEngine` is far larger than the
75 /// other variants).
76 Edit(Box<Mutex<WriteEngine>>),
77}
78
79/// What an operation through a file's write session can do to it, which is what
80/// decides how much of an object handle's memo it invalidates — and, with it,
81/// which edit surface the operation is using.
82///
83/// One variant carries both because they coincide: the *staged* surface is the
84/// one that commits, and a commit is the only thing that moves an object header,
85/// so [`Relocating`](Self::Relocating) is exactly the surface a SWMR writer
86/// refuses. An operation off that surface that could relocate would break the
87/// pairing and would have to say which it was separately.
88#[derive(Clone, Copy)]
89enum Change {
90 /// Rewrites object headers and can move them: a commit. The staging of one
91 /// counts too, though it writes nothing — a pending edit is a header move
92 /// this session has not made yet, and an address is worth no more against
93 /// one than against the commit that will apply it. Invalidates a handle's
94 /// address as well as the header it parsed.
95 ///
96 /// This is the staged surface, which a SWMR writer refuses.
97 Relocating,
98 /// Changes bytes without moving any object header: an immediate
99 /// [`Dataset::append`], which rewrites the dataset's dimension where its
100 /// header stands, and the free-space and status-flag bookkeeping a session's
101 /// teardown rewrites where it stands. Invalidates the parsed header alone,
102 /// which is what lets a handle reached by object reference — the one kind
103 /// with no name to look itself up by — go on appending.
104 InPlace,
105 /// Changes nothing a handle can observe: a durability barrier over writes
106 /// the operations that made them already accounted for, or a question whose
107 /// answer the engine caches. Invalidates no memo.
108 ///
109 /// Distinct from not going through the gate at all, which is what a *read*
110 /// does: these still need the write session, and still need the file to be
111 /// open for writing and unsealed.
112 Nothing,
113}
114
115/// Where an object handle's header sits, and what that answer holds as of.
116///
117/// A handle names its object — by path, or by the address a reference gave it —
118/// and this is a memo of what that name last resolved to. See
119/// [`FileInner::locate`], which takes one, and the two counters it reads.
120#[derive(Clone, Copy)]
121struct Resolution {
122 content_revision: u64,
123 address_revision: u64,
124 address: u64,
125}
126
127/// The pair of revisions read *before* a resolution is worked out, which the
128/// answer is then labelled with.
129///
130/// Splitting the reads from the address is what keeps the order right at every
131/// call site: there is no way to label an address with a revision taken after
132/// it, which would claim a freshness the address does not have. See
133/// [`FileInner::locate`].
134#[derive(Clone, Copy)]
135struct Revisions {
136 content: u64,
137 address: u64,
138}
139
140impl Revisions {
141 /// Label `address` as worked out at these revisions.
142 const fn at(self, address: u64) -> Resolution {
143 Resolution {
144 content_revision: self.content,
145 address_revision: self.address,
146 address,
147 }
148 }
149}
150
151/// Where a [`Dataset`] or [`Group`] handle stands with respect to the staged
152/// set, worked out by [`FileInner::staged_standing`].
153///
154/// A handle names its object by path, and a path can mean an object in the
155/// file, an object this session has staged, or — for a handle *born* onto a
156/// staged creation — nothing at all, once that creation is withdrawn. The third
157/// is why the handle carries a mark of its own: without one, a withdrawal is
158/// indistinguishable from a commit, and the handle would silently start
159/// answering for whatever the file holds at the path, which in the case that
160/// produces it is the object the session is deleting.
161#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162enum Standing {
163 /// Resolve the path against the file, as a handle opened by name does.
164 Live,
165 /// A creation this session staged owns the path and no commit has written
166 /// it: [`Error::NotCommitted`] for anything needing its bytes.
167 Pending,
168 /// This handle was made onto a staged creation that has since been
169 /// withdrawn: [`Error::StagingWithdrawn`].
170 Withdrawn,
171}
172
173/// A borrowed `Source` view over a [`File`]'s backend, used by the
174/// streaming-capable read paths so one call site serves both backends.
175pub(crate) enum SourceView<'a> {
176 Mem(&'a [u8]),
177 Stream(&'a (dyn Source + Send + Sync)),
178}
179
180impl Source for SourceView<'_> {
181 fn len(&self) -> u64 {
182 match self {
183 SourceView::Mem(b) => b.len() as u64,
184 SourceView::Stream(s) => s.len(),
185 }
186 }
187 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
188 match self {
189 SourceView::Mem(b) => BytesSource::new(*b).read_at(offset, buf),
190 SourceView::Stream(s) => s.read_at(offset, buf),
191 }
192 }
193
194 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
195 match self {
196 SourceView::Mem(b) => BytesSource::new(*b).read_metadata_at(offset, len),
197 SourceView::Stream(s) => s.read_metadata_at(offset, len),
198 }
199 }
200
201 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
202 match self {
203 // A whole-file buffer is already the cache, and holds no second one.
204 SourceView::Mem(_) => None,
205 SourceView::Stream(s) => s.metadata_cache_stats(),
206 }
207 }
208
209 fn reset_metadata_cache_stats(&self) {
210 match self {
211 SourceView::Mem(_) => {}
212 SourceView::Stream(s) => s.reset_metadata_cache_stats(),
213 }
214 }
215}
216
217/// File-access properties applied when opening an HDF5 file.
218///
219/// This is the `hdf5-pure` analogue of an HDF5 **file access property list**
220/// (`fapl`): one value carrying every access-time setting, built once and passed
221/// to whichever open a caller reaches for, exactly as a `fapl` is handed to
222/// `H5Fopen`. Every `*_with_options` constructor on [`File`] accepts it, so a
223/// read path and a read-write path can share one configuration.
224///
225/// The `Properties` suffix means the type stands in for one whole HDF5 property
226/// list, so every setting on it has a C counterpart to look up. It is a stand-in
227/// and not a port: a plain `Copy` value, with no handle to create or close, no
228/// runtime property registry, and no setter that can fail. `fapl` and each
229/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
230///
231/// - The metadata cache (`H5Pset_mdc_config`) applies to the streaming and
232/// bounded backends; an in-memory open already holds the whole file in one
233/// buffer.
234/// - The chunk cache (`H5Pset_cache`) is the file-wide default for datasets
235/// opened from any backend, overridable per dataset with
236/// [`DatasetAccessProperties`].
237/// - The locking policy (`H5Pset_file_locking`) applies to the read-write opens.
238/// Readers and the SWMR writer take no lock by design, so they ignore it.
239/// - The write-mark policy applies to the read-only opens, and has no C
240/// counterpart: `H5Fopen` refuses a file marked open for write with no
241/// override, where [`with_write_mark_policy`](Self::with_write_mark_policy)
242/// can admit a snapshot read of one.
243///
244/// # Supported properties
245///
246/// Selected through the [`File`] open-mode constructor, with memory budgets and locking set through
247/// [`FileAccessProperties`](Self), [`DatasetAccessProperties`] and [`FileLocking`]. The [status
248/// legend](crate::FileCreateProperties#status-legend) explains the status column.
249///
250/// | HDF5 property / driver | `hdf5-pure` | Status | Behavior |
251/// |---|---|---|---|
252/// | `H5Fopen(RDONLY)`, default sec2 | [`File::open`](crate::File::open) | **Genuine (read-only)** | Whole-file buffered read, without a lock. |
253/// | positioned / on-demand reads | [`File::open_streaming`](crate::File::open_streaming) | **Genuine (read-only, bounded)** | Fetches metadata and chunks on demand, with peak memory near one chunk. |
254/// | user-supplied read driver (cf. `H5Pset_driver`) | [`File::from_source`](crate::File::from_source) / [`from_source_with_options`](crate::File::from_source_with_options), over the [`Source`] trait | **Genuine (read-only, bounded)** | The same on-demand reads over bytes that are not a path: an object store addressed by range request, or a sandboxed guest handed byte ranges by its host. A read that comes back short is rejected. |
255/// | `H5Pset_fapl_core` / `H5Pset_file_image` | [`File::from_bytes`](crate::File::from_bytes) / [`FileBuilder::finish`](crate::FileBuilder::finish) | **Genuine** | Read an in-memory file image, or build one into a `Vec<u8>`. |
256/// | `H5Fopen(RDWR)` | [`File::open_rw`](crate::File::open_rw) / [`open_rw_with_options`](crate::File::open_rw_with_options) | **Genuine (read-write)** | Reads, appends, and staged edits with [`commit`](crate::File::commit). Holds a latest-format file with no userblock in bounded memory and any other file in a whole-file mirror, picked from the file. Commits to a paged file through a page-aware tail, and rejects a paged file without persisted free space at open. |
257/// | `H5Fopen(RDWR)`, bounded memory | [`File::open_rw_with_options`](crate::File::open_rw_with_options) + [`MemoryStrategy::Bounded`] | **Genuine (read-write, bounded)** | [`File::open_rw`](crate::File::open_rw) picks the bounded backing itself. Stating `Bounded` makes it strict: a file the bounded engine cannot edit is rejected, and never mirrored. |
258/// | no C counterpart | [`with_memory_strategy`](Self::with_memory_strategy), [`MemoryStrategy`] | **Behavioral** | How much memory a read-write open may spend holding the file, overriding the dispatch above. `Bounded` rejects a file the bounded engine cannot edit, a pre-v2 superblock or a userblock. `Auto`, which is what [`File::open_rw`](crate::File::open_rw) uses unset, falls back to the mirror, and `Mirrored` always mirrors. A paged file without persisted free space is rejected under both preferences, because the mirror cannot commit it either. [`File::edit_backing`](crate::File::edit_backing) reports which backend an open resolved to, as an [`EditBacking`] (`Bounded` or `Mirrored`), a separate type because `Auto` is a preference between the two and never an outcome. The C library has no analogue: `H5Fopen` picks its own caching with no caller-visible memory contract. |
259/// | `H5F_ACC_SWMR_READ` / `H5F_ACC_SWMR_WRITE` | [`File::open_swmr`](crate::File::open_swmr) / [`open_swmr_writer`](crate::File::open_swmr_writer) (`*_with_options`) | **Genuine** | No OS lock. The writer raises the superblock SWMR-write flag (a v3 superblock is required, as in the C library) and appends alone. It always mirrors, so it accepts `MemoryStrategy::Auto` and `Mirrored` and rejects an explicit `Bounded`. |
260/// | superblock status flags (`h5clear -s`) | checked by [`File::open`](crate::File::open), [`open_streaming`](crate::File::open_streaming), [`from_source`](crate::File::from_source), [`open_rw`](crate::File::open_rw), [`open_swmr`](crate::File::open_swmr) and [`open_swmr_writer`](crate::File::open_swmr_writer), and cleared by [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) | **Genuine** | A file the byte marks as held by a writer is rejected with [`Error::FileMarkedInUse`], as `H5Fopen` rejects it. [`File::open_swmr`](crate::File::open_swmr) follows such a file, and rejects a half-set mark alone. Version-3 superblocks only, which is where the C library checks. [`File::from_bytes`](crate::File::from_bytes) does not consult the byte, and neither, therefore, does [`mat::from_file`](crate::mat::from_file), so a caller holding the bytes can still read a flagged file, at the cost of a copy of it. |
261/// | no C counterpart | [`with_write_mark_policy`](Self::with_write_mark_policy), [`WriteMarkPolicy`] | **Behavioral** | Whether a read-only open proceeds past a file marked open for write by a non-SWMR writer, bit 0 alone, which is what a page-buffered session raises and what no SWMR reader can follow, so such a file is otherwise readable only by copying it through [`File::from_bytes`](crate::File::from_bytes). `AllowSnapshot` reads it as it stands, on the caller's assertion that the writer has flushed ([`File::sync`](crate::File::sync), or a clean close), which the byte cannot record and this crate therefore cannot check. It admits nothing else: a SWMR pair belongs to [`File::open_swmr`](crate::File::open_swmr), and [`File::open_rw`](crate::File::open_rw) and [`File::open_swmr_writer`](crate::File::open_swmr_writer) are rejected whatever it says. The default `Refuse` is what `H5Fopen` does, and `H5Fopen` offers no override at all: `h5clear` is its one way through, and it edits the file. |
262/// | `H5Pset_file_locking` + `HDF5_USE_FILE_LOCKING` | [`with_locking`](Self::with_locking), [`FileLocking`] | **Genuine** | Exclusive advisory lock on both read-write paths, mirror and bounded, taken non-blocking, so a lock another process holds is [`Error::FileLocked`]. The environment-variable override recognizes the same values as the C library. Readers and the SWMR writer run without a lock by design and ignore the setting. |
263/// | `H5Fget_libver_bounds` (read) | [`File::libver_bound`](crate::File::libver_bound) | **Read-only** | Reports the low library-version bound the superblock version implies. |
264/// | `H5Pset_cache` (`rdcc`) / `H5Pset_chunk_cache` (`dapl`) | [`with_chunk_cache`](Self::with_chunk_cache), [`DatasetAccessProperties::with_chunk_cache`](crate::DatasetAccessProperties::with_chunk_cache) | **Genuine (all backends)** | A cache of decompressed chunks and parsed indexes, 1 MiB in 16 slots by default. `rdcc_nslots` and `rdcc_nbytes` map directly. There is no write coalescing, since a mutation clears the cache, and no `rdcc_w0`, for the reason below. [`Dataset::chunk_cache_stats`](crate::Dataset::chunk_cache_stats) reports what it did. |
265/// | `H5Pset_mdc_config` | [`with_metadata_cache`](Self::with_metadata_cache), [`MetadataCacheConfig`] | **Behavioral (partial)** | A byte budget for a metadata-read LRU on the streaming and bounded backends, off by default. [`File::metadata_cache_stats`](crate::File::metadata_cache_stats) reports what it did (`H5Fget_mdc_hit_rate` and `H5Fget_mdc_size`) and [`File::reset_metadata_cache_stats`](crate::File::reset_metadata_cache_stats) clears the counters (`H5Freset_mdc_hit_rate_stats`). Of `H5AC_cache_config_t` this is `max_size`, and [the metadata cache](#the-metadata-cache-h5pset_mdc_config) below covers what the other 29 fields would mean here. |
266/// | no C counterpart | [`with_sync_policy`](Self::with_sync_policy), [`SyncPolicy`], [`File::sync`](crate::File::sync) | **Behavioral** | Who issues the `fsync`s. The default `Always` forces durability at every point the write paths define one. `OnClose` issues none during the session, leaving the cadence to the application through [`File::sync`](crate::File::sync), and one at `close` or drop, which write past the point any caller could order them. Either way, writes reach the operating system by the time the operation making them returns, unless [`with_page_buffer_size`](Self::with_page_buffer_size) is set, which is off by default and requires `OnClose`. What moves to the caller is therefore power-loss durability within the session, and same-machine visibility stays where it was. [Choosing the fsync cadence](crate::_guide::editing#choosing-the-fsync-cadence) has the cost in full. The C library has no property for this because it never `fsync`s at all: the default `sec2` driver installs no flush callback, so `H5Fflush` drains libhdf5's caches with `write` and stops. |
267/// | `H5Pset_page_buffer_size` | [`with_page_buffer_size`](Self::with_page_buffer_size) | **Genuine** | A write-back page buffer for a read-write session: dirty pages live across the ordering barriers inside a commit or append, up to the byte budget, then flush whole. It requires a budget of at least the page it merges within (the file's own when `Page`, and the format's 4 KiB default otherwise), a version-3 superblock, `SyncPolicy::OnClose`, and, on a `Page` file, persisted free space. A request that misses one of those is rejected, and the setting is never quietly ignored. The SWMR writer and any [`File::create_with_options`](crate::File::create_with_options) pair that could not be reopened are rejected too. A `Page` file is not required, where `H5PB_create` requires one: the C page buffer is a page cache whose per-kind reservations count pages the paged allocator segregates, while this is a write gatherer that needs a window to merge within. A budget below 1 MiB is accepted, as it is in C, and it costs something here: `H5PB_write` bypasses the C buffer for any I/O of a page or more, while nothing bypasses this one, so a smaller budget is where a long run is flushed and restarted (one 4 MiB append: 10 writes at 1 MiB, 1,094 at 4 KiB). A sub-page budget is rejected, where `H5Fopen` rounds it up silently. It reorders publish points ahead of the content they identify, so the session raises superblock status-flag bit 0 (`H5F_SUPER_WRITE_ACCESS`) for its lifetime: a writer that dies mid-flush leaves a file this crate, `H5Fopen` and h5py all reject, and never one that reads clean and returns fill values or a deleted object's bytes. The C library's page buffer reorders the same way and ships no such mark. The budget is the one field modeled, and `min_meta_perc` and `min_raw_perc` are not: the buffer does not evict, so it has no victim to choose. Off by default, which still leaves the write gathering every read-write session does. |
268/// | `H5Pset_fapl_family` / `split` / `multi` / `mpio` / `direct` / `ros3` / `log` | none | **Unsupported** | Only two implicit drivers exist: an in-memory buffer and `Read + Seek` positioned I/O. A multi-file, parallel, or remote-object file is not opened. |
269///
270/// ## The metadata cache (`H5Pset_mdc_config`)
271///
272/// `H5AC_cache_config_t` is 30 fields (version 1, checked against HDF5 2.1.0).
273/// [`MetadataCacheConfig`] models one of them, `max_size`, as `max_bytes`, plus a `max_entry_bytes`
274/// cap that has no C counterpart. That ratio invites the reading that 29 things are missing. This
275/// cache holds byte ranges of a file being read, where the C cache holds parsed entries of a file
276/// being written, and each group of fields would mean this here:
277///
278/// | Group | Fields | Applies here? |
279/// |---|---|---|
280/// | `max_size` | 1 | **Modeled**, as [`MetadataCacheConfig::max_bytes`](crate::MetadataCacheConfig::max_bytes). |
281/// | Adaptive resize: `min_size`, `epoch_length`, `incr_mode` + 4, `flash_incr_mode` + 2, `decr_mode` + 7 | 18 | Not modeled: they are one feature, and not 18 separate settings. See below. |
282/// | Dirty-data and parallel: `min_clean_fraction`, `dirty_bytes_threshold`, `metadata_write_strategy` | 3 | **Never.** This cache holds bytes read alone, so there is no dirty entry to keep clean, flush, or write. A read-write session drops the ranges its writes overlap, which [`MetadataCacheStats::invalidations`](crate::MetadataCacheStats::invalidations) counts. |
283/// | Diagnostics: `version`, `rpt_fcn_enabled`, `open_trace_file`, `close_trace_file`, `trace_file_name` | 5 | No. `version` is C ABI evolution, and the rest are a trace-file facility this crate has no counterpart to. [`File::metadata_cache_stats`](crate::File::metadata_cache_stats) reports what they were for. |
284/// | Initial sizing: `set_initial_size`, `initial_size` | 2 | No effect to have. There is no arena allocated up front: entries are allocated as reads admit them, so the initial size is zero and the maximum is the only bound. |
285/// | `evictions_enabled` | 1 | Expressible already, as a budget larger than the metadata a session reads. A separate flag would be a second way to say it, and one that silently uncaps memory. |
286///
287/// Adaptive resize is the one substantive gap, and leaving it out is deliberate. The algorithm
288/// grows the cache when an epoch's hit rate falls below `lower_hr_threshold` and shrinks it when
289/// the rate rises above `upper_hr_threshold` or entries age out, so that a caller does not have to
290/// pick a size. Picking one is cheap here: the store is indexed and not scanned
291/// ([#367](https://github.com/CramBL/hdf5-pure/issues/367)), so a hit costs about the same at
292/// 65,000 entries as at 1,000, 161 nanoseconds against 136, measured, and an over-generous budget
293/// costs memory and nothing else. The advice the algorithm would arrive at is one line, set the
294/// budget generously, and [`File::metadata_cache_stats`](crate::File::metadata_cache_stats)
295/// confirms it landed. Note also the direction the algorithm runs in: a low hit rate makes it hold
296/// more.
297///
298/// ## The chunk cache (`rdcc`)
299///
300/// [`ChunkCacheConfig`] models `rdcc_nslots` and `rdcc_nbytes` directly. The third field,
301/// `rdcc_w0`, is not modeled, and the reason is what it discriminates, and not a gap in the
302/// eviction code.
303///
304/// In `H5Dchunk.c`, `w0` is a head start measured in entries: `w[0] = (int)(rdcc->nused *
305/// rdcc->w0)`, counted down one per step of `H5D__chunk_cache_prune`. Two methods walk the LRU list
306/// from its least-recently-used end. Method 0 preempts the entries that are not partially consumed.
307/// Method 1 preempts anything unlocked, and is not introduced until the head start reaches zero. So
308/// `w0` sets how far the selective rule gets to run alone, and its discriminator is
309/// `H5D_rdcc_ent_t::rd_count` and `wr_count`, per-entry counts of bytes remaining, initialized to
310/// the chunk size and decremented by `naccessed` in `H5D__chunk_unlock`. `w0` decides exactly one
311/// thing: whether to drop a chunk the caller consumed in full ahead of one it consumed in part.
312///
313/// Nothing here turns on that decision, for two separate reasons.
314///
315/// | C field | Applies here? |
316/// |---|---|
317/// | `wr_count` | **Never.** This cache holds decompressed data read from the file. There is no dirty chunk to write back. A commit in the session drops what it may have made stale, which [`ChunkCacheStats::invalidations`](crate::ChunkCacheStats::invalidations) counts. |
318/// | `rd_count` | **Not recorded.** A slot records that it holds a chunk, and never how much of that chunk a caller took. The class `w0` sorts by therefore does not exist here to be sorted. Partial consumption is the common case: an edge chunk of a dataset whose extent is not a multiple of its chunk extent is stored full-size and read in part, which is `rd_count > 0` in C too. |
319///
320/// The second reason is that on the path where a scan would thrash, there is no prune to order at
321/// all. A whole read visits each of its chunks exactly once, so it fills the cache and then stops
322/// offering, and it hands back none of the chunks it has already placed for chunks it will never
323/// read again. That removes the problem `w0` exists to soften.
324///
325/// Which figure reports a too-small budget therefore depends on how the dataset is read, and each
326/// is structurally zero on the other's path:
327///
328/// | read | admission | budget signal |
329/// |---|---|---|
330/// | whole ([`read_f64`](crate::Dataset::read_f64) and its siblings) | fills the cache, then stops offering | `rejections`, with `evictions` at 0 |
331/// | row window ([`read_raw_rows`](crate::Dataset::read_raw_rows), `read_*_rows`) | plain LRU, since the chunk its successor needs is the one it finished on | `evictions`, with `rejections` at 0 |
332///
333/// A row window is the one path that does evict, so it is the only place a `w0` analogue could ever
334/// pick a different victim. It would not pick a better one: the entry the next window needs is the
335/// one this read finished on, which is the most recently used and the last thing an LRU rule gives
336/// up, while the partially-consumed entries `w0` would additionally protect are the leading
337/// boundary chunks that a forward sweep never revisits. That is an argument about the access
338/// patterns these readers generate, and not a proof for an arbitrary one.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
340#[doc(alias = "fapl")]
341pub struct FileAccessProperties {
342 metadata_cache: MetadataCacheConfig,
343 chunk_cache: ChunkCacheConfig,
344 locking: FileLocking,
345 memory_strategy: Option<MemoryStrategy>,
346 libver_bounds: Option<(LibVer, LibVer)>,
347 sync_policy: SyncPolicy,
348 page_buffer_size: usize,
349 write_mark_policy: WriteMarkPolicy,
350}
351
352impl FileAccessProperties {
353 /// A value carrying the crate's default access behavior.
354 pub const fn new() -> Self {
355 Self {
356 metadata_cache: MetadataCacheConfig::disabled(),
357 chunk_cache: ChunkCacheConfig::new(),
358 locking: FileLocking::Enabled,
359 memory_strategy: None,
360 libver_bounds: None,
361 sync_policy: SyncPolicy::Always,
362 page_buffer_size: 0,
363 write_mark_policy: WriteMarkPolicy::Refuse,
364 }
365 }
366
367 /// Configure the bounded streaming metadata cache.
368 #[doc(alias = "H5Pset_mdc_config")]
369 pub const fn with_metadata_cache(mut self, metadata_cache: MetadataCacheConfig) -> Self {
370 self.metadata_cache = metadata_cache;
371 self
372 }
373
374 /// Configure the per-dataset raw chunk cache used by datasets opened from
375 /// this file. This is the `H5Pset_cache`-style file-wide default.
376 #[doc(alias = "H5Pset_cache")]
377 pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
378 self.chunk_cache = chunk_cache;
379 self
380 }
381
382 /// Set the OS advisory file-locking policy for the read-write opens.
383 ///
384 /// Defaults to [`FileLocking::Enabled`]. Use [`FileLocking::Disabled`] only
385 /// when an external mechanism already guarantees single-writer access, or
386 /// [`FileLocking::BestEffort`] on a filesystem (such as some network mounts)
387 /// where the OS lock is unavailable. Setting `HDF5_USE_FILE_LOCKING` in the
388 /// environment overrides this, as in the C library.
389 ///
390 /// Readers and [`File::open_swmr_writer`] take no lock by design and ignore
391 /// this.
392 #[doc(alias = "H5Pset_file_locking")]
393 pub const fn with_locking(mut self, locking: FileLocking) -> Self {
394 self.locking = locking;
395 self
396 }
397
398 /// Set how much memory a read-write open may use to hold the file.
399 ///
400 /// Unset by default, which lets the entry point choose:
401 /// [`File::open_rw`] uses [`MemoryStrategy::Auto`], preferring the bounded
402 /// engine and falling back to the whole-file mirror for a file it cannot
403 /// edit. Setting this overrides that default, in either direction, so
404 /// [`MemoryStrategy::Bounded`] refuses such a file rather than quietly
405 /// spending `O(file size)` memory on a caller who asked not to;
406 /// [`MemoryStrategy::Mirrored`] takes
407 /// the whole-file mirror unconditionally, as `open_rw` did before it learned
408 /// to dispatch.
409 ///
410 /// The read-only opens ignore this: they build no editing session at all, and
411 /// their own names say what memory they spend. [`File::open_swmr_writer`]
412 /// does build one, and always mirrors: it accepts
413 /// [`MemoryStrategy::Auto`] and [`MemoryStrategy::Mirrored`], both of which
414 /// the mirror satisfies, and refuses an explicit [`MemoryStrategy::Bounded`]
415 /// with [`Error::EditUnsupported`] rather than quietly not honoring it. Ask a
416 /// `File` which backend it resolved to with [`File::edit_backing`].
417 pub const fn with_memory_strategy(mut self, memory_strategy: MemoryStrategy) -> Self {
418 self.memory_strategy = Some(memory_strategy);
419 self
420 }
421
422 /// Constrain the on-disk format an editing session may write, mirroring
423 /// HDF5's `H5Pset_libver_bounds` — which the C library classes as a *file
424 /// access* property for exactly this reason: it governs what a later write
425 /// to an existing file is allowed to add.
426 ///
427 /// Unset by default, which keeps [`File::open_rw`] adding whatever the
428 /// content needs. That default is what lets a file the C library wrote under
429 /// its own bounds be edited at all, but it means a session can add content
430 /// only a newer library can read *without changing the superblock*, and the
431 /// caller has no way to see it happen: adding a chunked, filtered, or
432 /// resizable dataset to an HDF5 1.8 file needs the version 4 data-layout
433 /// message and a 1.10 chunk index, since this crate does not write the
434 /// version 1 B-tree index that 1.8 used.
435 ///
436 /// Setting a `high` below [`LibVer::V110`] refuses that addition with
437 /// [`FormatError::LibverTooOldForContent`]
438 /// at [`File::commit`] instead — the same refusal
439 /// [`FileBuilder::with_libver_bounds`](crate::FileBuilder::with_libver_bounds)
440 /// gives when writing a whole file, so a `.mat` bounded to 1.8 for MATLAB
441 /// stays loadable by MATLAB after an edit.
442 ///
443 /// `low` only rules formats out — as in the C library it licenses newer
444 /// encodings without requiring them — so a lower bound of [`LibVer::V112`],
445 /// [`LibVer::V114`] or [`LibVer::LATEST`] leaves the session writing the 1.10
446 /// format rather than failing, provided `high` reaches it. An inverted range
447 /// such as `V114..=V110` is refused with
448 /// [`FormatError::LibverBoundsUnsatisfiable`].
449 ///
450 /// The read-only opens ignore this: they write nothing. [`File::open_swmr_writer`]
451 /// requires a version 3 superblock, so it refuses a `high` below
452 /// [`LibVer::V110`] up front rather than accepting a bound it cannot honor.
453 #[doc(alias = "H5Pset_libver_bounds")]
454 pub const fn with_libver_bounds(mut self, low: LibVer, high: LibVer) -> Self {
455 self.libver_bounds = Some((low, high));
456 self
457 }
458
459 /// Choose who owns this session's `fsync` cadence — this crate, or the
460 /// application through [`File::sync`].
461 ///
462 /// Defaults to [`SyncPolicy::Always`]: every commit and every immediate
463 /// [`Dataset::append`](crate::Dataset::append) forces its writes to durable
464 /// storage before returning. [`SyncPolicy::OnClose`] issues no `fsync` at all,
465 /// which is what the reference C library does; the writes still reach the
466 /// operating system by the time the operation making them returns, so only
467 /// power-loss durability moves to the caller. [`with_page_buffer_size`](Self::with_page_buffer_size),
468 /// off by default, is the one setting that changes that — and it requires
469 /// this policy.
470 ///
471 /// The read-only opens ignore this: they write nothing.
472 pub const fn with_sync_policy(mut self, sync_policy: SyncPolicy) -> Self {
473 self.sync_policy = sync_policy;
474 self
475 }
476
477 /// Let a read-write session's writes accumulate in a page buffer of
478 /// `bytes`, so repeated small updates landing in the same page cost one
479 /// write rather than one each.
480 ///
481 /// Defaults to `0`, which is off — as `H5Pset_page_buffer_size` defaults to
482 /// off — and leaves the gathering every read-write session already does: one
483 /// write per dirty page per *ordering barrier*, so a commit or an append
484 /// still reaches the operating system in full before it returns. What this
485 /// buys on top is letting a dirty page survive those barriers, which is where
486 /// a workload of many small appends into a few pages does most of its
487 /// repeating. Measured on a paged file, 32 chunk appends into eight datasets
488 /// followed by a commit: **188 writes with the default gathering and 5 with a
489 /// page buffer**, of which two are the mark below going up and coming down.
490 /// The appends issue nothing at all until the session ends.
491 ///
492 /// **It pays off over a long session, and costs on a short one.** The crash
493 /// mark below is two `fsync`s per session whatever the session then does, so
494 /// there is a break-even: measured on an Apple M1 Max (APFS) with 256-byte
495 /// appends into eight datasets, 400 appends ran 0.75x — slower — 800 broke
496 /// even, and 6,400 ran 1.64x. The ratio climbs with session length, because
497 /// the same pages are re-dirtied more often, and narrows to about 1.1x once
498 /// 64 KiB payloads rather than metadata churn dominate. One host's numbers,
499 /// and the short end is noisy; re-measure on the one that matters with
500 /// `cargo bench --bench hot_paths -- page_buffer`, which runs both sides of
501 /// the crossing.
502 ///
503 /// # What it costs, and what pays for it
504 ///
505 /// Gathered writes go out in address order, and every publish point sits
506 /// below the content it reaches, so all of them are issued first. A write
507 /// that fails, or a process that dies, mid-flush can therefore leave a file
508 /// whose superblock, dataset length or object header names bytes that never
509 /// arrived — and two of those read back **clean**, as fill values or as a
510 /// deleted object's data, with every checksum verifying. That is what a
511 /// write-back page buffer is, rather than a fault in this one:
512 /// `H5Pset_page_buffer_size` reorders the same way and makes no
513 /// crash-consistency claim either.
514 ///
515 /// So this session raises superblock status-flag bit 0
516 /// (`H5F_SUPER_WRITE_ACCESS`) for its whole life, `fsync`ed once at open and
517 /// cleared on a clean [`File::close`] or drop — the mark the reference C
518 /// library raises for *any* writer. A session that dies with pages in memory
519 /// leaves that byte standing, and a file carrying it is refused by this
520 /// crate, by `H5Fopen` and by h5py alike, with
521 /// [`Error::FileMarkedInUse`]. The silent
522 /// wrong answer becomes a refusal, and
523 /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — the `h5clear -s`
524 /// equivalent — is how to look at such a file anyway, knowing what it may
525 /// hold. A completed commit's bytes may also still be in this process's
526 /// memory when it returns.
527 ///
528 /// # Refusals
529 ///
530 /// Four, each refused with
531 /// [`Error::EditUnsupported`]:
532 ///
533 /// - a budget below the page the session merges within: the file's own
534 /// file-space page size when it was created with
535 /// [`FileSpaceStrategy::Page`], and the
536 /// format's 4 KiB default otherwise. A buffer that cannot hold one page
537 /// drains on every page it touches;
538 /// - a **paged** file whose free space is not persisted, which can be neither
539 /// committed to nor appended to, so the buffer would hold nothing while its
540 /// mark blocked every reader;
541 /// - a superblock older than version 3, whose status-flags byte no library
542 /// reads back, so the mark above would announce nothing;
543 /// - [`SyncPolicy::Always`], the default, where
544 /// every barrier is an `fsync` that flushes the buffer on its way out — so
545 /// it would hold nothing while still costing the mark. Pair this with
546 /// [`with_sync_policy(SyncPolicy::OnClose)`](Self::with_sync_policy).
547 ///
548 /// [`File::create_with_options`] refuses a creation/access pair it could not
549 /// then reopen with, rather than writing the file first, and
550 /// [`File::open_swmr_writer`] refuses a page buffer outright: its readers
551 /// observe the order its writes become visible in, which is exactly what a
552 /// buffer coalesces away.
553 ///
554 /// # Choosing a budget
555 ///
556 /// Any budget of at least one page is honored. One below 1 MiB is an explicit
557 /// request for less resident memory, not a mistake — a writer inside a tight
558 /// memory cap can ask for 256 KiB and get it — and what it buys that memory
559 /// with is writes: the budget is the point at which everything held is
560 /// flushed, so a long contiguous run is flushed and restarted once per
561 /// budget's worth of it. Writes issued on a 4 KiB-paged file:
562 ///
563 /// | workload | unset | 4 KiB | 64 KiB | 1 MiB |
564 /// | --- | --- | --- | --- | --- |
565 /// | 32 chunk appends into 8 datasets, then a commit | 188 | 25 | 4 | 4 |
566 /// | one 4 MiB append | 131 | 1,094 | 74 | 10 |
567 ///
568 /// On the scattered workload this property exists for, a small budget costs
569 /// little; on the long run 64 KiB is seven times the writes of 1 MiB.
570 ///
571 /// The memory comparison against leaving this unset is not the one the table
572 /// suggests. A session that sets nothing already gathers up to 1 MiB of dirty
573 /// bytes **per operation**, and releases it at every ordering barrier; a page
574 /// buffer holds its budget **across** operations, until the budget is spent,
575 /// an `fsync`, or [`File::close`]. So 1 MiB here trades a per-operation peak
576 /// for a continuous residency of the same size, and a budget below 1 MiB
577 /// lowers both.
578 ///
579 /// # How this differs from `H5Pset_page_buffer_size`
580 ///
581 /// **A paged file is not required, where the C library requires one.**
582 /// `H5PB_create` refuses an unpaged file because the C page buffer is a page
583 /// *cache*, and its `min_meta_perc` / `min_raw_perc` reservations are counted
584 /// in pages that the paged allocator keeps segregated by kind. This is a
585 /// write gatherer: it merges runs within a page-sized window and flushes
586 /// whole, so a window is all it needs, and an unpaged file gets the same
587 /// 4 KiB one that every read-write session already gathers under. Since
588 /// unpaged is the default strategy, requiring `Page` put this property out of
589 /// reach of most files for no reason this implementation had.
590 ///
591 /// **A small budget costs writes here, where it costs none in C.**
592 /// `H5PB_write` sends any I/O of a page or more straight to the driver, so a
593 /// small `page_buf_size` there caps memory without throttling a long write.
594 /// Nothing bypasses this buffer — the budget is the point at which everything
595 /// held is flushed — so a small one turns a single long run into repeated
596 /// flushes. Both libraries accept the budget; only this one charges for it.
597 /// See [Choosing a budget](#choosing-a-budget) for what it charges.
598 ///
599 /// **A sub-page budget is refused rather than rounded.** `H5Fopen` rounds it
600 /// up to one page silently, and `H5Fcreate` refuses it. A property quietly
601 /// ignored is worse than one refused.
602 ///
603 /// The read-only opens ignore this setting; they write nothing.
604 ///
605 /// Only the budget of `H5Pset_page_buffer_size` is modeled; its
606 /// `min_meta_perc` / `min_raw_perc` reservations are not, since this buffer
607 /// does not evict — it flushes whole.
608 #[doc(alias = "H5Pset_page_buffer_size")]
609 pub const fn with_page_buffer_size(mut self, bytes: usize) -> Self {
610 self.page_buffer_size = bytes;
611 self
612 }
613
614 /// Let a read-only open proceed past a superblock marked open for write by a
615 /// writer that is not a SWMR writer — status-flag bit 0 alone, which is what
616 /// [`with_page_buffer_size`](Self::with_page_buffer_size) raises for a
617 /// session's whole life.
618 ///
619 /// Defaults to [`WriteMarkPolicy::Refuse`], which is what `H5Fopen` does with
620 /// the same byte: [`File::open`], [`File::open_streaming`] and
621 /// [`File::from_source`] all report [`Error::FileMarkedInUse`].
622 /// [`WriteMarkPolicy::AllowSnapshot`] reads the file as it stands instead,
623 /// through whichever of those opens is passed these properties.
624 ///
625 /// # What the caller is asserting
626 ///
627 /// That the writer has flushed: it called [`File::sync`], or it stopped
628 /// after a flush and the mark stands only because nothing cleared it (a
629 /// clean [`File::close`] takes the mark down, and leaves nothing to opt past).
630 /// The mark is durable and says nothing about *when* — a live writer
631 /// mid-operation and one that exited without closing carry the same byte —
632 /// so this crate cannot check the assertion, and passing this value is how a
633 /// caller states it. It is exactly true for a writer under
634 /// [`SyncPolicy::OnClose`] that syncs at the
635 /// points it wants readable, and it is what the mark exists to guard against
636 /// when it is false: a page-buffered session's publish points are written
637 /// before the content they name, so a snapshot taken mid-flush can show a
638 /// dataset that reads clean and returns fill values, with every checksum
639 /// verifying.
640 ///
641 /// The snapshot is of the bytes on disk at open. A buffered open takes it
642 /// whole; a streaming open reads regions on demand, so a writer that carries
643 /// on writing can move bytes under it — reach for
644 /// [`File::open_with_options`] when the writer may continue, and for
645 /// [`File::open_streaming_with_options`] when it has stopped and the file is
646 /// too large to buffer.
647 ///
648 /// # What it does not unlock
649 ///
650 /// - a **SWMR pair** (both bits): that file has a reader of its own, and
651 /// [`File::open_swmr`] follows it — including across the writer's later
652 /// appends, which a snapshot cannot;
653 /// - [`File::open_rw`] and [`File::open_swmr_writer`], which are refused
654 /// whatever this says. A second writer must not join a file a writer
655 /// already holds;
656 /// - the OS advisory lock, a separate guard with its own policy
657 /// ([`with_locking`](Self::with_locking)).
658 ///
659 /// A file left marked by a writer that *crashed* is a different question,
660 /// and this is not the answer to it: it reads such a file as willingly as a
661 /// flushed one, and leaves the mark standing for the next reader to meet.
662 /// [`File::clear_swmr_flag`] — the `h5clear -s` equivalent — is the recovery
663 /// there, and it records the decision by clearing the byte.
664 ///
665 /// The C library offers no counterpart: `H5Fopen` refuses the byte with no
666 /// override, and `h5clear` is its only way through. This is the narrower one,
667 /// since it changes nothing on disk.
668 pub const fn with_write_mark_policy(mut self, policy: WriteMarkPolicy) -> Self {
669 self.write_mark_policy = policy;
670 self
671 }
672
673 /// Return the configured streaming metadata cache.
674 pub const fn metadata_cache(&self) -> MetadataCacheConfig {
675 self.metadata_cache
676 }
677
678 /// Return the configured library-version bounds, or `None` when an editing
679 /// session may write whatever its content needs.
680 pub const fn libver_bounds(&self) -> Option<(LibVer, LibVer)> {
681 self.libver_bounds
682 }
683
684 /// Return the configured per-dataset chunk cache.
685 pub const fn chunk_cache(&self) -> ChunkCacheConfig {
686 self.chunk_cache
687 }
688
689 /// Return the configured file-locking policy.
690 pub const fn locking(&self) -> FileLocking {
691 self.locking
692 }
693
694 /// Return the configured memory strategy, or `None` when none was asked for
695 /// and the entry point's own default applies. This is what was *requested*;
696 /// for which backend an open resolved to, see [`File::edit_backing`].
697 ///
698 /// The `Option` distinguishes "no preference stated" from an explicit
699 /// [`MemoryStrategy::Auto`], which is what lets an entry point supply its own
700 /// default without overriding a caller who asked for one; `None` resolves to
701 /// [`MemoryStrategy::Auto`], the only default any entry point now supplies
702 /// rather than as a second break on this accessor.
703 pub const fn memory_strategy(&self) -> Option<MemoryStrategy> {
704 self.memory_strategy
705 }
706
707 /// Return the configured `fsync` policy.
708 pub const fn sync_policy(&self) -> SyncPolicy {
709 self.sync_policy
710 }
711
712 /// Return the configured page-buffer budget in bytes; `0` when none was
713 /// asked for.
714 pub const fn page_buffer_size(&self) -> usize {
715 self.page_buffer_size
716 }
717
718 /// Return the configured write-mark policy.
719 pub const fn write_mark_policy(&self) -> WriteMarkPolicy {
720 self.write_mark_policy
721 }
722}
723
724/// Dataset-access properties applied when opening a single dataset.
725///
726/// This is the `hdf5-pure` analogue of an HDF5 **dataset access property list**
727/// (`dapl`). Its chunk cache corresponds to `H5Pset_chunk_cache`: it overrides,
728/// for this one dataset, the file-wide chunk-cache default configured with
729/// [`FileAccessProperties::with_chunk_cache`] (the `H5Pset_cache` analogue). When
730/// left unset, the dataset inherits that file-wide default — matching the `dapl`
731/// default sentinels (`H5D_CHUNK_CACHE_*_DEFAULT`), which also mean "use the
732/// file's setting".
733///
734/// The `Properties` suffix means the type stands in for one whole HDF5 property
735/// list, so every setting on it has a C counterpart to look up. It is a stand-in
736/// and not a port: a plain `Copy` value, with no handle to create or close, no
737/// runtime property registry, and no setter that can fail. `dapl` and each
738/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
739/// The chunk cache is the one `dapl` property modeled, and
740/// [Supported properties](#supported-properties) has the rest.
741///
742/// [`ChunkCacheConfig`] maps `H5Pset_chunk_cache`'s `rdcc_nslots` and
743/// `rdcc_nbytes`; its `rdcc_w0` preemption policy is not modeled, for the reason
744/// on [`ChunkCacheConfig::from_h5p_cache`].
745///
746/// Pass it to [`File::dataset_with_options`] or [`Group::dataset_with_options`].
747///
748/// # Supported properties
749///
750/// Passed to [`File::dataset_with_options`](crate::File::dataset_with_options) /
751/// [`Group::dataset_with_options`](crate::Group::dataset_with_options) as a
752/// [`DatasetAccessProperties`](Self), overriding the file-wide access defaults for one dataset. The
753/// status column is explained by the [status legend](crate::FileCreateProperties#status-legend).
754///
755/// | HDF5 property (C API) | `hdf5-pure` | Status | Behavior |
756/// |---|---|---|---|
757/// | `H5Pset_chunk_cache` | [`with_chunk_cache`](Self::with_chunk_cache) | **Genuine** | Overrides the file-wide chunk cache for this dataset only. Unset means inherit, matching the `H5D_CHUNK_CACHE_*_DEFAULT` sentinels. `rdcc_nslots` and `rdcc_nbytes` map directly, and `rdcc_w0` is not modeled, for the reason under [The chunk cache](crate::FileAccessProperties#the-chunk-cache-rdcc). |
758/// | `H5Pset_efile_prefix` / `H5Pset_virtual_prefix` | none | **Unsupported** | External and virtual datasets are not resolved, so there is no prefix to set. |
759/// | `H5Pset_virtual_view` / `H5Pset_virtual_printf_gap` | none | **Unsupported** | Virtual datasets are not supported. |
760/// | `H5Pset_append_flush` | none | **Unsupported** | There is no append callback and no per-boundary flush, and [`Dataset::append`](crate::Dataset::append) flushes on its own schedule. |
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
762#[doc(alias = "dapl")]
763pub struct DatasetAccessProperties {
764 chunk_cache: Option<ChunkCacheConfig>,
765}
766
767impl DatasetAccessProperties {
768 /// A value that inherits every file-wide access default.
769 pub const fn new() -> Self {
770 Self { chunk_cache: None }
771 }
772
773 /// Override the raw chunk cache for this one dataset, ignoring the file-wide
774 /// default. This is the `H5Pset_chunk_cache` analogue.
775 #[doc(alias = "H5Pset_chunk_cache")]
776 pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
777 self.chunk_cache = Some(chunk_cache);
778 self
779 }
780
781 /// Return the chunk-cache override, or `None` when the dataset inherits the
782 /// file-wide default.
783 pub const fn chunk_cache(&self) -> Option<ChunkCacheConfig> {
784 self.chunk_cache
785 }
786
787 /// Resolve the effective chunk-cache config: the per-dataset override if one
788 /// was set, otherwise the file-wide `default`.
789 const fn resolved_chunk_cache(&self, default: ChunkCacheConfig) -> ChunkCacheConfig {
790 match self.chunk_cache {
791 Some(config) => config,
792 None => default,
793 }
794 }
795}
796
797/// Test whether a file looks like an HDF5 file, without reading it whole.
798///
799/// This is the spelling of the C library's `H5Fis_accessible` /
800/// `H5Fis_hdf5`: it opens the file and scans only the 8-byte candidate windows
801/// where the HDF5 signature is permitted (offsets 0, 512, 1024, 2048, …), so it
802/// never buffers the whole file. Returns:
803///
804/// - `Ok(true)` — the HDF5 signature was found,
805/// - `Ok(false)` — the file opened but has no HDF5 signature,
806/// - `Err(..)` — the file could not be opened (missing, permissions, …).
807///
808/// It validates only the signature, not the rest of the format; a truncated or
809/// corrupt file past the signature still reports `true`. Use [`File::open`] to
810/// fully parse and validate.
811pub fn is_hdf5<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<bool> {
812 let handle = std::fs::File::open(path)?;
813 let source = ReadSeekSource::new(handle).map_err(std::io::Error::other)?;
814 match signature::find_signature_in(&source) {
815 Ok(_) => Ok(true),
816 Err(FormatError::SignatureNotFound) => Ok(false),
817 Err(e) => Err(std::io::Error::other(e)),
818 }
819}
820
821/// Test whether an in-memory buffer begins (at a permitted offset) with the
822/// HDF5 signature. The buffer-backed counterpart of [`is_hdf5`].
823pub fn is_hdf5_bytes(data: &[u8]) -> bool {
824 signature::find_signature(data).is_ok()
825}
826
827/// An open HDF5 file for reading.
828struct FileInner {
829 backend: Backend,
830 /// The mode every object header parse of this file is made under:
831 /// [`AccessMode::ReadWrite`] for an editing session and for a repack that parses
832 /// its source as a writer does, [`AccessMode::ReadOnly`] for every other open.
833 /// Fixed at the open, so the backend a later [`File::refresh`] swaps in cannot
834 /// change it.
835 access_mode: AccessMode,
836 superblock: Superblock,
837 /// Byte offset to add to all relative addresses (= original base_address).
838 addr_offset: BaseAddress,
839 /// Live file handle, retained only when the file was opened with
840 /// [`File::open_swmr`] so [`File::refresh`] can re-read appended data.
841 handle: Option<std::fs::File>,
842 /// File Space Info parsed from the superblock extension, if the file records
843 /// one. Best-effort: a malformed or unreadable extension leaves this `None`
844 /// rather than failing the open.
845 file_space_info: Option<FileSpaceInfo>,
846 /// The shared object header message (SOHM) table, if the superblock
847 /// extension records one. `None` for almost every file: no common producer
848 /// enables shared-message indexes. Best-effort like `file_space_info`, so a
849 /// file whose table cannot be read still opens and still reads every object
850 /// that shares nothing; what fails is following a reference into the heap,
851 /// with [`FormatError::UnsupportedSohmReference`].
852 ///
853 /// Boxed because it is absent on essentially every file, and this struct is
854 /// allocated once per open: one pointer costs less here than the table
855 /// inline, and the absent case allocates nothing at all.
856 sohm_table: Option<Box<crate::sohm::SohmTable>>,
857 access_properties: FileAccessProperties,
858 /// Set by [`File::close`] to seal a read-write file: after it, a write
859 /// through any surviving [`Dataset`]/[`Group`] handle or [`File`] clone
860 /// returns [`Error::FileClosed`]. Reads still work. Only ever set on a
861 /// `Backend::Edit` file.
862 closed: AtomicBool,
863 /// How many times a write session has been given the chance to change this
864 /// file's bytes, counted so an owned [`Dataset`] handle can tell that the
865 /// object header it parsed no longer says what the file says.
866 ///
867 /// Advanced by every operation that reaches the write engine; see
868 /// [`FileInner::with_engine_mut`], which is the only thing that advances
869 /// either counter. Never advances for a read-only or streaming file, whose
870 /// bytes cannot move under a handle at all.
871 content_revision: AtomicU64,
872 /// How many times a write session has been given the chance to *move* an
873 /// object header, which is the narrower question of whether an address a
874 /// handle is holding still names its object.
875 ///
876 /// [`Change::InPlace`] leaves this alone: an immediate [`Dataset::append`]
877 /// rewrites a dataset's header where it stands, so an address stays good
878 /// across one. That is what lets a handle reached by object reference — the
879 /// one kind with no name to look itself up by — go on appending, while a
880 /// commit ends it.
881 address_revision: AtomicU64,
882 /// True for a file opened with [`File::open_swmr_writer`]: no OS lock is held,
883 /// the superblock's SWMR-write flag is raised, only immediate
884 /// [`Dataset::append`] is permitted (the staged surface is refused), and the
885 /// flag is cleared on [`File::close`] / `Drop`. `false` for every other file.
886 swmr_write: bool,
887}
888
889impl Drop for FileInner {
890 /// Best-effort cleanup for a writer dropped without an explicit
891 /// [`File::close`], running only when the last `Arc<FileInner>` clone drops;
892 /// a clean `close` already did this work and set `closed`, so this is
893 /// idempotent and skipped in that case.
894 ///
895 /// - A SWMR writer clears the superblock's SWMR-write flag (mirroring
896 /// `File::close`).
897 /// - A read-write file that persists its free space rewrites its on-disk
898 /// free-space managers into canonical shape (issue #173), so a
899 /// dropped-without-`close` handle leaves the same file a clean `close`
900 /// would (a no-op unless an immediate append grew the file past them). A
901 /// true crash (`SIGKILL`, power loss) skips `drop` entirely; the appended
902 /// data is still durable, under the default
903 /// [`SyncPolicy::Always`](crate::SyncPolicy).
904 ///
905 /// Staged edits are *not* committed here: dropping a handle discards them,
906 /// which is what `close` exists to distinguish.
907 fn drop(&mut self) {
908 if self.closed.load(Ordering::Acquire) {
909 return;
910 }
911 let Backend::Edit(m) = &self.backend else {
912 return;
913 };
914 let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
915 // Both branches write, and this is the last moment anything can order
916 // those writes: the handle is gone once this returns, so `File::sync` is
917 // not an option the caller still has. The barrier is therefore forced
918 // rather than left to the session's `SyncPolicy` — see
919 // [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose).
920 // SWMR stages nothing and persists no free space, so it skips the
921 // re-homing; everything after that is the same teardown for both.
922 if !self.swmr_write {
923 let _ = session.finalize_persist();
924 }
925 // Only if the flush actually succeeded. The flags say this session's
926 // writes may still be in memory, and a failed `force_sync` is precisely
927 // the case where that is still true — taking them down there would
928 // publish the file as complete over a drain that did not finish.
929 if session.force_sync().is_ok() {
930 let _ = session.release_status_flags();
931 }
932 }
933}
934
935impl FileInner {
936 /// Opens the HDF5 file at `path` and reads it into memory.
937 pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
938 Self::open_with_options(path, FileAccessProperties::new())
939 }
940
941 /// Open an HDF5 file from a filesystem path with explicit access properties.
942 ///
943 /// Like [`open`](Self::open), this buffers the whole file in memory. Use
944 /// [`open_streaming_with_options`](Self::open_streaming_with_options) when
945 /// the metadata cache budget should apply to lazy metadata reads.
946 pub fn open_with_options<P: AsRef<std::path::Path>>(
947 path: P,
948 properties: FileAccessProperties,
949 ) -> Result<Self, Error> {
950 let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
951 let write_mark = properties.write_mark_policy;
952 let inner = Self::from_bytes_with_options(bytes, properties)?;
953 // The status-flag check belongs to every open that reads the live file
954 // — this one, `open_streaming` and `from_source` — and not to
955 // `from_bytes_with_options` (issue #245). The line is snapshot against
956 // live, not path against no path: a caller who already holds the bytes
957 // has taken its own snapshot, and there is no live file left to
958 // coordinate over. This is a deliberate divergence from the C library,
959 // which checks under its in-memory core driver too.
960 file_lock::check_status_flags(
961 &inner.superblock,
962 OpenIntent::Read(write_mark),
963 OpenTarget::Path(path.as_ref()),
964 )?;
965 Ok(inner)
966 }
967
968 /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
969 /// the file instead of buffering it whole.
970 ///
971 /// This lets a host read a file larger than its address space — the original
972 /// motivation being 32-bit targets reading multi-gigabyte files (issue #27).
973 /// Metadata and dataset chunks are read through a `ReadSeekSource`, so peak
974 /// memory stays close to one chunk plus the metadata being parsed. Chunks
975 /// that sit next to each other on disk are fetched together, in reads of at
976 /// most 256 KiB (a larger chunk is read on its own), which is what makes a
977 /// file written a row at a time — thousands of chunks of a few dozen bytes
978 /// — read at a sensible speed. See [`crate::chunk_span`].
979 ///
980 /// Reads match the buffered [`File::open`]: every storage layout and chunk
981 /// index type, both group forms (v2 and v1 symbol-table), and compact,
982 /// dense, shared, and variable-length attributes. What differs:
983 /// [`as_bytes`](Self::as_bytes) returns an empty slice (there is no
984 /// whole-file buffer), [`persisted_free_space`](Self::persisted_free_space)
985 /// returns no regions, a streaming file cannot be the *source* of a
986 /// cross-file copy, and chunk decompression is sequential (the `parallel`
987 /// feature accelerates only buffered reads).
988 pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
989 Self::open_streaming_with_options(path, FileAccessProperties::new())
990 }
991
992 /// Open an HDF5 file for streaming reads with explicit access properties.
993 pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
994 path: P,
995 properties: FileAccessProperties,
996 ) -> Result<Self, Error> {
997 let handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
998 let source = ReadSeekSource::new(handle).map_err(Error::Format)?;
999 Self::streaming(
1000 source,
1001 AccessMode::ReadOnly,
1002 properties,
1003 OpenTarget::Path(path.as_ref()),
1004 )
1005 }
1006
1007 /// Opens the file at `path` for streaming reads whose object header parses are
1008 /// made under `access_mode`. A parse under [`AccessMode::ReadWrite`] rejects a
1009 /// message of a type this crate cannot name that a decoder with write access
1010 /// must understand.
1011 pub(crate) fn open_streaming_with_access_mode(
1012 path: &std::path::Path,
1013 access_mode: AccessMode,
1014 ) -> Result<Self, Error> {
1015 let handle = std::fs::File::open(path).map_err(Error::Io)?;
1016 let source = ReadSeekSource::new(handle).map_err(Error::Format)?;
1017 Self::streaming(
1018 source,
1019 access_mode,
1020 FileAccessProperties::new(),
1021 OpenTarget::Path(path),
1022 )
1023 }
1024
1025 /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
1026 /// demand as [`open_streaming`](Self::open_streaming) does.
1027 pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
1028 Self::from_source_with_options(source, FileAccessProperties::new())
1029 }
1030
1031 /// Open an HDF5 file from any [`Source`] with explicit access properties.
1032 pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
1033 source: S,
1034 properties: FileAccessProperties,
1035 ) -> Result<Self, Error> {
1036 // The only source that reaches the parsers from outside the crate, and
1037 // so the only one whose reads are length-checked: see `ValidatedSource`.
1038 Self::streaming(
1039 ValidatedSource::new(source),
1040 AccessMode::ReadOnly,
1041 properties,
1042 OpenTarget::Source,
1043 )
1044 }
1045
1046 /// The body every streaming open shares.
1047 ///
1048 /// Wraps the source in whatever metadata cache the properties ask for,
1049 /// parses the superblock through it, and rejects a file a writer holds.
1050 /// The three differ in where the source came from, in `access_mode`, and in
1051 /// `target`, which does nothing but name the file in that rejection. One
1052 /// body is what keeps the rest of it from drifting apart.
1053 fn streaming<S: Source + Send + Sync + 'static>(
1054 source: S,
1055 access_mode: AccessMode,
1056 properties: FileAccessProperties,
1057 target: OpenTarget<'_>,
1058 ) -> Result<Self, Error> {
1059 let source: Box<dyn Source + Send + Sync> = if properties.metadata_cache.is_enabled() {
1060 Box::new(MetadataCachingSource::new(
1061 source,
1062 properties.metadata_cache,
1063 ))
1064 } else {
1065 Box::new(source)
1066 };
1067 let (superblock, addr_offset) = Self::parse_superblock_source(source.as_ref())?;
1068 file_lock::check_status_flags(
1069 &superblock,
1070 OpenIntent::Read(properties.write_mark_policy),
1071 target,
1072 )?;
1073 Ok(Self::from_parts(
1074 Backend::Streaming(source),
1075 access_mode,
1076 superblock,
1077 addr_offset,
1078 None,
1079 properties,
1080 ))
1081 }
1082
1083 /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
1084 ///
1085 /// Like [`File::open`], but retains a live handle to the file so that
1086 /// [`File::refresh`] can re-read data appended by a concurrent writer
1087 /// (whether produced by this crate's append writer, the reference HDF5 C
1088 /// library, or h5py in SWMR mode). The initial view is a consistent
1089 /// snapshot; call [`File::refresh`] to advance to a newer one.
1090 ///
1091 /// Only the `std` build supports this (it requires a live filesystem
1092 /// handle); the in-memory [`File::from_bytes`] path cannot refresh.
1093 pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1094 Self::open_swmr_with_options(path, FileAccessProperties::new())
1095 }
1096
1097 /// Open an HDF5 file for SWMR reading with explicit access properties.
1098 ///
1099 /// SWMR reads currently keep an in-memory mirror for refresh semantics, so
1100 /// only the per-dataset chunk-cache settings affect this backend.
1101 pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
1102 path: P,
1103 properties: FileAccessProperties,
1104 ) -> Result<Self, Error> {
1105 let mut handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
1106 let mut data = Vec::new();
1107 handle.read_to_end(&mut data).map_err(Error::Io)?;
1108 let (superblock, addr_offset) = Self::parse_superblock(&data)?;
1109 file_lock::check_status_flags(
1110 &superblock,
1111 OpenIntent::SwmrRead,
1112 OpenTarget::Path(path.as_ref()),
1113 )?;
1114 Ok(Self::from_parts(
1115 Backend::InMemory(data),
1116 AccessMode::ReadOnly,
1117 superblock,
1118 addr_offset,
1119 Some(handle),
1120 properties,
1121 ))
1122 }
1123
1124 /// Open an HDF5 file from an in-memory byte vector.
1125 pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
1126 Self::from_bytes_with_options(data, FileAccessProperties::new())
1127 }
1128
1129 /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
1130 pub fn from_bytes_with_options(
1131 data: Vec<u8>,
1132 properties: FileAccessProperties,
1133 ) -> Result<Self, Error> {
1134 let (superblock, addr_offset) = Self::parse_superblock(&data)?;
1135 Ok(Self::from_parts(
1136 Backend::InMemory(data),
1137 AccessMode::ReadOnly,
1138 superblock,
1139 addr_offset,
1140 None,
1141 properties,
1142 ))
1143 }
1144
1145 /// Open an existing HDF5 file for reading **and** in-place editing, applying
1146 /// `properties` (its [`FileLocking`] policy governs the OS file lock held for
1147 /// the file's life, and its chunk cache is the file-wide default).
1148 fn open_rw<P: AsRef<std::path::Path>>(
1149 path: P,
1150 properties: FileAccessProperties,
1151 ) -> Result<Self, Error> {
1152 Self::open_rw_with_default(path, properties, MemoryStrategy::Auto)
1153 }
1154
1155 /// Open read-write under the properties' memory strategy, falling back to
1156 /// `default` when the caller expressed none. The two public read-write entry
1157 /// [`File::open_rw`] passes [`MemoryStrategy::Auto`]: prefer the bounded
1158 /// engine, but take the mirror for a file the bounded engine cannot edit
1159 /// (issue #198, step 4). A caller who states a strategy overrides it.
1160 fn open_rw_with_default<P: AsRef<std::path::Path>>(
1161 path: P,
1162 properties: FileAccessProperties,
1163 default: MemoryStrategy,
1164 ) -> Result<Self, Error> {
1165 let session = WriteEngine::open_rw_with_strategy(
1166 path.as_ref(),
1167 properties.metadata_cache,
1168 properties.locking,
1169 properties.memory_strategy.unwrap_or(default),
1170 )?;
1171 Self::from_rw_session(session, properties)
1172 }
1173
1174 /// Wrap an opened [`WriteEngine`] as a read-write [`Backend::Edit`] file.
1175 fn from_rw_session(
1176 mut session: WriteEngine,
1177 properties: FileAccessProperties,
1178 ) -> Result<Self, Error> {
1179 // The one funnel every read-write session passes through, so the fapl's
1180 // format bound and `fsync` cadence reach the engine no matter which entry
1181 // point opened it — the SWMR writer included, which
1182 // `WriteEngine::open_swmr_writer` says why.
1183 session.set_libver_bounds(properties.libver_bounds)?;
1184 session.set_sync_policy(properties.sync_policy);
1185 // Every page-buffer refusal lives in `set_page_buffer_size`, including the
1186 // `SyncPolicy::Always` one — which is why `set_sync_policy` must precede
1187 // this call rather than merely happening to.
1188 session.set_page_buffer_size(properties.page_buffer_size)?;
1189 // The engine parsed and normalized this at open; take it rather than
1190 // re-parsing, so the image need not be able to hand out a slice.
1191 let superblock = session.superblock().clone();
1192 let addr_offset = superblock.base_address;
1193 Ok(Self::from_parts(
1194 Backend::Edit(Box::new(Mutex::new(session))),
1195 AccessMode::ReadWrite,
1196 superblock,
1197 addr_offset,
1198 None,
1199 properties,
1200 ))
1201 }
1202
1203 /// Open for SWMR writing: no OS lock, superblock SWMR-write flag raised.
1204 fn open_swmr_writer<P: AsRef<std::path::Path>>(
1205 path: P,
1206 properties: FileAccessProperties,
1207 ) -> Result<Self, Error> {
1208 // The SWMR writer always mirrors. `Auto` and unset are *satisfied* by
1209 // that — they ask for the bounded engine where it applies and accept the
1210 // mirror where it does not — but `Bounded` is a guarantee, and honoring a
1211 // guarantee by ignoring it is how a caller ends up spending `O(file size)`
1212 // memory it asked not to. Refusing is also the permissive direction to be
1213 // wrong in: if this writer ever runs bounded, the refusal stops firing,
1214 // which breaks nobody.
1215 if properties.memory_strategy == Some(MemoryStrategy::Bounded) {
1216 return Err(Error::EditUnsupported(
1217 "the SWMR writer always holds the file in a whole-file mirror; leave \
1218 MemoryStrategy unset, or pass MemoryStrategy::Auto or MemoryStrategy::Mirrored, \
1219 to open it",
1220 ));
1221 }
1222 // A library-version bound below 1.10 is the same shape of unhonorable
1223 // guarantee. SWMR needs a version 3 superblock — neither library reads
1224 // the SWMR-write flag back on an older one — so a caller asking for the
1225 // 1.8 format here is asking for a file this writer cannot produce.
1226 if let Some((low, high)) = properties.libver_bounds
1227 && LibVer::resolve_writable(Some((low, high))).map_err(Error::Format)? < LibVer::V110
1228 {
1229 return Err(Error::EditUnsupported(
1230 "the SWMR writer requires a version 3 superblock, which is the v1.10 format; \
1231 raise the FileAccessProperties library-version bound to open it",
1232 ));
1233 }
1234 // And a page buffer is the third. A SWMR reader follows the writer's
1235 // ordered phases as they become visible, so coalescing those writes is
1236 // not a slower or larger file but a reader that sees a state the phases
1237 // exist to keep it from seeing.
1238 if properties.page_buffer_size != 0 {
1239 return Err(Error::EditUnsupported(
1240 "the SWMR writer cannot buffer its writes: its readers observe the order they \
1241 become visible in; leave FileAccessProperties::with_page_buffer_size unset to \
1242 open it",
1243 ));
1244 }
1245 let session = WriteEngine::open_swmr_writer(path, properties.sync_policy)?;
1246 let mut inner = Self::from_rw_session(session, properties)?;
1247 inner.swmr_write = true;
1248 Ok(inner)
1249 }
1250
1251 /// After the caller has confirmed a [`Backend::Edit`] backend, gate the
1252 /// mutation: refuse a sealed file with [`Error::FileClosed`], and in
1253 /// SWMR-writer mode refuse a staged edit (`staged = true`) with
1254 /// [`Error::SwmrStagedUnsupported`] — only immediate appends are allowed.
1255 fn check_mutable(&self, staged: bool) -> Result<(), Error> {
1256 if self.closed.load(Ordering::Acquire) {
1257 return Err(Error::FileClosed);
1258 }
1259 if staged && self.swmr_write {
1260 return Err(Error::SwmrStagedUnsupported);
1261 }
1262 Ok(())
1263 }
1264
1265 /// Gate a staged edit *without* taking the session lock: the backend must
1266 /// offer the staged surface, and the file must still be mutable.
1267 ///
1268 /// This is the same gate the locking helpers apply before locking, split out
1269 /// so a public method taking a user closure can report a read-only or sealed
1270 /// file up front, run the closure with no lock held, and take the lock only
1271 /// to record the result (issue #200).
1272 fn check_staged_writable(&self) -> Result<(), Error> {
1273 match &self.backend {
1274 Backend::Edit(_) => self.check_mutable(true),
1275 _ => Err(Error::ReadOnly),
1276 }
1277 }
1278
1279 /// Lock this file's write session for an operation that may change the
1280 /// file, and record afterwards that it had the chance to.
1281 ///
1282 /// Every path that can change the file's bytes — [`File::commit`] and the
1283 /// copies, every staged and immediate edit a [`Dataset`] or [`Group`] handle
1284 /// makes, and the session teardown — goes through here, so a new entry point
1285 /// cannot change the file without classifying what it did. Two write the
1286 /// file without passing here, both because no handle can be alive to see it:
1287 /// [`FileInner::drop`], which runs when the last `Arc` goes, and
1288 /// [`File::refresh`], which takes `&mut self` through `Arc::get_mut` and
1289 /// advances the counters itself.
1290 ///
1291 /// The appender's claim bookkeeping ([`Dataset::claim_for_appender`] and its
1292 /// pair) locks the engine directly and rightly notes nothing: it records who
1293 /// is appending, not what the file holds — and it must keep working from a
1294 /// `Drop` on a sealed file, which this gate refuses.
1295 ///
1296 /// The counters advance whether `f` succeeded or not, and for a staged edit
1297 /// that changes no bytes at all. Both are deliberate: a refused commit can
1298 /// still have written and rolled back (issues #316 and #344), and the cost
1299 /// of a revision that did not need advancing is one re-read on the next use
1300 /// of a handle, where the cost of one that needed advancing and did not is a
1301 /// wrong answer.
1302 fn with_engine_mut<R>(
1303 &self,
1304 change: Change,
1305 f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
1306 ) -> Result<R, Error> {
1307 let Backend::Edit(m) = &self.backend else {
1308 return Err(Error::ReadOnly);
1309 };
1310 self.check_mutable(matches!(change, Change::Relocating))?;
1311 let out = {
1312 let mut engine = m.lock().unwrap_or_else(PoisonError::into_inner);
1313 f(&mut engine)
1314 };
1315 self.note(change);
1316 out
1317 }
1318
1319 /// Record that an operation of kind `change` has run against this file.
1320 ///
1321 /// The address counter moves *first*, against [`revisions`](Self::revisions)
1322 /// reading it second. A reader that sees the new content revision has
1323 /// therefore already synchronized with this release, so it cannot then read
1324 /// an address revision from before it and conclude that its memoized address
1325 /// outlived a commit that moved it.
1326 fn note(&self, change: Change) {
1327 match change {
1328 Change::Relocating => {
1329 self.address_revision.fetch_add(1, Ordering::Release);
1330 self.content_revision.fetch_add(1, Ordering::Release);
1331 }
1332 Change::InPlace => {
1333 self.content_revision.fetch_add(1, Ordering::Release);
1334 }
1335 Change::Nothing => {}
1336 }
1337 }
1338
1339 /// How many times a write session has been given the chance to change this
1340 /// file's bytes. A [`Dataset`] handle whose header was parsed at this value
1341 /// still holds what the file holds.
1342 fn content_revision(&self) -> u64 {
1343 self.content_revision.load(Ordering::Acquire)
1344 }
1345
1346 /// How many times a write session has been given the chance to move an
1347 /// object header. An address worked out at this value still names its
1348 /// object.
1349 fn address_revision(&self) -> u64 {
1350 self.address_revision.load(Ordering::Acquire)
1351 }
1352
1353 /// Work out where a handle's object header sits now, and say what that
1354 /// answer holds as of.
1355 ///
1356 /// One rule, in the order it is written. An address nothing has moved since
1357 /// still names its object, whichever way the handle names it — which is what
1358 /// keeps an in-place append anywhere in the file from making every handle
1359 /// walk its path again. Past that, a handle opened by `path` looks its
1360 /// object up, which follows it wherever a commit put it, and one reached by
1361 /// object reference has no name to look up: `memo` held the only address it
1362 /// will ever have, and the bytes a relocated header vacates still parse as
1363 /// the object that left them, so there is nothing left to read.
1364 ///
1365 /// Both counters are read *before* the resolution, never after: a value
1366 /// taken afterwards could name a state the address predates, and a handle
1367 /// that memoized that pairing would go on answering from a header a commit
1368 /// had already moved. Taken beforehand, a concurrent change makes the
1369 /// pairing merely stale — which the next use notices, so long as it is the
1370 /// next *use*. A commit running on another thread between this read and the
1371 /// bytes it labels is not ordered against either, so a handle shared across
1372 /// threads can still serve one read from a header a concurrent commit had
1373 /// moved. What these counters order is a handle against edits already made,
1374 /// not against one in flight. The callers that classify what they resolved
1375 /// to — [`Dataset::resolved`], [`Group::header_address`] — parse that header
1376 /// in the same unordered window, so a commit landing inside it can also make
1377 /// a live handle report [`Error::NotADataset`] or
1378 /// [`Error::NotAGroup`] for an object whose kind
1379 /// never changed. That is the same staleness reporting itself instead of
1380 /// answering, which is the better half of the trade.
1381 fn locate(
1382 &self,
1383 path: Option<&ObjectPathBuf>,
1384 memo: Option<Resolution>,
1385 ) -> Result<Resolution, Error> {
1386 let revisions = self.revisions();
1387 // A handle with no memo has never resolved — it names an object this
1388 // session staged — so there is nothing to short-circuit on, and its path
1389 // is walked afresh every time until a commit gives it a header.
1390 Ok(revisions.at(match (path, memo) {
1391 (_, Some(memo)) if revisions.address == memo.address_revision => memo.address,
1392 (Some(path), _) => self.resolve_path(&path.as_path())?,
1393 (None, _) => return Err(Error::StaleHandle),
1394 }))
1395 }
1396
1397 /// [`locate`](Self::locate), reported as [`Error::NotCommitted`] when this
1398 /// session has the path *staged* rather than written, and as
1399 /// [`Error::StagingWithdrawn`] when the staging a handle was born onto is
1400 /// gone.
1401 ///
1402 /// Every handle onto a staged object comes through here, so the distinction
1403 /// between "there is no such object", "there is one, and `commit` has not
1404 /// written it yet" and "the one this handle names has been withdrawn" is
1405 /// made in one place rather than at each caller.
1406 ///
1407 /// The staged set is consulted *before* the file for a handle that has never
1408 /// resolved, because a staged creation can sit on a path the file still
1409 /// holds: a delete and a create in one commit replace the object there
1410 /// (issue #305), and until that commit runs the old bytes are still
1411 /// perfectly readable. Answering from them would hand the caller the object
1412 /// their handle was explicitly not opened onto.
1413 ///
1414 /// `birth` is the handle's own [`Standing`] input — `None` for one opened
1415 /// onto an object in the file. This question is keyed by path alone, so a
1416 /// born handle whose creation was withdrawn and replaced by one of the other
1417 /// kind at the same path reports `NotCommitted` here where
1418 /// [`staged_dataset_view`](Self::staged_dataset_view) tells it apart; both
1419 /// refuse, and neither answers from the file.
1420 fn locate_staged(
1421 &self,
1422 path: Option<&ObjectPathBuf>,
1423 memo: Option<Resolution>,
1424 birth: Option<u64>,
1425 ) -> Result<Resolution, Error> {
1426 if let (None, Some(named)) = (memo, path) {
1427 match self.staged_standing(named, birth) {
1428 Standing::Pending => return Err(Error::NotCommitted(named.to_string())),
1429 Standing::Withdrawn => return Err(Error::StagingWithdrawn(named.to_string())),
1430 Standing::Live => {}
1431 }
1432 }
1433 match self.locate(path, memo) {
1434 Err(Error::Format(FormatError::PathNotFound(missing))) => {
1435 match path.and_then(|named| self.staged_object(named)) {
1436 Some(_) => Err(Error::NotCommitted(reported_path(path))),
1437 None => Err(Error::Format(FormatError::PathNotFound(missing))),
1438 }
1439 }
1440 other => other,
1441 }
1442 }
1443
1444 /// Run `f` against this file's write session, or `None` when there is none
1445 /// (a read-only file stages nothing, so it has nothing to be asked about).
1446 fn query_engine<R>(&self, f: impl FnOnce(&WriteEngine) -> R) -> Option<R> {
1447 match &self.backend {
1448 Backend::Edit(m) => Some(f(&m.lock().unwrap_or_else(PoisonError::into_inner))),
1449 _ => None,
1450 }
1451 }
1452
1453 /// What this session has staged at `path`, if anything. See
1454 /// [`WriteEngine::staged_object`] for the rule.
1455 fn staged_object(&self, path: &ObjectPathBuf) -> Option<StagedObject> {
1456 self.query_engine(|e| e.staged_object(path)).flatten()
1457 }
1458
1459 /// Where a handle naming `path` stands: whether the object it addresses is
1460 /// one the file holds, one this session has staged, or one whose staging has
1461 /// been withdrawn under it.
1462 ///
1463 /// `birth` is the session's [staged generation](WriteEngine::staged_generation)
1464 /// as of the moment the handle was made onto a staged creation, and `None`
1465 /// for every handle opened onto an object in the file. It is what separates
1466 /// the two ways a staged path can stop being staged — the commit published
1467 /// it, or a [`Group::delete`] withdrew it — which otherwise look identical
1468 /// from here, and which a handle must not confuse: a withdrawn creation
1469 /// leaves the file's own object at that path, and it is the object the
1470 /// session is *deleting*.
1471 ///
1472 /// Both halves come from one lock, so a commit cannot land between them and
1473 /// pair a generation with a staged set from the other side of it.
1474 ///
1475 /// A file with no write session stages nothing, so every handle onto one is
1476 /// live. That includes the `birth`-carrying arm, which such a file cannot
1477 /// produce: a handle holds its [`FileInner`] alive and a backend never
1478 /// changes under one, so the case is unreachable rather than merely unlikely.
1479 fn staged_standing(&self, path: &ObjectPathBuf, birth: Option<u64>) -> Standing {
1480 let Some((generation, staged)) =
1481 self.query_engine(|e| (e.staged_generation(), e.staged_object(path).is_some()))
1482 else {
1483 return Standing::Live;
1484 };
1485 match birth {
1486 // A commit has taken this session's staged set since the handle was
1487 // made, so what it names is in the file (or was deleted from it
1488 // afterwards, which resolving the path reports).
1489 Some(birth) if birth != generation => Standing::Live,
1490 Some(_) if !staged => Standing::Withdrawn,
1491 _ if staged => Standing::Pending,
1492 _ => Standing::Live,
1493 }
1494 }
1495
1496 /// What a dataset staged at `path` says about itself before it is written,
1497 /// for a handle whose staged generation at birth was `birth`.
1498 ///
1499 /// `Ok(None)` means the handle should read the file: either nothing is
1500 /// staged there, or a commit has published what was. The
1501 /// [`Error::StagingWithdrawn`] arm is [`staged_standing`](Self::staged_standing)'s,
1502 /// decided under the same lock — and told apart by *kind* here, since a
1503 /// dataset creation withdrawn and replaced by a group at the same path
1504 /// leaves nothing for this handle to answer from either.
1505 fn staged_dataset_view(
1506 &self,
1507 path: &ObjectPathBuf,
1508 birth: Option<u64>,
1509 ) -> Result<Option<StagedMeta>, Error> {
1510 let Some((generation, meta)) =
1511 self.query_engine(|e| (e.staged_generation(), e.staged_dataset_meta(path)))
1512 else {
1513 return Ok(None);
1514 };
1515 match birth {
1516 Some(birth) if birth != generation => Ok(None),
1517 Some(_) if meta.is_none() => Err(Error::StagingWithdrawn(path.to_string())),
1518 _ => Ok(meta),
1519 }
1520 }
1521
1522 /// The session's staged generation now, for a handle being made onto a
1523 /// creation staged in it. `None` on a file with no write session, which
1524 /// stages nothing and so makes no such handle.
1525 fn staged_generation(&self) -> Option<u64> {
1526 self.query_engine(WriteEngine::staged_generation)
1527 }
1528
1529 /// The direct children `parent` gains from this session's staged creations.
1530 fn staged_children(&self, parent: &ObjectPathBuf) -> StagedChildren {
1531 self.query_engine(|e| e.staged_children(parent))
1532 .unwrap_or_default()
1533 }
1534
1535 /// The revisions to label a resolution that is about to be worked out with.
1536 fn revisions(&self) -> Revisions {
1537 Revisions {
1538 content: self.content_revision(),
1539 address: self.address_revision(),
1540 }
1541 }
1542
1543 /// A `Source` view over the backend, for the streaming-capable paths.
1544 pub(crate) fn source(&self) -> SourceView<'_> {
1545 match &self.backend {
1546 Backend::InMemory(v) => SourceView::Mem(v),
1547 Backend::Streaming(s) => SourceView::Stream(s.as_ref()),
1548 // A mirror or bounded file's bytes live behind a lock and cannot be
1549 // lent out as a borrowed view; the read paths that reach every
1550 // backend go through [`with_source`](Self::with_source) instead.
1551 Backend::Edit(_) => SourceView::Mem(&[]),
1552 }
1553 }
1554
1555 /// Run `f` with a random-access view of this file's bytes, taking the
1556 /// write-engine lock when the backend requires one. Unlike
1557 /// [`source`](Self::source) — which cannot lend a borrowed view out of a
1558 /// lock and returns an empty view for the mirror and bounded backends —
1559 /// this serves every backend, so it is the dispatch for read paths (heap
1560 /// reads for variable-length data, chunk enumeration) that must also work
1561 /// on a read-write file. `f` must not re-enter this file's backend (the
1562 /// engine lock is held while it runs).
1563 pub(crate) fn with_source<R>(&self, f: impl FnOnce(&dyn Source) -> R) -> R {
1564 match &self.backend {
1565 Backend::InMemory(v) => f(&BytesSource::new(v.as_slice())),
1566 Backend::Streaming(s) => f(s.as_ref()),
1567 Backend::Edit(m) => {
1568 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1569 f(core.image())
1570 }
1571 }
1572 }
1573
1574 /// Run a read against a read-write session's file image, choosing the form
1575 /// its backing can serve: `on_slice` when the session holds the whole file
1576 /// in memory, so a slice-walking parser borrows the bytes instead of copying
1577 /// them, and `on_source` otherwise.
1578 ///
1579 /// Both closures must compute the same thing. The pair exists because a
1580 /// mirror can hand out a whole-file slice and a file-backed image cannot,
1581 /// not because the two backings answer differently (issue #198).
1582 fn with_engine<R>(
1583 engine: &Mutex<WriteEngine>,
1584 on_slice: impl FnOnce(&[u8]) -> R,
1585 on_source: impl FnOnce(&dyn Source) -> R,
1586 ) -> R {
1587 let core = engine
1588 .lock()
1589 .unwrap_or_else(std::sync::PoisonError::into_inner);
1590 match core.image_slice() {
1591 Some(data) => on_slice(data),
1592 None => on_source(core.image()),
1593 }
1594 }
1595
1596 /// Parse the superblock from `data`, returning it (with `root_group_address`
1597 /// normalized to an absolute offset) and the base-address offset.
1598 fn parse_superblock(data: &[u8]) -> Result<(Superblock, BaseAddress), Error> {
1599 let sig_offset = signature::find_signature(data)?;
1600 let mut superblock = Superblock::parse(data, sig_offset)?;
1601 let addr_offset = superblock.base_address;
1602 // Normalize root_group_address to absolute so resolve_path_any works.
1603 superblock.root_group_address =
1604 addr_offset.absolute(StoredAddress::new(superblock.root_group_address))?;
1605 Ok((superblock, addr_offset))
1606 }
1607
1608 /// Streaming counterpart of [`parse_superblock`](Self::parse_superblock): locate and parse the
1609 /// superblock by reading only small windows from the source.
1610 fn parse_superblock_source<S: Source + ?Sized>(
1611 source: &S,
1612 ) -> Result<(Superblock, BaseAddress), Error> {
1613 let sig_offset = signature::find_signature_in(source)?;
1614 let mut superblock = Superblock::parse_from_source(source, sig_offset)?;
1615 let addr_offset = superblock.base_address;
1616 superblock.root_group_address =
1617 addr_offset.absolute(StoredAddress::new(superblock.root_group_address))?;
1618 Ok((superblock, addr_offset))
1619 }
1620
1621 /// Assemble a [`File`] from parsed parts, then load the File Space Info from
1622 /// the superblock extension (best-effort, so a bad extension never fails the
1623 /// open).
1624 fn from_parts(
1625 backend: Backend,
1626 access_mode: AccessMode,
1627 superblock: Superblock,
1628 addr_offset: BaseAddress,
1629 handle: Option<std::fs::File>,
1630 access_properties: FileAccessProperties,
1631 ) -> Self {
1632 let mut file = FileInner {
1633 backend,
1634 access_mode,
1635 superblock,
1636 addr_offset,
1637 handle,
1638 file_space_info: None,
1639 sohm_table: None,
1640 access_properties,
1641 closed: AtomicBool::new(false),
1642 content_revision: AtomicU64::new(0),
1643 address_revision: AtomicU64::new(0),
1644 swmr_write: false,
1645 };
1646 file.file_space_info = file.read_file_space_info();
1647 file.sohm_table = file.read_sohm_table();
1648 file
1649 }
1650
1651 /// Parse the File Space Info message from the superblock extension, if the
1652 /// file records one and it can be read. Best-effort: any failure (no
1653 /// extension, unreadable object header, malformed message) yields `None`.
1654 fn read_file_space_info(&self) -> Option<FileSpaceInfo> {
1655 let rel = self.superblock.superblock_extension_address?;
1656 if rel == u64::MAX {
1657 return None;
1658 }
1659 let abs = self.addr_offset.absolute(StoredAddress::new(rel)).ok()?;
1660 let header = self.parse_header(abs).ok()?;
1661 let msg = header
1662 .messages
1663 .iter()
1664 .find(|m| m.msg_type == MessageType::FILE_SPACE_INFO)?;
1665 FileSpaceInfo::parse(
1666 &msg.data,
1667 self.superblock.offset_size,
1668 self.superblock.length_size,
1669 )
1670 .ok()
1671 }
1672
1673 /// Parse the Shared Message Table message from the superblock extension and
1674 /// read the master table it names, if the file records one.
1675 ///
1676 /// Best-effort in the same way and for the same reason as
1677 /// [`Self::read_file_space_info`]: a file whose shared-message table is
1678 /// unreadable still opens, and every object in it that shares no message
1679 /// still reads.
1680 fn read_sohm_table(&self) -> Option<Box<crate::sohm::SohmTable>> {
1681 let rel = self.superblock.superblock_extension_address?;
1682 if rel == u64::MAX {
1683 return None;
1684 }
1685 let abs = self.addr_offset.absolute(StoredAddress::new(rel)).ok()?;
1686 let header = self.parse_header(abs).ok()?;
1687 let msg = header
1688 .messages
1689 .iter()
1690 .find(|m| m.msg_type == MessageType::SHARED_MESSAGE_TABLE)?;
1691 let message =
1692 crate::sohm::SharedMessageTableMessage::parse(&msg.data, self.superblock.offset_size)
1693 .ok()?;
1694 // The table's address, like every address in a header message, is stored
1695 // relative to the file's base address, so the read is framed the same way
1696 // every other metadata walk here is.
1697 let base = self.addr_offset;
1698 let os = self.superblock.offset_size;
1699 match &self.backend {
1700 Backend::InMemory(v) => {
1701 crate::sohm::SohmTable::read(frame(v, base).ok()?, &message, os).ok()
1702 }
1703 Backend::Streaming(s) if base.is_zero() => {
1704 crate::sohm::SohmTable::read_from_source(s.as_ref(), &message, os).ok()
1705 }
1706 Backend::Streaming(s) => crate::sohm::SohmTable::read_from_source(
1707 &BaseOffsetSource {
1708 inner: s.as_ref(),
1709 base,
1710 },
1711 &message,
1712 os,
1713 )
1714 .ok(),
1715 Backend::Edit(m) => Self::with_engine(
1716 m,
1717 |d| Ok::<_, Error>(crate::sohm::SohmTable::read(frame(d, base)?, &message, os)?),
1718 |s| {
1719 if base.is_zero() {
1720 Ok(crate::sohm::SohmTable::read_from_source(s, &message, os)?)
1721 } else {
1722 Ok(crate::sohm::SohmTable::read_from_source(
1723 &BaseOffsetSource { inner: s, base },
1724 &message,
1725 os,
1726 )?)
1727 }
1728 },
1729 )
1730 .ok(),
1731 }
1732 .map(Box::new)
1733 }
1734
1735 /// Re-read the file from disk to pick up data appended by a concurrent
1736 /// writer, then re-parse the superblock.
1737 ///
1738 /// This is the SWMR reader's refresh primitive (analogous to the C library's
1739 /// `H5Drefresh` / h5py's `Dataset.refresh()`): after it returns, newly
1740 /// fetched [`Dataset`]/[`Group`] handles observe the writer's appended
1741 /// chunks and extended dimensions, because they re-parse object headers at
1742 /// their (stable) addresses against the refreshed bytes. Existing handles
1743 /// borrow `&self`, so they must be dropped before calling this; re-fetch
1744 /// them afterward.
1745 ///
1746 /// Returns [`Error::SwmrUnsupported`] if the file was not opened with
1747 /// [`File::open_swmr`]. The superblock is checksum-validated on every
1748 /// re-read; a transient parse failure (a writer caught mid-flush) is
1749 /// retried a bounded number of times before being surfaced.
1750 ///
1751 /// Cost: each call re-reads the entire file from disk (`O(file size)`).
1752 /// That keeps the implementation simple and correct, but when following a
1753 /// large, steadily growing log it is the cost paid per refresh; budget
1754 /// refresh frequency accordingly.
1755 pub fn refresh(&mut self) -> Result<(), Error> {
1756 let handle = self.handle.as_mut().ok_or(Error::SwmrUnsupported)?;
1757
1758 // A writer only appends (the file grows) and updates a few fixed-size,
1759 // individually checksummed structures in place (superblock EOF, object
1760 // header dimensions, array header counts). Re-reading the whole file and
1761 // re-validating the superblock checksum yields a consistent view; if the
1762 // superblock is caught mid-update, retry.
1763 const MAX_ATTEMPTS: u32 = 100;
1764 let mut last_err = None;
1765 for attempt in 0..MAX_ATTEMPTS {
1766 let mut data = Vec::new();
1767 handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
1768 handle.read_to_end(&mut data).map_err(Error::Io)?;
1769 match Self::parse_superblock(&data) {
1770 Ok((superblock, addr_offset)) => {
1771 self.backend = Backend::InMemory(data);
1772 self.superblock = superblock;
1773 self.addr_offset = addr_offset;
1774 self.file_space_info = self.read_file_space_info();
1775 self.sohm_table = self.read_sohm_table();
1776 // Every byte just moved. `File::refresh` takes `&mut self`
1777 // through `Arc::get_mut`, so no handle can be alive to see
1778 // it — but the counters are the file's statement about its
1779 // own bytes, and leaving them behind here would make that
1780 // statement false.
1781 *self.content_revision.get_mut() += 1;
1782 *self.address_revision.get_mut() += 1;
1783 return Ok(());
1784 }
1785 Err(e) => {
1786 last_err = Some(e);
1787 // Brief backoff before re-reading; the writer's in-place
1788 // updates are tiny, so a short pause clears the window. Skip
1789 // it on the final attempt, where there is no re-read to come.
1790 if attempt + 1 < MAX_ATTEMPTS {
1791 std::thread::sleep(std::time::Duration::from_micros(
1792 50 * (attempt + 1) as u64,
1793 ));
1794 }
1795 }
1796 }
1797 }
1798 // The loop always runs at least once and only reaches here via the
1799 // `Err` arm, so `last_err` is always `Some`; surface the real error.
1800 Err(last_err.expect("refresh retried at least once before failing"))
1801 }
1802
1803 /// Resolve a path to an object-header address, dispatching on the backend.
1804 fn resolve_path(&self, path: &ObjectPath<'_>) -> Result<u64, Error> {
1805 let access = self.access_mode();
1806 Ok(match &self.backend {
1807 Backend::InMemory(v) => group_v2::resolve_path_any(v, access, &self.superblock, path)?,
1808 Backend::Streaming(s) => {
1809 group_v2::resolve_path_any_from_source(s.as_ref(), access, &self.superblock, path)?
1810 }
1811 // A staged commit can relocate the object tree's root, so this
1812 // file's cached superblock may name a stale one; resolve against the
1813 // session's own superblock, which the commit updates.
1814 Backend::Edit(m) => {
1815 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1816 let sb = core.superblock().clone();
1817 match core.image_slice() {
1818 Some(data) => group_v2::resolve_path_any(data, access, &sb, path)?,
1819 None => {
1820 group_v2::resolve_path_any_from_source(core.image(), access, &sb, path)?
1821 }
1822 }
1823 }
1824 })
1825 }
1826
1827 /// The current root-group address (base-adjusted, absolute). For a read-write
1828 /// [`Backend::Edit`] file a prior relocating commit can have moved the
1829 /// root, so take the session's own superblock, which the commit updates;
1830 /// other backends use this file's cached one.
1831 fn mirror_root_address(&self) -> u64 {
1832 if let Backend::Edit(m) = &self.backend {
1833 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1834 return core.superblock().root_group_address;
1835 }
1836 self.superblock.root_group_address
1837 }
1838
1839 /// Returns the raw file bytes for an in-memory file, or an empty slice for a
1840 /// streaming file (which has no whole-file buffer).
1841 pub fn as_bytes(&self) -> &[u8] {
1842 match &self.backend {
1843 Backend::InMemory(v) => v,
1844 // A streaming, mirror, or bounded file has no borrowable whole-file
1845 // buffer.
1846 Backend::Streaming(_) | Backend::Edit(_) => &[],
1847 }
1848 }
1849
1850 /// Returns the access properties this file was opened with.
1851 pub const fn access_properties(&self) -> FileAccessProperties {
1852 self.access_properties
1853 }
1854
1855 /// The backend this file's editing session resolved to, or `None` when there
1856 /// is no editing session to ask. Always [`EditBacking::Mirrored`] for the
1857 /// SWMR writer, which builds a session but does not dispatch on the strategy.
1858 fn edit_backing(&self) -> Option<EditBacking> {
1859 match &self.backend {
1860 Backend::Edit(m) => Some(m.lock().unwrap_or_else(|e| e.into_inner()).edit_backing()),
1861 _ => None,
1862 }
1863 }
1864
1865 /// What this file's metadata cache has done, or `None` where the backend
1866 /// holds none.
1867 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
1868 self.with_source(|source| source.metadata_cache_stats())
1869 }
1870
1871 /// Zero those counters, keeping the cached entries.
1872 fn reset_metadata_cache_stats(&self) {
1873 self.with_source(|source| source.reset_metadata_cache_stats());
1874 }
1875
1876 /// Returns a reference to the parsed superblock.
1877 pub fn superblock(&self) -> &Superblock {
1878 &self.superblock
1879 }
1880
1881 /// The whole-file byte image when this file is buffered in memory
1882 /// ([`open`](Self::open) / [`from_bytes`](Self::from_bytes)); `None` for a
1883 /// streaming file ([`open_streaming`](Self::open_streaming)). Cross-file
1884 /// object copy ([`File::copy_from`](crate::File::copy_from)) uses this to read
1885 /// source objects by absolute address.
1886 pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
1887 match &self.backend {
1888 Backend::InMemory(data) => Some(data),
1889 Backend::Streaming(_) | Backend::Edit(_) => None,
1890 }
1891 }
1892
1893 /// The base address (`H5F` superblock base address), i.e. the byte offset
1894 /// added to every stored relative address. Zero for a file with no
1895 /// userblock.
1896 pub(crate) fn base_address(&self) -> BaseAddress {
1897 self.addr_offset
1898 }
1899
1900 /// Returns the mode this file reads under.
1901 pub(crate) fn access_mode(&self) -> AccessMode {
1902 self.access_mode
1903 }
1904
1905 /// The file-space management strategy this file records in its superblock
1906 /// extension (set with `H5Pset_file_space_strategy`), or `None` if the file
1907 /// records none — the default, which the C library also writes as "no
1908 /// message". See [`file_space_info`](Self::file_space_info) for the full
1909 /// record (persist flag, threshold, page size).
1910 pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
1911 self.file_space_info.as_ref().map(|info| info.strategy)
1912 }
1913
1914 /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
1915 /// if present and readable.
1916 pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
1917 self.file_space_info.as_ref()
1918 }
1919
1920 /// The free regions a file persists on disk in its free-space managers (when
1921 /// written with `H5Pset_file_space_strategy(..., persist = true)`), as
1922 /// `(address, length)` pairs sorted by address.
1923 ///
1924 /// Empty when the file does not persist free space, or for the streaming
1925 /// backend (which does not load the manager blocks). The addresses are file
1926 /// offsets (relative to the base address); reading data is unaffected by the
1927 /// presence or absence of these managers.
1928 pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
1929 let Some(info) = &self.file_space_info else {
1930 return Vec::new();
1931 };
1932 if !info.persist {
1933 return Vec::new();
1934 }
1935 let Backend::InMemory(data) = &self.backend else {
1936 return Vec::new();
1937 };
1938 let mut sections = free_space_manager::read_persisted_sections(
1939 data,
1940 &info.manager_addrs,
1941 self.addr_offset,
1942 self.superblock.offset_size,
1943 )
1944 .unwrap_or_default();
1945 // Distinct sections have distinct addresses in any well-formed file, so
1946 // the tie-break never arises; only a malformed manager can advertise one
1947 // address twice, and which of the pair is reported first is already
1948 // unspecified. No `debug_assert` here: this parses untrusted bytes, which
1949 // must not panic a debug build.
1950 sections.sort_unstable_by_key(|s| s.addr);
1951 sections
1952 .into_iter()
1953 .map(|s| (s.addr.get(), s.size))
1954 .collect()
1955 }
1956
1957 /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
1958 ///
1959 /// This is the total byte length of the backing store — for a streaming
1960 /// file the length reported by its source, for an in-memory file the length
1961 /// of its buffer. It includes any userblock prefix and trailing bytes, so it
1962 /// may exceed the superblock's logical end-of-file address; compare against
1963 /// `Superblock::eof_address` (reachable via
1964 /// [`File::superblock`]) to detect appended or unaccounted tail bytes.
1965 pub fn file_size(&self) -> u64 {
1966 match &self.backend {
1967 Backend::Edit(m) => {
1968 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1969 core.image().len()
1970 }
1971 _ => self.source().len(),
1972 }
1973 }
1974
1975 /// The minimum library version required to read this file, derived from its
1976 /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
1977 ///
1978 /// A version 3 superblock, for example, reports [`LibVer::V110`] because it
1979 /// was introduced in HDF5 1.10.
1980 pub fn libver_bound(&self) -> LibVer {
1981 LibVer::from_superblock_version(self.superblock.version)
1982 }
1983
1984 fn parse_header(&self, address: u64) -> Result<ObjectHeader, FormatError> {
1985 let os = self.superblock.offset_size;
1986 let ls = self.superblock.length_size;
1987 let access = self.access_mode();
1988 match &self.backend {
1989 Backend::InMemory(v) => ObjectHeader::parse_with_base(
1990 v,
1991 access,
1992 address.to_usize()?,
1993 os,
1994 ls,
1995 self.addr_offset,
1996 ),
1997 Backend::Streaming(s) => ObjectHeader::parse_from_source(
1998 s.as_ref(),
1999 access,
2000 address,
2001 os,
2002 ls,
2003 self.addr_offset,
2004 ),
2005 Backend::Edit(m) => Self::with_engine(
2006 m,
2007 |d| {
2008 ObjectHeader::parse_with_base(
2009 d,
2010 access,
2011 address.to_usize()?,
2012 os,
2013 ls,
2014 self.addr_offset,
2015 )
2016 },
2017 |s| ObjectHeader::parse_from_source(s, access, address, os, ls, self.addr_offset),
2018 ),
2019 }
2020 }
2021
2022 /// Resolve a base-relative object-header address (the value stored in an
2023 /// HDF5 `H5R_OBJECT` reference element) to the [`Object`] it points at.
2024 ///
2025 /// The stored address is relative to the superblock base address, so any
2026 /// MAT-file userblock is accounted for here. A null (`0`) or undefined
2027 /// (`HADDR_UNDEF`) address, or one whose object header is neither a dataset
2028 /// nor a group, yields [`FormatError::InvalidObjectReference`].
2029 fn object_at_relative(
2030 file: &Arc<FileInner>,
2031 revisions: Revisions,
2032 rel_addr: u64,
2033 ) -> Result<Object, Error> {
2034 // HADDR_UNDEF and the null address never name a real object. (Relative
2035 // address 0 is where the superblock sits, not an object header.)
2036 if rel_addr == u64::MAX || rel_addr == 0 {
2037 return Err(FormatError::InvalidObjectReference(rel_addr).into());
2038 }
2039 let abs = file.addr_offset.absolute(StoredAddress::new(rel_addr))?;
2040 let at = revisions.at(abs);
2041 let hdr = file.parse_header(abs)?;
2042 if has_message(&hdr, MessageType::DATA_LAYOUT) {
2043 let chunk_cache = DatasetAccessProperties::new()
2044 .resolved_chunk_cache(file.access_properties.chunk_cache);
2045 Ok(Object::Dataset(Box::new(Dataset::new(
2046 file.clone(),
2047 at,
2048 hdr,
2049 chunk_cache,
2050 None,
2051 ))))
2052 } else if is_group(&hdr) {
2053 Ok(Object::Group(Group::new(file.clone(), at, None)))
2054 } else {
2055 Err(FormatError::InvalidObjectReference(rel_addr).into())
2056 }
2057 }
2058
2059 fn offset_size(&self) -> u8 {
2060 self.superblock.offset_size
2061 }
2062
2063 fn length_size(&self) -> u8 {
2064 self.superblock.length_size
2065 }
2066
2067 /// Returns the entries of the group `hdr` describes, dispatching on the backend.
2068 ///
2069 /// Each entry holds the address the file stores for its child, which
2070 /// [`entry_address`](Self::entry_address) turns into a position to read at.
2071 ///
2072 /// # Errors
2073 ///
2074 /// Returns [`Error::Format`] if the group's own structures do not parse, or if `hdr` describes
2075 /// no group at all.
2076 fn group_children(&self, hdr: &ObjectHeader) -> Result<Vec<GroupEntry>, Error> {
2077 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2078 match &self.backend {
2079 Backend::InMemory(v) => group_v2::resolve_group_entries(v, hdr, os, ls, base),
2080 Backend::Streaming(s) => {
2081 group_v2::resolve_group_entries_from_source(s.as_ref(), hdr, os, ls, base)
2082 }
2083 Backend::Edit(m) => Self::with_engine(
2084 m,
2085 |d| group_v2::resolve_group_entries(d, hdr, os, ls, base),
2086 |s| group_v2::resolve_group_entries_from_source(s, hdr, os, ls, base),
2087 ),
2088 }
2089 .map_err(Error::Format)
2090 }
2091
2092 /// Returns the absolute file address of the object header `entry` names.
2093 ///
2094 /// # Errors
2095 ///
2096 /// Returns [`FormatError::OffsetOverflow`] if the base address cannot be added to it, which a
2097 /// crafted entry naming `HADDR_UNDEF` in a file with a userblock arranges.
2098 fn entry_address(&self, entry: &GroupEntry) -> Result<u64, FormatError> {
2099 self.addr_offset.absolute(entry.object_header_address)
2100 }
2101
2102 /// The child named `name`, at the absolute file address
2103 /// [`ChildLookup::Found`] carries.
2104 ///
2105 /// The by-name counterpart of [`group_children`](Self::group_children), and
2106 /// the one to reach for when a single child is wanted: it stops at the match
2107 /// rather than building an entry, and an owned name, for every other child
2108 /// of the group (issue #228).
2109 fn group_child(&self, group_address: u64, name: LinkName<'_>) -> Result<ChildLookup, Error> {
2110 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2111 let access = self.access_mode();
2112 let addr = group_address;
2113 match &self.backend {
2114 Backend::InMemory(v) => {
2115 group_v2::find_child_address(v, access, addr, os, ls, base, name)
2116 }
2117 Backend::Streaming(s) => group_v2::find_child_address_from_source(
2118 s.as_ref(),
2119 access,
2120 addr,
2121 os,
2122 ls,
2123 base,
2124 name,
2125 ),
2126 Backend::Edit(m) => Self::with_engine(
2127 m,
2128 |d| group_v2::find_child_address(d, access, addr, os, ls, base, name),
2129 |s| group_v2::find_child_address_from_source(s, access, addr, os, ls, base, name),
2130 ),
2131 }
2132 .map_err(Error::Format)
2133 }
2134
2135 /// Read all attributes attached to an object header, dispatching on the
2136 /// backend.
2137 fn attrs_of(&self, hdr: &ObjectHeader) -> Result<HashMap<String, AttrValue>, Error> {
2138 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2139 let attr_msgs = self.attr_messages_of(hdr)?;
2140 match &self.backend {
2141 Backend::Edit(m) => Ok(Self::with_engine(
2142 m,
2143 |d| attrs_to_map(&attr_msgs, &BytesSource::new(d), os, ls, base),
2144 |s| attrs_to_map(&attr_msgs, s, os, ls, base),
2145 )),
2146 _ => Ok(attrs_to_map(&attr_msgs, &self.source(), os, ls, base)),
2147 }
2148 }
2149
2150 /// The content of a header message, following the reference when the record
2151 /// marks the message *shared*.
2152 ///
2153 /// A shared record's body is not the message: it is an address, and a
2154 /// committed (`H5Tcommit`) datatype is stored exactly that way. Decoding the
2155 /// body directly turns a named `H5T_STD_I32LE` into a zero-width time type
2156 /// with no error anywhere, so every read of a message that HDF5 permits to be
2157 /// shared — datatype, dataspace, fill value, filter pipeline — goes through
2158 /// here. The borrowed case allocates nothing, which is every message this
2159 /// crate writes and nearly every one it reads.
2160 fn message_body<'m>(
2161 &self,
2162 msg: &'m crate::object_header::HeaderMessage,
2163 ) -> Result<Cow<'m, [u8]>, Error> {
2164 if !msg.flags.is_shared() {
2165 return Ok(Cow::Borrowed(&msg.data));
2166 }
2167 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2168 let access = self.access_mode();
2169 let sohm = self.sohm_table.as_deref();
2170 // A shared reference stores its address relative to the base address, so
2171 // frame the file at `base` exactly as [`Self::attr_messages_of`] does.
2172 let resolved = match &self.backend {
2173 Backend::InMemory(v) => BufferedResolver::new(frame(v, base)?, access, os, ls, sohm)
2174 .resolve(&msg.data, msg.msg_type),
2175 Backend::Streaming(s) if base.is_zero() => {
2176 SourceResolver::new(s.as_ref(), access, os, ls, sohm)
2177 .resolve(&msg.data, msg.msg_type)
2178 }
2179 Backend::Streaming(s) => SourceResolver::new(
2180 &BaseOffsetSource {
2181 inner: s.as_ref(),
2182 base,
2183 },
2184 access,
2185 os,
2186 ls,
2187 sohm,
2188 )
2189 .resolve(&msg.data, msg.msg_type),
2190 Backend::Edit(m) => Self::with_engine(
2191 m,
2192 |d| {
2193 BufferedResolver::new(frame(d, base)?, access, os, ls, sohm)
2194 .resolve(&msg.data, msg.msg_type)
2195 },
2196 |s| {
2197 if base.is_zero() {
2198 SourceResolver::new(s, access, os, ls, sohm)
2199 .resolve(&msg.data, msg.msg_type)
2200 } else {
2201 SourceResolver::new(
2202 &BaseOffsetSource { inner: s, base },
2203 access,
2204 os,
2205 ls,
2206 sohm,
2207 )
2208 .resolve(&msg.data, msg.msg_type)
2209 }
2210 },
2211 ),
2212 }?;
2213 Ok(Cow::Owned(resolved))
2214 }
2215
2216 /// The object-header address a *shared* header message names, or `None` when
2217 /// the record carries its own content or names the shared-message heap.
2218 ///
2219 /// [`Self::message_body`] answers what the message says; this answers which
2220 /// object says it. A rewrite needs both: the content to reproduce the type,
2221 /// and the address to tell which users share one committed object rather than
2222 /// each naming a type of their own. A heap-stored message has no such object
2223 /// — it is one anonymous copy rather than a named one — so it answers `None`
2224 /// and a rewrite spells the message out, which is what the file already
2225 /// means.
2226 pub(crate) fn shared_target_address(
2227 &self,
2228 msg: &crate::object_header::HeaderMessage,
2229 ) -> Result<Option<u64>, Error> {
2230 if !msg.flags.is_shared() {
2231 return Ok(None);
2232 }
2233 let reference =
2234 shared_message::parse_shared_ref(&msg.data, self.offset_size(), self.length_size())?;
2235 match reference.location {
2236 shared_message::SharedLocation::ObjectHeader(addr) => Ok(Some(addr.get())),
2237 shared_message::SharedLocation::SohmHeap(_) => Ok(None),
2238 }
2239 }
2240
2241 /// Extract every attribute message attached to an object header (compact,
2242 /// shared, and dense storage), dispatching on the backend.
2243 pub(crate) fn attr_messages_of(
2244 &self,
2245 hdr: &ObjectHeader,
2246 ) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
2247 let (os, ls) = (self.offset_size(), self.length_size());
2248 // Compact attributes come out of `hdr`, but the two addresses this walk
2249 // follows are read from message bodies and so are stored relative to the
2250 // base address: the Attribute Info message's fractal-heap address, and a
2251 // shared attribute's message address. Frame the file at `base` exactly as
2252 // [`Self::read_dataset_raw`] does, so both index it directly. For a plain
2253 // file (`base == 0`) this is the identity; without it, a userblock file's
2254 // dense attributes are looked for one userblock too early.
2255 let base = self.addr_offset;
2256 let access = self.access_mode();
2257 let sohm = self.sohm_table.as_deref();
2258 match &self.backend {
2259 Backend::InMemory(v) => Ok(extract_attributes_full(
2260 frame(v, base)?,
2261 access,
2262 hdr,
2263 os,
2264 ls,
2265 sohm,
2266 )?),
2267 Backend::Streaming(s) if base.is_zero() => Ok(extract_attributes_full_from_source(
2268 s.as_ref(),
2269 access,
2270 hdr,
2271 os,
2272 ls,
2273 sohm,
2274 )?),
2275 Backend::Streaming(s) => {
2276 let framed = BaseOffsetSource {
2277 inner: s.as_ref(),
2278 base,
2279 };
2280 Ok(extract_attributes_full_from_source(
2281 &framed, access, hdr, os, ls, sohm,
2282 )?)
2283 }
2284 Backend::Edit(m) => Self::with_engine(
2285 m,
2286 |d| {
2287 Ok(extract_attributes_full(
2288 frame(d, base)?,
2289 access,
2290 hdr,
2291 os,
2292 ls,
2293 sohm,
2294 )?)
2295 },
2296 |s| {
2297 if base.is_zero() {
2298 Ok(extract_attributes_full_from_source(
2299 s, access, hdr, os, ls, sohm,
2300 )?)
2301 } else {
2302 let framed = BaseOffsetSource { inner: s, base };
2303 Ok(extract_attributes_full_from_source(
2304 &framed, access, hdr, os, ls, sohm,
2305 )?)
2306 }
2307 },
2308 ),
2309 }
2310 }
2311
2312 /// Read a dataset's raw bytes for the given layout, dispatching on the backend.
2313 fn read_dataset_raw(
2314 &self,
2315 spec: RawReadSpec<'_>,
2316 cache: &ChunkCache,
2317 ) -> Result<Vec<u8>, FormatError> {
2318 let (os, ls) = (self.offset_size(), self.length_size());
2319 // Every on-disk address in `dl` — the contiguous data address, the chunk
2320 // index root, and (followed deeper in the chunked reader) every B-tree /
2321 // fixed-array / extensible-array node and chunk-data address — is stored
2322 // relative to the base address. Present the payload reader a base-relative
2323 // view of the file so all of them index it directly: slice the in-memory
2324 // buffer at `base`, or wrap the streaming source to add `base` to each
2325 // read. For a plain file (`base == 0`) this is the identity.
2326 let base = self.addr_offset;
2327 match &self.backend {
2328 Backend::InMemory(v) => {
2329 data_read::read_raw_data_cached(frame(v, base)?, spec, os, ls, cache)
2330 }
2331 Backend::Streaming(s) if base.is_zero() => {
2332 data_read::read_raw_data_cached_from_source(s.as_ref(), spec, os, ls, cache)
2333 }
2334 Backend::Streaming(s) => {
2335 let framed = BaseOffsetSource {
2336 inner: s.as_ref(),
2337 base,
2338 };
2339 data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2340 }
2341 Backend::Edit(m) => Self::with_engine(
2342 m,
2343 |data| {
2344 let framed = frame(data, base)?;
2345 data_read::read_raw_data_cached(framed, spec, os, ls, cache)
2346 },
2347 |s| {
2348 let framed = BaseOffsetSource { inner: s, base };
2349 data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2350 },
2351 ),
2352 }
2353 }
2354
2355 /// Windowed counterpart of [`read_dataset_raw`](Self::read_dataset_raw): read
2356 /// the raw element bytes of the row window `[start_row, start_row + num_rows)`,
2357 /// touching only the storage it overlaps. Reads through the same base-framed
2358 /// `Source`, so on-disk addresses resolve the same way. The caller clamps
2359 /// the window to the dataset.
2360 fn read_dataset_raw_rows(
2361 &self,
2362 spec: RawReadSpec<'_>,
2363 cache: &ChunkCache,
2364 pass: CachePass,
2365 start_row: u64,
2366 num_rows: u64,
2367 ) -> Result<Vec<u8>, FormatError> {
2368 let (os, ls) = (self.offset_size(), self.length_size());
2369 let (dl, ds, dt) = (spec.layout, spec.dataspace, spec.datatype);
2370 let elem_size = dt.element_size_usize()?;
2371 // Elements per row (product of inner dims; 1 when 0-D or 1-D). Checked so
2372 // a crafted dataspace whose inner dims overflow `usize` errors instead of
2373 // panicking (debug) or wrapping (release).
2374 let row_elems: usize = ds.dimensions.iter().skip(1).try_fold(1usize, |acc, &d| {
2375 acc.checked_mul(d.to_usize()?)
2376 .ok_or(FormatError::OffsetOverflow {
2377 offset: acc as u64,
2378 length: d,
2379 })
2380 })?;
2381 let row_bytes =
2382 row_elems
2383 .checked_mul(elem_size.get())
2384 .ok_or(FormatError::OffsetOverflow {
2385 offset: row_elems as u64,
2386 length: elem_size.get() as u64,
2387 })?;
2388
2389 // Compact data is inline in the layout message — no I/O, no framing.
2390 if let DataLayout::Compact { data } = dl {
2391 return compact_rows(data, start_row, num_rows, row_bytes);
2392 }
2393
2394 let base = self.addr_offset;
2395 match &self.backend {
2396 Backend::InMemory(v) => {
2397 let framed = frame(v, base)?;
2398 read_rows_framed(
2399 &BytesSource::new(framed),
2400 spec,
2401 os,
2402 ls,
2403 cache,
2404 pass,
2405 start_row,
2406 num_rows,
2407 row_bytes,
2408 )
2409 }
2410 Backend::Streaming(s) if base.is_zero() => read_rows_framed(
2411 s.as_ref(),
2412 spec,
2413 os,
2414 ls,
2415 cache,
2416 pass,
2417 start_row,
2418 num_rows,
2419 row_bytes,
2420 ),
2421 Backend::Streaming(s) => {
2422 let framed = BaseOffsetSource {
2423 inner: s.as_ref(),
2424 base,
2425 };
2426 read_rows_framed(
2427 &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2428 )
2429 }
2430 Backend::Edit(m) => Self::with_engine(
2431 m,
2432 |data| {
2433 let framed = frame(data, base)?;
2434 read_rows_framed(
2435 &BytesSource::new(framed),
2436 spec,
2437 os,
2438 ls,
2439 cache,
2440 pass,
2441 start_row,
2442 num_rows,
2443 row_bytes,
2444 )
2445 },
2446 |s| {
2447 let framed = BaseOffsetSource { inner: s, base };
2448 read_rows_framed(
2449 &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2450 )
2451 },
2452 ),
2453 }
2454 }
2455}
2456
2457/// Reads a row window through an already base-framed [`Source`].
2458///
2459/// A compact layout reads from the bytes the layout message holds, a contiguous
2460/// one from a single bounded sub-read or, where the file allocated no storage,
2461/// from the fill value, and a chunked one from the windowed chunk reader. Only
2462/// the rank-0 chunked corner a crafted file reaches falls back to a whole read
2463/// plus a slice.
2464///
2465/// # Errors
2466///
2467/// Returns [`FormatError::UnsupportedVirtualLayout`] for a virtual layout, and
2468/// the error the layout's own reader reports for a window the dataset's storage
2469/// does not hold.
2470fn read_rows_framed<S: Source + ?Sized>(
2471 source: &S,
2472 spec: RawReadSpec<'_>,
2473 os: u8,
2474 ls: u8,
2475 cache: &ChunkCache,
2476 pass: CachePass,
2477 start_row: u64,
2478 num_rows: u64,
2479 row_bytes: usize,
2480) -> Result<Vec<u8>, FormatError> {
2481 let (dl, fill) = (spec.layout, spec.fill);
2482 // A zero-row window reads nothing, uniformly across the *supported* layouts.
2483 // A `Virtual` layout is unsupported and must still error like `read_raw`
2484 // does, so it is excluded here and falls through to the match.
2485 if num_rows == 0 && !matches!(dl, DataLayout::Virtual) {
2486 return Ok(Vec::new());
2487 }
2488 match dl {
2489 DataLayout::Compact { data } => compact_rows(data, start_row, num_rows, row_bytes),
2490 DataLayout::Contiguous { address, size } => {
2491 // Unallocated storage: the window reads as the fill value, the same
2492 // answer the whole-dataset readers give for it.
2493 let Some(addr) = *address else {
2494 let len = num_rows.to_usize()?.saturating_mul(row_bytes);
2495 return fill.buffer(len);
2496 };
2497 let start =
2498 start_row
2499 .checked_mul(row_bytes as u64)
2500 .ok_or(FormatError::OffsetOverflow {
2501 offset: start_row,
2502 length: row_bytes as u64,
2503 })?;
2504 let len =
2505 num_rows
2506 .to_usize()?
2507 .checked_mul(row_bytes)
2508 .ok_or(FormatError::OffsetOverflow {
2509 offset: num_rows,
2510 length: row_bytes as u64,
2511 })?;
2512 // Never read past the dataset's own contiguous storage.
2513 if start.saturating_add(len as u64) > *size {
2514 return Err(FormatError::DataSizeMismatch {
2515 expected: start.to_usize()?.saturating_add(len),
2516 actual: (*size).to_usize()?,
2517 });
2518 }
2519 let off = addr
2520 .get()
2521 .checked_add(start)
2522 .ok_or(FormatError::OffsetOverflow {
2523 offset: addr.get(),
2524 length: start,
2525 })?;
2526 source.read_exact_at(off, len)
2527 }
2528 DataLayout::Chunked { .. } => {
2529 match crate::chunked_read::read_chunked_rows_from_source(
2530 source, spec, os, ls, cache, pass, start_row, num_rows,
2531 )? {
2532 Some(bytes) => Ok(bytes),
2533 // Rank-0 chunked (a crafted-file corner): fall back to a whole
2534 // read, then slice.
2535 None => {
2536 let full =
2537 data_read::read_raw_data_cached_from_source(source, spec, os, ls, cache)?;
2538 let start = start_row.to_usize()? * row_bytes;
2539 let len = num_rows.to_usize()? * row_bytes;
2540 full.get(start..start + len).map(<[u8]>::to_vec).ok_or(
2541 FormatError::DataSizeMismatch {
2542 expected: start + len,
2543 actual: full.len(),
2544 },
2545 )
2546 }
2547 }
2548 }
2549 DataLayout::Virtual => Err(FormatError::UnsupportedVirtualLayout),
2550 }
2551}
2552
2553/// Reads the rows `[start_row, start_row + num_rows)` of a compact dataset from
2554/// the element bytes its layout message holds.
2555///
2556/// Compact data is inline in the object header, so the window is a slice of
2557/// `data` and the file is never read.
2558///
2559/// # Errors
2560///
2561/// Returns [`FormatError::ValueTooLargeForPlatform`] if a row index exceeds
2562/// `usize`, [`FormatError::OffsetOverflow`] if the window's byte range overflows
2563/// it, and [`FormatError::DataSizeMismatch`] if the window ends past the bytes
2564/// the message holds.
2565fn compact_rows(
2566 data: &[u8],
2567 start_row: u64,
2568 num_rows: u64,
2569 row_bytes: usize,
2570) -> Result<Vec<u8>, FormatError> {
2571 let start = start_row.to_usize()?.checked_mul(row_bytes);
2572 let len = num_rows.to_usize()?.checked_mul(row_bytes);
2573 let (Some(start), Some(len)) = (start, len) else {
2574 return Err(FormatError::OffsetOverflow {
2575 offset: start_row,
2576 length: row_bytes as u64,
2577 });
2578 };
2579 let end = start.checked_add(len).ok_or(FormatError::OffsetOverflow {
2580 offset: start as u64,
2581 length: len as u64,
2582 })?;
2583 data.get(start..end)
2584 .map(<[u8]>::to_vec)
2585 .ok_or(FormatError::DataSizeMismatch {
2586 expected: end,
2587 actual: data.len(),
2588 })
2589}
2590
2591impl std::fmt::Debug for FileInner {
2592 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2593 f.debug_struct("File")
2594 .field("size", &self.file_size())
2595 .field("superblock_version", &self.superblock.version)
2596 .finish()
2597 }
2598}
2599
2600/// An open HDF5 file.
2601///
2602/// A `File` is an owned, cheaply cloneable handle to an open file: cloning it (or
2603/// deriving a [`Dataset`]/[`Group`] from it) shares one underlying open file
2604/// rather than re-reading it. Object handles returned by [`dataset`](Self::dataset),
2605/// [`group`](Self::group), and [`root`](Self::root) are **owned** — they keep the
2606/// file open for as long as they live and carry no borrow of the `File`, so they
2607/// can be stored in a struct, cached, cloned, and moved across threads. They stay
2608/// usable across a [`commit`](Self::commit), which is what makes caching one
2609/// worthwhile; see [`commit`](Self::commit) for the two cases that report
2610/// instead.
2611#[derive(Clone)]
2612pub struct File {
2613 inner: Arc<FileInner>,
2614}
2615
2616impl std::fmt::Debug for File {
2617 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2618 std::fmt::Debug::fmt(&*self.inner, f)
2619 }
2620}
2621
2622impl File {
2623 /// Opens the HDF5 file at `path` and reads it into memory.
2624 ///
2625 /// [`File::open_streaming`] reads a file larger than memory without buffering it, and
2626 /// [`File::open_swmr`] follows a file that a concurrent writer is appending to.
2627 ///
2628 /// # Errors
2629 ///
2630 /// Returns [`Error::FileMarkedInUse`] if the status flags mark
2631 /// the file as open for writing. `H5Fopen` makes the same check. The flag marks a live writer
2632 /// or one that exited without closing the file.
2633 /// [`clear_swmr_flag`](Self::clear_swmr_flag) clears a stale flag,
2634 /// [`open_swmr`](Self::open_swmr) follows a live SWMR writer, and
2635 /// [`from_bytes`](Self::from_bytes) reads the bytes its caller already holds without the check,
2636 /// which is the way to read a flagged file on a read-only mount.
2637 pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2638 Ok(File {
2639 inner: Arc::new(FileInner::open(path)?),
2640 })
2641 }
2642
2643 /// Open an HDF5 file from a filesystem path with explicit access properties.
2644 pub fn open_with_options<P: AsRef<std::path::Path>>(
2645 path: P,
2646 properties: FileAccessProperties,
2647 ) -> Result<Self, Error> {
2648 Ok(File {
2649 inner: Arc::new(FileInner::open_with_options(path, properties)?),
2650 })
2651 }
2652
2653 /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
2654 /// the file instead of buffering it whole.
2655 ///
2656 /// This lets a host read a file larger than its address space. Metadata and
2657 /// dataset chunks are read through a `ReadSeekSource`, so peak memory stays
2658 /// close to one chunk plus the metadata being parsed; chunks adjacent on
2659 /// disk are fetched together, in reads of at most 256 KiB, and a chunk
2660 /// larger than that is read on its own. Attribute reading and v1
2661 /// symbol-table groups on the resolved path are not yet supported on this
2662 /// backend.
2663 ///
2664 /// Like [`open`](Self::open), this refuses a file whose superblock marks it
2665 /// as held by a writer.
2666 pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2667 Ok(File {
2668 inner: Arc::new(FileInner::open_streaming(path)?),
2669 })
2670 }
2671
2672 /// Open an HDF5 file for streaming reads with explicit access properties.
2673 pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
2674 path: P,
2675 properties: FileAccessProperties,
2676 ) -> Result<Self, Error> {
2677 Ok(File {
2678 inner: Arc::new(FileInner::open_streaming_with_options(path, properties)?),
2679 })
2680 }
2681
2682 /// Opens the file at `path` for the streaming reads of a repack, whose object
2683 /// header parses are made under the `access_mode` the caller's
2684 /// [`RepackOptions`](crate::RepackOptions) gives.
2685 pub(crate) fn open_streaming_with_access_mode(
2686 path: &std::path::Path,
2687 access_mode: AccessMode,
2688 ) -> Result<Self, Error> {
2689 Ok(File {
2690 inner: Arc::new(FileInner::open_streaming_with_access_mode(
2691 path,
2692 access_mode,
2693 )?),
2694 })
2695 }
2696
2697 /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
2698 /// demand exactly as [`open_streaming`](Self::open_streaming) does.
2699 ///
2700 /// This is the streaming open for a caller whose bytes are not a path: an
2701 /// object store addressed by HTTP range request, a sandboxed guest that
2702 /// receives byte ranges from its host, a decrypting layer. A [`Source`]
2703 /// supplies a length and reads at an absolute offset, which is all the
2704 /// reader asks of a file, so peak memory stays at the metadata being parsed
2705 /// plus the chunks a read touches — not the file. Wrap a `Read + Seek` in
2706 /// [`ReadSeekSource`] rather than writing that impl again.
2707 ///
2708 /// A file marked as held by a writer is refused here as it is by
2709 /// [`open_streaming`](Self::open_streaming); with no path to report, the
2710 /// error names the source instead. Recovering such a file in place needs a
2711 /// path, through [`File::clear_swmr_flag`], so a caller that has none is
2712 /// left with [`File::from_bytes`](Self::from_bytes) and the whole file in
2713 /// memory.
2714 ///
2715 /// The metadata cache is **off** unless
2716 /// [`from_source_with_options`](Self::from_source_with_options) turns it
2717 /// on, which matters more here than it does for a local file: without one,
2718 /// every read a parser makes is a round trip. See
2719 /// [`MetadataCacheConfig`].
2720 ///
2721 /// ```no_run
2722 /// use hdf5_pure::{File, FormatError, Source};
2723 ///
2724 /// // However the bytes actually arrive: a range request, a host call, a
2725 /// // decrypting layer over a file.
2726 /// # fn fetch(offset: u64, len: usize) -> Result<Vec<u8>, String> { unimplemented!() }
2727 ///
2728 /// struct Remote {
2729 /// len: u64,
2730 /// }
2731 ///
2732 /// impl Source for Remote {
2733 /// fn len(&self) -> u64 {
2734 /// self.len
2735 /// }
2736 ///
2737 /// fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
2738 /// // Fill the whole request or fail: a short read is an error.
2739 /// let bytes = fetch(offset, buf.len()).map_err(FormatError::Source)?;
2740 /// buf.copy_from_slice(&bytes);
2741 /// Ok(())
2742 /// }
2743 /// }
2744 ///
2745 /// let file = File::from_source(Remote { len: 1 << 30 })?;
2746 /// let rows = file.dataset("frames")?.read_f64_rows(0, 64)?;
2747 /// # Ok::<(), hdf5_pure::Error>(())
2748 /// ```
2749 ///
2750 /// A `Read + Seek` needs none of that: wrap it in [`ReadSeekSource`].
2751 /// Reading a *file* that way is [`open_streaming`](Self::open_streaming),
2752 /// which does the wrapping for you.
2753 pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
2754 Ok(File {
2755 inner: Arc::new(FileInner::from_source(source)?),
2756 })
2757 }
2758
2759 /// Open an HDF5 file from any [`Source`] with explicit access properties.
2760 ///
2761 /// The metadata cache is what a remote source wants tuned: every parser
2762 /// read becomes a round trip without one. See [`MetadataCacheConfig`].
2763 pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
2764 source: S,
2765 properties: FileAccessProperties,
2766 ) -> Result<Self, Error> {
2767 Ok(File {
2768 inner: Arc::new(FileInner::from_source_with_options(source, properties)?),
2769 })
2770 }
2771
2772 /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
2773 ///
2774 /// Like [`File::open`], but retains a live handle to the file so that
2775 /// [`File::refresh`] can re-read data appended by a concurrent writer.
2776 ///
2777 /// This is the open that *follows* a file marked as held by a SWMR writer,
2778 /// where [`open`](Self::open) refuses one. Only a half-set mark is refused
2779 /// here, with [`Error::FileMarkedInUse`]:
2780 /// either bit without the other. Write access alone is what a plain
2781 /// (non-SWMR) writer leaves, and there is no protocol for following a writer
2782 /// that is not publishing consistent prefixes; the SWMR bit alone is a state
2783 /// no writer produces. Both bits is the live SWMR writer this exists to
2784 /// follow, and neither is a quiescent file.
2785 #[doc(alias = "H5F_ACC_SWMR_READ")]
2786 pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2787 Ok(File {
2788 inner: Arc::new(FileInner::open_swmr(path)?),
2789 })
2790 }
2791
2792 /// Open an HDF5 file for SWMR reading with explicit access properties.
2793 pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
2794 path: P,
2795 properties: FileAccessProperties,
2796 ) -> Result<Self, Error> {
2797 Ok(File {
2798 inner: Arc::new(FileInner::open_swmr_with_options(path, properties)?),
2799 })
2800 }
2801
2802 /// Open an HDF5 file from an in-memory byte vector.
2803 pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
2804 Ok(File {
2805 inner: Arc::new(FileInner::from_bytes(data)?),
2806 })
2807 }
2808
2809 /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
2810 pub fn from_bytes_with_options(
2811 data: Vec<u8>,
2812 properties: FileAccessProperties,
2813 ) -> Result<Self, Error> {
2814 Ok(File {
2815 inner: Arc::new(FileInner::from_bytes_with_options(data, properties)?),
2816 })
2817 }
2818
2819 /// Open an existing HDF5 file for reading **and** in-place editing.
2820 ///
2821 /// Unlike [`open`](Self::open) (read-only, buffered), this takes an exclusive
2822 /// OS file lock held for the file's life and lets owned handles modify the
2823 /// file — immediate [`Dataset::append`]s, plus [`Dataset::write`]/`set_attr`,
2824 /// [`Group::create_dataset`]/`create_group`/`delete`/`set_attr`, and
2825 /// [`copy`](Self::copy)/[`copy_from`](Self::copy_from) staged until
2826 /// [`commit`](Self::commit). The file must use 8-byte offsets and lengths and
2827 /// keep its superblock at its base address (a canonical userblock, as in a
2828 /// MATLAB `.mat` file, is supported); anything else is refused with
2829 /// [`Error::EditUnsupported`].
2830 ///
2831 /// The fast immediate [`Dataset::append`] additionally requires a
2832 /// latest-format (version-2/3) file with no userblock and an
2833 /// Extensible-Array-indexed dataset; [`Dataset::append_staged`] covers the
2834 /// general case.
2835 ///
2836 /// Two things can turn this open away because another writer holds the file:
2837 /// the exclusive OS lock, reported as
2838 /// [`Error::FileLocked`], and the superblock's
2839 /// status-flags byte, reported as
2840 /// [`Error::FileMarkedInUse`]. The second
2841 /// covers what the first cannot — a SWMR writer takes no lock, and a writer
2842 /// that exited without closing the file leaves the flag behind; recover a
2843 /// stale one with [`clear_swmr_flag`](Self::clear_swmr_flag).
2844 ///
2845 /// # Memory
2846 ///
2847 /// This picks its backing from the file rather than making the caller pick a
2848 /// function (issue #198): a latest-format file with no userblock is edited
2849 /// **bounded**, holding only the metadata being parsed plus the configured
2850 /// caches plus what an edit is building, so resident memory does not scale
2851 /// with the file; anything else falls back to a whole-file in-memory mirror,
2852 /// which is what makes a pre-v2 or userblock file editable at all. The two
2853 /// backings are the same engine over different storage and offer the same
2854 /// edit surface, differing in one trade: the bounded one applies a large
2855 /// immediate append in whole-chunk batches, each crash-atomic on its own, so
2856 /// a crash mid-call leaves a valid shorter dataset rather than none of the
2857 /// append. Ask a file which it got with
2858 /// [`edit_backing`](Self::edit_backing), and demand one with
2859 /// [`FileAccessProperties::with_memory_strategy`] —
2860 /// [`MemoryStrategy::Mirrored`] restores the unconditional mirror this
2861 /// entry point used before it learned to dispatch.
2862 #[doc(alias = "H5Fopen")]
2863 pub fn open_rw<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2864 Self::open_rw_with_options(path, FileAccessProperties::new())
2865 }
2866
2867 /// Open an existing file for reading and in-place editing with explicit
2868 /// access properties — see [`open_rw`](Self::open_rw).
2869 ///
2870 /// The properties carry the locking policy (the `H5Pset_file_locking` analogue,
2871 /// [`FileAccessProperties::with_locking`]), the memory strategy
2872 /// ([`FileAccessProperties::with_memory_strategy`], which overrides the
2873 /// dispatch described on [`open_rw`](Self::open_rw)), the `fsync` cadence
2874 /// ([`FileAccessProperties::with_sync_policy`]), the metadata cache used by
2875 /// the bounded backing, and the file-wide chunk-cache default applied to
2876 /// datasets opened from this file. Because one [`FileAccessProperties`] value
2877 /// serves every open, the same configuration can be shared with a read path.
2878 pub fn open_rw_with_options<P: AsRef<std::path::Path>>(
2879 path: P,
2880 properties: FileAccessProperties,
2881 ) -> Result<Self, Error> {
2882 Ok(File {
2883 inner: Arc::new(FileInner::open_rw(path, properties)?),
2884 })
2885 }
2886
2887 /// Open exactly as [`open_rw`](Self::open_rw) does, but behind an image that
2888 /// withholds its whole-file slice, so every read takes the `Source` path
2889 /// rather than the slice fast path.
2890 ///
2891 /// Each read this file serves has two forms (see `with_engine`), and only
2892 /// the slice form runs in production until a mirrorless backing lands
2893 /// (issue #198). Opening the same file both ways and comparing is what
2894 /// holds the other form to the same answers in the meantime.
2895 #[cfg(test)]
2896 pub(crate) fn open_rw_source_only(path: &std::path::Path) -> Result<Self, Error> {
2897 Ok(File {
2898 inner: Arc::new(FileInner::from_rw_session(
2899 WriteEngine::open_source_only(path)?,
2900 FileAccessProperties::new(),
2901 )?),
2902 })
2903 }
2904
2905 /// Open an existing file for **SWMR** (single-writer/multiple-reader)
2906 /// appending: take **no** OS lock (so concurrent readers, and Windows'
2907 /// mandatory locks, are never blocked) and raise the superblock's SWMR-write
2908 /// flag so a reader may attach with [`File::open_swmr`], the C library's
2909 /// `H5F_ACC_SWMR_READ`, or h5py `swmr=True`.
2910 ///
2911 /// Only immediate [`Dataset::append`] is permitted, and only over the SWMR
2912 /// subset — an **unfiltered**, chunk-aligned append, so a concurrent reader
2913 /// only ever observes a consistent prefix; a filtered or non-chunk-aligned
2914 /// append returns [`Error::SwmrAppendUnsupported`].
2915 /// The staged edit surface (`write`/`set_attr`/`create_*`/`delete`/`copy`/
2916 /// `commit`) returns
2917 /// [`Error::SwmrStagedUnsupported`].
2918 /// [`close`](Self::close) clears the SWMR-write flag; a writer that exits
2919 /// without a clean close leaves it set — recover with
2920 /// [`clear_swmr_flag`](Self::clear_swmr_flag). While the flag stands, this
2921 /// open is refused with
2922 /// [`Error::FileMarkedInUse`], which is what keeps a second writer off a
2923 /// file SWMR gives only one (no OS lock is held to do it).
2924 ///
2925 /// Requires a latest-format (version-3 superblock) file with no userblock
2926 /// and no persisted free-space; other files are refused with
2927 /// [`Error::SwmrAppendUnsupported`]. The version-3 requirement is the C
2928 /// library's: neither library reads the SWMR-write flag back on an older
2929 /// superblock, so raising one there would announce the writer to nobody.
2930 #[doc(alias = "H5F_ACC_SWMR_WRITE")]
2931 pub fn open_swmr_writer<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2932 Self::open_swmr_writer_with_options(path, FileAccessProperties::new())
2933 }
2934
2935 /// Open for SWMR appending with explicit access properties — see
2936 /// [`open_swmr_writer`](Self::open_swmr_writer).
2937 ///
2938 /// The properties' chunk cache is the file-wide default for datasets opened
2939 /// from this file. Its locking policy is ignored, which costs the caller
2940 /// nothing: SWMR takes no OS lock by design, which is stronger than any
2941 /// locking a caller could ask for. Its memory strategy is *not* ignored the
2942 /// same way — this writer always mirrors, so an explicit
2943 /// [`MemoryStrategy::Bounded`] is a guarantee it cannot meet and is refused
2944 /// with [`Error::EditUnsupported`]; [`MemoryStrategy::Auto`] and
2945 /// [`MemoryStrategy::Mirrored`] are both satisfied by the mirror.
2946 ///
2947 /// Its [`SyncPolicy`] applies here as to any other
2948 /// read-write session, the SWMR-write flag included; a reader on this
2949 /// machine is unaffected either way, since the barriers carry the write
2950 /// order across power loss rather than across processes.
2951 pub fn open_swmr_writer_with_options<P: AsRef<std::path::Path>>(
2952 path: P,
2953 properties: FileAccessProperties,
2954 ) -> Result<Self, Error> {
2955 Ok(File {
2956 inner: Arc::new(FileInner::open_swmr_writer(path, properties)?),
2957 })
2958 }
2959
2960 /// Clear a stale status flag left in `path` by a writer that exited without a
2961 /// clean [`close`](Self::close) — the `h5clear -s` equivalent, for recovering
2962 /// a file that both this crate and the reference C library otherwise refuse
2963 /// to open ([`Error::FileMarkedInUse`]). A no-op if the flag is already
2964 /// clear.
2965 ///
2966 /// It takes the exclusive OS lock first, so it cannot clear the flag out
2967 /// from under a *live* [`open_rw`](Self::open_rw) writer. A live SWMR writer
2968 /// holds no lock, so make sure it is really gone: clearing the flag under
2969 /// one leaves its readers with no record that it is publishing.
2970 ///
2971 /// It also clears the crash mark a page-buffered session raises
2972 /// ([`FileAccessProperties::with_page_buffer_size`]), and there the warning is
2973 /// sharper. That mark stands for pages that were still in memory, so a file
2974 /// still carrying it was left by a writer that did not finish: clearing it
2975 /// hands back a file whose datasets may read clean and return fill values or
2976 /// a deleted object's bytes, with every checksum verifying. Clear it to
2977 /// salvage what is there, not to resume trusting it. `h5clear` makes the same
2978 /// trade for the same reason.
2979 pub fn clear_swmr_flag<P: AsRef<std::path::Path>>(path: P) -> Result<(), Error> {
2980 crate::file_lock::clear_swmr_flag_at(path.as_ref())
2981 }
2982
2983 /// Create a new, empty HDF5 file at `path` and open it for reading and
2984 /// writing, so its contents can be built entirely through owned handles
2985 /// ([`Group::create_dataset`]/[`create_group`](Group::create_group), then
2986 /// [`commit`](Self::commit)).
2987 ///
2988 /// Overwrites any existing file at `path`. For an all-at-once write, use
2989 /// [`FileBuilder`](crate::FileBuilder) instead.
2990 #[doc(alias = "H5Fcreate")]
2991 pub fn create<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2992 Self::create_with_options(
2993 path,
2994 FileCreateProperties::new(),
2995 FileAccessProperties::new(),
2996 )
2997 }
2998
2999 /// Create a new, empty HDF5 file with explicit creation and access properties,
3000 /// then open it for reading and writing — see [`create`](Self::create).
3001 ///
3002 /// Mirrors `H5Fcreate(name, flags, fcpl_id, fapl_id)`: `create` carries the
3003 /// creation properties recorded in the new file (userblock, file-space
3004 /// strategy, library-version bounds), and `access` the properties governing
3005 /// the handle returned (locking policy, `fsync` cadence, chunk cache). Both are values, so a
3006 /// layout defined once can be reused across every file an application writes.
3007 ///
3008 /// A creation property is validated as the file is written, so an invalid
3009 /// userblock or page size surfaces here rather than when the properties were
3010 /// built. A file created with [`FileSpaceStrategy::Page`] can be grown
3011 /// through either editor, by an immediate [`Dataset::append`] or a staged
3012 /// commit, provided it also persists its free space (issue #198).
3013 pub fn create_with_options<P: AsRef<std::path::Path>>(
3014 path: P,
3015 create: FileCreateProperties,
3016 access: FileAccessProperties,
3017 ) -> Result<Self, Error> {
3018 // Refuse a pair the reopen below would refuse, before anything is
3019 // written: this call promises a file *and* an open handle, and half of
3020 // that is worse than neither.
3021 if let Some(reason) = crate::edit::create_would_refuse_reopen(&create, &access) {
3022 return Err(Error::EditUnsupported(reason));
3023 }
3024 let mut builder = crate::writer::FileBuilder::new();
3025 builder.with_create_properties(create);
3026 let bytes = builder.finish()?;
3027 std::fs::write(path.as_ref(), bytes).map_err(Error::Io)?;
3028 Self::open_rw_with_options(path, access)
3029 }
3030
3031 /// Apply all staged structural edits made through this file's handles —
3032 /// [`Dataset::write`]/`set_attr`/`remove_attr` and
3033 /// [`Group::create_group`]/`delete` — as one transaction. Immediate
3034 /// [`Dataset::append`]s need no commit.
3035 ///
3036 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3037 /// [`Error::ReadOnly`].
3038 ///
3039 /// Outstanding [`Dataset`] and [`Group`] handles stay usable: a commit
3040 /// relocates object headers, and each handle looks its object up again by
3041 /// path on its first use afterwards, so a long-lived handle answers for the
3042 /// file the commit left rather than for the copy it moved away from. Two
3043 /// exceptions, both of which report rather than answer wrongly. A *read*
3044 /// through a handle onto an object the commit deleted, or replaced with one
3045 /// of a different kind ([`Error::NotADataset`] or [`Error::NotAGroup`]),
3046 /// fails the way opening it
3047 /// would; its write methods still address the file by path, so they stage
3048 /// and the commit refuses them. And a handle reached by object reference
3049 /// ([`Dataset::dereference`]) has no path to look up, so it returns
3050 /// [`Error::StaleHandle`], and not only after a commit but after anything
3051 /// staged, synced or torn down, since only an immediate [`Dataset::append`]
3052 /// is known to leave every header where it stands. Dereference again from a
3053 /// fresh read.
3054 ///
3055 /// A handle onto an object this commit *publishes* — one
3056 /// [`Group::create_group`], [`Group::create_group_with`] or
3057 /// [`Group::create_dataset`] handed back, or a lookup of a staged name found
3058 /// — starts reading its object here. Until then it answers
3059 /// [`Error::NotCommitted`] for anything needing
3060 /// bytes, and a refused commit leaves it doing so.
3061 ///
3062 /// The commit is durable when it returns, under the default
3063 /// [`SyncPolicy::Always`]; under
3064 /// [`SyncPolicy::OnClose`] it has reached the operating system and waits for
3065 /// a [`sync`](Self::sync).
3066 ///
3067 /// **A commit refused before it publishes leaves every dataset reading what
3068 /// it read before.** Almost everything such a commit writes lands where
3069 /// nothing reaches it until the commit's linearization point; the one edit
3070 /// that does not is a same-length [`Dataset::write`], which overwrites the
3071 /// dataset's existing block, and the refusal writes those bytes back on its
3072 /// way out. A refusal raised before the first write keeps the staged batch
3073 /// too, so it can be corrected and committed again (issue #316).
3074 ///
3075 /// # Errors
3076 ///
3077 /// Two failures do not carry that promise, and both call for **re-reading**
3078 /// the datasets the batch named rather than for a retry:
3079 ///
3080 /// - [`Error::CommitPartiallyApplied`], where the restore itself failed, so
3081 /// a dataset may hold either value.
3082 /// - An error from a step *after* the commit published — repointing the
3083 /// object references that named a moved object is the one that can raise
3084 /// it. The batch is in the file and stays there; what failed is work the
3085 /// commit owed afterwards. The file is valid either way.
3086 pub fn commit(&self) -> Result<(), Error> {
3087 self.with_mirror_session(Change::Relocating, |session| session.commit())
3088 }
3089
3090 /// Copy the object at `src` to `dst` within this file (the in-file
3091 /// `H5Ocopy`), staged until [`commit`](Self::commit).
3092 ///
3093 /// A dataset whose storage was never allocated is copied as the storage it
3094 /// has — none — rather than as the fill value reading it answers with, so a
3095 /// schema-only dataset stays one. A dataset whose elements live in external
3096 /// files (`H5Pset_external`) carries that same empty storage while holding
3097 /// data this crate does not read, and is refused with
3098 /// [`Error::EditUnsupported`]. Copying it would leave its elements behind.
3099 ///
3100 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3101 /// [`Error::ReadOnly`].
3102 pub fn copy(&self, src: &str, dst: &str) -> Result<(), Error> {
3103 self.with_mirror_session(Change::Relocating, |session| {
3104 session.copy(&ObjectPathBuf::parse(src), &ObjectPathBuf::parse(dst))
3105 })
3106 }
3107
3108 /// Copy the object at `src` in `source` — a separate, buffered read-only
3109 /// file — into this file at `dst`: the cross-file `H5Ocopy`, staged until
3110 /// [`commit`](Self::commit).
3111 ///
3112 /// `source` must be a buffered file ([`File::open`] or [`File::from_bytes`],
3113 /// not [`File::open_streaming`]) that uses 8-byte offsets and has no
3114 /// userblock; anything else is refused with
3115 /// [`Error::EditUnsupported`]. The source
3116 /// subtree is read and validated eagerly, so `source` need not outlive this
3117 /// call — and so a source this cannot reproduce, external storage included,
3118 /// is refused by this call. Refusals that concern the *destination* — `dst`
3119 /// already exists, or its parent group does not — still come from `commit`.
3120 /// Requires a read-write destination ([`File::open_rw`]); a read-only
3121 /// one returns [`Error::ReadOnly`].
3122 pub fn copy_from(&self, source: &File, src: &str, dst: &str) -> Result<(), Error> {
3123 self.with_mirror_session(Change::Relocating, |session| {
3124 session.copy_from(
3125 source,
3126 &ObjectPathBuf::parse(src),
3127 &ObjectPathBuf::parse(dst),
3128 )
3129 })
3130 }
3131
3132 /// Report whether this file has structural edits staged but not yet applied
3133 /// by [`commit`](Self::commit) — [`Dataset::write`]/`set_attr`/`remove_attr`,
3134 /// [`Dataset::append_staged`], [`Group::create_group`]/`create_dataset`/
3135 /// `delete`/`set_attr`/`remove_attr`, and [`copy`](Self::copy)/
3136 /// [`copy_from`](Self::copy_from). Immediate [`Dataset::append`]s are never
3137 /// staged and do not count. Always `false` for a read-only file.
3138 ///
3139 /// A `commit` that refuses puts the staged set back untouched, so this still
3140 /// answers `true` afterwards and the same batch can be committed again — to
3141 /// the same refusal, until the session is dropped.
3142 pub fn has_staged_edits(&self) -> bool {
3143 match &self.inner.backend {
3144 Backend::Edit(m) => {
3145 let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3146 session.has_staged_edits()
3147 }
3148 _ => false,
3149 }
3150 }
3151
3152 /// Report this read-write file's live space usage as a [`SpaceAccounting`] —
3153 /// the current logical size, total reusable free bytes, and reusable free
3154 /// regions. It reflects committed state plus immediate in-place appends, not
3155 /// edits still staged for [`commit`](Self::commit).
3156 ///
3157 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3158 /// [`Error::ReadOnly`].
3159 pub fn space_accounting(&self) -> Result<SpaceAccounting, Error> {
3160 match &self.inner.backend {
3161 Backend::Edit(m) => {
3162 let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3163 Ok(session.space_accounting())
3164 }
3165 _ => Err(Error::ReadOnly),
3166 }
3167 }
3168
3169 /// Force everything written to this file so far to durable storage — the
3170 /// `fsync` the application issues at its own cadence under
3171 /// [`SyncPolicy::OnClose`], and a redundant one under the default
3172 /// [`SyncPolicy::Always`]. A SWMR-writer file syncs the same way.
3173 ///
3174 /// This is a durability barrier, not a flush: it writes nothing itself.
3175 /// Staged edits are not applied ([`commit`](Self::commit) does that, and a
3176 /// `sync` before one makes only the *previous* state durable), and elements
3177 /// held by a live [`BufferedAppender`] have not
3178 /// reached the file at all — flush it first.
3179 ///
3180 /// There is no need to call it before [`close`](Self::close): `close` — and
3181 /// dropping the last handle — issues its own barrier under every policy,
3182 /// because both write and both destroy the handle that would have ordered
3183 /// those writes. This is the mid-session checkpoint, not the closing one.
3184 ///
3185 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3186 /// [`Error::ReadOnly`], and a sealed one [`Error::FileClosed`]. A closed
3187 /// file has already been synced.
3188 #[doc(alias = "fsync")]
3189 pub fn sync(&self) -> Result<(), Error> {
3190 // A barrier over writes the operations that made them already accounted
3191 // for, so it invalidates no handle. Classing it as a change would end
3192 // every by-reference handle in the session because the caller asked for
3193 // an `fsync`.
3194 self.with_mirror_session(Change::Nothing, |session| session.force_sync())
3195 }
3196
3197 /// Commit any staged edits and seal this file. The exclusive OS lock is
3198 /// released once the last handle derived from this file is also dropped.
3199 ///
3200 /// After `close`, a write through any surviving [`Dataset`]/[`Group`] handle
3201 /// or [`File`] clone returns [`Error::FileClosed`], and
3202 /// reads still work. `close` commits, so the one handle a commit ends ends
3203 /// here too: one reached by [`Dataset::dereference`] reports
3204 /// [`Error::StaleHandle`] afterwards, where a handle opened by path
3205 /// re-resolves and keeps reading.
3206 pub fn close(self) -> Result<(), Error> {
3207 if matches!(self.inner.backend, Backend::Edit(_)) {
3208 // SWMR mode stages nothing — the staged surface is refused — so there
3209 // is nothing to commit, and it persists no free space, so there is
3210 // nothing to re-home.
3211 let swmr = self.inner.swmr_write;
3212 if !swmr {
3213 self.commit()?;
3214 }
3215 // Free-space managers and status flags, both rewritten where they
3216 // stand. No object header moves, so a handle that could read through
3217 // this file before `close` still can — which is what `close`'s own
3218 // documentation promises.
3219 self.with_mirror_session(Change::InPlace, |session| {
3220 // Immediate appends grow the file past any persisted free-space
3221 // managers without running a commit tail, so re-home them here. A
3222 // no-op unless this session left them stale.
3223 if !swmr {
3224 session.finalize_persist()?;
3225 }
3226 // Forced under every policy, and covering everything above: this
3227 // call consumes the handle, so it is the last point at which any
3228 // of these writes can be ordered at all.
3229 session.force_sync()?;
3230 // Last, and only after that sync. A session's status flags stand
3231 // for writes that may still have been in memory — a SWMR writer's
3232 // pair, a page buffer's crash mark — and this is the point at
3233 // which none are.
3234 session.release_status_flags()
3235 })?;
3236 self.inner.closed.store(true, Ordering::Release);
3237 }
3238 Ok(())
3239 }
3240
3241 /// Run `f` with the locked write session of a read-write file. `staged`
3242 /// distinguishes an edit applied by [`commit`](Self::commit) from an immediate
3243 /// one. Returns [`Error::ReadOnly`] for a read-only file,
3244 /// [`Error::FileClosed`] once the file is sealed by [`close`](Self::close),
3245 /// and [`Error::SwmrStagedUnsupported`] for a staged edit on a SWMR-writer
3246 /// file.
3247 fn with_mirror_session<R>(
3248 &self,
3249 change: Change,
3250 f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
3251 ) -> Result<R, Error> {
3252 self.inner.with_engine_mut(change, f)
3253 }
3254
3255 /// Returns an owned handle to the root group.
3256 pub fn root(&self) -> Group {
3257 let revisions = self.inner.revisions();
3258 Group::new(
3259 self.inner.clone(),
3260 // A relocating commit on a read-write file can move the root, so
3261 // resolve it from the live mirror rather than the cached superblock.
3262 revisions.at(self.inner.mirror_root_address()),
3263 Some(ObjectPathBuf::root()),
3264 )
3265 }
3266
3267 /// Resolve a path and return an owned [`Dataset`] handle.
3268 ///
3269 /// The dataset uses the file-wide chunk-cache default (configured with
3270 /// [`FileAccessProperties::with_chunk_cache`]). To override the cache for this
3271 /// one dataset, use [`dataset_with_options`](Self::dataset_with_options).
3272 ///
3273 /// Returns [`Error::NotADataset`] if the path names something that is not a
3274 /// dataset, and [`Error::NotAGroup`] if a component *along* the path is not
3275 /// a group: resolving `a/b/c` opens `a` and then `a/b` to look inside them,
3276 /// so a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3277 pub fn dataset(&self, path: &str) -> Result<Dataset, Error> {
3278 self.dataset_with_options(path, DatasetAccessProperties::new())
3279 }
3280
3281 /// Resolve a path and return an owned [`Dataset`] handle, applying per-dataset
3282 /// [`DatasetAccessProperties`] that override file-wide access defaults.
3283 ///
3284 /// This is the dataset-open-with-access-property-list path (HDF5's `dapl`):
3285 /// the properties' chunk cache corresponds to `H5Pset_chunk_cache` and takes
3286 /// precedence, for this dataset only, over the `H5Pset_cache`-style
3287 /// file-wide default.
3288 pub fn dataset_with_options(
3289 &self,
3290 path: &str,
3291 properties: DatasetAccessProperties,
3292 ) -> Result<Dataset, Error> {
3293 let chunk_cache = properties.resolved_chunk_cache(self.inner.access_properties.chunk_cache);
3294 let path = ObjectPathBuf::parse(path);
3295 match self.inner.staged_object(&path).map(|o| o.kind) {
3296 Some(StagedKind::Dataset) => {
3297 return Ok(Dataset::pending(self.inner.clone(), chunk_cache, path));
3298 }
3299 Some(StagedKind::Group) => return Err(Error::NotADataset(path.to_string())),
3300 None => {}
3301 }
3302 let revisions = self.inner.revisions();
3303 let addr = self.inner.resolve_path(&path.as_path())?;
3304 let hdr = self.inner.parse_header(addr)?;
3305 if !has_message(&hdr, MessageType::DATA_LAYOUT) {
3306 return Err(Error::NotADataset(path.to_string()));
3307 }
3308 Ok(Dataset::new(
3309 self.inner.clone(),
3310 revisions.at(addr),
3311 hdr,
3312 chunk_cache,
3313 Some(path),
3314 ))
3315 }
3316
3317 /// Resolve a path and return an owned [`Group`] handle.
3318 ///
3319 /// Returns [`Error::NotAGroup`] if the path names an object that is not a
3320 /// group, the way [`dataset`](Self::dataset) returns
3321 /// [`Error::NotADataset`] for the mirror case, and
3322 /// [`FormatError::PathNotFound`] if it names nothing.
3323 ///
3324 /// The same error reports a component *along* the path that is not a group,
3325 /// naming that component's own path rather than the one asked for: `a/b/c`
3326 /// stopped by a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3327 pub fn group(&self, path: &str) -> Result<Group, Error> {
3328 let path = ObjectPathBuf::parse(path);
3329 match self.inner.staged_object(&path).map(|o| o.kind) {
3330 Some(StagedKind::Group) => {
3331 return Ok(Group::pending(self.inner.clone(), path));
3332 }
3333 Some(StagedKind::Dataset) => return Err(Error::NotAGroup(path.to_string())),
3334 None => {}
3335 }
3336 let revisions = self.inner.revisions();
3337 let addr = self.inner.resolve_path(&path.as_path())?;
3338 if !is_group(&self.inner.parse_header(addr)?) {
3339 // Normalized, so that the same object refused here and refused by a
3340 // live handle below names itself the same way: a handle knows only
3341 // the normalized path it memoized.
3342 return Err(Error::NotAGroup(path.to_string()));
3343 }
3344 Ok(Group::new(
3345 self.inner.clone(),
3346 revisions.at(addr),
3347 Some(path),
3348 ))
3349 }
3350
3351 /// Re-read the file from disk to pick up data appended by a concurrent
3352 /// writer, then re-parse the superblock.
3353 ///
3354 /// This is the SWMR reader's refresh primitive. Returns
3355 /// [`Error::SwmrUnsupported`] if the file was not opened with
3356 /// [`File::open_swmr`], and [`Error::HandlesOutstanding`] if any owned
3357 /// [`Dataset`]/[`Group`] handle (or a clone of this `File`) is still alive —
3358 /// drop them before refreshing, then re-fetch them afterward, since they
3359 /// observe the new bytes only when re-derived from the refreshed file.
3360 pub fn refresh(&mut self) -> Result<(), Error> {
3361 let inner = Arc::get_mut(&mut self.inner).ok_or(Error::HandlesOutstanding)?;
3362 inner.refresh()
3363 }
3364
3365 // --- delegating value getters (forward to the shared inner state) ---
3366
3367 /// Returns the raw file bytes for an in-memory file, or an empty slice for a
3368 /// streaming file (which has no whole-file buffer).
3369 pub fn as_bytes(&self) -> &[u8] {
3370 self.inner.as_bytes()
3371 }
3372
3373 /// Returns the access properties this file was opened with.
3374 ///
3375 /// An open without options, such as [`File::open`], has [`FileAccessProperties::new`].
3376 pub fn access_properties(&self) -> FileAccessProperties {
3377 self.inner.access_properties()
3378 }
3379
3380 /// Which backend this file's read-write session resolved to:
3381 /// [`EditBacking::Bounded`] when it reads through a handle, or
3382 /// [`EditBacking::Mirrored`] when it holds a whole-file image.
3383 ///
3384 /// This is how a caller who opened with [`MemoryStrategy::Auto`] finds out
3385 /// whether the fallback was taken, and so whether memory scales with the
3386 /// file. A file with no editing session — a read-only open, a streaming open
3387 /// — reports `None`.
3388 ///
3389 /// The answer is an [`EditBacking`] rather than the [`MemoryStrategy`] that
3390 /// was asked for, because `Auto` is a preference between the two backends and
3391 /// not an outcome either can report; `.into()` converts back when a later
3392 /// reopen should be pinned to what this one got.
3393 pub fn edit_backing(&self) -> Option<EditBacking> {
3394 self.inner.edit_backing()
3395 }
3396
3397 /// What this file's metadata cache has done, and what it is holding.
3398 ///
3399 /// [`FileAccessProperties::with_metadata_cache`] sets a byte budget before
3400 /// any read has happened; this is how a caller finds out whether it was the
3401 /// right one. See [`MetadataCacheStats`] for which figure answers which
3402 /// question. Together the two are the `hdf5-pure` counterpart of HDF5's
3403 /// `H5Fget_mdc_hit_rate` and `H5Fget_mdc_size`.
3404 ///
3405 /// `None` where there is no metadata cache to report on: a buffered
3406 /// [`open`](Self::open) or [`from_bytes`](Self::from_bytes), which already
3407 /// holds the whole file; a mirrored read-write session, for the same reason;
3408 /// or a streaming or bounded open left at the default disabled budget.
3409 ///
3410 /// ```no_run
3411 /// # fn main() -> Result<(), hdf5_pure::Error> {
3412 /// use hdf5_pure::{File, FileAccessProperties, MetadataCacheConfig};
3413 ///
3414 /// let properties =
3415 /// FileAccessProperties::new().with_metadata_cache(MetadataCacheConfig::new(8 << 20));
3416 /// let file = File::open_streaming_with_options("data.h5", properties)?;
3417 /// for name in file.root().datasets()? {
3418 /// let _ = file.dataset(&name)?.read_raw()?;
3419 /// }
3420 ///
3421 /// let stats = file.metadata_cache_stats().expect("the budget enabled a cache");
3422 /// println!("{:?} over {} reads, {} evicted", stats.hit_rate(), stats.reads(), stats.evictions());
3423 /// # Ok(())
3424 /// # }
3425 /// ```
3426 pub fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
3427 self.inner.metadata_cache_stats()
3428 }
3429
3430 /// Zero this file's metadata-cache counters, keeping every cached entry.
3431 ///
3432 /// HDF5's `H5Freset_mdc_hit_rate_stats`, for measuring one phase of a
3433 /// program rather than a whole run: the reads that populate a cache miss by
3434 /// definition, so a hit rate taken over the run charges the steady state for
3435 /// the warm-up. Reset after warming to measure the part that repeats.
3436 ///
3437 /// It evicts nothing: occupancy, which
3438 /// [`metadata_cache_stats`](Self::metadata_cache_stats) also reports, is a
3439 /// measurement of the cache rather than a tally of its history. A file with
3440 /// no metadata cache ignores the call.
3441 pub fn reset_metadata_cache_stats(&self) {
3442 self.inner.reset_metadata_cache_stats();
3443 }
3444
3445 /// Returns a reference to the parsed superblock.
3446 pub fn superblock(&self) -> &Superblock {
3447 self.inner.superblock()
3448 }
3449
3450 /// The file-space management strategy this file records in its superblock
3451 /// extension, or `None` if it records none.
3452 pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
3453 self.inner.file_space_strategy()
3454 }
3455
3456 /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
3457 /// if present and readable.
3458 pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
3459 self.inner.file_space_info()
3460 }
3461
3462 /// The free regions a file persists on disk in its free-space managers, as
3463 /// `(address, length)` pairs sorted by address.
3464 pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
3465 self.inner.persisted_free_space()
3466 }
3467
3468 /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
3469 pub fn file_size(&self) -> u64 {
3470 self.inner.file_size()
3471 }
3472
3473 /// The minimum library version required to read this file, derived from its
3474 /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
3475 pub fn libver_bound(&self) -> LibVer {
3476 self.inner.libver_bound()
3477 }
3478
3479 /// A `Source` view over the backend, for the streaming-capable paths.
3480 pub(crate) fn source(&self) -> SourceView<'_> {
3481 self.inner.source()
3482 }
3483
3484 /// The whole-file byte image when this file is buffered in memory; `None`
3485 /// for a streaming file. Used by cross-file object copy.
3486 pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
3487 self.inner.in_memory_image()
3488 }
3489
3490 /// The access mode this file was opened under, which a cross-file copy passes
3491 /// to every parse it makes of this file's objects.
3492 pub(crate) fn access_mode(&self) -> AccessMode {
3493 self.inner.access_mode()
3494 }
3495
3496 /// The base address (superblock base address) added to every stored relative
3497 /// address. Zero for a file with no userblock.
3498 pub(crate) fn base_address(&self) -> BaseAddress {
3499 self.inner.base_address()
3500 }
3501}
3502
3503// ---------------------------------------------------------------------------
3504// Object reference target
3505// ---------------------------------------------------------------------------
3506
3507/// The resolved target of an HDF5 object reference (`H5R_OBJECT`): either a
3508/// group or a dataset.
3509///
3510/// Produced by [`Dataset::dereference`]. MATLAB `.mat` files use object
3511/// references pervasively — a cell array stores one reference per element, and
3512/// the `#subsystem#` machinery references its payloads — so resolving a
3513/// reference to the group or dataset it names is the foundation for reading
3514/// those structures.
3515///
3516/// The [`Dataset`](Object::Dataset) handle is boxed: it carries a parsed object
3517/// header and is much larger than a [`Group`](Object::Group) handle, so boxing
3518/// keeps `Object` (and a `Vec<Object>`) compact without a size disparity. The
3519/// `Box` derefs transparently, so `&obj_dataset` is usable wherever a
3520/// `&Dataset` is expected.
3521///
3522/// Non-exhaustive: a reference can name an object kind this crate does not yet
3523/// resolve — a committed (named) datatype is refused with
3524/// [`FormatError::InvalidObjectReference`]
3525/// today — so match with a `_` arm.
3526#[non_exhaustive]
3527pub enum Object {
3528 /// The reference points at a group's object header.
3529 Group(Group),
3530 /// The reference points at a dataset's object header.
3531 Dataset(Box<Dataset>),
3532}
3533
3534impl std::fmt::Debug for Object {
3535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3536 match self {
3537 Object::Group(_) => f.write_str("Object::Group"),
3538 Object::Dataset(_) => f.write_str("Object::Dataset"),
3539 }
3540 }
3541}
3542
3543// ---------------------------------------------------------------------------
3544// Group handle
3545// ---------------------------------------------------------------------------
3546
3547/// A group that exists only as a staged edit, handed to
3548/// [`Group::create_group_with`]'s closure so a whole subtree — attributes,
3549/// nested groups, datasets — can be described in one call.
3550///
3551/// It is a convenience, not the only way in: [`Group::create_group`] and
3552/// [`Group::create_group_with`] both return a live [`Group`] handle onto the
3553/// staged group, and everything this offers can be staged through that handle
3554/// instead. Reach for the closure when the shape of the subtree is known at the
3555/// call, and for the handle when it is built up by code that takes a `&Group`.
3556///
3557/// Every method stages; nothing is written until [`File::commit`].
3558///
3559/// The closure holding this records into a buffer rather than into the file's
3560/// writable session, so nothing is locked while it runs. The recorded operations
3561/// are applied together when it returns, which is why an object staged here is
3562/// addressable only once the closure has returned.
3563pub struct StagedGroup<'a> {
3564 ops: &'a mut Vec<StagedOp>,
3565 path: ObjectPathBuf,
3566}
3567
3568impl StagedGroup<'_> {
3569 /// Stage an attribute on this group, applied with its creation on
3570 /// [`File::commit`].
3571 pub fn set_attr(&mut self, name: &str, value: AttrValue) -> &mut Self {
3572 self.ops.push(StagedOp::SetGroupAttr {
3573 path: self.path.clone(),
3574 name: name.to_string(),
3575 value,
3576 });
3577 self
3578 }
3579
3580 /// Stage an empty subgroup of this group.
3581 ///
3582 /// `path` may have several components, which stages a group under this one, or begin with
3583 /// `/`, which stages it from the root group, both on the terms
3584 /// [`Group::create_group`] sets out.
3585 ///
3586 /// To configure it in the same commit, use
3587 /// [`create_group_with`](Self::create_group_with). To get a [`Group`] handle
3588 /// onto it, look it up by name once this closure has returned, or stage it
3589 /// through [`Group::create_group`] instead, which hands one back.
3590 pub fn create_group(&mut self, path: &str) -> &mut Self {
3591 self.create_group_with(path, |_| {})
3592 }
3593
3594 /// Stage a subgroup of this group, configured through `build`.
3595 pub fn create_group_with(
3596 &mut self,
3597 path: &str,
3598 build: impl FnOnce(&mut StagedGroup<'_>),
3599 ) -> &mut Self {
3600 let child = self.path.join_path(&ObjectPath::parse(path));
3601 self.ops.push(StagedOp::CreateGroup(child.clone()));
3602 let mut staged = StagedGroup {
3603 ops: &mut *self.ops,
3604 path: child,
3605 };
3606 build(&mut staged);
3607 self
3608 }
3609
3610 /// Stage a dataset in this group, configured through `build`.
3611 ///
3612 /// `path` may have several components, as it may for [`create_group`](Self::create_group).
3613 pub fn create_dataset(
3614 &mut self,
3615 path: &str,
3616 build: impl FnOnce(&mut DatasetBuilder),
3617 ) -> &mut Self {
3618 let mut builder = DatasetBuilder::new(path);
3619 build(&mut builder);
3620 self.ops.push(StagedOp::CreateDataset {
3621 path: self.path.join_path(&ObjectPath::parse(path)),
3622 builder: Box::new(builder),
3623 });
3624 self
3625 }
3626}
3627
3628/// One edit recorded by a [`StagedGroup`] closure, replayed onto the writable
3629/// session after the closure returns.
3630///
3631/// The indirection is what keeps user code off the session lock: the closure
3632/// touches only this buffer, so calling back into the same [`File`] from inside
3633/// it is at worst wrongly ordered rather than a deadlock (issue #200).
3634enum StagedOp {
3635 CreateGroup(ObjectPathBuf),
3636 SetGroupAttr {
3637 path: ObjectPathBuf,
3638 name: String,
3639 value: AttrValue,
3640 },
3641 CreateDataset {
3642 path: ObjectPathBuf,
3643 /// Boxed because a `DatasetBuilder` dwarfs the other variants, and a
3644 /// closure staging many groups would otherwise pay its size per entry.
3645 builder: Box<DatasetBuilder>,
3646 },
3647}
3648
3649impl StagedOp {
3650 /// Record this edit on the session. Applied in the order the closure made
3651 /// the calls, so a group is always staged before its own attributes and
3652 /// children.
3653 fn apply(self, session: &mut WriteEngine) -> Result<(), Error> {
3654 match self {
3655 StagedOp::CreateGroup(path) => session.create_group(&path),
3656 StagedOp::SetGroupAttr { path, name, value } => {
3657 session.set_group_attr(&path, &name, value)
3658 }
3659 StagedOp::CreateDataset { path, builder } => {
3660 session.stage_created_dataset(&path, *builder)
3661 }
3662 }
3663 }
3664}
3665
3666/// An owned handle to an HDF5 group.
3667///
3668/// The handle names the group by its root-relative path and remembers where that
3669/// path resolved to. A [`File::commit`] rewrites and relocates object headers,
3670/// so the memo is worked out again on the first use after any edit and the
3671/// handle goes on answering for the same group — see [`File::commit`] for the
3672/// two cases that report instead. Cloning gives a second handle to the same
3673/// group.
3674pub struct Group {
3675 file: Arc<FileInner>,
3676 /// Where this group's object header sits, as of the file revision it was
3677 /// resolved at. Re-resolved on first use after an edit could have moved it;
3678 /// see [`Group::header_address`]. A group carries no parsed header of its
3679 /// own — it re-reads one per call — so the address is the whole memo, and the
3680 /// content revision the [`Resolution`] carries beside it names no header this
3681 /// handle read and is never read back.
3682 ///
3683 /// `None` for a group this session has *staged* and not yet committed: there
3684 /// is no header to name one. Such a handle resolves its path on every use,
3685 /// and installs a memo the first time a commit gives it something to point
3686 /// at.
3687 state: RwLock<Option<Resolution>>,
3688 /// Root-relative path of this group (the root group's own path has no
3689 /// components), used to address the group and its children for write
3690 /// operations on a read-write file, and to find it again after an edit moved
3691 /// it. `None` for a group reached by object reference
3692 /// ([`Dataset::dereference`]), which has no resolvable path.
3693 path: Option<ObjectPathBuf>,
3694 /// The session's staged generation when this handle was made onto a staged
3695 /// creation, and `None` for a handle opened onto a group in the file.
3696 ///
3697 /// It is what keeps such a handle from being retargeted at a different
3698 /// object: see [`Standing`] and [`FileInner::staged_standing`].
3699 staged_birth: Option<u64>,
3700}
3701
3702impl Clone for Group {
3703 /// Clones share nothing but the open file: the clone is a second handle to
3704 /// the same group, resolved as of the same revision.
3705 fn clone(&self) -> Self {
3706 Self {
3707 file: Arc::clone(&self.file),
3708 state: RwLock::new(*self.state.read().unwrap_or_else(PoisonError::into_inner)),
3709 path: self.path.clone(),
3710 staged_birth: self.staged_birth,
3711 }
3712 }
3713}
3714
3715impl std::fmt::Debug for Group {
3716 /// Reports the handle as it stands, without re-resolving: a `Debug` that
3717 /// read the file could fail, and one that failed would have nothing to
3718 /// print. `staged` is true for a group this session has created and not yet
3719 /// committed, which has no address to report.
3720 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3721 let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
3722 f.debug_struct("Group")
3723 .field("path", &self.path)
3724 .field("staged", &state.is_none())
3725 .finish()
3726 }
3727}
3728
3729impl Group {
3730 /// Build a handle for a group whose header was found at `at`.
3731 fn new(file: Arc<FileInner>, at: Resolution, path: Option<ObjectPathBuf>) -> Self {
3732 Self {
3733 file,
3734 state: RwLock::new(Some(at)),
3735 path,
3736 staged_birth: None,
3737 }
3738 }
3739
3740 /// Build a handle for a group this session has staged and not yet
3741 /// committed, which has no header to memoize.
3742 ///
3743 /// It addresses the group by name from the moment it is staged: further
3744 /// creations, deletions and attribute edits under it are staged through it
3745 /// like any other handle. Anything that has to *read* the group reports
3746 /// [`Error::NotCommitted`] until [`File::commit`], after which the first use
3747 /// resolves the path and the handle behaves as if it had been opened by
3748 /// name.
3749 ///
3750 /// The session's staged generation is taken here so that the handle can tell
3751 /// that commit from a *withdrawal* of the same staging, which leaves the
3752 /// path meaning whatever the file holds — see [`Standing`].
3753 fn pending(file: Arc<FileInner>, path: ObjectPathBuf) -> Self {
3754 Self {
3755 staged_birth: file.staged_generation(),
3756 file,
3757 state: RwLock::new(None),
3758 path: Some(path),
3759 }
3760 }
3761
3762 /// This group's object-header address (base-adjusted, file-absolute), worked
3763 /// out again from its path if an edit could have moved it since the memo was
3764 /// taken. Also what resolves an object reference that points at this group.
3765 ///
3766 /// Returns [`Error::StaleHandle`] for a handle
3767 /// that has no path to re-resolve — one an object reference produced — once
3768 /// a commit has run under it, and the resolution's own error (a
3769 /// `PathNotFound`, say, for a group a commit deleted) when the path no
3770 /// longer names anything. A group this session has staged and not committed
3771 /// has no header at all, and that is [`Error::NotCommitted`]. A commit that
3772 /// replaces this group with a dataset of the same name (issue #305) leaves
3773 /// the path naming something that is not a group, and that is
3774 /// [`Error::NotAGroup`].
3775 pub(crate) fn header_address(&self) -> Result<u64, Error> {
3776 let memo = *self.state.read().unwrap_or_else(PoisonError::into_inner);
3777 if let Some(memo) = memo {
3778 if memo.address_revision == self.file.address_revision() {
3779 return Ok(memo.address);
3780 }
3781 }
3782 let at = self
3783 .file
3784 .locate_staged(self.path.as_ref(), memo, self.staged_birth)?;
3785 // Checked before it is memoized. The short-circuit above does not
3786 // re-check, so an address installed and then refused would be the
3787 // answer every later call returns without looking at it again.
3788 if !is_group(&self.file.parse_header(at.address)?) {
3789 // A path-less handle never reaches here: it failed the short-circuit
3790 // above, and `locate` answers `StaleHandle` for one whose address
3791 // memo it cannot reuse.
3792 return Err(Error::NotAGroup(reported_path(self.path.as_ref())));
3793 }
3794 let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
3795 // Two threads can re-resolve at once. The older answer must not land on
3796 // top of the newer one, or the newer handle would go on serving an
3797 // address the file has already moved past. A handle that had no memo
3798 // takes this one: any address beats naming nothing.
3799 if state.is_none_or(|memo| at.address_revision >= memo.address_revision) {
3800 *state = Some(at);
3801 }
3802 Ok(at.address)
3803 }
3804
3805 /// List the names of datasets in this group.
3806 ///
3807 /// To read from the datasets themselves, prefer
3808 /// [`iter_datasets`](Self::iter_datasets): it hands back opened handles for
3809 /// the cost of this call, where opening each name separately re-walks the
3810 /// group once per member.
3811 ///
3812 /// A dataset this session has staged and not yet committed is listed too,
3813 /// once — a name that is both on disk and staged is the replacement of issue
3814 /// #305, and the staged object is what that name already resolves to.
3815 pub fn datasets(&self) -> Result<Vec<String>, Error> {
3816 let (entries, staged) = self.children_and_staged()?;
3817 let members = staged_members(&staged, StagedKind::Dataset, &entries);
3818 let mut names = Vec::new();
3819 for entry in &entries {
3820 if superseded(&staged, &entry.name) {
3821 continue;
3822 }
3823 let hdr = self.file.parse_header(self.file.entry_address(entry)?)?;
3824 if has_message(&hdr, MessageType::DATA_LAYOUT) {
3825 names.push(entry.name.clone());
3826 }
3827 }
3828 names.extend(members);
3829 Ok(names)
3830 }
3831
3832 /// Open every dataset in this group, each paired with its name.
3833 ///
3834 /// This is the walk to reach for when the members themselves are what you
3835 /// want — their attributes, shapes or data — rather than a list of names.
3836 /// [`datasets`](Self::datasets) already parses every child's object header to
3837 /// tell a dataset from a group, and then keeps only the name, so following it
3838 /// with a [`dataset`](Self::dataset) call per entry re-walks the group's link
3839 /// structure and re-parses that same header. This keeps what it read, and
3840 /// costs one enumeration of the group rather than one per member.
3841 ///
3842 /// **This walk is for taking every member, or nearly every one.** Telling a
3843 /// dataset from a group means parsing its header, so the whole group is
3844 /// enumerated and every child's header parsed before the iterator is
3845 /// returned — breaking out early saves nothing, and reaching one known member
3846 /// this way costs far more than [`dataset`](Self::dataset) does. Only the
3847 /// handle construction is deferred to each step, and that is not where the
3848 /// cost is.
3849 ///
3850 /// The headers of the members are held for the length of the walk, since each
3851 /// one is what its handle is built from. That is bounded by the group being
3852 /// walked rather than by the file, but it is proportional to the group: a
3853 /// header carries a compact dataset's data and its compact attributes inline,
3854 /// so a large group of such datasets is a large allocation.
3855 ///
3856 /// Each dataset gets the file-wide chunk-cache default; to override the cache
3857 /// for one, open it by name with
3858 /// [`dataset_with_options`](Self::dataset_with_options).
3859 ///
3860 /// Members arrive in the order the group's link structure yields them — the
3861 /// same order [`datasets`](Self::datasets) reports, which is not necessarily
3862 /// sorted, with this session's staged datasets after them. Each handle is a
3863 /// snapshot taken when the iterator was built, so a [`File::commit`] that
3864 /// runs mid-walk is not reflected in the members still to come; re-open the
3865 /// group to see past it.
3866 ///
3867 /// ```no_run
3868 /// # use hdf5_pure::File;
3869 /// # fn main() -> Result<(), hdf5_pure::Error> {
3870 /// let file = File::open("runs.h5")?;
3871 /// for (name, dataset) in file.root().iter_datasets()? {
3872 /// println!("{name}: {:?}", dataset.shape());
3873 /// }
3874 /// # Ok(())
3875 /// # }
3876 /// ```
3877 pub fn iter_datasets(
3878 &self,
3879 ) -> Result<impl ExactSizeIterator<Item = (String, Dataset)> + use<>, Error> {
3880 let revisions = self.file.revisions();
3881 let (entries, staged) = self.children_and_staged()?;
3882 // Taken before the entries are consumed below, since which staged
3883 // members survive depends on the names the file already holds.
3884 let staged_members = staged_members(&staged, StagedKind::Dataset, &entries);
3885 // A member is either an on-disk header this walk read, or a staged
3886 // creation with no header to read yet.
3887 let mut members: Vec<(String, Option<(u64, ObjectHeader)>)> = Vec::new();
3888 for entry in entries {
3889 if superseded(&staged, &entry.name) {
3890 continue;
3891 }
3892 let address = self.file.entry_address(&entry)?;
3893 let hdr = self.file.parse_header(address)?;
3894 if has_message(&hdr, MessageType::DATA_LAYOUT) {
3895 members.push((entry.name, Some((address, hdr))));
3896 }
3897 }
3898 members.extend(staged_members.into_iter().map(|name| (name, None)));
3899 let file = Arc::clone(&self.file);
3900 let parent = self.path.clone();
3901 let chunk_cache = DatasetAccessProperties::new()
3902 .resolved_chunk_cache(self.file.access_properties.chunk_cache);
3903 Ok(members.into_iter().map(move |(name, on_disk)| {
3904 let path = LinkName::new(&name).and_then(|leaf| child_path_of(parent.as_ref(), leaf));
3905 let dataset = match on_disk {
3906 Some((address, header)) => Dataset::new(
3907 Arc::clone(&file),
3908 revisions.at(address),
3909 header,
3910 chunk_cache,
3911 path,
3912 ),
3913 // Only a group with a path of its own reports staged children,
3914 // and a staged name is one path component, so a staged member
3915 // always has a path of its own.
3916 None => Dataset::pending(
3917 Arc::clone(&file),
3918 chunk_cache,
3919 path.expect("a staged member is a link name under a parent with a path"),
3920 ),
3921 };
3922 (name, dataset)
3923 }))
3924 }
3925
3926 /// The names of children that are committed (`H5Tcommit`) datatype objects:
3927 /// an object header carrying a datatype and neither data nor links.
3928 ///
3929 /// Such an object is the third kind HDF5 links into a group, and it appears
3930 /// in neither [`datasets`](Self::datasets) nor [`groups`](Self::groups) — so a
3931 /// walk that asks only for those two passes over one without noticing. Read
3932 /// the type itself with [`named_datatype`](Self::named_datatype).
3933 pub fn named_datatypes(&self) -> Result<Vec<String>, Error> {
3934 let entries = self.children()?;
3935 let mut names = Vec::new();
3936 for entry in &entries {
3937 let hdr = self.file.parse_header(self.file.entry_address(entry)?)?;
3938 if is_named_datatype(&hdr) {
3939 names.push(entry.name.clone());
3940 }
3941 }
3942 Ok(names)
3943 }
3944
3945 /// Returns the datatype the committed (`H5Tcommit`) object `path` names holds.
3946 ///
3947 /// `path` resolves as it does for [`dataset`](Self::dataset), and its last component is a name
3948 /// [`named_datatypes`](Self::named_datatypes) reports for the group that holds it.
3949 ///
3950 /// # Errors
3951 ///
3952 /// Returns [`FormatError::PathNotFound`] if a component of `path` reaches nothing,
3953 /// [`Error::NotAGroup`] if an object along the way is not a group, and
3954 /// [`Error::NotANamedDatatype`] if `path` identifies an object of another kind, as `H5Topen`
3955 /// rejects one.
3956 pub fn named_datatype(&self, path: &str) -> Result<Datatype, Error> {
3957 Ok(self.named_datatype_at(path)?.0)
3958 }
3959
3960 /// Returns how many things reference the committed (`H5Tcommit`) datatype `path` names:
3961 /// its hard links, plus every dataset and attribute that names it.
3962 ///
3963 /// This is HDF5's own object reference count (`H5Oget_info`'s `rc`), and what
3964 /// says whether unlinking the name would destroy the type or merely stop it
3965 /// being reachable through the link. A version 1 object header keeps the count
3966 /// in its prefix, a version 2 header in an Object Reference Count message,
3967 /// and a version 2 header without that message has exactly one reference,
3968 /// which is what the format means by omitting it.
3969 ///
3970 /// # Errors
3971 ///
3972 /// Returns [`FormatError::PathNotFound`], [`Error::NotAGroup`] and
3973 /// [`Error::NotANamedDatatype`] on the terms [`named_datatype`](Self::named_datatype) sets out,
3974 /// and
3975 /// [`FormatError::UnexpectedEof`] for an Object Reference Count message too short to hold its
3976 /// count.
3977 pub fn named_datatype_references(&self, path: &str) -> Result<u32, Error> {
3978 let (_, hdr) = self.named_datatype_header(path)?;
3979 if let Some(count) = hdr.reference_count {
3980 return Ok(count);
3981 }
3982 let Ok(msg) = find_message(&hdr, MessageType::OBJECT_REFERENCE_COUNT) else {
3983 return Ok(1);
3984 };
3985 // version(1) + count(4).
3986 let body = self.file.message_body(msg)?;
3987 if body.len() < 5 {
3988 return Err(Error::Format(FormatError::UnexpectedEof {
3989 expected: 5,
3990 available: body.len(),
3991 }));
3992 }
3993 Ok(u32::from_le_bytes([body[1], body[2], body[3], body[4]]))
3994 }
3995
3996 /// Returns the object header of the committed datatype `path` identifies, and its
3997 /// address.
3998 ///
3999 /// The one place the by-name datatype lookups classify what they reached, so
4000 /// that an object this rejects cannot be one
4001 /// [`named_datatypes`](Self::named_datatypes) would list. Reached the way
4002 /// [`group`](Self::group) and [`dataset`](Self::dataset) reach theirs, which
4003 /// looks each component up by name.
4004 fn named_datatype_header(&self, path: &str) -> Result<(u64, ObjectHeader), Error> {
4005 let relative = ObjectPath::parse(path);
4006 let address = self.resolve_path(&relative)?;
4007 let hdr = self.file.parse_header(address)?;
4008 if !is_named_datatype(&hdr) {
4009 return Err(Error::NotANamedDatatype(self.root_relative(&relative)));
4010 }
4011 Ok((address, hdr))
4012 }
4013
4014 /// Returns the datatype the committed object `path` identifies holds, and the address of the
4015 /// object header holding it.
4016 ///
4017 /// The address is the identity every user of the type shares: two datasets
4018 /// naming the same address name one type, and reproducing that requires
4019 /// matching them up by address rather than by what the type decodes to.
4020 pub(crate) fn named_datatype_at(&self, path: &str) -> Result<(Datatype, u64), Error> {
4021 let (address, hdr) = self.named_datatype_header(path)?;
4022 let msg = find_message(&hdr, MessageType::DATATYPE)?;
4023 let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
4024 Ok((dt, address))
4025 }
4026
4027 /// List the names of subgroups in this group.
4028 ///
4029 /// To descend into the subgroups themselves, prefer
4030 /// [`iter_groups`](Self::iter_groups), which hands back opened handles for
4031 /// the cost of this call.
4032 ///
4033 /// A group this session has staged and not yet committed is listed too, on
4034 /// the terms [`datasets`](Self::datasets) sets out.
4035 pub fn groups(&self) -> Result<Vec<String>, Error> {
4036 let (entries, staged) = self.children_and_staged()?;
4037 let members = staged_members(&staged, StagedKind::Group, &entries);
4038 let mut names = Vec::new();
4039 for entry in &entries {
4040 if superseded(&staged, &entry.name) {
4041 continue;
4042 }
4043 let hdr = self.file.parse_header(self.file.entry_address(entry)?)?;
4044 if is_group(&hdr) {
4045 names.push(entry.name.clone());
4046 }
4047 }
4048 names.extend(members);
4049 Ok(names)
4050 }
4051
4052 /// Open every subgroup of this group, each paired with its name.
4053 ///
4054 /// The counterpart to [`iter_datasets`](Self::iter_datasets), and the way to
4055 /// recurse without paying a [`group`](Self::group) lookup per child: that
4056 /// lookup re-walks this group's link structure, which a walk of the whole
4057 /// tree would otherwise repeat once per subgroup.
4058 ///
4059 /// As with [`iter_datasets`](Self::iter_datasets), the whole group is
4060 /// enumerated and classified before the iterator is returned, so this is the
4061 /// walk for taking every subgroup rather than for reaching one — breaking out
4062 /// early saves nothing. A [`Group`] handle carries no parsed header, so
4063 /// unlike `iter_datasets` this holds none of them.
4064 ///
4065 /// Members arrive in the order the group's link structure yields them — the
4066 /// same order [`groups`](Self::groups) reports, which is not necessarily
4067 /// sorted, with this session's staged groups after them.
4068 ///
4069 /// ```no_run
4070 /// # use hdf5_pure::{Error, Group};
4071 /// fn total_datasets(group: &Group) -> Result<usize, Error> {
4072 /// let mut n = group.datasets()?.len();
4073 /// for (_, child) in group.iter_groups()? {
4074 /// n += total_datasets(&child)?;
4075 /// }
4076 /// Ok(n)
4077 /// }
4078 /// ```
4079 pub fn iter_groups(
4080 &self,
4081 ) -> Result<impl ExactSizeIterator<Item = (String, Group)> + use<>, Error> {
4082 let revisions = self.file.revisions();
4083 let (entries, staged) = self.children_and_staged()?;
4084 // Taken before the entries are consumed, as in `iter_datasets`.
4085 let staged_members = staged_members(&staged, StagedKind::Group, &entries);
4086 // `None` for a staged member, which has no address to resolve until the
4087 // commit places its header.
4088 let mut members: Vec<(String, Option<u64>)> = Vec::new();
4089 for entry in entries {
4090 if superseded(&staged, &entry.name) {
4091 continue;
4092 }
4093 // A `Group` handle carries no parsed header, so the header that
4094 // classified this child is dropped here rather than held for the
4095 // length of the walk.
4096 let address = self.file.entry_address(&entry)?;
4097 if is_group(&self.file.parse_header(address)?) {
4098 members.push((entry.name, Some(address)));
4099 }
4100 }
4101 members.extend(staged_members.into_iter().map(|name| (name, None)));
4102 let file = Arc::clone(&self.file);
4103 let parent = self.path.clone();
4104 Ok(members.into_iter().map(move |(name, address)| {
4105 let path = LinkName::new(&name).and_then(|leaf| child_path_of(parent.as_ref(), leaf));
4106 let group = match address {
4107 Some(address) => Group::new(Arc::clone(&file), revisions.at(address), path),
4108 // As in `iter_datasets`: a staged member is one path component
4109 // under a group that has a path of its own.
4110 None => Group::pending(
4111 Arc::clone(&file),
4112 path.expect("a staged member is a link name under a parent with a path"),
4113 ),
4114 };
4115 (name, group)
4116 }))
4117 }
4118
4119 /// Read all attributes of this group.
4120 ///
4121 /// Each value takes the [`AttrValue`] variant that describes its on-disk
4122 /// encoding, so the variant reflects the charset, width and dataspace its
4123 /// writer chose rather than the shape of the data alone: a one-element array
4124 /// stays an array, an ASCII string does not arrive as a UTF-8
4125 /// [`String`](AttrValue::String), and a 16-bit integer arrives as
4126 /// [`I16`](AttrValue::I16) rather than widened, a 32-bit float as
4127 /// [`F32`](AttrValue::F32). Prefer the accessors —
4128 /// [`AttrValue::as_str`], [`as_strings`](AttrValue::as_strings),
4129 /// [`as_i64`](AttrValue::as_i64) and the rest — over matching on the variant,
4130 /// unless the encoding is the thing you care about. **The variant may become
4131 /// more specific in a future release** as `AttrValue` grows further ones
4132 /// (variable-length strings, say), and a `_` arm is required regardless
4133 /// because the enum is `#[non_exhaustive]`.
4134 ///
4135 /// An attribute whose datatype has no `AttrValue` representation is omitted
4136 /// from the map rather than reported as an error. Read
4137 /// [`attr_datatypes`](Self::attr_datatypes) to see it.
4138 pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
4139 let hdr = self.file.parse_header(self.header_address()?)?;
4140 self.file.attrs_of(&hdr)
4141 }
4142
4143 /// The exact on-disk [`Datatype`] of every attribute on this group, keyed by
4144 /// name — including compound field offsets, integer widths and enumeration
4145 /// members.
4146 ///
4147 /// This is the type channel to [`attrs`](Self::attrs)'s value channel, the
4148 /// pair a dataset already has in [`Dataset::datatype`] and its `read_*`
4149 /// methods. An [`AttrValue`] is a deliberately lossy view of the value, so an
4150 /// attribute's byte order, sub-width precision, string padding and
4151 /// enumeration members are recoverable only from here — its width is not,
4152 /// since [`attrs`](Self::attrs) keeps that. Its *rank* is not either: that
4153 /// lives in the
4154 /// dataspace, which nothing public exposes, so a rank-2 attribute still
4155 /// reads as a flat `AttrValue` array with no way to recover its shape.
4156 ///
4157 /// **Every attribute message is reported, including the ones `attrs` omits**
4158 /// because no `AttrValue` can carry them, so a name missing from that map can
4159 /// be told from one the object does not have.
4160 ///
4161 /// A **committed** datatype — one created with `H5Tcommit`, what netCDF-4
4162 /// writes for a user-defined type and what h5py writes for
4163 /// `f["t"] = np.dtype(...)` — is stored as a reference to the type's own
4164 /// object header rather than inline, and is resolved to the type it names.
4165 /// What it does *not* carry is the name: two attributes sharing `/mytype`
4166 /// report the same [`Datatype`] as one that spells it out inline.
4167 ///
4168 /// A boolean attribute is the case that needs both channels. The C library
4169 /// gives `H5T_NATIVE_HBOOL` — what h5py writes for every `np.bool_` — a
4170 /// [`Datatype::Enumeration`] of `FALSE` and `TRUE` over an 8-bit base, and
4171 /// `attrs` decodes it through that base, so the value arrives as `0` or `1`
4172 /// and only the datatype records that it was a bool.
4173 pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
4174 Ok(self
4175 .attr_messages()?
4176 .into_iter()
4177 .map(|a| (a.name, a.datatype))
4178 .collect())
4179 }
4180
4181 /// Every attribute message on this group as it is encoded on disk, in the
4182 /// order the header holds them.
4183 ///
4184 /// [`attrs`](Self::attrs) decodes each into an [`AttrValue`], which loses the
4185 /// encoding; this keeps it. Repack copies from here so an attribute survives
4186 /// a rewrite unchanged, and falls back to the decoded map only where the
4187 /// bytes are not position-independent.
4188 pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
4189 let hdr = self.file.parse_header(self.header_address()?)?;
4190 self.file.attr_messages_of(&hdr)
4191 }
4192
4193 /// Returns the dataset `path` names, relative to this group.
4194 ///
4195 /// `path` is one link name or several separated by `/`, and a `path` that begins with `/`
4196 /// resolves from the root group of the file instead. The C library starts an absolute name at
4197 /// the root the same way (`H5G__traverse_real` in `H5Gtraverse.c`, HDF5 1.14.6). A repeated or
4198 /// a trailing separator separates nothing, and a `.` component identifies the group reached so
4199 /// far, so `a/b`, `a//b/` and `./a/b` identify one dataset.
4200 ///
4201 /// The dataset uses the file-wide chunk-cache default. To override the cache
4202 /// for this one dataset, use
4203 /// [`dataset_with_options`](Self::dataset_with_options).
4204 ///
4205 /// # Errors
4206 ///
4207 /// Returns [`FormatError::PathNotFound`] if a component of `path` reaches nothing,
4208 /// [`Error::NotADataset`] if `path` identifies an object that is not a dataset, and
4209 /// [`Error::NotAGroup`] if an object along the way is not a group. Each reports the object by
4210 /// its path from the root group, so one object has one name whichever handle a caller holds,
4211 /// except through a handle an object reference produced, which has no path of its own
4212 /// (issue #365).
4213 pub fn dataset(&self, path: &str) -> Result<Dataset, Error> {
4214 self.dataset_with_options(path, DatasetAccessProperties::new())
4215 }
4216
4217 /// Returns the dataset `path` names, applying per-dataset
4218 /// [`DatasetAccessProperties`] that override the file-wide access defaults (HDF5's
4219 /// `dapl`, `H5Pset_chunk_cache`).
4220 ///
4221 /// `path` resolves as it does for [`dataset`](Self::dataset).
4222 ///
4223 /// # Errors
4224 ///
4225 /// Returns [`FormatError::PathNotFound`], [`Error::NotADataset`] and [`Error::NotAGroup`] on
4226 /// the terms [`dataset`](Self::dataset) sets out.
4227 pub fn dataset_with_options(
4228 &self,
4229 path: &str,
4230 properties: DatasetAccessProperties,
4231 ) -> Result<Dataset, Error> {
4232 let chunk_cache = properties.resolved_chunk_cache(self.file.access_properties.chunk_cache);
4233 let relative = ObjectPath::parse(path);
4234 let child = self.child_path(&relative);
4235 if let Some(child) = &child {
4236 match self.file.staged_object(child).map(|o| o.kind) {
4237 Some(StagedKind::Dataset) => {
4238 return Ok(Dataset::pending(
4239 self.file.clone(),
4240 chunk_cache,
4241 child.clone(),
4242 ));
4243 }
4244 Some(StagedKind::Group) => return Err(Error::NotADataset(child.to_string())),
4245 None => {}
4246 }
4247 }
4248 let revisions = self.file.revisions();
4249 let address = self.resolve_path(&relative)?;
4250 let hdr = self.file.parse_header(address)?;
4251 if !has_message(&hdr, MessageType::DATA_LAYOUT) {
4252 return Err(Error::NotADataset(self.root_relative(&relative)));
4253 }
4254 Ok(Dataset::new(
4255 self.file.clone(),
4256 revisions.at(address),
4257 hdr,
4258 chunk_cache,
4259 child,
4260 ))
4261 }
4262
4263 /// Returns the subgroup `path` names, relative to this group.
4264 ///
4265 /// `path` resolves as it does for [`dataset`](Self::dataset). A relative `path` that spells no
4266 /// link, `""` or `"."`, identifies this group, and an absolute one, `"/"`, identifies the root
4267 /// group.
4268 ///
4269 /// # Errors
4270 ///
4271 /// Returns [`Error::NotAGroup`] if `path` identifies an object that is not a group, the way
4272 /// [`dataset`](Self::dataset) returns [`Error::NotADataset`] for the mirror case, and
4273 /// [`FormatError::PathNotFound`] if a component of `path` reaches nothing.
4274 pub fn group(&self, path: &str) -> Result<Group, Error> {
4275 let relative = ObjectPath::parse(path);
4276 let child = self.child_path(&relative);
4277 if let Some(child) = &child {
4278 match self.file.staged_object(child).map(|o| o.kind) {
4279 Some(StagedKind::Group) => {
4280 return Ok(Group::pending(self.file.clone(), child.clone()));
4281 }
4282 Some(StagedKind::Dataset) => return Err(Error::NotAGroup(child.to_string())),
4283 None => {}
4284 }
4285 }
4286 let revisions = self.file.revisions();
4287 let address = self.resolve_path(&relative)?;
4288 if !is_group(&self.file.parse_header(address)?) {
4289 return Err(Error::NotAGroup(self.root_relative(&relative)));
4290 }
4291 Ok(Group::new(self.file.clone(), revisions.at(address), child))
4292 }
4293
4294 /// Returns the address of the object header `path` identifies, walking from this group, or
4295 /// from the root group where `path` is absolute.
4296 ///
4297 /// [`FileInner::group_child`] looks each component up by name, which reads the one Link
4298 /// message a compact group holds for it and enumerates a version 1 or a dense group
4299 /// (issue #228). A `path` that spells no link identifies the object the walk starts from.
4300 ///
4301 /// The root is the one handle whose object nothing classifies: every other `Group` comes from
4302 /// a lookup that classified it (`File::group`, `Group::group`, `iter_groups`,
4303 /// `object_at_relative`) or re-resolves through `header_address`, which classifies again.
4304 /// `File::root` takes the superblock's word for it, so a root whose header describes some
4305 /// other object is rejected here, under the empty path a root handle stores.
4306 ///
4307 /// # Errors
4308 ///
4309 /// Returns [`FormatError::PathNotFound`] if a component reaches nothing and
4310 /// [`Error::NotAGroup`] if an object along the way is not a group. Both report the object
4311 /// through [`root_relative`](Self::root_relative).
4312 fn resolve_path(&self, path: &ObjectPath<'_>) -> Result<u64, Error> {
4313 // Resolved before the walk whichever way the walk starts, so a handle
4314 // that cannot resolve itself reports that.
4315 let own = self.header_address()?;
4316 // An absolute path starts at the root group, as `H5G__traverse_real`
4317 // starts one there (`H5Gtraverse.c`, HDF5 1.14.6).
4318 let mut address = if path.is_absolute() {
4319 self.file.mirror_root_address()
4320 } else {
4321 own
4322 };
4323 for (walked, &name) in path.components().iter().enumerate() {
4324 address = match self.file.group_child(address, name)? {
4325 ChildLookup::Found(found) => found,
4326 ChildLookup::Absent => {
4327 return Err(Error::Format(FormatError::PathNotFound(
4328 self.root_relative(&path.prefix(walked + 1)),
4329 )));
4330 }
4331 ChildLookup::NotAGroup => {
4332 return Err(Error::NotAGroup(self.root_relative(&path.prefix(walked))));
4333 }
4334 };
4335 }
4336 Ok(address)
4337 }
4338
4339 /// Returns the root-relative path of the object `path` identifies from this group, or from
4340 /// the root group where `path` is absolute, and `None` if this group itself has no resolvable
4341 /// path (reached by object reference).
4342 fn child_path(&self, path: &ObjectPath<'_>) -> Option<ObjectPathBuf> {
4343 Some(self.path.as_ref()?.join_path(path))
4344 }
4345
4346 /// Returns the path an error reports the object `path` reaches by: its path from the root
4347 /// group.
4348 ///
4349 /// One object is reported one way whether the caller holds a [`File`] or a [`Group`] handle,
4350 /// and from the root, so that the caller can go and open what the error reported. For a group
4351 /// reached by object reference, whose own path is `None`, the result is `path` alone, in the
4352 /// form [`ObjectPath`] displays.
4353 fn root_relative(&self, path: &ObjectPath<'_>) -> String {
4354 self.child_path(path)
4355 .map_or_else(|| path.to_string(), |child| child.to_string())
4356 }
4357
4358 /// Creates an empty subgroup at `path` within this group, staged until
4359 /// [`File::commit`], and returns a handle to it.
4360 ///
4361 /// The handle addresses the new group straight away: further groups,
4362 /// datasets, deletions and attributes can be staged through it, and
4363 /// [`group`](Self::group) finds it by name from the same session. `path` may have several
4364 /// components, and every group above the new one has to be in the file already or
4365 /// staged in this session: `create_group("a/b")` with no `a` anywhere stages it, and the
4366 /// commit rejects the whole batch (issue #533). Reading it
4367 /// — its attributes, or a member's data — reports
4368 /// [`Error::NotCommitted`] until the commit,
4369 /// after which the same handle answers for the group in the file. Deleting
4370 /// it before the commit withdraws the staging, and the handle then reports
4371 /// [`Error::StagingWithdrawn`] without
4372 /// answering for whatever else the path may name.
4373 ///
4374 /// **The handle keeps the file's exclusive OS lock alive**, as every
4375 /// [`Group`] and [`Dataset`] handle does, so `let g = root.create_group(..)?`
4376 /// holds what `root.create_group(..)?;` used to drop and a reopen of the
4377 /// file fails until it goes. [`File::close`] states the rule.
4378 ///
4379 /// [`create_group_with`](Self::create_group_with) builds a whole subtree in
4380 /// one call instead.
4381 ///
4382 /// Requires a read-write file ([`File::open_rw`]), else
4383 /// [`Error::ReadOnly`]. A `path` that begins with `/` stages
4384 /// the group from the root, as [`dataset`](Self::dataset) resolves an absolute path, and one
4385 /// that spells no link, `""`, `"."` or `"/"`, is rejected with
4386 /// [`Error::EditUnsupported`], since a write addresses a link. A name the file already
4387 /// links to is refused here with
4388 /// [`Error::EditUnsupported`] unless this
4389 /// session also deletes it — a [replacement](Self::delete) — since there
4390 /// would otherwise be no new object for the handle to address. A name this
4391 /// session already staged a *dataset* at is refused for the same reason —
4392 /// one name cannot mean two objects, and the handle would answer for
4393 /// whichever was staged first — while staging the same group twice is
4394 /// allowed and hands back another handle onto that one group, which is how
4395 /// attributes and children are added to a group already staged.
4396 /// [`delete`](Self::delete) withdraws a staged creation, which frees the
4397 /// name for another.
4398 ///
4399 /// ```no_run
4400 /// # use hdf5_pure::File;
4401 /// # fn main() -> Result<(), hdf5_pure::Error> {
4402 /// let file = File::open_rw("runs.h5")?;
4403 /// let run = file.root().create_group("run2")?;
4404 /// run.create_dataset("signal", |b| {
4405 /// b.with_f64_data(&[1.0, 2.0, 3.0]);
4406 /// })?;
4407 /// file.commit()?;
4408 /// # Ok(())
4409 /// # }
4410 /// ```
4411 pub fn create_group(&self, path: &str) -> Result<Group, Error> {
4412 self.create_group_with(path, |_| {})
4413 }
4414
4415 /// Creates a subgroup at `path` within this group, configuring it through
4416 /// `build` (attributes, nested groups and datasets), staged until
4417 /// [`File::commit`], and returns a handle to it.
4418 ///
4419 /// The closure describes a whole subtree in one call, which is what makes it
4420 /// worth having over [`create_group`](Self::create_group) plus calls on the
4421 /// handle that returns; either way the new group is addressable by name
4422 /// before the commit, and the handle this returns is the same one
4423 /// [`group`](Self::group) would give back.
4424 ///
4425 /// The closure records into a buffer rather than into the file itself, and
4426 /// what it stages is applied together when it returns — so reading the same
4427 /// [`File`] from inside it sees the file as it was before this call, and
4428 /// everything it staged is addressable by name from the moment it returns.
4429 ///
4430 /// The handle this returns keeps the file's exclusive OS lock alive, as
4431 /// every [`Group`] and [`Dataset`] handle does; see [`File::close`].
4432 ///
4433 /// Requires a read-write file ([`File::open_rw`]), else
4434 /// [`Error::ReadOnly`]. Name collisions are rejected on
4435 /// [`create_group`](Self::create_group)'s terms, for the group this creates
4436 /// and for everything the closure stages under it.
4437 ///
4438 /// ```no_run
4439 /// # use hdf5_pure::{AttrValue, File};
4440 /// # fn main() -> Result<(), hdf5_pure::Error> {
4441 /// let file = File::open_rw("runs.h5")?;
4442 /// file.root().create_group_with("run2", |g| {
4443 /// g.set_attr("count", AttrValue::I64(7));
4444 /// g.set_attr("label", AttrValue::String("second".into()));
4445 /// })?;
4446 /// file.commit()?;
4447 /// # Ok(())
4448 /// # }
4449 /// ```
4450 pub fn create_group_with(
4451 &self,
4452 path: &str,
4453 build: impl FnOnce(&mut StagedGroup<'_>),
4454 ) -> Result<Group, Error> {
4455 let child = self.child_edit_path(path)?;
4456 let mut ops = vec![StagedOp::CreateGroup(child.clone())];
4457 build(&mut StagedGroup {
4458 ops: &mut ops,
4459 path: child.clone(),
4460 });
4461 self.apply_staged(ops)?;
4462 Ok(Group::pending(self.file.clone(), child))
4463 }
4464
4465 /// Creates a dataset at `path` within this group, configuring it through `build`
4466 /// (shape, data, chunks, filters, …), staged until [`File::commit`], and
4467 /// returns a handle to it.
4468 ///
4469 /// The handle addresses the new dataset straight away, which is what lets a
4470 /// writer cache one per column while it is still building the schema. It
4471 /// answers [`shape`](Dataset::shape), [`maxshape`](Dataset::maxshape),
4472 /// [`dtype`](Dataset::dtype), [`datatype`](Dataset::datatype),
4473 /// [`is_chunked`](Dataset::is_chunked) and [`filters`](Dataset::filters)
4474 /// from what was staged, and [`append_staged`](Dataset::append_staged) folds
4475 /// more elements into the pending creation. Anything that reads the
4476 /// dataset's bytes — a `read_*`, its attributes, an immediate
4477 /// [`append`](Dataset::append) — reports
4478 /// [`Error::NotCommitted`] until the commit, after which the same handle
4479 /// reads the dataset in the file. Deleting it before the commit withdraws the
4480 /// staging, and the handle then reports [`Error::StagingWithdrawn`] without
4481 /// answering for whatever else the path may name.
4482 ///
4483 /// **The handle keeps the file's exclusive OS lock alive**, as every
4484 /// [`Group`] and [`Dataset`] handle does, so `let ds = root.create_dataset(..)?`
4485 /// holds what `root.create_dataset(..)?;` used to drop and a reopen of the
4486 /// file fails until it goes. [`File::close`] states the rule.
4487 ///
4488 /// As with [`create_group_with`](Self::create_group_with), the closure
4489 /// configures a builder rather than the file, so it may read the same
4490 /// [`File`] — it will see the file as it was before this call.
4491 ///
4492 /// Requires a read-write file ([`File::open_rw`]), else
4493 /// [`Error::ReadOnly`]. `path` may have several components or begin with `/`, and one
4494 /// that spells no link is rejected, all on the terms
4495 /// [`create_group`](Self::create_group) sets out.
4496 /// A name the file already
4497 /// links to is refused here with
4498 /// [`Error::EditUnsupported`] unless this
4499 /// session also deletes it — a [replacement](Self::delete) — since there
4500 /// would otherwise be no new dataset for the handle to address. A name this
4501 /// session already staged a creation at is refused for the same reason:
4502 /// two creations at one path are one name for two objects, and the handle
4503 /// would answer for whichever was staged first. [`delete`](Self::delete)
4504 /// withdraws a staged creation, which frees the name for another.
4505 ///
4506 /// ```no_run
4507 /// # use hdf5_pure::{File, MaxExtent};
4508 /// # fn main() -> Result<(), hdf5_pure::Error> {
4509 /// let file = File::open_rw("runs.h5")?;
4510 /// let mut col = file.root().create_dataset("col", |b| {
4511 /// b.with_f64_data(&[])
4512 /// .with_shape(&[0])
4513 /// .with_maxshape(&[MaxExtent::Unlimited])
4514 /// .with_chunks(&[512]);
4515 /// })?;
4516 /// col.append_staged(|a| {
4517 /// a.append_f64(&[1.0, 2.0, 3.0]);
4518 /// })?;
4519 /// file.commit()?;
4520 /// assert_eq!(col.read_f64()?, vec![1.0, 2.0, 3.0]);
4521 /// # Ok(())
4522 /// # }
4523 /// ```
4524 pub fn create_dataset(
4525 &self,
4526 path: &str,
4527 build: impl FnOnce(&mut DatasetBuilder),
4528 ) -> Result<Dataset, Error> {
4529 let child = self.child_edit_path(path)?;
4530 let mut builder = DatasetBuilder::new(path);
4531 build(&mut builder);
4532 self.apply_staged(vec![StagedOp::CreateDataset {
4533 path: child.clone(),
4534 builder: Box::new(builder),
4535 }])?;
4536 Ok(Dataset::pending(
4537 self.file.clone(),
4538 DatasetAccessProperties::new()
4539 .resolved_chunk_cache(self.file.access_properties.chunk_cache),
4540 child,
4541 ))
4542 }
4543
4544 /// Deletes the object at `path` from this group, staged until
4545 /// [`File::commit`]. See [`create_group`](Self::create_group) for the
4546 /// file-mode rules.
4547 ///
4548 /// Creating a new object at the same path in the same commit *replaces* it:
4549 /// the removal is applied before the addition and one superblock write
4550 /// publishes both, so a rotation costs one commit and the path is never
4551 /// momentarily absent.
4552 ///
4553 /// Deleting an object this session **staged** and has not committed
4554 /// withdraws that staging instead — its attributes, appends and staged
4555 /// children go with it — since there is no link in the file to unlink. Where
4556 /// the deletion was part of a replacement, the plain deletion of the file's
4557 /// own object is what remains. A handle onto the withdrawn creation reports
4558 /// [`Error::StagingWithdrawn`] from then on:
4559 /// it names nothing, and the object the file holds at that path is the one
4560 /// this session is removing.
4561 ///
4562 /// Deleting a group carries its whole subtree away, but only a commit that
4563 /// builds that group *again* can put anything back under it: staging a
4564 /// creation below a deleted path that nothing recreates is a batch `commit`
4565 /// refuses, and until it does the file's own children still own their names.
4566 /// The root itself cannot be deleted.
4567 ///
4568 /// A `path` that spells no link, `""`, `"."` or `"/"`, is rejected with
4569 /// [`Error::EditUnsupported`], since a deletion takes a link. The C library rejects `"."` from
4570 /// `H5L__delete_cb` (`H5Lint.c`) and an empty name in its argument check
4571 /// (`H5VL_setup_name_args` in `H5VLint.c`, HDF5 1.14.6). A `path` that begins with `/` deletes
4572 /// from the root group, as [`create_group`](Self::create_group) stages from there.
4573 ///
4574 /// ```no_run
4575 /// # use hdf5_pure::File;
4576 /// # fn main() -> Result<(), hdf5_pure::Error> {
4577 /// let file = File::open_rw("ring.h5")?;
4578 /// file.root().delete("t0")?;
4579 /// file.root().create_dataset("t0", |b| { b.with_i32_data(&[1, 2, 3]); })?;
4580 /// file.commit()?;
4581 /// # Ok(())
4582 /// # }
4583 /// ```
4584 pub fn delete(&self, path: &str) -> Result<(), Error> {
4585 self.with_child_session(path, |session, child| session.delete(child))
4586 }
4587
4588 /// Add or update an attribute on this group, staged until [`File::commit`].
4589 /// Use [`remove_attr`](Self::remove_attr) to remove one. The
4590 /// [`root`](File::root) group's attributes are edited the same way.
4591 ///
4592 /// Requires a read-write file ([`File::open_rw`]), else
4593 /// [`Error::ReadOnly`]. An attribute set too large
4594 /// for the object header — more than eight attributes, or one whose message
4595 /// the header's 2-byte size field cannot describe — is written to a fractal
4596 /// heap on `commit`, as it is when the whole file is written, and a group
4597 /// already storing its attributes in one is rebuilt.
4598 pub fn set_attr(&self, name: &str, value: AttrValue) -> Result<(), Error> {
4599 self.with_own_session(|session, path| session.set_group_attr(path, name, value))
4600 }
4601
4602 /// Remove an attribute from this group, staged until [`File::commit`].
4603 /// See [`set_attr`](Self::set_attr) for the file-mode rules.
4604 pub fn remove_attr(&self, name: &str) -> Result<(), Error> {
4605 self.with_own_session(|session, path| session.remove_group_attr(path, name))
4606 }
4607
4608 /// Runs `f` with the writable session and the root-relative path of the child `path`
4609 /// identifies.
4610 ///
4611 /// # Errors
4612 ///
4613 /// Returns what [`child_edit_path`](Self::child_edit_path) returns for `path`, and whatever
4614 /// `f` returns.
4615 fn with_child_session<R>(
4616 &self,
4617 path: &str,
4618 f: impl FnOnce(&mut WriteEngine, &ObjectPathBuf) -> Result<R, Error>,
4619 ) -> Result<R, Error> {
4620 let child = self.child_edit_path(path)?;
4621 self.file
4622 .with_engine_mut(Change::Relocating, |session| f(session, &child))
4623 }
4624
4625 /// Refuse an edit staged *through* a handle whose own staged creation has
4626 /// been withdrawn.
4627 ///
4628 /// Every read through such a handle already reports it: they resolve the
4629 /// path, and [`FileInner::locate_staged`] is where that is decided. The
4630 /// staging calls resolve nothing — they address the file by path — so this
4631 /// is where they ask the same question, and without it an edit staged
4632 /// through a withdrawn group would land under whatever the file holds at its
4633 /// path, which is the object the session is deleting.
4634 ///
4635 /// Costs nothing for a handle opened onto an object in the file: those carry
4636 /// no birth generation, so the session is never locked to answer.
4637 fn refuse_if_withdrawn(&self) -> Result<(), Error> {
4638 let Some(path) = self.path.as_ref().filter(|_| self.staged_birth.is_some()) else {
4639 return Ok(());
4640 };
4641 match self.file.staged_standing(path, self.staged_birth) {
4642 Standing::Withdrawn => Err(Error::StagingWithdrawn(path.to_string())),
4643 Standing::Live | Standing::Pending => Ok(()),
4644 }
4645 }
4646
4647 /// Returns the root-relative path of the child `path` identifies, checked as the target of a
4648 /// staged edit, *without* taking the session lock.
4649 ///
4650 /// Paired with [`apply_staged`](Self::apply_staged): the checks run first so
4651 /// a read-only or sealed file is reported before any user closure runs, the
4652 /// closure then runs unlocked, and the lock is taken only to record what it
4653 /// built (issue #200).
4654 ///
4655 /// # Errors
4656 ///
4657 /// Returns [`Error::EditUnsupported`] if `path` spells no link, since a write addresses a
4658 /// link. Returns [`Error::ReadOnly`] if the
4659 /// file is read-only or
4660 /// this group has no path of its own, and the failures
4661 /// [`check_staged_writable`](FileInner::check_staged_writable) and
4662 /// [`refuse_if_withdrawn`](Self::refuse_if_withdrawn) report.
4663 fn child_edit_path(&self, path: &str) -> Result<ObjectPathBuf, Error> {
4664 self.file.check_staged_writable()?;
4665 self.refuse_if_withdrawn()?;
4666 let relative = ObjectPath::parse(path);
4667 if relative.components().is_empty() {
4668 // A write addresses a link, and a path that parses to no
4669 // components spells none. The C library rejects the same call from
4670 // `H5L__delete_cb`, which reports a `"."` as a link it cannot
4671 // delete (`H5Lint.c`, HDF5 1.14.6).
4672 return Err(Error::EditUnsupported(NO_LINK_TO_WRITE));
4673 }
4674 self.child_path(&relative).ok_or(Error::ReadOnly)
4675 }
4676
4677 /// Record already-built edits on the writable session, holding the lock only
4678 /// for the duration of the replay.
4679 ///
4680 /// The file is re-checked here because the closure that produced `ops` ran
4681 /// unlocked and could have closed the file in the meantime; staging into a
4682 /// sealed file would otherwise be silently accepted and then dropped.
4683 fn apply_staged(&self, ops: Vec<StagedOp>) -> Result<(), Error> {
4684 self.file.with_engine_mut(Change::Relocating, |session| {
4685 // All or nothing: one call can carry a whole subtree, and each op is
4686 // validated as it is staged, so a refusal partway must not leave the
4687 // ops before it recorded.
4688 session.stage_atomically(|s| {
4689 for op in ops {
4690 op.apply(s)?;
4691 }
4692 Ok(())
4693 })
4694 })
4695 }
4696
4697 /// Run `f` with the writable session and this group's *own* root-relative
4698 /// path (for attribute edits, which act on the group itself rather than a
4699 /// child). Returns [`Error::ReadOnly`] if the file is read-only or this
4700 /// group has no resolvable path, and [`Error::FileClosed`] once the file is
4701 /// sealed.
4702 fn with_own_session<R>(
4703 &self,
4704 f: impl FnOnce(&mut WriteEngine, &ObjectPathBuf) -> Result<R, Error>,
4705 ) -> Result<R, Error> {
4706 self.refuse_if_withdrawn()?;
4707 let path = self.path.clone().ok_or(Error::ReadOnly)?;
4708 self.file
4709 .with_engine_mut(Change::Relocating, |session| f(session, &path))
4710 }
4711
4712 fn children(&self) -> Result<Vec<GroupEntry>, Error> {
4713 let hdr = self.file.parse_header(self.header_address()?)?;
4714 self.file.group_children(&hdr)
4715 }
4716
4717 /// This group's on-disk children, paired with the children the session has
4718 /// staged under it.
4719 ///
4720 /// A staged name supersedes an on-disk link of the same name: the two can
4721 /// coexist only as a replacement (issue #305), where the commit removes the
4722 /// link before adding the new object, and every by-name lookup already
4723 /// answers with the staged one. A group that is *itself* staged has no links
4724 /// on disk to enumerate, and its members are exactly what is staged under
4725 /// it.
4726 fn children_and_staged(&self) -> Result<(Vec<GroupEntry>, StagedChildren), Error> {
4727 let staged = match self.path.as_ref() {
4728 Some(path) => self.file.staged_children(path),
4729 // A group reached by object reference cannot name itself, so nothing
4730 // can have been staged under it by name either.
4731 None => Vec::new(),
4732 };
4733 match self.children() {
4734 Ok(entries) => Ok((entries, staged)),
4735 Err(Error::NotCommitted(_)) => Ok((Vec::new(), staged)),
4736 Err(e) => Err(e),
4737 }
4738 }
4739}
4740
4741/// The children of one group this session has staged.
4742type StagedChildren = Vec<StagedChild>;
4743
4744/// Whether a staged creation takes `name` over from the link the file holds
4745/// there — which it does only when the same commit removes that link.
4746///
4747/// A creation that merely collides with a surviving link is refused where it is
4748/// staged, so the file's own object is what the name lists as.
4749fn superseded(staged: &[StagedChild], name: &str) -> bool {
4750 staged.iter().any(|c| c.name == name && c.replaces_link)
4751}
4752
4753/// The staged children of `kind` that own their names, given the on-disk links
4754/// they sit beside, in the order they were staged.
4755fn staged_members(staged: &[StagedChild], kind: StagedKind, entries: &[GroupEntry]) -> Vec<String> {
4756 staged
4757 .iter()
4758 .filter(|c| c.kind == kind)
4759 // Either the file has no link of this name, or the commit removes it.
4760 .filter(|c| c.replaces_link || !entries.iter().any(|e| e.name == c.name))
4761 .map(|c| c.name.clone())
4762 .collect()
4763}
4764
4765// ---------------------------------------------------------------------------
4766// Dataset handle
4767// ---------------------------------------------------------------------------
4768
4769/// A [`Dataset`] handle's memo: where its object header sits, what that header
4770/// says, and what both hold as of.
4771///
4772/// The handle names the dataset; this is a memo of what that name resolved to.
4773/// See [`Dataset::resolved`].
4774struct DatasetState {
4775 /// Where the header sits — [`Resolution::address`] is base-adjusted and
4776 /// file-absolute — and the revisions the address and the parse hold as of.
4777 at: Resolution,
4778 header: ObjectHeader,
4779}
4780
4781/// An owned handle to an HDF5 dataset.
4782///
4783/// The handle names the dataset by its root-relative path and remembers where
4784/// that path resolved to and what the header there said. A [`File::commit`]
4785/// rewrites and relocates object headers, and an immediate [`append`](Self::append)
4786/// rewrites one where it stands, so the memo is worked out again on the first
4787/// use after either and the handle goes on answering for the same dataset — see
4788/// [`File::commit`] for the two cases that report instead. That covers an edit
4789/// made through *another* handle to the same dataset as well as through this
4790/// one. Cloning gives a second handle to the same dataset, sharing its chunk
4791/// cache.
4792///
4793/// Three accessors cannot report a handle that no longer resolves, because they
4794/// return no `Result`: [`filters`](Self::filters) and
4795/// [`filter_pipeline`](Self::filter_pipeline) answer empty, the same answer an
4796/// unfiltered dataset gives, and [`is_chunked`](Self::is_chunked) answers
4797/// `false`. Every other reader of the header says what went wrong.
4798///
4799/// # Reading
4800///
4801/// A read returns a flat `Vec<T>` in row-major order. Each typed `read_*` method coerces the
4802/// stored bytes into its own element type, so a read whose element type differs from the stored
4803/// datatype can lose precision.
4804///
4805/// | Method | Returns |
4806/// |---|---|
4807/// | [`read_f64`](Self::read_f64) / [`read_f32`](Self::read_f32) | `Vec<f64>` / `Vec<f32>` |
4808/// | [`read_i8`](Self::read_i8) / [`read_i16`](Self::read_i16) / [`read_i32`](Self::read_i32) / [`read_i64`](Self::read_i64) | signed-integer vectors |
4809/// | [`read_u8`](Self::read_u8) / [`read_u16`](Self::read_u16) / [`read_u32`](Self::read_u32) / [`read_u64`](Self::read_u64) | unsigned-integer vectors |
4810/// | [`read::<T>()`](Self::read) (generic over [`H5Element`]) | `Vec<T>` |
4811/// | [`read_compound::<T>()`](Self::read_compound) (over [`CompoundType`]) | `Vec<T>` |
4812/// | [`read_string`](Self::read_string) | `Vec<String>` (fixed- and variable-length string datasets) |
4813/// | [`read_raw`](Self::read_raw) | `Vec<u8>` (complete unfiltered record bytes) |
4814/// | [`read_array`](Self::read_array) (`ndarray` feature) | `Array<T, D>` (static rank `D`) |
4815/// | [`read_array_dyn`](Self::read_array_dyn) (`ndarray` feature) | `ArrayD<T>` (runtime rank) |
4816///
4817/// ```rust
4818/// # let dir = tempfile::tempdir()?;
4819/// # let path = dir.path().join("output.h5");
4820/// # let mut builder = hdf5_pure::FileBuilder::new();
4821/// # builder.create_dataset("temperature").with_f64_data(&[22.5, 23.1, 21.8]);
4822/// # builder.write(&path)?;
4823/// use hdf5_pure::File;
4824///
4825/// let file = File::open(&path)?;
4826/// let ds = file.dataset("temperature")?;
4827/// let values = ds.read_f64()?;
4828/// # assert_eq!(values, vec![22.5, 23.1, 21.8]);
4829/// # Ok::<(), hdf5_pure::Error>(())
4830/// ```
4831///
4832/// [`read::<T>()`](Self::read) requests delivery as `T`, which asserts nothing about the stored
4833/// datatype, so a lossless read picks the `T` the file stores.
4834/// [`read_compound::<T>()`](Self::read_compound) decodes each element using its exact on-disk
4835/// datatype, reading field offsets from the file. [`read_raw`](Self::read_raw) returns the
4836/// unfiltered record bytes verbatim, which pairs with [`datatype`](Self::datatype) for inspecting
4837/// or re-emitting an arbitrary type. [`read_array`](Self::read_array) and
4838/// [`read_array_dyn`](Self::read_array_dyn) require the `ndarray` feature, which the [ndarray
4839/// guide](crate::_guide::ndarray) covers.
4840///
4841/// Two further reads take variable-length strings beyond [`read_string`](Self::read_string):
4842/// [`read_vlen_strings(options)`](Self::read_vlen_strings) and the streaming
4843/// [`visit_vlen_strings(options, f)`](Self::visit_vlen_strings), bounded by
4844/// [`VlenStringReadOptions`], with [`vlen_string_payload_size()`](Self::vlen_string_payload_size)
4845/// reporting the payload size up front. See the
4846/// [variable-length strings guide](crate::_guide::vlen_strings).
4847///
4848/// # Inspecting a dataset's type
4849///
4850/// Two accessors describe an existing dataset's type:
4851///
4852/// - [`dtype`](Self::dtype) returns a simplified [`DType`] classification.
4853/// - [`datatype`](Self::datatype) returns the full low-level [`Datatype`], including exact compound
4854/// field offsets.
4855pub struct Dataset {
4856 file: Arc<FileInner>,
4857 /// Where this dataset's object header sits and what it says, as of the file
4858 /// revision they were read at. Re-taken on first use after the file changes;
4859 /// see [`Dataset::resolved`].
4860 ///
4861 /// `None` for a dataset this session has *staged* and not yet committed:
4862 /// there is no header to read. Such a handle answers the metadata questions
4863 /// from what was staged and resolves its path on every other use, until a
4864 /// commit gives it a header to memoize.
4865 state: RwLock<Option<Arc<DatasetState>>>,
4866 // Held per-dataset: the chunk index is keyed only by chunk coordinate, so
4867 // a file-level cache would alias chunk addresses across datasets. Shared
4868 // between clones, which are the same dataset: a chunk read through one is
4869 // warm for the other, and an edit through either drops it for both.
4870 chunk_cache: Arc<ChunkCache>,
4871 // The effective chunk-cache config for this dataset: the file-wide default
4872 // or a per-dataset DAPL override. Reported by `chunk_cache_config`.
4873 chunk_cache_config: ChunkCacheConfig,
4874 /// Root-relative path of this dataset, used to address it for write
4875 /// operations on a read-write file, and to find it again after an edit moved
4876 /// it. `None` for a dataset reached by object reference
4877 /// ([`Dataset::dereference`]), which has no resolvable path.
4878 path: Option<ObjectPathBuf>,
4879 /// The session's staged generation when this handle was made onto a staged
4880 /// creation, and `None` for a handle opened onto a dataset in the file.
4881 ///
4882 /// It is what keeps such a handle from being retargeted at a different
4883 /// object: see [`Standing`] and [`FileInner::staged_standing`].
4884 staged_birth: Option<u64>,
4885}
4886
4887impl Clone for Dataset {
4888 /// The clone is a second handle to the same dataset, sharing its chunk cache
4889 /// and resolved as of the same revision.
4890 fn clone(&self) -> Self {
4891 Self {
4892 file: Arc::clone(&self.file),
4893 state: RwLock::new(
4894 self.state
4895 .read()
4896 .unwrap_or_else(PoisonError::into_inner)
4897 .clone(),
4898 ),
4899 chunk_cache: Arc::clone(&self.chunk_cache),
4900 chunk_cache_config: self.chunk_cache_config,
4901 path: self.path.clone(),
4902 staged_birth: self.staged_birth,
4903 }
4904 }
4905}
4906
4907impl std::fmt::Debug for Dataset {
4908 /// Reports the memo as it stands, without re-resolving: a `Debug` that read
4909 /// the file could fail, and one that failed would have nothing to print.
4910 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4911 let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
4912 match state.as_ref() {
4913 Some(state) => f
4914 .debug_struct("Dataset")
4915 .field("messages", &state.header.messages.len())
4916 .finish(),
4917 None => f.debug_struct("Dataset").field("staged", &true).finish(),
4918 }
4919 }
4920}
4921
4922/// How many stored bytes a typed whole-dataset read holds beside its output,
4923/// before rounding the window up to whole chunk bands.
4924///
4925/// The decoded values are the caller's and there is no bound to put on them;
4926/// what this bounds is the *stored* copy standing next to them, which used to be
4927/// the whole dataset over again (issue #289). A mebibyte is small against any
4928/// dataset large enough for that to matter, and large enough that a sweep costs
4929/// reads in the tens rather than the thousands. A dataset that fits inside one
4930/// window is read whole, exactly as before.
4931const TYPED_READ_WINDOW_BYTES: u64 = 1 << 20;
4932
4933/// How many leading-dimension rows a typed whole-dataset read decodes at a time.
4934///
4935/// [`TYPED_READ_WINDOW_BYTES`] of stored bytes, rounded *down* to whole chunk
4936/// bands so that no chunk is ever decoded for two windows, and never fewer than
4937/// one row — or one band, when a single band is already over budget, since a
4938/// window narrower than that would decode the same chunks again.
4939///
4940/// A dataset whose rows have no bytes (a zero inner dimension) has no elements
4941/// to read at all: it answers `NonZeroU64::MAX`, which the caller reads as "one
4942/// window covers it".
4943///
4944/// The answer is a `NonZeroU64` because the sweep advances by it: a window of no
4945/// rows would leave that loop running forever rather than returning something
4946/// wrong, and a test cannot report the difference.
4947fn typed_window_rows(
4948 dl: &DataLayout,
4949 ds: &Dataspace,
4950 elem_size: NonZeroUsize,
4951) -> Result<NonZeroU64, FormatError> {
4952 let mut row_bytes = elem_size.get() as u64;
4953 for &d in ds.dimensions.iter().skip(1) {
4954 row_bytes = row_bytes
4955 .checked_mul(d)
4956 .ok_or(FormatError::OffsetOverflow {
4957 offset: row_bytes,
4958 length: d,
4959 })?;
4960 }
4961 if row_bytes == 0 {
4962 return Ok(NonZeroU64::MAX);
4963 }
4964
4965 let mut rows = (TYPED_READ_WINDOW_BYTES / row_bytes).max(1);
4966 if let DataLayout::Chunked {
4967 chunk_dimensions, ..
4968 } = dl
4969 {
4970 // A chunked layout message carries rank + 1 dimensions, the last being
4971 // the element size, so the first is the leading dimension's chunk extent
4972 // for every layout version this crate parses.
4973 if let Some(band) = chunk_dimensions.first().copied().filter(|&d| d > 0) {
4974 rows = if rows < band {
4975 band
4976 } else {
4977 rows - rows % band
4978 };
4979 }
4980 }
4981 // At least one row always, and at least one whole band when a band applied:
4982 // that arm runs only when `rows >= band`, so the remainder it subtracts
4983 // leaves a band standing.
4984 Ok(NonZeroU64::new(rows).unwrap_or(NonZeroU64::MIN))
4985}
4986
4987impl Dataset {
4988 /// Build a handle for a dataset whose header was found, and read, at `at`.
4989 fn new(
4990 file: Arc<FileInner>,
4991 at: Resolution,
4992 header: ObjectHeader,
4993 chunk_cache_config: ChunkCacheConfig,
4994 path: Option<ObjectPathBuf>,
4995 ) -> Self {
4996 Self {
4997 file,
4998 state: RwLock::new(Some(Arc::new(DatasetState { at, header }))),
4999 chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
5000 chunk_cache_config,
5001 path,
5002 staged_birth: None,
5003 }
5004 }
5005
5006 /// Build a handle for a dataset this session has staged and not yet
5007 /// committed, which has no object header to read.
5008 ///
5009 /// It answers the questions the staged builder already settles — shape,
5010 /// maximum shape, datatype, whether the storage is chunked and which filters
5011 /// it carries — and stages further edits on the pending creation. Everything
5012 /// that needs bytes reports [`Error::NotCommitted`] until [`File::commit`],
5013 /// after which the first use resolves the path and the handle behaves as if
5014 /// it had been opened by name.
5015 fn pending(
5016 file: Arc<FileInner>,
5017 chunk_cache_config: ChunkCacheConfig,
5018 path: ObjectPathBuf,
5019 ) -> Self {
5020 Self {
5021 staged_birth: file.staged_generation(),
5022 file,
5023 state: RwLock::new(None),
5024 chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
5025 chunk_cache_config,
5026 path: Some(path),
5027 }
5028 }
5029
5030 /// What this dataset's staged creation says about itself, `Ok(None)` once it
5031 /// is committed (or when it never was staged), and
5032 /// [`Error::StagingWithdrawn`] when the creation this handle was made onto
5033 /// has been withdrawn — the one answer that is neither the staged record nor
5034 /// the file, because the object at that path is one the session is deleting.
5035 ///
5036 /// Only a handle with no memo can be pending: one that has resolved a header
5037 /// names an object the file already holds, so this costs a lock read rather
5038 /// than a session lock on the common path.
5039 fn staged_meta(&self) -> Result<Option<StagedMeta>, Error> {
5040 if self
5041 .state
5042 .read()
5043 .unwrap_or_else(PoisonError::into_inner)
5044 .is_some()
5045 {
5046 return Ok(None);
5047 }
5048 let Some(path) = self.path.as_ref() else {
5049 return Ok(None);
5050 };
5051 self.file.staged_dataset_view(path, self.staged_birth)
5052 }
5053
5054 /// Refuse an operation that needs this dataset's bytes while it is still
5055 /// staged.
5056 fn refuse_if_pending(&self) -> Result<(), Error> {
5057 match self.staged_meta()? {
5058 // `staged_meta` answers only for a handle with a path.
5059 Some(_) => Err(Error::NotCommitted(reported_path(self.path.as_ref()))),
5060 None => Ok(()),
5061 }
5062 }
5063
5064 /// This dataset's address and parsed header, worked out again if the file
5065 /// has changed since the memo was taken.
5066 ///
5067 /// Returns [`Error::StaleHandle`] for a handle
5068 /// that has no path to re-resolve — one an object reference produced — once
5069 /// a commit has run under it, and the resolution's own error (a
5070 /// `PathNotFound`, say, for a dataset a commit deleted) when the path no
5071 /// longer names anything. A dataset this session has staged and not
5072 /// committed has no header at all, and that is
5073 /// [`Error::NotCommitted`]. A path that now names something other than a
5074 /// dataset is [`Error::NotADataset`], the error opening it afresh reports.
5075 fn resolved(&self) -> Result<Arc<DatasetState>, Error> {
5076 let live = self.file.content_revision();
5077 let memo = {
5078 let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
5079 match state.as_ref() {
5080 Some(state) if state.at.content_revision == live => {
5081 return Ok(Arc::clone(state));
5082 }
5083 Some(state) => Some(state.at),
5084 None => None,
5085 }
5086 };
5087 let at = self
5088 .file
5089 .locate_staged(self.path.as_ref(), memo, self.staged_birth)?;
5090 let header = self.file.parse_header(at.address)?;
5091 // Checked *before* it is memoized. A header installed and then refused is
5092 // the answer every later call short-circuits on, so this handle would
5093 // report `NotADataset` once and then serve the other object's header —
5094 // which a commit replacing a dataset with a group at the same path
5095 // (issue #305) makes reachable.
5096 if !has_message(&header, MessageType::DATA_LAYOUT) {
5097 // Only a path can reach this: an address memo that survived is the
5098 // address of the dataset this handle already read there.
5099 return Err(Error::NotADataset(reported_path(self.path.as_ref())));
5100 }
5101 Ok(self.install(at, header))
5102 }
5103
5104 /// Memoize `header`, read at `at`, as this handle's resolution.
5105 ///
5106 /// Drops the chunk cache: an edit that rewrote this header can have moved
5107 /// the chunk index and the chunks it names, so what the cache holds belongs
5108 /// to the copy the edit replaced.
5109 fn install(&self, at: Resolution, header: ObjectHeader) -> Arc<DatasetState> {
5110 let fresh = Arc::new(DatasetState { at, header });
5111 self.chunk_cache.clear();
5112 let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
5113 // Two threads can re-resolve at once. The older answer must not land on
5114 // top of the newer one, or the newer handle would go on serving a header
5115 // the file has already moved past. A handle that had no memo takes this
5116 // one: any header beats naming nothing.
5117 if state
5118 .as_ref()
5119 .is_none_or(|memo| at.content_revision >= memo.at.content_revision)
5120 {
5121 *state = Some(Arc::clone(&fresh));
5122 }
5123 fresh
5124 }
5125
5126 /// Address of this dataset's object header (base-adjusted, file-absolute).
5127 /// Used to resolve object references that point at this dataset.
5128 pub(crate) fn header_address(&self) -> Result<u64, Error> {
5129 Ok(self.resolved()?.at.address)
5130 }
5131
5132 /// Append `data` to this dataset in place, growing it along its first
5133 /// (unlimited) dimension. Every handle onto the dataset reads the new length
5134 /// afterwards, this one included.
5135 ///
5136 /// The file must have been opened for writing with [`File::open_rw`];
5137 /// a read-only file returns
5138 /// [`Error::ReadOnly`]. The target must be a chunked,
5139 /// rank-1, unlimited, Extensible-Array-indexed dataset: anything else returns
5140 /// [`Error::AppendInPlaceUnsupported`]. A missing path returns
5141 /// [`Error::Format`] with [`FormatError::PathNotFound`], and a header message
5142 /// that does not read or parse returns [`Error::Format`] with the failure the
5143 /// read or the parse produced.
5144 /// Both the dataset's current length and the appended length are
5145 /// unconstrained, on a filtered dataset as much as an unfiltered one: a
5146 /// partial trailing chunk is rewritten into a fresh allocation — decoded,
5147 /// extended and re-encoded when there is a filter pipeline — and its index
5148 /// element is repointed once those bytes are on the disk. The bytes the old
5149 /// chunk occupied are left for [`repack`](crate::repack()).
5150 ///
5151 /// Two things still require a chunk-aligned starting length. A **lossy**
5152 /// pipeline (ZFP, or float D-scale scale-offset) is refused, because
5153 /// re-encoding the trailing chunk would change values that are already
5154 /// committed rather than reproduce them. And a trailing chunk the chunk index
5155 /// does not name — one a writer allocated lazily, which the reference C
5156 /// library does for a chunk it has not written — cannot be read to be grown,
5157 /// so it is refused too; append whole chunks, or use
5158 /// [`append_staged`](Self::append_staged).
5159 ///
5160 /// The append is immediate and crash-atomic (no `commit` needed) — under the
5161 /// default [`SyncPolicy::Always`]. Under [`SyncPolicy::OnClose`] the same
5162 /// writes are made in the same order without the `fsync` barriers between
5163 /// them, so the append is still immediate and still crash-atomic against
5164 /// *this process* failing, but ordering it against power loss is the
5165 /// caller's, through [`File::sync`].
5166 ///
5167 /// A **SWMR** writer ([`File::open_swmr_writer`]) keeps the narrower rule it
5168 /// always had — unfiltered, and chunk-aligned at both ends — because its
5169 /// readers are concurrent by contract and a rewritten trailing chunk is one
5170 /// they could be crossing. It allocates at end-of-file for that same reason:
5171 /// a region this session freed is one a reader may still be inside.
5172 ///
5173 /// Where the new chunks land otherwise depends on how the file records its
5174 /// free space. A default-strategy file spends a hole an earlier commit in
5175 /// this session left; a file that **persists** its free-space managers
5176 /// (including every paged file) spends only space the session has first taken
5177 /// *out* of them, in a rewrite of its own, so no byte an append writes is one
5178 /// a durable manager advertises — a crash can strand the unspent remainder of
5179 /// such a batch, but nothing can be handed out twice. A batch gathers every
5180 /// hole the appended chunk fits in, up to a megabyte of them, so the file
5181 /// grows only when no hole can hold the chunk.
5182 ///
5183 /// A handle reached by object reference ([`dereference`](Self::dereference))
5184 /// has no resolvable path, so it names its dataset by the object-header
5185 /// address it was reached through and can append like any other — until the
5186 /// session stages or commits an edit. A commit can move that header, and the
5187 /// bytes it vacates still parse as the dataset they were, so an append
5188 /// against the old address would land in a header nothing points at. Rather
5189 /// than do that silently, such an append is refused once edits are staged or
5190 /// a commit has run; re-open the dataset by path to keep appending. A
5191 /// path-named handle is unaffected, because the path is resolved afresh every
5192 /// time.
5193 pub fn append<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
5194 let g = self.append_geometry()?;
5195 self.append_batches(g, data.len() as u64, |b, r| {
5196 b.append(&data[r]);
5197 })
5198 }
5199
5200 /// Append raw little-endian element bytes to this dataset in place. Prefer
5201 /// [`append`](Self::append) when the element type is known; see it for the
5202 /// file-mode and eligibility rules.
5203 pub fn append_raw(&mut self, bytes: &[u8]) -> Result<(), Error> {
5204 let g = self.append_geometry()?;
5205 let es = g.element_size;
5206 // Whole-element length is checked before any batch applies, so the
5207 // refusal is atomic (the per-batch validation would only reject the
5208 // final, short batch after earlier ones had durably committed).
5209 if bytes.len() % es != 0 {
5210 return Err(Error::AppendInPlaceUnsupported(
5211 "appended byte length is not a whole number of elements",
5212 ));
5213 }
5214 let total = (bytes.len() / es) as u64;
5215 self.append_batches(g, total, |b, r| {
5216 b.append_raw(&bytes[r.start * es.get()..r.end * es.get()]);
5217 })
5218 }
5219
5220 /// How an append names this dataset to the session: by path when the handle
5221 /// has one, so the session can check the target against its own staged
5222 /// edits, and otherwise by the object-header address the handle was reached
5223 /// through — which is what lets a handle obtained by object reference append
5224 /// at all.
5225 fn append_target(&self) -> Result<AppendTarget<'_>, Error> {
5226 Ok(match &self.path {
5227 Some(path) => AppendTarget::Path(path),
5228 None => AppendTarget::Header(self.resolved()?.at.address),
5229 })
5230 }
5231
5232 /// A [`BufferedAppender`] over this dataset: appended elements are held in
5233 /// memory and written a whole chunk at a time, so a caller appending less
5234 /// than a chunk per call writes to the file once per chunk instead of once
5235 /// per call, and a filtered dataset is never left mid-chunk for the next
5236 /// write to re-encode.
5237 ///
5238 /// Every eligibility rule [`append`](Self::append) applies is applied here,
5239 /// so an ineligible dataset is reported now rather than on the first write.
5240 /// Buffered elements are not in the file until the appender flushes; see
5241 /// [`BufferedAppender`] for the full bargain.
5242 pub fn buffered_appender(&mut self) -> Result<BufferedAppender<'_>, Error> {
5243 BufferedAppender::new(self)
5244 }
5245
5246 /// Register a live `BufferedAppender` on this dataset with the session, so a
5247 /// staged edit that would stop it from flushing is refused at the call that
5248 /// creates the conflict rather than in the appender's `Drop`.
5249 pub(crate) fn claim_for_appender(&self) -> Result<u64, Error> {
5250 let Backend::Edit(m) = &self.file.backend else {
5251 return Err(Error::ReadOnly);
5252 };
5253 m.lock()
5254 .unwrap_or_else(std::sync::PoisonError::into_inner)
5255 .claim_for_appender(self.path.as_ref())
5256 }
5257
5258 /// Release the claim taken by [`claim_for_appender`](Self::claim_for_appender).
5259 pub(crate) fn release_appender_claim(&self, token: u64) {
5260 if let Backend::Edit(m) = &self.file.backend {
5261 m.lock()
5262 .unwrap_or_else(std::sync::PoisonError::into_inner)
5263 .release_appender_claim(token);
5264 }
5265 }
5266
5267 /// Whether this dataset's session is the SWMR writer, whose append rules are
5268 /// a strict subset of the ordinary ones. `false` for a read-only file, which
5269 /// has no session to ask.
5270 pub(crate) fn session_is_swmr(&self) -> bool {
5271 match &self.file.backend {
5272 Backend::Edit(m) => m
5273 .lock()
5274 .unwrap_or_else(std::sync::PoisonError::into_inner)
5275 .is_swmr(),
5276 _ => false,
5277 }
5278 }
5279
5280 /// Immediate in-place append of an already-gathered builder, used by
5281 /// [`BufferedAppender`], whose bytes are materialized in its buffer before
5282 /// it decides how many of them to write. `append_batches` exists for the
5283 /// opposite case — a caller whose bytes are cheaper to build per batch — so
5284 /// this hands the engine one builder and lets it batch the plan.
5285 pub(crate) fn append_prebuilt(&mut self, b: &AppendBuilder) -> Result<(), Error> {
5286 let target = self.append_target()?;
5287 self.file.with_engine_mut(Change::InPlace, |engine| {
5288 engine.append_inplace_gathered(target, b, 4)
5289 })
5290 }
5291
5292 /// Fetch (locating on first use) this dataset's append geometry from the
5293 /// write session, which also applies every refusal that does not depend on
5294 /// the bytes being appended.
5295 pub(crate) fn append_geometry(&self) -> Result<AppendGeometry, Error> {
5296 // An in-place append rewrites bytes where they stand, and a dataset this
5297 // session has only staged has none. Refused here rather than deep in the
5298 // engine, so `append`, `append_raw` and `buffered_appender` all say the
5299 // same thing.
5300 self.refuse_if_pending()?;
5301 let target = self.append_target()?;
5302 self.file
5303 .with_engine_mut(Change::Nothing, |engine| engine.append_geometry(target))
5304 }
5305
5306 /// Immediate in-place append, driven batch by batch. The call is split into
5307 /// aligned batches — the trailing partial chunk is filled first, then
5308 /// whole-chunk batches under the session's byte budget — and `fill` builds
5309 /// each batch's bytes on demand, so a bounded session's peak memory holds
5310 /// one batch rather than the whole call. A session that keeps the whole file
5311 /// resident reports one unbounded batch, so the call stays a single
5312 /// crash-atomic apply there.
5313 ///
5314 /// Every predictable refusal (wrong datatype, ineligible dataset) is raised
5315 /// before the first batch is applied. The cached header and chunk cache are
5316 /// then refreshed so later reads on this handle observe the new length. A
5317 /// filtered dataset sitting on a partial trailing chunk is grown by the
5318 /// first batch, which re-encodes that chunk into a fresh allocation and
5319 /// leaves every later batch starting on a boundary.
5320 fn append_batches(
5321 &mut self,
5322 g: AppendGeometry,
5323 total_elems: u64,
5324 fill: impl Fn(&mut AppendBuilder, std::ops::Range<usize>),
5325 ) -> Result<(), Error> {
5326 // Worked out once for the whole call: every batch names the same
5327 // dataset, and an in-place append does not move it.
5328 let target = self.append_target()?;
5329 let mut dim = g.current_dim;
5330 let mut done = 0u64;
5331 loop {
5332 // An empty append still runs one (empty) engine call, so datatype
5333 // validation happens whether or not there are elements.
5334 let to_boundary = (g.chunk_elems - dim % g.chunk_elems) % g.chunk_elems;
5335 let take = (total_elems - done).min(to_boundary.saturating_add(g.full_batch_elems));
5336 let mut b = AppendBuilder::new();
5337 fill(&mut b, done.to_usize()?..(done + take).to_usize()?);
5338 self.file.with_engine_mut(Change::InPlace, |engine| {
5339 engine.append_inplace_gathered(target, &b, 4)
5340 })?;
5341 dim += take;
5342 done += take;
5343 if done >= total_elems {
5344 break;
5345 }
5346 }
5347 Ok(())
5348 }
5349
5350 /// Overwrite this dataset's values, staged until [`File::commit`]. The new
5351 /// data must match the dataset's existing shape and datatype.
5352 ///
5353 /// The file must have been opened with [`File::open_rw`], else
5354 /// [`Error::ReadOnly`]. Unlike [`append`](Self::append) (immediate), this is
5355 /// a staged edit applied on [`File::commit`].
5356 pub fn write<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
5357 // Build off the lock, as `write_staged` does: `write_into` is trait
5358 // code reached with no lock held, keeping both paths identical.
5359 self.check_staged_edit()?;
5360 let mut builder = DatasetBuilder::new("");
5361 T::write_into(&mut builder, data);
5362 self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
5363 }
5364
5365 /// Overwrite this dataset's values through its full [`DatasetBuilder`],
5366 /// staged until [`File::commit`] — the builder-level counterpart of
5367 /// [`write`](Self::write), and the only one of the two that can carry a
5368 /// **shape**. [`write`](Self::write) sends a flat `&[T]`, so it can overwrite
5369 /// a one-dimensional dataset only; a multi-dimensional one needs
5370 /// [`with_shape`](DatasetBuilder::with_shape) and so comes through here, as
5371 /// do compound, complex, and raw bytes under an explicit datatype.
5372 ///
5373 /// The replacement must match the on-disk datatype and shape exactly; a
5374 /// reshape or retype is refused on [`File::commit`].
5375 ///
5376 /// This overwrites element bytes and nothing else, so a builder asking for
5377 /// more than that is refused by **this call**, before anything is staged:
5378 /// chunking, filters or an extensible shape; an attribute; a fill value; and
5379 /// [`with_path_references`](DatasetBuilder::with_path_references), whose
5380 /// element bytes are placeholder addresses only a newly created dataset can
5381 /// resolve. Set those when the dataset is created.
5382 ///
5383 /// [`with_vlen_strings`](DatasetBuilder::with_vlen_strings) is **not**
5384 /// refused: overwriting a variable-length-string dataset places a fresh
5385 /// global heap collection for the new strings and resolves the staged
5386 /// element references against it. Overwriting the same dataset again
5387 /// reclaims the collection the previous overwrite placed, so rotating its
5388 /// strings in a session does not grow the file without bound. The
5389 /// collections it held when the session *opened* are not reclaimed — a
5390 /// collection can be shared between objects, and only this session's own
5391 /// placements are known not to be, so [`repack`](crate::repack()) is what
5392 /// recovers those.
5393 ///
5394 /// The reference refusal is on the builder, not on the datatype it produces:
5395 /// such a dataset can still be overwritten by supplying element bytes that
5396 /// need no resolving, with
5397 /// [`with_reference_data`](DatasetBuilder::with_reference_data) or
5398 /// [`with_raw_data`](DatasetBuilder::with_raw_data). An object reference
5399 /// supplied that way is screened at `commit` by *address*, against both what
5400 /// the same commit deletes and what it rewrites elsewhere, so it cannot be
5401 /// left naming storage the commit is vacating — the answer a target named as
5402 /// a path already got by name.
5403 ///
5404 /// The file must have been opened with [`File::open_rw`], else
5405 /// [`Error::ReadOnly`].
5406 ///
5407 /// ```no_run
5408 /// # use hdf5_pure::File;
5409 /// # fn main() -> Result<(), hdf5_pure::Error> {
5410 /// let file = File::open_rw("counters.h5")?;
5411 /// let mut ds = file.dataset("ticks")?;
5412 /// // Keep the dataset's own datatype and supply the replacement bytes it
5413 /// // describes — three little-endian 16-bit elements here.
5414 /// let dt = ds.datatype()?;
5415 /// ds.write_staged(|b| {
5416 /// b.with_raw_data(dt, vec![1, 0, 2, 0, 3, 0], 3);
5417 /// })?;
5418 /// file.commit()?;
5419 /// # Ok(())
5420 /// # }
5421 /// ```
5422 /// The closure configures a standalone builder, not the file, so it may read
5423 /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5424 pub fn write_staged(&mut self, build: impl FnOnce(&mut DatasetBuilder)) -> Result<(), Error> {
5425 // Report a read-only, sealed, or unaddressable dataset before running the
5426 // closure, then run it with no lock held; `stage_dataset_write` names the
5427 // builder from the dataset's path (issue #200).
5428 self.check_staged_edit()?;
5429 let mut builder = DatasetBuilder::new("");
5430 build(&mut builder);
5431 self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
5432 }
5433
5434 /// Stage an append to this dataset applied on [`File::commit`] — the staged,
5435 /// index-rebuilding counterpart of the immediate [`append`](Self::append).
5436 ///
5437 /// Unlike [`append`](Self::append) (immediate, amortized `O(1)`,
5438 /// Extensible-Array only), this rebuilds the chunk index on commit and so
5439 /// also grows datasets whose Extensible-Array index is not yet allocated.
5440 /// Like it, a dataset under a **lossy** pipeline (ZFP, or float D-scale
5441 /// scale-offset) whose length is not a whole multiple of its chunk length
5442 /// is refused: growing that trailing chunk would decode and re-encode
5443 /// values that are already committed, changing them. That one is refused
5444 /// *here*, by this call, rather than at the commit — nothing is staged, and
5445 /// edits staged beside it are unaffected.
5446 /// Configure the appended elements through `build` on the
5447 /// [`AppendBuilder`]; repeated calls within the builder concatenate in
5448 /// order. The dataset must be chunked, unlimited along axis 0,
5449 /// Extensible-Array indexed, rank 1, use a re-encodable filter pipeline, and
5450 /// have a single hard link, otherwise [`Error::AppendUnsupported`] is
5451 /// returned on [`File::commit`].
5452 ///
5453 /// The file must have been opened with [`File::open_rw`], else
5454 /// [`Error::ReadOnly`].
5455 /// The closure configures a standalone builder, not the file, so it may read
5456 /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5457 pub fn append_staged(&mut self, build: impl FnOnce(&mut AppendBuilder)) -> Result<(), Error> {
5458 // A dataset *this handle* names and this session has not written has no
5459 // bytes to grow, so the elements are folded into the pending creation
5460 // instead (see the note above). `check_staged_edit` refuses that dataset,
5461 // so ask it only about one the file already holds; the file-mode and path
5462 // gates apply either way.
5463 //
5464 // The distinction is the handle's, not the path's: a handle onto the
5465 // object a staged creation *replaces* is not pending, and the session
5466 // refuses its append rather than growing the replacement under it.
5467 let pending = self.staged_meta()?.is_some();
5468 if pending {
5469 self.file.check_staged_writable()?;
5470 } else {
5471 self.check_staged_edit()?;
5472 }
5473 let mut builder = AppendBuilder::new();
5474 build(&mut builder);
5475 self.with_session_mut(|session, path| {
5476 if pending {
5477 session.stage_dataset_append_pending(path, builder)
5478 } else {
5479 session.stage_dataset_append(path, builder)
5480 }
5481 })
5482 }
5483
5484 /// Add or update an attribute on this dataset, staged until
5485 /// [`File::commit`]. Use [`remove_attr`](Self::remove_attr) to remove one.
5486 /// An attribute set too large for the object header is written to a fractal
5487 /// heap, exactly as [`Group::set_attr`] does.
5488 ///
5489 /// The file must have been opened with [`File::open_rw`], else
5490 /// [`Error::ReadOnly`].
5491 pub fn set_attr(&mut self, name: &str, value: AttrValue) -> Result<(), Error> {
5492 self.refuse_if_pending()?;
5493 self.with_session_mut(|session, path| session.set_dataset_attr(path, name, value))
5494 }
5495
5496 /// Remove an attribute from this dataset, staged until [`File::commit`].
5497 /// See [`set_attr`](Self::set_attr) for the file-mode rules.
5498 pub fn remove_attr(&mut self, name: &str) -> Result<(), Error> {
5499 self.refuse_if_pending()?;
5500 self.with_session_mut(|session, path| session.remove_dataset_attr(path, name))
5501 }
5502
5503 /// Gate a staged edit on this dataset *without* taking the session lock:
5504 /// the file must accept staged edits, this handle must have a resolvable
5505 /// path, and this dataset's elements must be ones the engine owns.
5506 ///
5507 /// The path check belongs here rather than only in
5508 /// [`with_session_mut`](Self::with_session_mut) so that every reason to
5509 /// refuse is reported *before* a user closure runs, not after. A handle
5510 /// reached by object reference ([`dereference`](Self::dereference)) has no
5511 /// path, and would otherwise have its closure run and its result discarded.
5512 ///
5513 /// This is the one gate every data-writing entry point shares — `write`,
5514 /// `write_staged`, `append_staged`, and the staged rewrite behind
5515 /// [`BufferedAppender`] — which is why the external-storage refusal is here
5516 /// and not on any one of them. Attribute edits do not come through here, and
5517 /// are unaffected: they change the object header, not the elements.
5518 fn check_staged_edit(&self) -> Result<(), Error> {
5519 self.file.check_staged_writable()?;
5520 if self.path.is_none() {
5521 return Err(Error::ReadOnly);
5522 }
5523 // Ahead of the external-storage question, which reads a header this
5524 // dataset does not have yet. `append_staged` is the one staged edit that
5525 // *is* supported on a pending dataset, and it re-admits itself below.
5526 self.refuse_if_pending()?;
5527 // The elements of an externally stored dataset are not in this file, and
5528 // the engine writes only this file. Its contiguous layout message records
5529 // no address, so a write took it for never-allocated storage, appended the
5530 // new bytes and pointed the layout at them — leaving the file with two
5531 // contradictory records of where the data lives, and the reference
5532 // library still reading the external files it had not touched. See
5533 // [`has_external_storage`](Self::has_external_storage).
5534 if self.has_external_storage()? {
5535 return Err(Error::EditUnsupported(
5536 "this dataset's elements live in external files (H5Pset_external), which this \
5537engine does not write; writing them into the HDF5 file would leave it disagreeing with \
5538the external files about where the data is. Delete the dataset and create it again in \
5539the same commit to replace it",
5540 ));
5541 }
5542 Ok(())
5543 }
5544
5545 /// Run `f` with the writable session and this dataset's path, then refresh
5546 /// the cached header so a later read on this handle reflects any immediate
5547 /// change (e.g. an append's new dimension). Returns [`Error::ReadOnly`] if
5548 /// the file is read-only or the handle has no resolvable path (reached by
5549 /// object reference).
5550 fn with_session_mut<R>(
5551 &mut self,
5552 f: impl FnOnce(&mut WriteEngine, &ObjectPathBuf) -> Result<R, Error>,
5553 ) -> Result<R, Error> {
5554 let path = self.path.clone().ok_or(Error::ReadOnly)?;
5555 self.file
5556 .with_engine_mut(Change::Relocating, |session| f(session, &path))
5557 }
5558
5559 /// The effective raw chunk-cache configuration for this dataset.
5560 ///
5561 /// This reflects the per-dataset [`DatasetAccessProperties`] override when one
5562 /// was supplied to [`File::dataset_with_options`] /
5563 /// [`Group::dataset_with_options`], otherwise the file-wide default. It is
5564 /// the read-side analogue of HDF5's `H5Pget_chunk_cache`.
5565 pub const fn chunk_cache_config(&self) -> ChunkCacheConfig {
5566 self.chunk_cache_config
5567 }
5568
5569 /// A point-in-time snapshot of this dataset handle's chunk-cache occupancy.
5570 ///
5571 /// Lets callers confirm a chunk-cache configuration (set with
5572 /// [`FileAccessProperties::with_chunk_cache`]) is taking effect: after a
5573 /// chunked read, an enabled cache reports a loaded index and retained
5574 /// chunks; a disabled one (or one over its budget) reports fewer or none.
5575 /// The cache is per-handle — though clones of one handle share it — so a
5576 /// freshly opened [`Dataset`] reports an empty snapshot until its first read.
5577 pub fn chunk_cache_stats(&self) -> ChunkCacheStats {
5578 // Notice any edit first, which is what drops the chunks it invalidated.
5579 // A snapshot taken before that would count chunks no read will ever be
5580 // served, and this exists to say what the cache is holding *for* a read.
5581 // An unresolvable handle has no live cache to report, and an empty
5582 // snapshot is the truthful answer for one.
5583 let _ = self.resolved();
5584 self.chunk_cache.stats()
5585 }
5586
5587 /// Zero this handle's cumulative chunk-cache counters, leaving the retained
5588 /// index and chunks in place.
5589 ///
5590 /// [`ChunkCacheStats`]'s occupancy figures are unaffected — this resets what
5591 /// the cache has *done*, not what it is *holding* — so a caller can measure
5592 /// one read on a cache an earlier read already warmed. The cache is
5593 /// per-handle, though clones of one handle share it, so this resets the
5594 /// counters those clones report too.
5595 pub fn reset_chunk_cache_stats(&self) {
5596 // Resolve first for the same reason [`Self::chunk_cache_stats`] does: an
5597 // edit drops the chunks it invalidated, and those belong in the
5598 // invalidation count of the window being reset, not the next one.
5599 let _ = self.resolved();
5600 self.chunk_cache.reset_stats();
5601 }
5602
5603 /// Returns the shape (dimensions) of the dataset.
5604 ///
5605 /// A dataset this session has staged and not yet committed answers from what
5606 /// was staged, which includes the elements
5607 /// [`append_staged`](Self::append_staged) has folded into it.
5608 pub fn shape(&self) -> Result<Vec<u64>, Error> {
5609 if let Some(meta) = self.staged_meta()? {
5610 return Ok(meta.dimensions);
5611 }
5612 let ds = self.dataspace()?;
5613 Ok(ds.dimensions.clone())
5614 }
5615
5616 /// Returns the dataset's maximum dimensions, when it is extensible.
5617 ///
5618 /// A dimension that grows without bound is [`MaxExtent::Unlimited`].
5619 /// Returns `Ok(None)` for a fixed-shape dataset: one with no
5620 /// maximum-dimensions record, and one whose record is the current shape.
5621 /// A dataset this session has staged and not yet committed reports what was
5622 /// staged.
5623 ///
5624 /// Together with [`is_chunked`](Self::is_chunked) and
5625 /// [`chunk_shape`](Self::chunk_shape), this lets a caller check up front
5626 /// whether a dataset is eligible for
5627 /// [`Dataset::append_staged`](crate::Dataset::append_staged), which needs a
5628 /// chunked dataset whose first maximum dimension is
5629 /// [`MaxExtent::Unlimited`].
5630 ///
5631 /// # Errors
5632 ///
5633 /// Returns [`Error::Format`] if the dataspace message cannot be parsed, and
5634 /// [`Error::StaleHandle`] for a handle an object reference produced once a
5635 /// commit has run under it.
5636 pub fn maxshape(&self) -> Result<Option<Vec<MaxExtent>>, Error> {
5637 if let Some(meta) = self.staged_meta()? {
5638 return Ok(meta.maxshape);
5639 }
5640 Ok(self
5641 .dataspace()?
5642 .extensible_max_dimensions()
5643 .map(<[MaxExtent]>::to_vec))
5644 }
5645
5646 /// Whether the dataset uses chunked storage (as opposed to contiguous or
5647 /// compact). Filtered datasets are always chunked. Returns `false` for a
5648 /// dataset with no data-layout message, for a non-chunked layout, and — like
5649 /// the other accessors that return no `Result` — for a handle that can no
5650 /// longer be resolved. A dataset this session has staged and not yet
5651 /// committed answers with the storage its builder selected.
5652 pub fn is_chunked(&self) -> bool {
5653 if let Ok(Some(meta)) = self.staged_meta() {
5654 return meta.chunked;
5655 }
5656 matches!(self.data_layout(), Ok(DataLayout::Chunked { .. }))
5657 }
5658
5659 /// The dataset's chunk dimensions (one per dataset rank), or `Ok(None)` when
5660 /// the dataset is not chunked. The element-size dimension the on-disk layout
5661 /// appends is stripped, so the result lines up with
5662 /// [`shape`](Self::shape) / [`maxshape`](Self::maxshape).
5663 pub fn chunk_shape(&self) -> Result<Option<Vec<u64>>, Error> {
5664 let DataLayout::Chunked {
5665 chunk_dimensions, ..
5666 } = self.data_layout()?
5667 else {
5668 return Ok(None);
5669 };
5670 let rank = self.dataspace()?.dimensions.len();
5671 if chunk_dimensions.len() <= rank {
5672 return Ok(None);
5673 }
5674 Ok(Some(chunk_dimensions[..rank].to_vec()))
5675 }
5676
5677 /// The HDF5 filter IDs applied to this dataset's chunks, in pipeline
5678 /// (application) order, or an empty vector when the dataset is unfiltered.
5679 /// The IDs are the registered HDF5 filter numbers — e.g. 1 = deflate,
5680 /// 2 = shuffle, 3 = fletcher32, 6 = scale-offset — so a caller can inspect
5681 /// the pipeline without decoding a chunk. A dataset this session has staged
5682 /// and not yet committed reports the pipeline its builder asked for.
5683 pub fn filters(&self) -> Vec<u16> {
5684 if let Ok(Some(meta)) = self.staged_meta() {
5685 return meta.filters.into_iter().map(|(id, _)| id).collect();
5686 }
5687 self.filter_pipeline_parsed()
5688 .map(|p| p.filters.iter().map(|f| f.filter_id).collect())
5689 .unwrap_or_default()
5690 }
5691
5692 /// How and where this dataset's raw data is stored: compact, contiguous,
5693 /// chunked, or virtual.
5694 ///
5695 /// The structured companion to [`is_chunked`](Self::is_chunked) and
5696 /// [`chunk_shape`](Self::chunk_shape), which it subsumes: one call that
5697 /// classifies the layout and, for a [`Layout::Contiguous`] dataset, gives the
5698 /// absolute address and byte size to seek to, or for a [`Layout::Chunked`]
5699 /// dataset the chunk shape and [`ChunkIndex`] kind. This parses only the
5700 /// data-layout message; it never walks the chunk index or reads any data —
5701 /// use [`chunks`](Self::chunks) for per-chunk locations. The curated analogue
5702 /// of `H5Pget_layout`.
5703 ///
5704 /// # Errors
5705 ///
5706 /// Returns [`Error::Format`] if the data-layout message, or the dataspace a
5707 /// chunked dataset's chunk shape comes from, cannot be parsed.
5708 pub fn layout(&self) -> Result<Layout, Error> {
5709 Ok(match self.data_layout()? {
5710 DataLayout::Compact { data } => Layout::Compact {
5711 size: data.len() as u64,
5712 },
5713 DataLayout::Contiguous { address, size } => Layout::Contiguous {
5714 address: self.absolute_address(address)?,
5715 size,
5716 },
5717 DataLayout::Chunked { index, .. } => Layout::Chunked {
5718 // Reuse `chunk_shape` so the two accessors can never disagree on
5719 // how the element-size dimension is stripped.
5720 chunk_shape: self.chunk_shape()?.unwrap_or_default(),
5721 index: ChunkIndex::from(index),
5722 },
5723 DataLayout::Virtual => Layout::Virtual,
5724 })
5725 }
5726
5727 /// The [`ChunkIndex`] kind of this chunked dataset, or `Ok(None)` when the
5728 /// dataset is not chunked.
5729 ///
5730 /// A convenience shortcut for the `index` of [`Layout::Chunked`], for the
5731 /// common up-front append-eligibility check
5732 /// ([`ChunkIndex::supports_inplace_append`]). Complements
5733 /// [`maxshape`](Self::maxshape) and [`chunk_shape`](Self::chunk_shape).
5734 ///
5735 /// # Errors
5736 ///
5737 /// Returns [`Error::Format`] if the data-layout message cannot be parsed.
5738 pub fn chunk_index(&self) -> Result<Option<ChunkIndex>, Error> {
5739 match self.data_layout()? {
5740 DataLayout::Chunked { index, .. } => Ok(Some(ChunkIndex::from(index))),
5741 _ => Ok(None),
5742 }
5743 }
5744
5745 /// Enumerate every allocated chunk of this chunked dataset — one [`Chunk`]
5746 /// (logical offset, absolute file address, on-disk stored size, filter mask)
5747 /// per chunk, in index order.
5748 ///
5749 /// This reads only the chunk index, not the chunk data, so a caller can seek
5750 /// to and decode chunks one at a time without materializing the whole
5751 /// dataset. The curated analogue of `H5Dget_num_chunks` + `H5Dget_chunk_info`
5752 /// (`chunks()?.len()` is the chunk count).
5753 ///
5754 /// Returns `Ok(vec![])` for a chunked dataset whose storage has not been
5755 /// allocated yet (including a not-yet-written dataset that will use a
5756 /// [`ChunkIndex::BTreeV2`] index). Returns `Err` if the dataset is not chunked
5757 /// (check [`layout`](Self::layout) or [`is_chunked`](Self::is_chunked) first),
5758 /// or if its allocated storage is indexed by a [`ChunkIndex::BTreeV2`] index,
5759 /// which has no enumerator yet.
5760 pub fn chunks(&self) -> Result<Vec<Chunk>, Error> {
5761 let rank = self.dataspace()?.dimensions.len();
5762 let base = self.file.addr_offset;
5763 self.raw_chunks()?
5764 .into_iter()
5765 .map(|c| {
5766 Ok(Chunk {
5767 offset: c.offsets.into_iter().take(rank).collect(),
5768 address: base.absolute(c.address)?,
5769 storage_size: c.chunk_size.get(),
5770 filter_mask: c.filter_mask,
5771 })
5772 })
5773 .collect()
5774 }
5775
5776 /// This dataset's filter pipeline as an ordered list of [`Filter`]s — each
5777 /// with its identifier, optional name, optional/mandatory flag, and client
5778 /// data — or an empty vector when the dataset is unfiltered.
5779 ///
5780 /// The detailed companion to [`filters`](Self::filters), which returns just
5781 /// the identifiers. Filters are listed in application (write) order — the
5782 /// on-disk pipeline order, matching [`filters`](Self::filters); a reader
5783 /// inverts them in the *reverse* of this order to decode a chunk. The curated
5784 /// analogue of `H5Pget_nfilters` + `H5Pget_filter2`.
5785 ///
5786 /// A dataset this session has staged and not yet committed reports each
5787 /// filter's identifier and optional flag, and no name or client data: those
5788 /// are derived from the dataset being written (element size, chunk geometry,
5789 /// fill value) when [`File::commit`] writes it.
5790 pub fn filter_pipeline(&self) -> Vec<Filter> {
5791 if let Ok(Some(meta)) = self.staged_meta() {
5792 return meta
5793 .filters
5794 .into_iter()
5795 .map(|(id, is_optional)| Filter {
5796 id,
5797 is_optional,
5798 name: None,
5799 client_data: Vec::new(),
5800 })
5801 .collect();
5802 }
5803 self.filter_pipeline_parsed()
5804 .map(|p| {
5805 p.filters
5806 .into_iter()
5807 .map(|f| Filter {
5808 id: f.filter_id,
5809 is_optional: f.is_optional(),
5810 name: f.name,
5811 client_data: f.client_data,
5812 })
5813 .collect()
5814 })
5815 .unwrap_or_default()
5816 }
5817
5818 /// Returns the absolute file position of `address`, and `None` where a layout message stores
5819 /// the undefined address.
5820 ///
5821 /// # Errors
5822 ///
5823 /// Returns [`FormatError::OffsetOverflow`] if the sum exceeds `u64`.
5824 fn absolute_address(&self, address: Option<StoredAddress>) -> Result<Option<u64>, Error> {
5825 match address {
5826 Some(stored) => Ok(Some(self.file.addr_offset.absolute(stored)?)),
5827 None => Ok(None),
5828 }
5829 }
5830
5831 /// Returns the simplified datatype of the dataset.
5832 ///
5833 /// A dataset this session has staged and not yet committed answers with the
5834 /// datatype its builder settled on.
5835 pub fn dtype(&self) -> Result<DType, Error> {
5836 let dt = self.datatype()?;
5837 Ok(classify_datatype(&dt))
5838 }
5839
5840 /// The size in bytes of one on-disk element of this dataset's datatype —
5841 /// HDF5's datatype storage size (`H5Tget_size`).
5842 ///
5843 /// This is the byte width of a single stored element: 8 for `f64`, the
5844 /// declared length for a fixed-length string, the record size for a compound
5845 /// type, or the reference/descriptor size for a variable-length type (whose
5846 /// payload lives separately in the file's global heaps).
5847 ///
5848 /// Multiplied by the element count from [`shape`](Self::shape), it is the
5849 /// exact number of raw bytes a full [`read_raw`](Self::read_raw)
5850 /// materializes. A caller reading an untrusted file can use it to bound that
5851 /// allocation up front rather than trusting the file's declared extent: a
5852 /// dataset can name a small element count yet a per-element size of billions
5853 /// of bytes, so the product — not the count alone — is what a read allocates.
5854 pub fn element_size(&self) -> Result<u64, Error> {
5855 Ok(u64::from(self.datatype()?.type_size()))
5856 }
5857
5858 /// The raw bytes of this dataset's user-defined fill value, encoded in its
5859 /// datatype, or `None` when no user-defined fill value is set (the library
5860 /// default or an explicitly undefined fill). Reads whichever Fill Value
5861 /// message the header carries — the current `0x0005` (versions 1/2/3) or the
5862 /// legacy `0x0004` — so files from this crate, the reference C library, and
5863 /// h5py are all handled.
5864 pub(crate) fn defined_fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5865 let state = self.resolved()?;
5866 let msg = state
5867 .header
5868 .messages
5869 .iter()
5870 .find(|m| m.msg_type == MessageType::FILL_VALUE)
5871 .or_else(|| {
5872 state
5873 .header
5874 .messages
5875 .iter()
5876 .find(|m| m.msg_type == MessageType::FILL_VALUE_OLD)
5877 });
5878 match msg {
5879 Some(m) => Ok(crate::fill_value::parse_defined_fill_value(
5880 m.msg_type,
5881 &self.file.message_body(m)?,
5882 )?),
5883 None => Ok(None),
5884 }
5885 }
5886
5887 /// The fill bytes that *unallocated storage reads as* — which is not the
5888 /// same question [`defined_fill_bytes`](Self::defined_fill_bytes) answers.
5889 ///
5890 /// A dataset may declare a fill value and also declare, through the Fill
5891 /// Value Write Time, that the library never writes it
5892 /// (`H5D_FILL_TIME_NEVER`). Its unallocated storage then has no defined
5893 /// contents — the C library leaves the read buffer untouched — so this
5894 /// returns `None` and the region reads as deterministic zeros rather than as
5895 /// a value nothing ever put there. `fill_value` still reports the declared
5896 /// value, because it *is* declared; see [`fill_value_is_written`].
5897 ///
5898 /// [`fill_value_is_written`]: crate::fill_value::fill_value_is_written
5899 fn fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5900 let state = self.resolved()?;
5901 let msg = state
5902 .header
5903 .messages
5904 .iter()
5905 .find(|m| m.msg_type == MessageType::FILL_VALUE)
5906 .or_else(|| {
5907 state
5908 .header
5909 .messages
5910 .iter()
5911 .find(|m| m.msg_type == MessageType::FILL_VALUE_OLD)
5912 });
5913 let Some(m) = msg else {
5914 return Ok(None);
5915 };
5916 let body = self.file.message_body(m)?;
5917 if !crate::fill_value::fill_value_is_written(m.msg_type, &body)? {
5918 return Ok(None);
5919 }
5920 Ok(crate::fill_value::parse_defined_fill_value(
5921 m.msg_type, &body,
5922 )?)
5923 }
5924
5925 /// Read the whole dataset with `decode`, sweeping it a row window at a time.
5926 ///
5927 /// A typed whole-dataset read used to be [`read_raw`](Self::read_raw)
5928 /// followed by a decode of the entire buffer, which held the stored bytes
5929 /// and the decoded values at the same time and so peaked at twice the
5930 /// dataset — a caller reading a 4 GiB array needed 8 GiB (issue #289).
5931 /// Decoding a window at a time leaves one window of stored bytes beside the
5932 /// output instead of a whole second copy of it, and the bytes are identical
5933 /// either way: a window returns exactly the rows [`read_raw`](Self::read_raw)
5934 /// would have put there.
5935 ///
5936 /// The output buffer is reserved once, at the size the whole dataset decodes
5937 /// to, so no window reallocates it — a growth step would put a second copy of
5938 /// the output alongside the first and give back what the windowing saved.
5939 ///
5940 /// A dataset that fits in one window — including one with no rows at all — is
5941 /// read whole. The empty case still runs `decode`, because a decoder is also
5942 /// what reports a datatype it cannot read, and a zero-element string dataset
5943 /// must go on failing a numeric read rather than answering with an empty
5944 /// vector.
5945 fn read_whole_typed<T, F>(&self, decode: F) -> Result<Vec<T>, Error>
5946 where
5947 F: Fn(&[u8], &Datatype, &mut Vec<T>) -> Result<(), FormatError>,
5948 {
5949 let dt = self.datatype()?;
5950 let ds = self.dataspace()?;
5951 let dl = self.read_layout()?;
5952 let pipeline = self.filter_pipeline_parsed();
5953 // See `read_raw`: an unparseable fill value message is carried into the
5954 // read rather than failing it up front.
5955 let fill_bytes = self.fill_bytes();
5956 let elem_size = dt.element_size_usize()?;
5957 let fill = match &fill_bytes {
5958 Ok(b) => FillPattern::new(b.as_deref(), elem_size),
5959 Err(_) => FillPattern::UNKNOWN,
5960 };
5961 let spec = RawReadSpec {
5962 layout: &dl,
5963 dataspace: &ds,
5964 datatype: &dt,
5965 pipeline: pipeline.as_ref(),
5966 fill,
5967 };
5968 // What a whole read checks before it reads a byte, and what a sweep would
5969 // otherwise skip: a compact or contiguous layout whose declared size
5970 // disagrees with the dataspace is refused. Reading in windows must not
5971 // turn that into a check that fires only on datasets small enough to be
5972 // read whole.
5973 spec.stored_byte_len()?;
5974
5975 // A window is cut by the *stored* element width, while a decoder slices
5976 // what it is handed by the width of the type it decodes — the base type,
5977 // for an enumeration. Those are the same width for every valid file, an
5978 // enumeration's size being its base's. A crafted file where they differ
5979 // must not get one verdict from a sweep and another from a whole read, so
5980 // it is read whole.
5981 let decoded_width = data_read::effective_numeric(&dt).type_size();
5982
5983 let mut out = Vec::new();
5984 let n0 = ds.dimensions.first().copied().unwrap_or(1);
5985 let rows = typed_window_rows(&dl, &ds, elem_size)?.get();
5986 if n0 <= rows || decoded_width != dt.type_size() {
5987 // No reservation here: `decode` sizes the output from the bytes it
5988 // was handed, which is exact.
5989 let raw = self.file.read_dataset_raw(spec, &self.chunk_cache)?;
5990 decode(&raw, &dt, &mut out)?;
5991 return Ok(out);
5992 }
5993
5994 let values = ds.num_elements().to_usize()?;
5995
5996 // One pass for the whole sweep, not one per window: the sweep visits each
5997 // chunk exactly once, so a window that offered its chunks to a cache
5998 // already full would copy and evict with no later reader for either. The
5999 // cache ends up holding what a whole read would have left it — the
6000 // chunks reached first. See [`CachePass`].
6001 let pass = self.chunk_cache.begin_pass();
6002 let mut start = 0;
6003 while start < n0 {
6004 let count = rows.min(n0 - start);
6005 let raw =
6006 self.file
6007 .read_dataset_raw_rows(spec, &self.chunk_cache, pass, start, count)?;
6008 if start == 0 {
6009 // Reserved once, and only after a window has come back. This size
6010 // comes from the file: sizing an allocation from it before
6011 // reading anything lets a dataspace claiming a terabyte ask for a
6012 // terabyte over a file that cannot serve one row. `try_reserve`
6013 // for the same reason — a file-derived capacity that cannot be
6014 // had is an answer this reader owes its caller, not a panic.
6015 out.try_reserve(values)
6016 .map_err(|source| FormatError::AllocationFailed { values, source })?;
6017 }
6018 decode(&raw, &dt, &mut out)?;
6019 start += count;
6020 }
6021 Ok(out)
6022 }
6023
6024 /// Read all data as `f64` values.
6025 ///
6026 /// This and the other typed whole-dataset readers decode a row window at a
6027 /// time, so the memory standing beside the returned `Vec` is one window of
6028 /// stored bytes — on the order of a mebibyte — rather than a second copy of
6029 /// the dataset. Reading a 4 GiB array costs about 4 GiB, not 8.
6030 ///
6031 /// The values are what [`read_raw`](Self::read_raw) returns, decoded: a
6032 /// dataset stored as a narrower or wider type is converted, so pick the
6033 /// reader that matches the stored type for a lossless read.
6034 pub fn read_f64(&self) -> Result<Vec<f64>, Error> {
6035 self.read_whole_typed(data_read::read_as_f64_into)
6036 }
6037
6038 /// Read all data as `f32` values.
6039 pub fn read_f32(&self) -> Result<Vec<f32>, Error> {
6040 self.read_whole_typed(data_read::read_as_f32_into)
6041 }
6042
6043 /// Read all data as `i32` values.
6044 pub fn read_i32(&self) -> Result<Vec<i32>, Error> {
6045 self.read_whole_typed(data_read::read_as_i32_into)
6046 }
6047
6048 /// Read all data as `i64` values.
6049 pub fn read_i64(&self) -> Result<Vec<i64>, Error> {
6050 self.read_whole_typed(data_read::read_as_i64_into)
6051 }
6052
6053 /// Read all data as `u64` values.
6054 pub fn read_u64(&self) -> Result<Vec<u64>, Error> {
6055 self.read_whole_typed(data_read::read_as_u64_into)
6056 }
6057
6058 /// Read all data as `u8` values.
6059 pub fn read_u8(&self) -> Result<Vec<u8>, Error> {
6060 self.read_whole_typed(data_read::read_as_u8_into)
6061 }
6062
6063 /// Read all data as `i8` values.
6064 pub fn read_i8(&self) -> Result<Vec<i8>, Error> {
6065 self.read_whole_typed(data_read::read_as_i8_into)
6066 }
6067
6068 /// Read all data as `i16` values.
6069 pub fn read_i16(&self) -> Result<Vec<i16>, Error> {
6070 self.read_whole_typed(data_read::read_as_i16_into)
6071 }
6072
6073 /// Read all data as `u16` values.
6074 pub fn read_u16(&self) -> Result<Vec<u16>, Error> {
6075 self.read_whole_typed(data_read::read_as_u16_into)
6076 }
6077
6078 /// Read all data as `u32` values.
6079 pub fn read_u32(&self) -> Result<Vec<u32>, Error> {
6080 self.read_whole_typed(data_read::read_as_u32_into)
6081 }
6082
6083 /// Read all data as `String` values.
6084 ///
6085 /// Fixed-length and variable-length HDF5 string datasets are both
6086 /// supported. Use [`read_vlen_strings`](Self::read_vlen_strings) when
6087 /// variable-length allocation limits are required.
6088 pub fn read_string(&self) -> Result<Vec<String>, Error> {
6089 let dt = self.datatype()?;
6090 if vl_data::is_vlen_string_datatype(&dt) {
6091 self.read_vlen_strings(VlenStringReadOptions::default())
6092 } else {
6093 let raw = self.read_raw()?;
6094 Ok(data_read::read_as_strings(&raw, &dt)?)
6095 }
6096 }
6097
6098 /// Return the total bytes referenced by this VL string dataset.
6099 ///
6100 /// This is the payload equivalent of HDF5's `H5Dvlen_get_buf_size`: it
6101 /// excludes `Vec<String>` and `String` allocation metadata.
6102 pub fn vlen_string_payload_size(&self) -> Result<u64, Error> {
6103 let datatype = self.datatype()?;
6104 if !vl_data::is_vlen_string_datatype(&datatype) {
6105 return Err(FormatError::TypeMismatch {
6106 expected: "VariableLength string",
6107 actual: "non-VariableLength string",
6108 }
6109 .into());
6110 }
6111 let dataspace = self.dataspace()?;
6112 let raw = self.read_raw()?;
6113 Ok(vl_data::vlen_string_payload_size(
6114 &raw,
6115 dataspace.num_elements(),
6116 self.file.offset_size(),
6117 )?)
6118 }
6119
6120 /// Read a VL string dataset with explicit allocation limits.
6121 ///
6122 /// Both limits are checked before any string payload is materialized.
6123 pub fn read_vlen_strings(&self, options: VlenStringReadOptions) -> Result<Vec<String>, Error> {
6124 let mut strings = Vec::new();
6125 self.visit_vlen_strings(options, |string| strings.push(string.to_owned()))?;
6126 Ok(strings)
6127 }
6128
6129 /// Visit a VL string dataset one element at a time.
6130 ///
6131 /// The string slice passed to `visitor` is valid only for the duration of
6132 /// that callback. This avoids retaining all decoded string payloads at once.
6133 ///
6134 /// On a read-write file ([`File::open_rw`]) the
6135 /// visitor runs while the file's engine lock is held, so it must not read
6136 /// or write through this file (or a clone / handle of it) — doing so
6137 /// deadlocks. Collect values and act on them after the call instead.
6138 pub fn visit_vlen_strings<F>(
6139 &self,
6140 options: VlenStringReadOptions,
6141 visitor: F,
6142 ) -> Result<(), Error>
6143 where
6144 F: FnMut(&str),
6145 {
6146 let datatype = self.datatype()?;
6147 if !vl_data::is_vlen_string_datatype(&datatype) {
6148 return Err(FormatError::TypeMismatch {
6149 expected: "VariableLength string",
6150 actual: "non-VariableLength string",
6151 }
6152 .into());
6153 }
6154 let dataspace = self.dataspace()?;
6155 if let Some(limit) = options.max_elements()
6156 && dataspace.num_elements() > limit as u64
6157 {
6158 return Err(FormatError::VariableLengthElementLimitExceeded {
6159 limit,
6160 actual: dataspace.num_elements(),
6161 }
6162 .into());
6163 }
6164 let raw = self.read_raw()?;
6165 self.file.with_source(|source| {
6166 Ok(vl_data::visit_vl_strings_from_source(
6167 source,
6168 &raw,
6169 dataspace.num_elements(),
6170 self.file.offset_size(),
6171 self.file.length_size(),
6172 self.file.addr_offset,
6173 options,
6174 visitor,
6175 )?)
6176 })
6177 }
6178
6179 /// Read a VL string dataset's exact heap bytes, preserving the
6180 /// null-vs-empty distinction and never lossily decoding.
6181 ///
6182 /// Unlike [`read_vlen_strings`](Self::read_vlen_strings), which returns
6183 /// `String`s via `from_utf8_lossy` and so cannot reproduce embedded NULs or
6184 /// non-UTF-8 payloads, this yields each element's raw bytes (or a null
6185 /// marker). It underpins faithful rewriting (e.g. repack) of VL strings.
6186 pub(crate) fn read_vlen_string_bytes(
6187 &self,
6188 options: VlenStringReadOptions,
6189 ) -> Result<Vec<vl_data::VlByteObject>, Error> {
6190 let datatype = self.datatype()?;
6191 if !vl_data::is_vlen_string_datatype(&datatype) {
6192 return Err(FormatError::TypeMismatch {
6193 expected: "VariableLength string",
6194 actual: "non-VariableLength string",
6195 }
6196 .into());
6197 }
6198 let dataspace = self.dataspace()?;
6199 if let Some(limit) = options.max_elements()
6200 && dataspace.num_elements() > limit as u64
6201 {
6202 return Err(FormatError::VariableLengthElementLimitExceeded {
6203 limit,
6204 actual: dataspace.num_elements(),
6205 }
6206 .into());
6207 }
6208 let raw = self.read_raw()?;
6209 self.file.with_source(|source| {
6210 Ok(vl_data::read_vl_byte_objects_from_source(
6211 source,
6212 &raw,
6213 dataspace.num_elements(),
6214 self.file.offset_size(),
6215 self.file.length_size(),
6216 self.file.addr_offset,
6217 1, // a VL string's base type is a single byte
6218 options,
6219 )?)
6220 })
6221 }
6222
6223 /// Read every element of a *non-string* variable-length (sequence) dataset as
6224 /// its exact heap bytes, alongside the base-type element size in bytes.
6225 ///
6226 /// Each element's heap object holds `length * element_size` bytes, where
6227 /// `length` is the stored element count and `element_size` is the byte width
6228 /// of the sequence's base type. Returning the raw bytes (not decoded values)
6229 /// keeps a faithful rewrite (repack) byte-exact for any base type whose bytes
6230 /// carry no embedded heap or file addresses. Errors with a
6231 /// [`TypeMismatch`](crate::FormatError::TypeMismatch) if the datatype is not a
6232 /// non-string VL datatype.
6233 pub(crate) fn read_vlen_sequence_bytes(
6234 &self,
6235 options: VlenStringReadOptions,
6236 ) -> Result<(Vec<vl_data::VlByteObject>, usize), Error> {
6237 let datatype = self.datatype()?;
6238 let Datatype::VariableLength { base_type, .. } = &datatype else {
6239 return Err(FormatError::TypeMismatch {
6240 expected: "non-string VariableLength",
6241 actual: "non-VariableLength",
6242 }
6243 .into());
6244 };
6245 if vl_data::is_vlen_string_datatype(&datatype) {
6246 return Err(FormatError::TypeMismatch {
6247 expected: "non-string VariableLength",
6248 actual: "VariableLength string",
6249 }
6250 .into());
6251 }
6252 let element_size = base_type.type_size() as usize;
6253 if element_size == 0 {
6254 return Err(
6255 FormatError::VlDataError("non-string VL base type has zero size".into()).into(),
6256 );
6257 }
6258 let dataspace = self.dataspace()?;
6259 if let Some(limit) = options.max_elements()
6260 && dataspace.num_elements() > limit as u64
6261 {
6262 return Err(FormatError::VariableLengthElementLimitExceeded {
6263 limit,
6264 actual: dataspace.num_elements(),
6265 }
6266 .into());
6267 }
6268 let raw = self.read_raw()?;
6269 let objects = self.file.with_source(|source| {
6270 vl_data::read_vl_byte_objects_from_source(
6271 source,
6272 &raw,
6273 dataspace.num_elements(),
6274 self.file.offset_size(),
6275 self.file.length_size(),
6276 self.file.addr_offset,
6277 element_size,
6278 options,
6279 )
6280 })?;
6281 Ok((objects, element_size))
6282 }
6283
6284 /// Read a dataset whose datatype *contains* variable-length references
6285 /// without being variable-length itself — a compound with a VL member, or an
6286 /// array of them (issue #201).
6287 ///
6288 /// Returns everything a rewrite needs: the element bytes, where each embedded
6289 /// reference sits within them, and the heap payload each one names. That lets
6290 /// the writer re-stage the payloads into a new file's global heap and rewrite
6291 /// the references in place, which is what keeps a rewrite from carrying the
6292 /// source file's heap addresses into the destination.
6293 ///
6294 /// The references are resolved one slot at a time, so `options`' limits apply
6295 /// per slot rather than across the whole dataset.
6296 pub(crate) fn read_embedded_vlen_bytes(
6297 &self,
6298 slots: &[vl_data::EmbeddedVlSlot],
6299 options: VlenStringReadOptions,
6300 ) -> Result<vl_data::EmbeddedVlData, Error> {
6301 let stride = self.datatype()?.type_size() as usize;
6302 let dataspace = self.dataspace()?;
6303 let n = dataspace.num_elements();
6304 if let Some(limit) = options.max_elements()
6305 && n > limit as u64
6306 {
6307 return Err(
6308 FormatError::VariableLengthElementLimitExceeded { limit, actual: n }.into(),
6309 );
6310 }
6311
6312 // A zero-element dataset owns no element bytes, so there is no storage
6313 // to visit: skip the read rather than open a dataset the C library
6314 // left unallocated only to receive the same empty buffer back.
6315 let raw = if n == 0 { Vec::new() } else { self.read_raw()? };
6316 let n_usize = n.to_usize()?;
6317 let needed = n_usize
6318 .checked_mul(stride)
6319 .ok_or(FormatError::OffsetOverflow {
6320 offset: n,
6321 length: stride as u64,
6322 })?;
6323 if raw.len() < needed {
6324 return Err(FormatError::UnexpectedEof {
6325 expected: needed,
6326 available: raw.len(),
6327 }
6328 .into());
6329 }
6330
6331 let mut offsets = Vec::with_capacity(n_usize * slots.len());
6332 let mut objects = Vec::with_capacity(n_usize * slots.len());
6333 for slot in slots {
6334 // Gather this slot's reference from every element into a dense buffer,
6335 // which is the shape the shared VL reader consumes. Each slot has its
6336 // own base-type width, so they are resolved a slot at a time rather
6337 // than in one pass.
6338 let mut dense = Vec::with_capacity(n_usize * VL_REF_SIZE);
6339 for e in 0..n_usize {
6340 let at = e * stride + slot.byte_offset;
6341 dense.extend_from_slice(&raw[at..at + VL_REF_SIZE]);
6342 offsets.push(at);
6343 }
6344 let resolved = self.file.with_source(|source| {
6345 vl_data::read_vl_byte_objects_from_source(
6346 source,
6347 &dense,
6348 n,
6349 self.file.offset_size(),
6350 self.file.length_size(),
6351 self.file.addr_offset,
6352 slot.element_size,
6353 options,
6354 )
6355 })?;
6356 objects.extend(resolved);
6357 }
6358 Ok(vl_data::EmbeddedVlData {
6359 raw,
6360 offsets,
6361 objects,
6362 })
6363 }
6364
6365 /// Read all attributes of this dataset.
6366 ///
6367 /// The variant of each value describes its on-disk encoding; see
6368 /// [`Group::attrs`] for what that means for matching on it, and prefer the
6369 /// [`AttrValue`] accessors.
6370 pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
6371 self.file.attrs_of(&self.resolved()?.header)
6372 }
6373
6374 /// The exact on-disk [`Datatype`] of every attribute on this dataset, keyed
6375 /// by name.
6376 ///
6377 /// See [`Group::attr_datatypes`] for what this channel carries that
6378 /// [`attrs`](Self::attrs) cannot, including how a boolean attribute is
6379 /// recognized. Note that this describes the *attributes*, not the dataset's
6380 /// own element type — that is [`datatype`](Self::datatype).
6381 pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
6382 Ok(self
6383 .attr_messages()?
6384 .into_iter()
6385 .map(|a| (a.name, a.datatype))
6386 .collect())
6387 }
6388
6389 /// Every attribute message on this dataset as it is encoded on disk, in the
6390 /// order the header holds them.
6391 ///
6392 /// See [`Group::attr_messages`] for why repack reads these rather than the
6393 /// decoded map.
6394 pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
6395 self.file.attr_messages_of(&self.resolved()?.header)
6396 }
6397
6398 /// Returns the exact HDF5 datatype, including compound field offsets and
6399 /// total record size.
6400 ///
6401 /// A committed (`H5Tcommit`) element type — what netCDF-4 writes for a
6402 /// user-defined type, and what h5py writes for
6403 /// `create_dataset(..., dtype=f["t"])` — is stored as a reference to the
6404 /// datatype's own object header and is resolved to the type it names.
6405 pub fn datatype(&self) -> Result<Datatype, Error> {
6406 if let Some(meta) = self.staged_meta()? {
6407 return Ok(meta.datatype);
6408 }
6409 let state = self.resolved()?;
6410 let msg = find_message(&state.header, MessageType::DATATYPE)?;
6411 let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
6412 Ok(dt)
6413 }
6414
6415 /// The object-header address of this dataset's committed (shared) element
6416 /// type, or `None` when the type is written in the dataset's own header.
6417 ///
6418 /// [`datatype`](Self::datatype) resolves it either way, so this is for
6419 /// callers that must *reproduce* the dataset: writing the resolved type back
6420 /// inline loses the link every C-library reader reports by name, and the
6421 /// address is what says which committed object to name instead.
6422 pub(crate) fn committed_datatype_address(&self) -> Result<Option<u64>, Error> {
6423 let state = self.resolved()?;
6424 let msg = find_message(&state.header, MessageType::DATATYPE)?;
6425 self.file.shared_target_address(msg)
6426 }
6427
6428 pub(crate) fn dataspace(&self) -> Result<Dataspace, Error> {
6429 let state = self.resolved()?;
6430 let msg = find_message(&state.header, MessageType::DATASPACE)?;
6431 Ok(Dataspace::parse(
6432 &self.file.message_body(msg)?,
6433 self.file.length_size(),
6434 )?)
6435 }
6436
6437 pub(crate) fn data_layout(&self) -> Result<DataLayout, Error> {
6438 let state = self.resolved()?;
6439 let msg = find_message(&state.header, MessageType::DATA_LAYOUT)?;
6440 Ok(DataLayout::parse(
6441 &msg.data,
6442 self.file.offset_size(),
6443 self.file.length_size(),
6444 )?)
6445 }
6446
6447 /// A handle that can no longer be resolved reports no pipeline, the same
6448 /// answer an unfiltered dataset gives: this feeds the two infallible
6449 /// accessors ([`filters`](Self::filters) and
6450 /// [`filter_pipeline`](Self::filter_pipeline)), which have no way to say
6451 /// why. Every caller that *can* say uses a `Result` reader instead.
6452 pub(crate) fn filter_pipeline_parsed(&self) -> Option<FilterPipeline> {
6453 let state = self.resolved().ok()?;
6454 let msg = state
6455 .header
6456 .messages
6457 .iter()
6458 .find(|m| m.msg_type == MessageType::FILTER_PIPELINE)?;
6459 let body = self.file.message_body(msg).ok()?;
6460 FilterPipeline::parse(&body).ok()
6461 }
6462
6463 /// Whether this dataset's element bytes live in files outside this one
6464 /// (`H5Pset_external`, the External Data Files header message, type 7).
6465 ///
6466 /// Such a dataset carries a *contiguous* layout message whose data address
6467 /// is undefined — the same encoding a never-written dataset uses — so a
6468 /// caller that reads "no address" as "no storage" would call a dataset full
6469 /// of data empty. This crate does not follow the external files, so the only
6470 /// safe answer is to refuse: [`read_layout`](Self::read_layout) refuses a
6471 /// read of one rather than answering its fill value, and `repack` refuses to
6472 /// reproduce it without its data.
6473 pub(crate) fn has_external_storage(&self) -> Result<bool, Error> {
6474 Ok(self
6475 .resolved()?
6476 .header
6477 .messages
6478 .iter()
6479 .any(|m| m.msg_type == MessageType::EXTERNAL_DATA_FILES))
6480 }
6481
6482 /// The data layout to read element bytes through, as opposed to the one
6483 /// [`layout`](Self::layout) reports.
6484 ///
6485 /// [`data_layout`](Self::data_layout) answers what the message records;
6486 /// this answers whether those bytes are reachable at all. The two differ for
6487 /// exactly one kind of dataset: an externally stored one, whose contiguous layout
6488 /// with no address is also what a never-written dataset carries, so reading
6489 /// it would answer the fill value for every element it holds. Introspection
6490 /// keeps answering — the address-less layout is the evidence a caller needs
6491 /// — while every path that turns a layout into bytes comes through here and
6492 /// refuses.
6493 fn read_layout(&self) -> Result<DataLayout, Error> {
6494 if self.has_external_storage()? {
6495 return Err(FormatError::UnsupportedExternalStorage.into());
6496 }
6497 self.data_layout()
6498 }
6499
6500 /// Returns one [`ChunkInfo`](crate::chunked_read::ChunkInfo) per allocated chunk of this
6501 /// chunked dataset, the records the chunked reader walks before decompressing.
6502 ///
6503 /// Every address is in the frame the chunk index stores it in, so a caller reading the
6504 /// still-compressed chunk bytes from the file's own source adds the base address first,
6505 /// as `repack` does when it copies a chunk without decoding it.
6506 ///
6507 /// Returns `Err` if the layout is not chunked. Returns `Ok(vec![])` for an
6508 /// empty / never-allocated chunked dataset (no index address). Covers every
6509 /// index type the reader supports (v3 B-tree and v4 single-chunk, implicit,
6510 /// fixed-array, and extensible-array).
6511 pub(crate) fn raw_chunks(&self) -> Result<Vec<crate::chunked_read::ChunkInfo>, Error> {
6512 let DataLayout::Chunked {
6513 chunk_dimensions,
6514 index,
6515 ..
6516 } = self.data_layout()?
6517 else {
6518 return Err(Error::Format(crate::error::FormatError::ChunkedReadError(
6519 "chunk enumeration requires a chunked dataset".into(),
6520 )));
6521 };
6522 // An undefined index address means no storage is allocated yet.
6523 if index.address().is_none() {
6524 return Ok(Vec::new());
6525 }
6526 let dataspace = self.dataspace()?;
6527 let elem_size = self.datatype()?.element_size_usize()?;
6528 let base = self.file.addr_offset;
6529 // The chunk index stores base-relative addresses, in its root and in every internal
6530 // node, so the walk runs over a view of the file framed at the base. Every address it
6531 // reports stays in that frame.
6532 self.file.with_source(|source| {
6533 if base.is_zero() {
6534 return Ok(crate::chunked_read::collect_chunks_for_layout_from_source(
6535 source,
6536 index,
6537 &chunk_dimensions,
6538 &dataspace,
6539 elem_size.get() as u64,
6540 self.file.offset_size(),
6541 self.file.length_size(),
6542 )?);
6543 }
6544 let framed = BaseOffsetSource {
6545 inner: source,
6546 base,
6547 };
6548 Ok(crate::chunked_read::collect_chunks_for_layout_from_source(
6549 &framed,
6550 index,
6551 &chunk_dimensions,
6552 &dataspace,
6553 elem_size.get() as u64,
6554 self.file.offset_size(),
6555 self.file.length_size(),
6556 )?)
6557 })
6558 }
6559
6560 /// The raw `FilterPipeline` message bytes from this dataset's object header,
6561 /// if it has one. Repack reuses this verbatim so that every filter — including
6562 /// ones this crate cannot itself apply (ZFP, SZIP, unknown) — is reproduced
6563 /// byte-for-byte in the repacked file's pipeline message.
6564 pub(crate) fn filter_pipeline_message_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
6565 let state = self.resolved()?;
6566 let Some(msg) = state
6567 .header
6568 .messages
6569 .iter()
6570 .find(|m| m.msg_type == MessageType::FILTER_PIPELINE)
6571 else {
6572 return Ok(None);
6573 };
6574 // A shared pipeline message's record body is an address, not a pipeline;
6575 // its resolved content is what a copy must carry. The content itself is
6576 // position-independent, so copying it verbatim stays faithful.
6577 Ok(Some(self.file.message_body(msg)?.into_owned()))
6578 }
6579
6580 /// Read the dataset's exact unfiltered element bytes.
6581 ///
6582 /// For compound datasets this preserves all file padding and uses the
6583 /// offsets reported by [`datatype`](Self::datatype).
6584 ///
6585 /// A dataset whose elements live in external files (`H5Pset_external`) is
6586 /// refused with `FormatError::UnsupportedExternalStorage` rather than read as
6587 /// unallocated storage, which is what its layout message alone says. This
6588 /// applies to every read here; its shape, datatype, and
6589 /// [`layout`](Self::layout) still read.
6590 pub fn read_raw(&self) -> Result<Vec<u8>, Error> {
6591 let dt = self.datatype()?;
6592 let ds = self.dataspace()?;
6593 let dl = self.read_layout()?;
6594 // The data layout's on-disk addresses are left base-relative here;
6595 // `read_dataset_raw` applies the base address centrally (for both
6596 // contiguous and chunked layouts) by reading from a base-relative view of
6597 // the file.
6598 let pipeline = self.filter_pipeline_parsed();
6599 // A fill value message this parser cannot read does not, by itself,
6600 // make the dataset unreadable: it only decides what *unallocated*
6601 // storage looks like. Carry the uncertainty into the read and let it
6602 // fail there, and only there. `Dataset::fill_value` still reports the
6603 // parse error to a caller asking about the value.
6604 let fill_bytes = self.fill_bytes();
6605 let fill = match &fill_bytes {
6606 Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6607 Err(_) => FillPattern::UNKNOWN,
6608 };
6609 let spec = RawReadSpec {
6610 layout: &dl,
6611 dataspace: &ds,
6612 datatype: &dt,
6613 pipeline: pipeline.as_ref(),
6614 fill,
6615 };
6616 Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?)
6617 }
6618
6619 /// Read the raw element bytes of the row window `[start_row, start_row + num_rows)`
6620 /// — a range along the first dimension.
6621 ///
6622 /// The windowed companion to [`read_raw`](Self::read_raw): only the storage the
6623 /// window overlaps is read — a bounded sub-read for compact and contiguous
6624 /// layouts, just the overlapping chunks for chunked layouts — so peak memory
6625 /// scales with the window, not the dataset. Use it to stream a large dataset a
6626 /// fixed number of rows at a time.
6627 ///
6628 /// Each row keeps its full inner shape, and the bytes match what
6629 /// [`read_raw`](Self::read_raw) produces for those rows, so the typed
6630 /// `read_*_rows` helpers decode a window like their whole-dataset forms. The
6631 /// window is clamped to the first dimension: a read past the end returns only
6632 /// the rows that exist, and a 0-D scalar is one row. A window covering every
6633 /// row delegates to [`read_raw`](Self::read_raw), so a full-range window never
6634 /// costs more than a whole read. Variable-length string
6635 /// bytes are heap references, not text — use
6636 /// [`read_string_rows`](Self::read_string_rows).
6637 pub fn read_raw_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6638 let dt = self.datatype()?;
6639 let ds = self.dataspace()?;
6640 let dl = self.read_layout()?;
6641
6642 let n0 = ds.dimensions.first().copied().unwrap_or(1);
6643 let start = start_row.min(n0);
6644 let count = num_rows.min(n0 - start);
6645
6646 // A window covering every row is exactly a whole read: delegate, so it
6647 // never costs a window-shaped copy on top of one.
6648 // See `read_raw`: an unparseable fill value message is carried into the
6649 // read rather than failing it up front.
6650 let parsed_fill = self.fill_bytes();
6651 let fill = match &parsed_fill {
6652 Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6653 Err(_) => FillPattern::UNKNOWN,
6654 };
6655
6656 let pipeline = self.filter_pipeline_parsed();
6657 let spec = RawReadSpec {
6658 layout: &dl,
6659 dataspace: &ds,
6660 datatype: &dt,
6661 pipeline: pipeline.as_ref(),
6662 fill,
6663 };
6664
6665 if start == 0 && count == n0 {
6666 return Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?);
6667 }
6668
6669 // A lone window's successor is the adjacent one, and the chunk they share
6670 // is the one this read finishes on; `CachePass::LRU` is what retains it.
6671 Ok(self.file.read_dataset_raw_rows(
6672 spec,
6673 &self.chunk_cache,
6674 CachePass::LRU,
6675 start,
6676 count,
6677 )?)
6678 }
6679
6680 /// Windowed [`read_f64`](Self::read_f64) — decodes only the row window.
6681 pub fn read_f64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f64>, Error> {
6682 let raw = self.read_raw_rows(start_row, num_rows)?;
6683 Ok(data_read::read_as_f64(&raw, &self.datatype()?)?)
6684 }
6685
6686 /// Windowed [`read_f32`](Self::read_f32) — decodes only the row window.
6687 pub fn read_f32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f32>, Error> {
6688 let raw = self.read_raw_rows(start_row, num_rows)?;
6689 Ok(data_read::read_as_f32(&raw, &self.datatype()?)?)
6690 }
6691
6692 /// Windowed [`read_i8`](Self::read_i8) — decodes only the row window.
6693 pub fn read_i8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i8>, Error> {
6694 let raw = self.read_raw_rows(start_row, num_rows)?;
6695 Ok(data_read::read_as_i8(&raw, &self.datatype()?)?)
6696 }
6697
6698 /// Windowed [`read_i16`](Self::read_i16) — decodes only the row window.
6699 pub fn read_i16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i16>, Error> {
6700 let raw = self.read_raw_rows(start_row, num_rows)?;
6701 Ok(data_read::read_as_i16(&raw, &self.datatype()?)?)
6702 }
6703
6704 /// Windowed [`read_i32`](Self::read_i32) — decodes only the row window.
6705 pub fn read_i32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i32>, Error> {
6706 let raw = self.read_raw_rows(start_row, num_rows)?;
6707 Ok(data_read::read_as_i32(&raw, &self.datatype()?)?)
6708 }
6709
6710 /// Windowed [`read_i64`](Self::read_i64) — decodes only the row window.
6711 pub fn read_i64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i64>, Error> {
6712 let raw = self.read_raw_rows(start_row, num_rows)?;
6713 Ok(data_read::read_as_i64(&raw, &self.datatype()?)?)
6714 }
6715
6716 /// Windowed [`read_u8`](Self::read_u8) — reads only the row window.
6717 pub fn read_u8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6718 let raw = self.read_raw_rows(start_row, num_rows)?;
6719 Ok(data_read::read_as_u8(&raw, &self.datatype()?)?)
6720 }
6721
6722 /// Windowed [`read_u16`](Self::read_u16) — decodes only the row window.
6723 pub fn read_u16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u16>, Error> {
6724 let raw = self.read_raw_rows(start_row, num_rows)?;
6725 Ok(data_read::read_as_u16(&raw, &self.datatype()?)?)
6726 }
6727
6728 /// Windowed [`read_u32`](Self::read_u32) — decodes only the row window.
6729 pub fn read_u32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u32>, Error> {
6730 let raw = self.read_raw_rows(start_row, num_rows)?;
6731 Ok(data_read::read_as_u32(&raw, &self.datatype()?)?)
6732 }
6733
6734 /// Windowed [`read_u64`](Self::read_u64) — decodes only the row window.
6735 pub fn read_u64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u64>, Error> {
6736 let raw = self.read_raw_rows(start_row, num_rows)?;
6737 Ok(data_read::read_as_u64(&raw, &self.datatype()?)?)
6738 }
6739
6740 /// Windowed [`read_string`](Self::read_string).
6741 ///
6742 /// Fixed-length strings decode straight from the window. Variable-length
6743 /// strings resolve only the window's heap references, so the window memory
6744 /// bound holds for them too: peak allocation is the window's references,
6745 /// its text, and the metadata of the heap collections it touches.
6746 pub fn read_string_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<String>, Error> {
6747 let dt = self.datatype()?;
6748 if vl_data::is_vlen_string_datatype(&dt) {
6749 // The window's heap references, read memory-bounded like any other
6750 // fixed-size element (4-byte length + collection address + 4-byte
6751 // object index), one row spanning its inner dimensions. Resolving
6752 // only those against the global heap keeps the bound — the same
6753 // resolution `read_string` runs over the whole dataset's references.
6754 let raw = self.read_raw_rows(start_row, num_rows)?;
6755 let ref_size = 4 + self.file.offset_size() as usize + 4;
6756 let num_elements = (raw.len() / ref_size) as u64;
6757 let mut strings = Vec::new();
6758 self.file.with_source(|source| -> Result<(), Error> {
6759 Ok(vl_data::visit_vl_strings_from_source(
6760 source,
6761 &raw,
6762 num_elements,
6763 self.file.offset_size(),
6764 self.file.length_size(),
6765 self.file.addr_offset,
6766 VlenStringReadOptions::default(),
6767 |string| strings.push(String::from(string)),
6768 )?)
6769 })?;
6770 return Ok(strings);
6771 }
6772 let raw = self.read_raw_rows(start_row, num_rows)?;
6773 Ok(data_read::read_as_strings(&raw, &dt)?)
6774 }
6775
6776 /// Interpret this dataset as an array of HDF5 object references
6777 /// (`H5R_OBJECT`) and resolve each, in storage order, to the [`Object`] it
6778 /// points at.
6779 ///
6780 /// MATLAB cell arrays and the `#subsystem#` machinery store their members
6781 /// this way: the dataset holds one object-header address per element, each
6782 /// naming an object elsewhere in the file (conventionally under the hidden
6783 /// `#refs#` group).
6784 ///
6785 /// # Errors
6786 ///
6787 /// - [`FormatError::TypeMismatch`] if this dataset's datatype is not an
6788 /// object reference.
6789 /// - [`FormatError::InvalidObjectReference`] if an element is a null or
6790 /// undefined reference, or does not point at a group or dataset.
6791 pub fn dereference(&self) -> Result<Vec<Object>, Error> {
6792 let dt = self.datatype()?;
6793 if !matches!(
6794 dt,
6795 Datatype::Reference {
6796 ref_type: ReferenceType::Object,
6797 ..
6798 }
6799 ) {
6800 return Err(FormatError::TypeMismatch {
6801 expected: "object reference",
6802 actual: "non-reference datatype",
6803 }
6804 .into());
6805 }
6806 // An object reference stores an 8-byte object-header address. Refuse a
6807 // sub-address-width element rather than read a truncated address.
6808 let elem_size = dt.type_size().to_usize()?;
6809 if elem_size < 8 {
6810 return Err(FormatError::TypeMismatch {
6811 expected: "8-byte object reference",
6812 actual: "object reference narrower than 8 bytes",
6813 }
6814 .into());
6815 }
6816 let raw = self.read_raw()?;
6817 if raw.is_empty() {
6818 return Ok(Vec::new());
6819 }
6820 if !raw.len().is_multiple_of(elem_size) {
6821 return Err(FormatError::DataSizeMismatch {
6822 expected: elem_size,
6823 actual: raw.len(),
6824 }
6825 .into());
6826 }
6827 // Read after the element bytes the addresses came out of, which is the
6828 // one place these could be taken too late: a commit landing between that
6829 // read and this one would move the headers the addresses name, and a
6830 // handle labelled with the later revisions would call them current.
6831 let revisions = self.file.revisions();
6832 let mut out = Vec::with_capacity(raw.len() / elem_size);
6833 for chunk in raw.chunks_exact(elem_size) {
6834 let addr = u64::from_le_bytes(chunk[..8].try_into().expect("chunk has >= 8 bytes"));
6835 out.push(FileInner::object_at_relative(&self.file, revisions, addr)?);
6836 }
6837 Ok(out)
6838 }
6839
6840 /// Decode all elements of a compound dataset field by field.
6841 ///
6842 /// Built-in implementations support numeric tuples with one through twelve
6843 /// fields. Decoding uses the file's field offsets rather than Rust's tuple
6844 /// memory layout, so padded compound records are supported safely.
6845 pub fn read_compound<T: CompoundType>(&self) -> Result<Vec<T>, Error> {
6846 let datatype = self.datatype()?;
6847 let element_size = datatype.element_size_usize()?;
6848 if !matches!(datatype, Datatype::Compound { .. }) {
6849 return Err(FormatError::TypeMismatch {
6850 expected: "Compound",
6851 actual: "non-Compound",
6852 }
6853 .into());
6854 }
6855 let raw = self.read_raw()?;
6856 if !raw.len().is_multiple_of(element_size.get()) {
6857 return Err(FormatError::DataSizeMismatch {
6858 expected: element_size.get(),
6859 actual: raw.len(),
6860 }
6861 .into());
6862 }
6863 raw.chunks_exact(element_size.get())
6864 .map(|bytes| T::decode(&datatype, bytes).map_err(Error::Format))
6865 .collect()
6866 }
6867
6868 /// Verify this dataset against its stored provenance hash.
6869 ///
6870 /// Recomputes the SHA-256 of the dataset's raw bytes and compares it with
6871 /// the `_provenance_sha256` attribute written by
6872 /// [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
6873 /// Returns [`VerifyResult::NoHash`](crate::VerifyResult::NoHash) when the
6874 /// dataset carries no provenance hash, so a missing hash is distinguishable
6875 /// from an actual mismatch.
6876 #[cfg(feature = "provenance")]
6877 pub fn verify_provenance(&self) -> Result<crate::provenance::VerifyResult, Error> {
6878 use crate::provenance::{ATTR_SHA256, VerifyResult, sha256_hex};
6879
6880 let attrs = self.attrs()?;
6881 let stored = match attrs.get(ATTR_SHA256).and_then(AttrValue::as_str) {
6882 Some(s) => s.trim_end_matches('\0').to_string(),
6883 None => return Ok(VerifyResult::NoHash),
6884 };
6885
6886 let computed = sha256_hex(&self.read_raw()?);
6887 if computed == stored {
6888 Ok(VerifyResult::Ok)
6889 } else {
6890 Ok(VerifyResult::Mismatch { stored, computed })
6891 }
6892 }
6893}
6894
6895// ---------------------------------------------------------------------------
6896// Helpers
6897// ---------------------------------------------------------------------------
6898
6899fn find_message(
6900 header: &ObjectHeader,
6901 msg_type: MessageType,
6902) -> Result<&crate::object_header::HeaderMessage, Error> {
6903 header
6904 .messages
6905 .iter()
6906 .find(|m| m.msg_type == msg_type)
6907 .ok_or(Error::MissingMessage(msg_type))
6908}
6909
6910fn has_message(header: &ObjectHeader, msg_type: MessageType) -> bool {
6911 header.messages.iter().any(|m| m.msg_type == msg_type)
6912}
6913
6914/// Whether an object header describes a committed (`H5Tcommit`) datatype: it
6915/// carries a datatype and is neither a dataset nor a group.
6916///
6917/// A dataset's header carries a datatype message too — its element type — so
6918/// "has a datatype message" is not the question, and a lookup that asked only
6919/// that answered a dataset's element type where it owed a refusal (issue #364).
6920/// The listing and the by-name lookups share this one predicate so they cannot
6921/// disagree about the same child.
6922///
6923/// The conjunction encodes the precedence the reference library gets from its
6924/// ordering: `H5O__obj_class_real` walks `H5O_obj_class_g` in reverse, so it
6925/// asks group, then dataset, then datatype, and that is what `H5Topen` gates on.
6926/// Two terms are read differently here. It calls a header a dataset for a
6927/// datatype beside a *dataspace* where this reads a datatype beside a data
6928/// layout, and a group for a symbol table or link info where this counts a bare
6929/// link message as well. Every object either library writes carries the messages
6930/// that make those agree, so the rules part only on a malformed header.
6931fn is_named_datatype(header: &ObjectHeader) -> bool {
6932 has_message(header, MessageType::DATATYPE)
6933 && !has_message(header, MessageType::DATA_LAYOUT)
6934 && !is_group(header)
6935}
6936
6937/// The root-relative path of a child named `name` under `parent`, or `None` if
6938/// the parent has no resolvable path (reached by object reference).
6939///
6940/// Free-standing rather than a method on [`Group`] so the member iterators can
6941/// build child paths from a closure that outlives the borrow of the group they
6942/// came from.
6943///
6944/// A member whose name is no path component, such as the empty name a malformed file can store,
6945/// has no [`LinkName`] for the iterators to pass, and they return its handle with `None` for a
6946/// path.
6947fn child_path_of(parent: Option<&ObjectPathBuf>, name: LinkName<'_>) -> Option<ObjectPathBuf> {
6948 Some(parent?.join(&LinkNameBuf::from(name)))
6949}
6950
6951/// Returns the root-relative path an error reports an object by, and the empty string for a
6952/// handle reached by object reference, which has no path.
6953fn reported_path(path: Option<&ObjectPathBuf>) -> String {
6954 path.map_or_else(String::new, ObjectPathBuf::to_string)
6955}
6956
6957/// The reason [`Group::child_edit_path`] gives for a path that spells no link.
6958const NO_LINK_TO_WRITE: &str = "a write needs a link name, and this path holds none";
6959
6960#[cfg(test)]
6961mod tests {
6962 use super::*;
6963 use crate::data_layout::{ChunkIndexLayout, ChunkedLayoutFlags};
6964 use crate::dataspace::DataspaceType;
6965 use crate::datatype::layout::FixedPointLayout;
6966 use crate::message_flags::MessageFlags;
6967 use crate::{DatatypeByteOrder, FileBuilder};
6968 use std::sync::atomic::AtomicUsize;
6969
6970 // -----------------------------------------------------------------------
6971 // Reporting the metadata cache (issue #353)
6972 // -----------------------------------------------------------------------
6973
6974 /// `SourceView` serves its metadata reads from the streaming backend's
6975 /// cache, so it has to forward the account of them as well. It is the one
6976 /// wrapper `File::metadata_cache_stats` does not itself go through (that
6977 /// dispatch uses `with_source`, which reaches the read-write backend too), so
6978 /// nothing else would notice the forward going missing.
6979 #[test]
6980 fn the_source_view_reports_the_cache_it_reads_through() {
6981 let backend = MetadataCachingSource::new(
6982 BytesSource::new((0..=255u8).collect::<Vec<u8>>()),
6983 MetadataCacheConfig::new(4096),
6984 );
6985 let view = SourceView::Stream(&backend);
6986
6987 assert_eq!(view.metadata_cache_stats().unwrap().reads(), 0);
6988 view.read_metadata_at(0, 64).unwrap();
6989 view.read_metadata_at(0, 64).unwrap();
6990 let stats = view
6991 .metadata_cache_stats()
6992 .expect("the backend has a cache, so the view reports it");
6993 assert_eq!((stats.hits(), stats.misses()), (1, 1));
6994
6995 view.reset_metadata_cache_stats();
6996 let cleared = view.metadata_cache_stats().unwrap();
6997 assert_eq!(cleared.hits(), 0);
6998 assert_eq!(cleared.entries(), 1, "a reset evicts nothing");
6999
7000 // A whole-file buffer is the cache; there is no second one to report.
7001 assert_eq!(SourceView::Mem(&[0u8; 16]).metadata_cache_stats(), None);
7002 }
7003
7004 // -----------------------------------------------------------------------
7005 // Handle re-validation across an edit (issue #351)
7006 // -----------------------------------------------------------------------
7007
7008 /// A file with two chunked datasets whose trailing chunk is partial, one
7009 /// contiguous dataset, and one subgroup.
7010 fn revalidation_fixture(path: &std::path::Path) {
7011 let mut b = FileBuilder::new();
7012 for ds in ["log", "other"] {
7013 b.create_dataset(ds)
7014 .with_i32_data(&[0, 1])
7015 .with_shape(&[2])
7016 .with_maxshape(&[MaxExtent::Unlimited])
7017 .with_chunks(&[4]);
7018 }
7019 b.create_dataset("plain").with_i32_data(&[7, 8, 9]);
7020 let g = b.create_group("g");
7021 b.add_group(g.finish());
7022 b.write(path).unwrap();
7023 }
7024
7025 /// The issue itself: a handle taken before a commit goes on answering for
7026 /// the object after it, rather than for the copy the commit left behind.
7027 #[test]
7028 fn a_handle_follows_its_object_across_a_commit() {
7029 let dir = tempfile::tempdir().unwrap();
7030 let path = dir.path().join("follow.h5");
7031 revalidation_fixture(&path);
7032
7033 let file = File::open_rw(&path).unwrap();
7034 let mut ds = file.dataset("plain").unwrap();
7035 let group = file.group("g").unwrap();
7036
7037 // Both edits relocate an object header: the dataset's own, and — since
7038 // the new child is linked into it — the group's.
7039 ds.set_attr("units", AttrValue::AsciiString("m".into()))
7040 .unwrap();
7041 group
7042 .create_dataset("child", |b| {
7043 b.with_i32_data(&[4, 5]);
7044 })
7045 .unwrap();
7046 file.commit().unwrap();
7047
7048 assert_eq!(
7049 sorted(ds.attrs()),
7050 vec![r#"units=AsciiString("m")"#.to_string()],
7051 "the dataset handle must report the attribute the commit added"
7052 );
7053 assert_eq!(
7054 group.datasets().unwrap(),
7055 vec!["child".to_string()],
7056 "the group handle must report the child the commit added"
7057 );
7058 assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7059 assert_eq!(
7060 group.dataset("child").unwrap().read_i32().unwrap(),
7061 vec![4, 5]
7062 );
7063 file.close().unwrap();
7064 }
7065
7066 /// Two handles on one dataset are two views of one object, not two objects:
7067 /// an append through either is what the other reads next, chunk cache and
7068 /// all. The appended elements land in a chunk the reader already holds, so a
7069 /// retained one would answer with what stood there before.
7070 #[test]
7071 fn an_append_through_one_handle_is_what_another_reads() {
7072 let dir = tempfile::tempdir().unwrap();
7073 let path = dir.path().join("two_handles.h5");
7074 revalidation_fixture(&path);
7075
7076 let file = File::open_rw(&path).unwrap();
7077 let reader = file.dataset("log").unwrap();
7078 let mut writer = file.dataset("log").unwrap();
7079
7080 assert_eq!(reader.read_i32().unwrap(), vec![0, 1]);
7081 assert!(
7082 reader.chunk_cache_stats().cached_chunks() > 0,
7083 "the read must leave the partial trailing chunk cached, or this \
7084 test cannot tell a retained chunk from a re-read one"
7085 );
7086
7087 writer.append(&[2i32, 3]).unwrap();
7088
7089 assert_eq!(
7090 reader.chunk_cache_stats().cached_chunks(),
7091 0,
7092 "the snapshot must report what the cache holds for a read, and the \
7093 append left it holding nothing a read will be served"
7094 );
7095 assert_eq!(reader.shape().unwrap(), vec![4]);
7096 assert_eq!(reader.read_i32().unwrap(), vec![0, 1, 2, 3]);
7097 file.close().unwrap();
7098 }
7099
7100 /// A clone is a second handle to the same object, and follows it the same
7101 /// way. It shares the chunk cache, so the edit that drops one drops both.
7102 #[test]
7103 fn a_clone_is_a_second_handle_to_the_same_object() {
7104 let dir = tempfile::tempdir().unwrap();
7105 let path = dir.path().join("clone.h5");
7106 revalidation_fixture(&path);
7107
7108 let file = File::open_rw(&path).unwrap();
7109 let mut ds = file.dataset("log").unwrap();
7110 let copy = ds.clone();
7111 let root = file.root();
7112 let root_copy = root.clone();
7113
7114 assert_eq!(copy.read_i32().unwrap(), vec![0, 1]);
7115 assert!(copy.chunk_cache_stats().cached_chunks() > 0);
7116 assert_eq!(
7117 ds.chunk_cache_stats().cached_chunks(),
7118 copy.chunk_cache_stats().cached_chunks(),
7119 "clones share one cache: a chunk read through either is warm for both"
7120 );
7121
7122 ds.append(&[2i32, 3]).unwrap();
7123 assert_eq!(copy.read_i32().unwrap(), vec![0, 1, 2, 3]);
7124
7125 root.create_group("later").unwrap();
7126 file.commit().unwrap();
7127 assert!(
7128 root_copy.groups().unwrap().contains(&"later".to_string()),
7129 "a cloned group handle follows its group across a commit too"
7130 );
7131 file.close().unwrap();
7132 }
7133
7134 /// A handle to an object a commit deleted has nothing to answer for. The
7135 /// bytes it vacated still parse as the dataset that left them, so reading
7136 /// them would answer with data no longer in the file.
7137 #[test]
7138 fn a_handle_to_a_deleted_object_refuses_rather_than_reading_what_it_left() {
7139 let dir = tempfile::tempdir().unwrap();
7140 let path = dir.path().join("deleted.h5");
7141 revalidation_fixture(&path);
7142
7143 let file = File::open_rw(&path).unwrap();
7144 let ds = file.dataset("plain").unwrap();
7145 assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7146
7147 file.root().delete("plain").unwrap();
7148 file.commit().unwrap();
7149
7150 assert!(
7151 matches!(
7152 ds.read_i32(),
7153 Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "plain"
7154 ),
7155 "reading a deleted dataset must fail the way opening it does, got {:?}",
7156 ds.read_i32()
7157 );
7158 file.close().unwrap();
7159 }
7160
7161 /// A handle reached by object reference has no name to look itself up by, so
7162 /// it pins to the address the reference gave it. An immediate append leaves
7163 /// that address alone and it keeps reading; a commit can move the header and
7164 /// it stops.
7165 #[test]
7166 fn a_reference_handle_reads_until_a_commit_could_have_moved_its_object() {
7167 let dir = tempfile::tempdir().unwrap();
7168 let path = dir.path().join("by_ref.h5");
7169 let mut b = FileBuilder::new();
7170 b.create_dataset("log")
7171 .with_i32_data(&[0, 1])
7172 .with_shape(&[2])
7173 .with_maxshape(&[MaxExtent::Unlimited])
7174 .with_chunks(&[4]);
7175 b.create_dataset("refs").with_path_references(&["log"]);
7176 b.write(&path).unwrap();
7177
7178 let file = File::open_rw(&path).unwrap();
7179 let mut by_ref = match file
7180 .dataset("refs")
7181 .unwrap()
7182 .dereference()
7183 .unwrap()
7184 .remove(0)
7185 {
7186 Object::Dataset(ds) => *ds,
7187 other => panic!("expected a dataset, got {other:?}"),
7188 };
7189 assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1]);
7190
7191 // An in-place append rewrites the header where it stands, so the address
7192 // is still the object's and the handle reads its own new elements.
7193 by_ref.append(&[2i32, 3]).unwrap();
7194 assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1, 2, 3]);
7195
7196 // A commit can put the header somewhere else, and nothing on disk marks
7197 // the bytes it vacated as dead.
7198 file.root().create_group("g").unwrap();
7199 file.commit().unwrap();
7200 assert!(
7201 matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
7202 "unexpected: {:?}",
7203 by_ref.read_i32()
7204 );
7205
7206 // And the recovery the error names: dereference again, against the file
7207 // the commit left.
7208 let fresh = match file
7209 .dataset("refs")
7210 .unwrap()
7211 .dereference()
7212 .unwrap()
7213 .remove(0)
7214 {
7215 Object::Dataset(ds) => *ds,
7216 other => panic!("expected a dataset, got {other:?}"),
7217 };
7218 assert_eq!(fresh.read_i32().unwrap(), vec![0, 1, 2, 3]);
7219 file.close().unwrap();
7220 }
7221
7222 /// The counters are what every handle trusts, so every entry point that
7223 /// reaches the write engine has to declare what it does to them. This is
7224 /// that declaration, written out entry point by entry point and checked
7225 /// against the code: a [`Change::Relocating`] advances both counters, an
7226 /// [`Change::InPlace`] only the content one, and a [`Change::Nothing`]
7227 /// neither.
7228 ///
7229 /// The table is the point. An entry point classified too weakly leaves a
7230 /// handle memoizing a header it moved; one classified too strongly ends
7231 /// every by-reference handle in the session for nothing — which is what
7232 /// `File::sync` did until its line was written here.
7233 #[test]
7234 fn every_write_entry_point_declares_what_it_changes() {
7235 let dir = tempfile::tempdir().unwrap();
7236
7237 type Step = (&'static str, Change, fn(&File));
7238 let steps: Vec<Step> = vec![
7239 ("File::sync", Change::Nothing, |f| {
7240 f.sync().unwrap();
7241 }),
7242 ("Dataset::chunk_cache_stats", Change::Nothing, |f| {
7243 let _ = f.dataset("log").unwrap().chunk_cache_stats();
7244 }),
7245 ("Dataset::reset_chunk_cache_stats", Change::Nothing, |f| {
7246 f.dataset("log").unwrap().reset_chunk_cache_stats();
7247 }),
7248 ("BufferedAppender::new", Change::Nothing, |f| {
7249 let mut ds = f.dataset("log").unwrap();
7250 let app = ds.buffered_appender().unwrap();
7251 app.discard();
7252 }),
7253 ("Dataset::append", Change::InPlace, |f| {
7254 f.dataset("log").unwrap().append(&[9i32]).unwrap();
7255 }),
7256 ("Dataset::append_raw", Change::InPlace, |f| {
7257 f.dataset("log")
7258 .unwrap()
7259 .append_raw(&7i32.to_le_bytes())
7260 .unwrap();
7261 }),
7262 ("BufferedAppender::flush", Change::InPlace, |f| {
7263 let mut ds = f.dataset("log").unwrap();
7264 let mut app = ds.buffered_appender().unwrap();
7265 app.append(&[5i32, 6, 7, 8]).unwrap();
7266 app.flush().unwrap();
7267 }),
7268 ("Dataset::write", Change::Relocating, |f| {
7269 f.dataset("plain").unwrap().write(&[1i32, 2, 3]).unwrap();
7270 }),
7271 ("Dataset::set_attr", Change::Relocating, |f| {
7272 f.dataset("plain")
7273 .unwrap()
7274 .set_attr("a", AttrValue::I32(1))
7275 .unwrap();
7276 }),
7277 ("Dataset::remove_attr", Change::Relocating, |f| {
7278 let mut ds = f.dataset("plain").unwrap();
7279 ds.set_attr("gone", AttrValue::I32(1)).unwrap();
7280 f.commit().unwrap();
7281 ds.remove_attr("gone").unwrap();
7282 }),
7283 ("Dataset::write_staged", Change::Relocating, |f| {
7284 f.dataset("plain")
7285 .unwrap()
7286 .write_staged(|b| {
7287 b.with_i32_data(&[4, 5, 6]);
7288 })
7289 .unwrap();
7290 }),
7291 ("Dataset::append_staged", Change::Relocating, |f| {
7292 f.dataset("log")
7293 .unwrap()
7294 .append_staged(|b| {
7295 b.append_i32(&[3]);
7296 })
7297 .unwrap();
7298 }),
7299 ("Group::create_group", Change::Relocating, |f| {
7300 f.root().create_group("fresh").unwrap();
7301 }),
7302 ("Group::create_dataset", Change::Relocating, |f| {
7303 f.root()
7304 .create_dataset("made", |b| {
7305 b.with_i32_data(&[1]);
7306 })
7307 .unwrap();
7308 }),
7309 ("Group::delete", Change::Relocating, |f| {
7310 f.root().delete("plain").unwrap();
7311 }),
7312 ("Group::set_attr", Change::Relocating, |f| {
7313 f.root().set_attr("a", AttrValue::I32(1)).unwrap();
7314 }),
7315 ("Group::remove_attr", Change::Relocating, |f| {
7316 f.root().set_attr("gone", AttrValue::I32(1)).unwrap();
7317 f.commit().unwrap();
7318 f.root().remove_attr("gone").unwrap();
7319 }),
7320 ("Group::create_group_with", Change::Relocating, |f| {
7321 f.root()
7322 .create_group_with("built", |g| {
7323 g.set_attr("a", AttrValue::I32(1));
7324 })
7325 .unwrap();
7326 }),
7327 ("File::copy", Change::Relocating, |f| {
7328 f.copy("plain", "copied").unwrap();
7329 }),
7330 ("File::commit", Change::Relocating, |f| {
7331 f.root().create_group("committed").unwrap();
7332 f.commit().unwrap();
7333 }),
7334 ];
7335
7336 let revisions = |f: &File| (f.inner.content_revision(), f.inner.address_revision());
7337 let declared = |c: Change| match c {
7338 Change::Relocating => "Relocating",
7339 Change::InPlace => "InPlace",
7340 Change::Nothing => "Nothing",
7341 };
7342 for (name, change, step) in steps {
7343 let path = dir.path().join(format!("{}.h5", name.replace("::", "_")));
7344 revalidation_fixture(&path);
7345 let file = File::open_rw(&path).unwrap();
7346 let before = revisions(&file);
7347 step(&file);
7348 let after = revisions(&file);
7349 let observed = match (after.0 > before.0, after.1 > before.1) {
7350 (true, true) => "Relocating",
7351 (true, false) => "InPlace",
7352 (false, false) => "Nothing",
7353 (false, true) => "an address move with no content change",
7354 };
7355 assert_eq!(
7356 observed,
7357 declared(change),
7358 "{name} is declared here as one thing and behaves as another \
7359 ({before:?} -> {after:?})"
7360 );
7361 file.close().unwrap();
7362 }
7363
7364 // `close` consumes the file, so it cannot be a row above. It is two
7365 // operations: the commit it makes, which relocates like any other, and
7366 // the teardown, which re-homes free space and releases status flags
7367 // where they stand. Both counters therefore move, and the content one
7368 // moves twice.
7369 let path = dir.path().join("File_close.h5");
7370 revalidation_fixture(&path);
7371 let file = File::open_rw(&path).unwrap();
7372 let before = revisions(&file);
7373 let by_path = file.dataset("plain").unwrap();
7374 let by_ref = {
7375 let mut b = FileBuilder::new();
7376 b.create_dataset("d").with_i32_data(&[1, 2]);
7377 b.create_dataset("refs").with_path_references(&["d"]);
7378 let refs_path = dir.path().join("File_close_refs.h5");
7379 b.write(&refs_path).unwrap();
7380 let refs = File::open_rw(&refs_path).unwrap();
7381 let handle = match refs
7382 .dataset("refs")
7383 .unwrap()
7384 .dereference()
7385 .unwrap()
7386 .remove(0)
7387 {
7388 Object::Dataset(ds) => *ds,
7389 other => panic!("expected a dataset, got {other:?}"),
7390 };
7391 refs.close().unwrap();
7392 handle
7393 };
7394 // `close` consumes its `File`; the counters live on the shared inner
7395 // state every handle holds, so read them back through one of those.
7396 let inner = Arc::clone(&file.inner);
7397 file.close().unwrap();
7398 let after = (inner.content_revision(), inner.address_revision());
7399 assert_eq!(
7400 (after.0 - before.0, after.1 - before.1),
7401 (2, 1),
7402 "File::close is a Relocating commit and an InPlace teardown"
7403 );
7404 assert_eq!(
7405 by_path.read_i32().unwrap(),
7406 vec![7, 8, 9],
7407 "`close` promises reads through surviving handles still work"
7408 );
7409 assert!(
7410 matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
7411 "the one handle that cannot follow the commit `close` makes: {:?}",
7412 by_ref.read_i32()
7413 );
7414 }
7415
7416 /// What the *second* counter buys, which nothing else pins: an edit that
7417 /// moves no object header leaves a by-reference handle — the one kind that
7418 /// cannot look itself up again — still able to read. Collapse the two
7419 /// counters into one and every case here becomes `StaleHandle`.
7420 #[test]
7421 fn an_edit_that_moves_no_header_leaves_a_reference_handle_reading() {
7422 let dir = tempfile::tempdir().unwrap();
7423
7424 // Named for the same reason `Step` above is: a bare tuple of a name and
7425 // a function pointer reads as noise at the call site.
7426 type Case = (&'static str, fn(&File));
7427 let cases: Vec<Case> = vec![
7428 (
7429 "an append through another handle to the same dataset",
7430 |f| {
7431 f.dataset("log").unwrap().append(&[9i32]).unwrap();
7432 },
7433 ),
7434 ("an append to a different dataset", |f| {
7435 f.dataset("other").unwrap().append(&[9i32]).unwrap();
7436 }),
7437 ("a durability barrier", |f| {
7438 f.sync().unwrap();
7439 }),
7440 ];
7441
7442 for (name, edit) in cases {
7443 let path = dir.path().join(format!("{}.h5", name.replace(' ', "_")));
7444 let mut b = FileBuilder::new();
7445 for ds in ["log", "other"] {
7446 b.create_dataset(ds)
7447 .with_i32_data(&[0, 1])
7448 .with_shape(&[2])
7449 .with_maxshape(&[MaxExtent::Unlimited])
7450 .with_chunks(&[4]);
7451 }
7452 b.create_dataset("refs").with_path_references(&["log"]);
7453 b.write(&path).unwrap();
7454
7455 let file = File::open_rw(&path).unwrap();
7456 let by_ref = match file
7457 .dataset("refs")
7458 .unwrap()
7459 .dereference()
7460 .unwrap()
7461 .remove(0)
7462 {
7463 Object::Dataset(ds) => *ds,
7464 other => panic!("expected a dataset, got {other:?}"),
7465 };
7466 assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1], "{name}: before");
7467 edit(&file);
7468 assert!(
7469 by_ref.read_i32().is_ok(),
7470 "{name} moves no object header, so it must not end a handle that \
7471 names its dataset by address: {:?}",
7472 by_ref.read_i32()
7473 );
7474 file.close().unwrap();
7475 }
7476 }
7477
7478 /// A commit can put something else at a handle's path — issue #305 makes a
7479 /// dataset and a group interchangeable in one commit. The refusal has to
7480 /// hold on *every* call: a header memoized and then refused is the answer
7481 /// each later call short-circuits on, and this handle would go on serving
7482 /// the other object's header without an error.
7483 #[test]
7484 fn a_handle_whose_path_becomes_a_group_keeps_refusing() {
7485 let dir = tempfile::tempdir().unwrap();
7486 let path = dir.path().join("replaced.h5");
7487 revalidation_fixture(&path);
7488
7489 let file = File::open_rw(&path).unwrap();
7490 let ds = file.dataset("plain").unwrap();
7491 assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7492
7493 file.root().delete("plain").unwrap();
7494 file.root()
7495 .create_group_with("plain", |g| {
7496 g.set_attr("i_am_a_group", AttrValue::I32(42));
7497 })
7498 .unwrap();
7499 file.commit().unwrap();
7500
7501 for call in 1..=3 {
7502 assert!(
7503 matches!(ds.attrs(), Err(Error::NotADataset(ref p)) if p == "plain"),
7504 "call {call} answered {:?}",
7505 ds.attrs()
7506 );
7507 assert!(matches!(ds.read_i32(), Err(Error::NotADataset(_))));
7508 assert!(matches!(ds.shape(), Err(Error::NotADataset(_))));
7509 }
7510 file.close().unwrap();
7511 }
7512
7513 /// A read-only file cannot change under a handle, so nothing a reader does
7514 /// may move the counters — and no handle on one ever pays to re-resolve.
7515 #[test]
7516 fn reading_never_moves_the_file_on() {
7517 let dir = tempfile::tempdir().unwrap();
7518 let path = dir.path().join("readonly.h5");
7519 revalidation_fixture(&path);
7520
7521 let file = File::open(&path).unwrap();
7522 let _ = read_everything(&file);
7523 let ds = file.dataset("log").unwrap();
7524 let _ = ds.read_i32().unwrap();
7525 let _ = ds.attrs().unwrap();
7526 let _ = ds.layout().unwrap();
7527 let _ = file.root().groups().unwrap();
7528 assert_eq!(
7529 (file.inner.content_revision(), file.inner.address_revision()),
7530 (0, 0),
7531 "a read must not tell every handle its memo has expired"
7532 );
7533 }
7534
7535 // -----------------------------------------------------------------------
7536 // Opening an object as the wrong kind (issue #352)
7537 // -----------------------------------------------------------------------
7538
7539 /// A dataset at the root and a dataset one level down inside a group.
7540 ///
7541 /// Shared by the two sections below: opening one of those datasets *as* a
7542 /// group is issue #352, and resolving a path *through* one is issue #365, so
7543 /// the sibling refusals are provably about the same file.
7544 fn nested_dataset_bytes() -> Vec<u8> {
7545 let mut b = FileBuilder::new();
7546 b.create_dataset("plain").with_i32_data(&[1]);
7547 let mut g = b.create_group("g");
7548 g.create_dataset("inner").with_i32_data(&[2]);
7549 b.add_group(g.finish());
7550 b.finish().unwrap()
7551 }
7552
7553 /// The issue: a by-name group lookup took whatever the name resolved to.
7554 /// `H5Gopen` fails on a non-group, and so must this — at the lookup, where
7555 /// the caller can act on it, rather than at some later call on a handle that
7556 /// was never a group.
7557 ///
7558 /// The refusal matters most on the calls that did *not* fail: `attrs()`
7559 /// through such a handle answered with the dataset's attributes, which is a
7560 /// wrong answer rather than an error.
7561 #[test]
7562 fn opening_a_dataset_as_a_group_is_refused() {
7563 let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7564 let nested = file.group("g").unwrap();
7565
7566 // Both by-name forms — from the file by path, and from a group by child
7567 // name — at the root and one level down. Each is its own lookup, and
7568 // each was missing the check. The refusal names the object, which is
7569 // what the old `PathNotFound("object header is not a group")` could not.
7570 for (label, named, got) in [
7571 ("File::group", "plain", file.group("plain")),
7572 ("Group::group", "plain", file.root().group("plain")),
7573 ("File::group nested", "g/inner", file.group("g/inner")),
7574 (
7575 "Group::group from a subgroup",
7576 "g/inner",
7577 nested.group("inner"),
7578 ),
7579 ] {
7580 assert!(
7581 matches!(&got, Err(Error::NotAGroup(p)) if p == named),
7582 "{label} answered {:?}",
7583 got.map(|_| "a group")
7584 );
7585 }
7586
7587 // The name it reports is the normalized one, so the same object refused
7588 // at a lookup and refused through a live handle names itself the same
7589 // way — a handle holds only the normalized path.
7590 assert!(matches!(file.group("/plain/"), Err(Error::NotAGroup(ref p)) if p == "plain"));
7591
7592 // A name that resolves to nothing stays distinct from one that resolves
7593 // to the wrong kind: the second reports what is there.
7594 assert!(matches!(
7595 file.group("absent"),
7596 Err(Error::Format(FormatError::PathNotFound(_)))
7597 ));
7598 assert!(matches!(
7599 file.root().group("absent"),
7600 Err(Error::Format(FormatError::PathNotFound(_)))
7601 ));
7602
7603 // And a real group still opens, by either form.
7604 assert!(file.group("g").is_ok());
7605 assert!(file.root().group("g").is_ok());
7606 }
7607
7608 /// A v1 symbol-table group must keep opening by name.
7609 ///
7610 /// The predicate that decides a lookup was, until this change, only a filter
7611 /// over a listing, where failing to recognise a form merely left a group out.
7612 /// Gating the lookup on it makes each form it names load-bearing, and the v1
7613 /// form is the one with no writer here to produce it — the bytes are a
7614 /// fixture, and a classifier that forgot the symbol table would refuse every
7615 /// group in every file written before the 1.8 format.
7616 #[test]
7617 fn a_symbol_table_group_still_opens_by_name() {
7618 let file =
7619 File::from_bytes(include_bytes!("../tests/data/unattributed/two_groups.h5").to_vec())
7620 .unwrap();
7621
7622 // Names, not a count: this file holds two one-child groups, so a lookup
7623 // that classified correctly and then took its sibling's address would
7624 // pass any count worth asserting.
7625 for lookup in [file.group("group1"), file.root().group("group1")] {
7626 assert_eq!(lookup.unwrap().datasets().unwrap(), ["values"]);
7627 }
7628 }
7629
7630 /// A committed datatype is neither a dataset nor a group, so it separates
7631 /// "is a group" from "is not a dataset" — a check written as the latter
7632 /// would let this one through.
7633 #[test]
7634 fn opening_a_named_datatype_as_a_group_is_refused() {
7635 let mut b = FileBuilder::new();
7636 b.commit_datatype("mytype", crate::make_i32_type());
7637 let file = File::from_bytes(b.finish().unwrap()).unwrap();
7638
7639 assert_eq!(file.root().named_datatypes().unwrap(), vec!["mytype"]);
7640 assert!(matches!(file.group("mytype"), Err(Error::NotAGroup(_))));
7641 assert!(matches!(
7642 file.root().group("mytype"),
7643 Err(Error::NotAGroup(_))
7644 ));
7645 }
7646
7647 // -----------------------------------------------------------------------
7648 // Opening something else as a named datatype (issue #364)
7649 // -----------------------------------------------------------------------
7650
7651 /// An object header carrying exactly `types`, and nothing that would make it
7652 /// parse: the predicate below reads message types and no message body.
7653 fn header_of(types: &[MessageType]) -> ObjectHeader {
7654 ObjectHeader {
7655 version: 2,
7656 messages: types
7657 .iter()
7658 .map(|&msg_type| crate::object_header::HeaderMessage {
7659 msg_type,
7660 size: 0,
7661 flags: MessageFlags::NONE,
7662 creation_order: None,
7663 data: Vec::new(),
7664 })
7665 .collect(),
7666 reference_count: None,
7667 flags: 0,
7668 access_time: None,
7669 modification_time: None,
7670 change_time: None,
7671 birth_time: None,
7672 }
7673 }
7674
7675 /// The rule the listing and both by-name lookups now share, stated over the
7676 /// message combinations rather than over one file's children.
7677 ///
7678 /// Two of these cannot be produced by any writer, here or in the reference
7679 /// library, which is why this is a predicate test and not another fixture. A
7680 /// header carrying links *and* a datatype is a group to the C library, which
7681 /// asks whether it is a group before asking whether it is a datatype; a
7682 /// header carrying neither is no object class at all, and must not become a
7683 /// datatype by default.
7684 #[test]
7685 fn a_committed_datatype_is_a_datatype_that_is_neither_dataset_nor_group() {
7686 for (label, types, expected) in [
7687 ("a committed datatype", &[MessageType::DATATYPE][..], true),
7688 (
7689 "a dataset, whose element type is a datatype message too",
7690 &[MessageType::DATATYPE, MessageType::DATA_LAYOUT],
7691 false,
7692 ),
7693 (
7694 "a group with a link table",
7695 &[MessageType::LINK_INFO],
7696 false,
7697 ),
7698 (
7699 "a group with a symbol table",
7700 &[MessageType::SYMBOL_TABLE],
7701 false,
7702 ),
7703 (
7704 "a group carrying a datatype",
7705 &[MessageType::LINK_INFO, MessageType::DATATYPE],
7706 false,
7707 ),
7708 (
7709 "a header with no datatype at all",
7710 &[MessageType::DATASPACE],
7711 false,
7712 ),
7713 ] {
7714 assert_eq!(is_named_datatype(&header_of(types)), expected, "{label}");
7715 }
7716 }
7717
7718 /// The issue: the by-name datatype lookups asked only whether the child had
7719 /// a datatype message. Every dataset does — its element type — so a dataset
7720 /// answered, and the two entry points disagreed with the
7721 /// `named_datatypes()` listing about the same child.
7722 #[test]
7723 fn a_child_that_is_not_a_committed_datatype_is_refused_by_name() {
7724 let mut b = FileBuilder::new();
7725 b.commit_datatype("mytype", crate::make_i32_type());
7726 b.create_dataset("typed")
7727 .with_i32_data(&[1, 2, 3])
7728 .with_committed_datatype("mytype");
7729 b.create_dataset("plain").with_f64_data(&[1.0]);
7730 let g = b.create_group("g").finish();
7731 b.add_group(g);
7732 let file = File::from_bytes(b.finish().unwrap()).unwrap();
7733 let root = file.root();
7734
7735 // The listing is the contract both lookups now share, so it is what the
7736 // refusals below have to agree with.
7737 assert_eq!(root.named_datatypes().unwrap(), ["mytype"]);
7738
7739 // A dataset, a dataset carrying that very type, and a group: three kinds
7740 // that are not a committed datatype, against both entry points. Neither
7741 // had the check, so each needs its own assertion.
7742 for name in ["typed", "plain", "g"] {
7743 let got = root.named_datatype(name);
7744 assert!(
7745 matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7746 "named_datatype({name:?}) answered {got:?}"
7747 );
7748 let got = root.named_datatype_references(name);
7749 assert!(
7750 matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7751 "named_datatype_references({name:?}) answered {got:?}"
7752 );
7753 }
7754
7755 // A name that reaches nothing stays distinct from one that reaches the
7756 // wrong kind, as it is for `group` and `dataset`.
7757 assert!(matches!(
7758 root.named_datatype("absent"),
7759 Err(Error::Format(FormatError::PathNotFound(_)))
7760 ));
7761 assert!(matches!(
7762 root.named_datatype_references("absent"),
7763 Err(Error::Format(FormatError::PathNotFound(_)))
7764 ));
7765
7766 // And the committed type still reads, by both entry points — the value,
7767 // not merely `is_ok`, since a lookup that refused everything would pass
7768 // every assertion above.
7769 assert_eq!(
7770 root.named_datatype("mytype").unwrap(),
7771 crate::make_i32_type()
7772 );
7773 assert_eq!(root.named_datatype_references("mytype").unwrap(), 2);
7774 }
7775
7776 /// The mirror of [`a_handle_whose_path_becomes_a_group_keeps_refusing`]: a
7777 /// commit can leave a live group handle's path naming a dataset (issue
7778 /// #305), which is the one way past the lookup check above. The handle
7779 /// re-resolves, finds the wrong kind, and reports it on every call rather
7780 /// than serving the dataset's header as a group's.
7781 #[test]
7782 fn a_group_handle_whose_path_becomes_a_dataset_keeps_refusing() {
7783 let dir = tempfile::tempdir().unwrap();
7784 let path = dir.path().join("replaced_group.h5");
7785 revalidation_fixture(&path);
7786
7787 let file = File::open_rw(&path).unwrap();
7788 let group = file.group("g").unwrap();
7789 assert!(group.datasets().unwrap().is_empty());
7790
7791 file.root().delete("g").unwrap();
7792 file.root()
7793 .create_dataset("g", |b| {
7794 b.with_i32_data(&[7]);
7795 })
7796 .unwrap();
7797 file.commit().unwrap();
7798
7799 // `attrs` every time round: it is the call that answered with the
7800 // dataset's attributes instead of failing, and a check installed after
7801 // the memo rather than before it would let the second call through.
7802 for call in 1..=3 {
7803 assert!(
7804 matches!(group.attrs(), Err(Error::NotAGroup(ref p)) if p == "g"),
7805 "call {call} answered {:?}",
7806 group.attrs()
7807 );
7808 }
7809 // The rest of the read surface funnels through the same re-resolve, so
7810 // once each is enough to say the refusal is the group's, not `attrs`'s.
7811 assert!(matches!(group.datasets(), Err(Error::NotAGroup(_))));
7812 assert!(matches!(group.groups(), Err(Error::NotAGroup(_))));
7813 assert!(matches!(
7814 group.dataset("anything"),
7815 Err(Error::NotAGroup(_))
7816 ));
7817
7818 // The path still names something, and that something still opens as
7819 // what it now is.
7820 assert_eq!(file.dataset("g").unwrap().read_i32().unwrap(), vec![7]);
7821 file.close().unwrap();
7822 }
7823
7824 // -----------------------------------------------------------------------
7825 // Resolving a path *through* something that is not a group (issue #365)
7826 // -----------------------------------------------------------------------
7827
7828 /// The issue: resolution opens each component in turn to look the next one
7829 /// up inside it, and reported a component that is not a group as
7830 /// `PathNotFound("object header is not a group")` — one string for every
7831 /// such path, naming no component at all. It read as "this path does not
7832 /// exist" where the truth was "`plain` is a dataset".
7833 ///
7834 /// It is now the same [`Error::NotAGroup`] a *final* component that is not a
7835 /// group returns (issue #352), so one match covers a path that goes wrong
7836 /// anywhere along it, and it names the object that stopped the walk rather
7837 /// than the path that was asked for.
7838 #[test]
7839 fn a_path_through_a_non_group_names_the_object_that_stopped_it() {
7840 let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7841
7842 // Two entry points, because each resolves the path for itself, and two
7843 // depths, because the name is the whole prefix walked rather than the
7844 // one component: `g/inner` is a path the caller can go and open, where a
7845 // bare `inner` would not say where to find it.
7846 for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7847 for (entry, got) in [
7848 ("File::group", file.group(asked).map(|_| ())),
7849 ("File::dataset", file.dataset(asked).map(|_| ())),
7850 ] {
7851 assert!(
7852 matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7853 "{entry}({asked:?}) answered {got:?}, expected the stop at {stopper:?}"
7854 );
7855 }
7856 }
7857
7858 // A component that names nothing at all stays a `PathNotFound` naming
7859 // it. The two are different facts, and reading differently is the whole
7860 // point of the change.
7861 assert!(matches!(
7862 file.group("absent/sub"),
7863 Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7864 ));
7865
7866 // Empty components are dropped before the walk, so the object is named
7867 // the same way however the path was spelled.
7868 assert!(matches!(
7869 file.group("/plain//sub/"),
7870 Err(Error::NotAGroup(ref p)) if p == "plain"
7871 ));
7872
7873 // The name reaches a caller that only prints the error, too.
7874 let printed = file
7875 .group("g/inner/deeper")
7876 .map(|_| ())
7877 .unwrap_err()
7878 .to_string();
7879 assert!(printed.contains("g/inner"), "the message read {printed:?}");
7880
7881 // And a path that really does run through groups still resolves, so the
7882 // classification has not turned the walk itself into a refusal.
7883 assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7884
7885 // A committed datatype is neither a dataset nor a group, so it separates
7886 // "is not a group" from "is a dataset" for an intermediate component the
7887 // way it does for a final one.
7888 let mut b = FileBuilder::new();
7889 b.commit_datatype("mytype", crate::make_i32_type());
7890 let typed = File::from_bytes(b.finish().unwrap()).unwrap();
7891 assert!(matches!(
7892 typed.group("mytype/sub"),
7893 Err(Error::NotAGroup(ref p)) if p == "mytype"
7894 ));
7895 }
7896
7897 /// The streaming walk is a second copy of the same loop, reading each header
7898 /// from a `Source`. A fix applied to one and not the other would leave the
7899 /// backend that exists for files too large to buffer reporting the old
7900 /// string.
7901 #[test]
7902 fn the_streaming_walk_names_the_object_that_stopped_it_too() {
7903 let dir = tempfile::tempdir().unwrap();
7904 let path = dir.path().join("nested.h5");
7905 std::fs::write(&path, nested_dataset_bytes()).unwrap();
7906
7907 let file = File::open_streaming(&path).unwrap();
7908 for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7909 let got = file.group(asked).map(|_| ());
7910 assert!(
7911 matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7912 "group({asked:?}) answered {got:?}"
7913 );
7914 }
7915 assert!(matches!(
7916 file.group("absent/sub"),
7917 Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7918 ));
7919 assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7920 // Before the directory goes: an open file blocks its removal on Windows.
7921 drop(file);
7922 }
7923
7924 /// The root is the one group handle nothing classifies: `File::root` takes
7925 /// the address from the superblock, and no open validates that it names a
7926 /// group. So a file whose superblock points the root at a dataset reaches
7927 /// the refusal in `Group::child_address` that every other handle is kept
7928 /// away from — and names the root by the empty path it carries.
7929 #[test]
7930 fn a_root_that_is_not_a_group_refuses_rather_than_being_searched() {
7931 let mut bytes = nested_dataset_bytes();
7932 let sig = crate::signature::find_signature(&bytes).unwrap();
7933 let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
7934 // The fixture has no userblock, so its base address is zero and the
7935 // absolute address a walk returns is also the stored one the superblock
7936 // field wants.
7937 assert_eq!(sb.base_address, BaseAddress::ZERO);
7938 sb.root_group_address = group_v2::resolve_path_any(
7939 &bytes,
7940 AccessMode::ReadOnly,
7941 &sb,
7942 &ObjectPath::parse("plain"),
7943 )
7944 .unwrap();
7945 let rewritten = sb.serialize();
7946 bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
7947
7948 let file = File::from_bytes(bytes).unwrap();
7949 let got = file.root().dataset("anything").map(|_| ());
7950 assert!(
7951 matches!(&got, Err(Error::NotAGroup(p)) if p.is_empty()),
7952 "a root that is not a group must refuse, got {got:?}"
7953 );
7954 }
7955
7956 /// Read everything a read-write file can serve through the paired read
7957 /// paths, as comparable text.
7958 ///
7959 /// Each entry exercises a different `with_engine` call site: path
7960 /// resolution, object-header parsing, group listing, attribute reads (both
7961 /// the compact and the dense form), a whole-dataset read, and a row-range
7962 /// read. Errors are formatted rather than unwrapped so that a *divergence in
7963 /// which error* is reported also fails the comparison.
7964 fn read_everything(file: &File) -> Vec<String> {
7965 let mut out = Vec::new();
7966 out.push(format!("root groups: {:?}", file.root().groups()));
7967 out.push(format!("root datasets: {:?}", file.root().datasets()));
7968 out.push(format!("root attrs: {:?}", sorted(file.root().attrs())));
7969
7970 // `plain/nope` runs the walk through a dataset (issue #365): the two
7971 // backends must refuse it with the same error as well as agree on the
7972 // reads that succeed.
7973 for path in [
7974 "plain",
7975 "g/nested",
7976 "many_attrs",
7977 "missing",
7978 "g/missing",
7979 "plain/nope",
7980 ] {
7981 match file.dataset(path) {
7982 Ok(ds) => {
7983 out.push(format!("{path}: shape {:?}", ds.shape()));
7984 out.push(format!("{path}: attrs {:?}", sorted(ds.attrs())));
7985 out.push(format!("{path}: all {:?}", ds.read_i32()));
7986 out.push(format!("{path}: rows {:?}", ds.read_i32_rows(1, 2)));
7987 out.push(format!("{path}: raw rows {:?}", ds.read_raw_rows(0, 1)));
7988 }
7989 Err(e) => out.push(format!("{path}: error {e}")),
7990 }
7991 }
7992 out
7993 }
7994
7995 /// Attribute maps compare only after ordering; `HashMap`'s `Debug` is not
7996 /// deterministic, and an ordering difference here would be noise rather
7997 /// than the divergence this is looking for.
7998 fn sorted(attrs: Result<HashMap<String, AttrValue>, Error>) -> Vec<String> {
7999 match attrs {
8000 Ok(map) => {
8001 let mut v: Vec<String> =
8002 map.iter().map(|(k, val)| format!("{k}={val:?}")).collect();
8003 v.sort();
8004 v
8005 }
8006 Err(e) => vec![format!("error {e}")],
8007 }
8008 }
8009
8010 /// Drive `bytes` down both forms of every read a read-write file serves and
8011 /// require identical answers.
8012 ///
8013 /// Every read has a slice form (walking the whole-file mirror) and a
8014 /// `Source` form, and until a mirrorless backing lands (issue #198) only the
8015 /// slice form ever runs. This makes the other form reachable now, so it
8016 /// cannot quietly drift as its twin is edited.
8017 fn assert_both_read_paths_agree(bytes: &[u8], what: &str) {
8018 let dir = tempfile::tempdir().unwrap();
8019 let path = dir.path().join("both.h5");
8020 std::fs::write(&path, bytes).unwrap();
8021
8022 // One session at a time: `open_rw` takes an exclusive lock, and holding
8023 // two over one path fails outright where OS locks are mandatory.
8024 let via_mirror = {
8025 let f = File::open_rw(&path).unwrap();
8026 read_everything(&f)
8027 };
8028 let via_source = {
8029 let f = File::open_rw_source_only(&path).unwrap();
8030 read_everything(&f)
8031 };
8032
8033 assert_eq!(
8034 via_mirror.len(),
8035 via_source.len(),
8036 "{what}: the two read paths produced different numbers of results"
8037 );
8038 for (m, s) in via_mirror.iter().zip(&via_source) {
8039 assert_eq!(m, s, "{what}: slice and Source read paths disagree");
8040 }
8041 // Guard the guard: a helper that read nothing would make the comparison
8042 // vacuous, and a file whose datasets all failed to open would too.
8043 assert!(
8044 via_mirror.iter().any(|r| r.contains("all Ok(")),
8045 "{what}: no dataset read succeeded, so this compared nothing"
8046 );
8047 }
8048
8049 /// A file exercising each paired read: a plain dataset, a nested one behind
8050 /// a group (path resolution), and one carrying enough attributes to force
8051 /// the dense (fractal-heap) attribute layout rather than compact messages.
8052 fn both_paths_file_bytes(userblock: Option<u64>) -> Vec<u8> {
8053 let mut b = FileBuilder::new();
8054 if let Some(ub) = userblock {
8055 b.with_userblock(ub);
8056 }
8057 b.create_dataset("plain")
8058 .with_i32_data(&(0..24).collect::<Vec<i32>>())
8059 .with_shape(&[6, 4])
8060 .set_attr("units", AttrValue::String("m".into()));
8061 // Well past the eight-attribute compact limit, so the header converts to
8062 // the dense layout and the dense extraction path is the one that runs.
8063 {
8064 let ds = b
8065 .create_dataset("many_attrs")
8066 .with_i32_data(&(0..8).collect::<Vec<i32>>());
8067 for i in 0..24 {
8068 ds.set_attr(&format!("attr_{i:02}"), AttrValue::I64(i));
8069 }
8070 }
8071 let mut g = b.create_group("g");
8072 g.create_dataset("nested")
8073 .with_i32_data(&(100..112).collect::<Vec<i32>>())
8074 .with_shape(&[3, 4]);
8075 b.add_group(g.finish());
8076 b.finish().unwrap()
8077 }
8078
8079 #[test]
8080 fn both_read_paths_agree() {
8081 assert_both_read_paths_agree(&both_paths_file_bytes(None), "no userblock");
8082 }
8083
8084 /// The userblock case is the one where the two forms are built differently:
8085 /// the slice form reframes by slicing at the base address, the `Source` form
8086 /// wraps in a `BaseOffsetSource`. A file with a nonzero base is the only way
8087 /// to compare them.
8088 #[test]
8089 fn both_read_paths_agree_with_a_userblock() {
8090 assert_both_read_paths_agree(&both_paths_file_bytes(Some(512)), "512-byte userblock");
8091 }
8092
8093 /// One 256-element i32 dataset, chunked into 32-element chunks, in memory.
8094 fn chunked_file_bytes() -> Vec<u8> {
8095 let data: Vec<i32> = (0..256).collect();
8096 let mut b = FileBuilder::new();
8097 b.create_dataset("chunked")
8098 .with_i32_data(&data)
8099 .with_shape(&[256])
8100 .with_chunks(&[32]);
8101 b.finish().unwrap()
8102 }
8103
8104 // The DAPL override must drive the *live* `ChunkCache`, not merely the value
8105 // reported by `chunk_cache_config()`. These assertions reach the crate's
8106 // `#[cfg(test)]` cache introspection (unavailable to integration tests), so
8107 // they fail if the resolved config ever stops flowing into the real cache.
8108
8109 #[test]
8110 fn enabled_override_populates_live_cache_over_disabled_file_default() {
8111 let file = File::from_bytes_with_options(
8112 chunked_file_bytes(),
8113 FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
8114 )
8115 .unwrap();
8116
8117 let ds = file
8118 .dataset_with_options(
8119 "chunked",
8120 DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
8121 )
8122 .unwrap();
8123 assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
8124
8125 // The enabled override built the chunk index and retained chunks; the
8126 // disabled file default would have left both empty.
8127 assert!(ds.chunk_cache_stats().index_loaded());
8128 assert!(ds.chunk_cache_stats().cached_chunks() > 0);
8129 }
8130
8131 #[test]
8132 fn disabled_override_suppresses_live_cache_over_enabled_file_default() {
8133 let file = File::from_bytes_with_options(
8134 chunked_file_bytes(),
8135 FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
8136 )
8137 .unwrap();
8138
8139 let ds = file
8140 .dataset_with_options(
8141 "chunked",
8142 DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
8143 )
8144 .unwrap();
8145 assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
8146
8147 // The disabled override suppressed the index and chunk retention; the
8148 // enabled file default would have populated both.
8149 assert!(!ds.chunk_cache_stats().index_loaded());
8150 assert_eq!(ds.chunk_cache_stats().cached_chunks(), 0);
8151 }
8152
8153 /// A group child whose stored (base-relative) object-header address overflows
8154 /// `u64` once the base address is added must be rejected, not wrapped or
8155 /// panicked on. Reaching this needs a nonzero base address, so the file
8156 /// carries a userblock; the child link's stored address is then rewritten to
8157 /// `HADDR_UNDEF` (all ones) so adding the base to it overflows.
8158 #[test]
8159 fn group_child_address_base_overflow_is_rejected() {
8160 const UB: u64 = 512;
8161 let mut b = FileBuilder::new();
8162 b.with_userblock(UB);
8163 let mut child = b.create_group("child");
8164 child.create_dataset("inner").with_i32_data(&[1, 2, 3]);
8165 b.add_group(child.finish());
8166 let mut bytes = b.finish().unwrap();
8167
8168 // Baseline: the file reads and the subgroup is listed.
8169 let file = File::from_bytes(bytes.clone()).unwrap();
8170 assert_eq!(file.root().groups().unwrap(), vec!["child".to_string()]);
8171
8172 // Rewrite the child's stored object-header address to HADDR_UNDEF. It is
8173 // stored base-relative (absolute minus the userblock base) and, for this
8174 // single-child file, appears exactly once in the bytes. The link lives in
8175 // the root object header's chunk-0.
8176 let stored = file
8177 .root()
8178 .group("child")
8179 .unwrap()
8180 .header_address()
8181 .unwrap()
8182 - UB;
8183 let needle = stored.to_le_bytes();
8184 let matches: Vec<usize> = bytes
8185 .windows(8)
8186 .enumerate()
8187 .filter(|(_, w)| *w == needle)
8188 .map(|(i, _)| i)
8189 .collect();
8190 assert_eq!(
8191 matches.len(),
8192 1,
8193 "stored child address {stored:#x} was not uniquely locatable: {matches:?}"
8194 );
8195 bytes[matches[0]..matches[0] + 8].copy_from_slice(&u64::MAX.to_le_bytes());
8196
8197 // The v2 object header is checksum-protected, so a real crafted file would
8198 // carry a matching checksum; recompute the root header's over the edited
8199 // bytes so parsing reaches the address normalization rather than failing on
8200 // the checksum first. Mirrors the chunk-0 extent from `parse_v2`.
8201 #[cfg(feature = "checksum")]
8202 {
8203 let root_addr = file.root().header_address().unwrap() as usize;
8204 assert_eq!(&bytes[root_addr..root_addr + 4], b"OHDR");
8205 let flags = bytes[root_addr + 5];
8206 let mut pos = root_addr + 6;
8207 if flags & 0x20 != 0 {
8208 pos += 16;
8209 }
8210 if flags & 0x10 != 0 {
8211 pos += 4;
8212 }
8213 let width = 1usize << (flags & 0x03);
8214 let chunk0 = (0..width).fold(0usize, |acc, i| {
8215 acc | ((bytes[pos + i] as usize) << (8 * i))
8216 });
8217 pos += width;
8218 let chunk0_end = pos + chunk0;
8219 assert!(
8220 matches[0] < chunk0_end,
8221 "patched link address is outside the root header's chunk-0"
8222 );
8223 let cs = crate::checksum::jenkins_lookup3(&bytes[root_addr..chunk0_end]);
8224 bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
8225 }
8226
8227 // Iterating the root now normalizes `u64::MAX + base` and must surface the
8228 // overflow as a format error rather than panicking or wrapping.
8229 let file = File::from_bytes(bytes).unwrap();
8230 match file.root().groups() {
8231 Err(Error::Format(FormatError::OffsetOverflow { offset, length })) => {
8232 assert_eq!(offset, u64::MAX);
8233 assert_eq!(length, UB);
8234 }
8235 other => panic!("expected group-child address overflow, got {other:?}"),
8236 }
8237 }
8238
8239 /// Recompute a version-2 object header's checksum over its chunk 0, after a
8240 /// test has edited a message inside it.
8241 ///
8242 /// A crafted file a reader must survive carries a *valid* checksum — an
8243 /// attacker recomputes it — so a test that edits a header and leaves the old
8244 /// one measures the checksum rather than the thing it meant to.
8245 #[cfg(feature = "checksum")]
8246 fn refresh_v2_header_checksum(bytes: &mut [u8], header_addr: usize) -> std::ops::Range<usize> {
8247 assert_eq!(&bytes[header_addr..header_addr + 4], b"OHDR");
8248 let flags = bytes[header_addr + 5];
8249 let mut pos = header_addr + 6;
8250 if flags & 0x20 != 0 {
8251 pos += 16;
8252 }
8253 if flags & 0x10 != 0 {
8254 pos += 4;
8255 }
8256 let width = 1usize << (flags & 0x03);
8257 let chunk0 = (0..width).fold(0usize, |acc, i| {
8258 acc | ((bytes[pos + i] as usize) << (8 * i))
8259 });
8260 pos += width;
8261 let chunk0_end = pos + chunk0;
8262 let cs = crate::checksum::jenkins_lookup3(&bytes[header_addr..chunk0_end]);
8263 bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
8264 header_addr..chunk0_end
8265 }
8266
8267 /// A contiguous layout whose declared size disagrees with its dataspace is
8268 /// refused, whatever the dataset's size and whichever read asks.
8269 ///
8270 /// The whole-dataset readers have always refused it. A typed read now takes a
8271 /// large dataset a row window at a time, and a window only ever checks that
8272 /// its *own* rows are inside the declared storage — so without the shared
8273 /// check this refusal would have applied to small datasets, which are still
8274 /// read whole, and not to large ones. A validation that fires depending on
8275 /// the size of the input is the kind that surfaces years later as an
8276 /// inconsistent bug report, which is why both sizes are here.
8277 #[cfg(feature = "checksum")]
8278 #[test]
8279 fn a_layout_size_disagreeing_with_the_dataspace_is_refused_at_every_dataset_size() {
8280 // One dataset below the typed read's window budget and read whole; one
8281 // above it and swept.
8282 for n in [1000usize, 200_000] {
8283 let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
8284 let mut b = crate::writer::FileBuilder::new();
8285 b.create_dataset("t")
8286 .with_f64_data(&data)
8287 .with_shape(&[n as u64]);
8288 let mut bytes = b.finish().unwrap();
8289 // Taken before the edit: an edited header fails its checksum, and the
8290 // address is needed to recompute it.
8291 let header_addr = {
8292 let file = File::from_bytes(bytes.clone()).unwrap();
8293 file.dataset("t").unwrap().header_address().unwrap() as usize
8294 };
8295
8296 // The layout message's size field: the dataset's byte length, which
8297 // appears once in the file. The assertion is the fixture's own guard —
8298 // patching some other field would test nothing in particular.
8299 let declared = (n * 8) as u64;
8300 let needle = declared.to_le_bytes();
8301 let at: Vec<usize> = bytes
8302 .windows(8)
8303 .enumerate()
8304 .filter(|(_, w)| *w == needle)
8305 .map(|(i, _)| i)
8306 .collect();
8307 assert_eq!(
8308 at.len(),
8309 1,
8310 "the stored size {declared} was not uniquely locatable in a {n}-element file: {at:?}"
8311 );
8312 bytes[at[0]..at[0] + 8].copy_from_slice(&(declared * 2).to_le_bytes());
8313
8314 let chunk0 = refresh_v2_header_checksum(&mut bytes, header_addr);
8315 assert!(
8316 chunk0.contains(&at[0]),
8317 "the patched size is outside the header chunk whose checksum was refreshed"
8318 );
8319
8320 let file = File::from_bytes(bytes).unwrap();
8321 let ds = file.dataset("t").unwrap();
8322 let expected = FormatError::DataSizeMismatch {
8323 expected: n * 8,
8324 actual: n * 16,
8325 };
8326 for (what, err) in [
8327 ("read_raw", ds.read_raw().unwrap_err()),
8328 ("read_f64", ds.read_f64().unwrap_err()),
8329 ("read_i32", ds.read_i32().unwrap_err()),
8330 ] {
8331 match err {
8332 Error::Format(got) => assert_eq!(
8333 format!("{got:?}"),
8334 format!("{expected:?}"),
8335 "{what} over {n} elements reported the wrong mismatch"
8336 ),
8337 other => {
8338 panic!("{what} over {n} elements: expected a format error, got {other:?}")
8339 }
8340 }
8341 }
8342 }
8343 }
8344
8345 /// A zero-row window returns `Ok(empty)` uniformly across layouts, including
8346 /// over unallocated storage. Unallocated storage now reads as the fill value
8347 /// rather than erroring, so this no longer guards a cross-layout divergence
8348 /// in the error; what it still pins is that a window of no rows is an *empty*
8349 /// buffer and not a zero-length fill, which is what a caller iterating past
8350 /// the end of a dataset sees.
8351 #[test]
8352 fn read_rows_framed_zero_row_window_is_ok_even_when_unallocated() {
8353 let dl = DataLayout::Contiguous {
8354 address: None,
8355 size: 0,
8356 };
8357 let ds = Dataspace {
8358 space_type: crate::dataspace::DataspaceType::Simple,
8359 rank: 1,
8360 dimensions: vec![0],
8361 max_dimensions: None,
8362 };
8363 let dt = Datatype::FixedPoint {
8364 size: 8,
8365 byte_order: DatatypeByteOrder::LittleEndian,
8366 layout: FixedPointLayout {
8367 signed: false,
8368 bit_offset: 0,
8369 bit_precision: 64,
8370 },
8371 };
8372 let cache = ChunkCache::new();
8373 let out = read_rows_framed(
8374 &BytesSource::new(b""),
8375 RawReadSpec::plain(&dl, &ds, &dt),
8376 8,
8377 8,
8378 &cache,
8379 CachePass::LRU,
8380 0,
8381 0,
8382 8,
8383 )
8384 .expect("a zero-row window must be Ok(empty)");
8385 assert!(out.is_empty());
8386
8387 // A Virtual layout is unsupported and must still error for a zero-row
8388 // window, matching `read_raw`, rather than being swallowed by the early
8389 // return.
8390 let virtual_dl = DataLayout::Virtual;
8391 let err = read_rows_framed(
8392 &BytesSource::new(b""),
8393 RawReadSpec::plain(&virtual_dl, &ds, &dt),
8394 8,
8395 8,
8396 &cache,
8397 CachePass::LRU,
8398 0,
8399 0,
8400 8,
8401 )
8402 .expect_err("a virtual layout must error even for a zero-row window");
8403 assert!(
8404 matches!(err, FormatError::UnsupportedVirtualLayout),
8405 "expected UnsupportedVirtualLayout, got {err:?}"
8406 );
8407 }
8408
8409 #[test]
8410 fn read_rows_framed_reads_a_compact_row_window() {
8411 let dl = DataLayout::Compact {
8412 data: (0..16u8).collect(),
8413 };
8414 let ds = Dataspace {
8415 space_type: DataspaceType::Simple,
8416 rank: 2,
8417 dimensions: vec![4, 4],
8418 max_dimensions: None,
8419 };
8420 let dt = Datatype::FixedPoint {
8421 size: 1,
8422 byte_order: DatatypeByteOrder::LittleEndian,
8423 layout: FixedPointLayout {
8424 signed: false,
8425 bit_offset: 0,
8426 bit_precision: 8,
8427 },
8428 };
8429 let cache = ChunkCache::new();
8430 let rows = |start_row, num_rows| {
8431 read_rows_framed(
8432 &BytesSource::new(b""),
8433 RawReadSpec::plain(&dl, &ds, &dt),
8434 8,
8435 8,
8436 &cache,
8437 CachePass::LRU,
8438 start_row,
8439 num_rows,
8440 4,
8441 )
8442 };
8443
8444 let out = rows(2, 1).expect("the third row of a compact dataset");
8445 assert_eq!(out, vec![8, 9, 10, 11]);
8446
8447 let err = rows(4, 1).expect_err("a window past the inline bytes");
8448 assert!(
8449 matches!(
8450 err,
8451 FormatError::DataSizeMismatch {
8452 expected: 20,
8453 actual: 16
8454 }
8455 ),
8456 "expected DataSizeMismatch, got {err:?}"
8457 );
8458 }
8459
8460 /// The window a typed whole-dataset read sweeps in is a budget in *stored
8461 /// bytes* resolved against the dataset's own geometry, and a chunked dataset
8462 /// adds a second rule on top: whole chunk bands.
8463 ///
8464 /// A window that ended mid-band would make the next window decode the band
8465 /// again — the cost windowing exists to avoid — so the budget is rounded down
8466 /// to a multiple of the band, and *up* to one whole band when even one band
8467 /// is over budget. The three ways a window can come out are one rule with
8468 /// different inputs, which is why they are asserted together.
8469 #[test]
8470 fn a_typed_read_windows_in_whole_chunk_bands() {
8471 const BUDGET: u64 = TYPED_READ_WINDOW_BYTES;
8472 let ds1 = |dims: &[u64]| Dataspace {
8473 space_type: crate::dataspace::DataspaceType::Simple,
8474 rank: dims.len() as u8,
8475 dimensions: dims.to_vec(),
8476 max_dimensions: None,
8477 };
8478 let contiguous = DataLayout::Contiguous {
8479 address: Some(StoredAddress::new(0)),
8480 size: 0,
8481 };
8482 let chunked = |band: u32| DataLayout::Chunked {
8483 flags: ChunkedLayoutFlags::NONE,
8484 chunk_dimensions: vec![u64::from(band), 8],
8485 index: ChunkIndexLayout::BTreeV1 {
8486 address: Some(StoredAddress::new(0)),
8487 },
8488 };
8489 let elem = NonZeroUsize::new(8).unwrap();
8490
8491 // Unchunked: the budget, divided by the row width, exactly.
8492 assert_eq!(
8493 typed_window_rows(&contiguous, &ds1(&[1 << 20]), elem)
8494 .unwrap()
8495 .get(),
8496 BUDGET / 8
8497 );
8498
8499 // A band that divides the budget takes it unchanged; one that does not
8500 // is rounded down to a whole number of bands, never up.
8501 assert_eq!(
8502 typed_window_rows(&chunked(512), &ds1(&[1 << 20]), elem)
8503 .unwrap()
8504 .get(),
8505 BUDGET / 8
8506 );
8507 let rows = typed_window_rows(&chunked(300), &ds1(&[1 << 20]), elem)
8508 .unwrap()
8509 .get();
8510 assert_eq!(rows % 300, 0, "a window must end on a chunk band");
8511 assert!(
8512 rows <= BUDGET / 8 && rows > BUDGET / 8 - 300,
8513 "a window must be the largest whole number of bands within the \
8514 budget, not a smaller one: {rows} rows against {} in budget",
8515 BUDGET / 8
8516 );
8517
8518 // One band over budget: the window is that band, since a narrower one
8519 // would decode it twice.
8520 assert_eq!(
8521 typed_window_rows(&chunked(1 << 20), &ds1(&[1 << 21]), elem)
8522 .unwrap()
8523 .get(),
8524 1 << 20
8525 );
8526
8527 // Rows wider than the whole budget: one row, which is the least a window
8528 // can be and still make progress.
8529 assert_eq!(
8530 typed_window_rows(&contiguous, &ds1(&[4, 1 << 20]), elem)
8531 .unwrap()
8532 .get(),
8533 1
8534 );
8535
8536 // Rank 0. A chunked layout message carries rank + 1 dimensions, so a
8537 // scalar's only entry is the element-size trailer and the "band" read
8538 // out of it is not a band at all. Nothing rests on it: a scalar has one
8539 // row, so `n0 <= rows` sends it to the whole read whatever this says.
8540 // Asserted so that a later reading of `first()` as the leading extent
8541 // has to account for this case rather than discover it.
8542 let scalar = Dataspace {
8543 space_type: crate::dataspace::DataspaceType::Scalar,
8544 rank: 0,
8545 dimensions: Vec::new(),
8546 max_dimensions: None,
8547 };
8548 assert!(typed_window_rows(&chunked(8), &scalar, elem).unwrap().get() >= 1);
8549
8550 // A zero inner dimension makes a row zero bytes wide, and the dataset
8551 // has no elements at all: one window covers it, and nothing divides by
8552 // zero on the way there.
8553 assert_eq!(
8554 typed_window_rows(&contiguous, &ds1(&[4, 0]), elem)
8555 .unwrap()
8556 .get(),
8557 u64::MAX
8558 );
8559 }
8560
8561 /// The two channels a caller has for one object's attributes.
8562 struct AttrChannels {
8563 owner: &'static str,
8564 /// What `attrs` decoded — the values, lossily.
8565 values: HashMap<String, AttrValue>,
8566 /// What `attr_datatypes` reported — the encodings, exactly.
8567 datatypes: HashMap<String, Datatype>,
8568 }
8569
8570 /// One written file: its bytes, and both channels read back from each owner.
8571 struct AttrFile {
8572 /// The file as written, so a test can assert which storage form it got.
8573 bytes: Vec<u8>,
8574 owners: Vec<AttrChannels>,
8575 }
8576
8577 /// Put the same attributes on the root group and on a dataset, write the
8578 /// file, and read both channels back from each owner.
8579 ///
8580 /// Both owners, because `Group` and `Dataset` reach their attribute messages
8581 /// by different routes: one parses an object header by address, the other
8582 /// already holds one.
8583 fn attr_channels(
8584 values: &[(&str, AttrValue)],
8585 verbatim: &[crate::attribute::AttributeMessage],
8586 ) -> AttrFile {
8587 let mut b = FileBuilder::new();
8588 for (name, value) in values {
8589 b.set_attr(name, value.clone());
8590 }
8591 for message in verbatim {
8592 b.set_attr_verbatim(message.clone());
8593 }
8594 {
8595 let ds = b.create_dataset("data").with_f64_data(&[1.0]);
8596 for (name, value) in values {
8597 ds.set_attr(name, value.clone());
8598 }
8599 for message in verbatim {
8600 ds.set_attr_verbatim(message.clone());
8601 }
8602 }
8603 let bytes = b.finish().unwrap();
8604 let file = File::from_bytes(bytes.clone()).unwrap();
8605 let root = file.root();
8606 let dataset = file.dataset("data").unwrap();
8607 AttrFile {
8608 bytes,
8609 owners: vec![
8610 AttrChannels {
8611 owner: "root group",
8612 values: root.attrs().unwrap(),
8613 datatypes: root.attr_datatypes().unwrap(),
8614 },
8615 AttrChannels {
8616 owner: "dataset",
8617 values: dataset.attrs().unwrap(),
8618 datatypes: dataset.attr_datatypes().unwrap(),
8619 },
8620 ],
8621 }
8622 }
8623
8624 /// The two channels on the axes each one carries.
8625 ///
8626 /// Both now report an integer's width: the value channel keeps it (#350),
8627 /// so `count` is `I32` and its datatype is the 4-byte signed type it is
8628 /// stored as. What the value channel still cannot say is how those bytes are
8629 /// laid out — `be` holds the same number big-endian and decodes to the same
8630 /// `I32`, so the datatype channel is the only record that re-encoding from
8631 /// the value would flip its byte order. `AttrValue` is documented as lossy;
8632 /// this is where the loss is (#248).
8633 #[test]
8634 fn attr_datatypes_reports_the_byte_order_attrs_normalizes() {
8635 let be = crate::attribute::AttributeMessage {
8636 name: "be".into(),
8637 datatype: Datatype::FixedPoint {
8638 size: 4,
8639 byte_order: DatatypeByteOrder::BigEndian,
8640 layout: FixedPointLayout {
8641 signed: true,
8642 bit_offset: 0,
8643 bit_precision: 32,
8644 },
8645 },
8646 dataspace: Dataspace {
8647 space_type: crate::dataspace::DataspaceType::Scalar,
8648 rank: 0,
8649 dimensions: vec![],
8650 max_dimensions: None,
8651 },
8652 raw_data: (-7i32).to_be_bytes().to_vec(),
8653 datatype_location: crate::shared_message::DatatypeLocation::Inline,
8654 };
8655
8656 for c in attr_channels(&[("count", AttrValue::I32(-7))], std::slice::from_ref(&be)).owners {
8657 assert_eq!(
8658 c.values.get("count"),
8659 Some(&AttrValue::I32(-7)),
8660 "{}: the value channel keeps the width the attribute was written at",
8661 c.owner
8662 );
8663 let Some(Datatype::FixedPoint {
8664 size,
8665 byte_order: _,
8666 layout: FixedPointLayout { signed, .. },
8667 }) = c.datatypes.get("count")
8668 else {
8669 panic!(
8670 "{}: expected a fixed-point datatype, got {:?}",
8671 c.owner,
8672 c.datatypes.get("count")
8673 );
8674 };
8675 assert_eq!(
8676 (*size, *signed),
8677 (4, true),
8678 "{}: the datatype channel must report the width on disk",
8679 c.owner
8680 );
8681
8682 assert_eq!(
8683 c.values.get("be"),
8684 Some(&AttrValue::I32(-7)),
8685 "{}: a big-endian attribute decodes to the value it holds",
8686 c.owner
8687 );
8688 let Some(Datatype::FixedPoint { byte_order, .. }) = c.datatypes.get("be") else {
8689 panic!(
8690 "{}: expected a fixed-point datatype, got {:?}",
8691 c.owner,
8692 c.datatypes.get("be")
8693 );
8694 };
8695 assert_eq!(
8696 *byte_order,
8697 DatatypeByteOrder::BigEndian,
8698 "{}: the datatype channel is the only record of the byte order",
8699 c.owner
8700 );
8701 }
8702 }
8703
8704 /// Every attribute message is reported, including one `attrs` drops because
8705 /// no `AttrValue` can carry it.
8706 ///
8707 /// That is what lets a caller tell a dropped attribute from an absent one. An
8708 /// omission with nothing to compare against is invisible, which is how every
8709 /// `np.bool_` attribute in an h5py file went missing without a trace (#248).
8710 #[test]
8711 fn attr_datatypes_reports_an_attribute_attrs_omits() {
8712 let opaque = Datatype::Opaque {
8713 size: 3,
8714 tag: b"rgb".to_vec(),
8715 };
8716 let raw = crate::attribute::AttributeMessage {
8717 name: "raw".into(),
8718 datatype: opaque.clone(),
8719 dataspace: Dataspace {
8720 space_type: crate::dataspace::DataspaceType::Scalar,
8721 rank: 0,
8722 dimensions: vec![],
8723 max_dimensions: None,
8724 },
8725 raw_data: vec![1, 2, 3],
8726 datatype_location: crate::shared_message::DatatypeLocation::Inline,
8727 };
8728
8729 for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&raw)).owners {
8730 assert!(
8731 !c.values.contains_key("raw"),
8732 "{}: an opaque attribute has no `AttrValue`, so `attrs` omits it — \
8733 if that changes, this test is measuring the wrong thing",
8734 c.owner
8735 );
8736 assert_eq!(
8737 c.datatypes.get("raw"),
8738 Some(&opaque),
8739 "{}: the datatype channel must report an attribute `attrs` omits",
8740 c.owner
8741 );
8742 // Specific to the one attribute: a channel that reported only the
8743 // undecodable one, or dropped its neighbour, would pass the above.
8744 assert!(
8745 c.values.contains_key("count") && c.datatypes.contains_key("count"),
8746 "{}: the attribute beside it must appear in both channels",
8747 c.owner
8748 );
8749 }
8750 }
8751
8752 /// The refusal reaches a dataset too, not only the attribute the issue was
8753 /// reported through: the same decoders back `Dataset::read_*`, by both the
8754 /// whole-dataset and the windowed route.
8755 #[test]
8756 fn a_wide_dataset_is_refused_by_the_typed_readers() {
8757 let dt = Datatype::FixedPoint {
8758 size: 16,
8759 byte_order: DatatypeByteOrder::LittleEndian,
8760 layout: FixedPointLayout {
8761 signed: false,
8762 bit_offset: 0,
8763 bit_precision: 128,
8764 },
8765 };
8766 let mut b = FileBuilder::new();
8767 b.create_dataset("wide")
8768 .with_raw_data(dt.clone(), vec![0xFF; 32], 2);
8769 let file = File::from_bytes(b.finish().unwrap()).unwrap();
8770 let ds = file.dataset("wide").unwrap();
8771
8772 assert_eq!(
8773 ds.datatype().unwrap(),
8774 dt,
8775 "the datatype still reads: it is the values that have no answer"
8776 );
8777 assert!(matches!(
8778 ds.read_u64(),
8779 Err(Error::Format(FormatError::NumericElementTooWide {
8780 size: 16
8781 }))
8782 ));
8783 assert!(matches!(
8784 ds.read_u64_rows(0, 1),
8785 Err(Error::Format(FormatError::NumericElementTooWide {
8786 size: 16
8787 }))
8788 ));
8789 // What is refused is the decode, not the data: the bytes are still
8790 // there for a caller willing to read the width itself.
8791 assert_eq!(ds.read_raw().unwrap().len(), 32);
8792 }
8793
8794 /// A fixed-point attribute wider than the 64-bit value the readers decode
8795 /// into joins the attributes `attrs` omits, rather than appearing there
8796 /// holding part of its value.
8797 ///
8798 /// This one used to be *present* in the values channel: nine bytes holding
8799 /// 2^64 read back as `U64(0)`, a value indistinguishable from an attribute
8800 /// that really holds zero. Omitting it puts it where the opaque attribute
8801 /// above already sits — absent from the values, reported in full by the
8802 /// datatypes — so a caller can see that something was dropped (#361).
8803 #[test]
8804 fn an_attribute_too_wide_to_decode_is_omitted_rather_than_truncated() {
8805 let wide = Datatype::FixedPoint {
8806 size: 9,
8807 byte_order: DatatypeByteOrder::LittleEndian,
8808 layout: FixedPointLayout {
8809 signed: false,
8810 bit_offset: 0,
8811 bit_precision: 72,
8812 },
8813 };
8814 // 2^64 exactly, so every one of the low 64 bits is zero.
8815 let mut raw_data = vec![0u8; 9];
8816 raw_data[8] = 1;
8817 let huge = crate::attribute::AttributeMessage {
8818 name: "huge".into(),
8819 datatype: wide.clone(),
8820 dataspace: Dataspace {
8821 space_type: crate::dataspace::DataspaceType::Scalar,
8822 rank: 0,
8823 dimensions: vec![],
8824 max_dimensions: None,
8825 },
8826 raw_data,
8827 datatype_location: crate::shared_message::DatatypeLocation::Inline,
8828 };
8829
8830 for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&huge)).owners
8831 {
8832 assert!(
8833 !c.values.contains_key("huge"),
8834 "{}: decoding this attribute would report 0 for a value of 2^64",
8835 c.owner
8836 );
8837 assert_eq!(
8838 c.datatypes.get("huge"),
8839 Some(&wide),
8840 "{}: the datatype channel must still report the width on disk",
8841 c.owner
8842 );
8843 assert!(
8844 c.values.contains_key("count") && c.datatypes.contains_key("count"),
8845 "{}: the attribute beside it must still decode",
8846 c.owner
8847 );
8848 }
8849 }
8850
8851 /// The channel must cover dense (fractal-heap) attribute storage, not only
8852 /// the compact form that lives in the object header.
8853 ///
8854 /// The two tests above use three attributes, which is well inside the
8855 /// writer's compact threshold, so on their own they leave every dense path
8856 /// unpinned: an implementation that walked `hdr.messages` directly and never
8857 /// touched the heap would pass both and report *nothing* for a real file with
8858 /// many attributes. Twelve is past the threshold, and the heap signature is
8859 /// asserted so the fixture cannot quietly revert to compact storage and take
8860 /// the coverage with it.
8861 #[test]
8862 fn attr_datatypes_covers_dense_attribute_storage() {
8863 let names: Vec<String> = (0..12).map(|i| format!("a{i:02}")).collect();
8864 let values: Vec<(&str, AttrValue)> = names
8865 .iter()
8866 .enumerate()
8867 .map(|(i, n)| {
8868 (
8869 n.as_str(),
8870 #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
8871 AttrValue::I32(i as i32),
8872 )
8873 })
8874 .collect();
8875
8876 let f = attr_channels(&values, &[]);
8877 assert!(
8878 f.bytes.windows(4).any(|w| w == b"FRHP"),
8879 "the fixture must really use dense storage, or this test proves \
8880 nothing beyond the compact path the other tests already cover"
8881 );
8882
8883 for c in f.owners {
8884 assert_eq!(
8885 c.datatypes.len(),
8886 names.len(),
8887 "{}: every attribute in the heap must be reported, got {:?}",
8888 c.owner,
8889 c.datatypes.keys().collect::<Vec<_>>()
8890 );
8891 // The two channels must agree on which attributes exist: all of these
8892 // decode, so neither one has anything to omit here.
8893 let mut from_values: Vec<&String> = c.values.keys().collect();
8894 let mut from_types: Vec<&String> = c.datatypes.keys().collect();
8895 from_values.sort();
8896 from_types.sort();
8897 assert_eq!(
8898 from_values, from_types,
8899 "{}: the channels disagree",
8900 c.owner
8901 );
8902 for name in &names {
8903 assert!(
8904 matches!(
8905 c.datatypes.get(name),
8906 Some(Datatype::FixedPoint { size: 4, .. })
8907 ),
8908 "{}: {name} must keep its 4-byte width through the heap, got {:?}",
8909 c.owner,
8910 c.datatypes.get(name)
8911 );
8912 }
8913 }
8914 }
8915
8916 // -----------------------------------------------------------------------
8917 // Coalesced chunk reads (see `crate::chunk_span`)
8918 // -----------------------------------------------------------------------
8919
8920 /// A [`Source`] over a file image that counts what the reader asks the file
8921 /// for, so a test can assert read *volume* and not only the values returned.
8922 struct CountingSource {
8923 bytes: Vec<u8>,
8924 reads: Arc<AtomicUsize>,
8925 bytes_read: Arc<AtomicUsize>,
8926 }
8927
8928 impl Source for CountingSource {
8929 fn len(&self) -> u64 {
8930 self.bytes.len() as u64
8931 }
8932
8933 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
8934 self.reads.fetch_add(1, Ordering::Relaxed);
8935 self.bytes_read.fetch_add(buf.len(), Ordering::Relaxed);
8936 BytesSource::new(&self.bytes).read_at(offset, buf)
8937 }
8938 }
8939
8940 /// Counters for one measured read.
8941 #[derive(Default)]
8942 struct ReadCounts {
8943 reads: Arc<AtomicUsize>,
8944 bytes: Arc<AtomicUsize>,
8945 }
8946
8947 impl ReadCounts {
8948 /// Run `f` and report `(reads, bytes)` it cost.
8949 fn measure<R>(&self, f: impl FnOnce() -> R) -> (usize, usize) {
8950 let r0 = self.reads.load(Ordering::Relaxed);
8951 let b0 = self.bytes.load(Ordering::Relaxed);
8952 f();
8953 (
8954 self.reads.load(Ordering::Relaxed) - r0,
8955 self.bytes.load(Ordering::Relaxed) - b0,
8956 )
8957 }
8958 }
8959
8960 /// A streaming [`File`] over `bytes` whose reads are counted, built the way
8961 /// [`File::open_streaming_with_options`] builds one over a file handle.
8962 fn counting_streaming_file(
8963 bytes: Vec<u8>,
8964 counts: &ReadCounts,
8965 access: FileAccessProperties,
8966 ) -> File {
8967 File::from_source_with_options(
8968 CountingSource {
8969 bytes,
8970 reads: Arc::clone(&counts.reads),
8971 bytes_read: Arc::clone(&counts.bytes),
8972 },
8973 access,
8974 )
8975 .expect("open from source")
8976 }
8977
8978 /// An `n`-element f64 dataset in chunks of `chunk` elements.
8979 fn chunked_f64_file(n: usize, chunk: u64) -> (Vec<u8>, Vec<f64>) {
8980 let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
8981 let mut builder = FileBuilder::new();
8982 builder
8983 .create_dataset("d")
8984 .with_f64_data(&data)
8985 .with_shape(&[n as u64])
8986 .with_chunks(&[chunk]);
8987 (builder.finish().expect("write file"), data)
8988 }
8989
8990 /// One chunk per row is what a writer that appends as data arrives
8991 /// produces; the rows land next to each other, so the streaming reader must
8992 /// fetch them in a few spans rather than one read each.
8993 #[test]
8994 fn a_streaming_read_coalesces_a_run_of_small_chunks() {
8995 let n = 1024usize;
8996 let (bytes, data) = chunked_f64_file(n, 1);
8997 let counts = ReadCounts::default();
8998 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8999
9000 let mut got = Vec::new();
9001 let (reads, _) = counts.measure(|| got = file.dataset("d").unwrap().read_f64().unwrap());
9002 assert_eq!(got, data, "the coalesced read must return the same values");
9003
9004 // Reading each of the 1024 chunks on its own would cost at least that
9005 // many reads; the whole run plus its metadata fits in far fewer.
9006 assert!(
9007 reads < n / 8,
9008 "expected the {n} chunks to be coalesced into few reads, got {reads}"
9009 );
9010 }
9011
9012 /// A windowed read must coalesce only the chunks its window overlaps: a
9013 /// plan built over the dataset's whole chunk list would put the rows on
9014 /// either side of the window inside a span and read them for nothing.
9015 #[test]
9016 fn a_windowed_streaming_read_fetches_only_its_own_window() {
9017 let rows = 4096usize;
9018 let (bytes, data) = chunked_f64_file(rows, 4);
9019 let counts = ReadCounts::default();
9020 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9021 let ds = file.dataset("d").unwrap();
9022
9023 // The first window walks (and caches on this handle) the chunk index,
9024 // so measure the second: what it reads is the window's own chunks.
9025 assert_eq!(ds.read_f64_rows(0, 8).unwrap(), data[0..8]);
9026 let (_, window_bytes) =
9027 counts.measure(|| assert_eq!(ds.read_f64_rows(2048, 8).unwrap(), data[2048..2056]));
9028
9029 // Eight rows are two 32-byte chunks.
9030 assert!(
9031 window_bytes < 256,
9032 "an 8-row window read {window_bytes} bytes; it needs 64"
9033 );
9034 }
9035
9036 /// The read volume of a whole-dataset read must not depend on which read it
9037 /// is. The chunk list comes from the handle's cached index on every read
9038 /// after the first, and that index is a map: it yields no address order at
9039 /// all. A reader holding one coalesced span re-reads a whole span each time
9040 /// an unordered walk crosses back, so the second read of a dataset spanning
9041 /// more than one span cost two orders of magnitude more than the first.
9042 ///
9043 /// A regression here is probabilistic rather than certain — the map's order
9044 /// is seeded per process, and a run that happened to be sorted would pass —
9045 /// but with hundreds of chunks over two spans the chance of that is nil.
9046 /// Correct code passes deterministically.
9047 #[test]
9048 fn a_second_whole_read_costs_what_the_first_did() {
9049 // 512 KiB of f64 in 1 KiB chunks: more than one 256 KiB span, so an
9050 // unordered walk has somewhere to thrash between.
9051 let n = 64 * 1024usize;
9052 let dataset_bytes = n * 8;
9053 let (bytes, data) = chunked_f64_file(n, 128);
9054 let counts = ReadCounts::default();
9055 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9056 let ds = file.dataset("d").unwrap();
9057
9058 counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
9059 let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
9060
9061 assert!(
9062 second <= dataset_bytes,
9063 "the second read fetched {second} bytes of a {dataset_bytes}-byte dataset"
9064 );
9065 }
9066
9067 /// The same, for row windows: the [streaming guide](crate::_guide::streaming) walks a
9068 /// dataset in windows on one handle, so every window but the first takes its chunk list from
9069 /// the cached index.
9070 ///
9071 /// Each window here covers more than one span, which is what it takes to
9072 /// see the defect: a window whose chunks all fit a single span is served
9073 /// out of that one buffer whatever order it walks them in.
9074 #[test]
9075 fn a_window_loop_costs_the_dataset_once() {
9076 // 2 MiB of f64 in 1 KiB chunks, read in 512 KiB windows — two spans
9077 // each.
9078 let rows = 256 * 1024usize;
9079 let dataset_bytes = rows * 8;
9080 let (bytes, data) = chunked_f64_file(rows, 128);
9081 let counts = ReadCounts::default();
9082 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9083 let ds = file.dataset("d").unwrap();
9084
9085 let window = 64 * 1024;
9086 let (_, total) = counts.measure(|| {
9087 for lo in (0..rows).step_by(window) {
9088 let got = ds.read_f64_rows(lo as u64, window as u64).unwrap();
9089 assert_eq!(got, data[lo..lo + window]);
9090 }
9091 });
9092
9093 assert!(
9094 total <= 2 * dataset_bytes,
9095 "a window loop over a {dataset_bytes}-byte dataset read {total} bytes"
9096 );
9097 }
9098
9099 /// A span must not cover a chunk the chunk cache already holds: the read
9100 /// skips that chunk, so those bytes would be fetched for nothing.
9101 ///
9102 /// The cache here is given room for the whole dataset. At the default
9103 /// sixteen slots a dataset this size evicts its own warm chunks as it
9104 /// walks, so every chunk misses on the second read and the plan has nothing
9105 /// to leave out — a config that cannot show the difference either way.
9106 #[test]
9107 fn a_warm_chunk_cache_is_not_re_fetched() {
9108 // 32 KiB of f64 in 32 chunks of 1 KiB, laid end to end.
9109 let n = 4096usize;
9110 let chunk_bytes = 1024usize;
9111 let (bytes, data) = chunked_f64_file(n, 128);
9112 let counts = ReadCounts::default();
9113 let file = counting_streaming_file(
9114 bytes,
9115 &counts,
9116 FileAccessProperties::new()
9117 .with_chunk_cache(ChunkCacheConfig::new().with_max_slots(64)),
9118 );
9119 let ds = file.dataset("d").unwrap();
9120
9121 // Warm the second half of the dataset: chunks 16..31.
9122 assert_eq!(ds.read_f64_rows(2048, 2048).unwrap(), data[2048..]);
9123 assert_eq!(
9124 ds.chunk_cache_stats().cached_chunks(),
9125 16,
9126 "the window's own chunks stay cached"
9127 );
9128
9129 let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
9130 assert_eq!(
9131 second,
9132 16 * chunk_bytes,
9133 "only the cold half was needed; a span over the whole dataset would \
9134 have fetched {} bytes",
9135 32 * chunk_bytes
9136 );
9137 }
9138
9139 // -----------------------------------------------------------------------
9140 // Group member iterators (`iter_datasets` / `iter_groups`)
9141 // -----------------------------------------------------------------------
9142
9143 /// A root holding `datasets` datasets, `groups` subgroups (each with one
9144 /// dataset of its own) and one committed datatype, so a member walk has all
9145 /// three child kinds to sort apart.
9146 ///
9147 /// Names are not zero-padded, so lexical order and insertion order disagree
9148 /// past the tenth member: a walk that silently sorted would show up here.
9149 fn mixed_member_file(datasets: usize, groups: usize) -> Vec<u8> {
9150 let mut b = FileBuilder::new();
9151 b.commit_datatype("a_type", crate::make_i32_type());
9152 for i in 0..datasets {
9153 b.create_dataset(&format!("ds{i}"))
9154 .with_i32_data(&[i as i32, -(i as i32)]);
9155 }
9156 for i in 0..groups {
9157 let mut g = b.create_group(&format!("g{i}"));
9158 g.create_dataset("inner").with_i32_data(&[i as i32]);
9159 g.create_dataset("other").with_i32_data(&[-1]);
9160 b.add_group(g.finish());
9161 }
9162 b.finish().expect("write the fixture")
9163 }
9164
9165 /// The iterator must report exactly what opening each name reports: the same
9166 /// members, in the same order, resolving to the same objects. Anything the
9167 /// two disagree on is a member a caller would see differently for having
9168 /// chosen the cheaper walk.
9169 #[test]
9170 fn iter_datasets_agrees_with_opening_each_name() {
9171 let file = File::from_bytes(mixed_member_file(12, 3)).unwrap();
9172
9173 for group in [file.root(), file.group("g1").unwrap()] {
9174 let names = group.datasets().unwrap();
9175 assert!(
9176 !names.is_empty(),
9177 "the fixture must have members to compare"
9178 );
9179
9180 let iterated: Vec<(String, Dataset)> = group.iter_datasets().unwrap().collect();
9181 assert_eq!(
9182 iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
9183 names,
9184 "the iterator must yield the members `datasets` lists, in that order"
9185 );
9186
9187 for (name, ds) in &iterated {
9188 let opened = group.dataset(name).unwrap();
9189 assert_eq!(
9190 ds.header_address().unwrap(),
9191 opened.header_address().unwrap(),
9192 "{name}"
9193 );
9194 assert_eq!(ds.shape().unwrap(), opened.shape().unwrap(), "{name}");
9195 assert_eq!(ds.read_i32().unwrap(), opened.read_i32().unwrap(), "{name}");
9196 }
9197 }
9198 }
9199
9200 /// The subgroup counterpart, including that a handle it yields can be walked
9201 /// again — recursion through `iter_groups` is the shape it exists for.
9202 #[test]
9203 fn iter_groups_agrees_with_opening_each_name() {
9204 let file = File::from_bytes(mixed_member_file(4, 5)).unwrap();
9205 let root = file.root();
9206
9207 let names = root.groups().unwrap();
9208 let iterated: Vec<(String, Group)> = root.iter_groups().unwrap().collect();
9209 assert_eq!(
9210 iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
9211 names,
9212 "the iterator must yield the subgroups `groups` lists, in that order"
9213 );
9214 assert_eq!(names.len(), 5);
9215
9216 for (name, group) in &iterated {
9217 assert_eq!(
9218 group.header_address().unwrap(),
9219 root.group(name).unwrap().header_address().unwrap(),
9220 "{name}"
9221 );
9222 let mut inner = group
9223 .iter_datasets()
9224 .unwrap()
9225 .map(|(n, _)| n)
9226 .collect::<Vec<_>>();
9227 inner.sort();
9228 assert_eq!(inner, ["inner", "other"], "{name} must be walkable in turn");
9229 }
9230 }
9231
9232 /// Each iterator must claim only its own kind of child. A committed datatype
9233 /// is the child that belongs to neither, and the one a walk asking only for
9234 /// datasets and groups would otherwise be free to mis-sort into either.
9235 #[test]
9236 fn the_member_iterators_sort_the_child_kinds_apart() {
9237 let file = File::from_bytes(mixed_member_file(3, 2)).unwrap();
9238 let root = file.root();
9239
9240 let datasets: Vec<String> = root.iter_datasets().unwrap().map(|(n, _)| n).collect();
9241 let groups: Vec<String> = root.iter_groups().unwrap().map(|(n, _)| n).collect();
9242
9243 assert_eq!(datasets, ["ds0", "ds1", "ds2"]);
9244 assert_eq!(groups, ["g0", "g1"]);
9245 assert_eq!(root.named_datatypes().unwrap(), ["a_type"]);
9246 assert!(
9247 !datasets.contains(&"a_type".to_string()) && !groups.contains(&"a_type".to_string()),
9248 "a committed datatype is neither a dataset nor a group"
9249 );
9250 }
9251
9252 /// The bytes a walk of `n` members reads, by each route: opening every name,
9253 /// and iterating handles.
9254 fn member_walk_bytes(n: usize) -> (usize, usize) {
9255 let mut b = FileBuilder::new();
9256 for i in 0..n {
9257 b.create_dataset(&format!("ds{i}"))
9258 .with_i32_data(&[i as i32]);
9259 }
9260 let bytes = b.finish().expect("write the fixture");
9261
9262 let counts = ReadCounts::default();
9263 let file = counting_streaming_file(bytes.clone(), &counts, FileAccessProperties::new());
9264 let (_, by_name) = counts.measure(|| {
9265 let root = file.root();
9266 for name in root.datasets().unwrap() {
9267 root.dataset(&name).unwrap();
9268 }
9269 });
9270
9271 let counts = ReadCounts::default();
9272 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9273 let (_, iterated) =
9274 counts.measure(|| for (_, _ds) in file.root().iter_datasets().unwrap() {});
9275
9276 (by_name, iterated)
9277 }
9278
9279 /// Opening members by name re-walks the group's link structure once per
9280 /// member; iterating handles walks it once. Bytes is the metric that
9281 /// separates them: a group this size keeps its links inline in the object
9282 /// header, so that header grows with the member count and re-reading it per
9283 /// member is quadratic, while the *number* of reads stays linear either way
9284 /// and would show almost nothing.
9285 ///
9286 /// Asserted as how the cost scales rather than as one fixture's byte count,
9287 /// since a fixed number would pass just as well on a walk that stayed
9288 /// quadratic with a smaller constant.
9289 #[test]
9290 fn iterating_members_enumerates_the_group_once() {
9291 let (_, iterated_16) = member_walk_bytes(16);
9292 let (by_name_64, iterated_64) = member_walk_bytes(64);
9293
9294 // The rule this test exists for, and the only assertion here that is
9295 // about `iter_datasets` itself.
9296 assert!(
9297 iterated_64 <= 5 * iterated_16,
9298 "one enumeration plus one header per member is linear, so four times \
9299 the members must cost about four times the bytes: {iterated_16} -> \
9300 {iterated_64}"
9301 );
9302
9303 // Contrast, not a property of this code: it holds because `Group::dataset`
9304 // re-enumerates. If a future change makes that route cheap enough to turn
9305 // this red, nothing here has regressed — confirm the scaling assertion
9306 // above still holds and then drop this one.
9307 assert!(
9308 iterated_64 < by_name_64,
9309 "at 64 members the one-enumeration walk should still be the cheaper: \
9310 {iterated_64} bytes against {by_name_64}"
9311 );
9312 }
9313
9314 /// A yielded handle must carry the chunk-cache configuration the file was
9315 /// opened with, the same one `dataset` resolves for it.
9316 ///
9317 /// Nothing about the values read would show a handle that quietly ignored
9318 /// it: the cache decides how often the chunk index is re-parsed and how much
9319 /// decompressed data is retained, not what comes back. So the configuration
9320 /// has to be asserted directly, or dropping it here is a silent regression.
9321 #[test]
9322 fn a_member_handle_carries_the_files_chunk_cache_config() {
9323 let configured = ChunkCacheConfig::new()
9324 .with_max_slots(17)
9325 .with_max_bytes(4096)
9326 .with_index_cache(false);
9327 let file = File::from_bytes_with_options(
9328 mixed_member_file(3, 0),
9329 FileAccessProperties::new().with_chunk_cache(configured),
9330 )
9331 .unwrap();
9332 let root = file.root();
9333
9334 let members: Vec<(String, Dataset)> = root.iter_datasets().unwrap().collect();
9335 assert_eq!(members.len(), 3);
9336 for (name, ds) in &members {
9337 assert_eq!(
9338 ds.chunk_cache_config(),
9339 root.dataset(name).unwrap().chunk_cache_config(),
9340 "{name} must resolve its cache the way `dataset` does"
9341 );
9342 assert_eq!(
9343 ds.chunk_cache_config(),
9344 configured,
9345 "{name} must carry the configuration the file was opened with"
9346 );
9347 }
9348 }
9349
9350 /// A file whose root holds `inner` and `sub`, and a group `g0` holding
9351 /// children of those same names, plus a `refs` dataset pointing at `g0`.
9352 ///
9353 /// The duplicated names are the trap: a member handle that wrongly took a
9354 /// root-relative path would address a real object rather than fail, so the
9355 /// mistake would look like a successful write.
9356 fn dereferenced_group_file() -> Vec<u8> {
9357 let mut b = FileBuilder::new();
9358 b.create_dataset("inner").with_i32_data(&[0]);
9359 let mut root_sub = b.create_group("sub");
9360 root_sub.create_dataset("x").with_i32_data(&[0]);
9361 b.add_group(root_sub.finish());
9362
9363 let mut g = b.create_group("g0");
9364 g.create_dataset("inner").with_i32_data(&[1]);
9365 let mut nested = g.create_group("sub");
9366 nested.create_dataset("x").with_i32_data(&[1]);
9367 g.add_group(nested.finish());
9368 b.add_group(g.finish());
9369
9370 b.create_dataset("refs").with_path_references(&["g0"]);
9371 b.finish().expect("write the fixture")
9372 }
9373
9374 /// A group reached by object reference has no resolvable path, so neither can
9375 /// its members: there is nothing for a write through one to address. The
9376 /// iterators must carry that `None` across rather than fall back to a
9377 /// root-relative path, which here would reach a different, real object.
9378 #[test]
9379 fn members_of_a_dereferenced_group_have_no_path() {
9380 let dir = tempfile::tempdir().unwrap();
9381 let path = dir.path().join("refs.h5");
9382 std::fs::write(&path, dereferenced_group_file()).unwrap();
9383
9384 let file = File::open_rw(&path).unwrap();
9385 let mut objects = file.dataset("refs").unwrap().dereference().unwrap();
9386 let group = match objects.remove(0) {
9387 Object::Group(g) => g,
9388 other => panic!("expected a group, got {other:?}"),
9389 };
9390
9391 // The file is writable and both names exist at the root, so a refusal
9392 // here can only come from the handle having no path to address.
9393 let (name, mut member) = group.iter_datasets().unwrap().next().unwrap();
9394 assert_eq!(name, "inner");
9395 assert!(
9396 matches!(
9397 member.set_attr("tag", AttrValue::I64(1)),
9398 Err(Error::ReadOnly)
9399 ),
9400 "a member of a path-less group must refuse a write, not address `/inner`"
9401 );
9402
9403 let (name, subgroup) = group.iter_groups().unwrap().next().unwrap();
9404 assert_eq!(name, "sub");
9405 assert!(
9406 matches!(
9407 subgroup.set_attr("tag", AttrValue::I64(1)),
9408 Err(Error::ReadOnly)
9409 ),
9410 "and so must a subgroup of one, not address `/sub`"
9411 );
9412 }
9413
9414 /// A yielded handle must carry the same root-relative path as one opened by
9415 /// name, or a write through it would address the wrong object — or no object
9416 /// at all. A member of a *subgroup* is the case that separates them, since
9417 /// its path has a prefix to get right.
9418 #[test]
9419 fn a_member_handle_resolves_to_its_own_path() {
9420 let dir = tempfile::tempdir().unwrap();
9421 let path = dir.path().join("members.h5");
9422 std::fs::write(&path, mixed_member_file(2, 2)).unwrap();
9423
9424 let file = File::open_rw(&path).unwrap();
9425 let group = file.group("g1").unwrap();
9426 let (name, mut ds) = group
9427 .iter_datasets()
9428 .unwrap()
9429 .find(|(n, _)| n == "inner")
9430 .expect("g1/inner");
9431 assert_eq!(name, "inner");
9432 ds.set_attr("tag", AttrValue::I64(7)).unwrap();
9433 file.commit().unwrap();
9434 // Windows holds the write lock until the session is dropped.
9435 drop(ds);
9436 drop(group);
9437 drop(file);
9438
9439 let file = File::open(&path).unwrap();
9440 assert_eq!(
9441 file.dataset("g1/inner")
9442 .unwrap()
9443 .attrs()
9444 .unwrap()
9445 .get("tag")
9446 .and_then(AttrValue::as_i64),
9447 Some(7),
9448 "the attribute must land on the member the handle came from"
9449 );
9450 assert!(
9451 !file
9452 .dataset("g0/inner")
9453 .unwrap()
9454 .attrs()
9455 .unwrap()
9456 .contains_key("tag"),
9457 "and on no other group's member of the same name"
9458 );
9459 }
9460
9461 /// Every read-write entry point has to hand the fapl's `fsync` cadence to
9462 /// the session it opens, and none of them can be checked from outside: a
9463 /// skipped barrier writes the same bytes as an issued one (issue #263).
9464 ///
9465 /// One entry point missing the funnel is the whole failure mode, so this
9466 /// asserts the property at each of them rather than at the funnel.
9467 #[test]
9468 fn every_read_write_open_carries_the_fapl_sync_policy() {
9469 use tempfile::tempdir;
9470
9471 let dir = tempdir().unwrap();
9472 let fixture = |name: &str| {
9473 let path = dir.path().join(name);
9474 let mut b = FileBuilder::new();
9475 b.create_dataset("d")
9476 .with_i32_data(&[1, 2, 3, 4])
9477 .with_shape(&[4])
9478 .with_maxshape(&[MaxExtent::Unlimited])
9479 .with_chunks(&[2]);
9480 b.write(&path).unwrap();
9481 path
9482 };
9483 let props = || FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose);
9484 let policy_of = |file: &File| match &file.inner.backend {
9485 Backend::Edit(m) => m
9486 .lock()
9487 .unwrap_or_else(std::sync::PoisonError::into_inner)
9488 .sync_policy(),
9489 _ => panic!("a read-write open must build an editing session"),
9490 };
9491
9492 let opened = File::open_rw_with_options(fixture("open_rw.h5"), props()).unwrap();
9493 assert_eq!(policy_of(&opened), SyncPolicy::OnClose, "File::open_rw");
9494 // Windows OS locks are mandatory: release each session before the next
9495 // open touches the same directory's files.
9496 drop(opened);
9497
9498 let created = File::create_with_options(
9499 dir.path().join("created.h5"),
9500 crate::FileCreateProperties::new(),
9501 props(),
9502 )
9503 .unwrap();
9504 assert_eq!(policy_of(&created), SyncPolicy::OnClose, "File::create");
9505 drop(created);
9506
9507 let swmr = File::open_swmr_writer_with_options(fixture("swmr.h5"), props()).unwrap();
9508 assert_eq!(
9509 policy_of(&swmr),
9510 SyncPolicy::OnClose,
9511 "File::open_swmr_writer"
9512 );
9513 drop(swmr);
9514
9515 let bounded = File::open_rw_with_options(
9516 fixture("bounded.h5"),
9517 props().with_memory_strategy(MemoryStrategy::Bounded),
9518 )
9519 .unwrap();
9520 assert_eq!(
9521 policy_of(&bounded),
9522 SyncPolicy::OnClose,
9523 "File::open_rw_with_options (bounded)"
9524 );
9525 }
9526
9527 /// The page-buffer property is refused where it cannot be honored, rather
9528 /// than accepted and ignored (issues #288 and #308).
9529 ///
9530 /// Each refusal has a different reason and none stands in for the others: a
9531 /// budget under one page is a buffer that drains on every page it touches, a
9532 /// *paged* file that persists no free space can neither commit nor append, a
9533 /// pre-version-3 superblock carries a status-flags byte no library reads back
9534 /// so the crash mark would announce nothing, and the SWMR writer's readers
9535 /// observe the order its writes become visible in. The first is where the C
9536 /// library refuses `H5Pset_page_buffer_size` too; the rest are this crate's.
9537 ///
9538 /// Two shapes are **not** among them, and the accepted cases here are what
9539 /// pin that. An unpaged file: `H5PB_create` requires the paged allocator
9540 /// because the C page buffer is a page cache with per-kind reservations, and
9541 /// this gatherer has neither (issue #357). And a budget below the 1 MiB a
9542 /// session already gathers under, which is a request for less resident
9543 /// memory paid for in writes rather than an unhonorable pair (issue #391).
9544 ///
9545 /// Every refusal also asserts the file is left byte-identical. Each one
9546 /// fires with a read-write session already open, and the version-3 one fires
9547 /// from the same function that raises the mark — so a refusal ordered after
9548 /// the raise would leave a file marked in use by a session that never
9549 /// existed, which is a file nothing can open until `clear_swmr_flag`.
9550 #[test]
9551 fn a_page_buffer_is_refused_where_it_cannot_be_honored() {
9552 use tempfile::tempdir;
9553
9554 let dir = tempdir().unwrap();
9555 let fixture = |name: &str, paged: bool| {
9556 let path = dir.path().join(name);
9557 let mut b = FileBuilder::new();
9558 if paged {
9559 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9560 .with_file_space_page_size(16 * 1024);
9561 }
9562 b.create_dataset("d")
9563 .with_i32_data(&[1, 2, 3, 4])
9564 .with_shape(&[4]);
9565 b.write(&path).unwrap();
9566 path
9567 };
9568 let buffered = |bytes| {
9569 FileAccessProperties::new()
9570 .with_sync_policy(SyncPolicy::OnClose)
9571 .with_page_buffer_size(bytes)
9572 };
9573
9574 // Each refusal fires after the session is already open read-write, so the
9575 // file it declined must be left exactly as it was — and still openable.
9576 let untouched = |label: &str, path: &std::path::Path, before: &[u8]| {
9577 assert_eq!(
9578 std::fs::read(path).unwrap(),
9579 before,
9580 "{label}: a refused open changed the file"
9581 );
9582 assert!(
9583 File::open(path).is_ok(),
9584 "{label}: a refused open left the file unopenable"
9585 );
9586 };
9587
9588 // The two that are *accepted*, kept here beside their former siblings so
9589 // reinstating either refusal fails this test rather than only the
9590 // behavior tests one module over.
9591 let unpaged = fixture("unpaged.h5", false);
9592 let accepted = File::open_rw_with_options(&unpaged, buffered(1 << 20));
9593 assert!(
9594 accepted.is_ok(),
9595 "a page buffer on an unpaged file must be accepted, got {accepted:?}"
9596 );
9597 accepted.unwrap().close().unwrap();
9598
9599 // 256 KiB on a 16 KiB-paged file: a budget the session would not have
9600 // gathered under, which is the point of asking for it.
9601 let small_budget = fixture("small_budget.h5", true);
9602 let accepted = File::open_rw_with_options(&small_budget, buffered(256 * 1024));
9603 assert!(
9604 accepted.is_ok(),
9605 "a page buffer below the byte budget a session gathers under must be \
9606 accepted, got {accepted:?}"
9607 );
9608 accepted.unwrap().close().unwrap();
9609
9610 let paged = fixture("paged.h5", true);
9611 let before = std::fs::read(&paged).unwrap();
9612 let refused = File::open_rw_with_options(&paged, buffered(8192));
9613 assert!(
9614 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("page size")),
9615 "a budget below one page must be refused, got {refused:?}"
9616 );
9617 untouched("budget under one page", &paged, &before);
9618
9619 // And the pairing a caller reaches by doing nothing, since Always is the
9620 // default: every barrier there is an fsync that flushes the buffer.
9621 let refused = File::open_rw_with_options(
9622 &paged,
9623 FileAccessProperties::new().with_page_buffer_size(1 << 20),
9624 );
9625 assert!(
9626 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SyncPolicy::Always")),
9627 "a page buffer under SyncPolicy::Always must be refused, got {refused:?}"
9628 );
9629 untouched("page buffer under Always", &paged, &before);
9630
9631 // A version-2 superblock. This crate's writer refuses `Page` below the
9632 // 1.10 format, so the file is built at version 3 and its superblock
9633 // rewritten — the v2 and v3 layouts are identical apart from the version
9634 // byte and what the flags byte means, which is exactly the point.
9635 let old_format = dir.path().join("v2.h5");
9636 std::fs::copy(&paged, &old_format).unwrap();
9637 {
9638 let mut bytes = std::fs::read(&old_format).unwrap();
9639 let sig = crate::signature::find_signature(&bytes).unwrap();
9640 let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
9641 assert_eq!(sb.version, 3, "the fixture must start at the newer format");
9642 sb.version = 2;
9643 let rewritten = sb.serialize();
9644 bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
9645 std::fs::write(&old_format, &bytes).unwrap();
9646 }
9647 let before = std::fs::read(&old_format).unwrap();
9648 assert!(
9649 File::open(&old_format).is_ok(),
9650 "the version-2 fixture must be a readable file, or the refusal below \
9651 could be about anything"
9652 );
9653 let refused = File::open_rw_with_options(&old_format, buffered(1 << 20));
9654 assert!(
9655 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("version-3 superblock")),
9656 "a page buffer on a pre-v3 superblock must be refused, got {refused:?}"
9657 );
9658 untouched("version-2 superblock", &old_format, &before);
9659
9660 // A paged file whose free space is not persisted, reached two ways: asked
9661 // for outright, and produced by a userblock, for which persistence is
9662 // declined however the creation properties were written. Such a session
9663 // can neither commit nor append, so the buffer would hold nothing while
9664 // its mark blocked every reader.
9665 for (label, name, userblock) in [
9666 ("paged, not persisting", "no_persist.h5", 0u64),
9667 ("paged with a userblock", "ub_paged.h5", 4096),
9668 ] {
9669 let path = dir.path().join(name);
9670 let mut b = FileBuilder::new();
9671 if userblock != 0 {
9672 b.with_userblock(userblock);
9673 }
9674 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, userblock != 0, 1)
9675 .with_file_space_page_size(4096);
9676 b.create_dataset("d")
9677 .with_i32_data(&[1, 2, 3, 4])
9678 .with_shape(&[4]);
9679 b.write(&path).unwrap();
9680 let before = std::fs::read(&path).unwrap();
9681 let refused = File::open_rw_with_options(
9682 &path,
9683 buffered(1 << 20).with_memory_strategy(MemoryStrategy::Mirrored),
9684 );
9685 assert!(
9686 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("persisted")),
9687 "{label}: a page buffer on a session that cannot write must be refused, \
9688 got {refused:?}"
9689 );
9690 untouched(label, &path, &before);
9691 }
9692
9693 let swmr = fixture("swmr.h5", false);
9694 let before = std::fs::read(&swmr).unwrap();
9695 let refused = File::open_swmr_writer_with_options(&swmr, buffered(1 << 20));
9696 assert!(
9697 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SWMR")),
9698 "the SWMR writer must refuse a page buffer, got {refused:?}"
9699 );
9700 // The sharpest of the three: `open_swmr_writer` raises the on-disk
9701 // SWMR-write flag, and a refusal that fired after it would leave every
9702 // later open reporting `FileMarkedInUse`.
9703 untouched("swmr", &swmr, &before);
9704
9705 // And an unset page buffer refuses none of the three.
9706 for (name, paged) in [("ok_unpaged.h5", false), ("ok_paged.h5", true)] {
9707 let f = File::open_rw_with_options(fixture(name, paged), FileAccessProperties::new());
9708 assert!(f.is_ok(), "{name}: an unset page buffer refuses nothing");
9709 }
9710 let f = File::open_swmr_writer_with_options(
9711 fixture("ok_swmr.h5", false),
9712 FileAccessProperties::new(),
9713 );
9714 assert!(f.is_ok(), "swmr: an unset page buffer refuses nothing");
9715 }
9716
9717 /// `File::create_with_options` refuses a creation/access pair whose file it
9718 /// could write but not then open, rather than writing it and failing the open
9719 /// it promised (issue #288).
9720 ///
9721 /// A page buffer needs a budget of at least the file's page size and a
9722 /// version-3 superblock, and both of those are properties of the file being
9723 /// *created* — so the refusal belongs before the bytes are written, not in
9724 /// the reopen. The assertion that matters here is the `!path.exists()`: an
9725 /// error alone would pass with the file already on disk, which is the defect.
9726 ///
9727 /// The version-3 case is the one issue #357 made reachable. While a page
9728 /// buffer required a paged file it could not be: this crate's builder refuses
9729 /// `FileSpaceStrategy::Page` below the 1.10 format outright, so no pair got
9730 /// this far. An unpaged file at `LibVer::V18` is an ordinary buildable file,
9731 /// and without a check here it would be written and only then refused.
9732 #[test]
9733 fn create_with_options_refuses_a_page_buffer_it_could_not_reopen_with() {
9734 use tempfile::tempdir;
9735
9736 let dir = tempdir().unwrap();
9737 let paged = |page: u64| {
9738 crate::FileCreateProperties::new()
9739 .with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9740 .with_file_space_page_size(page)
9741 };
9742 // 8192 clears the format's 4096 default and still falls short of this
9743 // file's 16 KiB page, so it fails only against the page size actually
9744 // read from the file.
9745 //
9746 // The last case is the one a caller reaches by doing nothing: `Always` is
9747 // the default policy, and every other property in that pair is honorable.
9748 // It is here because it was missing — the refusal used to sit at the fapl
9749 // rather than with its siblings, so this function did not restate it and
9750 // the file was written before the open failed.
9751 let cases: [(&str, crate::FileCreateProperties, usize, SyncPolicy); 5] = [
9752 // A page larger than the budget, on a file that does not exist yet:
9753 // the page size has to come from the creation properties, which is
9754 // what this pins.
9755 (
9756 "page larger than the budget",
9757 paged(2 << 20),
9758 1 << 20,
9759 SyncPolicy::OnClose,
9760 ),
9761 (
9762 "the 1.8 format",
9763 crate::FileCreateProperties::new()
9764 .with_libver_bounds(crate::LibVer::Earliest, crate::LibVer::V18),
9765 1 << 20,
9766 SyncPolicy::OnClose,
9767 ),
9768 (
9769 "budget under one page",
9770 paged(16 * 1024),
9771 8192,
9772 SyncPolicy::OnClose,
9773 ),
9774 // The unpaged arm of the same check, which decides on its own now
9775 // that a budget below the session's gather budget is honored (issue
9776 // #391): an unpaged file has no page size, so it is held to the
9777 // format's 4 KiB default and 2 KiB falls short of it.
9778 (
9779 "unpaged budget under the default page",
9780 crate::FileCreateProperties::new(),
9781 2048,
9782 SyncPolicy::OnClose,
9783 ),
9784 (
9785 "the default sync policy",
9786 paged(16 * 1024),
9787 1 << 20,
9788 SyncPolicy::Always,
9789 ),
9790 ];
9791
9792 for (label, create, budget, policy) in cases {
9793 let path = dir
9794 .path()
9795 .join(std::format!("{}.h5", label.replace(' ', "_")));
9796 let result = File::create_with_options(
9797 &path,
9798 create,
9799 FileAccessProperties::new()
9800 .with_sync_policy(policy)
9801 .with_page_buffer_size(budget),
9802 );
9803 assert!(
9804 matches!(result, Err(Error::EditUnsupported(_))),
9805 "{label}: expected a refusal, got {result:?}"
9806 );
9807 assert!(
9808 !path.exists(),
9809 "{label}: the file was written and only then refused"
9810 );
9811 }
9812
9813 // The honorable pairs still create — including the unpaged one, which is
9814 // the default creation properties and so the pair a caller reaches by
9815 // asking for nothing but the buffer, and the 256 KiB one, which is below
9816 // the byte budget a session gathers under (issue #391).
9817 for (label, create, budget) in [
9818 ("paged", paged(16 * 1024), 1 << 20),
9819 ("unpaged", crate::FileCreateProperties::new(), 1 << 20),
9820 ("paged_small_budget", paged(16 * 1024), 256 * 1024),
9821 ] {
9822 let ok = File::create_with_options(
9823 dir.path().join(std::format!("ok_{label}.h5")),
9824 create,
9825 FileAccessProperties::new()
9826 .with_sync_policy(SyncPolicy::OnClose)
9827 .with_page_buffer_size(budget),
9828 );
9829 assert!(
9830 ok.is_ok(),
9831 "{label}: an honorable budget must create: {ok:?}"
9832 );
9833 ok.unwrap().close().unwrap();
9834 }
9835 }
9836
9837 /// `close` and `drop` issue their barrier under *every* policy, on both the
9838 /// ordinary and the SWMR branch — the four sites where this crate writes
9839 /// after the last point a caller could have ordered anything (issue #263).
9840 ///
9841 /// This is the half of the contract `SyncPolicy` cannot express: the two
9842 /// `drop` sites are unreachable by any caller discipline at all, since the
9843 /// handle that would have issued `File::sync` is gone by the time they run.
9844 /// Asserted through a counting image, because the difference between a
9845 /// forced barrier and a skipped one is invisible in the bytes.
9846 #[test]
9847 fn close_and_drop_force_their_barrier_under_every_policy() {
9848 use crate::edit::WriteEngine;
9849 use std::sync::Arc;
9850 use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
9851 use tempfile::tempdir;
9852
9853 let dir = tempdir().unwrap();
9854 // `swmr` picks the teardown branch; `explicit` picks `close` over `drop`.
9855 let teardown = |name: &str, swmr: bool, explicit: bool| -> u64 {
9856 let path = dir.path().join(name);
9857 let mut b = FileBuilder::new();
9858 b.create_dataset("d")
9859 .with_i32_data(&(0..8).collect::<Vec<_>>())
9860 .with_shape(&[8])
9861 .with_maxshape(&[MaxExtent::Unlimited])
9862 .with_chunks(&[4]);
9863 // Persisting, so the ordinary branch has manager re-homing to do:
9864 // an immediate append below leaves the on-disk managers mid-file,
9865 // and settling them is the write no earlier sync could cover.
9866 b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
9867 b.write(&path).unwrap();
9868
9869 let syncs = Arc::new(AtomicU64::new(0));
9870 let session =
9871 WriteEngine::open_sync_counting(&path, SyncPolicy::OnClose, Arc::clone(&syncs))
9872 .unwrap();
9873 let mut inner = FileInner::from_rw_session(
9874 session,
9875 FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose),
9876 )
9877 .unwrap();
9878 inner.swmr_write = swmr;
9879 let file = File {
9880 inner: Arc::new(inner),
9881 };
9882 if !swmr {
9883 // The SWMR branch stages nothing and appends through its own
9884 // path; give the ordinary branch real work to settle.
9885 file.dataset("d")
9886 .unwrap()
9887 .append(&[8i32, 9, 10, 11])
9888 .unwrap();
9889 }
9890 assert_eq!(
9891 syncs.load(AtomicOrdering::Relaxed),
9892 0,
9893 "nothing before teardown may sync under OnClose ({name})"
9894 );
9895
9896 if explicit {
9897 file.close().unwrap();
9898 } else {
9899 drop(file);
9900 }
9901 syncs.load(AtomicOrdering::Relaxed)
9902 };
9903
9904 for (name, swmr, explicit) in [
9905 ("close_plain.h5", false, true),
9906 ("close_swmr.h5", true, true),
9907 ("drop_plain.h5", false, false),
9908 ("drop_swmr.h5", true, false),
9909 ] {
9910 assert!(
9911 teardown(name, swmr, explicit) > 0,
9912 "{name} must force its barrier: the writes it makes are past the \
9913 last point a caller could have ordered them"
9914 );
9915 }
9916 }
9917}