From 1c89a5eeeb1fbf0056ec0e1ac3a9e8eaba534e6a Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Wed, 23 Sep 2026 04:05:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(operator):=20wake=20up=20for=20idle=20auto-?= =?UTF-8?q?stop=20=E2=80=94=20the=20timer=20had=20no=20driver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmptySince was stamped and then never revisited: player joins/leaves do not touch the CRD, RCON is only probed inside Reconcile, and a steady Running server produces no watch events (its status update goes out unchanged and is a no-op). Live, an empty server with a 30s grace sat Running for minutes with zero reconciles in the log — the auto-stop existed only on paper. reconcileRunning now returns a RequeueAfter for idle-enabled servers: exactly at the deadline while empty, or a 30s probe cadence while occupied so the moment the last player leaves is noticed. New unit tests pin all three: deadline requeue, occupied cadence, and no requeue when disabled. --- internal/operator/reconciler.go | 21 +++++++++- internal/operator/reconciler_test.go | 60 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/internal/operator/reconciler.go b/internal/operator/reconciler.go index 6bf7c04..845c720 100644 --- a/internal/operator/reconciler.go +++ b/internal/operator/reconciler.go @@ -35,6 +35,7 @@ const ( requeueStarting = 2 * time.Second requeueStopping = 5 * time.Second requeueSecret = 10 * time.Second + requeueIdleProbe = 30 * time.Second defaultTimeoutSeconds = 300 defaultReadinessTimeoutSec = 300 ) @@ -197,7 +198,25 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine } r.markRunningReady(server, players, endpointAddress) - return ctrl.Result{}, r.patchStatus(ctx, server) + if err := r.patchStatus(ctx, server); err != nil { + return ctrl.Result{}, err + } + // Idle auto-stop has no natural wake-up: player joins/leaves never touch + // this CRD and RCON is only probed here, so without a requeue the + // empty-duration counter would be stamped once and then never revisited + // (observed live: the stamp sat unexamined for minutes). Wake at the exact + // deadline while the tally says empty, or on a slow cadence while players + // are online, to notice the moment the last one leaves. + if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 { + if server.Status.EmptySince != nil { + deadline := server.Status.EmptySince.Time.Add(time.Duration(server.Spec.Idle.EmptySecondsBeforeStop) * time.Second) + if wait := deadline.Sub(r.now().Time); wait > 0 { + return ctrl.Result{RequeueAfter: wait}, nil + } + } + return ctrl.Result{RequeueAfter: requeueIdleProbe}, nil + } + return ctrl.Result{}, nil } func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) { diff --git a/internal/operator/reconciler_test.go b/internal/operator/reconciler_test.go index fecc6d5..caedb52 100644 --- a/internal/operator/reconciler_test.go +++ b/internal/operator/reconciler_test.go @@ -427,6 +427,66 @@ func TestIdleAutoStop_ResetsWhenPlayerJoins(t *testing.T) { } } +// TestIdleAutoStop_RequeuesUntilDeadline pins the self-driving requeue: an +// empty Running server must wake the controller at the auto-stop deadline with +// no external event to lean on. Live, the EmptySince stamp sat unexamined for +// minutes because nothing re-triggered the reconcile loop — this test fails if +// the requeue is ever dropped again. +func TestIdleAutoStop_RequeuesUntilDeadline(t *testing.T) { + r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) + base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + r.Now = func() metav1.Time { return metav1.NewTime(base) } + + s := getServer(t, c, "survival") + s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 30} + if err := c.Update(context.Background(), s); err != nil { + t.Fatalf("enable idle: %v", err) + } + + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + res := reconcile(t, r, "survival") + + if res.RequeueAfter != 30*time.Second { + t.Fatalf("RequeueAfter = %v, want exactly 30s (wake at the auto-stop deadline)", res.RequeueAfter) + } +} + +// TestIdleAutoStop_RequeuesWhileOccupied verifies the slow probe cadence that +// notices the last player leaving: with players online there is no deadline to +// aim at, but the tally must still be re-sampled. +func TestIdleAutoStop_RequeuesWhileOccupied(t *testing.T) { + r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 3, Max: 20}}, runningServer(), rconSecret()) + + s := getServer(t, c, "survival") + s.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 900} + if err := c.Update(context.Background(), s); err != nil { + t.Fatalf("enable idle: %v", err) + } + + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + res := reconcile(t, r, "survival") + + if res.RequeueAfter <= 0 { + t.Fatalf("RequeueAfter = %v, want a positive probe cadence while occupied", res.RequeueAfter) + } +} + +// TestNoIdleRequeueWhenDisabled guards against a blanket requeue: servers +// without idle auto-stop keep the old quiescent behaviour. +func TestNoIdleRequeueWhenDisabled(t *testing.T) { + r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20}}, runningServer(), rconSecret()) + + reconcile(t, r, "survival") + markPodReady(t, c, "survival") + res := reconcile(t, r, "survival") + + if res.RequeueAfter != 0 { + t.Fatalf("RequeueAfter = %v, want 0 when idle auto-stop is disabled", res.RequeueAfter) + } +} + // TestIdleAutoStop_SkipsWhenDisabled verifies that a Running empty server does // NOT get an EmptySince timestamp when AutoStopEnabled is false. func TestIdleAutoStop_SkipsWhenDisabled(t *testing.T) {