Skip to content

fix: preserve fetched merges during requirement enforcement - #25065

Draft
discord9 wants to merge 2 commits into
apache:mainfrom
discord9:fix/preserve-distribution-fetch
Draft

discord9 wants to merge 2 commits into
apache:mainfrom
discord9:fix/preserve-distribution-fetch

Conversation

@discord9

@discord9 discord9 commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Related to #14150 and #23800. This fix also applies independently of #23800.

Rationale for this change

Enforcing distribution and sorting requirements can remove a fetch that limits the combined output of several partitions. Reoptimizing a physical plan with a global LIMIT over a partitioned aggregate can therefore return more rows: a reproduced case returns one row after the first pass and three after the second.

For a fetched sort-preserving merge, ordering also determines which rows are selected. Replacing it with a fetched coalesce can return the right number of rows but the wrong values, even if the parent does not require ordered output. Removing it during sort cleanup can leave only partition-local TopK bounds instead of the global limit.

A SQL-derived example is:

SELECT b
FROM (SELECT a, b FROM t ORDER BY a LIMIT 2)
GROUP BY b

With two input partitions containing (a, b) values [(1, 10), (100, 99)] and [(2, 20), (200, 98)], the SQL-generated plan returns [10, 20]. Passing that plan through DefaultPhysicalPlanner::optimize_physical_plan again can remove its fetched merge and return [10, 20, 99]. This reproduces a failure when reoptimizing an existing plan, not a failure of ordinary one-pass SQL execution.

What changes are included in this PR?

  • Preserve fetched operators when removing distribution-changing operators, including fetch zero.
  • Preserve fetched sort-preserving merges and their ordered inputs when replacing order-preserving variants or removing unnecessary sorts.
  • Remove fetch collection and reapplication code made unnecessary by retaining those operators.
  • Update the single-partition merge regression to retain its limit and required input ordering.

Fetch-free distribution and sort-cleanup rewrites remain available.

What is the testing strategy for this PR?

Execution tests cover fetched coalesces, repeated optimization of a partitioned aggregate, ordered TopK selection, OFFSET, partition-local limits, and fetched versus fetch-free merge replacement. Assertions check selected values as well as row counts.

The SQL-derived regression plans and executes both an ordered LIMIT query and the aggregate query above, then reoptimizes each plan through the public physical planner API and checks the results again. Removing the sort-cleanup protection reproduces the extra-row failure; restoring it preserves [10, 20]. Separate deletion tests also reproduced the failures addressed by the distribution protections.

Passed locally:

cargo fmt --all --check
cargo test -p datafusion --test core_integration
cargo test -p datafusion-physical-optimizer --lib
cargo clippy --workspace --all-targets --all-features -- -D warnings

The core integration suite passed 1,126 tests and the physical optimizer suite passed 37 tests.

Are there any user-facing changes?

Distribution and sorting enforcement preserve existing LIMIT/OFFSET bounds and ordered row selection when optimizing physical plans. No public API or configuration changes.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.74%. Comparing base (8a92281) to head (b50c877).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25065      +/-   ##
==========================================
+ Coverage   81.72%   81.74%   +0.02%     
==========================================
  Files        1127     1128       +1     
  Lines      416519   416633     +114     
  Branches   416519   416633     +114     
==========================================
+ Hits       340401   340587     +186     
+ Misses      56115    55988     -127     
- Partials    20003    20058      +55     

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

@discord9 discord9 changed the title fix: preserve fetched merges during distribution enforcement fix: preserve fetched merges during requirement enforcement Sep 10, 2026
mohitgurav20 pushed a commit to mohitgurav20/datafusion that referenced this pull request Oct 24, 2026
…che#26455)

## Which issue does this PR close?

- Closes apache#26454.

## Rationale for this change

`ORDER BY ... LIMIT` over a `UNION ALL` returns wrong rows when a
leading sort key is a constant with a different value in each arm:

```sql
SELECT 5 AS c0, id AS c1 FROM t
UNION ALL
SELECT 1 AS c0, id AS c1 FROM t WHERE g = 0
ORDER BY c0, c1 LIMIT 2
-- returns (1, 0), (5, 0); expected (1, 0), (1, 1)
```

The optimizer plans a partitioned TopK under a `SortPreservingMergeExec`
and drops `c0` from the TopK key, because `c0` is constant within each
partition. All partitions of one `SortExec` share a single TopK dynamic
filter threshold. The `c0 = 5` partition publishes a threshold on `c1`
alone, and the `c0 = 1` partition then discards a row it needs. The
issue has the full walk-through.

## What changes are included in this PR?

`add_sort_above` and `add_sort_above_with_distribution` in
`datafusion/physical-optimizer/src/utils.rs` both remove sort keys that
are constant in the input. They now share one helper,
`ordering_without_constants(plan, sort_requirements,
keep_heterogeneous)`, which still removes them, except that a constant
whose value differs between partitions
(`AcrossPartitions::Heterogeneous`) is kept when `keep_heterogeneous` is
set. Each caller sets it when an operator compares rows from different
partitions on the new ordering:

- **Partitioned TopK** (both helpers): the new `SortExec` has a `fetch`
and its input has more than one partition. The shared threshold then
compares the full key, which is comparable across partitions.
- **Merge above the sort** (`add_sort_above_with_distribution` only):
when the parent requires a single partition, the helper wraps the sort
in a `SortPreservingMergeExec` built from the same ordering. That merge
compares rows across partitions even without a fetch, and since it
clears per-partition constants from its output, a shortened key would no
longer guarantee the required ordering. The constant is now kept there
too.

The helper preserves these existing behaviors:

- `Uniform` constants are still removed.
- A partitioned sort without a `fetch` and without an added merge still
removes `Heterogeneous` constants. Each partition sorts on its own, and
any merge above it sorts on the full key.
- With a single input partition nothing is compared across partitions.

With the change, the plan for the query above is:

```
SortPreservingMergeExec: [c0@0 ASC NULLS LAST, c1@1 ASC NULLS LAST], fetch=2
  SortExec: TopK(fetch=2), expr=[c0@0 ASC NULLS LAST, c1@1 ASC NULLS LAST], preserve_partitioning=[true], sort_prefix=[c0@0 ASC NULLS LAST]
```

The longer key gives the TopK a `sort_prefix` of `[c0]`, because each
partition's input is trivially ordered by its constant `c0`.

Why the fix lives here: `EXPLAIN VERBOSE` shows the shortened `SortExec:
TopK(fetch=2), expr=[c1@1 ...]` already in the plan right after
`EnsureRequirements`, with the fetch attached. No later rule changes it.
A temporary print in the two helpers showed `parallelize_sorts` building
it through `add_sort_above_with_check` → `add_sort_above`, and
`pushdown_sorts` building it again through
`add_sort_above_with_distribution`. Both calls had `fetch = Some(2)`,
two input partitions, and `c0` reported as `Heterogeneous`. So both
helpers need the change, and fixing only one would leave the bug.

apache#23528 fixed the same `Uniform`/`Heterogeneous` confusion in
`EquivalenceGroup`'s sort normalization. This PR applies it to the TopK
key and to the merge that these two helpers build.

Open PRs apache#25408 and apache#25065 change the callers of these helpers
(`enforce_sorting/sort_pushdown.rs` and `enforce_sorting/mod.rs`) but
not `utils.rs`. This PR should not conflict with them textually, but
whichever merges second should re-run `topk.slt`.

Two separate validation prerequisites are included: three narrowly
scoped `mutable_key_type` Clippy annotations matching existing optimizer
conventions, and lockfile patch updates for `rustls-webpki` (0.103.16)
and `webpki-root-certs` (1.0.10). The exact all-features Clippy command
failed on the unchanged base, and the security audit exposed
RUSTSEC-2026-0382 / RUSTSEC-2026-0383 in the existing lockfile. Neither
prerequisite changes the TopK implementation.

## What is the testing strategy for this PR?

- **`topk.slt`**, on `VALUES` tables, each with an `EXPLAIN` that pins
the TopK key and a result query:
  - the query above, and the same query without the filter;
  - `ORDER BY c0 DESC, c1`;
- `ORDER BY c1, c0`, where the constant is not the leading key and only
breaks ties on `c1`;
- `LIMIT ... OFFSET`, where the TopK needs three rows from the `c0 = 1`
arm;
- a three-arm union, so two arms can publish a threshold that cuts the
third;
- controls: `LIMIT 3`, a `Uniform` constant (end-to-end control), and no
`fetch` (sort pushed into the arms).

Whether a result query fails without the fix depends on which arm
publishes its threshold first (the `DESC` query never failed in 8 runs),
so the `EXPLAIN`s are what catch a regression reliably.
- **Unit tests in `utils.rs`**, one per branch: a partitioned TopK keeps
a `Heterogeneous` constant; the added merge keeps it with and without a
fetch; a partitioned sort without a fetch or merge drops it; a `Uniform`
constant is always dropped.
- **Unit tests in `topk/mod.rs`**: two TopKs share one
`TopKDynamicFilters`, with key `[c0, c1]` and `sort_prefix` `[c0]`. Both
feeding orders return the right top 2. A second test feeds several
batches per partition and checks the early-exit state: the better
partition keeps reading until it has `k` rows, and only then does the
worse one finish.

Ablation, with all tests in place:

- Dropping `Heterogeneous` constants again in
`ordering_without_constants` fails both `keep` unit tests in `utils.rs`
and 10 `topk.slt` assertions, including every new `EXPLAIN`.
- Removing only the merge part (`|| add_merge`) fails
`merge_keeps_a_constant_that_differs_between_partitions` on its `fetch =
None` case. No `.slt` test fails, see below.

No existing `.slt` plan changes.

Local validation on the final tree: formatting, `cargo clippy
--all-targets --all-features -- -D warnings`, and `uv run
./dev/rust_lint.sh` pass. The required extended workspace test command
passes with 12,733 Rust tests (8 ignored) and all 535 SQL files.
Independent mutations also confirm the new TopK tests fail when the
leading filter key is omitted, and the multi-batch test fails when the
shared-prefix comparison is reversed. Benchmark datasets were
unavailable; no performance claim is made.

## Are there any user-facing changes?

The wrong results are fixed. Plans that hit this case now show the
constant in the TopK `expr`. There are no API changes.

## Unknowns / assumptions

- **Shared prefix threshold.** The new key gives the TopK a
`sort_prefix` of `[c0]`. That enables the shared-prefix early exit from
apache#22991 for these plans. It stays correct because it compares on the full
key prefix, which now includes `c0`, and a threshold is only published
once the publishing partition has `k` rows. The multi-batch TopK unit
test exercises this directly; no `.slt` test forces a particular
partition to finish first.
- **No tested SQL reached the no-fetch merge branch.** I instrumented
`add_sort_above_with_distribution` for `fetch = None` with a
single-partition requirement and more than one input partition. It
recorded no hits across the whole sqllogictest suite, for candidate
queries (un-partitioned windows over union, filter, join, aggregate and
distinct), or with `optimizer.repartition_sorts = false` /
`optimizer.enable_sort_pushdown = false`. Distribution enforcement and
`parallelize_sorts` build the merge with the full ordering before
`pushdown_sorts` runs. That is an observation, not a proof, so that
branch is covered by the unit test only.
- **Fetch attached after key simplification (accepted risk;
follow-up).** The helper decides whether to retain heterogeneous
constants when it builds the sort. A partitioned sort without a fetch or
helper-added merge may omit `c0`; if `LimitPushdown` or
`LimitPushPastWindows` subsequently calls `SortExec::with_fetch`, that
method enables shared TopK filtering without restoring `c0`. This PR
does not guard that transition. No SQL query tried so far reaches the
entire sequence under the current rule order: the tested shapes already
have a fetch at construction, push sorts into individual union arms,
retain the full key below a merge, hash-repartition first, or eliminate
the inner sort. That evidence does not establish unreachability. The
proposed follow-up is a hand-built plan regression plus an executor
guard that isolates or disables unsafe shared filtering, including
dynamic-filter pushdown. The reproduction is proposed, not yet executed.
`preserve_fetch_above_ordered_plan` remains an additional path to audit.
- **Alternative fix.** The other option is to stop sharing the TopK
threshold across partitions when the input has a `Heterogeneous`
constant. That would be a larger change inside TopK and would give up
the shared filter. I went with the smaller planner change, but I am
happy to switch if maintainers prefer it.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Jay Zhan <[email protected]>
@github-actions

Copy link
Copy Markdown

Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days.

@github-actions github-actions Bot added the Stale PR has not had any activity for some time label Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate optimizer Optimizer rules Stale PR has not had any activity for some time

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants