Skip to main content

zai_rs/toolkits/
cache.rs

1//! Tool call result cache with intelligent invalidation
2
3use std::time::{Duration, SystemTime};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// Cache key for tool calls
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct CacheKey {
11    pub tool_name: String,
12    pub arguments: String,
13}
14
15impl CacheKey {
16    pub fn new(tool_name: String, arguments: Value) -> Self {
17        let normalized = normalize_json(&arguments);
18        Self {
19            tool_name,
20            arguments: normalized,
21        }
22    }
23}
24
25/// Cache entry with TTL
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CacheEntry {
28    pub result: Value,
29    pub timestamp: SystemTime,
30    pub ttl: Duration,
31    pub hit_count: u64,
32}
33
34impl CacheEntry {
35    pub fn new(result: Value, ttl: Duration) -> Self {
36        Self {
37            result,
38            timestamp: SystemTime::now(),
39            ttl,
40            hit_count: 0,
41        }
42    }
43
44    pub fn is_expired(&self) -> bool {
45        match self.timestamp.elapsed() {
46            Ok(elapsed) => elapsed > self.ttl,
47            Err(_) => true,
48        }
49    }
50
51    pub fn hit(&mut self) {
52        self.hit_count += 1;
53    }
54}
55
56/// Intelligent tool call result cache
57#[derive(Clone)]
58pub struct ToolCallCache {
59    entries: dashmap::DashMap<CacheKey, CacheEntry>,
60    default_ttl: Duration,
61    max_size: usize,
62    enable_cache: bool,
63}
64
65impl ToolCallCache {
66    pub fn new() -> Self {
67        Self {
68            entries: dashmap::DashMap::new(),
69            default_ttl: Duration::from_secs(300),
70            max_size: 1000,
71            enable_cache: true,
72        }
73    }
74
75    pub fn with_ttl(mut self, ttl: Duration) -> Self {
76        self.default_ttl = ttl;
77        self
78    }
79
80    pub fn with_max_size(mut self, size: usize) -> Self {
81        self.max_size = size;
82        self
83    }
84
85    pub fn with_enabled(mut self, enabled: bool) -> Self {
86        self.enable_cache = enabled;
87        self
88    }
89
90    pub fn get(&self, key: &CacheKey) -> Option<Value> {
91        if !self.enable_cache {
92            return None;
93        }
94
95        // Use DashMap's remove_if for atomic check-and-remove of expired entries.
96        // If the entry exists and is expired, atomically remove it and return None.
97        // If not expired, we need to get it again for hit counting.
98        // This avoids TOCTOU issues between check and remove.
99        let expired = self.entries.remove_if(key, |_k, v| v.is_expired());
100
101        if expired.is_some() {
102            // Entry was expired and removed atomically
103            return None;
104        }
105
106        // Entry was not expired (or didn't exist) - get it for hit counting
107        let mut entry = self.entries.get_mut(key)?;
108        entry.hit();
109        Some(entry.result.clone())
110    }
111
112    pub fn insert(&self, key: CacheKey, result: Value, ttl: Option<Duration>) {
113        if !self.enable_cache {
114            return;
115        }
116
117        if self.entries.len() >= self.max_size {
118            self.evict_lru();
119        }
120
121        let entry = CacheEntry::new(result, ttl.unwrap_or(self.default_ttl));
122        self.entries.insert(key, entry);
123    }
124
125    pub fn insert_with_key(&self, tool_name: String, arguments: Value, result: Value) {
126        let key = CacheKey::new(tool_name, arguments);
127        self.insert(key, result, None);
128    }
129
130    pub fn clear(&self) {
131        self.entries.clear();
132    }
133
134    pub fn invalidate_tool(&self, tool_name: &str) {
135        self.entries.retain(|key, _| key.tool_name != tool_name);
136    }
137
138    pub fn stats(&self) -> CacheStats {
139        let mut total_hits = 0u64;
140        let mut expired_count = 0u64;
141
142        for entry in self.entries.iter() {
143            total_hits += entry.hit_count;
144            if entry.is_expired() {
145                expired_count += 1;
146            }
147        }
148
149        CacheStats {
150            total_entries: self.entries.len(),
151            total_hits,
152            expired_count,
153            hit_rate: if self.entries.is_empty() {
154                0.0
155            } else {
156                total_hits as f64 / self.entries.len() as f64
157            },
158        }
159    }
160
161    fn evict_lru(&self) {
162        let mut entries: Vec<_> = self
163            .entries
164            .iter()
165            .map(|entry| (entry.key().clone(), entry.value().timestamp))
166            .collect();
167
168        entries.sort_by_key(|a| a.1);
169
170        let remove_count = (self.max_size / 10).max(1);
171        for (key, _) in entries.into_iter().take(remove_count) {
172            self.entries.remove(&key);
173        }
174    }
175}
176
177impl Default for ToolCallCache {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183/// Cache statistics
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CacheStats {
186    pub total_entries: usize,
187    pub total_hits: u64,
188    pub expired_count: u64,
189    pub hit_rate: f64,
190}
191
192fn normalize_json(value: &Value) -> String {
193    match value {
194        Value::Object(obj) => {
195            let mut normalized = serde_json::Map::new();
196            for (k, v) in obj {
197                let normalized_key = k.trim().to_string();
198                let normalized_value = normalize_json_value(v);
199                normalized.insert(normalized_key, normalized_value);
200            }
201            serde_json::to_string(&normalized).unwrap_or_default()
202        },
203        Value::Array(arr) => {
204            let normalized: Vec<_> = arr.iter().map(normalize_json_value).collect();
205            serde_json::to_string(&normalized).unwrap_or_default()
206        },
207        Value::String(s) => s.clone(),
208        _ => serde_json::to_string(value).unwrap_or_default(),
209    }
210}
211
212fn normalize_json_value(value: &Value) -> Value {
213    match value {
214        Value::Object(obj) => {
215            let mut normalized = serde_json::Map::new();
216            for (k, v) in obj {
217                let normalized_key = k.trim().to_string();
218                normalized.insert(normalized_key, normalize_json_value(v));
219            }
220            Value::Object(normalized)
221        },
222        Value::Array(arr) => {
223            let normalized: Vec<_> = arr.iter().map(normalize_json_value).collect();
224            Value::Array(normalized)
225        },
226        _ => value.clone(),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_cache_key_new() {
236        let args = serde_json::json!({"city": "Shenzhen", "count": 5});
237        let key = CacheKey::new("test_tool".to_string(), args);
238        assert_eq!(key.tool_name, "test_tool");
239        assert!(key.arguments.contains("city"));
240    }
241
242    #[test]
243    fn test_cache_entry_expired() {
244        let entry = CacheEntry::new(
245            serde_json::json!({"result": "success"}),
246            Duration::from_secs(1),
247        );
248        assert!(!entry.is_expired());
249
250        let mut entry_mut = entry.clone();
251        entry_mut.timestamp = SystemTime::now() - Duration::from_secs(2);
252        assert!(entry_mut.is_expired());
253    }
254
255    #[test]
256    fn test_cache_hit() {
257        let mut entry = CacheEntry::new(
258            serde_json::json!({"result": "success"}),
259            Duration::from_secs(60),
260        );
261        entry.hit();
262        entry.hit();
263        assert_eq!(entry.hit_count, 2);
264    }
265
266    #[test]
267    fn test_cache_insert_get() {
268        let cache = ToolCallCache::new();
269        let args = serde_json::json!({"input": "test"});
270        let result = serde_json::json!({"output": "success"});
271
272        cache.insert_with_key("test_tool".to_string(), args.clone(), result.clone());
273
274        let key = CacheKey::new("test_tool".to_string(), args);
275        let cached = cache.get(&key);
276        assert!(cached.is_some());
277        assert_eq!(cached.unwrap(), result);
278    }
279
280    #[test]
281    fn test_cache_expiration() {
282        // Test expiration with short TTL and sleep
283        let cache = ToolCallCache::new().with_ttl(Duration::from_millis(10));
284        let args = serde_json::json!({"input": "test"});
285        let result = serde_json::json!({"output": "success"});
286
287        cache.insert_with_key("test_tool".to_string(), args.clone(), result.clone());
288
289        let key = CacheKey::new("test_tool".to_string(), args.clone());
290
291        // Entry should be cached initially
292        assert!(cache.get(&key).is_some());
293
294        // Wait for TTL to expire
295        std::thread::sleep(Duration::from_millis(20));
296
297        // Entry should be expired now
298        assert!(cache.get(&key).is_none());
299    }
300
301    #[test]
302    fn test_cache_stats() {
303        let cache = ToolCallCache::new();
304        let args = serde_json::json!({"input": "test"});
305
306        cache.insert_with_key("tool_a".to_string(), args.clone(), serde_json::json!({}));
307        cache.insert_with_key("tool_b".to_string(), args.clone(), serde_json::json!({}));
308
309        let key = CacheKey::new("tool_a".to_string(), args.clone());
310        let _ = cache.get(&key);
311        let _ = cache.get(&key);
312
313        let stats = cache.stats();
314        assert_eq!(stats.total_entries, 2);
315        assert_eq!(stats.total_hits, 2);
316    }
317
318    #[test]
319    fn test_normalize_json() {
320        let obj = serde_json::json!({
321            "CITY": "Shenzhen",
322            "count": 5,
323            "Data": {"NAME": "test"}
324        });
325
326        let normalized = normalize_json(&obj);
327        let parsed: Value = serde_json::from_str(&normalized).unwrap();
328
329        // Keys should preserve original case (only trim whitespace)
330        if let Some(parsed_obj) = parsed.as_object() {
331            assert!(parsed_obj.contains_key("CITY"));
332            assert!(parsed_obj.contains_key("count"));
333            assert!(parsed_obj.contains_key("Data"));
334            assert_eq!(parsed_obj.get("CITY"), Some(&serde_json::json!("Shenzhen")));
335            assert_eq!(parsed_obj.get("count"), Some(&serde_json::json!(5)));
336        }
337    }
338
339    #[test]
340    fn test_normalize_json_consistency_with_llm() {
341        // Verify that normalize_json preserves case consistently with
342        // llm::normalize_arguments (both only trim, no case change)
343        let obj = serde_json::json!({"CityName": "Shenzhen", " UserID ": 42});
344        let normalized = normalize_json(&obj);
345        let parsed: Value = serde_json::from_str(&normalized).unwrap();
346        assert!(parsed.as_object().unwrap().contains_key("CityName"));
347        assert!(parsed.as_object().unwrap().contains_key("UserID"));
348    }
349
350    #[test]
351    fn test_cache_concurrent_insert_and_get() {
352        use std::{sync::Arc, thread};
353
354        let cache = Arc::new(ToolCallCache::new().with_max_size(1000));
355        let mut handles = Vec::new();
356
357        for i in 0..10 {
358            let cache_clone = Arc::clone(&cache);
359            handles.push(thread::spawn(move || {
360                let key_name = format!("tool_{}", i);
361                let args = serde_json::json!({"input": i});
362                let result = serde_json::json!({"output": format!("result_{}", i)});
363                cache_clone.insert_with_key(key_name.clone(), args.clone(), result);
364
365                // Read it back
366                let key = CacheKey::new(key_name, args);
367                cache_clone.get(&key)
368            }));
369        }
370
371        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
372        let successful_gets = results.iter().filter(|r| r.is_some()).count();
373        assert_eq!(successful_gets, 10);
374
375        let stats = cache.stats();
376        assert_eq!(stats.total_entries, 10);
377    }
378
379    #[test]
380    fn test_cache_evict_lru() {
381        // Create a cache with small max_size
382        let cache = ToolCallCache::new()
383            .with_max_size(5)
384            .with_ttl(Duration::from_secs(300));
385
386        // Insert 5 entries to fill the cache
387        for i in 0..5 {
388            let args = serde_json::json!({"input": i});
389            cache.insert_with_key(
390                format!("tool_{}", i),
391                args,
392                serde_json::json!({"result": i}),
393            );
394        }
395
396        assert_eq!(cache.stats().total_entries, 5);
397
398        // Insert one more to trigger eviction
399        let args = serde_json::json!({"input": "new"});
400        cache.insert_with_key(
401            "tool_new".to_string(),
402            args,
403            serde_json::json!({"result": "new"}),
404        );
405
406        let stats = cache.stats();
407        // After eviction, some entries should have been removed
408        assert!(stats.total_entries <= 5);
409        // The new entry should be present
410        let key = CacheKey::new("tool_new".to_string(), serde_json::json!({"input": "new"}));
411        assert!(cache.get(&key).is_some());
412    }
413}