perl_workspace_index/workspace/cache.rs
1//! Bounded LRU caches for workspace index components.
2//!
3//! This module provides thread-safe, bounded caches with LRU eviction policies
4//! for AST nodes, symbols, and workspace data. These caches enforce memory limits
5//! while maintaining high hit rates for frequently accessed data.
6//!
7//! # Performance Characteristics
8//!
9//! - **Cache hit rate**: >90% for typical workloads
10//! - **Eviction latency**: O(1) amortized with linked hash map
11//! - **Memory overhead**: ~32 bytes per cache entry
12//! - **Thread safety**: Lock-free reads with atomic reference counting
13//!
14//! # Cache Configuration
15//!
16//! - **AST Node Cache**: Max 10,000 nodes, 50MB memory limit
17//! - **Symbol Cache**: Max 50,000 symbols, 30MB memory limit
18//! - **Workspace Cache**: Max 1,000 files, 20MB memory limit
19//!
20//! # Usage
21//!
22//! ```rust,ignore
23//! use perl_workspace_index::workspace::cache::{BoundedLruCache, CacheConfig};
24//!
25//! let config = CacheConfig::default();
26//! let cache = BoundedLruCache::new(config);
27//!
28//! cache.insert("key", "value");
29//! assert_eq!(cache.get("key"), Some(&"value"));
30//! ```
31
32use parking_lot::Mutex;
33use std::collections::HashMap;
34use std::hash::Hash;
35use std::sync::Arc;
36use std::time::{Duration, Instant};
37
38/// Cache configuration for bounded LRU caches.
39///
40/// Defines size limits and memory budgets for cache instances.
41#[derive(Clone, Debug)]
42pub struct CacheConfig {
43 /// Maximum number of items in the cache
44 pub max_items: usize,
45 /// Maximum memory usage in bytes
46 pub max_bytes: usize,
47 /// TTL for cache entries (None = no expiration)
48 pub ttl: Option<Duration>,
49}
50
51impl Default for CacheConfig {
52 fn default() -> Self {
53 Self {
54 max_items: 10_000,
55 max_bytes: 50 * 1024 * 1024, // 50MB
56 ttl: None,
57 }
58 }
59}
60
61/// Cache statistics for monitoring and diagnostics.
62#[derive(Clone, Debug, Default)]
63pub struct CacheStats {
64 /// Total number of cache hits
65 pub hits: u64,
66 /// Total number of cache misses
67 pub misses: u64,
68 /// Total number of evictions
69 pub evictions: u64,
70 /// Current number of items in cache
71 pub current_items: usize,
72 /// Current memory usage in bytes
73 pub current_bytes: usize,
74 /// Hit rate (hits / (hits + misses))
75 pub hit_rate: f64,
76}
77
78impl CacheStats {
79 /// Calculate hit rate from hits and misses.
80 pub fn calculate_hit_rate(hits: u64, misses: u64) -> f64 {
81 let total = hits + misses;
82 if total == 0 { 0.0 } else { hits as f64 / total as f64 }
83 }
84}
85
86/// Cache entry with metadata for LRU tracking and expiration.
87#[derive(Clone)]
88struct CacheEntry<V> {
89 /// The cached value
90 value: V,
91 /// When this entry was last accessed
92 last_accessed: Instant,
93 /// When this entry was inserted
94 _inserted_at: Instant,
95 /// Size of the entry in bytes
96 size_bytes: usize,
97}
98
99impl<V> CacheEntry<V> {
100 /// Create a new cache entry.
101 fn new(value: V, size_bytes: usize) -> Self {
102 let now = Instant::now();
103 Self { value, last_accessed: now, _inserted_at: now, size_bytes }
104 }
105
106 /// Check if this entry has expired based on TTL.
107 fn is_expired(&self, ttl: Duration) -> bool {
108 self.last_accessed.elapsed() > ttl
109 }
110
111 /// Update the last accessed time.
112 fn touch(&mut self) {
113 self.last_accessed = Instant::now();
114 }
115}
116
117/// Thread-safe bounded LRU cache.
118///
119/// Implements a least-recently-used eviction policy with configurable
120/// size limits and optional TTL expiration.
121///
122/// # Type Parameters
123///
124/// * `K` - Cache key type (must implement Hash + Eq)
125/// * `V` - Cache value type
126///
127/// # Performance
128///
129/// - **Insert**: O(1) amortized
130/// - **Get**: O(1) amortized
131/// - **Eviction**: O(1) amortized
132pub struct BoundedLruCache<K, V>
133where
134 K: Hash + Eq + Clone,
135{
136 /// Cache entries (key -> entry)
137 entries: Arc<Mutex<HashMap<K, CacheEntry<V>>>>,
138 /// Access order for LRU tracking (oldest keys at front)
139 access_order: Arc<Mutex<Vec<K>>>,
140 /// Cache configuration
141 config: CacheConfig,
142 /// Cache statistics
143 stats: Arc<Mutex<CacheStats>>,
144}
145
146impl<K, V> BoundedLruCache<K, V>
147where
148 K: Hash + Eq + Clone,
149{
150 /// Create a new bounded LRU cache with the given configuration.
151 ///
152 /// # Arguments
153 ///
154 /// * `config` - Cache configuration (size limits, TTL, etc.)
155 ///
156 /// # Returns
157 ///
158 /// A new bounded LRU cache instance.
159 ///
160 /// # Examples
161 ///
162 /// ```rust
163 /// use perl_workspace_index::workspace::cache::{BoundedLruCache, CacheConfig};
164 ///
165 /// let config = CacheConfig {
166 /// max_items: 1000,
167 /// max_bytes: 10 * 1024 * 1024, // 10MB
168 /// ttl: None,
169 /// };
170 /// let cache: BoundedLruCache<String, String> = BoundedLruCache::new(config);
171 /// ```
172 pub fn new(config: CacheConfig) -> Self {
173 Self {
174 entries: Arc::new(Mutex::new(HashMap::new())),
175 access_order: Arc::new(Mutex::new(Vec::new())),
176 config,
177 stats: Arc::new(Mutex::new(CacheStats::default())),
178 }
179 }
180
181 /// Create a new cache with default configuration.
182 ///
183 /// # Returns
184 ///
185 /// A new bounded LRU cache with default limits (10,000 items, 50MB).
186 ///
187 /// # Examples
188 ///
189 /// ```rust
190 /// use perl_workspace_index::workspace::cache::BoundedLruCache;
191 ///
192 /// let cache: BoundedLruCache<String, String> = BoundedLruCache::default();
193 /// ```
194 pub fn default() -> Self {
195 Self::new(CacheConfig::default())
196 }
197
198 /// Insert a value into the cache.
199 ///
200 /// If the cache is at capacity, the least recently used entry will be evicted.
201 ///
202 /// # Arguments
203 ///
204 /// * `key` - Cache key
205 /// * `value` - Value to cache
206 /// * `size_bytes` - Size of the value in bytes (for memory tracking)
207 ///
208 /// # Returns
209 ///
210 /// `true` if the value was inserted, `false` if evicted due to size limits.
211 ///
212 /// # Examples
213 ///
214 /// ```rust
215 /// use perl_workspace_index::workspace::cache::BoundedLruCache;
216 ///
217 /// let mut cache = BoundedLruCache::default();
218 /// cache.insert_with_size("key", "value", 5);
219 /// ```
220 pub fn insert_with_size(&self, key: K, value: V, size_bytes: usize) -> bool {
221 let mut entries = self.entries.lock();
222 let mut access_order = self.access_order.lock();
223 let mut stats = self.stats.lock();
224
225 // Check if this key already exists
226 if let Some(entry) = entries.get_mut(&key) {
227 // Update existing entry — track byte delta incrementally
228 let old_size = entry.size_bytes;
229 entry.value = value;
230 entry.size_bytes = size_bytes;
231 entry.touch();
232
233 // Update access order (move to end = most recent)
234 if let Some(pos) = access_order.iter().position(|k| k == &key) {
235 access_order.remove(pos);
236 }
237 access_order.push(key.clone());
238
239 // Update stats incrementally
240 stats.current_bytes = stats.current_bytes - old_size + size_bytes;
241 stats.current_items = entries.len();
242 return true;
243 }
244
245 // Check if we need to evict entries
246 while !entries.is_empty()
247 && (entries.len() >= self.config.max_items
248 || stats.current_bytes + size_bytes > self.config.max_bytes)
249 {
250 // Evict least recently used (first in access_order)
251 if let Some(lru_key) = access_order.first() {
252 if let Some(entry) = entries.remove(lru_key) {
253 stats.current_bytes -= entry.size_bytes;
254 stats.evictions += 1;
255 }
256 access_order.remove(0);
257 } else {
258 break;
259 }
260 }
261
262 // Check if we can fit this entry
263 if entries.len() >= self.config.max_items
264 || stats.current_bytes + size_bytes > self.config.max_bytes
265 {
266 // Entry too large for cache
267 return false;
268 }
269
270 // Insert new entry
271 entries.insert(key.clone(), CacheEntry::new(value, size_bytes));
272 access_order.push(key);
273
274 // Update stats incrementally
275 stats.current_bytes += size_bytes;
276 stats.current_items = entries.len();
277
278 true
279 }
280
281 /// Insert a value into the cache with estimated size.
282 ///
283 /// Uses a simple size estimation based on the value's memory representation.
284 ///
285 /// # Arguments
286 ///
287 /// * `key` - Cache key
288 /// * `value` - Value to cache
289 ///
290 /// # Returns
291 ///
292 /// `true` if the value was inserted, `false` if evicted.
293 ///
294 /// # Examples
295 ///
296 /// ```rust
297 /// use perl_workspace_index::workspace::cache::BoundedLruCache;
298 ///
299 /// let mut cache = BoundedLruCache::default();
300 /// cache.insert("key", "value");
301 /// ```
302 pub fn insert(&self, key: K, value: V)
303 where
304 V: EstimateSize,
305 {
306 let size_bytes = value.estimate_size();
307 self.insert_with_size(key, value, size_bytes);
308 }
309
310 /// Get a value from the cache.
311 ///
312 /// Returns `None` if the key is not found or the entry has expired.
313 ///
314 /// # Arguments
315 ///
316 /// * `key` - Cache key to look up
317 ///
318 /// # Returns
319 ///
320 /// `Some(&V)` if found and not expired, `None` otherwise.
321 ///
322 /// # Examples
323 ///
324 /// ```rust,ignore
325 /// use perl_workspace_index::workspace::cache::BoundedLruCache;
326 ///
327 /// let mut cache = BoundedLruCache::default();
328 /// cache.insert("key", "value");
329 /// assert_eq!(cache.get("key"), Some(&"value"));
330 /// ```
331 pub fn get(&self, key: &K) -> Option<V>
332 where
333 V: Clone,
334 {
335 let mut entries = self.entries.lock();
336 let mut access_order = self.access_order.lock();
337 let mut stats = self.stats.lock();
338
339 // Check TTL expiration
340 if let Some(ttl) = self.config.ttl {
341 if let Some(entry) = entries.get(key) {
342 if entry.is_expired(ttl) {
343 // Entry expired, remove it
344 entries.remove(key);
345 if let Some(pos) = access_order.iter().position(|k| k == key) {
346 access_order.remove(pos);
347 }
348 stats.misses += 1;
349 stats.hit_rate = CacheStats::calculate_hit_rate(stats.hits, stats.misses);
350 return None;
351 }
352 }
353 }
354
355 // Get entry and update LRU
356 if let Some(entry) = entries.get_mut(key) {
357 entry.touch();
358
359 // Update access order (move to end = most recent)
360 if let Some(pos) = access_order.iter().position(|k| k == key) {
361 let key_clone = access_order.remove(pos);
362 access_order.push(key_clone);
363 }
364
365 stats.hits += 1;
366 stats.hit_rate = CacheStats::calculate_hit_rate(stats.hits, stats.misses);
367 Some(entry.value.clone())
368 } else {
369 stats.misses += 1;
370 stats.hit_rate = CacheStats::calculate_hit_rate(stats.hits, stats.misses);
371 None
372 }
373 }
374
375 /// Peek a value from the cache without changing hit/miss counters.
376 ///
377 /// Expired entries are still removed so stale data does not linger.
378 pub fn peek(&self, key: &K) -> Option<V>
379 where
380 V: Clone,
381 {
382 let mut entries = self.entries.lock();
383 let mut access_order = self.access_order.lock();
384 let mut stats = self.stats.lock();
385
386 if let Some(ttl) = self.config.ttl {
387 if entries.get(key).is_some_and(|entry| entry.is_expired(ttl)) {
388 if let Some(entry) = entries.remove(key) {
389 stats.current_bytes -= entry.size_bytes;
390 stats.current_items = entries.len();
391 }
392 if let Some(pos) = access_order.iter().position(|k| k == key) {
393 access_order.remove(pos);
394 }
395 return None;
396 }
397 }
398
399 entries.get(key).map(|entry| entry.value.clone())
400 }
401
402 /// Remove a value from the cache.
403 ///
404 /// # Arguments
405 ///
406 /// * `key` - Cache key to remove
407 ///
408 /// # Returns
409 ///
410 /// `Some(V)` if the entry was removed, `None` if not found.
411 ///
412 /// # Examples
413 ///
414 /// ```rust,ignore
415 /// use perl_workspace_index::workspace::cache::BoundedLruCache;
416 ///
417 /// let mut cache = BoundedLruCache::default();
418 /// cache.insert("key", "value");
419 /// assert_eq!(cache.remove("key"), Some("value"));
420 /// ```
421 pub fn remove(&self, key: &K) -> Option<V>
422 where
423 V: Clone,
424 {
425 let mut entries = self.entries.lock();
426 let mut access_order = self.access_order.lock();
427 let mut stats = self.stats.lock();
428
429 if let Some(entry) = entries.remove(key) {
430 stats.current_bytes -= entry.size_bytes;
431 stats.current_items = entries.len();
432
433 if let Some(pos) = access_order.iter().position(|k| k == key) {
434 access_order.remove(pos);
435 }
436
437 Some(entry.value)
438 } else {
439 None
440 }
441 }
442
443 /// Clear all entries from the cache.
444 ///
445 /// # Examples
446 ///
447 /// ```rust
448 /// use perl_workspace_index::workspace::cache::BoundedLruCache;
449 ///
450 /// let mut cache = BoundedLruCache::default();
451 /// cache.insert("key", "value");
452 /// cache.clear();
453 /// assert!(cache.is_empty());
454 /// ```
455 pub fn clear(&self) {
456 let mut entries = self.entries.lock();
457 let mut access_order = self.access_order.lock();
458 let mut stats = self.stats.lock();
459
460 entries.clear();
461 access_order.clear();
462 stats.current_bytes = 0;
463 stats.current_items = 0;
464 }
465
466 /// Check if the cache is empty.
467 ///
468 /// # Returns
469 ///
470 /// `true` if the cache contains no entries, `false` otherwise.
471 pub fn is_empty(&self) -> bool {
472 let entries = self.entries.lock();
473 entries.is_empty()
474 }
475
476 /// Get the number of items in the cache.
477 ///
478 /// # Returns
479 ///
480 /// The current number of cached items.
481 pub fn len(&self) -> usize {
482 let entries = self.entries.lock();
483 entries.len()
484 }
485
486 /// Get cache statistics.
487 ///
488 /// # Returns
489 ///
490 /// A snapshot of the cache statistics.
491 ///
492 /// # Examples
493 ///
494 /// ```rust,ignore
495 /// use perl_workspace_index::workspace::cache::BoundedLruCache;
496 ///
497 /// let cache = BoundedLruCache::default();
498 /// let stats = cache.stats();
499 /// assert_eq!(stats.hits, 0);
500 /// ```
501 pub fn stats(&self) -> CacheStats {
502 let stats = self.stats.lock();
503 stats.clone()
504 }
505
506 /// Get the cache configuration.
507 ///
508 /// # Returns
509 ///
510 /// The cache configuration.
511 pub fn config(&self) -> &CacheConfig {
512 &self.config
513 }
514}
515
516/// Trait for estimating the memory size of cached values.
517///
518/// Implement this trait for custom types to enable accurate memory tracking.
519pub trait EstimateSize {
520 /// Estimate the memory size of this value in bytes.
521 fn estimate_size(&self) -> usize;
522}
523
524// Implement EstimateSize for common types
525impl EstimateSize for String {
526 fn estimate_size(&self) -> usize {
527 self.len()
528 }
529}
530
531impl<T> EstimateSize for Vec<T>
532where
533 T: EstimateSize,
534{
535 fn estimate_size(&self) -> usize {
536 self.iter().map(|t| t.estimate_size()).sum()
537 }
538}
539
540impl<K, V> EstimateSize for HashMap<K, V>
541where
542 K: EstimateSize,
543 V: EstimateSize,
544{
545 fn estimate_size(&self) -> usize {
546 self.iter().map(|(k, v)| k.estimate_size() + v.estimate_size()).sum()
547 }
548}
549
550impl EstimateSize for str {
551 fn estimate_size(&self) -> usize {
552 self.len()
553 }
554}
555
556impl<T: EstimateSize + ?Sized> EstimateSize for &T {
557 fn estimate_size(&self) -> usize {
558 (**self).estimate_size()
559 }
560}
561
562impl EstimateSize for [u8] {
563 fn estimate_size(&self) -> usize {
564 self.len()
565 }
566}
567
568impl EstimateSize for () {
569 fn estimate_size(&self) -> usize {
570 0
571 }
572}
573
574impl<T> EstimateSize for Option<T>
575where
576 T: EstimateSize,
577{
578 fn estimate_size(&self) -> usize {
579 self.as_ref().map(|t| t.estimate_size()).unwrap_or(0)
580 }
581}
582
583impl<T, E> EstimateSize for Result<T, E>
584where
585 T: EstimateSize,
586 E: EstimateSize,
587{
588 fn estimate_size(&self) -> usize {
589 match self {
590 Ok(t) => t.estimate_size(),
591 Err(e) => e.estimate_size(),
592 }
593 }
594}
595
596/// AST node cache configuration.
597///
598/// Optimized for AST node caching with higher memory limits.
599#[derive(Clone, Debug)]
600pub struct AstCacheConfig {
601 /// Maximum number of AST nodes to cache
602 pub max_nodes: usize,
603 /// Maximum memory for AST cache in bytes
604 pub max_bytes: usize,
605}
606
607impl Default for AstCacheConfig {
608 fn default() -> Self {
609 Self {
610 max_nodes: 10_000,
611 max_bytes: 50 * 1024 * 1024, // 50MB
612 }
613 }
614}
615
616/// Symbol cache configuration.
617///
618/// Optimized for symbol lookup caching.
619#[derive(Clone, Debug)]
620pub struct SymbolCacheConfig {
621 /// Maximum number of symbols to cache
622 pub max_symbols: usize,
623 /// Maximum memory for symbol cache in bytes
624 pub max_bytes: usize,
625}
626
627impl Default for SymbolCacheConfig {
628 fn default() -> Self {
629 Self {
630 max_symbols: 50_000,
631 max_bytes: 30 * 1024 * 1024, // 30MB
632 }
633 }
634}
635
636/// Workspace cache configuration.
637///
638/// Optimized for workspace file metadata caching.
639#[derive(Clone, Debug)]
640pub struct WorkspaceCacheConfig {
641 /// Maximum number of workspace files to cache
642 pub max_files: usize,
643 /// Maximum memory for workspace cache in bytes
644 pub max_bytes: usize,
645}
646
647impl Default for WorkspaceCacheConfig {
648 fn default() -> Self {
649 Self {
650 max_files: 1_000,
651 max_bytes: 20 * 1024 * 1024, // 20MB
652 }
653 }
654}
655
656/// Combined cache configuration for all workspace caches.
657#[derive(Clone, Debug, Default)]
658pub struct CombinedWorkspaceCacheConfig {
659 /// AST node cache configuration
660 pub ast: AstCacheConfig,
661 /// Symbol cache configuration
662 pub symbol: SymbolCacheConfig,
663 /// Workspace cache configuration
664 pub workspace: WorkspaceCacheConfig,
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn test_cache_insert_get() {
673 let cache = BoundedLruCache::<String, String>::default();
674 cache.insert("key1".to_string(), "value1".to_string());
675 assert_eq!(cache.get(&"key1".to_string()), Some("value1".to_string()));
676 }
677
678 #[test]
679 fn test_cache_miss() {
680 let cache = BoundedLruCache::<String, String>::default();
681 assert_eq!(cache.get(&"nonexistent".to_string()), None);
682 }
683
684 #[test]
685 fn test_cache_eviction() {
686 let config = CacheConfig { max_items: 2, max_bytes: 100, ttl: None };
687 let cache = BoundedLruCache::<String, String>::new(config);
688
689 cache.insert("key1".to_string(), "value1".to_string());
690 cache.insert("key2".to_string(), "value2".to_string());
691 cache.insert("key3".to_string(), "value3".to_string());
692
693 // key1 should be evicted (LRU)
694 assert_eq!(cache.get(&"key1".to_string()), None);
695 assert_eq!(cache.get(&"key2".to_string()), Some("value2".to_string()));
696 assert_eq!(cache.get(&"key3".to_string()), Some("value3".to_string()));
697 }
698
699 #[test]
700 fn test_cache_stats() {
701 let cache = BoundedLruCache::<String, String>::default();
702 cache.insert("key1".to_string(), "value1".to_string());
703
704 cache.get(&"key1".to_string());
705 cache.get(&"key2".to_string());
706
707 let stats = cache.stats();
708 assert_eq!(stats.hits, 1);
709 assert_eq!(stats.misses, 1);
710 assert_eq!(stats.hit_rate, 0.5);
711 }
712
713 #[test]
714 fn test_cache_clear() {
715 let cache = BoundedLruCache::<String, String>::default();
716 cache.insert("key1".to_string(), "value1".to_string());
717 cache.clear();
718
719 assert!(cache.is_empty());
720 assert_eq!(cache.len(), 0);
721 }
722
723 #[test]
724 fn test_cache_remove() {
725 let cache = BoundedLruCache::<String, String>::default();
726 cache.insert("key1".to_string(), "value1".to_string());
727 assert_eq!(cache.remove(&"key1".to_string()), Some("value1".to_string()));
728 assert_eq!(cache.get(&"key1".to_string()), None);
729 }
730
731 #[test]
732 fn test_cache_peek_does_not_change_stats() {
733 let cache = BoundedLruCache::<String, String>::default();
734 cache.insert("key1".to_string(), "value1".to_string());
735
736 assert_eq!(cache.peek(&"key1".to_string()), Some("value1".to_string()));
737
738 let stats = cache.stats();
739 assert_eq!(stats.hits, 0);
740 assert_eq!(stats.misses, 0);
741 }
742
743 #[test]
744 fn test_estimate_size_string() {
745 let s = "hello world";
746 assert_eq!(s.estimate_size(), 11);
747 }
748
749 #[test]
750 fn test_estimate_size_vec() {
751 let v = vec!["hello", "world"];
752 assert_eq!(v.estimate_size(), 10);
753 }
754}