Skip to main content

fraiseql_core/cache/
dependency_tracker.rs

1//! Dependency tracking for cache invalidation.
2//!
3//! Tracks which cache entries depend on which database views/tables to enable
4//! efficient view-based invalidation when mutations occur.
5//!
6//! # Current Scope
7//!
8//! - View-based tracking (not entity-level)
9//! - Bidirectional mapping (cache ↔ views)
10//! - Simple dependency management
11//!
12//! # Future Enhancements
13//!
14//! - Entity-level tracking (`User:123` not just `v_user`)
15//! - Cascade integration (parse mutation metadata)
16//! - Coherency validation (ensure no stale reads)
17
18use std::collections::{HashMap, HashSet};
19
20/// Tracks which cache entries depend on which views/tables.
21///
22/// Maintains bidirectional mappings between cache keys and the database
23/// views/tables they access. This enables efficient lookup during invalidation:
24/// "Which cache entries read from `v_user`?"
25///
26/// # Example
27///
28/// ```
29/// use fraiseql_core::cache::DependencyTracker;
30///
31/// let mut tracker = DependencyTracker::new();
32///
33/// // Record that cache entry accesses v_user
34/// tracker.record_access(
35///     "cache_key_abc123".to_string(),
36///     vec!["v_user".to_string()]
37/// );
38///
39/// // Find all caches that read from v_user
40/// let affected = tracker.get_dependent_caches("v_user");
41/// assert!(affected.contains(&"cache_key_abc123".to_string()));
42/// ```
43#[derive(Debug)]
44pub struct DependencyTracker {
45    /// Cache key → list of views accessed.
46    ///
47    /// Forward mapping for removing cache entries.
48    cache_to_views: HashMap<String, Vec<String>>,
49
50    /// View name → set of cache keys that read it.
51    ///
52    /// Reverse mapping for finding affected caches during invalidation.
53    view_to_caches: HashMap<String, HashSet<String>>,
54}
55
56impl DependencyTracker {
57    /// Create new dependency tracker.
58    ///
59    /// # Example
60    ///
61    /// ```
62    /// use fraiseql_core::cache::DependencyTracker;
63    ///
64    /// let tracker = DependencyTracker::new();
65    /// ```
66    #[must_use]
67    pub fn new() -> Self {
68        Self {
69            cache_to_views: HashMap::new(),
70            view_to_caches: HashMap::new(),
71        }
72    }
73
74    /// Record that a cache entry accesses certain views.
75    ///
76    /// Updates both forward and reverse mappings.
77    ///
78    /// # Arguments
79    ///
80    /// * `cache_key` - Cache key (from `generate_cache_key()`)
81    /// * `views` - List of views accessed by the query
82    ///
83    /// # Example
84    ///
85    /// ```
86    /// use fraiseql_core::cache::DependencyTracker;
87    ///
88    /// let mut tracker = DependencyTracker::new();
89    ///
90    /// tracker.record_access(
91    ///     "key1".to_string(),
92    ///     vec!["v_user".to_string(), "v_post".to_string()]
93    /// );
94    /// ```
95    #[allow(clippy::needless_pass_by_value)] // Reason: both cache_key and views are inserted into internal HashMaps and consumed
96    pub fn record_access(&mut self, cache_key: String, views: Vec<String>) {
97        // If updating existing entry, remove old reverse mappings first
98        if let Some(old_views) = self.cache_to_views.get(&cache_key) {
99            for old_view in old_views {
100                if let Some(caches) = self.view_to_caches.get_mut(old_view) {
101                    caches.remove(&cache_key);
102                    // Clean up empty sets
103                    if caches.is_empty() {
104                        self.view_to_caches.remove(old_view);
105                    }
106                }
107            }
108        }
109
110        // Store cache → views mapping (forward)
111        self.cache_to_views.insert(cache_key.clone(), views.clone());
112
113        // Update view → caches reverse mapping
114        for view in views {
115            self.view_to_caches.entry(view).or_default().insert(cache_key.clone());
116        }
117    }
118
119    /// Get all cache keys that access a view.
120    ///
121    /// Used during invalidation to find affected cache entries.
122    ///
123    /// # Arguments
124    ///
125    /// * `view` - View/table name
126    ///
127    /// # Returns
128    ///
129    /// List of cache keys that read from this view
130    ///
131    /// # Example
132    ///
133    /// ```
134    /// use fraiseql_core::cache::DependencyTracker;
135    ///
136    /// let mut tracker = DependencyTracker::new();
137    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
138    ///
139    /// let affected = tracker.get_dependent_caches("v_user");
140    /// assert_eq!(affected.len(), 1);
141    /// assert!(affected.contains(&"key1".to_string()));
142    /// ```
143    #[must_use]
144    pub fn get_dependent_caches(&self, view: &str) -> Vec<String> {
145        self.view_to_caches
146            .get(view)
147            .map(|set| set.iter().cloned().collect())
148            .unwrap_or_default()
149    }
150
151    /// Remove a cache entry from tracking.
152    ///
153    /// Called when cache entry is evicted (LRU) or invalidated.
154    /// Cleans up both forward and reverse mappings.
155    ///
156    /// # Arguments
157    ///
158    /// * `cache_key` - Cache key to remove
159    ///
160    /// # Example
161    ///
162    /// ```
163    /// use fraiseql_core::cache::DependencyTracker;
164    ///
165    /// let mut tracker = DependencyTracker::new();
166    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
167    ///
168    /// tracker.remove_cache("key1");
169    ///
170    /// let affected = tracker.get_dependent_caches("v_user");
171    /// assert_eq!(affected.len(), 0);
172    /// ```
173    pub fn remove_cache(&mut self, cache_key: &str) {
174        if let Some(views) = self.cache_to_views.remove(cache_key) {
175            // Remove from view → caches mappings
176            for view in views {
177                if let Some(caches) = self.view_to_caches.get_mut(&view) {
178                    caches.remove(cache_key);
179                    // Clean up empty sets
180                    if caches.is_empty() {
181                        self.view_to_caches.remove(&view);
182                    }
183                }
184            }
185        }
186    }
187
188    /// Clear all tracking data.
189    ///
190    /// Used for testing and cache flush.
191    ///
192    /// # Example
193    ///
194    /// ```
195    /// use fraiseql_core::cache::DependencyTracker;
196    ///
197    /// let mut tracker = DependencyTracker::new();
198    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
199    ///
200    /// tracker.clear();
201    ///
202    /// assert_eq!(tracker.cache_count(), 0);
203    /// ```
204    pub fn clear(&mut self) {
205        self.cache_to_views.clear();
206        self.view_to_caches.clear();
207    }
208
209    /// Get total number of tracked cache entries.
210    ///
211    /// # Example
212    ///
213    /// ```
214    /// use fraiseql_core::cache::DependencyTracker;
215    ///
216    /// let mut tracker = DependencyTracker::new();
217    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
218    /// tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
219    ///
220    /// assert_eq!(tracker.cache_count(), 2);
221    /// ```
222    #[must_use]
223    pub fn cache_count(&self) -> usize {
224        self.cache_to_views.len()
225    }
226
227    /// Get total number of tracked views.
228    ///
229    /// # Example
230    ///
231    /// ```
232    /// use fraiseql_core::cache::DependencyTracker;
233    ///
234    /// let mut tracker = DependencyTracker::new();
235    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
236    /// tracker.record_access("key2".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
237    ///
238    /// assert_eq!(tracker.view_count(), 2);  // v_user and v_post
239    /// ```
240    #[must_use]
241    pub fn view_count(&self) -> usize {
242        self.view_to_caches.len()
243    }
244
245    /// Get all tracked views.
246    ///
247    /// Used for debugging and monitoring.
248    ///
249    /// # Example
250    ///
251    /// ```
252    /// use fraiseql_core::cache::DependencyTracker;
253    ///
254    /// let mut tracker = DependencyTracker::new();
255    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
256    ///
257    /// let views = tracker.get_all_views();
258    /// assert!(views.contains(&"v_user".to_string()));
259    /// ```
260    #[must_use]
261    pub fn get_all_views(&self) -> Vec<String> {
262        self.view_to_caches.keys().cloned().collect()
263    }
264}
265
266impl Default for DependencyTracker {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_record_and_get_dependency() {
278        let mut tracker = DependencyTracker::new();
279
280        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
281
282        let affected = tracker.get_dependent_caches("v_user");
283        assert_eq!(affected.len(), 1);
284        assert!(affected.contains(&"key1".to_string()));
285    }
286
287    #[test]
288    fn test_multiple_caches_same_view() {
289        let mut tracker = DependencyTracker::new();
290
291        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
292        tracker.record_access("key2".to_string(), vec!["v_user".to_string()]);
293
294        let affected = tracker.get_dependent_caches("v_user");
295        assert_eq!(affected.len(), 2);
296        assert!(affected.contains(&"key1".to_string()));
297        assert!(affected.contains(&"key2".to_string()));
298    }
299
300    #[test]
301    fn test_cache_accesses_multiple_views() {
302        let mut tracker = DependencyTracker::new();
303
304        tracker.record_access("key1".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
305
306        // Should appear in both view mappings
307        let user_caches = tracker.get_dependent_caches("v_user");
308        let post_caches = tracker.get_dependent_caches("v_post");
309
310        assert!(user_caches.contains(&"key1".to_string()));
311        assert!(post_caches.contains(&"key1".to_string()));
312    }
313
314    #[test]
315    fn test_remove_cache() {
316        let mut tracker = DependencyTracker::new();
317
318        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
319        tracker.remove_cache("key1");
320
321        let affected = tracker.get_dependent_caches("v_user");
322        assert_eq!(affected.len(), 0);
323    }
324
325    #[test]
326    fn test_remove_cache_with_multiple_views() {
327        let mut tracker = DependencyTracker::new();
328
329        tracker.record_access("key1".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
330
331        tracker.remove_cache("key1");
332
333        // Should be removed from both mappings
334        assert_eq!(tracker.get_dependent_caches("v_user").len(), 0);
335        assert_eq!(tracker.get_dependent_caches("v_post").len(), 0);
336    }
337
338    #[test]
339    fn test_remove_nonexistent_cache() {
340        let mut tracker = DependencyTracker::new();
341
342        // Should not panic
343        tracker.remove_cache("nonexistent");
344    }
345
346    #[test]
347    fn test_get_nonexistent_view() {
348        let tracker = DependencyTracker::new();
349
350        let affected = tracker.get_dependent_caches("nonexistent");
351        assert_eq!(affected.len(), 0);
352    }
353
354    #[test]
355    fn test_clear() {
356        let mut tracker = DependencyTracker::new();
357
358        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
359        tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
360
361        tracker.clear();
362
363        assert_eq!(tracker.cache_count(), 0);
364        assert_eq!(tracker.view_count(), 0);
365        assert_eq!(tracker.get_dependent_caches("v_user").len(), 0);
366    }
367
368    #[test]
369    fn test_cache_count() {
370        let mut tracker = DependencyTracker::new();
371
372        assert_eq!(tracker.cache_count(), 0);
373
374        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
375        assert_eq!(tracker.cache_count(), 1);
376
377        tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
378        assert_eq!(tracker.cache_count(), 2);
379
380        tracker.remove_cache("key1");
381        assert_eq!(tracker.cache_count(), 1);
382    }
383
384    #[test]
385    fn test_view_count() {
386        let mut tracker = DependencyTracker::new();
387
388        assert_eq!(tracker.view_count(), 0);
389
390        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
391        assert_eq!(tracker.view_count(), 1);
392
393        tracker.record_access("key2".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
394        assert_eq!(tracker.view_count(), 2); // v_user and v_post
395    }
396
397    #[test]
398    fn test_get_all_views() {
399        let mut tracker = DependencyTracker::new();
400
401        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
402        tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
403
404        let views = tracker.get_all_views();
405        assert_eq!(views.len(), 2);
406        assert!(views.contains(&"v_user".to_string()));
407        assert!(views.contains(&"v_post".to_string()));
408    }
409
410    #[test]
411    fn test_update_access_overwrites() {
412        let mut tracker = DependencyTracker::new();
413
414        // Initial access
415        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
416
417        // Update to different views
418        tracker.record_access("key1".to_string(), vec!["v_post".to_string()]);
419
420        // Should only be in v_post now
421        assert_eq!(tracker.get_dependent_caches("v_user").len(), 0);
422        assert_eq!(tracker.get_dependent_caches("v_post").len(), 1);
423    }
424
425    #[test]
426    fn test_bidirectional_consistency() {
427        let mut tracker = DependencyTracker::new();
428
429        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
430        tracker.record_access("key2".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
431
432        // Forward: 2 cache entries
433        assert_eq!(tracker.cache_count(), 2);
434
435        // Reverse: v_user has 2 dependencies, v_post has 1
436        assert_eq!(tracker.get_dependent_caches("v_user").len(), 2);
437        assert_eq!(tracker.get_dependent_caches("v_post").len(), 1);
438
439        // Remove one
440        tracker.remove_cache("key1");
441
442        // Consistency check
443        assert_eq!(tracker.cache_count(), 1);
444        assert_eq!(tracker.get_dependent_caches("v_user").len(), 1);
445        assert_eq!(tracker.get_dependent_caches("v_post").len(), 1);
446    }
447}