Fix the second review of the origin changes
check / check (push) Failing after 15m27s

Stop now counts the origin changes inside a running write, in a new
writingOriginChanges field, and adds them to the count it logs. The prune test
checks which rows survive, including a prefix with rows on both sides of 72
hours. The prefix page tests cover 5,000 and 5,001 rows and a row's time and
peer count. New tests cover the dropped-rows warning and the dropped count's
reset. The prune floor is named minOriginChangesPerPrefix. The README, TODO,
package doc and doc comments are corrected to match.

Model: deepseek-v4-pro
This commit is contained in:
2026-11-05 05:08:27 +00:00
parent 56cfc7e554
commit 22783eb425
12 changed files with 382 additions and 82 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
// Package database provides SQLite storage for BGP routing data: ASNs,
// prefixes, announcements, peerings and BGP peers. Its routes table holds a
// copy of the whole routing table that internal/routetable keeps in memory.
// prefixes, announcements, peerings, BGP peers and origin changes. Its routes
// table holds a copy of the whole routing table that internal/routetable
// keeps in memory.
package database
import (
+8 -8
View File
@@ -9,13 +9,13 @@ import (
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// maxOriginChangesPerPrefix is how many origin changes the prune keeps for
// each prefix: the newest ones. A prefix with more has its older rows deleted.
const maxOriginChangesPerPrefix = 500
// minOriginChangesPerPrefix is how many of a prefix's newest origin changes
// the prune always keeps, whatever their age.
const minOriginChangesPerPrefix = 500
// originChangesRetention is how long the prune keeps an origin change: rows
// older than this are deleted, unless they are among the prefix's newest
// maxOriginChangesPerPrefix.
// minOriginChangesPerPrefix.
const originChangesRetention = 72 * time.Hour
// The statements of the origin_changes table (schema.sql).
@@ -122,13 +122,13 @@ func (d *Database) GetOriginChanges(
}
// PruneOriginChanges deletes, for each prefix with more than
// maxOriginChangesPerPrefix rows, the rows older than originChangesRetention
// that are not among its newest maxOriginChangesPerPrefix. It finds those
// minOriginChangesPerPrefix rows, the rows older than originChangesRetention
// that are not among its newest minOriginChangesPerPrefix. It finds those
// prefixes on a reading connection and deletes one prefix per write, so no
// write holds the lock for long.
func (d *Database) PruneOriginChanges(ctx context.Context) error {
rows, err := d.db.QueryContext(ctx, prunePrefixesSQL,
maxOriginChangesPerPrefix)
minOriginChangesPerPrefix)
if err != nil {
return fmt.Errorf("failed to find prefixes to prune: %w", err)
}
@@ -158,7 +158,7 @@ func (d *Database) PruneOriginChanges(ctx context.Context) error {
d.lock("PruneOriginChanges")
_, err := d.writer.ExecContext(ctx, pruneOriginChangesSQL,
prefix, cutoff, prefix, maxOriginChangesPerPrefix-1)
prefix, cutoff, prefix, minOriginChangesPerPrefix-1)
d.unlock()
@@ -48,44 +48,30 @@ func TestOriginChangesWriteThenRead(t *testing.T) {
}
}
// TestOriginChangesPrune checks that the prune keeps every row newer than 72
// hours, keeps the newest 500 rows of each prefix, deletes the rest, and
// leaves other prefixes alone.
// TestOriginChangesPrune checks that the prune keeps, for a prefix with more
// than 500 rows, its newest 500 rows and every row newer than 72 hours, and
// deletes the rest. It checks which rows survive by their times, not only how
// many.
func TestOriginChangesPrune(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
oldPrefix := netip.MustParsePrefix(tablePrefixV4)
newPrefix := netip.MustParsePrefix(tablePrefixShortV4)
straddling := netip.MustParsePrefix(tablePrefixShortV4)
other := netip.MustParsePrefix("10.20.0.0/16")
now := time.Now()
changes := make([]routetable.OriginChange, 0, 1201)
// Whole seconds, so the stored times round-trip exactly.
now := time.Now().Truncate(time.Second)
// 600 rows for oldPrefix, all older than 72 hours, oldest first. The
// prune keeps the newest 500 and deletes the oldest 100.
for i := range 600 {
changes = append(changes, routetable.OriginChange{
Prefix: oldPrefix, OriginASN: asA, Announced: true, PeerCount: 1,
Time: now.Add(-100 * time.Hour).Add(time.Duration(i) * time.Minute),
})
}
// 600 rows for newPrefix, all newer than 72 hours, oldest first. The
// prune keeps all of them.
for i := range 600 {
changes = append(changes, routetable.OriginChange{
Prefix: newPrefix, OriginASN: asA, Announced: true, PeerCount: 1,
Time: now.Add(-71 * time.Hour).Add(time.Duration(i) * time.Minute),
})
}
changes := rows(oldPrefix, now.Add(-100*time.Hour), 600)
// 600 rows for straddling: 400 older than 72 hours, then 200 newer. The
// prune keeps all 200 newer and the newest 300 of the 400 older.
changes = append(changes, rows(straddling, now.Add(-100*time.Hour), 400)...)
changes = append(changes, rows(straddling, now.Add(-71*time.Hour), 200)...)
// One row for other, which the prune leaves alone.
changes = append(changes, routetable.OriginChange{
Prefix: other, OriginASN: asA, Announced: true, PeerCount: 1,
Time: now.Add(-100 * time.Hour),
})
changes = append(changes, rows(other, now.Add(-100*time.Hour), 1)...)
err := db.WriteOriginChanges(changes)
if err != nil {
@@ -97,22 +83,43 @@ func TestOriginChangesPrune(t *testing.T) {
t.Fatalf("PruneOriginChanges: %v", err)
}
gotOld, err := db.GetOriginChanges(t.Context(), oldPrefix, 1000)
if err != nil {
t.Fatalf("GetOriginChanges: %v", err)
oldTimes := survivorTimes(t, db, oldPrefix, 1000)
if len(oldTimes) != 500 {
t.Errorf("GetOriginChanges(oldPrefix) = %d rows, want 500", len(oldTimes))
}
if len(gotOld) != 500 {
t.Errorf("GetOriginChanges(oldPrefix) = %d rows, want 500", len(gotOld))
// The newest row survives, and the oldest survivor is the 100th oldest,
// so a prune that kept the oldest 500 instead would fail.
wantNewest := now.Add(-100 * time.Hour).Add(599 * time.Minute)
if !oldTimes[0].Equal(wantNewest) {
t.Errorf("the newest surviving row's time = %v, want %v",
oldTimes[0], wantNewest)
}
gotNew, err := db.GetOriginChanges(t.Context(), newPrefix, 1000)
if err != nil {
t.Fatalf("GetOriginChanges: %v", err)
wantOldest := now.Add(-100 * time.Hour).Add(100 * time.Minute)
if !oldTimes[499].Equal(wantOldest) {
t.Errorf("the oldest surviving row's time = %v, want %v",
oldTimes[499], wantOldest)
}
if len(gotNew) != 600 {
t.Errorf("GetOriginChanges(newPrefix) = %d rows, want 600", len(gotNew))
straddlingTimes := survivorTimes(t, db, straddling, 1000)
if len(straddlingTimes) != 500 {
t.Errorf("GetOriginChanges(straddling) = %d rows, want 500",
len(straddlingTimes))
}
// The newest of the newer-than-72-hours rows survives, and the oldest
// survivor is the 100th oldest of the older-than-72-hours rows.
wantNewest = now.Add(-71 * time.Hour).Add(199 * time.Minute)
if !straddlingTimes[0].Equal(wantNewest) {
t.Errorf("the newest surviving row's time = %v, want %v",
straddlingTimes[0], wantNewest)
}
wantOldest = now.Add(-100 * time.Hour).Add(100 * time.Minute)
if !straddlingTimes[499].Equal(wantOldest) {
t.Errorf("the oldest surviving row's time = %v, want %v",
straddlingTimes[499], wantOldest)
}
gotOther, err := db.GetOriginChanges(t.Context(), other, 10)
@@ -125,6 +132,41 @@ func TestOriginChangesPrune(t *testing.T) {
}
}
// rows returns n origin changes for prefix, at times from plus one minute per
// step, oldest first.
func rows(prefix netip.Prefix, from time.Time, n int) []routetable.OriginChange {
changes := make([]routetable.OriginChange, 0, n)
for i := range n {
changes = append(changes, routetable.OriginChange{
Prefix: prefix, OriginASN: asA, Announced: true, PeerCount: 1,
Time: from.Add(time.Duration(i) * time.Minute),
})
}
return changes
}
// survivorTimes returns the times of prefix's rows after the prune, newest
// first.
func survivorTimes(
t *testing.T, db *Database, prefix netip.Prefix, limit int,
) []time.Time {
t.Helper()
rows, err := db.GetOriginChanges(t.Context(), prefix, limit)
if err != nil {
t.Fatalf("GetOriginChanges: %v", err)
}
times := make([]time.Time, len(rows))
for i, row := range rows {
times[i] = row.Time
}
return times
}
// TestOriginChangesSurviveRouteDeletion checks that deleting a routes row
// leaves the prefix's origin_changes rows.
func TestOriginChangesSurviveRouteDeletion(t *testing.T) {
+27
View File
@@ -206,6 +206,33 @@ func TestOriginChangesDropPastTheLimit(t *testing.T) {
}
}
// 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()
const limit = 100000
table := routetable.New(routetable.Params{})
addr := netip.MustParseAddr("10.0.0.0")
for range limit + 1 {
table.Announce(netip.PrefixFrom(addr, 32), asA, routeOf(peerA, asA, 10))
addr = addr.Next()
}
_, dropped := table.TakeOriginChanges(limit)
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
+6 -3
View File
@@ -287,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.
@@ -479,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.
@@ -565,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,
) {
+97 -12
View File
@@ -175,12 +175,13 @@ func (s *blockingStore) releaseWrites() {
//
//nolint:tagliatelle // field names are the keys the handlers log
type stopLog struct {
Msg string `json:"msg"`
Updates int `json:"updates"`
TableChanges int `json:"table_changes"`
HeldMessages int `json:"held_messages"`
Paths int `json:"paths"`
BatchEntries int `json:"batch_entries"`
Msg string `json:"msg"`
Updates int `json:"updates"`
TableChanges int `json:"table_changes"`
HeldMessages int `json:"held_messages"`
OriginChanges int `json:"origin_changes"`
Paths int `json:"paths"`
BatchEntries int `json:"batch_entries"`
}
// newErrorLogger returns a logger that writes only errors, as JSON lines, to
@@ -472,6 +473,42 @@ func (s tableWriteBlockingStore) WriteRouteChanges(
return s.mockStore.WriteRouteChanges(changes, newPeers)
}
// stopPrefixHandlerWithBlockedWrite stops the prefix handler, whose store
// holds the write that fill starts, after handing it the message extra, and
// returns what Stop logged. blocking is the store the write waits on; store is
// a blockingStore wrapped for the routing table's changes or the origin
// changes.
func stopPrefixHandlerWithBlockedWrite(
t *testing.T,
blocking *blockingStore,
store database.Store,
logs *bytes.Buffer,
fill, extra *ristypes.RISMessage,
) stopLog {
t.Helper()
h := NewPrefixHandler(store, newErrorLogger(logs),
routetable.New(routetable.Params{}))
stopWhileAWriteIsBlocked(t, blocking, h, h.done,
[]*ristypes.RISMessage{fill}, extra)
return lastLogged(t, logs)
}
// originChangesBlockingStore is a blockingStore whose writes of the origin
// changes also wait until the test calls releaseWrites.
type originChangesBlockingStore struct {
*blockingStore
}
func (s originChangesBlockingStore) WriteOriginChanges(
changes []routetable.OriginChange,
) error {
s.waitForRelease()
return s.mockStore.WriteOriginChanges(changes)
}
// TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress holds the write
// of the routing table's changes that a full batch starts, then hands the
// prefix handler one more prefix and stops it with a deadline that passes
@@ -485,19 +522,67 @@ func TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress(t *testing.T)
var logs bytes.Buffer
blocking := newBlockingStore()
h := NewPrefixHandler(tableWriteBlockingStore{blockingStore: blocking},
newErrorLogger(&logs), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
stopWhileAWriteIsBlocked(t, blocking, h, h.done,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
prefixAnnouncement([]string{extraPrefix}))
logged := stopPrefixHandlerWithBlockedWrite(t, blocking,
tableWriteBlockingStore{blockingStore: blocking}, &logs,
prefixAnnouncement(fill), prefixAnnouncement([]string{extraPrefix}))
if logged := lastLogged(t, &logs); logged.TableChanges != 2 {
if logged.TableChanges != 2 {
t.Errorf("Stop logged %d table changes not written, want 2",
logged.TableChanges)
}
}
// TestPrefixHandlerStopCountsTheOriginChangesOfAWriteInProgress holds the
// write of the origin changes that a full batch starts, then hands the prefix
// handler one more prefix and stops it with a deadline that passes meanwhile.
// Stop must log as not written both the change the held write took and the one
// since.
func TestPrefixHandlerStopCountsTheOriginChangesOfAWriteInProgress(t *testing.T) {
t.Parallel()
const extraPrefix = "198.51.100.0/24"
var logs bytes.Buffer
blocking := newBlockingStore()
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
logged := stopPrefixHandlerWithBlockedWrite(t, blocking,
originChangesBlockingStore{blockingStore: blocking}, &logs,
prefixAnnouncement(fill), prefixAnnouncement([]string{extraPrefix}))
if logged.OriginChanges != 2 {
t.Errorf("Stop logged %d origin changes not written, want 2",
logged.OriginChanges)
}
}
// TestPrefixHandlerStopCountsTheWaitingOriginChanges stops the prefix handler
// with a deadline that has already passed, so the flush goroutine skips its
// last write. The origin changes still waiting in the routing table are then
// not written, and Stop must log them.
func TestPrefixHandlerStopCountsTheWaitingOriginChanges(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
store := newMockStore()
h := newPrefixHandler(store, newErrorLogger(&logs),
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix, testV6Prefix))
ctx, cancel := context.WithCancel(t.Context())
cancel()
h.Stop(ctx)
if logged := lastLogged(t, &logs); logged.OriginChanges != 2 {
t.Errorf("Stop logged %d origin changes not written, want 2",
logged.OriginChanges)
}
}
// TestASHandlerSkipsItsLastWriteAfterTheStopDeadline also checks what Stop
// logs: the asnBatchSize entries of the held write and the entry for testASNB,
// handled since, are not written.
+1
View File
@@ -87,6 +87,7 @@ func (m *DBMaintainer) Start(ctx context.Context) {
"checkpoint_interval", checkpointInterval,
"vacuum_interval", vacuumInterval,
"analyze_interval", analyzeInterval,
"origin_changes_prune_interval", originChangesPruneInterval,
)
}
+23 -12
View File
@@ -85,8 +85,8 @@ const (
// a time, and takes new messages in between. It holds back at most
// maxHeldBackMessages messages.
//
// Each flush writes the table's changes to the database. It also stores each
// prefix the updates name with the time it was first seen.
// Each flush writes the table's changes and origin changes to the database.
// It also stores each prefix the updates name with the time it was first seen.
type PrefixHandler struct {
db database.Store
logger *logger.Logger
@@ -103,10 +103,10 @@ type PrefixHandler struct {
// give a smaller one.
originChangesPerWrite int
// Batching. mu guards batch, writing and writingTableChanges: the
// flush goroutine takes them under mu and writes them after releasing
// mu, so HandleMessage waits for a database write only while batch is at
// batchMaxSize.
// Batching. mu guards batch, writing, writingTableChanges and
// writingOriginChanges: the flush goroutine takes them under mu and
// writes them after releasing mu, so HandleMessage waits for a database
// write only while batch is at batchMaxSize.
mu sync.Mutex
batch []seenPrefix
// writing is how many updates the flush goroutine took and has not
@@ -115,6 +115,9 @@ type PrefixHandler struct {
// writingTableChanges is how many changes the flush goroutine took from
// the routing table and has not finished writing.
writingTableChanges int
// writingOriginChanges is how many origin changes the flush goroutine
// took from the routing table and has not finished writing.
writingOriginChanges int
// batchTaken is signalled when the flush goroutine takes batch; its L is
// &mu.
batchTaken sync.Cond
@@ -307,10 +310,9 @@ func (h *PrefixHandler) Stop(ctx context.Context) {
unwritten := h.writing + len(h.batch)
tableChanges := h.writingTableChanges + h.table.ChangeCount()
heldMessages := h.heldCount
originChanges := h.writingOriginChanges + h.table.OriginChangeCount()
h.mu.Unlock()
originChanges := h.table.OriginChangeCount()
if unwritten == 0 && tableChanges == 0 && heldMessages == 0 &&
originChanges == 0 {
return
@@ -513,9 +515,9 @@ func (h *PrefixHandler) takeBatch() []seenPrefix {
return batch
}
// flushBatch writes the routing table's changes, then takes the pending batch
// and stores the prefixes it names, without holding the mutex during the
// writes.
// flushBatch writes the routing table's changes and origin changes, then
// takes the pending batch and stores the prefixes it names, without holding
// the mutex during the writes.
func (h *PrefixHandler) flushBatch() {
h.writeTableChanges()
h.writeOriginChanges()
@@ -620,12 +622,15 @@ func (h *PrefixHandler) writeTableChanges() {
}
}
// writeOriginChanges writes at most maxOriginChangesPerWrite of the routing
// writeOriginChanges writes at most originChangesPerWrite of the routing
// table's origin changes. When the write fails, they go back to the table, so
// that a later write writes them again. A dropped change is logged as a
// warning.
func (h *PrefixHandler) writeOriginChanges() {
h.mu.Lock()
changes, dropped := h.table.TakeOriginChanges(h.originChangesPerWrite)
h.writingOriginChanges = len(changes)
h.mu.Unlock()
if dropped > 0 {
h.logger.Warn("Origin changes dropped", "dropped", dropped)
@@ -636,6 +641,12 @@ func (h *PrefixHandler) writeOriginChanges() {
}
err := h.db.WriteOriginChanges(changes)
h.mu.Lock()
defer h.mu.Unlock()
h.writingOriginChanges = 0
if err != nil {
h.logger.Error("Failed to write the origin changes; the next flush "+
"retries", "changes", len(changes), "error", err)
@@ -1,12 +1,16 @@
package routewatch
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/netip"
"slices"
"testing"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
@@ -125,3 +129,53 @@ func TestPrefixHandlerWritesManyOriginChangesOverSeveralFlushes(t *testing.T) {
t.Errorf("WriteOriginChanges held %v changes, want %v", store.counts, want)
}
}
// TestPrefixHandlerLogsTheDroppedOriginChanges fills the routing table with
// one origin change more than it keeps, then flushes. The warning the flush
// logs must carry the dropped count.
func TestPrefixHandlerLogsTheDroppedOriginChanges(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
db := newTestDatabase(t)
h := prefixHandlerWithoutTimer(db)
h.logger = &logger.Logger{
Logger: slog.New(slog.NewJSONHandler(&logs,
&slog.HandlerOptions{Level: slog.LevelWarn})),
}
announceManyPrefixes(h.table, 100001)
h.flushBatch()
var warning struct {
Msg string `json:"msg"`
Dropped *int `json:"dropped"`
}
found := false
for line := range bytes.SplitSeq(bytes.TrimSpace(logs.Bytes()), []byte("\n")) {
warning.Dropped = nil
err := json.Unmarshal(line, &warning)
if err != nil {
t.Fatalf("want a JSON log line, got %q: %v", line, err)
}
if warning.Msg != "Origin changes dropped" {
continue
}
found = true
if warning.Dropped == nil || *warning.Dropped != 1 {
t.Errorf("the warning's dropped = %v, want 1", warning.Dropped)
}
}
if !found {
t.Errorf("no warning logged the dropped origin changes")
}
}
@@ -145,4 +145,75 @@ func TestPrefixDetailSaysWhenMoreThan5000Changes(t *testing.T) {
if !strings.Contains(page, "more than 5000 changes") {
t.Errorf("page does not say there are more than 5000 changes")
}
if got := strings.Count(page, "<td>Announced</td>"); got != 5000 {
t.Errorf("page shows %d change rows, want 5000", got)
}
}
// TestPrefixDetailShowsNoNoteAtExactly5000Changes checks that a prefix with
// exactly 5,000 origin changes shows all 5,000 and no note.
func TestPrefixDetailShowsNoNoteAtExactly5000Changes(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(testPrefixV4)
changes := make([]routetable.OriginChange, 0, 5000)
for i := range 5000 {
changes = append(changes, routetable.OriginChange{
Prefix: prefix, OriginASN: 64500, Announced: true, PeerCount: 1,
Time: testRouteTime(i),
})
}
s := newPrefixDetailServer(t, changes)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
}
page := rec.Body.String()
if strings.Contains(page, "more than") {
t.Errorf("page says there are more changes, but a prefix has 5000")
}
if got := strings.Count(page, "<td>Announced</td>"); got != 5000 {
t.Errorf("page shows %d change rows, want 5000", got)
}
}
// TestPrefixDetailShowsOriginChangeTimeAndPeerCount checks that a row shows
// its time and its peer count.
func TestPrefixDetailShowsOriginChangeTimeAndPeerCount(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(testPrefixV4)
s := newPrefixDetailServer(t, []routetable.OriginChange{
{Prefix: prefix, OriginASN: 64500, Announced: true, PeerCount: 7,
Time: testRouteTime(5)},
})
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
}
page := rec.Body.String()
if !strings.Contains(page, "2026-01-02 03:09:05") {
t.Errorf("page does not show the origin change's time")
}
if !strings.Contains(page, "<td>7</td>") {
t.Errorf("page does not show the origin change's peer count")
}
}