use std::{
collections::{BTreeMap, BTreeSet},
fmt,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use runner_manager_domain::{
model::{Clock, OwnerRepo, TargetScope, Timestamp},
policy::RunsOn,
};
use serde::Deserialize;
use crate::{
ApiRequest, ApiResponse, AuthenticatedClient, GithubError,
rest::{
ActivityScope, CancelToken, InventoryError, PER_PAGE, RateLimited, TargetCost,
UnavailableRepository,
},
};
pub const QUEUED_RUN_STATUS: &str = "queued";
pub const IN_PROGRESS_RUN_STATUS: &str = "in_progress";
pub const QUEUED_JOB_STATUS: &str = "queued";
pub const LATEST_JOBS_FILTER: &str = "latest";
pub const DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL: u32 = 4;
pub const MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL: usize = 6;
pub const MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL: usize = 4;
pub const MAX_JOB_PAGES_PER_RUN: usize = 3;
#[must_use]
pub const fn max_demand_requests_per_repository_per_poll() -> u32 {
2 + (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u32)
+ (MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL as u32)
}
const _: () = assert!(
DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL > 0,
"a demand poll costs at least the requests that fetched the run lists"
);
const _: () = assert!(
DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL <= max_demand_requests_per_repository_per_poll(),
"the projected demand cost must fit inside the worst case the caps allow"
);
const _: () = assert!(
DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL >= 2,
"every poll issues both run listings, so the projection cannot be below two"
);
const _: () = assert!(
MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL <= MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
"the in-progress safety net may not be given a larger budget than the primary signal"
);
const MAX_BENIGN_TOTAL_COUNT_SKEW: u64 = 16;
const _: () = assert!(
MAX_BENIGN_TOTAL_COUNT_SKEW > 0,
"a zero skew re-creates the check that trips on a run leaving the queue mid-serialisation"
);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct QueuedDemand {
per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>,
unavailable: Vec<UnavailableRepository>,
truncated: BTreeSet<OwnerRepo>,
}
impl QueuedDemand {
#[must_use]
pub fn new(per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>) -> Self {
Self {
per_repository,
unavailable: Vec::new(),
truncated: BTreeSet::new(),
}
}
#[must_use]
pub fn of(repository: OwnerRepo, jobs: impl IntoIterator<Item = RunsOn>) -> Self {
Self::new(BTreeMap::from([(
repository,
jobs.into_iter().collect::<Vec<_>>(),
)]))
}
#[must_use]
pub fn with_truncated(mut self, repository: OwnerRepo) -> Self {
self.truncated.insert(repository);
self
}
#[must_use]
pub fn with_unavailable(mut self, repository: OwnerRepo, reason: impl Into<String>) -> Self {
self.unavailable.push(UnavailableRepository {
repository,
reason: reason.into(),
});
self
}
#[must_use]
pub fn total(&self) -> u32 {
self.per_repository.values().fold(0_u32, |sum, jobs| {
sum.saturating_add(u32::try_from(jobs.len()).unwrap_or(u32::MAX))
})
}
#[must_use]
pub fn per_repository(&self) -> &BTreeMap<OwnerRepo, Vec<RunsOn>> {
&self.per_repository
}
#[must_use]
pub fn for_repository(&self, repository: &OwnerRepo) -> Option<u32> {
self.per_repository
.get(repository)
.map(|jobs| u32::try_from(jobs.len()).unwrap_or(u32::MAX))
}
#[must_use]
pub fn jobs_for(&self, repository: &OwnerRepo) -> &[RunsOn] {
self.per_repository
.get(repository)
.map_or(&[], Vec::as_slice)
}
pub fn jobs(&self) -> impl Iterator<Item = &RunsOn> {
self.per_repository.values().flat_map(Vec::as_slice)
}
#[must_use]
pub fn unavailable(&self) -> &[UnavailableRepository] {
&self.unavailable
}
#[must_use]
pub fn truncated(&self) -> &BTreeSet<OwnerRepo> {
&self.truncated
}
#[must_use]
pub fn is_truncated(&self, repository: &OwnerRepo) -> bool {
self.truncated.contains(repository)
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.unavailable.is_empty() && self.truncated.is_empty()
}
}
#[must_use]
pub fn demand_requests_per_poll(scope: &ActivityScope) -> u32 {
u32::try_from(scope.repositories().len())
.unwrap_or(u32::MAX)
.saturating_mul(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
}
#[must_use]
pub fn max_demand_requests_per_poll(scope: &ActivityScope) -> u32 {
u32::try_from(scope.repositories().len())
.unwrap_or(u32::MAX)
.saturating_mul(max_demand_requests_per_repository_per_poll())
}
#[must_use]
pub fn target_cost(scope: &ActivityScope) -> TargetCost {
TargetCost::from_activity_scope(scope)
.with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
}
#[async_trait::async_trait]
pub trait DemandGateway: fmt::Debug + Send + Sync {
async fn queued_demand(
&self,
scope: &ActivityScope,
cancel: &CancelToken,
) -> Result<QueuedDemand, InventoryError>;
fn now(&self) -> Timestamp;
}
pub struct RestDemand {
client: Arc<AuthenticatedClient>,
clock: Arc<dyn Clock>,
requests_issued: AtomicU64,
}
impl fmt::Debug for RestDemand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RestDemand")
.field(
"requests_issued",
&self.requests_issued.load(Ordering::Relaxed),
)
.finish_non_exhaustive()
}
}
impl RestDemand {
#[must_use]
pub fn new(client: Arc<AuthenticatedClient>, clock: Arc<dyn Clock>) -> Self {
Self {
client,
clock,
requests_issued: AtomicU64::new(0),
}
}
#[must_use]
pub fn requests_issued(&self) -> u64 {
self.requests_issued.load(Ordering::SeqCst)
}
async fn get(
&self,
request: &ApiRequest,
cancel: &CancelToken,
) -> Result<ApiResponse, InventoryError> {
cancel.check()?;
let result = cancel
.run(async {
self.requests_issued.fetch_add(1, Ordering::SeqCst);
self.client
.send(request)
.await
.map_err(InventoryError::from)
})
.await;
match result {
Ok(response) => Ok(response),
Err(InventoryError::Github(error)) => Err(Self::classify(error)),
Err(other) => Err(other),
}
}
fn classify(error: GithubError) -> InventoryError {
let Some(limit) = RateLimited::detect(&error) else {
return InventoryError::Github(error);
};
tracing::warn!(
kind = %limit.kind,
remaining = limit.remaining,
"GitHub is rate limiting this credential; demand for this poll is unknown, not zero"
);
InventoryError::RateLimited(limit)
}
async fn repository_queued(
&self,
repository: &OwnerRepo,
cancel: &CancelToken,
) -> Result<RepositoryDemand, InventoryError> {
let mut jobs: Vec<RunsOn> = Vec::new();
let mut exact = true;
let mut resolved: BTreeSet<u64> = BTreeSet::new();
for (status, cap) in [
(QUEUED_RUN_STATUS, MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL),
(
IN_PROGRESS_RUN_STATUS,
MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
),
] {
let listing = self.active_runs(repository, status, cap, cancel).await?;
exact &= listing.complete;
for run_id in listing.run_ids {
if !resolved.insert(run_id) {
continue;
}
let run = self.queued_jobs_of_run(repository, run_id, cancel).await?;
exact &= run.complete;
jobs.extend(run.jobs);
}
}
Ok(RepositoryDemand { jobs, exact })
}
async fn active_runs(
&self,
repository: &OwnerRepo,
status: &str,
cap: usize,
cancel: &CancelToken,
) -> Result<RunListing, InventoryError> {
let request = ApiRequest::get(format!(
"/repos/{}/{}/actions/runs",
repository.owner(),
repository.repo()
))
.query("status", status)
.query("per_page", PER_PAGE);
let response = self.get(&request, cancel).await?;
let has_next_page = response.next_page().is_some();
let page: QueuedRunsPage = response.json()?;
let listed = page.workflow_runs.len();
if let Some(total) = page.total_count
&& !has_next_page
&& total != listed as u64
{
if total > (listed as u64).saturating_add(MAX_BENIGN_TOTAL_COUNT_SKEW) {
tracing::warn!(
repository = %repository,
status,
total_count = total,
listed,
"GitHub's `total_count` is far larger than the single page it sent for \
a filtered query, so it is not the filtered count. Demand is counted \
from the jobs and does not depend on this field, but `c3` reads the \
same envelope for a dashboard number and does"
);
} else {
tracing::debug!(
repository = %repository,
status,
total_count = total,
listed,
"GitHub's `total_count` disagrees slightly with the page it arrived \
with; this is the documented race of a run leaving the queue while \
the response was being built"
);
}
}
let run_ids: Vec<u64> = page
.workflow_runs
.iter()
.take(cap)
.map(|run| run.id)
.collect();
Ok(RunListing {
complete: listed <= cap && !has_next_page,
run_ids,
})
}
async fn queued_jobs_of_run(
&self,
repository: &OwnerRepo,
run_id: u64,
cancel: &CancelToken,
) -> Result<RunJobs, InventoryError> {
let mut request = Some(
ApiRequest::get(format!(
"/repos/{}/{}/actions/runs/{run_id}/jobs",
repository.owner(),
repository.repo()
))
.query("filter", LATEST_JOBS_FILTER)
.query("per_page", PER_PAGE),
);
let mut jobs = Vec::new();
let mut pages = 0_usize;
while let Some(next) = request.take() {
if pages >= MAX_JOB_PAGES_PER_RUN {
tracing::warn!(
repository = %repository,
run_id,
pages,
"stopped listing one run's jobs at the page budget; the queued-job \
count for this repository is a floor, not a total"
);
return Ok(RunJobs {
jobs,
complete: false,
});
}
let response = self.get(&next, cancel).await?;
let following = response
.next_page()
.map(|url| ApiRequest::get(url.as_str()));
let page: RunJobsPage = response.json()?;
pages += 1;
jobs.extend(
page.jobs
.into_iter()
.filter(|job| job.status == QUEUED_JOB_STATUS)
.map(|job| RunsOn::from_job_labels(job.labels)),
);
request = following;
}
Ok(RunJobs {
jobs,
complete: true,
})
}
}
fn is_repository_local_failure(error: &InventoryError) -> bool {
match error {
InventoryError::Github(GithubError::Forbidden { .. }) => true,
InventoryError::Github(GithubError::Status { status, .. }) => *status == 404,
_ => false,
}
}
#[async_trait::async_trait]
impl DemandGateway for RestDemand {
async fn queued_demand(
&self,
scope: &ActivityScope,
cancel: &CancelToken,
) -> Result<QueuedDemand, InventoryError> {
let mut demand = QueuedDemand::default();
let aggregating = scope.target().scope() == TargetScope::Organization;
for repository in scope.repositories() {
match self.repository_queued(repository, cancel).await {
Ok(reading) => {
demand
.per_repository
.insert(repository.clone(), reading.jobs);
if !reading.exact {
demand.truncated.insert(repository.clone());
}
}
Err(error) if aggregating && is_repository_local_failure(&error) => {
tracing::warn!(
repository = %repository,
error = %error,
"a repository in this organization could not be polled for demand; \
the aggregate reports it as unavailable rather than as zero"
);
demand.unavailable.push(UnavailableRepository {
repository: repository.clone(),
reason: error.to_string(),
});
}
Err(error) => return Err(error),
}
}
Ok(demand)
}
fn now(&self) -> Timestamp {
self.clock.now()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RepositoryDemand {
jobs: Vec<RunsOn>,
exact: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RunListing {
run_ids: Vec<u64>,
complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RunJobs {
jobs: Vec<RunsOn>,
complete: bool,
}
#[derive(Debug, Deserialize)]
struct QueuedRunsPage {
total_count: Option<u64>,
#[serde(default)]
workflow_runs: Vec<QueuedRun>,
}
#[derive(Debug, Deserialize)]
struct QueuedRun {
id: u64,
}
#[derive(Debug, Deserialize)]
struct RunJobsPage {
#[serde(default)]
jobs: Vec<RunJob>,
}
#[derive(Debug, Deserialize)]
struct RunJob {
#[serde(default)]
status: String,
#[serde(default)]
labels: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{FIXTURE_TOKEN, TestClock};
use crate::{Endpoints, UserAccessToken};
use runner_manager_domain::{
model::{Arch, HostLabel, Label, Org, Os},
policy::{RoutingLabels, RunsOn, RunsOnMatch, UnresolvableRunsOn},
};
use secrecy::SecretString;
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path, query_param},
};
fn repo() -> OwnerRepo {
OwnerRepo::parse("octo/dashboard").expect("a valid owner/repo")
}
fn other_repo() -> OwnerRepo {
OwnerRepo::parse("octo/api").expect("a valid owner/repo")
}
fn third_repo() -> OwnerRepo {
OwnerRepo::parse("octo/tools").expect("a valid owner/repo")
}
fn org_scope(repositories: impl IntoIterator<Item = OwnerRepo>) -> ActivityScope {
ActivityScope::organization(
Org::new("octo-org").expect("a valid organization login"),
repositories,
)
}
fn gateway(server: &MockServer) -> RestDemand {
let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
let client = AuthenticatedClient::new(endpoints, token, Arc::new(TestClock::default()))
.expect("a client over the test server");
RestDemand::new(Arc::new(client), Arc::new(TestClock::default()))
}
fn runs_body(ids: &[u64]) -> serde_json::Value {
json!({
"total_count": ids.len(),
"workflow_runs": ids.iter().map(|id| json!({ "id": id })).collect::<Vec<_>>()
})
}
fn no_runs() -> serde_json::Value {
runs_body(&[])
}
fn jobs_body(labels: &[&str], queued: usize, running: usize) -> serde_json::Value {
let mut jobs = Vec::new();
for _ in 0..queued {
jobs.push(json!({ "status": "queued", "labels": labels }));
}
for _ in 0..running {
jobs.push(json!({ "status": "in_progress", "labels": labels }));
}
json!({ "total_count": jobs.len(), "jobs": jobs })
}
fn runs_path(repository: &OwnerRepo) -> String {
format!(
"/repos/{}/{}/actions/runs",
repository.owner(),
repository.repo()
)
}
fn jobs_path(repository: &OwnerRepo, run_id: u64) -> String {
format!("{}/{run_id}/jobs", runs_path(repository))
}
async fn mount_runs(
server: &MockServer,
repository: &OwnerRepo,
status: &str,
body: serde_json::Value,
) {
Mock::given(method("GET"))
.and(path(runs_path(repository)))
.and(query_param("status", status))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
async fn mount_jobs(
server: &MockServer,
repository: &OwnerRepo,
run_id: u64,
body: serde_json::Value,
) {
Mock::given(method("GET"))
.and(path(jobs_path(repository, run_id)))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
async fn mount_one_queued_run(
server: &MockServer,
repository: &OwnerRepo,
labels: &[&str],
queued: usize,
running: usize,
) {
mount_runs(server, repository, QUEUED_RUN_STATUS, runs_body(&[100])).await;
mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
mount_jobs(server, repository, 100, jobs_body(labels, queued, running)).await;
}
async fn mount_idle(server: &MockServer, repository: &OwnerRepo) {
mount_runs(server, repository, QUEUED_RUN_STATUS, no_runs()).await;
mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
}
fn host_labels() -> RoutingLabels {
RoutingLabels::from_parts(
Label::new("rm-home-win-x64").expect("a valid label"),
[
Label::new("self-hosted").expect("a valid label"),
Label::new("windows").expect("a valid label"),
],
)
}
#[tokio::test]
async fn a_matrix_run_of_eight_jobs_is_eight_units_of_demand_and_not_one() {
let server = MockServer::start().await;
mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 8, 0).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a queued-job count");
assert_eq!(
demand.total(),
8,
"eight jobs in one run are eight runners' worth of work; reading the run \
count here is the defect that forced the owner decision back"
);
assert_eq!(demand.for_repository(&repo()), Some(8));
assert!(demand.is_complete());
assert_eq!(
gateway.requests_issued(),
3,
"two run listings and one job listing for the single active run"
);
}
#[tokio::test]
async fn only_jobs_still_queued_are_counted() {
let server = MockServer::start().await;
mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 5).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a queued-job count");
assert_eq!(
demand.total(),
3,
"five of the eight jobs already have a runner and are not waiting for one"
);
}
#[tokio::test]
async fn each_queued_job_carries_the_runs_on_it_requires() {
let server = MockServer::start().await;
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100, 101])).await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
mount_jobs(
&server,
&repo(),
100,
jobs_body(&["self-hosted", "windows"], 2, 0),
)
.await;
mount_jobs(&server, &repo(), 101, jobs_body(&["ubuntu-latest"], 4, 0)).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a queued-job count");
assert_eq!(
demand.total(),
6,
"the unfiltered depth is every queued job"
);
let tally = host_labels().tally(demand.jobs_for(&repo()));
assert_eq!(
tally.demand(),
2,
"the four `ubuntu-latest` jobs are somebody else's work; before the job \
listing existed all six would have driven this policy toward max_capacity"
);
assert_eq!(tally.not_matched, 4);
}
#[tokio::test]
async fn both_run_statuses_are_polled_and_the_jobs_request_asks_for_the_latest_attempt() {
let server = MockServer::start().await;
mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
let gateway = gateway(&server);
gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("both filters are mounted");
let requests = server.received_requests().await.expect("recorded requests");
assert_eq!(requests.len(), 3);
let queries: Vec<String> = requests
.iter()
.map(|r| r.url.query().unwrap_or_default().to_string())
.collect();
assert!(
queries.iter().any(|q| q.contains("status=queued")),
"the primary signal is the queued run list; sent {queries:?}"
);
assert!(
queries.iter().any(|q| q.contains("status=in_progress")),
"the safety net catches a `needs:`-gated job whose run has already \
started; sent {queries:?}"
);
assert!(
queries
.iter()
.any(|q| q.contains(&format!("filter={LATEST_JOBS_FILTER}"))),
"`filter=all` would count every attempt of a re-run job as present \
demand; sent {queries:?}"
);
assert!(
queries
.iter()
.all(|q| q.contains(&format!("per_page={PER_PAGE}"))),
"asking for fewer than GitHub's maximum multiplies the request count \
against the budget this module projects; sent {queries:?}"
);
}
#[tokio::test]
async fn the_queued_run_list_is_read_before_the_in_progress_one() {
let server = MockServer::start().await;
mount_idle(&server, &repo()).await;
let gateway = gateway(&server);
gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("an idle repository still answers");
let requests = server.received_requests().await.expect("recorded requests");
let queries: Vec<String> = requests
.iter()
.map(|r| r.url.query().unwrap_or_default().to_string())
.collect();
assert_eq!(queries.len(), 2, "an idle repository lists no run's jobs");
assert!(
queries[0].contains("status=queued"),
"the primary signal is read first; sent {queries:?}"
);
assert!(
queries[1].contains("status=in_progress"),
"the safety net is read second; sent {queries:?}"
);
}
#[tokio::test]
async fn an_idle_repository_costs_only_the_two_run_listings() {
let server = MockServer::start().await;
mount_idle(&server, &repo()).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("an idle repository answers zero rather than failing");
assert_eq!(demand.total(), 0);
assert!(
demand.is_complete(),
"zero from a repository that answered is a measurement, not a floor"
);
assert_eq!(gateway.requests_issued(), 2);
assert!(
gateway.requests_issued() <= u64::from(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL),
"the idle case must sit inside the projection, which is the number `f2`'s \
refusals are computed from"
);
}
#[tokio::test]
async fn an_in_progress_run_with_no_queued_job_contributes_only_its_request() {
let server = MockServer::start().await;
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 0, 4)).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a queued-job count");
assert_eq!(demand.total(), 0);
assert!(demand.is_complete());
assert_eq!(gateway.requests_issued(), 3);
}
#[tokio::test]
async fn a_run_in_both_listings_is_resolved_once_and_not_counted_twice() {
let server = MockServer::start().await;
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[100])).await;
mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 3, 0)).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a queued-job count");
assert_eq!(
demand.total(),
3,
"the run holds three queued jobs, and appearing in both listings does not make it six"
);
assert_eq!(
gateway.requests_issued(),
3,
"and the duplicate costs no second job listing either"
);
}
#[tokio::test]
async fn a_job_that_enters_the_queue_after_its_run_started_is_still_found() {
let server = MockServer::start().await;
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 1, 1)).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a queued-job count");
assert_eq!(
demand.total(),
1,
"polling only `status=queued` runs would report zero here and the \
`needs:`-gated job would wait for a machine that is idle"
);
}
#[test]
fn the_wire_shapes_parse_a_payload_github_really_sent() {
let jobs: RunJobsPage = serde_json::from_value(json!({
"total_count": 5,
"jobs": [
{
"id": 101_231_925_899_i64,
"run_id": 33_938_794_901_i64,
"workflow_name": "tests",
"head_branch": "worktree-p1-lock-and-worktree-set",
"run_url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
"run_attempt": 1,
"node_id": "CR_kwDOS_wnss8AAAAXkeSaiw",
"head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
"url": "https://api.github.com/repos/o/r/actions/jobs/101231925899",
"html_url": "https://github.com/o/r/actions/runs/33938794901/job/101231925899",
"status": "completed",
"conclusion": "success",
"created_at": "2026-09-05T02:20:30Z",
"started_at": "2026-09-05T02:26:03Z",
"completed_at": "2026-09-05T02:28:44Z",
"name": "scripts-tests",
"steps": [],
"check_run_url": "https://api.github.com/repos/o/r/check-runs/101231925899",
"labels": ["self-hosted", "windows"],
"runner_id": 725,
"runner_name": "runner-manager-4bd32f05-088f-4b13-a59c-6900b9142aa1",
"runner_group_id": 1,
"runner_group_name": "Default"
},
{
"id": 101_231_925_900_i64,
"run_id": 33_938_794_901_i64,
"status": "queued",
"conclusion": serde_json::Value::Null,
"started_at": serde_json::Value::Null,
"completed_at": serde_json::Value::Null,
"name": "pipeline-tests",
"steps": [],
"labels": ["self-hosted", "windows"],
"runner_id": serde_json::Value::Null,
"runner_name": "",
"runner_group_name": ""
},
{
"id": 101_231_925_901_i64,
"status": "queued",
"name": "scripts-tests-macos",
"labels": ["self-hosted", "macOS", "rm-macmini-osx-arm64"]
}
]
}))
.expect("the jobs page GitHub really sends must deserialize");
assert_eq!(
jobs.jobs.len(),
3,
"every job is read, whatever else it carries"
);
let queued: Vec<RunsOn> = jobs
.jobs
.into_iter()
.filter(|job| job.status == QUEUED_JOB_STATUS)
.map(|job| RunsOn::from_job_labels(job.labels))
.collect();
assert_eq!(
queued,
vec![
RunsOn::Many(vec!["self-hosted".into(), "windows".into()]),
RunsOn::Many(vec![
"self-hosted".into(),
"macOS".into(),
"rm-macmini-osx-arm64".into()
]),
],
"the completed job is dropped and the two queued ones keep their labels"
);
let tally = host_labels().tally(&queued);
assert_eq!(tally.demand(), 1);
assert_eq!(tally.not_matched, 1);
let idle: QueuedRunsPage = serde_json::from_value(json!({
"total_count": 0,
"workflow_runs": []
}))
.expect("an idle run listing must deserialize");
assert_eq!(idle.total_count, Some(0));
assert!(idle.workflow_runs.is_empty());
let busy: QueuedRunsPage = serde_json::from_value(json!({
"total_count": 1,
"workflow_runs": [{
"id": 33_938_794_901_i64,
"name": "tests",
"node_id": "WFR_kwLOS_wnss8AAAAH6oSPFQ",
"head_branch": "main",
"head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
"path": ".github/workflows/tests.yml",
"run_number": 412,
"event": "push",
"status": "queued",
"conclusion": serde_json::Value::Null,
"workflow_id": 213_842_591_i64,
"url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
"created_at": "2026-09-05T02:20:30Z",
"updated_at": "2026-09-05T02:20:31Z"
}]
}))
.expect("a busy run listing must deserialize");
assert_eq!(
busy.workflow_runs
.iter()
.map(|run| run.id)
.collect::<Vec<_>>(),
vec![33_938_794_901],
"the run id is what the job listing is fetched by, and it is a u64: \
GitHub's run ids passed 2^32 long ago, so a u32 here would have \
wrapped on every real repository"
);
}
#[tokio::test]
async fn more_queued_runs_than_the_cap_report_a_floor_rather_than_a_total() {
let server = MockServer::start().await;
let ids: Vec<u64> = (0..(MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u64 + 4))
.map(|i| 100 + i)
.collect();
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&ids)).await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
for id in &ids {
mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
}
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a bounded poll still answers");
assert_eq!(
demand.total() as usize,
MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
"one job resolved per run the cap admits"
);
assert!(
!demand.is_complete(),
"a count clipped by the run cap is a floor and must say so; concluding \
`idle` from one would be the mistake the flag exists to prevent"
);
assert!(demand.is_truncated(&repo()));
}
#[tokio::test]
async fn a_repository_cannot_spend_more_than_the_documented_worst_case() {
let server = MockServer::start().await;
let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
mount_runs(
&server,
&repo(),
IN_PROGRESS_RUN_STATUS,
runs_body(&running),
)
.await;
for id in queued.iter().chain(running.iter()) {
mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
}
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a bounded poll still answers");
assert_eq!(
gateway.requests_issued(),
u64::from(max_demand_requests_per_repository_per_poll()),
"the measured ceiling must equal the projected one, or the bound is a \
sentence in a doc comment"
);
assert_eq!(
demand.total(),
2 * (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL
+ MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL) as u32
);
assert!(!demand.is_complete());
}
#[tokio::test]
async fn the_primary_signal_is_resolved_before_the_safety_net() {
let server = MockServer::start().await;
let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
mount_runs(
&server,
&repo(),
IN_PROGRESS_RUN_STATUS,
runs_body(&running),
)
.await;
for id in &queued {
mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
}
for id in &running {
mount_jobs(&server, &repo(), *id, jobs_body(&["ubuntu-latest"], 1, 0)).await;
}
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a bounded poll still answers");
let tally = host_labels().tally(demand.jobs_for(&repo()));
assert_eq!(
tally.demand() as usize,
MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
"the queued cap is spent in full on the primary signal"
);
assert_eq!(
tally.not_matched as usize, MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
"and the safety net gets its own smaller cap, not a share of the first"
);
}
#[tokio::test]
async fn a_runs_job_listing_walks_pages_and_stops_at_its_budget() {
let server = MockServer::start().await;
let base = server.uri();
let path_100 = jobs_path(&repo(), 100);
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
Mock::given(method("GET"))
.and(path(path_100.clone()))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(jobs_body(&["rm-home-win-x64"], 100, 0))
.insert_header(
"link",
format!("<{base}{path_100}?page=9>; rel=\"next\"").as_str(),
),
)
.mount(&server)
.await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a bounded walk still answers");
assert_eq!(
gateway.requests_issued() as usize,
2 + MAX_JOB_PAGES_PER_RUN,
"an endless `Link` chain must stop at the job page budget rather than \
spending the hourly ceiling on one run"
);
assert_eq!(demand.total() as usize, 100 * MAX_JOB_PAGES_PER_RUN);
assert!(
!demand.is_complete(),
"a count clipped by the page bound is a floor and must say so"
);
assert!(demand.is_truncated(&repo()));
}
#[tokio::test]
async fn a_total_count_that_disagrees_with_its_only_page_does_not_derail_the_count() {
let server = MockServer::start().await;
mount_runs(
&server,
&repo(),
QUEUED_RUN_STATUS,
json!({
"total_count": 5_000,
"workflow_runs": [{ "id": 100 }]
}),
)
.await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect("a wrong `total_count` no longer decides anything here");
assert_eq!(
demand.total(),
2,
"the count comes from the jobs of the runs that were actually listed, so a \
`total_count` carrying the unfiltered lifetime total cannot inflate it"
);
assert!(
demand.is_complete(),
"one listed run, no next page, and the cap not reached"
);
}
#[tokio::test]
async fn an_organization_aggregates_its_repositories_and_pays_per_repository() {
let server = MockServer::start().await;
mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 2, 0).await;
mount_one_queued_run(&server, &other_repo(), &["rm-home-win-x64"], 5, 0).await;
mount_idle(&server, &third_repo()).await;
let gateway = gateway(&server);
let two = org_scope([repo(), other_repo()]);
let three = org_scope([repo(), other_repo(), third_repo()]);
assert_eq!(
demand_requests_per_poll(&two),
2 * DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
"there is no organization-wide workflow-runs endpoint, so the cost is \
per repository"
);
assert!(
demand_requests_per_poll(&three) > demand_requests_per_poll(&two),
"a projection that did not grow with the repository count would understate \
an organization's real spend by exactly that factor"
);
let demand = gateway
.queued_demand(&three, &CancelToken::new())
.await
.expect("an aggregate");
assert_eq!(demand.total(), 7);
assert_eq!(demand.for_repository(&repo()), Some(2));
assert_eq!(demand.for_repository(&other_repo()), Some(5));
assert_eq!(
demand.for_repository(&third_repo()),
Some(0),
"a repository that answered zero is present as a zero, unlike one that \
could not answer at all"
);
assert_eq!(
host_labels().tally(demand.jobs()).demand(),
7,
"an organization policy serves any repository in its scope, so its demand \
is the whole aggregate's rather than one repository's"
);
assert!(
gateway.requests_issued() <= u64::from(max_demand_requests_per_poll(&three)),
"the measured cost must sit inside the projected ceiling, or the budget \
model is a table in a document"
);
}
#[test]
fn the_measured_demand_cost_is_reported_through_c3s_seam() {
use crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH;
let scope = org_scope([repo(), other_repo(), third_repo()]);
let estimated = TargetCost::from_activity_scope(&scope);
let measured = target_cost(&scope);
assert_eq!(
DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH, 2,
"`c3`'s estimate prices the runs request and one jobs request; this module \
now issues both, plus the in-progress listing and a jobs request per \
additional active run, so the measured figure is higher rather than lower"
);
assert_ne!(
measured, estimated,
"the seam must actually replace the estimate; a `target_cost` that returned \
`from_activity_scope` unchanged would report the estimate as measured"
);
assert_eq!(measured.requests_per_refresh(), 16);
assert_eq!(estimated.requests_per_refresh(), 10);
assert!(
measured.requests_per_refresh() > estimated.requests_per_refresh(),
"restoring the per-run job listing added requests; a measured cost that was \
not higher would mean this module is not issuing them"
);
}
#[test]
fn the_printed_target_ceiling_still_projects_c3s_estimate() {
use crate::rest::{BudgetProjection, budget_allowance};
use runner_manager_domain::model::RefreshInterval;
let interval = RefreshInterval::default();
let printed = BudgetProjection::max_repository_targets(interval);
let per_hour_estimated = TargetCost::repository().requests_per_hour(interval);
let per_hour_measured = TargetCost::repository()
.with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
.requests_per_hour(interval);
assert_eq!(per_hour_estimated, 240);
assert_eq!(per_hour_measured, 360);
assert_eq!(
printed, 10,
"the printed ceiling is `04-subsystem-contracts.md`'s figure, computed from \
`c3`'s estimate"
);
assert_eq!(
budget_allowance() / per_hour_measured,
6,
"while the cost this module actually issues allows six"
);
assert!(
printed > budget_allowance() / per_hour_measured,
"the gap now runs in the optimistic direction: the printed ceiling is larger \
than the measured cost supports. `BUDGET_SHARE_DIVISOR` is what absorbs it \
-- see this test's documentation before treating the inequality as harmless"
);
}
#[tokio::test]
async fn an_organization_steps_over_a_repository_local_failure_without_reading_it_as_zero() {
let server = MockServer::start().await;
mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 4, 0).await;
Mock::given(method("GET"))
.and(path(runs_path(&other_repo())))
.respond_with(
ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
)
.mount(&server)
.await;
let gateway = gateway(&server);
let demand = gateway
.queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
.await
.expect("an aggregate steps over a repository it cannot read");
assert_eq!(demand.total(), 4);
assert_eq!(demand.unavailable().len(), 1);
assert_eq!(demand.unavailable()[0].repository, other_repo());
assert_eq!(
demand.for_repository(&other_repo()),
None,
"a repository that could not be polled is absent from the map, not present \
as a zero"
);
assert!(
demand.jobs_for(&other_repo()).is_empty(),
"and its job list is empty rather than absent, so a caller tallying it \
cannot accidentally read an unavailable repository as demand"
);
assert!(
!demand.is_complete(),
"an aggregate missing a repository is not a complete reading"
);
}
#[tokio::test]
async fn a_repository_target_propagates_the_failure_rather_than_reporting_zero_demand() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(runs_path(&repo())))
.respond_with(
ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
)
.mount(&server)
.await;
let gateway = gateway(&server);
let error = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect_err("a scope of one has no aggregate to step over into");
assert!(matches!(
error,
InventoryError::Github(GithubError::Status { status: 404, .. })
));
}
#[tokio::test]
async fn a_failed_job_listing_is_not_read_as_a_run_with_no_queued_jobs() {
let server = MockServer::start().await;
mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
Mock::given(method("GET"))
.and(path(jobs_path(&repo(), 100)))
.respond_with(
ResponseTemplate::new(500).set_body_json(json!({ "message": "Server Error" })),
)
.mount(&server)
.await;
let gateway = gateway(&server);
let error = gateway
.queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
.await
.expect_err("a job listing that failed is not a run with nothing queued");
assert!(matches!(
error,
InventoryError::Github(GithubError::Status { status: 500, .. })
));
}
#[tokio::test]
async fn a_rate_limit_aborts_the_aggregate_rather_than_being_stepped_over() {
let server = MockServer::start().await;
mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
Mock::given(method("GET"))
.and(path(runs_path(&other_repo())))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "42")
.set_body_json(json!({
"message": "You have exceeded a secondary rate limit"
})),
)
.mount(&server)
.await;
let gateway = gateway(&server);
let error = gateway
.queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
.await
.expect_err("a rate limit is a fact about the credential, not the repository");
let limit = error
.rate_limited()
.expect("`c3`'s detector is what decides this, and it decided rate limit");
assert_eq!(limit.retry_after, Some(std::time::Duration::from_secs(42)));
}
#[tokio::test]
async fn cancellation_between_requests_stops_the_poll() {
let server = MockServer::start().await;
mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 0).await;
let gateway = gateway(&server);
let cancel = CancelToken::new();
let first = gateway
.repository_queued(&repo(), &cancel)
.await
.is_ok_and(|reading| reading.jobs.len() == 3);
assert!(first, "the uncancelled poll reads both lists and the jobs");
assert_eq!(gateway.requests_issued(), 3);
cancel.cancel();
let error = gateway
.repository_queued(&repo(), &cancel)
.await
.expect_err("a cancelled token opens no socket at all");
assert!(error.is_cancelled());
assert_eq!(
gateway.requests_issued(),
3,
"a cancelled poll must spend nothing; the count is of requests attempted"
);
}
#[test]
fn the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it() {
let labels = RoutingLabels::derive(
&HostLabel::new("home").expect("a valid host label"),
Os::Windows,
Arch::X64,
);
let host = labels.host_label().as_str().to_string();
assert_eq!(host, "rm-home-win-x64");
for form in [
RunsOn::Single(host.clone()),
RunsOn::Many(vec![host.clone()]),
RunsOn::Grouped {
group: Some("Default".into()),
labels: runner_manager_domain::policy::RunsOnLabels::One(host.clone()),
},
] {
assert!(
labels.matches(&form).is_match(),
"a job requiring only this policy's own label must match: {form:?}"
);
}
for form in [
RunsOn::Single("ubuntu-latest".into()),
RunsOn::Many(vec![host.clone(), "rm-office-win-x64".into()]),
] {
assert!(
!labels.matches(&form).is_match(),
"a job requiring a label this policy does not carry must not match: {form:?}"
);
}
let expression = RunsOn::Single("${{ matrix.runner }}".into());
assert!(matches!(
labels.matches(&expression),
RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
));
let tally = labels.tally(&[
RunsOn::Single(host.clone()),
RunsOn::Single("ubuntu-latest".into()),
expression,
]);
assert_eq!(tally.demand(), 1);
assert_eq!(tally.not_matched, 1);
assert_eq!(tally.unresolvable.len(), 1);
assert_eq!(tally.total_seen(), 3);
assert_eq!(
RunsOn::from_job_labels(["self-hosted", host.as_str()]),
RunsOn::Many(vec!["self-hosted".into(), host.clone()])
);
assert!(
labels
.matches(&RunsOn::from_job_labels([host.as_str()]))
.is_match()
);
assert!(
!labels
.matches(&RunsOn::from_job_labels(["self-hosted", host.as_str()]))
.is_match(),
"a job requiring `self-hosted` needs a policy carrying `self-hosted`"
);
let production = this_file_above_its_tests_without_prose();
for owned_by_b1 in ["RoutingLabels", "DemandTally"] {
assert!(
!production.contains(owned_by_b1),
"the demand gateway names `{owned_by_b1}`, which belongs to `b1`: this \
module builds the predicate's input and does not apply it. Filtering \
here would make the poll per-policy rather than per-target and multiply \
its request cost by the number of policies sharing a target -- if an \
owner decision changed that, it belongs in this module's documentation \
and in this test before it belongs in the code"
);
}
assert!(
production.contains("RunsOn"),
"and the gateway must still *build* a `RunsOn` per queued job; a production \
half that named none would mean the job listing had been removed again and \
the serial-matrix defect restored"
);
}
fn this_file_above_its_tests_without_prose() -> String {
let (production, _) = include_str!("demand.rs")
.split_once("\n#[cfg(test)]")
.expect("this file has a test module, and the scan is meaningless without one");
production
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
fn normalise_for_scan(text: &str) -> String {
text.to_ascii_lowercase().replace(['_', ' '], "")
}
const FORBIDDEN: &[&str] = &[
concat!("fn ", "acquire", "_job"),
concat!("fn ", "claim", "_job"),
concat!("fn ", "lease", "_job"),
concat!("fn ", "reserve", "_job"),
concat!("fn ", "ack", "nowledge"),
concat!("struct ", "Job", "Lease"),
concat!("struct ", "Job", "Claim"),
concat!("struct ", "Job", "Reservation"),
];
fn forbidden_shape_in(source: &str) -> Option<&'static str> {
let haystack = normalise_for_scan(source);
FORBIDDEN
.iter()
.copied()
.find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
}
#[test]
fn nothing_in_this_crate_reserves_or_claims_a_job() {
const SOURCES: &[(&str, &str)] = &[
("demand.rs", include_str!("demand.rs")),
("device_flow.rs", include_str!("device_flow.rs")),
("jit.rs", include_str!("jit.rs")),
("lib.rs", include_str!("lib.rs")),
("rest.rs", include_str!("rest.rs")),
];
fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
let entry = entry.expect("a readable directory entry");
let name = entry.file_name().to_string_lossy().into_owned();
let joined = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
if entry.path().is_dir() {
walk(&entry.path(), &joined, found);
} else if name.ends_with(".rs") {
found.push(joined);
}
}
}
let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
listed.sort_unstable();
let mut on_disk = Vec::new();
walk(
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
"",
&mut on_disk,
);
on_disk.sort_unstable();
assert_eq!(
listed, on_disk,
"a source file was added or removed; this scan claims to cover the whole \
crate and a stale list makes that claim false"
);
for (name, source) in SOURCES {
assert_eq!(
forbidden_shape_in(source),
None,
"{name} names a forbidden shape: there is no job reservation on the REST \
path, and a local lease coordinates this host with itself and with \
nothing else"
);
}
}
#[test]
fn the_reservation_scan_catches_an_injected_reservation() {
let call = format!(
" async {} {}{}(&self) -> Result<Vec<Job>, InventoryError> {{",
"fn", "acquire", "_jobs"
);
let item = format!("{} {}{} {{ id: u64 }}", "struct", "Job", "Lease");
let acknowledgement = format!(
" async {} {}{}(&self, id: u64) {{",
"fn", "ack", "nowledge_assignment"
);
for (planted, expected) in [
(&call, concat!("fn ", "acquire", "_job")),
(&item, concat!("struct ", "Job", "Lease")),
(&acknowledgement, concat!("fn ", "ack", "nowledge")),
] {
assert_eq!(
forbidden_shape_in(planted),
Some(expected),
"the scan's own matcher cannot see {planted:?}, so every negative \
assertion it makes about that shape is worthless"
);
}
assert!(
call.contains("acquire_jobs"),
"the planted shape is the plural the Actions-service protocol used"
);
assert!(
acknowledgement.contains("_assignment"),
"the planted acknowledgement names no job, which is why the needle \
carrying a `_job` suffix would have missed it"
);
}
}