Author SHA1 Message Date
sneak b8f956e867 Sample the prefix-length pages with bounded lookups, each prefix once (closes #66)
check / check (push) Canceled after 0s
/prefixlength/{length} and /prefixlength6/{length} grouped every live route
of that length by prefix, shuffled all the prefixes and joined the routes
back, so a request grew with the number of routes and listed a prefix once
for each peer that shared its newest time.

Now a length with at most 5000 prefixes has them all read and shuffled, and
the page lists 500 of them. A longer one is sampled with random row-id
lookups on the mask length index, at most four per prefix asked for, filled
up from the shuffled prefixes if the lookups keep repeating. Each prefix is
shown once, with its newest route.

Model: opus-5-5
2026-10-14 14:50:13 +00:00
18 changed files with 502 additions and 1031 deletions
+3 -11
View File
@@ -141,15 +141,11 @@ routewatch/
- **Batched Writes**: All database operations are batched for performance - **Batched Writes**: All database operations are batched for performance
- **Backpressure**: Probabilistic message dropping when queues exceed 50% capacity - **Backpressure**: Probabilistic message dropping when queues exceed 50% capacity
- **Graceful Shutdown**: 60-second timeout, flushes all pending batches - **Graceful Shutdown**: 60-second timeout, flushes all pending batches
- **Reconnection**: Any end of the stream other than shutdown, including RIS Live - **Reconnection**: Exponential backoff (5s-320s) with reset after 30s of stable connection
ending the response normally, marks the feed disconnected and reconnects with
exponential backoff (5s-320s). The backoff resets after a stream that lasted
more than 30s. A stream with no line for 60s is dropped and reconnected.
- **IP Lookup**: the most specific live route is found by looking up the - **IP Lookup**: the most specific live route is found by looking up the
address's prefix at each mask length, longest first, in the prefix index address's prefix at each mask length, longest first, in the prefix index
(at most 25 lookups for IPv4, 113 for IPv6); prefixes are stored in the (at most 33 lookups for IPv4, 129 for IPv6); prefixes are stored in the
text form Go's `net/netip` prints. Routes shorter than /8 for IPv4 or /16 text form Go's `net/netip` prints
for IPv6, such as a default route, are not used.
### Database Schema ### Database Schema
@@ -233,10 +229,6 @@ What happens at each limit:
keeps running. keeps running.
- Handler queues: each of the four handler queues holds at most 20,000 messages. - Handler queues: each of the four handler queues holds at most 20,000 messages.
When a queue fills, the streamer drops messages instead of blocking. When a queue fills, the streamer drops messages instead of blocking.
- Handler batches: while the AS, peer or prefix handler writes a batch to the
database, the updates that arrive collect in its next batch, which has no size
limit. A database that stays slower than the feed grows memory instead of
dropping updates.
With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds
with the goroutine count and Go memory figures. with the goroutine count and Go memory figures.
+184 -60
View File
@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2"
"net" "net"
"net/netip" "net/netip"
"os" "os"
@@ -37,14 +38,6 @@ const (
ipv6Bits = 128 ipv6Bits = 128
) )
// The IP lookup does not use routes shorter than these mask lengths. A default
// route or a route this short only says where a peer sends traffic it has no
// better route for; it does not say who holds the address.
const (
minLookupMaskLengthV4 = 8
minLookupMaskLengthV6 = 16
)
// SQLite memory tuning. cache_size and busy_timeout go in the DSN so every // SQLite memory tuning. cache_size and busy_timeout go in the DSN so every
// pooled connection gets them; the heap limits are process-wide and set once. // pooled connection gets them; the heap limits are process-wide and set once.
const ( const (
@@ -61,6 +54,17 @@ const (
sqliteHardHeapLimitBytes = 1610612736 sqliteHardHeapLimitBytes = 1610612736
) )
// Bounds on the work GetRandomPrefixesByLength does to pick its sample.
const (
// maxPrefixesToShuffle is the most prefixes of one mask length that are
// read in full and shuffled to pick the sample. A mask length with more is
// sampled with random lookups, which then seldom land on a prefix twice.
maxPrefixesToShuffle = 5000
// sampleLookupsPerPrefix is the most random lookups made for each prefix
// asked for.
sampleLookupsPerPrefix = 4
)
// Common errors // Common errors
var ( var (
// ErrInvalidIP is returned when an IP address is malformed // ErrInvalidIP is returned when an IP address is malformed
@@ -1632,17 +1636,16 @@ func (d *Database) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, er
} }
// GetPrefixDetails returns detailed information about a prefix // GetPrefixDetails returns detailed information about a prefix
func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) { func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) {
return d.GetPrefixDetailsContext(context.Background(), prefix) return d.GetPrefixDetailsContext(context.Background(), prefix)
} }
// GetPrefixDetailsContext returns the live routes for a prefix and, keyed by // GetPrefixDetailsContext returns detailed information about a prefix with context support
// AS number, the handle and description of each AS that originates it. func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error) {
func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, map[int]ASN, error) {
// Determine if it's IPv4 or IPv6 by parsing the prefix // Determine if it's IPv4 or IPv6 by parsing the prefix
_, ipnet, err := net.ParseCIDR(prefix) _, ipnet, err := net.ParseCIDR(prefix)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("invalid prefix format: %w", err) return nil, fmt.Errorf("invalid prefix format: %w", err)
} }
tableName := "live_routes_v4" tableName := "live_routes_v4"
@@ -1664,12 +1667,11 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
rows, err := d.db.QueryContext(ctx, query, prefix) rows, err := d.db.QueryContext(ctx, query, prefix)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to query prefix details: %w", err) return nil, fmt.Errorf("failed to query prefix details: %w", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
var routes []LiveRoute var routes []LiveRoute
origins := make(map[int]ASN)
for rows.Next() { for rows.Next() {
var route LiveRoute var route LiveRoute
var pathJSON string var pathJSON string
@@ -1692,18 +1694,13 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) (
route.IPVersion = ipVersion route.IPVersion = ipVersion
route.MaskLength, _ = ipnet.Mask.Size() route.MaskLength, _ = ipnet.Mask.Size()
routes = append(routes, route) routes = append(routes, route)
origins[route.OriginASN] = ASN{
ASN: route.OriginASN,
Handle: handle.String,
Description: description.String,
}
} }
if len(routes) == 0 { if len(routes) == 0 {
return nil, nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix) return nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix)
} }
return routes, origins, nil return routes, nil
} }
// GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length // GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length
@@ -1711,45 +1708,176 @@ func (d *Database) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) (
return d.GetRandomPrefixesByLengthContext(context.Background(), maskLength, ipVersion, limit) return d.GetRandomPrefixesByLengthContext(context.Background(), maskLength, ipVersion, limit)
} }
// GetRandomPrefixesByLengthContext returns a random sample of prefixes with context support // GetRandomPrefixesByLengthContext returns limit prefixes with the given mask
// length picked at random, or all of them when there are fewer, each once with
// its most recently updated live route. However many routes there are, it
// reads at most maxPrefixesToShuffle+1 prefixes and makes at most
// sampleLookupsPerPrefix lookups for each prefix asked for.
func (d *Database) GetRandomPrefixesByLengthContext( func (d *Database) GetRandomPrefixesByLengthContext(
ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) { ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) {
// Select unique prefixes with their most recent route information
tableName := "live_routes_v4" tableName := "live_routes_v4"
if ipVersion == ipVersionV6 { if ipVersion == ipVersionV6 {
tableName = "live_routes_v6" tableName = "live_routes_v6"
} }
// Reading one prefix more than maxPrefixesToShuffle tells whether these
// are all the prefixes with this mask length.
prefixes, err := d.firstPrefixesByLength(ctx, tableName, maskLength, maxPrefixesToShuffle+1)
if err != nil {
return nil, err
}
rand.Shuffle(len(prefixes), func(i, j int) {
prefixes[i], prefixes[j] = prefixes[j], prefixes[i]
})
if len(prefixes) > maxPrefixesToShuffle {
prefixes, err = d.samplePrefixesByLength(ctx, tableName, maskLength, limit, prefixes)
if err != nil {
return nil, err
}
}
prefixes = prefixes[:min(limit, len(prefixes))]
return d.newestRouteOfEachPrefix(ctx, tableName, ipVersion, prefixes)
}
// firstPrefixesByLength returns the first n distinct prefixes, in text order,
// with the given mask length. It reads only their entries in the mask length
// and prefix index.
func (d *Database) firstPrefixesByLength(
ctx context.Context, tableName string, maskLength, n int) ([]string, error) {
//nolint:gosec // Table name is hardcoded based on IP version //nolint:gosec // Table name is hardcoded based on IP version
query := fmt.Sprintf(` query := fmt.Sprintf(`
WITH unique_prefixes AS ( SELECT DISTINCT prefix FROM %s
SELECT prefix, MAX(last_updated) as max_updated WHERE mask_length = ?
FROM %s ORDER BY prefix
WHERE mask_length = ? LIMIT ?
GROUP BY prefix `, tableName)
ORDER BY RANDOM()
LIMIT ?
)
SELECT lr.prefix, lr.mask_length, lr.origin_asn, lr.as_path,
lr.peer_ip, lr.last_updated
FROM %s lr
INNER JOIN unique_prefixes up ON lr.prefix = up.prefix AND lr.last_updated = up.max_updated
WHERE lr.mask_length = ?
`, tableName, tableName)
rows, err := d.db.QueryContext(ctx, query, maskLength, limit, maskLength) rows, err := d.db.QueryContext(ctx, query, maskLength, n)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query random prefixes: %w", err) return nil, fmt.Errorf("failed to list prefixes: %w", err)
} }
defer func() { defer func() { _ = rows.Close() }()
_ = rows.Close()
}()
var routes []LiveRoute var prefixes []string
for rows.Next() { for rows.Next() {
var route LiveRoute var prefix string
if err := rows.Scan(&prefix); err != nil {
return nil, fmt.Errorf("failed to scan prefix: %w", err)
}
prefixes = append(prefixes, prefix)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list prefixes: %w", err)
}
return prefixes, nil
}
// samplePrefixesByLength picks limit distinct prefixes with the given mask
// length at random, in at most sampleLookupsPerPrefix lookups for each prefix
// asked for. ORDER BY RANDOM() would read every route with that mask length.
// Instead each lookup draws a row id between those of the first and last route
// with that mask length and takes the prefix of the first such route at or
// after it. SQLite keeps the row id at the end of every index entry, so the
// mask length index is ordered by mask length, then row id, and the lookup is
// one search of it. A prefix with more routes, or with more rows of other mask
// lengths just before its own, comes up more often: the page needs a varied
// sample, not an even one. Should the lookups keep landing on prefixes already
// picked, the rest are taken from shuffled, prefixes of that length in random
// order.
func (d *Database) samplePrefixesByLength(
ctx context.Context, tableName string, maskLength, limit int, shuffled []string) ([]string, error) {
//nolint:gosec // Table name is hardcoded based on IP version
rangeQuery := fmt.Sprintf(`
SELECT
(SELECT MIN(rowid) FROM %[1]s WHERE mask_length = ?),
(SELECT MAX(rowid) FROM %[1]s WHERE mask_length = ?)
`, tableName)
var first, last sql.NullInt64
if err := d.db.QueryRowContext(ctx, rangeQuery, maskLength, maskLength).Scan(&first, &last); err != nil {
return nil, fmt.Errorf("failed to read row id range: %w", err)
}
if !first.Valid {
// Every route with this mask length was withdrawn since they were listed.
return nil, nil
}
//nolint:gosec // Table name is hardcoded based on IP version
lookupQuery := fmt.Sprintf(`
SELECT prefix FROM %s
WHERE mask_length = ? AND rowid >= ?
ORDER BY rowid
LIMIT 1
`, tableName)
stmt, err := d.db.PrepareContext(ctx, lookupQuery)
if err != nil {
return nil, fmt.Errorf("failed to prepare prefix lookup: %w", err)
}
defer func() { _ = stmt.Close() }()
picked := make(map[string]bool, limit)
prefixes := make([]string, 0, limit)
pick := func(prefix string) {
if !picked[prefix] {
picked[prefix] = true
prefixes = append(prefixes, prefix)
}
}
for lookups := 0; lookups < limit*sampleLookupsPerPrefix && len(prefixes) < limit; lookups++ {
//nolint:gosec // The sample needs variety, not unpredictability.
rowID := first.Int64 + rand.Int64N(last.Int64-first.Int64+1)
var prefix string
err := stmt.QueryRowContext(ctx, maskLength, rowID).Scan(&prefix)
if errors.Is(err, sql.ErrNoRows) {
// The routes from rowID on were withdrawn since the range was read.
continue
}
if err != nil {
return nil, fmt.Errorf("failed to look up prefix: %w", err)
}
pick(prefix)
}
for _, prefix := range shuffled {
if len(prefixes) == limit {
break
}
pick(prefix)
}
return prefixes, nil
}
// newestRouteOfEachPrefix returns the most recently updated live route of each
// prefix, one lookup on the prefix index per prefix. A prefix whose routes were
// all withdrawn since it was picked is left out.
func (d *Database) newestRouteOfEachPrefix(
ctx context.Context, tableName string, ipVersion int, prefixes []string) ([]LiveRoute, error) {
//nolint:gosec // Table name is hardcoded based on IP version
query := fmt.Sprintf(`
SELECT prefix, mask_length, origin_asn, as_path, peer_ip, last_updated
FROM %s
WHERE prefix = ?
ORDER BY last_updated DESC
LIMIT 1
`, tableName)
stmt, err := d.db.PrepareContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to prepare route query: %w", err)
}
defer func() { _ = stmt.Close() }()
routes := make([]LiveRoute, 0, len(prefixes))
for _, prefix := range prefixes {
route := LiveRoute{IPVersion: ipVersion}
var pathJSON string var pathJSON string
err := rows.Scan( err := stmt.QueryRowContext(ctx, prefix).Scan(
&route.Prefix, &route.Prefix,
&route.MaskLength, &route.MaskLength,
&route.OriginASN, &route.OriginASN,
@@ -1757,17 +1885,16 @@ func (d *Database) GetRandomPrefixesByLengthContext(
&route.PeerIP, &route.PeerIP,
&route.LastUpdated, &route.LastUpdated,
) )
// Set IP version based on which table we queried if errors.Is(err, sql.ErrNoRows) {
route.IPVersion = ipVersion
if err != nil {
continue continue
} }
if err != nil {
return nil, fmt.Errorf("failed to query route of %s: %w", prefix, err)
}
// Decode AS path
if err := json.Unmarshal([]byte(pathJSON), &route.ASPath); err != nil { if err := json.Unmarshal([]byte(pathJSON), &route.ASPath); err != nil {
route.ASPath = []int{} route.ASPath = []int{}
} }
routes = append(routes, route) routes = append(routes, route)
} }
@@ -1891,12 +2018,11 @@ func (d *Database) GetIPInfo(ip string) (*IPInfo, error) {
// GetIPInfoContext returns comprehensive IP information with context support. // GetIPInfoContext returns comprehensive IP information with context support.
// //
// It finds the most specific live route for the address with point lookups on // It finds the most specific live route for the address with point lookups on
// the prefix index: for each mask length from the longest (32 or 128) down to // the prefix index: for each mask length from the longest (32 or 128) down to 0
// minLookupMaskLengthV4 or minLookupMaskLengthV6 it looks up the address's // it looks up the address's network prefix at that length, and the first length
// network prefix at that length, and the first length with a live route wins. // with a live route wins. That is at most 33 or 129 indexed lookups, however
// That is at most 25 or 113 indexed lookups, however many routes are live. The // many routes are live. The prefix is looked up in the text form net/netip
// prefix is looked up in the text form net/netip prints, which is the form the // prints, which is the form the prefix handler stores.
// prefix handler stores.
func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) { func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) {
addr, err := netip.ParseAddr(ip) addr, err := netip.ParseAddr(ip)
if err != nil { if err != nil {
@@ -1907,11 +2033,9 @@ func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, er
info := &IPInfo{IP: ip, IPVersion: ipVersionV6} info := &IPInfo{IP: ip, IPVersion: ipVersionV6}
routesTable, prefixesTable := "live_routes_v6", "prefixes_v6" routesTable, prefixesTable := "live_routes_v6", "prefixes_v6"
minMaskLength := minLookupMaskLengthV6
if addr.Is4() { if addr.Is4() {
info.IPVersion = ipVersionV4 info.IPVersion = ipVersionV4
routesTable, prefixesTable = "live_routes_v4", "prefixes_v4" routesTable, prefixesTable = "live_routes_v4", "prefixes_v4"
minMaskLength = minLookupMaskLengthV4
} }
// Route info with peer count and prefix first_seen // Route info with peer count and prefix first_seen
@@ -1952,7 +2076,7 @@ func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, er
var whoisUpdatedAt sql.NullTime var whoisUpdatedAt sql.NullTime
found := false found := false
for bits := addr.BitLen(); bits >= minMaskLength; bits-- { for bits := addr.BitLen(); bits >= 0; bits-- {
prefix := netip.PrefixFrom(addr, bits).Masked().String() prefix := netip.PrefixFrom(addr, bits).Masked().String()
err := stmt.QueryRowContext(ctx, prefix).Scan( err := stmt.QueryRowContext(ctx, prefix).Scan(
&info.Netblock, &info.Netblock,
+152 -72
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/netip" "net/netip"
"slices"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -45,6 +46,19 @@ const (
asnSecondBand = 100 asnSecondBand = 100
) )
// 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"
)
// TestGetIPInfoFindsMostSpecificLiveRoute stores nested live prefixes for both // TestGetIPInfoFindsMostSpecificLiveRoute stores nested live prefixes for both
// families and checks that a lookup returns the most specific one covering the // 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 // address, ErrNoRoute when none covers it, and the next less specific prefix
@@ -133,78 +147,6 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
lookup("2001:db8:1:2::3", "2001:db8:1::/48") lookup("2001:db8:1:2::3", "2001:db8:1::/48")
} }
// 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)
}
// TestConnectionPoolPragmas holds several pooled connections open at once and // TestConnectionPoolPragmas holds several pooled connections open at once and
// checks each one carries the per-connection settings from the DSN, plus the // checks each one carries the per-connection settings from the DSN, plus the
// process-wide hard heap limit, and sees the new file with auto_vacuum // process-wide hard heap limit, and sees the new file with auto_vacuum
@@ -389,3 +331,141 @@ func TestBatchWriteDuringCheckpoint(t *testing.T) {
cancel() cancel()
wg.Wait() wg.Wait()
} }
// 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) {
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("2001:db8::/32", 64500, ts)
v6Second := mkV6Route("2001:db8::/32", 64500, ts)
v6Second.PeerIP = secondV6PeerIP
if err := db.UpsertLiveRouteBatch([]*LiveRoute{v4First, v4Second, v6First, v6Second}); 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) {
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
if err := db.UpsertLiveRouteBatch([]*LiveRoute{olderRoute, newerRoute}); 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) {
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
var stored []string
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)
}
if err := db.UpsertLiveRouteBatch(routes); 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)
}
var listed []string
for _, route := range sample {
listed = append(listed, route.Prefix)
}
checkPrefixSample(t, "GetRandomPrefixesByLength", listed, stored)
looked, err := db.samplePrefixesByLength(
context.Background(), "live_routes_v4", 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)
}
}
+2 -2
View File
@@ -76,8 +76,8 @@ type Store interface {
GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error) GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error)
GetASPeers(asn int) ([]ASPeer, error) GetASPeers(asn int) ([]ASPeer, error)
GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error)
GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) GetPrefixDetails(prefix string) ([]LiveRoute, error)
GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, map[int]ASN, error) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error)
GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error)
GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error)
+3 -5
View File
@@ -252,15 +252,13 @@ func (m *mockStore) GetASDetailsContext(ctx context.Context, asn int) (*database
} }
// GetPrefixDetails mock implementation // GetPrefixDetails mock implementation
func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, map[int]database.ASN, error) { func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) {
// Return empty routes for now // Return empty routes for now
return []database.LiveRoute{}, map[int]database.ASN{}, nil return []database.LiveRoute{}, nil
} }
// GetPrefixDetailsContext mock implementation with context support // GetPrefixDetailsContext mock implementation with context support
func (m *mockStore) GetPrefixDetailsContext( func (m *mockStore) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]database.LiveRoute, error) {
ctx context.Context, prefix string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return m.GetPrefixDetails(prefix) return m.GetPrefixDetails(prefix)
} }
+27 -49
View File
@@ -32,15 +32,12 @@ type ASHandler struct {
db database.Store db database.Store
logger *logger.Logger logger *logger.Logger
// Batching. mu guards batch only: the flush goroutine takes the batch // Batching
// under mu and writes it after releasing mu, so HandleMessage never mu sync.Mutex
// waits for a database write. batch []asnOp
mu sync.Mutex lastFlush time.Time
batch []asnOp stopCh chan struct{}
batchFullCh chan struct{} // wakes the flush goroutine when batch is full wg sync.WaitGroup
lastFlush time.Time // used only by the flush goroutine
stopCh chan struct{}
wg sync.WaitGroup
} }
type asnOp struct { type asnOp struct {
@@ -55,12 +52,11 @@ type asnOp struct {
// background goroutine is terminated. // background goroutine is terminated.
func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler { func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler {
h := &ASHandler{ h := &ASHandler{
db: db, db: db,
logger: logger, logger: logger,
batch: make([]asnOp, 0, asnBatchSize), batch: make([]asnOp, 0, asnBatchSize),
batchFullCh: make(chan struct{}, 1), lastFlush: time.Now(),
lastFlush: time.Now(), stopCh: make(chan struct{}),
stopCh: make(chan struct{}),
} }
// Start the flush timer goroutine // Start the flush timer goroutine
@@ -89,8 +85,8 @@ func (h *ASHandler) QueueCapacity() int {
// HandleMessage processes a RIS Live BGP message by extracting all ASNs from // HandleMessage processes a RIS Live BGP message by extracting all ASNs from
// the AS path and queuing them for batch insertion into the database. The // the AS path and queuing them for batch insertion into the database. The
// origin ASN (last element in the path) and all transit ASNs are recorded // origin ASN (last element in the path) and all transit ASNs are recorded
// with their associated timestamps. When the batch reaches the configured size // with their associated timestamps. The batch is automatically flushed when
// threshold, the flush goroutine is woken to write it. // it reaches the configured size threshold.
func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) { func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
// Use the pre-parsed timestamp // Use the pre-parsed timestamp
timestamp := msg.ParsedTimestamp timestamp := msg.ParsedTimestamp
@@ -122,18 +118,13 @@ func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
} }
} }
// A full batch wakes the flush goroutine. If it is writing, the wake-up // Check if we need to flush
// waits in batchFullCh and the batch keeps growing until the write ends.
if len(h.batch) >= asnBatchSize { if len(h.batch) >= asnBatchSize {
select { h.flushBatchLocked()
case h.batchFullCh <- struct{}{}:
default: // already woken
}
} }
} }
// flushLoop runs in a goroutine, the only one that writes the batch: it // flushLoop runs in a goroutine and periodically flushes batches
// flushes when the batch is full, periodically, and once more on Stop.
func (h *ASHandler) flushLoop() { func (h *ASHandler) flushLoop() {
defer h.wg.Done() defer h.wg.Done()
ticker := time.NewTicker(asnBatchTimeout) ticker := time.NewTicker(asnBatchTimeout)
@@ -142,46 +133,31 @@ func (h *ASHandler) flushLoop() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
h.mu.Lock()
if time.Since(h.lastFlush) >= asnBatchTimeout { if time.Since(h.lastFlush) >= asnBatchTimeout {
h.flushBatch() h.flushBatchLocked()
} }
case <-h.batchFullCh: h.mu.Unlock()
h.flushBatch()
case <-h.stopCh: case <-h.stopCh:
// Final flush // Final flush
h.flushBatch() h.mu.Lock()
h.flushBatchLocked()
h.mu.Unlock()
return return
} }
} }
} }
// takeBatch returns the pending operations and leaves an empty batch in their // flushBatchLocked flushes the ASN batch to the database (must be called with mutex held)
// place for HandleMessage to fill. func (h *ASHandler) flushBatchLocked() {
func (h *ASHandler) takeBatch() []asnOp {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.batch) == 0 { if len(h.batch) == 0 {
return nil
}
batch := h.batch
h.batch = make([]asnOp, 0, asnBatchSize)
return batch
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
func (h *ASHandler) flushBatch() {
batch := h.takeBatch()
if len(batch) == 0 {
return return
} }
// Process ASNs first (deduped) // Process ASNs first (deduped)
asnMap := make(map[int]time.Time) asnMap := make(map[int]time.Time)
for _, op := range batch { for _, op := range h.batch {
if existing, ok := asnMap[op.number]; !ok || op.timestamp.After(existing) { if existing, ok := asnMap[op.number]; !ok || op.timestamp.After(existing) {
asnMap[op.number] = op.timestamp asnMap[op.number] = op.timestamp
} }
@@ -192,6 +168,8 @@ func (h *ASHandler) flushBatch() {
h.logger.Error("Failed to process ASN batch", "error", err, "count", len(asnMap)) h.logger.Error("Failed to process ASN batch", "error", err, "count", len(asnMap))
} }
// Clear batch
h.batch = h.batch[:0]
h.lastFlush = time.Now() h.lastFlush = time.Now()
} }
-192
View File
@@ -1,192 +0,0 @@
package routewatch
import (
"slices"
"sync"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
// handleMessageTimeout is how long a test waits for HandleMessage to return,
// or for a write to start, before it fails.
const handleMessageTimeout = 2 * time.Second
// blockingStore is a mockStore whose batch writes for the AS, peer and prefix
// handlers wait until the test closes release. writeStarted is closed when the
// first of them starts. It records what each write was given.
type blockingStore struct {
*mockStore
release chan struct{}
writeStarted chan struct{}
startOnce sync.Once
mu sync.Mutex
prefixes map[string]bool
asns map[int]bool
peers map[string]bool
}
func newBlockingStore() *blockingStore {
return &blockingStore{
mockStore: newMockStore(),
release: make(chan struct{}),
writeStarted: make(chan struct{}),
prefixes: make(map[string]bool),
asns: make(map[int]bool),
peers: make(map[string]bool),
}
}
func (s *blockingStore) waitForRelease() {
s.startOnce.Do(func() { close(s.writeStarted) })
<-s.release
}
func (s *blockingStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
s.waitForRelease()
s.mu.Lock()
defer s.mu.Unlock()
for _, route := range routes {
s.prefixes[route.Prefix] = true
}
return nil
}
func (s *blockingStore) GetOrCreateASNBatch(asns map[int]time.Time) error {
s.waitForRelease()
s.mu.Lock()
defer s.mu.Unlock()
for asn := range asns {
s.asns[asn] = true
}
return nil
}
func (s *blockingStore) UpdatePeerBatch(peers map[string]database.PeerUpdate) error {
s.waitForRelease()
s.mu.Lock()
defer s.mu.Unlock()
for peerIP := range peers {
s.peers[peerIP] = true
}
return nil
}
// returnsInTime reports whether f returns within handleMessageTimeout.
func returnsInTime(f func()) bool {
done := make(chan struct{})
go func() {
f()
close(done)
}()
select {
case <-done:
return true
case <-time.After(handleMessageTimeout):
return false
}
}
// handleWhileAWriteIsBlocked hands h the messages in fill, which fill its
// batch so that the handler writes it to store, waits until the write starts,
// and hands h the message extra while store holds the write. HandleMessage
// must return each time. Then it lets the writes finish and stops h, which
// writes what it holds.
func handleWhileAWriteIsBlocked(
t *testing.T,
store *blockingStore,
h interface {
HandleMessage(*ristypes.RISMessage)
Stop()
},
fill []*ristypes.RISMessage,
extra *ristypes.RISMessage,
) {
t.Helper()
defer h.Stop()
defer close(store.release)
if !returnsInTime(func() {
for _, msg := range fill {
h.HandleMessage(msg)
}
}) {
t.Fatal("HandleMessage did not return after filling the batch: it waits for the database write")
}
select {
case <-store.writeStarted:
case <-time.After(handleMessageTimeout):
t.Fatal("the handler did not write its full batch")
}
if !returnsInTime(func() { h.HandleMessage(extra) }) {
t.Fatal("HandleMessage did not return while a database write was blocked")
}
}
func TestPrefixHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
const (
fillPrefix = "192.0.2.0/24"
extraPrefix = "198.51.100.0/24"
)
announce := func(prefixes []string) *ristypes.RISMessage {
return &ristypes.RISMessage{
Peer: testPeerIP,
Path: ristypes.ASPath{testASNA},
Announcements: []ristypes.RISAnnouncement{{NextHop: testPeerIP, Prefixes: prefixes}},
}
}
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New())
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{announce(slices.Repeat([]string{fillPrefix}, prefixBatchSize))},
announce([]string{extraPrefix}))
store.mu.Lock()
defer store.mu.Unlock()
if !store.prefixes[extraPrefix] {
t.Errorf("Stop did not write %s, handled while a write was blocked", extraPrefix)
}
}
func TestASHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
store := newBlockingStore()
h := NewASHandler(store, logger.New())
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{{Path: slices.Repeat(ristypes.ASPath{testASNA}, asnBatchSize)}},
&ristypes.RISMessage{Path: ristypes.ASPath{testASNB}})
store.mu.Lock()
defer store.mu.Unlock()
if !store.asns[testASNB] {
t.Errorf("Stop did not write AS%d, handled while a write was blocked", testASNB)
}
}
func TestPeerHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
const otherPeerIP = "2001:db8:ffff::2"
store := newBlockingStore()
h := NewPeerHandler(store, logger.New())
handleWhileAWriteIsBlocked(t, store, h,
slices.Repeat([]*ristypes.RISMessage{{Peer: testPeerIP}}, peerBatchSize),
&ristypes.RISMessage{Peer: otherPeerIP})
store.mu.Lock()
defer store.mu.Unlock()
if !store.peers[otherPeerIP] {
t.Errorf("Stop did not write peer %s, handled while a write was blocked", otherPeerIP)
}
}
-5
View File
@@ -20,8 +20,6 @@ const (
debugInterval = 60 * time.Second debugInterval = 60 * time.Second
// bytesPerMB is bytes per megabyte // bytesPerMB is bytes per megabyte
bytesPerMB = 1024 * 1024 bytesPerMB = 1024 * 1024
// failureExitCode is the exit status when RouteWatch stops on an error
failureExitCode = 1
) )
// logDebugStats logs goroutine count and memory usage // logDebugStats logs goroutine count and memory usage
@@ -85,9 +83,6 @@ func CLIEntry() {
go func() { go func() {
if err := rw.Run(ctx); err != nil { if err := rw.Run(ctx); err != nil {
logger.Error("RouteWatch error", "error", err) logger.Error("RouteWatch error", "error", err)
if err := shutdowner.Shutdown(fx.ExitCode(failureExitCode)); err != nil {
logger.Error("Failed to shutdown gracefully", "error", err)
}
} }
}() }()
+27 -49
View File
@@ -35,15 +35,12 @@ type PeerHandler struct {
db database.Store db database.Store
logger *logger.Logger logger *logger.Logger
// Batching. mu guards peerBatch only: the flush goroutine takes the batch // Batching
// under mu and writes it after releasing mu, so HandleMessage never mu sync.Mutex
// waits for a database write. peerBatch []peerUpdate
mu sync.Mutex lastFlush time.Time
peerBatch []peerUpdate stopCh chan struct{}
batchFullCh chan struct{} // wakes the flush goroutine when peerBatch is full wg sync.WaitGroup
lastFlush time.Time // used only by the flush goroutine
stopCh chan struct{}
wg sync.WaitGroup
} }
type peerUpdate struct { type peerUpdate struct {
@@ -59,12 +56,11 @@ type peerUpdate struct {
// should be stopped by calling Stop when it is no longer needed. // should be stopped by calling Stop when it is no longer needed.
func NewPeerHandler(db database.Store, logger *logger.Logger) *PeerHandler { func NewPeerHandler(db database.Store, logger *logger.Logger) *PeerHandler {
h := &PeerHandler{ h := &PeerHandler{
db: db, db: db,
logger: logger, logger: logger,
peerBatch: make([]peerUpdate, 0, peerBatchSize), peerBatch: make([]peerUpdate, 0, peerBatchSize),
batchFullCh: make(chan struct{}, 1), lastFlush: time.Now(),
lastFlush: time.Now(), stopCh: make(chan struct{}),
stopCh: make(chan struct{}),
} }
// Start the flush timer goroutine // Start the flush timer goroutine
@@ -91,8 +87,8 @@ func (h *PeerHandler) QueueCapacity() int {
// HandleMessage processes a RIS message to track peer information. // HandleMessage processes a RIS message to track peer information.
// It extracts the peer IP address and ASN from the message and adds // It extracts the peer IP address and ASN from the message and adds
// the update to an internal batch. The flush goroutine writes the batch to the // the update to an internal batch. When the batch reaches peerBatchSize
// database when it reaches peerBatchSize or the batch timeout expires. // or the batch timeout expires, the batch is flushed to the database.
func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) { func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) {
// Parse peer ASN from string // Parse peer ASN from string
peerASN := 0 peerASN := 0
@@ -113,18 +109,13 @@ func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) {
timestamp: msg.ParsedTimestamp, timestamp: msg.ParsedTimestamp,
}) })
// A full batch wakes the flush goroutine. If it is writing, the wake-up // Check if we need to flush
// waits in batchFullCh and the batch keeps growing until the write ends.
if len(h.peerBatch) >= peerBatchSize { if len(h.peerBatch) >= peerBatchSize {
select { h.flushBatchLocked()
case h.batchFullCh <- struct{}{}:
default: // already woken
}
} }
} }
// flushLoop runs in a goroutine, the only one that writes the batch: it // flushLoop runs in a goroutine and periodically flushes batches
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PeerHandler) flushLoop() { func (h *PeerHandler) flushLoop() {
defer h.wg.Done() defer h.wg.Done()
ticker := time.NewTicker(peerBatchTimeout) ticker := time.NewTicker(peerBatchTimeout)
@@ -133,46 +124,31 @@ func (h *PeerHandler) flushLoop() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
h.mu.Lock()
if time.Since(h.lastFlush) >= peerBatchTimeout { if time.Since(h.lastFlush) >= peerBatchTimeout {
h.flushBatch() h.flushBatchLocked()
} }
case <-h.batchFullCh: h.mu.Unlock()
h.flushBatch()
case <-h.stopCh: case <-h.stopCh:
// Final flush // Final flush
h.flushBatch() h.mu.Lock()
h.flushBatchLocked()
h.mu.Unlock()
return return
} }
} }
} }
// takeBatch returns the pending updates and leaves an empty batch in their // flushBatchLocked flushes the peer batch to the database (must be called with mutex held)
// place for HandleMessage to fill. func (h *PeerHandler) flushBatchLocked() {
func (h *PeerHandler) takeBatch() []peerUpdate {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.peerBatch) == 0 { if len(h.peerBatch) == 0 {
return nil
}
batch := h.peerBatch
h.peerBatch = make([]peerUpdate, 0, peerBatchSize)
return batch
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
func (h *PeerHandler) flushBatch() {
batch := h.takeBatch()
if len(batch) == 0 {
return return
} }
// Deduplicate by peer IP, keeping the latest update for each peer // Deduplicate by peer IP, keeping the latest update for each peer
peerMap := make(map[string]peerUpdate) peerMap := make(map[string]peerUpdate)
for _, update := range batch { for _, update := range h.peerBatch {
if existing, ok := peerMap[update.peerIP]; !ok || update.timestamp.After(existing.timestamp) { if existing, ok := peerMap[update.peerIP]; !ok || update.timestamp.After(existing.timestamp) {
peerMap[update.peerIP] = update peerMap[update.peerIP] = update
} }
@@ -194,6 +170,8 @@ func (h *PeerHandler) flushBatch() {
h.logger.Error("Failed to process peer batch", "error", err, "count", len(dbPeerMap)) h.logger.Error("Failed to process peer batch", "error", err, "count", len(dbPeerMap))
} }
// Clear batch
h.peerBatch = h.peerBatch[:0]
h.lastFlush = time.Now() h.lastFlush = time.Now()
} }
+52 -75
View File
@@ -40,15 +40,12 @@ type PrefixHandler struct {
logger *logger.Logger logger *logger.Logger
metrics *metrics.Tracker metrics *metrics.Tracker
// Batching. mu guards batch only: the flush goroutine takes the batch // Batching
// under mu and writes it after releasing mu, so HandleMessage never mu sync.Mutex
// waits for a database write. batch []prefixUpdate
mu sync.Mutex lastFlush time.Time
batch []prefixUpdate stopCh chan struct{}
batchFullCh chan struct{} // wakes the flush goroutine when batch is full wg sync.WaitGroup
lastFlush time.Time // used only by the flush goroutine
stopCh chan struct{}
wg sync.WaitGroup
} }
type prefixUpdate struct { type prefixUpdate struct {
@@ -63,12 +60,11 @@ type prefixUpdate struct {
// NewPrefixHandler creates a new batched prefix tracking handler // NewPrefixHandler creates a new batched prefix tracking handler
func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler { func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler {
h := &PrefixHandler{ h := &PrefixHandler{
db: db, db: db,
logger: logger, logger: logger,
batch: make([]prefixUpdate, 0, prefixBatchSize), batch: make([]prefixUpdate, 0, prefixBatchSize),
batchFullCh: make(chan struct{}, 1), lastFlush: time.Now(),
lastFlush: time.Now(), stopCh: make(chan struct{}),
stopCh: make(chan struct{}),
} }
// Start the flush timer goroutine // Start the flush timer goroutine
@@ -109,24 +105,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// Withdrawals are queued before announcements, because the batch keeps // Process announcements
// each peer's last update for a prefix: a message that lists a prefix as
// both withdrawn and announced keeps the route (RFC 4271 section 4.3).
for _, prefix := range msg.Withdrawals {
h.batch = append(h.batch, prefixUpdate{
prefix: canonicalPrefix(prefix),
originASN: originASN, // Use the originASN from path if available
peer: msg.Peer,
messageType: "withdrawal",
timestamp: timestamp,
path: msg.Path,
})
// Record withdrawal in metrics
if h.metrics != nil {
h.metrics.RecordWithdrawal()
}
}
for _, announcement := range msg.Announcements { for _, announcement := range msg.Announcements {
for _, prefix := range announcement.Prefixes { for _, prefix := range announcement.Prefixes {
h.batch = append(h.batch, prefixUpdate{ h.batch = append(h.batch, prefixUpdate{
@@ -144,18 +123,29 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
} }
} }
// A full batch wakes the flush goroutine. If it is writing, the wake-up // Process withdrawals
// waits in batchFullCh and the batch keeps growing until the write ends. for _, prefix := range msg.Withdrawals {
if len(h.batch) >= prefixBatchSize { h.batch = append(h.batch, prefixUpdate{
select { prefix: canonicalPrefix(prefix),
case h.batchFullCh <- struct{}{}: originASN: originASN, // Use the originASN from path if available
default: // already woken peer: msg.Peer,
messageType: "withdrawal",
timestamp: timestamp,
path: msg.Path,
})
// Record withdrawal in metrics
if h.metrics != nil {
h.metrics.RecordWithdrawal()
} }
} }
// Check if we need to flush
if len(h.batch) >= prefixBatchSize {
h.flushBatchLocked()
}
} }
// flushLoop runs in a goroutine, the only one that writes the batch: it // flushLoop runs in a goroutine and periodically flushes batches
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PrefixHandler) flushLoop() { func (h *PrefixHandler) flushLoop() {
defer h.wg.Done() defer h.wg.Done()
ticker := time.NewTicker(prefixBatchTimeout) ticker := time.NewTicker(prefixBatchTimeout)
@@ -164,54 +154,39 @@ func (h *PrefixHandler) flushLoop() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
h.mu.Lock()
if time.Since(h.lastFlush) >= prefixBatchTimeout { if time.Since(h.lastFlush) >= prefixBatchTimeout {
h.flushBatch() h.flushBatchLocked()
} }
case <-h.batchFullCh: h.mu.Unlock()
h.flushBatch()
case <-h.stopCh: case <-h.stopCh:
// Final flush // Final flush
h.flushBatch() h.mu.Lock()
h.flushBatchLocked()
h.mu.Unlock()
return return
} }
} }
} }
// takeBatch returns the pending updates and leaves an empty batch in their // flushBatchLocked flushes the prefix batch to the database (must be called with mutex held)
// place for HandleMessage to fill. func (h *PrefixHandler) flushBatchLocked() {
func (h *PrefixHandler) takeBatch() []prefixUpdate {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.batch) == 0 { if len(h.batch) == 0 {
return nil
}
batch := h.batch
h.batch = make([]prefixUpdate, 0, prefixBatchSize)
return batch
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
func (h *PrefixHandler) flushBatch() {
batch := h.takeBatch()
if len(batch) == 0 {
return return
} }
startTime := time.Now() startTime := time.Now()
batchSize := len(batch) batchSize := len(h.batch)
// For each prefix and peer, keep only the last update the feed delivered. // Group updates by prefix to deduplicate
// Updates from different peers for the same prefix are all kept. Arrival // For each prefix, keep the latest update
// order decides, not the timestamp: timestamps are whole seconds, so prefixMap := make(map[string]prefixUpdate)
// updates within one second carry the same one. for _, update := range h.batch {
type prefixAndPeer struct{ prefix, peer string } key := update.prefix
lastUpdates := make(map[prefixAndPeer]prefixUpdate) if existing, ok := prefixMap[key]; !ok || update.timestamp.After(existing.timestamp) {
for _, update := range batch { prefixMap[key] = update
lastUpdates[prefixAndPeer{update.prefix, update.peer}] = update }
} }
// Collect routes to upsert and delete // Collect routes to upsert and delete
@@ -221,7 +196,7 @@ func (h *PrefixHandler) flushBatch() {
// Collect unique prefixes to update // Collect unique prefixes to update
prefixesToUpdate := make(map[string]time.Time) prefixesToUpdate := make(map[string]time.Time)
for _, update := range lastUpdates { for _, update := range prefixMap {
// Track prefix for both announcements and withdrawals // Track prefix for both announcements and withdrawals
if _, exists := prefixesToUpdate[update.prefix]; !exists || update.timestamp.After(prefixesToUpdate[update.prefix]) { if _, exists := prefixesToUpdate[update.prefix]; !exists || update.timestamp.After(prefixesToUpdate[update.prefix]) {
prefixesToUpdate[update.prefix] = update.timestamp prefixesToUpdate[update.prefix] = update.timestamp
@@ -280,11 +255,13 @@ func (h *PrefixHandler) flushBatch() {
elapsed := time.Since(startTime) elapsed := time.Since(startTime)
h.logger.Debug("Flushed prefix batch", h.logger.Debug("Flushed prefix batch",
"batch_size", batchSize, "batch_size", batchSize,
"unique_prefixes", len(prefixesToUpdate), "unique_prefixes", len(prefixMap),
"success", successCount, "success", successCount,
"duration_ms", elapsed.Milliseconds(), "duration_ms", elapsed.Milliseconds(),
) )
// Clear batch
h.batch = h.batch[:0]
h.lastFlush = time.Now() h.lastFlush = time.Now()
} }
+7 -139
View File
@@ -1,9 +1,7 @@
package routewatch package routewatch
import ( import (
"context"
"errors" "errors"
"slices"
"testing" "testing"
"time" "time"
@@ -30,6 +28,11 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
// Built without NewPrefixHandler's flush timer, so the test flushes itself. // Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{db: db, logger: logger.New()} h := &PrefixHandler{db: db, logger: logger.New()}
flush := func() {
h.mu.Lock()
defer h.mu.Unlock()
h.flushBatchLocked()
}
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
h.HandleMessage(&ristypes.RISMessage{ h.HandleMessage(&ristypes.RISMessage{
@@ -41,7 +44,7 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
Prefixes: []string{"2001:db8:1::/48", "192.0.2.0/24"}, Prefixes: []string{"2001:db8:1::/48", "192.0.2.0/24"},
}}, }},
}) })
h.flushBatch() flush()
for ip, want := range map[string]string{ for ip, want := range map[string]string{
"2001:db8:1::1": "2001:db8:1::/48", "2001:db8:1::1": "2001:db8:1::/48",
@@ -61,7 +64,7 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
ParsedTimestamp: ts.Add(time.Minute), ParsedTimestamp: ts.Add(time.Minute),
Withdrawals: []string{"2001:db8:1:0:0:0:0:0/48", "192.0.2.0/24"}, Withdrawals: []string{"2001:db8:1:0:0:0:0:0/48", "192.0.2.0/24"},
}) })
h.flushBatch() flush()
for _, ip := range []string{"2001:db8:1::1", "192.0.2.1"} { for _, ip := range []string{"2001:db8:1::1", "192.0.2.1"} {
if info, err := db.GetIPInfo(ip); !errors.Is(err, database.ErrNoRoute) { if info, err := db.GetIPInfo(ip); !errors.Is(err, database.ErrNoRoute) {
@@ -69,138 +72,3 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
} }
} }
} }
// TestPrefixHandlerAppliesEachPeersLastUpdateInABatch runs updates for one
// prefix through the prefix handler into a real database. All updates carry the
// same timestamp, as updates within one second of the feed do. Within a batch,
// the last update each peer sent for the prefix decides whether that peer has a
// live route for it, and updates from different peers are all applied. The
// in-memory route count and prefix distribution must match the route table.
func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) {
const (
prefix = "198.51.100.0/24"
prefixMaskLength = 24
otherPeerIP = "2001:db8:ffff::2"
)
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
announce := func(peer string) *ristypes.RISMessage {
return &ristypes.RISMessage{
Peer: peer,
Path: ristypes.ASPath{testASNA, testASNB},
ParsedTimestamp: ts,
Announcements: []ristypes.RISAnnouncement{{NextHop: peer, Prefixes: []string{prefix}}},
}
}
withdraw := func(peer string) *ristypes.RISMessage {
return &ristypes.RISMessage{Peer: peer, ParsedTimestamp: ts, Withdrawals: []string{prefix}}
}
announceAndWithdraw := func(peer string) *ristypes.RISMessage {
msg := announce(peer)
msg.Withdrawals = []string{prefix}
return msg
}
tests := []struct {
name string
// Each inner list is handled and then flushed as one batch.
batches [][]*ristypes.RISMessage
// The peers left with a live route for the prefix, by peer IP.
wantPeers []string
}{
{
name: "two peers announce the prefix",
batches: [][]*ristypes.RISMessage{{announce(testPeerIP), announce(otherPeerIP)}},
wantPeers: []string{testPeerIP, otherPeerIP},
},
{
name: "two peers withdraw the prefix",
batches: [][]*ristypes.RISMessage{
{announce(testPeerIP)},
{announce(otherPeerIP)},
{withdraw(testPeerIP), withdraw(otherPeerIP)},
},
},
{
name: "a peer announces and then withdraws the prefix",
batches: [][]*ristypes.RISMessage{{announce(testPeerIP), withdraw(testPeerIP)}},
},
{
name: "a peer withdraws and then re-announces the prefix",
batches: [][]*ristypes.RISMessage{
{announce(testPeerIP)},
{withdraw(testPeerIP), announce(testPeerIP)},
},
wantPeers: []string{testPeerIP},
},
{
// RFC 4271 section 4.3: the announcement counts, not the withdrawal.
name: "one message announces and withdraws the prefix",
batches: [][]*ristypes.RISMessage{
{announce(testPeerIP)},
{announceAndWithdraw(testPeerIP)},
},
wantPeers: []string{testPeerIP},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{db: db, logger: logger.New()}
for _, batch := range tt.batches {
for _, msg := range batch {
h.HandleMessage(msg)
}
h.flushBatch()
}
ctx := context.Background()
routes, _, err := db.GetPrefixDetailsContext(ctx, prefix)
if err != nil && !errors.Is(err, database.ErrNoRoute) {
t.Fatalf("GetPrefixDetailsContext: %v", err)
}
var gotPeers []string
for _, route := range routes {
gotPeers = append(gotPeers, route.PeerIP)
}
if !slices.Equal(gotPeers, tt.wantPeers) {
t.Errorf("peers with a live route for %s = %v, want %v", prefix, gotPeers, tt.wantPeers)
}
stats, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
}
tableRoutesV4, _, err := db.GetLiveRouteCountsContext(ctx)
if err != nil {
t.Fatalf("GetLiveRouteCountsContext: %v", err)
}
if stats.IPv4Routes != len(tt.wantPeers) || tableRoutesV4 != len(tt.wantPeers) {
t.Errorf("IPv4 live routes: %d in memory, %d in the table, want %d",
stats.IPv4Routes, tableRoutesV4, len(tt.wantPeers))
}
var wantDistribution []database.PrefixDistribution
if len(tt.wantPeers) > 0 {
wantDistribution = []database.PrefixDistribution{{MaskLength: prefixMaskLength, Count: 1}}
}
queryDistributionV4, _, err := db.GetPrefixDistributionContext(ctx)
if err != nil {
t.Fatalf("GetPrefixDistributionContext: %v", err)
}
if !slices.Equal(stats.IPv4PrefixDistribution, wantDistribution) ||
!slices.Equal(queryDistributionV4, wantDistribution) {
t.Errorf("IPv4 prefix distribution: %v in memory, %v from the table, want %v",
stats.IPv4PrefixDistribution, queryDistributionV4, wantDistribution)
}
})
}
}
+16 -23
View File
@@ -7,7 +7,6 @@ import (
"errors" "errors"
"net" "net"
"net/http" "net/http"
"net/netip"
"runtime" "runtime"
"sort" "sort"
"strconv" "strconv"
@@ -749,17 +748,10 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return return
} }
// Look the prefix up in the text form net/netip prints, the form the // Combine prefix and length into CIDR notation
// tables store, so that any valid way of writing it finds the route. prefix := prefixParam + "/" + lenParam
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
}
prefix := parsed.Masked().String()
routes, _, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { if errors.Is(err, database.ErrNoRoute) {
writeJSONError(w, http.StatusNotFound, err.Error()) writeJSONError(w, http.StatusNotFound, err.Error())
@@ -920,17 +912,10 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
return return
} }
// Look the prefix up in the text form net/netip prints, the form the // Combine prefix and length into CIDR notation
// tables store, so that any valid way of writing it finds the route. prefix := prefixParam + "/" + lenParam
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
http.Error(w, "Invalid prefix", http.StatusBadRequest)
return routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
}
prefix := parsed.Masked().String()
routes, originASNs, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoRoute) { if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "Prefix not found", http.StatusNotFound) http.Error(w, "Prefix not found", http.StatusNotFound)
@@ -952,10 +937,18 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
originMap := make(map[int]*ASNInfo) originMap := make(map[int]*ASNInfo)
for _, route := range routes { for _, route := range routes {
if _, exists := originMap[route.OriginASN]; !exists { if _, exists := originMap[route.OriginASN]; !exists {
// Get AS info from database
asInfo, _, _ := s.db.GetASDetailsContext(r.Context(), route.OriginASN)
handle := ""
description := ""
if asInfo != nil {
handle = asInfo.Handle
description = asInfo.Description
}
originMap[route.OriginASN] = &ASNInfo{ originMap[route.OriginASN] = &ASNInfo{
ASN: route.OriginASN, ASN: route.OriginASN,
Handle: originASNs[route.OriginASN].Handle, Handle: handle,
Description: originASNs[route.OriginASN].Description, Description: description,
PeerCount: 0, PeerCount: 0,
} }
} }
-122
View File
@@ -3,13 +3,10 @@ package server
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"html"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime" "runtime"
"slices" "slices"
"strings"
"testing" "testing"
"time" "time"
@@ -18,7 +15,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics" "git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/streamer" "git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -152,124 +148,6 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
} }
} }
// TestPrefixDetailHandlersParsePrefix checks that both prefix pages find a
// stored route whatever valid text form the URL writes its prefix in, and
// answer 400 for a prefix that does not parse. The tables store the form
// net/netip prints. Before the fix the URL text was looked up as typed, so
// "2001:DB8:0:0::/32" was not found although "2001:db8::/32" was stored, and
// "foo/24" answered 500 (https://git.eeqj.de/sneak/routewatch/issues/71).
func TestPrefixDetailHandlersParsePrefix(t *testing.T) {
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: "198.51.100.1", ASPath: []int{64500}, NextHop: "198.51.100.1",
LastUpdated: ts,
},
{
ID: uuid.New(), Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501}, NextHop: "2001:db8::1",
LastUpdated: ts,
},
}); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
tests := []struct {
path string
want int
}{
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
{"/prefix/foo/24", http.StatusBadRequest},
{"/api/v1/prefix/2001:DB8:0:0::/32", http.StatusOK},
{"/prefix/2001:DB8:0:0::/32", http.StatusOK},
{"/api/v1/prefix/192.0.2.1/24", http.StatusOK},
{"/prefix/192.0.2.1/24", http.StatusOK},
}
for _, tt := range tests {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
if rec.Code != tt.want {
t.Errorf("%s: status %d, want %d; body %s", tt.path, rec.Code, tt.want, rec.Body)
}
}
}
// failingASDetailsDB wraps a real store and fails every AS details lookup.
type failingASDetailsDB struct {
database.Store
}
func (failingASDetailsDB) GetASDetailsContext(_ context.Context, _ int) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errors.New("AS details lookup failed")
}
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
// origin AS's handle and description without looking up the AS's details,
// which also read every route the AS originates. Before the fix the page did
// that lookup once per origin, so with it failing the names were missing
// (https://git.eeqj.de/sneak/routewatch/issues/67). The AS path column shows
// handles too, so the test looks for the origin list's "(handle)" form.
func TestPrefixDetailShowsOriginNames(t *testing.T) {
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
origins := []int{15169, 13335}
for _, asn := range origins {
if _, err := db.GetOrCreateASN(asn, ts); err != nil {
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
}
}
if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: origins[0], PeerIP: "198.51.100.1", ASPath: []int{origins[0]}, NextHop: "198.51.100.1",
LastUpdated: ts,
},
{
ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: origins[1], PeerIP: "198.51.100.2", ASPath: []int{origins[1]}, NextHop: "198.51.100.2",
LastUpdated: ts,
},
}); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(failingASDetailsDB{db}, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/prefix/192.0.2.0/24", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
}
page := rec.Body.String()
for _, asn := range origins {
info, ok := asinfo.Get(asn)
if !ok {
t.Fatalf("AS%d is not in the AS info data", asn)
}
if want := "(" + html.EscapeString(info.Handle) + ")"; !strings.Contains(page, want) {
t.Errorf("page does not show AS%d's handle %q", asn, want)
}
if want := html.EscapeString(info.Description); !strings.Contains(page, want) {
t.Errorf("page does not show AS%d's description %q", asn, want)
}
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the // settledGoroutineCount lets transient goroutines finish, then reports the
// current count. // current count.
func settledGoroutineCount() int { func settledGoroutineCount() int {
+4 -14
View File
@@ -3,8 +3,6 @@ package server
import ( import (
"context" "context"
"fmt"
"net"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -55,12 +53,9 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger,
return s return s
} }
// Start listens on the configured port and serves HTTP in the background. // Start starts the HTTP server
// It returns an error if it cannot listen, for example because the port is
// already in use.
func (s *Server) Start() error { func (s *Server) Start() error {
const ( const (
network = "tcp"
readHeaderTimeout = 40 * time.Second readHeaderTimeout = 40 * time.Second
readTimeout = 60 * time.Second readTimeout = 60 * time.Second
writeTimeout = 60 * time.Second writeTimeout = 60 * time.Second
@@ -78,15 +73,10 @@ func (s *Server) Start() error {
s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr) s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr)
listener, err := net.Listen(network, s.srv.Addr) // Start in goroutine but log when actually listening
if err != nil {
return fmt.Errorf("starting HTTP server: %w", err)
}
s.logger.Info("HTTP server listening", "addr", listener.Addr().String())
go func() { go func() {
if err := s.srv.Serve(listener); err != nil && err != http.ErrServerClosed { s.logger.Info("HTTP server listening", "addr", s.srv.Addr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Error("HTTP server error", "error", err) s.logger.Error("HTTP server error", "error", err)
} }
}() }()
-33
View File
@@ -1,33 +0,0 @@
package server
import (
"context"
"net"
"testing"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/streamer"
)
// TestStartFailsWhenPortIsHeld holds a port with a listener of its own and
// checks that Start on that port returns an error. Before the fix Start
// listened in a goroutine, only logged the failure and returned nil, so the
// daemon kept running without its web server.
func TestStartFailsWhenPortIsHeld(t *testing.T) {
held, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatalf("failed to open the test listener: %v", err)
}
defer func() { _ = held.Close() }()
port := held.Addr().(*net.TCPAddr).Port
log := logger.New()
s := New(nil, streamer.New(log, metrics.New()), log, &config.Config{Port: port})
defer func() { _ = s.Stop(context.Background()) }()
if err := s.Start(); err == nil {
t.Fatalf("Start returned nil while port %d was held by another listener", port)
}
}
+15 -37
View File
@@ -7,7 +7,6 @@ import (
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math" "math"
@@ -49,7 +48,6 @@ const (
metricsUpdateRate = time.Second metricsUpdateRate = time.Second
minBackoffDelay = 5 * time.Second minBackoffDelay = 5 * time.Second
maxBackoffDelay = 320 * time.Second maxBackoffDelay = 320 * time.Second
streamIdleTimeout = 60 * time.Second // Reconnect when no line arrives for this long
metricsLogInterval = 10 * time.Second metricsLogInterval = 10 * time.Second
bytesPerKB = 1024 bytesPerKB = 1024
bytesPerMB = 1024 * 1024 bytesPerMB = 1024 * 1024
@@ -60,9 +58,6 @@ const (
backpressureSlope = 2.0 // Slope for linear drop probability increase backpressureSlope = 2.0 // Slope for linear drop probability increase
) )
// errStreamEnded is returned by stream when RIS Live ends the response.
var errStreamEnded = errors.New("RIS Live ended the stream")
// MessageHandler defines the interface for processing RIS messages. // MessageHandler defines the interface for processing RIS messages.
// Implementations must specify which message types they want to receive, // Implementations must specify which message types they want to receive,
// how to process messages, and their desired queue capacity. // how to process messages, and their desired queue capacity.
@@ -112,8 +107,6 @@ type Streamer struct {
logger *logger.Logger logger *logger.Logger
client *http.Client client *http.Client
url string url string
idleTimeout time.Duration // Drop the connection when no line arrives for this long
minBackoff time.Duration // First wait before reconnecting; doubles up to maxBackoffDelay
handlers []*handlerInfo handlers []*handlerInfo
rawHandler RawMessageHandler rawHandler RawMessageHandler
mu sync.RWMutex mu sync.RWMutex
@@ -131,10 +124,8 @@ type Streamer struct {
// The metrics tracker is used to record message counts, bytes received, and connection status. // The metrics tracker is used to record message counts, bytes received, and connection status.
func New(logger *logger.Logger, metrics *metrics.Tracker) *Streamer { func New(logger *logger.Logger, metrics *metrics.Tracker) *Streamer {
return &Streamer{ return &Streamer{
logger: logger, logger: logger,
url: risLiveURL, url: risLiveURL,
idleTimeout: streamIdleTimeout,
minBackoff: minBackoffDelay,
client: &http.Client{ client: &http.Client{
Timeout: 0, // No timeout for streaming Timeout: 0, // No timeout for streaming
Transport: &http.Transport{ Transport: &http.Transport{
@@ -414,7 +405,7 @@ func (s *Streamer) updateMetrics(messageBytes int) {
// streamWithReconnect handles streaming with automatic reconnection and exponential backoff // streamWithReconnect handles streaming with automatic reconnection and exponential backoff
func (s *Streamer) streamWithReconnect(ctx context.Context) { func (s *Streamer) streamWithReconnect(ctx context.Context) {
backoffDelay := s.minBackoff backoffDelay := minBackoffDelay
consecutiveFailures := 0 consecutiveFailures := 0
for { for {
@@ -431,9 +422,8 @@ func (s *Streamer) streamWithReconnect(ctx context.Context) {
err := s.stream(ctx) err := s.stream(ctx)
streamDuration := time.Since(startTime) streamDuration := time.Since(startTime)
// Only Stop ends the streamer. A stream that ends any other way, if err == nil {
// with or without an error, is a disconnect and is reconnected. // Clean exit (context cancelled)
if ctx.Err() != nil {
return return
} }
@@ -444,13 +434,17 @@ func (s *Streamer) streamWithReconnect(ctx context.Context) {
"stream_duration", streamDuration) "stream_duration", streamDuration)
s.metrics.SetConnected(false) s.metrics.SetConnected(false)
// A stream that lasted more than 30 seconds resets the backoff. That // Check if context is cancelled
// includes a stream dropped after streamIdleTimeout with no line, so if ctx.Err() != nil {
// a silent feed is retried after the first backoff delay every time. return
}
// If we streamed for more than 30 seconds, reset the backoff
// This indicates we had a successful connection that received data
if streamDuration > 30*time.Second { if streamDuration > 30*time.Second {
s.logger.Info("Resetting backoff delay due to successful connection", s.logger.Info("Resetting backoff delay due to successful connection",
"stream_duration", streamDuration) "stream_duration", streamDuration)
backoffDelay = s.minBackoff backoffDelay = minBackoffDelay
consecutiveFailures = 0 consecutiveFailures = 0
} else { } else {
// Increment consecutive failures // Increment consecutive failures
@@ -483,13 +477,7 @@ func (s *Streamer) stream(ctx context.Context) error {
connCtx, connCancel := context.WithCancel(ctx) connCtx, connCancel := context.WithCancel(ctx)
defer connCancel() defer connCancel()
// The idle timer drops the connection, by cancelling connCtx, when no req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
// line arrives for s.idleTimeout. Each line read starts it over. It
// also covers the wait for the server to answer.
idleTimer := time.AfterFunc(s.idleTimeout, connCancel)
defer idleTimer.Stop()
req, err := http.NewRequestWithContext(connCtx, "GET", s.url, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
@@ -571,8 +559,6 @@ func (s *Streamer) stream(ctx context.Context) error {
scanner := bufio.NewScanner(reader) scanner := bufio.NewScanner(reader)
for scanner.Scan() { for scanner.Scan() {
idleTimer.Reset(s.idleTimeout)
select { select {
case <-ctx.Done(): case <-ctx.Done():
s.logger.Info("Stream stopped by context") s.logger.Info("Stream stopped by context")
@@ -721,19 +707,11 @@ func (s *Streamer) stream(ctx context.Context) error {
s.mu.RUnlock() s.mu.RUnlock()
} }
if ctx.Err() != nil {
return ctx.Err()
}
if connCtx.Err() != nil {
return fmt.Errorf("no line received for %s", s.idleTimeout)
}
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return fmt.Errorf("scanner error: %w", err) return fmt.Errorf("scanner error: %w", err)
} }
return errStreamEnded return nil
} }
// shouldDropForBackpressure determines if a message should be dropped based on queue utilization // shouldDropForBackpressure determines if a message should be dropped based on queue utilization
+10 -142
View File
@@ -7,7 +7,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime" "runtime"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -40,14 +39,6 @@ func TestNewStreamer(t *testing.T) {
if s.metrics != metricsTracker { if s.metrics != metricsTracker {
t.Error("metrics tracker not set correctly") t.Error("metrics tracker not set correctly")
} }
if s.idleTimeout != streamIdleTimeout {
t.Errorf("idleTimeout = %s, want %s", s.idleTimeout, streamIdleTimeout)
}
if s.minBackoff != minBackoffDelay {
t.Errorf("minBackoff = %s, want %s", s.minBackoff, minBackoffDelay)
}
} }
// TestStreamDoesNotLeakTickersAcrossReconnects drives many short-lived // TestStreamDoesNotLeakTickersAcrossReconnects drives many short-lived
@@ -66,8 +57,8 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
// One warm-up connection so any persistent HTTP transport goroutine exists // One warm-up connection so any persistent HTTP transport goroutine exists
// before we take the baseline. // before we take the baseline.
if err := s.stream(context.Background()); !errors.Is(err, errStreamEnded) { if err := s.stream(context.Background()); err != nil {
t.Fatalf("warm-up stream returned %v, want %v", err, errStreamEnded) t.Fatalf("warm-up stream returned error: %v", err)
} }
s.client.CloseIdleConnections() s.client.CloseIdleConnections()
@@ -75,8 +66,8 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
const reconnects = 20 const reconnects = 20
for range reconnects { for range reconnects {
if err := s.stream(context.Background()); !errors.Is(err, errStreamEnded) { if err := s.stream(context.Background()); err != nil {
t.Fatalf("stream returned %v, want %v", err, errStreamEnded) t.Fatalf("stream returned error: %v", err)
} }
} }
s.client.CloseIdleConnections() s.client.CloseIdleConnections()
@@ -87,35 +78,23 @@ func TestStreamDoesNotLeakTickersAcrossReconnects(t *testing.T) {
} }
} }
// updateLine is one UPDATE message as RIS Live sends it. // updateHandler wants UPDATE messages and does nothing with them.
const updateLine = `{"type":"ris_message","data":{"type":"UPDATE","peer":"192.0.2.1",` + type updateHandler struct{}
`"peer_asn":"64496","timestamp":1700000000}}` + "\n"
// updateHandler wants UPDATE messages. For each one it handles it sends on
// handled if there is room, and otherwise does nothing; a nil handled is
// never sent on.
type updateHandler struct {
handled chan struct{}
}
func (updateHandler) WantsMessage(messageType string) bool { return messageType == "UPDATE" } func (updateHandler) WantsMessage(messageType string) bool { return messageType == "UPDATE" }
func (updateHandler) HandleMessage(*ristypes.RISMessage) {}
func (updateHandler) QueueCapacity() int { return 10 } func (updateHandler) QueueCapacity() int { return 10 }
func (h updateHandler) HandleMessage(*ristypes.RISMessage) {
select {
case h.handled <- struct{}{}:
default:
}
}
// TestStopBeforeMessageReachesQueues stops the streamer after the read loop // TestStopBeforeMessageReachesQueues stops the streamer after the read loop
// has checked for cancellation but before it hands the message to the handler // has checked for cancellation but before it hands the message to the handler
// queues. That is the gap a stop from another goroutine can land in, and it // queues. That is the gap a stop from another goroutine can land in, and it
// used to end in "send on closed channel". The raw handler runs in that gap on // used to end in "send on closed channel". The raw handler runs in that gap on
// the read loop itself, so calling Stop from it hits the gap every time. // the read loop itself, so calling Stop from it hits the gap every time.
func TestStopBeforeMessageReachesQueues(t *testing.T) { func TestStopBeforeMessageReachesQueues(t *testing.T) {
const line = `{"type":"ris_message","data":{"type":"UPDATE","peer":"192.0.2.1",` +
`"peer_asn":"64496","timestamp":1700000000}}` + "\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, updateLine) _, _ = io.WriteString(w, line)
})) }))
defer srv.Close() defer srv.Close()
@@ -138,117 +117,6 @@ func TestStopBeforeMessageReachesQueues(t *testing.T) {
s.Stop() s.Stop()
} }
// TestReconnectWhenStreamEnds has the server end the first response
// normally, as when RIS Live restarts. The streamer must treat that as a
// disconnect and connect again.
func TestReconnectWhenStreamEnds(t *testing.T) {
endResponse := func(http.ResponseWriter, *http.Request) {}
checkReconnect(t, endResponse, streamIdleTimeout)
}
// TestReconnectWhenStreamGoesIdle has the server hold the first response
// open without sending anything. The streamer must drop it once the idle
// timeout passes and connect again.
func TestReconnectWhenStreamGoesIdle(t *testing.T) {
const idleTimeout = 50 * time.Millisecond
holdResponse := func(w http.ResponseWriter, r *http.Request) {
w.(http.Flusher).Flush()
<-r.Context().Done()
}
checkReconnect(t, holdResponse, idleTimeout)
}
// TestReconnectWhenServerNeverAnswers has the server accept the first
// request and never answer it, not even with headers. The streamer must give
// up on it once the idle timeout passes and connect again.
func TestReconnectWhenServerNeverAnswers(t *testing.T) {
const idleTimeout = 50 * time.Millisecond
neverAnswer := func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}
checkReconnect(t, neverAnswer, idleTimeout)
}
// TestStreamNotDroppedWhileLinesArrive has the server send lines well within
// the idle timeout for several times the timeout, then end the response. Each
// line must start the idle timeout over, so the stream lasts until the server
// ends it instead of being dropped as idle.
func TestStreamNotDroppedWhileLinesArrive(t *testing.T) {
const (
idleTimeout = 100 * time.Millisecond
lineInterval = idleTimeout / 10
sendFor = 5 * idleTimeout
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
deadline := time.Now().Add(sendFor)
for time.Now().Before(deadline) {
_, _ = io.WriteString(w, updateLine)
w.(http.Flusher).Flush()
time.Sleep(lineInterval)
}
}))
defer srv.Close()
s := New(logger.New(), metrics.New())
s.url = srv.URL
s.idleTimeout = idleTimeout
if err := s.stream(context.Background()); !errors.Is(err, errStreamEnded) {
t.Fatalf("stream returned %v, want %v", err, errStreamEnded)
}
}
// checkReconnect runs a streamer against a fake RIS Live server. The first
// response is served by firstResponse; every later one carries one UPDATE
// line and then stays open. The streamer must come back for a second
// response, must not be marked connected when it does, and must handle the
// UPDATE in it.
func checkReconnect(t *testing.T, firstResponse http.HandlerFunc, idleTimeout time.Duration) {
t.Helper()
const maxWait = 5 * time.Second
tracker := metrics.New()
connectedAtReconnect := make(chan bool, 1)
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1) == 1 {
firstResponse(w, r)
return
}
select {
case connectedAtReconnect <- tracker.IsConnected():
default:
}
_, _ = io.WriteString(w, updateLine)
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer srv.Close()
s := New(logger.New(), tracker)
s.url = srv.URL
s.idleTimeout = idleTimeout
s.minBackoff = time.Millisecond
handler := updateHandler{handled: make(chan struct{}, 1)}
s.RegisterHandler(handler)
if err := s.Start(); err != nil {
t.Fatalf("Start returned error: %v", err)
}
defer s.Stop()
select {
case <-handler.handled:
case <-time.After(maxWait):
t.Fatalf("no message from a second response handled within %s", maxWait)
}
if <-connectedAtReconnect {
t.Error("streamer was still marked connected when it came back for a second response")
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the // settledGoroutineCount lets transient goroutines finish, then reports the
// current count. // current count.
func settledGoroutineCount() int { func settledGoroutineCount() int {
-1
View File
@@ -283,7 +283,6 @@
<div class="origin-item"> <div class="origin-item">
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a> <a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
{{if .Handle}} ({{.Handle}}){{end}} {{if .Handle}} ({{.Handle}}){{end}}
{{if .Description}} {{.Description}}{{end}}
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span> <span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
</div> </div>
{{end}} {{end}}