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:
co-authored by
Claude Fable 5.1
parent
6e84f31ed6
commit
bf8bbec87e
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user