sunchao commented on code in PR #6031:
URL: https://github.com/apache/datafusion-comet/pull/6031#discussion_r4051461775
##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -735,11 +752,70 @@ type ObjectStoreCache =
RwLock<HashMap<ObjectStoreCacheKey, Arc<dyn ObjectStore>
/// (e.g. `fs.s3a.access.key` / `fs.s3a.secret.key`) produce a different
`config_hash` when
/// those values change, which causes a new store to be created and inserted
under the new
/// key; the old entry is harmlessly superseded.
+///
+/// ## Scope-aware entries (S3 SPI path)
+///
+/// When the S3 `CometS3CredentialProvider` implements the
+/// `CometS3ScopedCredentialProvider` sub-interface, its
`getPolicyLocationsFor` report is
+/// carried alongside the store as a `ScopeEntry`. Multiple entries can
coexist under one key
+/// so that distinct policy scopes on the same bucket each get their own
credentials without
+/// evicting each other. Base (non-scoped) providers land as a single entry
with empty
+/// `prefixes`, preserving the historical single-entry-per-bucket behavior.
The advisory
+/// nature of the scope hint is enforced by wrapping each SPI store in
+/// [`RetryOn403ObjectStore`], whose rebuild closure re-fires the SPI with the
failing path
+/// in context and *appends* a new scope entry to this cache — leaving any
pre-existing
+/// entries intact so disjoint scoped stores on the same bucket continue to
coexist. See
+/// `docs/source/contributor-guide/s3-credential-provider-design.md` for the
full contract.
fn object_store_cache() -> &'static ObjectStoreCache {
static CACHE: OnceLock<ObjectStoreCache> = OnceLock::new();
CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
+/// True when `path` is covered by `prefixes`. Empty `prefixes` is a catchall.
+fn path_covered(path: &str, prefixes: &[String]) -> bool {
+ if prefixes.is_empty() {
+ return true;
+ }
+ prefixes.iter().any(|p| path.starts_with(p.as_str()))
+}
+
+/// Length of the longest prefix in `prefixes` that covers `path`. Empty
`prefixes` is a
+/// catchall and returns 0 (so any covering scoped entry wins the tie-break);
returns `None`
+/// when nothing matches.
+fn longest_covering_prefix_len(path: &str, prefixes: &[String]) ->
Option<usize> {
+ if prefixes.is_empty() {
+ return Some(0);
+ }
+ prefixes
+ .iter()
+ .filter(|p| path.starts_with(p.as_str()))
+ .map(|p| p.len())
Review Comment:
### Correctness
[P2] Could both sides be canonicalized to the bucket-relative representation
required by `CometS3ScopedCredentialProvider` before this comparison?
`requested_path` is taken directly from `url.path()`, so it starts with `/`,
while the Java contract explicitly requires prefixes without a leading slash. A
conforming hint such as `warehouse/table` therefore never covers
`/warehouse/table/file`. With only that scoped entry present, the exact lookup
helper produced zero hits in 1,000 requests, causing a new store and HTTP pool
to be constructed on each preparation. The absolute S3 URI used by the new
Minio test also never matches. The comparison should enforce the documented
path-component boundary too, so `table` cannot match `table_extra`. Please
exercise the Java contract's prefix format through the native cache.
##########
native/core/src/parquet/objectstore/retry.rs:
##########
@@ -0,0 +1,628 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Correctness safety net for the scope-hint SPI.
+//!
+//! # Why
+//!
+//! `CometS3ScopedCredentialProvider::getPolicyLocationsFor` is *advisory*:
vendors are
+//! encouraged to report narrower scopes than the policy grants, so a request
outside the
+//! reported scope can still legitimately fail with 403 at S3. The scope hint
is a latency
+//! optimization that lets Comet share a bridge across paths inside one scope,
saving one
+//! JVM round-trip per get. S3 itself remains authoritative.
+//!
+//! This wrapper is the mechanism that keeps that split honest: on a 403 from
a cached
+//! (scope-bound) store, we invoke a caller-provided rebuild closure exactly
once, passing
+//! it the `Path` of the request that failed. The rebuild constructs a fresh
bridge bound
+//! to that path, re-fires the SPI against it (so the vendor answers
`getPolicyLocationsFor`
+//! *for the actual failing request*), and appends the resulting scope entry
to the outer
+//! `object_store` registry cache — alongside any pre-existing entries, not
replacing them.
+//! We then retry the same operation against the rebuilt store; a second 403
propagates
+//! unchanged. Because the new entry is inserted with the vendor's fresh
(narrower) scope
+//! rather than an all-covering catchall, disjoint scoped stores on the same
bucket can
+//! continue to coexist after a 403 recovery.
+//!
+//! # Non-goals
+//!
+//! - Not a generic retry policy. Only `Error::PermissionDenied` (403) is
intercepted; every
+//! other error (including 401/`Unauthenticated`) passes through as-is.
+//! - Not an infinite retry. Exactly one rebuild per wrapper instance, exactly
one retry per
+//! operation.
+//! - Not a stream-level retry. `list`, `list_with_offset`, and
`delete_stream` return
+//! `BoxStream`s whose per-item errors are surfaced as-is; wrapping them
would require
+//! materializing the stream. Comet's parquet path first hits 403 at
`get_opts`/`get_ranges`
+//! which are covered; the rebuild there populates the shared cache and
subsequent stream
+//! requests use the newly-inserted scope entry.
+//! - Not applied to `put_multipart_opts`: a partial multipart upload cannot
be transparently
+//! retried, so a 403 mid-upload propagates for the caller to handle.
+
+use std::fmt;
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use futures::stream::BoxStream;
+use log::debug;
+use object_store::path::Path;
+use object_store::{
+ CopyOptions, Error, GetOptions, GetResult, ListResult, MultipartUpload,
ObjectMeta,
+ ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
RenameOptions, Result,
+};
+use once_cell::sync::OnceCell;
+
+/// Rebuild function contract: construct a fresh backing `ObjectStore` scoped
for the failing
+/// request and register it in the outer `object_store` registry cache. Called
at most once
+/// per wrapper.
+///
+/// The `Option<&Path>` argument is the location that triggered the 403
(source path for
+/// copy/rename). The rebuild closure passes this into the fresh bridge so the
SPI's
+/// `getPolicyLocationsFor` reflects the actual failing request rather than
the path baked in
+/// at construction time. `None` is reserved for callers that intercept a 403
without a path
+/// (none of the current retry sites) and lets the closure fall back to its
pre-baked path.
+pub type RebuildFn =
+ Arc<dyn Fn(Option<&Path>) -> Result<Arc<dyn ObjectStore>> + Send + Sync +
'static>;
+
+/// Wraps an `Arc<dyn ObjectStore>` so a single 403 rebuilds the store once
and retries.
+///
+/// See the module-level doc-comment for rationale and non-goals.
+pub struct RetryOn403ObjectStore {
+ inner: Arc<dyn ObjectStore>,
+ rebuild: RebuildFn,
+ /// Populated on the first 403 we successfully recover from. Cached so a
subsequent
+ /// request against this same wrapper skips straight to the rebuilt store,
and a 403 there
+ /// is treated as authoritative.
+ rebuilt: OnceCell<Arc<dyn ObjectStore>>,
+}
+
+impl RetryOn403ObjectStore {
+ pub fn new(inner: Arc<dyn ObjectStore>, rebuild: RebuildFn) -> Self {
+ Self {
+ inner,
+ rebuild,
+ rebuilt: OnceCell::new(),
+ }
+ }
+
+ /// Return the store that should service *this* request: the post-rebuild
store when we
+ /// have one, else the original.
+ fn current(&self) -> Arc<dyn ObjectStore> {
+ self.rebuilt
+ .get()
+ .cloned()
+ .unwrap_or_else(|| Arc::clone(&self.inner))
Review Comment:
### Correctness
[P2] Could the rebuilt store be selected by request path without permanently
replacing the backing store for every operation on this wrapper? The planner
constructs one store from the first file and uses it for all files in a Parquet
partition. With an A-only initial store and a B-only rebuilt store, reading A1,
B1, then A2 returns success, success, then 403. The original store still
authorizes A2, but `current()` now routes it to B and `already_rebuilt()`
suppresses recovery. Appending a new scope entry does not repair the original
cache entry or scans already holding this wrapper. I reproduced that sequence
using these exact methods and synthetic scoped stores. Please retain
path-specific routing and bound retries per operation, with A/B/A and
three-disjoint-scope coverage.
##########
native/core/src/parquet/objectstore/retry.rs:
##########
@@ -0,0 +1,628 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Correctness safety net for the scope-hint SPI.
+//!
+//! # Why
+//!
+//! `CometS3ScopedCredentialProvider::getPolicyLocationsFor` is *advisory*:
vendors are
+//! encouraged to report narrower scopes than the policy grants, so a request
outside the
+//! reported scope can still legitimately fail with 403 at S3. The scope hint
is a latency
+//! optimization that lets Comet share a bridge across paths inside one scope,
saving one
+//! JVM round-trip per get. S3 itself remains authoritative.
+//!
+//! This wrapper is the mechanism that keeps that split honest: on a 403 from
a cached
+//! (scope-bound) store, we invoke a caller-provided rebuild closure exactly
once, passing
+//! it the `Path` of the request that failed. The rebuild constructs a fresh
bridge bound
+//! to that path, re-fires the SPI against it (so the vendor answers
`getPolicyLocationsFor`
+//! *for the actual failing request*), and appends the resulting scope entry
to the outer
+//! `object_store` registry cache — alongside any pre-existing entries, not
replacing them.
+//! We then retry the same operation against the rebuilt store; a second 403
propagates
+//! unchanged. Because the new entry is inserted with the vendor's fresh
(narrower) scope
+//! rather than an all-covering catchall, disjoint scoped stores on the same
bucket can
+//! continue to coexist after a 403 recovery.
+//!
+//! # Non-goals
+//!
+//! - Not a generic retry policy. Only `Error::PermissionDenied` (403) is
intercepted; every
+//! other error (including 401/`Unauthenticated`) passes through as-is.
+//! - Not an infinite retry. Exactly one rebuild per wrapper instance, exactly
one retry per
+//! operation.
+//! - Not a stream-level retry. `list`, `list_with_offset`, and
`delete_stream` return
+//! `BoxStream`s whose per-item errors are surfaced as-is; wrapping them
would require
+//! materializing the stream. Comet's parquet path first hits 403 at
`get_opts`/`get_ranges`
+//! which are covered; the rebuild there populates the shared cache and
subsequent stream
+//! requests use the newly-inserted scope entry.
+//! - Not applied to `put_multipart_opts`: a partial multipart upload cannot
be transparently
+//! retried, so a 403 mid-upload propagates for the caller to handle.
+
+use std::fmt;
+use std::ops::Range;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use bytes::Bytes;
+use futures::stream::BoxStream;
+use log::debug;
+use object_store::path::Path;
+use object_store::{
+ CopyOptions, Error, GetOptions, GetResult, ListResult, MultipartUpload,
ObjectMeta,
+ ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
RenameOptions, Result,
+};
+use once_cell::sync::OnceCell;
+
+/// Rebuild function contract: construct a fresh backing `ObjectStore` scoped
for the failing
+/// request and register it in the outer `object_store` registry cache. Called
at most once
+/// per wrapper.
+///
+/// The `Option<&Path>` argument is the location that triggered the 403
(source path for
+/// copy/rename). The rebuild closure passes this into the fresh bridge so the
SPI's
+/// `getPolicyLocationsFor` reflects the actual failing request rather than
the path baked in
+/// at construction time. `None` is reserved for callers that intercept a 403
without a path
+/// (none of the current retry sites) and lets the closure fall back to its
pre-baked path.
+pub type RebuildFn =
+ Arc<dyn Fn(Option<&Path>) -> Result<Arc<dyn ObjectStore>> + Send + Sync +
'static>;
+
+/// Wraps an `Arc<dyn ObjectStore>` so a single 403 rebuilds the store once
and retries.
+///
+/// See the module-level doc-comment for rationale and non-goals.
+pub struct RetryOn403ObjectStore {
+ inner: Arc<dyn ObjectStore>,
+ rebuild: RebuildFn,
+ /// Populated on the first 403 we successfully recover from. Cached so a
subsequent
+ /// request against this same wrapper skips straight to the rebuilt store,
and a 403 there
+ /// is treated as authoritative.
+ rebuilt: OnceCell<Arc<dyn ObjectStore>>,
+}
+
+impl RetryOn403ObjectStore {
+ pub fn new(inner: Arc<dyn ObjectStore>, rebuild: RebuildFn) -> Self {
+ Self {
+ inner,
+ rebuild,
+ rebuilt: OnceCell::new(),
+ }
+ }
+
+ /// Return the store that should service *this* request: the post-rebuild
store when we
+ /// have one, else the original.
+ fn current(&self) -> Arc<dyn ObjectStore> {
+ self.rebuilt
+ .get()
+ .cloned()
+ .unwrap_or_else(|| Arc::clone(&self.inner))
+ }
+
+ /// Whether we have already spent our single rebuild allowance.
+ fn already_rebuilt(&self) -> bool {
+ self.rebuilt.get().is_some()
+ }
+
+ /// Attempt to install a rebuilt store, calling `rebuild` at most once.
Concurrent 403s
+ /// race harmlessly here — `once_cell::sync::OnceCell` guarantees only one
initializer
+ /// runs; the rest observe the same result. The `path` of the request that
first triggered
+ /// the rebuild is threaded into the closure so it can request a scope for
the actual
+ /// failing location.
+ fn rebuild_once(&self, path: Option<&Path>) -> Result<Arc<dyn
ObjectStore>> {
+ self.rebuilt
+ .get_or_try_init(|| (self.rebuild)(path))
+ .map(Arc::clone)
+ }
+}
+
+/// Only `PermissionDenied` (S3 403) triggers the retry. `Unauthenticated`
(401) is treated as
+/// a permanent credential-config error and not retried.
+fn is_forbidden(err: &Error) -> bool {
+ matches!(err, Error::PermissionDenied { .. })
+}
+
+impl fmt::Debug for RetryOn403ObjectStore {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("RetryOn403ObjectStore")
+ .field("inner", &self.inner)
+ .field("rebuilt_cached", &self.rebuilt.get().is_some())
+ .finish()
+ }
+}
+
+impl fmt::Display for RetryOn403ObjectStore {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "RetryOn403({})", self.inner)
+ }
+}
+
+#[async_trait]
+impl ObjectStore for RetryOn403ObjectStore {
+ async fn put_opts(
+ &self,
+ location: &Path,
+ payload: PutPayload,
+ opts: PutOptions,
+ ) -> Result<PutResult> {
+ let store = self.current();
+ match store.put_opts(location, payload.clone(), opts.clone()).await {
+ Err(e) if is_forbidden(&e) && !self.already_rebuilt() => {
+ debug!("RetryOn403: 403 on put({location}); rebuilding store");
+ let rebuilt = self.rebuild_once(Some(location))?;
+ rebuilt.put_opts(location, payload, opts).await
+ }
+ other => other,
+ }
+ }
+
+ async fn put_multipart_opts(
+ &self,
+ location: &Path,
+ opts: PutMultipartOptions,
+ ) -> Result<Box<dyn MultipartUpload>> {
+ // Multipart uploads can't be transparently retried mid-flight;
propagate 403 as-is.
+ self.current().put_multipart_opts(location, opts).await
+ }
+
+ async fn get_opts(&self, location: &Path, options: GetOptions) ->
Result<GetResult> {
+ let store = self.current();
+ match store.get_opts(location, options.clone()).await {
+ Err(e) if is_forbidden(&e) && !self.already_rebuilt() => {
+ debug!("RetryOn403: 403 on get({location}); rebuilding store");
+ let rebuilt = self.rebuild_once(Some(location))?;
Review Comment:
### Correctness
[P2] Could this decision distinguish a 403 from the original store from a
403 returned by the rebuilt store? Two requests can capture the original store
before either completes. If the first finishes, rebuilds successfully, and
retries, the second then observes `already_rebuilt() == true` and returns its
original 403 without trying the fresh store. A deterministic interleaving of
these exact methods reproduced one successful request and one failed request,
even though a later call for the failed path succeeds on the rebuilt store.
`OnceCell` prevents duplicate construction but does not make this guard safe.
Please let each operation retry once against the published replacement and
cover the same race in `get_ranges`.
##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -832,18 +939,96 @@ pub(crate) fn prepare_object_store_with_configs(
let (store, path): (Box<dyn ObjectStore>, Path) = if
is_hdfs_scheme {
create_hdfs_object_store(&url)
} else if scheme == "s3" {
- objectstore::s3::create_store(&url, object_store_configs,
Duration::from_secs(300))
+ objectstore::s3::create_store_with_bridge(
+ &url,
+ object_store_configs,
+ bridge_opt.clone(),
+ Duration::from_secs(300),
+ )
} else if is_azure_scheme(scheme) {
objectstore::azure::create_store(&url, object_store_configs)
} else {
parse_url(&url)
}
.map_err(|e| ExecutionError::GeneralError(e.to_string()))?;
- let store: Arc<dyn ObjectStore> = Arc::from(store);
- // Insert into cache
+ let raw_store: Arc<dyn ObjectStore> = Arc::from(store);
+
+ // Wrap SPI-backed stores in the 403-retry safety net. Non-SPI
paths keep their
+ // plain store so we don't add overhead for the base credential
chain (which is
+ // already correct without the wrapper).
+ let store: Arc<dyn ObjectStore> = if bridge_opt.is_some() {
+ let cache_key_for_rebuild = cache_key.clone();
+ let url_for_rebuild = url.clone();
+ let configs_for_rebuild: HashMap<String, String> =
object_store_configs.clone();
+ let rebuild: RebuildFn = Arc::new(move |failing_path:
Option<&Path>| {
+ // Fresh bridge → fresh SPI dispatch → fresh credentials
scoped for the
+ // actual failing request. We rebind the bridge's baked-in
path to the
+ // 403'd location (falling back to the URL's path when the
retry site did
+ // not supply one) so `fetch_policy_locations` returns the
vendor's scope
+ // for *this* request rather than whatever the initial
construction picked.
+ let failing_path_str = failing_path.map(|p|
format!("/{p}"));
+ let rebuilt_bridge =
objectstore::s3::try_construct_bridge_with_path(
+ &url_for_rebuild,
+ &configs_for_rebuild,
+ failing_path_str.as_deref(),
+ )
+ .map_err(|e| object_store::Error::Generic {
+ store: "S3",
+ source: format!("rebuild bridge failed: {e}").into(),
+ })?;
+ // Query the vendor for the fresh session's scope hint.
Errors normalize
+ // to an empty prefix list (catchall for the new entry
only) — matching
+ // the fallback the initial builder uses on the same call.
+ let fresh_prefixes = if let Some(bridge) =
rebuilt_bridge.as_ref() {
+ bridge.fetch_policy_locations().unwrap_or_else(|e| {
+ debug!("fetch_policy_locations on rebuild failed:
{e}");
+ Vec::new()
+ })
+ } else {
+ Vec::new()
+ };
+ let (rebuilt_raw, _path) =
objectstore::s3::create_store_with_bridge(
+ &url_for_rebuild,
+ &configs_for_rebuild,
+ rebuilt_bridge,
+ Duration::from_secs(300),
+ )?;
Review Comment:
### Correctness
[P2] Could recovery reuse the resolved region or perform the rebuild without
synchronously entering Tokio? This closure is invoked inside the async
`get_opts`/`get_ranges` operation. For an ordinary AWS bucket with neither
`fs.s3a.endpoint` nor `fs.s3a.endpoint.region` configured,
`create_store_with_bridge` calls
`get_runtime().block_on(resolve_bucket_region(bucket))`. Tokio rejects that
nested runtime entry with a panic, including when the region cache would return
immediately. I verified the latter with the locked Tokio 1.53.1 and a ready
cached-region future, without making an AWS request. Thus the first recoverable
403 can abort the scan instead of retrying. Please cover recovery without an
explicit endpoint or region.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]