Skip to main content

tokenmiser_cache/
lib.rs

1//! Two-layer cache: `L1Cache` is an exact-match TTL LRU, `L2Cache` is a
2//! per-tenant semantic cache over bge-small embeddings.
3
4use std::num::NonZeroUsize;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use lru::LruCache;
9use parking_lot::Mutex;
10use tokenmiser_providers::{ChatRequest, ChatResponse};
11
12pub mod key;
13pub mod l2;
14#[cfg(test)]
15mod threshold_bench;
16
17pub use key::exact_key;
18pub use l2::{L2Cache, SemanticStats};
19
20#[derive(Debug, Clone)]
21struct Entry {
22    response: ChatResponse,
23    inserted_at: Instant,
24}
25
26/// Exact-match cache: a capacity-bounded LRU with per-entry TTL. A single
27/// mutex suffices because the critical section is a hash lookup plus a clone.
28pub struct L1Cache {
29    inner: Mutex<LruCache<String, Entry>>,
30    ttl: Duration,
31    hits: std::sync::atomic::AtomicU64,
32    misses: std::sync::atomic::AtomicU64,
33}
34
35impl L1Cache {
36    pub fn new(capacity: usize, ttl: Duration) -> Arc<Self> {
37        let cap = NonZeroUsize::new(capacity.max(1)).unwrap();
38        Arc::new(Self {
39            inner: Mutex::new(LruCache::new(cap)),
40            ttl,
41            hits: Default::default(),
42            misses: Default::default(),
43        })
44    }
45
46    /// Look up an exact-match entry.
47    ///
48    /// Every call is counted exactly once, as a hit or a miss, so
49    /// `hits + misses == lookups` always holds. Callers must not count misses
50    /// themselves.
51    pub fn lookup(&self, req: &ChatRequest, tenant: &str) -> Option<ChatResponse> {
52        let k = exact_key(req, tenant);
53        let mut guard = self.inner.lock();
54        let found = match guard.get(&k) {
55            Some(entry) if entry.inserted_at.elapsed() <= self.ttl => Some(entry.response.clone()),
56            Some(_) => {
57                // Evict now rather than waiting for LRU pressure.
58                guard.pop(&k);
59                None
60            }
61            None => None,
62        };
63        drop(guard);
64        match found {
65            Some(resp) => {
66                self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
67                Some(resp)
68            }
69            None => {
70                self.misses
71                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
72                None
73            }
74        }
75    }
76
77    pub fn insert(&self, req: &ChatRequest, tenant: &str, resp: &ChatResponse) {
78        let k = exact_key(req, tenant);
79        let mut guard = self.inner.lock();
80        guard.put(
81            k,
82            Entry {
83                response: resp.clone(),
84                inserted_at: Instant::now(),
85            },
86        );
87    }
88
89    pub fn stats(&self) -> CacheStats {
90        CacheStats {
91            hits: self.hits.load(std::sync::atomic::Ordering::Relaxed),
92            misses: self.misses.load(std::sync::atomic::Ordering::Relaxed),
93            size: self.inner.lock().len() as u64,
94        }
95    }
96}
97
98#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
99pub struct CacheStats {
100    pub hits: u64,
101    pub misses: u64,
102    pub size: u64,
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::Value;
109    use tokenmiser_providers::{ChatChoice, ChatMessage, Usage};
110
111    fn req(content: &str) -> ChatRequest {
112        ChatRequest {
113            model: "gpt-5".into(),
114            messages: vec![ChatMessage {
115                role: "user".into(),
116                content: Value::String(content.into()),
117                extra: Default::default(),
118            }],
119            temperature: Some(0.0),
120            max_tokens: Some(100),
121            top_p: None,
122            stream: None,
123            extra: Default::default(),
124        }
125    }
126
127    fn resp(text: &str) -> ChatResponse {
128        ChatResponse {
129            id: "test".into(),
130            object: "chat.completion".into(),
131            created: 0,
132            model: "gpt-5".into(),
133            choices: vec![ChatChoice {
134                index: 0,
135                message: ChatMessage {
136                    role: "assistant".into(),
137                    content: Value::String(text.into()),
138                    extra: Default::default(),
139                },
140                finish_reason: Some("stop".into()),
141                logprobs: None,
142            }],
143            usage: Usage::default(),
144            extra: Default::default(),
145        }
146    }
147
148    #[test]
149    fn hit_returns_stored_response() {
150        let c = L1Cache::new(8, Duration::from_secs(60));
151        c.insert(&req("hello"), "tenant-a", &resp("world"));
152        let got = c.lookup(&req("hello"), "tenant-a").expect("hit");
153        assert_eq!(
154            got.choices[0].message.content,
155            Value::String("world".into())
156        );
157        let stats = c.stats();
158        assert_eq!(stats.hits, 1);
159        assert_eq!(stats.size, 1);
160    }
161
162    #[test]
163    fn tenant_isolation() {
164        let c = L1Cache::new(8, Duration::from_secs(60));
165        c.insert(&req("hello"), "tenant-a", &resp("a"));
166        assert!(c.lookup(&req("hello"), "tenant-b").is_none());
167    }
168
169    #[test]
170    fn ttl_expires_entries() {
171        let c = L1Cache::new(8, Duration::from_millis(50));
172        c.insert(&req("hello"), "tenant-a", &resp("world"));
173        std::thread::sleep(Duration::from_millis(80));
174        assert!(c.lookup(&req("hello"), "tenant-a").is_none());
175    }
176
177    #[test]
178    fn every_lookup_is_counted_exactly_once() {
179        let c = L1Cache::new(8, Duration::from_millis(50));
180        c.insert(&req("hello"), "tenant-a", &resp("world"));
181
182        assert!(c.lookup(&req("hello"), "tenant-a").is_some()); // hit
183        assert!(c.lookup(&req("nope"), "tenant-a").is_none()); // miss (absent)
184        assert!(c.lookup(&req("hello"), "tenant-b").is_none()); // miss (tenant)
185        std::thread::sleep(Duration::from_millis(80));
186        assert!(c.lookup(&req("hello"), "tenant-a").is_none()); // miss (expired)
187
188        let s = c.stats();
189        assert_eq!(s.hits, 1);
190        assert_eq!(s.misses, 3);
191        assert_eq!(s.hits + s.misses, 4, "hits+misses must equal lookups");
192    }
193
194    #[test]
195    fn concurrent_lookups_and_inserts_keep_stats_consistent() {
196        use std::sync::atomic::{AtomicU64, Ordering};
197        let c = L1Cache::new(1024, Duration::from_secs(60));
198        let lookups = Arc::new(AtomicU64::new(0));
199
200        std::thread::scope(|s| {
201            for t in 0..8 {
202                let c = Arc::clone(&c);
203                let lookups = Arc::clone(&lookups);
204                s.spawn(move || {
205                    for i in 0..500 {
206                        let r = req(&format!("prompt-{}", i % 50));
207                        if c.lookup(&r, "tenant").is_none() {
208                            c.insert(&r, "tenant", &resp("cached"));
209                        }
210                        lookups.fetch_add(1, Ordering::Relaxed);
211                        // Vary interleaving across threads.
212                        if i % 100 == t {
213                            std::thread::yield_now();
214                        }
215                    }
216                });
217            }
218        });
219
220        let s = c.stats();
221        assert_eq!(
222            s.hits + s.misses,
223            lookups.load(Ordering::Relaxed),
224            "hits+misses must equal total lookups under concurrency"
225        );
226        assert!(s.size <= 50);
227    }
228}