Skip to content

fix: cluster modules + replicasPerShard=0 + replica re-integration#299

Merged
jongko54 merged 4 commits into
mainfrom
fix/cluster-modules-rps0-replica-reintegration
Jun 18, 2026
Merged

fix: cluster modules + replicasPerShard=0 + replica re-integration#299
jongko54 merged 4 commits into
mainfrom
fix/cluster-modules-rps0-replica-reintegration

Conversation

@jongko54

Copy link
Copy Markdown
Contributor

Three operator defects found during a production incident + sharding benchmark. Independent of (and complementary to) #298.

replicasPerShard: 0 is silently coerced to 1 (masters-only impossible)

A ValkeyCluster with spec.replicasPerShard: 0 came up with shards×2 pods. Root cause was not omitempty (the field already round-trips 0 through the apiserver) but two things in the v1alpha1 webhook:

  • the mutating defaulter coerced 0 → 1,
  • the validator rejected autoFailover=true && rps=0 as contradictory.

Fix: drop both. Masters-only is a valid topology (no-failover is its inherent tradeoff, not a contradiction). Kept int32 (no *int32 churn across ~42 call sites — the non-pointer field already persists an explicit 0). Tests added: defaulter preserves 0, validator accepts masters-only, TotalNodes/podAddresses correct at rps=0.

ValkeyClusterSpec has no modules field (no valkey-search on cluster)

Valkey supports spec.modules (official BSD presets — valkey-search/json/bloom, ADR-0032) but ValkeyCluster rejected it (unknown field), so vector/secondary search could only run on a single node, never sharded.
Fix: add Modules []ModuleSpec to ValkeyClusterSpec (v1alpha1 + v1alpha2, mirroring ValkeySpec), wire it into the cluster STSParams so BuildModuleInitContainers + --loadmodule apply to cluster pods, and add the same official-preset validation to the ValkeyCluster webhook (rejects external Redis Stack modules). CRD + DeepCopy regenerated; chart CRD synced; JSON byte-copy conversion round-trips (test added).

③ Operator does not re-integrate a replica that fell out of the cluster

The slot-level health gate (cluster_state:ok) ignored membership, so a node that lost cluster membership (e.g. new node id after restart) was never re-added — the cluster ran below replicasPerShard indefinitely.
Fix (internal/controller/cluster_reintegrate.go): pure detectReintegration/buildObservedMembers compute which pods are missing from CLUSTER NODES and which master each replica should follow (primaries before replicas); live ensureClusterMembership runs CLUSTER MEET <current pod IP> + CLUSTER REPLICATE <master id>, gated on allReady && cluster_state==ok && rps>0, idempotent. New MeetNode/ReplicateTo reuse existing retry/IP-resolve helpers; IP-based MEET aligns with #298's announce-ip.

Verified vs needs e2e: decision logic is fully unit-tested (9 cases). The live MEET/REPLICATE sequence cannot be exercised in envtest (no real Valkey gossip) and needs real-cluster e2e to confirm gossip convergence and post-failover re-pointing. Implemented per existing patterns, not faked.

Tests

go build ./... clean; make test green except the pre-existing unrelated TestChartIconURLUsesCurrentValkeyAsset (fails identically on main). 16 files, +883/-37.

🤖 Generated with Claude Code

jongko54 and others added 4 commits June 18, 2026 17:03
Defect ④ (replicasPerShard=0 / masters-only topology):
The ValkeyCluster mutating webhook clobbered an explicit replicasPerShard=0
to 1, and the validator rejected autoFailover=true + rps=0, making a
masters-only topology impossible. The field is json:"replicasPerShard"
(no omitempty) with CRD +kubebuilder:default=1, so the apiserver already
honors an explicit 0 (defaults apply only to absent fields). Removed the
webhook 0→1 coercion and the autoFailover+rps=0 rejection — an unset field
still defaults to 1, an explicit 0 is preserved (shards masters, 0 replicas).

Defect ⑥ (cluster modules):
Added Modules []ModuleSpec to ValkeyClusterSpec (v1alpha1 + v1alpha2),
mirroring ValkeySpec exactly. Wired vc.Spec.Modules into the cluster
STSParams so module init-containers + --loadmodule apply to cluster pods,
and added the same official-preset / external-Redis-Stack validation
(validateModules) to the ValkeyCluster validating webhook. Regenerated CRDs
and DeepCopy. Conversion is JSON byte-copy so modules round-trip
automatically (test added).

Defect ③ (replica re-integration):
The health gate passed at the slot level (cluster_state:ok) and never
checked membership, so a node that fell out of CLUSTER NODES after a
restart (new node id / lost nodes.conf) was never re-added. Added
ensureClusterMembership: compares CLUSTER NODES against the desired
topology, then CLUSTER MEET (current pod IP) + CLUSTER REPLICATE the
correct shard primary for any drifted/missing member. Idempotent — healthy
members are skipped, primaries are met before their replicas. Reuses the
existing valkey client helpers (new exported MeetNode / ReplicateTo wrap
resolveAddrIP + replicateWithRetry). The pure decision logic
(detectReintegration / buildObservedMembers) is unit-tested; the live
MEET/REPLICATE path needs real-cluster e2e validation (no gossip in
envtest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a node re-joins with a new node id (e.g. after CLUSTER RESET HARD),
its old/dead id lingers in the gossip table as a fail,noaddr (or
slave,fail,noaddr) ghost, inflating cluster_known_nodes until Valkey's
slow auto-eviction. The re-integration reconcile path now also detects
such ghosts and CLUSTER FORGETs them on every healthy member.

detectStaleNodes (pure) selects only clearly-dead ghosts: nodes flagged
fail AND (noaddr OR an addr not backed by any current expected pod). It
never selects myself, a current-pod-backed member (even if transiently
failing), or handshake nodes (still converging — left to Valkey's own
timeout). forgetStaleNodes mirrors the scale-in/teardown forget pattern,
issuing CLUSTER FORGET from all current members for gossip propagation,
with a live-id guard so a real member can never be forgotten.

Gated inside ensureClusterMembership (allReady && cluster_state=ok &&
replicasPerShard>0) so it cannot race bootstrap. Best-effort: forget
failures don't block re-integration and retry next reconcile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(defect ④)

A ValkeyCluster with spec.replicasPerShard: 0 (masters-only topology) was
silently coerced to 1. The field was a non-pointer int32 with CRD marker
+kubebuilder:default=1; with a non-pointer int the apiserver cannot tell
"omitted" from "explicit 0", so the default defeated an explicit 0.

Fix: make ReplicasPerShard *int32 (v1alpha1 + v1alpha2), drop the CRD
default=1 marker, and move nil→1 defaulting into code:
  - GetReplicasPerShard() accessor: nil → 1, explicit value (incl. 0) → as-is.
  - TotalNodes() and all controller reads route through the accessor.
  - Mutating webhook defaults nil→1 so stored objects carry an explicit value,
    while never touching an explicit 0 (masters-only preserved).

JSON byte-copy conversion stays correct: pointer + omitempty makes nil↔nil
and explicit-0↔explicit-0 round-trip exactly. Regenerated CRDs (no more
default:1, replicasPerShard no longer required) + DeepCopy (controller-gen).
Chart CRD copy re-synced (TestCRDBaseChartSync green). k8s.io/utils promoted
to a direct dependency.

Tests prove: nil → GetReplicasPerShard()==1 and TotalNodes uses 1; explicit 0
→ ==0 and TotalNodes==shards (masters-only); explicit 2 → 2; webhook defaults
nil→1 and preserves explicit 0; conversion round-trips both nil and explicit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…raction)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jongko54 jongko54 merged commit 1527fcd into main Jun 18, 2026
13 of 17 checks passed
jongko54 added a commit that referenced this pull request Jun 18, 2026
# Conflicts:
#	internal/controller/valkeycluster_controller.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant