Skip to main content

things3_core/cache/
preloader.rs

1use anyhow::Result;
2use std::sync::Arc;
3
4use super::stats::CachePreloader;
5use super::ThingsCache;
6
7/// Default [`CachePreloader`] with a small set of hardcoded heuristics over
8/// the existing top-level cache keys.
9///
10/// Holds a [`Weak`] reference to the cache to avoid the obvious
11/// `Arc<ThingsCache>` ↔ `Arc<dyn CachePreloader>` reference cycle. Once the
12/// last strong reference to the cache is dropped, [`CachePreloader::warm`]
13/// becomes a no-op.
14///
15/// Heuristics:
16/// - Accessing `inbox:all` predicts `today:all` (priority 8).
17/// - Accessing `today:all` predicts `inbox:all` (priority 10).
18/// - Accessing `areas:all` predicts `projects:all` (priority 7).
19///
20/// Other keys produce no predictions. Future preloaders (per-project tasks,
21/// search-history-driven) plug in via the same trait.
22///
23/// # Warm-loop behaviour
24///
25/// The `inbox:all` ↔ `today:all` pair is mutually predictive, which would
26/// ordinarily create a perpetual warming loop. [`ThingsCache::notify_preloader`]
27/// guards against this: a predicted key is only enqueued when it is *not*
28/// already present in the cache. Once both keys are warm, no further
29/// enqueuing occurs until one of them expires or is invalidated.
30pub struct DefaultPreloader {
31    cache: std::sync::Weak<ThingsCache>,
32    db: Arc<crate::database::ThingsDatabase>,
33}
34
35impl DefaultPreloader {
36    /// Construct a preloader that holds a [`Weak`] handle to `cache` and a
37    /// strong handle to `db`. Wrap in [`Arc`] before registering with
38    /// [`ThingsCache::set_preloader`].
39    #[must_use]
40    pub fn new(cache: &Arc<ThingsCache>, db: Arc<crate::database::ThingsDatabase>) -> Arc<Self> {
41        Arc::new(Self {
42            cache: Arc::downgrade(cache),
43            db,
44        })
45    }
46}
47
48impl CachePreloader for DefaultPreloader {
49    fn predict(&self, accessed_key: &str) -> Vec<(String, u32)> {
50        match accessed_key {
51            "inbox:all" => vec![("today:all".to_string(), 8)],
52            "today:all" => vec![("inbox:all".to_string(), 10)],
53            "areas:all" => vec![("projects:all".to_string(), 7)],
54            _ => vec![],
55        }
56    }
57
58    fn warm(&self, key: &str) {
59        let Some(cache) = self.cache.upgrade() else {
60            return;
61        };
62        let db = Arc::clone(&self.db);
63        let key = key.to_string();
64        tokio::spawn(async move {
65            let result: Result<()> = match key.as_str() {
66                "inbox:all" => cache
67                    .get_tasks(&key, || async {
68                        db.get_inbox(None).await.map_err(anyhow::Error::from)
69                    })
70                    .await
71                    .map(|_| ()),
72                "today:all" => cache
73                    .get_tasks(&key, || async {
74                        db.get_today(None).await.map_err(anyhow::Error::from)
75                    })
76                    .await
77                    .map(|_| ()),
78                "areas:all" => cache
79                    .get_areas(&key, || async {
80                        db.get_areas().await.map_err(anyhow::Error::from)
81                    })
82                    .await
83                    .map(|_| ()),
84                "projects:all" => cache
85                    .get_projects(&key, || async {
86                        db.get_projects(None).await.map_err(anyhow::Error::from)
87                    })
88                    .await
89                    .map(|_| ()),
90                _ => Ok(()),
91            };
92            if let Err(e) = result {
93                tracing::warn!("DefaultPreloader::warm({key}) failed: {e}");
94            }
95        });
96    }
97}
98
99/// Cache key generators
100pub mod keys {
101    /// Generate cache key for inbox tasks
102    #[must_use]
103    pub fn inbox(limit: Option<usize>) -> String {
104        format!(
105            "inbox:{}",
106            limit.map_or("all".to_string(), |l| l.to_string())
107        )
108    }
109
110    /// Generate cache key for today's tasks
111    #[must_use]
112    pub fn today(limit: Option<usize>) -> String {
113        format!(
114            "today:{}",
115            limit.map_or("all".to_string(), |l| l.to_string())
116        )
117    }
118
119    /// Generate cache key for projects
120    #[must_use]
121    pub fn projects(area_uuid: Option<&str>) -> String {
122        format!("projects:{}", area_uuid.unwrap_or("all"))
123    }
124
125    /// Generate cache key for areas
126    #[must_use]
127    pub fn areas() -> String {
128        "areas:all".to_string()
129    }
130
131    /// Generate cache key for search results
132    #[must_use]
133    pub fn search(query: &str, limit: Option<usize>) -> String {
134        format!(
135            "search:{}:{}",
136            query,
137            limit.map_or("all".to_string(), |l| l.to_string())
138        )
139    }
140}