This is an automated email from the ASF dual-hosted git repository.
zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new 83711d98e5 [VL][Iceberg] Fallback Iceberg read if vended credentials
are present (#13008)
83711d98e5 is described below
commit 83711d98e56cbf597a831de1080634731040f9e6
Author: inf / Hazmi <[email protected]>
AuthorDate: Fri Oct 9 17:18:59 2026 +0300
[VL][Iceberg] Fallback Iceberg read if vended credentials are present
(#13008)
---
.../gluten/execution/VeloxIcebergSuite.scala | 140 ++++++++++++++++++++-
docs/get-started/VeloxIceberg.md | 5 +-
.../gluten/execution/IcebergScanTransformer.scala | 5 +
.../spark/source/GlutenIcebergSourceUtil.scala | 31 +++++
4 files changed, 178 insertions(+), 3 deletions(-)
diff --git
a/backends-velox/src-iceberg/test/scala/org/apache/gluten/execution/VeloxIcebergSuite.scala
b/backends-velox/src-iceberg/test/scala/org/apache/gluten/execution/VeloxIcebergSuite.scala
index 10617a6f07..5bec8136d7 100644
---
a/backends-velox/src-iceberg/test/scala/org/apache/gluten/execution/VeloxIcebergSuite.scala
+++
b/backends-velox/src-iceberg/test/scala/org/apache/gluten/execution/VeloxIcebergSuite.scala
@@ -17,16 +17,152 @@
package org.apache.gluten.execution
import org.apache.gluten.config.{GlutenConfig, VeloxConfig}
+import org.apache.gluten.extension.columnar.FallbackTags
import org.apache.spark.sql.Row
import org.apache.spark.sql.connector.catalog.{Identifier, TableCatalog}
+import org.apache.spark.sql.execution.datasources.v2.BatchScanExec
+import org.apache.spark.utils.GlutenSuiteUtils
-import org.apache.iceberg.UpdateSchema
+import org.apache.iceberg.{FileScanTask, UpdateSchema}
import org.apache.iceberg.expressions.Literal
-import org.apache.iceberg.spark.source.SparkTable
+import org.apache.iceberg.hadoop.HadoopCatalog
+import org.apache.iceberg.io.{ResolvingFileIO, StorageCredential}
+import org.apache.iceberg.spark.{ScanTaskSetManager, Spark3Util, SparkCatalog,
SparkReadOptions}
+import org.apache.iceberg.spark.source.{GlutenIcebergSourceUtil, SparkTable}
import org.apache.iceberg.types.{Type, Types}
+import scala.collection.JavaConverters._
+
class VeloxIcebergSuite extends IcebergSuite {
+ private def withCredentialTable(catalogName: String)(
+ f: (String, ResolvingFileIO) => Unit): Unit = {
+ withTempDir {
+ dir =>
+ withSQLConf(
+ s"spark.sql.catalog.$catalogName" -> classOf[SparkCatalog].getName,
+ s"spark.sql.catalog.$catalogName.type" -> "hadoop",
+ s"spark.sql.catalog.$catalogName.warehouse" -> dir.toURI.toString,
+ s"spark.sql.catalog.$catalogName.io-impl" ->
classOf[ResolvingFileIO].getName,
+ s"spark.sql.catalog.$catalogName.cache-enabled" -> "false"
+ ) {
+ val catalog = spark.sessionState.catalogManager.catalog(catalogName)
+
.asInstanceOf[SparkCatalog].icebergCatalog().asInstanceOf[HadoopCatalog]
+ val tableName = s"$catalogName.default.t"
+ try {
+ withTable(tableName) {
+ withSQLConf(GlutenConfig.GLUTEN_ENABLED.key -> "false") {
+ spark.sql(s"CREATE TABLE $tableName (id INT) USING iceberg")
+ spark.sql(s"INSERT INTO $tableName VALUES (1), (2)")
+ }
+ f(
+ tableName,
+ Spark3Util.loadIcebergTable(spark, tableName).io()
+ .asInstanceOf[ResolvingFileIO])
+ }
+ } finally {
+ try {
+ GlutenSuiteUtils.waitUntilEmpty(spark.sparkContext)
+ } finally {
+ catalog.close()
+ }
+ }
+ }
+ }
+ }
+
+ test("iceberg vended FileIO credentials fall back") {
+ withCredentialTable("vended_credentials") {
+ (tableName, io) =>
+ val originalProperties = io.properties().asScala.toMap
+ val keys = Map("s3.access-key-id" -> "test-key",
"s3.secret-access-key" -> "test-secret")
+ // These are the FileIO inputs supplied by REST catalogs. Exercise the
real scan path
+ // without duplicating Iceberg's REST client/server implementation.
+ val cases = Seq(
+ ("S3 key pair", keys, Seq.empty[StorageCredential]),
+ ("S3 session credentials", keys + ("s3.session-token" ->
"test-token"), Seq.empty),
+ (
+ "storage-credentials",
+ Map.empty[String, String],
+ Seq(StorageCredential.create("s3://bucket/table", keys.asJava))),
+ (
+ "credential refresh",
+ Map("client.refresh-credentials-endpoint" -> "credentials"),
+ Seq.empty)
+ )
+ cases.foreach {
+ case (name, properties, credentials) =>
+ withClue(name) {
+ io.initialize((originalProperties ++ properties).asJava)
+ io.setCredentials(credentials.asJava)
+ runQueryAndCompare(s"SELECT id FROM $tableName", noFallBack =
false) {
+ df =>
+ checkAnswer(df, Seq(Row(1), Row(2)))
+ checkGlutenPlanCount[IcebergScanTransformer](df, 0)
+ val scans = getExecutedPlan(df).collect { case scan:
BatchScanExec => scan }
+ assert(scans.size == 1)
+ val reason =
FallbackTags.get(scans.head.logicalLink.get).reason()
+ assert(reason.contains("catalog-vended credentials"))
+ assert(!reason.contains("test-secret") &&
!reason.contains("test-token"))
+ }
+ }
+ }
+ }
+ }
+
+ test("iceberg staged scans with vended FileIO credentials fall back") {
+ withCredentialTable("staged_vended_credentials") {
+ (tableName, io) =>
+ io.initialize((io.properties().asScala.toMap ++ Map(
+ "s3.access-key-id" -> "test-key",
+ "s3.secret-access-key" -> "test-secret")).asJava)
+ val table = Spark3Util.loadIcebergTable(spark, tableName)
+ val taskSetId = "vended-credentials"
+ val taskSetManager = ScanTaskSetManager.get()
+ val tasks = table.newScan().planFiles()
+ try {
+ taskSetManager.stageTasks(table, taskSetId,
tasks.asScala.toList.asJava)
+ compareDfResultsAgainstVanillaSpark(
+ () =>
+ spark.read
+ .format("iceberg")
+ .option(SparkReadOptions.SCAN_TASK_SET_ID, taskSetId)
+ .load(tableName),
+ noFallBack = false,
+ customCheck = df => {
+ checkAnswer(df, Seq(Row(1), Row(2)))
+ checkGlutenPlanCount[IcebergScanTransformer](df, 0)
+ val scans = getExecutedPlan(df).collect { case scan:
BatchScanExec => scan }
+ assert(scans.size == 1)
+
assert(GlutenIcebergSourceUtil.isSparkStagedScan(scans.head.scan))
+ val reason =
FallbackTags.get(scans.head.logicalLink.get).reason()
+ assert(reason.contains("catalog-vended credentials"))
+ assert(!reason.contains("test-secret"))
+ }
+ )
+ } finally {
+ taskSetManager.removeTasks[FileScanTask](table, taskSetId)
+ tasks.close()
+ }
+ }
+ }
+
+ test("iceberg catalog authentication and storage settings still offload") {
+ withCredentialTable("catalog_settings") {
+ (tableName, io) =>
+ io.initialize((io.properties().asScala.toMap ++ Map(
+ "credential" -> "catalog-secret",
+ "token" -> "catalog-token",
+ "s3.endpoint" -> "http://unused",
+ "client.region" -> "us-east-1")).asJava)
+ runQueryAndCompare(s"SELECT id FROM $tableName") {
+ df =>
+ checkAnswer(df, Seq(Row(1), Row(2)))
+ checkGlutenPlanCount[IcebergScanTransformer](df, 1)
+ }
+ }
+ }
+
test("iceberg parquet split uses name mapping for projected columns") {
withTable("iceberg_parquet_name_mapping") {
withSQLConf(VeloxConfig.PARQUET_USE_COLUMN_NAMES.key -> "false") {
diff --git a/docs/get-started/VeloxIceberg.md b/docs/get-started/VeloxIceberg.md
index 6b81da3e77..b9f18256a4 100644
--- a/docs/get-started/VeloxIceberg.md
+++ b/docs/get-started/VeloxIceberg.md
@@ -110,7 +110,10 @@ the added column name is same to the deleted column, the
scan will fall back.
These options can be changed per session with `SET`.
### Catalogs
-All the catalog configurations are transparent to Gluten
+
+Catalog configuration is passed through to Iceberg. Scans, including staged
scans, fall back to Spark when table FileIO exposes storage credentials, an S3
access/secret key pair, or a credential refresh endpoint. Native readers do not
support these credentials.
+
+Catalog authentication (`credential`, `token`) and storage settings
(`s3.endpoint`, `client.region`) alone do not trigger this fallback.
### SQL Extensions
Fallback
diff --git
a/gluten-iceberg/src/main/scala/org/apache/gluten/execution/IcebergScanTransformer.scala
b/gluten-iceberg/src/main/scala/org/apache/gluten/execution/IcebergScanTransformer.scala
index 6ac22b956a..57ffcb68c6 100644
---
a/gluten-iceberg/src/main/scala/org/apache/gluten/execution/IcebergScanTransformer.scala
+++
b/gluten-iceberg/src/main/scala/org/apache/gluten/execution/IcebergScanTransformer.scala
@@ -86,6 +86,11 @@ case class IcebergScanTransformer(
}
override def doValidateInternal(): ValidationResult = {
+ if (GlutenIcebergSourceUtil.hasVendedCredentials(scan)) {
+ return ValidationResult.failed(
+ "Iceberg scan uses catalog-vended credentials, which native readers do
not support")
+ }
+
val validationResult = super.doValidateInternal()
if (!validationResult.ok()) {
return validationResult
diff --git
a/gluten-iceberg/src/main/scala/org/apache/iceberg/spark/source/GlutenIcebergSourceUtil.scala
b/gluten-iceberg/src/main/scala/org/apache/iceberg/spark/source/GlutenIcebergSourceUtil.scala
index 233c06c30c..7704fb54f6 100644
---
a/gluten-iceberg/src/main/scala/org/apache/iceberg/spark/source/GlutenIcebergSourceUtil.scala
+++
b/gluten-iceberg/src/main/scala/org/apache/iceberg/spark/source/GlutenIcebergSourceUtil.scala
@@ -29,7 +29,9 @@ import org.apache.spark.sql.connector.read.Scan
import org.apache.spark.sql.types.StructType
import org.apache.iceberg._
+import org.apache.iceberg.io.{FileIO, SupportsStorageCredentials}
import org.apache.iceberg.spark.SparkSchemaUtil
+import org.apache.iceberg.util.PropertyUtil
import java.lang.{Long => JLong}
import java.util.{ArrayList => JArrayList, HashMap => JHashMap, List => JList,
Map => JMap}
@@ -49,6 +51,35 @@ object GlutenIcebergSourceUtil {
def isSparkStagedScan(sparkScan: Scan): Boolean =
sparkScan.isInstanceOf[SparkStagedScan]
+ def hasVendedCredentials(sparkScan: Scan): Boolean =
+ isSupportedScan(sparkScan) &&
hasVendedCredentials(getTable(sparkScan).io())
+
+ private def hasVendedCredentials(io: FileIO): Boolean = {
+ io match {
+ case credentials: SupportsStorageCredentials if
!credentials.credentials().isEmpty =>
+ return true
+ case _ =>
+ }
+ val properties =
+ try {
+ io.properties()
+ } catch {
+ case _: UnsupportedOperationException => return false
+ }
+ (Option(properties.get("s3.access-key-id")).exists(_.nonEmpty) &&
+ Option(properties.get("s3.secret-access-key")).exists(_.nonEmpty)) ||
+ Option(properties.get("gcs.oauth2.token")).exists(_.nonEmpty) ||
+ properties.asScala.exists {
+ case (key, value) =>
+ key.startsWith("adls.sas-token.") && Option(value).exists(_.nonEmpty)
+ } ||
+ Seq("client", "gcs.oauth2", "adls").exists {
+ prefix =>
+
Option(properties.get(s"$prefix.refresh-credentials-endpoint")).exists(_.nonEmpty)
&&
+ PropertyUtil.propertyAsBoolean(properties,
s"$prefix.refresh-credentials-enabled", true)
+ }
+ }
+
def deleteExists(p: SparkDataSourceRDDPartition): Boolean = {
p.inputPartitions.exists {
case ip: SparkInputPartition =>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]