runner_manager_github/demand.rs
1// owner: c4-demand-and-jit-gateway
2
3//! How much work is waiting for a runner that does not exist yet.
4//!
5//! Demand is the number `e1` feeds to `clamp(demand, min_capacity,
6//! max_capacity)`, so it is the number that decides how many runner processes
7//! this host starts. Everything in this module exists to make that number
8//! honest — and, where it cannot be, to make it *say* so rather than look
9//! precise.
10//!
11//! ```text
12//! GET /repos/{owner}/{repo}/actions/runs?status=queued&per_page=100
13//! GET /repos/{owner}/{repo}/actions/runs?status=in_progress&per_page=100
14//! -> 200 { "total_count": N, "workflow_runs": [ { "id": … }, … ] }
15//! GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs?filter=latest&per_page=100
16//! -> 200 { "total_count": M, "jobs": [ { "status": …, "labels": […] }, … ] }
17//! ```
18//!
19//! # The counting unit is a **job**, and this reverses an earlier decision
20//!
21//! This is the single most important thing to know about this module, and it is
22//! an owner decision that **replaced the opposite owner decision**. The history
23//! is kept here rather than deleted, because the reasoning that produced the old
24//! answer is still sound in the abstract and a later reader who only sees the
25//! new code will otherwise re-derive it and revert this.
26//!
27//! **What the previous decision said.** `d18b-run-count-filtering.md` probed the
28//! runs endpoint against live GitHub and established that its `total_count`
29//! counts **workflow runs matching the query**. Counting runs costs exactly one
30//! request per repository, a fixed figure the budget model
31//! ([`crate::rest::TargetCost`], [`crate::rest::BudgetProjection`]) could price
32//! and `f2`'s `add` refusals could be computed from. Resolving each queued run's
33//! jobs costs one extra request *per run*, a variable cost scaling with queue
34//! depth. The owner chose the fixed cost, accepted the resulting under-count,
35//! and this module said at length: *do not "fix" this, and do not add a per-run
36//! job listing*.
37//!
38//! **Why it was reversed.** A workflow run holds many jobs — a matrix, or a
39//! `jobs:` map with several independent entries — and **each job needs its own
40//! runner**. Under the run count, a single queued run holding eight jobs read as
41//! demand `1`, `e1` started **one** runner, that runner took one job, and the
42//! remaining seven queued behind it. The next poll saw the same run still
43//! queued, still read `1`, and started one more. So a host configured for ten
44//! concurrent runners served an eight-job matrix **serially, roughly one at a
45//! time**, with the queue depth on GitHub growing while the machine sat idle.
46//! The under-count was not a rounding error in the demand signal; on the
47//! workflow shape people actually write, it was the difference between the
48//! product's headline feature working and not working. That is a worse outcome
49//! than a variable request cost, so the trade was re-made the other way.
50//!
51//! **What that means for a later reader.** The per-run job listing below is
52//! deliberate and load-bearing. Removing it to restore a fixed per-repository
53//! cost re-creates the serial-matrix defect described above. If a future owner
54//! decision reverses this again, it belongs in this documentation and in
55//! `tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it` before it
56//! belongs in the code.
57//!
58//! # Both run statuses are polled, and the second one is not redundant
59//!
60//! `status=queued` alone is the obvious query and it is not sufficient. A run's
61//! status is a property of the *run*, and a run holding both a running job and a
62//! queued one has to report one value for both. Live observation on a repository
63//! using this product caught a run of five jobs — two completed, one
64//! `in_progress`, two `queued` — reporting `status: "queued"`, so `queued` does
65//! take precedence over `in_progress` while any job is still waiting.
66//!
67//! That observation is what makes `status=queued` the *primary* signal, and it
68//! is not what makes it sufficient. The case it does not cover is a job that
69//! becomes queued **later**: `needs:` holds a job back until its dependency
70//! finishes, and whether GitHub flips the run's status back to `queued` at that
71//! moment is a claim about a state machine this project has not observed. A
72//! `needs:`-gated job is an ordinary workflow shape, and missing one entirely
73//! would be the same class of defect this module was just rewritten to fix.
74//!
75//! So `status=in_progress` is polled too, as a **safety net rather than a second
76//! primary signal**, and it is budgeted like one: it is read after the queued
77//! runs and gets the smaller of the two run caps
78//! ([`MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`] against
79//! [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`]). A run appears in at most one of
80//! the two lists — the queries are disjoint by construction — and only jobs
81//! whose own `status` is `queued` are counted, so an in-progress run whose jobs
82//! have all been dispatched contributes nothing but the request that discovered
83//! that.
84//!
85//! `completed` and `waiting` runs are not polled. A completed run has no
86//! dispatchable job left, and a `waiting` run is held by a deployment gate or a
87//! concurrency group rather than by the absence of a runner — starting one for
88//! it would produce a runner that idles until its timeout.
89//!
90//! # Routing labels ARE applied now, and the predicate is still `b1`'s
91//!
92//! The previous decision's second accepted cost was that **no routing-label
93//! filtering happened at all**: a run carries no `runs-on`, labels live on jobs,
94//! and this module fetched no jobs. Every queued run in a watched repository
95//! counted, including runs whose jobs targeted `ubuntu-latest` or another host's
96//! `rm-<host>-…` label, and each runner started that way idled until it timed
97//! out.
98//!
99//! Fetching the jobs supplies the input that was missing, so that cost is paid
100//! back by the same change. **This module still owns no predicate.** It reads
101//! each job's `labels` array, builds a [`RunsOn`] from it, and hands that to the
102//! caller. `b1` owns the matching
103//! ([`runner_manager_domain::policy::RoutingLabels::matches`] and its `tally`),
104//! `e1` owns applying it per policy, and
105//! `tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it` scans
106//! this file's own source to pin that no second implementation grows here.
107//!
108//! The filtering is deliberately **not** done in this module even though it now
109//! has the input, and the reason is `e1`'s: one target can be watched by more
110//! than one policy, each with its own routing labels, and `e1` polls a target
111//! once for all of them. A gateway that filtered would have to be told whose
112//! labels to filter by, which would make the poll per-policy and multiply its
113//! request cost by the number of policies sharing the target. So the gateway
114//! returns the jobs and each policy tallies them.
115//!
116//! # What is still approximate, stated plainly rather than left to be discovered
117//!
118//! * **A job listing is a snapshot.** A job that leaves the queue between the
119//! run list and the job list is counted; one that arrives after is not. The
120//! next poll corrects both, and `e1`'s per-policy `active_owned` term stops a
121//! job already being served from being served twice.
122//! * **The run caps make a large queue a floor.** Past
123//! [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] the count is a *floor* rather
124//! than a total, reported through [`QueuedDemand::is_truncated`]. Scaling up
125//! from a floor is safe and successive polls converge; concluding "idle" from
126//! one is not, which is why the floor is expressible at all.
127//! * **`runs-on` is not always resolvable.** `runs-on: ${{ matrix.runner }}` can
128//! only be evaluated by GitHub. `b1` reports those as
129//! [`runner_manager_domain::policy::UnresolvableRunsOn`] and they are neither
130//! counted as demand nor silently dropped.
131//!
132//! # There is no job reservation, and nothing here may pretend otherwise
133//!
134//! The scale-set model's `AcquireJobs` has no REST equivalent
135//! (`d17-user-to-server-scale-set-chain.md`), so demand is **advisory**: another
136//! host may take a job this host has already started a runner for
137//! (`01-current-architecture.md`, edge case 6).
138//!
139//! **Do not add a claim, a lease, a local reservation table, or an
140//! acknowledgement call to compensate.** No such call exists in this crate, and
141//! `b1`'s, `e1`'s and this task's specifications all say so independently
142//! because implementers keep reaching for one. The bounding controls are the
143//! host-scoped labels in `b1` and the two capacity ceilings in `e1`; a local
144//! lease would coordinate this host with itself and with nothing else, which is
145//! the one thing the problem does not need.
146
147use std::{
148 collections::{BTreeMap, BTreeSet},
149 fmt,
150 sync::{
151 Arc, Mutex,
152 atomic::{AtomicU64, Ordering},
153 },
154 time::Duration,
155};
156
157use runner_manager_domain::{
158 model::{Clock, OwnerRepo, TargetScope, Timestamp},
159 policy::RunsOn,
160};
161use serde::Deserialize;
162
163use crate::{
164 ApiRequest, AuthenticatedClient, GithubError,
165 conditional::{ConditionalCache, Fetched},
166 rest::{
167 ActivityScope, CancelToken, DEFAULT_RATE_LIMIT_BACKOFF, InventoryError,
168 MAX_RATE_LIMIT_BACKOFF, PER_PAGE, RateLimitKind, RateLimited, TargetCost,
169 UnavailableRepository,
170 },
171 traffic::RateLimitSnapshot,
172};
173
174// ---------------------------------------------------------------------------
175// Constants
176// ---------------------------------------------------------------------------
177
178/// The `status` filter that selects runs with a job that may still be waiting.
179///
180/// Stated as a constant rather than inlined because it is the whole difference
181/// between this module and `c3`'s activity count, and a one-word typo here
182/// produces a plausible-looking number rather than an error.
183pub const QUEUED_RUN_STATUS: &str = "queued";
184
185/// The `status` filter for the safety-net pass over runs already under way.
186///
187/// Not a second primary signal. The module documentation states what it covers
188/// that [`QUEUED_RUN_STATUS`] does not — a `needs:`-gated job entering the queue
189/// after its run has already started — and why the difference is not something
190/// this project has observed its way out of needing.
191pub const IN_PROGRESS_RUN_STATUS: &str = "in_progress";
192
193/// The job `status` that means "waiting for a runner that does not exist yet".
194///
195/// This is the value the whole module reduces to. A job in any other state
196/// either has a runner or has finished with one, and counting it would start a
197/// runner for work already being done — the same mistake as reading `c3`'s
198/// in-progress count as demand, one level down.
199pub const QUEUED_JOB_STATUS: &str = "queued";
200
201/// The `filter` for the jobs endpoint: the latest attempt of each job only.
202///
203/// The default is `latest`, and it is sent explicitly because the alternative,
204/// `all`, returns every attempt of every re-run. Under `all` a job re-run three
205/// times contributes three entries, and the two that are historical would be
206/// counted as present demand.
207pub const LATEST_JOBS_FILTER: &str = "latest";
208
209/// Requests one demand poll costs, **per repository**, in steady state.
210///
211/// Four: the two run listings ([`QUEUED_RUN_STATUS`] and
212/// [`IN_PROGRESS_RUN_STATUS`]), plus a job listing for each of the couple of
213/// runs a repository has under way at any moment.
214///
215/// # Why this is a projection and not a measurement, and what bounds it
216///
217/// Under the previous owner decision this constant was `1` and it was exact: one
218/// request per repository, always, because `total_count` on the runs query
219/// answered the whole question. Counting jobs makes the cost depend on how many
220/// runs are active, which is a number no constant can know. So this is the
221/// steady-state figure the budget model prices, in the same spirit as
222/// [`crate::rest::ACTIVITY_REQUESTS_PER_REPOSITORY_PER_REFRESH`], which is also
223/// a best case with a documented worse one.
224///
225/// The **worst** case is `2 + MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL +
226/// MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`, which is what
227/// [`max_demand_requests_per_repository_per_poll`] returns, and it is bounded by
228/// construction rather than by hope: the two caps are hard, and a repository
229/// that reaches them says so through [`QueuedDemand::is_truncated`] rather than
230/// spending more. [`crate::rest::BUDGET_SHARE_DIVISOR`] absorbs the gap between
231/// the two figures — the projection is compared against half the documented
232/// ceiling precisely so that the half nobody models has somewhere to go.
233///
234/// A repository is only at the worst case while it genuinely has that many runs
235/// in flight, which is also exactly when spending requests to scale correctly is
236/// worth more than saving them.
237pub const DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL: u32 = 4;
238
239/// The most `status=queued` runs one repository's job listing may resolve per
240/// poll.
241///
242/// The primary signal gets the larger cap. Past it the reported count is a
243/// **floor** rather than a total, which is safe in the direction that matters:
244/// `e1` clamps demand to `max_capacity` and the host ceiling anyway, so a
245/// repository with more than this many queued runs is one whose real demand
246/// exceeds any realistic host's capacity — the allocation is already pinned at
247/// the ceiling and a larger number would not change it. Successive polls resolve
248/// the rest as the earlier runs drain.
249pub const MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL: usize = 6;
250
251/// The most `status=in_progress` runs one repository's job listing may resolve
252/// per poll.
253///
254/// Smaller than [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] on purpose: this
255/// pass exists to catch a `needs:`-gated job whose run has already started, and
256/// on the overwhelmingly common shape it finds nothing and costs one request per
257/// run to discover that. Giving the safety net the same budget as the primary
258/// signal would double the worst case to buy a rarer case.
259pub const MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL: usize = 4;
260
261/// The most pages of jobs one run's listing may walk.
262///
263/// A run's job count is bounded by GitHub's own matrix limit of 256, so two
264/// pages at [`PER_PAGE`] cover every legal run with room to spare. The third is
265/// there because a `Link: rel="next"` chain that does not terminate is a
266/// runaway, and this walk is charged against a budget.
267pub const MAX_JOB_PAGES_PER_RUN: usize = 3;
268
269/// The ceiling on what one repository's demand poll may spend.
270///
271/// Stated as a function rather than a constant because it is the sum of three
272/// constants and a reader checking the budget arithmetic should not have to
273/// re-add them — and because `f1` and `f2` render the worst case beside the
274/// projection.
275#[must_use]
276pub const fn max_demand_requests_per_repository_per_poll() -> u32 {
277 // The two run listings, then one job listing per run each cap admits.
278 2 + (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u32)
279 + (MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL as u32)
280}
281
282// A poll that costs nothing is a poll that issued no request, and a budget line
283// of zero would let `f2` admit an unbounded number of targets.
284const _: () = assert!(
285 DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL > 0,
286 "a demand poll costs at least the requests that fetched the run lists"
287);
288
289// The projection has to sit inside the bound, or the bound is not a bound. This
290// is a compile-time check rather than a test because the two numbers drifting
291// apart is the defect itself rather than a symptom of one: a projection above
292// the ceiling would have `f2` refuse configurations that cannot occur, and the
293// arithmetic that produced it would look deliberate.
294const _: () = assert!(
295 DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL <= max_demand_requests_per_repository_per_poll(),
296 "the projected demand cost must fit inside the worst case the caps allow"
297);
298
299// The projection must also cover the two run listings, which every poll issues
300// unconditionally. A projection below that floor would under-price even a
301// completely idle repository, which is the one case the model must get exactly
302// right: it is what `f1`'s printed ceiling and `f2`'s `add` refusals are
303// computed from.
304const _: () = assert!(
305 DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL >= 2,
306 "every poll issues both run listings, so the projection cannot be below two"
307);
308
309// The safety net must not outgrow the signal it is backing up. If these ever
310// invert, the cheaper `in_progress` pass would be resolving more runs than the
311// `queued` pass that actually carries the demand.
312const _: () = assert!(
313 MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL <= MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
314 "the in-progress safety net may not be given a larger budget than the primary signal"
315);
316
317/// How far GitHub's `total_count` may exceed the single page it arrived with
318/// before the disagreement stops being a race and starts being evidence.
319///
320/// `c3` states the reasoning in full at its own `MAX_BENIGN_TOTAL_COUNT_SKEW`,
321/// which is private to `crates/github/src/rest.rs`; the value is repeated rather
322/// than shared because this task does not own that file. The short form:
323///
324/// * A run leaving the queue between GitHub computing `total_count` and
325/// serialising the page makes `total` exceed `listed` by a handful. That race
326/// is documented and legitimate, and a debug build pointed at live GitHub is
327/// the build most likely to meet it.
328/// * The defect being hunted — `total_count` carrying the repository's
329/// *unfiltered* lifetime total — is gross: thousands over a page of three.
330///
331/// # This is now a consistency check rather than a load-bearing one
332///
333/// Under the previous owner decision `total_count` **was** the demand number,
334/// and being wrong about it started the wrong number of runners. It no longer
335/// reaches `clamp()`: demand is counted from the jobs, and `total_count` is read
336/// only to notice that a repository has more runs than the caps will resolve.
337/// So the check stays — it is free, and `c3` reads the same envelope for a
338/// dashboard number — but it is a `warn!` and no longer a `debug_assert!`.
339/// Panicking a development build over a field the decision no longer depends on
340/// would be a tripwire that cries wolf, and those get deleted.
341const MAX_BENIGN_TOTAL_COUNT_SKEW: u64 = 16;
342
343// A zero skew is `total == listed` again, which fires on a run leaving the queue
344// mid-serialisation. At compile time rather than in a test because a test
345// deriving its fixture from this constant moves with it and stays green at zero.
346const _: () = assert!(
347 MAX_BENIGN_TOTAL_COUNT_SKEW > 0,
348 "a zero skew re-creates the check that trips on a run leaving the queue mid-serialisation"
349);
350
351// ---------------------------------------------------------------------------
352// The demand reading
353// ---------------------------------------------------------------------------
354
355/// Queued **jobs**, per repository, each carrying the `runs-on` it requires.
356///
357/// **This is a count of jobs, not of runs, and it is not filtered by any
358/// policy's routing labels — but it carries everything needed to filter it.**
359/// Both facts are the module documentation's subject and are repeated on the
360/// type because this is what a caller holds.
361///
362/// The unfiltered totals ([`QueuedDemand::total`],
363/// [`QueuedDemand::for_repository`]) are the raw queue depth, which is what `g2`
364/// renders for an operator: "what is waiting in this repository", independent of
365/// which host could serve it. The number `e1` clamps is **not** either of those.
366/// It comes from tallying [`QueuedDemand::jobs_for`] against one policy's
367/// routing labels, and the difference between the two is exactly the jobs this
368/// host cannot serve.
369///
370/// # A count can be short in two different ways, and both have to say so
371///
372/// A repository can fail to answer at all ([`QueuedDemand::unavailable`]), and a
373/// repository can answer with a number that is only a **floor**
374/// ([`QueuedDemand::truncated`]) — more runs were active than
375/// [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] and
376/// [`MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`] allow resolving, or one
377/// run's job listing walked to [`MAX_JOB_PAGES_PER_RUN`].
378/// [`QueuedDemand::is_complete`] is `false` for either.
379///
380/// The shape mirrors [`crate::rest::ActivityCount`] deliberately, down to the
381/// method names, because `g2` renders the two side by side and a caller that has
382/// learned one should not have to learn the other. They stay separate types for
383/// the reason `c3` keeps the busy-runner count and the in-progress count apart:
384/// a type that can hold either number is a type that will eventually add them.
385#[derive(Debug, Clone, PartialEq, Eq, Default)]
386pub struct QueuedDemand {
387 per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>,
388 /// Which job each entry of `per_repository` is, in the same order, where
389 /// the reading came from GitHub's job listing.
390 identities: BTreeMap<OwnerRepo, Vec<QueuedJobIdentity>>,
391 unavailable: Vec<UnavailableRepository>,
392 /// Repositories whose count is a floor rather than a total.
393 truncated: BTreeSet<OwnerRepo>,
394 /// The workflow file of each run the reading listed, by repository.
395 run_workflows: BTreeMap<OwnerRepo, BTreeMap<u64, String>>,
396 /// Repositories with runs under way whose listings **changed** since the
397 /// previous poll. See [`QueuedDemand::is_quiet`].
398 busy: BTreeSet<OwnerRepo>,
399 /// The account's hourly quota as GitHub reported it during this poll.
400 rate_limit: Option<RateLimitSnapshot>,
401}
402
403impl QueuedDemand {
404 #[must_use]
405 pub fn new(per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>) -> Self {
406 Self {
407 per_repository,
408 identities: BTreeMap::new(),
409 unavailable: Vec::new(),
410 truncated: BTreeSet::new(),
411 run_workflows: BTreeMap::new(),
412 busy: BTreeSet::new(),
413 rate_limit: None,
414 }
415 }
416
417 /// The workflow file of each of `repository`'s active runs the reading saw,
418 /// by run id.
419 #[must_use]
420 pub fn run_workflows_for(&self, repository: &OwnerRepo) -> Option<&BTreeMap<u64, String>> {
421 self.run_workflows.get(repository)
422 }
423
424 /// `repository`'s queued jobs with their identities, where the reading
425 /// has them.
426 pub fn identified_jobs_for(
427 &self,
428 repository: &OwnerRepo,
429 ) -> impl Iterator<Item = (&RunsOn, Option<&QueuedJobIdentity>)> {
430 let identities = self
431 .identities
432 .get(repository)
433 .map_or(&[][..], Vec::as_slice);
434 self.jobs_for(repository)
435 .iter()
436 .enumerate()
437 .map(move |(index, job)| (job, identities.get(index)))
438 }
439
440 /// Says which job each of `repository`'s entries is, in the same order.
441 #[must_use]
442 pub fn with_identities(
443 mut self,
444 repository: OwnerRepo,
445 identities: Vec<QueuedJobIdentity>,
446 ) -> Self {
447 self.identities.insert(repository, identities);
448 self
449 }
450
451 /// One repository's queued jobs, for the repository-target case and for
452 /// tests.
453 #[must_use]
454 pub fn of(repository: OwnerRepo, jobs: impl IntoIterator<Item = RunsOn>) -> Self {
455 Self::new(BTreeMap::from([(
456 repository,
457 jobs.into_iter().collect::<Vec<_>>(),
458 )]))
459 }
460
461 /// Record that `repository`'s count is a floor rather than a total.
462 #[must_use]
463 pub fn with_truncated(mut self, repository: OwnerRepo) -> Self {
464 self.truncated.insert(repository);
465 self
466 }
467
468 /// Record that `repository` has runs under way whose listings changed.
469 #[must_use]
470 pub fn with_busy(mut self, repository: OwnerRepo) -> Self {
471 self.busy.insert(repository);
472 self
473 }
474
475 /// Attach the hourly-quota reading GitHub sent during this poll.
476 #[must_use]
477 pub const fn with_rate_limit(mut self, rate_limit: Option<RateLimitSnapshot>) -> Self {
478 self.rate_limit = rate_limit;
479 self
480 }
481
482 /// Whether nothing is happening in any repository this poll read.
483 ///
484 /// A repository is quiet when it had **no run under way**, or when every
485 /// listing it took to find out was answered `304 Not Modified`. The second
486 /// half matters: a run stuck in the queue for a label no host serves stays
487 /// in the listing for hours, and treating that as activity would hold the
488 /// whole target at the slower active interval for no reason, when its
489 /// listings cost nothing while they do not change.
490 ///
491 /// `e1` polls a quiet target at the host's *idle* interval. That is the
492 /// moment latency matters most, because work arrives after idle, and the
493 /// moment polling is free, because every answer is a `304`.
494 #[must_use]
495 pub fn is_quiet(&self) -> bool {
496 // An unavailable repository is not quiet either: its failing listing
497 // is not cached, so every poll of it is a charged request, and an
498 // idle interval would multiply that by six.
499 self.busy.is_empty() && self.unavailable.is_empty()
500 }
501
502 /// The account's hourly quota as GitHub reported it during this poll.
503 ///
504 /// Every response carries it, a `304` included, and it describes every
505 /// client signed in as the same user, so it is what `e1` slows down on.
506 #[must_use]
507 pub const fn rate_limit(&self) -> Option<&RateLimitSnapshot> {
508 self.rate_limit.as_ref()
509 }
510
511 /// Record that `repository` could not be read at all, and why.
512 ///
513 /// Deliberately **not** the same as a count of zero: nothing was learned
514 /// about it, and a zero would render a possibly-busy repository as idle.
515 #[must_use]
516 pub fn with_unavailable(mut self, repository: OwnerRepo, reason: impl Into<String>) -> Self {
517 self.unavailable.push(UnavailableRepository {
518 repository,
519 reason: reason.into(),
520 });
521 self
522 }
523
524 /// Queued jobs across every repository that answered, **unfiltered**.
525 ///
526 /// The raw queue depth, not the demand any one policy should serve. See the
527 /// type documentation for the distinction, which is the whole reason
528 /// [`QueuedDemand::jobs`] exists beside this.
529 ///
530 /// Saturating rather than wrapping: a total wider than a `u32` is not a
531 /// number to wrap around zero, and `u32::MAX` runners is refused by the
532 /// capacity ceilings long before it means anything.
533 #[must_use]
534 pub fn total(&self) -> u32 {
535 self.per_repository.values().fold(0_u32, |sum, jobs| {
536 sum.saturating_add(u32::try_from(jobs.len()).unwrap_or(u32::MAX))
537 })
538 }
539
540 #[must_use]
541 pub fn per_repository(&self) -> &BTreeMap<OwnerRepo, Vec<RunsOn>> {
542 &self.per_repository
543 }
544
545 /// One repository's unfiltered count, or `None` when it did not answer.
546 #[must_use]
547 pub fn for_repository(&self, repository: &OwnerRepo) -> Option<u32> {
548 self.per_repository
549 .get(repository)
550 .map(|jobs| u32::try_from(jobs.len()).unwrap_or(u32::MAX))
551 }
552
553 /// One repository's queued jobs, as the `runs-on` each requires.
554 ///
555 /// This is the input `b1`'s predicate was written for and never had. An
556 /// empty slice for a repository that answered means it really is idle; a
557 /// repository that did not answer is in [`QueuedDemand::unavailable`]
558 /// instead, and the two must not be conflated.
559 #[must_use]
560 pub fn jobs_for(&self, repository: &OwnerRepo) -> &[RunsOn] {
561 self.per_repository
562 .get(repository)
563 .map_or(&[], Vec::as_slice)
564 }
565
566 /// Every queued job across every repository that answered.
567 ///
568 /// What an organization target tallies, for the reason a repository target
569 /// tallies [`QueuedDemand::jobs_for`]: one policy watching an organization
570 /// serves any repository in it, so its demand is the whole scope's.
571 pub fn jobs(&self) -> impl Iterator<Item = &RunsOn> {
572 self.per_repository.values().flat_map(Vec::as_slice)
573 }
574
575 #[must_use]
576 pub fn unavailable(&self) -> &[UnavailableRepository] {
577 &self.unavailable
578 }
579
580 #[must_use]
581 pub fn truncated(&self) -> &BTreeSet<OwnerRepo> {
582 &self.truncated
583 }
584
585 #[must_use]
586 pub fn is_truncated(&self, repository: &OwnerRepo) -> bool {
587 self.truncated.contains(repository)
588 }
589
590 /// Whether every repository in scope answered with an exact count.
591 ///
592 /// `false` means the total is a **floor**. Scaling *up* from a floor is
593 /// safe; concluding "idle" from one is not, which is the mistake this exists
594 /// to make expressible.
595 #[must_use]
596 pub fn is_complete(&self) -> bool {
597 self.unavailable.is_empty() && self.truncated.is_empty()
598 }
599}
600
601/// Requests one demand poll over `scope` costs, in steady state.
602///
603/// Grows with the repository count, because there is no organization-wide
604/// workflow-runs endpoint and an organization therefore pays per repository the
605/// App is installed on. That growth *is* the product constraint after D4: every
606/// added repository multiplies this policy's share of the shared hourly ceiling.
607#[must_use]
608pub fn demand_requests_per_poll(scope: &ActivityScope) -> u32 {
609 u32::try_from(scope.repositories().len())
610 .unwrap_or(u32::MAX)
611 .saturating_mul(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
612}
613
614/// The most requests one demand poll over `scope` may spend.
615///
616/// The companion to [`demand_requests_per_poll`], and the number a reader should
617/// be shown when they ask why a busy hour cost more than the projection. See
618/// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for why the model prices the
619/// steady state and bounds the peak rather than trying to price the peak.
620#[must_use]
621pub fn max_demand_requests_per_poll(scope: &ActivityScope) -> u32 {
622 u32::try_from(scope.repositories().len())
623 .unwrap_or(u32::MAX)
624 .saturating_mul(max_demand_requests_per_repository_per_poll())
625}
626
627/// `scope`'s budget cost with this module's demand figure substituted for
628/// `c3`'s estimate.
629///
630/// This is the reporting seam `c4`'s specification requires — "report the
631/// per-poll request count to `c3`'s budget model rather than estimating it
632/// there" — and [`TargetCost::with_demand_requests_per_repository`] is where
633/// `c3` left it open. Callers that project a budget (`f1`'s `host show`, `f2`'s
634/// `repo add` and `org add`, `g3`'s settings) should build their
635/// [`TargetCost`] through this function rather than through
636/// [`TargetCost::from_activity_scope`] directly.
637#[must_use]
638pub fn target_cost(scope: &ActivityScope) -> TargetCost {
639 TargetCost::from_activity_scope(scope)
640 .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
641}
642
643// ---------------------------------------------------------------------------
644// The gateway
645// ---------------------------------------------------------------------------
646
647/// The demand read model.
648///
649/// A trait for [`crate::rest::InventoryGateway`]'s reason: `e1` and `g2` are
650/// tested against `runner_manager_testkit::github::FakeGithub` with no network
651/// and no `wiremock` in their dependency graphs. [`RestDemand`] is the one
652/// implementation that talks to GitHub.
653///
654/// # Why the scope type is `c3`'s `ActivityScope` and not a new one
655///
656/// The name says "activity" and this is demand, so the reuse is worth
657/// justifying rather than leaving to look like an oversight.
658///
659/// [`ActivityScope`] is not a description of the in-progress count; it is the
660/// answer to "which repositories does one per-repository workflow-runs
661/// aggregate cover", and demand asks that question with exactly the same
662/// answer. Both hit `/repos/{o}/{r}/actions/runs`, both have no
663/// organization-wide form, and both take the repository list from the same
664/// caller-held set — `c3` documents that the list "has to come from the caller,
665/// \[because\] `f1` and `e1` already hold it, from
666/// [`crate::AuthenticatedClient::discover_installations`], and re-discovering it
667/// on every refresh would cost more requests than the count itself".
668///
669/// A `DemandScope` beside it would be that type with a different name, and the
670/// cost of the duplicate is concrete rather than aesthetic: `e1` polls both read
671/// models in one refresh, and two scope types are two chances for them to
672/// disagree about which repositories are in scope — which would make the demand
673/// total and the activity total describe different sets while being rendered
674/// side by side. What is *not* shared is the cost function:
675/// [`ActivityScope::requests_per_refresh`] prices the activity count, and
676/// [`demand_requests_per_poll`] prices this one, because those really are two
677/// different numbers.
678#[async_trait::async_trait]
679pub trait DemandGateway: fmt::Debug + Send + Sync {
680 /// Queued workflow runs across `scope`.
681 ///
682 /// # Errors
683 /// Every variant of [`InventoryError`].
684 async fn queued_demand(
685 &self,
686 scope: &ActivityScope,
687 cancel: &CancelToken,
688 ) -> Result<QueuedDemand, InventoryError>;
689
690 /// The workflow file (`.github/workflows/ci.yml`) of one run. Nothing else
691 /// on a run names its workflow stably: its `name` and a job's `workflow_name`
692 /// can be a title that changes at every dispatch.
693 ///
694 /// # Errors
695 /// Every variant of [`InventoryError`].
696 async fn run_workflow(
697 &self,
698 _repository: &OwnerRepo,
699 _run_id: u64,
700 _cancel: &CancelToken,
701 ) -> Result<Option<String>, InventoryError> {
702 Ok(None)
703 }
704
705 /// The instant a reading is stamped with.
706 fn now(&self) -> Timestamp;
707}
708
709/// One gateway shared by every target loop on a host. Sharing is what lets
710/// [`RestDemand`] read a repository once for every target that covers it, and
711/// silence every loop at once inside a rate-limit quiet period.
712#[async_trait::async_trait]
713impl<T: DemandGateway + ?Sized> DemandGateway for Arc<T> {
714 async fn queued_demand(
715 &self,
716 scope: &ActivityScope,
717 cancel: &CancelToken,
718 ) -> Result<QueuedDemand, InventoryError> {
719 (**self).queued_demand(scope, cancel).await
720 }
721
722 async fn run_workflow(
723 &self,
724 repository: &OwnerRepo,
725 run_id: u64,
726 cancel: &CancelToken,
727 ) -> Result<Option<String>, InventoryError> {
728 (**self).run_workflow(repository, run_id, cancel).await
729 }
730
731 fn now(&self) -> Timestamp {
732 (**self).now()
733 }
734}
735
736/// [`DemandGateway`] over `api.github.com`.
737///
738/// Holds no credential of its own: authentication is entirely
739/// [`AuthenticatedClient`]'s, and this type only ever hands it an
740/// [`ApiRequest`].
741///
742/// # Why this is not a method on `c3`'s `RestInventory`
743///
744/// It would be the better shape, and it is not available. `RestInventory::get`
745/// — the one place cancellation, request accounting and the rate-limit gate meet
746/// — is private to `crates/github/src/rest.rs`, and a sibling module cannot
747/// reach a private item. Making it `pub(crate)` is an edit to `c3`'s file, which
748/// this task does not own, so the choice was between duplicating `c3`'s whole
749/// rate-limit *policy* here and consuming what `c3` already exports. This
750/// consumes.
751///
752/// # What "consuming `c3`'s rate-limit policy" means precisely
753///
754/// [`RateLimited::detect`] is `c3`'s decision procedure for whether a failure is
755/// a rate limit and which of GitHub's two it is, including the part that keeps a
756/// permissions `403` landing on an exhausted quota from being misreported as
757/// one. It is public, and this module calls it rather than re-deciding.
758///
759/// What this module deliberately does **not** copy is `c3`'s in-gateway
760/// *back-off latch*, the window during which `RestInventory` opens no socket at
761/// all. A second latch would be a second, quietly divergent copy of a policy
762/// that only works if there is one of it. The scheduling floor is
763/// [`crate::rest::RefreshState::retry_delay`], which `c3` documents as "the
764/// **absolute floor** on when `e1` may try again" — an `e1` honouring it stops
765/// demand polling for the same window, from the layer that owns the schedule.
766///
767/// The residual gap is stated rather than hidden: a rate limit whose *first*
768/// evidence arrives on a demand request does not silence `RestInventory`, and
769/// vice versa. Both report [`InventoryError::RateLimited`] to `e1`, which is the
770/// layer that can act on either.
771pub struct RestDemand {
772 client: Arc<AuthenticatedClient>,
773 clock: Arc<dyn Clock>,
774 requests_issued: AtomicU64,
775 /// Every listing's `ETag` and body, so a repeat poll can be answered `304`.
776 conditional: ConditionalCache,
777 /// One repository's reading, shared between every target that covers it.
778 shared: SharedReadings,
779 /// The host-wide quiet period after GitHub said "rate limited".
780 hold: Mutex<Option<RateLimitHold>>,
781 stats: PollCounters,
782}
783
784/// A repository's reading, handed to every target that asks for it within a
785/// window.
786///
787/// # Why: one poll per repository, not one per target
788///
789/// The daemon runs one loop per target, and two targets can cover the same
790/// repository: an organization target and a repository target inside it.
791/// Each loop used to poll for itself. With one `RestDemand` shared by every
792/// loop on the host and a window here, a repository is read **once** per
793/// window however many targets, policies and profiles want it, and the others
794/// are handed that reading.
795///
796/// A zero window, the default, shares nothing: every call reads GitHub. That
797/// is what a single-target caller and the tests of the reading itself want.
798struct SharedReadings {
799 /// The window, in milliseconds. Atomic so the daemon can change it when
800 /// the host's idle interval changes, without stopping a loop.
801 window_ms: AtomicU64,
802 /// Pruned of readings older than [`HOLD_MEMORY`] on every insert.
803 readings: Mutex<BTreeMap<OwnerRepo, (Timestamp, RepositoryDemand)>>,
804 /// One gate per repository, so two loops asking at once make one request
805 /// between them.
806 gates: Mutex<BTreeMap<OwnerRepo, Arc<tokio::sync::Mutex<()>>>>,
807}
808
809/// How long an ended rate-limit hold is still remembered as part of the same
810/// episode, and how long an unused shared reading is kept.
811const HOLD_MEMORY: Duration = Duration::from_secs(60 * 60);
812
813/// The rate-limit quiet period, host-wide.
814#[derive(Debug, Clone, Copy)]
815struct RateLimitHold {
816 until: Timestamp,
817 limit: RateLimited,
818 /// Secondary limits in a row. GitHub: "If your request continues to fail
819 /// due to a secondary rate limit, wait for an exponentially increasing
820 /// amount of time between retries."
821 consecutive_secondary: u32,
822}
823
824#[derive(Debug, Default)]
825struct PollCounters {
826 reads: AtomicU64,
827 unchanged_reads: AtomicU64,
828 shared_reads: AtomicU64,
829 suppressed: AtomicU64,
830}
831
832/// What [`RestDemand`] has done, for `status` and `doctor`.
833#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
834pub struct DemandStats {
835 /// Repository readings fetched from GitHub.
836 pub reads: u64,
837 /// Of those, readings where every listing came back `304`.
838 pub unchanged_reads: u64,
839 /// Readings handed to another target instead of being fetched again.
840 pub shared_reads: u64,
841 /// Polls answered "rate limited" without a request, inside the host-wide
842 /// quiet period.
843 pub suppressed: u64,
844}
845
846impl fmt::Debug for RestDemand {
847 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
848 // Hand-written for the reason `AuthenticatedClient`'s own `Debug`
849 // records: nothing here may render a credential, and a derive on a type
850 // holding a client is how that is lost.
851 f.debug_struct("RestDemand")
852 .field(
853 "requests_issued",
854 &self.requests_issued.load(Ordering::Relaxed),
855 )
856 .finish_non_exhaustive()
857 }
858}
859
860impl RestDemand {
861 #[must_use]
862 pub fn new(client: Arc<AuthenticatedClient>, clock: Arc<dyn Clock>) -> Self {
863 Self {
864 conditional: ConditionalCache::new(Arc::clone(&clock)),
865 client,
866 clock,
867 requests_issued: AtomicU64::new(0),
868 shared: SharedReadings {
869 window_ms: AtomicU64::new(0),
870 readings: Mutex::new(BTreeMap::new()),
871 gates: Mutex::new(BTreeMap::new()),
872 },
873 hold: Mutex::new(None),
874 stats: PollCounters::default(),
875 }
876 }
877
878 /// Hand a repository's reading to every target that asks within `window`
879 /// of it being fetched. See [`SharedReadings`].
880 ///
881 /// The daemon passes its idle interval: a repository is then read at most
882 /// once per idle interval, and a target sharing it sees a reading no older
883 /// than that.
884 #[must_use]
885 pub fn with_shared_readings(self, window: Duration) -> Self {
886 self.set_shared_window(window);
887 self
888 }
889
890 /// Change the sharing window while running.
891 pub fn set_shared_window(&self, window: Duration) {
892 self.shared.window_ms.store(
893 u64::try_from(window.as_millis()).unwrap_or(u64::MAX),
894 Ordering::SeqCst,
895 );
896 }
897
898 /// Drops `repository`'s shared reading, so the next target to ask for it
899 /// reads GitHub instead of being handed a reading from before.
900 ///
901 /// For a coordinator's wake: GitHub just said this repository's queue
902 /// changed, so a reading shared from a few seconds ago is known stale.
903 /// Nothing else is bypassed — the conditional cache still makes an
904 /// unchanged listing a free `304`, and the rate-limit quiet period still
905 /// withholds every request inside it.
906 pub fn forget_shared_reading(&self, repository: &OwnerRepo) {
907 self.shared
908 .readings
909 .lock()
910 .unwrap_or_else(std::sync::PoisonError::into_inner)
911 .remove(repository);
912 }
913
914 /// The window readings are shared for.
915 fn shared_window(&self) -> Duration {
916 Duration::from_millis(self.shared.window_ms.load(Ordering::SeqCst))
917 }
918
919 /// What this gateway has done.
920 #[must_use]
921 pub fn stats(&self) -> DemandStats {
922 DemandStats {
923 reads: self.stats.reads.load(Ordering::SeqCst),
924 unchanged_reads: self.stats.unchanged_reads.load(Ordering::SeqCst),
925 shared_reads: self.stats.shared_reads.load(Ordering::SeqCst),
926 suppressed: self.stats.suppressed.load(Ordering::SeqCst),
927 }
928 }
929
930 /// The client every request goes through, for its traffic counters.
931 #[must_use]
932 pub const fn client(&self) -> &Arc<AuthenticatedClient> {
933 &self.client
934 }
935
936 /// The rate limit still being waited out, with the time left on it.
937 fn held(&self, now: Timestamp) -> Option<RateLimited> {
938 let hold = (*self
939 .hold
940 .lock()
941 .unwrap_or_else(std::sync::PoisonError::into_inner))?;
942 let left = hold
943 .until
944 .signed_duration_since(now)
945 .to_std()
946 .ok()?
947 // A clock that jumped backwards must not stretch the hold past
948 // the longest one this gateway ever sets.
949 .min(MAX_RATE_LIMIT_BACKOFF);
950 if left.is_zero() {
951 return None;
952 }
953 Some(RateLimited {
954 retry_after: Some(left),
955 ..hold.limit
956 })
957 }
958
959 /// Start, or lengthen, the host-wide quiet period for `limit`, and say
960 /// how long it is.
961 ///
962 /// # Why a quiet period here, when `e1` already waits
963 ///
964 /// `e1`'s schedule waits per loop, and the daemon runs one loop per
965 /// target. A secondary limit hit by one target's poll used to leave every
966 /// other target's loop polling on its own schedule, which is exactly the
967 /// "continuing to make requests while you are rate limited" GitHub warns
968 /// "may result in the banning of your integration"
969 /// (<https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#handle-rate-limit-errors-appropriately>).
970 /// Every loop shares this gateway, so a quiet period here silences them
971 /// all.
972 ///
973 /// The wait is GitHub's own: `retry-after` when sent, else until
974 /// `x-ratelimit-reset` for the primary limit, else one minute. A secondary
975 /// limit that recurs doubles it each time, up to
976 /// [`MAX_RATE_LIMIT_BACKOFF`], which is GitHub's "exponentially increasing
977 /// amount of time between retries".
978 fn hold_for(&self, limit: RateLimited, now: Timestamp) -> RateLimited {
979 let mut hold = self
980 .hold
981 .lock()
982 .unwrap_or_else(std::sync::PoisonError::into_inner);
983 // A hold that ended long ago is not part of this episode: a secondary
984 // limit days after the last one starts from one minute again, even if
985 // nothing but errors came back in between.
986 let previous = (*hold).filter(|previous| {
987 now.signed_duration_since(previous.until)
988 < chrono::TimeDelta::from_std(HOLD_MEMORY).unwrap_or(chrono::TimeDelta::MAX)
989 });
990 // The doubling counts EPISODES, not answers. Every target loop shares
991 // this gateway, so one secondary limit can come back on several
992 // requests that were in flight together; each of those is the same
993 // limit, and only an answer arriving after the previous hold ended is
994 // GitHub saying "still limited".
995 let live = previous.filter(|previous| previous.until > now);
996 let consecutive_secondary = match (limit.kind, previous, live) {
997 (RateLimitKind::Primary, _, _) => 0,
998 (RateLimitKind::Secondary, _, Some(live)) => live.consecutive_secondary.max(1),
999 (RateLimitKind::Secondary, Some(previous), None) => {
1000 previous.consecutive_secondary.saturating_add(1)
1001 }
1002 (RateLimitKind::Secondary, None, None) => 1,
1003 };
1004 let mut wait = limit.delay_from(now);
1005 if consecutive_secondary > 1 {
1006 let doubled = DEFAULT_RATE_LIMIT_BACKOFF
1007 .saturating_mul(1_u32 << (consecutive_secondary - 1).min(10));
1008 wait = wait.max(doubled).min(MAX_RATE_LIMIT_BACKOFF);
1009 }
1010 let mut until = now
1011 .checked_add_signed(chrono::TimeDelta::from_std(wait).unwrap_or(chrono::TimeDelta::MAX))
1012 .unwrap_or(now);
1013 // A later answer never SHORTENS a hold in force: a primary limit with
1014 // a near reset must not reopen traffic a secondary limit closed.
1015 if let Some(live) = live
1016 && live.until > until
1017 {
1018 until = live.until;
1019 wait = until.signed_duration_since(now).to_std().unwrap_or(wait);
1020 }
1021 *hold = Some(RateLimitHold {
1022 until,
1023 limit,
1024 consecutive_secondary: consecutive_secondary
1025 .max(live.map_or(0, |live| live.consecutive_secondary)),
1026 });
1027 RateLimited {
1028 retry_after: Some(wait),
1029 ..limit
1030 }
1031 }
1032
1033 /// A request went through: the next secondary limit starts the doubling
1034 /// again from one minute.
1035 fn clear_hold(&self) {
1036 let mut hold = self
1037 .hold
1038 .lock()
1039 .unwrap_or_else(std::sync::PoisonError::into_inner);
1040 if hold.is_some_and(|h| h.until <= self.clock.now()) {
1041 *hold = None;
1042 }
1043 }
1044
1045 /// How many HTTP requests this gateway has issued.
1046 ///
1047 /// [`demand_requests_per_poll`] projects a cost and this measures it. A
1048 /// projection nothing measures is a table in a document, which is why `c3`
1049 /// exposes the same counter and why the tests below assert one against the
1050 /// other.
1051 #[must_use]
1052 pub fn requests_issued(&self) -> u64 {
1053 self.requests_issued.load(Ordering::SeqCst)
1054 }
1055
1056 /// One request, with cancellation applied and a rate limit recognised.
1057 ///
1058 /// Cancellation is consulted twice for `c3`'s reason, and the two are not
1059 /// redundant: [`CancelToken::check`] stops a multi-page walk *between*
1060 /// pages, and [`CancelToken::run`] stops one already blocked on a socket.
1061 ///
1062 /// Every request is **conditional** once its listing has been seen: see
1063 /// [`crate::conditional`]. And none is sent at all inside the host-wide
1064 /// rate-limit quiet period; see [`Self::hold_for`].
1065 async fn get(
1066 &self,
1067 request: &ApiRequest,
1068 cancel: &CancelToken,
1069 ) -> Result<Fetched, InventoryError> {
1070 cancel.check()?;
1071 if let Some(limit) = self.held(self.clock.now()) {
1072 self.stats.suppressed.fetch_add(1, Ordering::SeqCst);
1073 return Err(InventoryError::RateLimited(limit));
1074 }
1075
1076 let result = cancel
1077 .run(async {
1078 // Counted inside the future, so the count is of requests
1079 // actually attempted: `run`'s biased `select!` answers
1080 // `Cancelled` without polling this block when the token is
1081 // already flipped, and no socket is opened.
1082 self.requests_issued.fetch_add(1, Ordering::SeqCst);
1083 self.conditional
1084 .send(&self.client, request)
1085 .await
1086 .map_err(InventoryError::from)
1087 })
1088 .await;
1089 match result {
1090 Ok(fetched) => {
1091 self.clear_hold();
1092 Ok(fetched)
1093 }
1094 Err(InventoryError::Github(error)) => Err(self.classify(error)),
1095 Err(other) => Err(other),
1096 }
1097 }
1098
1099 /// Turn a failure into a rate limit when GitHub's own evidence says it is
1100 /// one, using `c3`'s decision procedure rather than a second one, and
1101 /// start the host-wide quiet period for it.
1102 fn classify(&self, error: GithubError) -> InventoryError {
1103 let Some(limit) = RateLimited::detect(&error) else {
1104 return InventoryError::Github(error);
1105 };
1106 let held = self.hold_for(limit, self.clock.now());
1107 tracing::warn!(
1108 kind = %limit.kind,
1109 remaining = limit.remaining,
1110 quiet_secs = held.retry_after.map_or(0, |wait| wait.as_secs()),
1111 "GitHub is rate limiting this credential; demand for this poll is unknown, not zero, \
1112 and no demand request leaves this host until the quiet period ends"
1113 );
1114 InventoryError::RateLimited(held)
1115 }
1116
1117 /// Queued **jobs** for one repository, each with the `runs-on` it requires.
1118 ///
1119 /// Two run listings, then one job listing per run either cap admits. See
1120 /// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for what that costs in steady
1121 /// state and [`max_demand_requests_per_repository_per_poll`] for the bound.
1122 ///
1123 /// # The order of the two passes is load-bearing
1124 ///
1125 /// Queued runs are resolved first and in-progress runs second, because the
1126 /// caps are spent in that order and the first pass carries the signal. A
1127 /// repository busy enough to exhaust the queued cap gets no in-progress pass
1128 /// at all, which is the right trade: its count is already a floor above any
1129 /// realistic host ceiling, and one more `needs:`-gated job cannot change the
1130 /// allocation.
1131 ///
1132 /// # Why a run is resolved at most once, though the two queries are disjoint
1133 ///
1134 /// They are disjoint *at any one instant*, and these are two requests with
1135 /// time between them. A run that is `queued` when the first is answered and
1136 /// `in_progress` when the second is — which is precisely what happens when
1137 /// its last waiting job gets picked up, so it is the common transition
1138 /// rather than an exotic one — appears in **both** lists. Listing its jobs
1139 /// twice would count every queued job it still holds twice, and demand that
1140 /// double-counts starts runners for work that does not exist.
1141 ///
1142 /// The seen set is what stops that. It is deliberately not an argument that
1143 /// the race is too narrow to matter: the window is one HTTP round trip
1144 /// against a poll that repeats every sixty seconds forever, and the failure
1145 /// it produces is silent inflation rather than an error.
1146 async fn repository_queued(
1147 &self,
1148 repository: &OwnerRepo,
1149 cancel: &CancelToken,
1150 ) -> Result<RepositoryDemand, InventoryError> {
1151 let mut jobs: Vec<(RunsOn, QueuedJobIdentity)> = Vec::new();
1152 let mut exact = true;
1153 let mut changed = false;
1154 let mut resolved: BTreeSet<u64> = BTreeSet::new();
1155 let mut run_workflows = BTreeMap::new();
1156
1157 for (status, cap) in [
1158 (QUEUED_RUN_STATUS, MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL),
1159 (
1160 IN_PROGRESS_RUN_STATUS,
1161 MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
1162 ),
1163 ] {
1164 let listing = self.active_runs(repository, status, cap, cancel).await?;
1165 exact &= listing.complete;
1166 changed |= listing.changed;
1167 run_workflows.extend(listing.workflows);
1168
1169 for run_id in listing.run_ids {
1170 // A run that changed status between the two listings is in both.
1171 // Resolving it twice would double every queued job it holds.
1172 if !resolved.insert(run_id) {
1173 continue;
1174 }
1175 let run = self.queued_jobs_of_run(repository, run_id, cancel).await?;
1176 exact &= run.complete;
1177 changed |= run.changed;
1178 let workflow = run_workflows.get(&run_id);
1179 jobs.extend(run.jobs.into_iter().map(|(job, mut identity)| {
1180 identity.workflow = workflow.cloned();
1181 (job, identity)
1182 }));
1183 }
1184 }
1185
1186 self.stats.reads.fetch_add(1, Ordering::SeqCst);
1187 if !changed {
1188 self.stats.unchanged_reads.fetch_add(1, Ordering::SeqCst);
1189 }
1190 let (jobs, identities) = jobs.into_iter().unzip();
1191 Ok(RepositoryDemand {
1192 jobs,
1193 identities,
1194 run_workflows,
1195 exact,
1196 busy: changed && !resolved.is_empty(),
1197 })
1198 }
1199
1200 /// One repository's reading, from the shared window when another target
1201 /// fetched it recently enough, from GitHub otherwise.
1202 async fn repository_reading(
1203 &self,
1204 repository: &OwnerRepo,
1205 cancel: &CancelToken,
1206 ) -> Result<RepositoryDemand, InventoryError> {
1207 let window = self.shared_window();
1208 if window.is_zero() {
1209 return self.repository_queued(repository, cancel).await;
1210 }
1211 let gate = Arc::clone(
1212 self.shared
1213 .gates
1214 .lock()
1215 .unwrap_or_else(std::sync::PoisonError::into_inner)
1216 .entry(repository.clone())
1217 .or_default(),
1218 );
1219 // Held across the fetch, so a second loop asking for this repository
1220 // meanwhile waits for this reading instead of making its own request.
1221 let _gate = cancel.run(async { Ok(gate.lock().await) }).await?;
1222 let now = self.clock.now();
1223 let fresh = self
1224 .shared
1225 .readings
1226 .lock()
1227 .unwrap_or_else(std::sync::PoisonError::into_inner)
1228 .get(repository)
1229 .filter(|(at, _)| {
1230 now.signed_duration_since(*at)
1231 .to_std()
1232 .is_ok_and(|age| age < window)
1233 })
1234 .map(|(_, reading)| reading.clone());
1235 if let Some(reading) = fresh {
1236 self.stats.shared_reads.fetch_add(1, Ordering::SeqCst);
1237 return Ok(reading);
1238 }
1239 let reading = self.repository_queued(repository, cancel).await?;
1240 let mut readings = self
1241 .shared
1242 .readings
1243 .lock()
1244 .unwrap_or_else(std::sync::PoisonError::into_inner);
1245 readings.insert(repository.clone(), (now, reading.clone()));
1246 // A repository that left an organization's list stops being asked
1247 // for; its reading goes once nobody could use it.
1248 readings.retain(|_, (at, _)| {
1249 now.signed_duration_since(*at)
1250 .to_std()
1251 .is_ok_and(|age| age < HOLD_MEMORY)
1252 });
1253 Ok(reading)
1254 }
1255
1256 /// The ids of one repository's runs in `status`, up to `cap`.
1257 ///
1258 /// **One request, never a page walk.** Both caps are far below [`PER_PAGE`],
1259 /// so the first page always carries more run ids than this may use, and a
1260 /// second page could only contain runs that are already past the cap. That
1261 /// is the whole reason the caps are stated in *runs* rather than in pages:
1262 /// it turns the run listing back into the fixed one-request cost the
1263 /// previous owner decision valued, and spends the variable cost only where
1264 /// it buys the job-level count.
1265 async fn active_runs(
1266 &self,
1267 repository: &OwnerRepo,
1268 status: &str,
1269 cap: usize,
1270 cancel: &CancelToken,
1271 ) -> Result<RunListing, InventoryError> {
1272 let request = ApiRequest::get(format!(
1273 "/repos/{}/{}/actions/runs",
1274 repository.owner(),
1275 repository.repo()
1276 ))
1277 .query("status", status)
1278 .query("per_page", PER_PAGE);
1279
1280 let Fetched {
1281 response,
1282 not_modified,
1283 } = self.get(&request, cancel).await?;
1284 let has_next_page = response.next_page().is_some();
1285 let page: QueuedRunsPage = response.json()?;
1286
1287 let listed = page.workflow_runs.len();
1288 if let Some(total) = page.total_count
1289 && !has_next_page
1290 && total != listed as u64
1291 {
1292 // Free, and no longer load-bearing: see `MAX_BENIGN_TOTAL_COUNT_SKEW`
1293 // for why this is a log line and not the `debug_assert!` it was
1294 // while `total_count` *was* the demand number. The two levels draw
1295 // the same distinction that constant does. A handful over is the
1296 // documented race of a run leaving the queue mid-serialisation,
1297 // which this module's own documentation calls legitimate — warning
1298 // on it every poll is how an operator learns to ignore the warning.
1299 // Thousands over is the unfiltered lifetime total, which is a real
1300 // finding about the API.
1301 if total > (listed as u64).saturating_add(MAX_BENIGN_TOTAL_COUNT_SKEW) {
1302 tracing::warn!(
1303 repository = %repository,
1304 status,
1305 total_count = total,
1306 listed,
1307 "GitHub's `total_count` is far larger than the single page it sent for \
1308 a filtered query, so it is not the filtered count. Demand is counted \
1309 from the jobs and does not depend on this field, but `c3` reads the \
1310 same envelope for a dashboard number and does"
1311 );
1312 } else {
1313 tracing::debug!(
1314 repository = %repository,
1315 status,
1316 total_count = total,
1317 listed,
1318 "GitHub's `total_count` disagrees slightly with the page it arrived \
1319 with; this is the documented race of a run leaving the queue while \
1320 the response was being built"
1321 );
1322 }
1323 }
1324
1325 let run_ids: Vec<u64> = page
1326 .workflow_runs
1327 .iter()
1328 .take(cap)
1329 .map(|run| run.id)
1330 .collect();
1331 let workflows = page
1332 .workflow_runs
1333 .into_iter()
1334 .filter_map(|run| Some((run.id, run.path?)))
1335 .collect();
1336
1337 Ok(RunListing {
1338 // More runs exist than this pass will resolve, so the job count it
1339 // produces is a floor. Both conditions matter: `listed > cap` is the
1340 // ordinary case, and `has_next_page` catches a repository whose
1341 // first page was itself short of the whole filtered set.
1342 complete: listed <= cap && !has_next_page,
1343 run_ids,
1344 workflows,
1345 changed: !not_modified,
1346 })
1347 }
1348
1349 /// One run's jobs that are still waiting for a runner.
1350 ///
1351 /// Jobs in any other state are dropped here rather than downstream, because
1352 /// "queued" is what makes a job demand and a caller holding a mixed list
1353 /// would have to re-derive that. The `runs-on` is rebuilt from the job's
1354 /// `labels` array — which is the array form, so [`RunsOn::from_job_labels`]
1355 /// is the constructor `b1` provides for exactly this.
1356 async fn queued_jobs_of_run(
1357 &self,
1358 repository: &OwnerRepo,
1359 run_id: u64,
1360 cancel: &CancelToken,
1361 ) -> Result<RunJobs, InventoryError> {
1362 let mut request = Some(
1363 ApiRequest::get(format!(
1364 "/repos/{}/{}/actions/runs/{run_id}/jobs",
1365 repository.owner(),
1366 repository.repo()
1367 ))
1368 .query("filter", LATEST_JOBS_FILTER)
1369 .query("per_page", PER_PAGE),
1370 );
1371
1372 let mut jobs = Vec::new();
1373 let mut pages = 0_usize;
1374 let mut changed = false;
1375
1376 while let Some(next) = request.take() {
1377 if pages >= MAX_JOB_PAGES_PER_RUN {
1378 tracing::warn!(
1379 repository = %repository,
1380 run_id,
1381 pages,
1382 "stopped listing one run's jobs at the page budget; the queued-job \
1383 count for this repository is a floor, not a total"
1384 );
1385 return Ok(RunJobs {
1386 jobs,
1387 complete: false,
1388 changed,
1389 });
1390 }
1391
1392 let Fetched {
1393 response,
1394 not_modified,
1395 } = self.get(&next, cancel).await?;
1396 changed |= !not_modified;
1397 let following = response
1398 .next_page()
1399 .map(|url| ApiRequest::get(url.as_str()));
1400 let page: RunJobsPage = response.json()?;
1401 pages += 1;
1402
1403 jobs.extend(
1404 page.jobs
1405 .into_iter()
1406 .filter(|job| job.status == QUEUED_JOB_STATUS)
1407 .map(|job| {
1408 (
1409 RunsOn::from_job_labels(job.labels),
1410 QueuedJobIdentity {
1411 id: job.id,
1412 name: job.name,
1413 workflow: None,
1414 },
1415 )
1416 }),
1417 );
1418
1419 request = following;
1420 }
1421
1422 Ok(RunJobs {
1423 jobs,
1424 complete: true,
1425 changed,
1426 })
1427 }
1428}
1429
1430/// Whether a per-repository failure should be recorded and stepped over, or
1431/// should abort the whole aggregate.
1432///
1433/// The same line `c3` draws in its private `is_repository_local_failure`, and
1434/// repeated here rather than shared because that function lives in a file this
1435/// task does not own. The line is between a fact about *that repository* — a
1436/// `404` for one deleted or renamed, a plain `403` for one with Actions disabled
1437/// — and a fact about the credential or the connection, which stepping over
1438/// would turn into a total short by an unknown amount while looking complete.
1439///
1440/// It applies to an aggregate only. Stepping over the only repository in scope
1441/// would turn a permissions failure into demand `0`, and `e1` would then read
1442/// "nothing queued" for a target it cannot see at all — so the caller checks
1443/// [`TargetScope`] first.
1444fn is_repository_local_failure(error: &InventoryError) -> bool {
1445 match error {
1446 InventoryError::Github(GithubError::Forbidden { .. }) => true,
1447 InventoryError::Github(GithubError::Status { status, .. }) => *status == 404,
1448 _ => false,
1449 }
1450}
1451
1452#[async_trait::async_trait]
1453impl DemandGateway for RestDemand {
1454 async fn queued_demand(
1455 &self,
1456 scope: &ActivityScope,
1457 cancel: &CancelToken,
1458 ) -> Result<QueuedDemand, InventoryError> {
1459 let mut demand = QueuedDemand::default();
1460 // Only an aggregate steps over a bad repository; see
1461 // `is_repository_local_failure`.
1462 let aggregating = scope.target().scope() == TargetScope::Organization;
1463 self.conditional.evict_idle();
1464
1465 for repository in scope.repositories() {
1466 match self.repository_reading(repository, cancel).await {
1467 Ok(reading) => {
1468 if reading.busy {
1469 demand.busy.insert(repository.clone());
1470 }
1471 demand
1472 .per_repository
1473 .insert(repository.clone(), reading.jobs);
1474 demand
1475 .identities
1476 .insert(repository.clone(), reading.identities);
1477 demand
1478 .run_workflows
1479 .insert(repository.clone(), reading.run_workflows);
1480 if !reading.exact {
1481 // A floor travels with the aggregate rather than being
1482 // flattened into it: one truncated repository makes the
1483 // total a floor too, and nothing downstream has another
1484 // way to know.
1485 demand.truncated.insert(repository.clone());
1486 }
1487 }
1488 Err(error) if aggregating && is_repository_local_failure(&error) => {
1489 tracing::warn!(
1490 repository = %repository,
1491 error = %error,
1492 "a repository in this organization could not be polled for demand; \
1493 the aggregate reports it as unavailable rather than as zero"
1494 );
1495 demand.unavailable.push(UnavailableRepository {
1496 repository: repository.clone(),
1497 reason: error.to_string(),
1498 });
1499 }
1500 Err(error) => return Err(error),
1501 }
1502 }
1503
1504 demand.rate_limit = self.client.traffic().latest_rate_limit();
1505 Ok(demand)
1506 }
1507
1508 async fn run_workflow(
1509 &self,
1510 repository: &OwnerRepo,
1511 run_id: u64,
1512 cancel: &CancelToken,
1513 ) -> Result<Option<String>, InventoryError> {
1514 let request = ApiRequest::get(format!(
1515 "/repos/{}/{}/actions/runs/{run_id}",
1516 repository.owner(),
1517 repository.repo()
1518 ));
1519 let Fetched { response, .. } = self.get(&request, cancel).await?;
1520 let run: QueuedRun = response.json()?;
1521 Ok(run.path)
1522 }
1523
1524 fn now(&self) -> Timestamp {
1525 self.clock.now()
1526 }
1527}
1528
1529// ---------------------------------------------------------------------------
1530// Wire shapes
1531// ---------------------------------------------------------------------------
1532
1533/// Which job a queued entry is, as GitHub's job listing names it: the same id
1534/// and name a `workflow_job` webhook carries. Not its `workflow_name`, which is
1535/// the run's title and changes at every dispatch of a workflow with a `run-name`.
1536#[derive(Debug, Clone, PartialEq, Eq)]
1537pub struct QueuedJobIdentity {
1538 pub id: Option<u64>,
1539 /// The job's name, matrix values expanded.
1540 pub name: Option<String>,
1541 /// Its run's workflow file (`.github/workflows/ci.yml`), which, unlike the
1542 /// title, does not change between dispatches.
1543 pub workflow: Option<String>,
1544}
1545
1546/// One repository's queued jobs, and whether that set is the whole truth.
1547#[derive(Debug, Clone, PartialEq, Eq)]
1548struct RepositoryDemand {
1549 /// The `runs-on` of every job still waiting for a runner.
1550 jobs: Vec<RunsOn>,
1551 /// Which job each of `jobs` is, in the same order.
1552 identities: Vec<QueuedJobIdentity>,
1553 /// The workflow file of each run listed.
1554 run_workflows: BTreeMap<u64, String>,
1555 /// `false` when `jobs` is a **floor**: a run cap or a job page budget
1556 /// stopped the walk before the whole queue had been seen.
1557 exact: bool,
1558 /// Runs were under way and at least one listing changed since the last
1559 /// poll. See [`QueuedDemand::is_quiet`].
1560 busy: bool,
1561}
1562
1563/// One run listing's ids, and whether the cap left any behind.
1564#[derive(Debug, Clone, PartialEq, Eq)]
1565struct RunListing {
1566 run_ids: Vec<u64>,
1567 workflows: BTreeMap<u64, String>,
1568 complete: bool,
1569 /// `false` when GitHub answered `304`.
1570 changed: bool,
1571}
1572
1573/// One run's queued jobs, and whether the page budget saw all of them.
1574#[derive(Debug, Clone, PartialEq, Eq)]
1575struct RunJobs {
1576 jobs: Vec<(RunsOn, QueuedJobIdentity)>,
1577 complete: bool,
1578 /// `false` when every page was answered `304`.
1579 changed: bool,
1580}
1581
1582/// One page of `GET …/actions/runs?status=…`.
1583///
1584/// Only the `id` is read, and it is read because the job listing needs it. That
1585/// is the field the previous owner decision deliberately did not have — the
1586/// module documentation used to point at `jobs_url` and ask nobody to follow it
1587/// — and reversing that decision is what put it here.
1588#[derive(Debug, Deserialize)]
1589struct QueuedRunsPage {
1590 total_count: Option<u64>,
1591 #[serde(default)]
1592 workflow_runs: Vec<QueuedRun>,
1593}
1594
1595/// One workflow run, reduced to the identifier its jobs are fetched by and its
1596/// workflow file.
1597#[derive(Debug, Deserialize)]
1598struct QueuedRun {
1599 id: u64,
1600 #[serde(default)]
1601 path: Option<String>,
1602}
1603
1604/// One page of `GET …/actions/runs/{run_id}/jobs`.
1605#[derive(Debug, Deserialize)]
1606struct RunJobsPage {
1607 #[serde(default)]
1608 jobs: Vec<RunJob>,
1609}
1610
1611/// One job of a run: whether it is still waiting, and what it needs.
1612///
1613/// `labels` is GitHub's flat array form of `runs-on`, which is why
1614/// [`RunsOn::from_job_labels`] exists on `b1`'s side. It defaults to empty
1615/// rather than being required, because a job with no labels is a real answer —
1616/// `b1` reports it as [`runner_manager_domain::policy::UnresolvableRunsOn`] —
1617/// and a missing field should not fail the whole repository's poll.
1618#[derive(Debug, Deserialize)]
1619struct RunJob {
1620 #[serde(default)]
1621 id: Option<u64>,
1622 #[serde(default)]
1623 status: String,
1624 #[serde(default)]
1625 labels: Vec<String>,
1626 #[serde(default)]
1627 name: Option<String>,
1628}
1629
1630// The unit tests below are inline rather than in a `src/demand/tests.rs`, and
1631// that is a constraint rather than a preference: `lib.rs`'s
1632// `the_confidential_credential_scan_covers_every_source_file` walks `src/`
1633// recursively and requires every `.rs` file under it to appear in
1634// `CRATE_SOURCES` — a list in `lib.rs`, which `c2` owns. A second file in this
1635// directory would fail that pin, and the only fix would be editing another
1636// task's file.
1637#[cfg(test)]
1638mod tests {
1639 use super::*;
1640 use crate::testing::{FIXTURE_TOKEN, TestClock};
1641 use crate::{Endpoints, UserAccessToken};
1642 use runner_manager_domain::{
1643 model::{Arch, HostLabel, Label, Org, Os},
1644 policy::{RoutingLabels, RunsOn, RunsOnMatch, UnresolvableRunsOn},
1645 };
1646 use secrecy::SecretString;
1647 use serde_json::json;
1648 use wiremock::{
1649 Mock, MockServer, ResponseTemplate,
1650 matchers::{method, path, query_param},
1651 };
1652
1653 fn repo() -> OwnerRepo {
1654 OwnerRepo::parse("octo/dashboard").expect("a valid owner/repo")
1655 }
1656
1657 fn other_repo() -> OwnerRepo {
1658 OwnerRepo::parse("octo/api").expect("a valid owner/repo")
1659 }
1660
1661 fn third_repo() -> OwnerRepo {
1662 OwnerRepo::parse("octo/tools").expect("a valid owner/repo")
1663 }
1664
1665 fn org_scope(repositories: impl IntoIterator<Item = OwnerRepo>) -> ActivityScope {
1666 ActivityScope::organization(
1667 Org::new("octo-org").expect("a valid organization login"),
1668 repositories,
1669 )
1670 }
1671
1672 fn gateway(server: &MockServer) -> RestDemand {
1673 let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
1674 let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
1675 let client = AuthenticatedClient::new(endpoints, token, Arc::new(TestClock::default()))
1676 .expect("a client over the test server");
1677 RestDemand::new(Arc::new(client), Arc::new(TestClock::default()))
1678 }
1679
1680 // -- fixtures -----------------------------------------------------------
1681
1682 /// A run list carrying `ids`, and a `total_count` that agrees with it.
1683 fn runs_body(ids: &[u64]) -> serde_json::Value {
1684 json!({
1685 "total_count": ids.len(),
1686 "workflow_runs": ids.iter().map(|id| json!({ "id": id })).collect::<Vec<_>>()
1687 })
1688 }
1689
1690 /// An empty run list, which is what an idle repository answers.
1691 fn no_runs() -> serde_json::Value {
1692 runs_body(&[])
1693 }
1694
1695 /// A jobs page: `queued` jobs carrying `labels`, then `running` that do not
1696 /// count.
1697 fn jobs_body(labels: &[&str], queued: usize, running: usize) -> serde_json::Value {
1698 let mut jobs = Vec::new();
1699 for _ in 0..queued {
1700 jobs.push(json!({ "status": "queued", "labels": labels }));
1701 }
1702 for _ in 0..running {
1703 jobs.push(json!({ "status": "in_progress", "labels": labels }));
1704 }
1705 json!({ "total_count": jobs.len(), "jobs": jobs })
1706 }
1707
1708 fn runs_path(repository: &OwnerRepo) -> String {
1709 format!(
1710 "/repos/{}/{}/actions/runs",
1711 repository.owner(),
1712 repository.repo()
1713 )
1714 }
1715
1716 fn jobs_path(repository: &OwnerRepo, run_id: u64) -> String {
1717 format!("{}/{run_id}/jobs", runs_path(repository))
1718 }
1719
1720 /// Mount one repository's run list for one status filter.
1721 async fn mount_runs(
1722 server: &MockServer,
1723 repository: &OwnerRepo,
1724 status: &str,
1725 body: serde_json::Value,
1726 ) {
1727 Mock::given(method("GET"))
1728 .and(path(runs_path(repository)))
1729 .and(query_param("status", status))
1730 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1731 .mount(server)
1732 .await;
1733 }
1734
1735 /// Mount one run's job list.
1736 async fn mount_jobs(
1737 server: &MockServer,
1738 repository: &OwnerRepo,
1739 run_id: u64,
1740 body: serde_json::Value,
1741 ) {
1742 Mock::given(method("GET"))
1743 .and(path(jobs_path(repository, run_id)))
1744 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1745 .mount(server)
1746 .await;
1747 }
1748
1749 /// The ordinary shape: one queued run of `queued` + `running` jobs, and an
1750 /// empty in-progress list.
1751 async fn mount_one_queued_run(
1752 server: &MockServer,
1753 repository: &OwnerRepo,
1754 labels: &[&str],
1755 queued: usize,
1756 running: usize,
1757 ) {
1758 mount_runs(server, repository, QUEUED_RUN_STATUS, runs_body(&[100])).await;
1759 mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1760 mount_jobs(server, repository, 100, jobs_body(labels, queued, running)).await;
1761 }
1762
1763 /// A repository with nothing waiting: two run listings, no job listings.
1764 async fn mount_idle(server: &MockServer, repository: &OwnerRepo) {
1765 mount_runs(server, repository, QUEUED_RUN_STATUS, no_runs()).await;
1766 mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1767 }
1768
1769 /// The routing labels of a realistic policy on this host.
1770 ///
1771 /// Deliberately **not** a bare [`RoutingLabels::derive`]. That produces the
1772 /// derived host label alone, and a job written `runs-on: [self-hosted,
1773 /// windows]` — the shape people actually write — requires labels a bare
1774 /// derived set does not carry, so it would not match. An operator adds those
1775 /// with `repo add --label`, and a fixture that skipped them would test the
1776 /// filtering against a policy nobody configures.
1777 fn host_labels() -> RoutingLabels {
1778 RoutingLabels::from_parts(
1779 Label::new("rm-home-win-x64").expect("a valid label"),
1780 [
1781 Label::new("self-hosted").expect("a valid label"),
1782 Label::new("windows").expect("a valid label"),
1783 ],
1784 )
1785 }
1786
1787 // -- the count itself ---------------------------------------------------
1788
1789 /// The defect this module was rewritten for, as an executable statement.
1790 ///
1791 /// One queued run holding eight matrix jobs. Under the previous owner
1792 /// decision this repository reported demand `1`, `e1` started one runner,
1793 /// and the other seven jobs waited for a machine that was sitting idle. It
1794 /// must now report `8`.
1795 #[tokio::test]
1796 async fn a_matrix_run_of_eight_jobs_is_eight_units_of_demand_and_not_one() {
1797 let server = MockServer::start().await;
1798 mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 8, 0).await;
1799 let gateway = gateway(&server);
1800
1801 let demand = gateway
1802 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1803 .await
1804 .expect("a queued-job count");
1805
1806 assert_eq!(
1807 demand.total(),
1808 8,
1809 "eight jobs in one run are eight runners' worth of work; reading the run \
1810 count here is the defect that forced the owner decision back"
1811 );
1812 assert_eq!(demand.for_repository(&repo()), Some(8));
1813 assert!(demand.is_complete());
1814 assert_eq!(
1815 gateway.requests_issued(),
1816 3,
1817 "two run listings and one job listing for the single active run"
1818 );
1819 }
1820
1821 /// A job that already has a runner is not demand.
1822 ///
1823 /// The run-level mistake one level down: `status=in_progress` work already
1824 /// has a runner, and counting it would start a second for the same job.
1825 #[tokio::test]
1826 async fn only_jobs_still_queued_are_counted() {
1827 let server = MockServer::start().await;
1828 mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 5).await;
1829 let gateway = gateway(&server);
1830
1831 let demand = gateway
1832 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1833 .await
1834 .expect("a queued-job count");
1835
1836 assert_eq!(
1837 demand.total(),
1838 3,
1839 "five of the eight jobs already have a runner and are not waiting for one"
1840 );
1841 }
1842
1843 /// The labels travel with the job, which is the input `b1`'s predicate never
1844 /// had.
1845 #[tokio::test]
1846 async fn each_queued_job_carries_the_runs_on_it_requires() {
1847 let server = MockServer::start().await;
1848 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100, 101])).await;
1849 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
1850 mount_jobs(
1851 &server,
1852 &repo(),
1853 100,
1854 jobs_body(&["rm-home-win-x64", "self-hosted", "windows"], 2, 0),
1855 )
1856 .await;
1857 mount_jobs(&server, &repo(), 101, jobs_body(&["ubuntu-latest"], 4, 0)).await;
1858 let gateway = gateway(&server);
1859
1860 let demand = gateway
1861 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1862 .await
1863 .expect("a queued-job count");
1864
1865 assert_eq!(
1866 demand.total(),
1867 6,
1868 "the unfiltered depth is every queued job"
1869 );
1870
1871 // And the number `e1` clamps, which is the unfiltered depth put through
1872 // `b1`'s predicate. This gateway does not compute it; it supplies it.
1873 let tally = host_labels().tally(demand.jobs_for(&repo()));
1874 assert_eq!(
1875 tally.demand(),
1876 2,
1877 "the four `ubuntu-latest` jobs are somebody else's work; before the job \
1878 listing existed all six would have driven this policy toward max_capacity"
1879 );
1880 assert_eq!(tally.not_matched, 4);
1881 }
1882
1883 /// The two run statuses are both on the wire, and the job filter with them.
1884 ///
1885 /// The queries are one word apart and answer different questions, so a
1886 /// gateway that sent the wrong one would return a plausible number. The
1887 /// mocks match on the exact status, so sending anything else 404s here
1888 /// rather than passing.
1889 #[tokio::test]
1890 async fn both_run_statuses_are_polled_and_the_jobs_request_asks_for_the_latest_attempt() {
1891 let server = MockServer::start().await;
1892 mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
1893 let gateway = gateway(&server);
1894
1895 gateway
1896 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1897 .await
1898 .expect("both filters are mounted");
1899
1900 let requests = server.received_requests().await.expect("recorded requests");
1901 assert_eq!(requests.len(), 3);
1902
1903 let queries: Vec<String> = requests
1904 .iter()
1905 .map(|r| r.url.query().unwrap_or_default().to_string())
1906 .collect();
1907
1908 assert!(
1909 queries.iter().any(|q| q.contains("status=queued")),
1910 "the primary signal is the queued run list; sent {queries:?}"
1911 );
1912 assert!(
1913 queries.iter().any(|q| q.contains("status=in_progress")),
1914 "the safety net catches a `needs:`-gated job whose run has already \
1915 started; sent {queries:?}"
1916 );
1917 assert!(
1918 queries
1919 .iter()
1920 .any(|q| q.contains(&format!("filter={LATEST_JOBS_FILTER}"))),
1921 "`filter=all` would count every attempt of a re-run job as present \
1922 demand; sent {queries:?}"
1923 );
1924 assert!(
1925 queries
1926 .iter()
1927 .all(|q| q.contains(&format!("per_page={PER_PAGE}"))),
1928 "asking for fewer than GitHub's maximum multiplies the request count \
1929 against the budget this module projects; sent {queries:?}"
1930 );
1931 }
1932
1933 /// The queued run list is read before the in-progress one.
1934 ///
1935 /// The order is what spends the caps on the signal rather than on the safety
1936 /// net, and it is the kind of thing that is only true until somebody
1937 /// reorders a literal.
1938 #[tokio::test]
1939 async fn the_queued_run_list_is_read_before_the_in_progress_one() {
1940 let server = MockServer::start().await;
1941 mount_idle(&server, &repo()).await;
1942 let gateway = gateway(&server);
1943
1944 gateway
1945 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1946 .await
1947 .expect("an idle repository still answers");
1948
1949 let requests = server.received_requests().await.expect("recorded requests");
1950 let queries: Vec<String> = requests
1951 .iter()
1952 .map(|r| r.url.query().unwrap_or_default().to_string())
1953 .collect();
1954 assert_eq!(queries.len(), 2, "an idle repository lists no run's jobs");
1955 assert!(
1956 queries[0].contains("status=queued"),
1957 "the primary signal is read first; sent {queries:?}"
1958 );
1959 assert!(
1960 queries[1].contains("status=in_progress"),
1961 "the safety net is read second; sent {queries:?}"
1962 );
1963 }
1964
1965 /// An idle repository costs the two listings and nothing more.
1966 ///
1967 /// The steady-state figure the budget model prices, asserted against what
1968 /// the gateway really spends rather than left as a claim in a doc comment.
1969 #[tokio::test]
1970 async fn an_idle_repository_costs_only_the_two_run_listings() {
1971 let server = MockServer::start().await;
1972 mount_idle(&server, &repo()).await;
1973 let gateway = gateway(&server);
1974
1975 let demand = gateway
1976 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1977 .await
1978 .expect("an idle repository answers zero rather than failing");
1979
1980 assert_eq!(demand.total(), 0);
1981 assert!(
1982 demand.is_complete(),
1983 "zero from a repository that answered is a measurement, not a floor"
1984 );
1985 assert_eq!(gateway.requests_issued(), 2);
1986 assert!(
1987 gateway.requests_issued() <= u64::from(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL),
1988 "the idle case must sit inside the projection, which is the number `f2`'s \
1989 refusals are computed from"
1990 );
1991 }
1992
1993 /// A run whose jobs have all been dispatched is found and costs one request.
1994 ///
1995 /// The safety net's ordinary outcome, and the reason it is capped lower than
1996 /// the primary signal: on the common shape it finds nothing.
1997 #[tokio::test]
1998 async fn an_in_progress_run_with_no_queued_job_contributes_only_its_request() {
1999 let server = MockServer::start().await;
2000 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
2001 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
2002 mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 0, 4)).await;
2003 let gateway = gateway(&server);
2004
2005 let demand = gateway
2006 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2007 .await
2008 .expect("a queued-job count");
2009
2010 assert_eq!(demand.total(), 0);
2011 assert!(demand.is_complete());
2012 assert_eq!(gateway.requests_issued(), 3);
2013 }
2014
2015 /// A run caught mid-transition appears in both listings and is counted once.
2016 ///
2017 /// The two GitHub queries are disjoint at any one instant and these are two
2018 /// requests with time between them. A run whose last waiting job is picked
2019 /// up between them is `queued` for the first and `in_progress` for the
2020 /// second — the ordinary transition, not an exotic one — so it comes back in
2021 /// both lists. Counting its jobs twice would inflate demand silently, which
2022 /// is the failure mode with no error to notice.
2023 #[tokio::test]
2024 async fn a_run_in_both_listings_is_resolved_once_and_not_counted_twice() {
2025 let server = MockServer::start().await;
2026 // The same run id in both lists, which is what the race produces.
2027 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2028 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[100])).await;
2029 mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 3, 0)).await;
2030 let gateway = gateway(&server);
2031
2032 let demand = gateway
2033 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2034 .await
2035 .expect("a queued-job count");
2036
2037 assert_eq!(
2038 demand.total(),
2039 3,
2040 "the run holds three queued jobs, and appearing in both listings does not make it six"
2041 );
2042 assert_eq!(
2043 gateway.requests_issued(),
2044 3,
2045 "and the duplicate costs no second job listing either"
2046 );
2047 }
2048
2049 /// The job listing's id and names travel with each queued job: they are
2050 /// what a `workflow_job` webhook calls the same job.
2051 #[tokio::test]
2052 async fn each_queued_job_keeps_its_id_and_names() {
2053 let server = MockServer::start().await;
2054 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[200])).await;
2055 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2056 mount_jobs(
2057 &server,
2058 &repo(),
2059 200,
2060 json!({"total_count": 3, "jobs": [
2061 {"id": 7, "status": "in_progress", "name": "running",
2062 "workflow_name": "CI", "labels": ["rm-pool-linux"]},
2063 {"id": 8, "status": "queued", "name": "build (linux, 3.12)",
2064 "workflow_name": "CI", "labels": ["rm-pool-linux"]},
2065 {"id": 9, "status": "queued", "name": "older", "workflow_name": null,
2066 "labels": ["rm-pool-linux"]},
2067 ]}),
2068 )
2069 .await;
2070 let demand = gateway(&server)
2071 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2072 .await
2073 .expect("a reading");
2074 let identities: Vec<QueuedJobIdentity> = demand
2075 .identified_jobs_for(&repo())
2076 .filter_map(|(_, identity)| identity.cloned())
2077 .collect();
2078 assert_eq!(
2079 identities,
2080 vec![
2081 QueuedJobIdentity {
2082 id: Some(8),
2083 name: Some("build (linux, 3.12)".into()),
2084 workflow: None,
2085 },
2086 QueuedJobIdentity {
2087 id: Some(9),
2088 name: Some("older".into()),
2089 workflow: None,
2090 },
2091 ]
2092 );
2093 assert_eq!(demand.total(), 2);
2094 }
2095
2096 /// A job's workflow comes from its run's `path`, which unlike its title does
2097 /// not change between dispatches.
2098 #[tokio::test]
2099 async fn a_queued_job_is_named_with_its_runs_workflow_file() {
2100 let server = MockServer::start().await;
2101 mount_runs(
2102 &server,
2103 &repo(),
2104 QUEUED_RUN_STATUS,
2105 json!({
2106 "total_count": 1,
2107 "workflow_runs": [{
2108 "id": 100,
2109 "name": "load light [1,2] (a title)",
2110 "path": ".github/workflows/load.yml"
2111 }]
2112 }),
2113 )
2114 .await;
2115 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2116 mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2117 let gateway = gateway(&server);
2118
2119 let demand = gateway
2120 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2121 .await
2122 .expect("read");
2123
2124 let (_, identity) = demand.identified_jobs_for(&repo()).next().expect("a job");
2125 assert_eq!(
2126 identity.and_then(|identity| identity.workflow.as_deref()),
2127 Some(".github/workflows/load.yml")
2128 );
2129 assert_eq!(
2130 demand
2131 .run_workflows_for(&repo())
2132 .and_then(|runs| runs.get(&100))
2133 .map(String::as_str),
2134 Some(".github/workflows/load.yml")
2135 );
2136 }
2137
2138 #[tokio::test]
2139 async fn one_runs_workflow_file_can_be_asked_for() {
2140 let server = MockServer::start().await;
2141 Mock::given(method("GET"))
2142 .and(path(format!(
2143 "/repos/{}/{}/actions/runs/77",
2144 repo().owner(),
2145 repo().repo()
2146 )))
2147 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2148 "id": 77,
2149 "path": ".github/workflows/ci.yml"
2150 })))
2151 .mount(&server)
2152 .await;
2153
2154 let answer = gateway(&server)
2155 .run_workflow(&repo(), 77, &CancelToken::new())
2156 .await
2157 .expect("read");
2158
2159 assert_eq!(answer.as_deref(), Some(".github/workflows/ci.yml"));
2160 }
2161
2162 /// A `needs:`-gated job whose run has already started is still demand.
2163 ///
2164 /// This is the whole reason the in-progress pass exists. Live sampling of a
2165 /// repository using this product never caught GitHub reporting a run as
2166 /// `in_progress` while one of its jobs was queued — 44 samples, 25 of them
2167 /// with a queued job — so the primary signal covers everything that was
2168 /// observed. It does not cover a job that becomes queued *after* its run
2169 /// started, which is what `needs:` produces and what this pins.
2170 #[tokio::test]
2171 async fn a_job_that_enters_the_queue_after_its_run_started_is_still_found() {
2172 let server = MockServer::start().await;
2173 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
2174 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
2175 // One job running, one released by `needs:` and now waiting.
2176 mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 1, 1)).await;
2177 let gateway = gateway(&server);
2178
2179 let demand = gateway
2180 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2181 .await
2182 .expect("a queued-job count");
2183
2184 assert_eq!(
2185 demand.total(),
2186 1,
2187 "polling only `status=queued` runs would report zero here and the \
2188 `needs:`-gated job would wait for a machine that is idle"
2189 );
2190 }
2191
2192 // -- the wire contract, against payloads GitHub really sent -------------
2193
2194 /// The deserializers are pinned against **real** GitHub responses, not only
2195 /// against fixtures written to match them.
2196 ///
2197 /// Every other test here builds its own JSON, so all of them would keep
2198 /// passing if this module's idea of the wire format were wrong in the same
2199 /// way the fixtures are. That is the one failure a mock server cannot catch,
2200 /// and it is the failure this change was most exposed to: the previous owner
2201 /// decision deliberately read *nothing* out of a run and had no job
2202 /// endpoint at all, so `workflow_runs[].id`, `jobs[].status` and
2203 /// `jobs[].labels` are all fields this crate had never parsed before.
2204 ///
2205 /// The payloads below are verbatim excerpts captured from
2206 /// `api.github.com` on 2026-09-05 against a repository using this product,
2207 /// trimmed only by replacing the `steps` array — which is long, which this
2208 /// module does not read, and whose presence is itself part of what is being
2209 /// asserted, since a job object carries twenty-odd fields that must all be
2210 /// ignored without error.
2211 #[test]
2212 fn the_wire_shapes_parse_a_payload_github_really_sent() {
2213 // GET /repos/{o}/{r}/actions/runs/33938794901/jobs?filter=latest&per_page=100
2214 let jobs: RunJobsPage = serde_json::from_value(json!({
2215 "total_count": 5,
2216 "jobs": [
2217 {
2218 "id": 101_231_925_899_i64,
2219 "run_id": 33_938_794_901_i64,
2220 "workflow_name": "tests",
2221 "head_branch": "worktree-p1-lock-and-worktree-set",
2222 "run_url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
2223 "run_attempt": 1,
2224 "node_id": "CR_kwDOS_wnss8AAAAXkeSaiw",
2225 "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
2226 "url": "https://api.github.com/repos/o/r/actions/jobs/101231925899",
2227 "html_url": "https://github.com/o/r/actions/runs/33938794901/job/101231925899",
2228 "status": "completed",
2229 "conclusion": "success",
2230 "created_at": "2026-09-05T02:20:30Z",
2231 "started_at": "2026-09-05T02:26:03Z",
2232 "completed_at": "2026-09-05T02:28:44Z",
2233 "name": "scripts-tests",
2234 "steps": [],
2235 "check_run_url": "https://api.github.com/repos/o/r/check-runs/101231925899",
2236 "labels": ["self-hosted", "windows"],
2237 "runner_id": 725,
2238 "runner_name": "runner-manager-4bd32f05-088f-4b13-a59c-6900b9142aa1",
2239 "runner_group_id": 1,
2240 "runner_group_name": "Default"
2241 },
2242 {
2243 "id": 101_231_925_900_i64,
2244 "run_id": 33_938_794_901_i64,
2245 "status": "queued",
2246 "conclusion": serde_json::Value::Null,
2247 "started_at": serde_json::Value::Null,
2248 "completed_at": serde_json::Value::Null,
2249 "name": "pipeline-tests",
2250 "steps": [],
2251 "labels": ["self-hosted", "windows"],
2252 "runner_id": serde_json::Value::Null,
2253 "runner_name": "",
2254 "runner_group_name": ""
2255 },
2256 {
2257 "id": 101_231_925_901_i64,
2258 "status": "queued",
2259 "name": "scripts-tests-macos",
2260 "labels": ["self-hosted", "macOS", "rm-macmini-osx-arm64"]
2261 }
2262 ]
2263 }))
2264 .expect("the jobs page GitHub really sends must deserialize");
2265
2266 assert_eq!(
2267 jobs.jobs.len(),
2268 3,
2269 "every job is read, whatever else it carries"
2270 );
2271
2272 // What the gateway does with it: keep the queued ones, and turn each
2273 // one's `labels` array into the `RunsOn` `b1` matches.
2274 let queued: Vec<RunsOn> = jobs
2275 .jobs
2276 .into_iter()
2277 .filter(|job| job.status == QUEUED_JOB_STATUS)
2278 .map(|job| RunsOn::from_job_labels(job.labels))
2279 .collect();
2280
2281 assert_eq!(
2282 queued,
2283 vec![
2284 RunsOn::Many(vec!["self-hosted".into(), "windows".into()]),
2285 RunsOn::Many(vec![
2286 "self-hosted".into(),
2287 "macOS".into(),
2288 "rm-macmini-osx-arm64".into()
2289 ]),
2290 ],
2291 "the completed job is dropped and the two queued ones keep their labels"
2292 );
2293
2294 // The Windows job requires a subset of this policy's labels, even
2295 // without its selector. The macOS job still requires missing labels.
2296 let tally = host_labels().tally(&queued);
2297 assert_eq!(tally.demand(), 1);
2298 assert_eq!(tally.not_matched, 1);
2299
2300 // GET /repos/{o}/{r}/actions/runs?status=queued&per_page=100, as an idle
2301 // repository answers it. `total_count` and an empty array, which is the
2302 // shape that must read as "nothing waiting" rather than as a failure.
2303 let idle: QueuedRunsPage = serde_json::from_value(json!({
2304 "total_count": 0,
2305 "workflow_runs": []
2306 }))
2307 .expect("an idle run listing must deserialize");
2308 assert_eq!(idle.total_count, Some(0));
2309 assert!(idle.workflow_runs.is_empty());
2310
2311 // And a busy one. A run object carries far more than the id, and the id
2312 // is the only field this module reads out of it.
2313 let busy: QueuedRunsPage = serde_json::from_value(json!({
2314 "total_count": 1,
2315 "workflow_runs": [{
2316 "id": 33_938_794_901_i64,
2317 "name": "tests",
2318 "node_id": "WFR_kwLOS_wnss8AAAAH6oSPFQ",
2319 "head_branch": "main",
2320 "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
2321 "path": ".github/workflows/tests.yml",
2322 "run_number": 412,
2323 "event": "push",
2324 "status": "queued",
2325 "conclusion": serde_json::Value::Null,
2326 "workflow_id": 213_842_591_i64,
2327 "url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
2328 "created_at": "2026-09-05T02:20:30Z",
2329 "updated_at": "2026-09-05T02:20:31Z"
2330 }]
2331 }))
2332 .expect("a busy run listing must deserialize");
2333 assert_eq!(
2334 busy.workflow_runs
2335 .iter()
2336 .map(|run| run.id)
2337 .collect::<Vec<_>>(),
2338 vec![33_938_794_901],
2339 "the run id is what the job listing is fetched by, and it is a u64: \
2340 GitHub's run ids passed 2^32 long ago, so a u32 here would have \
2341 wrapped on every real repository"
2342 );
2343 }
2344
2345 // -- the caps -----------------------------------------------------------
2346
2347 /// More queued runs than the cap resolves makes the answer a floor.
2348 #[tokio::test]
2349 async fn more_queued_runs_than_the_cap_report_a_floor_rather_than_a_total() {
2350 let server = MockServer::start().await;
2351 let ids: Vec<u64> = (0..(MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u64 + 4))
2352 .map(|i| 100 + i)
2353 .collect();
2354 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&ids)).await;
2355 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2356 for id in &ids {
2357 mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2358 }
2359 let gateway = gateway(&server);
2360
2361 let demand = gateway
2362 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2363 .await
2364 .expect("a bounded poll still answers");
2365
2366 assert_eq!(
2367 demand.total() as usize,
2368 MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
2369 "one job resolved per run the cap admits"
2370 );
2371 assert!(
2372 !demand.is_complete(),
2373 "a count clipped by the run cap is a floor and must say so; concluding \
2374 `idle` from one would be the mistake the flag exists to prevent"
2375 );
2376 assert!(demand.is_truncated(&repo()));
2377 }
2378
2379 /// The caps bound what one repository can spend, whatever GitHub sends.
2380 ///
2381 /// The projection is a steady-state figure and the worst case is what keeps
2382 /// it honest, so the worst case has to be a real ceiling rather than an
2383 /// estimate. A repository with a hundred active runs must not spend a
2384 /// hundred requests.
2385 #[tokio::test]
2386 async fn a_repository_cannot_spend_more_than_the_documented_worst_case() {
2387 let server = MockServer::start().await;
2388 let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
2389 let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
2390 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
2391 mount_runs(
2392 &server,
2393 &repo(),
2394 IN_PROGRESS_RUN_STATUS,
2395 runs_body(&running),
2396 )
2397 .await;
2398 for id in queued.iter().chain(running.iter()) {
2399 mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2400 }
2401 let gateway = gateway(&server);
2402
2403 let demand = gateway
2404 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2405 .await
2406 .expect("a bounded poll still answers");
2407
2408 assert_eq!(
2409 gateway.requests_issued(),
2410 u64::from(max_demand_requests_per_repository_per_poll()),
2411 "the measured ceiling must equal the projected one, or the bound is a \
2412 sentence in a doc comment"
2413 );
2414 assert_eq!(
2415 demand.total(),
2416 2 * (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL
2417 + MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL) as u32
2418 );
2419 assert!(!demand.is_complete());
2420 }
2421
2422 /// The queued pass is served before the in-progress one when both are over
2423 /// their caps.
2424 #[tokio::test]
2425 async fn the_primary_signal_is_resolved_before_the_safety_net() {
2426 let server = MockServer::start().await;
2427 let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
2428 let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
2429 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
2430 mount_runs(
2431 &server,
2432 &repo(),
2433 IN_PROGRESS_RUN_STATUS,
2434 runs_body(&running),
2435 )
2436 .await;
2437 // Queued runs hold this host's work; in-progress runs hold another
2438 // host's. If the caps were spent in the other order the demand would be
2439 // made of the wrong jobs.
2440 for id in &queued {
2441 mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2442 }
2443 for id in &running {
2444 mount_jobs(&server, &repo(), *id, jobs_body(&["ubuntu-latest"], 1, 0)).await;
2445 }
2446 let gateway = gateway(&server);
2447
2448 let demand = gateway
2449 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2450 .await
2451 .expect("a bounded poll still answers");
2452
2453 let tally = host_labels().tally(demand.jobs_for(&repo()));
2454 assert_eq!(
2455 tally.demand() as usize,
2456 MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
2457 "the queued cap is spent in full on the primary signal"
2458 );
2459 assert_eq!(
2460 tally.not_matched as usize, MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
2461 "and the safety net gets its own smaller cap, not a share of the first"
2462 );
2463 }
2464
2465 /// One run's job listing follows pages, and stops at its budget.
2466 #[tokio::test]
2467 async fn a_runs_job_listing_walks_pages_and_stops_at_its_budget() {
2468 let server = MockServer::start().await;
2469 let base = server.uri();
2470 let path_100 = jobs_path(&repo(), 100);
2471
2472 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2473 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2474 // Every page points at another, forever.
2475 Mock::given(method("GET"))
2476 .and(path(path_100.clone()))
2477 .respond_with(
2478 ResponseTemplate::new(200)
2479 .set_body_json(jobs_body(&["rm-home-win-x64"], 100, 0))
2480 .insert_header(
2481 "link",
2482 format!("<{base}{path_100}?page=9>; rel=\"next\"").as_str(),
2483 ),
2484 )
2485 .mount(&server)
2486 .await;
2487 let gateway = gateway(&server);
2488
2489 let demand = gateway
2490 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2491 .await
2492 .expect("a bounded walk still answers");
2493
2494 assert_eq!(
2495 gateway.requests_issued() as usize,
2496 2 + MAX_JOB_PAGES_PER_RUN,
2497 "an endless `Link` chain must stop at the job page budget rather than \
2498 spending the hourly ceiling on one run"
2499 );
2500 assert_eq!(demand.total() as usize, 100 * MAX_JOB_PAGES_PER_RUN);
2501 assert!(
2502 !demand.is_complete(),
2503 "a count clipped by the page bound is a floor and must say so"
2504 );
2505 assert!(demand.is_truncated(&repo()));
2506 }
2507
2508 // -- the `total_count` tripwire ----------------------------------------
2509
2510 /// The zero-cost check that `total_count` is the *filtered* count.
2511 ///
2512 /// A repository with 3 queued runs out of 5,000 lifetime runs would answer
2513 /// `len() == 3`, no `Link`, and `total_count == 5000`. The demand number no
2514 /// longer comes from that field, so the contradiction is a `warn!` rather
2515 /// than a `debug_assert!` — but it still means GitHub is not answering the
2516 /// question this module asked, and it is free to notice.
2517 #[tokio::test]
2518 async fn a_total_count_that_disagrees_with_its_only_page_does_not_derail_the_count() {
2519 let server = MockServer::start().await;
2520 mount_runs(
2521 &server,
2522 &repo(),
2523 QUEUED_RUN_STATUS,
2524 json!({
2525 "total_count": 5_000,
2526 "workflow_runs": [{ "id": 100 }]
2527 }),
2528 )
2529 .await;
2530 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2531 mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2532 let gateway = gateway(&server);
2533
2534 let demand = gateway
2535 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2536 .await
2537 .expect("a wrong `total_count` no longer decides anything here");
2538
2539 assert_eq!(
2540 demand.total(),
2541 2,
2542 "the count comes from the jobs of the runs that were actually listed, so a \
2543 `total_count` carrying the unfiltered lifetime total cannot inflate it"
2544 );
2545 assert!(
2546 demand.is_complete(),
2547 "one listed run, no next page, and the cap not reached"
2548 );
2549 }
2550
2551 // -- organization aggregation ------------------------------------------
2552
2553 /// An organization aggregates across its installed repositories, and the
2554 /// request count grows with that repository count.
2555 #[tokio::test]
2556 async fn an_organization_aggregates_its_repositories_and_pays_per_repository() {
2557 let server = MockServer::start().await;
2558 mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 2, 0).await;
2559 mount_one_queued_run(&server, &other_repo(), &["rm-home-win-x64"], 5, 0).await;
2560 mount_idle(&server, &third_repo()).await;
2561 let gateway = gateway(&server);
2562
2563 let two = org_scope([repo(), other_repo()]);
2564 let three = org_scope([repo(), other_repo(), third_repo()]);
2565
2566 assert_eq!(
2567 demand_requests_per_poll(&two),
2568 2 * DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
2569 "there is no organization-wide workflow-runs endpoint, so the cost is \
2570 per repository"
2571 );
2572 assert!(
2573 demand_requests_per_poll(&three) > demand_requests_per_poll(&two),
2574 "a projection that did not grow with the repository count would understate \
2575 an organization's real spend by exactly that factor"
2576 );
2577
2578 let demand = gateway
2579 .queued_demand(&three, &CancelToken::new())
2580 .await
2581 .expect("an aggregate");
2582
2583 assert_eq!(demand.total(), 7);
2584 assert_eq!(demand.for_repository(&repo()), Some(2));
2585 assert_eq!(demand.for_repository(&other_repo()), Some(5));
2586 assert_eq!(
2587 demand.for_repository(&third_repo()),
2588 Some(0),
2589 "a repository that answered zero is present as a zero, unlike one that \
2590 could not answer at all"
2591 );
2592 assert_eq!(
2593 host_labels().tally(demand.jobs()).demand(),
2594 7,
2595 "an organization policy serves any repository in its scope, so its demand \
2596 is the whole aggregate's rather than one repository's"
2597 );
2598 assert!(
2599 gateway.requests_issued() <= u64::from(max_demand_requests_per_poll(&three)),
2600 "the measured cost must sit inside the projected ceiling, or the budget \
2601 model is a table in a document"
2602 );
2603 }
2604
2605 /// The measured cost is reported to `c3`'s budget model through the seam
2606 /// `c3` left for it, rather than inheriting the estimate.
2607 #[test]
2608 fn the_measured_demand_cost_is_reported_through_c3s_seam() {
2609 use crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH;
2610
2611 let scope = org_scope([repo(), other_repo(), third_repo()]);
2612 let estimated = TargetCost::from_activity_scope(&scope);
2613 let measured = target_cost(&scope);
2614
2615 assert_eq!(
2616 DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH, 2,
2617 "`c3`'s estimate prices the runs request and one jobs request; this module \
2618 now issues both, plus the in-progress listing and a jobs request per \
2619 additional active run, so the measured figure is higher rather than lower"
2620 );
2621 assert_ne!(
2622 measured, estimated,
2623 "the seam must actually replace the estimate; a `target_cost` that returned \
2624 `from_activity_scope` unchanged would report the estimate as measured"
2625 );
2626 // 1 inventory request + 3 repositories * (1 activity + 4 demand).
2627 assert_eq!(measured.requests_per_refresh(), 16);
2628 // 1 + 3 * (1 + 2), `c3`'s estimate.
2629 assert_eq!(estimated.requests_per_refresh(), 10);
2630 assert!(
2631 measured.requests_per_refresh() > estimated.requests_per_refresh(),
2632 "restoring the per-run job listing added requests; a measured cost that was \
2633 not higher would mean this module is not issuing them"
2634 );
2635 }
2636
2637 /// **A known gap, pinned so it stays visible.** `f2`'s refusal *decision*
2638 /// can consume the measured demand cost; the number it prints alongside the
2639 /// refusal cannot.
2640 ///
2641 /// [`crate::rest::BudgetProjection::admit`] takes the candidate
2642 /// [`TargetCost`] from its caller, so an `f2` that builds it through
2643 /// [`target_cost`] gets an admission computed from the real cost.
2644 /// [`crate::rest::BudgetProjection::max_repository_targets`] builds
2645 /// [`TargetCost::repository`] internally, which cannot see the seam and
2646 /// therefore still prices demand at `c3`'s estimate of two.
2647 ///
2648 /// The two then disagree, and an operator sees both. **The direction of the
2649 /// disagreement inverted when this module started counting jobs**, and that
2650 /// is why this test matters more than it did: the printed ceiling used to be
2651 /// conservative, and is now optimistic. It says a host fits ten repository
2652 /// targets when the cost this module really issues fits six, so an operator
2653 /// planning against the printed number can configure a host that spends more
2654 /// than the projection admitted.
2655 ///
2656 /// What keeps that from being a live budget overrun is
2657 /// `BUDGET_SHARE_DIVISOR`: the projection is compared against half of
2658 /// GitHub's hourly ceiling, so the gap is spent out of the half deliberately
2659 /// left unplanned. It is a reporting defect with a safety margin under it,
2660 /// not a correctness one — and `f1`'s `host show` prints the caveat beside
2661 /// the number.
2662 ///
2663 /// It is not fixable from this file. Both remedies — changing
2664 /// [`crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH`], or giving
2665 /// `max_repository_targets` a [`TargetCost`] argument — are edits to
2666 /// `crates/github/src/rest.rs`, which `c3` owns. This test records the
2667 /// discrepancy with its arithmetic so that whoever holds that file can act on
2668 /// it, and fails if it is ever closed, at which point this test and the note
2669 /// above should go.
2670 #[test]
2671 fn the_printed_target_ceiling_still_projects_c3s_estimate() {
2672 use crate::rest::{BudgetProjection, budget_allowance};
2673 use runner_manager_domain::model::RefreshInterval;
2674
2675 let interval = RefreshInterval::default();
2676 let printed = BudgetProjection::max_repository_targets(interval);
2677 let per_hour_estimated = TargetCost::repository().requests_per_hour(interval);
2678 let per_hour_measured = TargetCost::repository()
2679 .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
2680 .requests_per_hour(interval);
2681
2682 // 4 requests per refresh * 60 refreshes, against 6 * 60.
2683 assert_eq!(per_hour_estimated, 240);
2684 assert_eq!(per_hour_measured, 360);
2685 assert_eq!(
2686 printed, 10,
2687 "the printed ceiling is `04-subsystem-contracts.md`'s figure, computed from \
2688 `c3`'s estimate"
2689 );
2690 assert_eq!(
2691 budget_allowance() / per_hour_measured,
2692 6,
2693 "while the cost this module actually issues allows six"
2694 );
2695 assert!(
2696 printed > budget_allowance() / per_hour_measured,
2697 "the gap now runs in the optimistic direction: the printed ceiling is larger \
2698 than the measured cost supports. `BUDGET_SHARE_DIVISOR` is what absorbs it \
2699 -- see this test's documentation before treating the inequality as harmless"
2700 );
2701 }
2702
2703 /// One archived repository must not take down an organization's whole
2704 /// demand poll — and must not read as zero either.
2705 #[tokio::test]
2706 async fn an_organization_steps_over_a_repository_local_failure_without_reading_it_as_zero() {
2707 let server = MockServer::start().await;
2708 mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 4, 0).await;
2709 Mock::given(method("GET"))
2710 .and(path(runs_path(&other_repo())))
2711 .respond_with(
2712 ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
2713 )
2714 .mount(&server)
2715 .await;
2716 let gateway = gateway(&server);
2717
2718 let demand = gateway
2719 .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
2720 .await
2721 .expect("an aggregate steps over a repository it cannot read");
2722
2723 assert_eq!(demand.total(), 4);
2724 assert_eq!(demand.unavailable().len(), 1);
2725 assert_eq!(demand.unavailable()[0].repository, other_repo());
2726 assert_eq!(
2727 demand.for_repository(&other_repo()),
2728 None,
2729 "a repository that could not be polled is absent from the map, not present \
2730 as a zero"
2731 );
2732 assert!(
2733 demand.jobs_for(&other_repo()).is_empty(),
2734 "and its job list is empty rather than absent, so a caller tallying it \
2735 cannot accidentally read an unavailable repository as demand"
2736 );
2737 assert!(
2738 !demand.is_complete(),
2739 "an aggregate missing a repository is not a complete reading"
2740 );
2741 }
2742
2743 /// The same `404`, on a repository *target*, propagates instead.
2744 ///
2745 /// Stepping over the only repository in scope would answer demand `0` for a
2746 /// target this host cannot see at all, and `e1` would then correctly start
2747 /// no runners for the wrong reason — with no error anywhere to explain it.
2748 #[tokio::test]
2749 async fn a_repository_target_propagates_the_failure_rather_than_reporting_zero_demand() {
2750 let server = MockServer::start().await;
2751 Mock::given(method("GET"))
2752 .and(path(runs_path(&repo())))
2753 .respond_with(
2754 ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
2755 )
2756 .mount(&server)
2757 .await;
2758 let gateway = gateway(&server);
2759
2760 let error = gateway
2761 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2762 .await
2763 .expect_err("a scope of one has no aggregate to step over into");
2764 assert!(matches!(
2765 error,
2766 InventoryError::Github(GithubError::Status { status: 404, .. })
2767 ));
2768 }
2769
2770 /// A failure on the *job* listing is a failure of the whole poll, not a
2771 /// silently short count.
2772 ///
2773 /// The job listing is where the new requests are, so it is where a new way
2774 /// to under-count could enter: a gateway that swallowed a failed job listing
2775 /// would report the run's jobs as zero, which is indistinguishable from a
2776 /// run whose jobs have all been dispatched.
2777 #[tokio::test]
2778 async fn a_failed_job_listing_is_not_read_as_a_run_with_no_queued_jobs() {
2779 let server = MockServer::start().await;
2780 mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2781 mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2782 Mock::given(method("GET"))
2783 .and(path(jobs_path(&repo(), 100)))
2784 .respond_with(
2785 ResponseTemplate::new(500).set_body_json(json!({ "message": "Server Error" })),
2786 )
2787 .mount(&server)
2788 .await;
2789 let gateway = gateway(&server);
2790
2791 let error = gateway
2792 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2793 .await
2794 .expect_err("a job listing that failed is not a run with nothing queued");
2795 assert!(matches!(
2796 error,
2797 InventoryError::Github(GithubError::Status { status: 500, .. })
2798 ));
2799 }
2800
2801 /// A credential failure aborts the aggregate rather than being stepped over.
2802 ///
2803 /// A revoked token is a fact about the credential, not about the repository:
2804 /// stepping over it would report every remaining repository as unavailable
2805 /// and the total as a number, when in truth nothing can be read at all.
2806 #[tokio::test]
2807 async fn a_rate_limit_aborts_the_aggregate_rather_than_being_stepped_over() {
2808 let server = MockServer::start().await;
2809 mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
2810 Mock::given(method("GET"))
2811 .and(path(runs_path(&other_repo())))
2812 .respond_with(
2813 ResponseTemplate::new(429)
2814 .insert_header("retry-after", "42")
2815 .set_body_json(json!({
2816 "message": "You have exceeded a secondary rate limit"
2817 })),
2818 )
2819 .mount(&server)
2820 .await;
2821 let gateway = gateway(&server);
2822
2823 let error = gateway
2824 .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
2825 .await
2826 .expect_err("a rate limit is a fact about the credential, not the repository");
2827
2828 let limit = error
2829 .rate_limited()
2830 .expect("`c3`'s detector is what decides this, and it decided rate limit");
2831 assert_eq!(limit.retry_after, Some(std::time::Duration::from_secs(42)));
2832 }
2833
2834 // -- conditional polling, the shared window, and the quiet period -------
2835
2836 fn gateway_at(server: &MockServer, clock: &Arc<TestClock>) -> RestDemand {
2837 let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
2838 let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
2839 let client =
2840 AuthenticatedClient::new(endpoints, token, Arc::clone(clock) as Arc<dyn Clock>)
2841 .expect("a client over the test server");
2842 RestDemand::new(Arc::new(client), Arc::clone(clock) as Arc<dyn Clock>)
2843 }
2844
2845 /// The account-wide quota headers GitHub puts on every response, `304`s
2846 /// included.
2847 fn with_quota(template: ResponseTemplate, remaining: u64) -> ResponseTemplate {
2848 template
2849 .insert_header("x-ratelimit-limit", "5000")
2850 .insert_header("x-ratelimit-remaining", remaining.to_string().as_str())
2851 .insert_header("x-ratelimit-used", (5000 - remaining).to_string().as_str())
2852 .insert_header("x-ratelimit-reset", "1787274000")
2853 .insert_header("x-ratelimit-resource", "core")
2854 }
2855
2856 /// One listing that answers `304` to its own tag and `200` + tag
2857 /// otherwise. Mounted conditional-first, because wiremock answers with the
2858 /// first mock whose matchers all pass.
2859 async fn mount_tagged(
2860 server: &MockServer,
2861 listing: &str,
2862 status: Option<&str>,
2863 etag: &str,
2864 body: serde_json::Value,
2865 ) {
2866 let mut not_modified = Mock::given(method("GET"))
2867 .and(path(listing.to_string()))
2868 .and(wiremock::matchers::header("if-none-match", etag));
2869 if let Some(status) = status {
2870 not_modified = not_modified.and(query_param("status", status));
2871 }
2872 not_modified
2873 .respond_with(with_quota(
2874 ResponseTemplate::new(304).insert_header("etag", etag),
2875 4_000,
2876 ))
2877 .mount(server)
2878 .await;
2879 let mut full = Mock::given(method("GET")).and(path(listing.to_string()));
2880 if let Some(status) = status {
2881 full = full.and(query_param("status", status));
2882 }
2883 full.respond_with(with_quota(
2884 ResponseTemplate::new(200)
2885 .insert_header("etag", etag)
2886 .set_body_json(body),
2887 4_000,
2888 ))
2889 .mount(server)
2890 .await;
2891 }
2892
2893 async fn mount_tagged_idle(server: &MockServer, repository: &OwnerRepo) {
2894 let runs = runs_path(repository);
2895 mount_tagged(
2896 server,
2897 &runs,
2898 Some(QUEUED_RUN_STATUS),
2899 r#"W/"queued""#,
2900 no_runs(),
2901 )
2902 .await;
2903 mount_tagged(
2904 server,
2905 &runs,
2906 Some(IN_PROGRESS_RUN_STATUS),
2907 r#"W/"in-progress""#,
2908 no_runs(),
2909 )
2910 .await;
2911 }
2912
2913 fn conditional_requests(received: &[wiremock::Request]) -> usize {
2914 received
2915 .iter()
2916 .filter(|request| request.headers.contains_key("if-none-match"))
2917 .count()
2918 }
2919
2920 /// The owner's requirement in one test: an idle repository polled again
2921 /// sends its tags back, GitHub answers `304`, and the reading is the same.
2922 #[tokio::test]
2923 async fn an_unchanged_repository_is_polled_with_its_etags_and_answered_304() {
2924 let server = MockServer::start().await;
2925 mount_tagged_idle(&server, &repo()).await;
2926 let clock = Arc::new(TestClock::default());
2927 let gateway = gateway_at(&server, &clock);
2928 let scope = ActivityScope::repository(repo());
2929
2930 let first = gateway
2931 .queued_demand(&scope, &CancelToken::new())
2932 .await
2933 .expect("a first reading");
2934 let second = gateway
2935 .queued_demand(&scope, &CancelToken::new())
2936 .await
2937 .expect("a second reading");
2938
2939 assert_eq!(first.per_repository(), second.per_repository());
2940 let received = server.received_requests().await.expect("recorded requests");
2941 assert_eq!(received.len(), 4, "two listings, twice");
2942 assert_eq!(
2943 conditional_requests(&received),
2944 2,
2945 "the second poll sends both tags back"
2946 );
2947 let traffic = gateway.client().traffic().summary(clock.now());
2948 assert_eq!((traffic.full, traffic.not_modified), (2, 2));
2949 let stats = gateway.stats();
2950 assert_eq!((stats.reads, stats.unchanged_reads), (2, 1));
2951 assert!(second.is_quiet());
2952 }
2953
2954 /// A conditional request must never hide a change: a new run on the very
2955 /// next poll is counted on that poll.
2956 #[tokio::test]
2957 async fn a_run_that_arrives_between_polls_is_counted_on_the_next_one() {
2958 let server = MockServer::start().await;
2959 let runs = runs_path(&repo());
2960 // Asked with the first tag, the queue has moved on: a full answer with
2961 // a new tag.
2962 Mock::given(method("GET"))
2963 .and(path(runs.clone()))
2964 .and(query_param("status", QUEUED_RUN_STATUS))
2965 .and(wiremock::matchers::header("if-none-match", r#"W/"empty""#))
2966 .respond_with(
2967 ResponseTemplate::new(200)
2968 .insert_header("etag", r#"W/"one-run""#)
2969 .set_body_json(runs_body(&[100])),
2970 )
2971 .mount(&server)
2972 .await;
2973 Mock::given(method("GET"))
2974 .and(path(runs.clone()))
2975 .and(query_param("status", QUEUED_RUN_STATUS))
2976 .respond_with(
2977 ResponseTemplate::new(200)
2978 .insert_header("etag", r#"W/"empty""#)
2979 .set_body_json(no_runs()),
2980 )
2981 .mount(&server)
2982 .await;
2983 mount_tagged(
2984 &server,
2985 &runs,
2986 Some(IN_PROGRESS_RUN_STATUS),
2987 r#"W/"in-progress""#,
2988 no_runs(),
2989 )
2990 .await;
2991 mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2992 let clock = Arc::new(TestClock::default());
2993 let gateway = gateway_at(&server, &clock);
2994 let scope = ActivityScope::repository(repo());
2995
2996 let before = gateway
2997 .queued_demand(&scope, &CancelToken::new())
2998 .await
2999 .expect("an idle reading");
3000 let after = gateway
3001 .queued_demand(&scope, &CancelToken::new())
3002 .await
3003 .expect("a reading after the run arrived");
3004
3005 assert_eq!(before.total(), 0);
3006 assert_eq!(after.total(), 2, "the change is seen on the very next poll");
3007 assert!(
3008 !after.is_quiet(),
3009 "a run under way whose listing changed is activity"
3010 );
3011 }
3012
3013 /// A run stuck in the queue for a label nobody serves is still in the
3014 /// listing hours later. While its listings answer `304` it costs nothing,
3015 /// and it must not hold the target at the slower active interval.
3016 #[tokio::test]
3017 async fn a_queued_run_whose_listings_are_unchanged_is_quiet() {
3018 let server = MockServer::start().await;
3019 let runs = runs_path(&repo());
3020 mount_tagged(
3021 &server,
3022 &runs,
3023 Some(QUEUED_RUN_STATUS),
3024 r#"W/"stuck""#,
3025 runs_body(&[100]),
3026 )
3027 .await;
3028 mount_tagged(
3029 &server,
3030 &runs,
3031 Some(IN_PROGRESS_RUN_STATUS),
3032 r#"W/"in-progress""#,
3033 no_runs(),
3034 )
3035 .await;
3036 mount_tagged(
3037 &server,
3038 &jobs_path(&repo(), 100),
3039 None,
3040 r#"W/"jobs""#,
3041 jobs_body(&["ubuntu-latest"], 1, 0),
3042 )
3043 .await;
3044 let clock = Arc::new(TestClock::default());
3045 let gateway = gateway_at(&server, &clock);
3046 let scope = ActivityScope::repository(repo());
3047
3048 let first = gateway
3049 .queued_demand(&scope, &CancelToken::new())
3050 .await
3051 .expect("a first reading");
3052 let second = gateway
3053 .queued_demand(&scope, &CancelToken::new())
3054 .await
3055 .expect("a second reading");
3056
3057 assert!(
3058 !first.is_quiet(),
3059 "the first sight of a run is a change, and the target must look at it"
3060 );
3061 assert!(second.is_quiet(), "nothing changed; three 304s");
3062 assert_eq!(second.total(), 1, "the job is still counted from the cache");
3063 let received = server.received_requests().await.expect("recorded requests");
3064 assert_eq!(conditional_requests(&received), 3);
3065 }
3066
3067 /// The quota GitHub reports during the poll travels with the reading, so
3068 /// `e1` can slow down on it.
3069 #[tokio::test]
3070 async fn the_accounts_quota_travels_with_the_reading() {
3071 let server = MockServer::start().await;
3072 mount_tagged_idle(&server, &repo()).await;
3073 let clock = Arc::new(TestClock::default());
3074 let gateway = gateway_at(&server, &clock);
3075
3076 let reading = gateway
3077 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3078 .await
3079 .expect("a reading");
3080
3081 let quota = reading
3082 .rate_limit()
3083 .expect("every response carries the quota");
3084 assert_eq!(
3085 (quota.limit, quota.remaining, quota.used),
3086 (5000, 4000, Some(1000))
3087 );
3088 }
3089
3090 /// Two targets covering one repository make one read between them.
3091 #[tokio::test]
3092 async fn a_repository_is_read_once_for_every_target_that_covers_it() {
3093 let server = MockServer::start().await;
3094 mount_tagged_idle(&server, &repo()).await;
3095 mount_tagged_idle(&server, &other_repo()).await;
3096 let clock = Arc::new(TestClock::default());
3097 let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3098
3099 // A repository target and an organization target that contains it.
3100 gateway
3101 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3102 .await
3103 .expect("the repository target's reading");
3104 let organization = gateway
3105 .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
3106 .await
3107 .expect("the organization target's reading");
3108
3109 assert_eq!(organization.per_repository().len(), 2);
3110 assert_eq!(
3111 gateway.requests_issued(),
3112 4,
3113 "dashboard once (2 listings) and api once (2): the organization target is \
3114 handed the repository target's reading of dashboard"
3115 );
3116 assert_eq!(gateway.stats().shared_reads, 1);
3117
3118 // Past the window the repository is read again.
3119 clock.advance_secs(10);
3120 gateway
3121 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3122 .await
3123 .expect("a later reading");
3124 assert_eq!(gateway.requests_issued(), 6);
3125 }
3126
3127 /// A coordinator's wake drops the shared reading, so the woken poll reads
3128 /// GitHub inside the window instead of being handed the stale one.
3129 #[tokio::test]
3130 async fn a_forgotten_reading_is_read_again_inside_the_window() {
3131 let server = MockServer::start().await;
3132 mount_tagged_idle(&server, &repo()).await;
3133 let clock = Arc::new(TestClock::default());
3134 let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3135 let scope = ActivityScope::repository(repo());
3136
3137 gateway
3138 .queued_demand(&scope, &CancelToken::new())
3139 .await
3140 .expect("a first reading");
3141 gateway
3142 .queued_demand(&scope, &CancelToken::new())
3143 .await
3144 .expect("shared");
3145 assert_eq!(gateway.requests_issued(), 2, "the second was shared");
3146
3147 gateway.forget_shared_reading(&repo());
3148 gateway
3149 .queued_demand(&scope, &CancelToken::new())
3150 .await
3151 .expect("read again");
3152 assert_eq!(gateway.requests_issued(), 4);
3153 }
3154
3155 /// Two loops asking at the same moment make one read between them.
3156 #[tokio::test]
3157 async fn concurrent_targets_wait_for_one_read_instead_of_making_two() {
3158 let server = MockServer::start().await;
3159 mount_tagged_idle(&server, &repo()).await;
3160 let clock = Arc::new(TestClock::default());
3161 let gateway =
3162 Arc::new(gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10)));
3163
3164 let scope = ActivityScope::repository(repo());
3165 let cancel = CancelToken::new();
3166 let (left, right) = tokio::join!(
3167 gateway.queued_demand(&scope, &cancel),
3168 gateway.queued_demand(&scope, &cancel),
3169 );
3170 left.expect("one reading");
3171 right.expect("the other reading");
3172 assert_eq!(gateway.requests_issued(), 2, "one read: two listings");
3173 }
3174
3175 fn secondary_limit(retry_after: Option<&str>) -> ResponseTemplate {
3176 let template = ResponseTemplate::new(429).set_body_json(json!({
3177 "message": "You have exceeded a secondary rate limit"
3178 }));
3179 match retry_after {
3180 Some(seconds) => template.insert_header("retry-after", seconds),
3181 None => template,
3182 }
3183 }
3184
3185 /// GitHub: "If the `retry-after` response header is present, you should
3186 /// not retry your request until after that many seconds has elapsed." Not
3187 /// this poll, and not any other target's poll on this host either.
3188 #[tokio::test]
3189 async fn a_secondary_limit_stops_every_demand_request_until_retry_after() {
3190 let server = MockServer::start().await;
3191 Mock::given(method("GET"))
3192 .respond_with(secondary_limit(Some("42")))
3193 .mount(&server)
3194 .await;
3195 let clock = Arc::new(TestClock::default());
3196 let gateway = gateway_at(&server, &clock);
3197
3198 let first = gateway
3199 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3200 .await
3201 .expect_err("a rate limit");
3202 assert_eq!(
3203 first.rate_limited().and_then(|limit| limit.retry_after),
3204 Some(Duration::from_secs(42))
3205 );
3206
3207 // Another target, half-way through the window: no request at all.
3208 clock.advance_secs(20);
3209 let held = gateway
3210 .queued_demand(
3211 &ActivityScope::repository(other_repo()),
3212 &CancelToken::new(),
3213 )
3214 .await
3215 .expect_err("still inside the quiet period");
3216 assert_eq!(
3217 held.rate_limited().and_then(|limit| limit.retry_after),
3218 Some(Duration::from_secs(22)),
3219 "the wait left, so the loop's schedule sleeps exactly that"
3220 );
3221 assert_eq!(
3222 server.received_requests().await.expect("recorded").len(),
3223 1,
3224 "a request inside the quiet period is the one GitHub said not to send"
3225 );
3226 assert_eq!(gateway.stats().suppressed, 1);
3227
3228 // At the end of the window, the next poll asks again.
3229 clock.advance_secs(22);
3230 let _ = gateway
3231 .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3232 .await;
3233 assert_eq!(server.received_requests().await.expect("recorded").len(), 2);
3234 }
3235
3236 /// GitHub: "Otherwise, wait for at least one minute before retrying. If
3237 /// your request continues to fail due to a secondary rate limit, wait for
3238 /// an exponentially increasing amount of time between retries."
3239 #[tokio::test]
3240 async fn a_recurring_secondary_limit_doubles_the_quiet_period() {
3241 let server = MockServer::start().await;
3242 Mock::given(method("GET"))
3243 .respond_with(secondary_limit(None))
3244 .mount(&server)
3245 .await;
3246 let clock = Arc::new(TestClock::default());
3247 let gateway = gateway_at(&server, &clock);
3248 let scope = ActivityScope::repository(repo());
3249
3250 let mut waits = Vec::new();
3251 for _ in 0..4 {
3252 let error = gateway
3253 .queued_demand(&scope, &CancelToken::new())
3254 .await
3255 .expect_err("a rate limit");
3256 let wait = error
3257 .rate_limited()
3258 .and_then(|limit| limit.retry_after)
3259 .expect("a wait");
3260 waits.push(wait.as_secs());
3261 clock.advance_secs(i64::try_from(wait.as_secs()).expect("fits"));
3262 }
3263 assert_eq!(waits, [60, 120, 240, 480]);
3264 }
3265
3266 fn secondary(retry_after: Option<u64>) -> RateLimited {
3267 RateLimited {
3268 kind: RateLimitKind::Secondary,
3269 retry_after: retry_after.map(Duration::from_secs),
3270 remaining: Some(4_000),
3271 reset_unix_secs: None,
3272 }
3273 }
3274
3275 /// Every loop shares the gateway, so one secondary limit can come back on
3276 /// several requests that were in flight together. That is one episode.
3277 #[tokio::test]
3278 async fn answers_to_one_secondary_limit_in_flight_together_are_one_episode() {
3279 let server = MockServer::start().await;
3280 let clock = Arc::new(TestClock::default());
3281 let gateway = gateway_at(&server, &clock);
3282 let now = clock.now();
3283
3284 let waits: Vec<u64> = (0..4)
3285 .map(|_| {
3286 gateway
3287 .hold_for(secondary(None), now)
3288 .retry_after
3289 .expect("a wait")
3290 .as_secs()
3291 })
3292 .collect();
3293 assert_eq!(waits, [60, 60, 60, 60], "four answers, one limit");
3294
3295 clock.advance_secs(60);
3296 let again = gateway.hold_for(secondary(None), clock.now());
3297 assert_eq!(
3298 again.retry_after,
3299 Some(Duration::from_secs(120)),
3300 "an answer after the hold ended is GitHub saying \"still limited\""
3301 );
3302 }
3303
3304 /// A primary limit with a near reset must not reopen what a secondary
3305 /// limit closed.
3306 #[tokio::test]
3307 async fn a_later_answer_never_shortens_a_hold_in_force() {
3308 let server = MockServer::start().await;
3309 let clock = Arc::new(TestClock::default());
3310 let gateway = gateway_at(&server, &clock);
3311
3312 let _ = gateway.hold_for(secondary(Some(600)), clock.now());
3313 let primary = RateLimited {
3314 kind: RateLimitKind::Primary,
3315 retry_after: None,
3316 remaining: Some(0),
3317 reset_unix_secs: Some(u64::try_from(clock.now().timestamp() + 30).expect("positive")),
3318 };
3319 let after = gateway.hold_for(primary, clock.now());
3320 assert_eq!(after.retry_after, Some(Duration::from_secs(600)));
3321 clock.advance_secs(300);
3322 assert_eq!(
3323 gateway
3324 .held(clock.now())
3325 .and_then(|limit| limit.retry_after),
3326 Some(Duration::from_secs(300))
3327 );
3328 }
3329
3330 #[test]
3331 fn an_unavailable_repository_is_not_quiet() {
3332 assert!(QueuedDemand::default().is_quiet());
3333 assert!(
3334 !QueuedDemand::default()
3335 .with_unavailable(repo(), "404")
3336 .is_quiet(),
3337 "its failing listing is charged on every poll; polling it every few seconds \
3338 would multiply that"
3339 );
3340 }
3341
3342 /// A draining loop must not wait behind another loop's read.
3343 #[tokio::test]
3344 async fn a_cancelled_loop_does_not_wait_for_another_loops_read() {
3345 let server = MockServer::start().await;
3346 let clock = Arc::new(TestClock::default());
3347 let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3348 let gate = Arc::clone(
3349 gateway
3350 .shared
3351 .gates
3352 .lock()
3353 .expect("gates")
3354 .entry(repo())
3355 .or_default(),
3356 );
3357 let _another_loop = gate.lock().await;
3358 let cancel = CancelToken::new();
3359 cancel.cancel();
3360
3361 let answer = tokio::time::timeout(
3362 Duration::from_secs(5),
3363 gateway.repository_reading(&repo(), &cancel),
3364 )
3365 .await
3366 .expect("a cancelled loop returns at once instead of queueing on the gate");
3367 assert!(answer.is_err_and(|error| error.is_cancelled()));
3368 }
3369
3370 /// A success in between resets the doubling.
3371 #[tokio::test]
3372 async fn a_success_resets_the_doubling() {
3373 let server = MockServer::start().await;
3374 Mock::given(method("GET"))
3375 .respond_with(secondary_limit(None))
3376 .up_to_n_times(1)
3377 .mount(&server)
3378 .await;
3379 mount_idle(&server, &repo()).await;
3380 let clock = Arc::new(TestClock::default());
3381 let gateway = gateway_at(&server, &clock);
3382 let scope = ActivityScope::repository(repo());
3383
3384 let _ = gateway.queued_demand(&scope, &CancelToken::new()).await;
3385 clock.advance_secs(60);
3386 gateway
3387 .queued_demand(&scope, &CancelToken::new())
3388 .await
3389 .expect("GitHub answered");
3390 server.reset().await;
3391 Mock::given(method("GET"))
3392 .respond_with(secondary_limit(None))
3393 .mount(&server)
3394 .await;
3395 let again = gateway
3396 .queued_demand(&scope, &CancelToken::new())
3397 .await
3398 .expect_err("a fresh rate limit");
3399 assert_eq!(
3400 again.rate_limited().and_then(|limit| limit.retry_after),
3401 Some(Duration::from_secs(60)),
3402 "a limit after a success is a first limit again"
3403 );
3404 }
3405
3406 // -- cancellation -------------------------------------------------------
3407
3408 /// A token flipped between requests stops the poll before the next one.
3409 #[tokio::test]
3410 async fn cancellation_between_requests_stops_the_poll() {
3411 let server = MockServer::start().await;
3412 mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 0).await;
3413
3414 let gateway = gateway(&server);
3415 let cancel = CancelToken::new();
3416
3417 let first = gateway
3418 .repository_queued(&repo(), &cancel)
3419 .await
3420 .is_ok_and(|reading| reading.jobs.len() == 3);
3421 assert!(first, "the uncancelled poll reads both lists and the jobs");
3422 assert_eq!(gateway.requests_issued(), 3);
3423
3424 cancel.cancel();
3425 let error = gateway
3426 .repository_queued(&repo(), &cancel)
3427 .await
3428 .expect_err("a cancelled token opens no socket at all");
3429 assert!(error.is_cancelled());
3430 assert_eq!(
3431 gateway.requests_issued(),
3432 3,
3433 "a cancelled poll must spend nothing; the count is of requests attempted"
3434 );
3435 }
3436
3437 // -- the seam this module does not own ---------------------------------
3438
3439 /// The `runs-on` predicate is `b1`'s. This module builds its **input** and
3440 /// implements no part of the matching.
3441 ///
3442 /// # Read this before concluding the test is in the wrong file
3443 ///
3444 /// `c4`'s specification asks for "a `runs-on` table covering forms that must
3445 /// match, forms that must not, and an unresolvable expression, delegating
3446 /// the predicate to `b1`". For a while that table had no input: an owner
3447 /// decision had removed the per-run job listing, a workflow *run* carries no
3448 /// `runs-on`, and this gateway therefore produced nothing to match. That
3449 /// decision has been reversed — the module documentation says why — so the
3450 /// table has its input back and this test asserts both halves of the seam
3451 /// rather than only the delegation half.
3452 ///
3453 /// The division of labour is: `c4` reads each queued job's `labels` array
3454 /// and builds a [`RunsOn`], `b1` decides what matches, and `e1` applies the
3455 /// decision per policy. Constructing a `RunsOn` here is the correct side of
3456 /// that line; comparing labels here is not.
3457 ///
3458 /// # How the second half of that claim is asserted, and how far it reaches
3459 ///
3460 /// By reading this file's own source, because nothing done to `b1` can prove
3461 /// anything about what *this* module contains: every assertion in the body
3462 /// below would pass unchanged with a full label matcher sitting beside it.
3463 /// The scan takes the production half — everything above `#[cfg(test)]`,
3464 /// with comment lines dropped so that the module documentation may keep
3465 /// explaining the seam — and requires that it names none of `b1`'s
3466 /// *decision* vocabulary.
3467 ///
3468 /// `RunsOn` is no longer in the forbidden set, and could not be: this module
3469 /// constructs one per queued job, which is the whole point of the reversal.
3470 /// What stays forbidden is everything that would mean deciding rather than
3471 /// describing — `RoutingLabels`, whose `matches` and `tally` are the
3472 /// predicate, and `DemandTally`, which is the predicate's result. A `c4`
3473 /// that named either would be filtering, and filtering here would make the
3474 /// poll per-policy rather than per-target; the module documentation explains
3475 /// why that trade is refused.
3476 ///
3477 /// Like the needles in `nothing_in_this_crate_reserves_or_claims_a_job`, it
3478 /// is a tripwire on the obvious shape rather than a proof: a hand-rolled
3479 /// comparison of raw label strings that never names a `policy` type would
3480 /// walk past it. Stated rather than implied, for the same reason it is
3481 /// stated there.
3482 #[test]
3483 fn the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it() {
3484 let labels = RoutingLabels::derive(
3485 &HostLabel::new("home").expect("a valid host label"),
3486 Os::Windows,
3487 Arch::X64,
3488 );
3489 let host = labels.host_label().as_str().to_string();
3490 assert_eq!(host, "rm-home-win-x64");
3491
3492 // Must match: the host label alone, in each documented form.
3493 for form in [
3494 RunsOn::Single(host.clone()),
3495 RunsOn::Many(vec![host.clone()]),
3496 RunsOn::Grouped {
3497 group: Some("Default".into()),
3498 labels: runner_manager_domain::policy::RunsOnLabels::One(host.clone()),
3499 },
3500 ] {
3501 assert!(
3502 labels.matches(&form).is_match(),
3503 "a job requiring only this policy's own label must match: {form:?}"
3504 );
3505 }
3506
3507 // Must not match: GitHub-hosted, and another host's label.
3508 for form in [
3509 RunsOn::Single("ubuntu-latest".into()),
3510 RunsOn::Many(vec![host.clone(), "rm-office-win-x64".into()]),
3511 ] {
3512 assert!(
3513 !labels.matches(&form).is_match(),
3514 "a job requiring a label this policy does not carry must not match: {form:?}"
3515 );
3516 }
3517
3518 // Unresolvable: an expression only GitHub can evaluate. Never demand and
3519 // never discarded.
3520 let expression = RunsOn::Single("${{ matrix.runner }}".into());
3521 assert!(matches!(
3522 labels.matches(&expression),
3523 RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
3524 ));
3525
3526 // And the tally keeps the three apart, which is what `e1` clamps.
3527 let tally = labels.tally(&[
3528 RunsOn::Single(host.clone()),
3529 RunsOn::Single("ubuntu-latest".into()),
3530 expression,
3531 ]);
3532 assert_eq!(tally.demand(), 1);
3533 assert_eq!(tally.not_matched, 1);
3534 assert_eq!(tally.unresolvable.len(), 1);
3535 assert_eq!(tally.total_seen(), 3);
3536
3537 // The form this gateway actually builds is the array one, because that
3538 // is the shape the jobs API returns. Pinned so that a `RunsOn` built
3539 // from a job's `labels` really is a value `b1`'s predicate accepts,
3540 // rather than one that happens to compile.
3541 assert_eq!(
3542 RunsOn::from_job_labels(["self-hosted", host.as_str()]),
3543 RunsOn::Many(vec!["self-hosted".into(), host.clone()])
3544 );
3545 assert!(
3546 labels
3547 .matches(&RunsOn::from_job_labels([host.as_str()]))
3548 .is_match()
3549 );
3550 // And the direction that surprises people: a bare derived set does not
3551 // carry `self-hosted`, so the shape most workflows are written in --
3552 // `runs-on: [self-hosted, windows]` -- does **not** match a policy whose
3553 // operator never added those labels. That is `b1`'s superset rule
3554 // working as specified rather than a gap, and it is asserted here
3555 // because the job listing is what finally made it observable.
3556 assert!(
3557 !labels
3558 .matches(&RunsOn::from_job_labels(["self-hosted", host.as_str()]))
3559 .is_match(),
3560 "a job requiring `self-hosted` needs a policy carrying `self-hosted`"
3561 );
3562
3563 // And the other half of this test's title, which everything above leaves
3564 // untouched: that the predicate exercised here has no second
3565 // implementation in this file. See the documentation on this test for
3566 // what the scan covers, what it does not, and why `RunsOn` is absent
3567 // from the list.
3568 let production = this_file_above_its_tests_without_prose();
3569 for owned_by_b1 in ["RoutingLabels", "DemandTally"] {
3570 assert!(
3571 !production.contains(owned_by_b1),
3572 "the demand gateway names `{owned_by_b1}`, which belongs to `b1`: this \
3573 module builds the predicate's input and does not apply it. Filtering \
3574 here would make the poll per-policy rather than per-target and multiply \
3575 its request cost by the number of policies sharing a target -- if an \
3576 owner decision changed that, it belongs in this module's documentation \
3577 and in this test before it belongs in the code"
3578 );
3579 }
3580 assert!(
3581 production.contains("RunsOn"),
3582 "and the gateway must still *build* a `RunsOn` per queued job; a production \
3583 half that named none would mean the job listing had been removed again and \
3584 the serial-matrix defect restored"
3585 );
3586 }
3587
3588 /// This file's source above its test module, with comment lines dropped.
3589 ///
3590 /// Two exclusions, each load-bearing. The **test module** goes because the
3591 /// tests above legitimately drive `b1`'s predicate and would accuse the file
3592 /// of owning what they are proving it delegates. The **comments** go because
3593 /// this module's documentation explains the seam at length and names the
3594 /// types to do it — a scan that forbade the explanation would get the
3595 /// explanation deleted, which is the trade
3596 /// `nothing_in_this_crate_reserves_or_claims_a_job` records making in the
3597 /// other direction.
3598 fn this_file_above_its_tests_without_prose() -> String {
3599 let (production, _) = include_str!("demand.rs")
3600 .split_once("\n#[cfg(test)]")
3601 .expect("this file has a test module, and the scan is meaningless without one");
3602 production
3603 .lines()
3604 .filter(|line| !line.trim_start().starts_with("//"))
3605 .collect::<Vec<_>>()
3606 .join("\n")
3607 }
3608
3609 /// The one normalisation both halves of the reservation scan use.
3610 ///
3611 /// Shared rather than written twice, because the defect it closes was two
3612 /// spellings of "the same" normalisation drifting apart: the haystack was
3613 /// lower-cased and the needle was not, so every needle carrying a capital —
3614 /// which is every type-shaped one — could not match a lower-cased haystack,
3615 /// and three of the seven assertions were vacuously true from the day they
3616 /// were written. One function cannot disagree with itself.
3617 fn normalise_for_scan(text: &str) -> String {
3618 text.to_ascii_lowercase().replace(['_', ' '], "")
3619 }
3620
3621 // The Actions-service call this design has no equivalent of, plus the shapes
3622 // an implementer would invent in its place. Matched case-insensitively with
3623 // `_` and spaces removed, so one needle catches the snake, camel and Pascal
3624 // spellings of an identifier at once — and so that the singular needle
3625 // catches the plural.
3626 //
3627 // # Why every needle carries `fn` or `struct`
3628 //
3629 // A bare `acquirejobs` fires on this crate's own prose, and it was written
3630 // that way first: three modules explain *why* there is no job reservation,
3631 // and each has to name the call that does not exist in order to say so. A
3632 // scan that forbids the explanation is a scan that gets the explanation
3633 // deleted, which costs more than it protects. Requiring the item keyword
3634 // narrows the needle to a *definition*, which is what the rule is actually
3635 // about.
3636 //
3637 // What this therefore does **not** catch is stated rather than implied: a
3638 // reservation reached through a trait method, a closure, or a
3639 // differently-named helper. It is a tripwire on the obvious shape, and
3640 // `c4`'s Definition of Done names review as the primary control.
3641 //
3642 // # And why every needle is spelled in halves
3643 //
3644 // `lib.rs`'s confidential-credential scan solves the same problem the same
3645 // way: a needle written out whole would appear in this file's own source and
3646 // the scan would accuse itself. Normalising
3647 // `concat!("fn ", "acquire", "_job")` leaves the quote-comma-quote between
3648 // the halves, so no needle ever appears whole in the text being scanned.
3649 const FORBIDDEN: &[&str] = &[
3650 concat!("fn ", "acquire", "_job"),
3651 concat!("fn ", "claim", "_job"),
3652 concat!("fn ", "lease", "_job"),
3653 concat!("fn ", "reserve", "_job"),
3654 // The acknowledgement this test's own title names alongside the other
3655 // three, and which the list did not actually carry. Spelled to the verb
3656 // rather than to a `_job` suffix, because the shape an implementer
3657 // reaches for acknowledges a *message* or an *assignment* as readily as
3658 // a job, and a suffixed needle would walk straight past those.
3659 concat!("fn ", "ack", "nowledge"),
3660 concat!("struct ", "Job", "Lease"),
3661 concat!("struct ", "Job", "Claim"),
3662 concat!("struct ", "Job", "Reservation"),
3663 ];
3664
3665 /// Which forbidden shape a source text names, if any.
3666 ///
3667 /// The whole-crate scan and its positive control both go through here, so a
3668 /// normalisation that cannot see a shape fails the control loudly instead of
3669 /// passing the scan silently. That is the entire point of the indirection:
3670 /// the arrangement this replaced had the control re-deriving the needle
3671 /// itself, and a copy that agrees with a buggy original proves nothing — it
3672 /// re-derived the needle the same wrong way and went green.
3673 fn forbidden_shape_in(source: &str) -> Option<&'static str> {
3674 let haystack = normalise_for_scan(source);
3675 FORBIDDEN
3676 .iter()
3677 .copied()
3678 .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
3679 }
3680
3681 /// No reservation, claim, lease, or acknowledgement call exists anywhere in
3682 /// this crate.
3683 ///
3684 /// `AcquireJobs` had no REST replacement, so a well-meaning implementer
3685 /// reaches for a local lease to "fix" the surplus-runner case. Three
3686 /// specifications say not to; this makes the instruction executable, over
3687 /// the whole crate rather than only this file, because the edit would most
3688 /// likely land in a new module rather than here.
3689 #[test]
3690 fn nothing_in_this_crate_reserves_or_claims_a_job() {
3691 const SOURCES: &[(&str, &str)] = &[
3692 ("conditional.rs", include_str!("conditional.rs")),
3693 ("demand.rs", include_str!("demand.rs")),
3694 ("device_flow.rs", include_str!("device_flow.rs")),
3695 ("jit.rs", include_str!("jit.rs")),
3696 ("lib.rs", include_str!("lib.rs")),
3697 ("rest.rs", include_str!("rest.rs")),
3698 ("traffic.rs", include_str!("traffic.rs")),
3699 ];
3700 // `SOURCES` is a snapshot, and a snapshot makes "anywhere in the crate"
3701 // false the moment a file is added. The walk below turns the claim back
3702 // into a claim.
3703 //
3704 // It **recurses**, and that is not incidental. `lib.rs` records the
3705 // defect a single `read_dir` produced for the same pin: a module
3706 // directory (`src/rest/mod.rs`) arrives as the entry `rest`, which does
3707 // not end in `.rs`, so a flat filter drops it and takes every file
3708 // underneath with it — leaving the pin passing while the files it exists
3709 // to cover are scanned by nothing at all.
3710 fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
3711 for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
3712 let entry = entry.expect("a readable directory entry");
3713 let name = entry.file_name().to_string_lossy().into_owned();
3714 // `/`-joined, which is what `include_str!` takes on every
3715 // platform, so the two sides compare directly.
3716 let joined = if prefix.is_empty() {
3717 name.clone()
3718 } else {
3719 format!("{prefix}/{name}")
3720 };
3721 if entry.path().is_dir() {
3722 walk(&entry.path(), &joined, found);
3723 } else if name.ends_with(".rs") {
3724 found.push(joined);
3725 }
3726 }
3727 }
3728
3729 let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
3730 listed.sort_unstable();
3731 let mut on_disk = Vec::new();
3732 walk(
3733 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
3734 "",
3735 &mut on_disk,
3736 );
3737 on_disk.sort_unstable();
3738 assert_eq!(
3739 listed, on_disk,
3740 "a source file was added or removed; this scan claims to cover the whole \
3741 crate and a stale list makes that claim false"
3742 );
3743
3744 for (name, source) in SOURCES {
3745 assert_eq!(
3746 forbidden_shape_in(source),
3747 None,
3748 "{name} names a forbidden shape: there is no job reservation on the REST \
3749 path, and a local lease coordinates this host with itself and with \
3750 nothing else"
3751 );
3752 }
3753 }
3754
3755 /// The scan above can actually see the things it forbids.
3756 ///
3757 /// A substring scan that never matches passes for the wrong reason. This
3758 /// plants the exact shapes and runs them through [`forbidden_shape_in`] —
3759 /// the same matcher the scan uses, rather than a second copy of it.
3760 ///
3761 /// # Why one planted shape was not enough
3762 ///
3763 /// It planted only the `fn` form, and that form is the one that could not
3764 /// fail: a `fn` needle is already lower-case, so it survived a
3765 /// normalisation that lower-cased the haystack and not the needle. Every
3766 /// type-shaped needle carries capitals and therefore could never match the
3767 /// lower-cased haystack — all three of them were un-catchable, and their
3768 /// three assertions vacuously true, while this control stayed green. (They
3769 /// are not written out here for the reason the list itself is spelled in
3770 /// halves: a literal would make this file fail its own gate.) Both kinds
3771 /// are planted now, and the case-shape of the plant is the property under
3772 /// test rather than an incidental detail of it.
3773 #[test]
3774 fn the_reservation_scan_catches_an_injected_reservation() {
3775 // Assembled from fragments rather than written out, because the scan
3776 // above reads this very file: a literal here would make the crate fail
3777 // its own gate, which is the trap that forced the needles to carry an
3778 // item keyword in the first place.
3779 let call = format!(
3780 " async {} {}{}(&self) -> Result<Vec<Job>, InventoryError> {{",
3781 "fn", "acquire", "_jobs"
3782 );
3783 let item = format!("{} {}{} {{ id: u64 }}", "struct", "Job", "Lease");
3784 let acknowledgement = format!(
3785 " async {} {}{}(&self, id: u64) {{",
3786 "fn", "ack", "nowledge_assignment"
3787 );
3788
3789 for (planted, expected) in [
3790 (&call, concat!("fn ", "acquire", "_job")),
3791 (&item, concat!("struct ", "Job", "Lease")),
3792 (&acknowledgement, concat!("fn ", "ack", "nowledge")),
3793 ] {
3794 assert_eq!(
3795 forbidden_shape_in(planted),
3796 Some(expected),
3797 "the scan's own matcher cannot see {planted:?}, so every negative \
3798 assertion it makes about that shape is worthless"
3799 );
3800 }
3801
3802 // And the plural really is caught by the singular needle, which is the
3803 // one thing about the list above that is not obvious from reading it.
3804 assert!(
3805 call.contains("acquire_jobs"),
3806 "the planted shape is the plural the Actions-service protocol used"
3807 );
3808 // Likewise the acknowledgement needle stops at the verb, so it catches
3809 // the shapes that acknowledge something other than a job by name.
3810 assert!(
3811 acknowledgement.contains("_assignment"),
3812 "the planted acknowledgement names no job, which is why the needle \
3813 carrying a `_job` suffix would have missed it"
3814 );
3815 }
3816}