Repository navigation
fix: drop the redundant Limit above a Sort in the same PushDownLimit visit - #25672
Conversation
…visit For Limit(skip=0, fetch=n) over a Sort without a fetch, PushDownLimit set Sort.fetch = n but kept the Limit, and removed it only on its next visit in the next optimizer pass. Every ORDER BY ... LIMIT n query therefore needed one more logical optimizer pass. Drop the Limit in the same visit, and apply the TopK-through-join pushdown to the Sort there, because the Sort is now the visited node and is not visited again in this pass.
PushDownLimit now removes the Limit above a Sort in the same visit that gives the Sort its fetch, so a rule that runs later in the pass sees the Sort with a fetch, not Limit over Sort.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25672 +/- ##
==========================================
- Coverage 82.48% 82.48% -0.01%
==========================================
Files 1140 1140
Lines 437614 438847 +1233
Branches 437614 438847 +1233
==========================================
+ Hits 360986 361998 +1012
- Misses 54834 54945 +111
- Partials 21794 21904 +110 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| // so drop the Limit now instead of on the next pass. The | ||
| // Sort then replaces the Limit as the visited node and its | ||
| // own visit is skipped, so apply the TopK pushdown here. | ||
| Ok(Transformed::yes(push_topk_through_join(sort)?.data)) |
There was a problem hiding this comment.
| Ok(Transformed::yes(push_topk_through_join(sort)?.data)) | |
| push_topk_through_join(sort) |
?
push_topk_through_join() may return Transformed::no()
There was a problem hiding this comment.
It is intentional. At this point the Limit is already removed, so the plan changed even when push_topk_through_join returns Transformed::no. Passing that no through would report an unchanged plan. I made this explicit in the code (684da9c) and added limit_push_down_sort_reports_transformed, which fails with the suggested change.
| @@ -171,8 +171,16 @@ fn rewrite_limit(mut limit: Limit) -> Result<Transformed<LogicalPlan>> { | |||
There was a problem hiding this comment.
Would that benefit of using push_topk_through_join(sort) too ?!
There was a problem hiding this comment.
Yes, good catch. That branch has the same skipped visit, so the TopK pushdown waited one more pass there too. In 684da9c both skip == 0 branches share one path that calls push_topk_through_join, and topk_pushed_through_limit_over_sort_with_{same,smaller}_fetch_in_one_pass cover it with max_passes = 1.
… is dropped When the Sort already has an equal or smaller fetch, PushDownLimit also drops the Limit and returns the Sort, so the Sort's own visit is skipped in that pass. Both skip = 0 paths now share one branch that calls push_topk_through_join. The result is always marked transformed, because the Limit is removed even when the TopK does not move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imit` visit (apache#25672) ## Which issue does this PR close? - Closes apache#25671. ## Rationale for this change For `Limit(skip=0, fetch=n)` over a `Sort` without a fetch, `PushDownLimit` sets `Sort.fetch = n` but keeps the `Limit`. It removes the `Limit` only on its next visit, in the next optimizer pass. Thus every `ORDER BY ... LIMIT n` query runs 3 logical optimizer passes where 2 are enough. The final plan does not change. Only planning time does. A query engine that wraps every query in a row-cap `LIMIT` over an `ORDER BY` hits this on nearly every query. Reproduction, `datafusion-cli` on `main` (95bb0a0): ```sql CREATE TABLE t (a INT, ts TIMESTAMP) AS VALUES (1, TIMESTAMP '2026-01-01T00:00:00'); EXPLAIN VERBOSE SELECT a FROM t ORDER BY ts DESC LIMIT 10; ``` Rules that change the plan, per pass (the `logical_plan after <rule>` rows that are not `SAME TEXT AS ABOVE`): ```text main: pass 0: push_down_limit, optimize_projections pass 1: push_down_limit <- removes the Limit it kept in pass 0 pass 2: (no change) this PR: pass 0: push_down_limit, optimize_projections pass 1: (no change) ``` The final `logical_plan` is identical on both: ```text Projection: t.a Sort: t.ts DESC NULLS FIRST, fetch=10 TableScan: t projection=[a, ts] ``` ## What changes are included in this PR? In `rewrite_limit`, `LogicalPlan::Sort` branch, when the rule gives the `Sort` a new fetch and `skip == 0`, it now returns the `Sort` and drops the `Limit` in the same visit. With `skip > 0` the `Limit` stays, as before. When the `Sort` already has an equal or smaller fetch and `skip == 0`, `main` already dropped the `Limit`. Both `skip == 0` cases now share one branch. One detail needs attention. The optimizer applies `PushDownLimit` top-down and then recurses into the children of the node that the rule returns. Before this change, the `Sort` was a child of the returned `Limit`, so the same pass also visited it and applied the TopK-through-join pushdown (`push_topk_through_join`). Now the `Sort` is the returned node, so the traversal does not visit it again in this pass. The `skip == 0` branch therefore calls `push_topk_through_join` on the `Sort` directly. Without that call, `Limit -> Sort -> Left Join` would move the extra pass from the `Limit` removal to the TopK pushdown instead of removing it. The same was already true on `main` for a `Sort` that already had a fetch, so that case also gets the TopK pushdown in the same pass now. The branch always returns `Transformed::yes`, also when `push_topk_through_join` returns `Transformed::no`, because the `Limit` is removed in every case. ## What is the testing strategy for this PR? - `push_down_limit::test::limit_push_down_sort`: snapshot updated. After one pass the plan is `Sort: ..., fetch=10` without the `Limit` above it. - New `push_down_limit::test::limit_push_down_sort_settles_in_first_pass`: runs `PushDownLimit` with `max_passes = 3` and asserts that the optimizer stops after 2 passes (pass 1 leaves the plan unchanged). On `main` it runs 3. - `topk_through_join::test::topk_pushed_through_limit_then_sort_with_two_passes` is renamed to `topk_pushed_through_limit_then_sort_in_one_pass` and now uses `max_passes = 1`, with the same expected plan. It fails if the new branch does not call `push_topk_through_join`. - New `topk_through_join::test::topk_pushed_through_limit_over_sort_with_{same,smaller}_fetch_in_one_pass`: `Limit` over a `Sort` that already has `fetch=3`, over a Left Join, with `max_passes = 1`. The TopK moves below the join in the one pass. Both fail on the earlier code. - New `push_down_limit::test::limit_push_down_sort_reports_transformed`: calls `PushDownLimit::rewrite` on `Limit -> Sort -> TableScan` (no join) and asserts `transformed == true`. It fails if the branch returns the `Transformed` from `push_topk_through_join` as it is. - `user_defined_plan.rs`: the example `TopKOptimizerRule` also matches a `Sort` with a fetch (see below). The `topk_plan` and `topk_invariants*` tests failed without that change. I checked that the optimizer tests fail without the fix. Results: - `cargo test --profile ci -p datafusion-optimizer`: 901 + 26 + 5 passed, 0 failed. - `cargo test --profile ci -p datafusion-sqllogictest --test sqllogictests`: 522 of 522 files pass. No expected plan changes, because the final plans are the same. - `cargo fmt --all -- --check` and `cargo clippy --profile ci -p datafusion-optimizer --all-targets -- -D warnings`: clean. ## Are there any user-facing changes? Final plans are the same. Planning an `ORDER BY ... LIMIT` query without an `OFFSET` takes one less optimizer pass. A custom optimizer rule that runs after `PushDownLimit` and matches `Limit` over `Sort` no longer sees that pattern in the first pass. It sees a `Sort` with a fetch instead. On `main` such a rule also saw only the `Sort` with a fetch from the second pass on. The `TopKOptimizerRule` example in `datafusion/core/tests/user_defined/user_defined_plan.rs` had this dependency, so it now also matches a `Sort` with a fetch. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Which issue does this PR close?
PushDownLimitkeeps a redundantLimitabove aSortit gave a fetch, so everyORDER BY ... LIMIT nquery runs one extra optimizer pass #25671.Rationale for this change
For
Limit(skip=0, fetch=n)over aSortwithout a fetch,PushDownLimitsetsSort.fetch = nbut keeps theLimit. It removes theLimitonly on its next visit, in the next optimizer pass. Thus everyORDER BY ... LIMIT nquery runs 3 logical optimizer passes where 2 are enough. The final plan does not change. Only planning time does.A query engine that wraps every query in a row-cap
LIMITover anORDER BYhits this on nearly every query.Reproduction,
datafusion-clionmain(95bb0a0):Rules that change the plan, per pass (the
logical_plan after <rule>rows that are notSAME TEXT AS ABOVE):The final
logical_planis identical on both:What changes are included in this PR?
In
rewrite_limit,LogicalPlan::Sortbranch, when the rule gives theSorta new fetch andskip == 0, it now returns theSortand drops theLimitin the same visit. Withskip > 0theLimitstays, as before. When theSortalready has an equal or smaller fetch andskip == 0,mainalready dropped theLimit. Bothskip == 0cases now share one branch.One detail needs attention. The optimizer applies
PushDownLimittop-down and then recurses into the children of the node that the rule returns. Before this change, theSortwas a child of the returnedLimit, so the same pass also visited it and applied the TopK-through-join pushdown (push_topk_through_join). Now theSortis the returned node, so the traversal does not visit it again in this pass. Theskip == 0branch therefore callspush_topk_through_joinon theSortdirectly. Without that call,Limit -> Sort -> Left Joinwould move the extra pass from theLimitremoval to the TopK pushdown instead of removing it. The same was already true onmainfor aSortthat already had a fetch, so that case also gets the TopK pushdown in the same pass now.The branch always returns
Transformed::yes, also whenpush_topk_through_joinreturnsTransformed::no, because theLimitis removed in every case.What is the testing strategy for this PR?
push_down_limit::test::limit_push_down_sort: snapshot updated. After one pass the plan isSort: ..., fetch=10without theLimitabove it.New
push_down_limit::test::limit_push_down_sort_settles_in_first_pass: runsPushDownLimitwithmax_passes = 3and asserts that the optimizer stops after 2 passes (pass 1 leaves the plan unchanged). Onmainit runs 3.topk_through_join::test::topk_pushed_through_limit_then_sort_with_two_passesis renamed totopk_pushed_through_limit_then_sort_in_one_passand now usesmax_passes = 1, with the same expected plan. It fails if the new branch does not callpush_topk_through_join.New
topk_through_join::test::topk_pushed_through_limit_over_sort_with_{same,smaller}_fetch_in_one_pass:Limitover aSortthat already hasfetch=3, over a Left Join, withmax_passes = 1. The TopK moves below the join in the one pass. Both fail on the earlier code.New
push_down_limit::test::limit_push_down_sort_reports_transformed: callsPushDownLimit::rewriteonLimit -> Sort -> TableScan(no join) and assertstransformed == true. It fails if the branch returns theTransformedfrompush_topk_through_joinas it is.user_defined_plan.rs: the exampleTopKOptimizerRulealso matches aSortwith a fetch (see below). Thetopk_planandtopk_invariants*tests failed without that change.I checked that the optimizer tests fail without the fix. Results:
cargo test --profile ci -p datafusion-optimizer: 901 + 26 + 5 passed, 0 failed.cargo test --profile ci -p datafusion-sqllogictest --test sqllogictests: 522 of 522 files pass. No expected plan changes, because the final plans are the same.cargo fmt --all -- --checkandcargo clippy --profile ci -p datafusion-optimizer --all-targets -- -D warnings: clean.Are there any user-facing changes?
Final plans are the same. Planning an
ORDER BY ... LIMITquery without anOFFSETtakes one less optimizer pass.A custom optimizer rule that runs after
PushDownLimitand matchesLimitoverSortno longer sees that pattern in the first pass. It sees aSortwith a fetch instead. Onmainsuch a rule also saw only theSortwith a fetch from the second pass on. TheTopKOptimizerRuleexample indatafusion/core/tests/user_defined/user_defined_plan.rshad this dependency, so it now also matches aSortwith a fetch.