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}