1use 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
26pub 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 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 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()); assert!(c.lookup(&req("nope"), "tenant-a").is_none()); assert!(c.lookup(&req("hello"), "tenant-b").is_none()); std::thread::sleep(Duration::from_millis(80));
186 assert!(c.lookup(&req("hello"), "tenant-a").is_none()); 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 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}