Store the routing table in the database (part of #122)
check / check (push) Canceled after 0s

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
This commit is contained in:
2026-10-27 07:19:11 +00:00
parent 7a34a46c4d
commit eadad8dc83
7 changed files with 964 additions and 8 deletions
+3 -1
View File
@@ -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 (
+326
View File
@@ -0,0 +1,326 @@
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 kept route whose peer has no number in
// route_peers.
var errUnknownRoutePeer = errors.New("kept route of a peer without a number")
// 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.
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))
for address, number := range peerNumbers {
addresses[number] = address
}
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)
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.
func scanRouteRow(
rows *sql.Rows, addresses map[uint]string,
) (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)
}
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
}
+464
View File
@@ -0,0 +1,464 @@
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)
}
// checkReadTable checks that the table ReadRouteTable reads answers every read
// as want does, for the prefixes and the origin ASes the tests use, and that
// reading it recorded no change.
func checkReadTable(t *testing.T, db *Database, want *routetable.Table) {
t.Helper()
got, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
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))
}
}
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)
}
}
// 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
}
+30 -1
View File
@@ -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
);
+62 -4
View File
@@ -218,6 +218,57 @@ 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 peer set of change holds the peers' numbers from Params.PeerNumbers. A
// kept route whose peer has no number there is left out, because numbering
// the peer would record a new peer.
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, known := t.peerNumbers[r.Peer]
if !known {
continue
}
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 +464,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 +482,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.
+72
View File
@@ -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,77 @@ 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{})
}
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.