Skip to content

[GLUTEN-12712][CORE] Derive Iceberg getRootPathsInternal from actual scanned file paths - #12833

Open
HwangDongJun wants to merge 1 commit into
apache:mainfrom
HwangDongJun:fix/gluten-12712-iceberg-root-paths
Open

[GLUTEN-12712][CORE] Derive Iceberg getRootPathsInternal from actual scanned file paths#12833
HwangDongJun wants to merge 1 commit into
apache:mainfrom
HwangDongJun:fix/gluten-12712-iceberg-root-paths

Conversation

@HwangDongJun

Copy link
Copy Markdown

What changes are proposed in this pull request?

IcebergScanTransformer.getRootPathsInternal was hardcoded to return Seq.empty, behind a // TODO: get root paths from table. comment (gluten-iceberg/src/main/scala/org/apache/gluten/execution/IcebergScanTransformer.scala:196).

BasicScanExecTransformer.doValidateInternal passes this value as rootPaths into VeloxBackend.validateScanExec, whose validateScheme() only performs native filesystem scheme validation (VeloxFileSystemValidationJniWrapper.allSupportedByRegisteredFileSystems) when filteredRootPaths.nonEmpty. An empty Seq is therefore treated as "nothing to check", so scheme validation is silently skipped for every Iceberg scan regardless of the table's actual filesystem.

As a result, Gluten does not correctly fall back to vanilla Spark for Iceberg tables backed by a filesystem scheme unsupported by the native build; any resulting failure instead surfaces later inside native code, in a much less clear form. See #12712.

This PR returns the Iceberg table's base location, mirroring how DSv1/DSv2 scans already expose their root paths via FileIndex.rootPaths (BatchScanExecTransformer.getRootPathsInternal). table.location() is the standard Iceberg API for a table's base location and is already used elsewhere in this codebase (e.g. ContentFileUtil.java).

Fixes #12712.

How was this patch tested?

Added a regression test (gluten-iceberg/src/test/scala/org/apache/gluten/execution/IcebergSuite.scala) asserting getRootPathsInternal returns a non-empty root path for a plain Iceberg table, instead of the previous Seq.empty.

Verified locally that gluten-iceberg (main + test sources) compiles cleanly against the change with ./build/mvn compile/test-compile -pl gluten-iceberg -am -Piceberg, and that scalastyle reports 0 errors/warnings.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: OpenCode claude-sonnet-5

@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@HwangDongJun
HwangDongJun force-pushed the fix/gluten-12712-iceberg-root-paths branch from 8784a8c to ee68b26 Compare August 21, 2026 00:53
@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

Comment on lines +63 to +86
// SparkShims.getBatchScanExecTable returns null on Spark 3.3 (BatchScanExec has no `table`
// field until Spark 3.4), so IcebergScanTransformer.table is always null there and this
// improvement does not take effect; it does on Spark 3.4+.
testWithMinSparkVersion("iceberg getRootPathsInternal returns table location", "3.4") {
// 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)")

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.forall(_.nonEmpty))
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only verifies that getRootPathsInternal is non-empty, but the test warehouse uses file:// already:

.set("spark.sql.catalog.spark_catalog.warehouse", s"file://$rootPath/tpch-data-iceberg-velox")

Velox explicitly filters file:// paths out of filesystem scheme validation:

def distinctRootPaths(paths: Seq[String]): Seq[String] = {
// Skip native validation for local path, as local file system is always registered.
// For evey file scheme, only one path is kept.
paths
.map(p => (new Path(p).toUri.getScheme, p))
.groupBy(_._1)
.filter(_._1 != "file")
.map(_._2.head._2)
.toSeq
}

So this does not test any regression. Can we change the test to a case with an unsupported scheme and verify that the Iceberg scan falls back instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out — you're right that file:// gets excluded from distinctRootPaths, so that test wasn't really proving anything. I wasn't able to get a real table working on an actually-unsupported scheme (Iceberg's FileIO fails the write itself in that case), so I took a different approach instead.

The shared test now checks that the returned path is a real per-file path (ends in .parquet, under .../data/...), and I added a Velox-side test that feeds a fake unsupported scheme directly into VeloxFileSystemValidationJniWrapper.allSupportedByRegisteredFileSystems (the same function validateScanExec relies on) and checks that it's correctly rejected.

Happy to adjust further if you think there's a better way to test this.

case t: SparkTable => Seq(t.table().location())
case _ => Seq.empty
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we derive the paths from the Iceberg scan instead of BatchScanExec.table? GlutenIcebergSourceUtil already accesses SparkBatchQueryScan.table(), so this also works on Spark3.3 and we wouldn't need any version limitations:

def getFieldIds(sparkScan: Scan): JHashMap[String, Integer] = {
val fieldIds = new JHashMap[String, Integer]()
sparkScan match {
case scan: SparkBatchQueryScan =>
scan.table().schema().columns().asScala.foreach {
field => fieldIds.put(field.name(), field.fieldId())

Also, table.location() is not necessarily the filesystem containing the files being read. Iceberg supports a separate write.data.path and write.location-provider.impl:
https://github.com/apache/iceberg/blob/apache-iceberg-1.10.0/docs/docs/configuration.md#write-properties

Gluten already gets the actual data path from task.file().path() when building the native scan:

partition.inputPartitions.foreach {
case partition: SparkInputPartition =>
val tasks = partition.taskGroup[ScanTask]().tasks().asScala
asFileScanTask(tasks.toList).foreach {
task =>
val filePath = task.file().path().toString
paths.add(BackendsApiManager.getTransformerApiInstance.encodeFilePathIfNeed(filePath))
starts.add(task.start())
lengths.add(task.length())
partitionColumns.add(getPartitionColumns(task, readPartitionSchema))
deleteFilesList.add(task.deletes())
metadataColumns.add(
genMetadataColumns(metadataColumnNames, filePath, task.start(), task.length()))
val currentFileFormat = convertFileFormat(task.file().format())

Could we add a helper in GlutenIcebergSourceUtil that extracts the actual scan file path and use that instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, that's a really good catch — I hadn't considered write.data.path at all. I've updated the fix to derive the path from the actual scan tasks instead (GlutenIcebergSourceUtil.getRootPaths(finalPartitions), one task.file().path() per partition, following the same pattern already used in genSplitInfo/deleteExists). As a bonus, this also resolves the Spark 3.3 gap, since it no longer relies on BatchScanExec.table.

One thing I'd like to flag for visibility: this means finalPartitions now gets computed during validation for every scan, rather than only for format-v3 tables as before. It's the same cached value, just triggered more often. Also, asFileScanTask can throw for an unexpected task type, but that's already caught upstream and converted into a fallback rather than a crash, so it should be safe.

Please let me know if you'd like me to handle this differently.

@HwangDongJun
HwangDongJun force-pushed the fix/gluten-12712-iceberg-root-paths branch from ee68b26 to d00725b Compare August 23, 2026 23:42
@github-actions github-actions Bot added the VELOX label Aug 23, 2026
@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@HwangDongJun
HwangDongJun force-pushed the fix/gluten-12712-iceberg-root-paths branch from d00725b to 6862b36 Compare August 23, 2026 23:48
@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

@HwangDongJun HwangDongJun changed the title [GLUTEN-12712][CORE] Return the Iceberg table location from getRootPathsInternal [GLUTEN-12712][CORE] Derive Iceberg getRootPathsInternal from actual scanned file paths Aug 23, 2026
…scanned file paths

IcebergScanTransformer.getRootPathsInternal was hardcoded to return
Seq.empty behind a "TODO: get root paths from table." comment.
BasicScanExecTransformer.doValidateInternal passes this value as
rootPaths into VeloxBackend.validateScanExec, whose validateScheme()
only performs native filesystem scheme validation
(VeloxFileSystemValidationJniWrapper.allSupportedByRegisteredFileSystems)
"if (filteredRootPaths.nonEmpty && ...)". An empty Seq is therefore
treated as "nothing to check", silently skipping scheme validation for
every Iceberg scan regardless of the table's actual filesystem.

As a result, Gluten does not correctly fall back to vanilla Spark for
Iceberg tables backed by a filesystem scheme unsupported by the native
build; any resulting failure instead surfaces later inside native
code, in a much less clear form.

Fix: add GlutenIcebergSourceUtil.getRootPaths, which returns one
representative scanned data file path per planned Spark input
partition, taken from the actual FileScanTask (task.file().path()),
and wire it into IcebergScanTransformer.getRootPathsInternal via the
already-computed finalPartitions.

This intentionally reads the real per-file scan path rather than the
Iceberg table's declared base location (Table.location()):

- Iceberg lets a table relocate its actual data files independently of
  the table location via the write.data.path property or a custom
  LocationProvider, so Table.location() is not guaranteed to reflect
  the filesystem the data is actually read from.
- It works uniformly across all supported Spark versions. An earlier
  version of this patch read the location via
  SparkShims.getBatchScanExecTable(batchScan), which always returns
  null on Spark 3.3 (BatchScanExec has no `table` field until Spark
  3.4), silently no-oping there; deriving paths from the scan's own
  planned partitions has no such gap.

Trade-off: since scanFileSchemeValidationEnabled defaults to true,
computing root paths now forces Iceberg partition/split planning
(finalPartitions) during plan validation rather than only at
execution time. This is the same lazily-memoized value already forced
during validation for other checks (e.g. the format-version >= 3
delete-file check), just no longer conditional on format version. The
reused task-decoding helper (asFileScanTask) can throw
UnsupportedOperationException for an unexpected ScanTask type; that is
already safely converted to a validation failure (fallback, not a
crash) by ValidatablePlan.failValidationWithException.

Adds a regression test asserting getRootPathsInternal returns the
actual per-file scanned path (not just a non-empty placeholder), plus
a Velox-specific test confirming that (a) those real `file`-scheme
paths pass native filesystem validation as expected, and (b) a
genuinely unsupported scheme -- a clean synthetic URI, not derived by
string-concatenating a fake scheme onto an already-scheme-prefixed
real path -- is correctly rejected by the same native filesystem check
VeloxBackend.validateScanExec relies on.

Generated-by: OpenCode claude-sonnet-5
@HwangDongJun
HwangDongJun force-pushed the fix/gluten-12712-iceberg-root-paths branch from 6862b36 to 7879935 Compare August 24, 2026 01:47
@github-actions

Copy link
Copy Markdown

Run Gluten Clickhouse CI on x86

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[VL] IcebergScanTransformer.getRootPathsInternal always returns Seq.empty, silently skipping native filesystem scheme validation

2 participants