Serve /api/v1/stats from a cache and index-scan the route timestamps (closes #27)
check / check (push) Failing after 0s
check / check (push) Failing after 0s
Once the database passed about 4.5 GiB every stats request ran a COUNT(*) over each table plus a MIN/MAX union scan of both route tables, took the full timeout and returned HTTP 500, so the status page went blank. The server now keeps the last database statistics in memory and recomputes them at most once every 30 seconds; requests serve the cached copy and a stale copy triggers a single background refresh, so no request runs the scans. The route-count split is folded into the cached stats, removing the separate per-request live-route count query. The oldest/newest route timestamps now read one row from each end of the last_updated index instead of scanning both tables, and select the column directly so the driver parses it into time.Time; the old aggregate returned an untyped string that failed to scan and logged a warning every call. Model: opus-4-8
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// tempStoreMemory is the PRAGMA temp_store value meaning "hold temp B-trees in
|
||||
@@ -425,6 +426,105 @@ func TestBatchWriteDuringCheckpoint(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestStatsRouteTimestampsAndCounts checks GetStatsContext reports the correct
|
||||
// route counts and the oldest/newest last_updated across both route tables. The
|
||||
// old union-scan query read the aggregate result into *time.Time, which the
|
||||
// driver could not parse, so it logged a warning every call and left both
|
||||
// timestamps nil; this asserts they are populated from the right rows.
|
||||
func TestStatsRouteTimestampsAndCounts(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() }()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty database: no routes, so both timestamps are nil and no error.
|
||||
empty, err := db.GetStatsContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatsContext on empty database: %v", err)
|
||||
}
|
||||
if empty.OldestRoute != nil || empty.NewestRoute != nil {
|
||||
t.Fatalf("empty database timestamps = (%v, %v), want (nil, nil)",
|
||||
empty.OldestRoute, empty.NewestRoute)
|
||||
}
|
||||
if empty.LiveRoutes != 0 {
|
||||
t.Fatalf("empty database LiveRoutes = %d, want 0", empty.LiveRoutes)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
oldest := base
|
||||
middle := base.Add(time.Minute)
|
||||
newest := base.Add(2 * time.Minute)
|
||||
|
||||
mkV4 := func(prefix string, asn int, ts time.Time) *LiveRoute {
|
||||
start, end, rerr := CalculateIPv4Range(prefix)
|
||||
if rerr != nil {
|
||||
t.Fatalf("CalculateIPv4Range(%s): %v", prefix, rerr)
|
||||
}
|
||||
|
||||
return &LiveRoute{
|
||||
ID: uuid.New(),
|
||||
Prefix: prefix,
|
||||
MaskLength: 24,
|
||||
IPVersion: ipVersionV4,
|
||||
OriginASN: asn,
|
||||
PeerIP: "192.0.2.1",
|
||||
ASPath: []int{asn},
|
||||
NextHop: "192.0.2.254",
|
||||
LastUpdated: ts,
|
||||
V4IPStart: &start,
|
||||
V4IPEnd: &end,
|
||||
}
|
||||
}
|
||||
|
||||
// Two IPv4 routes (one oldest, one middle) and one IPv6 route (newest).
|
||||
routes := []*LiveRoute{
|
||||
mkV4("198.51.100.0/24", 64500, middle),
|
||||
mkV4("203.0.113.0/24", 64501, oldest),
|
||||
{
|
||||
ID: uuid.New(),
|
||||
Prefix: "2001:db8::/32",
|
||||
MaskLength: 32,
|
||||
IPVersion: ipVersionV6,
|
||||
OriginASN: 64502,
|
||||
PeerIP: "2001:db8::1",
|
||||
ASPath: []int{64502},
|
||||
NextHop: "2001:db8::ffff",
|
||||
LastUpdated: newest,
|
||||
},
|
||||
}
|
||||
for _, route := range routes {
|
||||
if err := db.UpsertLiveRoute(route); err != nil {
|
||||
t.Fatalf("UpsertLiveRoute(%s): %v", route.Prefix, err)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := db.GetStatsContext(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatsContext: %v", err)
|
||||
}
|
||||
|
||||
if stats.IPv4Routes != 2 {
|
||||
t.Errorf("IPv4Routes = %d, want 2", stats.IPv4Routes)
|
||||
}
|
||||
if stats.IPv6Routes != 1 {
|
||||
t.Errorf("IPv6Routes = %d, want 1", stats.IPv6Routes)
|
||||
}
|
||||
if stats.LiveRoutes != 3 {
|
||||
t.Errorf("LiveRoutes = %d, want 3", stats.LiveRoutes)
|
||||
}
|
||||
if stats.OldestRoute == nil || !stats.OldestRoute.Equal(oldest) {
|
||||
t.Errorf("OldestRoute = %v, want %v", stats.OldestRoute, oldest)
|
||||
}
|
||||
if stats.NewestRoute == nil || !stats.NewestRoute.Equal(newest) {
|
||||
t.Errorf("NewestRoute = %v, want %v", stats.NewestRoute, newest)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIPToUint32(b *testing.B) {
|
||||
ip := net.ParseIP("192.168.1.1")
|
||||
b.ResetTimer()
|
||||
|
||||
Reference in New Issue
Block a user