Skip to content

Commit 30f70d7

Browse files
feat: recover Cluster from a lost primary majority
A Cluster that loses more than half its primaries at once cannot heal itself — voting a replica in needs a master quorum that no longer exists, so it sits in cluster_state:fail with unserved slots indefinitely while its healthy, current replicas wait for votes that never come. The operator now drives recovery. Once the survivors are provably below a voting quorum (healthy*2 <= total) and each dead primary is fenced from two independent perspectives — the k8s API view of pod/node liveness AND a direct data-path check — it waits out a 45s debounce so it never races a failover gossip could still perform, then issues CLUSTER FAILOVER TAKEOVER on the most up-to-date surviving replica of each shard. Re-created primaries rejoin as replicas via their retained PVCs and the cluster self-heals. Automatic for the Cache profile; opt-in for Durable via the valkey.wellcake.io/quorum-takeover annotation, since a forced takeover can drop acknowledged writes. Write safety rests on Valkey's own minority-write-block (a primary partitioned from the majority stops serving after cluster-node-timeout), not on proving process death — see ADR 0006. Surfaced via status.quorumDownSince and failover_total{reason= "cluster-takeover"}. Validated live on k3d: majority of primaries fenced, recovered to cluster_state:ok with 0 data loss and the fenced primaries rejoining as replicas.
1 parent b625331 commit 30f70d7

11 files changed

Lines changed: 1089 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Added
10+
- Cluster topology now recovers automatically from the loss of a **majority of
11+
primaries** — the disaster gossip cannot fix on its own, because voting a
12+
replica in needs a master quorum that no longer exists, so the cluster would
13+
otherwise sit in `cluster_state:fail` with unserved slots indefinitely. The
14+
operator confirms each dead primary is fenced from two independent
15+
perspectives (the k8s API view of pod/node liveness AND a direct data-path
16+
check), waits out a debounce so it never races a failover gossip could still
17+
perform, then issues `CLUSTER FAILOVER TAKEOVER` on the most up-to-date
18+
surviving replica of each shard. The re-created primaries rejoin as replicas
19+
via their retained PVCs. Automatic for the Cache profile; opt-in for Durable
20+
(via `valkey.wellcake.io/quorum-takeover: "true"`, since a forced takeover can
21+
drop acknowledged writes). Surfaced through `status.quorumDownSince` and the
22+
`failover_total{reason="cluster-takeover"}` metric. See ADR 0006. Validated
23+
live on k3d: majority of primaries fenced, cluster recovered to `ok` with 0
24+
data loss and the fenced primaries rejoining as replicas.
25+
926
### Changed
1027
- Replicas now authenticate to their primary as a dedicated, least-privilege ACL
1128
user (`replicator`, granting only `+psync +replconf +ping` and no key access)

api/v1beta1/valkeycluster_types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,17 @@ type ValkeyClusterStatus struct {
584584
// +optional
585585
PrimaryDownSince *metav1.Time `json:"primaryDownSince,omitempty"`
586586

587+
// QuorumDownSince is when the operator first observed the Cluster topology
588+
// stuck in cluster_state:fail with a majority of primaries unreachable —
589+
// the state gossip cannot self-heal, because authorizing a replica failover
590+
// needs a master quorum that no longer exists. It debounces operator-driven
591+
// quorum recovery (CLUSTER FAILOVER TAKEOVER): the operator only intervenes
592+
// after the cluster has been stuck for the recovery threshold, giving gossip
593+
// time to promote replicas on its own where a quorum still exists. Cleared
594+
// once the cluster reports cluster_state:ok again.
595+
// +optional
596+
QuorumDownSince *metav1.Time `json:"quorumDownSince,omitempty"`
597+
587598
// InternalEndpoint is the in-cluster client endpoint (host:port) of the
588599
// client Service, for consumers to connect to. Surfaced here so wrappers
589600
// (e.g. a Crossplane Composition) can project it without recomputing names.

api/v1beta1/zz_generated.deepcopy.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

charts/valkey-operator/templates/crd/cache.wellcake.io_valkeyclusters.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5089,6 +5089,18 @@ spec:
50895089
prematurely failed over. Cleared once a master is reachable again.
50905090
format: date-time
50915091
type: string
5092+
quorumDownSince:
5093+
description: |-
5094+
QuorumDownSince is when the operator first observed the Cluster topology
5095+
stuck in cluster_state:fail with a majority of primaries unreachable —
5096+
the state gossip cannot self-heal, because authorizing a replica failover
5097+
needs a master quorum that no longer exists. It debounces operator-driven
5098+
quorum recovery (CLUSTER FAILOVER TAKEOVER): the operator only intervenes
5099+
after the cluster has been stuck for the recovery threshold, giving gossip
5100+
time to promote replicas on its own where a quorum still exists. Cleared
5101+
once the cluster reports cluster_state:ok again.
5102+
format: date-time
5103+
type: string
50925104
readyReplicas:
50935105
description: ReadyReplicas is the count of ready Valkey pods.
50945106
format: int32

config/crd/bases/cache.wellcake.io_valkeyclusters.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5085,6 +5085,18 @@ spec:
50855085
prematurely failed over. Cleared once a master is reachable again.
50865086
format: date-time
50875087
type: string
5088+
quorumDownSince:
5089+
description: |-
5090+
QuorumDownSince is when the operator first observed the Cluster topology
5091+
stuck in cluster_state:fail with a majority of primaries unreachable —
5092+
the state gossip cannot self-heal, because authorizing a replica failover
5093+
needs a master quorum that no longer exists. It debounces operator-driven
5094+
quorum recovery (CLUSTER FAILOVER TAKEOVER): the operator only intervenes
5095+
after the cluster has been stuck for the recovery threshold, giving gossip
5096+
time to promote replicas on its own where a quorum still exists. Cleared
5097+
once the cluster reports cluster_state:ok again.
5098+
format: date-time
5099+
type: string
50885100
readyReplicas:
50895101
description: ReadyReplicas is the count of ready Valkey pods.
50905102
format: int32
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# ADR 0006 — Recovery from a lost primary majority (Cluster topology)
2+
3+
- Status: **Implemented** for **Cluster** topology. Automatic for the **Cache**
4+
profile; **opt-in for Durable** via the per-cluster annotation
5+
`valkey.wellcake.io/quorum-takeover: "true"` (a forced takeover can drop
6+
acknowledged writes, which the Durable profile promises to keep). Validated
7+
live on k3d: a 3-shard cluster, majority of primaries fenced, recovered to
8+
`cluster_state:ok` with **0 data loss** and the fenced primaries rejoining as
9+
replicas. See "Validation" below.
10+
- Date: 2026-08-04
11+
- Context: ADR 0004 handles *planned* restarts and *single* primary loss (a
12+
replica is promoted, reactively or proactively). It does not cover the
13+
disaster where a Cluster loses the **majority of its primaries at once** — an
14+
AZ/node-group outage, a bad drain, a correlated crash.
15+
16+
## Context
17+
18+
Valkey Cluster fails a primary over by **vote**: a replica asking to take its
19+
dead primary's slots needs acknowledgement from a majority of the *masters*.
20+
That is exactly what a majority-primary loss removes. With `N` primaries and more
21+
than `floor(N/2)` of them down, the survivors are not a quorum, so **no replica
22+
can be voted in**. The cluster sits in `cluster_state:fail` with unserved slots
23+
**indefinitely** — the surviving replicas of the dead primaries are healthy and
24+
current, but gossip will never promote them. This is a genuine SPOF for the
25+
Cluster topology (the AR1 gap): the data is there, but nothing brings it back.
26+
27+
The one Valkey primitive that breaks the deadlock is `CLUSTER FAILOVER TAKEOVER`:
28+
a replica promotes itself **unilaterally**, bumping its `configEpoch` above every
29+
peer and claiming its primary's slots **without a vote**. It is powerful and
30+
correspondingly dangerous — run it while the old primary is still serving and you
31+
get two owners for the same slots (split-brain). Gossip therefore never issues it
32+
on its own; a human (or an operator) must decide the old primary is really gone.
33+
34+
An operator is well placed to make that call, because it sees something gossip
35+
cannot: **pod and node liveness through the k8s API**, plus a direct out-of-band
36+
connection to every node.
37+
38+
## Decision
39+
40+
Add an operator-driven recovery path (`maybeRecoverClusterQuorum`, ahead of the
41+
allReady-gated scale/survey steps so it runs *during* the outage). It intervenes
42+
only when **all** of the following hold:
43+
44+
1. **Enabled for this cluster** — Cache by default; Durable only with the opt-in
45+
annotation. Availability-first vs durability-first is a per-workload choice.
46+
2. **Below a voting quorum** — from `CLUSTER NODES`, `healthyMasters*2 <=
47+
totalMasters`. With a quorum intact, gossip can (and should) heal on its own,
48+
or deliberately refuse under the Durable replica-validity factor; the operator
49+
must not race or overrule it. A minority of primaries down is left alone.
50+
3. **Stuck for the debounce**`cluster_state != ok` continuously for
51+
`quorumRecoveryDownAfter` (45s). Gossip promotes a recoverable failure within
52+
seconds; only an unrecoverable one stays stuck this long.
53+
4. **Each dead primary is fenced from two independent perspectives:**
54+
- **k8s view** — the pod is missing, not Running, not Ready, or on a NotReady/
55+
absent node; AND
56+
- **data-path view** — the operator cannot reach it, or reaches it and it
57+
reports `cluster_state:fail` (it has detected its own minority isolation).
58+
A primary reachable **and** reporting `cluster_state:ok` is genuinely
59+
serving and is **never** taken over.
60+
61+
For each such shard the operator deletes the dead primary's pod (fencing it and
62+
letting the StatefulSet re-create it) and issues `CLUSTER FAILOVER TAKEOVER` on
63+
the shard's most up-to-date reachable replica. The re-created old primary rejoins
64+
through its retained data PVC, sees the higher `configEpoch`, and demotes itself
65+
to a replica — the cluster self-heals back to full redundancy.
66+
67+
### Why this is not split-brain (the honest argument)
68+
69+
Write safety does **not** rest on proving the old primary's process is dead — no
70+
k8s operator can do that without STONITH, and on a control-plane partition a
71+
pod on a NotReady node may still be running. It rests on a guarantee Valkey
72+
Cluster already makes: **a primary partitioned from the majority of masters for
73+
longer than `cluster-node-timeout` stops accepting writes** (it returns
74+
`CLUSTERDOWN`, because a replica on the majority side may have been promoted).
75+
The debounce (45s) is an order of magnitude above the operator's node-timeout
76+
(5s), so by the time a takeover fires, any still-running-but-isolated old primary
77+
has already stopped serving writes. The k8s fence and the data-path check are
78+
defence in depth on top of that: they decide *when* to intervene and refuse to
79+
overrule a primary that is demonstrably still serving.
80+
81+
Residual, accepted limits: a minority primary may still answer **stale reads**
82+
until it rejoins (the Cache profile, the only one that recovers automatically,
83+
accepts this AP trade-off); and a pathological *asymmetric* partition is outside
84+
what any voteless takeover can fully rule out. Durable clusters opt in
85+
deliberately, or stay stuck for a human to resolve.
86+
87+
## Consequences
88+
89+
- The Cluster topology recovers automatically from a lost primary majority
90+
(Cache), closing the AR1 SPOF, with a clear, auditable trail (structured logs
91+
+ the `failover_total{reason="cluster-takeover"}` metric + the
92+
`status.quorumDownSince` debounce marker).
93+
- Durable clusters keep their no-silent-loss contract by default; recovering
94+
them is a conscious opt-in.
95+
- A shard that lost **every** pod (primary and all replicas) is *not* recovered
96+
by takeover — there is nothing to promote. That is a restore, out of scope
97+
here, and is logged as such.
98+
- The feature is Cluster-only; Replication/Sentinel already fail over reactively
99+
(ADR 0004) and have no quorum to lose.
100+
101+
## Validation
102+
103+
Live on a dedicated k3d cluster (3 shards, 1 replica each, Cache profile,
104+
Valkey 8.0):
105+
106+
1. Loaded 500 keys; cordoned all nodes; force-deleted 2 of 3 primary pods so they
107+
stayed Pending (fenced) while their replicas kept running — a true majority
108+
loss (`cluster_state:fail`, `healthy=1/3`).
109+
2. The operator armed the debounce (`quorum lost and recoverable; starting
110+
recovery debounce`), waited 45s, then issued `CLUSTER FAILOVER TAKEOVER` on
111+
both surviving replicas.
112+
3. Result: `cluster_state:ok`, all 16384 slots served, `cluster_size:3`, **500/500
113+
keys intact**. The takeover replicas carried `configEpoch` bumped above the
114+
fenced primaries.
115+
4. Uncordoned: the two fenced primaries rescheduled (retained PVC) and rejoined
116+
as **replicas** of the new primaries — full redundancy restored, no operator
117+
action needed. `status.quorumDownSince` cleared on `cluster_state:ok`.

docs/runbook.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,41 @@ The operator does not intervene. The Sentinel quorum elects a new primary;
388388
clients learn it via `SENTINEL get-master-addr-by-name`. The operator only
389389
maintains the StatefulSets, ConfigMaps, and Services.
390390

391+
## Cluster: recovering from a lost primary majority (ADR 0006)
392+
393+
A Cluster fails a single primary over by gossip vote, which needs a **majority of
394+
masters**. If it loses more than half its primaries at once (an AZ/node-group
395+
outage, a bad drain), the survivors are not a quorum and gossip **cannot** promote
396+
the healthy, current replicas of the dead primaries — the cluster stays in
397+
`cluster_state:fail` with unserved slots indefinitely.
398+
399+
The operator recovers this automatically for the **Cache** profile. It only acts
400+
once the survivors are provably below a quorum, the cluster has been stuck for a
401+
45s debounce (so it never races a failover gossip could still do), and each dead
402+
primary is fenced from both the k8s API (pod/node not serving) and a direct
403+
data-path check. It then issues `CLUSTER FAILOVER TAKEOVER` on the best surviving
404+
replica of each shard. The re-created primaries rejoin as replicas via their
405+
retained PVCs — no manual step.
406+
407+
```sh
408+
# watch it happen
409+
kubectl -n <ns> get valkeycluster <cluster> -o jsonpath='{.status.quorumDownSince}' # set while stuck, cleared on recovery
410+
kubectl -n valkey-operator-system logs deploy/valkey-operator-controller-manager | grep 'quorum recovery'
411+
# metric: failover_total{reason="cluster-takeover"}
412+
```
413+
414+
**Durable profile:** recovery is **off by default**, because a forced takeover can
415+
promote a slightly-behind replica and drop acknowledged writes — the opposite of
416+
the Durable contract. A stuck Durable cluster waits for a human. Opt in only when
417+
you accept that trade-off:
418+
419+
```sh
420+
kubectl -n <ns> annotate valkeycluster <cluster> valkey.wellcake.io/quorum-takeover=true
421+
```
422+
423+
A shard that lost **every** pod (primary and all its replicas) is not recovered by
424+
takeover — there is nothing to promote. Restore it from a backup instead.
425+
391426
## Cluster per-shard workload (ADR 0005, experimental)
392427

393428
With `spec.perShardWorkload: true`, a Cluster is rendered as **one

internal/controller/cluster.go

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -57,39 +57,23 @@ func (r *ValkeyClusterReconciler) reconcileCluster(ctx context.Context, vc *cach
5757
}
5858
}
5959

60-
// Scale-up: if the spec asks for more replicas than the cluster currently
61-
// knows about (Status.LastAppliedReplicas), and all desired pods are
62-
// already Ready, run a one-shot add-node Job. Slot rebalance is gated
63-
// by spec.autoReshard.
64-
if vc.Status.ClusterInitialized && allReady && vc.Status.LastAppliedReplicas < want {
65-
result, err := r.runClusterScaleUp(ctx, vc, password)
66-
if err != nil || !result.IsZero() {
67-
return result, err
68-
}
69-
}
70-
71-
// Scale-down: if the spec wants fewer replicas than the cluster currently
72-
// has (Status.LastAppliedReplicas), run the scale-down Job that reshards
73-
// slots away from the leaving masters and del-nodes them. The StatefulSet
74-
// is held at the old size by statefulSetReplicas() until this Job
75-
// succeeds — otherwise pods owning slots would be deleted and lose data.
76-
if vc.Status.ClusterInitialized && vc.Status.LastAppliedReplicas > want {
77-
result, err := r.runClusterScaleDown(ctx, vc, password)
78-
if err != nil || !result.IsZero() {
79-
return result, err
80-
}
60+
// Quorum recovery (closes the Cluster-topology SPOF of AR1): a Cluster that
61+
// has lost the majority of its primaries is stuck in cluster_state:fail —
62+
// gossip cannot vote a failover without a master quorum. The operator detects
63+
// it and drives CLUSTER FAILOVER TAKEOVER on surviving replicas after fencing
64+
// the dead primaries via the k8s API. Automatic for Cache; opt-in for Durable
65+
// (a forced takeover can drop acknowledged writes). This must run even when
66+
// not all pods are Ready — an unready majority is exactly the outage it repairs
67+
// — so it sits ahead of the allReady-gated scale/reshard/survey steps below.
68+
if res, handled, rerr := r.maybeRecoverClusterQuorum(ctx, vc, password); handled {
69+
return res, rerr
8170
}
8271

83-
// Manual reshard request (valkey.wellcake.io/reshard): run a one-off rebalance
84-
// once per distinct token. Only when the cluster is initialized and all
85-
// pods are Ready so the rebalance sees a stable membership.
86-
if vc.Status.ClusterInitialized && allReady {
87-
if tok := vc.Annotations[reshardAnnotation]; tok != "" && tok != vc.Status.LastReshardToken {
88-
result, err := r.runClusterReshard(ctx, vc, password, tok)
89-
if err != nil || !result.IsZero() {
90-
return result, err
91-
}
92-
}
72+
// One-shot membership operations (scale-up add-node, scale-down reshard-away,
73+
// manual reshard): each runs a Job and short-circuits the reconcile while in
74+
// flight. Extracted to keep this function under the complexity budget.
75+
if res, handled, rerr := r.maybeReconcileClusterScaling(ctx, vc, password, want, allReady); handled {
76+
return res, rerr
9377
}
9478

9579
// Proactive rolling restart (ADR 0004): when opted in and the cluster is in
@@ -137,6 +121,43 @@ func (r *ValkeyClusterReconciler) maybeDriveClusterRollout(
137121
return ctrl.Result{}, false, nil
138122
}
139123

124+
// maybeReconcileClusterScaling advances the one-shot membership operations —
125+
// scale-up (add-node), scale-down (reshard slots away + del-node) and a manual
126+
// reshard request — each of which runs a Job and short-circuits the reconcile
127+
// while in flight. Returns handled=true (with the Job's result) when one is in
128+
// progress or just started; handled=false when there is nothing to do and normal
129+
// reconciliation should continue. Scale-up and reshard require a stable, all-Ready
130+
// membership; scale-down is gated on the replica count alone because
131+
// statefulSetReplicas() holds the STS at its old size until the reshard completes.
132+
func (r *ValkeyClusterReconciler) maybeReconcileClusterScaling(
133+
ctx context.Context, vc *cachev1beta1.ValkeyCluster, password string, want int32, allReady bool,
134+
) (ctrl.Result, bool, error) {
135+
if !vc.Status.ClusterInitialized {
136+
return ctrl.Result{}, false, nil
137+
}
138+
if allReady && vc.Status.LastAppliedReplicas < want {
139+
result, err := r.runClusterScaleUp(ctx, vc, password)
140+
if err != nil || !result.IsZero() {
141+
return result, true, err
142+
}
143+
}
144+
if vc.Status.LastAppliedReplicas > want {
145+
result, err := r.runClusterScaleDown(ctx, vc, password)
146+
if err != nil || !result.IsZero() {
147+
return result, true, err
148+
}
149+
}
150+
if allReady {
151+
if tok := vc.Annotations[reshardAnnotation]; tok != "" && tok != vc.Status.LastReshardToken {
152+
result, err := r.runClusterReshard(ctx, vc, password, tok)
153+
if err != nil || !result.IsZero() {
154+
return result, true, err
155+
}
156+
}
157+
}
158+
return ctrl.Result{}, false, nil
159+
}
160+
140161
// ensureClusterWorkload reconciles the static prerequisites for a Cluster-topology
141162
// object — password Secret, headless+client Services, ConfigMap, StatefulSet, PDB
142163
// and NetworkPolicy — and returns the resolved password plus the live StatefulSet.

0 commit comments

Comments
 (0)