Skip to content

Commit f1c9994

Browse files
adriangbclaude
authored andcommitted
fix: keep a CoalescePartitionsExec required by a SinglePartition child (apache#23948)
- None filed; happy to open one if preferred. 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): ```sql 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. `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. 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. 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. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0552b4f commit f1c9994

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

  • datafusion
    • physical-optimizer/src/enforce_sorting
    • sqllogictest/test_files

datafusion/physical-optimizer/src/enforce_sorting/mod.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -676,11 +676,45 @@ fn adjust_window_sort_removal(
676676
/// the plan, some of the remaining `RepartitionExec`s might become unnecessary.
677677
/// Removes such `RepartitionExec`s from the plan as well.
678678
fn remove_bottleneck_in_subplan(
679+
requirements: PlanWithCorrespondingCoalescePartitions,
680+
) -> Result<PlanWithCorrespondingCoalescePartitions> {
681+
// The root is the node `parallelize_sorts` is rewriting (a `SortExec`,
682+
// `SortPreservingMergeExec` or `CoalescePartitionsExec`). Its own distribution
683+
// requirement does not constrain the removal, because the caller drops the node and
684+
// rebuilds the cascade around the result.
685+
remove_bottleneck_in_subplan_impl(requirements, true)
686+
}
687+
688+
fn remove_bottleneck_in_subplan_impl(
679689
mut requirements: PlanWithCorrespondingCoalescePartitions,
690+
is_root: bool,
680691
) -> Result<PlanWithCorrespondingCoalescePartitions> {
681692
let plan = &requirements.plan;
693+
// Below the root, a `CoalescePartitionsExec` feeding a child that requires
694+
// `Distribution::SinglePartition` is not an avoidable bottleneck: it is what satisfies
695+
// that requirement. Removing it leaves the parent with a multi-partition input it cannot
696+
// accept, and nothing re-runs distribution enforcement afterwards, so the plan reaches
697+
// `SanityCheckPlan` invalid. The traversal reaches such a node because
698+
// `update_coalesce_ctx_children` marks a node as connected when *any* child qualifies:
699+
// a `CollectLeft` `HashJoinExec` whose probe side is connected is descended into even
700+
// though its build side must stay single-partition.
701+
//
702+
// Only `SinglePartition` is protected. A `HashPartitioned` child is in principle in the
703+
// same position — a single-partition input trivially satisfies a hash requirement, so a
704+
// coalesce below one is also load-bearing — but nothing puts a coalesce there:
705+
// `ensure_distribution` satisfies a hash requirement with a `RepartitionExec`, never a
706+
// `CoalescePartitionsExec`. Widening the check would be dead code today.
707+
let dist_reqs = plan.required_input_distribution();
708+
let removable = |idx: usize| {
709+
is_root || !matches!(dist_reqs.get(idx), Some(Distribution::SinglePartition))
710+
};
711+
let remove_from_first_child = requirements
712+
.children
713+
.first()
714+
.is_some_and(|child| is_coalesce_partitions(&child.plan))
715+
&& removable(0);
682716
let children = &mut requirements.children;
683-
if is_coalesce_partitions(&children[0].plan) {
717+
if remove_from_first_child {
684718
// We can safely use the 0th index since we have a `CoalescePartitionsExec`.
685719
let mut new_child_node = children[0].children.swap_remove(0);
686720
while new_child_node.plan.output_partitioning() == plan.output_partitioning()
@@ -694,9 +728,14 @@ fn remove_bottleneck_in_subplan(
694728
requirements.children = requirements
695729
.children
696730
.into_iter()
697-
.map(|node| {
698-
if node.data {
699-
remove_bottleneck_in_subplan(node)
731+
.enumerate()
732+
.map(|(idx, node)| {
733+
// Deliberately conservative: not descending at all also skips legitimate
734+
// cleanups *below* a protected child (a redundant second coalesce under the
735+
// load-bearing one, say). This could later be narrowed to "descend, but
736+
// protect only the topmost coalesce" if that turns out to matter.
737+
if node.data && removable(idx) {
738+
remove_bottleneck_in_subplan_impl(node, false)
700739
} else {
701740
Ok(node)
702741
}

datafusion/sqllogictest/test_files/joins.slt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5527,3 +5527,77 @@ DROP TABLE t1;
55275527

55285528
statement ok
55295529
DROP TABLE t2;
5530+
5531+
# Regression test: a `CollectLeft` `HashJoinExec` requires `SinglePartition` on its build
5532+
# (left) child, and the `CoalescePartitionsExec` that satisfies it must survive the
5533+
# sort-parallelization phase of `EnsureRequirements`. It used to be removed positionally
5534+
# (the traversal descends into the join because the *probe* side is linked to a coalesce),
5535+
# leaving a multi-partition build side that `SanityCheckPlan` rejects with
5536+
# "does not satisfy distribution requirements: SinglePartition".
5537+
5538+
statement ok
5539+
set datafusion.execution.target_partitions = 8;
5540+
5541+
# Keep the scan multi-partition as written, i.e. one partition per file.
5542+
statement ok
5543+
set datafusion.optimizer.repartition_file_scans = false;
5544+
5545+
statement ok
5546+
CREATE TABLE collect_left_src (id INT, ts INT) AS VALUES (1, 10), (2, 20), (3, 30);
5547+
5548+
query I
5549+
COPY (SELECT * FROM collect_left_src) TO 'test_files/scratch/joins/collect_left/0.parquet' STORED AS PARQUET;
5550+
----
5551+
3
5552+
5553+
query I
5554+
COPY (SELECT * FROM collect_left_src) TO 'test_files/scratch/joins/collect_left/1.parquet' STORED AS PARQUET;
5555+
----
5556+
3
5557+
5558+
query I
5559+
COPY (SELECT * FROM collect_left_src) TO 'test_files/scratch/joins/collect_left/2.parquet' STORED AS PARQUET;
5560+
----
5561+
3
5562+
5563+
query I
5564+
COPY (SELECT * FROM collect_left_src) TO 'test_files/scratch/joins/collect_left/3.parquet' STORED AS PARQUET;
5565+
----
5566+
3
5567+
5568+
statement ok
5569+
CREATE EXTERNAL TABLE collect_left STORED AS PARQUET LOCATION 'test_files/scratch/joins/collect_left/';
5570+
5571+
# The build side is the 4-partition scan; the probe side is the `DISTINCT ON` aggregate,
5572+
# whose `CoalescePartitionsExec` is what makes the traversal reach the join.
5573+
query I
5574+
SELECT a.id
5575+
FROM collect_left a
5576+
LEFT JOIN (SELECT DISTINCT ON (id) id, ts FROM collect_left ORDER BY id, ts) f
5577+
ON a.id = f.id
5578+
ORDER BY a.id;
5579+
----
5580+
1
5581+
1
5582+
1
5583+
1
5584+
2
5585+
2
5586+
2
5587+
2
5588+
3
5589+
3
5590+
3
5591+
3
5592+
5593+
statement ok
5594+
DROP TABLE collect_left;
5595+
5596+
statement ok
5597+
DROP TABLE collect_left_src;
5598+
5599+
statement ok
5600+
reset datafusion.optimizer.repartition_file_scans;
5601+
5602+
statement ok
5603+
set datafusion.execution.target_partitions = 4;

0 commit comments

Comments
 (0)