peter-toth commented on code in PR #58340:
URL: https://github.com/apache/spark/pull/58340#discussion_r3879451300


##########
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 for these shapes 
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. 
`PERMISSIVE` keeps the fields it did parse and `FAILFAST` rejects such a record 
either way, so neither of those modes changes. 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.planme
 rging.MergeSubplans` to `spark.sql.optimizer.excludedRules`.

Review Comment:
   **Finding 1.** The first remedy does not restore the previous behaviour.
   
   Both effects this bullet describes are already what V1 does. V1 merges two 
scans of the same relation that differ only in their projected columns, and the 
merged required schema is the union. So moving the format back into 
`spark.sql.sources.useV1SourceList` hands the user the *new* rows, not the old 
ones.
   
   Measured on this PR's head, with the suite's own data (`Seq("0,0", "1,10", 
"2,BAD", "3,30", "4,40")`, schema `a long, b long`, `mode=DROPMALFORMED`) and 
the suite's two-subquery query:
   
   | path | default | `MergeSubplans` in `excludedRules` |
   |---|---|---|
   | V2 | `[8, 80]` | `[10, 80]` |
   | V1 | `[8, 80]` | `[10, 80]` |
   
   The V1 column is already pinned two files over, at 
`FileSourceV2PlanMergingSuite.scala:476` (`assert(rows("DROPMALFORMED", useV1 = 
true) == droppedV2)`), so this sentence contradicts the test that ships with it.
   
   Only the `excludedRules` remedy works. Suggest ending the bullet with just 
that, e.g. "To restore the previous behavior, disable subplan merging by adding 
`org.apache.spark.sql.execution.planmerging.MergeSubplans` to 
`spark.sql.optimizer.excludedRules`; falling back to 
`spark.sql.sources.useV1SourceList` does not help, because the V1 path merges 
these shapes the same way."
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala:
##########
@@ -0,0 +1,493 @@
+/*
+ * 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.SparkException
+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, 
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 with V2ScanMergingTestHelper {
+  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 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}")
+    }
+  }
+
+  /** `(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) {

Review Comment:
   **Finding 4.** This helper never checks which read path it ran on, so the 
parity claim is asserted but not measured.
   
   The suite scaladoc says "every test goes through `DataFrameReader` and 
asserts the plan is V2 before asserting anything about merging". `mergedCounts` 
and the `rows` helper in the parse-mode test are the two that do not. In "V1 
and V2 file sources merge the same subquery shapes" the only assertions are `v1 
== v2` and `v1 == ((1, 1))`; if `USE_V1_SOURCE_LIST` ever stopped taking effect 
for a format, both arms would run V2, both would return `(1, 1)`, and the test 
would pass while measuring nothing about V1.
   
   "V1 merges differing partition filters, V2 does not" is safe already, 
because its two arms assert different values. Only this one needs the check:
   
   ```scala
           val df = sql(query)
           checkAnswer(df, expected)
           if (useV1) assertUsesFileSourceV1(df) else assertUsesFileSourceV2(df)
           subqueryCounts(df)
   ```
   
   with `assertUsesFileSourceV1` the one-line mirror of the existing helper 
(`collectWithSubqueries { case r: LogicalRelation => r }.nonEmpty`, and no 
`DataSourceV2ScanRelation`). Worth calling `assertUsesFileSourceV2` in the V2 
arm of the parse-mode `rows` too.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/V2ScanMergingTestHelper.scala:
##########
@@ -0,0 +1,40 @@
+/*
+ * 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
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
+
+/**
+ * Collects the DSv2 scans of a plan for the scan-merging suites in this 
package. Shared so that a
+ * change to how merged scans are collected reaches every suite that measures 
merging.
+ */
+private[planmerging] trait V2ScanMergingTestHelper {
+
+  protected 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.

Review Comment:
   **Finding 7.** "two means it was declined" is not true in the direction that 
matters.
   
   A decline between two scans that read the same columns still canonicalizes 
to one distinct scan, which is exactly the trap the nested-fields test 
documents at its `nestedPruning=false` arm. Reading it as stated is how that 
arm became vacuous in the first place. Suggest: "one distinct scan is 
consistent with a merge, two means it was declined; two identical scans 
canonicalize equal either way, so use `subqueryCounts` when the columns match."
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala:
##########
@@ -37,6 +37,14 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap
 import org.apache.spark.sql.util.SchemaUtils
 import org.apache.spark.util.ArrayImplicits._
 
+/**
+ * A [[Table]] backed by files.
+ *
+ * Subclasses inherit the `SCAN_MERGING` capability, which holds them to this: 
with the scan options
+ * held constant, what a scan reads is determined by the filters pushed and 
the columns pruned on
+ * its builder. A subclass whose `newScanBuilder` does not meet that has to 
override
+ * [[capabilities]] to drop `SCAN_MERGING`, or Spark may fuse two of its scans 
into one.

Review Comment:
   **Finding 5.** The opt-out this paragraph prescribes cannot reuse the base 
set.
   
   `FileTable.CAPABILITIES` is `private` to the companion, so a subclass 
following this advice has to write `util.EnumSet.of(BATCH_READ, BATCH_WRITE)` 
from scratch. It then silently drops whatever the base set gains later: the 
next capability added to `FileTable` would reach every built-in format but not 
that subclass, and nothing would fail.
   
   A seam on the class makes the opt-out one line, keeps the base set in one 
place, and is testable:
   
   ```scala
     /** Whether this table meets the `SCAN_MERGING` contract described above. 
*/
     protected def supportsScanMerging: Boolean = true
   
     override def capabilities: java.util.Set[TableCapability] =
       if (supportsScanMerging) FileTable.CAPABILITIES_WITH_SCAN_MERGING else 
FileTable.CAPABILITIES
   ```
   
   This is also the seam finding 6 would need, if you take that route.
   



-- 
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]

Reply via email to