Record origin changes and show them on the prefix page (closes #161)
check / check (push) Successful in 1m3s
check / check (push) Successful in 1m3s
The routing table records a row each time a prefix and origin AS gets its first peer (announced) or loses its last (withdrawn), with the time and how many peers carry the prefix just after. The prefix handler writes the rows to a new origin_changes table, apart from routes. Every hour the database maintainer keeps each prefix's rows from the last 72 hours or its newest 500, whichever keeps more. The prefix page lists them newest first, at most 5,000. At most 100,000 rows wait in memory to be written; past that the oldest are dropped and logged. A stop loses the rows past its last write. Model: deepseek-v4-pro (implementation); opus-5-5 (commit message)
This commit was merged in pull request #231.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package routetable
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxWaitingOriginChanges is the most origin changes the table keeps waiting
|
||||
// for the prefix handler to write. The oldest change past it is dropped and
|
||||
// counted.
|
||||
const MaxWaitingOriginChanges = 100000
|
||||
|
||||
// OriginChange is one change of a prefix and origin AS pair: the origin AS
|
||||
// gained its first peer, or lost its last.
|
||||
type OriginChange struct {
|
||||
Prefix netip.Prefix
|
||||
OriginASN int
|
||||
// Announced says the origin AS gained its first peer. When false, it
|
||||
// lost its last peer.
|
||||
Announced bool
|
||||
// PeerCount is how many peers carry the prefix just after the change,
|
||||
// counting every origin AS.
|
||||
PeerCount int
|
||||
// Time is when RouteWatch applied the change.
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// recordOriginChange records that the pair of prefix and asn gained its first
|
||||
// peer, when announced, or lost its last, when not. The caller holds the lock.
|
||||
// peerCount is how many peers carry the prefix just after the change.
|
||||
func (t *Table) recordOriginChange(
|
||||
prefix netip.Prefix, asn int, announced bool, peerCount int,
|
||||
) {
|
||||
change := OriginChange{
|
||||
Prefix: prefix,
|
||||
OriginASN: asn,
|
||||
Announced: announced,
|
||||
PeerCount: peerCount,
|
||||
Time: t.now(),
|
||||
}
|
||||
|
||||
t.originChanges = append(t.originChanges, change)
|
||||
t.trimOriginChanges()
|
||||
}
|
||||
|
||||
// TakeOriginChanges returns at most limit of the origin changes that wait to
|
||||
// be written, oldest first, and how many were dropped since the last call. It
|
||||
// then forgets what it returned.
|
||||
func (t *Table) TakeOriginChanges(limit int) ([]OriginChange, int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
n := min(limit, len(t.originChanges))
|
||||
changes := slices.Clone(t.originChanges[:n])
|
||||
t.originChanges = t.originChanges[n:]
|
||||
|
||||
dropped := t.originChangesDropped
|
||||
t.originChangesDropped = 0
|
||||
|
||||
return changes, dropped
|
||||
}
|
||||
|
||||
// PutBackOriginChanges records again what one TakeOriginChanges call returned,
|
||||
// for a caller that could not write it. The next TakeOriginChanges returns
|
||||
// those changes first, before the ones that arrived since. The oldest changes
|
||||
// past the cap are dropped and counted.
|
||||
func (t *Table) PutBackOriginChanges(changes []OriginChange) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.originChanges = append(changes, t.originChanges...)
|
||||
t.trimOriginChanges()
|
||||
}
|
||||
|
||||
// trimOriginChanges drops the oldest origin changes past the cap and counts
|
||||
// them. The caller holds the lock.
|
||||
func (t *Table) trimOriginChanges() {
|
||||
if extra := len(t.originChanges) - MaxWaitingOriginChanges; extra > 0 {
|
||||
t.originChangesDropped += extra
|
||||
t.originChanges = t.originChanges[extra:]
|
||||
}
|
||||
}
|
||||
|
||||
// OriginChangeCount returns how many origin changes wait for TakeOriginChanges.
|
||||
func (t *Table) OriginChangeCount() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
return len(t.originChanges)
|
||||
}
|
||||
|
||||
// prefixPeerCount returns how many peers carry prefix, counting every origin
|
||||
// AS. The caller holds the lock.
|
||||
func (t *Table) prefixPeerCount(prefix netip.Prefix) int {
|
||||
count := 0
|
||||
|
||||
for _, o := range t.origins(prefix) {
|
||||
count += int(o.peers.Count())
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package routetable_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bits-and-blooms/bitset"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||
)
|
||||
|
||||
// originChange returns the origin change the table records for prefix and asn
|
||||
// at the clock's time, announced or withdrawn, with peerCount peers.
|
||||
func originChange(
|
||||
prefix netip.Prefix, asn int, announced bool, peerCount int, at time.Time,
|
||||
) routetable.OriginChange {
|
||||
return routetable.OriginChange{
|
||||
Prefix: prefix,
|
||||
OriginASN: asn,
|
||||
Announced: announced,
|
||||
PeerCount: peerCount,
|
||||
Time: at,
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginChangesRecordAnnouncedAndWithdrawn checks that a prefix and origin
|
||||
// AS pair records an announced row when it gains its first peer, nothing for a
|
||||
// second peer, and a withdrawn row when it loses its last peer.
|
||||
func TestOriginChangesRecordAnnouncedAndWithdrawn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := at(0)
|
||||
table := routetable.New(routetable.Params{
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
prefix := netip.MustParsePrefix(prefixV4)
|
||||
|
||||
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
|
||||
table.Announce(prefix, asA, routeOf(peerB, asA, 20))
|
||||
|
||||
checkOriginChanges(t, table, []routetable.OriginChange{
|
||||
originChange(prefix, asA, true, 1, now),
|
||||
})
|
||||
|
||||
table.Withdraw(prefix, peerA)
|
||||
table.Withdraw(prefix, peerB)
|
||||
|
||||
checkOriginChanges(t, table, []routetable.OriginChange{
|
||||
originChange(prefix, asA, false, 0, now),
|
||||
})
|
||||
}
|
||||
|
||||
// TestOriginChangesRecordAPeerMovingOrigin checks that a peer moving from one
|
||||
// origin AS to another records a withdrawn row for the old one and an
|
||||
// announced row for the new one.
|
||||
func TestOriginChangesRecordAPeerMovingOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := at(0)
|
||||
table := routetable.New(routetable.Params{
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
prefix := netip.MustParsePrefix(prefixV4)
|
||||
|
||||
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
|
||||
table.Announce(prefix, asB, routeOf(peerA, asB, 20))
|
||||
|
||||
checkOriginChanges(t, table, []routetable.OriginChange{
|
||||
originChange(prefix, asA, true, 1, now),
|
||||
originChange(prefix, asA, false, 0, now),
|
||||
originChange(prefix, asB, true, 1, now),
|
||||
})
|
||||
}
|
||||
|
||||
// TestOriginChangesRecordPeersDown checks that PeersDown records a withdrawn
|
||||
// row for each prefix and origin AS pair it empties.
|
||||
func TestOriginChangesRecordPeersDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := at(0)
|
||||
table := routetable.New(routetable.Params{
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
v4 := netip.MustParsePrefix(prefixV4)
|
||||
v6 := netip.MustParsePrefix(prefixV6)
|
||||
|
||||
table.Announce(v4, asA, routeOf(peerA, asA, 10))
|
||||
table.Announce(v6, asA, routeOf(peerA, asA, 10))
|
||||
table.Announce(v4, asB, routeOf(peerB, asB, 20))
|
||||
|
||||
table.PeersDown([]string{peerA})
|
||||
|
||||
// peerA carried v4 and v6 with asA; both pairs are emptied. peerB still
|
||||
// carries v4 with asB, so that pair is not.
|
||||
checkOriginChanges(t, table, []routetable.OriginChange{
|
||||
originChange(v4, asA, true, 1, now),
|
||||
originChange(v6, asA, true, 1, now),
|
||||
originChange(v4, asB, true, 2, now),
|
||||
originChange(v4, asA, false, 1, now),
|
||||
originChange(v6, asA, false, 0, now),
|
||||
})
|
||||
}
|
||||
|
||||
// TestOriginChangesLoadRecordsNothing checks that Load records no origin
|
||||
// change.
|
||||
func TestOriginChangesLoadRecordsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
table := routetable.New(routetable.Params{
|
||||
PeerNumbers: map[string]uint{peerA: 0},
|
||||
})
|
||||
prefix := netip.MustParsePrefix(prefixV4)
|
||||
|
||||
peers := new(bitset.BitSet)
|
||||
peers.Set(0)
|
||||
|
||||
table.Load(routetable.Change{
|
||||
Prefix: prefix,
|
||||
OriginASN: asA,
|
||||
Peers: peers,
|
||||
Routes: []routetable.Route{routeOf(peerA, asA, 10)},
|
||||
})
|
||||
|
||||
changes, dropped := table.TakeOriginChanges(math.MaxInt)
|
||||
if dropped != 0 || len(changes) != 0 {
|
||||
t.Errorf("TakeOriginChanges = %+v, %d; want none", changes, dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginChangesTakeReturnsOldestFirst checks that TakeOriginChanges
|
||||
// returns the changes oldest first and at most max.
|
||||
func TestOriginChangesTakeReturnsOldestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
table := routetable.New(routetable.Params{})
|
||||
prefix := netip.MustParsePrefix(prefixV4)
|
||||
|
||||
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
|
||||
table.Announce(prefix, asB, routeOf(peerB, asB, 20))
|
||||
table.Announce(prefix, asC, routeOf(peerC, asC, 30))
|
||||
|
||||
first, dropped := table.TakeOriginChanges(2)
|
||||
if dropped != 0 {
|
||||
t.Errorf("dropped = %d, want 0", dropped)
|
||||
}
|
||||
|
||||
if len(first) != 2 || first[0].OriginASN != asA || first[1].OriginASN != asB {
|
||||
t.Errorf("first TakeOriginChanges = %+v, want the first two", first)
|
||||
}
|
||||
|
||||
second, _ := table.TakeOriginChanges(math.MaxInt)
|
||||
if len(second) != 1 || second[0].OriginASN != asC {
|
||||
t.Errorf("second TakeOriginChanges = %+v, want the third", second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginChangesPutBackPutsRowsInFront checks that PutBackOriginChanges
|
||||
// puts rows back in front, so the next TakeOriginChanges returns them first.
|
||||
func TestOriginChangesPutBackPutsRowsInFront(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
table := routetable.New(routetable.Params{})
|
||||
prefix := netip.MustParsePrefix(prefixV4)
|
||||
|
||||
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
|
||||
table.Announce(prefix, asB, routeOf(peerB, asB, 20))
|
||||
|
||||
taken, _ := table.TakeOriginChanges(math.MaxInt)
|
||||
table.Announce(prefix, asC, routeOf(peerC, asC, 30))
|
||||
table.PutBackOriginChanges(taken)
|
||||
|
||||
changes, _ := table.TakeOriginChanges(math.MaxInt)
|
||||
if len(changes) != 3 || changes[0].OriginASN != asA ||
|
||||
changes[1].OriginASN != asB || changes[2].OriginASN != asC {
|
||||
t.Errorf("TakeOriginChanges = %+v, want the put-back rows first", changes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginChangesDropPastTheLimit checks that a change past 100,000 waiting
|
||||
// rows is dropped and counted.
|
||||
func TestOriginChangesDropPastTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
table := routetable.New(routetable.Params{})
|
||||
addr := netip.MustParseAddr("10.0.0.0")
|
||||
|
||||
for range routetable.MaxWaitingOriginChanges + 1 {
|
||||
prefix := netip.PrefixFrom(addr, 32)
|
||||
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
|
||||
|
||||
addr = addr.Next()
|
||||
}
|
||||
|
||||
changes, dropped := table.TakeOriginChanges(routetable.MaxWaitingOriginChanges)
|
||||
if dropped != 1 {
|
||||
t.Errorf("dropped = %d, want 1", dropped)
|
||||
}
|
||||
|
||||
if len(changes) != routetable.MaxWaitingOriginChanges {
|
||||
t.Errorf("len(changes) = %d, want %d", len(changes),
|
||||
routetable.MaxWaitingOriginChanges)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginChangesTakeResetsTheDroppedCount checks that a TakeOriginChanges
|
||||
// that reports dropped rows leaves the next one reporting none, so each drop
|
||||
// is logged once, not on every later write.
|
||||
func TestOriginChangesTakeResetsTheDroppedCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
table := routetable.New(routetable.Params{})
|
||||
addr := netip.MustParseAddr("10.0.0.0")
|
||||
|
||||
for range routetable.MaxWaitingOriginChanges + 1 {
|
||||
table.Announce(netip.PrefixFrom(addr, 32), asA, routeOf(peerA, asA, 10))
|
||||
addr = addr.Next()
|
||||
}
|
||||
|
||||
_, dropped := table.TakeOriginChanges(routetable.MaxWaitingOriginChanges)
|
||||
if dropped != 1 {
|
||||
t.Fatalf("dropped = %d, want 1", dropped)
|
||||
}
|
||||
|
||||
_, dropped = table.TakeOriginChanges(0)
|
||||
if dropped != 0 {
|
||||
t.Errorf("the next take's dropped = %d, want 0", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginChangesPutBackTrimsToTheLimit checks that putting back what a
|
||||
// failed write took, after new changes filled the table again, drops the
|
||||
// oldest changes past the cap and counts them, so the cap still holds. The
|
||||
// clock advances one second per change, so a change's time says its age.
|
||||
func TestOriginChangesPutBackTrimsToTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
taken = 20000
|
||||
added = 20000
|
||||
)
|
||||
|
||||
now := time.Unix(0, 0)
|
||||
table := routetable.New(routetable.Params{
|
||||
Now: func() time.Time {
|
||||
now = now.Add(time.Second)
|
||||
|
||||
return now
|
||||
},
|
||||
})
|
||||
addr := netip.MustParseAddr("10.0.0.0")
|
||||
|
||||
announce := func(n int) {
|
||||
for range n {
|
||||
table.Announce(netip.PrefixFrom(addr, 32), asA, routeOf(peerA, asA, 10))
|
||||
addr = addr.Next()
|
||||
}
|
||||
}
|
||||
|
||||
announce(routetable.MaxWaitingOriginChanges)
|
||||
|
||||
takenChanges, _ := table.TakeOriginChanges(taken)
|
||||
|
||||
announce(added)
|
||||
|
||||
table.PutBackOriginChanges(takenChanges)
|
||||
|
||||
changes, dropped := table.TakeOriginChanges(math.MaxInt)
|
||||
if dropped != added {
|
||||
t.Errorf("dropped = %d, want %d", dropped, added)
|
||||
}
|
||||
|
||||
if len(changes) != routetable.MaxWaitingOriginChanges {
|
||||
t.Errorf("len(changes) = %d, want %d", len(changes),
|
||||
routetable.MaxWaitingOriginChanges)
|
||||
}
|
||||
|
||||
// The put-back rows are the oldest, so they are the ones dropped. The
|
||||
// first remaining change is the one that followed them.
|
||||
if want := time.Unix(int64(added+1), 0); !changes[0].Time.Equal(want) {
|
||||
t.Errorf("first change's time = %v, want %v", changes[0].Time, want)
|
||||
}
|
||||
}
|
||||
|
||||
// checkOriginChanges checks that TakeOriginChanges, taking every change,
|
||||
// returns want.
|
||||
func checkOriginChanges(
|
||||
t *testing.T, table *routetable.Table, want []routetable.OriginChange,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
changes, dropped := table.TakeOriginChanges(math.MaxInt)
|
||||
if dropped != 0 {
|
||||
t.Errorf("dropped = %d, want 0", dropped)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(changes, want) {
|
||||
t.Errorf("TakeOriginChanges = %+v, want %+v", changes, want)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
// and origin AS, it keeps the peers whose current route to the prefix has that
|
||||
// origin AS. It also keeps the two newest of those routes in full. It applies
|
||||
// announcements, withdrawals and peers' sessions going down. It answers the
|
||||
// route reads that the pages and the API need.
|
||||
// route reads that the pages and the API need. It also records each prefix and
|
||||
// origin AS pair's origin changes: when it gains its first peer and when it
|
||||
// loses its last.
|
||||
package routetable
|
||||
|
||||
import (
|
||||
@@ -91,6 +93,9 @@ type Params struct {
|
||||
// peer not in it gets the next number above all of them the first time it
|
||||
// announces a route.
|
||||
PeerNumbers map[string]uint
|
||||
// Now returns the time RouteWatch applies a change at. It is time.Now
|
||||
// unless a test gives one.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Table is the routing table. Its methods are safe for concurrent use.
|
||||
@@ -126,11 +131,24 @@ type Table struct {
|
||||
// originsWithoutKeptRoute counts the origins that peers carry but that
|
||||
// keep no route.
|
||||
originsWithoutKeptRoute int
|
||||
|
||||
// now returns the time RouteWatch applies a change at.
|
||||
now func() time.Time
|
||||
// originChanges holds the origin changes that wait to be written, oldest
|
||||
// first. originChangesDropped counts those dropped since the last
|
||||
// TakeOriginChanges.
|
||||
originChanges []OriginChange
|
||||
originChangesDropped int
|
||||
}
|
||||
|
||||
// New returns an empty table that numbers new peers on from
|
||||
// params.PeerNumbers.
|
||||
func New(params Params) *Table {
|
||||
now := params.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
t := &Table{
|
||||
ipv4: newFamily(ipv4Bits, minLookupMaskLengthV4),
|
||||
ipv6: newFamily(ipv6Bits, minLookupMaskLengthV6),
|
||||
@@ -139,6 +157,7 @@ func New(params Params) *Table {
|
||||
peerAddresses: make(map[uint]string, len(params.PeerNumbers)),
|
||||
newPeers: make(map[string]uint),
|
||||
changed: make(map[pair]struct{}),
|
||||
now: now,
|
||||
keptTimes: btree.NewG(keptTimesDegree, func(a, b keptTime) bool {
|
||||
return a.time.Before(b.time)
|
||||
}),
|
||||
@@ -268,8 +287,9 @@ func (t *Table) PeersDown(peers []string) {
|
||||
// Load puts one prefix and origin AS pair that the database holds into the
|
||||
// table, as startup does with each row it reads. The change's peers join the
|
||||
// pair's peer set, and its routes are kept as an announcement's would be.
|
||||
// Unlike an update, Load records no change, so TakeChanges does not hand the
|
||||
// pair back to be written again. Loading a pair twice changes nothing.
|
||||
// Unlike an update, Load records no change and no origin change, so
|
||||
// TakeChanges does not hand the pair back to be written again. Loading a pair
|
||||
// twice changes nothing.
|
||||
//
|
||||
// The caller makes sure that every peer change names, in its peer set or in
|
||||
// its routes, has a number in Params.PeerNumbers.
|
||||
@@ -460,6 +480,7 @@ func (t *Table) removeOrigin(prefix netip.Prefix, o *origin) {
|
||||
// r in full when it is one of the two newest routes of o. The peer's earlier
|
||||
// route, if o keeps one, is replaced: it is no longer the peer's current route.
|
||||
// The pair is recorded as changed only when its peer set or kept routes change.
|
||||
// When r is the origin's first peer, an announced origin change is recorded.
|
||||
func (t *Table) join(prefix netip.Prefix, o *origin, r route) {
|
||||
if slices.ContainsFunc(o.routes, r.equal) {
|
||||
// The peer announced its kept route again.
|
||||
@@ -469,6 +490,8 @@ func (t *Table) join(prefix netip.Prefix, o *origin, r route) {
|
||||
wasWithoutKeptRoute := o.withoutKeptRoute()
|
||||
|
||||
joined := !o.peers.Test(r.peer)
|
||||
|
||||
firstPeer := joined && o.peers.None()
|
||||
if joined {
|
||||
o.peers.Set(r.peer)
|
||||
|
||||
@@ -482,6 +505,10 @@ func (t *Table) join(prefix netip.Prefix, o *origin, r route) {
|
||||
if joined || dropped || kept {
|
||||
t.recordChange(prefix, o, wasWithoutKeptRoute)
|
||||
}
|
||||
|
||||
if firstPeer {
|
||||
t.recordOriginChange(prefix, o.asn, true, t.prefixPeerCount(prefix))
|
||||
}
|
||||
}
|
||||
|
||||
// leave takes peer out of the peer set of o, an origin of prefix, and drops
|
||||
@@ -540,7 +567,8 @@ func (t *Table) dropKeptRoute(o *origin, peer uint) bool {
|
||||
// recordChange records that the pair of prefix and the AS of o changed, and
|
||||
// brings the count of origins without a kept route up to date.
|
||||
// wasWithoutKeptRoute says whether o was such an origin before the change. An
|
||||
// origin that no peer carries any more is removed.
|
||||
// origin that no peer carries any more is removed, and a withdrawn origin
|
||||
// change is recorded.
|
||||
func (t *Table) recordChange(
|
||||
prefix netip.Prefix, o *origin, wasWithoutKeptRoute bool,
|
||||
) {
|
||||
@@ -549,6 +577,7 @@ func (t *Table) recordChange(
|
||||
t.countOriginWithoutKeptRoute(o, wasWithoutKeptRoute)
|
||||
|
||||
if o.peers.None() {
|
||||
t.recordOriginChange(prefix, o.asn, false, t.prefixPeerCount(prefix))
|
||||
t.removeOrigin(prefix, o)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user