1use std::collections::HashMap;
62use std::fmt;
63use std::sync::{Arc, Mutex};
64use std::time::Duration;
65
66use runner_manager_domain::model::{Clock, Timestamp};
67
68use crate::{ApiRequest, ApiResponse, AuthenticatedClient, GithubError, Validators};
69
70pub const IDLE_ENTRY_TTL: Duration = Duration::from_secs(2 * 60 * 60);
78
79pub const MAX_ENTRIES: usize = 1_024;
83
84struct Entry {
85 validators: Validators,
86 response: Arc<ApiResponse>,
89 last_used: Timestamp,
90}
91
92#[derive(Debug, Clone)]
94pub struct Fetched {
95 pub response: Arc<ApiResponse>,
98 pub not_modified: bool,
100}
101
102pub struct ConditionalCache {
104 clock: Arc<dyn Clock>,
105 entries: Mutex<HashMap<String, Entry>>,
106}
107
108impl fmt::Debug for ConditionalCache {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.debug_struct("ConditionalCache")
113 .field("entries", &self.len())
114 .finish_non_exhaustive()
115 }
116}
117
118impl ConditionalCache {
119 #[must_use]
120 pub fn new(clock: Arc<dyn Clock>) -> Self {
121 Self {
122 clock,
123 entries: Mutex::new(HashMap::new()),
124 }
125 }
126
127 #[must_use]
129 pub fn len(&self) -> usize {
130 self.lock().len()
131 }
132
133 #[must_use]
134 pub fn is_empty(&self) -> bool {
135 self.len() == 0
136 }
137
138 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
139 self.entries
140 .lock()
141 .unwrap_or_else(std::sync::PoisonError::into_inner)
142 }
143
144 pub async fn send(
149 &self,
150 client: &AuthenticatedClient,
151 request: &ApiRequest,
152 ) -> Result<Fetched, GithubError> {
153 let key = request.cache_key();
154 let validators = self.lock().get(&key).map(|entry| entry.validators.clone());
155 let sent = match validators.clone() {
156 Some(validators) => request.clone().conditional(validators),
157 None => request.clone(),
158 };
159 let response = client.send(&sent).await?;
160 let now = self.clock.now();
161
162 if response.is_not_modified() {
163 let cached = self.cached_for(&key, validators.as_ref(), now);
164 if let Some(response) = cached {
165 return Ok(Fetched {
166 response,
167 not_modified: true,
168 });
169 }
170 let response = Arc::new(client.send(request).await?);
175 self.store(key, &response, now);
176 return Ok(Fetched {
177 response,
178 not_modified: false,
179 });
180 }
181
182 let response = Arc::new(response);
183 self.store(key, &response, now);
184 Ok(Fetched {
185 response,
186 not_modified: false,
187 })
188 }
189
190 fn cached_for(
197 &self,
198 key: &str,
199 sent: Option<&Validators>,
200 now: Timestamp,
201 ) -> Option<Arc<ApiResponse>> {
202 self.lock()
203 .get_mut(key)
204 .filter(|entry| Some(&entry.validators) == sent)
205 .map(|entry| {
206 entry.last_used = now;
207 Arc::clone(&entry.response)
208 })
209 }
210
211 fn store(&self, key: String, response: &Arc<ApiResponse>, now: Timestamp) {
212 let validators = Validators::of(response);
213 let mut entries = self.lock();
214 if validators.is_empty() {
215 entries.remove(&key);
218 return;
219 }
220 entries.insert(
221 key,
222 Entry {
223 validators,
224 response: Arc::clone(response),
225 last_used: now,
226 },
227 );
228 if entries.len() > MAX_ENTRIES {
229 retain_recent(&mut entries, now);
230 }
231 while entries.len() > MAX_ENTRIES {
232 let Some(oldest) = entries
233 .iter()
234 .min_by_key(|(_, entry)| entry.last_used)
235 .map(|(key, _)| key.clone())
236 else {
237 break;
238 };
239 entries.remove(&oldest);
240 }
241 }
242
243 pub fn evict_idle(&self) {
245 let now = self.clock.now();
246 retain_recent(&mut self.lock(), now);
247 }
248}
249
250fn retain_recent(entries: &mut HashMap<String, Entry>, now: Timestamp) {
252 let idle = chrono::TimeDelta::from_std(IDLE_ENTRY_TTL).unwrap_or(chrono::TimeDelta::MAX);
253 entries.retain(|_, entry| now.signed_duration_since(entry.last_used) < idle);
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::testing::{FIXTURE_TOKEN, TestClock};
260 use crate::{Endpoints, UserAccessToken};
261 use secrecy::SecretString;
262 use serde_json::json;
263 use wiremock::matchers::{header, header_exists, method, path};
264 use wiremock::{Mock, MockServer, ResponseTemplate};
265
266 const ETAG: &str = r#"W/"6042c04e7d4294834a2b329b7cb11869""#;
267
268 fn client(server: &MockServer, clock: Arc<TestClock>) -> AuthenticatedClient {
269 let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
270 let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
271 AuthenticatedClient::new(endpoints, token, clock).expect("a client over the test server")
272 }
273
274 fn listing() -> ApiRequest {
275 ApiRequest::get("/repos/octo/dashboard/actions/runs")
276 .query("status", "queued")
277 .query("per_page", 100)
278 }
279
280 async fn mount_unchanged_listing(server: &MockServer, body: serde_json::Value) {
284 Mock::given(method("GET"))
285 .and(path("/repos/octo/dashboard/actions/runs"))
286 .and(header("if-none-match", ETAG))
287 .respond_with(ResponseTemplate::new(304).insert_header("etag", ETAG))
288 .mount(server)
289 .await;
290 Mock::given(method("GET"))
291 .and(path("/repos/octo/dashboard/actions/runs"))
292 .respond_with(
293 ResponseTemplate::new(200)
294 .insert_header("etag", ETAG)
295 .set_body_json(body),
296 )
297 .mount(server)
298 .await;
299 }
300
301 #[tokio::test]
302 async fn a_304_hands_back_the_cached_body_and_says_it_was_unchanged() {
303 let server = MockServer::start().await;
304 let body = json!({ "total_count": 1, "workflow_runs": [ { "id": 7 } ] });
305 mount_unchanged_listing(&server, body.clone()).await;
306 let clock = Arc::new(TestClock::default());
307 let client = client(&server, Arc::clone(&clock));
308 let cache = ConditionalCache::new(clock);
309
310 let first = cache
311 .send(&client, &listing())
312 .await
313 .expect("a first answer");
314 assert!(
315 !first.not_modified,
316 "nothing was cached, so nothing was asked conditionally"
317 );
318 let second = cache
319 .send(&client, &listing())
320 .await
321 .expect("a second answer");
322 assert!(
323 second.not_modified,
324 "the tag was sent back and GitHub said unchanged"
325 );
326 assert_eq!(
327 second
328 .response
329 .json::<serde_json::Value>()
330 .expect("the cached JSON"),
331 body,
332 "a 304 has no body; the caller must get the one it already paid for"
333 );
334 assert_eq!(second.response.status(), reqwest::StatusCode::OK);
335
336 let received = server.received_requests().await.expect("recorded requests");
337 assert_eq!(received.len(), 2);
338 assert!(received[0].headers.get("if-none-match").is_none());
339 assert_eq!(
340 received[1]
341 .headers
342 .get("if-none-match")
343 .and_then(|v| v.to_str().ok()),
344 Some(ETAG),
345 "the weak tag goes back verbatim, `W/` prefix and all"
346 );
347 let traffic = client.traffic().summary(client.clock.now());
348 assert_eq!((traffic.full, traffic.not_modified), (1, 1));
349 }
350
351 #[tokio::test]
352 async fn a_changed_listing_replaces_the_cached_body() {
353 let server = MockServer::start().await;
354 Mock::given(method("GET"))
357 .and(header("if-none-match", ETAG))
358 .respond_with(
359 ResponseTemplate::new(200)
360 .insert_header("etag", r#"W/"second""#)
361 .set_body_json(json!({ "total_count": 0, "workflow_runs": [] })),
362 )
363 .mount(&server)
364 .await;
365 Mock::given(method("GET"))
366 .and(header("if-none-match", r#"W/"second""#))
367 .respond_with(ResponseTemplate::new(304))
368 .mount(&server)
369 .await;
370 Mock::given(method("GET"))
371 .respond_with(
372 ResponseTemplate::new(200)
373 .insert_header("etag", ETAG)
374 .set_body_json(json!({ "total_count": 1, "workflow_runs": [ { "id": 7 } ] })),
375 )
376 .mount(&server)
377 .await;
378 let clock = Arc::new(TestClock::default());
379 let client = client(&server, Arc::clone(&clock));
380 let cache = ConditionalCache::new(clock);
381
382 let _ = cache
383 .send(&client, &listing())
384 .await
385 .expect("the first answer");
386 let changed = cache
387 .send(&client, &listing())
388 .await
389 .expect("the changed answer");
390 assert!(!changed.not_modified);
391 assert_eq!(
392 changed.response.json::<serde_json::Value>().expect("JSON")["total_count"],
393 0
394 );
395 let unchanged = cache
396 .send(&client, &listing())
397 .await
398 .expect("the third answer");
399 assert!(unchanged.not_modified, "the new tag was the one sent back");
400 assert_eq!(
401 unchanged
402 .response
403 .json::<serde_json::Value>()
404 .expect("JSON")["total_count"],
405 0,
406 "the cached body is the newest one, not the first"
407 );
408 }
409
410 #[tokio::test]
411 async fn last_modified_is_sent_back_as_if_modified_since() {
412 let server = MockServer::start().await;
413 let stamp = "Wed, 07 Oct 2026 15:06:57 GMT";
414 Mock::given(method("GET"))
417 .and(move |request: &wiremock::Request| {
418 request
419 .headers
420 .get("if-modified-since")
421 .and_then(|v| v.to_str().ok())
422 == Some(stamp)
423 })
424 .respond_with(ResponseTemplate::new(304))
425 .mount(&server)
426 .await;
427 Mock::given(method("GET"))
428 .respond_with(
429 ResponseTemplate::new(200)
430 .insert_header("last-modified", stamp)
431 .set_body_json(json!({ "total_count": 0, "workflow_runs": [] })),
432 )
433 .mount(&server)
434 .await;
435 let clock = Arc::new(TestClock::default());
436 let client = client(&server, Arc::clone(&clock));
437 let cache = ConditionalCache::new(clock);
438
439 let _ = cache
440 .send(&client, &listing())
441 .await
442 .expect("the first answer");
443 let second = cache
444 .send(&client, &listing())
445 .await
446 .expect("the second answer");
447 assert!(second.not_modified);
448 }
449
450 #[tokio::test]
451 async fn a_response_without_validators_is_never_asked_conditionally() {
452 let server = MockServer::start().await;
453 Mock::given(method("GET"))
454 .and(header_exists("if-none-match"))
455 .respond_with(ResponseTemplate::new(500))
456 .mount(&server)
457 .await;
458 Mock::given(method("GET"))
459 .respond_with(
460 ResponseTemplate::new(200)
461 .set_body_json(json!({ "total_count": 0, "workflow_runs": [] })),
462 )
463 .mount(&server)
464 .await;
465 let clock = Arc::new(TestClock::default());
466 let client = client(&server, Arc::clone(&clock));
467 let cache = ConditionalCache::new(clock);
468
469 for _ in 0..3 {
470 let fetched = cache.send(&client, &listing()).await.expect("an answer");
471 assert!(!fetched.not_modified);
472 }
473 assert!(cache.is_empty());
474 }
475
476 #[tokio::test]
477 async fn a_304_nobody_asked_for_is_still_an_error() {
478 let server = MockServer::start().await;
479 Mock::given(method("GET"))
480 .respond_with(ResponseTemplate::new(304))
481 .mount(&server)
482 .await;
483 let client = client(&server, Arc::new(TestClock::default()));
484
485 let error = client
486 .send(&listing())
487 .await
488 .expect_err("an unconditional request has no cached body for a 304 to mean");
489 assert!(
490 matches!(error, GithubError::Status { status: 304, .. }),
491 "{error:?}"
492 );
493 }
494
495 #[tokio::test]
496 async fn a_304_vouches_only_for_the_body_its_validators_describe() {
497 let server = MockServer::start().await;
498 mount_unchanged_listing(&server, json!({ "total_count": 0, "workflow_runs": [] })).await;
499 let clock = Arc::new(TestClock::default());
500 let client = client(&server, Arc::clone(&clock));
501 let cache = ConditionalCache::new(Arc::clone(&clock) as Arc<dyn Clock>);
502 let _ = cache.send(&client, &listing()).await.expect("an answer");
503 let key = listing().cache_key();
504 let ours = Validators {
505 etag: Some(ETAG.to_string()),
506 last_modified: None,
507 };
508 let theirs = Validators {
509 etag: Some(r#"W/"stored-by-a-request-that-finished-later""#.to_string()),
510 last_modified: None,
511 };
512
513 assert!(cache.cached_for(&key, Some(&ours), clock.now()).is_some());
514 assert!(
515 cache.cached_for(&key, Some(&theirs), clock.now()).is_none(),
516 "a 304 to other validators must not hand out the body cached now"
517 );
518 assert!(cache.cached_for(&key, None, clock.now()).is_none());
519 }
520
521 #[tokio::test]
522 async fn idle_entries_are_evicted() {
523 let server = MockServer::start().await;
524 mount_unchanged_listing(&server, json!({ "total_count": 0, "workflow_runs": [] })).await;
525 let clock = Arc::new(TestClock::default());
526 let client = client(&server, Arc::clone(&clock));
527 let cache = ConditionalCache::new(Arc::clone(&clock) as Arc<dyn Clock>);
528
529 let _ = cache.send(&client, &listing()).await.expect("an answer");
530 assert_eq!(cache.len(), 1);
531 clock.advance_secs(i64::try_from(IDLE_ENTRY_TTL.as_secs()).expect("fits") - 1);
532 cache.evict_idle();
533 assert_eq!(cache.len(), 1, "not yet idle long enough");
534 clock.advance_secs(1);
535 cache.evict_idle();
536 assert!(cache.is_empty());
537 }
538}