Skip to main content

facto_core/registries/
cache.rs

1//! TTL-based cache decorator for [`Registry`] implementations.
2//!
3//! `CachedRegistry<R>` wraps any concrete `Registry` and caches successful
4//! responses for each method, keyed by input arguments. TTLs are taken from
5//! [`CacheTtlConfig`]. Errors are never cached -- the next call retries.
6//!
7//! Used in [`super::manager::RegistryManager::new`] to wire configured TTLs
8//! to actual registry calls (`get_package`, `get_versions`, `search`).
9use std::future::Future;
10use std::hash::Hash;
11use std::pin::Pin;
12use std::sync::Arc;
13use std::time::Duration;
14
15use dashmap::DashMap;
16use tokio::time::Instant;
17
18use crate::config::CacheTtlConfig;
19use crate::models::*;
20use crate::registries::{Registry, RegistryResult};
21
22type CacheMap<K, V> = Arc<DashMap<K, (V, Instant)>>;
23
24pub struct CachedRegistry<R: Registry> {
25    inner: R,
26    get_package_cache: CacheMap<String, PackageInfo>,
27    get_versions_cache: CacheMap<String, Vec<VersionInfo>>,
28    search_cache: CacheMap<(String, usize), Vec<SearchResult>>,
29    ttl: CacheTtlConfig,
30}
31
32impl<R: Registry> CachedRegistry<R> {
33    pub fn new(inner: R, ttl: CacheTtlConfig) -> Self {
34        Self {
35            inner,
36            get_package_cache: Arc::new(DashMap::new()),
37            get_versions_cache: Arc::new(DashMap::new()),
38            search_cache: Arc::new(DashMap::new()),
39            ttl,
40        }
41    }
42}
43
44fn get_fresh<K, V>(cache: &DashMap<K, (V, Instant)>, key: &K, ttl: Duration) -> Option<V>
45where
46    K: Hash + Eq,
47    V: Clone,
48{
49    cache
50        .get(key)
51        .filter(|e| e.1.elapsed() < ttl)
52        .map(|e| e.0.clone())
53}
54
55impl<R: Registry + 'static> Registry for CachedRegistry<R> {
56    fn id(&self) -> &str {
57        self.inner.id()
58    }
59
60    fn display_name(&self) -> &str {
61        self.inner.display_name()
62    }
63
64    fn supports_latest_version(&self) -> bool {
65        self.inner.supports_latest_version()
66    }
67
68    fn get_package<'a>(
69        &'a self,
70        name: &'a str,
71    ) -> Pin<Box<dyn Future<Output = RegistryResult<PackageInfo>> + Send + 'a>> {
72        Box::pin(async move {
73            let ttl = Duration::from_secs(self.ttl.package_secs);
74            let key = name.to_string();
75            if let Some(v) = get_fresh(&self.get_package_cache, &key, ttl) {
76                return Ok(v);
77            }
78            let v = self.inner.get_package(name).await?;
79            self.get_package_cache
80                .insert(key, (v.clone(), Instant::now()));
81            Ok(v)
82        })
83    }
84
85    fn get_versions<'a>(
86        &'a self,
87        name: &'a str,
88    ) -> Pin<Box<dyn Future<Output = RegistryResult<Vec<VersionInfo>>> + Send + 'a>> {
89        Box::pin(async move {
90            let ttl = Duration::from_secs(self.ttl.package_secs);
91            let key = name.to_string();
92            if let Some(v) = get_fresh(&self.get_versions_cache, &key, ttl) {
93                return Ok(v);
94            }
95            let v = self.inner.get_versions(name).await?;
96            self.get_versions_cache
97                .insert(key, (v.clone(), Instant::now()));
98            Ok(v)
99        })
100    }
101
102    fn search<'a>(
103        &'a self,
104        query: &'a str,
105        limit: usize,
106    ) -> Pin<Box<dyn Future<Output = RegistryResult<Vec<SearchResult>>> + Send + 'a>> {
107        Box::pin(async move {
108            let ttl = Duration::from_secs(self.ttl.search_secs);
109            let key = (query.to_string(), limit);
110            if let Some(v) = get_fresh(&self.search_cache, &key, ttl) {
111                return Ok(v);
112            }
113            let v = self.inner.search(query, limit).await?;
114            self.search_cache.insert(key, (v.clone(), Instant::now()));
115            Ok(v)
116        })
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::registries::{Registry, RegistryError, RegistryResult};
124    use std::sync::atomic::{AtomicUsize, Ordering};
125
126    struct CountingRegistry {
127        get_package_calls: AtomicUsize,
128    }
129
130    impl Registry for CountingRegistry {
131        fn id(&self) -> &str {
132            "counting"
133        }
134        fn display_name(&self) -> &str {
135            "Counting"
136        }
137
138        fn supports_latest_version(&self) -> bool {
139            false
140        }
141
142        fn get_package<'a>(
143            &'a self,
144            _name: &'a str,
145        ) -> Pin<Box<dyn Future<Output = RegistryResult<PackageInfo>> + Send + 'a>> {
146            Box::pin(async move {
147                self.get_package_calls.fetch_add(1, Ordering::SeqCst);
148                Ok(PackageInfo {
149                    name: "foo".into(),
150                    registry: "counting".into(),
151                    latest_version: None,
152                    description: None,
153                    license: None,
154                    homepage: None,
155                    repository: None,
156                    authors: Vec::new(),
157                    updated_at: None,
158                    keywords: Vec::new(),
159                    classifiers: Vec::new(),
160                    requires_python: None,
161                })
162            })
163        }
164
165        fn get_versions<'a>(
166            &'a self,
167            _name: &'a str,
168        ) -> Pin<Box<dyn Future<Output = RegistryResult<Vec<VersionInfo>>> + Send + 'a>> {
169            Box::pin(async move { Err(RegistryError::Parse("unused".into())) })
170        }
171
172        fn search<'a>(
173            &'a self,
174            _query: &'a str,
175            _limit: usize,
176        ) -> Pin<Box<dyn Future<Output = RegistryResult<Vec<SearchResult>>> + Send + 'a>> {
177            Box::pin(async move { Ok(Vec::new()) })
178        }
179    }
180
181    #[tokio::test]
182    async fn cached_registry_hits_cache_on_second_call() {
183        let inner = CountingRegistry {
184            get_package_calls: AtomicUsize::new(0),
185        };
186        let cached = CachedRegistry::new(inner, CacheTtlConfig::default());
187
188        cached.get_package("foo").await.unwrap();
189        cached.get_package("foo").await.unwrap();
190
191        assert_eq!(
192            cached.inner.get_package_calls.load(Ordering::SeqCst),
193            1,
194            "second call should have come from cache"
195        );
196    }
197
198    #[tokio::test]
199    async fn cached_registry_different_keys_hit_inner() {
200        let inner = CountingRegistry {
201            get_package_calls: AtomicUsize::new(0),
202        };
203        let cached = CachedRegistry::new(inner, CacheTtlConfig::default());
204
205        cached.get_package("foo").await.unwrap();
206        cached.get_package("bar").await.unwrap();
207
208        assert_eq!(cached.inner.get_package_calls.load(Ordering::SeqCst), 2);
209    }
210
211    #[tokio::test]
212    async fn cached_registry_expired_entries_refetch() {
213        let inner = CountingRegistry {
214            get_package_calls: AtomicUsize::new(0),
215        };
216        let ttl = CacheTtlConfig {
217            package_secs: 0, // instant expiry
218            ..Default::default()
219        };
220        let cached = CachedRegistry::new(inner, ttl);
221
222        cached.get_package("foo").await.unwrap();
223        tokio::time::sleep(Duration::from_millis(10)).await;
224        cached.get_package("foo").await.unwrap();
225
226        assert_eq!(
227            cached.inner.get_package_calls.load(Ordering::SeqCst),
228            2,
229            "expired entry should refetch"
230        );
231    }
232
233    #[test]
234    fn forwards_supports_latest_version() {
235        let inner = CountingRegistry {
236            get_package_calls: AtomicUsize::new(0),
237        };
238        let cached = CachedRegistry::new(inner, CacheTtlConfig::default());
239        assert!(!cached.supports_latest_version());
240    }
241}