fraiseql-core 2.2.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! Invalidation methods for `CachedDatabaseAdapter`.
//!
//! This module provides view-level and entity-level cache invalidation,
//! including cascade expansion via `CascadeInvalidator`.

use super::adapter::CachedDatabaseAdapter;
use crate::{db::DatabaseAdapter, error::Result};

impl<A: DatabaseAdapter> CachedDatabaseAdapter<A> {
    /// Invalidate cache entries that read from specified views.
    ///
    /// Call this after mutations to ensure cache consistency. All cache entries
    /// that accessed any of the modified views will be removed.
    ///
    /// # Arguments
    ///
    /// * `views` - List of views/tables that were modified
    ///
    /// # Returns
    ///
    /// Number of cache entries invalidated
    ///
    /// # Errors
    ///
    /// Returns error if cache mutex is poisoned (very rare).
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
    /// # use fraiseql_core::db::postgres::PostgresAdapter;
    /// # async fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) -> Result<(), Box<dyn std::error::Error>> {
    /// // After creating a user
    /// let count = adapter.invalidate_views(&["v_user".to_string()])?;
    /// println!("Invalidated {} cache entries", count);
    /// # Ok(())
    /// # }
    /// ```
    pub fn invalidate_views(&self, views: &[String]) -> Result<u64> {
        // When caching is disabled, both the shard scan and the CascadeInvalidator
        // mutex are unnecessary — skip them entirely to avoid serializing mutations.
        if !self.cache.is_enabled() {
            return Ok(0);
        }

        // Expand the view list with transitive dependents when a cascade
        // invalidator is configured.
        if let Some(cascader) = &self.cascade_invalidator {
            let mut expanded: std::collections::HashSet<String> = views.iter().cloned().collect();
            let mut guard = cascader.lock().map_err(|e| crate::error::FraiseQLError::Internal {
                message: format!("Cascade invalidator lock poisoned: {e}"),
                source:  None,
            })?;
            for view in views {
                let transitive = guard.cascade_invalidate(view)?;
                expanded.extend(transitive);
            }
            let expanded_views: Vec<String> = expanded.into_iter().collect();
            return self.cache.invalidate_views(&expanded_views);
        }
        self.cache.invalidate_views(views)
    }

    /// Invalidate cache entries based on GraphQL Cascade response entities.
    ///
    /// This is the entity-aware invalidation method that provides more
    /// precise invalidation. Instead of invalidating all caches reading from
    /// a view, only caches that depend on the affected entities are invalidated.
    ///
    /// # Arguments
    ///
    /// * `cascade_response` - GraphQL mutation response with cascade field
    /// * `parser` - `CascadeResponseParser` to extract entities
    ///
    /// # Returns
    ///
    /// Number of cache entries invalidated
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, CascadeResponseParser};
    /// # use fraiseql_core::db::postgres::PostgresAdapter;
    /// # use serde_json::json;
    /// # async fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) -> Result<(), Box<dyn std::error::Error>> {
    /// let cascade_response = json!({
    ///     "createPost": {
    ///         "cascade": {
    ///             "updated": [
    ///                 { "__typename": "User", "id": "uuid-1" }
    ///             ]
    ///         }
    ///     }
    /// });
    ///
    /// let parser = CascadeResponseParser::new();
    /// let count = adapter.invalidate_cascade_entities(&cascade_response, &parser)?;
    /// println!("Invalidated {} cache entries", count);
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// # Note on Performance
    ///
    /// This method replaces view-level invalidation with entity-level invalidation.
    /// Instead of clearing all caches that touch a view (e.g., `v_user`), only caches
    /// that touch the specific entities are cleared (e.g., User:uuid-1).
    ///
    /// Expected improvement:
    /// - **View-level**: 60-70% hit rate (many false positives)
    /// - **Entity-level**: 90-95% hit rate (only true positives)
    ///
    /// # Errors
    ///
    /// Returns `FraiseQLError` if the cascade response cannot be parsed.
    pub fn invalidate_cascade_entities(
        &self,
        cascade_response: &serde_json::Value,
        parser: &super::cascade_response_parser::CascadeResponseParser,
    ) -> Result<u64> {
        // Parse cascade response to extract affected entities
        let cascade_entities = parser.parse_cascade_response(cascade_response)?;

        if !cascade_entities.has_changes() {
            // No entities affected - no invalidation needed
            return Ok(0);
        }

        // View-level invalidation: convert entity types to view names and evict all
        // cache entries that read from those views. This is used for the cascade response
        // path where multiple entity types can be affected by a single mutation.
        // Unlike the executor's entity-aware path, cascade invalidation uses view-level
        // because the cascade entities may not be indexed in the cache by entity ID.
        let mut views_to_invalidate = std::collections::HashSet::new();
        for entity in cascade_entities.all_affected() {
            // Derive view name from entity type (e.g., "User" → "v_user")
            let view_name = format!("v_{}", entity.entity_type.to_lowercase());
            views_to_invalidate.insert(view_name);
        }

        let views: Vec<String> = views_to_invalidate.into_iter().collect();
        self.cache.invalidate_views(&views)
    }

    /// Evict only list (multi-row) cache entries for the given views.
    ///
    /// Unlike `invalidate_views()`, leaves single-entity point-lookup entries
    /// intact.  Used for CREATE mutations: creating a new entity does not affect
    /// queries that fetch a *different* existing entity by UUID.
    ///
    /// Expands the view list with transitive dependents when a
    /// `CascadeInvalidator` is configured (same logic as `invalidate_views()`).
    ///
    /// # Returns
    ///
    /// Number of cache entries evicted.
    ///
    /// # Errors
    ///
    /// Returns error if the cascade invalidator lock is poisoned.
    pub fn invalidate_list_queries(&self, views: &[String]) -> Result<u64> {
        if !self.cache.is_enabled() {
            return Ok(0);
        }

        if let Some(cascader) = &self.cascade_invalidator {
            let mut expanded: std::collections::HashSet<String> = views.iter().cloned().collect();
            let mut guard = cascader.lock().map_err(|e| crate::error::FraiseQLError::Internal {
                message: format!("Cascade invalidator lock poisoned: {e}"),
                source:  None,
            })?;
            for view in views {
                let transitive = guard.cascade_invalidate(view)?;
                expanded.extend(transitive);
            }
            let expanded_views: Vec<String> = expanded.into_iter().collect();
            return self.cache.invalidate_list_queries(&expanded_views);
        }
        self.cache.invalidate_list_queries(views)
    }

    /// Evict cache entries that contain the given entity UUID.
    ///
    /// Delegates to `QueryResultCache::invalidate_by_entity`. Only entries
    /// whose entity-ID index (built at `put()` time) contains the given UUID
    /// are removed; all other entries remain warm.
    ///
    /// # Returns
    ///
    /// Number of cache entries evicted.
    ///
    /// # Errors
    ///
    /// Returns error if the cache mutex is poisoned.
    pub fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
        self.cache.invalidate_by_entity(entity_type, entity_id)
    }
}