1use 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
18struct CacheEntry<T> {
20 value: T,
21 inserted_at: Instant,
22}
23
24pub 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 #[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 fn is_enabled(&self) -> bool {
56 !self.ttl.is_zero()
57 }
58
59 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 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 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 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 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 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 let result1 = cached.patchsets(&ListParams::default()).await;
256 assert!(result1.is_ok());
257
258 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 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 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(¶ms1).await;
314 let _ = cached.patchsets(¶ms2).await;
316 }
318}