Skip to main content

fraiseql_core/cache/
cascade_invalidator.rs

1//! Cascading cache invalidation for transitive view dependencies.
2//!
3//! Tracks view-to-view dependencies and propagates invalidation through the dependency graph.
4//! When a view is invalidated, all dependent views are also invalidated.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8use serde::{Deserialize, Serialize};
9
10#[allow(unused_imports)] // Reason: used only in doc links for `# Errors` sections
11use crate::error::FraiseQLError;
12use crate::error::Result;
13
14/// Tracks transitive view-to-view dependencies for cascading invalidation.
15///
16/// # Architecture
17///
18/// When `v_user` depends on `v_raw_user`, and `v_analytics` depends on `v_user`:
19///
20/// ```text
21/// v_raw_user (source)
22///    ↓ (depends on)
23/// v_user (intermediate)
24///    ↓ (depends on)
25/// v_analytics (leaf)
26/// ```
27///
28/// Invalidating `v_raw_user` cascades to invalidate `v_user` and `v_analytics`.
29///
30/// # Example
31///
32/// ```rust
33/// use fraiseql_core::cache::cascade_invalidator::CascadeInvalidator;
34///
35/// let mut invalidator = CascadeInvalidator::new();
36///
37/// // Register that v_user depends on v_raw_user
38/// invalidator.add_dependency("v_user", "v_raw_user").unwrap();
39///
40/// // Register that v_analytics depends on v_user
41/// invalidator.add_dependency("v_analytics", "v_user").unwrap();
42///
43/// // Invalidate v_raw_user - cascades to v_user and v_analytics
44/// let affected = invalidator.cascade_invalidate("v_raw_user").unwrap();
45/// assert_eq!(affected.len(), 3); // v_raw_user, v_user, v_analytics
46/// ```
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CascadeInvalidator {
49    /// Dependent view → list of views it depends on.
50    /// Example: `v_user` → [`v_raw_user`]
51    view_dependencies: HashMap<String, HashSet<String>>,
52
53    /// View → list of views that depend on it (reverse mapping).
54    /// Example: `v_raw_user` → [`v_user`]
55    dependents: HashMap<String, HashSet<String>>,
56
57    /// Statistics for monitoring.
58    stats: InvalidationStats,
59}
60
61/// Statistics for cascade invalidation operations.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct InvalidationStats {
64    /// Total number of cascade invalidations performed.
65    pub total_cascades: u64,
66
67    /// Total views invalidated across all cascades.
68    pub total_invalidated: u64,
69
70    /// Average views affected per cascade.
71    pub average_affected: f64,
72
73    /// Maximum views affected in a single cascade.
74    pub max_affected: usize,
75}
76
77impl Default for InvalidationStats {
78    fn default() -> Self {
79        Self {
80            total_cascades:    0,
81            total_invalidated: 0,
82            average_affected:  0.0,
83            max_affected:      0,
84        }
85    }
86}
87
88impl CascadeInvalidator {
89    /// Create new cascade invalidator.
90    #[must_use]
91    pub fn new() -> Self {
92        Self {
93            view_dependencies: HashMap::new(),
94            dependents:        HashMap::new(),
95            stats:             InvalidationStats::default(),
96        }
97    }
98
99    /// Register a view dependency.
100    ///
101    /// Declares that `dependent_view` depends on `dependency_view`.
102    /// When `dependency_view` is invalidated, `dependent_view` will also be invalidated.
103    ///
104    /// # Arguments
105    ///
106    /// * `dependent_view` - View that depends on another
107    /// * `dependency_view` - View that is depended upon
108    ///
109    /// # Errors
110    ///
111    /// Returns [`FraiseQLError::Validation`] if `dependent_view` and
112    /// `dependency_view` are the same (self-dependency), or if adding the edge
113    /// would create a cycle in the dependency graph.
114    ///
115    /// # Example
116    ///
117    /// ```rust
118    /// use fraiseql_core::cache::cascade_invalidator::CascadeInvalidator;
119    ///
120    /// let mut invalidator = CascadeInvalidator::new();
121    /// invalidator.add_dependency("v_analytics", "v_user").unwrap();
122    /// ```
123    pub fn add_dependency(&mut self, dependent_view: &str, dependency_view: &str) -> Result<()> {
124        if dependent_view == dependency_view {
125            return Err(crate::error::FraiseQLError::Validation {
126                message: "View cannot depend on itself".to_string(),
127                path:    Some("cascade_invalidator::add_dependency".to_string()),
128            });
129        }
130
131        // Check for indirect cycles: would adding dependent → dependency create a cycle?
132        // That happens if dependency_view can already reach dependent_view via existing edges.
133        // We traverse the `dependents` graph (reverse edges) from dependency_view and check
134        // whether we ever reach dependent_view.
135        if self.can_reach(dependency_view, dependent_view) {
136            return Err(crate::error::FraiseQLError::Validation {
137                message: format!(
138                    "Adding dependency '{}' → '{}' would create a cycle",
139                    dependent_view, dependency_view
140                ),
141                path:    Some("cascade_invalidator::add_dependency".to_string()),
142            });
143        }
144
145        let dependent = dependent_view.to_string();
146        let dependency = dependency_view.to_string();
147
148        // Add forward mapping: dependent → dependency
149        self.view_dependencies
150            .entry(dependent.clone())
151            .or_default()
152            .insert(dependency.clone());
153
154        // Add reverse mapping: dependency → dependent
155        self.dependents.entry(dependency).or_default().insert(dependent);
156
157        Ok(())
158    }
159
160    /// Check whether `from` can reach `target` by following the forward dependency graph.
161    ///
162    /// Uses BFS over `view_dependencies` (from dependent to what it depends on). Returns
163    /// `true` if `target` is reachable from `from`, meaning `from` transitively depends on
164    /// `target`.
165    ///
166    /// This is used to detect cycles before adding a new edge: if `dependency_view` can
167    /// already reach `dependent_view`, adding `dependent_view → dependency_view` would
168    /// create a cycle.
169    fn can_reach(&self, from: &str, target: &str) -> bool {
170        let mut visited = HashSet::new();
171        let mut queue = VecDeque::new();
172        queue.push_back(from.to_string());
173
174        while let Some(current) = queue.pop_front() {
175            if current == target {
176                return true;
177            }
178            if !visited.insert(current.clone()) {
179                continue; // already visited
180            }
181            // Follow forward edges: what does `current` depend on?
182            if let Some(deps) = self.view_dependencies.get(&current) {
183                for dep in deps {
184                    if !visited.contains(dep.as_str()) {
185                        queue.push_back(dep.clone());
186                    }
187                }
188            }
189        }
190
191        false
192    }
193
194    /// Cascade invalidate a view and all dependent views.
195    ///
196    /// Uses breadth-first search to find all views that transitively depend on
197    /// the given view, and returns the complete set of invalidated views.
198    ///
199    /// # Algorithm
200    ///
201    /// 1. Start with the target view
202    /// 2. Find all views that directly depend on it
203    /// 3. For each dependent, recursively find views that depend on it
204    /// 4. Return complete set (target + all transitive dependents)
205    ///
206    /// # Arguments
207    ///
208    /// * `view` - View to invalidate
209    ///
210    /// # Returns
211    ///
212    /// Set of all invalidated views (including the target)
213    ///
214    /// # Errors
215    ///
216    /// Currently infallible — always returns `Ok`. The `Result` return type is
217    /// reserved for future cycle-detection logic that may return
218    /// [`FraiseQLError::Validation`] on circular dependency graphs.
219    ///
220    /// # Example
221    ///
222    /// ```rust
223    /// use fraiseql_core::cache::cascade_invalidator::CascadeInvalidator;
224    ///
225    /// let mut invalidator = CascadeInvalidator::new();
226    /// invalidator.add_dependency("v_user_stats", "v_user").unwrap();
227    /// invalidator.add_dependency("v_dashboard", "v_user_stats").unwrap();
228    ///
229    /// let invalidated = invalidator.cascade_invalidate("v_user").unwrap();
230    /// assert!(invalidated.contains("v_user"));
231    /// assert!(invalidated.contains("v_user_stats"));
232    /// assert!(invalidated.contains("v_dashboard"));
233    /// ```
234    pub fn cascade_invalidate(&mut self, view: &str) -> Result<HashSet<String>> {
235        let mut invalidated = HashSet::new();
236        let mut queue = VecDeque::new();
237
238        queue.push_back(view.to_string());
239        invalidated.insert(view.to_string());
240
241        // BFS to find all transitive dependents
242        while let Some(current_view) = queue.pop_front() {
243            if let Some(dependent_views) = self.dependents.get(&current_view) {
244                for dependent in dependent_views {
245                    if !invalidated.contains(dependent) {
246                        invalidated.insert(dependent.clone());
247                        queue.push_back(dependent.clone());
248                    }
249                }
250            }
251        }
252
253        // Update statistics
254        self.stats.total_cascades += 1;
255        #[allow(clippy::cast_possible_truncation)]
256        // Reason: invalidated.len() is a usize which fits in u64 on all supported 64-bit platforms
257        {
258            self.stats.total_invalidated += invalidated.len() as u64;
259        }
260        self.stats.max_affected = self.stats.max_affected.max(invalidated.len());
261        if self.stats.total_cascades > 0 {
262            #[allow(clippy::cast_precision_loss)]
263            // Reason: average_affected is a display metric; f64 precision loss on u64 counters is
264            // acceptable
265            {
266                self.stats.average_affected =
267                    self.stats.total_invalidated as f64 / self.stats.total_cascades as f64;
268            }
269        }
270
271        Ok(invalidated)
272    }
273
274    /// Get all views that depend on a given view (direct dependents only).
275    ///
276    /// For transitive dependents, use `cascade_invalidate()`.
277    ///
278    /// # Arguments
279    ///
280    /// * `view` - View to query
281    ///
282    /// # Returns
283    ///
284    /// Set of views that directly depend on the given view
285    #[must_use]
286    pub fn get_direct_dependents(&self, view: &str) -> HashSet<String> {
287        self.dependents.get(view).cloned().unwrap_or_default()
288    }
289
290    /// Get all views that a given view depends on (direct dependencies only).
291    ///
292    /// # Arguments
293    ///
294    /// * `view` - View to query
295    ///
296    /// # Returns
297    ///
298    /// Set of views that the given view depends on
299    #[must_use]
300    pub fn get_direct_dependencies(&self, view: &str) -> HashSet<String> {
301        self.view_dependencies.get(view).cloned().unwrap_or_default()
302    }
303
304    /// Get all views that transitively depend on a given view.
305    ///
306    /// Like `cascade_invalidate()` but non-mutating (for queries).
307    ///
308    /// # Arguments
309    ///
310    /// * `view` - View to query
311    ///
312    /// # Returns
313    ///
314    /// Set of all transitive dependents (including the view itself)
315    #[must_use]
316    pub fn get_transitive_dependents(&self, view: &str) -> HashSet<String> {
317        let mut result = HashSet::new();
318        let mut queue = VecDeque::new();
319
320        queue.push_back(view.to_string());
321        result.insert(view.to_string());
322
323        while let Some(current) = queue.pop_front() {
324            if let Some(deps) = self.dependents.get(&current) {
325                for dep in deps {
326                    if !result.contains(dep) {
327                        result.insert(dep.clone());
328                        queue.push_back(dep.clone());
329                    }
330                }
331            }
332        }
333
334        result
335    }
336
337    /// Check if there's a dependency path between two views.
338    ///
339    /// Returns true if `dependent` transitively depends on `dependency`.
340    ///
341    /// # Arguments
342    ///
343    /// * `dependent` - Potentially dependent view
344    /// * `dependency` - Potentially depended-upon view
345    ///
346    /// # Returns
347    ///
348    /// true if there's a transitive dependency
349    #[must_use]
350    pub fn has_dependency_path(&self, dependent: &str, dependency: &str) -> bool {
351        let mut visited = HashSet::new();
352        let mut queue = VecDeque::new();
353
354        queue.push_back(dependent.to_string());
355
356        while let Some(current) = queue.pop_front() {
357            if visited.contains(&current) {
358                continue;
359            }
360            visited.insert(current.clone());
361
362            if let Some(deps) = self.view_dependencies.get(&current) {
363                for dep in deps {
364                    if dep == dependency {
365                        return true;
366                    }
367                    queue.push_back(dep.clone());
368                }
369            }
370        }
371
372        false
373    }
374
375    /// Get cascade invalidation statistics.
376    #[must_use]
377    pub fn stats(&self) -> InvalidationStats {
378        self.stats.clone()
379    }
380
381    /// Clear all registered dependencies.
382    pub fn clear(&mut self) {
383        self.view_dependencies.clear();
384        self.dependents.clear();
385        self.stats = InvalidationStats::default();
386    }
387
388    /// Get total number of views tracked.
389    #[must_use]
390    pub fn view_count(&self) -> usize {
391        let mut views = HashSet::new();
392        views.extend(self.dependents.keys().cloned());
393        views.extend(self.view_dependencies.keys().cloned());
394        views.len()
395    }
396
397    /// Get total number of dependency edges.
398    #[must_use]
399    pub fn dependency_count(&self) -> usize {
400        self.view_dependencies.values().map(|deps| deps.len()).sum()
401    }
402}
403
404impl Default for CascadeInvalidator {
405    fn default() -> Self {
406        Self::new()
407    }
408}