Skip to main content

lean_ctx/core/providers/
cache.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, Instant, SystemTime};
4
5static PROVIDER_CACHE: std::sync::LazyLock<Mutex<ProviderCache>> =
6    std::sync::LazyLock::new(|| Mutex::new(ProviderCache::new()));
7
8struct CacheEntry {
9    data: String,
10    expires_at: Instant,
11    provider_id: String,
12}
13
14/// Per-provider cache statistics.
15#[derive(Debug, Clone, Default)]
16pub struct ProviderCacheStats {
17    pub provider_id: String,
18    pub hits: u64,
19    pub misses: u64,
20    pub entry_count: usize,
21    pub last_fetch: Option<SystemTime>,
22}
23
24impl ProviderCacheStats {
25    pub fn hit_rate(&self) -> f64 {
26        let total = self.hits + self.misses;
27        if total == 0 {
28            return 0.0;
29        }
30        self.hits as f64 / total as f64
31    }
32}
33
34/// Global cache statistics across all providers.
35#[derive(Debug, Clone, Default)]
36pub struct CacheMetrics {
37    pub total_hits: u64,
38    pub total_misses: u64,
39    pub total_entries: usize,
40    pub provider_stats: Vec<ProviderCacheStats>,
41}
42
43impl CacheMetrics {
44    pub fn total_hit_rate(&self) -> f64 {
45        let total = self.total_hits + self.total_misses;
46        if total == 0 {
47            return 0.0;
48        }
49        self.total_hits as f64 / total as f64
50    }
51}
52
53struct ProviderCache {
54    entries: HashMap<String, CacheEntry>,
55    hits: HashMap<String, u64>,
56    misses: HashMap<String, u64>,
57    last_fetch: HashMap<String, SystemTime>,
58}
59
60impl ProviderCache {
61    fn new() -> Self {
62        Self {
63            entries: HashMap::new(),
64            hits: HashMap::new(),
65            misses: HashMap::new(),
66            last_fetch: HashMap::new(),
67        }
68    }
69
70    fn get(&mut self, key: &str) -> Option<&str> {
71        self.entries.retain(|_, v| v.expires_at > Instant::now());
72        if let Some(entry) = self.entries.get(key) {
73            *self.hits.entry(entry.provider_id.clone()).or_insert(0) += 1;
74            Some(entry.data.as_str())
75        } else {
76            let provider = key.split(':').next().unwrap_or("unknown");
77            *self.misses.entry(provider.to_string()).or_insert(0) += 1;
78            None
79        }
80    }
81
82    fn set(&mut self, key: String, data: String, ttl: Duration, provider_id: &str) {
83        self.last_fetch
84            .insert(provider_id.to_string(), SystemTime::now());
85        self.entries.insert(
86            key,
87            CacheEntry {
88                data,
89                expires_at: Instant::now() + ttl,
90                provider_id: provider_id.to_string(),
91            },
92        );
93    }
94
95    fn invalidate_provider(&mut self, provider_id: &str) -> usize {
96        let before = self.entries.len();
97        self.entries.retain(|_, v| v.provider_id != provider_id);
98        before - self.entries.len()
99    }
100
101    fn invalidate_all(&mut self) -> usize {
102        let count = self.entries.len();
103        self.entries.clear();
104        count
105    }
106
107    fn metrics(&mut self) -> CacheMetrics {
108        self.entries.retain(|_, v| v.expires_at > Instant::now());
109
110        let mut by_provider: HashMap<String, ProviderCacheStats> = HashMap::new();
111
112        for entry in self.entries.values() {
113            let stats = by_provider.entry(entry.provider_id.clone()).or_default();
114            stats.provider_id.clone_from(&entry.provider_id);
115            stats.entry_count += 1;
116        }
117
118        for (pid, &count) in &self.hits {
119            let stats = by_provider.entry(pid.clone()).or_default();
120            stats.provider_id.clone_from(pid);
121            stats.hits = count;
122        }
123        for (pid, &count) in &self.misses {
124            let stats = by_provider.entry(pid.clone()).or_default();
125            stats.provider_id.clone_from(pid);
126            stats.misses = count;
127        }
128        for (pid, &ts) in &self.last_fetch {
129            let stats = by_provider.entry(pid.clone()).or_default();
130            stats.provider_id.clone_from(pid);
131            stats.last_fetch = Some(ts);
132        }
133
134        let mut provider_stats: Vec<_> = by_provider.into_values().collect();
135        provider_stats.sort_by(|a, b| a.provider_id.cmp(&b.provider_id));
136
137        CacheMetrics {
138            total_hits: self.hits.values().sum(),
139            total_misses: self.misses.values().sum(),
140            total_entries: self.entries.len(),
141            provider_stats,
142        }
143    }
144}
145
146pub fn get_cached(key: &str) -> Option<String> {
147    PROVIDER_CACHE
148        .lock()
149        .ok()
150        .and_then(|mut c| c.get(key).map(std::string::ToString::to_string))
151}
152
153pub fn set_cached(key: &str, data: &str, ttl_secs: u64) {
154    set_cached_with_provider(
155        key,
156        data,
157        ttl_secs,
158        key.split(':').next().unwrap_or("unknown"),
159    );
160}
161
162pub fn set_cached_with_provider(key: &str, data: &str, ttl_secs: u64, provider_id: &str) {
163    if let Ok(mut cache) = PROVIDER_CACHE.lock() {
164        cache.set(
165            key.to_string(),
166            data.to_string(),
167            Duration::from_secs(ttl_secs),
168            provider_id,
169        );
170    }
171}
172
173pub fn invalidate_provider(provider_id: &str) -> usize {
174    PROVIDER_CACHE
175        .lock()
176        .ok()
177        .map_or(0, |mut c| c.invalidate_provider(provider_id))
178}
179
180pub fn invalidate_all() -> usize {
181    PROVIDER_CACHE
182        .lock()
183        .ok()
184        .map_or(0, |mut c| c.invalidate_all())
185}
186
187pub fn cache_metrics() -> CacheMetrics {
188    PROVIDER_CACHE
189        .lock()
190        .ok()
191        .map(|mut c| c.metrics())
192        .unwrap_or_default()
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn cache_set_and_get() {
201        let mut cache = ProviderCache::new();
202        cache.set(
203            "test:key".into(),
204            "value".into(),
205            Duration::from_mins(1),
206            "test",
207        );
208        assert_eq!(cache.get("test:key"), Some("value"));
209    }
210
211    #[test]
212    fn cache_expired_entry_returns_none() {
213        let mut cache = ProviderCache::new();
214        cache.set(
215            "test:key".into(),
216            "value".into(),
217            Duration::from_secs(0),
218            "test",
219        );
220        std::thread::sleep(Duration::from_millis(10));
221        assert!(cache.get("test:key").is_none());
222    }
223
224    #[test]
225    fn cache_tracks_hits_and_misses() {
226        let mut cache = ProviderCache::new();
227        cache.set(
228            "github:key".into(),
229            "data".into(),
230            Duration::from_mins(1),
231            "github",
232        );
233        cache.get("github:key"); // hit
234        cache.get("github:key"); // hit
235        cache.get("github:missing"); // miss
236
237        let metrics = cache.metrics();
238        assert_eq!(metrics.total_hits, 2);
239        assert_eq!(metrics.total_misses, 1);
240        assert!((metrics.total_hit_rate() - 0.666).abs() < 0.01);
241    }
242
243    #[test]
244    fn cache_invalidate_provider() {
245        let mut cache = ProviderCache::new();
246        cache.set(
247            "github:a".into(),
248            "1".into(),
249            Duration::from_mins(1),
250            "github",
251        );
252        cache.set(
253            "github:b".into(),
254            "2".into(),
255            Duration::from_mins(1),
256            "github",
257        );
258        cache.set(
259            "gitlab:c".into(),
260            "3".into(),
261            Duration::from_mins(1),
262            "gitlab",
263        );
264
265        let removed = cache.invalidate_provider("github");
266        assert_eq!(removed, 2);
267        assert!(cache.get("github:a").is_none());
268        assert_eq!(cache.get("gitlab:c"), Some("3"));
269    }
270
271    #[test]
272    fn cache_invalidate_all() {
273        let mut cache = ProviderCache::new();
274        cache.set("a".into(), "1".into(), Duration::from_mins(1), "x");
275        cache.set("b".into(), "2".into(), Duration::from_mins(1), "y");
276
277        let removed = cache.invalidate_all();
278        assert_eq!(removed, 2);
279        assert!(cache.get("a").is_none());
280    }
281
282    #[test]
283    fn cache_metrics_per_provider() {
284        let mut cache = ProviderCache::new();
285        cache.set(
286            "github:x".into(),
287            "a".into(),
288            Duration::from_mins(1),
289            "github",
290        );
291        cache.set(
292            "gitlab:y".into(),
293            "b".into(),
294            Duration::from_mins(1),
295            "gitlab",
296        );
297        cache.get("github:x");
298        cache.get("gitlab:miss");
299
300        let metrics = cache.metrics();
301        assert_eq!(metrics.provider_stats.len(), 2);
302
303        let gh = metrics
304            .provider_stats
305            .iter()
306            .find(|s| s.provider_id == "github")
307            .unwrap();
308        assert_eq!(gh.entry_count, 1);
309        assert_eq!(gh.hits, 1);
310
311        let gl = metrics
312            .provider_stats
313            .iter()
314            .find(|s| s.provider_id == "gitlab")
315            .unwrap();
316        assert_eq!(gl.entry_count, 1);
317        assert!(gl.last_fetch.is_some());
318    }
319
320    #[test]
321    fn provider_cache_stats_hit_rate() {
322        let stats = ProviderCacheStats {
323            provider_id: "test".into(),
324            hits: 3,
325            misses: 1,
326            entry_count: 2,
327            last_fetch: None,
328        };
329        assert!((stats.hit_rate() - 0.75).abs() < f64::EPSILON);
330    }
331
332    #[test]
333    fn provider_cache_stats_hit_rate_zero() {
334        let stats = ProviderCacheStats::default();
335        assert!((stats.hit_rate() - 0.0).abs() < f64::EPSILON);
336    }
337}