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::address::BaseAddress;
11use crate::edit::{
12 AppendBuilder, AppendGeometry, AppendTarget, EditBacking, MemoryStrategy, SpaceAccounting,
13 StagedChild, StagedKind, StagedMeta, StagedObject, SyncPolicy, WriteEngine,
14};
15use crate::element::H5Element;
16use crate::type_builders::{DatasetBuilder, VL_REF_SIZE};
17
18use crate::appender::BufferedAppender;
19use crate::attribute::{extract_attributes_full, extract_attributes_full_from_source};
20use crate::chunk_cache::{CachePass, ChunkCache, ChunkCacheConfig, ChunkCacheStats};
21use crate::compound::CompoundType;
22use crate::convert::TryToUsize;
23use crate::data_layout::DataLayout;
24use crate::data_read;
25use crate::dataspace::Dataspace;
26use crate::datatype::{Datatype, ReferenceType};
27use crate::error::{Error, FormatError};
28use crate::file_create_properties::FileCreateProperties;
29use crate::file_lock::{self, FileLocking, OpenIntent, OpenTarget, WriteMarkPolicy};
30use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy};
31use crate::fill_value::FillPattern;
32use crate::filter_pipeline::FilterPipeline;
33use crate::free_space_manager;
34use crate::group_v1::GroupEntry;
35use crate::group_v2::{self, ChildLookup, is_group};
36use crate::layout_info::{Chunk, ChunkIndex, Filter, Layout};
37use crate::libver::LibVer;
38use crate::message_type::MessageType;
39use crate::object_header::ObjectHeader;
40use crate::read_spec::RawReadSpec;
41use crate::shared_message::{self, BufferedResolver, SharedResolver, SourceResolver};
42use crate::signature;
43use crate::source::{
44 BaseOffsetSource, BytesSource, MetadataCacheConfig, MetadataCacheStats, MetadataCachingSource,
45 ReadSeekSource, Source, ValidatedSource, frame,
46};
47use crate::superblock::Superblock;
48use crate::vl_data::{self, VlenStringReadOptions};
49
50use crate::types::{AttrValue, DType, attrs_to_map, classify_datatype};
51
52// ---------------------------------------------------------------------------
53// File
54// ---------------------------------------------------------------------------
55
56/// Backing store for a [`File`]: either the whole file buffered in memory, or a
57/// lazy [`Source`] that reads regions on demand (see [`File::open_streaming`]).
58enum Backend {
59 InMemory(Vec<u8>),
60 Streaming(Box<dyn Source + Send + Sync>),
61 /// A read-write file opened with [`File::open_rw`]: a [`WriteEngine`] (exclusive OS lock + staged
62 /// edit queues + append geometry cache) behind a lock, so owned handles can
63 /// both read and mutate in place. Handle write methods route to the engine,
64 /// and `File::commit` applies staged structural edits.
65 ///
66 /// Either backing — a whole-file mirror, or positioned I/O against the
67 /// handle — appears here as the same `WriteEngine`; which one an open
68 /// resolved to is the engine's own business rather than the backend's
69 /// (issue #198). Reads
70 /// borrow the mirror's slice when there is one and go through the image's
71 /// `Source` otherwise; see [`with_engine`](FileInner::with_engine). Boxed to
72 /// keep the `Backend` enum small (a `WriteEngine` is far larger than the
73 /// other variants).
74 Edit(Box<Mutex<WriteEngine>>),
75}
76
77/// What an operation through a file's write session can do to it, which is what
78/// decides how much of an object handle's memo it invalidates — and, with it,
79/// which edit surface the operation is using.
80///
81/// One variant carries both because they coincide: the *staged* surface is the
82/// one that commits, and a commit is the only thing that moves an object header,
83/// so [`Relocating`](Self::Relocating) is exactly the surface a SWMR writer
84/// refuses. An operation off that surface that could relocate would break the
85/// pairing and would have to say which it was separately.
86#[derive(Clone, Copy)]
87enum Change {
88 /// Rewrites object headers and can move them: a commit. The staging of one
89 /// counts too, though it writes nothing — a pending edit is a header move
90 /// this session has not made yet, and an address is worth no more against
91 /// one than against the commit that will apply it. Invalidates a handle's
92 /// address as well as the header it parsed.
93 ///
94 /// This is the staged surface, which a SWMR writer refuses.
95 Relocating,
96 /// Changes bytes without moving any object header: an immediate
97 /// [`Dataset::append`], which rewrites the dataset's dimension where its
98 /// header stands, and the free-space and status-flag bookkeeping a session's
99 /// teardown rewrites where it stands. Invalidates the parsed header alone,
100 /// which is what lets a handle reached by object reference — the one kind
101 /// with no name to look itself up by — go on appending.
102 InPlace,
103 /// Changes nothing a handle can observe: a durability barrier over writes
104 /// the operations that made them already accounted for, or a question whose
105 /// answer the engine caches. Invalidates no memo.
106 ///
107 /// Distinct from not going through the gate at all, which is what a *read*
108 /// does: these still need the write session, and still need the file to be
109 /// open for writing and unsealed.
110 Nothing,
111}
112
113/// Where an object handle's header sits, and what that answer holds as of.
114///
115/// A handle names its object — by path, or by the address a reference gave it —
116/// and this is a memo of what that name last resolved to. See
117/// [`FileInner::locate`], which takes one, and the two counters it reads.
118#[derive(Clone, Copy)]
119struct Resolution {
120 content_revision: u64,
121 address_revision: u64,
122 address: u64,
123}
124
125/// The pair of revisions read *before* a resolution is worked out, which the
126/// answer is then labelled with.
127///
128/// Splitting the reads from the address is what keeps the order right at every
129/// call site: there is no way to label an address with a revision taken after
130/// it, which would claim a freshness the address does not have. See
131/// [`FileInner::locate`].
132#[derive(Clone, Copy)]
133struct Revisions {
134 content: u64,
135 address: u64,
136}
137
138impl Revisions {
139 /// Label `address` as worked out at these revisions.
140 const fn at(self, address: u64) -> Resolution {
141 Resolution {
142 content_revision: self.content,
143 address_revision: self.address,
144 address,
145 }
146 }
147}
148
149/// Where a [`Dataset`] or [`Group`] handle stands with respect to the staged
150/// set, worked out by [`FileInner::staged_standing`].
151///
152/// A handle names its object by path, and a path can mean an object in the
153/// file, an object this session has staged, or — for a handle *born* onto a
154/// staged creation — nothing at all, once that creation is withdrawn. The third
155/// is why the handle carries a mark of its own: without one, a withdrawal is
156/// indistinguishable from a commit, and the handle would silently start
157/// answering for whatever the file holds at the path, which in the case that
158/// produces it is the object the session is deleting.
159#[derive(Clone, Copy, PartialEq, Eq, Debug)]
160enum Standing {
161 /// Resolve the path against the file, as a handle opened by name does.
162 Live,
163 /// A creation this session staged owns the path and no commit has written
164 /// it: [`Error::NotCommitted`] for anything needing its bytes.
165 Pending,
166 /// This handle was made onto a staged creation that has since been
167 /// withdrawn: [`Error::StagingWithdrawn`].
168 Withdrawn,
169}
170
171/// A borrowed `Source` view over a [`File`]'s backend, used by the
172/// streaming-capable read paths so one call site serves both backends.
173pub(crate) enum SourceView<'a> {
174 Mem(&'a [u8]),
175 Stream(&'a (dyn Source + Send + Sync)),
176}
177
178impl Source for SourceView<'_> {
179 fn len(&self) -> u64 {
180 match self {
181 SourceView::Mem(b) => b.len() as u64,
182 SourceView::Stream(s) => s.len(),
183 }
184 }
185 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
186 match self {
187 SourceView::Mem(b) => BytesSource::new(*b).read_at(offset, buf),
188 SourceView::Stream(s) => s.read_at(offset, buf),
189 }
190 }
191
192 fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
193 match self {
194 SourceView::Mem(b) => BytesSource::new(*b).read_metadata_at(offset, len),
195 SourceView::Stream(s) => s.read_metadata_at(offset, len),
196 }
197 }
198
199 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
200 match self {
201 // A whole-file buffer is already the cache, and holds no second one.
202 SourceView::Mem(_) => None,
203 SourceView::Stream(s) => s.metadata_cache_stats(),
204 }
205 }
206
207 fn reset_metadata_cache_stats(&self) {
208 match self {
209 SourceView::Mem(_) => {}
210 SourceView::Stream(s) => s.reset_metadata_cache_stats(),
211 }
212 }
213}
214
215/// File-access properties applied when opening an HDF5 file.
216///
217/// This is the `hdf5-pure` analogue of an HDF5 **file access property list**
218/// (`fapl`): one value carrying every access-time setting, built once and passed
219/// to whichever open a caller reaches for, exactly as a `fapl` is handed to
220/// `H5Fopen`. Every `*_with_options` constructor on [`File`] accepts it, so a
221/// read path and a read-write path can share one configuration.
222///
223/// The `Properties` suffix means the type stands in for one whole HDF5 property
224/// list, so every setting on it has a C counterpart to look up. It is a stand-in
225/// and not a port: a plain `Copy` value, with no handle to create or close, no
226/// runtime property registry, and no setter that can fail. `fapl` and each
227/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
228///
229/// - The metadata cache (`H5Pset_mdc_config`) applies to the streaming and
230/// bounded backends; an in-memory open already holds the whole file in one
231/// buffer.
232/// - The chunk cache (`H5Pset_cache`) is the file-wide default for datasets
233/// opened from any backend, overridable per dataset with
234/// [`DatasetAccessProperties`].
235/// - The locking policy (`H5Pset_file_locking`) applies to the read-write opens.
236/// Readers and the SWMR writer take no lock by design, so they ignore it.
237/// - The write-mark policy applies to the read-only opens, and has no C
238/// counterpart: `H5Fopen` refuses a file marked open for write with no
239/// override, where [`with_write_mark_policy`](Self::with_write_mark_policy)
240/// can admit a snapshot read of one.
241///
242/// See the [property-support reference] for the full property-by-property map.
243///
244/// [property-support reference]: https://github.com/CramBL/hdf5-pure/blob/main/docs/reference/property-support.md
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
246#[doc(alias = "fapl")]
247pub struct FileAccessProperties {
248 metadata_cache: MetadataCacheConfig,
249 chunk_cache: ChunkCacheConfig,
250 locking: FileLocking,
251 memory_strategy: Option<MemoryStrategy>,
252 libver_bounds: Option<(LibVer, LibVer)>,
253 sync_policy: SyncPolicy,
254 page_buffer_size: usize,
255 write_mark_policy: WriteMarkPolicy,
256}
257
258impl FileAccessProperties {
259 /// A value carrying the crate's default access behavior.
260 pub const fn new() -> Self {
261 Self {
262 metadata_cache: MetadataCacheConfig::disabled(),
263 chunk_cache: ChunkCacheConfig::new(),
264 locking: FileLocking::Enabled,
265 memory_strategy: None,
266 libver_bounds: None,
267 sync_policy: SyncPolicy::Always,
268 page_buffer_size: 0,
269 write_mark_policy: WriteMarkPolicy::Refuse,
270 }
271 }
272
273 /// Configure the bounded streaming metadata cache.
274 #[doc(alias = "H5Pset_mdc_config")]
275 pub const fn with_metadata_cache(mut self, metadata_cache: MetadataCacheConfig) -> Self {
276 self.metadata_cache = metadata_cache;
277 self
278 }
279
280 /// Configure the per-dataset raw chunk cache used by datasets opened from
281 /// this file. This is the `H5Pset_cache`-style file-wide default.
282 #[doc(alias = "H5Pset_cache")]
283 pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
284 self.chunk_cache = chunk_cache;
285 self
286 }
287
288 /// Set the OS advisory file-locking policy for the read-write opens.
289 ///
290 /// Defaults to [`FileLocking::Enabled`]. Use [`FileLocking::Disabled`] only
291 /// when an external mechanism already guarantees single-writer access, or
292 /// [`FileLocking::BestEffort`] on a filesystem (such as some network mounts)
293 /// where the OS lock is unavailable. Setting `HDF5_USE_FILE_LOCKING` in the
294 /// environment overrides this, as in the C library.
295 ///
296 /// Readers and [`File::open_swmr_writer`] take no lock by design and ignore
297 /// this.
298 #[doc(alias = "H5Pset_file_locking")]
299 pub const fn with_locking(mut self, locking: FileLocking) -> Self {
300 self.locking = locking;
301 self
302 }
303
304 /// Set how much memory a read-write open may use to hold the file.
305 ///
306 /// Unset by default, which lets the entry point choose:
307 /// [`File::open_rw`] uses [`MemoryStrategy::Auto`], preferring the bounded
308 /// engine and falling back to the whole-file mirror for a file it cannot
309 /// edit. Setting this overrides that default, in either direction, so
310 /// [`MemoryStrategy::Bounded`] refuses such a file rather than quietly
311 /// spending `O(file size)` memory on a caller who asked not to;
312 /// [`MemoryStrategy::Mirrored`] takes
313 /// the whole-file mirror unconditionally, as `open_rw` did before it learned
314 /// to dispatch.
315 ///
316 /// The read-only opens ignore this: they build no editing session at all, and
317 /// their own names say what memory they spend. [`File::open_swmr_writer`]
318 /// does build one, and always mirrors: it accepts
319 /// [`MemoryStrategy::Auto`] and [`MemoryStrategy::Mirrored`], both of which
320 /// the mirror satisfies, and refuses an explicit [`MemoryStrategy::Bounded`]
321 /// with [`Error::EditUnsupported`] rather than quietly not honoring it. Ask a
322 /// `File` which backend it resolved to with [`File::edit_backing`].
323 pub const fn with_memory_strategy(mut self, memory_strategy: MemoryStrategy) -> Self {
324 self.memory_strategy = Some(memory_strategy);
325 self
326 }
327
328 /// Constrain the on-disk format an editing session may write, mirroring
329 /// HDF5's `H5Pset_libver_bounds` — which the C library classes as a *file
330 /// access* property for exactly this reason: it governs what a later write
331 /// to an existing file is allowed to add.
332 ///
333 /// Unset by default, which keeps [`File::open_rw`] adding whatever the
334 /// content needs. That default is what lets a file the C library wrote under
335 /// its own bounds be edited at all, but it means a session can add content
336 /// only a newer library can read *without changing the superblock*, and the
337 /// caller has no way to see it happen: adding a chunked, filtered, or
338 /// resizable dataset to an HDF5 1.8 file needs the version 4 data-layout
339 /// message and a 1.10 chunk index, since this crate does not write the
340 /// version 1 B-tree index that 1.8 used.
341 ///
342 /// Setting a `high` below [`LibVer::V110`] refuses that addition with
343 /// [`FormatError::LibverTooOldForContent`](crate::FormatError::LibverTooOldForContent)
344 /// at [`File::commit`] instead — the same refusal
345 /// [`FileBuilder::with_libver_bounds`](crate::FileBuilder::with_libver_bounds)
346 /// gives when writing a whole file, so a `.mat` bounded to 1.8 for MATLAB
347 /// stays loadable by MATLAB after an edit.
348 ///
349 /// `low` only rules formats out — as in the C library it licenses newer
350 /// encodings without requiring them — so a lower bound of [`LibVer::V112`],
351 /// [`LibVer::V114`] or [`LibVer::LATEST`] leaves the session writing the 1.10
352 /// format rather than failing, provided `high` reaches it. An inverted range
353 /// such as `V114..=V110` is refused with
354 /// [`FormatError::LibverBoundsUnsatisfiable`](crate::FormatError::LibverBoundsUnsatisfiable).
355 ///
356 /// The read-only opens ignore this: they write nothing. [`File::open_swmr_writer`]
357 /// requires a version 3 superblock, so it refuses a `high` below
358 /// [`LibVer::V110`] up front rather than accepting a bound it cannot honor.
359 #[doc(alias = "H5Pset_libver_bounds")]
360 pub const fn with_libver_bounds(mut self, low: LibVer, high: LibVer) -> Self {
361 self.libver_bounds = Some((low, high));
362 self
363 }
364
365 /// Choose who owns this session's `fsync` cadence — this crate, or the
366 /// application through [`File::sync`].
367 ///
368 /// Defaults to [`SyncPolicy::Always`]: every commit and every immediate
369 /// [`Dataset::append`](crate::Dataset::append) forces its writes to durable
370 /// storage before returning. [`SyncPolicy::OnClose`] issues no `fsync` at all,
371 /// which is what the reference C library does; the writes still reach the
372 /// operating system by the time the operation making them returns, so only
373 /// power-loss durability moves to the caller. [`with_page_buffer_size`](Self::with_page_buffer_size),
374 /// off by default, is the one setting that changes that — and it requires
375 /// this policy.
376 ///
377 /// The read-only opens ignore this: they write nothing.
378 pub const fn with_sync_policy(mut self, sync_policy: SyncPolicy) -> Self {
379 self.sync_policy = sync_policy;
380 self
381 }
382
383 /// Let a read-write session's writes accumulate in a page buffer of
384 /// `bytes`, so repeated small updates landing in the same page cost one
385 /// write rather than one each.
386 ///
387 /// Defaults to `0`, which is off — as `H5Pset_page_buffer_size` defaults to
388 /// off — and leaves the gathering every read-write session already does: one
389 /// write per dirty page per *ordering barrier*, so a commit or an append
390 /// still reaches the operating system in full before it returns. What this
391 /// buys on top is letting a dirty page survive those barriers, which is where
392 /// a workload of many small appends into a few pages does most of its
393 /// repeating. Measured on a paged file, 32 chunk appends into eight datasets
394 /// followed by a commit: **188 writes with the default gathering and 5 with a
395 /// page buffer**, of which two are the mark below going up and coming down.
396 /// The appends issue nothing at all until the session ends.
397 ///
398 /// **It pays off over a long session, and costs on a short one.** The crash
399 /// mark below is two `fsync`s per session whatever the session then does, so
400 /// there is a break-even: measured on an Apple M1 Max (APFS) with 256-byte
401 /// appends into eight datasets, 400 appends ran 0.75x — slower — 800 broke
402 /// even, and 6,400 ran 1.64x. The ratio climbs with session length, because
403 /// the same pages are re-dirtied more often, and narrows to about 1.1x once
404 /// 64 KiB payloads rather than metadata churn dominate. One host's numbers,
405 /// and the short end is noisy; re-measure on the one that matters with
406 /// `cargo bench --bench hot_paths -- page_buffer`, which runs both sides of
407 /// the crossing.
408 ///
409 /// # What it costs, and what pays for it
410 ///
411 /// Gathered writes go out in address order, and every publish point sits
412 /// below the content it reaches, so all of them are issued first. A write
413 /// that fails, or a process that dies, mid-flush can therefore leave a file
414 /// whose superblock, dataset length or object header names bytes that never
415 /// arrived — and two of those read back **clean**, as fill values or as a
416 /// deleted object's data, with every checksum verifying. That is what a
417 /// write-back page buffer is, rather than a fault in this one:
418 /// `H5Pset_page_buffer_size` reorders the same way and makes no
419 /// crash-consistency claim either.
420 ///
421 /// So this session raises superblock status-flag bit 0
422 /// (`H5F_SUPER_WRITE_ACCESS`) for its whole life, `fsync`ed once at open and
423 /// cleared on a clean [`File::close`] or drop — the mark the reference C
424 /// library raises for *any* writer. A session that dies with pages in memory
425 /// leaves that byte standing, and a file carrying it is refused by this
426 /// crate, by `H5Fopen` and by h5py alike, with
427 /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse). The silent
428 /// wrong answer becomes a refusal, and
429 /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — the `h5clear -s`
430 /// equivalent — is how to look at such a file anyway, knowing what it may
431 /// hold. A completed commit's bytes may also still be in this process's
432 /// memory when it returns.
433 ///
434 /// # Refusals
435 ///
436 /// Four, each refused with
437 /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) rather than
438 /// quietly ignored:
439 ///
440 /// - a budget below the page the session merges within: the file's own
441 /// file-space page size when it was created with
442 /// [`FileSpaceStrategy::Page`](crate::FileSpaceStrategy::Page), and the
443 /// format's 4 KiB default otherwise. A buffer that cannot hold one page
444 /// drains on every page it touches;
445 /// - a **paged** file whose free space is not persisted, which can be neither
446 /// committed to nor appended to, so the buffer would hold nothing while its
447 /// mark blocked every reader;
448 /// - a superblock older than version 3, whose status-flags byte no library
449 /// reads back, so the mark above would announce nothing;
450 /// - [`SyncPolicy::Always`](crate::SyncPolicy::Always), the default, where
451 /// every barrier is an `fsync` that flushes the buffer on its way out — so
452 /// it would hold nothing while still costing the mark. Pair this with
453 /// [`with_sync_policy(SyncPolicy::OnClose)`](Self::with_sync_policy).
454 ///
455 /// [`File::create_with_options`] refuses a creation/access pair it could not
456 /// then reopen with, rather than writing the file first, and
457 /// [`File::open_swmr_writer`] refuses a page buffer outright: its readers
458 /// observe the order its writes become visible in, which is exactly what a
459 /// buffer coalesces away.
460 ///
461 /// # Choosing a budget
462 ///
463 /// Any budget of at least one page is honored. One below 1 MiB is an explicit
464 /// request for less resident memory, not a mistake — a writer inside a tight
465 /// memory cap can ask for 256 KiB and get it — and what it buys that memory
466 /// with is writes: the budget is the point at which everything held is
467 /// flushed, so a long contiguous run is flushed and restarted once per
468 /// budget's worth of it. Writes issued on a 4 KiB-paged file:
469 ///
470 /// | workload | unset | 4 KiB | 64 KiB | 1 MiB |
471 /// | --- | --- | --- | --- | --- |
472 /// | 32 chunk appends into 8 datasets, then a commit | 188 | 25 | 4 | 4 |
473 /// | one 4 MiB append | 131 | 1,094 | 74 | 10 |
474 ///
475 /// On the scattered workload this property exists for, a small budget costs
476 /// little; on the long run 64 KiB is seven times the writes of 1 MiB.
477 ///
478 /// The memory comparison against leaving this unset is not the one the table
479 /// suggests. A session that sets nothing already gathers up to 1 MiB of dirty
480 /// bytes **per operation**, and releases it at every ordering barrier; a page
481 /// buffer holds its budget **across** operations, until the budget is spent,
482 /// an `fsync`, or [`File::close`]. So 1 MiB here trades a per-operation peak
483 /// for a continuous residency of the same size, and a budget below 1 MiB
484 /// lowers both.
485 ///
486 /// # How this differs from `H5Pset_page_buffer_size`
487 ///
488 /// **A paged file is not required, where the C library requires one.**
489 /// `H5PB_create` refuses an unpaged file because the C page buffer is a page
490 /// *cache*, and its `min_meta_perc` / `min_raw_perc` reservations are counted
491 /// in pages that the paged allocator keeps segregated by kind. This is a
492 /// write gatherer: it merges runs within a page-sized window and flushes
493 /// whole, so a window is all it needs, and an unpaged file gets the same
494 /// 4 KiB one that every read-write session already gathers under. Since
495 /// unpaged is the default strategy, requiring `Page` put this property out of
496 /// reach of most files for no reason this implementation had.
497 ///
498 /// **A small budget costs writes here, where it costs none in C.**
499 /// `H5PB_write` sends any I/O of a page or more straight to the driver, so a
500 /// small `page_buf_size` there caps memory without throttling a long write.
501 /// Nothing bypasses this buffer — the budget is the point at which everything
502 /// held is flushed — so a small one turns a single long run into repeated
503 /// flushes. Both libraries accept the budget; only this one charges for it.
504 /// See [Choosing a budget](#choosing-a-budget) for what it charges.
505 ///
506 /// **A sub-page budget is refused rather than rounded.** `H5Fopen` rounds it
507 /// up to one page silently, and `H5Fcreate` refuses it. A property quietly
508 /// ignored is worse than one refused.
509 ///
510 /// The read-only opens ignore this setting; they write nothing.
511 ///
512 /// Only the budget of `H5Pset_page_buffer_size` is modeled; its
513 /// `min_meta_perc` / `min_raw_perc` reservations are not, since this buffer
514 /// does not evict — it flushes whole.
515 #[doc(alias = "H5Pset_page_buffer_size")]
516 pub const fn with_page_buffer_size(mut self, bytes: usize) -> Self {
517 self.page_buffer_size = bytes;
518 self
519 }
520
521 /// Let a read-only open proceed past a superblock marked open for write by a
522 /// writer that is not a SWMR writer — status-flag bit 0 alone, which is what
523 /// [`with_page_buffer_size`](Self::with_page_buffer_size) raises for a
524 /// session's whole life.
525 ///
526 /// Defaults to [`WriteMarkPolicy::Refuse`], which is what `H5Fopen` does with
527 /// the same byte: [`File::open`], [`File::open_streaming`] and
528 /// [`File::from_source`] all report
529 /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse).
530 /// [`WriteMarkPolicy::AllowSnapshot`] reads the file as it stands instead,
531 /// through whichever of those opens is passed these properties.
532 ///
533 /// # What the caller is asserting
534 ///
535 /// That the writer has flushed: it called [`File::sync`], or it stopped
536 /// after a flush and the mark stands only because nothing cleared it (a
537 /// clean [`File::close`] takes the mark down, and leaves nothing to opt past).
538 /// The mark is durable and says nothing about *when* — a live writer
539 /// mid-operation and one that exited without closing carry the same byte —
540 /// so this crate cannot check the assertion, and passing this value is how a
541 /// caller states it. It is exactly true for a writer under
542 /// [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose) that syncs at the
543 /// points it wants readable, and it is what the mark exists to guard against
544 /// when it is false: a page-buffered session's publish points are written
545 /// before the content they name, so a snapshot taken mid-flush can show a
546 /// dataset that reads clean and returns fill values, with every checksum
547 /// verifying.
548 ///
549 /// The snapshot is of the bytes on disk at open. A buffered open takes it
550 /// whole; a streaming open reads regions on demand, so a writer that carries
551 /// on writing can move bytes under it — reach for
552 /// [`File::open_with_options`] when the writer may continue, and for
553 /// [`File::open_streaming_with_options`] when it has stopped and the file is
554 /// too large to buffer.
555 ///
556 /// # What it does not unlock
557 ///
558 /// - a **SWMR pair** (both bits): that file has a reader of its own, and
559 /// [`File::open_swmr`] follows it — including across the writer's later
560 /// appends, which a snapshot cannot;
561 /// - [`File::open_rw`] and [`File::open_swmr_writer`], which are refused
562 /// whatever this says. A second writer must not join a file a writer
563 /// already holds;
564 /// - the OS advisory lock, a separate guard with its own policy
565 /// ([`with_locking`](Self::with_locking)).
566 ///
567 /// A file left marked by a writer that *crashed* is a different question,
568 /// and this is not the answer to it: it reads such a file as willingly as a
569 /// flushed one, and leaves the mark standing for the next reader to meet.
570 /// [`File::clear_swmr_flag`] — the `h5clear -s` equivalent — is the recovery
571 /// there, and it records the decision by clearing the byte.
572 ///
573 /// The C library offers no counterpart: `H5Fopen` refuses the byte with no
574 /// override, and `h5clear` is its only way through. This is the narrower one,
575 /// since it changes nothing on disk.
576 pub const fn with_write_mark_policy(mut self, policy: WriteMarkPolicy) -> Self {
577 self.write_mark_policy = policy;
578 self
579 }
580
581 /// Return the configured streaming metadata cache.
582 pub const fn metadata_cache(&self) -> MetadataCacheConfig {
583 self.metadata_cache
584 }
585
586 /// Return the configured library-version bounds, or `None` when an editing
587 /// session may write whatever its content needs.
588 pub const fn libver_bounds(&self) -> Option<(LibVer, LibVer)> {
589 self.libver_bounds
590 }
591
592 /// Return the configured per-dataset chunk cache.
593 pub const fn chunk_cache(&self) -> ChunkCacheConfig {
594 self.chunk_cache
595 }
596
597 /// Return the configured file-locking policy.
598 pub const fn locking(&self) -> FileLocking {
599 self.locking
600 }
601
602 /// Return the configured memory strategy, or `None` when none was asked for
603 /// and the entry point's own default applies. This is what was *requested*;
604 /// for which backend an open resolved to, see [`File::edit_backing`].
605 ///
606 /// The `Option` distinguishes "no preference stated" from an explicit
607 /// [`MemoryStrategy::Auto`], which is what lets an entry point supply its own
608 /// default without overriding a caller who asked for one; `None` resolves to
609 /// [`MemoryStrategy::Auto`], the only default any entry point now supplies
610 /// rather than as a second break on this accessor.
611 pub const fn memory_strategy(&self) -> Option<MemoryStrategy> {
612 self.memory_strategy
613 }
614
615 /// Return the configured `fsync` policy.
616 pub const fn sync_policy(&self) -> SyncPolicy {
617 self.sync_policy
618 }
619
620 /// Return the configured page-buffer budget in bytes; `0` when none was
621 /// asked for.
622 pub const fn page_buffer_size(&self) -> usize {
623 self.page_buffer_size
624 }
625
626 /// Return the configured write-mark policy.
627 pub const fn write_mark_policy(&self) -> WriteMarkPolicy {
628 self.write_mark_policy
629 }
630}
631
632/// Dataset-access properties applied when opening a single dataset.
633///
634/// This is the `hdf5-pure` analogue of an HDF5 **dataset access property list**
635/// (`dapl`). Its chunk cache corresponds to `H5Pset_chunk_cache`: it overrides,
636/// for this one dataset, the file-wide chunk-cache default configured with
637/// [`FileAccessProperties::with_chunk_cache`] (the `H5Pset_cache` analogue). When
638/// left unset, the dataset inherits that file-wide default — matching the `dapl`
639/// default sentinels (`H5D_CHUNK_CACHE_*_DEFAULT`), which also mean "use the
640/// file's setting".
641///
642/// The `Properties` suffix means the type stands in for one whole HDF5 property
643/// list, so every setting on it has a C counterpart to look up. It is a stand-in
644/// and not a port: a plain `Copy` value, with no handle to create or close, no
645/// runtime property registry, and no setter that can fail. `dapl` and each
646/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
647/// The chunk cache is the one `dapl` property modeled; see the
648/// [property-support reference] for the rest.
649///
650/// [`ChunkCacheConfig`] maps `H5Pset_chunk_cache`'s `rdcc_nslots` and
651/// `rdcc_nbytes`; its `rdcc_w0` preemption policy is not modeled, for the reason
652/// on [`ChunkCacheConfig::from_h5p_cache`].
653///
654/// Pass it to [`File::dataset_with_options`] or [`Group::dataset_with_options`].
655///
656/// [property-support reference]: https://github.com/CramBL/hdf5-pure/blob/main/docs/reference/property-support.md
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
658#[doc(alias = "dapl")]
659pub struct DatasetAccessProperties {
660 chunk_cache: Option<ChunkCacheConfig>,
661}
662
663impl DatasetAccessProperties {
664 /// A value that inherits every file-wide access default.
665 pub const fn new() -> Self {
666 Self { chunk_cache: None }
667 }
668
669 /// Override the raw chunk cache for this one dataset, ignoring the file-wide
670 /// default. This is the `H5Pset_chunk_cache` analogue.
671 #[doc(alias = "H5Pset_chunk_cache")]
672 pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
673 self.chunk_cache = Some(chunk_cache);
674 self
675 }
676
677 /// Return the chunk-cache override, or `None` when the dataset inherits the
678 /// file-wide default.
679 pub const fn chunk_cache(&self) -> Option<ChunkCacheConfig> {
680 self.chunk_cache
681 }
682
683 /// Resolve the effective chunk-cache config: the per-dataset override if one
684 /// was set, otherwise the file-wide `default`.
685 const fn resolved_chunk_cache(&self, default: ChunkCacheConfig) -> ChunkCacheConfig {
686 match self.chunk_cache {
687 Some(config) => config,
688 None => default,
689 }
690 }
691}
692
693/// Test whether a file looks like an HDF5 file, without reading it whole.
694///
695/// This is the spelling of the C library's `H5Fis_accessible` /
696/// `H5Fis_hdf5`: it opens the file and scans only the 8-byte candidate windows
697/// where the HDF5 signature is permitted (offsets 0, 512, 1024, 2048, …), so it
698/// never buffers the whole file. Returns:
699///
700/// - `Ok(true)` — the HDF5 signature was found,
701/// - `Ok(false)` — the file opened but has no HDF5 signature,
702/// - `Err(..)` — the file could not be opened (missing, permissions, …).
703///
704/// It validates only the signature, not the rest of the format; a truncated or
705/// corrupt file past the signature still reports `true`. Use [`File::open`] to
706/// fully parse and validate.
707pub fn is_hdf5<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<bool> {
708 let handle = std::fs::File::open(path)?;
709 let source = ReadSeekSource::new(handle).map_err(std::io::Error::other)?;
710 match signature::find_signature_in(&source) {
711 Ok(_) => Ok(true),
712 Err(FormatError::SignatureNotFound) => Ok(false),
713 Err(e) => Err(std::io::Error::other(e)),
714 }
715}
716
717/// Test whether an in-memory buffer begins (at a permitted offset) with the
718/// HDF5 signature. The buffer-backed counterpart of [`is_hdf5`].
719pub fn is_hdf5_bytes(data: &[u8]) -> bool {
720 signature::find_signature(data).is_ok()
721}
722
723/// An open HDF5 file for reading.
724struct FileInner {
725 backend: Backend,
726 superblock: Superblock,
727 /// Byte offset to add to all relative addresses (= original base_address).
728 addr_offset: BaseAddress,
729 /// Live file handle, retained only when the file was opened with
730 /// [`File::open_swmr`] so [`File::refresh`] can re-read appended data.
731 handle: Option<std::fs::File>,
732 /// File Space Info parsed from the superblock extension, if the file records
733 /// one. Best-effort: a malformed or unreadable extension leaves this `None`
734 /// rather than failing the open.
735 file_space_info: Option<FileSpaceInfo>,
736 /// The shared object header message (SOHM) table, if the superblock
737 /// extension records one. `None` for almost every file: no common producer
738 /// enables shared-message indexes. Best-effort like `file_space_info`, so a
739 /// file whose table cannot be read still opens and still reads every object
740 /// that shares nothing; what fails is following a reference into the heap,
741 /// with [`FormatError::UnsupportedSohmReference`].
742 ///
743 /// Boxed because it is absent on essentially every file, and this struct is
744 /// allocated once per open: one pointer costs less here than the table
745 /// inline, and the absent case allocates nothing at all.
746 sohm_table: Option<Box<crate::sohm::SohmTable>>,
747 access_properties: FileAccessProperties,
748 /// Set by [`File::close`] to seal a read-write file: after it, a write
749 /// through any surviving [`Dataset`]/[`Group`] handle or [`File`] clone
750 /// returns [`Error::FileClosed`]. Reads still work. Only ever set on a
751 /// `Backend::Edit` file.
752 closed: AtomicBool,
753 /// How many times a write session has been given the chance to change this
754 /// file's bytes, counted so an owned [`Dataset`] handle can tell that the
755 /// object header it parsed no longer says what the file says.
756 ///
757 /// Advanced by every operation that reaches the write engine; see
758 /// [`FileInner::with_engine_mut`], which is the only thing that advances
759 /// either counter. Never advances for a read-only or streaming file, whose
760 /// bytes cannot move under a handle at all.
761 content_revision: AtomicU64,
762 /// How many times a write session has been given the chance to *move* an
763 /// object header, which is the narrower question of whether an address a
764 /// handle is holding still names its object.
765 ///
766 /// [`Change::InPlace`] leaves this alone: an immediate [`Dataset::append`]
767 /// rewrites a dataset's header where it stands, so an address stays good
768 /// across one. That is what lets a handle reached by object reference — the
769 /// one kind with no name to look itself up by — go on appending, while a
770 /// commit ends it.
771 address_revision: AtomicU64,
772 /// True for a file opened with [`File::open_swmr_writer`]: no OS lock is held,
773 /// the superblock's SWMR-write flag is raised, only immediate
774 /// [`Dataset::append`] is permitted (the staged surface is refused), and the
775 /// flag is cleared on [`File::close`] / `Drop`. `false` for every other file.
776 swmr_write: bool,
777}
778
779impl Drop for FileInner {
780 /// Best-effort cleanup for a writer dropped without an explicit
781 /// [`File::close`], running only when the last `Arc<FileInner>` clone drops;
782 /// a clean `close` already did this work and set `closed`, so this is
783 /// idempotent and skipped in that case.
784 ///
785 /// - A SWMR writer clears the superblock's SWMR-write flag (mirroring
786 /// `File::close`).
787 /// - A read-write file that persists its free space rewrites its on-disk
788 /// free-space managers into canonical shape (issue #173), so a
789 /// dropped-without-`close` handle leaves the same file a clean `close`
790 /// would (a no-op unless an immediate append grew the file past them). A
791 /// true crash (`SIGKILL`, power loss) skips `drop` entirely; the appended
792 /// data is still durable, under the default
793 /// [`SyncPolicy::Always`](crate::SyncPolicy).
794 ///
795 /// Staged edits are *not* committed here: dropping a handle discards them,
796 /// which is what `close` exists to distinguish.
797 fn drop(&mut self) {
798 if self.closed.load(Ordering::Acquire) {
799 return;
800 }
801 let Backend::Edit(m) = &self.backend else {
802 return;
803 };
804 let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
805 // Both branches write, and this is the last moment anything can order
806 // those writes: the handle is gone once this returns, so `File::sync` is
807 // not an option the caller still has. The barrier is therefore forced
808 // rather than left to the session's `SyncPolicy` — see
809 // [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose).
810 // SWMR stages nothing and persists no free space, so it skips the
811 // re-homing; everything after that is the same teardown for both.
812 if !self.swmr_write {
813 let _ = session.finalize_persist();
814 }
815 // Only if the flush actually succeeded. The flags say this session's
816 // writes may still be in memory, and a failed `force_sync` is precisely
817 // the case where that is still true — taking them down there would
818 // publish the file as complete over a drain that did not finish.
819 if session.force_sync().is_ok() {
820 let _ = session.release_status_flags();
821 }
822 }
823}
824
825impl FileInner {
826 /// Open an HDF5 file from a filesystem path.
827 ///
828 /// Reads the file into memory once. To follow a file that a concurrent
829 /// single writer is appending to (SWMR), use [`File::open_swmr`] instead.
830 /// To read a file larger than memory (e.g. on a 32-bit host) without
831 /// buffering it, use [`File::open_streaming`].
832 pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
833 Self::open_with_options(path, FileAccessProperties::new())
834 }
835
836 /// Open an HDF5 file from a filesystem path with explicit access properties.
837 ///
838 /// Like [`open`](Self::open), this buffers the whole file in memory. Use
839 /// [`open_streaming_with_options`](Self::open_streaming_with_options) when
840 /// the metadata cache budget should apply to lazy metadata reads.
841 pub fn open_with_options<P: AsRef<std::path::Path>>(
842 path: P,
843 properties: FileAccessProperties,
844 ) -> Result<Self, Error> {
845 let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
846 let write_mark = properties.write_mark_policy;
847 let inner = Self::from_bytes_with_options(bytes, properties)?;
848 // The status-flag check belongs to every open that reads the live file
849 // — this one, `open_streaming` and `from_source` — and not to
850 // `from_bytes_with_options` (issue #245). The line is snapshot against
851 // live, not path against no path: a caller who already holds the bytes
852 // has taken its own snapshot, and there is no live file left to
853 // coordinate over. This is a deliberate divergence from the C library,
854 // which checks under its in-memory core driver too.
855 file_lock::check_status_flags(
856 &inner.superblock,
857 OpenIntent::Read(write_mark),
858 OpenTarget::Path(path.as_ref()),
859 )?;
860 Ok(inner)
861 }
862
863 /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
864 /// the file instead of buffering it whole.
865 ///
866 /// This lets a host read a file larger than its address space — the original
867 /// motivation being 32-bit targets reading multi-gigabyte files (issue #27).
868 /// Metadata and dataset chunks are read through a `ReadSeekSource`, so peak
869 /// memory stays close to one chunk plus the metadata being parsed. Chunks
870 /// that sit next to each other on disk are fetched together, in reads of at
871 /// most 256 KiB (a larger chunk is read on its own), which is what makes a
872 /// file written a row at a time — thousands of chunks of a few dozen bytes
873 /// — read at a sensible speed. See [`crate::chunk_span`].
874 ///
875 /// Reads match the buffered [`File::open`]: every storage layout and chunk
876 /// index type, both group forms (v2 and v1 symbol-table), and compact,
877 /// dense, shared, and variable-length attributes. What differs:
878 /// [`as_bytes`](Self::as_bytes) returns an empty slice (there is no
879 /// whole-file buffer), [`persisted_free_space`](Self::persisted_free_space)
880 /// returns no regions, a streaming file cannot be the *source* of a
881 /// cross-file copy, and chunk decompression is sequential (the `parallel`
882 /// feature accelerates only buffered reads).
883 pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
884 Self::open_streaming_with_options(path, FileAccessProperties::new())
885 }
886
887 /// Open an HDF5 file for streaming reads with explicit access properties.
888 pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
889 path: P,
890 properties: FileAccessProperties,
891 ) -> Result<Self, Error> {
892 let handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
893 let source = ReadSeekSource::new(handle).map_err(Error::Format)?;
894 Self::streaming(source, properties, OpenTarget::Path(path.as_ref()))
895 }
896
897 /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
898 /// demand as [`open_streaming`](Self::open_streaming) does.
899 pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
900 Self::from_source_with_options(source, FileAccessProperties::new())
901 }
902
903 /// Open an HDF5 file from any [`Source`] with explicit access properties.
904 pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
905 source: S,
906 properties: FileAccessProperties,
907 ) -> Result<Self, Error> {
908 // The only source that reaches the parsers from outside the crate, and
909 // so the only one whose reads are length-checked: see `ValidatedSource`.
910 Self::streaming(ValidatedSource::new(source), properties, OpenTarget::Source)
911 }
912
913 /// The body both streaming opens share.
914 ///
915 /// Wraps the source in whatever metadata cache the properties ask for,
916 /// parses the superblock through it, and refuses a file a writer holds.
917 /// The two differ in where the source came from and in `target`, which does
918 /// nothing but name the file in that refusal; one body is what keeps the
919 /// rest of it from drifting apart.
920 fn streaming<S: Source + Send + Sync + 'static>(
921 source: S,
922 properties: FileAccessProperties,
923 target: OpenTarget<'_>,
924 ) -> Result<Self, Error> {
925 let source: Box<dyn Source + Send + Sync> = if properties.metadata_cache.is_enabled() {
926 Box::new(MetadataCachingSource::new(
927 source,
928 properties.metadata_cache,
929 ))
930 } else {
931 Box::new(source)
932 };
933 let (superblock, addr_offset) = Self::parse_superblock_source(source.as_ref())?;
934 file_lock::check_status_flags(
935 &superblock,
936 OpenIntent::Read(properties.write_mark_policy),
937 target,
938 )?;
939 Ok(Self::from_parts(
940 Backend::Streaming(source),
941 superblock,
942 addr_offset,
943 None,
944 properties,
945 ))
946 }
947
948 /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
949 ///
950 /// Like [`File::open`], but retains a live handle to the file so that
951 /// [`File::refresh`] can re-read data appended by a concurrent writer
952 /// (whether produced by this crate's append writer, the reference HDF5 C
953 /// library, or h5py in SWMR mode). The initial view is a consistent
954 /// snapshot; call [`File::refresh`] to advance to a newer one.
955 ///
956 /// Only the `std` build supports this (it requires a live filesystem
957 /// handle); the in-memory [`File::from_bytes`] path cannot refresh.
958 pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
959 Self::open_swmr_with_options(path, FileAccessProperties::new())
960 }
961
962 /// Open an HDF5 file for SWMR reading with explicit access properties.
963 ///
964 /// SWMR reads currently keep an in-memory mirror for refresh semantics, so
965 /// only the per-dataset chunk-cache settings affect this backend.
966 pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
967 path: P,
968 properties: FileAccessProperties,
969 ) -> Result<Self, Error> {
970 let mut handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
971 let mut data = Vec::new();
972 handle.read_to_end(&mut data).map_err(Error::Io)?;
973 let (superblock, addr_offset) = Self::parse_superblock(&data)?;
974 file_lock::check_status_flags(
975 &superblock,
976 OpenIntent::SwmrRead,
977 OpenTarget::Path(path.as_ref()),
978 )?;
979 Ok(Self::from_parts(
980 Backend::InMemory(data),
981 superblock,
982 addr_offset,
983 Some(handle),
984 properties,
985 ))
986 }
987
988 /// Open an HDF5 file from an in-memory byte vector.
989 pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
990 Self::from_bytes_with_options(data, FileAccessProperties::new())
991 }
992
993 /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
994 pub fn from_bytes_with_options(
995 data: Vec<u8>,
996 properties: FileAccessProperties,
997 ) -> Result<Self, Error> {
998 let (superblock, addr_offset) = Self::parse_superblock(&data)?;
999 Ok(Self::from_parts(
1000 Backend::InMemory(data),
1001 superblock,
1002 addr_offset,
1003 None,
1004 properties,
1005 ))
1006 }
1007
1008 /// Open an existing HDF5 file for reading **and** in-place editing, applying
1009 /// `properties` (its [`FileLocking`] policy governs the OS file lock held for
1010 /// the file's life, and its chunk cache is the file-wide default).
1011 fn open_rw<P: AsRef<std::path::Path>>(
1012 path: P,
1013 properties: FileAccessProperties,
1014 ) -> Result<Self, Error> {
1015 Self::open_rw_with_default(path, properties, MemoryStrategy::Auto)
1016 }
1017
1018 /// Open read-write under the properties' memory strategy, falling back to
1019 /// `default` when the caller expressed none. The two public read-write entry
1020 /// [`File::open_rw`] passes [`MemoryStrategy::Auto`]: prefer the bounded
1021 /// engine, but take the mirror for a file the bounded engine cannot edit
1022 /// (issue #198, step 4). A caller who states a strategy overrides it.
1023 fn open_rw_with_default<P: AsRef<std::path::Path>>(
1024 path: P,
1025 properties: FileAccessProperties,
1026 default: MemoryStrategy,
1027 ) -> Result<Self, Error> {
1028 let session = WriteEngine::open_rw_with_strategy(
1029 path.as_ref(),
1030 properties.metadata_cache,
1031 properties.locking,
1032 properties.memory_strategy.unwrap_or(default),
1033 )?;
1034 Self::from_rw_session(session, properties)
1035 }
1036
1037 /// Wrap an opened [`WriteEngine`] as a read-write [`Backend::Edit`] file.
1038 fn from_rw_session(
1039 mut session: WriteEngine,
1040 properties: FileAccessProperties,
1041 ) -> Result<Self, Error> {
1042 // The one funnel every read-write session passes through, so the fapl's
1043 // format bound and `fsync` cadence reach the engine no matter which entry
1044 // point opened it — the SWMR writer included, which
1045 // `WriteEngine::open_swmr_writer` says why.
1046 session.set_libver_bounds(properties.libver_bounds)?;
1047 session.set_sync_policy(properties.sync_policy);
1048 // Every page-buffer refusal lives in `set_page_buffer_size`, including the
1049 // `SyncPolicy::Always` one — which is why `set_sync_policy` must precede
1050 // this call rather than merely happening to.
1051 session.set_page_buffer_size(properties.page_buffer_size)?;
1052 // The engine parsed and normalized this at open; take it rather than
1053 // re-parsing, so the image need not be able to hand out a slice.
1054 let superblock = session.superblock().clone();
1055 let addr_offset = superblock.base_address;
1056 Ok(Self::from_parts(
1057 Backend::Edit(Box::new(Mutex::new(session))),
1058 superblock,
1059 addr_offset,
1060 None,
1061 properties,
1062 ))
1063 }
1064
1065 /// Open for SWMR writing: no OS lock, superblock SWMR-write flag raised.
1066 fn open_swmr_writer<P: AsRef<std::path::Path>>(
1067 path: P,
1068 properties: FileAccessProperties,
1069 ) -> Result<Self, Error> {
1070 // The SWMR writer always mirrors. `Auto` and unset are *satisfied* by
1071 // that — they ask for the bounded engine where it applies and accept the
1072 // mirror where it does not — but `Bounded` is a guarantee, and honoring a
1073 // guarantee by ignoring it is how a caller ends up spending `O(file size)`
1074 // memory it asked not to. Refusing is also the permissive direction to be
1075 // wrong in: if this writer ever runs bounded, the refusal stops firing,
1076 // which breaks nobody.
1077 if properties.memory_strategy == Some(MemoryStrategy::Bounded) {
1078 return Err(Error::EditUnsupported(
1079 "the SWMR writer always holds the file in a whole-file mirror; leave \
1080 MemoryStrategy unset, or pass MemoryStrategy::Auto or MemoryStrategy::Mirrored, \
1081 to open it",
1082 ));
1083 }
1084 // A library-version bound below 1.10 is the same shape of unhonorable
1085 // guarantee. SWMR needs a version 3 superblock — neither library reads
1086 // the SWMR-write flag back on an older one — so a caller asking for the
1087 // 1.8 format here is asking for a file this writer cannot produce.
1088 if let Some((low, high)) = properties.libver_bounds
1089 && LibVer::resolve_writable(Some((low, high))).map_err(Error::Format)? < LibVer::V110
1090 {
1091 return Err(Error::EditUnsupported(
1092 "the SWMR writer requires a version 3 superblock, which is the v1.10 format; \
1093 raise the FileAccessProperties library-version bound to open it",
1094 ));
1095 }
1096 // And a page buffer is the third. A SWMR reader follows the writer's
1097 // ordered phases as they become visible, so coalescing those writes is
1098 // not a slower or larger file but a reader that sees a state the phases
1099 // exist to keep it from seeing.
1100 if properties.page_buffer_size != 0 {
1101 return Err(Error::EditUnsupported(
1102 "the SWMR writer cannot buffer its writes: its readers observe the order they \
1103 become visible in; leave FileAccessProperties::with_page_buffer_size unset to \
1104 open it",
1105 ));
1106 }
1107 let session = WriteEngine::open_swmr_writer(path, properties.sync_policy)?;
1108 let mut inner = Self::from_rw_session(session, properties)?;
1109 inner.swmr_write = true;
1110 Ok(inner)
1111 }
1112
1113 /// After the caller has confirmed a [`Backend::Edit`] backend, gate the
1114 /// mutation: refuse a sealed file with [`Error::FileClosed`], and in
1115 /// SWMR-writer mode refuse a staged edit (`staged = true`) with
1116 /// [`Error::SwmrStagedUnsupported`] — only immediate appends are allowed.
1117 fn check_mutable(&self, staged: bool) -> Result<(), Error> {
1118 if self.closed.load(Ordering::Acquire) {
1119 return Err(Error::FileClosed);
1120 }
1121 if staged && self.swmr_write {
1122 return Err(Error::SwmrStagedUnsupported);
1123 }
1124 Ok(())
1125 }
1126
1127 /// Gate a staged edit *without* taking the session lock: the backend must
1128 /// offer the staged surface, and the file must still be mutable.
1129 ///
1130 /// This is the same gate the locking helpers apply before locking, split out
1131 /// so a public method taking a user closure can report a read-only or sealed
1132 /// file up front, run the closure with no lock held, and take the lock only
1133 /// to record the result (issue #200).
1134 fn check_staged_writable(&self) -> Result<(), Error> {
1135 match &self.backend {
1136 Backend::Edit(_) => self.check_mutable(true),
1137 _ => Err(Error::ReadOnly),
1138 }
1139 }
1140
1141 /// Lock this file's write session for an operation that may change the
1142 /// file, and record afterwards that it had the chance to.
1143 ///
1144 /// Every path that can change the file's bytes — [`File::commit`] and the
1145 /// copies, every staged and immediate edit a [`Dataset`] or [`Group`] handle
1146 /// makes, and the session teardown — goes through here, so a new entry point
1147 /// cannot change the file without classifying what it did. Two write the
1148 /// file without passing here, both because no handle can be alive to see it:
1149 /// [`FileInner::drop`], which runs when the last `Arc` goes, and
1150 /// [`File::refresh`], which takes `&mut self` through `Arc::get_mut` and
1151 /// advances the counters itself.
1152 ///
1153 /// The appender's claim bookkeeping ([`Dataset::claim_for_appender`] and its
1154 /// pair) locks the engine directly and rightly notes nothing: it records who
1155 /// is appending, not what the file holds — and it must keep working from a
1156 /// `Drop` on a sealed file, which this gate refuses.
1157 ///
1158 /// The counters advance whether `f` succeeded or not, and for a staged edit
1159 /// that changes no bytes at all. Both are deliberate: a refused commit can
1160 /// still have written and rolled back (issues #316 and #344), and the cost
1161 /// of a revision that did not need advancing is one re-read on the next use
1162 /// of a handle, where the cost of one that needed advancing and did not is a
1163 /// wrong answer.
1164 fn with_engine_mut<R>(
1165 &self,
1166 change: Change,
1167 f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
1168 ) -> Result<R, Error> {
1169 let Backend::Edit(m) = &self.backend else {
1170 return Err(Error::ReadOnly);
1171 };
1172 self.check_mutable(matches!(change, Change::Relocating))?;
1173 let out = {
1174 let mut engine = m.lock().unwrap_or_else(PoisonError::into_inner);
1175 f(&mut engine)
1176 };
1177 self.note(change);
1178 out
1179 }
1180
1181 /// Record that an operation of kind `change` has run against this file.
1182 ///
1183 /// The address counter moves *first*, against [`revisions`](Self::revisions)
1184 /// reading it second. A reader that sees the new content revision has
1185 /// therefore already synchronized with this release, so it cannot then read
1186 /// an address revision from before it and conclude that its memoized address
1187 /// outlived a commit that moved it.
1188 fn note(&self, change: Change) {
1189 match change {
1190 Change::Relocating => {
1191 self.address_revision.fetch_add(1, Ordering::Release);
1192 self.content_revision.fetch_add(1, Ordering::Release);
1193 }
1194 Change::InPlace => {
1195 self.content_revision.fetch_add(1, Ordering::Release);
1196 }
1197 Change::Nothing => {}
1198 }
1199 }
1200
1201 /// How many times a write session has been given the chance to change this
1202 /// file's bytes. A [`Dataset`] handle whose header was parsed at this value
1203 /// still holds what the file holds.
1204 fn content_revision(&self) -> u64 {
1205 self.content_revision.load(Ordering::Acquire)
1206 }
1207
1208 /// How many times a write session has been given the chance to move an
1209 /// object header. An address worked out at this value still names its
1210 /// object.
1211 fn address_revision(&self) -> u64 {
1212 self.address_revision.load(Ordering::Acquire)
1213 }
1214
1215 /// Work out where a handle's object header sits now, and say what that
1216 /// answer holds as of.
1217 ///
1218 /// One rule, in the order it is written. An address nothing has moved since
1219 /// still names its object, whichever way the handle names it — which is what
1220 /// keeps an in-place append anywhere in the file from making every handle
1221 /// walk its path again. Past that, a handle opened by `path` looks its
1222 /// object up, which follows it wherever a commit put it, and one reached by
1223 /// object reference has no name to look up: `memo` held the only address it
1224 /// will ever have, and the bytes a relocated header vacates still parse as
1225 /// the object that left them, so there is nothing left to read.
1226 ///
1227 /// Both counters are read *before* the resolution, never after: a value
1228 /// taken afterwards could name a state the address predates, and a handle
1229 /// that memoized that pairing would go on answering from a header a commit
1230 /// had already moved. Taken beforehand, a concurrent change makes the
1231 /// pairing merely stale — which the next use notices, so long as it is the
1232 /// next *use*. A commit running on another thread between this read and the
1233 /// bytes it labels is not ordered against either, so a handle shared across
1234 /// threads can still serve one read from a header a concurrent commit had
1235 /// moved. What these counters order is a handle against edits already made,
1236 /// not against one in flight. The callers that classify what they resolved
1237 /// to — [`Dataset::resolved`], [`Group::header_address`] — parse that header
1238 /// in the same unordered window, so a commit landing inside it can also make
1239 /// a live handle report [`Error::NotADataset`](crate::Error::NotADataset) or
1240 /// [`Error::NotAGroup`](crate::Error::NotAGroup) for an object whose kind
1241 /// never changed. That is the same staleness reporting itself instead of
1242 /// answering, which is the better half of the trade.
1243 fn locate(&self, path: Option<&str>, memo: Option<Resolution>) -> Result<Resolution, Error> {
1244 let revisions = self.revisions();
1245 // A handle with no memo has never resolved — it names an object this
1246 // session staged — so there is nothing to short-circuit on, and its path
1247 // is walked afresh every time until a commit gives it a header.
1248 Ok(revisions.at(match (path, memo) {
1249 (_, Some(memo)) if revisions.address == memo.address_revision => memo.address,
1250 (Some(path), _) => self.resolve_path(path)?,
1251 (None, _) => return Err(Error::StaleHandle),
1252 }))
1253 }
1254
1255 /// [`locate`](Self::locate), reported as [`Error::NotCommitted`] when this
1256 /// session has the path *staged* rather than written, and as
1257 /// [`Error::StagingWithdrawn`] when the staging a handle was born onto is
1258 /// gone.
1259 ///
1260 /// Every handle onto a staged object comes through here, so the distinction
1261 /// between "there is no such object", "there is one, and `commit` has not
1262 /// written it yet" and "the one this handle names has been withdrawn" is
1263 /// made in one place rather than at each caller.
1264 ///
1265 /// The staged set is consulted *before* the file for a handle that has never
1266 /// resolved, because a staged creation can sit on a path the file still
1267 /// holds: a delete and a create in one commit replace the object there
1268 /// (issue #305), and until that commit runs the old bytes are still
1269 /// perfectly readable. Answering from them would hand the caller the object
1270 /// their handle was explicitly not opened onto.
1271 ///
1272 /// `birth` is the handle's own [`Standing`] input — `None` for one opened
1273 /// onto an object in the file. This question is keyed by path alone, so a
1274 /// born handle whose creation was withdrawn and replaced by one of the other
1275 /// kind at the same path reports `NotCommitted` here where
1276 /// [`staged_dataset_view`](Self::staged_dataset_view) tells it apart; both
1277 /// refuse, and neither answers from the file.
1278 fn locate_staged(
1279 &self,
1280 path: Option<&str>,
1281 memo: Option<Resolution>,
1282 birth: Option<u64>,
1283 ) -> Result<Resolution, Error> {
1284 if let (None, Some(named)) = (memo, path) {
1285 match self.staged_standing(named, birth) {
1286 Standing::Pending => return Err(Error::NotCommitted(named.to_string())),
1287 Standing::Withdrawn => return Err(Error::StagingWithdrawn(named.to_string())),
1288 Standing::Live => {}
1289 }
1290 }
1291 match self.locate(path, memo) {
1292 Err(Error::Format(FormatError::PathNotFound(missing))) => {
1293 let named = path.unwrap_or_default();
1294 match self.staged_object(named) {
1295 Some(_) => Err(Error::NotCommitted(named.to_string())),
1296 None => Err(Error::Format(FormatError::PathNotFound(missing))),
1297 }
1298 }
1299 other => other,
1300 }
1301 }
1302
1303 /// Run `f` against this file's write session, or `None` when there is none
1304 /// (a read-only file stages nothing, so it has nothing to be asked about).
1305 fn query_engine<R>(&self, f: impl FnOnce(&WriteEngine) -> R) -> Option<R> {
1306 match &self.backend {
1307 Backend::Edit(m) => Some(f(&m.lock().unwrap_or_else(PoisonError::into_inner))),
1308 _ => None,
1309 }
1310 }
1311
1312 /// What this session has staged at `path`, if anything. See
1313 /// [`WriteEngine::staged_object`] for the rule.
1314 fn staged_object(&self, path: &str) -> Option<StagedObject> {
1315 self.query_engine(|e| e.staged_object(path)).flatten()
1316 }
1317
1318 /// Where a handle naming `path` stands: whether the object it addresses is
1319 /// one the file holds, one this session has staged, or one whose staging has
1320 /// been withdrawn under it.
1321 ///
1322 /// `birth` is the session's [staged generation](WriteEngine::staged_generation)
1323 /// as of the moment the handle was made onto a staged creation, and `None`
1324 /// for every handle opened onto an object in the file. It is what separates
1325 /// the two ways a staged path can stop being staged — the commit published
1326 /// it, or a [`Group::delete`] withdrew it — which otherwise look identical
1327 /// from here, and which a handle must not confuse: a withdrawn creation
1328 /// leaves the file's own object at that path, and it is the object the
1329 /// session is *deleting*.
1330 ///
1331 /// Both halves come from one lock, so a commit cannot land between them and
1332 /// pair a generation with a staged set from the other side of it.
1333 ///
1334 /// A file with no write session stages nothing, so every handle onto one is
1335 /// live. That includes the `birth`-carrying arm, which such a file cannot
1336 /// produce: a handle holds its [`FileInner`] alive and a backend never
1337 /// changes under one, so the case is unreachable rather than merely unlikely.
1338 fn staged_standing(&self, path: &str, birth: Option<u64>) -> Standing {
1339 let Some((generation, staged)) =
1340 self.query_engine(|e| (e.staged_generation(), e.staged_object(path).is_some()))
1341 else {
1342 return Standing::Live;
1343 };
1344 match birth {
1345 // A commit has taken this session's staged set since the handle was
1346 // made, so what it names is in the file (or was deleted from it
1347 // afterwards, which resolving the path reports).
1348 Some(birth) if birth != generation => Standing::Live,
1349 Some(_) if !staged => Standing::Withdrawn,
1350 _ if staged => Standing::Pending,
1351 _ => Standing::Live,
1352 }
1353 }
1354
1355 /// What a dataset staged at `path` says about itself before it is written,
1356 /// for a handle whose staged generation at birth was `birth`.
1357 ///
1358 /// `Ok(None)` means the handle should read the file: either nothing is
1359 /// staged there, or a commit has published what was. The
1360 /// [`Error::StagingWithdrawn`] arm is [`staged_standing`](Self::staged_standing)'s,
1361 /// decided under the same lock — and told apart by *kind* here, since a
1362 /// dataset creation withdrawn and replaced by a group at the same path
1363 /// leaves nothing for this handle to answer from either.
1364 fn staged_dataset_view(
1365 &self,
1366 path: &str,
1367 birth: Option<u64>,
1368 ) -> Result<Option<StagedMeta>, Error> {
1369 let Some((generation, meta)) =
1370 self.query_engine(|e| (e.staged_generation(), e.staged_dataset_meta(path)))
1371 else {
1372 return Ok(None);
1373 };
1374 match birth {
1375 Some(birth) if birth != generation => Ok(None),
1376 Some(_) if meta.is_none() => Err(Error::StagingWithdrawn(path.to_string())),
1377 _ => Ok(meta),
1378 }
1379 }
1380
1381 /// The session's staged generation now, for a handle being made onto a
1382 /// creation staged in it. `None` on a file with no write session, which
1383 /// stages nothing and so makes no such handle.
1384 fn staged_generation(&self) -> Option<u64> {
1385 self.query_engine(WriteEngine::staged_generation)
1386 }
1387
1388 /// The direct children `parent` gains from this session's staged creations.
1389 fn staged_children(&self, parent: &str) -> StagedChildren {
1390 self.query_engine(|e| e.staged_children(parent))
1391 .unwrap_or_default()
1392 }
1393
1394 /// The revisions to label a resolution that is about to be worked out with.
1395 fn revisions(&self) -> Revisions {
1396 Revisions {
1397 content: self.content_revision(),
1398 address: self.address_revision(),
1399 }
1400 }
1401
1402 /// A `Source` view over the backend, for the streaming-capable paths.
1403 pub(crate) fn source(&self) -> SourceView<'_> {
1404 match &self.backend {
1405 Backend::InMemory(v) => SourceView::Mem(v),
1406 Backend::Streaming(s) => SourceView::Stream(s.as_ref()),
1407 // A mirror or bounded file's bytes live behind a lock and cannot be
1408 // lent out as a borrowed view; the read paths that reach every
1409 // backend go through [`with_source`](Self::with_source) instead.
1410 Backend::Edit(_) => SourceView::Mem(&[]),
1411 }
1412 }
1413
1414 /// Run `f` with a random-access view of this file's bytes, taking the
1415 /// write-engine lock when the backend requires one. Unlike
1416 /// [`source`](Self::source) — which cannot lend a borrowed view out of a
1417 /// lock and returns an empty view for the mirror and bounded backends —
1418 /// this serves every backend, so it is the dispatch for read paths (heap
1419 /// reads for variable-length data, chunk enumeration) that must also work
1420 /// on a read-write file. `f` must not re-enter this file's backend (the
1421 /// engine lock is held while it runs).
1422 pub(crate) fn with_source<R>(&self, f: impl FnOnce(&dyn Source) -> R) -> R {
1423 match &self.backend {
1424 Backend::InMemory(v) => f(&BytesSource::new(v.as_slice())),
1425 Backend::Streaming(s) => f(s.as_ref()),
1426 Backend::Edit(m) => {
1427 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1428 f(core.image())
1429 }
1430 }
1431 }
1432
1433 /// Run a read against a read-write session's file image, choosing the form
1434 /// its backing can serve: `on_slice` when the session holds the whole file
1435 /// in memory, so a slice-walking parser borrows the bytes instead of copying
1436 /// them, and `on_source` otherwise.
1437 ///
1438 /// Both closures must compute the same thing. The pair exists because a
1439 /// mirror can hand out a whole-file slice and a file-backed image cannot,
1440 /// not because the two backings answer differently (issue #198).
1441 fn with_engine<R>(
1442 engine: &Mutex<WriteEngine>,
1443 on_slice: impl FnOnce(&[u8]) -> R,
1444 on_source: impl FnOnce(&dyn Source) -> R,
1445 ) -> R {
1446 let core = engine
1447 .lock()
1448 .unwrap_or_else(std::sync::PoisonError::into_inner);
1449 match core.image_slice() {
1450 Some(data) => on_slice(data),
1451 None => on_source(core.image()),
1452 }
1453 }
1454
1455 /// Parse the superblock from `data`, returning it (with `root_group_address`
1456 /// normalized to an absolute offset) and the base-address offset.
1457 fn parse_superblock(data: &[u8]) -> Result<(Superblock, BaseAddress), Error> {
1458 let sig_offset = signature::find_signature(data)?;
1459 let mut superblock = Superblock::parse(data, sig_offset)?;
1460 let addr_offset = superblock.base_address;
1461 // Normalize root_group_address to absolute so resolve_path_any works.
1462 superblock.root_group_address = addr_offset.absolute(superblock.root_group_address)?;
1463 Ok((superblock, addr_offset))
1464 }
1465
1466 /// Streaming counterpart of [`parse_superblock`]: locate and parse the
1467 /// superblock by reading only small windows from the source.
1468 fn parse_superblock_source<S: Source + ?Sized>(
1469 source: &S,
1470 ) -> Result<(Superblock, BaseAddress), Error> {
1471 let sig_offset = signature::find_signature_in(source)?;
1472 let mut superblock = Superblock::parse_from_source(source, sig_offset)?;
1473 let addr_offset = superblock.base_address;
1474 superblock.root_group_address = addr_offset.absolute(superblock.root_group_address)?;
1475 Ok((superblock, addr_offset))
1476 }
1477
1478 /// Assemble a [`File`] from parsed parts, then load the File Space Info from
1479 /// the superblock extension (best-effort, so a bad extension never fails the
1480 /// open).
1481 fn from_parts(
1482 backend: Backend,
1483 superblock: Superblock,
1484 addr_offset: BaseAddress,
1485 handle: Option<std::fs::File>,
1486 access_properties: FileAccessProperties,
1487 ) -> Self {
1488 let mut file = FileInner {
1489 backend,
1490 superblock,
1491 addr_offset,
1492 handle,
1493 file_space_info: None,
1494 sohm_table: None,
1495 access_properties,
1496 closed: AtomicBool::new(false),
1497 content_revision: AtomicU64::new(0),
1498 address_revision: AtomicU64::new(0),
1499 swmr_write: false,
1500 };
1501 file.file_space_info = file.read_file_space_info();
1502 file.sohm_table = file.read_sohm_table();
1503 file
1504 }
1505
1506 /// Parse the File Space Info message from the superblock extension, if the
1507 /// file records one and it can be read. Best-effort: any failure (no
1508 /// extension, unreadable object header, malformed message) yields `None`.
1509 fn read_file_space_info(&self) -> Option<FileSpaceInfo> {
1510 let rel = self.superblock.superblock_extension_address?;
1511 if rel == u64::MAX {
1512 return None;
1513 }
1514 let abs = self.addr_offset.absolute(rel).ok()?;
1515 let header = self.parse_header(abs).ok()?;
1516 let msg = header
1517 .messages
1518 .iter()
1519 .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
1520 FileSpaceInfo::parse(
1521 &msg.data,
1522 self.superblock.offset_size,
1523 self.superblock.length_size,
1524 )
1525 .ok()
1526 }
1527
1528 /// Parse the Shared Message Table message from the superblock extension and
1529 /// read the master table it names, if the file records one.
1530 ///
1531 /// Best-effort in the same way and for the same reason as
1532 /// [`Self::read_file_space_info`]: a file whose shared-message table is
1533 /// unreadable still opens, and every object in it that shares no message
1534 /// still reads.
1535 fn read_sohm_table(&self) -> Option<Box<crate::sohm::SohmTable>> {
1536 let rel = self.superblock.superblock_extension_address?;
1537 if rel == u64::MAX {
1538 return None;
1539 }
1540 let abs = self.addr_offset.absolute(rel).ok()?;
1541 let header = self.parse_header(abs).ok()?;
1542 let msg = header
1543 .messages
1544 .iter()
1545 .find(|m| m.msg_type == MessageType::SharedMessageTable)?;
1546 let message =
1547 crate::sohm::SharedMessageTableMessage::parse(&msg.data, self.superblock.offset_size)
1548 .ok()?;
1549 // The table's address, like every address in a header message, is stored
1550 // relative to the file's base address, so the read is framed the same way
1551 // every other metadata walk here is.
1552 let base = self.addr_offset;
1553 let os = self.superblock.offset_size;
1554 match &self.backend {
1555 Backend::InMemory(v) => {
1556 crate::sohm::SohmTable::read(frame(v, base).ok()?, &message, os).ok()
1557 }
1558 Backend::Streaming(s) if base.is_zero() => {
1559 crate::sohm::SohmTable::read_from_source(s.as_ref(), &message, os).ok()
1560 }
1561 Backend::Streaming(s) => crate::sohm::SohmTable::read_from_source(
1562 &BaseOffsetSource {
1563 inner: s.as_ref(),
1564 base,
1565 },
1566 &message,
1567 os,
1568 )
1569 .ok(),
1570 Backend::Edit(m) => Self::with_engine(
1571 m,
1572 |d| Ok::<_, Error>(crate::sohm::SohmTable::read(frame(d, base)?, &message, os)?),
1573 |s| {
1574 if base.is_zero() {
1575 Ok(crate::sohm::SohmTable::read_from_source(s, &message, os)?)
1576 } else {
1577 Ok(crate::sohm::SohmTable::read_from_source(
1578 &BaseOffsetSource { inner: s, base },
1579 &message,
1580 os,
1581 )?)
1582 }
1583 },
1584 )
1585 .ok(),
1586 }
1587 .map(Box::new)
1588 }
1589
1590 /// Re-read the file from disk to pick up data appended by a concurrent
1591 /// writer, then re-parse the superblock.
1592 ///
1593 /// This is the SWMR reader's refresh primitive (analogous to the C library's
1594 /// `H5Drefresh` / h5py's `Dataset.refresh()`): after it returns, newly
1595 /// fetched [`Dataset`]/[`Group`] handles observe the writer's appended
1596 /// chunks and extended dimensions, because they re-parse object headers at
1597 /// their (stable) addresses against the refreshed bytes. Existing handles
1598 /// borrow `&self`, so they must be dropped before calling this; re-fetch
1599 /// them afterward.
1600 ///
1601 /// Returns [`Error::SwmrUnsupported`] if the file was not opened with
1602 /// [`File::open_swmr`]. The superblock is checksum-validated on every
1603 /// re-read; a transient parse failure (a writer caught mid-flush) is
1604 /// retried a bounded number of times before being surfaced.
1605 ///
1606 /// Cost: each call re-reads the entire file from disk (`O(file size)`).
1607 /// That keeps the implementation simple and correct, but when following a
1608 /// large, steadily growing log it is the cost paid per refresh; budget
1609 /// refresh frequency accordingly.
1610 pub fn refresh(&mut self) -> Result<(), Error> {
1611 let handle = self.handle.as_mut().ok_or(Error::SwmrUnsupported)?;
1612
1613 // A writer only appends (the file grows) and updates a few fixed-size,
1614 // individually checksummed structures in place (superblock EOF, object
1615 // header dimensions, array header counts). Re-reading the whole file and
1616 // re-validating the superblock checksum yields a consistent view; if the
1617 // superblock is caught mid-update, retry.
1618 const MAX_ATTEMPTS: u32 = 100;
1619 let mut last_err = None;
1620 for attempt in 0..MAX_ATTEMPTS {
1621 let mut data = Vec::new();
1622 handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
1623 handle.read_to_end(&mut data).map_err(Error::Io)?;
1624 match Self::parse_superblock(&data) {
1625 Ok((superblock, addr_offset)) => {
1626 self.backend = Backend::InMemory(data);
1627 self.superblock = superblock;
1628 self.addr_offset = addr_offset;
1629 self.file_space_info = self.read_file_space_info();
1630 self.sohm_table = self.read_sohm_table();
1631 // Every byte just moved. `File::refresh` takes `&mut self`
1632 // through `Arc::get_mut`, so no handle can be alive to see
1633 // it — but the counters are the file's statement about its
1634 // own bytes, and leaving them behind here would make that
1635 // statement false.
1636 *self.content_revision.get_mut() += 1;
1637 *self.address_revision.get_mut() += 1;
1638 return Ok(());
1639 }
1640 Err(e) => {
1641 last_err = Some(e);
1642 // Brief backoff before re-reading; the writer's in-place
1643 // updates are tiny, so a short pause clears the window. Skip
1644 // it on the final attempt, where there is no re-read to come.
1645 if attempt + 1 < MAX_ATTEMPTS {
1646 std::thread::sleep(std::time::Duration::from_micros(
1647 50 * (attempt + 1) as u64,
1648 ));
1649 }
1650 }
1651 }
1652 }
1653 // The loop always runs at least once and only reaches here via the
1654 // `Err` arm, so `last_err` is always `Some`; surface the real error.
1655 Err(last_err.expect("refresh retried at least once before failing"))
1656 }
1657
1658 /// Resolve a path to an object-header address, dispatching on the backend.
1659 fn resolve_path(&self, path: &str) -> Result<u64, Error> {
1660 Ok(match &self.backend {
1661 Backend::InMemory(v) => group_v2::resolve_path_any(v, &self.superblock, path)?,
1662 Backend::Streaming(s) => {
1663 group_v2::resolve_path_any_from_source(s.as_ref(), &self.superblock, path)?
1664 }
1665 // A staged commit can relocate the object tree's root, so this
1666 // file's cached superblock may name a stale one; resolve against the
1667 // session's own superblock, which the commit updates.
1668 Backend::Edit(m) => {
1669 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1670 let sb = core.superblock().clone();
1671 match core.image_slice() {
1672 Some(data) => group_v2::resolve_path_any(data, &sb, path)?,
1673 None => group_v2::resolve_path_any_from_source(core.image(), &sb, path)?,
1674 }
1675 }
1676 })
1677 }
1678
1679 /// The current root-group address (base-adjusted, absolute). For a read-write
1680 /// [`Backend::Edit`] file a prior relocating commit can have moved the
1681 /// root, so take the session's own superblock, which the commit updates;
1682 /// other backends use this file's cached one.
1683 fn mirror_root_address(&self) -> u64 {
1684 if let Backend::Edit(m) = &self.backend {
1685 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1686 return core.superblock().root_group_address;
1687 }
1688 self.superblock.root_group_address
1689 }
1690
1691 /// Returns the raw file bytes for an in-memory file, or an empty slice for a
1692 /// streaming file (which has no whole-file buffer).
1693 pub fn as_bytes(&self) -> &[u8] {
1694 match &self.backend {
1695 Backend::InMemory(v) => v,
1696 // A streaming, mirror, or bounded file has no borrowable whole-file
1697 // buffer.
1698 Backend::Streaming(_) | Backend::Edit(_) => &[],
1699 }
1700 }
1701
1702 /// Return the access properties used when opening this file.
1703 pub const fn access_properties(&self) -> FileAccessProperties {
1704 self.access_properties
1705 }
1706
1707 /// The backend this file's editing session resolved to, or `None` when there
1708 /// is no editing session to ask. Always [`EditBacking::Mirrored`] for the
1709 /// SWMR writer, which builds a session but does not dispatch on the strategy.
1710 fn edit_backing(&self) -> Option<EditBacking> {
1711 match &self.backend {
1712 Backend::Edit(m) => Some(m.lock().unwrap_or_else(|e| e.into_inner()).edit_backing()),
1713 _ => None,
1714 }
1715 }
1716
1717 /// What this file's metadata cache has done, or `None` where the backend
1718 /// holds none.
1719 fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
1720 self.with_source(|source| source.metadata_cache_stats())
1721 }
1722
1723 /// Zero those counters, keeping the cached entries.
1724 fn reset_metadata_cache_stats(&self) {
1725 self.with_source(|source| source.reset_metadata_cache_stats());
1726 }
1727
1728 /// Returns a reference to the parsed superblock.
1729 pub fn superblock(&self) -> &Superblock {
1730 &self.superblock
1731 }
1732
1733 /// The whole-file byte image when this file is buffered in memory
1734 /// ([`open`](Self::open) / [`from_bytes`](Self::from_bytes)); `None` for a
1735 /// streaming file ([`open_streaming`](Self::open_streaming)). Cross-file
1736 /// object copy ([`File::copy_from`](crate::File::copy_from)) uses this to read
1737 /// source objects by absolute address.
1738 pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
1739 match &self.backend {
1740 Backend::InMemory(data) => Some(data),
1741 Backend::Streaming(_) | Backend::Edit(_) => None,
1742 }
1743 }
1744
1745 /// The base address (`H5F` superblock base address), i.e. the byte offset
1746 /// added to every stored relative address. Zero for a file with no
1747 /// userblock.
1748 pub(crate) fn base_address(&self) -> BaseAddress {
1749 self.addr_offset
1750 }
1751
1752 /// The file-space management strategy this file records in its superblock
1753 /// extension (set with `H5Pset_file_space_strategy`), or `None` if the file
1754 /// records none — the default, which the C library also writes as "no
1755 /// message". See [`file_space_info`](Self::file_space_info) for the full
1756 /// record (persist flag, threshold, page size).
1757 pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
1758 self.file_space_info.as_ref().map(|info| info.strategy)
1759 }
1760
1761 /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
1762 /// if present and readable.
1763 pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
1764 self.file_space_info.as_ref()
1765 }
1766
1767 /// The free regions a file persists on disk in its free-space managers (when
1768 /// written with `H5Pset_file_space_strategy(..., persist = true)`), as
1769 /// `(address, length)` pairs sorted by address.
1770 ///
1771 /// Empty when the file does not persist free space, or for the streaming
1772 /// backend (which does not load the manager blocks). The addresses are file
1773 /// offsets (relative to the base address); reading data is unaffected by the
1774 /// presence or absence of these managers.
1775 pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
1776 let Some(info) = &self.file_space_info else {
1777 return Vec::new();
1778 };
1779 if !info.persist {
1780 return Vec::new();
1781 }
1782 let Backend::InMemory(data) = &self.backend else {
1783 return Vec::new();
1784 };
1785 let mut sections = free_space_manager::read_persisted_sections(
1786 data,
1787 &info.manager_addrs,
1788 self.addr_offset,
1789 self.superblock.offset_size,
1790 )
1791 .unwrap_or_default();
1792 // Distinct sections have distinct addresses in any well-formed file, so
1793 // the tie-break never arises; only a malformed manager can advertise one
1794 // address twice, and which of the pair is reported first is already
1795 // unspecified. No `debug_assert` here: this parses untrusted bytes, which
1796 // must not panic a debug build.
1797 sections.sort_unstable_by_key(|s| s.addr);
1798 sections.into_iter().map(|s| (s.addr, s.size)).collect()
1799 }
1800
1801 /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
1802 ///
1803 /// This is the total byte length of the backing store — for a streaming
1804 /// file the length reported by its source, for an in-memory file the length
1805 /// of its buffer. It includes any userblock prefix and trailing bytes, so it
1806 /// may exceed the superblock's logical end-of-file address; compare against
1807 /// `Superblock::eof_address` (reachable via
1808 /// [`File::superblock`]) to detect appended or unaccounted tail bytes.
1809 pub fn file_size(&self) -> u64 {
1810 match &self.backend {
1811 Backend::Edit(m) => {
1812 let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1813 core.image().len()
1814 }
1815 _ => self.source().len(),
1816 }
1817 }
1818
1819 /// The minimum library version required to read this file, derived from its
1820 /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
1821 ///
1822 /// A version 3 superblock, for example, reports [`LibVer::V110`] because it
1823 /// was introduced in HDF5 1.10.
1824 pub fn libver_bound(&self) -> LibVer {
1825 LibVer::from_superblock_version(self.superblock.version)
1826 }
1827
1828 fn parse_header(&self, address: u64) -> Result<ObjectHeader, FormatError> {
1829 let os = self.superblock.offset_size;
1830 let ls = self.superblock.length_size;
1831 match &self.backend {
1832 Backend::InMemory(v) => {
1833 ObjectHeader::parse_with_base(v, address.to_usize()?, os, ls, self.addr_offset)
1834 }
1835 Backend::Streaming(s) => {
1836 ObjectHeader::parse_from_source(s.as_ref(), address, os, ls, self.addr_offset)
1837 }
1838 Backend::Edit(m) => Self::with_engine(
1839 m,
1840 |d| ObjectHeader::parse_with_base(d, address.to_usize()?, os, ls, self.addr_offset),
1841 |s| ObjectHeader::parse_from_source(s, address, os, ls, self.addr_offset),
1842 ),
1843 }
1844 }
1845
1846 /// Resolve a base-relative object-header address (the value stored in an
1847 /// HDF5 `H5R_OBJECT` reference element) to the [`Object`] it points at.
1848 ///
1849 /// The stored address is relative to the superblock base address, so any
1850 /// MAT-file userblock is accounted for here. A null (`0`) or undefined
1851 /// (`HADDR_UNDEF`) address, or one whose object header is neither a dataset
1852 /// nor a group, yields [`FormatError::InvalidObjectReference`].
1853 fn object_at_relative(
1854 file: &Arc<FileInner>,
1855 revisions: Revisions,
1856 rel_addr: u64,
1857 ) -> Result<Object, Error> {
1858 // HADDR_UNDEF and the null address never name a real object. (Relative
1859 // address 0 is where the superblock sits, not an object header.)
1860 if rel_addr == u64::MAX || rel_addr == 0 {
1861 return Err(FormatError::InvalidObjectReference(rel_addr).into());
1862 }
1863 let abs = file
1864 .addr_offset
1865 .absolute(rel_addr)
1866 .map_err(|_| FormatError::InvalidObjectReference(rel_addr))?;
1867 let at = revisions.at(abs);
1868 let hdr = file.parse_header(abs)?;
1869 if has_message(&hdr, MessageType::DataLayout) {
1870 let chunk_cache = DatasetAccessProperties::new()
1871 .resolved_chunk_cache(file.access_properties.chunk_cache);
1872 Ok(Object::Dataset(Box::new(Dataset::new(
1873 file.clone(),
1874 at,
1875 hdr,
1876 chunk_cache,
1877 None,
1878 ))))
1879 } else if is_group(&hdr) {
1880 Ok(Object::Group(Group::new(file.clone(), at, None)))
1881 } else {
1882 Err(FormatError::InvalidObjectReference(rel_addr).into())
1883 }
1884 }
1885
1886 fn offset_size(&self) -> u8 {
1887 self.superblock.offset_size
1888 }
1889
1890 fn length_size(&self) -> u8 {
1891 self.superblock.length_size
1892 }
1893
1894 /// Resolve the children of a group object header, dispatching on the backend
1895 /// and converting link addresses to absolute.
1896 fn group_children(&self, hdr: &ObjectHeader) -> Result<Vec<GroupEntry>, Error> {
1897 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1898 let mut entries = match &self.backend {
1899 Backend::InMemory(v) => group_v2::resolve_group_entries(v, hdr, os, ls, base),
1900 Backend::Streaming(s) => {
1901 group_v2::resolve_group_entries_from_source(s.as_ref(), hdr, os, ls, base)
1902 }
1903 Backend::Edit(m) => Self::with_engine(
1904 m,
1905 |d| group_v2::resolve_group_entries(d, hdr, os, ls, base),
1906 |s| group_v2::resolve_group_entries_from_source(s, hdr, os, ls, base),
1907 ),
1908 }
1909 .map_err(Error::Format)?;
1910 for entry in &mut entries {
1911 // The stored address is relative to the base address; normalize to an
1912 // absolute file offset. A crafted entry (e.g. the HADDR_UNDEF sentinel)
1913 // must not wrap or panic.
1914 entry.object_header_address = base.absolute(entry.object_header_address)?;
1915 }
1916 Ok(entries)
1917 }
1918
1919 /// The child named `name`, at the absolute file address
1920 /// [`ChildLookup::Found`] carries.
1921 ///
1922 /// The by-name counterpart of [`group_children`](Self::group_children), and
1923 /// the one to reach for when a single child is wanted: it stops at the match
1924 /// rather than building an entry, and an owned name, for every other child
1925 /// of the group (issue #228).
1926 fn group_child(&self, group_address: u64, name: &str) -> Result<ChildLookup, Error> {
1927 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1928 let addr = group_address;
1929 match &self.backend {
1930 Backend::InMemory(v) => group_v2::find_child_address(v, addr, os, ls, base, name),
1931 Backend::Streaming(s) => {
1932 group_v2::find_child_address_from_source(s.as_ref(), addr, os, ls, base, name)
1933 }
1934 Backend::Edit(m) => Self::with_engine(
1935 m,
1936 |d| group_v2::find_child_address(d, addr, os, ls, base, name),
1937 |s| group_v2::find_child_address_from_source(s, addr, os, ls, base, name),
1938 ),
1939 }
1940 .map_err(Error::Format)
1941 }
1942
1943 /// Read all attributes attached to an object header, dispatching on the
1944 /// backend.
1945 fn attrs_of(&self, hdr: &ObjectHeader) -> Result<HashMap<String, AttrValue>, Error> {
1946 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1947 let attr_msgs = self.attr_messages_of(hdr)?;
1948 match &self.backend {
1949 Backend::Edit(m) => Ok(Self::with_engine(
1950 m,
1951 |d| attrs_to_map(&attr_msgs, &BytesSource::new(d), os, ls, base),
1952 |s| attrs_to_map(&attr_msgs, s, os, ls, base),
1953 )),
1954 _ => Ok(attrs_to_map(&attr_msgs, &self.source(), os, ls, base)),
1955 }
1956 }
1957
1958 /// The content of a header message, following the reference when the record
1959 /// marks the message *shared*.
1960 ///
1961 /// A shared record's body is not the message: it is an address, and a
1962 /// committed (`H5Tcommit`) datatype is stored exactly that way. Decoding the
1963 /// body directly turns a named `H5T_STD_I32LE` into a zero-width time type
1964 /// with no error anywhere, so every read of a message that HDF5 permits to be
1965 /// shared — datatype, dataspace, fill value, filter pipeline — goes through
1966 /// here. The borrowed case allocates nothing, which is every message this
1967 /// crate writes and nearly every one it reads.
1968 fn message_body<'m>(
1969 &self,
1970 msg: &'m crate::object_header::HeaderMessage,
1971 ) -> Result<Cow<'m, [u8]>, Error> {
1972 if !shared_message::is_shared(msg.flags) {
1973 return Ok(Cow::Borrowed(&msg.data));
1974 }
1975 let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1976 let sohm = self.sohm_table.as_deref();
1977 // A shared reference stores its address relative to the base address, so
1978 // frame the file at `base` exactly as [`Self::attr_messages_of`] does.
1979 let resolved = match &self.backend {
1980 Backend::InMemory(v) => BufferedResolver::new(frame(v, base)?, os, ls, sohm)
1981 .resolve(&msg.data, msg.msg_type),
1982 Backend::Streaming(s) if base.is_zero() => {
1983 SourceResolver::new(s.as_ref(), os, ls, sohm).resolve(&msg.data, msg.msg_type)
1984 }
1985 Backend::Streaming(s) => SourceResolver::new(
1986 &BaseOffsetSource {
1987 inner: s.as_ref(),
1988 base,
1989 },
1990 os,
1991 ls,
1992 sohm,
1993 )
1994 .resolve(&msg.data, msg.msg_type),
1995 Backend::Edit(m) => Self::with_engine(
1996 m,
1997 |d| {
1998 BufferedResolver::new(frame(d, base)?, os, ls, sohm)
1999 .resolve(&msg.data, msg.msg_type)
2000 },
2001 |s| {
2002 if base.is_zero() {
2003 SourceResolver::new(s, os, ls, sohm).resolve(&msg.data, msg.msg_type)
2004 } else {
2005 SourceResolver::new(&BaseOffsetSource { inner: s, base }, os, ls, sohm)
2006 .resolve(&msg.data, msg.msg_type)
2007 }
2008 },
2009 ),
2010 }?;
2011 Ok(Cow::Owned(resolved))
2012 }
2013
2014 /// The object-header address a *shared* header message names, or `None` when
2015 /// the record carries its own content or names the shared-message heap.
2016 ///
2017 /// [`Self::message_body`] answers what the message says; this answers which
2018 /// object says it. A rewrite needs both: the content to reproduce the type,
2019 /// and the address to tell which users share one committed object rather than
2020 /// each naming a type of their own. A heap-stored message has no such object
2021 /// — it is one anonymous copy rather than a named one — so it answers `None`
2022 /// and a rewrite spells the message out, which is what the file already
2023 /// means.
2024 pub(crate) fn shared_target_address(
2025 &self,
2026 msg: &crate::object_header::HeaderMessage,
2027 ) -> Result<Option<u64>, Error> {
2028 if !shared_message::is_shared(msg.flags) {
2029 return Ok(None);
2030 }
2031 let reference =
2032 shared_message::parse_shared_ref(&msg.data, self.offset_size(), self.length_size())?;
2033 match reference.location {
2034 shared_message::SharedLocation::ObjectHeader(addr) => Ok(Some(addr)),
2035 shared_message::SharedLocation::SohmHeap(_) => Ok(None),
2036 }
2037 }
2038
2039 /// Extract every attribute message attached to an object header (compact,
2040 /// shared, and dense storage), dispatching on the backend.
2041 pub(crate) fn attr_messages_of(
2042 &self,
2043 hdr: &ObjectHeader,
2044 ) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
2045 let (os, ls) = (self.offset_size(), self.length_size());
2046 // Compact attributes come out of `hdr`, but the two addresses this walk
2047 // follows are read from message bodies and so are stored relative to the
2048 // base address: the Attribute Info message's fractal-heap address, and a
2049 // shared attribute's message address. Frame the file at `base` exactly as
2050 // [`Self::read_dataset_raw`] does, so both index it directly. For a plain
2051 // file (`base == 0`) this is the identity; without it, a userblock file's
2052 // dense attributes are looked for one userblock too early.
2053 let base = self.addr_offset;
2054 let sohm = self.sohm_table.as_deref();
2055 match &self.backend {
2056 Backend::InMemory(v) => {
2057 Ok(extract_attributes_full(frame(v, base)?, hdr, os, ls, sohm)?)
2058 }
2059 Backend::Streaming(s) if base.is_zero() => Ok(extract_attributes_full_from_source(
2060 s.as_ref(),
2061 hdr,
2062 os,
2063 ls,
2064 sohm,
2065 )?),
2066 Backend::Streaming(s) => {
2067 let framed = BaseOffsetSource {
2068 inner: s.as_ref(),
2069 base,
2070 };
2071 Ok(extract_attributes_full_from_source(
2072 &framed, hdr, os, ls, sohm,
2073 )?)
2074 }
2075 Backend::Edit(m) => Self::with_engine(
2076 m,
2077 |d| Ok(extract_attributes_full(frame(d, base)?, hdr, os, ls, sohm)?),
2078 |s| {
2079 if base.is_zero() {
2080 Ok(extract_attributes_full_from_source(s, hdr, os, ls, sohm)?)
2081 } else {
2082 let framed = BaseOffsetSource { inner: s, base };
2083 Ok(extract_attributes_full_from_source(
2084 &framed, hdr, os, ls, sohm,
2085 )?)
2086 }
2087 },
2088 ),
2089 }
2090 }
2091
2092 /// Read a dataset's raw bytes for the given layout, dispatching on the backend.
2093 fn read_dataset_raw(
2094 &self,
2095 spec: RawReadSpec<'_>,
2096 cache: &ChunkCache,
2097 ) -> Result<Vec<u8>, FormatError> {
2098 let (os, ls) = (self.offset_size(), self.length_size());
2099 // Every on-disk address in `dl` — the contiguous data address, the chunk
2100 // index root, and (followed deeper in the chunked reader) every B-tree /
2101 // fixed-array / extensible-array node and chunk-data address — is stored
2102 // relative to the base address. Present the payload reader a base-relative
2103 // view of the file so all of them index it directly: slice the in-memory
2104 // buffer at `base`, or wrap the streaming source to add `base` to each
2105 // read. For a plain file (`base == 0`) this is the identity.
2106 let base = self.addr_offset;
2107 match &self.backend {
2108 Backend::InMemory(v) => {
2109 data_read::read_raw_data_cached(frame(v, base)?, spec, os, ls, cache)
2110 }
2111 Backend::Streaming(s) if base.is_zero() => {
2112 data_read::read_raw_data_cached_from_source(s.as_ref(), spec, os, ls, cache)
2113 }
2114 Backend::Streaming(s) => {
2115 let framed = BaseOffsetSource {
2116 inner: s.as_ref(),
2117 base,
2118 };
2119 data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2120 }
2121 Backend::Edit(m) => Self::with_engine(
2122 m,
2123 |data| {
2124 let framed = frame(data, base)?;
2125 data_read::read_raw_data_cached(framed, spec, os, ls, cache)
2126 },
2127 |s| {
2128 let framed = BaseOffsetSource { inner: s, base };
2129 data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2130 },
2131 ),
2132 }
2133 }
2134
2135 /// Windowed counterpart of [`read_dataset_raw`](Self::read_dataset_raw): read
2136 /// the raw element bytes of the row window `[start_row, start_row + num_rows)`,
2137 /// touching only the storage it overlaps. Reads through the same base-framed
2138 /// `Source`, so on-disk addresses resolve the same way. The caller clamps
2139 /// the window to the dataset.
2140 fn read_dataset_raw_rows(
2141 &self,
2142 spec: RawReadSpec<'_>,
2143 cache: &ChunkCache,
2144 pass: CachePass,
2145 start_row: u64,
2146 num_rows: u64,
2147 ) -> Result<Vec<u8>, FormatError> {
2148 let (os, ls) = (self.offset_size(), self.length_size());
2149 let (dl, ds, dt) = (spec.layout, spec.dataspace, spec.datatype);
2150 let elem_size = dt.element_size_usize()?;
2151 // Elements per row (product of inner dims; 1 when 0-D or 1-D). Checked so
2152 // a crafted dataspace whose inner dims overflow `usize` errors instead of
2153 // panicking (debug) or wrapping (release).
2154 let row_elems: usize = ds.dimensions.iter().skip(1).try_fold(1usize, |acc, &d| {
2155 acc.checked_mul(d.to_usize()?)
2156 .ok_or(FormatError::OffsetOverflow {
2157 offset: acc as u64,
2158 length: d,
2159 })
2160 })?;
2161 let row_bytes =
2162 row_elems
2163 .checked_mul(elem_size.get())
2164 .ok_or(FormatError::OffsetOverflow {
2165 offset: row_elems as u64,
2166 length: elem_size.get() as u64,
2167 })?;
2168
2169 // Compact data is inline in the layout message — no I/O, no framing.
2170 if let DataLayout::Compact { data } = dl {
2171 let start = start_row.to_usize()?.checked_mul(row_bytes);
2172 let len = num_rows.to_usize()?.checked_mul(row_bytes);
2173 let (Some(start), Some(len)) = (start, len) else {
2174 return Err(FormatError::OffsetOverflow {
2175 offset: start_row,
2176 length: row_bytes as u64,
2177 });
2178 };
2179 let end = start.checked_add(len).ok_or(FormatError::OffsetOverflow {
2180 offset: start as u64,
2181 length: len as u64,
2182 })?;
2183 return data
2184 .get(start..end)
2185 .map(<[u8]>::to_vec)
2186 .ok_or(FormatError::DataSizeMismatch {
2187 expected: end,
2188 actual: data.len(),
2189 });
2190 }
2191
2192 let base = self.addr_offset;
2193 match &self.backend {
2194 Backend::InMemory(v) => {
2195 let framed = frame(v, base)?;
2196 read_rows_framed(
2197 &BytesSource::new(framed),
2198 spec,
2199 os,
2200 ls,
2201 cache,
2202 pass,
2203 start_row,
2204 num_rows,
2205 row_bytes,
2206 )
2207 }
2208 Backend::Streaming(s) if base.is_zero() => read_rows_framed(
2209 s.as_ref(),
2210 spec,
2211 os,
2212 ls,
2213 cache,
2214 pass,
2215 start_row,
2216 num_rows,
2217 row_bytes,
2218 ),
2219 Backend::Streaming(s) => {
2220 let framed = BaseOffsetSource {
2221 inner: s.as_ref(),
2222 base,
2223 };
2224 read_rows_framed(
2225 &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2226 )
2227 }
2228 Backend::Edit(m) => Self::with_engine(
2229 m,
2230 |data| {
2231 let framed = frame(data, base)?;
2232 read_rows_framed(
2233 &BytesSource::new(framed),
2234 spec,
2235 os,
2236 ls,
2237 cache,
2238 pass,
2239 start_row,
2240 num_rows,
2241 row_bytes,
2242 )
2243 },
2244 |s| {
2245 let framed = BaseOffsetSource { inner: s, base };
2246 read_rows_framed(
2247 &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2248 )
2249 },
2250 ),
2251 }
2252 }
2253}
2254
2255/// Read a row window through an already base-framed `Source`. Contiguous
2256/// layouts are one bounded sub-read; chunked layouts use the windowed chunk
2257/// reader (only the rank-0 crafted-file corner falls back to a whole read
2258/// plus slice).
2259fn read_rows_framed<S: Source + ?Sized>(
2260 source: &S,
2261 spec: RawReadSpec<'_>,
2262 os: u8,
2263 ls: u8,
2264 cache: &ChunkCache,
2265 pass: CachePass,
2266 start_row: u64,
2267 num_rows: u64,
2268 row_bytes: usize,
2269) -> Result<Vec<u8>, FormatError> {
2270 let (dl, fill) = (spec.layout, spec.fill);
2271 // A zero-row window reads nothing, uniformly across the *supported* layouts.
2272 // A `Virtual` layout is unsupported and must still error like `read_raw`
2273 // does, so it is excluded here and falls through to the match.
2274 if num_rows == 0 && !matches!(dl, DataLayout::Virtual { .. }) {
2275 return Ok(Vec::new());
2276 }
2277 match dl {
2278 DataLayout::Compact { .. } => unreachable!("compact is handled before framing"),
2279 DataLayout::Contiguous { address, size } => {
2280 // Unallocated storage: the window reads as the fill value, the same
2281 // answer the whole-dataset readers give for it.
2282 let Some(addr) = *address else {
2283 let len = num_rows.to_usize()?.saturating_mul(row_bytes);
2284 return fill.buffer(len);
2285 };
2286 let start =
2287 start_row
2288 .checked_mul(row_bytes as u64)
2289 .ok_or(FormatError::OffsetOverflow {
2290 offset: start_row,
2291 length: row_bytes as u64,
2292 })?;
2293 let len =
2294 num_rows
2295 .to_usize()?
2296 .checked_mul(row_bytes)
2297 .ok_or(FormatError::OffsetOverflow {
2298 offset: num_rows,
2299 length: row_bytes as u64,
2300 })?;
2301 // Never read past the dataset's own contiguous storage.
2302 if start.saturating_add(len as u64) > *size {
2303 return Err(FormatError::DataSizeMismatch {
2304 expected: start.to_usize()?.saturating_add(len),
2305 actual: (*size).to_usize()?,
2306 });
2307 }
2308 let off = addr.checked_add(start).ok_or(FormatError::OffsetOverflow {
2309 offset: addr,
2310 length: start,
2311 })?;
2312 source.read_exact_at(off, len)
2313 }
2314 DataLayout::Chunked { .. } => {
2315 match crate::chunked_read::read_chunked_rows_from_source(
2316 source, spec, os, ls, cache, pass, start_row, num_rows,
2317 )? {
2318 Some(bytes) => Ok(bytes),
2319 // Rank-0 chunked (a crafted-file corner): fall back to a whole
2320 // read, then slice.
2321 None => {
2322 let full =
2323 data_read::read_raw_data_cached_from_source(source, spec, os, ls, cache)?;
2324 let start = start_row.to_usize()? * row_bytes;
2325 let len = num_rows.to_usize()? * row_bytes;
2326 full.get(start..start + len).map(<[u8]>::to_vec).ok_or(
2327 FormatError::DataSizeMismatch {
2328 expected: start + len,
2329 actual: full.len(),
2330 },
2331 )
2332 }
2333 }
2334 }
2335 DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVirtualLayout),
2336 }
2337}
2338
2339impl std::fmt::Debug for FileInner {
2340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2341 f.debug_struct("File")
2342 .field("size", &self.file_size())
2343 .field("superblock_version", &self.superblock.version)
2344 .finish()
2345 }
2346}
2347
2348/// An open HDF5 file.
2349///
2350/// A `File` is an owned, cheaply cloneable handle to an open file: cloning it (or
2351/// deriving a [`Dataset`]/[`Group`] from it) shares one underlying open file
2352/// rather than re-reading it. Object handles returned by [`dataset`](Self::dataset),
2353/// [`group`](Self::group), and [`root`](Self::root) are **owned** — they keep the
2354/// file open for as long as they live and carry no borrow of the `File`, so they
2355/// can be stored in a struct, cached, cloned, and moved across threads. They stay
2356/// usable across a [`commit`](Self::commit), which is what makes caching one
2357/// worthwhile; see [`commit`](Self::commit) for the two cases that report
2358/// instead.
2359#[derive(Clone)]
2360pub struct File {
2361 inner: Arc<FileInner>,
2362}
2363
2364impl std::fmt::Debug for File {
2365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2366 std::fmt::Debug::fmt(&*self.inner, f)
2367 }
2368}
2369
2370impl File {
2371 /// Open an HDF5 file from a filesystem path.
2372 ///
2373 /// Reads the file into memory once. To follow a file that a concurrent
2374 /// single writer is appending to (SWMR), use [`File::open_swmr`] instead.
2375 /// To read a file larger than memory (e.g. on a 32-bit host) without
2376 /// buffering it, use [`File::open_streaming`].
2377 ///
2378 /// A file whose superblock marks it as held by a writer is refused with
2379 /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse) — the check
2380 /// `H5Fopen` makes of the same byte. That means a live writer or one that
2381 /// exited without closing the file; clear a stale flag with
2382 /// [`clear_swmr_flag`](Self::clear_swmr_flag), and follow a live SWMR writer
2383 /// with [`open_swmr`](Self::open_swmr). [`from_bytes`](Self::from_bytes) does
2384 /// not check, since its caller already holds the bytes — which is also the
2385 /// way to read a flagged file on a read-only mount, where clearing the flag
2386 /// would need write access.
2387 pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2388 Ok(File {
2389 inner: Arc::new(FileInner::open(path)?),
2390 })
2391 }
2392
2393 /// Open an HDF5 file from a filesystem path with explicit access properties.
2394 pub fn open_with_options<P: AsRef<std::path::Path>>(
2395 path: P,
2396 properties: FileAccessProperties,
2397 ) -> Result<Self, Error> {
2398 Ok(File {
2399 inner: Arc::new(FileInner::open_with_options(path, properties)?),
2400 })
2401 }
2402
2403 /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
2404 /// the file instead of buffering it whole.
2405 ///
2406 /// This lets a host read a file larger than its address space. Metadata and
2407 /// dataset chunks are read through a `ReadSeekSource`, so peak memory stays
2408 /// close to one chunk plus the metadata being parsed; chunks adjacent on
2409 /// disk are fetched together, in reads of at most 256 KiB, and a chunk
2410 /// larger than that is read on its own. Attribute reading and v1
2411 /// symbol-table groups on the resolved path are not yet supported on this
2412 /// backend.
2413 ///
2414 /// Like [`open`](Self::open), this refuses a file whose superblock marks it
2415 /// as held by a writer.
2416 pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2417 Ok(File {
2418 inner: Arc::new(FileInner::open_streaming(path)?),
2419 })
2420 }
2421
2422 /// Open an HDF5 file for streaming reads with explicit access properties.
2423 pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
2424 path: P,
2425 properties: FileAccessProperties,
2426 ) -> Result<Self, Error> {
2427 Ok(File {
2428 inner: Arc::new(FileInner::open_streaming_with_options(path, properties)?),
2429 })
2430 }
2431
2432 /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
2433 /// demand exactly as [`open_streaming`](Self::open_streaming) does.
2434 ///
2435 /// This is the streaming open for a caller whose bytes are not a path: an
2436 /// object store addressed by HTTP range request, a sandboxed guest that
2437 /// receives byte ranges from its host, a decrypting layer. A [`Source`]
2438 /// supplies a length and reads at an absolute offset, which is all the
2439 /// reader asks of a file, so peak memory stays at the metadata being parsed
2440 /// plus the chunks a read touches — not the file. Wrap a `Read + Seek` in
2441 /// [`ReadSeekSource`] rather than writing that impl again.
2442 ///
2443 /// A file marked as held by a writer is refused here as it is by
2444 /// [`open_streaming`](Self::open_streaming); with no path to report, the
2445 /// error names the source instead. Recovering such a file in place needs a
2446 /// path, through [`File::clear_swmr_flag`], so a caller that has none is
2447 /// left with [`File::from_bytes`](Self::from_bytes) and the whole file in
2448 /// memory.
2449 ///
2450 /// The metadata cache is **off** unless
2451 /// [`from_source_with_options`](Self::from_source_with_options) turns it
2452 /// on, which matters more here than it does for a local file: without one,
2453 /// every read a parser makes is a round trip. See
2454 /// [`MetadataCacheConfig`].
2455 ///
2456 /// ```no_run
2457 /// use hdf5_pure::{File, FormatError, Source};
2458 ///
2459 /// // However the bytes actually arrive: a range request, a host call, a
2460 /// // decrypting layer over a file.
2461 /// # fn fetch(offset: u64, len: usize) -> Result<Vec<u8>, String> { unimplemented!() }
2462 ///
2463 /// struct Remote {
2464 /// len: u64,
2465 /// }
2466 ///
2467 /// impl Source for Remote {
2468 /// fn len(&self) -> u64 {
2469 /// self.len
2470 /// }
2471 ///
2472 /// fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
2473 /// // Fill the whole request or fail: a short read is an error.
2474 /// let bytes = fetch(offset, buf.len()).map_err(FormatError::Source)?;
2475 /// buf.copy_from_slice(&bytes);
2476 /// Ok(())
2477 /// }
2478 /// }
2479 ///
2480 /// let file = File::from_source(Remote { len: 1 << 30 })?;
2481 /// let rows = file.dataset("frames")?.read_f64_rows(0, 64)?;
2482 /// # Ok::<(), hdf5_pure::Error>(())
2483 /// ```
2484 ///
2485 /// A `Read + Seek` needs none of that: wrap it in [`ReadSeekSource`].
2486 /// Reading a *file* that way is [`open_streaming`](Self::open_streaming),
2487 /// which does the wrapping for you.
2488 pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
2489 Ok(File {
2490 inner: Arc::new(FileInner::from_source(source)?),
2491 })
2492 }
2493
2494 /// Open an HDF5 file from any [`Source`] with explicit access properties.
2495 ///
2496 /// The metadata cache is what a remote source wants tuned: every parser
2497 /// read becomes a round trip without one. See [`MetadataCacheConfig`].
2498 pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
2499 source: S,
2500 properties: FileAccessProperties,
2501 ) -> Result<Self, Error> {
2502 Ok(File {
2503 inner: Arc::new(FileInner::from_source_with_options(source, properties)?),
2504 })
2505 }
2506
2507 /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
2508 ///
2509 /// Like [`File::open`], but retains a live handle to the file so that
2510 /// [`File::refresh`] can re-read data appended by a concurrent writer.
2511 ///
2512 /// This is the open that *follows* a file marked as held by a SWMR writer,
2513 /// where [`open`](Self::open) refuses one. Only a half-set mark is refused
2514 /// here, with [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse):
2515 /// either bit without the other. Write access alone is what a plain
2516 /// (non-SWMR) writer leaves, and there is no protocol for following a writer
2517 /// that is not publishing consistent prefixes; the SWMR bit alone is a state
2518 /// no writer produces. Both bits is the live SWMR writer this exists to
2519 /// follow, and neither is a quiescent file.
2520 #[doc(alias = "H5F_ACC_SWMR_READ")]
2521 pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2522 Ok(File {
2523 inner: Arc::new(FileInner::open_swmr(path)?),
2524 })
2525 }
2526
2527 /// Open an HDF5 file for SWMR reading with explicit access properties.
2528 pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
2529 path: P,
2530 properties: FileAccessProperties,
2531 ) -> Result<Self, Error> {
2532 Ok(File {
2533 inner: Arc::new(FileInner::open_swmr_with_options(path, properties)?),
2534 })
2535 }
2536
2537 /// Open an HDF5 file from an in-memory byte vector.
2538 pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
2539 Ok(File {
2540 inner: Arc::new(FileInner::from_bytes(data)?),
2541 })
2542 }
2543
2544 /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
2545 pub fn from_bytes_with_options(
2546 data: Vec<u8>,
2547 properties: FileAccessProperties,
2548 ) -> Result<Self, Error> {
2549 Ok(File {
2550 inner: Arc::new(FileInner::from_bytes_with_options(data, properties)?),
2551 })
2552 }
2553
2554 /// Open an existing HDF5 file for reading **and** in-place editing.
2555 ///
2556 /// Unlike [`open`](Self::open) (read-only, buffered), this takes an exclusive
2557 /// OS file lock held for the file's life and lets owned handles modify the
2558 /// file — immediate [`Dataset::append`]s, plus [`Dataset::write`]/`set_attr`,
2559 /// [`Group::create_dataset`]/`create_group`/`delete`/`set_attr`, and
2560 /// [`copy`](Self::copy)/[`copy_from`](Self::copy_from) staged until
2561 /// [`commit`](Self::commit). The file must use 8-byte offsets and lengths and
2562 /// keep its superblock at its base address (a canonical userblock, as in a
2563 /// MATLAB `.mat` file, is supported); anything else is refused with
2564 /// [`Error::EditUnsupported`](crate::Error::EditUnsupported).
2565 ///
2566 /// The fast immediate [`Dataset::append`] additionally requires a
2567 /// latest-format (version-2/3) file with no userblock and an
2568 /// Extensible-Array-indexed dataset; [`Dataset::append_staged`] covers the
2569 /// general case.
2570 ///
2571 /// Two things can turn this open away because another writer holds the file:
2572 /// the exclusive OS lock, reported as
2573 /// [`Error::FileLocked`](crate::Error::FileLocked), and the superblock's
2574 /// status-flags byte, reported as
2575 /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse). The second
2576 /// covers what the first cannot — a SWMR writer takes no lock, and a writer
2577 /// that exited without closing the file leaves the flag behind; recover a
2578 /// stale one with [`clear_swmr_flag`](Self::clear_swmr_flag).
2579 ///
2580 /// # Memory
2581 ///
2582 /// This picks its backing from the file rather than making the caller pick a
2583 /// function (issue #198): a latest-format file with no userblock is edited
2584 /// **bounded**, holding only the metadata being parsed plus the configured
2585 /// caches plus what an edit is building, so resident memory does not scale
2586 /// with the file; anything else falls back to a whole-file in-memory mirror,
2587 /// which is what makes a pre-v2 or userblock file editable at all. The two
2588 /// backings are the same engine over different storage and offer the same
2589 /// edit surface, differing in one trade: the bounded one applies a large
2590 /// immediate append in whole-chunk batches, each crash-atomic on its own, so
2591 /// a crash mid-call leaves a valid shorter dataset rather than none of the
2592 /// append. Ask a file which it got with
2593 /// [`edit_backing`](Self::edit_backing), and demand one with
2594 /// [`FileAccessProperties::with_memory_strategy`] —
2595 /// [`MemoryStrategy::Mirrored`] restores the unconditional mirror this
2596 /// entry point used before it learned to dispatch.
2597 #[doc(alias = "H5Fopen")]
2598 pub fn open_rw<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2599 Self::open_rw_with_options(path, FileAccessProperties::new())
2600 }
2601
2602 /// Open an existing file for reading and in-place editing with explicit
2603 /// access properties — see [`open_rw`](Self::open_rw).
2604 ///
2605 /// The properties carry the locking policy (the `H5Pset_file_locking` analogue,
2606 /// [`FileAccessProperties::with_locking`]), the memory strategy
2607 /// ([`FileAccessProperties::with_memory_strategy`], which overrides the
2608 /// dispatch described on [`open_rw`](Self::open_rw)), the `fsync` cadence
2609 /// ([`FileAccessProperties::with_sync_policy`]), the metadata cache used by
2610 /// the bounded backing, and the file-wide chunk-cache default applied to
2611 /// datasets opened from this file. Because one [`FileAccessProperties`] value
2612 /// serves every open, the same configuration can be shared with a read path.
2613 pub fn open_rw_with_options<P: AsRef<std::path::Path>>(
2614 path: P,
2615 properties: FileAccessProperties,
2616 ) -> Result<Self, Error> {
2617 Ok(File {
2618 inner: Arc::new(FileInner::open_rw(path, properties)?),
2619 })
2620 }
2621
2622 /// Open exactly as [`open_rw`](Self::open_rw) does, but behind an image that
2623 /// withholds its whole-file slice, so every read takes the `Source` path
2624 /// rather than the slice fast path.
2625 ///
2626 /// Each read this file serves has two forms (see `with_engine`), and only
2627 /// the slice form runs in production until a mirrorless backing lands
2628 /// (issue #198). Opening the same file both ways and comparing is what
2629 /// holds the other form to the same answers in the meantime.
2630 #[cfg(test)]
2631 pub(crate) fn open_rw_source_only(path: &std::path::Path) -> Result<Self, Error> {
2632 Ok(File {
2633 inner: Arc::new(FileInner::from_rw_session(
2634 WriteEngine::open_source_only(path)?,
2635 FileAccessProperties::new(),
2636 )?),
2637 })
2638 }
2639
2640 /// Open an existing file for **SWMR** (single-writer/multiple-reader)
2641 /// appending: take **no** OS lock (so concurrent readers, and Windows'
2642 /// mandatory locks, are never blocked) and raise the superblock's SWMR-write
2643 /// flag so a reader may attach with [`File::open_swmr`], the C library's
2644 /// `H5F_ACC_SWMR_READ`, or h5py `swmr=True`.
2645 ///
2646 /// Only immediate [`Dataset::append`] is permitted, and only over the SWMR
2647 /// subset — an **unfiltered**, chunk-aligned append, so a concurrent reader
2648 /// only ever observes a consistent prefix; a filtered or non-chunk-aligned
2649 /// append returns [`Error::SwmrAppendUnsupported`](crate::Error::SwmrAppendUnsupported).
2650 /// The staged edit surface (`write`/`set_attr`/`create_*`/`delete`/`copy`/
2651 /// `commit`) returns
2652 /// [`Error::SwmrStagedUnsupported`](crate::Error::SwmrStagedUnsupported).
2653 /// [`close`](Self::close) clears the SWMR-write flag; a writer that exits
2654 /// without a clean close leaves it set — recover with
2655 /// [`clear_swmr_flag`](Self::clear_swmr_flag). While the flag stands, this
2656 /// open is refused with
2657 /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse), which is what
2658 /// keeps a second writer off a file SWMR gives only one (no OS lock is held
2659 /// to do it).
2660 ///
2661 /// Requires a latest-format (version-3 superblock) file with no userblock
2662 /// and no persisted free-space; other files are refused with
2663 /// [`Error::SwmrAppendUnsupported`](crate::Error::SwmrAppendUnsupported).
2664 /// The version-3 requirement is the C library's: neither library reads the
2665 /// SWMR-write flag back on an older superblock, so raising one there would
2666 /// announce the writer to nobody.
2667 #[doc(alias = "H5F_ACC_SWMR_WRITE")]
2668 pub fn open_swmr_writer<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2669 Self::open_swmr_writer_with_options(path, FileAccessProperties::new())
2670 }
2671
2672 /// Open for SWMR appending with explicit access properties — see
2673 /// [`open_swmr_writer`](Self::open_swmr_writer).
2674 ///
2675 /// The properties' chunk cache is the file-wide default for datasets opened
2676 /// from this file. Its locking policy is ignored, which costs the caller
2677 /// nothing: SWMR takes no OS lock by design, which is stronger than any
2678 /// locking a caller could ask for. Its memory strategy is *not* ignored the
2679 /// same way — this writer always mirrors, so an explicit
2680 /// [`MemoryStrategy::Bounded`] is a guarantee it cannot meet and is refused
2681 /// with [`Error::EditUnsupported`]; [`MemoryStrategy::Auto`] and
2682 /// [`MemoryStrategy::Mirrored`] are both satisfied by the mirror.
2683 ///
2684 /// Its [`SyncPolicy`](crate::SyncPolicy) applies here as to any other
2685 /// read-write session, the SWMR-write flag included; a reader on this
2686 /// machine is unaffected either way, since the barriers carry the write
2687 /// order across power loss rather than across processes.
2688 pub fn open_swmr_writer_with_options<P: AsRef<std::path::Path>>(
2689 path: P,
2690 properties: FileAccessProperties,
2691 ) -> Result<Self, Error> {
2692 Ok(File {
2693 inner: Arc::new(FileInner::open_swmr_writer(path, properties)?),
2694 })
2695 }
2696
2697 /// Clear a stale status flag left in `path` by a writer that exited without a
2698 /// clean [`close`](Self::close) — the `h5clear -s` equivalent, for recovering
2699 /// a file that both this crate and the reference C library otherwise refuse
2700 /// to open ([`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse)). A
2701 /// no-op if the flag is already clear.
2702 ///
2703 /// It takes the exclusive OS lock first, so it cannot clear the flag out
2704 /// from under a *live* [`open_rw`](Self::open_rw) writer. A live SWMR writer
2705 /// holds no lock, so make sure it is really gone: clearing the flag under
2706 /// one leaves its readers with no record that it is publishing.
2707 ///
2708 /// It also clears the crash mark a page-buffered session raises
2709 /// ([`FileAccessProperties::with_page_buffer_size`]), and there the warning is
2710 /// sharper. That mark stands for pages that were still in memory, so a file
2711 /// still carrying it was left by a writer that did not finish: clearing it
2712 /// hands back a file whose datasets may read clean and return fill values or
2713 /// a deleted object's bytes, with every checksum verifying. Clear it to
2714 /// salvage what is there, not to resume trusting it. `h5clear` makes the same
2715 /// trade for the same reason.
2716 pub fn clear_swmr_flag<P: AsRef<std::path::Path>>(path: P) -> Result<(), Error> {
2717 crate::file_lock::clear_swmr_flag_at(path.as_ref())
2718 }
2719
2720 /// Create a new, empty HDF5 file at `path` and open it for reading and
2721 /// writing, so its contents can be built entirely through owned handles
2722 /// ([`Group::create_dataset`]/[`create_group`](Group::create_group), then
2723 /// [`commit`](Self::commit)).
2724 ///
2725 /// Overwrites any existing file at `path`. For an all-at-once write, use
2726 /// [`FileBuilder`](crate::FileBuilder) instead.
2727 #[doc(alias = "H5Fcreate")]
2728 pub fn create<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2729 Self::create_with_options(
2730 path,
2731 FileCreateProperties::new(),
2732 FileAccessProperties::new(),
2733 )
2734 }
2735
2736 /// Create a new, empty HDF5 file with explicit creation and access properties,
2737 /// then open it for reading and writing — see [`create`](Self::create).
2738 ///
2739 /// Mirrors `H5Fcreate(name, flags, fcpl_id, fapl_id)`: `create` carries the
2740 /// creation properties recorded in the new file (userblock, file-space
2741 /// strategy, library-version bounds), and `access` the properties governing
2742 /// the handle returned (locking policy, `fsync` cadence, chunk cache). Both are values, so a
2743 /// layout defined once can be reused across every file an application writes.
2744 ///
2745 /// A creation property is validated as the file is written, so an invalid
2746 /// userblock or page size surfaces here rather than when the properties were
2747 /// built. A file created with [`FileSpaceStrategy::Page`] can be grown
2748 /// through either editor, by an immediate [`Dataset::append`] or a staged
2749 /// commit, provided it also persists its free space (issue #198).
2750 pub fn create_with_options<P: AsRef<std::path::Path>>(
2751 path: P,
2752 create: FileCreateProperties,
2753 access: FileAccessProperties,
2754 ) -> Result<Self, Error> {
2755 // Refuse a pair the reopen below would refuse, before anything is
2756 // written: this call promises a file *and* an open handle, and half of
2757 // that is worse than neither.
2758 if let Some(reason) = crate::edit::create_would_refuse_reopen(&create, &access) {
2759 return Err(Error::EditUnsupported(reason));
2760 }
2761 let mut builder = crate::writer::FileBuilder::new();
2762 builder.with_create_properties(create);
2763 let bytes = builder.finish()?;
2764 std::fs::write(path.as_ref(), bytes).map_err(Error::Io)?;
2765 Self::open_rw_with_options(path, access)
2766 }
2767
2768 /// Apply all staged structural edits made through this file's handles —
2769 /// [`Dataset::write`]/`set_attr`/`remove_attr` and
2770 /// [`Group::create_group`]/`delete` — as one transaction. Immediate
2771 /// [`Dataset::append`]s need no commit.
2772 ///
2773 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2774 /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2775 ///
2776 /// Outstanding [`Dataset`] and [`Group`] handles stay usable: a commit
2777 /// relocates object headers, and each handle looks its object up again by
2778 /// path on its first use afterwards, so a long-lived handle answers for the
2779 /// file the commit left rather than for the copy it moved away from. Two
2780 /// exceptions, both of which report rather than answer wrongly. A *read*
2781 /// through a handle onto an object the commit deleted — or replaced with one
2782 /// of a different kind, which is
2783 /// [`Error::NotADataset`](crate::Error::NotADataset) or
2784 /// [`Error::NotAGroup`](crate::Error::NotAGroup) — fails the way opening it
2785 /// would; its write methods still address the file by path, so they stage
2786 /// and the commit refuses them. And a handle reached by object reference
2787 /// ([`Dataset::dereference`]) has no path to look up, so it returns
2788 /// [`Error::StaleHandle`](crate::Error::StaleHandle) — not only after a
2789 /// commit but after anything staged, synced or torn down, since only an
2790 /// immediate [`Dataset::append`] is known to leave every header where it
2791 /// stands. Dereference again from a fresh read.
2792 ///
2793 /// A handle onto an object this commit *publishes* — one
2794 /// [`Group::create_group`], [`Group::create_group_with`] or
2795 /// [`Group::create_dataset`] handed back, or a lookup of a staged name found
2796 /// — starts reading its object here. Until then it answers
2797 /// [`Error::NotCommitted`](crate::Error::NotCommitted) for anything needing
2798 /// bytes, and a refused commit leaves it doing so.
2799 ///
2800 /// The commit is durable when it returns, under the default
2801 /// [`SyncPolicy::Always`]; under
2802 /// [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose) it has reached the
2803 /// operating system and waits for a [`sync`](Self::sync).
2804 ///
2805 /// **A commit refused before it publishes leaves every dataset reading what
2806 /// it read before.** Almost everything such a commit writes lands where
2807 /// nothing reaches it until the commit's linearization point; the one edit
2808 /// that does not is a same-length [`Dataset::write`], which overwrites the
2809 /// dataset's existing block, and the refusal writes those bytes back on its
2810 /// way out. A refusal raised before the first write keeps the staged batch
2811 /// too, so it can be corrected and committed again (issue #316).
2812 ///
2813 /// # Errors
2814 ///
2815 /// Two failures do not carry that promise, and both call for **re-reading**
2816 /// the datasets the batch named rather than for a retry:
2817 ///
2818 /// - [`Error::CommitPartiallyApplied`](crate::Error::CommitPartiallyApplied),
2819 /// where the restore itself failed, so a dataset may hold either value.
2820 /// - An error from a step *after* the commit published — repointing the
2821 /// object references that named a moved object is the one that can raise
2822 /// it. The batch is in the file and stays there; what failed is work the
2823 /// commit owed afterwards. The file is valid either way.
2824 pub fn commit(&self) -> Result<(), Error> {
2825 self.with_mirror_session(Change::Relocating, |session| session.commit())
2826 }
2827
2828 /// Copy the object at `src` to `dst` within this file (the in-file
2829 /// `H5Ocopy`), staged until [`commit`](Self::commit).
2830 ///
2831 /// A dataset whose storage was never allocated is copied as the storage it
2832 /// has — none — rather than as the fill value reading it answers with, so a
2833 /// schema-only dataset stays one. A dataset whose elements live in external
2834 /// files (`H5Pset_external`) carries that same empty storage while holding
2835 /// data this crate does not read, and is refused with
2836 /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) rather than
2837 /// copied without it.
2838 ///
2839 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2840 /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2841 pub fn copy(&self, src: &str, dst: &str) -> Result<(), Error> {
2842 self.with_mirror_session(Change::Relocating, |session| {
2843 session.copy(&normalize_path(src), &normalize_path(dst))
2844 })
2845 }
2846
2847 /// Copy the object at `src` in `source` — a separate, buffered read-only
2848 /// file — into this file at `dst`: the cross-file `H5Ocopy`, staged until
2849 /// [`commit`](Self::commit).
2850 ///
2851 /// `source` must be a buffered file ([`File::open`] or [`File::from_bytes`],
2852 /// not [`File::open_streaming`]) that uses 8-byte offsets and has no
2853 /// userblock; anything else is refused with
2854 /// [`Error::EditUnsupported`](crate::Error::EditUnsupported). The source
2855 /// subtree is read and validated eagerly, so `source` need not outlive this
2856 /// call — and so a source this cannot reproduce, external storage included,
2857 /// is refused by this call. Refusals that concern the *destination* — `dst`
2858 /// already exists, or its parent group does not — still come from `commit`.
2859 /// Requires a read-write destination ([`File::open_rw`]); a read-only
2860 /// one returns [`Error::ReadOnly`](crate::Error::ReadOnly).
2861 pub fn copy_from(&self, source: &File, src: &str, dst: &str) -> Result<(), Error> {
2862 self.with_mirror_session(Change::Relocating, |session| {
2863 session.copy_from(source, src, dst)
2864 })
2865 }
2866
2867 /// Report whether this file has structural edits staged but not yet applied
2868 /// by [`commit`](Self::commit) — [`Dataset::write`]/`set_attr`/`remove_attr`,
2869 /// [`Dataset::append_staged`], [`Group::create_group`]/`create_dataset`/
2870 /// `delete`/`set_attr`/`remove_attr`, and [`copy`](Self::copy)/
2871 /// [`copy_from`](Self::copy_from). Immediate [`Dataset::append`]s are never
2872 /// staged and do not count. Always `false` for a read-only file.
2873 ///
2874 /// A `commit` that refuses puts the staged set back untouched, so this still
2875 /// answers `true` afterwards and the same batch can be committed again — to
2876 /// the same refusal, until the session is dropped.
2877 pub fn has_staged_edits(&self) -> bool {
2878 match &self.inner.backend {
2879 Backend::Edit(m) => {
2880 let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2881 session.has_staged_edits()
2882 }
2883 _ => false,
2884 }
2885 }
2886
2887 /// Report this read-write file's live space usage as a [`SpaceAccounting`] —
2888 /// the current logical size, total reusable free bytes, and reusable free
2889 /// regions. It reflects committed state plus immediate in-place appends, not
2890 /// edits still staged for [`commit`](Self::commit).
2891 ///
2892 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2893 /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2894 pub fn space_accounting(&self) -> Result<SpaceAccounting, Error> {
2895 match &self.inner.backend {
2896 Backend::Edit(m) => {
2897 let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2898 Ok(session.space_accounting())
2899 }
2900 _ => Err(Error::ReadOnly),
2901 }
2902 }
2903
2904 /// Force everything written to this file so far to durable storage — the
2905 /// `fsync` the application issues at its own cadence under
2906 /// [`SyncPolicy::OnClose`], and a redundant one under the default
2907 /// [`SyncPolicy::Always`]. A SWMR-writer file syncs the same way.
2908 ///
2909 /// This is a durability barrier, not a flush: it writes nothing itself.
2910 /// Staged edits are not applied ([`commit`](Self::commit) does that, and a
2911 /// `sync` before one makes only the *previous* state durable), and elements
2912 /// held by a live [`BufferedAppender`](crate::BufferedAppender) have not
2913 /// reached the file at all — flush it first.
2914 ///
2915 /// There is no need to call it before [`close`](Self::close): `close` — and
2916 /// dropping the last handle — issues its own barrier under every policy,
2917 /// because both write and both destroy the handle that would have ordered
2918 /// those writes. This is the mid-session checkpoint, not the closing one.
2919 ///
2920 /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2921 /// [`Error::ReadOnly`](crate::Error::ReadOnly), and a sealed one
2922 /// [`Error::FileClosed`](crate::Error::FileClosed) — a closed file has
2923 /// already been synced.
2924 #[doc(alias = "fsync")]
2925 pub fn sync(&self) -> Result<(), Error> {
2926 // A barrier over writes the operations that made them already accounted
2927 // for, so it invalidates no handle. Classing it as a change would end
2928 // every by-reference handle in the session because the caller asked for
2929 // an `fsync`.
2930 self.with_mirror_session(Change::Nothing, |session| session.force_sync())
2931 }
2932
2933 /// Commit any staged edits and seal this file. The exclusive OS lock is
2934 /// released once the last handle derived from this file is also dropped.
2935 ///
2936 /// After `close`, a write through any surviving [`Dataset`]/[`Group`] handle
2937 /// or [`File`] clone returns [`Error::FileClosed`](crate::Error::FileClosed);
2938 /// reads still work. `close` commits, so the one handle a commit ends ends
2939 /// here too: one reached by [`Dataset::dereference`] reports
2940 /// [`Error::StaleHandle`](crate::Error::StaleHandle) afterwards, where a
2941 /// handle opened by path re-resolves and keeps reading.
2942 pub fn close(self) -> Result<(), Error> {
2943 if matches!(self.inner.backend, Backend::Edit(_)) {
2944 // SWMR mode stages nothing — the staged surface is refused — so there
2945 // is nothing to commit, and it persists no free space, so there is
2946 // nothing to re-home.
2947 let swmr = self.inner.swmr_write;
2948 if !swmr {
2949 self.commit()?;
2950 }
2951 // Free-space managers and status flags, both rewritten where they
2952 // stand. No object header moves, so a handle that could read through
2953 // this file before `close` still can — which is what `close`'s own
2954 // documentation promises.
2955 self.with_mirror_session(Change::InPlace, |session| {
2956 // Immediate appends grow the file past any persisted free-space
2957 // managers without running a commit tail, so re-home them here. A
2958 // no-op unless this session left them stale.
2959 if !swmr {
2960 session.finalize_persist()?;
2961 }
2962 // Forced under every policy, and covering everything above: this
2963 // call consumes the handle, so it is the last point at which any
2964 // of these writes can be ordered at all.
2965 session.force_sync()?;
2966 // Last, and only after that sync. A session's status flags stand
2967 // for writes that may still have been in memory — a SWMR writer's
2968 // pair, a page buffer's crash mark — and this is the point at
2969 // which none are.
2970 session.release_status_flags()
2971 })?;
2972 self.inner.closed.store(true, Ordering::Release);
2973 }
2974 Ok(())
2975 }
2976
2977 /// Run `f` with the locked write session of a read-write file. `staged`
2978 /// distinguishes an edit applied by [`commit`](Self::commit) from an immediate
2979 /// one. Returns [`Error::ReadOnly`](crate::Error::ReadOnly) for a read-only
2980 /// file, [`Error::FileClosed`](crate::Error::FileClosed) once the file is
2981 /// sealed by [`close`](Self::close), and
2982 /// [`Error::SwmrStagedUnsupported`](crate::Error::SwmrStagedUnsupported) for a
2983 /// staged edit on a SWMR-writer file.
2984 fn with_mirror_session<R>(
2985 &self,
2986 change: Change,
2987 f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
2988 ) -> Result<R, Error> {
2989 self.inner.with_engine_mut(change, f)
2990 }
2991
2992 /// Returns an owned handle to the root group.
2993 pub fn root(&self) -> Group {
2994 let revisions = self.inner.revisions();
2995 Group::new(
2996 self.inner.clone(),
2997 // A relocating commit on a read-write file can move the root, so
2998 // resolve it from the live mirror rather than the cached superblock.
2999 revisions.at(self.inner.mirror_root_address()),
3000 Some(String::new()),
3001 )
3002 }
3003
3004 /// Resolve a path and return an owned [`Dataset`] handle.
3005 ///
3006 /// The dataset uses the file-wide chunk-cache default (configured with
3007 /// [`FileAccessProperties::with_chunk_cache`]). To override the cache for this
3008 /// one dataset, use [`dataset_with_options`](Self::dataset_with_options).
3009 ///
3010 /// Returns [`Error::NotADataset`] if the path names something that is not a
3011 /// dataset, and [`Error::NotAGroup`] if a component *along* the path is not
3012 /// a group: resolving `a/b/c` opens `a` and then `a/b` to look inside them,
3013 /// so a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3014 pub fn dataset(&self, path: &str) -> Result<Dataset, Error> {
3015 self.dataset_with_options(path, DatasetAccessProperties::new())
3016 }
3017
3018 /// Resolve a path and return an owned [`Dataset`] handle, applying per-dataset
3019 /// [`DatasetAccessProperties`] that override file-wide access defaults.
3020 ///
3021 /// This is the dataset-open-with-access-property-list path (HDF5's `dapl`):
3022 /// the properties' chunk cache corresponds to `H5Pset_chunk_cache` and takes
3023 /// precedence, for this dataset only, over the `H5Pset_cache`-style
3024 /// file-wide default.
3025 pub fn dataset_with_options(
3026 &self,
3027 path: &str,
3028 properties: DatasetAccessProperties,
3029 ) -> Result<Dataset, Error> {
3030 let chunk_cache = properties.resolved_chunk_cache(self.inner.access_properties.chunk_cache);
3031 let normalized = normalize_path(path);
3032 match self.inner.staged_object(&normalized).map(|o| o.kind) {
3033 Some(StagedKind::Dataset) => {
3034 return Ok(Dataset::pending(
3035 self.inner.clone(),
3036 chunk_cache,
3037 normalized,
3038 ));
3039 }
3040 Some(StagedKind::Group) => return Err(Error::NotADataset(normalized)),
3041 None => {}
3042 }
3043 let revisions = self.inner.revisions();
3044 let addr = self.inner.resolve_path(path)?;
3045 let hdr = self.inner.parse_header(addr)?;
3046 if !has_message(&hdr, MessageType::DataLayout) {
3047 return Err(Error::NotADataset(path.to_string()));
3048 }
3049 Ok(Dataset::new(
3050 self.inner.clone(),
3051 revisions.at(addr),
3052 hdr,
3053 chunk_cache,
3054 Some(normalized),
3055 ))
3056 }
3057
3058 /// Resolve a path and return an owned [`Group`] handle.
3059 ///
3060 /// Returns [`Error::NotAGroup`] if the path names an object that is not a
3061 /// group, the way [`dataset`](Self::dataset) returns
3062 /// [`Error::NotADataset`] for the mirror case, and
3063 /// [`FormatError::PathNotFound`] if it names nothing.
3064 ///
3065 /// The same error reports a component *along* the path that is not a group,
3066 /// naming that component's own path rather than the one asked for: `a/b/c`
3067 /// stopped by a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3068 pub fn group(&self, path: &str) -> Result<Group, Error> {
3069 let normalized = normalize_path(path);
3070 match self.inner.staged_object(&normalized).map(|o| o.kind) {
3071 Some(StagedKind::Group) => {
3072 return Ok(Group::pending(self.inner.clone(), normalized));
3073 }
3074 Some(StagedKind::Dataset) => return Err(Error::NotAGroup(normalized)),
3075 None => {}
3076 }
3077 let revisions = self.inner.revisions();
3078 let addr = self.inner.resolve_path(path)?;
3079 if !is_group(&self.inner.parse_header(addr)?) {
3080 // Normalized, so that the same object refused here and refused by a
3081 // live handle below names itself the same way: a handle knows only
3082 // the normalized path it memoized.
3083 return Err(Error::NotAGroup(normalized));
3084 }
3085 Ok(Group::new(
3086 self.inner.clone(),
3087 revisions.at(addr),
3088 Some(normalized),
3089 ))
3090 }
3091
3092 /// Re-read the file from disk to pick up data appended by a concurrent
3093 /// writer, then re-parse the superblock.
3094 ///
3095 /// This is the SWMR reader's refresh primitive. Returns
3096 /// [`Error::SwmrUnsupported`] if the file was not opened with
3097 /// [`File::open_swmr`], and [`Error::HandlesOutstanding`] if any owned
3098 /// [`Dataset`]/[`Group`] handle (or a clone of this `File`) is still alive —
3099 /// drop them before refreshing, then re-fetch them afterward, since they
3100 /// observe the new bytes only when re-derived from the refreshed file.
3101 pub fn refresh(&mut self) -> Result<(), Error> {
3102 let inner = Arc::get_mut(&mut self.inner).ok_or(Error::HandlesOutstanding)?;
3103 inner.refresh()
3104 }
3105
3106 // --- delegating value getters (forward to the shared inner state) ---
3107
3108 /// Returns the raw file bytes for an in-memory file, or an empty slice for a
3109 /// streaming file (which has no whole-file buffer).
3110 pub fn as_bytes(&self) -> &[u8] {
3111 self.inner.as_bytes()
3112 }
3113
3114 /// Return the access properties used when opening this file.
3115 pub fn access_properties(&self) -> FileAccessProperties {
3116 self.inner.access_properties()
3117 }
3118
3119 /// Which backend this file's read-write session resolved to:
3120 /// [`EditBacking::Bounded`] when it reads through a handle, or
3121 /// [`EditBacking::Mirrored`] when it holds a whole-file image.
3122 ///
3123 /// This is how a caller who opened with [`MemoryStrategy::Auto`] finds out
3124 /// whether the fallback was taken, and so whether memory scales with the
3125 /// file. A file with no editing session — a read-only open, a streaming open
3126 /// — reports `None`.
3127 ///
3128 /// The answer is an [`EditBacking`] rather than the [`MemoryStrategy`] that
3129 /// was asked for, because `Auto` is a preference between the two backends and
3130 /// not an outcome either can report; `.into()` converts back when a later
3131 /// reopen should be pinned to what this one got.
3132 pub fn edit_backing(&self) -> Option<EditBacking> {
3133 self.inner.edit_backing()
3134 }
3135
3136 /// What this file's metadata cache has done, and what it is holding.
3137 ///
3138 /// [`FileAccessProperties::with_metadata_cache`] sets a byte budget before
3139 /// any read has happened; this is how a caller finds out whether it was the
3140 /// right one. See [`MetadataCacheStats`] for which figure answers which
3141 /// question. Together the two are the `hdf5-pure` counterpart of HDF5's
3142 /// `H5Fget_mdc_hit_rate` and `H5Fget_mdc_size`.
3143 ///
3144 /// `None` where there is no metadata cache to report on: a buffered
3145 /// [`open`](Self::open) or [`from_bytes`](Self::from_bytes), which already
3146 /// holds the whole file; a mirrored read-write session, for the same reason;
3147 /// or a streaming or bounded open left at the default disabled budget.
3148 ///
3149 /// ```no_run
3150 /// # fn main() -> Result<(), hdf5_pure::Error> {
3151 /// use hdf5_pure::{File, FileAccessProperties, MetadataCacheConfig};
3152 ///
3153 /// let properties =
3154 /// FileAccessProperties::new().with_metadata_cache(MetadataCacheConfig::new(8 << 20));
3155 /// let file = File::open_streaming_with_options("data.h5", properties)?;
3156 /// for name in file.root().datasets()? {
3157 /// let _ = file.dataset(&name)?.read_raw()?;
3158 /// }
3159 ///
3160 /// let stats = file.metadata_cache_stats().expect("the budget enabled a cache");
3161 /// println!("{:?} over {} reads, {} evicted", stats.hit_rate(), stats.reads(), stats.evictions());
3162 /// # Ok(())
3163 /// # }
3164 /// ```
3165 pub fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
3166 self.inner.metadata_cache_stats()
3167 }
3168
3169 /// Zero this file's metadata-cache counters, keeping every cached entry.
3170 ///
3171 /// HDF5's `H5Freset_mdc_hit_rate_stats`, for measuring one phase of a
3172 /// program rather than a whole run: the reads that populate a cache miss by
3173 /// definition, so a hit rate taken over the run charges the steady state for
3174 /// the warm-up. Reset after warming to measure the part that repeats.
3175 ///
3176 /// It evicts nothing: occupancy, which
3177 /// [`metadata_cache_stats`](Self::metadata_cache_stats) also reports, is a
3178 /// measurement of the cache rather than a tally of its history. A file with
3179 /// no metadata cache ignores the call.
3180 pub fn reset_metadata_cache_stats(&self) {
3181 self.inner.reset_metadata_cache_stats();
3182 }
3183
3184 /// Returns a reference to the parsed superblock.
3185 pub fn superblock(&self) -> &Superblock {
3186 self.inner.superblock()
3187 }
3188
3189 /// The file-space management strategy this file records in its superblock
3190 /// extension, or `None` if it records none.
3191 pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
3192 self.inner.file_space_strategy()
3193 }
3194
3195 /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
3196 /// if present and readable.
3197 pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
3198 self.inner.file_space_info()
3199 }
3200
3201 /// The free regions a file persists on disk in its free-space managers, as
3202 /// `(address, length)` pairs sorted by address.
3203 pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
3204 self.inner.persisted_free_space()
3205 }
3206
3207 /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
3208 pub fn file_size(&self) -> u64 {
3209 self.inner.file_size()
3210 }
3211
3212 /// The minimum library version required to read this file, derived from its
3213 /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
3214 pub fn libver_bound(&self) -> LibVer {
3215 self.inner.libver_bound()
3216 }
3217
3218 /// A `Source` view over the backend, for the streaming-capable paths.
3219 pub(crate) fn source(&self) -> SourceView<'_> {
3220 self.inner.source()
3221 }
3222
3223 /// The whole-file byte image when this file is buffered in memory; `None`
3224 /// for a streaming file. Used by cross-file object copy.
3225 pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
3226 self.inner.in_memory_image()
3227 }
3228
3229 /// The base address (superblock base address) added to every stored relative
3230 /// address. Zero for a file with no userblock.
3231 pub(crate) fn base_address(&self) -> BaseAddress {
3232 self.inner.base_address()
3233 }
3234}
3235
3236// ---------------------------------------------------------------------------
3237// Object reference target
3238// ---------------------------------------------------------------------------
3239
3240/// The resolved target of an HDF5 object reference (`H5R_OBJECT`): either a
3241/// group or a dataset.
3242///
3243/// Produced by [`Dataset::dereference`]. MATLAB `.mat` files use object
3244/// references pervasively — a cell array stores one reference per element, and
3245/// the `#subsystem#` machinery references its payloads — so resolving a
3246/// reference to the group or dataset it names is the foundation for reading
3247/// those structures.
3248///
3249/// The [`Dataset`](Object::Dataset) handle is boxed: it carries a parsed object
3250/// header and is much larger than a [`Group`](Object::Group) handle, so boxing
3251/// keeps `Object` (and a `Vec<Object>`) compact without a size disparity. The
3252/// `Box` derefs transparently, so `&obj_dataset` is usable wherever a
3253/// `&Dataset` is expected.
3254///
3255/// Non-exhaustive: a reference can name an object kind this crate does not yet
3256/// resolve — a committed (named) datatype is refused with
3257/// [`FormatError::InvalidObjectReference`](crate::FormatError::InvalidObjectReference)
3258/// today — so match with a `_` arm.
3259#[non_exhaustive]
3260pub enum Object {
3261 /// The reference points at a group's object header.
3262 Group(Group),
3263 /// The reference points at a dataset's object header.
3264 Dataset(Box<Dataset>),
3265}
3266
3267impl std::fmt::Debug for Object {
3268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3269 match self {
3270 Object::Group(_) => f.write_str("Object::Group"),
3271 Object::Dataset(_) => f.write_str("Object::Dataset"),
3272 }
3273 }
3274}
3275
3276// ---------------------------------------------------------------------------
3277// Group handle
3278// ---------------------------------------------------------------------------
3279
3280/// A group that exists only as a staged edit, handed to
3281/// [`Group::create_group_with`]'s closure so a whole subtree — attributes,
3282/// nested groups, datasets — can be described in one call.
3283///
3284/// It is a convenience, not the only way in: [`Group::create_group`] and
3285/// [`Group::create_group_with`] both return a live [`Group`] handle onto the
3286/// staged group, and everything this offers can be staged through that handle
3287/// instead. Reach for the closure when the shape of the subtree is known at the
3288/// call, and for the handle when it is built up by code that takes a `&Group`.
3289///
3290/// Every method stages; nothing is written until [`File::commit`].
3291///
3292/// The closure holding this records into a buffer rather than into the file's
3293/// writable session, so nothing is locked while it runs. The recorded operations
3294/// are applied together when it returns, which is why an object staged here is
3295/// addressable only once the closure has returned.
3296pub struct StagedGroup<'a> {
3297 ops: &'a mut Vec<StagedOp>,
3298 path: String,
3299}
3300
3301impl StagedGroup<'_> {
3302 /// Stage an attribute on this group, applied with its creation on
3303 /// [`File::commit`].
3304 pub fn set_attr(&mut self, name: &str, value: AttrValue) -> &mut Self {
3305 self.ops.push(StagedOp::SetGroupAttr {
3306 path: self.path.clone(),
3307 name: name.to_string(),
3308 value,
3309 });
3310 self
3311 }
3312
3313 /// Stage an empty subgroup of this group.
3314 ///
3315 /// To configure it in the same commit, use
3316 /// [`create_group_with`](Self::create_group_with). To get a [`Group`] handle
3317 /// onto it, look it up by name once this closure has returned, or stage it
3318 /// through [`Group::create_group`] instead, which hands one back.
3319 pub fn create_group(&mut self, name: &str) -> &mut Self {
3320 self.create_group_with(name, |_| {})
3321 }
3322
3323 /// Stage a subgroup of this group, configured through `build`.
3324 pub fn create_group_with(
3325 &mut self,
3326 name: &str,
3327 build: impl FnOnce(&mut StagedGroup<'_>),
3328 ) -> &mut Self {
3329 let child = format!("{}/{}", self.path, name);
3330 self.ops.push(StagedOp::CreateGroup(child.clone()));
3331 let mut staged = StagedGroup {
3332 ops: &mut *self.ops,
3333 path: child,
3334 };
3335 build(&mut staged);
3336 self
3337 }
3338
3339 /// Stage a dataset in this group, configured through `build`.
3340 pub fn create_dataset(
3341 &mut self,
3342 name: &str,
3343 build: impl FnOnce(&mut DatasetBuilder),
3344 ) -> &mut Self {
3345 let mut builder = DatasetBuilder::new(name);
3346 build(&mut builder);
3347 self.ops.push(StagedOp::CreateDataset {
3348 path: format!("{}/{}", self.path, name),
3349 builder: Box::new(builder),
3350 });
3351 self
3352 }
3353}
3354
3355/// One edit recorded by a [`StagedGroup`] closure, replayed onto the writable
3356/// session after the closure returns.
3357///
3358/// The indirection is what keeps user code off the session lock: the closure
3359/// touches only this buffer, so calling back into the same [`File`] from inside
3360/// it is at worst wrongly ordered rather than a deadlock (issue #200).
3361enum StagedOp {
3362 CreateGroup(String),
3363 SetGroupAttr {
3364 path: String,
3365 name: String,
3366 value: AttrValue,
3367 },
3368 CreateDataset {
3369 path: String,
3370 /// Boxed because a `DatasetBuilder` dwarfs the other variants, and a
3371 /// closure staging many groups would otherwise pay its size per entry.
3372 builder: Box<DatasetBuilder>,
3373 },
3374}
3375
3376impl StagedOp {
3377 /// Record this edit on the session. Applied in the order the closure made
3378 /// the calls, so a group is always staged before its own attributes and
3379 /// children.
3380 fn apply(self, session: &mut WriteEngine) -> Result<(), Error> {
3381 match self {
3382 StagedOp::CreateGroup(path) => session.create_group(&path),
3383 StagedOp::SetGroupAttr { path, name, value } => {
3384 session.set_group_attr(&path, &name, value)
3385 }
3386 StagedOp::CreateDataset { path, builder } => {
3387 session.stage_created_dataset(&path, *builder)
3388 }
3389 }
3390 }
3391}
3392
3393/// An owned handle to an HDF5 group.
3394///
3395/// The handle names the group by its root-relative path and remembers where that
3396/// path resolved to. A [`File::commit`] rewrites and relocates object headers,
3397/// so the memo is worked out again on the first use after any edit and the
3398/// handle goes on answering for the same group — see [`File::commit`] for the
3399/// two cases that report instead. Cloning gives a second handle to the same
3400/// group.
3401pub struct Group {
3402 file: Arc<FileInner>,
3403 /// Where this group's object header sits, as of the file revision it was
3404 /// resolved at. Re-resolved on first use after an edit could have moved it;
3405 /// see [`Group::header_address`]. A group carries no parsed header of its
3406 /// own — it re-reads one per call — so the address is the whole memo, and the
3407 /// content revision the [`Resolution`] carries beside it names no header this
3408 /// handle read and is never read back.
3409 ///
3410 /// `None` for a group this session has *staged* and not yet committed: there
3411 /// is no header to name one. Such a handle resolves its path on every use,
3412 /// and installs a memo the first time a commit gives it something to point
3413 /// at.
3414 state: RwLock<Option<Resolution>>,
3415 /// Root-relative path of this group (e.g. `""` for the root, `"a/b"`), used
3416 /// to address the group and its children for write operations on a
3417 /// read-write file, and to find it again after an edit moved it. `None` for
3418 /// a group reached by object reference ([`Dataset::dereference`]), which has
3419 /// no resolvable path.
3420 path: Option<String>,
3421 /// The session's staged generation when this handle was made onto a staged
3422 /// creation, and `None` for a handle opened onto a group in the file.
3423 ///
3424 /// It is what keeps such a handle from being retargeted at a different
3425 /// object: see [`Standing`] and [`FileInner::staged_standing`].
3426 staged_birth: Option<u64>,
3427}
3428
3429impl Clone for Group {
3430 /// Clones share nothing but the open file: the clone is a second handle to
3431 /// the same group, resolved as of the same revision.
3432 fn clone(&self) -> Self {
3433 Self {
3434 file: Arc::clone(&self.file),
3435 state: RwLock::new(*self.state.read().unwrap_or_else(PoisonError::into_inner)),
3436 path: self.path.clone(),
3437 staged_birth: self.staged_birth,
3438 }
3439 }
3440}
3441
3442impl std::fmt::Debug for Group {
3443 /// Reports the handle as it stands, without re-resolving: a `Debug` that
3444 /// read the file could fail, and one that failed would have nothing to
3445 /// print. `staged` is true for a group this session has created and not yet
3446 /// committed, which has no address to report.
3447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3448 let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
3449 f.debug_struct("Group")
3450 .field("path", &self.path)
3451 .field("staged", &state.is_none())
3452 .finish()
3453 }
3454}
3455
3456impl Group {
3457 /// Build a handle for a group whose header was found at `at`.
3458 fn new(file: Arc<FileInner>, at: Resolution, path: Option<String>) -> Self {
3459 Self {
3460 file,
3461 state: RwLock::new(Some(at)),
3462 path,
3463 staged_birth: None,
3464 }
3465 }
3466
3467 /// Build a handle for a group this session has staged and not yet
3468 /// committed, which has no header to memoize.
3469 ///
3470 /// It addresses the group by name from the moment it is staged: further
3471 /// creations, deletions and attribute edits under it are staged through it
3472 /// like any other handle. Anything that has to *read* the group reports
3473 /// [`Error::NotCommitted`](crate::Error::NotCommitted) until
3474 /// [`File::commit`], after which the first use resolves the path and the
3475 /// handle behaves as if it had been opened by name.
3476 ///
3477 /// The session's staged generation is taken here so that the handle can tell
3478 /// that commit from a *withdrawal* of the same staging, which leaves the
3479 /// path meaning whatever the file holds — see [`Standing`].
3480 fn pending(file: Arc<FileInner>, path: String) -> Self {
3481 Self {
3482 staged_birth: file.staged_generation(),
3483 file,
3484 state: RwLock::new(None),
3485 path: Some(path),
3486 }
3487 }
3488
3489 /// This group's object-header address (base-adjusted, file-absolute), worked
3490 /// out again from its path if an edit could have moved it since the memo was
3491 /// taken. Also what resolves an object reference that points at this group.
3492 ///
3493 /// Returns [`Error::StaleHandle`](crate::Error::StaleHandle) for a handle
3494 /// that has no path to re-resolve — one an object reference produced — once
3495 /// a commit has run under it, and the resolution's own error (a
3496 /// `PathNotFound`, say, for a group a commit deleted) when the path no
3497 /// longer names anything. A group this session has staged and not committed
3498 /// has no header at all, and that is
3499 /// [`Error::NotCommitted`](crate::Error::NotCommitted). A commit that
3500 /// replaces this group with a dataset of the same name (issue #305) leaves
3501 /// the path naming something that is not a group, and that is
3502 /// [`Error::NotAGroup`](crate::Error::NotAGroup).
3503 pub(crate) fn header_address(&self) -> Result<u64, Error> {
3504 let memo = *self.state.read().unwrap_or_else(PoisonError::into_inner);
3505 if let Some(memo) = memo {
3506 if memo.address_revision == self.file.address_revision() {
3507 return Ok(memo.address);
3508 }
3509 }
3510 let at = self
3511 .file
3512 .locate_staged(self.path.as_deref(), memo, self.staged_birth)?;
3513 // Checked before it is memoized. The short-circuit above does not
3514 // re-check, so an address installed and then refused would be the
3515 // answer every later call returns without looking at it again.
3516 if !is_group(&self.file.parse_header(at.address)?) {
3517 // A path-less handle never reaches here: it failed the short-circuit
3518 // above, and `locate` answers `StaleHandle` for one whose address
3519 // memo it cannot reuse. The default stands for the root's own empty
3520 // path, which is the only empty one a handle holds.
3521 return Err(Error::NotAGroup(self.path.clone().unwrap_or_default()));
3522 }
3523 let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
3524 // Two threads can re-resolve at once. The older answer must not land on
3525 // top of the newer one, or the newer handle would go on serving an
3526 // address the file has already moved past. A handle that had no memo
3527 // takes this one: any address beats naming nothing.
3528 if state.is_none_or(|memo| at.address_revision >= memo.address_revision) {
3529 *state = Some(at);
3530 }
3531 Ok(at.address)
3532 }
3533
3534 /// List the names of datasets in this group.
3535 ///
3536 /// To read from the datasets themselves, prefer
3537 /// [`iter_datasets`](Self::iter_datasets): it hands back opened handles for
3538 /// the cost of this call, where opening each name separately re-walks the
3539 /// group once per member.
3540 ///
3541 /// A dataset this session has staged and not yet committed is listed too,
3542 /// once — a name that is both on disk and staged is the replacement of issue
3543 /// #305, and the staged object is what that name already resolves to.
3544 pub fn datasets(&self) -> Result<Vec<String>, Error> {
3545 let (entries, staged) = self.children_and_staged()?;
3546 let members = staged_members(&staged, StagedKind::Dataset, &entries);
3547 let mut names = Vec::new();
3548 for entry in &entries {
3549 if superseded(&staged, &entry.name) {
3550 continue;
3551 }
3552 let hdr = self.file.parse_header(entry.object_header_address)?;
3553 if has_message(&hdr, MessageType::DataLayout) {
3554 names.push(entry.name.clone());
3555 }
3556 }
3557 names.extend(members);
3558 Ok(names)
3559 }
3560
3561 /// Open every dataset in this group, each paired with its name.
3562 ///
3563 /// This is the walk to reach for when the members themselves are what you
3564 /// want — their attributes, shapes or data — rather than a list of names.
3565 /// [`datasets`](Self::datasets) already parses every child's object header to
3566 /// tell a dataset from a group, and then keeps only the name, so following it
3567 /// with a [`dataset`](Self::dataset) call per entry re-walks the group's link
3568 /// structure and re-parses that same header. This keeps what it read, and
3569 /// costs one enumeration of the group rather than one per member.
3570 ///
3571 /// **This walk is for taking every member, or nearly every one.** Telling a
3572 /// dataset from a group means parsing its header, so the whole group is
3573 /// enumerated and every child's header parsed before the iterator is
3574 /// returned — breaking out early saves nothing, and reaching one known member
3575 /// this way costs far more than [`dataset`](Self::dataset) does. Only the
3576 /// handle construction is deferred to each step, and that is not where the
3577 /// cost is.
3578 ///
3579 /// The headers of the members are held for the length of the walk, since each
3580 /// one is what its handle is built from. That is bounded by the group being
3581 /// walked rather than by the file, but it is proportional to the group: a
3582 /// header carries a compact dataset's data and its compact attributes inline,
3583 /// so a large group of such datasets is a large allocation.
3584 ///
3585 /// Each dataset gets the file-wide chunk-cache default; to override the cache
3586 /// for one, open it by name with
3587 /// [`dataset_with_options`](Self::dataset_with_options).
3588 ///
3589 /// Members arrive in the order the group's link structure yields them — the
3590 /// same order [`datasets`](Self::datasets) reports, which is not necessarily
3591 /// sorted, with this session's staged datasets after them. Each handle is a
3592 /// snapshot taken when the iterator was built, so a [`File::commit`] that
3593 /// runs mid-walk is not reflected in the members still to come; re-open the
3594 /// group to see past it.
3595 ///
3596 /// ```no_run
3597 /// # use hdf5_pure::File;
3598 /// # fn main() -> Result<(), hdf5_pure::Error> {
3599 /// let file = File::open("runs.h5")?;
3600 /// for (name, dataset) in file.root().iter_datasets()? {
3601 /// println!("{name}: {:?}", dataset.shape());
3602 /// }
3603 /// # Ok(())
3604 /// # }
3605 /// ```
3606 pub fn iter_datasets(
3607 &self,
3608 ) -> Result<impl ExactSizeIterator<Item = (String, Dataset)> + use<>, Error> {
3609 let revisions = self.file.revisions();
3610 let (entries, staged) = self.children_and_staged()?;
3611 // Taken before the entries are consumed below, since which staged
3612 // members survive depends on the names the file already holds.
3613 let staged_members = staged_members(&staged, StagedKind::Dataset, &entries);
3614 // A member is either an on-disk header this walk read, or a staged
3615 // creation with no header to read yet.
3616 let mut members: Vec<(String, Option<(u64, ObjectHeader)>)> = Vec::new();
3617 for entry in entries {
3618 if superseded(&staged, &entry.name) {
3619 continue;
3620 }
3621 let hdr = self.file.parse_header(entry.object_header_address)?;
3622 if has_message(&hdr, MessageType::DataLayout) {
3623 members.push((entry.name, Some((entry.object_header_address, hdr))));
3624 }
3625 }
3626 members.extend(staged_members.into_iter().map(|name| (name, None)));
3627 let file = Arc::clone(&self.file);
3628 let parent = self.path.clone();
3629 let chunk_cache = DatasetAccessProperties::new()
3630 .resolved_chunk_cache(self.file.access_properties.chunk_cache);
3631 Ok(members.into_iter().map(move |(name, on_disk)| {
3632 let path = child_path_of(parent.as_deref(), &name);
3633 let dataset = match on_disk {
3634 Some((address, header)) => Dataset::new(
3635 Arc::clone(&file),
3636 revisions.at(address),
3637 header,
3638 chunk_cache,
3639 path,
3640 ),
3641 // Only a group with a path of its own reports staged children,
3642 // so a staged member always has one to be named by.
3643 None => Dataset::pending(
3644 Arc::clone(&file),
3645 chunk_cache,
3646 path.expect("a staged member's parent has a path"),
3647 ),
3648 };
3649 (name, dataset)
3650 }))
3651 }
3652
3653 /// The names of children that are committed (`H5Tcommit`) datatype objects:
3654 /// an object header carrying a datatype and neither data nor links.
3655 ///
3656 /// Such an object is the third kind HDF5 links into a group, and it appears
3657 /// in neither [`datasets`](Self::datasets) nor [`groups`](Self::groups) — so a
3658 /// walk that asks only for those two passes over one without noticing. Read
3659 /// the type itself with [`named_datatype`](Self::named_datatype).
3660 pub fn named_datatypes(&self) -> Result<Vec<String>, Error> {
3661 let entries = self.children()?;
3662 let mut names = Vec::new();
3663 for entry in &entries {
3664 let hdr = self.file.parse_header(entry.object_header_address)?;
3665 if is_named_datatype(&hdr) {
3666 names.push(entry.name.clone());
3667 }
3668 }
3669 Ok(names)
3670 }
3671
3672 /// The datatype a committed (`H5Tcommit`) child object holds.
3673 ///
3674 /// `name` must be one [`named_datatypes`](Self::named_datatypes) returned: a
3675 /// name that reaches nothing fails with [`FormatError::PathNotFound`], and
3676 /// one that reaches an object of another kind fails with
3677 /// [`Error::NotANamedDatatype`], the way `H5Topen` does.
3678 pub fn named_datatype(&self, name: &str) -> Result<Datatype, Error> {
3679 Ok(self.named_datatype_at(name)?.0)
3680 }
3681
3682 /// How many things reference the committed (`H5Tcommit`) datatype `name`:
3683 /// its hard links, plus every dataset and attribute that names it.
3684 ///
3685 /// This is HDF5's own object reference count (`H5Oget_info`'s `rc`), and what
3686 /// says whether unlinking the name would destroy the type or merely stop it
3687 /// being reachable through the link. A version 1 object header keeps the count
3688 /// in its prefix, a version 2 header in an Object Reference Count message,
3689 /// and a version 2 header without that message has exactly one reference,
3690 /// which is what the format means by omitting it.
3691 ///
3692 /// A name reaching anything but a committed datatype is
3693 /// [`Error::NotANamedDatatype`], as for
3694 /// [`named_datatype`](Self::named_datatype).
3695 pub fn named_datatype_references(&self, name: &str) -> Result<u32, Error> {
3696 let (_, hdr) = self.named_datatype_header(name)?;
3697 if let Some(count) = hdr.reference_count {
3698 return Ok(count);
3699 }
3700 let Ok(msg) = find_message(&hdr, MessageType::ObjectReferenceCount) else {
3701 return Ok(1);
3702 };
3703 // version(1) + count(4).
3704 let body = self.file.message_body(msg)?;
3705 if body.len() < 5 {
3706 return Err(Error::Format(FormatError::UnexpectedEof {
3707 expected: 5,
3708 available: body.len(),
3709 }));
3710 }
3711 Ok(u32::from_le_bytes([body[1], body[2], body[3], body[4]]))
3712 }
3713
3714 /// The object header of a child that is a committed datatype, and its
3715 /// address.
3716 ///
3717 /// The one place the by-name datatype lookups classify what they reached, so
3718 /// that a child this refuses cannot be one
3719 /// [`named_datatypes`](Self::named_datatypes) would list. Reached the way
3720 /// [`group`](Self::group) and [`dataset`](Self::dataset) reach theirs, which
3721 /// looks the one name up rather than enumerating the group to find it.
3722 fn named_datatype_header(&self, name: &str) -> Result<(u64, ObjectHeader), Error> {
3723 let address = self
3724 .child_address(name)?
3725 .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
3726 let hdr = self.file.parse_header(address)?;
3727 if !is_named_datatype(&hdr) {
3728 return Err(Error::NotANamedDatatype(name.to_string()));
3729 }
3730 Ok((address, hdr))
3731 }
3732
3733 /// The datatype a committed child object holds, and the address of the object
3734 /// header holding it.
3735 ///
3736 /// The address is the identity every user of the type shares: two datasets
3737 /// naming the same address name one type, and reproducing that requires
3738 /// matching them up by address rather than by what the type decodes to.
3739 pub(crate) fn named_datatype_at(&self, name: &str) -> Result<(Datatype, u64), Error> {
3740 let (address, hdr) = self.named_datatype_header(name)?;
3741 let msg = find_message(&hdr, MessageType::Datatype)?;
3742 let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
3743 Ok((dt, address))
3744 }
3745
3746 /// List the names of subgroups in this group.
3747 ///
3748 /// To descend into the subgroups themselves, prefer
3749 /// [`iter_groups`](Self::iter_groups), which hands back opened handles for
3750 /// the cost of this call.
3751 ///
3752 /// A group this session has staged and not yet committed is listed too, on
3753 /// the terms [`datasets`](Self::datasets) sets out.
3754 pub fn groups(&self) -> Result<Vec<String>, Error> {
3755 let (entries, staged) = self.children_and_staged()?;
3756 let members = staged_members(&staged, StagedKind::Group, &entries);
3757 let mut names = Vec::new();
3758 for entry in &entries {
3759 if superseded(&staged, &entry.name) {
3760 continue;
3761 }
3762 let hdr = self.file.parse_header(entry.object_header_address)?;
3763 if is_group(&hdr) {
3764 names.push(entry.name.clone());
3765 }
3766 }
3767 names.extend(members);
3768 Ok(names)
3769 }
3770
3771 /// Open every subgroup of this group, each paired with its name.
3772 ///
3773 /// The counterpart to [`iter_datasets`](Self::iter_datasets), and the way to
3774 /// recurse without paying a [`group`](Self::group) lookup per child: that
3775 /// lookup re-walks this group's link structure, which a walk of the whole
3776 /// tree would otherwise repeat once per subgroup.
3777 ///
3778 /// As with [`iter_datasets`](Self::iter_datasets), the whole group is
3779 /// enumerated and classified before the iterator is returned, so this is the
3780 /// walk for taking every subgroup rather than for reaching one — breaking out
3781 /// early saves nothing. A [`Group`] handle carries no parsed header, so
3782 /// unlike `iter_datasets` this holds none of them.
3783 ///
3784 /// Members arrive in the order the group's link structure yields them — the
3785 /// same order [`groups`](Self::groups) reports, which is not necessarily
3786 /// sorted, with this session's staged groups after them.
3787 ///
3788 /// ```no_run
3789 /// # use hdf5_pure::{Error, Group};
3790 /// fn total_datasets(group: &Group) -> Result<usize, Error> {
3791 /// let mut n = group.datasets()?.len();
3792 /// for (_, child) in group.iter_groups()? {
3793 /// n += total_datasets(&child)?;
3794 /// }
3795 /// Ok(n)
3796 /// }
3797 /// ```
3798 pub fn iter_groups(
3799 &self,
3800 ) -> Result<impl ExactSizeIterator<Item = (String, Group)> + use<>, Error> {
3801 let revisions = self.file.revisions();
3802 let (entries, staged) = self.children_and_staged()?;
3803 // Taken before the entries are consumed, as in `iter_datasets`.
3804 let staged_members = staged_members(&staged, StagedKind::Group, &entries);
3805 // `None` for a staged member, which has no address to resolve until the
3806 // commit places its header.
3807 let mut members: Vec<(String, Option<u64>)> = Vec::new();
3808 for entry in entries {
3809 if superseded(&staged, &entry.name) {
3810 continue;
3811 }
3812 // A `Group` handle carries no parsed header, so the header that
3813 // classified this child is dropped here rather than held for the
3814 // length of the walk.
3815 if is_group(&self.file.parse_header(entry.object_header_address)?) {
3816 members.push((entry.name, Some(entry.object_header_address)));
3817 }
3818 }
3819 members.extend(staged_members.into_iter().map(|name| (name, None)));
3820 let file = Arc::clone(&self.file);
3821 let parent = self.path.clone();
3822 Ok(members.into_iter().map(move |(name, address)| {
3823 let path = child_path_of(parent.as_deref(), &name);
3824 let group = match address {
3825 Some(address) => Group::new(Arc::clone(&file), revisions.at(address), path),
3826 // As in `iter_datasets`: staged members exist only under a
3827 // group that has a path.
3828 None => Group::pending(
3829 Arc::clone(&file),
3830 path.expect("a staged member's parent has a path"),
3831 ),
3832 };
3833 (name, group)
3834 }))
3835 }
3836
3837 /// Read all attributes of this group.
3838 ///
3839 /// Each value takes the [`AttrValue`] variant that describes its on-disk
3840 /// encoding, so the variant reflects the charset, width and dataspace its
3841 /// writer chose rather than the shape of the data alone: a one-element array
3842 /// stays an array, an ASCII string does not arrive as a UTF-8
3843 /// [`String`](AttrValue::String), and a 16-bit integer arrives as
3844 /// [`I16`](AttrValue::I16) rather than widened, a 32-bit float as
3845 /// [`F32`](AttrValue::F32). Prefer the accessors —
3846 /// [`AttrValue::as_str`], [`as_strings`](AttrValue::as_strings),
3847 /// [`as_i64`](AttrValue::as_i64) and the rest — over matching on the variant,
3848 /// unless the encoding is the thing you care about. **The variant may become
3849 /// more specific in a future release** as `AttrValue` grows further ones
3850 /// (variable-length strings, say), and a `_` arm is required regardless
3851 /// because the enum is `#[non_exhaustive]`.
3852 ///
3853 /// An attribute whose datatype has no `AttrValue` representation is omitted
3854 /// from the map rather than reported as an error. Read
3855 /// [`attr_datatypes`](Self::attr_datatypes) to see it.
3856 pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
3857 let hdr = self.file.parse_header(self.header_address()?)?;
3858 self.file.attrs_of(&hdr)
3859 }
3860
3861 /// The exact on-disk [`Datatype`] of every attribute on this group, keyed by
3862 /// name — including compound field offsets, integer widths and enumeration
3863 /// members.
3864 ///
3865 /// This is the type channel to [`attrs`](Self::attrs)'s value channel, the
3866 /// pair a dataset already has in [`Dataset::datatype`] and its `read_*`
3867 /// methods. An [`AttrValue`] is a deliberately lossy view of the value, so an
3868 /// attribute's byte order, sub-width precision, string padding and
3869 /// enumeration members are recoverable only from here — its width is not,
3870 /// since [`attrs`](Self::attrs) keeps that. Its *rank* is not either: that
3871 /// lives in the
3872 /// dataspace, which nothing public exposes, so a rank-2 attribute still
3873 /// reads as a flat `AttrValue` array with no way to recover its shape.
3874 ///
3875 /// **Every attribute message is reported, including the ones `attrs` omits**
3876 /// because no `AttrValue` can carry them, so a name missing from that map can
3877 /// be told from one the object does not have.
3878 ///
3879 /// A **committed** datatype — one created with `H5Tcommit`, what netCDF-4
3880 /// writes for a user-defined type and what h5py writes for
3881 /// `f["t"] = np.dtype(...)` — is stored as a reference to the type's own
3882 /// object header rather than inline, and is resolved to the type it names.
3883 /// What it does *not* carry is the name: two attributes sharing `/mytype`
3884 /// report the same [`Datatype`] as one that spells it out inline.
3885 ///
3886 /// A boolean attribute is the case that needs both channels. The C library
3887 /// gives `H5T_NATIVE_HBOOL` — what h5py writes for every `np.bool_` — a
3888 /// [`Datatype::Enumeration`] of `FALSE` and `TRUE` over an 8-bit base, and
3889 /// `attrs` decodes it through that base, so the value arrives as `0` or `1`
3890 /// and only the datatype records that it was a bool.
3891 pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
3892 Ok(self
3893 .attr_messages()?
3894 .into_iter()
3895 .map(|a| (a.name, a.datatype))
3896 .collect())
3897 }
3898
3899 /// Every attribute message on this group as it is encoded on disk, in the
3900 /// order the header holds them.
3901 ///
3902 /// [`attrs`](Self::attrs) decodes each into an [`AttrValue`], which loses the
3903 /// encoding; this keeps it. Repack copies from here so an attribute survives
3904 /// a rewrite unchanged, and falls back to the decoded map only where the
3905 /// bytes are not position-independent.
3906 pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
3907 let hdr = self.file.parse_header(self.header_address()?)?;
3908 self.file.attr_messages_of(&hdr)
3909 }
3910
3911 /// Get a dataset within this group by name.
3912 ///
3913 /// The dataset uses the file-wide chunk-cache default. To override the cache
3914 /// for this one dataset, use
3915 /// [`dataset_with_options`](Self::dataset_with_options).
3916 pub fn dataset(&self, name: &str) -> Result<Dataset, Error> {
3917 self.dataset_with_options(name, DatasetAccessProperties::new())
3918 }
3919
3920 /// Get a dataset within this group by name, applying per-dataset
3921 /// [`DatasetAccessProperties`] that override file-wide access defaults (HDF5's
3922 /// `dapl`; see `H5Pset_chunk_cache`).
3923 pub fn dataset_with_options(
3924 &self,
3925 name: &str,
3926 properties: DatasetAccessProperties,
3927 ) -> Result<Dataset, Error> {
3928 let chunk_cache = properties.resolved_chunk_cache(self.file.access_properties.chunk_cache);
3929 if let Some(child) = self.child_path(name) {
3930 match self.file.staged_object(&child).map(|o| o.kind) {
3931 Some(StagedKind::Dataset) => {
3932 return Ok(Dataset::pending(self.file.clone(), chunk_cache, child));
3933 }
3934 Some(StagedKind::Group) => return Err(Error::NotADataset(name.to_string())),
3935 None => {}
3936 }
3937 }
3938 let revisions = self.file.revisions();
3939 let address = self
3940 .child_address(name)?
3941 .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
3942 let hdr = self.file.parse_header(address)?;
3943 if !has_message(&hdr, MessageType::DataLayout) {
3944 return Err(Error::NotADataset(name.to_string()));
3945 }
3946 Ok(Dataset::new(
3947 self.file.clone(),
3948 revisions.at(address),
3949 hdr,
3950 chunk_cache,
3951 self.child_path(name),
3952 ))
3953 }
3954
3955 /// Get a subgroup within this group by name.
3956 ///
3957 /// Returns [`Error::NotAGroup`] if the child is not a group, the way
3958 /// [`dataset`](Self::dataset) returns [`Error::NotADataset`] for the mirror
3959 /// case, and [`FormatError::PathNotFound`] if there is no such child.
3960 pub fn group(&self, name: &str) -> Result<Group, Error> {
3961 if let Some(child) = self.child_path(name) {
3962 match self.file.staged_object(&child).map(|o| o.kind) {
3963 Some(StagedKind::Group) => {
3964 return Ok(Group::pending(self.file.clone(), child));
3965 }
3966 Some(StagedKind::Dataset) => return Err(Error::NotAGroup(name.to_string())),
3967 None => {}
3968 }
3969 }
3970 let revisions = self.file.revisions();
3971 let address = self
3972 .child_address(name)?
3973 .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
3974 if !is_group(&self.file.parse_header(address)?) {
3975 return Err(Error::NotAGroup(name.to_string()));
3976 }
3977 Ok(Group::new(
3978 self.file.clone(),
3979 revisions.at(address),
3980 self.child_path(name),
3981 ))
3982 }
3983
3984 /// The object-header address of this group's child named `name`.
3985 ///
3986 /// The by-name form of [`children`](Self::children): it reads the group's
3987 /// links without building one entry per child, which is what makes opening
3988 /// each member of a large group in turn cost the group once rather than once
3989 /// per member (issue #228).
3990 fn child_address(&self, name: &str) -> Result<Option<u64>, Error> {
3991 match self.file.group_child(self.header_address()?, name)? {
3992 ChildLookup::Found(address) => Ok(Some(address)),
3993 ChildLookup::Absent => Ok(None),
3994 // Reached by the one handle whose object is never classified: the
3995 // root. Every other `Group` comes from a lookup that classified it
3996 // (`File::group`, `Group::group`, `iter_groups`, `object_at_relative`)
3997 // or re-resolves through `header_address`, which classifies again;
3998 // `File::root` takes the superblock's word for it, and nothing checks
3999 // that the root address names a group. The empty path is the root's
4000 // own name here, so the refusal names it correctly.
4001 ChildLookup::NotAGroup => Err(Error::NotAGroup(self.path.clone().unwrap_or_default())),
4002 }
4003 }
4004
4005 /// The root-relative path of a child named `name`, or `None` if this group
4006 /// itself has no resolvable path (reached by object reference).
4007 fn child_path(&self, name: &str) -> Option<String> {
4008 child_path_of(self.path.as_deref(), name)
4009 }
4010
4011 /// Create an empty subgroup `name` within this group, staged until
4012 /// [`File::commit`], and return a handle to it.
4013 ///
4014 /// The handle addresses the new group straight away: further groups,
4015 /// datasets, deletions and attributes can be staged through it, and
4016 /// [`group`](Self::group) finds it by name from the same session — as does
4017 /// any group named in this call, but not an intermediate one the commit
4018 /// fills in (`create_group("a/b")` leaves `a` unaddressable until then).
4019 /// Reading it
4020 /// — its attributes, or a member's data — reports
4021 /// [`Error::NotCommitted`](crate::Error::NotCommitted) until the commit,
4022 /// after which the same handle answers for the group in the file. Deleting
4023 /// it before the commit withdraws the staging, and the handle then reports
4024 /// [`Error::StagingWithdrawn`](crate::Error::StagingWithdrawn) rather than
4025 /// answering for whatever else the path may name.
4026 ///
4027 /// **The handle keeps the file's exclusive OS lock alive**, as every
4028 /// [`Group`] and [`Dataset`] handle does, so `let g = root.create_group(..)?`
4029 /// holds what `root.create_group(..)?;` used to drop and a reopen of the
4030 /// file fails until it goes. [`File::close`] states the rule.
4031 ///
4032 /// [`create_group_with`](Self::create_group_with) builds a whole subtree in
4033 /// one call instead.
4034 ///
4035 /// Requires a read-write file ([`File::open_rw`]), else
4036 /// [`Error::ReadOnly`](crate::Error::ReadOnly). A name the file already
4037 /// links to is refused here with
4038 /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) unless this
4039 /// session also deletes it — a [replacement](Self::delete) — since there
4040 /// would otherwise be no new object for the handle to address. A name this
4041 /// session already staged a *dataset* at is refused for the same reason —
4042 /// one name cannot mean two objects, and the handle would answer for
4043 /// whichever was staged first — while staging the same group twice is
4044 /// allowed and hands back another handle onto that one group, which is how
4045 /// attributes and children are added to a group already staged.
4046 /// [`delete`](Self::delete) withdraws a staged creation, which frees the
4047 /// name for another.
4048 ///
4049 /// ```no_run
4050 /// # use hdf5_pure::File;
4051 /// # fn main() -> Result<(), hdf5_pure::Error> {
4052 /// let file = File::open_rw("runs.h5")?;
4053 /// let run = file.root().create_group("run2")?;
4054 /// run.create_dataset("signal", |b| {
4055 /// b.with_f64_data(&[1.0, 2.0, 3.0]);
4056 /// })?;
4057 /// file.commit()?;
4058 /// # Ok(())
4059 /// # }
4060 /// ```
4061 pub fn create_group(&self, name: &str) -> Result<Group, Error> {
4062 self.create_group_with(name, |_| {})
4063 }
4064
4065 /// Create a subgroup `name` within this group, configuring it through
4066 /// `build` (attributes, nested groups and datasets), staged until
4067 /// [`File::commit`], and return a handle to it.
4068 ///
4069 /// The closure describes a whole subtree in one call, which is what makes it
4070 /// worth having over [`create_group`](Self::create_group) plus calls on the
4071 /// handle that returns; either way the new group is addressable by name
4072 /// before the commit, and the handle this returns is the same one
4073 /// [`group`](Self::group) would give back.
4074 ///
4075 /// The closure records into a buffer rather than into the file itself, and
4076 /// what it stages is applied together when it returns — so reading the same
4077 /// [`File`] from inside it sees the file as it was before this call, and
4078 /// everything it staged is addressable by name from the moment it returns.
4079 ///
4080 /// The handle this returns keeps the file's exclusive OS lock alive, as
4081 /// every [`Group`] and [`Dataset`] handle does; see [`File::close`].
4082 ///
4083 /// Requires a read-write file ([`File::open_rw`]), else
4084 /// [`Error::ReadOnly`](crate::Error::ReadOnly). Name collisions are refused
4085 /// on [`create_group`](Self::create_group)'s terms, for the group this
4086 /// creates and for everything the closure stages under it.
4087 ///
4088 /// ```no_run
4089 /// # use hdf5_pure::{AttrValue, File};
4090 /// # fn main() -> Result<(), hdf5_pure::Error> {
4091 /// let file = File::open_rw("runs.h5")?;
4092 /// file.root().create_group_with("run2", |g| {
4093 /// g.set_attr("count", AttrValue::I64(7));
4094 /// g.set_attr("label", AttrValue::String("second".into()));
4095 /// })?;
4096 /// file.commit()?;
4097 /// # Ok(())
4098 /// # }
4099 /// ```
4100 pub fn create_group_with(
4101 &self,
4102 name: &str,
4103 build: impl FnOnce(&mut StagedGroup<'_>),
4104 ) -> Result<Group, Error> {
4105 let child = self.child_edit_path(name)?;
4106 let mut ops = vec![StagedOp::CreateGroup(child.clone())];
4107 build(&mut StagedGroup {
4108 ops: &mut ops,
4109 path: child.clone(),
4110 });
4111 self.apply_staged(ops)?;
4112 Ok(Group::pending(self.file.clone(), child))
4113 }
4114
4115 /// Create a dataset `name` within this group, configuring it through `build`
4116 /// (shape, data, chunks, filters, …), staged until [`File::commit`], and
4117 /// return a handle to it.
4118 ///
4119 /// The handle addresses the new dataset straight away, which is what lets a
4120 /// writer cache one per column while it is still building the schema. It
4121 /// answers [`shape`](Dataset::shape), [`maxshape`](Dataset::maxshape),
4122 /// [`dtype`](Dataset::dtype), [`datatype`](Dataset::datatype),
4123 /// [`is_chunked`](Dataset::is_chunked) and [`filters`](Dataset::filters)
4124 /// from what was staged, and [`append_staged`](Dataset::append_staged) folds
4125 /// more elements into the pending creation. Anything that reads the
4126 /// dataset's bytes — a `read_*`, its attributes, an immediate
4127 /// [`append`](Dataset::append) — reports
4128 /// [`Error::NotCommitted`](crate::Error::NotCommitted) until the commit,
4129 /// after which the same handle reads the dataset in the file. Deleting it
4130 /// before the commit withdraws the staging, and the handle then reports
4131 /// [`Error::StagingWithdrawn`](crate::Error::StagingWithdrawn) rather than
4132 /// answering for whatever else the path may name.
4133 ///
4134 /// **The handle keeps the file's exclusive OS lock alive**, as every
4135 /// [`Group`] and [`Dataset`] handle does, so `let ds = root.create_dataset(..)?`
4136 /// holds what `root.create_dataset(..)?;` used to drop and a reopen of the
4137 /// file fails until it goes. [`File::close`] states the rule.
4138 ///
4139 /// As with [`create_group_with`](Self::create_group_with), the closure
4140 /// configures a builder rather than the file, so it may read the same
4141 /// [`File`] — it will see the file as it was before this call.
4142 ///
4143 /// Requires a read-write file ([`File::open_rw`]), else
4144 /// [`Error::ReadOnly`](crate::Error::ReadOnly). A name the file already
4145 /// links to is refused here with
4146 /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) unless this
4147 /// session also deletes it — a [replacement](Self::delete) — since there
4148 /// would otherwise be no new dataset for the handle to address. A name this
4149 /// session already staged a creation at is refused for the same reason:
4150 /// two creations at one path are one name for two objects, and the handle
4151 /// would answer for whichever was staged first. [`delete`](Self::delete)
4152 /// withdraws a staged creation, which frees the name for another.
4153 ///
4154 /// ```no_run
4155 /// # use hdf5_pure::File;
4156 /// # fn main() -> Result<(), hdf5_pure::Error> {
4157 /// let file = File::open_rw("runs.h5")?;
4158 /// let mut col = file.root().create_dataset("col", |b| {
4159 /// b.with_f64_data(&[])
4160 /// .with_shape(&[0])
4161 /// .with_maxshape(&[u64::MAX])
4162 /// .with_chunks(&[512]);
4163 /// })?;
4164 /// col.append_staged(|a| {
4165 /// a.append_f64(&[1.0, 2.0, 3.0]);
4166 /// })?;
4167 /// file.commit()?;
4168 /// assert_eq!(col.read_f64()?, vec![1.0, 2.0, 3.0]);
4169 /// # Ok(())
4170 /// # }
4171 /// ```
4172 pub fn create_dataset(
4173 &self,
4174 name: &str,
4175 build: impl FnOnce(&mut DatasetBuilder),
4176 ) -> Result<Dataset, Error> {
4177 let child = self.child_edit_path(name)?;
4178 let mut builder = DatasetBuilder::new(name);
4179 build(&mut builder);
4180 self.apply_staged(vec![StagedOp::CreateDataset {
4181 path: child.clone(),
4182 builder: Box::new(builder),
4183 }])?;
4184 Ok(Dataset::pending(
4185 self.file.clone(),
4186 DatasetAccessProperties::new()
4187 .resolved_chunk_cache(self.file.access_properties.chunk_cache),
4188 child,
4189 ))
4190 }
4191
4192 /// Delete the object named `name` from this group, staged until
4193 /// [`File::commit`]. See [`create_group`](Self::create_group) for the
4194 /// file-mode rules.
4195 ///
4196 /// Creating a new object at the same path in the same commit *replaces* it:
4197 /// the removal is applied before the addition and one superblock write
4198 /// publishes both, so a rotation costs one commit and the path is never
4199 /// momentarily absent.
4200 ///
4201 /// Deleting an object this session **staged** and has not committed
4202 /// withdraws that staging instead — its attributes, appends and staged
4203 /// children go with it — since there is no link in the file to unlink. Where
4204 /// the deletion was part of a replacement, the plain deletion of the file's
4205 /// own object is what remains. A handle onto the withdrawn creation reports
4206 /// [`Error::StagingWithdrawn`](crate::Error::StagingWithdrawn) from then on:
4207 /// it names nothing, and the object the file holds at that path is the one
4208 /// this session is removing.
4209 ///
4210 /// Deleting a group carries its whole subtree away, but only a commit that
4211 /// builds that group *again* can put anything back under it: staging a
4212 /// creation below a deleted path that nothing recreates is a batch `commit`
4213 /// refuses, and until it does the file's own children still own their names.
4214 /// The root itself cannot be deleted.
4215 ///
4216 /// ```no_run
4217 /// # use hdf5_pure::File;
4218 /// # fn main() -> Result<(), hdf5_pure::Error> {
4219 /// let file = File::open_rw("ring.h5")?;
4220 /// file.root().delete("t0")?;
4221 /// file.root().create_dataset("t0", |b| { b.with_i32_data(&[1, 2, 3]); })?;
4222 /// file.commit()?;
4223 /// # Ok(())
4224 /// # }
4225 /// ```
4226 pub fn delete(&self, name: &str) -> Result<(), Error> {
4227 self.with_child_session(name, |session, child| session.delete(child))
4228 }
4229
4230 /// Add or update an attribute on this group, staged until [`File::commit`].
4231 /// Use [`remove_attr`](Self::remove_attr) to remove one. The
4232 /// [`root`](File::root) group's attributes are edited the same way.
4233 ///
4234 /// Requires a read-write file ([`File::open_rw`]), else
4235 /// [`Error::ReadOnly`](crate::Error::ReadOnly). An attribute set too large
4236 /// for the object header — more than eight attributes, or one whose message
4237 /// the header's 2-byte size field cannot describe — is written to a fractal
4238 /// heap on `commit`, as it is when the whole file is written, and a group
4239 /// already storing its attributes in one is rebuilt.
4240 pub fn set_attr(&self, name: &str, value: AttrValue) -> Result<(), Error> {
4241 self.with_own_session(|session, path| session.set_group_attr(path, name, value))
4242 }
4243
4244 /// Remove an attribute from this group, staged until [`File::commit`].
4245 /// See [`set_attr`](Self::set_attr) for the file-mode rules.
4246 pub fn remove_attr(&self, name: &str) -> Result<(), Error> {
4247 self.with_own_session(|session, path| session.remove_group_attr(path, name))
4248 }
4249
4250 /// Run `f` with the writable session and the root-relative path of child
4251 /// `name`. Returns [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is
4252 /// read-only or this group has no resolvable path.
4253 fn with_child_session<R>(
4254 &self,
4255 name: &str,
4256 f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
4257 ) -> Result<R, Error> {
4258 self.refuse_if_withdrawn()?;
4259 let child = self.child_path(name).ok_or(Error::ReadOnly)?;
4260 self.file
4261 .with_engine_mut(Change::Relocating, |session| f(session, &child))
4262 }
4263
4264 /// Refuse an edit staged *through* a handle whose own staged creation has
4265 /// been withdrawn.
4266 ///
4267 /// Every read through such a handle already reports it: they resolve the
4268 /// path, and [`FileInner::locate_staged`] is where that is decided. The
4269 /// staging calls resolve nothing — they address the file by path — so this
4270 /// is where they ask the same question, and without it an edit staged
4271 /// through a withdrawn group would land under whatever the file holds at its
4272 /// path, which is the object the session is deleting.
4273 ///
4274 /// Costs nothing for a handle opened onto an object in the file: those carry
4275 /// no birth generation, so the session is never locked to answer.
4276 fn refuse_if_withdrawn(&self) -> Result<(), Error> {
4277 let Some(path) = self.path.as_deref().filter(|_| self.staged_birth.is_some()) else {
4278 return Ok(());
4279 };
4280 match self.file.staged_standing(path, self.staged_birth) {
4281 Standing::Withdrawn => Err(Error::StagingWithdrawn(path.to_string())),
4282 Standing::Live | Standing::Pending => Ok(()),
4283 }
4284 }
4285
4286 /// Validate that this group can stage an edit to child `name` and return the
4287 /// child's root-relative path, *without* taking the session lock.
4288 ///
4289 /// Paired with [`apply_staged`](Self::apply_staged): the checks run first so
4290 /// a read-only or sealed file is reported before any user closure runs, the
4291 /// closure then runs unlocked, and the lock is taken only to record what it
4292 /// built (issue #200).
4293 fn child_edit_path(&self, name: &str) -> Result<String, Error> {
4294 self.file.check_staged_writable()?;
4295 self.refuse_if_withdrawn()?;
4296 self.child_path(name).ok_or(Error::ReadOnly)
4297 }
4298
4299 /// Record already-built edits on the writable session, holding the lock only
4300 /// for the duration of the replay.
4301 ///
4302 /// The file is re-checked here because the closure that produced `ops` ran
4303 /// unlocked and could have closed the file in the meantime; staging into a
4304 /// sealed file would otherwise be silently accepted and then dropped.
4305 fn apply_staged(&self, ops: Vec<StagedOp>) -> Result<(), Error> {
4306 self.file.with_engine_mut(Change::Relocating, |session| {
4307 // All or nothing: one call can carry a whole subtree, and each op is
4308 // validated as it is staged, so a refusal partway must not leave the
4309 // ops before it recorded.
4310 session.stage_atomically(|s| {
4311 for op in ops {
4312 op.apply(s)?;
4313 }
4314 Ok(())
4315 })
4316 })
4317 }
4318
4319 /// Run `f` with the writable session and this group's *own* root-relative
4320 /// path (for attribute edits, which act on the group itself rather than a
4321 /// child). Returns [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is
4322 /// read-only or this group has no resolvable path, and
4323 /// [`Error::FileClosed`](crate::Error::FileClosed) once the file is sealed.
4324 fn with_own_session<R>(
4325 &self,
4326 f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
4327 ) -> Result<R, Error> {
4328 self.refuse_if_withdrawn()?;
4329 let path = self.path.clone().ok_or(Error::ReadOnly)?;
4330 self.file
4331 .with_engine_mut(Change::Relocating, |session| f(session, &path))
4332 }
4333
4334 fn children(&self) -> Result<Vec<GroupEntry>, Error> {
4335 let hdr = self.file.parse_header(self.header_address()?)?;
4336 self.file.group_children(&hdr)
4337 }
4338
4339 /// This group's on-disk children, paired with the children the session has
4340 /// staged under it.
4341 ///
4342 /// A staged name supersedes an on-disk link of the same name: the two can
4343 /// coexist only as a replacement (issue #305), where the commit removes the
4344 /// link before adding the new object, and every by-name lookup already
4345 /// answers with the staged one. A group that is *itself* staged has no links
4346 /// on disk to enumerate, and its members are exactly what is staged under
4347 /// it.
4348 fn children_and_staged(&self) -> Result<(Vec<GroupEntry>, StagedChildren), Error> {
4349 let staged = match self.path.as_deref() {
4350 Some(path) => self.file.staged_children(path),
4351 // A group reached by object reference cannot name itself, so nothing
4352 // can have been staged under it by name either.
4353 None => Vec::new(),
4354 };
4355 match self.children() {
4356 Ok(entries) => Ok((entries, staged)),
4357 Err(Error::NotCommitted(_)) => Ok((Vec::new(), staged)),
4358 Err(e) => Err(e),
4359 }
4360 }
4361}
4362
4363/// The children of one group this session has staged.
4364type StagedChildren = Vec<StagedChild>;
4365
4366/// Whether a staged creation takes `name` over from the link the file holds
4367/// there — which it does only when the same commit removes that link.
4368///
4369/// A creation that merely collides with a surviving link is refused where it is
4370/// staged, so the file's own object is what the name lists as.
4371fn superseded(staged: &[StagedChild], name: &str) -> bool {
4372 staged.iter().any(|c| c.name == name && c.replaces_link)
4373}
4374
4375/// The staged children of `kind` that own their names, given the on-disk links
4376/// they sit beside, in the order they were staged.
4377fn staged_members(staged: &[StagedChild], kind: StagedKind, entries: &[GroupEntry]) -> Vec<String> {
4378 staged
4379 .iter()
4380 .filter(|c| c.kind == kind)
4381 // Either the file has no link of this name, or the commit removes it.
4382 .filter(|c| c.replaces_link || !entries.iter().any(|e| e.name == c.name))
4383 .map(|c| c.name.clone())
4384 .collect()
4385}
4386
4387// ---------------------------------------------------------------------------
4388// Dataset handle
4389// ---------------------------------------------------------------------------
4390
4391/// A [`Dataset`] handle's memo: where its object header sits, what that header
4392/// says, and what both hold as of.
4393///
4394/// The handle names the dataset; this is a memo of what that name resolved to.
4395/// See [`Dataset::resolved`].
4396struct DatasetState {
4397 /// Where the header sits — [`Resolution::address`] is base-adjusted and
4398 /// file-absolute — and the revisions the address and the parse hold as of.
4399 at: Resolution,
4400 header: ObjectHeader,
4401}
4402
4403/// An owned handle to an HDF5 dataset.
4404///
4405/// The handle names the dataset by its root-relative path and remembers where
4406/// that path resolved to and what the header there said. A [`File::commit`]
4407/// rewrites and relocates object headers, and an immediate [`append`](Self::append)
4408/// rewrites one where it stands, so the memo is worked out again on the first
4409/// use after either and the handle goes on answering for the same dataset — see
4410/// [`File::commit`] for the two cases that report instead. That covers an edit
4411/// made through *another* handle to the same dataset as well as through this
4412/// one. Cloning gives a second handle to the same dataset, sharing its chunk
4413/// cache.
4414///
4415/// Three accessors cannot report a handle that no longer resolves, because they
4416/// return no `Result`: [`filters`](Self::filters) and
4417/// [`filter_pipeline`](Self::filter_pipeline) answer empty, the same answer an
4418/// unfiltered dataset gives, and [`is_chunked`](Self::is_chunked) answers
4419/// `false`. Every other reader of the header says what went wrong.
4420pub struct Dataset {
4421 file: Arc<FileInner>,
4422 /// Where this dataset's object header sits and what it says, as of the file
4423 /// revision they were read at. Re-taken on first use after the file changes;
4424 /// see [`Dataset::resolved`].
4425 ///
4426 /// `None` for a dataset this session has *staged* and not yet committed:
4427 /// there is no header to read. Such a handle answers the metadata questions
4428 /// from what was staged and resolves its path on every other use, until a
4429 /// commit gives it a header to memoize.
4430 state: RwLock<Option<Arc<DatasetState>>>,
4431 // Held per-dataset: the chunk index is keyed only by chunk coordinate, so
4432 // a file-level cache would alias chunk addresses across datasets. Shared
4433 // between clones, which are the same dataset: a chunk read through one is
4434 // warm for the other, and an edit through either drops it for both.
4435 chunk_cache: Arc<ChunkCache>,
4436 // The effective chunk-cache config for this dataset: the file-wide default
4437 // or a per-dataset DAPL override. Reported by `chunk_cache_config`.
4438 chunk_cache_config: ChunkCacheConfig,
4439 /// Root-relative path of this dataset, used to address it for write
4440 /// operations on a read-write file, and to find it again after an edit moved
4441 /// it. `None` for a dataset reached by object reference
4442 /// ([`Dataset::dereference`]), which has no resolvable path.
4443 path: Option<String>,
4444 /// The session's staged generation when this handle was made onto a staged
4445 /// creation, and `None` for a handle opened onto a dataset in the file.
4446 ///
4447 /// It is what keeps such a handle from being retargeted at a different
4448 /// object: see [`Standing`] and [`FileInner::staged_standing`].
4449 staged_birth: Option<u64>,
4450}
4451
4452impl Clone for Dataset {
4453 /// The clone is a second handle to the same dataset, sharing its chunk cache
4454 /// and resolved as of the same revision.
4455 fn clone(&self) -> Self {
4456 Self {
4457 file: Arc::clone(&self.file),
4458 state: RwLock::new(
4459 self.state
4460 .read()
4461 .unwrap_or_else(PoisonError::into_inner)
4462 .clone(),
4463 ),
4464 chunk_cache: Arc::clone(&self.chunk_cache),
4465 chunk_cache_config: self.chunk_cache_config,
4466 path: self.path.clone(),
4467 staged_birth: self.staged_birth,
4468 }
4469 }
4470}
4471
4472impl std::fmt::Debug for Dataset {
4473 /// Reports the memo as it stands, without re-resolving: a `Debug` that read
4474 /// the file could fail, and one that failed would have nothing to print.
4475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4476 let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
4477 match state.as_ref() {
4478 Some(state) => f
4479 .debug_struct("Dataset")
4480 .field("messages", &state.header.messages.len())
4481 .finish(),
4482 None => f.debug_struct("Dataset").field("staged", &true).finish(),
4483 }
4484 }
4485}
4486
4487/// How many stored bytes a typed whole-dataset read holds beside its output,
4488/// before rounding the window up to whole chunk bands.
4489///
4490/// The decoded values are the caller's and there is no bound to put on them;
4491/// what this bounds is the *stored* copy standing next to them, which used to be
4492/// the whole dataset over again (issue #289). A mebibyte is small against any
4493/// dataset large enough for that to matter, and large enough that a sweep costs
4494/// reads in the tens rather than the thousands. A dataset that fits inside one
4495/// window is read whole, exactly as before.
4496const TYPED_READ_WINDOW_BYTES: u64 = 1 << 20;
4497
4498/// How many values of the requested type a whole-dataset read produces per
4499/// stored element, which is what its output buffer is reserved at.
4500#[derive(Clone, Copy)]
4501enum OutputSize {
4502 /// One value per stored element — every numeric decoder.
4503 PerElement,
4504 /// One value per stored *byte* — [`Dataset::read_i8`], which reinterprets
4505 /// bytes rather than decoding elements, and so yields one per byte of a
4506 /// dataset whose elements are wider than one.
4507 PerByte,
4508}
4509
4510/// How many leading-dimension rows a typed whole-dataset read decodes at a time.
4511///
4512/// [`TYPED_READ_WINDOW_BYTES`] of stored bytes, rounded *down* to whole chunk
4513/// bands so that no chunk is ever decoded for two windows, and never fewer than
4514/// one row — or one band, when a single band is already over budget, since a
4515/// window narrower than that would decode the same chunks again.
4516///
4517/// A dataset whose rows have no bytes (a zero inner dimension) has no elements
4518/// to read at all: it answers `NonZeroU64::MAX`, which the caller reads as "one
4519/// window covers it".
4520///
4521/// The answer is a `NonZeroU64` because the sweep advances by it: a window of no
4522/// rows would leave that loop running forever rather than returning something
4523/// wrong, and a test cannot report the difference.
4524fn typed_window_rows(
4525 dl: &DataLayout,
4526 ds: &Dataspace,
4527 elem_size: NonZeroUsize,
4528) -> Result<NonZeroU64, FormatError> {
4529 let mut row_bytes = elem_size.get() as u64;
4530 for &d in ds.dimensions.iter().skip(1) {
4531 row_bytes = row_bytes
4532 .checked_mul(d)
4533 .ok_or(FormatError::OffsetOverflow {
4534 offset: row_bytes,
4535 length: d,
4536 })?;
4537 }
4538 if row_bytes == 0 {
4539 return Ok(NonZeroU64::MAX);
4540 }
4541
4542 let mut rows = (TYPED_READ_WINDOW_BYTES / row_bytes).max(1);
4543 if let DataLayout::Chunked {
4544 chunk_dimensions, ..
4545 } = dl
4546 {
4547 // A chunked layout message carries rank + 1 dimensions, the last being
4548 // the element size, so the first is the leading dimension's chunk extent
4549 // for every layout version this crate parses.
4550 if let Some(band) = chunk_dimensions
4551 .first()
4552 .map(|&d| u64::from(d))
4553 .filter(|&d| d > 0)
4554 {
4555 rows = if rows < band {
4556 band
4557 } else {
4558 rows - rows % band
4559 };
4560 }
4561 }
4562 // At least one row always, and at least one whole band when a band applied:
4563 // that arm runs only when `rows >= band`, so the remainder it subtracts
4564 // leaves a band standing.
4565 Ok(NonZeroU64::new(rows).unwrap_or(NonZeroU64::MIN))
4566}
4567
4568impl Dataset {
4569 /// Build a handle for a dataset whose header was found, and read, at `at`.
4570 fn new(
4571 file: Arc<FileInner>,
4572 at: Resolution,
4573 header: ObjectHeader,
4574 chunk_cache_config: ChunkCacheConfig,
4575 path: Option<String>,
4576 ) -> Self {
4577 Self {
4578 file,
4579 state: RwLock::new(Some(Arc::new(DatasetState { at, header }))),
4580 chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
4581 chunk_cache_config,
4582 path,
4583 staged_birth: None,
4584 }
4585 }
4586
4587 /// Build a handle for a dataset this session has staged and not yet
4588 /// committed, which has no object header to read.
4589 ///
4590 /// It answers the questions the staged builder already settles — shape,
4591 /// maximum shape, datatype, whether the storage is chunked and which filters
4592 /// it carries — and stages further edits on the pending creation. Everything
4593 /// that needs bytes reports [`Error::NotCommitted`](crate::Error::NotCommitted)
4594 /// until [`File::commit`], after which the first use resolves the path and
4595 /// the handle behaves as if it had been opened by name.
4596 fn pending(file: Arc<FileInner>, chunk_cache_config: ChunkCacheConfig, path: String) -> Self {
4597 Self {
4598 staged_birth: file.staged_generation(),
4599 file,
4600 state: RwLock::new(None),
4601 chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
4602 chunk_cache_config,
4603 path: Some(path),
4604 }
4605 }
4606
4607 /// What this dataset's staged creation says about itself, `Ok(None)` once it
4608 /// is committed (or when it never was staged), and
4609 /// [`Error::StagingWithdrawn`] when the creation this handle was made onto
4610 /// has been withdrawn — the one answer that is neither the staged record nor
4611 /// the file, because the object at that path is one the session is deleting.
4612 ///
4613 /// Only a handle with no memo can be pending: one that has resolved a header
4614 /// names an object the file already holds, so this costs a lock read rather
4615 /// than a session lock on the common path.
4616 fn staged_meta(&self) -> Result<Option<StagedMeta>, Error> {
4617 if self
4618 .state
4619 .read()
4620 .unwrap_or_else(PoisonError::into_inner)
4621 .is_some()
4622 {
4623 return Ok(None);
4624 }
4625 let Some(path) = self.path.as_deref() else {
4626 return Ok(None);
4627 };
4628 self.file.staged_dataset_view(path, self.staged_birth)
4629 }
4630
4631 /// Refuse an operation that needs this dataset's bytes while it is still
4632 /// staged.
4633 fn refuse_if_pending(&self) -> Result<(), Error> {
4634 match self.staged_meta()? {
4635 // `staged_meta` answers only for a handle with a path.
4636 Some(_) => Err(Error::NotCommitted(self.path.clone().unwrap_or_default())),
4637 None => Ok(()),
4638 }
4639 }
4640
4641 /// This dataset's address and parsed header, worked out again if the file
4642 /// has changed since the memo was taken.
4643 ///
4644 /// Returns [`Error::StaleHandle`](crate::Error::StaleHandle) for a handle
4645 /// that has no path to re-resolve — one an object reference produced — once
4646 /// a commit has run under it, and the resolution's own error (a
4647 /// `PathNotFound`, say, for a dataset a commit deleted) when the path no
4648 /// longer names anything. A dataset this session has staged and not
4649 /// committed has no header at all, and that is
4650 /// [`Error::NotCommitted`](crate::Error::NotCommitted). A path that now
4651 /// names something other than a dataset is
4652 /// [`Error::NotADataset`](crate::Error::NotADataset), the same answer
4653 /// opening it afresh would give.
4654 fn resolved(&self) -> Result<Arc<DatasetState>, Error> {
4655 let live = self.file.content_revision();
4656 let memo = {
4657 let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
4658 match state.as_ref() {
4659 Some(state) if state.at.content_revision == live => {
4660 return Ok(Arc::clone(state));
4661 }
4662 Some(state) => Some(state.at),
4663 None => None,
4664 }
4665 };
4666 let at = self
4667 .file
4668 .locate_staged(self.path.as_deref(), memo, self.staged_birth)?;
4669 let header = self.file.parse_header(at.address)?;
4670 // Checked *before* it is memoized. A header installed and then refused is
4671 // the answer every later call short-circuits on, so this handle would
4672 // report `NotADataset` once and then serve the other object's header —
4673 // which a commit replacing a dataset with a group at the same path
4674 // (issue #305) makes reachable.
4675 if !has_message(&header, MessageType::DataLayout) {
4676 // Only a path can reach this: an address memo that survived is the
4677 // address of the dataset this handle already read there.
4678 return Err(Error::NotADataset(self.path.clone().unwrap_or_default()));
4679 }
4680 Ok(self.install(at, header))
4681 }
4682
4683 /// Memoize `header`, read at `at`, as this handle's resolution.
4684 ///
4685 /// Drops the chunk cache: an edit that rewrote this header can have moved
4686 /// the chunk index and the chunks it names, so what the cache holds belongs
4687 /// to the copy the edit replaced.
4688 fn install(&self, at: Resolution, header: ObjectHeader) -> Arc<DatasetState> {
4689 let fresh = Arc::new(DatasetState { at, header });
4690 self.chunk_cache.clear();
4691 let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
4692 // Two threads can re-resolve at once. The older answer must not land on
4693 // top of the newer one, or the newer handle would go on serving a header
4694 // the file has already moved past. A handle that had no memo takes this
4695 // one: any header beats naming nothing.
4696 if state
4697 .as_ref()
4698 .is_none_or(|memo| at.content_revision >= memo.at.content_revision)
4699 {
4700 *state = Some(Arc::clone(&fresh));
4701 }
4702 fresh
4703 }
4704
4705 /// Address of this dataset's object header (base-adjusted, file-absolute).
4706 /// Used to resolve object references that point at this dataset.
4707 pub(crate) fn header_address(&self) -> Result<u64, Error> {
4708 Ok(self.resolved()?.at.address)
4709 }
4710
4711 /// Append `data` to this dataset in place, growing it along its first
4712 /// (unlimited) dimension. Every handle onto the dataset reads the new length
4713 /// afterwards, this one included.
4714 ///
4715 /// The file must have been opened for writing with [`File::open_rw`];
4716 /// a read-only file returns
4717 /// [`Error::ReadOnly`](crate::Error::ReadOnly). The target must be a chunked,
4718 /// rank-1, unlimited, Extensible-Array-indexed dataset; anything else returns
4719 /// [`Error::AppendInPlaceUnsupported`](crate::Error::AppendInPlaceUnsupported).
4720 /// Both the dataset's current length and the appended length are
4721 /// unconstrained, on a filtered dataset as much as an unfiltered one: a
4722 /// partial trailing chunk is rewritten into a fresh allocation — decoded,
4723 /// extended and re-encoded when there is a filter pipeline — and its index
4724 /// element is repointed once those bytes are on the disk. The bytes the old
4725 /// chunk occupied are left for [`repack`](crate::repack).
4726 ///
4727 /// Two things still require a chunk-aligned starting length. A **lossy**
4728 /// pipeline (ZFP, or float D-scale scale-offset) is refused, because
4729 /// re-encoding the trailing chunk would change values that are already
4730 /// committed rather than reproduce them. And a trailing chunk the chunk index
4731 /// does not name — one a writer allocated lazily, which the reference C
4732 /// library does for a chunk it has not written — cannot be read to be grown,
4733 /// so it is refused too; append whole chunks, or use
4734 /// [`append_staged`](Self::append_staged).
4735 ///
4736 /// The append is immediate and crash-atomic (no `commit` needed) — under the
4737 /// default [`SyncPolicy::Always`]. Under
4738 /// [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose) the same writes are made
4739 /// in the same order without the `fsync` barriers between them, so the
4740 /// append is still immediate and still crash-atomic against *this process*
4741 /// failing, but ordering it against power loss is the caller's, through
4742 /// [`File::sync`].
4743 ///
4744 /// A **SWMR** writer ([`File::open_swmr_writer`]) keeps the narrower rule it
4745 /// always had — unfiltered, and chunk-aligned at both ends — because its
4746 /// readers are concurrent by contract and a rewritten trailing chunk is one
4747 /// they could be crossing. It allocates at end-of-file for that same reason:
4748 /// a region this session freed is one a reader may still be inside.
4749 ///
4750 /// Where the new chunks land otherwise depends on how the file records its
4751 /// free space. A default-strategy file spends a hole an earlier commit in
4752 /// this session left; a file that **persists** its free-space managers
4753 /// (including every paged file) spends only space the session has first taken
4754 /// *out* of them, in a rewrite of its own, so no byte an append writes is one
4755 /// a durable manager advertises — a crash can strand the unspent remainder of
4756 /// such a batch, but nothing can be handed out twice. A batch gathers every
4757 /// hole the appended chunk fits in, up to a megabyte of them, so the file
4758 /// grows only when no hole can hold the chunk.
4759 ///
4760 /// A handle reached by object reference ([`dereference`](Self::dereference))
4761 /// has no resolvable path, so it names its dataset by the object-header
4762 /// address it was reached through and can append like any other — until the
4763 /// session stages or commits an edit. A commit can move that header, and the
4764 /// bytes it vacates still parse as the dataset they were, so an append
4765 /// against the old address would land in a header nothing points at. Rather
4766 /// than do that silently, such an append is refused once edits are staged or
4767 /// a commit has run; re-open the dataset by path to keep appending. A
4768 /// path-named handle is unaffected, because the path is resolved afresh every
4769 /// time.
4770 pub fn append<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
4771 let g = self.append_geometry()?;
4772 self.append_batches(g, data.len() as u64, |b, r| {
4773 b.append(&data[r]);
4774 })
4775 }
4776
4777 /// Append raw little-endian element bytes to this dataset in place. Prefer
4778 /// [`append`](Self::append) when the element type is known; see it for the
4779 /// file-mode and eligibility rules.
4780 pub fn append_raw(&mut self, bytes: &[u8]) -> Result<(), Error> {
4781 let g = self.append_geometry()?;
4782 let es = g.element_size;
4783 // Whole-element length is checked before any batch applies, so the
4784 // refusal is atomic (the per-batch validation would only reject the
4785 // final, short batch after earlier ones had durably committed).
4786 if bytes.len() % es != 0 {
4787 return Err(Error::AppendInPlaceUnsupported(
4788 "appended byte length is not a whole number of elements",
4789 ));
4790 }
4791 let total = (bytes.len() / es) as u64;
4792 self.append_batches(g, total, |b, r| {
4793 b.append_raw(&bytes[r.start * es.get()..r.end * es.get()]);
4794 })
4795 }
4796
4797 /// How an append names this dataset to the session: by path when the handle
4798 /// has one, so the session can check the target against its own staged
4799 /// edits, and otherwise by the object-header address the handle was reached
4800 /// through — which is what lets a handle obtained by object reference append
4801 /// at all.
4802 fn append_target(&self) -> Result<AppendTarget<'_>, Error> {
4803 Ok(match &self.path {
4804 Some(path) => AppendTarget::Path(path),
4805 None => AppendTarget::Header(self.resolved()?.at.address),
4806 })
4807 }
4808
4809 /// A [`BufferedAppender`] over this dataset: appended elements are held in
4810 /// memory and written a whole chunk at a time, so a caller appending less
4811 /// than a chunk per call writes to the file once per chunk instead of once
4812 /// per call, and a filtered dataset is never left mid-chunk for the next
4813 /// write to re-encode.
4814 ///
4815 /// Every eligibility rule [`append`](Self::append) applies is applied here,
4816 /// so an ineligible dataset is reported now rather than on the first write.
4817 /// Buffered elements are not in the file until the appender flushes; see
4818 /// [`BufferedAppender`] for the full bargain.
4819 pub fn buffered_appender(&mut self) -> Result<BufferedAppender<'_>, Error> {
4820 BufferedAppender::new(self)
4821 }
4822
4823 /// Register a live `BufferedAppender` on this dataset with the session, so a
4824 /// staged edit that would stop it from flushing is refused at the call that
4825 /// creates the conflict rather than in the appender's `Drop`.
4826 pub(crate) fn claim_for_appender(&self) -> Result<u64, Error> {
4827 let Backend::Edit(m) = &self.file.backend else {
4828 return Err(Error::ReadOnly);
4829 };
4830 m.lock()
4831 .unwrap_or_else(std::sync::PoisonError::into_inner)
4832 .claim_for_appender(self.path.as_deref())
4833 }
4834
4835 /// Release the claim taken by [`claim_for_appender`](Self::claim_for_appender).
4836 pub(crate) fn release_appender_claim(&self, token: u64) {
4837 if let Backend::Edit(m) = &self.file.backend {
4838 m.lock()
4839 .unwrap_or_else(std::sync::PoisonError::into_inner)
4840 .release_appender_claim(token);
4841 }
4842 }
4843
4844 /// Whether this dataset's session is the SWMR writer, whose append rules are
4845 /// a strict subset of the ordinary ones. `false` for a read-only file, which
4846 /// has no session to ask.
4847 pub(crate) fn session_is_swmr(&self) -> bool {
4848 match &self.file.backend {
4849 Backend::Edit(m) => m
4850 .lock()
4851 .unwrap_or_else(std::sync::PoisonError::into_inner)
4852 .is_swmr(),
4853 _ => false,
4854 }
4855 }
4856
4857 /// Immediate in-place append of an already-gathered builder, used by
4858 /// [`BufferedAppender`], whose bytes are materialized in its buffer before
4859 /// it decides how many of them to write. `append_batches` exists for the
4860 /// opposite case — a caller whose bytes are cheaper to build per batch — so
4861 /// this hands the engine one builder and lets it batch the plan.
4862 pub(crate) fn append_prebuilt(&mut self, b: &AppendBuilder) -> Result<(), Error> {
4863 let target = self.append_target()?;
4864 self.file.with_engine_mut(Change::InPlace, |engine| {
4865 engine.append_inplace_gathered(target, b, 4)
4866 })
4867 }
4868
4869 /// Fetch (locating on first use) this dataset's append geometry from the
4870 /// write session, which also applies every refusal that does not depend on
4871 /// the bytes being appended.
4872 pub(crate) fn append_geometry(&self) -> Result<AppendGeometry, Error> {
4873 // An in-place append rewrites bytes where they stand, and a dataset this
4874 // session has only staged has none. Refused here rather than deep in the
4875 // engine, so `append`, `append_raw` and `buffered_appender` all say the
4876 // same thing.
4877 self.refuse_if_pending()?;
4878 let target = self.append_target()?;
4879 self.file
4880 .with_engine_mut(Change::Nothing, |engine| engine.append_geometry(target))
4881 }
4882
4883 /// Immediate in-place append, driven batch by batch. The call is split into
4884 /// aligned batches — the trailing partial chunk is filled first, then
4885 /// whole-chunk batches under the session's byte budget — and `fill` builds
4886 /// each batch's bytes on demand, so a bounded session's peak memory holds
4887 /// one batch rather than the whole call. A session that keeps the whole file
4888 /// resident reports one unbounded batch, so the call stays a single
4889 /// crash-atomic apply there.
4890 ///
4891 /// Every predictable refusal (wrong datatype, ineligible dataset) is raised
4892 /// before the first batch is applied. The cached header and chunk cache are
4893 /// then refreshed so later reads on this handle observe the new length. A
4894 /// filtered dataset sitting on a partial trailing chunk is grown by the
4895 /// first batch, which re-encodes that chunk into a fresh allocation and
4896 /// leaves every later batch starting on a boundary.
4897 fn append_batches(
4898 &mut self,
4899 g: AppendGeometry,
4900 total_elems: u64,
4901 fill: impl Fn(&mut AppendBuilder, std::ops::Range<usize>),
4902 ) -> Result<(), Error> {
4903 // Worked out once for the whole call: every batch names the same
4904 // dataset, and an in-place append does not move it.
4905 let target = self.append_target()?;
4906 let mut dim = g.current_dim;
4907 let mut done = 0u64;
4908 loop {
4909 // An empty append still runs one (empty) engine call, so datatype
4910 // validation happens whether or not there are elements.
4911 let to_boundary = (g.chunk_elems - dim % g.chunk_elems) % g.chunk_elems;
4912 let take = (total_elems - done).min(to_boundary.saturating_add(g.full_batch_elems));
4913 let mut b = AppendBuilder::new();
4914 fill(&mut b, done.to_usize()?..(done + take).to_usize()?);
4915 self.file.with_engine_mut(Change::InPlace, |engine| {
4916 engine.append_inplace_gathered(target, &b, 4)
4917 })?;
4918 dim += take;
4919 done += take;
4920 if done >= total_elems {
4921 break;
4922 }
4923 }
4924 Ok(())
4925 }
4926
4927 /// Overwrite this dataset's values, staged until [`File::commit`]. The new
4928 /// data must match the dataset's existing shape and datatype.
4929 ///
4930 /// The file must have been opened with [`File::open_rw`], else
4931 /// [`Error::ReadOnly`](crate::Error::ReadOnly). Unlike [`append`](Self::append)
4932 /// (immediate), this is a staged edit applied on [`File::commit`].
4933 pub fn write<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
4934 // Build off the lock, as `write_staged` does: `write_into` is trait
4935 // code reached with no lock held, keeping both paths identical.
4936 self.check_staged_edit()?;
4937 let mut builder = DatasetBuilder::new("");
4938 T::write_into(&mut builder, data);
4939 self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
4940 }
4941
4942 /// Overwrite this dataset's values through its full [`DatasetBuilder`],
4943 /// staged until [`File::commit`] — the builder-level counterpart of
4944 /// [`write`](Self::write), and the only one of the two that can carry a
4945 /// **shape**. [`write`](Self::write) sends a flat `&[T]`, so it can overwrite
4946 /// a one-dimensional dataset only; a multi-dimensional one needs
4947 /// [`with_shape`](DatasetBuilder::with_shape) and so comes through here, as
4948 /// do compound, complex, and raw bytes under an explicit datatype.
4949 ///
4950 /// The replacement must match the on-disk datatype and shape exactly; a
4951 /// reshape or retype is refused on [`File::commit`].
4952 ///
4953 /// This overwrites element bytes and nothing else, so a builder asking for
4954 /// more than that is refused by **this call**, before anything is staged:
4955 /// chunking, filters or an extensible shape; an attribute; a fill value; and
4956 /// [`with_path_references`](DatasetBuilder::with_path_references), whose
4957 /// element bytes are placeholder addresses only a newly created dataset can
4958 /// resolve. Set those when the dataset is created.
4959 ///
4960 /// [`with_vlen_strings`](DatasetBuilder::with_vlen_strings) is **not**
4961 /// refused: overwriting a variable-length-string dataset places a fresh
4962 /// global heap collection for the new strings and resolves the staged
4963 /// element references against it. Overwriting the same dataset again
4964 /// reclaims the collection the previous overwrite placed, so rotating its
4965 /// strings in a session does not grow the file without bound. The
4966 /// collections it held when the session *opened* are not reclaimed — a
4967 /// collection can be shared between objects, and only this session's own
4968 /// placements are known not to be — so [`repack`](crate::repack) is what
4969 /// recovers those.
4970 ///
4971 /// The reference refusal is on the builder, not on the datatype it produces:
4972 /// such a dataset can still be overwritten by supplying element bytes that
4973 /// need no resolving, with
4974 /// [`with_reference_data`](DatasetBuilder::with_reference_data) or
4975 /// [`with_raw_data`](DatasetBuilder::with_raw_data). An object reference
4976 /// supplied that way is screened at `commit` by *address*, against both what
4977 /// the same commit deletes and what it rewrites elsewhere, so it cannot be
4978 /// left naming storage the commit is vacating — the answer a target named as
4979 /// a path already got by name.
4980 ///
4981 /// The file must have been opened with [`File::open_rw`], else
4982 /// [`Error::ReadOnly`](crate::Error::ReadOnly).
4983 ///
4984 /// ```no_run
4985 /// # use hdf5_pure::File;
4986 /// # fn main() -> Result<(), hdf5_pure::Error> {
4987 /// let file = File::open_rw("counters.h5")?;
4988 /// let mut ds = file.dataset("ticks")?;
4989 /// // Keep the dataset's own datatype and supply the replacement bytes it
4990 /// // describes — three little-endian 16-bit elements here.
4991 /// let dt = ds.datatype()?;
4992 /// ds.write_staged(|b| {
4993 /// b.with_raw_data(dt, vec![1, 0, 2, 0, 3, 0], 3);
4994 /// })?;
4995 /// file.commit()?;
4996 /// # Ok(())
4997 /// # }
4998 /// ```
4999 /// The closure configures a standalone builder, not the file, so it may read
5000 /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5001 pub fn write_staged(&mut self, build: impl FnOnce(&mut DatasetBuilder)) -> Result<(), Error> {
5002 // Report a read-only, sealed, or unaddressable dataset before running the
5003 // closure, then run it with no lock held; `stage_dataset_write` names the
5004 // builder from the dataset's path (issue #200).
5005 self.check_staged_edit()?;
5006 let mut builder = DatasetBuilder::new("");
5007 build(&mut builder);
5008 self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
5009 }
5010
5011 /// Stage an append to this dataset applied on [`File::commit`] — the staged,
5012 /// index-rebuilding counterpart of the immediate [`append`](Self::append).
5013 ///
5014 /// Unlike [`append`](Self::append) (immediate, amortized `O(1)`,
5015 /// Extensible-Array only), this rebuilds the chunk index on commit and so
5016 /// also grows datasets whose Extensible-Array index is not yet allocated.
5017 /// Like it, a dataset under a **lossy** pipeline (ZFP, or float D-scale
5018 /// scale-offset) whose length is not a whole multiple of its chunk length
5019 /// is refused: growing that trailing chunk would decode and re-encode
5020 /// values that are already committed, changing them. That one is refused
5021 /// *here*, by this call, rather than at the commit — nothing is staged, and
5022 /// edits staged beside it are unaffected.
5023 /// Configure the appended elements through `build` on the
5024 /// [`AppendBuilder`]; repeated calls within the builder concatenate in
5025 /// order. The dataset must be chunked, unlimited
5026 /// along axis 0, Extensible-Array indexed, rank 1, use a re-encodable filter
5027 /// pipeline, and have a single hard link, otherwise
5028 /// [`Error::AppendUnsupported`](crate::Error::AppendUnsupported) is returned
5029 /// on [`File::commit`].
5030 ///
5031 /// The file must have been opened with [`File::open_rw`], else
5032 /// [`Error::ReadOnly`](crate::Error::ReadOnly).
5033 /// The closure configures a standalone builder, not the file, so it may read
5034 /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5035 pub fn append_staged(&mut self, build: impl FnOnce(&mut AppendBuilder)) -> Result<(), Error> {
5036 // A dataset *this handle* names and this session has not written has no
5037 // bytes to grow, so the elements are folded into the pending creation
5038 // instead (see the note above). `check_staged_edit` refuses that dataset,
5039 // so ask it only about one the file already holds; the file-mode and path
5040 // gates apply either way.
5041 //
5042 // The distinction is the handle's, not the path's: a handle onto the
5043 // object a staged creation *replaces* is not pending, and the session
5044 // refuses its append rather than growing the replacement under it.
5045 let pending = self.staged_meta()?.is_some();
5046 if pending {
5047 self.file.check_staged_writable()?;
5048 } else {
5049 self.check_staged_edit()?;
5050 }
5051 let mut builder = AppendBuilder::new();
5052 build(&mut builder);
5053 self.with_session_mut(|session, path| {
5054 if pending {
5055 session.stage_dataset_append_pending(path, builder)
5056 } else {
5057 session.stage_dataset_append(path, builder)
5058 }
5059 })
5060 }
5061
5062 /// Add or update an attribute on this dataset, staged until
5063 /// [`File::commit`]. Use [`remove_attr`](Self::remove_attr) to remove one.
5064 /// An attribute set too large for the object header is written to a fractal
5065 /// heap, exactly as [`Group::set_attr`] does.
5066 ///
5067 /// The file must have been opened with [`File::open_rw`], else
5068 /// [`Error::ReadOnly`](crate::Error::ReadOnly).
5069 pub fn set_attr(&mut self, name: &str, value: AttrValue) -> Result<(), Error> {
5070 self.refuse_if_pending()?;
5071 self.with_session_mut(|session, path| session.set_dataset_attr(path, name, value))
5072 }
5073
5074 /// Remove an attribute from this dataset, staged until [`File::commit`].
5075 /// See [`set_attr`](Self::set_attr) for the file-mode rules.
5076 pub fn remove_attr(&mut self, name: &str) -> Result<(), Error> {
5077 self.refuse_if_pending()?;
5078 self.with_session_mut(|session, path| session.remove_dataset_attr(path, name))
5079 }
5080
5081 /// Gate a staged edit on this dataset *without* taking the session lock:
5082 /// the file must accept staged edits, this handle must have a resolvable
5083 /// path, and this dataset's elements must be ones the engine owns.
5084 ///
5085 /// The path check belongs here rather than only in
5086 /// [`with_session_mut`](Self::with_session_mut) so that every reason to
5087 /// refuse is reported *before* a user closure runs, not after. A handle
5088 /// reached by object reference ([`dereference`](Self::dereference)) has no
5089 /// path, and would otherwise have its closure run and its result discarded.
5090 ///
5091 /// This is the one gate every data-writing entry point shares — `write`,
5092 /// `write_staged`, `append_staged`, and the staged rewrite behind
5093 /// [`BufferedAppender`] — which is why the external-storage refusal is here
5094 /// and not on any one of them. Attribute edits do not come through here, and
5095 /// are unaffected: they change the object header, not the elements.
5096 fn check_staged_edit(&self) -> Result<(), Error> {
5097 self.file.check_staged_writable()?;
5098 if self.path.is_none() {
5099 return Err(Error::ReadOnly);
5100 }
5101 // Ahead of the external-storage question, which reads a header this
5102 // dataset does not have yet. `append_staged` is the one staged edit that
5103 // *is* supported on a pending dataset, and it re-admits itself below.
5104 self.refuse_if_pending()?;
5105 // The elements of an externally stored dataset are not in this file, and
5106 // the engine writes only this file. Its contiguous layout message records
5107 // no address, so a write took it for never-allocated storage, appended the
5108 // new bytes and pointed the layout at them — leaving the file with two
5109 // contradictory records of where the data lives, and the reference
5110 // library still reading the external files it had not touched. See
5111 // [`has_external_storage`](Self::has_external_storage).
5112 if self.has_external_storage()? {
5113 return Err(Error::EditUnsupported(
5114 "this dataset's elements live in external files (H5Pset_external), which this \
5115engine does not write; writing them into the HDF5 file would leave it disagreeing with \
5116the external files about where the data is. Delete the dataset and create it again in \
5117the same commit to replace it",
5118 ));
5119 }
5120 Ok(())
5121 }
5122
5123 /// Run `f` with the writable session and this dataset's path, then refresh
5124 /// the cached header so a later read on this handle reflects any immediate
5125 /// change (e.g. an append's new dimension). Returns
5126 /// [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is read-only or the
5127 /// handle has no resolvable path (reached by object reference).
5128 fn with_session_mut<R>(
5129 &mut self,
5130 f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
5131 ) -> Result<R, Error> {
5132 let path = self.path.clone().ok_or(Error::ReadOnly)?;
5133 self.file
5134 .with_engine_mut(Change::Relocating, |session| f(session, &path))
5135 }
5136
5137 /// The effective raw chunk-cache configuration for this dataset.
5138 ///
5139 /// This reflects the per-dataset [`DatasetAccessProperties`] override when one
5140 /// was supplied to [`File::dataset_with_options`] /
5141 /// [`Group::dataset_with_options`], otherwise the file-wide default. It is
5142 /// the read-side analogue of HDF5's `H5Pget_chunk_cache`.
5143 pub const fn chunk_cache_config(&self) -> ChunkCacheConfig {
5144 self.chunk_cache_config
5145 }
5146
5147 /// A point-in-time snapshot of this dataset handle's chunk-cache occupancy.
5148 ///
5149 /// Lets callers confirm a chunk-cache configuration (set with
5150 /// [`FileAccessProperties::with_chunk_cache`]) is taking effect: after a
5151 /// chunked read, an enabled cache reports a loaded index and retained
5152 /// chunks; a disabled one (or one over its budget) reports fewer or none.
5153 /// The cache is per-handle — though clones of one handle share it — so a
5154 /// freshly opened [`Dataset`] reports an empty snapshot until its first read.
5155 pub fn chunk_cache_stats(&self) -> ChunkCacheStats {
5156 // Notice any edit first, which is what drops the chunks it invalidated.
5157 // A snapshot taken before that would count chunks no read will ever be
5158 // served, and this exists to say what the cache is holding *for* a read.
5159 // An unresolvable handle has no live cache to report, and an empty
5160 // snapshot is the truthful answer for one.
5161 let _ = self.resolved();
5162 self.chunk_cache.stats()
5163 }
5164
5165 /// Zero this handle's cumulative chunk-cache counters, leaving the retained
5166 /// index and chunks in place.
5167 ///
5168 /// [`ChunkCacheStats`]'s occupancy figures are unaffected — this resets what
5169 /// the cache has *done*, not what it is *holding* — so a caller can measure
5170 /// one read on a cache an earlier read already warmed. The cache is
5171 /// per-handle, though clones of one handle share it, so this resets the
5172 /// counters those clones report too.
5173 pub fn reset_chunk_cache_stats(&self) {
5174 // Resolve first for the same reason [`Self::chunk_cache_stats`] does: an
5175 // edit drops the chunks it invalidated, and those belong in the
5176 // invalidation count of the window being reset, not the next one.
5177 let _ = self.resolved();
5178 self.chunk_cache.reset_stats();
5179 }
5180
5181 /// Returns the shape (dimensions) of the dataset.
5182 ///
5183 /// A dataset this session has staged and not yet committed answers from what
5184 /// was staged, which includes the elements
5185 /// [`append_staged`](Self::append_staged) has folded into it.
5186 pub fn shape(&self) -> Result<Vec<u64>, Error> {
5187 if let Some(meta) = self.staged_meta()? {
5188 return Ok(meta.dimensions);
5189 }
5190 let ds = self.dataspace()?;
5191 Ok(ds.dimensions.clone())
5192 }
5193
5194 /// The dataset's maximum dimensions, when it is extensible. An unlimited
5195 /// dimension is reported as `u64::MAX`. Returns `Ok(None)` for a fixed-shape
5196 /// dataset (no maximum-dimensions record, or one equal to the current shape).
5197 ///
5198 /// Together with [`is_chunked`](Self::is_chunked) and
5199 /// [`chunk_shape`](Self::chunk_shape), this lets a caller check up front
5200 /// whether a dataset is eligible for
5201 /// [`Dataset::append_staged`](crate::Dataset::append_staged)
5202 /// (which requires a chunked dataset whose first maximum dimension is
5203 /// `u64::MAX`) instead of relying on the append's refusal error.
5204 pub fn maxshape(&self) -> Result<Option<Vec<u64>>, Error> {
5205 if let Some(meta) = self.staged_meta()? {
5206 return Ok(meta.maxshape);
5207 }
5208 let ds = self.dataspace()?;
5209 match &ds.max_dimensions {
5210 Some(md) if *md != ds.dimensions => Ok(Some(md.clone())),
5211 _ => Ok(None),
5212 }
5213 }
5214
5215 /// Whether the dataset uses chunked storage (as opposed to contiguous or
5216 /// compact). Filtered datasets are always chunked. Returns `false` for a
5217 /// dataset with no data-layout message, for a non-chunked layout, and — like
5218 /// the other accessors that return no `Result` — for a handle that can no
5219 /// longer be resolved. A dataset this session has staged and not yet
5220 /// committed answers with the storage its builder selected.
5221 pub fn is_chunked(&self) -> bool {
5222 if let Ok(Some(meta)) = self.staged_meta() {
5223 return meta.chunked;
5224 }
5225 matches!(self.data_layout(), Ok(DataLayout::Chunked { .. }))
5226 }
5227
5228 /// The dataset's chunk dimensions (one per dataset rank), or `Ok(None)` when
5229 /// the dataset is not chunked. The element-size dimension the on-disk layout
5230 /// appends is stripped, so the result lines up with
5231 /// [`shape`](Self::shape) / [`maxshape`](Self::maxshape).
5232 pub fn chunk_shape(&self) -> Result<Option<Vec<u64>>, Error> {
5233 let DataLayout::Chunked {
5234 chunk_dimensions, ..
5235 } = self.data_layout()?
5236 else {
5237 return Ok(None);
5238 };
5239 let rank = self.dataspace()?.dimensions.len();
5240 if chunk_dimensions.len() <= rank {
5241 return Ok(None);
5242 }
5243 Ok(Some(
5244 chunk_dimensions[..rank]
5245 .iter()
5246 .map(|&c| u64::from(c))
5247 .collect(),
5248 ))
5249 }
5250
5251 /// The HDF5 filter IDs applied to this dataset's chunks, in pipeline
5252 /// (application) order, or an empty vector when the dataset is unfiltered.
5253 /// The IDs are the registered HDF5 filter numbers — e.g. 1 = deflate,
5254 /// 2 = shuffle, 3 = fletcher32, 6 = scale-offset — so a caller can inspect
5255 /// the pipeline without decoding a chunk. A dataset this session has staged
5256 /// and not yet committed reports the pipeline its builder asked for.
5257 pub fn filters(&self) -> Vec<u16> {
5258 if let Ok(Some(meta)) = self.staged_meta() {
5259 return meta.filters.into_iter().map(|(id, _)| id).collect();
5260 }
5261 self.filter_pipeline_parsed()
5262 .map(|p| p.filters.iter().map(|f| f.filter_id).collect())
5263 .unwrap_or_default()
5264 }
5265
5266 /// How and where this dataset's raw data is stored: compact, contiguous,
5267 /// chunked, or virtual.
5268 ///
5269 /// The structured companion to [`is_chunked`](Self::is_chunked) and
5270 /// [`chunk_shape`](Self::chunk_shape), which it subsumes: one call that
5271 /// classifies the layout and, for a [`Layout::Contiguous`] dataset, gives the
5272 /// absolute address and byte size to seek to, or for a [`Layout::Chunked`]
5273 /// dataset the chunk shape and [`ChunkIndex`] kind. This parses only the
5274 /// data-layout message; it never walks the chunk index or reads any data —
5275 /// use [`chunks`](Self::chunks) for per-chunk locations. The curated analogue
5276 /// of `H5Pget_layout`.
5277 ///
5278 /// Returns `Err` if the dataset has no data-layout message, if it cannot be
5279 /// parsed, or if a chunked dataset uses an index kind this crate does not
5280 /// recognize.
5281 pub fn layout(&self) -> Result<Layout, Error> {
5282 Ok(match self.data_layout()? {
5283 DataLayout::Compact { data } => Layout::Compact {
5284 size: data.len() as u64,
5285 },
5286 DataLayout::Contiguous { address, size } => Layout::Contiguous {
5287 address: self.absolute_address(address)?,
5288 size,
5289 },
5290 DataLayout::Chunked {
5291 version,
5292 chunk_index_type,
5293 ..
5294 } => Layout::Chunked {
5295 // Reuse `chunk_shape` so the two accessors can never disagree on
5296 // how the element-size dimension is stripped.
5297 chunk_shape: self.chunk_shape()?.unwrap_or_default(),
5298 index: ChunkIndex::from_layout(version, chunk_index_type)?,
5299 },
5300 DataLayout::Virtual { .. } => Layout::Virtual,
5301 })
5302 }
5303
5304 /// The [`ChunkIndex`] kind of this chunked dataset, or `Ok(None)` when the
5305 /// dataset is not chunked.
5306 ///
5307 /// A convenience shortcut for the `index` of [`Layout::Chunked`], for the
5308 /// common up-front append-eligibility check
5309 /// ([`ChunkIndex::supports_inplace_append`]). Complements
5310 /// [`maxshape`](Self::maxshape) and [`chunk_shape`](Self::chunk_shape).
5311 ///
5312 /// Returns `Err` if the data-layout message is missing or cannot be parsed,
5313 /// or if a chunked dataset uses an index kind this crate does not recognize.
5314 pub fn chunk_index(&self) -> Result<Option<ChunkIndex>, Error> {
5315 match self.data_layout()? {
5316 DataLayout::Chunked {
5317 version,
5318 chunk_index_type,
5319 ..
5320 } => Ok(Some(ChunkIndex::from_layout(version, chunk_index_type)?)),
5321 _ => Ok(None),
5322 }
5323 }
5324
5325 /// Enumerate every allocated chunk of this chunked dataset — one [`Chunk`]
5326 /// (logical offset, absolute file address, on-disk stored size, filter mask)
5327 /// per chunk, in index order.
5328 ///
5329 /// This reads only the chunk index, not the chunk data, so a caller can seek
5330 /// to and decode chunks one at a time without materializing the whole
5331 /// dataset. The curated analogue of `H5Dget_num_chunks` + `H5Dget_chunk_info`
5332 /// (`chunks()?.len()` is the chunk count).
5333 ///
5334 /// Returns `Ok(vec![])` for a chunked dataset whose storage has not been
5335 /// allocated yet (including a not-yet-written dataset that will use a
5336 /// [`ChunkIndex::BTreeV2`] index). Returns `Err` if the dataset is not chunked
5337 /// (check [`layout`](Self::layout) or [`is_chunked`](Self::is_chunked) first),
5338 /// or if its allocated storage is indexed by a [`ChunkIndex::BTreeV2`] index,
5339 /// which has no enumerator yet.
5340 pub fn chunks(&self) -> Result<Vec<Chunk>, Error> {
5341 let rank = self.dataspace()?.dimensions.len();
5342 Ok(self
5343 .raw_chunks()?
5344 .into_iter()
5345 .map(|c| Chunk {
5346 offset: c.offsets.into_iter().take(rank).collect(),
5347 address: c.address,
5348 storage_size: u64::from(c.chunk_size),
5349 filter_mask: c.filter_mask,
5350 })
5351 .collect())
5352 }
5353
5354 /// This dataset's filter pipeline as an ordered list of [`Filter`]s — each
5355 /// with its identifier, optional name, optional/mandatory flag, and client
5356 /// data — or an empty vector when the dataset is unfiltered.
5357 ///
5358 /// The detailed companion to [`filters`](Self::filters), which returns just
5359 /// the identifiers. Filters are listed in application (write) order — the
5360 /// on-disk pipeline order, matching [`filters`](Self::filters); a reader
5361 /// inverts them in the *reverse* of this order to decode a chunk. The curated
5362 /// analogue of `H5Pget_nfilters` + `H5Pget_filter2`.
5363 ///
5364 /// A dataset this session has staged and not yet committed reports each
5365 /// filter's identifier and optional flag, and no name or client data: those
5366 /// are derived from the dataset being written (element size, chunk geometry,
5367 /// fill value) when [`File::commit`] writes it.
5368 pub fn filter_pipeline(&self) -> Vec<Filter> {
5369 if let Ok(Some(meta)) = self.staged_meta() {
5370 return meta
5371 .filters
5372 .into_iter()
5373 .map(|(id, is_optional)| Filter {
5374 id,
5375 is_optional,
5376 name: None,
5377 client_data: Vec::new(),
5378 })
5379 .collect();
5380 }
5381 self.filter_pipeline_parsed()
5382 .map(|p| {
5383 p.filters
5384 .into_iter()
5385 .map(|f| Filter {
5386 id: f.filter_id,
5387 is_optional: f.is_optional(),
5388 name: f.name,
5389 client_data: f.client_data,
5390 })
5391 .collect()
5392 })
5393 .unwrap_or_default()
5394 }
5395
5396 /// Shift a base-relative on-disk address to an absolute file offset using the
5397 /// superblock base address (`addr_offset`). A no-op for the common
5398 /// base-zero file. Returns `Ok(None)` for an unallocated (undefined) address.
5399 fn absolute_address(&self, address: Option<u64>) -> Result<Option<u64>, Error> {
5400 match address {
5401 Some(rel) => Ok(Some(self.file.addr_offset.absolute(rel)?)),
5402 None => Ok(None),
5403 }
5404 }
5405
5406 /// Returns the simplified datatype of the dataset.
5407 ///
5408 /// A dataset this session has staged and not yet committed answers with the
5409 /// datatype its builder settled on.
5410 pub fn dtype(&self) -> Result<DType, Error> {
5411 let dt = self.datatype()?;
5412 Ok(classify_datatype(&dt))
5413 }
5414
5415 /// The size in bytes of one on-disk element of this dataset's datatype —
5416 /// HDF5's datatype storage size (`H5Tget_size`).
5417 ///
5418 /// This is the byte width of a single stored element: 8 for `f64`, the
5419 /// declared length for a fixed-length string, the record size for a compound
5420 /// type, or the reference/descriptor size for a variable-length type (whose
5421 /// payload lives separately in the file's global heaps).
5422 ///
5423 /// Multiplied by the element count from [`shape`](Self::shape), it is the
5424 /// exact number of raw bytes a full [`read_raw`](Self::read_raw)
5425 /// materializes. A caller reading an untrusted file can use it to bound that
5426 /// allocation up front rather than trusting the file's declared extent: a
5427 /// dataset can name a small element count yet a per-element size of billions
5428 /// of bytes, so the product — not the count alone — is what a read allocates.
5429 pub fn element_size(&self) -> Result<u64, Error> {
5430 Ok(u64::from(self.datatype()?.type_size()))
5431 }
5432
5433 /// The raw bytes of this dataset's user-defined fill value, encoded in its
5434 /// datatype, or `None` when no user-defined fill value is set (the library
5435 /// default or an explicitly undefined fill). Reads whichever Fill Value
5436 /// message the header carries — the current `0x0005` (versions 1/2/3) or the
5437 /// legacy `0x0004` — so files from this crate, the reference C library, and
5438 /// h5py are all handled.
5439 pub(crate) fn defined_fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5440 let state = self.resolved()?;
5441 let msg = state
5442 .header
5443 .messages
5444 .iter()
5445 .find(|m| m.msg_type == MessageType::FillValue)
5446 .or_else(|| {
5447 state
5448 .header
5449 .messages
5450 .iter()
5451 .find(|m| m.msg_type == MessageType::FillValueOld)
5452 });
5453 match msg {
5454 Some(m) => Ok(crate::fill_value::parse_defined_fill_value(
5455 m.msg_type,
5456 &self.file.message_body(m)?,
5457 )?),
5458 None => Ok(None),
5459 }
5460 }
5461
5462 /// The fill bytes that *unallocated storage reads as* — which is not the
5463 /// same question [`defined_fill_bytes`](Self::defined_fill_bytes) answers.
5464 ///
5465 /// A dataset may declare a fill value and also declare, through the Fill
5466 /// Value Write Time, that the library never writes it
5467 /// (`H5D_FILL_TIME_NEVER`). Its unallocated storage then has no defined
5468 /// contents — the C library leaves the read buffer untouched — so this
5469 /// returns `None` and the region reads as deterministic zeros rather than as
5470 /// a value nothing ever put there. `fill_value` still reports the declared
5471 /// value, because it *is* declared; see [`fill_value_is_written`].
5472 fn fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5473 let state = self.resolved()?;
5474 let msg = state
5475 .header
5476 .messages
5477 .iter()
5478 .find(|m| m.msg_type == MessageType::FillValue)
5479 .or_else(|| {
5480 state
5481 .header
5482 .messages
5483 .iter()
5484 .find(|m| m.msg_type == MessageType::FillValueOld)
5485 });
5486 let Some(m) = msg else {
5487 return Ok(None);
5488 };
5489 let body = self.file.message_body(m)?;
5490 if !crate::fill_value::fill_value_is_written(m.msg_type, &body)? {
5491 return Ok(None);
5492 }
5493 Ok(crate::fill_value::parse_defined_fill_value(
5494 m.msg_type, &body,
5495 )?)
5496 }
5497
5498 /// Read the whole dataset with `decode`, sweeping it a row window at a time.
5499 ///
5500 /// A typed whole-dataset read used to be [`read_raw`](Self::read_raw)
5501 /// followed by a decode of the entire buffer, which held the stored bytes
5502 /// and the decoded values at the same time and so peaked at twice the
5503 /// dataset — a caller reading a 4 GiB array needed 8 GiB (issue #289).
5504 /// Decoding a window at a time leaves one window of stored bytes beside the
5505 /// output instead of a whole second copy of it, and the bytes are identical
5506 /// either way: a window returns exactly the rows [`read_raw`](Self::read_raw)
5507 /// would have put there.
5508 ///
5509 /// The output buffer is reserved once, at the size the whole dataset decodes
5510 /// to, so no window reallocates it — a growth step would put a second copy of
5511 /// the output alongside the first and give back what the windowing saved.
5512 ///
5513 /// A dataset that fits in one window — including one with no rows at all — is
5514 /// read whole. The empty case still runs `decode`, because a decoder is also
5515 /// what reports a datatype it cannot read, and a zero-element string dataset
5516 /// must go on failing a numeric read rather than answering with an empty
5517 /// vector.
5518 fn read_whole_typed<T, F>(&self, out_size: OutputSize, decode: F) -> Result<Vec<T>, Error>
5519 where
5520 F: Fn(&[u8], &Datatype, &mut Vec<T>) -> Result<(), FormatError>,
5521 {
5522 let dt = self.datatype()?;
5523 let ds = self.dataspace()?;
5524 let dl = self.read_layout()?;
5525 let pipeline = self.filter_pipeline_parsed();
5526 // See `read_raw`: an unparseable fill value message is carried into the
5527 // read rather than failing it up front.
5528 let fill_bytes = self.fill_bytes();
5529 let elem_size = dt.element_size_usize()?;
5530 let fill = match &fill_bytes {
5531 Ok(b) => FillPattern::new(b.as_deref(), elem_size),
5532 Err(_) => FillPattern::UNKNOWN,
5533 };
5534 let spec = RawReadSpec {
5535 layout: &dl,
5536 dataspace: &ds,
5537 datatype: &dt,
5538 pipeline: pipeline.as_ref(),
5539 fill,
5540 };
5541
5542 // What a whole read checks before it reads a byte, and what a sweep would
5543 // otherwise skip: a compact or contiguous layout whose declared size
5544 // disagrees with the dataspace is refused. Reading in windows must not
5545 // turn that into a check that fires only on datasets small enough to be
5546 // read whole.
5547 let stored = spec.stored_byte_len()?;
5548
5549 // A window is cut by the *stored* element width, while a decoder slices
5550 // what it is handed by the width of the type it decodes — the base type,
5551 // for an enumeration. Those are the same width for every valid file, an
5552 // enumeration's size being its base's. A crafted file where they differ
5553 // must not get one verdict from a sweep and another from a whole read, so
5554 // it is read whole.
5555 let decoded_width = data_read::effective_numeric(&dt).type_size();
5556
5557 let mut out = Vec::new();
5558 let n0 = ds.dimensions.first().copied().unwrap_or(1);
5559 let rows = typed_window_rows(&dl, &ds, elem_size)?.get();
5560 if n0 <= rows || decoded_width != dt.type_size() {
5561 // No reservation here: `decode` sizes the output from the bytes it
5562 // was handed, which is exact.
5563 let raw = self.file.read_dataset_raw(spec, &self.chunk_cache)?;
5564 decode(&raw, &dt, &mut out)?;
5565 return Ok(out);
5566 }
5567
5568 let values = match out_size {
5569 OutputSize::PerElement => ds.num_elements().to_usize()?,
5570 OutputSize::PerByte => stored,
5571 };
5572
5573 // One pass for the whole sweep, not one per window: the sweep visits each
5574 // chunk exactly once, so a window that offered its chunks to a cache
5575 // already full would copy and evict with no later reader for either. The
5576 // cache ends up holding what a whole read would have left it — the
5577 // chunks reached first. See [`CachePass`].
5578 let pass = self.chunk_cache.begin_pass();
5579 let mut start = 0;
5580 while start < n0 {
5581 let count = rows.min(n0 - start);
5582 let raw =
5583 self.file
5584 .read_dataset_raw_rows(spec, &self.chunk_cache, pass, start, count)?;
5585 if start == 0 {
5586 // Reserved once, and only after a window has come back. This size
5587 // comes from the file: sizing an allocation from it before
5588 // reading anything lets a dataspace claiming a terabyte ask for a
5589 // terabyte over a file that cannot serve one row. `try_reserve`
5590 // for the same reason — a file-derived capacity that cannot be
5591 // had is an answer this reader owes its caller, not a panic.
5592 out.try_reserve(values)
5593 .map_err(|_| FormatError::ValueTooLargeForPlatform {
5594 value: values as u64,
5595 target: "one allocation",
5596 })?;
5597 }
5598 decode(&raw, &dt, &mut out)?;
5599 start += count;
5600 }
5601 Ok(out)
5602 }
5603
5604 /// Read all data as `f64` values.
5605 ///
5606 /// This and the other typed whole-dataset readers decode a row window at a
5607 /// time, so the memory standing beside the returned `Vec` is one window of
5608 /// stored bytes — on the order of a mebibyte — rather than a second copy of
5609 /// the dataset. Reading a 4 GiB array costs about 4 GiB, not 8.
5610 ///
5611 /// The values are what [`read_raw`](Self::read_raw) returns, decoded: a
5612 /// dataset stored as a narrower or wider type is converted, so pick the
5613 /// reader that matches the stored type for a lossless read.
5614 pub fn read_f64(&self) -> Result<Vec<f64>, Error> {
5615 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_f64_into)
5616 }
5617
5618 /// Read all data as `f32` values.
5619 pub fn read_f32(&self) -> Result<Vec<f32>, Error> {
5620 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_f32_into)
5621 }
5622
5623 /// Read all data as `i32` values.
5624 pub fn read_i32(&self) -> Result<Vec<i32>, Error> {
5625 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_i32_into)
5626 }
5627
5628 /// Read all data as `i64` values.
5629 pub fn read_i64(&self) -> Result<Vec<i64>, Error> {
5630 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_i64_into)
5631 }
5632
5633 /// Read all data as `u64` values.
5634 pub fn read_u64(&self) -> Result<Vec<u64>, Error> {
5635 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_u64_into)
5636 }
5637
5638 /// Read all data as `u8` values.
5639 pub fn read_u8(&self) -> Result<Vec<u8>, Error> {
5640 self.read_raw()
5641 }
5642
5643 /// Read all data as `i8` values.
5644 pub fn read_i8(&self) -> Result<Vec<i8>, Error> {
5645 self.read_whole_typed(OutputSize::PerByte, |raw, _dt, out| {
5646 #[expect(
5647 clippy::cast_possible_wrap,
5648 reason = "read_i8 reinterprets each stored byte as the signed i8 the caller requested"
5649 )]
5650 out.extend(raw.iter().map(|&b| b as i8));
5651 Ok(())
5652 })
5653 }
5654
5655 /// Read all data as `i16` values.
5656 pub fn read_i16(&self) -> Result<Vec<i16>, Error> {
5657 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_i16_into)
5658 }
5659
5660 /// Read all data as `u16` values.
5661 pub fn read_u16(&self) -> Result<Vec<u16>, Error> {
5662 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_u16_into)
5663 }
5664
5665 /// Read all data as `u32` values.
5666 pub fn read_u32(&self) -> Result<Vec<u32>, Error> {
5667 self.read_whole_typed(OutputSize::PerElement, data_read::read_as_u32_into)
5668 }
5669
5670 /// Read all data as `String` values.
5671 ///
5672 /// Fixed-length and variable-length HDF5 string datasets are both
5673 /// supported. Use [`read_vlen_strings`](Self::read_vlen_strings) when
5674 /// variable-length allocation limits are required.
5675 pub fn read_string(&self) -> Result<Vec<String>, Error> {
5676 let dt = self.datatype()?;
5677 if vl_data::is_vlen_string_datatype(&dt) {
5678 self.read_vlen_strings(VlenStringReadOptions::default())
5679 } else {
5680 let raw = self.read_raw()?;
5681 Ok(data_read::read_as_strings(&raw, &dt)?)
5682 }
5683 }
5684
5685 /// Return the total bytes referenced by this VL string dataset.
5686 ///
5687 /// This is the payload equivalent of HDF5's `H5Dvlen_get_buf_size`: it
5688 /// excludes `Vec<String>` and `String` allocation metadata.
5689 pub fn vlen_string_payload_size(&self) -> Result<u64, Error> {
5690 let datatype = self.datatype()?;
5691 if !vl_data::is_vlen_string_datatype(&datatype) {
5692 return Err(FormatError::TypeMismatch {
5693 expected: "VariableLength string",
5694 actual: "non-VariableLength string",
5695 }
5696 .into());
5697 }
5698 let dataspace = self.dataspace()?;
5699 let raw = self.read_raw()?;
5700 Ok(vl_data::vlen_string_payload_size(
5701 &raw,
5702 dataspace.num_elements(),
5703 self.file.offset_size(),
5704 )?)
5705 }
5706
5707 /// Read a VL string dataset with explicit allocation limits.
5708 ///
5709 /// Both limits are checked before any string payload is materialized.
5710 pub fn read_vlen_strings(&self, options: VlenStringReadOptions) -> Result<Vec<String>, Error> {
5711 let mut strings = Vec::new();
5712 self.visit_vlen_strings(options, |string| strings.push(string.to_owned()))?;
5713 Ok(strings)
5714 }
5715
5716 /// Visit a VL string dataset one element at a time.
5717 ///
5718 /// The string slice passed to `visitor` is valid only for the duration of
5719 /// that callback. This avoids retaining all decoded string payloads at once.
5720 ///
5721 /// On a read-write file ([`File::open_rw`]) the
5722 /// visitor runs while the file's engine lock is held, so it must not read
5723 /// or write through this file (or a clone / handle of it) — doing so
5724 /// deadlocks. Collect values and act on them after the call instead.
5725 pub fn visit_vlen_strings<F>(
5726 &self,
5727 options: VlenStringReadOptions,
5728 visitor: F,
5729 ) -> Result<(), Error>
5730 where
5731 F: FnMut(&str),
5732 {
5733 let datatype = self.datatype()?;
5734 if !vl_data::is_vlen_string_datatype(&datatype) {
5735 return Err(FormatError::TypeMismatch {
5736 expected: "VariableLength string",
5737 actual: "non-VariableLength string",
5738 }
5739 .into());
5740 }
5741 let dataspace = self.dataspace()?;
5742 if let Some(limit) = options.max_elements()
5743 && dataspace.num_elements() > limit as u64
5744 {
5745 return Err(FormatError::VariableLengthElementLimitExceeded {
5746 limit,
5747 actual: dataspace.num_elements(),
5748 }
5749 .into());
5750 }
5751 let raw = self.read_raw()?;
5752 self.file.with_source(|source| {
5753 Ok(vl_data::visit_vl_strings_from_source(
5754 source,
5755 &raw,
5756 dataspace.num_elements(),
5757 self.file.offset_size(),
5758 self.file.length_size(),
5759 self.file.addr_offset,
5760 options,
5761 visitor,
5762 )?)
5763 })
5764 }
5765
5766 /// Read a VL string dataset's exact heap bytes, preserving the
5767 /// null-vs-empty distinction and never lossily decoding.
5768 ///
5769 /// Unlike [`read_vlen_strings`](Self::read_vlen_strings), which returns
5770 /// `String`s via `from_utf8_lossy` and so cannot reproduce embedded NULs or
5771 /// non-UTF-8 payloads, this yields each element's raw bytes (or a null
5772 /// marker). It underpins faithful rewriting (e.g. repack) of VL strings.
5773 pub(crate) fn read_vlen_string_bytes(
5774 &self,
5775 options: VlenStringReadOptions,
5776 ) -> Result<Vec<vl_data::VlByteObject>, Error> {
5777 let datatype = self.datatype()?;
5778 if !vl_data::is_vlen_string_datatype(&datatype) {
5779 return Err(FormatError::TypeMismatch {
5780 expected: "VariableLength string",
5781 actual: "non-VariableLength string",
5782 }
5783 .into());
5784 }
5785 let dataspace = self.dataspace()?;
5786 if let Some(limit) = options.max_elements()
5787 && dataspace.num_elements() > limit as u64
5788 {
5789 return Err(FormatError::VariableLengthElementLimitExceeded {
5790 limit,
5791 actual: dataspace.num_elements(),
5792 }
5793 .into());
5794 }
5795 let raw = self.read_raw()?;
5796 self.file.with_source(|source| {
5797 Ok(vl_data::read_vl_byte_objects_from_source(
5798 source,
5799 &raw,
5800 dataspace.num_elements(),
5801 self.file.offset_size(),
5802 self.file.length_size(),
5803 self.file.addr_offset,
5804 1, // a VL string's base type is a single byte
5805 options,
5806 )?)
5807 })
5808 }
5809
5810 /// Read every element of a *non-string* variable-length (sequence) dataset as
5811 /// its exact heap bytes, alongside the base-type element size in bytes.
5812 ///
5813 /// Each element's heap object holds `length * element_size` bytes, where
5814 /// `length` is the stored element count and `element_size` is the byte width
5815 /// of the sequence's base type. Returning the raw bytes (not decoded values)
5816 /// keeps a faithful rewrite (repack) byte-exact for any base type whose bytes
5817 /// carry no embedded heap or file addresses. Errors with a
5818 /// [`TypeMismatch`](crate::FormatError::TypeMismatch) if the datatype is not a
5819 /// non-string VL datatype.
5820 pub(crate) fn read_vlen_sequence_bytes(
5821 &self,
5822 options: VlenStringReadOptions,
5823 ) -> Result<(Vec<vl_data::VlByteObject>, usize), Error> {
5824 let datatype = self.datatype()?;
5825 let Datatype::VariableLength { base_type, .. } = &datatype else {
5826 return Err(FormatError::TypeMismatch {
5827 expected: "non-string VariableLength",
5828 actual: "non-VariableLength",
5829 }
5830 .into());
5831 };
5832 if vl_data::is_vlen_string_datatype(&datatype) {
5833 return Err(FormatError::TypeMismatch {
5834 expected: "non-string VariableLength",
5835 actual: "VariableLength string",
5836 }
5837 .into());
5838 }
5839 let element_size = base_type.type_size() as usize;
5840 if element_size == 0 {
5841 return Err(
5842 FormatError::VlDataError("non-string VL base type has zero size".into()).into(),
5843 );
5844 }
5845 let dataspace = self.dataspace()?;
5846 if let Some(limit) = options.max_elements()
5847 && dataspace.num_elements() > limit as u64
5848 {
5849 return Err(FormatError::VariableLengthElementLimitExceeded {
5850 limit,
5851 actual: dataspace.num_elements(),
5852 }
5853 .into());
5854 }
5855 let raw = self.read_raw()?;
5856 let objects = self.file.with_source(|source| {
5857 vl_data::read_vl_byte_objects_from_source(
5858 source,
5859 &raw,
5860 dataspace.num_elements(),
5861 self.file.offset_size(),
5862 self.file.length_size(),
5863 self.file.addr_offset,
5864 element_size,
5865 options,
5866 )
5867 })?;
5868 Ok((objects, element_size))
5869 }
5870
5871 /// Read a dataset whose datatype *contains* variable-length references
5872 /// without being variable-length itself — a compound with a VL member, or an
5873 /// array of them (issue #201).
5874 ///
5875 /// Returns everything a rewrite needs: the element bytes, where each embedded
5876 /// reference sits within them, and the heap payload each one names. That lets
5877 /// the writer re-stage the payloads into a new file's global heap and rewrite
5878 /// the references in place, which is what keeps a rewrite from carrying the
5879 /// source file's heap addresses into the destination.
5880 ///
5881 /// The references are resolved one slot at a time, so `options`' limits apply
5882 /// per slot rather than across the whole dataset.
5883 pub(crate) fn read_embedded_vlen_bytes(
5884 &self,
5885 slots: &[vl_data::EmbeddedVlSlot],
5886 options: VlenStringReadOptions,
5887 ) -> Result<vl_data::EmbeddedVlData, Error> {
5888 let stride = self.datatype()?.type_size() as usize;
5889 let dataspace = self.dataspace()?;
5890 let n = dataspace.num_elements();
5891 if let Some(limit) = options.max_elements()
5892 && n > limit as u64
5893 {
5894 return Err(
5895 FormatError::VariableLengthElementLimitExceeded { limit, actual: n }.into(),
5896 );
5897 }
5898
5899 // A zero-element dataset owns no element bytes, so there is no storage
5900 // to visit: skip the read rather than open a dataset the C library
5901 // left unallocated only to receive the same empty buffer back.
5902 let raw = if n == 0 { Vec::new() } else { self.read_raw()? };
5903 let n_usize = n.to_usize()?;
5904 let needed = n_usize
5905 .checked_mul(stride)
5906 .ok_or(FormatError::OffsetOverflow {
5907 offset: n,
5908 length: stride as u64,
5909 })?;
5910 if raw.len() < needed {
5911 return Err(FormatError::UnexpectedEof {
5912 expected: needed,
5913 available: raw.len(),
5914 }
5915 .into());
5916 }
5917
5918 let mut offsets = Vec::with_capacity(n_usize * slots.len());
5919 let mut objects = Vec::with_capacity(n_usize * slots.len());
5920 for slot in slots {
5921 // Gather this slot's reference from every element into a dense buffer,
5922 // which is the shape the shared VL reader consumes. Each slot has its
5923 // own base-type width, so they are resolved a slot at a time rather
5924 // than in one pass.
5925 let mut dense = Vec::with_capacity(n_usize * VL_REF_SIZE);
5926 for e in 0..n_usize {
5927 let at = e * stride + slot.byte_offset;
5928 dense.extend_from_slice(&raw[at..at + VL_REF_SIZE]);
5929 offsets.push(at);
5930 }
5931 let resolved = self.file.with_source(|source| {
5932 vl_data::read_vl_byte_objects_from_source(
5933 source,
5934 &dense,
5935 n,
5936 self.file.offset_size(),
5937 self.file.length_size(),
5938 self.file.addr_offset,
5939 slot.element_size,
5940 options,
5941 )
5942 })?;
5943 objects.extend(resolved);
5944 }
5945 Ok(vl_data::EmbeddedVlData {
5946 raw,
5947 offsets,
5948 objects,
5949 })
5950 }
5951
5952 /// Read all attributes of this dataset.
5953 ///
5954 /// The variant of each value describes its on-disk encoding; see
5955 /// [`Group::attrs`] for what that means for matching on it, and prefer the
5956 /// [`AttrValue`] accessors.
5957 pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
5958 self.file.attrs_of(&self.resolved()?.header)
5959 }
5960
5961 /// The exact on-disk [`Datatype`] of every attribute on this dataset, keyed
5962 /// by name.
5963 ///
5964 /// See [`Group::attr_datatypes`] for what this channel carries that
5965 /// [`attrs`](Self::attrs) cannot, including how a boolean attribute is
5966 /// recognized. Note that this describes the *attributes*, not the dataset's
5967 /// own element type — that is [`datatype`](Self::datatype).
5968 pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
5969 Ok(self
5970 .attr_messages()?
5971 .into_iter()
5972 .map(|a| (a.name, a.datatype))
5973 .collect())
5974 }
5975
5976 /// Every attribute message on this dataset as it is encoded on disk, in the
5977 /// order the header holds them.
5978 ///
5979 /// See [`Group::attr_messages`] for why repack reads these rather than the
5980 /// decoded map.
5981 pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
5982 self.file.attr_messages_of(&self.resolved()?.header)
5983 }
5984
5985 /// Returns the exact HDF5 datatype, including compound field offsets and
5986 /// total record size.
5987 ///
5988 /// A committed (`H5Tcommit`) element type — what netCDF-4 writes for a
5989 /// user-defined type, and what h5py writes for
5990 /// `create_dataset(..., dtype=f["t"])` — is stored as a reference to the
5991 /// datatype's own object header and is resolved to the type it names.
5992 pub fn datatype(&self) -> Result<Datatype, Error> {
5993 if let Some(meta) = self.staged_meta()? {
5994 return Ok(meta.datatype);
5995 }
5996 let state = self.resolved()?;
5997 let msg = find_message(&state.header, MessageType::Datatype)?;
5998 let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
5999 Ok(dt)
6000 }
6001
6002 /// The object-header address of this dataset's committed (shared) element
6003 /// type, or `None` when the type is written in the dataset's own header.
6004 ///
6005 /// [`datatype`](Self::datatype) resolves it either way, so this is for
6006 /// callers that must *reproduce* the dataset: writing the resolved type back
6007 /// inline loses the link every C-library reader reports by name, and the
6008 /// address is what says which committed object to name instead.
6009 pub(crate) fn committed_datatype_address(&self) -> Result<Option<u64>, Error> {
6010 let state = self.resolved()?;
6011 let msg = find_message(&state.header, MessageType::Datatype)?;
6012 self.file.shared_target_address(msg)
6013 }
6014
6015 pub(crate) fn dataspace(&self) -> Result<Dataspace, Error> {
6016 let state = self.resolved()?;
6017 let msg = find_message(&state.header, MessageType::Dataspace)?;
6018 Ok(Dataspace::parse(
6019 &self.file.message_body(msg)?,
6020 self.file.length_size(),
6021 )?)
6022 }
6023
6024 pub(crate) fn data_layout(&self) -> Result<DataLayout, Error> {
6025 let state = self.resolved()?;
6026 let msg = find_message(&state.header, MessageType::DataLayout)?;
6027 Ok(DataLayout::parse(
6028 &msg.data,
6029 self.file.offset_size(),
6030 self.file.length_size(),
6031 )?)
6032 }
6033
6034 /// A handle that can no longer be resolved reports no pipeline, the same
6035 /// answer an unfiltered dataset gives: this feeds the two infallible
6036 /// accessors ([`filters`](Self::filters) and
6037 /// [`filter_pipeline`](Self::filter_pipeline)), which have no way to say
6038 /// why. Every caller that *can* say uses a `Result` reader instead.
6039 pub(crate) fn filter_pipeline_parsed(&self) -> Option<FilterPipeline> {
6040 let state = self.resolved().ok()?;
6041 let msg = state
6042 .header
6043 .messages
6044 .iter()
6045 .find(|m| m.msg_type == MessageType::FilterPipeline)?;
6046 let body = self.file.message_body(msg).ok()?;
6047 FilterPipeline::parse(&body).ok()
6048 }
6049
6050 /// Whether this dataset's element bytes live in files outside this one
6051 /// (`H5Pset_external`, the External Data Files header message, type 7).
6052 ///
6053 /// Such a dataset carries a *contiguous* layout message whose data address
6054 /// is undefined — the same encoding a never-written dataset uses — so a
6055 /// caller that reads "no address" as "no storage" would call a dataset full
6056 /// of data empty. This crate does not follow the external files, so the only
6057 /// safe answer is to refuse: [`read_layout`](Self::read_layout) refuses a
6058 /// read of one rather than answering its fill value, and `repack` refuses to
6059 /// reproduce it without its data.
6060 pub(crate) fn has_external_storage(&self) -> Result<bool, Error> {
6061 Ok(self
6062 .resolved()?
6063 .header
6064 .messages
6065 .iter()
6066 .any(|m| m.msg_type == MessageType::ExternalDataFiles))
6067 }
6068
6069 /// The data layout to read element bytes through, as opposed to the one
6070 /// [`layout`](Self::layout) reports.
6071 ///
6072 /// [`data_layout`](Self::data_layout) answers what the message records;
6073 /// this answers whether those bytes are reachable at all. The two differ for
6074 /// exactly one kind of dataset: an externally stored one, whose contiguous layout
6075 /// with no address is also what a never-written dataset carries, so reading
6076 /// it would answer the fill value for every element it holds. Introspection
6077 /// keeps answering — the address-less layout is the evidence a caller needs
6078 /// — while every path that turns a layout into bytes comes through here and
6079 /// refuses.
6080 fn read_layout(&self) -> Result<DataLayout, Error> {
6081 if self.has_external_storage()? {
6082 return Err(FormatError::UnsupportedExternalStorage.into());
6083 }
6084 self.data_layout()
6085 }
6086
6087 /// The raw, still-compressed on-disk bytes of every allocated chunk of this
6088 /// chunked dataset, with each chunk's `(address, on-disk size, filter mask,
6089 /// logical offset)` — the same `ChunkInfo`s the chunked reader walks before
6090 /// decompressing. Used by repack to copy compressed chunks verbatim without
6091 /// ever decoding them.
6092 ///
6093 /// Returns `Err` if the layout is not chunked. Returns `Ok(vec![])` for an
6094 /// empty / never-allocated chunked dataset (no index address). Covers every
6095 /// index type the reader supports (v3 B-tree and v4 single-chunk, implicit,
6096 /// fixed-array, and extensible-array).
6097 pub(crate) fn raw_chunks(&self) -> Result<Vec<crate::chunked_read::ChunkInfo>, Error> {
6098 let DataLayout::Chunked {
6099 chunk_dimensions,
6100 btree_address,
6101 version,
6102 chunk_index_type,
6103 single_chunk_filtered_size,
6104 single_chunk_filter_mask,
6105 } = self.data_layout()?
6106 else {
6107 return Err(Error::Format(crate::error::FormatError::ChunkedReadError(
6108 "chunk enumeration requires a chunked dataset".into(),
6109 )));
6110 };
6111 // An undefined index address means no storage is allocated yet.
6112 let Some(addr) = btree_address else {
6113 return Ok(Vec::new());
6114 };
6115 let dataspace = self.dataspace()?;
6116 let elem_size = self.datatype()?.element_size_usize()?;
6117 let base = self.file.addr_offset;
6118 // The chunk index — its root at `addr` and every internal node — stores
6119 // addresses relative to the base address. Walk it through a base-relative
6120 // view so those resolve, then shift each returned chunk address back to an
6121 // absolute file offset, since callers (repack) read the chunk bytes from
6122 // the full file source.
6123 self.file.with_source(|source| {
6124 if base.is_zero() {
6125 return Ok(crate::chunked_read::collect_chunks_for_layout_from_source(
6126 source,
6127 version,
6128 chunk_index_type,
6129 addr,
6130 single_chunk_filtered_size,
6131 single_chunk_filter_mask,
6132 &chunk_dimensions,
6133 &dataspace,
6134 elem_size,
6135 self.file.offset_size(),
6136 self.file.length_size(),
6137 )?);
6138 }
6139 let framed = BaseOffsetSource {
6140 inner: source,
6141 base,
6142 };
6143 let mut chunks = crate::chunked_read::collect_chunks_for_layout_from_source(
6144 &framed,
6145 version,
6146 chunk_index_type,
6147 addr,
6148 single_chunk_filtered_size,
6149 single_chunk_filter_mask,
6150 &chunk_dimensions,
6151 &dataspace,
6152 elem_size,
6153 self.file.offset_size(),
6154 self.file.length_size(),
6155 )?;
6156 for c in &mut chunks {
6157 c.address = base.absolute(c.address)?;
6158 }
6159 Ok(chunks)
6160 })
6161 }
6162
6163 /// The raw `FilterPipeline` message bytes from this dataset's object header,
6164 /// if it has one. Repack reuses this verbatim so that every filter — including
6165 /// ones this crate cannot itself apply (ZFP, SZIP, unknown) — is reproduced
6166 /// byte-for-byte in the repacked file's pipeline message.
6167 pub(crate) fn filter_pipeline_message_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
6168 let state = self.resolved()?;
6169 let Some(msg) = state
6170 .header
6171 .messages
6172 .iter()
6173 .find(|m| m.msg_type == MessageType::FilterPipeline)
6174 else {
6175 return Ok(None);
6176 };
6177 // A shared pipeline message's record body is an address, not a pipeline;
6178 // its resolved content is what a copy must carry. The content itself is
6179 // position-independent, so copying it verbatim stays faithful.
6180 Ok(Some(self.file.message_body(msg)?.into_owned()))
6181 }
6182
6183 /// Read the dataset's exact unfiltered element bytes.
6184 ///
6185 /// For compound datasets this preserves all file padding and uses the
6186 /// offsets reported by [`datatype`](Self::datatype).
6187 ///
6188 /// A dataset whose elements live in external files (`H5Pset_external`) is
6189 /// refused with `FormatError::UnsupportedExternalStorage` rather than read as
6190 /// unallocated storage, which is what its layout message alone says. This
6191 /// applies to every read here; its shape, datatype, and
6192 /// [`layout`](Self::layout) still read.
6193 pub fn read_raw(&self) -> Result<Vec<u8>, Error> {
6194 let dt = self.datatype()?;
6195 let ds = self.dataspace()?;
6196 let dl = self.read_layout()?;
6197 // The data layout's on-disk addresses are left base-relative here;
6198 // `read_dataset_raw` applies the base address centrally (for both
6199 // contiguous and chunked layouts) by reading from a base-relative view of
6200 // the file.
6201 let pipeline = self.filter_pipeline_parsed();
6202 // A fill value message this parser cannot read does not, by itself,
6203 // make the dataset unreadable: it only decides what *unallocated*
6204 // storage looks like. Carry the uncertainty into the read and let it
6205 // fail there, and only there. `Dataset::fill_value` still reports the
6206 // parse error to a caller asking about the value.
6207 let fill_bytes = self.fill_bytes();
6208 let fill = match &fill_bytes {
6209 Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6210 Err(_) => FillPattern::UNKNOWN,
6211 };
6212 let spec = RawReadSpec {
6213 layout: &dl,
6214 dataspace: &ds,
6215 datatype: &dt,
6216 pipeline: pipeline.as_ref(),
6217 fill,
6218 };
6219 Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?)
6220 }
6221
6222 /// Read the raw element bytes of the row window `[start_row, start_row + num_rows)`
6223 /// — a range along the first dimension.
6224 ///
6225 /// The windowed companion to [`read_raw`](Self::read_raw): only the storage the
6226 /// window overlaps is read — a bounded sub-read for compact and contiguous
6227 /// layouts, just the overlapping chunks for chunked layouts — so peak memory
6228 /// scales with the window, not the dataset. Use it to stream a large dataset a
6229 /// fixed number of rows at a time.
6230 ///
6231 /// Each row keeps its full inner shape, and the bytes match what
6232 /// [`read_raw`](Self::read_raw) produces for those rows, so the typed
6233 /// `read_*_rows` helpers decode a window like their whole-dataset forms. The
6234 /// window is clamped to the first dimension: a read past the end returns only
6235 /// the rows that exist, and a 0-D scalar is one row. A window covering every
6236 /// row delegates to [`read_raw`](Self::read_raw), so a full-range window never
6237 /// costs more than a whole read. Variable-length string
6238 /// bytes are heap references, not text — use
6239 /// [`read_string_rows`](Self::read_string_rows).
6240 pub fn read_raw_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6241 let dt = self.datatype()?;
6242 let ds = self.dataspace()?;
6243 let dl = self.read_layout()?;
6244
6245 let n0 = ds.dimensions.first().copied().unwrap_or(1);
6246 let start = start_row.min(n0);
6247 let count = num_rows.min(n0 - start);
6248
6249 // A window covering every row is exactly a whole read: delegate, so it
6250 // never costs a window-shaped copy on top of one.
6251 // See `read_raw`: an unparseable fill value message is carried into the
6252 // read rather than failing it up front.
6253 let parsed_fill = self.fill_bytes();
6254 let fill = match &parsed_fill {
6255 Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6256 Err(_) => FillPattern::UNKNOWN,
6257 };
6258
6259 let pipeline = self.filter_pipeline_parsed();
6260 let spec = RawReadSpec {
6261 layout: &dl,
6262 dataspace: &ds,
6263 datatype: &dt,
6264 pipeline: pipeline.as_ref(),
6265 fill,
6266 };
6267
6268 if start == 0 && count == n0 {
6269 return Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?);
6270 }
6271
6272 // A lone window's successor is the adjacent one, and the chunk they share
6273 // is the one this read finishes on; `CachePass::LRU` is what retains it.
6274 Ok(self.file.read_dataset_raw_rows(
6275 spec,
6276 &self.chunk_cache,
6277 CachePass::LRU,
6278 start,
6279 count,
6280 )?)
6281 }
6282
6283 /// Windowed [`read_f64`](Self::read_f64) — decodes only the row window.
6284 pub fn read_f64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f64>, Error> {
6285 let raw = self.read_raw_rows(start_row, num_rows)?;
6286 Ok(data_read::read_as_f64(&raw, &self.datatype()?)?)
6287 }
6288
6289 /// Windowed [`read_f32`](Self::read_f32) — decodes only the row window.
6290 pub fn read_f32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f32>, Error> {
6291 let raw = self.read_raw_rows(start_row, num_rows)?;
6292 Ok(data_read::read_as_f32(&raw, &self.datatype()?)?)
6293 }
6294
6295 /// Windowed [`read_i8`](Self::read_i8) — decodes only the row window.
6296 #[expect(
6297 clippy::cast_possible_wrap,
6298 reason = "read_i8 reinterprets each stored byte as the signed i8 the caller requested"
6299 )]
6300 pub fn read_i8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i8>, Error> {
6301 let raw = self.read_raw_rows(start_row, num_rows)?;
6302 Ok(raw.iter().map(|&b| b as i8).collect())
6303 }
6304
6305 /// Windowed [`read_i16`](Self::read_i16) — decodes only the row window.
6306 pub fn read_i16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i16>, Error> {
6307 let raw = self.read_raw_rows(start_row, num_rows)?;
6308 Ok(data_read::read_as_i16(&raw, &self.datatype()?)?)
6309 }
6310
6311 /// Windowed [`read_i32`](Self::read_i32) — decodes only the row window.
6312 pub fn read_i32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i32>, Error> {
6313 let raw = self.read_raw_rows(start_row, num_rows)?;
6314 Ok(data_read::read_as_i32(&raw, &self.datatype()?)?)
6315 }
6316
6317 /// Windowed [`read_i64`](Self::read_i64) — decodes only the row window.
6318 pub fn read_i64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i64>, Error> {
6319 let raw = self.read_raw_rows(start_row, num_rows)?;
6320 Ok(data_read::read_as_i64(&raw, &self.datatype()?)?)
6321 }
6322
6323 /// Windowed [`read_u8`](Self::read_u8) — reads only the row window.
6324 pub fn read_u8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6325 self.read_raw_rows(start_row, num_rows)
6326 }
6327
6328 /// Windowed [`read_u16`](Self::read_u16) — decodes only the row window.
6329 pub fn read_u16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u16>, Error> {
6330 let raw = self.read_raw_rows(start_row, num_rows)?;
6331 Ok(data_read::read_as_u16(&raw, &self.datatype()?)?)
6332 }
6333
6334 /// Windowed [`read_u32`](Self::read_u32) — decodes only the row window.
6335 pub fn read_u32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u32>, Error> {
6336 let raw = self.read_raw_rows(start_row, num_rows)?;
6337 Ok(data_read::read_as_u32(&raw, &self.datatype()?)?)
6338 }
6339
6340 /// Windowed [`read_u64`](Self::read_u64) — decodes only the row window.
6341 pub fn read_u64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u64>, Error> {
6342 let raw = self.read_raw_rows(start_row, num_rows)?;
6343 Ok(data_read::read_as_u64(&raw, &self.datatype()?)?)
6344 }
6345
6346 /// Windowed [`read_string`](Self::read_string).
6347 ///
6348 /// Fixed-length strings decode straight from the window. Variable-length
6349 /// strings resolve only the window's heap references, so the window memory
6350 /// bound holds for them too: peak allocation is the window's references,
6351 /// its text, and the metadata of the heap collections it touches.
6352 pub fn read_string_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<String>, Error> {
6353 let dt = self.datatype()?;
6354 if vl_data::is_vlen_string_datatype(&dt) {
6355 // The window's heap references, read memory-bounded like any other
6356 // fixed-size element (4-byte length + collection address + 4-byte
6357 // object index), one row spanning its inner dimensions. Resolving
6358 // only those against the global heap keeps the bound — the same
6359 // resolution `read_string` runs over the whole dataset's references.
6360 let raw = self.read_raw_rows(start_row, num_rows)?;
6361 let ref_size = 4 + self.file.offset_size() as usize + 4;
6362 let num_elements = (raw.len() / ref_size) as u64;
6363 let mut strings = Vec::new();
6364 self.file.with_source(|source| -> Result<(), Error> {
6365 Ok(vl_data::visit_vl_strings_from_source(
6366 source,
6367 &raw,
6368 num_elements,
6369 self.file.offset_size(),
6370 self.file.length_size(),
6371 self.file.addr_offset,
6372 VlenStringReadOptions::default(),
6373 |string| strings.push(String::from(string)),
6374 )?)
6375 })?;
6376 return Ok(strings);
6377 }
6378 let raw = self.read_raw_rows(start_row, num_rows)?;
6379 Ok(data_read::read_as_strings(&raw, &dt)?)
6380 }
6381
6382 /// Interpret this dataset as an array of HDF5 object references
6383 /// (`H5R_OBJECT`) and resolve each, in storage order, to the [`Object`] it
6384 /// points at.
6385 ///
6386 /// MATLAB cell arrays and the `#subsystem#` machinery store their members
6387 /// this way: the dataset holds one object-header address per element, each
6388 /// naming an object elsewhere in the file (conventionally under the hidden
6389 /// `#refs#` group).
6390 ///
6391 /// # Errors
6392 ///
6393 /// - [`FormatError::TypeMismatch`] if this dataset's datatype is not an
6394 /// object reference.
6395 /// - [`FormatError::InvalidObjectReference`] if an element is a null or
6396 /// undefined reference, or does not point at a group or dataset.
6397 pub fn dereference(&self) -> Result<Vec<Object>, Error> {
6398 let dt = self.datatype()?;
6399 if !matches!(
6400 dt,
6401 Datatype::Reference {
6402 ref_type: ReferenceType::Object,
6403 ..
6404 }
6405 ) {
6406 return Err(FormatError::TypeMismatch {
6407 expected: "object reference",
6408 actual: "non-reference datatype",
6409 }
6410 .into());
6411 }
6412 // An object reference stores an 8-byte object-header address. Refuse a
6413 // sub-address-width element rather than read a truncated address.
6414 let elem_size = dt.type_size().to_usize()?;
6415 if elem_size < 8 {
6416 return Err(FormatError::TypeMismatch {
6417 expected: "8-byte object reference",
6418 actual: "object reference narrower than 8 bytes",
6419 }
6420 .into());
6421 }
6422 let raw = self.read_raw()?;
6423 if raw.is_empty() {
6424 return Ok(Vec::new());
6425 }
6426 if !raw.len().is_multiple_of(elem_size) {
6427 return Err(FormatError::DataSizeMismatch {
6428 expected: elem_size,
6429 actual: raw.len(),
6430 }
6431 .into());
6432 }
6433 // Read after the element bytes the addresses came out of, which is the
6434 // one place these could be taken too late: a commit landing between that
6435 // read and this one would move the headers the addresses name, and a
6436 // handle labelled with the later revisions would call them current.
6437 let revisions = self.file.revisions();
6438 let mut out = Vec::with_capacity(raw.len() / elem_size);
6439 for chunk in raw.chunks_exact(elem_size) {
6440 let addr = u64::from_le_bytes(chunk[..8].try_into().expect("chunk has >= 8 bytes"));
6441 out.push(FileInner::object_at_relative(&self.file, revisions, addr)?);
6442 }
6443 Ok(out)
6444 }
6445
6446 /// Decode all elements of a compound dataset field by field.
6447 ///
6448 /// Built-in implementations support numeric tuples with one through twelve
6449 /// fields. Decoding uses the file's field offsets rather than Rust's tuple
6450 /// memory layout, so padded compound records are supported safely.
6451 pub fn read_compound<T: CompoundType>(&self) -> Result<Vec<T>, Error> {
6452 let datatype = self.datatype()?;
6453 let element_size = datatype.element_size_usize()?;
6454 if !matches!(datatype, Datatype::Compound { .. }) {
6455 return Err(FormatError::TypeMismatch {
6456 expected: "Compound",
6457 actual: "non-Compound",
6458 }
6459 .into());
6460 }
6461 let raw = self.read_raw()?;
6462 if !raw.len().is_multiple_of(element_size.get()) {
6463 return Err(FormatError::DataSizeMismatch {
6464 expected: element_size.get(),
6465 actual: raw.len(),
6466 }
6467 .into());
6468 }
6469 raw.chunks_exact(element_size.get())
6470 .map(|bytes| T::decode(&datatype, bytes).map_err(Error::Format))
6471 .collect()
6472 }
6473
6474 /// Verify this dataset against its stored provenance hash.
6475 ///
6476 /// Recomputes the SHA-256 of the dataset's raw bytes and compares it with
6477 /// the `_provenance_sha256` attribute written by
6478 /// [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
6479 /// Returns [`VerifyResult::NoHash`](crate::VerifyResult::NoHash) when the
6480 /// dataset carries no provenance hash, so a missing hash is distinguishable
6481 /// from an actual mismatch.
6482 #[cfg(feature = "provenance")]
6483 pub fn verify_provenance(&self) -> Result<crate::provenance::VerifyResult, Error> {
6484 use crate::provenance::{ATTR_SHA256, VerifyResult, sha256_hex};
6485
6486 let attrs = self.attrs()?;
6487 let stored = match attrs.get(ATTR_SHA256).and_then(AttrValue::as_str) {
6488 Some(s) => s.trim_end_matches('\0').to_string(),
6489 None => return Ok(VerifyResult::NoHash),
6490 };
6491
6492 let computed = sha256_hex(&self.read_raw()?);
6493 if computed == stored {
6494 Ok(VerifyResult::Ok)
6495 } else {
6496 Ok(VerifyResult::Mismatch { stored, computed })
6497 }
6498 }
6499}
6500
6501// ---------------------------------------------------------------------------
6502// Helpers
6503// ---------------------------------------------------------------------------
6504
6505fn find_message(
6506 header: &ObjectHeader,
6507 msg_type: MessageType,
6508) -> Result<&crate::object_header::HeaderMessage, Error> {
6509 header
6510 .messages
6511 .iter()
6512 .find(|m| m.msg_type == msg_type)
6513 .ok_or(Error::MissingMessage(msg_type))
6514}
6515
6516/// Normalize a user-supplied object path to the root-relative form the write
6517/// session addresses by: strip any leading/trailing `/` so `"/a/b"` and `"a/b"`
6518/// name the same object.
6519fn normalize_path(path: &str) -> String {
6520 path.trim_matches('/').to_string()
6521}
6522
6523fn has_message(header: &ObjectHeader, msg_type: MessageType) -> bool {
6524 header.messages.iter().any(|m| m.msg_type == msg_type)
6525}
6526
6527/// Whether an object header describes a committed (`H5Tcommit`) datatype: it
6528/// carries a datatype and is neither a dataset nor a group.
6529///
6530/// A dataset's header carries a datatype message too — its element type — so
6531/// "has a datatype message" is not the question, and a lookup that asked only
6532/// that answered a dataset's element type where it owed a refusal (issue #364).
6533/// The listing and the by-name lookups share this one predicate so they cannot
6534/// disagree about the same child.
6535///
6536/// The conjunction encodes the precedence the reference library gets from its
6537/// ordering: `H5O__obj_class_real` walks `H5O_obj_class_g` in reverse, so it
6538/// asks group, then dataset, then datatype, and that is what `H5Topen` gates on.
6539/// Two terms are read differently here. It calls a header a dataset for a
6540/// datatype beside a *dataspace* where this reads a datatype beside a data
6541/// layout, and a group for a symbol table or link info where this counts a bare
6542/// link message as well. Every object either library writes carries the messages
6543/// that make those agree, so the rules part only on a malformed header.
6544fn is_named_datatype(header: &ObjectHeader) -> bool {
6545 has_message(header, MessageType::Datatype)
6546 && !has_message(header, MessageType::DataLayout)
6547 && !is_group(header)
6548}
6549
6550/// The root-relative path of a child named `name` under `parent`, or `None` if
6551/// the parent has no resolvable path (reached by object reference).
6552///
6553/// Free-standing rather than a method on [`Group`] so the member iterators can
6554/// build child paths from a closure that outlives the borrow of the group they
6555/// came from.
6556fn child_path_of(parent: Option<&str>, name: &str) -> Option<String> {
6557 parent.map(|p| {
6558 if p.is_empty() {
6559 name.to_string()
6560 } else {
6561 format!("{p}/{name}")
6562 }
6563 })
6564}
6565
6566#[cfg(test)]
6567mod tests {
6568 use super::*;
6569 use crate::FileBuilder;
6570 use std::sync::atomic::AtomicUsize;
6571
6572 // -----------------------------------------------------------------------
6573 // Reporting the metadata cache (issue #353)
6574 // -----------------------------------------------------------------------
6575
6576 /// `SourceView` serves its metadata reads from the streaming backend's
6577 /// cache, so it has to forward the account of them as well. It is the one
6578 /// wrapper `File::metadata_cache_stats` does not itself go through (that
6579 /// dispatch uses `with_source`, which reaches the read-write backend too), so
6580 /// nothing else would notice the forward going missing.
6581 #[test]
6582 fn the_source_view_reports_the_cache_it_reads_through() {
6583 let backend = MetadataCachingSource::new(
6584 BytesSource::new((0..=255u8).collect::<Vec<u8>>()),
6585 MetadataCacheConfig::new(4096),
6586 );
6587 let view = SourceView::Stream(&backend);
6588
6589 assert_eq!(view.metadata_cache_stats().unwrap().reads(), 0);
6590 view.read_metadata_at(0, 64).unwrap();
6591 view.read_metadata_at(0, 64).unwrap();
6592 let stats = view
6593 .metadata_cache_stats()
6594 .expect("the backend has a cache, so the view reports it");
6595 assert_eq!((stats.hits(), stats.misses()), (1, 1));
6596
6597 view.reset_metadata_cache_stats();
6598 let cleared = view.metadata_cache_stats().unwrap();
6599 assert_eq!(cleared.hits(), 0);
6600 assert_eq!(cleared.entries(), 1, "a reset evicts nothing");
6601
6602 // A whole-file buffer is the cache; there is no second one to report.
6603 assert_eq!(SourceView::Mem(&[0u8; 16]).metadata_cache_stats(), None);
6604 }
6605
6606 // -----------------------------------------------------------------------
6607 // Handle re-validation across an edit (issue #351)
6608 // -----------------------------------------------------------------------
6609
6610 /// A file with two chunked datasets whose trailing chunk is partial, one
6611 /// contiguous dataset, and one subgroup.
6612 fn revalidation_fixture(path: &std::path::Path) {
6613 let mut b = FileBuilder::new();
6614 for ds in ["log", "other"] {
6615 b.create_dataset(ds)
6616 .with_i32_data(&[0, 1])
6617 .with_shape(&[2])
6618 .with_maxshape(&[u64::MAX])
6619 .with_chunks(&[4]);
6620 }
6621 b.create_dataset("plain").with_i32_data(&[7, 8, 9]);
6622 let g = b.create_group("g");
6623 b.add_group(g.finish());
6624 b.write(path).unwrap();
6625 }
6626
6627 /// The issue itself: a handle taken before a commit goes on answering for
6628 /// the object after it, rather than for the copy the commit left behind.
6629 #[test]
6630 fn a_handle_follows_its_object_across_a_commit() {
6631 let dir = tempfile::tempdir().unwrap();
6632 let path = dir.path().join("follow.h5");
6633 revalidation_fixture(&path);
6634
6635 let file = File::open_rw(&path).unwrap();
6636 let mut ds = file.dataset("plain").unwrap();
6637 let group = file.group("g").unwrap();
6638
6639 // Both edits relocate an object header: the dataset's own, and — since
6640 // the new child is linked into it — the group's.
6641 ds.set_attr("units", AttrValue::AsciiString("m".into()))
6642 .unwrap();
6643 group
6644 .create_dataset("child", |b| {
6645 b.with_i32_data(&[4, 5]);
6646 })
6647 .unwrap();
6648 file.commit().unwrap();
6649
6650 assert_eq!(
6651 sorted(ds.attrs()),
6652 vec![r#"units=AsciiString("m")"#.to_string()],
6653 "the dataset handle must report the attribute the commit added"
6654 );
6655 assert_eq!(
6656 group.datasets().unwrap(),
6657 vec!["child".to_string()],
6658 "the group handle must report the child the commit added"
6659 );
6660 assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
6661 assert_eq!(
6662 group.dataset("child").unwrap().read_i32().unwrap(),
6663 vec![4, 5]
6664 );
6665 file.close().unwrap();
6666 }
6667
6668 /// Two handles on one dataset are two views of one object, not two objects:
6669 /// an append through either is what the other reads next, chunk cache and
6670 /// all. The appended elements land in a chunk the reader already holds, so a
6671 /// retained one would answer with what stood there before.
6672 #[test]
6673 fn an_append_through_one_handle_is_what_another_reads() {
6674 let dir = tempfile::tempdir().unwrap();
6675 let path = dir.path().join("two_handles.h5");
6676 revalidation_fixture(&path);
6677
6678 let file = File::open_rw(&path).unwrap();
6679 let reader = file.dataset("log").unwrap();
6680 let mut writer = file.dataset("log").unwrap();
6681
6682 assert_eq!(reader.read_i32().unwrap(), vec![0, 1]);
6683 assert!(
6684 reader.chunk_cache_stats().cached_chunks() > 0,
6685 "the read must leave the partial trailing chunk cached, or this \
6686 test cannot tell a retained chunk from a re-read one"
6687 );
6688
6689 writer.append(&[2i32, 3]).unwrap();
6690
6691 assert_eq!(
6692 reader.chunk_cache_stats().cached_chunks(),
6693 0,
6694 "the snapshot must report what the cache holds for a read, and the \
6695 append left it holding nothing a read will be served"
6696 );
6697 assert_eq!(reader.shape().unwrap(), vec![4]);
6698 assert_eq!(reader.read_i32().unwrap(), vec![0, 1, 2, 3]);
6699 file.close().unwrap();
6700 }
6701
6702 /// A clone is a second handle to the same object, and follows it the same
6703 /// way. It shares the chunk cache, so the edit that drops one drops both.
6704 #[test]
6705 fn a_clone_is_a_second_handle_to_the_same_object() {
6706 let dir = tempfile::tempdir().unwrap();
6707 let path = dir.path().join("clone.h5");
6708 revalidation_fixture(&path);
6709
6710 let file = File::open_rw(&path).unwrap();
6711 let mut ds = file.dataset("log").unwrap();
6712 let copy = ds.clone();
6713 let root = file.root();
6714 let root_copy = root.clone();
6715
6716 assert_eq!(copy.read_i32().unwrap(), vec![0, 1]);
6717 assert!(copy.chunk_cache_stats().cached_chunks() > 0);
6718 assert_eq!(
6719 ds.chunk_cache_stats().cached_chunks(),
6720 copy.chunk_cache_stats().cached_chunks(),
6721 "clones share one cache: a chunk read through either is warm for both"
6722 );
6723
6724 ds.append(&[2i32, 3]).unwrap();
6725 assert_eq!(copy.read_i32().unwrap(), vec![0, 1, 2, 3]);
6726
6727 root.create_group("later").unwrap();
6728 file.commit().unwrap();
6729 assert!(
6730 root_copy.groups().unwrap().contains(&"later".to_string()),
6731 "a cloned group handle follows its group across a commit too"
6732 );
6733 file.close().unwrap();
6734 }
6735
6736 /// A handle to an object a commit deleted has nothing to answer for. The
6737 /// bytes it vacated still parse as the dataset that left them, so reading
6738 /// them would answer with data no longer in the file.
6739 #[test]
6740 fn a_handle_to_a_deleted_object_refuses_rather_than_reading_what_it_left() {
6741 let dir = tempfile::tempdir().unwrap();
6742 let path = dir.path().join("deleted.h5");
6743 revalidation_fixture(&path);
6744
6745 let file = File::open_rw(&path).unwrap();
6746 let ds = file.dataset("plain").unwrap();
6747 assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
6748
6749 file.root().delete("plain").unwrap();
6750 file.commit().unwrap();
6751
6752 assert!(
6753 matches!(
6754 ds.read_i32(),
6755 Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "plain"
6756 ),
6757 "reading a deleted dataset must fail the way opening it does, got {:?}",
6758 ds.read_i32()
6759 );
6760 file.close().unwrap();
6761 }
6762
6763 /// A handle reached by object reference has no name to look itself up by, so
6764 /// it pins to the address the reference gave it. An immediate append leaves
6765 /// that address alone and it keeps reading; a commit can move the header and
6766 /// it stops.
6767 #[test]
6768 fn a_reference_handle_reads_until_a_commit_could_have_moved_its_object() {
6769 let dir = tempfile::tempdir().unwrap();
6770 let path = dir.path().join("by_ref.h5");
6771 let mut b = FileBuilder::new();
6772 b.create_dataset("log")
6773 .with_i32_data(&[0, 1])
6774 .with_shape(&[2])
6775 .with_maxshape(&[u64::MAX])
6776 .with_chunks(&[4]);
6777 b.create_dataset("refs").with_path_references(&["log"]);
6778 b.write(&path).unwrap();
6779
6780 let file = File::open_rw(&path).unwrap();
6781 let mut by_ref = match file
6782 .dataset("refs")
6783 .unwrap()
6784 .dereference()
6785 .unwrap()
6786 .remove(0)
6787 {
6788 Object::Dataset(ds) => *ds,
6789 other => panic!("expected a dataset, got {other:?}"),
6790 };
6791 assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1]);
6792
6793 // An in-place append rewrites the header where it stands, so the address
6794 // is still the object's and the handle reads its own new elements.
6795 by_ref.append(&[2i32, 3]).unwrap();
6796 assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1, 2, 3]);
6797
6798 // A commit can put the header somewhere else, and nothing on disk marks
6799 // the bytes it vacated as dead.
6800 file.root().create_group("g").unwrap();
6801 file.commit().unwrap();
6802 assert!(
6803 matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
6804 "unexpected: {:?}",
6805 by_ref.read_i32()
6806 );
6807
6808 // And the recovery the error names: dereference again, against the file
6809 // the commit left.
6810 let fresh = match file
6811 .dataset("refs")
6812 .unwrap()
6813 .dereference()
6814 .unwrap()
6815 .remove(0)
6816 {
6817 Object::Dataset(ds) => *ds,
6818 other => panic!("expected a dataset, got {other:?}"),
6819 };
6820 assert_eq!(fresh.read_i32().unwrap(), vec![0, 1, 2, 3]);
6821 file.close().unwrap();
6822 }
6823
6824 /// The counters are what every handle trusts, so every entry point that
6825 /// reaches the write engine has to declare what it does to them. This is
6826 /// that declaration, written out entry point by entry point and checked
6827 /// against the code: a [`Change::Relocating`] advances both counters, an
6828 /// [`Change::InPlace`] only the content one, and a [`Change::Nothing`]
6829 /// neither.
6830 ///
6831 /// The table is the point. An entry point classified too weakly leaves a
6832 /// handle memoizing a header it moved; one classified too strongly ends
6833 /// every by-reference handle in the session for nothing — which is what
6834 /// `File::sync` did until its line was written here.
6835 #[test]
6836 fn every_write_entry_point_declares_what_it_changes() {
6837 let dir = tempfile::tempdir().unwrap();
6838
6839 type Step = (&'static str, Change, fn(&File));
6840 let steps: Vec<Step> = vec![
6841 ("File::sync", Change::Nothing, |f| {
6842 f.sync().unwrap();
6843 }),
6844 ("Dataset::chunk_cache_stats", Change::Nothing, |f| {
6845 let _ = f.dataset("log").unwrap().chunk_cache_stats();
6846 }),
6847 ("Dataset::reset_chunk_cache_stats", Change::Nothing, |f| {
6848 f.dataset("log").unwrap().reset_chunk_cache_stats();
6849 }),
6850 ("BufferedAppender::new", Change::Nothing, |f| {
6851 let mut ds = f.dataset("log").unwrap();
6852 let app = ds.buffered_appender().unwrap();
6853 app.discard();
6854 }),
6855 ("Dataset::append", Change::InPlace, |f| {
6856 f.dataset("log").unwrap().append(&[9i32]).unwrap();
6857 }),
6858 ("Dataset::append_raw", Change::InPlace, |f| {
6859 f.dataset("log")
6860 .unwrap()
6861 .append_raw(&7i32.to_le_bytes())
6862 .unwrap();
6863 }),
6864 ("BufferedAppender::flush", Change::InPlace, |f| {
6865 let mut ds = f.dataset("log").unwrap();
6866 let mut app = ds.buffered_appender().unwrap();
6867 app.append(&[5i32, 6, 7, 8]).unwrap();
6868 app.flush().unwrap();
6869 }),
6870 ("Dataset::write", Change::Relocating, |f| {
6871 f.dataset("plain").unwrap().write(&[1i32, 2, 3]).unwrap();
6872 }),
6873 ("Dataset::set_attr", Change::Relocating, |f| {
6874 f.dataset("plain")
6875 .unwrap()
6876 .set_attr("a", AttrValue::I32(1))
6877 .unwrap();
6878 }),
6879 ("Dataset::remove_attr", Change::Relocating, |f| {
6880 let mut ds = f.dataset("plain").unwrap();
6881 ds.set_attr("gone", AttrValue::I32(1)).unwrap();
6882 f.commit().unwrap();
6883 ds.remove_attr("gone").unwrap();
6884 }),
6885 ("Dataset::write_staged", Change::Relocating, |f| {
6886 f.dataset("plain")
6887 .unwrap()
6888 .write_staged(|b| {
6889 b.with_i32_data(&[4, 5, 6]);
6890 })
6891 .unwrap();
6892 }),
6893 ("Dataset::append_staged", Change::Relocating, |f| {
6894 f.dataset("log")
6895 .unwrap()
6896 .append_staged(|b| {
6897 b.append_i32(&[3]);
6898 })
6899 .unwrap();
6900 }),
6901 ("Group::create_group", Change::Relocating, |f| {
6902 f.root().create_group("fresh").unwrap();
6903 }),
6904 ("Group::create_dataset", Change::Relocating, |f| {
6905 f.root()
6906 .create_dataset("made", |b| {
6907 b.with_i32_data(&[1]);
6908 })
6909 .unwrap();
6910 }),
6911 ("Group::delete", Change::Relocating, |f| {
6912 f.root().delete("plain").unwrap();
6913 }),
6914 ("Group::set_attr", Change::Relocating, |f| {
6915 f.root().set_attr("a", AttrValue::I32(1)).unwrap();
6916 }),
6917 ("Group::remove_attr", Change::Relocating, |f| {
6918 f.root().set_attr("gone", AttrValue::I32(1)).unwrap();
6919 f.commit().unwrap();
6920 f.root().remove_attr("gone").unwrap();
6921 }),
6922 ("Group::create_group_with", Change::Relocating, |f| {
6923 f.root()
6924 .create_group_with("built", |g| {
6925 g.set_attr("a", AttrValue::I32(1));
6926 })
6927 .unwrap();
6928 }),
6929 ("File::copy", Change::Relocating, |f| {
6930 f.copy("plain", "copied").unwrap();
6931 }),
6932 ("File::commit", Change::Relocating, |f| {
6933 f.root().create_group("committed").unwrap();
6934 f.commit().unwrap();
6935 }),
6936 ];
6937
6938 let revisions = |f: &File| (f.inner.content_revision(), f.inner.address_revision());
6939 let declared = |c: Change| match c {
6940 Change::Relocating => "Relocating",
6941 Change::InPlace => "InPlace",
6942 Change::Nothing => "Nothing",
6943 };
6944 for (name, change, step) in steps {
6945 let path = dir.path().join(format!("{}.h5", name.replace("::", "_")));
6946 revalidation_fixture(&path);
6947 let file = File::open_rw(&path).unwrap();
6948 let before = revisions(&file);
6949 step(&file);
6950 let after = revisions(&file);
6951 let observed = match (after.0 > before.0, after.1 > before.1) {
6952 (true, true) => "Relocating",
6953 (true, false) => "InPlace",
6954 (false, false) => "Nothing",
6955 (false, true) => "an address move with no content change",
6956 };
6957 assert_eq!(
6958 observed,
6959 declared(change),
6960 "{name} is declared here as one thing and behaves as another \
6961 ({before:?} -> {after:?})"
6962 );
6963 file.close().unwrap();
6964 }
6965
6966 // `close` consumes the file, so it cannot be a row above. It is two
6967 // operations: the commit it makes, which relocates like any other, and
6968 // the teardown, which re-homes free space and releases status flags
6969 // where they stand. Both counters therefore move, and the content one
6970 // moves twice.
6971 let path = dir.path().join("File_close.h5");
6972 revalidation_fixture(&path);
6973 let file = File::open_rw(&path).unwrap();
6974 let before = revisions(&file);
6975 let by_path = file.dataset("plain").unwrap();
6976 let by_ref = {
6977 let mut b = FileBuilder::new();
6978 b.create_dataset("d").with_i32_data(&[1, 2]);
6979 b.create_dataset("refs").with_path_references(&["d"]);
6980 let refs_path = dir.path().join("File_close_refs.h5");
6981 b.write(&refs_path).unwrap();
6982 let refs = File::open_rw(&refs_path).unwrap();
6983 let handle = match refs
6984 .dataset("refs")
6985 .unwrap()
6986 .dereference()
6987 .unwrap()
6988 .remove(0)
6989 {
6990 Object::Dataset(ds) => *ds,
6991 other => panic!("expected a dataset, got {other:?}"),
6992 };
6993 refs.close().unwrap();
6994 handle
6995 };
6996 // `close` consumes its `File`; the counters live on the shared inner
6997 // state every handle holds, so read them back through one of those.
6998 let inner = Arc::clone(&file.inner);
6999 file.close().unwrap();
7000 let after = (inner.content_revision(), inner.address_revision());
7001 assert_eq!(
7002 (after.0 - before.0, after.1 - before.1),
7003 (2, 1),
7004 "File::close is a Relocating commit and an InPlace teardown"
7005 );
7006 assert_eq!(
7007 by_path.read_i32().unwrap(),
7008 vec![7, 8, 9],
7009 "`close` promises reads through surviving handles still work"
7010 );
7011 assert!(
7012 matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
7013 "the one handle that cannot follow the commit `close` makes: {:?}",
7014 by_ref.read_i32()
7015 );
7016 }
7017
7018 /// What the *second* counter buys, which nothing else pins: an edit that
7019 /// moves no object header leaves a by-reference handle — the one kind that
7020 /// cannot look itself up again — still able to read. Collapse the two
7021 /// counters into one and every case here becomes `StaleHandle`.
7022 #[test]
7023 fn an_edit_that_moves_no_header_leaves_a_reference_handle_reading() {
7024 let dir = tempfile::tempdir().unwrap();
7025
7026 // Named for the same reason `Step` above is: a bare tuple of a name and
7027 // a function pointer reads as noise at the call site.
7028 type Case = (&'static str, fn(&File));
7029 let cases: Vec<Case> = vec![
7030 (
7031 "an append through another handle to the same dataset",
7032 |f| {
7033 f.dataset("log").unwrap().append(&[9i32]).unwrap();
7034 },
7035 ),
7036 ("an append to a different dataset", |f| {
7037 f.dataset("other").unwrap().append(&[9i32]).unwrap();
7038 }),
7039 ("a durability barrier", |f| {
7040 f.sync().unwrap();
7041 }),
7042 ];
7043
7044 for (name, edit) in cases {
7045 let path = dir.path().join(format!("{}.h5", name.replace(' ', "_")));
7046 let mut b = FileBuilder::new();
7047 for ds in ["log", "other"] {
7048 b.create_dataset(ds)
7049 .with_i32_data(&[0, 1])
7050 .with_shape(&[2])
7051 .with_maxshape(&[u64::MAX])
7052 .with_chunks(&[4]);
7053 }
7054 b.create_dataset("refs").with_path_references(&["log"]);
7055 b.write(&path).unwrap();
7056
7057 let file = File::open_rw(&path).unwrap();
7058 let by_ref = match file
7059 .dataset("refs")
7060 .unwrap()
7061 .dereference()
7062 .unwrap()
7063 .remove(0)
7064 {
7065 Object::Dataset(ds) => *ds,
7066 other => panic!("expected a dataset, got {other:?}"),
7067 };
7068 assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1], "{name}: before");
7069 edit(&file);
7070 assert!(
7071 by_ref.read_i32().is_ok(),
7072 "{name} moves no object header, so it must not end a handle that \
7073 names its dataset by address: {:?}",
7074 by_ref.read_i32()
7075 );
7076 file.close().unwrap();
7077 }
7078 }
7079
7080 /// A commit can put something else at a handle's path — issue #305 makes a
7081 /// dataset and a group interchangeable in one commit. The refusal has to
7082 /// hold on *every* call: a header memoized and then refused is the answer
7083 /// each later call short-circuits on, and this handle would go on serving
7084 /// the other object's header without an error.
7085 #[test]
7086 fn a_handle_whose_path_becomes_a_group_keeps_refusing() {
7087 let dir = tempfile::tempdir().unwrap();
7088 let path = dir.path().join("replaced.h5");
7089 revalidation_fixture(&path);
7090
7091 let file = File::open_rw(&path).unwrap();
7092 let ds = file.dataset("plain").unwrap();
7093 assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7094
7095 file.root().delete("plain").unwrap();
7096 file.root()
7097 .create_group_with("plain", |g| {
7098 g.set_attr("i_am_a_group", AttrValue::I32(42));
7099 })
7100 .unwrap();
7101 file.commit().unwrap();
7102
7103 for call in 1..=3 {
7104 assert!(
7105 matches!(ds.attrs(), Err(Error::NotADataset(ref p)) if p == "plain"),
7106 "call {call} answered {:?}",
7107 ds.attrs()
7108 );
7109 assert!(matches!(ds.read_i32(), Err(Error::NotADataset(_))));
7110 assert!(matches!(ds.shape(), Err(Error::NotADataset(_))));
7111 }
7112 file.close().unwrap();
7113 }
7114
7115 /// A read-only file cannot change under a handle, so nothing a reader does
7116 /// may move the counters — and no handle on one ever pays to re-resolve.
7117 #[test]
7118 fn reading_never_moves_the_file_on() {
7119 let dir = tempfile::tempdir().unwrap();
7120 let path = dir.path().join("readonly.h5");
7121 revalidation_fixture(&path);
7122
7123 let file = File::open(&path).unwrap();
7124 let _ = read_everything(&file);
7125 let ds = file.dataset("log").unwrap();
7126 let _ = ds.read_i32().unwrap();
7127 let _ = ds.attrs().unwrap();
7128 let _ = ds.layout().unwrap();
7129 let _ = file.root().groups().unwrap();
7130 assert_eq!(
7131 (file.inner.content_revision(), file.inner.address_revision()),
7132 (0, 0),
7133 "a read must not tell every handle its memo has expired"
7134 );
7135 }
7136
7137 // -----------------------------------------------------------------------
7138 // Opening an object as the wrong kind (issue #352)
7139 // -----------------------------------------------------------------------
7140
7141 /// A dataset at the root and a dataset one level down inside a group.
7142 ///
7143 /// Shared by the two sections below: opening one of those datasets *as* a
7144 /// group is issue #352, and resolving a path *through* one is issue #365, so
7145 /// the sibling refusals are provably about the same file.
7146 fn nested_dataset_bytes() -> Vec<u8> {
7147 let mut b = FileBuilder::new();
7148 b.create_dataset("plain").with_i32_data(&[1]);
7149 let mut g = b.create_group("g");
7150 g.create_dataset("inner").with_i32_data(&[2]);
7151 b.add_group(g.finish());
7152 b.finish().unwrap()
7153 }
7154
7155 /// The issue: a by-name group lookup took whatever the name resolved to.
7156 /// `H5Gopen` fails on a non-group, and so must this — at the lookup, where
7157 /// the caller can act on it, rather than at some later call on a handle that
7158 /// was never a group.
7159 ///
7160 /// The refusal matters most on the calls that did *not* fail: `attrs()`
7161 /// through such a handle answered with the dataset's attributes, which is a
7162 /// wrong answer rather than an error.
7163 #[test]
7164 fn opening_a_dataset_as_a_group_is_refused() {
7165 let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7166 let nested = file.group("g").unwrap();
7167
7168 // Both by-name forms — from the file by path, and from a group by child
7169 // name — at the root and one level down. Each is its own lookup, and
7170 // each was missing the check. The refusal names the object, which is
7171 // what the old `PathNotFound("object header is not a group")` could not.
7172 for (label, named, got) in [
7173 ("File::group", "plain", file.group("plain")),
7174 ("Group::group", "plain", file.root().group("plain")),
7175 ("File::group nested", "g/inner", file.group("g/inner")),
7176 (
7177 "Group::group from a subgroup",
7178 "inner",
7179 nested.group("inner"),
7180 ),
7181 ] {
7182 assert!(
7183 matches!(&got, Err(Error::NotAGroup(p)) if p == named),
7184 "{label} answered {:?}",
7185 got.map(|_| "a group")
7186 );
7187 }
7188
7189 // The name it reports is the normalized one, so the same object refused
7190 // at a lookup and refused through a live handle names itself the same
7191 // way — a handle holds only the normalized path.
7192 assert!(matches!(file.group("/plain/"), Err(Error::NotAGroup(ref p)) if p == "plain"));
7193
7194 // A name that resolves to nothing stays distinct from one that resolves
7195 // to the wrong kind: the second reports what is there.
7196 assert!(matches!(
7197 file.group("absent"),
7198 Err(Error::Format(FormatError::PathNotFound(_)))
7199 ));
7200 assert!(matches!(
7201 file.root().group("absent"),
7202 Err(Error::Format(FormatError::PathNotFound(_)))
7203 ));
7204
7205 // And a real group still opens, by either form.
7206 assert!(file.group("g").is_ok());
7207 assert!(file.root().group("g").is_ok());
7208 }
7209
7210 /// A v1 symbol-table group must keep opening by name.
7211 ///
7212 /// The predicate that decides a lookup was, until this change, only a filter
7213 /// over a listing, where failing to recognise a form merely left a group out.
7214 /// Gating the lookup on it makes each form it names load-bearing, and the v1
7215 /// form is the one with no writer here to produce it — the bytes are a
7216 /// fixture, and a classifier that forgot the symbol table would refuse every
7217 /// group in every file written before the 1.8 format.
7218 #[test]
7219 fn a_symbol_table_group_still_opens_by_name() {
7220 let file =
7221 File::from_bytes(include_bytes!("../tests/data/unattributed/two_groups.h5").to_vec())
7222 .unwrap();
7223
7224 // Names, not a count: this file holds two one-child groups, so a lookup
7225 // that classified correctly and then took its sibling's address would
7226 // pass any count worth asserting.
7227 for lookup in [file.group("group1"), file.root().group("group1")] {
7228 assert_eq!(lookup.unwrap().datasets().unwrap(), ["values"]);
7229 }
7230 }
7231
7232 /// A committed datatype is neither a dataset nor a group, so it separates
7233 /// "is a group" from "is not a dataset" — a check written as the latter
7234 /// would let this one through.
7235 #[test]
7236 fn opening_a_named_datatype_as_a_group_is_refused() {
7237 let mut b = FileBuilder::new();
7238 b.commit_datatype("mytype", crate::make_i32_type());
7239 let file = File::from_bytes(b.finish().unwrap()).unwrap();
7240
7241 assert_eq!(file.root().named_datatypes().unwrap(), vec!["mytype"]);
7242 assert!(matches!(file.group("mytype"), Err(Error::NotAGroup(_))));
7243 assert!(matches!(
7244 file.root().group("mytype"),
7245 Err(Error::NotAGroup(_))
7246 ));
7247 }
7248
7249 // -----------------------------------------------------------------------
7250 // Opening something else as a named datatype (issue #364)
7251 // -----------------------------------------------------------------------
7252
7253 /// An object header carrying exactly `types`, and nothing that would make it
7254 /// parse: the predicate below reads message types and no message body.
7255 fn header_of(types: &[MessageType]) -> ObjectHeader {
7256 ObjectHeader {
7257 version: 2,
7258 messages: types
7259 .iter()
7260 .map(|&msg_type| crate::object_header::HeaderMessage {
7261 msg_type,
7262 size: 0,
7263 flags: 0,
7264 creation_order: None,
7265 data: Vec::new(),
7266 })
7267 .collect(),
7268 reference_count: None,
7269 flags: 0,
7270 access_time: None,
7271 modification_time: None,
7272 change_time: None,
7273 birth_time: None,
7274 }
7275 }
7276
7277 /// The rule the listing and both by-name lookups now share, stated over the
7278 /// message combinations rather than over one file's children.
7279 ///
7280 /// Two of these cannot be produced by any writer, here or in the reference
7281 /// library, which is why this is a predicate test and not another fixture. A
7282 /// header carrying links *and* a datatype is a group to the C library, which
7283 /// asks whether it is a group before asking whether it is a datatype; a
7284 /// header carrying neither is no object class at all, and must not become a
7285 /// datatype by default.
7286 #[test]
7287 fn a_committed_datatype_is_a_datatype_that_is_neither_dataset_nor_group() {
7288 for (label, types, expected) in [
7289 ("a committed datatype", &[MessageType::Datatype][..], true),
7290 (
7291 "a dataset, whose element type is a datatype message too",
7292 &[MessageType::Datatype, MessageType::DataLayout],
7293 false,
7294 ),
7295 ("a group with a link table", &[MessageType::LinkInfo], false),
7296 (
7297 "a group with a symbol table",
7298 &[MessageType::SymbolTable],
7299 false,
7300 ),
7301 (
7302 "a group carrying a datatype",
7303 &[MessageType::LinkInfo, MessageType::Datatype],
7304 false,
7305 ),
7306 (
7307 "a header with no datatype at all",
7308 &[MessageType::Dataspace],
7309 false,
7310 ),
7311 ] {
7312 assert_eq!(is_named_datatype(&header_of(types)), expected, "{label}");
7313 }
7314 }
7315
7316 /// The issue: the by-name datatype lookups asked only whether the child had
7317 /// a datatype message. Every dataset does — its element type — so a dataset
7318 /// answered, and the two entry points disagreed with the
7319 /// `named_datatypes()` listing about the same child.
7320 #[test]
7321 fn a_child_that_is_not_a_committed_datatype_is_refused_by_name() {
7322 let mut b = FileBuilder::new();
7323 b.commit_datatype("mytype", crate::make_i32_type());
7324 b.create_dataset("typed")
7325 .with_i32_data(&[1, 2, 3])
7326 .with_committed_datatype("mytype");
7327 b.create_dataset("plain").with_f64_data(&[1.0]);
7328 let g = b.create_group("g").finish();
7329 b.add_group(g);
7330 let file = File::from_bytes(b.finish().unwrap()).unwrap();
7331 let root = file.root();
7332
7333 // The listing is the contract both lookups now share, so it is what the
7334 // refusals below have to agree with.
7335 assert_eq!(root.named_datatypes().unwrap(), ["mytype"]);
7336
7337 // A dataset, a dataset carrying that very type, and a group: three kinds
7338 // that are not a committed datatype, against both entry points. Neither
7339 // had the check, so each needs its own assertion.
7340 for name in ["typed", "plain", "g"] {
7341 let got = root.named_datatype(name);
7342 assert!(
7343 matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7344 "named_datatype({name:?}) answered {got:?}"
7345 );
7346 let got = root.named_datatype_references(name);
7347 assert!(
7348 matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7349 "named_datatype_references({name:?}) answered {got:?}"
7350 );
7351 }
7352
7353 // A name that reaches nothing stays distinct from one that reaches the
7354 // wrong kind, as it is for `group` and `dataset`.
7355 assert!(matches!(
7356 root.named_datatype("absent"),
7357 Err(Error::Format(FormatError::PathNotFound(_)))
7358 ));
7359 assert!(matches!(
7360 root.named_datatype_references("absent"),
7361 Err(Error::Format(FormatError::PathNotFound(_)))
7362 ));
7363
7364 // And the committed type still reads, by both entry points — the value,
7365 // not merely `is_ok`, since a lookup that refused everything would pass
7366 // every assertion above.
7367 assert_eq!(
7368 root.named_datatype("mytype").unwrap(),
7369 crate::make_i32_type()
7370 );
7371 assert_eq!(root.named_datatype_references("mytype").unwrap(), 2);
7372 }
7373
7374 /// The mirror of [`a_handle_whose_path_becomes_a_group_keeps_refusing`]: a
7375 /// commit can leave a live group handle's path naming a dataset (issue
7376 /// #305), which is the one way past the lookup check above. The handle
7377 /// re-resolves, finds the wrong kind, and reports it on every call rather
7378 /// than serving the dataset's header as a group's.
7379 #[test]
7380 fn a_group_handle_whose_path_becomes_a_dataset_keeps_refusing() {
7381 let dir = tempfile::tempdir().unwrap();
7382 let path = dir.path().join("replaced_group.h5");
7383 revalidation_fixture(&path);
7384
7385 let file = File::open_rw(&path).unwrap();
7386 let group = file.group("g").unwrap();
7387 assert!(group.datasets().unwrap().is_empty());
7388
7389 file.root().delete("g").unwrap();
7390 file.root()
7391 .create_dataset("g", |b| {
7392 b.with_i32_data(&[7]);
7393 })
7394 .unwrap();
7395 file.commit().unwrap();
7396
7397 // `attrs` every time round: it is the call that answered with the
7398 // dataset's attributes instead of failing, and a check installed after
7399 // the memo rather than before it would let the second call through.
7400 for call in 1..=3 {
7401 assert!(
7402 matches!(group.attrs(), Err(Error::NotAGroup(ref p)) if p == "g"),
7403 "call {call} answered {:?}",
7404 group.attrs()
7405 );
7406 }
7407 // The rest of the read surface funnels through the same re-resolve, so
7408 // once each is enough to say the refusal is the group's, not `attrs`'s.
7409 assert!(matches!(group.datasets(), Err(Error::NotAGroup(_))));
7410 assert!(matches!(group.groups(), Err(Error::NotAGroup(_))));
7411 assert!(matches!(
7412 group.dataset("anything"),
7413 Err(Error::NotAGroup(_))
7414 ));
7415
7416 // The path still names something, and that something still opens as
7417 // what it now is.
7418 assert_eq!(file.dataset("g").unwrap().read_i32().unwrap(), vec![7]);
7419 file.close().unwrap();
7420 }
7421
7422 // -----------------------------------------------------------------------
7423 // Resolving a path *through* something that is not a group (issue #365)
7424 // -----------------------------------------------------------------------
7425
7426 /// The issue: resolution opens each component in turn to look the next one
7427 /// up inside it, and reported a component that is not a group as
7428 /// `PathNotFound("object header is not a group")` — one string for every
7429 /// such path, naming no component at all. It read as "this path does not
7430 /// exist" where the truth was "`plain` is a dataset".
7431 ///
7432 /// It is now the same [`Error::NotAGroup`] a *final* component that is not a
7433 /// group returns (issue #352), so one match covers a path that goes wrong
7434 /// anywhere along it, and it names the object that stopped the walk rather
7435 /// than the path that was asked for.
7436 #[test]
7437 fn a_path_through_a_non_group_names_the_object_that_stopped_it() {
7438 let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7439
7440 // Two entry points, because each resolves the path for itself, and two
7441 // depths, because the name is the whole prefix walked rather than the
7442 // one component: `g/inner` is a path the caller can go and open, where a
7443 // bare `inner` would not say where to find it.
7444 for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7445 for (entry, got) in [
7446 ("File::group", file.group(asked).map(|_| ())),
7447 ("File::dataset", file.dataset(asked).map(|_| ())),
7448 ] {
7449 assert!(
7450 matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7451 "{entry}({asked:?}) answered {got:?}, expected the stop at {stopper:?}"
7452 );
7453 }
7454 }
7455
7456 // A component that names nothing at all stays a `PathNotFound` naming
7457 // it. The two are different facts, and reading differently is the whole
7458 // point of the change.
7459 assert!(matches!(
7460 file.group("absent/sub"),
7461 Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7462 ));
7463
7464 // Empty components are dropped before the walk, so the object is named
7465 // the same way however the path was spelled.
7466 assert!(matches!(
7467 file.group("/plain//sub/"),
7468 Err(Error::NotAGroup(ref p)) if p == "plain"
7469 ));
7470
7471 // The name reaches a caller that only prints the error, too.
7472 let printed = file
7473 .group("g/inner/deeper")
7474 .map(|_| ())
7475 .unwrap_err()
7476 .to_string();
7477 assert!(printed.contains("g/inner"), "the message read {printed:?}");
7478
7479 // And a path that really does run through groups still resolves, so the
7480 // classification has not turned the walk itself into a refusal.
7481 assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7482
7483 // A committed datatype is neither a dataset nor a group, so it separates
7484 // "is not a group" from "is a dataset" for an intermediate component the
7485 // way it does for a final one.
7486 let mut b = FileBuilder::new();
7487 b.commit_datatype("mytype", crate::make_i32_type());
7488 let typed = File::from_bytes(b.finish().unwrap()).unwrap();
7489 assert!(matches!(
7490 typed.group("mytype/sub"),
7491 Err(Error::NotAGroup(ref p)) if p == "mytype"
7492 ));
7493 }
7494
7495 /// The streaming walk is a second copy of the same loop, reading each header
7496 /// from a `Source`. A fix applied to one and not the other would leave the
7497 /// backend that exists for files too large to buffer reporting the old
7498 /// string.
7499 #[test]
7500 fn the_streaming_walk_names_the_object_that_stopped_it_too() {
7501 let dir = tempfile::tempdir().unwrap();
7502 let path = dir.path().join("nested.h5");
7503 std::fs::write(&path, nested_dataset_bytes()).unwrap();
7504
7505 let file = File::open_streaming(&path).unwrap();
7506 for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7507 let got = file.group(asked).map(|_| ());
7508 assert!(
7509 matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7510 "group({asked:?}) answered {got:?}"
7511 );
7512 }
7513 assert!(matches!(
7514 file.group("absent/sub"),
7515 Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7516 ));
7517 assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7518 // Before the directory goes: an open file blocks its removal on Windows.
7519 drop(file);
7520 }
7521
7522 /// The root is the one group handle nothing classifies: `File::root` takes
7523 /// the address from the superblock, and no open validates that it names a
7524 /// group. So a file whose superblock points the root at a dataset reaches
7525 /// the refusal in `Group::child_address` that every other handle is kept
7526 /// away from — and names the root by the empty path it carries.
7527 #[test]
7528 fn a_root_that_is_not_a_group_refuses_rather_than_being_searched() {
7529 let mut bytes = nested_dataset_bytes();
7530 let sig = crate::signature::find_signature(&bytes).unwrap();
7531 let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
7532 // The fixture has no userblock, so its base address is zero and the
7533 // absolute address a walk returns is also the stored one the superblock
7534 // field wants.
7535 assert_eq!(sb.base_address, BaseAddress::ZERO);
7536 sb.root_group_address = group_v2::resolve_path_any(&bytes, &sb, "plain").unwrap();
7537 let rewritten = sb.serialize();
7538 bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
7539
7540 let file = File::from_bytes(bytes).unwrap();
7541 let got = file.root().dataset("anything").map(|_| ());
7542 assert!(
7543 matches!(&got, Err(Error::NotAGroup(p)) if p.is_empty()),
7544 "a root that is not a group must refuse, got {got:?}"
7545 );
7546 }
7547
7548 /// Read everything a read-write file can serve through the paired read
7549 /// paths, as comparable text.
7550 ///
7551 /// Each entry exercises a different `with_engine` call site: path
7552 /// resolution, object-header parsing, group listing, attribute reads (both
7553 /// the compact and the dense form), a whole-dataset read, and a row-range
7554 /// read. Errors are formatted rather than unwrapped so that a *divergence in
7555 /// which error* is reported also fails the comparison.
7556 fn read_everything(file: &File) -> Vec<String> {
7557 let mut out = Vec::new();
7558 out.push(format!("root groups: {:?}", file.root().groups()));
7559 out.push(format!("root datasets: {:?}", file.root().datasets()));
7560 out.push(format!("root attrs: {:?}", sorted(file.root().attrs())));
7561
7562 // `plain/nope` runs the walk through a dataset (issue #365): the two
7563 // backends must refuse it with the same error as well as agree on the
7564 // reads that succeed.
7565 for path in [
7566 "plain",
7567 "g/nested",
7568 "many_attrs",
7569 "missing",
7570 "g/missing",
7571 "plain/nope",
7572 ] {
7573 match file.dataset(path) {
7574 Ok(ds) => {
7575 out.push(format!("{path}: shape {:?}", ds.shape()));
7576 out.push(format!("{path}: attrs {:?}", sorted(ds.attrs())));
7577 out.push(format!("{path}: all {:?}", ds.read_i32()));
7578 out.push(format!("{path}: rows {:?}", ds.read_i32_rows(1, 2)));
7579 out.push(format!("{path}: raw rows {:?}", ds.read_raw_rows(0, 1)));
7580 }
7581 Err(e) => out.push(format!("{path}: error {e}")),
7582 }
7583 }
7584 out
7585 }
7586
7587 /// Attribute maps compare only after ordering; `HashMap`'s `Debug` is not
7588 /// deterministic, and an ordering difference here would be noise rather
7589 /// than the divergence this is looking for.
7590 fn sorted(attrs: Result<HashMap<String, AttrValue>, Error>) -> Vec<String> {
7591 match attrs {
7592 Ok(map) => {
7593 let mut v: Vec<String> =
7594 map.iter().map(|(k, val)| format!("{k}={val:?}")).collect();
7595 v.sort();
7596 v
7597 }
7598 Err(e) => vec![format!("error {e}")],
7599 }
7600 }
7601
7602 /// Drive `bytes` down both forms of every read a read-write file serves and
7603 /// require identical answers.
7604 ///
7605 /// Every read has a slice form (walking the whole-file mirror) and a
7606 /// `Source` form, and until a mirrorless backing lands (issue #198) only the
7607 /// slice form ever runs. This makes the other form reachable now, so it
7608 /// cannot quietly drift as its twin is edited.
7609 fn assert_both_read_paths_agree(bytes: &[u8], what: &str) {
7610 let dir = tempfile::tempdir().unwrap();
7611 let path = dir.path().join("both.h5");
7612 std::fs::write(&path, bytes).unwrap();
7613
7614 // One session at a time: `open_rw` takes an exclusive lock, and holding
7615 // two over one path fails outright where OS locks are mandatory.
7616 let via_mirror = {
7617 let f = File::open_rw(&path).unwrap();
7618 read_everything(&f)
7619 };
7620 let via_source = {
7621 let f = File::open_rw_source_only(&path).unwrap();
7622 read_everything(&f)
7623 };
7624
7625 assert_eq!(
7626 via_mirror.len(),
7627 via_source.len(),
7628 "{what}: the two read paths produced different numbers of results"
7629 );
7630 for (m, s) in via_mirror.iter().zip(&via_source) {
7631 assert_eq!(m, s, "{what}: slice and Source read paths disagree");
7632 }
7633 // Guard the guard: a helper that read nothing would make the comparison
7634 // vacuous, and a file whose datasets all failed to open would too.
7635 assert!(
7636 via_mirror.iter().any(|r| r.contains("all Ok(")),
7637 "{what}: no dataset read succeeded, so this compared nothing"
7638 );
7639 }
7640
7641 /// A file exercising each paired read: a plain dataset, a nested one behind
7642 /// a group (path resolution), and one carrying enough attributes to force
7643 /// the dense (fractal-heap) attribute layout rather than compact messages.
7644 fn both_paths_file_bytes(userblock: Option<u64>) -> Vec<u8> {
7645 let mut b = FileBuilder::new();
7646 if let Some(ub) = userblock {
7647 b.with_userblock(ub);
7648 }
7649 b.create_dataset("plain")
7650 .with_i32_data(&(0..24).collect::<Vec<i32>>())
7651 .with_shape(&[6, 4])
7652 .set_attr("units", AttrValue::String("m".into()));
7653 // Well past the eight-attribute compact limit, so the header converts to
7654 // the dense layout and the dense extraction path is the one that runs.
7655 {
7656 let ds = b
7657 .create_dataset("many_attrs")
7658 .with_i32_data(&(0..8).collect::<Vec<i32>>());
7659 for i in 0..24 {
7660 ds.set_attr(&format!("attr_{i:02}"), AttrValue::I64(i));
7661 }
7662 }
7663 let mut g = b.create_group("g");
7664 g.create_dataset("nested")
7665 .with_i32_data(&(100..112).collect::<Vec<i32>>())
7666 .with_shape(&[3, 4]);
7667 b.add_group(g.finish());
7668 b.finish().unwrap()
7669 }
7670
7671 #[test]
7672 fn both_read_paths_agree() {
7673 assert_both_read_paths_agree(&both_paths_file_bytes(None), "no userblock");
7674 }
7675
7676 /// The userblock case is the one where the two forms are built differently:
7677 /// the slice form reframes by slicing at the base address, the `Source` form
7678 /// wraps in a `BaseOffsetSource`. A file with a nonzero base is the only way
7679 /// to compare them.
7680 #[test]
7681 fn both_read_paths_agree_with_a_userblock() {
7682 assert_both_read_paths_agree(&both_paths_file_bytes(Some(512)), "512-byte userblock");
7683 }
7684
7685 /// One 256-element i32 dataset, chunked into 32-element chunks, in memory.
7686 fn chunked_file_bytes() -> Vec<u8> {
7687 let data: Vec<i32> = (0..256).collect();
7688 let mut b = FileBuilder::new();
7689 b.create_dataset("chunked")
7690 .with_i32_data(&data)
7691 .with_shape(&[256])
7692 .with_chunks(&[32]);
7693 b.finish().unwrap()
7694 }
7695
7696 // The DAPL override must drive the *live* `ChunkCache`, not merely the value
7697 // reported by `chunk_cache_config()`. These assertions reach the crate's
7698 // `#[cfg(test)]` cache introspection (unavailable to integration tests), so
7699 // they fail if the resolved config ever stops flowing into the real cache.
7700
7701 #[test]
7702 fn enabled_override_populates_live_cache_over_disabled_file_default() {
7703 let file = File::from_bytes_with_options(
7704 chunked_file_bytes(),
7705 FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
7706 )
7707 .unwrap();
7708
7709 let ds = file
7710 .dataset_with_options(
7711 "chunked",
7712 DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
7713 )
7714 .unwrap();
7715 assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
7716
7717 // The enabled override built the chunk index and retained chunks; the
7718 // disabled file default would have left both empty.
7719 assert!(ds.chunk_cache_stats().index_loaded());
7720 assert!(ds.chunk_cache_stats().cached_chunks() > 0);
7721 }
7722
7723 #[test]
7724 fn disabled_override_suppresses_live_cache_over_enabled_file_default() {
7725 let file = File::from_bytes_with_options(
7726 chunked_file_bytes(),
7727 FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
7728 )
7729 .unwrap();
7730
7731 let ds = file
7732 .dataset_with_options(
7733 "chunked",
7734 DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
7735 )
7736 .unwrap();
7737 assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
7738
7739 // The disabled override suppressed the index and chunk retention; the
7740 // enabled file default would have populated both.
7741 assert!(!ds.chunk_cache_stats().index_loaded());
7742 assert_eq!(ds.chunk_cache_stats().cached_chunks(), 0);
7743 }
7744
7745 /// A group child whose stored (base-relative) object-header address overflows
7746 /// `u64` once the base address is added must be rejected, not wrapped or
7747 /// panicked on. Reaching this needs a nonzero base address, so the file
7748 /// carries a userblock; the child link's stored address is then rewritten to
7749 /// `HADDR_UNDEF` (all ones) so `group_children`'s normalization overflows.
7750 #[test]
7751 fn group_child_address_base_overflow_is_rejected() {
7752 const UB: u64 = 512;
7753 let mut b = FileBuilder::new();
7754 b.with_userblock(UB);
7755 let mut child = b.create_group("child");
7756 child.create_dataset("inner").with_i32_data(&[1, 2, 3]);
7757 b.add_group(child.finish());
7758 let mut bytes = b.finish().unwrap();
7759
7760 // Baseline: the file reads and the subgroup is listed.
7761 let file = File::from_bytes(bytes.clone()).unwrap();
7762 assert_eq!(file.root().groups().unwrap(), vec!["child".to_string()]);
7763
7764 // Rewrite the child's stored object-header address to HADDR_UNDEF. It is
7765 // stored base-relative (absolute minus the userblock base) and, for this
7766 // single-child file, appears exactly once in the bytes. The link lives in
7767 // the root object header's chunk-0.
7768 let stored = file
7769 .root()
7770 .group("child")
7771 .unwrap()
7772 .header_address()
7773 .unwrap()
7774 - UB;
7775 let needle = stored.to_le_bytes();
7776 let matches: Vec<usize> = bytes
7777 .windows(8)
7778 .enumerate()
7779 .filter(|(_, w)| *w == needle)
7780 .map(|(i, _)| i)
7781 .collect();
7782 assert_eq!(
7783 matches.len(),
7784 1,
7785 "stored child address {stored:#x} was not uniquely locatable: {matches:?}"
7786 );
7787 bytes[matches[0]..matches[0] + 8].copy_from_slice(&u64::MAX.to_le_bytes());
7788
7789 // The v2 object header is checksum-protected, so a real crafted file would
7790 // carry a matching checksum; recompute the root header's over the edited
7791 // bytes so parsing reaches the address normalization rather than failing on
7792 // the checksum first. Mirrors the chunk-0 extent from `parse_v2`.
7793 #[cfg(feature = "checksum")]
7794 {
7795 let root_addr = file.root().header_address().unwrap() as usize;
7796 assert_eq!(&bytes[root_addr..root_addr + 4], b"OHDR");
7797 let flags = bytes[root_addr + 5];
7798 let mut pos = root_addr + 6;
7799 if flags & 0x20 != 0 {
7800 pos += 16;
7801 }
7802 if flags & 0x10 != 0 {
7803 pos += 4;
7804 }
7805 let width = 1usize << (flags & 0x03);
7806 let chunk0 = (0..width).fold(0usize, |acc, i| {
7807 acc | ((bytes[pos + i] as usize) << (8 * i))
7808 });
7809 pos += width;
7810 let chunk0_end = pos + chunk0;
7811 assert!(
7812 matches[0] < chunk0_end,
7813 "patched link address is outside the root header's chunk-0"
7814 );
7815 let cs = crate::checksum::jenkins_lookup3(&bytes[root_addr..chunk0_end]);
7816 bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
7817 }
7818
7819 // Iterating the root now normalizes `u64::MAX + base` and must surface the
7820 // overflow as a format error rather than panicking or wrapping.
7821 let file = File::from_bytes(bytes).unwrap();
7822 match file.root().groups() {
7823 Err(Error::Format(FormatError::OffsetOverflow { offset, length })) => {
7824 assert_eq!(offset, u64::MAX);
7825 assert_eq!(length, UB);
7826 }
7827 other => panic!("expected group-child address overflow, got {other:?}"),
7828 }
7829 }
7830
7831 /// Recompute a version-2 object header's checksum over its chunk 0, after a
7832 /// test has edited a message inside it.
7833 ///
7834 /// A crafted file a reader must survive carries a *valid* checksum — an
7835 /// attacker recomputes it — so a test that edits a header and leaves the old
7836 /// one measures the checksum rather than the thing it meant to.
7837 #[cfg(feature = "checksum")]
7838 fn refresh_v2_header_checksum(bytes: &mut [u8], header_addr: usize) -> std::ops::Range<usize> {
7839 assert_eq!(&bytes[header_addr..header_addr + 4], b"OHDR");
7840 let flags = bytes[header_addr + 5];
7841 let mut pos = header_addr + 6;
7842 if flags & 0x20 != 0 {
7843 pos += 16;
7844 }
7845 if flags & 0x10 != 0 {
7846 pos += 4;
7847 }
7848 let width = 1usize << (flags & 0x03);
7849 let chunk0 = (0..width).fold(0usize, |acc, i| {
7850 acc | ((bytes[pos + i] as usize) << (8 * i))
7851 });
7852 pos += width;
7853 let chunk0_end = pos + chunk0;
7854 let cs = crate::checksum::jenkins_lookup3(&bytes[header_addr..chunk0_end]);
7855 bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
7856 header_addr..chunk0_end
7857 }
7858
7859 /// A contiguous layout whose declared size disagrees with its dataspace is
7860 /// refused, whatever the dataset's size and whichever read asks.
7861 ///
7862 /// The whole-dataset readers have always refused it. A typed read now takes a
7863 /// large dataset a row window at a time, and a window only ever checks that
7864 /// its *own* rows are inside the declared storage — so without the shared
7865 /// check this refusal would have applied to small datasets, which are still
7866 /// read whole, and not to large ones. A validation that fires depending on
7867 /// the size of the input is the kind that surfaces years later as an
7868 /// inconsistent bug report, which is why both sizes are here.
7869 #[cfg(feature = "checksum")]
7870 #[test]
7871 fn a_layout_size_disagreeing_with_the_dataspace_is_refused_at_every_dataset_size() {
7872 // One dataset below the typed read's window budget and read whole; one
7873 // above it and swept.
7874 for n in [1000usize, 200_000] {
7875 let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
7876 let mut b = crate::writer::FileBuilder::new();
7877 b.create_dataset("t")
7878 .with_f64_data(&data)
7879 .with_shape(&[n as u64]);
7880 let mut bytes = b.finish().unwrap();
7881 // Taken before the edit: an edited header fails its checksum, and the
7882 // address is needed to recompute it.
7883 let header_addr = {
7884 let file = File::from_bytes(bytes.clone()).unwrap();
7885 file.dataset("t").unwrap().header_address().unwrap() as usize
7886 };
7887
7888 // The layout message's size field: the dataset's byte length, which
7889 // appears once in the file. The assertion is the fixture's own guard —
7890 // patching some other field would test nothing in particular.
7891 let declared = (n * 8) as u64;
7892 let needle = declared.to_le_bytes();
7893 let at: Vec<usize> = bytes
7894 .windows(8)
7895 .enumerate()
7896 .filter(|(_, w)| *w == needle)
7897 .map(|(i, _)| i)
7898 .collect();
7899 assert_eq!(
7900 at.len(),
7901 1,
7902 "the stored size {declared} was not uniquely locatable in a {n}-element file: {at:?}"
7903 );
7904 bytes[at[0]..at[0] + 8].copy_from_slice(&(declared * 2).to_le_bytes());
7905
7906 let chunk0 = refresh_v2_header_checksum(&mut bytes, header_addr);
7907 assert!(
7908 chunk0.contains(&at[0]),
7909 "the patched size is outside the header chunk whose checksum was refreshed"
7910 );
7911
7912 let file = File::from_bytes(bytes).unwrap();
7913 let ds = file.dataset("t").unwrap();
7914 let expected = FormatError::DataSizeMismatch {
7915 expected: n * 8,
7916 actual: n * 16,
7917 };
7918 for (what, err) in [
7919 ("read_raw", ds.read_raw().unwrap_err()),
7920 ("read_f64", ds.read_f64().unwrap_err()),
7921 ("read_i32", ds.read_i32().unwrap_err()),
7922 ] {
7923 match err {
7924 Error::Format(got) => assert_eq!(
7925 format!("{got:?}"),
7926 format!("{expected:?}"),
7927 "{what} over {n} elements reported the wrong mismatch"
7928 ),
7929 other => {
7930 panic!("{what} over {n} elements: expected a format error, got {other:?}")
7931 }
7932 }
7933 }
7934 }
7935 }
7936
7937 /// A zero-row window returns `Ok(empty)` uniformly across layouts, including
7938 /// over unallocated storage. Unallocated storage now reads as the fill value
7939 /// rather than erroring, so this no longer guards a cross-layout divergence
7940 /// in the error; what it still pins is that a window of no rows is an *empty*
7941 /// buffer and not a zero-length fill, which is what a caller iterating past
7942 /// the end of a dataset sees.
7943 #[test]
7944 fn read_rows_framed_zero_row_window_is_ok_even_when_unallocated() {
7945 let dl = DataLayout::Contiguous {
7946 address: None,
7947 size: 0,
7948 };
7949 let ds = Dataspace {
7950 space_type: crate::dataspace::DataspaceType::Simple,
7951 rank: 1,
7952 dimensions: vec![0],
7953 max_dimensions: None,
7954 };
7955 let dt = Datatype::FixedPoint {
7956 size: 8,
7957 byte_order: crate::datatype::DatatypeByteOrder::LittleEndian,
7958 signed: false,
7959 bit_offset: 0,
7960 bit_precision: 64,
7961 };
7962 let cache = ChunkCache::new();
7963 let out = read_rows_framed(
7964 &BytesSource::new(b""),
7965 RawReadSpec::plain(&dl, &ds, &dt),
7966 8,
7967 8,
7968 &cache,
7969 CachePass::LRU,
7970 0,
7971 0,
7972 8,
7973 )
7974 .expect("a zero-row window must be Ok(empty)");
7975 assert!(out.is_empty());
7976
7977 // A Virtual layout is unsupported and must still error for a zero-row
7978 // window, matching `read_raw`, rather than being swallowed by the early
7979 // return.
7980 let virtual_dl = DataLayout::Virtual { version: 4 };
7981 let err = read_rows_framed(
7982 &BytesSource::new(b""),
7983 RawReadSpec::plain(&virtual_dl, &ds, &dt),
7984 8,
7985 8,
7986 &cache,
7987 CachePass::LRU,
7988 0,
7989 0,
7990 8,
7991 )
7992 .expect_err("a virtual layout must error even for a zero-row window");
7993 assert!(
7994 matches!(err, FormatError::UnsupportedVirtualLayout),
7995 "expected UnsupportedVirtualLayout, got {err:?}"
7996 );
7997 }
7998
7999 /// The window a typed whole-dataset read sweeps in is a budget in *stored
8000 /// bytes* resolved against the dataset's own geometry, and a chunked dataset
8001 /// adds a second rule on top: whole chunk bands.
8002 ///
8003 /// A window that ended mid-band would make the next window decode the band
8004 /// again — the cost windowing exists to avoid — so the budget is rounded down
8005 /// to a multiple of the band, and *up* to one whole band when even one band
8006 /// is over budget. The three ways a window can come out are one rule with
8007 /// different inputs, which is why they are asserted together.
8008 #[test]
8009 fn a_typed_read_windows_in_whole_chunk_bands() {
8010 const BUDGET: u64 = TYPED_READ_WINDOW_BYTES;
8011 let ds1 = |dims: &[u64]| Dataspace {
8012 space_type: crate::dataspace::DataspaceType::Simple,
8013 rank: dims.len() as u8,
8014 dimensions: dims.to_vec(),
8015 max_dimensions: None,
8016 };
8017 let contiguous = DataLayout::Contiguous {
8018 address: Some(0),
8019 size: 0,
8020 };
8021 let chunked = |band: u32| DataLayout::Chunked {
8022 chunk_dimensions: vec![band, 8],
8023 btree_address: Some(0),
8024 version: 3,
8025 chunk_index_type: None,
8026 single_chunk_filtered_size: None,
8027 single_chunk_filter_mask: None,
8028 };
8029 let elem = NonZeroUsize::new(8).unwrap();
8030
8031 // Unchunked: the budget, divided by the row width, exactly.
8032 assert_eq!(
8033 typed_window_rows(&contiguous, &ds1(&[1 << 20]), elem)
8034 .unwrap()
8035 .get(),
8036 BUDGET / 8
8037 );
8038
8039 // A band that divides the budget takes it unchanged; one that does not
8040 // is rounded down to a whole number of bands, never up.
8041 assert_eq!(
8042 typed_window_rows(&chunked(512), &ds1(&[1 << 20]), elem)
8043 .unwrap()
8044 .get(),
8045 BUDGET / 8
8046 );
8047 let rows = typed_window_rows(&chunked(300), &ds1(&[1 << 20]), elem)
8048 .unwrap()
8049 .get();
8050 assert_eq!(rows % 300, 0, "a window must end on a chunk band");
8051 assert!(
8052 rows <= BUDGET / 8 && rows > BUDGET / 8 - 300,
8053 "a window must be the largest whole number of bands within the \
8054 budget, not a smaller one: {rows} rows against {} in budget",
8055 BUDGET / 8
8056 );
8057
8058 // One band over budget: the window is that band, since a narrower one
8059 // would decode it twice.
8060 assert_eq!(
8061 typed_window_rows(&chunked(1 << 20), &ds1(&[1 << 21]), elem)
8062 .unwrap()
8063 .get(),
8064 1 << 20
8065 );
8066
8067 // Rows wider than the whole budget: one row, which is the least a window
8068 // can be and still make progress.
8069 assert_eq!(
8070 typed_window_rows(&contiguous, &ds1(&[4, 1 << 20]), elem)
8071 .unwrap()
8072 .get(),
8073 1
8074 );
8075
8076 // Rank 0. A chunked layout message carries rank + 1 dimensions, so a
8077 // scalar's only entry is the element-size trailer and the "band" read
8078 // out of it is not a band at all. Nothing rests on it: a scalar has one
8079 // row, so `n0 <= rows` sends it to the whole read whatever this says.
8080 // Asserted so that a later reading of `first()` as the leading extent
8081 // has to account for this case rather than discover it.
8082 let scalar = Dataspace {
8083 space_type: crate::dataspace::DataspaceType::Scalar,
8084 rank: 0,
8085 dimensions: Vec::new(),
8086 max_dimensions: None,
8087 };
8088 assert!(typed_window_rows(&chunked(8), &scalar, elem).unwrap().get() >= 1);
8089
8090 // A zero inner dimension makes a row zero bytes wide, and the dataset
8091 // has no elements at all: one window covers it, and nothing divides by
8092 // zero on the way there.
8093 assert_eq!(
8094 typed_window_rows(&contiguous, &ds1(&[4, 0]), elem)
8095 .unwrap()
8096 .get(),
8097 u64::MAX
8098 );
8099 }
8100
8101 /// The two channels a caller has for one object's attributes.
8102 struct AttrChannels {
8103 owner: &'static str,
8104 /// What `attrs` decoded — the values, lossily.
8105 values: HashMap<String, AttrValue>,
8106 /// What `attr_datatypes` reported — the encodings, exactly.
8107 datatypes: HashMap<String, Datatype>,
8108 }
8109
8110 /// One written file: its bytes, and both channels read back from each owner.
8111 struct AttrFile {
8112 /// The file as written, so a test can assert which storage form it got.
8113 bytes: Vec<u8>,
8114 owners: Vec<AttrChannels>,
8115 }
8116
8117 /// Put the same attributes on the root group and on a dataset, write the
8118 /// file, and read both channels back from each owner.
8119 ///
8120 /// Both owners, because `Group` and `Dataset` reach their attribute messages
8121 /// by different routes: one parses an object header by address, the other
8122 /// already holds one.
8123 fn attr_channels(
8124 values: &[(&str, AttrValue)],
8125 verbatim: &[crate::attribute::AttributeMessage],
8126 ) -> AttrFile {
8127 let mut b = FileBuilder::new();
8128 for (name, value) in values {
8129 b.set_attr(name, value.clone());
8130 }
8131 for message in verbatim {
8132 b.set_attr_verbatim(message.clone());
8133 }
8134 {
8135 let ds = b.create_dataset("data").with_f64_data(&[1.0]);
8136 for (name, value) in values {
8137 ds.set_attr(name, value.clone());
8138 }
8139 for message in verbatim {
8140 ds.set_attr_verbatim(message.clone());
8141 }
8142 }
8143 let bytes = b.finish().unwrap();
8144 let file = File::from_bytes(bytes.clone()).unwrap();
8145 let root = file.root();
8146 let dataset = file.dataset("data").unwrap();
8147 AttrFile {
8148 bytes,
8149 owners: vec![
8150 AttrChannels {
8151 owner: "root group",
8152 values: root.attrs().unwrap(),
8153 datatypes: root.attr_datatypes().unwrap(),
8154 },
8155 AttrChannels {
8156 owner: "dataset",
8157 values: dataset.attrs().unwrap(),
8158 datatypes: dataset.attr_datatypes().unwrap(),
8159 },
8160 ],
8161 }
8162 }
8163
8164 /// The two channels on the axes each one carries.
8165 ///
8166 /// Both now report an integer's width: the value channel keeps it (#350),
8167 /// so `count` is `I32` and its datatype is the 4-byte signed type it is
8168 /// stored as. What the value channel still cannot say is how those bytes are
8169 /// laid out — `be` holds the same number big-endian and decodes to the same
8170 /// `I32`, so the datatype channel is the only record that re-encoding from
8171 /// the value would flip its byte order. `AttrValue` is documented as lossy;
8172 /// this is where the loss is (#248).
8173 #[test]
8174 fn attr_datatypes_reports_the_byte_order_attrs_normalizes() {
8175 let be = crate::attribute::AttributeMessage {
8176 name: "be".into(),
8177 datatype: Datatype::FixedPoint {
8178 size: 4,
8179 byte_order: crate::datatype::DatatypeByteOrder::BigEndian,
8180 signed: true,
8181 bit_offset: 0,
8182 bit_precision: 32,
8183 },
8184 dataspace: Dataspace {
8185 space_type: crate::dataspace::DataspaceType::Scalar,
8186 rank: 0,
8187 dimensions: vec![],
8188 max_dimensions: None,
8189 },
8190 raw_data: (-7i32).to_be_bytes().to_vec(),
8191 datatype_location: crate::shared_message::DatatypeLocation::Inline,
8192 };
8193
8194 for c in attr_channels(&[("count", AttrValue::I32(-7))], std::slice::from_ref(&be)).owners {
8195 assert_eq!(
8196 c.values.get("count"),
8197 Some(&AttrValue::I32(-7)),
8198 "{}: the value channel keeps the width the attribute was written at",
8199 c.owner
8200 );
8201 let Some(Datatype::FixedPoint { size, signed, .. }) = c.datatypes.get("count") else {
8202 panic!(
8203 "{}: expected a fixed-point datatype, got {:?}",
8204 c.owner,
8205 c.datatypes.get("count")
8206 );
8207 };
8208 assert_eq!(
8209 (*size, *signed),
8210 (4, true),
8211 "{}: the datatype channel must report the width on disk",
8212 c.owner
8213 );
8214
8215 assert_eq!(
8216 c.values.get("be"),
8217 Some(&AttrValue::I32(-7)),
8218 "{}: a big-endian attribute decodes to the value it holds",
8219 c.owner
8220 );
8221 let Some(Datatype::FixedPoint { byte_order, .. }) = c.datatypes.get("be") else {
8222 panic!(
8223 "{}: expected a fixed-point datatype, got {:?}",
8224 c.owner,
8225 c.datatypes.get("be")
8226 );
8227 };
8228 assert_eq!(
8229 *byte_order,
8230 crate::datatype::DatatypeByteOrder::BigEndian,
8231 "{}: the datatype channel is the only record of the byte order",
8232 c.owner
8233 );
8234 }
8235 }
8236
8237 /// Every attribute message is reported, including one `attrs` drops because
8238 /// no `AttrValue` can carry it.
8239 ///
8240 /// That is what lets a caller tell a dropped attribute from an absent one. An
8241 /// omission with nothing to compare against is invisible, which is how every
8242 /// `np.bool_` attribute in an h5py file went missing without a trace (#248).
8243 #[test]
8244 fn attr_datatypes_reports_an_attribute_attrs_omits() {
8245 let opaque = Datatype::Opaque {
8246 size: 3,
8247 tag: b"rgb".to_vec(),
8248 };
8249 let raw = crate::attribute::AttributeMessage {
8250 name: "raw".into(),
8251 datatype: opaque.clone(),
8252 dataspace: Dataspace {
8253 space_type: crate::dataspace::DataspaceType::Scalar,
8254 rank: 0,
8255 dimensions: vec![],
8256 max_dimensions: None,
8257 },
8258 raw_data: vec![1, 2, 3],
8259 datatype_location: crate::shared_message::DatatypeLocation::Inline,
8260 };
8261
8262 for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&raw)).owners {
8263 assert!(
8264 !c.values.contains_key("raw"),
8265 "{}: an opaque attribute has no `AttrValue`, so `attrs` omits it — \
8266 if that changes, this test is measuring the wrong thing",
8267 c.owner
8268 );
8269 assert_eq!(
8270 c.datatypes.get("raw"),
8271 Some(&opaque),
8272 "{}: the datatype channel must report an attribute `attrs` omits",
8273 c.owner
8274 );
8275 // Specific to the one attribute: a channel that reported only the
8276 // undecodable one, or dropped its neighbour, would pass the above.
8277 assert!(
8278 c.values.contains_key("count") && c.datatypes.contains_key("count"),
8279 "{}: the attribute beside it must appear in both channels",
8280 c.owner
8281 );
8282 }
8283 }
8284
8285 /// The refusal reaches a dataset too, not only the attribute the issue was
8286 /// reported through: the same decoders back `Dataset::read_*`, by both the
8287 /// whole-dataset and the windowed route.
8288 #[test]
8289 fn a_wide_dataset_is_refused_by_the_typed_readers() {
8290 let dt = Datatype::FixedPoint {
8291 size: 16,
8292 byte_order: crate::datatype::DatatypeByteOrder::LittleEndian,
8293 signed: false,
8294 bit_offset: 0,
8295 bit_precision: 128,
8296 };
8297 let mut b = FileBuilder::new();
8298 b.create_dataset("wide")
8299 .with_raw_data(dt.clone(), vec![0xFF; 32], 2);
8300 let file = File::from_bytes(b.finish().unwrap()).unwrap();
8301 let ds = file.dataset("wide").unwrap();
8302
8303 assert_eq!(
8304 ds.datatype().unwrap(),
8305 dt,
8306 "the datatype still reads: it is the values that have no answer"
8307 );
8308 assert!(matches!(
8309 ds.read_u64(),
8310 Err(Error::Format(FormatError::NumericElementTooWide {
8311 size: 16
8312 }))
8313 ));
8314 assert!(matches!(
8315 ds.read_u64_rows(0, 1),
8316 Err(Error::Format(FormatError::NumericElementTooWide {
8317 size: 16
8318 }))
8319 ));
8320 // What is refused is the decode, not the data: the bytes are still
8321 // there for a caller willing to read the width itself.
8322 assert_eq!(ds.read_raw().unwrap().len(), 32);
8323 }
8324
8325 /// A fixed-point attribute wider than the 64-bit value the readers decode
8326 /// into joins the attributes `attrs` omits, rather than appearing there
8327 /// holding part of its value.
8328 ///
8329 /// This one used to be *present* in the values channel: nine bytes holding
8330 /// 2^64 read back as `U64(0)`, a value indistinguishable from an attribute
8331 /// that really holds zero. Omitting it puts it where the opaque attribute
8332 /// above already sits — absent from the values, reported in full by the
8333 /// datatypes — so a caller can see that something was dropped (#361).
8334 #[test]
8335 fn an_attribute_too_wide_to_decode_is_omitted_rather_than_truncated() {
8336 let wide = Datatype::FixedPoint {
8337 size: 9,
8338 byte_order: crate::datatype::DatatypeByteOrder::LittleEndian,
8339 signed: false,
8340 bit_offset: 0,
8341 bit_precision: 72,
8342 };
8343 // 2^64 exactly, so every one of the low 64 bits is zero.
8344 let mut raw_data = vec![0u8; 9];
8345 raw_data[8] = 1;
8346 let huge = crate::attribute::AttributeMessage {
8347 name: "huge".into(),
8348 datatype: wide.clone(),
8349 dataspace: Dataspace {
8350 space_type: crate::dataspace::DataspaceType::Scalar,
8351 rank: 0,
8352 dimensions: vec![],
8353 max_dimensions: None,
8354 },
8355 raw_data,
8356 datatype_location: crate::shared_message::DatatypeLocation::Inline,
8357 };
8358
8359 for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&huge)).owners
8360 {
8361 assert!(
8362 !c.values.contains_key("huge"),
8363 "{}: decoding this attribute would report 0 for a value of 2^64",
8364 c.owner
8365 );
8366 assert_eq!(
8367 c.datatypes.get("huge"),
8368 Some(&wide),
8369 "{}: the datatype channel must still report the width on disk",
8370 c.owner
8371 );
8372 assert!(
8373 c.values.contains_key("count") && c.datatypes.contains_key("count"),
8374 "{}: the attribute beside it must still decode",
8375 c.owner
8376 );
8377 }
8378 }
8379
8380 /// The channel must cover dense (fractal-heap) attribute storage, not only
8381 /// the compact form that lives in the object header.
8382 ///
8383 /// The two tests above use three attributes, which is well inside the
8384 /// writer's compact threshold, so on their own they leave every dense path
8385 /// unpinned: an implementation that walked `hdr.messages` directly and never
8386 /// touched the heap would pass both and report *nothing* for a real file with
8387 /// many attributes. Twelve is past the threshold, and the heap signature is
8388 /// asserted so the fixture cannot quietly revert to compact storage and take
8389 /// the coverage with it.
8390 #[test]
8391 fn attr_datatypes_covers_dense_attribute_storage() {
8392 let names: Vec<String> = (0..12).map(|i| format!("a{i:02}")).collect();
8393 let values: Vec<(&str, AttrValue)> = names
8394 .iter()
8395 .enumerate()
8396 .map(|(i, n)| {
8397 (
8398 n.as_str(),
8399 #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
8400 AttrValue::I32(i as i32),
8401 )
8402 })
8403 .collect();
8404
8405 let f = attr_channels(&values, &[]);
8406 assert!(
8407 f.bytes.windows(4).any(|w| w == b"FRHP"),
8408 "the fixture must really use dense storage, or this test proves \
8409 nothing beyond the compact path the other tests already cover"
8410 );
8411
8412 for c in f.owners {
8413 assert_eq!(
8414 c.datatypes.len(),
8415 names.len(),
8416 "{}: every attribute in the heap must be reported, got {:?}",
8417 c.owner,
8418 c.datatypes.keys().collect::<Vec<_>>()
8419 );
8420 // The two channels must agree on which attributes exist: all of these
8421 // decode, so neither one has anything to omit here.
8422 let mut from_values: Vec<&String> = c.values.keys().collect();
8423 let mut from_types: Vec<&String> = c.datatypes.keys().collect();
8424 from_values.sort();
8425 from_types.sort();
8426 assert_eq!(
8427 from_values, from_types,
8428 "{}: the channels disagree",
8429 c.owner
8430 );
8431 for name in &names {
8432 assert!(
8433 matches!(
8434 c.datatypes.get(name),
8435 Some(Datatype::FixedPoint { size: 4, .. })
8436 ),
8437 "{}: {name} must keep its 4-byte width through the heap, got {:?}",
8438 c.owner,
8439 c.datatypes.get(name)
8440 );
8441 }
8442 }
8443 }
8444
8445 // -----------------------------------------------------------------------
8446 // Coalesced chunk reads (see `crate::chunk_span`)
8447 // -----------------------------------------------------------------------
8448
8449 /// A [`Source`] over a file image that counts what the reader asks the file
8450 /// for, so a test can assert read *volume* and not only the values returned.
8451 struct CountingSource {
8452 bytes: Vec<u8>,
8453 reads: Arc<AtomicUsize>,
8454 bytes_read: Arc<AtomicUsize>,
8455 }
8456
8457 impl Source for CountingSource {
8458 fn len(&self) -> u64 {
8459 self.bytes.len() as u64
8460 }
8461
8462 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
8463 self.reads.fetch_add(1, Ordering::Relaxed);
8464 self.bytes_read.fetch_add(buf.len(), Ordering::Relaxed);
8465 BytesSource::new(&self.bytes).read_at(offset, buf)
8466 }
8467 }
8468
8469 /// Counters for one measured read.
8470 #[derive(Default)]
8471 struct ReadCounts {
8472 reads: Arc<AtomicUsize>,
8473 bytes: Arc<AtomicUsize>,
8474 }
8475
8476 impl ReadCounts {
8477 /// Run `f` and report `(reads, bytes)` it cost.
8478 fn measure<R>(&self, f: impl FnOnce() -> R) -> (usize, usize) {
8479 let r0 = self.reads.load(Ordering::Relaxed);
8480 let b0 = self.bytes.load(Ordering::Relaxed);
8481 f();
8482 (
8483 self.reads.load(Ordering::Relaxed) - r0,
8484 self.bytes.load(Ordering::Relaxed) - b0,
8485 )
8486 }
8487 }
8488
8489 /// A streaming [`File`] over `bytes` whose reads are counted, built the way
8490 /// [`File::open_streaming_with_options`] builds one over a file handle.
8491 fn counting_streaming_file(
8492 bytes: Vec<u8>,
8493 counts: &ReadCounts,
8494 access: FileAccessProperties,
8495 ) -> File {
8496 File::from_source_with_options(
8497 CountingSource {
8498 bytes,
8499 reads: Arc::clone(&counts.reads),
8500 bytes_read: Arc::clone(&counts.bytes),
8501 },
8502 access,
8503 )
8504 .expect("open from source")
8505 }
8506
8507 /// An `n`-element f64 dataset in chunks of `chunk` elements.
8508 fn chunked_f64_file(n: usize, chunk: u64) -> (Vec<u8>, Vec<f64>) {
8509 let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
8510 let mut builder = FileBuilder::new();
8511 builder
8512 .create_dataset("d")
8513 .with_f64_data(&data)
8514 .with_shape(&[n as u64])
8515 .with_chunks(&[chunk]);
8516 (builder.finish().expect("write file"), data)
8517 }
8518
8519 /// One chunk per row is what a writer that appends as data arrives
8520 /// produces; the rows land next to each other, so the streaming reader must
8521 /// fetch them in a few spans rather than one read each.
8522 #[test]
8523 fn a_streaming_read_coalesces_a_run_of_small_chunks() {
8524 let n = 1024usize;
8525 let (bytes, data) = chunked_f64_file(n, 1);
8526 let counts = ReadCounts::default();
8527 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8528
8529 let mut got = Vec::new();
8530 let (reads, _) = counts.measure(|| got = file.dataset("d").unwrap().read_f64().unwrap());
8531 assert_eq!(got, data, "the coalesced read must return the same values");
8532
8533 // Reading each of the 1024 chunks on its own would cost at least that
8534 // many reads; the whole run plus its metadata fits in far fewer.
8535 assert!(
8536 reads < n / 8,
8537 "expected the {n} chunks to be coalesced into few reads, got {reads}"
8538 );
8539 }
8540
8541 /// A windowed read must coalesce only the chunks its window overlaps: a
8542 /// plan built over the dataset's whole chunk list would put the rows on
8543 /// either side of the window inside a span and read them for nothing.
8544 #[test]
8545 fn a_windowed_streaming_read_fetches_only_its_own_window() {
8546 let rows = 4096usize;
8547 let (bytes, data) = chunked_f64_file(rows, 4);
8548 let counts = ReadCounts::default();
8549 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8550 let ds = file.dataset("d").unwrap();
8551
8552 // The first window walks (and caches on this handle) the chunk index,
8553 // so measure the second: what it reads is the window's own chunks.
8554 assert_eq!(ds.read_f64_rows(0, 8).unwrap(), data[0..8]);
8555 let (_, window_bytes) =
8556 counts.measure(|| assert_eq!(ds.read_f64_rows(2048, 8).unwrap(), data[2048..2056]));
8557
8558 // Eight rows are two 32-byte chunks.
8559 assert!(
8560 window_bytes < 256,
8561 "an 8-row window read {window_bytes} bytes; it needs 64"
8562 );
8563 }
8564
8565 /// The read volume of a whole-dataset read must not depend on which read it
8566 /// is. The chunk list comes from the handle's cached index on every read
8567 /// after the first, and that index is a map: it yields no address order at
8568 /// all. A reader holding one coalesced span re-reads a whole span each time
8569 /// an unordered walk crosses back, so the second read of a dataset spanning
8570 /// more than one span cost two orders of magnitude more than the first.
8571 ///
8572 /// A regression here is probabilistic rather than certain — the map's order
8573 /// is seeded per process, and a run that happened to be sorted would pass —
8574 /// but with hundreds of chunks over two spans the chance of that is nil.
8575 /// Correct code passes deterministically.
8576 #[test]
8577 fn a_second_whole_read_costs_what_the_first_did() {
8578 // 512 KiB of f64 in 1 KiB chunks: more than one 256 KiB span, so an
8579 // unordered walk has somewhere to thrash between.
8580 let n = 64 * 1024usize;
8581 let dataset_bytes = n * 8;
8582 let (bytes, data) = chunked_f64_file(n, 128);
8583 let counts = ReadCounts::default();
8584 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8585 let ds = file.dataset("d").unwrap();
8586
8587 counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
8588 let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
8589
8590 assert!(
8591 second <= dataset_bytes,
8592 "the second read fetched {second} bytes of a {dataset_bytes}-byte dataset"
8593 );
8594 }
8595
8596 /// The same, for row windows: `docs/guide/streaming.md` walks a dataset in
8597 /// windows on one handle, so every window but the first takes its chunk
8598 /// list from the cached index.
8599 ///
8600 /// Each window here covers more than one span, which is what it takes to
8601 /// see the defect: a window whose chunks all fit a single span is served
8602 /// out of that one buffer whatever order it walks them in.
8603 #[test]
8604 fn a_window_loop_costs_the_dataset_once() {
8605 // 2 MiB of f64 in 1 KiB chunks, read in 512 KiB windows — two spans
8606 // each.
8607 let rows = 256 * 1024usize;
8608 let dataset_bytes = rows * 8;
8609 let (bytes, data) = chunked_f64_file(rows, 128);
8610 let counts = ReadCounts::default();
8611 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8612 let ds = file.dataset("d").unwrap();
8613
8614 let window = 64 * 1024;
8615 let (_, total) = counts.measure(|| {
8616 for lo in (0..rows).step_by(window) {
8617 let got = ds.read_f64_rows(lo as u64, window as u64).unwrap();
8618 assert_eq!(got, data[lo..lo + window]);
8619 }
8620 });
8621
8622 assert!(
8623 total <= 2 * dataset_bytes,
8624 "a window loop over a {dataset_bytes}-byte dataset read {total} bytes"
8625 );
8626 }
8627
8628 /// A span must not cover a chunk the chunk cache already holds: the read
8629 /// skips that chunk, so those bytes would be fetched for nothing.
8630 ///
8631 /// The cache here is given room for the whole dataset. At the default
8632 /// sixteen slots a dataset this size evicts its own warm chunks as it
8633 /// walks, so every chunk misses on the second read and the plan has nothing
8634 /// to leave out — a config that cannot show the difference either way.
8635 #[test]
8636 fn a_warm_chunk_cache_is_not_re_fetched() {
8637 // 32 KiB of f64 in 32 chunks of 1 KiB, laid end to end.
8638 let n = 4096usize;
8639 let chunk_bytes = 1024usize;
8640 let (bytes, data) = chunked_f64_file(n, 128);
8641 let counts = ReadCounts::default();
8642 let file = counting_streaming_file(
8643 bytes,
8644 &counts,
8645 FileAccessProperties::new()
8646 .with_chunk_cache(ChunkCacheConfig::new().with_max_slots(64)),
8647 );
8648 let ds = file.dataset("d").unwrap();
8649
8650 // Warm the second half of the dataset: chunks 16..31.
8651 assert_eq!(ds.read_f64_rows(2048, 2048).unwrap(), data[2048..]);
8652 assert_eq!(
8653 ds.chunk_cache_stats().cached_chunks(),
8654 16,
8655 "the window's own chunks stay cached"
8656 );
8657
8658 let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
8659 assert_eq!(
8660 second,
8661 16 * chunk_bytes,
8662 "only the cold half was needed; a span over the whole dataset would \
8663 have fetched {} bytes",
8664 32 * chunk_bytes
8665 );
8666 }
8667
8668 // -----------------------------------------------------------------------
8669 // Group member iterators (`iter_datasets` / `iter_groups`)
8670 // -----------------------------------------------------------------------
8671
8672 /// A root holding `datasets` datasets, `groups` subgroups (each with one
8673 /// dataset of its own) and one committed datatype, so a member walk has all
8674 /// three child kinds to sort apart.
8675 ///
8676 /// Names are not zero-padded, so lexical order and insertion order disagree
8677 /// past the tenth member: a walk that silently sorted would show up here.
8678 fn mixed_member_file(datasets: usize, groups: usize) -> Vec<u8> {
8679 let mut b = FileBuilder::new();
8680 b.commit_datatype("a_type", crate::make_i32_type());
8681 for i in 0..datasets {
8682 b.create_dataset(&format!("ds{i}"))
8683 .with_i32_data(&[i as i32, -(i as i32)]);
8684 }
8685 for i in 0..groups {
8686 let mut g = b.create_group(&format!("g{i}"));
8687 g.create_dataset("inner").with_i32_data(&[i as i32]);
8688 g.create_dataset("other").with_i32_data(&[-1]);
8689 b.add_group(g.finish());
8690 }
8691 b.finish().expect("write the fixture")
8692 }
8693
8694 /// The iterator must report exactly what opening each name reports: the same
8695 /// members, in the same order, resolving to the same objects. Anything the
8696 /// two disagree on is a member a caller would see differently for having
8697 /// chosen the cheaper walk.
8698 #[test]
8699 fn iter_datasets_agrees_with_opening_each_name() {
8700 let file = File::from_bytes(mixed_member_file(12, 3)).unwrap();
8701
8702 for group in [file.root(), file.group("g1").unwrap()] {
8703 let names = group.datasets().unwrap();
8704 assert!(
8705 !names.is_empty(),
8706 "the fixture must have members to compare"
8707 );
8708
8709 let iterated: Vec<(String, Dataset)> = group.iter_datasets().unwrap().collect();
8710 assert_eq!(
8711 iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
8712 names,
8713 "the iterator must yield the members `datasets` lists, in that order"
8714 );
8715
8716 for (name, ds) in &iterated {
8717 let opened = group.dataset(name).unwrap();
8718 assert_eq!(
8719 ds.header_address().unwrap(),
8720 opened.header_address().unwrap(),
8721 "{name}"
8722 );
8723 assert_eq!(ds.shape().unwrap(), opened.shape().unwrap(), "{name}");
8724 assert_eq!(ds.read_i32().unwrap(), opened.read_i32().unwrap(), "{name}");
8725 }
8726 }
8727 }
8728
8729 /// The subgroup counterpart, including that a handle it yields can be walked
8730 /// again — recursion through `iter_groups` is the shape it exists for.
8731 #[test]
8732 fn iter_groups_agrees_with_opening_each_name() {
8733 let file = File::from_bytes(mixed_member_file(4, 5)).unwrap();
8734 let root = file.root();
8735
8736 let names = root.groups().unwrap();
8737 let iterated: Vec<(String, Group)> = root.iter_groups().unwrap().collect();
8738 assert_eq!(
8739 iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
8740 names,
8741 "the iterator must yield the subgroups `groups` lists, in that order"
8742 );
8743 assert_eq!(names.len(), 5);
8744
8745 for (name, group) in &iterated {
8746 assert_eq!(
8747 group.header_address().unwrap(),
8748 root.group(name).unwrap().header_address().unwrap(),
8749 "{name}"
8750 );
8751 let mut inner = group
8752 .iter_datasets()
8753 .unwrap()
8754 .map(|(n, _)| n)
8755 .collect::<Vec<_>>();
8756 inner.sort();
8757 assert_eq!(inner, ["inner", "other"], "{name} must be walkable in turn");
8758 }
8759 }
8760
8761 /// Each iterator must claim only its own kind of child. A committed datatype
8762 /// is the child that belongs to neither, and the one a walk asking only for
8763 /// datasets and groups would otherwise be free to mis-sort into either.
8764 #[test]
8765 fn the_member_iterators_sort_the_child_kinds_apart() {
8766 let file = File::from_bytes(mixed_member_file(3, 2)).unwrap();
8767 let root = file.root();
8768
8769 let datasets: Vec<String> = root.iter_datasets().unwrap().map(|(n, _)| n).collect();
8770 let groups: Vec<String> = root.iter_groups().unwrap().map(|(n, _)| n).collect();
8771
8772 assert_eq!(datasets, ["ds0", "ds1", "ds2"]);
8773 assert_eq!(groups, ["g0", "g1"]);
8774 assert_eq!(root.named_datatypes().unwrap(), ["a_type"]);
8775 assert!(
8776 !datasets.contains(&"a_type".to_string()) && !groups.contains(&"a_type".to_string()),
8777 "a committed datatype is neither a dataset nor a group"
8778 );
8779 }
8780
8781 /// The bytes a walk of `n` members reads, by each route: opening every name,
8782 /// and iterating handles.
8783 fn member_walk_bytes(n: usize) -> (usize, usize) {
8784 let mut b = FileBuilder::new();
8785 for i in 0..n {
8786 b.create_dataset(&format!("ds{i}"))
8787 .with_i32_data(&[i as i32]);
8788 }
8789 let bytes = b.finish().expect("write the fixture");
8790
8791 let counts = ReadCounts::default();
8792 let file = counting_streaming_file(bytes.clone(), &counts, FileAccessProperties::new());
8793 let (_, by_name) = counts.measure(|| {
8794 let root = file.root();
8795 for name in root.datasets().unwrap() {
8796 root.dataset(&name).unwrap();
8797 }
8798 });
8799
8800 let counts = ReadCounts::default();
8801 let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8802 let (_, iterated) =
8803 counts.measure(|| for (_, _ds) in file.root().iter_datasets().unwrap() {});
8804
8805 (by_name, iterated)
8806 }
8807
8808 /// Opening members by name re-walks the group's link structure once per
8809 /// member; iterating handles walks it once. Bytes is the metric that
8810 /// separates them: a group this size keeps its links inline in the object
8811 /// header, so that header grows with the member count and re-reading it per
8812 /// member is quadratic, while the *number* of reads stays linear either way
8813 /// and would show almost nothing.
8814 ///
8815 /// Asserted as how the cost scales rather than as one fixture's byte count,
8816 /// since a fixed number would pass just as well on a walk that stayed
8817 /// quadratic with a smaller constant.
8818 #[test]
8819 fn iterating_members_enumerates_the_group_once() {
8820 let (_, iterated_16) = member_walk_bytes(16);
8821 let (by_name_64, iterated_64) = member_walk_bytes(64);
8822
8823 // The rule this test exists for, and the only assertion here that is
8824 // about `iter_datasets` itself.
8825 assert!(
8826 iterated_64 <= 5 * iterated_16,
8827 "one enumeration plus one header per member is linear, so four times \
8828 the members must cost about four times the bytes: {iterated_16} -> \
8829 {iterated_64}"
8830 );
8831
8832 // Contrast, not a property of this code: it holds because `Group::dataset`
8833 // re-enumerates. If a future change makes that route cheap enough to turn
8834 // this red, nothing here has regressed — confirm the scaling assertion
8835 // above still holds and then drop this one.
8836 assert!(
8837 iterated_64 < by_name_64,
8838 "at 64 members the one-enumeration walk should still be the cheaper: \
8839 {iterated_64} bytes against {by_name_64}"
8840 );
8841 }
8842
8843 /// A yielded handle must carry the chunk-cache configuration the file was
8844 /// opened with, the same one `dataset` resolves for it.
8845 ///
8846 /// Nothing about the values read would show a handle that quietly ignored
8847 /// it: the cache decides how often the chunk index is re-parsed and how much
8848 /// decompressed data is retained, not what comes back. So the configuration
8849 /// has to be asserted directly, or dropping it here is a silent regression.
8850 #[test]
8851 fn a_member_handle_carries_the_files_chunk_cache_config() {
8852 let configured = ChunkCacheConfig::new()
8853 .with_max_slots(17)
8854 .with_max_bytes(4096)
8855 .with_index_cache(false);
8856 let file = File::from_bytes_with_options(
8857 mixed_member_file(3, 0),
8858 FileAccessProperties::new().with_chunk_cache(configured),
8859 )
8860 .unwrap();
8861 let root = file.root();
8862
8863 let members: Vec<(String, Dataset)> = root.iter_datasets().unwrap().collect();
8864 assert_eq!(members.len(), 3);
8865 for (name, ds) in &members {
8866 assert_eq!(
8867 ds.chunk_cache_config(),
8868 root.dataset(name).unwrap().chunk_cache_config(),
8869 "{name} must resolve its cache the way `dataset` does"
8870 );
8871 assert_eq!(
8872 ds.chunk_cache_config(),
8873 configured,
8874 "{name} must carry the configuration the file was opened with"
8875 );
8876 }
8877 }
8878
8879 /// A file whose root holds `inner` and `sub`, and a group `g0` holding
8880 /// children of those same names, plus a `refs` dataset pointing at `g0`.
8881 ///
8882 /// The duplicated names are the trap: a member handle that wrongly took a
8883 /// root-relative path would address a real object rather than fail, so the
8884 /// mistake would look like a successful write.
8885 fn dereferenced_group_file() -> Vec<u8> {
8886 let mut b = FileBuilder::new();
8887 b.create_dataset("inner").with_i32_data(&[0]);
8888 let mut root_sub = b.create_group("sub");
8889 root_sub.create_dataset("x").with_i32_data(&[0]);
8890 b.add_group(root_sub.finish());
8891
8892 let mut g = b.create_group("g0");
8893 g.create_dataset("inner").with_i32_data(&[1]);
8894 let mut nested = g.create_group("sub");
8895 nested.create_dataset("x").with_i32_data(&[1]);
8896 g.add_group(nested.finish());
8897 b.add_group(g.finish());
8898
8899 b.create_dataset("refs").with_path_references(&["g0"]);
8900 b.finish().expect("write the fixture")
8901 }
8902
8903 /// A group reached by object reference has no resolvable path, so neither can
8904 /// its members: there is nothing for a write through one to address. The
8905 /// iterators must carry that `None` across rather than fall back to a
8906 /// root-relative path, which here would reach a different, real object.
8907 #[test]
8908 fn members_of_a_dereferenced_group_have_no_path() {
8909 let dir = tempfile::tempdir().unwrap();
8910 let path = dir.path().join("refs.h5");
8911 std::fs::write(&path, dereferenced_group_file()).unwrap();
8912
8913 let file = File::open_rw(&path).unwrap();
8914 let mut objects = file.dataset("refs").unwrap().dereference().unwrap();
8915 let group = match objects.remove(0) {
8916 Object::Group(g) => g,
8917 other => panic!("expected a group, got {other:?}"),
8918 };
8919
8920 // The file is writable and both names exist at the root, so a refusal
8921 // here can only come from the handle having no path to address.
8922 let (name, mut member) = group.iter_datasets().unwrap().next().unwrap();
8923 assert_eq!(name, "inner");
8924 assert!(
8925 matches!(
8926 member.set_attr("tag", AttrValue::I64(1)),
8927 Err(Error::ReadOnly)
8928 ),
8929 "a member of a path-less group must refuse a write, not address `/inner`"
8930 );
8931
8932 let (name, subgroup) = group.iter_groups().unwrap().next().unwrap();
8933 assert_eq!(name, "sub");
8934 assert!(
8935 matches!(
8936 subgroup.set_attr("tag", AttrValue::I64(1)),
8937 Err(Error::ReadOnly)
8938 ),
8939 "and so must a subgroup of one, not address `/sub`"
8940 );
8941 }
8942
8943 /// A yielded handle must carry the same root-relative path as one opened by
8944 /// name, or a write through it would address the wrong object — or no object
8945 /// at all. A member of a *subgroup* is the case that separates them, since
8946 /// its path has a prefix to get right.
8947 #[test]
8948 fn a_member_handle_resolves_to_its_own_path() {
8949 let dir = tempfile::tempdir().unwrap();
8950 let path = dir.path().join("members.h5");
8951 std::fs::write(&path, mixed_member_file(2, 2)).unwrap();
8952
8953 let file = File::open_rw(&path).unwrap();
8954 let group = file.group("g1").unwrap();
8955 let (name, mut ds) = group
8956 .iter_datasets()
8957 .unwrap()
8958 .find(|(n, _)| n == "inner")
8959 .expect("g1/inner");
8960 assert_eq!(name, "inner");
8961 ds.set_attr("tag", AttrValue::I64(7)).unwrap();
8962 file.commit().unwrap();
8963 // Windows holds the write lock until the session is dropped.
8964 drop(ds);
8965 drop(group);
8966 drop(file);
8967
8968 let file = File::open(&path).unwrap();
8969 assert_eq!(
8970 file.dataset("g1/inner")
8971 .unwrap()
8972 .attrs()
8973 .unwrap()
8974 .get("tag")
8975 .and_then(AttrValue::as_i64),
8976 Some(7),
8977 "the attribute must land on the member the handle came from"
8978 );
8979 assert!(
8980 !file
8981 .dataset("g0/inner")
8982 .unwrap()
8983 .attrs()
8984 .unwrap()
8985 .contains_key("tag"),
8986 "and on no other group's member of the same name"
8987 );
8988 }
8989
8990 /// Every read-write entry point has to hand the fapl's `fsync` cadence to
8991 /// the session it opens, and none of them can be checked from outside: a
8992 /// skipped barrier writes the same bytes as an issued one (issue #263).
8993 ///
8994 /// One entry point missing the funnel is the whole failure mode, so this
8995 /// asserts the property at each of them rather than at the funnel.
8996 #[test]
8997 fn every_read_write_open_carries_the_fapl_sync_policy() {
8998 use tempfile::tempdir;
8999
9000 let dir = tempdir().unwrap();
9001 let fixture = |name: &str| {
9002 let path = dir.path().join(name);
9003 let mut b = FileBuilder::new();
9004 b.create_dataset("d")
9005 .with_i32_data(&[1, 2, 3, 4])
9006 .with_shape(&[4])
9007 .with_maxshape(&[u64::MAX])
9008 .with_chunks(&[2]);
9009 b.write(&path).unwrap();
9010 path
9011 };
9012 let props = || FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose);
9013 let policy_of = |file: &File| match &file.inner.backend {
9014 Backend::Edit(m) => m
9015 .lock()
9016 .unwrap_or_else(std::sync::PoisonError::into_inner)
9017 .sync_policy(),
9018 _ => panic!("a read-write open must build an editing session"),
9019 };
9020
9021 let opened = File::open_rw_with_options(fixture("open_rw.h5"), props()).unwrap();
9022 assert_eq!(policy_of(&opened), SyncPolicy::OnClose, "File::open_rw");
9023 // Windows OS locks are mandatory: release each session before the next
9024 // open touches the same directory's files.
9025 drop(opened);
9026
9027 let created = File::create_with_options(
9028 dir.path().join("created.h5"),
9029 crate::FileCreateProperties::new(),
9030 props(),
9031 )
9032 .unwrap();
9033 assert_eq!(policy_of(&created), SyncPolicy::OnClose, "File::create");
9034 drop(created);
9035
9036 let swmr = File::open_swmr_writer_with_options(fixture("swmr.h5"), props()).unwrap();
9037 assert_eq!(
9038 policy_of(&swmr),
9039 SyncPolicy::OnClose,
9040 "File::open_swmr_writer"
9041 );
9042 drop(swmr);
9043
9044 let bounded = File::open_rw_with_options(
9045 fixture("bounded.h5"),
9046 props().with_memory_strategy(MemoryStrategy::Bounded),
9047 )
9048 .unwrap();
9049 assert_eq!(
9050 policy_of(&bounded),
9051 SyncPolicy::OnClose,
9052 "File::open_rw_with_options (bounded)"
9053 );
9054 }
9055
9056 /// The page-buffer property is refused where it cannot be honored, rather
9057 /// than accepted and ignored (issues #288 and #308).
9058 ///
9059 /// Each refusal has a different reason and none stands in for the others: a
9060 /// budget under one page is a buffer that drains on every page it touches, a
9061 /// *paged* file that persists no free space can neither commit nor append, a
9062 /// pre-version-3 superblock carries a status-flags byte no library reads back
9063 /// so the crash mark would announce nothing, and the SWMR writer's readers
9064 /// observe the order its writes become visible in. The first is where the C
9065 /// library refuses `H5Pset_page_buffer_size` too; the rest are this crate's.
9066 ///
9067 /// Two shapes are **not** among them, and the accepted cases here are what
9068 /// pin that. An unpaged file: `H5PB_create` requires the paged allocator
9069 /// because the C page buffer is a page cache with per-kind reservations, and
9070 /// this gatherer has neither (issue #357). And a budget below the 1 MiB a
9071 /// session already gathers under, which is a request for less resident
9072 /// memory paid for in writes rather than an unhonorable pair (issue #391).
9073 ///
9074 /// Every refusal also asserts the file is left byte-identical. Each one
9075 /// fires with a read-write session already open, and the version-3 one fires
9076 /// from the same function that raises the mark — so a refusal ordered after
9077 /// the raise would leave a file marked in use by a session that never
9078 /// existed, which is a file nothing can open until `clear_swmr_flag`.
9079 #[test]
9080 fn a_page_buffer_is_refused_where_it_cannot_be_honored() {
9081 use tempfile::tempdir;
9082
9083 let dir = tempdir().unwrap();
9084 let fixture = |name: &str, paged: bool| {
9085 let path = dir.path().join(name);
9086 let mut b = FileBuilder::new();
9087 if paged {
9088 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9089 .with_file_space_page_size(16 * 1024);
9090 }
9091 b.create_dataset("d")
9092 .with_i32_data(&[1, 2, 3, 4])
9093 .with_shape(&[4]);
9094 b.write(&path).unwrap();
9095 path
9096 };
9097 let buffered = |bytes| {
9098 FileAccessProperties::new()
9099 .with_sync_policy(SyncPolicy::OnClose)
9100 .with_page_buffer_size(bytes)
9101 };
9102
9103 // Each refusal fires after the session is already open read-write, so the
9104 // file it declined must be left exactly as it was — and still openable.
9105 let untouched = |label: &str, path: &std::path::Path, before: &[u8]| {
9106 assert_eq!(
9107 std::fs::read(path).unwrap(),
9108 before,
9109 "{label}: a refused open changed the file"
9110 );
9111 assert!(
9112 File::open(path).is_ok(),
9113 "{label}: a refused open left the file unopenable"
9114 );
9115 };
9116
9117 // The two that are *accepted*, kept here beside their former siblings so
9118 // reinstating either refusal fails this test rather than only the
9119 // behavior tests one module over.
9120 let unpaged = fixture("unpaged.h5", false);
9121 let accepted = File::open_rw_with_options(&unpaged, buffered(1 << 20));
9122 assert!(
9123 accepted.is_ok(),
9124 "a page buffer on an unpaged file must be accepted, got {accepted:?}"
9125 );
9126 accepted.unwrap().close().unwrap();
9127
9128 // 256 KiB on a 16 KiB-paged file: a budget the session would not have
9129 // gathered under, which is the point of asking for it.
9130 let small_budget = fixture("small_budget.h5", true);
9131 let accepted = File::open_rw_with_options(&small_budget, buffered(256 * 1024));
9132 assert!(
9133 accepted.is_ok(),
9134 "a page buffer below the byte budget a session gathers under must be \
9135 accepted, got {accepted:?}"
9136 );
9137 accepted.unwrap().close().unwrap();
9138
9139 let paged = fixture("paged.h5", true);
9140 let before = std::fs::read(&paged).unwrap();
9141 let refused = File::open_rw_with_options(&paged, buffered(8192));
9142 assert!(
9143 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("page size")),
9144 "a budget below one page must be refused, got {refused:?}"
9145 );
9146 untouched("budget under one page", &paged, &before);
9147
9148 // And the pairing a caller reaches by doing nothing, since Always is the
9149 // default: every barrier there is an fsync that flushes the buffer.
9150 let refused = File::open_rw_with_options(
9151 &paged,
9152 FileAccessProperties::new().with_page_buffer_size(1 << 20),
9153 );
9154 assert!(
9155 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SyncPolicy::Always")),
9156 "a page buffer under SyncPolicy::Always must be refused, got {refused:?}"
9157 );
9158 untouched("page buffer under Always", &paged, &before);
9159
9160 // A version-2 superblock. This crate's writer refuses `Page` below the
9161 // 1.10 format, so the file is built at version 3 and its superblock
9162 // rewritten — the v2 and v3 layouts are identical apart from the version
9163 // byte and what the flags byte means, which is exactly the point.
9164 let old_format = dir.path().join("v2.h5");
9165 std::fs::copy(&paged, &old_format).unwrap();
9166 {
9167 let mut bytes = std::fs::read(&old_format).unwrap();
9168 let sig = crate::signature::find_signature(&bytes).unwrap();
9169 let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
9170 assert_eq!(sb.version, 3, "the fixture must start at the newer format");
9171 sb.version = 2;
9172 let rewritten = sb.serialize();
9173 bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
9174 std::fs::write(&old_format, &bytes).unwrap();
9175 }
9176 let before = std::fs::read(&old_format).unwrap();
9177 assert!(
9178 File::open(&old_format).is_ok(),
9179 "the version-2 fixture must be a readable file, or the refusal below \
9180 could be about anything"
9181 );
9182 let refused = File::open_rw_with_options(&old_format, buffered(1 << 20));
9183 assert!(
9184 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("version-3 superblock")),
9185 "a page buffer on a pre-v3 superblock must be refused, got {refused:?}"
9186 );
9187 untouched("version-2 superblock", &old_format, &before);
9188
9189 // A paged file whose free space is not persisted, reached two ways: asked
9190 // for outright, and produced by a userblock, for which persistence is
9191 // declined however the creation properties were written. Such a session
9192 // can neither commit nor append, so the buffer would hold nothing while
9193 // its mark blocked every reader.
9194 for (label, name, userblock) in [
9195 ("paged, not persisting", "no_persist.h5", 0u64),
9196 ("paged with a userblock", "ub_paged.h5", 4096),
9197 ] {
9198 let path = dir.path().join(name);
9199 let mut b = FileBuilder::new();
9200 if userblock != 0 {
9201 b.with_userblock(userblock);
9202 }
9203 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, userblock != 0, 1)
9204 .with_file_space_page_size(4096);
9205 b.create_dataset("d")
9206 .with_i32_data(&[1, 2, 3, 4])
9207 .with_shape(&[4]);
9208 b.write(&path).unwrap();
9209 let before = std::fs::read(&path).unwrap();
9210 let refused = File::open_rw_with_options(
9211 &path,
9212 buffered(1 << 20).with_memory_strategy(MemoryStrategy::Mirrored),
9213 );
9214 assert!(
9215 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("persisted")),
9216 "{label}: a page buffer on a session that cannot write must be refused, \
9217 got {refused:?}"
9218 );
9219 untouched(label, &path, &before);
9220 }
9221
9222 let swmr = fixture("swmr.h5", false);
9223 let before = std::fs::read(&swmr).unwrap();
9224 let refused = File::open_swmr_writer_with_options(&swmr, buffered(1 << 20));
9225 assert!(
9226 matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SWMR")),
9227 "the SWMR writer must refuse a page buffer, got {refused:?}"
9228 );
9229 // The sharpest of the three: `open_swmr_writer` raises the on-disk
9230 // SWMR-write flag, and a refusal that fired after it would leave every
9231 // later open reporting `FileMarkedInUse`.
9232 untouched("swmr", &swmr, &before);
9233
9234 // And an unset page buffer refuses none of the three.
9235 for (name, paged) in [("ok_unpaged.h5", false), ("ok_paged.h5", true)] {
9236 let f = File::open_rw_with_options(fixture(name, paged), FileAccessProperties::new());
9237 assert!(f.is_ok(), "{name}: an unset page buffer refuses nothing");
9238 }
9239 let f = File::open_swmr_writer_with_options(
9240 fixture("ok_swmr.h5", false),
9241 FileAccessProperties::new(),
9242 );
9243 assert!(f.is_ok(), "swmr: an unset page buffer refuses nothing");
9244 }
9245
9246 /// `File::create_with_options` refuses a creation/access pair whose file it
9247 /// could write but not then open, rather than writing it and failing the open
9248 /// it promised (issue #288).
9249 ///
9250 /// A page buffer needs a budget of at least the file's page size and a
9251 /// version-3 superblock, and both of those are properties of the file being
9252 /// *created* — so the refusal belongs before the bytes are written, not in
9253 /// the reopen. The assertion that matters here is the `!path.exists()`: an
9254 /// error alone would pass with the file already on disk, which is the defect.
9255 ///
9256 /// The version-3 case is the one issue #357 made reachable. While a page
9257 /// buffer required a paged file it could not be: this crate's builder refuses
9258 /// `FileSpaceStrategy::Page` below the 1.10 format outright, so no pair got
9259 /// this far. An unpaged file at `LibVer::V18` is an ordinary buildable file,
9260 /// and without a check here it would be written and only then refused.
9261 #[test]
9262 fn create_with_options_refuses_a_page_buffer_it_could_not_reopen_with() {
9263 use tempfile::tempdir;
9264
9265 let dir = tempdir().unwrap();
9266 let paged = |page: u64| {
9267 crate::FileCreateProperties::new()
9268 .with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9269 .with_file_space_page_size(page)
9270 };
9271 // 8192 clears the format's 4096 default and still falls short of this
9272 // file's 16 KiB page, so it fails only against the page size actually
9273 // read from the file.
9274 //
9275 // The last case is the one a caller reaches by doing nothing: `Always` is
9276 // the default policy, and every other property in that pair is honorable.
9277 // It is here because it was missing — the refusal used to sit at the fapl
9278 // rather than with its siblings, so this function did not restate it and
9279 // the file was written before the open failed.
9280 let cases: [(&str, crate::FileCreateProperties, usize, SyncPolicy); 5] = [
9281 // A page larger than the budget, on a file that does not exist yet:
9282 // the page size has to come from the creation properties, which is
9283 // what this pins.
9284 (
9285 "page larger than the budget",
9286 paged(2 << 20),
9287 1 << 20,
9288 SyncPolicy::OnClose,
9289 ),
9290 (
9291 "the 1.8 format",
9292 crate::FileCreateProperties::new()
9293 .with_libver_bounds(crate::LibVer::Earliest, crate::LibVer::V18),
9294 1 << 20,
9295 SyncPolicy::OnClose,
9296 ),
9297 (
9298 "budget under one page",
9299 paged(16 * 1024),
9300 8192,
9301 SyncPolicy::OnClose,
9302 ),
9303 // The unpaged arm of the same check, which decides on its own now
9304 // that a budget below the session's gather budget is honored (issue
9305 // #391): an unpaged file has no page size, so it is held to the
9306 // format's 4 KiB default and 2 KiB falls short of it.
9307 (
9308 "unpaged budget under the default page",
9309 crate::FileCreateProperties::new(),
9310 2048,
9311 SyncPolicy::OnClose,
9312 ),
9313 (
9314 "the default sync policy",
9315 paged(16 * 1024),
9316 1 << 20,
9317 SyncPolicy::Always,
9318 ),
9319 ];
9320
9321 for (label, create, budget, policy) in cases {
9322 let path = dir
9323 .path()
9324 .join(std::format!("{}.h5", label.replace(' ', "_")));
9325 let result = File::create_with_options(
9326 &path,
9327 create,
9328 FileAccessProperties::new()
9329 .with_sync_policy(policy)
9330 .with_page_buffer_size(budget),
9331 );
9332 assert!(
9333 matches!(result, Err(Error::EditUnsupported(_))),
9334 "{label}: expected a refusal, got {result:?}"
9335 );
9336 assert!(
9337 !path.exists(),
9338 "{label}: the file was written and only then refused"
9339 );
9340 }
9341
9342 // The honorable pairs still create — including the unpaged one, which is
9343 // the default creation properties and so the pair a caller reaches by
9344 // asking for nothing but the buffer, and the 256 KiB one, which is below
9345 // the byte budget a session gathers under (issue #391).
9346 for (label, create, budget) in [
9347 ("paged", paged(16 * 1024), 1 << 20),
9348 ("unpaged", crate::FileCreateProperties::new(), 1 << 20),
9349 ("paged_small_budget", paged(16 * 1024), 256 * 1024),
9350 ] {
9351 let ok = File::create_with_options(
9352 dir.path().join(std::format!("ok_{label}.h5")),
9353 create,
9354 FileAccessProperties::new()
9355 .with_sync_policy(SyncPolicy::OnClose)
9356 .with_page_buffer_size(budget),
9357 );
9358 assert!(
9359 ok.is_ok(),
9360 "{label}: an honorable budget must create: {ok:?}"
9361 );
9362 ok.unwrap().close().unwrap();
9363 }
9364 }
9365
9366 /// `close` and `drop` issue their barrier under *every* policy, on both the
9367 /// ordinary and the SWMR branch — the four sites where this crate writes
9368 /// after the last point a caller could have ordered anything (issue #263).
9369 ///
9370 /// This is the half of the contract `SyncPolicy` cannot express: the two
9371 /// `drop` sites are unreachable by any caller discipline at all, since the
9372 /// handle that would have issued `File::sync` is gone by the time they run.
9373 /// Asserted through a counting image, because the difference between a
9374 /// forced barrier and a skipped one is invisible in the bytes.
9375 #[test]
9376 fn close_and_drop_force_their_barrier_under_every_policy() {
9377 use crate::edit::WriteEngine;
9378 use std::sync::Arc;
9379 use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
9380 use tempfile::tempdir;
9381
9382 let dir = tempdir().unwrap();
9383 // `swmr` picks the teardown branch; `explicit` picks `close` over `drop`.
9384 let teardown = |name: &str, swmr: bool, explicit: bool| -> u64 {
9385 let path = dir.path().join(name);
9386 let mut b = FileBuilder::new();
9387 b.create_dataset("d")
9388 .with_i32_data(&(0..8).collect::<Vec<_>>())
9389 .with_shape(&[8])
9390 .with_maxshape(&[u64::MAX])
9391 .with_chunks(&[4]);
9392 // Persisting, so the ordinary branch has manager re-homing to do:
9393 // an immediate append below leaves the on-disk managers mid-file,
9394 // and settling them is the write no earlier sync could cover.
9395 b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
9396 b.write(&path).unwrap();
9397
9398 let syncs = Arc::new(AtomicU64::new(0));
9399 let session =
9400 WriteEngine::open_sync_counting(&path, SyncPolicy::OnClose, Arc::clone(&syncs))
9401 .unwrap();
9402 let mut inner = FileInner::from_rw_session(
9403 session,
9404 FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose),
9405 )
9406 .unwrap();
9407 inner.swmr_write = swmr;
9408 let file = File {
9409 inner: Arc::new(inner),
9410 };
9411 if !swmr {
9412 // The SWMR branch stages nothing and appends through its own
9413 // path; give the ordinary branch real work to settle.
9414 file.dataset("d")
9415 .unwrap()
9416 .append(&[8i32, 9, 10, 11])
9417 .unwrap();
9418 }
9419 assert_eq!(
9420 syncs.load(AtomicOrdering::Relaxed),
9421 0,
9422 "nothing before teardown may sync under OnClose ({name})"
9423 );
9424
9425 if explicit {
9426 file.close().unwrap();
9427 } else {
9428 drop(file);
9429 }
9430 syncs.load(AtomicOrdering::Relaxed)
9431 };
9432
9433 for (name, swmr, explicit) in [
9434 ("close_plain.h5", false, true),
9435 ("close_swmr.h5", true, true),
9436 ("drop_plain.h5", false, false),
9437 ("drop_swmr.h5", true, false),
9438 ] {
9439 assert!(
9440 teardown(name, swmr, explicit) > 0,
9441 "{name} must force its barrier: the writes it makes are past the \
9442 last point a caller could have ordered them"
9443 );
9444 }
9445 }
9446}