Skip to main content

zai_rs/toolkits/
cache.rs

1//! Concurrent tool-call result cache with TTL and FIFO eviction.
2
3use std::{
4    collections::VecDeque,
5    sync::{
6        Arc, Mutex,
7        atomic::{AtomicU64, Ordering},
8    },
9    time::{Duration, SystemTime},
10};
11
12use serde_json::Value;
13
14/// Cache key for tool calls
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub(crate) struct CacheKey {
17    /// Name of the tool.
18    tool_name: String,
19    /// Canonically serialized arguments. Object keys and string values are
20    /// preserved exactly.
21    arguments: String,
22}
23
24impl CacheKey {
25    /// Create a cache key from a tool name and its (arbitrary JSON) arguments.
26    #[cfg(test)]
27    fn new(tool_name: String, arguments: Value) -> Self {
28        Self {
29            tool_name,
30            arguments: canonical_json(&arguments),
31        }
32    }
33
34    /// Build an executor cache key tied to one registration generation.
35    pub(crate) fn for_generation(tool_name: String, arguments: Value, generation: u64) -> Self {
36        Self {
37            tool_name,
38            arguments: format!("{generation}:{}", canonical_json(&arguments)),
39        }
40    }
41}
42
43/// Cache entry with TTL
44#[derive(Debug, Clone)]
45struct CacheEntry {
46    /// Cached tool result.
47    result: Value,
48    /// When the entry was inserted.
49    timestamp: SystemTime,
50    /// Time-to-live for this entry.
51    ttl: Duration,
52}
53
54impl CacheEntry {
55    /// Create a new cache entry with the given result and TTL.
56    fn new(result: Value, ttl: Duration) -> Self {
57        Self {
58            result,
59            timestamp: SystemTime::now(),
60            ttl,
61        }
62    }
63
64    /// Whether this entry has exceeded its TTL.
65    fn is_expired(&self) -> bool {
66        match self.timestamp.elapsed() {
67            Ok(elapsed) => elapsed >= self.ttl,
68            Err(_) => true,
69        }
70    }
71}
72
73/// Concurrent (`DashMap`-backed) cache of tool-call results with per-entry TTL
74/// and bounded FIFO eviction. Cloning is cheap
75/// (an `Arc` bump) — all clones share the same cached entries, so a
76/// [`ToolExecutor`](crate::toolkits::executor::ToolExecutor) cloned per tool
77/// call does not deep-copy the cache.
78#[derive(Clone)]
79pub(crate) struct ToolCallCache {
80    /// Shared mutable cache contents (entries + eviction ordering).
81    state: Arc<CacheState>,
82    default_ttl: Duration,
83    max_size: usize,
84    enable_cache: bool,
85}
86
87/// The shared, concurrent interior of [`ToolCallCache`].
88struct CacheState {
89    entries: dashmap::DashMap<CacheKey, StoredEntry>,
90    /// Reads do not refresh insertion order. Generation tags make stale queue
91    /// records harmless after expiry, replacement, or invalidation.
92    insertion_order: Mutex<VecDeque<(CacheKey, u64)>>,
93    next_generation: AtomicU64,
94    total_hits: AtomicU64,
95    total_misses: AtomicU64,
96}
97
98struct StoredEntry {
99    value: CacheEntry,
100    generation: u64,
101}
102
103impl ToolCallCache {
104    /// Create a new cache (default TTL 300s, max 1000 entries, enabled).
105    pub(crate) fn new() -> Self {
106        Self {
107            state: Arc::new(CacheState {
108                entries: dashmap::DashMap::new(),
109                insertion_order: Mutex::new(VecDeque::new()),
110                next_generation: AtomicU64::new(0),
111                total_hits: AtomicU64::new(0),
112                total_misses: AtomicU64::new(0),
113            }),
114            default_ttl: Duration::from_secs(300),
115            max_size: 1000,
116            enable_cache: true,
117        }
118    }
119
120    /// Set the default TTL for entries without an explicit TTL.
121    pub(crate) fn with_ttl(mut self, ttl: Duration) -> Self {
122        self.default_ttl = ttl;
123        self
124    }
125
126    /// Set the maximum number of cached entries.
127    pub(crate) fn with_max_size(mut self, size: usize) -> Self {
128        self.max_size = size;
129        self
130    }
131
132    /// Enable or disable the cache entirely.
133    pub(crate) fn with_enabled(mut self, enabled: bool) -> Self {
134        self.enable_cache = enabled;
135        self
136    }
137
138    /// Whether the cache is currently enabled (a `get`/`insert` no-op when
139    /// false). Lets callers avoid building a [`CacheKey`] — which deep-clones
140    /// and re-serializes the arguments — when the cache is disabled.
141    pub(crate) fn enabled(&self) -> bool {
142        self.enable_cache
143    }
144
145    /// Look up a cached result, returning `None` if disabled, missing, or
146    /// expired (expired entries are atomically removed).
147    pub(crate) fn get(&self, key: &CacheKey) -> Option<Value> {
148        if !self.enable_cache {
149            return None;
150        }
151
152        // Check-and-remove must be atomic: a separate expiry check followed by
153        // `remove` could delete a concurrent replacement.
154        let expired = self
155            .state
156            .entries
157            .remove_if(key, |_key, stored| stored.value.is_expired());
158
159        if let Some((_, expired)) = expired {
160            self.state
161                .insertion_order
162                .lock()
163                .unwrap_or_else(std::sync::PoisonError::into_inner)
164                .retain(|(queued_key, generation)| {
165                    queued_key != key || *generation != expired.generation
166                });
167            saturating_increment(&self.state.total_misses);
168            return None;
169        }
170
171        let Some(stored) = self.state.entries.get(key) else {
172            saturating_increment(&self.state.total_misses);
173            return None;
174        };
175        saturating_increment(&self.state.total_hits);
176        Some(stored.value.result.clone())
177    }
178
179    /// Insert a result, evicting the oldest entries at capacity. No-op if
180    /// disabled.
181    pub(crate) fn insert(&self, key: CacheKey, result: Value, ttl: Option<Duration>) {
182        if !self.enable_cache || self.max_size == 0 {
183            return;
184        }
185
186        // Serialize insertion-order updates and capacity enforcement. Each
187        // queue record carries a generation, so a stale record from an expired,
188        // invalidated, or replaced key can never remove a newer value.
189        let mut order = self
190            .state
191            .insertion_order
192            .lock()
193            .unwrap_or_else(std::sync::PoisonError::into_inner);
194        // A replacement becomes the newest FIFO entry. Removing prior records
195        // also bounds the queue under repeated writes to one key.
196        order.retain(|(queued_key, _)| queued_key != &key);
197        let generation = self.state.next_generation.fetch_add(1, Ordering::Relaxed);
198        let entry = CacheEntry::new(result, ttl.unwrap_or(self.default_ttl));
199        self.state.entries.insert(
200            key.clone(),
201            StoredEntry {
202                value: entry,
203                generation,
204            },
205        );
206        order.push_back((key, generation));
207
208        while self.state.entries.len() > self.max_size {
209            let Some((oldest, oldest_generation)) = order.pop_front() else {
210                break;
211            };
212            self.state.entries.remove_if(&oldest, |_key, stored| {
213                stored.generation == oldest_generation
214            });
215        }
216    }
217
218    /// Convenience: build a [`CacheKey`] from name+arguments and insert.
219    #[cfg(test)]
220    fn insert_with_key(&self, tool_name: String, arguments: Value, result: Value) {
221        let key = CacheKey::new(tool_name, arguments);
222        self.insert(key, result, None);
223    }
224
225    /// Remove all cached entries.
226    pub(crate) fn clear(&self) {
227        let mut order = self
228            .state
229            .insertion_order
230            .lock()
231            .unwrap_or_else(std::sync::PoisonError::into_inner);
232        self.state.entries.clear();
233        order.clear();
234        self.state.total_hits.store(0, Ordering::Relaxed);
235        self.state.total_misses.store(0, Ordering::Relaxed);
236    }
237
238    /// Invalidate every entry for the given tool.
239    pub(crate) fn invalidate_tool(&self, tool_name: &str) {
240        let mut order = self
241            .state
242            .insertion_order
243            .lock()
244            .unwrap_or_else(std::sync::PoisonError::into_inner);
245        self.state
246            .entries
247            .retain(|key, _| key.tool_name != tool_name);
248        order.retain(|(key, _)| key.tool_name != tool_name);
249    }
250
251    /// Compute aggregate cache statistics (entry count, hits, expiry, hit
252    /// rate).
253    pub(crate) fn stats(&self) -> CacheStats {
254        let mut expired_count = 0u64;
255
256        for entry in self.state.entries.iter() {
257            if entry.value.is_expired() {
258                expired_count += 1;
259            }
260        }
261
262        let total_entries = self.state.entries.len();
263        let total_hits = self.state.total_hits.load(Ordering::Relaxed);
264        let total_misses = self.state.total_misses.load(Ordering::Relaxed);
265        let total_lookups = total_hits.saturating_add(total_misses);
266        CacheStats {
267            total_entries,
268            total_hits,
269            total_misses,
270            expired_count,
271            hit_rate: if total_lookups == 0 {
272                0.0
273            } else {
274                total_hits as f64 / total_lookups as f64
275            },
276        }
277    }
278}
279
280impl Default for ToolCallCache {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286/// Cache statistics
287#[derive(Debug, Clone, serde::Serialize)]
288pub struct CacheStats {
289    /// Number of entries currently in the cache.
290    pub total_entries: usize,
291    /// Total cache hits across all entries.
292    pub total_hits: u64,
293    /// Total enabled-cache lookups that did not find a live entry.
294    pub total_misses: u64,
295    /// Number of entries that have expired (not yet evicted).
296    pub expired_count: u64,
297    /// Fraction of enabled-cache lookups that found a live entry.
298    pub hit_rate: f64,
299}
300
301fn saturating_increment(counter: &AtomicU64) {
302    let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
303        value.checked_add(1)
304    });
305}
306
307fn canonical_json(value: &Value) -> String {
308    // `serde_json::Value`'s Display implementation emits valid compact JSON.
309    // With serde_json's default map backend, object keys are sorted, so
310    // equivalent objects with different insertion order share a key.
311    value.to_string()
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_cache_key_new() {
320        let args = serde_json::json!({"city": "Shenzhen", "count": 5});
321        let key = CacheKey::new("test_tool".to_string(), args);
322        assert_eq!(key.tool_name, "test_tool");
323        assert!(key.arguments.contains("city"));
324    }
325
326    #[test]
327    fn cache_key_distinguishes_name_input_and_registration_generation() {
328        let first = CacheKey::for_generation("one".to_string(), serde_json::json!({"n": 1}), 7);
329        let other_name =
330            CacheKey::for_generation("two".to_string(), serde_json::json!({"n": 1}), 7);
331        let other_input =
332            CacheKey::for_generation("one".to_string(), serde_json::json!({"n": 2}), 7);
333        let other_generation =
334            CacheKey::for_generation("one".to_string(), serde_json::json!({"n": 1}), 8);
335
336        assert_ne!(first, other_name);
337        assert_ne!(first, other_input);
338        assert_ne!(first, other_generation);
339    }
340
341    #[test]
342    fn test_cache_entry_expired() {
343        let entry = CacheEntry::new(
344            serde_json::json!({"result": "success"}),
345            Duration::from_secs(1),
346        );
347        assert!(!entry.is_expired());
348
349        let mut entry_mut = entry;
350        entry_mut.timestamp = SystemTime::now() - Duration::from_secs(2);
351        assert!(entry_mut.is_expired());
352    }
353
354    #[test]
355    fn test_cache_insert_get() {
356        let cache = ToolCallCache::new();
357        let args = serde_json::json!({"input": "test"});
358        let result = serde_json::json!({"output": "success"});
359
360        cache.insert_with_key("test_tool".to_string(), args.clone(), result.clone());
361
362        let key = CacheKey::new("test_tool".to_string(), args);
363        let cached = cache.get(&key);
364        assert!(cached.is_some());
365        assert_eq!(cached.unwrap(), result);
366    }
367
368    #[test]
369    fn test_cache_expiration() {
370        // Test expiration with short TTL and sleep
371        let cache = ToolCallCache::new().with_ttl(Duration::from_millis(10));
372        let args = serde_json::json!({"input": "test"});
373        let result = serde_json::json!({"output": "success"});
374
375        cache.insert_with_key("test_tool".to_string(), args.clone(), result);
376
377        let key = CacheKey::new("test_tool".to_string(), args);
378
379        // Entry should be cached initially
380        assert!(cache.get(&key).is_some());
381
382        // Wait for TTL to expire
383        std::thread::sleep(Duration::from_millis(20));
384
385        // Entry should be expired now
386        assert!(cache.get(&key).is_none());
387    }
388
389    #[test]
390    fn test_cache_stats() {
391        let cache = ToolCallCache::new();
392        let args = serde_json::json!({"input": "test"});
393
394        cache.insert_with_key("tool_a".to_string(), args.clone(), serde_json::json!({}));
395        cache.insert_with_key("tool_b".to_string(), args.clone(), serde_json::json!({}));
396
397        let key = CacheKey::new("tool_a".to_string(), args);
398        let _ = cache.get(&key);
399        let _ = cache.get(&key);
400        let _ = cache.get(&CacheKey::new("missing".to_string(), serde_json::json!({})));
401
402        let stats = cache.stats();
403        assert_eq!(stats.total_entries, 2);
404        assert_eq!(stats.total_hits, 2);
405        assert_eq!(stats.total_misses, 1);
406        assert!((stats.hit_rate - (2.0 / 3.0)).abs() < f64::EPSILON);
407    }
408
409    #[test]
410    fn test_canonical_json() {
411        let obj = serde_json::json!({
412            "CITY": "Shenzhen",
413            "count": 5,
414            "Data": {"NAME": "test"}
415        });
416
417        let normalized = canonical_json(&obj);
418        let parsed: Value = serde_json::from_str(&normalized).unwrap();
419
420        // Keys preserve their exact spelling.
421        if let Some(parsed_obj) = parsed.as_object() {
422            assert!(parsed_obj.contains_key("CITY"));
423            assert!(parsed_obj.contains_key("count"));
424            assert!(parsed_obj.contains_key("Data"));
425            assert_eq!(parsed_obj.get("CITY"), Some(&serde_json::json!("Shenzhen")));
426            assert_eq!(parsed_obj.get("count"), Some(&serde_json::json!(5)));
427        }
428    }
429
430    #[test]
431    fn test_canonical_json_preserves_key_whitespace() {
432        let obj = serde_json::json!({"CityName": "Shenzhen", " UserID ": 42});
433        let normalized = canonical_json(&obj);
434        let parsed: Value = serde_json::from_str(&normalized).unwrap();
435        assert!(parsed.as_object().unwrap().contains_key("CityName"));
436        assert!(parsed.as_object().unwrap().contains_key(" UserID "));
437    }
438
439    #[test]
440    fn test_cache_concurrent_insert_and_get() {
441        use std::{sync::Arc, thread};
442
443        let cache = Arc::new(ToolCallCache::new().with_max_size(1000));
444        let mut handles = Vec::new();
445
446        for i in 0..10 {
447            let cache_clone = Arc::clone(&cache);
448            handles.push(thread::spawn(move || {
449                let key_name = format!("tool_{i}");
450                let args = serde_json::json!({"input": i});
451                let result = serde_json::json!({"output": format!("result_{}", i)});
452                cache_clone.insert_with_key(key_name.clone(), args.clone(), result);
453
454                // Read it back
455                let key = CacheKey::new(key_name, args);
456                cache_clone.get(&key)
457            }));
458        }
459
460        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
461        let successful_gets = results.iter().filter(|r| r.is_some()).count();
462        assert_eq!(successful_gets, 10);
463
464        let stats = cache.stats();
465        assert_eq!(stats.total_entries, 10);
466    }
467
468    #[test]
469    fn test_cache_evicts_at_capacity() {
470        // Create a cache with small max_size
471        let cache = ToolCallCache::new()
472            .with_max_size(5)
473            .with_ttl(Duration::from_secs(300));
474
475        // Insert 5 entries to fill the cache
476        for i in 0..5 {
477            let args = serde_json::json!({"input": i});
478            cache.insert_with_key(format!("tool_{i}"), args, serde_json::json!({"result": i}));
479        }
480
481        assert_eq!(cache.stats().total_entries, 5);
482
483        // Insert one more to trigger eviction
484        let args = serde_json::json!({"input": "new"});
485        cache.insert_with_key(
486            "tool_new".to_string(),
487            args,
488            serde_json::json!({"result": "new"}),
489        );
490
491        let stats = cache.stats();
492        // After eviction, some entries should have been removed
493        assert!(stats.total_entries <= 5);
494        // The new entry should be present
495        let key = CacheKey::new("tool_new".to_string(), serde_json::json!({"input": "new"}));
496        assert!(cache.get(&key).is_some());
497    }
498
499    #[test]
500    fn test_cache_clone_shares_state() {
501        // Cloning a cache is a cheap `Arc` bump and shares the entries, so a
502        // write through one handle is visible through the other (a
503        // `ToolExecutor` cloned per tool call does not deep-copy the cache).
504        let cache = ToolCallCache::new();
505        let other = cache.clone();
506
507        let args = serde_json::json!({"input": "shared"});
508        cache.insert_with_key(
509            "tool".to_string(),
510            args.clone(),
511            serde_json::json!({"v": 1}),
512        );
513
514        let key = CacheKey::new("tool".to_string(), args);
515        assert!(cache.get(&key).is_some());
516        // Visible through the clone.
517        assert!(other.get(&key).is_some());
518    }
519
520    #[test]
521    fn test_cache_evicts_in_fifo_order() {
522        // O(1) FIFO eviction: with max_size 3, inserting a 4th entry evicts
523        // the oldest (t0), leaving t1/t2/t3.
524        let cache = ToolCallCache::new().with_max_size(3);
525        for i in 0..3 {
526            cache.insert_with_key(
527                format!("t{i}"),
528                serde_json::json!({"i": i}),
529                serde_json::json!({}),
530            );
531        }
532        assert_eq!(cache.stats().total_entries, 3);
533
534        cache.insert_with_key(
535            "t3".to_string(),
536            serde_json::json!({"i": 3}),
537            serde_json::json!({}),
538        );
539
540        assert!(
541            cache
542                .get(&CacheKey::new(
543                    "t0".to_string(),
544                    serde_json::json!({"i": 0})
545                ))
546                .is_none(),
547            "oldest entry (t0) should have been evicted"
548        );
549        assert!(
550            cache
551                .get(&CacheKey::new(
552                    "t1".to_string(),
553                    serde_json::json!({"i": 1})
554                ))
555                .is_some()
556        );
557        assert!(
558            cache
559                .get(&CacheKey::new(
560                    "t3".to_string(),
561                    serde_json::json!({"i": 3})
562                ))
563                .is_some()
564        );
565        assert!(cache.stats().total_entries <= 3);
566    }
567
568    #[test]
569    fn test_cache_max_size_zero_stores_nothing() {
570        let cache = ToolCallCache::new().with_max_size(0);
571        let args = serde_json::json!({"input": "test"});
572        let key = CacheKey::new("tool".to_string(), args.clone());
573
574        cache.insert_with_key(
575            "tool".to_string(),
576            args,
577            serde_json::json!({"result": true}),
578        );
579
580        assert!(cache.get(&key).is_none());
581        assert_eq!(cache.stats().total_entries, 0);
582    }
583
584    #[test]
585    fn test_cache_collides_on_reordered_keys() {
586        // End-to-end pin: equivalent objects with reordered keys share a key.
587        let cache = ToolCallCache::new();
588        cache.insert_with_key(
589            "t".to_string(),
590            serde_json::json!({"a": 1, "b": 2}),
591            serde_json::json!(true),
592        );
593        let reordered = CacheKey::new("t".to_string(), serde_json::json!({"b": 2, "a": 1}));
594        assert!(
595            cache.get(&reordered).is_some(),
596            "reordered object keys must collide in the cache"
597        );
598    }
599
600    #[test]
601    fn test_cache_key_preserves_whitespace_bearing_keys() {
602        let messy = CacheKey::new("t".to_string(), serde_json::json!({" a ": 1}));
603        let clean = CacheKey::new("t".to_string(), serde_json::json!({"a": 1}));
604        assert_ne!(messy, clean);
605    }
606
607    #[test]
608    fn string_and_json_null_do_not_collide() {
609        let string = CacheKey::new("t".to_string(), Value::String("null".to_string()));
610        let null = CacheKey::new("t".to_string(), Value::Null);
611        assert_ne!(string, null);
612    }
613
614    #[test]
615    fn invalidation_removes_fifo_record_before_reinsert() {
616        let cache = ToolCallCache::new().with_max_size(2);
617        let key = CacheKey::new("same".to_string(), serde_json::json!({}));
618        cache.insert(key.clone(), serde_json::json!(1), None);
619        cache.insert(
620            CacheKey::new("older".to_string(), serde_json::json!({})),
621            serde_json::json!(0),
622            None,
623        );
624        cache.invalidate_tool("same");
625        cache.insert(key.clone(), serde_json::json!(2), None);
626        cache.insert(
627            CacheKey::new("newest".to_string(), serde_json::json!({})),
628            serde_json::json!(3),
629            None,
630        );
631        assert_eq!(cache.get(&key), Some(serde_json::json!(2)));
632        assert_eq!(
633            cache
634                .state
635                .insertion_order
636                .lock()
637                .unwrap_or_else(std::sync::PoisonError::into_inner)
638                .len(),
639            2
640        );
641    }
642}