ulysses-you commented on code in PR #58522:
URL: https://github.com/apache/spark/pull/58522#discussion_r3946149353
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala:
##########
@@ -96,7 +96,17 @@ trait DataSourceV2ScanExecBase
val rowOrdering = RowOrdering.createNaturalAscendingOrdering(dataTypes)
val partitionKeys =
inputPartitions.map(_.asInstanceOf[HasPartitionKey].partitionKey()).sorted(rowOrdering)
- KeyedPartitioning(exprs, partitionKeys)
+ val partitioning = KeyedPartitioning(exprs, partitionKeys)
+ // A partition key may reference a column that was pruned out of the
scan output (kept only
+ // when operation keys may be a subset of the partition keys, see
+ // V2ScanPartitioningAndOrdering). Project such unresolvable key
positions away so the
+ // reported partitioning only references output columns.
+ val resolvablePositions = exprs.indices.filter(i =>
exprs(i).references.subsetOf(outputSet))
+ if (resolvablePositions.isEmpty) {
+ super.outputPartitioning
+ } else {
+ partitioning.project(resolvablePositions)
Review Comment:
Same issue @sunchao raised; fixed in 973bde7e6ce.
`reportedKeyedPartitioning` carries the source's
report at full key width and is what `filteredPartitions` passes down;
`outputPartitioning` stays
projected for planning. `replanWithRuntimeFilters` now takes
`Option[KeyedPartitioning]`, so the
projected view cannot reach the raw key rows.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala:
##########
@@ -41,33 +42,45 @@ object V2ScanPartitioningAndOrdering extends
Rule[LogicalPlan] with Logging {
}
}
- private def partitioning(plan: LogicalPlan) = plan.transformDownWithPruning(
+ private def partitioning(plan: LogicalPlan) = {
+ val allowKeysSubsetOfPartitionKeys =
SQLConf.get.v2BucketingAllowKeysSubsetOfPartitionKeys
+ plan.transformDownWithPruning(
_.containsPattern(DATA_SOURCE_V2_SCAN_RELATION)) {
- case d @ ExtractV2ScanInfo(relation, scan: SupportsReportPartitioning, _)
- if d.keyGroupedPartitioning.isEmpty =>
- val catalystPartitioning = scan.outputPartitioning() match {
- case kgp: KeyGroupedPartitioning =>
- val partitioning = sequenceToOption(
- kgp.keys().map(V2ExpressionUtils.toCatalystOpt(_, relation,
relation.funCatalog))
- .toImmutableArraySeq)
- if (partitioning.isEmpty) {
- None
- } else {
- if (partitioning.get.forall(p =>
p.references.subsetOf(d.outputSet))) {
- partitioning
- } else {
+ case d @ ExtractV2ScanInfo(relation, scan: SupportsReportPartitioning, _)
+ if d.keyGroupedPartitioning.isEmpty =>
+ val catalystPartitioning = scan.outputPartitioning() match {
+ case kgp: KeyGroupedPartitioning =>
+ val partitioning = sequenceToOption(
+ kgp.keys().map(V2ExpressionUtils.toCatalystOpt(_, relation,
relation.funCatalog))
+ .toImmutableArraySeq)
+ if (partitioning.isEmpty) {
None
+ } else {
+ val inOutput = partitioning.get.map(p =>
p.references.subsetOf(d.outputSet))
+ if (inOutput.forall(identity)) {
+ partitioning
+ } else if (inOutput.exists(identity) &&
allowKeysSubsetOfPartitionKeys) {
Review Comment:
Agreed, gate removed in 973bde7e6ce.
Your mechanism holds: `project` marks `isCollapsed` exactly on the
collapsing case and
`mayGroupToSatisfy` keeps the config over it, so the rule only has to decide
whether any key
survives. The rule also has no partition key values in hand, so it cannot
tell the two cases apart
even in principle.
The case the conjunct was costing is now pinned by `a pruned key that
collapses nothing keeps SPJ
without the config`, with `allowKeysSubsetOfPartitionKeys` set to `false`
explicitly.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -2526,6 +2526,233 @@ class KeyGroupedPartitioningSuite
}
}
+ test("SPARK-59248: join key subset of partition keys, extra partition key
pruned from the " +
+ "output") {
+ // Both tables are partitioned by (id, data). The join is only on `id`,
and `data` is not
+ // selected, so it is column-pruned out of both scan outputs. Without
+ // allowKeysSubsetOfPartitionKeys the pruned `data` key drops the reported
partitioning and both
+ // sides shuffle; with it, the partitioning is kept and projected onto
`id`, so SPJ triggers.
+ val table1 = "prune_t1"
+ val table2 = "prune_t2"
+ val partition = Array(identity("id"), identity("data"))
+ createTable(table1, columns, partition)
+ sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+ "(1, 'aa', cast('2020-01-01' as timestamp)), " +
+ "(2, 'bb', cast('2020-01-01' as timestamp)), " +
+ "(2, 'cc', cast('2020-01-01' as timestamp)), " +
+ "(3, 'dd', cast('2020-01-01' as timestamp))")
+
+ createTable(table2, columns, partition)
+ sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+ "(2, 'bb', cast('2020-01-01' as timestamp)), " +
+ "(2, 'cc', cast('2020-01-01' as timestamp)), " +
+ "(3, 'ee', cast('2020-01-01' as timestamp)), " +
+ "(4, 'ff', cast('2020-01-01' as timestamp))")
+
+ // Selecting only `id` prunes the other partition key `data` (and `ts`)
from both scans. The
+ // expected result is the within-`id` cross product (id=2 matches 2 x 2
rows, id=3 matches
+ // 1 x 1).
+ val expected = Seq(Row(2), Row(2), Row(2), Row(2), Row(3))
+ val query =
+ s"""
+ |${selectWithMergeJoinHint("t1", "t2")}
+ |t1.id AS id
+ |FROM testcat.ns.$table1 t1 JOIN testcat.ns.$table2 t2
+ |ON t1.id = t2.id ORDER BY id
+ |""".stripMargin
+
+ Seq(true, false).foreach { allowKeysSubsetOfPartitionKeys =>
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
+ allowKeysSubsetOfPartitionKeys.toString) {
+ val df = sql(query)
+ val shuffles = collectShuffles(df.queryExecution.executedPlan)
+ val groupPartitions =
collectGroupPartitions(df.queryExecution.executedPlan)
+ if (allowKeysSubsetOfPartitionKeys) {
+ assert(shuffles.isEmpty, "SPJ should be triggered even though `data`
is pruned")
+ assert(groupPartitions.nonEmpty, "GroupPartitionsExec should
coalesce on the join key")
+ // The reported partitioning is kept on the scan even though `data`
is pruned ...
+ val scans = collectScans(df.queryExecution.executedPlan)
+ assert(scans.nonEmpty)
+ scans.foreach { scan =>
+ assert(scan.keyGroupedPartitioning.isDefined,
+ "partitioning should be kept despite the pruned key")
+ // ... but the physical output partitioning must only reference
output columns, so the
+ // pruned column reaches no consumer (shuffle spec, ordering, plan
equality).
+ scan.outputPartitioning match {
+ case kp: physical.KeyedPartitioning =>
+
assert(kp.expressions.forall(_.references.subsetOf(scan.outputSet)),
+ s"partitioning ${kp.expressions} references a column outside
${scan.output}")
+ case other =>
+ fail(s"expected KeyedPartitioning but got $other")
+ }
+ }
+ } else {
+ assert(shuffles.nonEmpty, "SPJ should not be triggered without the
config")
+ assert(groupPartitions.isEmpty)
+ }
+ checkAnswer(df, expected)
+ }
+ }
+ }
+
+ test("SPARK-59248: scan reports no partitioning when all partition keys are
pruned") {
+ // The table is partitioned by (id, data), but the query selects only
`ts`, so both partition
+ // keys are column-pruned out of the scan output. Even with
allowKeysSubsetOfPartitionKeys on,
+ // no partition key survives in the output, so the scan must not keep a
dangling
+ // KeyedPartitioning and reports no (unknown) partitioning.
+ val table1 = "prune_all_keys"
+ createTable(table1, columns, Array(identity("id"), identity("data")))
+ sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+ "(1, 'aa', cast('2020-01-01' as timestamp)), " +
+ "(2, 'bb', cast('2020-01-02' as timestamp))")
+
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") {
+ val df = sql(s"SELECT ts FROM testcat.ns.$table1")
+ checkAnswer(df, Seq(
+ Row(Timestamp.valueOf("2020-01-01 00:00:00")),
+ Row(Timestamp.valueOf("2020-01-02 00:00:00"))))
+ val scans = collectScans(df.queryExecution.executedPlan)
+ assert(scans.length == 1)
+ scans.foreach { scan =>
+ assert(scan.keyGroupedPartitioning.isEmpty,
+ s"no partition key survives in the output, got
${scan.keyGroupedPartitioning}")
+ scan.outputPartitioning match {
+ case _: physical.UnknownPartitioning => // expected: nothing left to
partition by
+ case other => fail(s"expected UnknownPartitioning but got $other")
+ }
+ }
+ }
+ }
+
+ test("SPARK-59248: self-join with a pruned partition key keeps plans
canonicalizable") {
+ // Same-table join where the extra partition key `data` is pruned from
both scan instances. This
+ // exercises canonicalization/plan-equality over scans whose reported
partitioning carries a key
+ // that is not in the scan output; results must stay correct and planning
must not fail.
+ val table1 = "prune_self"
+ val partition = Array(identity("id"), identity("data"))
+ createTable(table1, columns, partition)
+ sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+ "(1, 'aa', cast('2020-01-01' as timestamp)), " +
+ "(2, 'bb', cast('2020-01-01' as timestamp)), " +
+ "(2, 'cc', cast('2020-01-01' as timestamp)), " +
+ "(3, 'dd', cast('2020-01-01' as timestamp))")
+
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") {
+ val df = sql(
+ s"""
+ |${selectWithMergeJoinHint("a", "b")}
+ |a.id AS id
+ |FROM testcat.ns.$table1 a JOIN testcat.ns.$table1 b
+ |ON a.id = b.id ORDER BY id
+ |""".stripMargin)
+ assert(collectShuffles(df.queryExecution.executedPlan).isEmpty, "SPJ
should be triggered")
+ // id=1 yields 1 row, id=2 yields 2 x 2 = 4 rows, id=3 yields 1 row.
+ checkAnswer(df, Seq(Row(1), Row(2), Row(2), Row(2), Row(2), Row(3)))
+
+ // Both scan instances must survive (no incorrect dedup) and each must
report a partitioning
+ // that only references its own output, even though `data` is pruned:
this is what keeps the
+ // dangling key from reaching any consumer (shuffle spec, ordering,
canonicalized comparison).
+ val scans = collectScans(df.queryExecution.executedPlan)
+ assert(scans.length == 2, s"expected the two self-join scans, got:\n" +
+ s"${df.queryExecution.executedPlan}")
+ scans.foreach { scan =>
+ assert(scan.keyGroupedPartitioning.isDefined,
+ "partitioning should be kept despite the pruned key")
+ scan.outputPartitioning match {
+ case kp: physical.KeyedPartitioning =>
+
assert(kp.expressions.forall(_.references.subsetOf(scan.outputSet)),
+ s"partitioning ${kp.expressions} references a column outside
${scan.output}")
+ case other =>
+ fail(s"expected KeyedPartitioning but got $other")
+ }
+ // Canonicalization must be stable and must not throw with a dangling
key present.
+ assert(scan.canonicalized.sameResult(scan.canonicalized))
+ }
+ }
+ }
+
+ test("SPARK-59248: a pruned partition key must not defeat plan reuse") {
+ val table1 = "prune_reuse"
+ val partition = Array(identity("id"), identity("data"))
+ createTable(table1, columns, partition)
+ sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+ "(1, 'aa', cast('2020-01-01' as timestamp)), " +
+ "(2, 'bb', cast('2020-01-02' as timestamp)), " +
+ "(3, 'dd', cast('2020-01-03' as timestamp))")
+
+ // Self-join on the non-partition column `ts`; the other partition key
`data` is pruned from
+ // both scan instances. The two legs are identical subtrees, so Spark
reuses one leg's exchange
+ // for the other. With allowKeysSubsetOfPartitionKeys the scan keeps its
reported partitioning,
+ // which then references the pruned `data`; that dangling key must not
leak into canonicalized
+ // plan comparison and break the reuse.
+ val query =
+ s"""
+ |SELECT a.id AS id1, b.id AS id2
+ |FROM testcat.ns.$table1 a JOIN testcat.ns.$table1 b
+ |ON a.ts = b.ts ORDER BY id1, id2
+ |""".stripMargin
+
+ Seq(true, false).foreach { allowKeysSubsetOfPartitionKeys =>
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
+ allowKeysSubsetOfPartitionKeys.toString) {
+ val df = sql(query)
+ checkAnswer(df, Seq(Row(1, 1), Row(2, 2), Row(3, 3)))
+ val plan = df.queryExecution.executedPlan
+ val reused = collect(plan) { case r: ReusedExchangeExec => r }
+ val scans = collectScans(plan)
+ assert(scans.length == 1,
+ s"the two identical legs should reuse a single scan " +
+
s"(allowKeysSubsetOfPartitionKeys=$allowKeysSubsetOfPartitionKeys):\n$plan")
+ assert(reused.length == 1,
+ s"expected one reused exchange " +
+
s"(allowKeysSubsetOfPartitionKeys=$allowKeysSubsetOfPartitionKeys):\n$plan")
+ }
+ }
+ }
+
+ test("SPARK-59248: a pruned source-reported ordering must not defeat
exchange reuse") {
Review Comment:
Confirmed, and the test is removed in 973bde7e6ce.
The ordering half of `doCanonicalize` is pinned instead by
`MergeSubplansSuite`'s `identical DSv2
scans whose reported ordering is on a pruned column are deduplicated, not
fused`, which does fail
with the `takeWhile` reverted.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala:
##########
@@ -41,33 +42,45 @@ object V2ScanPartitioningAndOrdering extends
Rule[LogicalPlan] with Logging {
}
}
- private def partitioning(plan: LogicalPlan) = plan.transformDownWithPruning(
+ private def partitioning(plan: LogicalPlan) = {
+ val allowKeysSubsetOfPartitionKeys =
SQLConf.get.v2BucketingAllowKeysSubsetOfPartitionKeys
Review Comment:
Moot now that the gate is gone (finding 2): the config keeps the two roles
it already documents, so
`V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.doc()` is unchanged.
--
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]