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