andygrove commented on code in PR #6031:
URL: https://github.com/apache/datafusion-comet/pull/6031#discussion_r4053635741


##########
native/core/src/cloud/s3/credential_bridge.rs:
##########
@@ -358,3 +390,66 @@ fn read_optional_string(
         .map(Some)
         .map_err(|e| ExecutionError::GeneralError(format!("try_to_string: 
{e}")))
 }
+
+/// Decode a `java.util.List<String>` into `Vec<String>`, treating null list, 
null elements, and
+/// non-string elements as end-of-list / skip respectively. Only called from
+/// [`CometS3CredentialBridge::fetch_policy_locations`] where the JVM 
dispatcher normalizes null
+/// returns to `Collections.emptyList()`, but the extra null-check keeps the 
helper self-contained.
+fn read_java_string_list(

Review Comment:
   Every other JNI call in this file goes through `jni_static_call!`, which 
runs `check_exception` for you. This helper calls `call_method_unchecked` 
directly, so if a vendor hands back a lazily evaluated `List` that throws from 
`size()` or `get(i)`, the exception stays pending and the next JNI call on that 
thread misbehaves. Could it use the checked path instead?
   
   Separately, line 448 casts each element straight through `JString::from_raw` 
with no type check, while the doc comment above says non-string elements are 
skipped. Worth making the code match the comment, since a vendor returning a 
`List<Object>` would currently be undefined behavior rather than a skipped 
element.



##########
spark/src/test/scala/org/apache/comet/CometPublicApiSuite.scala:
##########
@@ -44,7 +44,8 @@ class CometPublicApiSuite extends AnyFunSuite {
     "org.apache.comet.cloud.s3.CometS3AccessMode",
     "org.apache.comet.cloud.s3.CometS3CredentialContext",
     "org.apache.comet.cloud.s3.CometS3CredentialProvider",
-    "org.apache.comet.cloud.s3.CometS3Credentials")
+    "org.apache.comet.cloud.s3.CometS3Credentials",
+    "org.apache.comet.cloud.s3.CometS3ScopedCredentialProvider")

Review Comment:
   `docs/source/about/versioning_policy.md` enumerates the public API as well, 
and it still lists only the four pre-existing types. The comment at the top of 
this suite asks for the doc and this list to change together, so could 
`CometS3ScopedCredentialProvider` be added there too? It would also help to say 
what adding a method to the sub-interface later would mean for compatibility, 
since the existing text covers that question for `CometS3CredentialProvider`.



##########
spark/src/test/scala/org/apache/comet/cloud/s3/CometS3CredentialBridgeSuite.scala:
##########
@@ -229,4 +229,86 @@ class CometS3CredentialBridgeSuite
       spark.sql("DROP TABLE iso_b.db.t")
     }
   }
+
+  // ---------------------------------------------------------------------
+  // Scope-aware object_store cache scenarios
+  //
+  // The Rust unit tests in native/core/src/parquet/objectstore/retry.rs cover 
the retry-on-403
+  // wrapper end-to-end (pass-through, rebuild-then-retry, second-403 
propagation, idempotency,
+  // error surface, 401-not-retried, Send+Sync, Arc<Mutex> composition). Minio 
does not enforce
+  // per-prefix denial out of the box, so the full 
"overreport-then-403-recover" path is proven
+  // in Rust rather than here. The two IT scenarios below exercise the 
Java/JNI + native-cache
+  // plumbing: the scope hint reaches Rust and the cache preserves scope 
granularity.
+  // ---------------------------------------------------------------------
+
+  test("scoped provider: two reads inside the same scope share one 
object_store entry") {

Review Comment:
   Beyond the URI-shaped hint and the callback assertion sunchao flagged, 
there's a third thing working against this test. The native `object_store` 
cache is a process-wide static and `MinioCometS3CredentialProvider` defaults to 
an empty hint, so the earlier tests in this suite have already seeded a 
catchall entry under the same bucket key. `find_matching_scope` matches that at 
effective length 0, so the scoped entry never gets exercised even once the 
prefix format is fixed.
   
   Would using a bucket the other tests don't touch be enough here, or is there 
a way to reset the native cache between tests?



##########
docs/source/user-guide/latest/s3-credential-providers.md:
##########
@@ -201,6 +201,33 @@ public CometS3Credentials 
getCredentialsForPath(CometS3CredentialContext ctx) th
 }
 ```
 
+### Scope hints via `CometS3ScopedCredentialProvider`
+
+The base `CometS3CredentialProvider` gives Comet one credential per (bucket, 
path) call. Vendors whose credentials are inherently *scoped* — a single STS 
session covers `s3://bucket/prefix-A/**` but not `s3://bucket/prefix-B/**` — 
can implement the opt-in sub-interface `CometS3ScopedCredentialProvider` to let 
Comet keep multiple scoped stores side-by-side in a single bucket instead of 
caching a single session and 403-ing when it is asked to serve a path outside 
its scope.
+
+```java
+package org.apache.comet.cloud.s3;
+
+public interface CometS3ScopedCredentialProvider extends 
CometS3CredentialProvider {
+    /**
+     * Advisory list of path prefixes (or absolute s3://... URIs) the 
credential
+     * returned by {@link #getCredentialsForPath(CometS3CredentialContext)} for
+     * the same {@code context} is valid against.  An empty list means 
"unknown /
+     * catchall"; Comet then falls back to the pre-existing 
single-entry-per-bucket
+     * behavior.
+     */
+    java.util.List<String> getPolicyLocationsFor(CometS3CredentialContext 
context);
+}
+```
+
+Comet uses the hint to key the native `object_store` registry as `(bucket, 
config_hash, backend, scope_prefixes)` instead of the base three-tuple, so two 
prefixes with disjoint scopes each get their own store. **The hint is 
advisory.** S3 itself is authoritative: if the vendor overreports a scope and 
S3 returns 403 anyway, the native cache transparently rebuilds the store under 
a widened (catchall) scope, retries the operation once, and continues. A second 
403 propagates to Spark as a real error.

Review Comment:
   Two things on this paragraph. The registry isn't keyed as `(bucket, 
config_hash, backend, scope_prefixes)` -- the design doc has a whole section 
explaining why that shape was rejected, and the code keeps the three-tuple with 
the prefixes hanging off the entry. And the rebuild doesn't widen to a 
catchall; it re-fires the SPI and uses whatever prefixes come back.
   
   Together with the point sunchao raised about base-interface providers being 
wrapped despite this page saying their retry path stays inactive, that's three 
claims on this page that need reconciling with the final behavior. The backward 
compatibility promise rests on the third one, so I'd like that one to be 
accurate in particular.



##########
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),
+                    )?;
+                    let rebuilt: Arc<dyn ObjectStore> = Arc::from(rebuilt_raw);
+                    // Append the new scope entry alongside any pre-existing 
ones. The old
+                    // entry stays: the vendor may still legitimately serve 
its original scope
+                    // even though the failing path fell outside it. 
Longest-prefix match on
+                    // the lookup side ensures each request routes to the 
narrowest applicable
+                    // session.
+                    if let Ok(mut cache) = object_store_cache().write() {
+                        let entries = 
cache.entry(cache_key_for_rebuild.clone()).or_default();
+                        // Deduplicate: a concurrent rebuild may have already 
inserted an
+                        // entry with the same prefixes.
+                        if !entries.iter().any(|e| e.prefixes == 
fresh_prefixes) {

Review Comment:
   This dedup check means the append that the whole recovery story depends on 
usually doesn't happen. In the overreporting case the vendor returns the same 
over-broad prefix on the rebuild as it did on the initial fetch, so 
`fresh_prefixes` equals the existing entry's prefixes, the check fires, and 
nothing gets pushed. The wrapper is left silently rebound to the new store with 
no entry recording it, which is the state sunchao describes on the retry 
wrapper. Should the dedup key on something that tells the two sessions apart 
rather than on the prefix list?



-- 
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]

Reply via email to