Record origin changes and show them on the prefix page (closes #161)
check / check (push) Successful in 4m6s

The routing table records each prefix and origin AS pair's origin changes:
when it gains its first peer and when it loses its last. Each row holds the
prefix, the origin AS, announced or withdrawn, the peer count and the time.
The prefix handler writes them to a new origin_changes table, and the database
maintainer prunes each prefix to its newest 500 rows and 72 hours. The prefix
page lists them newest first, at most 5,000.

Model: deepseek-v4-pro
This commit is contained in:
2026-11-05 04:07:39 +00:00
parent 15bf20a894
commit 347e1456a2
16 changed files with 1127 additions and 27 deletions
@@ -0,0 +1,84 @@
package routewatch
import (
"errors"
"net/netip"
"testing"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// TestPrefixHandlerWritesOriginChanges hands a running prefix handler an
// announcement and flushes. The flush must write the origin change to the
// database.
func TestPrefixHandlerWritesOriginChanges(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
h := prefixHandlerWithoutTimer(db)
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
h.flushBatch()
got, err := db.GetOriginChanges(t.Context(),
netip.MustParsePrefix(testV4Prefix), 10)
if err != nil {
t.Fatalf("GetOriginChanges: %v", err)
}
if len(got) != 1 || !got[0].Announced || got[0].OriginASN != testASNB {
t.Errorf("GetOriginChanges = %+v, want one announced row for AS%d",
got, testASNB)
}
}
// errTestOriginChangeWrite is the error a test store's failing write of the
// origin changes returns.
var errTestOriginChangeWrite = errors.New(
"test: the write of the origin changes failed")
// originChangesFailsStore is a real database whose first write of the origin
// changes fails, writing nothing. writes counts the writes asked for.
type originChangesFailsStore struct {
*database.Database
writes int
}
func (s *originChangesFailsStore) WriteOriginChanges(
changes []routetable.OriginChange,
) error {
s.writes++
if s.writes == 1 {
return errTestOriginChangeWrite
}
return s.Database.WriteOriginChanges(changes)
}
// TestPrefixHandlerWritesAFailedOriginChangeWriteAgain runs two flushes into a
// database whose first write of the origin changes fails. The second write must
// hold the change of the first.
func TestPrefixHandlerWritesAFailedOriginChangeWriteAgain(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
store := &originChangesFailsStore{Database: db}
h := prefixHandlerWithoutTimer(store)
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
h.flushBatch()
h.flushBatch()
got, err := db.GetOriginChanges(t.Context(),
netip.MustParsePrefix(testV4Prefix), 10)
if err != nil {
t.Fatalf("GetOriginChanges: %v", err)
}
if len(got) != 1 || !got[0].Announced || got[0].OriginASN != testASNB {
t.Errorf("GetOriginChanges = %+v, want one announced row for AS%d",
got, testASNB)
}
}