check / check (push) Waiting to run
A peer has at most one route for a prefix, but the live route tables were keyed by prefix, origin AS and peer. An announcement with a new origin AS added a second row instead of replacing the first, and a withdrawal sent in the same message as announcements was given their origin AS and so missed the withdrawn route. The tables are now unique on prefix and peer. An announcement updates the peer's row for the prefix, origin AS included, and a withdrawal deletes it without looking at an origin AS. The origin-only delete statements, the deletion's origin field and DeleteLiveRoute's origin parameter are removed. Model: opus-5-5
426 lines
14 KiB
Go
426 lines
14 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"github.com/google/uuid"
|
|
"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
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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) {
|
|
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,
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
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
|
|
// 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) {
|
|
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)
|
|
announce := func(prefix string, asn int) {
|
|
t.Helper()
|
|
|
|
err := db.UpsertLiveRouteBatch([]*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 != nil {
|
|
t.Fatalf("UpsertLiveRouteBatch(%s): %v", prefix, err)
|
|
}
|
|
}
|
|
|
|
// 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"} {
|
|
if info, err := db.GetIPInfo(ip); !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)
|
|
}
|
|
|
|
// 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) {
|
|
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("2001:db8::/32", 64500, ts), insertSQL: insertLiveRouteV6SQL},
|
|
} {
|
|
if err := db.UpsertLiveRoute(tt.route); err != nil {
|
|
t.Fatalf("UpsertLiveRoute(%s): %v", tt.route.Prefix, err)
|
|
}
|
|
|
|
_, err := db.db.Exec(tt.insertSQL, uuid.NewString(), 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConnectionPoolPragmas holds several pooled connections open at once and
|
|
// 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
|
|
// incremental.
|
|
func TestConnectionPoolPragmas(t *testing.T) {
|
|
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 := 0; i < heldConnections; i++ {
|
|
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 {
|
|
var cacheSize int
|
|
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 {
|
|
t.Errorf("conn %d: cache_size = %d, want %d", i, cacheSize, sqliteCacheSizeKiB)
|
|
}
|
|
|
|
var busyTimeout int
|
|
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 {
|
|
t.Errorf("conn %d: busy_timeout = %d, want %d", i, busyTimeout, sqliteBusyTimeoutMs)
|
|
}
|
|
|
|
var tempStore int
|
|
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 {
|
|
t.Errorf("conn %d: temp_store = %d, want anything but %d (MEMORY)", i, tempStore, tempStoreMemory)
|
|
}
|
|
|
|
var hardHeapLimit int64
|
|
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 {
|
|
t.Errorf("conn %d: hard_heap_limit = %d, want %d", i, hardHeapLimit, sqliteHardHeapLimitBytes)
|
|
}
|
|
|
|
var autoVacuum int
|
|
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 {
|
|
t.Errorf("conn %d: auto_vacuum = %d, want %d (incremental)", i, autoVacuum, autoVacuumIncremental)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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,
|
|
})
|
|
}
|
|
|
|
if err := db.UpsertLiveRouteBatch(routes); err != nil {
|
|
t.Fatalf("failed to write routes: %v", err)
|
|
}
|
|
if err := db.DeleteLiveRouteBatch(deletions); err != nil {
|
|
t.Fatalf("failed to delete routes: %v", err)
|
|
}
|
|
|
|
var before int
|
|
if err := db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&before); err != nil {
|
|
t.Fatalf("failed to read freelist_count: %v", err)
|
|
}
|
|
if before == 0 {
|
|
t.Fatalf("no free pages after deleting %d routes", vacuumTestRoutes)
|
|
}
|
|
|
|
if err := db.Vacuum(ctx); err != nil {
|
|
t.Fatalf("Vacuum failed: %v", err)
|
|
}
|
|
|
|
var after int
|
|
if err := db.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&after); 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 reproduces issue #25. A batch write reads
|
|
// (SELECT) before it writes (INSERT/UPDATE). Under the default deferred locking
|
|
// the transaction begins as a reader and, when the maintainer's WAL checkpoint
|
|
// holds the write lock, its upgrade to writer fails immediately with "database
|
|
// is locked" without honouring busy_timeout, dropping the batch. With
|
|
// _txlock=immediate the transaction takes the write lock at BEGIN and waits, so
|
|
// no batch is dropped. The checkpoint runs without the Database mutex, exactly
|
|
// as the background maintainer does in production.
|
|
func TestBatchWriteDuringCheckpoint(t *testing.T) {
|
|
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.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
_ = db.Checkpoint(ctx) // errors are the checkpoint's own to absorb
|
|
}
|
|
}
|
|
}()
|
|
|
|
ts := time.Now().UTC()
|
|
for i := 0; i < contentionIterations; i++ {
|
|
asns := map[int]time.Time{
|
|
i % contendedASNCount: ts,
|
|
(i % contendedASNCount) + asnSecondBand: ts,
|
|
}
|
|
if err := db.GetOrCreateASNBatch(asns); err != nil {
|
|
cancel()
|
|
wg.Wait()
|
|
t.Fatalf("batch write failed under checkpoint contention: %v", err)
|
|
}
|
|
}
|
|
|
|
cancel()
|
|
wg.Wait()
|
|
}
|