Expand description
The loop that turns GitHub demand into a decision to start runners — and that refuses to start them when it should not.
Every ceiling in this product is enforced from here, so the module is organised around the four things that can go wrong silently:
PollSchedule— the budget-aware interval. Demand shares one 5,000 requests/hour ceiling with inventory and workflow counts, so this loop polls on a bounded interval (default 60 s, hard floor 30 s per target) and increases the delay under a rate-limit signal, never decreases it to catch up.RepositoryCache— the per-organization repository list, refreshed on an interval materially slower than the demand poll. Re-listing an organization at demand-poll frequency is what exhausts the shared budget the paragraph above exists to protect.Reconciler::reconcile— the allocation pass. It re-reads the attempt set under the host-wide allocation lock, once per runtime created, so two policies reconciling concurrently cannot both spend the same headroom.LifecycleEvent— whatg2and the local log sink see. Every field is an identifier, a count, an enumerated state or a duration; nothing free text, and nothing that came off the wire.
§There is no acquisition step, and none may be added
The scale-set model called AcquireJobs to reserve an assignment before
scaling. The REST path has no equivalent (01-current-architecture.md, edge
case 6), so demand is advisory. Two consequences are load-bearing here
and neither is a defect:
- A surplus runner is an accepted outcome. Another host serving the
same labels may take the job first; this host’s runner then finds no work
and exits on its idle timeout, having cost one capacity slot and one cold
start. That terminal outcome is
AttemptOutcome::ExitedIdleWithoutWork, is cleaned like any other, and is counted apart from a failure — seeReconcileReport::idle_exits. - The same job is still
queuedon the next poll while its runner starts. The- active_owned_runnersterm inHostAllocator::allocateis what stops that from starting a second runner, and then a third. This module’s only job in that arithmetic is to hand the allocator the attempt set the host actually holds — which is whyRunnerLaunchersupplies both the attempts and the launch, from one supply point, for the reasonb1gives atHostAllocator::from_attempts.
tests::nothing_in_this_module_reserves_or_claims_a_job is a tripwire on the
obvious shape of a reservation being added back.
§Demand is measured in JOBS, filtered by this policy’s routing labels
02-target-architecture.md writes the formula as “queued jobs whose
runs-on matches this policy’s routing labels”, and that is now exactly
what this module clamps. It was not always: an earlier owner decision priced
the per-run job listing out and left this module clamping a count of
runs, unfiltered. crates/github/src/demand.rs records that decision,
why it was reversed, and what the reversal costs in requests.
What the reversal means here is two changes to one line:
- A run of eight jobs is now eight units of demand, not one. Under the run count a matrix filled one runner per poll while the rest of the matrix waited, so a host configured for ten concurrent runners served an eight-job matrix nearly serially. That was the defect that forced the decision back.
- A job this host cannot serve is no longer demand. A repository whose
jobs target
ubuntu-latest, or another host’srm-<host>-…label, used to drive its policy towardmax_capacityand start runners that idled until they timed out. The gateway now returns each queued job’sruns-on, sob1’s predicate finally has its input.
The predicate is still b1’s and the input is still c4’s. This module
calls runner_manager_domain::policy::RoutingLabels’s tally and
implements no label comparison of its own;
tests::the_label_predicate_is_b1s_and_this_module_only_applies_it scans
this file’s own source and fails if a second implementation grows here, which
is the same tripwire c4 carries one layer down.
§The filtering happens here rather than in the gateway, on purpose
One target can be watched by more than one policy, each with its own routing
labels, and Reconciler’s poll_targets deliberately 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 — the budget model prices a
target, not a policy. So the gateway returns the jobs and each policy tallies
them against its own labels.
§What is still approximate
The surplus-runner path above is narrowed by this change and not closed. A
runs-on: ${{ matrix.runner }} cannot be resolved without evaluating the
workflow, so b1 reports it as unresolvable: never counted as demand, never
silently dropped, and surfaced through
LifecycleEvent::DemandObserved::unresolvable so that an operator can see
a workflow this host will never serve sitting in the queue. And demand
remains advisory — another host may still take a job this one started a
runner for — which is what the two ceilings bound.
§What is testable without a network, a filesystem, or a process
All of it. DemandSource, RunnerLauncher, AllocationLock,
RepositoryDirectory, Jitter and EventSink are ports;
GatewayDemand, FileAllocationLock, RandomJitter and
TracingEvents are the production adapters, and every one of them is a
thin shell over a decision made in this file.
Structs§
- Allocation
Guard - The lock is held for as long as this value lives.
- Allocation
Lock Busy - The host-wide allocation lock could not be taken.
- Event
Log - Keeps every event, in order.
- File
Allocation Lock d1’s file lock, which is host-wide across processes as well as across tasks.- Fixed
Jitter - A fixed fraction, for tests and for the acceptance suite.
- Gateway
Demand DemandSourceoverc4’sDemandGateway.- InProcess
Allocation Lock - The lock every task inside one agent process contends for.
- Launch
Failure - Why one runner could not be started.
- Launch
Request - What this loop asks
e3to create. - Next
Poll - When to poll next, and why then.
- NoEvents
- Discards everything. For callers that only want the report.
- NoJitter
- No jitter at all: the back-off is exactly what the schedule computed.
- Offline
State - What an operator is told while GitHub is unreachable.
- Poll
Schedule - The bounded, budget-aware poll interval.
- Random
Jitter - The production source.
- Reconcile
Report - What one reconciliation pass did.
- Reconciler
- The reconciliation loop.
- Reconciler
Ports - Everything one reconciler needs, written down at the call site.
- Replacement
Intent - A lifecycle conclusion that must return through ordinary demand and capacity allocation before another runner may start.
- Repository
Cache - The per-organization repository list, refreshed far more slowly than demand.
- Scale
Down Report - What one scale-down request did.
- TeeEvents
- Both sinks at once: the log sink for the operator’s file, the buffer for
g2’s screen. - Tracing
Events - The local log sink, through
d1’s redacting layer. - WslRecovery
Allocation Lock - Adds the Windows/WSL recovery fence to an ordinary allocation lock.
Enums§
- Lifecycle
Event - What
g2’s activity view and the local log sink see. - Outcome
Kind - Which terminal thing happened, as a closed vocabulary.
- Poll
Outcome - One target’s demand poll, as a value this module can decide from.
- Poll
Pace - Why the next poll is when it is.
Constants§
- ALLOCATION_
LOCK_ WAIT - How long
FileAllocationLockwaits for the host-wide allocation lock before reporting contention. - GITHUB_
CANCELS_ QUEUED_ JOBS_ AFTER - GitHub cancels a queued job after this long.
- MAX_
OFFLINE_ BACKOFF - The longest the unjittered offline back-off may grow to.
- REPOSITORY_
LIST_ REFRESH_ MULTIPLE - How much slower than the demand poll the per-organization repository list is refreshed.
Traits§
- Allocation
Lock - Flow 2.4’s “takes the host-wide allocation lock before creating each local runtime”, as a port.
- Demand
Source - Where this loop gets its demand from.
- Event
Sink - Where lifecycle events go.
- Jitter
- The randomness in the offline back-off, as a port.
- Repository
Directory - Which repositories an organization installation reaches.
- Runner
Launcher - The seam between the decision to start a runner and the act of starting one.
Functions§
- failure_
reason_ kind - A
FailureReason’s variant name, with no detail.