Skip to main content

sqry_core/query/cache/
ast_parse_cache.rs

1//! AST parse cache: query string → parsed `QueryAST` (`Arc<ParsedQuery>`)
2//!
3//! # Design
4//!
5//! This cache stores `Arc<ParsedQuery>` to enable zero-copy cache hits.
6//! Cache hits only clone the Arc (atomic increment + pointer copy ~3-5ns),
7//! not the entire `ParsedQuery` structure (which would be 50-200ns).
8//!
9//! **Key Design Choice**: Shares the adaptive TinyLFU/LRU policy facade with the
10//! primary symbol cache so cache hits feed telemetry and the CLI debug output.
11//!
12//! # Design Notes
13//!
14//! - **Storage**: `Arc<ParsedQuery>` (boolean AST + repo filter + normalized string)
15//! - **Purpose**: Boolean query language (AND/OR/NOT operators)
16//! - **Normalization**: Cache key is the normalized query (repo predicates stripped)
17//! - **Integration**: Used by `parse_query_ast()` in `QueryExecutor`
18//!
19//! # Performance
20//!
21//! Target metrics:
22//! - Single-threaded: 15-20ns (Arc clone + hash)
23//! - Multi-threaded (8 threads): 40-80ns (no lock contention)
24//! - Production (realistic load): 50-100ns
25//!
26//! # Thread Safety
27//!
28//! `AstParseCache` stores `Arc<ParsedQuery>` values behind an internal
29//! `RwLock<LruCache<...>>`. Hits clone the Arc (cheap), and eviction decisions
30//! are coordinated through the shared `CachePolicy` facade.
31//!
32//! # Usage
33//!
34//! ```rust
35//! use sqry_core::query::cache::AstParseCache;
36//! use sqry_core::query::{QueryParser, ParsedQuery};
37//! use std::sync::Arc;
38//!
39//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
40//! let cache = AstParseCache::new(1000);
41//!
42//! // Parse and cache (normally done by QueryExecutor)
43//! let ast = QueryParser::parse_query("kind:function AND name:test")?;
44//! let parsed = ParsedQuery::from_ast(Arc::new(ast))?;
45//! cache.insert("kind:function AND name:test".into(), parsed);
46//!
47//! // Cache hit (Arc clone, ~5ns)
48//! let cached = cache.get("kind:function AND name:test").unwrap();
49//! # Ok(())
50//! # }
51//! ```
52
53use super::types::CacheStats;
54use crate::cache::CacheConfig;
55use crate::cache::policy::{
56    CacheAdmission, CachePolicy, CachePolicyConfig, CachePolicyKind, build_cache_policy,
57};
58use crate::query::ParsedQuery;
59use log::{debug, warn};
60use lru::LruCache;
61use parking_lot::RwLock;
62use std::num::NonZeroUsize;
63use std::sync::Arc;
64use std::sync::atomic::{AtomicU64, Ordering};
65
66/// Minimum capacity enforced for `AstParseCache`
67const MIN_CAPACITY: u64 = 1;
68const AST_ENTRY_WEIGHT_BYTES: u64 = 2048;
69
70/// AST parse cache: query string → `Arc<ParsedQuery>` (boolean query AST)
71pub struct AstParseCache {
72    cache: RwLock<LruCache<String, Arc<ParsedQuery>>>,
73    capacity: usize,
74    policy: Arc<dyn CachePolicy<String>>,
75    hits: AtomicU64,
76    misses: AtomicU64,
77    evictions: AtomicU64,
78}
79
80impl AstParseCache {
81    /// Create new AST parse cache with capacity
82    #[must_use]
83    pub fn new(capacity: usize) -> Self {
84        let cap = if capacity == 0 {
85            warn!("AstParseCache::new received zero capacity; defaulting to {MIN_CAPACITY}");
86            #[allow(clippy::cast_possible_truncation)]
87            {
88                MIN_CAPACITY as usize
89            }
90        } else {
91            capacity
92        };
93        let (kind, window_ratio) = Self::policy_params_from_env();
94        Self::with_policy(cap, kind, window_ratio)
95    }
96
97    fn with_policy(capacity: usize, kind: CachePolicyKind, window_ratio: f32) -> Self {
98        #[allow(clippy::cast_possible_truncation)]
99        let normalized_capacity = capacity.max(MIN_CAPACITY as usize);
100        let cap = NonZeroUsize::new(normalized_capacity).unwrap_or(NonZeroUsize::MIN);
101        let policy_config = CachePolicyConfig::new(kind, normalized_capacity as u64, window_ratio);
102        Self {
103            cache: RwLock::new(LruCache::new(cap)),
104            capacity: normalized_capacity,
105            policy: build_cache_policy(&policy_config),
106            hits: AtomicU64::new(0),
107            misses: AtomicU64::new(0),
108            evictions: AtomicU64::new(0),
109        }
110    }
111
112    fn policy_params_from_env() -> (CachePolicyKind, f32) {
113        let cfg = CacheConfig::from_env();
114        (cfg.policy_kind(), cfg.policy_window_ratio())
115    }
116
117    fn handle_policy_evictions(&self) {
118        let evicted = self.policy.drain_evictions();
119        if evicted.is_empty() {
120            return;
121        }
122        let mut cache = self.cache.write();
123        for eviction in evicted {
124            if cache.pop(&eviction.key).is_some() {
125                self.evictions.fetch_add(1, Ordering::Relaxed);
126            }
127        }
128    }
129
130    /// Get parsed query from cache
131    pub fn get(&self, query_str: &str) -> Option<Arc<ParsedQuery>> {
132        self.handle_policy_evictions();
133        let mut cache = self.cache.write();
134        if let Some(parsed_arc) = cache.get(query_str) {
135            self.hits.fetch_add(1, Ordering::Relaxed);
136            let key = query_str.to_owned();
137            let _ = self.policy.record_hit(&key);
138            Some(parsed_arc.clone())
139        } else {
140            self.misses.fetch_add(1, Ordering::Relaxed);
141            None
142        }
143    }
144
145    /// Insert parsed query into cache
146    pub fn insert(&self, query_str: String, parsed: ParsedQuery) {
147        self.insert_arc(query_str, Arc::new(parsed));
148    }
149
150    /// Insert Arc-wrapped `ParsedQuery` into cache
151    pub fn insert_arc(&self, query_str: String, parsed_arc: Arc<ParsedQuery>) {
152        self.handle_policy_evictions();
153
154        let key_clone = query_str.clone();
155        if matches!(
156            self.policy.admit(&key_clone, AST_ENTRY_WEIGHT_BYTES),
157            CacheAdmission::Rejected
158        ) {
159            debug!(
160                "AST parse cache policy {:?} rejected entry",
161                self.policy.kind()
162            );
163            return;
164        }
165
166        let mut cache = self.cache.write();
167        if cache.len() == self.capacity
168            && let Some((evicted_key, _)) = cache.pop_lru()
169        {
170            self.policy.invalidate(&evicted_key);
171            self.evictions.fetch_add(1, Ordering::Relaxed);
172        }
173        cache.put(query_str, parsed_arc);
174    }
175
176    /// Get cache statistics
177    pub fn stats(&self) -> CacheStats {
178        CacheStats {
179            hits: self.hits.load(Ordering::Relaxed),
180            misses: self.misses.load(Ordering::Relaxed),
181            evictions: self.evictions.load(Ordering::Relaxed),
182        }
183    }
184
185    /// Clear cache (for testing and invalidation)
186    pub fn clear(&self) {
187        let mut cache = self.cache.write();
188        cache.clear();
189        self.hits.store(0, Ordering::Relaxed);
190        self.misses.store(0, Ordering::Relaxed);
191        self.evictions.store(0, Ordering::Relaxed);
192        self.policy.reset();
193    }
194
195    /// Get current cache size
196    pub fn len(&self) -> usize {
197        self.cache.read().len()
198    }
199
200    /// Check if cache is empty
201    pub fn is_empty(&self) -> bool {
202        self.len() == 0
203    }
204
205    #[cfg(test)]
206    fn with_policy_kind(capacity: usize, kind: CachePolicyKind) -> Self {
207        Self::with_policy(capacity, kind, CacheConfig::DEFAULT_POLICY_WINDOW_RATIO)
208    }
209
210    #[cfg(test)]
211    fn policy_metrics(&self) -> crate::cache::policy::CachePolicyMetrics {
212        self.policy.stats()
213    }
214}
215
216// Thread safety: moka::sync::Cache<K, V> is Send + Sync
217// AtomicU64 is Send + Sync
218// No manual unsafe impl needed
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::cache::policy::CachePolicyKind;
224    use crate::query::types::{Condition, Expr, Field, Operator, Query as QueryAST, Span, Value};
225
226    fn make_test_parsed_query(_query_str: &str) -> ParsedQuery {
227        // Create simple AST for testing
228        let ast = QueryAST {
229            root: Expr::Condition(Condition {
230                field: Field::new("kind"),
231                operator: Operator::Equal,
232                value: Value::String("function".to_string()),
233                span: Span::new(0, 13),
234            }),
235            span: Span::new(0, 13),
236        };
237
238        ParsedQuery::from_ast(Arc::new(ast)).unwrap()
239    }
240
241    #[test]
242    fn ast_parse_cache_hit() {
243        let cache = AstParseCache::new(100);
244        let query_str = "kind:function";
245        let parsed = make_test_parsed_query(query_str);
246
247        // Insert
248        cache.insert(query_str.to_string(), parsed.clone());
249
250        // Hit
251        let cached = cache.get(query_str).unwrap();
252        assert_eq!(cached.normalized.as_ref(), parsed.normalized.as_ref());
253
254        // Stats
255        let stats = cache.stats();
256        assert_eq!(stats.hits, 1);
257        assert_eq!(stats.misses, 0);
258    }
259
260    #[test]
261    fn ast_parse_cache_miss() {
262        let cache = AstParseCache::new(100);
263
264        // Miss
265        let result = cache.get("kind:function");
266        assert!(result.is_none());
267
268        // Stats
269        let stats = cache.stats();
270        assert_eq!(stats.hits, 0);
271        assert_eq!(stats.misses, 1);
272    }
273
274    #[test]
275    fn ast_parse_cache_eviction() {
276        let cache = AstParseCache::new(2);
277
278        cache.insert("q1".into(), make_test_parsed_query("q1"));
279        cache.insert("q2".into(), make_test_parsed_query("q2"));
280        cache.insert("q3".into(), make_test_parsed_query("q3"));
281
282        // After eviction, we should have at most 2 entries
283        let count = cache.len();
284        assert!(
285            count <= 2,
286            "Cache should have at most 2 entries after eviction, got {count}"
287        );
288
289        // Validate eviction stats were incremented by the eviction listener
290        let stats = cache.stats();
291        assert!(
292            stats.evictions >= 1,
293            "Eviction counter should be incremented (got {})",
294            stats.evictions
295        );
296    }
297
298    #[test]
299    fn ast_parse_cache_clear() {
300        let cache = AstParseCache::new(100);
301        cache.insert("q1".into(), make_test_parsed_query("q1"));
302
303        cache.clear();
304
305        assert_eq!(cache.len(), 0);
306        assert!(cache.get("q1").is_none());
307    }
308
309    #[test]
310    fn ast_parse_cache_zero_capacity_defaults_to_one() {
311        let cache = AstParseCache::new(0);
312
313        cache.insert("q1".into(), make_test_parsed_query("q1"));
314        cache.insert("q2".into(), make_test_parsed_query("q2"));
315
316        // Capacity should clamp to 1, so we should have at most 1 entry
317        let count = cache.len();
318        assert!(
319            count <= 1,
320            "Cache with capacity 1 should have at most 1 entry, got {count}"
321        );
322    }
323
324    #[test]
325    fn ast_parse_cache_arc_sharing() {
326        let cache = AstParseCache::new(100);
327        let query_str = "kind:function";
328        let parsed = make_test_parsed_query(query_str);
329
330        // Insert
331        cache.insert(query_str.to_string(), parsed);
332
333        // Two cache hits should return same Arc pointer
334        let cached1 = cache.get(query_str).unwrap();
335        let cached2 = cache.get(query_str).unwrap();
336
337        // Verify Arc pointer equality (zero-copy sharing)
338        assert!(Arc::ptr_eq(&cached1, &cached2));
339    }
340
341    #[test]
342    fn tiny_lfu_preserves_hot_queries() {
343        let cache = AstParseCache::with_policy_kind(3, CachePolicyKind::TinyLfu);
344        cache.insert("hot".into(), make_test_parsed_query("hot"));
345
346        for i in 0..20 {
347            let query = format!("cold{i}");
348            cache.insert(query.clone(), make_test_parsed_query(&query));
349        }
350
351        let metrics = cache.policy_metrics();
352        assert!(
353            metrics.lfu_rejects > 0,
354            "expected TinyLFU to reject cold entries"
355        );
356    }
357}