Skip to content

Push the limits past window functions - #17347

Merged
avantgardnerio merged 1 commit into
apache:mainfrom
coralogix:bg_push_limits
Aug 29, 2025
Merged

avantgardnerio merged 1 commit into
apache:mainfrom
coralogix:bg_push_limits

Conversation

@avantgardnerio

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

We would like plans with window functions to go faster.

What changes are included in this PR?

A proof-of-concept level optimizer rule push limits past some window functions.

Are these changes tested?

So far only by the existing sqllogictest suite.

Are there any user-facing changes?

Queries with window functions may go faster, particularly if the TableProvider supports TopK pushdown.

@github-actions github-actions Bot added documentation Improvements or additions to documentation optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) common Related to common crate labels Aug 28, 2025
Comment thread datafusion/common/src/config.rs Resolved
@avantgardnerio
avantgardnerio marked this pull request as ready for review August 29, 2025 14:46

@akurmustafa akurmustafa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @avantgardnerio for this PR

if let Some(window) = node.as_any().downcast_ref::<BoundedWindowAggExec>() {
for expr in window.window_expr().iter() {
let frame = expr.get_window_frame();
if frame.units != WindowFrameUnits::Rows {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As far as I can see, we update latest_max only for WindowFrameUnits::Rows cases. If I am not mistaken, it is also valid to update latest_max for WindowFrameUnits::Range and WindowFrameUnits::Groups as longs as end_bound is WindowFrameBound::Preceding(_). I think, we can extend checks to include this use case also.
This should change the plan for following kind of queries

SELECT
    c9,
    SUM(c9) OVER(ORDER BY c9 ASC RANGE BETWEEN 5 PRECEDING AND 1 PRECEDING) as sum1
    FROM aggregate_test_100
    LIMIT 5

However, we can do this change in subsequent PRs. I think in current form, this PR is correct and we can merge as is. Thanks @avantgardnerio for this PR.

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.

Good point! I will file a subsequent PR 😄

@avantgardnerio avantgardnerio Oct 14, 2025 •

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.

I tried this and I think it's unsafe:

1. query result mismatch:
[SQL] SELECT
 SUM(c1) OVER (ORDER BY c2 DESC) as summation1
 FROM null_cases
 LIMIT 5;
[Diff] (-expected|+actual)
-   962
-   962
-   962
-   962
-   962
+   263
+   263
+   263
+   263
+   263

and

[Diff] (-expected|+actual)
    logical_plan
    01)Projection: sum(null_cases.c1) ORDER BY [null_cases.c2 DESC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW AS summation1
    02)--Limit: skip=0, fetch=5
    03)----WindowAggr: windowExpr=[[sum(null_cases.c1) ORDER BY [null_cases.c2 DESC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
    04)------TableScan: null_cases projection=[c1, c2]
    physical_plan
    01)ProjectionExec: expr=[sum(null_cases.c1) ORDER BY [null_cases.c2 DESC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@2 as summation1]
    02)--GlobalLimitExec: skip=0, fetch=5
    03)----BoundedWindowAggExec: wdw=[sum(null_cases.c1) ORDER BY [null_cases.c2 DESC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { name: "sum(null_cases.c1) ORDER BY [null_cases.c2 DESC NULLS FIRST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW", data_type: Int64, nullable: true, dict_id: 0, dict_is_ordered: false, metadata: {} }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
-   04)------SortExec: expr=[c2@1 DESC], preserve_partitioning=[false]
+   04)------SortExec: TopK(fetch=5), expr=[c2@1 DESC], preserve_partitioning=[false]
    05)--------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/core/tests/data/null_cases.csv]]}, projection=[c1, c2], file_type=csv, has_header=true

I think this is failing because there are multiple entries with the same value, so it needs more than the limit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

However, for this case end bound is Current Row. I think, when end bound is WindowFrameBound::Preceding(_) we can still do this optimization for WindowFrameUnits::Range and WindowFrameUnits::Groups

@avantgardnerio
avantgardnerio merged commit 25acb64 into apache:main Aug 29, 2025
@avantgardnerio
avantgardnerio deleted the bg_push_limits branch August 29, 2025 17:32
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Sep 2, 2025
Add physical optimizer rule to push limits past certain window functions (part 1).

(cherry picked from commit 25acb64)
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Oct 2, 2025
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Oct 15, 2025
Dandandan added a commit to coralogix/arrow-datafusion that referenced this pull request Nov 17, 2025
* Get working build

* Add pool_size method to MemoryPool (#218) (#230)

* Add pool_size method to MemoryPool

* Fix

* Fmt

Co-authored-by: Daniël Heres <danielheres@gmail.com>

* Respect `IGNORE NULLS` flag in `ARRAY_AGG` (#260) (apache#15544) v48

* Hook for doing distributed `CollectLeft` joins (#269)

* Ignore writer shutdown error (#271)

* ignore writer shutdown error

* cargo check

* Fix  bug in `swap_hash_join` (#278)

* Try and fix swap_hash_join

* Only swap projections when join does not have projections

* just backport upstream fix

* remove println

* Support Duration in min/max agg functions (#283) (apache#15310) v47

* Support Duration in min/max agg functions

* Attempt to fix build

* Attempt to fix build - Fix chrono version

* Revert "Attempt to fix build - Fix chrono version"

This reverts commit fd76fe6.

* Revert "Attempt to fix build"

This reverts commit 9114b86.

---------

Co-authored-by: svranesevic <svranesevic@users.noreply.github.com>

* Fix panics in array_union (#287) (apache#15149) v48

* Drop rust-toolchain

* Fix panics in array_union

* Fix the chrono

* Backport `GroupsAccumulator` for Duration min/max agg (#288) (apache#15322) v47

* Fix array_sort for empty record batch (#290) (apache#15149) v48

* fix: rewrite fetch, skip of the Limit node in correct order (apache#14496) v46

* fix: rewrite fetch, skip of the Limit node in correct order

* style: fix clippy

* Support aliases in ConstEvaluator (apache#14734) (#281) v46

* Support aliases in ConstEvaluator (apache#14734)

Not sure why they are not supported. It seems that if we're not careful,
some transformations can introduce aliases nested inside other expressions.

* Format Cargo.toml

* Preserve the name of grouping sets in SimplifyExpressions (#282) (apache#14888) v46

Whenever we use `recompute_schema` or `with_exprs_and_inputs`,
this ensures that we obtain the same schema.

* Support Duration in min/max agg functions (#284) (apache#15310) v47

Co-authored-by: svranesevic <svranesevic@users.noreply.github.com>

* fix case_column_or_null with nullable when conditions (apache#13886) v45

* fix case_column_or_null with nullable when conditions

* improve sqllogictests for case_column_or_null

---------

Co-authored-by: zhangli20 <zhangli20@kuaishou.com>

* Fix casewhen (apache#14156) v45

* Cherry-pick topk limit pushdown fix (apache#14192) v45

* fix: FULL OUTER JOIN and LIMIT produces wrong results (apache#14338) v45

* fix: FULL OUTER JOIN and LIMIT produces wrong results

* Fix minor slt testing

* fix test

(cherry picked from commit ecc5694)

* Cherry-pick global limit fix (apache#14245) v45

* fix: Limits are not applied correctly (apache#14418) v46

* fix: Limits are not applied correctly

* Add easy fix

* Add fix

* Add slt testing

* Address comments

* Disable grouping set in CSE

* Fix spm + limit (apache#14569) v46

* prost 0.13 / fix parquet dep

* Delete unreliable checks

* Segfault in ByteGroupValueBuilder (#294) (apache#15968) v50

* test to demonstrate segfault in ByteGroupValueBuilder

* check for offset overflow

* clippy

(cherry picked from commit 5bdaeaf)

* Update arrow dependency to include rowid (#295)

* Update arrow version

* Feat: Add fetch to CoalescePartitionsExec (apache#14499) (#298) v46

* add fetch info to CoalescePartitionsExec

* use Statistics with_fetch API on CoalescePartitionsExec

* check limit_reached only if fetch is assigned

Co-authored-by: mertak-synnada <mertak67+synaada@gmail.com>

* Fix `CoalescePartitionsExec` proto serialization (apache#15824) (#299) v48

* add fetch to CoalescePartitionsExecNode

* gen proto code

* Add test

* fix

* fix build

* Fix test build

* remove comments

Co-authored-by: 张林伟 <lewiszlw520@gmail.com>

* Add JoinContext with JoinLeftData to TaskContext in HashJoinExec (#300)

* Add JoinContext with JoinLeftData to TaskContext in HashJoinExec

* Expose random state as const

* re-export ahash::RandomState

* JoinContext default impl

* Add debug log when setting join left data

* Update arrow version for not preserving dict_id (#303)

* Use partial aggregation schema for spilling to avoid column mismatch in GroupedHashAggregateStream (apache#13995) (#302) v45

* Refactor spill handling in GroupedHashAggregateStream to use partial aggregate schema

* Implement aggregate functions with spill handling in tests

* Add tests for aggregate functions with and without spill handling

* Move test related imports into mod test

* Rename spill pool test functions for clarity and consistency

* Refactor aggregate function imports to use fully qualified paths

* Remove outdated comments regarding input batch schema for spilling in GroupedHashAggregateStream

* Update aggregate test to use AVG instead of MAX

* assert spill count

* Refactor partial aggregate schema creation to use create_schema function

* Refactor partial aggregation schema creation and remove redundant function

* Remove unused import of Schema from arrow::datatypes in row_hash.rs

* move spill pool testing for aggregate functions to physical-plan/src/aggregates

* Use Arc::clone for schema references in aggregate functions

(cherry picked from commit 81b50c4)

Co-authored-by: kosiew <kosiew@gmail.com>

* Update tag

* Push limits past windows (#337) (apache#17347) v50

* Restore old method for DQE

* feat(optimizer): Enable filter pushdown on window functions (apache#14026) v45

* Avoid Aliased Window Expr Enter Unreachable Code (apache#14109) v45

(cherry picked from commit fda500a)

* Use `Expr::qualified_name()` and `Column::new()` to extract partition keys from window and aggregate operators (#355) (apache#17757) v51

* Update PR template to be relevant to our fork

* Make limit pushdown work for SortPreservingMergeExec (apache#17893) (#361)

* re-publicise functions DQE relies on

* Handle columns in with_new_exprs with a Join (apache#15055) (#384)

apache#15055

* handle columns in with_new_exprs with Join

* test doesn't return result

* take join from result

* clippy

* make test fallible

* accept any pair of expression for new_on in with_new_exprs for Join

* use with_capacity

Co-authored-by: delamarch3 <68732277+delamarch3@users.noreply.github.com>

---------

Co-authored-by: Georgi Krastev <georgi.krastev@coralogix.com>
Co-authored-by: Daniël Heres <danielheres@gmail.com>
Co-authored-by: Dan Harris <1327726+thinkharderdev@users.noreply.github.com>
Co-authored-by: Faiaz Sanaulla <105630300+fsdvh@users.noreply.github.com>
Co-authored-by: Sava Vranešević <20240220+svranesevic@users.noreply.github.com>
Co-authored-by: svranesevic <svranesevic@users.noreply.github.com>
Co-authored-by: Yingwen <realevenyag@gmail.com>
Co-authored-by: Zhang Li <richselian@gmail.com>
Co-authored-by: zhangli20 <zhangli20@kuaishou.com>
Co-authored-by: Aleksey Kirilishin <54231417+avkirilishin@users.noreply.github.com>
Co-authored-by: xudong.w <wxd963996380@gmail.com>
Co-authored-by: Qi Zhu <821684824@qq.com>
Co-authored-by: Martins Purins <martins.purins@coralogix.com>
Co-authored-by: mertak-synnada <mertak67+synaada@gmail.com>
Co-authored-by: 张林伟 <lewiszlw520@gmail.com>
Co-authored-by: kosiew <kosiew@gmail.com>
Co-authored-by: nuno-faria <nunofpfaria@gmail.com>
Co-authored-by: Berkay Şahin <124376117+berkaysynnada@users.noreply.github.com>
Co-authored-by: Mason Hall <mason.hall@coralogix.com>
Co-authored-by: delamarch3 <68732277+delamarch3@users.noreply.github.com>
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Dec 18, 2025
dispanser pushed a commit to coralogix/arrow-datafusion that referenced this pull request Jan 7, 2026
dispanser pushed a commit to coralogix/arrow-datafusion that referenced this pull request Jan 7, 2026
dispanser pushed a commit to coralogix/arrow-datafusion that referenced this pull request Feb 19, 2026
---
[Cherry-pick summary: v46→v47]
Source commit: 3cc79bb (Push limits past windows (#337) (apache#17347) v50)
Strategy: cherry-picked, minor adaptions (2 conflict regions in optimizer.rs + 3 in explain.slt)
Upstream PR: fork-only (extends LimitPushdown with window-specific variant)
Conflicts resolved:
- optimizer.rs: kept BOTH LimitPushPastWindows (new) AND LimitPushdown (HEAD), new rule runs first
- explain.slt (x3): inserted LimitPushPastWindows output line before existing LimitPushdown line;
  dropped cherry-pick's extra ProjectionPushdown line (already present in HEAD after LimitPushdown)
Test coverage: adequate
Tests: cargo nextest run -p datafusion-physical-optimizer passed (61 tests)
dispanser pushed a commit to coralogix/arrow-datafusion that referenced this pull request Feb 26, 2026
---
[Cherry-pick summary: v46→v47]
Source commit: 3cc79bb (Push limits past windows (#337) (apache#17347) v50)
Strategy: cherry-picked, minor adaptions (2 conflict regions in optimizer.rs + 3 in explain.slt)
Upstream PR: fork-only (extends LimitPushdown with window-specific variant)
Conflicts resolved:
- optimizer.rs: kept BOTH LimitPushPastWindows (new) AND LimitPushdown (HEAD), new rule runs first
- explain.slt (x3): inserted LimitPushPastWindows output line before existing LimitPushdown line;
  dropped cherry-pick's extra ProjectionPushdown line (already present in HEAD after LimitPushdown)
Test coverage: adequate
Tests: cargo nextest run -p datafusion-physical-optimizer passed (61 tests)
dispanser pushed a commit to coralogix/arrow-datafusion that referenced this pull request Feb 26, 2026
---
[Cherry-pick summary: v46→v47]
Source commit: 3cc79bb (Push limits past windows (#337) (apache#17347) v50)
Strategy: cherry-picked, minor adaptions (2 conflict regions in optimizer.rs + 3 in explain.slt)
Upstream PR: fork-only (extends LimitPushdown with window-specific variant)
Conflicts resolved:
- optimizer.rs: kept BOTH LimitPushPastWindows (new) AND LimitPushdown (HEAD), new rule runs first
- explain.slt (x3): inserted LimitPushPastWindows output line before existing LimitPushdown line;
  dropped cherry-pick's extra ProjectionPushdown line (already present in HEAD after LimitPushdown)
Test coverage: adequate
Tests: cargo nextest run -p datafusion-physical-optimizer passed (61 tests)
dispanser pushed a commit to coralogix/arrow-datafusion that referenced this pull request Feb 26, 2026
---
[Cherry-pick summary: v46→v47]
Source commit: 3cc79bb (Push limits past windows (#337) (apache#17347) v50)
Strategy: cherry-picked, minor adaptions (2 conflict regions in optimizer.rs + 3 in explain.slt)
Upstream PR: fork-only (extends LimitPushdown with window-specific variant)
Conflicts resolved:
- optimizer.rs: kept BOTH LimitPushPastWindows (new) AND LimitPushdown (HEAD), new rule runs first
- explain.slt (x3): inserted LimitPushPastWindows output line before existing LimitPushdown line;
  dropped cherry-pick's extra ProjectionPushdown line (already present in HEAD after LimitPushdown)
Test coverage: adequate
Tests: cargo nextest run -p datafusion-physical-optimizer passed (61 tests)
dispanser pushed a commit to coralogix/arrow-datafusion that referenced this pull request Feb 26, 2026
---
[Cherry-pick summary: v46→v47]
Source commit: 3cc79bb (Push limits past windows (#337) (apache#17347) v50)
Strategy: cherry-picked, minor adaptions (2 conflict regions in optimizer.rs + 3 in explain.slt)
Upstream PR: fork-only (extends LimitPushdown with window-specific variant)
Conflicts resolved:
- optimizer.rs: kept BOTH LimitPushPastWindows (new) AND LimitPushdown (HEAD), new rule runs first
- explain.slt (x3): inserted LimitPushPastWindows output line before existing LimitPushdown line;
  dropped cherry-pick's extra ProjectionPushdown line (already present in HEAD after LimitPushdown)
Test coverage: adequate
Tests: cargo nextest run -p datafusion-physical-optimizer passed (61 tests)
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Mar 18, 2026
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Mar 18, 2026
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Apr 7, 2026
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Apr 7, 2026
avantgardnerio added a commit to coralogix/arrow-datafusion that referenced this pull request Apr 7, 2026
Surajsuthar pushed a commit to Surajsuthar/datafusion that referenced this pull request Nov 2, 2026
…ache#26773)

## Which issue does this PR close?

- Closes apache#26659.

## Rationale for this change

`LimitPushPastWindows` turns the `SortExec` under a window into a TopK
when a `LIMIT` sits above it, but only for the first window in a plan.
Every later one keeps a full sort — more sorting and more rows held in
memory than necessary, though that first bug alone never changed a
result.

Fixing it is not the whole of this PR. Continuing the walk past the
first window made two wrong-results bugs reachable, both fixed here: one
that invented a limit where the plan had none, and one that
under-counted how many rows a stack of windows needs — `main` already
gets that second one wrong. A third fix is a missed optimization below a
`SortPreservingMergeExec` that `main` misses too.

## What changes are included in this PR?

One commit per cause, plus ten answering review.

**`refactor: keep LimitPushPastWindows's traversal phase in
TraverseState`** — despite the prefix this one is a behavior fix as
well, which @jayzhan211 caught in review. `phase` was a local in
`optimize()`, so neither the `reset` closure nor `apply_limit` could
reach it, and those are the two places that drop the chain being walked.
`phase` therefore had a single one-way edge into `Apply`: a window whose
lookahead is not known — a `RANGE` frame, an unbounded `FOLLOWING`
bound, a window function that does not declare a `limit_effect` — left
it there for the rest of the walk, and the guard on `get_limit` then
skipped every later limit, so an independent limit and window further
down the plan kept a full sort. It moves into `TraverseState`, which
gets one `reset` that returns the struct to its default.

**`fix: push a limit past every window in a plan, not just the first`**
— `apply_limit` returned `Transformed::complete`, whose
`TreeNodeRecursion` is `Stop`, ending the whole `transform_down`. It now
returns `Transformed::yes`.

The ten later commits answer the review:

- **`test: cover the limit a window the rule can't grow past used to
strand`** — the sqllogictest coverage the phase reset was missing.
- **`refactor: take the traversal state in apply_limit`** —
`std::mem::take` reads the chain and forgets it in one step, instead of
clearing it field by field.
- **`fix: don't invent a limit from LimitEffect::Absolute`** — a
wrong-results bug. `grow_limit` raised the limit it was carrying through
`ctx.limit.unwrap_or(0)`, so with no limit in hand it did not raise a
limit but invented one, and the sort below got a `fetch` although
nothing above it asked for one. apache#26660 has since renamed that input to
`WindowLookahead::min_rows` and moved its computation into
`BoundedWindowAggExec`, carrying the same `unwrap_or(0)` along; the fix
reads it as "only raise a limit we actually have". That rename is
internal to the rule's side of the interface — `LimitEffect` is still
the enum a `WindowUDFImpl` returns from `limit_effect`, which is why the
unit tests below still name it. Where it comes from splits by shape, and
there is a test for each: a single window with no limit anywhere reaches
it on `main` as it stands, while the nested shape raised in review is
reachable only once the walk continues below a sort it has already given
a fetch to.
- **`fix: pick up a limit below a window the rule already grew past`** —
a third shape of the same bug, found in review. A window the rule *can*
grow past leaves the walk in `Phase::Apply`, and the guard on
`get_limit` skipped every limit while in that phase, so an inner
`GlobalLimitExec` was never read and the chain was dropped at it
instead. The phase only has to hold the walk back at a
`SortPreservingMergeExec`, which is both a limit source and an apply
target; a plain limit node is never an apply target, so the guard now
covers just that.
- **`refactor: build the rule's test plans from one sort+window
helper`** — the three builders in the test module stood up the same
`SortExec → BoundedWindowAggExec` scaffold by hand. No snapshot changes.
- **`test: cover the nested shape Absolute could invent a limit in`** —
the test that landed first used the smallest shape that reaches the arm,
which left the nested shape raised in review without one of its own.
- **`fix: add stacked windows' lookaheads instead of taking the max`** —
the other wrong-results bug, found in review. A window reads ahead of
every row the windows above it need, so the lookaheads of stacked
windows add; the rule took the max, which leaves the sort below short of
the rows the inner window needs. Expressions within one window do share
a lookahead, and `WindowLookahead` has already taken the maximum over
those. See below for why this changes five `EXPLAIN`s in `window.slt`.
- **`fix: no lookahead from a window whose partitions are interleaved`**
— a precondition the arithmetic has always had and never stated.
`WindowLookahead::frame_end` is read as a row count of the input, but a
frame bound counts rows *within a partition*, so that only holds when
each partition is a contiguous run of the input —
`InputOrderMode::Sorted`. `BoundedWindowAggExec::limit_behavior` now
returns `Window(None)` in the other two modes, which is what the variant
documents it for. No plan changes in tree; see below.
- **`fix: start a new chain from a SortPreservingMergeExec's fetch`** —
a missed optimization @jayzhan211 found in review, which `main` misses
too. `apply_limit` takes the traversal state, which is right for a
`SortExec`: a sort reorders its input, so its fetch says nothing about
how many rows the plan below it must produce. A
`SortPreservingMergeExec` is different — it emits its input's first rows
in order, so its fetch bounds every partition below it, the same reading
`limit_pushdown` already has of `LimitBehavior::Merge`. Taking the state
threw that bound away, so a window below the merge — the shape a
`PARTITION BY` window nested under another window produces — kept a full
sort. `main` misses it by stopping the walk at the merge instead; the
plans are identical, so this is not a regression of this PR.
- **`docs: state what the tests hold down, not when it was found`** —
three comments described this change's own history ("the nested shape
from review", "before this PR", "the first commit in this PR"). After a
squash merge none of those have a referent, so each now states the same
thing as a property of the rule. Comments only.

### Why not `Jump`, as the issue suggests

The issue's diagnosis is exactly right, and `Jump` does fix the union
case. It is not enough for a second window **nested further down the
same chain**, because there the second limit and window sit *inside* the
subtree `Jump` prunes:

| | union, two branches | nested, a sort in between | nested, no sort in
between |
| --- | --- | --- | --- |
| before | ❌ first branch only | ❌ outer only | ❌ outer only |
| `Jump` | ✅ | ❌ still outer only | ❌ still outer only |
| `Transformed::yes` | ✅ | ✅ | ❌ still outer only |
| + the `get_limit` guard | ✅ | ✅ | ✅ |

The third column is the shape @jayzhan211 found in review. Whether a
sort survives between the two levels is decided by `EnforceSorting`:
order both levels by the same column and the outer `SortExec` is elided,
which matters because `apply_limit` is one of the two places that drop
the chain being walked. With a sort in between, continuing the walk is
enough on its own. Without one, the limit below also has to get past the
`phase` guard.

This does deliberately reverse the early stop added in apache#17347. That stop
existed to avoid walking below a sort that already had its fetch, which
was necessary while the traversal state survived that point. It no
longer does: `apply_limit` takes the state, so nothing stale is carried
below an applied sort. Below a `SortExec` the walk starts from the
default state, which is what it held at the start of the traversal, so
descending is equivalent to running the rule on that subtree afresh;
below a `SortPreservingMergeExec` it starts a fresh chain from that
merge's fetch, which is a bound on every partition under it. Either way
there is nothing stale to protect against, so there is nothing left to
prune for.

### Fixed along the way

- **A second window nested in the same chain**, not just a second union
branch. The issue says "The same happens for any later window in the
plan, not only under a union"; `Jump` would have covered only the union
half of that.
- **`apply_limit` leaked `lookahead`.** Its successful path took `limit`
but left `lookahead` set. Harmless while the traversal stopped right
after, a live bug once it continues.
- **A window's `min_rows` invented a limit out of nothing** — the sort
below got a fetch although nothing above it asked for one.
- **Stacked windows under-counted their lookahead.** `main` is wrong
here too whenever two windows sit on one chain above a single sort and
the outer reads the inner's output; the early stop only kept the walk
out of the subtree below a sort it had already given a fetch to, never
out of a second window on the same chain. Continuing the walk adds one
more shape, where an `ORDER BY ... LIMIT` between the two levels spends
the outer limit on a sort.
- **A window below a `SortPreservingMergeExec`** kept a full sort,
because applying a fetch to the merge forgot that the same fetch bounds
every partition below it.
- **The lookahead arithmetic assumed `InputOrderMode::Sorted`** without
checking. Not reachable today — see the last commit's note below — but
it is a precondition, not an invariant of the code that reads it.

## What is the testing strategy for this PR?

Nine sqllogictest cases in `window_limits.slt`, one per plan shape, plus
four unit tests. The nine shapes are expressible in SQL, so they are
`.slt`; `min_rows` and a non-`Sorted` input order mode are not, so they
get unit tests — both per the [testing
guide](https://datafusion.apache.org/contributor-guide/testing.html#choosing-what-kind-of-test-to-write).
Each `.slt` case asserts results with `enable_window_limits` both off
and on, then asserts the plan.

The nested case is what separates `Transformed::yes` from `Jump`:
switching to `Jump` turns that case red and leaves the union case green.
Two cases added at @jayzhan211's suggestion cover the phase bug; they
differ only in the outer `ORDER BY`, which decides whether a sort sits
between the un-growable window and the limit below it and so which of
the two clears is the one that runs. A fifth is the previous query with
the outer frame changed from `RANGE` to `ROWS`, which is what makes it
fail: a frame the rule cannot grow past resets the state on the way
down, a frame it can does not.

The two cases for the merge differ in where the limit is. In the first
it sits above the outer window, so the merge is given `5 + 1` and that 6
starts the next chain, which reaches the inner window's sort as `6 + 1`.
In the second it has moved inside the subquery, so the rule has no limit
in hand at the merge at all and the merge's *own* fetch of 5 is what
starts the chain, reaching the sort as `5 + 1`. Dropping the merge
branch turns both red; narrowing it to the limit the rule computed,
rather than falling back to the merge's existing fetch, leaves the first
green and turns only the second red. Their results differ as well — the
second query's last row has no following row for the outer window to
read, because its `LIMIT 5` is applied before that window rather than
after it.

The two cases for the stacked lookahead differ in whether an `ORDER BY
... LIMIT` sits between the two levels. Without it, both windows are on
one chain above a single sort, which needs `3 + 1 + 2` rows and got `3 +
max(1, 2)`; the third row's `s2` came back `11103612124` instead of
`15119054465`. `main` fails that one too. With it, the outer limit is
spent on a sort and the walk starts a new chain at the inner limit, so
that shape is reachable only once applying a fetch stops ending the
walk.

`WindowLookahead::min_rows` is unreachable from SQL — `ntile`,
`cume_dist` and `percent_rank` declare `Unknown`, `row_number`,
`nth_value`, `rank` and `dense_rank` declare `None`, and `lead`/`lag`
declare `Relative(n)` or `Unknown` — so a `row_number` wrapper
overriding `limit_effect` stands in for a third-party UDWF. Its three
tests cover one shape each: a single window with no limit anywhere, the
same thing nested below a sort the rule has already given a fetch to,
and a limit that is present and does get raised to the window's floor.
The first two are red without the fix; the third is green either way and
holds down the `max`, so that deleting the arm is not mistaken for a
fix.

Those first two are not redundant, because the shapes differ in when
they became reachable. Reverting `Transformed::yes` to
`Transformed::complete` on top of reverting the `min_rows` fix — so the
walk stops at an applied sort, as on `main` — leaves the nested test
green and the single-window test red.

The fourth unit test is the same plan as the first, with only the input
order mode changed to `Linear`: the rule must leave that sort alone. It
is red without the guard, where the sort gets `TopK(fetch=100)`.

### The five `window.slt` expectations that change

Adding the lookaheads is a safe upper bound, but an over-estimate when
the outer window does not read the inner window's output: then the inner
window only has to *emit* the rows the outer one reads, not compute them
over full frames. All five changed cases are that shape — both windows
in each of them read `c9` — so their fetch grows by 1 to 5 rows (10→11
three times, 10→15, 15→16) with no change in results. Telling the two
apart needs the columns each window reads traced through the projections
between them, which is a separate change; the whole suite's remaining
500-plus files are unaffected, so nothing but genuinely stacked windows
pays for it.

### On the `InputOrderMode` guard

No plan changes in tree, and none expected. `get_best_fitting_window`
refuses a non-`Sorted` mode for bounded input, and this rule's only
apply targets are `SortExec` and `SortPreservingMergeExec`; a `SortExec`
below a window makes that window's input bounded, so every window the
rule can act on today is `Sorted`. The non-`Sorted` modes are reached
over an unbounded source, where there is no sort below the window at
all. The one path that does not go through the planner is the protobuf
decode, which rebuilds whatever mode the wire carries. The other readers
of `limit_behavior` match on `Window(_)` without looking at the payload,
so they are unaffected.

### Suite

Full sqllogictest suite passes with the five `window.slt` expectations
above as the only changes. `cargo clippy --all-targets --all-features --
-D warnings`, `cargo fmt --all --check`,
`datafusion-physical-optimizer`, `datafusion-physical-plan` and the
`core_integration` `physical_optimizer::` tests are clean. No local
benchmark reaches this rule (`benchmarks/queries/h2o/window.sql` has no
`LIMIT`; the benchmark queries that do use `LIMIT` have no windows), so
there is nothing to compare against `main`.

One note on the rebase: apache#26660 landed while this was in review and moved
the window's lookahead out of this rule into
`BoundedWindowAggExec::limit_behavior`, replacing `LimitEffect` and the
frame arithmetic in `grow_limit` with
`LimitBehavior::Window(Option<WindowLookahead>)`. Every commit here has
been rebased onto that vocabulary; two needed resolving by hand, and the
`min_rows` fix is the one that changed shape rather than position. The
branch has since been rebased onto current `main` again, over the arrow
60.1.0 bump — no conflicts, and every earlier commit kept its
`patch-id`.

## Are there any user-facing changes?

No API or configuration changes. Affected plans get a TopK where they
had a full sort, and five plans with stacked windows get a slightly
larger fetch; results are unchanged.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

common Related to common crate documentation Improvements or additions to documentation optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimizer should push limits past window functions

4 participants