Skip to main content

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}