Skip to main content

vulcan_luaskills/runtime/
cache.rs

1use 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
10/// Default maximum number of cached entries for the process-wide shared tool cache.
11/// 工具缓存默认最大条目数,用于限制进程内共享缓存的总体容量。
12pub const DEFAULT_TOOL_CACHE_MAX_ENTRIES: usize = 1000;
13
14/// Default cache entry lifetime in seconds, used when callers do not provide an explicit TTL.
15/// 工具缓存默认存活时间,单位为秒;未显式指定时使用该值。
16pub const DEFAULT_TOOL_CACHE_DEFAULT_TTL_SECS: u64 = 30 * 60;
17
18/// Maximum cache entry lifetime in seconds; larger requested TTL values are clamped to this ceiling.
19/// 工具缓存允许的最长存活时间,单位为秒;超过该值会被自动钳制。
20pub const DEFAULT_TOOL_CACHE_MAX_TTL_SECS: u64 = 30 * 60;
21
22/// Runtime configuration for the shared tool cache, controlling capacity and expiration behavior.
23/// 共享工具缓存的运行时配置,控制容量与过期策略。
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct ToolCacheConfig {
26    /// Maximum number of entries; oldest entries are evicted when the cache exceeds this size.
27    /// 缓存最大条目数,超出后会按创建顺序淘汰最旧条目。
28    pub max_entries: usize,
29    /// Default TTL in seconds used when callers omit a TTL.
30    /// 默认 TTL(秒),调用方未传 TTL 时使用。
31    pub default_ttl_secs: u64,
32    /// Maximum TTL in seconds; requested TTL values are clamped to this ceiling.
33    /// 最大 TTL(秒),请求 TTL 会被限制在该范围内。
34    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/// Internal representation of one cache entry, recording the owning tool, payload, creation order, and expiration time.
48/// 单个缓存条目的内部表示,记录归属工具、内容、创建顺序与过期时间。
49#[derive(Clone, Debug)]
50struct ToolCacheEntry {
51    /// Tool or skill name that owns this entry, used to isolate cache namespaces.
52    /// 写入该条目的工具/技能名称,用于隔离不同工具的缓存空间。
53    tool_name: String,
54    /// Cached JSON payload returned to callers as-is on reads.
55    /// 缓存的 JSON 值,会在读取时原样返回给调用方。
56    value: Value,
57    /// Monotonic creation sequence used to evict the oldest entry when capacity is exceeded.
58    /// 创建序号,用于在容量超限时淘汰最旧条目。
59    created_seq: u64,
60    /// Expiration instant; reads after this moment trigger automatic cleanup.
61    /// 条目失效时刻;超过该时间后读取会自动清理。
62    expires_at: Instant,
63}
64
65/// Mutable storage backing the shared cache, protected by a read-write lock.
66/// 共享缓存的可变存储体,受读写锁保护。
67#[derive(Default)]
68struct ToolCacheStore {
69    entries: HashMap<String, ToolCacheEntry>,
70}
71
72/// Process-wide shared cache for all Lua skills, intended for short-lived pagination and tool state handoff.
73/// 主程序级共享工具缓存,供所有 Lua 技能复用短时分页/状态数据。
74pub struct SharedToolCache {
75    store: RwLock<ToolCacheStore>,
76    config: ToolCacheConfig,
77    counter: AtomicU64,
78}
79
80impl SharedToolCache {
81    /// Create a shared cache instance with the provided configuration.
82    /// 使用指定配置创建共享缓存实例。
83    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    /// Store one cache record; missing TTL falls back to the default and values above the ceiling are clamped.
92    /// 写入一条缓存记录;TTL 为空时使用默认值,超出上限时会自动钳制。
93    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    /// Read a cached entry by tool name and cache id; expired hits are removed and returned as empty.
112    /// 按工具名和缓存编号读取缓存;命中但已过期时会自动删除并返回空。
113    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    /// Delete one cache entry under the given tool namespace and return whether an entry was actually removed.
137    /// 删除指定工具名下的缓存条目;返回是否确实删除了条目。
138    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    /// Resolve the effective TTL by applying defaulting, clamping to the configured maximum, and enforcing a 1-second minimum.
151    /// 解析最终 TTL,未传使用默认值,超限后按最大值裁剪,最小保证为 1 秒。
152    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    /// Generate a cache id by combining a timestamp with a monotonic counter to reduce collision risk.
159    /// 生成缓存编号,结合时间戳与自增计数以降低碰撞风险。
160    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    /// Remove all expired entries so subsequent reads and writes operate on the current valid view.
170    /// 清理所有已过期条目,保证后续读写看到的是当前有效视图。
171    fn cleanup_expired_locked(&self, store: &mut ToolCacheStore, now: Instant) {
172        store.entries.retain(|_, entry| entry.expires_at > now);
173    }
174
175    /// Evict the oldest entries while the cache is above its configured capacity.
176    /// 在缓存超出上限时淘汰最旧条目,直到条目数回落到配置范围内。
177    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
196/// Initialize the global shared cache, typically once during process startup.
197/// 初始化全局共享缓存;通常在主程序启动时执行一次。
198pub fn configure_global_tool_cache(config: ToolCacheConfig) {
199    let _ = GLOBAL_TOOL_CACHE.set(Arc::new(SharedToolCache::new(config)));
200}
201
202/// Get the global shared cache, lazily creating it with default settings if startup did not configure it explicitly.
203/// 获取全局共享缓存;若尚未初始化则使用默认配置惰性创建。
204pub 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    /// Build one deterministic cache config used by unit tests.
218    /// 为单元测试构造一份稳定可预测的缓存配置。
219    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    /// Verify entries are isolated by tool namespace and cannot be read across scopes.
232    /// 验证缓存条目按工具命名空间隔离,不能跨作用域读取。
233    #[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    /// Verify cache entries expire according to the configured default TTL.
243    /// 验证缓存条目会按照配置的默认 TTL 正常过期。
244    #[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    /// Verify requested TTL values are clamped to the configured maximum TTL.
255    /// 验证调用方请求的 TTL 会被正确限制到配置允许的最大值。
256    #[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    /// Verify the oldest cache entry is evicted when the capacity is exceeded.
267    /// 验证缓存容量超限时会淘汰最早创建的条目。
268    #[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}