Skip to content

Commit 531283a

Browse files
committed
feat(sedona-raster-zarr): async-native cloud storage backends via object_store
ZarrChunkReader now accepts file://, bare paths, s3://, gs:// / gcs://, az:// / abfs:// / abfss://, and http:// / https://. The reader's public API is pure-storage-in: hand it an Arc<dyn AsyncReadableListableStorageTraits> and a group URI (for chunk-anchor URI formatting) and it walks the chunk grid. Where the storage came from — registry lookup, env-var-built builder, an in-memory test fixture — is the caller's concern, not the reader's. * `open_storage_from_uri(uri, store_override: Option<Arc<dyn ObjectStore>>)` is the helper for callers that don't already hold a credentialed store. `Some(store)` uses it directly (with PrefixStore for s3/gs/az; http(s) is rooted at the URL by its builder). `None` falls back to per-scheme *Builder::from_env().with_url(uri).build() — the same env-var credential discovery that read_format's orchestrated path gets via ensure_object_store_registered_with_options. file:// and bare paths always go through zarrs_filesystem's FilesystemStore wrapped in SyncToAsyncStorageAdapter, so the local backend shows up on the same async storage surface. * ZarrChunkReader::try_new is async; the sync RecordBatchReader streaming surface is unchanged since next() is pure CPU. * No dedicated tokio runtime; async tasks run on whatever runtime the caller is on (DataFusion's executor for the future SQL UDTF, an ad-hoc current-thread runtime block_on'd in PyZarrChunkReader::new for the Python FFI, #[tokio::test] in tests). * PyZarrChunkReader::new builds storage via open_storage_from_uri (env-var credentials by default), then drives the async try_new through a local current-thread tokio runtime. No synthetic RuntimeEnv, no datafusion-execution dep on the FFI cdylib. * Replaces Group::child_arrays (which opens every child up front and errors hard on any per-array metadata failure) with two purpose- built paths driven by the caller's arrays filter: - arrays: Some([...]) opens each by name with Array::async_open. No listing — usable against backends that can't list (plain HTTPS without WebDAV, S3-via-HttpStore). - arrays: None lists direct children with storage.list_dir, then Array::async_open each. Per-array open failures are logged at warn! and skipped, so a single malformed sibling (e.g. an xarray-style fixed-length-Unicode coord variable with a null fill_value that zarrs 0.23 can't open) no longer poisons the rest of the group. * zarrs_object_store pinned to 0.5 in the workspace; 0.6 depends on object_store 0.13, semver-incompatible with DataFusion 52's object_store 0.12.x. * Cloud smoke tests pass strictly against the public anonymous ITS_LIVE v2 ice-velocity datacubes (s3://its-live-data/...). Same bucket via s3:// (AmazonS3Builder) and via the virtual-hosted HTTPS URL (HttpStore), with an explicit M11/M12 filter to avoid listing on the HTTPS path.
1 parent 0b45f1d commit 531283a

10 files changed

Lines changed: 521 additions & 179 deletions

File tree

Cargo.lock

Lines changed: 39 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ wkb = "0.9.2"
137137
wkt = "0.14.0"
138138
zarrs = { version = "0.23", default-features = false }
139139
zarrs_filesystem = "0.3"
140+
zarrs_object_store = "0.5"
140141

141142
# Workspace path dependencies for internal crates
142143
sedona = { version = "0.4.0", path = "rust/sedona" }

python/sedonadb-zarr/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,8 @@ doc = false
2929

3030
[dependencies]
3131
arrow-array = { workspace = true, features = ["ffi"] }
32+
object_store = { workspace = true, features = ["aws", "gcp", "azure", "http"] }
3233
pyo3 = { version = "0.25.1" }
3334
sedona-raster-zarr = { workspace = true }
35+
tokio = { workspace = true, features = ["rt"] }
36+
url = { workspace = true }

python/sedonadb-zarr/src/lib.rs

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@
2020
//! Python-side `ZarrFormatSpec`.
2121
2222
use std::ffi::CString;
23-
use std::sync::Mutex;
23+
use std::sync::{Arc, Mutex};
2424

2525
use arrow_array::ffi_stream::FFI_ArrowArrayStream;
26+
use object_store::ObjectStore;
2627
use pyo3::exceptions::{PyRuntimeError, PyValueError};
2728
use pyo3::prelude::*;
2829
use pyo3::types::PyCapsule;
29-
use sedona_raster_zarr::ZarrChunkReader;
30+
use sedona_raster_zarr::{open_storage_from_uri, ZarrChunkReader};
31+
use tokio::runtime::Builder;
32+
use url::Url;
3033

3134
/// Single-use `__arrow_c_stream__` wrapper around `ZarrChunkReader`.
3235
#[pyclass]
@@ -39,7 +42,24 @@ impl PyZarrChunkReader {
3942
#[new]
4043
#[pyo3(signature = (uri, arrays=None, batch_size=8192))]
4144
fn new(uri: &str, arrays: Option<Vec<String>>, batch_size: usize) -> PyResult<Self> {
42-
let reader = ZarrChunkReader::try_new(uri, arrays.as_deref(), batch_size)
45+
let store =
46+
default_object_store_for_uri(uri).map_err(|e| PyValueError::new_err(e.to_string()))?;
47+
let storage =
48+
open_storage_from_uri(uri, store).map_err(|e| PyValueError::new_err(e.to_string()))?;
49+
// The crate's async-native loader exposes `try_new` as an
50+
// `async fn`; the Python FFI is a sync constructor by
51+
// contract, so we bridge with an ad-hoc current-thread tokio
52+
// runtime here. `next()` on the returned reader is pure CPU
53+
// and stays synchronous.
54+
let runtime = Builder::new_current_thread()
55+
.enable_all()
56+
.build()
57+
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
58+
let arrays_ref = arrays.as_deref();
59+
let reader = runtime
60+
.block_on(ZarrChunkReader::try_new(
61+
storage, uri, arrays_ref, batch_size,
62+
))
4363
.map_err(|e| PyValueError::new_err(e.to_string()))?;
4464
Ok(Self {
4565
inner: Mutex::new(Some(reader)),
@@ -70,6 +90,88 @@ impl PyZarrChunkReader {
7090
}
7191
}
7292

93+
/// Build an `Arc<dyn ObjectStore>` for `uri` using env-var credential
94+
/// discovery, per scheme. This is the temporary bridge that lets the
95+
/// Python FFI work without going through `con.read_format`; it
96+
/// reaches into `AWS_*` / `GOOGLE_*` / `AZURE_*` environment
97+
/// variables via the `object_store` per-backend `Builder::from_env`
98+
/// helpers.
99+
///
100+
/// **Slated for removal.** When the host wheel surfaces
101+
/// `args.src.store` to `open_reader` via the `FFI_ObjectStore`
102+
/// capsule machinery (in progress — see
103+
/// <https://github.com/apache/sedona-db/pull/890>), this function (and
104+
/// the `object_store` `aws`/`gcp`/`azure`/`http` features that back it)
105+
/// gets deleted — the store will arrive credentialed from the host's
106+
/// `ObjectStoreRegistry` and `PyZarrChunkReader::new` will extract it
107+
/// from the capsule instead. For `file://` and bare paths the
108+
/// returned store is a no-op placeholder; the loader uses
109+
/// `FilesystemStore` directly for the local case.
110+
fn default_object_store_for_uri(uri: &str) -> Result<Arc<dyn ObjectStore>, PyErr> {
111+
// file:// and bare paths use a LocalFileSystem rooted at `/`;
112+
// open_storage_from_uri prefixes it at the group's path. This
113+
// matches the store the host's ObjectStoreRegistry yields for
114+
// file://, so the loader treats local and cloud uniformly.
115+
if uri.starts_with("file://") || !uri.contains("://") {
116+
return Ok(Arc::new(object_store::local::LocalFileSystem::new()));
117+
}
118+
let url = Url::parse(uri)
119+
.map_err(|e| PyValueError::new_err(format!("group URI {uri:?} is not a valid URL: {e}")))?;
120+
match url.scheme().to_ascii_lowercase().as_str() {
121+
"s3" => {
122+
use object_store::aws::AmazonS3Builder;
123+
let store = AmazonS3Builder::from_env()
124+
.with_url(uri)
125+
.build()
126+
.map_err(|e| PyValueError::new_err(build_err("s3", uri, e)))?;
127+
Ok(Arc::new(store))
128+
}
129+
"gs" | "gcs" => {
130+
use object_store::gcp::GoogleCloudStorageBuilder;
131+
let store = GoogleCloudStorageBuilder::from_env()
132+
.with_url(uri)
133+
.build()
134+
.map_err(|e| PyValueError::new_err(build_err("gcs", uri, e)))?;
135+
Ok(Arc::new(store))
136+
}
137+
"az" | "abfs" | "abfss" => {
138+
use object_store::azure::MicrosoftAzureBuilder;
139+
let store = MicrosoftAzureBuilder::from_env()
140+
.with_url(uri)
141+
.build()
142+
.map_err(|e| PyValueError::new_err(build_err("azure", uri, e)))?;
143+
Ok(Arc::new(store))
144+
}
145+
"http" | "https" => {
146+
use object_store::http::HttpBuilder;
147+
// open_storage_from_uri applies the path as a PrefixStore,
148+
// so the HttpStore must be rooted at scheme+authority only
149+
// — unlike S3/GCS/Azure, HttpBuilder roots at whatever URL
150+
// it's given, so hand it the authority without the path.
151+
let authority = format!("{}://{}", url.scheme(), url.authority());
152+
let store = HttpBuilder::new()
153+
.with_url(authority)
154+
.build()
155+
.map_err(|e| PyValueError::new_err(build_err("http", uri, e)))?;
156+
Ok(Arc::new(store))
157+
}
158+
other => Err(PyValueError::new_err(format!(
159+
"unsupported Zarr URI scheme {other:?}; expected one of: \
160+
file, s3, gs, gcs, az, abfs, abfss, http, https"
161+
))),
162+
}
163+
}
164+
165+
fn build_err(backend: &str, uri: &str, err: object_store::Error) -> String {
166+
format!(
167+
"failed to build {backend} object_store for {uri}: {err}. \
168+
Provide credentials via standard environment variables \
169+
(AWS_ACCESS_KEY_ID/AWS_REGION for s3, GOOGLE_SERVICE_ACCOUNT_KEY \
170+
for gcs, AZURE_STORAGE_ACCOUNT_NAME/AZURE_STORAGE_ACCOUNT_KEY \
171+
for azure)."
172+
)
173+
}
174+
73175
#[pymodule]
74176
fn _lib(m: &Bound<'_, PyModule>) -> PyResult<()> {
75177
m.add_class::<PyZarrChunkReader>()?;

rust/sedona-raster-zarr/Cargo.toml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,26 @@ result_large_err = "allow"
3333
[dependencies]
3434
arrow-array = { workspace = true }
3535
arrow-schema = { workspace = true }
36+
async-trait = { workspace = true }
37+
datafusion = { workspace = true, default_features = false }
3638
datafusion-common = { workspace = true }
3739
futures = { workspace = true }
3840
log = { workspace = true }
41+
object_store = { workspace = true }
3942
sedona-common = { workspace = true }
4043
sedona-raster = { workspace = true }
4144
sedona-schema = { workspace = true }
4245
serde = { workspace = true }
4346
serde_json = { workspace = true }
44-
zarrs = { workspace = true, features = ["filesystem", "gzip", "zstd", "blosc", "crc32c", "sharding", "transpose"] }
45-
zarrs_filesystem = { workspace = true }
47+
tokio = { workspace = true }
48+
url = { workspace = true }
49+
zarrs = { workspace = true, features = ["async", "gzip", "zstd", "blosc", "crc32c", "sharding", "transpose"] }
50+
zarrs_object_store = { workspace = true }
4651

4752
[dev-dependencies]
53+
object_store = { workspace = true, features = ["aws", "gcp", "azure", "http"] }
4854
tempfile = { workspace = true }
4955
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
56+
# Test fixtures write Zarr groups to a temp dir; the loader itself
57+
# reads everything through object_store, so this is dev-only.
58+
zarrs_filesystem = { workspace = true }

rust/sedona-raster-zarr/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ mod loader;
3333
mod source_uri;
3434

3535
pub use loader::ZarrChunkReader;
36+
pub use source_uri::open_storage_from_uri;

0 commit comments

Comments
 (0)