state: skip node health writes that change nothing

An all-unchanged probe cycle no longer publishes a snapshot.

Updates #3417
This commit is contained in:
Kristoffer Dalby
2026-09-09 14:46:32 +00:00
parent dfe0d3f2e5
commit 1b820b7ebe
2 changed files with 52 additions and 0 deletions
+24
View File
@@ -65,3 +65,27 @@ func TestScanNodeHealthReportsInvalidNameWithoutMutating(t *testing.T) {
require.True(t, ok)
require.Empty(t, nv.GivenName(), "boot scan must not mutate the stored name")
}
// TestBatchSetNodeHealthUnchangedSkipsWrite ensures asking for the health
// value already stored publishes no snapshot — no peersFunc invocation, no
// peer-map rebuild.
func TestBatchSetNodeHealthUnchangedSkipsWrite(t *testing.T) {
_, s, nodeID := persistTestSetup(t)
t.Cleanup(func() { _ = s.Close() })
// Use the State's nodeStore directly to confirm the node exists.
_, exists := s.nodeStore.GetNode(nodeID)
require.True(t, exists)
initialSnapshot := s.nodeStore.data.Load()
// The node starts with Unhealthy=false. Asking for "healthy=true"
// means Unhealthy stays false — no change.
for range 20 {
changed := s.BatchSetNodeHealth(map[types.NodeID]bool{nodeID: true})
require.False(t, changed,
"unchanged health write must not report a route-primary change")
require.Same(t, initialSnapshot, s.nodeStore.data.Load(),
"unchanged health write must not publish a new snapshot")
}
}
+28
View File
@@ -33,6 +33,8 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/hscontrol/util/zlog"
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -1368,11 +1370,25 @@ func (s *State) BatchSetNodeHealth(updates map[types.NodeID]bool) bool {
prevRoutes := s.nodeStore.PrimaryRoutes()
// Skip writes that would not change anything so an all-unchanged
// probe cycle publishes no snapshot. healthSetter stays authoritative
// for the candidacy and race checks under the writer.
fns := make(map[types.NodeID]UpdateNodeFunc, len(updates))
for id, healthy := range updates {
if nv, ok := s.nodeStore.GetNode(id); ok && nv.Unhealthy() == !healthy {
haHealthUpdates.WithLabelValues("unchanged").Inc()
continue
}
fns[id] = healthSetter(healthy)
}
if len(fns) == 0 {
return false
}
s.nodeStore.UpdateNodes(fns)
return !maps.Equal(prevRoutes, s.nodeStore.PrimaryRoutes())
@@ -1383,15 +1399,27 @@ func (s *State) BatchSetNodeHealth(updates map[types.NodeID]bool) bool {
// an unhealthy mark only sticks when the node is still online and
// still advertises approved routes, so a node that left HA candidacy
// between probe dispatch and result does not carry a stale bit.
// Bounded labels only: every requested health update lands in exactly one
// outcome, so the series can be summed to the probe request count.
var haHealthUpdates = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: prometheusNamespace,
Name: "ha_health_updates_total",
Help: "HA health updates by outcome: unchanged and skipped, applied, or rejected because the node left candidacy.",
}, []string{"result"})
func healthSetter(healthy bool) UpdateNodeFunc {
return func(n *types.Node) {
if !healthy {
online := n.IsOnline != nil && *n.IsOnline
if !online || len(n.AllApprovedRoutes()) == 0 {
haHealthUpdates.WithLabelValues("rejected").Inc()
return
}
}
haHealthUpdates.WithLabelValues("changed").Inc()
n.Unhealthy = !healthy
}
}