andygrove commented on code in PR #5314:
URL: https://github.com/apache/datafusion-comet/pull/5314#discussion_r3897661236
##########
spark/src/main/java/org/apache/comet/parquet/CometFileKeyUnwrapper.java:
##########
@@ -92,6 +93,12 @@
*/
public class CometFileKeyUnwrapper {
+ // S3-family schemes that all address the same logical filesystem and must
fold to a single
+ // canonical cache key. Kept in sync with the native alias handling
+ // (native/core/src/parquet/objectstore/s3_blob_fs_support.rs and the
s3a/s3n aliases object_store
+ // recognizes). The `:` disambiguates them, so order is irrelevant.
+ private static final String[] S3_ALIAS_SCHEMES = {"s3", "s3n", "s3a",
"blob"};
Review Comment:
This list is hardcoded to `blob`, but the scheme set is configurable and the
docs suggest `blob,minio,r2`. If someone sets
`fs.comet.s3Compliant.schemes=minio` and reads encrypted Parquet, I think the
read fails.
The put side caches under `minio://bucket/key`, since `CometExecIterator`
passes `relation.inputFiles` straight through and nothing here matches. The get
side arrives as `s3://bucket/key`, because `parquet_exec.rs` sets `uri_base`
from the already-normalized `ObjectStoreUrl`, and that folds to
`s3a://bucket/key`. The two keys never meet, so it comes out as `Failed to find
DecryptionKeyRetriever`.
`storeDecryptionKeyRetriever` already receives the `Configuration` and
already caches it in the `conf` field, so the configured set is available at
exactly the right moment. Could the alias list be read from
`fs.comet.s3Compliant.schemes` there and cached for the get side, instead of
the static array? `TestCometFileKeyUnwrapper` only covers `blob` today, so a
`minio://` case alongside it would keep this from drifting again.
##########
native/core/src/execution/operators/iceberg_scan.rs:
##########
@@ -255,6 +255,9 @@ impl IcebergScanExec {
};
match scheme {
"file" => Ok(Arc::new(OpenDalStorageFactory::Fs)),
+ // blob:// never reaches here: the planner normalizes it to s3://
via
Review Comment:
Does the Iceberg path need the rewrite at all?
The S3 backend derives both the bucket and the key prefix from the path's
own scheme: `s3_config_build` takes the bucket from `Url::host_str` without
checking the scheme, and `create_operator` builds `format!("{}://{}/",
url.scheme(), op_info.name())`. That is exactly why a raw `blob://` data file
path works, as the comment on `data_file_path` says. Which means the only
reason `metadata_location` gets normalized upstream is to land in this match
arm. As far as I can tell it is never opened, only used here, in
`build_s3_credential_loader` for the host and path, and in the `is_s3_family`
region check below.
If this arm matched configured aliases directly, metadata, delete files and
data files could all stay raw, and `normalize_object_store_url_string` could go
away entirely, taking both of its invariants with it, along with the asymmetry
where the data file path must stay raw but the delete file's own location must
not. That asymmetry is the part I would most like to see shrink, because it is
exactly the kind of thing a future change breaks silently: get it wrong and
deleted rows come back, with no error.
The one thing you would give up is the single-slash `blob:/bucket/key` form,
which the tests already describe as defensive rather than a shape Iceberg
emits. `is_s3_family` below would need to consult the alias set too. Am I
missing a reason the rewrite has to happen upstream in the planner?
##########
docs/source/user-guide/latest/datasources.md:
##########
@@ -216,6 +216,62 @@ Beyond credential providers, Comet's Parquet scan supports
additional S3 configu
All configuration options support bucket-specific overrides using the pattern
`fs.s3a.bucket.{bucket-name}.{option}`.
+### S3-Compliant Filesystem Schemes
+
+Some environments front an S3-compatible service (MinIO, Ceph RGW, Cloudflare
R2, Wasabi, and
+similar) with a vendor-branded Hadoop filesystem client that registers its own
URL scheme, for
+example `blob://`, instead of `s3://` or `s3a://`. Comet can treat such
schemes as aliases for
+`s3://` so the native Parquet and Iceberg scans read them directly, without
the caller rewriting
+URLs.
+
+This is opt-in and disabled by default. Enable it by listing the schemes to
treat as S3-compliant
+aliases in `spark.hadoop.fs.comet.s3Compliant.schemes` (Hadoop key
+`fs.comet.s3Compliant.schemes`), a comma-separated, case-insensitive list.
This mirrors the
+existing `fs.comet.libhdfs.schemes` config.
+
+```shell
+--conf spark.hadoop.fs.comet.s3Compliant.schemes=blob
+```
+
+Multiple schemes can be listed together:
+
+```shell
+--conf spark.hadoop.fs.comet.s3Compliant.schemes=blob,minio,r2
+```
+
+With no configuration, Comet claims none of these aliases, so for example a
`blob://` path falls
+back to Spark unchanged. The empty default is deliberate: short scheme names
like `blob` are not
+unique to S3-compatible storage. Azure Blob Storage is the clearest example,
so claiming `blob://`
+unconditionally would risk misrouting paths that were never meant for Comet's
S3 client. Only add a
+scheme here if you intend Comet to treat it as S3-compatible.
+
+For each scheme `<s>` listed in `fs.comet.s3Compliant.schemes`, Comet also
reads vendor-style,
+per-authority Hadoop keys of the form `fs.<s>.<authority>.<property>` (the
authority is typically
+the bucket or account name from the URL) and translates them into the
`fs.s3a.*` surface described
+above. The recognized vendor-style properties and their `fs.s3a.*` targets are:
+
+| Vendor property (`fs.<s>.<authority>.<property>`) | Translated `fs.s3a.*`
suffix |
+| ------------------------------------------------- |
---------------------------- |
+| `awsAccessKeyId` | `access.key`
|
+| `awsSecretAccessKey` | `secret.key`
|
+| `awsSessionToken` | `session.token`
|
+| `endpoint` | `endpoint`
|
+| `region` | `endpoint.region`
|
+| `pathStyleAccess` | `path.style.access`
|
+
+Unrecognized `fs.<s>.<authority>.*` properties are ignored.
Review Comment:
The `fs.<s>.default.<property>` case is implemented and tested, where an
authority of literally `default` resolves to the bucket taken from the URL
path, but it is not mentioned here. Since that is the form an authorityless
`blob:///bucket/key` URI produces, it seems worth a sentence so users know the
key exists.
##########
spark/src/main/scala/org/apache/comet/CometConf.scala:
##########
@@ -878,6 +878,19 @@ object CometConf extends ShimCometConf {
.stringConf
.createOptional
+ val COMET_S3_COMPLIANT_SCHEMES_KEY = "fs.comet.s3Compliant.schemes"
+
+ val COMET_S3_COMPLIANT_SCHEMES: OptionalConfigEntry[String] =
Review Comment:
This entry is never read anywhere. Every consumer goes through
`hadoopConf.get(COMET_S3_COMPLIANT_SCHEMES_KEY)` instead, which is different
from `COMET_LIBHDFS_SCHEMES`, where `CometScanRule` reads the entry with
`.get()`.
The practical consequence is that
`spark.conf.set("spark.hadoop.fs.comet.s3Compliant.schemes", ...)` after the
session has started is silently ignored: `SessionState.newHadoopConf` copies
SQLConf keys with the `spark.hadoop.` prefix still attached, so
`fs.comet.s3Compliant.schemes` is never set in the Hadoop conf. Only SparkConf
at startup or core-site.xml works, which is fine but not what a user would
guess from seeing it in configs.md.
Could this either read the entry the way the libhdfs gate does, or say in
the doc string that it has to be set before the session starts?
##########
spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala:
##########
@@ -654,24 +667,36 @@ case class CometScanRule(session: SparkSession)
false
}
+ // Opt-in S3-compliant alias schemes (e.g. `blob`) from
`fs.comet.s3Compliant.schemes`,
+ // resolved once for this scan (used by the file scheme gate below and
its fallback
+ // message).
+ val icebergS3CompliantSchemes =
+
CometScanRule.resolveS3CompliantSchemes(session.sessionState.newHadoopConf())
Review Comment:
Minor: this builds a fresh `Configuration` per Iceberg scan node just to
read one key, and `transformV2Scan` already builds another one at line 464
inside the `metadataOpt` block. Could one be hoisted to the top of the method
and shared?
##########
spark/src/main/scala/org/apache/comet/objectstore/NativeConfig.scala:
##########
@@ -42,43 +44,144 @@ object NativeConfig {
"abfs" -> Seq("fs.azure.", "fs.abfs."),
"abfss" -> Seq("fs.azure.", "fs.abfss.", "fs.abfs."))
+ // Some alias filesystems report the literal authority "default" when the
URI has none (e.g.
+ // `scheme:///bucket/key`); the real bucket is then promoted from the URL
path. Keys under this
+ // authority map to the per-bucket `fs.s3a.bucket.<resolved-bucket>.*`
scope, NOT global
+ // `fs.s3a.*`, because native `get_config` checks per-bucket before global.
+ private val vendorDefaultAuthority = "default"
+
+ // Recognized vendor properties -> `fs.s3a` suffix. Mirrors
CometIcebergNativeScan's target set.
+ // Unknown properties are dropped.
+ private val vendorPropertyToS3aSuffix = Map(
+ "awsAccessKeyId" -> "access.key",
+ "awsSecretAccessKey" -> "secret.key",
+ "awsSessionToken" -> "session.token",
+ "endpoint" -> "endpoint",
+ "region" -> "endpoint.region",
+ "pathStyleAccess" -> "path.style.access")
+
+ // Comma-separated scheme list -> trimmed, lowercased set
(case-insensitive). Shared with
+ // CometScanRule's scheme gate so the JVM admit-decision and native rewrite
parse identically.
+ private[comet] def parseSchemeSet(raw: String): Set[String] =
+ Option(raw)
+
.map(_.split(",").iterator.map(_.trim.toLowerCase(Locale.ROOT)).filter(_.nonEmpty).toSet)
+ .getOrElse(Set.empty)
+
+ // True when the user pinned path-style at this bucket scope. Suppresses the
synthesized soft
+ // default so an explicit per-bucket setting (including `false`, the escape
hatch) survives. A
+ // global `fs.s3a.path.style.access` is intentionally ignored: it may target
other s3a workloads
+ // or be an ambient cluster default. Per-bucket synth wins over global in
native anyway.
+ private def userSetPathStyle(hadoopConf: Configuration, bucket:
Option[String]): Boolean =
+ bucket.exists(b => hadoopConf.get(s"fs.s3a.bucket.$b.path.style.access")
!= null)
+
+ /**
+ * The S3 bucket a URI addresses: its authority, or -- for the authorityless
+ * `blob:///bucket/key` form -- the first path segment (matching the native
rewrite that
+ * promotes it into the host). None when neither is present (e.g. a
schemeless root path).
+ */
+ def bucketForUri(uri: URI): Option[String] = {
Review Comment:
For a local Hadoop catalog this returns something odd:
`file:///tmp/warehouse/db/t/metadata/v1.metadata.json` has no authority, so the
first path segment wins and the bucket comes back as `Some("tmp")`. Every
local-catalog Iceberg table then calls `hadoopToIcebergS3Properties` with a
target bucket of `tmp`.
Harmless today, since nobody has `fs.s3a.bucket.tmp.*` configured, but it is
a surprising value to hand to a function whose entire job is choosing which
bucket's credentials get promoted to global. Worth returning `None` when the
scheme is not S3-family?
--
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]