Files
routewatch/internal/routetable/routetable_test.go
T
sneak eadad8dc83
check / check (push) Canceled after 0s
Store the routing table in the database (part of #122)
A new routes table holds one row per prefix and origin AS: the peer set
as the bitset's bytes, and the two kept routes, each with its peer's
number, AS path and time in seconds. A new route_peers table holds each
peer's number and address.

WriteRouteChanges writes one TakeChanges result in a transaction.
ReadRouteTable reads both tables into a new routing table, through the
new Table.Load, which records no change. Nothing uses them yet.

Model: opus-5-5
2026-10-27 07:19:11 +00:00

616 lines
18 KiB
Go

package routetable_test
import (
"net/netip"
"reflect"
"slices"
"sync"
"testing"
"time"
"github.com/bits-and-blooms/bitset"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
const (
peerA = "192.0.2.1"
peerB = "192.0.2.2"
peerC = "2001:db8::3"
peerD = "2001:db8::4"
asA = 64500
asB = 64501
asC = 64502
// transitAS is the first AS of every test path, the peer's own.
transitAS = 64496
prefixV4 = "10.1.2.0/24"
prefixV6 = "2001:db8:1::/48"
)
// at returns the time second seconds after the start of every test.
func at(second int) time.Time {
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
Add(time.Duration(second) * time.Second)
}
// timeAt returns at(second) for a field that may be nil.
func timeAt(second int) *time.Time {
t := at(second)
return &t
}
// routeOf returns the route peer announces with origin AS asn second seconds
// after the start.
func routeOf(peer string, asn, second int) routetable.Route {
return routetable.Route{
Peer: peer, ASPath: []int{transitAS, asn}, Time: at(second),
}
}
// step is one update a test applies to a table for one prefix.
type step func(table *routetable.Table, prefix netip.Prefix)
// announce is the step of peer announcing prefix with origin AS asn second
// seconds after the start.
func announce(peer string, asn, second int) step {
return func(table *routetable.Table, prefix netip.Prefix) {
table.Announce(prefix, asn, routeOf(peer, asn, second))
}
}
// withdraw is the step of peer withdrawing prefix.
func withdraw(peer string) step {
return func(table *routetable.Table, prefix netip.Prefix) {
table.Withdraw(prefix, peer)
}
}
// peerDown is the step of peer's session going down.
func peerDown(peer string) step {
return func(table *routetable.Table, _ netip.Prefix) {
table.PeerDown(peer)
}
}
// rule is one case of the rule that decides what the table holds for a prefix:
// the steps applied to an empty table, and the prefix's origins after them.
// The peers are numbered in the order they first announce, which is the order
// Origin.Peers lists them in.
type rule struct {
name string
steps []step
want []routetable.Origin
}
// checkRules runs each case for an IPv4 and an IPv6 prefix.
func checkRules(t *testing.T, rules []rule) {
t.Helper()
for _, tc := range rules {
for _, text := range []string{prefixV4, prefixV6} {
t.Run(tc.name+" "+text, func(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(text)
table := routetable.New(routetable.Params{})
for _, apply := range tc.steps {
apply(table, prefix)
}
got := table.Origins(prefix)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Origins(%s) = %+v, want %+v", prefix, got, tc.want)
}
})
}
}
}
func TestAnnouncementsKeepTheTwoNewestRoutes(t *testing.T) {
t.Parallel()
checkRules(t, []rule{
{
name: "an announcement is kept",
steps: []step{announce(peerA, asA, 10)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
{
name: "a lagging peer's older route is not kept",
steps: []step{announce(peerA, asA, 20), announce(peerB, asA, 30),
announce(peerC, asA, 10)},
want: []routetable.Origin{{ASN: asA,
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
routeOf(peerB, asA, 30), routeOf(peerA, asA, 20),
}}},
},
{
name: "a third newer route drops the older kept one, whose peer " +
"keeps its bit",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 30)},
want: []routetable.Origin{{ASN: asA,
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
}}},
},
{
name: "a route as old as the older kept one replaces it",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 10)},
want: []routetable.Origin{{ASN: asA,
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
routeOf(peerB, asA, 20), routeOf(peerC, asA, 10),
}}},
},
{
name: "a peer's new route replaces its kept one",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerA, asA, 30)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
Routes: []routetable.Route{
routeOf(peerA, asA, 30), routeOf(peerB, asA, 20),
}}},
},
{
name: "a peer's new route replaces its kept one even when older",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerB, asA, 5)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
Routes: []routetable.Route{
routeOf(peerA, asA, 10), routeOf(peerB, asA, 5),
}}},
},
})
}
func TestAPeerCarriesAPrefixWithOneOrigin(t *testing.T) {
t.Parallel()
checkRules(t, []rule{
{
name: "a peer moving to a new origin leaves the old one",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerA, asB, 30)},
want: []routetable.Origin{
{ASN: asA, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asA, 20)}},
{ASN: asB, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asB, 30)}},
},
},
{
name: "a peer whose route is not kept moves to a new origin",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 30), announce(peerA, asB, 40)},
want: []routetable.Origin{
{ASN: asA, Peers: []string{peerB, peerC},
Routes: []routetable.Route{
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
}},
{ASN: asB, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asB, 40)}},
},
},
{
name: "origins are listed in AS number order",
steps: []step{announce(peerA, asC, 10), announce(peerB, asA, 20),
announce(peerC, asB, 30)},
want: []routetable.Origin{
{ASN: asA, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asA, 20)}},
{ASN: asB, Peers: []string{peerC},
Routes: []routetable.Route{routeOf(peerC, asB, 30)}},
{ASN: asC, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asC, 10)}},
},
},
})
}
func TestWithdrawalsAndPeerDowns(t *testing.T) {
t.Parallel()
// threePeers is peerA, peerB and peerC announcing in turn; the routes of
// peerC and peerB are kept.
threePeers := []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 30)}
checkRules(t, []rule{
{
name: "a withdrawal drops the peer's kept route",
steps: append(slices.Clone(threePeers), withdraw(peerC)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
Routes: []routetable.Route{routeOf(peerB, asA, 20)}}},
},
{
name: "a withdrawal of a route that is not kept leaves the kept ones",
steps: append(slices.Clone(threePeers), withdraw(peerA)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerB, peerC},
Routes: []routetable.Route{
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
}}},
},
{
name: "an origin whose kept routes are withdrawn keeps its other " +
"peers",
steps: append(slices.Clone(threePeers), withdraw(peerB),
withdraw(peerC)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA}}},
},
{
name: "an origin without kept routes keeps the next update",
steps: append(slices.Clone(threePeers), withdraw(peerB),
withdraw(peerC), announce(peerA, asA, 5)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 5)}}},
},
{
name: "an origin no peer carries is removed",
steps: []step{announce(peerA, asA, 10), announce(peerB, asB, 20),
withdraw(peerA)},
want: []routetable.Origin{{ASN: asB, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asB, 20)}}},
},
{
name: "a prefix no peer carries is removed",
steps: []step{announce(peerA, asA, 10), withdraw(peerA)},
want: nil,
},
{
name: "a withdrawal from a peer that does not carry it changes nothing",
steps: []step{announce(peerA, asA, 10), withdraw(peerB)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
{
name: "a peer going down leaves the prefix",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
peerDown(peerB)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
{
name: "a peer going down after it withdrew changes nothing",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
withdraw(peerB), peerDown(peerB)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
})
}
func TestPeerDownLeavesEveryPrefix(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
prefixes := []netip.Prefix{
netip.MustParsePrefix(prefixV4),
netip.MustParsePrefix("10.2.0.0/16"),
netip.MustParsePrefix(prefixV6),
}
// peerA carries every prefix with origin asA, peerB the first one with
// origin asB.
for _, prefix := range prefixes {
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
}
table.Announce(prefixes[0], asB, routeOf(peerB, asB, 20))
table.PeerDown(peerA)
want := []routetable.Origin{{ASN: asB, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asB, 20)}}}
got := table.Origins(prefixes[0])
if !reflect.DeepEqual(got, want) {
t.Errorf("Origins(%s) = %+v, want %+v", prefixes[0], got, want)
}
for _, prefix := range prefixes[1:] {
got := table.Origins(prefix)
if got != nil {
t.Errorf("Origins(%s) = %+v after the only peer went down, want nil",
prefix, got)
}
}
counts := table.Counts()
if counts.IPv4Prefixes != 1 || counts.IPv6Prefixes != 0 ||
counts.IPv4Routes != 1 || counts.IPv6Routes != 0 {
t.Errorf("Counts() = %+v, want one IPv4 prefix and route", counts)
}
if len(table.OriginatedPrefixes(asA)) != 0 {
t.Errorf("OriginatedPrefixes(%d) = %+v, want none",
asA, table.OriginatedPrefixes(asA))
}
}
func TestCountsOriginsWithoutKeptRoute(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(prefixV4)
table := routetable.New(routetable.Params{})
steps := []struct {
apply step
want int
}{
{announce(peerA, asA, 10), 0},
{announce(peerB, asA, 20), 0},
{announce(peerC, asA, 30), 0},
{withdraw(peerC), 0},
{withdraw(peerB), 1},
// An update from the last peer is kept.
{announce(peerA, asA, 40), 0},
{withdraw(peerA), 0},
// Once more without kept routes, then without peers: the origin
// is removed.
{announce(peerA, asA, 10), 0},
{announce(peerB, asA, 20), 0},
{announce(peerC, asA, 30), 0},
{withdraw(peerB), 0},
{withdraw(peerC), 1},
{peerDown(peerA), 0},
}
for i, s := range steps {
s.apply(table, prefix)
got := table.Counts().OriginsWithoutKeptRoute
if got != s.want {
t.Errorf("after step %d, OriginsWithoutKeptRoute = %d, want %d",
i, got, s.want)
}
}
}
// change is a routetable.Change with its peer set as a list of peer numbers.
type change struct {
Prefix string
OriginASN int
Removed bool
Peers []uint
Routes []routetable.Route
}
// peerNumbers returns the numbers in set, or nil for a nil set.
func peerNumbers(set *bitset.BitSet) []uint {
if set == nil {
return nil
}
return slices.Collect(set.EachSet())
}
func TestTakeChanges(t *testing.T) {
t.Parallel()
// peerA was numbered 5 before; the next new peer is 6.
table := routetable.New(routetable.Params{
PeerNumbers: map[string]uint{peerA: 5},
})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
table.Announce(v6, asA, routeOf(peerA, asA, 10))
table.Announce(v4, asA, routeOf(peerA, asA, 10))
table.Announce(v4, asA, routeOf(peerB, asA, 20))
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{5, 6},
Routes: []routetable.Route{
routeOf(peerB, asA, 20), routeOf(peerA, asA, 10),
}},
{Prefix: prefixV6, OriginASN: asA, Peers: []uint{5},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}},
}, map[string]uint{peerB: 6})
// TakeChanges cleared the record.
checkChanges(t, table, []change{}, map[string]uint{})
// peerB moves to asB; peerA withdraws, which removes the pair of v4 and
// asA.
table.Announce(v4, asB, routeOf(peerB, asB, 30))
table.Withdraw(v4, peerA)
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Removed: true},
{Prefix: prefixV4, OriginASN: asB, Peers: []uint{6},
Routes: []routetable.Route{routeOf(peerB, asB, 30)}},
}, map[string]uint{})
}
func TestTakeChangesHoldsOnlyPairsThatChanged(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
// peerA, peerB and peerC are numbered 0, 1 and 2. The routes of peerC and
// peerB to v4 are kept; peerA keeps only its bit.
announceAll(table, prefixV4, asA, 10, peerA, peerB, peerC)
table.Announce(v6, asA, routeOf(peerB, asA, 10))
table.TakeChanges()
// peerA announces again, with a time older than both kept routes.
table.Announce(v4, asA, routeOf(peerA, asA, 15))
checkChanges(t, table, []change{}, map[string]uint{})
// peerC announces its kept route again.
table.Announce(v4, asA, routeOf(peerC, asA, 30))
checkChanges(t, table, []change{}, map[string]uint{})
// peerC announces the same path with a newer time, so its kept route
// changes.
table.Announce(v4, asA, routeOf(peerC, asA, 40))
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{0, 1, 2},
Routes: []routetable.Route{
routeOf(peerC, asA, 40), routeOf(peerB, asA, 20),
}},
}, map[string]uint{})
// peerA withdraws v6, which it does not carry.
table.Withdraw(v6, peerA)
checkChanges(t, table, []change{}, map[string]uint{})
// peerA going down changes v4, the one prefix it carries.
table.PeerDown(peerA)
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{1, 2},
Routes: []routetable.Route{
routeOf(peerC, asA, 40), routeOf(peerB, asA, 20),
}},
}, map[string]uint{})
// peerA, which carries nothing now, goes down again.
table.PeerDown(peerA)
checkChanges(t, table, []change{}, map[string]uint{})
}
func TestLoad(t *testing.T) {
t.Parallel()
const otherV4 = "10.9.0.0/16"
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
other := netip.MustParsePrefix(otherV4)
// The routes of peerC and peerB to v4 are kept; peerA keeps only its bit.
// The two routes to v6 have the same time. The routes of other were
// withdrawn while peerA still carries it.
source := routetable.New(routetable.Params{})
announceAll(source, prefixV4, asA, 10, peerA, peerB, peerC)
announceAll(source, prefixV4, asB, 40, peerD)
source.Announce(v6, asA, routeOf(peerA, asA, 50))
source.Announce(v6, asA, routeOf(peerC, asA, 50))
announceAll(source, otherV4, asB, 10, peerA, peerB, peerC)
source.Withdraw(other, peerB)
source.Withdraw(other, peerC)
changes, peers := source.TakeChanges()
loaded := routetable.New(routetable.Params{PeerNumbers: peers})
// Loading a pair twice changes nothing.
for range 2 {
for _, change := range changes {
loaded.Load(change)
}
}
for _, prefix := range []netip.Prefix{v4, v6, other} {
got, want := loaded.Origins(prefix), source.Origins(prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("Origins(%s) = %+v after the load, want %+v", prefix, got, want)
}
}
if !reflect.DeepEqual(loaded.Counts(), source.Counts()) {
t.Errorf("Counts() = %+v after the load, want %+v",
loaded.Counts(), source.Counts())
}
checkChanges(t, loaded, []change{}, map[string]uint{})
}
func TestLoadLeavesOutTheRouteOfAPeerWithoutANumber(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(prefixV4)
source := routetable.New(routetable.Params{})
announceAll(source, prefixV4, asA, 10, peerA, peerB)
// peerB has no number in the loaded table.
changes, _ := source.TakeChanges()
loaded := routetable.New(routetable.Params{
PeerNumbers: map[string]uint{peerA: 0},
})
loaded.Load(changes[0])
want := []routetable.Route{routeOf(peerA, asA, 10)}
origins := loaded.Origins(prefix)
if len(origins) != 1 || !reflect.DeepEqual(origins[0].Routes, want) {
t.Errorf("Origins(%s) = %+v, want one origin with routes %+v",
prefix, origins, want)
}
checkChanges(t, loaded, []change{}, map[string]uint{})
}
// TestConcurrentUse has goroutines update one table while others read it. The
// race detector, which make test runs, fails it when a method uses the table
// without its lock.
func TestConcurrentUse(t *testing.T) {
t.Parallel()
const (
// rounds is how many times each goroutine calls its method.
rounds = 1000
ipVersion = 4
sampleSize = 10
)
table := routetable.New(routetable.Params{})
prefix := netip.MustParsePrefix(prefixV4)
uses := []func(round int){
func(round int) { table.Announce(prefix, asA, routeOf(peerA, asA, round)) },
func(round int) { table.Announce(prefix, asB, routeOf(peerB, asB, round)) },
func(int) { table.Withdraw(prefix, peerA) },
func(int) { table.PeerDown(peerB) },
func(int) { table.TakeChanges() },
func(int) { table.Lookup(prefix.Addr()) },
func(int) { table.Origins(prefix) },
func(int) { table.OriginatedPrefixes(asA) },
func(int) { table.SamplePrefixes(ipVersion, prefix.Bits(), sampleSize) },
func(int) { table.Counts() },
}
var wg sync.WaitGroup
for _, use := range uses {
wg.Go(func() {
for round := range rounds {
use(round)
}
})
}
wg.Wait()
}
// checkChanges checks that TakeChanges returns want and wantPeers.
func checkChanges(
t *testing.T, table *routetable.Table, want []change,
wantPeers map[string]uint,
) {
t.Helper()
changes, newPeers := table.TakeChanges()
got := make([]change, 0, len(changes))
for _, c := range changes {
got = append(got, change{
Prefix: c.Prefix.String(), OriginASN: c.OriginASN, Removed: c.Removed,
Peers: peerNumbers(c.Peers), Routes: c.Routes,
})
}
if !reflect.DeepEqual(got, want) {
t.Errorf("TakeChanges() changes = %+v, want %+v", got, want)
}
if !reflect.DeepEqual(newPeers, wantPeers) {
t.Errorf("TakeChanges() new peers = %v, want %v", newPeers, wantPeers)
}
}