From d8371f04d09c4a1f6f6bc269472b29df288cf9de Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Fri, 24 Jul 2026 13:49:40 -0700 Subject: [PATCH] feat: split top adverts into flood/direct closes: #90 --- .../021_mv_top_advertisers_route_type.sql | 32 +++++++++++++ db/queries/queries.sql | 2 + db/sqlc/models.go | 16 ++++--- db/sqlc/queries.sql.go | 18 +++++--- db/stats.go | 16 ++++--- db/stats_test.go | 45 +++++++++++++++++++ docs/docs.go | 7 +++ docs/swagger.json | 7 +++ docs/swagger.yaml | 9 ++++ internal/api/stats.go | 8 +++- 10 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 db/migrations/021_mv_top_advertisers_route_type.sql diff --git a/db/migrations/021_mv_top_advertisers_route_type.sql b/db/migrations/021_mv_top_advertisers_route_type.sql new file mode 100644 index 0000000..a2ac09b --- /dev/null +++ b/db/migrations/021_mv_top_advertisers_route_type.sql @@ -0,0 +1,32 @@ +-- Copyright 2026 Beacon Contributors +-- SPDX-License-Identifier: AGPL-3.0-or-later + +-- Splits mv_top_advertisers_by_iata's advert_count into flood- and direct-routed counts, +-- alongside the existing combined total, so /stats/top-advertisers can report how a node is +-- actually being heard rather than only a single blended number. +-- +-- Flood = route_type 0 (transport_flood) or 1 (flood) -- broadcast, no prior path needed. +-- Direct = route_type 2 (direct) or 3 (transport_direct) -- routed along a known path. + +DROP MATERIALIZED VIEW mv_top_advertisers_by_iata; + +CREATE MATERIALIZED VIEW mv_top_advertisers_by_iata AS +SELECT + po.iata, + n.id AS node_id, + n.name, + n.node_type, + date_trunc('hour', po.heard_at)::timestamptz AS bucket, + COUNT(DISTINCT p.packet_hash) AS advert_count, + COUNT(DISTINCT p.packet_hash) FILTER (WHERE p.route_type IN (0, 1)) AS flood_advert_count, + COUNT(DISTINCT p.packet_hash) FILTER (WHERE p.route_type IN (2, 3)) AS direct_advert_count, + MAX(po.heard_at) AS last_heard +FROM packets p +JOIN packet_observations po ON po.packet_hash = p.packet_hash +JOIN nodes n ON n.public_key = p.origin_pubkey +WHERE p.payload_type = 4 -- ADVERT + AND po.heard_at > NOW() - INTERVAL '30 days' +GROUP BY po.iata, n.id, n.name, n.node_type, date_trunc('hour', po.heard_at); + +CREATE UNIQUE INDEX idx_mv_top_advertisers + ON mv_top_advertisers_by_iata(iata, node_id, bucket); diff --git a/db/queries/queries.sql b/db/queries/queries.sql index b2253b3..a206cd6 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -902,6 +902,8 @@ SELECT name, node_type, COALESCE(SUM(advert_count), 0)::bigint AS advert_count, + COALESCE(SUM(flood_advert_count), 0)::bigint AS flood_advert_count, + COALESCE(SUM(direct_advert_count), 0)::bigint AS direct_advert_count, MAX(last_heard)::timestamptz AS last_heard, COALESCE(MAX(iata), '')::bpchar AS iata FROM mv_top_advertisers_by_iata diff --git a/db/sqlc/models.go b/db/sqlc/models.go index a91f545..af80eeb 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -90,13 +90,15 @@ type MvRadioPreset struct { } type MvTopAdvertisersByIatum struct { - Iata string `json:"iata"` - NodeID uuid.UUID `json:"node_id"` - Name *string `json:"name"` - NodeType int16 `json:"node_type"` - Bucket pgtype.Timestamptz `json:"bucket"` - AdvertCount int64 `json:"advert_count"` - LastHeard interface{} `json:"last_heard"` + Iata string `json:"iata"` + NodeID uuid.UUID `json:"node_id"` + Name *string `json:"name"` + NodeType int16 `json:"node_type"` + Bucket pgtype.Timestamptz `json:"bucket"` + AdvertCount int64 `json:"advert_count"` + FloodAdvertCount int64 `json:"flood_advert_count"` + DirectAdvertCount int64 `json:"direct_advert_count"` + LastHeard interface{} `json:"last_heard"` } type MvTopNodesByIatum struct { diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 4156701..7d64413 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -1277,6 +1277,8 @@ SELECT name, node_type, COALESCE(SUM(advert_count), 0)::bigint AS advert_count, + COALESCE(SUM(flood_advert_count), 0)::bigint AS flood_advert_count, + COALESCE(SUM(direct_advert_count), 0)::bigint AS direct_advert_count, MAX(last_heard)::timestamptz AS last_heard, COALESCE(MAX(iata), '')::bpchar AS iata FROM mv_top_advertisers_by_iata @@ -1294,12 +1296,14 @@ type GetStatsTopAdvertisersParams struct { } type GetStatsTopAdvertisersRow struct { - ID uuid.UUID `json:"id"` - Name *string `json:"name"` - NodeType int16 `json:"node_type"` - AdvertCount int64 `json:"advert_count"` - LastHeard pgtype.Timestamptz `json:"last_heard"` - Iata string `json:"iata"` + ID uuid.UUID `json:"id"` + Name *string `json:"name"` + NodeType int16 `json:"node_type"` + AdvertCount int64 `json:"advert_count"` + FloodAdvertCount int64 `json:"flood_advert_count"` + DirectAdvertCount int64 `json:"direct_advert_count"` + LastHeard pgtype.Timestamptz `json:"last_heard"` + Iata string `json:"iata"` } // Top N advertisers in the window, summed from the hourly buckets. @@ -1317,6 +1321,8 @@ func (q *Queries) GetStatsTopAdvertisers(ctx context.Context, arg GetStatsTopAdv &i.Name, &i.NodeType, &i.AdvertCount, + &i.FloodAdvertCount, + &i.DirectAdvertCount, &i.LastHeard, &i.Iata, ); err != nil { diff --git a/db/stats.go b/db/stats.go index 78c7984..75f9311 100644 --- a/db/stats.go +++ b/db/stats.go @@ -146,13 +146,15 @@ func (s *Store) GetStatsTopAdvertisers(ctx context.Context, iatas []string, sinc items := make([]api.TopAdvertiser, 0, len(rows)) for _, v := range rows { items = append(items, api.TopAdvertiser{ - NodeID: v.ID, - NodeName: v.Name, - NodeType: v.NodeType, - NodeTypeName: api.NodeTypeName(v.NodeType), - IATA: v.Iata, - AdvertCount: v.AdvertCount, - LastHeard: v.LastHeard.Time.UnixMilli(), + NodeID: v.ID, + NodeName: v.Name, + NodeType: v.NodeType, + NodeTypeName: api.NodeTypeName(v.NodeType), + IATA: v.Iata, + AdvertCount: v.AdvertCount, + FloodAdvertCount: v.FloodAdvertCount, + DirectAdvertCount: v.DirectAdvertCount, + LastHeard: v.LastHeard.Time.UnixMilli(), }) } return items, nil diff --git a/db/stats_test.go b/db/stats_test.go index fec74d1..a7c56fb 100644 --- a/db/stats_test.go +++ b/db/stats_test.go @@ -114,6 +114,51 @@ func TestGetStatsTopObservers_IATATypeAssertion(t *testing.T) { } } +func TestGetStatsTopAdvertisers_FloodDirectSplit(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000003") + name := "test-node" + heardAt := pgtype.Timestamptz{Time: time.Now(), Valid: true} + + mock.EXPECT(). + GetStatsTopAdvertisers(gomock.Any(), gomock.Any()). + Return([]sqlc.GetStatsTopAdvertisersRow{ + { + ID: nodeID, + Name: &name, + NodeType: 2, // repeater + AdvertCount: 10, + FloodAdvertCount: 7, + DirectAdvertCount: 3, + LastHeard: heardAt, + Iata: "YVR", + }, + }, nil) + + store := &Store{q: mock} + items, err := store.GetStatsTopAdvertisers(context.Background(), []string{"YVR"}, time.Now().Add(-time.Hour), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].AdvertCount != 10 { + t.Errorf("expected AdvertCount 10, got %d", items[0].AdvertCount) + } + if items[0].FloodAdvertCount != 7 { + t.Errorf("expected FloodAdvertCount 7, got %d", items[0].FloodAdvertCount) + } + if items[0].DirectAdvertCount != 3 { + t.Errorf("expected DirectAdvertCount 3, got %d", items[0].DirectAdvertCount) + } + if items[0].FloodAdvertCount+items[0].DirectAdvertCount != items[0].AdvertCount { + t.Error("expected FloodAdvertCount + DirectAdvertCount to equal AdvertCount") + } +} + func TestGetStatsNodeTypes_Mapping(t *testing.T) { ctrl := gomock.NewController(t) mock := mockdb.NewMockQuerier(ctrl) diff --git a/docs/docs.go b/docs/docs.go index 347a251..9a6fdef 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3559,6 +3559,13 @@ const docTemplate = `{ "advertCount": { "type": "integer" }, + "directAdvertCount": { + "type": "integer" + }, + "floodAdvertCount": { + "description": "FloodAdvertCount/DirectAdvertCount split AdvertCount by how the advert was routed:\nflood = route type 0 (transport_flood) or 1 (flood), broadcast with no known path;\ndirect = route type 2 (direct) or 3 (transport_direct), routed along a known path.\nFloodAdvertCount + DirectAdvertCount == AdvertCount.", + "type": "integer" + }, "iata": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index aa87673..325b8ff 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3557,6 +3557,13 @@ "advertCount": { "type": "integer" }, + "directAdvertCount": { + "type": "integer" + }, + "floodAdvertCount": { + "description": "FloodAdvertCount/DirectAdvertCount split AdvertCount by how the advert was routed:\nflood = route type 0 (transport_flood) or 1 (flood), broadcast with no known path;\ndirect = route type 2 (direct) or 3 (transport_direct), routed along a known path.\nFloodAdvertCount + DirectAdvertCount == AdvertCount.", + "type": "integer" + }, "iata": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d0914d7..456ee23 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -934,6 +934,15 @@ definitions: properties: advertCount: type: integer + directAdvertCount: + type: integer + floodAdvertCount: + description: |- + FloodAdvertCount/DirectAdvertCount split AdvertCount by how the advert was routed: + flood = route type 0 (transport_flood) or 1 (flood), broadcast with no known path; + direct = route type 2 (direct) or 3 (transport_direct), routed along a known path. + FloodAdvertCount + DirectAdvertCount == AdvertCount. + type: integer iata: type: string lastHeard: diff --git a/internal/api/stats.go b/internal/api/stats.go index 714c95d..7f51a21 100644 --- a/internal/api/stats.go +++ b/internal/api/stats.go @@ -76,7 +76,13 @@ type TopAdvertiser struct { NodeTypeName string `json:"nodeTypeName"` IATA string `json:"iata"` AdvertCount int64 `json:"advertCount"` - LastHeard int64 `json:"lastHeard"` // epoch ms + // FloodAdvertCount/DirectAdvertCount split AdvertCount by how the advert was routed: + // flood = route type 0 (transport_flood) or 1 (flood), broadcast with no known path; + // direct = route type 2 (direct) or 3 (transport_direct), routed along a known path. + // FloodAdvertCount + DirectAdvertCount == AdvertCount. + FloodAdvertCount int64 `json:"floodAdvertCount"` + DirectAdvertCount int64 `json:"directAdvertCount"` + LastHeard int64 `json:"lastHeard"` // epoch ms } // TopTalker is a companion name ranked by decrypted channel message count within the