Skip to content

fix: keep a CoalescePartitionsExec required by a SinglePartition child - #23948

Open
adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:fix/keep-coalesce-for-single-partition-child
Open

fix: keep a CoalescePartitionsExec required by a SinglePartition child#23948
adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:fix/keep-coalesce-for-single-partition-child

Conversation

@adriangb

@adriangb adriangb commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

  • None filed; happy to open one if preferred.

Rationale for this change

A valid query can be planned into a physical plan that SanityCheckPlan then rejects:

SanityCheckPlan
caused by
Error during planning: Plan: ["HashJoinExec: mode=CollectLeft, join_type=Left, on=[(id@0, id@0)], projection=[id@0]",
  "  DataSourceExec: file_groups={4 groups: [...]}, projection=[id], file_type=parquet",
  "  RepartitionExec: partitioning=RoundRobinBatch(8), input_partitions=1",
  "    CoalescePartitionsExec",
  "      ProjectionExec: expr=[first_value(t.id) ORDER BY [...]@1 as id]",
  "        AggregateExec: mode=FinalPartitioned, gby=[id@0 as id], aggr=[first_value(t.id) ORDER BY [...]]",
  "          RepartitionExec: partitioning=Hash([id@0], 8), input_partitions=4",
  "            AggregateExec: mode=Partial, gby=[id@1 as id], aggr=[first_value(t.id) ORDER BY [...]]",
  "              DataSourceExec: file_groups={4 groups: [...]}, projection=[ts, id], file_type=parquet"]
does not satisfy distribution requirements: SinglePartition. Child-0 output partitioning: UnknownPartitioning(4)

The HashJoinExec is in CollectLeft mode, which requires Distribution::SinglePartition on its build (left) child, but child 0 is a bare 4-partition DataSourceExec with no CoalescePartitionsExec above it.

Self-contained reproducer with datafusion-cli (the four COPY statements are what make the scan multi-partition):

set datafusion.execution.target_partitions = 8;
set datafusion.optimizer.repartition_file_scans = false;

create table src (id int, ts int) as values (1, 10), (2, 20), (3, 30);

copy (select * from src) to 'data/0.parquet' stored as parquet;
copy (select * from src) to 'data/1.parquet' stored as parquet;
copy (select * from src) to 'data/2.parquet' stored as parquet;
copy (select * from src) to 'data/3.parquet' stored as parquet;

create external table t stored as parquet location 'data/';

select a.id
from t a
left join (select distinct on (id) id, ts from t order by id, ts) f on a.id = f.id
order by a.id;

Setting datafusion.optimizer.repartition_sorts = false makes it plan fine, which points at the sort-parallelization phase.

EnsureRequirements does insert the coalesce for the SinglePartition requirement (enforce_distribution.rs, Distribution::SinglePartition => add_merge_on_top(...)). Its own phase 3a (parallelize_sorts) then takes it back out: remove_bottleneck_in_subplan removes a CoalescePartitionsExec found at children[0] positionally, without consulting the parent's distribution requirement for that child.

That parent is reached because update_coalesce_ctx_children marks a node as connected when any child qualifies. It correctly excludes a SinglePartition-requiring child from setting the flag, but the join's other child (UnspecifiedDistribution, connected to a coalesce below) sets it, so the traversal descends into the join and rewrites child 0 anyway. Nothing re-enforces distribution afterwards, so SanityCheckPlan is the first thing to notice. Note the surviving CoalescePartitionsExec on the probe side in the plan above: it is what propagated the flag, and it is untouched because the if returns without recursing into child 1.

The sibling helper on the phase 2b path already does consult the requirement (update_child_to_remove_unnecessary_sort / remove_corresponding_sort_from_sub_plan re-add a merge using the per-child child_distribution(child_idx)); only this path is missing it.

The same failure shows up with a build child that is already hash-partitioned on the join key (Child-0 output partitioning: Hash([k@0], 8)), which is what a JoinSelection input swap leaves behind — a CollectLeft join reported as join_type=Right with an embedded projection.

What changes are included in this PR?

remove_bottleneck_in_subplan now checks the parent's per-child distribution requirement before removing a coalesce, both for children[0] and when recursing into the other children.

The node parallelize_sorts is itself rewriting (the root of the call) is exempt, since the caller drops that node and rebuilds the sort cascade around the result — that is the rule's intended transformation, and gating it too would disable sort parallelization below a global sort. This is threaded through as an is_root flag on a private _impl function; the public entry point keeps its signature.

Are these changes tested?

Yes, at two levels:

  • An end-to-end sqllogictest in datafusion/sqllogictest/test_files/joins.slt reproducing it from SQL (the reproducer above, with the data written by COPY inside the test). On main it fails with exactly the distribution error above.
  • Two tests in datafusion/core/tests/physical_optimizer/ensure_requirements.rs covering both shapes of the build child (UnknownPartitioning(n) and Hash([k], n)), running the full EnsureRequirements rule and then SanityCheckPlan via the existing optimize_and_sanity_check helper, plus the idempotency check.

cargo test -p datafusion-physical-optimizer, cargo test -p datafusion --test core_integration -- physical_optimizer (530 tests) and the full sqllogictest suite (498 files) pass.

Are there any user-facing changes?

No API changes. Plans that were previously rejected by SanityCheckPlan now plan and execute; a coalesce that is genuinely required is retained where it was previously (incorrectly) removed.

`parallelize_sorts` (phase 3a of `EnsureRequirements`) removes
`CoalescePartitionsExec`s that only act as a parallelism bottleneck below a
global sort. `remove_bottleneck_in_subplan` decides that positionally: if
`children[0]` is a `CoalescePartitionsExec`, it is removed, without consulting
the parent's own distribution requirement for that child.

The traversal reaches such a parent because `update_coalesce_ctx_children`
marks a node as connected when *any* of its children is linked to a coalesce
below, and it deliberately excludes children that require
`Distribution::SinglePartition` only from *setting* that flag. So a
`CollectLeft` `HashJoinExec` whose probe side is connected is descended into,
and the coalesce satisfying the build side's `SinglePartition` requirement is
then taken out of the plan. Nothing re-runs distribution enforcement
afterwards, so the first thing to notice is `SanityCheckPlan`, which rejects
the plan with `does not satisfy distribution requirements: SinglePartition`.

Consult the per-child distribution requirement before removing, both for
`children[0]` and when recursing into other children. The node
`parallelize_sorts` is itself rewriting (the root of the call) is exempt: the
caller drops that node and rebuilds the sort cascade around the result, so its
requirement does not constrain the removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate labels Jul 28, 2026
Covers the same regression end to end from SQL: a multi-file (and therefore
multi-partition) parquet scan on the build side of a `CollectLeft` join, with a
`DISTINCT ON` aggregate on the probe side supplying the coalesce link that
makes the traversal descend into the join.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adriangb
adriangb marked this pull request as ready for review July 28, 2026 18:11
@adriangb

Copy link
Copy Markdown
Contributor Author

@zhuqi-lucas @2010YOUY01 would one of you mind taking a look at this change please?

Copilot AI 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.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Fixes a regression in EnsureRequirements sort-parallelization where a required CoalescePartitionsExec (needed to satisfy Distribution::SinglePartition for CollectLeft joins) could be removed, leading to SanityCheckPlan failures.

Changes:

  • Prevent remove_bottleneck_in_subplan from removing CoalescePartitionsExec when the parent requires SinglePartition for that child.
  • Add end-to-end sqllogictest coverage for the CollectLeft join + sort-parallelization reproducer.
  • Add physical optimizer regression tests covering both UnknownPartitioning(n) and Hash([k], n) build-side shapes.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
datafusion/physical-optimizer/src/ensure_requirements/enforce_sorting/mod.rs Adds per-child distribution checks to avoid removing required coalesces during sort parallelization.
datafusion/sqllogictest/test_files/joins.slt Adds an end-to-end regression test reproducing the CollectLeft + SinglePartition failure.
datafusion/core/tests/physical_optimizer/ensure_requirements.rs Adds optimizer-level regression tests and snapshots to ensure required coalesces are retained.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +634 to +643
let removable = |idx: usize| {
is_root
|| !matches!(
plan.input_distribution_requirements()
.child_distribution(idx),
Some(Distribution::SinglePartition)
)
};
let remove_from_first_child =
is_coalesce_partitions(&requirements.children[0].plan) && removable(0);
Comment on lines +634 to +641
let removable = |idx: usize| {
is_root
|| !matches!(
plan.input_distribution_requirements()
.child_distribution(idx),
Some(Distribution::SinglePartition)
)
};
Comment on lines +1317 to +1325
assert_snapshot!(plan_string(&optimized), @r"
SortPreservingMergeExec: [a@0 ASC NULLS LAST]
SortExec: expr=[a@0 ASC NULLS LAST], preserve_partitioning=[true]
HashJoinExec: mode=CollectLeft, join_type=Left, on=[(a@0, a@0)]
CoalescePartitionsExec
MockMultiPartitionExec
RepartitionExec: partitioning=RoundRobinBatch(8), input_partitions=4
MockMultiPartitionExec
");
Comment on lines +5629 to +5632
query I
COPY (SELECT * FROM collect_left_src) TO 'test_files/scratch/joins/collect_left/0.parquet' STORED AS PARQUET;
----
3
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.69%. Comparing base (994fc81) to head (4b31a7d).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23948      +/-   ##
==========================================
- Coverage   80.69%   80.69%   -0.01%     
==========================================
  Files        1095     1095              
  Lines      372626   372641      +15     
  Branches   372626   372641      +15     
==========================================
- Hits       300700   300692       -8     
- Misses      53978    53994      +16     
- Partials    17948    17955       +7     

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants