This is an automated email from the ASF dual-hosted git repository.
LuciferYang pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 69e1cb10f435 [SPARK-57205][SQL] Declare SCAN_MERGING on the built-in
file sources
69e1cb10f435 is described below
commit 69e1cb10f43554035c6a8d13239dd7512e0b3a2f
Author: YangJie <[email protected]>
AuthorDate: Tue Sep 1 11:44:33 2026 +0800
[SPARK-57205][SQL] Declare SCAN_MERGING on the built-in file sources
### What changes were proposed in this pull request?
`FileTable` gains a `supportsScanMerging` seam, and `ParquetTable`,
`OrcTable`, `TextTable` and `AvroTable` override it. Those four then take part
in the DSv2 scan merging added by #57360 (SPARK-40259): `PlanMerger` drives
`V2ScanRelationPushDown.rebuildScan` to rebuild a merged scan, and a source
supplies no merge logic of its own. It only declares that widening the set of
columns pruned on its builder, with the scan options and pushed filters held
constant, changes neither which row [...]
`CSVTable` and `JsonTable` do not override it. Their parsers are handed the
columns the scan asked for and decide from that set what counts as a malformed
record, so a merged scan reading the union of two column sets can drop or
rewrite rows the narrower scan returned. Measured, csv and json alike: with
`mode=DROPMALFORMED` and a record malformed only in the other subquery's
column, `sum(a)` is 8 where two separate scans give 10; with `PERMISSIVE`, the
default, and `_corrupt_record` i [...]
Two further gates keep the contract true of the formats that do declare it.
`FileTable` withholds the capability when
`spark.sql.files.ignoreCorruptFiles` or `spark.sql.files.ignoreMissingFiles` is
set, matching `FileScanRDD.hasStrictFileReads` on the physical side. Under a
non-strict read a failure in a column that only the sibling subquery projects
is swallowed and the rest of that file's rows go with it, so the merged scan
returns fewer rows than the narrow one did. Measured on parquet, V1 and V2
alike: `SELECT (SELECT sum(a) FROM t), (SELECT count(b) FROM [...]
`AvroTable` withholds it under `positionalFieldMatching`.
`AvroPartitionReaderFactory` builds the deserializer from the pruned read
schema while the Avro side stays the full Avro schema, so under that option
catalyst field *i* of the projection takes Avro field *i* of that schema, and
widening the projection changes the values a column comes back with. That is a
bug in its own right, filed as SPARK-59108 (#58409), which lands after this one
and deletes this gate with it. ORC needs no [...]
The rest is a new test suite, a package-private `V2ScanMergingTestHelper`
shared with `DSv2PlanMergingSuite`, one test in `AvroV2Suite`, and one
documentation update.
Landing order: #58411 (SPARK-59107) first, then this one, then #58409
(SPARK-59108), which deletes the `AvroTable` gate. Nothing here depends on the
first step, since this PR no longer asserts anything about how V1 merges CSV
and JSON. Once #58411 is in, `FileTable.hasStrictFileReads` should call the
`FileSourceOptions.hasStrictFileReads` it adds rather than spell the predicate
out a third time, beside `FileScanRDD` and the cache-repeatability check in
`InMemoryRelation`.
### Why are the changes needed?
Two scans of the same file table that differ only in their projected
columns cannot be reused today. A file source folds its data filters into the
`FileScan` object, where they are used to list files and prune row groups, and
`FileScan.equals` compares them, so two subquery scans over the same path are
not canonically identical and `PlanMerger`'s identical-plan fast path does not
fire. On the V1 path those filters sit in a `Filter` above an identical
`LogicalRelation`, so it does fire [...]
On TPC-DS at scale factor 100, with `spark.sql.sources.useV1SourceList`
cleared, two queries change: q9 goes from 16 distinct scans to 6 under the
default configuration and to 2 with `dsv2SymmetricFilterPropagation` on, and
q28 from 6 to 1 with that configuration on. Of the 99 v1.4 queries, 95 ran and
the other 93 of those are unchanged. Wall clock for q9 went from 78.1s to 40.3s
by default and to 21.6s with the configuration on, and q28 from 61.3s to 34.4s;
measured on `local[1]` wit [...]
### Does this PR introduce _any_ user-facing change?
Yes, on the V2 file source read path, which a format reaches only when it
is removed from `spark.sql.sources.useV1SourceList`.
Plan shape only: subqueries over the same Parquet, ORC, text or Avro table
that differ only in their projected columns now collapse into a single scan
reading the union of those columns. No query result changes, which is what the
exclusions and the two gates above are for, and why this needs no
migration-guide entry. The `dsv2SymmetricFilterPropagation` entry in
`docs/sql-performance-tuning.md` said "no built-in source does", which this PR
falsifies. The four formats and the two withh [...]
Four gaps are left in place. The first three are follow-ups #57360 already
lists.
1. Two scans with different partition filters do not merge, while V1 merges
them. A partition filter is fully enforced by the V2 scan and reported as
strict, so widening it to `OR` would leave the merged scan returning rows
nothing above it filters out.
2. Parquet and ORC nested columns do not merge while nested schema pruning
is on. Each side narrows the struct to the field it reads, so the read column
is no longer a same-type subset of the relation's column. V1 does not merge
this shape either, because `SchemaPruning` rewrites each side's `dataSchema`
and the two relations stop being canonically equal.
3. Differing data filters need
`spark.sql.optimizer.mergeSubplans.filterPropagation.dsv2SymmetricFilterPropagation.enabled`,
which defaults to false. On the V2 path that configuration alone is enough,
where V1 needs the broader `symmetricFilterPropagation`.
4. CSV and JSON get no merging at all, which is deliberate rather than a
follow-up. It costs them two shapes: scans whose projected columns differ, and
scans reading the same columns under different filters, since `FileScan.equals`
compares the normalized data filters so those are not canonically equal either
and do not fall back to plain reuse. The V1 path merges both today and answers
`[8, 80]` where two separate scans answer `[10, 80]`, so one subquery's result
depends on what a si [...]
### How was this patch tested?
New suite `FileSourceV2PlanMergingSuite`, 16 tests. Every built-in file
table is checked on the side it belongs to; a non-strict read is checked to
withhold the capability on both strictness configurations, and separately to
keep its scans separate and return `[45, 0]`, with the table and the temp view
built outside the configuration scope so that a cached gate would fail the
test. Those are two tests rather than one so that a mutation names which half
it broke, the capability asserti [...]
Then V1/V2 parity on three shapes and the partition-filter gap pinned as
the one shape where they disagree, both on parquet, whose reads this suite pins
strict and whose parser does not depend on the projection, so SPARK-59107
leaves them merging on V1. The CSV and JSON parse behaviour is pinned on the V2
side alone, on three shapes: `DROPMALFORMED` gives `[10, 80]`, `PERMISSIVE`
with `_corrupt_record` in the schema gives `[0, 80]`, and `FAILFAST` with a
short CSV row returns rows rat [...]
Every test asserts which read path the plan took before asserting anything
about merging. SQL-on-file and catalog tables resolve to the V1 `FileFormat`
regardless of `spark.sql.sources.useV1SourceList`, so a suite driven by that
configuration alone would quietly run V1 on both sides.
One test in `AvroV2Suite`, because `AvroTable` lives in the module that has
it on the classpath: it asserts the capability, that `positionalFieldMatching`
withholds it, and that two scans differing only in their projected columns fuse
into one reading the union.
Mutation checks. Turning the three sql/core overrides to `false` fails 8 of
the 16 tests; of the 8 that pass, 6 assert a decline, one asserts that V1
merges differing partition filters where V2 does not and V2 declines under the
mutation too, and one asserts that a non-strict read keeps its scans separate,
which the mutation also produces. Dropping `&& hasStrictFileReads` from
`capabilities` fails both strictness tests, the capability one at its first
assertion and the rows one with ` [...]
Regression: the `planmerging` suites, `ExplainSuite` and `ExplainSuiteAE`,
`FileBasedDataSourceSuite`, `FileTableSuite`, `OrcV2SchemaPruningSuite`,
`ParquetV2SchemaPruningSuite`, `ParquetV2FilterSuite`, `SubquerySuite`,
`SameResultSuite`, `ParquetV2AggregatePushDownSuite`,
`OrcV2AggregatePushDownSuite`, `DataSourceV2Suite` and `AvroV2Suite`.
No golden file or `PlanStabilitySuite` plan needed regenerating. Those
build their tables with `CREATE TABLE ... USING <format>`, which resolves to
the V1 `FileFormat`, so none of them reaches a V2 file scan.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
Closes #58340 from LuciferYang/SPARK-57205.
Authored-by: YangJie <[email protected]>
Signed-off-by: yangjie01 <[email protected]>
(cherry picked from commit 484866bd80dcc153202103572f4c96ef6240ad87)
Signed-off-by: yangjie01 <[email protected]>
---
.../org/apache/spark/sql/v2/avro/AvroTable.scala | 15 +-
.../org/apache/spark/sql/avro/AvroSuite.scala | 47 +-
docs/sql-performance-tuning.md | 4 +-
.../sql/connector/catalog/TableCapability.java | 6 +
.../sql/execution/datasources/v2/FileTable.scala | 45 +-
.../execution/datasources/v2/orc/OrcTable.scala | 6 +
.../datasources/v2/parquet/ParquetTable.scala | 6 +
.../execution/datasources/v2/text/TextTable.scala | 4 +
.../planmerging/DSv2PlanMergingSuite.scala | 9 +-
.../planmerging/FileSourceV2PlanMergingSuite.scala | 697 +++++++++++++++++++++
.../planmerging/V2ScanMergingTestHelper.scala | 41 ++
11 files changed, 869 insertions(+), 11 deletions(-)
diff --git
a/connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroTable.scala
b/connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroTable.scala
index abcea9a2a238..7b6db3c16c5b 100644
--- a/connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroTable.scala
+++ b/connector/avro/src/main/scala/org/apache/spark/sql/v2/avro/AvroTable.scala
@@ -21,7 +21,7 @@ import scala.jdk.CollectionConverters._
import org.apache.hadoop.fs.FileStatus
import org.apache.spark.sql.SparkSession
-import org.apache.spark.sql.avro.AvroUtils
+import org.apache.spark.sql.avro.{AvroOptions, AvroUtils}
import org.apache.spark.sql.connector.write.{LogicalWriteInfo, Write,
WriteBuilder}
import org.apache.spark.sql.execution.datasources.FileFormat
import org.apache.spark.sql.execution.datasources.v2.FileTable
@@ -52,4 +52,17 @@ case class AvroTable(
override def supportsDataType(dataType: DataType): Boolean =
AvroUtils.supportsDataType(dataType)
override def formatName: String = "Avro"
+
+ // Avro has no record-level parse verdict: a record is either decodable or
the read fails, and
+ // there is no mode that drops or rewrites a record based on the columns
asked for. The `mode`
+ // option in AvroOptions is read by from_avro and schema_of_avro, not by
this scan.
+ //
+ // `positionalFieldMatching` is the exception. AvroPartitionReaderFactory
builds the deserializer
+ // from the pruned read schema while the Avro side stays the full Avro
schema, so under that
+ // option catalyst field i of the projection takes Avro field i of that
schema, and widening the
+ // projection changes the values a column comes back with. Read the option
off the map rather than
+ // through AvroOptions, whose constructor resolves `avroSchemaUrl` and would
do I/O here, and read
+ // it leniently so a malformed value still fails where Avro reports it
rather than here.
+ override protected def supportsScanMerging: Boolean =
+
!"true".equalsIgnoreCase(options.get(AvroOptions.POSITIONAL_FIELD_MATCHING))
}
diff --git
a/connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala
b/connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala
index 813401773834..f4e27dd7f038 100644
--- a/connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala
+++ b/connector/avro/src/test/scala/org/apache/spark/sql/avro/AvroSuite.scala
@@ -42,9 +42,10 @@ import
org.apache.spark.sql.catalyst.expressions.AttributeReference
import org.apache.spark.sql.catalyst.plans.logical.Filter
import org.apache.spark.sql.catalyst.util.DateTimeTestUtils
import
org.apache.spark.sql.catalyst.util.DateTimeTestUtils.{withDefaultTimeZone, LA,
UTC}
+import org.apache.spark.sql.connector.catalog.TableCapability
import org.apache.spark.sql.execution.{FormattedMode, SparkPlan}
import org.apache.spark.sql.execution.datasources.{CommonFileDataSourceSuite,
DataSource, FilePartition}
-import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
FileDataSourceV2, FileTable}
+import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
DataSourceV2ScanRelation, FileDataSourceV2, FileTable}
import org.apache.spark.sql.functions._
import org.apache.spark.sql.internal.LegacyBehaviorPolicy
import org.apache.spark.sql.internal.LegacyBehaviorPolicy._
@@ -3937,6 +3938,50 @@ class AvroV2Suite extends AvroSuite with
ExplainSuiteHelper {
s"V2 formatName '${v2Table.formatName}' != V1 toString
'${v1Format.toString}'")
}
+ test("SPARK-57205: Avro V2 declares SCAN_MERGING and merges scans differing
only in columns") {
+ // AvroTable withholds the capability under positionalFieldMatching,
because the deserializer is
+ // built from the pruned read schema while the Avro side stays unpruned,
so catalyst field i of
+ // the projection takes Avro field i of that schema and widening the
projection shifts values.
+ // FileTable also withholds it when the reads are not strict, so pin that
rather than inherit.
+ withSQLConf(
+ SQLConf.IGNORE_CORRUPT_FILES.key -> "false",
+ SQLConf.IGNORE_MISSING_FILES.key -> "false") {
+ val v2Provider = DataSource.lookupDataSourceV2("avro",
spark.sessionState.conf)
+ assert(v2Provider.isDefined)
+ val dsV2 = v2Provider.get.asInstanceOf[FileDataSourceV2]
+ val v2Table = dsV2.getTable(
+ new StructType(), Array.empty, JCollections.emptyMap[String, String]())
+ assert(v2Table.capabilities().contains(TableCapability.SCAN_MERGING))
+ val positional = dsV2.getTable(new StructType(), Array.empty,
+ JCollections.singletonMap("positionalFieldMatching", "true"))
+ assert(!positional.capabilities().contains(TableCapability.SCAN_MERGING))
+
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ spark.range(0, 20).selectExpr("id AS a", "id * 2 AS b", "id % 3 AS c")
+ .write.format("avro").save(path)
+ withTempView("avro_scan_merging") {
+
spark.read.format("avro").load(path).createOrReplaceTempView("avro_scan_merging")
+ val df = sql(
+ """
+ |SELECT
+ | (SELECT sum(a) FROM avro_scan_merging WHERE c = 1),
+ | (SELECT sum(b) FROM avro_scan_merging WHERE c = 1)
+ |""".stripMargin)
+ checkAnswer(df, Row(70, 140))
+ val scans = df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case s: DataSourceV2ScanRelation => s
+ }
+ assert(scans.map(_.canonicalized).distinct.length == 1,
+ s"the two Avro scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ // c is read because the filter stays above the merged scan.
+ assert(scans.head.output.map(_.name).toSet == Set("a", "b", "c"),
+ s"the merged scan should read the union of both columns; got
${scans.head.output}")
+ }
+ }
+ }
+ }
+
test("Geospatial types are not supported in Avro") {
withTempDir { dir =>
// Temporary directory for writing the test data.
diff --git a/docs/sql-performance-tuning.md b/docs/sql-performance-tuning.md
index fee8801086c4..eff53e60d8c1 100644
--- a/docs/sql-performance-tuning.md
+++ b/docs/sql-performance-tuning.md
@@ -342,6 +342,8 @@ They are merged into one aggregate that computes `min` and
`max` together, so `s
Two subplans are merged when their plans match node by node: `Project` lists
are unioned, `Aggregate`s must have the same grouping and use the same
aggregation implementation (so a `min` is not merged with a `collect_list`),
`Filter`s must have the same condition, `Join`s must have the same type,
condition and hints, and the leaves must read the same input. Subplans that
differ only in their `WHERE` conditions can be merged as well, by turning each
side's condition into a boolean column [...]
+On the DataSource V2 read path the requirement that the leaves read the same
input is relaxed for a source that declares the `SCAN_MERGING` table
capability: two leaves that differ only in their projected columns merge into a
single scan reading the union of those columns. Among the built-in file formats
Parquet, ORC, text and Avro declare it; a format reaches its V2 read path only
when it is removed from `spark.sql.sources.useV1SourceList`. A file table
withholds the capability when `sp [...]
+
When only one of the two subplans has a filter, merging is always beneficial,
because the unfiltered side reads all the data anyway. This case is on by
default, unless the filter has to cross a `Join` to reach the aggregate, which
needs the through-join configuration below. When both sides have a filter (the
symmetric case), the merged scan filter becomes `OR(f1, f2)`, which is less
selective than either original filter and can therefore read more data - for
example when the filters prun [...]
Still, it is worth considering on queries that compute several differently
filtered aggregates over the same table, which is a common analytical shape:
@@ -384,7 +386,7 @@ In TPC-DS benchmark runs, enabling symmetric filter
propagation made `q9` and `q
<td><code>spark.sql.optimizer.mergeSubplans.filterPropagation.dsv2SymmetricFilterPropagation.enabled</code></td>
<td>false</td>
<td>
- When true, two DataSource V2 scans that pushed the same strictly
enforced filters but carry different best-effort (post-scan) filters can be
merged even when
<code>spark.sql.optimizer.mergeSubplans.filterPropagation.symmetricFilterPropagation.enabled</code>
is false. In this case widening cannot change the set of rows the scan is
required to return, as the strict filters are re-pushed unchanged and the
enclosing <code>Filter</code> re-checks the rest above the scan. This applies
on [...]
+ When true, two DataSource V2 scans that pushed the same strictly
enforced filters but carry different best-effort (post-scan) filters can be
merged even when
<code>spark.sql.optimizer.mergeSubplans.filterPropagation.symmetricFilterPropagation.enabled</code>
is false. In this case widening cannot change the set of rows the scan is
required to return, as the strict filters are re-pushed unchanged and the
enclosing <code>Filter</code> re-checks the rest above the scan. This applies
on [...]
</td>
<td>4.3.0</td>
</tr>
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableCapability.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableCapability.java
index 599e801f2a07..e98cf7c11a05 100644
---
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableCapability.java
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/TableCapability.java
@@ -139,6 +139,12 @@ public enum TableCapability {
* obtaining a fresh {@link org.apache.spark.sql.connector.read.ScanBuilder}
with the same options
* and re-applying the same pushed filters and pruned columns yields an
equivalent scan.
* <p>
+ * Determinism alone is not enough: widening the set of pruned columns, with
the options and
+ * pushed filters held constant, must not change which rows the scan returns
nor the values it
+ * returns for the columns already asked for. It may at most surface a read
error. A source whose
+ * parser decides what counts as a malformed record from the set of columns
it was asked for does
+ * not meet this, and neither does one that resolves a column by its
position in the projection.
+ * <p>
* Given that contract, Spark builds the merged scan itself: it prunes a
fresh ScanBuilder to the
* union of both read schemas, re-pushes the (possibly OR-widened) filters,
and builds. The merged
* scan reads the union of the two scans' columns and a superset of their
rows; each original
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
index 8941a4c8d8c7..41b4a6196d5f 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/FileTable.scala
@@ -37,6 +37,18 @@ 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.
+ *
+ * A subclass opts in to the `SCAN_MERGING` capability by overriding
[[supportsScanMerging]], which
+ * holds it to this: with the scan options and the pushed filters held
constant, widening the set of
+ * columns pruned on its builder must not change which rows the scan returns,
nor the values it
+ * returns for the columns it was already asked for. It may at most surface a
read error. A format
+ * whose parser decides what counts as a malformed record from the set of
columns it was asked for
+ * does not meet that, and neither does one that resolves a column by its
position in the
+ * projection. The capability is also withheld from a table whose reads are
not strict, see
+ * `hasStrictFileReads`.
+ */
abstract class FileTable(
sparkSession: SparkSession,
options: CaseInsensitiveStringMap,
@@ -111,7 +123,32 @@ abstract class FileTable(
override def properties: util.Map[String, String] =
options.asCaseSensitiveMap
- override def capabilities: java.util.Set[TableCapability] =
FileTable.CAPABILITIES
+ override def capabilities: java.util.Set[TableCapability] =
+ if (supportsScanMerging && hasStrictFileReads) {
+ FileTable.CAPABILITIES_WITH_SCAN_MERGING
+ } else {
+ FileTable.CAPABILITIES
+ }
+
+ /**
+ * Whether this table meets the `SCAN_MERGING` contract described on this
class. Defaults to
+ * false: a format that does not merge only misses an optimization, while a
format that merges
+ * when its parser is projection-sensitive returns wrong rows.
+ */
+ protected def supportsScanMerging: Boolean = false
+
+ /**
+ * Whether a read of this table is strict. Under `ignoreCorruptFiles`, a
read failure in a column
+ * that only the other scan projects is swallowed and the remaining rows of
that file are dropped,
+ * so the merged scan would not read a superset of either input's rows.
`ignoreMissingFiles` drops
+ * the same rows whatever is projected, and is included to match
`FileScanRDD.hasStrictFileReads`,
+ * the same predicate on the physical side. Evaluated per call rather than
cached, so a table
+ * built before either configuration was set still answers for the read that
is running.
+ */
+ private def hasStrictFileReads: Boolean = {
+ val fileSourceOptions = new
FileSourceOptions(options.asCaseSensitiveMap.asScala.toMap)
+ !fileSourceOptions.ignoreCorruptFiles &&
!fileSourceOptions.ignoreMissingFiles
+ }
/**
* When possible, this method should return the schema of the given `files`.
When the format
@@ -182,4 +219,10 @@ abstract class FileTable(
object FileTable {
private val CAPABILITIES = util.EnumSet.of(BATCH_READ, BATCH_WRITE)
+
+ // For the formats that override supportsScanMerging. `fileIndex` is a lazy
val, so every scan
+ // built from one table lists the same files, and `newScanBuilder` returns a
fresh builder over
+ // `mergedOptions(options)`.
+ private val CAPABILITIES_WITH_SCAN_MERGING =
+ util.EnumSet.of(BATCH_READ, BATCH_WRITE, SCAN_MERGING)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/orc/OrcTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/orc/OrcTable.scala
index 08cd89fdacc6..b3aaf10ccc2b 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/orc/OrcTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/orc/OrcTable.scala
@@ -68,4 +68,10 @@ case class OrcTable(
}
override def formatName: String = "ORC"
+
+ // A row is decoded from the columns the scan asked for, so under strict
file reads reading more
+ // columns can only surface an error, never silently change which rows come
back. When the read is
+ // not strict that error is swallowed and the rest of the file's rows go
with it, which is why
+ // FileTable withholds the capability there.
+ override protected def supportsScanMerging: Boolean = true
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
index 67052c201a9d..768e1ce329f5 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetTable.scala
@@ -70,4 +70,10 @@ case class ParquetTable(
}
override def formatName: String = "Parquet"
+
+ // A row is decoded from the column chunks the scan asked for, so under
strict file reads reading
+ // more columns can only surface an error, never silently change which rows
come back. When the
+ // read is not strict that error is swallowed and the rest of the file's
rows go with it, which is
+ // why FileTable withholds the capability there.
+ override protected def supportsScanMerging: Boolean = true
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/text/TextTable.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/text/TextTable.scala
index 289c41ca1d0b..5fcb4d3b3af2 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/text/TextTable.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/text/TextTable.scala
@@ -50,4 +50,8 @@ case class TextTable(
dataType.isInstanceOf[StringType]
override def formatName: String = "Text"
+
+ // The schema is a single `value` column, and every line -- or every file
under `wholetext` --
+ // becomes a row, so there is no parse step whose outcome a wider column set
could change.
+ override protected def supportsScanMerging: Boolean = true
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/DSv2PlanMergingSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/DSv2PlanMergingSuite.scala
index 86bbe951ab9b..b426b27acac1 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/DSv2PlanMergingSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/DSv2PlanMergingSuite.scala
@@ -23,7 +23,7 @@ import org.apache.spark.sql.{DataFrame, QueryTest, Row}
import org.apache.spark.sql.catalyst.plans.physical.KeyedPartitioning
import org.apache.spark.sql.connector.FakeV2ProviderWithCustomSchema
import
org.apache.spark.sql.connector.catalog.{InMemoryScanMergingPartitionFilterCatalog,
InMemoryScanMergingReportingCatalog}
-import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
DataSourceV2Relation, DataSourceV2ScanRelation}
+import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
DataSourceV2Relation}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
@@ -37,7 +37,7 @@ import org.apache.spark.sql.test.SharedSparkSession
* mis-classify it as non-strict and decline the merge (leaving two scans).
*/
class DSv2PlanMergingSuite extends QueryTest with SharedSparkSession
- with BeforeAndAfter {
+ with BeforeAndAfter with V2ScanMergingTestHelper {
private val v2Source = classOf[FakeV2ProviderWithCustomSchema].getName
private val tbl = "scanmerge.t"
@@ -56,11 +56,6 @@ class DSv2PlanMergingSuite extends QueryTest with
SharedSparkSession
spark.conf.unset("spark.sql.catalog.scanmergereport")
}
- private def v2Scans(df: DataFrame): Seq[DataSourceV2ScanRelation] =
- df.queryExecution.optimizedPlan.collectWithSubqueries {
- case s: DataSourceV2ScanRelation => s
- }
-
// A successful DSv2 merge builds the scan and leaves NO bare
DataSourceV2Relation in the plan.
// A leaked deferred scan (e.g. if a future recursion arm forwarded
`deferredScan` without
// building it) would surface as an unbuilt placeholder relation the read
path cannot plan --
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala
new file mode 100644
index 000000000000..51fb31be8ee3
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/FileSourceV2PlanMergingSuite.scala
@@ -0,0 +1,697 @@
+/*
+ * 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.SparkConf
+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,
FileScan, FileTable}
+import org.apache.spark.sql.execution.datasources.v2.parquet.ParquetScan
+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).
+ *
+ * Parquet, ORC, text and Avro override [[FileTable.supportsScanMerging]], so
[[PlanMerger]] may
+ * fuse two of their scans of the same table. Scans that differ only in their
projected columns
+ * merge under the default configuration; scans whose data filters differ need
one of the symmetric
+ * filter propagation configurations, and scans whose partition filters differ
never merge, because
+ * for a file source the partition filters are the strictly enforced ones and
the data filters are
+ * best-effort. The capability is also withheld from a table whose reads are
not strict.
+ *
+ * CSV and JSON do not override it. Their parsers are handed the columns the
scan asked for, at
+ * least while `spark.sql.csv.parser.columnPruning.enabled` is on for CSV, and
decide from that set
+ * what counts as a malformed record.
+ *
+ * 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
+ * which read path the plan took before asserting anything about merging.
+ */
+class FileSourceV2PlanMergingSuite extends QueryTest with SharedSparkSession
+ with AdaptiveSparkPlanHelper with V2ScanMergingTestHelper {
+ import testImplicits._
+
+ // Pin what the merge decision now depends on, and what the subquery-count
measure depends on, so
+ // a changed default fails in one legible place rather than inverting every
assertion below. Tests
+ // that vary one of these set it themselves.
+ override protected def sparkConf: SparkConf = super.sparkConf
+ .set(SQLConf.IGNORE_CORRUPT_FILES, false)
+ .set(SQLConf.IGNORE_MISSING_FILES, false)
+ .set(SQLConf.MERGE_SUBPLANS_FILTER_PROPAGATION_ENABLED, true)
+ .set(SQLConf.SUBQUERY_REUSE_ENABLED, true)
+
+ // The formats in sql/core that declare SCAN_MERGING and have more than one
column. Avro also
+ // declares it but lives in connector/avro; text declares it but has only
`value`.
+ private val mergingFormats = Seq("parquet", "orc")
+
+ // These do not declare it: the parser is handed the columns the scan asked
for, so widening the
+ // column set changes which records it treats as malformed.
+ private val projectionSensitiveFormats = Seq("csv", "json")
+
+ private val flatSchema = "a long, b long, c long, d long"
+
+ private def writeFlat(format: String, path: String, start: Long = 0): Unit =
+ spark.range(start, start + 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`. The view
+ * is created inside the `USE_V1_SOURCE_LIST` scope on purpose: a temp view
stores its analyzed
+ * plan, so which read path it takes is fixed when the view is created, not
when it is queried.
+ *
+ * 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}")
+ }
+ }
+
+ private def assertUsesFileSourceV1(df: DataFrame): Unit = {
+ val plan = df.queryExecution.optimizedPlan
+ assert(plan.collectWithSubqueries { case r: LogicalRelation => r
}.nonEmpty,
+ s"expected the V1 file source path, but the plan has no V1
relation:\n$plan")
+ assert(v2Scans(df).isEmpty, s"expected no DSv2 file scan on the V1
path:\n$plan")
+ }
+
+ /** `(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 AQE as given and
+ * 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)
+ if (useV1) assertUsesFileSourceV1(df) else assertUsesFileSourceV2(df)
+ subqueryCounts(df)
+ }
+ }
+ }
+
+ test("SPARK-57205: which built-in file tables declare SCAN_MERGING") {
+ // Avro is covered in AvroV2Suite, the module that has AvroTable on the
classpath.
+ Seq("parquet" -> true, "orc" -> true, "text" -> true, "csv" -> false,
"json" -> false)
+ .foreach { case (format, declares) =>
+ 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) == declares,
+ s"${table.getClass.getSimpleName}.capabilities() should " +
+ s"${if (declares) "declare" else "not declare"}
SCAN_MERGING")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: withhold SCAN_MERGING from a table whose reads are not
strict") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ spark.range(0, 10).selectExpr("id AS a", "cast(id AS string) AS
b").write.parquet(path)
+ // The table is built outside the strictness scope on purpose: the gate
is evaluated per call,
+ // so it has to answer for the read that is running rather than for the
read that built it.
+ withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> "") {
+ val relations = spark.read.schema("a long, b long").parquet(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.capabilities().contains(TableCapability.SCAN_MERGING),
+ "a strict read should declare SCAN_MERGING")
+ // Both halves of the strictness predicate, on the table that was
built while both were off.
+ Seq(SQLConf.IGNORE_CORRUPT_FILES.key,
SQLConf.IGNORE_MISSING_FILES.key).foreach { conf =>
+ withClue(s"$conf=true: ") {
+ withSQLConf(conf -> "true") {
+
assert(!table.capabilities().contains(TableCapability.SCAN_MERGING),
+ s"$conf should withhold SCAN_MERGING")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Separate from the capability test above so that a change breaking the
gate names which half it
+ // broke: an assertion that aborts on the capability never reports on the
rows.
+ test("SPARK-57205: a non-strict read keeps its scans separate") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ // b is written as a string and read as a long, so the reader fails only
once it reads b.
+ spark.range(0, 10).selectExpr("id AS a", "cast(id AS string) AS
b").write.parquet(path)
+ // The scans stay separate, so the a-only scan never reads b and sum(a)
is still exact. If
+ // they merged, reading b would fail, ignoreCorruptFiles would swallow
it and drop the rest of
+ // the file, and sum(a) would come back null over rows nothing above the
scan removed. The
+ // view is registered before the conf is set, so a cached gate would
answer from the strict
+ // read.
+ withFileView("parquet", path, schema = Some("a long, b long")) {
+ withSQLConf(SQLConf.IGNORE_CORRUPT_FILES.key -> "true") {
+ val df = sql("SELECT (SELECT sum(a) FROM t), (SELECT count(b) FROM
t)")
+ checkAnswer(df, Row(45, 0))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 2,
+ s"the two scans should stay
separate:\n${df.queryExecution.optimizedPlan}")
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: merge two file scans that differ only in their projected
columns") {
+ mergingFormats.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.
+ val mergedOutput = v2Scans(df).head.output
+ assert(mergedOutput.map(_.name).toSet == Set("a", "b", "c"),
+ s"the merged scan should read the union of both columns; got
$mergedOutput")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: merge two text scans that differ only in their projected
columns") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ Seq("a", "bb", "ccc").toDS().write.text(path)
+ withFileView("text", path) {
+ // A text table has the single column `value`, so the only projection
difference reachable
+ // is an empty read set against `[value]`. Both aggregates have to be
hash-aggregatable or
+ // PlanMerger's supportedAggregateMerge declines above the scans,
before the capability is
+ // reached: max(value) over a string is neither hash nor object-hash,
sum(length(value)) is.
+ val df = sql("SELECT (SELECT count(*) FROM t), (SELECT
sum(length(value)) FROM t)")
+ checkAnswer(df, Row(3, 6))
+ assertUsesFileSourceV2(df)
+ assert(distinctScans(df) == 1,
+ s"the two scans should be fused into
one:\n${df.queryExecution.optimizedPlan}")
+ val mergedOutput = v2Scans(df).head.output
+ assert(mergedOutput.map(_.name) == Seq("value"),
+ s"the merged scan should read value; got $mergedOutput")
+ }
+ }
+ }
+
+ test("SPARK-57205: do not merge CSV or JSON scans that differ in their
projected columns") {
+ projectionSensitiveFormats.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)
+
+ checkAnswer(df, Row(70, 140))
+ assertUsesFileSourceV2(df)
+ // Same shape as the test above, which merges for parquet and orc.
These two decline
+ // because neither table declares SCAN_MERGING, which is what
keeps the union of the
+ // columns out of the parser. Both measures are meaningful here:
the two scans read
+ // different columns, so they do not canonicalize equal either.
+ assert(distinctScans(df) == 2,
+ s"the two scans should stay
separate:\n${df.queryExecution.optimizedPlan}")
+ assert(subqueryCounts(df) == ((2, 0)),
+ s"unexpected subquery
counts:\n${df.queryExecution.executedPlan}")
+ }
+ }
+ }
+ }
+ }
+
+ 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.
+ assert(scan.output.map(_.name).toSet == Set("a", "b"),
+ s"the merged scan should read the union of both columns; got
${scan.output}")
+ // The rebuilt scan has to carry the filter or it would read all four
partitions. Read it
+ // off the FileScan rather than off `pushedFilters`, which each
unmerged scan records too.
+ val partitionFilters = scan.scan match {
+ case f: FileScan => f.partitionFilters
+ case other => fail(s"expected a FileScan, got
${other.getClass.getSimpleName}")
+ }
+ assert(partitionFilters.exists(_.references.exists(_.name == "p")),
+ s"the merged scan should still enforce the partition filter; got
$partitionFilters")
+ }
+ }
+ }
+
+ 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}")
+ val mergedOutput = v2Scans(df).head.output
+ assert(mergedOutput.map(_.name).toSet == Set("a", "b", "c", "d"),
+ s"the merged scan should read the union of all three; got
$mergedOutput")
+ }
+ }
+ }
+
+ test("SPARK-57205: merge file scans with differing data filters only when
dsv2 symmetric " +
+ "filter propagation is on") {
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ writeFlat("parquet", path)
+ withFileView("parquet", path) {
+ Seq(true, false).foreach { dsv2Symmetric =>
+ withClue(s"dsv2SymmetricFilterPropagation=$dsv2Symmetric: ") {
+ // The generic symmetric propagation would enable this merge on
its own, so pin it off:
+ // the point of the test is that the dsv2 configuration alone
decides.
+ withSQLConf(
+
SQLConf.MERGE_SUBPLANS_SYMMETRIC_FILTER_PROPAGATION_ENABLED.key -> "false",
+
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}")
+ if (dsv2Symmetric) {
+ // The widened predicate has to reach the rebuilt scan, or the
merge would keep the
+ // answer right through the enclosing Filter while losing the
row-group pruning that
+ // is the whole point. checkAnswer and the scan count both
stay green in that case.
+ val dataFilters = v2Scans(df).head.scan match {
+ case f: FileScan => f.dataFilters
+ case other => fail(s"expected a FileScan, got
${other.getClass.getSimpleName}")
+ }
+ val referenced =
dataFilters.flatMap(_.references.map(_.name)).toSet
+ assert(referenced == Set("a", "b"),
+ s"the merged scan should carry the widened predicate; got
$dataFilters")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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)
+ withFileView("parquet", path) {
+ Seq(true, false).foreach { nestedPruning =>
+ withClue(s"nestedSchemaPruning=$nestedPruning: ") {
+ 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 resolve
against the narrowed type.
+ // Without pruning both scans read the whole struct and merge on
PlanMerger's
+ // identical-plan path, which needs no capability. Two
whole-struct scans canonicalize
+ // equal, so distinctScans cannot tell that merge from a
decline; subqueryCounts can.
+ val expectedCounts = if (nestedPruning) (2, 0) else (1, 1)
+ assert(subqueryCounts(df) == expectedCounts,
+ s"unexpected subquery
counts:\n${df.queryExecution.executedPlan}")
+ if (nestedPruning) {
+ assert(distinctScans(df) == 2,
+ s"the pruned scans should stay
separate:\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)
+ // Observe the aggregate itself, not just `!mergeableScan`, which
several other pushdowns
+ // also clear: a decline for one of those reasons must not pass as
this one.
+ assert(v2Scans(df).forall(_.scan match {
+ case p: ParquetScan => p.pushedAggregate.isDefined
+ case _ => false
+ }),
+ s"the aggregate should have been pushed into both scans:\n" +
+ df.queryExecution.optimizedPlan)
+ // 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)
+ // Different rows in the second table, so a merge across the two would
change the answer and
+ // not just the plan shape.
+ writeFlat("parquet", dir2.getCanonicalPath, start = 100)
+ 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)
+
+ // c = 1 selects ids 1, 4, ..., 19 in t1 and 100, 103, ..., 118 in
t2.
+ checkAnswer(df, Row(70, 1526))
+ 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)),
+ "V1 should merge the two partition filters into one subquery")
+ assert(mergedCounts(path, query, Row(45, 100), useV1 = false,
enableAQE) == ((2, 0)),
+ "V2 should leave the two differing partition filters unmerged")
+ }
+ }
+ }
+ }
+
+ test("SPARK-57205: CSV and JSON decline to merge, so their parsing stays per
subquery") {
+ // The parsers are handed just the columns the scan asked for (for CSV
under
+ // spark.sql.csv.parser.columnPruning.enabled), so which columns a scan
reads decides which
+ // records it treats as malformed. Neither table declares SCAN_MERGING, so
each subquery keeps
+ // its own scan. Every shape below is one where a merged scan would return
something else, so
+ // adding the capability back to either table fails this test. The V1 path
does merge them
+ // today, and does return something else, which is SPARK-59107.
+ val typeErrorCsv = Seq("0,0", "1,10", "2,BAD", "3,30", "4,40")
+ val typeErrorJson = Seq(
+ """{"a":0,"b":0}""",
+ """{"a":1,"b":10}""",
+ """{"a":2,"b":"BAD"}""",
+ """{"a":3,"b":30}""",
+ """{"a":4,"b":40}""")
+ // One token where the schema has two columns. Neither narrow scan is
malformed: with column
+ // pruning the parsed schema is the projection, so a one-column scan
matches a one-token row.
+ val shortRowCsv = Seq("0,0", "1,10", "2", "3,30", "4,40")
+ val sumQuery =
+ """
+ |SELECT
+ | (SELECT sum(a) FROM t),
+ | (SELECT sum(b) FROM t)
+ |""".stripMargin
+ val corruptQuery =
+ """
+ |SELECT
+ | (SELECT count(_corrupt_record) FROM t WHERE a >= 0),
+ | (SELECT sum(b) FROM t WHERE a >= 0)
+ |""".stripMargin
+
+ def withData(lines: Seq[String])(f: String => Unit): Unit =
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ lines.toDS().write.text(path)
+ f(path)
+ }
+
+ def rows(
+ format: String,
+ path: String,
+ schema: String,
+ mode: String,
+ query: String): Seq[Row] =
+ // Pin what the expectations below depend on rather than rely on the
defaults: with CSV column
+ // pruning off the parser is handed the full data schema, and with JSON
partial results off
+ // the malformed record yields an all-null row, which the WHERE then
drops.
+ withSQLConf(
+ SQLConf.CSV_PARSER_COLUMN_PRUNING.key -> "true",
+ SQLConf.JSON_ENABLE_PARTIAL_RESULTS.key -> "true") {
+ withFileView(format, path, schema = Some(schema),
+ options = Map("mode" -> mode, "columnNameOfCorruptRecord" ->
"_corrupt_record")) {
+ val df = sql(query)
+ assertUsesFileSourceV2(df)
+ val result = df.collect().toSeq
+ // Asserted after collect() so that AQE has finalized and a merged
subquery's reuse would
+ // be visible in the plan. Pinning this alongside the rows
attributes them to the merge
+ // decision itself.
+ assert(subqueryCounts(df) == ((2, 0)),
+ s"the two subqueries should keep their own
scans:\n${df.queryExecution.executedPlan}")
+ result
+ }
+ }
+
+ Seq("csv" -> typeErrorCsv, "json" -> typeErrorJson).foreach { case
(format, lines) =>
+ withClue(s"format=$format: ") {
+ withData(lines) { path =>
+ // DROPMALFORMED. The a-only scan never parses b, so the record
survives for sum(a). A
+ // merged scan would parse the union and drop it for both, giving 8.
+ assert(rows(format, path, "a long, b long", "DROPMALFORMED",
sumQuery) ==
+ Seq(Row(10, 80)))
+
+ // PERMISSIVE, the default mode, with the corrupt-record column in
the schema. The
+ // subquery that reads a and the corrupt column does not flag the
record; a merged scan
+ // would parse b and populate the column for a row that subquery
counted as clean.
+ assert(rows(format, path, "a long, b long, _corrupt_record string",
"PERMISSIVE",
+ corruptQuery) == Seq(Row(0, 80)))
+ }
+ }
+ }
+
+ // FAILFAST, CSV only: JSON has no arity check, so a missing field is
null, not malformed. Each
+ // narrow scan matches the short row, so the query returns rows; a merged
scan would parse two
+ // columns against a one-token row and throw, turning a working query into
an error.
+ withData(shortRowCsv) { path =>
+ assert(rows("csv", path, "a long, b long", "FAILFAST", sumQuery) ==
Seq(Row(10, 80)))
+ }
+ }
+}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/V2ScanMergingTestHelper.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/V2ScanMergingTestHelper.scala
new file mode 100644
index 000000000000..b822d65ac0ad
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/V2ScanMergingTestHelper.scala
@@ -0,0 +1,41 @@
+/*
+ * 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 is consistent with a
+ * merge and more than one means it was declined. Scans that read the same
columns and carry the
+ * same filters canonicalize equal either way, so use subquery counts for
those.
+ */
+ protected def distinctScans(df: DataFrame): Int =
v2Scans(df).map(_.canonicalized).distinct.length
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]