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}