Repository navigation
fix(scheduler): stop provisioning runners for jobs that no longer exist - #210
Merged
Merged
Conversation
On 2026-10-05 the Windows node (2 slots) ran ~10 real jobs a day while
booting ~23 runners for jobs GitHub had cancelled up to three days
earlier. Each one idled out the 90-minute orphan grace, so ~34 of 48
slot-hours went to nothing and live jobs queued for 4h behind them. The
macOS pool's week-long outage was the same loop on a 1-slot pool.
Three defects compounded:
- admitDispatch decided "already ran" from the in-memory started map,
which forgets a job after ~JobTimeout, while a slot wait had no bound.
Dispatches 24h and 28h into their wait were admitted for dead jobs.
- reprovisionIfStranded re-queued any job not in started, so each idle
runner's exit put the dead job straight back in line.
- cleanSeen reset attempts[key] whenever seen aged past 10m, including
for jobs still pending or running. Every strand cycle is longer than
that, so maxProvisionAttempts never fired.
Fixes:
- New optional providers.JobStateReporter; GitHub implements it with
GET /repos/{o}/{r}/actions/jobs/{id}. admitDispatch asks when the
dispatch waited for its slot (the fast path skips the call), and
reprovisionIfStranded asks before every re-queue. A gone verdict is
recorded in started. Fails open: an API error provisions as before.
- acquireSlot gives up after maxSlotWait (6h). A still-queued job is
rediscovered by the reconcile poll or its next queued event.
- cleanSeen keeps attempts and jobLabels while the job is pending or
running.
|
ePHPm Preview — removed Preview deployment has been torn down. |
|
ePHPm Preview — deployed (health check pending)
Preview updates automatically on each push to this PR. |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On 2026-10-05 the Windows node (2 slots) ran ~10 real jobs in 24h while booting ~23 runners for jobs GitHub had already cancelled or finished, some three days earlier. Each dead-job runner idled out the 90-minute orphan grace, so ~34 of the node's 48 slot-hours went to nothing, and live jobs queued for hours behind them: the ephpm Nightly's Windows jobs waited 4h with the node otherwise idle. At the time of diagnosis, 26 dispatches were parked on the node's slots, most of them for dead jobs; 7 sampled IDs were all
completedat GitHub, the oldest from 10-02.The macOS pool's week-long outage was this same loop on a 1-slot pool (alongside the stale-runner glob, which is a separate fix).
Root cause
Three defects compound:
admitDispatchtrusts a record that expires. It decides "already ran" from the in-memorystartedmap, pruned after ~JobTimeout, but a slot wait had no bound. Dispatches 24h and 28h into their wait were admitted for jobs that had been finished for days.reprovisionIfStrandedre-queues any job not instarted. Each idle runner's exit put the dead job straight back in line.cleanSeenresetattempts[key]wheneverseen[key]aged pastseenTTL(10m), including for jobs still pending on a slot or running. Every strand cycle is longer than 10 minutes, so the counter was zero at each re-provision.Fix
providers.JobStateReporter. GitHub implements it withGET /repos/{o}/{r}/actions/jobs/{id}: in_progress, completed, or 404 means gone; queued, waiting, pending, or requested means still needs a runner.admitDispatchasks when the dispatch waited for its slot. A dispatch that found a free slot at once skips the call, since its webhook is seconds old.reprovisionIfStrandedasks before every re-queue.started.acquireSlotgives up aftermaxSlotWait(6h). Nothing is lost: a still-queued job is re-dispatched by the reconcile poll or its nextqueuedevent.cleanSeenkeepsattemptsandjobLabelswhile the key is inpendingorrunning.Tests
pkg/scheduler/stale_dispatch_test.go:reprovisionIfStranded× {gone, queued, API error}.cleanSeenkeeps attempts for pending and running jobs, and drops them for a job that has left.pkg/github/jobawaiting_test.go: the status mapping, 404 meaning gone, and 5xx returning an error.The new scheduler tests pass 20/20 runs locally;
pkg/scheduler,pkg/githubandpkg/providers/...all pass; vet is clean on windows and linux. The local golangci-lint build is broken, so lint relies on CI.docs/arch/webhook-job-lifecycle.mdhas a new Stale dispatch decisions section.Relief applied in the meantime
The Windows node was restarted onto v0.2.17 with
--no-drain, which cleared the in-memory backlog. Without this fix, the backlog builds up again within a day or two.