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}