feat(promql): draw the fused node's tree in a bracketed block

The tree lines carried their own absolute indentation, so a nested fused
node's tree sat in the same column as the node's real children: in the early
logical snapshots of EXPLAIN VERBOSE the region fused node's tree root read
as a sibling of the node and its TableScan as a child of the window above it.
The tree now opens a native_tree=[ ... ] block that starts at column zero, the
same shape MergeScan uses for its remote input, so only the lines after the
closing bracket are plan children.

Signed-off-by: discord9 <discord9@163.com>
This commit is contained in:
discord9
2026-11-06 20:46:49 +08:00
parent fe18484261
commit c141cda97f
6 changed files with 96 additions and 84 deletions
@@ -1075,7 +1075,7 @@ impl UserDefinedLogicalNodeCore for FusedSeries {
"{separator}(state routing: canonical labels except {excluded:?})"
)?;
}
write!(f, "\n{}", self.tree)
write!(f, "\nnative_tree=[\n{}\n]", self.tree)
}
fn with_exprs_and_inputs(
@@ -2593,7 +2593,11 @@ impl DisplayAs for FusedSeriesExec {
if self.format == FusedOutputFormat::SeriesFrame {
write!(f, ": (output: series_frame)")?;
}
write!(f, "\n{}", self.tree.display_with(&self.node_metrics()))
write!(
f,
"\nnative_tree=[\n{}\n]",
self.tree.display_with(&self.node_metrics())
)
}
}
@@ -2439,10 +2439,10 @@ impl Display for Explain<'_> {
fn fmt_for_explain_prints_the_native_tree() {
let text = Explain(&fused(rate_tree())).to_string();
assert!(
text.starts_with("FusedSeries\n Window: rate [300000ms]"),
text.starts_with("FusedSeries\nnative_tree=[\nWindow: rate [300000ms]"),
"{text}"
);
assert!(text.contains(" Input: raw samples source=0"), "{text}");
assert!(text.contains("\n Input: raw samples source=0"), "{text}");
assert!(!text.contains("metrics="), "{text}");
}
@@ -2456,16 +2456,16 @@ fn fmt_as_prints_the_native_tree() {
assert!(!text.contains("metrics="), "{text}");
assert!(
text.starts_with("FusedSeriesExec\n Window: rate"),
text.starts_with("FusedSeriesExec\nnative_tree=[\nWindow: rate"),
"the node line precedes its tree: {text}"
);
// A distinctive line of the compiled tree: the window's bound range, offset, anchor and grid.
assert!(
text.contains(" Window: rate [300000ms] offset 0 anchor - on grid 300000/60000x5"),
text.contains("\nWindow: rate [300000ms] offset 0 anchor - on grid 300000/60000x5"),
"the node line carries the tree it runs: {text}"
);
assert!(
text.contains(" Input: raw samples"),
text.contains("\n Input: raw samples"),
"the node line carries the tree's source: {text}"
);
}
@@ -2480,7 +2480,7 @@ async fn fmt_as_reports_node_metrics_after_execution() {
let text = displayable(exec.as_ref()).indent(false).to_string();
println!("=== physical_plan after execution ===\n{text}");
assert!(
text.starts_with("FusedSeriesExec\n Window: rate"),
text.starts_with("FusedSeriesExec\nnative_tree=[\nWindow: rate"),
"{text}"
);
assert!(text.contains("metrics=["), "{text}");
@@ -5832,7 +5832,7 @@ fn the_explain_text_names_the_frame_format_only() {
assert!(
Explain(&rows)
.to_string()
.starts_with("FusedSeries\n Window:")
.starts_with("FusedSeries\nnative_tree=[\nWindow:")
);
let framed = FusedSeries::try_new_with_format(
rows.tree().clone(),
@@ -5845,21 +5845,21 @@ fn the_explain_text_names_the_frame_format_only() {
assert!(
Explain(&framed)
.to_string()
.starts_with("FusedSeries: (output: series_frame)\n Window:"),
.starts_with("FusedSeries: (output: series_frame)\nnative_tree=[\nWindow:"),
"a frame output names its format: {}",
Explain(&framed)
);
let framed_exec = plan_of(framed, memory_exec(Vec::new())).unwrap();
let text = displayable(framed_exec.as_ref()).indent(false).to_string();
assert!(
text.starts_with("FusedSeriesExec: (output: series_frame)\n Window:"),
text.starts_with("FusedSeriesExec: (output: series_frame)\nnative_tree=[\nWindow:"),
"{text}"
);
let canonical = canonical_producer_node();
let text = Explain(&canonical).to_string();
assert!(
text.starts_with(
"FusedSeries: (output: series_frame, per-partition canonical merge)\n Merge:"
"FusedSeries: (output: series_frame, per-partition canonical merge)\nnative_tree=[\nMerge:"
),
"{text}"
);
@@ -5867,7 +5867,7 @@ fn the_explain_text_names_the_frame_format_only() {
let plain = plan(rate_tree(), memory_exec(Vec::new())).expect("the runtime executes the tree");
let text = displayable(plain.as_ref()).indent(false).to_string();
assert!(
text.starts_with("FusedSeriesExec\n Window:"),
text.starts_with("FusedSeriesExec\nnative_tree=[\nWindow:"),
"the row-table node line has no parameters: {text}"
);
}
+9 -8
View File
@@ -519,8 +519,7 @@ impl NativeTree {
report_node(self.root(), ROOT_PATH, metrics)
}
/// This tree rendered under its operator line, with each node's own
/// figures when `metrics` holds them.
/// This tree rendered from column zero, with each node's own figures when `metrics` holds them.
pub fn display_with<'a>(&'a self, metrics: &'a NodeMetricsSnapshot) -> NativeTreeDisplay<'a> {
NativeTreeDisplay {
tree: self,
@@ -538,9 +537,10 @@ pub struct NativeTreeDisplay<'a> {
impl fmt::Display for NativeTreeDisplay<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// The root sits one level in from the operator line it is drawn under, and each child one
// level below its consumer.
write_report(f, &self.tree.report(self.metrics), 1)
// The root starts at column zero: the block the caller brackets the tree in carries the
// indentation, so each line here is depth-relative to that block rather than to the
// caller's own place in the plan.
write_report(f, &self.tree.report(self.metrics), 0)
}
}
@@ -567,10 +567,11 @@ fn write_report(
}
impl fmt::Display for NativeTree {
/// One line per node, outermost first, two spaces per depth, without figures: this is the
/// tree's own shape, which is what a display that holds no metrics snapshot can show.
/// One line per node, outermost first, from column zero with two spaces per depth and without
/// figures: this is the tree's own shape, which is what a display that holds no metrics
/// snapshot can show.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write_report(f, &self.report(&NodeMetricsSnapshot::default()), 1)
write_report(f, &self.report(&NodeMetricsSnapshot::default()), 0)
}
}
+39 -39
View File
@@ -125,7 +125,7 @@ fn shown(lines: &[&str]) -> String {
fn a_tree_is_plan_lines_only() {
let tree = NativeTree::try_new(raw_input()).unwrap();
assert_eq!(tree.to_string(), shown(&[" Input: raw samples source=0"]));
assert_eq!(tree.to_string(), shown(&["Input: raw samples source=0"]));
assert_eq!(
NativeTree::try_new(window_over(
raw_input(),
@@ -138,8 +138,8 @@ fn a_tree_is_plan_lines_only() {
.unwrap()
.to_string(),
shown(&[
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
"Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
])
);
}
@@ -161,8 +161,8 @@ fn a_window_over_raw_samples_is_shown_once_per_node() {
assert_eq!(
tree.to_string(),
shown(&[
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
"Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
])
);
}
@@ -187,9 +187,9 @@ fn a_map_nests_below_the_window_it_maps() {
assert_eq!(
tree.to_string(),
shown(&[
" Map: abs, round 3",
" Window: rate [300000ms] offset 60000 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
"Map: abs, round 3",
" Window: rate [300000ms] offset 60000 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
])
);
}
@@ -211,8 +211,8 @@ fn a_window_names_its_constant_argument_and_its_anchor() {
assert_eq!(
tree.to_string(),
shown(&[
" Window: quantile_over_time 0.9 [60000ms] offset 0 anchor 42 on grid 1000/60000x4",
" Input: raw samples source=0",
"Window: quantile_over_time 0.9 [60000ms] offset 0 anchor 42 on grid 1000/60000x4",
" Input: raw samples source=0",
])
);
}
@@ -236,7 +236,7 @@ fn an_input_reports_whether_it_is_raw_and_what_ordering_it_guarantees() {
let tree = NativeTree::try_new(evaluated_input).unwrap();
assert_eq!(
tree.to_string(),
shown(&[" Input: evaluated samples source=0, contiguous",])
shown(&["Input: evaluated samples source=0, contiguous",])
);
let interleaved = NativeType::Samples(Arc::new(SeriesSchema {
@@ -254,7 +254,7 @@ fn an_input_reports_whether_it_is_raw_and_what_ordering_it_guarantees() {
let tree = NativeTree::try_new(raw_input).unwrap();
assert_eq!(
tree.to_string(),
shown(&[" Input: raw samples source=0, interleaved"])
shown(&["Input: raw samples source=0, interleaved"])
);
}
@@ -268,7 +268,7 @@ fn a_scalar_and_an_instant_line_show_what_they_hold() {
output: NativeType::Scalar(Arc::clone(&out)),
};
let tree = NativeTree::try_new(scalar).unwrap();
assert_eq!(tree.to_string(), shown(&[" Scalar: 2"]));
assert_eq!(tree.to_string(), shown(&["Scalar: 2"]));
let instant = NativeNode {
expr: NativeExpr::Instant {
@@ -287,8 +287,8 @@ fn a_scalar_and_an_instant_line_show_what_they_hold() {
assert_eq!(
tree.to_string(),
shown(&[
" Instant: lookback 300000ms offset 60000 on grid 0/60000x4",
" Input: raw samples source=0",
"Instant: lookback 300000ms offset 60000 on grid 0/60000x4",
" Input: raw samples source=0",
])
);
}
@@ -320,9 +320,9 @@ fn an_aggregate_and_a_binary_name_their_binding() {
assert_eq!(
tree.to_string(),
shown(&[
" Aggregate: sum by [job] complete",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
"Aggregate: sum by [job] complete",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
])
);
@@ -356,10 +356,10 @@ fn an_aggregate_and_a_binary_name_their_binding() {
assert_eq!(
tree.to_string(),
shown(&[
" Binary: + ignoring [] one-to-one",
" Scalar: 2",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
"Binary: + ignoring [] one-to-one",
" Scalar: 2",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
])
);
@@ -375,7 +375,7 @@ fn an_aggregate_and_a_binary_name_their_binding() {
.to_string()
.lines()
.next(),
Some(" Binary: + ignoring [] one-to-one fill_right 0.5")
Some("Binary: + ignoring [] one-to-one fill_right 0.5")
);
}
@@ -409,15 +409,15 @@ fn a_set_operation_names_its_operator_and_labels() {
};
assert_eq!(
root_line(SetOp::And, MatchingLabels::On(vec!["job".into()])),
" Set: and on [job]"
"Set: and on [job]"
);
assert_eq!(
root_line(SetOp::Or, MatchingLabels::Ignoring(vec!["job".into()])),
" Set: or ignoring [job]"
"Set: or ignoring [job]"
);
assert_eq!(
root_line(SetOp::Unless, MatchingLabels::Ignoring(Vec::new())),
" Set: unless ignoring []"
"Set: unless ignoring []"
);
}
@@ -451,9 +451,9 @@ fn a_without_grouping_does_not_read_as_by() {
assert_eq!(
tree.to_string(),
shown(&[
" Aggregate: sum without [job] complete",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
"Aggregate: sum without [job] complete",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4",
" Input: raw samples source=0",
])
);
}
@@ -501,22 +501,22 @@ fn a_vector_operation_names_its_binding() {
top: false,
group: Grouping::Without(Arc::new(["job".into()])),
},
" Vector: bottomk 3 without [job]",
"Vector: bottomk 3 without [job]",
),
(
BoundVectorOp::LimitK {
k: 2.0,
group: Grouping::By(Arc::new([])),
},
" Vector: limitk 2 by []",
"Vector: limitk 2 by []",
),
(
BoundVectorOp::LimitRatio { ratio: -0.5 },
" Vector: limit_ratio -0.5",
"Vector: limit_ratio -0.5",
),
(
BoundVectorOp::Sort(SortOrder::ByLabelDesc(vec!["job".into(), "le".into()])),
" Vector: sort_by_label_desc [job, le]",
"Vector: sort_by_label_desc [job, le]",
),
(
BoundVectorOp::Absent {
@@ -529,14 +529,14 @@ fn a_vector_operation_names_its_binding() {
)
.unwrap(),
},
r#" Vector: absent {job="x"}"#,
r#"Vector: absent {job="x"}"#,
),
(
BoundVectorOp::CountValues {
label: "value".into(),
group: Grouping::Without(Arc::new(["job".into()])),
},
r#" Vector: count_values "value" without [job]"#,
r#"Vector: count_values "value" without [job]"#,
),
];
for (op, line) in lines {
@@ -573,7 +573,7 @@ fn a_modified_window_names_its_modifier() {
assert_eq!(
tree.to_string().lines().next(),
Some(
" Window: rate [300000ms] smoothed lookback 300000ms offset 0 anchor - on grid \
"Window: rate [300000ms] smoothed lookback 300000ms offset 0 anchor - on grid \
0/60000x4"
)
);
@@ -624,10 +624,10 @@ fn each_node_shows_only_the_figures_it_recorded() {
assert_eq!(
tree.display_with(&metrics).to_string(),
shown(&[
" Aggregate: sum by [job] complete, metrics=[groups: 120]",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4, \
"Aggregate: sum by [job] complete, metrics=[groups: 120]",
" Window: rate [300000ms] offset 0 anchor - on grid 0/60000x4, \
metrics=[output_batches: 4, output_values: 7200]",
" Input: raw samples source=0, metrics=[input_batches: 1, input_samples: 1486080]",
" Input: raw samples source=0, metrics=[input_batches: 1, input_samples: 1486080]",
])
);
}
+20 -20
View File
@@ -1039,8 +1039,8 @@ fn the_display_of_a_compiled_tree_is_the_tree() {
assert_eq!(
tree.to_string(),
[
" Window: rate [300000ms] offset 0 anchor - on grid 0/5000x21",
" Input: raw samples source=0",
"Window: rate [300000ms] offset 0 anchor - on grid 0/5000x21",
" Input: raw samples source=0",
]
.join("\n")
);
@@ -1055,29 +1055,29 @@ fn the_display_of_a_tree_the_batch_added_to_is_the_query() {
(
"sum by(job)(rate(x[5m]) * 100)",
&[
" Aggregate: sum by [job] complete",
" Binary: * ignoring [] one-to-one",
" Window: rate [300000ms] offset 0 anchor - on grid 0/5000x21",
" Input: raw samples source=0",
" Scalar: 100",
"Aggregate: sum by [job] complete",
" Binary: * ignoring [] one-to-one",
" Window: rate [300000ms] offset 0 anchor - on grid 0/5000x21",
" Input: raw samples source=0",
" Scalar: 100",
],
),
(
"-x",
&[
" Binary: * ignoring [] one-to-one",
" Scalar: -1",
" Instant: lookback 5000ms offset 0 on grid 0/5000x21",
" Input: raw samples source=0",
"Binary: * ignoring [] one-to-one",
" Scalar: -1",
" Instant: lookback 5000ms offset 0 on grid 0/5000x21",
" Input: raw samples source=0",
],
),
(
"0 < x",
&[
" Binary: < ignoring [] one-to-one",
" Scalar: 0",
" Instant: lookback 5000ms offset 0 on grid 0/5000x21",
" Input: raw samples source=0",
"Binary: < ignoring [] one-to-one",
" Scalar: 0",
" Instant: lookback 5000ms offset 0 on grid 0/5000x21",
" Input: raw samples source=0",
],
),
];
@@ -1289,7 +1289,7 @@ fn the_supported_math_functions_map_to_their_kernels() {
let root = display.lines().next().expect("a compiled tree has a root");
assert_eq!(
root,
format!(" Map: {op}"),
format!("Map: {op}"),
"`{query}` bound the wrong kernel"
);
}
@@ -2853,7 +2853,7 @@ fn the_aggregates_that_are_not_a_fold_bind_a_vector_operation() {
.unwrap_or_else(|error| panic!("`{query}` was refused: {error}"));
let display = tree.to_string();
let root = display.lines().next().expect("a compiled tree has a root");
assert_eq!(root, format!(" Vector: {op}"), "`{query}`");
assert_eq!(root, format!("Vector: {op}"), "`{query}`");
}
let error = compile(
@@ -4287,7 +4287,7 @@ fn whole_vector_functions_bind_a_vector_operation() {
.unwrap_or_else(|error| panic!("`{query}` was refused: {error}"));
let display = tree.to_string();
let root = display.lines().next().expect("a compiled tree has a root");
assert_eq!(root, format!(" Vector: {op}"), "`{query}`");
assert_eq!(root, format!("Vector: {op}"), "`{query}`");
}
}
@@ -4830,7 +4830,7 @@ fn label_functions_bind_their_change_into_a_map() {
.unwrap_or_else(|error| panic!("`{query}` was refused: {error}"));
let display = tree.to_string();
let root = display.lines().next().expect("a compiled tree has a root");
assert_eq!(root, format!(" Map: {op}"), "`{query}`");
assert_eq!(root, format!("Map: {op}"), "`{query}`");
}
}
@@ -4853,7 +4853,7 @@ fn histogram_functions_bind_their_kernel_into_a_map() {
.unwrap_or_else(|error| panic!("`{query}` was refused: {error}"));
let display = tree.to_string();
let root = display.lines().next().expect("a compiled tree has a root");
assert_eq!(root, format!(" Map: {name}"), "`{query}`");
assert_eq!(root, format!("Map: {name}"), "`{query}`");
}
}
+11 -4
View File
@@ -2620,7 +2620,10 @@ async fn native_fused_rate_plans_to_a_fused_series_node() {
let plan = build_native_fused_plan("rate(some_metric[5m])", true).await;
let display = plan.display_indent().to_string();
assert!(display.contains(" Window: rate [300000ms]"), "{display}");
assert!(
display.contains("native_tree=[\nWindow: rate [300000ms]"),
"{display}"
);
// The node reads the selector's own scan; nothing between them packs the frame it reads.
assert!(display.contains("TableScan: some_metric"), "{display}");
// The node keeps the compiled tree, not the query text.
@@ -4225,14 +4228,18 @@ async fn native_split_explain_renders_each_tree_including_remote_input() {
let (final_text, remote_text) = text
.split_once("MergeScan [is_placeholder=false, remote_input=[")
.expect("the partial is inside a merge scan");
assert!(final_text.starts_with("FusedSeries\n Binary:"), "{text}");
assert!(
final_text.starts_with("FusedSeries\nnative_tree=[\nBinary:"),
"{text}"
);
assert!(
final_text.contains("Aggregate: avg by [tag_0] final"),
"{text}"
);
assert!(
remote_text
.contains("FusedSeries: (output: series_frame)\n Aggregate: avg by [tag_0] partial"),
remote_text.contains(
"FusedSeries: (output: series_frame)\nnative_tree=[\nAggregate: avg by [tag_0] partial"
),
"{text}"
);
assert!(remote_text.contains("Window: irate [60000ms]"), "{text}");