Skip to content

Keep the outdated phase until the runner spec itself changes - #4660

Open
nikola-jokic wants to merge 2 commits into
masterfrom
nikola-jokic-outdated-phase-stickiness
Open

nikola-jokic wants to merge 2 commits into
masterfrom
nikola-jokic-outdated-phase-stickiness

Conversation

@nikola-jokic

@nikola-jokic nikola-jokic commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #4652, which switches the scale set off instead of rebuilding it when the runners reject the runner spec. The teardown works; the recovery condition was wrong in both directions.

The bugs

Outdated was left on a metadata.generation signal rather than on the spec the runners actually rejected, and that signal is wrong twice over:

  1. Under-parking. The generation check in Reconcile ran before the Outdated short-circuit, so any spec edit — maxRunners, minRunners, runnerGroup, runnerScaleSetName — moved the phase back to Pending, switched the listener on, and resumed acquiring jobs for runners that reject the spec exactly as before. That is the churn loop Switch the scale set off instead of rebuilding it when runners are outdated #4652 set out to end.
  2. Over-parking. The mirror image: when the generation recorded on the EphemeralRunnerSet is already current, ephemeralRunnerSetNeedsOutdatedRecovery refuses recovery even though the runner spec genuinely changed. Reachable whenever the derived runner spec moves without metadata.generation moving with it. Covered by TestAutoscalingRunnerSetRecoversWithoutAnUnobservedGeneration, which fails on master.

The fix

Decide recovery by comparing what the runners would be handed next, via ephemeralRunnerSetDesiredSpecChanged: the EphemeralRunnerSpec (through the existing ephemeralRunnerSetActionableSpecChanged, Semantic.DeepEqual for the reasons in its doc comment) plus EphemeralRunnerMetadata. Replicas, PatchID and ActionableRevision are excluded — scaling bookkeeping, pinned to zero while parked.

  • The Outdated handling moves above the generation comparison and becomes spec-aware. It sits after the deletion, version and finalizer handling, so deletion still wins over the phase.
  • The mid-flow gate asks the same question, so recovery always coincides with a real spec change and the patch bumps ActionableRevision through nextActionableRevision. patchAppliedActionableRevisionStatus therefore still fires, re-buckets the failed runners as stale, clears the EphemeralRunnerSet's own Outdated phase, and lets it scale from 0. A metadata-only recovery bumps the revision for the same reason — without it the metadata patch would land alone and the set would be pushed straight back to Outdated.
  • ephemeralRunnerSetOutdatedForAppliedRevision still requires the applied revision to have caught up, so the window between patching the fix and the set processing it does not re-trigger teardown. The new guard is "spec changed", not "phase != Outdated", so the Pending-pushed-back-to-Outdated deadlock shape does not reappear.
  • ephemeralRunnerSetNeedsOutdatedRecovery is removed. The generation annotation on the EphemeralRunnerSet stays as a record of provenance; nothing keys off it now.

Behaviour reviewers should weigh

  • minRunners/maxRunners can never un-park an outdated scale set. They live on the AutoscalingRunnerSet and not in EphemeralRunnerSpec, so they are no longer a recovery route. This is intended, and it reverses what Switch the scale set off instead of rebuilding it when runners are outdated #4652 allowed.
  • Runner group and scale set name are likewise not recovery routes. Narrow in practice: exit code 7 means the runner rejected the spec it was handed, and that spec is EphemeralRunnerSpec.
  • A missing EphemeralRunnerSet while outdated counts as corrected. There is nothing left to compare against, so the set is rebuilt from the current spec — and if that spec is still bad it re-enters Outdated immediately. It is not an escape hatch, only a way out of a phase that could otherwise never be left.

Tests

envtest (Test AutoscalingRunnerSet outdated lifecycle): maxRunners, runner group and scale set name edited while outdated must keep the phase Outdated, the listener absent and the revision unchanged — asserted with Consistently, since the bug is a spurious transition. Runner-spec and runner-metadata corrections must recover fully. It("recovers when a field outside the runner spec is updated") asserted the old contract and is replaced by the sticky cases.

Every new It was confirmed to fail before the fix and pass after, run individually since the container is Ordered. At unit level, four of the five fake-client tests fail on the pre-fix controller — parking the first rejection, staying parked without a spec change, recovering without an unobserved generation, and recovering on a metadata change — each for a different reason. The published generation is now an explicit fixture parameter, because defaulting it to the live generation made the sticky-phase test pass for the wrong reason.

go build ./..., go vet ./... and go test ./controllers/... ./cmd/... ./apis/... are green.

The outdated phase means the runners rejected the runner spec they were
given, so the listener is switched off and the EphemeralRunnerSet is
pinned at zero replicas. Recovery was keyed on metadata.generation
instead of on that spec: the generation check moved the phase back to
pending before the outdated short-circuit ran, so editing maxRunners,
the runner group or the scale set name switched the listener back on to
acquire jobs for runners that reject the spec exactly as before.

Gate recovery on ephemeralRunnerSetActionableSpecChanged, the predicate
that compares the runner spec the set is running with the one derived
from the AutoscalingRunnerSet. The outdated check now runs before the
generation comparison, and leaves the phase only when that spec differs.
Recovery still goes through the existing patch path, so the actionable
revision advances with the new spec and the EphemeralRunnerSet stops
judging itself by the runners that failed.

ephemeralRunnerSetNeedsOutdatedRecovery has no callers left and is
removed. The generation annotation on the EphemeralRunnerSet is kept as
a record of the spec's provenance, but nothing keys off it any more.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 15, 2026 14:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Add a test covering missing EphemeralRunnerSet recovery and recreation while outdated.

Review tier: Lite
Findings: None

What changed in this PR

Updates AutoscalingRunnerSet recovery so the Outdated phase persists until the runner specification changes.

Changes:

  • Replaces generation-based recovery with runner-spec comparison.
  • Keeps outdated scale sets disabled for unrelated edits.
  • Adds lifecycle and fake-client coverage.

A moderate issue remains: add coverage for recovery and recreation when the EphemeralRunnerSet is missing while outdated.

File Description
controllers/​actions.github.com/​helpers.go Removes obsolete generation-based recovery logic.
controllers/​actions.github.com/​constants.go Clarifies generation annotation semantics.
controllers/​actions.github.com/​autoscalingrunnerset_outdated_recovery_test.go Adds focused recovery tests.
controllers/​actions.github.com/​autoscalingrunnerset_controller.go Implements spec-aware outdated handling.
controllers/​actions.github.com/​autoscalingrunnerset_controller_test.go Tests sticky outdated lifecycle behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The EphemeralRunnerSet spec carries the metadata stamped onto the runners
it creates alongside the runner spec itself. Both are published by the
AutoscalingRunnerSet and both change what the next runner is handed, so
both recover a scale set from the outdated phase; Replicas, PatchID and
ActionableRevision stay out of the comparison, since they are scaling
bookkeeping pinned to zero while the set is parked.

Recovery on a metadata-only change has to advance the actionable
revision as well. Otherwise the metadata patch lands without it, the
EphemeralRunnerSet keeps judging itself by the runners that failed, and
the scale set is pushed straight back to outdated.

The fake-client fixture published the live generation on the
EphemeralRunnerSet, which is the one value that made the old
generation-based signal park for its own reasons, so the sticky-phase
test passed without the fix. The published generation is now explicit
per test, which also makes room for the opposite failure: with the
generation current and the runner spec genuinely different, the old
signal refused a legitimate recovery.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A critical listener-cleanup issue and unresolved moderate findings must be addressed.

Get a fresh assessment by requesting another Copilot review.

Review tier: Lite
Findings: 1 High severity · 1 Low severity

Open (2)

Comment on lines +166 to +167
if !corrected {
return r.reconcileOutdated(ctx, &autoscalingRunnerSet, log)
Comment on lines +13 to +15
// It is also the sole signal that recovers a scale set from the outdated phase:
// the runners rejected this spec, so nothing short of changing it is reason to
// retry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants