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    pub fn record_access(&mut self, cache_key: String, views: Vec<String>) {
96        // If updating existing entry, remove old reverse mappings first
97        if let Some(old_views) = self.cache_to_views.get(&cache_key) {
98            for old_view in old_views {
99                if let Some(caches) = self.view_to_caches.get_mut(old_view) {
100                    caches.remove(&cache_key);
101                    // Clean up empty sets
102                    if caches.is_empty() {
103                        self.view_to_caches.remove(old_view);
104                    }
105                }
106            }
107        }
108
109        // Store cache → views mapping (forward)
110        self.cache_to_views.insert(cache_key.clone(), views.clone());
111
112        // Update view → caches reverse mapping
113        for view in views {
114            self.view_to_caches
115                .entry(view)
116                .or_insert_with(HashSet::new)
117                .insert(cache_key.clone());
118        }
119    }
120
121    /// Get all cache keys that access a view.
122    ///
123    /// Used during invalidation to find affected cache entries.
124    ///
125    /// # Arguments
126    ///
127    /// * `view` - View/table name
128    ///
129    /// # Returns
130    ///
131    /// List of cache keys that read from this view
132    ///
133    /// # Example
134    ///
135    /// ```
136    /// use fraiseql_core::cache::DependencyTracker;
137    ///
138    /// let mut tracker = DependencyTracker::new();
139    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
140    ///
141    /// let affected = tracker.get_dependent_caches("v_user");
142    /// assert_eq!(affected.len(), 1);
143    /// assert!(affected.contains(&"key1".to_string()));
144    /// ```
145    #[must_use]
146    pub fn get_dependent_caches(&self, view: &str) -> Vec<String> {
147        self.view_to_caches
148            .get(view)
149            .map(|set| set.iter().cloned().collect())
150            .unwrap_or_default()
151    }
152
153    /// Remove a cache entry from tracking.
154    ///
155    /// Called when cache entry is evicted (LRU) or invalidated.
156    /// Cleans up both forward and reverse mappings.
157    ///
158    /// # Arguments
159    ///
160    /// * `cache_key` - Cache key to remove
161    ///
162    /// # Example
163    ///
164    /// ```
165    /// use fraiseql_core::cache::DependencyTracker;
166    ///
167    /// let mut tracker = DependencyTracker::new();
168    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
169    ///
170    /// tracker.remove_cache("key1");
171    ///
172    /// let affected = tracker.get_dependent_caches("v_user");
173    /// assert_eq!(affected.len(), 0);
174    /// ```
175    pub fn remove_cache(&mut self, cache_key: &str) {
176        if let Some(views) = self.cache_to_views.remove(cache_key) {
177            // Remove from view → caches mappings
178            for view in views {
179                if let Some(caches) = self.view_to_caches.get_mut(&view) {
180                    caches.remove(cache_key);
181                    // Clean up empty sets
182                    if caches.is_empty() {
183                        self.view_to_caches.remove(&view);
184                    }
185                }
186            }
187        }
188    }
189
190    /// Clear all tracking data.
191    ///
192    /// Used for testing and cache flush.
193    ///
194    /// # Example
195    ///
196    /// ```
197    /// use fraiseql_core::cache::DependencyTracker;
198    ///
199    /// let mut tracker = DependencyTracker::new();
200    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
201    ///
202    /// tracker.clear();
203    ///
204    /// assert_eq!(tracker.cache_count(), 0);
205    /// ```
206    pub fn clear(&mut self) {
207        self.cache_to_views.clear();
208        self.view_to_caches.clear();
209    }
210
211    /// Get total number of tracked cache entries.
212    ///
213    /// # Example
214    ///
215    /// ```
216    /// use fraiseql_core::cache::DependencyTracker;
217    ///
218    /// let mut tracker = DependencyTracker::new();
219    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
220    /// tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
221    ///
222    /// assert_eq!(tracker.cache_count(), 2);
223    /// ```
224    #[must_use]
225    pub fn cache_count(&self) -> usize {
226        self.cache_to_views.len()
227    }
228
229    /// Get total number of tracked views.
230    ///
231    /// # Example
232    ///
233    /// ```
234    /// use fraiseql_core::cache::DependencyTracker;
235    ///
236    /// let mut tracker = DependencyTracker::new();
237    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
238    /// tracker.record_access("key2".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
239    ///
240    /// assert_eq!(tracker.view_count(), 2);  // v_user and v_post
241    /// ```
242    #[must_use]
243    pub fn view_count(&self) -> usize {
244        self.view_to_caches.len()
245    }
246
247    /// Get all tracked views.
248    ///
249    /// Used for debugging and monitoring.
250    ///
251    /// # Example
252    ///
253    /// ```
254    /// use fraiseql_core::cache::DependencyTracker;
255    ///
256    /// let mut tracker = DependencyTracker::new();
257    /// tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
258    ///
259    /// let views = tracker.get_all_views();
260    /// assert!(views.contains(&"v_user".to_string()));
261    /// ```
262    #[must_use]
263    pub fn get_all_views(&self) -> Vec<String> {
264        self.view_to_caches.keys().cloned().collect()
265    }
266}
267
268impl Default for DependencyTracker {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_record_and_get_dependency() {
280        let mut tracker = DependencyTracker::new();
281
282        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
283
284        let affected = tracker.get_dependent_caches("v_user");
285        assert_eq!(affected.len(), 1);
286        assert!(affected.contains(&"key1".to_string()));
287    }
288
289    #[test]
290    fn test_multiple_caches_same_view() {
291        let mut tracker = DependencyTracker::new();
292
293        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
294        tracker.record_access("key2".to_string(), vec!["v_user".to_string()]);
295
296        let affected = tracker.get_dependent_caches("v_user");
297        assert_eq!(affected.len(), 2);
298        assert!(affected.contains(&"key1".to_string()));
299        assert!(affected.contains(&"key2".to_string()));
300    }
301
302    #[test]
303    fn test_cache_accesses_multiple_views() {
304        let mut tracker = DependencyTracker::new();
305
306        tracker.record_access("key1".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
307
308        // Should appear in both view mappings
309        let user_caches = tracker.get_dependent_caches("v_user");
310        let post_caches = tracker.get_dependent_caches("v_post");
311
312        assert!(user_caches.contains(&"key1".to_string()));
313        assert!(post_caches.contains(&"key1".to_string()));
314    }
315
316    #[test]
317    fn test_remove_cache() {
318        let mut tracker = DependencyTracker::new();
319
320        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
321        tracker.remove_cache("key1");
322
323        let affected = tracker.get_dependent_caches("v_user");
324        assert_eq!(affected.len(), 0);
325    }
326
327    #[test]
328    fn test_remove_cache_with_multiple_views() {
329        let mut tracker = DependencyTracker::new();
330
331        tracker.record_access("key1".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
332
333        tracker.remove_cache("key1");
334
335        // Should be removed from both mappings
336        assert_eq!(tracker.get_dependent_caches("v_user").len(), 0);
337        assert_eq!(tracker.get_dependent_caches("v_post").len(), 0);
338    }
339
340    #[test]
341    fn test_remove_nonexistent_cache() {
342        let mut tracker = DependencyTracker::new();
343
344        // Should not panic
345        tracker.remove_cache("nonexistent");
346    }
347
348    #[test]
349    fn test_get_nonexistent_view() {
350        let tracker = DependencyTracker::new();
351
352        let affected = tracker.get_dependent_caches("nonexistent");
353        assert_eq!(affected.len(), 0);
354    }
355
356    #[test]
357    fn test_clear() {
358        let mut tracker = DependencyTracker::new();
359
360        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
361        tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
362
363        tracker.clear();
364
365        assert_eq!(tracker.cache_count(), 0);
366        assert_eq!(tracker.view_count(), 0);
367        assert_eq!(tracker.get_dependent_caches("v_user").len(), 0);
368    }
369
370    #[test]
371    fn test_cache_count() {
372        let mut tracker = DependencyTracker::new();
373
374        assert_eq!(tracker.cache_count(), 0);
375
376        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
377        assert_eq!(tracker.cache_count(), 1);
378
379        tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
380        assert_eq!(tracker.cache_count(), 2);
381
382        tracker.remove_cache("key1");
383        assert_eq!(tracker.cache_count(), 1);
384    }
385
386    #[test]
387    fn test_view_count() {
388        let mut tracker = DependencyTracker::new();
389
390        assert_eq!(tracker.view_count(), 0);
391
392        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
393        assert_eq!(tracker.view_count(), 1);
394
395        tracker.record_access("key2".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
396        assert_eq!(tracker.view_count(), 2); // v_user and v_post
397    }
398
399    #[test]
400    fn test_get_all_views() {
401        let mut tracker = DependencyTracker::new();
402
403        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
404        tracker.record_access("key2".to_string(), vec!["v_post".to_string()]);
405
406        let views = tracker.get_all_views();
407        assert_eq!(views.len(), 2);
408        assert!(views.contains(&"v_user".to_string()));
409        assert!(views.contains(&"v_post".to_string()));
410    }
411
412    #[test]
413    fn test_update_access_overwrites() {
414        let mut tracker = DependencyTracker::new();
415
416        // Initial access
417        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
418
419        // Update to different views
420        tracker.record_access("key1".to_string(), vec!["v_post".to_string()]);
421
422        // Should only be in v_post now
423        assert_eq!(tracker.get_dependent_caches("v_user").len(), 0);
424        assert_eq!(tracker.get_dependent_caches("v_post").len(), 1);
425    }
426
427    #[test]
428    fn test_bidirectional_consistency() {
429        let mut tracker = DependencyTracker::new();
430
431        tracker.record_access("key1".to_string(), vec!["v_user".to_string()]);
432        tracker.record_access("key2".to_string(), vec!["v_user".to_string(), "v_post".to_string()]);
433
434        // Forward: 2 cache entries
435        assert_eq!(tracker.cache_count(), 2);
436
437        // Reverse: v_user has 2 dependencies, v_post has 1
438        assert_eq!(tracker.get_dependent_caches("v_user").len(), 2);
439        assert_eq!(tracker.get_dependent_caches("v_post").len(), 1);
440
441        // Remove one
442        tracker.remove_cache("key1");
443
444        // Consistency check
445        assert_eq!(tracker.cache_count(), 1);
446        assert_eq!(tracker.get_dependent_caches("v_user").len(), 1);
447        assert_eq!(tracker.get_dependent_caches("v_post").len(), 1);
448    }
449}