check / check (push) Canceled after 0s
The live route tables lose their random UUID key: the row id, which the prefix-length sample draws, is now declared as the integer key. The prefix tables are keyed by the prefix, without the UUID or the duplicate index. A route update sets only the columns that change, so SQLite no longer rewrites the mask length indexes on every update, nor the origin index while the origin AS stays the same. Each kept index names the queries that use it. BenchmarkPrefixBatchOnFiveMillionRoutes, run by hand, times one 25,000-update batch on 5 million routes. Model: opus-5-5
653 lines
15 KiB
Go
653 lines
15 KiB
Go
package routewatch
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"git.eeqj.de/sneak/routewatch/internal/server"
|
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
|
)
|
|
|
|
// mockStore is a mock implementation of database.Store for testing
|
|
type mockStore struct {
|
|
mu sync.Mutex
|
|
|
|
// Counters for tracking calls
|
|
ASNCount int
|
|
PrefixCount int
|
|
PeeringCount int
|
|
RouteCount int
|
|
WithdrawalCount int
|
|
|
|
// Track unique items
|
|
ASNs map[int]*database.ASN
|
|
Prefixes map[string]*database.Prefix
|
|
Peerings map[string]bool // key is "from_to"
|
|
Routes map[string]bool // key is "prefix_origin_peer"
|
|
|
|
// Track IP versions
|
|
IPv4Prefixes int
|
|
IPv6Prefixes int
|
|
}
|
|
|
|
// newMockStore creates a new mock store
|
|
func newMockStore() *mockStore {
|
|
return &mockStore{
|
|
ASNs: make(map[int]*database.ASN),
|
|
Prefixes: make(map[string]*database.Prefix),
|
|
Peerings: make(map[string]bool),
|
|
Routes: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// GetOrCreateASN mock implementation
|
|
func (m *mockStore) GetOrCreateASN(
|
|
number int, timestamp time.Time,
|
|
) (*database.ASN, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if asn, exists := m.ASNs[number]; exists {
|
|
asn.LastSeen = timestamp
|
|
|
|
return asn, nil
|
|
}
|
|
|
|
asn := &database.ASN{
|
|
ASN: number,
|
|
FirstSeen: timestamp,
|
|
LastSeen: timestamp,
|
|
}
|
|
m.ASNs[number] = asn
|
|
m.ASNCount++
|
|
|
|
return asn, nil
|
|
}
|
|
|
|
// UpdatePrefixesBatch mock implementation
|
|
func (m *mockStore) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
for prefix, timestamp := range prefixes {
|
|
if p, exists := m.Prefixes[prefix]; exists {
|
|
p.LastSeen = timestamp
|
|
} else {
|
|
const (
|
|
ipVersionV4 = 4
|
|
ipVersionV6 = 6
|
|
)
|
|
|
|
ipVersion := ipVersionV4
|
|
if strings.Contains(prefix, ":") {
|
|
ipVersion = ipVersionV6
|
|
}
|
|
|
|
m.Prefixes[prefix] = &database.Prefix{
|
|
Prefix: prefix,
|
|
IPVersion: ipVersion,
|
|
FirstSeen: timestamp,
|
|
LastSeen: timestamp,
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetOrCreatePrefix mock implementation
|
|
func (m *mockStore) GetOrCreatePrefix(
|
|
prefix string, timestamp time.Time,
|
|
) (*database.Prefix, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if p, exists := m.Prefixes[prefix]; exists {
|
|
p.LastSeen = timestamp
|
|
|
|
return p, nil
|
|
}
|
|
|
|
const (
|
|
ipVersionV4 = 4
|
|
ipVersionV6 = 6
|
|
)
|
|
|
|
ipVersion := ipVersionV4
|
|
if strings.Contains(prefix, ":") {
|
|
ipVersion = ipVersionV6
|
|
}
|
|
|
|
p := &database.Prefix{
|
|
Prefix: prefix,
|
|
IPVersion: ipVersion,
|
|
FirstSeen: timestamp,
|
|
LastSeen: timestamp,
|
|
}
|
|
m.Prefixes[prefix] = p
|
|
m.PrefixCount++
|
|
|
|
if ipVersion == ipVersionV4 {
|
|
m.IPv4Prefixes++
|
|
} else {
|
|
m.IPv6Prefixes++
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// RecordAnnouncement mock implementation
|
|
func (m *mockStore) RecordAnnouncement(_ *database.Announcement) error {
|
|
// Not tracking announcements in detail for now
|
|
return nil
|
|
}
|
|
|
|
// RecordPeering mock implementation
|
|
func (m *mockStore) RecordPeering(asA, asB int, _ time.Time) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Normalize
|
|
if asA > asB {
|
|
asA, asB = asB, asA
|
|
}
|
|
|
|
key := fmt.Sprintf("%d_%d", asA, asB)
|
|
if !m.Peerings[key] {
|
|
m.Peerings[key] = true
|
|
m.PeeringCount++
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdatePeer mock implementation
|
|
func (m *mockStore) UpdatePeer(_ string, _ int, _ string, _ time.Time) error {
|
|
// Simple mock - just return nil
|
|
return nil
|
|
}
|
|
|
|
// Close mock implementation
|
|
func (m *mockStore) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// GetStats returns statistics about the mock store
|
|
func (m *mockStore) GetStats() (database.Stats, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
return database.Stats{
|
|
ASNs: len(m.ASNs),
|
|
Prefixes: len(m.Prefixes),
|
|
IPv4Prefixes: m.IPv4Prefixes,
|
|
IPv6Prefixes: m.IPv6Prefixes,
|
|
Peerings: m.PeeringCount,
|
|
Peers: 10, // Mock peer count
|
|
}, nil
|
|
}
|
|
|
|
// GetStatsContext returns statistics about the mock store with context support
|
|
func (m *mockStore) GetStatsContext(_ context.Context) (database.Stats, error) {
|
|
return m.GetStats()
|
|
}
|
|
|
|
// UpsertLiveRoute mock implementation
|
|
func (m *mockStore) UpsertLiveRoute(_ *database.LiveRoute) error {
|
|
// Simple mock - just return nil
|
|
return nil
|
|
}
|
|
|
|
// DeleteLiveRoute mock implementation
|
|
func (m *mockStore) DeleteLiveRoute(_, _ string) error {
|
|
// Simple mock - just return nil
|
|
return nil
|
|
}
|
|
|
|
// GetPrefixDistribution mock implementation
|
|
func (m *mockStore) GetPrefixDistribution() (
|
|
[]database.PrefixDistribution, []database.PrefixDistribution, error,
|
|
) {
|
|
// Return empty distributions for now
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// GetPrefixDistributionContext mock implementation with context support
|
|
func (m *mockStore) GetPrefixDistributionContext(_ context.Context) (
|
|
[]database.PrefixDistribution, []database.PrefixDistribution, error,
|
|
) {
|
|
return m.GetPrefixDistribution()
|
|
}
|
|
|
|
// GetLiveRouteCounts mock implementation
|
|
func (m *mockStore) GetLiveRouteCounts() (int, int, error) {
|
|
// Return mock counts
|
|
return m.RouteCount / 2, m.RouteCount / 2, nil
|
|
}
|
|
|
|
// GetLiveRouteCountsContext mock implementation with context support
|
|
func (m *mockStore) GetLiveRouteCountsContext(_ context.Context) (int, int, error) {
|
|
return m.GetLiveRouteCounts()
|
|
}
|
|
|
|
// GetASDetails mock implementation
|
|
func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Check if ASN exists
|
|
if asnInfo, exists := m.ASNs[asn]; exists {
|
|
// Return empty prefixes for now
|
|
return asnInfo, []database.LiveRoute{}, nil
|
|
}
|
|
|
|
return nil, nil, database.ErrNoRoute
|
|
}
|
|
|
|
// GetASDetailsContext mock implementation with context support
|
|
func (m *mockStore) GetASDetailsContext(
|
|
_ context.Context, asn int,
|
|
) (*database.ASN, []database.LiveRoute, error) {
|
|
return m.GetASDetails(asn)
|
|
}
|
|
|
|
// GetPrefixDetails mock implementation
|
|
func (m *mockStore) GetPrefixDetails(
|
|
_ string,
|
|
) ([]database.LiveRoute, map[int]database.ASN, error) {
|
|
// Return empty routes for now
|
|
return []database.LiveRoute{}, map[int]database.ASN{}, nil
|
|
}
|
|
|
|
// GetPrefixDetailsContext mock implementation with context support
|
|
func (m *mockStore) GetPrefixDetailsContext(
|
|
_ context.Context, prefix string,
|
|
) ([]database.LiveRoute, map[int]database.ASN, error) {
|
|
return m.GetPrefixDetails(prefix)
|
|
}
|
|
|
|
func (m *mockStore) GetRandomPrefixesByLength(
|
|
_, _, _ int,
|
|
) ([]database.LiveRoute, error) {
|
|
// Return empty routes for now
|
|
return []database.LiveRoute{}, nil
|
|
}
|
|
|
|
// GetRandomPrefixesByLengthContext mock implementation with context support
|
|
func (m *mockStore) GetRandomPrefixesByLengthContext(
|
|
_ context.Context, maskLength, ipVersion, limit int,
|
|
) ([]database.LiveRoute, error) {
|
|
return m.GetRandomPrefixesByLength(maskLength, ipVersion, limit)
|
|
}
|
|
|
|
// GetASPeers mock implementation
|
|
func (m *mockStore) GetASPeers(_ int) ([]database.ASPeer, error) {
|
|
// Return empty peers for now
|
|
return []database.ASPeer{}, nil
|
|
}
|
|
|
|
// GetASPeersContext mock implementation with context support
|
|
func (m *mockStore) GetASPeersContext(
|
|
_ context.Context, asn int,
|
|
) ([]database.ASPeer, error) {
|
|
return m.GetASPeers(asn)
|
|
}
|
|
|
|
// GetIPInfo mock implementation
|
|
func (m *mockStore) GetIPInfo(ip string) (*database.IPInfo, error) {
|
|
return m.GetIPInfoContext(context.Background(), ip)
|
|
}
|
|
|
|
// GetIPInfoContext mock implementation with context support
|
|
func (m *mockStore) GetIPInfoContext(
|
|
_ context.Context, ip string,
|
|
) (*database.IPInfo, error) {
|
|
now := time.Now()
|
|
|
|
return &database.IPInfo{
|
|
IP: ip,
|
|
Netblock: "8.8.8.0/24",
|
|
MaskLength: 24,
|
|
IPVersion: 4,
|
|
NumPeers: 3,
|
|
ASN: 15169,
|
|
Handle: "GOOGLE",
|
|
Description: "Google LLC",
|
|
CountryCode: "US",
|
|
FirstSeen: now.Add(-24 * time.Hour),
|
|
LastSeen: now,
|
|
}, nil
|
|
}
|
|
|
|
// GetNextStaleASN mock implementation
|
|
func (m *mockStore) GetNextStaleASN(_ context.Context, _ time.Duration) (int, error) {
|
|
return 0, database.ErrNoStaleASN
|
|
}
|
|
|
|
// UpdateASNWHOIS mock implementation
|
|
func (m *mockStore) UpdateASNWHOIS(
|
|
_ context.Context, _ *database.ASNWHOISUpdate,
|
|
) error {
|
|
return nil
|
|
}
|
|
|
|
// GetWHOISStats mock implementation
|
|
func (m *mockStore) GetWHOISStats(
|
|
_ context.Context, _ time.Duration,
|
|
) (*database.WHOISStats, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
return &database.WHOISStats{
|
|
TotalASNs: len(m.ASNs),
|
|
FreshASNs: 0,
|
|
StaleASNs: 0,
|
|
NeverFetched: len(m.ASNs),
|
|
}, nil
|
|
}
|
|
|
|
// UpsertLiveRouteBatch mock implementation
|
|
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
for _, route := range routes {
|
|
// Track prefix
|
|
if _, exists := m.Prefixes[route.Prefix]; !exists {
|
|
m.Prefixes[route.Prefix] = &database.Prefix{
|
|
Prefix: route.Prefix,
|
|
IPVersion: route.IPVersion,
|
|
FirstSeen: route.LastUpdated,
|
|
LastSeen: route.LastUpdated,
|
|
}
|
|
m.PrefixCount++
|
|
|
|
if route.IPVersion == 4 {
|
|
m.IPv4Prefixes++
|
|
} else {
|
|
m.IPv6Prefixes++
|
|
}
|
|
}
|
|
|
|
m.RouteCount++
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteLiveRouteBatch mock implementation
|
|
func (m *mockStore) DeleteLiveRouteBatch(_ []database.LiveRouteDeletion) error {
|
|
// Simple mock - just return nil
|
|
return nil
|
|
}
|
|
|
|
// DeleteLiveRoutesOfPeer mock implementation
|
|
func (m *mockStore) DeleteLiveRoutesOfPeer(_ string) (int, error) {
|
|
// Simple mock - deletes nothing
|
|
return 0, nil
|
|
}
|
|
|
|
// GetOrCreateASNBatch mock implementation
|
|
func (m *mockStore) GetOrCreateASNBatch(asns map[int]time.Time) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
for number, timestamp := range asns {
|
|
if _, exists := m.ASNs[number]; !exists {
|
|
m.ASNs[number] = &database.ASN{
|
|
ASN: number,
|
|
FirstSeen: timestamp,
|
|
LastSeen: timestamp,
|
|
}
|
|
m.ASNCount++
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdatePeerBatch mock implementation
|
|
func (m *mockStore) UpdatePeerBatch(_ map[string]database.PeerUpdate) error {
|
|
// Simple mock - just return nil
|
|
return nil
|
|
}
|
|
|
|
// Vacuum mock implementation
|
|
func (m *mockStore) Vacuum(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Analyze mock implementation
|
|
func (m *mockStore) Analyze(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Checkpoint mock implementation
|
|
func (m *mockStore) Checkpoint(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Ping mock implementation
|
|
func (m *mockStore) Ping(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func TestRouteWatchLiveFeed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if testing.Short() {
|
|
t.Skip("skipping live RIPE RIS network feed test in short mode; " +
|
|
"run without -short to include it")
|
|
}
|
|
|
|
// Create mock database
|
|
mockDB := newMockStore()
|
|
|
|
defer func() {
|
|
err := mockDB.Close()
|
|
if err != nil {
|
|
t.Errorf("failed to close mock database: %v", err)
|
|
}
|
|
}()
|
|
|
|
logger := logger.New()
|
|
|
|
// Create metrics tracker
|
|
metricsTracker := metrics.New()
|
|
|
|
// Create streamer
|
|
s := streamer.New(logger, metricsTracker)
|
|
|
|
// Create test config with empty state dir (no snapshot loading)
|
|
cfg := &config.Config{
|
|
StateDir: "",
|
|
MaxRuntime: 5 * time.Second,
|
|
EnableBatchedDatabaseWrites: true,
|
|
}
|
|
|
|
// Create server
|
|
srv := server.New(mockDB, s, logger, cfg)
|
|
|
|
// Create RouteWatch with 5 second limit
|
|
deps := Dependencies{
|
|
DB: mockDB,
|
|
Streamer: s,
|
|
Server: srv,
|
|
Logger: logger,
|
|
Config: cfg,
|
|
}
|
|
rw := New(deps)
|
|
|
|
// Run with context
|
|
ctx := context.Background()
|
|
go func() {
|
|
_ = rw.Run(ctx)
|
|
}()
|
|
|
|
// Wait for the configured duration
|
|
time.Sleep(5 * time.Second)
|
|
|
|
// Force peering processing for test
|
|
if rw.peeringHandler != nil {
|
|
rw.peeringHandler.ProcessPeeringsNow()
|
|
}
|
|
|
|
// Get statistics
|
|
stats, err := mockDB.GetStats()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get stats: %v", err)
|
|
}
|
|
|
|
if stats.ASNs == 0 {
|
|
t.Error("Expected to receive some ASNs from live feed")
|
|
}
|
|
|
|
t.Logf("Received %d unique ASNs in 5 seconds", stats.ASNs)
|
|
|
|
if stats.Prefixes == 0 {
|
|
t.Error("Expected to receive some prefixes from live feed")
|
|
}
|
|
|
|
t.Logf("Received %d unique prefixes (%d IPv4, %d IPv6) in 5 seconds",
|
|
stats.Prefixes, stats.IPv4Prefixes, stats.IPv6Prefixes)
|
|
|
|
if stats.Peerings == 0 {
|
|
t.Error("Expected to receive some peerings from live feed")
|
|
}
|
|
|
|
t.Logf("Recorded %d AS peering relationships in 5 seconds", stats.Peerings)
|
|
}
|
|
|
|
// gatedPrefixHandler is a PrefixHandler whose worker in the streamer waits on
|
|
// the first message until release is closed, so the messages after it stay on
|
|
// the streamer's queue.
|
|
type gatedPrefixHandler struct {
|
|
*PrefixHandler
|
|
|
|
release chan struct{}
|
|
}
|
|
|
|
func (h gatedPrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|
<-h.release
|
|
h.PrefixHandler.HandleMessage(msg)
|
|
}
|
|
|
|
// announcementFeed returns the lines RIS Live would send for one UPDATE per
|
|
// prefix, each announcing that prefix from testPeerIP with origin testASNA,
|
|
// followed by a KEEPALIVE. The streamer hands a KEEPALIVE to no handler, so
|
|
// once a raw handler has seen it, every update before it is queued.
|
|
func announcementFeed(prefixes []string) string {
|
|
var feed strings.Builder
|
|
|
|
for _, prefix := range prefixes {
|
|
fmt.Fprintf(&feed, `{"type":"ris_message","data":{"type":"UPDATE",`+
|
|
`"peer":"%s","peer_asn":"%d","timestamp":1700000000,"path":[%d],`+
|
|
`"announcements":[{"next_hop":"%s","prefixes":["%s"]}]}}`+"\n",
|
|
testPeerIP, testASNA, testASNA, testPeerIP, prefix)
|
|
}
|
|
|
|
fmt.Fprintf(&feed, `{"type":"ris_message","data":{"type":"KEEPALIVE",`+
|
|
`"peer":"%s","peer_asn":"%d","timestamp":1700000000}}`+"\n",
|
|
testPeerIP, testASNA)
|
|
|
|
return feed.String()
|
|
}
|
|
|
|
// TestShutdownWritesTheUpdatesQueuedInTheStreamer has a local server send
|
|
// announcements of three prefixes and keep the response open. The prefix
|
|
// handler's worker waits on the first one until the server sees the streamer
|
|
// close the connection, which only Shutdown does, so all three are still in
|
|
// the streamer when Shutdown starts. Each prefix must be in the database when
|
|
// Shutdown returns.
|
|
func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const maxWait = 10 * time.Second
|
|
|
|
prefixes := []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"}
|
|
feed := announcementFeed(prefixes)
|
|
|
|
release := make(chan struct{})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, feed)
|
|
_ = http.NewResponseController(w).Flush()
|
|
|
|
<-r.Context().Done()
|
|
close(release)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
store := newMockStore()
|
|
log := logger.New()
|
|
cfg := &config.Config{}
|
|
s := streamer.New(log, metrics.New())
|
|
s.SetURL(srv.URL)
|
|
|
|
rw := New(Dependencies{
|
|
DB: store,
|
|
Streamer: s,
|
|
Server: server.New(store, s, log, cfg),
|
|
Logger: log,
|
|
Config: cfg,
|
|
})
|
|
rw.prefixHandler = NewPrefixHandler(store, log)
|
|
s.RegisterHandler(gatedPrefixHandler{
|
|
PrefixHandler: rw.prefixHandler, release: release,
|
|
})
|
|
|
|
linesSeen := make(chan struct{}, len(prefixes)+1)
|
|
|
|
s.RegisterRawHandler(func(string) { linesSeen <- struct{}{} })
|
|
|
|
err := s.Start(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("Start returned error: %v", err)
|
|
}
|
|
|
|
for range cap(linesSeen) {
|
|
select {
|
|
case <-linesSeen:
|
|
case <-time.After(maxWait):
|
|
t.Fatalf("the streamer did not read %d lines within %s",
|
|
cap(linesSeen), maxWait)
|
|
}
|
|
}
|
|
|
|
shutDown := make(chan struct{})
|
|
|
|
go func() {
|
|
rw.Shutdown(t.Context())
|
|
close(shutDown)
|
|
}()
|
|
|
|
select {
|
|
case <-shutDown:
|
|
case <-time.After(maxWait):
|
|
t.Fatalf("Shutdown did not return within %s", maxWait)
|
|
}
|
|
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
|
|
for _, prefix := range prefixes {
|
|
if _, ok := store.Prefixes[prefix]; !ok {
|
|
t.Errorf("%s was queued in the streamer when Shutdown started "+
|
|
"but is not in the database", prefix)
|
|
}
|
|
}
|
|
}
|