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


##########
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:
   **Finding 1.** `project` rebuilds the key rows onto `resolvablePositions`, 
so from here the partitioning's `partitionKeys` are projected rows and its 
`keyDataTypes` are the projected types. `BatchScanExec.filteredPartitions` 
passes this same partitioning to `PushDownUtils.replanWithRuntimeFilters`, 
which reads the raw, full-width `HasPartitionKey.partitionKey()` rows with it:
   
   - 
`parts.sortBy(_.asInstanceOf[HasPartitionKey].partitionKey())(k.keyRowOrdering)`
 at 
`sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:357`
   - `getInternalRowComparableWrapperFactory(k.keyDataTypes)` applied to 
`partitionKey()` at 
`sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala:316`
   
   Those line up only when the surviving positions are the leading prefix. 
Every existing test prunes the trailing key, so nothing catches it.
   
   **Wrong results.** `GroupPartitionsExec.grouping` labels child RDD partition 
`i` with `childKp.partitionKeys(i)`. `partitionKeys` is ordered by the full 
key, while `filteredPartitions` is ordered by the projected prefix. The two 
disagree wherever the full-key sort reorders inside a prefix group. Measured on 
a 7-split table partitioned by `(store_id, dept_id)` with `store_id` pruned:
   
       rdd partition keys (store,dept): (1,10), (1,20), (1,30), (2,5), (2,40), 
(3,7), (3,1)
       partitioning keys (dept):          10,     20,     30,      5,     40,   
   1,     7
   
   Indices 5 and 6 carry each other's label. A join that lays the other side 
out on those keys loses exactly those two:
   
   ```scala
   val cols = Array(
     Column.create("store_id", IntegerType),
     Column.create("dept_id", IntegerType))
   createTable("t", cols, Array(identity("store_id"), identity("dept_id")))
   sql("INSERT INTO testcat.ns.t VALUES (1, 20), (1, 10), (1, 30), (2, 5), (2, 
40), (3, 7), (3, 1)")
   withTempView("other") {
     spark.range(1, 41).selectExpr("cast(id as int) as 
dept_id").createOrReplaceTempView("other")
     withSQLConf(
       SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
       SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
       SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") {
       val df = sql("SELECT /*+ MERGE(t, o) */ t.dept_id " +
         "FROM testcat.ns.t t JOIN other o ON t.dept_id = o.dept_id")
       assert(df.collect().map(_.getInt(0)).sorted.toSeq === Seq(1, 5, 7, 10, 
20, 30, 40))
     }
   }
   ```
   
   This branch returns `5, 10, 20, 30, 40`. With 
`allowKeysSubsetOfPartitionKeys` off the same query is correct, so it is this 
path.
   
   **ClassCastException** when the pruned leading key has a different type. Two 
tables partitioned by `(identity("data"), identity("id"))`, joined on `id` with 
`data` pruned, throw `java.lang.ClassCastException: class 
org.apache.spark.unsafe.types.UTF8String cannot be cast to class 
java.lang.Integer` at `PushDownUtils.scala:357`. `keyRowOrdering` comes from 
the projected `[IntegerType]` and is applied to a `(String, Int)` row.
   
   **Fix.** Give `replanWithRuntimeFilters` the unprojected partitioning: its 
keys are the raw rows, in the order it sorts the input partitions into. 
Splitting the construction out of `outputPartitioning` is enough:
   
   ```scala
     /**
      * The partitioning as the source reported it: one key per input 
partition, holding every reported
      * key position, in the order a consumer must sort the input partitions 
into.
      */
     protected def reportedKeyedPartitioning: Option[KeyedPartitioning] = {
       keyGroupedPartitioning match {
         case Some(exprs) if conf.v2BucketingEnabled && 
KeyedPartitioning.supportsExpressions(exprs) &&
             inputPartitions.nonEmpty && 
inputPartitions.forall(_.isInstanceOf[HasPartitionKey]) =>
           val dataTypes = exprs.map(_.dataType)
           val rowOrdering = 
RowOrdering.createNaturalAscendingOrdering(dataTypes)
           val partitionKeys =
             
inputPartitions.map(_.asInstanceOf[HasPartitionKey].partitionKey()).sorted(rowOrdering)
           Some(KeyedPartitioning(exprs, partitionKeys))
         case _ => None
       }
     }
   
     /**
      * What orders the input partitions. `outputPartitioning` may have 
projected key positions away,
      * and its keys and key types then no longer describe the raw 
`HasPartitionKey.partitionKey()`
      * rows, so a consumer reading those rows must take the types and the 
order from here.
      */
     protected def inputPartitionOrdering: physical.Partitioning =
       if (outputPartitioning.isInstanceOf[KeyedPartitioning]) {
         reportedKeyedPartitioning.getOrElse(super.outputPartitioning)
       } else {
         super.outputPartitioning
       }
   
     override def outputPartitioning: physical.Partitioning = 
reportedKeyedPartitioning match {
       case Some(partitioning) =>
         val exprs = partitioning.expressions
         val resolvablePositions = exprs.indices.filter(i => 
exprs(i).references.subsetOf(outputSet))
         if (resolvablePositions.isEmpty) super.outputPartitioning
         else partitioning.project(resolvablePositions)
       case _ => super.outputPartitioning
     }
   ```
   
   with `BatchScanExec.filteredPartitions` passing `inputPartitionOrdering` 
instead of `outputPartitioning`. I ran `KeyGroupedPartitioningSuite`, 
`DataSourceV2CatalystRuntimeFilterSuite` and `MergeSubplansSuite` with that 
applied plus the two cases above: 245 tests, all green.
   



##########
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:
   **Finding 2.** The opt-in is needed for the grouping, not for the report. 
Projecting a key position away is a collapse only when two distinct source keys 
land on the same projected key. `KeyedPartitioning.project` sets `isCollapsed` 
exactly then, and `mayGroupToSatisfy` refuses to group a collapsed partitioning 
without this config. The safety property is therefore already enforced one 
layer down, on the case that needs it.
   
   What this conjunct costs is the other case: a pruned key whose removal 
collapses nothing. The projected keys stay unique, `isGrouped` is true, and 
`satisfies(ClusteredDistribution)` holds whatever the config says.
   
   That split is the one SPARK-46367 (`e656d04c157`) already drew for the 
sibling narrowing in `PartitioningPreservingUnaryExecNode`: distinct projected 
keys need no config, duplicate projected keys require 
`allowKeysSubsetOfPartitionKeys`. `KeyedPartitioning`'s class doc argues the 
same for not gating the report - "Reporting `UnknownPartitioning` would give up 
all of them, and make the plan shape depend on a config."
   
   I measured both halves with the conjunct dropped, each with 
`allowKeysSubsetOfPartitionKeys=false`:
   
   - partitioned by `(dept_id, store_id)`, one `store_id` per `dept_id`, 
`store_id` pruned, join on `dept_id`: no shuffle, correct answer.
   - partitioned by `(id, data)`, two `data` values per `id`, `data` pruned, 
join on `id`: two shuffles, correct answer. `mayGroupToSatisfy` refuses, as it 
should.
   
   So `else if (inOutput.exists(identity))` looks like the right gate.
   



##########
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:
   **Finding 3.** This one passes on master. `BatchScanExec` never looks at 
`ordering` - `equals`, `hashCode` and `doCanonicalize` all leave it out - so a 
dangling reported ordering could not block physical exchange reuse in the first 
place.
   
   Measured two ways. On `e261626f152` with only the test files applied, it 
passes. With the whole PR applied but the `takeWhile` in 
`DataSourceV2ScanRelation.doCanonicalize` reverted, it still passes. The other 
tests behave as expected under the same treatment: "join key subset ..." and 
"self-join ..." fail on base, and "a pruned partition key must not defeat plan 
reuse" fails when only `BatchScanExec.scala` is reverted.
   
   The `MergeSubplansSuite` test is the one that covers the `takeWhile` - it 
fails with that hunk alone reverted, matching your description. So either drop 
this test, or point it at the logical path (`sameResult` over two 
`DataSourceV2ScanRelation`s), where the ordering really is compared.
   



##########
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:
   **Finding 4.** If the gate stays (see finding 2), 
`V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.doc()` needs a sentence for 
it. The doc enumerates the config's roles today - allowing the operation keys 
to be a subset of the partition keys, and gating the grouping of a collapsed 
partitioning. This adds a third that differs in kind: whether the scan reports 
a partitioning at all. That is the one a user meets as "the plan shape 
changed", so it is worth naming.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to