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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -77,10 +87,21 @@ case class TransformExpression(
    * It compares the transforms only. A caller that compares partition keys 
has to consult
    * `reducedWith` as well, since a reduced key space is not the one its 
transform names.
    *
+   * Only a transform whose arguments are all bare columns can be the same as 
another. The
+   * comparison ignores which column an argument is, and 
`KeyedShuffleSpec.keyPositions` pairs the
+   * two sides up by the column each expression references. That pairing is 
sound only when the
+   * argument is the column itself. `KeyedShuffleSpec.createPartitioning` 
builds a transform over
+   * the other side's join key, which can be an expression or a struct field. 
`bucket(4, b + 1)` is
+   * a function of `b`, but not the same function of it as `bucket(4, x)` is 
of `x`, so a join on
+   * `b = x` must not pair the two, and neither may a join on `s = t` pair 
`bucket(4, s.a)` with
+   * `bucket(4, t.b)`. Such a transform is not the same as any transform, 
itself included, which
+   * costs a shuffle rather than a match.
+   *
    * @param other the transform expression to compare to
    * @return true if this and `other` has the same semantics w.r.t to 
transform, false otherwise.
    */
-  def isSameFunction(other: TransformExpression): Boolean = functionId == 
other.functionId
+  def isSameFunction(other: TransformExpression): Boolean =
+    argumentsAreAttributes && other.argumentsAreAttributes && functionId == 
other.functionId

Review Comment:
   Thanks, confirmed, and fixed in 
[69f16ab](https://github.com/apache/spark/pull/59165/commits/69f16abd834a271f68e04905d5391b887fcb4a49)
 the way you suggest. `GroupPartitionsExec` now rebuilds `reduced` over the 
member's own argument, the child of a transform or the member itself. So a 
`bucket(4, b + 1)` member becomes `bucket(2, b + 1)`, which the guard refuses. 
`reduced`'s own argument is always a bare column now, since the guard also 
covers the identity arm 
([thread](https://github.com/apache/spark/pull/59165#discussion_r4148432898)).
   
   "SPARK-59887: a reduce keeps what each member's keys are computed from" runs 
your four shapes. It returns 0 of 7 rows without the change.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2199,8 +2203,14 @@ case class KeyedShuffleSpec(
       // cannot rewrite it. This also keeps the unprojected spec returned by 
`createShuffleSpec`
       // for a marked narrowing projection from being chosen as the best spec.
       keyPositions.forall(_.nonEmpty) &&

Review Comment:
   Fixed in 
[69f16ab](https://github.com/apache/spark/pull/59165/commits/69f16abd834a271f68e04905d5391b887fcb4a49),
 as SPARK-59901, which I had filed for this and have now added to the title. 
`keyPositions` maps an expression with more than one reference to no position 
instead of asserting. `canCreatePartitioning` and `areKeysCompatible` both turn 
an empty position away, so a spec over `b + c` pairs with nothing and is no 
layout to shuffle onto. Both of your queries are in "SPARK-59901: a join key 
over two columns is not paired through one of them".
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1943,6 +1943,10 @@ case class KeyedShuffleSpec(
    *
    * The assertion is what makes the reference reading sound. With one 
reference per expression,
    * "some reference is a cluster key" and "every reference is" are the same 
statement.
+   *
+   * This says which cluster key an expression is a function of, not which 
function it is:
+   * `bucket(4, b + 1)` maps to `b` just as `bucket(4, b)` does. Telling those 
two apart is

Review Comment:
   Confirmed. It is a separate consumer, so I filed SPARK-59905 for it rather 
than growing this PR. The same binding also fails planning for `ORDER BY p.b + 
p.c` over the two-column key, with an `ArrayIndexOutOfBoundsException`, so I 
added that shape to the ticket. The `EnsureRequirements` comment that claimed 
one attribute per partition expression for this binding now points there.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2199,8 +2203,14 @@ case class KeyedShuffleSpec(
       // cannot rewrite it. This also keeps the unprojected spec returned by 
`createShuffleSpec`
       // for a marked narrowing projection from being chosen as the best spec.
       keyPositions.forall(_.nonEmpty) &&
-      partitioning.expressions.forall { e =>
-        e.isInstanceOf[AttributeReference] || 
e.isInstanceOf[TransformExpression]
+      partitioning.expressions.forall {
+        case _: AttributeReference => true
+        // `createPartitioning` replaces a transform's argument with the other 
child's cluster key,
+        // which drops whatever surrounds the key, e.g. the `+ 1` of 
`bucket(4, b + 1)` or the `.a`
+        // of `bucket(4, s.a)`. Such a spec's own child would then be laid out 
differently from the
+        // children shuffled onto it.
+        case t: TransformExpression => t.argumentsAreAttributes

Review Comment:
   Thanks, done in 
[69f16ab](https://github.com/apache/spark/pull/59165/commits/69f16abd834a271f68e04905d5391b887fcb4a49).
 `ShuffleSpecCollection.canCreatePartitioning` is now an `exists`, and 
`pickCoPartitionTarget` offers only the winner's members that can serve. 
`PartitioningCollection.createShuffleSpec` admits only members that could 
satisfy the distribution, so a collection without a keyed member hardly ever 
mixes the two answers. The planner, join, bucketed read, AQE and plan stability 
suites pass unchanged. Your two shapes and the `xy` one take 2 shuffles again, 
and the description no longer lists them as costs.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -65,6 +65,16 @@ case class TransformExpression(
   lazy val functionId: TransformFunctionId =
     TransformFunctionId(function.canonicalName(), numBucketsOpt)
 
+  /**
+   * Whether every argument is a bare column, an [[Attribute]]. See 
[[isSameFunction]] for why only
+   * such a transform can be compared with another one. A literal argument is 
a parameter of the
+   * transform rather than a column, so it does not count.
+   */
+  private[sql] lazy val argumentsAreAttributes: Boolean = children.forall {
+    case _: Literal => true
+    case c => c.isInstanceOf[Attribute]

Review Comment:
   Right. I added it to the costs in the description. I'd rather not treat an 
up-cast as the column in a fix meant for the maintenance branches. That leans 
on each connector's canonical name meaning the same across input types.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -1520,6 +1520,113 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  // Each key is a function of `b` other than `b` itself. A check on the 
leaves of the key would
+  // reject `b + 1` for its literal, but not `-b`, so both are tested.
+  Seq("p.b + 1" -> ((b: Long) => b + 1), "-p.b" -> ((b: Long) => -b)).foreach {
+    case (key, keyOf) =>
+      // `plain` holds `b` in 0..7, and `keys`, bucketed on `id`, holds the 
key of each `b`.
+      def createKeyTables(): Unit = {
+        createTable("plain", Array(Column.create("b", LongType)), Array.empty)
+        sql("INSERT INTO testcat.ns.plain VALUES " + (0 until 8).map(b => 
s"($b)").mkString(", "))
+        createTable("keys", Array(Column.create("id", LongType)), 
Array(bucket(4, "id")))
+        sql("INSERT INTO testcat.ns.keys VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)})").mkString(", "))
+      }
+
+      test("SPARK-59887: a side shuffled onto a transform of an expression is 
not paired as it " +
+          s"is ($key)") {
+        createKeyTables()
+        Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 
8))
+
+        // The first join shuffles `plain` onto the bucket of its key, which 
is a function of `b`,
+        // but not the same function of it as the third table's `bucket(n, 
id)` is of `id`. The
+        // second join clusters `plain` on `b`, so pairing the two as they 
stand, or after reducing
+        // `bucket(8, id)` onto 4 buckets, puts a row with `b = id` on a 
different partition on
+        // each side.
+        Seq(4 -> "false", 8 -> "true").foreach { case (third, 
allowCompatibleTransforms) =>
+          withSQLConf(
+            SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+            SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> 
allowCompatibleTransforms) {
+            val df = sql(
+              s"""SELECT k.id, p.b, t.id FROM testcat.ns.keys k
+                 |JOIN testcat.ns.plain p ON k.id = $key
+                 |JOIN testcat.ns.bucket$third t ON p.b = t.id""".stripMargin)
+            checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong, 
b.toLong)))
+            // One shuffle per join: `plain` for the first, the first join's 
output for the
+            // second, and none for the third table. The second join does not 
shuffle onto the
+            // bucket of the key, since that would move both of its sides.
+            
assert(collectShuffles(stripAQEPlan(df.queryExecution.executedPlan)).size == 2)
+          }
+        }
+      }
+
+      test(s"SPARK-59887: a side is not shuffled onto a transform of an 
expression ($key)") {
+        createKeyTables()
+        createTable("xy", Array(Column.create("x", LongType), 
Column.create("y", LongType)),
+          Array.empty)
+        sql("INSERT INTO testcat.ns.xy VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)}, $b)").mkString(", "))
+
+        // The first join shuffles `plain` onto the bucket of its key, so its 
output offers that
+        // layout next to `bucket(4, id)`. The second join must not shuffle 
`xy` onto the first
+        // one. That builds `bucket(4, y)` for `xy`, not the bucket of the 
same function of `y`,
+        // so a row with `b = y` lands in a different bucket on each side.
+        withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+          val df = sql(
+            s"""SELECT k.id, p.b, q.x FROM testcat.ns.plain p

Review Comment:
   Good point, done in 
[69f16ab](https://github.com/apache/spark/pull/59165/commits/69f16abd834a271f68e04905d5391b887fcb4a49).
 The test now runs both join orders, and it asserts 2 shuffles with none keyed 
on `y`.
   



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/TransformExpressionSuite.scala:
##########
@@ -116,4 +135,32 @@ class TransformExpressionSuite extends SparkFunSuite {
     // `GroupPartitionsExec` apply to a reported partitioning.
     assert(left.hasSameReducedKeys(left.withReference(b)))
   }
+
+  test("SPARK-59887: a transform of an expression is not the same as any 
transform") {
+    // A join pairs its two sides up by the column each partition expression 
references, so over
+    // `a = b` it would pair `bucket(4, a + 1)` with `bucket(4, b)`. A row 
with `a = b` sits in a
+    // different bucket on each side.
+    val fn = new NamedFunction("test.bucket")
+    val overExpression = bucket(fn, Add(a, Literal(1)))
+    assert(bucket(fn, a).isSameFunction(bucket(fn, b)), "two columns are the 
same function")
+    assert(!overExpression.isSameFunction(bucket(fn, b)))
+    assert(!bucket(fn, b).isSameFunction(overExpression))
+    assert(!overExpression.isSameFunction(overExpression), "not even as 
itself")
+
+    // A struct field is not the column the pairing uses either: that is the 
struct.

Review Comment:
   Added in 
[69f16ab](https://github.com/apache/spark/pull/59165/commits/69f16abd834a271f68e04905d5391b887fcb4a49).
 The unit test moved to `ShuffleSpecSuite` together with the guard, and it now 
runs every assertion, the reduce in both directions included, for the struct 
field as well.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -117,7 +127,10 @@ case class TransformExpression(
    */
   def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
     (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
+      // A reduce pairs the two sides up the same way `isSameFunction` does, 
so it needs the same
+      // arguments.
+      case (e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _])
+          if argumentsAreAttributes && other.argumentsAreAttributes =>

Review Comment:
   Agreed, moved in 
[69f16ab](https://github.com/apache/spark/pull/59165/commits/69f16abd834a271f68e04905d5391b887fcb4a49).
 `TransformExpression` is back to master. The guard is 
`KeyedShuffleSpec.argumentsAreColumns`, asked at the entry of 
`isExpressionCompatible` and by `canCreatePartitioning`.
   
   At the entry it also covers the identity arm. That arm used to reduce an 
identity side onto `bucket(4, b + 1)` by evaluating `bucket(4, id + 1)` on the 
identity side's partition keys while planning. Under ANSI, a key of 
`Long.MaxValue` then fails the query with `ARITHMETIC_OVERFLOW`, although the 
query never computes `id + 1`. "SPARK-59887: an identity side is not reduced 
onto a transform of an expression" covers it.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2199,8 +2203,14 @@ case class KeyedShuffleSpec(
       // cannot rewrite it. This also keeps the unprojected spec returned by 
`createShuffleSpec`
       // for a marked narrowing projection from being chosen as the best spec.
       keyPositions.forall(_.nonEmpty) &&
-      partitioning.expressions.forall { e =>
-        e.isInstanceOf[AttributeReference] || 
e.isInstanceOf[TransformExpression]
+      partitioning.expressions.forall {
+        case _: AttributeReference => true
+        // `createPartitioning` replaces a transform's argument with the other 
child's cluster key,

Review Comment:
   Thanks, this is very useful for SPARK-59900, the ANSI point especially. 
Substituting the reference makes the shuffled side evaluate `y + 1` on rows 
where the query never computes it.
   



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