check / check (push) Canceled after 0s
Shutdown passes its context to every stage: the streamer, the four handlers, the WHOIS fetcher, the database maintainer and the HTTP server each stop waiting when it ends. A handler then skips its last write and logs how many updates it did not write, and the HTTP server closes the connections still open. The database is checkpointed and closed last, and left open once the deadline has passed, since a write may still hold it. The stop hook gives Shutdown 8 seconds and fx 9, inside the 10 seconds upaas and docker stop allow, so a stop signal exits 0. Model: opus-5-5
263 lines
7.8 KiB
Go
263 lines
7.8 KiB
Go
package routewatch
|
|
|
|
// peerhandler.go provides batched peer tracking functionality for BGP route
|
|
// monitoring. It tracks BGP peers from incoming UPDATE messages and maintains
|
|
// peer state in the database.
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
)
|
|
|
|
const (
|
|
// peerHandlerQueueSize is the queue capacity for peer tracking operations,
|
|
// about 4 seconds of feed at peak. The streamer drops rather than blocks
|
|
// when a queue is full, so this bounds memory. Batches still flush on a
|
|
// timer (peerBatchTimeout).
|
|
peerHandlerQueueSize = 20000
|
|
|
|
// peerBatchSize is the number of peer updates to batch together
|
|
peerBatchSize = 10000
|
|
|
|
// peerBatchMaxSize is the most peer updates the batch holds while the
|
|
// flush goroutine writes the previous one: ten batches, about 10 MiB
|
|
// (README "Memory"). At this size HandleMessage waits until the flush
|
|
// goroutine takes the batch.
|
|
peerBatchMaxSize = 100000
|
|
|
|
// peerBatchTimeout is the maximum time to wait before flushing a batch
|
|
peerBatchTimeout = 2 * time.Second
|
|
)
|
|
|
|
// PeerHandler tracks BGP peers from UPDATE messages using batched operations.
|
|
// It maintains a queue of peer updates and periodically flushes them to the database
|
|
// in batches to improve performance. The handler deduplicates peer updates within
|
|
// each batch, keeping only the most recent update for each peer IP address.
|
|
type PeerHandler struct {
|
|
db database.Store
|
|
logger *logger.Logger
|
|
|
|
// Batching. mu guards peerBatch and writing only: the flush goroutine
|
|
// takes the batch under mu and writes it after releasing mu, so
|
|
// HandleMessage waits for a database write only while peerBatch is at
|
|
// peerBatchMaxSize.
|
|
mu sync.Mutex
|
|
peerBatch []peerUpdate
|
|
// writing is how many updates the flush goroutine took from peerBatch
|
|
// and has not finished writing.
|
|
writing int
|
|
// batchTaken is signalled when the flush goroutine takes peerBatch; its L
|
|
// is &mu.
|
|
batchTaken sync.Cond
|
|
batchFullCh chan struct{} // wakes the flush goroutine when peerBatch is full
|
|
lastFlush time.Time // used only by the flush goroutine
|
|
// stopCh hands Stop's context to the flush goroutine. It holds one, so
|
|
// Stop does not wait for a write in progress to hand it over.
|
|
stopCh chan context.Context
|
|
done chan struct{} // closed when the flush goroutine returns
|
|
}
|
|
|
|
type peerUpdate struct {
|
|
peerIP string
|
|
peerASN int
|
|
messageType string
|
|
timestamp time.Time
|
|
}
|
|
|
|
// NewPeerHandler creates a new PeerHandler with the given database store and logger.
|
|
// It initializes the peer batch buffer and starts a background goroutine that
|
|
// periodically flushes accumulated peer updates to the database. The handler
|
|
// should be stopped by calling Stop when it is no longer needed.
|
|
func NewPeerHandler(db database.Store, logger *logger.Logger) *PeerHandler {
|
|
h := &PeerHandler{
|
|
db: db,
|
|
logger: logger,
|
|
peerBatch: make([]peerUpdate, 0, peerBatchSize),
|
|
batchFullCh: make(chan struct{}, 1),
|
|
lastFlush: time.Now(),
|
|
stopCh: make(chan context.Context, 1),
|
|
done: make(chan struct{}),
|
|
}
|
|
h.batchTaken.L = &h.mu
|
|
|
|
// Start the flush timer goroutine
|
|
go h.flushLoop()
|
|
|
|
return h
|
|
}
|
|
|
|
// WantsMessage returns true for UPDATE messages only. A peer whose session
|
|
// never comes up sends STATE messages and nothing else, so a STATE message is
|
|
// not a sign of a peer that carries routes.
|
|
func (h *PeerHandler) WantsMessage(messageType string) bool {
|
|
return messageType == ristypes.MessageTypeUpdate
|
|
}
|
|
|
|
// QueueCapacity returns the desired queue capacity for this handler.
|
|
// The PeerHandler uses a large queue capacity because batching allows
|
|
// for efficient processing of many updates at once.
|
|
func (h *PeerHandler) QueueCapacity() int {
|
|
// Batching allows us to use a larger queue
|
|
return peerHandlerQueueSize
|
|
}
|
|
|
|
// HandleMessage processes a RIS message to track peer information.
|
|
// It extracts the peer IP address and ASN from the message and adds
|
|
// the update to an internal batch. The flush goroutine writes the batch to the
|
|
// database when it reaches peerBatchSize or the batch timeout expires.
|
|
func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|
// Parse peer ASN from string
|
|
peerASN := 0
|
|
|
|
if msg.PeerASN != "" {
|
|
asn, err := strconv.Atoi(msg.PeerASN)
|
|
if err == nil {
|
|
peerASN = asn
|
|
}
|
|
}
|
|
|
|
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.peerBatch) >= peerBatchMaxSize {
|
|
h.batchTaken.Wait()
|
|
}
|
|
|
|
// Add to batch
|
|
h.peerBatch = append(h.peerBatch, peerUpdate{
|
|
peerIP: msg.Peer,
|
|
peerASN: peerASN,
|
|
messageType: msg.Type,
|
|
timestamp: msg.ParsedTimestamp,
|
|
})
|
|
|
|
// 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 peerBatchMaxSize.
|
|
if len(h.peerBatch) >= peerBatchSize {
|
|
select {
|
|
case h.batchFullCh <- struct{}{}:
|
|
default: // already woken
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the flush goroutine, which writes the updates the handler holds
|
|
// before it returns, and waits for it until ctx ends. Once ctx has ended the
|
|
// flush goroutine skips that last write, and a write still running is lost
|
|
// when the process exits, so Stop logs how many updates are not written.
|
|
func (h *PeerHandler) Stop(ctx context.Context) {
|
|
h.stopCh <- ctx
|
|
|
|
select {
|
|
case <-h.done:
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
h.mu.Lock()
|
|
unwritten := h.writing + len(h.peerBatch)
|
|
h.mu.Unlock()
|
|
|
|
if unwritten > 0 {
|
|
h.logger.Error("Stop deadline passed before the peer handler wrote "+
|
|
"its updates", "updates", unwritten)
|
|
}
|
|
}
|
|
|
|
// 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 *PeerHandler) flushLoop() {
|
|
defer close(h.done)
|
|
|
|
ticker := time.NewTicker(peerBatchTimeout)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if time.Since(h.lastFlush) >= peerBatchTimeout {
|
|
h.flushBatch()
|
|
}
|
|
case <-h.batchFullCh:
|
|
h.flushBatch()
|
|
case ctx := <-h.stopCh:
|
|
// Final flush, unless the stop deadline has passed
|
|
if ctx.Err() == nil {
|
|
h.flushBatch()
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// takeBatch returns the pending updates and leaves an empty batch in their
|
|
// place for HandleMessage to fill. They count in writing until flushBatch has
|
|
// written them.
|
|
func (h *PeerHandler) takeBatch() []peerUpdate {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
if len(h.peerBatch) == 0 {
|
|
return nil
|
|
}
|
|
|
|
batch := h.peerBatch
|
|
h.writing = len(batch)
|
|
h.peerBatch = make([]peerUpdate, 0, peerBatchSize)
|
|
h.batchTaken.Broadcast()
|
|
|
|
return batch
|
|
}
|
|
|
|
// flushBatch takes the pending batch and writes it to the database, without
|
|
// holding the mutex during the write.
|
|
func (h *PeerHandler) flushBatch() {
|
|
batch := h.takeBatch()
|
|
if len(batch) == 0 {
|
|
return
|
|
}
|
|
|
|
// Deduplicate by peer IP, keeping the latest update for each peer
|
|
peerMap := make(map[string]peerUpdate)
|
|
for _, update := range batch {
|
|
existing, ok := peerMap[update.peerIP]
|
|
if !ok || update.timestamp.After(existing.timestamp) {
|
|
peerMap[update.peerIP] = update
|
|
}
|
|
}
|
|
|
|
// Convert to database format
|
|
dbPeerMap := make(map[string]database.PeerUpdate)
|
|
for peerIP, update := range peerMap {
|
|
dbPeerMap[peerIP] = database.PeerUpdate{
|
|
PeerIP: update.peerIP,
|
|
PeerASN: update.peerASN,
|
|
MessageType: update.messageType,
|
|
Timestamp: update.timestamp,
|
|
}
|
|
}
|
|
|
|
// Process all peers in a single batch transaction
|
|
err := h.db.UpdatePeerBatch(dbPeerMap)
|
|
if err != nil {
|
|
h.logger.Error("Failed to process peer batch",
|
|
"error", err, "count", len(dbPeerMap))
|
|
}
|
|
|
|
h.mu.Lock()
|
|
h.writing = 0
|
|
h.mu.Unlock()
|
|
|
|
h.lastFlush = time.Now()
|
|
}
|