mirror of
https://github.com/tailscale/tailscale.git
synced 2026-06-03 21:01:54 +08:00
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>
215 lines
5.1 KiB
Go
215 lines
5.1 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
||
// SPDX-License-Identifier: BSD-3-Clause
|
||
|
||
package traffic_test
|
||
|
||
import (
|
||
"maps"
|
||
"math/rand/v2"
|
||
"slices"
|
||
"testing"
|
||
|
||
gocmp "github.com/google/go-cmp/cmp"
|
||
"tailscale.com/net/traffic"
|
||
"tailscale.com/tailcfg"
|
||
)
|
||
|
||
// WantScores is a convenience alias for the type of [traffic.Score.scores].
|
||
type wantScores = map[tailcfg.NodeID]traffic.Score
|
||
|
||
var scoresCases = []struct {
|
||
name string
|
||
peers []*tailcfg.Node
|
||
want wantScores
|
||
}{
|
||
{
|
||
name: "none",
|
||
peers: nil,
|
||
want: wantScores{},
|
||
},
|
||
{
|
||
name: "no-scores",
|
||
peers: []*tailcfg.Node{
|
||
{ID: 37},
|
||
{ID: 42},
|
||
},
|
||
want: wantScores{
|
||
37: 0,
|
||
42: 0,
|
||
},
|
||
},
|
||
{
|
||
name: "mixed-scores",
|
||
peers: []*tailcfg.Node{
|
||
{ID: 37},
|
||
{
|
||
ID: 42,
|
||
Hostinfo: (&tailcfg.Hostinfo{
|
||
Location: &tailcfg.Location{Priority: 1},
|
||
}).View(),
|
||
},
|
||
},
|
||
want: wantScores{
|
||
37: 0,
|
||
42: 1,
|
||
},
|
||
},
|
||
}
|
||
|
||
func TestScoreOne(t *testing.T) {
|
||
for _, tc := range scoresCases {
|
||
if len(tc.peers) == 0 {
|
||
continue
|
||
}
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
selfID := tailcfg.NodeID(1)
|
||
ss := traffic.ScoresFor(selfID, nil)
|
||
for _, n := range tc.peers {
|
||
want := tc.want[n.ID]
|
||
score := ss.Score(n.View())
|
||
if score != want {
|
||
t.Errorf("initial Score for nodeid:%d: score %d, want %d", n.ID, score, want)
|
||
}
|
||
score = ss.Score(n.View())
|
||
if score != want {
|
||
t.Errorf("subsequent Score for nodeid:%d: score %d, want %d", n.ID, score, want)
|
||
}
|
||
}
|
||
got := maps.Collect(ss.All())
|
||
if diff := gocmp.Diff(tc.want, got); diff != "" {
|
||
t.Errorf("-want +got:\n%s", diff)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestScoreMany(t *testing.T) {
|
||
for _, tc := range scoresCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
selfID := tailcfg.NodeID(1)
|
||
var peers []tailcfg.NodeView
|
||
for _, n := range tc.peers {
|
||
peers = append(peers, n.View())
|
||
}
|
||
|
||
t.Run("ScoresFor", func(t *testing.T) {
|
||
ss := traffic.ScoresFor(selfID, peers)
|
||
got := maps.Collect(ss.All())
|
||
if diff := gocmp.Diff(tc.want, got); diff != "" {
|
||
t.Errorf("-want +got:\n%s", diff)
|
||
}
|
||
})
|
||
|
||
t.Run("ScorePeers", func(t *testing.T) {
|
||
ss := traffic.ScoresFor(selfID, nil)
|
||
ss.ScorePeers(peers)
|
||
got := maps.Collect(ss.All())
|
||
if diff := gocmp.Diff(tc.want, got); diff != "" {
|
||
t.Errorf("-want +got:\n%s", diff)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
func FuzzNodeHasherCompare(f *testing.F) {
|
||
for _, seed := range [][]uint64{
|
||
{0, 0, 0},
|
||
{1, 1, 1},
|
||
{1, 10, 11},
|
||
{1, 11, 10},
|
||
{2, 10, 11},
|
||
} {
|
||
selfID, aID, bID := seed[0], seed[1], seed[2]
|
||
f.Add(selfID, aID, bID)
|
||
}
|
||
f.Fuzz(func(t *testing.T, selfID, aID, bID uint64) {
|
||
t.Logf("selfID %d, aID %d, bID %d", selfID, aID, bID)
|
||
h := traffic.MakeRendezvousHasher(tailcfg.NodeID(selfID))
|
||
a, b := tailcfg.NodeID(aID), tailcfg.NodeID(bID)
|
||
c := h.Compare(a, b)
|
||
if c == 0 && a != b {
|
||
t.Fatalf("got %d: expected different hashes because a ≠ b, ", c)
|
||
}
|
||
if cc := h.Compare(a, b); c != cc {
|
||
t.Fatalf("c %d, cc %d: expected matching values", c, cc)
|
||
}
|
||
if d := h.Compare(b, a); c != -d {
|
||
t.Fatalf("c %d, d %d: expected inverse values", c, d)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 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 isn’t
|
||
// 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)
|
||
}
|
||
}
|
||
})
|
||
}
|