1use std::collections::HashMap;
9use std::sync::Arc;
10
11use datafusion::arrow::array::RecordBatch;
12use datafusion::arrow::datatypes::DataType;
13use datafusion::arrow::util::display::{ArrayFormatter, FormatOptions};
14use datafusion::physical_plan::{ExecutionPlan, collect, displayable};
15use oxidelake_compute::{GpuAggregateExec, GpuFilterExec, GpuHashJoinExec, GpuVectorDistanceExec};
16use oxidelake_core::telemetry::{OperatorSnapshot, PlanNodeSummary};
17use oxidelake_core::{BackendKind, EngineError};
18use oxidelake_tui::{ColumnProfile, DashboardModel};
19
20use crate::session::OxideSession;
21
22fn node_backend(node: &dyn ExecutionPlan) -> BackendKind {
25 if let Some(exec) = node.downcast_ref::<GpuFilterExec>() {
26 exec.target()
27 } else if let Some(exec) = node.downcast_ref::<GpuHashJoinExec>() {
28 exec.target()
29 } else if let Some(exec) = node.downcast_ref::<GpuAggregateExec>() {
30 exec.target()
31 } else if let Some(exec) = node.downcast_ref::<GpuVectorDistanceExec>() {
32 exec.target()
33 } else {
34 BackendKind::CpuSimd
35 }
36}
37
38pub fn plan_summary(plan: &Arc<dyn ExecutionPlan>) -> Vec<PlanNodeSummary> {
40 fn walk(node: &Arc<dyn ExecutionPlan>, depth: usize, out: &mut Vec<PlanNodeSummary>) {
41 let line = displayable(node.as_ref()).one_line().to_string();
42 let detail = line
43 .split_once(": ")
44 .map_or(String::new(), |(_, rest)| rest.trim().to_owned());
45 out.push(PlanNodeSummary {
46 depth,
47 name: node.name().to_owned(),
48 backend: node_backend(node.as_ref()),
49 detail,
50 });
51 for child in node.children() {
52 walk(child, depth + 1, out);
53 }
54 }
55 let mut out = Vec::new();
56 walk(plan, 0, &mut out);
57 out
58}
59
60fn operators_for_plan(
65 plan: &[PlanNodeSummary],
66 recorded: &[OperatorSnapshot],
67) -> Vec<OperatorSnapshot> {
68 let mut by_name: HashMap<&str, OperatorSnapshot> = HashMap::new();
69 for op in recorded {
70 let entry = by_name
71 .entry(op.name.as_str())
72 .or_insert_with(|| OperatorSnapshot {
73 id: 0,
74 name: op.name.clone(),
75 backend: op.backend,
76 rows_in: 0,
77 rows_out: 0,
78 batches: 0,
79 elapsed_ns: 0,
80 bytes_h2d: 0,
81 bytes_d2h: 0,
82 memory_bytes: 0,
83 });
84 entry.rows_in += op.rows_in;
85 entry.rows_out += op.rows_out;
86 entry.batches += op.batches;
87 entry.elapsed_ns += op.elapsed_ns;
88 entry.bytes_h2d += op.bytes_h2d;
89 entry.bytes_d2h += op.bytes_d2h;
90 entry.memory_bytes = entry.memory_bytes.max(op.memory_bytes);
91 }
92 plan.iter()
93 .enumerate()
94 .map(|(id, node)| {
95 let mut op =
96 by_name
97 .get(node.name.as_str())
98 .cloned()
99 .unwrap_or_else(|| OperatorSnapshot {
100 id: 0,
101 name: node.name.clone(),
102 backend: node.backend,
103 rows_in: 0,
104 rows_out: 0,
105 batches: 0,
106 elapsed_ns: 0,
107 bytes_h2d: 0,
108 bytes_d2h: 0,
109 memory_bytes: 0,
110 });
111 op.id = id;
112 op.backend = node.backend;
113 op
114 })
115 .collect()
116}
117
118fn render_cell(batch: &RecordBatch, column: usize) -> String {
119 let opts = FormatOptions::default().with_null("");
120 batch
121 .columns()
122 .get(column)
123 .and_then(|c| ArrayFormatter::try_new(c.as_ref(), &opts).ok())
124 .map_or(String::new(), |f| f.value(0).to_string())
125}
126
127pub async fn profile_table(
130 session: &OxideSession,
131 table: &str,
132) -> Result<Vec<ColumnProfile>, EngineError> {
133 let schema = session
134 .ctx()
135 .table_provider(table)
136 .await
137 .map_err(EngineError::from)?
138 .schema();
139 let mut selects = vec!["count(*)".to_owned()];
140 let mut layout = Vec::new();
143 for field in schema.fields() {
144 let name = field.name();
145 let quoted = format!("\"{}\"", name.replace('"', "\"\""));
146 let numeric = matches!(field.data_type(), DataType::Int64 | DataType::Float64);
147 let orderable = numeric || matches!(field.data_type(), DataType::Utf8);
148 let first = selects.len();
149 selects.push(format!("count({quoted})"));
150 if orderable {
151 selects.push(format!("min({quoted})"));
152 selects.push(format!("max({quoted})"));
153 }
154 if numeric {
155 for q in ["0.25", "0.5", "0.99"] {
156 selects.push(format!("approx_percentile_cont({quoted}, {q})"));
157 }
158 }
159 layout.push((first, orderable, numeric));
160 }
161 let quoted_table = format!("\"{}\"", table.replace('"', "\"\""));
164 let sql = format!("SELECT {} FROM {quoted_table}", selects.join(", "));
165 let batches = session.sql(&sql).await?.collect().await?;
166 let row = batches
167 .iter()
168 .find(|b| b.num_rows() > 0)
169 .ok_or_else(|| EngineError::execution("profile query returned no rows"))?;
170 let total = render_cell(row, 0).parse::<u64>().unwrap_or(0);
171 let mut profiles = Vec::with_capacity(schema.fields().len());
172 for (field, (first, orderable, numeric)) in schema.fields().iter().zip(layout) {
173 let non_null = render_cell(row, first).parse::<u64>().unwrap_or(0);
174 let (min, max) = if orderable {
175 (render_cell(row, first + 1), render_cell(row, first + 2))
176 } else {
177 (String::new(), String::new())
178 };
179 let quantile = |i: usize| {
180 if numeric {
181 render_cell(row, first + 3 + i)
182 } else {
183 String::new()
184 }
185 };
186 profiles.push(ColumnProfile {
187 name: field.name().clone(),
188 data_type: field.data_type().to_string(),
189 min,
190 max,
191 null_count: total.saturating_sub(non_null),
192 p25: quantile(0),
193 p50: quantile(1),
194 p99: quantile(2),
195 });
196 }
197 Ok(profiles)
198}
199
200pub async fn query_dashboard(
204 session: &OxideSession,
205 sql: &str,
206 tables: &[String],
207) -> Result<DashboardModel, EngineError> {
208 let plan = session
209 .sql(sql)
210 .await?
211 .create_physical_plan()
212 .await
213 .map_err(EngineError::from)?;
214 collect(Arc::clone(&plan), session.ctx().task_ctx())
215 .await
216 .map_err(EngineError::from)?;
217 let nodes = plan_summary(&plan);
218 session.telemetry().set_plan(nodes.clone());
219 let mut telemetry = session.telemetry().snapshot();
220 telemetry.operators = operators_for_plan(&nodes, &telemetry.operators);
221 telemetry.plan = nodes;
222 let mut profiles = Vec::new();
223 for table in tables {
224 profiles.extend(profile_table(session, table).await?);
225 }
226 Ok(DashboardModel {
227 telemetry,
228 profiles,
229 title: sql.to_owned(),
230 })
231}
232
233#[cfg(test)]
234#[allow(clippy::unwrap_used, clippy::expect_used)]
235mod tests {
236 use arrow::array::{Float64Array, Int64Array};
237 use arrow::datatypes::{Field, Schema};
238
239 use super::*;
240
241 fn session_with_table() -> OxideSession {
242 let session = OxideSession::local_with_target(BackendKind::Cuda).unwrap();
243 let schema = Arc::new(Schema::new(vec![
244 Field::new("k", DataType::Int64, true),
245 Field::new("v", DataType::Float64, true),
246 ]));
247 let batch = RecordBatch::try_new(
248 schema,
249 vec![
250 Arc::new(Int64Array::from(vec![Some(1), Some(2), None, Some(2)])),
251 Arc::new(Float64Array::from(vec![
252 Some(0.5),
253 Some(1.5),
254 Some(2.5),
255 None,
256 ])),
257 ],
258 )
259 .unwrap();
260 session.ctx().register_batch("t", batch).unwrap();
261 session
262 }
263
264 #[tokio::test]
265 async fn dashboard_reports_plan_tags_and_live_counters() {
266 let session = session_with_table();
267 let model = query_dashboard(
268 &session,
269 "SELECT k, sum(v) FROM t WHERE k >= 1 GROUP BY k",
270 &["t".to_owned()],
271 )
272 .await
273 .unwrap();
274 let names: Vec<&str> = model.plan().iter().map(|n| n.name.as_str()).collect();
275 assert!(names.contains(&"GpuAggregateExec"), "{names:?}");
276 assert!(names.contains(&"GpuFilterExec"), "{names:?}");
277 let agg_row = model
278 .plan()
279 .iter()
280 .position(|n| n.name == "GpuAggregateExec")
281 .unwrap();
282 assert_eq!(model.plan()[agg_row].backend, BackendKind::Cuda);
283 let op = model.operator(agg_row).unwrap();
285 assert_eq!(op.name, "GpuAggregateExec");
286 assert!(op.rows_in > 0 && op.batches > 0, "{op:?}");
287 assert_eq!(model.profiles.len(), 2);
289 assert_eq!(model.profiles[0].name, "k");
290 assert_eq!(model.profiles[0].null_count, 1);
291 assert_eq!(model.profiles[0].min, "1");
292 assert_eq!(model.profiles[0].max, "2");
293 assert_eq!(model.profiles[1].null_count, 1);
294 assert!(!model.profiles[1].p50.is_empty());
295 }
296}