mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-27 00:36:49 +09:00
mapper: avoid refiltering broadcast recipients
Backport 648905480 from #3568.
This commit is contained in:
1 parent
c32e8e4c97
commit
f1499ce280
3 files changed
+306
-7
No files matched your search
@@ -734,18 +734,14 @@ func (b *Batcher) addToBatch(changes ...change.Change) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle broadcast changes - send to all nodes, filtering as needed
|
||||
// Broadcasts have no TargetNode, so every node receives the same changes.
|
||||
if len(broadcast) > 0 {
|
||||
b.nodes.Range(func(nodeID types.NodeID, nc *multiChannelNodeConn) bool {
|
||||
b.nodes.Range(func(_ types.NodeID, nc *multiChannelNodeConn) bool {
|
||||
if nc == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
filtered := change.FilterForNode(nodeID, broadcast)
|
||||
|
||||
if len(filtered) > 0 {
|
||||
nc.appendPending(filtered...)
|
||||
}
|
||||
nc.appendPending(broadcast...)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -17,7 +17,10 @@ import (
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||
"github.com/juanfont/headscale/hscontrol/util/zlog/zf"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -740,3 +743,182 @@ func BenchmarkMapResponseFromChange(b *testing.B) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Frozen from 1096abfc8; keep the body unchanged for differential tests and benchmarks.
|
||||
func baselineAddToBatch(b *Batcher, changes ...change.Change) {
|
||||
// Clean up any nodes being permanently removed from the system.
|
||||
//
|
||||
// This handles the case where a node is deleted from state but the batcher
|
||||
// still has it registered. By cleaning up here, we prevent "node not found"
|
||||
// errors when workers try to generate map responses for deleted nodes.
|
||||
//
|
||||
// [change.Change.DeletedNodes] is an explicit internal lifecycle signal;
|
||||
// [change.Change.PeersRemoved] remains only the protocol delta sent to clients
|
||||
// when peers disappear from their view.
|
||||
//
|
||||
// See: https://github.com/juanfont/headscale/issues/2924
|
||||
for _, ch := range changes {
|
||||
for _, removedID := range ch.DeletedNodes {
|
||||
if nc, existed := b.nodes.LoadAndDelete(removedID); existed {
|
||||
b.totalNodes.Add(-1)
|
||||
|
||||
// Tear the node's map sessions down. Dropping the entry alone
|
||||
// leaves [mapSession.serveLongPoll] streaming to a node that no
|
||||
// longer exists: [Batcher.Close] ranges b.nodes and can no
|
||||
// longer reach it, so shutdown blocks on clientStreamsOpen, and
|
||||
// the client keeps polling a node it should be re-authenticating.
|
||||
// See: https://github.com/juanfont/headscale/issues/3410
|
||||
if nc != nil {
|
||||
nc.close()
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Uint64(zf.NodeID, removedID.Uint64()).
|
||||
Msg("removed deleted node from batcher")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A full update supersedes every state change pending or in this call,
|
||||
// but not the pings addressed to a node; those follow the full.
|
||||
if change.HasFull(changes) {
|
||||
b.nodes.Range(func(_ types.NodeID, nc *multiChannelNodeConn) bool {
|
||||
if nc == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
nc.collapsePendingToFull(changes)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
broadcast, targeted := change.SplitTargetedAndBroadcast(changes)
|
||||
|
||||
// Handle targeted changes - send only to the specific node
|
||||
for _, ch := range targeted {
|
||||
if nc, ok := b.nodes.Load(ch.TargetNode); ok && nc != nil {
|
||||
nc.appendPending(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle broadcast changes - send to all nodes, filtering as needed
|
||||
if len(broadcast) > 0 {
|
||||
b.nodes.Range(func(nodeID types.NodeID, nc *multiChannelNodeConn) bool {
|
||||
if nc == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
filtered := change.FilterForNode(nodeID, broadcast)
|
||||
|
||||
if len(filtered) > 0 {
|
||||
nc.appendPending(filtered...)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var batchIngressImplementations = []struct {
|
||||
name string
|
||||
add func(*Batcher, ...change.Change)
|
||||
}{
|
||||
{"baseline", baselineAddToBatch},
|
||||
{"candidate", (*Batcher).addToBatch},
|
||||
}
|
||||
|
||||
// BenchmarkBatchIngressPaired measures enqueue work only; fixture construction,
|
||||
// delivery-count checks, and draining pending are outside the timer.
|
||||
func BenchmarkBatchIngressPaired(b *testing.B) {
|
||||
level := zerolog.GlobalLevel()
|
||||
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
defer zerolog.SetGlobalLevel(level)
|
||||
|
||||
for _, nodes := range []int{100, 1000, 10000} {
|
||||
for _, batch := range []int{1, 8, 64} {
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
mixed bool
|
||||
burst int
|
||||
}{
|
||||
{"broadcast", false, 1},
|
||||
{"mixed", true, 1},
|
||||
{"burst", false, 4},
|
||||
} {
|
||||
for _, impl := range batchIngressImplementations {
|
||||
name := fmt.Sprintf(
|
||||
"nodes=%d/batch=%d/%s/%s",
|
||||
nodes,
|
||||
batch,
|
||||
mode.name,
|
||||
impl.name,
|
||||
)
|
||||
b.Run(name, func(b *testing.B) {
|
||||
b.StopTimer()
|
||||
|
||||
batcher, _ := benchBatcher(nodes, 1)
|
||||
b.Cleanup(batcher.Close)
|
||||
|
||||
conns := make([]*multiChannelNodeConn, nodes)
|
||||
for i := range conns {
|
||||
conns[i], _ = batcher.nodes.Load(
|
||||
types.NodeID(i + 1),
|
||||
) //nolint:gosec // bounded benchmark
|
||||
}
|
||||
|
||||
changes := make([]change.Change, 0, batch+2)
|
||||
for i := range batch {
|
||||
changes = append(
|
||||
changes,
|
||||
change.NodeOnline(types.NodeID(i+1)),
|
||||
) //nolint:gosec // bounded benchmark
|
||||
}
|
||||
|
||||
if mode.mixed {
|
||||
changes = append(
|
||||
changes,
|
||||
change.SelfUpdate(1),
|
||||
change.SelfUpdate(10001),
|
||||
) //nolint:gosec // bounded benchmark
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for range b.N {
|
||||
b.StartTimer()
|
||||
|
||||
for range mode.burst {
|
||||
impl.add(batcher, changes...)
|
||||
}
|
||||
|
||||
b.StopTimer()
|
||||
|
||||
for i, nc := range conns {
|
||||
want := batch * mode.burst
|
||||
if mode.mixed && i == 0 {
|
||||
want += mode.burst
|
||||
}
|
||||
|
||||
if got := len(nc.drainPending()); got != want {
|
||||
b.Fatalf(
|
||||
"node %d: got %d changes, want %d",
|
||||
i+1,
|
||||
got,
|
||||
want,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.ReportMetric(float64(mode.burst), "calls/op")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,127 @@ func TestAddToBatch_ConcurrentBroadcast(t *testing.T) {
|
||||
"at least some nodes should have pending changes after broadcast")
|
||||
}
|
||||
|
||||
func TestAddToBatchDifferential(t *testing.T) {
|
||||
baseline := setupLightweightBatcher(t, 4, 1)
|
||||
candidate := setupLightweightBatcher(t, 4, 1)
|
||||
t.Cleanup(baseline.cleanup)
|
||||
t.Cleanup(candidate.cleanup)
|
||||
|
||||
var stopped [2]atomic.Int64
|
||||
|
||||
for i, lb := range []*lightweightBatcher{baseline, candidate} {
|
||||
nc, _ := lb.b.nodes.Load(4)
|
||||
nc.connections[0].stop = func() { stopped[i].Add(1) }
|
||||
|
||||
lb.b.RemoveNode(3, lb.channels[3])
|
||||
lb.b.nodes.Store(99, nil)
|
||||
}
|
||||
|
||||
endpoint := change.EndpointOrDERPUpdate(1, &tailcfg.PeerChange{
|
||||
NodeID: 1, DERPRegion: 7, Online: new(true),
|
||||
})
|
||||
ping := change.PingNode(2, &tailcfg.PingRequest{URL: "https://example.com/one"})
|
||||
stages := []struct {
|
||||
name string
|
||||
changes []change.Change
|
||||
}{
|
||||
{"empty", nil},
|
||||
{"mixed", []change.Change{
|
||||
change.NodeOnline(2), change.SelfUpdate(1), endpoint,
|
||||
change.SelfUpdate(88), ping, change.NodeOnline(2),
|
||||
}},
|
||||
{"repeated_kinds", []change.Change{
|
||||
change.NodeOnline(2), change.NodeOffline(2), change.NodeOnline(2),
|
||||
change.PeersChanged("several peers", 3, 1),
|
||||
}},
|
||||
{"deletion", []change.Change{
|
||||
change.NodeRemoved(4), change.SelfUpdate(4), change.PeersRemoved(3),
|
||||
}},
|
||||
{
|
||||
"full_with_pings",
|
||||
[]change.Change{
|
||||
change.FullUpdate(),
|
||||
change.PingNode(
|
||||
1,
|
||||
&tailcfg.PingRequest{URL: "https://example.com/two"},
|
||||
),
|
||||
change.PingNode(
|
||||
88,
|
||||
&tailcfg.PingRequest{URL: "https://example.com/missing"},
|
||||
),
|
||||
change.NodeOnline(3),
|
||||
},
|
||||
},
|
||||
{"repeated_full", []change.Change{change.FullUpdate(), change.FullUpdate()}},
|
||||
{"after_full", []change.Change{endpoint, change.SelfUpdate(1), ping}},
|
||||
}
|
||||
|
||||
for _, stage := range stages {
|
||||
t.Run(stage.name, func(t *testing.T) {
|
||||
baselineAddToBatch(baseline.b, stage.changes...)
|
||||
candidate.b.addToBatch(stage.changes...)
|
||||
|
||||
for _, id := range []types.NodeID{1, 2, 3, 4, 88, 99} {
|
||||
want, wantExists := baseline.b.nodes.Load(id)
|
||||
got, gotExists := candidate.b.nodes.Load(id)
|
||||
require.Equal(t, wantExists, gotExists, "node %d", id)
|
||||
require.Equal(t, want == nil, got == nil, "node %d", id)
|
||||
|
||||
if got == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
assert.Equal(t, getPendingForNode(baseline.b, id),
|
||||
getPendingForNode(candidate.b, id), "node %d", id)
|
||||
}
|
||||
|
||||
assert.Equal(t, baseline.b.totalNodes.Load(), candidate.b.totalNodes.Load())
|
||||
assert.Equal(t, stopped[0].Load(), stopped[1].Load())
|
||||
|
||||
if stage.name == "mixed" {
|
||||
assert.Equal(t, []change.Change{
|
||||
change.SelfUpdate(
|
||||
1,
|
||||
),
|
||||
change.NodeOnline(2),
|
||||
endpoint,
|
||||
change.NodeOnline(2),
|
||||
}, getPendingForNode(candidate.b, 1))
|
||||
assert.Equal(t, []change.Change{
|
||||
change.NodeOnline(2), endpoint, change.NodeOnline(2),
|
||||
}, getPendingForNode(candidate.b, 3), "disconnected recipient retains broadcasts")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
require.EqualValues(t, 1, stopped[1].Load(), "deletion must stop the session once")
|
||||
}
|
||||
|
||||
func TestAddToBatchRecipientStorage(t *testing.T) {
|
||||
for _, impl := range batchIngressImplementations {
|
||||
t.Run(impl.name, func(t *testing.T) {
|
||||
lb := setupLightweightBatcher(t, 2, 1)
|
||||
t.Cleanup(lb.cleanup)
|
||||
|
||||
incoming := []change.Change{change.NodeOnline(1), change.NodeOffline(1)}
|
||||
impl.add(lb.b, incoming...)
|
||||
incoming[0] = change.FullUpdate()
|
||||
|
||||
first, _ := lb.b.nodes.Load(1)
|
||||
first.pendingMu.Lock()
|
||||
first.pending[0].Reason = "recipient-local change"
|
||||
first.pendingMu.Unlock()
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]change.Change{change.NodeOnline(1), change.NodeOffline(1)},
|
||||
getPendingForNode(lb.b, 2),
|
||||
"recipients must own their pending slices",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddToBatch_FullUpdateOverrides verifies that a FullUpdate replaces
|
||||
// all pending changes for every node.
|
||||
func TestAddToBatch_FullUpdateOverrides(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user