state: batch HA probe results so dual-disconnect cannot flap primary

requirePrimaryStable in TestHASubnetRouterFailoverDockerDisconnect
Phase 5a (simultaneous cable-pull of both routers) intermittently
caught the primary flipping to the offline r1. Both probe goroutines
mark their target unhealthy back-to-back; SetNodeUnhealthy publishes a
fresh NodeStore snapshot each call, so the intermediate snapshot — r1
unhealthy, r2 still healthy — runs the election with one healthy
candidate left and picks it. The next snapshot then enters the
all-unhealthy preserve-prev path, which preserves the wrong choice.

Collect probe results from the cycle and apply them through a new
NodeStore.UpdateNodes batched op so the election only runs once, with
the cycle's final health state. PolicyChange dispatch moves outside
the wg.Go goroutines and fires once if the primary assignment
actually changed.
This commit is contained in:
Kristoffer Dalby 2026-05-14 17:04:12 +00:00
parent fb8eecae25
commit de6be71a86
6 changed files with 157 additions and 88 deletions

View file

@ -3,6 +3,7 @@ package state
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/juanfont/headscale/hscontrol/types"
@ -59,10 +60,14 @@ func (p *HAHealthProber) forgetSession(id types.NodeID) {
p.lastStableSession.Delete(id)
}
// ProbeOnce pings all HA subnet router nodes and dispatches health
// changes inline. A timeout that fires after the node reconnected,
// or against a session younger than one probe cycle, is dropped so
// wgengine has time to apply the new netmap before a failover.
// ProbeOnce pings every HA subnet router and applies the cycle's
// results in one batch so the election sees a single transition.
// Per-result snapshots could otherwise elect a node that the next
// snapshot demotes again, flipping primary onto an unreachable peer.
// A timeout that fires after the node reconnected, or against a
// session younger than one probe cycle, is dropped so wgengine has
// time to apply the new netmap before silence is read as
// unreachability.
func (p *HAHealthProber) ProbeOnce(
ctx context.Context,
dispatch func(...change.Change),
@ -109,7 +114,11 @@ func (p *HAHealthProber) ProbeOnce(
Int("haNodes", len(nodeIDs)).
Msg("HA health prober starting probe cycle")
var wg sync.WaitGroup
var (
wg sync.WaitGroup
results = xsync.NewMap[types.NodeID, bool]()
deferred atomic.Bool
)
for _, id := range nodeIDs {
if !p.isConnected(id) {
@ -148,13 +157,7 @@ func (p *HAHealthProber) ProbeOnce(
Dur("latency", latency).
Msg("HA probe: node responded")
if p.state.SetNodeUnhealthy(id, false) {
dispatch(change.PolicyChange())
log.Info().
Uint64(zf.NodeID, id.Uint64()).
Msg("HA probe: node recovered, recalculating primaries")
}
results.Store(id, true)
case <-timer.C:
p.state.CancelPing(pingID)
@ -179,6 +182,8 @@ func (p *HAHealthProber) ProbeOnce(
Uint64("current_session", curr.SessionEpoch()).
Msg("HA probe: node reconnected during probe, skipping")
deferred.Store(true)
return
}
@ -188,6 +193,8 @@ func (p *HAHealthProber) ProbeOnce(
Uint64("probe_session", probeSession).
Msg("HA probe: probe of fresh session timed out, deferring to next cycle")
deferred.Store(true)
return
}
@ -196,13 +203,7 @@ func (p *HAHealthProber) ProbeOnce(
Dur("timeout", p.cfg.ProbeTimeout).
Msg("HA probe: node did not respond")
if p.state.SetNodeUnhealthy(id, true) {
dispatch(change.PolicyChange())
log.Info().
Uint64(zf.NodeID, id.Uint64()).
Msg("HA probe: node unhealthy, triggering failover")
}
results.Store(id, false)
case <-ctx.Done():
p.state.CancelPing(pingID)
@ -211,4 +212,28 @@ func (p *HAHealthProber) ProbeOnce(
}
wg.Wait()
// When any probe in the cycle was deferred (fresh session or
// reconnected mid-probe), drop the whole cycle's results: a partial
// batch lets the election pick a node whose connectivity is still
// unknown. The next cycle will run with stable sessions for every
// candidate and can decide on a complete picture.
if deferred.Load() {
return
}
healthByNode := make(map[types.NodeID]bool, results.Size())
results.Range(func(id types.NodeID, healthy bool) bool {
healthByNode[id] = healthy
return true
})
if p.state.BatchSetNodeHealth(healthByNode) {
dispatch(change.PolicyChange())
log.Info().
Int("haNodes", len(healthByNode)).
Msg("HA probe: health changed, triggering failover/recovery")
}
}