Repository navigation
[SPARK-58385][SQL] Do not strip a GlobalLimit whose cap is not statically known in LimitPushDown - #58065
Open
Vivek1106-04 wants to merge 1 commit into
Open
[SPARK-58385][SQL] Do not strip a GlobalLimit whose cap is not statically known in LimitPushDown#58065Vivek1106-04 wants to merge 1 commit into
Vivek1106-04 wants to merge 1 commit into
Conversation
…ally known in LimitPushDown
`LimitPushDown.maybePushLocalLimit` decided whether a pushed-down limit may replace a
child's existing limit by inspecting `plan.maxRowsPerPartition`. The `(_, None)` arm read
`None` as "the child has no cap" and called `stripGlobalLimitIfPresent`, but `None` also
means "the cap is not statically known".
`GlobalLimit.maxRows` recognizes only `IntegerLiteral`, and `LimitPushDown` is listed
before `ConstantFolding` in the same operator-optimization rule set, so a foldable
non-literal limit such as `LIMIT 2+3` is still `Add(2, 3)` when the rule runs. The rule
therefore deleted a semantic `GlobalLimit`, replacing a global row cap with a
per-partition one and returning too many rows:
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);
-- 10, expected 5
Foldable non-literal LIMIT expressions are valid SQL - `checkLimitLikeClause` accepts any
foldable integer expression - and arise from templated SQL that renders arithmetic into
the query text.
The `(_, None)` arm now pushes the new `LocalLimit` without stripping the child
`GlobalLimit`. Stripping remains in the literal arm, where the cap is known and the
`newLimit < childMaxRows` guard holds. That arm is the only one a `GlobalLimit` with a
literal limit can reach, so the removed `stripGlobalLimitIfPresent` call was reachable
only for the incorrect case.
uros-b
approved these changes
Aug 18, 2026
Member
|
LGTM. Correct |
Member
|
Just please re-run the CI |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
LimitPushDown.maybePushLocalLimitdecides whether a pushed-down limit may replace a child's existing limit by inspectingplan.maxRowsPerPartition:The
(_, None)arm readNoneas "the child has no cap" and stripped a childGlobalLimitunconditionally. ButNonealso means "the cap is not statically known".This PR changes that arm to push the new
LocalLimitwithout stripping the childGlobalLimit. Stripping stays in the literal arm, where the cap is known and thenewLimit < childMaxRowsguard holds.Note that a
GlobalLimitwith a literal limit always reportsSome(cap)and so can only reach the literal arm. ThestripGlobalLimitIfPresentcall removed here was therefore reachable only for the incorrect case, and no correct plan change is lost.Why are the changes needed?
This is a correctness bug present since Spark 2.0.0.
GlobalLimit.maxRowsrecognizes onlyIntegerLiteral, andLimitPushDownis listed beforeConstantFoldingin the same operator-optimization rule set. So a limit written as a foldable expression (LIMIT 2+3) is stillAdd(2, 3)on the first fixed-point pass,maxRowsPerPartitionreturnsNone, and the rule deletes the semanticGlobalLimit— replacing a global row cap with a per-partition one.Foldable non-literal LIMIT expressions are valid SQL:
checkLimitLikeClauseaccepts any foldable integer expression. They arise naturally from templated or generated SQL that renders arithmetic into the query text, e.g. a pagination template producingLIMIT 20*5.Repro:
The larger outer limit strips the nested global limit during its pushdown, so the inner
LIMIT 5becomes a per-partition cap overlb's 4 partitions. Manually folding the inner limit to5produces the correct answer, which is what makes this specific to the unfolded form.This is related to SPARK-57956 in that both are about incorrect limit stripping, but the underlying causes differ: SPARK-57956 was
LogicalQueryStage.maxRowssurfacing a cost estimate as a hard bound, this one isNonebeing read as "uncapped" rather than "unknown".Does this PR introduce any user-facing change?
Yes, it fixes the wrong results shown above. Queries whose plans contain a
GlobalLimitwith a foldable non-literal limit expression underneath a pushed-down limit now return the correct number of rows.There is no change for limits written as plain literals, which is the overwhelmingly common case: those take the literal arm, whose behavior is untouched.
One secondary effect worth flagging: for a foldable non-literal child limit the rule now keeps the
GlobalLimitinstead of removing it, so the plan retains one more node than before. Results are correct in both directions of that change — the previous plan was simply wrong. OnceConstantFoldingfolds the expression on a later pass, the surrounding limit rules simplify the plan as usual.The alternative fix suggested on the JIRA — teaching
GlobalLimit.maxRowsto evaluate foldable limit expressions — would additionally let the guarded literal arm apply here and recover the stripping. That changesmaxRowssemantics for every consumer of the API, so it is deliberately left out of this correctness fix and can be pursued separately as an optimization.How was this patch tested?
New tests, each verified to fail without the fix:
LimitPushdownSuite, two plan tests covering the join andUnionpushdown paths, asserting the childGlobalLimitsurvives. Without the fix the optimized plan dropsGlobalLimit 2and keeps onlyLocalLimit 2.SQLQuerySuite, an end-to-end test using the repro above. Without the fix it returns10instead of5.Existing regression suites, all green:
catalyst/testOnly org.apache.spark.sql.catalyst.optimizer.*— 1409 testssql/testOnly org.apache.spark.sql.SQLQuerySuite org.apache.spark.sql.JoinSuite org.apache.spark.sql.DataFrameSetOperationsSuite org.apache.spark.sql.execution.adaptive.AdaptiveQueryExecSuite— 510 testsWas this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)