Skip to main content

vtcode_core/tools/
cache.rs

1//! Caching system for tool results
2
3use super::types::{EnhancedCacheEntry, EnhancedCacheStats};
4use once_cell::sync::Lazy;
5use quick_cache::sync::Cache;
6use serde_json::Value;
7use std::future::Future;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
10use std::time::Duration;
11use tokio::sync::Mutex;
12
13use parking_lot::RwLock;
14
15use crate::cache::estimate_json_size;
16use vtcode_config::FileReadCacheConfig;
17
18/// Global file cache instance
19pub static FILE_CACHE: Lazy<FileCache> = Lazy::new(|| FileCache::new(1000));
20
21static FILE_READ_CACHE_CONFIG: Lazy<RwLock<FileReadCacheConfig>> =
22    Lazy::new(|| RwLock::new(FileReadCacheConfig::default()));
23
24/// Enhanced file cache with quick-cache for high-performance caching
25///
26/// Uses `tokio::sync::Mutex` for async-safe stats access across `.await` boundaries.
27/// Stores `Arc<Value>` internally for zero-copy cache hits.
28/// See: <https://ratatui.rs/faq/>
29pub struct FileCache {
30    file_cache: Arc<Cache<String, EnhancedCacheEntry<Arc<Value>>>>,
31    directory_cache: Arc<Cache<String, EnhancedCacheEntry<Arc<Value>>>>,
32    stats: Arc<Mutex<EnhancedCacheStats>>,
33    max_size_bytes: AtomicUsize,
34    ttl_millis: AtomicU64,
35}
36
37impl FileCache {
38    pub fn new(capacity: usize) -> Self {
39        Self {
40            file_cache: Arc::new(Cache::new(capacity)),
41            directory_cache: Arc::new(Cache::new(capacity / 2)),
42            stats: Arc::new(Mutex::new(EnhancedCacheStats::default())),
43            max_size_bytes: AtomicUsize::new(50 * 1024 * 1024), // 50MB default
44            ttl_millis: AtomicU64::new(300_000),                // 5 minutes default
45        }
46    }
47
48    #[inline]
49    fn ttl(&self) -> Duration {
50        Duration::from_millis(self.ttl_millis.load(Ordering::Relaxed))
51    }
52
53    #[inline]
54    fn max_size_bytes(&self) -> usize {
55        self.max_size_bytes.load(Ordering::Relaxed)
56    }
57
58    /// Get cached file content (clones the value for backwards compatibility)
59    pub async fn get_file(&self, key: &str) -> Option<Value> {
60        self.get_file_arc(key).await.map(|arc| (*arc).clone())
61    }
62
63    /// Get cached file content as Arc for zero-copy access
64    pub async fn get_file_arc(&self, key: &str) -> Option<Arc<Value>> {
65        let mut stats = self.stats.lock().await;
66
67        if let Some(entry) = self.file_cache.get(key) {
68            // Check if entry is still valid
69            if entry.timestamp.elapsed() < self.ttl() {
70                // Note: quick-cache handles access tracking automatically
71                stats.hits += 1;
72                return Some(Arc::clone(&entry.data));
73            } else {
74                // Entry expired, remove it
75                self.file_cache.remove(key);
76                stats.expired_evictions += 1;
77            }
78        }
79
80        stats.misses += 1;
81        None
82    }
83
84    /// Calculate byte size of a JSON value for cache tracking.
85    /// Walks the Value tree without allocating, unlike the previous
86    /// implementation that serialized to a temporary String.
87    #[inline]
88    fn estimate_value_size(value: &Value) -> usize {
89        estimate_json_size(value) as usize
90    }
91
92    /// Cache file content
93    pub fn put_file(&self, key: String, value: Value) -> impl Future<Output = ()> + '_ {
94        self.put_file_arc(key, Arc::new(value))
95    }
96
97    /// Cache file content with pre-wrapped Arc for zero-copy insertion
98    pub async fn put_file_arc(&self, key: String, value: Arc<Value>) {
99        let size_bytes = Self::estimate_value_size(&value);
100        let entry = EnhancedCacheEntry::new(value, size_bytes);
101
102        let mut stats = self.stats.lock().await;
103
104        // Check memory limits (quick-cache handles eviction automatically, but we track stats)
105        if stats.total_size_bytes + size_bytes > self.max_size_bytes() {
106            stats.memory_evictions += 1;
107        }
108
109        self.file_cache.insert(key, entry);
110        stats.entries = self.file_cache.len();
111        stats.total_size_bytes += size_bytes;
112    }
113
114    /// Get cached directory listing (clones for backwards compatibility)
115    pub async fn get_directory(&self, key: &str) -> Option<Value> {
116        self.get_directory_arc(key).await.map(|arc| (*arc).clone())
117    }
118
119    /// Get cached directory listing as Arc for zero-copy access
120    pub async fn get_directory_arc(&self, key: &str) -> Option<Arc<Value>> {
121        let mut stats = self.stats.lock().await;
122
123        if let Some(entry) = self.directory_cache.get(key) {
124            if entry.timestamp.elapsed() < self.ttl() {
125                stats.hits += 1;
126                return Some(Arc::clone(&entry.data));
127            } else {
128                self.directory_cache.remove(key);
129                stats.expired_evictions += 1;
130            }
131        }
132
133        stats.misses += 1;
134        None
135    }
136
137    /// Cache directory listing
138    pub fn put_directory(&self, key: String, value: Value) -> impl Future<Output = ()> + '_ {
139        self.put_directory_arc(key, Arc::new(value))
140    }
141
142    /// Cache directory listing with pre-wrapped Arc
143    pub async fn put_directory_arc(&self, key: String, value: Arc<Value>) {
144        let size_bytes = Self::estimate_value_size(&value);
145        let entry = EnhancedCacheEntry::new(value, size_bytes);
146
147        let mut stats = self.stats.lock().await;
148
149        self.directory_cache.insert(key, entry);
150        stats.entries += self.directory_cache.len();
151        stats.total_size_bytes += size_bytes;
152    }
153
154    /// Get cache statistics
155    pub async fn stats(&self) -> EnhancedCacheStats {
156        self.stats.lock().await.clone()
157    }
158
159    /// Clear all caches
160    pub async fn clear(&self) {
161        self.file_cache.clear();
162        self.directory_cache.clear();
163        *self.stats.lock().await = EnhancedCacheStats::default();
164    }
165
166    /// Get cache capacity information
167    pub fn capacity(&self) -> (usize, usize) {
168        (
169            self.file_cache.capacity().try_into().unwrap_or(0),
170            self.directory_cache.capacity().try_into().unwrap_or(0),
171        )
172    }
173
174    /// Get current cache size
175    pub fn len(&self) -> (usize, usize) {
176        (self.file_cache.len(), self.directory_cache.len())
177    }
178
179    /// Check memory pressure and enforce limits with tiered eviction
180    pub async fn check_pressure_and_evict(&self) {
181        let mut stats = self.stats.lock().await;
182
183        let current_size = stats.total_size_bytes;
184        let max_size = self.max_size_bytes();
185
186        if current_size > max_size {
187            // Tier 1: Clear directory cache first (cheaper to rebuild)
188            self.directory_cache.clear();
189
190            // Re-calculate size (approximate, since we don't iterate to sum remaining)
191            // Ideally we'd track directory vs file size separately, but for now we assume
192            // a significant portion was directories or we just set a flag.
193            // Since we cleared directories, we subtract their contribution if we tracked it,
194            // but we track total. For safety/simplicity in this "panic" mode:
195
196            // If we are VERY over limit (e.g. 150%), clear everything.
197            if current_size as f64 > max_size as f64 * 1.5 {
198                self.file_cache.clear();
199                stats.total_size_bytes = 0;
200                stats.entries = 0;
201                stats.memory_evictions += 1;
202                return;
203            }
204
205            // Tier 2: If just moderately over, we accept that directory clear helped
206            // and we rely on the implementation details of quick_cache to handle
207            // the file cache eviction over time or we trigger a partial clear.
208            // Since we can't easily partially clear quick_cache by size:
209
210            // We'll reset the total size tracking if we cleared everything,
211            // but here we cleared only directories.
212            // Let's rely on a simplified approach:
213            // If over limit, clear directory cache.
214            // If *still* conceptually over limit (checked next time or if we had separate counters),
215            // we'd clear files.
216
217            // Improvement: Track File and Dir sizes separately in future.
218            // For now, "Hard Limit" means clear all to be safe.
219            // But let's try to preserve files if possible.
220
221            // Since we can't accurately know how much we freed without separate counters,
222            // we will decrement stats based on an estimate or just reset if we clear all.
223
224            // Revised Strategy:
225            // 1. Clear directories.
226            // 2. If valid entries remain, we might still be over.
227            // But ensuring stability is key.
228
229            self.file_cache.clear(); // For now, safe clear all is better than OOM
230            stats.total_size_bytes = 0;
231            stats.entries = 0;
232            stats.memory_evictions += 1;
233        } else if current_size as f64 > max_size as f64 * 0.9 {
234            // Tier 3: Soft limit warning or proactive pruning
235            // In a real implementation with an LRU, we'd trim the tail.
236        }
237    }
238
239    /// Set explicit memory limit in bytes
240    pub fn set_capacity_limit(&mut self, max_bytes: usize) {
241        self.max_size_bytes.store(max_bytes, Ordering::Relaxed);
242    }
243
244    /// Update cache policy from configuration
245    pub fn apply_read_cache_config(&self, config: &FileReadCacheConfig) {
246        self.max_size_bytes
247            .store(config.max_size_bytes, Ordering::Relaxed);
248        self.ttl_millis
249            .store(config.ttl_secs.saturating_mul(1000), Ordering::Relaxed);
250    }
251
252    /// Adjust cache capacity based on system memory availability.
253    /// target_memory_ratio: 0.0 to 1.0 (fraction of total system memory to use).
254    pub fn adjust_capacity(&self, target_memory_ratio: f64) {
255        // Heuristic: Assume 16GB system if we can't query (conservative default)
256        const ASSUMED_SYSTEM_MEMORY: usize = 16 * 1024 * 1024 * 1024;
257
258        let target_bytes = (ASSUMED_SYSTEM_MEMORY as f64 * target_memory_ratio) as usize;
259        self.max_size_bytes.store(target_bytes, Ordering::Relaxed);
260    }
261}
262
263/// Configure global file cache from optimization settings.
264pub fn configure_file_cache(config: &FileReadCacheConfig) {
265    *FILE_READ_CACHE_CONFIG.write() = config.clone();
266    FILE_CACHE.apply_read_cache_config(config);
267}
268
269pub fn file_read_cache_config() -> FileReadCacheConfig {
270    FILE_READ_CACHE_CONFIG.read().clone()
271}