check / check (push) Waiting to run
The IP lookups, the AS and prefix pages and their API answers, the prefix-length pages and the stats read routes only from the routing table the prefix handler keeps. fx reads the table at startup and gives it to both. AS names, WHOIS details and first-seen times still come from the database. The IP lookup names the origin AS most peers carry, with the time of the newest kept route. The prefix answers list peers whose routes are not kept without a path or time. The stats gain origins_without_kept_route. They no longer read route times from the per-peer tables, so the two helpers that did are gone. Model: opus-5-5
2575 lines
76 KiB
Go
2575 lines
76 KiB
Go
package database
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"net/netip"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
|
"github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// tempStoreMemory is the PRAGMA temp_store value meaning "hold temp B-trees in
|
|
// memory"; the DSN change must leave temp_store below this so they spill to disk.
|
|
const tempStoreMemory = 2
|
|
|
|
// autoVacuumIncremental is the PRAGMA auto_vacuum value meaning "incremental".
|
|
const autoVacuumIncremental = 2
|
|
|
|
// vacuumTestRoutes is how many routes the vacuum test writes and then deletes,
|
|
// enough to leave many free pages in the file.
|
|
const vacuumTestRoutes = 2000
|
|
|
|
// heldConnections is how many pooled connections the pragma test holds open at
|
|
// once so each is a distinct SQLite connection that parsed the DSN.
|
|
const heldConnections = 5
|
|
|
|
// bytesPerKiB turns a cache_size in KiB into bytes.
|
|
const bytesPerKiB = 1024
|
|
|
|
// testPeerIP is the peer every route in the IP lookup test is learned from.
|
|
const testPeerIP = "192.0.2.254"
|
|
|
|
// Parameters for the checkpoint-contention regression test.
|
|
const (
|
|
// contentionIterations is how many batch writes race the checkpoint loop.
|
|
contentionIterations = 400
|
|
// contendedASNCount is the small set of ASNs the batches reuse, so most
|
|
// batches update existing rows and exercise the read-before-write path.
|
|
contendedASNCount = 16
|
|
// asnSecondBand offsets a second ASN per batch so each batch writes more
|
|
// than one row.
|
|
asnSecondBand = 100
|
|
)
|
|
|
|
// Parameters for the tests of maintenance and the database lock.
|
|
const (
|
|
// lockMessageBuffer is how many lock messages a test database's logger
|
|
// holds before logging waits for the test to read them.
|
|
lockMessageBuffer = 64
|
|
// operationKey is the log attribute that names the operation taking the
|
|
// lock.
|
|
operationKey = "operation"
|
|
// testBatchWrite is the operation name the tests take the lock under.
|
|
testBatchWrite = "test batch write"
|
|
// lockWaitTimeout is the context timeout of a maintenance call that the
|
|
// tests let run out while it waits for the lock.
|
|
lockWaitTimeout = time.Millisecond
|
|
// lockTestASN is the AS the batch write in the lock tests stores.
|
|
lockTestASN = 64500
|
|
// readerCheckpointLimit is how long a checkpoint, and a batch write
|
|
// waiting for it, may take while a reader is open: well under the busy
|
|
// timeout, which a checkpoint that waits for the reader takes.
|
|
readerCheckpointLimit = time.Second
|
|
// truncateCheckpointLimit is how long a checkpoint of a WAL file over
|
|
// sqliteWALSizeLimitBytes may take to give up while a reader is open: its
|
|
// wait for the reader plus a second, well under the busy timeout.
|
|
truncateCheckpointLimit = truncateBusyTimeoutMs*time.Millisecond +
|
|
time.Second
|
|
)
|
|
|
|
// Parameters for the prefix-length sample tests.
|
|
const (
|
|
// sampleLimit is how many prefixes the tests ask the sample for.
|
|
sampleLimit = 10
|
|
// sampledPrefixCount is how many prefixes the sampling test stores, more
|
|
// than sampleLimit so the sample has to choose among them.
|
|
sampledPrefixCount = 30
|
|
// secondV4PeerIP and secondV6PeerIP are the second peer each prefix is
|
|
// learned from, besides the one mkV4Route and mkV6Route set.
|
|
secondV4PeerIP = "192.0.2.2"
|
|
secondV6PeerIP = "2001:db8::2"
|
|
// largeSamplePrefixCount is how many prefixes the test of a mask length
|
|
// with more than maxPrefixesToShuffle prefixes stores: twice that, so about
|
|
// half of them come after the first maxPrefixesToShuffle+1 in text order.
|
|
largeSamplePrefixCount = 2 * maxPrefixesToShuffle
|
|
// largeSampleLimit is how many prefixes that test asks for. Each random
|
|
// lookup lands after the first maxPrefixesToShuffle+1 in text order about
|
|
// half the time, so none of them doing so is about as likely as
|
|
// largeSampleLimit coin tosses all coming up heads.
|
|
largeSampleLimit = 30
|
|
// liveRoutesV4Table and liveRoutesV6Table are the live route tables the
|
|
// tests pass to the sampling steps.
|
|
liveRoutesV4Table = "live_routes_v4"
|
|
liveRoutesV6Table = "live_routes_v6"
|
|
)
|
|
|
|
// TestGetIPInfoFindsMostSpecificLiveRoute stores nested live prefixes for both
|
|
// families and checks that a lookup returns the most specific one covering the
|
|
// address, ErrNoRoute when none covers it, and the next less specific prefix
|
|
// once the only route of the most specific one is withdrawn.
|
|
func TestGetIPInfoFindsMostSpecificLiveRoute(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() }()
|
|
|
|
// Nested live prefixes, each originated by its own AS.
|
|
origins := map[string]int{
|
|
"10.0.0.0/8": 64500,
|
|
"10.1.0.0/16": 64501,
|
|
"10.1.2.0/24": 64502,
|
|
"2001:db8::/32": 64500,
|
|
"2001:db8:1::/48": 64501,
|
|
"2001:db8:1:2::/64": 64502,
|
|
}
|
|
writeOriginRoutes(t, db, origins)
|
|
|
|
// lookup checks that ip resolves to the live prefix want, or to ErrNoRoute
|
|
// when want is empty.
|
|
lookup := func(ip, want string) {
|
|
t.Helper()
|
|
|
|
info, err := db.GetIPInfo(ip)
|
|
if want == "" {
|
|
if !errors.Is(err, ErrNoRoute) {
|
|
t.Errorf("GetIPInfo(%s) = %+v, %v; want ErrNoRoute", ip, info, err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("GetIPInfo(%s): %v", ip, err)
|
|
|
|
return
|
|
}
|
|
|
|
if info.Netblock != want || info.MaskLength != netip.MustParsePrefix(want).Bits() ||
|
|
info.ASN != origins[want] {
|
|
t.Errorf("GetIPInfo(%s) = %s (mask %d) AS%d, want %s AS%d",
|
|
ip, info.Netblock, info.MaskLength, info.ASN, want, origins[want])
|
|
}
|
|
}
|
|
|
|
lookup("10.1.2.3", "10.1.2.0/24")
|
|
lookup("::ffff:10.1.2.3", "10.1.2.0/24")
|
|
lookup("10.1.3.4", "10.1.0.0/16")
|
|
lookup("10.2.0.1", "10.0.0.0/8")
|
|
lookup("192.0.2.1", "")
|
|
lookup("2001:db8:1:2::3", "2001:db8:1:2::/64")
|
|
lookup("2001:db8:1:3::4", "2001:db8:1::/48")
|
|
lookup("2001:db8:2::1", "2001:db8::/32")
|
|
lookup("2001:db9::1", "")
|
|
|
|
err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{
|
|
{Prefix: "10.1.2.0/24", PeerIP: testPeerIP, IPVersion: ipVersionV4},
|
|
{Prefix: "2001:db8:1:2::/64", PeerIP: testPeerIP, IPVersion: ipVersionV6},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DeleteLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
lookup("10.1.2.3", "10.1.0.0/16")
|
|
lookup("2001:db8:1:2::3", "2001:db8:1::/48")
|
|
}
|
|
|
|
// TestGetIPInfoIgnoresShortRoutes checks that the IP lookup does not report a
|
|
// default route, or a route shorter than /8 for IPv4 or /16 for IPv6, as the
|
|
// route for an address, and that a route of exactly /8 or /16, or longer, still
|
|
// is. The prefixes are written out so that a wrong limit fails the test.
|
|
func TestGetIPInfoIgnoresShortRoutes(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() }()
|
|
|
|
// announce writes one live route for prefix, originated by asn.
|
|
announce := func(prefix string, asn int) {
|
|
t.Helper()
|
|
|
|
writeOriginRoutes(t, db, map[string]int{prefix: asn})
|
|
}
|
|
|
|
// lookup checks that ip resolves to the live prefix netblock from asn.
|
|
lookup := func(ip, netblock string, asn int) {
|
|
t.Helper()
|
|
|
|
info, err := db.GetIPInfo(ip)
|
|
if err != nil {
|
|
t.Errorf("GetIPInfo(%s): %v", ip, err)
|
|
|
|
return
|
|
}
|
|
|
|
if info.Netblock != netblock || info.ASN != asn {
|
|
t.Errorf("GetIPInfo(%s) = %s AS%d, want %s AS%d",
|
|
ip, info.Netblock, info.ASN, netblock, asn)
|
|
}
|
|
}
|
|
|
|
// Default and very short routes, and routes one bit shorter than each limit.
|
|
announce("2000::/3", 64500)
|
|
announce("0.0.0.0/0", 64500)
|
|
announce("2000::/15", 64500)
|
|
announce("192.0.0.0/7", 64500)
|
|
|
|
for _, ip := range []string{"2001:db8::1", "192.0.2.1"} {
|
|
info, err := db.GetIPInfo(ip)
|
|
if !errors.Is(err, ErrNoRoute) {
|
|
t.Errorf("GetIPInfo(%s) with only short routes = %+v, %v; "+
|
|
"want ErrNoRoute", ip, info, err)
|
|
}
|
|
}
|
|
|
|
// Routes of exactly the minimum length are used.
|
|
announce("2001::/16", 64501)
|
|
announce("192.0.0.0/8", 64501)
|
|
|
|
lookup("2001:db8::1", "2001::/16", 64501)
|
|
lookup("192.0.2.1", "192.0.0.0/8", 64501)
|
|
|
|
announce("2001:db8::/32", 64502)
|
|
|
|
lookup("2001:db8::1", "2001:db8::/32", 64502)
|
|
}
|
|
|
|
// TestGetIPInfoNeedsNoWHOISRefreshForOriginZero checks that the IP lookup asks
|
|
// for no WHOIS lookup when the address's route has origin 0, no known origin,
|
|
// and still asks for one when its origin AS has no WHOIS data.
|
|
func TestGetIPInfoNeedsNoWHOISRefreshForOriginZero(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() }()
|
|
|
|
writeOriginRoutes(t, db, map[string]int{"10.0.0.0/8": 0, "11.0.0.0/8": 64500})
|
|
|
|
for ip, want := range map[string]bool{"10.0.0.1": false, "11.0.0.1": true} {
|
|
info, err := db.GetIPInfo(ip)
|
|
if err != nil {
|
|
t.Fatalf("GetIPInfo(%s): %v", ip, err)
|
|
}
|
|
|
|
if info.NeedsWHOISRefresh != want {
|
|
t.Errorf("GetIPInfo(%s) asks for a WHOIS lookup of AS%d: %t, want %t",
|
|
ip, info.ASN, info.NeedsWHOISRefresh, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLiveRouteTablesHoldOneRoutePerPrefixAndPeer checks that each live route
|
|
// table refuses a second route from a peer for a prefix, even with another
|
|
// origin AS. An upsert updates the peer's route before it would insert one, so
|
|
// only an insert straight into the table shows what the table allows.
|
|
func TestLiveRouteTablesHoldOneRoutePerPrefixAndPeer(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 otherOriginASN = 64501
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
for _, tt := range []struct {
|
|
route *LiveRoute
|
|
insertSQL string
|
|
}{
|
|
{
|
|
route: mkV4Route(t, "198.51.100.0/24", 64500, ts),
|
|
insertSQL: insertLiveRouteV4SQL,
|
|
},
|
|
{
|
|
route: mkV6Route(testV6Prefix, 64500, ts),
|
|
insertSQL: insertLiveRouteV6SQL,
|
|
},
|
|
} {
|
|
err := db.UpsertLiveRoute(tt.route)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRoute(%s): %v", tt.route.Prefix, err)
|
|
}
|
|
|
|
_, err = db.db.ExecContext(t.Context(), tt.insertSQL,
|
|
tt.route.Prefix, tt.route.MaskLength, otherOriginASN,
|
|
tt.route.PeerIP, "[]", tt.route.NextHop, ts)
|
|
|
|
var sqliteErr sqlite3.Error
|
|
if !errors.As(err, &sqliteErr) ||
|
|
sqliteErr.ExtendedCode != sqlite3.ErrConstraintUnique {
|
|
t.Errorf("second route from peer %s for %s: error %v, "+
|
|
"want a UNIQUE constraint error",
|
|
tt.route.PeerIP, tt.route.Prefix, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
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
|
|
// checks each one carries the per-connection settings from the DSN, plus the
|
|
// process-wide heap limits, and sees the new file with auto_vacuum
|
|
// incremental.
|
|
func TestConnectionPoolPragmas(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()
|
|
|
|
// Hold distinct connections open simultaneously so the pool must open a new
|
|
// one (each parsing the DSN) rather than hand back the same connection.
|
|
conns := make([]*sql.Conn, 0, heldConnections)
|
|
defer func() {
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
}()
|
|
|
|
for i := range heldConnections {
|
|
c, err := db.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to open connection %d: %v", i, err)
|
|
}
|
|
|
|
conns = append(conns, c)
|
|
}
|
|
|
|
for i, c := range conns {
|
|
cacheSize := readPragma(ctx, t, c, i, "cache_size")
|
|
if cacheSize != sqliteReadCacheSizeKiB {
|
|
t.Errorf("conn %d: cache_size = %d, want %d",
|
|
i, cacheSize, sqliteReadCacheSizeKiB)
|
|
}
|
|
|
|
busyTimeout := readPragma(ctx, t, c, i, "busy_timeout")
|
|
if busyTimeout != sqliteBusyTimeoutMs {
|
|
t.Errorf("conn %d: busy_timeout = %d, want %d",
|
|
i, busyTimeout, sqliteBusyTimeoutMs)
|
|
}
|
|
|
|
tempStore := readPragma(ctx, t, c, i, "temp_store")
|
|
if tempStore == tempStoreMemory {
|
|
t.Errorf("conn %d: temp_store = %d, want anything but %d (MEMORY)",
|
|
i, tempStore, tempStoreMemory)
|
|
}
|
|
|
|
softHeapLimit := readPragma(ctx, t, c, i, "soft_heap_limit")
|
|
if softHeapLimit != sqliteSoftHeapLimitBytes {
|
|
t.Errorf("conn %d: soft_heap_limit = %d, want %d",
|
|
i, softHeapLimit, sqliteSoftHeapLimitBytes)
|
|
}
|
|
|
|
hardHeapLimit := readPragma(ctx, t, c, i, "hard_heap_limit")
|
|
if hardHeapLimit != sqliteHardHeapLimitBytes {
|
|
t.Errorf("conn %d: hard_heap_limit = %d, want %d",
|
|
i, hardHeapLimit, sqliteHardHeapLimitBytes)
|
|
}
|
|
|
|
autoVacuum := readPragma(ctx, t, c, i, "auto_vacuum")
|
|
if autoVacuum != autoVacuumIncremental {
|
|
t.Errorf("conn %d: auto_vacuum = %d, want %d (incremental)",
|
|
i, autoVacuum, autoVacuumIncremental)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWritingConnectionCache checks that the connection kept for writing has
|
|
// the large cache, and that the soft heap limit is above the caches of all the
|
|
// connections together: SQLite shrinks the caches to stay under it.
|
|
func TestWritingConnectionCache(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 := t.Context()
|
|
|
|
var writeCacheSize, readCacheSize, softHeapLimit int64
|
|
|
|
err = db.writer.QueryRowContext(ctx, "PRAGMA cache_size").
|
|
Scan(&writeCacheSize)
|
|
if err != nil {
|
|
t.Fatalf("failed to read the writing connection's cache_size: %v", err)
|
|
}
|
|
|
|
if writeCacheSize != sqliteWriteCacheSizeKiB {
|
|
t.Errorf("writing connection: cache_size = %d, want %d",
|
|
writeCacheSize, sqliteWriteCacheSizeKiB)
|
|
}
|
|
|
|
err = db.db.QueryRowContext(ctx, "PRAGMA cache_size").Scan(&readCacheSize)
|
|
if err != nil {
|
|
t.Fatalf("failed to read a reading connection's cache_size: %v", err)
|
|
}
|
|
|
|
err = db.db.QueryRowContext(ctx, "PRAGMA soft_heap_limit").
|
|
Scan(&softHeapLimit)
|
|
if err != nil {
|
|
t.Fatalf("failed to read soft_heap_limit: %v", err)
|
|
}
|
|
|
|
cachesBytes := -bytesPerKiB * (writeCacheSize + (maxConns-1)*readCacheSize)
|
|
if softHeapLimit <= cachesBytes {
|
|
t.Errorf("soft_heap_limit = %d, not above the %d bytes of the caches",
|
|
softHeapLimit, cachesBytes)
|
|
}
|
|
}
|
|
|
|
// TestWritingConnectionWALSizeLimit checks that the connection kept for
|
|
// writing, the one that writes the WAL, has the WAL size limit, which cuts the
|
|
// WAL file back when SQLite starts the WAL again from the beginning.
|
|
func TestWritingConnectionWALSizeLimit(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() }()
|
|
|
|
var limit int64
|
|
|
|
err = db.writer.QueryRowContext(t.Context(), "PRAGMA journal_size_limit").
|
|
Scan(&limit)
|
|
if err != nil {
|
|
t.Fatalf("failed to read the writing connection's journal_size_limit: %v",
|
|
err)
|
|
}
|
|
|
|
if limit != sqliteWALSizeLimitBytes {
|
|
t.Errorf("writing connection: journal_size_limit = %d, want %d",
|
|
limit, sqliteWALSizeLimitBytes)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// 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.
|
|
// With auto_vacuum off (issue https://git.eeqj.de/sneak/routewatch/issues/43)
|
|
// the free pages stayed in the file, and with the PRAGMA run by ExecContext
|
|
// Vacuum freed only one page per call.
|
|
func TestVacuumReturnsFreePages(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()
|
|
ts := time.Now().UTC()
|
|
|
|
const asn = 64500
|
|
|
|
routes := make([]*LiveRoute, 0, vacuumTestRoutes)
|
|
deletions := make([]LiveRouteDeletion, 0, vacuumTestRoutes)
|
|
|
|
for i := range vacuumTestRoutes {
|
|
route := mkV4Route(t, fmt.Sprintf("10.%d.%d.0/24", i/256, i%256), asn, ts)
|
|
routes = append(routes, route)
|
|
deletions = append(deletions, LiveRouteDeletion{
|
|
Prefix: route.Prefix,
|
|
PeerIP: route.PeerIP,
|
|
IPVersion: ipVersionV4,
|
|
})
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("failed to write routes: %v", err)
|
|
}
|
|
|
|
err = db.DeleteLiveRouteBatch(deletions)
|
|
if err != nil {
|
|
t.Fatalf("failed to delete routes: %v", err)
|
|
}
|
|
|
|
var before int
|
|
|
|
err = db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&before)
|
|
if err != nil {
|
|
t.Fatalf("failed to read freelist_count: %v", err)
|
|
}
|
|
|
|
if before == 0 {
|
|
t.Fatalf("no free pages after deleting %d routes", vacuumTestRoutes)
|
|
}
|
|
|
|
err = db.Vacuum(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Vacuum failed: %v", err)
|
|
}
|
|
|
|
var after int
|
|
|
|
err = db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&after)
|
|
if err != nil {
|
|
t.Fatalf("failed to read freelist_count: %v", err)
|
|
}
|
|
|
|
if after != 0 {
|
|
t.Errorf("free pages after Vacuum = %d of %d, want 0", after, before)
|
|
}
|
|
}
|
|
|
|
// TestBatchWriteDuringCheckpoint runs checkpoints back to back while batch
|
|
// writes run and checks that every batch write succeeds: a checkpoint holds the
|
|
// database lock as a batch write does, and checkpoints must not keep batch
|
|
// writes from getting it.
|
|
func TestBatchWriteDuringCheckpoint(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, cancel := context.WithCancel(context.Background())
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
wg.Go(func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
_ = db.Checkpoint(ctx) // errors are the checkpoint's own to absorb
|
|
}
|
|
}
|
|
})
|
|
|
|
ts := time.Now().UTC()
|
|
seen := SeenTimes{First: ts, Last: ts}
|
|
|
|
for i := range contentionIterations {
|
|
asns := map[int]SeenTimes{
|
|
i % contendedASNCount: seen,
|
|
(i % contendedASNCount) + asnSecondBand: seen,
|
|
}
|
|
|
|
err := db.GetOrCreateASNBatch(asns)
|
|
if err != nil {
|
|
cancel()
|
|
wg.Wait()
|
|
t.Fatalf("batch write failed under checkpoint contention: %v", err)
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
wg.Wait()
|
|
}
|
|
|
|
// lockMessage is a debug message the database lock logged, and the name of the
|
|
// operation that logged it.
|
|
type lockMessage struct {
|
|
text string
|
|
operation string
|
|
}
|
|
|
|
// lockMessages is a log handler that passes on each message the database lock
|
|
// logs. The lock tests read it to know that an operation waits for the lock or
|
|
// holds it, instead of guessing how long that takes.
|
|
type lockMessages chan lockMessage
|
|
|
|
// Enabled lets every message through, so the lock's debug messages arrive.
|
|
func (m lockMessages) Enabled(context.Context, slog.Level) bool { return true }
|
|
|
|
// Handle passes on a message that names an operation and drops the others.
|
|
func (m lockMessages) Handle(_ context.Context, record slog.Record) error {
|
|
record.Attrs(func(attr slog.Attr) bool {
|
|
if attr.Key != operationKey {
|
|
return true
|
|
}
|
|
|
|
m <- lockMessage{text: record.Message, operation: attr.Value.String()}
|
|
|
|
return false
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// WithAttrs returns m: the tests need no attributes added.
|
|
func (m lockMessages) WithAttrs([]slog.Attr) slog.Handler { return m }
|
|
|
|
// WithGroup returns m: the tests need no groups.
|
|
func (m lockMessages) WithGroup(string) slog.Handler { return m }
|
|
|
|
// newDatabaseWithLockMessages opens a new database whose logger passes the lock's
|
|
// messages to the returned channel.
|
|
func newDatabaseWithLockMessages(t *testing.T) (*Database, lockMessages) {
|
|
t.Helper()
|
|
|
|
messages := make(lockMessages, lockMessageBuffer)
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
|
|
db, err := New(cfg, &logger.Logger{Logger: slog.New(messages)})
|
|
if err != nil {
|
|
t.Fatalf("failed to create database: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db, messages
|
|
}
|
|
|
|
// waitForLockMessage waits until operation logs text, and fails the test if
|
|
// returned delivers first: the call running operation returned without logging
|
|
// text.
|
|
func waitForLockMessage(
|
|
t *testing.T,
|
|
messages lockMessages,
|
|
text, operation string,
|
|
returned <-chan error,
|
|
) {
|
|
t.Helper()
|
|
|
|
for {
|
|
select {
|
|
case message := <-messages:
|
|
if message.text == text && message.operation == operation {
|
|
return
|
|
}
|
|
case err := <-returned:
|
|
t.Fatalf("%s returned (error: %v) without logging %q",
|
|
operation, err, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVacuumWaitsForBatchWrite holds the database lock as a batch write does and
|
|
// checks that Vacuum waits for it and then succeeds. Vacuum ran without the
|
|
// lock, met the batch writes at SQLite's write lock and failed with "database
|
|
// is locked" (https://git.eeqj.de/sneak/routewatch/issues/94).
|
|
func TestVacuumWaitsForBatchWrite(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, messages := newDatabaseWithLockMessages(t)
|
|
|
|
db.lock(testBatchWrite)
|
|
|
|
vacuumed := make(chan error, 1)
|
|
|
|
go func() { vacuumed <- db.Vacuum(context.Background()) }()
|
|
|
|
waitForLockMessage(t, messages, lockWaitMessage, "Vacuum", vacuumed)
|
|
|
|
db.unlock()
|
|
|
|
err := <-vacuumed
|
|
if err != nil {
|
|
t.Fatalf("Vacuum after the batch write: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBatchWriteWaitsForAnalyze keeps Analyze waiting for SQLite's write lock,
|
|
// which another connection holds, and checks that a batch write started
|
|
// meanwhile waits for Analyze to give up the database lock, that both then
|
|
// succeed, and that Analyze stored statistics. ANALYZE ran without the database
|
|
// lock, and a batch write that waited longer than the busy timeout for it failed
|
|
// with "database is locked".
|
|
func TestBatchWriteWaitsForAnalyze(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, messages := newDatabaseWithLockMessages(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
asns := map[int]SeenTimes{lockTestASN: {First: now, Last: now}}
|
|
|
|
// A stored AS gives ANALYZE a table to analyze and statistics to write.
|
|
err := db.GetOrCreateASNBatch(asns)
|
|
if err != nil {
|
|
t.Fatalf("failed to store an AS: %v", err)
|
|
}
|
|
|
|
// With _txlock=immediate this transaction takes SQLite's write lock at
|
|
// BEGIN, so ANALYZE has to wait for it while Analyze holds the database lock.
|
|
writer, err := db.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to begin the transaction ANALYZE waits for: %v", err)
|
|
}
|
|
|
|
defer func() { _ = writer.Rollback() }()
|
|
|
|
analyzed := make(chan error, 1)
|
|
|
|
go func() { analyzed <- db.Analyze(ctx) }()
|
|
|
|
waitForLockMessage(t, messages, lockHeldMessage, "Analyze", analyzed)
|
|
|
|
written := make(chan error, 1)
|
|
|
|
go func() { written <- db.GetOrCreateASNBatch(asns) }()
|
|
|
|
waitForLockMessage(t, messages, lockWaitMessage, "GetOrCreateASNBatch", written)
|
|
|
|
err = writer.Rollback()
|
|
if err != nil {
|
|
t.Fatalf("failed to end the transaction ANALYZE waits for: %v", err)
|
|
}
|
|
|
|
err = <-analyzed
|
|
if err != nil {
|
|
t.Errorf("Analyze: %v", err)
|
|
}
|
|
|
|
err = <-written
|
|
if err != nil {
|
|
t.Errorf("batch write after Analyze: %v", err)
|
|
}
|
|
|
|
var statistics int
|
|
|
|
err = db.db.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM sqlite_stat1 WHERE tbl = 'asns'").Scan(&statistics)
|
|
if err != nil {
|
|
t.Fatalf("failed to read the statistics: %v", err)
|
|
}
|
|
|
|
if statistics == 0 {
|
|
t.Error("Analyze stored no statistics for the asns table")
|
|
}
|
|
}
|
|
|
|
// TestCheckpointLockWaitEndsWithContext holds the database lock while the
|
|
// context of a waiting Checkpoint times out, and checks that Checkpoint then
|
|
// returns the context's error and leaves the lock free.
|
|
func TestCheckpointLockWaitEndsWithContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, messages := newDatabaseWithLockMessages(t)
|
|
|
|
db.lock(testBatchWrite)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), lockWaitTimeout)
|
|
defer cancel()
|
|
|
|
checkpointed := make(chan error, 1)
|
|
|
|
go func() { checkpointed <- db.Checkpoint(ctx) }()
|
|
|
|
waitForLockMessage(t, messages, lockWaitMessage, "Checkpoint", checkpointed)
|
|
<-ctx.Done()
|
|
db.unlock()
|
|
|
|
err := <-checkpointed
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Checkpoint = %v, want %v", err, context.DeadlineExceeded)
|
|
}
|
|
|
|
if !db.mu.TryLock() {
|
|
t.Fatal("Checkpoint kept the database lock after its context ended")
|
|
}
|
|
|
|
db.mu.Unlock()
|
|
}
|
|
|
|
// TestCheckpointDoesNotWaitForReader keeps a read transaction on
|
|
// live_routes_v4 open while a route is committed, then starts Checkpoint and a
|
|
// batch write together. It checks that both end within readerCheckpointLimit,
|
|
// whichever of them takes the database lock first, and that Checkpoint reports
|
|
// the part of the WAL the reader still needs as left uncopied. A TRUNCATE
|
|
// checkpoint waited the busy timeout for the reader while holding the lock
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/102).
|
|
func TestCheckpointDoesNotWaitForReader(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()
|
|
ts := time.Now().UTC()
|
|
|
|
err = db.UpsertLiveRouteBatch(
|
|
[]*LiveRoute{mkV4Route(t, "198.51.100.0/24", lockTestASN, ts)})
|
|
if err != nil {
|
|
t.Fatalf("failed to store the route the reader reads: %v", err)
|
|
}
|
|
|
|
reader, err := db.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to open the reading connection: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
// A plain BEGIN starts a transaction that only reads, unlike the BEGIN
|
|
// IMMEDIATE of _txlock. It keeps the snapshot its first read takes until
|
|
// it ends.
|
|
_, err = reader.ExecContext(ctx, "BEGIN")
|
|
if err != nil {
|
|
t.Fatalf("failed to begin the read transaction: %v", err)
|
|
}
|
|
defer func() { _, _ = reader.ExecContext(ctx, "ROLLBACK") }()
|
|
|
|
var routes int
|
|
|
|
err = reader.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM live_routes_v4").Scan(&routes)
|
|
if err != nil {
|
|
t.Fatalf("failed to read the live routes: %v", err)
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(
|
|
[]*LiveRoute{mkV4Route(t, "203.0.113.0/24", lockTestASN, ts)})
|
|
if err != nil {
|
|
t.Fatalf("failed to store a route after the read: %v", err)
|
|
}
|
|
|
|
started := time.Now()
|
|
checkpointed := make(chan error, 1)
|
|
|
|
go func() { checkpointed <- db.Checkpoint(ctx) }()
|
|
|
|
// A route write, not an AS write: the first new AS loads the embedded AS
|
|
// names, which takes seconds under -race.
|
|
route := mkV4Route(t, "192.0.2.0/24", lockTestASN, ts)
|
|
written := make(chan error, 1)
|
|
|
|
go func() { written <- db.UpsertLiveRouteBatch([]*LiveRoute{route}) }()
|
|
|
|
err = <-checkpointed
|
|
if !errors.Is(err, ErrCheckpointIncomplete) {
|
|
t.Errorf("Checkpoint = %v with a reader open, want %v",
|
|
err, ErrCheckpointIncomplete)
|
|
}
|
|
|
|
took := time.Since(started)
|
|
if took >= readerCheckpointLimit {
|
|
t.Errorf("Checkpoint took %v with a reader open, want under %v",
|
|
took, readerCheckpointLimit)
|
|
}
|
|
|
|
err = <-written
|
|
if err != nil {
|
|
t.Errorf("batch write during Checkpoint: %v", err)
|
|
}
|
|
|
|
took = time.Since(started)
|
|
if took >= readerCheckpointLimit {
|
|
t.Errorf("batch write during Checkpoint ended after %v, want under %v",
|
|
took, readerCheckpointLimit)
|
|
}
|
|
}
|
|
|
|
// TestCheckpointEmptiesWALOnceNoReadIsOpen fills the WAL file past
|
|
// sqliteWALSizeLimitBytes while the test holds a read open. With the read
|
|
// open, it checks that Checkpoint gives up within truncateCheckpointLimit and
|
|
// reports the checkpoint incomplete, and that a batch write still succeeds.
|
|
// After the test closes the read, it checks that the next Checkpoint brings
|
|
// the WAL file under the limit and gives the writing connection its busy
|
|
// timeout back (https://git.eeqj.de/sneak/routewatch/issues/102).
|
|
func TestCheckpointEmptiesWALOnceNoReadIsOpen(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 := t.Context()
|
|
|
|
reader, err := db.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to open the reading connection: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
// A plain BEGIN starts a transaction that only reads, unlike the BEGIN
|
|
// IMMEDIATE of _txlock. It keeps the snapshot its first read takes until
|
|
// it ends, so no checkpoint can copy what is written after that read.
|
|
_, err = reader.ExecContext(ctx, "BEGIN")
|
|
if err != nil {
|
|
t.Fatalf("failed to begin the read transaction: %v", err)
|
|
}
|
|
|
|
var routes int
|
|
|
|
err = reader.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM live_routes_v4").Scan(&routes)
|
|
if err != nil {
|
|
t.Fatalf("failed to read the live routes: %v", err)
|
|
}
|
|
|
|
fillWALPastLimit(ctx, t, db)
|
|
|
|
started := time.Now()
|
|
|
|
err = db.Checkpoint(ctx)
|
|
if !errors.Is(err, ErrCheckpointIncomplete) {
|
|
t.Errorf("Checkpoint = %v with a read open, want %v",
|
|
err, ErrCheckpointIncomplete)
|
|
}
|
|
|
|
took := time.Since(started)
|
|
if took >= truncateCheckpointLimit {
|
|
t.Errorf("Checkpoint took %v with a read open, want under %v",
|
|
took, truncateCheckpointLimit)
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{
|
|
mkV4Route(t, "192.0.2.0/24", lockTestASN, time.Now().UTC()),
|
|
})
|
|
if err != nil {
|
|
t.Errorf("batch write after Checkpoint gave up: %v", err)
|
|
}
|
|
|
|
_, err = reader.ExecContext(ctx, "ROLLBACK")
|
|
if err != nil {
|
|
t.Fatalf("failed to end the read transaction: %v", err)
|
|
}
|
|
|
|
err = db.Checkpoint(ctx)
|
|
if err != nil {
|
|
t.Errorf("Checkpoint with no read open: %v", err)
|
|
}
|
|
|
|
size := walFileSize(t, db)
|
|
if size >= sqliteWALSizeLimitBytes {
|
|
t.Errorf("WAL file of %d bytes after Checkpoint with no read open, "+
|
|
"want under %d", size, sqliteWALSizeLimitBytes)
|
|
}
|
|
|
|
busyTimeout := readPragma(ctx, t, db.writer, 0, "busy_timeout")
|
|
if busyTimeout != sqliteBusyTimeoutMs {
|
|
t.Errorf("writing connection: busy_timeout = %d after the checkpoints, "+
|
|
"want %d", busyTimeout, sqliteBusyTimeoutMs)
|
|
}
|
|
}
|
|
|
|
// fillWALPastLimit writes sqliteWALSizeLimitBytes of random bytes to a table
|
|
// of the test's own, which with SQLite's page and frame headers leaves the WAL
|
|
// file of db over the limit, and checks that it does.
|
|
func fillWALPastLimit(ctx context.Context, t *testing.T, db *Database) {
|
|
t.Helper()
|
|
|
|
_, err := db.writer.ExecContext(ctx, "CREATE TABLE wal_fill (data BLOB)")
|
|
if err != nil {
|
|
t.Fatalf("failed to create the table the test writes to: %v", err)
|
|
}
|
|
|
|
_, err = db.writer.ExecContext(ctx,
|
|
"INSERT INTO wal_fill VALUES (randomblob(?))", sqliteWALSizeLimitBytes)
|
|
if err != nil {
|
|
t.Fatalf("failed to fill the WAL: %v", err)
|
|
}
|
|
|
|
size := walFileSize(t, db)
|
|
if size <= sqliteWALSizeLimitBytes {
|
|
t.Fatalf("WAL file of %d bytes after the test's write, want over %d",
|
|
size, sqliteWALSizeLimitBytes)
|
|
}
|
|
}
|
|
|
|
// walFileSize returns the size of the WAL file of db.
|
|
func walFileSize(t *testing.T, db *Database) int64 {
|
|
t.Helper()
|
|
|
|
wal, err := os.Stat(db.path + walFileSuffix)
|
|
if err != nil {
|
|
t.Fatalf("failed to read the WAL file's size: %v", err)
|
|
}
|
|
|
|
return wal.Size()
|
|
}
|
|
|
|
// TestStartupCheckpointLogsItsFigures opens a new database, writes a route,
|
|
// and opens the same file again while the first stays open, so that its WAL
|
|
// file stays. The first open finds no WAL file: it logs a size of 0 and no
|
|
// warning. The second logs the size of the WAL file the first left, and the
|
|
// checkpoint's busy flag and frame counts, each under its own name; the
|
|
// counts read 0 once the checkpoint has emptied the file.
|
|
// Initialize logged the busy flag as wal_pages and the counts under each
|
|
// other's names (https://git.eeqj.de/sneak/routewatch/issues/117).
|
|
func TestStartupCheckpointLogsItsFigures(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
|
|
db, lines := openLogged(t, cfg)
|
|
|
|
for _, line := range lines {
|
|
if line[slog.LevelKey] == slog.LevelWarn.String() {
|
|
t.Errorf("opening a new database logged a warning: %v", line)
|
|
}
|
|
}
|
|
|
|
line := findLine(t, lines, startupCheckpointMessage)
|
|
if line["wal_bytes"] != 0.0 {
|
|
t.Errorf("new database: start-up checkpoint logged wal_bytes = %v, "+
|
|
"want 0", line["wal_bytes"])
|
|
}
|
|
|
|
err := db.UpsertLiveRouteBatch([]*LiveRoute{
|
|
mkV4Route(t, "192.0.2.0/24", lockTestASN, time.Now().UTC()),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to store a route: %v", err)
|
|
}
|
|
|
|
walBytes := walFileSize(t, db)
|
|
if walBytes == 0 {
|
|
t.Fatal("WAL file empty after the route write, want frames in it")
|
|
}
|
|
|
|
_, lines = openLogged(t, cfg)
|
|
line = findLine(t, lines, startupCheckpointMessage)
|
|
|
|
for _, key := range []string{"busy", "wal_frames", "checkpointed_frames"} {
|
|
if line[key] != 0.0 {
|
|
t.Errorf("start-up checkpoint logged %s = %v, want 0", key, line[key])
|
|
}
|
|
}
|
|
|
|
if line["wal_bytes"] != float64(walBytes) {
|
|
t.Errorf("start-up checkpoint logged wal_bytes = %v, want the %d "+
|
|
"bytes the first database left", line["wal_bytes"], walBytes)
|
|
}
|
|
}
|
|
|
|
// TestStartupCheckpointWarnsWhenIncomplete keeps a read open on a database
|
|
// while a route is written after the read began, then opens the same file
|
|
// again. The second open's start-up checkpoint cannot copy the frames written
|
|
// after the read began, and gives up once the busy timeout has passed. It
|
|
// checks that the checkpoint is logged as a warning with busy 1 and fewer
|
|
// frames copied than the WAL holds.
|
|
func TestStartupCheckpointWarnsWhenIncomplete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &config.Config{StateDir: t.TempDir()}
|
|
db, _ := openLogged(t, cfg)
|
|
ctx := context.Background()
|
|
|
|
reader, err := db.db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to open the reading connection: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
// A plain BEGIN starts a transaction that only reads, unlike the BEGIN
|
|
// IMMEDIATE of _txlock. It keeps the snapshot its first read takes until
|
|
// it ends.
|
|
_, err = reader.ExecContext(ctx, "BEGIN")
|
|
if err != nil {
|
|
t.Fatalf("failed to begin the read transaction: %v", err)
|
|
}
|
|
defer func() { _, _ = reader.ExecContext(ctx, "ROLLBACK") }()
|
|
|
|
var routes int
|
|
|
|
err = reader.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM live_routes_v4").Scan(&routes)
|
|
if err != nil {
|
|
t.Fatalf("failed to read the live routes: %v", err)
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{
|
|
mkV4Route(t, "192.0.2.0/24", lockTestASN, time.Now().UTC()),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to store a route after the read: %v", err)
|
|
}
|
|
|
|
_, lines := openLogged(t, cfg)
|
|
line := findLine(t, lines, startupCheckpointIncompleteMessage)
|
|
|
|
if line[slog.LevelKey] != slog.LevelWarn.String() {
|
|
t.Errorf("start-up checkpoint logged at level %v, want %s",
|
|
line[slog.LevelKey], slog.LevelWarn)
|
|
}
|
|
|
|
if line["busy"] != 1.0 {
|
|
t.Errorf("start-up checkpoint logged busy = %v, want 1", line["busy"])
|
|
}
|
|
|
|
walFrames, _ := line["wal_frames"].(float64)
|
|
checkpointedFrames, _ := line["checkpointed_frames"].(float64)
|
|
|
|
if checkpointedFrames >= walFrames {
|
|
t.Errorf("start-up checkpoint logged checkpointed_frames = %v and "+
|
|
"wal_frames = %v with a read open, want fewer copied than the WAL "+
|
|
"holds", line["checkpointed_frames"], line["wal_frames"])
|
|
}
|
|
}
|
|
|
|
// openLogged opens the database of cfg with a logger that writes JSON, and
|
|
// returns it with the lines it logged while it opened, decoded.
|
|
func openLogged(t *testing.T, cfg *config.Config) (*Database, []map[string]any) {
|
|
t.Helper()
|
|
|
|
var logged bytes.Buffer
|
|
|
|
db, err := New(cfg,
|
|
&logger.Logger{Logger: slog.New(slog.NewJSONHandler(&logged, nil))})
|
|
if err != nil {
|
|
t.Fatalf("failed to open the database: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
var lines []map[string]any
|
|
|
|
decoder := json.NewDecoder(&logged)
|
|
for decoder.More() {
|
|
var line map[string]any
|
|
|
|
err := decoder.Decode(&line)
|
|
if err != nil {
|
|
t.Fatalf("failed to decode the log: %v", err)
|
|
}
|
|
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
return db, lines
|
|
}
|
|
|
|
// findLine returns the first of lines logged with message.
|
|
func findLine(
|
|
t *testing.T,
|
|
lines []map[string]any,
|
|
message string,
|
|
) map[string]any {
|
|
t.Helper()
|
|
|
|
for _, line := range lines {
|
|
if line[slog.MessageKey] == message {
|
|
return line
|
|
}
|
|
}
|
|
|
|
t.Fatalf("no line %q in the log", message)
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestGetRandomPrefixesByLengthListsPrefixOnce stores one prefix of each
|
|
// family learned from two peers in the same second and checks that the
|
|
// prefix-length sample lists each of them once.
|
|
func TestGetRandomPrefixesByLengthListsPrefixOnce(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)
|
|
v4First := mkV4Route(t, "198.51.100.0/24", 64500, ts)
|
|
v4Second := mkV4Route(t, "198.51.100.0/24", 64500, ts)
|
|
v4Second.PeerIP = secondV4PeerIP
|
|
v6First := mkV6Route(testV6Prefix, 64500, ts)
|
|
v6Second := mkV6Route(testV6Prefix, 64500, ts)
|
|
v6Second.PeerIP = secondV6PeerIP
|
|
|
|
err = db.UpsertLiveRouteBatch(
|
|
[]*LiveRoute{v4First, v4Second, v6First, v6Second})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
for _, want := range []*LiveRoute{v4First, v6First} {
|
|
routes, err := db.GetRandomPrefixesByLength(want.MaskLength,
|
|
want.IPVersion, sampleLimit)
|
|
if err != nil {
|
|
t.Fatalf("GetRandomPrefixesByLength(%d, %d): %v",
|
|
want.MaskLength, want.IPVersion, err)
|
|
}
|
|
|
|
if len(routes) != 1 || routes[0].Prefix != want.Prefix {
|
|
t.Errorf("GetRandomPrefixesByLength(%d, %d) = %+v, want %s listed once",
|
|
want.MaskLength, want.IPVersion, routes, want.Prefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetRandomPrefixesByLengthListsNewestRoute stores a prefix learned from
|
|
// two peers at different times, the older route first, and checks that the
|
|
// prefix-length sample lists the newer route.
|
|
func TestGetRandomPrefixesByLengthListsNewestRoute(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() }()
|
|
|
|
older := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
olderRoute := mkV4Route(t, "198.51.100.0/24", 64500, older)
|
|
newerRoute := mkV4Route(t, "198.51.100.0/24", 64500, older.Add(time.Minute))
|
|
newerRoute.PeerIP = secondV4PeerIP
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{olderRoute, newerRoute})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
routes, err := db.GetRandomPrefixesByLength(newerRoute.MaskLength,
|
|
ipVersionV4, sampleLimit)
|
|
if err != nil {
|
|
t.Fatalf("GetRandomPrefixesByLength: %v", err)
|
|
}
|
|
|
|
if len(routes) != 1 || routes[0].PeerIP != newerRoute.PeerIP {
|
|
t.Errorf("GetRandomPrefixesByLength = %+v, want only the newer route, "+
|
|
"from %s", routes, newerRoute.PeerIP)
|
|
}
|
|
}
|
|
|
|
// TestGetRandomPrefixesByLengthSamplesManyPrefixes stores more /24 prefixes
|
|
// than the sample asks for, each followed by a /16 so that routes of another
|
|
// mask length sit between the /24 rows, and each prefix learned from two peers
|
|
// in the same second. It checks both the sample the pages get, picked from the
|
|
// shuffled prefixes, and the random lookups used for a mask length with more
|
|
// than maxPrefixesToShuffle prefixes.
|
|
func TestGetRandomPrefixesByLengthSamplesManyPrefixes(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)
|
|
|
|
var routes []*LiveRoute
|
|
|
|
stored := make([]string, 0, sampledPrefixCount)
|
|
|
|
for i := range sampledPrefixCount {
|
|
prefix := fmt.Sprintf("10.0.%d.0/24", i)
|
|
for _, p := range []string{prefix, fmt.Sprintf("172.%d.0.0/16", i)} {
|
|
first := mkV4Route(t, p, 64500, ts)
|
|
second := mkV4Route(t, p, 64500, ts)
|
|
second.PeerIP = secondV4PeerIP
|
|
routes = append(routes, first, second)
|
|
}
|
|
|
|
stored = append(stored, prefix)
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
slices.Sort(stored)
|
|
|
|
maskLength := routes[0].MaskLength
|
|
|
|
sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV4, sampleLimit)
|
|
if err != nil {
|
|
t.Fatalf("GetRandomPrefixesByLength: %v", err)
|
|
}
|
|
|
|
listed := make([]string, 0, len(sample))
|
|
for _, route := range sample {
|
|
listed = append(listed, route.Prefix)
|
|
}
|
|
|
|
checkPrefixSample(t, "GetRandomPrefixesByLength", listed, stored)
|
|
|
|
looked, err := db.samplePrefixesByLength(
|
|
context.Background(), liveRoutesV4Table, maskLength, sampleLimit, nil)
|
|
if err != nil {
|
|
t.Fatalf("samplePrefixesByLength: %v", err)
|
|
}
|
|
|
|
checkPrefixSample(t, "samplePrefixesByLength", looked, stored)
|
|
}
|
|
|
|
// checkPrefixSample checks that sample lists sampleLimit prefixes, each once
|
|
// and each one of stored, and that it is not just the first sampleLimit of
|
|
// stored, which is in text order.
|
|
func checkPrefixSample(t *testing.T, name string, sample, stored []string) {
|
|
t.Helper()
|
|
|
|
if len(sample) != sampleLimit {
|
|
t.Errorf("%s listed %d prefixes, want %d", name, len(sample), sampleLimit)
|
|
}
|
|
|
|
listed := make(map[string]bool)
|
|
|
|
for _, prefix := range sample {
|
|
if listed[prefix] || !slices.Contains(stored, prefix) {
|
|
t.Errorf("%s lists %s twice or it is not one of the /24s", name, prefix)
|
|
}
|
|
|
|
listed[prefix] = true
|
|
}
|
|
|
|
if slices.Equal(slices.Sorted(slices.Values(sample)), stored[:sampleLimit]) {
|
|
t.Errorf("%s lists just the first %d prefixes in text order",
|
|
name, sampleLimit)
|
|
}
|
|
}
|
|
|
|
// TestGetRandomPrefixesByLengthSamplesPastFirstPrefixes stores more prefixes
|
|
// of one mask length than maxPrefixesToShuffle. It checks that
|
|
// firstPrefixesByLength reads just the first maxPrefixesToShuffle+1 in text
|
|
// order, and that the sample lists the prefixes asked for, each once, some of
|
|
// them after those first ones, which only the random lookups reach.
|
|
func TestGetRandomPrefixesByLengthSamplesPastFirstPrefixes(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)
|
|
routes := make([]*LiveRoute, 0, largeSamplePrefixCount)
|
|
stored := make([]string, 0, largeSamplePrefixCount)
|
|
|
|
for i := range largeSamplePrefixCount {
|
|
route := mkV6Route(fmt.Sprintf("2001:%x::/32", i), 64500, ts)
|
|
routes = append(routes, route)
|
|
stored = append(stored, route.Prefix)
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
slices.Sort(stored)
|
|
firstPrefixes := stored[:maxPrefixesToShuffle+1]
|
|
maskLength := routes[0].MaskLength
|
|
|
|
read, err := db.firstPrefixesByLength(context.Background(),
|
|
liveRoutesV6Table, maskLength, maxPrefixesToShuffle+1)
|
|
if err != nil {
|
|
t.Fatalf("firstPrefixesByLength: %v", err)
|
|
}
|
|
|
|
if !slices.Equal(read, firstPrefixes) {
|
|
t.Errorf("firstPrefixesByLength read %d prefixes, want the first %d "+
|
|
"in text order", len(read), len(firstPrefixes))
|
|
}
|
|
|
|
sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV6,
|
|
largeSampleLimit)
|
|
if err != nil {
|
|
t.Fatalf("GetRandomPrefixesByLength: %v", err)
|
|
}
|
|
|
|
if len(sample) != largeSampleLimit {
|
|
t.Errorf("GetRandomPrefixesByLength listed %d prefixes, want %d",
|
|
len(sample), largeSampleLimit)
|
|
}
|
|
|
|
listed := make(map[string]bool)
|
|
pastFirstPrefixes := 0
|
|
|
|
for _, route := range sample {
|
|
if listed[route.Prefix] {
|
|
t.Errorf("GetRandomPrefixesByLength lists %s twice", route.Prefix)
|
|
}
|
|
|
|
listed[route.Prefix] = true
|
|
|
|
if !slices.Contains(firstPrefixes, route.Prefix) {
|
|
pastFirstPrefixes++
|
|
}
|
|
}
|
|
|
|
if pastFirstPrefixes == 0 {
|
|
t.Errorf("GetRandomPrefixesByLength lists only prefixes among the first "+
|
|
"%d in text order", len(firstPrefixes))
|
|
}
|
|
}
|
|
|
|
// TestSamplePrefixesByLengthFillsUpFromShuffled stores a single prefix, so
|
|
// every random lookup lands on it, and checks that the sample still lists
|
|
// sampleLimit prefixes: the stored one, then the shuffled ones in order. The
|
|
// stored prefix is the second shuffled one, so the fill-up must skip it.
|
|
func TestSamplePrefixesByLengthFillsUpFromShuffled(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() }()
|
|
|
|
shuffled := make([]string, 0, sampledPrefixCount)
|
|
for i := range sampledPrefixCount {
|
|
shuffled = append(shuffled, fmt.Sprintf("10.0.%d.0/24", i))
|
|
}
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
route := mkV4Route(t, shuffled[1], 64500, ts)
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{route})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
sample, err := db.samplePrefixesByLength(context.Background(),
|
|
liveRoutesV4Table, route.MaskLength, sampleLimit, shuffled)
|
|
if err != nil {
|
|
t.Fatalf("samplePrefixesByLength: %v", err)
|
|
}
|
|
|
|
want := append([]string{shuffled[1], shuffled[0]}, shuffled[2:sampleLimit]...)
|
|
if !slices.Equal(sample, want) {
|
|
t.Errorf("samplePrefixesByLength = %v, want %v", sample, want)
|
|
}
|
|
}
|
|
|
|
// TestGetRandomPrefixesByLengthSkipsWithdrawnRoutes withdraws a route after
|
|
// storing it, as can happen between the steps of GetRandomPrefixesByLength. It
|
|
// checks that the random lookups pick nothing for a mask length with no route
|
|
// left, and that only the prefixes that still have a route get their newest
|
|
// route listed.
|
|
func TestGetRandomPrefixesByLengthSkipsWithdrawnRoutes(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)
|
|
kept := mkV4Route(t, "198.51.100.0/24", 64500, ts)
|
|
withdrawn := mkV4Route(t, "172.16.0.0/16", 64500, ts)
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{kept, withdrawn})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
err = db.DeleteLiveRoute(withdrawn.Prefix, withdrawn.PeerIP)
|
|
if err != nil {
|
|
t.Fatalf("DeleteLiveRoute: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
sample, err := db.samplePrefixesByLength(ctx, liveRoutesV4Table,
|
|
withdrawn.MaskLength, sampleLimit, []string{withdrawn.Prefix})
|
|
if err != nil {
|
|
t.Fatalf("samplePrefixesByLength: %v", err)
|
|
}
|
|
|
|
if len(sample) != 0 {
|
|
t.Errorf("samplePrefixesByLength = %v, want none for a mask length "+
|
|
"with no route left", sample)
|
|
}
|
|
|
|
routes, err := db.newestRouteOfEachPrefix(ctx, liveRoutesV4Table,
|
|
ipVersionV4, []string{withdrawn.Prefix, kept.Prefix})
|
|
if err != nil {
|
|
t.Fatalf("newestRouteOfEachPrefix: %v", err)
|
|
}
|
|
|
|
if len(routes) != 1 || routes[0].Prefix != kept.Prefix {
|
|
t.Errorf("newestRouteOfEachPrefix = %+v, want only the route of %s",
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetASDetailsListsEachPrefixOnce stores two IPv4 prefixes and an IPv6
|
|
// prefix an AS originates, two of them from two peers at different times, and
|
|
// a prefix another AS originates. The AS details list each of the AS's
|
|
// prefixes once, IPv4 first and in text order, with its mask length and the
|
|
// time of its newest route.
|
|
func TestGetASDetailsListsEachPrefixOnce(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 asn, otherASN = 64500, 64501
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
_, err = db.GetOrCreateASN(asn, ts)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN: %v", err)
|
|
}
|
|
|
|
v4Older := mkV4Route(t, "192.0.2.0/24", asn, ts)
|
|
v4Newer := mkV4Route(t, "192.0.2.0/24", asn, ts.Add(time.Minute))
|
|
v4Newer.PeerIP = secondV4PeerIP
|
|
v4Single := mkV4Route(t, "198.51.100.128/25", asn, ts)
|
|
v6Newer := mkV6Route(testV6Prefix, asn, ts.Add(time.Minute))
|
|
v6Older := mkV6Route(testV6Prefix, asn, ts)
|
|
v6Older.PeerIP = secondV6PeerIP
|
|
other := mkV4Route(t, "203.0.113.0/24", otherASN, ts)
|
|
|
|
err = db.UpsertLiveRouteBatch([]*LiveRoute{
|
|
v4Older, v4Newer, v4Single, v6Newer, v6Older, other,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
_, prefixes, err := db.GetASDetails(asn)
|
|
if err != nil {
|
|
t.Fatalf("GetASDetails: %v", err)
|
|
}
|
|
|
|
want := []*LiveRoute{v4Newer, v4Single, v6Newer}
|
|
|
|
listedOnce := slices.EqualFunc(prefixes, want,
|
|
func(got LiveRoute, want *LiveRoute) bool {
|
|
return got.Prefix == want.Prefix &&
|
|
got.MaskLength == want.MaskLength &&
|
|
got.IPVersion == want.IPVersion &&
|
|
got.OriginASN == asn &&
|
|
got.LastUpdated.Equal(want.LastUpdated)
|
|
})
|
|
if !listedOnce {
|
|
t.Errorf("GetASDetails listed %+v, want the prefixes and times of %+v",
|
|
prefixes, want)
|
|
}
|
|
}
|
|
|
|
// TestGetASDetailsOfASNotInDatabase asks for ASes that the database has no
|
|
// record of, some with stored routes. Each gets the prefixes of its routes and
|
|
// no seen times. One that the AS info lookup knows gets its handle and
|
|
// description from it. An AS with no record, no lookup entry and no route is
|
|
// ErrNoRoute. Before the fix every AS with no record was ErrNoRoute.
|
|
func TestGetASDetailsOfASNotInDatabase(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() }()
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
asn int
|
|
listed bool // in the AS info lookup
|
|
prefixes []string // of the routes stored for the AS
|
|
}{
|
|
{15169, true, []string{"192.0.2.0/24"}},
|
|
{13335, true, nil},
|
|
{4200000000, false, []string{"198.51.100.0/24"}},
|
|
}
|
|
|
|
var routes []*LiveRoute
|
|
|
|
for _, tt := range tests {
|
|
if _, ok := asinfo.Get(tt.asn); ok != tt.listed {
|
|
t.Fatalf("AS%d in the AS info data: %t, want %t", tt.asn, ok, tt.listed)
|
|
}
|
|
|
|
for _, prefix := range tt.prefixes {
|
|
routes = append(routes, mkV4Route(t, prefix, tt.asn, ts))
|
|
}
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got, prefixes, err := db.GetASDetails(tt.asn)
|
|
if err != nil {
|
|
t.Errorf("GetASDetails(%d): %v", tt.asn, err)
|
|
|
|
continue
|
|
}
|
|
|
|
want := ASN{ASN: tt.asn}
|
|
if info, ok := asinfo.Get(tt.asn); ok {
|
|
want.Handle, want.Description = info.Handle, info.Description
|
|
}
|
|
|
|
var gotPrefixes []string
|
|
for _, route := range prefixes {
|
|
gotPrefixes = append(gotPrefixes, route.Prefix)
|
|
}
|
|
|
|
if *got != want || !slices.Equal(gotPrefixes, tt.prefixes) {
|
|
t.Errorf("GetASDetails(%d) = %+v with prefixes %v, want %+v with %v",
|
|
tt.asn, *got, gotPrefixes, want, tt.prefixes)
|
|
}
|
|
}
|
|
|
|
const absentASN = 4200000001 // no record, no lookup entry and no route
|
|
|
|
if _, ok := asinfo.Get(absentASN); ok {
|
|
t.Fatalf("AS%d is in the AS info data", absentASN)
|
|
}
|
|
|
|
_, _, err = db.GetASDetails(absentASN)
|
|
if !errors.Is(err, ErrNoRoute) {
|
|
t.Errorf("GetASDetails(%d): error %v, want %v", absentASN, err, ErrNoRoute)
|
|
}
|
|
}
|
|
|
|
// TestGetASReadsTheRecordOrTheBuiltInList asks GetAS for an AS the asns table
|
|
// holds, one only the AS info lookup holds, and one neither holds. The first
|
|
// gets its record's seen times, the second its handle and description from the
|
|
// lookup, and only the third is not known.
|
|
func TestGetASReadsTheRecordOrTheBuiltInList(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 (
|
|
storedASN = 64500 // a documentation AS, not in the lookup
|
|
listedASN = 15169 // in the lookup, not stored
|
|
unknownASN = 4200000001 // in neither
|
|
)
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
_, err = db.GetOrCreateASN(storedASN, ts)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN: %v", err)
|
|
}
|
|
|
|
listed, ok := asinfo.Get(listedASN)
|
|
if !ok {
|
|
t.Fatalf("AS%d is not in the AS info data", listedASN)
|
|
}
|
|
|
|
tests := []struct {
|
|
asn int
|
|
want ASN
|
|
wantKnown bool
|
|
}{
|
|
{storedASN, ASN{ASN: storedASN, FirstSeen: ts, LastSeen: ts}, true},
|
|
{listedASN, ASN{
|
|
ASN: listedASN, Handle: listed.Handle, Description: listed.Description,
|
|
}, true},
|
|
{unknownASN, ASN{ASN: unknownASN}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got, known, err := db.GetAS(t.Context(), tt.asn)
|
|
if err != nil {
|
|
t.Errorf("GetAS(%d): %v", tt.asn, err)
|
|
|
|
continue
|
|
}
|
|
|
|
if !got.FirstSeen.Equal(tt.want.FirstSeen) ||
|
|
!got.LastSeen.Equal(tt.want.LastSeen) {
|
|
t.Errorf("GetAS(%d) seen %v to %v, want %v to %v", tt.asn,
|
|
got.FirstSeen, got.LastSeen, tt.want.FirstSeen, tt.want.LastSeen)
|
|
}
|
|
|
|
got.FirstSeen, got.LastSeen = tt.want.FirstSeen, tt.want.LastSeen
|
|
if *got != tt.want || known != tt.wantKnown {
|
|
t.Errorf("GetAS(%d) = %+v, known %t; want %+v, known %t",
|
|
tt.asn, *got, known, tt.want, tt.wantKnown)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetASWHOISReadsTheStoredDetails stores an AS with WHOIS details and asks
|
|
// GetASWHOIS for it and for an AS the asns table does not hold. The first gets
|
|
// its stored handle, description and details, and the time they were fetched.
|
|
// The second gets only its number, although the AS info lookup holds it.
|
|
func TestGetASWHOISReadsTheStoredDetails(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 storedASN, unstoredASN = 15169, 13335
|
|
|
|
_, err = db.GetOrCreateASN(storedASN, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN: %v", err)
|
|
}
|
|
|
|
update := &ASNWHOISUpdate{
|
|
ASN: storedASN, ASName: "TEST-AS", OrgName: "Test Org", OrgID: "ORG-1",
|
|
Address: "1 Test Street", CountryCode: "DE", AbuseEmail: "abuse@test",
|
|
RIR: "RIPE",
|
|
}
|
|
|
|
err = db.UpdateASNWHOIS(t.Context(), update)
|
|
if err != nil {
|
|
t.Fatalf("UpdateASNWHOIS: %v", err)
|
|
}
|
|
|
|
got, err := db.GetASWHOIS(t.Context(), storedASN)
|
|
if err != nil {
|
|
t.Fatalf("GetASWHOIS(%d): %v", storedASN, err)
|
|
}
|
|
|
|
want := ASN{
|
|
ASN: storedASN, Handle: asinfo.GetHandle(storedASN),
|
|
Description: asinfo.GetDescription(storedASN), ASName: update.ASName,
|
|
OrgName: update.OrgName, OrgID: update.OrgID, Address: update.Address,
|
|
CountryCode: update.CountryCode, AbuseEmail: update.AbuseEmail,
|
|
RIR: update.RIR,
|
|
}
|
|
|
|
if got.WHOISUpdatedAt == nil {
|
|
t.Errorf("GetASWHOIS(%d) has no time the details were fetched", storedASN)
|
|
}
|
|
|
|
got.WHOISUpdatedAt = nil
|
|
if *got != want {
|
|
t.Errorf("GetASWHOIS(%d) = %+v, want %+v", storedASN, *got, want)
|
|
}
|
|
|
|
got, err = db.GetASWHOIS(t.Context(), unstoredASN)
|
|
if err != nil || *got != (ASN{ASN: unstoredASN}) {
|
|
t.Errorf("GetASWHOIS(%d) = %+v, %v; want only the number",
|
|
unstoredASN, got, err)
|
|
}
|
|
}
|
|
|
|
// TestGetPrefixFirstSeenReadsThePrefixTables stores an IPv4 and an IPv6
|
|
// prefix and expects GetPrefixFirstSeen to give each its first-seen time, and
|
|
// the zero time for a prefix that is not stored.
|
|
func TestGetPrefixFirstSeenReadsThePrefixTables(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() }()
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
stored := map[string]time.Time{
|
|
"192.0.2.0/24": ts,
|
|
testV6Prefix: ts.Add(time.Hour),
|
|
}
|
|
|
|
err = db.UpdatePrefixesBatch(stored)
|
|
if err != nil {
|
|
t.Fatalf("UpdatePrefixesBatch: %v", err)
|
|
}
|
|
|
|
stored["198.51.100.0/24"] = time.Time{}
|
|
|
|
for prefix, want := range stored {
|
|
got, err := db.GetPrefixFirstSeen(t.Context(), netip.MustParsePrefix(prefix))
|
|
if err != nil || !got.Equal(want) {
|
|
t.Errorf("GetPrefixFirstSeen(%s) = %v, %v; want %v",
|
|
prefix, got, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestASPrefixQueriesUseCoveringIndexes checks the plan of each query that
|
|
// lists the prefixes an AS originates: a search of a covering index and no
|
|
// temporary B-tree, so the list reads one index entry per prefix, not one
|
|
// table row per route. Grouping the AS's routes by prefix read every route's
|
|
// row and sorted them in a temporary B-tree
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/104).
|
|
func TestASPrefixQueriesUseCoveringIndexes(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() }()
|
|
|
|
tests := []struct {
|
|
query string
|
|
args []any
|
|
index string
|
|
}{
|
|
{nextOriginPrefixV4SQL, []any{0, ""}, "idx_live_routes_v4_origin_prefix"},
|
|
{newestRouteTimeV4SQL, []any{""}, "idx_live_routes_v4_prefix_last_updated"},
|
|
{nextOriginPrefixV6SQL, []any{0, ""}, "idx_live_routes_v6_origin_prefix"},
|
|
{newestRouteTimeV6SQL, []any{""}, "idx_live_routes_v6_prefix_last_updated"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
plan := queryPlan(t, db, tt.query, tt.args...)
|
|
|
|
if !strings.Contains(plan, "USING COVERING INDEX "+tt.index+" ") ||
|
|
strings.Contains(plan, "TEMP B-TREE") {
|
|
t.Errorf("plan of %s:\n%s\nwant a search of covering index %s "+
|
|
"and no temporary B-tree", tt.query, plan, tt.index)
|
|
}
|
|
}
|
|
}
|
|
|
|
// queryPlan returns the steps of EXPLAIN QUERY PLAN for query, one per line.
|
|
func queryPlan(t *testing.T, db *Database, query string, args ...any) string {
|
|
t.Helper()
|
|
|
|
rows, err := db.db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query,
|
|
args...)
|
|
if err != nil {
|
|
t.Fatalf("EXPLAIN QUERY PLAN %s: %v", query, err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var steps []string
|
|
|
|
for rows.Next() {
|
|
var (
|
|
id, parent, unused int
|
|
detail string
|
|
)
|
|
|
|
err := rows.Scan(&id, &parent, &unused, &detail)
|
|
if err != nil {
|
|
t.Fatalf("reading the plan of %s: %v", query, err)
|
|
}
|
|
|
|
steps = append(steps, detail)
|
|
}
|
|
|
|
err = rows.Err()
|
|
if err != nil {
|
|
t.Fatalf("reading the plan of %s: %v", query, err)
|
|
}
|
|
|
|
return strings.Join(steps, "\n")
|
|
}
|
|
|
|
// TestUpdatePrefixesBatchKeepsFirstSeen writes a prefix, then a batch with it
|
|
// and a new prefix. Only the new prefix is inserted and counted, and the known
|
|
// one keeps its first_seen time.
|
|
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 time.Time
|
|
}{
|
|
{
|
|
query: "SELECT first_seen FROM prefixes_v4 WHERE prefix = ?",
|
|
prefix: known, firstSeen: first,
|
|
},
|
|
{
|
|
query: "SELECT first_seen FROM prefixes_v6 WHERE prefix = ?",
|
|
prefix: testV6Prefix, firstSeen: later,
|
|
},
|
|
} {
|
|
var firstSeen time.Time
|
|
|
|
err := db.db.QueryRowContext(t.Context(), tt.query, tt.prefix).
|
|
Scan(&firstSeen)
|
|
if err != nil {
|
|
t.Fatalf("reading %s: %v", tt.prefix, err)
|
|
}
|
|
|
|
if !firstSeen.Equal(tt.firstSeen) {
|
|
t.Errorf("%s: first seen %s, want %s", tt.prefix, firstSeen,
|
|
tt.firstSeen)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUpdatePeerBatchStoresTheEarliestAndLatestTimesOfANewPeer writes a new
|
|
// peer whose updates span 5 seconds. Its row is first seen at the earliest time
|
|
// and last seen at the latest.
|
|
func TestUpdatePeerBatchStoresTheEarliestAndLatestTimesOfANewPeer(
|
|
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() }()
|
|
|
|
first := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
last := first.Add(5 * time.Second)
|
|
|
|
err = db.UpdatePeerBatch(map[string]PeerUpdate{
|
|
testPeerIP: {
|
|
PeerIP: testPeerIP, PeerASN: 64500, MessageType: "UPDATE",
|
|
Seen: SeenTimes{First: first, Last: last},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdatePeerBatch: %v", err)
|
|
}
|
|
|
|
var firstSeen, lastSeen time.Time
|
|
|
|
err = db.db.QueryRowContext(t.Context(),
|
|
"SELECT first_seen, last_seen FROM bgp_peers WHERE peer_ip = ?",
|
|
testPeerIP).Scan(&firstSeen, &lastSeen)
|
|
if err != nil {
|
|
t.Fatalf("reading peer %s: %v", testPeerIP, err)
|
|
}
|
|
|
|
if !firstSeen.Equal(first) || !lastSeen.Equal(last) {
|
|
t.Errorf("peer %s: first seen %s, last seen %s; want %s, %s",
|
|
testPeerIP, firstSeen, lastSeen, first, last)
|
|
}
|
|
}
|
|
|
|
// backlogAge is how much older one write is than the other in the tests of
|
|
// first_seen and last_seen, as when a collector catches up on a backlog.
|
|
const backlogAge = 24 * time.Hour
|
|
|
|
// batchSpan is how much earlier a batch's First is than its Last in the tests
|
|
// of first_seen and last_seen.
|
|
const batchSpan = time.Hour
|
|
|
|
// TestWritesKeepEarliestFirstSeenAndLatestLastSeen writes a row of each of
|
|
// asns, peerings and bgp_peers through each function that writes one, at a
|
|
// newer time and at an older one. Each row takes the older write's First as
|
|
// first_seen and the newer write's Last as last_seen. The test writes in both
|
|
// orders, each in a database of its own. Newer then older is a collector
|
|
// catching up on a backlog; older then newer is a feed that arrives in order.
|
|
// Each function writes a row of its own. A batch write's First is batchSpan
|
|
// before its Last, so the test catches the two swapped. A single-row write
|
|
// takes one time.
|
|
func TestWritesKeepEarliestFirstSeenAndLatestLastSeen(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
batchASN, singleASN = 64500, 64501
|
|
peerASN = 64502
|
|
asnQuery = "SELECT first_seen, last_seen FROM asns WHERE asn = ?"
|
|
peeringQuery = "SELECT first_seen, last_seen FROM peerings " +
|
|
"WHERE as_a = ? AND as_b = ?"
|
|
peerQuery = "SELECT first_seen, last_seen FROM bgp_peers " +
|
|
"WHERE peer_ip = ?"
|
|
)
|
|
|
|
for name, olderFirst := range map[string]bool{
|
|
"newer then older": false,
|
|
"older then newer": true,
|
|
} {
|
|
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() }()
|
|
|
|
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
|
|
return db.GetOrCreateASNBatch(map[int]SeenTimes{batchASN: seen})
|
|
}, batchSpan, asnQuery, batchASN)
|
|
|
|
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
|
|
_, err := db.GetOrCreateASN(singleASN, seen.Last)
|
|
|
|
return err
|
|
}, 0, asnQuery, singleASN)
|
|
|
|
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
|
|
return db.RecordPeering(batchASN, singleASN, seen)
|
|
}, batchSpan, peeringQuery, batchASN, singleASN)
|
|
|
|
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
|
|
return db.UpdatePeerBatch(map[string]PeerUpdate{
|
|
testV4PeerIP: {
|
|
PeerIP: testV4PeerIP, PeerASN: peerASN,
|
|
MessageType: ristypes.MessageTypeUpdate,
|
|
Seen: seen,
|
|
},
|
|
})
|
|
}, batchSpan, peerQuery, testV4PeerIP)
|
|
|
|
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
|
|
return db.UpdatePeer(testPeerIP, peerASN,
|
|
ristypes.MessageTypeUpdate, seen.Last)
|
|
}, 0, peerQuery, testPeerIP)
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkSeenTimes calls write twice, each time with First span before Last: at
|
|
// newer times and at times backlogAge older. It writes the older times first
|
|
// when olderFirst is true. It checks that query, given args, reads the older
|
|
// write's First as first_seen and the newer write's Last as last_seen.
|
|
func checkSeenTimes(
|
|
t *testing.T, db *Database, olderFirst bool,
|
|
write func(seen SeenTimes) error,
|
|
span time.Duration, query string, args ...any,
|
|
) {
|
|
t.Helper()
|
|
|
|
newerLast := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
newer := SeenTimes{First: newerLast.Add(-span), Last: newerLast}
|
|
older := SeenTimes{
|
|
First: newer.First.Add(-backlogAge),
|
|
Last: newer.Last.Add(-backlogAge),
|
|
}
|
|
|
|
writes := []SeenTimes{newer, older}
|
|
if olderFirst {
|
|
writes = []SeenTimes{older, newer}
|
|
}
|
|
|
|
for _, seen := range writes {
|
|
err := write(seen)
|
|
if err != nil {
|
|
t.Fatalf("writing first %s, last %s: %v", seen.First, seen.Last, err)
|
|
}
|
|
}
|
|
|
|
var firstSeen, lastSeen time.Time
|
|
|
|
err := db.db.QueryRowContext(t.Context(), query, args...).
|
|
Scan(&firstSeen, &lastSeen)
|
|
if err != nil {
|
|
t.Fatalf("reading the row: %v", err)
|
|
}
|
|
|
|
if !firstSeen.Equal(older.First) || !lastSeen.Equal(newer.Last) {
|
|
t.Errorf("first seen %s, last seen %s; want %s, %s",
|
|
firstSeen, lastSeen, older.First, newer.Last)
|
|
}
|
|
}
|
|
|
|
// TestGetOrCreateASNReturnsStoredSeenTimes stores an AS at a newer time, then
|
|
// at an older one. The second call returns the times the row now holds: the
|
|
// older first_seen and the newer last_seen.
|
|
func TestGetOrCreateASNReturnsStoredSeenTimes(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 asn = 64500
|
|
|
|
newer := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
older := newer.Add(-backlogAge)
|
|
|
|
_, err = db.GetOrCreateASN(asn, newer)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN at %s: %v", newer, err)
|
|
}
|
|
|
|
got, err := db.GetOrCreateASN(asn, older)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateASN at %s: %v", older, err)
|
|
}
|
|
|
|
if !got.FirstSeen.Equal(older) || !got.LastSeen.Equal(newer) {
|
|
t.Errorf("first seen %s, last seen %s; want %s, %s",
|
|
got.FirstSeen, got.LastSeen, older, newer)
|
|
}
|
|
}
|
|
|
|
// TestUpsertLiveRouteBatchWritesInKeyOrder writes new routes of two peers out
|
|
// of key order, the last one a second announcement of the first, and checks
|
|
// that the rows were inserted in key order, peer then prefix (each new row's id
|
|
// is one above the last), and that the route announced twice carries the later
|
|
// announcement.
|
|
func TestUpsertLiveRouteBatchWritesInKeyOrder(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, laterOrigin = 64500, 64501
|
|
// Three prefixes, in text order.
|
|
firstPrefix = "192.0.2.0/24"
|
|
secondPrefix = "198.51.100.0/24"
|
|
thirdPrefix = "203.0.113.0/24"
|
|
)
|
|
|
|
type row struct {
|
|
peer, prefix string
|
|
origin int
|
|
}
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
announcements := []row{
|
|
{secondV4PeerIP, secondPrefix, firstOrigin},
|
|
{testV4PeerIP, thirdPrefix, firstOrigin},
|
|
{secondV4PeerIP, firstPrefix, firstOrigin},
|
|
{testV4PeerIP, secondPrefix, firstOrigin},
|
|
{secondV4PeerIP, secondPrefix, laterOrigin},
|
|
}
|
|
|
|
routes := make([]*LiveRoute, 0, len(announcements))
|
|
|
|
for _, announced := range announcements {
|
|
route := mkV4Route(t, announced.prefix, announced.origin, ts)
|
|
route.PeerIP = announced.peer
|
|
routes = append(routes, route)
|
|
}
|
|
|
|
err = db.UpsertLiveRouteBatch(routes)
|
|
if err != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
|
}
|
|
|
|
rows, err := db.db.QueryContext(t.Context(),
|
|
"SELECT peer_ip, prefix, origin_asn FROM live_routes_v4 ORDER BY id")
|
|
if err != nil {
|
|
t.Fatalf("reading the routes: %v", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var stored []row
|
|
|
|
for rows.Next() {
|
|
var r row
|
|
|
|
err := rows.Scan(&r.peer, &r.prefix, &r.origin)
|
|
if err != nil {
|
|
t.Fatalf("reading a route: %v", err)
|
|
}
|
|
|
|
stored = append(stored, r)
|
|
}
|
|
|
|
err = rows.Err()
|
|
if err != nil {
|
|
t.Fatalf("reading the routes: %v", err)
|
|
}
|
|
|
|
want := []row{
|
|
{testV4PeerIP, secondPrefix, firstOrigin},
|
|
{testV4PeerIP, thirdPrefix, firstOrigin},
|
|
{secondV4PeerIP, firstPrefix, firstOrigin},
|
|
{secondV4PeerIP, secondPrefix, laterOrigin},
|
|
}
|
|
|
|
if !slices.Equal(stored, want) {
|
|
t.Errorf("routes in insertion order = %+v, want %+v", stored, want)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// benchMaxBatchSize is the largest batch the prefix handler writes, the
|
|
// one it holds while a slow write runs (prefixBatchMaxSize in
|
|
// internal/routewatch).
|
|
benchMaxBatchSize = 250000
|
|
// 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 batch of 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 times a full batch of 25,000 updates,
|
|
// then the largest batch, 250,000. 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 10x ./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 _, batchSize := range []int{benchBatchSize, benchMaxBatchSize} {
|
|
b.Run(strconv.Itoa(batchSize), func(b *testing.B) {
|
|
for b.Loop() {
|
|
ts = ts.Add(time.Second)
|
|
|
|
var (
|
|
routes []*LiveRoute
|
|
withdrawals []LiveRouteDeletion
|
|
)
|
|
|
|
for i := range batchSize {
|
|
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 every prefix the batch names, of
|
|
// which only the ones not stored yet are inserted.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Parameters for BenchmarkGetASDetailsWithFiveHundredPeers: the AS originates
|
|
// benchASPrefixes prefixes and every one of benchASPeers peers has a route for
|
|
// each, 2.5 million routes in all.
|
|
const (
|
|
benchASPrefixes = 5000
|
|
benchASPeers = 500
|
|
)
|
|
|
|
// BenchmarkGetASDetailsWithFiveHundredPeers times the read behind the AS page
|
|
// for an AS that originates 5,000 IPv4 prefixes, each from 500 peers. It
|
|
// builds that database first, one peer at a time, which takes some minutes and
|
|
// about a GB of disk under TMPDIR. It is run by hand, not by make test, for
|
|
// example in the test image:
|
|
//
|
|
// go test -run '^$' -bench GetASDetails -benchtime 10x ./internal/database/
|
|
func BenchmarkGetASDetailsWithFiveHundredPeers(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() }()
|
|
|
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
|
|
_, err = db.GetOrCreateASN(benchFirstASN, ts)
|
|
if err != nil {
|
|
b.Fatalf("GetOrCreateASN: %v", err)
|
|
}
|
|
|
|
for peer := range benchASPeers {
|
|
peerIP := fmt.Sprintf("198.18.%d.%d", peer/256, peer%256)
|
|
|
|
routes := make([]*LiveRoute, 0, benchASPrefixes)
|
|
for prefix := range benchASPrefixes {
|
|
routes = append(routes, &LiveRoute{
|
|
Prefix: fmt.Sprintf("%d.%d.%d.0/%d",
|
|
1+prefix>>16, (prefix>>8)%256, prefix%256, benchMaskLength),
|
|
MaskLength: benchMaskLength,
|
|
IPVersion: ipVersionV4,
|
|
OriginASN: benchFirstASN,
|
|
PeerIP: peerIP,
|
|
ASPath: []int{benchFirstASN + 1 + peer, benchFirstASN},
|
|
NextHop: peerIP,
|
|
LastUpdated: ts,
|
|
})
|
|
}
|
|
|
|
writeBenchBatch(b, db, routes, nil, ts)
|
|
}
|
|
|
|
for b.Loop() {
|
|
_, prefixes, err := db.GetASDetails(benchFirstASN)
|
|
if err != nil {
|
|
b.Fatalf("GetASDetails: %v", err)
|
|
}
|
|
|
|
if len(prefixes) != benchASPrefixes {
|
|
b.Fatalf("GetASDetails listed %d prefixes, want %d",
|
|
len(prefixes), benchASPrefixes)
|
|
}
|
|
}
|
|
}
|