blob: f52a26aefbbee227eda4222bcb69abba21ee7bdb [file]
use crate::models::profile::*;
use crate::parser::{fragment_parser, section_parser, value_parser};
/// Parse a raw Doris profile text into a structured DorisProfile.
pub fn parse(raw_text: &str) -> DorisProfile {
let normalized = section_parser::normalize_text(raw_text);
let sections = section_parser::split_sections(&normalized);
// Parse Summary
let summary_text = sections.get("Summary").map(|s| s.as_str()).unwrap_or("");
let summary_kv = section_parser::parse_kv_section(summary_text);
let total_time_str = summary_kv.get("Total").cloned().unwrap_or_default();
let total_time_ms = value_parser::parse_duration_ms(&total_time_str);
let summary = ProfileSummary {
query_id: summary_kv.get("Profile ID").cloned().unwrap_or_default(),
total_time: total_time_str,
total_time_ms,
state: summary_kv.get("Task State").cloned().unwrap_or_default(),
sql: summary_kv.get("Sql Statement").cloned().unwrap_or_default(),
doris_version: summary_kv.get("Doris Version").cloned(),
is_nereids: summary_kv.get("Is Nereids").map(|v| v == "Yes"),
is_cached: summary_kv.get("Is Cached").map(|v| v == "Yes"),
total_instances: summary_kv
.get("Total Instances Num")
.and_then(|v| v.parse().ok()),
user: summary_kv.get("User").cloned(),
start_time: summary_kv.get("Start Time").cloned(),
end_time: summary_kv.get("End Time").cloned(),
default_db: summary_kv.get("Default Db").cloned(),
};
// Parse Execution Summary
let exec_text = sections
.get("Execution Summary")
.map(|s| s.as_str())
.unwrap_or("");
let exec_kv = section_parser::parse_kv_section(exec_text);
let execution_summary = ExecutionSummary {
parse_sql_time: exec_kv.get("Parse SQL Time").cloned(),
plan_time: exec_kv.get("Plan Time").cloned(),
schedule_time: exec_kv.get("Schedule Time").cloned(),
wait_fetch_result_time: exec_kv.get("Wait and Fetch Result Time").cloned(),
fetch_result_time: exec_kv.get("Fetch Result Time").cloned(),
write_result_time: exec_kv.get("Write Result Time").cloned(),
nereids_analysis_time: exec_kv.get("Nereids Analysis Time").cloned(),
nereids_rewrite_time: exec_kv.get("Nereids Rewrite Time").cloned(),
nereids_optimize_time: exec_kv.get("Nereids Optimize Time").cloned(),
workload_group: exec_kv.get("Workload Group").cloned(),
};
// Parse Changed Session Variables
let vars_text = sections
.get("Changed Session Variables")
.map(|s| s.as_str())
.unwrap_or("");
let changed_session_vars = parse_session_vars(vars_text);
// Parse MergedProfile
let merged_text = sections
.get("MergedProfile")
.map(|s| s.as_str())
.unwrap_or("");
let fragments = fragment_parser::parse_merged_profile(merged_text);
DorisProfile {
summary,
execution_summary,
changed_session_vars,
fragments,
}
}
fn parse_session_vars(text: &str) -> Vec<SessionVar> {
let trimmed = text.trim();
// Newer Doris emits this section as a JSON array of
// [{VarName, CurrentValue, DefaultValue}, ...]; older versions use a
// pipe-delimited table. Try JSON first, then fall back to the table parser.
if trimmed.starts_with('[') {
if let Ok(serde_json::Value::Array(items)) =
serde_json::from_str::<serde_json::Value>(trimmed)
{
let vars: Vec<SessionVar> = items
.iter()
.filter_map(|item| {
let name = item.get("VarName").and_then(|v| v.as_str())?;
if name.is_empty() {
return None;
}
Some(SessionVar {
name: name.to_string(),
current_value: item
.get("CurrentValue")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
default_value: item
.get("DefaultValue")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
})
})
.collect();
if !vars.is_empty() {
return vars;
}
}
}
// Legacy pipe-delimited table: "VarName | CurrentValue | DefaultValue".
let mut vars = Vec::new();
for line in text.lines() {
let line = line.trim();
// Skip header and separator lines
if line.is_empty()
|| line.starts_with("VarName")
|| line.starts_with("---")
|| line.starts_with('-')
{
continue;
}
let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect();
if parts.len() >= 3 {
vars.push(SessionVar {
name: parts[0].to_string(),
current_value: parts[1].to_string(),
default_value: parts[2].to_string(),
});
}
}
vars
}
/// Extract all operators from a parsed profile, flattened.
pub fn flatten_operators(profile: &DorisProfile) -> Vec<FlatOperator> {
let total_time_ms = profile.summary.total_time_ms.unwrap_or(1.0).max(0.001);
let mut result = Vec::new();
for fragment in &profile.fragments {
for pipeline in &fragment.pipelines {
for op in &pipeline.operators {
let exec_time_avg_ms = op
.metrics
.exec_time
.as_ref()
.and_then(|v| v.avg)
.unwrap_or(0.0);
let input_rows = op.metrics.input_rows.as_ref().and_then(|v| v.sum.or(v.avg));
let output_rows = op
.metrics
.rows_produced
.as_ref()
.and_then(|v| v.sum.or(v.avg));
let skew_ratio = op.metrics.exec_time.as_ref().and_then(|v| v.skew_ratio());
let time_pct = (exec_time_avg_ms / total_time_ms) * 100.0;
// Selectivity: input / output ratio
let selectivity = match (input_rows, output_rows) {
(Some(inp), Some(out)) if out > 0.0 => Some(round2(inp / out)),
_ => None,
};
// Spill detection
let spill_write = op
.all_counters
.get("SpillWriteBytes")
.or_else(|| op.all_counters.get("SpillWriteBytesToLocalStorage"))
.and_then(|v| v.sum.or(v.avg))
.unwrap_or(0.0);
let spilled = if spill_write > 0.0 { Some(true) } else { None };
// Peak memory
let peak_mem_bytes = op
.metrics
.memory_usage_peak
.as_ref()
.and_then(|v| v.sum.or(v.avg))
.filter(|&v| v > 0.0);
// Wait dependency analysis: blocked if wait_time > exec_time * 2
let wait_time_ms = op
.metrics
.wait_for_dependency_time
.as_ref()
.and_then(|v| v.avg)
.filter(|&v| v > 0.0);
let blocked_on_upstream = match (wait_time_ms, exec_time_avg_ms) {
(Some(wait), exec) if exec > 0.0 && wait > exec * 2.0 => Some(true),
_ => None,
};
// Runtime filter extraction from plan_info AND all_counters
let mut runtime_filters: Vec<String> = Vec::new();
// From plan_info: keys containing RF references
for (k, v) in &op.plan_info {
if k.contains("RF") || k.contains("runtime") || k.contains("RuntimeFilter") {
runtime_filters.push(format!("{k}: {v}"));
}
}
// From all_counters: RuntimeFilter-related counters
for (k, v) in &op.all_counters {
if k.contains("RuntimeFilter") || k.starts_with("RF") {
let val = v.avg.or(v.sum).unwrap_or(0.0);
if val > 0.0 {
runtime_filters.push(format!("{k}: {val}"));
}
}
}
// From all_counters: check for runtime filter specific metrics
let rf_input = op
.all_counters
.get("RuntimeFilterInput")
.or_else(|| op.all_counters.get("RuntimeFilterNum"))
.and_then(|v| v.sum.or(v.avg));
if let Some(rf_count) = rf_input {
if rf_count > 0.0
&& !runtime_filters.iter().any(|r| r.contains("RuntimeFilter"))
{
runtime_filters.push(format!("RuntimeFiltersApplied: {rf_count}"));
}
}
let runtime_filters = if runtime_filters.is_empty() {
None
} else {
Some(runtime_filters)
};
// Shuffle bytes (for exchange/data_stream operators)
let shuffle_bytes = op
.all_counters
.get("ShuffleSendBytes")
.or_else(|| op.all_counters.get("BytesSent"))
.or_else(|| op.all_counters.get("OverallThroughput"))
.and_then(|v| v.sum.or(v.avg))
.filter(|&v| v > 0.0);
// Cache hit percentage (for scan operators)
let cache_hit_pct = {
let hit = op
.all_counters
.get("FileCacheHitBytes")
.or_else(|| op.all_counters.get("CacheHitBytes"))
.and_then(|v| v.sum.or(v.avg))
.unwrap_or(0.0);
let miss = op
.all_counters
.get("FileCacheMissBytes")
.or_else(|| op.all_counters.get("CacheMissBytes"))
.and_then(|v| v.sum.or(v.avg))
.unwrap_or(0.0);
let total = hit + miss;
if total > 0.0 {
Some(round2((hit / total) * 100.0))
} else {
None
}
};
// Rows filtered (scan: output < input means filtering happened)
let rows_filtered = match (input_rows, output_rows) {
(Some(inp), Some(out)) if inp > out && inp > 0.0 => Some(inp - out),
_ => None,
};
// Join type from PlanInfo
let join_type = op
.plan_info
.iter()
.find_map(|(k, v)| {
let combined = format!("{k} {v}").to_lowercase();
if combined.contains("broadcast") {
Some("broadcast".to_string())
} else if combined.contains("bucket_shuffle")
|| combined.contains("bucketshuffle")
{
Some("bucket_shuffle".to_string())
} else if combined.contains("colocate") {
Some("colocated".to_string())
} else if combined.contains("shuffle") || combined.contains("hash") {
Some("shuffle".to_string())
} else {
None
}
})
// Also try extracting from operator name
.or_else(|| {
let name_lower = op.info.full_name.to_lowercase();
if name_lower.contains("broadcast") {
Some("broadcast".to_string())
} else if name_lower.contains("colocate") {
Some("colocated".to_string())
} else {
None
}
});
result.push(FlatOperator {
name: op.info.full_name.clone(),
table: op.info.table_name.clone(),
frag: fragment.id,
pipeline: pipeline.id,
exec_time_avg_ms: round2(exec_time_avg_ms),
input_rows,
output_rows,
skew_ratio: skew_ratio.map(round2),
time_pct: round2(time_pct),
selectivity,
spilled,
peak_mem_bytes,
blocked_on_upstream,
wait_time_ms: wait_time_ms.map(round2),
runtime_filters,
shuffle_bytes,
cache_hit_pct,
rows_filtered,
join_type,
});
}
}
}
// Sort by exec_time descending
result.sort_by(|a, b| {
b.exec_time_avg_ms
.partial_cmp(&a.exec_time_avg_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
result
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
#[cfg(test)]
mod real_profile_tests {
use super::*;
/// A REAL Doris profile captured by the e2e harness: `suite_profile.sh` writes
/// it on a successful `profile get --raw`. Committing that file turns this into
/// an offline regression guard for the whole parse pipeline; until it exists the
/// test is a visible no-op (it prints how to produce the fixture and returns).
fn fixture_path() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/e2e/fixtures/sample_profile.txt")
}
/// WHY this test exists: the parser's contract is to turn an opaque profile
/// text into a structured tree the diagnosis commands can rely on. A unit test
/// over a hand-written snippet can't catch a real-profile-only regression (a
/// section header that moved, a counter format that changed). This parses an
/// actual captured profile and asserts the load-bearing invariants every
/// downstream command depends on — so it FAILS if any of them break.
#[test]
fn parses_a_real_captured_profile() {
let path = fixture_path();
let text = match std::fs::read_to_string(&path) {
Ok(t) if !t.trim().is_empty() => t,
_ => {
eprintln!(
"SKIP parses_a_real_captured_profile: no fixture at {}.\n \
Run ./start-testing.sh against a cluster, then commit\n \
tests/e2e/fixtures/sample_profile.txt to activate this test.",
path.display()
);
return;
}
};
let profile = parse(&text);
// Summary: a real profile must yield a non-empty Profile ID and a positive
// total time (proves the Summary section + duration parsing both worked).
assert!(
!profile.summary.query_id.is_empty(),
"summary.query_id must be parsed from a real profile"
);
assert!(
profile.summary.total_time_ms.unwrap_or(0.0) > 0.0,
"summary.total_time_ms must parse to a positive number, got {:?}",
profile.summary.total_time_ms
);
// Structure: at least one fragment with at least one pipeline of operators
// (proves MergedProfile -> fragment -> pipeline -> operator parsing worked).
assert!(
!profile.fragments.is_empty(),
"a real profile must yield at least one fragment"
);
assert!(
profile
.fragments
.iter()
.any(|f| f.pipelines.iter().any(|p| !p.operators.is_empty())),
"at least one fragment must contain operators"
);
// Flattening: the diagnosis surface must be non-empty, every operator named,
// and sorted by exec_time descending (the head is the slowest).
let flat = flatten_operators(&profile);
assert!(
!flat.is_empty(),
"flatten_operators must yield operators for a real profile"
);
assert!(
flat.iter().all(|op| !op.name.is_empty()),
"every flattened operator must carry a name"
);
assert!(
flat.windows(2)
.all(|w| w[0].exec_time_avg_ms >= w[1].exec_time_avg_ms),
"flattened operators must be sorted by exec_time_avg_ms descending"
);
// Regression (PlanInfo-swallows-counters): an operator that carries a
// "- PlanInfo" block must still surface its counters. The bug was that the
// PlanInfo block never terminated, so every following counter line (which
// shares the "- key: value" shape of a plan-info item) was parsed as
// plan-info and the operator lost RowsProduced -> output_rows -> the
// downstream total_scan_rows degraded to null.
let with_plan_info: Vec<_> = profile
.fragments
.iter()
.flat_map(|f| &f.pipelines)
.flat_map(|p| &p.operators)
.filter(|op| !op.plan_info.is_empty())
.collect();
assert!(
!with_plan_info.is_empty(),
"fixture must contain operators with a PlanInfo block to exercise this path"
);
for op in &with_plan_info {
// Every Doris operator emits at least ExecTime, so a PlanInfo operator
// with an empty counter map means the block absorbed the counters.
assert!(
!op.all_counters.is_empty(),
"operator {:?} carries a PlanInfo block but has no counters the \
PlanInfo parser swallowed them",
op.info.full_name
);
}
assert!(
with_plan_info
.iter()
.any(|op| op.metrics.rows_produced.is_some()),
"at least one PlanInfo-bearing operator must surface RowsProduced (output_rows)"
);
// Regression: DetailProfile / Appendix must be section boundaries, not bleed
// into MergedProfile and fabricate empty fragments — every parsed fragment has
// real pipelines.
assert!(
profile.fragments.iter().all(|f| !f.pipelines.is_empty()),
"no parsed fragment may be empty (DetailProfile/Appendix must terminate MergedProfile)"
);
// Regression: the Changed Session Variables section is parsed (JSON-array or
// pipe-table form). A profile captured by the e2e harness ran with --profile,
// which always changes at least `enable_profile`.
assert!(
!profile.changed_session_vars.is_empty(),
"changed_session_vars must be parsed from a real profile"
);
// Regression: the Physical Plan section is recognized regardless of spelling
// ("PhysicalPlan" vs "Physical Plan").
let sections = section_parser::split_sections(&section_parser::normalize_text(&text));
assert!(
sections.contains_key("Physical Plan"),
"the Physical Plan section must be extracted"
);
}
}