Skip to content

Commit e5f6f81

Browse files
Move children isolator union insertion before distribution (#565)
## What changed - Add a pre-pass that replaces `UnionExec` with `ChildrenIsolatorUnionExec` before annotation and network-boundary distribution. - Keep the pre-inserted placeholder single-node-correct by mapping all children into one default task. - Make annotation react to pre-inserted `ChildrenIsolatorUnionExec` for child task-count propagation without constructing the node there. - Finalize the per-task child map during `distribute_plan`, where final task counts are available. ## Why Fixes #539. This keeps union-isolator insertion out of the network-boundary distribution recursion while preserving the existing per-task assignment behavior. ## Validation - `cargo fmt` - `cargo test --lib` - `cargo test --lib insert_children_isolator_union` - `cargo test --features integration
1 parent 9524a5a commit e5f6f81

5 files changed

Lines changed: 156 additions & 12 deletions

File tree

src/distributed_planner/distributed_query_planner.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::distributed_planner::inject_network_boundaries::{
44
CardinalityBasedNetworkBoundaryBuilder, inject_network_boundaries,
55
};
66
use crate::distributed_planner::insert_broadcast::insert_broadcast_execs;
7+
use crate::distributed_planner::insert_children_isolator_union::insert_children_isolator_unions;
78
use crate::distributed_planner::partial_reduce_below_network_shuffles::partial_reduce_below_network_shuffles;
89
use crate::distributed_planner::prepare_network_boundaries::prepare_network_boundaries;
910
use crate::distributed_planner::push_fetch_into_network_coalesce::push_fetch_into_network_coalesce;
@@ -28,6 +29,9 @@ use std::sync::Arc;
2829
/// partition-collecting parent and injects a `NetworkCoalesceExec` above its child). Then
2930
/// [insert_broadcast_execs] adds `BroadcastExec` nodes on the build side of `CollectLeft`
3031
/// hash joins so those build sides can later be wrapped in `NetworkBroadcastExec`.
32+
/// [insert_children_isolator_unions] replaces `UnionExec` nodes with single-node-correct
33+
/// `ChildrenIsolatorUnionExec` placeholders whose task maps are finalized during boundary
34+
/// injection.
3135
///
3236
/// 2. **Boundary injection.** [inject_network_boundaries] walks the plan, computes a task count
3337
/// for each node, and inserts `NetworkShuffleExec` / `NetworkBroadcastExec` /
@@ -109,6 +113,7 @@ impl QueryPlanner for DistributedQueryPlanner {
109113
let cfg = session_state.config_options();
110114

111115
plan = insert_broadcast_execs(plan, cfg)?;
116+
plan = insert_children_isolator_unions(plan, cfg)?;
112117

113118
if d_cfg.dynamic_task_count {
114119
// The task count will be decided dynamically at execution time.

src/distributed_planner/inject_network_boundaries.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ use datafusion::physical_plan::execution_plan::CardinalityEffect;
1717
use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
1818
use datafusion::physical_plan::repartition::RepartitionExec;
1919
use datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
20-
use datafusion::physical_plan::union::UnionExec;
2120
use datafusion::physical_plan::{ExecutionPlan, PlanProperties};
2221
use datafusion::prelude::SessionConfig;
2322
use std::any::TypeId;
@@ -259,9 +258,9 @@ async fn _inject_network_boundaries(
259258
let mut task_count = estimator
260259
.task_estimation(&plan, nb_ctx.cfg)
261260
.map_or(Desired(1), |v| v.task_count);
262-
if nb_ctx.d_cfg.children_isolator_unions && plan.is::<UnionExec>() {
263-
// Unions have the chance to decide how many tasks they should run on. If there's a union
264-
// with a bunch of children, the user might want to increase parallelism and increase the
261+
if plan.is::<ChildrenIsolatorUnionExec>() {
262+
// Isolating unions have the chance to decide how many tasks they should run on. If there
263+
// is a union with a bunch of children, the user might want to increase parallelism and the
265264
// task count for the stage running that.
266265
let mut count = 0;
267266
for processed_child in processed_children.iter() {
@@ -404,10 +403,8 @@ async fn _inject_network_boundaries(
404403
/// stage). Instead, rescale the boundary's input via [network_boundary_scale_input] using the
405404
/// *consumer* partition and task counts of this side of the boundary, and stitch the rescaled
406405
/// stage back in via [NetworkBoundary::with_input_stage].
407-
/// - **Eligible `UnionExec`s** (when `children_isolator_unions` is on): rewrite to
408-
/// [ChildrenIsolatorUnionExec] and recurse into each child with the per-child task count
409-
/// chosen by [ChildrenIsolatorUnionExec::from_children_and_task_counts] — each child runs
410-
/// isolated in its own subset of tasks.
406+
/// - **`ChildrenIsolatorUnionExec`s**: finalize the placeholder's task map and recurse into each
407+
/// child with the allocated task count. Each child runs isolated in its own subset of tasks.
411408
/// - **Everything else**: recurse into children with the same `task_count`, then rebuild the
412409
/// node with the rebuilt children.
413410
impl InjectNetworkBoundaryContext<'_> {
@@ -440,8 +437,8 @@ impl InjectNetworkBoundaryContext<'_> {
440437
// Just annotate the network boundary and stop recursion here.
441438
Ok(self.plan_with_task_count(Arc::clone(plan), task_count))
442439

443-
// Handle ChildrenIsolatorUnionExec.
444-
} else if self.d_cfg.children_isolator_unions && plan.is::<UnionExec>() {
440+
// Finalize a pre-inserted ChildrenIsolatorUnionExec.
441+
} else if plan.is::<ChildrenIsolatorUnionExec>() {
445442
// Propagating through ChildrenIsolatorUnionExec is not that easy, each child will
446443
// be executed in its own task, and therefore, they will act as if they were in executing
447444
// in a non-distributed context. The ChildrenIsolatorUnionExec itself will make sure to
@@ -629,6 +626,7 @@ impl NetworkBoundaryBuilder for CardinalityBasedNetworkBoundaryBuilder {
629626
mod tests {
630627
use super::*;
631628
use crate::distributed_planner::insert_broadcast::insert_broadcast_execs;
629+
use crate::distributed_planner::insert_children_isolator_union::insert_children_isolator_unions;
632630
use crate::test_utils::plans::{BuildSideOneTaskEstimator, TestPlanBuilder};
633631
use crate::{TaskEstimation, TaskEstimator, assert_snapshot};
634632
use datafusion::config::ConfigOptions;
@@ -1264,8 +1262,10 @@ mod tests {
12641262
let plan = test_plan.physical_plan(query).await;
12651263
let session_config = test_plan.get_ctx().copied_config();
12661264

1267-
let plan_w_broadcast = insert_broadcast_execs(plan, session_config.options())
1265+
let plan = insert_broadcast_execs(plan, session_config.options())
12681266
.expect("failed to insert broadcasts");
1267+
let plan = insert_children_isolator_unions(plan, session_config.options())
1268+
.expect("failed to insert children isolator unions");
12691269
let network_boundaries_ctx = InjectNetworkBoundaryContext {
12701270
cfg: session_config.options(),
12711271
d_cfg: DistributedConfig::from_config_options(session_config.options()).unwrap(),
@@ -1277,7 +1277,7 @@ mod tests {
12771277
nb_builder: &CardinalityBasedNetworkBoundaryBuilder,
12781278
};
12791279

1280-
let annotated = _inject_network_boundaries(plan_w_broadcast, None, &network_boundaries_ctx)
1280+
let annotated = _inject_network_boundaries(plan, None, &network_boundaries_ctx)
12811281
.await
12821282
.expect("failed to annotate plan");
12831283
debug_annotated(&annotated, 0, &network_boundaries_ctx)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use std::sync::Arc;
2+
3+
use datafusion::common::tree_node::{Transformed, TreeNode};
4+
use datafusion::config::ConfigOptions;
5+
use datafusion::error::DataFusionError;
6+
use datafusion::physical_plan::ExecutionPlan;
7+
use datafusion::physical_plan::union::UnionExec;
8+
9+
use crate::execution_plans::ChildrenIsolatorUnionExec;
10+
11+
use super::DistributedConfig;
12+
13+
/// Replaces every [`UnionExec`] with a single-node-correct [`ChildrenIsolatorUnionExec`].
14+
///
15+
/// The placeholder maps every child to one default task. Network-boundary injection later
16+
/// replaces that mapping with the final per-child task allocation.
17+
pub(super) fn insert_children_isolator_unions(
18+
plan: Arc<dyn ExecutionPlan>,
19+
cfg: &ConfigOptions,
20+
) -> Result<Arc<dyn ExecutionPlan>, DataFusionError> {
21+
let d_cfg = DistributedConfig::from_config_options(cfg)?;
22+
if !d_cfg.children_isolator_unions {
23+
return Ok(plan);
24+
}
25+
26+
plan.transform_down(|node| {
27+
if !node.is::<UnionExec>() {
28+
return Ok(Transformed::no(node));
29+
}
30+
Ok(Transformed::yes(Arc::new(
31+
ChildrenIsolatorUnionExec::new_single_task(node.children().into_iter().cloned())?,
32+
)))
33+
})
34+
.map(|transformed| transformed.data)
35+
}
36+
37+
#[cfg(test)]
38+
mod tests {
39+
use super::*;
40+
use crate::distributed_ext::DistributedExt;
41+
use datafusion::arrow::array::{ArrayRef, Int32Array};
42+
use datafusion::arrow::record_batch::RecordBatch;
43+
use datafusion::datasource::memory::MemorySourceConfig;
44+
use datafusion::physical_plan::ExecutionPlanProperties;
45+
use datafusion::physical_plan::common::collect;
46+
use datafusion::prelude::{SessionConfig, SessionContext};
47+
48+
#[tokio::test]
49+
async fn inserted_union_is_single_node_correct() {
50+
let original = union_plan();
51+
let expected_schema = original.schema();
52+
let expected_partition_count = original.output_partitioning().partition_count();
53+
let expected_rows = collect_rows(Arc::clone(&original)).await;
54+
55+
let plan = insert_children_isolator_unions(original, config(true).options())
56+
.expect("insert children isolator union");
57+
58+
assert!(plan.is::<ChildrenIsolatorUnionExec>());
59+
assert_eq!(plan.schema(), expected_schema);
60+
assert_eq!(
61+
plan.output_partitioning().partition_count(),
62+
expected_partition_count
63+
);
64+
assert_eq!(collect_rows(plan).await, expected_rows);
65+
}
66+
67+
#[test]
68+
fn replaces_nested_unions() {
69+
let inner = union_plan();
70+
let plan =
71+
UnionExec::try_new(vec![inner, memory_plan(&[5, 6])]).expect("create outer union");
72+
73+
let plan = insert_children_isolator_unions(plan, config(true).options())
74+
.expect("insert children isolator unions");
75+
76+
assert!(plan.is::<ChildrenIsolatorUnionExec>());
77+
assert!(plan.children()[0].is::<ChildrenIsolatorUnionExec>());
78+
}
79+
80+
#[test]
81+
fn leaves_union_when_disabled() {
82+
let plan = insert_children_isolator_unions(union_plan(), config(false).options())
83+
.expect("skip children isolator union");
84+
85+
assert!(plan.is::<UnionExec>());
86+
}
87+
88+
fn union_plan() -> Arc<dyn ExecutionPlan> {
89+
UnionExec::try_new(vec![memory_plan(&[1, 2]), memory_plan(&[3, 4])]).expect("create union")
90+
}
91+
92+
fn memory_plan(values: &[i32]) -> Arc<dyn ExecutionPlan> {
93+
let values: ArrayRef = Arc::new(Int32Array::from(values.to_vec()));
94+
let batch = RecordBatch::try_from_iter([("value", values)]).expect("create record batch");
95+
MemorySourceConfig::try_new_exec(&[vec![batch.clone()]], batch.schema(), None)
96+
.expect("create memory plan")
97+
}
98+
99+
async fn collect_rows(plan: Arc<dyn ExecutionPlan>) -> Vec<i32> {
100+
let context = SessionContext::new().task_ctx();
101+
let mut rows = Vec::new();
102+
for partition in 0..plan.output_partitioning().partition_count() {
103+
let stream = plan
104+
.execute(partition, Arc::clone(&context))
105+
.expect("execute partition");
106+
let batches = collect(stream).await.expect("collect partition");
107+
for batch in batches {
108+
let values = batch
109+
.column(0)
110+
.as_any()
111+
.downcast_ref::<Int32Array>()
112+
.expect("int32 column");
113+
for row in 0..values.len() {
114+
rows.push(values.value(row));
115+
}
116+
}
117+
}
118+
rows
119+
}
120+
121+
fn config(children_isolator_unions: bool) -> SessionConfig {
122+
let mut config = SessionConfig::new();
123+
config.set_distributed_option_extension(DistributedConfig {
124+
children_isolator_unions,
125+
..Default::default()
126+
});
127+
config
128+
}
129+
}

src/distributed_planner/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod distributed_config;
22
mod distributed_query_planner;
33
mod inject_network_boundaries;
44
mod insert_broadcast;
5+
mod insert_children_isolator_union;
56
mod network_boundary;
67
mod partial_reduce_below_network_shuffles;
78
mod prepare_network_boundaries;

src/execution_plans/children_isolator_union.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ impl ChildWeight {
129129
}
130130

131131
impl ChildrenIsolatorUnionExec {
132+
/// Creates a single-node union placeholder with every child assigned to the default task.
133+
pub(crate) fn new_single_task(
134+
children: impl IntoIterator<Item = Arc<dyn ExecutionPlan>>,
135+
) -> Result<Self, DataFusionError> {
136+
let children = children.into_iter().collect_vec();
137+
let child_count = children.len();
138+
Self::from_children_and_weights(children, vec![ChildWeight::desired(1.0); child_count], 1)
139+
}
140+
132141
pub(crate) fn from_children_and_weights(
133142
children: impl IntoIterator<Item = Arc<dyn ExecutionPlan>>,
134143
children_weights: impl IntoIterator<Item = ChildWeight>,

0 commit comments

Comments
 (0)