Skip to main content

perl_workspace/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::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, VecDeque};
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<VecDeque<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::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(VecDeque::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::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::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_back(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.pop_front() {
252                if let Some(entry) = entries.remove(&lru_key) {
253                    stats.current_bytes -= entry.size_bytes;
254                    stats.evictions += 1;
255                }
256            } else {
257                break;
258            }
259        }
260
261        // Check if we can fit this entry
262        if entries.len() >= self.config.max_items
263            || stats.current_bytes + size_bytes > self.config.max_bytes
264        {
265            // Entry too large for cache
266            return false;
267        }
268
269        // Insert new entry
270        entries.insert(key.clone(), CacheEntry::new(value, size_bytes));
271        access_order.push_back(key);
272
273        // Update stats incrementally
274        stats.current_bytes += size_bytes;
275        stats.current_items = entries.len();
276
277        true
278    }
279
280    /// Insert a value into the cache with estimated size.
281    ///
282    /// Uses a simple size estimation based on the value's memory representation.
283    ///
284    /// # Arguments
285    ///
286    /// * `key` - Cache key
287    /// * `value` - Value to cache
288    ///
289    /// # Returns
290    ///
291    /// `true` if the value was inserted, `false` if evicted.
292    ///
293    /// # Examples
294    ///
295    /// ```rust
296    /// use perl_workspace::workspace::cache::BoundedLruCache;
297    ///
298    /// let mut cache = BoundedLruCache::default();
299    /// cache.insert("key", "value");
300    /// ```
301    pub fn insert(&self, key: K, value: V)
302    where
303        V: EstimateSize,
304    {
305        let size_bytes = value.estimate_size();
306        self.insert_with_size(key, value, size_bytes);
307    }
308
309    /// Get a value from the cache.
310    ///
311    /// Returns `None` if the key is not found or the entry has expired.
312    ///
313    /// # Arguments
314    ///
315    /// * `key` - Cache key to look up
316    ///
317    /// # Returns
318    ///
319    /// `Some(&V)` if found and not expired, `None` otherwise.
320    ///
321    /// # Examples
322    ///
323    /// ```rust,ignore
324    /// use perl_workspace::workspace::cache::BoundedLruCache;
325    ///
326    /// let mut cache = BoundedLruCache::default();
327    /// cache.insert("key", "value");
328    /// assert_eq!(cache.get("key"), Some(&"value"));
329    /// ```
330    pub fn get(&self, key: &K) -> Option<V>
331    where
332        V: Clone,
333    {
334        let mut entries = self.entries.lock();
335        let mut access_order = self.access_order.lock();
336        let mut stats = self.stats.lock();
337
338        // Check TTL expiration
339        if let Some(ttl) = self.config.ttl {
340            if let Some(entry) = entries.get(key) {
341                if entry.is_expired(ttl) {
342                    // Entry expired, remove it
343                    entries.remove(key);
344                    if let Some(pos) = access_order.iter().position(|k| k == key) {
345                        access_order.remove(pos);
346                    }
347                    stats.misses += 1;
348                    stats.hit_rate = CacheStats::calculate_hit_rate(stats.hits, stats.misses);
349                    return None;
350                }
351            }
352        }
353
354        // Get entry and update LRU
355        if let Some(entry) = entries.get_mut(key) {
356            entry.touch();
357
358            // Update access order (move to end = most recent)
359            if let Some(pos) = access_order.iter().position(|k| k == key) {
360                let key_clone = access_order.remove(pos);
361                if let Some(key_clone) = key_clone {
362                    access_order.push_back(key_clone);
363                }
364            }
365
366            stats.hits += 1;
367            stats.hit_rate = CacheStats::calculate_hit_rate(stats.hits, stats.misses);
368            Some(entry.value.clone())
369        } else {
370            stats.misses += 1;
371            stats.hit_rate = CacheStats::calculate_hit_rate(stats.hits, stats.misses);
372            None
373        }
374    }
375
376    /// Peek a value from the cache without changing hit/miss counters.
377    ///
378    /// Expired entries are still removed so stale data does not linger.
379    pub fn peek(&self, key: &K) -> Option<V>
380    where
381        V: Clone,
382    {
383        let mut entries = self.entries.lock();
384        let mut access_order = self.access_order.lock();
385        let mut stats = self.stats.lock();
386
387        if let Some(ttl) = self.config.ttl {
388            if entries.get(key).is_some_and(|entry| entry.is_expired(ttl)) {
389                if let Some(entry) = entries.remove(key) {
390                    stats.current_bytes -= entry.size_bytes;
391                    stats.current_items = entries.len();
392                }
393                if let Some(pos) = access_order.iter().position(|k| k == key) {
394                    access_order.remove(pos);
395                }
396                return None;
397            }
398        }
399
400        entries.get(key).map(|entry| entry.value.clone())
401    }
402
403    /// Remove a value from the cache.
404    ///
405    /// # Arguments
406    ///
407    /// * `key` - Cache key to remove
408    ///
409    /// # Returns
410    ///
411    /// `Some(V)` if the entry was removed, `None` if not found.
412    ///
413    /// # Examples
414    ///
415    /// ```rust,ignore
416    /// use perl_workspace::workspace::cache::BoundedLruCache;
417    ///
418    /// let mut cache = BoundedLruCache::default();
419    /// cache.insert("key", "value");
420    /// assert_eq!(cache.remove("key"), Some("value"));
421    /// ```
422    pub fn remove(&self, key: &K) -> Option<V>
423    where
424        V: Clone,
425    {
426        let mut entries = self.entries.lock();
427        let mut access_order = self.access_order.lock();
428        let mut stats = self.stats.lock();
429
430        if let Some(entry) = entries.remove(key) {
431            stats.current_bytes -= entry.size_bytes;
432            stats.current_items = entries.len();
433
434            if let Some(pos) = access_order.iter().position(|k| k == key) {
435                access_order.remove(pos);
436            }
437
438            Some(entry.value)
439        } else {
440            None
441        }
442    }
443
444    /// Clear all entries from the cache.
445    ///
446    /// # Examples
447    ///
448    /// ```rust
449    /// use perl_workspace::workspace::cache::BoundedLruCache;
450    ///
451    /// let mut cache = BoundedLruCache::default();
452    /// cache.insert("key", "value");
453    /// cache.clear();
454    /// assert!(cache.is_empty());
455    /// ```
456    pub fn clear(&self) {
457        let mut entries = self.entries.lock();
458        let mut access_order = self.access_order.lock();
459        let mut stats = self.stats.lock();
460
461        entries.clear();
462        access_order.clear();
463        stats.current_bytes = 0;
464        stats.current_items = 0;
465    }
466
467    /// Check if the cache is empty.
468    ///
469    /// # Returns
470    ///
471    /// `true` if the cache contains no entries, `false` otherwise.
472    pub fn is_empty(&self) -> bool {
473        let entries = self.entries.lock();
474        entries.is_empty()
475    }
476
477    /// Get the number of items in the cache.
478    ///
479    /// # Returns
480    ///
481    /// The current number of cached items.
482    pub fn len(&self) -> usize {
483        let entries = self.entries.lock();
484        entries.len()
485    }
486
487    /// Get cache statistics.
488    ///
489    /// # Returns
490    ///
491    /// A snapshot of the cache statistics.
492    ///
493    /// # Examples
494    ///
495    /// ```rust,ignore
496    /// use perl_workspace::workspace::cache::BoundedLruCache;
497    ///
498    /// let cache = BoundedLruCache::default();
499    /// let stats = cache.stats();
500    /// assert_eq!(stats.hits, 0);
501    /// ```
502    pub fn stats(&self) -> CacheStats {
503        let stats = self.stats.lock();
504        stats.clone()
505    }
506
507    /// Get the cache configuration.
508    ///
509    /// # Returns
510    ///
511    /// The cache configuration.
512    pub fn config(&self) -> &CacheConfig {
513        &self.config
514    }
515}
516
517/// Trait for estimating the memory size of cached values.
518///
519/// Implement this trait for custom types to enable accurate memory tracking.
520pub trait EstimateSize {
521    /// Estimate the memory size of this value in bytes.
522    fn estimate_size(&self) -> usize;
523}
524
525// Implement EstimateSize for common types
526impl EstimateSize for String {
527    fn estimate_size(&self) -> usize {
528        self.len()
529    }
530}
531
532impl<T> EstimateSize for Vec<T>
533where
534    T: EstimateSize,
535{
536    fn estimate_size(&self) -> usize {
537        self.iter().map(|t| t.estimate_size()).sum()
538    }
539}
540
541impl<K, V> EstimateSize for HashMap<K, V>
542where
543    K: EstimateSize,
544    V: EstimateSize,
545{
546    fn estimate_size(&self) -> usize {
547        self.iter().map(|(k, v)| k.estimate_size() + v.estimate_size()).sum()
548    }
549}
550
551impl EstimateSize for str {
552    fn estimate_size(&self) -> usize {
553        self.len()
554    }
555}
556
557impl<T: EstimateSize + ?Sized> EstimateSize for &T {
558    fn estimate_size(&self) -> usize {
559        (**self).estimate_size()
560    }
561}
562
563impl EstimateSize for [u8] {
564    fn estimate_size(&self) -> usize {
565        self.len()
566    }
567}
568
569impl EstimateSize for () {
570    fn estimate_size(&self) -> usize {
571        0
572    }
573}
574
575impl<T> EstimateSize for Option<T>
576where
577    T: EstimateSize,
578{
579    fn estimate_size(&self) -> usize {
580        self.as_ref().map(|t| t.estimate_size()).unwrap_or(0)
581    }
582}
583
584impl<T, E> EstimateSize for Result<T, E>
585where
586    T: EstimateSize,
587    E: EstimateSize,
588{
589    fn estimate_size(&self) -> usize {
590        match self {
591            Ok(t) => t.estimate_size(),
592            Err(e) => e.estimate_size(),
593        }
594    }
595}
596
597/// AST node cache configuration.
598///
599/// Optimized for AST node caching with higher memory limits.
600#[derive(Clone, Debug)]
601pub struct AstCacheConfig {
602    /// Maximum number of AST nodes to cache
603    pub max_nodes: usize,
604    /// Maximum memory for AST cache in bytes
605    pub max_bytes: usize,
606}
607
608impl Default for AstCacheConfig {
609    fn default() -> Self {
610        Self {
611            max_nodes: 10_000,
612            max_bytes: 50 * 1024 * 1024, // 50MB
613        }
614    }
615}
616
617/// Symbol cache configuration.
618///
619/// Optimized for symbol lookup caching.
620#[derive(Clone, Debug)]
621pub struct SymbolCacheConfig {
622    /// Maximum number of symbols to cache
623    pub max_symbols: usize,
624    /// Maximum memory for symbol cache in bytes
625    pub max_bytes: usize,
626}
627
628impl Default for SymbolCacheConfig {
629    fn default() -> Self {
630        Self {
631            max_symbols: 50_000,
632            max_bytes: 30 * 1024 * 1024, // 30MB
633        }
634    }
635}
636
637/// Workspace cache configuration.
638///
639/// Optimized for workspace file metadata caching.
640#[derive(Clone, Debug)]
641pub struct WorkspaceCacheConfig {
642    /// Maximum number of workspace files to cache
643    pub max_files: usize,
644    /// Maximum memory for workspace cache in bytes
645    pub max_bytes: usize,
646}
647
648impl Default for WorkspaceCacheConfig {
649    fn default() -> Self {
650        Self {
651            max_files: 1_000,
652            max_bytes: 20 * 1024 * 1024, // 20MB
653        }
654    }
655}
656
657/// Combined cache configuration for all workspace caches.
658#[derive(Clone, Debug, Default)]
659pub struct CombinedWorkspaceCacheConfig {
660    /// AST node cache configuration
661    pub ast: AstCacheConfig,
662    /// Symbol cache configuration
663    pub symbol: SymbolCacheConfig,
664    /// Workspace cache configuration
665    pub workspace: WorkspaceCacheConfig,
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_cache_insert_get() {
674        let cache = BoundedLruCache::<String, String>::default();
675        cache.insert("key1".to_string(), "value1".to_string());
676        assert_eq!(cache.get(&"key1".to_string()), Some("value1".to_string()));
677    }
678
679    #[test]
680    fn test_cache_miss() {
681        let cache = BoundedLruCache::<String, String>::default();
682        assert_eq!(cache.get(&"nonexistent".to_string()), None);
683    }
684
685    #[test]
686    fn test_cache_eviction() {
687        let config = CacheConfig { max_items: 2, max_bytes: 100, ttl: None };
688        let cache = BoundedLruCache::<String, String>::new(config);
689
690        cache.insert("key1".to_string(), "value1".to_string());
691        cache.insert("key2".to_string(), "value2".to_string());
692        cache.insert("key3".to_string(), "value3".to_string());
693
694        // key1 should be evicted (LRU)
695        assert_eq!(cache.get(&"key1".to_string()), None);
696        assert_eq!(cache.get(&"key2".to_string()), Some("value2".to_string()));
697        assert_eq!(cache.get(&"key3".to_string()), Some("value3".to_string()));
698    }
699
700    #[test]
701    fn test_cache_stats() {
702        let cache = BoundedLruCache::<String, String>::default();
703        cache.insert("key1".to_string(), "value1".to_string());
704
705        cache.get(&"key1".to_string());
706        cache.get(&"key2".to_string());
707
708        let stats = cache.stats();
709        assert_eq!(stats.hits, 1);
710        assert_eq!(stats.misses, 1);
711        assert_eq!(stats.hit_rate, 0.5);
712    }
713
714    #[test]
715    fn test_cache_clear() {
716        let cache = BoundedLruCache::<String, String>::default();
717        cache.insert("key1".to_string(), "value1".to_string());
718        cache.clear();
719
720        assert!(cache.is_empty());
721        assert_eq!(cache.len(), 0);
722    }
723
724    #[test]
725    fn test_cache_remove() {
726        let cache = BoundedLruCache::<String, String>::default();
727        cache.insert("key1".to_string(), "value1".to_string());
728        assert_eq!(cache.remove(&"key1".to_string()), Some("value1".to_string()));
729        assert_eq!(cache.get(&"key1".to_string()), None);
730    }
731
732    #[test]
733    fn test_cache_peek_does_not_change_stats() {
734        let cache = BoundedLruCache::<String, String>::default();
735        cache.insert("key1".to_string(), "value1".to_string());
736
737        assert_eq!(cache.peek(&"key1".to_string()), Some("value1".to_string()));
738
739        let stats = cache.stats();
740        assert_eq!(stats.hits, 0);
741        assert_eq!(stats.misses, 0);
742    }
743
744    #[test]
745    fn test_estimate_size_string() {
746        let s = "hello world";
747        assert_eq!(s.estimate_size(), 11);
748    }
749
750    #[test]
751    fn test_estimate_size_vec() {
752        let v = vec!["hello", "world"];
753        assert_eq!(v.estimate_size(), 10);
754    }
755}