check / check (push) Waiting to run
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. It fails on a row that names a peer number missing from route_peers. Nothing uses them yet. Model: opus-5-5
527 lines
15 KiB
Go
527 lines
15 KiB
Go
package database
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"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()
|
|
|
|
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()
|
|
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()
|
|
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
|
|
}
|