check / check (push) Waiting to run
The prefix, AS and peer handlers skipped a timed write unless one interval had passed since the last write ended. The next tick came a little less than one interval after that, so every other tick was skipped. Each tick now writes. A full batch's write restarts the timer, so the next timed write comes one interval after that write began. Comparing clock readings would not do: two ticks one interval apart can read a hair under it. flushBatch no longer reports whether it wrote a batch, since nothing reads that. A new test runs the loop on a fake clock and fails on the old code. Model: opus-5-5
233 lines
7.4 KiB
Go
233 lines
7.4 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
|
|
|
|
// 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 asnBatchMaxSize.
|
|
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,
|
|
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) >= asnBatchMaxSize {
|
|
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 asnBatchMaxSize.
|
|
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()
|
|
}
|