Skip to main content

fraiseql_core/cache/
invalidation_api.rs

1//! Invalidation methods for `CachedDatabaseAdapter`.
2//!
3//! This module provides view-level and entity-level cache invalidation,
4//! including cascade expansion via `CascadeInvalidator`.
5
6use fraiseql_db::ViewName;
7
8use super::adapter::CachedDatabaseAdapter;
9use crate::{db::DatabaseAdapter, error::Result};
10
11impl<A: DatabaseAdapter> CachedDatabaseAdapter<A> {
12    /// Invalidate cache entries that read from specified views.
13    ///
14    /// Call this after mutations to ensure cache consistency. All cache entries
15    /// that accessed any of the modified views will be removed.
16    ///
17    /// # Arguments
18    ///
19    /// * `views` - List of views/tables that were modified
20    ///
21    /// # Returns
22    ///
23    /// Number of cache entries invalidated
24    ///
25    /// # Errors
26    ///
27    /// Returns error if cache mutex is poisoned (very rare).
28    ///
29    /// # Example
30    ///
31    /// ```rust,no_run
32    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
33    /// # use fraiseql_core::db::postgres::PostgresAdapter;
34    /// # use fraiseql_db::ViewName;
35    /// # async fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) -> Result<(), Box<dyn std::error::Error>> {
36    /// // After creating a user
37    /// let count = adapter.invalidate_views(&[ViewName::from("v_user")])?;
38    /// println!("Invalidated {} cache entries", count);
39    /// # Ok(())
40    /// # }
41    /// ```
42    pub fn invalidate_views(&self, views: &[ViewName]) -> Result<u64> {
43        // When caching is disabled, both the shard scan and the CascadeInvalidator
44        // mutex are unnecessary — skip them entirely to avoid serializing mutations.
45        if !self.cache.is_enabled() {
46            return Ok(0);
47        }
48
49        // Expand the view list with transitive dependents when a cascade
50        // invalidator is configured. `CascadeInvalidator` works in terms of raw
51        // `String` view names today; promote each `ViewName` to `String` for the
52        // cascade walk and convert the expanded result back at the end.
53        if let Some(cascader) = &self.cascade_invalidator {
54            let mut expanded: std::collections::HashSet<String> =
55                views.iter().map(|v| v.as_str().to_owned()).collect();
56            let mut guard = cascader.lock().map_err(|e| crate::error::FraiseQLError::Internal {
57                message: format!("Cascade invalidator lock poisoned: {e}"),
58                source:  None,
59            })?;
60            for view in views {
61                let transitive = guard.cascade_invalidate(view.as_str())?;
62                expanded.extend(transitive);
63            }
64            let expanded_views: Vec<ViewName> = expanded.into_iter().map(ViewName::from).collect();
65            return self.cache.invalidate_views(&expanded_views);
66        }
67        self.cache.invalidate_views(views)
68    }
69
70    /// Invalidate cache entries based on GraphQL Cascade response entities.
71    ///
72    /// This is the entity-aware invalidation method that provides more
73    /// precise invalidation. Instead of invalidating all caches reading from
74    /// a view, only caches that depend on the affected entities are invalidated.
75    ///
76    /// # Arguments
77    ///
78    /// * `cascade_response` - GraphQL mutation response with cascade field
79    /// * `parser` - `CascadeResponseParser` to extract entities
80    ///
81    /// # Returns
82    ///
83    /// Number of cache entries invalidated
84    ///
85    /// # Example
86    ///
87    /// ```rust,no_run
88    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, CascadeResponseParser};
89    /// # use fraiseql_core::db::postgres::PostgresAdapter;
90    /// # use serde_json::json;
91    /// # async fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) -> Result<(), Box<dyn std::error::Error>> {
92    /// let cascade_response = json!({
93    ///     "createPost": {
94    ///         "cascade": {
95    ///             "updated": [
96    ///                 { "__typename": "User", "id": "uuid-1" }
97    ///             ]
98    ///         }
99    ///     }
100    /// });
101    ///
102    /// let parser = CascadeResponseParser::new();
103    /// let count = adapter.invalidate_cascade_entities(&cascade_response, &parser)?;
104    /// println!("Invalidated {} cache entries", count);
105    /// # Ok(())
106    /// # }
107    /// ```
108    ///
109    /// # Note on Performance
110    ///
111    /// This method replaces view-level invalidation with entity-level invalidation.
112    /// Instead of clearing all caches that touch a view (e.g., `v_user`), only caches
113    /// that touch the specific entities are cleared (e.g., User:uuid-1).
114    ///
115    /// Expected improvement:
116    /// - **View-level**: 60-70% hit rate (many false positives)
117    /// - **Entity-level**: 90-95% hit rate (only true positives)
118    ///
119    /// # Errors
120    ///
121    /// Returns `FraiseQLError` if the cascade response cannot be parsed.
122    pub fn invalidate_cascade_entities(
123        &self,
124        cascade_response: &serde_json::Value,
125        parser: &super::cascade_response_parser::CascadeResponseParser,
126    ) -> Result<u64> {
127        // Parse cascade response to extract affected entities
128        let cascade_entities = parser.parse_cascade_response(cascade_response)?;
129
130        if !cascade_entities.has_changes() {
131            // No entities affected - no invalidation needed
132            return Ok(0);
133        }
134
135        // View-level invalidation: convert entity types to view names and evict all
136        // cache entries that read from those views. This is used for the cascade response
137        // path where multiple entity types can be affected by a single mutation.
138        // Unlike the executor's entity-aware path, cascade invalidation uses view-level
139        // because the cascade entities may not be indexed in the cache by entity ID.
140        let mut views_to_invalidate = std::collections::HashSet::new();
141        for entity in cascade_entities.all_affected() {
142            // Derive view name from entity type (e.g., "User" → "v_user")
143            let view_name = format!("v_{}", entity.entity_type.to_lowercase());
144            views_to_invalidate.insert(view_name);
145        }
146
147        let views: Vec<ViewName> = views_to_invalidate.into_iter().map(ViewName::from).collect();
148        self.cache.invalidate_views(&views)
149    }
150
151    /// Evict only list (multi-row) cache entries for the given views.
152    ///
153    /// Unlike `invalidate_views()`, leaves single-entity point-lookup entries
154    /// intact.  Used for CREATE mutations: creating a new entity does not affect
155    /// queries that fetch a *different* existing entity by UUID.
156    ///
157    /// Expands the view list with transitive dependents when a
158    /// `CascadeInvalidator` is configured (same logic as `invalidate_views()`).
159    ///
160    /// # Returns
161    ///
162    /// Number of cache entries evicted.
163    ///
164    /// # Errors
165    ///
166    /// Returns error if the cascade invalidator lock is poisoned.
167    pub fn invalidate_list_queries(&self, views: &[ViewName]) -> Result<u64> {
168        if !self.cache.is_enabled() {
169            return Ok(0);
170        }
171
172        if let Some(cascader) = &self.cascade_invalidator {
173            let mut expanded: std::collections::HashSet<String> =
174                views.iter().map(|v| v.as_str().to_owned()).collect();
175            let mut guard = cascader.lock().map_err(|e| crate::error::FraiseQLError::Internal {
176                message: format!("Cascade invalidator lock poisoned: {e}"),
177                source:  None,
178            })?;
179            for view in views {
180                let transitive = guard.cascade_invalidate(view.as_str())?;
181                expanded.extend(transitive);
182            }
183            let expanded_views: Vec<ViewName> = expanded.into_iter().map(ViewName::from).collect();
184            return self.cache.invalidate_list_queries(&expanded_views);
185        }
186        self.cache.invalidate_list_queries(views)
187    }
188
189    /// Evict cache entries that contain the given entity UUID.
190    ///
191    /// Delegates to `QueryResultCache::invalidate_by_entity`. Only entries
192    /// whose entity-ID index (built at `put()` time) contains the given UUID
193    /// are removed; all other entries remain warm.
194    ///
195    /// # Returns
196    ///
197    /// Number of cache entries evicted.
198    ///
199    /// # Errors
200    ///
201    /// Returns error if the cache mutex is poisoned.
202    pub fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
203        self.cache.invalidate_by_entity(entity_type, entity_id)
204    }
205}