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 #[allow(clippy::cast_possible_truncation)] cache.insert(key, vec![create_test_node_id(i as u32)]);
381 }
382
383 let budget_stats = budget.stats();
385 assert!(budget_stats.total_entries <= 5, "Budget should be enforced");
386 assert!(
387 budget_stats.clamp_count > 0,
388 "Clamping should have occurred"
389 );
390
391 let cache_stats = cache.stats();
393 assert!(cache_stats.evictions > 0, "Evictions should be recorded");
394 }
395
396 #[test]
397 fn result_cache_budget_reset_on_clear() {
398 use super::super::CacheBudgetController;
399 use std::sync::Arc;
400
401 let budget = Arc::new(CacheBudgetController::new());
402 let cache = ResultCache::with_budget(100, Some(Arc::clone(&budget)));
403
404 for i in 0..5 {
406 let key = CacheKey {
407 query_hash: i,
408 plugin_hash: 0,
409 file_set_hash: 0,
410 root_path_hash: 0,
411 repo_filter_hash: 0,
412 };
413 #[allow(clippy::cast_possible_truncation)] cache.insert(key, vec![create_test_node_id(i as u32)]);
415 }
416
417 let stats_before = budget.stats();
418 assert!(stats_before.total_entries > 0);
419
420 cache.clear();
422
423 let stats_after = budget.stats();
425 assert_eq!(stats_after.total_entries, 0);
426 assert_eq!(stats_after.estimated_memory_bytes, 0);
427 }
428
429 #[test]
430 fn result_cache_without_budget() {
431 let cache = ResultCache::new(10);
433
434 for i in 0..15 {
435 let key = CacheKey {
436 query_hash: i,
437 plugin_hash: 0,
438 file_set_hash: 0,
439 root_path_hash: 0,
440 repo_filter_hash: 0,
441 };
442 #[allow(clippy::cast_possible_truncation)] cache.insert(key, vec![create_test_node_id(i as u32)]);
444 }
445
446 assert_eq!(cache.len(), 10);
448 let stats = cache.stats();
449 assert_eq!(stats.evictions, 5); }
451
452 #[test]
453 fn tiny_lfu_preserves_hot_results() {
454 let cache = ResultCache::with_policy_kind(3, None, CachePolicyKind::TinyLfu);
455 let hot_key = CacheKey {
456 query_hash: 1,
457 plugin_hash: 0,
458 file_set_hash: 0,
459 root_path_hash: 0,
460 repo_filter_hash: 0,
461 };
462 cache.insert(hot_key.clone(), vec![create_test_node_id(42)]);
463 for _ in 0..5 {
464 assert!(cache.get(&hot_key).is_some());
465 }
466
467 for i in 0_u64..20 {
468 let key = CacheKey {
469 query_hash: 100 + i,
470 plugin_hash: 0,
471 file_set_hash: 0,
472 root_path_hash: 0,
473 repo_filter_hash: 0,
474 };
475 #[allow(clippy::cast_possible_truncation)] cache.insert(key, vec![create_test_node_id(100 + i as u32)]);
477 }
478
479 assert!(
480 cache.get(&hot_key).is_some(),
481 "hot entry should survive cache churn"
482 );
483 assert!(cache.policy_metrics().lfu_rejects > 0);
484 }
485}