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 # 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, - 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 seeded at startup and adjusted on every live-route write, so a request no
longer reads every live route (closes #30) longer reads every live route (closes #30)
+51 -29
View File
@@ -54,12 +54,16 @@ const (
sqliteHardHeapLimitBytes = 1610612736 sqliteHardHeapLimitBytes = 1610612736
) )
// sampleLookupsPerPrefix bounds the random lookups GetRandomPrefixesByLength // Bounds on the work GetRandomPrefixesByLength does to pick its sample.
// makes when a mask length has more prefixes than it lists: at most this many const (
// for each prefix asked for. A lookup that lands on a prefix already picked is // maxPrefixesToShuffle is the most prefixes of one mask length that are
// spent, so a sample can come back a little short when there are only a few // read in full and shuffled to pick the sample. A mask length with more is
// more prefixes than asked for, or their routes are spread unevenly. // sampled with random lookups, which then seldom land on a prefix twice.
const sampleLookupsPerPrefix = 4 maxPrefixesToShuffle = 5000
// sampleLookupsPerPrefix is the most random lookups made for each prefix
// asked for.
sampleLookupsPerPrefix = 4
)
// Common errors // Common errors
var ( var (
@@ -1704,10 +1708,11 @@ func (d *Database) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) (
return d.GetRandomPrefixesByLengthContext(context.Background(), maskLength, ipVersion, limit) return d.GetRandomPrefixesByLengthContext(context.Background(), maskLength, ipVersion, limit)
} }
// GetRandomPrefixesByLengthContext returns up to limit distinct prefixes with // GetRandomPrefixesByLengthContext returns limit prefixes with the given mask
// the given mask length, each with its most recently updated live route. Its // length picked at random, or all of them when there are fewer, each once with
// work grows with limit, not with the number of routes: a mask length with at // its most recently updated live route. However many routes there are, it
// most limit prefixes has all of them listed, any other is sampled. // reads at most maxPrefixesToShuffle+1 prefixes and makes at most
// sampleLookupsPerPrefix lookups for each prefix asked for.
func (d *Database) GetRandomPrefixesByLengthContext( func (d *Database) GetRandomPrefixesByLengthContext(
ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) { ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) {
tableName := "live_routes_v4" tableName := "live_routes_v4"
@@ -1715,18 +1720,22 @@ func (d *Database) GetRandomPrefixesByLengthContext(
tableName = "live_routes_v6" tableName = "live_routes_v6"
} }
// Reading one prefix more than limit tells whether there are more prefixes // Reading one prefix more than maxPrefixesToShuffle tells whether these
// to choose from than the page lists. // are all the prefixes with this mask length.
prefixes, err := d.firstPrefixesByLength(ctx, tableName, maskLength, limit+1) prefixes, err := d.firstPrefixesByLength(ctx, tableName, maskLength, maxPrefixesToShuffle+1)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(prefixes) > limit { rand.Shuffle(len(prefixes), func(i, j int) {
prefixes, err = d.samplePrefixesByLength(ctx, tableName, maskLength, limit) prefixes[i], prefixes[j] = prefixes[j], prefixes[i]
})
if len(prefixes) > maxPrefixesToShuffle {
prefixes, err = d.samplePrefixesByLength(ctx, tableName, maskLength, limit, prefixes)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
prefixes = prefixes[:min(limit, len(prefixes))]
return d.newestRouteOfEachPrefix(ctx, tableName, ipVersion, prefixes) return d.newestRouteOfEachPrefix(ctx, tableName, ipVersion, prefixes)
} }
@@ -1765,18 +1774,20 @@ func (d *Database) firstPrefixesByLength(
return prefixes, nil return prefixes, nil
} }
// samplePrefixesByLength picks up to limit distinct prefixes with the given // samplePrefixesByLength picks limit distinct prefixes with the given mask
// mask length at random, in at most sampleLookupsPerPrefix lookups for each // length at random, in at most sampleLookupsPerPrefix lookups for each prefix
// prefix asked for. ORDER BY RANDOM() would read every route with that mask // asked for. ORDER BY RANDOM() would read every route with that mask length.
// length. Instead each lookup draws a row id between those of the first and // Instead each lookup draws a row id between those of the first and last route
// last route with that mask length and takes the prefix of the first such // with that mask length and takes the prefix of the first such route at or
// route at or after it. SQLite keeps the row id at the end of every index // after it. SQLite keeps the row id at the end of every index entry, so the
// entry, so the mask length index is ordered by mask length, then row id, and // mask length index is ordered by mask length, then row id, and the lookup is
// the lookup is one search of it. A prefix with more routes, or with more rows // one search of it. A prefix with more routes, or with more rows of other mask
// of other mask lengths just before its own, comes up more often: the page // lengths just before its own, comes up more often: the page needs a varied
// needs a varied sample, not an even one. // 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( 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 //nolint:gosec // Table name is hardcoded based on IP version
rangeQuery := fmt.Sprintf(` rangeQuery := fmt.Sprintf(`
SELECT SELECT
@@ -1809,6 +1820,13 @@ func (d *Database) samplePrefixesByLength(
picked := make(map[string]bool, limit) picked := make(map[string]bool, limit)
prefixes := make([]string, 0, 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++ { for lookups := 0; lookups < limit*sampleLookupsPerPrefix && len(prefixes) < limit; lookups++ {
//nolint:gosec // The sample needs variety, not unpredictability. //nolint:gosec // The sample needs variety, not unpredictability.
rowID := first.Int64 + rand.Int64N(last.Int64-first.Int64+1) rowID := first.Int64 + rand.Int64N(last.Int64-first.Int64+1)
@@ -1822,10 +1840,14 @@ func (d *Database) samplePrefixesByLength(
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to look up prefix: %w", err) return nil, fmt.Errorf("failed to look up prefix: %w", err)
} }
if !picked[prefix] { pick(prefix)
picked[prefix] = true }
prefixes = append(prefixes, prefix)
for _, prefix := range shuffled {
if len(prefixes) == limit {
break
} }
pick(prefix)
} }
return prefixes, nil return prefixes, nil
+73 -14
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/netip" "net/netip"
"slices"
"sync" "sync"
"testing" "testing"
"time" "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 // TestGetRandomPrefixesByLengthSamplesManyPrefixes stores more /24 prefixes
// than the sample asks for, each learned from two peers in the same second, // than the sample asks for, each followed by a /16 so that routes of another
// plus a /16, and checks that the sample lists as many prefixes as it asks // mask length sit between the /24 rows, and each prefix learned from two peers
// for, each once and each a /24. // 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) { func TestGetRandomPrefixesByLengthSamplesManyPrefixes(t *testing.T) {
cfg := &config.Config{StateDir: t.TempDir()} 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) ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
var routes []*LiveRoute var routes []*LiveRoute
var stored []string
for i := range sampledPrefixCount { for i := range sampledPrefixCount {
prefix := fmt.Sprintf("10.0.%d.0/24", i) prefix := fmt.Sprintf("10.0.%d.0/24", i)
first := mkV4Route(t, prefix, 64500, ts) for _, p := range []string{prefix, fmt.Sprintf("172.%d.0.0/16", i)} {
second := mkV4Route(t, prefix, 64500, ts) first := mkV4Route(t, p, 64500, ts)
second.PeerIP = secondV4PeerIP second := mkV4Route(t, p, 64500, ts)
routes = append(routes, first, second) 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 { if err := db.UpsertLiveRouteBatch(routes); err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err) t.Fatalf("UpsertLiveRouteBatch: %v", err)
} }
slices.Sort(stored)
maskLength := routes[0].MaskLength maskLength := routes[0].MaskLength
sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV4, sampleLimit) sample, err := db.GetRandomPrefixesByLength(maskLength, ipVersionV4, sampleLimit)
if err != nil { if err != nil {
t.Fatalf("GetRandomPrefixesByLength: %v", err) 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 { 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) listed := make(map[string]bool)
for _, route := range sample { for _, prefix := range sample {
if listed[route.Prefix] || route.MaskLength != maskLength { if listed[prefix] || !slices.Contains(stored, prefix) {
t.Errorf("sample lists %s (mask %d) twice or at the wrong mask length", t.Errorf("%s lists %s twice or it is not one of the /24s", name, prefix)
route.Prefix, route.MaskLength)
} }
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) { func (t *Tracker) SetConnected(connected bool) {
wasConnected := t.isConnected.Swap(connected) t.isConnected.Store(connected)
if connected { if !connected {
t.mu.Lock() return
t.connectedSince = time.Now() }
t.mu.Unlock()
// Increment reconnect count (but not for the initial connection) t.mu.Lock()
if wasConnected || t.reconnectCount.Load() > 0 { // connectedSince is zero until the first connection.
t.reconnectCount.Add(1) 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)
}
}