Skip to main content

fraiseql_core/cache/
result.rs

1//! Query result caching with W-TinyLFU eviction and per-entry TTL.
2//!
3//! This module provides a `moka::sync::Cache`-backed store for GraphQL query results.
4//! Moka uses Concurrent W-TinyLFU policy with lock-free reads — cache hits do NOT
5//! acquire any shared lock, eliminating the hot-key serialisation bottleneck present
6//! in the old 64-shard `parking_lot::Mutex<LruCache>` design.
7//!
8//! ## Performance characteristics
9//!
10//! - **`get()` hot path** (cache hit): lock-free frequency-counter update (thread-local ring
11//!   buffer, drained lazily on writes), `Arc` clone (single atomic increment), one atomic counter
12//!   bump.
13//! - **`put()` path**: early-exit guards (disabled / list / size) before touching the store.
14//!   Reverse-index updates use `DashMap` (fine-grained sharding, no global lock).
15//! - **`metrics()`**: reads `store.entry_count()` directly — no shard scan.
16//! - **`invalidate_views()` / `invalidate_by_entity()`**: O(k) where k = matching entries (via
17//!   reverse indexes), not O(total entries).
18//!
19//! ## Reverse indexes
20//!
21//! Because `moka` does not support arbitrary iteration, view-based and entity-based
22//! invalidation rely on two `DashMap` reverse indexes maintained alongside the store:
23//!
24//! ```text
25//! view_index:   DashMap<view_name,   DashSet<cache_key>>
26//! entity_index: DashMap<entity_type, DashMap<entity_id, DashSet<cache_key>>>
27//! ```
28//!
29//! Indexes are populated in `put()` and pruned via moka's eviction listener (fired
30//! asynchronously). `clear()` resets all indexes synchronously.
31
32use std::{
33    collections::HashSet,
34    sync::{
35        Arc,
36        atomic::{AtomicU64, AtomicUsize, Ordering},
37    },
38    time::Duration,
39};
40
41use dashmap::{DashMap, DashSet};
42use fraiseql_db::ViewName;
43use moka::sync::Cache as MokaCache;
44use serde::{Deserialize, Serialize};
45
46use super::config::CacheConfig;
47use crate::{db::types::JsonbValue, error::Result};
48
49/// Cached query result with metadata.
50///
51/// Stores the query result along with tracking information for
52/// TTL expiry, view-based invalidation, and monitoring.
53#[derive(Debug, Clone)]
54pub struct CachedResult {
55    /// The actual query result (JSONB array from database).
56    ///
57    /// Wrapped in `Arc` for cheap cloning on cache hits (zero-copy).
58    pub result: Arc<Vec<JsonbValue>>,
59
60    /// Which views/tables this query accesses.
61    ///
62    /// Format: `[ViewName::from("v_user"), ViewName::from("v_post")]`
63    ///
64    /// Stored as a boxed slice of [`ViewName`] (each backed by `Arc<str>`)
65    /// so cloning a name into the reverse index is a cheap atomic ref-count
66    /// bump rather than a fresh heap allocation. Views are fixed at `put()`
67    /// time and never modified.
68    pub accessed_views: Box<[ViewName]>,
69
70    /// When this entry was cached (Unix timestamp in seconds).
71    ///
72    /// Wall-clock timestamp for debugging. TTL enforcement is handled by moka
73    /// internally via `CacheEntryExpiry`.
74    pub cached_at: u64,
75
76    /// Per-entry TTL in seconds.
77    ///
78    /// Overrides `CacheConfig::ttl_seconds` when set via `put(..., Some(ttl))`.
79    /// Read by `CacheEntryExpiry::expire_after_create` to tell moka the expiry.
80    pub ttl_seconds: u64,
81
82    /// Entity references for selective entity-level invalidation.
83    ///
84    /// Contains one `(entity_type, entity_id)` pair per row in `result` that has
85    /// a valid string in its `"id"` field.  Empty for queries with no `id` column
86    /// or when `put()` is called without an `entity_type`.
87    /// Used by the eviction listener to clean up `entity_index` on eviction.
88    pub entity_refs: Box<[(String, String)]>,
89
90    /// True when `result.len() > 1` at put time.
91    ///
92    /// Used by `invalidate_list_queries()` to avoid evicting single-entity
93    /// point-lookup entries on CREATE mutations.
94    pub is_list_query: bool,
95}
96
97/// Moka `Expiry` implementation: reads TTL from `CachedResult.ttl_seconds`.
98struct CacheEntryExpiry;
99
100impl moka::Expiry<u64, Arc<CachedResult>> for CacheEntryExpiry {
101    fn expire_after_create(
102        &self,
103        _key: &u64,
104        value: &Arc<CachedResult>,
105        _created_at: std::time::Instant,
106    ) -> Option<Duration> {
107        if value.ttl_seconds == 0 {
108            // TTL=0 means "no time-based expiry" — entry lives until explicitly
109            // invalidated by a mutation.  Return None so moka never schedules
110            // a timer-wheel eviction for this entry.
111            None
112        } else {
113            Some(Duration::from_secs(value.ttl_seconds))
114        }
115    }
116
117    // `expire_after_read` is intentionally NOT overridden.
118    //
119    // Moka's default returns `None` (no change to the timer) which skips the
120    // internal timer-wheel reschedule on every get().  Overriding it to return
121    // `duration_until_expiry` — even though the value is semantically unchanged —
122    // forces moka to acquire its timer-wheel lock on every cache hit.  Under 40
123    // concurrent workers reading the same key, that lock becomes the new hot-key
124    // bottleneck, serialising reads and degrading list-query throughput ~3×.
125    //
126    // Entries expire at creation_time + ttl_seconds regardless of read frequency,
127    // which is the correct fixed-TTL semantics for query result caching.
128}
129
130/// Thread-safe W-TinyLFU cache for query results.
131///
132/// Backed by [`moka::sync::Cache`] which provides lock-free reads via
133/// Concurrent `TinyLFU`. Reverse `DashMap` indexes enable O(k) invalidation.
134///
135/// # Thread Safety
136///
137/// `moka::sync::Cache` is `Send + Sync`. All reverse indexes use `DashMap`
138/// (fine-grained shard locking) and `DashSet` (also shard-locked). There is no
139/// global mutex on the read path.
140///
141/// # Example
142///
143/// ```rust
144/// use fraiseql_core::cache::{QueryResultCache, CacheConfig};
145/// use fraiseql_core::db::types::JsonbValue;
146/// use serde_json::json;
147///
148/// let cache = QueryResultCache::new(CacheConfig::default());
149///
150/// // Cache a result
151/// let result = vec![JsonbValue::new(json!({"id": 1, "name": "Alice"}))];
152/// cache.put(
153///     12345_u64,
154///     result.clone(),
155///     vec!["v_user".to_string()],
156///     None, // use global TTL
157///     None, // no entity type index
158/// ).unwrap();
159///
160/// // Retrieve from cache
161/// if let Some(cached) = cache.get(12345).unwrap() {
162///     println!("Cache hit! {} results", cached.len());
163/// }
164/// ```
165pub struct QueryResultCache {
166    /// Moka W-TinyLFU store.
167    ///
168    /// `Arc<CachedResult>` rather than `CachedResult` so that `get()` returns in
169    /// one atomic increment instead of deep-cloning the struct (which would copy
170    /// `accessed_views: Box<[String]>` on every cache hit).
171    store: MokaCache<u64, Arc<CachedResult>>,
172
173    /// Configuration (immutable after creation).
174    config: CacheConfig,
175
176    // Metrics counters — `Relaxed` ordering is sufficient: these counters are
177    // used only for monitoring, not for correctness or synchronisation.
178    hits:          AtomicU64,
179    misses:        AtomicU64,
180    total_cached:  AtomicU64,
181    invalidations: AtomicU64,
182
183    /// Estimated total memory in use.
184    ///
185    /// Wrapped in `Arc` so the eviction listener closure (which requires `'static`)
186    /// can hold a clone and decrement on eviction.
187    memory_bytes: Arc<AtomicUsize>,
188
189    /// Reverse index: view name → set of cache keys accessing that view.
190    ///
191    /// Keys are [`ViewName`] (`Arc<str>` inside) so inserts share the same
192    /// allocation as the names stored in [`CachedResult::accessed_views`].
193    /// Lookup by `&str` still works via the `Borrow<str>` impl on `ViewName`.
194    view_index: Arc<DashMap<ViewName, DashSet<u64>>>,
195
196    /// Reverse index: entity type → entity id → set of cache keys.
197    entity_index: Arc<DashMap<String, DashMap<String, DashSet<u64>>>>,
198
199    /// Reverse index: view name → set of cache keys for list (multi-row) entries only.
200    ///
201    /// Populated in `put_arc()` when `result.len() > 1`. Used by
202    /// `invalidate_list_queries()` for CREATE-targeted eviction that leaves
203    /// point-lookup entries intact.
204    list_index: Arc<DashMap<ViewName, DashSet<u64>>>,
205}
206
207/// Cache metrics for monitoring.
208///
209/// Exposed via API for observability and debugging.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct CacheMetrics {
212    /// Number of cache hits (returned cached result).
213    pub hits: u64,
214
215    /// Number of cache misses (executed query).
216    pub misses: u64,
217
218    /// Total entries cached across all time.
219    pub total_cached: u64,
220
221    /// Number of invalidations triggered.
222    pub invalidations: u64,
223
224    /// Current size of cache (number of entries).
225    pub size: usize,
226
227    /// Estimated memory usage in bytes.
228    ///
229    /// This is a rough estimate based on `CachedResult` struct size.
230    /// Actual memory usage may vary based on result sizes.
231    pub memory_bytes: usize,
232}
233
234/// Estimate the per-entry accounting overhead.
235const fn entry_overhead() -> usize {
236    std::mem::size_of::<CachedResult>() + std::mem::size_of::<u64>() * 2
237}
238
239/// Build the moka store, wiring the eviction listener to the reverse indexes
240/// and memory counter.
241fn build_store(
242    config: &CacheConfig,
243    memory_bytes: Arc<AtomicUsize>,
244    view_index: Arc<DashMap<ViewName, DashSet<u64>>>,
245    entity_index: Arc<DashMap<String, DashMap<String, DashSet<u64>>>>,
246    list_index: Arc<DashMap<ViewName, DashSet<u64>>>,
247) -> MokaCache<u64, Arc<CachedResult>> {
248    let max_cap = config.max_entries as u64;
249    let mb = memory_bytes;
250    let vi = view_index;
251    let ei = entity_index;
252    let li = list_index;
253
254    MokaCache::builder()
255        .max_capacity(max_cap)
256        .expire_after(CacheEntryExpiry)
257        .eviction_listener(move |key: Arc<u64>, value: Arc<CachedResult>, _cause| {
258            // Decrement memory budget so put()'s byte-gate stays accurate.
259            mb.fetch_sub(entry_overhead(), Ordering::Relaxed);
260
261            // Remove key from view index.
262            for view in &value.accessed_views {
263                if let Some(keys) = vi.get(view) {
264                    keys.remove(&*key);
265                }
266            }
267
268            // Remove key from list index (only populated for multi-row entries).
269            if value.is_list_query {
270                for view in &value.accessed_views {
271                    if let Some(keys) = li.get(view) {
272                        keys.remove(&*key);
273                    }
274                }
275            }
276
277            // Remove ALL entity_refs from entity index.
278            for (et, id) in &*value.entity_refs {
279                if let Some(by_type) = ei.get(et) {
280                    if let Some(keys) = by_type.get(id) {
281                        keys.remove(&*key);
282                    }
283                }
284            }
285        })
286        .build()
287}
288
289impl QueryResultCache {
290    /// Create new cache with configuration.
291    ///
292    /// # Panics
293    ///
294    /// Panics if `config.max_entries` is 0 (invalid configuration).
295    ///
296    /// # Example
297    ///
298    /// ```rust
299    /// use fraiseql_core::cache::{QueryResultCache, CacheConfig};
300    ///
301    /// let cache = QueryResultCache::new(CacheConfig::default());
302    /// ```
303    #[must_use]
304    pub fn new(config: CacheConfig) -> Self {
305        assert!(config.max_entries > 0, "max_entries must be > 0");
306
307        let memory_bytes = Arc::new(AtomicUsize::new(0));
308        let view_index: Arc<DashMap<ViewName, DashSet<u64>>> = Arc::new(DashMap::new());
309        let entity_index: Arc<DashMap<String, DashMap<String, DashSet<u64>>>> =
310            Arc::new(DashMap::new());
311        let list_index: Arc<DashMap<ViewName, DashSet<u64>>> = Arc::new(DashMap::new());
312
313        let store = build_store(
314            &config,
315            Arc::clone(&memory_bytes),
316            Arc::clone(&view_index),
317            Arc::clone(&entity_index),
318            Arc::clone(&list_index),
319        );
320
321        Self {
322            store,
323            config,
324            hits: AtomicU64::new(0),
325            misses: AtomicU64::new(0),
326            total_cached: AtomicU64::new(0),
327            invalidations: AtomicU64::new(0),
328            memory_bytes,
329            view_index,
330            entity_index,
331            list_index,
332        }
333    }
334
335    /// Returns whether caching is enabled.
336    ///
337    /// Used by `CachedDatabaseAdapter` to short-circuit key generation
338    /// and result clone overhead when caching is disabled.
339    #[must_use]
340    pub const fn is_enabled(&self) -> bool {
341        self.config.enabled
342    }
343
344    /// Look up a cached result by its cache key.
345    ///
346    /// Returns `None` when caching is disabled or the key is not present or expired.
347    /// Moka handles TTL expiry internally — if `get()` returns `Some`, the entry is live.
348    ///
349    /// # Errors
350    ///
351    /// This method is infallible. The `Result` return type is kept for API compatibility.
352    pub fn get(&self, cache_key: u64) -> Result<Option<Arc<Vec<JsonbValue>>>> {
353        if !self.config.enabled {
354            return Ok(None);
355        }
356
357        // moka::sync::Cache::get() is lock-free on the read path.
358        if let Some(cached) = self.store.get(&cache_key) {
359            self.hits.fetch_add(1, Ordering::Relaxed);
360            Ok(Some(Arc::clone(&cached.result)))
361        } else {
362            self.misses.fetch_add(1, Ordering::Relaxed);
363            Ok(None)
364        }
365    }
366
367    /// Store query result in cache, accepting an already-`Arc`-wrapped result.
368    ///
369    /// Preferred over [`put`](Self::put) on the hot miss path: callers that already
370    /// hold an `Arc<Vec<JsonbValue>>` (e.g. `CachedDatabaseAdapter`) can store it
371    /// without an extra `Vec` clone.
372    ///
373    /// # Arguments
374    ///
375    /// * `cache_key` - Cache key (from `generate_cache_key()`)
376    /// * `result` - Arc-wrapped query result to cache
377    /// * `accessed_views` - List of views accessed by this query
378    /// * `ttl_override` - Per-entry TTL in seconds; `None` uses `CacheConfig::ttl_seconds`
379    /// * `entity_type` - Optional GraphQL type name for entity-ID indexing
380    ///
381    /// # Errors
382    ///
383    /// This method is infallible. The `Result` return type is kept for API compatibility.
384    pub fn put_arc(
385        &self,
386        cache_key: u64,
387        result: Arc<Vec<JsonbValue>>,
388        accessed_views: Vec<String>,
389        ttl_override: Option<u64>,
390        entity_type: Option<&str>,
391    ) -> Result<()> {
392        if !self.config.enabled {
393            return Ok(());
394        }
395
396        let ttl_seconds = ttl_override.unwrap_or(self.config.ttl_seconds);
397
398        // TTL=0 means "no time-based expiry" — store the entry and rely entirely
399        // on mutation-based invalidation.  expire_after_create returns None for
400        // these entries so moka never schedules a timer-wheel eviction.
401
402        // Respect cache_list_queries: a result with more than one row is considered a list.
403        if !self.config.cache_list_queries && result.len() > 1 {
404            return Ok(());
405        }
406
407        // Enforce per-entry size limit: estimate entry size from serialized JSON.
408        if let Some(max_entry) = self.config.max_entry_bytes {
409            let estimated = serde_json::to_vec(&*result).map_or(0, |v| v.len());
410            if estimated > max_entry {
411                return Ok(()); // silently skip oversized entries
412            }
413        }
414
415        // Enforce total cache size limit.
416        if let Some(max_total) = self.config.max_total_bytes {
417            if self.memory_bytes.load(Ordering::Relaxed) >= max_total {
418                return Ok(()); // silently skip when budget is exhausted
419            }
420        }
421
422        let is_list_query = result.len() > 1;
423
424        // Extract entity refs from ALL rows (not just the first).
425        let entity_refs: Box<[(String, String)]> = if let Some(et) = entity_type {
426            result
427                .iter()
428                .filter_map(|row| {
429                    row.as_value()
430                        .as_object()?
431                        .get("id")?
432                        .as_str()
433                        .map(|id| (et.to_string(), id.to_string()))
434                })
435                .collect::<Vec<_>>()
436                .into_boxed_slice()
437        } else {
438            Box::default()
439        };
440
441        // Promote owned `String` view names into `ViewName(Arc<str>)` exactly
442        // once. The same Arc is then shared by `view_index`, `list_index`,
443        // and `accessed_views` (the slice stored on the cached entry).
444        let accessed_views: Box<[ViewName]> =
445            accessed_views.into_iter().map(ViewName::from).collect();
446
447        // Register in view index.
448        for view in &accessed_views {
449            self.view_index.entry(view.clone()).or_default().insert(cache_key);
450        }
451
452        // Register in list index (only for multi-row results).
453        if is_list_query {
454            for view in &accessed_views {
455                self.list_index.entry(view.clone()).or_default().insert(cache_key);
456            }
457        }
458
459        // Register ALL entity refs in entity index.
460        for (et, id) in &*entity_refs {
461            self.entity_index
462                .entry(et.clone())
463                .or_default()
464                .entry(id.clone())
465                .or_default()
466                .insert(cache_key);
467        }
468
469        let cached = CachedResult {
470            result,
471            accessed_views,
472            cached_at: std::time::SystemTime::now()
473                .duration_since(std::time::UNIX_EPOCH)
474                .map_or(0, |d| d.as_secs()),
475            ttl_seconds,
476            entity_refs,
477            is_list_query,
478        };
479
480        self.memory_bytes.fetch_add(entry_overhead(), Ordering::Relaxed);
481        // Wrap in Arc so moka's get() costs one atomic increment, not a full clone.
482        self.store.insert(cache_key, Arc::new(cached));
483        self.total_cached.fetch_add(1, Ordering::Relaxed);
484        Ok(())
485    }
486
487    /// Store query result in cache.
488    ///
489    /// If caching is disabled, this is a no-op.
490    ///
491    /// Wraps `result` in an `Arc` and delegates to [`put_arc`](Self::put_arc).
492    /// Prefer [`put_arc`](Self::put_arc) when the caller already holds an `Arc`.
493    ///
494    /// # Arguments
495    ///
496    /// * `cache_key` - Cache key (from `generate_cache_key()`)
497    /// * `result` - Query result to cache
498    /// * `accessed_views` - List of views accessed by this query
499    /// * `ttl_override` - Per-entry TTL in seconds; `None` uses `CacheConfig::ttl_seconds`
500    /// * `entity_type` - Optional GraphQL type name (e.g. `"User"`) for entity-ID indexing. When
501    ///   provided, each row's `"id"` field is extracted and stored in `entity_index` so that
502    ///   `invalidate_by_entity()` can perform selective eviction.
503    ///
504    /// # Errors
505    ///
506    /// This method is infallible. The `Result` return type is kept for API compatibility.
507    ///
508    /// # Example
509    ///
510    /// ```rust
511    /// use fraiseql_core::cache::{QueryResultCache, CacheConfig};
512    /// use fraiseql_core::db::types::JsonbValue;
513    /// use serde_json::json;
514    ///
515    /// let cache = QueryResultCache::new(CacheConfig::default());
516    ///
517    /// let result = vec![JsonbValue::new(json!({"id": "uuid-1"}))];
518    /// cache.put(0xabc123, result, vec!["v_user".to_string()], None, Some("User"))?;
519    /// # Ok::<(), fraiseql_core::error::FraiseQLError>(())
520    /// ```
521    pub fn put(
522        &self,
523        cache_key: u64,
524        result: Vec<JsonbValue>,
525        accessed_views: Vec<String>,
526        ttl_override: Option<u64>,
527        entity_type: Option<&str>,
528    ) -> Result<()> {
529        self.put_arc(cache_key, Arc::new(result), accessed_views, ttl_override, entity_type)
530    }
531
532    /// Invalidate entries accessing specified views.
533    ///
534    /// Uses the `view_index` for O(k) lookup instead of O(n) full-cache scan.
535    /// Keys accessing multiple views in `views` are deduplicated before invalidation.
536    ///
537    /// # Arguments
538    ///
539    /// * `views` - List of view/table names modified by mutation
540    ///
541    /// # Returns
542    ///
543    /// Number of cache entries invalidated.
544    ///
545    /// # Errors
546    ///
547    /// This method is infallible. The `Result` return type is kept for API compatibility.
548    ///
549    /// # Example
550    ///
551    /// ```rust
552    /// use fraiseql_core::cache::{QueryResultCache, CacheConfig};
553    /// use fraiseql_db::ViewName;
554    ///
555    /// let cache = QueryResultCache::new(CacheConfig::default());
556    ///
557    /// // After createUser mutation
558    /// let invalidated = cache.invalidate_views(&[ViewName::from("v_user")])?;
559    /// println!("Invalidated {} cache entries", invalidated);
560    /// # Ok::<(), fraiseql_core::error::FraiseQLError>(())
561    /// ```
562    pub fn invalidate_views(&self, views: &[ViewName]) -> Result<u64> {
563        if !self.config.enabled {
564            return Ok(0);
565        }
566
567        // Collect keys first (releases DashMap guards) then invalidate.
568        // Moka's eviction listener fires synchronously on the calling thread, so
569        // we must NOT hold any DashMap shard guard when calling store.invalidate() —
570        // the listener itself calls view_index.get() on the same shard, which
571        // would deadlock on a non-re-entrant parking_lot::RwLock.
572        let mut keys_to_invalidate: HashSet<u64> = HashSet::new();
573        for view in views {
574            // ViewName implements Borrow<str>, so DashMap lookup by &str works
575            // without materialising a fresh ViewName.
576            if let Some(keys) = self.view_index.get(view.as_str()) {
577                // Dedup: a query accessing multiple views in `views` would
578                // otherwise be counted and invalidated once per view.
579                for key in keys.iter() {
580                    keys_to_invalidate.insert(*key);
581                }
582            }
583            // Guard dropped here — safe to proceed
584        }
585
586        #[allow(clippy::cast_possible_truncation)]
587        // Reason: entry count never exceeds u64
588        let count = keys_to_invalidate.len() as u64;
589
590        for key in keys_to_invalidate {
591            self.store.invalidate(&key);
592            // Index cleanup handled by eviction listener.
593        }
594
595        self.invalidations.fetch_add(count, Ordering::Relaxed);
596        Ok(count)
597    }
598
599    /// Evict only list (multi-row) cache entries for the given views.
600    ///
601    /// Unlike `invalidate_views()`, this method leaves single-entity point-lookup
602    /// entries intact. Used for CREATE mutations: creating a new entity does not
603    /// affect queries that fetch a *different* existing entity by UUID, but it
604    /// does invalidate queries that return a variable-length list of entities.
605    ///
606    /// Uses the `list_index` for O(k) lookup.
607    ///
608    /// # Errors
609    ///
610    /// This method is infallible. The `Result` return type is kept for API compatibility.
611    pub fn invalidate_list_queries(&self, views: &[ViewName]) -> Result<u64> {
612        if !self.config.enabled {
613            return Ok(0);
614        }
615
616        let mut keys_to_invalidate: HashSet<u64> = HashSet::new();
617        for view in views {
618            if let Some(keys) = self.list_index.get(view.as_str()) {
619                for k in keys.iter() {
620                    keys_to_invalidate.insert(*k);
621                }
622            }
623        }
624
625        #[allow(clippy::cast_possible_truncation)]
626        // Reason: entry count never exceeds u64
627        let count = keys_to_invalidate.len() as u64;
628        for key in keys_to_invalidate {
629            self.store.invalidate(&key);
630        }
631        self.invalidations.fetch_add(count, Ordering::Relaxed);
632        Ok(count)
633    }
634
635    /// Evict cache entries that contain a specific entity UUID.
636    ///
637    /// Uses the `entity_index` for O(k) lookup. Entries not referencing this
638    /// entity are left untouched.
639    ///
640    /// # Arguments
641    ///
642    /// * `entity_type` - GraphQL type name (e.g. `"User"`)
643    /// * `entity_id`   - UUID string of the mutated entity
644    ///
645    /// # Returns
646    ///
647    /// Number of cache entries evicted.
648    ///
649    /// # Errors
650    ///
651    /// This method is infallible. The `Result` return type is kept for API compatibility.
652    pub fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
653        if !self.config.enabled {
654            return Ok(0);
655        }
656
657        // Short-circuit: if entity_type has no indexed entries, skip the DashMap
658        // lookup entirely.  Covers cold-cache and write-heavy workloads where no
659        // reads are cached yet.
660        if !self.entity_index.contains_key(entity_type) {
661            return Ok(0);
662        }
663
664        // Collect keys first (releases DashMap guards) then invalidate.
665        // Moka's eviction listener fires synchronously on the calling thread, so
666        // we must NOT hold any DashMap shard guard when calling store.invalidate() —
667        // the listener itself calls entity_index.get() on the same shard, which
668        // would deadlock on a non-re-entrant parking_lot::RwLock.
669        let keys_to_invalidate: Vec<u64> = self
670            .entity_index
671            .get(entity_type)
672            .and_then(|by_type| {
673                by_type.get(entity_id).map(|keys| keys.iter().map(|k| *k).collect())
674            })
675            .unwrap_or_default();
676
677        #[allow(clippy::cast_possible_truncation)]
678        // Reason: entry count never exceeds u64
679        let count = keys_to_invalidate.len() as u64;
680
681        for key in keys_to_invalidate {
682            self.store.invalidate(&key);
683            // Index cleanup handled by eviction listener.
684        }
685
686        self.invalidations.fetch_add(count, Ordering::Relaxed);
687        Ok(count)
688    }
689
690    /// Get cache metrics snapshot.
691    ///
692    /// Returns a consistent snapshot of current counters. Individual fields may
693    /// be updated independently (atomics), so the snapshot is not a single atomic
694    /// transaction, but is accurate enough for monitoring.
695    ///
696    /// # Errors
697    ///
698    /// This method is infallible. The `Result` return type is kept for API compatibility.
699    ///
700    /// # Example
701    ///
702    /// ```rust
703    /// use fraiseql_core::cache::{QueryResultCache, CacheConfig};
704    ///
705    /// let cache = QueryResultCache::new(CacheConfig::default());
706    /// let metrics = cache.metrics()?;
707    ///
708    /// println!("Hit rate: {:.1}%", metrics.hit_rate() * 100.0);
709    /// println!("Size: {} / {} entries", metrics.size, 10_000);
710    /// # Ok::<(), fraiseql_core::error::FraiseQLError>(())
711    /// ```
712    pub fn metrics(&self) -> Result<CacheMetrics> {
713        Ok(CacheMetrics {
714            hits: self.hits.load(Ordering::Relaxed),
715            misses: self.misses.load(Ordering::Relaxed),
716            total_cached: self.total_cached.load(Ordering::Relaxed),
717            invalidations: self.invalidations.load(Ordering::Relaxed),
718            #[allow(clippy::cast_possible_truncation)]
719            // Reason: entry count fits in usize on any 64-bit target
720            size: self.store.entry_count() as usize,
721            memory_bytes: self.memory_bytes.load(Ordering::Relaxed),
722        })
723    }
724
725    /// Clear all cache entries.
726    ///
727    /// Resets the store, reverse indexes, and `memory_bytes` synchronously.
728    /// The eviction listener will still fire asynchronously for each evicted entry,
729    /// but its index-cleanup operations will be no-ops on the already-cleared maps.
730    ///
731    /// # Errors
732    ///
733    /// This method is infallible. The `Result` return type is kept for API compatibility.
734    ///
735    /// # Example
736    ///
737    /// ```rust
738    /// use fraiseql_core::cache::{QueryResultCache, CacheConfig};
739    ///
740    /// let cache = QueryResultCache::new(CacheConfig::default());
741    /// cache.clear()?;
742    /// # Ok::<(), fraiseql_core::error::FraiseQLError>(())
743    /// ```
744    pub fn clear(&self) -> Result<()> {
745        self.store.invalidate_all();
746        // Reset indexes and memory counter synchronously — don't rely on the
747        // async eviction listener to do this.
748        self.view_index.clear();
749        self.entity_index.clear();
750        self.list_index.clear();
751        self.memory_bytes.store(0, Ordering::Relaxed);
752        Ok(())
753    }
754
755    /// Flush pending background tasks in the moka store.
756    ///
757    /// Used in tests to synchronise async eviction/invalidation before assertions.
758    #[cfg(test)]
759    pub(crate) fn run_pending_tasks(&self) {
760        self.store.run_pending_tasks();
761    }
762}
763
764impl CacheMetrics {
765    /// Calculate cache hit rate.
766    ///
767    /// Returns ratio of hits to total requests (0.0 to 1.0).
768    ///
769    /// # Returns
770    ///
771    /// - `1.0` if all requests were hits
772    /// - `0.0` if all requests were misses
773    /// - `0.0` if no requests yet
774    ///
775    /// # Example
776    ///
777    /// ```rust
778    /// use fraiseql_core::cache::CacheMetrics;
779    ///
780    /// let metrics = CacheMetrics {
781    ///     hits: 80,
782    ///     misses: 20,
783    ///     total_cached: 100,
784    ///     invalidations: 5,
785    ///     size: 95,
786    ///     memory_bytes: 1_000_000,
787    /// };
788    ///
789    /// assert_eq!(metrics.hit_rate(), 0.8);  // 80% hit rate
790    /// ```
791    #[must_use]
792    pub fn hit_rate(&self) -> f64 {
793        let total = self.hits + self.misses;
794        if total == 0 {
795            return 0.0;
796        }
797        #[allow(clippy::cast_precision_loss)]
798        // Reason: hit-rate is a display metric; f64 precision loss on u64 counters is acceptable
799        {
800            self.hits as f64 / total as f64
801        }
802    }
803
804    /// Check if cache is performing well.
805    ///
806    /// Returns `true` if hit rate is above 60% (reasonable threshold).
807    ///
808    /// # Example
809    ///
810    /// ```rust
811    /// use fraiseql_core::cache::CacheMetrics;
812    ///
813    /// let good_metrics = CacheMetrics {
814    ///     hits: 80,
815    ///     misses: 20,
816    ///     total_cached: 100,
817    ///     invalidations: 5,
818    ///     size: 95,
819    ///     memory_bytes: 1_000_000,
820    /// };
821    ///
822    /// assert!(good_metrics.is_healthy());  // 80% > 60%
823    /// ```
824    #[must_use]
825    pub fn is_healthy(&self) -> bool {
826        self.hit_rate() > 0.6
827    }
828}