1mod config;
4mod operations;
5mod preloader;
6mod stats;
7
8pub use config::{CacheConfig, CacheDependency, InvalidationStrategy};
9pub use preloader::{keys, DefaultPreloader};
10pub use stats::{CachePreloader, CacheStats, CachedData};
11
12use crate::models::{Area, Project, Task};
13use moka::future::Cache;
14use parking_lot::RwLock;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18pub struct ThingsCache {
20 tasks: Cache<String, CachedData<Vec<Task>>>,
22 projects: Cache<String, CachedData<Vec<Project>>>,
24 areas: Cache<String, CachedData<Vec<Area>>>,
26 search_results: Cache<String, CachedData<Vec<Task>>>,
28 stats: Arc<RwLock<CacheStats>>,
30 config: CacheConfig,
32 warming_entries: Arc<RwLock<HashMap<String, u32>>>,
34 preloader: Arc<RwLock<Option<Arc<dyn CachePreloader>>>>,
37 warming_task: Option<tokio::task::JoinHandle<()>>,
39}
40
41#[cfg(test)]
42mod tests {
43 use super::*;
44 use crate::test_utils::{create_mock_areas, create_mock_projects, create_mock_tasks};
45 use std::time::Duration;
46
47 #[test]
48 fn test_cache_config_default() {
49 let config = CacheConfig::default();
50
51 assert_eq!(config.max_capacity, 1000);
52 assert_eq!(config.ttl, Duration::from_secs(300));
53 assert_eq!(config.tti, Duration::from_secs(60));
54 }
55
56 #[test]
57 fn test_cache_config_custom() {
58 let config = CacheConfig {
59 max_capacity: 500,
60 ttl: Duration::from_secs(600),
61 tti: Duration::from_secs(120),
62 invalidation_strategy: InvalidationStrategy::Hybrid,
63 enable_cache_warming: true,
64 warming_interval: Duration::from_secs(60),
65 max_warming_entries: 50,
66 };
67
68 assert_eq!(config.max_capacity, 500);
69 assert_eq!(config.ttl, Duration::from_secs(600));
70 assert_eq!(config.tti, Duration::from_secs(120));
71 }
72
73 #[test]
74 fn test_cached_data_creation() {
75 let data = vec![1, 2, 3];
76 let ttl = Duration::from_secs(60);
77 let cached = CachedData::new(data.clone(), ttl);
78
79 assert_eq!(cached.data, data);
80 assert!(cached.cached_at <= chrono::Utc::now());
81 assert!(cached.expires_at > cached.cached_at);
82 assert!(!cached.is_expired());
83 }
84
85 #[test]
86 fn test_cached_data_expiration() {
87 let data = vec![1, 2, 3];
88 let ttl = Duration::from_millis(1);
89 let cached = CachedData::new(data, ttl);
90
91 assert!(!cached.is_expired());
93
94 std::thread::sleep(Duration::from_millis(10));
96 }
98
99 #[test]
100 fn test_cached_data_serialization() {
101 let data = vec![1, 2, 3];
102 let ttl = Duration::from_secs(60);
103 let cached = CachedData::new(data, ttl);
104
105 let json = serde_json::to_string(&cached).unwrap();
107 assert!(json.contains("data"));
108 assert!(json.contains("cached_at"));
109 assert!(json.contains("expires_at"));
110
111 let deserialized: CachedData<Vec<i32>> = serde_json::from_str(&json).unwrap();
113 assert_eq!(deserialized.data, cached.data);
114 }
115
116 #[test]
117 fn test_cache_stats_default() {
118 let stats = CacheStats::default();
119
120 assert_eq!(stats.hits, 0);
121 assert_eq!(stats.misses, 0);
122 assert_eq!(stats.entries, 0);
123 assert!((stats.hit_rate - 0.0).abs() < f64::EPSILON);
124 }
125
126 #[test]
127 fn test_cache_stats_calculation() {
128 let mut stats = CacheStats {
129 hits: 8,
130 misses: 2,
131 entries: 5,
132 hit_rate: 0.0,
133 ..Default::default()
134 };
135
136 stats.calculate_hit_rate();
137 assert!((stats.hit_rate - 0.8).abs() < f64::EPSILON);
138 }
139
140 #[test]
141 fn test_cache_stats_zero_total() {
142 let mut stats = CacheStats {
143 hits: 0,
144 misses: 0,
145 entries: 0,
146 hit_rate: 0.0,
147 ..Default::default()
148 };
149
150 stats.calculate_hit_rate();
151 assert!((stats.hit_rate - 0.0).abs() < f64::EPSILON);
152 }
153
154 #[test]
155 fn test_cache_stats_serialization() {
156 let stats = CacheStats {
157 hits: 10,
158 misses: 5,
159 entries: 3,
160 hit_rate: 0.67,
161 ..Default::default()
162 };
163
164 let json = serde_json::to_string(&stats).unwrap();
166 assert!(json.contains("hits"));
167 assert!(json.contains("misses"));
168 assert!(json.contains("entries"));
169 assert!(json.contains("hit_rate"));
170
171 let deserialized: CacheStats = serde_json::from_str(&json).unwrap();
173 assert_eq!(deserialized.hits, stats.hits);
174 assert_eq!(deserialized.misses, stats.misses);
175 assert_eq!(deserialized.entries, stats.entries);
176 assert!((deserialized.hit_rate - stats.hit_rate).abs() < f64::EPSILON);
177 }
178
179 #[test]
180 fn test_cache_stats_clone() {
181 let stats = CacheStats {
182 hits: 5,
183 misses: 3,
184 entries: 2,
185 hit_rate: 0.625,
186 ..Default::default()
187 };
188
189 let cloned = stats.clone();
190 assert_eq!(cloned.hits, stats.hits);
191 assert_eq!(cloned.misses, stats.misses);
192 assert_eq!(cloned.entries, stats.entries);
193 assert!((cloned.hit_rate - stats.hit_rate).abs() < f64::EPSILON);
194 }
195
196 #[test]
197 fn test_cache_stats_debug() {
198 let stats = CacheStats {
199 hits: 1,
200 misses: 1,
201 entries: 1,
202 hit_rate: 0.5,
203 ..Default::default()
204 };
205
206 let debug_str = format!("{stats:?}");
207 assert!(debug_str.contains("CacheStats"));
208 assert!(debug_str.contains("hits"));
209 assert!(debug_str.contains("misses"));
210 }
211
212 #[tokio::test]
213 async fn test_cache_new() {
214 let config = CacheConfig::default();
215 let _cache = ThingsCache::new(&config);
216
217 }
220
221 #[tokio::test]
222 async fn test_cache_new_default() {
223 let _cache = ThingsCache::new_default();
224
225 }
228
229 #[tokio::test]
230 async fn test_cache_basic_operations() {
231 let cache = ThingsCache::new_default();
232
233 let result = cache.get_tasks("test", || async { Ok(vec![]) }).await;
235 assert!(result.is_ok());
236
237 let result = cache.get_tasks("test", || async { Ok(vec![]) }).await;
239 assert!(result.is_ok());
240
241 let stats = cache.get_stats();
242 assert_eq!(stats.hits, 1);
243 assert_eq!(stats.misses, 1);
244 }
245
246 #[tokio::test]
247 async fn test_cache_tasks_with_data() {
248 let cache = ThingsCache::new_default();
249 let mock_tasks = create_mock_tasks();
250
251 let result = cache
253 .get_tasks("tasks", || async { Ok(mock_tasks.clone()) })
254 .await;
255 assert!(result.is_ok());
256 assert_eq!(result.unwrap().len(), mock_tasks.len());
257
258 let result = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
260 assert!(result.is_ok());
261 assert_eq!(result.unwrap().len(), mock_tasks.len());
262
263 let stats = cache.get_stats();
264 assert_eq!(stats.hits, 1);
265 assert_eq!(stats.misses, 1);
266 }
267
268 #[tokio::test]
269 async fn test_cache_projects() {
270 let cache = ThingsCache::new_default();
271 let mock_projects = create_mock_projects();
272
273 let result = cache
275 .get_projects("projects", || async { Ok(mock_projects.clone()) })
276 .await;
277 assert!(result.is_ok());
278
279 let result = cache
281 .get_projects("projects", || async { Ok(vec![]) })
282 .await;
283 assert!(result.is_ok());
284
285 let stats = cache.get_stats();
286 assert_eq!(stats.hits, 1);
287 assert_eq!(stats.misses, 1);
288 }
289
290 #[tokio::test]
291 async fn test_cache_areas() {
292 let cache = ThingsCache::new_default();
293 let mock_areas = create_mock_areas();
294
295 let result = cache
297 .get_areas("areas", || async { Ok(mock_areas.clone()) })
298 .await;
299 assert!(result.is_ok());
300
301 let result = cache.get_areas("areas", || async { Ok(vec![]) }).await;
303 assert!(result.is_ok());
304
305 let stats = cache.get_stats();
306 assert_eq!(stats.hits, 1);
307 assert_eq!(stats.misses, 1);
308 }
309
310 #[tokio::test]
311 async fn test_cache_search_results() {
312 let cache = ThingsCache::new_default();
313 let mock_tasks = create_mock_tasks();
314
315 let result = cache
317 .get_search_results("search:test", || async { Ok(mock_tasks.clone()) })
318 .await;
319 assert!(result.is_ok());
320
321 let result = cache
323 .get_search_results("search:test", || async { Ok(vec![]) })
324 .await;
325 assert!(result.is_ok());
326
327 let stats = cache.get_stats();
328 assert_eq!(stats.hits, 1);
329 assert_eq!(stats.misses, 1);
330 }
331
332 #[tokio::test]
333 async fn test_cache_fetcher_error() {
334 let cache = ThingsCache::new_default();
335
336 let result = cache
338 .get_tasks("error", || async { Err(anyhow::anyhow!("Test error")) })
339 .await;
340
341 assert!(result.is_err());
342 assert!(result.unwrap_err().to_string().contains("Test error"));
343
344 let stats = cache.get_stats();
345 assert_eq!(stats.hits, 0);
346 assert_eq!(stats.misses, 1);
347 }
348
349 #[tokio::test]
350 async fn test_cache_expiration() {
351 let config = CacheConfig {
352 max_capacity: 100,
353 ttl: Duration::from_millis(10),
354 tti: Duration::from_millis(5),
355 invalidation_strategy: InvalidationStrategy::Hybrid,
356 enable_cache_warming: true,
357 warming_interval: Duration::from_secs(60),
358 max_warming_entries: 50,
359 };
360 let cache = ThingsCache::new(&config);
361
362 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
364
365 tokio::time::sleep(Duration::from_millis(20)).await;
367
368 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
370
371 let stats = cache.get_stats();
372 assert_eq!(stats.misses, 2);
373 }
374
375 #[tokio::test]
376 async fn test_cache_invalidate_all() {
377 let cache = ThingsCache::new_default();
378
379 let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
381 let _ = cache
382 .get_projects("projects", || async { Ok(vec![]) })
383 .await;
384 let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
385 let _ = cache
386 .get_search_results("search", || async { Ok(vec![]) })
387 .await;
388
389 cache.invalidate_all();
391
392 let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
394 let _ = cache
395 .get_projects("projects", || async { Ok(vec![]) })
396 .await;
397 let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
398 let _ = cache
399 .get_search_results("search", || async { Ok(vec![]) })
400 .await;
401
402 let stats = cache.get_stats();
403 assert_eq!(stats.misses, 8); }
405
406 #[tokio::test]
407 async fn test_cache_invalidate_specific() {
408 let cache = ThingsCache::new_default();
409
410 let _ = cache.get_tasks("key1", || async { Ok(vec![]) }).await;
412 let _ = cache.get_tasks("key2", || async { Ok(vec![]) }).await;
413
414 cache.invalidate("key1").await;
416
417 let _ = cache.get_tasks("key1", || async { Ok(vec![]) }).await;
419 let _ = cache.get_tasks("key2", || async { Ok(vec![]) }).await;
420
421 let stats = cache.get_stats();
422 assert_eq!(stats.hits, 1); assert_eq!(stats.misses, 3); }
425
426 #[tokio::test]
427 async fn test_cache_reset_stats() {
428 let cache = ThingsCache::new_default();
429
430 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
432 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
433
434 let stats_before = cache.get_stats();
435 assert!(stats_before.hits > 0 || stats_before.misses > 0);
436
437 cache.reset_stats();
439
440 let stats_after = cache.get_stats();
441 assert_eq!(stats_after.hits, 0);
442 assert_eq!(stats_after.misses, 0);
443 assert!((stats_after.hit_rate - 0.0).abs() < f64::EPSILON);
444 }
445
446 #[test]
447 fn test_cache_keys_inbox() {
448 assert_eq!(keys::inbox(None), "inbox:all");
449 assert_eq!(keys::inbox(Some(10)), "inbox:10");
450 assert_eq!(keys::inbox(Some(0)), "inbox:0");
451 }
452
453 #[test]
454 fn test_cache_keys_today() {
455 assert_eq!(keys::today(None), "today:all");
456 assert_eq!(keys::today(Some(5)), "today:5");
457 assert_eq!(keys::today(Some(100)), "today:100");
458 }
459
460 #[test]
461 fn test_cache_keys_projects() {
462 assert_eq!(keys::projects(None), "projects:all");
463 assert_eq!(keys::projects(Some("uuid-123")), "projects:uuid-123");
464 assert_eq!(keys::projects(Some("")), "projects:");
465 }
466
467 #[test]
468 fn test_cache_keys_areas() {
469 assert_eq!(keys::areas(), "areas:all");
470 }
471
472 #[test]
473 fn test_cache_keys_search() {
474 assert_eq!(keys::search("test query", None), "search:test query:all");
475 assert_eq!(keys::search("test query", Some(10)), "search:test query:10");
476 assert_eq!(keys::search("", Some(5)), "search::5");
477 }
478
479 #[tokio::test]
480 async fn test_cache_multiple_keys() {
481 let cache = ThingsCache::new_default();
482 let mock_tasks1 = create_mock_tasks();
483 let mock_tasks2 = create_mock_tasks();
484
485 let _ = cache
487 .get_tasks("key1", || async { Ok(mock_tasks1.clone()) })
488 .await;
489 let _ = cache
490 .get_tasks("key2", || async { Ok(mock_tasks2.clone()) })
491 .await;
492
493 let result1 = cache
495 .get_tasks("key1", || async { Ok(vec![]) })
496 .await
497 .unwrap();
498 let result2 = cache
499 .get_tasks("key2", || async { Ok(vec![]) })
500 .await
501 .unwrap();
502
503 assert_eq!(result1.len(), mock_tasks1.len());
504 assert_eq!(result2.len(), mock_tasks2.len());
505
506 let stats = cache.get_stats();
507 assert_eq!(stats.hits, 2);
508 assert_eq!(stats.misses, 2);
509 }
510
511 #[tokio::test]
512 async fn test_cache_entry_count() {
513 let cache = ThingsCache::new_default();
514
515 let stats = cache.get_stats();
517 assert_eq!(stats.entries, 0);
518
519 let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
521 let _ = cache
522 .get_projects("projects", || async { Ok(vec![]) })
523 .await;
524 let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
525 let _ = cache
526 .get_search_results("search", || async { Ok(vec![]) })
527 .await;
528
529 let stats = cache.get_stats();
532 let _ = stats.entries;
534 }
535
536 #[tokio::test]
537 async fn test_cache_hit_rate_calculation() {
538 let cache = ThingsCache::new_default();
539
540 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; let stats = cache.get_stats();
546 assert_eq!(stats.hits, 2);
547 assert_eq!(stats.misses, 1);
548 assert!((stats.hit_rate - 2.0 / 3.0).abs() < 0.001);
549 }
550
551 #[test]
552 fn test_cache_dependency_matches_rules() {
553 use crate::models::ThingsId;
554 let id_a = ThingsId::new_v4();
555 let id_b = ThingsId::new_v4();
556 let dep_concrete = CacheDependency {
557 entity_type: "task".to_string(),
558 entity_id: Some(id_a.clone()),
559 invalidating_operations: vec!["task_updated".to_string()],
560 };
561 let dep_wildcard = CacheDependency {
562 entity_type: "task".to_string(),
563 entity_id: None,
564 invalidating_operations: vec!["task_updated".to_string()],
565 };
566
567 assert!(dep_concrete.matches("task", Some(&id_a)));
569 assert!(!dep_concrete.matches("task", Some(&id_b)));
570 assert!(dep_concrete.matches("task", None));
572 assert!(dep_wildcard.matches("task", Some(&id_a)));
574 assert!(!dep_concrete.matches("project", Some(&id_a)));
576
577 assert!(dep_concrete.matches_operation("task_updated"));
579 assert!(!dep_concrete.matches_operation("task_deleted"));
580 }
581
582 fn task_with_ids(
585 uuid: crate::models::ThingsId,
586 project: Option<crate::models::ThingsId>,
587 area: Option<crate::models::ThingsId>,
588 ) -> crate::models::Task {
589 let mut t = create_mock_tasks().into_iter().next().unwrap();
590 t.uuid = uuid;
591 t.project_uuid = project;
592 t.area_uuid = area;
593 t
594 }
595
596 #[tokio::test]
597 async fn test_invalidate_by_entity_selective_by_id() {
598 use crate::models::ThingsId;
599 let cache = ThingsCache::new_default();
600 let id_x = ThingsId::new_v4();
601 let id_y = ThingsId::new_v4();
602
603 let id_x2 = id_x.clone();
604 let id_y2 = id_y.clone();
605 cache
606 .get_tasks("key_x", || async {
607 Ok(vec![task_with_ids(id_x2, None, None)])
608 })
609 .await
610 .unwrap();
611 cache
612 .get_tasks("key_y", || async {
613 Ok(vec![task_with_ids(id_y2, None, None)])
614 })
615 .await
616 .unwrap();
617
618 let removed = cache.invalidate_by_entity("task", Some(&id_x)).await;
619 assert_eq!(removed, 1, "only the entry depending on id_x should evict");
620 cache.tasks.run_pending_tasks().await;
621 assert!(cache.tasks.get("key_x").await.is_none());
622 assert!(cache.tasks.get("key_y").await.is_some());
623 }
624
625 #[tokio::test]
626 async fn test_invalidate_by_entity_wildcard_id() {
627 use crate::models::ThingsId;
628 let cache = ThingsCache::new_default();
629 let id_x = ThingsId::new_v4();
630 let id_y = ThingsId::new_v4();
631
632 let id_x2 = id_x.clone();
633 let id_y2 = id_y.clone();
634 cache
635 .get_tasks("key_x", || async {
636 Ok(vec![task_with_ids(id_x2, None, None)])
637 })
638 .await
639 .unwrap();
640 cache
641 .get_tasks("key_y", || async {
642 Ok(vec![task_with_ids(id_y2, None, None)])
643 })
644 .await
645 .unwrap();
646
647 let removed = cache.invalidate_by_entity("task", None).await;
648 assert_eq!(removed, 2);
649 cache.tasks.run_pending_tasks().await;
650 assert!(cache.tasks.get("key_x").await.is_none());
651 assert!(cache.tasks.get("key_y").await.is_none());
652 }
653
654 #[tokio::test]
655 async fn test_invalidate_by_entity_leaves_unrelated_caches() {
656 use crate::models::ThingsId;
657 let cache = ThingsCache::new_default();
658 let task_id = ThingsId::new_v4();
659 let project_id = ThingsId::new_v4();
660
661 let task_id2 = task_id.clone();
662 let project_id2 = project_id.clone();
663 cache
665 .get_tasks("inbox", || async {
666 Ok(vec![task_with_ids(task_id2, Some(project_id2), None)])
667 })
668 .await
669 .unwrap();
670 let mut p = create_mock_projects().into_iter().next().unwrap();
672 p.uuid = project_id;
673 cache
674 .get_projects("projects:all", || async { Ok(vec![p]) })
675 .await
676 .unwrap();
677
678 let removed = cache.invalidate_by_entity("task", Some(&task_id)).await;
680 assert_eq!(removed, 1);
681 cache.tasks.run_pending_tasks().await;
682 cache.projects.run_pending_tasks().await;
683 assert!(cache.tasks.get("inbox").await.is_none());
684 assert!(cache.projects.get("projects:all").await.is_some());
685 }
686
687 #[tokio::test]
688 async fn test_invalidate_by_operation_selective() {
689 use crate::models::ThingsId;
690 let cache = ThingsCache::new_default();
691 let task_id = ThingsId::new_v4();
692 let area_id = ThingsId::new_v4();
693
694 let task_id2 = task_id.clone();
695 cache
697 .get_tasks("inbox", || async {
698 Ok(vec![task_with_ids(task_id2, None, None)])
699 })
700 .await
701 .unwrap();
702 let mut a = create_mock_areas().into_iter().next().unwrap();
704 a.uuid = area_id;
705 cache
706 .get_areas("areas:all", || async { Ok(vec![a]) })
707 .await
708 .unwrap();
709
710 let removed = cache.invalidate_by_operation("task_updated").await;
711 assert_eq!(removed, 1);
712 cache.tasks.run_pending_tasks().await;
713 cache.areas.run_pending_tasks().await;
714 assert!(cache.tasks.get("inbox").await.is_none());
715 assert!(cache.areas.get("areas:all").await.is_some());
716 }
717
718 struct RecordingPreloader {
723 predictions: Arc<RwLock<Vec<(String, u32)>>>,
724 seen_predict: Arc<RwLock<Vec<String>>>,
725 seen_warm: Arc<RwLock<Vec<String>>>,
726 }
727
728 impl RecordingPreloader {
729 fn new(predictions: Vec<(String, u32)>) -> Self {
730 Self {
731 predictions: Arc::new(RwLock::new(predictions)),
732 seen_predict: Arc::new(RwLock::new(Vec::new())),
733 seen_warm: Arc::new(RwLock::new(Vec::new())),
734 }
735 }
736 }
737
738 impl CachePreloader for RecordingPreloader {
739 fn predict(&self, accessed_key: &str) -> Vec<(String, u32)> {
740 self.seen_predict.write().push(accessed_key.to_string());
741 self.predictions.read().clone()
742 }
743 fn warm(&self, key: &str) {
744 self.seen_warm.write().push(key.to_string());
745 }
746 }
747
748 #[tokio::test]
749 async fn test_default_preloader_predict_rules() {
750 let f = tempfile::NamedTempFile::new().unwrap();
754 crate::test_utils::create_test_database(f.path())
755 .await
756 .unwrap();
757 let db = Arc::new(crate::ThingsDatabase::new(f.path()).await.unwrap());
758 let cache = Arc::new(ThingsCache::new_default());
759 let pre = DefaultPreloader::new(&cache, db);
760
761 assert_eq!(pre.predict("inbox:all"), vec![("today:all".to_string(), 8)]);
762 assert_eq!(
763 pre.predict("today:all"),
764 vec![("inbox:all".to_string(), 10)]
765 );
766 assert_eq!(
767 pre.predict("areas:all"),
768 vec![("projects:all".to_string(), 7)]
769 );
770 assert!(pre.predict("search:foo").is_empty());
771 }
772
773 #[tokio::test]
774 async fn test_predict_fires_on_get_tasks_miss_and_hit() {
775 let cache = ThingsCache::new_default();
776 let pre = Arc::new(RecordingPreloader::new(vec![]));
777 cache.set_preloader(pre.clone());
778
779 cache
780 .get_tasks("inbox:all", || async { Ok(vec![]) })
781 .await
782 .unwrap();
783 cache
784 .get_tasks("inbox:all", || async { Ok(vec![]) })
785 .await
786 .unwrap();
787
788 let seen = pre.seen_predict.read().clone();
789 assert_eq!(seen, vec!["inbox:all".to_string(), "inbox:all".to_string()]);
790 }
791
792 #[tokio::test]
793 async fn test_predict_enqueues_warming() {
794 let cache = ThingsCache::new_default();
795 let pre = Arc::new(RecordingPreloader::new(vec![("today:all".to_string(), 5)]));
796 cache.set_preloader(pre);
797
798 cache
799 .get_tasks("inbox:all", || async { Ok(vec![]) })
800 .await
801 .unwrap();
802
803 let entries = cache.warming_entries.read();
804 assert_eq!(entries.get("today:all"), Some(&5));
805 }
806
807 #[tokio::test]
808 async fn test_no_preloader_is_noop() {
809 let config = CacheConfig {
812 warming_interval: Duration::from_millis(20),
813 ..Default::default()
814 };
815 let cache = ThingsCache::new(&config);
816 cache
817 .get_tasks("inbox:all", || async { Ok(vec![]) })
818 .await
819 .unwrap();
820 tokio::time::sleep(Duration::from_millis(80)).await;
822 let stats = cache.get_stats();
823 assert_eq!(stats.warmed_keys, 0);
824 assert_eq!(stats.warming_runs, 0);
825 }
826
827 #[tokio::test]
828 async fn test_warming_loop_invokes_warm() {
829 let config = CacheConfig {
830 warming_interval: Duration::from_millis(20),
831 max_warming_entries: 10,
832 ..Default::default()
833 };
834 let cache = ThingsCache::new(&config);
835
836 let pre = Arc::new(RecordingPreloader::new(vec![]));
837 cache.set_preloader(pre.clone());
838
839 cache.add_to_warming("inbox:all".to_string(), 10);
840 cache.add_to_warming("today:all".to_string(), 8);
841
842 tokio::time::sleep(Duration::from_millis(100)).await;
844
845 let warmed = pre.seen_warm.read().clone();
846 assert!(warmed.contains(&"inbox:all".to_string()));
847 assert!(warmed.contains(&"today:all".to_string()));
848
849 assert!(cache.warming_entries.read().is_empty());
851
852 let stats = cache.get_stats();
854 assert!(stats.warming_runs >= 1);
855 assert!(stats.warmed_keys >= 2);
856 }
857
858 #[tokio::test]
859 async fn test_clear_preloader_disables_predict() {
860 let cache = ThingsCache::new_default();
861 let pre = Arc::new(RecordingPreloader::new(vec![("today:all".to_string(), 5)]));
862 cache.set_preloader(pre.clone());
863 cache
864 .get_tasks("inbox:all", || async { Ok(vec![]) })
865 .await
866 .unwrap();
867 assert_eq!(pre.seen_predict.read().len(), 1);
868
869 cache.clear_preloader();
870 cache
871 .get_tasks("inbox:all", || async { Ok(vec![]) })
872 .await
873 .unwrap();
874 assert_eq!(pre.seen_predict.read().len(), 1);
876 }
877
878 #[tokio::test]
879 async fn test_default_preloader_warms_via_db() {
880 let f = tempfile::NamedTempFile::new().unwrap();
883 crate::test_utils::create_test_database(f.path())
884 .await
885 .unwrap();
886 let db = Arc::new(crate::ThingsDatabase::new(f.path()).await.unwrap());
887
888 let config = CacheConfig {
889 warming_interval: Duration::from_millis(20),
890 ..Default::default()
891 };
892 let cache = Arc::new(ThingsCache::new(&config));
893 cache.set_preloader(DefaultPreloader::new(&cache, Arc::clone(&db)));
894
895 cache
897 .get_tasks("inbox:all", || async {
898 db.get_inbox(None).await.map_err(anyhow::Error::from)
899 })
900 .await
901 .unwrap();
902
903 tokio::time::sleep(Duration::from_millis(150)).await;
906
907 let result = cache
910 .get_tasks("today:all", || async {
911 panic!("today:all should be served from warmed cache, not fetched")
912 })
913 .await
914 .unwrap();
915 let expected = db.get_today(None).await.unwrap();
917 assert_eq!(result.len(), expected.len());
918 }
919
920 #[tokio::test]
921 async fn test_default_preloader_weak_ref_breaks_cycle() {
922 let f = tempfile::NamedTempFile::new().unwrap();
924 crate::test_utils::create_test_database(f.path())
925 .await
926 .unwrap();
927 let db = Arc::new(crate::ThingsDatabase::new(f.path()).await.unwrap());
928
929 let cache = Arc::new(ThingsCache::new_default());
930 let preloader = DefaultPreloader::new(&cache, db);
931 let preloader_dyn: Arc<dyn CachePreloader> = preloader.clone();
932
933 drop(cache);
934
935 preloader_dyn.warm("inbox:all");
937 tokio::time::sleep(Duration::from_millis(20)).await;
940 }
941}