feat: split top adverts into flood/direct

closes: #90
This commit is contained in:
Enot (ded) Skelly
2026-07-24 13:49:40 -07:00
parent 3799242bce
commit d8371f04d0
10 changed files with 139 additions and 21 deletions
@@ -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);
+2
View File
@@ -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
+9 -7
View File
@@ -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 {
+12 -6
View File
@@ -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 {
+9 -7
View File
@@ -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
+45
View File
@@ -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)
+7
View File
@@ -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"
},
+7
View File
@@ -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"
},
+9
View File
@@ -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:
+7 -1
View File
@@ -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