Skip to content

fix: sentinel network partition failover - #91

Open
mvasilenko wants to merge 15 commits into
chideat:mainfrom
mvasilenko:fix/sentinel-network-partition-failover
Open

fix: sentinel network partition failover#91
mvasilenko wants to merge 15 commits into
chideat:mainfrom
mvasilenko:fix/sentinel-network-partition-failover

Conversation

@mvasilenko

Copy link
Copy Markdown

No description provided.

mvasilenko and others added 6 commits April 17, 2026 12:55
Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Assisted-by: claude-code/claude-opus-4-6
* fix: skip unreachable nodes during network partition instead of aborting

Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Assisted-by: claude-code/claude-opus-4-6

* chore: update Helm chart appVersion to v0.1.0 [skip ci]

---------

Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Assisted-by: claude-code/claude-opus-4-6
…ailover' into fix/sentinel-network-partition-failover
@chideat
chideat marked this pull request as ready for review April 18, 2026 23:54
Copilot AI review requested due to automatic review settings April 18, 2026 23:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes failover behavior during network partitions by avoiding unnecessary healing actions when Sentinels or Valkey pods are temporarily unreachable.

Changes:

  • Sentinel master discovery now skips unreachable Sentinel nodes and relies on quorum/majority reporting.
  • Failover rule engine health checks now use RawNodes() to distinguish unreachable pods from misconfiguration (reducing false HealMonitor/HealPod triggers).
  • Adds unit tests covering network-partition scenarios and index-mismatch healing behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
internal/valkey/failover/monitor/sentinel_monitor.go Avoids failing master discovery on a single unreachable Sentinel; continues to allow quorum decision.
internal/ops/failover/engine.go Adjusts node health logic to avoid fighting Sentinel failover when pods are unreachable and refines index-mismatch behavior.
internal/ops/failover/engine_test.go Adds tests for partition/unreachable-pod scenarios to validate the updated health-check logic.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/ops/failover/engine.go Outdated
Comment on lines +312 to +316
} else if len(rawPods) > len(inst.Nodes()) {
// Unreachable pod != dead pod — healing would fight Sentinel's failover.
logger.Info("master not in connected nodes but unreachable pods exist, allowing label update",
"master", monitorMaster.Address())
return nil

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The len(rawPods) > len(inst.Nodes()) check is too broad: it skips healing whenever any pod failed to materialize as a ValkeyNode, even if the Sentinel-reported master address doesn't correspond to the missing/unreachable pod. This can mask genuine master-address inconsistencies (e.g., Sentinel points to an IP that isn't any pod) as long as some other pod is unreachable. Consider narrowing the condition by first matching monitorMaster.Address() to a specific pod in rawPods and only skipping heal if that specific pod exists but is not present/connectable in inst.Nodes().

Suggested change
} else if len(rawPods) > len(inst.Nodes()) {
// Unreachable pod != dead pod — healing would fight Sentinel's failover.
logger.Info("master not in connected nodes but unreachable pods exist, allowing label update",
"master", monitorMaster.Address())
return nil
} else {
masterHost, _, addrErr := net.SplitHostPort(monitorMaster.Address())
if addrErr != nil {
logger.Error(addrErr, "failed to parse monitor master address; treating master as down",
"master", monitorMaster.Address())
} else {
masterRawPodExists := false
for _, pod := range rawPods {
if pod.Status.PodIP == masterHost {
masterRawPodExists = true
break
}
}
if masterRawPodExists {
// The Sentinel-reported master maps to a real pod, but that pod is not
// currently present in connected nodes. Avoid healing to prevent fighting
// Sentinel during a transient connectivity issue.
logger.Info("master not in connected nodes but matching raw pod exists, allowing label update",
"master", monitorMaster.Address())
return nil
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +332 to +336
rawPods, rawErr := inst.RawNodes(ctx)
if rawErr != nil {
logger.Error(rawErr, "failed to get raw nodes for index check")
return actor.RequeueWithError(rawErr)
}

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inst.RawNodes(ctx) is called twice in isNodesHealthy (once in the masterNode == nil branch and again before the index check). Since RawNodes lists pods from the API, consider fetching it once and reusing the result to avoid duplicate list calls in the path where masterNode == nil but healing continues.

Copilot uses AI. Check for mistakes.
Comment thread internal/ops/failover/engine_test.go Outdated
Comment on lines +296 to +302
// Network partition: master reachable but another pod isn't — should not trigger heal.
func TestIsNodesHealthy_IndexMismatch_WhenMasterNodeUnreachable(t *testing.T) {
ctx := context.Background()

// Only ha-1 is connected (index=1)
ha1 := newNode("ha-1", 1, "10.0.0.2", 6379)

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name TestIsNodesHealthy_IndexMismatch_WhenMasterNodeUnreachable doesn't match the scenario described in the comment/body (Sentinel master is reachable; a different pod is missing from Nodes()). Consider renaming the test so its intent matches what it's asserting.

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +165
@@ -162,9 +162,9 @@ func (s *SentinelMonitor) Master(ctx context.Context, flags ...bool) (*vkcli.Sen
s.logger.Error(err, "master not registered", "addr", node.addr)
continue
}
// NOTE: here ignored any error, for the node may be offline forever
s.logger.Error(err, "check monitoring master status of sentinel failed", "addr", node.addr)
return nil, err
// sentinel unreachable, skip and let majority quorum decide

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The err == ErrNoMaster || strings.Contains(err.Error(), "no such host") branch logs "master not registered" even when the error indicates DNS/host resolution failure for the Sentinel address. This is misleading for debugging network/DNS incidents; consider splitting the cases and logging a distinct message (and reason) for the "no such host" condition.

Copilot uses AI. Check for mistakes.
Comment thread internal/ops/failover/engine.go Outdated
if masterNode == nil {
rawPods, rawErr := inst.RawNodes(ctx)
if rawErr != nil {
logger.Error(rawErr, "failed to get raw nodes; treating master as down")

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If inst.RawNodes(ctx) fails here, the code logs the error but then proceeds as if the master is down and triggers CommandHealMonitor. For transient API issues this can cause unnecessary healing and churn; consider returning actor.RequeueWithError(rawErr) (or otherwise propagating the error) for consistency with the later RawNodes usage in this function.

Suggested change
logger.Error(rawErr, "failed to get raw nodes; treating master as down")
logger.Error(rawErr, "failed to get raw nodes")
return actor.RequeueWithError(rawErr)

Copilot uses AI. Check for mistakes.
@mvasilenko
mvasilenko force-pushed the fix/sentinel-network-partition-failover branch from dbcbca7 to 844a8d3 Compare April 20, 2026 13:38
Comment thread internal/ops/failover/engine.go Outdated

if masterNode == nil {
for _, pod := range rawPods {
if pod.Status.PodIP == monitorMaster.IP {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here can's use pod.Status.PodIP, for monitorMaster.IP many be a announce address, for example NodePort or LoadBalancer

Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Assisted-by: claude-code/claude-sonnet-4-6

@chideat chideat left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling sentinel network-partition tolerance — the high-level direction (skip unreachable sentinels so a quorum can still act, defer to sentinel instead of aborting) is the right one. However, I'm requesting changes: a few of the error-classification changes turn transient/normal conditions into active heal operations against a healthy cluster, and the core changed logic isn't covered by the new tests.

Merge blockers

1. isNetworkError swallows context.Canceled / context.DeadlineExceeded → spurious CommandHealMonitor
internal/valkey/failover/monitor/sentinel_monitor.go (new helper, ~L52). Verified against redigo: redis.DoContext returns ctx.Err() unwrapped, so errors.Is matches. When the reconcile context is canceled (leader change/shutdown) or the 5-min DefaultReconcileTimeout is hit mid-loop, every sentinel call returns a context error → all skipped → masterStat empty → return nil, ErrNoMasterengine.go:291 maps ErrNoMaster to CommandHealMonitor. Pre-PR these hit return nil, errRequeueWithError (a safe retry). Context errors should not be classified as transient/skippable.

2. Full operator↔sentinel partition now heals instead of requeues
Same sink as #1, different trigger: when all sentinels are unreachable, the old code returned the raw *net.OpError (→ RequeueWithError); the new code skips them all → ErrNoMasterCommandHealMonitor. Please distinguish "no sentinel reachable" (requeue) from "sentinels agree there is no master" (heal).

3. idx >= len(rawPods) false-heals when rawPods is empty
internal/ops/failover/engine.go:358. Failover.RawNodes returns (nil, nil) when the StatefulSet is NotFound (failover.go:328). inst.Nodes() comes from an earlier snapshot, so it can be non-empty while a fresh RawNodes() returns empty → else branch → idx >= 0 is true for every node → CommandHealPod on a healthy cluster during a transient list/STS blip. Guard the empty case before the loop.

4. UpdateConfig skips DNS errors but not network errors (asymmetric with Master)
internal/valkey/failover/monitor/sentinel_monitor.go:467. This PR changed the line to isDNSError but didn't add the isNetworkError skip that Master() got. A network-unreachable sentinel makes UpdateConfig return err; since it runs earlier in the reconcile than the partition-tolerant Master() path, the reconcile bails on the same node the PR is trying to tolerate — defeating the fix for the config path. Add the isNetworkError skip here too.

5. Tests don't exercise the changed code
internal/ops/failover/engine_test.go: mockFailoverMonitor.Master() returns m.master directly, so the new isDNSError/isNetworkError/quorum-skip logic in SentinelMonitor.Master() — the heart of the PR — is never tested. Also, the "unreachable pod with reachable master" case never enters the new masterNode == nil block (ha-1 is the reachable master, so masterNode is non-nil). Please drive the real SentinelMonitor with injected client errors (DNS / connection-refused / context-canceled) and add coverage for the empty-rawPods branch.

Recommended (non-blocking) cleanups

  • Quorum weakened: sentinel_monitor.go:255 — with unreachable sentinels skipped, masterStat[0].Count == registeredNodes lets a single minority sentinel be authoritative during a partition (e.g. 1 of 3 reachable still returns a master). Worth reconsidering.
  • Fragile heuristic: engine.go:344len(inst.Nodes()) == len(rawPods) can hold while membership differs (rolling replacement), so the strict ordering branch runs on a mismatched set. Compare identity, not counts.
  • Empty-string match: engine.go:317pod.Status.PodIP == monitorMaster.IP lacks a non-empty guard (a Pending pod with PodIP=="" matching an empty master IP suppresses a needed heal). Mirror the adjacent announceIP != "" check.
  • Redundant API list + TOCTOU: engine.go:309 and :338 each List pods; hoist to one call and reuse — also closes the window where the two snapshots disagree.
  • Convention: repo CLAUDE.md specifies Ginkgo v2 + Gomega for unit tests; the new file uses testing + testify.

Pre-existing crashes adjacent to these paths (worth fixing while here)

  • internal/valkey/failover/monitor/sentinel_node.go:115 — in UpdateConfig, strings.Contains(err.Error(), ...) runs where err is already nil (after the earlier return), so any erroring pipeline result panics. Should be ret.Error.Error().
  • internal/ops/failover/actor/actor_patch_labels.go:65actor.RequeueWithError(err) is missing a return, then masterNode.IP is dereferenced at L76 → nil-deref on any Master() error (more reachable now via #1/#2).

Happy to re-review once #1#5 are addressed.

@mvasilenko
mvasilenko requested a review from chideat June 30, 2026 16:58
Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Assisted-by: claude-code/claude-opus-4-8
…partition-failover

Signed-off-by: Michael Vasilenko <mvasilenko@gmail.com>
Assisted-by: claude-code/claude-opus-4-8

# Conflicts:
#	charts/valkey-operator/Chart.yaml
#	internal/ops/failover/engine.go
#	internal/ops/failover/engine_test.go
#	internal/valkey/failover/monitor/sentinel_monitor.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.

3 participants