Files
beacon-server/db/stats_series_integration_test.go
MrAlders0n 98006a9364 feat(stats): serve historical stats from hourly rollups
Stats endpoints read hourly rollup tables filled once per closed hour by one
advisory-locked task, instead of materialized views that re-scanned every
observation on each refresh. IATA-set tables give exact distinct counts for any
IATA set or region. Cleanup holds raw rows for unrolled hours (up to 24h).

Adds /stats/series; ingest keeps packet observation counts, observer last IATA,
channel message counts and trace tag summaries. 001_baseline.sql is rewritten,
so pre-release 2.0 databases must be recreated. See docs/historical-stats.md.
2026-10-02 07:43:56 -04:00

127 lines
4.4 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"testing"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
// seriesOracle computes a summary straight from raw rows over [since, until).
const seriesOracle = `
WITH po AS (
SELECT po.*, date_trunc('hour', po.heard_at, 'UTC') AS hour, p.scope_id
FROM packet_observations po JOIN packets p ON p.packet_hash = po.packet_hash
WHERE po.heard_at >= $1 AND po.heard_at < $2
AND (cardinality($3::bpchar[]) = 0 OR po.iata = ANY($3::bpchar[]))
AND date_trunc('hour', po.heard_at, 'UTC') <> ALL($4::timestamptz[])
), sig AS (
SELECT * FROM po WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0)
)
SELECT (SELECT count(*) FROM po),
(SELECT count(*) FROM (SELECT DISTINCT hour, packet_hash FROM po) d),
(SELECT count(DISTINCT observer_id) FROM po),
(SELECT count(DISTINCT iata) FROM po),
(SELECT count(*) FROM (SELECT DISTINCT hour, packet_hash FROM po WHERE scope_id IS NOT NULL) d),
(SELECT count(DISTINCT scope_id) FROM po),
(SELECT COALESCE(sum(snr), 0)::double precision FROM sig), (SELECT count(snr) FROM sig),
(SELECT COALESCE(sum(rssi), 0)::double precision FROM sig), (SELECT count(rssi) FROM sig)`
func TestStatsSeriesPostgres(t *testing.T) {
ctx, pool := schemaPool(t)
h0 := rollupFixture(t, ctx, pool)
store := New(pool, 0, 0)
r, ok, err := store.BeginRollup(ctx)
if err != nil || !ok {
t.Fatalf("BeginRollup: ok=%v err=%v", ok, err)
}
if err := r.RegisterHours(ctx, h0); err != nil {
t.Fatal(err)
}
rollAll(t, ctx, r)
r.Close(ctx)
since, until := h0, h0.Add(3*time.Hour)
for _, tc := range []struct {
name string
iatas []string
excluded []time.Time // hours not complete
maxPath int32
}{
{"all", nil, nil, 2},
{"one IATA", []string{"YYZ"}, nil, 1},
{"overlapping IATAs", []string{"YVR", "YYZ", "YVR"}, nil, 2},
{"empty region", []string{""}, nil, 0},
{"partial hour excluded", nil, []time.Time{h0.Add(time.Hour)}, 2},
} {
t.Run(tc.name, func(t *testing.T) {
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(ctx)
for _, h := range tc.excluded {
if _, err := tx.Exec(ctx, "UPDATE analytics_rollup_hours SET status = 'partial' WHERE hour = $1", h); err != nil {
t.Fatal(err)
}
}
got, err := (&Store{q: sqlc.New(tx)}).GetStatsSeries(ctx, since, until, tc.iatas)
if err != nil {
t.Fatal(err)
}
var want api.StatsSeriesValues
excluded := tc.excluded
if excluded == nil {
excluded = []time.Time{}
}
iatas := tc.iatas
if iatas == nil {
iatas = []string{}
}
if err := tx.QueryRow(ctx, seriesOracle, since, until, iatas, excluded).Scan(&want.Observations, &want.UniquePackets,
&want.ActiveObservers, &want.ActiveIATAs, &want.ScopedPackets, &want.ActiveScopes,
&want.SNRSum, &want.SNRSamples, &want.RSSISum, &want.RSSISamples); err != nil {
t.Fatal(err)
}
want.MaxPathEntries = tc.maxPath
if got.Summary != want {
t.Errorf("summary\n got %+v\nwant %+v", got.Summary, want)
}
if len(got.Hours) != 3 || got.Since != since.UnixMilli() || got.Until != until.UnixMilli() || got.EarliestComplete == nil {
t.Fatalf("series shape: %+v", got)
}
var obs, packets int64
for _, h := range got.Hours {
partial := false
for _, e := range tc.excluded {
partial = partial || e.UnixMilli() == h.Hour
}
if partial != (h.Values == nil) || (partial && h.Status != "partial") {
t.Errorf("hour %d status %s values %v", h.Hour, h.Status, h.Values)
}
if h.Values != nil {
obs += h.Values.Observations
packets += h.Values.UniquePackets
}
}
// Cards and sparklines must agree for additive metrics.
if obs != got.Summary.Observations || packets != got.Summary.UniquePackets {
t.Errorf("hours sum to %d observations / %d packets, summary %d / %d", obs, packets, got.Summary.Observations, got.Summary.UniquePackets)
}
if got.CompleteHours != 3-len(tc.excluded) {
t.Errorf("complete hours %d", got.CompleteHours)
}
})
}
// Hours never registered (or not yet eligible) are missing.
got, err := store.GetStatsSeries(ctx, h0.Add(-2*time.Hour), h0, nil)
if err != nil || len(got.Hours) != 2 || got.Hours[0].Status != "missing" || got.Hours[0].Values != nil || got.CompleteHours != 0 {
t.Errorf("unregistered hours: %+v %v", got, err)
}
}