2 Commits
Author SHA1 Message Date
sneak b8f956e867 Sample the prefix-length pages with bounded lookups, each prefix once (closes #66)
check / check (push) Canceled after 0s
/prefixlength/{length} and /prefixlength6/{length} grouped every live route
of that length by prefix, shuffled all the prefixes and joined the routes
back, so a request grew with the number of routes and listed a prefix once
for each peer that shared its newest time.

Now a length with at most 5000 prefixes has them all read and shuffled, and
the page lists 500 of them. A longer one is sampled with random row-id
lookups on the mask length index, at most four per prefix asked for, filled
up from the shuffled prefixes if the lookups keep repeating. Each prefix is
shown once, with its newest route.

Model: opus-5-5
2026-10-14 14:50:13 +00:00
clawbot a43935308e Count every reconnect in reconnect_count (closes #68)
check / check (push) Canceled after 0s
`reconnect_count` in `/api/v1/stats` stayed 0 however often the stream reconnected: `SetConnected` counted a reconnect only while the stream was still marked connected, and the streamer always marks it disconnected first. It now counts one reconnect for every connection after the first, recognised by the zero `connectedSince` before the first connection. A test connects and disconnects twice and expects 2.

Model: opus-5-5
2026-10-14 15:59:15 +02:00
5 changed files with 160 additions and 58 deletions
-5
View File
@@ -27,11 +27,6 @@ real 5 GiB limit (https://git.eeqj.de/sneak/routewatch/issues/3).
# Completed Steps
- 2026-10-14: `/prefixlength/{length}` and `/prefixlength6/{length}` no
longer read every route of that length: a length with at most 500
prefixes has all of them listed, a longer one is sampled with random
lookups on the mask length index, and each prefix is listed once, with
its newest route (closes #66)
- 2026-10-03: `/api/v1/stats` serves the prefix distribution from memory,
seeded at startup and adjusted on every live-route write, so a request no
longer reads every live route (closes #30)
+51 -29
View File
@@ -54,12 +54,16 @@ const (
sqliteHardHeapLimitBytes = 1610612736
)
// sampleLookupsPerPrefix bounds the random lookups GetRandomPrefixesByLength
// makes when a mask length has more prefixes than it lists: at most this many
// for each prefix asked for. A lookup that lands on a prefix already picked is
// spent, so a sample can come back a little short when there are only a few
// more prefixes than asked for, or their routes are spread unevenly.
const sampleLookupsPerPrefix = 4
// Bounds on the work GetRandomPrefixesByLength does to pick its sample.
const (
// maxPrefixesToShuffle is the most prefixes of one mask length that are
// read in full and shuffled to pick the sample. A mask length with more is
// sampled with random lookups, which then seldom land on a prefix twice.
maxPrefixesToShuffle = 5000
// sampleLookupsPerPrefix is the most random lookups made for each prefix
// asked for.
sampleLookupsPerPrefix = 4
)
// Common errors
var (
@@ -1704,10 +1708,11 @@ func (d *Database) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) (
return d.GetRandomPrefixesByLengthContext(context.Background(), maskLength, ipVersion, limit)
}
// GetRandomPrefixesByLengthContext returns up to limit distinct prefixes with
// the given mask length, each with its most recently updated live route. Its
// work grows with limit, not with the number of routes: a mask length with at
// most limit prefixes has all of them listed, any other is sampled.
// GetRandomPrefixesByLengthContext returns limit prefixes with the given mask
// length picked at random, or all of them when there are fewer, each once with
// its most recently updated live route. However many routes there are, it
// reads at most maxPrefixesToShuffle+1 prefixes and makes at most
// sampleLookupsPerPrefix lookups for each prefix asked for.
func (d *Database) GetRandomPrefixesByLengthContext(
ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) {
tableName := "live_routes_v4"
@@ -1715,18 +1720,22 @@ func (d *Database) GetRandomPrefixesByLengthContext(
tableName = "live_routes_v6"
}
// Reading one prefix more than limit tells whether there are more prefixes
// to choose from than the page lists.
prefixes, err := d.firstPrefixesByLength(ctx, tableName, maskLength, limit+1)
// Reading one prefix more than maxPrefixesToShuffle tells whether these
// are all the prefixes with this mask length.
prefixes, err := d.firstPrefixesByLength(ctx, tableName, maskLength, maxPrefixesToShuffle+1)
if err != nil {
return nil, err
}
if len(prefixes) > limit {
prefixes, err = d.samplePrefixesByLength(ctx, tableName, maskLength, limit)
rand.Shuffle(len(prefixes), func(i, j int) {
prefixes[i], prefixes[j] = prefixes[j], prefixes[i]
})
if len(prefixes) > maxPrefixesToShuffle {
prefixes, err = d.samplePrefixesByLength(ctx, tableName, maskLength, limit, prefixes)
if err != nil {
return nil, err
}
}
prefixes = prefixes[:min(limit, len(prefixes))]
return d.newestRouteOfEachPrefix(ctx, tableName, ipVersion, prefixes)
}
@@ -1765,18 +1774,20 @@ func (d *Database) firstPrefixesByLength(
return prefixes, nil
}
// samplePrefixesByLength picks up to limit distinct prefixes with the given
// mask length at random, in at most sampleLookupsPerPrefix lookups for each
// prefix asked for. ORDER BY RANDOM() would read every route with that mask
// length. Instead each lookup draws a row id between those of the first and
// last route with that mask length and takes the prefix of the first such
// route at or after it. SQLite keeps the row id at the end of every index
// entry, so the mask length index is ordered by mask length, then row id, and
// the lookup is one search of it. A prefix with more routes, or with more rows
// of other mask lengths just before its own, comes up more often: the page
// needs a varied sample, not an even one.
// samplePrefixesByLength picks limit distinct prefixes with the given mask
// length at random, in at most sampleLookupsPerPrefix lookups for each prefix
// asked for. ORDER BY RANDOM() would read every route with that mask length.
// Instead each lookup draws a row id between those of the first and last route
// with that mask length and takes the prefix of the first such route at or
// after it. SQLite keeps the row id at the end of every index entry, so the
// mask length index is ordered by mask length, then row id, and the lookup is
// one search of it. A prefix with more routes, or with more rows of other mask
// lengths just before its own, comes up more often: the page needs a varied
// sample, not an even one. Should the lookups keep landing on prefixes already
// picked, the rest are taken from shuffled, prefixes of that length in random
// order.
func (d *Database) samplePrefixesByLength(
ctx context.Context, tableName string, maskLength, limit int) ([]string, error) {
ctx context.Context, tableName string, maskLength, limit int, shuffled []string) ([]string, error) {
//nolint:gosec // Table name is hardcoded based on IP version
rangeQuery := fmt.Sprintf(`
SELECT
@@ -1809,6 +1820,13 @@ func (d *Database) samplePrefixesByLength(
picked := make(map[string]bool, limit)
prefixes := make([]string, 0, limit)
pick := func(prefix string) {
if !picked[prefix] {
picked[prefix] = true
prefixes = append(prefixes, prefix)
}
}
for lookups := 0; lookups < limit*sampleLookupsPerPrefix && len(prefixes) < limit; lookups++ {
//nolint:gosec // The sample needs variety, not unpredictability.
rowID := first.Int64 + rand.Int64N(last.Int64-first.Int64+1)
@@ -1822,10 +1840,14 @@ func (d *Database) samplePrefixesByLength(
if err != nil {
return nil, fmt.Errorf("failed to look up prefix: %w", err)
}
if !picked[prefix] {
picked[prefix] = true
prefixes = append(prefixes, prefix)
pick(prefix)
}
for _, prefix := range shuffled {
if len(prefixes) == limit {
break
}
pick(prefix)
}
return prefixes, nil
+73 -14
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/netip"
"slices"
"sync"
"testing"
"time"
@@ -366,10 +367,42 @@ func TestGetRandomPrefixesByLengthListsPrefixOnce(t *testing.T) {
}
}
// TestGetRandomPrefixesByLengthListsNewestRoute stores a prefix learned from
// two peers at different times, the older route first, and checks that the
// prefix-length sample lists the newer route.
func TestGetRandomPrefixesByLengthListsNewestRoute(t *testing.T) {
cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
older := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
olderRoute := mkV4Route(t, "198.51.100.0/24", 64500, older)
newerRoute := mkV4Route(t, "198.51.100.0/24", 64500, older.Add(time.Minute))
newerRoute.PeerIP = secondV4PeerIP
if err := db.UpsertLiveRouteBatch([]*LiveRoute{olderRoute, newerRoute}); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
routes, err := db.GetRandomPrefixesByLength(newerRoute.MaskLength, ipVersionV4, sampleLimit)
if err != nil {
t.Fatalf("GetRandomPrefixesByLength: %v", err)
}
if len(routes) != 1 || routes[0].PeerIP != newerRoute.PeerIP {
t.Errorf("GetRandomPrefixesByLength = %+v, want only the newer route, from %s",
routes, newerRoute.PeerIP)
}
}
// TestGetRandomPrefixesByLengthSamplesManyPrefixes stores more /24 prefixes
// than the sample asks for, each learned from two peers in the same second,
// plus a /16, and checks that the sample lists as many prefixes as it asks
// for, each once and each a /24.
// than the sample asks for, each followed by a /16 so that routes of another
// mask length sit between the /24 rows, and each prefix learned from two peers
// in the same second. It checks both the sample the pages get, picked from the
// shuffled prefixes, and the random lookups used for a mask length with more
// than maxPrefixesToShuffle prefixes.
func TestGetRandomPrefixesByLengthSamplesManyPrefixes(t *testing.T) {
cfg := &config.Config{StateDir: t.TempDir()}
@@ -381,32 +414,58 @@ func TestGetRandomPrefixesByLengthSamplesManyPrefixes(t *testing.T) {
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
var routes []*LiveRoute
var stored []string
for i := range sampledPrefixCount {
prefix := fmt.Sprintf("10.0.%d.0/24", i)
first := mkV4Route(t, prefix, 64500, ts)
second := mkV4Route(t, prefix, 64500, ts)
second.PeerIP = secondV4PeerIP
routes = append(routes, first, second)
for _, p := range []string{prefix, fmt.Sprintf("172.%d.0.0/16", i)} {
first := mkV4Route(t, p, 64500, ts)
second := mkV4Route(t, p, 64500, ts)
second.PeerIP = secondV4PeerIP
routes = append(routes, first, second)
}
stored = append(stored, prefix)
}
routes = append(routes, mkV4Route(t, "10.1.0.0/16", 64501, ts))
if err := db.UpsertLiveRouteBatch(routes); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
slices.Sort(stored)
maskLength := routes[0].MaskLength
sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV4, sampleLimit)
if err != nil {
t.Fatalf("GetRandomPrefixesByLength: %v", err)
}
var listed []string
for _, route := range sample {
listed = append(listed, route.Prefix)
}
checkPrefixSample(t, "GetRandomPrefixesByLength", listed, stored)
looked, err := db.samplePrefixesByLength(
context.Background(), "live_routes_v4", maskLength, sampleLimit, nil)
if err != nil {
t.Fatalf("samplePrefixesByLength: %v", err)
}
checkPrefixSample(t, "samplePrefixesByLength", looked, stored)
}
// checkPrefixSample checks that sample lists sampleLimit prefixes, each once
// and each one of stored, and that it is not just the first sampleLimit of
// stored, which is in text order.
func checkPrefixSample(t *testing.T, name string, sample, stored []string) {
t.Helper()
if len(sample) != sampleLimit {
t.Errorf("sample listed %d routes, want %d", len(sample), sampleLimit)
t.Errorf("%s listed %d prefixes, want %d", name, len(sample), sampleLimit)
}
listed := make(map[string]bool)
for _, route := range sample {
if listed[route.Prefix] || route.MaskLength != maskLength {
t.Errorf("sample lists %s (mask %d) twice or at the wrong mask length",
route.Prefix, route.MaskLength)
for _, prefix := range sample {
if listed[prefix] || !slices.Contains(stored, prefix) {
t.Errorf("%s lists %s twice or it is not one of the /24s", name, prefix)
}
listed[route.Prefix] = true
listed[prefix] = true
}
if slices.Equal(slices.Sorted(slices.Values(sample)), stored[:sampleLimit]) {
t.Errorf("%s lists just the first %d prefixes in text order", name, sampleLimit)
}
}
+15 -10
View File
@@ -60,17 +60,22 @@ func New() *Tracker {
}
}
// SetConnected updates the connection status
// SetConnected updates the connection status. Every connection after the
// first counts one reconnect.
func (t *Tracker) SetConnected(connected bool) {
wasConnected := t.isConnected.Swap(connected)
if connected {
t.mu.Lock()
t.connectedSince = time.Now()
t.mu.Unlock()
// Increment reconnect count (but not for the initial connection)
if wasConnected || t.reconnectCount.Load() > 0 {
t.reconnectCount.Add(1)
}
t.isConnected.Store(connected)
if !connected {
return
}
t.mu.Lock()
// connectedSince is zero until the first connection.
connectedBefore := !t.connectedSince.IsZero()
t.connectedSince = time.Now()
t.mu.Unlock()
if connectedBefore {
t.reconnectCount.Add(1)
}
}
+21
View File
@@ -0,0 +1,21 @@
package metrics
import "testing"
// TestReconnectCount connects three times with a disconnect between each, the
// way the streamer does, and expects every connection after the first to count
// one reconnect.
func TestReconnectCount(t *testing.T) {
tracker := New()
tracker.SetConnected(true)
tracker.SetConnected(false)
tracker.SetConnected(true)
tracker.SetConnected(false)
tracker.SetConnected(true)
const wantReconnects = 2
if got := tracker.GetReconnectCount(); got != wantReconnects {
t.Fatalf("reconnect count = %d, want %d", got, wantReconnects)
}
}