Compare commits
2
Commits
456aafde73
...
741bea4b9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
741bea4b9e | ||
|
|
a0ddc0d254 |
@@ -128,7 +128,10 @@ routewatch/
|
|||||||
4. **Handler Details**
|
4. **Handler Details**
|
||||||
- **ASHandler**: Tracks all ASNs seen in AS paths
|
- **ASHandler**: Tracks all ASNs seen in AS paths
|
||||||
- **PeerHandler**: Records BGP peer information
|
- **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
|
- **PeeringHandler**: Extracts AS peering relationships from AS paths
|
||||||
|
|
||||||
5. **HTTP Request Flow**
|
5. **HTTP Request Flow**
|
||||||
|
|||||||
@@ -349,6 +349,89 @@ func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error {
|
|||||||
return db.DeleteLiveRouteBatch(deletions)
|
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
|
// TestStatsRouteTimestamps checks the oldest/newest route timestamps are read
|
||||||
// from the right rows across both tables and parse into time.Time. The old
|
// 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
|
// MIN/MAX union query read its result into *time.Time, which the driver could
|
||||||
|
|||||||
+270
-30
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -61,6 +62,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.
|
||||||
|
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
|
||||||
@@ -530,6 +542,91 @@ func (d *Database) DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error {
|
|||||||
return nil
|
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
|
// UpdatePrefixesBatch updates the last_seen time for multiple prefixes in a single transaction
|
||||||
func (d *Database) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
|
func (d *Database) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
|
||||||
if len(prefixes) == 0 {
|
if len(prefixes) == 0 {
|
||||||
@@ -1667,45 +1764,189 @@ 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, 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(
|
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 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
|
//nolint:gosec // Table name is hardcoded based on IP version
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
WITH unique_prefixes AS (
|
SELECT prefix FROM %s
|
||||||
SELECT prefix, MAX(last_updated) as max_updated
|
WHERE mask_length = ? AND prefix > ?
|
||||||
FROM %s
|
ORDER BY prefix
|
||||||
WHERE mask_length = ?
|
LIMIT 1
|
||||||
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)
|
stmt, err := d.db.PrepareContext(ctx, query)
|
||||||
if err != nil {
|
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() {
|
defer func() { _ = stmt.Close() }()
|
||||||
_ = rows.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var routes []LiveRoute
|
var prefixes []string
|
||||||
for rows.Next() {
|
previous := "" // every prefix sorts after the empty string
|
||||||
var route LiveRoute
|
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
|
var pathJSON string
|
||||||
err := rows.Scan(
|
err := stmt.QueryRowContext(ctx, prefix).Scan(
|
||||||
&route.Prefix,
|
&route.Prefix,
|
||||||
&route.MaskLength,
|
&route.MaskLength,
|
||||||
&route.OriginASN,
|
&route.OriginASN,
|
||||||
@@ -1713,17 +1954,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -46,6 +47,32 @@ 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"
|
||||||
|
// 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
|
// 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
|
||||||
@@ -423,3 +450,281 @@ 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(), 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Store interface {
|
|||||||
UpsertLiveRouteBatch(routes []*LiveRoute) error
|
UpsertLiveRouteBatch(routes []*LiveRoute) error
|
||||||
DeleteLiveRoute(prefix, peerIP string) error
|
DeleteLiveRoute(prefix, peerIP string) error
|
||||||
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
|
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
|
||||||
|
DeleteLiveRoutesOfPeer(peerIP string) (deleted int, err error)
|
||||||
GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
|
GetPrefixDistribution() (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
|
||||||
GetPrefixDistributionContext(ctx context.Context) (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
|
GetPrefixDistributionContext(ctx context.Context) (ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
|
||||||
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
|
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ CREATE TABLE IF NOT EXISTS live_routes_v4 (
|
|||||||
as_path TEXT NOT NULL, -- JSON array
|
as_path TEXT NOT NULL, -- JSON array
|
||||||
next_hop TEXT NOT NULL,
|
next_hop TEXT NOT NULL,
|
||||||
last_updated DATETIME 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
|
-- 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
|
as_path TEXT NOT NULL, -- JSON array
|
||||||
next_hop TEXT NOT NULL,
|
next_hop TEXT NOT NULL,
|
||||||
last_updated DATETIME 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
|
-- 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_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_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);
|
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);
|
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_prefix ON live_routes_v4(mask_length, prefix);
|
||||||
|
|
||||||
-- Indexes for live_routes_v6 table
|
-- 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_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_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);
|
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated);
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ import (
|
|||||||
"time"
|
"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.
|
// ASPath represents a BGP AS path as a slice of AS numbers.
|
||||||
// It handles JSON unmarshaling of both simple arrays and nested AS sets,
|
// It handles JSON unmarshaling of both simple arrays and nested AS sets,
|
||||||
// flattening any nested structures into a single sequence of AS numbers.
|
// flattening any nested structures into a single sequence of AS numbers.
|
||||||
@@ -85,6 +101,8 @@ type RISMessage struct {
|
|||||||
Announcements []RISAnnouncement `json:"announcements,omitempty"`
|
Announcements []RISAnnouncement `json:"announcements,omitempty"`
|
||||||
Withdrawals []string `json:"withdrawals,omitempty"`
|
Withdrawals []string `json:"withdrawals,omitempty"`
|
||||||
Raw string `json:"-"`
|
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.
|
// RISAnnouncement represents a BGP route announcement within a RIS message.
|
||||||
|
|||||||
@@ -364,6 +364,12 @@ func (m *mockStore) DeleteLiveRouteBatch(deletions []database.LiveRouteDeletion)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteLiveRoutesOfPeer mock implementation
|
||||||
|
func (m *mockStore) DeleteLiveRoutesOfPeer(peerIP string) (int, error) {
|
||||||
|
// Simple mock - deletes nothing
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetOrCreateASNBatch mock implementation
|
// GetOrCreateASNBatch mock implementation
|
||||||
func (m *mockStore) GetOrCreateASNBatch(asns map[int]time.Time) error {
|
func (m *mockStore) GetOrCreateASNBatch(asns map[int]time.Time) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// peerHandlerQueueSize is the queue capacity for peer tracking operations,
|
// peerHandlerQueueSize is the queue capacity for peer tracking operations,
|
||||||
// about 4 seconds of feed at peak. The streamer drops rather than blocks
|
// about 4 seconds of feed at peak. The streamer drops messages rather than
|
||||||
// when a queue is full, so this bounds memory. Batches still flush on a
|
// blocks when a queue is full (a STATE message waits for room), so this
|
||||||
// timer (peerBatchTimeout).
|
// bounds memory. Batches still flush on a timer (peerBatchTimeout).
|
||||||
peerHandlerQueueSize = 20000
|
peerHandlerQueueSize = 20000
|
||||||
|
|
||||||
// peerBatchSize is the number of peer updates to batch together
|
// peerBatchSize is the number of peer updates to batch together
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package routewatch
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,9 +17,10 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// prefixHandlerQueueSize is the queue capacity for prefix tracking
|
// prefixHandlerQueueSize is the queue capacity for prefix tracking
|
||||||
// operations, about 4 seconds of feed at peak. The streamer drops rather
|
// operations, about 4 seconds of feed at peak. The streamer drops updates
|
||||||
// than blocks when a queue is full, so this bounds memory. Batches still
|
// rather than blocks when a queue is full (a STATE message waits for
|
||||||
// flush on a timer (prefixBatchTimeout).
|
// room), so this bounds memory. Batches still flush on a timer
|
||||||
|
// (prefixBatchTimeout).
|
||||||
prefixHandlerQueueSize = 20000
|
prefixHandlerQueueSize = 20000
|
||||||
|
|
||||||
// prefixBatchSize is the number of prefix updates to batch together
|
// 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.
|
// 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 {
|
type PrefixHandler struct {
|
||||||
db database.Store
|
db database.Store
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
metrics *metrics.Tracker
|
metrics *metrics.Tracker
|
||||||
|
|
||||||
// Batching. mu guards batch only: the flush goroutine takes the batch
|
// Batching. mu guards batch and peersDown only: the flush goroutine takes
|
||||||
// under mu and writes it after releasing mu, so HandleMessage waits for
|
// both under mu and writes them after releasing mu, so HandleMessage
|
||||||
// a database write only while batch is at prefixBatchMaxSize.
|
// waits for a database write only while batch is at prefixBatchMaxSize.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
batch []prefixUpdate
|
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
|
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
|
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
|
||||||
lastFlush time.Time // used only by the flush goroutine
|
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
|
// WantsMessage returns true if this handler wants to process messages of the given type
|
||||||
func (h *PrefixHandler) WantsMessage(messageType string) bool {
|
func (h *PrefixHandler) WantsMessage(messageType string) bool {
|
||||||
// We only care about UPDATE messages for the routing table
|
// UPDATE messages change routes; a STATE message can say a peer's
|
||||||
return messageType == "UPDATE"
|
// 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
|
// 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
|
// HandleMessage processes a message to track prefix information
|
||||||
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
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
|
// Use the pre-parsed timestamp
|
||||||
timestamp := msg.ParsedTimestamp
|
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
|
// 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.
|
// flushes when the batch is full, periodically, and once more on Stop.
|
||||||
func (h *PrefixHandler) flushLoop() {
|
func (h *PrefixHandler) flushLoop() {
|
||||||
@@ -194,33 +220,53 @@ func (h *PrefixHandler) flushLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeBatch returns the pending updates and leaves an empty batch in their
|
// takeBatch returns the pending updates and the peers whose session went down
|
||||||
// place for HandleMessage to fill.
|
// since the last flush, and leaves an empty batch and no peers in their place
|
||||||
func (h *PrefixHandler) takeBatch() []prefixUpdate {
|
// for HandleMessage to fill.
|
||||||
|
func (h *PrefixHandler) takeBatch() ([]prefixUpdate, []string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
if len(h.batch) == 0 {
|
if len(h.batch) == 0 && len(h.peersDown) == 0 {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
batch := h.batch
|
batch, peersDown := h.batch, h.peersDown
|
||||||
h.batch = make([]prefixUpdate, 0, prefixBatchSize)
|
h.batch = make([]prefixUpdate, 0, prefixBatchSize)
|
||||||
|
h.peersDown = nil
|
||||||
h.batchTaken.Broadcast()
|
h.batchTaken.Broadcast()
|
||||||
|
|
||||||
return batch
|
return batch, peersDown
|
||||||
}
|
}
|
||||||
|
|
||||||
// flushBatch takes the pending batch and writes it to the database, without
|
// flushBatch takes the pending batch and writes it to the database, without
|
||||||
// holding the mutex during the write.
|
// holding the mutex during the write.
|
||||||
func (h *PrefixHandler) flushBatch() {
|
func (h *PrefixHandler) flushBatch() {
|
||||||
batch := h.takeBatch()
|
batch, peersDown := h.takeBatch()
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 && len(peersDown) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
batchSize := len(batch)
|
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.
|
// 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
|
// Updates from different peers for the same prefix are all kept. Arrival
|
||||||
// order decides, not the timestamp: timestamps are whole seconds, so
|
// order decides, not the timestamp: timestamps are whole seconds, so
|
||||||
@@ -296,6 +342,7 @@ 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,
|
||||||
|
"peers_down", len(peersDown),
|
||||||
"unique_prefixes", len(prefixesToUpdate),
|
"unique_prefixes", len(prefixesToUpdate),
|
||||||
"success", successCount,
|
"success", successCount,
|
||||||
"duration_ms", elapsed.Milliseconds(),
|
"duration_ms", elapsed.Milliseconds(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// 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
|
// IP version are wantRoutes and wantDistribution, both as held in memory and as
|
||||||
// read from the route table.
|
// read from the route table.
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ type MessageHandler interface {
|
|||||||
// QueueCapacity returns the desired queue capacity for this handler.
|
// QueueCapacity returns the desired queue capacity for this handler.
|
||||||
// Handlers that process quickly can have larger queues to buffer bursts.
|
// Handlers that process quickly can have larger queues to buffer bursts.
|
||||||
// When the queue fills up, messages will be dropped according to the
|
// 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
|
QueueCapacity() int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,7 +629,7 @@ func (s *Streamer) stream(ctx context.Context) error {
|
|||||||
|
|
||||||
// Process based on message type
|
// Process based on message type
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case "UPDATE":
|
case ristypes.MessageTypeUpdate:
|
||||||
// Process BGP UPDATE messages
|
// Process BGP UPDATE messages
|
||||||
// Will be dispatched to handlers
|
// Will be dispatched to handlers
|
||||||
case "RIS_PEER_STATE":
|
case "RIS_PEER_STATE":
|
||||||
@@ -668,9 +668,9 @@ func (s *Streamer) stream(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
case "STATE":
|
case ristypes.MessageTypeState:
|
||||||
// Peer state changes - silently ignore
|
// The BGP session with the peer changed state, for example went
|
||||||
continue
|
// down. Dispatched to the handlers that want it.
|
||||||
default:
|
default:
|
||||||
s.logger.Warn("Unknown message type, skipping",
|
s.logger.Warn("Unknown message type, skipping",
|
||||||
"type", msg.Type,
|
"type", msg.Type,
|
||||||
@@ -693,30 +693,20 @@ func (s *Streamer) stream(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should drop due to backpressure
|
if !s.queueMessage(connCtx, info, &msg) {
|
||||||
if s.shouldDropForBackpressure(info) {
|
|
||||||
atomic.AddUint64(&info.metrics.droppedCount, 1)
|
atomic.AddUint64(&info.metrics.droppedCount, 1)
|
||||||
atomic.AddUint64(&s.totalDropped, 1)
|
atomic.AddUint64(&s.totalDropped, 1)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to queue the message
|
// Update high water mark if needed
|
||||||
select {
|
queueLen := len(info.queue)
|
||||||
case info.queue <- &msg:
|
info.metrics.mu.Lock()
|
||||||
// Message queued successfully
|
if queueLen > info.metrics.queueHighWaterMark {
|
||||||
// Update high water mark if needed
|
info.metrics.queueHighWaterMark = queueLen
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
info.metrics.mu.Unlock()
|
||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
}
|
}
|
||||||
@@ -736,6 +726,34 @@ func (s *Streamer) stream(ctx context.Context) error {
|
|||||||
return errStreamEnded
|
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
|
// shouldDropForBackpressure determines if a message should be dropped based on queue utilization
|
||||||
func (s *Streamer) shouldDropForBackpressure(info *handlerInfo) bool {
|
func (s *Streamer) shouldDropForBackpressure(info *handlerInfo) bool {
|
||||||
// Calculate queue utilization
|
// Calculate queue utilization
|
||||||
|
|||||||
@@ -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
|
// 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
|
||||||
|
|||||||
Reference in New Issue
Block a user