check / check (push) Canceled after 0s
Five prefix handler tests gave HandleMessage two seconds to apply a full batch of 25,000 prefixes, which under the race detector on a loaded host takes longer. A test now waits up to stepTimeout, 15 seconds, for each step it expects. The slowest tests filled production limits, such as the peering handler's 500,000 paths. The peering handler now takes its path limit, and the prefix handler its limit on one write of the table's changes, as they take their timer interval. Those tests give small limits, and the tests of the maximum batch size lower it to one batch. This also fixes #215. The test helpers bound every wait, Stop included, so a test that hangs in one fails with its own message. Model: opus-5-5
238 lines
7.6 KiB
Go
238 lines
7.6 KiB
Go
package routewatch
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
)
|
|
|
|
const (
|
|
// asHandlerQueueSize is the queue capacity for ASN 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
|
|
// (asnBatchTimeout), so a queue smaller than asnBatchSize is fine.
|
|
asHandlerQueueSize = 20000
|
|
|
|
// asnBatchSize is the number of ASN operations to batch together
|
|
asnBatchSize = 30000
|
|
|
|
// asnBatchMaxSize is the most ASN operations 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.
|
|
asnBatchMaxSize = 300000
|
|
|
|
// asnBatchTimeout is the maximum time to wait before flushing a batch
|
|
// DO NOT reduce this timeout - larger batches are more efficient
|
|
asnBatchTimeout = 2 * time.Second
|
|
)
|
|
|
|
// ASHandler processes Autonomous System Number (ASN) information extracted from
|
|
// BGP UPDATE messages. It uses batched database operations to efficiently store
|
|
// ASN data, collecting operations into batches that are flushed either when the
|
|
// batch reaches a size threshold or after a timeout period.
|
|
type ASHandler struct {
|
|
db database.Store
|
|
logger *logger.Logger
|
|
|
|
// batchMaxSize is asnBatchMaxSize. Only HandleMessage reads it, so a test
|
|
// may lower it before it hands the handler a message.
|
|
batchMaxSize int
|
|
|
|
// Batching. mu guards batch 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 batch is at batchMaxSize.
|
|
mu sync.Mutex
|
|
batch []asnOp
|
|
// writing is how many operations the flush goroutine took from batch
|
|
// and has not finished writing.
|
|
writing int
|
|
// batchTaken is signalled when the flush goroutine takes batch; its L is
|
|
// &mu.
|
|
batchTaken sync.Cond
|
|
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
|
|
// 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 asnOp struct {
|
|
number int
|
|
timestamp time.Time
|
|
}
|
|
|
|
// NewASHandler creates and returns a new ASHandler instance. It initializes
|
|
// the batching system and starts a background goroutine that periodically
|
|
// flushes accumulated ASN operations to the database. The caller must call
|
|
// Stop when finished to ensure all pending operations are flushed and the
|
|
// background goroutine is terminated.
|
|
func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler {
|
|
return newASHandler(db, logger, asnBatchTimeout)
|
|
}
|
|
|
|
// newASHandler is NewASHandler with the flush timer's interval, batchTimeout,
|
|
// in place of asnBatchTimeout. A test that the timer must not disturb gives an
|
|
// interval longer than the test runs.
|
|
func newASHandler(
|
|
db database.Store, logger *logger.Logger, batchTimeout time.Duration,
|
|
) *ASHandler {
|
|
h := &ASHandler{
|
|
db: db,
|
|
logger: logger,
|
|
batchMaxSize: asnBatchMaxSize,
|
|
batch: make([]asnOp, 0, asnBatchSize),
|
|
batchFullCh: make(chan struct{}, 1),
|
|
stopCh: make(chan context.Context, 1),
|
|
done: make(chan struct{}),
|
|
}
|
|
h.batchTaken.L = &h.mu
|
|
|
|
// Start the flush timer goroutine
|
|
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
|
|
|
|
return h
|
|
}
|
|
|
|
// WantsMessage reports whether this handler should process messages of the
|
|
// given type. ASHandler only processes "UPDATE" messages, as these contain
|
|
// the AS path information needed to track autonomous systems.
|
|
func (h *ASHandler) WantsMessage(messageType string) bool {
|
|
// We only care about UPDATE messages for the database
|
|
return messageType == ristypes.MessageTypeUpdate
|
|
}
|
|
|
|
// QueueCapacity returns the recommended message queue size for this handler.
|
|
// ASHandler uses a large queue capacity to accommodate high-volume BGP streams,
|
|
// as the batching mechanism allows efficient processing of accumulated messages.
|
|
func (h *ASHandler) QueueCapacity() int {
|
|
// Batching allows us to use a larger queue
|
|
return asHandlerQueueSize
|
|
}
|
|
|
|
// HandleMessage processes a RIS Live BGP message by extracting all ASNs from
|
|
// the AS path and queuing them for batch insertion into the database. The
|
|
// origin ASN and all transit ASNs are recorded with their associated
|
|
// timestamps. When the batch reaches the configured size threshold, the flush
|
|
// goroutine is woken to write it.
|
|
func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|
// Use the pre-parsed timestamp
|
|
timestamp := msg.ParsedTimestamp
|
|
|
|
originASN := msg.Path.Origin()
|
|
|
|
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) >= h.batchMaxSize {
|
|
h.batchTaken.Wait()
|
|
}
|
|
|
|
// Queue origin ASN operation
|
|
if originASN > 0 {
|
|
h.batch = append(h.batch, asnOp{
|
|
number: originASN,
|
|
timestamp: timestamp,
|
|
})
|
|
}
|
|
|
|
// Also track all ASNs in the path, the members of an AS set included
|
|
for _, asn := range slices.Concat(msg.Path.Sequence, msg.Path.Set) {
|
|
if asn > 0 {
|
|
h.batch = append(h.batch, asnOp{
|
|
number: asn,
|
|
timestamp: timestamp,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 batchMaxSize.
|
|
if len(h.batch) >= asnBatchSize {
|
|
select {
|
|
case h.batchFullCh <- struct{}{}:
|
|
default: // already woken
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the flush goroutine, which writes the operations 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 operations are not written.
|
|
func (h *ASHandler) Stop(ctx context.Context) {
|
|
h.stopCh <- ctx
|
|
|
|
select {
|
|
case <-h.done:
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
h.mu.Lock()
|
|
unwritten := h.writing + len(h.batch)
|
|
h.mu.Unlock()
|
|
|
|
if unwritten > 0 {
|
|
h.logger.Error("Stop deadline passed before the AS handler wrote "+
|
|
"its updates", "updates", unwritten)
|
|
}
|
|
}
|
|
|
|
// takeBatch returns the pending operations and leaves an empty batch in their
|
|
// place for HandleMessage to fill. They count in writing until flushBatch has
|
|
// written them.
|
|
func (h *ASHandler) takeBatch() []asnOp {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
if len(h.batch) == 0 {
|
|
return nil
|
|
}
|
|
|
|
batch := h.batch
|
|
h.writing = len(batch)
|
|
h.batch = make([]asnOp, 0, asnBatchSize)
|
|
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 *ASHandler) flushBatch() {
|
|
batch := h.takeBatch()
|
|
if len(batch) == 0 {
|
|
return
|
|
}
|
|
|
|
// The earliest and the latest time the batch saw each AS. The batch holds
|
|
// several updates for an AS, from many peers, so a new AS is stored first
|
|
// seen at the earliest of them and last seen at the latest.
|
|
asns := make(map[int]database.SeenTimes)
|
|
for _, op := range batch {
|
|
seen := asns[op.number]
|
|
seen.Add(op.timestamp)
|
|
asns[op.number] = seen
|
|
}
|
|
|
|
// Process all ASNs in a single batch transaction
|
|
err := h.db.GetOrCreateASNBatch(asns)
|
|
if err != nil {
|
|
h.logger.Error("Failed to process ASN batch",
|
|
"error", err, "count", len(asns))
|
|
}
|
|
|
|
h.mu.Lock()
|
|
h.writing = 0
|
|
h.mu.Unlock()
|
|
}
|