2 Commits
Author SHA1 Message Date
clawbot 741bea4b9e Remove a peer's live routes when its session goes down (closes #56)
check / check (push) Canceled after 0s
RIS sends no withdrawals for the routes of a peer whose BGP session goes
down, so those routes stayed in the live tables for good.

The streamer now hands STATE messages to the handlers that want them and
never drops one for backpressure: it waits for room in a full queue until
the idle timer drops the connection. When the prefix handler gets one whose
state is down, it drops the peer's updates still in its batch and notes the
peer; the next flush deletes all of the peer's live routes, IPv4 and IPv6,
before writing the batch, so updates the peer sends after the down are
kept. The live route tables' unique key now leads with peer_ip, so it also
finds a peer's routes.

Model: opus-5-5
2026-10-19 08:48:23 +00:00
clawbot a0ddc0d254 Sample the prefix-length pages with bounded lookups, each prefix once (closes #66)
check / check (push) Canceled after 0s
The prefix-length pages read every live route of that length and listed a
prefix once per peer sharing its newest time.

A length with at most 5000 prefixes now has its prefixes listed with one
index search each and 500 picked from them at random. A longer one is
sampled with random row id lookups on the mask length index, at most four
per prefix, topped up from the listed prefixes. Each picked prefix's newest
route is one search of a new index on prefix and last_updated, which
replaces the prefix-only index. Each prefix is listed once. Above 5000
prefixes the sample is uneven: a route is picked in proportion to the row
ids before it.

Model: opus-5-5
2026-10-19 10:29:01 +02:00
13 changed files with 1178 additions and 78 deletions
+4 -1
View File
@@ -128,7 +128,10 @@ routewatch/
4. **Handler Details**
- **ASHandler**: Tracks all ASNs seen in AS paths
- **PeerHandler**: Records BGP peer information
- **PrefixHandler**: Maintains live routing table (upserts on announcement, deletes on withdrawal)
- **PrefixHandler**: Maintains live routing table (upserts on announcement, deletes on withdrawal,
deletes all of a peer's routes when its BGP session goes down). Routes are kept per peer IP, so
when one peer IP has sessions on two collectors, a session going down on one also removes the
routes the other still carries, until they are announced again.
- **PeeringHandler**: Extracts AS peering relationships from AS paths
5. **HTTP Request Flow**
+83
View File
@@ -349,6 +349,89 @@ func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error {
return db.DeleteLiveRouteBatch(deletions)
}
// TestDeleteLiveRoutesOfPeer gives two peers IPv4 and IPv6 live routes, some
// for the same prefixes, and deletes all of one peer's routes. The other peer's
// routes stay, and the route counts and prefix distribution held in memory
// equal what is read from the route tables.
func TestDeleteLiveRoutesOfPeer(t *testing.T) {
const (
peerIP = "192.0.2.1"
otherPeerIP = "192.0.2.2"
sharedV4 = "198.51.100.0/24"
sharedV6 = "2001:db8::/32"
)
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
ofPeer := func(peer string, route *LiveRoute) *LiveRoute {
route.PeerIP = peer
return route
}
onlyV6 := mkV6Route("2001:db8:1::/48", 64503, ts)
onlyV6.MaskLength = 48
if err := db.UpsertLiveRouteBatch([]*LiveRoute{
ofPeer(peerIP, mkV4Route(t, sharedV4, 64500, ts)),
ofPeer(otherPeerIP, mkV4Route(t, sharedV4, 64500, ts)),
ofPeer(peerIP, mkV4Route(t, "203.0.113.0/24", 64501, ts)),
ofPeer(peerIP, mkV6Route(sharedV6, 64502, ts)),
ofPeer(otherPeerIP, mkV6Route(sharedV6, 64502, ts)),
ofPeer(peerIP, onlyV6),
}); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
// peerIP has two IPv4 and two IPv6 routes; a second delete finds none.
for _, want := range []int{4, 0} {
deleted, err := db.DeleteLiveRoutesOfPeer(peerIP)
if err != nil {
t.Fatalf("DeleteLiveRoutesOfPeer: %v", err)
}
if deleted != want {
t.Errorf("DeleteLiveRoutesOfPeer deleted %d routes, want %d", deleted, want)
}
}
ctx := context.Background()
for _, prefix := range []string{sharedV4, sharedV6} {
routes, _, err := db.GetPrefixDetailsContext(ctx, prefix)
if err != nil {
t.Fatalf("GetPrefixDetailsContext(%s): %v", prefix, err)
}
if len(routes) != 1 || routes[0].PeerIP != otherPeerIP {
t.Errorf("live routes for %s = %+v, want only the one from %s", prefix, routes, otherPeerIP)
}
}
stats, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
}
tableV4, tableV6, err := db.GetLiveRouteCountsContext(ctx)
if err != nil {
t.Fatalf("GetLiveRouteCountsContext: %v", err)
}
if stats.IPv4Routes != 1 || tableV4 != 1 || stats.IPv6Routes != 1 || tableV6 != 1 {
t.Errorf("live routes (IPv4, IPv6): (%d, %d) in memory, (%d, %d) in the tables, want (1, 1)",
stats.IPv4Routes, stats.IPv6Routes, tableV4, tableV6)
}
queryV4, queryV6, err := db.GetPrefixDistributionContext(ctx)
if err != nil {
t.Fatalf("GetPrefixDistributionContext: %v", err)
}
wantV4 := []PrefixDistribution{{MaskLength: 24, Count: 1}}
wantV6 := []PrefixDistribution{{MaskLength: 32, Count: 1}}
assertDistribution(t, "IPv4 distribution", stats.IPv4PrefixDistribution, wantV4)
assertDistribution(t, "IPv6 distribution", stats.IPv6PrefixDistribution, wantV6)
assertDistribution(t, "IPv4 distribution query", queryV4, wantV4)
assertDistribution(t, "IPv6 distribution query", queryV6, wantV6)
}
// TestStatsRouteTimestamps checks the oldest/newest route timestamps are read
// from the right rows across both tables and parse into time.Time. The old
// MIN/MAX union query read its result into *time.Time, which the driver could
+270 -30
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"net"
"net/netip"
"os"
@@ -61,6 +62,17 @@ const (
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.
maxPrefixesToShuffle = 5000
// sampleLookupsPerPrefix is the most random lookups made for each prefix
// asked for.
sampleLookupsPerPrefix = 4
)
// Common errors
var (
// ErrInvalidIP is returned when an IP address is malformed
@@ -530,6 +542,91 @@ func (d *Database) DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error {
return nil
}
// Deleting all live routes of a peer. The first query of each table selects the
// mask length of each of the peer's routes whose prefix no other peer has a
// live route for: the delete takes those prefixes out of the prefix
// distribution.
const (
peerOnlyRouteMaskLengthsV4SQL = `SELECT r.mask_length FROM live_routes_v4 AS r WHERE r.peer_ip = ?
AND NOT EXISTS (SELECT 1 FROM live_routes_v4 AS o WHERE o.prefix = r.prefix AND o.peer_ip != r.peer_ip)`
deletePeerLiveRoutesV4SQL = `DELETE FROM live_routes_v4 WHERE peer_ip = ?`
peerOnlyRouteMaskLengthsV6SQL = `SELECT r.mask_length FROM live_routes_v6 AS r WHERE r.peer_ip = ?
AND NOT EXISTS (SELECT 1 FROM live_routes_v6 AS o WHERE o.prefix = r.prefix AND o.peer_ip != r.peer_ip)`
deletePeerLiveRoutesV6SQL = `DELETE FROM live_routes_v6 WHERE peer_ip = ?`
)
// deletePeerRoutes deletes all live routes of peerIP from one table, given that
// table's two statements above. It returns how many routes it deleted and the
// mask length of each prefix it left with no live route.
func deletePeerRoutes(tx *loggingTx, onlyRouteMaskLengthsSQL, deleteSQL, peerIP string) (
deleted int64, goneMaskLengths []int, err error) {
rows, err := tx.Query(onlyRouteMaskLengthsSQL, peerIP)
if err != nil {
return 0, nil, err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var maskLength int
if err := rows.Scan(&maskLength); err != nil {
return 0, nil, err
}
goneMaskLengths = append(goneMaskLengths, maskLength)
}
if err := rows.Err(); err != nil {
return 0, nil, err
}
res, err := tx.Exec(deleteSQL, peerIP)
if err != nil {
return 0, nil, err
}
deleted, err = res.RowsAffected()
if err != nil {
return 0, nil, err
}
return deleted, goneMaskLengths, nil
}
// DeleteLiveRoutesOfPeer deletes every live route of the peer, IPv4 and IPv6,
// in one transaction, as when the peer's BGP session goes down. It returns how
// many routes it deleted.
func (d *Database) DeleteLiveRoutesOfPeer(peerIP string) (int, error) {
d.lock("DeleteLiveRoutesOfPeer")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return 0, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
d.logger.Error("Failed to rollback transaction", "error", err)
}
}()
deletedV4, goneMaskLengthsV4, err := deletePeerRoutes(tx,
peerOnlyRouteMaskLengthsV4SQL, deletePeerLiveRoutesV4SQL, peerIP)
if err != nil {
return 0, fmt.Errorf("failed to delete the IPv4 routes of peer %s: %w", peerIP, err)
}
deletedV6, goneMaskLengthsV6, err := deletePeerRoutes(tx,
peerOnlyRouteMaskLengthsV6SQL, deletePeerLiveRoutesV6SQL, peerIP)
if err != nil {
return 0, fmt.Errorf("failed to delete the IPv6 routes of peer %s: %w", peerIP, err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("failed to commit transaction: %w", err)
}
d.counts.addRoutes(-int(deletedV4), -int(deletedV6))
d.counts.addToDistribution(goneMaskLengthsV4, goneMaskLengthsV6, -1)
return int(deletedV4 + deletedV6), nil
}
// UpdatePrefixesBatch updates the last_seen time for multiple prefixes in a single transaction
func (d *Database) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
if len(prefixes) == 0 {
@@ -1667,45 +1764,189 @@ func (d *Database) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) (
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, for a limit up to
// maxPrefixesToShuffle. However many routes there are, it makes at most
// maxPrefixesToShuffle+1 index searches to list prefixes, at most
// sampleLookupsPerPrefix random lookups for each prefix asked for, and one
// index search for each listed prefix's newest route.
func (d *Database) GetRandomPrefixesByLengthContext(
ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) {
// Select unique prefixes with their most recent route information
tableName := "live_routes_v4"
if ipVersion == ipVersionV6 {
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 prefixes, in text order, with the
// given mask length. Each prefix is one search of the mask length and prefix
// index for the prefix after the previous one, so the read does not grow with
// the number of peers that carry a prefix, as a SELECT DISTINCT would.
func (d *Database) firstPrefixesByLength(
ctx context.Context, tableName string, maskLength, n int) ([]string, error) {
//nolint:gosec // Table name is hardcoded based on IP version
query := fmt.Sprintf(`
WITH unique_prefixes AS (
SELECT prefix, MAX(last_updated) as max_updated
FROM %s
WHERE mask_length = ?
GROUP BY prefix
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)
SELECT prefix FROM %s
WHERE mask_length = ? AND prefix > ?
ORDER BY prefix
LIMIT 1
`, tableName)
rows, err := d.db.QueryContext(ctx, query, maskLength, limit, maskLength)
stmt, err := d.db.PrepareContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query random prefixes: %w", err)
return nil, fmt.Errorf("failed to prepare prefix query: %w", err)
}
defer func() {
_ = rows.Close()
}()
defer func() { _ = stmt.Close() }()
var routes []LiveRoute
for rows.Next() {
var route LiveRoute
var prefixes []string
previous := "" // every prefix sorts after the empty string
for len(prefixes) < n {
var prefix string
err := stmt.QueryRowContext(ctx, maskLength, previous).Scan(&prefix)
if errors.Is(err, sql.ErrNoRows) {
break
}
if err != nil {
return nil, fmt.Errorf("failed to list prefixes: %w", err)
}
prefixes = append(prefixes, prefix)
previous = prefix
}
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.
//
// The sample is uneven, as the page needs a varied sample, not an even one: a
// route is picked in proportion to the row ids between it and the previous
// route with that mask length, including the row ids of routes with other mask
// lengths and of withdrawn routes. A route that follows a long run of
// withdrawn routes is picked far more often than one that directly follows
// another route with that mask length.
//
// 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 search of the prefix and last_updated index per prefix however
// many peers carry it. 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
err := rows.Scan(
err := stmt.QueryRowContext(ctx, prefix).Scan(
&route.Prefix,
&route.MaskLength,
&route.OriginASN,
@@ -1713,17 +1954,16 @@ func (d *Database) GetRandomPrefixesByLengthContext(
&route.PeerIP,
&route.LastUpdated,
)
// Set IP version based on which table we queried
route.IPVersion = ipVersion
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
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 {
route.ASPath = []int{}
}
routes = append(routes, route)
}
+305
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/netip"
"slices"
"sync"
"testing"
"time"
@@ -46,6 +47,32 @@ const (
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"
// largeSamplePrefixCount is how many prefixes the test of a mask length
// with more than maxPrefixesToShuffle prefixes stores: twice that, so about
// half of them come after the first maxPrefixesToShuffle+1 in text order.
largeSamplePrefixCount = 2 * maxPrefixesToShuffle
// largeSampleLimit is how many prefixes that test asks for. Each random
// lookup lands after the first maxPrefixesToShuffle+1 in text order about
// half the time, so none of them doing so is about as likely as
// largeSampleLimit coin tosses all coming up heads.
largeSampleLimit = 30
// liveRoutesV4Table and liveRoutesV6Table are the live route tables the
// tests pass to the sampling steps.
liveRoutesV4Table = "live_routes_v4"
liveRoutesV6Table = "live_routes_v6"
)
// TestGetIPInfoFindsMostSpecificLiveRoute stores nested live prefixes for both
// families and checks that a lookup returns the most specific one covering the
// address, ErrNoRoute when none covers it, and the next less specific prefix
@@ -423,3 +450,281 @@ func TestBatchWriteDuringCheckpoint(t *testing.T) {
cancel()
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(), liveRoutesV4Table, maskLength, sampleLimit, nil)
if err != nil {
t.Fatalf("samplePrefixesByLength: %v", err)
}
checkPrefixSample(t, "samplePrefixesByLength", looked, stored)
}
// checkPrefixSample checks that sample lists sampleLimit prefixes, each once
// and each one of stored, and that it is not just the first sampleLimit of
// stored, which is in text order.
func checkPrefixSample(t *testing.T, name string, sample, stored []string) {
t.Helper()
if len(sample) != sampleLimit {
t.Errorf("%s listed %d prefixes, want %d", name, len(sample), sampleLimit)
}
listed := make(map[string]bool)
for _, prefix := range sample {
if listed[prefix] || !slices.Contains(stored, prefix) {
t.Errorf("%s lists %s twice or it is not one of the /24s", name, prefix)
}
listed[prefix] = true
}
if slices.Equal(slices.Sorted(slices.Values(sample)), stored[:sampleLimit]) {
t.Errorf("%s lists just the first %d prefixes in text order", name, sampleLimit)
}
}
// TestGetRandomPrefixesByLengthSamplesPastFirstPrefixes stores more prefixes
// of one mask length than maxPrefixesToShuffle. It checks that
// firstPrefixesByLength reads just the first maxPrefixesToShuffle+1 in text
// order, and that the sample lists the prefixes asked for, each once, some of
// them after those first ones, which only the random lookups reach.
func TestGetRandomPrefixesByLengthSamplesPastFirstPrefixes(t *testing.T) {
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 largeSamplePrefixCount {
route := mkV6Route(fmt.Sprintf("2001:%x::/32", i), 64500, ts)
routes = append(routes, route)
stored = append(stored, route.Prefix)
}
if err := db.UpsertLiveRouteBatch(routes); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
slices.Sort(stored)
firstPrefixes := stored[:maxPrefixesToShuffle+1]
maskLength := routes[0].MaskLength
read, err := db.firstPrefixesByLength(
context.Background(), liveRoutesV6Table, maskLength, maxPrefixesToShuffle+1)
if err != nil {
t.Fatalf("firstPrefixesByLength: %v", err)
}
if !slices.Equal(read, firstPrefixes) {
t.Errorf("firstPrefixesByLength read %d prefixes, want the first %d in text order",
len(read), len(firstPrefixes))
}
sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV6, largeSampleLimit)
if err != nil {
t.Fatalf("GetRandomPrefixesByLength: %v", err)
}
if len(sample) != largeSampleLimit {
t.Errorf("GetRandomPrefixesByLength listed %d prefixes, want %d", len(sample), largeSampleLimit)
}
listed := make(map[string]bool)
pastFirstPrefixes := 0
for _, route := range sample {
if listed[route.Prefix] {
t.Errorf("GetRandomPrefixesByLength lists %s twice", route.Prefix)
}
listed[route.Prefix] = true
if !slices.Contains(firstPrefixes, route.Prefix) {
pastFirstPrefixes++
}
}
if pastFirstPrefixes == 0 {
t.Errorf("GetRandomPrefixesByLength lists only prefixes among the first %d in text order",
len(firstPrefixes))
}
}
// TestSamplePrefixesByLengthFillsUpFromShuffled stores a single prefix, so
// every random lookup lands on it, and checks that the sample still lists
// sampleLimit prefixes: the stored one, then the shuffled ones in order. The
// stored prefix is the second shuffled one, so the fill-up must skip it.
func TestSamplePrefixesByLengthFillsUpFromShuffled(t *testing.T) {
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() }()
var shuffled []string
for i := range sampledPrefixCount {
shuffled = append(shuffled, fmt.Sprintf("10.0.%d.0/24", i))
}
route := mkV4Route(t, shuffled[1], 64500, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
if err := db.UpsertLiveRouteBatch([]*LiveRoute{route}); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
sample, err := db.samplePrefixesByLength(
context.Background(), liveRoutesV4Table, route.MaskLength, sampleLimit, shuffled)
if err != nil {
t.Fatalf("samplePrefixesByLength: %v", err)
}
want := append([]string{shuffled[1], shuffled[0]}, shuffled[2:sampleLimit]...)
if !slices.Equal(sample, want) {
t.Errorf("samplePrefixesByLength = %v, want %v", sample, want)
}
}
// TestGetRandomPrefixesByLengthSkipsWithdrawnRoutes withdraws a route after
// storing it, as can happen between the steps of GetRandomPrefixesByLength. It
// checks that the random lookups pick nothing for a mask length with no route
// left, and that only the prefixes that still have a route get their newest
// route listed.
func TestGetRandomPrefixesByLengthSkipsWithdrawnRoutes(t *testing.T) {
cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
kept := mkV4Route(t, "198.51.100.0/24", 64500, ts)
withdrawn := mkV4Route(t, "172.16.0.0/16", 64500, ts)
if err := db.UpsertLiveRouteBatch([]*LiveRoute{kept, withdrawn}); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
if err := db.DeleteLiveRoute(withdrawn.Prefix, withdrawn.PeerIP); err != nil {
t.Fatalf("DeleteLiveRoute: %v", err)
}
ctx := context.Background()
sample, err := db.samplePrefixesByLength(
ctx, liveRoutesV4Table, withdrawn.MaskLength, sampleLimit, []string{withdrawn.Prefix})
if err != nil {
t.Fatalf("samplePrefixesByLength: %v", err)
}
if len(sample) != 0 {
t.Errorf("samplePrefixesByLength = %v, want none for a mask length with no route left", sample)
}
routes, err := db.newestRouteOfEachPrefix(
ctx, liveRoutesV4Table, ipVersionV4, []string{withdrawn.Prefix, kept.Prefix})
if err != nil {
t.Fatalf("newestRouteOfEachPrefix: %v", err)
}
if len(routes) != 1 || routes[0].Prefix != kept.Prefix {
t.Errorf("newestRouteOfEachPrefix = %+v, want only the route of %s", routes, kept.Prefix)
}
}
+1
View File
@@ -57,6 +57,7 @@ type Store interface {
UpsertLiveRouteBatch(routes []*LiveRoute) error
DeleteLiveRoute(prefix, peerIP string) error
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
DeleteLiveRoutesOfPeer(peerIP string) (deleted int, err error)
GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
GetPrefixDistributionContext(ctx context.Context) (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
+10 -4
View File
@@ -107,7 +107,10 @@ CREATE TABLE IF NOT EXISTS live_routes_v4 (
as_path TEXT NOT NULL, -- JSON array
next_hop TEXT NOT NULL,
last_updated DATETIME NOT NULL,
UNIQUE(prefix, peer_ip) -- a peer has at most one route for a prefix
-- A peer has at most one route for a prefix. peer_ip comes first so that
-- this key also finds all of a peer's routes, when its BGP session goes
-- down; lookups by prefix use the prefix index below.
UNIQUE(peer_ip, prefix)
);
-- IPv6 routing table maintained by PrefixHandler
@@ -120,11 +123,13 @@ CREATE TABLE IF NOT EXISTS live_routes_v6 (
as_path TEXT NOT NULL, -- JSON array
next_hop TEXT NOT NULL,
last_updated DATETIME NOT NULL,
UNIQUE(prefix, peer_ip) -- a peer has at most one route for a prefix
-- As in live_routes_v4: peer_ip first, to find all of a peer's routes.
UNIQUE(peer_ip, prefix)
);
-- Indexes for live_routes_v4 table
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_prefix ON live_routes_v4(prefix);
-- Finds a prefix's newest route in one search, whatever the number of peers.
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_prefix_last_updated ON live_routes_v4(prefix, last_updated);
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_length ON live_routes_v4(mask_length);
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_origin_asn ON live_routes_v4(origin_asn);
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_last_updated ON live_routes_v4(last_updated);
@@ -132,7 +137,8 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_v4_last_updated ON live_routes_v4(las
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_prefix ON live_routes_v4(mask_length, prefix);
-- Indexes for live_routes_v6 table
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_prefix ON live_routes_v6(prefix);
-- Finds a prefix's newest route in one search, whatever the number of peers.
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_prefix_last_updated ON live_routes_v6(prefix, last_updated);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_mask_length ON live_routes_v6(mask_length);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_origin_asn ON live_routes_v6(origin_asn);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated);
+18
View File
@@ -6,6 +6,22 @@ import (
"time"
)
// Message types and the session state the handlers act on, as the feed writes
// them in a message's type and state fields.
const (
// MessageTypeUpdate is a BGP UPDATE: announcements and withdrawals.
MessageTypeUpdate = "UPDATE"
// MessageTypeState is a change in the state of the BGP session between
// the collector and the peer.
MessageTypeState = "STATE"
// StateDown is the state of a STATE message whose session has ended: the
// collector no longer has any of the peer's routes, and sends no
// withdrawals for them.
StateDown = "down"
)
// ASPath represents a BGP AS path as a slice of AS numbers.
// It handles JSON unmarshaling of both simple arrays and nested AS sets,
// flattening any nested structures into a single sequence of AS numbers.
@@ -85,6 +101,8 @@ type RISMessage struct {
Announcements []RISAnnouncement `json:"announcements,omitempty"`
Withdrawals []string `json:"withdrawals,omitempty"`
Raw string `json:"-"`
// State is set on STATE messages: the session's new state, such as StateDown.
State string `json:"state,omitempty"`
}
// RISAnnouncement represents a BGP route announcement within a RIS message.
@@ -364,6 +364,12 @@ func (m *mockStore) DeleteLiveRouteBatch(deletions []database.LiveRouteDeletion)
return nil
}
// DeleteLiveRoutesOfPeer mock implementation
func (m *mockStore) DeleteLiveRoutesOfPeer(peerIP 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()
+3 -3
View File
@@ -15,9 +15,9 @@ import (
const (
// peerHandlerQueueSize is the queue capacity for peer tracking operations,
// about 4 seconds of feed at peak. The streamer drops rather than blocks
// when a queue is full, so this bounds memory. Batches still flush on a
// timer (peerBatchTimeout).
// about 4 seconds of feed at peak. The streamer drops messages rather than
// blocks when a queue is full (a STATE message waits for room), so this
// bounds memory. Batches still flush on a timer (peerBatchTimeout).
peerHandlerQueueSize = 20000
// peerBatchSize is the number of peer updates to batch together
+65 -18
View File
@@ -3,6 +3,7 @@ package routewatch
import (
"net"
"net/netip"
"slices"
"strings"
"sync"
"time"
@@ -16,9 +17,10 @@ import (
const (
// prefixHandlerQueueSize is the queue capacity for prefix tracking
// operations, about 4 seconds of feed at peak. The streamer drops rather
// than blocks when a queue is full, so this bounds memory. Batches still
// flush on a timer (prefixBatchTimeout).
// operations, about 4 seconds of feed at peak. The streamer drops updates
// rather than blocks when a queue is full (a STATE message waits for
// room), so this bounds memory. Batches still flush on a timer
// (prefixBatchTimeout).
prefixHandlerQueueSize = 20000
// prefixBatchSize is the number of prefix updates to batch together
@@ -40,17 +42,19 @@ const (
)
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the database.
// Routes are added on announcement and deleted on withdrawal.
// Routes are added on announcement and deleted on withdrawal, and all routes of
// a peer are deleted when its BGP session goes down.
type PrefixHandler struct {
db database.Store
logger *logger.Logger
metrics *metrics.Tracker
// Batching. mu guards batch only: the flush goroutine takes the batch
// under mu and writes it after releasing mu, so HandleMessage waits for
// a database write only while batch is at prefixBatchMaxSize.
// Batching. mu guards batch and peersDown only: the flush goroutine takes
// both under mu and writes them after releasing mu, so HandleMessage
// waits for a database write only while batch is at prefixBatchMaxSize.
mu sync.Mutex
batch []prefixUpdate
peersDown []string // peers whose BGP session went down since the batch was taken
batchTaken sync.Cond // signalled when the flush goroutine takes batch; its L is &mu
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
lastFlush time.Time // used only by the flush goroutine
@@ -93,8 +97,9 @@ func (h *PrefixHandler) SetMetricsTracker(metrics *metrics.Tracker) {
// WantsMessage returns true if this handler wants to process messages of the given type
func (h *PrefixHandler) WantsMessage(messageType string) bool {
// We only care about UPDATE messages for the routing table
return messageType == "UPDATE"
// UPDATE messages change routes; a STATE message can say a peer's
// session went down, which removes all of its routes.
return messageType == ristypes.MessageTypeUpdate || messageType == ristypes.MessageTypeState
}
// QueueCapacity returns the desired queue capacity for this handler
@@ -105,6 +110,14 @@ func (h *PrefixHandler) QueueCapacity() int {
// HandleMessage processes a message to track prefix information
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
if msg.Type == ristypes.MessageTypeState {
if msg.State == ristypes.StateDown {
h.peerSessionDown(msg.Peer)
}
return
}
// Use the pre-parsed timestamp
timestamp := msg.ParsedTimestamp
@@ -170,6 +183,19 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
}
}
// peerSessionDown notes that the peer's BGP session went down, so that the next
// flush deletes all of the peer's live routes before it writes the batch. The
// peer's updates still in the batch came before the down, which replaces them,
// so they are dropped; updates the peer sends later stay in the batch and are
// written after the delete.
func (h *PrefixHandler) peerSessionDown(peer string) {
h.mu.Lock()
defer h.mu.Unlock()
h.batch = slices.DeleteFunc(h.batch, func(update prefixUpdate) bool { return update.peer == peer })
h.peersDown = append(h.peersDown, peer)
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PrefixHandler) flushLoop() {
@@ -194,33 +220,53 @@ func (h *PrefixHandler) flushLoop() {
}
}
// takeBatch returns the pending updates and leaves an empty batch in their
// place for HandleMessage to fill.
func (h *PrefixHandler) takeBatch() []prefixUpdate {
// takeBatch returns the pending updates and the peers whose session went down
// since the last flush, and leaves an empty batch and no peers in their place
// for HandleMessage to fill.
func (h *PrefixHandler) takeBatch() ([]prefixUpdate, []string) {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.batch) == 0 {
return nil
if len(h.batch) == 0 && len(h.peersDown) == 0 {
return nil, nil
}
batch := h.batch
batch, peersDown := h.batch, h.peersDown
h.batch = make([]prefixUpdate, 0, prefixBatchSize)
h.peersDown = nil
h.batchTaken.Broadcast()
return batch
return batch, peersDown
}
// 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 {
batch, peersDown := h.takeBatch()
if len(batch) == 0 && len(peersDown) == 0 {
return
}
startTime := time.Now()
batchSize := len(batch)
// The routes of a peer whose session went down are deleted first: the
// batch holds only the updates the peer sent after the down. Most downs
// come from peers that have no routes, so only a delete that removed
// routes is logged.
for _, peer := range peersDown {
deleted, err := h.db.DeleteLiveRoutesOfPeer(peer)
if err != nil {
h.logger.Error("Failed to delete the live routes of a peer whose session went down",
"peer", peer, "error", err)
continue
}
if deleted > 0 {
h.logger.Info("Deleted the live routes of a peer whose session went down",
"peer", peer, "routes", deleted)
}
}
// For each prefix and peer, keep only the last update the feed delivered.
// Updates from different peers for the same prefix are all kept. Arrival
// order decides, not the timestamp: timestamps are whole seconds, so
@@ -296,6 +342,7 @@ func (h *PrefixHandler) flushBatch() {
elapsed := time.Since(startTime)
h.logger.Debug("Flushed prefix batch",
"batch_size", batchSize,
"peers_down", len(peersDown),
"unique_prefixes", len(prefixesToUpdate),
"success", successCount,
"duration_ms", elapsed.Milliseconds(),
+236
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"slices"
"sync"
"testing"
"time"
@@ -288,6 +289,241 @@ func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
}
}
// TestPrefixHandlerDeletesAPeersRoutesWhenItsSessionGoesDown runs
// announcements from two peers through the prefix handler into a real
// database, then a batch in which one of the peers announces a prefix and its
// session goes down. The down deletes every live route of that peer, IPv4 and
// IPv6, the one announced just before it included, and keeps the other peer's
// routes for the same prefixes. An announcement the peer sends after the down,
// in the same batch, is kept. The in-memory route counts and prefix
// distribution must match the route tables.
func TestPrefixHandlerDeletesAPeersRoutesWhenItsSessionGoesDown(t *testing.T) {
const (
otherPeerIP = "2001:db8:ffff::2"
// Both peers announce the shared prefixes, only testPeerIP the others.
sharedV4 = "198.51.100.0/24"
sharedV6 = "2001:db8:1::/48"
onlyV4 = "203.0.113.0/24"
onlyV6 = "2001:db8:2::/48"
beforeDown = "192.0.2.0/24"
afterDown = "2001:db8:3::/48"
// The mask lengths of the IPv4 and of the IPv6 prefixes.
maskLengthV4 = 24
maskLengthV6 = 48
)
tests := []struct {
name string
// Handled and flushed as one batch after both peers' announcements.
batch []*ristypes.RISMessage
// Whether testPeerIP is left with a live route for afterDown.
wantAfterDown bool
}{
{
name: "the down ends the batch",
batch: []*ristypes.RISMessage{peerAnnouncement(testPeerIP, beforeDown), sessionDown(testPeerIP)},
},
{
name: "the peer announces a prefix after the down",
batch: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, beforeDown),
sessionDown(testPeerIP),
peerAnnouncement(testPeerIP, afterDown),
},
wantAfterDown: true,
},
}
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()}
h.HandleMessage(peerAnnouncement(testPeerIP, sharedV4, sharedV6, onlyV4, onlyV6))
h.HandleMessage(peerAnnouncement(otherPeerIP, sharedV4, sharedV6))
h.flushBatch()
for _, msg := range tt.batch {
h.HandleMessage(msg)
}
h.flushBatch()
// The peers left with a live route for each prefix.
wantPeers := map[string][]string{
sharedV4: {otherPeerIP},
sharedV6: {otherPeerIP},
onlyV4: nil,
onlyV6: nil,
beforeDown: nil,
afterDown: nil,
}
wantRoutesV6 := 1
if tt.wantAfterDown {
wantPeers[afterDown] = []string{testPeerIP}
wantRoutesV6++
}
assertPeers(t, db, wantPeers)
assertCounts(t, db, ipv4Version, 1,
[]database.PrefixDistribution{{MaskLength: maskLengthV4, Count: 1}})
assertCounts(t, db, ipv6Version, wantRoutesV6,
[]database.PrefixDistribution{{MaskLength: maskLengthV6, Count: wantRoutesV6}})
})
}
}
// TestPrefixHandlerKeepsWhatAPeerAnnouncesAfterTheFlushOfItsDown runs, through
// the prefix handler into a real database, one flush after another: a peer's
// announcement, its session going down, a new announcement from the peer, and
// another peer's announcement. The down deletes the peer's routes on the flush
// that handles it and on no later flush, so the route the peer announced after
// it is kept.
func TestPrefixHandlerKeepsWhatAPeerAnnouncesAfterTheFlushOfItsDown(t *testing.T) {
const (
otherPeerIP = "2001:db8:ffff::2"
beforeDown = "192.0.2.0/24"
afterDown = "198.51.100.0/24"
otherPeers = "203.0.113.0/24"
maskLength = 24
)
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 _, msg := range []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, beforeDown),
sessionDown(testPeerIP),
peerAnnouncement(testPeerIP, afterDown),
peerAnnouncement(otherPeerIP, otherPeers),
} {
h.HandleMessage(msg)
h.flushBatch()
}
assertPeers(t, db, map[string][]string{
beforeDown: nil,
afterDown: {testPeerIP},
otherPeers: {otherPeerIP},
})
assertCounts(t, db, ipv4Version, 2, []database.PrefixDistribution{{MaskLength: maskLength, Count: 2}})
}
// firstWriteHeldStore is a real database whose first live route batch write
// waits until the test closes release. writeStarted is closed when it starts.
type firstWriteHeldStore struct {
*database.Database
writeStarted chan struct{}
release chan struct{}
once sync.Once
}
func (s *firstWriteHeldStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
s.once.Do(func() {
close(s.writeStarted)
<-s.release
})
return s.Database.UpsertLiveRouteBatch(routes)
}
// TestPrefixHandlerHandlesADownWhileAFlushWrites holds the write of a flush
// that stores a peer's route and meanwhile hands the prefix handler the peer's
// session going down, then a new announcement from the peer. The next flush
// must delete the route the held write stored and keep the one announced after
// the down.
func TestPrefixHandlerHandlesADownWhileAFlushWrites(t *testing.T) {
const (
beforeDown = "192.0.2.0/24"
afterDown = "198.51.100.0/24"
maskLength = 24
)
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() }()
store := &firstWriteHeldStore{Database: db, writeStarted: make(chan struct{}), release: make(chan struct{})}
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{db: store, logger: logger.New()}
h.HandleMessage(peerAnnouncement(testPeerIP, beforeDown))
flushed := make(chan struct{})
go func() {
h.flushBatch()
close(flushed)
}()
select {
case <-store.writeStarted:
case <-time.After(handleMessageTimeout):
t.Fatal("the flush did not start its write")
}
if !returnsInTime(func() {
h.HandleMessage(sessionDown(testPeerIP))
h.HandleMessage(peerAnnouncement(testPeerIP, afterDown))
}) {
t.Fatal("HandleMessage did not return while a database write was held")
}
close(store.release)
select {
case <-flushed:
case <-time.After(handleMessageTimeout):
t.Fatal("the flush did not finish after its write was let go")
}
h.flushBatch()
assertPeers(t, db, map[string][]string{beforeDown: nil, afterDown: {testPeerIP}})
assertCounts(t, db, ipv4Version, 1, []database.PrefixDistribution{{MaskLength: maskLength, Count: 1}})
}
// peerAnnouncement returns an UPDATE message in which peer announces prefixes
// with origin AS testASNB.
func peerAnnouncement(peer string, prefixes ...string) *ristypes.RISMessage {
return &ristypes.RISMessage{
Type: ristypes.MessageTypeUpdate,
Peer: peer,
Path: ristypes.ASPath{testASNA, testASNB},
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
Announcements: []ristypes.RISAnnouncement{{NextHop: peer, Prefixes: prefixes}},
}
}
// sessionDown returns the STATE message that says peer's BGP session went down.
func sessionDown(peer string) *ristypes.RISMessage {
return &ristypes.RISMessage{Type: ristypes.MessageTypeState, Peer: peer, State: ristypes.StateDown}
}
// assertPeers checks, for each prefix in wantPeers, that the peers with a live
// route for it are the ones listed, in peer IP order; nil means none.
func assertPeers(t *testing.T, db *database.Database, wantPeers map[string][]string) {
t.Helper()
for prefix, want := range wantPeers {
routes, _, err := db.GetPrefixDetailsContext(context.Background(), prefix)
if err != nil && !errors.Is(err, database.ErrNoRoute) {
t.Fatalf("GetPrefixDetailsContext(%s): %v", prefix, err)
}
var got []string
for _, route := range routes {
got = append(got, route.PeerIP)
}
if !slices.Equal(got, want) {
t.Errorf("peers with a live route for %s = %v, want %v", prefix, got, want)
}
}
}
// assertCounts checks that the live route count and prefix distribution of one
// IP version are wantRoutes and wantDistribution, both as held in memory and as
// read from the route table.
+40 -22
View File
@@ -78,7 +78,7 @@ type MessageHandler interface {
// QueueCapacity returns the desired queue capacity for this handler.
// Handlers that process quickly can have larger queues to buffer bursts.
// When the queue fills up, messages will be dropped according to the
// backpressure algorithm.
// backpressure algorithm, except STATE messages, which wait for room.
QueueCapacity() int
}
@@ -629,7 +629,7 @@ func (s *Streamer) stream(ctx context.Context) error {
// Process based on message type
switch msg.Type {
case "UPDATE":
case ristypes.MessageTypeUpdate:
// Process BGP UPDATE messages
// Will be dispatched to handlers
case "RIS_PEER_STATE":
@@ -668,9 +668,9 @@ func (s *Streamer) stream(ctx context.Context) error {
)
continue
case "STATE":
// Peer state changes - silently ignore
continue
case ristypes.MessageTypeState:
// The BGP session with the peer changed state, for example went
// down. Dispatched to the handlers that want it.
default:
s.logger.Warn("Unknown message type, skipping",
"type", msg.Type,
@@ -693,30 +693,20 @@ func (s *Streamer) stream(ctx context.Context) error {
continue
}
// Check if we should drop due to backpressure
if s.shouldDropForBackpressure(info) {
if !s.queueMessage(connCtx, info, &msg) {
atomic.AddUint64(&info.metrics.droppedCount, 1)
atomic.AddUint64(&s.totalDropped, 1)
continue
}
// Try to queue the message
select {
case info.queue <- &msg:
// Message queued successfully
// Update high water mark if needed
queueLen := len(info.queue)
info.metrics.mu.Lock()
if queueLen > info.metrics.queueHighWaterMark {
info.metrics.queueHighWaterMark = queueLen
}
info.metrics.mu.Unlock()
default:
// Queue is full, drop the message
atomic.AddUint64(&info.metrics.droppedCount, 1)
atomic.AddUint64(&s.totalDropped, 1)
// Update high water mark if needed
queueLen := len(info.queue)
info.metrics.mu.Lock()
if queueLen > info.metrics.queueHighWaterMark {
info.metrics.queueHighWaterMark = queueLen
}
info.metrics.mu.Unlock()
}
s.mu.RUnlock()
}
@@ -736,6 +726,34 @@ func (s *Streamer) stream(ctx context.Context) error {
return errStreamEnded
}
// queueMessage puts msg on the handler's queue and reports whether it did. A
// message is dropped while the queue is filling up (shouldDropForBackpressure)
// or full, except a STATE message: one whose state is down is the only notice
// that all of a peer's routes are gone, so a STATE message waits for room
// instead. The wait ends if the idle timer drops the connection by cancelling
// connCtx, so the read loop never waits longer than its idle timeout.
func (s *Streamer) queueMessage(connCtx context.Context, info *handlerInfo, msg *ristypes.RISMessage) bool {
if msg.Type == ristypes.MessageTypeState {
select {
case info.queue <- msg:
return true
case <-connCtx.Done():
return false
}
}
if s.shouldDropForBackpressure(info) {
return false
}
select {
case info.queue <- msg:
return true
default:
return false
}
}
// shouldDropForBackpressure determines if a message should be dropped based on queue utilization
func (s *Streamer) shouldDropForBackpressure(info *handlerInfo) bool {
// Calculate queue utilization
+137
View File
@@ -108,6 +108,143 @@ func (h updateHandler) HandleMessage(*ristypes.RISMessage) {
}
}
// stateDownLine is the STATE message RIS Live sends when the collector's BGP
// session with the peer stateDownPeer goes down.
const (
stateDownPeer = "192.0.2.1"
stateDownLine = `{"type":"ris_message","data":{"type":"STATE","peer":"` + stateDownPeer + `",` +
`"peer_asn":"64496","timestamp":1700000000,"state":"down"}}` + "\n"
)
// stateHandler wants STATE messages only. Tests read what it is handed from
// its queue, so HandleMessage does nothing.
type stateHandler struct{}
func (stateHandler) WantsMessage(messageType string) bool {
return messageType == ristypes.MessageTypeState
}
func (stateHandler) QueueCapacity() int { return 10 }
func (stateHandler) HandleMessage(*ristypes.RISMessage) {}
// TestStreamHandsStateMessagesToHandlersThatWantThem has the server send one
// STATE message whose state is down. The streamer must queue it, with its
// peer and state, for a handler that wants STATE messages.
func TestStreamHandsStateMessagesToHandlersThatWantThem(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, stateDownLine)
}))
defer srv.Close()
s := New(logger.New(), metrics.New())
s.url = srv.URL
s.RegisterHandler(stateHandler{})
if err := s.stream(context.Background()); !errors.Is(err, errStreamEnded) {
t.Fatalf("stream returned %v, want %v", err, errStreamEnded)
}
select {
case msg := <-s.handlers[0].queue:
if msg.Type != ristypes.MessageTypeState || msg.Peer != stateDownPeer || msg.State != ristypes.StateDown {
t.Errorf("queued message has type %q, peer %q, state %q; want %q, %q, %q",
msg.Type, msg.Peer, msg.State, ristypes.MessageTypeState, stateDownPeer, ristypes.StateDown)
}
default:
t.Fatal("the STATE message was not queued for the handler that wants it")
}
}
// streamWithFullStateQueue runs stream, in a goroutine, against a streamer
// reading from url whose stateHandler's queue is full of STATE messages with
// no peer. It returns the queue and a channel that gets what stream returns.
func streamWithFullStateQueue(url string, idleTimeout time.Duration) (
queue chan *ristypes.RISMessage, streamErr chan error) {
s := New(logger.New(), metrics.New())
s.url = url
s.idleTimeout = idleTimeout
s.RegisterHandler(stateHandler{})
queue = s.handlers[0].queue
for len(queue) < cap(queue) {
queue <- &ristypes.RISMessage{Type: ristypes.MessageTypeState}
}
streamErr = make(chan error, 1)
go func() { streamErr <- s.stream(context.Background()) }()
return queue, streamErr
}
// TestStreamWaitsForRoomForAStateMessage has the server send one STATE message
// whose state is down while the queue of the handler that wants it is full. A
// full queue is where the random drop always drops, and where a message that
// cannot wait is dropped. The streamer must not drop it: it waits until the
// handler takes a message off its queue, then queues it.
func TestStreamWaitsForRoomForAStateMessage(t *testing.T) {
const (
stillWaitingAfter = 100 * time.Millisecond
maxWait = 5 * time.Second
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, stateDownLine)
}))
defer srv.Close()
queue, streamErr := streamWithFullStateQueue(srv.URL, streamIdleTimeout)
select {
case err := <-streamErr:
t.Fatalf("stream returned %v while the queue was full: it dropped the STATE message", err)
case <-time.After(stillWaitingAfter):
}
<-queue // the handler takes a message, which makes room
select {
case err := <-streamErr:
if !errors.Is(err, errStreamEnded) {
t.Fatalf("stream returned %v, want %v", err, errStreamEnded)
}
case <-time.After(maxWait):
t.Fatalf("stream did not queue the STATE message within %s of there being room", maxWait)
}
var last *ristypes.RISMessage
for len(queue) > 0 {
last = <-queue
}
if last.Peer != stateDownPeer || last.State != ristypes.StateDown {
t.Errorf("last queued message has peer %q, state %q; want %q, %q",
last.Peer, last.State, stateDownPeer, ristypes.StateDown)
}
}
// TestStreamStopsWaitingForRoomAtTheIdleTimeout has the server send one STATE
// message and then hold the response open, while the queue of the handler
// that wants it stays full. The wait for room must not stall the read loop
// past its idle timeout: the stream ends as when no line arrives.
func TestStreamStopsWaitingForRoomAtTheIdleTimeout(t *testing.T) {
const (
idleTimeout = 50 * time.Millisecond
maxWait = 5 * time.Second
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, stateDownLine)
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer srv.Close()
_, streamErr := streamWithFullStateQueue(srv.URL, idleTimeout)
select {
case err := <-streamErr:
if err == nil || errors.Is(err, errStreamEnded) {
t.Errorf("stream returned %v, want the error of a stream dropped as idle", err)
}
case <-time.After(maxWait):
t.Fatalf("stream still waiting for room %s after the idle timeout of %s", maxWait, idleTimeout)
}
}
// TestStopBeforeMessageReachesQueues stops the streamer after the read loop
// 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