clawhdf5-remote: ObjectStoreStorage works from any thread
It refused whenever Handle::try_current() was Ok, which is also the case inside spawn_blocking threads — so the workaround its own error message recommended failed the same way, and the backend could only be used from a bare std::thread in a tokio application. Reads are now spawned on the storage's own runtime and the caller waits on a channel: the future never runs on the caller's thread, so neither a spawn_blocking thread nor a current-thread runtime can deadlock or panic (a read inside a runtime blocks that thread, like any blocking call; the docs still recommend spawn_blocking there). Tests: a read in spawn_blocking of a multi-thread runtime and a read inside a current-thread runtime's task give File::open's values (both errors before). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
+4
-3
@@ -32,9 +32,10 @@
|
|||||||
backoff; bodies are asked for with `Accept-Encoding: identity` and an
|
backoff; bodies are asked for with `Accept-Encoding: identity` and an
|
||||||
encoded one is refused. The ranges of one call are fetched in parallel.
|
encoded one is refused. The ranges of one call are fetched in parallel.
|
||||||
- **`ObjectStoreStorage`** reads one object of any `object_store` store,
|
- **`ObjectStoreStorage`** reads one object of any `object_store` store,
|
||||||
pinned by ETag (else version or modification time) and size. It blocks
|
pinned by ETag (else version or modification time) and size. Each read
|
||||||
on a small tokio runtime it owns; called from inside another runtime it
|
runs on a small tokio runtime the storage owns while the caller waits,
|
||||||
refuses (`RemoteError::Usage`) instead of blocking a worker.
|
so it works from any thread, `spawn_blocking` and other runtimes
|
||||||
|
included.
|
||||||
`open_object(store, path, options)` opens a file through a block cache.
|
`open_object(store, path, options)` opens a file through a block cache.
|
||||||
- Counted on the conformance corpus (tank, 2026-09-26,
|
- Counted on the conformance corpus (tank, 2026-09-26,
|
||||||
`CLAWHDF5_REMOTE_CORPUS=conformance/.cache/corpus CLAWHDF5_REMOTE_REPORT=1
|
`CLAWHDF5_REMOTE_CORPUS=conformance/.cache/corpus CLAWHDF5_REMOTE_REPORT=1
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ checks both).
|
|||||||
## Object stores
|
## Object stores
|
||||||
|
|
||||||
`object_store` is async; `Storage` is synchronous (parsing is CPU work).
|
`object_store` is async; `Storage` is synchronous (parsing is CPU work).
|
||||||
`ObjectStoreStorage` owns a small tokio runtime (two worker threads) and
|
`ObjectStoreStorage` owns a small tokio runtime (two worker threads): each
|
||||||
blocks the calling thread on it for each read, so it is read from ordinary
|
read runs there while the calling thread waits, so it works from any
|
||||||
threads, several at once. From inside an async runtime it refuses
|
thread, several at once — including `tokio::task::spawn_blocking` and code
|
||||||
(`RemoteError::Usage`) rather than block a worker: read in
|
inside another runtime (where `spawn_blocking` is still the better place,
|
||||||
`tokio::task::spawn_blocking`. The object is pinned by its ETag
|
since a read blocks the thread it is called on). The object is pinned by its ETag
|
||||||
(`If-Match`, and compared on every response), else its version or
|
(`If-Match`, and compared on every response), else its version or
|
||||||
modification time, and its size. The ranges of one read are fetched
|
modification time, and its size. The ranges of one read are fetched
|
||||||
concurrently (up to 8).
|
concurrently (up to 8).
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
//!
|
//!
|
||||||
//! `object_store` is async and [`Storage`] is synchronous (parsing is CPU
|
//! `object_store` is async and [`Storage`] is synchronous (parsing is CPU
|
||||||
//! work; `docs/design/range-reads.md` §3 (a)). The storage owns a small
|
//! work; `docs/design/range-reads.md` §3 (a)). The storage owns a small
|
||||||
//! multi-threaded tokio runtime (two worker threads) and blocks the calling
|
//! multi-threaded tokio runtime (two worker threads): each read is spawned
|
||||||
//! thread on it for each read, so it can be used from ordinary threads —
|
//! on it and the calling thread waits for the result, so it can be used
|
||||||
//! several at once. Calling it from inside another tokio runtime would
|
//! from any thread — several at once, and from async code too. From async
|
||||||
//! block that runtime's worker, so it refuses with
|
//! code prefer `tokio::task::spawn_blocking` (a read blocks the thread it
|
||||||
//! [`RemoteError::Usage`]: from async code, read in
|
//! is called on, which inside a runtime is one of its workers).
|
||||||
//! `tokio::task::spawn_blocking`.
|
|
||||||
//!
|
//!
|
||||||
//! The object is pinned when the storage is made: its size, and its ETag
|
//! 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)
|
//! (sent as `If-Match` with every read, and compared with every response)
|
||||||
@@ -105,19 +104,23 @@ impl ObjectStoreStorage {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn block_on<T>(
|
/// 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,
|
&self,
|
||||||
fut: impl std::future::Future<Output = Result<T, RemoteError>>,
|
fut: impl std::future::Future<Output = Result<T, RemoteError>> + Send + 'static,
|
||||||
) -> Result<T, RemoteError> {
|
) -> Result<T, RemoteError> {
|
||||||
if tokio::runtime::Handle::try_current().is_ok() {
|
|
||||||
return Err(RemoteError::Usage(
|
|
||||||
"ObjectStoreStorage blocks on its own runtime and cannot be read from inside \
|
|
||||||
an async runtime; read in tokio::task::spawn_blocking"
|
|
||||||
.into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let rt = self.runtime.as_ref().expect("runtime lives until drop");
|
let rt = self.runtime.as_ref().expect("runtime lives until drop");
|
||||||
rt.block_on(fut)
|
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 {
|
fn options(&self, range: Range<u64>) -> GetOptions {
|
||||||
|
|||||||
@@ -109,10 +109,51 @@ fn missing_objects_and_async_callers_are_clean_errors() {
|
|||||||
assert!(ObjectStoreStorage::new(store.clone(), ObjectPath::from("nope.h5")).is_err());
|
assert!(ObjectStoreStorage::new(store.clone(), ObjectPath::from("nope.h5")).is_err());
|
||||||
put(store.as_ref(), "m.h5", multi_block_file());
|
put(store.as_ref(), "m.h5", multi_block_file());
|
||||||
let storage = ObjectStoreStorage::new(store, ObjectPath::from("m.h5")).unwrap();
|
let storage = ObjectStoreStorage::new(store, ObjectPath::from("m.h5")).unwrap();
|
||||||
// From inside a runtime: refused, not a panic or a deadlock.
|
// From inside a current-thread runtime: works (the read runs on the
|
||||||
|
// storage's own runtime), no panic, no deadlock.
|
||||||
let rt = tokio_rt();
|
let rt = tokio_rt();
|
||||||
let r = rt.block_on(async { storage.read_at(0, 10).map(|b| b.len()) });
|
let r = rt.block_on(async { storage.read_at(0, 10).map(|b| b.len()) });
|
||||||
assert!(r.unwrap_err().to_string().contains("spawn_blocking"));
|
assert_eq!(r.unwrap(), 10);
|
||||||
// Dropping the storage inside a runtime does not panic.
|
// Dropping the storage inside a runtime does not panic.
|
||||||
rt.block_on(async move { drop(storage) });
|
rt.block_on(async move { drop(storage) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The advice for async callers works: a read in `spawn_blocking` of a
|
||||||
|
/// multi-threaded runtime (where `Handle::try_current` is Ok), and a read
|
||||||
|
/// straight inside a current-thread runtime's task.
|
||||||
|
#[test]
|
||||||
|
fn object_stores_read_from_spawn_blocking_and_inside_runtimes() {
|
||||||
|
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
|
||||||
|
let bytes = multi_block_file();
|
||||||
|
put(store.as_ref(), "m.h5", bytes.clone());
|
||||||
|
let want = File::from_bytes(bytes)
|
||||||
|
.unwrap()
|
||||||
|
.dataset("big")
|
||||||
|
.unwrap()
|
||||||
|
.read_f64()
|
||||||
|
.unwrap();
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let (s, w) = (store.clone(), want.clone());
|
||||||
|
let got = rt
|
||||||
|
.block_on(async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
assert!(tokio::runtime::Handle::try_current().is_ok());
|
||||||
|
let (f, _) =
|
||||||
|
open_object(s, "m.h5", &Options::default()).map_err(|e| e.to_string())?;
|
||||||
|
let v = f.dataset("big").unwrap().read_f64().unwrap();
|
||||||
|
Ok::<_, String>(v == w)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got, Ok(true));
|
||||||
|
let rt = tokio_rt();
|
||||||
|
let got = rt.block_on(async {
|
||||||
|
let (f, _) = open_object(store, "m.h5", &Options::default()).unwrap();
|
||||||
|
f.dataset("big").unwrap().read_f64().unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(got, want);
|
||||||
|
}
|
||||||
|
|||||||
@@ -471,9 +471,10 @@ fast path within benchmark noise.
|
|||||||
with ring. S3/GCS/Azure go through `object_store` (`ObjectStoreStorage`,
|
with ring. S3/GCS/Azure go through `object_store` (`ObjectStoreStorage`,
|
||||||
features `s3`/`gcs`/`azure`, opt-in because of aws-lc-rs); the
|
features `s3`/`gcs`/`azure`, opt-in because of aws-lc-rs); the
|
||||||
`object-store` feature alone (in-memory, local files, a store you
|
`object-store` feature alone (in-memory, local files, a store you
|
||||||
build) is pure Rust. object_store is async: the storage blocks on a
|
build) is pure Rust. object_store is async: each read runs on a
|
||||||
two-thread tokio runtime of its own, and refuses to run inside another
|
two-thread tokio runtime of the storage's own while the caller waits,
|
||||||
runtime.
|
so the caller's context (a plain thread, `spawn_blocking`, another
|
||||||
|
runtime) does not matter.
|
||||||
- `BlockCache` (any `Storage`): 1 MiB blocks and a 64 MiB LRU budget by
|
- `BlockCache` (any `Storage`): 1 MiB blocks and a 64 MiB LRU budget by
|
||||||
default, the first block fetched at open (for HTTP by the request
|
default, the first block fetched at open (for HTTP by the request
|
||||||
that learns the length), the missing blocks of one read fetched as
|
that learns the length), the missing blocks of one read fetched as
|
||||||
|
|||||||
Reference in New Issue
Block a user