Make live route writes cheaper so the prefix handler keeps up (closes #96)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The live route tables lose their random UUID key: the row id, which the prefix-length sample draws, is now declared as the integer key. The prefix tables are keyed by the prefix, without the UUID or the duplicate index. A route update sets only the columns that change, so SQLite no longer rewrites the mask length indexes on every update, nor the origin index while the origin AS stays the same. Each kept index names the queries that use it. BenchmarkPrefixBatchOnFiveMillionRoutes, run by hand, times one 25,000-update batch on 5 million routes. Model: opus-5-5
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
@@ -293,7 +293,7 @@ func TestLiveRouteTablesHoldOneRoutePerPrefixAndPeer(t *testing.T) {
|
||||
t.Fatalf("UpsertLiveRoute(%s): %v", tt.route.Prefix, err)
|
||||
}
|
||||
|
||||
_, err = db.db.ExecContext(t.Context(), tt.insertSQL, uuid.NewString(),
|
||||
_, err = db.db.ExecContext(t.Context(), tt.insertSQL,
|
||||
tt.route.Prefix, tt.route.MaskLength, otherOriginASN,
|
||||
tt.route.PeerIP, "[]", tt.route.NextHop, ts)
|
||||
|
||||
@@ -317,7 +317,6 @@ func writeOriginRoutes(t *testing.T, db *Database, origins map[string]int) {
|
||||
|
||||
for prefix, asn := range origins {
|
||||
routes = append(routes, &LiveRoute{
|
||||
ID: uuid.New(),
|
||||
Prefix: prefix,
|
||||
MaskLength: netip.MustParsePrefix(prefix).Bits(),
|
||||
IPVersion: detectIPVersion(prefix),
|
||||
@@ -1090,3 +1089,284 @@ func TestGetRandomPrefixesByLengthSkipsWithdrawnRoutes(t *testing.T) {
|
||||
routes, kept.Prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveRouteUpdateSetsOriginWhenItChanges announces a peer's route for a
|
||||
// prefix, then again with the same origin AS and a new path, then with a new
|
||||
// origin AS. Each time the route must carry the announced origin AS, path and
|
||||
// time, and the AS details must list the prefix under that origin AS only. The
|
||||
// update sets the origin AS with a statement of its own, which changes nothing
|
||||
// while the origin AS stays the same.
|
||||
func TestLiveRouteUpdateSetsOriginWhenItChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
const firstOrigin, secondOrigin = 64500, 64501
|
||||
|
||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
for _, asn := range []int{firstOrigin, secondOrigin} {
|
||||
_, err := db.GetOrCreateASN(asn, ts)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
|
||||
}
|
||||
}
|
||||
|
||||
route := mkV4Route(t, "198.51.100.0/24", firstOrigin, ts)
|
||||
|
||||
for _, announced := range []struct {
|
||||
origin int
|
||||
path []int
|
||||
}{
|
||||
{firstOrigin, []int{firstOrigin}},
|
||||
{firstOrigin, []int{secondOrigin, firstOrigin}},
|
||||
{secondOrigin, []int{firstOrigin, secondOrigin}},
|
||||
} {
|
||||
ts = ts.Add(time.Minute)
|
||||
route.OriginASN, route.ASPath, route.LastUpdated =
|
||||
announced.origin, announced.path, ts
|
||||
|
||||
err := db.UpsertLiveRouteBatch([]*LiveRoute{route})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
||||
}
|
||||
|
||||
routes, _, err := db.GetPrefixDetails(route.Prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPrefixDetails: %v", err)
|
||||
}
|
||||
|
||||
if len(routes) != 1 || routes[0].OriginASN != announced.origin ||
|
||||
!slices.Equal(routes[0].ASPath, announced.path) ||
|
||||
!routes[0].LastUpdated.Equal(ts) {
|
||||
t.Errorf("after announcing origin AS%d, path %v at %s: routes %+v",
|
||||
announced.origin, announced.path, ts, routes)
|
||||
}
|
||||
|
||||
for _, asn := range []int{firstOrigin, secondOrigin} {
|
||||
_, prefixes, err := db.GetASDetails(asn)
|
||||
if err != nil {
|
||||
t.Fatalf("GetASDetails(%d): %v", asn, err)
|
||||
}
|
||||
|
||||
listed := len(prefixes) == 1 && prefixes[0].Prefix == route.Prefix
|
||||
if listed != (asn == announced.origin) {
|
||||
t.Errorf("after announcing origin AS%d, AS%d lists %+v",
|
||||
announced.origin, asn, prefixes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdatePrefixesBatchKeepsFirstSeen writes a prefix, then a batch with it
|
||||
// and a new prefix. Only the new prefix is counted, and the known one keeps its
|
||||
// first_seen time while its last_seen time moves on.
|
||||
func TestUpdatePrefixesBatchKeepsFirstSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
const known = "198.51.100.0/24"
|
||||
|
||||
first := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
later := first.Add(time.Hour)
|
||||
|
||||
err = db.UpdatePrefixesBatch(map[string]time.Time{known: first})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePrefixesBatch: %v", err)
|
||||
}
|
||||
|
||||
err = db.UpdatePrefixesBatch(map[string]time.Time{
|
||||
known: later, testV6Prefix: later,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePrefixesBatch: %v", err)
|
||||
}
|
||||
|
||||
stats, err := db.GetStats()
|
||||
if err != nil {
|
||||
t.Fatalf("GetStats: %v", err)
|
||||
}
|
||||
|
||||
if stats.IPv4Prefixes != 1 || stats.IPv6Prefixes != 1 {
|
||||
t.Errorf("prefix counts = (v4 %d, v6 %d), want (1, 1)",
|
||||
stats.IPv4Prefixes, stats.IPv6Prefixes)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
query, prefix string
|
||||
firstSeen, lastSeen time.Time
|
||||
}{
|
||||
{
|
||||
query: "SELECT first_seen, last_seen FROM prefixes_v4 WHERE prefix = ?",
|
||||
prefix: known, firstSeen: first, lastSeen: later,
|
||||
},
|
||||
{
|
||||
query: "SELECT first_seen, last_seen FROM prefixes_v6 WHERE prefix = ?",
|
||||
prefix: testV6Prefix, firstSeen: later, lastSeen: later,
|
||||
},
|
||||
} {
|
||||
var firstSeen, lastSeen time.Time
|
||||
|
||||
err := db.db.QueryRowContext(t.Context(), tt.query, tt.prefix).
|
||||
Scan(&firstSeen, &lastSeen)
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s: %v", tt.prefix, err)
|
||||
}
|
||||
|
||||
if !firstSeen.Equal(tt.firstSeen) || !lastSeen.Equal(tt.lastSeen) {
|
||||
t.Errorf("%s: first seen %s, last seen %s; want %s, %s", tt.prefix,
|
||||
firstSeen, lastSeen, tt.firstSeen, tt.lastSeen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parameters for BenchmarkPrefixBatchOnFiveMillionRoutes.
|
||||
const (
|
||||
// benchPeers and benchPrefixes size the table the batches are written to:
|
||||
// every peer has a route for every prefix, 5 million routes in all.
|
||||
benchPeers = 10
|
||||
benchPrefixes = 500000
|
||||
// benchBatchSize is how many updates each batch holds, as many as a full
|
||||
// prefix handler batch (prefixBatchSize in internal/routewatch).
|
||||
benchBatchSize = 25000
|
||||
// benchWithdrawalEvery makes every fifth update of a timed batch a
|
||||
// withdrawal; the others are announcements.
|
||||
benchWithdrawalEvery = 5
|
||||
// benchOrigins is how many origin ASes the prefixes are spread over.
|
||||
benchOrigins = 50000
|
||||
// benchFirstASN is the lowest AS number in the benchmark's AS paths.
|
||||
benchFirstASN = 64500
|
||||
// benchMaskLength is the mask length of every prefix in the benchmark.
|
||||
benchMaskLength = 24
|
||||
)
|
||||
|
||||
// BenchmarkPrefixBatchOnFiveMillionRoutes times the writes the prefix handler
|
||||
// makes for one full batch, 25,000 announcements and withdrawals of routes
|
||||
// picked at random, to a database that already holds 5 million live routes:
|
||||
// 500,000 IPv4 prefixes, each from 10 peers. It builds that database first,
|
||||
// one batch at a time, which takes some minutes and a few GB of disk under
|
||||
// TMPDIR. It is run by hand, not by make test, for example in the test image:
|
||||
//
|
||||
// go test -run '^$' -bench FiveMillion -benchtime 20x ./internal/database/
|
||||
func BenchmarkPrefixBatchOnFiveMillionRoutes(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 batches every run.
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
// Each peer sends its whole table, as when its session comes up.
|
||||
for peer := range benchPeers {
|
||||
for first := 0; first < benchPrefixes; first += benchBatchSize {
|
||||
routes := make([]*LiveRoute, 0, benchBatchSize)
|
||||
for prefix := first; prefix < first+benchBatchSize; prefix++ {
|
||||
routes = append(routes, benchRoute(rng, prefix, peer, ts))
|
||||
}
|
||||
|
||||
writeBenchBatch(b, db, routes, nil, ts)
|
||||
}
|
||||
}
|
||||
|
||||
for b.Loop() {
|
||||
ts = ts.Add(time.Second)
|
||||
|
||||
var (
|
||||
routes []*LiveRoute
|
||||
withdrawals []LiveRouteDeletion
|
||||
)
|
||||
|
||||
for i := range benchBatchSize {
|
||||
route := benchRoute(rng, rng.IntN(benchPrefixes),
|
||||
rng.IntN(benchPeers), ts)
|
||||
if i%benchWithdrawalEvery == 0 {
|
||||
withdrawals = append(withdrawals, LiveRouteDeletion{
|
||||
Prefix: route.Prefix, PeerIP: route.PeerIP,
|
||||
IPVersion: ipVersionV4,
|
||||
})
|
||||
} else {
|
||||
routes = append(routes, route)
|
||||
}
|
||||
}
|
||||
|
||||
writeBenchBatch(b, db, routes, withdrawals, ts)
|
||||
}
|
||||
}
|
||||
|
||||
// benchRoute returns the route peer has for the prefix numbered prefix, a /24
|
||||
// from 1.0.0.0/24 on, with a new AS path of three to six ASes from the peer's
|
||||
// AS to the prefix's origin AS.
|
||||
func benchRoute(rng *rand.Rand, prefix, peer int, ts time.Time) *LiveRoute {
|
||||
const minPathLength, extraPathLength = 3, 4
|
||||
|
||||
origin := benchFirstASN + prefix%benchOrigins
|
||||
pathLength := minPathLength + rng.IntN(extraPathLength)
|
||||
|
||||
path := make([]int, 0, pathLength)
|
||||
path = append(path, benchFirstASN+peer)
|
||||
|
||||
for len(path) < pathLength-1 {
|
||||
path = append(path, benchFirstASN+rng.IntN(benchOrigins))
|
||||
}
|
||||
|
||||
path = append(path, origin)
|
||||
peerIP := fmt.Sprintf("198.51.100.%d", 1+peer)
|
||||
|
||||
return &LiveRoute{
|
||||
Prefix: fmt.Sprintf("%d.%d.%d.0/%d",
|
||||
1+prefix>>16, (prefix>>8)%256, prefix%256, benchMaskLength),
|
||||
MaskLength: benchMaskLength,
|
||||
IPVersion: ipVersionV4,
|
||||
OriginASN: origin,
|
||||
PeerIP: peerIP,
|
||||
ASPath: path,
|
||||
NextHop: peerIP,
|
||||
LastUpdated: ts,
|
||||
}
|
||||
}
|
||||
|
||||
// writeBenchBatch writes what the prefix handler writes for a batch: the
|
||||
// announced routes, the withdrawals, and the last-seen time of every prefix
|
||||
// the batch names.
|
||||
func writeBenchBatch(
|
||||
b *testing.B, db *Database, routes []*LiveRoute,
|
||||
withdrawals []LiveRouteDeletion, ts time.Time,
|
||||
) {
|
||||
b.Helper()
|
||||
|
||||
prefixes := make(map[string]time.Time, len(routes)+len(withdrawals))
|
||||
for _, route := range routes {
|
||||
prefixes[route.Prefix] = ts
|
||||
}
|
||||
|
||||
for _, withdrawal := range withdrawals {
|
||||
prefixes[withdrawal.Prefix] = ts
|
||||
}
|
||||
|
||||
err := db.UpsertLiveRouteBatch(routes)
|
||||
if err != nil {
|
||||
b.Fatalf("UpsertLiveRouteBatch: %v", err)
|
||||
}
|
||||
|
||||
err = db.DeleteLiveRouteBatch(withdrawals)
|
||||
if err != nil {
|
||||
b.Fatalf("DeleteLiveRouteBatch: %v", err)
|
||||
}
|
||||
|
||||
err = db.UpdatePrefixesBatch(prefixes)
|
||||
if err != nil {
|
||||
b.Fatalf("UpdatePrefixesBatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user