1use 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
16pub struct ResultCache {
18 cache: RwLock<LruCache<CacheKey, Vec<NodeId>>>,
20
21 stats: RwLock<CacheStats>,
23
24 budget_controller: Option<Arc<CacheBudgetController>>,
26
27 policy: Arc<dyn CachePolicy<CacheKey>>,
29}
30
31impl ResultCache {
32 #[must_use]
34 pub fn new(capacity: usize) -> Self {
35 let cap = capacity.max(1);
36 Self::with_budget(cap, None)
37 }
38
39 #[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 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 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 let mut stats = self.stats.write();
72 stats.misses += 1;
73 None
74 }
75 }
76
77 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 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 let mut stats = self.stats.write();
150 stats.evictions += 1;
151
152 if let Some(budget) = &self.budget_controller {
154 budget.record_remove(1, budget.config().estimated_symbol_size);
155 }
156 }
157 }
158 }
159
160 pub fn clear(&self) {
162 let mut cache = self.cache.write();
163 cache.clear();
164
165 if let Some(budget) = &self.budget_controller {
167 budget.reset();
168 }
169
170 self.policy.reset();
171 }
172
173 pub fn stats(&self) -> CacheStats {
175 self.stats.read().clone()
176 }
177
178 pub fn len(&self) -> usize {
180 self.cache.read().len()
181 }
182
183 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#[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 cache.insert(key.clone(), results.clone());
264
265 let cached = cache.get(&key).unwrap();
267 assert_eq!(cached.len(), 1);
268 assert_eq!(cached[0], NodeId::new(1, 1));
269
270 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 let result = cache.get(&key);
289 assert!(result.is_none());
290
291 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)]); assert!(cache.get(&key1).is_none()); 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 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 let cache = ResultCache::with_budget(100, Some(Arc::clone(&budget)));
369
370 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 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 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 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 cache.clear();
420
421 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 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 assert_eq!(cache.len(), 10);
445 let stats = cache.stats();
446 assert_eq!(stats.evictions, 5); }
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}