WIP db-tests lint fixes

Model: opus-5-5
This commit is contained in:
2026-10-14 13:03:14 +00:00
parent c9fb637ecd
commit c5ae72fca7
3 changed files with 322 additions and 162 deletions
@@ -12,6 +12,12 @@ import (
"github.com/google/uuid" "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 every IPv6 route in these tests.
const testV6Prefix = "2001:db8::/32"
// mkV4Route builds an IPv4 live route with its mask length taken from the // mkV4Route builds an IPv4 live route with its mask length taken from the
// prefix. // prefix.
func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute { func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute {
@@ -28,18 +34,18 @@ func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute {
MaskLength: maskLength, MaskLength: maskLength,
IPVersion: ipVersionV4, IPVersion: ipVersionV4,
OriginASN: asn, OriginASN: asn,
PeerIP: "192.0.2.1", PeerIP: testV4PeerIP,
ASPath: []int{asn}, ASPath: []int{asn},
NextHop: "192.0.2.254", NextHop: "192.0.2.254",
LastUpdated: ts, LastUpdated: ts,
} }
} }
// mkV6Route builds an IPv6 live route. // mkV6Route builds an IPv6 live route for testV6Prefix.
func mkV6Route(prefix string, asn int, ts time.Time) *LiveRoute { func mkV6Route(asn int, ts time.Time) *LiveRoute {
return &LiveRoute{ return &LiveRoute{
ID: uuid.New(), ID: uuid.New(),
Prefix: prefix, Prefix: testV6Prefix,
MaskLength: 32, MaskLength: 32,
IPVersion: ipVersionV6, IPVersion: ipVersionV6,
OriginASN: asn, OriginASN: asn,
@@ -56,6 +62,8 @@ func mkV6Route(prefix string, asn int, ts time.Time) *LiveRoute {
// deleted. These counts are what /api/v1/stats reports; before this change the // 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. // endpoint recomputed them with a COUNT(*) over each table on every request.
func TestLiveCountsTrackWritesInRealtime(t *testing.T) { func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -70,6 +78,7 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetStatsContext on empty database: %v", err) t.Fatalf("GetStatsContext on empty database: %v", err)
} }
if empty.ASNs != 0 || empty.Prefixes != 0 || empty.Peerings != 0 || if empty.ASNs != 0 || empty.Prefixes != 0 || empty.Peerings != 0 ||
empty.Peers != 0 || empty.LiveRoutes != 0 { empty.Peers != 0 || empty.LiveRoutes != 0 {
t.Fatalf("empty database counts nonzero: %+v", empty) t.Fatalf("empty database counts nonzero: %+v", empty)
@@ -77,30 +86,16 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := db.GetOrCreateASNBatch(map[int]time.Time{64500: ts, 64501: ts}); err != nil { writeASNsPrefixesAndPeers(t, db, ts)
t.Fatalf("GetOrCreateASNBatch: %v", err)
}
if err := db.UpdatePrefixesBatch(map[string]time.Time{
"198.51.100.0/24": ts,
"2001:db8::/32": ts,
}); err != nil {
t.Fatalf("UpdatePrefixesBatch: %v", err)
}
if err := db.UpdatePeerBatch(map[string]PeerUpdate{
"192.0.2.1": {PeerIP: "192.0.2.1", PeerASN: 64500, MessageType: "UPDATE", Timestamp: ts},
}); err != nil {
t.Fatalf("UpdatePeerBatch: %v", err)
}
if err := db.RecordPeering(64500, 64501, ts); err != nil {
t.Fatalf("RecordPeering: %v", err)
}
routes := []*LiveRoute{ routes := []*LiveRoute{
mkV4Route(t, "198.51.100.0/24", 64500, ts), mkV4Route(t, "198.51.100.0/24", 64500, ts),
mkV4Route(t, "203.0.113.0/24", 64501, ts.Add(time.Minute)), mkV4Route(t, "203.0.113.0/24", 64501, ts.Add(time.Minute)),
mkV6Route("2001:db8::/32", 64502, ts.Add(2*time.Minute)), mkV6Route(64502, ts.Add(2*time.Minute)),
} }
if err := db.UpsertLiveRouteBatch(routes); err != nil {
err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err) t.Fatalf("UpsertLiveRouteBatch: %v", err)
} }
@@ -108,44 +103,88 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetStatsContext: %v", err) t.Fatalf("GetStatsContext: %v", err)
} }
assertCounts(t, "after inserts", stats, wantCounts{ assertCounts(t, "after inserts", stats, wantCounts{
asns: 2, prefixes: 2, peerings: 1, peers: 1, asns: 2, prefixes: 2, peerings: 1, peers: 1,
ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3, ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3,
}) })
// Re-announcing the same routes is an update, not an insert: counts hold. // Re-announcing the same routes is an update, not an insert: counts hold.
if err := db.UpsertLiveRouteBatch(routes); err != nil { err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch (re-announce): %v", err) t.Fatalf("UpsertLiveRouteBatch (re-announce): %v", err)
} }
stats, err = db.GetStatsContext(ctx) stats, err = db.GetStatsContext(ctx)
if err != nil { if err != nil {
t.Fatalf("GetStatsContext: %v", err) t.Fatalf("GetStatsContext: %v", err)
} }
assertCounts(t, "after re-announce", stats, wantCounts{ assertCounts(t, "after re-announce", stats, wantCounts{
asns: 2, prefixes: 2, peerings: 1, peers: 1, asns: 2, prefixes: 2, peerings: 1, peers: 1,
ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3, ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3,
}) })
// A withdrawal removes one route. // A withdrawal removes one route.
if err := db.DeleteLiveRouteBatch([]LiveRouteDeletion{ err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{{
{Prefix: "203.0.113.0/24", OriginASN: 64501, PeerIP: "192.0.2.1", IPVersion: ipVersionV4}, Prefix: "203.0.113.0/24", OriginASN: 64501,
}); err != nil { PeerIP: testV4PeerIP, IPVersion: ipVersionV4,
}})
if err != nil {
t.Fatalf("DeleteLiveRouteBatch: %v", err) t.Fatalf("DeleteLiveRouteBatch: %v", err)
} }
stats, err = db.GetStatsContext(ctx) stats, err = db.GetStatsContext(ctx)
if err != nil { if err != nil {
t.Fatalf("GetStatsContext: %v", err) t.Fatalf("GetStatsContext: %v", err)
} }
assertCounts(t, "after delete", stats, wantCounts{ assertCounts(t, "after delete", stats, wantCounts{
asns: 2, prefixes: 2, peerings: 1, peers: 1, asns: 2, prefixes: 2, peerings: 1, peers: 1,
ipv4Routes: 1, ipv6Routes: 1, liveRoutes: 2, 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 // TestLiveCountsSeededFromDatabaseAtStartup writes rows, reopens the same
// database file, and checks the counts and the prefix distribution come back // database file, and checks the counts and the prefix distribution come back
// from the seed scan rather than starting at zero. // from the seed scan rather than starting at zero.
func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) { func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -154,16 +193,22 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
} }
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := db.GetOrCreateASNBatch(map[int]time.Time{64500: ts, 64501: ts, 64502: ts}); err != nil {
err = db.GetOrCreateASNBatch(map[int]time.Time{64500: ts, 64501: ts, 64502: ts})
if err != nil {
t.Fatalf("GetOrCreateASNBatch: %v", err) t.Fatalf("GetOrCreateASNBatch: %v", err)
} }
if err := db.UpsertLiveRouteBatch([]*LiveRoute{
err = db.UpsertLiveRouteBatch([]*LiveRoute{
mkV4Route(t, "198.51.100.0/24", 64500, ts), mkV4Route(t, "198.51.100.0/24", 64500, ts),
mkV6Route("2001:db8::/32", 64502, ts), mkV6Route(64502, ts),
}); err != nil { })
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err) t.Fatalf("UpsertLiveRouteBatch: %v", err)
} }
if err := db.Close(); err != nil {
err = db.Close()
if err != nil {
t.Fatalf("Close: %v", err) t.Fatalf("Close: %v", err)
} }
@@ -177,13 +222,16 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetStatsContext after reopen: %v", err) t.Fatalf("GetStatsContext after reopen: %v", err)
} }
if stats.ASNs != 3 { if stats.ASNs != 3 {
t.Errorf("seeded ASNs = %d, want 3", stats.ASNs) t.Errorf("seeded ASNs = %d, want 3", stats.ASNs)
} }
if stats.IPv4Routes != 1 || stats.IPv6Routes != 1 || stats.LiveRoutes != 2 { if stats.IPv4Routes != 1 || stats.IPv6Routes != 1 || stats.LiveRoutes != 2 {
t.Errorf("seeded routes = (v4 %d, v6 %d, total %d), want (1, 1, 2)", t.Errorf("seeded routes = (v4 %d, v6 %d, total %d), want (1, 1, 2)",
stats.IPv4Routes, stats.IPv6Routes, stats.LiveRoutes) stats.IPv4Routes, stats.IPv6Routes, stats.LiveRoutes)
} }
assertDistribution(t, "seeded IPv4 distribution", stats.IPv4PrefixDistribution, assertDistribution(t, "seeded IPv4 distribution", stats.IPv4PrefixDistribution,
[]PrefixDistribution{{MaskLength: 24, Count: 1}}) []PrefixDistribution{{MaskLength: 24, Count: 1}})
assertDistribution(t, "seeded IPv6 distribution", stats.IPv6PrefixDistribution, assertDistribution(t, "seeded IPv6 distribution", stats.IPv6PrefixDistribution,
@@ -196,26 +244,91 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
// tables. The steps run once through the batch methods the prefix handler uses // tables. The steps run once through the batch methods the prefix handler uses
// and once through the single-route methods. // and once through the single-route methods.
func TestPrefixDistributionTracksWrites(t *testing.T) { 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) ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
shared := mkV4Route(t, "198.51.100.0/24", 64500, ts) shared := mkV4Route(t, "198.51.100.0/24", 64500, ts)
sharedSecondPeer := 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" sharedSecondPeer.PeerIP = "192.0.2.2"
other := mkV4Route(t, "203.0.113.0/24", 64501, ts) other := mkV4Route(t, "203.0.113.0/24", 64501, ts)
wide := mkV4Route(t, "172.16.0.0/16", 64502, ts) wide := mkV4Route(t, "172.16.0.0/16", 64502, ts)
v6 := mkV6Route("2001:db8::/32", 64503, ts) v6 := mkV6Route(64503, ts)
v6SecondPeer := mkV6Route("2001:db8::/32", 64503, ts) v6SecondPeer := mkV6Route(64503, ts)
v6SecondPeer.PeerIP = "2001:db8::2" v6SecondPeer.PeerIP = "2001:db8::2"
all := []PrefixDistribution{{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 2}} all := []PrefixDistribution{{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 2}}
v6Only := []PrefixDistribution{{MaskLength: 32, Count: 1}} v6Only := []PrefixDistribution{{MaskLength: 32, Count: 1}}
steps := []struct { return []prefixDistributionStep{
name string
announce []*LiveRoute
withdraw []*LiveRoute
wantV4 []PrefixDistribution
wantV6 []PrefixDistribution
}{
{ {
name: "new routes", name: "new routes",
announce: []*LiveRoute{shared, other, wide, v6}, announce: []*LiveRoute{shared, other, wide, v6},
@@ -239,7 +352,9 @@ func TestPrefixDistributionTracksWrites(t *testing.T) {
{ {
name: "withdrawal of the last route for a prefix", name: "withdrawal of the last route for a prefix",
withdraw: []*LiveRoute{sharedSecondPeer, v6SecondPeer}, withdraw: []*LiveRoute{sharedSecondPeer, v6SecondPeer},
wantV4: []PrefixDistribution{{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 1}}, wantV4: []PrefixDistribution{
{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 1},
},
}, },
{ {
name: "withdrawal of every remaining route", name: "withdrawal of every remaining route",
@@ -270,45 +385,6 @@ func TestPrefixDistributionTracksWrites(t *testing.T) {
wantV6: v6Only, wantV6: v6Only,
}, },
} }
for _, batch := range []bool{true, false} {
name := "single-route writes"
if batch {
name = "batch writes"
}
t.Run(name, func(t *testing.T) {
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 {
if err := announceRoutes(db, batch, step.announce); err != nil {
t.Fatalf("%s: announce: %v", step.name, err)
}
if err := withdrawRoutes(db, batch, step.withdraw); 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)
}
})
}
} }
// announceRoutes writes routes in one UpsertLiveRouteBatch, or with one // announceRoutes writes routes in one UpsertLiveRouteBatch, or with one
@@ -317,8 +393,10 @@ func announceRoutes(db *Database, batch bool, routes []*LiveRoute) error {
if batch { if batch {
return db.UpsertLiveRouteBatch(routes) return db.UpsertLiveRouteBatch(routes)
} }
for _, route := range routes { for _, route := range routes {
if err := db.UpsertLiveRoute(route); err != nil { err := db.UpsertLiveRoute(route)
if err != nil {
return err return err
} }
} }
@@ -333,7 +411,8 @@ func announceRoutes(db *Database, batch bool, routes []*LiveRoute) error {
func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error { func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error {
if !batch { if !batch {
for _, route := range routes { for _, route := range routes {
if err := db.DeleteLiveRoute(route.Prefix, 0, route.PeerIP); err != nil { err := db.DeleteLiveRoute(route.Prefix, 0, route.PeerIP)
if err != nil {
return err return err
} }
} }
@@ -356,6 +435,8 @@ func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error {
// MIN/MAX union query read its result into *time.Time, which the driver could // 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. // not parse, so it logged a warning every call and left both timestamps nil.
func TestStatsRouteTimestamps(t *testing.T) { func TestStatsRouteTimestamps(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -370,6 +451,7 @@ func TestStatsRouteTimestamps(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetStatsContext on empty database: %v", err) t.Fatalf("GetStatsContext on empty database: %v", err)
} }
if empty.OldestRoute != nil || empty.NewestRoute != nil { if empty.OldestRoute != nil || empty.NewestRoute != nil {
t.Fatalf("empty database timestamps = (%v, %v), want (nil, nil)", t.Fatalf("empty database timestamps = (%v, %v), want (nil, nil)",
empty.OldestRoute, empty.NewestRoute) empty.OldestRoute, empty.NewestRoute)
@@ -378,11 +460,13 @@ func TestStatsRouteTimestamps(t *testing.T) {
base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
oldest := base oldest := base
newest := base.Add(2 * time.Minute) newest := base.Add(2 * time.Minute)
if err := db.UpsertLiveRouteBatch([]*LiveRoute{
err = db.UpsertLiveRouteBatch([]*LiveRoute{
mkV4Route(t, "198.51.100.0/24", 64500, base.Add(time.Minute)), mkV4Route(t, "198.51.100.0/24", 64500, base.Add(time.Minute)),
mkV4Route(t, "203.0.113.0/24", 64501, oldest), mkV4Route(t, "203.0.113.0/24", 64501, oldest),
mkV6Route("2001:db8::/32", 64502, newest), mkV6Route(64502, newest),
}); err != nil { })
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err) t.Fatalf("UpsertLiveRouteBatch: %v", err)
} }
@@ -390,9 +474,11 @@ func TestStatsRouteTimestamps(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetStatsContext: %v", err) t.Fatalf("GetStatsContext: %v", err)
} }
if stats.OldestRoute == nil || !stats.OldestRoute.Equal(oldest) { if stats.OldestRoute == nil || !stats.OldestRoute.Equal(oldest) {
t.Errorf("OldestRoute = %v, want %v", stats.OldestRoute, oldest) t.Errorf("OldestRoute = %v, want %v", stats.OldestRoute, oldest)
} }
if stats.NewestRoute == nil || !stats.NewestRoute.Equal(newest) { if stats.NewestRoute == nil || !stats.NewestRoute.Equal(newest) {
t.Errorf("NewestRoute = %v, want %v", stats.NewestRoute, newest) t.Errorf("NewestRoute = %v, want %v", stats.NewestRoute, newest)
} }
@@ -401,6 +487,8 @@ func TestStatsRouteTimestamps(t *testing.T) {
// TestLiveCountsConcurrentReadWrite runs writers and stats readers at once so // TestLiveCountsConcurrentReadWrite runs writers and stats readers at once so
// the race detector proves the counters are safe under concurrent use. // the race detector proves the counters are safe under concurrent use.
func TestLiveCountsConcurrentReadWrite(t *testing.T) { func TestLiveCountsConcurrentReadWrite(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -412,17 +500,21 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
const writers = 4 const writers = 4
var wg sync.WaitGroup
var wg sync.WaitGroup
wg.Add(writers) wg.Add(writers)
for w := range writers { for w := range writers {
go func(base int) { go func(base int) {
defer wg.Done() defer wg.Done()
for i := range 25 { for i := range 25 {
asn := 65000 + base*100 + i asn := 65000 + base*100 + i
route := mkV6Route("2001:db8::/32", asn, ts) route := mkV6Route(asn, ts)
route.PeerIP = "2001:db8::" + uuid.NewString() route.PeerIP = "2001:db8::" + uuid.NewString()
if err := db.UpsertLiveRoute(route); err != nil {
err := db.UpsertLiveRoute(route)
if err != nil {
t.Errorf("UpsertLiveRoute: %v", err) t.Errorf("UpsertLiveRoute: %v", err)
return return
@@ -433,15 +525,19 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
var readerWG sync.WaitGroup var readerWG sync.WaitGroup
readerWG.Add(1) readerWG.Add(1)
stop := make(chan struct{}) stop := make(chan struct{})
go func() { go func() {
defer readerWG.Done() defer readerWG.Done()
for { for {
select { select {
case <-stop: case <-stop:
return return
default: default:
if _, err := db.GetStatsContext(context.Background()); err != nil { _, err := db.GetStatsContext(context.Background())
if err != nil {
t.Errorf("GetStatsContext: %v", err) t.Errorf("GetStatsContext: %v", err)
return return
@@ -458,6 +554,7 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetStatsContext: %v", err) t.Fatalf("GetStatsContext: %v", err)
} }
if want := writers * 25; stats.IPv6Routes != want { if want := writers * 25; stats.IPv6Routes != want {
t.Errorf("IPv6Routes = %d, want %d", stats.IPv6Routes, want) t.Errorf("IPv6Routes = %d, want %d", stats.IPv6Routes, want)
} }
@@ -482,21 +579,27 @@ func assertCounts(t *testing.T, when string, got Stats, want wantCounts) {
if got.ASNs != want.asns { if got.ASNs != want.asns {
t.Errorf("%s: ASNs = %d, want %d", when, got.ASNs, want.asns) t.Errorf("%s: ASNs = %d, want %d", when, got.ASNs, want.asns)
} }
if got.Prefixes != want.prefixes { if got.Prefixes != want.prefixes {
t.Errorf("%s: Prefixes = %d, want %d", when, got.Prefixes, want.prefixes) t.Errorf("%s: Prefixes = %d, want %d", when, got.Prefixes, want.prefixes)
} }
if got.Peerings != want.peerings { if got.Peerings != want.peerings {
t.Errorf("%s: Peerings = %d, want %d", when, got.Peerings, want.peerings) t.Errorf("%s: Peerings = %d, want %d", when, got.Peerings, want.peerings)
} }
if got.Peers != want.peers { if got.Peers != want.peers {
t.Errorf("%s: Peers = %d, want %d", when, got.Peers, want.peers) t.Errorf("%s: Peers = %d, want %d", when, got.Peers, want.peers)
} }
if got.IPv4Routes != want.ipv4Routes { if got.IPv4Routes != want.ipv4Routes {
t.Errorf("%s: IPv4Routes = %d, want %d", when, got.IPv4Routes, want.ipv4Routes) t.Errorf("%s: IPv4Routes = %d, want %d", when, got.IPv4Routes, want.ipv4Routes)
} }
if got.IPv6Routes != want.ipv6Routes { if got.IPv6Routes != want.ipv6Routes {
t.Errorf("%s: IPv6Routes = %d, want %d", when, got.IPv6Routes, want.ipv6Routes) t.Errorf("%s: IPv6Routes = %d, want %d", when, got.IPv6Routes, want.ipv6Routes)
} }
if got.LiveRoutes != want.liveRoutes { if got.LiveRoutes != want.liveRoutes {
t.Errorf("%s: LiveRoutes = %d, want %d", when, got.LiveRoutes, want.liveRoutes) t.Errorf("%s: LiveRoutes = %d, want %d", when, got.LiveRoutes, want.liveRoutes)
} }
@@ -50,6 +50,8 @@ const (
// address, ErrNoRoute when none covers it, and the next less specific prefix // address, ErrNoRoute when none covers it, and the next less specific prefix
// once the only route of the most specific one is withdrawn. // once the only route of the most specific one is withdrawn.
func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) { func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -67,24 +69,7 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
"2001:db8:1::/48": 64501, "2001:db8:1::/48": 64501,
"2001:db8:1:2::/64": 64502, "2001:db8:1:2::/64": 64502,
} }
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) writeOriginRoutes(t, db, origins)
routes := make([]*LiveRoute, 0, len(origins))
for prefix, asn := range origins {
routes = append(routes, &LiveRoute{
ID: uuid.New(),
Prefix: prefix,
MaskLength: netip.MustParsePrefix(prefix).Bits(),
IPVersion: detectIPVersion(prefix),
OriginASN: asn,
PeerIP: testPeerIP,
ASPath: []int{asn},
NextHop: testPeerIP,
LastUpdated: ts,
})
}
if err := db.UpsertLiveRouteBatch(routes); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
// lookup checks that ip resolves to the live prefix want, or to ErrNoRoute // lookup checks that ip resolves to the live prefix want, or to ErrNoRoute
// when want is empty. // when want is empty.
@@ -99,11 +84,13 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
return return
} }
if err != nil { if err != nil {
t.Errorf("GetIPInfo(%s): %v", ip, err) t.Errorf("GetIPInfo(%s): %v", ip, err)
return return
} }
if info.Netblock != want || info.MaskLength != netip.MustParsePrefix(want).Bits() || if info.Netblock != want || info.MaskLength != netip.MustParsePrefix(want).Bits() ||
info.ASN != origins[want] { info.ASN != origins[want] {
t.Errorf("GetIPInfo(%s) = %s (mask %d) AS%d, want %s AS%d", t.Errorf("GetIPInfo(%s) = %s (mask %d) AS%d, want %s AS%d",
@@ -122,8 +109,14 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
lookup("2001:db9::1", "") lookup("2001:db9::1", "")
err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{ err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{
{Prefix: "10.1.2.0/24", OriginASN: 64502, PeerIP: testPeerIP, IPVersion: ipVersionV4}, {
{Prefix: "2001:db8:1:2::/64", OriginASN: 64502, PeerIP: testPeerIP, IPVersion: ipVersionV6}, Prefix: "10.1.2.0/24", OriginASN: 64502,
PeerIP: testPeerIP, IPVersion: ipVersionV4,
},
{
Prefix: "2001:db8:1:2::/64", OriginASN: 64502,
PeerIP: testPeerIP, IPVersion: ipVersionV6,
},
}) })
if err != nil { if err != nil {
t.Fatalf("DeleteLiveRouteBatch: %v", err) t.Fatalf("DeleteLiveRouteBatch: %v", err)
@@ -133,11 +126,41 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
lookup("2001:db8:1:2::3", "2001:db8:1::/48") lookup("2001:db8:1:2::3", "2001:db8:1::/48")
} }
// writeOriginRoutes writes one live route from testPeerIP for each prefix in
// origins, originated by the AS it maps to.
func writeOriginRoutes(t *testing.T, db *Database, origins map[string]int) {
t.Helper()
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
routes := make([]*LiveRoute, 0, len(origins))
for prefix, asn := range origins {
routes = append(routes, &LiveRoute{
ID: uuid.New(),
Prefix: prefix,
MaskLength: netip.MustParsePrefix(prefix).Bits(),
IPVersion: detectIPVersion(prefix),
OriginASN: asn,
PeerIP: testPeerIP,
ASPath: []int{asn},
NextHop: testPeerIP,
LastUpdated: ts,
})
}
err := db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
}
// TestConnectionPoolPragmas holds several pooled connections open at once and // TestConnectionPoolPragmas holds several pooled connections open at once and
// checks each one carries the per-connection settings from the DSN, plus the // checks each one carries the per-connection settings from the DSN, plus the
// process-wide hard heap limit, and sees the new file with auto_vacuum // process-wide hard heap limit, and sees the new file with auto_vacuum
// incremental. // incremental.
func TestConnectionPoolPragmas(t *testing.T) { func TestConnectionPoolPragmas(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -157,57 +180,64 @@ func TestConnectionPoolPragmas(t *testing.T) {
} }
}() }()
for i := 0; i < heldConnections; i++ { for i := range heldConnections {
c, err := db.db.Conn(ctx) c, err := db.db.Conn(ctx)
if err != nil { if err != nil {
t.Fatalf("failed to open connection %d: %v", i, err) t.Fatalf("failed to open connection %d: %v", i, err)
} }
conns = append(conns, c) conns = append(conns, c)
} }
for i, c := range conns { for i, c := range conns {
var cacheSize int cacheSize := readPragma(ctx, t, c, i, "cache_size")
if err := c.QueryRowContext(ctx, "PRAGMA cache_size").Scan(&cacheSize); err != nil {
t.Fatalf("conn %d: failed to read cache_size: %v", i, err)
}
if cacheSize != sqliteCacheSizeKiB { if cacheSize != sqliteCacheSizeKiB {
t.Errorf("conn %d: cache_size = %d, want %d", i, cacheSize, sqliteCacheSizeKiB) t.Errorf("conn %d: cache_size = %d, want %d",
i, cacheSize, sqliteCacheSizeKiB)
} }
var busyTimeout int busyTimeout := readPragma(ctx, t, c, i, "busy_timeout")
if err := c.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil {
t.Fatalf("conn %d: failed to read busy_timeout: %v", i, err)
}
if busyTimeout != sqliteBusyTimeoutMs { if busyTimeout != sqliteBusyTimeoutMs {
t.Errorf("conn %d: busy_timeout = %d, want %d", i, busyTimeout, sqliteBusyTimeoutMs) t.Errorf("conn %d: busy_timeout = %d, want %d",
i, busyTimeout, sqliteBusyTimeoutMs)
} }
var tempStore int tempStore := readPragma(ctx, t, c, i, "temp_store")
if err := c.QueryRowContext(ctx, "PRAGMA temp_store").Scan(&tempStore); err != nil {
t.Fatalf("conn %d: failed to read temp_store: %v", i, err)
}
if tempStore == tempStoreMemory { if tempStore == tempStoreMemory {
t.Errorf("conn %d: temp_store = %d, want anything but %d (MEMORY)", i, tempStore, tempStoreMemory) t.Errorf("conn %d: temp_store = %d, want anything but %d (MEMORY)",
i, tempStore, tempStoreMemory)
} }
var hardHeapLimit int64 hardHeapLimit := readPragma(ctx, t, c, i, "hard_heap_limit")
if err := c.QueryRowContext(ctx, "PRAGMA hard_heap_limit").Scan(&hardHeapLimit); err != nil {
t.Fatalf("conn %d: failed to read hard_heap_limit: %v", i, err)
}
if hardHeapLimit != sqliteHardHeapLimitBytes { if hardHeapLimit != sqliteHardHeapLimitBytes {
t.Errorf("conn %d: hard_heap_limit = %d, want %d", i, hardHeapLimit, sqliteHardHeapLimitBytes) t.Errorf("conn %d: hard_heap_limit = %d, want %d",
i, hardHeapLimit, sqliteHardHeapLimitBytes)
} }
var autoVacuum int autoVacuum := readPragma(ctx, t, c, i, "auto_vacuum")
if err := c.QueryRowContext(ctx, "PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
t.Fatalf("conn %d: failed to read auto_vacuum: %v", i, err)
}
if autoVacuum != autoVacuumIncremental { if autoVacuum != autoVacuumIncremental {
t.Errorf("conn %d: auto_vacuum = %d, want %d (incremental)", i, autoVacuum, autoVacuumIncremental) t.Errorf("conn %d: auto_vacuum = %d, want %d (incremental)",
i, autoVacuum, autoVacuumIncremental)
} }
} }
} }
// readPragma reads the integer value of PRAGMA name on connection i, c.
func readPragma(
ctx context.Context, t *testing.T, c *sql.Conn, i int, name string,
) int64 {
t.Helper()
var value int64
err := c.QueryRowContext(ctx, "PRAGMA "+name).Scan(&value)
if err != nil {
t.Fatalf("conn %d: failed to read %s: %v", i, name, err)
}
return value
}
// TestVacuumReturnsFreePages checks that after routes are deleted from a new // TestVacuumReturnsFreePages checks that after routes are deleted from a new
// database, one Vacuum call returns every page they used. The deletes leave // database, one Vacuum call returns every page they used. The deletes leave
// fewer free pages than the 1000 Vacuum frees per call, so none may remain. // fewer free pages than the 1000 Vacuum frees per call, so none may remain.
@@ -215,6 +245,8 @@ func TestConnectionPoolPragmas(t *testing.T) {
// the free pages stayed in the file, and with the PRAGMA run by ExecContext // the free pages stayed in the file, and with the PRAGMA run by ExecContext
// Vacuum freed only one page per call. // Vacuum freed only one page per call.
func TestVacuumReturnsFreePages(t *testing.T) { func TestVacuumReturnsFreePages(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -225,10 +257,12 @@ func TestVacuumReturnsFreePages(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ts := time.Now().UTC() ts := time.Now().UTC()
const asn = 64500 const asn = 64500
routes := make([]*LiveRoute, 0, vacuumTestRoutes) routes := make([]*LiveRoute, 0, vacuumTestRoutes)
deletions := make([]LiveRouteDeletion, 0, vacuumTestRoutes) deletions := make([]LiveRouteDeletion, 0, vacuumTestRoutes)
for i := range vacuumTestRoutes { for i := range vacuumTestRoutes {
route := mkV4Route(t, fmt.Sprintf("10.%d.%d.0/24", i/256, i%256), asn, ts) route := mkV4Route(t, fmt.Sprintf("10.%d.%d.0/24", i/256, i%256), asn, ts)
routes = append(routes, route) routes = append(routes, route)
@@ -240,29 +274,39 @@ func TestVacuumReturnsFreePages(t *testing.T) {
}) })
} }
if err := db.UpsertLiveRouteBatch(routes); err != nil { err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("failed to write routes: %v", err) t.Fatalf("failed to write routes: %v", err)
} }
if err := db.DeleteLiveRouteBatch(deletions); err != nil {
err = db.DeleteLiveRouteBatch(deletions)
if err != nil {
t.Fatalf("failed to delete routes: %v", err) t.Fatalf("failed to delete routes: %v", err)
} }
var before int var before int
if err := db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&before); err != nil {
err = db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&before)
if err != nil {
t.Fatalf("failed to read freelist_count: %v", err) t.Fatalf("failed to read freelist_count: %v", err)
} }
if before == 0 { if before == 0 {
t.Fatalf("no free pages after deleting %d routes", vacuumTestRoutes) t.Fatalf("no free pages after deleting %d routes", vacuumTestRoutes)
} }
if err := db.Vacuum(ctx); err != nil { err = db.Vacuum(ctx)
if err != nil {
t.Fatalf("Vacuum failed: %v", err) t.Fatalf("Vacuum failed: %v", err)
} }
var after int var after int
if err := db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&after); err != nil {
err = db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&after)
if err != nil {
t.Fatalf("failed to read freelist_count: %v", err) t.Fatalf("failed to read freelist_count: %v", err)
} }
if after != 0 { if after != 0 {
t.Errorf("free pages after Vacuum = %d of %d, want 0", after, before) t.Errorf("free pages after Vacuum = %d of %d, want 0", after, before)
} }
@@ -277,6 +321,8 @@ func TestVacuumReturnsFreePages(t *testing.T) {
// no batch is dropped. The checkpoint runs without the Database mutex, exactly // no batch is dropped. The checkpoint runs without the Database mutex, exactly
// as the background maintainer does in production. // as the background maintainer does in production.
func TestBatchWriteDuringCheckpoint(t *testing.T) { func TestBatchWriteDuringCheckpoint(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()} cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New()) db, err := New(cfg, logger.New())
@@ -289,8 +335,10 @@ func TestBatchWriteDuringCheckpoint(t *testing.T) {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -302,12 +350,14 @@ func TestBatchWriteDuringCheckpoint(t *testing.T) {
}() }()
ts := time.Now().UTC() ts := time.Now().UTC()
for i := 0; i < contentionIterations; i++ { for i := range contentionIterations {
asns := map[int]time.Time{ asns := map[int]time.Time{
i % contendedASNCount: ts, i % contendedASNCount: ts,
(i % contendedASNCount) + asnSecondBand: ts, (i % contendedASNCount) + asnSecondBand: ts,
} }
if err := db.GetOrCreateASNBatch(asns); err != nil {
err := db.GetOrCreateASNBatch(asns)
if err != nil {
cancel() cancel()
wg.Wait() wg.Wait()
t.Fatalf("batch write failed under checkpoint contention: %v", err) t.Fatalf("batch write failed under checkpoint contention: %v", err)
+26 -19
View File
@@ -1,4 +1,3 @@
// Package database provides SQLite storage for BGP routing data.
package database package database
import ( import (
@@ -9,6 +8,8 @@ import (
// ASN represents an Autonomous System Number with its metadata including // ASN represents an Autonomous System Number with its metadata including
// handle, description, WHOIS data, and first/last seen timestamps. // handle, description, WHOIS data, and first/last seen timestamps.
//
//nolint:tagliatelle // field names are the existing JSON API
type ASN struct { type ASN struct {
ASN int `json:"asn"` ASN int `json:"asn"`
Handle string `json:"handle"` Handle string `json:"handle"`
@@ -36,37 +37,39 @@ type ASN struct {
// Prefix represents an IP prefix (CIDR block) with its IP version (4 or 6) // Prefix represents an IP prefix (CIDR block) with its IP version (4 or 6)
// and first/last seen timestamps. // and first/last seen timestamps.
type Prefix struct { type Prefix struct {
ID uuid.UUID `json:"id"` ID uuid.UUID
Prefix string `json:"prefix"` Prefix string
IPVersion int `json:"ip_version"` // 4 or 6 IPVersion int // 4 or 6
FirstSeen time.Time `json:"first_seen"` FirstSeen time.Time
LastSeen time.Time `json:"last_seen"` LastSeen time.Time
} }
// Announcement represents a BGP announcement or withdrawal event, // Announcement represents a BGP announcement or withdrawal event,
// containing the prefix, AS path, origin ASN, peer ASN, next hop, and timestamp. // containing the prefix, AS path, origin ASN, peer ASN, next hop, and timestamp.
type Announcement struct { type Announcement struct {
ID uuid.UUID `json:"id"` ID uuid.UUID
PrefixID uuid.UUID `json:"prefix_id"` PrefixID uuid.UUID
PeerASN int `json:"peer_asn"` PeerASN int
OriginASN int `json:"origin_asn"` OriginASN int
Path string `json:"path"` // JSON-encoded AS path Path string // JSON-encoded AS path
NextHop string `json:"next_hop"` NextHop string
Timestamp time.Time `json:"timestamp"` Timestamp time.Time
IsWithdrawal bool `json:"is_withdrawal"` IsWithdrawal bool
} }
// ASNPeering represents a peering relationship between two ASNs, // ASNPeering represents a peering relationship between two ASNs,
// stored with the lower ASN as ASA and the higher as ASB. // stored with the lower ASN as ASA and the higher as ASB.
type ASNPeering struct { type ASNPeering struct {
ID uuid.UUID `json:"id"` ID uuid.UUID
ASA int `json:"as_a"` ASA int
ASB int `json:"as_b"` ASB int
FirstSeen time.Time `json:"first_seen"` FirstSeen time.Time
LastSeen time.Time `json:"last_seen"` LastSeen time.Time
} }
// LiveRoute represents a route in the live routing table // LiveRoute represents a route in the live routing table
//
//nolint:tagliatelle // field names are the existing JSON API
type LiveRoute struct { type LiveRoute struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
@@ -80,12 +83,16 @@ type LiveRoute struct {
} }
// PrefixDistribution represents the distribution of prefixes by mask length // PrefixDistribution represents the distribution of prefixes by mask length
//
//nolint:tagliatelle // field names are the existing JSON API
type PrefixDistribution struct { type PrefixDistribution struct {
MaskLength int `json:"mask_length"` MaskLength int `json:"mask_length"`
Count int `json:"count"` Count int `json:"count"`
} }
// IPInfo represents comprehensive IP information for the /ip endpoint // IPInfo represents comprehensive IP information for the /ip endpoint
//
//nolint:tagliatelle // field names are the existing JSON API
type IPInfo struct { type IPInfo struct {
IP string `json:"ip"` IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"` PTR []string `json:"ptr,omitempty"`