Skip to content

fix: wait for the whole NULL peer group before emitting RANGE N PRECEDING window results - #25860

Merged
jayzhan211 merged 1 commit into
apache:mainfrom
kita-renji:fix/window-range-null-peers
Oct 18, 2026
Merged

jayzhan211 merged 1 commit into
apache:mainfrom
kita-renji:fix/window-range-null-peers

Conversation

@kita-renji

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Window aggregates over a RANGE ... N PRECEDING frame return results that depend on how the input is split into batches when the ORDER BY value is NULL. For a NULL row the frame is its group of NULL peers, and when that group spans batches the row is computed before the rest of the group arrives. At default settings a query over 20,000 rows with 10,000 NULL keys returns a count of 6384 instead of 10000 for 6,384 of the NULL rows. Details and repros are in the issue.

What changes are included in this PR?

  • WindowFrame::new_bounds: a RANGE frame is causal only when it ends at UNBOUNDED PRECEDING. With an offset end bound, the frame of a NULL or NaN row ends at its last peer, which can be a later row. GROUPS keeps the current rule, since NULL peers form an ordinary group there.
  • is_end_bound_safe_for_range: a PRECEDING end bound is handled like CURRENT ROW, so the row waits until an input row past its peer group arrives (is_row_ahead) or the partition ends. An N PRECEDING frame only reaches the end of the buffer when the row's frame ends at its last peer, so the check only holds back NULL and NaN rows.

Rows with other ORDER BY values behave as before: their N PRECEDING frame ends before the current row, so it never reaches the end of the buffer and results are still produced as the input streams. RANGE frames planned from SQL were already non-causal (their bounds are strings until type coercion), so their plans don't change; the second change is what fixes them. The first change fixes frames built from typed bounds (reversed frames, the DataFrame API, proto), where built-in window functions such as nth_value were affected too.

What is the testing strategy for this PR?

New cases at the end of window.slt, with batch_size = 2 so the peers span batches: sum/count/max over 5 PRECEDING AND 1 PRECEDING and UNBOUNDED PRECEDING AND 1 PRECEDING, NULLS FIRST and DESC orderings, a FOLLOWING frame that the planner reverses to reuse another window's sort (covering nth_value), and a group of NaN keys. Expected values match DuckDB 1.5.5 and Postgres 17, and all four queries fail without the fix.

I also ran a randomized check outside the test suite: random tables with NULL keys, random RANGE/GROUPS/ROWS frames and functions whose result doesn't depend on tie order, compared across batch sizes 1, 2, 3, 7 and 8192 and against DuckDB. Main had 31 batch-dependent results in the first 1,000 queries; this branch had none in 7,500, and none in variants with NaN keys and with an unbounded ordered source (Linear mode, PARTITION BY). The whole sqllogictest suite and the window fuzz tests (--features extended_tests) pass.

Are there any user-facing changes?

Queries with NULL or NaN values in the ORDER BY column of a RANGE ... N PRECEDING window now return the same results regardless of batch size. WindowFrame::is_causal() now returns false for RANGE frames with an offset end bound. For frames built from typed bounds this can drop an ordering that a set-monotonic aggregate used to add to the window output, so a plan may need an extra sort; SQL-planned frames were already non-causal, and no sqllogictest plan changed.

🤖 Generated with Claude Code

…window results

For a RANGE frame with an offset end bound, the frame of a row whose ORDER BY
value is NULL (or NaN) ends at its last peer, which can arrive in a later
batch. BoundedWindowAggExec emitted such rows early, so results depended on
batch boundaries.

- WindowFrame::new_bounds: RANGE frames are causal only when they end at
  UNBOUNDED PRECEDING.
- is_end_bound_safe_for_range: handle a PRECEDING end bound like CURRENT ROW
  and wait until a row past the peer group arrives.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@github-actions github-actions Bot added logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) labels Sep 29, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.57%. Comparing base (bdad988) to head (75e325f).

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #25860   +/-   ##
=======================================
  Coverage   82.57%   82.57%           
=======================================
  Files        1142     1142           
  Lines      440861   440859    -2     
  Branches   440861   440859    -2     
=======================================
+ Hits       364047   364051    +4     
+ Misses      54818    54809    -9     
- Partials    21996    21999    +3     

☔ 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.

@Nagato-Yuzuru

Copy link
Copy Markdown
Contributor

Hi @kita-renji, thank you for this fix.

I work on the fuzz test plan in #24243. When I added NULL values to the window fuzz test, the test also found #25859. I opened the fuzz change as #26386.

On my fuzz branch, your patch fixes the error and without it the test fails. I tested all four sort orders (ASC/DESC, NULLS FIRST/LAST).

If you agree, you can cherry-pick the commits of #26386 into this PR. It make the fix and its fuzz test are in one PR. The commits apply to this branch without conflicts:

git fetch https://github.com/Nagato-Yuzuru/datafusion.git fuzz-window-nulls
git cherry-pick FETCH_HEAD~2..FETCH_HEAD

If you prefer to keep them separate also good. #26386 can merge after this PR.

@jayzhan211 jayzhan211 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 @kita-renji 👍🏻

@jayzhan211
jayzhan211 added this pull request to the merge queue Oct 18, 2026
Merged via the queue into apache:main with commit 3470bd0 Oct 18, 2026
42 checks passed
charan-rathore pushed a commit to charan-rathore/datafusion that referenced this pull request Oct 19, 2026
<!--
We generally require a GitHub issue to be filed for all bug fixes and
enhancements and this helps us generate change logs for our releases.
You can link an issue to this PR using the GitHub syntax. For example
`Closes apache#123` indicates that this PR will close issue apache#123.
-->

Part of apache#24243. 

This PR adds NULL values to the window fuzz test, as planned.


## Rationale for this change

<!--
Why are you proposing this change? If this is already explained clearly
in the issue then this section is not needed.
Explaining clearly why changes are proposed helps reviewers understand
your changes and offer better suggestions for fixes.

Please explain the problem you are trying to solve in terms of the
user-visible
behavior, rather than the implementation.

For example, "The code in `foo.rs` doesn't handle nulls" is a symptom of
the
implementation. "COUNT(DISTINCT) returns wrong results when the column
contains
nulls" is the user-visible problem.
-->


The window fuzz test had two gaps:

- The test data had no NULL values.
- The test used only one sort order: `ASC NULLS FIRST`.

With these gaps closed, the test finds wrong results on `main`. All of
them
come from one known error: apache#25859.

## What changes are included in this PR?

<!--
There is no need to duplicate the description in the issue here, but it
is sometimes worth providing a summary of the individual changes in this
PR.
-->

- Add NULL values to the test data. The NULL rate changes between 0,
0.01, 0.1
  and 0.5.
- Give each sort column a random sort direction and a random NULL
position.

Note: a seed now gives different data than before this PR.


## What is the testing strategy for this PR?

<!--
We typically require tests for all PRs in order to:
1. Prevent the code from being accidentally broken by subsequent changes
2. Serve as another way to document the expected behavior of the code

Briefly describe how this PR is tested, and point to the specific tests
you added. For example: 'This new feature is covered by the
`sqllogictest` cases added in `foo.slt`'.

If this PR does not add tests, explain why. For example, if the change
is already covered by existing tests, please mention it.

You should also check the `codecov` bot reply on this PR to confirm the
changed code is exercised.
-->

```bash
cargo test --profile ci -p datafusion --features extended_tests --test fuzz -- window_fuzz
```

On `main`, the test fails. For example:

```
WindowFrame { units: Range, start_bound: Preceding(Int32(6)), end_bound: Preceding(Int32(5)), .. },
window_fn: max(x), random_seed: 267, null_pct: 0.5, pb_cols: ["a"], ..
```

All failures have a `RANGE` frame that ends in `N PRECEDING`, and NULL
values in
the data. They occur with all four sort orders (ASC/DESC, NULLS
FIRST/LAST).

## Additional notes

apache#25860 fixes apache#25859. With apache#25860, this test passes. This PR must merge
after
apache#25860. Or, if the author of apache#25860 agrees, these commits can go into
apache#25860.


## Are there any user-facing changes?

<!--
If there are user-facing changes then we may require documentation to be
updated before approving the PR.

If there are any breaking changes to public APIs, please add the `api
change` label.
-->
No
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong results: window aggregates over RANGE ... N PRECEDING depend on batch boundaries when the ORDER BY value is NULL

4 participants