Identify a live route by prefix and peer (closes #55)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A peer has at most one route per prefix, but both live route tables were unique on prefix, origin AS and peer. A peer that changed a prefix's origin AS left its old route behind, and a withdrawal sent in the same message as announcements was given their origin AS and missed the withdrawn route. Both tables are now unique on prefix and peer. An announcement replaces the peer's row for the prefix, origin AS included; a withdrawal deletes it without looking at an origin AS. The route counts and prefix distribution kept in memory stay exact, for IPv4 and IPv6. Model: opus-5-5
This commit was merged in pull request #82.
This commit is contained in:
@@ -205,7 +205,7 @@ func (m *mockStore) UpsertLiveRoute(route *database.LiveRoute) error {
|
||||
}
|
||||
|
||||
// DeleteLiveRoute mock implementation
|
||||
func (m *mockStore) DeleteLiveRoute(prefix string, originASN int, peerIP string) error {
|
||||
func (m *mockStore) DeleteLiveRoute(prefix, peerIP string) error {
|
||||
// Simple mock - just return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -108,14 +108,14 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||
// Withdrawals are queued before announcements, because the batch keeps
|
||||
// each peer's last update for a prefix: a message that lists a prefix as
|
||||
// both withdrawn and announced keeps the route (RFC 4271 section 4.3).
|
||||
// A withdrawal names only the prefix and peer: the message's AS path, and
|
||||
// so its origin AS, belongs to the announced prefixes.
|
||||
for _, prefix := range msg.Withdrawals {
|
||||
h.batch = append(h.batch, prefixUpdate{
|
||||
prefix: canonicalPrefix(prefix),
|
||||
originASN: originASN, // Use the originASN from path if available
|
||||
peer: msg.Peer,
|
||||
messageType: "withdrawal",
|
||||
timestamp: timestamp,
|
||||
path: msg.Path,
|
||||
})
|
||||
// Record withdrawal in metrics
|
||||
if h.metrics != nil {
|
||||
@@ -221,7 +221,6 @@ func (h *PrefixHandler) flushBatchLocked() {
|
||||
// Create deletion record for batch delete
|
||||
routesToDelete = append(routesToDelete, database.LiveRouteDeletion{
|
||||
Prefix: update.prefix,
|
||||
OriginASN: update.originASN,
|
||||
PeerIP: update.peer,
|
||||
IPVersion: ipVersion,
|
||||
})
|
||||
@@ -425,51 +424,24 @@ func (h *PrefixHandler) processAnnouncementDirect(update prefixUpdate) {
|
||||
// processWithdrawalDirect handles removing a route directly without prefix table
|
||||
// nolint:unused // kept for potential future use
|
||||
func (h *PrefixHandler) processWithdrawalDirect(update prefixUpdate) {
|
||||
// For withdrawals, we need to delete the route from live_routes
|
||||
if update.originASN > 0 {
|
||||
if err := h.db.DeleteLiveRoute(update.prefix, update.originASN, update.peer); err != nil {
|
||||
h.logger.Error("Failed to delete live route",
|
||||
"prefix", update.prefix,
|
||||
"origin_asn", update.originASN,
|
||||
"peer", update.peer,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// If no origin ASN, just delete all routes for this prefix from this peer
|
||||
if err := h.db.DeleteLiveRoute(update.prefix, 0, update.peer); err != nil {
|
||||
h.logger.Error("Failed to delete live route (no origin ASN)",
|
||||
"prefix", update.prefix,
|
||||
"peer", update.peer,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if err := h.db.DeleteLiveRoute(update.prefix, update.peer); err != nil {
|
||||
h.logger.Error("Failed to delete live route",
|
||||
"prefix", update.prefix,
|
||||
"peer", update.peer,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// processWithdrawal handles removing a route from the live routing table
|
||||
// nolint:unused // kept for potential future use
|
||||
func (h *PrefixHandler) processWithdrawal(_ *database.Prefix, update prefixUpdate) {
|
||||
// For withdrawals, we need to delete the route from live_routes
|
||||
// Since we have the origin ASN from the update, we can delete the specific route
|
||||
if update.originASN > 0 {
|
||||
if err := h.db.DeleteLiveRoute(update.prefix, update.originASN, update.peer); err != nil {
|
||||
h.logger.Error("Failed to delete live route",
|
||||
"prefix", update.prefix,
|
||||
"origin_asn", update.originASN,
|
||||
"peer", update.peer,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// If no origin ASN, just delete all routes for this prefix from this peer
|
||||
if err := h.db.DeleteLiveRoute(update.prefix, 0, update.peer); err != nil {
|
||||
h.logger.Error("Failed to delete live route (no origin ASN)",
|
||||
"prefix", update.prefix,
|
||||
"peer", update.peer,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
if err := h.db.DeleteLiveRoute(update.prefix, update.peer); err != nil {
|
||||
h.logger.Error("Failed to delete live route",
|
||||
"prefix", update.prefix,
|
||||
"peer", update.peer,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package routewatch
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -182,32 +183,155 @@ func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) {
|
||||
t.Errorf("peers with a live route for %s = %v, want %v", prefix, gotPeers, tt.wantPeers)
|
||||
}
|
||||
|
||||
stats, err := db.GetStatsContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatsContext: %v", err)
|
||||
}
|
||||
tableRoutesV4, _, err := db.GetLiveRouteCountsContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLiveRouteCountsContext: %v", err)
|
||||
}
|
||||
if stats.IPv4Routes != len(tt.wantPeers) || tableRoutesV4 != len(tt.wantPeers) {
|
||||
t.Errorf("IPv4 live routes: %d in memory, %d in the table, want %d",
|
||||
stats.IPv4Routes, tableRoutesV4, len(tt.wantPeers))
|
||||
}
|
||||
|
||||
var wantDistribution []database.PrefixDistribution
|
||||
if len(tt.wantPeers) > 0 {
|
||||
wantDistribution = []database.PrefixDistribution{{MaskLength: prefixMaskLength, Count: 1}}
|
||||
}
|
||||
queryDistributionV4, _, err := db.GetPrefixDistributionContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPrefixDistributionContext: %v", err)
|
||||
}
|
||||
if !slices.Equal(stats.IPv4PrefixDistribution, wantDistribution) ||
|
||||
!slices.Equal(queryDistributionV4, wantDistribution) {
|
||||
t.Errorf("IPv4 prefix distribution: %v in memory, %v from the table, want %v",
|
||||
stats.IPv4PrefixDistribution, queryDistributionV4, wantDistribution)
|
||||
}
|
||||
assertCounts(t, db, ipv4Version, len(tt.wantPeers), wantDistribution)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer runs updates from one peer
|
||||
// through the prefix handler into a real database, flushing each message as its
|
||||
// own batch, once with IPv4 prefixes and once with IPv6 prefixes. A peer has at
|
||||
// most one route for a prefix: a later announcement replaces it whatever its
|
||||
// origin AS, and a withdrawal removes it whatever AS the message's AS path ends
|
||||
// in. The in-memory route count and prefix distribution must match the route
|
||||
// table.
|
||||
func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
|
||||
families := []struct {
|
||||
ipVersion int
|
||||
prefix, otherPrefix string
|
||||
// The mask length of both prefixes.
|
||||
maskLength int
|
||||
}{
|
||||
{ipVersion: ipv4Version, prefix: "198.51.100.0/24", otherPrefix: "203.0.113.0/24", maskLength: 24},
|
||||
{ipVersion: ipv6Version, prefix: "2001:db8:1::/48", otherPrefix: "2001:db8:2::/48", maskLength: 48},
|
||||
}
|
||||
|
||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
announce := func(path ristypes.ASPath, announced string) *ristypes.RISMessage {
|
||||
return &ristypes.RISMessage{
|
||||
Peer: testPeerIP,
|
||||
Path: path,
|
||||
ParsedTimestamp: ts,
|
||||
Announcements: []ristypes.RISAnnouncement{{NextHop: testPeerIP, Prefixes: []string{announced}}},
|
||||
}
|
||||
}
|
||||
|
||||
for _, family := range families {
|
||||
prefix, otherPrefix := family.prefix, family.otherPrefix
|
||||
|
||||
// One message that announces otherPrefix with origin AS testASNB and
|
||||
// withdraws prefix.
|
||||
announceOtherAndWithdraw := announce(ristypes.ASPath{testASNA, testASNB}, otherPrefix)
|
||||
announceOtherAndWithdraw.Withdrawals = []string{prefix}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []*ristypes.RISMessage
|
||||
// The origin AS of the peer's live route for each prefix. A prefix
|
||||
// left out must have no live route.
|
||||
wantOrigins map[string]int
|
||||
}{
|
||||
{
|
||||
name: "the peer announces the prefix with a new origin AS",
|
||||
messages: []*ristypes.RISMessage{
|
||||
announce(ristypes.ASPath{testASNA}, prefix),
|
||||
announce(ristypes.ASPath{testASNA, testASNB}, prefix),
|
||||
},
|
||||
wantOrigins: map[string]int{prefix: testASNB},
|
||||
},
|
||||
{
|
||||
name: "one message announces a prefix with another origin AS and withdraws the prefix",
|
||||
messages: []*ristypes.RISMessage{
|
||||
announce(ristypes.ASPath{testASNA}, prefix),
|
||||
announceOtherAndWithdraw,
|
||||
},
|
||||
wantOrigins: map[string]int{otherPrefix: testASNB},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("IPv%d %s", family.ipVersion, tt.name), func(t *testing.T) {
|
||||
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
|
||||
h := &PrefixHandler{db: db, logger: logger.New()}
|
||||
for _, msg := range tt.messages {
|
||||
h.HandleMessage(msg)
|
||||
h.mu.Lock()
|
||||
h.flushBatchLocked()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
for _, p := range []string{prefix, otherPrefix} {
|
||||
routes, _, err := db.GetPrefixDetailsContext(context.Background(), p)
|
||||
if err != nil && !errors.Is(err, database.ErrNoRoute) {
|
||||
t.Fatalf("GetPrefixDetailsContext(%s): %v", p, err)
|
||||
}
|
||||
var gotOrigins []int
|
||||
for _, route := range routes {
|
||||
gotOrigins = append(gotOrigins, route.OriginASN)
|
||||
}
|
||||
var wantOrigins []int
|
||||
if origin, ok := tt.wantOrigins[p]; ok {
|
||||
wantOrigins = []int{origin}
|
||||
}
|
||||
if !slices.Equal(gotOrigins, wantOrigins) {
|
||||
t.Errorf("origin ASes of the live routes for %s = %v, want %v", p, gotOrigins, wantOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
wantDistribution := []database.PrefixDistribution{
|
||||
{MaskLength: family.maskLength, Count: len(tt.wantOrigins)},
|
||||
}
|
||||
assertCounts(t, db, family.ipVersion, len(tt.wantOrigins), wantDistribution)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func assertCounts(t *testing.T, db *database.Database, ipVersion, wantRoutes int,
|
||||
wantDistribution []database.PrefixDistribution) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
stats, err := db.GetStatsContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatsContext: %v", err)
|
||||
}
|
||||
tableRoutesV4, tableRoutesV6, err := db.GetLiveRouteCountsContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLiveRouteCountsContext: %v", err)
|
||||
}
|
||||
queryDistributionV4, queryDistributionV6, err := db.GetPrefixDistributionContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPrefixDistributionContext: %v", err)
|
||||
}
|
||||
|
||||
memoryRoutes, tableRoutes := stats.IPv4Routes, tableRoutesV4
|
||||
memoryDistribution, queryDistribution := stats.IPv4PrefixDistribution, queryDistributionV4
|
||||
if ipVersion == ipv6Version {
|
||||
memoryRoutes, tableRoutes = stats.IPv6Routes, tableRoutesV6
|
||||
memoryDistribution, queryDistribution = stats.IPv6PrefixDistribution, queryDistributionV6
|
||||
}
|
||||
|
||||
if memoryRoutes != wantRoutes || tableRoutes != wantRoutes {
|
||||
t.Errorf("IPv%d live routes: %d in memory, %d in the table, want %d",
|
||||
ipVersion, memoryRoutes, tableRoutes, wantRoutes)
|
||||
}
|
||||
if !slices.Equal(memoryDistribution, wantDistribution) ||
|
||||
!slices.Equal(queryDistribution, wantDistribution) {
|
||||
t.Errorf("IPv%d prefix distribution: %v in memory, %v from the table, want %v",
|
||||
ipVersion, memoryDistribution, queryDistribution, wantDistribution)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user