Skip to main content

query_runtime/observability/
metrics_recorder.rs

1//! Typed query metrics recorder for native plan execution.
2//!
3//! A `MetricsRecorder` acts as a dispatch handle rather than an accumulator.
4//! Each method emits a typed metric fact directly to the plan execution's
5//! observation session, which serves as the canonical record. Materialized
6//! [`QueryMetrics`] structures represent projections derived from that session.
7//! Recorder methods correspond directly to downstream metric reductions.
8
9use std::{collections::BTreeMap, time::Duration};
10
11use cudf::ExecutionBudgetReport;
12use query_planning::{
13    correlation::{PlanNodeId, QueryId, TraceId},
14    dynamic_filter::{DynamicFilter, DynamicFilterRoute},
15    plan::{GraphAlgorithm, NodeId},
16};
17
18use super::{
19    BudgetWindow, BudgetWindowPeaks, PlanExecutionId, QueryMetricsCheckpoint, RuntimeObservationConfig,
20    RuntimeObservationHandle, RuntimeObservationSession, SourceReadFact,
21};
22use crate::{
23    metrics::{
24        BoundaryMetrics, DynamicFilterSourceSnapshot, GraphAlgorithmRuntimeMetrics, OperatorMetrics, QueryMetrics,
25        SourceReadRuntimeMetrics,
26    },
27    observability::graph_observation::GraphMeasuredPeakProvenance,
28};
29
30/// Records one plan execution's metrics into its observation owner.
31#[derive(Debug, Clone)]
32pub struct MetricsRecorder {
33    observation: RuntimeObservationHandle,
34    plan_execution_id: PlanExecutionId,
35}
36
37impl MetricsRecorder {
38    /// Binds a recorder to one plan execution of a query-bound session.
39    pub fn new(observation: RuntimeObservationHandle, plan_execution_id: PlanExecutionId) -> Self {
40        Self { observation, plan_execution_id }
41    }
42
43    /// Binds a recorder to one plan execution, owning the metrics locally when
44    /// the caller's observation has no owner.
45    ///
46    /// A context built without an observation session still produces metrics:
47    /// its plan execution gets a private owner with the native retention
48    /// profile. That owner records metrics only; the caller's trace stays off.
49    pub fn bind(
50        observation: &RuntimeObservationHandle,
51        plan_execution_id: PlanExecutionId,
52        query_id: &str,
53        trace_id: Option<&str>,
54    ) -> Self {
55        let observation = if observation.has_owner() {
56            observation.clone()
57        } else {
58            RuntimeObservationSession::new(
59                QueryId::new(query_id),
60                trace_id.map(TraceId::new),
61                RuntimeObservationConfig::native_execution(),
62            )
63            .handle()
64        };
65        Self::new(observation, plan_execution_id)
66    }
67
68    /// The plan execution this recorder reports into.
69    pub const fn plan_execution_id(&self) -> &PlanExecutionId {
70        &self.plan_execution_id
71    }
72
73    /// One internally consistent view of this plan execution's metrics.
74    pub fn checkpoint(&self) -> Option<QueryMetricsCheckpoint> {
75        self.observation.metrics_checkpoint(&self.plan_execution_id)
76    }
77
78    /// Freezes this plan execution's metrics and returns them. Facts recorded
79    /// for it afterwards are ignored, so every later checkpoint and the
80    /// sealed snapshot carry this value.
81    pub fn freeze(&self) -> Option<QueryMetricsCheckpoint> {
82        self.observation.freeze_plan_metrics(&self.plan_execution_id)
83    }
84
85    /// Source chunk and partial aggregate counts so far, for diagnostic facts.
86    /// Never an input to execution decisions.
87    pub fn progress(&self) -> ExecutionProgress {
88        self.observation.execution_progress(&self.plan_execution_id)
89    }
90
91    /// The data-boundary counters so far, for reporting one operation's share.
92    /// Never an input to execution decisions.
93    pub fn boundary(&self) -> BoundaryMetrics {
94        self.observation.boundary_metrics(&self.plan_execution_id)
95    }
96
97    fn apply(&self, reduce: impl FnOnce(&mut QueryMetrics)) {
98        self.observation.apply_metrics(&self.plan_execution_id, reduce);
99    }
100
101    fn source(&self, fact: SourceReadFact) {
102        self.observation.record_source_read(&self.plan_execution_id, fact);
103    }
104
105    // ---- source reads --------------------------------------------------
106
107    /// Records one source-read fact.
108    pub fn record_source_read(&self, fact: SourceReadFact) {
109        self.source(fact);
110    }
111
112    /// Offers one source-read detail row for bounded retention.
113    pub fn record_source_read_runtime(&self, row: SourceReadRuntimeMetrics) {
114        self.observation.record_source_read_row(&self.plan_execution_id, row);
115    }
116
117    /// Records one input batch and its row count.
118    pub fn record_input_batch(&self, rows: u64) {
119        self.source(SourceReadFact::InputBatch { rows });
120    }
121
122    /// Records one Parquet source file observation.
123    pub fn record_parquet_source(&self, known_bytes: Option<u64>, selected_row_groups: u64, read_columns: u64) {
124        self.source(SourceReadFact::ParquetSource {
125            files: 1,
126            known_bytes: known_bytes.unwrap_or(0),
127            selected_row_groups,
128            read_columns,
129        });
130    }
131
132    /// Records that a source chunk was planned.
133    pub fn record_source_chunk_planned(&self, row_groups: u64) {
134        self.source(SourceReadFact::ChunkPlanned { row_groups });
135    }
136
137    /// Records one source chunk attempt.
138    pub fn record_source_chunk_attempt(&self) {
139        self.source(SourceReadFact::ChunkAttempted);
140    }
141
142    /// Records one committed source chunk and row-group count.
143    pub fn record_source_chunk_commit(&self, row_groups: u64) {
144        self.source(SourceReadFact::ChunkCommitted { chunk_count: 1, row_groups });
145    }
146
147    /// Records committed source chunks and row-group count.
148    pub fn record_source_chunk_commit_count(&self, chunk_count: u64, row_groups: u64) {
149        self.source(SourceReadFact::ChunkCommitted { chunk_count, row_groups });
150    }
151
152    /// Records one source chunk retry.
153    pub fn record_source_chunk_retry(&self) {
154        self.source(SourceReadFact::ChunkRetried);
155    }
156
157    /// Records a started source-read prefetch.
158    pub fn record_source_read_prefetch_started(&self) {
159        self.source(SourceReadFact::PrefetchStarted);
160    }
161
162    /// Records a completed source-read prefetch.
163    pub fn record_source_read_prefetch_completed(&self) {
164        self.source(SourceReadFact::PrefetchCompleted);
165    }
166
167    /// Records a skipped source-read prefetch.
168    pub fn record_source_read_prefetch_skipped(&self) {
169        self.source(SourceReadFact::PrefetchSkipped);
170    }
171
172    /// Records an abandoned source-read prefetch.
173    pub fn record_source_read_prefetch_abandoned(&self) {
174        self.source(SourceReadFact::PrefetchAbandoned);
175    }
176
177    /// Adds source-read orchestration time.
178    pub fn add_source_read_time(&self, elapsed: Duration) {
179        self.source(SourceReadFact::SourceReadTime(elapsed));
180    }
181
182    // ---- operator, merge, sort, and materialization time ---------------
183
184    /// Adds native operator time.
185    pub fn add_operator_time(&self, elapsed: Duration) {
186        self.apply(|metrics| saturating_add_duration(&mut metrics.operator_time, elapsed));
187    }
188
189    /// Adds operator time spent inside source chunks.
190    pub fn add_chunk_operator_time(&self, elapsed: Duration) {
191        self.apply(|metrics| saturating_add_duration(&mut metrics.chunk_operator_time, elapsed));
192    }
193
194    /// Adds final-merge time.
195    pub fn add_final_merge_time(&self, elapsed: Duration) {
196        self.apply(|metrics| saturating_add_duration(&mut metrics.final_merge_time, elapsed));
197    }
198
199    /// Adds final-sort time.
200    pub fn add_final_sort_time(&self, elapsed: Duration) {
201        self.apply(|metrics| saturating_add_duration(&mut metrics.final_sort_time, elapsed));
202    }
203
204    /// Adds host materialization time.
205    pub fn add_materialization_time(&self, elapsed: Duration) {
206        self.apply(|metrics| saturating_add_duration(&mut metrics.materialization_time, elapsed));
207    }
208
209    // ---- data boundary -------------------------------------------------
210
211    /// Records a GPU-resident input handoff.
212    pub fn record_gpu_resident_input_handoff(&self) {
213        self.apply(|metrics| metrics.boundary.record_gpu_resident_input_handoff());
214    }
215
216    /// Records a host-to-device upload.
217    pub fn record_host_to_device_upload(&self, rows: u64, bytes: u64) {
218        self.apply(|metrics| metrics.boundary.record_host_to_device_upload(rows, bytes));
219    }
220
221    /// Records a device-to-host materialization.
222    pub fn record_device_to_host_materialization(&self, rows: u64, bytes: u64) {
223        self.apply(|metrics| metrics.boundary.record_device_to_host_materialization(rows, bytes));
224    }
225
226    // ---- memory and operators -----------------------------------------
227
228    /// Records one device memory observation.
229    pub fn record_device_memory_observation(&self, bytes: u64) {
230        self.apply(|metrics| metrics.record_device_memory_observation(bytes));
231    }
232
233    /// Records the allocation high-water mark of the attempt's allocation domain.
234    pub fn record_allocator_high_water_bytes(&self, bytes: u64) {
235        self.apply(|metrics| metrics.record_allocator_high_water_bytes(bytes));
236    }
237
238    /// Records one operator occurrence.
239    pub fn record_operator_metrics(&self, row: OperatorMetrics) {
240        self.apply(|metrics| metrics.record_operator_metrics(row));
241    }
242
243    // ---- aggregates ------------------------------------------------------
244
245    /// Records one aggregate strategy decision.
246    pub fn record_aggregate_strategy_decision(&self, node_id: impl ToString, facts: &BTreeMap<&'static str, String>) {
247        let node_id = node_id.to_string();
248        let facts = facts.clone();
249        self.apply(|metrics| metrics.record_aggregate_strategy_decision(node_id, &facts));
250    }
251
252    /// Records one partial aggregate.
253    pub fn record_partial_aggregate(&self, input_rows: u64, output_rows: u64, retained_bytes: u64) {
254        self.apply(|metrics| metrics.record_partial_aggregate(input_rows, output_rows, retained_bytes));
255    }
256
257    /// Records one final-merge attempt.
258    pub fn record_final_merge_attempt(&self, merge_fan_in: usize) {
259        self.apply(|metrics| metrics.record_final_merge_attempt(merge_fan_in));
260    }
261
262    /// Records one final-merge level.
263    pub fn record_final_merge_level(&self) {
264        self.apply(QueryMetrics::record_final_merge_level);
265    }
266
267    // ---- joins -----------------------------------------------------------
268
269    /// Records one join strategy decision.
270    pub fn record_join_strategy_decision(&self, node_id: impl ToString, facts: &BTreeMap<&'static str, String>) {
271        let node_id = node_id.to_string();
272        let facts = facts.clone();
273        self.apply(|metrics| metrics.record_join_strategy_decision(node_id, &facts));
274    }
275
276    /// Records a hash-join build.
277    pub fn record_hash_join_build(&self) {
278        self.apply(QueryMetrics::record_hash_join_build);
279    }
280
281    /// Records a filtered-join build.
282    pub fn record_filtered_join_build(&self) {
283        self.apply(QueryMetrics::record_filtered_join_build);
284    }
285
286    /// Records a one-shot join.
287    pub fn record_one_shot_join(&self) {
288        self.apply(QueryMetrics::record_one_shot_join);
289    }
290
291    /// Records hash-join hash table bytes.
292    pub fn record_hash_join_hash_table_bytes(&self, bytes: u64) {
293        self.apply(|metrics| metrics.record_hash_join_hash_table_bytes(bytes));
294    }
295
296    /// Records filtered-join hash table bytes.
297    pub fn record_filtered_join_hash_table_bytes(&self, bytes: u64) {
298        self.apply(|metrics| metrics.record_filtered_join_hash_table_bytes(bytes));
299    }
300
301    /// Records filtered-join probe state bytes.
302    pub fn record_filtered_join_probe_state_bytes(&self, bytes: u64) {
303        self.apply(|metrics| metrics.record_filtered_join_probe_state_bytes(bytes));
304    }
305
306    /// Records mixed-join size data bytes.
307    pub fn record_mixed_join_size_data_bytes(&self, bytes: u64) {
308        self.apply(|metrics| metrics.record_mixed_join_size_data_bytes(bytes));
309    }
310
311    /// Records one-shot join index bytes.
312    pub fn record_one_shot_join_index_bytes(&self, bytes: u64) {
313        self.apply(|metrics| metrics.record_one_shot_join_index_bytes(bytes));
314    }
315
316    /// Records one-shot join materialization bytes.
317    pub fn record_one_shot_join_materialization_bytes(&self, bytes: u64) {
318        self.apply(|metrics| metrics.record_one_shot_join_materialization_bytes(bytes));
319    }
320
321    /// Records one-shot join output bytes.
322    pub fn record_one_shot_join_output_bytes(&self, bytes: u64) {
323        self.apply(|metrics| metrics.record_one_shot_join_output_bytes(bytes));
324    }
325
326    // ---- dynamic filters -------------------------------------------------
327
328    /// Records a produced dynamic filter.
329    pub fn record_dynamic_filter_produced(
330        &self,
331        route: &DynamicFilterRoute,
332        filter: &DynamicFilter,
333        producer_rows: u64,
334    ) {
335        self.apply(|metrics| metrics.record_dynamic_filter_produced(route, filter, producer_rows));
336    }
337
338    /// Records a skipped dynamic filter.
339    pub fn record_dynamic_filter_skipped(&self, route: &DynamicFilterRoute, reason: impl Into<String>) {
340        let reason = reason.into();
341        self.apply(|metrics| metrics.record_dynamic_filter_skipped(route, reason));
342    }
343
344    /// Records a dynamic filter applied to a source.
345    pub fn record_dynamic_filter_applied(
346        &self,
347        route: &DynamicFilterRoute,
348        filter: &DynamicFilter,
349        before: DynamicFilterSourceSnapshot,
350        after: DynamicFilterSourceSnapshot,
351    ) {
352        self.apply(|metrics| metrics.record_dynamic_filter_applied(route, filter, before, after));
353    }
354
355    /// Records a dynamic filter applied after decode.
356    pub fn record_dynamic_filter_applied_post_decode(
357        &self,
358        route: &DynamicFilterRoute,
359        filter: &DynamicFilter,
360        rows_before: Option<u64>,
361        rows_after: Option<u64>,
362    ) {
363        self.apply(|metrics| metrics.record_dynamic_filter_applied_post_decode(route, filter, rows_before, rows_after));
364    }
365
366    /// Records one source post-decode dynamic-filter stage.
367    pub fn record_dynamic_filter_source_post_decode_stage(
368        &self,
369        route: &DynamicFilterRoute,
370        filter: &DynamicFilter,
371        rows_before: Option<u64>,
372        rows_after: Option<u64>,
373    ) {
374        self.apply(|metrics| {
375            metrics.record_dynamic_filter_source_post_decode_stage(route, filter, rows_before, rows_after);
376        });
377    }
378
379    /// Records post-decode dynamic-filter row counts.
380    pub fn record_dynamic_filter_post_decode_rows(
381        &self,
382        route: &DynamicFilterRoute,
383        filter: &DynamicFilter,
384        rows_before: Option<u64>,
385        rows_after: Option<u64>,
386    ) {
387        self.apply(|metrics| metrics.record_dynamic_filter_post_decode_rows(route, filter, rows_before, rows_after));
388    }
389
390    /// Records reader-predicate dynamic-filter row counts for one source chunk.
391    pub fn record_dynamic_filter_source_reader_predicate_rows(
392        &self,
393        route: &DynamicFilterRoute,
394        filter: &DynamicFilter,
395        source_file_index: usize,
396        source_chunk_index: usize,
397        rows_before: Option<u64>,
398        rows_after: Option<u64>,
399    ) {
400        self.apply(|metrics| {
401            metrics.record_dynamic_filter_source_reader_predicate_rows(
402                route,
403                filter,
404                source_file_index,
405                source_chunk_index,
406                rows_before,
407                rows_after,
408            );
409        });
410    }
411
412    /// Records reader-predicate dynamic-filter row counts for one source batch.
413    pub fn record_dynamic_filter_source_reader_predicate_batch_rows(
414        &self,
415        route: &DynamicFilterRoute,
416        filter: &DynamicFilter,
417        rows_before: Option<u64>,
418        rows_after: Option<u64>,
419    ) {
420        self.apply(|metrics| {
421            metrics.record_dynamic_filter_source_reader_predicate_batch_rows(route, filter, rows_before, rows_after);
422        });
423    }
424
425    /// Records a reader-predicate dynamic-filter source reduction.
426    pub fn record_dynamic_filter_reader_predicate_source_reduction(
427        &self,
428        rows_before: Option<u64>,
429        rows_after: Option<u64>,
430    ) {
431        self.apply(|metrics| metrics.record_dynamic_filter_reader_predicate_source_reduction(rows_before, rows_after));
432    }
433
434    // ---- graph -----------------------------------------------------------
435
436    /// Records one graph algorithm runtime row.
437    pub fn record_graph_algorithm_runtime(&self, row: GraphAlgorithmRuntimeMetrics) {
438        self.apply(|metrics| metrics.record_graph_algorithm_runtime(row));
439    }
440
441    /// Opens a pending graph measured-peak row.
442    pub fn record_graph_claim_calibration(&self, node_id: NodeId, algorithm: GraphAlgorithm) {
443        self.apply(|metrics| metrics.record_graph_claim_calibration(node_id, algorithm));
444    }
445
446    /// Completes the most recent pending graph measured-peak row.
447    pub fn record_graph_claim_calibration_measurement(
448        &self,
449        node_id: NodeId,
450        algorithm: GraphAlgorithm,
451        measured_peak_bytes: Option<u64>,
452        measured_peak_provenance: GraphMeasuredPeakProvenance,
453    ) {
454        self.apply(|metrics| {
455            metrics.record_graph_claim_calibration_measurement(
456                node_id,
457                algorithm,
458                measured_peak_bytes,
459                measured_peak_provenance,
460            );
461        });
462    }
463
464    /// Records the transition into the native graph algorithm executor.
465    pub fn record_graph_claim_calibration_algorithm_started(&self, node_id: NodeId, algorithm: GraphAlgorithm) {
466        self.apply(|metrics| metrics.record_graph_claim_calibration_algorithm_started(node_id, algorithm));
467    }
468
469    // ---- tasks and budgets -------------------------------------------------
470
471    /// Records a lower-level cuDF execution-budget report.
472    pub fn record_budgeted_execution(&self, operation: impl Into<String>, report: &ExecutionBudgetReport) {
473        let operation = operation.into();
474        self.apply(|metrics| metrics.record_budgeted_execution(operation, report));
475    }
476
477    /// Opens a window whose budget reports are later folded into one task.
478    pub fn open_budget_window(&self) -> BudgetWindow {
479        self.observation.open_budget_window(&self.plan_execution_id)
480    }
481
482    /// Closes a budget window and returns the peaks it saw, for an operation
483    /// that reports them itself.
484    pub fn take_budget_window(&self, window: BudgetWindow) -> BudgetWindowPeaks {
485        self.observation.take_budget_window(&self.plan_execution_id, window)
486    }
487
488    /// Folds the peaks a budget window saw into one compiled task.
489    pub fn close_budget_window(&self, compiled_task_id: impl ToString, window: BudgetWindow) {
490        self.observation.close_budget_window(&self.plan_execution_id, window, &compiled_task_id.to_string());
491    }
492
493    /// Records execution of a compiled pipeline task.
494    pub fn record_compiled_pipeline_task_execution(
495        &self,
496        compiled_task_id: impl ToString,
497        operator_kind: impl Into<String>,
498    ) {
499        let compiled_task_id = compiled_task_id.to_string();
500        let operator_kind = operator_kind.into();
501        self.apply(|metrics| metrics.record_compiled_pipeline_task_execution(compiled_task_id, operator_kind));
502    }
503
504    /// Records one compiled task's plan mapping.
505    pub fn record_compiled_pipeline_task_mapping(
506        &self,
507        compiled_task_id: impl ToString,
508        operator_kind: impl Into<String>,
509        plan_node_id: Option<impl Into<PlanNodeId>>,
510    ) {
511        let compiled_task_id = compiled_task_id.to_string();
512        let operator_kind = operator_kind.into();
513        let plan_node_id = plan_node_id.map(Into::into);
514        self.apply(|metrics| {
515            metrics.record_compiled_pipeline_task_mapping(compiled_task_id, operator_kind, plan_node_id);
516        });
517    }
518
519    /// Records elapsed time for one task.
520    pub fn record_task_elapsed(&self, task_id: impl ToString, operator_kind: impl Into<String>, elapsed: Duration) {
521        let task_id = task_id.to_string();
522        let operator_kind = operator_kind.into();
523        self.apply(|metrics| metrics.record_task_elapsed(task_id, operator_kind, elapsed));
524    }
525
526    /// Records a resource-exhausted retry.
527    pub fn record_retry_resource_exhausted(&self) {
528        self.apply(QueryMetrics::record_retry_resource_exhausted);
529    }
530
531    /// Records one compiled pipeline attempt.
532    pub fn record_compiled_pipeline_attempt(&self) {
533        self.apply(QueryMetrics::record_compiled_pipeline_attempt);
534    }
535
536    /// Records executed compiled pipeline tasks.
537    pub fn record_compiled_pipeline_tasks_executed(&self, tasks: u64) {
538        self.apply(|metrics| metrics.record_compiled_pipeline_tasks_executed(tasks));
539    }
540
541    /// Records a compiled pipeline host Arrow sink.
542    pub fn record_compiled_pipeline_host_arrow_sink(&self) {
543        self.apply(QueryMetrics::record_compiled_pipeline_host_arrow_sink);
544    }
545}
546
547/// How far a plan execution has progressed, as reported in diagnostic facts.
548#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
549pub struct ExecutionProgress {
550    /// Source chunks planned.
551    pub source_chunks_planned: u64,
552    /// Source chunks committed.
553    pub source_chunks_committed: u64,
554    /// Partial aggregates recorded.
555    pub partial_aggregate_count: u64,
556}
557
558fn saturating_add_duration(total: &mut Duration, value: Duration) {
559    *total = total.checked_add(value).unwrap_or(Duration::MAX);
560}