Runner Polling
Work assignment is a two-phase protocol:
- runner group poll claims work tentatively
- ephemeral runner accept makes execution authoritative
This minimizes wasted credential generation and provides a clean recovery path when a poll succeeds but the worker never starts.
For github-actions runner groups the same protocol applies, except the “poll” is driven by an in-process dispatcher on the backend (not an external long-poll) and the “ephemeral runner” is a workflow run started via workflow_dispatch. See runner_groups_github_actions.md for the full flow.
Actors
- runner group coordinator: long-lived process
- ephemeral runner: short-lived per-run worker
- backend: source of truth for assignments, liveness, and credentials
Phase 1: Poll
Endpoint:
GET /api/v1/runner-groups/poll
High-level flow:
- authenticate runner group JWT
- find the oldest
pendingrun whose runner selector is contained in the polling group’s labels (an empty selector falls back to the org’s default runner selector) - create a stub ephemeral-runner record
- atomically assign the run to that runner
- set assignment expiry
- return
run_idandrunner_id
If no work matches, the endpoint returns no assignment.
Per-org runs/hour throttle
After a run is claimed (step 4, SetExclusiveAssignment won the SKIP-LOCKED
race) but before the tx commits, the poll consumes one token from the org’s
runs/hour dispatch budget — a shared Redis token bucket keyed on the org
(PolicyOrgRunsPerHour, refill = the org’s purchased
organizations.runs_per_hour_limit, burst = that rate × 24h). Consuming after
the claim (not before) means groups that lost the race never spend a token, so
the budget reflects runs actually dispatched, not polls. When the bucket is
empty the claim is rolled back (run returns to pending, no stub runner
persists) and PollForWork returns the sentinel ErrOrgDispatchThrottled:
- the HTTP poll handler maps it to a 204 (the self-hosted poller retries next tick), so a throttle is transparent to the runner-group client;
- the backend dispatcher (
dispatchOne) treats it as not contended (unlike a genuine SKIP-LOCKED nil), soDrainPendingstops instead of hot-looping every 10 ms until the bucket refills.
On a throttle the org is also deferred: organizations.dispatch_throttled_until
is stamped to the bucket’s expected refill, and the backend-dispatch candidate
query (ListDispatchCandidates) skips the whole org until then. Without this a
throttled run — which rolls back to pending and frees no group capacity — would
re-appear as a candidate every tick, wasting claim/rollback work and starving
other orgs out of the bounded candidate batch. The stamp is a best-effort
scheduling hint (a separate tx off the throttle path); the Redis bucket stays
authoritative, so a stamp that is merely old only means the org is retried a
little late — it decays against NOW().
A stamp that outlives its policy, though, is not benign, because the stamp
encodes the refill of the rate in force when it was written. An org throttled at
1/h is deferred a full hour; raise it to 50/h — the operator’s usual response to a
throttled backlog — and the bucket takes the new rate at once while the stamp goes
on excluding the org from dispatch for the rest of that hour. The budget then
reads as having room while the runs sit in the queue. So SetRunsPerHourLimit
clears dispatch_throttled_until in the same write: a rate change invalidates
the projection. Dropping it is safe — if the bucket is genuinely empty the next
poll re-throttles and re-stamps at the new rate.
Cross-org fairness. The candidate query also skips any org already at its
per-org in-flight cap (GANTRYCD_DISPATCH_MAX_PER_ORG active runners of the
type). Together with the throttle defer this bounds each org’s dispatch to its
cap, so a heavy org dispatches its cap in one tick then yields to peers — “heavy
org waits, others don’t” — without a per-tick scan of the whole pending backlog.
Two metrics expose when the dispatcher itself (not an org’s budget) is the
bottleneck: gantrycd_dispatch_batch_saturated (full-batch ticks) and
gantrycd_dispatch_oldest_dispatchable_age_seconds (dispatch-latency SLO). Both
are recorded after the tick, over only the candidates it could actually serve
— orgs the throttle rejected during the tick are dropped — so they signal “add
dispatcher capacity,” not a purchased-budget throttle.
Excluding merely deferred orgs (the candidate query’s job) is not enough, and
assuming it was is how a healthy dispatcher ended up paging: the deferral lapses
the instant the bucket yields its next token, so for that one tick the org’s entire
aged backlog is a candidate again — one run dispatches, the org re-defers, and the
tick meanwhile reported the age of a run that is old because of the org’s purchased
budget. With one sample per tick that is enough to pin the p95 latency alarm on for
as long as the backlog exists. A throttled org’s queue depth belongs to
gantrycd_org_run_dispatch_total{outcome="limited"}, which is the signal that
actually means “this org needs a bigger budget.”
This meters how fast queued runs are handed out to an org’s runner groups; it
does not gate run creation. The limiter fails open (a Redis error dispatches
the run) and is a no-op when rate limiting is disabled instance-wide.
Super-admins set the per-org rate from Backstage; a GET /api/v1/orgs/{org_id}/dispatch-budget read (surfaced as a banner on the org
Queue page) lets org admins see when they are throttled and the time to the next
slot. Default: new orgs start at 2/h (the column default in
schema/tbl_organizations.hcl). The column is never grandfathered or backfilled,
so changing that default moves only orgs created after the schema apply — orgs
already in the DB keep whatever value they hold, including the 5/h they were
created with before the default dropped. Operators raise per-org limits from
Backstage as orgs are onboarded.
Selector Matching
Labels are key=value pairs, modelled on Kubernetes node selectors. Compatibility is JSONB containment:
- A stack carries a namespaced
labelsmap. Keys under the reserved prefixgantrycd:runner-group:(prefix stripped) form the runner selector, snapshotted immutably onto each run asruns.runner_selectorat creation. (gantrycd:is a reserved system prefix; other keys are free user labels with no matching role.) - A runner group advertises plain capability labels in
runner_groups.labels. - Match rule:
run.runner_selector <@ group.labels(every pair in the selector is present in the group’s labels). Runners carry no labels of their own. - A run with an empty runner selector is served by the org’s optional
organizations.default_runner_group_id(chosen explicitly, not label-matched). When no default is set, an empty-selector run may run on any group.
Matching is enforced in SQL, not by best-effort filtering in Go.
Stub Runner Creation
Poll creates a minimal runner record before the ephemeral process exists. This satisfies assignment foreign keys and gives the backend a concrete runner identity for the follow-up lifecycle.
The stub starts in not-ready.
Ready
Before accepting the run context, the ephemeral runner calls:
POST /api/v1/runners/{runner_id}/ready
This transitions the stub to a real ready runner and finalizes DB-backed runner metadata.
Phase 2: Accept
Endpoint:
POST /api/v1/runs/{run_id}/accept
Accept does the expensive work:
- verifies the run is still assigned to this runner
- checks assignment expiry
- generates backend/log/SCM/runtime credentials
- transitions run
assigned → running - transitions deployment into active execution state
- returns full
RunContext
Status Polling During Execution
The ephemeral runner polls:
GET /api/v1/runs/{run_id}/status
Purposes:
- refresh liveness
- detect
requested_cancellation
If cancellation is requested, the runner cancels its local context and later acknowledges cancellation explicitly.
Live Log Push During Execution
Alongside the status poll, the runner’s log uploader ticks every 2 seconds and pushes a snapshot of the active (unsealed) log chunk:
POST /api/v1/runs/{run_id}/logs/live
Body (contracts.LiveLogPushRequest): the active chunk’s index, the count of chunks durably uploaded to S3 (sealed_chunks, advanced only after a successful PUT — it never claims a chunk S3 doesn’t have), and the gzipped chunk bytes. The backend stores one record per run in Redis, fully replaced on every push; the web log APIs answer active-run metadata and active-chunk reads from it without touching S3.
Properties:
- Best-effort: push failures are logged on the runner and never fail the run; the endpoint always returns 204 for a well-formed push (without Redis the record is dropped — logs then appear chunk-by-chunk as they seal).
- Deduped: a tick where the chunk and sealed count are unchanged costs no HTTP call.
- Same auth as
/statusand/done: ephemeral runner JWT plus the path↔JWT run binding check. - Lifecycle:
/donedeletes the record (the final flush made S3 authoritative); for runs whose runner went silent,CleanupInactiveRunnersflushes the last snapshot to S3 as the final chunk before the record’s TTL (sized aboveRUNNER_EXECUTION_TIMEOUT) reaps it.
Sealed chunks never travel this path — they are PUT to S3 exactly once by the runner; see run_context_and_artifacts.md for the chunk storage contract.
Completion And Cleanup
After execution the runner calls:
POST /api/v1/runs/{run_id}/doneDELETE /api/v1/runners/{runner_id}/deregister
If the runner sees cancellation first, it also calls:
POST /api/v1/runs/{run_id}/acknowledging-cancel
Which Run A Runner Holds
The binding between a runner and its run lives in exactly one column,
runs.assigned_runner_id. A runner row carries no pointer back: the
assigned_run_id / assigned_stack_id / assigned_deployment_id fields on the
runner API type are derived per read, by joining the run whose
assigned_runner_id is this runner and whose status is not terminal.
A runner holds a run when it is executing it (running,
requested_cancellation, cancelling) or was dispatched for it and could still
accept (assigned with an unexpired lease). That is narrower than “the run is
not terminal”, and the difference is load-bearing: a lapsed lease returns the run
to pending while keeping assigned_runner_id (see One Runner Per
Run), so a non-terminal run does not imply a live claim.
deregister refuses only while the runner still holds a run. A runner whose run
was skipped under it, or whose lease lapsed while it was booting, can always
remove itself. That matters for more than tidiness: a runner that cannot
deregister keeps its group’s concurrency slot and holds its abandoned run out of
dispatch until a reaper removes it, up to RUNNER_EXECUTION_TIMEOUT later.
It also means a terminal run releases its runner everywhere at once, with no code
path having to remember to clear anything. The copy that used to live on the
runner row was cleared only on the /done path, so every abnormal termination
left it dangling.
Failure Windows
The recovery path splits cleanly on one signal: runs.started_at. It is NULL
until /accept succeeds, so a run that was assigned but never accepted (the
worker never actually started) is handled differently from a run that accepted
and was executing when its runner was lost. Three reapers, each keyed off a
different timestamp, run on the 1-minute scheduler:
| Reaper | Signal | Action |
|---|---|---|
CleanupExpiredAssignments | assigned_expires_at (RUNNER_ASSIGNMENT_TIMEOUT, default 2m) | Resets an unaccepted assigned run back to pending so a fresh runner can re-poll it. Per-attempt; loops freely. Clears the lease only — assigned_runner_id survives (see below). |
CleanupStartupDeadline | first_pending_at (RUNNER_STARTUP_DEADLINE, default 2d) | Overall give-up: a never-accepted run (started_at IS NULL) still waiting past the deadline is failed as startup_timeout. |
CleanupInactiveRunners | last_seen_at (RUNNER_EXECUTION_TIMEOUT, default 1h) | An accepted run (running) whose runner has gone silent is force-failed and its lane released. |
CleanupStrandedCancellations | run status + started_at IS NULL + lapsed assigned_expires_at | A never-started run parked in requested_cancellation/cancelling — awaiting an acknowledgement no runner can send — is cancelled and its lane released. |
Reaping the runner row, not just the run
DeleteInactive sweeps runner rows on the same two-phase split, keyed on whether
the runner ever accepted:
| Runner status | Signal | Cutoff |
|---|---|---|
not-ready, ready — never accepted | created_at | RUNNER_ASSIGNMENT_TIMEOUT-scale startup cutoff |
busy and the terminal statuses — accepted | last_seen_at | RUNNER_EXECUTION_TIMEOUT (default 1h) |
Neither startup state heartbeats, so last_seen_at cannot age them. ready used
to sit on the execution cutoff, and that was the bug: a worker that signalled
ready and then died — a lost /ready response, a node that vanished in the
ready-to-accept gap — kept its row for a full hour. Because the
one-runner-per-run guard reads a surviving row as a live claim, the run that
assignment expiry had just requeued stayed undispatchable for that hour, and the
group was charged a concurrency slot for a process that no longer existed. At
max_concurrent_runs = 1 that stalls the whole group.
/ready is also idempotent on ready. It answers with an empty 204, so a
lost response is indistinguishable to the runner from one that never committed,
and it retries for 30 seconds. A strict not-ready → ready edge failed every
replay, and the runner exited before installing its deregistration defer —
creating the stranded row above. busy still conflicts: a runner that already
accepted is not replaying.
When the accept response never arrives
/accept is authoritative and its transaction commits before the RunContext
reaches the socket. If that response is reset, truncated, or lost to a backend
exit in between, the run is running with the runner holding nothing it can
execute — and it cannot be handed the same context again, because the context is
not persisted, the API token exists only as a hash under a one-token-per-run
constraint, and the credentials inside it were response secrets.
The runner’s retry is therefore the signal. A second /accept from the same
runner for a run already running can only mean the first response was not
delivered, so the backend fails that run immediately — the same terminal
transition CleanupInactiveRunners would perform an hour later, on evidence
instead of on silence — and returns 409 with
code: "accept_response_undeliverable". The stack’s lane is released with it and
the deployment can be retried.
This does not race a live execution: the runner only retries when it could not decode a context, and it discards the response it could not decode. The code matters because the client must distinguish this from the other 409s: a reassignment, an expired lease, or a run already terminal (next section).
The last one is a backstop, not a routine window: it is the only reaper that can see
a run whose status has left pending/assigned without ever reaching running.
RequestCancellation no longer creates that state (it terminates a never-started run
outright — see Cancellation), but a run
already in it matches none of the reapers above, its not-ready stub is excluded from
CleanupInactiveRunners, and PromoteNextOnStack treats requested_cancellation as a
live lock holder — so its stack’s lane would never release. Gating on a lapsed
assignment lease is what keeps it from racing an /accept still in flight.
first_pending_at is stamped the first time a run becomes pending and is
preserved across re-queue cycles, so the startup deadline is measured from the
run’s original eligibility, not reset on every retry.
When the run is already terminal at accept
An ephemeral runner takes tens of seconds to boot (a GitHub Actions dispatch
about a minute), and in that window the deployment can be skipped or the run
cancelled. The runner then reaches /accept for a run that is discarded or
cancelled. That is the outcome of someone’s decision, not a fault, so it is
kept apart from every other rejection: the backend returns 409 with
code: "run_already_terminal", logs it at INFO where every other conflict is a
WARN, leaves the span unmarked, and the runner deregisters and exits 0.
Only those two statuses are benign. A run that is failure (a startup failure
committed by an earlier accept whose response was lost) or startup_timeout
(the reapers above gave up on this runner) is terminal too, but a fault: it gets
an uncoded 409 naming the status, stays a warning, and the runner still exits 1.
Status is what decides, not the assignment columns. SkipDeployment leaves
assigned_runner_id in place, a stub that deregisters nulls it through the FK,
and ResetExpiredAssignments clears only the lease and keeps the runner id. So
the remaining 409s key on status too, and each names its cause: pending means
the lease expired and was reset; a different assigned_runner_id means the run
was reassigned; an assigned run past assigned_expires_at means the reaper has
not run yet. All of them are a runner that arrived too late, and both sides keep
them a warning.
One Runner Per Run
When an assignment lapses, ResetExpiredAssignments clears the lease
(assigned_at, assigned_expires_at) but deliberately keeps
assigned_runner_id. The dispatched runner may still be cold-booting toward
/accept, so both dispatch paths exclude a run whose assigned_runner_id
names a live runner — ListDispatchCandidates for backend-dispatched groups and
PollPendingRun for self-hosted long-polling ones. Without that, the run becomes
servable again the instant its lease lapses and a second runner is sent, then a
third — a herd all racing to accept a run only one of them can have.
The two predicates are written separately but must stay in step; a guard on only one path leaves the invariant half-held, which is the state this replaced.
The pointer is released by the ON DELETE SET NULL FK when the runner row goes
away — either because the runner deregistered (the common case, and immediate) or
because a reaper removed it. Nothing has to remember to clear it, and no reaper
can forget. Note the reaper path is not prompt: a runner that reached ready is
past the short stub cutoff and waits on RUNNER_EXECUTION_TIMEOUT (1h), so a
runner that exits without deregistering holds its run out of dispatch for
that long.
A non-null assigned_runner_id therefore does not mean a live assignment.
Every reader gates on run status as well — /accept additionally requires
status = 'assigned' and an unexpired lease, so a superseded runner cannot
accept.
Important recovery cases:
- poll succeeded, the runner group cannot spin up the runner (github-actions
workflow_dispatchrejected, self-hosted worker spawn failed, token signing failed):- the group reports it — synchronously inside the github-actions dispatcher,
or via
POST /api/v1/runner-groups/dispatch-failedfor self-hosted groups RunnerGroupService.ReportDispatchFailurefails the claimed run and its deployment, callsPromoteNextOnStack, deletes the stub runner, and recordslast_dispatch_erroron the group (surfaced in the UI). This is a definitive failure the group knows about, so the run is failed, not re-queued — re-dispatching it would just fail again in a loop.
- the group reports it — synchronously inside the github-actions dispatcher,
or via
- poll succeeded, the runner is dispatched but silently never checks in:
CleanupExpiredAssignmentsresets the run topendingso another runner re-polls it (the orphaned not-ready stub is later garbage-collected byCleanupInactiveRunners, with no dispatch failure recorded — a vanished stub usually means a successful re-queue). If no runner ever starts the run withinRUNNER_STARTUP_DEADLINE,CleanupStartupDeadlinefails it asstartup_timeout.
- runner accepts, then crashes mid-run:
CleanupInactiveRunnersfails the run once the runner is silent pastRUNNER_EXECUTION_TIMEOUTand releases the stack lane. To short-circuit the wait, an operator can force-delete the lost runner (RunnerGroupService.ForceDeleteRunner, eligible once the runner has been offline past the force gate), which fails the active run and promotes the queue immediately.
- runner group crashes after launching workers:
- local launcher can rediscover running workers on restart
Launcher Notes
Launcher variants are separate from the protocol:
- local launcher starts OS processes
- Docker launcher starts containers
- Kubernetes launcher creates Pods (one per run; ownership tracked via the
gantrycd.io/group-id+gantrycd.io/run-idlabels soDiscovercan re-attach after a restart) - GitHub launcher dispatches GitHub Actions jobs
All of them converge on the same runner-group and ephemeral-runner API surface.
See also: