dongjoon-hyun commented on code in PR #59194:
URL: https://github.com/apache/spark/pull/59194#discussion_r4328844923


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1008,8 +1008,10 @@ object LimitPushDown extends Rule[LogicalPlan] {
       val newAgg = EliminateSorts(a.copy(child = LocalLimit(le, 
a.child))).asInstanceOf[Aggregate]
       Limit(le, p.copy(child = Project(newAgg.aggregateExpressions, 
newAgg.child)))
     // Merge offset value and limit value into LocalLimit and pushes down 
LocalLimit through Offset.
+    // Fold the merged limit so that the LocalLimit can be planned without a 
later ConstantFolding
+    // run: BasicOperators only plans LocalLimit(IntegerLiteral, _).
     case LocalLimit(le, Offset(oe, grandChild)) =>
-      Offset(oe, LocalLimit(Add(le, oe), grandChild))
+      Offset(oe, LocalLimit(ConstantFolding.constantFolding(Add(le, oe)), 
grandChild))

Review Comment:
   **The analysis check does not cover every `LocalLimit` over `Offset` that 
reaches this case, so this sum can still overflow.** The PR description says 
the overflow case "is already rejected during analysis with 
`SUM_OF_LIMIT_AND_OFFSET_EXCEEDS_MAX_INT`", but CheckAnalysis.scala:921-935 
only checks a `LocalLimit` whose direct child is an `Offset` in the analyzed 
plan. The optimizer also creates such pairs, which the analyzer never sees: the 
`Union` case at L976-977 and `maybePushLocalLimit`'s `case (_, None)` at 
L942-944 push the limit onto an `Offset` branch, the semi/anti join case at 
L1016-1017 pushes `Literal(1)`, and `PushProjectionThroughLimitAndOffset` or 
`RemoveNoopOperators` can move or remove a `Project` between them. For example, 
with a table `t` that has 3 rows:
   
   ```sql
   (SELECT * FROM t OFFSET 1) UNION ALL (SELECT * FROM t) LIMIT 2147483647
   ```
   
   - With ANSI on (the default), this valid query fails with 
`[ARITHMETIC_OVERFLOW]`, although the user wrote no addition.
   - With ANSI off, the sum wraps to -2147483648, and `LocalLimitExec` returns 
no rows for that branch (limit.scala:200 and :233), so the query silently 
returns 3 rows instead of 5.
   - With ANSI off, `SELECT * FROM (SELECT * FROM t OFFSET 10) LIMIT 
2147483647` still matches `OffsetAndLimit`, because `globalLimit + offset == 
localLimit` (basicLogicalOperators.scala:2041) wraps the same way, and then 
fails the `CollectLimitExec` assertion (limit.scala:49) as an INTERNAL_ERROR.
   
   This is not a regression in the default configuration, since 
`ConstantFolding` produced the same values before. But with `ConstantFolding` 
excluded and ANSI off, the `UNION ALL` query used to fail with `No plan for 
LocalLimit (2147483647 + 1)`, and with this PR it returns wrong rows silently. 
I traced this by reading the code and did not run it. I know asugranyes pointed 
out on #56180 that an `Add` left behind cannot be planned without 
`ConstantFolding`, but that only affects sums that overflow, which cannot give 
a correct plan here anyway, and keeping the `Add` makes them fail loudly 
instead of returning wrong rows. Could you fold only when the sum fits in an 
`Int`, e.g.
   
   ```scala
   val merged = Add(le, oe)
   val fits = le.eval().asInstanceOf[Int].toLong + oe.eval().asInstanceOf[Int] 
<= Int.MaxValue
   Offset(oe, LocalLimit(if (fits) ConstantFolding.constantFolding(merged) else 
merged, grandChild))
   ```
   
   and also correct the overflow sentence in the PR description, with a 
follow-up JIRA for the overflow itself?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/LimitPushdownSuite.scala:
##########
@@ -335,6 +335,16 @@ class LimitPushdownSuite extends PlanTest {
       GlobalLimit(1, Offset(2, LocalLimit(3, testRelation))).analyze)
   }
 
+  test("SPARK-57125: Push down limit through Offset folds the merged limit") {

Review Comment:
   Minor: This test checks the output of `LimitPushDown` alone, but nothing 
checks the user-visible part of the fix: that a `LIMIT`/`OFFSET` query is 
planned and returns the right rows when `ConstantFolding` is excluded. 
SPARK-59044 fixed the same kind of failure for `EliminateOffsets` and added an 
end-to-end test for both planning paths, "SPARK-59044: OFFSET 0 succeeds when 
EliminateOffsets is in excludedRules" (SQLQuerySuite.scala:5359 on master). For 
example:
   
   ```scala
   withSQLConf(SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> 
ConstantFolding.ruleName) {
     checkAnswer(sql("SELECT id FROM range(10) LIMIT 1 OFFSET 2"), Row(2))
     checkAnswer(sql("SELECT count(*) FROM (SELECT id FROM range(10) LIMIT 2 
OFFSET 2)"), Row(2))
   }
   ```
   
   By my reading, both queries fail on master with `No plan for LocalLimit 
(...)` and pass with this PR. The source needs more rows than the limit plus 
the offset; otherwise `EliminateLimits` removes the limit before this rule 
runs. Could you add such a test next to the SPARK-59044 one, after a rebase?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1008,8 +1008,10 @@ object LimitPushDown extends Rule[LogicalPlan] {
       val newAgg = EliminateSorts(a.copy(child = LocalLimit(le, 
a.child))).asInstanceOf[Aggregate]
       Limit(le, p.copy(child = Project(newAgg.aggregateExpressions, 
newAgg.child)))
     // Merge offset value and limit value into LocalLimit and pushes down 
LocalLimit through Offset.
+    // Fold the merged limit so that the LocalLimit can be planned without a 
later ConstantFolding
+    // run: BasicOperators only plans LocalLimit(IntegerLiteral, _).
     case LocalLimit(le, Offset(oe, grandChild)) =>

Review Comment:
   **Evaluating the sum here, before `EliminateOffsets` and `EliminateLimits` 
run, turns a query that returns an empty result on master into an error.** In 
the operator optimization batch, this rule (L112) runs before 
`EliminateOffsets` (L122), `EliminateLimits` (L123) and `ConstantFolding` 
(L139), so on master the unfolded `Add` could be pruned with its subtree before 
anything evaluated it. Now it is evaluated right here. For example, with ANSI 
on (the default) and a table `t`:
   
   ```sql
   SELECT * FROM (SELECT * FROM (SELECT * FROM t OFFSET 10) LIMIT 2147483647) 
LIMIT 0
   ```
   
   The analysis check does not apply, because a `Project` sits between the 
inner `LIMIT` and `OFFSET`. On master, this rule leaves `LocalLimit(2147483647 
+ 10, t)` unevaluated, and `EliminateLimits` then replaces the whole plan with 
an empty `LocalRelation` (`case GlobalLimit(IntegerLiteral(0), child)` at 
L3116), so the query returns no rows. With this PR, L1014 evaluates `2147483647 
+ 10` first, and the query fails with `[ARITHMETIC_OVERFLOW]`. 
`spark.range(100).offset(10).select("id").limit(Int.MaxValue).limit(0).collect()`
 behaves the same, and so does an outer `OFFSET 2147483647` instead of `LIMIT 
0`. This is an edge case, but it contradicts "No user-facing change". I traced 
this by reading the code and did not run it. Could you fold only when the sum 
fits in an `Int`, as suggested on L1014, so that this case behaves as on master?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1008,8 +1008,10 @@ object LimitPushDown extends Rule[LogicalPlan] {
       val newAgg = EliminateSorts(a.copy(child = LocalLimit(le, 
a.child))).asInstanceOf[Aggregate]
       Limit(le, p.copy(child = Project(newAgg.aggregateExpressions, 
newAgg.child)))
     // Merge offset value and limit value into LocalLimit and pushes down 
LocalLimit through Offset.
+    // Fold the merged limit so that the LocalLimit can be planned without a 
later ConstantFolding
+    // run: BasicOperators only plans LocalLimit(IntegerLiteral, _).

Review Comment:
   Minor: About the PR description. The "Does this PR introduce _any_ 
user-facing change?" section answers "No", but the template counts bug fixes 
and other behavior changes as user-facing. With `ConstantFolding` excluded, 
`SELECT id FROM range(10) LIMIT 1 OFFSET 2` used to fail with `No plan for 
LocalLimit (1 + 2)` and now returns `2`, and SPARK-59044 answered "Yes" for the 
same kind of change. Also, "`ConstantFolding` folds the `Add` anyway" does not 
mean that the default-configuration plans stay the same: the code that runs 
between this rule and `ConstantFolding` in the same iteration (e.g. 
`maybePushLocalLimit` and `LimitPushDownThroughWindow`, which match 
`IntegerLiteral` limits) now sees the literal one iteration earlier, so some 
optimized plans can differ, although the results do not. Could you answer "Yes" 
there, with the before and after behavior?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -1008,8 +1008,10 @@ object LimitPushDown extends Rule[LogicalPlan] {
       val newAgg = EliminateSorts(a.copy(child = LocalLimit(le, 
a.child))).asInstanceOf[Aggregate]
       Limit(le, p.copy(child = Project(newAgg.aggregateExpressions, 
newAgg.child)))
     // Merge offset value and limit value into LocalLimit and pushes down 
LocalLimit through Offset.
+    // Fold the merged limit so that the LocalLimit can be planned without a 
later ConstantFolding
+    // run: BasicOperators only plans LocalLimit(IntegerLiteral, _).
     case LocalLimit(le, Offset(oe, grandChild)) =>
-      Offset(oe, LocalLimit(Add(le, oe), grandChild))
+      Offset(oe, LocalLimit(ConstantFolding.constantFolding(Add(le, oe)), 
grandChild))

Review Comment:
   Minor: About a backport. `ConstantFolding.constantFolding` became 
`private[sql]` in SPARK-52875, so this line compiles on master, branch-4.x, 
branch-4.2 and branch-4.1. On branch-4.0 it is still `private def 
constantFolding(` (expressions.scala:74), so a cherry-pick there would not 
compile, and branch-4.0 has no `PushProjectionThroughLimitAndOffsetSuite`. If 
this fix should also go to branch-4.0, could you note in the JIRA that the 
backport needs a separate patch (e.g. evaluating the sum directly, as 
`EliminateLimits` does at L3119-3124)?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/LimitPushdownSuite.scala:
##########
@@ -335,6 +335,16 @@ class LimitPushdownSuite extends PlanTest {
       GlobalLimit(1, Offset(2, LocalLimit(3, testRelation))).analyze)
   }
 
+  test("SPARK-57125: Push down limit through Offset folds the merged limit") {
+    // The merged LocalLimit must be folded without a later ConstantFolding 
run.
+    object OptimizeOnlyLimitPushDown extends RuleExecutor[LogicalPlan] {

Review Comment:
   Nit: This local executor is left over from the first commit, where it served 
two cases. With one case left, it wraps a single rule, and 
`comparePlans(LimitPushDown(testRelation.offset(2).limit(1).analyze), ...)` 
does the same, as other optimizer suites do (e.g. 
InferFiltersFromConstraintsSuite.scala:398). The input and the expected plan 
are also the same as in the test above, so only the missing `ConstantFolding` 
differs. Separately, "How was this patch tested?" could also mention the 
updated `PushProjectionThroughLimitAndOffsetSuite` expectations, which fail 
without the fix too. Could you call the rule directly here, unless you prefer 
the `RuleExecutor` style of this suite?



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