check / check (push) Canceled after 0s
When the delete of a peer's live routes after its session went down failed, the down was dropped and the routes stayed. The prefix handler now keeps such a peer, once, with the updates it sent after the down, for the next flush, which retries the delete before it writes them; each failure is logged. At most the last 25,000 of those updates are kept, outside the batch, so they do not start a flush. The delete runs in chunks of at most 10,000 routes per table, taken in prefix order through the peer's key, each chunk in its own transaction, so the database lock is released between chunks and a failure undoes at most one chunk. Model: opus-5-5
837 lines
23 KiB
Go
837 lines
23 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// testV4PeerIP is the peer the IPv4 routes in these tests are learned from.
|
|
const testV4PeerIP = "192.0.2.1"
|
|
|
|
// testV6Prefix is the prefix of the IPv6 routes in these tests.
|
|
const testV6Prefix = "2001:db8::/32"
|
|
|
|
// mkV4Route builds an IPv4 live route with its mask length taken from the
|
|
// prefix.
|
|
func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute {
|
|
t.Helper()
|
|
|
|
maskLength, err := prefixMaskLength(prefix)
|
|
if err != nil {
|
|
t.Fatalf("prefixMaskLength(%s): %v", prefix, err)
|
|
}
|
|
|
|
return &LiveRoute{
|
|
Prefix: prefix,
|
|
MaskLength: maskLength,
|
|
IPVersion: ipVersionV4,
|
|
OriginASN: asn,
|
|
PeerIP: testV4PeerIP,
|
|
ASPath: []int{asn},
|
|
NextHop: "192.0.2.254",
|
|
LastUpdated: ts,
|
|
}
|
|
}
|
|
|
|
// mkV6Route builds an IPv6 live route.
|
|
func mkV6Route(prefix string, asn int, ts time.Time) *LiveRoute {
|
|
return &LiveRoute{
|
|
Prefix: prefix,
|
|
MaskLength: 32,
|
|
IPVersion: ipVersionV6,
|
|
OriginASN: asn,
|
|
PeerIP: "2001:db8::1",
|
|
ASPath: []int{asn},
|
|
NextHop: "2001:db8::ffff",
|
|
LastUpdated: ts,
|
|
}
|
|
}
|
|
|
|
// TestLiveCountsTrackWritesInRealtime checks that the stats counts start at
|
|
// zero, reflect each write the moment it commits (no recompute, no timer), do
|
|
// not move when a route is merely re-announced, and drop when a route is
|
|
// deleted. These counts are what /api/v1/stats reports; before this change the
|
|
// endpoint recomputed them with a COUNT(*) over each table on every request.
|
|
func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
|
|
db, err := New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
ctx := context.Background()
|
|
|
|
empty, err := db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext on empty database: %v", err)
|
|
}
|
|
|
|
if empty.ASNs != 0 || empty.Prefixes != 0 || empty.Peerings != 0 ||
|
|
empty.Peers != 0 || empty.LiveRoutes != 0 {
|
|
t.Fatalf("empty database counts nonzero: %+v", empty)
|
|
}
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
writeASNsPrefixesAndPeers(t, db, ts)
|
|
|
|
routes := []*LiveRoute{
|
|
mkV4Route(t, "198.51.100.0/24", 64500, ts),
|
|
mkV4Route(t, "203.0.113.0/24", 64501, ts.Add(time.Minute)),
|
|
mkV6Route(testV6Prefix, 64502, ts.Add(2*time.Minute)),
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
stats, err := db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
assertCounts(t, "after inserts", stats, wantCounts{
|
|
asns: 2, prefixes: 2, peerings: 1, peers: 1,
|
|
ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3,
|
|
})
|
|
|
|
// Re-announcing the same routes is an update, not an insert: counts hold.
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch (re-announce): %v", err)
|
|
}
|
|
|
|
stats, err = db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
assertCounts(t, "after re-announce", stats, wantCounts{
|
|
asns: 2, prefixes: 2, peerings: 1, peers: 1,
|
|
ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3,
|
|
})
|
|
|
|
// A withdrawal removes one route.
|
|
err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{
|
|
{Prefix: "203.0.113.0/24", PeerIP: testV4PeerIP, IPVersion: ipVersionV4},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DeleteLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
stats, err = db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
assertCounts(t, "after delete", stats, wantCounts{
|
|
asns: 2, prefixes: 2, peerings: 1, peers: 1,
|
|
ipv4Routes: 1, ipv6Routes: 1, liveRoutes: 2,
|
|
})
|
|
}
|
|
|
|
// writeASNsPrefixesAndPeers writes two ASNs, two prefixes, one peer and one
|
|
// peering: the rows besides live routes that the stats count.
|
|
func writeASNsPrefixesAndPeers(t *testing.T, db *Database, ts time.Time) {
|
|
t.Helper()
|
|
|
|
err := db.GetOrCreateASNBatch(map[int]time.Time{64500: ts, 64501: ts})
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASNBatch: %v", err)
|
|
}
|
|
|
|
err = db.UpdatePrefixesBatch(map[string]time.Time{
|
|
"198.51.100.0/24": ts,
|
|
testV6Prefix: ts,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdatePrefixesBatch: %v", err)
|
|
}
|
|
|
|
err = db.UpdatePeerBatch(map[string]PeerUpdate{
|
|
testV4PeerIP: {
|
|
PeerIP: testV4PeerIP, PeerASN: 64500,
|
|
MessageType: "UPDATE", Timestamp: ts,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdatePeerBatch: %v", err)
|
|
}
|
|
|
|
err = db.RecordPeering(64500, 64501, ts)
|
|
if err != nil {
|
|
t.Fatalf("RecordPeering: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestLiveCountsSeededFromDatabaseAtStartup writes rows, reopens the same
|
|
// database file, and checks the counts and the prefix distribution come back
|
|
// from the seed scan rather than starting at zero.
|
|
func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
|
|
db, err := New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
err = db.GetOrCreateASNBatch(map[int]time.Time{64500: ts, 64501: ts, 64502: ts})
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASNBatch: %v", err)
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{
|
|
mkV4Route(t, "198.51.100.0/24", 64500, ts),
|
|
mkV6Route(testV6Prefix, 64502, ts),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
err = db.Close()
|
|
if err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
reopened, err := New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to reopen database: %v", err)
|
|
}
|
|
defer func() { _ = reopened.Close() }()
|
|
|
|
stats, err := reopened.GetStatsContext(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext after reopen: %v", err)
|
|
}
|
|
|
|
if stats.ASNs != 3 {
|
|
t.Errorf("seeded ASNs = %d, want 3", stats.ASNs)
|
|
}
|
|
|
|
if stats.IPv4Routes != 1 || stats.IPv6Routes != 1 || stats.LiveRoutes != 2 {
|
|
t.Errorf("seeded routes = (v4 %d, v6 %d, total %d), want (1, 1, 2)",
|
|
stats.IPv4Routes, stats.IPv6Routes, stats.LiveRoutes)
|
|
}
|
|
|
|
assertDistribution(t, "seeded IPv4 distribution", stats.IPv4PrefixDistribution,
|
|
[]PrefixDistribution{{MaskLength: 24, Count: 1}})
|
|
assertDistribution(t, "seeded IPv6 distribution", stats.IPv6PrefixDistribution,
|
|
[]PrefixDistribution{{MaskLength: 32, Count: 1}})
|
|
}
|
|
|
|
// TestPrefixDistributionTracksWrites checks that the prefix distribution the
|
|
// stats read reports stays exact across each kind of live-route write, and that
|
|
// after every step it equals what the distribution query reads from the route
|
|
// tables. The steps run once through the batch methods the prefix handler uses
|
|
// and once through the single-route methods.
|
|
func TestPrefixDistributionTracksWrites(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
steps := prefixDistributionSteps(t)
|
|
|
|
for _, batch := range []bool{true, false} {
|
|
name := "single-route writes"
|
|
if batch {
|
|
name = "batch writes"
|
|
}
|
|
|
|
t.Run(name, func(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() }()
|
|
|
|
ctx := context.Background()
|
|
|
|
for _, step := range steps {
|
|
err := announceRoutes(db, batch, step.announce)
|
|
if err != nil {
|
|
t.Fatalf("%s: announce: %v", step.name, err)
|
|
}
|
|
|
|
err = withdrawRoutes(db, batch, step.withdraw)
|
|
if err != nil {
|
|
t.Fatalf("%s: withdraw: %v", step.name, err)
|
|
}
|
|
|
|
stats, err := db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("%s: GetStatsContext: %v", step.name, err)
|
|
}
|
|
|
|
queryV4, queryV6, err := db.GetPrefixDistributionContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("%s: GetPrefixDistributionContext: %v", step.name, err)
|
|
}
|
|
|
|
assertDistribution(t, step.name+": IPv4 distribution",
|
|
stats.IPv4PrefixDistribution, step.wantV4)
|
|
assertDistribution(t, step.name+": IPv6 distribution",
|
|
stats.IPv6PrefixDistribution, step.wantV6)
|
|
assertDistribution(t, step.name+": IPv4 distribution query",
|
|
queryV4, step.wantV4)
|
|
assertDistribution(t, step.name+": IPv6 distribution query",
|
|
queryV6, step.wantV6)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// prefixDistributionStep is one step of TestPrefixDistributionTracksWrites:
|
|
// the routes it announces, then the routes it withdraws, then the
|
|
// distributions both the stats and the distribution query must report.
|
|
type prefixDistributionStep struct {
|
|
name string
|
|
announce []*LiveRoute
|
|
withdraw []*LiveRoute
|
|
wantV4 []PrefixDistribution
|
|
wantV6 []PrefixDistribution
|
|
}
|
|
|
|
// prefixDistributionSteps returns the steps of
|
|
// TestPrefixDistributionTracksWrites, in the order they run.
|
|
func prefixDistributionSteps(t *testing.T) []prefixDistributionStep {
|
|
t.Helper()
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
shared := mkV4Route(t, "198.51.100.0/24", 64500, ts)
|
|
sharedSecondPeer := mkV4Route(t, "198.51.100.0/24", 64500, ts)
|
|
sharedSecondPeer.PeerIP = "192.0.2.2"
|
|
other := mkV4Route(t, "203.0.113.0/24", 64501, ts)
|
|
wide := mkV4Route(t, "172.16.0.0/16", 64502, ts)
|
|
v6 := mkV6Route(testV6Prefix, 64503, ts)
|
|
v6SecondPeer := mkV6Route(testV6Prefix, 64503, ts)
|
|
v6SecondPeer.PeerIP = "2001:db8::2"
|
|
|
|
all := []PrefixDistribution{{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 2}}
|
|
v6Only := []PrefixDistribution{{MaskLength: 32, Count: 1}}
|
|
|
|
return []prefixDistributionStep{
|
|
{
|
|
name: "new routes",
|
|
announce: []*LiveRoute{shared, other, wide, v6},
|
|
wantV4: all, wantV6: v6Only,
|
|
},
|
|
{
|
|
name: "re-announcement",
|
|
announce: []*LiveRoute{shared, other, wide, v6},
|
|
wantV4: all, wantV6: v6Only,
|
|
},
|
|
{
|
|
name: "second peer announces a prefix that has a live route",
|
|
announce: []*LiveRoute{sharedSecondPeer, v6SecondPeer},
|
|
wantV4: all, wantV6: v6Only,
|
|
},
|
|
{
|
|
name: "withdrawal of a route that is not the last for its prefix",
|
|
withdraw: []*LiveRoute{shared, v6},
|
|
wantV4: all, wantV6: v6Only,
|
|
},
|
|
{
|
|
name: "withdrawal of the last route for a prefix",
|
|
withdraw: []*LiveRoute{sharedSecondPeer, v6SecondPeer},
|
|
wantV4: []PrefixDistribution{
|
|
{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 1},
|
|
},
|
|
},
|
|
{
|
|
name: "withdrawal of every remaining route",
|
|
withdraw: []*LiveRoute{other, wide},
|
|
},
|
|
{
|
|
name: "two peers announce a new prefix together",
|
|
announce: []*LiveRoute{shared, sharedSecondPeer, v6, v6SecondPeer},
|
|
wantV4: []PrefixDistribution{{MaskLength: 24, Count: 1}},
|
|
wantV6: v6Only,
|
|
},
|
|
{
|
|
name: "both routes for a prefix withdrawn together",
|
|
withdraw: []*LiveRoute{shared, sharedSecondPeer, v6, v6SecondPeer},
|
|
},
|
|
// The feed often withdraws a route that is not live. That must not take
|
|
// the prefix out of the distribution. A count wrongly taken below zero is
|
|
// left out of the answer, so the next step shows it: its announcement
|
|
// at the same mask length would then not be counted.
|
|
{
|
|
name: "withdrawal of routes that are not live",
|
|
withdraw: []*LiveRoute{other, v6},
|
|
},
|
|
{
|
|
name: "announcement after a withdrawal of routes that are not live",
|
|
announce: []*LiveRoute{other, v6},
|
|
wantV4: []PrefixDistribution{{MaskLength: 24, Count: 1}},
|
|
wantV6: v6Only,
|
|
},
|
|
}
|
|
}
|
|
|
|
// announceRoutes writes routes in one UpsertLiveRouteBatch, or with one
|
|
// UpsertLiveRoute each.
|
|
func announceRoutes(db *Database, batch bool, routes []*LiveRoute) error {
|
|
if batch {
|
|
return db.UpsertLiveRouteBatch(routes)
|
|
}
|
|
|
|
for _, route := range routes {
|
|
err := db.UpsertLiveRoute(route)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// withdrawRoutes removes routes in one DeleteLiveRouteBatch, or with one
|
|
// DeleteLiveRoute each.
|
|
func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error {
|
|
if !batch {
|
|
for _, route := range routes {
|
|
err := db.DeleteLiveRoute(route.Prefix, route.PeerIP)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
deletions := make([]LiveRouteDeletion, 0, len(routes))
|
|
for _, route := range routes {
|
|
deletions = append(deletions, LiveRouteDeletion{
|
|
Prefix: route.Prefix, PeerIP: route.PeerIP, IPVersion: route.IPVersion,
|
|
})
|
|
}
|
|
|
|
return db.DeleteLiveRouteBatch(deletions)
|
|
}
|
|
|
|
// TestDeleteLiveRoutesOfPeer gives two peers IPv4 and IPv6 live routes, some
|
|
// for the same prefixes, and deletes all of one peer's routes. The other peer's
|
|
// routes stay, and the route counts and prefix distribution held in memory
|
|
// equal what is read from the route tables.
|
|
func TestDeleteLiveRoutesOfPeer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
peerIP = "192.0.2.1"
|
|
otherPeerIP = "192.0.2.2"
|
|
sharedV4 = "198.51.100.0/24"
|
|
sharedV6 = "2001:db8::/32"
|
|
)
|
|
|
|
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() }()
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
ofPeer := func(peer string, route *LiveRoute) *LiveRoute {
|
|
route.PeerIP = peer
|
|
|
|
return route
|
|
}
|
|
onlyV6 := mkV6Route("2001:db8:1::/48", 64503, ts)
|
|
onlyV6.MaskLength = 48
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{
|
|
ofPeer(peerIP, mkV4Route(t, sharedV4, 64500, ts)),
|
|
ofPeer(otherPeerIP, mkV4Route(t, sharedV4, 64500, ts)),
|
|
ofPeer(peerIP, mkV4Route(t, "203.0.113.0/24", 64501, ts)),
|
|
ofPeer(peerIP, mkV6Route(sharedV6, 64502, ts)),
|
|
ofPeer(otherPeerIP, mkV6Route(sharedV6, 64502, ts)),
|
|
ofPeer(peerIP, onlyV6),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
// peerIP has two IPv4 and two IPv6 routes; a second delete finds none.
|
|
for _, want := range []int{4, 0} {
|
|
deleted, err := db.DeleteLiveRoutesOfPeer(peerIP)
|
|
if err != nil {
|
|
t.Fatalf("DeleteLiveRoutesOfPeer: %v", err)
|
|
}
|
|
|
|
if deleted != want {
|
|
t.Errorf("DeleteLiveRoutesOfPeer deleted %d routes, want %d",
|
|
deleted, want)
|
|
}
|
|
}
|
|
|
|
for _, prefix := range []string{sharedV4, sharedV6} {
|
|
routes, _, err := db.GetPrefixDetailsContext(context.Background(), prefix)
|
|
if err != nil {
|
|
t.Fatalf("GetPrefixDetailsContext(%s): %v", prefix, err)
|
|
}
|
|
|
|
if len(routes) != 1 || routes[0].PeerIP != otherPeerIP {
|
|
t.Errorf("live routes for %s = %+v, want only the one from %s",
|
|
prefix, routes, otherPeerIP)
|
|
}
|
|
}
|
|
|
|
checkOneLiveRoutePerIPVersion(t, db)
|
|
}
|
|
|
|
// TestDeleteLiveRoutesOfPeerDeletesInChunks gives a peer one IPv4 route more
|
|
// than a chunk holds, two of them for prefixes another peer also has. One chunk
|
|
// deletes peerRouteChunkSize of the peer's routes and commits, leaving the last
|
|
// one. The routes are written again, and DeleteLiveRoutesOfPeer deletes all of
|
|
// them, a chunk and then the last one. After each step the route count and
|
|
// prefix distribution held in memory and those read from the route table are
|
|
// the ones wanted.
|
|
func TestDeleteLiveRoutesOfPeerDeletesInChunks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
peerIP = "192.0.2.1"
|
|
otherPeerIP = "192.0.2.2"
|
|
// How many of the peer's prefixes the other peer also has, the first
|
|
// ones in prefix order.
|
|
sharedPrefixes = 2
|
|
)
|
|
|
|
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() }()
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
peerRoutes := make([]*LiveRoute, 0, peerRouteChunkSize+1)
|
|
otherPeerRoutes := make([]*LiveRoute, 0, sharedPrefixes)
|
|
|
|
for i := range peerRouteChunkSize + 1 {
|
|
prefix := fmt.Sprintf("10.%d.%d.0/24", i/256, i%256)
|
|
|
|
route := mkV4Route(t, prefix, 64500, ts)
|
|
route.PeerIP = peerIP
|
|
peerRoutes = append(peerRoutes, route)
|
|
|
|
if i < sharedPrefixes {
|
|
route := mkV4Route(t, prefix, 64500, ts)
|
|
route.PeerIP = otherPeerIP
|
|
otherPeerRoutes = append(otherPeerRoutes, route)
|
|
}
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(slices.Concat(peerRoutes, otherPeerRoutes))
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
deletedV4, deletedV6, err := db.deletePeerRouteChunk(peerIP)
|
|
if err != nil {
|
|
t.Fatalf("deletePeerRouteChunk: %v", err)
|
|
}
|
|
|
|
if deletedV4 != peerRouteChunkSize || deletedV6 != 0 {
|
|
t.Errorf("deletePeerRouteChunk deleted (%d, %d) routes, want (%d, 0)",
|
|
deletedV4, deletedV6, peerRouteChunkSize)
|
|
}
|
|
|
|
// The shared prefixes went with the chunk, so each route left has a
|
|
// prefix of its own.
|
|
checkV4RoutesOfOwnPrefixes(t, db, "after one chunk", 1+sharedPrefixes)
|
|
|
|
err = db.UpsertLiveRouteBatch(peerRoutes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
deleted, err := db.DeleteLiveRoutesOfPeer(peerIP)
|
|
if err != nil {
|
|
t.Fatalf("DeleteLiveRoutesOfPeer: %v", err)
|
|
}
|
|
|
|
if deleted != len(peerRoutes) {
|
|
t.Errorf("DeleteLiveRoutesOfPeer deleted %d routes, want %d",
|
|
deleted, len(peerRoutes))
|
|
}
|
|
|
|
checkV4RoutesOfOwnPrefixes(t, db, "after DeleteLiveRoutesOfPeer",
|
|
sharedPrefixes)
|
|
}
|
|
|
|
// checkV4RoutesOfOwnPrefixes checks that the database has wantRoutes IPv4 live
|
|
// routes, each for a /24 no other live route has, both as counted in memory and
|
|
// as read from the route table. when says which step of the test checks.
|
|
func checkV4RoutesOfOwnPrefixes(
|
|
t *testing.T, db *Database, when string, wantRoutes int,
|
|
) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
|
|
stats, err := db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
tableRoutes, _, err := db.GetLiveRouteCountsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetLiveRouteCountsContext: %v", err)
|
|
}
|
|
|
|
if stats.IPv4Routes != wantRoutes || tableRoutes != wantRoutes {
|
|
t.Errorf("%s: IPv4 live routes: %d in memory, %d in the table, want %d",
|
|
when, stats.IPv4Routes, tableRoutes, wantRoutes)
|
|
}
|
|
|
|
queryDistribution, _, err := db.GetPrefixDistributionContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetPrefixDistributionContext: %v", err)
|
|
}
|
|
|
|
want := []PrefixDistribution{{MaskLength: 24, Count: wantRoutes}}
|
|
assertDistribution(t, when+": IPv4 distribution",
|
|
stats.IPv4PrefixDistribution, want)
|
|
assertDistribution(t, when+": IPv4 distribution query",
|
|
queryDistribution, want)
|
|
}
|
|
|
|
// checkOneLiveRoutePerIPVersion checks that the database has one IPv4 live
|
|
// route, for a /24, and one IPv6 live route, for a /32, both as counted in
|
|
// memory and as read from the route tables.
|
|
func checkOneLiveRoutePerIPVersion(t *testing.T, db *Database) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
|
|
stats, err := db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
tableV4, tableV6, err := db.GetLiveRouteCountsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetLiveRouteCountsContext: %v", err)
|
|
}
|
|
|
|
if stats.IPv4Routes != 1 || tableV4 != 1 || stats.IPv6Routes != 1 ||
|
|
tableV6 != 1 {
|
|
t.Errorf("live routes (IPv4, IPv6): (%d, %d) in memory, (%d, %d) in "+
|
|
"the tables, want (1, 1)",
|
|
stats.IPv4Routes, stats.IPv6Routes, tableV4, tableV6)
|
|
}
|
|
|
|
queryV4, queryV6, err := db.GetPrefixDistributionContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetPrefixDistributionContext: %v", err)
|
|
}
|
|
|
|
wantV4 := []PrefixDistribution{{MaskLength: 24, Count: 1}}
|
|
wantV6 := []PrefixDistribution{{MaskLength: 32, Count: 1}}
|
|
|
|
assertDistribution(t, "IPv4 distribution", stats.IPv4PrefixDistribution, wantV4)
|
|
assertDistribution(t, "IPv6 distribution", stats.IPv6PrefixDistribution, wantV6)
|
|
assertDistribution(t, "IPv4 distribution query", queryV4, wantV4)
|
|
assertDistribution(t, "IPv6 distribution query", queryV6, wantV6)
|
|
}
|
|
|
|
// TestStatsRouteTimestamps checks the oldest/newest route timestamps are read
|
|
// from the right rows across both tables and parse into time.Time. The old
|
|
// MIN/MAX union query read its result into *time.Time, which the driver could
|
|
// not parse, so it logged a warning every call and left both timestamps nil.
|
|
func TestStatsRouteTimestamps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
|
|
db, err := New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
ctx := context.Background()
|
|
|
|
empty, err := db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext on empty database: %v", err)
|
|
}
|
|
|
|
if empty.OldestRoute != nil || empty.NewestRoute != nil {
|
|
t.Fatalf("empty database timestamps = (%v, %v), want (nil, nil)",
|
|
empty.OldestRoute, empty.NewestRoute)
|
|
}
|
|
|
|
base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
oldest := base
|
|
newest := base.Add(2 * time.Minute)
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{
|
|
mkV4Route(t, "198.51.100.0/24", 64500, base.Add(time.Minute)),
|
|
mkV4Route(t, "203.0.113.0/24", 64501, oldest),
|
|
mkV6Route(testV6Prefix, 64502, newest),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
stats, err := db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
if stats.OldestRoute == nil || !stats.OldestRoute.Equal(oldest) {
|
|
t.Errorf("OldestRoute = %v, want %v", stats.OldestRoute, oldest)
|
|
}
|
|
|
|
if stats.NewestRoute == nil || !stats.NewestRoute.Equal(newest) {
|
|
t.Errorf("NewestRoute = %v, want %v", stats.NewestRoute, newest)
|
|
}
|
|
}
|
|
|
|
// TestLiveCountsConcurrentReadWrite runs writers and stats readers at once so
|
|
// the race detector proves the counters are safe under concurrent use.
|
|
func TestLiveCountsConcurrentReadWrite(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
|
|
db, err := New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
const writers = 4
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(writers)
|
|
|
|
for w := range writers {
|
|
go func(base int) {
|
|
defer wg.Done()
|
|
|
|
for i := range 25 {
|
|
asn := 65000 + base*100 + i
|
|
route := mkV6Route(testV6Prefix, asn, ts)
|
|
route.PeerIP = "2001:db8::" + uuid.NewString()
|
|
|
|
err := db.UpsertLiveRoute(route)
|
|
if err != nil {
|
|
t.Errorf("UpsertLiveRoute: %v", err)
|
|
|
|
return
|
|
}
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
var readerWG sync.WaitGroup
|
|
readerWG.Add(1)
|
|
|
|
stop := make(chan struct{})
|
|
|
|
go func() {
|
|
defer readerWG.Done()
|
|
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
_, err := db.GetStatsContext(context.Background())
|
|
if err != nil {
|
|
t.Errorf("GetStatsContext: %v", err)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
wg.Wait()
|
|
close(stop)
|
|
readerWG.Wait()
|
|
|
|
stats, err := db.GetStatsContext(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetStatsContext: %v", err)
|
|
}
|
|
|
|
if want := writers * 25; stats.IPv6Routes != want {
|
|
t.Errorf("IPv6Routes = %d, want %d", stats.IPv6Routes, want)
|
|
}
|
|
// Every writer announced the same prefix, so it counts once.
|
|
assertDistribution(t, "IPv6 distribution", stats.IPv6PrefixDistribution,
|
|
[]PrefixDistribution{{MaskLength: 32, Count: 1}})
|
|
}
|
|
|
|
type wantCounts struct {
|
|
asns int
|
|
prefixes int
|
|
peerings int
|
|
peers int
|
|
ipv4Routes int
|
|
ipv6Routes int
|
|
liveRoutes int
|
|
}
|
|
|
|
func assertCounts(t *testing.T, when string, got Stats, want wantCounts) {
|
|
t.Helper()
|
|
|
|
if got.ASNs != want.asns {
|
|
t.Errorf("%s: ASNs = %d, want %d", when, got.ASNs, want.asns)
|
|
}
|
|
|
|
if got.Prefixes != want.prefixes {
|
|
t.Errorf("%s: Prefixes = %d, want %d", when, got.Prefixes, want.prefixes)
|
|
}
|
|
|
|
if got.Peerings != want.peerings {
|
|
t.Errorf("%s: Peerings = %d, want %d", when, got.Peerings, want.peerings)
|
|
}
|
|
|
|
if got.Peers != want.peers {
|
|
t.Errorf("%s: Peers = %d, want %d", when, got.Peers, want.peers)
|
|
}
|
|
|
|
if got.IPv4Routes != want.ipv4Routes {
|
|
t.Errorf("%s: IPv4Routes = %d, want %d", when, got.IPv4Routes, want.ipv4Routes)
|
|
}
|
|
|
|
if got.IPv6Routes != want.ipv6Routes {
|
|
t.Errorf("%s: IPv6Routes = %d, want %d", when, got.IPv6Routes, want.ipv6Routes)
|
|
}
|
|
|
|
if got.LiveRoutes != want.liveRoutes {
|
|
t.Errorf("%s: LiveRoutes = %d, want %d", when, got.LiveRoutes, want.liveRoutes)
|
|
}
|
|
}
|
|
|
|
func assertDistribution(t *testing.T, what string, got, want []PrefixDistribution) {
|
|
t.Helper()
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("%s = %v, want %v", what, got, want)
|
|
}
|
|
}
|