HyukjinKwon commented on code in PR #59344:
URL: https://github.com/apache/spark/pull/59344#discussion_r4267146118


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/joins/SplitStreamedSideJoinConditionSuite.scala:
##########
@@ -151,6 +163,15 @@ class SplitStreamedSideJoinConditionSuite extends 
QueryTest with SharedSparkSess
       |ON s.id = b.bid AND s.a < 5
     """.stripMargin))
 
+  testWithCodegenOnAndOff(
+    "left outer join with lossless widening cast in streamed-only condition")(
+    spark.sql("""
+      |SELECT /*+ BROADCAST(b) */ s.id, s.a, s.pad, b.bid
+      |FROM (SELECT id, CAST(id AS int) AS a, pad FROM streamed_t WHERE pad % 
2 = 0) s
+      |LEFT OUTER JOIN (SELECT DISTINCT bid FROM build_t) b
+      |ON s.id = b.bid AND CAST(s.a AS bigint) < 5L

Review Comment:
   **Nit (P3):** This conjunct doesn't survive the optimizer. 
`UnwrapCastInBinaryComparison` (operator optimization batch) matches `Cast(s.a: 
INT, LongType) < Literal(5L)`: `canUnwrapCast(IntegerType, LongType)` falls 
through to `Cast.canUpCast`, and 5 is in INT range. `simplifyNumericComparison` 
therefore rewrites it to `s.a < 5` before planning. By the time 
`StreamedSideJoinCondition.split` runs there is no `Cast`, so this is 
effectively the existing "left outer join, unique build key" case, and it 
passes with the `ExprUtils` change reverted. `checkDataFrame` only compares 
hoisting on vs. off, so it can't tell either way.
   
   To actually cover a hoisted cast, use a pair that `canUpCast` admits but 
`canUnwrapCast` rejects, e.g. `CAST(s.id AS DOUBLE) < 5.0D` (Long -> Double is 
excluded by SPARK-39476), or compare against a non-literal streamed column. 
Then assert from the executed `BroadcastHashJoinExec` that `split` puts the 
`Cast` conjunct in the streamed-only part.
   
   **Verification:**
   - **Regression:** With the Cast admission arm reverted, the end-to-end case 
fails its hoisting assertion. With the arm present, it passes.
   - **Behavior:** A left outer broadcast hash join whose streamed-only 
condition contains a surviving numeric up-cast returns the same rows with 
hoisting enabled and disabled, with whole-stage codegen on and off.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ExprUtils.scala:
##########
@@ -262,6 +264,9 @@ object ExprUtils extends EvalHelper with QueryErrorsBase {
 
   private def canEvaluateUnconditionallyInternal(e: Expression): Boolean = e 
match {
     case _: AttributeReference | _: Literal => true
+    case Cast(child, toType, _, _) =>
+      child.dataType.isInstanceOf[NumericType] && 
toType.isInstanceOf[NumericType] &&
+        Cast.canUpCast(child.dataType, toType) && 
canEvaluateUnconditionallyInternal(child)

Review Comment:
   **Non-blocking (P2):** `Cast.canUpCast` isn't a no-throw guarantee for 
decimal -> integral. `UpCastRule` admits `(DecimalType, NumericType)` via 
`DecimalType.isTighterThan`, which for an integral target only checks 
`precision < DecimalType.forType(target).precision && scale == 0`. 
`LongDecimal` is `DecimalType(20, 0)`, so `DECIMAL(19,0) -> BIGINT` is 
admitted, but `DECIMAL(19,0)` can hold `9999999999999999999`, which is larger 
than `Long.MaxValue`. Under ANSI (the default), both the interpreted path 
(`exactNumeric.toLong` -> `Decimal.roundToLong`) and codegen 
(`castDecimalToIntegralTypeCode`) call `longValueExact` and throw 
`CAST_OVERFLOW`.
   
   With `spark.sql.join.splitStreamedSideJoinCondition=true`, something like
   
   ```sql
   SELECT * FROM s LEFT JOIN b ON s.k = b.k AND CAST(s.d AS BIGINT) > s.id  -- 
s.d: DECIMAL(19,0)
   ```
   
   now gets the cast conjunct hoisted by `StreamedSideJoinCondition.split`. A 
non-literal comparand, so `UnwrapCastInBinaryComparison` leaves the cast alone. 
A streamed row with `d = 9999999999999999999` and no matching `b.k` used to 
come back null-extended, and now fails the whole query. That is exactly the 
outer/anti-result-to-exception case this whitelist exists to prevent.
   
   The narrowest fix is to not rely on `canUpCast` for decimal sources with 
non-decimal targets in this arm. For example, also require 
`!child.dataType.isInstanceOf[DecimalType] || 
toType.isInstanceOf[DecimalType]`, and add a negative `ExprUtilsSuite` case for 
`Cast(<DECIMAL(19,0) attr>, LongType)`. Changing `isTighterThan` itself would 
also affect analyzer up-cast validation, so I'd keep the fix local here.
   
   **Verification:**
   - **Regression:** A unit assertion that canEvaluateUnconditionally rejects a 
Cast of a DECIMAL(19,0) attribute to BIGINT. It fails against the current PR 
head.
   - **Behavior:** Unit assertions that integral -> wider-decimal and decimal 
-> wider-decimal casts over attributes remain admitted, and that the existing 
Int -> Long admission still holds.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ExprUtils.scala:
##########
@@ -243,14 +243,16 @@ object ExprUtils extends EvalHelper with QueryErrorsBase {
    *
    * Only a whitelist of total, deterministic expressions qualifies:
    *   - leaves: attribute references and literals;
+   *   - lossless widening numeric casts: Cast where both types are numeric 
and Cast.canUpCast

Review Comment:
   **Nit (P3):** "Lossless" isn't accurate for everything `Cast.canUpCast` 
admits: via `legalNumericPrecedence` it also accepts Int -> Float and Long -> 
Float/Double. For example, `16777217` -> `1.6777216E7`, or `9007199254740993L` 
-> `9.007199254740992E15`. `UnwrapCastInBinaryComparison.canUnwrapCast` 
explicitly excludes those pairs for that reason (SPARK-39476). They never 
throw, so the whitelist is still safe for them. What it actually relies on is 
that the cast is total and non-throwing, so wording like "non-throwing widening 
numeric up-casts (Cast.canUpCast)" would describe the contract accurately and 
avoid someone later treating these casts as injective.
   
   **Verification:**
   - **Inspection:** The whitelist doc's description of admitted casts matches 
the implemented admission rule and makes no losslessness claim that Int/Long -> 
Float/Double casts would violate.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/ExprUtilsSuite.scala:
##########
@@ -44,6 +46,10 @@ class ExprUtilsSuite extends SparkFunSuite {
     assert(!arithmetic.throwable)
     assert(!ExprUtils.canEvaluateUnconditionally(arithmetic))
     assert(!ExprUtils.canEvaluateUnconditionally(EqualTo(Remainder(a, 
Literal(3L)), Literal(0L))))
+    // Narrowing cast (Long -> Int), parsing cast (String -> Int), and 
non-numeric cast.

Review Comment:
   **Nit (P3):** None of these negative cases depends on the 
`canEvaluateUnconditionallyInternal(child)` recursion in the new `Cast` arm. 
All three are rejected by the type and `canUpCast` checks first, and the 
existing "whitelisted predicate over a non-whitelisted child" case uses 
`IsNotNull`, not `Cast`. If the recursion were dropped, everything here would 
still pass while `Cast(Add(i, Literal(1)), LongType)` (with `Add` able to 
overflow under ANSI) became hoistable. A one-line negative assertion like 
`assert(!ExprUtils.canEvaluateUnconditionally(Cast(Add(i, Literal(1)), 
LongType)))` would pin it.
   
   **Verification:**
   - **Regression:** With the child-recursion conjunct removed from the Cast 
arm, the new assertion fails. With it present, the assertion passes.



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