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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -596,47 +596,42 @@ case class KeyedPartitioning(
    * The types the `partitionKeys` rows were built with. Anything reading 
those rows should take its
    * types from here. It is a driver-side value, since `partitionKeys` is 
`@transient`.
    *
-   * They are the `expressionDataTypes` unless a reducer has rewritten the 
keys. With
-   * `v2BucketingAllowCompatibleTransforms` a storage-partitioned join reduces 
one or both sides'
-   * keys onto a common key space, while the partitioning keeps reporting the 
expressions it was
-   * built from. Joining an `identity(ts)`-partitioned table to a 
`years(ts)`-partitioned one leaves
-   * `IntegerType` year values under a `TimestampType`-declared expression. 
With no key at all the
-   * expressions are all there is, and there is no row to read or to place.
+   * They differ from the `expressionDataTypes` in two cases. A join that 
reduced both sides' keys
+   * onto a key space no transform names leaves a marked expression whose type 
can be anything, see
+   * `expressionsDescribeKeys`. A one-side reduce keeps them equal, because 
the expression the
+   * partitioning then reports is the target transform and 
`EnsureRequirements` refuses a reducer
+   * whose result type disagrees with it. 
`KeyedShuffleSpec.createPartitioning` is the other case.
+   * It puts the other child's expressions over these keys with no reducer in 
sight, so a struct
+   * field can be named differently on the two sides. With no key at all the 
expressions are all
+   * there is, and there is no row to read or to place.

Review Comment:
   Thank you, this reproduces. I measured it both ways, on `days`/`years` 
tables where one leg's two sides hold disjoint keys, so the intersect empties 
it.
   
   On this PR the query fails with 
`STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. On the base commit the 
same query fails with `ClassCastException: Long cannot be cast to Integer`, 
from applying a reducer to already reduced values. So the shape is broken 
either way and nothing here regresses.
   
   One correction to the framing. The untruthful fallback is pre-existing, it 
arrived with SPARK-59120. The failure at the reduced-types check becomes 
reachable through this PR, because once no reducer is derived for an already 
reduced pair both sides read `keyDataTypes` directly. Before that the reducers 
were computed and both sides reported the reducer's type, so the check passed 
and the query died later.
   
   Filed as SPARK-59176 with the repro and the two ways to fix it. The scaladoc 
now points at it, and I have started on it in a separate branch.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -122,27 +147,78 @@ case class TransformExpression(
     Option(res)
   }
 
+  /**
+   * The unordered pair of transforms whose reduce produced this expression's 
keys, which is what
+   * identifies the key space they landed in. Reducing is symmetric, so the 
two sides of one reduce
+   * carry the same pair.
+   */
+  private def reducedKeySpace: Option[Set[TransformFunctionId]] =
+    reducedWith.map(partner => Set(functionId, partner))
+
+  /**
+   * Whether this and `other` describe the same reduced key space, i.e. 
whether the same pair of
+   * transforms was reduced together to produce both.
+   *
+   * Two reduces that happen to land on the same space through different 
pairings are not
+   * recognised as one. For instance `bucket(12)` with `bucket(8)` and 
`bucket(12)` with
+   * `bucket(20)` both reduce onto `id % 4`. The [[Reducer]] API does not name 
the space it reduces
+   * onto, so the pairing is all there is to compare.
+   */
+  def hasSameReducedKeys(other: TransformExpression): Boolean =
+    reducedWith.isDefined && reducedKeySpace == other.reducedKeySpace
+
+  /** Records that this expression's keys were reduced together with 
`other`'s. */
+  def reducedTogetherWith(other: TransformExpression): TransformExpression =
+    copy(reducedWith = Some(other.functionId))
+
   override def dataType: DataType = function.resultType()
 
   override protected def withNewChildrenInternal(newChildren: 
IndexedSeq[Expression]): Expression =
     copy(children = newChildren)
 
-  private lazy val resolvedFunction: Option[Expression] = this match {
-    case TransformExpression(scalarFunc: ScalarFunction[_], arguments, 
Some(numBuckets)) =>
-      Some(V2ExpressionUtils.resolveScalarFunction(scalarFunc,
-        Seq(Literal(numBuckets)) ++ arguments))
-    case TransformExpression(scalarFunc: ScalarFunction[_], arguments, None) =>
+  /**
+   * Builds the scalar function call this transform stands for, with the 
bucket count prepended as
+   * a literal argument. `None` when the bound function is not a 
[[ScalarFunction]], and also when a
+   * join reduced this expression's keys, since then the call no longer 
computes them. Evaluating it
+   * to place a row would send the row to a partition it does not belong to. 
That second arm is a
+   * local gate. No caller reaches it today, because every consumer of a 
reduced partitioning
+   * refuses it first, and the write path never sees one.
+   *
+   * A fresh expression per call. The result can be stateful 
([[ApplyFunctionExpression]]), so a
+   * caller that puts it in a plan must not share one instance across two 
positions.
+   */
+  def resolveFunctionCall(): Option[Expression] = function match {
+    case scalarFunc: ScalarFunction[_] if reducedWith.isEmpty =>
+      val arguments = numBucketsOpt.fold(children)(n => Literal(n) +: children)
       Some(V2ExpressionUtils.resolveScalarFunction(scalarFunc, arguments))
     case _ => None
   }
 
-  override def eval(input: InternalRow): Any = {
-    resolvedFunction match {
-      case Some(fn) => fn.eval(input)
-      case None => throw 
QueryExecutionErrors.cannotEvaluateExpressionError(this)
-    }
+  /**
+   * Memoised for `eval`, which runs per row. Safe to reuse only because it 
stays inside this
+   * expression, unlike the call `resolveFunctionCall` hands to a caller that 
plans with it.
+   */
+  private lazy val evaluableFunctionCall: Option[Expression] = 
resolveFunctionCall()

Review Comment:
   Agreed, and both mechanisms are there. 
`QueryExecution.cloneWithFreshStatefulExpressions` maps every node's 
expressions through `freshCopyIfContainsStatefulExpression()` before 
optimization, and `ExpressionsEvaluator.prepareExpressions` does the same per 
evaluator. So the hazard the split guarded against cannot arise.
   
   Collapsed back into one `lazy val resolvedFunction`, un-private, with the 
`reducedWith` guard, and both caveat comments are gone.
   



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