Skip to main content

Module demand

Module demand 

Source
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-policy active_owned term 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_POLL the count is a floor rather than a total, reported through QueuedDemand::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-on is not always resolvable. runs-on: ${{ matrix.runner }} can only be evaluated by GitHub. b1 reports those as runner_manager_domain::policy::UnresolvableRunsOn and 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§

QueuedDemand
Queued jobs, per repository, each carrying the runs-on it requires.
RestDemand
DemandGateway over api.github.com.

Constants§

DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL
Requests one demand poll costs, per repository, in steady state.
IN_PROGRESS_RUN_STATUS
The status filter for the safety-net pass over runs already under way.
LATEST_JOBS_FILTER
The filter for the jobs endpoint: the latest attempt of each job only.
MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL
The most status=in_progress runs 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=queued runs one repository’s job listing may resolve per poll.
QUEUED_JOB_STATUS
The job status that means “waiting for a runner that does not exist yet”.
QUEUED_RUN_STATUS
The status filter that selects runs with a job that may still be waiting.

Traits§

DemandGateway
The demand read model.

Functions§

demand_requests_per_poll
Requests one demand poll over scope costs, in steady state.
max_demand_requests_per_poll
The most requests one demand poll over scope may 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 for c3’s estimate.