Skip to main content

linkrs_query/executor/explain/
physical_plan_explain.rs

1//! PhysicalPlan → PlanDescription conversion for EXPLAIN.
2//!
3//! Step 2.2: The EXPLAIN statement now uses the arena PhysicalPlan as its
4//! data source instead of the old ExecutionPlan / PlanNodeEnum tree.
5//! This function walks the PhysicalPlan's operator arena and fragment DAG
6//! to produce a PlanDescription compatible with the existing formatters.
7
8use std::collections::{HashMap, HashSet};
9
10use crate::executor::streaming::plan::types::{
11    FragmentGraph, FragmentId, PhysicalOperatorId, PhysicalPlan,
12};
13use crate::planning::plan::explain::{Pair, PlanDescription, PlanNodeDescription};
14
15/// Flat bypass columns (`{var}.{prop}`) carried by a rowless expand hop, in
16/// layout order (edge properties first, then destination properties).
17fn expand_bypass_display(
18    col_names: &[String],
19    edge_props: &Option<Vec<String>>,
20    dst_props: &Option<Vec<String>>,
21) -> Vec<String> {
22    let Some(edge_var) = col_names.get(1) else {
23        return Vec::new();
24    };
25    let Some(dst_var) = col_names.get(2) else {
26        return Vec::new();
27    };
28    let mut out = Vec::new();
29    if let Some(props) = edge_props {
30        let mut sorted = props.clone();
31        sorted.sort();
32        sorted.dedup();
33        out.extend(sorted.iter().map(|prop| format!("{edge_var}.{prop}")));
34    }
35    if let Some(props) = dst_props {
36        let mut sorted = props.clone();
37        sorted.sort();
38        sorted.dedup();
39        out.extend(sorted.iter().map(|prop| format!("{dst_var}.{prop}")));
40    }
41    out
42}
43
44/// Build a [`PlanDescription`] from an arena [`PhysicalPlan`].
45///
46/// Walks the fragment DAG in topological order (producers before consumers)
47/// and creates one [`PlanNodeDescription`] per physical operator.
48pub fn physical_plan_to_plan_description(plan: &PhysicalPlan) -> PlanDescription {
49    let mut desc = PlanDescription::new();
50    desc.requested_workers = 1;
51    desc.parallel_fallback_reason = plan.parallel_fallback_reason.clone();
52    desc.cbo_notes = plan.cbo_notes.clone();
53    desc.statistics_summary = plan.statistics_summary.clone();
54    if let Some(spec) = plan.partition_spec() {
55        let version = spec
56            .layout_version()
57            .map(|v| format!(", layout_version={v}"))
58            .unwrap_or_default();
59        desc.partition_spec_description = Some(match spec.strategy() {
60            crate::planning::plan::PartitionStrategy::Range => {
61                let ranges = spec
62                    .ranges()
63                    .iter()
64                    .map(|r| format!("[{}..{})", r.start, r.end))
65                    .collect::<Vec<_>>()
66                    .join(", ");
67                format!(
68                    "{} partitioned into {} ranges [{}]{}",
69                    spec.source(),
70                    spec.partition_count(),
71                    ranges,
72                    version
73                )
74            }
75            crate::planning::plan::PartitionStrategy::Hash { key } => {
76                let key_desc = if key.is_empty() {
77                    String::new()
78                } else {
79                    format!(" by key '{key}'")
80                };
81                format!(
82                    "{} hash-partitioned into {} buckets{}{}",
83                    spec.source(),
84                    spec.partition_count(),
85                    key_desc,
86                    version
87                )
88            }
89            crate::planning::plan::PartitionStrategy::RoundRobin => {
90                format!(
91                    "{} round-robin partitioned into {} buckets{}",
92                    spec.source(),
93                    spec.partition_count(),
94                    version
95                )
96            }
97        });
98    }
99
100    // Build a reverse dependency map: operator_id → list of operator ids that depend on it.
101    // Also track which fragment each operator belongs to.
102    let topo = topological_operator_order(&plan.fragments, plan);
103
104    let mut operator_to_fragment: HashMap<PhysicalOperatorId, FragmentId> = HashMap::new();
105    for frag in plan.fragments.fragments() {
106        for op_id in &frag.operators {
107            operator_to_fragment.insert(*op_id, frag.id);
108        }
109    }
110
111    // parent_of[b] = a means "a depends on b" → b is a child of a in the output DAG.
112    // We build this by following the fragment graph.
113    let mut parent_of: HashMap<PhysicalOperatorId, Vec<PhysicalOperatorId>> = HashMap::new();
114
115    // Within each fragment, operators form a pipeline (listed leaf→root).
116    for frag in plan.fragments.fragments() {
117        for (i, op_id) in frag.operators.iter().enumerate() {
118            if i > 0 {
119                // op[i] depends on op[i-1] within the same fragment
120                parent_of
121                    .entry(frag.operators[i - 1])
122                    .or_default()
123                    .push(*op_id);
124            }
125        }
126        // The first operator in a fragment depends on the root operators of input fragments.
127        if let Some(first_op) = frag.operators.first() {
128            for input_fid in &frag.inputs {
129                if let Some(input_frag) = plan.fragments.get(*input_fid) {
130                    parent_of
131                        .entry(input_frag.root_operator)
132                        .or_default()
133                        .push(*first_op);
134                }
135            }
136        }
137    }
138
139    // Collect all operator IDs in topological order (producers first).
140    let mut desc_ids: HashSet<PhysicalOperatorId> = HashSet::new();
141    let mut ordered_ops: Vec<PhysicalOperatorId> = Vec::new();
142    for &fid in &topo {
143        if let Some(frag) = plan.fragments.get(fid) {
144            for op_id in &frag.operators {
145                if desc_ids.insert(*op_id) {
146                    ordered_ops.push(*op_id);
147                }
148            }
149        }
150    }
151
152    for &op_id in &ordered_ops {
153        let op_spec = match plan.operator(op_id) {
154            Some(s) => s,
155            None => continue,
156        };
157
158        let mut pnd = PlanNodeDescription::new(op_spec.explain_name, op_id.0 as i64);
159
160        // Extract description from properties
161        let mut pairs = Vec::new();
162        if let Some(est) = op_spec.estimated_cardinality {
163            pairs.push(Pair::new("est_rows", est.to_string()));
164        }
165        if let Some(reason) = &op_spec.choice_reason {
166            pairs.push(Pair::new("reason", reason.clone()));
167        }
168        // Constant folding applied on the source logical node.
169        if op_spec.has_folded_expressions {
170            pairs.push(Pair::new("folded", "true"));
171        }
172
173        let props = &op_spec.properties;
174        if let crate::executor::streaming::plan::properties::Ordering::Sorted(orders) =
175            &props.ordering
176        {
177            let order_str = orders
178                .iter()
179                .map(|o| {
180                    format!(
181                        "slot#{} {}",
182                        o.slot,
183                        if o.ascending { "ASC" } else { "DESC" }
184                    )
185                })
186                .collect::<Vec<_>>()
187                .join(", ");
188            if !order_str.is_empty() {
189                pairs.push(Pair::new("ordering", order_str));
190            }
191        }
192
193        if let crate::executor::streaming::plan::properties::Distribution::HashPartitioned(keys) =
194            &props.distribution
195        {
196            if !keys.is_empty() {
197                pairs.push(Pair::new(
198                    "hash_slots",
199                    keys.iter()
200                        .map(|s| format!("slot#{}", s))
201                        .collect::<Vec<_>>()
202                        .join(", "),
203                ));
204            }
205        }
206
207        if props.pipeline_kind
208            == crate::executor::streaming::plan::properties::PipelineKind::Blocking
209        {
210            pairs.push(Pair::new("blocking", "true"));
211        }
212
213        // PatternApply subquery keys: hash_keys evaluate against the outer
214        // (left) layout, probe_keys against the subquery (right) layout.
215        if let crate::executor::streaming::plan::types::OperatorKindSpec::Apply(
216            crate::executor::streaming::operators::spec::ApplySpec::PatternApply {
217                hash_keys,
218                probe_keys,
219                anti,
220            },
221        ) = &op_spec.spec
222        {
223            if !hash_keys.is_empty() {
224                pairs.push(Pair::new(
225                    "hash_keys",
226                    hash_keys
227                        .iter()
228                        .map(linkrs_core::types::expr::Expression::to_expression_string)
229                        .collect::<Vec<_>>()
230                        .join(", "),
231                ));
232            }
233            if !probe_keys.is_empty() {
234                pairs.push(Pair::new(
235                    "probe_keys",
236                    probe_keys
237                        .iter()
238                        .map(linkrs_core::types::expr::Expression::to_expression_string)
239                        .collect::<Vec<_>>()
240                        .join(", "),
241                ));
242            }
243            if *anti {
244                pairs.push(Pair::new("anti", "true"));
245            }
246        }
247
248        // CorrelatedApply: the right subtree is re-executed per outer row.
249        if let crate::executor::streaming::plan::types::OperatorKindSpec::Apply(
250            crate::executor::streaming::operators::spec::ApplySpec::CorrelatedApply {
251                anti, ..
252            },
253        ) = &op_spec.spec
254        {
255            if *anti {
256                pairs.push(Pair::new("anti", "true"));
257            }
258        }
259
260        // SemiJoin (Mark-Join): surface the residual join condition and the
261        // anti (NOT EXISTS) semantics.
262        if let crate::executor::streaming::plan::types::OperatorKindSpec::Join(
263            crate::executor::streaming::operators::spec::JoinSpec::SemiJoin {
264                join_condition,
265                anti,
266            },
267        ) = &op_spec.spec
268        {
269            if let Some(condition) = join_condition {
270                pairs.push(Pair::new(
271                    "join_condition",
272                    condition.to_expression_string(),
273                ));
274            }
275            if *anti {
276                pairs.push(Pair::new("anti", "true"));
277            }
278        }
279
280        // Expression-level subqueries: Filter/Project/Assign hosts
281        // surface the number of compiled subqueries as `subquery: N`.
282        if let crate::executor::streaming::plan::types::OperatorKindSpec::Unary(unary_spec) =
283            &op_spec.spec
284        {
285            let count = unary_spec.subquery_runners().len();
286            if count > 0 {
287                pairs.push(Pair::new("subquery", count.to_string()));
288            }
289        }
290
291        if let crate::executor::streaming::plan::properties::MemoryPolicy::Spillable { threshold } =
292            &props.memory_policy
293        {
294            pairs.push(Pair::new("spill_threshold", format!("{}", threshold)));
295        }
296
297        // Annotated ExpandAll hops surface their de-materialization mode.
298        // The columnar marker reflects the planner closed-loop claim; the
299        // executor re-verifies it against storage schemas per chunk and falls
300        // back to the row path on mismatch.
301        if let crate::executor::streaming::plan::types::OperatorKindSpec::Graph(
302            crate::executor::streaming::operators::spec::GraphSpec::ExpandAll {
303                count_only,
304                emit_raw_ids,
305                closed_loop,
306                skip_rows,
307                col_names,
308                edge_required_props,
309                dst_required_props,
310                ..
311            },
312        ) = &op_spec.spec
313        {
314            if *count_only {
315                pairs.push(crate::planning::plan::explain::Pair::new(
316                    "mode",
317                    "count_only",
318                ));
319                // A count-only expand emits one partial-count row per input
320                // chunk: its estimate counts chunks, not edges.
321                pairs.push(crate::planning::plan::explain::Pair::new("unit", "chunks"));
322            } else if *emit_raw_ids {
323                pairs.push(crate::planning::plan::explain::Pair::new("mode", "id_only"));
324            } else if *closed_loop {
325                pairs.push(crate::planning::plan::explain::Pair::new(
326                    "mode", "columnar",
327                ));
328                if *skip_rows {
329                    pairs.push(crate::planning::plan::explain::Pair::new("rows", "skipped"));
330                    let bypass =
331                        expand_bypass_display(col_names, edge_required_props, dst_required_props);
332                    if !bypass.is_empty() {
333                        pairs.push(crate::planning::plan::explain::Pair::new(
334                            "bypass",
335                            bypass.join(","),
336                        ));
337                    }
338                }
339            }
340        }
341
342        // Try to extract output column names from SourceSpec
343        if let crate::executor::streaming::plan::types::OperatorKindSpec::Source(src_spec) =
344            &op_spec.spec
345        {
346            let col_names: Vec<String> = match src_spec {
347                crate::executor::streaming::operators::spec::SourceSpec::ScanVertices {
348                    col_names,
349                    ..
350                }
351                | crate::executor::streaming::operators::spec::SourceSpec::StandaloneValues {
352                    col_names,
353                    ..
354                } => col_names.clone(),
355                crate::executor::streaming::operators::spec::SourceSpec::StorageScanVertices {
356                    col_names,
357                    identity_only,
358                    skip_rows,
359                    ..
360                } => {
361                    if *identity_only {
362                        pairs.push(crate::planning::plan::explain::Pair::new(
363                            "mode", "identity",
364                        ));
365                    }
366                    if *skip_rows {
367                        pairs.push(crate::planning::plan::explain::Pair::new("rows", "skipped"));
368                    }
369                    col_names.clone()
370                }
371                crate::executor::streaming::operators::spec::SourceSpec::ScanEdges {
372                    col_names,
373                    ..
374                } => col_names.clone(),
375                crate::executor::streaming::operators::spec::SourceSpec::StorageScanEdges {
376                    col_names,
377                    identity_only,
378                    ..
379                } => {
380                    if *identity_only {
381                        pairs.push(crate::planning::plan::explain::Pair::new(
382                            "mode", "identity",
383                        ));
384                    }
385                    col_names.clone()
386                }
387                _ => vec![],
388            };
389            if !col_names.is_empty() {
390                pnd.output_var = col_names.join(", ");
391            }
392            // Surface the projected property list for storage scans and
393            // graph-operator sources so the property-pruning optimizations
394            // (EnrichScanSlotsWithFilterProps, typed GetVertices/GetNeighbors
395            // pushdown) are observable in EXPLAIN output.
396            let projected: Vec<String> = match src_spec {
397                crate::executor::streaming::operators::spec::SourceSpec::StorageScanVertices {
398                    projected_properties,
399                    ..
400                }
401                | crate::executor::streaming::operators::spec::SourceSpec::StorageScanEdges {
402                    projected_properties,
403                    ..
404                }
405                | crate::executor::streaming::operators::spec::SourceSpec::GetVertices {
406                    projected_properties,
407                    ..
408                }
409                | crate::executor::streaming::operators::spec::SourceSpec::GetEdges {
410                    projected_properties,
411                    ..
412                }
413                | crate::executor::streaming::operators::spec::SourceSpec::GetNeighbors {
414                    projected_properties,
415                    ..
416                } => projected_properties.clone(),
417                _ => vec![],
418            };
419            if !projected.is_empty() {
420                pairs.push(crate::planning::plan::explain::Pair::new(
421                    "projected",
422                    projected.join(","),
423                ));
424            }
425        }
426
427        if !pairs.is_empty() {
428            pnd.description = Some(pairs);
429        }
430
431        // Dependencies
432        if let Some(deps) = parent_of.get(&op_id) {
433            let dep_ids: Vec<i64> = deps.iter().map(|d| d.0 as i64).collect();
434            pnd.set_dependencies(dep_ids);
435        }
436
437        desc.add_node_desc(pnd);
438    }
439
440    desc
441}
442
443/// Return fragment IDs in topological order (sources first, root last).
444fn topological_operator_order(fragments: &FragmentGraph, _plan: &PhysicalPlan) -> Vec<FragmentId> {
445    let root = fragments.root();
446    let mut order = Vec::new();
447    let mut visited = HashSet::new();
448    let mut stack = vec![root];
449
450    while let Some(fid) = stack.pop() {
451        if !visited.insert(fid) {
452            continue;
453        }
454        if let Some(frag) = fragments.get(fid) {
455            for &input in &frag.inputs {
456                stack.push(input);
457            }
458            order.push(fid);
459        }
460    }
461
462    order.reverse();
463    order
464}