Skip to main content

oxirs_tdb/
adaptive_execution.rs

1//! Adaptive Query Execution
2//!
3//! This module provides runtime query plan adaptation based on actual execution
4//! statistics. It monitors query performance and dynamically adjusts execution
5//! strategies to improve performance.
6//!
7//! ## Features
8//! - Runtime cardinality tracking
9//! - Dynamic plan switching
10//! - Execution statistics collection
11//! - Learning from query history
12//! - Automatic re-optimization on plan deviation
13
14use crate::dictionary::NodeId;
15use crate::error::{Result, TdbError};
16use crate::query_hints::IndexType;
17use crate::query_optimizer::{QueryOptimizer, QueryPattern, QueryPlan};
18use parking_lot::RwLock;
19use std::collections::HashMap;
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22
23/// Adaptive execution engine
24pub struct AdaptiveExecutor {
25    /// Query optimizer for plan generation
26    optimizer: Arc<QueryOptimizer>,
27    /// Execution history tracking
28    history: Arc<RwLock<ExecutionHistory>>,
29    /// Configuration
30    config: AdaptiveConfig,
31}
32
33/// Configuration for adaptive execution
34#[derive(Debug, Clone)]
35pub struct AdaptiveConfig {
36    /// Enable adaptive execution
37    pub enabled: bool,
38    /// Threshold for plan deviation before reoptimization (ratio)
39    pub reoptimize_threshold: f64,
40    /// Minimum samples before adaptation
41    pub min_samples: usize,
42    /// Maximum history entries per pattern
43    pub max_history_entries: usize,
44    /// Enable mid-execution plan switching
45    pub enable_plan_switching: bool,
46}
47
48impl Default for AdaptiveConfig {
49    fn default() -> Self {
50        Self {
51            enabled: true,
52            reoptimize_threshold: 2.0, // Reoptimize if actual results are 2x estimate
53            min_samples: 3,
54            max_history_entries: 100,
55            enable_plan_switching: true,
56        }
57    }
58}
59
60/// Execution statistics for a query
61#[derive(Debug, Clone)]
62pub struct ExecutionStats {
63    /// Pattern executed
64    pub pattern: QueryPattern,
65    /// Plan used
66    pub index_used: IndexType,
67    /// Actual execution time
68    pub execution_time: Duration,
69    /// Actual number of results
70    pub actual_results: usize,
71    /// Estimated results (from plan)
72    pub estimated_results: usize,
73    /// Whether plan was optimal
74    pub was_optimal: bool,
75    /// Timestamp of execution
76    pub timestamp: Instant,
77}
78
79impl ExecutionStats {
80    /// Calculate estimation error ratio (1.0 = perfect, >1.0 = underestimate, <1.0 = overestimate)
81    pub fn estimation_error_ratio(&self) -> f64 {
82        if self.estimated_results == 0 {
83            return if self.actual_results == 0 {
84                1.0
85            } else {
86                f64::INFINITY
87            };
88        }
89        self.actual_results as f64 / self.estimated_results as f64
90    }
91
92    /// Check if plan significantly deviated from estimate
93    pub fn has_significant_deviation(&self, threshold: f64) -> bool {
94        let ratio = self.estimation_error_ratio();
95        ratio > threshold || ratio < (1.0 / threshold)
96    }
97}
98
99/// Execution history tracker
100#[derive(Debug, Default)]
101struct ExecutionHistory {
102    /// Map from pattern to execution statistics
103    entries: HashMap<QueryPattern, Vec<ExecutionStats>>,
104}
105
106impl ExecutionHistory {
107    /// Add execution statistics
108    fn add_entry(&mut self, stats: ExecutionStats, max_entries: usize) {
109        let entries = self.entries.entry(stats.pattern.clone()).or_default();
110
111        entries.push(stats);
112
113        // Limit history size
114        if entries.len() > max_entries {
115            entries.remove(0);
116        }
117    }
118
119    /// Get average execution time for a pattern
120    fn avg_execution_time(&self, pattern: &QueryPattern) -> Option<Duration> {
121        let entries = self.entries.get(pattern)?;
122        if entries.is_empty() {
123            return None;
124        }
125
126        let total_nanos: u128 = entries.iter().map(|e| e.execution_time.as_nanos()).sum();
127        let avg_nanos = total_nanos / entries.len() as u128;
128
129        Some(Duration::from_nanos(avg_nanos as u64))
130    }
131
132    /// Get average estimation error ratio for a pattern
133    fn avg_estimation_error(&self, pattern: &QueryPattern) -> Option<f64> {
134        let entries = self.entries.get(pattern)?;
135        if entries.is_empty() {
136            return None;
137        }
138
139        let total_error: f64 = entries.iter().map(|e| e.estimation_error_ratio()).sum();
140        Some(total_error / entries.len() as f64)
141    }
142
143    /// Get number of samples for a pattern
144    fn sample_count(&self, pattern: &QueryPattern) -> usize {
145        self.entries.get(pattern).map_or(0, |e| e.len())
146    }
147
148    /// Check if pattern has enough history for adaptation
149    fn has_sufficient_history(&self, pattern: &QueryPattern, min_samples: usize) -> bool {
150        self.sample_count(pattern) >= min_samples
151    }
152}
153
154impl AdaptiveExecutor {
155    /// Create a new adaptive executor
156    pub fn new(optimizer: Arc<QueryOptimizer>) -> Self {
157        Self {
158            optimizer,
159            history: Arc::new(RwLock::new(ExecutionHistory::default())),
160            config: AdaptiveConfig::default(),
161        }
162    }
163
164    /// Create with custom configuration
165    pub fn with_config(optimizer: Arc<QueryOptimizer>, config: AdaptiveConfig) -> Self {
166        Self {
167            optimizer,
168            history: Arc::new(RwLock::new(ExecutionHistory::default())),
169            config,
170        }
171    }
172
173    /// Generate initial query plan with adaptive hints
174    pub fn create_plan(&self, pattern: QueryPattern) -> Result<QueryPlan> {
175        if !self.config.enabled {
176            // Fallback to standard optimization
177            let hints = crate::query_hints::QueryHints::new();
178            return self.optimizer.optimize(pattern, &hints);
179        }
180
181        // Check execution history
182        let history = self.history.read();
183        if history.has_sufficient_history(&pattern, self.config.min_samples) {
184            // Use historical data to improve estimates
185            if let Some(avg_error) = history.avg_estimation_error(&pattern) {
186                // Create plan with adjusted estimates
187                let mut hints = crate::query_hints::QueryHints::new();
188
189                // Apply learned correction factor
190                if avg_error > self.config.reoptimize_threshold {
191                    // Disable caching for queries with poor estimates
192                    hints = hints.with_caching(false);
193                }
194
195                return self.optimizer.optimize(pattern, &hints);
196            }
197        }
198
199        // No history or insufficient samples - use standard optimization
200        let hints = crate::query_hints::QueryHints::new();
201        self.optimizer.optimize(pattern, &hints)
202    }
203
204    /// Record execution results for learning
205    pub fn record_execution(
206        &self,
207        pattern: QueryPattern,
208        plan: &QueryPlan,
209        actual_results: usize,
210        execution_time: Duration,
211    ) {
212        let stats = ExecutionStats {
213            pattern: pattern.clone(),
214            index_used: plan.index,
215            execution_time,
216            actual_results,
217            estimated_results: plan.estimated_results,
218            was_optimal: !self.should_reoptimize(plan, actual_results),
219            timestamp: Instant::now(),
220        };
221
222        let mut history = self.history.write();
223        history.add_entry(stats, self.config.max_history_entries);
224    }
225
226    /// Check if query should be reoptimized based on results
227    pub fn should_reoptimize(&self, plan: &QueryPlan, actual_results: usize) -> bool {
228        if !self.config.enabled {
229            return false;
230        }
231
232        let ratio = if plan.estimated_results == 0 {
233            if actual_results == 0 {
234                1.0
235            } else {
236                return true; // Significantly wrong
237            }
238        } else {
239            actual_results as f64 / plan.estimated_results as f64
240        };
241
242        ratio > self.config.reoptimize_threshold || ratio < (1.0 / self.config.reoptimize_threshold)
243    }
244
245    /// Get execution statistics for a pattern
246    pub fn get_statistics(&self, pattern: &QueryPattern) -> Option<PatternStatistics> {
247        let history = self.history.read();
248
249        if !history.has_sufficient_history(pattern, self.config.min_samples) {
250            return None;
251        }
252
253        Some(PatternStatistics {
254            sample_count: history.sample_count(pattern),
255            avg_execution_time: history.avg_execution_time(pattern)?,
256            avg_estimation_error: history.avg_estimation_error(pattern)?,
257        })
258    }
259
260    /// Clear execution history
261    pub fn clear_history(&self) {
262        let mut history = self.history.write();
263        history.entries.clear();
264    }
265
266    /// Get total number of tracked patterns
267    pub fn tracked_patterns_count(&self) -> usize {
268        let history = self.history.read();
269        history.entries.len()
270    }
271
272    /// Get total number of execution samples
273    pub fn total_samples(&self) -> usize {
274        let history = self.history.read();
275        history.entries.values().map(|v| v.len()).sum()
276    }
277
278    /// Reoptimize query with updated estimates
279    pub fn reoptimize(&self, pattern: QueryPattern, actual_results: usize) -> Result<QueryPlan> {
280        // Use actual results to inform new plan
281        let mut hints = crate::query_hints::QueryHints::new();
282
283        // Adjust hints based on actual cardinality
284        if actual_results > 10000 {
285            // Disable caching for large result sets
286            hints = hints.with_caching(false);
287        }
288
289        self.optimizer.optimize(pattern, &hints)
290    }
291}
292
293/// Statistics summary for a query pattern
294#[derive(Debug, Clone)]
295pub struct PatternStatistics {
296    /// Number of execution samples
297    pub sample_count: usize,
298    /// Average execution time
299    pub avg_execution_time: Duration,
300    /// Average estimation error ratio
301    pub avg_estimation_error: f64,
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::statistics::{StatisticsConfig, TripleStatistics};
308
309    fn create_test_executor() -> AdaptiveExecutor {
310        let stats = Arc::new(TripleStatistics::new(StatisticsConfig::default()));
311        let optimizer = Arc::new(QueryOptimizer::new(stats));
312        AdaptiveExecutor::new(optimizer)
313    }
314
315    #[test]
316    fn test_adaptive_executor_creation() {
317        let executor = create_test_executor();
318        assert_eq!(executor.tracked_patterns_count(), 0);
319        assert_eq!(executor.total_samples(), 0);
320    }
321
322    #[test]
323    fn test_execution_stats_error_ratio() {
324        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
325        let stats = ExecutionStats {
326            pattern,
327            index_used: IndexType::SPO,
328            execution_time: Duration::from_millis(100),
329            actual_results: 200,
330            estimated_results: 100,
331            was_optimal: false,
332            timestamp: Instant::now(),
333        };
334
335        // Actual is 2x estimate
336        assert_eq!(stats.estimation_error_ratio(), 2.0);
337        assert!(stats.has_significant_deviation(1.5));
338    }
339
340    #[test]
341    fn test_record_and_retrieve_execution() {
342        let executor = create_test_executor();
343        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
344        let plan = QueryPlan::new(pattern.clone(), IndexType::SPO, 100);
345
346        // Record multiple executions
347        executor.record_execution(pattern.clone(), &plan, 100, Duration::from_millis(50));
348        executor.record_execution(pattern.clone(), &plan, 110, Duration::from_millis(55));
349        executor.record_execution(pattern.clone(), &plan, 90, Duration::from_millis(45));
350
351        // Should have statistics now
352        let stats = executor.get_statistics(&pattern);
353        assert!(stats.is_some());
354
355        let stats = stats.unwrap();
356        assert_eq!(stats.sample_count, 3);
357        assert!(stats.avg_execution_time.as_millis() >= 45);
358        assert!(stats.avg_execution_time.as_millis() <= 55);
359    }
360
361    #[test]
362    fn test_reoptimization_trigger() {
363        let executor = create_test_executor();
364        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
365        let plan = QueryPlan::new(pattern.clone(), IndexType::SPO, 100);
366
367        // Small deviation - should not reoptimize
368        assert!(!executor.should_reoptimize(&plan, 120));
369
370        // Large deviation - should reoptimize
371        assert!(executor.should_reoptimize(&plan, 300));
372
373        // Underestimate - should also reoptimize
374        assert!(executor.should_reoptimize(&plan, 30));
375    }
376
377    #[test]
378    fn test_history_size_limit() {
379        let config = AdaptiveConfig {
380            max_history_entries: 5,
381            ..Default::default()
382        };
383
384        let stats = Arc::new(TripleStatistics::new(StatisticsConfig::default()));
385        let optimizer = Arc::new(QueryOptimizer::new(stats));
386        let executor = AdaptiveExecutor::with_config(optimizer, config);
387
388        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
389        let plan = QueryPlan::new(pattern.clone(), IndexType::SPO, 100);
390
391        // Record 10 executions
392        for i in 0..10 {
393            executor.record_execution(pattern.clone(), &plan, 100 + i, Duration::from_millis(50));
394        }
395
396        // Should only keep last 5
397        let stats = executor.get_statistics(&pattern).unwrap();
398        assert_eq!(stats.sample_count, 5);
399    }
400
401    #[test]
402    fn test_disabled_adaptive_execution() {
403        let config = AdaptiveConfig {
404            enabled: false,
405            ..Default::default()
406        };
407
408        let stats = Arc::new(TripleStatistics::new(StatisticsConfig::default()));
409        let optimizer = Arc::new(QueryOptimizer::new(stats));
410        let executor = AdaptiveExecutor::with_config(optimizer, config);
411
412        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
413        let plan = QueryPlan::new(pattern.clone(), IndexType::SPO, 100);
414
415        // Should never trigger reoptimization when disabled
416        assert!(!executor.should_reoptimize(&plan, 1000));
417    }
418
419    #[test]
420    fn test_clear_history() {
421        let executor = create_test_executor();
422        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
423        let plan = QueryPlan::new(pattern.clone(), IndexType::SPO, 100);
424
425        // Record some executions
426        for _ in 0..5 {
427            executor.record_execution(pattern.clone(), &plan, 100, Duration::from_millis(50));
428        }
429
430        assert_eq!(executor.total_samples(), 5);
431
432        executor.clear_history();
433
434        assert_eq!(executor.total_samples(), 0);
435        assert_eq!(executor.tracked_patterns_count(), 0);
436    }
437
438    #[test]
439    fn test_insufficient_samples_no_stats() {
440        let executor = create_test_executor();
441        let pattern = QueryPattern::new(Some(NodeId::new(1)), None, None);
442        let plan = QueryPlan::new(pattern.clone(), IndexType::SPO, 100);
443
444        // Record only 2 executions (less than min_samples which is 3)
445        executor.record_execution(pattern.clone(), &plan, 100, Duration::from_millis(50));
446        executor.record_execution(pattern.clone(), &plan, 100, Duration::from_millis(50));
447
448        // Should not have statistics yet
449        assert!(executor.get_statistics(&pattern).is_none());
450    }
451
452    #[test]
453    fn test_pattern_specific_tracking() {
454        let executor = create_test_executor();
455
456        let pattern1 = QueryPattern::new(Some(NodeId::new(1)), None, None);
457        let pattern2 = QueryPattern::new(None, Some(NodeId::new(2)), None);
458
459        let plan1 = QueryPlan::new(pattern1.clone(), IndexType::SPO, 100);
460        let plan2 = QueryPlan::new(pattern2.clone(), IndexType::POS, 200);
461
462        // Record executions for both patterns
463        for _ in 0..3 {
464            executor.record_execution(pattern1.clone(), &plan1, 100, Duration::from_millis(50));
465            executor.record_execution(pattern2.clone(), &plan2, 200, Duration::from_millis(100));
466        }
467
468        // Should track both patterns separately
469        assert_eq!(executor.tracked_patterns_count(), 2);
470        assert_eq!(executor.total_samples(), 6);
471
472        let stats1 = executor.get_statistics(&pattern1).unwrap();
473        let stats2 = executor.get_statistics(&pattern2).unwrap();
474
475        assert_eq!(stats1.sample_count, 3);
476        assert_eq!(stats2.sample_count, 3);
477        assert!(stats1.avg_execution_time < stats2.avg_execution_time);
478    }
479}