net/traffic: add fuzz test for sorting nodes by traffic score (#19893)

In PR #19682, we introduced the traffic package which provides a
traffic.Scores.SortNodes method that uses rendezvous hashing to
break ties by equally distribute the “best” node for any given client.

This PR adds a fuzzer to make sure this algorithm is not wildly unfair.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
Simon Law
2026-05-29 11:55:49 -07:00
committed by GitHub
parent 8b58bd6c64
commit 5d935c8900
+75
View File
@@ -5,6 +5,8 @@ package traffic_test
import (
"maps"
"math/rand/v2"
"slices"
"testing"
gocmp "github.com/google/go-cmp/cmp"
@@ -137,3 +139,76 @@ func FuzzNodeHasherCompare(f *testing.F) {
}
})
}
// FuzzSortNodes tests that nodes are sorted such that every node
// with an equal score has an equal chance of being first.
func FuzzSortNodes(f *testing.F) {
nodeIDs := func(nodes []tailcfg.NodeView) []tailcfg.NodeID {
nids := make([]tailcfg.NodeID, len(nodes))
for i, n := range nodes {
nids[i] = n.ID()
}
return nids
}
for _, seed := range [][]uint64{
{0, 0},
{1, 2},
{37, 42},
{5376970906336561236, 2417489263451880030},
} {
f.Add(seed[0], seed[1])
}
f.Fuzz(func(t *testing.T, seed1, seed2 uint64) {
t.Logf("using random seeds %d %d", seed1, seed2)
rnd := rand.New(rand.NewPCG(seed1, seed2))
wantScore := traffic.Score(rnd.IntN(2000))
// Create a reasonably large tailnet, between 20 and 40 nodes.
// If the number of nodes is too small, the chances of one node
// getting ranked best becomes non-trivial.
nodes := make([]tailcfg.NodeView, rnd.IntN(20)+20)
for i := range len(nodes) {
n := tailcfg.Node{
ID: tailcfg.NodeID(rnd.Int64()),
Hostinfo: (&tailcfg.Hostinfo{
Location: &tailcfg.Location{
Priority: int(wantScore),
},
}).View(),
}
nodes[i] = n.View()
}
t.Logf("node ids: %v", nodeIDs(nodes))
// All scores should be the same.
ss := traffic.ScoresFor(nodes[0].ID(), nodes)
for _, n := range nodes {
s := ss.Score(n)
if s != wantScore {
t.Errorf("%s: score %d, want %d", n.ID(), s, wantScore)
}
}
// Sort nodes from the point-of-view of each node and ensure that
// every node is equally represented by ensuring one node isnt
// sorted highest too often.
best := make(map[tailcfg.NodeID][]tailcfg.NodeID, len(nodes))
for i := range len(nodes) {
peers := slices.Clone(nodes)
selfID := peers[i].ID()
ss := traffic.ScoresFor(selfID, peers)
ss.SortNodes(peers)
t.Logf("self %s, sorted %v", selfID, nodeIDs(peers))
bestID := peers[0].ID()
best[bestID] = append(best[bestID], selfID)
}
for nid, selfIDs := range best {
if count := len(selfIDs); count > len(nodes)/2 {
t.Errorf("%s is best too frequently: %d", nid, selfIDs)
t.Fatalf("best map: %v", best)
}
}
})
}