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}