Skip to content

fix: drop the redundant Limit above a Sort in the same PushDownLimit visit - #25672

Merged
adriangb merged 3 commits into
apache:mainfrom
adriangb:push-down-limit-sort-same-pass
Sep 25, 2026
Merged

adriangb merged 3 commits into
apache:mainfrom
adriangb:push-down-limit-sort-same-pass

Conversation

@adriangb

@adriangb adriangb commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

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

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

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:

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.

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

codecov-commenter commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.07692% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.48%. Comparing base (95bb0a0) to head (684da9c).
⚠️ Report is 18 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/optimizer/src/push_down_limit.rs 67.56% 0 Missing and 12 partials ⚠️
...optimizer/src/push_down_limit/topk_through_join.rs 78.04% 1 Missing and 8 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Ok(Transformed::yes(push_topk_through_join(sort)?.data))
push_topk_through_join(sort)

?
push_topk_through_join() may return Transformed::no()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would that benefit of using push_topk_through_join(sort) too ?!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@github-actions github-actions Bot added the core Core DataFusion crate label Sep 24, 2026
@adriangb
adriangb requested a review from martin-g September 24, 2026 19:58
@adriangb
adriangb added this pull request to the merge queue Sep 25, 2026
Merged via the queue into apache:main with commit 5719930 Sep 25, 2026
42 checks passed
@adriangb
adriangb deleted the push-down-limit-sort-same-pass branch September 25, 2026 11:56
Omega359 pushed a commit to Omega359/arrow-datafusion that referenced this pull request Oct 11, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate optimizer Optimizer rules v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PushDownLimit keeps a redundant Limit above a Sort it gave a fetch, so every ORDER BY ... LIMIT n query runs one extra optimizer pass

4 participants