Switch the scale set off instead of rebuilding it when runners are outdated - #4652
Conversation
24beb32 to
e2768dd
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Gate outdated recovery on an actual spec-generation change to prevent retrying the same rejected runner specification.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
controllers/actions.github.com/autoscalingrunnerset_controller.go — Do not recover on listener-only Pending state |
What changed in this PR
This PR prevents outdated runner specifications from causing scale-set teardown and recreation loops by disabling the scale set and preserving its runner set.
Changes:
- Persists the
Outdatedphase, removes the listener, and pins replicas to zero. - Adds recovery revision handling and lifecycle tests.
- A recovery condition must avoid advancing revisions when only listener changes occur without a spec update.
| File | Description |
|---|---|
controllers/actions.github.com/autoscalingrunnerset_controller.go |
Implements outdated-state handling and recovery. |
controllers/actions.github.com/autoscalingrunnerset_controller_test.go |
Adds shutdown and recovery lifecycle coverage. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
e2768dd to
472c8e7
Compare
33b092b to
cb17830
Compare
cb17830 to
f6b6024
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
A metadata rebuild can re-enable the listener while the EphemeralRunnerSet remains outdated.
Get a fresh assessment by requesting another Copilot review.
Review tier: Lite
Findings: 1
Open (1)
Resolved since last review (1)
f6b6024 to
22e2708
Compare
…tdated When the runners reject the runner spec they were given, the listener has to stop acquiring jobs the scale set cannot run. The controller removed the listener but then deleted the EphemeralRunnerSet, and left the AutoscalingRunnerSet phase on Running. AutoscalingRunnerSetPhaseOutdated was declared and read, but never assigned by anything. Nothing held the scale set switched off as a result. The next reconcile saw a missing EphemeralRunnerSet, created it, created a listener for it, and the fresh runners rejected the same spec again, so the scale set churned through create and teardown cycles against the Actions service instead of resting. Record the outdated phase and keep the EphemeralRunnerSet, pinned to zero replicas and patch id. It releases every runner that is not executing a job while the phase keeps the listener from being rebuilt, and the revision bookkeeping that decides when the scale set may run again is preserved. Recovery is driven by the spec update that the phase is waiting for: it moves the phase back to pending, and the runner spec is then republished to the set with an advanced revision even when the runner spec itself did not change. The revision is what tells the EphemeralRunnerSet to stop judging itself by the runners that failed, so without advancing it a scale set could only be recovered by editing the pod template, and an edit to anything else would switch the listener back on against a set parked at zero. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Pending does not exclusively mean an AutoscalingRunnerSet spec update. A metadata-only listener rebuild also sets Pending while deliberately leaving ObservedGeneration unchanged. If the EphemeralRunnerSet reports Outdated during that window, the recovery branch previously advanced ActionableRevision and retried the same rejected runner spec. Require Generation to be ahead of ObservedGeneration before using an Outdated status as a recovery signal. This replaces the false exhaustiveness assumption that the earlier Running-only case claimed every outdated set; Pending sets also reach the default branch. Add an envtest that blocks a metadata-driven listener rebuild, reports the runner set Outdated, and verifies the rejected revision is not retried. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A metadata-only listener rebuild sets the AutoscalingRunnerSet to Pending without advancing its generation. If the EphemeralRunnerSet becomes outdated during that window, waiting for the phase to return to Running briefly recreates the listener against the rejected runner spec before the following reconcile switches it off again. Handle an applied-revision outdated state whenever there is no unobserved AutoscalingRunnerSet generation. This transitions metadata-only rebuilds directly to Outdated while preserving spec-update recovery. Extend the envtest to watch listener identities and prove no replacement listener is created. Restoring the old Running-only condition makes the test observe a new listener UID and fail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
22e2708 to
bcda1d6
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Address the recovery race and strengthen the outdated-state test coverage.
Get a fresh assessment by requesting another Copilot review.
Review tier: Lite
Findings: 1
Open (2)
Resolved since last review (1)
ObservedGeneration can remain behind across ERS publication and listener creation, so generation lag alone cannot distinguish recovery from the first rejection of a normal spec edit. If listener creation keeps failing, that ambiguity can repeatedly advance ActionableRevision and recreate runners for a revision already known to fail. Stamp each EphemeralRunnerSet with the AutoscalingRunnerSet generation that published its current actionable revision. Recover only when the live unobserved generation is newer than that stamp; once a revision has been published for a generation, its rejection parks the scale set instead of retrying it. Also seed lifecycle tests with nonzero replicas and patch ID so reconcileOutdated executes and verifies its zeroing patch rather than returning through the existing zero/zero short-circuit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Downstream ARC recovery integration is ready, but it cannot complete against this head because the native Outdated lifecycle is not yet observable as the accepted bounded recovery contract. Please expose the following on this PR before release:
The recovery budget must permit at most one automatic attempt for an AutoscalingRunnerSet incident epoch. A repeated rejection in that epoch must open the circuit and must not retry. Metric labels must remain bounded. Existing listener assigned, running, and started-job metrics already provide downstream objective-work and semantic-progress inputs. Please publish the exact condition or Event reasons, metric names, label set, and release version so the deployed scrape and recovery proof can use the producer interface without guessing. |



Was stacked on #4647, which has since merged; this is now based directly on
master.Supersedes #4649, which GitHub auto-closed as merged when its base branch was force-pushed during a stack reorder. Same change, now first in the stack instead of second.
The problem
AutoscalingRunnerSetPhaseOutdatedwas declared and read, but nothing in the codebase ever assigned it.So when the runners rejected the runner spec (pod exits 7 →
EphemeralRunnergoesOutdated→EphemeralRunnerSetgoesOutdated), theAutoscalingRunnerSetremoved the listener and then calledcleanupEphemeralRunnerSet, which deletes the set — while leaving its own phase onRunning.Nothing held the scale set switched off. The next reconcile saw a missing
EphemeralRunnerSet, created it, created a listener for it, and the fresh runners rejected the same spec again. The scale set churned through create/teardown cycles against the Actions service instead of resting, directly contradicting the comment in that branch saying it "should stay in outdated state until the spec is updated".The
outdatedbranch at the top ofReconcilealready did the right thing — remove the listener, keep the set, pin it to zero replicas — but was dead code, because the phase it keys on was never set.The intended lifecycle
EphemeralRunnerphaseOutdated.EphemeralRunnerSetsees it, goesOutdated, and releases every runner that is not executing a job (cleanUpEphemeralRunnersalready skipsHasJob()runners).AutoscalingRunnerSetgoesOutdatedand switches the listener off, so no more jobs are acquired. ← this step was missingThe change
When the
EphemeralRunnerSetis outdated for its applied revision, theAutoscalingRunnerSetrecordsAutoscalingRunnerSetPhaseOutdatedand hands over to the same handler the top-of-reconcileoutdatedbranch uses, which is extracted intoreconcileOutdated. TheEphemeralRunnerSetis kept and pinned toReplicas = 0, PatchID = 0rather than deleted.ObservedGenerationis carried over unchanged, so a later spec edit still registers as new work and moves the phase back toPendingthrough the existing generation check.The
EphemeralRunnerSetspec patch also fires on the recovery path, when the set is still outdated for its applied revision but the spec has since been updated.The revision has to advance even when the runner spec itself did not change. The revision is what tells the
EphemeralRunnerSetto stop judging itself by the runners that failed (patchAppliedActionableRevisionStatusre-buckets them as stale and clears the outdated phase). Without it, a scale set could only ever be recovered by editing the pod template, and an edit to anything else —maxRunners, the runner group, the config secret — would switch the listener back on against a set permanently parked at zero.Telling recovery apart from a first rejection
Parking and recovering are the same two states seen from different angles, so the condition that separates them carries the whole correctness argument. Both branches now key on one helper and are exact complements — no gap, no overlap:
The phase alone cannot make this call.
Pendingalso means a metadata-only listener rebuild, which deliberately leavesObservedGenerationuntouched; recovering there would retry the exact spec the runners just rejected.Generation > ObservedGenerationalone cannot make it either. That is also true while a newly edited spec is being published, becauseObservedGenerationonly catches up at the tail of a full reconcile and the intervening passes return early at theEphemeralRunnerSetpatch and at listener creation. If the runners reject the new spec inside that window, generation lag alone reads the first rejection as recovery and immediately republishes a revision already known to fail. That window stays open for as long as listener creation keeps failing, so it is not self-limiting.The missing information is provenance: which
AutoscalingRunnerSetgeneration published the revision the runners rejected. Each desiredEphemeralRunnerSetis therefore stamped with the generation that produced it, and recovery requires both an unobserved generation and a stamp older than it. Once a revision has been published for the current generation, its rejection parks the set; only a further edit recovers it. An unstamped set from a previous version is treated as recoverable, so upgrades get exactly one republish.The stamp is an annotation on the owned
EphemeralRunnerSetrather than a newAutoscalingRunnerSetstatus field, which keeps the CRDs and shipped chart copies unchanged. It is not a return to annotation fingerprinting: what #4575 removed was a hash over the whole object recomputed every reconcile; this is a singleint64generation formatted once at build time, and it is already covered by the existing owned-resource predicates.Tests
The outdated path had no coverage at all in
autoscalingrunnerset_controller_test.go, which is why deleting the runner set went unnoticed. AddedTest AutoscalingRunnerSet outdated lifecyclecovering:Outdated, the listener is deleted, and theEphemeralRunnerSetsurvives at zero replicas (asserted withConsistently, since the bug was that it got deleted);The set is seeded with nonzero
Replicas/PatchIDbefore being reported outdated.reconcileOutdatedshort-circuits when both are already zero, and the builder creates them at zero, so without the seed the zeroing patch under test never executed.TestAutoscalingRunnerSetParksFirstRejectionOfPublishedGenerationpins the race directly: an unobserved generation whose revision has already been published, then rejected, must park rather than republish.All of these fail on the parent commit, and each was mutation-checked by reverting its guard individually.