Skip to main content

radixdb_executor/optimizer/
feedback.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Cardinality Feedback for Query Optimization
16//!
17//! This module implements a learning system that improves cardinality estimates
18//! by tracking the difference between estimated and actual row counts during
19//! query execution. When similar predicates are seen again, the correction
20//! factors are applied to produce more accurate estimates.
21//!
22//! ## How It Works
23//!
24//! 1. During EXPLAIN ANALYZE, we record estimated vs actual row counts
25//! 2. A fingerprint is computed for each predicate pattern (structure, not values)
26//! 3. Correction factors are stored: `correction = actual / estimated`
27//! 4. Future queries with similar patterns use the correction factor
28//!
29//! ## Example
30//!
31//! ```text
32//! -- First query: estimated 100, actual 1000 → correction = 10.0
33//! EXPLAIN ANALYZE SELECT * FROM users WHERE status = 'active';
34//!
35//! -- Later query: base estimate 50, corrected estimate 500
36//! SELECT * FROM users WHERE status = 'pending';
37//! ```
38
39use radixdb_core::time_compat::{system_time_now, UNIX_EPOCH};
40use rustc_hash::{FxHashMap, FxHasher};
41use std::hash::{Hash, Hasher};
42use std::sync::RwLock;
43
44use radixdb_sql::ast::Expression;
45
46/// Default decay factor for exponential moving average
47pub const DEFAULT_DECAY_FACTOR: f64 = 0.3;
48
49/// Minimum sample count before applying feedback
50pub const MIN_SAMPLE_COUNT: u64 = 2;
51
52/// Maximum correction factor to prevent extreme adjustments
53pub const MAX_CORRECTION_FACTOR: f64 = 100.0;
54
55/// Minimum correction factor
56pub const MIN_CORRECTION_FACTOR: f64 = 0.01;
57
58#[inline]
59fn valid_decay_factor(decay_factor: f64) -> f64 {
60    if decay_factor.is_finite() && (0.0..=1.0).contains(&decay_factor) {
61        decay_factor
62    } else {
63        DEFAULT_DECAY_FACTOR
64    }
65}
66
67#[inline]
68fn apply_finite_correction(base_estimate: u64, correction: f64) -> u64 {
69    if !correction.is_finite() || correction <= 0.0 {
70        return base_estimate;
71    }
72    let corrected = base_estimate as f64 * correction;
73    if !corrected.is_finite() || corrected >= u64::MAX as f64 {
74        u64::MAX
75    } else {
76        (corrected.round() as u64).max(1)
77    }
78}
79
80/// Cardinality feedback entry for a predicate pattern
81#[derive(Debug, Clone)]
82pub struct CardinalityFeedback {
83    /// Hash of the predicate structure (not values)
84    pub predicate_hash: u64,
85    /// Table name this feedback applies to
86    pub table_name: String,
87    /// Column name (if applicable)
88    pub column_name: Option<String>,
89    /// Estimated row count from cost model
90    pub estimated_rows: u64,
91    /// Actual row count from execution
92    pub actual_rows: u64,
93    /// Correction factor (actual/estimated), smoothed over samples
94    pub correction_factor: f64,
95    /// Number of samples used to compute the correction
96    pub sample_count: u64,
97    /// Last update timestamp (nanoseconds since epoch)
98    pub last_updated: i64,
99}
100
101impl CardinalityFeedback {
102    /// Create a new feedback entry from estimated and actual row counts
103    pub fn new(
104        predicate_hash: u64,
105        table_name: impl Into<String>,
106        column_name: Option<String>,
107        estimated_rows: u64,
108        actual_rows: u64,
109    ) -> Self {
110        let correction = if estimated_rows > 0 {
111            (actual_rows as f64 / estimated_rows as f64)
112                .clamp(MIN_CORRECTION_FACTOR, MAX_CORRECTION_FACTOR)
113        } else {
114            1.0
115        };
116
117        Self {
118            predicate_hash,
119            table_name: table_name.into(),
120            column_name,
121            estimated_rows,
122            actual_rows,
123            correction_factor: correction,
124            sample_count: 1,
125            last_updated: get_current_timestamp(),
126        }
127    }
128
129    /// Update feedback with a new sample using exponential moving average
130    pub fn update(&mut self, estimated_rows: u64, actual_rows: u64, decay_factor: f64) {
131        let decay_factor = valid_decay_factor(decay_factor);
132        let new_correction = if estimated_rows > 0 {
133            (actual_rows as f64 / estimated_rows as f64)
134                .clamp(MIN_CORRECTION_FACTOR, MAX_CORRECTION_FACTOR)
135        } else {
136            1.0
137        };
138
139        // Exponential moving average: new = decay * new_sample + (1-decay) * old
140        self.correction_factor =
141            decay_factor * new_correction + (1.0 - decay_factor) * self.correction_factor;
142
143        // Clamp the correction factor
144        self.correction_factor = self
145            .correction_factor
146            .clamp(MIN_CORRECTION_FACTOR, MAX_CORRECTION_FACTOR);
147
148        self.estimated_rows = estimated_rows;
149        self.actual_rows = actual_rows;
150        self.sample_count += 1;
151        self.last_updated = get_current_timestamp();
152    }
153
154    /// Check if this feedback has enough samples to be reliable
155    pub fn is_reliable(&self) -> bool {
156        self.sample_count >= MIN_SAMPLE_COUNT
157    }
158
159    /// Apply correction to an estimate
160    pub fn apply_correction(&self, base_estimate: u64) -> u64 {
161        if !self.is_reliable() {
162            return base_estimate;
163        }
164        apply_finite_correction(base_estimate, self.correction_factor)
165    }
166}
167
168/// Cache for cardinality feedback entries
169#[derive(Debug)]
170pub struct FeedbackCache {
171    /// Feedback entries keyed by (table_name, predicate_hash)
172    entries: RwLock<FxHashMap<(String, u64), CardinalityFeedback>>,
173    /// Decay factor for EMA smoothing
174    decay_factor: f64,
175    /// Maximum number of entries to keep
176    max_entries: usize,
177}
178
179impl Default for FeedbackCache {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl FeedbackCache {
186    /// Create a new feedback cache with default settings
187    pub fn new() -> Self {
188        Self {
189            entries: RwLock::new(FxHashMap::default()),
190            decay_factor: DEFAULT_DECAY_FACTOR,
191            max_entries: 10000,
192        }
193    }
194
195    /// Create a cache with custom settings
196    pub fn with_settings(decay_factor: f64, max_entries: usize) -> Self {
197        Self {
198            entries: RwLock::new(FxHashMap::default()),
199            decay_factor: valid_decay_factor(decay_factor),
200            max_entries,
201        }
202    }
203
204    /// Record cardinality feedback for a predicate
205    pub fn record_feedback(
206        &self,
207        table_name: &str,
208        predicate_hash: u64,
209        column_name: Option<String>,
210        estimated_rows: u64,
211        actual_rows: u64,
212    ) {
213        if self.max_entries == 0 {
214            return;
215        }
216        let key = (table_name.to_string(), predicate_hash);
217
218        let mut entries = self.entries.write().unwrap();
219
220        if let Some(existing) = entries.get_mut(&key) {
221            existing.update(estimated_rows, actual_rows, self.decay_factor);
222        } else {
223            // Evict oldest entries if at capacity
224            if entries.len() >= self.max_entries {
225                self.evict_oldest(&mut entries);
226            }
227
228            let feedback = CardinalityFeedback::new(
229                predicate_hash,
230                table_name,
231                column_name,
232                estimated_rows,
233                actual_rows,
234            );
235            entries.insert(key, feedback);
236        }
237    }
238
239    /// Look up feedback for a predicate pattern
240    pub fn lookup(&self, table_name: &str, predicate_hash: u64) -> Option<CardinalityFeedback> {
241        let key = (table_name.to_string(), predicate_hash);
242        let entries = self.entries.read().unwrap();
243        entries.get(&key).cloned()
244    }
245
246    /// Get correction factor for a predicate, returns 1.0 if no feedback
247    pub fn get_correction(&self, table_name: &str, predicate_hash: u64) -> f64 {
248        match self.lookup(table_name, predicate_hash) {
249            Some(feedback)
250                if feedback.is_reliable()
251                    && feedback.correction_factor.is_finite()
252                    && feedback.correction_factor > 0.0 =>
253            {
254                feedback.correction_factor
255            }
256            _ => 1.0,
257        }
258    }
259
260    /// Apply correction to an estimate
261    pub fn apply_correction(&self, table_name: &str, predicate_hash: u64, estimate: u64) -> u64 {
262        let correction = self.get_correction(table_name, predicate_hash);
263        apply_finite_correction(estimate, correction)
264    }
265
266    /// Clear all feedback entries
267    pub fn clear(&self) {
268        self.entries.write().unwrap().clear();
269    }
270
271    /// Remove all learned corrections for a table after data or schema change.
272    pub fn invalidate_table(&self, table_name: &str) {
273        self.entries
274            .write()
275            .unwrap()
276            .retain(|(name, _), _| !name.eq_ignore_ascii_case(table_name));
277    }
278
279    /// Get the number of feedback entries
280    pub fn len(&self) -> usize {
281        self.entries.read().unwrap().len()
282    }
283
284    /// Check if cache is empty
285    pub fn is_empty(&self) -> bool {
286        self.entries.read().unwrap().is_empty()
287    }
288
289    /// Evict oldest entries to make room for new ones
290    fn evict_oldest(&self, entries: &mut FxHashMap<(String, u64), CardinalityFeedback>) {
291        // Find the oldest 10% of entries
292        let evict_count = (self.max_entries / 10).max(1);
293
294        let mut timestamps: Vec<_> = entries
295            .iter()
296            .map(|(k, v)| (k.clone(), v.last_updated))
297            .collect();
298
299        timestamps.sort_by_key(|(_, ts)| *ts);
300
301        for (key, _) in timestamps.into_iter().take(evict_count) {
302            entries.remove(&key);
303        }
304    }
305
306    /// Get all feedback entries for a table
307    pub fn get_table_feedback(&self, table_name: &str) -> Vec<CardinalityFeedback> {
308        let entries = self.entries.read().unwrap();
309        entries
310            .iter()
311            .filter(|((name, _), _)| name == table_name)
312            .map(|(_, fb)| fb.clone())
313            .collect()
314    }
315}
316
317/// Compute a fingerprint for a predicate expression
318///
319/// The fingerprint captures the structure of the predicate but not the literal
320/// values. This allows the same correction factor to be applied to:
321/// - `status = 'active'` and `status = 'pending'` (same structure)
322///
323/// Different structures produce different fingerprints:
324/// - `status = 'active'` vs `status = 'active' AND age > 30`
325///
326/// Uses FxHasher which is 2-5x faster than SipHash for small keys.
327pub fn fingerprint_predicate(table_name: &str, expr: &Expression) -> u64 {
328    let mut hasher = FxHasher::default();
329
330    // Include table name
331    table_name.hash(&mut hasher);
332
333    // Hash the expression structure (recursive)
334    hash_expression_structure(expr, &mut hasher);
335
336    hasher.finish()
337}
338
339/// Hash the structure of an expression (not literal values)
340fn hash_expression_structure(expr: &Expression, hasher: &mut FxHasher) {
341    // Hash the expression type discriminant
342    std::mem::discriminant(expr).hash(hasher);
343
344    match expr {
345        Expression::Identifier(id) => {
346            // Column names are part of structure
347            id.value.hash(hasher);
348        }
349        Expression::QualifiedIdentifier(qid) => {
350            // Table.column is structural
351            qid.qualifier.value.hash(hasher);
352            qid.name.value.hash(hasher);
353        }
354        Expression::Infix(infix) => {
355            // Operator type is structural
356            infix.op_type.hash(hasher);
357            hash_expression_structure(&infix.left, hasher);
358            hash_expression_structure(&infix.right, hasher);
359        }
360        Expression::Prefix(prefix) => {
361            prefix.op_type.hash(hasher);
362            hash_expression_structure(&prefix.right, hasher);
363        }
364        Expression::Between(between) => {
365            "BETWEEN".hash(hasher);
366            between.not.hash(hasher);
367            hash_expression_structure(&between.expr, hasher);
368            // Lower and upper structure matters
369            hash_expression_structure(&between.lower, hasher);
370            hash_expression_structure(&between.upper, hasher);
371        }
372        Expression::In(in_expr) => {
373            "IN".hash(hasher);
374            in_expr.not.hash(hasher);
375            hash_expression_structure(&in_expr.left, hasher);
376            // Hash the right side structure (list or subquery)
377            hash_expression_structure(&in_expr.right, hasher);
378        }
379        Expression::Like(like) => {
380            "LIKE".hash(hasher);
381            like.operator.hash(hasher);
382            hash_expression_structure(&like.left, hasher);
383            // Pattern structure (prefix vs contains vs suffix) could matter
384            // but for simplicity we just note it's a LIKE
385        }
386        Expression::FunctionCall(func) => {
387            "FUNCTION".hash(hasher);
388            func.function.hash(hasher);
389            func.arguments.len().hash(hasher);
390            for arg in &func.arguments {
391                hash_expression_structure(arg, hasher);
392            }
393        }
394        Expression::Case(case) => {
395            "CASE".hash(hasher);
396            case.when_clauses.len().hash(hasher);
397            case.else_value.is_some().hash(hasher);
398        }
399        Expression::Cast(cast) => {
400            "CAST".hash(hasher);
401            cast.type_name.hash(hasher);
402            hash_expression_structure(&cast.expr, hasher);
403        }
404        Expression::ScalarSubquery(_) => {
405            "SUBQUERY".hash(hasher);
406            // Subqueries are complex, just mark as present
407        }
408        Expression::Exists(_) => {
409            "EXISTS".hash(hasher);
410        }
411        // Literal values are part of the distribution identity. Reusing one
412        // correction across unrelated constants is not statistically sound.
413        Expression::IntegerLiteral(literal) => {
414            "INTEGER_LITERAL".hash(hasher);
415            literal.value.hash(hasher);
416        }
417        Expression::FloatLiteral(literal) => {
418            "FLOAT_LITERAL".hash(hasher);
419            literal.value.to_bits().hash(hasher);
420        }
421        Expression::StringLiteral(literal) => {
422            "STRING_LITERAL".hash(hasher);
423            literal.value.hash(hasher);
424        }
425        Expression::BooleanLiteral(literal) => {
426            "BOOLEAN_LITERAL".hash(hasher);
427            literal.value.hash(hasher);
428        }
429        Expression::NullLiteral(_) => {
430            "NULL_LITERAL".hash(hasher);
431        }
432        // Other expressions just mark as present
433        Expression::List(list) => {
434            "LIST".hash(hasher);
435            list.elements.len().hash(hasher);
436            for element in &list.elements {
437                hash_expression_structure(element, hasher);
438            }
439        }
440        Expression::Star(_) => {
441            "STAR".hash(hasher);
442        }
443        _ => {
444            // For other types, just use the discriminant
445            "OTHER".hash(hasher);
446        }
447    }
448}
449
450/// Extract the column name from a simple predicate (col = value)
451pub fn extract_column_from_predicate(expr: &Expression) -> Option<String> {
452    match expr {
453        Expression::Infix(infix) => {
454            // Check if left side is a column
455            if let Expression::Identifier(id) = &*infix.left {
456                return Some(id.value.to_string());
457            }
458            if let Expression::QualifiedIdentifier(qid) = &*infix.left {
459                return Some(qid.name.value.to_string());
460            }
461            // Check if right side is a column (for reversed comparisons)
462            if let Expression::Identifier(id) = &*infix.right {
463                return Some(id.value.to_string());
464            }
465            if let Expression::QualifiedIdentifier(qid) = &*infix.right {
466                return Some(qid.name.value.to_string());
467            }
468            None
469        }
470        Expression::Between(between) => {
471            if let Expression::Identifier(id) = &*between.expr {
472                return Some(id.value.to_string());
473            }
474            if let Expression::QualifiedIdentifier(qid) = &*between.expr {
475                return Some(qid.name.value.to_string());
476            }
477            None
478        }
479        Expression::In(in_expr) => {
480            if let Expression::Identifier(id) = &*in_expr.left {
481                return Some(id.value.to_string());
482            }
483            if let Expression::QualifiedIdentifier(qid) = &*in_expr.left {
484                return Some(qid.name.value.to_string());
485            }
486            None
487        }
488        Expression::Like(like) => {
489            if let Expression::Identifier(id) = &*like.left {
490                return Some(id.value.to_string());
491            }
492            if let Expression::QualifiedIdentifier(qid) = &*like.left {
493                return Some(qid.name.value.to_string());
494            }
495            None
496        }
497        // IS NULL is expressed via Infix expression with Is/IsNot operator
498        // The column extraction from Infix already handles this case
499        _ => None,
500    }
501}
502
503/// Get current timestamp in nanoseconds
504fn get_current_timestamp() -> i64 {
505    system_time_now()
506        .duration_since(UNIX_EPOCH)
507        .map(|d| d.as_nanos() as i64)
508        .unwrap_or(0)
509}
510
511/// Global feedback cache (singleton pattern for easy access)
512static FEEDBACK_CACHE: std::sync::OnceLock<FeedbackCache> = std::sync::OnceLock::new();
513
514/// Get the global feedback cache
515pub fn global_feedback_cache() -> &'static FeedbackCache {
516    FEEDBACK_CACHE.get_or_init(FeedbackCache::new)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use radixdb_sql::ast::{InfixExpression, InfixOperator};
523    use radixdb_sql::{Identifier, IntegerLiteral, Position, Token, TokenType};
524
525    fn make_token(literal: &str) -> Token {
526        Token::new(TokenType::Identifier, literal, Position::new(0, 1, 1))
527    }
528
529    fn make_identifier(name: &str) -> Expression {
530        Expression::Identifier(Identifier::new(make_token(name), name.to_string()))
531    }
532
533    fn make_literal_int(val: i64) -> Expression {
534        Expression::IntegerLiteral(IntegerLiteral {
535            token: Token::new(TokenType::Integer, val.to_string(), Position::new(0, 1, 1)),
536            value: val,
537        })
538    }
539
540    fn make_equality(col: &str, val: i64) -> Expression {
541        Expression::Infix(InfixExpression {
542            token: Token::new(TokenType::Operator, "=", Position::new(0, 1, 1)),
543            left: Box::new(make_identifier(col)),
544            operator: "=".into(),
545            op_type: InfixOperator::Equal,
546            right: Box::new(make_literal_int(val)),
547        })
548    }
549
550    #[test]
551    fn test_feedback_entry_creation() {
552        let fb = CardinalityFeedback::new(12345, "users", None, 100, 1000);
553        assert_eq!(fb.correction_factor, 10.0);
554        assert_eq!(fb.sample_count, 1);
555        assert!(!fb.is_reliable()); // Need MIN_SAMPLE_COUNT samples
556    }
557
558    #[test]
559    fn test_feedback_update_ema() {
560        let mut fb = CardinalityFeedback::new(12345, "users", None, 100, 1000);
561        // correction = 10.0
562
563        // Second sample: estimated 100, actual 100 → new_correction = 1.0
564        fb.update(100, 100, DEFAULT_DECAY_FACTOR);
565        // EMA: 0.3 * 1.0 + 0.7 * 10.0 = 7.3
566        assert!((fb.correction_factor - 7.3).abs() < 0.001);
567        assert_eq!(fb.sample_count, 2);
568        assert!(fb.is_reliable());
569    }
570
571    #[test]
572    fn test_feedback_cache() {
573        let cache = FeedbackCache::new();
574
575        // Record feedback
576        cache.record_feedback("users", 12345, Some("status".to_string()), 100, 1000);
577
578        // First sample not reliable yet
579        assert_eq!(cache.get_correction("users", 12345), 1.0);
580
581        // Add second sample
582        cache.record_feedback("users", 12345, Some("status".to_string()), 100, 1000);
583
584        // Now should have correction
585        let correction = cache.get_correction("users", 12345);
586        assert!(correction > 1.0);
587    }
588
589    #[test]
590    fn test_fingerprint_includes_literal_distribution() {
591        // Equal shape with different constants may have unrelated selectivity.
592        let pred1 = make_equality("status", 1);
593        let pred2 = make_equality("status", 2);
594
595        let hash1 = fingerprint_predicate("users", &pred1);
596        let hash2 = fingerprint_predicate("users", &pred2);
597
598        assert_ne!(hash1, hash2);
599    }
600
601    #[test]
602    fn test_fingerprint_different_columns() {
603        // Different columns should have different hashes
604        let pred1 = make_equality("status", 1);
605        let pred2 = make_equality("role", 1);
606
607        let hash1 = fingerprint_predicate("users", &pred1);
608        let hash2 = fingerprint_predicate("users", &pred2);
609
610        assert_ne!(hash1, hash2);
611    }
612
613    #[test]
614    fn test_fingerprint_different_tables() {
615        // Same predicate on different tables should have different hashes
616        let pred = make_equality("status", 1);
617
618        let hash1 = fingerprint_predicate("users", &pred);
619        let hash2 = fingerprint_predicate("orders", &pred);
620
621        assert_ne!(hash1, hash2);
622    }
623
624    #[test]
625    fn test_extract_column() {
626        let pred = make_equality("status", 1);
627        let col = extract_column_from_predicate(&pred);
628        assert_eq!(col, Some("status".to_string()));
629    }
630
631    #[test]
632    fn test_apply_correction() {
633        let cache = FeedbackCache::new();
634
635        // Record multiple samples to make it reliable
636        cache.record_feedback("users", 12345, None, 100, 500);
637        cache.record_feedback("users", 12345, None, 100, 500);
638
639        // Apply correction to a new estimate
640        let corrected = cache.apply_correction("users", 12345, 200);
641
642        // Should be > 200 because correction factor > 1
643        assert!(corrected > 200);
644    }
645
646    #[test]
647    fn v2_r5_small_capacity_and_invalid_decay_fail_safe() {
648        let disabled = FeedbackCache::with_settings(0.3, 0);
649        disabled.record_feedback("t", 1, None, 1, 10);
650        assert_eq!(disabled.len(), 0);
651
652        let one = FeedbackCache::with_settings(0.3, 1);
653        one.record_feedback("t", 1, None, 1, 10);
654        one.record_feedback("t", 2, None, 1, 10);
655        assert_eq!(one.len(), 1);
656
657        for invalid in [f64::NAN, f64::INFINITY, -1.0, 2.0] {
658            let cache = FeedbackCache::with_settings(invalid, 4);
659            cache.record_feedback("t", 1, None, 10, 100);
660            cache.record_feedback("t", 1, None, 10, 100);
661            let corrected = cache.apply_correction("t", 1, 10);
662            assert!((1..=1000).contains(&corrected));
663        }
664
665        let mut poisoned = CardinalityFeedback::new(1, "t", None, 10, 10);
666        poisoned.sample_count = MIN_SAMPLE_COUNT;
667        poisoned.correction_factor = f64::NAN;
668        assert_eq!(poisoned.apply_correction(77), 77);
669    }
670}