[SPARK-58549][SQL] Preserve key-grouped partitioning and ordering across a DSv2 scan merge - #57753
Conversation
…oss a DSv2 scan merge Follow-up to SPARK-40259. Instead of declining a DSv2 scan merge whenever either input reports key-grouped partitioning or ordering, allow the merge and re-derive the merged scan's own report, declining only if that would degrade what the inputs reported. - Drop the kGP/ordering conjuncts from the mergeable gate. - At the leaf, combine the two inputs' reports (remapped into the merged relation's attribute space) into the single report the merge must preserve: kGP must be equal, ordering is the stronger of the two. If the inputs are incompatible (differing non-empty kGP, or neither ordering satisfies the other) no rebuilt scan could keep both not-worse, so decline right there -- before rebuilding -- unless the matching config accepts degrading that dimension. - After rebuildScan, run V2ScanPartitioningAndOrdering on the single merged scan node to re-derive its partitioning/ordering, then check it against the combined required report (mergeDegradesReporting): decline if the merged scan's kGP does not match, or its ordering does not satisfy, what was required (carried through DSv2DeferredScan for the deferred build). Gated per dimension by two new configs, default false: spark.sql.optimizer.mergeSubplans.dsv2ScanMerge.allowKeyGroupedPartitioningDegradation and ...allowOrderingDegradation. With defaults it is a pure improvement (merge when not worse, decline on degradation). - Tests: default-decline-on-degradation, default-decline-on-incompatible-inputs, and config-allows-degradation in MergeSubplansSuite; a preserves-kGP end-to-end test in DSv2PlanMergingSuite with a new reporting SCAN_MERGING fixture.
|
cc @LuciferYang |
|
I’ll take a look at it tomorrow. |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Although I understand your intention, for new test cases, SPARK-58549 test prefix should be used according to this PR's JIRA instead of the previous one, @peter-toth .
| @@ -169,4 +172,39 @@ class DSv2PlanMergingSuite extends QueryTest with SharedSparkSession | |||
| } | |||
| } | |||
| } | |||
|
|
|||
| test("SPARK-40259: a scan merge preserves the sources' reported key-grouped partitioning") { | |||
There was a problem hiding this comment.
| test("SPARK-40259: a scan merge preserves the sources' reported key-grouped partitioning") { | |
| test("SPARK-58549: a scan merge preserves the sources' reported key-grouped partitioning") { |
| @@ -2618,6 +2619,109 @@ class MergeSubplansSuite extends PlanTest { | |||
| assertDeclines(s => s.copy(ordering = Some(Seq(SortOrder(s.output.head, Ascending))))) | |||
| } | |||
|
|
|||
| test("SPARK-40259: do not merge DSv2 scans reporting incompatible kGP/ordering") { | |||
There was a problem hiding this comment.
| test("SPARK-40259: do not merge DSv2 scans reporting incompatible kGP/ordering") { | |
| test("SPARK-58549: do not merge DSv2 scans reporting incompatible kGP/ordering") { |
| assertDeclines(s => s.copy(ordering = Some(Seq(SortOrder(s.output.head, Ascending))))) | ||
| } | ||
|
|
||
| test("SPARK-40259: merge DSv2 scans reporting kGP/ordering when the degradation config allows") { |
There was a problem hiding this comment.
| test("SPARK-40259: merge DSv2 scans reporting kGP/ordering when the degradation config allows") { | |
| test("SPARK-58549: merge DSv2 scans reporting kGP/ordering when the degradation config allows") { |
| SQLConf.MERGE_SUBPLANS_DSV2_ALLOW_ORDERING_DEGRADATION.key) | ||
| } | ||
|
|
||
| test("SPARK-40259: enforce the required report on the deferred under-Filter scan build") { |
There was a problem hiding this comment.
| test("SPARK-40259: enforce the required report on the deferred under-Filter scan build") { | |
| test("SPARK-58549: enforce the required report on the deferred under-Filter scan build") { |
| // stronger, which satisfies both). None from combine* means the inputs are INCOMPATIBLE -- no | ||
| // rebuilt scan could keep both not-worse -- so decline HERE, before rebuilding, unless the | ||
| // matching config accepts degrading that dimension. | ||
| val requiredKeyGroupedPartitioning = combineRequiredKeyGroupedPartitioning( |
There was a problem hiding this comment.
requiredKeyGroupedPartitioning / requiredOrdering seem to carry a double meaning here. These Options use .isEmpty to mean the inputs' reports are incompatible, while the same names are reused for the Seq parameters of tryBuildMergedDSv2Scan / mergeDegradesReporting, where empty means no requirement. Renaming the Options to combined* (and deriving the expected* Seqs from them) makes the early-decline condition read correctly at the use site. Maybe
requiredKeyGroupedPartitioning->combinedKeyGroupedPartitioningrequiredOrdering->combinedOrdering
dongjoon-hyun
left a comment
There was a problem hiding this comment.
+1 for the logic. LGTM with two minor issues in the above.
|
Thanks @dongjoon-hyun! Both applied — the |
LuciferYang
left a comment
There was a problem hiding this comment.
Sorry for the delayed reply.
| requiredKeyGroupedPartitioning: Seq[Expression], | ||
| requiredOrdering: Seq[SortOrder]): Boolean = { | ||
| val kgpDegraded = !dsv2AllowKeyGroupedPartitioningDegradation && | ||
| requiredKeyGroupedPartitioning.nonEmpty && |
There was a problem hiding this comment.
The not-worse check catches a report weaker than an input's, not one that appears when neither input had one.
- on a
(c1,c2)table with the sides reading{c1,c3}and{c2,c3}, the merged scan gainsSome([c1,c2])and starts taking theKeyedPartitioningbranch ofreplanWithRuntimeFilters: pruned splits get padded withNoneand launch empty tasks, and threeSparkExceptioninvariants now apply. - when the inputs conflict and the config is on, line 730 empties the requirement, so that dimension's post-rebuild check goes too.
There was a problem hiding this comment.
Both halves are answered in the summary comment — short version: the gain is real and is now documented as deliberate, and emptying the requirement when the inputs conflict is a no-op, because that dimension's config being on already short-circuits mergeDegradesReporting.
| // does not satisfy the required ordering (the combined report the merge must preserve, computed | ||
| // at the leaf). Gated per dimension by the dsv2ScanMerge degradation configs; an empty required | ||
| // report imposes no constraint. Compared in cp's relation space. | ||
| private def mergeDegradesReporting( |
There was a problem hiding this comment.
mergeDegradesReporting compares the expression list only, but SPJ also needs numPartitions and partitionKeys to match. once the .orElse at 831 drops the best-effort filter the split count grows, so the expression list survives while the key set changes. The check still returns false, an SPJ join no longer lines up, and the shuffle comes back.
There was a problem hiding this comment.
Agreed, and documented on the method: only the expressions are compared because that is all DataSourceV2ScanRelation carries. Worth noting it isn't specific to the .orElse either — the first build can prune differently from either input too.
| // neither satisfies the other they are INCOMPATIBLE (None). An empty ordering never constrains. | ||
| private def combineRequiredOrdering( | ||
| a: Seq[SortOrder], b: Seq[SortOrder]): Option[Seq[SortOrder]] = { | ||
| if (SortOrder.orderingSatisfies(a, b)) Some(a) |
There was a problem hiding this comment.
The ordering dimension has kGP's root cause: orderingSatisfies goes to SortOrder.satisfies, then semanticEquals, which is canonicalized ==. for a source reporting sort(bucket(4, id)) the two binds differ, so both sides report the same ordering and the pair is still declined at the leaf.
There was a problem hiding this comment.
Fixed, and this was the most valuable comment of the round. Both dimensions now compare via isSameFunction, recursively, so a nested transform's children are compared the same way. Note BoundFunction.canonicalName()'s default returns a random UUID, so this works exactly for connectors that override it — the same set for which SPJ works at all.
| child.collectFirst { case r: DataSourceV2Relation => r }.flatMap { relation => | ||
| tryBuildMergedDSv2Scan(relation, d.unionAttrs, d.strictFilters, bestEffortFilter) | ||
| .orElse(tryBuildMergedDSv2Scan(relation, d.unionAttrs, d.strictFilters, None)) | ||
| tryBuildMergedDSv2Scan(relation, d.unionAttrs, d.strictFilters, bestEffortFilter, |
There was a problem hiding this comment.
The report-related declines moved from the read-only gate to behind rebuildScan, so each attempt costs one or two full rebuilds, and merge walks the whole cache per subplan.
- connector that must list files to answer
numPartitions()pays file planning at optimizer time. - the
.orElsecannot tell the twoNonecauses apart. A structural degradation fails again; a source reporting per pruned file set makes the retry succeed instead.
There was a problem hiding this comment.
Fixed: the report check moved to the callers of tryBuildMergedDSv2Scan and runs per attempt, so a degradation no longer looks like a filter-enforcement failure. Separately, you're right that the retry itself has no test — deleting the .orElse leaves everything green. That predates this PR, so I've noted it as a follow-up rather than folding it in here.
| _.map(_.canonicalized) == requiredKeyGroupedPartitioning.map(_.canonicalized)) | ||
| val orderingDegraded = !dsv2AllowOrderingDegradation && | ||
| requiredOrdering.nonEmpty && | ||
| !SortOrder.orderingSatisfies(merged.ordering.getOrElse(Nil), requiredOrdering) |
There was a problem hiding this comment.
No test makes the merged scan carry a non-empty ordering, so the check at 877 never returns false with a non-empty requirement. replacing merged.ordering.getOrElse(Nil) with Nil stays green, and that switches merging off for every SupportsReportOrdering source. making combineRequiredOrdering pick the weaker side is also green, and would combine [a] and [a, b] into [a].
There was a problem hiding this comment.
Confirmed by measurement — Nil in place of merged.ordering.getOrElse(Nil) left all 82 tests green. Two new tests fix that. On the weaker-side variant: that is green only for two non-empty orderings; flipping the empty-side branches along with it fails the existing single-side decline test.
| } | ||
| } | ||
|
|
||
| // The key-grouped partitioning the merged scan must reproduce to keep both inputs not-worse: they |
There was a problem hiding this comment.
The comment above combineRequiredKeyGroupedPartitioning says kGP "must be equal (bucketing is a table property)", and on that reasoning else None is dead code. Line 712 says the two sides "usually agree ... but need not" and lists the reasons.
There was a problem hiding this comment.
Reworded. Your deeper point stands too: differing pruning explains differing split counts and partition values, not differing key expressions, and only the expressions are compared — so the comment now points at the ordering dimension, where a per-scan report really can arise.
| // reporting either declines the merge (checked on both the np and cp side) -- the plan is left | ||
| // unchanged -- rather than silently dropping it. Preserving them across a merge is a deferred | ||
| // follow-up. (The plain-scan merge is already covered by the projected-columns test above.) | ||
| // An input reports key-grouped partitioning or ordering, but the merged scan -- rebuilt over a |
There was a problem hiding this comment.
The test is still named do not merge DSv2 scans that report key-grouped partitioning or ordering, and this PR removes that rule; the comment underneath now says the merge would degrade what the input reported.
There was a problem hiding this comment.
Renamed to "do not merge DSv2 scans when the merge would degrade a reported key-grouped partitioning or ordering". Kept the SPARK-40259 prefix, since the test predates this PR.
| test("SPARK-58549: a scan merge preserves the sources' reported key-grouped partitioning") { | ||
| val t = "scanmergereport.t2" | ||
| withTable(t) { | ||
| withSQLConf(SQLConf.V2_BUCKETING_ENABLED.key -> "true") { |
There was a problem hiding this comment.
nit: The withSQLConf(V2_BUCKETING_ENABLED -> "true") here does nothing: it is true by default, only the physical outputPartitioning reads it, and every assertion is on optimizedPlan.
There was a problem hiding this comment.
Correct. Rather than drop it I made it matter: the test now also asserts the merged scan reports KeyedPartitioning on c1 in the physical plan, with AQE off so executedPlan is walkable.
| .createWithDefault(false) | ||
|
|
||
| val MERGE_SUBPLANS_DSV2_ALLOW_KEY_GROUPED_PARTITIONING_DEGRADATION = buildConf( | ||
| "spark.sql.optimizer.mergeSubplans.dsv2ScanMerge.allowKeyGroupedPartitioningDegradation") |
There was a problem hiding this comment.
These two open a new mergeSubplans.dsv2ScanMerge.* namespace and are the only booleans in the family without an .enabled suffix; the existing four sit under filterPropagation and all end in .enabled.
There was a problem hiding this comment.
Fixed. I dropped allow at the same time — with it, the key came to 94 chars, one over the line limit, which would have made it the only key in SQLConf split across two literals and so unsearchable from a log.
| extends InMemoryTableEnhancedPartitionFilterCatalog { | ||
| import CatalogV2Implicits._ | ||
|
|
||
| override def createTable( |
There was a problem hiding this comment.
nit: InMemoryScanMergingReportingCatalog.createTable is the third copy of this body (the parent plus the sibling in this file), and capabilities() is copied verbatim too.
There was a problem hiding this comment.
Fixed: a newScanMergingTable hook on the catalog, and InMemoryScanMergingPartitionFilterTable now extends the reporting table, so createTable and capabilities() each exist once.
- Compare reported partitioning/ordering expressions by transform semantics (`TransformExpression.isSameFunction`) instead of `canonicalized ==`. `V2ExpressionUtils` binds the transform function afresh on every derivation and a connector may return a new `BoundFunction` each time, so canonical equality called two identical `bucket(4, id)` reports different and declined the merge for every transform-partitioned source. The comparison recurses, so a nested transform's children are compared the same way. - Check the required report per build attempt at the callers of `tryBuildMergedDSv2Scan` rather than inside it. The strict filters and the report are independent reasons to reject a build, so a source whose report depends on which filters were pushed still gets its second chance from the strict-only attempt, while `None` from the build again means only "strict filters not re-enforceable", as its scaladoc says. - Rename both new configs to `…dsv2ScanMerge.keyGroupedPartitioningDegradation .enabled` and `…dsv2ScanMerge.orderingDegradation.enabled`: every other `mergeSubplans.*` config ends in `.enabled`, and dropping the redundant `allow` keeps each key inside the line limit as a single, greppable literal. - Say in both config docs that the flag also governs the decline that happens before any rebuild, when the two inputs' reports are incompatible. - Cover four branches nothing exercised: two independently derived bucket transform reports (the only test that reaches the transform comparison), a merged scan that re-derives the required ordering (merges), one that re-derives less than the combined ordering (declines), and incompatible input reports with the degradation config on (merges). `TestV2Table` can now report an ordering or a bucket partitioning, and a new fresh-binding `FunctionCatalog` fixture binds the transform function per derivation the way a real connector does. - Assert the preserved partitioning also reaches the physical plan, which is what `V2_BUCKETING_ENABLED` gates in the end-to-end test. - Fix stale comments: `mergeable` is no longer the only gate, the leaf is no longer the only place eligibility is decided, and the kGP combine comment contradicted its caller on whether two reports can differ. Also note what `mergeDegradesReporting` does not compare (split counts, partition values), that a report the merged scan gains is not a degradation, and that only the partitioning pass drops a report on pruning -- the ordering pass has no such guard. - Rename the pre-existing decline test, which still described the rule this change removes. - Drop the third copy of `createTable` and the second `capabilities()` in the scan-merging fixtures.
|
No problem at all, and thanks — this review was greatly appreciated. It found real gaps, including a couple I'd have shipped. Everything is in What changed
Where I'd push back a little
Two follow-ups I'd rather not fold in here
Correction (2026-08-08). Nothing to change in the PR — the fix is right as it stands. But I dug deeper into the transform-comparison bullet above, and my justification for it was too strong. "Every transform-partitioned source declined the merge" isn't true: What's left is narrower but real. |
The deferred build checks the required report on each build attempt rather than once on whichever came back, so the strict-only attempt -- which is exactly what the leaf builds when no Filter sits above the scan -- can still satisfy a report that the strict + best-effort attempt could not. Nothing pinned that: restoring the check-once shape stayed green. `TestV2ReportingScan` can now report only when no filter was pushed into it, modelling a source whose ordering holds only over the unpruned file set, and the new test drives both attempts through it -- the first is rejected on its degraded report, the second re-derives the ordering and the merge lands.
LuciferYang
left a comment
There was a problem hiding this comment.
LGTM , Thank you @peter-toth
The comment claimed no transform-partitioned source would pass the checks, which is too strong: `TransformExpression`'s equality delegates to `BoundFunction.equals`, and a connector that defines it semantically is unaffected -- Iceberg's `BaseScalarFunction` compares on `canonicalName`. What justifies comparing by `isSameFunction` is narrower: `canonicalName` is a documented obligation, with a random-UUID default that forces an override, while `equals` on `BoundFunction` is required nowhere. So a connector meeting only the documented contract does compare two identical reports unequal, and Spark should not depend on the courtesy of it doing more.
…oss a DSv2 scan merge ### What changes were proposed in this pull request? Follow-up to SPARK-40259 (subquery plan merge for DataSource V2 scans). Today the DSv2 scan merge declines whenever either input scan reports key-grouped partitioning or ordering. The rebuilt merged scan carries neither on its own, so fusing the two scans could drop a partitioning/ordering the original plan relied on and force an extra shuffle or sort. Declining is safe but leaves the merge unused for any partitioned/ordered source. This PR lets the merge proceed and re-derives the merged scan's own report instead of declining up front: - Drop the `keyGroupedPartitioning`/`ordering` conjuncts from the `mergeable` gate in `PlanMerger.tryMergeScanRelations`. - At the leaf, combine the two inputs' reports (np's remapped into cp's relation space) into the single report the merge must preserve: - key-grouped partitioning: the two must be equal (`combineRequiredKeyGroupedPartitioning`); - ordering: the stronger of the two, i.e. the one that satisfies the other (`combineRequiredOrdering`). Both compare reported expressions by transform semantics (`TransformExpression.isSameFunction`, the way a storage-partitioned join compares its two sides) rather than by canonical equality. `V2ExpressionUtils` binds a transform function afresh on every derivation, and `canonicalName` is a documented obligation (its default returns a random UUID to force an override) while `equals` on `BoundFunction` is required nowhere -- so for a connector meeting only the documented contract, canonical equality calls two identical `bucket(4, id)` reports different and declines the merge. Connectors that do define `equals` semantically are unaffected, but that is a courtesy rather than a contract. If the inputs are incompatible (differing non-empty kGP, or neither ordering satisfies the other), no rebuilt scan can keep both not-worse, so the merge is declined right there -- before rebuilding -- saving a scan rebuild. - After `V2ScanRelationPushDown.rebuildScan`, re-derive the merged scan's partitioning/ordering by running `V2ScanPartitioningAndOrdering` on the single merged scan node, then check it against the combined required report (`mergeDegradesReporting`): decline if the merged scan's kGP does not match, or its ordering does not satisfy, what was required. The check runs at the build's callers, and per build attempt, so a report degradation stays distinguishable from a strict filter that cannot be re-enforced. The required report is carried through `DSv2DeferredScan` for the deferred (under-`Filter`) build. Two new opt-in configs gate accepting a degradation instead of declining (both default `false`), so with the defaults this is a pure improvement -- merge when not worse, decline on loss: - `spark.sql.optimizer.mergeSubplans.dsv2ScanMerge.keyGroupedPartitioningDegradation.enabled` - `spark.sql.optimizer.mergeSubplans.dsv2ScanMerge.orderingDegradation.enabled` Only sources that declare the `SCAN_MERGING` table capability are affected. ### Why are the changes needed? The parent feature (SPARK-40259) conservatively declines a merge whenever an input reports key-grouped partitioning or ordering, to avoid forcing a shuffle/sort that the original plan avoided. That is correct but overly broad: when the rebuilt merged scan re-derives the same (or a stronger) report, fusing the scans loses nothing and still removes a duplicate scan -- which matters for partitioned/ordered sources, e.g. an SPJ join inside the merged subplan. This PR keeps the safety (decline on real degradation) while capturing the merge when it is genuinely not worse. ### Does this PR introduce _any_ user-facing change? No. The two new configs are opt-in and default to `false`, and no built-in source declares `SCAN_MERGING`, so there is no behavior change for existing sources. ### How was this patch tested? New and existing unit tests, all under `sql/core`: - `MergeSubplansSuite`: - default-decline when a single input reports kGP/ordering the rebuilt scan drops (degradation); - default-decline when the two inputs report incompatible kGP/ordering (declined at the leaf, before rebuild); - config-allows-degradation: the merge proceeds to the plain column union under each `...Degradation.enabled=true`, both for a single reporting input (the degradation is found after the rebuild) and for two incompatible ones (declined before it); - two independently derived `bucket(4, a)` reports merge and the rebuilt scan keeps the report -- the only test that reaches the transform comparison. It uses a `FunctionCatalog` fixture that binds a fresh `BoundFunction` per call, the way a real connector does; Spark's own `UnboundBucketFunction` returns a singleton, so a fixture built on that would pass with or without the fix; - the merge proceeds when the rebuilt scan re-derives the required ordering, and declines when it re-derives less than the two inputs' combined (stronger) ordering; - the deferred (under-`Filter`) build path enforces the required report too: decline by default, merge with the config on; - the deferred build checks the report on EACH attempt, so a source that reports only over the unpruned file set still merges on the strict-only attempt -- which is what the leaf builds when no `Filter` is above the scan, so checking only the first attempt would leave the deferred path weaker than the leaf path; - the pre-existing empty-report case still merges. - `DSv2PlanMergingSuite`: an end-to-end test that a scan merge preserves the sources' reported key-grouped partitioning, using a new `SCAN_MERGING` fixture (`InMemoryScanMergingReportingCatalog`/`InMemoryScanMergingReportingTable`) that keeps its reported partitioning (no `NonReportingScan` wrapper), an identity-partitioned table under `spark.sql.sources.v2.bucketing.enabled=true`, and two scalar subqueries reading the partition column. It also asserts that the preserved partitioning reaches the physical plan, i.e. the merged `BatchScanExec` reports `KeyedPartitioning` on the partition column. `build/sbt 'sql/testOnly *MergeSubplansSuite *DSv2PlanMergingSuite *PlanMergingSuite'` -- 99 tests pass. `dev/lint-scala` clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) Closes #57753 from peter-toth/SPARK-58549-preserve-kgp-ordering-dsv2-scan-merge. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 8645e2c) Signed-off-by: Peter Toth <peter.toth@gmail.com>
|
Thank you @uros-b, @dongjoon-hyun and @LuciferYang for the review! |
What changes were proposed in this pull request?
Follow-up to SPARK-40259 (subquery plan merge for DataSource V2 scans).
Today the DSv2 scan merge declines whenever either input scan reports key-grouped partitioning or ordering. The rebuilt merged scan carries neither on its own, so fusing the two scans could drop a partitioning/ordering the original plan relied on and force an extra shuffle or sort. Declining is safe but leaves the merge unused for any partitioned/ordered source.
This PR lets the merge proceed and re-derives the merged scan's own report instead of declining up front:
Drop the
keyGroupedPartitioning/orderingconjuncts from themergeablegate inPlanMerger.tryMergeScanRelations.At the leaf, combine the two inputs' reports (np's remapped into cp's relation space) into the single report the merge must preserve:
combineRequiredKeyGroupedPartitioning);combineRequiredOrdering).Both compare reported expressions by transform semantics (
TransformExpression.isSameFunction, the way a storage-partitioned join compares its two sides) rather than by canonical equality.V2ExpressionUtilsbinds a transform function afresh on every derivation, andcanonicalNameis a documented obligation (its default returns a random UUID to force an override) whileequalsonBoundFunctionis required nowhere -- so for a connector meeting only the documented contract, canonical equality calls two identicalbucket(4, id)reports different and declines the merge. Connectors that do defineequalssemantically are unaffected, but that is a courtesy rather than a contract.If the inputs are incompatible (differing non-empty kGP, or neither ordering satisfies the other), no rebuilt scan can keep both not-worse, so the merge is declined right there -- before rebuilding -- saving a scan rebuild.
After
V2ScanRelationPushDown.rebuildScan, re-derive the merged scan's partitioning/ordering by runningV2ScanPartitioningAndOrderingon the single merged scan node, then check it against the combined required report (mergeDegradesReporting): decline if the merged scan's kGP does not match, or its ordering does not satisfy, what was required. The check runs at the build's callers, and per build attempt, so a report degradation stays distinguishable from a strict filter that cannot be re-enforced. The required report is carried throughDSv2DeferredScanfor the deferred (under-Filter) build.Two new opt-in configs gate accepting a degradation instead of declining (both default
false), so with the defaults this is a pure improvement -- merge when not worse, decline on loss:spark.sql.optimizer.mergeSubplans.dsv2ScanMerge.keyGroupedPartitioningDegradation.enabledspark.sql.optimizer.mergeSubplans.dsv2ScanMerge.orderingDegradation.enabledOnly sources that declare the
SCAN_MERGINGtable capability are affected.Why are the changes needed?
The parent feature (SPARK-40259) conservatively declines a merge whenever an input reports key-grouped partitioning or ordering, to avoid forcing a shuffle/sort that the original plan avoided. That is correct but overly broad: when the rebuilt merged scan re-derives the same (or a stronger) report, fusing the scans loses nothing and still removes a duplicate scan -- which matters for partitioned/ordered sources, e.g. an SPJ join inside the merged subplan. This PR keeps the safety (decline on real degradation) while capturing the merge when it is genuinely not worse.
Does this PR introduce any user-facing change?
No. The two new configs are opt-in and default to
false, and no built-in source declaresSCAN_MERGING, so there is no behavior change for existing sources.How was this patch tested?
New and existing unit tests, all under
sql/core:MergeSubplansSuite:...Degradation.enabled=true, both for a single reporting input (the degradation is found after the rebuild) and for two incompatible ones (declined before it);bucket(4, a)reports merge and the rebuilt scan keeps the report -- the only test that reaches the transform comparison. It uses aFunctionCatalogfixture that binds a freshBoundFunctionper call, the way a real connector does; Spark's ownUnboundBucketFunctionreturns a singleton, so a fixture built on that would pass with or without the fix;Filter) build path enforces the required report too: decline by default, merge with the config on;Filteris above the scan, so checking only the first attempt would leave the deferred path weaker than the leaf path;DSv2PlanMergingSuite: an end-to-end test that a scan merge preserves the sources' reported key-grouped partitioning, using a newSCAN_MERGINGfixture (InMemoryScanMergingReportingCatalog/InMemoryScanMergingReportingTable) that keeps its reported partitioning (noNonReportingScanwrapper), an identity-partitioned table underspark.sql.sources.v2.bucketing.enabled=true, and two scalar subqueries reading the partition column. It also asserts that the preserved partitioning reaches the physical plan, i.e. the mergedBatchScanExecreportsKeyedPartitioningon the partition column.build/sbt 'sql/testOnly *MergeSubplansSuite *DSv2PlanMergingSuite *PlanMergingSuite'-- 99 tests pass.dev/lint-scalaclean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)