Lazy remote files in the browser (M4), SWMR reader (M5), Python remote reads and editing #19
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Correctness: edits planned from another file after a rename or `chdir` (2026-09-27)
|
||||||
|
- **`FileEditor` planned each edit by re-opening its path but wrote
|
||||||
|
through the file it held open** (fixed 2026-09-27; on main since PR #18,
|
||||||
|
no release). When the path came to name another file between edits — a
|
||||||
|
rename or replacement, or, for a relative path, a change of working
|
||||||
|
directory — an edit was laid out from the other file's metadata and
|
||||||
|
written into the held one, corrupting it (h5py: "invalid dataset size,
|
||||||
|
likely file corruption"). The Python `'r+'` handle had the same flaw in
|
||||||
|
its reads: it reopened the path after every edit, so reads came from the
|
||||||
|
other file. The editor now plans every edit from the file it holds, and
|
||||||
|
its path is canonicalised at open. New `FileEditor::reader()` opens the
|
||||||
|
held file anew for reading (on Linux through `/proc/self/fd`, so it
|
||||||
|
follows a renamed file; elsewhere by the path, refused when the path no
|
||||||
|
longer names the held file), without sharing the editor's lock; the
|
||||||
|
Python handle reads through it, and a `'w'` file is written at the
|
||||||
|
absolute path it was opened with. Tests: `edit_tests.rs`'s
|
||||||
|
`edits_go_to_the_file_held_not_the_path`; `test_edit.py`'s
|
||||||
|
`test_relative_path_and_chdir` and `test_path_replaced_between_edits`
|
||||||
|
(the review's repro).
|
||||||
|
|
||||||
### Correctness: zero extents in Fixed/Extensible Array chunk indexes (2026-09-27)
|
### Correctness: zero extents in Fixed/Extensible Array chunk indexes (2026-09-27)
|
||||||
- **A chunked dataset whose maximum (or, with none recorded, current)
|
- **A chunked dataset whose maximum (or, with none recorded, current)
|
||||||
extent is 0 along a dimension made the reader divide by zero** (fixed
|
extent is 0 along a dimension made the reader divide by zero** (fixed
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ impl MmapReader {
|
|||||||
Ok(Self { _file: file, mmap })
|
Ok(Self { _file: file, mmap })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Memory-map a file that is already open (for reading).
|
||||||
|
///
|
||||||
|
/// The mapping references `file`'s open file description for as long as
|
||||||
|
/// it lives, so a `flock` taken through that description (or a
|
||||||
|
/// `try_clone` of it) is held until the reader is dropped.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// The same contract as [`open`](Self::open): the file must not be
|
||||||
|
/// modified while the mapping is active.
|
||||||
|
pub fn from_file(file: fs::File) -> io::Result<Self> {
|
||||||
|
// SAFETY: a read-only mapping; the caller keeps the file unmodified
|
||||||
|
// while it is alive.
|
||||||
|
let mmap = unsafe { Mmap::map(&file)? };
|
||||||
|
Ok(Self { _file: file, mmap })
|
||||||
|
}
|
||||||
|
|
||||||
/// Zero-copy access to the entire file contents.
|
/// Zero-copy access to the entire file contents.
|
||||||
pub fn as_bytes(&self) -> &[u8] {
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
&self.mmap
|
&self.mmap
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ impl PyFile {
|
|||||||
"w" => Ok(Self {
|
"w" => Ok(Self {
|
||||||
filename,
|
filename,
|
||||||
inner: Some(FileInner::Write(WriteState {
|
inner: Some(FileInner::Write(WriteState {
|
||||||
path: PathBuf::from(path),
|
// Absolute now: the file is written at close, possibly
|
||||||
|
// after the working directory changed.
|
||||||
|
path: std::path::absolute(path).unwrap_or_else(|_| PathBuf::from(path)),
|
||||||
root_datasets: Vec::new(),
|
root_datasets: Vec::new(),
|
||||||
root_attrs: Arc::new(Mutex::new(Vec::new())),
|
root_attrs: Arc::new(Mutex::new(Vec::new())),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
//!
|
//!
|
||||||
//! A file opened with `'r+'` also holds a [`FileEditor`]. An edit takes the
|
//! A file opened with `'r+'` also holds a [`FileEditor`]. An edit takes the
|
||||||
//! file's write lock, so no read runs while the file changes underneath it,
|
//! file's write lock, so no read runs while the file changes underneath it,
|
||||||
//! and reopens the file afterwards: reads after an edit see the new bytes
|
//! and reopens the file afterwards, through the editor's own open file
|
||||||
|
//! rather than its path: reads after an edit see the new bytes
|
||||||
//! (a grown file, a new dataspace), never a stale mapping or chunk cache.
|
//! (a grown file, a new dataspace), never a stale mapping or chunk cache.
|
||||||
//! Objects that cache something an edit can change compare
|
//! Objects that cache something an edit can change compare
|
||||||
//! [`Handle::generation`] with the value they cached it at.
|
//! [`Handle::generation`] with the value they cached it at.
|
||||||
@@ -16,7 +17,6 @@
|
|||||||
//! Lock discipline (no deadlock with the GIL): the file lock is only taken
|
//! Lock discipline (no deadlock with the GIL): the file lock is only taken
|
||||||
//! with the GIL released, and code that holds it never touches Python.
|
//! with the GIL released, and code that holds it never touches Python.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex, PoisonError, RwLock};
|
use std::sync::{Arc, Mutex, PoisonError, RwLock};
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ use crate::{panic_text, to_py_err};
|
|||||||
|
|
||||||
/// Where the file's bytes come from.
|
/// Where the file's bytes come from.
|
||||||
pub(crate) enum Source {
|
pub(crate) enum Source {
|
||||||
/// A local path (memory-mapped).
|
/// A local file (memory-mapped). Its path is not kept: nothing reopens
|
||||||
Local(PathBuf),
|
/// it by path (see `open_editable`).
|
||||||
|
Local,
|
||||||
/// A URL, read through `clawhdf5-remote`'s block cache.
|
/// A URL, read through `clawhdf5-remote`'s block cache.
|
||||||
Remote {
|
Remote {
|
||||||
url: String,
|
url: String,
|
||||||
@@ -74,25 +75,23 @@ impl Handle {
|
|||||||
/// A local file, read-only.
|
/// A local file, read-only.
|
||||||
pub(crate) fn open_local(py: Python<'_>, path: &str) -> PyResult<Arc<Self>> {
|
pub(crate) fn open_local(py: Python<'_>, path: &str) -> PyResult<Arc<Self>> {
|
||||||
let file = py.detach(|| crate::no_panic(|| File::open(path).map_err(to_py_err)))?;
|
let file = py.detach(|| crate::no_panic(|| File::open(path).map_err(to_py_err)))?;
|
||||||
Ok(Self::new(file, Source::Local(PathBuf::from(path)), None))
|
Ok(Self::new(file, Source::Local, None))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A local file, open for in-place editing (`'r+'`): the editor takes
|
/// A local file, open for in-place editing (`'r+'`): the editor takes
|
||||||
/// the file's exclusive lock and checks that it can edit the file, then
|
/// the file's exclusive lock and checks that it can edit the file, then
|
||||||
/// the file is opened for reading.
|
/// the file is read through the editor's own file — never by path
|
||||||
|
/// again, so a later `os.chdir` or a rename or replacement of the path
|
||||||
|
/// cannot make reads (or the editor's plans) come from another file.
|
||||||
pub(crate) fn open_editable(py: Python<'_>, path: &str) -> PyResult<Arc<Self>> {
|
pub(crate) fn open_editable(py: Python<'_>, path: &str) -> PyResult<Arc<Self>> {
|
||||||
let (file, editor) = py.detach(|| {
|
let (file, editor) = py.detach(|| {
|
||||||
crate::no_panic(|| {
|
crate::no_panic(|| {
|
||||||
let editor = FileEditor::open(path).map_err(to_py_err)?;
|
let editor = FileEditor::open(path).map_err(to_py_err)?;
|
||||||
let file = File::open(path).map_err(to_py_err)?;
|
let file = editor.reader().map_err(to_py_err)?;
|
||||||
Ok((file, editor))
|
Ok((file, editor))
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
Ok(Self::new(
|
Ok(Self::new(file, Source::Local, Some(editor)))
|
||||||
file,
|
|
||||||
Source::Local(PathBuf::from(path)),
|
|
||||||
Some(editor),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A remote file (`http(s)://`, `s3://`, ...).
|
/// A remote file (`http(s)://`, `s3://`, ...).
|
||||||
@@ -153,7 +152,7 @@ impl Handle {
|
|||||||
pub(crate) fn remote_storage(&self) -> Option<&clawhdf5_remote::RemoteStorage> {
|
pub(crate) fn remote_storage(&self) -> Option<&clawhdf5_remote::RemoteStorage> {
|
||||||
match &self.source {
|
match &self.source {
|
||||||
Source::Remote { storage, .. } => Some(storage),
|
Source::Remote { storage, .. } => Some(storage),
|
||||||
Source::Local(_) => None,
|
Source::Local => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +160,7 @@ impl Handle {
|
|||||||
pub(crate) fn redacted_url(&self) -> Option<String> {
|
pub(crate) fn redacted_url(&self) -> Option<String> {
|
||||||
match &self.source {
|
match &self.source {
|
||||||
Source::Remote { url, .. } => Some(clawhdf5_remote::redact_url(url)),
|
Source::Remote { url, .. } => Some(clawhdf5_remote::redact_url(url)),
|
||||||
Source::Local(_) => None,
|
Source::Local => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,14 +184,12 @@ impl Handle {
|
|||||||
let Some(editor) = &self.editor else {
|
let Some(editor) = &self.editor else {
|
||||||
return Err(PyOSError::new_err(match self.source {
|
return Err(PyOSError::new_err(match self.source {
|
||||||
Source::Remote { .. } => "remote files are read-only",
|
Source::Remote { .. } => "remote files are read-only",
|
||||||
Source::Local(_) => {
|
Source::Local => "the file is open read-only; open it with mode 'r+' to change it",
|
||||||
"the file is open read-only; open it with mode 'r+' to change it"
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
let Source::Local(path) = &self.source else {
|
if matches!(self.source, Source::Remote { .. }) {
|
||||||
return Err(PyOSError::new_err("remote files are read-only"));
|
return Err(PyOSError::new_err("remote files are read-only"));
|
||||||
};
|
}
|
||||||
py.detach(|| {
|
py.detach(|| {
|
||||||
let mut ed = editor.lock().unwrap_or_else(PoisonError::into_inner);
|
let mut ed = editor.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
let ed = ed
|
let ed = ed
|
||||||
@@ -202,7 +199,8 @@ impl Handle {
|
|||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(ed)));
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(ed)));
|
||||||
// Drop the old mapping and its chunk cache before reopening.
|
// Drop the old mapping and its chunk cache before reopening.
|
||||||
*file = None;
|
*file = None;
|
||||||
let reopened = std::panic::catch_unwind(|| File::open(path));
|
// Through the editor's file, not the path (see `open_editable`).
|
||||||
|
let reopened = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ed.reader()));
|
||||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||||
match reopened {
|
match reopened {
|
||||||
Ok(Ok(f)) => *file = Some(f),
|
Ok(Ok(f)) => *file = Some(f),
|
||||||
|
|||||||
@@ -823,3 +823,76 @@ def test_close_releases_the_file(h5py, tmp_path):
|
|||||||
ds[0] = 1
|
ds[0] = 1
|
||||||
with h5py.File(path, "r+") as g:
|
with h5py.File(path, "r+") as g:
|
||||||
g["d"][0] = 9
|
g["d"][0] = 9
|
||||||
|
|
||||||
|
|
||||||
|
def _two_files(h5py, tmp_path):
|
||||||
|
"""d1/f.h5 and d2/f.h5: same name, different layouts (the review's
|
||||||
|
repro)."""
|
||||||
|
(tmp_path / "d1").mkdir()
|
||||||
|
(tmp_path / "d2").mkdir()
|
||||||
|
with h5py.File(tmp_path / "d1" / "f.h5", "w") as f:
|
||||||
|
f.create_dataset("x", data=np.arange(10, dtype="<i4"))
|
||||||
|
f.create_dataset("big", data=np.full(5000, 1.5))
|
||||||
|
with h5py.File(tmp_path / "d2" / "f.h5", "w") as f:
|
||||||
|
f.create_dataset("pad", data=np.full(3000, 2.5))
|
||||||
|
f.create_dataset("x", data=np.arange(10, dtype="<i4") + 500)
|
||||||
|
return tmp_path / "d1" / "f.h5", tmp_path / "d2" / "f.h5"
|
||||||
|
|
||||||
|
|
||||||
|
def _held_file_edited(h5py, held, other, other_bytes):
|
||||||
|
"""The edits went to `held`, planned from its own metadata; `other`
|
||||||
|
was not touched."""
|
||||||
|
assert other.read_bytes() == other_bytes, "the other file changed"
|
||||||
|
with h5py.File(held, "r") as f:
|
||||||
|
np.testing.assert_array_equal(f["x"][()], np.full(10, 7))
|
||||||
|
np.testing.assert_array_equal(f["big"][()], np.full(5000, 1.5))
|
||||||
|
np.testing.assert_array_equal(f.attrs["note"], np.arange(50.0))
|
||||||
|
h5dump_reads(str(held))
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_path_and_chdir(h5py, tmp_path, monkeypatch):
|
||||||
|
"""A file opened by a relative path keeps being the file edited and read
|
||||||
|
after os.chdir (an edit was planned from the file the path named in the
|
||||||
|
new directory and written into the one open, corrupting it)."""
|
||||||
|
held, other = _two_files(h5py, tmp_path)
|
||||||
|
other_bytes = other.read_bytes()
|
||||||
|
monkeypatch.chdir(held.parent)
|
||||||
|
f = clawhdf5.File("f.h5", "r+")
|
||||||
|
ds = f["x"]
|
||||||
|
monkeypatch.chdir(other.parent)
|
||||||
|
ds[:] = np.full(10, 7, "<i4")
|
||||||
|
np.testing.assert_array_equal(ds[:5], np.full(5, 7)) # read from the held file
|
||||||
|
f.attrs["note"] = np.arange(50.0)
|
||||||
|
np.testing.assert_array_equal(f["big"][()], np.full(5000, 1.5))
|
||||||
|
assert "pad" not in f
|
||||||
|
f.close()
|
||||||
|
_held_file_edited(h5py, held, other, other_bytes)
|
||||||
|
# 'w' writes where the path named when the file was opened.
|
||||||
|
monkeypatch.chdir(held.parent)
|
||||||
|
w = clawhdf5.File("new.h5", "w")
|
||||||
|
monkeypatch.chdir(other.parent)
|
||||||
|
w.create_dataset("d", data=np.arange(3))
|
||||||
|
w.close()
|
||||||
|
assert (held.parent / "new.h5").exists() and not (other.parent / "new.h5").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_replaced_between_edits(h5py, tmp_path):
|
||||||
|
"""The path renamed away and another file put in its place between
|
||||||
|
edits: the edits go to the file held open, never mixed with the other."""
|
||||||
|
held, other = _two_files(h5py, tmp_path)
|
||||||
|
path = str(tmp_path / "f.h5")
|
||||||
|
os.replace(held, path)
|
||||||
|
moved = tmp_path / "moved.h5"
|
||||||
|
with clawhdf5.File(path, "r+") as f:
|
||||||
|
ds = f["x"]
|
||||||
|
ds[0] = 7
|
||||||
|
os.replace(path, moved)
|
||||||
|
shutil.copy(other, path)
|
||||||
|
other_bytes = open(path, "rb").read()
|
||||||
|
ds[:] = np.full(10, 7, "<i4")
|
||||||
|
f.attrs["note"] = np.arange(50.0)
|
||||||
|
np.testing.assert_array_equal(f["x"][()], np.full(10, 7))
|
||||||
|
assert "pad" not in f
|
||||||
|
from pathlib import Path
|
||||||
|
_held_file_edited(h5py, moved, Path(path), other_bytes)
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ const MSG_FLAG_DONTSHARE: u8 = 0x04;
|
|||||||
/// that may be mid-update.
|
/// that may be mid-update.
|
||||||
///
|
///
|
||||||
/// Every method is one self-contained edit: it re-reads the file's
|
/// Every method is one self-contained edit: it re-reads the file's
|
||||||
/// metadata, applies the change, and syncs the file before returning.
|
/// metadata (from the file it holds open, never by path), applies the
|
||||||
|
/// change, and syncs the file before returning.
|
||||||
///
|
///
|
||||||
/// # What it can change
|
/// # What it can change
|
||||||
///
|
///
|
||||||
@@ -750,9 +751,17 @@ impl FileEditor {
|
|||||||
/// consistent: a metadata cache image, paged or persistent free-space
|
/// consistent: a metadata cache image, paged or persistent free-space
|
||||||
/// management, a multi-file driver, a file another writer has marked
|
/// management, a multi-file driver, a file another writer has marked
|
||||||
/// open (superblock version 3 consistency flags).
|
/// open (superblock version 3 consistency flags).
|
||||||
|
///
|
||||||
|
/// The path is only used to open the file: every edit is planned from
|
||||||
|
/// and written to the file opened here, even if the path is renamed,
|
||||||
|
/// replaced or (relative) resolved from another working directory
|
||||||
|
/// later. [`path`](Self::path) is the absolute path it had at open.
|
||||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
|
||||||
let path = path.as_ref().to_path_buf();
|
let file = OpenOptions::new()
|
||||||
let file = OpenOptions::new().read(true).write(true).open(&path)?;
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(path.as_ref())?;
|
||||||
|
let path = std::fs::canonicalize(path.as_ref())?;
|
||||||
match file.try_lock() {
|
match file.try_lock() {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(TryLockError::WouldBlock) => {
|
Err(TryLockError::WouldBlock) => {
|
||||||
@@ -768,16 +777,68 @@ impl FileEditor {
|
|||||||
file,
|
file,
|
||||||
free: FreeList::default(),
|
free: FreeList::default(),
|
||||||
};
|
};
|
||||||
let f = File::open(&ed.path)?;
|
let f = ed.plan_reader()?;
|
||||||
check_editable(&f)?;
|
check_editable(&f)?;
|
||||||
Ok(ed)
|
Ok(ed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The file's path.
|
/// The file's absolute path when it was opened (it may have been
|
||||||
|
/// renamed since; the editor keeps editing the file it opened).
|
||||||
pub fn path(&self) -> &Path {
|
pub fn path(&self) -> &Path {
|
||||||
&self.path
|
&self.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A reader over the file this editor holds, as last written: the file
|
||||||
|
/// opened by [`open`](Self::open), not whatever its path names now.
|
||||||
|
///
|
||||||
|
/// It opens the file anew (read-only), so it does not share the
|
||||||
|
/// editor's lock and stays usable after the editor is dropped: on Linux
|
||||||
|
/// through `/proc/self/fd`, which reaches the held file even after its
|
||||||
|
/// path was renamed or replaced; elsewhere by the path the file had at
|
||||||
|
/// open, refused with [`Error::Io`] when that path no longer names the
|
||||||
|
/// held file (on Unix, compared by device and inode; Windows cannot
|
||||||
|
/// check). With the `mmap` feature the reader maps the file: edits
|
||||||
|
/// through the editor change the bytes it sees, so take a new reader
|
||||||
|
/// after each edit rather than reading through an old one while an edit
|
||||||
|
/// runs.
|
||||||
|
pub fn reader(&self) -> Result<File, Error> {
|
||||||
|
let dir = self.path.parent().map(Path::to_path_buf);
|
||||||
|
File::from_std_file(self.reopen()?, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new read-only open file description of the held file (see
|
||||||
|
/// [`reader`](Self::reader)).
|
||||||
|
fn reopen(&self) -> Result<std::fs::File, Error> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let proc = format!("/proc/self/fd/{}", self.file.as_raw_fd());
|
||||||
|
if let Ok(f) = std::fs::File::open(proc) {
|
||||||
|
return Ok(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let f = std::fs::File::open(&self.path)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let (a, b) = (self.file.metadata()?, f.metadata()?);
|
||||||
|
if (a.dev(), a.ino()) != (b.dev(), b.ino()) {
|
||||||
|
return Err(Error::Io(std::io::Error::other(format!(
|
||||||
|
"{} no longer names the file being edited (renamed or replaced)",
|
||||||
|
self.path.display()
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reader over the held file itself for planning an edit (it shares
|
||||||
|
/// the file's lock, and is dropped before the edit writes).
|
||||||
|
fn plan_reader(&self) -> Result<File, Error> {
|
||||||
|
let dir = self.path.parent().map(Path::to_path_buf);
|
||||||
|
File::from_std_file(self.file.try_clone()?, dir)
|
||||||
|
}
|
||||||
|
|
||||||
/// Bytes earlier edits of this editor freed that later ones can still
|
/// Bytes earlier edits of this editor freed that later ones can still
|
||||||
/// reuse.
|
/// reuse.
|
||||||
pub fn reusable_bytes(&self) -> u64 {
|
pub fn reusable_bytes(&self) -> u64 {
|
||||||
@@ -794,7 +855,7 @@ impl FileEditor {
|
|||||||
&mut self,
|
&mut self,
|
||||||
op: impl FnOnce(&File, &mut Image<'_>) -> Result<R, Error>,
|
op: impl FnOnce(&File, &mut Image<'_>) -> Result<R, Error>,
|
||||||
) -> Result<R, Error> {
|
) -> Result<R, Error> {
|
||||||
let f = File::open(&self.path)?;
|
let f = self.plan_reader()?;
|
||||||
check_editable(&f)?;
|
check_editable(&f)?;
|
||||||
let sb = f.superblock().clone();
|
let sb = f.superblock().clone();
|
||||||
let user_block = f.user_block_size();
|
let user_block = f.user_block_size();
|
||||||
|
|||||||
@@ -405,6 +405,38 @@ impl File {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A reader over an already open file (the file itself, not whatever
|
||||||
|
/// its path names now): mapped with the `mmap` feature, else read into
|
||||||
|
/// memory. `base_dir` resolves external Virtual Dataset sources.
|
||||||
|
pub(crate) fn from_std_file(
|
||||||
|
file: std::fs::File,
|
||||||
|
base_dir: Option<std::path::PathBuf>,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
#[cfg(feature = "mmap")]
|
||||||
|
let mut f = {
|
||||||
|
let reader = clawhdf5_io::MmapReader::from_file(file).map_err(Error::Io)?;
|
||||||
|
let (data, superblock) = FileData::new(Backing::Mmap(reader))?;
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
superblock,
|
||||||
|
chunk_cache: ChunkCache::new(),
|
||||||
|
base_dir: None,
|
||||||
|
vds_resolver: None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "mmap"))]
|
||||||
|
let mut f = {
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
let mut file = file;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
|
||||||
|
file.read_to_end(&mut bytes).map_err(Error::Io)?;
|
||||||
|
Self::from_bytes(bytes)?
|
||||||
|
};
|
||||||
|
f.base_dir = base_dir;
|
||||||
|
Ok(f)
|
||||||
|
}
|
||||||
|
|
||||||
/// Open an HDF5 file by reading it entirely into memory.
|
/// Open an HDF5 file by reading it entirely into memory.
|
||||||
///
|
///
|
||||||
/// This is the pre-mmap behaviour and is useful when memory-mapping is
|
/// This is the pre-mmap behaviour and is useful when memory-mapping is
|
||||||
|
|||||||
@@ -162,3 +162,59 @@ fn shrink_then_grow_reads_fill() {
|
|||||||
raw[..4].fill(0.5);
|
raw[..4].fill(0.5);
|
||||||
assert_eq!(f.dataset("raw").unwrap().read_f64().unwrap(), raw);
|
assert_eq!(f.dataset("raw").unwrap().read_f64().unwrap(), raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The editor plans every edit from the file it holds open, never by
|
||||||
|
/// re-opening its path: with the path renamed away and another file put in
|
||||||
|
/// its place, edits go to the held file, planned from its own metadata,
|
||||||
|
/// and the file now at the path is untouched (planning from it and writing
|
||||||
|
/// into the held file corrupted the held one).
|
||||||
|
#[test]
|
||||||
|
fn edits_go_to_the_file_held_not_the_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let a = dir.path().join("a.h5");
|
||||||
|
let b = dir.path().join("b.h5");
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("x")
|
||||||
|
.with_i32_data(&[0; 10])
|
||||||
|
.with_shape(&[10]);
|
||||||
|
fb.create_dataset("big")
|
||||||
|
.with_f64_data(&[1.5; 5000])
|
||||||
|
.with_shape(&[5000]);
|
||||||
|
fb.set_attr("title", AttrValue::String("a".into()));
|
||||||
|
fb.write(&a).unwrap();
|
||||||
|
let mut fb = FileBuilder::new();
|
||||||
|
fb.create_dataset("pad")
|
||||||
|
.with_f64_data(&[2.5; 3000])
|
||||||
|
.with_shape(&[3000]);
|
||||||
|
fb.create_dataset("x")
|
||||||
|
.with_i32_data(&[500; 10])
|
||||||
|
.with_shape(&[10]);
|
||||||
|
fb.write(&b).unwrap();
|
||||||
|
|
||||||
|
let mut ed = FileEditor::open(&a).unwrap();
|
||||||
|
assert!(ed.path().is_absolute());
|
||||||
|
let moved = dir.path().join("moved.h5");
|
||||||
|
std::fs::rename(&a, &moved).unwrap();
|
||||||
|
std::fs::rename(&b, &a).unwrap();
|
||||||
|
let other = std::fs::read(&a).unwrap();
|
||||||
|
|
||||||
|
ed.write_values("x", &Selection::All, &[7i32; 10]).unwrap();
|
||||||
|
let vals: Vec<f64> = (0..50).map(f64::from).collect();
|
||||||
|
ed.set_attr("/", "note", &AttrValue::F64Array(vals.clone()))
|
||||||
|
.unwrap();
|
||||||
|
// The editor's own reader sees the held file.
|
||||||
|
let r = ed.reader().unwrap();
|
||||||
|
assert_eq!(r.dataset("x").unwrap().read_i32().unwrap(), [7; 10]);
|
||||||
|
assert!(r.dataset("pad").is_err());
|
||||||
|
drop(r);
|
||||||
|
drop(ed);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
std::fs::read(&a).unwrap() == other,
|
||||||
|
"the file at the path changed"
|
||||||
|
);
|
||||||
|
let f = File::open(&moved).unwrap();
|
||||||
|
assert_eq!(f.dataset("x").unwrap().read_i32().unwrap(), [7; 10]);
|
||||||
|
assert_eq!(f.dataset("big").unwrap().read_f64().unwrap(), [1.5; 5000]);
|
||||||
|
assert!(matches!(f.root().attr("note").unwrap(), Some(AttrValue::F64Array(v)) if v == vals));
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ space it leaves is too small for its next, larger version.
|
|||||||
**No journal.** A crash while an edit patches existing structures can leave
|
**No journal.** A crash while an edit patches existing structures can leave
|
||||||
the file inconsistent; see the `FileEditor` documentation.
|
the file inconsistent; see the `FileEditor` documentation.
|
||||||
|
|
||||||
|
**Renamed files outside Linux.** Edits always go to the file the editor
|
||||||
|
opened. `FileEditor::reader()` (which the Python `'r+'` handle reads
|
||||||
|
through) reopens it through `/proc/self/fd` on Linux; elsewhere it reopens
|
||||||
|
the path, and after the path was renamed or replaced it fails (on Unix;
|
||||||
|
Windows cannot tell and would read whatever the path names).
|
||||||
|
|
||||||
## Python in-place editing (`clawhdf5.File(path, 'r+')`) limits
|
## Python in-place editing (`clawhdf5.File(path, 'r+')`) limits
|
||||||
|
|
||||||
**Status:** open (added 2026-09-27). The Python bindings edit through
|
**Status:** open (added 2026-09-27). The Python bindings edit through
|
||||||
|
|||||||
Reference in New Issue
Block a user