Skip to main content

sqry_core/query/cache/
result_cache.rs

1//! Result cache: `CacheKey` → `Vec<NodeId>`
2
3use super::budget::{CacheBudgetController, ClampAction};
4use super::types::{CacheKey, CacheStats};
5use crate::cache::CacheConfig;
6use crate::cache::policy::{
7    CacheAdmission, CachePolicy, CachePolicyConfig, CachePolicyKind, build_cache_policy,
8};
9use crate::graph::unified::node::NodeId;
10use log::debug;
11use lru::LruCache;
12use parking_lot::RwLock;
13use std::num::NonZeroUsize;
14use std::sync::Arc;
15
16/// Result cache: `CacheKey` → `Vec<NodeId>`
17pub struct ResultCache {
18    /// LRU cache: `CacheKey` directly (no manual hashing)
19    cache: RwLock<LruCache<CacheKey, Vec<NodeId>>>,
20
21    /// Statistics
22    stats: RwLock<CacheStats>,
23
24    /// Optional budget controller for memory limiting
25    budget_controller: Option<Arc<CacheBudgetController>>,
26
27    /// Adaptive eviction policy shared with core cache
28    policy: Arc<dyn CachePolicy<CacheKey>>,
29}
30
31impl ResultCache {
32    /// Create new result cache with capacity
33    #[must_use]
34    pub fn new(capacity: usize) -> Self {
35        let cap = capacity.max(1);
36        Self::with_budget(cap, None)
37    }
38
39    /// Create new result cache with optional budget controller
40    #[must_use]
41    pub fn with_budget(
42        capacity: usize,
43        budget_controller: Option<Arc<CacheBudgetController>>,
44    ) -> Self {
45        let normalized_capacity = capacity.max(1);
46        let cap = NonZeroUsize::new(normalized_capacity).unwrap_or(NonZeroUsize::MIN);
47        let (kind, window_ratio) = Self::policy_params_from_env();
48        let policy_config = CachePolicyConfig::new(kind, normalized_capacity as u64, window_ratio);
49        Self {
50            cache: RwLock::new(LruCache::new(cap)),
51            stats: RwLock::new(CacheStats::default()),
52            budget_controller,
53            policy: build_cache_policy(&policy_config),
54        }
55    }
56
57    /// Get cached results
58    pub fn get(&self, key: &CacheKey) -> Option<Vec<NodeId>> {
59        self.handle_policy_evictions();
60        let mut cache = self.cache.write();
61
62        if let Some(results) = cache.get(key) {
63            // Cache hit: update stats and return clone
64            let mut stats = self.stats.write();
65            stats.hits += 1;
66            drop(stats);
67            let _ = self.policy.record_hit(key);
68            Some(results.clone())
69        } else {
70            // Cache miss
71            let mut stats = self.stats.write();
72            stats.misses += 1;
73            None
74        }
75    }
76
77    /// Insert results into cache with LRU eviction and budget enforcement
78    pub fn insert(&self, key: CacheKey, results: Vec<NodeId>) {
79        self.handle_policy_evictions();
80        let cache = self.cache.read();
81        let is_update = cache.contains(&key);
82        drop(cache);
83
84        let estimated_bytes = if let Some(budget) = &self.budget_controller {
85            results.len() * budget.config().estimated_symbol_size
86        } else {
87            0
88        };
89
90        let mut budget_recorded = false;
91        if let Some(budget) = &self.budget_controller {
92            if !is_update {
93                budget.record_insert(1, estimated_bytes);
94                budget_recorded = true;
95            }
96
97            match budget.check_budget() {
98                ClampAction::Evict { count, .. } => {
99                    self.evict_entries(count);
100                    budget.record_clamp();
101                }
102                ClampAction::None => {}
103            }
104        }
105
106        if matches!(
107            self.policy.admit(&key, estimated_bytes as u64),
108            CacheAdmission::Rejected
109        ) {
110            if let Some(budget) = &self.budget_controller
111                && budget_recorded
112            {
113                budget.record_remove(1, estimated_bytes);
114            }
115            debug!(
116                "result cache policy {:?} rejected entry",
117                self.policy.kind()
118            );
119            return;
120        }
121
122        let mut cache = self.cache.write();
123        if cache.len() == cache.cap().get()
124            && !is_update
125            && let Some((evicted_key, _)) = cache.pop_lru()
126        {
127            self.policy.invalidate(&evicted_key);
128            {
129                let mut stats = self.stats.write();
130                stats.evictions += 1;
131            }
132            if let Some(budget) = &self.budget_controller {
133                budget.record_remove(1, budget.config().estimated_symbol_size);
134            }
135        }
136
137        cache.put(key, results);
138    }
139
140    /// Evict entries to reduce cache size
141    fn evict_entries(&self, count: usize) {
142        let mut cache = self.cache.write();
143        let to_evict = count.min(cache.len());
144
145        for _ in 0..to_evict {
146            if let Some((evicted_key, _)) = cache.pop_lru() {
147                self.policy.invalidate(&evicted_key);
148                // Record eviction
149                let mut stats = self.stats.write();
150                stats.evictions += 1;
151
152                // Update budget tracking
153                if let Some(budget) = &self.budget_controller {
154                    budget.record_remove(1, budget.config().estimated_symbol_size);
155                }
156            }
157        }
158    }
159
160    /// Clear all cache entries (on invalidation)
161    pub fn clear(&self) {
162        let mut cache = self.cache.write();
163        cache.clear();
164
165        // Reset budget tracking
166        if let Some(budget) = &self.budget_controller {
167            budget.reset();
168        }
169
170        self.policy.reset();
171    }
172
173    /// Get cache statistics
174    pub fn stats(&self) -> CacheStats {
175        self.stats.read().clone()
176    }
177
178    /// Get current cache size
179    pub fn len(&self) -> usize {
180        self.cache.read().len()
181    }
182
183    /// Check if cache is empty
184    pub fn is_empty(&self) -> bool {
185        self.len() == 0
186    }
187
188    fn handle_policy_evictions(&self) {
189        let evicted = self.policy.drain_evictions();
190        if evicted.is_empty() {
191            return;
192        }
193        let mut cache = self.cache.write();
194        for eviction in evicted {
195            if cache.pop(&eviction.key).is_some() {
196                {
197                    let mut stats = self.stats.write();
198                    stats.evictions += 1;
199                }
200                if let Some(budget) = &self.budget_controller {
201                    budget.record_remove(1, budget.config().estimated_symbol_size);
202                }
203            }
204        }
205    }
206
207    fn policy_params_from_env() -> (CachePolicyKind, f32) {
208        let cfg = CacheConfig::from_env();
209        (cfg.policy_kind(), cfg.policy_window_ratio())
210    }
211
212    #[cfg(test)]
213    fn with_policy_kind(
214        capacity: usize,
215        budget_controller: Option<Arc<CacheBudgetController>>,
216        kind: CachePolicyKind,
217    ) -> Self {
218        Self {
219            cache: RwLock::new(LruCache::new(
220                NonZeroUsize::new(capacity.max(1)).unwrap_or(NonZeroUsize::MIN),
221            )),
222            stats: RwLock::new(CacheStats::default()),
223            budget_controller,
224            policy: build_cache_policy(&CachePolicyConfig::new(
225                kind,
226                capacity.max(1) as u64,
227                CacheConfig::DEFAULT_POLICY_WINDOW_RATIO,
228            )),
229        }
230    }
231
232    #[cfg(test)]
233    fn policy_metrics(&self) -> crate::cache::policy::CachePolicyMetrics {
234        self.policy.stats()
235    }
236}
237
238// Thread safety: parking_lot::RwLock<T> is Send + Sync when T: Send + Sync
239// No manual unsafe impl needed
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::cache::policy::CachePolicyKind;
245
246    fn create_test_node_id(index: u32) -> NodeId {
247        NodeId::new(index, 1)
248    }
249
250    #[test]
251    fn result_cache_hit() {
252        let cache = ResultCache::new(100);
253        let key = CacheKey {
254            query_hash: 123,
255            plugin_hash: 456,
256            file_set_hash: 789,
257            root_path_hash: 101,
258            repo_filter_hash: 0,
259        };
260        let results = vec![create_test_node_id(1)];
261
262        // Insert
263        cache.insert(key.clone(), results.clone());
264
265        // Hit
266        let cached = cache.get(&key).unwrap();
267        assert_eq!(cached.len(), 1);
268        assert_eq!(cached[0], NodeId::new(1, 1));
269
270        // Stats
271        let stats = cache.stats();
272        assert_eq!(stats.hits, 1);
273        assert_eq!(stats.misses, 0);
274    }
275
276    #[test]
277    fn result_cache_miss() {
278        let cache = ResultCache::new(100);
279        let key = CacheKey {
280            query_hash: 123,
281            plugin_hash: 456,
282            file_set_hash: 789,
283            root_path_hash: 101,
284            repo_filter_hash: 0,
285        };
286
287        // Miss
288        let result = cache.get(&key);
289        assert!(result.is_none());
290
291        // Stats
292        let stats = cache.stats();
293        assert_eq!(stats.hits, 0);
294        assert_eq!(stats.misses, 1);
295    }
296
297    #[test]
298    fn result_cache_eviction() {
299        let cache = ResultCache::new(2);
300
301        let key1 = CacheKey {
302            query_hash: 1,
303            plugin_hash: 0,
304            file_set_hash: 0,
305            root_path_hash: 0,
306            repo_filter_hash: 0,
307        };
308        let key2 = CacheKey {
309            query_hash: 2,
310            plugin_hash: 0,
311            file_set_hash: 0,
312            root_path_hash: 0,
313            repo_filter_hash: 0,
314        };
315        let key3 = CacheKey {
316            query_hash: 3,
317            plugin_hash: 0,
318            file_set_hash: 0,
319            root_path_hash: 0,
320            repo_filter_hash: 0,
321        };
322
323        cache.insert(key1.clone(), vec![create_test_node_id(1)]);
324        cache.insert(key2.clone(), vec![create_test_node_id(2)]);
325        cache.insert(key3.clone(), vec![create_test_node_id(3)]); // Evicts key1
326
327        assert!(cache.get(&key1).is_none()); // Evicted
328        assert!(cache.get(&key2).is_some());
329        assert!(cache.get(&key3).is_some());
330
331        let stats = cache.stats();
332        assert_eq!(stats.evictions, 1);
333    }
334
335    #[test]
336    fn result_cache_clear() {
337        let cache = ResultCache::new(100);
338        let key = CacheKey {
339            query_hash: 1,
340            plugin_hash: 0,
341            file_set_hash: 0,
342            root_path_hash: 0,
343            repo_filter_hash: 0,
344        };
345
346        cache.insert(key.clone(), vec![create_test_node_id(10)]);
347
348        cache.clear();
349        assert_eq!(cache.len(), 0);
350        assert!(cache.get(&key).is_none());
351    }
352
353    #[test]
354    fn result_cache_with_budget_enforcement() {
355        use super::super::{BudgetConfig, CacheBudgetController};
356        use std::sync::Arc;
357
358        // Create budget controller with small limits
359        let budget_config = BudgetConfig {
360            max_entries: 5,
361            max_memory_bytes: 10_000,
362            estimated_symbol_size: 512,
363            ..Default::default()
364        };
365        let budget = Arc::new(CacheBudgetController::with_config(budget_config));
366
367        // Create cache with budget
368        let cache = ResultCache::with_budget(100, Some(Arc::clone(&budget)));
369
370        // Insert 10 entries (should trigger budget enforcement)
371        for i in 0..10 {
372            let key = CacheKey {
373                query_hash: i,
374                plugin_hash: 0,
375                file_set_hash: 0,
376                root_path_hash: 0,
377                repo_filter_hash: 0,
378            };
379            cache.insert(key, vec![create_test_node_id(i as u32)]);
380        }
381
382        // Cache should have evicted some entries to stay within budget
383        let budget_stats = budget.stats();
384        assert!(budget_stats.total_entries <= 5, "Budget should be enforced");
385        assert!(
386            budget_stats.clamp_count > 0,
387            "Clamping should have occurred"
388        );
389
390        // Cache evictions should have been recorded
391        let cache_stats = cache.stats();
392        assert!(cache_stats.evictions > 0, "Evictions should be recorded");
393    }
394
395    #[test]
396    fn result_cache_budget_reset_on_clear() {
397        use super::super::CacheBudgetController;
398        use std::sync::Arc;
399
400        let budget = Arc::new(CacheBudgetController::new());
401        let cache = ResultCache::with_budget(100, Some(Arc::clone(&budget)));
402
403        // Insert some entries
404        for i in 0..5 {
405            let key = CacheKey {
406                query_hash: i,
407                plugin_hash: 0,
408                file_set_hash: 0,
409                root_path_hash: 0,
410                repo_filter_hash: 0,
411            };
412            cache.insert(key, vec![create_test_node_id(i as u32)]);
413        }
414
415        let stats_before = budget.stats();
416        assert!(stats_before.total_entries > 0);
417
418        // Clear cache
419        cache.clear();
420
421        // Budget should be reset
422        let stats_after = budget.stats();
423        assert_eq!(stats_after.total_entries, 0);
424        assert_eq!(stats_after.estimated_memory_bytes, 0);
425    }
426
427    #[test]
428    fn result_cache_without_budget() {
429        // Cache without budget should work normally
430        let cache = ResultCache::new(10);
431
432        for i in 0..15 {
433            let key = CacheKey {
434                query_hash: i,
435                plugin_hash: 0,
436                file_set_hash: 0,
437                root_path_hash: 0,
438                repo_filter_hash: 0,
439            };
440            cache.insert(key, vec![create_test_node_id(i as u32)]);
441        }
442
443        // Should only evict based on LRU capacity, not budget
444        assert_eq!(cache.len(), 10);
445        let stats = cache.stats();
446        assert_eq!(stats.evictions, 5); // 15 - 10 = 5 evictions
447    }
448
449    #[test]
450    fn tiny_lfu_preserves_hot_results() {
451        let cache = ResultCache::with_policy_kind(3, None, CachePolicyKind::TinyLfu);
452        let hot_key = CacheKey {
453            query_hash: 1,
454            plugin_hash: 0,
455            file_set_hash: 0,
456            root_path_hash: 0,
457            repo_filter_hash: 0,
458        };
459        cache.insert(hot_key.clone(), vec![create_test_node_id(42)]);
460        for _ in 0..5 {
461            assert!(cache.get(&hot_key).is_some());
462        }
463
464        for i in 0_u64..20 {
465            let key = CacheKey {
466                query_hash: 100 + i,
467                plugin_hash: 0,
468                file_set_hash: 0,
469                root_path_hash: 0,
470                repo_filter_hash: 0,
471            };
472            cache.insert(key, vec![create_test_node_id(100 + i as u32)]);
473        }
474
475        assert!(
476            cache.get(&hot_key).is_some(),
477            "hot entry should survive cache churn"
478        );
479        assert!(cache.policy_metrics().lfu_rejects > 0);
480    }
481}