Skip to content

A sort by a rolling max, min, count or unsigned sum is dropped when DataFusion misreads the window's order #354

Closed

Description

@iceboundrock

Problem

A sort by the output of a rolling(...) aggregate can be dropped, so its rows come back in the window's order instead of sorted. It happens when the window has no partition_by, for rolling().max() of any type but float, rolling().count() and unsigned rolling().sum(). min gets the same kind of claim (see Cause):

from ltseq import LTSeq

t = (
    LTSeq.from_dict({"x": [3, 2, 1, 0]})
    .sort("x", desc=True)
    .derive(m=lambda r: r.x.rolling(2).max())
)
[r["m"] for r in t.sort("m").to_dicts()]            # [3, 3, 2, 1]
[r["m"] for r in t.collect().sort("m").to_dicts()]  # [1, 2, 3, 3]

Probed on the #353 branch (7e6b042):

Window, no partition_by Table order sort("m") lazily after collect()
rolling(2).max(), int64 x descending [3, 3, 2, 1] [1, 2, 3, 3]
rolling(2).max(), string x descending ['c', 'c', 'b'] ['b', 'c', 'c']
rolling(2).sum(), uint64 x descending [3, 5, 3, 1] [1, 3, 3, 5]
rolling(2).count(), int64 [1, 2, None, None] x ascending (NULLs last) [1, 2, 1, 0] [0, 1, 1, 2]

These go through DataFusion's own aggregates on that branch as on main. #353 changes only float min/max, and sum and count not at all. I have not rerun them on a main build. With partition_by, the same max window sorted correctly, and so did signed sum, which DataFusion does not mark as monotonic.

Cause

DataFusion 55's window_equivalence_properties (datafusion-physical-plan, src/windows/mod.rs, the branch for a causal sliding frame) adds an output ordering for a set-monotonic aggregate whose argument is already ordered. With no partitioning, it claims ascending output for an increasing aggregate (max, count, unsigned sum) whatever direction the argument is ordered in, because of increasing && (asc || no_partitioning). It claims descending output for min the same way. A rolling max over a descending argument falls. A non-NULL count falls once NULLs enter the frame. EnforceSorting then removes a sort that the output does not satisfy. I found no upstream report by searching apache/datafusion.

Not checked: the min claim is DESC NULLS LAST, while sort(desc=True) asks for NULLs first, so no dropped sort was seen for it. Whether a window order_by or another consumer of the ordering can reach it is unknown.

Impact

sort returns rows out of order, and so does everything that reads the sorted order after it: slice, head, tail, positional operations and writers. No error is raised.

Why it is not fixed in #353

#353 is about float min/max (#253). Its review found the float form of this problem, a running float extreme that is monotonic only in the contract's order, and #353 fixes that by having float min/max claim no monotonicity. The rest predates #353, involves aggregates it does not touch, and needs the decision below.

Open question

set_monotonicity sees only the data type, not the frame, so an aggregate cannot drop the claim for sliding frames alone. The two options seen so far are to report the bug upstream and wait for a fix, or to have ltseq's max, min, count and sum claim nothing. The second also gives up the correct sort elision after cum_max, cum_min and unsigned cum_sum, whose frames only grow.

Acceptance criteria

  • For rolling(...).max(), .min(), .count() and .sum() over every argument type, with the argument sorted ascending or descending, with and without NULLs and with and without partition_by, t.sort("m") and t.sort("m", desc=True) return the rows that t.collect().sort(...) does, whole and through slice.
  • cum_* windows keep correct results.

Found while addressing #353 (comment).

Activity

  1. iceboundrock commented on Nov 10, 2026

    @iceboundrock
    ContributorAuthor

    Follow-up plan (2026-11-09): upstream linkage, two-phase fix, and expanded acceptance

    Related Apache DataFusion issue

    • Upstream: Wrong results: ORDER BY ... NULLS LAST on a running MAX window function puts the NULL row first apache/datafusion#27267 — Wrong results: ORDER BY ... NULLS LAST on a running MAX window function puts the NULL row first (open as checked on 2026-11-09).
    • Relationship: Related ordering-property / sort-elimination correctness bug, not a confirmed exact duplicate. #27267 demonstrates an incorrect ordering claim for a running window MAX with NULL placement; this issue additionally covers sliding max/min/count/unsigned sum with no partition_by, input direction, and elements entering/leaving a frame. Fixing #27267 alone may not fix A sort by a rolling max, min, count or unsigned sum is dropped when DataFusion misreads the window's order #354.
    • Static source review of DataFusion 55.2.0, branch-56, and main found the problematic window_equivalence_properties causal sliding-frame condition (increasing && (asc || no_partitioning), and the corresponding decreasing case) still present. This is not a runtime regression result for those branches.
    • Upstream action: Track and link only for now. Do not create an upstream issue, PR, or comment without separate owner approval. Do not block an LTSeq-local correctness fix on upstream progress.

    Recommended implementation: two phases

    Phase 1 — correctness first (P0 candidate; confirm against the current launch policy).

    1. Reproduce the report's failures on the latest LTSeq main and pinned DataFusion; record exact HEAD/dependency versions, observed results, and physical EXPLAIN evidence identifying an incorrectly eliminated sort. Check whether other changes have already fixed or altered any paths.
    2. Perform a bounded feasibility spike comparing:
      • Preferred if practical: a narrowly scoped LTSeq-local correction to erroneous sliding-window ordering-property propagation / sort elision, using supported DataFusion extension points and without breaking legitimate ordering in cumulative windows.
      • Safe fallback: conservatively report SetMonotonicity::NotMonotonic for impacted min/max/count/sum variants so that unsupported ordering claims cannot remove required sorts. Confirm which aggregate wrappers and call sites actually supply those properties; avoid unnecessary semantic/accumulator rewrites.
    3. Choose the simplest verifiably correct, maintainable solution. If an optimizer-level fix requires a DataFusion fork, brittle internal APIs, or cannot be proven safe in the bounded spike, prefer the conservative fallback. Correctness is not negotiable; lost sort-elision opportunities are an acceptable, measured tradeoff.
    4. Implement as a narrowly scoped PR with regression tests, before/after physical plans, release-note/changelog impact, independent review, and the existing project verification gates. Do not merge or release without owner approval.

    Phase 2 — recover safe optimizations (only if justified by evidence).

    1. Benchmark the correctness fix versus the baseline for cum_min/cum_max/cum_sum and rolling aggregates, including elapsed time, peak memory, and added SortExec/TopK operators; vary data size, batches, partitions, and in-memory/Parquet inputs.
    2. If the regression is material, investigate frame-aware ordering inference or a later upstream fix. Preserve safe cumulative-window sort elision where provable, but never infer sliding-window monotonicity solely from aggregate set-monotonicity.
    3. Track DataFusion upgrades via the same reproducer and regression matrix; only remove local mitigation after correctness and plan-level evidence on the new version.

    Expanded acceptance criteria (proposed additions to the issue's existing criteria)

    Correctness matrix:

    • Aggregates: rolling(...).min/max/count/sum and applicable cum_* variants. Verify all supported argument types; explicitly include signed/unsigned integer, float (including the fix: order float min and max by the contract on every path (#253) #353 semantics), string for extrema, and decimals where supported. Record unsupported combinations instead of silently omitting them.
    • Orderings: source argument ASC and DESC; result sort("m") and sort("m", desc=True); correct NULLS FIRST/LAST semantics according to the current LTSeq contract; ties and deterministic row identity checks where applicable.
    • NULL patterns: no NULLs, leading/trailing/interspersed NULLs, all-NULL frames, and values entering/leaving the frame. Include count changing non-monotonically as NULLs enter/leave.
    • Windows: sliding frames of varying widths and boundaries, expanding/cumulative frames, without partition_by, with one partition key, and with multiple partitions.
    • Execution layouts: one/multiple batches, one/multiple physical partitions; in-memory input, Parquet file with multiple row groups, and multi-file Parquet directory where supported.
    • Downstream composition: full sorted results and slice/head/tail; relevant positional operations and writers; test pipelines where an optimizer could drop a required SortExec.
    • Independent oracle: validate window values and output row order against a Python reference or independently constructed expected rows, not only t.collect().sort(...) (which remains a useful differential cross-check). Compare stable row IDs when aggregate values tie; honor documented tie-order guarantees.
    • Physical-plan evidence: show failing/baseline and fixed EXPLAIN plans and prove a required sort is preserved (or ordering is otherwise correctly guaranteed). Retain legitimate optimizations when possible.
    • No regressions: cum_* results remain correct; fix: order float min and max by the contract on every path (#253) #353 float min/max semantics, grouped/ungrouped aggregates, and unaffected signed sum behavior remain intact.
    • Performance evidence: compare cumulative and rolling workloads before/after for time, peak memory, and number/type of sorts, with explicit configurations and repeat counts; treat regressions as an optimization follow-up rather than a reason to accept silent wrong results.
    • Verification: run targeted tests and relevant full suites/CI on the final PR HEAD, with independent review and repeated execution across engine layouts; link exact test and CI evidence in the PR and issue.

    Decision proposal: prioritize correctness and an LTSeq-local fix, track DataFusion #27267 without making upstream actions or upgrades a prerequisite. This comment proposes implementation and acceptance scope; it does not claim the fix has been implemented or verified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions