1use std::collections::{HashMap, HashSet};
10use std::sync::{Arc, Mutex};
11use std::time::Instant;
12
13#[derive(Debug, Clone, Hash, PartialEq, Eq)]
19pub struct ResponseCacheKey {
20 pub tenant_id: Option<String>,
22 pub query_hash: u64,
24 pub variables_hash: u64,
26}
27
28impl ResponseCacheKey {
29 pub fn new(tenant_id: Option<&str>, query: &str, variables: Option<&str>) -> Self {
31 Self {
32 tenant_id: tenant_id.map(|s| s.to_string()),
33 query_hash: fnv1a(query),
34 variables_hash: variables.map(fnv1a).unwrap_or(0),
35 }
36 }
37}
38
39fn fnv1a(s: &str) -> u64 {
40 const BASIS: u64 = 14_695_981_039_346_656_037;
41 const PRIME: u64 = 1_099_511_628_211;
42 s.bytes()
43 .fold(BASIS, |h, b| h.wrapping_mul(PRIME) ^ u64::from(b))
44}
45
46#[derive(Debug, Clone)]
52pub struct CachePolicy {
53 pub max_age_ms: u64,
55 pub stale_while_revalidate_ms: u64,
58 pub vary_by_user: bool,
61}
62
63impl CachePolicy {
64 pub fn simple(max_age_ms: u64) -> Self {
67 Self {
68 max_age_ms,
69 stale_while_revalidate_ms: 0,
70 vary_by_user: false,
71 }
72 }
73
74 pub fn is_stale_usable(&self, age_ms: u64) -> bool {
77 age_ms < self.max_age_ms + self.stale_while_revalidate_ms
78 }
79}
80
81impl Default for CachePolicy {
82 fn default() -> Self {
83 Self::simple(60_000)
84 }
85}
86
87#[derive(Debug, Clone)]
93pub struct CachedResponse {
94 pub body: String,
96 pub stored_at: Instant,
98 pub policy: CachePolicy,
100 pub touched_types: Vec<String>,
103 pub touched_fields: Vec<String>,
105 pub hit_count: u64,
107}
108
109impl CachedResponse {
110 fn age_ms(&self) -> u64 {
111 self.stored_at.elapsed().as_millis() as u64
112 }
113
114 pub fn is_fresh(&self) -> bool {
116 self.age_ms() < self.policy.max_age_ms
117 }
118
119 pub fn is_stale_usable(&self) -> bool {
122 self.policy.is_stale_usable(self.age_ms())
123 }
124
125 pub fn remaining_fresh_ms(&self) -> u64 {
127 self.policy.max_age_ms.saturating_sub(self.age_ms())
128 }
129}
130
131struct ResponseStore {
136 entries: HashMap<ResponseCacheKey, CachedResponse>,
137 type_index: HashMap<String, HashSet<ResponseCacheKey>>,
139 field_index: HashMap<String, HashSet<ResponseCacheKey>>,
141 tenant_index: HashMap<String, HashSet<ResponseCacheKey>>,
143 max_entries: usize,
144}
145
146impl ResponseStore {
147 fn new(max_entries: usize) -> Self {
148 Self {
149 entries: HashMap::new(),
150 type_index: HashMap::new(),
151 field_index: HashMap::new(),
152 tenant_index: HashMap::new(),
153 max_entries,
154 }
155 }
156
157 fn insert(&mut self, key: ResponseCacheKey, entry: CachedResponse) {
158 for type_name in &entry.touched_types {
160 self.type_index
161 .entry(type_name.clone())
162 .or_default()
163 .insert(key.clone());
164 }
165 for field_path in &entry.touched_fields {
167 self.field_index
168 .entry(field_path.clone())
169 .or_default()
170 .insert(key.clone());
171 }
172 if let Some(tid) = &key.tenant_id {
174 self.tenant_index
175 .entry(tid.clone())
176 .or_default()
177 .insert(key.clone());
178 }
179
180 if self.entries.len() >= self.max_entries {
182 if let Some(oldest) = self.entries.keys().next().cloned() {
183 self.remove(&oldest);
184 }
185 }
186
187 self.entries.insert(key, entry);
188 }
189
190 fn remove(&mut self, key: &ResponseCacheKey) {
191 if let Some(entry) = self.entries.remove(key) {
192 for t in &entry.touched_types {
193 if let Some(s) = self.type_index.get_mut(t) {
194 s.remove(key);
195 }
196 }
197 for f in &entry.touched_fields {
198 if let Some(s) = self.field_index.get_mut(f) {
199 s.remove(key);
200 }
201 }
202 if let Some(tid) = &key.tenant_id {
203 if let Some(s) = self.tenant_index.get_mut(tid) {
204 s.remove(key);
205 }
206 }
207 }
208 }
209
210 fn remove_keys(&mut self, keys: Vec<ResponseCacheKey>) -> usize {
211 let n = keys.len();
212 for k in keys {
213 self.remove(&k);
214 }
215 n
216 }
217}
218
219pub struct GraphQlResponseCache {
226 store: Arc<Mutex<ResponseStore>>,
227}
228
229impl std::fmt::Debug for GraphQlResponseCache {
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 f.debug_struct("GraphQlResponseCache").finish()
232 }
233}
234
235impl GraphQlResponseCache {
236 pub fn new(max_entries: usize) -> Self {
238 Self {
239 store: Arc::new(Mutex::new(ResponseStore::new(max_entries))),
240 }
241 }
242
243 pub fn cached_response(&self, key: &ResponseCacheKey) -> Option<CachedResponse> {
249 let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
250 match store.entries.get(key) {
251 None => None,
252 Some(entry) if !entry.is_fresh() && !entry.is_stale_usable() => {
253 let k = key.clone();
254 store.remove(&k);
255 None
256 }
257 Some(entry) => {
258 let mut response = entry.clone();
259 response.hit_count += 1;
260 let k = key.clone();
261 store.entries.entry(k).and_modify(|e| e.hit_count += 1);
262 Some(response)
263 }
264 }
265 }
266
267 pub fn store_response(
269 &self,
270 key: ResponseCacheKey,
271 body: String,
272 policy: CachePolicy,
273 touched_types: Vec<String>,
274 touched_fields: Vec<String>,
275 ) {
276 let entry = CachedResponse {
277 body,
278 stored_at: Instant::now(),
279 policy,
280 touched_types,
281 touched_fields,
282 hit_count: 0,
283 };
284 let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
285 store.insert(key, entry);
286 }
287
288 pub fn invalidate_type(&self, type_name: &str) -> usize {
292 let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
293 let keys: Vec<ResponseCacheKey> = store
294 .type_index
295 .get(type_name)
296 .cloned()
297 .unwrap_or_default()
298 .into_iter()
299 .collect();
300 store.remove_keys(keys)
301 }
302
303 pub fn invalidate_field(&self, type_name: &str, field: &str) -> usize {
307 let path = format!("{type_name}.{field}");
308 let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
309 let keys: Vec<ResponseCacheKey> = store
310 .field_index
311 .get(&path)
312 .cloned()
313 .unwrap_or_default()
314 .into_iter()
315 .collect();
316 store.remove_keys(keys)
317 }
318
319 pub fn warm_up(
326 &self,
327 tenant_id: Option<&str>,
328 queries: &[(String, serde_json::Value)],
329 ) -> Vec<ResponseCacheKey> {
330 queries
331 .iter()
332 .map(|(q, v)| {
333 let vars_json = v.to_string();
334 let vars_str = if vars_json == "null" {
335 None
336 } else {
337 Some(vars_json.as_str())
338 };
339 ResponseCacheKey::new(tenant_id, q, vars_str)
340 })
341 .collect()
342 }
343
344 pub fn invalidate_tenant(&self, tenant_id: &str) -> usize {
346 let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
347 let keys: Vec<ResponseCacheKey> = store
348 .tenant_index
349 .get(tenant_id)
350 .cloned()
351 .unwrap_or_default()
352 .into_iter()
353 .collect();
354 store.remove_keys(keys)
355 }
356
357 pub fn size(&self) -> usize {
359 self.store.lock().map(|s| s.entries.len()).unwrap_or(0)
360 }
361
362 pub fn clear(&self) {
364 let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
365 store.entries.clear();
366 store.type_index.clear();
367 store.field_index.clear();
368 store.tenant_index.clear();
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct FieldLevelCacheDirective {
379 pub max_age_secs: Option<u64>,
381 pub scope: Option<CacheScope>,
383 pub inherit_max_age: bool,
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
389pub enum CacheScope {
390 Public,
391 Private,
392}
393
394impl FieldLevelCacheDirective {
395 pub fn parse(input: &str) -> Option<Self> {
399 if !input.contains("@cacheControl") {
400 return None;
401 }
402
403 let max_age_secs = Self::extract_u64_arg(input, "maxAge");
404 let scope = if input.contains("scope:") {
405 if input.contains("PRIVATE") {
406 Some(CacheScope::Private)
407 } else {
408 Some(CacheScope::Public)
409 }
410 } else {
411 None
412 };
413 let inherit_max_age =
414 input.contains("inheritMaxAge: true") || input.contains("inheritMaxAge:true");
415
416 Some(Self {
417 max_age_secs,
418 scope,
419 inherit_max_age,
420 })
421 }
422
423 fn extract_u64_arg(input: &str, arg: &str) -> Option<u64> {
424 let needle = format!("{arg}:");
425 let pos = input.find(needle.as_str())?;
426 let after = input[pos + needle.len()..].trim_start();
427 let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
429 digits.parse().ok()
430 }
431
432 pub fn effective_max_age_ms(directives: &[FieldLevelCacheDirective]) -> Option<u64> {
439 directives
440 .iter()
441 .filter_map(|d| d.max_age_secs.map(|s| s * 1_000))
442 .reduce(u64::min)
443 }
444}
445
446#[cfg(test)]
451mod tests {
452 use super::*;
453 use std::time::Duration;
454
455 fn key(tenant: Option<&str>, query: &str) -> ResponseCacheKey {
456 ResponseCacheKey::new(tenant, query, None)
457 }
458
459 fn store_simple(cache: &GraphQlResponseCache, key: ResponseCacheKey, body: &str) {
460 cache.store_response(
461 key,
462 body.to_string(),
463 CachePolicy::simple(60_000),
464 vec![],
465 vec![],
466 );
467 }
468
469 #[test]
472 fn test_key_same_query_same_tenant_equal() {
473 let k1 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
474 let k2 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
475 assert_eq!(k1, k2);
476 }
477
478 #[test]
479 fn test_key_different_tenant_not_equal() {
480 let k1 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
481 let k2 = ResponseCacheKey::new(Some("t2"), "{ hello }", None);
482 assert_ne!(k1, k2);
483 }
484
485 #[test]
486 fn test_key_none_tenant_vs_some_not_equal() {
487 let k1 = ResponseCacheKey::new(None, "{ hello }", None);
488 let k2 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
489 assert_ne!(k1, k2);
490 }
491
492 #[test]
493 fn test_key_different_variables_not_equal() {
494 let k1 = ResponseCacheKey::new(None, "{ q }", Some(r#"{"id":1}"#));
495 let k2 = ResponseCacheKey::new(None, "{ q }", Some(r#"{"id":2}"#));
496 assert_ne!(k1, k2);
497 }
498
499 #[test]
502 fn test_policy_fresh_entry() {
503 let policy = CachePolicy::simple(10_000);
504 assert!(policy.is_stale_usable(9_999));
506 assert!(!policy.is_stale_usable(10_001));
508 }
509
510 #[test]
511 fn test_policy_stale_within_swr_window() {
512 let policy = CachePolicy {
513 max_age_ms: 1_000,
514 stale_while_revalidate_ms: 5_000,
515 vary_by_user: false,
516 };
517 assert!(policy.is_stale_usable(5_999));
518 assert!(!policy.is_stale_usable(6_001));
519 }
520
521 #[test]
524 fn test_store_and_retrieve() {
525 let cache = GraphQlResponseCache::new(100);
526 let k = key(Some("tenant1"), "{ data }");
527 store_simple(&cache, k.clone(), r#"{"data":{}}"#);
528
529 let resp = cache.cached_response(&k).expect("should be in cache");
530 assert_eq!(resp.body, r#"{"data":{}}"#);
531 }
532
533 #[test]
534 fn test_miss_returns_none() {
535 let cache = GraphQlResponseCache::new(100);
536 let k = key(None, "{ missing }");
537 assert!(cache.cached_response(&k).is_none());
538 }
539
540 #[test]
541 fn test_expired_entry_returns_none() {
542 let cache = GraphQlResponseCache::new(100);
543 let k = key(None, "{ expiring }");
544 cache.store_response(
545 k.clone(),
546 "body".to_string(),
547 CachePolicy {
548 max_age_ms: 1,
549 stale_while_revalidate_ms: 0,
550 vary_by_user: false,
551 },
552 vec![],
553 vec![],
554 );
555 std::thread::sleep(Duration::from_millis(5));
556 assert!(cache.cached_response(&k).is_none());
557 assert_eq!(cache.size(), 0);
558 }
559
560 #[test]
561 fn test_stale_while_revalidate_still_served() {
562 let cache = GraphQlResponseCache::new(100);
563 let k = key(None, "{ swr }");
564 cache.store_response(
566 k.clone(),
567 "stale-body".to_string(),
568 CachePolicy {
569 max_age_ms: 1,
570 stale_while_revalidate_ms: 5_000,
571 vary_by_user: false,
572 },
573 vec![],
574 vec![],
575 );
576 std::thread::sleep(Duration::from_millis(5));
577 let resp = cache.cached_response(&k);
579 assert!(
580 resp.is_some(),
581 "stale entry should be served within SWR window"
582 );
583 }
584
585 #[test]
586 fn test_invalidate_type_removes_matching_entries() {
587 let cache = GraphQlResponseCache::new(100);
588 let k1 = key(None, "q1");
589 let k2 = key(None, "q2");
590
591 cache.store_response(
592 k1.clone(),
593 "r1".to_string(),
594 CachePolicy::default(),
595 vec!["User".to_string()],
596 vec![],
597 );
598 cache.store_response(
599 k2.clone(),
600 "r2".to_string(),
601 CachePolicy::default(),
602 vec!["Product".to_string()],
603 vec![],
604 );
605
606 let evicted = cache.invalidate_type("User");
607 assert_eq!(evicted, 1);
608 assert!(cache.cached_response(&k1).is_none());
609 assert!(cache.cached_response(&k2).is_some());
610 }
611
612 #[test]
613 fn test_invalidate_field_removes_matching_entries() {
614 let cache = GraphQlResponseCache::new(100);
615 let k1 = key(None, "q1");
616 let k2 = key(None, "q2");
617
618 cache.store_response(
619 k1.clone(),
620 "r1".to_string(),
621 CachePolicy::default(),
622 vec![],
623 vec!["User.email".to_string()],
624 );
625 cache.store_response(
626 k2.clone(),
627 "r2".to_string(),
628 CachePolicy::default(),
629 vec![],
630 vec!["User.name".to_string()],
631 );
632
633 let evicted = cache.invalidate_field("User", "email");
634 assert_eq!(evicted, 1);
635 assert!(cache.cached_response(&k1).is_none());
636 assert!(cache.cached_response(&k2).is_some());
637 }
638
639 #[test]
640 fn test_invalidate_type_no_match_returns_zero() {
641 let cache = GraphQlResponseCache::new(100);
642 let evicted = cache.invalidate_type("NonExistent");
643 assert_eq!(evicted, 0);
644 }
645
646 #[test]
647 fn test_invalidate_field_no_match_returns_zero() {
648 let cache = GraphQlResponseCache::new(100);
649 let evicted = cache.invalidate_field("Ghost", "field");
650 assert_eq!(evicted, 0);
651 }
652
653 #[test]
654 fn test_invalidate_tenant() {
655 let cache = GraphQlResponseCache::new(100);
656 let k_a = key(Some("acme"), "q1");
657 let k_b = key(Some("widgets"), "q1");
658
659 store_simple(&cache, k_a.clone(), "ra");
660 store_simple(&cache, k_b.clone(), "rb");
661
662 let evicted = cache.invalidate_tenant("acme");
663 assert_eq!(evicted, 1);
664 assert!(cache.cached_response(&k_a).is_none());
665 assert!(cache.cached_response(&k_b).is_some());
666 }
667
668 #[test]
669 fn test_warm_up_returns_keys() {
670 let cache = GraphQlResponseCache::new(100);
671 let queries = vec![
672 ("{ users }".to_string(), serde_json::Value::Null),
673 ("{ products }".to_string(), serde_json::json!({"limit": 10})),
674 ];
675 let keys = cache.warm_up(Some("tenant1"), &queries);
676 assert_eq!(keys.len(), 2);
677 assert_eq!(keys[0].query_hash, fnv1a("{ users }"));
678 }
679
680 #[test]
681 fn test_clear() {
682 let cache = GraphQlResponseCache::new(100);
683 store_simple(&cache, key(None, "q1"), "r1");
684 store_simple(&cache, key(None, "q2"), "r2");
685 cache.clear();
686 assert_eq!(cache.size(), 0);
687 }
688
689 #[test]
690 fn test_hit_count_increments() {
691 let cache = GraphQlResponseCache::new(100);
692 let k = key(Some("t"), "q");
693 store_simple(&cache, k.clone(), "body");
694
695 let resp1 = cache.cached_response(&k).expect("hit 1");
696 assert_eq!(resp1.hit_count, 1);
697 let resp2 = cache.cached_response(&k).expect("hit 2");
698 assert_eq!(resp2.hit_count, 2);
699 }
700
701 #[test]
704 fn test_parse_cache_control_max_age() {
705 let directive = FieldLevelCacheDirective::parse("@cacheControl(maxAge: 60)");
706 assert!(directive.is_some());
707 let d = directive.expect("should succeed");
708 assert_eq!(d.max_age_secs, Some(60));
709 assert_eq!(d.scope, None);
710 assert!(!d.inherit_max_age);
711 }
712
713 #[test]
714 fn test_parse_cache_control_with_scope() {
715 let d = FieldLevelCacheDirective::parse("@cacheControl(maxAge: 30, scope: PUBLIC)")
716 .expect("should parse");
717 assert_eq!(d.max_age_secs, Some(30));
718 assert_eq!(d.scope, Some(CacheScope::Public));
719 }
720
721 #[test]
722 fn test_parse_cache_control_private_scope() {
723 let d =
724 FieldLevelCacheDirective::parse("@cacheControl(scope: PRIVATE)").expect("should parse");
725 assert_eq!(d.scope, Some(CacheScope::Private));
726 }
727
728 #[test]
729 fn test_parse_no_cache_control_returns_none() {
730 assert!(FieldLevelCacheDirective::parse("{ user { name } }").is_none());
731 }
732
733 #[test]
734 fn test_parse_inherit_max_age() {
735 let d = FieldLevelCacheDirective::parse("@cacheControl(inheritMaxAge: true)")
736 .expect("should parse");
737 assert!(d.inherit_max_age);
738 }
739
740 #[test]
741 fn test_effective_max_age_ms_minimum() {
742 let directives = vec![
743 FieldLevelCacheDirective {
744 max_age_secs: Some(120),
745 scope: None,
746 inherit_max_age: false,
747 },
748 FieldLevelCacheDirective {
749 max_age_secs: Some(30),
750 scope: None,
751 inherit_max_age: false,
752 },
753 FieldLevelCacheDirective {
754 max_age_secs: Some(60),
755 scope: None,
756 inherit_max_age: false,
757 },
758 ];
759 let effective = FieldLevelCacheDirective::effective_max_age_ms(&directives);
760 assert_eq!(effective, Some(30_000)); }
762
763 #[test]
764 fn test_effective_max_age_ms_no_directives() {
765 let directives: Vec<FieldLevelCacheDirective> = vec![];
766 assert_eq!(
767 FieldLevelCacheDirective::effective_max_age_ms(&directives),
768 None
769 );
770 }
771
772 #[test]
773 fn test_effective_max_age_ms_mixed_none() {
774 let directives = vec![
775 FieldLevelCacheDirective {
776 max_age_secs: None,
777 scope: None,
778 inherit_max_age: true,
779 },
780 FieldLevelCacheDirective {
781 max_age_secs: Some(90),
782 scope: None,
783 inherit_max_age: false,
784 },
785 ];
786 assert_eq!(
788 FieldLevelCacheDirective::effective_max_age_ms(&directives),
789 Some(90_000)
790 );
791 }
792
793 #[test]
794 fn test_remaining_fresh_ms_positive() {
795 let resp = CachedResponse {
796 body: "x".to_string(),
797 stored_at: Instant::now(),
798 policy: CachePolicy::simple(60_000),
799 touched_types: vec![],
800 touched_fields: vec![],
801 hit_count: 0,
802 };
803 assert!(resp.remaining_fresh_ms() > 0);
804 }
805
806 #[test]
807 fn test_capacity_cap() {
808 let cache = GraphQlResponseCache::new(3);
809 for i in 0..5u32 {
810 store_simple(&cache, key(None, &format!("q{i}")), "r");
811 }
812 assert!(cache.size() <= 3);
813 }
814}