Repository navigation
Push the limits past window functions - #17347
Conversation
7ccfcdb to
3906548
Compare
3906548 to
307e0a5
Compare
akurmustafa
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 5However, 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.
There was a problem hiding this comment.
Good point! I will file a subsequent PR 😄
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Add physical optimizer rule to push limits past certain window functions (part 1). (cherry picked from commit 25acb64)
* 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>
--- [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)
--- [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)
--- [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)
--- [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)
--- [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)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…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>
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
TableProvidersupports TopK pushdown.