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(¤t) {
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(¤t_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(¤t) {
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(¤t) {
358 continue;
359 }
360 visited.insert(current.clone());
361
362 if let Some(deps) = self.view_dependencies.get(¤t) {
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}