Skip to main content

linkrs_query/optimizer/engine/
feedback.rs

1use super::OptimizerEngine;
2use crate::optimizer::stats::feedback::query::OperatorFeedback;
3impl OptimizerEngine {
4    /// Fold execution feedback into selectivity/cardinality corrections.
5    ///
6    /// Consumes actual row counts from `feedback_history`, which execution
7    /// populates via `collect_execution_feedback` from runtime operator
8    /// profiles (estimated vs actual per shape/predicate). Observability
9    /// portraits (`QueryProfile` in metrics) and optimizer corrections
10    /// (`QueryExecutionFeedback` here) serve separate roles — human-facing
11    /// diagnostics vs EWMA estimate correction — and are both fed from the
12    /// same runtime profiles.
13    ///
14    /// Each execution is folded exactly once (per-fingerprint sequence
15    /// watermark): re-applying the retained history on every optimization
16    /// would walk the corrections over identical samples again. Samples
17    /// from starved operators or without a measurable input pairing are
18    /// dropped instead of folded.
19    pub fn maybe_apply_feedback(&self) {
20        if !self.enable_feedback {
21            return;
22        }
23        let history = self.feedback_history();
24        let mut applied = 0usize;
25        let mut skipped_truncated = 0usize;
26        let mut skipped_unpaired = 0usize;
27        let mut decision_runs = 0usize;
28        for fingerprint in history.get_all_fingerprints() {
29            let Some(avg_error) = history.get_avg_row_error(&fingerprint) else {
30                continue;
31            };
32            if !self.feedback_trigger.should_trigger(avg_error) {
33                continue;
34            }
35            let watermark = self
36                .feedback_applied_seq
37                .lock()
38                .get(&fingerprint)
39                .copied()
40                .unwrap_or(0);
41            let mut max_seq = watermark;
42            let mut folded_runs = 0usize;
43            for feedback in history.get_feedback_for_query(&fingerprint) {
44                if feedback.sequence <= watermark {
45                    continue;
46                }
47                max_seq = max_seq.max(feedback.sequence);
48                if feedback.apply_rows > 0 {
49                    self.decision_feedback.record_apply_run(
50                        space_of(&feedback),
51                        feedback.apply_rows,
52                        feedback.apply_time_us,
53                    );
54                    decision_runs += 1;
55                }
56                if feedback.join_rows > 0 {
57                    self.decision_feedback.record_join_run(
58                        space_of(&feedback),
59                        feedback.join_rows,
60                        feedback.join_time_us,
61                    );
62                    decision_runs += 1;
63                }
64                for op in &feedback.operator_feedbacks {
65                    if !op.exhausted {
66                        // Starved by Limit / early termination: partial
67                        // counts describe the cutoff, not the operator.
68                        skipped_truncated += 1;
69                        continue;
70                    }
71                    if let Some(key) = &op.condition_key {
72                        match self.measured_selectivity_ratio(key, op) {
73                            Some((ratio, input_rows)) => {
74                                if self
75                                    .selectivity_feedback
76                                    .update_measured_ratio(key, ratio, input_rows)
77                                {
78                                    applied += 1;
79                                }
80                            }
81                            None => {
82                                skipped_unpaired += 1;
83                            }
84                        }
85                    } else if let Some(key) = &op.shape_key {
86                        let ratio = op.actual_rows as f64 / op.estimated_rows.max(1) as f64;
87                        if self.cardinality_feedback.update_feedback_ratio(key, ratio) {
88                            applied += 1;
89                        }
90                    }
91                }
92                self.feedback_trigger.record_feedback();
93                folded_runs += 1;
94            }
95            if folded_runs > 0 {
96                self.feedback_applied_seq
97                    .lock()
98                    .insert(fingerprint, max_seq);
99                self.feedback_trigger.mark_updated();
100            }
101        }
102        if applied > 0 {
103            log::debug!(
104                "Feedback loop: applied {} selectivity/cardinality corrections from {} fingerprints ({} truncated, {} unpaired)",
105                applied,
106                history.query_count(),
107                skipped_truncated,
108                skipped_unpaired,
109            );
110        }
111        if decision_runs > 0 {
112            log::debug!(
113                "Feedback loop: folded {} Apply/Join decision runs from {} fingerprints",
114                decision_runs,
115                history.query_count()
116            );
117        }
118    }
119
120    /// Isolate the operator-local selectivity error for a filter sample.
121    ///
122    /// Returns the observed-vs-estimated selectivity ratio plus the measured
123    /// input rows. `None` when the pairing is unavailable (unknown key,
124    /// non-positive baseline, or missing/empty input measurement): such a
125    /// sample cannot separate predicate error from upstream cardinality
126    /// error and must not train the correction.
127    fn measured_selectivity_ratio(&self, key: &str, op: &OperatorFeedback) -> Option<(f64, u64)> {
128        let baseline = self
129            .selectivity_feedback
130            .get_feedback(key)?
131            .estimated_selectivity();
132        if baseline <= 0.0 {
133            return None;
134        }
135        let actual_in = op.actual_input_rows?;
136        if actual_in == 0 {
137            return None;
138        }
139        let observed = op.actual_rows as f64 / actual_in as f64;
140        Some((observed / baseline, actual_in))
141    }
142
143    /// Drop all feedback corrections scoped to `space` (`None` clears all).
144    ///
145    /// Called when a space's statistics are invalidated (ANALYZE force or
146    /// DDL commit) so stale corrections cannot mislead estimates after a
147    /// schema or data change.
148    pub fn invalidate_space_feedback(&self, space: Option<&str>) {
149        let removed = match space {
150            Some(space) => {
151                let removed = self
152                    .selectivity_feedback
153                    .remove_feedback_by_space(&format!("{}:", space));
154                removed
155                    + self
156                        .cardinality_feedback
157                        .remove_feedback_by_space(&format!("{}:", space))
158            }
159            None => {
160                let keys = self.selectivity_feedback.get_all_keys();
161                for key in &keys {
162                    self.selectivity_feedback.remove_feedback(key);
163                }
164                let cardinality_keys = self.cardinality_feedback.get_all_keys();
165                for key in &cardinality_keys {
166                    self.cardinality_feedback.remove_feedback(key);
167                }
168                keys.len() + cardinality_keys.len()
169            }
170        };
171        self.decision_feedback.invalidate_space(space);
172        if removed > 0 {
173            log::info!(
174                "Invalidated {} feedback corrections for space {:?}",
175                removed,
176                space
177            );
178        }
179    }
180}
181
182/// Target space of a collected execution, defaulting to unscoped.
183fn space_of(feedback: &crate::optimizer::stats::feedback::query::QueryExecutionFeedback) -> &str {
184    feedback.space.as_deref().unwrap_or("")
185}