use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use runner_manager_domain::model::{Clock, Timestamp};
use crate::{ApiRequest, ApiResponse, AuthenticatedClient, GithubError, Validators};
pub const IDLE_ENTRY_TTL: Duration = Duration::from_secs(2 * 60 * 60);
pub const MAX_ENTRIES: usize = 1_024;
struct Entry {
validators: Validators,
response: Arc<ApiResponse>,
last_used: Timestamp,
}
#[derive(Debug, Clone)]
pub struct Fetched {
pub response: Arc<ApiResponse>,
pub not_modified: bool,
}
pub struct ConditionalCache {
clock: Arc<dyn Clock>,
entries: Mutex<HashMap<String, Entry>>,
}
impl fmt::Debug for ConditionalCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ConditionalCache")
.field("entries", &self.len())
.finish_non_exhaustive()
}
}
impl ConditionalCache {
#[must_use]
pub fn new(clock: Arc<dyn Clock>) -> Self {
Self {
clock,
entries: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.lock().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub async fn send(
&self,
client: &AuthenticatedClient,
request: &ApiRequest,
) -> Result<Fetched, GithubError> {
let key = request.cache_key();
let validators = self.lock().get(&key).map(|entry| entry.validators.clone());
let sent = match validators.clone() {
Some(validators) => request.clone().conditional(validators),
None => request.clone(),
};
let response = client.send(&sent).await?;
let now = self.clock.now();
if response.is_not_modified() {
let cached = self.cached_for(&key, validators.as_ref(), now);
if let Some(response) = cached {
return Ok(Fetched {
response,
not_modified: true,
});
}
let response = Arc::new(client.send(request).await?);
self.store(key, &response, now);
return Ok(Fetched {
response,
not_modified: false,
});
}
let response = Arc::new(response);
self.store(key, &response, now);
Ok(Fetched {
response,
not_modified: false,
})
}
fn cached_for(
&self,
key: &str,
sent: Option<&Validators>,
now: Timestamp,
) -> Option<Arc<ApiResponse>> {
self.lock()
.get_mut(key)
.filter(|entry| Some(&entry.validators) == sent)
.map(|entry| {
entry.last_used = now;
Arc::clone(&entry.response)
})
}
fn store(&self, key: String, response: &Arc<ApiResponse>, now: Timestamp) {
let validators = Validators::of(response);
let mut entries = self.lock();
if validators.is_empty() {
entries.remove(&key);
return;
}
entries.insert(
key,
Entry {
validators,
response: Arc::clone(response),
last_used: now,
},
);
if entries.len() > MAX_ENTRIES {
retain_recent(&mut entries, now);
}
while entries.len() > MAX_ENTRIES {
let Some(oldest) = entries
.iter()
.min_by_key(|(_, entry)| entry.last_used)
.map(|(key, _)| key.clone())
else {
break;
};
entries.remove(&oldest);
}
}
pub fn evict_idle(&self) {
let now = self.clock.now();
retain_recent(&mut self.lock(), now);
}
}
fn retain_recent(entries: &mut HashMap<String, Entry>, now: Timestamp) {
let idle = chrono::TimeDelta::from_std(IDLE_ENTRY_TTL).unwrap_or(chrono::TimeDelta::MAX);
entries.retain(|_, entry| now.signed_duration_since(entry.last_used) < idle);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{FIXTURE_TOKEN, TestClock};
use crate::{Endpoints, UserAccessToken};
use secrecy::SecretString;
use serde_json::json;
use wiremock::matchers::{header, header_exists, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const ETAG: &str = r#"W/"6042c04e7d4294834a2b329b7cb11869""#;
fn client(server: &MockServer, clock: Arc<TestClock>) -> AuthenticatedClient {
let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
AuthenticatedClient::new(endpoints, token, clock).expect("a client over the test server")
}
fn listing() -> ApiRequest {
ApiRequest::get("/repos/octo/dashboard/actions/runs")
.query("status", "queued")
.query("per_page", 100)
}
async fn mount_unchanged_listing(server: &MockServer, body: serde_json::Value) {
Mock::given(method("GET"))
.and(path("/repos/octo/dashboard/actions/runs"))
.and(header("if-none-match", ETAG))
.respond_with(ResponseTemplate::new(304).insert_header("etag", ETAG))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/repos/octo/dashboard/actions/runs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", ETAG)
.set_body_json(body),
)
.mount(server)
.await;
}
#[tokio::test]
async fn a_304_hands_back_the_cached_body_and_says_it_was_unchanged() {
let server = MockServer::start().await;
let body = json!({ "total_count": 1, "workflow_runs": [ { "id": 7 } ] });
mount_unchanged_listing(&server, body.clone()).await;
let clock = Arc::new(TestClock::default());
let client = client(&server, Arc::clone(&clock));
let cache = ConditionalCache::new(clock);
let first = cache
.send(&client, &listing())
.await
.expect("a first answer");
assert!(
!first.not_modified,
"nothing was cached, so nothing was asked conditionally"
);
let second = cache
.send(&client, &listing())
.await
.expect("a second answer");
assert!(
second.not_modified,
"the tag was sent back and GitHub said unchanged"
);
assert_eq!(
second
.response
.json::<serde_json::Value>()
.expect("the cached JSON"),
body,
"a 304 has no body; the caller must get the one it already paid for"
);
assert_eq!(second.response.status(), reqwest::StatusCode::OK);
let received = server.received_requests().await.expect("recorded requests");
assert_eq!(received.len(), 2);
assert!(received[0].headers.get("if-none-match").is_none());
assert_eq!(
received[1]
.headers
.get("if-none-match")
.and_then(|v| v.to_str().ok()),
Some(ETAG),
"the weak tag goes back verbatim, `W/` prefix and all"
);
let traffic = client.traffic().summary(client.clock.now());
assert_eq!((traffic.full, traffic.not_modified), (1, 1));
}
#[tokio::test]
async fn a_changed_listing_replaces_the_cached_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(header("if-none-match", ETAG))
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", r#"W/"second""#)
.set_body_json(json!({ "total_count": 0, "workflow_runs": [] })),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(header("if-none-match", r#"W/"second""#))
.respond_with(ResponseTemplate::new(304))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", ETAG)
.set_body_json(json!({ "total_count": 1, "workflow_runs": [ { "id": 7 } ] })),
)
.mount(&server)
.await;
let clock = Arc::new(TestClock::default());
let client = client(&server, Arc::clone(&clock));
let cache = ConditionalCache::new(clock);
let _ = cache
.send(&client, &listing())
.await
.expect("the first answer");
let changed = cache
.send(&client, &listing())
.await
.expect("the changed answer");
assert!(!changed.not_modified);
assert_eq!(
changed.response.json::<serde_json::Value>().expect("JSON")["total_count"],
0
);
let unchanged = cache
.send(&client, &listing())
.await
.expect("the third answer");
assert!(unchanged.not_modified, "the new tag was the one sent back");
assert_eq!(
unchanged
.response
.json::<serde_json::Value>()
.expect("JSON")["total_count"],
0,
"the cached body is the newest one, not the first"
);
}
#[tokio::test]
async fn last_modified_is_sent_back_as_if_modified_since() {
let server = MockServer::start().await;
let stamp = "Wed, 07 Oct 2026 15:06:57 GMT";
Mock::given(method("GET"))
.and(move |request: &wiremock::Request| {
request
.headers
.get("if-modified-since")
.and_then(|v| v.to_str().ok())
== Some(stamp)
})
.respond_with(ResponseTemplate::new(304))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("last-modified", stamp)
.set_body_json(json!({ "total_count": 0, "workflow_runs": [] })),
)
.mount(&server)
.await;
let clock = Arc::new(TestClock::default());
let client = client(&server, Arc::clone(&clock));
let cache = ConditionalCache::new(clock);
let _ = cache
.send(&client, &listing())
.await
.expect("the first answer");
let second = cache
.send(&client, &listing())
.await
.expect("the second answer");
assert!(second.not_modified);
}
#[tokio::test]
async fn a_response_without_validators_is_never_asked_conditionally() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(header_exists("if-none-match"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "total_count": 0, "workflow_runs": [] })),
)
.mount(&server)
.await;
let clock = Arc::new(TestClock::default());
let client = client(&server, Arc::clone(&clock));
let cache = ConditionalCache::new(clock);
for _ in 0..3 {
let fetched = cache.send(&client, &listing()).await.expect("an answer");
assert!(!fetched.not_modified);
}
assert!(cache.is_empty());
}
#[tokio::test]
async fn a_304_nobody_asked_for_is_still_an_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(304))
.mount(&server)
.await;
let client = client(&server, Arc::new(TestClock::default()));
let error = client
.send(&listing())
.await
.expect_err("an unconditional request has no cached body for a 304 to mean");
assert!(
matches!(error, GithubError::Status { status: 304, .. }),
"{error:?}"
);
}
#[tokio::test]
async fn a_304_vouches_only_for_the_body_its_validators_describe() {
let server = MockServer::start().await;
mount_unchanged_listing(&server, json!({ "total_count": 0, "workflow_runs": [] })).await;
let clock = Arc::new(TestClock::default());
let client = client(&server, Arc::clone(&clock));
let cache = ConditionalCache::new(Arc::clone(&clock) as Arc<dyn Clock>);
let _ = cache.send(&client, &listing()).await.expect("an answer");
let key = listing().cache_key();
let ours = Validators {
etag: Some(ETAG.to_string()),
last_modified: None,
};
let theirs = Validators {
etag: Some(r#"W/"stored-by-a-request-that-finished-later""#.to_string()),
last_modified: None,
};
assert!(cache.cached_for(&key, Some(&ours), clock.now()).is_some());
assert!(
cache.cached_for(&key, Some(&theirs), clock.now()).is_none(),
"a 304 to other validators must not hand out the body cached now"
);
assert!(cache.cached_for(&key, None, clock.now()).is_none());
}
#[tokio::test]
async fn idle_entries_are_evicted() {
let server = MockServer::start().await;
mount_unchanged_listing(&server, json!({ "total_count": 0, "workflow_runs": [] })).await;
let clock = Arc::new(TestClock::default());
let client = client(&server, Arc::clone(&clock));
let cache = ConditionalCache::new(Arc::clone(&clock) as Arc<dyn Clock>);
let _ = cache.send(&client, &listing()).await.expect("an answer");
assert_eq!(cache.len(), 1);
clock.advance_secs(i64::try_from(IDLE_ENTRY_TTL.as_secs()).expect("fits") - 1);
cache.evict_idle();
assert_eq!(cache.len(), 1, "not yet idle long enough");
clock.advance_secs(1);
cache.evict_idle();
assert!(cache.is_empty());
}
}