Skip to content

Commit 9773e58

Browse files
fix union recomputing child properties (datafusion-contrib#423)
The `ChildrenIsolatorUnionExec` when replacing children would keep the old cached properties (things like `output_partitioning()`) rather than recomputing them via the replaced children. This would cause stale properties to remain. This is evident in a scenario like this: ```text ChildrenIsolatorUnionExec -> advertises output partitions = 2 child 0: 2 partitions child 1: 1 partition ``` This is ok because: ``` children say: max child/task output = 2 ChildrenIsolatorUnionExec properties: output_partitioning = 2 ``` But say an optimizer rule replaces child 0 with a new plan that has more partitions: ```text ChildrenIsolatorUnionExec -> advertises output partitions = 2 (now stale) child 0: 8 partitions (updated) child 1: 1 partition ``` Now `ChildrenIsolatorUntionExec is incorrect about its output. **If `ChildrenIsolatorUntionExec` advertises too few partitions:** ```text actual child partitions: 0 1 2 3 advertised ChildrenIsolatorUntionExec partitions: 0 1 scheduled downstream work: 0 1 missing work: 2 3 ``` This can cause correctness issues if those extra partitions might never be executed. **If `ChildrenIsolatorUntionExec` advertises too many partitions:** ```text actual child partitions: 0 1 advertised CIU partitions: 0 1 2 3 ``` Then downstream operators might schedule extra empty work.
1 parent 840ddcb commit 9773e58

2 files changed

Lines changed: 60 additions & 5 deletions

File tree

src/execution_plans/children_isolator_union.rs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,18 @@ impl ChildrenIsolatorUnionExec {
148148
task_idx_map,
149149
})
150150
}
151+
152+
fn child_task_counts(&self) -> Vec<usize> {
153+
// Preserve the task assignment in task_idx_map and allow child plans to be
154+
// replaced and properties to be recomputed from these new children.
155+
let mut counts = vec![0; self.children.len()];
156+
for children_in_task in &self.task_idx_map {
157+
for (child_idx, child_task_ctx) in children_in_task {
158+
counts[*child_idx] = counts[*child_idx].max(child_task_ctx.task_count);
159+
}
160+
}
161+
counts
162+
}
151163
}
152164

153165
impl DisplayAs for ChildrenIsolatorUnionExec {
@@ -205,9 +217,11 @@ impl ExecutionPlan for ChildrenIsolatorUnionExec {
205217
self.children.len()
206218
);
207219
}
208-
let mut clone = self.as_ref().clone();
209-
clone.children = children;
210-
Ok(Arc::new(clone))
220+
Ok(Arc::new(Self::from_children_and_task_counts(
221+
children,
222+
self.child_task_counts(),
223+
self.task_idx_map.len(),
224+
)?))
211225
}
212226

213227
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
@@ -452,6 +466,11 @@ fn split_children(
452466
#[cfg(test)]
453467
mod tests {
454468
use super::*;
469+
use datafusion::arrow::datatypes::{DataType, Field, Schema};
470+
use datafusion::physical_plan::ExecutionPlan;
471+
use datafusion::physical_plan::empty::EmptyExec;
472+
use datafusion::physical_plan::repartition::RepartitionExec;
473+
use std::sync::Arc;
455474

456475
#[test]
457476
fn children_split_all_1_task() -> Result<(), Box<dyn std::error::Error>> {
@@ -531,4 +550,40 @@ mod tests {
531550
task_count,
532551
}
533552
}
553+
554+
fn empty_partitions(partitions: usize) -> Arc<dyn ExecutionPlan> {
555+
let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int32, false)]));
556+
Arc::new(EmptyExec::new(schema).with_partitions(partitions))
557+
}
558+
559+
#[test]
560+
fn with_new_children_recomputes_partitioning() -> Result<(), Box<dyn std::error::Error>> {
561+
let child0 = empty_partitions(2);
562+
let child1 = empty_partitions(1);
563+
564+
let union = Arc::new(ChildrenIsolatorUnionExec::from_children_and_task_counts(
565+
vec![Arc::clone(&child0), Arc::clone(&child1)],
566+
vec![2, 1],
567+
3,
568+
)?);
569+
assert_eq!(
570+
union.properties().output_partitioning().partition_count(),
571+
2
572+
);
573+
574+
// Rewrites can wrap a child in a partition-changing operator. The cached union
575+
// properties must be part of the replacement. If not, the downstream planning can skip
576+
// partitions that the new child produces.
577+
let repartitioned_child0: Arc<dyn ExecutionPlan> = Arc::new(RepartitionExec::try_new(
578+
child0,
579+
Partitioning::RoundRobinBatch(8),
580+
)?);
581+
let rebuilt = union.with_new_children(vec![repartitioned_child0, child1])?;
582+
assert_eq!(
583+
rebuilt.properties().output_partitioning().partition_count(),
584+
8
585+
);
586+
587+
Ok(())
588+
}
534589
}

tests/work_unit_feed.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,9 @@ mod tests {
376376
assert_snapshot!(plan + &results, @r"
377377
┌───── DistributedExec ── Tasks: t0:[p0]
378378
│ SortPreservingMergeExec: [tag@0 ASC NULLS LAST, task@1 ASC NULLS LAST, partition@2 ASC NULLS LAST, letter@3 ASC NULLS LAST]
379-
│ [Stage 1] => NetworkCoalesceExec: output_partitions=12, input_tasks=3
379+
│ [Stage 1] => NetworkCoalesceExec: output_partitions=6, input_tasks=3
380380
└──────────────────────────────────────────────────
381-
┌───── Stage 1 ── Tasks: t0:[p0..p3] t1:[p4..p7] t2:[p8..p11]
381+
┌───── Stage 1 ── Tasks: t0:[p0..p1] t1:[p2..p3] t2:[p4..p5]
382382
│ DistributedUnionExec: t0:[c0(0/2)] t1:[c0(1/2)] t2:[c1]
383383
│ SortExec: expr=[tag@0 ASC NULLS LAST, task@1 ASC NULLS LAST, partition@2 ASC NULLS LAST, letter@3 ASC NULLS LAST], preserve_partitioning=[true]
384384
│ RowGeneratorExec: tag=feed, tasks=2, rows_per_partition=[[2], [1], [1], [2]]

0 commit comments

Comments
 (0)