datafusion_distributed/execution_plans/
network_coalesce.rs1use crate::DistributedTaskContext;
2use crate::common::require_one_child;
3use crate::distributed_planner::{NetworkBoundary, ProducerHead};
4use crate::execution_plans::common::scale_partitioning_props;
5use crate::stage::{LocalStage, Stage};
6use crate::worker::WorkerConnectionPool;
7use datafusion::common::tree_node::TreeNodeRecursion;
8use datafusion::common::{exec_err, not_impl_err, plan_err};
9use datafusion::error::Result;
10use datafusion::execution::{SendableRecordBatchStream, TaskContext};
11use datafusion::physical_expr::PhysicalExpr;
12use datafusion::physical_expr_common::metrics::MetricsSet;
13use datafusion::physical_plan::limit::LocalLimitExec;
14use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
15use datafusion::physical_plan::{
16 DisplayAs, DisplayFormatType, EmptyRecordBatchStream, ExecutionPlan, PlanProperties,
17 Statistics, StatisticsArgs, internal_err,
18};
19use std::fmt::{Debug, Formatter};
20use std::sync::Arc;
21use uuid::Uuid;
22
23#[derive(Debug, Clone)]
77pub struct NetworkCoalesceExec {
78 pub(crate) properties: Arc<PlanProperties>,
80 pub(crate) input_stage: Stage,
81 pub(crate) worker_connections: WorkerConnectionPool,
82}
83
84impl NetworkCoalesceExec {
85 pub(crate) fn try_from_stage(
86 input_stage: Stage,
87 input_properties: Arc<PlanProperties>,
88 consumer_tasks: usize,
89 ) -> Result<Self> {
90 let max_input_task_count = input_stage.task_count().div_ceil(consumer_tasks).max(1);
94 let props = scale_partitioning_props(&input_properties, |p| p * max_input_task_count)?;
95
96 Ok(Self {
97 properties: props,
98 worker_connections: WorkerConnectionPool::new(input_stage.task_count()),
99 input_stage,
100 })
101 }
102
103 pub fn try_new(
118 input: Arc<dyn ExecutionPlan>,
119 producer_tasks: usize,
120 consumer_tasks: usize,
121 ) -> Result<Self> {
122 if consumer_tasks == 0 {
123 return plan_err!("The `consumer_tasks` input of a NetworkCoalesceExec must not be 0");
124 }
125
126 let input_properties = Arc::clone(input.properties());
127 Self::try_from_stage(
128 Stage::Local(LocalStage {
129 query_id: Uuid::nil(),
133 num: 0,
134 plan: input,
135 tasks: producer_tasks,
136 metrics_set: Default::default(),
137 }),
138 input_properties,
139 consumer_tasks,
140 )
141 }
142
143 pub(crate) fn with_fetch_on_input_stage(&self, fetch: usize) -> Result<Arc<dyn ExecutionPlan>> {
144 let Stage::Local(local) = &self.input_stage else {
145 return Ok(Arc::new(self.clone()));
146 };
147
148 let input_with_fetch = if local.plan.fetch().is_some_and(|existing| existing <= fetch) {
149 Arc::clone(&local.plan)
150 } else {
151 local
152 .plan
153 .with_fetch(Some(fetch))
154 .unwrap_or_else(|| Arc::new(LocalLimitExec::new(Arc::clone(&local.plan), fetch)))
155 };
156
157 let mut self_clone = self.clone();
158 self_clone.input_stage = Stage::Local(LocalStage {
159 query_id: local.query_id,
160 num: local.num,
161 plan: input_with_fetch,
162 tasks: local.tasks,
163 metrics_set: Default::default(),
164 });
165 Ok(Arc::new(self_clone))
166 }
167}
168
169impl NetworkBoundary for NetworkCoalesceExec {
170 fn input_stage(&self) -> &Stage {
171 &self.input_stage
172 }
173
174 fn with_input_stage(&self, input_stage: Stage) -> Result<Arc<dyn NetworkBoundary>> {
175 let mut self_clone = self.clone();
176 self_clone.properties = scale_partitioning_props(self_clone.properties(), |p| {
177 p * input_stage.task_count() / self_clone.input_stage.task_count().max(1)
178 })?;
179 self_clone.worker_connections = WorkerConnectionPool::new(input_stage.task_count());
180 self_clone.input_stage = input_stage;
181 Ok(Arc::new(self_clone))
182 }
183
184 fn producer_head(&self, _consumer_task_count: usize) -> Result<ProducerHead> {
185 Ok(ProducerHead::None)
186 }
187}
188
189impl DisplayAs for NetworkCoalesceExec {
190 fn fmt_as(&self, _t: DisplayFormatType, f: &mut Formatter) -> std::fmt::Result {
191 let input_tasks = self.input_stage.task_count();
192 let partitions = self.properties.partitioning.partition_count();
193 let stage = self.input_stage.num();
194 write!(
195 f,
196 "[Stage {stage}] => NetworkCoalesceExec: output_partitions={partitions}, input_tasks={input_tasks}",
197 )
198 }
199}
200
201impl ExecutionPlan for NetworkCoalesceExec {
202 fn name(&self) -> &str {
203 "NetworkCoalesceExec"
204 }
205
206 fn properties(&self) -> &Arc<PlanProperties> {
207 &self.properties
208 }
209
210 fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
211 match &self.input_stage.local_plan() {
212 Some(v) => vec![v],
213 None => vec![],
214 }
215 }
216
217 fn apply_expressions(
218 &self,
219 _f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> Result<TreeNodeRecursion>,
220 ) -> Result<TreeNodeRecursion> {
221 Ok(TreeNodeRecursion::Continue)
222 }
223
224 fn with_new_children(
225 self: Arc<Self>,
226 children: Vec<Arc<dyn ExecutionPlan>>,
227 ) -> Result<Arc<dyn ExecutionPlan>> {
228 let mut self_clone = self.as_ref().clone();
229 match &mut self_clone.input_stage {
230 Stage::Local(local) => {
231 local.plan = require_one_child(children)?;
232 }
233 Stage::Remote(_) => {
234 if !children.is_empty() {
235 not_impl_err!("NetworkBoundary cannot accept children")?
236 }
237 }
238 }
239 Ok(Arc::new(self_clone))
240 }
241
242 fn execute(
243 &self,
244 partition: usize,
245 context: Arc<TaskContext>,
246 ) -> Result<SendableRecordBatchStream> {
247 let remote_stage = match &self.input_stage {
248 Stage::Local(local) => return local.execute(partition, context),
249 Stage::Remote(remote_stage) => remote_stage,
250 };
251
252 let task_context = DistributedTaskContext::from_ctx(&context);
253 if task_context.task_index >= task_context.task_count {
254 return exec_err!(
255 "NetworkCoalesceExec invalid task context: task_index={} >= task_count={}",
256 task_context.task_index,
257 task_context.task_count
258 );
259 }
260
261 let out_partitions = self.properties().partitioning.partition_count();
262 let partitions_per_task = out_partitions
263 .checked_div(
264 self.input_stage
265 .task_count()
266 .div_ceil(task_context.task_count)
267 .max(1),
268 )
269 .unwrap_or(0);
270 if partitions_per_task == 0 {
271 return exec_err!("NetworkCoalesceExec has 0 partitions per input task");
272 }
273
274 let input_task_count = self.input_stage.task_count();
275 let group = task_group(
276 input_task_count,
277 task_context.task_index,
278 task_context.task_count,
279 );
280
281 let input_task_offset = partition / partitions_per_task;
282 let target_partition = partition % partitions_per_task;
283
284 if input_task_offset >= group.len {
290 return Ok(Box::pin(EmptyRecordBatchStream::new(self.schema())));
291 }
292
293 if input_task_offset >= group.max_len {
295 return internal_err!(
296 "NetworkCoalesceExec input_task_offset={} >= group.max_len={}",
297 input_task_offset,
298 group.max_len
299 );
300 }
301
302 let target_task = group.start_task + input_task_offset;
303
304 let stream = self.worker_connections.execute(
305 remote_stage,
306 0..partitions_per_task,
307 target_task,
308 target_partition,
309 self.producer_head(task_context.task_count)?,
310 &context,
311 )?;
312
313 Ok(Box::pin(RecordBatchStreamAdapter::new(
314 self.schema(),
315 stream,
316 )))
317 }
318
319 fn metrics(&self) -> Option<MetricsSet> {
320 Some(self.worker_connections.metrics.clone_inner())
321 }
322
323 fn statistics_from_inputs(
324 &self,
325 _input_stats: &[Arc<Statistics>],
326 args: &StatisticsArgs,
327 ) -> Result<Arc<Statistics>> {
328 self.input_stage.partition_statistics(
329 args.partition(),
330 self.properties.output_partitioning().partition_count(),
331 self.schema(),
332 )
333 }
334}
335
336#[derive(Debug, Clone, Copy)]
337struct TaskGroup {
338 start_task: usize,
340 len: usize,
342 max_len: usize,
348}
349
350fn task_group(input_task_count: usize, task_index: usize, task_count: usize) -> TaskGroup {
352 if task_count == 0 {
353 return TaskGroup {
354 start_task: 0,
355 len: 0,
356 max_len: 0,
357 };
358 }
359
360 let base_tasks_per_group = input_task_count / task_count;
364 let groups_with_extra_task = input_task_count % task_count;
365
366 let len = base_tasks_per_group + usize::from(task_index < groups_with_extra_task);
367 let start_task = (task_index * base_tasks_per_group) + task_index.min(groups_with_extra_task);
368 let max_len = base_tasks_per_group + usize::from(groups_with_extra_task > 0);
369
370 TaskGroup {
371 start_task,
372 len,
373 max_len,
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use datafusion::arrow::datatypes::Schema;
381 use datafusion::physical_plan::empty::EmptyExec;
382
383 #[derive(Clone, Copy)]
384 struct Case {
385 name: &'static str,
386 input_tasks: usize,
387 consumer_tasks: usize,
388 }
389
390 fn expected_groups(input_tasks: usize, consumer_tasks: usize) -> Vec<(usize, usize)> {
391 assert!(consumer_tasks > 0, "consumer_tasks must be non-zero");
392
393 let base_tasks_per_group = input_tasks / consumer_tasks;
394 let groups_with_extra_task = input_tasks % consumer_tasks;
395 let mut groups = Vec::with_capacity(consumer_tasks);
396 let mut start_task = 0;
397
398 for task_index in 0..consumer_tasks {
399 let len = base_tasks_per_group + usize::from(task_index < groups_with_extra_task);
400 groups.push((start_task, len));
401 start_task += len;
402 }
403
404 groups
405 }
406
407 fn assert_case(case: Case) -> Result<()> {
408 let child: Arc<dyn ExecutionPlan> = Arc::new(EmptyExec::new(Arc::new(Schema::empty())));
410 let child_partitions = child.properties().partitioning.partition_count();
411
412 let exec = NetworkCoalesceExec::try_new(
413 Arc::clone(&child),
414 case.input_tasks,
415 case.consumer_tasks,
416 )?;
417
418 let max_group_size = case.input_tasks.div_ceil(case.consumer_tasks).max(1);
420 assert_eq!(
421 exec.properties().partitioning.partition_count(),
422 child_partitions * max_group_size
423 );
424
425 let groups = expected_groups(case.input_tasks, case.consumer_tasks);
426 assert_eq!(groups.len(), case.consumer_tasks);
427
428 let mut seen = vec![false; case.input_tasks];
429 let mut expected_start = 0;
430 let mut padding_slots = 0;
431
432 for (index, (start, len)) in groups.into_iter().enumerate() {
433 assert_eq!(
434 start, expected_start,
435 "case {} group {} should be contiguous",
436 case.name, index
437 );
438 assert!(
439 start + len <= case.input_tasks,
440 "case {} group {} exceeds input task count",
441 case.name,
442 index
443 );
444
445 for (offset, seen_task) in seen.iter_mut().skip(start).take(len).enumerate() {
446 let task = start + offset;
447 assert!(
448 !*seen_task,
449 "case {} input task {} appears twice",
450 case.name, task
451 );
452 *seen_task = true;
453 }
454
455 expected_start = start + len;
456 padding_slots += max_group_size - len;
457 }
458
459 assert_eq!(
460 expected_start, case.input_tasks,
461 "case {} groups should cover all input tasks",
462 case.name
463 );
464 assert!(
465 seen.iter().all(|v| *v),
466 "case {} missing at least one input task",
467 case.name
468 );
469
470 let total_slots = case.consumer_tasks * max_group_size;
471 let total_padding = total_slots - case.input_tasks;
472 assert_eq!(
473 padding_slots, total_padding,
474 "case {} padding slots mismatch",
475 case.name
476 );
477
478 Ok(())
479 }
480
481 const ONE_TO_MANY_INPUT: usize = 1;
482 const ONE_TO_MANY_OUTPUT: usize = 3;
483 const MANY_TO_ONE_INPUT: usize = 4;
484 const MANY_TO_ONE_OUTPUT: usize = 1;
485 const MANY_TO_FEWER_INPUT: usize = 5;
486 const MANY_TO_FEWER_OUTPUT: usize = 2;
487 const FEWER_TO_MANY_INPUT: usize = 2;
488 const FEWER_TO_MANY_OUTPUT: usize = 5;
489
490 #[test]
491 fn validates_partition_coverage_one_to_many() -> Result<()> {
492 assert_case(Case {
493 name: "1_to_n",
494 input_tasks: ONE_TO_MANY_INPUT,
495 consumer_tasks: ONE_TO_MANY_OUTPUT,
496 })
497 }
498
499 #[test]
500 fn validates_partition_coverage_many_to_one() -> Result<()> {
501 assert_case(Case {
502 name: "n_to_1",
503 input_tasks: MANY_TO_ONE_INPUT,
504 consumer_tasks: MANY_TO_ONE_OUTPUT,
505 })
506 }
507
508 #[test]
509 fn validates_partition_coverage_many_to_fewer() -> Result<()> {
510 assert_case(Case {
511 name: "n_to_m_n_gt_m",
512 input_tasks: MANY_TO_FEWER_INPUT,
513 consumer_tasks: MANY_TO_FEWER_OUTPUT,
514 })
515 }
516
517 #[test]
518 fn validates_partition_coverage_fewer_to_many() -> Result<()> {
519 assert_case(Case {
520 name: "m_to_n_n_gt_m",
521 input_tasks: FEWER_TO_MANY_INPUT,
522 consumer_tasks: FEWER_TO_MANY_OUTPUT,
523 })
524 }
525}