diff --git a/internal/controller/cluster_helpers_test.go b/internal/controller/cluster_helpers_test.go index b2aaa9b..3537eaa 100644 --- a/internal/controller/cluster_helpers_test.go +++ b/internal/controller/cluster_helpers_test.go @@ -261,8 +261,13 @@ func TestDecidePhase_matrix(t *testing.T) { vc.Status.Version = status return vc } + // 기본적으로 slots_ok == slots_assigned (healthy). partial-outage 케이스는 + // mkInfoOK 로 별도 구성. mkInfo := func(state string, slots int32) *vk.ClusterInfo { - return &vk.ClusterInfo{State: state, SlotsAssigned: slots} + return &vk.ClusterInfo{State: state, SlotsAssigned: slots, SlotsOK: slots} + } + mkInfoOK := func(state string, slots, slotsOK int32) *vk.ClusterInfo { + return &vk.ClusterInfo{State: state, SlotsAssigned: slots, SlotsOK: slotsOK} } cases := []struct { name string @@ -278,6 +283,8 @@ func TestDecidePhase_matrix(t *testing.T) { {"sts ready, cluster not ok", mkVC("8.1.6", "8.1.6"), 6, 6, mkInfo("fail", 0), cachev1alpha1.ClusterPhaseInitializing}, {"resharding", mkVC("8.1.6", "8.1.6"), 6, 6, mkInfo("ok", 8192), cachev1alpha1.ClusterPhaseResharding}, {"running", mkVC("8.1.6", "8.1.6"), 6, 6, mkInfo("ok", 16384), cachev1alpha1.ClusterPhaseRunning}, + // 결함 ⑤: state=ok + assigned=16384 이지만 slots_ok<16384 → Running 아님(Resharding cadence). + {"partial-slot outage", mkVC("8.1.6", "8.1.6"), 6, 6, mkInfoOK("ok", 16384, 10922), cachev1alpha1.ClusterPhaseResharding}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/controller/cluster_stuckslots.go b/internal/controller/cluster_stuckslots.go new file mode 100644 index 0000000..a41ebf6 --- /dev/null +++ b/internal/controller/cluster_stuckslots.go @@ -0,0 +1,122 @@ +/* +Copyright 2026 Keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/log" + + commonsevents "github.com/keiailab/keiailab-commons/pkg/events" + cachev1alpha1 "github.com/keiailab/valkey-operator/api/v1alpha1" + "github.com/keiailab/valkey-operator/internal/observability" + vk "github.com/keiailab/valkey-operator/internal/valkey" +) + +// 결함 ⑤ — partial-slot outage 자가복구. +// +// 증상: node churn 후 cluster_state:ok + slots_assigned:16384 이지만 slots_ok 가 +// 16384 미만 — 한 shard 의 slot 이 `fail` flag 가 붙은 master 소유로 남아 그만큼의 +// 키스페이스가 DOWN. 기존 health gate (cluster_state / slots_assigned) 는 이를 +// "정상" 으로 오판해 self-heal 이 멈췄다. +// +// 본 path 는: +// 1. vk.IsClusterDegraded: slots_ok<16384 또는 fail master 가 slot 소유 시 degraded. +// 2. vk.DetectStuckSlotHeals: 각 stuck master 마다 takeover 가능한 healthy replica 를 +// 선정 (순수 함수, 단위테스트). +// 3. healStuckSlots: 선정된 replica 에 CLUSTER FAILOVER TAKEOVER 발행 → slot 소유권이 +// replica 로 승계되어 slot 이 ok 로 복귀. +// +// 보수성 / 멱등: +// - `fail` flag 는 Valkey 가 node-timeout 경과 + gossip 합의 후에만 설정한다. 따라서 +// flag 의 존재 자체가 "node-timeout 만큼 기다렸다" 는 신호 — converging cluster 를 +// thrash 하지 않는다 (pfail/fail? 는 takeover 대상으로 보지 않는다). +// - takeover 후 master 가 다시 healthy 가 되면 stuck 조건이 사라져 자동 no-op. +// - healthy replica 가 없는 stuck master 는 TAKEOVER 하지 않고 (데이터 보존) 경고만. + +// reconcileStuckSlots — partial-slot outage 게이트 + 복구. allReady && cluster 도달 +// 가능한 상태에서 호출. 반환값은 발행한 takeover 수 (0 이면 no-op). +func (r *ValkeyClusterReconciler) reconcileStuckSlots( + ctx context.Context, vc *cachev1alpha1.ValkeyCluster, + password string, info *vk.ClusterInfo, nodes []vk.NodeView, +) (int, error) { + if info == nil || len(nodes) == 0 { + return 0, nil + } + if !vk.IsClusterDegraded(info, nodes) { + return 0, nil + } + + logger := log.FromContext(ctx) + heals := vk.DetectStuckSlotHeals(nodes) + if len(heals) == 0 { + // degraded 이지만 안전한 takeover 후보가 없다 (예: fail master 에 healthy + // replica 부재, 또는 slots_ok 저하가 pfail 같은 transient 상태). 이번 reconcile + // 에서는 행동하지 않고 가시화만 — 다음 reconcile 에서 gossip 수렴 또는 멤버십 + // 자가복구(결함 ③)가 replica 를 재합류시킨 뒤 재평가한다. + logger.Info("ValkeyCluster degraded (partial-slot outage) but no safe takeover candidate", + "slotsOK", info.SlotsOK, "slotsAssigned", info.SlotsAssigned, "state", info.State) + commonsevents.EmitWarningf(r.Recorder, vc, "PartialSlotOutage", + "slots_ok=%d/%d — fail master 의 slot 이 stuck 이나 takeover 가능한 healthy replica 없음", + info.SlotsOK, vk.ClusterTotalSlots) + return 0, nil + } + + logger.Info("ValkeyCluster partial-slot outage detected; healing via TAKEOVER", + "slotsOK", info.SlotsOK, "stuckShards", len(heals)) + + return r.healStuckSlots(ctx, vc, password, heals) +} + +// healStuckSlots — 각 heal 계획의 replica 에 CLUSTER FAILOVER TAKEOVER 발행. +// +// TAKEOVER 는 합의 없이 replica 가 slot + epoch 를 승계 — master 가 이미 fail 일 때만 +// 안전하며 (DetectStuckSlotHeals 가 그것을 보장), split-brain 위험 때문에 그 외에는 +// 호출하지 않는다. +func (r *ValkeyClusterReconciler) healStuckSlots( + ctx context.Context, vc *cachev1alpha1.ValkeyCluster, + password string, heals []vk.StuckSlotHeal, +) (int, error) { + ctx, span := observability.StartCallSpan(ctx, "ValkeyCluster/HealStuckSlots") + defer span.End() + logger := log.FromContext(ctx) + + tlsCfg, err := r.tlsConfigForCluster(ctx, vc) + if err != nil { + return 0, fmt.Errorf("tls config: %w", err) + } + + var done int + var firstErr error + for _, h := range heals { + c := dialPod(h.TakeoverReplicaAddr, password, tlsCfg) + takeoverCtx, takeoverSpan := observability.StartCallSpan(ctx, "ValkeyCluster/FailoverTakeover") + takeErr := vk.ClusterFailoverTakeover(takeoverCtx, c) + if takeErr != nil { + takeoverSpan.RecordError(takeErr) + } + takeoverSpan.End() + _ = c.Close() + if takeErr != nil { + logger.Error(takeErr, "CLUSTER FAILOVER TAKEOVER failed — will retry next reconcile", + "replica", h.TakeoverReplicaAddr, "failedMaster", h.FailedMasterAddr) + if firstErr == nil { + firstErr = takeErr + } + continue + } + done++ + MetricStuckSlotTakeoverTotal.WithLabelValues(vc.Namespace, vc.Name).Inc() + logger.Info("Stuck-slot takeover issued", + "replica", h.TakeoverReplicaAddr, "promotedFromFailedMaster", h.FailedMasterAddr) + commonsevents.Emitf(r.Recorder, vc, "StuckSlotTakeover", + "partial-slot outage 자가복구: replica %s 가 fail master %s 의 slot 승계 (CLUSTER FAILOVER TAKEOVER)", + h.TakeoverReplicaAddr, h.FailedMasterAddr) + } + return done, firstErr +} diff --git a/internal/controller/conditions_test.go b/internal/controller/conditions_test.go index afb901b..75b74aa 100644 --- a/internal/controller/conditions_test.go +++ b/internal/controller/conditions_test.go @@ -29,7 +29,7 @@ func TestApplyClusterConditions_running_state(t *testing.T) { vc.Generation = 5 vc.Status.Phase = cachev1alpha1.ClusterPhaseRunning conds := []metav1.Condition{} - info := &vk.ClusterInfo{State: "ok", SlotsAssigned: 16384} + info := &vk.ClusterInfo{State: "ok", SlotsAssigned: 16384, SlotsOK: 16384} applyClusterConditions(&conds, vc, info, 6, 6) @@ -96,6 +96,25 @@ func TestApplyClusterConditions_resharding(t *testing.T) { } } +// 결함 ⑤ — partial-slot outage 는 ClusterReady=False (Reason=PartialSlotOutage). +func TestApplyClusterConditions_partialSlotOutage(t *testing.T) { + vc := &cachev1alpha1.ValkeyCluster{} + vc.Status.Phase = cachev1alpha1.ClusterPhaseResharding + conds := []metav1.Condition{} + // state=ok + assigned=16384 이지만 slots_ok<16384. + info := &vk.ClusterInfo{State: "ok", SlotsAssigned: 16384, SlotsOK: 10922} + + applyClusterConditions(&conds, vc, info, 6, 6) + + c := findCondition(conds, CondTypeClusterReady) + if c.Status != metav1.ConditionFalse { + t.Errorf("ClusterReady during partial outage must be False: %v", c.Status) + } + if c.Reason != "PartialSlotOutage" { + t.Errorf("Reason: %q want PartialSlotOutage", c.Reason) + } +} + func TestApplyClusterConditions_scalePending(t *testing.T) { vc := &cachev1alpha1.ValkeyCluster{} vc.Status.Phase = cachev1alpha1.ClusterPhaseRunning @@ -106,7 +125,7 @@ func TestApplyClusterConditions_scalePending(t *testing.T) { } conds := []metav1.Condition{} - applyClusterConditions(&conds, vc, &vk.ClusterInfo{State: "ok", SlotsAssigned: 16384}, 6, 6) + applyClusterConditions(&conds, vc, &vk.ClusterInfo{State: "ok", SlotsAssigned: 16384, SlotsOK: 16384}, 6, 6) c := findCondition(conds, CondTypeScalePending) if c.Status != metav1.ConditionTrue { diff --git a/internal/controller/metrics.go b/internal/controller/metrics.go index f84e412..0d3633b 100644 --- a/internal/controller/metrics.go +++ b/internal/controller/metrics.go @@ -129,6 +129,18 @@ var ( labelNamespaceName, ) + // MetricStuckSlotTakeoverTotal — 결함 ⑤ partial-slot outage 자가복구로 발행한 + // CLUSTER FAILOVER TAKEOVER 카운터. fail master 의 slot 이 healthy replica 로 + // 승계될 때 (성공 분기) 증가. + MetricStuckSlotTakeoverTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Subsystem: metricSubsystem, + Name: "stuck_slot_takeover_total", + Help: "Total CLUSTER FAILOVER TAKEOVER events to heal partial-slot outages (Cluster mode)", + }, + labelNamespaceName, + ) + // MetricCapabilityActive — CR 의 활성 optional capability 추적 (PR #62 Status.Capabilities // 의 Prometheus 측 노출). fleet-wide 채택 추적 용: // sum by (capability) (valkey_cluster_capability_active) → namespace 별 채택 CR 수 @@ -169,6 +181,7 @@ func init() { MetricBackupTotal, MetricRestoreTotal, MetricFailoverTotal, + MetricStuckSlotTakeoverTotal, MetricCapabilityActive, MetricBuildInfo, ) diff --git a/internal/controller/valkeycluster_controller.go b/internal/controller/valkeycluster_controller.go index 845066a..c9dc363 100644 --- a/internal/controller/valkeycluster_controller.go +++ b/internal/controller/valkeycluster_controller.go @@ -397,6 +397,25 @@ func (r *ValkeyClusterReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } + // 10.6 partial-slot outage 자가복구 (결함 ⑤) — slot 레벨 health gate (cluster_state + // =ok && slots_assigned=16384) 를 통과해도 *slots_ok<16384 또는 fail master 가 + // slot 을 소유* 하면 키스페이스 일부가 DOWN 이다. node churn 후 fail master 의 + // slot 이 stuck 인 경우, 그 master 의 healthy replica 에 CLUSTER FAILOVER TAKEOVER + // 를 발행해 slot 소유권을 승계시킨다. 보수적: `fail` flag 는 node-timeout 경과 + // 후에만 설정되므로 converging cluster 를 thrash 하지 않으며, 멱등(복구되면 no-op). + if allReady && info != nil { + if healed, hErr := r.reconcileStuckSlots(ctx, vc, password, info, nodes); hErr != nil { + logger.Error(hErr, "Partial-slot outage heal pending — will retry") + } else if healed > 0 { + logger.Info("Healed partial-slot outage via takeover", "count", healed) + // takeover 직후 NODES/INFO 를 다시 읽어 status 정확도를 높인다 (slot 소유권 + // 승계가 막 일어났다). + if i2, n2, e2 := r.pollClusterState(ctx, vc, password); e2 == nil && i2 != nil { + info, nodes = i2, n2 + } + } + } + // 11. Shard status 빌드 + metrics 갱신. // ADR-0004 후속: NODES 응답이 있으면 *실제 토폴로지* 기반 — failover 정확. // 없으면 spec 기반 fallback (부트스트랩 직후 / NODES 조회 실패 시). @@ -526,8 +545,11 @@ func applyClusterConditions( ObservedGeneration: vc.Generation, }) - // ClusterReady — cluster_state=ok && 16384 slot. - clusterReady := info != nil && info.State == "ok" && info.SlotsAssigned == 16384 + // ClusterReady — cluster_state=ok && 16384 slot assigned && 모든 slot 이 ok. + // 결함 ⑤: slots_assigned=16384 이지만 slots_ok<16384 (fail master 소유 slot) 인 + // partial-slot outage 를 Ready 로 오판하지 않도록 slots_ok 도 게이트에 포함. + clusterReady := info != nil && info.State == "ok" && + info.SlotsAssigned == vk.ClusterTotalSlots && info.SlotsOK == vk.ClusterTotalSlots clusterReason := "ClusterStateOK" clusterMsg := "" if !clusterReady { @@ -535,8 +557,12 @@ func applyClusterConditions( if info == nil { clusterMsg = "Cluster info not yet polled" } else { - clusterMsg = fmt.Sprintf("state=%s slots_assigned=%d ready_replicas=%d/%d", - info.State, info.SlotsAssigned, readyReplicas, totalReplicas) + if info.State == "ok" && info.SlotsAssigned == vk.ClusterTotalSlots && + info.SlotsOK < vk.ClusterTotalSlots { + clusterReason = "PartialSlotOutage" + } + clusterMsg = fmt.Sprintf("state=%s slots_assigned=%d slots_ok=%d ready_replicas=%d/%d", + info.State, info.SlotsAssigned, info.SlotsOK, readyReplicas, totalReplicas) } } setCondition(conds, metav1.Condition{ @@ -633,7 +659,12 @@ func decidePhase(vc *cachev1alpha1.ValkeyCluster, readyReplicas, totalReplicas i return cachev1alpha1.ClusterPhaseInitializing case info == nil || info.State != "ok": return cachev1alpha1.ClusterPhaseInitializing - case info.SlotsAssigned != 16384: + case info.SlotsAssigned != vk.ClusterTotalSlots: + return cachev1alpha1.ClusterPhaseResharding + case info.SlotsOK < vk.ClusterTotalSlots: + // 결함 ⑤ partial-slot outage: state=ok + slots_assigned=16384 이지만 + // 일부 slot 이 ok 가 아님 (fail master 소유). Running 으로 오판하지 않고 + // 빠른 requeue(Resharding cadence)로 self-heal(takeover)을 재시도시킨다. return cachev1alpha1.ClusterPhaseResharding default: return cachev1alpha1.ClusterPhaseRunning diff --git a/internal/valkey/replication.go b/internal/valkey/replication.go index c9380bd..a06f054 100644 --- a/internal/valkey/replication.go +++ b/internal/valkey/replication.go @@ -48,6 +48,22 @@ func PromoteToPrimary(ctx context.Context, c *redis.Client) error { return nil } +// ClusterFailoverTakeover — replica 에서 CLUSTER FAILOVER TAKEOVER 발행 (결함 ⑤). +// +// 일반 CLUSTER FAILOVER 는 master 와의 handshake (offset 동기 + 합의)를 요구하므로 +// master 가 *이미 fail* 인 partial-slot outage 에서는 동작하지 않는다. TAKEOVER 옵션은 +// 합의 없이 replica 가 즉시 slot 소유권 + epoch 를 승계해 stuck slot 을 ok 로 되돌린다. +// +// 주의: TAKEOVER 는 split-brain 위험이 있어 *master 가 확실히 fail* 일 때만 호출해야 +// 한다 (caller 의 보수적 게이트 책임). go-redis 의 ClusterFailover 는 옵션 인자를 +// 받지 않으므로 raw Do 로 발행한다. 본 명령은 replica 노드에 직접 보내야 한다. +func ClusterFailoverTakeover(ctx context.Context, c *redis.Client) error { + if err := c.Do(ctx, "CLUSTER", "FAILOVER", "TAKEOVER").Err(); err != nil { + return fmt.Errorf("cluster failover takeover: %w", err) + } + return nil +} + // ParseReplicationOffset — INFO replication 응답에서 master_repl_offset 또는 // slave_repl_offset 추출. ADR-0017 의 failover 후보 선출에 사용. // diff --git a/internal/valkey/stuckslots.go b/internal/valkey/stuckslots.go new file mode 100644 index 0000000..e6708a5 --- /dev/null +++ b/internal/valkey/stuckslots.go @@ -0,0 +1,138 @@ +/* +Copyright 2026 Keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ +package valkey + +// 결함 ⑤ — partial-slot outage 감지 (순수 로직). +// +// 증상 (실 incident): node churn 후 cluster_state:ok, cluster_slots_assigned:16384 +// 이지만 cluster_slots_ok:10922 — 한 shard(약 5462 slot)가 `fail` flag 노드 소유라 +// 키스페이스의 1/3 이 실제로 DOWN. 기존 health gate 는 cluster_state / slots_assigned +// 만 검사해 이 상태를 "정상"으로 오판하고 self-heal 이 동작하지 않았다. +// +// 본 파일은 CLUSTER INFO + CLUSTER NODES 스냅샷만으로 (1) cluster 가 degraded +// (heal 필요) 인지, (2) 어느 fail master 의 slot 이 stuck 이고 어느 healthy replica 가 +// takeover 대상인지를 판정한다 — gossip / 실 명령 없이 단위테스트 가능한 순수 함수. + +// ClusterTotalSlots — Valkey cluster 의 전체 slot 수 (도메인 [0,16383]). +const ClusterTotalSlots = 16384 + +// StuckSlotHeal — partial-slot outage 한 건의 heal 계획. +// +// FailedMaster: slot 을 여전히 소유한 채 `fail` flag 가 붙은 primary. +// TakeoverReplica: 해당 master 를 따르는 healthy replica 중 takeover 대상. +// 둘 다 NodeView 의 *복사본* — caller 는 Addr / ID 로 명령을 발행한다. +type StuckSlotHeal struct { + FailedMasterID string + FailedMasterAddr string + TakeoverReplicaID string + // TakeoverReplicaAddr — CLUSTER FAILOVER TAKEOVER 를 발행할 replica 주소. + TakeoverReplicaAddr string +} + +// IsClusterDegraded — health gate 강화 (결함 ⑤). 다음 중 하나라도 참이면 degraded. +// +// 1. info == nil — 상태 미상 (호출측에서 별도 처리하지만 보수적으로 false). +// 2. cluster_state != ok. +// 3. cluster_slots_ok < 16384 — 일부 slot 이 ok 가 아님 (pfail/fail 소유). +// 4. `fail` flag 가 붙은 master 가 여전히 slot 을 소유. +// +// 기존 게이트는 (2) 와 slots_assigned 만 봤다. (3)/(4) 가 본 결함의 핵심 — assigned +// 는 16384 인데 ok 가 16384 미만인 partial outage 를 잡는다. +func IsClusterDegraded(info *ClusterInfo, nodes []NodeView) bool { + if info == nil { + return false + } + if info.State != "ok" { + return true + } + if info.SlotsOK < ClusterTotalSlots { + return true + } + for i := range nodes { + if nodeOwnsSlotsWhileFailed(&nodes[i]) { + return true + } + } + return false +} + +// nodeOwnsSlotsWhileFailed — `master` + `fail` flag 인데 slot 을 여전히 소유. +// +// Valkey 는 master 가 fail 판정돼도 replica failover 가 일어나기 전까지 slot 소유권을 +// 유지한다. takeover 가 일어나지 않으면 이 상태가 영구히 stuck — 그 slot 은 DOWN. +func nodeOwnsSlotsWhileFailed(n *NodeView) bool { + return n.IsPrimary() && n.Flags["fail"] && len(n.Slots) > 0 +} + +// DetectStuckSlotHeals — slot 을 stuck 시킨 fail master 마다 takeover 가능한 healthy +// replica 를 1개 선정해 heal 계획을 만든다. 순수 함수. +// +// 선정 규칙: +// - master 가 `master` + `fail` flag 이고 slot 을 소유. +// - 그 master 를 따르는 replica 중 *healthy* (fail/pfail flag 없음 && link connected) +// 한 것을 takeover 대상으로. ID 사전순으로 안정 선택 (idempotent / 결정적). +// - healthy replica 가 없는 master 는 takeover 불가 → 계획에서 제외 (데이터 보존 +// 우선, 호출측이 별도 알림). stuck 이지만 promote 할 안전한 후보가 없는 케이스. +// +// 반환은 FailedMasterID 사전순 정렬 — 결정적 출력. +func DetectStuckSlotHeals(nodes []NodeView) []StuckSlotHeal { + // master id → replica NodeView 목록. + replicasByMaster := make(map[string][]*NodeView) + for i := range nodes { + n := &nodes[i] + if n.IsReplica() && n.MasterID != "" && n.MasterID != "-" { + replicasByMaster[n.MasterID] = append(replicasByMaster[n.MasterID], n) + } + } + + var heals []StuckSlotHeal + for i := range nodes { + m := &nodes[i] + if !nodeOwnsSlotsWhileFailed(m) { + continue + } + repl := pickHealthyReplica(replicasByMaster[m.ID]) + if repl == nil { + // stuck 이지만 안전한 takeover 후보 없음 — 제외. + continue + } + heals = append(heals, StuckSlotHeal{ + FailedMasterID: m.ID, + FailedMasterAddr: m.Addr, + TakeoverReplicaID: repl.ID, + TakeoverReplicaAddr: repl.Addr, + }) + } + sortHealsByMasterID(heals) + return heals +} + +// pickHealthyReplica — fail/pfail flag 가 없고 link 가 connected 인 replica 중 +// ID 사전순 최솟값. 없으면 nil. +func pickHealthyReplica(replicas []*NodeView) *NodeView { + var best *NodeView + for _, r := range replicas { + if r.Flags["fail"] || r.Flags["fail?"] || r.Flags["pfail"] { + continue + } + if !r.LinkOK { + continue + } + if best == nil || r.ID < best.ID { + best = r + } + } + return best +} + +// sortHealsByMasterID — 결정적 출력을 위한 단순 삽입정렬 (heal 건수는 shard 수 — 작다). +func sortHealsByMasterID(h []StuckSlotHeal) { + for i := 1; i < len(h); i++ { + for j := i; j > 0 && h[j].FailedMasterID < h[j-1].FailedMasterID; j-- { + h[j], h[j-1] = h[j-1], h[j] + } + } +} diff --git a/internal/valkey/stuckslots_test.go b/internal/valkey/stuckslots_test.go new file mode 100644 index 0000000..71f6ee0 --- /dev/null +++ b/internal/valkey/stuckslots_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2026 Keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ +package valkey + +import "testing" + +// 결함 ⑤ — partial-slot outage 감지 *순수 로직* 단위테스트. +// 실 Valkey gossip 없이, CLUSTER INFO/NODES 스냅샷만으로 (1) degraded 판정, +// (2) takeover 대상 선정 알고리즘의 회귀를 차단한다. + +// 실 incident snapshot: cluster_state:ok, slots_assigned:16384, slots_ok:10922. +// ccc shard 의 master 가 fail flag 인 채 slot [10922-16383] 을 여전히 소유. +const incidentNodes = `aaa11111111111111111111111111111111111111 10.0.0.1:6379@16379 myself,master - 0 0 1 connected 0-5460 +bbb22222222222222222222222222222222222222 10.0.0.2:6379@16379 master - 0 1700000000000 2 connected 5461-10921 +ccc33333333333333333333333333333333333333 10.0.0.3:6379@16379 master,fail - 0 1700000000000 3 connected 10922-16383 +ddd44444444444444444444444444444444444444 10.0.0.4:6379@16379 slave aaa11111111111111111111111111111111111111 0 1700000000000 1 connected +eee55555555555555555555555555555555555555 10.0.0.5:6379@16379 slave bbb22222222222222222222222222222222222222 0 1700000000000 2 connected +fff66666666666666666666666666666666666666 10.0.0.6:6379@16379 slave ccc33333333333333333333333333333333333333 0 1700000000000 3 connected +` + +func TestIsClusterDegraded_partialSlotOutage(t *testing.T) { + // state=ok, assigned=16384 이지만 slots_ok<16384 → degraded (결함 ⑤ 핵심). + info := &ClusterInfo{State: "ok", SlotsAssigned: 16384, SlotsOK: 10922} + nodes := parseClusterNodes(incidentNodes) + if !IsClusterDegraded(info, nodes) { + t.Fatalf("partial-slot outage must be degraded (slots_ok=%d)", info.SlotsOK) + } +} + +func TestIsClusterDegraded_failMasterOwnsSlots_evenIfSlotsOKFull(t *testing.T) { + // slots_ok 가 16384 로 보고돼도 fail master 가 slot 을 소유하면 degraded. + // (slots_ok 보고와 NODES flag 간 일시적 불일치 방어.) + info := &ClusterInfo{State: "ok", SlotsAssigned: 16384, SlotsOK: 16384} + nodes := parseClusterNodes(incidentNodes) + if !IsClusterDegraded(info, nodes) { + t.Fatalf("fail master owning slots must be degraded") + } +} + +func TestIsClusterDegraded_healthy_notDegraded(t *testing.T) { + info := &ClusterInfo{State: "ok", SlotsAssigned: 16384, SlotsOK: 16384} + nodes := parseClusterNodes(`aaa11111111111111111111111111111111111111 10.0.0.1:6379@16379 myself,master - 0 0 1 connected 0-5460 +bbb22222222222222222222222222222222222222 10.0.0.2:6379@16379 master - 0 1700000000000 2 connected 5461-10921 +ccc33333333333333333333333333333333333333 10.0.0.3:6379@16379 master - 0 1700000000000 3 connected 10922-16383 +`) + if IsClusterDegraded(info, nodes) { + t.Fatalf("fully healthy cluster must not be degraded") + } +} + +func TestIsClusterDegraded_stateFail(t *testing.T) { + info := &ClusterInfo{State: "fail", SlotsAssigned: 16384, SlotsOK: 16384} + if !IsClusterDegraded(info, nil) { + t.Fatalf("cluster_state=fail must be degraded") + } +} + +func TestIsClusterDegraded_nilInfo(t *testing.T) { + if IsClusterDegraded(nil, nil) { + t.Fatalf("nil info must be conservatively non-degraded (handled elsewhere)") + } +} + +func TestDetectStuckSlotHeals_incident_picksHealthyReplica(t *testing.T) { + nodes := parseClusterNodes(incidentNodes) + heals := DetectStuckSlotHeals(nodes) + if len(heals) != 1 { + t.Fatalf("want 1 heal (ccc shard), got %d: %+v", len(heals), heals) + } + h := heals[0] + if h.FailedMasterID != "ccc33333333333333333333333333333333333333" { + t.Errorf("failed master: %q", h.FailedMasterID) + } + if h.TakeoverReplicaID != "fff66666666666666666666666666666666666666" { + t.Errorf("takeover replica: %q", h.TakeoverReplicaID) + } + if h.TakeoverReplicaAddr != "10.0.0.6:6379" { + t.Errorf("takeover replica addr: %q", h.TakeoverReplicaAddr) + } +} + +func TestDetectStuckSlotHeals_noFailMaster_empty(t *testing.T) { + nodes := parseClusterNodes(`aaa 10.0.0.1:6379@16379 myself,master - 0 0 1 connected 0-8191 +bbb 10.0.0.2:6379@16379 master - 0 0 2 connected 8192-16383 +`) + if got := DetectStuckSlotHeals(nodes); len(got) != 0 { + t.Fatalf("no fail master → no heals, got %+v", got) + } +} + +func TestDetectStuckSlotHeals_failMasterNoHealthyReplica_excluded(t *testing.T) { + // fail master 의 유일한 replica 도 fail → 안전한 takeover 후보 없음 → 제외. + nodes := parseClusterNodes(`ccc 10.0.0.3:6379@16379 master,fail - 0 0 3 connected 10922-16383 +fff 10.0.0.6:6379@16379 slave,fail ccc 0 0 3 disconnected +`) + if got := DetectStuckSlotHeals(nodes); len(got) != 0 { + t.Fatalf("fail master with no healthy replica → excluded, got %+v", got) + } +} + +func TestDetectStuckSlotHeals_skipsUnhealthyReplica_picksHealthyOne(t *testing.T) { + // ccc 가 fail. replica 둘 중 하나(ggg)는 pfail, 다른 하나(fff)는 healthy. + nodes := parseClusterNodes(`ccc 10.0.0.3:6379@16379 master,fail - 0 0 3 connected 10922-16383 +ggg 10.0.0.7:6379@16379 slave,fail? ccc 0 0 3 connected +fff 10.0.0.6:6379@16379 slave ccc 0 0 3 connected +`) + heals := DetectStuckSlotHeals(nodes) + if len(heals) != 1 { + t.Fatalf("want 1 heal, got %d: %+v", len(heals), heals) + } + if heals[0].TakeoverReplicaID != "fff" { + t.Errorf("should pick healthy replica fff, got %q", heals[0].TakeoverReplicaID) + } +} + +func TestDetectStuckSlotHeals_failMasterNoSlots_excluded(t *testing.T) { + // fail master 이지만 slot 을 이미 잃음 (failover 일부 진행) → stuck 아님 → 제외. + nodes := parseClusterNodes(`ccc 10.0.0.3:6379@16379 master,fail - 0 0 3 connected +fff 10.0.0.6:6379@16379 slave ccc 0 0 3 connected +`) + if got := DetectStuckSlotHeals(nodes); len(got) != 0 { + t.Fatalf("fail master without slots is not stuck, got %+v", got) + } +} + +func TestDetectStuckSlotHeals_multipleStuckShards_deterministicOrder(t *testing.T) { + // 두 master 가 동시에 fail + slot 소유. 출력은 FailedMasterID 사전순. + nodes := parseClusterNodes(`zzz 10.0.0.9:6379@16379 master,fail - 0 0 9 connected 0-5460 +aaa 10.0.0.1:6379@16379 master,fail - 0 0 1 connected 5461-10921 +zr1 10.0.0.8:6379@16379 slave zzz 0 0 9 connected +ar1 10.0.0.7:6379@16379 slave aaa 0 0 1 connected +`) + heals := DetectStuckSlotHeals(nodes) + if len(heals) != 2 { + t.Fatalf("want 2 heals, got %d", len(heals)) + } + if heals[0].FailedMasterID != "aaa" || heals[1].FailedMasterID != "zzz" { + t.Errorf("heals not sorted by master id: %+v", heals) + } +}