Josh Rosen created SPARK-58385:
----------------------------------

             Summary: LimitPushDown incorrectly removes a GlobalLimit whose 
limit expression is not a literal
                 Key: SPARK-58385
                 URL: https://issues.apache.org/jira/browse/SPARK-58385
             Project: Spark
          Issue Type: Bug
          Components: SQL
    Affects Versions: 4.0.0
            Reporter: Josh Rosen


This is a report of a correctness bug present in Spark 2.0.0 and all later 
versions.

[{{LimitPushDown.maybePushLocalLimit}}|https://github.com/apache/spark/blob/5ce51a624421acceec378e0170da24a8fc2a96eb/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala#L916]
 decides whether a pushed-down limit may replace a child's existing limit by 
inspecting {{{}plan.maxRowsPerPartition{}}}:
{code:java}
 private def maybePushLocalLimit(limitExp: Expression, plan: LogicalPlan): 
LogicalPlan = {
    (limitExp, plan.maxRowsPerPartition) match {
      case (IntegerLiteral(newLimit), Some(childMaxRows)) if newLimit < 
childMaxRows =>
        // If the child has a cap on max rows per partition and the cap is 
larger than
        // the new limit, put a new LocalLimit there.
        LocalLimit(limitExp, stripGlobalLimitIfPresent(plan))

      case (_, None) =>
        // If the child has no cap, put the new LocalLimit.
        LocalLimit(limitExp, stripGlobalLimitIfPresent(plan))

      case _ =>
        // Otherwise, don't put a new LocalLimit.
        plan
    }
  }{code}
 * The first arm strips a child {{GlobalLimit}} only under a guard 
({{{}newLimit < childMaxRows{}}}).
 * The {{(_, None)}} arm strips unconditionally, reading {{None}} as "the child 
has no cap" — but {{None}} also means "the cap is statically unknown".
 * 
[{{GlobalLimit.maxRows}}|https://github.com/apache/spark/blob/5ce51a624421acceec378e0170da24a8fc2a96eb/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala#L1896-L1901]
 / {{maxRowsPerPartition}} recognize only {{{}IntegerLiteral{}}}, and a limit 
written as a foldable expression ({{{}LIMIT 2+3{}}}) is still {{Add(2, 3)}} 
when {{LimitPushDown}} runs: it is 
[listed|https://github.com/apache/spark/blob/5ce51a624421acceec378e0170da24a8fc2a96eb/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala#L112]
 before {{ConstantFolding}} in the same operator-optimization rule set, so on 
the first fixed-point pass the expression is unfolded, {{maxRowsPerPartition}} 
returns {{{}None{}}}, and the rule deletes the semantic {{{}GlobalLimit{}}}, 
replacing a global row cap with a per-partition one.
 * Foldable non-literal LIMIT expressions are valid SQL: 
{{checkLimitLikeClause}} accepts any foldable integer expression; they arise 
naturally from templated/generated SQL that renders arithmetic into the query 
text, e.g. a pagination template producing {{{}LIMIT 20*5{}}}.

{*}Repro{*}:
{code:java}
CREATE TABLE la USING parquet AS SELECT 1 AS id;
CREATE TABLE lb USING parquet AS SELECT id FROM range(0, 40, 1, 4);

SELECT count(*) FROM (SELECT * FROM la CROSS JOIN (SELECT * FROM lb LIMIT 2+3) 
LIMIT 100);
-- outputs: 10   WRONG (expected: 5){code}
This returns an incorrect answer because the larger outer limit strips the 
nested global limit. If we manually fold the inner limit to {{5}} .

{*}Fix{*}:
Treat {{None}} as "unknown", not "uncapped": in the {{(_, None)}} arm, never 
strip a {{GlobalLimit}} child (push the {{LocalLimit}} beneath it, or leave the 
plan unchanged). Alternatively (or additionally), make {{GlobalLimit.maxRows}} 
evaluate foldable limit expressions so the guarded arm applies. Stripping is 
only sound when the child's cap is known and the guard {{newLimit < 
childMaxRows}} holds, as the literal arm already implements.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to