Skip to main content

remendo/client/
cache.rs

1//! In-memory caching decorator for [`SashikoApi`].
2//!
3//! Wraps any `Arc<dyn SashikoApi>` with TTL-based caching per endpoint.
4//! Each cached response is stored with an insertion timestamp and served
5//! from memory if the TTL has not expired. `clear_cache()` invalidates
6//! all entries (used by Refresh/Ctrl-r).
7
8use crate::client::api::SashikoApi;
9use crate::client::error::ApiError;
10use crate::client::types::{ListParams, ReviewQuery};
11use crate::models::{
12    EmailMessage, MailingList, Paginated, PatchId, Patchset, PatchsetDetail, ServerStats,
13};
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18/// A cached value with insertion timestamp.
19struct CacheEntry<T> {
20    value: T,
21    inserted_at: Instant,
22}
23
24/// In-memory caching decorator for `SashikoApi`.
25///
26/// Caches responses from the 4 actively-used endpoints (`lists`,
27/// `patchsets`, `stats`, `patchset_summary`) in typed `HashMap`s.
28/// All other trait methods pass through to the inner client.
29pub struct CachingClient {
30    inner: Arc<dyn SashikoApi>,
31    ttl: Duration,
32    lists_cache: Mutex<Option<CacheEntry<Vec<MailingList>>>>,
33    patchsets_cache: Mutex<HashMap<String, CacheEntry<Paginated<Patchset>>>>,
34    stats_cache: Mutex<Option<CacheEntry<ServerStats>>>,
35    detail_cache: Mutex<HashMap<String, CacheEntry<PatchsetDetail>>>,
36}
37
38impl CachingClient {
39    /// Create a new caching decorator wrapping the given client.
40    ///
41    /// A `ttl` of zero disables caching (always misses).
42    #[must_use]
43    pub fn new(inner: Arc<dyn SashikoApi>, ttl: Duration) -> Self {
44        Self {
45            inner,
46            ttl,
47            lists_cache: Mutex::new(None),
48            patchsets_cache: Mutex::new(HashMap::new()),
49            stats_cache: Mutex::new(None),
50            detail_cache: Mutex::new(HashMap::new()),
51        }
52    }
53
54    /// Check if the TTL is non-zero (caching enabled).
55    fn is_enabled(&self) -> bool {
56        !self.ttl.is_zero()
57    }
58
59    /// Build a cache key for patchsets from `ListParams`.
60    fn patchsets_key(params: &ListParams) -> String {
61        format!(
62            "p={}&pp={}&q={}&ml={}",
63            params.page,
64            params.per_page,
65            params.search.as_deref().unwrap_or(""),
66            params.mailing_list.as_deref().unwrap_or("")
67        )
68    }
69
70    /// Try to read a valid entry from a single-value cache.
71    fn read_single<T: Clone>(cache: &Mutex<Option<CacheEntry<T>>>, ttl: Duration) -> Option<T> {
72        let guard = cache.lock().ok()?;
73        let entry = guard.as_ref()?;
74        if entry.inserted_at.elapsed() < ttl {
75            Some(entry.value.clone())
76        } else {
77            None
78        }
79    }
80
81    /// Try to read a valid entry from a keyed cache.
82    fn read_keyed<T: Clone>(
83        cache: &Mutex<HashMap<String, CacheEntry<T>>>,
84        key: &str,
85        ttl: Duration,
86    ) -> Option<T> {
87        let guard = cache.lock().ok()?;
88        let entry = guard.get(key)?;
89        if entry.inserted_at.elapsed() < ttl {
90            Some(entry.value.clone())
91        } else {
92            None
93        }
94    }
95
96    /// Store a value in a single-value cache.
97    fn store_single<T>(cache: &Mutex<Option<CacheEntry<T>>>, value: T) {
98        if let Ok(mut guard) = cache.lock() {
99            *guard = Some(CacheEntry {
100                value,
101                inserted_at: Instant::now(),
102            });
103        }
104    }
105
106    /// Store a value in a keyed cache.
107    fn store_keyed<T>(cache: &Mutex<HashMap<String, CacheEntry<T>>>, key: String, value: T) {
108        if let Ok(mut guard) = cache.lock() {
109            guard.insert(
110                key,
111                CacheEntry {
112                    value,
113                    inserted_at: Instant::now(),
114                },
115            );
116        }
117    }
118}
119
120#[async_trait::async_trait]
121impl SashikoApi for CachingClient {
122    async fn lists(&self) -> Result<Vec<MailingList>, ApiError> {
123        if self.is_enabled()
124            && let Some(cached) = Self::read_single(&self.lists_cache, self.ttl)
125        {
126            tracing::trace!("cache hit: lists");
127            return Ok(cached);
128        }
129        let result = self.inner.lists().await?;
130        if self.is_enabled() {
131            Self::store_single(&self.lists_cache, result.clone());
132        }
133        Ok(result)
134    }
135
136    async fn patchsets(&self, params: &ListParams) -> Result<Paginated<Patchset>, ApiError> {
137        let key = Self::patchsets_key(params);
138        if self.is_enabled()
139            && let Some(cached) = Self::read_keyed(&self.patchsets_cache, &key, self.ttl)
140        {
141            tracing::trace!(key = %key, "cache hit: patchsets");
142            return Ok(cached);
143        }
144        let result = self.inner.patchsets(params).await?;
145        if self.is_enabled() {
146            Self::store_keyed(&self.patchsets_cache, key, result.clone());
147        }
148        Ok(result)
149    }
150
151    async fn stats(&self) -> Result<ServerStats, ApiError> {
152        if self.is_enabled()
153            && let Some(cached) = Self::read_single(&self.stats_cache, self.ttl)
154        {
155            tracing::trace!("cache hit: stats");
156            return Ok(cached);
157        }
158        let result = self.inner.stats().await?;
159        if self.is_enabled() {
160            Self::store_single(&self.stats_cache, result.clone());
161        }
162        Ok(result)
163    }
164
165    async fn patchset_summary(&self, id: &PatchId) -> Result<PatchsetDetail, ApiError> {
166        let key = id.to_string();
167        if self.is_enabled()
168            && let Some(cached) = Self::read_keyed(&self.detail_cache, &key, self.ttl)
169        {
170            tracing::trace!(key = %key, "cache hit: patchset detail");
171            return Ok(cached);
172        }
173        let result = self.inner.patchset_summary(id).await?;
174        if self.is_enabled() {
175            Self::store_keyed(&self.detail_cache, key, result.clone());
176        }
177        Ok(result)
178    }
179
180    // --- Passthrough methods (not cached) ---
181
182    async fn messages(&self, params: &ListParams) -> Result<Paginated<EmailMessage>, ApiError> {
183        self.inner.messages(params).await
184    }
185
186    async fn patch_detail(&self, id: &PatchId) -> Result<PatchsetDetail, ApiError> {
187        self.inner.patch_detail(id).await
188    }
189
190    async fn message_detail(&self, id: &PatchId) -> Result<EmailMessage, ApiError> {
191        self.inner.message_detail(id).await
192    }
193
194    async fn review(&self, params: &ReviewQuery) -> Result<serde_json::Value, ApiError> {
195        self.inner.review(params).await
196    }
197
198    async fn review_log(&self, params: &ReviewQuery) -> Result<serde_json::Value, ApiError> {
199        self.inner.review_log(params).await
200    }
201
202    async fn stats_timeline(
203        &self,
204        subsystem_id: Option<i64>,
205    ) -> Result<serde_json::Value, ApiError> {
206        self.inner.stats_timeline(subsystem_id).await
207    }
208
209    async fn stats_reviews(&self) -> Result<serde_json::Value, ApiError> {
210        self.inner.stats_reviews().await
211    }
212
213    async fn stats_tools(&self) -> Result<serde_json::Value, ApiError> {
214        self.inner.stats_tools().await
215    }
216
217    fn clear_cache(&self) {
218        if let Ok(mut guard) = self.lists_cache.lock() {
219            *guard = None;
220        }
221        if let Ok(mut guard) = self.patchsets_cache.lock() {
222            guard.clear();
223        }
224        if let Ok(mut guard) = self.stats_cache.lock() {
225            *guard = None;
226        }
227        if let Ok(mut guard) = self.detail_cache.lock() {
228            guard.clear();
229        }
230        tracing::debug!("cache cleared");
231    }
232}
233
234#[cfg(test)]
235#[allow(clippy::expect_used)]
236mod tests {
237    use super::*;
238    use crate::client::MockClient;
239    use crate::models::Patchset;
240
241    #[tokio::test]
242    async fn cache_hit_returns_cloned_value() {
243        let mock = Arc::new(MockClient::new());
244        let patchsets = Paginated {
245            items: vec![Patchset::fixture()],
246            total: 1,
247            page: 1,
248            per_page: 50,
249        };
250        mock.set_patchsets(Ok(patchsets));
251
252        let cached = CachingClient::new(mock, Duration::from_mins(5));
253
254        // First call: cache miss, fetches from mock
255        let result1 = cached.patchsets(&ListParams::default()).await;
256        assert!(result1.is_ok());
257
258        // Second call: cache hit
259        let result2 = cached.patchsets(&ListParams::default()).await;
260        assert!(result2.is_ok());
261        assert_eq!(result2.expect("cached").items.len(), 1);
262    }
263
264    #[tokio::test]
265    async fn clear_cache_invalidates() {
266        let mock = Arc::new(MockClient::new());
267        mock.set_stats(Ok(ServerStats::fixture()));
268
269        let cached = CachingClient::new(mock, Duration::from_mins(5));
270
271        let _ = cached.stats().await;
272        cached.clear_cache();
273
274        // After clear, the cache entry should be gone
275        // (but the mock still returns data, so the call succeeds)
276        let result = cached.stats().await;
277        assert!(result.is_ok());
278    }
279
280    #[tokio::test]
281    async fn zero_ttl_disables_caching() {
282        let mock = Arc::new(MockClient::new());
283        mock.set_stats(Ok(ServerStats::fixture()));
284
285        let cached = CachingClient::new(mock, Duration::ZERO);
286
287        // Both calls go to the inner client (no caching)
288        let r1 = cached.stats().await;
289        let r2 = cached.stats().await;
290        assert!(r1.is_ok());
291        assert!(r2.is_ok());
292    }
293
294    #[tokio::test]
295    async fn different_params_are_different_cache_entries() {
296        let mock = Arc::new(MockClient::new());
297        let patchsets = Paginated {
298            items: vec![Patchset::fixture()],
299            total: 1,
300            page: 1,
301            per_page: 50,
302        };
303        mock.set_patchsets(Ok(patchsets));
304
305        let cached = CachingClient::new(mock, Duration::from_mins(5));
306
307        let params1 = ListParams::default();
308        let params2 = ListParams {
309            page: 2,
310            ..ListParams::default()
311        };
312
313        let _ = cached.patchsets(&params1).await;
314        // params2 has a different key, so it's a cache miss
315        let _ = cached.patchsets(&params2).await;
316        // Both should succeed (mock returns same data for any params)
317    }
318}