Skip to main content

velesdb_core/velesql/
cache.rs

1//! Query cache for `VelesQL` parsed queries.
2//!
3//! Provides an LRU cache for parsed AST to avoid re-parsing identical queries.
4//! Effective on workloads that repeat the **exact same query text**: a hit
5//! requires equality of the original query string, so two formatting variants
6//! of the same query (different whitespace, casing, parameter names) never
7//! share a cache entry.
8
9use parking_lot::RwLock;
10use rustc_hash::FxHashMap;
11use std::collections::VecDeque;
12use std::hash::{BuildHasher, Hasher};
13use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
14use std::sync::Arc;
15
16use super::ast::Query;
17use super::error::ParseError;
18use super::Parser;
19
20/// Statistics for the query cache.
21#[derive(Debug, Clone, Copy, Default)]
22pub struct CacheStats {
23    /// Number of cache hits.
24    pub hits: u64,
25    /// Number of cache misses.
26    pub misses: u64,
27    /// Number of evictions.
28    pub evictions: u64,
29}
30
31impl CacheStats {
32    /// Returns the cache hit rate as a percentage (0.0 - 100.0).
33    #[must_use]
34    #[allow(clippy::cast_precision_loss)]
35    pub fn hit_rate(&self) -> f64 {
36        let total = self.hits + self.misses;
37        if total == 0 {
38            return 0.0;
39        }
40        (self.hits as f64 / total as f64) * 100.0
41    }
42}
43
44/// Bounded query cache for parsed `VelesQL` queries (issue #903).
45///
46/// Thread-safe implementation using `parking_lot::RwLock`.
47///
48/// # Design notes
49///
50/// - Canonical query text is hashed for compact bucketing.
51/// - Hash collisions are handled explicitly via a per-bucket vector, with a
52///   strict equality check on the original query text before reuse.
53/// - Parsed ASTs are stored behind `Arc<Query>`; a hit returns `Arc::clone`
54///   (a refcount bump) instead of deep-cloning the AST.
55/// - A live `usize` size counter (`AtomicUsize`) gives O(1) `len()` and avoids
56///   re-summing every bucket on each insert/eviction.
57///
58/// # Hot-path concurrency (issue #903)
59///
60/// The previous design took a **global write lock** (`order.write()`) on every
61/// cache hit to promote the entry to the MRU position, serialising all reads.
62/// This implementation replaces strict LRU with a **CLOCK / second-chance**
63/// policy:
64///
65/// - A cache **hit** takes only a shared `read()` lock and sets a per-entry
66///   `referenced` bit via a relaxed atomic store — no write lock, so concurrent
67///   hits run in parallel.
68/// - Eviction (on the cold insert path, under the write lock) sweeps the
69///   insertion-order ring: an entry whose `referenced` bit is set gets a second
70///   chance (bit cleared, moved to the back); an entry with a clear bit is
71///   evicted. This approximates LRU while keeping the hit path lock-light.
72pub struct QueryCache {
73    /// Cache storage + CLOCK ring guarded by a single lock so a hit can observe
74    /// both under one `read()` acquisition.
75    inner: RwLock<CacheInner>,
76    /// Live entry count. O(1) `len()`; kept in sync with `inner` under the write
77    /// lock on insert/evict and reset on clear.
78    size: AtomicUsize,
79    /// Maximum cache size.
80    max_size: usize,
81    /// Hash function for canonical query text.
82    hash_fn: fn(&str) -> u64,
83    /// Cache statistics.
84    stats: AtomicCacheStats,
85}
86
87/// Storage + CLOCK ring, guarded together by `QueryCache::inner`.
88struct CacheInner {
89    /// Cache storage: canonical-hash -> collision-safe entries.
90    map: FxHashMap<u64, Vec<CacheEntry>>,
91    /// CLOCK ring of cache keys in insertion order; the eviction hand sweeps
92    /// from the front. Mutated only under the write lock (insert / evict).
93    order: VecDeque<CacheKey>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97struct CacheKey {
98    hash: u64,
99    original_query: String,
100}
101
102#[derive(Debug)]
103struct CacheEntry {
104    original_query: String,
105    canonical_query: String,
106    /// Parsed AST shared via `Arc`; hits return `Arc::clone`, never a deep copy.
107    parsed: Arc<Query>,
108    /// CLOCK second-chance bit. Set on every hit (relaxed atomic under a read
109    /// lock); consulted and cleared by the eviction sweep.
110    referenced: AtomicBool,
111}
112
113#[derive(Debug, Default)]
114struct AtomicCacheStats {
115    hits: AtomicU64,
116    misses: AtomicU64,
117    evictions: AtomicU64,
118}
119
120impl AtomicCacheStats {
121    fn snapshot(&self) -> CacheStats {
122        CacheStats {
123            hits: self.hits.load(Ordering::Relaxed),
124            misses: self.misses.load(Ordering::Relaxed),
125            evictions: self.evictions.load(Ordering::Relaxed),
126        }
127    }
128
129    fn clear(&self) {
130        self.hits.store(0, Ordering::Relaxed);
131        self.misses.store(0, Ordering::Relaxed);
132        self.evictions.store(0, Ordering::Relaxed);
133    }
134}
135
136impl QueryCache {
137    /// Creates a new query cache with the specified maximum size.
138    ///
139    /// # Arguments
140    ///
141    /// * `max_size` - Maximum number of queries to cache (minimum 1).
142    #[must_use]
143    pub fn new(max_size: usize) -> Self {
144        Self::new_with_hasher(max_size, default_query_hash)
145    }
146
147    fn new_with_hasher(max_size: usize, hash_fn: fn(&str) -> u64) -> Self {
148        let max_size = max_size.max(1);
149        Self {
150            inner: RwLock::new(CacheInner {
151                map: FxHashMap::default(),
152                order: VecDeque::with_capacity(max_size),
153            }),
154            size: AtomicUsize::new(0),
155            max_size,
156            hash_fn,
157            stats: AtomicCacheStats::default(),
158        }
159    }
160
161    /// Parses a query, returning a shared (`Arc`) cached AST if available.
162    ///
163    /// # Errors
164    ///
165    /// Returns `ParseError` if the query is invalid.
166    pub fn parse(&self, query: &str) -> Result<Arc<Query>, ParseError> {
167        self.parse_impl(query, true)
168    }
169
170    #[cfg(feature = "internal-bench")]
171    pub(crate) fn parse_without_stats(&self, query: &str) -> Result<Arc<Query>, ParseError> {
172        self.parse_impl(query, false)
173    }
174
175    fn parse_impl(&self, query: &str, record_stats: bool) -> Result<Arc<Query>, ParseError> {
176        let canonical_query = canonicalize_query(query);
177        let hash = (self.hash_fn)(&canonical_query);
178
179        if let Some(cached) = self.try_cache_hit(hash, query, &canonical_query, record_stats) {
180            return Ok(cached);
181        }
182
183        let parsed = Arc::new(Parser::parse(query)?);
184        self.insert_into_cache(hash, canonical_query, query, &parsed, record_stats);
185        Ok(parsed)
186    }
187
188    /// Read-only hot path (issue #903): looks up a cached query under a **shared**
189    /// lock and, on a hit, sets the CLOCK `referenced` bit with a relaxed atomic
190    /// store. No write lock is taken, so concurrent hits do not serialise.
191    fn try_cache_hit(
192        &self,
193        hash: u64,
194        original_query: &str,
195        canonical_query: &str,
196        record_stats: bool,
197    ) -> Option<Arc<Query>> {
198        let inner = self.inner.read();
199        let entry = inner.map.get(&hash).and_then(|entries| {
200            entries.iter().find(|entry| {
201                entry.original_query == original_query && entry.canonical_query == canonical_query
202            })
203        })?;
204
205        // Second-chance bit: cheap relaxed store, safe under a shared lock via
206        // interior mutability (AtomicBool). No global write lock on the hit path.
207        entry.referenced.store(true, Ordering::Relaxed);
208        let parsed = Arc::clone(&entry.parsed);
209        drop(inner);
210
211        if record_stats {
212            self.stats.hits.fetch_add(1, Ordering::Relaxed);
213        }
214        Some(parsed)
215    }
216
217    /// Inserts a freshly parsed query into the cache, evicting via CLOCK as needed.
218    fn insert_into_cache(
219        &self,
220        hash: u64,
221        canonical_query: String,
222        raw_query: &str,
223        parsed: &Arc<Query>,
224        record_stats: bool,
225    ) {
226        let mut inner = self.inner.write();
227
228        if record_stats {
229            self.stats.misses.fetch_add(1, Ordering::Relaxed);
230        }
231
232        let key = CacheKey {
233            hash,
234            original_query: raw_query.to_string(),
235        };
236
237        // Replacing an existing entry for the same query is not a net size change,
238        // so only evict when inserting a genuinely new key.
239        let is_new_key = !Self::bucket_contains(&inner.map, hash, raw_query);
240        if is_new_key {
241            self.evict_until_below_bound(&mut inner, record_stats);
242        }
243
244        let new_entry = CacheEntry {
245            original_query: raw_query.to_string(),
246            canonical_query,
247            parsed: Arc::clone(parsed),
248            referenced: AtomicBool::new(false),
249        };
250
251        let bucket = inner.map.entry(hash).or_default();
252        bucket.retain(|entry| entry.original_query != raw_query);
253        bucket.push(new_entry);
254
255        if is_new_key {
256            inner.order.push_back(key);
257            self.size.fetch_add(1, Ordering::Relaxed);
258        }
259        debug_assert_eq!(self.size.load(Ordering::Relaxed), inner.order.len());
260    }
261
262    /// CLOCK / second-chance eviction: sweep the insertion-order ring until the
263    /// live size is back under `max_size`. An entry whose `referenced` bit is set
264    /// gets a second chance (bit cleared, re-queued at the back); otherwise it is
265    /// evicted. Amortised O(1) per insert — no per-iteration bucket re-sum.
266    fn evict_until_below_bound(&self, inner: &mut CacheInner, record_stats: bool) {
267        while self.size.load(Ordering::Relaxed) >= self.max_size {
268            let Some(candidate) = inner.order.pop_front() else {
269                break;
270            };
271            if Self::take_second_chance(&inner.map, &candidate) {
272                inner.order.push_back(candidate);
273                continue;
274            }
275            Self::remove_entry(&mut inner.map, &candidate);
276            self.size.fetch_sub(1, Ordering::Relaxed);
277            if record_stats {
278                self.stats.evictions.fetch_add(1, Ordering::Relaxed);
279            }
280        }
281    }
282
283    /// Returns `true` (granting a second chance) if the candidate's entry has its
284    /// `referenced` bit set, clearing the bit as a side effect.
285    fn take_second_chance(map: &FxHashMap<u64, Vec<CacheEntry>>, key: &CacheKey) -> bool {
286        map.get(&key.hash)
287            .and_then(|bucket| {
288                bucket
289                    .iter()
290                    .find(|entry| entry.original_query == key.original_query)
291            })
292            .is_some_and(|entry| entry.referenced.swap(false, Ordering::Relaxed))
293    }
294
295    /// Removes the entry identified by `key` from its bucket, dropping the bucket
296    /// if it becomes empty.
297    fn remove_entry(map: &mut FxHashMap<u64, Vec<CacheEntry>>, key: &CacheKey) {
298        if let Some(bucket) = map.get_mut(&key.hash) {
299            bucket.retain(|entry| entry.original_query != key.original_query);
300            if bucket.is_empty() {
301                map.remove(&key.hash);
302            }
303        }
304    }
305
306    /// Returns `true` if a bucket already holds an entry for `raw_query`.
307    fn bucket_contains(map: &FxHashMap<u64, Vec<CacheEntry>>, hash: u64, raw_query: &str) -> bool {
308        map.get(&hash)
309            .is_some_and(|bucket| bucket.iter().any(|entry| entry.original_query == raw_query))
310    }
311
312    /// Returns current cache statistics.
313    #[must_use]
314    pub fn stats(&self) -> CacheStats {
315        self.stats.snapshot()
316    }
317
318    /// Returns the current number of cached queries (O(1)).
319    #[must_use]
320    pub fn len(&self) -> usize {
321        self.size.load(Ordering::Relaxed)
322    }
323
324    /// Returns true if the cache is empty.
325    #[must_use]
326    pub fn is_empty(&self) -> bool {
327        self.len() == 0
328    }
329
330    /// Clears all cached queries and resets statistics.
331    pub fn clear(&self) {
332        let mut inner = self.inner.write();
333        inner.map.clear();
334        inner.order.clear();
335        self.size.store(0, Ordering::Relaxed);
336        self.stats.clear();
337    }
338}
339
340impl Default for QueryCache {
341    fn default() -> Self {
342        Self::new(1000)
343    }
344}
345
346fn default_query_hash(query: &str) -> u64 {
347    let mut hasher = rustc_hash::FxBuildHasher.build_hasher();
348    hasher.write(query.as_bytes());
349    hasher.finish()
350}
351
352fn canonicalize_query(query: &str) -> String {
353    query.split_whitespace().collect::<Vec<_>>().join(" ")
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_cache_stats_hit_rate_empty() {
362        let stats = CacheStats::default();
363        assert!((stats.hit_rate() - 0.0).abs() < 1e-5);
364    }
365
366    #[test]
367    fn test_cache_stats_hit_rate_all_hits() {
368        let stats = CacheStats {
369            hits: 10,
370            misses: 0,
371            evictions: 0,
372        };
373        assert!((stats.hit_rate() - 100.0).abs() < 1e-5);
374    }
375
376    #[test]
377    fn test_cache_stats_hit_rate_half() {
378        let stats = CacheStats {
379            hits: 5,
380            misses: 5,
381            evictions: 0,
382        };
383        assert!((stats.hit_rate() - 50.0).abs() < 1e-5);
384    }
385
386    #[test]
387    fn test_query_cache_new() {
388        let cache = QueryCache::new(100);
389        assert!(cache.is_empty());
390        assert_eq!(cache.len(), 0);
391    }
392
393    #[test]
394    fn test_query_cache_default() {
395        let cache = QueryCache::default();
396        assert!(cache.is_empty());
397    }
398
399    #[test]
400    fn test_query_cache_parse_and_hit() {
401        let cache = QueryCache::new(10);
402        let query = "SELECT * FROM docs LIMIT 5";
403
404        let result1 = cache.parse(query);
405        assert!(result1.is_ok());
406        assert_eq!(cache.stats().misses, 1);
407        assert_eq!(cache.stats().hits, 0);
408
409        let result2 = cache.parse(query);
410        assert!(result2.is_ok());
411        assert_eq!(cache.stats().hits, 1);
412    }
413
414    #[test]
415    fn test_query_cache_clear() {
416        let cache = QueryCache::new(10);
417        let _ = cache.parse("SELECT * FROM docs LIMIT 1");
418        assert!(!cache.is_empty());
419
420        cache.clear();
421        assert!(cache.is_empty());
422        assert_eq!(cache.stats().hits, 0);
423        assert_eq!(cache.stats().misses, 0);
424    }
425
426    #[test]
427    fn test_query_cache_eviction() {
428        let cache = QueryCache::new(2);
429
430        let _ = cache.parse("SELECT * FROM docs LIMIT 1");
431        let _ = cache.parse("SELECT * FROM docs LIMIT 2");
432        assert_eq!(cache.len(), 2);
433
434        let _ = cache.parse("SELECT * FROM docs LIMIT 3");
435        assert_eq!(cache.len(), 2);
436        assert!(cache.stats().evictions >= 1);
437    }
438
439    #[test]
440    fn test_query_cache_hit_keeps_clock_ring_unique() {
441        // Issue #903: a hit no longer rewrites LRU order (CLOCK promotion sets a
442        // referenced bit instead). The ring must still contain each key once and
443        // stay in sync with the O(1) size counter.
444        let cache = QueryCache::new(3);
445        let q1 = "SELECT * FROM docs LIMIT 1";
446        let q2 = "SELECT * FROM docs LIMIT 2";
447        let q3 = "SELECT * FROM docs LIMIT 3";
448
449        let _ = cache.parse(q1);
450        let _ = cache.parse(q2);
451        let _ = cache.parse(q3);
452        let _ = cache.parse(q1); // hit: sets referenced bit, no reordering
453
454        let inner = cache.inner.read();
455        assert_eq!(inner.order.len(), cache.len());
456        assert_eq!(
457            inner
458                .order
459                .iter()
460                .filter(|v| v.original_query.as_str() == q1)
461                .count(),
462            1,
463            "no duplicate ring entries on hit"
464        );
465    }
466
467    #[test]
468    fn test_query_cache_clock_referenced_entry_survives_eviction() {
469        // Issue #903: CLOCK second chance. q1 is referenced (hit) before pressure;
470        // it must survive while an un-referenced entry is evicted instead.
471        let cache = QueryCache::new(2);
472        let q1 = "SELECT * FROM docs LIMIT 1";
473        let q2 = "SELECT * FROM docs LIMIT 2";
474        let q3 = "SELECT * FROM docs LIMIT 3";
475
476        let _ = cache.parse(q1);
477        let _ = cache.parse(q2);
478        let _ = cache.parse(q1); // hit -> q1 gets the referenced bit
479        let _ = cache.parse(q3); // miss -> eviction sweep: q2 evicted, q1 spared
480
481        assert_eq!(cache.len(), 2);
482        // q1 still hits (was spared), q2 should now miss.
483        let hits_before = cache.stats().hits;
484        let _ = cache.parse(q1);
485        assert_eq!(cache.stats().hits, hits_before + 1, "q1 must survive");
486    }
487
488    #[test]
489    fn test_query_cache_hit_path_takes_no_write_lock() {
490        // Issue #903: a hit must not need the write lock. We hold a read guard on
491        // the cache and concurrently issue a hit from another thread; if the hit
492        // tried to take a write lock it would deadlock against our read guard.
493        use std::sync::Arc;
494        use std::thread;
495
496        let cache = Arc::new(QueryCache::new(10));
497        let q = "SELECT * FROM docs LIMIT 1";
498        let _ = cache.parse(q); // populate
499
500        let held = cache.inner.read(); // hold a shared lock for the whole test
501
502        let cache2 = Arc::clone(&cache);
503        let handle = thread::spawn(move || cache2.parse(q).map(|_| ()));
504
505        // If the hit path were write-locking, join() would block forever; the
506        // test harness would hang. A successful join proves the hit is read-only.
507        let res = handle
508            .join()
509            .expect("hit thread must finish without deadlock");
510        assert!(res.is_ok());
511        drop(held);
512    }
513
514    #[test]
515    fn test_query_cache_hit_returns_shared_arc() {
516        // Issue #903: a hit returns Arc::clone of the stored AST, not a deep copy.
517        let cache = QueryCache::new(10);
518        let q = "SELECT * FROM docs LIMIT 1";
519
520        let first = cache.parse(q).expect("parse");
521        let second = cache.parse(q).expect("hit");
522
523        assert!(
524            Arc::ptr_eq(&first, &second),
525            "hit must return the same Arc allocation (no deep clone)"
526        );
527        // The cache also retains its own reference, so strong count is >= 3.
528        assert!(Arc::strong_count(&first) >= 3);
529    }
530
531    #[test]
532    fn test_query_cache_concurrent_invariant_no_order_duplicates() {
533        use std::sync::Arc;
534        use std::thread;
535
536        let cache = Arc::new(QueryCache::new(32));
537        let queries = [
538            "SELECT * FROM docs LIMIT 1",
539            "SELECT * FROM docs LIMIT 2",
540            "SELECT * FROM docs LIMIT 3",
541            "SELECT * FROM docs LIMIT 4",
542            "SELECT * FROM docs LIMIT 5",
543        ];
544
545        let mut handles = Vec::new();
546        for _ in 0..8 {
547            let cache = Arc::clone(&cache);
548            handles.push(thread::spawn(move || {
549                for i in 0..200 {
550                    let q = queries[i % queries.len()];
551                    let _ = cache.parse(q);
552                }
553            }));
554        }
555
556        for h in handles {
557            h.join().expect("thread must complete");
558        }
559
560        let inner = cache.inner.read();
561        let mut uniq = std::collections::HashSet::new();
562        for key in &inner.order {
563            assert!(uniq.insert(key.clone()), "duplicate query in CLOCK ring");
564        }
565        assert_eq!(inner.order.len(), cache.len());
566    }
567
568    #[test]
569    fn test_query_cache_collision_safe_with_forced_hash_collision() {
570        let cache = QueryCache::new_with_hasher(10, |_| 42);
571        let q1 = "SELECT * FROM docs LIMIT 1";
572        let q2 = "SELECT id FROM docs LIMIT 2";
573
574        let r1 = cache.parse(q1).expect("q1 should parse");
575        let r2 = cache.parse(q2).expect("q2 should parse");
576        let r1_again = cache.parse(q1).expect("q1 should be cache hit");
577
578        assert_eq!(r1, r1_again);
579        assert_ne!(r1, r2);
580        assert_eq!(cache.len(), 2);
581    }
582
583    #[test]
584    fn test_query_cache_min_size() {
585        let cache = QueryCache::new(0);
586        let _ = cache.parse("SELECT * FROM docs LIMIT 1");
587        assert!(!cache.is_empty());
588    }
589
590    #[test]
591    fn test_query_cache_invalid_query() {
592        let cache = QueryCache::new(10);
593        let result = cache.parse("INVALID QUERY SYNTAX!!!");
594        assert!(result.is_err());
595    }
596}