Store the routing table in the database (part of #122) #186
@@ -33,6 +33,11 @@ tested.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-27: the database keeps a copy of the whole routing table in a new
|
||||
`routes` table, one row per prefix and origin AS. The peers' numbers are in a
|
||||
new `route_peers` table. One function writes the pairs that changed in memory,
|
||||
and another reads the whole table back into memory at startup. Nothing uses
|
||||
them yet (part of #122)
|
||||
- 2026-10-27: a new package, `internal/routetable`, holds the whole routing
|
||||
table in memory. For each prefix and origin AS it keeps the peers that carry
|
||||
it, one bit per peer, and the two newest of their routes. Nothing uses it yet
|
||||
@@ -249,5 +254,5 @@ tested.
|
||||
# Future Steps
|
||||
|
||||
- the rest of https://git.eeqj.de/sneak/routewatch/issues/122, one PR each: the
|
||||
routing table in the database, the prefix handler writing to it, the pages and
|
||||
the API reading from it, then removing the per-peer route tables
|
||||
prefix handler writing to the routing table, the pages and the API reading
|
||||
from it, then removing the per-peer route tables
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Package database provides SQLite storage for BGP routing data including ASNs,
|
||||
// prefixes, announcements, peerings, and live route tables.
|
||||
// prefixes, announcements, peerings, and live route tables. Its routes table
|
||||
// holds a copy of the whole routing table that internal/routetable keeps in
|
||||
// memory.
|
||||
package database
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/bits-and-blooms/bitset"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||
)
|
||||
|
||||
// keptRoutesPerRow is how many kept routes a row of the routes table has
|
||||
// columns for: the newest and the older one.
|
||||
const keptRoutesPerRow = 2
|
||||
|
||||
// errUnknownRoutePeer is returned for a peer that route_peers lacks: on a
|
||||
// write, the peer of a kept route; on a read, a peer number in a row.
|
||||
var errUnknownRoutePeer = errors.New("peer not in route_peers")
|
||||
|
||||
// The statements of the routes and route_peers tables (schema.sql).
|
||||
const (
|
||||
insertRoutePeerSQL = `INSERT INTO route_peers (number, address)
|
||||
VALUES (?, ?)`
|
||||
routePeerNumberSQL = `SELECT number FROM route_peers WHERE address = ?`
|
||||
routePeersSQL = `SELECT number, address FROM route_peers`
|
||||
|
||||
upsertRouteSQL = `INSERT INTO routes (prefix, origin_asn, peers,
|
||||
newest_peer, newest_as_path, newest_time,
|
||||
older_peer, older_as_path, older_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (prefix, origin_asn) DO UPDATE SET peers = excluded.peers,
|
||||
newest_peer = excluded.newest_peer,
|
||||
newest_as_path = excluded.newest_as_path,
|
||||
newest_time = excluded.newest_time,
|
||||
older_peer = excluded.older_peer,
|
||||
older_as_path = excluded.older_as_path,
|
||||
older_time = excluded.older_time`
|
||||
deleteRouteSQL = `DELETE FROM routes WHERE prefix = ? AND origin_asn = ?`
|
||||
routesSQL = `SELECT prefix, origin_asn, peers,
|
||||
newest_peer, newest_as_path, newest_time,
|
||||
older_peer, older_as_path, older_time FROM routes`
|
||||
)
|
||||
|
||||
// WriteRouteChanges writes, in one transaction, what one call of the routing
|
||||
// table's TakeChanges returned. It stores each new peer with its number, writes
|
||||
// the row of each changed prefix and origin AS pair, and deletes the row of
|
||||
// each removed one. Route times are stored to the second, as the feed gives
|
||||
// them.
|
||||
//
|
||||
// When it fails, it writes nothing. The caller then writes these changes and
|
||||
// peers again, because later changes can name the peers it did not store.
|
||||
func (d *Database) WriteRouteChanges(
|
||||
changes []routetable.Change, newPeers map[string]uint,
|
||||
) error {
|
||||
if len(changes) == 0 && len(newPeers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
d.lock("WriteRouteChanges")
|
||||
defer d.unlock()
|
||||
|
||||
tx, err := d.beginTx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer d.rollback(tx)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// The new peers first, so that the rows can look up their numbers.
|
||||
for address, number := range newPeers {
|
||||
_, err := tx.ExecContext(ctx, insertRoutePeerSQL, number, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store peer %s: %w", address, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = writeRouteRows(ctx, tx, changes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeRouteRows upserts the row of each change in tx, or deletes it for a
|
||||
// removed pair.
|
||||
func writeRouteRows(
|
||||
ctx context.Context, tx *loggingTx, changes []routetable.Change,
|
||||
) error {
|
||||
upsert, err := tx.PrepareContext(ctx, upsertRouteSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare route upsert: %w", err)
|
||||
}
|
||||
defer func() { _ = upsert.Close() }()
|
||||
|
||||
del, err := tx.PrepareContext(ctx, deleteRouteSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare route delete: %w", err)
|
||||
}
|
||||
defer func() { _ = del.Close() }()
|
||||
|
||||
peerNumber, err := tx.PrepareContext(ctx, routePeerNumberSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare peer number lookup: %w", err)
|
||||
}
|
||||
defer func() { _ = peerNumber.Close() }()
|
||||
|
||||
for _, change := range changes {
|
||||
prefix := change.Prefix.String()
|
||||
|
||||
if change.Removed {
|
||||
_, err := del.ExecContext(ctx, prefix, change.OriginASN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete route %s: %w", prefix, err)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
row, err := routeRow(ctx, peerNumber, change)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write route %s: %w", prefix, err)
|
||||
}
|
||||
|
||||
_, err = upsert.ExecContext(ctx, row...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write route %s: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// routeRow returns the values of the routes row of change, in the order of
|
||||
// upsertRouteSQL. peerNumber looks up a peer's number by its address. The
|
||||
// table keeps at most keptRoutesPerRow routes of a pair.
|
||||
func routeRow(
|
||||
ctx context.Context, peerNumber *sql.Stmt, change routetable.Change,
|
||||
) ([]any, error) {
|
||||
peers, err := change.Peers.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := []any{change.Prefix.String(), change.OriginASN, peers}
|
||||
|
||||
for i := range keptRoutesPerRow {
|
||||
if i >= len(change.Routes) {
|
||||
row = append(row, nil, nil, nil)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
r := change.Routes[i]
|
||||
|
||||
var number uint
|
||||
|
||||
err := peerNumber.QueryRowContext(ctx, r.Peer).Scan(&number)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnknownRoutePeer, r.Peer)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path, err := json.Marshal(r.ASPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row = append(row, number, string(path), r.Time.Unix())
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ReadRouteTable returns the routing table that the routes and route_peers
|
||||
// tables hold, as startup reads it. It reads the peers first, so that the
|
||||
// table keeps their numbers, then loads each row into the table as it reads
|
||||
// it. The two reads are separate, so nothing may write the tables meanwhile.
|
||||
// It fails on a row that names a peer number route_peers lacks.
|
||||
func (d *Database) ReadRouteTable(ctx context.Context) (*routetable.Table, error) {
|
||||
peerNumbers, err := d.readRoutePeers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addresses := make(map[uint]string, len(peerNumbers))
|
||||
numbers := new(bitset.BitSet)
|
||||
|
||||
for address, number := range peerNumbers {
|
||||
addresses[number] = address
|
||||
numbers.Set(number)
|
||||
}
|
||||
|
||||
table := routetable.New(routetable.Params{PeerNumbers: peerNumbers})
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, routesSQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read routes: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
change, err := scanRouteRow(rows, addresses, numbers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
table.Load(change)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read routes: %w", err)
|
||||
}
|
||||
|
||||
return table, nil
|
||||
}
|
||||
|
||||
// readRoutePeers returns the number of each peer in route_peers, by address.
|
||||
func (d *Database) readRoutePeers(ctx context.Context) (map[string]uint, error) {
|
||||
rows, err := d.db.QueryContext(ctx, routePeersSQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read route peers: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
peerNumbers := make(map[string]uint)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
number uint
|
||||
address string
|
||||
)
|
||||
|
||||
err := rows.Scan(&number, &address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read a route peer: %w", err)
|
||||
}
|
||||
|
||||
peerNumbers[address] = number
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read route peers: %w", err)
|
||||
}
|
||||
|
||||
return peerNumbers, nil
|
||||
}
|
||||
|
||||
// keptRouteColumns are the three columns of one kept route in a routes row.
|
||||
type keptRouteColumns struct {
|
||||
peer sql.Null[uint]
|
||||
asPath sql.NullString
|
||||
time sql.NullInt64
|
||||
}
|
||||
|
||||
// scanRouteRow returns the content of the routes row rows is at, with each kept
|
||||
// route's peer named by its address in addresses. It fails when the row names a
|
||||
// peer number that addresses lacks. numbers holds the numbers of addresses as a
|
||||
// set, against which a row's whole peer set is checked at once.
|
||||
func scanRouteRow(
|
||||
rows *sql.Rows, addresses map[uint]string, numbers *bitset.BitSet,
|
||||
) (routetable.Change, error) {
|
||||
var (
|
||||
change routetable.Change
|
||||
prefix string
|
||||
peers []byte
|
||||
kept [keptRoutesPerRow]keptRouteColumns
|
||||
)
|
||||
|
||||
err := rows.Scan(&prefix, &change.OriginASN, &peers,
|
||||
&kept[0].peer, &kept[0].asPath, &kept[0].time,
|
||||
&kept[1].peer, &kept[1].asPath, &kept[1].time)
|
||||
if err != nil {
|
||||
return change, fmt.Errorf("failed to read a route: %w", err)
|
||||
}
|
||||
|
||||
change.Prefix, err = netip.ParsePrefix(prefix)
|
||||
if err != nil {
|
||||
return change, fmt.Errorf("failed to read route %s: %w", prefix, err)
|
||||
}
|
||||
|
||||
change.Peers = new(bitset.BitSet)
|
||||
|
||||
err = change.Peers.UnmarshalBinary(peers)
|
||||
if err != nil {
|
||||
return change, fmt.Errorf("failed to read the peers of route %s: %w",
|
||||
prefix, err)
|
||||
}
|
||||
|
||||
if !numbers.IsSuperSet(change.Peers) {
|
||||
unknown, _ := change.Peers.Difference(numbers).NextSet(0)
|
||||
|
||||
return change, fmt.Errorf("%w: number %d in the peers of route %s",
|
||||
errUnknownRoutePeer, unknown, prefix)
|
||||
}
|
||||
|
||||
for _, columns := range kept {
|
||||
if !columns.peer.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
address, known := addresses[columns.peer.V]
|
||||
if !known {
|
||||
return change, fmt.Errorf("%w: number %d in route %s",
|
||||
errUnknownRoutePeer, columns.peer.V, prefix)
|
||||
}
|
||||
|
||||
r := routetable.Route{
|
||||
Peer: address,
|
||||
Time: time.Unix(columns.time.Int64, 0).UTC(),
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(columns.asPath.String), &r.ASPath)
|
||||
if err != nil {
|
||||
return change, fmt.Errorf("failed to read an AS path of route %s: %w",
|
||||
prefix, err)
|
||||
}
|
||||
|
||||
change.Routes = append(change.Routes, r)
|
||||
}
|
||||
|
||||
return change, nil
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
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
|
||||
}
|
||||
@@ -146,4 +146,33 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_v6_origin_prefix ON live_routes_v6(or
|
||||
-- last_updated: the oldest and newest route times in /api/v1/stats and
|
||||
-- /status.json.
|
||||
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_last_updated ON live_routes_v4(last_updated);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated);
|
||||
|
||||
-- The whole routing table, as internal/routetable keeps it in memory: one row
|
||||
-- for each prefix and origin AS that some peer carries. Startup reads every
|
||||
-- row, and the rows of the pairs that change are written back.
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
prefix TEXT NOT NULL, -- in the text form Go's net/netip prints
|
||||
origin_asn INTEGER NOT NULL,
|
||||
-- The peers whose current route to the prefix has this origin AS: a set
|
||||
-- of their numbers in route_peers, as the bitset package's MarshalBinary
|
||||
-- writes it.
|
||||
peers BLOB NOT NULL,
|
||||
-- The newest of those routes and the one before it, the two kept in full.
|
||||
-- Each has its peer's number in route_peers, its AS path as a JSON array,
|
||||
-- and its time in seconds since 1970, UTC. A route that is not kept has
|
||||
-- NULL in its three columns.
|
||||
newest_peer INTEGER,
|
||||
newest_as_path TEXT,
|
||||
newest_time INTEGER,
|
||||
older_peer INTEGER,
|
||||
older_as_path TEXT,
|
||||
older_time INTEGER,
|
||||
PRIMARY KEY (prefix, origin_asn)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- The peers of the routes table, each with the number it has there.
|
||||
CREATE TABLE IF NOT EXISTS route_peers (
|
||||
number INTEGER PRIMARY KEY,
|
||||
address TEXT NOT NULL UNIQUE
|
||||
);
|
||||
@@ -218,6 +218,53 @@ func (t *Table) PeerDown(peer string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load puts one prefix and origin AS pair that the database holds into the
|
||||
// table, as startup does with each row it reads. The change's peers join the
|
||||
// pair's peer set, and its routes are kept as an announcement's would be.
|
||||
// Unlike an update, Load records no change, so TakeChanges does not hand the
|
||||
// pair back to be written again. Loading a pair twice changes nothing.
|
||||
//
|
||||
// The caller makes sure that every peer change names, in its peer set or in
|
||||
// its routes, has a number in Params.PeerNumbers.
|
||||
func (t *Table) Load(change Change) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// A removed pair has no peers, and a pair without peers is not in the
|
||||
// table.
|
||||
if change.Peers == nil || change.Peers.None() || !change.Prefix.IsValid() {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := change.Prefix.Masked()
|
||||
o := t.findOrAddOrigin(prefix, change.OriginASN)
|
||||
wasWithoutKeptRoute := o.withoutKeptRoute()
|
||||
|
||||
for peer := range change.Peers.EachSet() {
|
||||
if !o.peers.Test(peer) {
|
||||
o.peers.Set(peer)
|
||||
|
||||
t.familyOf(prefix.Addr()).routes++
|
||||
t.peerPrefixes[peer]++
|
||||
}
|
||||
}
|
||||
|
||||
// Oldest first, as they were announced, so that two routes with the same
|
||||
// time are kept in the order they had.
|
||||
for _, r := range slices.Backward(change.Routes) {
|
||||
peer := t.peerNumbers[r.Peer]
|
||||
|
||||
t.dropKeptRoute(o, peer)
|
||||
t.keepIfNewest(o, route{
|
||||
peer: peer,
|
||||
path: slices.Clone(r.ASPath),
|
||||
time: r.Time,
|
||||
})
|
||||
}
|
||||
|
||||
t.countOriginWithoutKeptRoute(o, wasWithoutKeptRoute)
|
||||
}
|
||||
|
||||
// TakeChanges returns the prefix and origin AS pairs that changed since it last
|
||||
// ran, in prefix order, each with its current content. It also returns the
|
||||
// peers numbered since then, by address. It then clears both records.
|
||||
@@ -413,6 +460,17 @@ func (t *Table) recordChange(
|
||||
) {
|
||||
t.changed[pair{prefix: prefix, asn: o.asn}] = struct{}{}
|
||||
|
||||
t.countOriginWithoutKeptRoute(o, wasWithoutKeptRoute)
|
||||
|
||||
if o.peers.None() {
|
||||
t.removeOrigin(prefix, o)
|
||||
}
|
||||
}
|
||||
|
||||
// countOriginWithoutKeptRoute brings the count of origins without a kept route
|
||||
// up to date after a change to o. wasWithoutKeptRoute says whether o was such
|
||||
// an origin before the change.
|
||||
func (t *Table) countOriginWithoutKeptRoute(o *origin, wasWithoutKeptRoute bool) {
|
||||
if wasWithoutKeptRoute {
|
||||
t.originsWithoutKeptRoute--
|
||||
}
|
||||
@@ -420,10 +478,6 @@ func (t *Table) recordChange(
|
||||
if o.withoutKeptRoute() {
|
||||
t.originsWithoutKeptRoute++
|
||||
}
|
||||
|
||||
if o.peers.None() {
|
||||
t.removeOrigin(prefix, o)
|
||||
}
|
||||
}
|
||||
|
||||
// countKeptTime adds delta to the number of kept routes with time at.
|
||||
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
peerA = "192.0.2.1"
|
||||
peerB = "192.0.2.2"
|
||||
peerC = "2001:db8::3"
|
||||
peerD = "2001:db8::4"
|
||||
|
||||
asA = 64500
|
||||
asB = 64501
|
||||
@@ -475,6 +476,52 @@ func TestTakeChangesHoldsOnlyPairsThatChanged(t *testing.T) {
|
||||
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{})
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user