feat(stats): filter scope counts by IATA and region (#142)

* feat(stats): filter scope counts by IATA and region

* perf(stats): aggregate region observations before counting scopes
This commit is contained in:
n30nex
2026-09-18 06:18:06 -04:00
committed by GitHub
parent b21279c747
commit bcdea76c26
18 changed files with 476 additions and 34 deletions
+29 -5
View File
@@ -1102,14 +1102,38 @@ WHERE ($1::text = '' OR preset = $1::text)
ORDER BY preset, iata, source_type;
-- name: GetScopeStats :many
-- Count each table on its own; the old cross-join blew up to millions of rows
-- before COUNT(DISTINCT) (~10s).
-- Aggregate matching observations once, separately from node memberships to avoid
-- a cross-join. Empty IATAs keep the original global counts, including associations
-- whose observations have expired; the filtered aggregates are empty in that case.
WITH observation_counts AS (
SELECT p.scope_id,
COUNT(DISTINCT p.packet_hash) AS packet_count,
COUNT(DISTINCT os.observer_id) AS observer_count
FROM packet_observations po
JOIN packets p ON p.packet_hash = po.packet_hash
LEFT JOIN observer_scopes os ON os.scope_id = p.scope_id AND os.observer_id = po.observer_id
WHERE po.iata = ANY(sqlc.arg(iatas)::bpchar[]) AND p.scope_id IS NOT NULL
GROUP BY p.scope_id
), node_counts AS (
SELECT n.default_scope_id AS scope_id, COUNT(*) AS node_count
FROM nodes n
WHERE n.id IN (SELECT node_id FROM node_iatas WHERE iata = ANY(sqlc.arg(iatas)::bpchar[]))
GROUP BY n.default_scope_id
)
SELECT
ts.name,
(SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id) AS packet_count,
(SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id) AS observer_count,
(SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id) AS node_count
CASE WHEN COALESCE(cardinality(sqlc.arg(iatas)::bpchar[]), 0) = 0
THEN (SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id)
ELSE COALESCE(oc.packet_count, 0) END::bigint AS packet_count,
CASE WHEN COALESCE(cardinality(sqlc.arg(iatas)::bpchar[]), 0) = 0
THEN (SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id)
ELSE COALESCE(oc.observer_count, 0) END::bigint AS observer_count,
CASE WHEN COALESCE(cardinality(sqlc.arg(iatas)::bpchar[]), 0) = 0
THEN (SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id)
ELSE COALESCE(nc.node_count, 0) END::bigint AS node_count
FROM transport_scopes ts
LEFT JOIN observation_counts oc ON oc.scope_id = ts.id
LEFT JOIN node_counts nc ON nc.scope_id = ts.id
ORDER BY ts.name;
-- ============================================================
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"os"
"reflect"
"testing"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5"
)
// Run on a migrated private database. All fixture tables and writes are rolled back.
func TestScopeStatsPostgres(t *testing.T) {
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set BEACON_TEST_POSTGRES_DSN for PostgreSQL regression")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer conn.Close(context.Background())
tx, err := conn.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(context.Background())
for _, table := range []string{"transport_scopes", "packets", "packet_observations", "observer_scopes", "nodes", "node_iatas"} {
if _, err := tx.Exec(ctx, "CREATE TEMP TABLE "+table+" (LIKE public."+table+" INCLUDING ALL) ON COMMIT DROP"); err != nil {
t.Fatal(err)
}
}
_, err = tx.Exec(ctx, `
INSERT INTO transport_scopes (id,name,transport_key,key_fingerprint)
SELECT i,name,decode(repeat('00',16),'hex'),decode(lpad(to_hex(i),16,'0'),'hex')
FROM (VALUES (1,'#a'),(2,'#b'),(3,'#unused')) v(i,name);
INSERT INTO packets (packet_hash,scope_id,payload_type,payload_version,route_type,raw_payload,raw_header,first_heard_at,last_heard_at)
SELECT decode(lpad(to_hex(i),2,'0'),'hex'),scope_id,4,0,1,'\x00','\x00',NOW(),NOW()
FROM (VALUES (1,1),(2,1),(3,1),(4,1),(5,2),(6,2),(7,2),(8,NULL)) v(i,scope_id);
INSERT INTO observer_scopes (observer_id,scope_id)
SELECT md5(i::text)::uuid,scope_id FROM (VALUES (1,1),(2,1),(4,1),(5,1),(1,2),(2,2),(3,2)) v(i,scope_id);
INSERT INTO packet_observations (id,packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count)
SELECT id,decode(lpad(to_hex(packet),2,'0'),'hex'),md5(observer::text)::uuid,iata,
'2026-01-01'::timestamptz+id*interval '1 second',0,1,0
FROM (VALUES (1,1,1,'YVR'),(2,1,4,'YVR'),(3,1,2,'YYJ'),(4,2,2,'YVR'),
(5,3,1,'YYZ'),(6,5,3,'YVR'),(7,6,1,'YYJ'),(8,7,2,'YYZ'),(9,8,1,'YVR')) v(id,packet,observer,iata);
INSERT INTO nodes (id,public_key,node_type,default_scope_id)
SELECT md5(i::text)::uuid,decode(lpad(to_hex(i),64,'0'),'hex'),2,scope_id
FROM (VALUES (1,1),(2,1),(3,1),(4,1),(5,2),(6,2),(7,NULL)) v(i,scope_id);
INSERT INTO node_iatas (node_id,iata)
SELECT md5(i::text)::uuid,iata FROM (VALUES (1,'YVR'),(1,'YYJ'),(2,'YVR'),(3,'YYZ'),(5,'YYJ'),(6,'YVR'),(6,'YYJ'),(7,'YVR')) v(i,iata);
`)
if err != nil {
t.Fatal(err)
}
store := &Store{q: sqlc.New(tx)}
for _, tc := range []struct {
name string
iatas []string
a, b [3]int64 // packets, observers, nodes
}{
{"global", nil, [3]int64{4, 4, 4}, [3]int64{3, 3, 2}},
{"empty filter", []string{}, [3]int64{4, 4, 4}, [3]int64{3, 3, 2}},
{"YVR", []string{"YVR"}, [3]int64{2, 3, 2}, [3]int64{1, 1, 1}},
{"YYJ", []string{"YYJ"}, [3]int64{1, 1, 1}, [3]int64{1, 1, 2}},
{"YYZ", []string{"YYZ"}, [3]int64{1, 1, 1}, [3]int64{1, 1, 0}},
{"overlapping IATAs", []string{"YVR", "YYJ"}, [3]int64{2, 3, 2}, [3]int64{2, 2, 2}},
{"duplicate IATAs", []string{"YYJ", "YVR", "YYJ"}, [3]int64{2, 3, 2}, [3]int64{2, 2, 2}},
{"unknown IATA", []string{"ZZZ"}, [3]int64{}, [3]int64{}},
} {
t.Run(tc.name, func(t *testing.T) {
rows, err := store.GetScopeStats(ctx, tc.iatas)
if err != nil {
t.Fatal(err)
}
want := []api.ScopeStats{
{Name: "#a", PacketCount: tc.a[0], ObserverCount: tc.a[1], NodeCount: tc.a[2]},
{Name: "#b", PacketCount: tc.b[0], ObserverCount: tc.b[1], NodeCount: tc.b[2]},
{Name: "#unused"},
}
if !reflect.DeepEqual(rows, want) {
t.Fatalf("counts = %+v, want %+v", rows, want)
}
})
}
if _, err := tx.Exec(ctx, "DELETE FROM transport_scopes"); err != nil {
t.Fatal(err)
}
rows, err := store.GetScopeStats(ctx, nil)
if err != nil || rows == nil || len(rows) != 0 {
t.Fatalf("empty roster = %+v, %v", rows, err)
}
}
+4 -4
View File
@@ -653,18 +653,18 @@ func (mr *MockQuerierMockRecorder) GetScopeNames(ctx any) *gomock.Call {
}
// GetScopeStats mocks base method.
func (m *MockQuerier) GetScopeStats(ctx context.Context) ([]db.GetScopeStatsRow, error) {
func (m *MockQuerier) GetScopeStats(ctx context.Context, iatas []string) ([]db.GetScopeStatsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetScopeStats", ctx)
ret := m.ctrl.Call(m, "GetScopeStats", ctx, iatas)
ret0, _ := ret[0].([]db.GetScopeStatsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetScopeStats indicates an expected call of GetScopeStats.
func (mr *MockQuerierMockRecorder) GetScopeStats(ctx any) *gomock.Call {
func (mr *MockQuerierMockRecorder) GetScopeStats(ctx, iatas any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScopeStats", reflect.TypeOf((*MockQuerier)(nil).GetScopeStats), ctx)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScopeStats", reflect.TypeOf((*MockQuerier)(nil).GetScopeStats), ctx, iatas)
}
// GetScopesByIATAs mocks base method.
+4 -3
View File
@@ -82,9 +82,10 @@ type Querier interface {
GetRegionIATAs(ctx context.Context, regionID int32) ([]string, error)
GetScopeByName(ctx context.Context, name string) (GetScopeByNameRow, error)
GetScopeNames(ctx context.Context) ([]string, error)
// Count each table on its own; the old cross-join blew up to millions of rows
// before COUNT(DISTINCT) (~10s).
GetScopeStats(ctx context.Context) ([]GetScopeStatsRow, error)
// Aggregate matching observations once, separately from node memberships to avoid
// a cross-join. Empty IATAs keep the original global counts, including associations
// whose observations have expired; the filtered aggregates are empty in that case.
GetScopeStats(ctx context.Context, iatas []string) ([]GetScopeStatsRow, error)
GetScopesByIATAs(ctx context.Context, dollar_1 []string) ([]GetScopesByIATAsRow, error)
// Repeaters/room servers (node_type 2/3) whose current advert-derived clock drift exceeds
// the given threshold in magnitude, worst first. Not time-windowed -- reflects each node's
+31 -7
View File
@@ -1418,12 +1418,35 @@ func (q *Queries) GetScopeNames(ctx context.Context) ([]string, error) {
}
const getScopeStats = `-- name: GetScopeStats :many
WITH observation_counts AS (
SELECT p.scope_id,
COUNT(DISTINCT p.packet_hash) AS packet_count,
COUNT(DISTINCT os.observer_id) AS observer_count
FROM packet_observations po
JOIN packets p ON p.packet_hash = po.packet_hash
LEFT JOIN observer_scopes os ON os.scope_id = p.scope_id AND os.observer_id = po.observer_id
WHERE po.iata = ANY($1::bpchar[]) AND p.scope_id IS NOT NULL
GROUP BY p.scope_id
), node_counts AS (
SELECT n.default_scope_id AS scope_id, COUNT(*) AS node_count
FROM nodes n
WHERE n.id IN (SELECT node_id FROM node_iatas WHERE iata = ANY($1::bpchar[]))
GROUP BY n.default_scope_id
)
SELECT
ts.name,
(SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id) AS packet_count,
(SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id) AS observer_count,
(SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id) AS node_count
CASE WHEN COALESCE(cardinality($1::bpchar[]), 0) = 0
THEN (SELECT COUNT(*) FROM packets p WHERE p.scope_id = ts.id)
ELSE COALESCE(oc.packet_count, 0) END::bigint AS packet_count,
CASE WHEN COALESCE(cardinality($1::bpchar[]), 0) = 0
THEN (SELECT COUNT(*) FROM observer_scopes os WHERE os.scope_id = ts.id)
ELSE COALESCE(oc.observer_count, 0) END::bigint AS observer_count,
CASE WHEN COALESCE(cardinality($1::bpchar[]), 0) = 0
THEN (SELECT COUNT(*) FROM nodes n WHERE n.default_scope_id = ts.id)
ELSE COALESCE(nc.node_count, 0) END::bigint AS node_count
FROM transport_scopes ts
LEFT JOIN observation_counts oc ON oc.scope_id = ts.id
LEFT JOIN node_counts nc ON nc.scope_id = ts.id
ORDER BY ts.name
`
@@ -1434,10 +1457,11 @@ type GetScopeStatsRow struct {
NodeCount int64 `json:"node_count"`
}
// Count each table on its own; the old cross-join blew up to millions of rows
// before COUNT(DISTINCT) (~10s).
func (q *Queries) GetScopeStats(ctx context.Context) ([]GetScopeStatsRow, error) {
rows, err := q.db.Query(ctx, getScopeStats)
// Aggregate matching observations once, separately from node memberships to avoid
// a cross-join. Empty IATAs keep the original global counts, including associations
// whose observations have expired; the filtered aggregates are empty in that case.
func (q *Queries) GetScopeStats(ctx context.Context, iatas []string) ([]GetScopeStatsRow, error) {
rows, err := q.db.Query(ctx, getScopeStats, iatas)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -243,8 +243,8 @@ func (s *Store) GetRadioPresets(ctx context.Context, preset string, iatas []stri
return items, nil
}
func (s *Store) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
rows, err := s.q.GetScopeStats(ctx)
func (s *Store) GetScopeStats(ctx context.Context, iatas []string) ([]api.ScopeStats, error) {
rows, err := s.q.GetScopeStats(ctx, iatas)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -227,13 +227,13 @@ func TestGetScopeStats(t *testing.T) {
mock := mockdb.NewMockQuerier(ctrl)
mock.EXPECT().
GetScopeStats(gomock.Any()).
GetScopeStats(gomock.Any(), []string{"YVR"}).
Return([]sqlc.GetScopeStatsRow{
{Name: "default", PacketCount: 100, ObserverCount: 5, NodeCount: 20},
}, nil)
store := &Store{q: mock}
items, err := store.GetScopeStats(context.Background())
items, err := store.GetScopeStats(context.Background(), []string{"YVR"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+33
View File
@@ -1988,6 +1988,7 @@ const docTemplate = `{
},
"/stats/scopes": {
"get": {
"description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.",
"produces": [
"application/json"
],
@@ -1995,6 +1996,32 @@ const docTemplate = `{
"Stats"
],
"summary": "Scope statistics",
"parameters": [
{
"type": "string",
"description": "Comma-separated IATA codes",
"name": "iatas",
"in": "query"
},
{
"type": "string",
"description": "Single IATA code; used when iatas is absent",
"name": "iata",
"in": "query"
},
{
"type": "integer",
"description": "Filter by region ID, expands to member IATAs",
"name": "regionId",
"in": "query"
},
{
"type": "string",
"description": "Filter by region slug, expands to member IATAs; combined with explicit IATAs",
"name": "region",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
@@ -2005,6 +2032,12 @@ const docTemplate = `{
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
+33
View File
@@ -1986,6 +1986,7 @@
},
"/stats/scopes": {
"get": {
"description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.",
"produces": [
"application/json"
],
@@ -1993,6 +1994,32 @@
"Stats"
],
"summary": "Scope statistics",
"parameters": [
{
"type": "string",
"description": "Comma-separated IATA codes",
"name": "iatas",
"in": "query"
},
{
"type": "string",
"description": "Single IATA code; used when iatas is absent",
"name": "iata",
"in": "query"
},
{
"type": "integer",
"description": "Filter by region ID, expands to member IATAs",
"name": "regionId",
"in": "query"
},
{
"type": "string",
"description": "Filter by region slug, expands to member IATAs; combined with explicit IATAs",
"name": "region",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
@@ -2003,6 +2030,12 @@
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
+26
View File
@@ -2509,6 +2509,28 @@ paths:
- Stats
/stats/scopes:
get:
description: Counts each packet, observer and node once per scope. IATA filters
use retained observations for packets/observers and node IATA memberships
for nodes. Without filters, returns global totals. Scopes with zero matching
counts remain listed; an empty region returns an empty array.
parameters:
- description: Comma-separated IATA codes
in: query
name: iatas
type: string
- description: Single IATA code; used when iatas is absent
in: query
name: iata
type: string
- description: Filter by region ID, expands to member IATAs
in: query
name: regionId
type: integer
- description: Filter by region slug, expands to member IATAs; combined with
explicit IATAs
in: query
name: region
type: string
produces:
- application/json
responses:
@@ -2518,6 +2540,10 @@ paths:
items:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeStats'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/internal_api_handlers.APIError'
"500":
description: Internal Server Error
schema:
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
func TestGetStatsScopes_RegionErrors(t *testing.T) {
for _, query := range []string{"regionId=invalid", "regionId=2147483648", "regionId=999", "region=missing"} {
t.Run(query, func(t *testing.T) {
reader := stubReader{
getRegion: func(context.Context, int32) (*api.Region, error) {
return nil, errors.New("not found")
},
getRegionBySlug: func(context.Context, string) (*api.Region, error) {
return nil, errors.New("not found")
},
}
w := httptest.NewRecorder()
getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+query, nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("got status %d, want 400", w.Code)
}
})
}
}
func TestGetStatsScopes_Filters(t *testing.T) {
for _, tc := range []struct {
query string
want []string
empty bool
}{
{"", nil, false},
{"iata=yvr", []string{"YVR"}, false},
{"iatas=yyj,%20yvr,yyj", []string{"YYJ", "YVR", "YYJ"}, false},
{"iatas=YVR&iata=YYZ", []string{"YVR"}, false},
{"regionId=7", []string{"YVR", "YYJ"}, false},
{"region=west", []string{"YVR", "YYJ"}, false},
{"regionId=7&region=missing", []string{"YVR", "YYJ"}, false},
{"iatas=YYZ,YVR&region=west", []string{"YYZ", "YVR", "YVR", "YYJ"}, false},
{"region=empty", nil, true},
{"region=empty&iata=YVR", []string{"YVR"}, false},
} {
t.Run(tc.query, func(t *testing.T) {
called := false
reader := stubReader{
getRegion: func(_ context.Context, id int32) (*api.Region, error) {
if id != 7 {
t.Fatalf("unexpected region ID %d", id)
}
return &api.Region{IATAs: []string{"YVR", "YYJ"}}, nil
},
getRegionBySlug: func(_ context.Context, slug string) (*api.Region, error) {
if slug == "empty" {
return &api.Region{}, nil
}
if slug != "west" {
t.Fatalf("unexpected region slug %s", slug)
}
return &api.Region{IATAs: []string{"YVR", "YYJ"}}, nil
},
getScopeStats: func(_ context.Context, iatas []string) ([]api.ScopeStats, error) {
called = true
if !slices.Equal(iatas, tc.want) {
t.Fatalf("IATAs = %v, want %v", iatas, tc.want)
}
return []api.ScopeStats{{Name: "#test", PacketCount: 2, ObserverCount: 1, NodeCount: 3}}, nil
},
}
w := httptest.NewRecorder()
getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+tc.query, nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
if called == tc.empty {
t.Fatalf("reader called = %v, empty region = %v", called, tc.empty)
}
var rows []api.ScopeStats
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
if tc.empty {
if rows == nil || len(rows) != 0 {
t.Fatalf("empty region returned %s", w.Body.String())
}
} else if len(rows) != 1 || rows[0].PacketCount != 2 || rows[0].ObserverCount != 1 || rows[0].NodeCount != 3 {
t.Fatalf("response changed: %+v", rows)
}
})
}
}
func TestGetStatsScopes_ReaderError(t *testing.T) {
w := httptest.NewRecorder()
getStatsScopes(stubReader{getScopeStats: func(context.Context, []string) ([]api.ScopeStats, error) {
return nil, errors.New("database unavailable")
}})(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?iata=YVR", nil))
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", w.Code)
}
}
+20 -1
View File
@@ -414,14 +414,33 @@ func getStatsRadioPresets(reader api.Reader) http.HandlerFunc {
// getStatsScopes godoc
//
// @Summary Scope statistics
// @Description Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.
// @Tags Stats
// @Produce json
// @Param iatas query string false "Comma-separated IATA codes"
// @Param iata query string false "Single IATA code; used when iatas is absent"
// @Param regionId query int false "Filter by region ID, expands to member IATAs"
// @Param region query string false "Filter by region slug, expands to member IATAs; combined with explicit IATAs"
// @Success 200 {object} []api.ScopeStats
// @Failure 400 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Router /stats/scopes [get]
func getStatsScopes(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := reader.GetScopeStats(r.Context())
iatas := parseIATAs(r)
if regionID := r.URL.Query().Get("regionId"); regionID != "" || r.URL.Query().Get("region") != "" {
regionIATAs, err := resolveRegionIATAs(r.Context(), regionID, r.URL.Query().Get("region"), reader)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
iatas = append(iatas, regionIATAs...)
if len(iatas) == 0 {
respond(w, http.StatusOK, []api.ScopeStats{})
return
}
}
stats, err := reader.GetScopeStats(r.Context(), iatas)
if err != nil {
respondError(w, http.StatusInternalServerError, "internal server error")
return
+1 -1
View File
@@ -282,7 +282,7 @@ func TestGetStatsRadioPresets_OK(t *testing.T) {
func TestGetStatsScopes_OK(t *testing.T) {
r := chi.NewRouter()
r.Get("/stats/scopes", getStatsScopes(stubReader{
getScopeStats: func(_ context.Context) ([]api.ScopeStats, error) {
getScopeStats: func(_ context.Context, _ []string) ([]api.ScopeStats, error) {
return []api.ScopeStats{{Name: "#bc", PacketCount: 100}}, nil
},
}))
+3 -3
View File
@@ -50,7 +50,7 @@ type stubReader struct {
getStatsTopAdvertisers func(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopAdvertiser, error)
getStatsClockDrift func(ctx context.Context, iatas []string, limit int32) ([]api.ClockDriftEntry, error)
getStatsTopTalkers func(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopTalker, error)
getScopeStats func(ctx context.Context) ([]api.ScopeStats, error)
getScopeStats func(ctx context.Context, iatas []string) ([]api.ScopeStats, error)
getStatsNodeTypes func(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error)
getScopeNames func(ctx context.Context) ([]string, error)
getScopesByIATAs func(ctx context.Context, iatas []string) ([]api.ScopeSummary, error)
@@ -303,9 +303,9 @@ func (s stubReader) GetStatsTopTalkers(ctx context.Context, iatas []string, sinc
return nil, nil
}
func (s stubReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
func (s stubReader) GetScopeStats(ctx context.Context, iatas []string) ([]api.ScopeStats, error) {
if s.getScopeStats != nil {
return s.getScopeStats(ctx)
return s.getScopeStats(ctx, iatas)
}
return nil, nil
}
+2 -1
View File
@@ -188,7 +188,8 @@ type Reader interface {
GetStatsTopTalkers(ctx context.Context, iatas []string, since time.Time, limit int32) ([]TopTalker, error)
// GetScopeStats returns aggregate packet, observer and node counts per transport scope.
GetScopeStats(ctx context.Context) ([]ScopeStats, error)
// Pass empty iatas for global totals. IATAs must be uppercase.
GetScopeStats(ctx context.Context, iatas []string) ([]ScopeStats, error)
// GetStatsNodeTypes returns node counts grouped by type, optionally filtered by IATA.
GetStatsNodeTypes(ctx context.Context, iatas []string) ([]NodeTypeCount, error)
+4 -2
View File
@@ -47,8 +47,10 @@ func (s *stubReader) GetRegion(_ context.Context, _ int32) (*api.Region, error)
func (s *stubReader) GetRegionBySlug(_ context.Context, _ string) (*api.Region, error) {
return nil, nil
}
func (s *stubReader) GetScopeNames(_ context.Context) ([]string, error) { return nil, nil }
func (s *stubReader) GetScopeStats(_ context.Context) ([]api.ScopeStats, error) { return nil, nil }
func (s *stubReader) GetScopeNames(_ context.Context) ([]string, error) { return nil, nil }
func (s *stubReader) GetScopeStats(_ context.Context, _ []string) ([]api.ScopeStats, error) {
return nil, nil
}
func (s *stubReader) GetScopesByIATAs(_ context.Context, _ []string) ([]api.ScopeSummary, error) {
return nil, nil
}
+10 -3
View File
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
@@ -141,9 +142,15 @@ func (cr *CachedReader) GetScopeNames(ctx context.Context) ([]string, error) {
}
// GetScopeStats implements [api.Reader].
func (cr *CachedReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
return getOrSet(ctx, cr.c, keyScopeStats, cr.ttl.Reference, func() ([]api.ScopeStats, error) {
return cr.inner.GetScopeStats(ctx)
func (cr *CachedReader) GetScopeStats(ctx context.Context, iatas []string) ([]api.ScopeStats, error) {
segment := "all"
if len(iatas) > 0 {
sorted := append([]string(nil), iatas...)
sort.Strings(sorted)
segment = strings.Join(slices.Compact(sorted), ",")
}
return getOrSet(ctx, cr.c, keyScopeStats+":"+segment, cr.ttl.Reference, func() ([]api.ScopeStats, error) {
return cr.inner.GetScopeStats(ctx, iatas)
})
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package cache
import (
"context"
"slices"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
type scopeStatsReader struct {
api.Reader
calls int64
}
func (r *scopeStatsReader) GetScopeStats(context.Context, []string) ([]api.ScopeStats, error) {
r.calls++
return []api.ScopeStats{{Name: "#test", PacketCount: r.calls}}, nil
}
func TestScopeStatsCacheSeparatesIATAs(t *testing.T) {
c, _ := newTestClient(t)
inner := &scopeStatsReader{}
reader := NewCachedReader(inner, c, CacheTTLs{Reference: time.Minute})
for _, tc := range []struct {
iatas []string
want int64
}{
{nil, 1},
{[]string{"YVR"}, 2},
{[]string{"YYJ"}, 3},
{[]string{"YYJ", "YVR"}, 4},
{[]string{"YVR", "YYJ", "YVR"}, 4},
{[]string{"YVR"}, 2},
{[]string{}, 1},
{[]string{"ZZZ"}, 5},
{[]string{""}, 6},
{nil, 1},
} {
before := slices.Clone(tc.iatas)
rows, err := reader.GetScopeStats(context.Background(), tc.iatas)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].PacketCount != tc.want {
t.Fatalf("IATAs %v: got %+v, want cached count %d", tc.iatas, rows, tc.want)
}
if !slices.Equal(before, tc.iatas) {
t.Fatalf("caller IATAs mutated: %v -> %v", before, tc.iatas)
}
}
if inner.calls != 6 {
t.Fatalf("underlying calls = %d, want 6", inner.calls)
}
}