Skip to content

Commit 6264653

Browse files
authored
feat(rust/sedona-raster-zarr): cloud storage backends (s3, gcs, azure, http) via object_store (#888)
1 parent 6ec3d02 commit 6264653

10 files changed

Lines changed: 517 additions & 179 deletions

File tree

Cargo.lock

Lines changed: 37 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", "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: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,32 @@
2020
//! Python-side `ZarrFormatSpec`.
2121
2222
use std::ffi::CString;
23-
use std::sync::Mutex;
23+
use std::sync::{Arc, Mutex, OnceLock};
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, Runtime};
32+
use url::Url;
33+
34+
/// Process-wide tokio runtime backing the sync Python FFI bridge.
35+
///
36+
/// `ZarrChunkReader::try_new` is async, but the Python constructor is sync
37+
/// by contract, so we `block_on` here. Building a runtime per reader is
38+
/// wasteful; one shared runtime serves every open in the package. `next()`
39+
/// on the returned reader is pure CPU and never touches this runtime.
40+
fn shared_runtime() -> &'static Runtime {
41+
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
42+
RUNTIME.get_or_init(|| {
43+
Builder::new_current_thread()
44+
.enable_all()
45+
.build()
46+
.expect("build sedonadb-zarr tokio runtime")
47+
})
48+
}
3049

3150
/// Single-use `__arrow_c_stream__` wrapper around `ZarrChunkReader`.
3251
#[pyclass]
@@ -39,7 +58,19 @@ impl PyZarrChunkReader {
3958
#[new]
4059
#[pyo3(signature = (uri, arrays=None, batch_size=8192))]
4160
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)
61+
let store =
62+
default_object_store_for_uri(uri).map_err(|e| PyValueError::new_err(e.to_string()))?;
63+
let storage =
64+
open_storage_from_uri(uri, store).map_err(|e| PyValueError::new_err(e.to_string()))?;
65+
// The crate's async-native loader exposes `try_new` as an
66+
// `async fn`; the Python FFI is a sync constructor by contract,
67+
// so we bridge by blocking on the shared package runtime.
68+
// `next()` on the returned reader is pure CPU and stays synchronous.
69+
let arrays_ref = arrays.as_deref();
70+
let reader = shared_runtime()
71+
.block_on(ZarrChunkReader::try_new(
72+
storage, uri, arrays_ref, batch_size,
73+
))
4374
.map_err(|e| PyValueError::new_err(e.to_string()))?;
4475
Ok(Self {
4576
inner: Mutex::new(Some(reader)),
@@ -70,6 +101,73 @@ impl PyZarrChunkReader {
70101
}
71102
}
72103

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

rust/sedona-raster-zarr/Cargo.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,21 @@ arrow-schema = { workspace = true }
3636
datafusion-common = { workspace = true }
3737
futures = { workspace = true }
3838
log = { workspace = true }
39+
object_store = { workspace = true }
3940
sedona-common = { workspace = true }
4041
sedona-raster = { workspace = true }
4142
sedona-schema = { workspace = true }
4243
serde = { workspace = true }
4344
serde_json = { workspace = true }
44-
zarrs = { workspace = true, features = ["filesystem", "gzip", "zstd", "blosc", "crc32c", "sharding", "transpose"] }
45-
zarrs_filesystem = { workspace = true }
45+
tokio = { workspace = true }
46+
url = { workspace = true }
47+
zarrs = { workspace = true, features = ["async", "gzip", "zstd", "blosc", "crc32c", "sharding", "transpose"] }
48+
zarrs_object_store = { workspace = true }
4649

4750
[dev-dependencies]
51+
object_store = { workspace = true, features = ["aws", "http"] }
4852
tempfile = { workspace = true }
4953
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
54+
# Test fixtures write Zarr groups to a temp dir; the loader itself
55+
# reads everything through object_store, so this is dev-only.
56+
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)