Skip to main content

oxirs_gql/federation/
entity_cache.rs

1//! Apollo Federation v2 Entity Batch Loader with Caching
2//!
3//! Provides efficient entity resolution for Apollo Federation by:
4//! - Batching entity requests together to minimise round-trips.
5//! - Caching resolved entities with TTL and LRU eviction.
6//! - Tracking cache statistics per entity type.
7//! - Supporting per-tenant entity isolation.
8
9use anyhow::Result;
10use std::collections::{HashMap, VecDeque};
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17/// A serialisable entity representation.
18///
19/// Entities are keyed by `(type_name, key_json)` where `key_json` is the
20/// JSON-encoded representation of the entity's `@key` fields.
21#[derive(Debug, Clone)]
22pub struct ResolvedEntity {
23    /// GraphQL type name (e.g. `"Product"`).
24    pub type_name: String,
25    /// JSON-encoded entity key fields.
26    pub key_json: String,
27    /// JSON-encoded resolved entity data.
28    pub data_json: String,
29    /// When this entity was resolved.
30    pub resolved_at: Instant,
31    /// How long this entity should be cached.
32    pub ttl: Duration,
33}
34
35impl ResolvedEntity {
36    /// Returns `true` if this entity has expired.
37    pub fn is_expired(&self) -> bool {
38        self.resolved_at.elapsed() >= self.ttl
39    }
40
41    /// Remaining time-to-live (saturating at zero).
42    pub fn remaining_ttl(&self) -> Duration {
43        self.ttl.saturating_sub(self.resolved_at.elapsed())
44    }
45}
46
47/// Cache key for entity entries.
48#[derive(Debug, Clone, Hash, PartialEq, Eq)]
49pub struct EntityCacheKey {
50    /// Optional tenant identifier for isolation.
51    pub tenant_id: Option<String>,
52    /// The GraphQL type name.
53    pub type_name: String,
54    /// JSON-encoded entity key (normalised / sorted).
55    pub key_json: String,
56}
57
58impl EntityCacheKey {
59    /// Create a new entity cache key.
60    pub fn new(
61        tenant_id: Option<&str>,
62        type_name: impl Into<String>,
63        key_json: impl Into<String>,
64    ) -> Self {
65        Self {
66            tenant_id: tenant_id.map(|s| s.to_string()),
67            type_name: type_name.into(),
68            key_json: key_json.into(),
69        }
70    }
71}
72
73/// Inner mutable cache store.
74struct EntityCacheStore {
75    entries: HashMap<EntityCacheKey, ResolvedEntity>,
76    lru: VecDeque<EntityCacheKey>,
77    max_entries: usize,
78    /// Per-type stats: (hits, misses).
79    type_stats: HashMap<String, (u64, u64)>,
80}
81
82impl EntityCacheStore {
83    fn new(max_entries: usize) -> Self {
84        Self {
85            entries: HashMap::new(),
86            lru: VecDeque::new(),
87            max_entries,
88            type_stats: HashMap::new(),
89        }
90    }
91
92    fn insert(&mut self, key: EntityCacheKey, entity: ResolvedEntity) {
93        // Remove existing slot from LRU order
94        if self.entries.contains_key(&key) {
95            self.lru.retain(|k| k != &key);
96        }
97
98        self.entries.insert(key.clone(), entity);
99        self.lru.push_back(key);
100
101        // Evict LRU if over capacity
102        while self.entries.len() > self.max_entries {
103            if let Some(oldest) = self.lru.pop_front() {
104                self.entries.remove(&oldest);
105            } else {
106                break;
107            }
108        }
109    }
110
111    fn get_mut(&mut self, key: &EntityCacheKey) -> Option<&mut ResolvedEntity> {
112        self.entries.get_mut(key)
113    }
114
115    fn touch(&mut self, key: &EntityCacheKey) {
116        self.lru.retain(|k| k != key);
117        self.lru.push_back(key.clone());
118    }
119
120    fn remove(&mut self, key: &EntityCacheKey) {
121        self.entries.remove(key);
122        self.lru.retain(|k| k != key);
123    }
124
125    fn evict_expired(&mut self) -> usize {
126        let expired: Vec<EntityCacheKey> = self
127            .entries
128            .iter()
129            .filter(|(_, e)| e.is_expired())
130            .map(|(k, _)| k.clone())
131            .collect();
132
133        let count = expired.len();
134        for key in expired {
135            self.remove(&key);
136        }
137        count
138    }
139
140    fn record_hit(&mut self, type_name: &str) {
141        let entry = self
142            .type_stats
143            .entry(type_name.to_string())
144            .or_insert((0, 0));
145        entry.0 += 1;
146    }
147
148    fn record_miss(&mut self, type_name: &str) {
149        let entry = self
150            .type_stats
151            .entry(type_name.to_string())
152            .or_insert((0, 0));
153        entry.1 += 1;
154    }
155}
156
157/// Statistics for the entity cache.
158#[derive(Debug, Clone)]
159pub struct EntityCacheStats {
160    /// Total cache hits.
161    pub total_hits: u64,
162    /// Total cache misses.
163    pub total_misses: u64,
164    /// Total LRU + TTL evictions.
165    pub total_evictions: u64,
166    /// Current number of cached entities.
167    pub current_size: usize,
168    /// Per-type hit/miss counts.
169    pub by_type: HashMap<String, (u64, u64)>,
170}
171
172impl EntityCacheStats {
173    /// Returns the overall hit rate `[0.0, 1.0]`.
174    pub fn hit_rate(&self) -> f64 {
175        let total = (self.total_hits + self.total_misses) as f64;
176        if total == 0.0 {
177            0.0
178        } else {
179            self.total_hits as f64 / total
180        }
181    }
182}
183
184/// Thread-safe entity cache with LRU eviction and TTL.
185pub struct EntityCache {
186    store: Arc<Mutex<EntityCacheStore>>,
187    default_ttl: Duration,
188    total_hits: Arc<AtomicU64>,
189    total_misses: Arc<AtomicU64>,
190    total_evictions: Arc<AtomicU64>,
191}
192
193impl std::fmt::Debug for EntityCache {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.debug_struct("EntityCache")
196            .field("default_ttl", &self.default_ttl)
197            .field("hits", &self.total_hits.load(Ordering::Relaxed))
198            .field("misses", &self.total_misses.load(Ordering::Relaxed))
199            .finish()
200    }
201}
202
203impl EntityCache {
204    /// Create a new entity cache.
205    pub fn new(max_entries: usize, default_ttl: Duration) -> Self {
206        Self {
207            store: Arc::new(Mutex::new(EntityCacheStore::new(max_entries))),
208            default_ttl,
209            total_hits: Arc::new(AtomicU64::new(0)),
210            total_misses: Arc::new(AtomicU64::new(0)),
211            total_evictions: Arc::new(AtomicU64::new(0)),
212        }
213    }
214
215    /// Look up a cached entity.
216    ///
217    /// Returns `Some(data_json)` on a hit, or `None` on a miss / expired entry.
218    pub fn get(&self, key: &EntityCacheKey) -> Option<String> {
219        let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
220
221        // Check if entity exists and gather needed data before modifying the store.
222        // We clone the required fields so the mutable borrow is released before
223        // we call other &mut methods on `store`.
224        let entity_info: Option<(bool, String, String)> = store.get_mut(key).map(|entity| {
225            (
226                entity.is_expired(),
227                entity.type_name.clone(),
228                entity.data_json.clone(),
229            )
230        });
231
232        match entity_info {
233            Some((true, ref type_name, _)) => {
234                // Entity is expired: remove it
235                store.remove(key);
236                store.record_miss(type_name);
237                self.total_misses.fetch_add(1, Ordering::Relaxed);
238                self.total_evictions.fetch_add(1, Ordering::Relaxed);
239                None
240            }
241            Some((false, ref type_name, ref data)) => {
242                // Entity is valid: record hit and return data
243                let data_clone = data.clone();
244                store.touch(key);
245                store.record_hit(type_name);
246                self.total_hits.fetch_add(1, Ordering::Relaxed);
247                Some(data_clone)
248            }
249            None => {
250                let type_name = key.type_name.clone();
251                store.record_miss(&type_name);
252                self.total_misses.fetch_add(1, Ordering::Relaxed);
253                None
254            }
255        }
256    }
257
258    /// Store a resolved entity with the default TTL.
259    pub fn put(&self, key: EntityCacheKey, entity: ResolvedEntity) {
260        self.put_with_ttl(key, entity, self.default_ttl);
261    }
262
263    /// Store a resolved entity with an explicit TTL.
264    pub fn put_with_ttl(&self, key: EntityCacheKey, mut entity: ResolvedEntity, ttl: Duration) {
265        entity.ttl = ttl;
266        entity.resolved_at = Instant::now();
267        if let Ok(mut store) = self.store.lock() {
268            store.insert(key, entity);
269        }
270    }
271
272    /// Remove expired entries.
273    pub fn evict_expired(&self) -> usize {
274        let count = self
275            .store
276            .lock()
277            .map(|mut s| s.evict_expired())
278            .unwrap_or(0);
279        self.total_evictions
280            .fetch_add(count as u64, Ordering::Relaxed);
281        count
282    }
283
284    /// Clear all entries.
285    pub fn clear(&self) -> usize {
286        if let Ok(mut store) = self.store.lock() {
287            let count = store.entries.len();
288            store.entries.clear();
289            store.lru.clear();
290            count
291        } else {
292            0
293        }
294    }
295
296    /// Current number of entries.
297    pub fn size(&self) -> usize {
298        self.store.lock().map(|s| s.entries.len()).unwrap_or(0)
299    }
300
301    /// Return cache statistics.
302    pub fn stats(&self) -> EntityCacheStats {
303        let by_type = self
304            .store
305            .lock()
306            .map(|s| s.type_stats.clone())
307            .unwrap_or_default();
308
309        EntityCacheStats {
310            total_hits: self.total_hits.load(Ordering::Relaxed),
311            total_misses: self.total_misses.load(Ordering::Relaxed),
312            total_evictions: self.total_evictions.load(Ordering::Relaxed),
313            current_size: self.size(),
314            by_type,
315        }
316    }
317
318    /// Hit rate `[0.0, 1.0]`.
319    pub fn hit_rate(&self) -> f64 {
320        let hits = self.total_hits.load(Ordering::Relaxed) as f64;
321        let misses = self.total_misses.load(Ordering::Relaxed) as f64;
322        let total = hits + misses;
323        if total == 0.0 {
324            0.0
325        } else {
326            hits / total
327        }
328    }
329}
330
331/// A pending batch of entity resolution requests.
332#[derive(Debug)]
333pub struct EntityBatch {
334    /// Type name for this batch.
335    pub type_name: String,
336    /// Entity key JSONs to resolve.
337    pub keys: Vec<String>,
338    /// Optional tenant ID.
339    pub tenant_id: Option<String>,
340}
341
342impl EntityBatch {
343    /// Create a new batch for a given entity type.
344    pub fn new(type_name: impl Into<String>, tenant_id: Option<&str>) -> Self {
345        Self {
346            type_name: type_name.into(),
347            keys: Vec::new(),
348            tenant_id: tenant_id.map(|s| s.to_string()),
349        }
350    }
351
352    /// Add a key JSON to this batch.
353    pub fn add_key(&mut self, key_json: impl Into<String>) {
354        self.keys.push(key_json.into());
355    }
356
357    /// Number of keys in this batch.
358    pub fn len(&self) -> usize {
359        self.keys.len()
360    }
361
362    /// Whether this batch is empty.
363    pub fn is_empty(&self) -> bool {
364        self.keys.is_empty()
365    }
366}
367
368/// Resolver function type for resolving a batch of entities.
369///
370/// Receives the batch and should return a Vec of `(key_json, data_json)` pairs.
371type BatchResolverFn = Box<
372    dyn Fn(EntityBatch) -> Pin<Box<dyn Future<Output = Result<Vec<(String, String)>>> + Send>>
373        + Send
374        + Sync,
375>;
376
377/// Entity batch loader that integrates with `EntityCache`.
378///
379/// Collects entity requests, checks the cache first, and issues batch calls
380/// to the underlying subgraph for cache misses.
381pub struct EntityBatchLoader {
382    cache: Arc<EntityCache>,
383    resolver: Arc<BatchResolverFn>,
384    /// Maximum number of keys per batch call.
385    max_batch_size: usize,
386    default_ttl: Duration,
387}
388
389impl std::fmt::Debug for EntityBatchLoader {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        f.debug_struct("EntityBatchLoader")
392            .field("max_batch_size", &self.max_batch_size)
393            .field("default_ttl", &self.default_ttl)
394            .finish()
395    }
396}
397
398impl EntityBatchLoader {
399    /// Create a new batch loader.
400    pub fn new(
401        cache: Arc<EntityCache>,
402        resolver: impl Fn(EntityBatch) -> Pin<Box<dyn Future<Output = Result<Vec<(String, String)>>> + Send>>
403            + Send
404            + Sync
405            + 'static,
406        max_batch_size: usize,
407        default_ttl: Duration,
408    ) -> Self {
409        Self {
410            cache,
411            resolver: Arc::new(Box::new(resolver)),
412            max_batch_size,
413            default_ttl,
414        }
415    }
416
417    /// Resolve a list of entity keys for a given type, using the cache.
418    ///
419    /// Returns a map from key JSON to resolved data JSON.
420    pub async fn load_many(
421        &self,
422        type_name: &str,
423        key_jsons: Vec<String>,
424        tenant_id: Option<&str>,
425    ) -> Result<HashMap<String, String>> {
426        let mut result: HashMap<String, String> = HashMap::new();
427        let mut cache_misses: Vec<String> = Vec::new();
428
429        // Check cache first
430        for key_json in &key_jsons {
431            let cache_key = EntityCacheKey::new(tenant_id, type_name, key_json.as_str());
432            if let Some(data) = self.cache.get(&cache_key) {
433                result.insert(key_json.clone(), data);
434            } else {
435                cache_misses.push(key_json.clone());
436            }
437        }
438
439        // Resolve misses in batches
440        for chunk in cache_misses.chunks(self.max_batch_size) {
441            let mut batch = EntityBatch::new(type_name, tenant_id);
442            for key in chunk {
443                batch.add_key(key.clone());
444            }
445
446            let resolved = (self.resolver)(batch).await?;
447
448            for (key_json, data_json) in resolved {
449                let cache_key = EntityCacheKey::new(tenant_id, type_name, key_json.as_str());
450                let entity = ResolvedEntity {
451                    type_name: type_name.to_string(),
452                    key_json: key_json.clone(),
453                    data_json: data_json.clone(),
454                    resolved_at: Instant::now(),
455                    ttl: self.default_ttl,
456                };
457                self.cache.put(cache_key, entity);
458                result.insert(key_json, data_json);
459            }
460        }
461
462        Ok(result)
463    }
464
465    /// Load a single entity.
466    pub async fn load_one(
467        &self,
468        type_name: &str,
469        key_json: &str,
470        tenant_id: Option<&str>,
471    ) -> Result<Option<String>> {
472        let mut results = self
473            .load_many(type_name, vec![key_json.to_string()], tenant_id)
474            .await?;
475        Ok(results.remove(key_json))
476    }
477
478    /// Return a reference to the underlying entity cache.
479    pub fn cache(&self) -> &Arc<EntityCache> {
480        &self.cache
481    }
482
483    /// Invalidate all cached entities of a given type.
484    pub fn invalidate_type(&self, type_name: &str) -> usize {
485        // We use the full clear as a conservative strategy since we can't
486        // efficiently filter by type without an extra index. In production this
487        // would be extended with a type index.
488        let mut to_remove: Vec<EntityCacheKey> = Vec::new();
489        if let Ok(store) = self.cache.store.lock() {
490            for key in store.entries.keys() {
491                if key.type_name == type_name {
492                    to_remove.push(key.clone());
493                }
494            }
495        }
496        let count = to_remove.len();
497        if let Ok(mut store) = self.cache.store.lock() {
498            for key in &to_remove {
499                store.remove(key);
500            }
501        }
502        count
503    }
504}
505
506/// Create a simple mock batch resolver for testing.
507///
508/// The mock resolver returns `{"id": "<key>"}` for every requested key.
509#[allow(clippy::type_complexity)]
510pub fn mock_batch_resolver(
511) -> impl Fn(EntityBatch) -> Pin<Box<dyn Future<Output = Result<Vec<(String, String)>>> + Send>>
512       + Send
513       + Sync
514       + 'static {
515    |batch: EntityBatch| {
516        Box::pin(async move {
517            let results: Vec<(String, String)> = batch
518                .keys
519                .into_iter()
520                .map(|k| {
521                    let data = format!(r#"{{"id": {k}, "type": "{}"}}"#, batch.type_name);
522                    (k, data)
523                })
524                .collect();
525            Ok(results)
526        })
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::sync::Arc;
534    use std::time::Duration;
535
536    fn make_cache() -> Arc<EntityCache> {
537        Arc::new(EntityCache::new(100, Duration::from_secs(60)))
538    }
539
540    fn entity(type_name: &str, key: &str, data: &str) -> ResolvedEntity {
541        ResolvedEntity {
542            type_name: type_name.to_string(),
543            key_json: key.to_string(),
544            data_json: data.to_string(),
545            resolved_at: Instant::now(),
546            ttl: Duration::from_secs(60),
547        }
548    }
549
550    fn ekey(type_name: &str, key: &str) -> EntityCacheKey {
551        EntityCacheKey::new(None, type_name, key)
552    }
553
554    fn ekey_tenant(tenant: &str, type_name: &str, key: &str) -> EntityCacheKey {
555        EntityCacheKey::new(Some(tenant), type_name, key)
556    }
557
558    // ---- EntityCacheKey tests -----------------------------------------------
559
560    #[test]
561    fn test_entity_cache_key_equality() {
562        let k1 = ekey("Product", r#"{"id":1}"#);
563        let k2 = ekey("Product", r#"{"id":1}"#);
564        assert_eq!(k1, k2);
565    }
566
567    #[test]
568    fn test_entity_cache_key_different_types() {
569        let k1 = ekey("Product", r#"{"id":1}"#);
570        let k2 = ekey("User", r#"{"id":1}"#);
571        assert_ne!(k1, k2);
572    }
573
574    #[test]
575    fn test_entity_cache_key_different_tenants() {
576        let k1 = ekey_tenant("acme", "Product", r#"{"id":1}"#);
577        let k2 = ekey_tenant("corp", "Product", r#"{"id":1}"#);
578        assert_ne!(k1, k2);
579    }
580
581    #[test]
582    fn test_entity_cache_key_none_vs_some_tenant() {
583        let k1 = ekey("Product", r#"{"id":1}"#);
584        let k2 = ekey_tenant("acme", "Product", r#"{"id":1}"#);
585        assert_ne!(k1, k2);
586    }
587
588    // ---- ResolvedEntity tests -----------------------------------------------
589
590    #[test]
591    fn test_resolved_entity_not_expired_immediately() {
592        let e = entity("Product", r#"{"id":1}"#, r#"{"name":"Foo"}"#);
593        assert!(!e.is_expired());
594    }
595
596    #[test]
597    fn test_resolved_entity_expired_after_ttl() {
598        let mut e = entity("Product", r#"{"id":1}"#, r#"{"name":"Foo"}"#);
599        e.ttl = Duration::from_nanos(1);
600        e.resolved_at = Instant::now();
601        std::thread::sleep(Duration::from_millis(5));
602        assert!(e.is_expired());
603    }
604
605    #[test]
606    fn test_resolved_entity_remaining_ttl() {
607        let e = entity("Product", r#"{"id":1}"#, r#"{"name":"Foo"}"#);
608        assert!(e.remaining_ttl() > Duration::ZERO);
609    }
610
611    // ---- EntityCache tests --------------------------------------------------
612
613    #[test]
614    fn test_entity_cache_put_and_get() {
615        let cache = make_cache();
616        let key = ekey("Product", r#"{"id":1}"#);
617        let e = entity("Product", r#"{"id":1}"#, r#"{"name":"Widget"}"#);
618        cache.put(key.clone(), e);
619
620        let result = cache.get(&key);
621        assert_eq!(result.as_deref(), Some(r#"{"name":"Widget"}"#));
622    }
623
624    #[test]
625    fn test_entity_cache_miss_returns_none() {
626        let cache = make_cache();
627        let key = ekey("Product", r#"{"id":999}"#);
628        assert!(cache.get(&key).is_none());
629    }
630
631    #[test]
632    fn test_entity_cache_expired_entry_removed_on_get() {
633        let cache = make_cache();
634        let key = ekey("Product", r#"{"id":1}"#);
635        let mut e = entity("Product", r#"{"id":1}"#, r#"{"name":"Old"}"#);
636        e.ttl = Duration::from_nanos(1);
637        cache.put_with_ttl(key.clone(), e, Duration::from_nanos(1));
638
639        std::thread::sleep(Duration::from_millis(5));
640
641        assert!(cache.get(&key).is_none());
642        assert_eq!(cache.size(), 0);
643    }
644
645    #[test]
646    fn test_entity_cache_lru_eviction() {
647        let cache = Arc::new(EntityCache::new(2, Duration::from_secs(60)));
648
649        let k1 = ekey("T", "k1");
650        let k2 = ekey("T", "k2");
651        let k3 = ekey("T", "k3");
652
653        cache.put(k1.clone(), entity("T", "k1", "d1"));
654        cache.put(k2.clone(), entity("T", "k2", "d2"));
655        // Touch k1 to make k2 the LRU
656        cache.get(&k1);
657        // Insert k3 — should evict k2
658        cache.put(k3.clone(), entity("T", "k3", "d3"));
659
660        assert_eq!(cache.size(), 2);
661        assert!(cache.get(&k2).is_none(), "k2 should have been evicted");
662        assert!(cache.get(&k1).is_some());
663        assert!(cache.get(&k3).is_some());
664    }
665
666    #[test]
667    fn test_entity_cache_evict_expired() {
668        let cache = Arc::new(EntityCache::new(100, Duration::from_nanos(1)));
669        let key = ekey("Product", "exp");
670        cache.put_with_ttl(
671            key,
672            entity("Product", "exp", "old"),
673            Duration::from_nanos(1),
674        );
675        std::thread::sleep(Duration::from_millis(5));
676
677        let removed = cache.evict_expired();
678        assert_eq!(removed, 1);
679        assert_eq!(cache.size(), 0);
680    }
681
682    #[test]
683    fn test_entity_cache_clear() {
684        let cache = make_cache();
685        cache.put(ekey("T", "k1"), entity("T", "k1", "d1"));
686        cache.put(ekey("T", "k2"), entity("T", "k2", "d2"));
687
688        let removed = cache.clear();
689        assert_eq!(removed, 2);
690        assert_eq!(cache.size(), 0);
691    }
692
693    #[test]
694    fn test_entity_cache_stats() {
695        let cache = make_cache();
696        let key = ekey("Product", "k1");
697        cache.put(key.clone(), entity("Product", "k1", "d1"));
698        cache.get(&key); // hit
699        cache.get(&ekey("Product", "miss")); // miss
700
701        let stats = cache.stats();
702        assert_eq!(stats.total_hits, 1);
703        assert_eq!(stats.total_misses, 1);
704        assert_eq!(stats.current_size, 1);
705        assert!((cache.hit_rate() - 0.5).abs() < f64::EPSILON);
706    }
707
708    #[test]
709    fn test_entity_cache_per_type_stats() {
710        let cache = make_cache();
711        let key = ekey("Product", "k1");
712        cache.put(key.clone(), entity("Product", "k1", "d1"));
713        cache.get(&key); // hit for Product
714        cache.get(&ekey("User", "u1")); // miss for User
715
716        let stats = cache.stats();
717        let product_stats = stats.by_type.get("Product");
718        assert!(product_stats.is_some());
719        // Hit should be recorded for Product
720        assert_eq!(product_stats.map(|(h, _)| *h).unwrap_or(0), 1);
721    }
722
723    #[test]
724    fn test_entity_cache_tenant_isolation() {
725        let cache = make_cache();
726        let k1 = ekey_tenant("acme", "Product", r#"{"id":1}"#);
727        let k2 = ekey_tenant("corp", "Product", r#"{"id":1}"#);
728
729        cache.put(
730            k1.clone(),
731            entity("Product", r#"{"id":1}"#, r#"{"name":"ACME Widget"}"#),
732        );
733        cache.put(
734            k2.clone(),
735            entity("Product", r#"{"id":1}"#, r#"{"name":"Corp Widget"}"#),
736        );
737
738        assert_ne!(cache.get(&k1), cache.get(&k2));
739        assert_eq!(cache.size(), 2);
740    }
741
742    // ---- EntityBatch tests --------------------------------------------------
743
744    #[test]
745    fn test_entity_batch_add_key() {
746        let mut batch = EntityBatch::new("Product", None);
747        assert!(batch.is_empty());
748
749        batch.add_key(r#"{"id":1}"#);
750        batch.add_key(r#"{"id":2}"#);
751        assert_eq!(batch.len(), 2);
752        assert!(!batch.is_empty());
753    }
754
755    #[test]
756    fn test_entity_batch_tenant() {
757        let batch = EntityBatch::new("Product", Some("acme"));
758        assert_eq!(batch.tenant_id.as_deref(), Some("acme"));
759    }
760
761    // ---- EntityBatchLoader tests --------------------------------------------
762
763    #[tokio::test]
764    async fn test_batch_loader_load_one_hit() {
765        let cache = make_cache();
766        let loader = EntityBatchLoader::new(
767            Arc::clone(&cache),
768            mock_batch_resolver(),
769            50,
770            Duration::from_secs(60),
771        );
772
773        let result = loader.load_one("Product", r#"{"id":1}"#, None).await;
774        assert!(result.is_ok());
775        assert!(result.expect("should succeed").is_some());
776    }
777
778    #[tokio::test]
779    async fn test_batch_loader_caches_result() {
780        let cache = make_cache();
781        let loader = EntityBatchLoader::new(
782            Arc::clone(&cache),
783            mock_batch_resolver(),
784            50,
785            Duration::from_secs(60),
786        );
787
788        // First call resolves and caches
789        let _ = loader.load_one("Product", r#"{"id":42}"#, None).await;
790        assert_eq!(cache.size(), 1);
791
792        // Second call should be a cache hit (no additional resolver call needed)
793        let result = loader.load_one("Product", r#"{"id":42}"#, None).await;
794        assert!(result.expect("should succeed").is_some());
795    }
796
797    #[tokio::test]
798    async fn test_batch_loader_load_many() {
799        let cache = make_cache();
800        let loader = EntityBatchLoader::new(
801            Arc::clone(&cache),
802            mock_batch_resolver(),
803            50,
804            Duration::from_secs(60),
805        );
806
807        let keys = vec![
808            r#"{"id":1}"#.to_string(),
809            r#"{"id":2}"#.to_string(),
810            r#"{"id":3}"#.to_string(),
811        ];
812
813        let results = loader.load_many("Product", keys.clone(), None).await;
814        assert!(results.is_ok());
815        let map = results.expect("should succeed");
816        assert_eq!(map.len(), 3);
817        for key in &keys {
818            assert!(map.contains_key(key.as_str()));
819        }
820    }
821
822    #[tokio::test]
823    async fn test_batch_loader_invalidate_type() {
824        let cache = make_cache();
825        let loader = EntityBatchLoader::new(
826            Arc::clone(&cache),
827            mock_batch_resolver(),
828            50,
829            Duration::from_secs(60),
830        );
831
832        let _ = loader.load_one("Product", r#"{"id":1}"#, None).await;
833        let _ = loader.load_one("Product", r#"{"id":2}"#, None).await;
834        let _ = loader.load_one("User", r#"{"id":1}"#, None).await;
835
836        assert_eq!(cache.size(), 3);
837
838        let removed = loader.invalidate_type("Product");
839        assert_eq!(removed, 2);
840        assert_eq!(cache.size(), 1);
841    }
842
843    #[tokio::test]
844    async fn test_batch_loader_batches_large_requests() {
845        // Verify that chunking works correctly for large key lists
846        let cache = make_cache();
847        let loader = EntityBatchLoader::new(
848            Arc::clone(&cache),
849            mock_batch_resolver(),
850            2, // small batch size to test chunking
851            Duration::from_secs(60),
852        );
853
854        let keys: Vec<String> = (1..=5).map(|i| format!(r#"{{"id":{i}}}"#)).collect();
855        let results = loader.load_many("Product", keys, None).await;
856        assert!(results.is_ok());
857        assert_eq!(results.expect("should succeed").len(), 5);
858    }
859
860    #[test]
861    fn test_entity_cache_size_after_multiple_puts() {
862        let cache = make_cache();
863        for i in 0..10 {
864            cache.put(
865                ekey("T", &format!("k{i}")),
866                entity("T", &format!("k{i}"), &format!("d{i}")),
867            );
868        }
869        assert_eq!(cache.size(), 10);
870    }
871
872    #[test]
873    fn test_entity_cache_hit_rate_zero_when_no_requests() {
874        let cache = make_cache();
875        assert_eq!(cache.hit_rate(), 0.0);
876    }
877}