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 60200390f4 [GLUTEN-12712][CORE] Derive Iceberg getRootPathsInternal 
from actual scanned file paths (#12833)
60200390f4 is described below

commit 60200390f473e93203cae1781521705b7cc251c2
Author: DongjunHwang <[email protected]>
AuthorDate: Fri Oct 16 17:23:17 2026 +0900

    [GLUTEN-12712][CORE] Derive Iceberg getRootPathsInternal from actual 
scanned file paths (#12833)
---
 .../gluten/execution/VeloxIcebergSuite.scala       | 40 +++++++++++++
 gluten-iceberg/pom.xml                             | 12 ++--
 .../gluten/execution/IcebergScanTransformer.scala  | 11 +++-
 .../spark/source/GlutenIcebergSourceUtil.scala     | 67 ++++++++++++++++++++--
 .../org/apache/gluten/execution/IcebergSuite.scala | 60 +++++++++++++++++++
 5 files changed, 175 insertions(+), 15 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 5bec8136d7..4b71e6d4b6 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
@@ -18,6 +18,7 @@ package org.apache.gluten.execution
 
 import org.apache.gluten.config.{GlutenConfig, VeloxConfig}
 import org.apache.gluten.extension.columnar.FallbackTags
+import org.apache.gluten.utils.VeloxFileSystemValidationJniWrapper
 
 import org.apache.spark.sql.Row
 import org.apache.spark.sql.connector.catalog.{Identifier, TableCatalog}
@@ -226,4 +227,43 @@ class VeloxIcebergSuite extends IcebergSuite {
       }
     }
   }
+
+  test("iceberg root paths on an unsupported scheme fail native scheme 
validation") {
+    // See https://github.com/apache/gluten/issues/12712: 
IcebergScanTransformer used to always
+    // report Seq.empty root paths, so VeloxBackend.validateScanExec's scheme 
check was silently
+    // skipped for every Iceberg scan, regardless of the scan's actual 
filesystem.
+    withTable("iceberg_root_paths_scheme_tb") {
+      spark.sql("""
+                  |CREATE TABLE iceberg_root_paths_scheme_tb (id INT)
+                  |USING iceberg
+                  |""".stripMargin)
+      spark.sql("INSERT INTO iceberg_root_paths_scheme_tb VALUES (1), (2)")
+
+      runQueryAndCompare("SELECT * FROM iceberg_root_paths_scheme_tb") {
+        df =>
+          val scans = getExecutedPlan(df).collect { case i: 
IcebergScanTransformer => i }
+          assert(scans.size == 1)
+          val rootPaths = scans.head.getRootPathsInternal
+          assert(rootPaths.nonEmpty)
+          // Confirm these are real table root paths (registered as `file` 
scheme), which
+          // VeloxBackendSettings.distinctRootPaths always excludes from 
scheme validation (the
+          // local filesystem is always registered) -- so a real root path 
from this suite
+          // passes validation, as expected for a supported local table.
+          assert(
+            
VeloxFileSystemValidationJniWrapper.allSupportedByRegisteredFileSystems(
+              rootPaths.toArray))
+
+          // Since this suite only exercises the local `file` scheme, which is 
always
+          // considered supported, separately confirm -- with a clean, 
synthetic URI, not
+          // derived from the real paths above -- that Velox's own native 
filesystem check (the
+          // same one VeloxBackend.validateScanExec relies on to decide 
whether to fall back)
+          // correctly rejects a genuinely unsupported scheme.
+          assert(
+            
!VeloxFileSystemValidationJniWrapper.allSupportedByRegisteredFileSystems(
+              Array("unsupported-test-scheme://bucket/path/file.parquet")),
+            "expected an unsupported scheme to fail native filesystem 
validation"
+          )
+      }
+    }
+  }
 }
diff --git a/gluten-iceberg/pom.xml b/gluten-iceberg/pom.xml
index 2cb694f8e9..95230f5eea 100644
--- a/gluten-iceberg/pom.xml
+++ b/gluten-iceberg/pom.xml
@@ -56,6 +56,12 @@
       <version>${iceberg.version}</version>
       <scope>provided</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.hadoop</groupId>
+      <artifactId>hadoop-client</artifactId>
+      <version>${hadoop.version}</version>
+      <scope>provided</scope>
+    </dependency>
 
     <!-- For test -->
     <dependency>
@@ -90,12 +96,6 @@
       <version>${spark.version}</version>
       <scope>test</scope>
     </dependency>
-    <dependency>
-      <groupId>org.apache.hadoop</groupId>
-      <artifactId>hadoop-client</artifactId>
-      <version>${hadoop.version}</version>
-      <scope>test</scope>
-    </dependency>
     <dependency>
       <groupId>org.scalatest</groupId>
       <artifactId>scalatest_${scala.binary.version}</artifactId>
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 57ffcb68c6..535c97a1c7 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
@@ -17,6 +17,7 @@
 package org.apache.gluten.execution
 
 import org.apache.gluten.backendsapi.BackendsApiManager
+import org.apache.gluten.config.GlutenConfig
 import org.apache.gluten.exception.GlutenNotSupportException
 import 
org.apache.gluten.execution.IcebergScanTransformer.{containsMetadataColumn, 
containsUuidOrFixedType}
 import org.apache.gluten.sql.shims.SparkShimLoader
@@ -198,8 +199,12 @@ case class IcebergScanTransformer(
 
   override def getDataSchema: StructType = new StructType()
 
-  // TODO: get root paths from table.
-  override def getRootPathsInternal: Seq[String] = Seq.empty
+  private lazy val fileFormatAndRootPaths: (ReadFileFormat, Seq[String]) =
+    GlutenIcebergSourceUtil.getFileFormatAndRootPaths(
+      scan,
+      collectRootPaths = GlutenConfig.get.scanFileSchemeValidationEnabled)
+
+  override def getRootPathsInternal: Seq[String] = fileFormatAndRootPaths._2
 
   private lazy val readSchemaFields =
     scan.readSchema().fieldNames.map(_.toLowerCase(Locale.ROOT)).toSet
@@ -218,7 +223,7 @@ case class IcebergScanTransformer(
     metadataColumns ++ extraMetadataColumns
   }
 
-  override lazy val fileFormat: ReadFileFormat = 
GlutenIcebergSourceUtil.getFileFormat(scan)
+  override lazy val fileFormat: ReadFileFormat = fileFormatAndRootPaths._1
 
   override def getSplitInfosFromPartitions(
       partitions: Seq[(Partition, ReadFileFormat)]): Seq[SplitInfo] = {
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 7704fb54f6..20364bc85c 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
@@ -28,6 +28,7 @@ import 
org.apache.spark.sql.catalyst.catalog.ExternalCatalogUtils
 import org.apache.spark.sql.connector.read.Scan
 import org.apache.spark.sql.types.StructType
 
+import org.apache.hadoop.fs.Path
 import org.apache.iceberg._
 import org.apache.iceberg.io.{FileIO, SupportsStorageCredentials}
 import org.apache.iceberg.spark.SparkSchemaUtil
@@ -186,16 +187,70 @@ object GlutenIcebergSourceUtil {
     metadataColumns
   }
 
-  def getFileFormat(sparkScan: Scan): ReadFileFormat = {
+  /**
+   * Derives both the file format and, optionally, the root path(s) actually 
scanned from the
+   * planned Iceberg scan tasks in a single pass over 
`getScanTasks(sparkScan)`.
+   *
+   * Root paths are derived from the real per-file (and, when present, 
per-delete-file) paths rather
+   * than table metadata (Table.location() / write.data.path), since data 
files can live at a
+   * location the static metadata does not reflect (e.g. after write.data.path 
is changed without
+   * moving previously-written files). One representative path per distinct 
scheme prefix is kept,
+   * since callers only need this to validate that the native filesystem 
implementation supports
+   * every scheme in play, not to enumerate every file.
+   *
+   * NOTE: this deliberately reuses the same `getScanTasks(sparkScan)` 
(Iceberg's own
+   * SparkPartitioningAwareScan#tasks()) that `getReadPartitionSchema` already 
calls unconditionally
+   * from `doValidateInternal()`, not Spark's 
BatchScanExecShim#filteredPartitions (which
+   * additionally calls SupportsRuntimeV2Filtering#filter() and
+   * Scan#toBatch().planInputPartitions(), and was the root cause of the DPP 
regression from
+   * https://github.com/apache/gluten/issues/12712 when a prior revision of 
this fix read paths from
+   * BatchScanExecTransformerBase#finalPartitions).
+   */
+  def getFileFormatAndRootPaths(
+      sparkScan: Scan,
+      collectRootPaths: Boolean): (ReadFileFormat, Seq[String]) = {
+    var fileFormat: ReadFileFormat = ReadFileFormat.UnknownFormat
+    // Built with plain mutable local collections that are never returned or 
captured, then
+    // copied into an immutable Seq at the end: the result is cached in a 
`lazy val` on
+    // IcebergScanTransformer (a case class captured by Spark task closures), 
so it must not
+    // retain any reference to a non-serializable mutable collection.
+    val seenSchemes = scala.collection.mutable.HashSet.empty[String]
+    val rootPathsBuilder = Seq.newBuilder[String]
+
+    def recordPath(path: String): Unit = {
+      if (collectRootPaths) {
+        // Use Hadoop's Path to extract the scheme rather than java.net.URI 
(whose strict RFC
+        // 3986 parsing throws URISyntaxException on characters like spaces or 
'[' / ']' that
+        // Hadoop-style paths otherwise tolerate, e.g. from a partition value 
baked into the file
+        // name) or plain "://"-based string slicing (which mis-detects 
single-slash Hadoop paths
+        // like "file:/tmp/x.parquet" or "hdfs:/warehouse/x.parquet" as having 
no scheme at all,
+        // causing them to collide with each other and silently drop a real 
scheme).
+        val scheme = Option(new Path(path).toUri.getScheme).getOrElse("")
+        if (seenSchemes.add(scheme)) {
+          rootPathsBuilder += path
+        }
+      }
+    }
+
     asFileScanTask(getScanTasks(sparkScan)).foreach {
       task =>
-        task.file().format() match {
-          case FileFormat.PARQUET => return ReadFileFormat.ParquetReadFormat
-          case FileFormat.ORC => return ReadFileFormat.OrcReadFormat
-          case _ =>
+        if (fileFormat == ReadFileFormat.UnknownFormat) {
+          task.file().format() match {
+            case FileFormat.PARQUET => fileFormat = 
ReadFileFormat.ParquetReadFormat
+            case FileFormat.ORC => fileFormat = ReadFileFormat.OrcReadFormat
+            case _ =>
+          }
+        }
+        if (collectRootPaths) {
+          recordPath(task.file().path().toString)
+          task.deletes().asScala.foreach(deleteFile => 
recordPath(deleteFile.path().toString))
         }
     }
-    throw new GlutenNotSupportException("Iceberg Only support parquet and orc 
file format.")
+
+    if (fileFormat == ReadFileFormat.UnknownFormat) {
+      throw new GlutenNotSupportException("Iceberg Only support parquet and 
orc file format.")
+    }
+    (fileFormat, rootPathsBuilder.result())
   }
 
   def getReadPartitionSchema(sparkScan: Scan): StructType = {
diff --git 
a/gluten-iceberg/src/test/scala/org/apache/gluten/execution/IcebergSuite.scala 
b/gluten-iceberg/src/test/scala/org/apache/gluten/execution/IcebergSuite.scala
index 4b97852a5a..d69e2229a0 100644
--- 
a/gluten-iceberg/src/test/scala/org/apache/gluten/execution/IcebergSuite.scala
+++ 
b/gluten-iceberg/src/test/scala/org/apache/gluten/execution/IcebergSuite.scala
@@ -66,6 +66,66 @@ abstract class IcebergSuite extends 
WholeStageTransformerSuite {
     }
   }
 
+  test("iceberg getRootPathsInternal covers the actual scanned data files") {
+    // See https://github.com/apache/gluten/issues/12712: getRootPathsInternal 
used to always
+    // return Seq.empty for Iceberg scans, silently skipping native filesystem 
scheme validation.
+    withTable("iceberg_root_paths_tb") {
+      spark.sql("""
+                  |CREATE TABLE iceberg_root_paths_tb (id INT)
+                  |USING iceberg
+                  |""".stripMargin)
+      spark.sql("INSERT INTO iceberg_root_paths_tb VALUES (1), (2)")
+
+      val actualFilePath = spark
+        .sql("SELECT input_file_name() FROM iceberg_root_paths_tb LIMIT 1")
+        .collect()(0)
+        .getString(0)
+
+      runQueryAndCompare("SELECT * FROM iceberg_root_paths_tb") {
+        df =>
+          val scans = getExecutedPlan(df).collect { case i: 
IcebergScanTransformer => i }
+          assert(scans.size == 1)
+          val rootPaths = scans.head.getRootPathsInternal
+          assert(rootPaths.nonEmpty, "getRootPathsInternal should not be empty 
for Iceberg tables")
+          assert(
+            rootPaths.exists(actualFilePath.startsWith),
+            s"expected one of root paths ($rootPaths) to be a prefix of the 
actual scanned " +
+              s"file ($actualFilePath)"
+          )
+      }
+    }
+  }
+
+  test("iceberg getRootPathsInternal reflects a custom write.data.path") {
+    // See https://github.com/apache/gluten/issues/12712 (review discussion): 
the table's
+    // declared location can differ from where data files actually live, e.g. 
via the
+    // write.data.path property. getRootPathsInternal must reflect that, not 
just the table's
+    // default location.
+    withTempDir {
+      dataDir =>
+        withTable("iceberg_custom_data_path_tb") {
+          spark.sql(s"""
+                       |CREATE TABLE iceberg_custom_data_path_tb (id INT)
+                       |USING iceberg
+                       |TBLPROPERTIES ('write.data.path' = 
'${dataDir.getCanonicalPath}')
+                       |""".stripMargin)
+          spark.sql("INSERT INTO iceberg_custom_data_path_tb VALUES (1), (2)")
+
+          runQueryAndCompare("SELECT * FROM iceberg_custom_data_path_tb") {
+            df =>
+              val scans = getExecutedPlan(df).collect { case i: 
IcebergScanTransformer => i }
+              assert(scans.size == 1)
+              val rootPaths = scans.head.getRootPathsInternal
+              assert(
+                rootPaths.exists(_.contains(dataDir.getCanonicalPath)),
+                s"expected root paths ($rootPaths) to include the configured " 
+
+                  s"write.data.path (${dataDir.getCanonicalPath})"
+              )
+          }
+        }
+    }
+  }
+
   test("rewrite_data_files uses an iceberg staged scan transformer") {
     val tableName = "iceberg_rewrite_tb"
     withTable(tableName) {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to