Skip to main content

linkrs_query/optimizer/stats/feedback/
query.rs

1//! Query Feedback Structure Module
2//!
3//! A data structure that provides feedback on the execution of a query, including feedback on the operators used and details regarding the query execution itself.
4
5use std::time::Instant;
6
7/// Operator execution feedback
8///
9/// Records statistical information about the execution of individual operators.
10/// Refer to the output format of EXPLAIN ANALYZE in PostgreSQL.
11///
12/// # Field Description
13/// `operator_id`: The unique identifier for the operator.
14/// `operator_type`: The type of the operator (such as Scan, Filter, Join, etc.)
15/// `estimated_rows`: The number of output rows estimated by the optimizer.
16/// `actual_rows`: The actual number of rows output.
17/// estimated_time_us: Estimated execution time (in microseconds)
18/// `actual_time_us`: Actual execution time (in microseconds)
19/// `execution_loops`: The number of executions (for example, the number of times the inner loop of a Nested Loop is executed).
20///
21/// # Example
22/// ```
23/// use linkrs_query::optimizer::stats::feedback::query::OperatorFeedback;
24///
25/// let feedback = OperatorFeedback {
26///     operator_id: "scan_1".to_string(),
27///     operator_type: "IndexScan".to_string(),
28///     estimated_rows: 100,
29///     actual_rows: 150,
30///     actual_input_rows: None,
31///     partition_count: 1,
32///     exhausted: true,
33///     estimated_time_us: 1000,
34///     actual_time_us: 1200,
35///     execution_loops: 1,
36///     condition_key: None,
37///     shape_key: None,
38/// };
39///
40/// assert_eq!(feedback.row_estimation_error(), 0.5); // (150-100)/100
41/// ```
42#[derive(Debug, Clone)]
43pub struct OperatorFeedback {
44    /// Operator ID
45    pub operator_id: String,
46    /// Operator types
47    pub operator_type: String,
48    /// Estimated number of output lines
49    pub estimated_rows: u64,
50    /// Number of actual lines of output
51    pub actual_rows: u64,
52    /// Actual input rows consumed by this operator, summed across
53    /// partitions. Lets the fold loop isolate the operator-local
54    /// parameter (e.g. observed selectivity) from upstream cardinality
55    /// error. `None` means unmeasured: the sample cannot separate
56    /// predicate error and must not train selectivity.
57    pub actual_input_rows: Option<u64>,
58    /// How many partition instances this sample aggregates. Always >= 1;
59    /// greater than 1 means per-partition profiles were summed into one
60    /// per-execution sample before any correction was computed.
61    pub partition_count: u32,
62    /// Whether every aggregated instance ran to exhaustion. Samples from
63    /// starved operators (Limit / early termination) carry partial counts
64    /// and must not train the feedback loop.
65    pub exhausted: bool,
66    /// Estimated execution time (in microseconds)
67    pub estimated_time_us: u64,
68    /// Actual execution time (in microseconds)
69    pub actual_time_us: u64,
70    /// Number of executions (for example, the number of times the inner loop of a nested loop is executed)
71    pub execution_loops: u64,
72    /// Normalized predicate key for filter operators.
73    ///
74    /// Populated only for operators whose spec carries a predicate (e.g.
75    /// `UnarySpec::Filter`); used by the statistics feedback loop
76    /// to attach estimated-vs-actual row ratios to a specific predicate.
77    pub condition_key: Option<String>,
78    /// Normalized operator shape key (`"{space}:{Type}:{discriminator}"`).
79    ///
80    /// Populated for operators whose output cardinality is estimated
81    /// independently (scans, graph traversals, joins, applies); used by the
82    /// cardinality feedback loop to correct per-shape row estimates.  Filter
83    /// operators are keyed by `condition_key` instead and leave this `None`.
84    pub shape_key: Option<String>,
85}
86
87impl OperatorFeedback {
88    /// Create new operator feedback.
89    pub fn new(
90        operator_id: String,
91        operator_type: String,
92        estimated_rows: u64,
93        actual_rows: u64,
94    ) -> Self {
95        Self {
96            operator_id,
97            operator_type,
98            estimated_rows,
99            actual_rows,
100            estimated_time_us: 0,
101            actual_time_us: 0,
102            execution_loops: 1,
103            actual_input_rows: None,
104            partition_count: 1,
105            exhausted: true,
106            condition_key: None,
107            shape_key: None,
108        }
109    }
110
111    /// Estimation error of the number of lines calculated
112    ///
113    /// Return the relative error: (Actual – Estimated) / Estimated
114    /// If the estimate is 0, return 1.0
115    pub fn row_estimation_error(&self) -> f64 {
116        if self.estimated_rows == 0 {
117            return 1.0;
118        }
119        let estimated = self.estimated_rows as f64;
120        let actual = self.actual_rows as f64;
121        ((actual - estimated).abs() / estimated).min(10.0)
122    }
123
124    /// Calculating the estimated error in time measurement
125    ///
126    /// Return relative error: (actual-estimate)/estimate
127    pub fn time_estimation_error(&self) -> f64 {
128        if self.estimated_time_us == 0 {
129            return 1.0;
130        }
131        let estimated = self.estimated_time_us as f64;
132        let actual = self.actual_time_us as f64;
133        ((actual - estimated).abs() / estimated).min(10.0)
134    }
135
136    /// Obtain the average number of actual rows executed in each case.
137    ///
138    /// For operators that are executed multiple times (such as the inner loop in a Nested Loop),
139    /// Return the average number of lines executed per run.
140    pub fn avg_rows_per_loop(&self) -> f64 {
141        if self.execution_loops == 0 {
142            return 0.0;
143        }
144        self.actual_rows as f64 / self.execution_loops as f64
145    }
146
147    /// Obtain the average actual time (in microseconds) for each execution.
148    pub fn avg_time_us_per_loop(&self) -> f64 {
149        if self.execution_loops == 0 {
150            return 0.0;
151        }
152        self.actual_time_us as f64 / self.execution_loops as f64
153    }
154}
155
156/// Query execution feedback
157///
158/// Record the complete feedback information for each query execution.
159///
160/// # Examples
161/// ```
162/// use linkrs_query::optimizer::stats::feedback::query::QueryExecutionFeedback;
163///
164/// let mut feedback = QueryExecutionFeedback::new("query_fp_123".to_string());
165/// feedback.estimated_rows = 1000;
166/// feedback.actual_rows = 1200;
167/// feedback.estimated_time_us = 5000;
168/// feedback.actual_time_us = 6000;
169///
170/// assert!(feedback.row_estimation_error() > 0.0);
171/// ```
172#[derive(Debug, Clone)]
173pub struct QueryExecutionFeedback {
174    /// Querying fingerprints
175    pub query_fingerprint: String,
176    /// Monotonic sequence assigned by [`QueryFeedbackHistory`] on insert.
177    /// Lets the fold loop consume each execution exactly once instead of
178    /// re-applying the whole retained history on every optimization.
179    pub sequence: u64,
180    /// Target space name (if any), used to scope selectivity corrections.
181    pub space: Option<String>,
182    /// Estimated number of output lines
183    pub estimated_rows: u64,
184    /// Actual number of output lines
185    pub actual_rows: u64,
186    /// Estimated execution time (microseconds)
187    pub estimated_time_us: u64,
188    /// Actual execution time (microseconds)
189    pub actual_time_us: u64,
190    /// Execution timestamp
191    pub execution_timestamp: Instant,
192    /// Actual rows produced by Apply-style operators (nested-loop paths).
193    ///
194    /// Populated from the execution profile; used to contrast the nested-loop
195    /// execution cost of kept `PatternApply` subqueries against the hash
196    /// `SemiJoin` rows of unnested plans.
197    pub apply_rows: u64,
198    /// Actual rows produced by `SemiJoin` operators (unnested hash paths).
199    pub join_rows: u64,
200    /// Actual time (microseconds) spent in Apply-style operators.
201    pub apply_time_us: u64,
202    /// Actual time (microseconds) spent in `SemiJoin` operators.
203    pub join_time_us: u64,
204    /// Feedback from each operator
205    pub operator_feedbacks: Vec<OperatorFeedback>,
206}
207
208impl QueryExecutionFeedback {
209    /// Create new feedback for query execution.
210    pub fn new(query_fingerprint: String) -> Self {
211        Self {
212            query_fingerprint,
213            sequence: 0,
214            space: None,
215            estimated_rows: 0,
216            actual_rows: 0,
217            estimated_time_us: 0,
218            actual_time_us: 0,
219            execution_timestamp: Instant::now(),
220            apply_rows: 0,
221            join_rows: 0,
222            apply_time_us: 0,
223            join_time_us: 0,
224            operator_feedbacks: Vec::new(),
225        }
226    }
227
228    /// Estimated error in calculating the number of rows
229    pub fn row_estimation_error(&self) -> f64 {
230        if self.estimated_rows == 0 {
231            return 1.0;
232        }
233        let estimated = self.estimated_rows as f64;
234        let actual = self.actual_rows as f64;
235        ((actual - estimated).abs() / estimated).min(10.0)
236    }
237
238    /// Calculation time estimation error
239    pub fn time_estimation_error(&self) -> f64 {
240        if self.estimated_time_us == 0 {
241            return 1.0;
242        }
243        let estimated = self.estimated_time_us as f64;
244        let actual = self.actual_time_us as f64;
245        ((actual - estimated).abs() / estimated).min(10.0)
246    }
247
248    /// Add operator feedback
249    pub fn add_operator_feedback(&mut self, feedback: OperatorFeedback) {
250        self.operator_feedbacks.push(feedback);
251    }
252
253    /// Number of operator feedbacks obtained
254    pub fn operator_feedback_count(&self) -> usize {
255        self.operator_feedbacks.len()
256    }
257
258    /// Obtaining feedback on a specific operator
259    pub fn get_operator_feedback(&self, operator_id: &str) -> Option<&OperatorFeedback> {
260        self.operator_feedbacks
261            .iter()
262            .find(|f| f.operator_id == operator_id)
263    }
264
265    /// Calculate the average estimation error for the number of rows of all operators.
266    pub fn avg_operator_row_error(&self) -> f64 {
267        if self.operator_feedbacks.is_empty() {
268            return 0.0;
269        }
270        let total_error: f64 = self
271            .operator_feedbacks
272            .iter()
273            .map(|f| f.row_estimation_error())
274            .sum();
275        total_error / self.operator_feedbacks.len() as f64
276    }
277
278    /// Calculate the average time estimation error for all operators.
279    pub fn avg_operator_time_error(&self) -> f64 {
280        if self.operator_feedbacks.is_empty() {
281            return 0.0;
282        }
283        let total_error: f64 = self
284            .operator_feedbacks
285            .iter()
286            .map(|f| f.time_estimation_error())
287            .sum();
288        total_error / self.operator_feedbacks.len() as f64
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_operator_feedback() {
298        let feedback = OperatorFeedback {
299            operator_id: "scan_1".to_string(),
300            operator_type: "IndexScan".to_string(),
301            estimated_rows: 100,
302            actual_rows: 150,
303            estimated_time_us: 1000,
304            actual_time_us: 1200,
305            execution_loops: 1,
306            actual_input_rows: None,
307            partition_count: 1,
308            exhausted: true,
309            condition_key: None,
310            shape_key: None,
311        };
312
313        assert_eq!(feedback.row_estimation_error(), 0.5); // (150-100)/100
314        assert_eq!(feedback.time_estimation_error(), 0.2); // (1200-1000)/1000
315    }
316
317    #[test]
318    fn test_operator_feedback_loops() {
319        let feedback = OperatorFeedback {
320            operator_id: "nested_loop_inner".to_string(),
321            operator_type: "IndexScan".to_string(),
322            estimated_rows: 100,
323            actual_rows: 500, // In total, 500 lines were processed, and this operation was performed 10 times.
324            estimated_time_us: 1000,
325            actual_time_us: 5000,
326            execution_loops: 10,
327            actual_input_rows: None,
328            partition_count: 1,
329            exhausted: true,
330            condition_key: None,
331            shape_key: None,
332        };
333
334        assert_eq!(feedback.avg_rows_per_loop(), 50.0); // 500/10
335        assert_eq!(feedback.avg_time_us_per_loop(), 500.0); // 5000/10
336    }
337
338    #[test]
339    fn test_query_execution_feedback() {
340        let mut feedback = QueryExecutionFeedback::new("fp_123".to_string());
341        feedback.estimated_rows = 1000;
342        feedback.actual_rows = 1200;
343        feedback.estimated_time_us = 5000;
344        feedback.actual_time_us = 6000;
345
346        // Add operator feedback
347        let op_feedback = OperatorFeedback::new("scan_1".into(), "SeqScan".to_string(), 1000, 1200);
348        feedback.add_operator_feedback(op_feedback);
349
350        assert_eq!(feedback.operator_feedback_count(), 1);
351        assert!(feedback.row_estimation_error() > 0.0);
352        assert!(feedback.time_estimation_error() > 0.0);
353    }
354
355    #[test]
356    fn test_avg_operator_errors() {
357        let mut feedback = QueryExecutionFeedback::new("fp_123".to_string());
358
359        // Add feedback for two operators.
360        feedback.add_operator_feedback(OperatorFeedback {
361            operator_id: "op1".to_string(),
362            operator_type: "Scan".to_string(),
363            estimated_rows: 100,
364            actual_rows: 110,
365            estimated_time_us: 1000,
366            actual_time_us: 1100,
367            execution_loops: 1,
368            actual_input_rows: None,
369            partition_count: 1,
370            exhausted: true,
371            condition_key: None,
372            shape_key: None,
373        });
374
375        feedback.add_operator_feedback(OperatorFeedback {
376            operator_id: "op2".to_string(),
377            operator_type: "Filter".to_string(),
378            estimated_rows: 100,
379            actual_rows: 90,
380            estimated_time_us: 500,
381            actual_time_us: 450,
382            execution_loops: 1,
383            actual_input_rows: None,
384            partition_count: 1,
385            exhausted: true,
386            condition_key: None,
387            shape_key: None,
388        });
389
390        let avg_row_error = feedback.avg_operator_row_error();
391        let avg_time_error = feedback.avg_operator_time_error();
392
393        assert!(avg_row_error > 0.0);
394        assert!(avg_time_error > 0.0);
395    }
396}