From 3e0b6c074c13a77b5aa2c13169fb0414686dab96 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 2 Oct 2026 13:51:04 -0400 Subject: [PATCH] feat(stats): report the longest path per hour in /stats/paths --- db/paths.go | 1 + db/paths_integration_test.go | 23 +++++++++++++++++++++++ db/paths_test.go | 27 +++++++++++++++++++++++++++ db/queries/paths.sql | 4 +++- db/sqlc/paths.sql.go | 6 +++++- docs/docs.go | 4 ++++ docs/swagger.json | 4 ++++ docs/swagger.yaml | 4 ++++ internal/api/paths.go | 1 + 9 files changed, 72 insertions(+), 2 deletions(-) diff --git a/db/paths.go b/db/paths.go index 19cffd8..2189f92 100644 --- a/db/paths.go +++ b/db/paths.go @@ -31,6 +31,7 @@ func (s *Store) GetPathStats(ctx context.Context, since, until time.Time, iatas } point := &stats.Hourly[len(stats.Hourly)-1] point.Receptions += row.Receptions + point.MaxEntries = max(point.MaxEntries, row.MaxEntries) switch row.Category { case 0: switch row.HashBytes { diff --git a/db/paths_integration_test.go b/db/paths_integration_test.go index 656c48f..fbec87f 100644 --- a/db/paths_integration_test.go +++ b/db/paths_integration_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http/httptest" "os" + "slices" "testing" "time" @@ -112,6 +113,28 @@ SELECT int4send(n),'00000000-0000-0000-0000-000000000001',heard_at,iata,payload_ }) } } + // The longest routed path per hour; hours with only empty, TRACE or invalid paths report 0. + for _, tc := range []struct { + iatas []string + want []int32 + }{ + {nil, []int32{32, 63, 0}}, + {[]string{"YVR", "YYJ"}, []int32{32, 63, 0}}, + {[]string{"YYJ"}, []int32{2, 0}}, + {[]string{"YYZ"}, []int32{0}}, + } { + got, err := store.GetPathStats(ctx, since, until, tc.iatas) + if err != nil { + t.Fatal(err) + } + var max []int32 + for _, h := range got.Hourly { + max = append(max, h.MaxEntries) + } + if !slices.Equal(max, tc.want) { + t.Errorf("maxEntries for %v = %v, want %v", tc.iatas, max, tc.want) + } + } w := httptest.NewRecorder() handlers.StatsRouter(store, handlers.StatsOptions{}).ServeHTTP(w, httptest.NewRequest("GET", fmt.Sprintf("/paths?since=%d&until=%d&iatas=YVR", since.UnixMilli()+123, until.UnixMilli()+123), nil).WithContext(ctx)) var response api.PathStats diff --git a/db/paths_test.go b/db/paths_test.go index beb2f58..3b13606 100644 --- a/db/paths_test.go +++ b/db/paths_test.go @@ -9,6 +9,7 @@ import ( "time" sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" + "github.com/jackc/pgx/v5/pgtype" ) type pathBucketQuerier struct { @@ -31,3 +32,29 @@ func TestPathStatsRejectsInvalidDatabaseBucket(t *testing.T) { } } } + +type pathRowsQuerier struct { + sqlc.Querier + rows []sqlc.GetPathStatsRow +} + +func (q pathRowsQuerier) GetPathStats(context.Context, sqlc.GetPathStatsParams) ([]sqlc.GetPathStatsRow, error) { + return q.rows, nil +} + +// An hour's maxEntries is the largest across its category/width rows, not the last one seen. +func TestPathStatsHourlyMaxEntries(t *testing.T) { + hour := pgtype.Timestamptz{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true} + store := &Store{q: pathRowsQuerier{rows: []sqlc.GetPathStatsRow{ + {IsHourly: true, Hour: hour, Category: 0, HashBytes: 1, Receptions: 3, MaxEntries: 9}, + {IsHourly: true, Hour: hour, Category: 0, HashBytes: 2, Receptions: 1, MaxEntries: 5}, + {IsHourly: true, Hour: hour, Category: 1, HashBytes: 0, Receptions: 2, MaxEntries: 0}, + }}} + got, err := store.GetPathStats(context.Background(), time.Time{}, time.Time{}, nil) + if err != nil { + t.Fatal(err) + } + if len(got.Hourly) != 1 || got.Hourly[0].MaxEntries != 9 { + t.Errorf("hourly %+v, want one hour with maxEntries 9", got.Hourly) + } +} diff --git a/db/queries/paths.sql b/db/queries/paths.sql index 8cdaf51..effe72f 100644 --- a/db/queries/paths.sql +++ b/db/queries/paths.sql @@ -12,7 +12,9 @@ WITH readings AS ( SELECT (grouping(hour) = 0)::boolean AS is_hourly, COALESCE(hour, 'epoch'::timestamptz)::timestamptz AS hour, category, hash_bytes, COALESCE(entries, 0)::integer AS entries, - sum(receptions)::bigint AS receptions + sum(receptions)::bigint AS receptions, + -- Non-routed categories store entries = 0, so an hour of only empty paths gives 0. + COALESCE(max(entries) FILTER (WHERE receptions > 0), 0)::integer AS max_entries FROM readings GROUP BY GROUPING SETS ((category, hash_bytes, entries), (hour, category, hash_bytes)) ORDER BY is_hourly, hour, category, hash_bytes, entries; diff --git a/db/sqlc/paths.sql.go b/db/sqlc/paths.sql.go index f5c24f1..5c2f342 100644 --- a/db/sqlc/paths.sql.go +++ b/db/sqlc/paths.sql.go @@ -24,7 +24,9 @@ WITH readings AS ( SELECT (grouping(hour) = 0)::boolean AS is_hourly, COALESCE(hour, 'epoch'::timestamptz)::timestamptz AS hour, category, hash_bytes, COALESCE(entries, 0)::integer AS entries, - sum(receptions)::bigint AS receptions + sum(receptions)::bigint AS receptions, + -- Non-routed categories store entries = 0, so an hour of only empty paths gives 0. + COALESCE(max(entries) FILTER (WHERE receptions > 0), 0)::integer AS max_entries FROM readings GROUP BY GROUPING SETS ((category, hash_bytes, entries), (hour, category, hash_bytes)) ORDER BY is_hourly, hour, category, hash_bytes, entries @@ -43,6 +45,7 @@ type GetPathStatsRow struct { HashBytes int32 `json:"hash_bytes"` Entries int32 `json:"entries"` Receptions int64 `json:"receptions"` + MaxEntries int32 `json:"max_entries"` } // All HTTP aggregates read the hourly rollup, never observations. @@ -62,6 +65,7 @@ func (q *Queries) GetPathStats(ctx context.Context, arg GetPathStatsParams) ([]G &i.HashBytes, &i.Entries, &i.Receptions, + &i.MaxEntries, ); err != nil { return nil, err } diff --git a/docs/docs.go b/docs/docs.go index 425a460..7dd349d 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4835,6 +4835,10 @@ const docTemplate = `{ "hour": { "type": "integer" }, + "maxEntries": { + "description": "longest path received that hour across the requested IATAs; 0 if only empty paths", + "type": "integer" + }, "oneByte": { "type": "integer" }, diff --git a/docs/swagger.json b/docs/swagger.json index 757caae..18fd7a9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4833,6 +4833,10 @@ "hour": { "type": "integer" }, + "maxEntries": { + "description": "longest path received that hour across the requested IATAs; 0 if only empty paths", + "type": "integer" + }, "oneByte": { "type": "integer" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b1431fe..f63782d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1021,6 +1021,10 @@ definitions: type: integer hour: type: integer + maxEntries: + description: longest path received that hour across the requested IATAs; 0 + if only empty paths + type: integer oneByte: type: integer receptions: diff --git a/internal/api/paths.go b/internal/api/paths.go index 86c3a00..dad76d1 100644 --- a/internal/api/paths.go +++ b/internal/api/paths.go @@ -43,4 +43,5 @@ type PathHour struct { Empty int64 `json:"empty"` Trace int64 `json:"trace"` Unclassified int64 `json:"unclassified"` + MaxEntries int32 `json:"maxEntries"` // longest path received that hour across the requested IATAs; 0 if only empty paths }