Skip to main content

runner_manager_github/
conditional.rs

1// owner: c4-demand-and-jit-gateway (conditional polling)
2
3//! Conditional `GET`s: ask GitHub whether a listing changed before paying for
4//! it again.
5//!
6//! # Why
7//!
8//! A demand poll re-reads the same few listings every interval, and on an
9//! idle host they are the same bytes every time. GitHub's REST documentation
10//! ("Best practices for using the REST API", *Use conditional requests if
11//! appropriate*,
12//! <https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#use-conditional-requests-if-appropriate>):
13//!
14//! > Most endpoints return an `etag` header, and many endpoints return a
15//! > `last-modified` header. You can use the values of these headers to make
16//! > conditional `GET` requests. … If the data has not changed, you will
17//! > receive a `304 Not Modified` response.
18//! >
19//! > Making a conditional request does not count against your primary rate
20//! > limit if a `304` response is returned and the request was made while
21//! > correctly authorized with an `Authorization` header.
22//!
23//! So [`ConditionalCache`] keeps each listing's validators and body, sends the
24//! validators back, and on `304` hands the caller the body it already has. The
25//! caller cannot tell the two apart except through [`Fetched::not_modified`],
26//! which is the point: parsing is unchanged, and an idle poll costs nothing
27//! against the hourly quota.
28//!
29//! # What was measured before this was relied on
30//!
31//! Against `api.github.com`, with `gh api -i`, read-only:
32//!
33//! * `GET /repos/{o}/{r}/actions/runs?status=queued|in_progress&per_page=100`,
34//!   `GET …/actions/runs/{id}/jobs?filter=latest&per_page=100` and
35//!   `GET /repos/{o}/{r}/actions/runners?per_page=100` each return a **weak**
36//!   `ETag` (`W/"…"`) and `Cache-Control: private, max-age=60`.
37//! * For an unchanged listing the `ETag` is stable. Five listings were asked
38//!   twelve times, ten seconds apart: an idle repository's queued and
39//!   in-progress runs, a queued-runs listing holding two stuck runs, one of
40//!   those runs' jobs, and a repository's runners. All sixty answers were
41//!   `304`, one distinct `ETag` per listing.
42//! * A listing that is changing is not: while CI ran, the jobs of an
43//!   in-progress run changed on 11 of 18 polls ten seconds apart, and the
44//!   in-progress runs listing on 1 of 18. That is why an active target keeps
45//!   the longer active interval.
46//! * A `304` carries the account's `x-ratelimit-*` headers, and twenty `304`s
47//!   in a row left `x-ratelimit-used` unchanged apart from other clients'
48//!   traffic on the same account — consistent with the documented exemption.
49//! * The `ETag` differs between credentials for the same listing (the
50//!   response varies on `Authorization`), so it is cached per client and is
51//!   never shared between machines. After a token renewal the first poll of
52//!   each listing may be a full `200`; that is one request per listing per
53//!   eight hours.
54//!
55//! # Bounded
56//!
57//! A job listing's URL names a run id, so a busy repository mints new keys all
58//! day. Entries unused for [`IDLE_ENTRY_TTL`] are dropped, and the map never
59//! holds more than [`MAX_ENTRIES`].
60
61use 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
70/// How long an entry nobody asked for is kept.
71///
72/// Longer than any wait the schedule produces in practice: the 15-minute
73/// ceilings on the offline and rate-limit back-offs, and the active interval
74/// stretched four times under a critically low quota up to a 30-minute
75/// active interval. An entry evicted by a long wait costs one charged `200`
76/// on the next poll, which is exactly the wrong moment when the quota is low.
77pub const IDLE_ENTRY_TTL: Duration = Duration::from_secs(2 * 60 * 60);
78
79/// The most entries kept. A ceiling against a pathological number of runs,
80/// not a size anyone should reach: ten repositories with a handful of active
81/// runs each use a few dozen.
82pub const MAX_ENTRIES: usize = 1_024;
83
84struct Entry {
85    validators: Validators,
86    /// Shared, so answering a `304` hands out a reference rather than a copy
87    /// of a body that may be a hundred workflow runs long.
88    response: Arc<ApiResponse>,
89    last_used: Timestamp,
90}
91
92/// One response, and whether GitHub said it was unchanged.
93#[derive(Debug, Clone)]
94pub struct Fetched {
95    /// The full response: freshly received, or the cached copy GitHub said is
96    /// still current. Never a bodiless `304`.
97    pub response: Arc<ApiResponse>,
98    /// `true` when GitHub answered `304` and `response` is the cached copy.
99    pub not_modified: bool,
100}
101
102/// Validators and bodies of the listings one client polls.
103pub 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        // Bodies may be large and are nobody's business in a log; the count is
111        // what a reader of a `Debug` dump needs.
112        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    /// How many listings are cached.
128    #[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    /// `request`, sent conditionally when a previous answer to it is cached.
145    ///
146    /// # Errors
147    /// Whatever [`AuthenticatedClient::send`] fails with.
148    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            // The entry was evicted or replaced between the lookup and the
171            // answer, so the body GitHub says is current is gone. Ask again
172            // without validators: one charged request, instead of a bodiless
173            // or wrong reply.
174            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    /// The cached body a `304` to `sent` vouches for, if it is still the one
191    /// cached.
192    ///
193    /// Only the body the validators we SENT describe. Another request for
194    /// this key may have stored a different answer meanwhile, and a `304`
195    /// vouches for ours, not for whatever is cached now.
196    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            // Nothing to send back next time, so nothing worth keeping; and an
216            // entry for this key from an earlier answer is now stale.
217            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    /// Drop every entry unused for [`IDLE_ENTRY_TTL`].
244    pub fn evict_idle(&self) {
245        let now = self.clock.now();
246        retain_recent(&mut self.lock(), now);
247    }
248}
249
250/// Keep only the entries used within [`IDLE_ENTRY_TTL`] of `now`.
251fn 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    /// The first answer is a `200` with an `ETag`; every request that sends the
281    /// tag back gets a bodiless `304`. Mounted most-specific first: wiremock
282    /// answers with the first mock whose matchers all pass.
283    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        // The server has moved on: a conditional request gets a new body and
355        // a new tag, and the next request must send the new tag.
356        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        // A closure rather than `header(..)`: wiremock splits a header value on
415        // commas, and every HTTP-date has one.
416        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}