Skip to main content

kaccy_db/
statement_cache.rs

1//! Prepared statement caching for improved query performance.
2//!
3//! This module provides:
4//! - Statement pool with LRU eviction
5//! - Query fingerprinting for cache key generation
6//! - Statement reuse metrics
7
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::collections::{HashMap, VecDeque};
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use tracing::{debug, info};
15
16/// Configuration for statement cache
17#[derive(Debug, Clone)]
18pub struct StatementCacheConfig {
19    /// Maximum number of cached statements
20    pub max_size: usize,
21    /// Time-to-live for cached statements (in seconds)
22    pub ttl_secs: u64,
23    /// Whether to enable query fingerprinting
24    pub enable_fingerprinting: bool,
25}
26
27impl Default for StatementCacheConfig {
28    fn default() -> Self {
29        Self {
30            max_size: 1000,
31            ttl_secs: 3600, // 1 hour
32            enable_fingerprinting: true,
33        }
34    }
35}
36
37/// Cached statement metadata
38#[derive(Debug, Clone)]
39struct CachedStatement {
40    /// SQL statement text
41    sql: String,
42    /// Query fingerprint (normalized SQL)
43    #[allow(dead_code)]
44    fingerprint: String,
45    /// Timestamp when statement was cached
46    cached_at: Instant,
47    /// Number of times this statement was reused
48    reuse_count: u64,
49    /// Last access time
50    last_accessed: Instant,
51}
52
53impl CachedStatement {
54    fn new(sql: String, fingerprint: String) -> Self {
55        let now = Instant::now();
56        Self {
57            sql,
58            fingerprint,
59            cached_at: now,
60            reuse_count: 0,
61            last_accessed: now,
62        }
63    }
64
65    fn is_expired(&self, ttl: Duration) -> bool {
66        self.cached_at.elapsed() > ttl
67    }
68
69    fn touch(&mut self) {
70        self.reuse_count += 1;
71        self.last_accessed = Instant::now();
72    }
73}
74
75/// Statement cache with LRU eviction
76#[derive(Debug, Clone)]
77pub struct StatementCache {
78    config: StatementCacheConfig,
79    cache: Arc<RwLock<HashMap<String, CachedStatement>>>,
80    lru_queue: Arc<RwLock<VecDeque<String>>>,
81    stats: Arc<RwLock<CacheStats>>,
82}
83
84/// Statement cache performance statistics.
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct CacheStats {
87    /// Number of cache hits
88    pub hits: u64,
89    /// Number of cache misses
90    pub misses: u64,
91    /// Number of evictions
92    pub evictions: u64,
93    /// Number of expired entries removed
94    pub expirations: u64,
95    /// Total statements in cache
96    pub cached_statements: usize,
97}
98
99impl CacheStats {
100    /// Calculate cache hit rate
101    pub fn hit_rate(&self) -> f64 {
102        let total = self.hits + self.misses;
103        if total == 0 {
104            0.0
105        } else {
106            self.hits as f64 / total as f64
107        }
108    }
109
110    /// Reset statistics
111    pub fn reset(&mut self) {
112        self.hits = 0;
113        self.misses = 0;
114        self.evictions = 0;
115        self.expirations = 0;
116    }
117}
118
119impl Default for StatementCache {
120    fn default() -> Self {
121        Self::new(StatementCacheConfig::default())
122    }
123}
124
125impl StatementCache {
126    /// Create a new statement cache
127    pub fn new(config: StatementCacheConfig) -> Self {
128        Self {
129            config,
130            cache: Arc::new(RwLock::new(HashMap::new())),
131            lru_queue: Arc::new(RwLock::new(VecDeque::new())),
132            stats: Arc::new(RwLock::new(CacheStats::default())),
133        }
134    }
135
136    /// Generate a fingerprint for a SQL query
137    pub fn fingerprint(&self, sql: &str) -> String {
138        if !self.config.enable_fingerprinting {
139            return sql.to_string();
140        }
141
142        // Normalize SQL for fingerprinting
143        let normalized = normalize_sql(sql);
144
145        // Generate SHA256 hash
146        let mut hasher = Sha256::new();
147        hasher.update(normalized.as_bytes());
148        let result = hasher.finalize();
149
150        hex::encode(result.as_slice())
151    }
152
153    /// Get a cached statement
154    pub fn get(&self, sql: &str) -> Option<String> {
155        let fingerprint = self.fingerprint(sql);
156
157        let mut cache = self.cache.write();
158        let mut stats = self.stats.write();
159
160        if let Some(stmt) = cache.get_mut(&fingerprint) {
161            // Check if expired
162            if stmt.is_expired(Duration::from_secs(self.config.ttl_secs)) {
163                cache.remove(&fingerprint);
164                self.remove_from_lru(&fingerprint);
165                stats.expirations += 1;
166                stats.misses += 1;
167                debug!(fingerprint = %fingerprint, "Statement expired");
168                return None;
169            }
170
171            stmt.touch();
172            stats.hits += 1;
173
174            // Move to front of LRU queue
175            self.update_lru(&fingerprint);
176
177            debug!(
178                fingerprint = %fingerprint,
179                reuse_count = stmt.reuse_count,
180                "Statement cache hit"
181            );
182
183            Some(stmt.sql.clone())
184        } else {
185            stats.misses += 1;
186            debug!(fingerprint = %fingerprint, "Statement cache miss");
187            None
188        }
189    }
190
191    /// Cache a statement
192    pub fn put(&self, sql: String) {
193        let fingerprint = self.fingerprint(&sql);
194
195        let mut cache = self.cache.write();
196        let mut stats = self.stats.write();
197
198        // Evict if cache is full
199        while cache.len() >= self.config.max_size {
200            if let Some(oldest) = self.lru_queue.write().pop_back() {
201                cache.remove(&oldest);
202                stats.evictions += 1;
203                debug!(fingerprint = %oldest, "Statement evicted from cache");
204            } else {
205                break;
206            }
207        }
208
209        // Add to cache
210        let stmt = CachedStatement::new(sql, fingerprint.clone());
211        cache.insert(fingerprint.clone(), stmt);
212
213        // Add to LRU queue
214        self.lru_queue.write().push_front(fingerprint.clone());
215
216        stats.cached_statements = cache.len();
217
218        debug!(
219            fingerprint = %fingerprint,
220            cache_size = cache.len(),
221            "Statement cached"
222        );
223    }
224
225    /// Clear the cache
226    pub fn clear(&self) {
227        self.cache.write().clear();
228        self.lru_queue.write().clear();
229        self.stats.write().cached_statements = 0;
230
231        info!("Statement cache cleared");
232    }
233
234    /// Get cache statistics
235    pub fn stats(&self) -> CacheStats {
236        self.stats.read().clone()
237    }
238
239    /// Remove expired entries
240    pub fn remove_expired(&self) {
241        let ttl = Duration::from_secs(self.config.ttl_secs);
242        let mut cache = self.cache.write();
243        let mut stats = self.stats.write();
244
245        let expired: Vec<String> = cache
246            .iter()
247            .filter(|(_, stmt)| stmt.is_expired(ttl))
248            .map(|(fp, _)| fp.clone())
249            .collect();
250
251        for fp in &expired {
252            cache.remove(fp);
253            self.remove_from_lru(fp);
254            stats.expirations += 1;
255        }
256
257        stats.cached_statements = cache.len();
258
259        if !expired.is_empty() {
260            info!(count = expired.len(), "Removed expired statements");
261        }
262    }
263
264    /// Update LRU queue (move to front)
265    fn update_lru(&self, fingerprint: &str) {
266        let mut queue = self.lru_queue.write();
267
268        // Remove from current position
269        if let Some(pos) = queue.iter().position(|fp| fp == fingerprint) {
270            queue.remove(pos);
271        }
272
273        // Add to front
274        queue.push_front(fingerprint.to_string());
275    }
276
277    /// Remove from LRU queue
278    fn remove_from_lru(&self, fingerprint: &str) {
279        let mut queue = self.lru_queue.write();
280        if let Some(pos) = queue.iter().position(|fp| fp == fingerprint) {
281            queue.remove(pos);
282        }
283    }
284}
285
286/// Normalize SQL for fingerprinting
287fn normalize_sql(sql: &str) -> String {
288    // Remove extra whitespace and normalize to lowercase
289    let normalized = sql
290        .split_whitespace()
291        .collect::<Vec<_>>()
292        .join(" ")
293        .to_lowercase();
294
295    // Replace literal values with placeholders
296    // This is a simple approach; a more sophisticated version would use a SQL parser
297
298    replace_literals(&normalized)
299}
300
301static RE_LITERAL_STRING: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
302    regex::Regex::new(r"'[^']*'")
303        .expect("static regex pattern for SQL string literals is always valid")
304});
305
306static RE_LITERAL_NUMBER: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
307    regex::Regex::new(r"\b\d+\b")
308        .expect("static regex pattern for SQL numeric literals is always valid")
309});
310
311/// Replace literal values with placeholders
312fn replace_literals(sql: &str) -> String {
313    // Replace string literals
314    let sql = RE_LITERAL_STRING.replace_all(sql, "?");
315
316    // Replace numeric literals
317    let sql = RE_LITERAL_NUMBER.replace_all(&sql, "?");
318
319    sql.to_string()
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_statement_cache_config_default() {
328        let config = StatementCacheConfig::default();
329        assert_eq!(config.max_size, 1000);
330        assert_eq!(config.ttl_secs, 3600);
331        assert!(config.enable_fingerprinting);
332    }
333
334    #[test]
335    fn test_fingerprint_generation() {
336        let cache = StatementCache::default();
337
338        let sql1 = "SELECT * FROM users WHERE id = 1";
339        let sql2 = "SELECT * FROM users WHERE id = 2";
340
341        let fp1 = cache.fingerprint(sql1);
342        let fp2 = cache.fingerprint(sql2);
343
344        // Should generate the same fingerprint (normalized)
345        assert_eq!(fp1, fp2);
346    }
347
348    #[test]
349    fn test_cache_put_and_get() {
350        let cache = StatementCache::default();
351        let sql = "SELECT * FROM users WHERE id = 1".to_string();
352
353        cache.put(sql.clone());
354
355        let result = cache.get(&sql);
356        assert!(result.is_some());
357        assert_eq!(result.unwrap(), sql);
358    }
359
360    #[test]
361    fn test_cache_miss() {
362        let cache = StatementCache::default();
363        let sql = "SELECT * FROM users WHERE id = 1";
364
365        let result = cache.get(sql);
366        assert!(result.is_none());
367    }
368
369    #[test]
370    fn test_cache_stats() {
371        let cache = StatementCache::default();
372        let sql = "SELECT * FROM users WHERE id = 1".to_string();
373
374        // Miss
375        cache.get(&sql);
376
377        // Put
378        cache.put(sql.clone());
379
380        // Hit
381        cache.get(&sql);
382        cache.get(&sql);
383
384        let stats = cache.stats();
385        assert_eq!(stats.hits, 2);
386        assert_eq!(stats.misses, 1);
387        assert_eq!(stats.cached_statements, 1);
388    }
389
390    #[test]
391    fn test_cache_hit_rate() {
392        let stats = CacheStats {
393            hits: 80,
394            misses: 20,
395            evictions: 0,
396            expirations: 0,
397            cached_statements: 100,
398        };
399
400        assert!((stats.hit_rate() - 0.8).abs() < 0.001);
401    }
402
403    #[test]
404    fn test_cache_eviction() {
405        let config = StatementCacheConfig {
406            max_size: 3,
407            enable_fingerprinting: false, // Disable fingerprinting for this test
408            ..Default::default()
409        };
410
411        let cache = StatementCache::new(config);
412
413        cache.put("SELECT 1".to_string());
414        cache.put("SELECT 2".to_string());
415        cache.put("SELECT 3".to_string());
416        cache.put("SELECT 4".to_string()); // Should evict oldest
417
418        let stats = cache.stats();
419        assert_eq!(stats.cached_statements, 3);
420        assert_eq!(stats.evictions, 1);
421    }
422
423    #[test]
424    fn test_cache_clear() {
425        let cache = StatementCache::default();
426
427        cache.put("SELECT 1".to_string());
428        cache.put("SELECT 2".to_string());
429
430        cache.clear();
431
432        let stats = cache.stats();
433        assert_eq!(stats.cached_statements, 0);
434    }
435
436    #[test]
437    fn test_normalize_sql() {
438        let sql = "SELECT  *  FROM  users  WHERE  id  =  1";
439        let normalized = normalize_sql(sql);
440
441        assert_eq!(normalized, "select * from users where id = ?");
442    }
443
444    #[test]
445    fn test_replace_literals() {
446        let sql = "SELECT * FROM users WHERE id = 123 AND name = 'john'";
447        let replaced = replace_literals(sql);
448
449        assert!(replaced.contains("?"));
450        assert!(!replaced.contains("123"));
451        assert!(!replaced.contains("john"));
452    }
453
454    #[test]
455    fn test_statement_reuse_count() {
456        let cache = StatementCache::default();
457        let sql = "SELECT * FROM users WHERE id = 1".to_string();
458
459        cache.put(sql.clone());
460
461        // Access multiple times
462        for _ in 0..5 {
463            cache.get(&sql);
464        }
465
466        let fingerprint = cache.fingerprint(&sql);
467        let cached_cache = cache.cache.read();
468        let stmt = cached_cache.get(&fingerprint).unwrap();
469
470        assert_eq!(stmt.reuse_count, 5);
471    }
472
473    #[test]
474    fn test_stats_reset() {
475        let mut stats = CacheStats {
476            hits: 100,
477            misses: 50,
478            evictions: 10,
479            expirations: 5,
480            cached_statements: 200,
481        };
482
483        stats.reset();
484
485        assert_eq!(stats.hits, 0);
486        assert_eq!(stats.misses, 0);
487        assert_eq!(stats.evictions, 0);
488        assert_eq!(stats.expirations, 0);
489    }
490}