Expand description
How much work is waiting for a runner that does not exist yet.
Demand is the number e1 feeds to clamp(demand, min_capacity, max_capacity), so it is the number that decides how many runner processes
this host starts. Everything in this module exists to make that number
honest — and, where it cannot be, to make it say so rather than look
precise.
GET /repos/{owner}/{repo}/actions/runs?status=queued&per_page=100
GET /repos/{owner}/{repo}/actions/runs?status=in_progress&per_page=100
-> 200 { "total_count": N, "workflow_runs": [ { "id": … }, … ] }
GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs?filter=latest&per_page=100
-> 200 { "total_count": M, "jobs": [ { "status": …, "labels": […] }, … ] }§The counting unit is a job, and this reverses an earlier decision
This is the single most important thing to know about this module, and it is an owner decision that replaced the opposite owner decision. The history is kept here rather than deleted, because the reasoning that produced the old answer is still sound in the abstract and a later reader who only sees the new code will otherwise re-derive it and revert this.
What the previous decision said. d18b-run-count-filtering.md probed the
runs endpoint against live GitHub and established that its total_count
counts workflow runs matching the query. Counting runs costs exactly one
request per repository, a fixed figure the budget model
(crate::rest::TargetCost, crate::rest::BudgetProjection) could price
and f2’s add refusals could be computed from. Resolving each queued run’s
jobs costs one extra request per run, a variable cost scaling with queue
depth. The owner chose the fixed cost, accepted the resulting under-count,
and this module said at length: do not “fix” this, and do not add a per-run
job listing.
Why it was reversed. A workflow run holds many jobs — a matrix, or a
jobs: map with several independent entries — and each job needs its own
runner. Under the run count, a single queued run holding eight jobs read as
demand 1, e1 started one runner, that runner took one job, and the
remaining seven queued behind it. The next poll saw the same run still
queued, still read 1, and started one more. So a host configured for ten
concurrent runners served an eight-job matrix serially, roughly one at a
time, with the queue depth on GitHub growing while the machine sat idle.
The under-count was not a rounding error in the demand signal; on the
workflow shape people actually write, it was the difference between the
product’s headline feature working and not working. That is a worse outcome
than a variable request cost, so the trade was re-made the other way.
What that means for a later reader. The per-run job listing below is
deliberate and load-bearing. Removing it to restore a fixed per-repository
cost re-creates the serial-matrix defect described above. If a future owner
decision reverses this again, it belongs in this documentation and in
tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it before it
belongs in the code.
§Both run statuses are polled, and the second one is not redundant
status=queued alone is the obvious query and it is not sufficient. A run’s
status is a property of the run, and a run holding both a running job and a
queued one has to report one value for both. Live observation on a repository
using this product caught a run of five jobs — two completed, one
in_progress, two queued — reporting status: "queued", so queued does
take precedence over in_progress while any job is still waiting.
That observation is what makes status=queued the primary signal, and it
is not what makes it sufficient. The case it does not cover is a job that
becomes queued later: needs: holds a job back until its dependency
finishes, and whether GitHub flips the run’s status back to queued at that
moment is a claim about a state machine this project has not observed. A
needs:-gated job is an ordinary workflow shape, and missing one entirely
would be the same class of defect this module was just rewritten to fix.
So status=in_progress is polled too, as a safety net rather than a second
primary signal, and it is budgeted like one: it is read after the queued
runs and gets the smaller of the two run caps
(MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL against
MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL). A run appears in at most one of
the two lists — the queries are disjoint by construction — and only jobs
whose own status is queued are counted, so an in-progress run whose jobs
have all been dispatched contributes nothing but the request that discovered
that.
completed and waiting runs are not polled. A completed run has no
dispatchable job left, and a waiting run is held by a deployment gate or a
concurrency group rather than by the absence of a runner — starting one for
it would produce a runner that idles until its timeout.
§Routing labels ARE applied now, and the predicate is still b1’s
The previous decision’s second accepted cost was that no routing-label
filtering happened at all: a run carries no runs-on, labels live on jobs,
and this module fetched no jobs. Every queued run in a watched repository
counted, including runs whose jobs targeted ubuntu-latest or another host’s
rm-<host>-… label, and each runner started that way idled until it timed
out.
Fetching the jobs supplies the input that was missing, so that cost is paid
back by the same change. This module still owns no predicate. It reads
each job’s labels array, builds a RunsOn from it, and hands that to the
caller. b1 owns the matching
(runner_manager_domain::policy::RoutingLabels::matches and its tally),
e1 owns applying it per policy, and
tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it scans
this file’s own source to pin that no second implementation grows here.
The filtering is deliberately not done in this module even though it now
has the input, and the reason is e1’s: one target can be watched by more
than one policy, each with its own routing labels, and e1 polls a target
once for all of them. A gateway that filtered would have to be told whose
labels to filter by, which would make the poll per-policy and multiply its
request cost by the number of policies sharing the target. So the gateway
returns the jobs and each policy tallies them.
§What is still approximate, stated plainly rather than left to be discovered
- A job listing is a snapshot. A job that leaves the queue between the
run list and the job list is counted; one that arrives after is not. The
next poll corrects both, and
e1’s per-policyactive_ownedterm stops a job already being served from being served twice. - The run caps make a large queue a floor. Past
MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLLthe count is a floor rather than a total, reported throughQueuedDemand::is_truncated. Scaling up from a floor is safe and successive polls converge; concluding “idle” from one is not, which is why the floor is expressible at all. runs-onis not always resolvable.runs-on: ${{ matrix.runner }}can only be evaluated by GitHub.b1reports those asrunner_manager_domain::policy::UnresolvableRunsOnand they are neither counted as demand nor silently dropped.
§There is no job reservation, and nothing here may pretend otherwise
The scale-set model’s AcquireJobs has no REST equivalent
(d17-user-to-server-scale-set-chain.md), so demand is advisory: another
host may take a job this host has already started a runner for
(01-current-architecture.md, edge case 6).
Do not add a claim, a lease, a local reservation table, or an
acknowledgement call to compensate. No such call exists in this crate, and
b1’s, e1’s and this task’s specifications all say so independently
because implementers keep reaching for one. The bounding controls are the
host-scoped labels in b1 and the two capacity ceilings in e1; a local
lease would coordinate this host with itself and with nothing else, which is
the one thing the problem does not need.
Structs§
- Queued
Demand - Queued jobs, per repository, each carrying the
runs-onit requires. - Rest
Demand DemandGatewayoverapi.github.com.
Constants§
- DEMAND_
REQUESTS_ PER_ REPOSITORY_ PER_ POLL - Requests one demand poll costs, per repository, in steady state.
- IN_
PROGRESS_ RUN_ STATUS - The
statusfilter for the safety-net pass over runs already under way. - LATEST_
JOBS_ FILTER - The
filterfor the jobs endpoint: the latest attempt of each job only. - MAX_
IN_ PROGRESS_ RUNS_ PER_ REPOSITORY_ PER_ POLL - The most
status=in_progressruns one repository’s job listing may resolve per poll. - MAX_
JOB_ PAGES_ PER_ RUN - The most pages of jobs one run’s listing may walk.
- MAX_
QUEUED_ RUNS_ PER_ REPOSITORY_ PER_ POLL - The most
status=queuedruns one repository’s job listing may resolve per poll. - QUEUED_
JOB_ STATUS - The job
statusthat means “waiting for a runner that does not exist yet”. - QUEUED_
RUN_ STATUS - The
statusfilter that selects runs with a job that may still be waiting.
Traits§
- Demand
Gateway - The demand read model.
Functions§
- demand_
requests_ per_ poll - Requests one demand poll over
scopecosts, in steady state. - max_
demand_ requests_ per_ poll - The most requests one demand poll over
scopemay spend. - max_
demand_ requests_ per_ repository_ per_ poll - The ceiling on what one repository’s demand poll may spend.
- target_
cost scope’s budget cost with this module’s demand figure substituted forc3’s estimate.