1use lru::LruCache;
12use sha2::{Digest, Sha256};
13use std::collections::BTreeMap;
14use std::num::NonZeroUsize;
15use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18pub const DEFAULT_CAPACITY: usize = 4096;
20
21#[derive(Debug, Clone)]
25pub struct KeyInputs<'a> {
26 pub build_id: &'a str,
28 pub route_pattern: &'a str,
30 pub params: &'a BTreeMap<String, String>,
32 pub query: &'a BTreeMap<String, String>,
34 pub vary: &'a BTreeMap<String, String>,
36 pub source_generations: &'a BTreeMap<String, String>,
38 pub user_id: Option<&'a str>,
40}
41
42pub fn compose_key(inputs: &KeyInputs) -> String {
46 let mut h = Sha256::new();
47 feed(&mut h, b"build_id", inputs.build_id.as_bytes());
48 feed(&mut h, b"route", inputs.route_pattern.as_bytes());
49 feed_map(&mut h, b"params", inputs.params);
50 feed_map(&mut h, b"query", inputs.query);
51 feed_map(&mut h, b"vary", inputs.vary);
52 feed_map(&mut h, b"gen", inputs.source_generations);
53 if let Some(uid) = inputs.user_id {
54 feed(&mut h, b"user", uid.as_bytes());
55 }
56 hex::encode(h.finalize())
57}
58
59fn feed(h: &mut Sha256, label: &[u8], value: &[u8]) {
60 h.update(label);
61 h.update([0u8]);
62 h.update(value);
63 h.update([0x1eu8]); }
65
66fn feed_map(h: &mut Sha256, label: &[u8], map: &BTreeMap<String, String>) {
67 h.update(label);
68 h.update([0x02u8]); for (k, v) in map {
70 h.update(k.as_bytes());
71 h.update([0x1fu8]); h.update(v.as_bytes());
73 h.update([0x1eu8]);
74 }
75 h.update([0x03u8]); }
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum CacheState {
81 Fresh,
83 Stale,
85 Expired,
87}
88
89impl CacheState {
90 pub fn label(self) -> &'static str {
92 match self {
93 CacheState::Fresh => "fresh",
94 CacheState::Stale => "stale",
95 CacheState::Expired => "expired",
96 }
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct CacheEntry {
103 pub status: u16,
104 pub html: String,
105 pub headers: BTreeMap<String, String>,
106 pub ttl: Duration,
107 pub swr: Duration,
108 pub stored_at: Instant,
109}
110
111impl CacheEntry {
112 pub fn state_at(&self, now: Instant) -> CacheState {
113 let age = now.saturating_duration_since(self.stored_at);
114 if age < self.ttl {
115 CacheState::Fresh
116 } else if age < self.ttl + self.swr {
117 CacheState::Stale
118 } else {
119 CacheState::Expired
120 }
121 }
122
123 pub fn state(&self) -> CacheState {
124 self.state_at(Instant::now())
125 }
126}
127
128pub struct ResponseCache {
131 inner: Mutex<LruCache<String, CacheEntry>>,
132}
133
134impl ResponseCache {
135 pub fn new() -> Self {
136 Self::with_capacity(DEFAULT_CAPACITY)
137 }
138
139 pub fn with_capacity(cap: usize) -> Self {
140 let cap = NonZeroUsize::new(cap).unwrap_or(NonZeroUsize::new(1).unwrap());
141 Self { inner: Mutex::new(LruCache::new(cap)) }
142 }
143
144 pub fn get(&self, key: &str) -> Option<CacheEntry> {
147 self.inner.lock().unwrap().get(key).cloned()
148 }
149
150 pub fn insert(&self, key: String, entry: CacheEntry) {
151 self.inner.lock().unwrap().put(key, entry);
152 }
153
154 pub fn remove(&self, key: &str) {
157 self.inner.lock().unwrap().pop(key);
158 }
159
160 pub fn len(&self) -> usize {
161 self.inner.lock().unwrap().len()
162 }
163
164 pub fn is_empty(&self) -> bool {
165 self.len() == 0
166 }
167}
168
169impl Default for ResponseCache {
170 fn default() -> Self {
171 Self::new()
172 }
173}
174
175pub fn cache_window(
180 status: u16,
181 ttl: Duration,
182 swr: Duration,
183 negative_ttl: Duration,
184) -> Option<(Duration, Duration)> {
185 if (500..600).contains(&status) {
186 None
187 } else if (200..400).contains(&status) {
188 Some((ttl, swr))
189 } else {
190 Some((negative_ttl, Duration::ZERO))
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
200 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
201 }
202
203 fn base_inputs<'a>(
204 build_id: &'a str,
205 empty: &'a BTreeMap<String, String>,
206 ) -> KeyInputs<'a> {
207 KeyInputs {
208 build_id,
209 route_pattern: "/p/:id",
210 params: empty,
211 query: empty,
212 vary: empty,
213 source_generations: empty,
214 user_id: None,
215 }
216 }
217
218 #[test]
219 fn key_is_deterministic_and_64_hex_chars() {
220 let empty = map(&[]);
221 let k1 = compose_key(&base_inputs("b1", &empty));
222 let k2 = compose_key(&base_inputs("b1", &empty));
223 assert_eq!(k1, k2);
224 assert_eq!(k1.len(), 64);
225 assert!(k1.chars().all(|c| c.is_ascii_hexdigit()));
226 }
227
228 #[test]
229 fn build_id_busts_the_key() {
230 let empty = map(&[]);
231 assert_ne!(
232 compose_key(&base_inputs("b1", &empty)),
233 compose_key(&base_inputs("b2", &empty)),
234 );
235 }
236
237 #[test]
238 fn source_generation_bump_changes_the_key() {
239 let empty = map(&[]);
240 let g1 = map(&[("project_db", "100")]);
241 let g2 = map(&[("project_db", "200")]);
242 let mut a = base_inputs("b1", &empty);
243 a.source_generations = &g1;
244 let mut b = base_inputs("b1", &empty);
245 b.source_generations = &g2;
246 assert_ne!(compose_key(&a), compose_key(&b));
247 }
248
249 #[test]
250 fn user_id_only_folds_in_when_present() {
251 let empty = map(&[]);
252 let anon = base_inputs("b1", &empty);
253 let mut with_user = base_inputs("b1", &empty);
254 with_user.user_id = Some("u42");
255 assert_ne!(compose_key(&anon), compose_key(&with_user));
256 }
257
258 #[test]
259 fn map_field_boundaries_do_not_collide() {
260 let empty = map(&[]);
262 let ab = map(&[("a", "b")]);
263 let mut as_param = base_inputs("b1", &empty);
264 as_param.params = &ab;
265 let mut as_query = base_inputs("b1", &empty);
266 as_query.query = &ab;
267 assert_ne!(compose_key(&as_param), compose_key(&as_query));
268 }
269
270 fn entry(ttl: u64, swr: u64, stored_ago: Duration) -> CacheEntry {
271 CacheEntry {
272 status: 200,
273 html: "x".into(),
274 headers: BTreeMap::new(),
275 ttl: Duration::from_secs(ttl),
276 swr: Duration::from_secs(swr),
277 stored_at: Instant::now() - stored_ago,
278 }
279 }
280
281 #[test]
282 fn state_machine_fresh_stale_expired() {
283 assert_eq!(entry(60, 300, Duration::from_secs(10)).state(), CacheState::Fresh);
285 assert_eq!(entry(60, 300, Duration::from_secs(120)).state(), CacheState::Stale);
286 assert_eq!(entry(60, 300, Duration::from_secs(400)).state(), CacheState::Expired);
287 }
288
289 #[test]
290 fn zero_ttl_zero_swr_is_immediately_expired() {
291 assert_eq!(entry(0, 0, Duration::from_millis(1)).state(), CacheState::Expired);
292 }
293
294 #[test]
295 fn cache_window_classes() {
296 let ttl = Duration::from_secs(60);
297 let swr = Duration::from_secs(300);
298 let neg = Duration::from_secs(10);
299 assert_eq!(cache_window(200, ttl, swr, neg), Some((ttl, swr)));
301 assert_eq!(cache_window(302, ttl, swr, neg), Some((ttl, swr)));
302 assert_eq!(cache_window(404, ttl, swr, neg), Some((neg, Duration::ZERO)));
304 assert_eq!(cache_window(503, ttl, swr, neg), None);
306 }
307
308 #[test]
309 fn lru_round_trip_and_recency_eviction() {
310 let cache = ResponseCache::with_capacity(2);
311 cache.insert("a".into(), entry(60, 0, Duration::ZERO));
312 cache.insert("b".into(), entry(60, 0, Duration::ZERO));
313 assert!(cache.get("a").is_some());
315 cache.insert("c".into(), entry(60, 0, Duration::ZERO));
316 assert!(cache.get("a").is_some());
317 assert!(cache.get("b").is_none(), "b should have been evicted");
318 assert!(cache.get("c").is_some());
319 }
320}