vulcan_luaskills/runtime/
cache.rs1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::OnceLock;
6use std::sync::RwLock;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10pub const DEFAULT_TOOL_CACHE_MAX_ENTRIES: usize = 1000;
13
14pub const DEFAULT_TOOL_CACHE_DEFAULT_TTL_SECS: u64 = 30 * 60;
17
18pub const DEFAULT_TOOL_CACHE_MAX_TTL_SECS: u64 = 30 * 60;
21
22#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct ToolCacheConfig {
26 pub max_entries: usize,
29 pub default_ttl_secs: u64,
32 pub max_ttl_secs: u64,
35}
36
37impl Default for ToolCacheConfig {
38 fn default() -> Self {
39 Self {
40 max_entries: DEFAULT_TOOL_CACHE_MAX_ENTRIES,
41 default_ttl_secs: DEFAULT_TOOL_CACHE_DEFAULT_TTL_SECS,
42 max_ttl_secs: DEFAULT_TOOL_CACHE_MAX_TTL_SECS,
43 }
44 }
45}
46
47#[derive(Clone, Debug)]
50struct ToolCacheEntry {
51 tool_name: String,
54 value: Value,
57 created_seq: u64,
60 expires_at: Instant,
63}
64
65#[derive(Default)]
68struct ToolCacheStore {
69 entries: HashMap<String, ToolCacheEntry>,
70}
71
72pub struct SharedToolCache {
75 store: RwLock<ToolCacheStore>,
76 config: ToolCacheConfig,
77 counter: AtomicU64,
78}
79
80impl SharedToolCache {
81 pub fn new(config: ToolCacheConfig) -> Self {
84 Self {
85 store: RwLock::new(ToolCacheStore::default()),
86 config,
87 counter: AtomicU64::new(1),
88 }
89 }
90
91 pub fn create(&self, tool_name: &str, value: Value, ttl_secs: Option<u64>) -> String {
94 let now = Instant::now();
95 let ttl = self.resolve_ttl(ttl_secs);
96 let cache_id = self.next_cache_id();
97 let entry = ToolCacheEntry {
98 tool_name: tool_name.to_string(),
99 value,
100 created_seq: self.counter.fetch_add(1, Ordering::Relaxed),
101 expires_at: now + ttl,
102 };
103
104 let mut store = self.store.write().expect("tool cache poisoned");
105 self.cleanup_expired_locked(&mut store, now);
106 store.entries.insert(cache_id.clone(), entry);
107 self.enforce_capacity_locked(&mut store);
108 cache_id
109 }
110
111 pub fn get(&self, tool_name: &str, cache_id: &str) -> Option<Value> {
114 let now = Instant::now();
115
116 {
117 let store = self.store.read().expect("tool cache poisoned");
118 if let Some(entry) = store.entries.get(cache_id)
119 && entry.tool_name == tool_name
120 && entry.expires_at > now
121 {
122 return Some(entry.value.clone());
123 }
124 }
125
126 let mut store = self.store.write().expect("tool cache poisoned");
127 self.cleanup_expired_locked(&mut store, now);
128 match store.entries.get(cache_id) {
129 Some(entry) if entry.tool_name == tool_name && entry.expires_at > now => {
130 Some(entry.value.clone())
131 }
132 _ => None,
133 }
134 }
135
136 pub fn delete(&self, tool_name: &str, cache_id: &str) -> bool {
139 let mut store = self.store.write().expect("tool cache poisoned");
140 self.cleanup_expired_locked(&mut store, Instant::now());
141 if let Some(entry) = store.entries.get(cache_id)
142 && entry.tool_name == tool_name
143 {
144 store.entries.remove(cache_id);
145 return true;
146 }
147 false
148 }
149
150 fn resolve_ttl(&self, ttl_secs: Option<u64>) -> Duration {
153 let requested = ttl_secs.unwrap_or(self.config.default_ttl_secs);
154 let clamped = requested.max(1).min(self.config.max_ttl_secs.max(1));
155 Duration::from_secs(clamped)
156 }
157
158 fn next_cache_id(&self) -> String {
161 let unix_ms = SystemTime::now()
162 .duration_since(UNIX_EPOCH)
163 .unwrap_or_default()
164 .as_millis();
165 let seq = self.counter.fetch_add(1, Ordering::Relaxed);
166 format!("tc-{}-{}", unix_ms, seq)
167 }
168
169 fn cleanup_expired_locked(&self, store: &mut ToolCacheStore, now: Instant) {
172 store.entries.retain(|_, entry| entry.expires_at > now);
173 }
174
175 fn enforce_capacity_locked(&self, store: &mut ToolCacheStore) {
178 while store.entries.len() > self.config.max_entries.max(1) {
179 let oldest_id = store
180 .entries
181 .iter()
182 .min_by_key(|(_, entry)| entry.created_seq)
183 .map(|(cache_id, _)| cache_id.clone());
184 match oldest_id {
185 Some(cache_id) => {
186 store.entries.remove(&cache_id);
187 }
188 None => break,
189 }
190 }
191 }
192}
193
194static GLOBAL_TOOL_CACHE: OnceLock<Arc<SharedToolCache>> = OnceLock::new();
195
196pub fn configure_global_tool_cache(config: ToolCacheConfig) {
199 let _ = GLOBAL_TOOL_CACHE.set(Arc::new(SharedToolCache::new(config)));
200}
201
202pub fn global_tool_cache() -> Arc<SharedToolCache> {
205 GLOBAL_TOOL_CACHE
206 .get_or_init(|| Arc::new(SharedToolCache::new(ToolCacheConfig::default())))
207 .clone()
208}
209
210#[cfg(test)]
211mod tests {
212 use super::{SharedToolCache, ToolCacheConfig};
213 use serde_json::json;
214 use std::thread;
215 use std::time::Duration;
216
217 fn test_cache_config(
220 max_entries: usize,
221 default_ttl_secs: u64,
222 max_ttl_secs: u64,
223 ) -> ToolCacheConfig {
224 ToolCacheConfig {
225 max_entries,
226 default_ttl_secs,
227 max_ttl_secs,
228 }
229 }
230
231 #[test]
234 fn cache_entries_are_isolated_by_tool_name() {
235 let cache = SharedToolCache::new(test_cache_config(10, 5, 5));
236 let cache_id = cache.create("skill-a", json!({"value": 1}), None);
237
238 assert_eq!(cache.get("skill-a", &cache_id), Some(json!({"value": 1})));
239 assert_eq!(cache.get("skill-b", &cache_id), None);
240 }
241
242 #[test]
245 fn cache_entries_expire_after_default_ttl() {
246 let cache = SharedToolCache::new(test_cache_config(10, 1, 1));
247 let cache_id = cache.create("skill-a", json!({"value": 1}), None);
248
249 thread::sleep(Duration::from_millis(1100));
250
251 assert_eq!(cache.get("skill-a", &cache_id), None);
252 }
253
254 #[test]
257 fn cache_requested_ttl_is_clamped_to_maximum() {
258 let cache = SharedToolCache::new(test_cache_config(10, 5, 1));
259 let cache_id = cache.create("skill-a", json!({"value": 1}), Some(60));
260
261 thread::sleep(Duration::from_millis(1100));
262
263 assert_eq!(cache.get("skill-a", &cache_id), None);
264 }
265
266 #[test]
269 fn cache_evicts_oldest_entry_when_capacity_is_exceeded() {
270 let cache = SharedToolCache::new(test_cache_config(2, 5, 5));
271 let first_id = cache.create("skill-a", json!({"value": 1}), None);
272 let second_id = cache.create("skill-a", json!({"value": 2}), None);
273 let third_id = cache.create("skill-a", json!({"value": 3}), None);
274
275 assert_eq!(cache.get("skill-a", &first_id), None);
276 assert_eq!(cache.get("skill-a", &second_id), Some(json!({"value": 2})));
277 assert_eq!(cache.get("skill-a", &third_id), Some(json!({"value": 3})));
278 }
279}