Re-vendor the shared files from sneak/prompts at dd4027b (closes #52)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Takes .golangci.yml, .dockerignore, .gitignore, .editorconfig, the workflow and REPO_POLICIES.md from sneak/prompts dd4027b, keeping this repository's own entries after the shared content. Lint (golangci-lint v2.14.0) and test become Dockerfile phases that script/lint and script/test build alone and uncached; the image build depends on both, and script/cibuild bootstraps, runs script/check, then builds the image. The agent guidance file moves to AGENTS.md. The Go code is fixed for what the new configuration finds; reads that iterate rows now also report an iteration error. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
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", OriginASN: 64502,
|
||||
PeerIP: testPeerIP, IPVersion: ipVersionV4,
|
||||
},
|
||||
{
|
||||
Prefix: "2001:db8:1:2::/64", OriginASN: 64502,
|
||||
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")
|
||||
}
|
||||
|
||||
// writeOriginRoutes writes one live route from testPeerIP for each prefix in
|
||||
// origins, originated by the AS it maps to.
|
||||
func writeOriginRoutes(t *testing.T, db *Database, origins map[string]int) {
|
||||
t.Helper()
|
||||
|
||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
routes := make([]*LiveRoute, 0, len(origins))
|
||||
|
||||
for prefix, asn := range origins {
|
||||
routes = append(routes, &LiveRoute{
|
||||
ID: uuid.New(),
|
||||
Prefix: prefix,
|
||||
MaskLength: netip.MustParsePrefix(prefix).Bits(),
|
||||
IPVersion: detectIPVersion(prefix),
|
||||
OriginASN: asn,
|
||||
PeerIP: testPeerIP,
|
||||
ASPath: []int{asn},
|
||||
NextHop: testPeerIP,
|
||||
LastUpdated: ts,
|
||||
})
|
||||
}
|
||||
|
||||
err := db.UpsertLiveRouteBatch(routes)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectionPoolPragmas holds several pooled connections open at once and
|
||||
// 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) {
|
||||
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 != sqliteCacheSizeKiB {
|
||||
t.Errorf("conn %d: cache_size = %d, want %d",
|
||||
i, cacheSize, sqliteCacheSizeKiB)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
OriginASN: asn,
|
||||
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 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) {
|
||||
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.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 := range contentionIterations {
|
||||
asns := map[int]time.Time{
|
||||
i % contendedASNCount: ts,
|
||||
(i % contendedASNCount) + asnSecondBand: ts,
|
||||
}
|
||||
|
||||
err := db.GetOrCreateASNBatch(asns)
|
||||
if err != nil {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
t.Fatalf("batch write failed under checkpoint contention: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user