Persistent storage: what an operator owns¶
A Persistent ServerGroup gives each ordinal a world on a
PersistentVolumeClaim of its own. Three things follow from that which nobody
can read off the CRD, and none of them is a defect: they are consequences of
decisions taken deliberately, and they are here rather than in
known-issues.md because that file carries problems and
these are properties.
The short version, for somebody who is in the middle of something:
- This operator never deletes a claim. Not on scale-down, not on group deletion, not ever. Orphans accumulate and removing one is a human act.
- Deleting a claim deletes a world. There is no undelete and no confirmation, because the operator is not the one deleting it.
- A group whose storage is broken stalls rather than thrashing, and takes
roughly five and a half hours to say
Degradedabout it. There are two status fields that say something true from the first failure onward.
Claims, and why they outlive their servers¶
Claims accumulate, and this operator can never remove one. Deleting a
Server — by scaling down, by hand, or through the failed-retention path
in the next section — never deletes the PersistentVolumeClaim it mounted:
podspec.BuildDataClaim stamps no owner reference, and nothing in this
operator calls Delete on a claim anywhere. That is not merely the observed
behaviour, it is enforced structurally: the ClusterRole
(config/rbac/role.yaml) grants persistentvolumeclaims:
create;get;list;watch;patch and nothing else — patch is growClaim's,
which touches a claim's requested size and no other field — and
internal/rbacaudit/required.go documents exactly those five verbs with a
comment explaining why delete and update are absent on purpose.
internal/rbacaudit's tests compare the
generated role against that table in both directions — extra grants as well as
missing ones — so a future delete marker added anywhere in the codebase
turns the audit red before it can ship. A lowered spec.replicas, a group
deleted outright, or an ordinal simply never brought back all leave their
claims standing, by design: §3.3 of the persistent-groups design settles that
a mistake here should cost a stray object, never a world.
To find what a namespace has accumulated:
kubectl get pvc -l spawnery.cloud/managed-by=spawnery-operator -n <namespace>
Every claim this operator ever created carries that label
(podspec.LabelManagedBy), and it is the one — the only one — that restricts
the manager's own cache over claims (cmd/spawnery-operator/main.go).
podspec.BuildDataClaim puts three more on every claim it renders,
spawnery.cloud/network, spawnery.cloud/group and spawnery.cloud/server;
none of those narrows anything the operator does, and they are there for
whoever is reading claims by hand. To tell a claim still in
service from an orphan, compare each claim's spawnery.cloud/server label
against the Server objects that currently exist for that group: a claim
named <group>-<ordinal>-data whose spawnery.cloud/server names a Server
that is gone (scaled away, or the group itself deleted) is an orphan.
Deleting a claim deletes a world — there is no undelete, and no
confirmation this operator can offer, because it never performs the deletion
itself. Removing one is a deliberate human act with kubectl delete pvc,
outside this operator entirely, and belongs on the runbook that grows up
around this operator's use rather than in its own code.
A claim that never binds ends in a stall, and the stall is deliberate.
docs/superpowers/specs/2026-08-15-persistent-groups-design.md §3.5 is on its
third version for exactly this mechanism — the first two were wrong, and its
own top-of-section note says so — so what follows is checked against the code
as it stands rather than repeated from memory:
- A pod that never becomes playable fails its server's startup deadline the
same way an ephemeral one would;
phase.Decide'sFailedcase is type-blind. - Nothing on the group's side ever removes a persistent server for having
failed.
pruneFailedonly runsif group.IsEphemeral()(internal/controller/servergroup_controller.go), andDecidePersistentSizeholds an ordinal for as long as any server carries it, in any phase — so aFailedcorpse keeps its ordinal. - What does eventually move it is
phase.Decide's own retention clock: oncenow - status.failedAt >= spec.failedRetentionSeconds(3600 seconds at the CRD default), theFailedcase returnsTerminating, and the Server controller deletes the object once its pod is gone (internal/controller/server_controller.go, thedecision.Next == phase.Terminating && !podFoundbranch). The ordinal is free the moment that delete lands. - The group's very next pass sees the ordinal missing and creates it again,
under the identical deterministic name —
podspec.DataClaimNamederives the claim name from the server name, and the server name is<group>-<ordinal>— so the new server's claim-create call getsAlreadyExistsand mounts the same, still-broken volume.DecideBackoff's create gate (backoff.MayCreate, gatingCreateOrdinalsthe same way it gates the ephemeral count ininternal/controller/servergroup_controller.go'ssize()) is what turns this from an unbounded loop into a bounded one: six counted failures and the group gives up. - So the period of the retry loop is
spec.failedRetentionSeconds, not the backoff window — the backoff window is at most 160 seconds (10s doubling to 160s across five gaps before the sixth failure), which at the CRD's 3600-second default never actually delays an attempt. What the backoff contributes here is only the give-up. - After the give-up the group waits indefinitely — but not, at first, with no
Serverobject for that ordinal. At the moment the count reaches the threshold the sixth corpse is still standing and still holding its ordinal, and it stays for one morefailedRetentionSecondsbefore the Server controller takes it away. The empty-ordinal state is where this settles, roughly an hour later at the CRD default, not where it begins. The claim and the world on it are untouched throughout — nothing in this operator can delete a claim, and its one write grows a claim's size, per the RBAC point above — and a spec change (any edit that movesmetadata.generation) resets the counter and brings the ordinal back.
Stalling is the right outcome rather than a tolerated one. A persistent world
lives on one claim and nothing else can serve it, so a rebuild only ever meets
the same broken volume — sequentially, never concurrently, since the corpse's
pod is deleted before its replacement is created. After six attempts roughly
an hour apart, what is broken is the storage and not the server, and only a
human can fix a storage class, a quota, or a stuck WaitForFirstConsumer
binding.
The failure clock, and why Degraded is late¶
Degraded is late, and that is worth knowing before it fires. At the
default failedRetentionSeconds of 3600 the group is visibly backing off
(BackingOff: True) for only ten to a hundred and sixty seconds of each
roughly hourly cycle. For the rest of each cycle it publishes BackingOff:
False with the reason "no server has failed to start recently" — true in the
narrow sense the field means, and easy to read as "nothing is wrong" while a
Failed corpse is sitting right there holding the ordinal. Six counted
failures span five gaps, not six, and each gap is longer than the
retention window alone: the corpse's failedRetentionSeconds (3600s) has to
elapse before the Server object is removed and a replacement created, and
that replacement then runs its own --startup-deadline (300s by default)
before it can fail in turn and be counted as the next failure. Each gap is
therefore close to 3600 + 300 = 3900 seconds, about sixty-five minutes, not
an even hour. Degraded therefore does not turn true until roughly five
and a half hours after the first failure — five gaps of about sixty-five
minutes each — not six.
The figure holds at any replicas, which is newer than it looks: a healthy
sibling used to reset a broken ordinal's streak, so at two or more ordinals
Degraded could be delayed without bound or never arrive at all.
CountFailures takes requiredOrdinals now and, for a persistent group,
breaks the streak only when every required ordinal has a ready server. What
is left is the lateness itself, which is arithmetic rather than a defect.
An operator watching for a stall in that window should not wait for Degraded
or for BackingOff: True: both status.consecutiveFailures and
status.lastFailureAt are written from the very first counted failure, for a
group of either type — that counting is unconditional in Reconcile, not
behind if group.IsEphemeral() the way the two conditions used to be before
this milestone's own review lifted them out.
kubectl get servergroup <name> \
-o jsonpath='{.status.consecutiveFailures} {.status.lastFailureAt}'
That says something true from the first failure onward, hours before either condition would.
Two things a lowered replicas and a dead node each cost¶
Lowering replicas nominates the top ordinal whoever is on it. The two
sizing rules do not agree about this, and they share one delete path.
SelectDeletionCandidates (internal/controller/candidates.go) skips any
server that mayHavePlayers(), so an ephemeral group shrinks around its
players and takes an empty server instead. DecidePersistentSize
(internal/controller/persistent.go) has no such guard in its surplus loop: it
sorts the ordinals at or above the new replicas and names them, highest
first. Lowering replicas from 3 to 2 therefore asks for survival-2 with
players still on it.
What protects them from there is the ordinary drain, and only that: the Server
controller moves them through the proxies and waits
spec.drain.timeoutSeconds (60 by the CRD default), and anyone still connected
when that deadline passes is disconnected with the pod. Design §7's acceptance
criterion 3 now carries that qualifier; it previously read "without
disconnecting a player on it", unconditionally, which is true only of a drain
that finishes in time.
The alternative is worth naming rather than assuming: a mayHavePlayers()
guard here would mean a lowered replicas is not honoured at all while anyone
is online, because no other server can take ordinal N's place — an ephemeral
group has a different server to delete instead, and a persistent group does
not. Neither direction is free. If you need the players off first, empty the
ordinal before lowering replicas, or raise spec.drain.timeoutSeconds on the
group beforehand so the drain has time to finish.
An ordinal waits, visibly, for a pod that a dead node will never finish
terminating. As of the branch review closing this milestone, the Server
controller refuses to create a pod while a pod of the same name still exists,
terminating or not (internal/controller/server_controller.go). That is what
it must do — creating into the name gets AlreadyExists, and the controller
would then adopt a pod it did not create and delete its own Server one pass
later — but it means the wait inherits whatever bound the termination has. For
an ordinary pod deletion that is spec.terminationGracePeriodSeconds. For a
pod on a node that has gone NotReady, there is none: the API server keeps the
object until a kubelet confirms the kill, and there is no kubelet to confirm
it.
The Server says so rather than sitting silent — Accepted: False with reason
PodNameTerminating and the pod's name in the message — and it says nothing
else: the server never reaches Failed, the per-group backoff never counts it,
and the phase stays Pending for as long as the wait lasts.
That used to be an accident and is a decision since 2026-08-24.
status.startedAt is now stamped when the operator accepts a Server rather than
beside its pod, so a Server with no pod does have a clock — and the deadline
that clock drives is deliberately not run while the pod's name is held by
another pod. Failing here would make the situation worse than the wait: the
replacement is derived from the same ordinal name and meets the same pod, a
Failed server holds its ordinal in DecidePersistentSize's held map, and
pruneFailed does not run for a persistent group — so the object would stay for
its full failedRetentionSeconds, an hour by default, including after
somebody force-deletes the stuck pod below. The wait, by contrast, ends the
moment the name comes free.
kubectl get server <group>-<ordinal> -n <namespace> \
-o jsonpath='{range .status.conditions[?(@.type=="Accepted")]}{.reason}: {.message}{"\n"}{end}'
The remedy is the same one a StatefulSet needs in this situation, and it
carries the same warning: force-deleting the pod object tells the API server
the container is gone without anything having verified that it is. On a node
that is merely unreachable rather than dead, the process may still be running
and still holding the volume, and the replacement will then contend for a
ReadWriteOnce claim the old pod has not released — which hangs on the volume.
Confirm the node is really gone first.
kubectl delete pod <group>-<ordinal> -n <namespace> --force --grace-period=0