Skip to main content

heliosdb_proxy/analytics/
patterns.rs

1//! Pattern Detection
2//!
3//! Detect problematic query patterns like N+1 queries and query bursts.
4
5use std::collections::VecDeque;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, Instant};
8
9use dashmap::DashMap;
10use parking_lot::RwLock;
11
12use super::config::PatternConfig;
13use super::fingerprinter::QueryFingerprint;
14use super::statistics::QueryExecution;
15
16/// Pattern alert types
17#[derive(Debug, Clone)]
18pub enum PatternAlert {
19    /// N+1 query detected
20    NplusOne(NplusOnePattern),
21    /// Query burst detected
22    Burst(QueryBurst),
23}
24
25impl PatternAlert {
26    /// Get severity level (1-5)
27    pub fn severity(&self) -> u8 {
28        match self {
29            PatternAlert::NplusOne(p) => {
30                if p.repeat_count > 100 {
31                    5
32                } else if p.repeat_count > 50 {
33                    4
34                } else if p.repeat_count > 20 {
35                    3
36                } else if p.repeat_count > 10 {
37                    2
38                } else {
39                    1
40                }
41            }
42            PatternAlert::Burst(b) => {
43                if b.query_count > 500 {
44                    5
45                } else if b.query_count > 200 {
46                    4
47                } else if b.query_count > 100 {
48                    3
49                } else if b.query_count > 50 {
50                    2
51                } else {
52                    1
53                }
54            }
55        }
56    }
57
58    /// Get description
59    pub fn description(&self) -> String {
60        match self {
61            PatternAlert::NplusOne(p) => {
62                format!(
63                    "N+1 query pattern: {} repeated {} times in session {}",
64                    truncate(&p.fingerprint, 50),
65                    p.repeat_count,
66                    p.session_id
67                )
68            }
69            PatternAlert::Burst(b) => {
70                format!(
71                    "Query burst: {} queries in {:?} from session {}",
72                    b.query_count, b.window, b.session_id
73                )
74            }
75        }
76    }
77}
78
79/// N+1 query pattern
80#[derive(Debug, Clone)]
81pub struct NplusOnePattern {
82    /// Session that exhibited the pattern
83    pub session_id: String,
84
85    /// Fingerprint of the repeated query
86    pub fingerprint: String,
87
88    /// Fingerprint hash
89    pub fingerprint_hash: u64,
90
91    /// Number of repetitions
92    pub repeat_count: usize,
93
94    /// Time window in which repetitions occurred
95    pub window: Duration,
96
97    /// First seen timestamp
98    pub first_seen_nanos: u64,
99
100    /// Last seen timestamp
101    pub last_seen_nanos: u64,
102
103    /// Tables involved
104    pub tables: Vec<String>,
105}
106
107/// Query burst (many queries in short window)
108#[derive(Debug, Clone)]
109pub struct QueryBurst {
110    /// Session that exhibited the burst
111    pub session_id: String,
112
113    /// Number of queries in window
114    pub query_count: usize,
115
116    /// Detection window
117    pub window: Duration,
118
119    /// Start timestamp
120    pub start_nanos: u64,
121
122    /// End timestamp
123    pub end_nanos: u64,
124
125    /// Top fingerprints in burst
126    pub top_fingerprints: Vec<(u64, usize)>,
127}
128
129/// Session query history
130struct SessionHistory {
131    /// Recent query timestamps
132    query_times: VecDeque<Instant>,
133
134    /// Recent fingerprint hashes
135    recent_fingerprints: VecDeque<(u64, Instant, String, Vec<String>)>,
136
137    /// Last activity time
138    last_activity: Instant,
139
140    /// Session ID
141    session_id: String,
142}
143
144impl SessionHistory {
145    fn new(session_id: String) -> Self {
146        Self {
147            query_times: VecDeque::new(),
148            recent_fingerprints: VecDeque::new(),
149            last_activity: Instant::now(),
150            session_id,
151        }
152    }
153
154    fn record_query(&mut self, fingerprint: &QueryFingerprint, max_history: usize) {
155        let now = Instant::now();
156        self.last_activity = now;
157
158        // Record timestamp
159        self.query_times.push_back(now);
160        while self.query_times.len() > max_history {
161            self.query_times.pop_front();
162        }
163
164        // Record fingerprint
165        self.recent_fingerprints.push_back((
166            fingerprint.hash,
167            now,
168            fingerprint.normalized.clone(),
169            fingerprint.tables.clone(),
170        ));
171        while self.recent_fingerprints.len() > max_history {
172            self.recent_fingerprints.pop_front();
173        }
174    }
175
176    fn count_in_window(&self, window: Duration) -> usize {
177        // `Instant - Duration` panics on underflow, and the monotonic clock
178        // can start near zero (e.g. shortly after host boot). When the window
179        // predates the process start, every recorded query falls inside it.
180        match Instant::now().checked_sub(window) {
181            Some(cutoff) => self.query_times.iter().filter(|t| **t > cutoff).count(),
182            None => self.query_times.len(),
183        }
184    }
185
186    fn count_fingerprint_in_window(&self, hash: u64, window: Duration) -> usize {
187        // See count_in_window: near host boot the window predates process
188        // start, so every matching fingerprint counts as in-window.
189        match Instant::now().checked_sub(window) {
190            Some(cutoff) => self
191                .recent_fingerprints
192                .iter()
193                .filter(|(h, t, _, _)| *h == hash && *t > cutoff)
194                .count(),
195            None => self
196                .recent_fingerprints
197                .iter()
198                .filter(|(h, _, _, _)| *h == hash)
199                .count(),
200        }
201    }
202
203    fn get_repeated_fingerprints(
204        &self,
205        threshold: usize,
206    ) -> Vec<(u64, usize, String, Vec<String>)> {
207        let mut counts: std::collections::HashMap<u64, (usize, String, Vec<String>)> =
208            std::collections::HashMap::new();
209
210        for (hash, _, normalized, tables) in &self.recent_fingerprints {
211            let entry = counts
212                .entry(*hash)
213                .or_insert((0, normalized.clone(), tables.clone()));
214            entry.0 += 1;
215        }
216
217        counts
218            .into_iter()
219            .filter(|(_, (count, _, _))| *count >= threshold)
220            .map(|(hash, (count, normalized, tables))| (hash, count, normalized, tables))
221            .collect()
222    }
223}
224
225/// Pattern detector
226pub struct PatternDetector {
227    /// Configuration
228    config: PatternConfig,
229
230    /// Per-session history
231    sessions: DashMap<String, SessionHistory>,
232
233    /// Detected alerts
234    alerts: RwLock<VecDeque<PatternAlert>>,
235
236    /// Alert counter
237    alert_count: AtomicU64,
238
239    /// Last cleanup time
240    last_cleanup: RwLock<Instant>,
241}
242
243impl PatternDetector {
244    /// Create new pattern detector
245    pub fn new(config: PatternConfig) -> Self {
246        Self {
247            config,
248            sessions: DashMap::new(),
249            alerts: RwLock::new(VecDeque::new()),
250            alert_count: AtomicU64::new(0),
251            last_cleanup: RwLock::new(Instant::now()),
252        }
253    }
254
255    /// Record a query execution
256    pub fn record_query(
257        &self,
258        session_id: &str,
259        _execution: &QueryExecution,
260        fingerprint: &QueryFingerprint,
261    ) {
262        // Periodic cleanup
263        self.maybe_cleanup();
264
265        // Get or create session history
266        let mut session = self
267            .sessions
268            .entry(session_id.to_string())
269            .or_insert_with(|| SessionHistory::new(session_id.to_string()));
270
271        // Record the query
272        session.record_query(fingerprint, self.config.session_history_size);
273
274        // Check for N+1 pattern
275        if self.config.n_plus_one_detection {
276            self.check_n_plus_one(&session, fingerprint);
277        }
278
279        // Check for burst pattern
280        if self.config.burst_detection {
281            self.check_burst(&session);
282        }
283    }
284
285    /// Check for N+1 query pattern
286    fn check_n_plus_one(&self, session: &SessionHistory, fingerprint: &QueryFingerprint) {
287        let count = session.count_fingerprint_in_window(fingerprint.hash, Duration::from_secs(5));
288
289        if count >= self.config.n_plus_one_threshold {
290            let pattern = NplusOnePattern {
291                session_id: session.session_id.clone(),
292                fingerprint: fingerprint.normalized.clone(),
293                fingerprint_hash: fingerprint.hash,
294                repeat_count: count,
295                window: Duration::from_secs(5),
296                first_seen_nanos: now_nanos(),
297                last_seen_nanos: now_nanos(),
298                tables: fingerprint.tables.clone(),
299            };
300
301            self.add_alert(PatternAlert::NplusOne(pattern));
302        }
303    }
304
305    /// Check for query burst
306    fn check_burst(&self, session: &SessionHistory) {
307        let count = session.count_in_window(self.config.burst_window);
308
309        if count >= self.config.burst_threshold {
310            // Get top fingerprints in the burst
311            let repeated = session.get_repeated_fingerprints(3);
312            let top_fingerprints: Vec<_> = repeated
313                .iter()
314                .take(5)
315                .map(|(hash, count, _, _)| (*hash, *count))
316                .collect();
317
318            let burst = QueryBurst {
319                session_id: session.session_id.clone(),
320                query_count: count,
321                window: self.config.burst_window,
322                // saturating_sub avoids u64 underflow if the wall clock is
323                // near epoch / the window is enormous; 0 = "window opened at
324                // epoch 0", which retains all recent entries as intended.
325                start_nanos: now_nanos().saturating_sub(self.config.burst_window.as_nanos() as u64),
326                end_nanos: now_nanos(),
327                top_fingerprints,
328            };
329
330            self.add_alert(PatternAlert::Burst(burst));
331        }
332    }
333
334    /// Add an alert
335    fn add_alert(&self, alert: PatternAlert) {
336        self.alert_count.fetch_add(1, Ordering::Relaxed);
337
338        let mut alerts = self.alerts.write();
339        alerts.push_back(alert);
340
341        // Keep only recent alerts (max 1000)
342        while alerts.len() > 1000 {
343            alerts.pop_front();
344        }
345    }
346
347    /// Get recent alerts
348    pub fn get_alerts(&self) -> Vec<PatternAlert> {
349        self.alerts.read().iter().cloned().collect()
350    }
351
352    /// Get alerts by type
353    pub fn get_n_plus_one_alerts(&self) -> Vec<NplusOnePattern> {
354        self.alerts
355            .read()
356            .iter()
357            .filter_map(|a| match a {
358                PatternAlert::NplusOne(p) => Some(p.clone()),
359                _ => None,
360            })
361            .collect()
362    }
363
364    /// Get burst alerts
365    pub fn get_burst_alerts(&self) -> Vec<QueryBurst> {
366        self.alerts
367            .read()
368            .iter()
369            .filter_map(|a| match a {
370                PatternAlert::Burst(b) => Some(b.clone()),
371                _ => None,
372            })
373            .collect()
374    }
375
376    /// Get alert count
377    pub fn alert_count(&self) -> u64 {
378        self.alert_count.load(Ordering::Relaxed)
379    }
380
381    /// Clear alerts
382    pub fn clear_alerts(&self) {
383        self.alerts.write().clear();
384    }
385
386    /// Cleanup inactive sessions
387    fn maybe_cleanup(&self) {
388        let now = Instant::now();
389        let mut last_cleanup = self.last_cleanup.write();
390
391        // Cleanup every minute
392        if now.duration_since(*last_cleanup) < Duration::from_secs(60) {
393            return;
394        }
395        *last_cleanup = now;
396        drop(last_cleanup);
397
398        // Remove inactive sessions
399        let timeout = self.config.session_timeout;
400        self.sessions
401            .retain(|_, session| now.duration_since(session.last_activity) < timeout);
402
403        // Enforce max sessions
404        while self.sessions.len() > self.config.max_sessions {
405            // Remove oldest session
406            let oldest = self
407                .sessions
408                .iter()
409                .min_by_key(|s| s.last_activity)
410                .map(|s| s.key().clone());
411
412            if let Some(key) = oldest {
413                self.sessions.remove(&key);
414            } else {
415                break;
416            }
417        }
418    }
419
420    /// Get session count
421    pub fn session_count(&self) -> usize {
422        self.sessions.len()
423    }
424
425    /// Reset detector
426    pub fn reset(&self) {
427        self.sessions.clear();
428        self.alerts.write().clear();
429        self.alert_count.store(0, Ordering::Relaxed);
430    }
431}
432
433fn now_nanos() -> u64 {
434    std::time::SystemTime::now()
435        .duration_since(std::time::SystemTime::UNIX_EPOCH)
436        .map(|d| d.as_nanos() as u64)
437        .unwrap_or(0)
438}
439
440fn truncate(s: &str, max: usize) -> String {
441    if s.len() > max {
442        format!("{}...", &s[..max])
443    } else {
444        s.to_string()
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::analytics::fingerprinter::QueryFingerprinter;
452
453    #[test]
454    fn test_pattern_detector_new() {
455        let config = PatternConfig::default();
456        let detector = PatternDetector::new(config);
457        assert_eq!(detector.session_count(), 0);
458        assert_eq!(detector.alert_count(), 0);
459    }
460
461    #[test]
462    fn test_n_plus_one_detection() {
463        let config = PatternConfig {
464            n_plus_one_threshold: 3,
465            burst_detection: false,
466            ..Default::default()
467        };
468
469        let detector = PatternDetector::new(config);
470        let fp = QueryFingerprinter::new();
471
472        let session_id = "session-1";
473
474        // Record same query multiple times
475        for i in 0..5 {
476            let query = format!("SELECT * FROM users WHERE id = {}", i);
477            let fingerprint = fp.fingerprint(&query);
478            let execution =
479                super::super::statistics::QueryExecution::new(query, Duration::from_millis(5));
480            detector.record_query(session_id, &execution, &fingerprint);
481        }
482
483        // Should have detected N+1 pattern
484        let alerts = detector.get_n_plus_one_alerts();
485        assert!(!alerts.is_empty(), "Should detect N+1 pattern");
486    }
487
488    #[test]
489    fn test_burst_detection() {
490        let config = PatternConfig {
491            burst_threshold: 5,
492            burst_window: Duration::from_secs(1),
493            n_plus_one_detection: false,
494            ..Default::default()
495        };
496
497        let detector = PatternDetector::new(config);
498        let fp = QueryFingerprinter::new();
499
500        let session_id = "session-1";
501
502        // Record many queries quickly
503        for i in 0..10 {
504            let query = format!("SELECT * FROM table_{}", i);
505            let fingerprint = fp.fingerprint(&query);
506            let execution =
507                super::super::statistics::QueryExecution::new(query, Duration::from_millis(1));
508            detector.record_query(session_id, &execution, &fingerprint);
509        }
510
511        // Should have detected burst
512        let alerts = detector.get_burst_alerts();
513        assert!(!alerts.is_empty(), "Should detect burst pattern");
514    }
515
516    #[test]
517    fn test_alert_severity() {
518        let pattern = NplusOnePattern {
519            session_id: "session-1".to_string(),
520            fingerprint: "select * from users where id = ?".to_string(),
521            fingerprint_hash: 12345,
522            repeat_count: 25,
523            window: Duration::from_secs(5),
524            first_seen_nanos: 0,
525            last_seen_nanos: 0,
526            tables: vec!["users".to_string()],
527        };
528
529        let alert = PatternAlert::NplusOne(pattern);
530        assert_eq!(alert.severity(), 3);
531    }
532
533    #[test]
534    fn test_session_cleanup() {
535        let config = PatternConfig {
536            session_timeout: Duration::from_millis(100),
537            ..Default::default()
538        };
539
540        let detector = PatternDetector::new(config);
541        let fp = QueryFingerprinter::new();
542
543        // Record query in session
544        let fingerprint = fp.fingerprint("SELECT 1");
545        let execution =
546            super::super::statistics::QueryExecution::new("SELECT 1", Duration::from_millis(1));
547        detector.record_query("session-1", &execution, &fingerprint);
548
549        assert_eq!(detector.session_count(), 1);
550
551        // Wait for timeout
552        std::thread::sleep(Duration::from_millis(150));
553
554        // Record in new session to trigger cleanup
555        detector.record_query("session-2", &execution, &fingerprint);
556
557        // Old session should be cleaned up (cleanup runs every minute in production,
558        // but our test may or may not have triggered it)
559    }
560
561    #[test]
562    fn test_reset() {
563        let config = PatternConfig::default();
564        let detector = PatternDetector::new(config);
565        let fp = QueryFingerprinter::new();
566
567        let fingerprint = fp.fingerprint("SELECT 1");
568        let execution =
569            super::super::statistics::QueryExecution::new("SELECT 1", Duration::from_millis(1));
570        detector.record_query("session-1", &execution, &fingerprint);
571
572        detector.reset();
573
574        assert_eq!(detector.session_count(), 0);
575        assert_eq!(detector.alert_count(), 0);
576    }
577
578    #[test]
579    fn test_alert_description() {
580        let pattern = NplusOnePattern {
581            session_id: "sess-123".to_string(),
582            fingerprint: "select * from users where id = ?".to_string(),
583            fingerprint_hash: 12345,
584            repeat_count: 10,
585            window: Duration::from_secs(5),
586            first_seen_nanos: 0,
587            last_seen_nanos: 0,
588            tables: vec!["users".to_string()],
589        };
590
591        let alert = PatternAlert::NplusOne(pattern);
592        let desc = alert.description();
593
594        assert!(desc.contains("N+1"));
595        assert!(desc.contains("10 times"));
596        assert!(desc.contains("sess-123"));
597    }
598
599    #[test]
600    fn window_larger_than_uptime_does_not_panic() {
601        // Regression: `Instant::now() - window` panicked on Instant underflow
602        // when the monotonic clock was younger than the window (near host
603        // boot), and `now_nanos() - burst_window` underflowed the u64 nanos.
604        // A window larger than any possible uptime forces both fixed paths:
605        // the checked_sub None branch (every entry counts as in-window) and
606        // the saturating_sub start_nanos.
607        let huge = Duration::from_secs(u64::MAX);
608
609        // Direct SessionHistory helpers: None branch retains all entries.
610        let fp = QueryFingerprinter::new();
611        let fingerprint = fp.fingerprint("SELECT * FROM users WHERE id = 1");
612        let mut history = SessionHistory::new("s".to_string());
613        for _ in 0..3 {
614            history.record_query(&fingerprint, 100);
615        }
616        assert_eq!(history.count_in_window(huge), 3);
617        assert_eq!(
618            history.count_fingerprint_in_window(fingerprint.hash, huge),
619            3
620        );
621
622        // Full detector burst path with an enormous window still fires an
623        // alert (computing saturating_sub start_nanos) without panicking.
624        let config = PatternConfig {
625            burst_window: huge,
626            burst_threshold: 3,
627            n_plus_one_detection: false,
628            ..Default::default()
629        };
630        let detector = PatternDetector::new(config);
631        let execution =
632            super::super::statistics::QueryExecution::new("SELECT 1", Duration::from_millis(1));
633        for _ in 0..5 {
634            let f = fp.fingerprint("SELECT 1");
635            detector.record_query("s", &execution, &f);
636        }
637        assert!(!detector.get_burst_alerts().is_empty());
638    }
639}