Files
clawhdf5/crates/clawhdf5-remote/src/object.rs
T
osobhandClaude Opus 5.5 ef480746da clawhdf5-remote: a failed first fetch is Error::Remote
cached() mapped an error of its open-time prefetch (a network error, a
changed file) to Error::Hdf5(Format(Storage)), misclassifying it for
callers that match on the variant. It is now Error::Remote
(RemoteError::Backend with the backend's message). open_object and the
s3/gs/az URLs fetch the first block of an ObjectStoreStorage directly, so
their errors keep their kind (FileChanged, ObjectStore).

Test: cached() over a backend whose reads fail gives Error::Remote(Backend)
(Error::Hdf5 before).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:36:35 -05:00

295 lines
11 KiB
Rust

//! Object stores (S3, GCS, Azure, local files, memory) through the
//! [`object_store`] crate: [`ObjectStoreStorage`].
//!
//! `object_store` is async and [`Storage`] is synchronous (parsing is CPU
//! work; `docs/design/range-reads.md` §3 (a)). The storage owns a small
//! multi-threaded tokio runtime (two worker threads): each read is spawned
//! on it and the calling thread waits for the result, so it can be used
//! from any thread — several at once, and from async code too. From async
//! code prefer `tokio::task::spawn_blocking` (a read blocks the thread it
//! is called on, which inside a runtime is one of its workers).
//!
//! The object is pinned when the storage is made: its size, and its ETag
//! (sent as `If-Match` with every read, and compared with every response)
//! or, without one, its version or modification time. A change while it is
//! open is [`RemoteError::FileChanged`]. The ranges of one `read_ranges`
//! call are fetched concurrently (at most 8 at a time).
use std::borrow::Cow;
use std::ops::Range;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use clawhdf5_format::error::FormatError;
use clawhdf5_format::storage::Storage;
use futures_util::{StreamExt, TryStreamExt};
use object_store::path::Path;
use object_store::{GetOptions, GetRange, ObjectMeta, ObjectStore, ObjectStoreExt};
use crate::error::RemoteError;
/// Concurrent requests of one `read_ranges` call.
const MAX_CONCURRENT: usize = 8;
/// One object of an [`ObjectStore`], read by ranged `get`s. See the
/// [module documentation](self).
pub struct ObjectStoreStorage {
store: Arc<dyn ObjectStore>,
path: Path,
meta: ObjectMeta,
runtime: Option<tokio::runtime::Runtime>,
requests: AtomicU64,
bytes: AtomicU64,
}
impl std::fmt::Debug for ObjectStoreStorage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ObjectStoreStorage")
.field("store", &self.store.to_string())
.field("path", &self.path)
.field("size", &self.meta.size)
.field("e_tag", &self.meta.e_tag)
.finish()
}
}
fn os_error(e: object_store::Error) -> RemoteError {
match e {
object_store::Error::Precondition { .. } | object_store::Error::NotModified { .. } => {
RemoteError::FileChanged(e.to_string())
}
other => RemoteError::ObjectStore(other.to_string()),
}
}
impl ObjectStoreStorage {
/// Open the object at `path` of `store`: one `head` request for its
/// size and validators.
pub fn new(store: Arc<dyn ObjectStore>, path: Path) -> Result<Self, RemoteError> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("clawhdf5-remote")
.enable_all()
.build()
.map_err(|e| RemoteError::Usage(format!("cannot start a tokio runtime: {e}")))?;
let mut s = ObjectStoreStorage {
store,
path,
meta: ObjectMeta {
location: Path::default(),
last_modified: Default::default(),
size: 0,
e_tag: None,
version: None,
},
runtime: Some(runtime),
requests: AtomicU64::new(1),
bytes: AtomicU64::new(0),
};
let (store, path) = (s.store.clone(), s.path.clone());
s.meta = s.block_on(async move { store.head(&path).await.map_err(os_error) })?;
Ok(s)
}
/// The object's metadata as pinned at open.
pub fn meta(&self) -> &ObjectMeta {
&self.meta
}
/// Requests made (the `head` included) and bytes received.
pub fn stats(&self) -> (u64, u64) {
(
self.requests.load(Ordering::Relaxed),
self.bytes.load(Ordering::Relaxed),
)
}
/// Run `fut` on the storage's own runtime and wait for it. The future
/// never runs on the caller's thread, so the caller's context does not
/// matter: a plain thread, `spawn_blocking`, or even inside another
/// runtime (whose thread is then blocked for the duration of the read,
/// as by any blocking call, but nothing deadlocks or panics).
fn block_on<T: Send + 'static>(
&self,
fut: impl std::future::Future<Output = Result<T, RemoteError>> + Send + 'static,
) -> Result<T, RemoteError> {
let rt = self.runtime.as_ref().expect("runtime lives until drop");
let (tx, rx) = std::sync::mpsc::sync_channel(1);
rt.spawn(async move {
let _ = tx.send(fut.await);
});
rx.recv().map_err(|_| {
RemoteError::ObjectStore("the object store task ended without a result".into())
})?
}
fn options(&self, range: Range<u64>) -> GetOptions {
let mut o = GetOptions {
range: Some(GetRange::Bounded(range)),
..GetOptions::default()
};
if let Some(e) = &self.meta.e_tag {
o.if_match = Some(e.clone());
} else if let Some(v) = &self.meta.version {
o.version = Some(v.clone());
} else {
o.if_unmodified_since = Some(self.meta.last_modified);
}
o
}
/// The object's first `n` bytes (fewer if it is shorter).
pub(crate) fn fetch_first(&self, n: u64) -> Result<Vec<u8>, RemoteError> {
Ok(self.fetch_all(&[0..n])?.pop().unwrap_or_default())
}
fn fetch_all(&self, ranges: &[Range<u64>]) -> Result<Vec<Vec<u8>>, RemoteError> {
let len = self.meta.size;
let jobs: Vec<(usize, Range<u64>)> = ranges
.iter()
.enumerate()
.filter_map(|(i, r)| {
let end = r.end.min(len);
(r.start < end).then_some((i, r.start..end))
})
.collect();
let store = self.store.clone();
let path = self.path.clone();
let pinned = self.meta.e_tag.clone();
let reqs: Vec<(usize, Range<u64>, GetOptions)> = jobs
.into_iter()
.map(|(i, r)| {
let o = self.options(r.clone());
(i, r, o)
})
.collect();
self.requests
.fetch_add(reqs.len() as u64, Ordering::Relaxed);
let fetched: Vec<(usize, Vec<u8>)> = self.block_on(async move {
futures_util::stream::iter(reqs)
.map(|(i, r, o)| {
let (store, path, pinned) = (store.clone(), path.clone(), pinned.clone());
async move {
let got = store.get_opts(&path, o).await.map_err(os_error)?;
if let (Some(want), Some(have)) = (&pinned, &got.meta.e_tag)
&& want != have
{
return Err(RemoteError::FileChanged(format!(
"{path}: ETag {have} instead of {want}"
)));
}
if got.meta.size != len {
return Err(RemoteError::FileChanged(format!(
"{path}: size {} instead of {len}",
got.meta.size
)));
}
let bytes = got.bytes().await.map_err(os_error)?;
if bytes.len() as u64 != r.end - r.start {
return Err(RemoteError::BadResponse(format!(
"{path}: {} bytes for range {r:?}",
bytes.len()
)));
}
Ok::<_, RemoteError>((i, bytes.to_vec()))
}
})
.buffer_unordered(MAX_CONCURRENT)
.try_collect()
.await
})?;
let mut out = vec![Vec::new(); ranges.len()];
for (i, b) in fetched {
self.bytes.fetch_add(b.len() as u64, Ordering::Relaxed);
out[i] = b;
}
Ok(out)
}
}
impl Drop for ObjectStoreStorage {
fn drop(&mut self) {
// Dropping a runtime blocks, which panics inside an async context.
if let Some(rt) = self.runtime.take() {
rt.shutdown_background();
}
}
}
impl Storage for ObjectStoreStorage {
fn read_at(&self, offset: u64, len: usize) -> Result<Cow<'_, [u8]>, FormatError> {
let range = offset..offset.saturating_add(len as u64);
let mut v = self.fetch_all(std::slice::from_ref(&range))?;
Ok(Cow::Owned(v.pop().unwrap_or_default()))
}
fn len(&self) -> u64 {
self.meta.size
}
fn read_ranges(&self, ranges: &[Range<u64>]) -> Result<Vec<Cow<'_, [u8]>>, FormatError> {
if ranges.iter().any(|r| r.end < r.start) {
return Err(FormatError::Storage(
"read range ends before it starts".into(),
));
}
Ok(self
.fetch_all(ranges)?
.into_iter()
.map(Cow::Owned)
.collect())
}
}
/// The store and object path for a cloud URL (`s3://bucket/key`,
/// `gs://bucket/key`, `az://container/key`, ...), configured from the
/// environment as `object_store`'s `from_env` builders do (`AWS_*`,
/// `GOOGLE_*`, `AZURE_*`).
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
pub(crate) fn store_for_url(url: &str) -> Result<(Arc<dyn ObjectStore>, Path), RemoteError> {
let redactor = crate::error::Redactor::new(url);
let parsed = object_store::path::Path::parse(
url.split_once("://")
.and_then(|(_, rest)| rest.split_once('/'))
.map(|(_, key)| key)
.unwrap_or(""),
)
.map_err(|e| RemoteError::InvalidUrl(format!("{}: {e}", crate::redact_url(url))))?;
let scheme = url.split_once("://").map(|(s, _)| s.to_ascii_lowercase());
let store: Arc<dyn ObjectStore> = match scheme.as_deref() {
#[cfg(feature = "s3")]
Some("s3" | "s3a") => Arc::new(
object_store::aws::AmazonS3Builder::from_env()
.with_url(url)
.build()
.map_err(|e| os_error(e).scrubbed(&redactor))?,
),
#[cfg(feature = "gcs")]
Some("gs") => Arc::new(
object_store::gcp::GoogleCloudStorageBuilder::from_env()
.with_url(url)
.build()
.map_err(|e| os_error(e).scrubbed(&redactor))?,
),
#[cfg(feature = "azure")]
Some("az" | "azure" | "abfs" | "abfss" | "adl") => Arc::new(
object_store::azure::MicrosoftAzureBuilder::from_env()
.with_url(url)
.build()
.map_err(|e| os_error(e).scrubbed(&redactor))?,
),
_ => return Err(RemoteError::UnsupportedScheme(crate::redact_url(url))),
};
Ok((store, parsed))
}
#[cfg(all(test, feature = "s3"))]
mod tests {
#[test]
fn s3_urls_name_the_bucket_and_key() {
let (store, path) = super::store_for_url("s3://my-bucket/dir/file.h5").unwrap();
assert_eq!(path.as_ref(), "dir/file.h5");
assert!(store.to_string().contains("my-bucket"), "{store}");
}
}