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
10use crate::error::Result;
11
12/// Tracks transitive view-to-view dependencies for cascading invalidation.
13///
14/// # Architecture
15///
16/// When `v_user` depends on `v_raw_user`, and `v_analytics` depends on `v_user`:
17///
18/// ```text
19/// v_raw_user (source)
20///    ↓ (depends on)
21/// v_user (intermediate)
22///    ↓ (depends on)
23/// v_analytics (leaf)
24/// ```
25///
26/// Invalidating `v_raw_user` cascades to invalidate `v_user` and `v_analytics`.
27///
28/// # Example
29///
30/// ```rust
31/// use fraiseql_core::cache::cascade_invalidator::CascadeInvalidator;
32///
33/// let mut invalidator = CascadeInvalidator::new();
34///
35/// // Register that v_user depends on v_raw_user
36/// invalidator.add_dependency("v_user", "v_raw_user").unwrap();
37///
38/// // Register that v_analytics depends on v_user
39/// invalidator.add_dependency("v_analytics", "v_user").unwrap();
40///
41/// // Invalidate v_raw_user - cascades to v_user and v_analytics
42/// let affected = invalidator.cascade_invalidate("v_raw_user").unwrap();
43/// assert_eq!(affected.len(), 3); // v_raw_user, v_user, v_analytics
44/// ```
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CascadeInvalidator {
47    /// Dependent view → list of views it depends on.
48    /// Example: v_user → [v_raw_user]
49    view_dependencies: HashMap<String, HashSet<String>>,
50
51    /// View → list of views that depend on it (reverse mapping).
52    /// Example: v_raw_user → [v_user]
53    dependents: HashMap<String, HashSet<String>>,
54
55    /// Statistics for monitoring.
56    stats: InvalidationStats,
57}
58
59/// Statistics for cascade invalidation operations.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct InvalidationStats {
62    /// Total number of cascade invalidations performed.
63    pub total_cascades: u64,
64
65    /// Total views invalidated across all cascades.
66    pub total_invalidated: u64,
67
68    /// Average views affected per cascade.
69    pub average_affected: f64,
70
71    /// Maximum views affected in a single cascade.
72    pub max_affected: usize,
73}
74
75impl Default for InvalidationStats {
76    fn default() -> Self {
77        Self {
78            total_cascades:    0,
79            total_invalidated: 0,
80            average_affected:  0.0,
81            max_affected:      0,
82        }
83    }
84}
85
86impl CascadeInvalidator {
87    /// Create new cascade invalidator.
88    #[must_use]
89    pub fn new() -> Self {
90        Self {
91            view_dependencies: HashMap::new(),
92            dependents:        HashMap::new(),
93            stats:             InvalidationStats::default(),
94        }
95    }
96
97    /// Register a view dependency.
98    ///
99    /// Declares that `dependent_view` depends on `dependency_view`.
100    /// When `dependency_view` is invalidated, `dependent_view` will also be invalidated.
101    ///
102    /// # Arguments
103    ///
104    /// * `dependent_view` - View that depends on another
105    /// * `dependency_view` - View that is depended upon
106    ///
107    /// # Example
108    ///
109    /// ```rust
110    /// use fraiseql_core::cache::cascade_invalidator::CascadeInvalidator;
111    ///
112    /// let mut invalidator = CascadeInvalidator::new();
113    /// invalidator.add_dependency("v_analytics", "v_user").unwrap();
114    /// ```
115    pub fn add_dependency(&mut self, dependent_view: &str, dependency_view: &str) -> Result<()> {
116        if dependent_view == dependency_view {
117            return Err(crate::error::FraiseQLError::Validation {
118                message: "View cannot depend on itself".to_string(),
119                path:    Some("cascade_invalidator::add_dependency".to_string()),
120            });
121        }
122
123        let dependent = dependent_view.to_string();
124        let dependency = dependency_view.to_string();
125
126        // Add forward mapping: dependent → dependency
127        self.view_dependencies
128            .entry(dependent.clone())
129            .or_insert_with(HashSet::new)
130            .insert(dependency.clone());
131
132        // Add reverse mapping: dependency → dependent
133        self.dependents.entry(dependency).or_insert_with(HashSet::new).insert(dependent);
134
135        Ok(())
136    }
137
138    /// Cascade invalidate a view and all dependent views.
139    ///
140    /// Uses breadth-first search to find all views that transitively depend on
141    /// the given view, and returns the complete set of invalidated views.
142    ///
143    /// # Algorithm
144    ///
145    /// 1. Start with the target view
146    /// 2. Find all views that directly depend on it
147    /// 3. For each dependent, recursively find views that depend on it
148    /// 4. Return complete set (target + all transitive dependents)
149    ///
150    /// # Arguments
151    ///
152    /// * `view` - View to invalidate
153    ///
154    /// # Returns
155    ///
156    /// Set of all invalidated views (including the target)
157    ///
158    /// # Example
159    ///
160    /// ```rust
161    /// use fraiseql_core::cache::cascade_invalidator::CascadeInvalidator;
162    ///
163    /// let mut invalidator = CascadeInvalidator::new();
164    /// invalidator.add_dependency("v_user_stats", "v_user").unwrap();
165    /// invalidator.add_dependency("v_dashboard", "v_user_stats").unwrap();
166    ///
167    /// let invalidated = invalidator.cascade_invalidate("v_user").unwrap();
168    /// assert!(invalidated.contains("v_user"));
169    /// assert!(invalidated.contains("v_user_stats"));
170    /// assert!(invalidated.contains("v_dashboard"));
171    /// ```
172    pub fn cascade_invalidate(&mut self, view: &str) -> Result<HashSet<String>> {
173        let mut invalidated = HashSet::new();
174        let mut queue = VecDeque::new();
175
176        queue.push_back(view.to_string());
177        invalidated.insert(view.to_string());
178
179        // BFS to find all transitive dependents
180        while let Some(current_view) = queue.pop_front() {
181            if let Some(dependent_views) = self.dependents.get(&current_view) {
182                for dependent in dependent_views {
183                    if !invalidated.contains(dependent) {
184                        invalidated.insert(dependent.clone());
185                        queue.push_back(dependent.clone());
186                    }
187                }
188            }
189        }
190
191        // Update statistics
192        self.stats.total_cascades += 1;
193        self.stats.total_invalidated += invalidated.len() as u64;
194        self.stats.max_affected = self.stats.max_affected.max(invalidated.len());
195        if self.stats.total_cascades > 0 {
196            self.stats.average_affected =
197                self.stats.total_invalidated as f64 / self.stats.total_cascades as f64;
198        }
199
200        Ok(invalidated)
201    }
202
203    /// Get all views that depend on a given view (direct dependents only).
204    ///
205    /// For transitive dependents, use `cascade_invalidate()`.
206    ///
207    /// # Arguments
208    ///
209    /// * `view` - View to query
210    ///
211    /// # Returns
212    ///
213    /// Set of views that directly depend on the given view
214    pub fn get_direct_dependents(&self, view: &str) -> HashSet<String> {
215        self.dependents.get(view).cloned().unwrap_or_default()
216    }
217
218    /// Get all views that a given view depends on (direct dependencies only).
219    ///
220    /// # Arguments
221    ///
222    /// * `view` - View to query
223    ///
224    /// # Returns
225    ///
226    /// Set of views that the given view depends on
227    pub fn get_direct_dependencies(&self, view: &str) -> HashSet<String> {
228        self.view_dependencies.get(view).cloned().unwrap_or_default()
229    }
230
231    /// Get all views that transitively depend on a given view.
232    ///
233    /// Like `cascade_invalidate()` but non-mutating (for queries).
234    ///
235    /// # Arguments
236    ///
237    /// * `view` - View to query
238    ///
239    /// # Returns
240    ///
241    /// Set of all transitive dependents (including the view itself)
242    pub fn get_transitive_dependents(&self, view: &str) -> HashSet<String> {
243        let mut result = HashSet::new();
244        let mut queue = VecDeque::new();
245
246        queue.push_back(view.to_string());
247        result.insert(view.to_string());
248
249        while let Some(current) = queue.pop_front() {
250            if let Some(deps) = self.dependents.get(&current) {
251                for dep in deps {
252                    if !result.contains(dep) {
253                        result.insert(dep.clone());
254                        queue.push_back(dep.clone());
255                    }
256                }
257            }
258        }
259
260        result
261    }
262
263    /// Check if there's a dependency path between two views.
264    ///
265    /// Returns true if `dependent` transitively depends on `dependency`.
266    ///
267    /// # Arguments
268    ///
269    /// * `dependent` - Potentially dependent view
270    /// * `dependency` - Potentially depended-upon view
271    ///
272    /// # Returns
273    ///
274    /// true if there's a transitive dependency
275    pub fn has_dependency_path(&self, dependent: &str, dependency: &str) -> bool {
276        let mut visited = HashSet::new();
277        let mut queue = VecDeque::new();
278
279        queue.push_back(dependent.to_string());
280
281        while let Some(current) = queue.pop_front() {
282            if visited.contains(&current) {
283                continue;
284            }
285            visited.insert(current.clone());
286
287            if let Some(deps) = self.view_dependencies.get(&current) {
288                for dep in deps {
289                    if dep == dependency {
290                        return true;
291                    }
292                    queue.push_back(dep.clone());
293                }
294            }
295        }
296
297        false
298    }
299
300    /// Get cascade invalidation statistics.
301    pub fn stats(&self) -> InvalidationStats {
302        self.stats.clone()
303    }
304
305    /// Clear all registered dependencies.
306    pub fn clear(&mut self) {
307        self.view_dependencies.clear();
308        self.dependents.clear();
309        self.stats = InvalidationStats::default();
310    }
311
312    /// Get total number of views tracked.
313    pub fn view_count(&self) -> usize {
314        let mut views = HashSet::new();
315        views.extend(self.dependents.keys().cloned());
316        views.extend(self.view_dependencies.keys().cloned());
317        views.len()
318    }
319
320    /// Get total number of dependency edges.
321    pub fn dependency_count(&self) -> usize {
322        self.view_dependencies.values().map(|deps| deps.len()).sum()
323    }
324}
325
326impl Default for CascadeInvalidator {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_add_single_dependency() {
338        let mut invalidator = CascadeInvalidator::new();
339        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
340
341        assert!(invalidator.get_direct_dependencies("v_user").contains("v_raw_user"));
342        assert!(invalidator.get_direct_dependents("v_raw_user").contains("v_user"));
343    }
344
345    #[test]
346    fn test_self_dependency_fails() {
347        let mut invalidator = CascadeInvalidator::new();
348        let result = invalidator.add_dependency("v_user", "v_user");
349        assert!(result.is_err());
350    }
351
352    #[test]
353    fn test_cascade_invalidate_single_level() {
354        let mut invalidator = CascadeInvalidator::new();
355        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
356
357        let invalidated = invalidator.cascade_invalidate("v_raw_user").unwrap();
358        assert_eq!(invalidated.len(), 2);
359        assert!(invalidated.contains("v_raw_user"));
360        assert!(invalidated.contains("v_user"));
361    }
362
363    #[test]
364    fn test_cascade_invalidate_multiple_levels() {
365        let mut invalidator = CascadeInvalidator::new();
366        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
367        invalidator.add_dependency("v_analytics", "v_user").unwrap();
368        invalidator.add_dependency("v_dashboard", "v_analytics").unwrap();
369
370        let invalidated = invalidator.cascade_invalidate("v_raw_user").unwrap();
371        assert_eq!(invalidated.len(), 4);
372        assert!(invalidated.contains("v_raw_user"));
373        assert!(invalidated.contains("v_user"));
374        assert!(invalidated.contains("v_analytics"));
375        assert!(invalidated.contains("v_dashboard"));
376    }
377
378    #[test]
379    fn test_cascade_invalidate_branching() {
380        let mut invalidator = CascadeInvalidator::new();
381        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
382        invalidator.add_dependency("v_post", "v_raw_user").unwrap();
383        invalidator.add_dependency("v_analytics", "v_user").unwrap();
384        invalidator.add_dependency("v_dashboard", "v_post").unwrap();
385
386        let invalidated = invalidator.cascade_invalidate("v_raw_user").unwrap();
387        assert_eq!(invalidated.len(), 5);
388        assert!(invalidated.contains("v_raw_user"));
389        assert!(invalidated.contains("v_user"));
390        assert!(invalidated.contains("v_post"));
391        assert!(invalidated.contains("v_analytics"));
392        assert!(invalidated.contains("v_dashboard"));
393    }
394
395    #[test]
396    fn test_get_direct_dependents() {
397        let mut invalidator = CascadeInvalidator::new();
398        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
399        invalidator.add_dependency("v_post", "v_raw_user").unwrap();
400
401        let dependents = invalidator.get_direct_dependents("v_raw_user");
402        assert_eq!(dependents.len(), 2);
403        assert!(dependents.contains("v_user"));
404        assert!(dependents.contains("v_post"));
405    }
406
407    #[test]
408    fn test_get_direct_dependencies() {
409        let mut invalidator = CascadeInvalidator::new();
410        invalidator.add_dependency("v_analytics", "v_user").unwrap();
411        invalidator.add_dependency("v_analytics", "v_post").unwrap();
412
413        let deps = invalidator.get_direct_dependencies("v_analytics");
414        assert_eq!(deps.len(), 2);
415        assert!(deps.contains("v_user"));
416        assert!(deps.contains("v_post"));
417    }
418
419    #[test]
420    fn test_get_transitive_dependents() {
421        let mut invalidator = CascadeInvalidator::new();
422        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
423        invalidator.add_dependency("v_analytics", "v_user").unwrap();
424        invalidator.add_dependency("v_dashboard", "v_analytics").unwrap();
425
426        let transitive = invalidator.get_transitive_dependents("v_raw_user");
427        assert_eq!(transitive.len(), 4);
428        assert!(transitive.contains("v_raw_user"));
429        assert!(transitive.contains("v_user"));
430        assert!(transitive.contains("v_analytics"));
431        assert!(transitive.contains("v_dashboard"));
432    }
433
434    #[test]
435    fn test_has_dependency_path() {
436        let mut invalidator = CascadeInvalidator::new();
437        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
438        invalidator.add_dependency("v_analytics", "v_user").unwrap();
439
440        assert!(invalidator.has_dependency_path("v_analytics", "v_raw_user"));
441        assert!(invalidator.has_dependency_path("v_analytics", "v_user"));
442        assert!(invalidator.has_dependency_path("v_user", "v_raw_user"));
443        assert!(!invalidator.has_dependency_path("v_raw_user", "v_analytics"));
444        assert!(!invalidator.has_dependency_path("v_raw_user", "v_user"));
445    }
446
447    #[test]
448    fn test_stats_tracking() {
449        let mut invalidator = CascadeInvalidator::new();
450        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
451        invalidator.add_dependency("v_analytics", "v_user").unwrap();
452
453        invalidator.cascade_invalidate("v_raw_user").unwrap();
454        invalidator.cascade_invalidate("v_user").unwrap();
455
456        let stats = invalidator.stats();
457        assert_eq!(stats.total_cascades, 2);
458        assert_eq!(stats.total_invalidated, 5); // 3 (raw_user + user + analytics) + 2 (user + analytics)
459        assert_eq!(stats.max_affected, 3);
460    }
461
462    #[test]
463    fn test_clear() {
464        let mut invalidator = CascadeInvalidator::new();
465        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
466        assert_eq!(invalidator.view_count(), 2);
467
468        invalidator.clear();
469        assert_eq!(invalidator.view_count(), 0);
470        assert_eq!(invalidator.dependency_count(), 0);
471    }
472
473    #[test]
474    fn test_view_and_dependency_count() {
475        let mut invalidator = CascadeInvalidator::new();
476        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
477        invalidator.add_dependency("v_post", "v_raw_user").unwrap();
478        invalidator.add_dependency("v_analytics", "v_user").unwrap();
479
480        assert_eq!(invalidator.view_count(), 4);
481        assert_eq!(invalidator.dependency_count(), 3);
482    }
483
484    #[test]
485    fn test_diamond_dependency() {
486        let mut invalidator = CascadeInvalidator::new();
487        // Diamond: raw → [user, post] → analytics
488        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
489        invalidator.add_dependency("v_post", "v_raw_user").unwrap();
490        invalidator.add_dependency("v_analytics", "v_user").unwrap();
491        invalidator.add_dependency("v_analytics", "v_post").unwrap();
492
493        let invalidated = invalidator.cascade_invalidate("v_raw_user").unwrap();
494        // raw_user, user, post, analytics (4 total)
495        assert_eq!(invalidated.len(), 4);
496        assert!(invalidated.contains("v_raw_user"));
497        assert!(invalidated.contains("v_user"));
498        assert!(invalidated.contains("v_post"));
499        assert!(invalidated.contains("v_analytics"));
500    }
501
502    #[test]
503    fn test_multiple_independent_chains() {
504        let mut invalidator = CascadeInvalidator::new();
505        // Chain 1: raw1 → user1 → analytics1
506        invalidator.add_dependency("v_user_1", "v_raw_1").unwrap();
507        invalidator.add_dependency("v_analytics_1", "v_user_1").unwrap();
508        // Chain 2: raw2 → user2 → analytics2
509        invalidator.add_dependency("v_user_2", "v_raw_2").unwrap();
510        invalidator.add_dependency("v_analytics_2", "v_user_2").unwrap();
511
512        let invalidated = invalidator.cascade_invalidate("v_raw_1").unwrap();
513        assert_eq!(invalidated.len(), 3); // Only chain 1
514        assert!(!invalidated.contains("v_raw_2"));
515        assert!(!invalidated.contains("v_user_2"));
516    }
517
518    #[test]
519    fn test_cycle_detection_via_has_dependency_path() {
520        let mut invalidator = CascadeInvalidator::new();
521        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
522        invalidator.add_dependency("v_analytics", "v_user").unwrap();
523        // Note: Can't actually add cycle due to self-dependency check
524
525        // Verify no cycles in what we added
526        assert!(!invalidator.has_dependency_path("v_raw_user", "v_analytics"));
527    }
528
529    #[test]
530    fn test_serialization() {
531        let mut invalidator = CascadeInvalidator::new();
532        invalidator.add_dependency("v_user", "v_raw_user").unwrap();
533
534        let json = serde_json::to_string(&invalidator).expect("serialize should work");
535        let restored: CascadeInvalidator =
536            serde_json::from_str(&json).expect("deserialize should work");
537
538        assert_eq!(
539            restored.get_direct_dependents("v_raw_user"),
540            invalidator.get_direct_dependents("v_raw_user")
541        );
542    }
543}