Files
actions-runner-controller/controllers/actions.github.com/ephemeralrunnerset_scaleup_suppression_test.go
T
Nikola JokicandCopilot App c441394218 Clear the scale-up suppression marker when the revision advances
Status.FinishedRunnerCleanupPatchID was only ever set, never cleared, so
a marker recorded under one listener incarnation outlived the patch-ID
sequence it described. Applying a new actionable revision deletes the
idle and pending runners so they are rebuilt from the new spec, and it
restarts the listener. A restarted listener numbers its patches from 0
upwards and counts through every integer, so it does not merely risk
reusing the leftover value, it passes through it. If that reuse lands on
the reconcile that has to refill the pool, the guard suppresses exactly
the scale up the revision change asked for.

Clearing the marker where the applied revision advances is enough,
because the marker only ever means "the gap below Spec.Replicas was made
by cleaning up finished runners for this patch ID", and a spec change
invalidates that claim outright. That read now bypasses the cache: the
patch is a diff against the object that was read, so a cached copy
showing 0 while the server held a marker would emit no entry for the
field and leave the stale value behind.

Clearing the marker also breaks the invariant the cached fast path in
scaleUpServicedByFinishedRunnerCleanup rested on. That path was safe
only because a marker was never removed, so a cache hit could not be a
false positive. Now it can be: a lagging cache can show a marker the
server has already cleared, which suppresses the rebuild. The decision
is therefore always made against an uncached read. That costs one GET,
and only on reconciles that were about to issue creates anyway.

One window remains and is documented rather than papered over. A
listener that restarts without a spec change keeps the marker and still
renumbers from 0, so a collision is still likely. It costs one
suppressed reconcile, not an outage: the listener calls back into
scaling on every long-poll timeout rather than only on change, and an
idle set at its minimum publishes the collapsed patch ID 0, which is
never suppressed.

This was found while investigating the update-gha-runner-scale-set e2e
failure on the tip of the stack. It is a real defect, but it does not
explain that failure, whose cause remains open: the suppression here is
self-correcting within a long-poll cycle rather than terminal.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 22:56:48 +02:00

202 lines
8.7 KiB
Go

package actionsgithubcom
import (
"context"
"testing"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
// TestScaleUpServicedByFinishedRunnerCleanup pins down the decision that
// suppresses scale up after finished runners were cleaned up.
//
// The interesting case is the third one. The marker is written by one reconcile
// and read back by the next, and the deletions performed by the first reconcile
// are themselves what triggers the second. That next reconcile is regularly
// served from an informer cache that has not yet observed the controller's own
// status write, so the decision must not be made from the cached copy alone. An
// envtest spec only hits that window under load, which is why this is asserted
// directly instead.
func TestScaleUpServicedByFinishedRunnerCleanup(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, v1alpha1.AddToScheme(scheme))
key := types.NamespacedName{Namespace: "test-ns", Name: "test-ers"}
newSet := func(specPatchID, statusCleanupPatchID int) *v1alpha1.EphemeralRunnerSet {
return &v1alpha1.EphemeralRunnerSet{
ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name},
Spec: v1alpha1.EphemeralRunnerSetSpec{
Replicas: 2,
PatchID: specPatchID,
},
Status: v1alpha1.EphemeralRunnerSetStatus{
FinishedRunnerCleanupPatchID: statusCleanupPatchID,
},
}
}
newReader := func(objects ...client.Object) client.Client {
return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
}
t.Run("suppresses when the API server records the spec patch ID", func(t *testing.T) {
r := &EphemeralRunnerSetReconciler{
Client: newReader(newSet(2, 2)),
APIReader: newReader(newSet(2, 2)),
}
suppressed, err := r.scaleUpServicedByFinishedRunnerCleanup(context.Background(), key, newSet(2, 2))
require.NoError(t, err)
assert.True(t, suppressed, "the cleanup for this patch ID is recorded, so the shortfall is not new demand")
})
t.Run("does not suppress when neither the cache nor the API server records the patch ID", func(t *testing.T) {
r := &EphemeralRunnerSetReconciler{
Client: newReader(newSet(2, 0)),
APIReader: newReader(newSet(2, 0)),
}
suppressed, err := r.scaleUpServicedByFinishedRunnerCleanup(context.Background(), key, newSet(2, 0))
require.NoError(t, err)
assert.False(t, suppressed, "no cleanup was performed for this patch ID, so the shortfall is genuine demand")
})
t.Run("suppresses when the cache is stale but the API server records the patch ID", func(t *testing.T) {
// The cleanup reconcile wrote the marker and returned, and the reconcile
// triggered by its own deletions is still reading a pre-write cache.
// Client stands in for that lagging cache, APIReader for the API server
// that already has the write.
stale := newSet(2, 0)
r := &EphemeralRunnerSetReconciler{
Client: newReader(newSet(2, 0)),
APIReader: newReader(newSet(2, 2)),
}
suppressed, err := r.scaleUpServicedByFinishedRunnerCleanup(context.Background(), key, stale)
require.NoError(t, err)
assert.True(t, suppressed, "the decision must come from the API server, not from a cache that has not caught up")
})
t.Run("does not suppress when the cache still shows a marker the API server has cleared", func(t *testing.T) {
// The mirror image of the case above, and the one that opens up once
// applying a new revision clears the marker. A cached hit is no longer
// self-evidently safe: here the cache still carries the marker from
// before the spec change while the API server has already cleared it, and
// trusting the cache would suppress the scale up that rebuilds the pool.
stale := newSet(2, 2)
r := &EphemeralRunnerSetReconciler{
Client: newReader(newSet(2, 2)),
APIReader: newReader(newSet(2, 0)),
}
suppressed, err := r.scaleUpServicedByFinishedRunnerCleanup(context.Background(), key, stale)
require.NoError(t, err)
assert.False(t, suppressed, "a cleared marker on the API server must win over a stale cached one")
})
t.Run("never suppresses when the listener published patch ID zero", func(t *testing.T) {
// Patch ID 0 is the collapsed state the listener republishes on every
// long-poll timeout once the set is idle at its minimum. Suppressing on
// it would let a scale set sit below its minimum indefinitely.
r := &EphemeralRunnerSetReconciler{
Client: newReader(newSet(0, 0)),
APIReader: newReader(newSet(0, 0)),
}
suppressed, err := r.scaleUpServicedByFinishedRunnerCleanup(context.Background(), key, newSet(0, 0))
require.NoError(t, err)
assert.False(t, suppressed, "patch ID 0 must always be free to scale up")
})
t.Run("fails loudly when it cannot read past the cache", func(t *testing.T) {
r := &EphemeralRunnerSetReconciler{Client: newReader(newSet(2, 2))}
_, err := r.scaleUpServicedByFinishedRunnerCleanup(context.Background(), key, newSet(2, 0))
assert.Error(t, err, "a missing APIReader must surface rather than silently fall back to the cached marker")
})
}
// TestPatchAppliedActionableRevisionStatusClearsFinishedRunnerCleanupPatchID
// covers the marker's lifetime across a spec change.
//
// The marker is a patch ID, and patch IDs are only meaningful within one
// listener incarnation. A spec change restarts the listener, which numbers its
// patches from 0 upwards and so passes through any leftover value. Carrying the
// marker across the revision boundary therefore suppresses the scale up that is
// supposed to rebuild the pool the revision cleanup just deleted.
func TestPatchAppliedActionableRevisionStatusClearsFinishedRunnerCleanupPatchID(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, v1alpha1.AddToScheme(scheme))
key := types.NamespacedName{Namespace: "test-ns", Name: "test-ers"}
newSet := func(appliedRevision int64, cleanupPatchID int) *v1alpha1.EphemeralRunnerSet {
return &v1alpha1.EphemeralRunnerSet{
ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name},
Status: v1alpha1.EphemeralRunnerSetStatus{
AppliedActionableRevision: appliedRevision,
FinishedRunnerCleanupPatchID: cleanupPatchID,
},
}
}
newClient := func(object client.Object) client.Client {
return fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(object).
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
Build()
}
t.Run("clears the marker when the applied revision advances", func(t *testing.T) {
c := newClient(newSet(3, 7))
r := &EphemeralRunnerSetReconciler{Client: c, APIReader: c}
require.NoError(t, r.patchAppliedActionableRevisionStatus(context.Background(), key, 4))
var got v1alpha1.EphemeralRunnerSet
require.NoError(t, c.Get(context.Background(), key, &got))
assert.EqualValues(t, 4, got.Status.AppliedActionableRevision, "the applied revision should advance")
assert.Zero(t, got.Status.FinishedRunnerCleanupPatchID, "a marker from the previous patch sequence must not survive the spec change")
})
t.Run("decides against authoritative state rather than the cache", func(t *testing.T) {
// The helper both decides and diffs against the object it reads, so that
// read has to bypass the cache. Here the cache has already caught up to
// revision 4 while the API server has not, which is the shape a cache
// takes when it has observed a write the reconcile is about to redo: a
// cached read would conclude there is nothing to do and leave the stale
// marker in place.
authoritative := newClient(newSet(3, 7))
lagging := newClient(newSet(4, 7))
r := &EphemeralRunnerSetReconciler{Client: lagging, APIReader: authoritative}
require.NoError(t, r.patchAppliedActionableRevisionStatus(context.Background(), key, 4))
var got v1alpha1.EphemeralRunnerSet
require.NoError(t, lagging.Get(context.Background(), key, &got))
assert.Zero(t, got.Status.FinishedRunnerCleanupPatchID, "the clear must be computed against authoritative state")
})
t.Run("leaves the marker alone when the revision has already been applied", func(t *testing.T) {
// Nothing was cleaned up here, so there is no reason to disturb a marker
// that is still describing the current patch sequence.
c := newClient(newSet(4, 7))
r := &EphemeralRunnerSetReconciler{Client: c, APIReader: c}
require.NoError(t, r.patchAppliedActionableRevisionStatus(context.Background(), key, 4))
var got v1alpha1.EphemeralRunnerSet
require.NoError(t, c.Get(context.Background(), key, &got))
assert.EqualValues(t, 7, got.Status.FinishedRunnerCleanupPatchID, "an unchanged revision must not clear the marker")
})
}