Skip to main content

datafusion_distributed/
stage.rs

1use crate::coordinator::{DistributedExec, MetricsStore};
2use crate::execution_plans::{DistributedLeafExec, NetworkCoalesceExec};
3use crate::metrics::DISTRIBUTED_DATAFUSION_TASK_ID_LABEL;
4use datafusion::common::{HashMap, Statistics, config_err};
5use datafusion::common::{exec_err, plan_err};
6use datafusion::error::Result;
7use datafusion::execution::{SendableRecordBatchStream, TaskContext};
8use datafusion::physical_plan::display::DisplayableExecutionPlan;
9use datafusion::physical_plan::metrics::{Label, Metric, MetricsSet};
10use datafusion::physical_plan::{
11    ColumnStatistics, ExecutionPlan, ExecutionPlanProperties, displayable,
12};
13use itertools::Either;
14use std::collections::VecDeque;
15use std::sync::Arc;
16use url::Url;
17use uuid::Uuid;
18
19/// A unit of isolation for a portion of a physical execution plan
20/// that can be executed independently and across a network boundary.
21/// It implements [`ExecutionPlan`] and can be executed to produce a
22/// stream of record batches.
23///
24/// If a stage has input stages, then those input stages will be executed on remote resources
25/// and will be provided the remainder of the stage tree.
26///
27/// For example, if our stage tree looks like this:
28///
29/// ```text
30///                       ┌─────────┐
31///                       │ stage 1 │
32///                       └───┬─────┘
33///                           │
34///                    ┌──────┴────────┐
35///               ┌────┴────┐     ┌────┴────┐
36///               │ stage 2 │     │ stage 3 │
37///               └────┬────┘     └─────────┘
38///                    │
39///             ┌──────┴────────┐
40///        ┌────┴────┐     ┌────┴────┐
41///        │ stage 4 │     │ Stage 5 │
42///        └─────────┘     └─────────┘
43///
44/// ```
45///
46/// Then executing Stage 1 will run its plan locally. Stage 1 has two inputs, Stage 2 and Stage 3. We
47/// know these will execute on remote resources. As such, the plan for Stage 1 must contain a
48/// [`NetworkShuffleExec`] node that will read the results of Stage 2 and Stage 3 and coalesce the
49/// results.
50///
51/// When Stage 1's [`NetworkShuffleExec`] node is executed, it makes an ArrowFlightRequest to the
52/// host assigned in the Stage. It provides the following Stage tree serialized in the body of the
53/// Arrow Flight Ticket:
54///
55/// ```text
56///               ┌─────────┐
57///               │ Stage 2 │
58///               └────┬────┘
59///                    │
60///             ┌──────┴────────┐
61///        ┌────┴────┐     ┌────┴────┐
62///        │ Stage 4 │     │ Stage 5 │
63///        └─────────┘     └─────────┘
64///
65/// ```
66///
67/// The receiving Worker will then execute Stage 2 and will repeat this process.
68///
69/// When Stage 4 is executed, it has no input tasks, so it is assumed that the plan included in that
70/// Stage can complete on its own; it's likely holding a leaf node in the overall physical plan and
71/// producing data from a [`DataSourceExec`].
72#[derive(Debug, Clone)]
73pub enum Stage {
74    Local(LocalStage),
75    Remote(RemoteStage),
76}
77
78#[derive(Debug, Clone)]
79pub struct LocalStage {
80    /// Our query_id
81    pub query_id: Uuid,
82    /// Our stage number
83    pub num: usize,
84    /// The physical execution plan that this stage will execute. It will only be present if
85    /// accessing to it through the coordinating stage.
86    pub plan: Arc<dyn ExecutionPlan>,
87    /// The number of tasks the stage has.
88    pub tasks: usize,
89    /// Metrics collected by the coordinator
90    pub metrics_set: MetricsSet,
91}
92
93impl LocalStage {
94    pub fn execute(
95        &self,
96        partition: usize,
97        context: Arc<TaskContext>,
98    ) -> Result<SendableRecordBatchStream> {
99        if self.tasks > 1 {
100            return exec_err!("Cannot execute a local stage with more than 1 task");
101        }
102        self.plan.execute(partition, context)
103    }
104}
105
106#[derive(Debug, Clone)]
107pub struct RemoteStage {
108    /// Our query_id
109    pub query_id: Uuid,
110    /// Our stage number
111    pub num: usize,
112    /// The worker URLs to which queries should be issued.
113    pub workers: Vec<Url>,
114    /// Statistics collected at runtime, if any.
115    pub runtime_stats: Option<Arc<Statistics>>,
116}
117
118impl Stage {
119    pub fn query_id(&self) -> Uuid {
120        match &self {
121            Self::Local(v) => v.query_id,
122            Self::Remote(v) => v.query_id,
123        }
124    }
125
126    pub fn num(&self) -> usize {
127        match &self {
128            Self::Local(v) => v.num,
129            Self::Remote(v) => v.num,
130        }
131    }
132
133    pub fn task_count(&self) -> usize {
134        match &self {
135            Self::Local(v) => v.tasks,
136            Self::Remote(v) => v.workers.len(),
137        }
138    }
139
140    pub fn local_plan(&self) -> Option<&Arc<dyn ExecutionPlan>> {
141        match &self {
142            Self::Local(v) => Some(&v.plan),
143            Self::Remote(_) => None,
144        }
145    }
146
147    pub fn metrics(&self) -> MetricsSet {
148        match &self {
149            Self::Local(v) => v.metrics_set.clone(),
150            Self::Remote(_) => MetricsSet::new(),
151        }
152    }
153
154    pub fn partition_statistics(
155        &self,
156        partition: Option<usize>,
157        partition_count: usize,
158        schema: SchemaRef,
159    ) -> Result<Arc<Statistics>> {
160        match self {
161            Stage::Local(local) => local.plan.partition_statistics(partition),
162            Stage::Remote(remote) => {
163                let Some(runtime_stats) = &remote.runtime_stats else {
164                    return Ok(Arc::new(Statistics::new_unknown(&schema)));
165                };
166                match partition {
167                    None => Ok(Arc::clone(runtime_stats)),
168                    Some(_) => Ok(Arc::new(multiply_stats(
169                        runtime_stats,
170                        1.0 / partition_count as f32,
171                    ))),
172                }
173            }
174        }
175    }
176}
177
178fn multiply_stats(stats: &Statistics, f: f32) -> Statistics {
179    Statistics {
180        num_rows: multiply_precision(stats.num_rows, f),
181        total_byte_size: multiply_precision(stats.total_byte_size, f),
182        column_statistics: stats
183            .column_statistics
184            .iter()
185            .map(|col| ColumnStatistics {
186                null_count: multiply_precision(col.null_count, f),
187                max_value: Precision::Absent,
188                min_value: Precision::Absent,
189                sum_value: Precision::Absent,
190                distinct_count: multiply_precision(col.distinct_count, f),
191                byte_size: multiply_precision(col.byte_size, f),
192            })
193            .collect(),
194    }
195}
196
197fn multiply_precision(p: Precision<usize>, f: f32) -> Precision<usize> {
198    match p {
199        Precision::Exact(v) => Precision::Exact((v as f32 * f) as usize),
200        Precision::Inexact(v) => Precision::Inexact((v as f32 * f) as usize),
201        Precision::Absent => Precision::Absent,
202    }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct DistributedTaskContext {
207    pub task_index: usize,
208    pub task_count: usize,
209}
210
211impl DistributedTaskContext {
212    pub fn from_ctx(ctx: &Arc<TaskContext>) -> Arc<Self> {
213        ctx.session_config()
214            .get_extension::<Self>()
215            .unwrap_or(Arc::new(DistributedTaskContext {
216                task_index: 0,
217                task_count: 1,
218            }))
219    }
220}
221
222use crate::{
223    DistributedMetricsFormat, NetworkShuffleExec, TaskKey, rewrite_distributed_plan_with_metrics,
224};
225use crate::{NetworkBoundary, NetworkBoundaryExt};
226use datafusion::arrow::datatypes::SchemaRef;
227use datafusion::common::DataFusionError;
228use datafusion::common::stats::Precision;
229use datafusion::physical_expr::Partitioning;
230/// Be able to display a nice tree for stages.
231///
232/// The challenge to doing this at the moment is that `TreeRenderVisitor`
233/// in [`datafusion::physical_plan::display`] is not public, and that it also
234/// is specific to an `ExecutionPlan` trait object, which we don't have.
235///
236/// TODO: try to upstream a change to make rendering of Trees (logical, physical, stages) against
237/// a generic trait rather than a specific trait object. This would allow us to
238/// use the same rendering code for all trees, including stages.
239///
240/// In the meantime, we can make a dummy ExecutionPlan that will let us render
241/// the Stage tree.
242use std::fmt::Write;
243
244/// explain_analyze renders an [ExecutionPlan] with metrics.
245pub async fn explain_analyze(
246    executed: Arc<dyn ExecutionPlan>,
247    format: DistributedMetricsFormat,
248) -> Result<String, DataFusionError> {
249    match executed.downcast_ref::<DistributedExec>() {
250        None => Ok(DisplayableExecutionPlan::with_metrics(executed.as_ref())
251            .indent(true)
252            .to_string()),
253        Some(_) => {
254            let executed = rewrite_distributed_plan_with_metrics(executed.clone(), format).await?;
255            Ok(display_plan_ascii(executed.as_ref(), true))
256        }
257    }
258}
259
260// Unicode box-drawing characters for creating borders and connections.
261const LTCORNER: &str = "┌"; // Left top corner
262const LDCORNER: &str = "└"; // Left bottom corner
263const VERTICAL: &str = "│"; // Vertical line
264const HORIZONTAL: &str = "─"; // Horizontal line
265pub fn display_plan_ascii(plan: &dyn ExecutionPlan, show_metrics: bool) -> String {
266    if let Some(plan) = plan.downcast_ref::<DistributedExec>() {
267        let mut f = String::new();
268        display_ascii(plan, Either::Left(plan), 0, show_metrics, &mut f).unwrap();
269        f
270    } else {
271        match show_metrics {
272            true => DisplayableExecutionPlan::with_metrics(plan)
273                .indent(true)
274                .to_string(),
275            false => displayable(plan).indent(true).to_string(),
276        }
277    }
278}
279
280fn display_ascii(
281    root: &DistributedExec,
282    stage: Either<&DistributedExec, &Stage>,
283    depth: usize,
284    show_metrics: bool,
285    f: &mut String,
286) -> std::fmt::Result {
287    let plan = match stage {
288        Either::Left(distributed_exec) => distributed_exec.children().first().unwrap(),
289        Either::Right(stage) => {
290            let Some(plan) = stage.local_plan() else {
291                return write!(f, "StageExec: encoded input plan");
292            };
293            plan
294        }
295    };
296    match stage {
297        Either::Left(dist_exec) => {
298            // DistributedExec is the coordinator's single-task head, so its task/partition counts
299            // are always 1; omit them and show only its (coordinator-side) metrics, if any.
300            write!(
301                f,
302                "{}{}{} DistributedExec",
303                "  ".repeat(depth),
304                LTCORNER,
305                HORIZONTAL.repeat(5),
306            )?;
307            if show_metrics && let Some(metrics) = dist_exec.metrics() {
308                writeln!(
309                    f,
310                    " {} {}",
311                    HORIZONTAL.repeat(2),
312                    format_metrics_by_task(&metrics)
313                )?;
314            } else {
315                writeln!(f)?;
316            }
317        }
318        Either::Right(stage) => {
319            write!(
320                f,
321                "{}{}{} Stage {} {} {}",
322                "  ".repeat(depth),
323                LTCORNER,
324                HORIZONTAL.repeat(5),
325                stage.num(),
326                HORIZONTAL.repeat(2),
327                format_tasks_for_stage(stage.task_count(), plan)
328            )?;
329            if show_metrics && let Some(metrics_store) = &root.metrics_store {
330                let metrics = gather_stage_header_metrics(stage, metrics_store);
331                write!(f, " ")?;
332                writeln!(f, "{}", format_metrics_by_task(&metrics))?;
333            } else {
334                writeln!(f)?;
335            }
336        }
337    }
338
339    let mut plan_str = String::new();
340    display_inner_ascii(plan, 0, show_metrics, &mut plan_str)?;
341    let plan_str = plan_str
342        .split('\n')
343        .filter(|v| !v.is_empty())
344        .collect::<Vec<_>>()
345        .join(&format!("\n{}{}", "  ".repeat(depth), VERTICAL));
346    writeln!(f, "{}{}{}", "  ".repeat(depth), VERTICAL, plan_str)?;
347    writeln!(
348        f,
349        "{}{}{}",
350        "  ".repeat(depth),
351        LDCORNER,
352        HORIZONTAL.repeat(50)
353    )?;
354    for input_stage in find_input_stages(plan.as_ref()) {
355        display_ascii(root, Either::Right(input_stage), depth + 1, show_metrics, f)?;
356    }
357    Ok(())
358}
359
360fn display_inner_ascii(
361    plan: &Arc<dyn ExecutionPlan>,
362    indent: usize,
363    show_metrics: bool,
364    f: &mut String,
365) -> std::fmt::Result {
366    if plan.is::<DistributedLeafExec>() {
367        return display_inner_distributed_leaf(plan, indent, show_metrics, f);
368    }
369
370    let node_str = displayable(plan.as_ref()).one_line().to_string();
371    let metrics_str = match show_metrics {
372        true => metrics_suffix(plan.metrics().map(|m| format_metrics_by_task(&m))),
373        false => String::new(),
374    };
375    writeln!(
376        f,
377        "{} {}{metrics_str}",
378        " ".repeat(indent),
379        node_str.trim_end() // remove trailing newline
380    )?;
381
382    if plan.is_network_boundary() {
383        return Ok(());
384    }
385
386    for child in plan.children() {
387        display_inner_ascii(child, indent + 2, show_metrics, f)?;
388    }
389    Ok(())
390}
391
392fn display_inner_distributed_leaf(
393    plan: &Arc<dyn ExecutionPlan>,
394    indent: usize,
395    show_metrics: bool,
396    f: &mut String,
397) -> std::fmt::Result {
398    let Some(leaf) = plan.downcast_ref::<DistributedLeafExec>() else {
399        return Ok(());
400    };
401    let indent = " ".repeat(indent);
402
403    // The leaf node is wrapped in a `MetricsWrapperExec` by the metrics rewriter, so the
404    // per-task metrics live on `plan.metrics()` (the wrapper), not on `leaf.metrics()` (which
405    // delegates to the un-rewritten original). Split them by task id to show each variant's
406    // own metrics.
407    if let Some(by_task) = show_metrics
408        .then(|| plan.metrics())
409        .flatten()
410        .map(|m| metrics_by_task_id(&m))
411        && !by_task.is_empty()
412    {
413        writeln!(f, "{indent} DistributedLeafExec:")?;
414        for (task_i, variant) in leaf.variants.iter().enumerate() {
415            let variant = displayable(variant.as_ref()).one_line().to_string();
416            let metrics = match by_task.is_empty() {
417                true => String::new(),
418                false => metrics_suffix(by_task.get(&task_i).map(format_metrics_by_task)),
419            };
420            writeln!(f, "{indent}   t{task_i}: {}{metrics}", variant.trim_end())?;
421        }
422    } else {
423        let header = match show_metrics {
424            true => metrics_suffix(plan.metrics().map(|m| format_metrics_by_task(&m))),
425            false => String::new(),
426        };
427        writeln!(f, "{indent} DistributedLeafExec:{header}")?;
428        for (task_i, variant) in leaf.variants.iter().enumerate() {
429            let variant = displayable(variant.as_ref()).one_line().to_string();
430            writeln!(f, "{indent}   t{task_i}: {}", variant.trim_end())?;
431        }
432    }
433    Ok(())
434}
435
436/// Gathers the metrics global to a stage. These metrics are not specific to any plan node, and
437/// are instead global to a whole stage.
438fn gather_stage_header_metrics(stage: &Stage, metrics_store: &MetricsStore) -> MetricsSet {
439    let mut task_key = TaskKey {
440        query_id: stage.query_id(),
441        stage_id: stage.num(),
442        task_number: 0,
443    };
444    let mut all_metrics = stage.metrics();
445    while let Some(metrics_set) = metrics_store.get(&task_key).map(|v| v.task_metrics) {
446        for metric in metrics_set.iter() {
447            let mut labels = metric.labels().to_vec();
448            labels.push(Label::new(
449                DISTRIBUTED_DATAFUSION_TASK_ID_LABEL,
450                task_key.task_number.to_string(),
451            ));
452            all_metrics.push(Arc::new(Metric::new_with_labels(
453                metric.value().clone(),
454                metric.partition(),
455                labels,
456            )));
457        }
458        task_key.task_number += 1;
459    }
460    all_metrics
461}
462
463/// Aggregates metrics by (name, task_id), preserving the [DISTRIBUTED_DATAFUSION_TASK_ID_LABEL]
464/// only. Metrics without a task_id label (ie. non distributed metrics) are aggregated together.
465///
466/// For a non-distributed plan, this is equivalent to [MetricsSet::aggregate_by_name] since there
467/// will be no task ids. For a distributed plan, it's expected that the metrics rewriter populated
468/// task id labels in all metrics.
469fn aggregate_by_task_id(metrics: &MetricsSet) -> MetricsSet {
470    // Key: (metric_name, Option<task_id>)
471    let mut map: HashMap<(String, Option<String>), Metric> = HashMap::new();
472
473    for metric in metrics.iter() {
474        let name = metric.value().name().to_string();
475        let task_id = metric
476            .labels()
477            .iter()
478            .find(|l| l.name() == DISTRIBUTED_DATAFUSION_TASK_ID_LABEL)
479            .map(|l| l.value().to_string());
480
481        let key = (name, task_id.clone());
482
483        map.entry(key)
484            .and_modify(|accum| {
485                accum.value_mut().aggregate(metric.value());
486            })
487            .or_insert_with(|| {
488                let labels = task_id
489                    .map(|id| vec![Label::new(DISTRIBUTED_DATAFUSION_TASK_ID_LABEL, id)])
490                    .unwrap_or_default();
491                let mut accum = Metric::new_with_labels(
492                    metric.value().new_empty(),
493                    None, // no partition
494                    labels,
495                );
496                accum.value_mut().aggregate(metric.value());
497                accum
498            });
499    }
500
501    let mut result = MetricsSet::new();
502    for (_, metric) in map {
503        result.push(Arc::new(metric));
504    }
505    result
506}
507
508/// Sorts metrics by display priority, then name, then by task_id (numerically).
509///
510/// For a non-distributed plan, this is equivalent to [MetricsSet::sorted_for_display] since there
511/// will be no task ids. For a distributed plan, it's expected that the metrics rewriter populated
512/// task id labels in all metrics.
513fn sorted_for_display_by_task_id(metrics: MetricsSet) -> MetricsSet {
514    let mut vec: Vec<Arc<Metric>> = metrics.iter().cloned().collect();
515    vec.sort_unstable_by_key(|metric| {
516        let task_id = metric
517            .labels()
518            .iter()
519            .find(|l| l.name() == DISTRIBUTED_DATAFUSION_TASK_ID_LABEL)
520            .and_then(|l| l.value().parse::<u64>().ok());
521        (
522            metric.value().display_sort_key(),
523            metric.value().name().to_owned(),
524            task_id,
525        )
526    });
527    let mut result = MetricsSet::new();
528    for m in vec {
529        result.push(m);
530    }
531    result
532}
533
534/// Formats metrics grouped by name, collapsing each metric's per-task values into a
535/// `{task_id:value, ...}` map so a single line can carry every task without repeating the name.
536/// e.g., "output_rows={0:100, 1:150}, elapsed_compute={0:50ns, 1:100ns}"
537///
538/// For a non-distributed plan the metrics carry no task id and keep the plain `name=value` form,
539/// equivalent to using [ShowMetrics::Aggregated] / [DisplayableExecutionPlan::with_metrics] which
540/// aggregates, sorts, removes timestamps, and finally formats the metrics.
541///
542/// See
543/// https://github.com/apache/datafusion/blob/b463a9f9e3c9603eb2db7113125fea3a1b7f5455/datafusion/physical-plan/src/display.rs#L421.
544fn format_metrics_by_task(metrics: &MetricsSet) -> String {
545    let aggregated = aggregate_by_task_id(metrics);
546    let sorted = sorted_for_display_by_task_id(aggregated).timestamps_removed();
547
548    // Metrics are sorted by (name, task_id), so entries sharing a name are contiguous. Fold each
549    // name into a single group, then render task-labeled values as a `{task_id:value, ...}` map and
550    // task-less values (non-distributed plans) as a bare `value`.
551    let mut groups: Vec<(String, bool, Vec<String>)> = Vec::new();
552    for m in sorted.iter() {
553        let name = m.value().name().to_string();
554        let task_id = m
555            .labels()
556            .iter()
557            .find(|l| l.name() == DISTRIBUTED_DATAFUSION_TASK_ID_LABEL)
558            .map(|l| l.value());
559        let entry = match task_id {
560            Some(id) => format!("{id}:{}", m.value()),
561            None => m.value().to_string(),
562        };
563        match groups.last_mut() {
564            Some((n, _, entries)) if *n == name => entries.push(entry),
565            _ => groups.push((name, task_id.is_some(), vec![entry])),
566        }
567    }
568
569    groups
570        .into_iter()
571        .map(|(name, has_task_id, entries)| match has_task_id {
572            true => format!("{name}={{{}}}", entries.join(", ")),
573            false => format!("{name}={}", entries.join(", ")),
574        })
575        .collect::<Vec<_>>()
576        .join(", ")
577}
578
579/// Wraps a formatted metrics string into the `, metrics=[...]` suffix used in plan displays.
580/// A missing or empty value renders as `, metrics=[]`.
581fn metrics_suffix(formatted: Option<String>) -> String {
582    match formatted.unwrap_or_default() {
583        s if s.is_empty() => ", metrics=[]".to_string(),
584        s => format!(", metrics=[{s}]"),
585    }
586}
587
588/// Splits a [MetricsSet] into a map from task index to the metrics belonging to that task.
589/// Only metrics that carry a [DISTRIBUTED_DATAFUSION_TASK_ID_LABEL] are included; metrics without
590/// that label are dropped. Returns an empty map when no task-labelled metrics are present.
591fn metrics_by_task_id(metrics: &MetricsSet) -> HashMap<usize, MetricsSet> {
592    let mut map: HashMap<usize, MetricsSet> = HashMap::new();
593    for metric in metrics.iter() {
594        let Some(task_id) = metric
595            .labels()
596            .iter()
597            .find(|l| l.name() == DISTRIBUTED_DATAFUSION_TASK_ID_LABEL)
598            .and_then(|l| l.value().parse::<usize>().ok())
599        else {
600            continue;
601        };
602        map.entry(task_id).or_default().push(Arc::clone(metric));
603    }
604    map
605}
606
607fn format_tasks_for_stage(n_tasks: usize, head: &Arc<dyn ExecutionPlan>) -> String {
608    let partitioning = head.properties().output_partitioning();
609    let input_partitions = partitioning.partition_count();
610    let hash_shuffle = matches!(partitioning, Partitioning::Hash(_, _));
611    // In a hash shuffle every task reads the same partition range, so the stage spans
612    // `input_partitions` distinct partitions. Otherwise each task owns its own slice, for a total
613    // of `n_tasks * input_partitions`.
614    let partitions = match hash_shuffle {
615        true => input_partitions,
616        false => n_tasks * input_partitions,
617    };
618    format!("tasks={n_tasks}, partitions={partitions}")
619}
620
621// num_colors must agree with the colorscheme selected from
622// https://graphviz.org/doc/info/colors.html
623const NUM_COLORS: usize = 6;
624const COLOR_SCHEME: &str = "spectral6";
625
626/// This will render a regular or distributed datafusion plan as
627/// Graphviz dot format.
628/// You can view them on https://vis-js.com
629///
630/// Or it is often useful to experiment with plan output using
631/// https://datafusion-fiddle.vercel.app/
632pub fn display_plan_graphviz(plan: Arc<dyn ExecutionPlan>) -> Result<String> {
633    let mut f = String::new();
634
635    writeln!(
636        f,
637        "digraph G {{
638  rankdir=BT
639  edge[colorscheme={COLOR_SCHEME}, penwidth=2.0]
640  splines=false
641"
642    )?;
643
644    if plan.is::<DistributedExec>() {
645        let mut max_num = 0;
646        let mut all_stages = find_all_stages(&plan)
647            .into_iter()
648            .inspect(|v| max_num = max_num.max(v.num()))
649            .collect::<Vec<_>>();
650        let head_stage = Stage::Local(LocalStage {
651            query_id: Default::default(),
652            num: max_num + 1,
653            plan: plan.clone(),
654            tasks: 1,
655            metrics_set: MetricsSet::new(),
656        });
657        all_stages.insert(0, &head_stage);
658
659        // draw all tasks first
660        for stage in &all_stages {
661            for i in 0..stage.task_count() {
662                let p = display_single_task(stage, i)?;
663                writeln!(f, "{p}")?;
664            }
665        }
666        // now draw edges between the tasks
667        for stage in &all_stages {
668            let Some(plan) = stage.local_plan() else {
669                continue;
670            };
671            for input_stage in find_input_stages(plan.as_ref()) {
672                for task_i in 0..stage.task_count() {
673                    for input_task_i in 0..input_stage.task_count() {
674                        let edges =
675                            display_inter_task_edges(stage, task_i, input_stage, input_task_i)?;
676                        writeln!(
677                            f,
678                            "// edges from child stage {} task {} to stage {} task {}\n {}",
679                            input_stage.num(),
680                            input_task_i,
681                            stage.num(),
682                            task_i,
683                            edges
684                        )?;
685                    }
686                }
687            }
688        }
689    } else {
690        // single plan, not a stage tree
691        writeln!(f, "node[shape=none]")?;
692        let p = display_plan(&plan, 0, 1, 0)?;
693        writeln!(f, "{p}")?;
694    }
695
696    writeln!(f, "}}")?;
697
698    Ok(f)
699}
700
701fn display_single_task(stage: &Stage, task_i: usize) -> Result<String> {
702    let Some(plan) = stage.local_plan() else {
703        return config_err!("plan not present");
704    };
705    let partition_group =
706        build_partition_group(task_i, plan.output_partitioning().partition_count());
707
708    let mut f = String::new();
709    writeln!(
710        f,
711        "
712  subgraph \"cluster_stage_{}_task_{}_margin\" {{
713    style=invis
714    margin=20.0
715  subgraph \"cluster_stage_{}_task_{}\" {{
716    color=blue
717    style=dotted
718    label = \"Stage {} Task {} Partitions {}\"
719    labeljust=r
720    labelloc=b
721
722    node[shape=none]
723
724",
725        stage.num(),
726        task_i,
727        stage.num(),
728        task_i,
729        stage.num(),
730        task_i,
731        format_pg(&partition_group)
732    )?;
733
734    writeln!(
735        f,
736        "{}",
737        display_plan(plan, task_i, stage.task_count(), stage.num())?
738    )?;
739    writeln!(f, "  }}")?;
740    writeln!(f, "  }}")?;
741
742    Ok(f)
743}
744
745fn display_plan(
746    plan: &Arc<dyn ExecutionPlan>,
747    task_i: usize,
748    _n_tasks: usize,
749    stage_num: usize,
750) -> Result<String> {
751    // draw all plans
752    // we need to label the nodes including depth to uniquely identify them within this task
753    // the tree node API provides depth first traversal, but we need breadth to align with
754    // how we will draw edges below, so we'll do that.
755    let mut queue = VecDeque::from([plan]);
756    let mut node_index = 0;
757
758    let mut f = String::new();
759    while let Some(plan) = queue.pop_front() {
760        node_index += 1;
761        let p = display_single_plan(plan.as_ref(), stage_num, task_i, node_index)?;
762        writeln!(f, "{p}")?;
763
764        if plan.is_network_boundary() {
765            continue;
766        }
767        for child in plan.children().iter() {
768            queue.push_back(child);
769        }
770    }
771
772    // draw edges between the plan nodes
773    type PlanWithParent<'a> = (
774        &'a Arc<dyn ExecutionPlan>,
775        Option<&'a Arc<dyn ExecutionPlan>>,
776        usize,
777    );
778    let mut queue: VecDeque<PlanWithParent> = VecDeque::from([(plan, None, 0usize)]);
779    node_index = 0;
780    while let Some((plan, maybe_parent, parent_idx)) = queue.pop_front() {
781        node_index += 1;
782        if let Some(parent) = maybe_parent {
783            let output_partitions = plan.output_partitioning().partition_count();
784
785            for i in 0..output_partitions {
786                let style = "";
787
788                writeln!(
789                    f,
790                    "  {}_{}_{}_{}:t{}:n -> {}_{}_{}_{}:b{}:s {}[color={}]",
791                    plan.name(),
792                    stage_num,
793                    task_i,
794                    node_index,
795                    i,
796                    parent.name(),
797                    stage_num,
798                    task_i,
799                    parent_idx,
800                    i,
801                    style,
802                    i % NUM_COLORS + 1
803                )?;
804            }
805        }
806
807        if plan.as_ref().is_network_boundary() {
808            continue;
809        }
810
811        for child in plan.children() {
812            queue.push_back((child, Some(plan), node_index));
813        }
814    }
815    Ok(f)
816}
817
818/// We want to display a single plan as a three row table with the top and bottom being
819/// graphvis ports.
820///
821/// We accept an index to make the node name unique in the graphviz output within
822/// a plan at the same depth
823///
824/// An example of such a node would be:
825///
826/// ```text
827///       NetworkShuffleExec [label=<
828///     <TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="0">
829///         <TR>
830///             <TD CELLBORDER="0">
831///                 <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
832///                     <TR>
833///                         <TD PORT="t1"></TD>
834///                         <TD PORT="t2"></TD>
835///                     </TR>
836///                 </TABLE>
837///             </TD>
838///         </TR>
839///         <TR>
840///             <TD BORDER="0" CELLPADDING="0" CELLSPACING="0">
841///                 <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
842///                     <TR>
843///                         <TD>NetworkShuffleExec</TD>
844///                     </TR>
845///                 </TABLE>
846///             </TD>
847///         </TR>
848///         <TR>
849///             <TD CELLBORDER="0">
850///                 <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0">
851///                     <TR>
852///                         <TD PORT="b1"></TD>
853///                         <TD PORT="b2"></TD>
854///                     </TR>
855///                 </TABLE>
856///             </TD>
857///         </TR>
858///     </TABLE>
859/// >];
860/// ```
861pub fn display_single_plan(
862    plan: &(dyn ExecutionPlan + 'static),
863    stage_num: usize,
864    task_i: usize,
865    node_index: usize,
866) -> Result<String> {
867    let mut f = String::new();
868    let output_partitions = plan.output_partitioning().partition_count();
869    let input_partitions = if plan.is_network_boundary() {
870        output_partitions
871    } else if let Some(child) = plan.children().first() {
872        child.output_partitioning().partition_count()
873    } else {
874        1
875    };
876
877    writeln!(
878        f,
879        "
880    {}_{}_{}_{} [label=<
881    <TABLE BORDER='0' CELLBORDER='0' CELLSPACING='0' CELLPADDING='0'>
882        <TR>
883            <TD CELLBORDER='0'>
884                <TABLE BORDER='0' CELLBORDER='1' CELLSPACING='0'>
885                    <TR>",
886        plan.name(),
887        stage_num,
888        task_i,
889        node_index
890    )?;
891
892    for i in 0..output_partitions {
893        writeln!(f, "                        <TD PORT='t{i}'></TD>")?;
894    }
895
896    writeln!(
897        f,
898        "                   </TR>
899                </TABLE>
900            </TD>
901        </TR>
902        <TR>
903            <TD BORDER='0' CELLPADDING='0' CELLSPACING='0'>
904                <TABLE BORDER='0' CELLBORDER='1' CELLSPACING='0'>
905                    <TR>
906                        <TD>{}</TD>
907                    </TR>
908                </TABLE>
909            </TD>
910        </TR>
911        <TR>
912            <TD CELLBORDER='0'>
913                <TABLE BORDER='0' CELLBORDER='1' CELLSPACING='0'>
914                    <TR>",
915        plan.name()
916    )?;
917
918    for i in 0..input_partitions {
919        writeln!(f, "                        <TD PORT='b{i}'></TD>")?;
920    }
921
922    writeln!(
923        f,
924        "                   </TR>
925                </TABLE>
926            </TD>
927        </TR>
928    </TABLE>
929  >];
930"
931    )?;
932    Ok(f)
933}
934
935fn display_inter_task_edges(
936    stage: &Stage,
937    task_i: usize,
938    input_stage: &Stage,
939    input_task_i: usize,
940) -> Result<String> {
941    let Some(plan) = stage.local_plan() else {
942        return plan_err!("The inner plan of a stage was encoded.");
943    };
944    let Some(input_plan) = input_stage.local_plan() else {
945        return plan_err!("The inner plan of a stage was encoded.");
946    };
947    let mut f = String::new();
948
949    let mut queue = VecDeque::from([plan]);
950    let mut index = 0;
951    while let Some(plan) = queue.pop_front() {
952        index += 1;
953        if let Some(node) = plan.downcast_ref::<NetworkShuffleExec>() {
954            if node.input_stage().num() != input_stage.num() {
955                continue;
956            }
957            // draw the edges to this node pulling data up from its child
958            let output_partitions = plan.output_partitioning().partition_count();
959            for p in 0..output_partitions {
960                writeln!(
961                    f,
962                    "  {}_{}_{}_{}:t{}:n -> {}_{}_{}_{}:b{}:s [color={}]",
963                    input_plan.name(),
964                    input_stage.num(),
965                    input_task_i,
966                    1, // the repartition exec is always the first node in the plan
967                    p + (task_i * output_partitions),
968                    plan.name(),
969                    stage.num(),
970                    task_i,
971                    index,
972                    p,
973                    p % NUM_COLORS + 1
974                )?;
975            }
976            continue;
977        } else if let Some(node) = plan.downcast_ref::<NetworkCoalesceExec>() {
978            if node.input_stage().num() != input_stage.num() {
979                continue;
980            }
981            // draw the edges to this node pulling data up from its child
982            let output_partitions = plan.output_partitioning().partition_count();
983            let input_partitions_per_task = output_partitions / input_stage.task_count();
984            for p in 0..input_partitions_per_task {
985                writeln!(
986                    f,
987                    "  {}_{}_{}_{}:t{}:n -> {}_{}_{}_{}:b{}:s [color={}]",
988                    input_plan.name(),
989                    input_stage.num(),
990                    input_task_i,
991                    1, // the repartition exec is always the first node in the plan
992                    p,
993                    plan.name(),
994                    stage.num(),
995                    task_i,
996                    index,
997                    p + (input_task_i * input_partitions_per_task),
998                    p % NUM_COLORS + 1
999                )?;
1000            }
1001            continue;
1002        }
1003
1004        for child in plan.children() {
1005            queue.push_back(child);
1006        }
1007    }
1008
1009    Ok(f)
1010}
1011
1012fn format_pg(partition_group: &[usize]) -> String {
1013    partition_group
1014        .iter()
1015        .map(|pg| format!("{pg}"))
1016        .collect::<Vec<_>>()
1017        .join("_")
1018}
1019
1020fn build_partition_group(task_i: usize, partitions: usize) -> Vec<usize> {
1021    ((task_i * partitions)..((task_i + 1) * partitions)).collect::<Vec<_>>()
1022}
1023
1024fn find_input_stages(plan: &dyn ExecutionPlan) -> Vec<&Stage> {
1025    let mut result = vec![];
1026    for child in plan.children() {
1027        if let Some(plan) = child.as_network_boundary() {
1028            result.push(plan.input_stage());
1029        } else {
1030            result.extend(find_input_stages(child.as_ref()));
1031        }
1032    }
1033    result
1034}
1035
1036pub(crate) fn find_all_stages(plan: &Arc<dyn ExecutionPlan>) -> Vec<&Stage> {
1037    let mut result = vec![];
1038    if let Some(plan) = plan.as_network_boundary() {
1039        result.push(plan.input_stage());
1040    }
1041    for child in plan.children() {
1042        result.extend(find_all_stages(child));
1043    }
1044    result
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use crate::test_utils::mock_exec::MockExec;
1051    use datafusion::arrow::datatypes::{DataType, Field, Schema};
1052    use datafusion::physical_expr::expressions::Column;
1053    use datafusion::physical_expr::{Partitioning, PhysicalExpr};
1054    use datafusion::physical_plan::metrics::{Count, MetricValue};
1055    use datafusion::physical_plan::repartition::RepartitionExec;
1056
1057    /// Builds an `output_rows` metric holding `rows`, optionally tagged with a task id.
1058    fn output_rows(rows: usize, task_id: Option<u64>) -> Arc<Metric> {
1059        let count = Count::new();
1060        count.add(rows);
1061        let labels = task_id
1062            .map(|id| {
1063                vec![Label::new(
1064                    DISTRIBUTED_DATAFUSION_TASK_ID_LABEL,
1065                    id.to_string(),
1066                )]
1067            })
1068            .unwrap_or_default();
1069        Arc::new(Metric::new_with_labels(
1070            MetricValue::OutputRows(count),
1071            None,
1072            labels,
1073        ))
1074    }
1075
1076    fn metrics_set(metrics: impl IntoIterator<Item = Arc<Metric>>) -> MetricsSet {
1077        let mut set = MetricsSet::new();
1078        for m in metrics {
1079            set.push(m);
1080        }
1081        set
1082    }
1083
1084    #[test]
1085    fn format_metrics_by_task_collapses_per_task_values_into_a_map() {
1086        let set = metrics_set([
1087            output_rows(100, Some(0)),
1088            output_rows(150, Some(1)),
1089            output_rows(200, Some(2)),
1090        ]);
1091        assert_eq!(
1092            format_metrics_by_task(&set),
1093            "output_rows={0:100, 1:150, 2:200}"
1094        );
1095    }
1096
1097    #[test]
1098    fn format_metrics_by_task_keeps_task_ids_explicit_when_non_contiguous() {
1099        // A node that only ran on a subset of tasks (e.g. under a ChildrenIsolatorUnionExec)
1100        // reports a non-contiguous set of ids. The map keeps them explicit; a positional list
1101        // would misread task 2's value as task 1's.
1102        let set = metrics_set([output_rows(100, Some(0)), output_rows(200, Some(2))]);
1103        assert_eq!(format_metrics_by_task(&set), "output_rows={0:100, 2:200}");
1104    }
1105
1106    #[test]
1107    fn format_metrics_by_task_without_task_ids_stays_scalar() {
1108        let set = metrics_set([output_rows(100, None)]);
1109        assert_eq!(format_metrics_by_task(&set), "output_rows=100");
1110    }
1111
1112    fn single_column_schema() -> Arc<Schema> {
1113        Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]))
1114    }
1115
1116    #[test]
1117    fn format_tasks_for_stage_non_hash_counts_every_task_slice() {
1118        // Non-hash: each task owns a distinct slice of 3 partitions, so 2 tasks span 6 partitions.
1119        let plan: Arc<dyn ExecutionPlan> = Arc::new(MockExec::new_partitioned(
1120            vec![vec![], vec![], vec![]],
1121            single_column_schema(),
1122        ));
1123        assert_eq!(format_tasks_for_stage(2, &plan), "tasks=2, partitions=6");
1124    }
1125
1126    #[test]
1127    fn format_tasks_for_stage_hash_shares_partitions_across_tasks() {
1128        // Hash shuffle: every task reads the same range, so the stage spans just those 8 partitions.
1129        let mock: Arc<dyn ExecutionPlan> = Arc::new(MockExec::new(vec![], single_column_schema()));
1130        let expr: Arc<dyn PhysicalExpr> = Arc::new(Column::new("id", 0));
1131        let hashed: Arc<dyn ExecutionPlan> =
1132            Arc::new(RepartitionExec::try_new(mock, Partitioning::Hash(vec![expr], 8)).unwrap());
1133        assert_eq!(format_tasks_for_stage(3, &hashed), "tasks=3, partitions=8");
1134    }
1135}