Handover to milestone 4b: rolling updates of ephemeral groups¶
Status: end of milestone 4a, slot-based scaling (2026-08-13). Written for a session that starts with no memory of this one, possibly on another machine.
This document is not a spec. It says where 4a stopped, what 4b is, what it finds in place, and the handful of things it has to decide before any code is written. The 4b spec and plan do not exist yet; writing them is the first piece of work.
0. Start here¶
Read, in this order, and nothing else to begin with:
- This document.
docs/superpowers/specs/2026-08-07-minecraft-cloud-operator-design.md§4.4 "Rolling updates" and §6.3 "Slot-based scaling" — twenty lines each, and they are the requirement 4b implements.docs/superpowers/specs/2026-08-13-slot-based-scaling-design.md§3 "Decisions" — 4a's reasoning, three of which 4b either inherits or deliberately reverses.internal/controller/scaling.goin full. It is 249 lines and it is where most of 4b lands.
docs/handover-milestone-4.md is the previous handover, written at the end
of milestone 3c and since extended. It covers milestone 4 as a whole and is
still the right place for what 4c needs; this document does not repeat it.
1. Where the project is¶
Milestone 3 is closed, both halves of its criterion proven against real
clusters: a player joins automatically (2026-08-12) and by hand with a real
Microsoft account, and a deleted Server moves that player rather than
disconnecting them (2026-08-13, a different machine, from an empty cluster
upward). docs/runbook-milestone-3-evidence.md and the "evidence run" and
"manual session" sections of docs/handover-milestone-4.md carry the
measurements.
Milestone 4 was cut into three sub-projects:
| Contents | State | |
|---|---|---|
| 4a | Slot-based scaling of ephemeral ServerGroups |
done |
| 4b | Rolling updates of ephemeral groups — this document | done |
| 4c | Proxy drain and node drain | after 4b |
4a made an ephemeral ServerGroup size itself from its free player slots
instead of sitting at spec.scaling.minReplicas. DecideSize in
internal/controller/scaling.go is the rule; ServerGroupReconciler.size
executes it.
2. What 4b is¶
From the master design, §4.4. For an ephemeral group whose spec changed —
which raises its metadata.generation, making every server created before it
stale — the changeover runs surge-first and without kicks:
- Stale servers no longer count towards the group's free slots, so the scaler produces replacements of the new generation by itself.
- Once enough ready capacity of the new generation exists — at least one ready server, mandatory for fallback groups — a stale server goes into soft drain: deregistered, accepting no new joins, its players undisturbed until it empties on its own.
spec.update.maxUnavailable(default 1) limits how many servers of the group are inDrainingorTerminatingat once because of a generation change.spec.update.maxStaleSeconds(default 0 = unlimited) forces the active drain once it expires, if a server does not empty on its own.
The design states why step 1 is not optional: without it the update would never terminate, because a lobby that as a fallback target practically never empties would stay registered and its free slots would keep the scaler from creating anything.
Per-group exponential backoff belongs to 4b too. docs/known-issues.md
carries it as a milestone 4 precondition and names 4b as its owner: master
design §7 asks for backoff with a Degraded condition and a stop after
repeated failures, and internal/controller/candidates.go:235 currently
holds the line with a blunt maxRetainedFailures = 1 whose own comment says
so. It is in 4b because it touches pruneFailed and the failure path, which
the rolling update touches anyway.
Persistent groups are milestone 5, not 4b. The CRD already refuses
spec.update on them (api/v1alpha1/servergroup_types.go:106).
3. What is in place¶
The CRD is complete. 4b should need no new field. UpdateSpec is at
api/v1alpha1/servergroup_types.go:61 with both fields, their defaults and
their validation. Verify that claim before relying on it, but it held at the
time of writing.
The generation is recorded on every server and read by exactly one thing
today. Server.spec.groupGeneration is written once, at creation
(internal/controller/servergroup_controller.go:421), and read once, into
ServerView.Generation (:378). Its only consumer is AggregateGroup
(internal/controller/candidates.go:301), which excludes stale generations
from status.freeSlots — the hook §4.4 step 1 asks for, already built and
so far unused by any decision.
DecideSize is the sizing rule and 4b extends it rather than standing up a
second scaler. It is a pure function over value types, table-tested without
a cluster, alongside phase.Decide and SelectDeletionCandidates. Its
inputs are a ScalingInputs struct; adding the generation and the update
knobs to that struct is the natural shape.
expectations (internal/controller/expectations.go) reserves the
creates and deletes a reconcile has issued and the cache has not shown, keyed
by name, with a 30-second TTL. A rolling update creates and deletes far more
than a floor ever did, so 4b leans on it harder than 4a does.
Soft drain's primitive already exists, in two halves. Registrar
(internal/controller/registrar.go) has Register, Deregister and Drain
as three separate calls, and phase.Decision has Deregister and
StartDrain as two separate flags. Soft drain is Deregister without
Drain. Milestone 3c proved the proxy side of both against a live cluster.
ServerView.EmptyFor carries how long a server has been reporting zero
players, from agent.Snapshot.EmptyFor. maxStaleSeconds needs a different
clock — time since the server became stale, not since it emptied — but the
shape to copy is there, including the rule that neither value ever decides
anything on its own: every rule that reads one also asks
Players == 0 && !Stale.
countsTowardSize() (internal/controller/candidates.go:165) already
excludes Draining, Terminating and Failed. A server that enters soft
drain therefore stops holding the group at its floor, and the scaler orders
its replacement on the next pass. That is the surge in §4.4 step 2, and it
falls out of existing code rather than needing new code.
4. The one structural thing 4b has to decide¶
Soft drain is a state the Server machine cannot express today, and this
is 4b's equivalent of the contract change 4c owns.
phase.Decision.Deregister is documented as "set on every exit from Ready",
and every path that sets it also leaves Ready. The phases available are:
Ready— registered, taking joins, holding players.Draining— deregistered, players actively being moved off, bounded byspec.drain.timeoutSeconds, after which the server goes toTerminatingwith players still online.
Soft drain is neither. It is deregistered, not being drained, players
undisturbed, waiting to empty on its own — and, unlike Draining, it must
not have spec.drain.timeoutSeconds hanging over it, because a lobby can sit
in it for hours legitimately. spec.update.maxStaleSeconds is what bounds
it, and only when configured non-zero.
Three shapes are open, and the choice belongs in the 4b spec:
- A new phase, e.g.
Retiring, betweenReadyandDraining. Clearest to read on a CR, and every consumer of the phase —countsTowardSize,mayHavePlayers, the PDB'soccupiedPods,derivePhase— has to be revisited, which is a feature rather than a cost: each one is a question worth answering explicitly. Drainingentered withoutStartDrain, with the drain deadline suppressed while the reason is a generation change. Smallest diff, and it overloads a phase whose name then means two things — and §4.4 step 3 counts "DrainingorTerminating" againstmaxUnavailable, which would then silently include soft-drained servers.- A field beside the phase, e.g.
status.retiring, leaving the phase alone. Avoids touching the state machine and creates a second axis somebody has to remember to read — the shape this repository has been bitten by before (internal/controller/candidates.go:60records what two implementations of one rule cost when they drifted).
Whichever is chosen, the invariant that governs everything else must survive
unchanged: a server that may be carrying players is never nominated for
deletion. SelectDeletionCandidates holds it today and every 4a review
checked it. Write it into the 4b spec as an acceptance criterion.
5. The other decisions worth settling before code¶
- Where the generation filter goes back into
DecideSize, and with what brakes. 4a is deliberately generation-blind; §6 below says why, and it matters more here than anywhere else in this document. - What "enough ready capacity of the new generation" means, given §4.4
step 2 makes one ready server mandatory for fallback groups. A group knows
it is a fallback target only through the
ProxyGroups that name it inspec.fallbackGroups, which theServerGroupcontroller does not read today. Either it learns to, or the rule is applied to every group. - How
maxUnavailableis counted — against servers made unavailable by this update, or against everyDraining/Terminatingserver whatever the cause, including a scale-down that 4a's demand rule started. The design says "because of a generation change", which points at the former and needs a way to tell the two apart. - What clock
maxStaleSecondsmeasures from. The server has no "became stale at" — its generation is fixed at creation and the group's moved. The group'smetadata.generationchange has no timestamp of its own either. Something has to record one. - Whether a rolling update and a scale-down may run in the same pass. 4a
established the precedent that a group short of capacity never also shrinks
for lack of demand, and that the ceiling is an instruction that overrides
the shortfall. A third source of removals needs its place in that order,
and the order is in
DecideSize, in one function, deliberately.
6. What 4a left for 4b, deliberately¶
docs/known-issues.md, section "From milestone 4a", holds both in full.
In short:
4a reads no generation at all, and that was a correction made during
planning, not an oversight. A scale-up rule that credited only servers of
the current generation would find, the instant any field of the group's spec
changed, that nothing running counts — and would order a full replacement set
up to maxReplicas on the next five-second pass. That is precisely 4b's
rolling update, performed without maxUnavailable, without soft drain and
without the "at least one ready server of the new generation" guarantee.
Putting the generation back into DecideSize is 4b's central task, and it
is only safe together with the rules that brake it. Do not do it first and
add the brakes afterwards; a tree in that state kicks players.
provisionalCapacity cannot tell "has never reported" from "the pod
vanished". Both present as Slots == 0, because Registry.Lookup on an
unknown pod returns a zero snapshot, so a server whose pod is gone is
credited a full maxPlayers it does not have for the seconds until the
Server controller fails it. The blast radius is under-creation for a resync
or two and no invariant is touched. The obvious fix — testing Stale before
Slots == 0 — is a regression, verified by mutation: a genuinely starting
server is also stale and has never reported, and crediting it zero
reintroduces the runaway the rule exists to prevent. The right signal is
ServerView.SessionsGone, already on the view and unused here: one line at
the top of the function, plus its unit tests. It belongs with 4b because 4b
is already in scaling.go.
derivePhase measures readiness against DesiredReplicas(), which
before 4a was the size the group ran at and is now only its floor. A group
scaled to five for spare slots publishes status.phase: Ready with one
server up and four starting. Defensible, but the field changed meaning
silently, and a rolling update is exactly where somebody will want to ask
"is this group finished changing over?" and find that the phase does not
answer it.
7. What 4b must not do¶
- Do not unify
status.freeSlotswith the scaler's own figures. There are three numbers on purpose:AggregateGroup'sFreeSlots(generation-filtered,Readyonly, published on the CR),provisionalCapacity's sum (credits ordered-but-unarrived capacity, generation-blind, drives scale-up) andreadyFree(arrived capacity, generation-blind, the denominator of the scale-down feasibility test).scaling.gosays why in comments, the 4a spec says why in §3 and §4.2, anddocs/known-issues.mdsays why a third time so that a search finds it. A reader meeting them for the first time will want to collapse them; 4b will change what the first one is filtered by, and that is the only one of the three whose meaning is meant to move. - Do not weaken
SelectDeletionCandidates. Every path that removes a server goes through it, and itsmayHavePlayersguard is what the whole system rests on. - Do not make a test pass by changing what it asserts without first
proving the property it guarded is still measured somewhere. 4a's reviews
found eleven assertions that could not fail, and the worst of them was a
pre-existing test that had silently stopped testing: mutating
collectViewsto a constantfalse— the exact defect the test named in its own failure message — left it green, because a new filter excluded the server before the old guard was ever consulted. "The test stopped failing" and "the test stopped testing" look identical from outside. For any test whose expectations move, make the mutation for real and report the output.
8. The environment¶
nix develop # Go, controller-gen, protoc, envtest assets, kubectl, kind, k3d, JDK 21, Gradle
nix develop -c make test # Go only. Must be green before anything is touched.
nix develop -c make manifests # Must produce no diff unless a CRD field really changed.
make test takes about 38 seconds, of which internal/controller is about
34: envtest boots a real API server. A slow run is not a hang. Coverage sits
at 88% for internal/controller and 98% for internal/agent; both should
stay there.
4b is operator-only Go, as 4a was. No proto change, no agent change, no
image change — so make agent, make agent-test, make image-test and
make image-repro are not in its path. Verify that with
git diff --name-only before merging rather than assuming it.
envtest's client is a direct client, not a cached one. No envtest can
reproduce the cache lag expectations exists for; that behaviour lives in
its unit tests. Do not try to build a cache-lag envtest — 4a's plan says so
and the attempt wastes an afternoon.
Machine-specific notes live in this repository's docs/known-issues.md
and, on the machine 4a was built on, in that session's project memory: image
builds there needed CONTAINER=podman and a disk-backed TMPDIR, and Gradle
daemons exhaust a small machine. Both overrides are conditional on the
machine, not unconditional requirements — CONTAINER=podman is needed where
there is no docker binary at all, and TMPDIR where /tmp is a tmpfs too
small for Podman's OCI-archive extraction, which is the reason
docs/runbook-milestone-3-evidence.md §0 gives for TMPDIR. (§0 states
CONTAINER=podman itself as conditional; its TMPDIR bullet is an
unconditional instruction, and only the rationale attached to it is
qualified the same way.) Measured 2026-08-14 on
this repository's own machine, where docker is a symlink into
podman-docker-compat and /tmp is disk-backed: neither override is needed,
and make agent-test, make image-test and make image-repro all pass on
plain defaults. None of it applies to 4b's Go-only path, and none of it —
including the absence of the overrides — should be assumed on a different
machine.
9. Where everything lives¶
| Master design | docs/superpowers/specs/2026-08-07-minecraft-cloud-operator-design.md |
| 4a design | docs/superpowers/specs/2026-08-13-slot-based-scaling-design.md |
| 4a plan | docs/superpowers/plans/2026-08-13-slot-based-scaling.md |
| Open points, by owning milestone | docs/known-issues.md |
| Milestone 4 as a whole, and 4c's contract change | docs/handover-milestone-4.md |
| Milestone 3's evidence | docs/runbook-milestone-3-evidence.md |
The working method that produced 4a, and which is worth repeating: brainstorm
to a design spec, then a plan of bite-sized tasks each with its failing test
written out, then a fresh implementer per task with a two-stage review after
each, then one whole-branch review before merge. The whole-branch review
earned its keep on 4a — it found a fixed point no per-task review could see,
where a lowered maxReplicas was never enforced on a group that was also
short of spare slots.
4b has landed¶
4b closed against this document's own decisions: Retiring as a phase of its
own (§4), the generation kept out of the capacity arithmetic and readmitted
only for retirement candidacy and the demand rule's changeover filter (§5,
§6), and every open question in §5 resolved and recorded either here or in
docs/known-issues.md, "From milestone 4b". The design it produced is
docs/superpowers/specs/2026-08-13-rolling-updates-design.md; that document's
§11, "What 4b leaves open," is where the changeover's own gaps live —
derivePhase still not answering "has this group finished changing over?",
and the drain's proxy-side blind spot from milestone 3, both carried forward
rather than closed here.
Per-group exponential backoff is next. §1 of the 4b design measured, and
found false, the handover's own assumption that backoff belongs with 4b
because it "touches the failure path which the rolling update touches
anyway" — 4b shares no code with pruneFailed. maxRetainedFailures = 1
still stands in for it, capping the footprint of a retained failure but not
the rate at which the group tries again; 4b's own cold start inherits and
extends that gap rather than closing it (docs/known-issues.md, "From
milestone 4b"). Master design §7's Degraded condition is that spec's to
write.