check / check (push) Waiting to run
The prefix handler applies each update to the routing table in memory as it handles it, sorted into announcements and withdrawals as for the live route tables. Each flush first writes at most 20,000 of the table's changed pairs, with every new peer; the rest follow in the next flushes. A failed write puts its changes and new peers back in the table. Stop makes one last flush, and logs the changed pairs left. Startup reads the table before the streamer connects, and logs its pairs, peers and time. The per-peer counts are a slice, so the read takes about a third less time. The health check's start period is 60 seconds, so a full table's read fits before upaas looks. Model: opus-5-5
528 lines
15 KiB
Go
528 lines
15 KiB
Go
package database
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"math/rand/v2"
|
|
"net/netip"
|
|
"os"
|
|
"reflect"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bits-and-blooms/bitset"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
)
|
|
|
|
// Peers, origin ASes and prefixes of the routes table tests.
|
|
const (
|
|
peerA = "192.0.2.1"
|
|
peerB = "192.0.2.2"
|
|
peerC = "2001:db8::3"
|
|
peerD = "2001:db8::4"
|
|
peerE = "2001:db8::5"
|
|
|
|
asA = 64500
|
|
asB = 64501
|
|
// transitAS is the first AS of every test path, the peer's own.
|
|
transitAS = 64496
|
|
|
|
tablePrefixV4 = "10.1.2.0/24"
|
|
tablePrefixShortV4 = "10.9.0.0/16"
|
|
tablePrefixV6 = "2001:db8:1::/48"
|
|
)
|
|
|
|
// tableTime returns the time second seconds after the start of every routes
|
|
// table test.
|
|
func tableTime(second int) time.Time {
|
|
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
|
|
Add(time.Duration(second) * time.Second)
|
|
}
|
|
|
|
// announceTo has peer announce prefix with origin AS asn second seconds after
|
|
// the start.
|
|
func announceTo(
|
|
table *routetable.Table, prefix string, asn int, peer string, second int,
|
|
) {
|
|
table.Announce(netip.MustParsePrefix(prefix), asn, routetable.Route{
|
|
Peer: peer, ASPath: []int{transitAS, asn}, Time: tableTime(second),
|
|
})
|
|
}
|
|
|
|
// newRoutesTestDatabase returns a new database in a temporary directory.
|
|
func newRoutesTestDatabase(t *testing.T) *Database {
|
|
t.Helper()
|
|
|
|
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db
|
|
}
|
|
|
|
// writeTableChanges writes what table changed since the last call.
|
|
func writeTableChanges(t *testing.T, db *Database, table *routetable.Table) {
|
|
t.Helper()
|
|
|
|
changes, newPeers := table.TakeChanges(math.MaxInt)
|
|
|
|
err := db.WriteRouteChanges(changes, newPeers)
|
|
if err != nil {
|
|
t.Fatalf("WriteRouteChanges: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadRouteTableGivesBackWhatWasWritten(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := newRoutesTestDatabase(t)
|
|
source := routetable.New(routetable.Params{})
|
|
|
|
// Two origins of one prefix: the routes of peerC and peerB are kept for
|
|
// asA, peerA keeps only its bit.
|
|
announceTo(source, tablePrefixV4, asA, peerA, 10)
|
|
announceTo(source, tablePrefixV4, asA, peerB, 20)
|
|
announceTo(source, tablePrefixV4, asA, peerC, 30)
|
|
announceTo(source, tablePrefixV4, asB, peerD, 40)
|
|
// Two routes with the same time.
|
|
announceTo(source, tablePrefixV6, asA, peerA, 50)
|
|
announceTo(source, tablePrefixV6, asA, peerC, 50)
|
|
// An origin whose kept routes were withdrawn while peerA carries it.
|
|
announceTo(source, tablePrefixShortV4, asB, peerA, 10)
|
|
announceTo(source, tablePrefixShortV4, asB, peerB, 20)
|
|
announceTo(source, tablePrefixShortV4, asB, peerC, 30)
|
|
source.Withdraw(netip.MustParsePrefix(tablePrefixShortV4), peerB)
|
|
source.Withdraw(netip.MustParsePrefix(tablePrefixShortV4), peerC)
|
|
|
|
writeTableChanges(t, db, source)
|
|
checkReadTable(t, db, source)
|
|
|
|
// A peer moves to another origin, one goes down and a new one comes.
|
|
announceTo(source, tablePrefixV4, asB, peerA, 60)
|
|
source.PeerDown(peerC)
|
|
announceTo(source, tablePrefixV6, asB, peerE, 70)
|
|
|
|
writeTableChanges(t, db, source)
|
|
checkReadTable(t, db, source)
|
|
}
|
|
|
|
func TestReadRouteTableGivesATableThatTakesLaterUpdates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := newRoutesTestDatabase(t)
|
|
source := routetable.New(routetable.Params{})
|
|
|
|
// peerA and peerB carry tablePrefixV4, peerB and peerC tablePrefixV6.
|
|
announceTo(source, tablePrefixV4, asA, peerA, 10)
|
|
announceTo(source, tablePrefixV4, asA, peerB, 20)
|
|
announceTo(source, tablePrefixV6, asA, peerB, 30)
|
|
announceTo(source, tablePrefixV6, asA, peerC, 40)
|
|
|
|
writeTableChanges(t, db, source)
|
|
read := checkReadTable(t, db, source)
|
|
|
|
// The same updates to both tables: a loaded peer withdraws a prefix,
|
|
// another one goes down, and a new peer comes.
|
|
for _, table := range []*routetable.Table{source, read} {
|
|
table.Withdraw(netip.MustParsePrefix(tablePrefixV4), peerA)
|
|
table.PeerDown(peerB)
|
|
announceTo(table, tablePrefixShortV4, asB, peerD, 50)
|
|
}
|
|
|
|
checkSameReads(t, read, source)
|
|
}
|
|
|
|
func TestReadRouteTableFailsOnAPeerWithoutANumber(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := newRoutesTestDatabase(t)
|
|
table := routetable.New(routetable.Params{})
|
|
|
|
// Only the routes of peerB and peerC are kept, so only the peer set names
|
|
// peerA.
|
|
announceTo(table, tablePrefixV4, asA, peerA, 10)
|
|
announceTo(table, tablePrefixV4, asA, peerB, 20)
|
|
announceTo(table, tablePrefixV4, asA, peerC, 30)
|
|
writeTableChanges(t, db, table)
|
|
|
|
_, err := db.db.ExecContext(t.Context(),
|
|
"DELETE FROM route_peers WHERE address = ?", peerA)
|
|
if err != nil {
|
|
t.Fatalf("deleting a route peer: %v", err)
|
|
}
|
|
|
|
_, err = db.ReadRouteTable(t.Context())
|
|
if !errors.Is(err, errUnknownRoutePeer) {
|
|
t.Fatalf("ReadRouteTable = %v, want %v", err, errUnknownRoutePeer)
|
|
}
|
|
}
|
|
|
|
// checkReadTable checks that the table ReadRouteTable reads answers every read
|
|
// as want does, and that reading it recorded no change. It returns that table.
|
|
func checkReadTable(
|
|
t *testing.T, db *Database, want *routetable.Table,
|
|
) *routetable.Table {
|
|
t.Helper()
|
|
|
|
got, err := db.ReadRouteTable(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("ReadRouteTable: %v", err)
|
|
}
|
|
|
|
checkSameReads(t, got, want)
|
|
|
|
changes, newPeers := got.TakeChanges(math.MaxInt)
|
|
if len(changes) != 0 || len(newPeers) != 0 {
|
|
t.Errorf("the read recorded changes %+v and new peers %v, want none",
|
|
changes, newPeers)
|
|
}
|
|
|
|
return got
|
|
}
|
|
|
|
// checkSameReads checks that got answers every read as want does, for the
|
|
// prefixes and the origin ASes the tests use.
|
|
func checkSameReads(t *testing.T, got, want *routetable.Table) {
|
|
t.Helper()
|
|
|
|
if !reflect.DeepEqual(got.Counts(), want.Counts()) {
|
|
t.Errorf("Counts() = %+v, want %+v", got.Counts(), want.Counts())
|
|
}
|
|
|
|
for _, text := range []string{
|
|
tablePrefixV4, tablePrefixShortV4, tablePrefixV6,
|
|
} {
|
|
prefix := netip.MustParsePrefix(text)
|
|
|
|
if !reflect.DeepEqual(got.Origins(prefix), want.Origins(prefix)) {
|
|
t.Errorf("Origins(%s) = %+v, want %+v",
|
|
prefix, got.Origins(prefix), want.Origins(prefix))
|
|
}
|
|
|
|
gotSummary, gotFound := got.Lookup(prefix.Addr())
|
|
wantSummary, wantFound := want.Lookup(prefix.Addr())
|
|
|
|
if gotFound != wantFound || !reflect.DeepEqual(gotSummary, wantSummary) {
|
|
t.Errorf("Lookup(%s) = %+v, %v; want %+v, %v", prefix.Addr(),
|
|
gotSummary, gotFound, wantSummary, wantFound)
|
|
}
|
|
|
|
ipVersion := ipVersionV6
|
|
if prefix.Addr().Is4() {
|
|
ipVersion = ipVersionV4
|
|
}
|
|
|
|
gotSample := sortedSample(got, ipVersion, prefix.Bits())
|
|
wantSample := sortedSample(want, ipVersion, prefix.Bits())
|
|
|
|
if !reflect.DeepEqual(gotSample, wantSample) {
|
|
t.Errorf("SamplePrefixes(%d, %d) = %+v, want %+v",
|
|
ipVersion, prefix.Bits(), gotSample, wantSample)
|
|
}
|
|
}
|
|
|
|
for _, asn := range []int{asA, asB} {
|
|
if !reflect.DeepEqual(got.OriginatedPrefixes(asn),
|
|
want.OriginatedPrefixes(asn)) {
|
|
t.Errorf("OriginatedPrefixes(%d) = %+v, want %+v", asn,
|
|
got.OriginatedPrefixes(asn), want.OriginatedPrefixes(asn))
|
|
}
|
|
}
|
|
}
|
|
|
|
// sortedSample returns every prefix of the IP version and mask length in
|
|
// table, in prefix order.
|
|
func sortedSample(
|
|
table *routetable.Table, ipVersion, maskLength int,
|
|
) []routetable.PrefixSummary {
|
|
// More than the tests have of any mask length.
|
|
const all = 100
|
|
|
|
sample := table.SamplePrefixes(ipVersion, maskLength, all)
|
|
slices.SortFunc(sample, func(a, b routetable.PrefixSummary) int {
|
|
return a.Prefix.Compare(b.Prefix)
|
|
})
|
|
|
|
return sample
|
|
}
|
|
|
|
func TestWriteRouteChangesDeletesTheRowOfARemovedPair(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := newRoutesTestDatabase(t)
|
|
table := routetable.New(routetable.Params{})
|
|
|
|
announceTo(table, tablePrefixV4, asA, peerA, 10)
|
|
announceTo(table, tablePrefixV4, asB, peerB, 20)
|
|
writeTableChanges(t, db, table)
|
|
|
|
// The prefix's only peer with asA withdraws it.
|
|
table.Withdraw(netip.MustParsePrefix(tablePrefixV4), peerA)
|
|
writeTableChanges(t, db, table)
|
|
|
|
var origins []int
|
|
|
|
rows, err := db.db.QueryContext(t.Context(),
|
|
"SELECT origin_asn FROM routes WHERE prefix = ?", tablePrefixV4)
|
|
if err != nil {
|
|
t.Fatalf("reading the routes: %v", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
for rows.Next() {
|
|
var origin int
|
|
|
|
err := rows.Scan(&origin)
|
|
if err != nil {
|
|
t.Fatalf("reading a route: %v", err)
|
|
}
|
|
|
|
origins = append(origins, origin)
|
|
}
|
|
|
|
err = rows.Err()
|
|
if err != nil {
|
|
t.Fatalf("reading the routes: %v", err)
|
|
}
|
|
|
|
if !slices.Equal(origins, []int{asB}) {
|
|
t.Errorf("rows of %s have origins %v, want only %d",
|
|
tablePrefixV4, origins, asB)
|
|
}
|
|
}
|
|
|
|
// routePeer is one row of route_peers.
|
|
type routePeer struct {
|
|
number uint
|
|
address string
|
|
}
|
|
|
|
// readRoutePeerRows returns the rows of route_peers in number order.
|
|
func readRoutePeerRows(t *testing.T, db *Database) []routePeer {
|
|
t.Helper()
|
|
|
|
rows, err := db.db.QueryContext(t.Context(),
|
|
"SELECT number, address FROM route_peers ORDER BY number")
|
|
if err != nil {
|
|
t.Fatalf("reading the route peers: %v", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var peers []routePeer
|
|
|
|
for rows.Next() {
|
|
var peer routePeer
|
|
|
|
err := rows.Scan(&peer.number, &peer.address)
|
|
if err != nil {
|
|
t.Fatalf("reading a route peer: %v", err)
|
|
}
|
|
|
|
peers = append(peers, peer)
|
|
}
|
|
|
|
err = rows.Err()
|
|
if err != nil {
|
|
t.Fatalf("reading the route peers: %v", err)
|
|
}
|
|
|
|
return peers
|
|
}
|
|
|
|
func TestWriteRouteChangesStoresNewPeers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := newRoutesTestDatabase(t)
|
|
table := routetable.New(routetable.Params{})
|
|
|
|
announceTo(table, tablePrefixV4, asA, peerA, 10)
|
|
announceTo(table, tablePrefixV6, asA, peerC, 20)
|
|
writeTableChanges(t, db, table)
|
|
|
|
// peerA is stored already; peerB is new.
|
|
announceTo(table, tablePrefixV4, asA, peerA, 30)
|
|
announceTo(table, tablePrefixV4, asA, peerB, 40)
|
|
writeTableChanges(t, db, table)
|
|
|
|
want := []routePeer{{0, peerA}, {1, peerC}, {2, peerB}}
|
|
|
|
got := readRoutePeerRows(t, db)
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("route_peers = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestWriteRouteChangesWritesNothingWhenItFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db := newRoutesTestDatabase(t)
|
|
table := routetable.New(routetable.Params{})
|
|
|
|
announceTo(table, tablePrefixV4, asA, peerA, 10)
|
|
announceTo(table, tablePrefixV6, asA, peerB, 20)
|
|
|
|
// peerB's number is missing, so its kept route cannot be written.
|
|
changes, newPeers := table.TakeChanges(math.MaxInt)
|
|
delete(newPeers, peerB)
|
|
|
|
err := db.WriteRouteChanges(changes, newPeers)
|
|
if !errors.Is(err, errUnknownRoutePeer) {
|
|
t.Fatalf("WriteRouteChanges = %v, want %v", err, errUnknownRoutePeer)
|
|
}
|
|
|
|
peers := readRoutePeerRows(t, db)
|
|
if len(peers) != 0 {
|
|
t.Errorf("route_peers = %+v after the failed write, want none", peers)
|
|
}
|
|
|
|
var routes int
|
|
|
|
err = db.db.QueryRowContext(t.Context(),
|
|
"SELECT COUNT(*) FROM routes").Scan(&routes)
|
|
if err != nil {
|
|
t.Fatalf("counting the routes: %v", err)
|
|
}
|
|
|
|
if routes != 0 {
|
|
t.Errorf("routes holds %d rows after the failed write, want none", routes)
|
|
}
|
|
}
|
|
|
|
// Parameters for BenchmarkReadRouteTable.
|
|
const (
|
|
// benchTablePairs is how many prefix and origin AS pairs the table holds:
|
|
// one origin AS for each prefix. Every benchTableV6Every-th prefix is an
|
|
// IPv6 /48, the others IPv4 /24s.
|
|
benchTablePairs = 200000
|
|
benchTableV6Every = 5
|
|
// benchTablePeers is how many peers there are, about as many as RIS has.
|
|
// benchTablePeersPerPair is how many of them, picked at random, carry each
|
|
// pair: RIS's 460 million routes over 1.4 million pairs.
|
|
benchTablePeers = 1400
|
|
benchTablePeersPerPair = 330
|
|
// benchTableBatch is how many changes each write holds.
|
|
benchTableBatch = 10000
|
|
)
|
|
|
|
// BenchmarkReadRouteTable times the read of a routing table of 200,000 prefix
|
|
// and origin AS pairs, each carried by 330 of 1,400 peers and keeping two
|
|
// routes. It writes that table first. It reports the bytes of the database
|
|
// file per pair. It is run by hand, not by make test, for example in the test
|
|
// image:
|
|
//
|
|
// go test -run '^$' -bench ReadRouteTable -benchtime 5x ./internal/database/
|
|
func BenchmarkReadRouteTable(b *testing.B) {
|
|
db, err := New(&config.Config{StateDir: b.TempDir()}, logger.New())
|
|
if err != nil {
|
|
b.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
//nolint:gosec // The benchmark needs the same table every run.
|
|
rng := rand.New(rand.NewPCG(1, 2))
|
|
|
|
newPeers := make(map[string]uint, benchTablePeers)
|
|
for number := range uint(benchTablePeers) {
|
|
newPeers[benchPeerAddress(number)] = number
|
|
}
|
|
|
|
for first := 0; first < benchTablePairs; first += benchTableBatch {
|
|
changes := make([]routetable.Change, 0, benchTableBatch)
|
|
for pair := first; pair < first+benchTableBatch; pair++ {
|
|
changes = append(changes, benchTableChange(rng, pair))
|
|
}
|
|
|
|
err := db.WriteRouteChanges(changes, newPeers)
|
|
if err != nil {
|
|
b.Fatalf("WriteRouteChanges: %v", err)
|
|
}
|
|
|
|
newPeers = nil
|
|
}
|
|
|
|
// The WAL is copied into the database file, whose size is reported.
|
|
err = db.Checkpoint(b.Context())
|
|
if err != nil {
|
|
b.Fatalf("Checkpoint: %v", err)
|
|
}
|
|
|
|
file, err := os.Stat(db.path)
|
|
if err != nil {
|
|
b.Fatalf("reading the database file's size: %v", err)
|
|
}
|
|
|
|
for b.Loop() {
|
|
table, err := db.ReadRouteTable(b.Context())
|
|
if err != nil {
|
|
b.Fatalf("ReadRouteTable: %v", err)
|
|
}
|
|
|
|
counts := table.Counts()
|
|
if counts.IPv4Prefixes+counts.IPv6Prefixes != benchTablePairs {
|
|
b.Fatalf("read %d prefixes, want %d",
|
|
counts.IPv4Prefixes+counts.IPv6Prefixes, benchTablePairs)
|
|
}
|
|
}
|
|
|
|
// After the loop: b.Loop drops the metrics reported before it starts.
|
|
b.ReportMetric(float64(file.Size())/benchTablePairs, "file-bytes/pair")
|
|
}
|
|
|
|
// benchPeerAddress returns the address of the peer numbered number.
|
|
func benchPeerAddress(number uint) string {
|
|
const addressesPerBlock = 256
|
|
|
|
return fmt.Sprintf("198.18.%d.%d",
|
|
number/addressesPerBlock, number%addressesPerBlock)
|
|
}
|
|
|
|
// benchTableChange returns the content of the pair numbered pair: its prefix,
|
|
// its origin AS, benchTablePeersPerPair peers picked at random, and the routes
|
|
// of two of them, each with an AS path of four ASes.
|
|
func benchTableChange(rng *rand.Rand, pair int) routetable.Change {
|
|
const (
|
|
firstOrigin = 64500
|
|
transitASes = 50000
|
|
olderSeconds = 10
|
|
)
|
|
|
|
text := fmt.Sprintf("%d.%d.%d.0/24", 1+pair>>16, (pair>>8)%256, pair%256)
|
|
if pair%benchTableV6Every == 0 {
|
|
text = fmt.Sprintf("2001:%x:%x::/48", pair>>16, pair%65536)
|
|
}
|
|
|
|
prefix := netip.MustParsePrefix(text)
|
|
origin := firstOrigin + pair%transitASes
|
|
peers := bitset.New(benchTablePeers)
|
|
|
|
for peers.Count() < benchTablePeersPerPair {
|
|
peers.Set(rng.UintN(benchTablePeers))
|
|
}
|
|
|
|
change := routetable.Change{Prefix: prefix, OriginASN: origin, Peers: peers}
|
|
|
|
// The routes of the first two peers, newest first.
|
|
for i, number := range slices.Collect(peers.EachSet())[:keptRoutesPerRow] {
|
|
change.Routes = append(change.Routes, routetable.Route{
|
|
Peer: benchPeerAddress(number),
|
|
ASPath: []int{
|
|
firstOrigin + rng.IntN(transitASes),
|
|
firstOrigin + rng.IntN(transitASes),
|
|
firstOrigin + rng.IntN(transitASes),
|
|
origin,
|
|
},
|
|
Time: tableTime(pair - i*olderSeconds),
|
|
})
|
|
}
|
|
|
|
return change
|
|
}
|