fix(clawhdf5): surface filter-pipeline parse errors; write files atomically

- Dataset::filter_pipeline() (reader, lazy and mmap variants) swallowed parse
  errors with `.ok()`, so a malformed pipeline message silently became "no
  filters" and the still-compressed chunk bytes were returned as the data. It
  now returns Result<Option<_>>; a present-but-unparseable pipeline is
  Error::Format.
- FileBuilder::write used std::fs::write, which truncates the destination
  first: a crash mid-write destroyed the existing file. It now writes a
  sibling temp file, syncs it, renames it over the target and syncs the
  directory, cleaning the temp file up on failure.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
osobh
2026-09-19 06:14:30 -07:00
co-authored by Claude Fable 5.1
parent 6e84f31ed6
commit bf8bbec87e
4 changed files with 109 additions and 11 deletions
+8 -3
View File
@@ -436,19 +436,24 @@ impl<'f, R: HDF5Read> LazyDataset<'f, R> {
)?) )?)
} }
fn filter_pipeline(&self) -> Option<FilterPipeline> { /// `Ok(None)` means the dataset has no filter pipeline. A pipeline message
/// that is present but unparseable is an error: treating it as "no
/// filters" would hand the caller the still-compressed bytes as if they
/// were the data.
fn filter_pipeline(&self) -> Result<Option<FilterPipeline>, Error> {
self.header self.header
.messages .messages
.iter() .iter()
.find(|m| m.msg_type == MessageType::FilterPipeline) .find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|msg| FilterPipeline::parse(&msg.data).ok()) .map(|msg| FilterPipeline::parse(&msg.data).map_err(Error::Format))
.transpose()
} }
fn read_raw(&self) -> Result<Vec<u8>, Error> { fn read_raw(&self) -> Result<Vec<u8>, Error> {
let dt = self.datatype()?; let dt = self.datatype()?;
let ds = self.dataspace()?; let ds = self.dataspace()?;
let dl = self.data_layout()?; let dl = self.data_layout()?;
let pipeline = self.filter_pipeline(); let pipeline = self.filter_pipeline()?;
let data = self.file.reader.as_bytes(); let data = self.file.reader.as_bytes();
Ok(data_read::read_raw_data_full( Ok(data_read::read_raw_data_full(
data, data,
+8 -3
View File
@@ -377,19 +377,24 @@ impl<'f> MmapDataset<'f> {
)?) )?)
} }
fn filter_pipeline(&self) -> Option<FilterPipeline> { /// `Ok(None)` means the dataset has no filter pipeline. A pipeline message
/// that is present but unparseable is an error: treating it as "no
/// filters" would hand the caller the still-compressed bytes as if they
/// were the data.
fn filter_pipeline(&self) -> Result<Option<FilterPipeline>, Error> {
self.header self.header
.messages .messages
.iter() .iter()
.find(|m| m.msg_type == MessageType::FilterPipeline) .find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|msg| FilterPipeline::parse(&msg.data).ok()) .map(|msg| FilterPipeline::parse(&msg.data).map_err(Error::Format))
.transpose()
} }
fn read_raw(&self) -> Result<Vec<u8>, Error> { fn read_raw(&self) -> Result<Vec<u8>, Error> {
let dt = self.datatype()?; let dt = self.datatype()?;
let ds = self.dataspace()?; let ds = self.dataspace()?;
let dl = self.data_layout()?; let dl = self.data_layout()?;
let pipeline = self.filter_pipeline(); let pipeline = self.filter_pipeline()?;
Ok(data_read::read_raw_data_full( Ok(data_read::read_raw_data_full(
self.file.reader.as_bytes(), self.file.reader.as_bytes(),
&dl, &dl,
+9 -4
View File
@@ -447,7 +447,7 @@ impl<'f> Dataset<'f> {
let dt = self.datatype()?; let dt = self.datatype()?;
let ds = self.dataspace()?; let ds = self.dataspace()?;
let dl = self.data_layout()?; let dl = self.data_layout()?;
let pipeline = self.filter_pipeline(); let pipeline = self.filter_pipeline()?;
Ok(data_read::read_raw_data_selection( Ok(data_read::read_raw_data_selection(
self.file.data.as_bytes(), self.file.data.as_bytes(),
&dl, &dl,
@@ -743,19 +743,24 @@ impl<'f> Dataset<'f> {
)?) )?)
} }
fn filter_pipeline(&self) -> Option<FilterPipeline> { /// `Ok(None)` means the dataset has no filter pipeline. A pipeline message
/// that is present but unparseable is an error: treating it as "no
/// filters" would hand the caller the still-compressed bytes as if they
/// were the data.
fn filter_pipeline(&self) -> Result<Option<FilterPipeline>, Error> {
self.header self.header
.messages .messages
.iter() .iter()
.find(|m| m.msg_type == MessageType::FilterPipeline) .find(|m| m.msg_type == MessageType::FilterPipeline)
.and_then(|msg| FilterPipeline::parse(&msg.data).ok()) .map(|msg| FilterPipeline::parse(&msg.data).map_err(Error::Format))
.transpose()
} }
fn read_raw(&self) -> Result<Vec<u8>, Error> { fn read_raw(&self) -> Result<Vec<u8>, Error> {
let dt = self.datatype()?; let dt = self.datatype()?;
let ds = self.dataspace()?; let ds = self.dataspace()?;
let dl = self.data_layout()?; let dl = self.data_layout()?;
let pipeline = self.filter_pipeline(); let pipeline = self.filter_pipeline()?;
// Virtual datasets are assembled from source datasets; the per-file // Virtual datasets are assembled from source datasets; the per-file
// chunk cache does not apply. Route them through the resolver path so // chunk cache does not apply. Route them through the resolver path so
+84 -1
View File
@@ -72,7 +72,7 @@ impl FileBuilder {
/// Serialize and write the file to the given path. /// Serialize and write the file to the given path.
pub fn write<P: AsRef<std::path::Path>>(self, path: P) -> Result<(), Error> { pub fn write<P: AsRef<std::path::Path>>(self, path: P) -> Result<(), Error> {
let bytes = self.finish()?; let bytes = self.finish()?;
std::fs::write(path, bytes).map_err(Error::Io) write_file_atomically(path.as_ref(), &bytes).map_err(Error::Io)
} }
} }
@@ -186,3 +186,86 @@ pub fn create_datasets_parallel(specs: Vec<DatasetSpec>) -> Result<Vec<u8>, Erro
let bytes = clawhdf5_format::file_writer::finalize_parallel(blocks)?; let bytes = clawhdf5_format::file_writer::finalize_parallel(blocks)?;
Ok(bytes) Ok(bytes)
} }
/// Write `bytes` to `path` so that a crash or power loss leaves either the old
/// file or the complete new one — never a truncated mix. `std::fs::write`
/// truncates the destination first, so dying mid-write used to destroy the
/// existing file.
fn write_file_atomically(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
// Same directory as the target, so the rename stays on one filesystem.
let mut tmp_name = path
.file_name()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name")
})?
.to_os_string();
tmp_name.push(format!(".tmp-{}", std::process::id()));
let tmp_path = path.with_file_name(tmp_name);
let result = (|| {
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(bytes)?;
f.sync_all()?;
std::fs::rename(&tmp_path, path)
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
return result;
}
// Make the rename itself durable. Best-effort: not every filesystem
// supports syncing a directory, and the new file is already in place.
#[cfg(unix)]
if let Some(dir) = path.parent() {
let dir = if dir.as_os_str().is_empty() {
std::path::Path::new(".")
} else {
dir
};
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
}
#[cfg(test)]
mod atomic_write_tests {
use super::write_file_atomically;
fn entries(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn replaces_existing_file_and_leaves_no_temp_behind() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("out.h5");
std::fs::write(&path, b"old contents").unwrap();
write_file_atomically(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(entries(dir.path()), ["out.h5"]);
}
#[test]
fn failure_leaves_the_existing_file_untouched() {
let dir = tempfile::TempDir::new().unwrap();
// The target is a directory, so the final rename cannot succeed.
let path = dir.path().join("taken");
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("keep"), b"x").unwrap();
assert!(write_file_atomically(&path, b"new").is_err());
assert!(path.is_dir());
assert_eq!(entries(dir.path()), ["taken"], "temp file cleaned up");
}
}