check / check (push) Waiting to run
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
539 lines
16 KiB
Go
539 lines
16 KiB
Go
package routewatch
|
|
|
|
import (
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
// prefixHandlerQueueSize is the queue capacity for prefix tracking
|
|
// 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
|
|
prefixBatchSize = 25000
|
|
|
|
// prefixBatchMaxSize is the most prefix updates the batch holds while the
|
|
// flush goroutine writes the previous one: ten batches, about 60 MiB
|
|
// (README "Memory"). At this size HandleMessage waits until the flush
|
|
// goroutine takes the batch.
|
|
prefixBatchMaxSize = 250000
|
|
|
|
// prefixBatchTimeout is the maximum time to wait before flushing a batch
|
|
// DO NOT reduce this timeout - larger batches are more efficient
|
|
prefixBatchTimeout = 1 * time.Second
|
|
|
|
// IP version constants
|
|
ipv4Version = 4
|
|
ipv6Version = 6
|
|
)
|
|
|
|
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the database.
|
|
// 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 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
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
type prefixUpdate struct {
|
|
prefix string
|
|
originASN int
|
|
peer string
|
|
messageType string
|
|
timestamp time.Time
|
|
path []int
|
|
}
|
|
|
|
// NewPrefixHandler creates a new batched prefix tracking handler
|
|
func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler {
|
|
h := &PrefixHandler{
|
|
db: db,
|
|
logger: logger,
|
|
batch: make([]prefixUpdate, 0, prefixBatchSize),
|
|
batchFullCh: make(chan struct{}, 1),
|
|
lastFlush: time.Now(),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
h.batchTaken.L = &h.mu
|
|
|
|
// Start the flush timer goroutine
|
|
h.wg.Add(1)
|
|
go h.flushLoop()
|
|
|
|
return h
|
|
}
|
|
|
|
// SetMetricsTracker sets the metrics tracker for recording route updates
|
|
func (h *PrefixHandler) SetMetricsTracker(metrics *metrics.Tracker) {
|
|
h.metrics = metrics
|
|
}
|
|
|
|
// WantsMessage returns true if this handler wants to process messages of the given type
|
|
func (h *PrefixHandler) WantsMessage(messageType string) bool {
|
|
// 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
|
|
func (h *PrefixHandler) QueueCapacity() int {
|
|
// Batching allows us to use a larger queue
|
|
return prefixHandlerQueueSize
|
|
}
|
|
|
|
// 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
|
|
|
|
// Get origin ASN from path (last element)
|
|
var originASN int
|
|
if len(msg.Path) > 0 {
|
|
originASN = msg.Path[len(msg.Path)-1]
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
// While the batch is at its maximum size, wait until the flush goroutine
|
|
// takes it. Meanwhile the handler takes nothing off its queue, which
|
|
// fills, and the streamer drops messages for it.
|
|
for len(h.batch) >= prefixBatchMaxSize {
|
|
h.batchTaken.Wait()
|
|
}
|
|
|
|
// 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),
|
|
peer: msg.Peer,
|
|
messageType: "withdrawal",
|
|
timestamp: timestamp,
|
|
})
|
|
// Record withdrawal in metrics
|
|
if h.metrics != nil {
|
|
h.metrics.RecordWithdrawal()
|
|
}
|
|
}
|
|
|
|
for _, announcement := range msg.Announcements {
|
|
for _, prefix := range announcement.Prefixes {
|
|
h.batch = append(h.batch, prefixUpdate{
|
|
prefix: canonicalPrefix(prefix),
|
|
originASN: originASN,
|
|
peer: msg.Peer,
|
|
messageType: "announcement",
|
|
timestamp: timestamp,
|
|
path: msg.Path,
|
|
})
|
|
// Record announcement in metrics
|
|
if h.metrics != nil {
|
|
h.metrics.RecordAnnouncement()
|
|
}
|
|
}
|
|
}
|
|
|
|
// A full batch wakes the flush goroutine. If it is writing, the wake-up
|
|
// waits in batchFullCh and the batch keeps growing until the write ends
|
|
// or the batch reaches prefixBatchMaxSize.
|
|
if len(h.batch) >= prefixBatchSize {
|
|
select {
|
|
case h.batchFullCh <- struct{}{}:
|
|
default: // already woken
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
defer h.wg.Done()
|
|
ticker := time.NewTicker(prefixBatchTimeout)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if time.Since(h.lastFlush) >= prefixBatchTimeout {
|
|
h.flushBatch()
|
|
}
|
|
case <-h.batchFullCh:
|
|
h.flushBatch()
|
|
case <-h.stopCh:
|
|
// Final flush
|
|
h.flushBatch()
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 && len(h.peersDown) == 0 {
|
|
return nil, nil
|
|
}
|
|
batch, peersDown := h.batch, h.peersDown
|
|
h.batch = make([]prefixUpdate, 0, prefixBatchSize)
|
|
h.peersDown = nil
|
|
h.batchTaken.Broadcast()
|
|
|
|
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, 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
|
|
// updates within one second carry the same one.
|
|
type prefixAndPeer struct{ prefix, peer string }
|
|
lastUpdates := make(map[prefixAndPeer]prefixUpdate)
|
|
for _, update := range batch {
|
|
lastUpdates[prefixAndPeer{update.prefix, update.peer}] = update
|
|
}
|
|
|
|
// Collect routes to upsert and delete
|
|
var routesToUpsert []*database.LiveRoute
|
|
var routesToDelete []database.LiveRouteDeletion
|
|
|
|
// Collect unique prefixes to update
|
|
prefixesToUpdate := make(map[string]time.Time)
|
|
|
|
for _, update := range lastUpdates {
|
|
// Track prefix for both announcements and withdrawals
|
|
if _, exists := prefixesToUpdate[update.prefix]; !exists || update.timestamp.After(prefixesToUpdate[update.prefix]) {
|
|
prefixesToUpdate[update.prefix] = update.timestamp
|
|
}
|
|
|
|
if update.messageType == "announcement" && update.originASN > 0 {
|
|
// Create live route for batch upsert
|
|
route := h.createLiveRoute(update)
|
|
if route != nil {
|
|
routesToUpsert = append(routesToUpsert, route)
|
|
}
|
|
} else if update.messageType == "withdrawal" {
|
|
// Parse CIDR to get IP version
|
|
_, ipVersion, err := parseCIDR(update.prefix)
|
|
if err != nil {
|
|
h.logger.Error("Failed to parse CIDR for withdrawal", "prefix", update.prefix, "error", err)
|
|
|
|
continue
|
|
}
|
|
|
|
// Create deletion record for batch delete
|
|
routesToDelete = append(routesToDelete, database.LiveRouteDeletion{
|
|
Prefix: update.prefix,
|
|
PeerIP: update.peer,
|
|
IPVersion: ipVersion,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Process batch operations
|
|
successCount := 0
|
|
if len(routesToUpsert) > 0 {
|
|
if err := h.db.UpsertLiveRouteBatch(routesToUpsert); err != nil {
|
|
h.logger.Error("Failed to upsert route batch", "error", err, "count", len(routesToUpsert))
|
|
} else {
|
|
successCount += len(routesToUpsert)
|
|
}
|
|
}
|
|
|
|
if len(routesToDelete) > 0 {
|
|
if err := h.db.DeleteLiveRouteBatch(routesToDelete); err != nil {
|
|
h.logger.Error("Failed to delete route batch", "error", err, "count", len(routesToDelete))
|
|
} else {
|
|
successCount += len(routesToDelete)
|
|
}
|
|
}
|
|
|
|
// Update prefix tables
|
|
if len(prefixesToUpdate) > 0 {
|
|
if err := h.db.UpdatePrefixesBatch(prefixesToUpdate); err != nil {
|
|
h.logger.Error("Failed to update prefix batch", "error", err, "count", len(prefixesToUpdate))
|
|
}
|
|
}
|
|
|
|
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(),
|
|
)
|
|
|
|
h.lastFlush = time.Now()
|
|
}
|
|
|
|
// canonicalPrefix returns prefix in the text form net/netip prints, the form
|
|
// the IP lookup builds when it looks a prefix up. The feed sends IPv6
|
|
// announcements compressed ("2001:db8::/32") but IPv6 withdrawals uncompressed
|
|
// ("2001:db8:0:0:0:0:0:0/32"); stored as received, a withdrawal would not match
|
|
// the route its announcement stored. A prefix that does not parse is returned
|
|
// unchanged, and the batch flush reports it.
|
|
func canonicalPrefix(prefix string) string {
|
|
p, err := netip.ParsePrefix(prefix)
|
|
if err != nil {
|
|
return prefix
|
|
}
|
|
|
|
return p.Masked().String()
|
|
}
|
|
|
|
// parseCIDR extracts the mask length and IP version from a prefix string
|
|
func parseCIDR(prefix string) (maskLength int, ipVersion int, err error) {
|
|
_, ipNet, err := net.ParseCIDR(prefix)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
ones, _ := ipNet.Mask.Size()
|
|
if strings.Contains(prefix, ":") {
|
|
return ones, ipv6Version, nil
|
|
}
|
|
|
|
return ones, ipv4Version, nil
|
|
}
|
|
|
|
// processAnnouncement handles storing an announcement in the database
|
|
// nolint:unused // kept for potential future use
|
|
func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpdate) {
|
|
// Parse CIDR to get mask length
|
|
maskLength, ipVersion, err := parseCIDR(update.prefix)
|
|
if err != nil {
|
|
h.logger.Error("Failed to parse CIDR",
|
|
"prefix", update.prefix,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Track route update metrics
|
|
if h.metrics != nil {
|
|
if ipVersion == ipv4Version {
|
|
h.metrics.RecordIPv4Update()
|
|
} else {
|
|
h.metrics.RecordIPv6Update()
|
|
}
|
|
}
|
|
|
|
// Create live route record
|
|
liveRoute := &database.LiveRoute{
|
|
ID: uuid.New(),
|
|
Prefix: update.prefix,
|
|
MaskLength: maskLength,
|
|
IPVersion: ipVersion,
|
|
OriginASN: update.originASN,
|
|
PeerIP: update.peer,
|
|
ASPath: update.path,
|
|
NextHop: update.peer, // Using peer as next hop
|
|
LastUpdated: update.timestamp,
|
|
}
|
|
|
|
if err := h.db.UpsertLiveRoute(liveRoute); err != nil {
|
|
h.logger.Error("Failed to upsert live route",
|
|
"prefix", update.prefix,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// createLiveRoute creates a LiveRoute from a prefix update
|
|
func (h *PrefixHandler) createLiveRoute(update prefixUpdate) *database.LiveRoute {
|
|
// Parse CIDR to get mask length
|
|
maskLength, ipVersion, err := parseCIDR(update.prefix)
|
|
if err != nil {
|
|
h.logger.Error("Failed to parse CIDR",
|
|
"prefix", update.prefix,
|
|
"error", err,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Track route update metrics
|
|
if h.metrics != nil {
|
|
if ipVersion == ipv4Version {
|
|
h.metrics.RecordIPv4Update()
|
|
} else {
|
|
h.metrics.RecordIPv6Update()
|
|
}
|
|
}
|
|
|
|
// Create live route record
|
|
liveRoute := &database.LiveRoute{
|
|
ID: uuid.New(),
|
|
Prefix: update.prefix,
|
|
MaskLength: maskLength,
|
|
IPVersion: ipVersion,
|
|
OriginASN: update.originASN,
|
|
PeerIP: update.peer,
|
|
ASPath: update.path,
|
|
NextHop: update.peer, // Using peer as next hop
|
|
LastUpdated: update.timestamp,
|
|
}
|
|
|
|
return liveRoute
|
|
}
|
|
|
|
// processAnnouncementDirect handles storing an announcement directly without prefix table
|
|
// nolint:unused // kept for potential future use
|
|
func (h *PrefixHandler) processAnnouncementDirect(update prefixUpdate) {
|
|
// Parse CIDR to get mask length
|
|
maskLength, ipVersion, err := parseCIDR(update.prefix)
|
|
if err != nil {
|
|
h.logger.Error("Failed to parse CIDR",
|
|
"prefix", update.prefix,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Track route update metrics
|
|
if h.metrics != nil {
|
|
if ipVersion == ipv4Version {
|
|
h.metrics.RecordIPv4Update()
|
|
} else {
|
|
h.metrics.RecordIPv6Update()
|
|
}
|
|
}
|
|
|
|
// Create live route record
|
|
liveRoute := &database.LiveRoute{
|
|
ID: uuid.New(),
|
|
Prefix: update.prefix,
|
|
MaskLength: maskLength,
|
|
IPVersion: ipVersion,
|
|
OriginASN: update.originASN,
|
|
PeerIP: update.peer,
|
|
ASPath: update.path,
|
|
NextHop: update.peer, // Using peer as next hop
|
|
LastUpdated: update.timestamp,
|
|
}
|
|
|
|
if err := h.db.UpsertLiveRoute(liveRoute); err != nil {
|
|
h.logger.Error("Failed to upsert live route",
|
|
"prefix", update.prefix,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// processWithdrawalDirect handles removing a route directly without prefix table
|
|
// nolint:unused // kept for potential future use
|
|
func (h *PrefixHandler) processWithdrawalDirect(update prefixUpdate) {
|
|
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) {
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Stop gracefully stops the handler and flushes remaining batches
|
|
func (h *PrefixHandler) Stop() {
|
|
close(h.stopCh)
|
|
h.wg.Wait()
|
|
}
|