dongjoon-hyun commented on code in PR #58340:
URL: https://github.com/apache/spark/pull/58340#discussion_r3876055606
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala:
##########
@@ -181,5 +181,9 @@ abstract class FileTable(
}
object FileTable {
- private val CAPABILITIES = util.EnumSet.of(BATCH_READ, BATCH_WRITE)
+ // A file table meets the determinism contract SCAN_MERGING requires:
`fileIndex` is a lazy val,
+ // so every scan built from this table lists the same files, and
`newScanBuilder` returns a fresh
+ // builder over `mergedOptions(options)`. The same options, pushed filters
and pruned columns
+ // therefore rebuild an equivalent scan.
+ private val CAPABILITIES = util.EnumSet.of(BATCH_READ, BATCH_WRITE,
SCAN_MERGING)
Review Comment:
**[correctness / CONFIRMED]** CSV/JSON in non-PERMISSIVE parse modes violate
`SCAN_MERGING`'s documented contract, yet the capability is declared
unconditionally. `TableCapability.java:144` promises the merged scan reads "a
superset of their rows", but with `DROPMALFORMED` the merged scan parses the
union of columns and drops a record malformed only in the other subquery's
column, returning *fewer* rows than the original scan — the new suite's own
comment concedes this, and pins `sum(a)` flipping from 10 to 8 depending on
whether an unrelated sibling subquery triggers a merge. Today's only consumer
(`PlanMerger`) survives because each side re-filters above the scan, but a
future `SCAN_MERGING` consumer relying on the superset premise (e.g. reusing a
wider scan for a narrower query without re-checking) would silently drop rows
for CSV/JSON. `CSVTable`/`JsonTable` have the parse mode in hand, so the
capability could be withheld when `mode` is not `PERMISSIVE` — or at minimum the
exception should be carved out in the capability javadoc and this comment
rather than living only in a test comment and a migration-guide sentence.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala:
##########
@@ -181,5 +181,9 @@ abstract class FileTable(
}
object FileTable {
- private val CAPABILITIES = util.EnumSet.of(BATCH_READ, BATCH_WRITE)
+ // A file table meets the determinism contract SCAN_MERGING requires:
`fileIndex` is a lazy val,
Review Comment:
**[altitude / CONFIRMED]** Adding the capability to the shared
`FileTable.CAPABILITIES` opts in every subclass at once — including out-of-tree
file connectors (the class is public, `capabilities()` is non-final, and
cross-module extension is real: `connector/avro`'s `AvroTable`). But the
determinism this comment cites is a property of the *subclasses*
(`newScanBuilder` is abstract), which the base class cannot enforce. A
third-party `FileTable` whose `ScanBuilder` is not deterministic per (options,
filters, columns) silently inherits the contract on upgrade and gets its scans
fused, with no opt-out other than discovering it must override
`capabilities()`. Mitigating: `org.apache.spark.sql.execution` is nominally
internal (blanket MiMa exclusion), so this is unsupported-but-common extension
rather than stable API. Per-format declaration in the six built-in tables — or
at least documenting the inherited contract in `FileTable`'s scaladoc for
subclass authors — would avoid the
silent opt-in.
##########
docs/sql-migration-guide.md:
##########
@@ -25,6 +25,7 @@ license: |
## Upgrading from Spark SQL 4.3 to 4.4
- Since Spark 4.4, for storage-partitioned joins,
`spark.sql.requireAllClusterKeysForCoPartition` requires every join key to be
covered by some partition key instead of matching the partition keys
positionally. As a result, a join-key column partitioned by more than one
transform no longer prevents shuffle elimination, and
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` no
longer additionally requires `spark.sql.requireAllClusterKeysForCoPartition` to
be `false` when the join keys are a subset of the partition keys. As before,
when the partition keys cover only part of the join keys, eliminating the
shuffle still requires `spark.sql.requireAllClusterKeysForCoPartition` to be
`false`.
+- Since Spark 4.4, the built-in file formats declare the `SCAN_MERGING` table
capability on their DataSource V2 read path, so two scans of the same file
table that differ only in their projected columns can be merged into a single
scan reading the union of those columns. A format takes that path only when it
is removed from `spark.sql.sources.useV1SourceList`, and merging there now
matches what the V1 path already did. For CSV and JSON this also changes which
records count as malformed, because the parser is handed only the columns the
scan reads: with `mode` set to `DROPMALFORMED`, a record malformed only in the
columns the other scan reads is now dropped for both. To restore the previous
behavior, add the format back to `spark.sql.sources.useV1SourceList`, or
disable subplan merging entirely by adding
`org.apache.spark.sql.execution.planmerging.MergeSubplans` to
`spark.sql.optimizer.excludedRules`.
Review Comment:
**[documentation / CONFIRMED + PLAUSIBLE]** Two issues in this bullet:
1. It spells out the behavior change only for `DROPMALFORMED`, but the same
widened-parsed-columns mechanism changes results in the other two modes as well
(`FailureSafeParser.scala:41-84`): in `PERMISSIVE` mode, a record malformed
only in the other subquery's column flips `columnNameOfCorruptRecord` from null
to populated (and nulls that row's other fields); in `FAILFAST` mode, a query
that previously succeeded now throws
`malformedRecordsDetectedInRecordParsingError` — the harshest flip, with no
explicit warning here. Consider enumerating all three modes or saying "with any
parse `mode`".
2. The clause "merging there now matches what the V1 path already did"
mildly overclaims: the PR's own test "V1 merges differing partition filters, V2
does not" pins a shape the suite itself labels "Known gap against V1". A
qualifier like "for these shapes" would keep a skimming reader from
over-generalizing.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala:
##########
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.planmerging
+
+import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.connector.catalog.TableCapability
+import org.apache.spark.sql.execution.{ReusedSubqueryExec, SubqueryExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.LogicalRelation
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
DataSourceV2ScanRelation, FileTable}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * Scan merging for the built-in file sources on their DSv2 read path
(SPARK-57205).
+ *
+ * [[FileTable]] declares the `SCAN_MERGING` table capability, so
[[PlanMerger]] may fuse two file
+ * scans of the same table that differ only in their projected columns and/or
pushed filters. For a
+ * file source the strictly enforced filters are the partition filters and the
best-effort ones are
+ * the data filters.
+ *
+ * SQL-on-file and catalog tables resolve to the V1 `FileFormat` regardless of
+ * `spark.sql.sources.useV1SourceList`, so every test goes through
`DataFrameReader` and asserts the
+ * plan is V2 before asserting anything about merging.
+ */
+class FileSourceV2PlanMergingSuite extends QueryTest with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+ import testImplicits._
+
+ // The multi-column formats in sql/core; text is single-column and Avro
lives in connector/avro.
+ private val multiColumnFormats = Seq("parquet", "orc", "json", "csv")
+
+ private val flatSchema = "a long, b long, c long, d long"
+
+ private def writeFlat(format: String, path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id * 3 AS d")
+ .write.format(format).save(path)
+
+ private def writePartitioned(path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id % 4 AS p")
+ .write.partitionBy("p").format("parquet").save(path)
+
+ /**
+ * Registers `path` as a temp view read through the V2 path, or the V1 path
if `useV1`. Not named
+ * `withView`: that name is taken by a varargs helper in
`QueryCleanupHelper`, which a call with
+ * only positional String arguments would silently bind to instead.
+ */
+ private def withFileView[T](
+ format: String,
+ path: String,
+ useV1: Boolean = false,
+ schema: Option[String] = None,
+ options: Map[String, String] = Map.empty,
+ viewName: String = "t")(f: => T): T = {
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> (if (useV1) format else ""))
{
+ val base = spark.read.format(format).options(options)
+ val reader = schema.map(s => base.schema(s)).getOrElse(base)
+ reader.load(path).createOrReplaceTempView(viewName)
+ try f finally spark.catalog.dropTempView(viewName)
+ }
+ }
+
+ private def v2Scans(df: DataFrame): Seq[DataSourceV2ScanRelation] =
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case s: DataSourceV2ScanRelation => s
+ }
+
+ /**
+ * A merged subquery is referenced once per original subquery, so the
logical plan duplicates it
+ * (physical planning reuses it). Dedupe by canonical form: one distinct
scan means the merge
+ * happened, two means it was declined.
+ */
+ private def distinctScans(df: DataFrame): Int =
v2Scans(df).map(_.canonicalized).distinct.length
+
+ private def assertUsesFileSourceV2(df: DataFrame): Unit = {
+ val plan = df.queryExecution.optimizedPlan
+ assert(plan.collectWithSubqueries { case r: LogicalRelation => r }.isEmpty,
+ s"expected the V2 file source path, but the plan has a V1
relation:\n$plan")
+ val scans = v2Scans(df)
+ assert(scans.nonEmpty, s"expected a DSv2 file scan:\n$plan")
+ scans.foreach { s =>
+ assert(s.relation.table.isInstanceOf[FileTable],
+ s"expected a FileTable, got
${s.relation.table.getClass.getSimpleName}")
+ }
+ }
+
+ // A successful merge builds the scan and leaves no bare
DataSourceV2Relation behind; a leaked
+ // deferred scan would show up as an unbuilt placeholder the read path
cannot plan.
+ private def assertNoPlaceholderRelation(df: DataFrame): Unit =
Review Comment:
**[simplification / CONFIRMED]** This assert can never fire. All 4 call
sites run after `checkAnswer`, which has already physically planned and
executed every subquery — and a leaked bare `DataSourceV2Relation` has no
batch-read physical strategy (`DataSourceV2Strategy` plans only
`DataSourceV2ScanRelation` for reads), so planning would already have failed
inside `checkAnswer` with `QueryPlanner`'s "No plan for" assertion before this
helper's friendlier message could be reached. It was copied from
`DSv2PlanMergingSuite`, where the placement is equally post-`checkAnswer` and
equally dead. Deleting the helper and its 4 calls loses nothing.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala:
##########
@@ -181,5 +181,9 @@ abstract class FileTable(
}
object FileTable {
- private val CAPABILITIES = util.EnumSet.of(BATCH_READ, BATCH_WRITE)
+ // A file table meets the determinism contract SCAN_MERGING requires:
`fileIndex` is a lazy val,
+ // so every scan built from this table lists the same files, and
`newScanBuilder` returns a fresh
Review Comment:
**[correctness / PLAUSIBLE]** The determinism justification here holds only
per table *instance*: `PlanMerger`'s gate is canonicalized-plan equality
(`PlanMerger.scala:680`), the table case classes exclude the lazy `fileIndex`
from equality, and `InMemoryFileIndex.equals` compares root paths only — so
scans from two table instances whose listings were taken at different times
(df1 read, files land, df2 read) are canonically equal, and the rebuilt merged
scan reads one side's snapshot for both. This is largely an extension of
long-standing rootPaths-only equality semantics (identical-column scans were
already collapsed by the capability-free identical-plan path and
`ReuseSubquery`/`ReuseExchange`, and V1 merges these shapes the same way), so
no action beyond wording may be needed — but the comment overclaims as written;
stating it as an instance-level property (or noting the cross-instance caveat)
would keep it accurate.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala:
##########
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.planmerging
+
+import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.connector.catalog.TableCapability
+import org.apache.spark.sql.execution.{ReusedSubqueryExec, SubqueryExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.LogicalRelation
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
DataSourceV2ScanRelation, FileTable}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * Scan merging for the built-in file sources on their DSv2 read path
(SPARK-57205).
+ *
+ * [[FileTable]] declares the `SCAN_MERGING` table capability, so
[[PlanMerger]] may fuse two file
+ * scans of the same table that differ only in their projected columns and/or
pushed filters. For a
+ * file source the strictly enforced filters are the partition filters and the
best-effort ones are
+ * the data filters.
+ *
+ * SQL-on-file and catalog tables resolve to the V1 `FileFormat` regardless of
+ * `spark.sql.sources.useV1SourceList`, so every test goes through
`DataFrameReader` and asserts the
+ * plan is V2 before asserting anything about merging.
+ */
+class FileSourceV2PlanMergingSuite extends QueryTest with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+ import testImplicits._
+
+ // The multi-column formats in sql/core; text is single-column and Avro
lives in connector/avro.
+ private val multiColumnFormats = Seq("parquet", "orc", "json", "csv")
+
+ private val flatSchema = "a long, b long, c long, d long"
+
+ private def writeFlat(format: String, path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id * 3 AS d")
+ .write.format(format).save(path)
+
+ private def writePartitioned(path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id % 4 AS p")
+ .write.partitionBy("p").format("parquet").save(path)
+
+ /**
+ * Registers `path` as a temp view read through the V2 path, or the V1 path
if `useV1`. Not named
+ * `withView`: that name is taken by a varargs helper in
`QueryCleanupHelper`, which a call with
+ * only positional String arguments would silently bind to instead.
+ */
+ private def withFileView[T](
+ format: String,
+ path: String,
+ useV1: Boolean = false,
+ schema: Option[String] = None,
+ options: Map[String, String] = Map.empty,
+ viewName: String = "t")(f: => T): T = {
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> (if (useV1) format else ""))
{
+ val base = spark.read.format(format).options(options)
+ val reader = schema.map(s => base.schema(s)).getOrElse(base)
+ reader.load(path).createOrReplaceTempView(viewName)
+ try f finally spark.catalog.dropTempView(viewName)
+ }
+ }
+
+ private def v2Scans(df: DataFrame): Seq[DataSourceV2ScanRelation] =
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case s: DataSourceV2ScanRelation => s
+ }
+
+ /**
+ * A merged subquery is referenced once per original subquery, so the
logical plan duplicates it
+ * (physical planning reuses it). Dedupe by canonical form: one distinct
scan means the merge
+ * happened, two means it was declined.
+ */
+ private def distinctScans(df: DataFrame): Int =
v2Scans(df).map(_.canonicalized).distinct.length
+
+ private def assertUsesFileSourceV2(df: DataFrame): Unit = {
+ val plan = df.queryExecution.optimizedPlan
+ assert(plan.collectWithSubqueries { case r: LogicalRelation => r }.isEmpty,
+ s"expected the V2 file source path, but the plan has a V1
relation:\n$plan")
+ val scans = v2Scans(df)
+ assert(scans.nonEmpty, s"expected a DSv2 file scan:\n$plan")
+ scans.foreach { s =>
+ assert(s.relation.table.isInstanceOf[FileTable],
+ s"expected a FileTable, got
${s.relation.table.getClass.getSimpleName}")
+ }
+ }
+
+ // A successful merge builds the scan and leaves no bare
DataSourceV2Relation behind; a leaked
+ // deferred scan would show up as an unbuilt placeholder the read path
cannot plan.
+ private def assertNoPlaceholderRelation(df: DataFrame): Unit =
+ assert(
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case r: DataSourceV2Relation => r
+ }.isEmpty,
+ s"unbuilt placeholder DataSourceV2Relation left in
plan:\n${df.queryExecution.optimizedPlan}")
+
+ /** `(SubqueryExec, ReusedSubqueryExec)` counts, the same measure
`PlanMergingSuite` uses. */
+ private def subqueryCounts(df: DataFrame): (Int, Int) = {
+ val plan = df.queryExecution.executedPlan
+ val subqueries = collectWithSubqueries(plan) { case s: SubqueryExec =>
s.id }
+ val reused = collectWithSubqueries(plan) { case rs: ReusedSubqueryExec =>
rs.child.id }
+ (subqueries.size, reused.size)
+ }
+
+ /**
+ * Runs `query` over the parquet data at `path` on the V1 or V2 read path
with both symmetric
+ * filter propagation configurations on, checks the rows and returns the
subquery counts.
+ */
+ private def mergedCounts(
+ path: String,
+ query: String,
+ expected: Row,
+ useV1: Boolean,
+ enableAQE: Boolean): (Int, Int) = {
+ withFileView("parquet", path, useV1 = useV1) {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> enableAQE.toString,
+ SQLConf.MERGE_SUBPLANS_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key ->
"true",
+ SQLConf.MERGE_SUBPLANS_DSV2_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key
-> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ subqueryCounts(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: every built-in file table declares SCAN_MERGING") {
+ Seq("parquet", "orc", "json", "csv", "text").foreach { format =>
+ withClue(s"format=$format: ") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ spark.range(0, 5).selectExpr("cast(id AS string) AS value")
+ .write.format(format).save(path)
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val relations = spark.read.format(format).load(path)
+ .queryExecution.analyzed.collect { case r: DataSourceV2Relation
=> r }
+ assert(relations.size == 1, s"expected a single DSv2 relation, got
$relations")
+ val table = relations.head.table
+ assert(table.isInstanceOf[FileTable], s"expected a FileTable, got
$table")
+ assert(table.capabilities().contains(TableCapability.SCAN_MERGING),
+ s"${table.getClass.getSimpleName} should declare SCAN_MERGING")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: merge two file scans that differ only in their projected
columns") {
+ multiColumnFormats.foreach { format =>
+ withClue(s"format=$format: ") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat(format, path)
+ withFileView(format, path, schema = Some(flatSchema)) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE c = 1),
+ | (SELECT sum(b) FROM t WHERE c = 1)
+ |""".stripMargin)
+
+ // c is id % 3, so c = 1 selects ids 1, 4, 7, 10, 13, 16 and 19.
+ checkAnswer(df, Row(70, 140))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 1,
+ s"the two scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ // Both sides carry the same data filter, so no widening is needed
and this merges
+ // under the default configuration. c is read because the filter
stays above the scan.
+ assert(v2Scans(df).head.output.map(_.name).toSet == Set("a", "b",
"c"),
+ s"the merged scan should read the union of both columns; " +
+ s"got ${v2Scans(df).head.output}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: merge two file scans over the same partition filter") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writePartitioned(path)
+ withFileView("parquet", path) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE p = 1),
+ | (SELECT sum(b) FROM t WHERE p = 1)
+ |""".stripMargin)
+
+ // p is id % 4, so p = 1 selects ids 1, 5, 9, 13 and 17.
+ checkAnswer(df, Row(45, 90))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 1,
+ s"the two scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ val scan = v2Scans(df).head
+ // A partition filter is fully enforced by the scan and nothing above
it re-checks, so p is
+ // not read; the rebuilt scan has to push the filter again or it would
read all partitions.
+ assert(scan.output.map(_.name).toSet == Set("a", "b"),
+ s"the merged scan should read the union of both columns; got
${scan.output}")
+ assert(scan.pushedFilters.exists(_.references.exists(_.name == "p")),
+ s"the partition filter should be re-pushed strict onto the merged
scan; " +
+ s"got pushedFilters=${scan.pushedFilters.mkString("[", ", ",
"]")}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: merge three file scans into one") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat("parquet", path)
+ withFileView("parquet", path) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE c = 1),
+ | (SELECT sum(b) FROM t WHERE c = 1),
+ | (SELECT sum(d) FROM t WHERE c = 1)
+ |""".stripMargin)
+
+ checkAnswer(df, Row(70, 140, 210))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 1,
+ s"the three scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ assert(v2Scans(df).head.output.map(_.name).toSet == Set("a", "b", "c",
"d"),
+ s"the merged scan should read the union of all three; got
${v2Scans(df).head.output}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: merge file scans with differing data filters only when
dsv2 symmetric " +
+ "filter propagation is on") {
+ Seq(true, false).foreach { dsv2Symmetric =>
+ withClue(s"dsv2SymmetricFilterPropagation=$dsv2Symmetric: ") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat("parquet", path)
+ withFileView("parquet", path) {
+
withSQLConf(SQLConf.MERGE_SUBPLANS_DSV2_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key
->
+ dsv2Symmetric.toString) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE a > 10),
+ | (SELECT sum(b) FROM t WHERE b > 10)
+ |""".stripMargin)
+
+ // a > 10 selects ids 11 to 19; b is 2 * a, so b > 10 selects
ids 6 to 19.
+ checkAnswer(df, Row(135, 350))
+ assertUsesFileSourceV2(df)
+ // a and b are data columns, so neither scan pushes a strict
filter: the strict sets
+ // are equal and only the OR-widening of the differing
best-effort filters gates the
+ // merge. The enclosing Filter keeps each aggregate exact either
way.
+ assert(distinctScans(df) == (if (dsv2Symmetric) 1 else 2),
+ s"unexpected scan count:\n${df.queryExecution.optimizedPlan}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: do not merge file scans with different partition
filters") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writePartitioned(path)
+ withFileView("parquet", path) {
+ // Known gap against V1, which merges this shape: a partition filter
is strictly enforced
+ // by the scan, so widening it to OR would make the merged scan return
rows nothing above
+ // it filters out. Both propagation configs are on to show the merge
is declined regardless.
+ withSQLConf(
+ SQLConf.MERGE_SUBPLANS_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key ->
"true",
+
SQLConf.MERGE_SUBPLANS_DSV2_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key -> "true")
{
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE p = 1),
+ | (SELECT sum(b) FROM t WHERE p = 2)
+ |""".stripMargin)
+
+ // p = 1 selects ids 1, 5, 9, 13, 17; p = 2 selects ids 2, 6, 10,
14, 18.
+ checkAnswer(df, Row(45, 100))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 2,
+ s"scans with different partition filters must not be fused:\n" +
+ df.queryExecution.optimizedPlan)
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: do not merge file scans that read different nested
fields") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ spark.range(0, 20).selectExpr("id AS a", "named_struct('x', id, 'y', id
* 2) AS s")
+ .write.format("parquet").save(path)
+ Seq(true, false).foreach { nestedPruning =>
+ withClue(s"nestedSchemaPruning=$nestedPruning: ") {
+ withFileView("parquet", path) {
+ withSQLConf(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key ->
nestedPruning.toString) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(s.x) FROM t),
+ | (SELECT sum(s.y) FROM t)
+ |""".stripMargin)
+
+ checkAnswer(df, Row(190, 380))
+ assertUsesFileSourceV2(df)
+ // Nested pruning narrows s to the one field each side reads, so
the read column is
+ // no longer a same-type subset of the relation's s and the
merge is declined -- the
+ // field ordinals in the extractors above the scan are resolved
against the narrowed
+ // type. Without pruning both scans read the whole struct and
are canonically equal,
+ // so they merge on PlanMerger's identical-plan path, which
needs no capability.
+ assert(distinctScans(df) == (if (nestedPruning) 2 else 1),
Review Comment:
**[test-coverage / CONFIRMED]** The `nestedPruning=false` branch of this
assertion is vacuous. With pruning off, both subqueries' scans read the
identical whole struct `s`, and `FileScan.equals` (`FileScan.scala:104-111`)
compares only fileIndex, readSchema and normalized filters — so even if the
identical-plan merge were declined and two separate scan relations remained,
they would canonicalize equal and `distinctScans` would still return 1. The
comment says this branch verifies merging "on PlanMerger's identical-plan
path", but a regression that stops that merge would go undetected.
`subqueryCounts` (already defined in this suite) would actually distinguish:
(1, 1) merged vs (2, 0) unmerged. The `nestedPruning=true` branch is fine —
there the readSchemas differ, so `== 2` is meaningful.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala:
##########
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.planmerging
+
+import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.connector.catalog.TableCapability
+import org.apache.spark.sql.execution.{ReusedSubqueryExec, SubqueryExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.LogicalRelation
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
DataSourceV2ScanRelation, FileTable}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * Scan merging for the built-in file sources on their DSv2 read path
(SPARK-57205).
+ *
+ * [[FileTable]] declares the `SCAN_MERGING` table capability, so
[[PlanMerger]] may fuse two file
+ * scans of the same table that differ only in their projected columns and/or
pushed filters. For a
+ * file source the strictly enforced filters are the partition filters and the
best-effort ones are
+ * the data filters.
+ *
+ * SQL-on-file and catalog tables resolve to the V1 `FileFormat` regardless of
+ * `spark.sql.sources.useV1SourceList`, so every test goes through
`DataFrameReader` and asserts the
+ * plan is V2 before asserting anything about merging.
+ */
+class FileSourceV2PlanMergingSuite extends QueryTest with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+ import testImplicits._
+
+ // The multi-column formats in sql/core; text is single-column and Avro
lives in connector/avro.
+ private val multiColumnFormats = Seq("parquet", "orc", "json", "csv")
+
+ private val flatSchema = "a long, b long, c long, d long"
+
+ private def writeFlat(format: String, path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id * 3 AS d")
+ .write.format(format).save(path)
+
+ private def writePartitioned(path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id % 4 AS p")
+ .write.partitionBy("p").format("parquet").save(path)
+
+ /**
+ * Registers `path` as a temp view read through the V2 path, or the V1 path
if `useV1`. Not named
+ * `withView`: that name is taken by a varargs helper in
`QueryCleanupHelper`, which a call with
+ * only positional String arguments would silently bind to instead.
+ */
+ private def withFileView[T](
+ format: String,
+ path: String,
+ useV1: Boolean = false,
+ schema: Option[String] = None,
+ options: Map[String, String] = Map.empty,
+ viewName: String = "t")(f: => T): T = {
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> (if (useV1) format else ""))
{
+ val base = spark.read.format(format).options(options)
+ val reader = schema.map(s => base.schema(s)).getOrElse(base)
+ reader.load(path).createOrReplaceTempView(viewName)
+ try f finally spark.catalog.dropTempView(viewName)
+ }
+ }
+
+ private def v2Scans(df: DataFrame): Seq[DataSourceV2ScanRelation] =
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case s: DataSourceV2ScanRelation => s
+ }
+
+ /**
+ * A merged subquery is referenced once per original subquery, so the
logical plan duplicates it
+ * (physical planning reuses it). Dedupe by canonical form: one distinct
scan means the merge
+ * happened, two means it was declined.
+ */
+ private def distinctScans(df: DataFrame): Int =
v2Scans(df).map(_.canonicalized).distinct.length
+
+ private def assertUsesFileSourceV2(df: DataFrame): Unit = {
+ val plan = df.queryExecution.optimizedPlan
+ assert(plan.collectWithSubqueries { case r: LogicalRelation => r }.isEmpty,
+ s"expected the V2 file source path, but the plan has a V1
relation:\n$plan")
+ val scans = v2Scans(df)
+ assert(scans.nonEmpty, s"expected a DSv2 file scan:\n$plan")
+ scans.foreach { s =>
+ assert(s.relation.table.isInstanceOf[FileTable],
+ s"expected a FileTable, got
${s.relation.table.getClass.getSimpleName}")
+ }
+ }
+
+ // A successful merge builds the scan and leaves no bare
DataSourceV2Relation behind; a leaked
+ // deferred scan would show up as an unbuilt placeholder the read path
cannot plan.
+ private def assertNoPlaceholderRelation(df: DataFrame): Unit =
+ assert(
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case r: DataSourceV2Relation => r
+ }.isEmpty,
+ s"unbuilt placeholder DataSourceV2Relation left in
plan:\n${df.queryExecution.optimizedPlan}")
+
+ /** `(SubqueryExec, ReusedSubqueryExec)` counts, the same measure
`PlanMergingSuite` uses. */
+ private def subqueryCounts(df: DataFrame): (Int, Int) = {
+ val plan = df.queryExecution.executedPlan
+ val subqueries = collectWithSubqueries(plan) { case s: SubqueryExec =>
s.id }
+ val reused = collectWithSubqueries(plan) { case rs: ReusedSubqueryExec =>
rs.child.id }
+ (subqueries.size, reused.size)
+ }
+
+ /**
+ * Runs `query` over the parquet data at `path` on the V1 or V2 read path
with both symmetric
+ * filter propagation configurations on, checks the rows and returns the
subquery counts.
+ */
+ private def mergedCounts(
+ path: String,
+ query: String,
+ expected: Row,
+ useV1: Boolean,
+ enableAQE: Boolean): (Int, Int) = {
+ withFileView("parquet", path, useV1 = useV1) {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> enableAQE.toString,
+ SQLConf.MERGE_SUBPLANS_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key ->
"true",
+ SQLConf.MERGE_SUBPLANS_DSV2_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key
-> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ subqueryCounts(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: every built-in file table declares SCAN_MERGING") {
+ Seq("parquet", "orc", "json", "csv", "text").foreach { format =>
Review Comment:
**[test-coverage / CONFIRMED]** `AvroTable` inherits `SCAN_MERGING` through
`FileTable`, but this test enumerates only parquet/orc/json/csv/text and
nothing in `connector/avro` asserts the capability or exercises merging (grep
for `SCAN_MERGING`/`PlanMerging`/`MergeSubplans` there returns zero hits). An
Avro-specific merge defect — e.g. interaction of the union-column rebuild with
`positionalFieldMatching` or Avro's filter pushdown — would ship
enabled-by-default with zero coverage, while the migration guide tells users
"the built-in file formats" are included. A small merging test in
`connector/avro` (or at least a capability assertion there) would close the gap.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala:
##########
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.planmerging
+
+import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.connector.catalog.TableCapability
+import org.apache.spark.sql.execution.{ReusedSubqueryExec, SubqueryExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.LogicalRelation
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
DataSourceV2ScanRelation, FileTable}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * Scan merging for the built-in file sources on their DSv2 read path
(SPARK-57205).
+ *
+ * [[FileTable]] declares the `SCAN_MERGING` table capability, so
[[PlanMerger]] may fuse two file
+ * scans of the same table that differ only in their projected columns and/or
pushed filters. For a
+ * file source the strictly enforced filters are the partition filters and the
best-effort ones are
+ * the data filters.
+ *
+ * SQL-on-file and catalog tables resolve to the V1 `FileFormat` regardless of
+ * `spark.sql.sources.useV1SourceList`, so every test goes through
`DataFrameReader` and asserts the
+ * plan is V2 before asserting anything about merging.
+ */
+class FileSourceV2PlanMergingSuite extends QueryTest with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+ import testImplicits._
+
+ // The multi-column formats in sql/core; text is single-column and Avro
lives in connector/avro.
+ private val multiColumnFormats = Seq("parquet", "orc", "json", "csv")
+
+ private val flatSchema = "a long, b long, c long, d long"
+
+ private def writeFlat(format: String, path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id * 3 AS d")
+ .write.format(format).save(path)
+
+ private def writePartitioned(path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id % 4 AS p")
+ .write.partitionBy("p").format("parquet").save(path)
+
+ /**
+ * Registers `path` as a temp view read through the V2 path, or the V1 path
if `useV1`. Not named
+ * `withView`: that name is taken by a varargs helper in
`QueryCleanupHelper`, which a call with
+ * only positional String arguments would silently bind to instead.
+ */
+ private def withFileView[T](
+ format: String,
+ path: String,
+ useV1: Boolean = false,
+ schema: Option[String] = None,
+ options: Map[String, String] = Map.empty,
+ viewName: String = "t")(f: => T): T = {
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> (if (useV1) format else ""))
{
+ val base = spark.read.format(format).options(options)
+ val reader = schema.map(s => base.schema(s)).getOrElse(base)
+ reader.load(path).createOrReplaceTempView(viewName)
+ try f finally spark.catalog.dropTempView(viewName)
+ }
+ }
+
+ private def v2Scans(df: DataFrame): Seq[DataSourceV2ScanRelation] =
Review Comment:
**[reuse / CONFIRMED]** `v2Scans` is a character-for-character copy of the
private helper in `DSv2PlanMergingSuite.scala:59-62`, and
`assertNoPlaceholderRelation` below duplicates its lines 69-75 — same test
package. A future change to how merged V2 scans are collected (e.g. a new
wrapper node) must land in both suites or they silently measure different
things. A small shared package-private trait would collapse the duplication,
and is also a natural home for `distinctScans`/`subqueryCounts` (the latter's
measure is inlined eleven times in `PlanMergingSuite`).
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala:
##########
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.planmerging
+
+import org.apache.spark.sql.{DataFrame, QueryTest, Row}
+import org.apache.spark.sql.connector.catalog.TableCapability
+import org.apache.spark.sql.execution.{ReusedSubqueryExec, SubqueryExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.LogicalRelation
+import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation,
DataSourceV2ScanRelation, FileTable}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * Scan merging for the built-in file sources on their DSv2 read path
(SPARK-57205).
+ *
+ * [[FileTable]] declares the `SCAN_MERGING` table capability, so
[[PlanMerger]] may fuse two file
+ * scans of the same table that differ only in their projected columns and/or
pushed filters. For a
+ * file source the strictly enforced filters are the partition filters and the
best-effort ones are
+ * the data filters.
+ *
+ * SQL-on-file and catalog tables resolve to the V1 `FileFormat` regardless of
+ * `spark.sql.sources.useV1SourceList`, so every test goes through
`DataFrameReader` and asserts the
+ * plan is V2 before asserting anything about merging.
+ */
+class FileSourceV2PlanMergingSuite extends QueryTest with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+ import testImplicits._
+
+ // The multi-column formats in sql/core; text is single-column and Avro
lives in connector/avro.
+ private val multiColumnFormats = Seq("parquet", "orc", "json", "csv")
+
+ private val flatSchema = "a long, b long, c long, d long"
+
+ private def writeFlat(format: String, path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id * 3 AS d")
+ .write.format(format).save(path)
+
+ private def writePartitioned(path: String): Unit =
+ spark.range(0, 20)
+ .selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c", "id % 4 AS p")
+ .write.partitionBy("p").format("parquet").save(path)
+
+ /**
+ * Registers `path` as a temp view read through the V2 path, or the V1 path
if `useV1`. Not named
+ * `withView`: that name is taken by a varargs helper in
`QueryCleanupHelper`, which a call with
+ * only positional String arguments would silently bind to instead.
+ */
+ private def withFileView[T](
+ format: String,
+ path: String,
+ useV1: Boolean = false,
+ schema: Option[String] = None,
+ options: Map[String, String] = Map.empty,
+ viewName: String = "t")(f: => T): T = {
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> (if (useV1) format else ""))
{
+ val base = spark.read.format(format).options(options)
+ val reader = schema.map(s => base.schema(s)).getOrElse(base)
+ reader.load(path).createOrReplaceTempView(viewName)
+ try f finally spark.catalog.dropTempView(viewName)
+ }
+ }
+
+ private def v2Scans(df: DataFrame): Seq[DataSourceV2ScanRelation] =
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case s: DataSourceV2ScanRelation => s
+ }
+
+ /**
+ * A merged subquery is referenced once per original subquery, so the
logical plan duplicates it
+ * (physical planning reuses it). Dedupe by canonical form: one distinct
scan means the merge
+ * happened, two means it was declined.
+ */
+ private def distinctScans(df: DataFrame): Int =
v2Scans(df).map(_.canonicalized).distinct.length
+
+ private def assertUsesFileSourceV2(df: DataFrame): Unit = {
+ val plan = df.queryExecution.optimizedPlan
+ assert(plan.collectWithSubqueries { case r: LogicalRelation => r }.isEmpty,
+ s"expected the V2 file source path, but the plan has a V1
relation:\n$plan")
+ val scans = v2Scans(df)
+ assert(scans.nonEmpty, s"expected a DSv2 file scan:\n$plan")
+ scans.foreach { s =>
+ assert(s.relation.table.isInstanceOf[FileTable],
+ s"expected a FileTable, got
${s.relation.table.getClass.getSimpleName}")
+ }
+ }
+
+ // A successful merge builds the scan and leaves no bare
DataSourceV2Relation behind; a leaked
+ // deferred scan would show up as an unbuilt placeholder the read path
cannot plan.
+ private def assertNoPlaceholderRelation(df: DataFrame): Unit =
+ assert(
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case r: DataSourceV2Relation => r
+ }.isEmpty,
+ s"unbuilt placeholder DataSourceV2Relation left in
plan:\n${df.queryExecution.optimizedPlan}")
+
+ /** `(SubqueryExec, ReusedSubqueryExec)` counts, the same measure
`PlanMergingSuite` uses. */
+ private def subqueryCounts(df: DataFrame): (Int, Int) = {
+ val plan = df.queryExecution.executedPlan
+ val subqueries = collectWithSubqueries(plan) { case s: SubqueryExec =>
s.id }
+ val reused = collectWithSubqueries(plan) { case rs: ReusedSubqueryExec =>
rs.child.id }
+ (subqueries.size, reused.size)
+ }
+
+ /**
+ * Runs `query` over the parquet data at `path` on the V1 or V2 read path
with both symmetric
+ * filter propagation configurations on, checks the rows and returns the
subquery counts.
+ */
+ private def mergedCounts(
+ path: String,
+ query: String,
+ expected: Row,
+ useV1: Boolean,
+ enableAQE: Boolean): (Int, Int) = {
+ withFileView("parquet", path, useV1 = useV1) {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> enableAQE.toString,
+ SQLConf.MERGE_SUBPLANS_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key ->
"true",
+ SQLConf.MERGE_SUBPLANS_DSV2_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key
-> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ subqueryCounts(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: every built-in file table declares SCAN_MERGING") {
+ Seq("parquet", "orc", "json", "csv", "text").foreach { format =>
+ withClue(s"format=$format: ") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ spark.range(0, 5).selectExpr("cast(id AS string) AS value")
+ .write.format(format).save(path)
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val relations = spark.read.format(format).load(path)
+ .queryExecution.analyzed.collect { case r: DataSourceV2Relation
=> r }
+ assert(relations.size == 1, s"expected a single DSv2 relation, got
$relations")
+ val table = relations.head.table
+ assert(table.isInstanceOf[FileTable], s"expected a FileTable, got
$table")
+ assert(table.capabilities().contains(TableCapability.SCAN_MERGING),
+ s"${table.getClass.getSimpleName} should declare SCAN_MERGING")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: merge two file scans that differ only in their projected
columns") {
+ multiColumnFormats.foreach { format =>
+ withClue(s"format=$format: ") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat(format, path)
+ withFileView(format, path, schema = Some(flatSchema)) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE c = 1),
+ | (SELECT sum(b) FROM t WHERE c = 1)
+ |""".stripMargin)
+
+ // c is id % 3, so c = 1 selects ids 1, 4, 7, 10, 13, 16 and 19.
+ checkAnswer(df, Row(70, 140))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 1,
+ s"the two scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ // Both sides carry the same data filter, so no widening is needed
and this merges
+ // under the default configuration. c is read because the filter
stays above the scan.
+ assert(v2Scans(df).head.output.map(_.name).toSet == Set("a", "b",
"c"),
+ s"the merged scan should read the union of both columns; " +
+ s"got ${v2Scans(df).head.output}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: merge two file scans over the same partition filter") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writePartitioned(path)
+ withFileView("parquet", path) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE p = 1),
+ | (SELECT sum(b) FROM t WHERE p = 1)
+ |""".stripMargin)
+
+ // p is id % 4, so p = 1 selects ids 1, 5, 9, 13 and 17.
+ checkAnswer(df, Row(45, 90))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 1,
+ s"the two scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ val scan = v2Scans(df).head
+ // A partition filter is fully enforced by the scan and nothing above
it re-checks, so p is
+ // not read; the rebuilt scan has to push the filter again or it would
read all partitions.
+ assert(scan.output.map(_.name).toSet == Set("a", "b"),
+ s"the merged scan should read the union of both columns; got
${scan.output}")
+ assert(scan.pushedFilters.exists(_.references.exists(_.name == "p")),
+ s"the partition filter should be re-pushed strict onto the merged
scan; " +
+ s"got pushedFilters=${scan.pushedFilters.mkString("[", ", ",
"]")}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: merge three file scans into one") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat("parquet", path)
+ withFileView("parquet", path) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE c = 1),
+ | (SELECT sum(b) FROM t WHERE c = 1),
+ | (SELECT sum(d) FROM t WHERE c = 1)
+ |""".stripMargin)
+
+ checkAnswer(df, Row(70, 140, 210))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 1,
+ s"the three scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ assert(v2Scans(df).head.output.map(_.name).toSet == Set("a", "b", "c",
"d"),
+ s"the merged scan should read the union of all three; got
${v2Scans(df).head.output}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: merge file scans with differing data filters only when
dsv2 symmetric " +
+ "filter propagation is on") {
+ Seq(true, false).foreach { dsv2Symmetric =>
+ withClue(s"dsv2SymmetricFilterPropagation=$dsv2Symmetric: ") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat("parquet", path)
+ withFileView("parquet", path) {
+
withSQLConf(SQLConf.MERGE_SUBPLANS_DSV2_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key
->
+ dsv2Symmetric.toString) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE a > 10),
+ | (SELECT sum(b) FROM t WHERE b > 10)
+ |""".stripMargin)
+
+ // a > 10 selects ids 11 to 19; b is 2 * a, so b > 10 selects
ids 6 to 19.
+ checkAnswer(df, Row(135, 350))
+ assertUsesFileSourceV2(df)
+ // a and b are data columns, so neither scan pushes a strict
filter: the strict sets
+ // are equal and only the OR-widening of the differing
best-effort filters gates the
+ // merge. The enclosing Filter keeps each aggregate exact either
way.
+ assert(distinctScans(df) == (if (dsv2Symmetric) 1 else 2),
+ s"unexpected scan count:\n${df.queryExecution.optimizedPlan}")
+ assertNoPlaceholderRelation(df)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: do not merge file scans with different partition
filters") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writePartitioned(path)
+ withFileView("parquet", path) {
+ // Known gap against V1, which merges this shape: a partition filter
is strictly enforced
+ // by the scan, so widening it to OR would make the merged scan return
rows nothing above
+ // it filters out. Both propagation configs are on to show the merge
is declined regardless.
+ withSQLConf(
+ SQLConf.MERGE_SUBPLANS_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key ->
"true",
+
SQLConf.MERGE_SUBPLANS_DSV2_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key -> "true")
{
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE p = 1),
+ | (SELECT sum(b) FROM t WHERE p = 2)
+ |""".stripMargin)
+
+ // p = 1 selects ids 1, 5, 9, 13, 17; p = 2 selects ids 2, 6, 10,
14, 18.
+ checkAnswer(df, Row(45, 100))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 2,
+ s"scans with different partition filters must not be fused:\n" +
+ df.queryExecution.optimizedPlan)
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: do not merge file scans that read different nested
fields") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ spark.range(0, 20).selectExpr("id AS a", "named_struct('x', id, 'y', id
* 2) AS s")
+ .write.format("parquet").save(path)
+ Seq(true, false).foreach { nestedPruning =>
+ withClue(s"nestedSchemaPruning=$nestedPruning: ") {
+ withFileView("parquet", path) {
+ withSQLConf(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key ->
nestedPruning.toString) {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(s.x) FROM t),
+ | (SELECT sum(s.y) FROM t)
+ |""".stripMargin)
+
+ checkAnswer(df, Row(190, 380))
+ assertUsesFileSourceV2(df)
+ // Nested pruning narrows s to the one field each side reads, so
the read column is
+ // no longer a same-type subset of the relation's s and the
merge is declined -- the
+ // field ordinals in the extractors above the scan are resolved
against the narrowed
+ // type. Without pruning both scans read the whole struct and
are canonically equal,
+ // so they merge on PlanMerger's identical-plan path, which
needs no capability.
+ assert(distinctScans(df) == (if (nestedPruning) 2 else 1),
+ s"unexpected scan count:\n${df.queryExecution.optimizedPlan}")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: do not merge file scans that carry a pushed aggregate") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat("parquet", path)
+ withFileView("parquet", path) {
+ withSQLConf(SQLConf.PARQUET_AGGREGATE_PUSHDOWN_ENABLED.key -> "true") {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT max(a) FROM t),
+ | (SELECT max(b) FROM t)
+ |""".stripMargin)
+
+ checkAnswer(df, Row(19, 38))
+ assertUsesFileSourceV2(df)
+ // A pushed aggregate is built on a branch of V2ScanRelationPushDown
that never marks the
+ // scan mergeable, so the merge is declined before the capability is
consulted.
+ assert(distinctScans(df) == 2,
+ s"scans with a pushed aggregate must not be fused:\n" +
+ df.queryExecution.optimizedPlan)
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: do not merge file scans of different tables") {
+ withTempPath { dir1 =>
+ withTempPath { dir2 =>
+ writeFlat("parquet", dir1.getCanonicalPath)
+ writeFlat("parquet", dir2.getCanonicalPath)
+ withFileView("parquet", dir1.getCanonicalPath, viewName = "t1") {
+ withFileView("parquet", dir2.getCanonicalPath, viewName = "t2") {
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t1 WHERE c = 1),
+ | (SELECT sum(b) FROM t2 WHERE c = 1)
+ |""".stripMargin)
+
+ checkAnswer(df, Row(70, 140))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 2,
+ s"scans of different tables must remain separate:\n" +
+ df.queryExecution.optimizedPlan)
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: V1 and V2 file sources merge the same subquery shapes") {
+ val shapes = Seq(
+ ("differing projected columns",
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE c = 1),
+ | (SELECT sum(b) FROM t WHERE c = 1)
+ |""".stripMargin,
+ Row(70, 140)),
+ ("differing data filters",
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE a > 10),
+ | (SELECT sum(b) FROM t WHERE b > 10)
+ |""".stripMargin,
+ Row(135, 350)),
+ ("same partition filter, differing data filters",
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE p = 1 AND a > 4),
+ | (SELECT sum(b) FROM t WHERE p = 1 AND b > 20)
+ |""".stripMargin,
+ Row(44, 60)))
+
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writePartitioned(path)
+ shapes.foreach { case (shape, query, expected) =>
+ Seq(false, true).foreach { enableAQE =>
+ withClue(s"$shape, AQE=$enableAQE: ") {
+ val v1 = mergedCounts(path, query, expected, useV1 = true,
enableAQE)
+ val v2 = mergedCounts(path, query, expected, useV1 = false,
enableAQE)
+ assert(v1 == v2, s"V1 and V2 should merge alike; V1 got $v1, V2
got $v2")
+ assert(v1 == ((1, 1)), s"both paths should merge into a single
subquery; got $v1")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: V1 merges differing partition filters, V2 does not") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writePartitioned(path)
+ val query =
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t WHERE p = 1),
+ | (SELECT sum(b) FROM t WHERE p = 2)
+ |""".stripMargin
+ Seq(false, true).foreach { enableAQE =>
+ withClue(s"AQE=$enableAQE: ") {
+ // V1 keeps the partition filter in a Filter node until physical
planning, so symmetric
+ // propagation can widen it; on the V2 path V2ScanRelationPushDown
has already pushed it
+ // into the scan as a strict filter by the time MergeSubplans runs,
and strict filters
+ // have to be equal to merge. Both paths return the same rows.
+ assert(mergedCounts(path, query, Row(45, 100), useV1 = true,
enableAQE) == ((1, 1)))
+ assert(mergedCounts(path, query, Row(45, 100), useV1 = false,
enableAQE) == ((2, 0)))
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: merging widens the columns a text-based scan parses, as
it does on V1") {
+ // A record malformed only in the column the other scan reads. The parsers
are handed just the
+ // requested columns (spark.sql.csv.parser.columnPruning.enabled for CSV),
so which columns a
+ // scan reads decides which malformed values it notices.
+ val cases = Seq(
+ "csv" -> Seq("0,0", "1,10", "2,BAD", "3,30", "4,40"),
+ "json" -> Seq(
+ """{"a":0,"b":0}""",
+ """{"a":1,"b":10}""",
+ """{"a":2,"b":"BAD"}""",
+ """{"a":3,"b":30}""",
+ """{"a":4,"b":40}"""))
+ val query =
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t),
+ | (SELECT sum(b) FROM t)
+ |""".stripMargin
+
+ cases.foreach { case (format, lines) =>
+ withClue(s"format=$format: ") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ lines.toDS().write.text(path)
+
+ def rows(useV1: Boolean): Seq[Row] =
+ withFileView(format, path, useV1 = useV1, schema = Some("a long, b
long"),
+ options = Map("mode" -> "DROPMALFORMED")) {
+ sql(query).collect().toSeq
+ }
+
+ // Merging makes one scan parse both a and b, so the record
malformed in b is dropped for
+ // both aggregates and sum(a) is 8, not the 10 two separate scans
produce. The merged scan
+ // therefore reads fewer rows than the a-only scan did, which
SCAN_MERGING's "superset of
+ // their rows" premise does not allow. V1 already read the union
after merging, so the V2
+ // path now matches it.
+ assert(rows(useV1 = false) == Seq(Row(8, 80)))
+ assert(rows(useV1 = true) == rows(useV1 = false))
Review Comment:
**[efficiency / CONFIRMED]** `rows(useV1 = false)` is called in both
asserts, and each call re-registers the temp view and re-executes the whole
two-subquery query via `collect()` — one full redundant execution per format in
CI. Binding `val v2Rows = rows(useV1 = false)` once and reusing it in both
asserts eliminates the waste. Same pattern class elsewhere:
`withTempPath`/`writeFlat` sit inside the `dsv2Symmetric` flag loop in the
differing-data-filters test, writing identical data twice, where the
nested-fields test already hoists the write outside its flag loop.
--
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]