Repository navigation
Make window aggregates claim no output order, so a sort by a rolling or running column sorts (#354) - #391
Conversation
DataFusion 55 turns an aggregate's set monotonicity into an ordering of the window column and drops a later sort that ordering satisfies. For the windows LTSeq builds the derived orderings are wrong: a sliding frame with no partition_by is claimed ascending whichever way its argument is sorted (rolling max over a descending column, rolling count once NULLs enter the frame, rolling unsigned sum), a running max or unsigned sum is claimed ASC NULLS LAST although it is NULL until the first non-NULL value (apache/datafusion#27267), and an unsigned sum wraps on overflow. A sort by such a column came back in the window's order, with no error. aggregate_to_window, which builds every window aggregate LTSeq plans, now wraps the aggregate in NoOrderClaim: the same aggregate in every respect (name, types, accumulators, statistics, rewrites) except that its set monotonicity is NotMonotonic. DataFusion 55 reads set monotonicity only for window orderings, so grouped and ungrouped aggregates are unchanged. The cost is the sort a cum_max or cum_min of a column without NULLs made unnecessary.
AI Code Review — Round 1Reviewed HEAD: FindingsNone. SpecThe implementation addresses the original ordering criteria through a type-independent mitigation at the shared window-aggregate builder. Passing regressions cover rolling min/max/count/sum, cumulative aggregates, both sort directions, NULL patterns, partitioned windows, slices, execution/input layouts and writers. Independent Python window-value oracles and a physical-plan assertion supplement the collect-and-sort comparison. StandardsConsistent with the Rust/DataFusion architecture and no-materialization rule. The wrapper implements all 29 current AggregateUDFImpl methods, changing monotonicity while preserving accumulator and typing behavior. The change includes an Unreleased changelog entry and introduces no Python API requiring stub changes. The new Rust module passes rustfmt; repository-wide formatting failures occur in existing code, with corresponding failures confirmed at the merge base. Reviewed sources remain unchanged and the worktree is clean. AssessmentNo actionable correctness, safety or maintainability defect found. Conservatively suppressing ordering claims is an appropriate correctness-first workaround for the pinned DataFusion version. Reversal and beneficial-ordering replacements retain the wrapper, and the affected rolling, cumulative and group-dialect paths use the shared builder. The principal tradeoff is additional sorting rather than changed aggregate semantics. ObservationsThe captured CI checks were all pending; their eventual outcomes and the full Python/tutorial suites were not independently verified. Runtime and peak-memory benchmarking from the proposed expanded checklist are absent, as the PR explicitly acknowledges; recovering safe sort-elision optimizations remains phase-two work. Existing unsigned-sum overflow behavior is unchanged. Verification
SummaryNo findings requiring another fix round. The focused regression suite, 564 related Python tests, Rust tests and clippy passed. Full CI and broader-suite verification remain incomplete. Needs another fix round: NO |
Problem
DataFusion 55 assumes a window aggregate's output is already sorted, based only on the aggregate:
max,countand unsignedsumonly rise as values join the set, andminonly falls.window_equivalence_propertiesturns that into an ordering of the window column without looking at the frame, andEnforceSortingthen drops a later sort that the ordering seems to satisfy. For the windows LTSeq builds, that ordering is often false, sosort("m")came back in the window's order.slice,head,tailand the writers followed it, with no error.Reproduced on
main(bc2cbb2) before this change:sort("m")lazilycollect()rolling(2).max(),int64xdesc[3, 3, 2, 1][1, 2, 3, 3]rolling(2).max(),stringxdesc['c', 'c', 'b']['b', 'c', 'c']rolling(2).sum(),uint64xdesc[3, 5, 3, 1][1, 3, 3, 5]rolling(2).count(),[1, 2, None, None]xasc[1, 2, 1, 0][0, 1, 1, 2]cum_max(),[None, 1, 2]xdesc[None, 2, 2][2, 2, None]cum_sum(),uint64[None, 1, 2]xdesc[None, 2, 3][2, 3, None]cum_sum(),uint64[2**64 - 1, 1][2**64 - 1, 0][0, 2**64 - 1]The first four rows are the issue's cases. The last three are not in the issue:
ASC NULLS LAST, but a runningmaxorsumis NULL until the first non-NULL value, so those NULL rows led the column. This is the problem tracked upstream in Wrong results:ORDER BY ... NULLS LASTon a runningMAXwindow function puts the NULL row first apache/datafusion#27267.sumwraps unsigned integers on overflow, so an unsigned running or rolling sum is not monotonic either.A rolling
min(claimedDESC NULLS LAST) never matchedsort(desc=True), which asks for NULLs first, so no dropped sort was seen for it, as the issue predicted.Change
src/transpiler/no_order_claim.rsaddsNoOrderClaim, anAggregateUDFImplwrapper that delegates everything to the wrapped aggregate (name, signature, return field and nullability, accumulators including the sliding one, statistics, simplification, reversal, beneficial ordering) exceptset_monotonicity, which returnsNotMonotonic.aggregate_to_window, which builds every window aggregate LTSeq plans (rolling,cum_*,LTSeq.cum_sum, the group dialect), wraps the aggregate with it. Wrapping is idempotent, and a reversed aggregate is wrapped too. Name-based rewrites (them0checked and exact sums,narrow_decimals) still seesum; their replacements claim no order either.In DataFusion 55, set monotonicity is read only for window orderings (
window_equivalence_propertiesandPlainAggregateWindowExpr::add_equal_orderings), so grouped and ungrouped aggregates are unaffected. The #253 floatmin/maxoverride stays as it is.Why not frame-aware
set_monotonicitysees only the argument type. A narrower fix would keep the claim for running frames over non-nullable arguments. In practice almost no LTSeq column is non-nullable (from_dict, Arrow, CSV and most Parquet columns are nullable), and unsignedsumwould still have to drop it because it wraps. So that variant would keep almost nothing while adding nullability plumbing. If the lost sort elision turns out to matter, recovering it is the issue's phase 2.Cost
A sort by a window column now always runs. The only sort previously skipped correctly was the one after a
cum_max/cum_minof a column with no NULLs. Physical plan forsort("m")afterrolling(2).max()overxsorted descending:Before (sort dropped):
After:
I did not run a benchmark. The added work is one
SortExec(a TopK underslice/head) over the window output, only where the old plan skipped it.Tests
py-ltseq/tests/test_window_order_claims.py:test_sort_by_window_sorts:rolling(2|3).max/min/count/sumandcum_max/cum_min/cum_sumoverint64,uint64,float64,decimal128,stringanddate32(sumnumeric only). Each case runs with the table sorted by the argument ascending, descending, or by row id (NULLs interspersed); with no NULLs, one, three, or all NULL; and with and withoutpartition_by. The window values come from an independent Python oracle; thensort("m")andsort("m", desc=True)are checked whole and throughslice(TopK) against the oracle's sorted values and row multiset, and againstcollect().sort(...). Pinned at 2 partitions and batch size 3.test_issue_cases_in_every_layout: the cases above in every engine layout (engine_layout) crossed with every input layout (data_layout: one or many batches, Parquet file, row groups, files, CSV file, files). CSV reads unsigned columns back asint64; those cases skip.LTSeq.cum_sum(built apart fromderive), the wrapping unsignedcum_sum,head/tail,write_parquet/write_csvround trips, and a physical-plan check that aSortExecon the window column sits above the window.no_order_claim::tests::claims_nothing_and_keeps_the_restchecks the wrapper claims nothing wheremax,min,countand unsignedsumdo, and keeps name, signature, nullability, return type and reversal.With the wrapper temporarily removed from
aggregate_to_window, 72 of the 109 selected cases fail: every reported case and the running ones. Theminand float cases pass either way.Run locally:
pytest py-ltseq/tests/test_window_order_claims.py: 469 passed, 36 skippedtest_derive*,test_float_min_max,test_group_*,test_nested_derive_proxy,test_no_materialization_rule,test_offset_slice,test_ordered_invariants,test_ranking,test_sort_*,test_when_cumagg,test_window_*): 1489 passed, 36 skippedcargo clippy --all-targets -- -D warnings: cleancargo test: 190 passedThe full Python suite and the tutorial notebooks are left to CI. No Python API, docstring or stub changed.
CHANGELOG.mdhas a Fixed entry.Not in this PR
ORDER BY ... NULLS LASTon a runningMAXwindow function puts the NULL row first apache/datafusion#27267 is linked only. Per the issue's plan, no upstream issue or comment was made.cum_sum/rolling().sum()that overflows wraps silently ([2**64 - 1, 1]sums to0). This PR only stops the wrong sort elision there. Whether it should raise is a separate decision.Closes #354