mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-17 13:40:05 +00:00
GET /traces takes an optional cursorTag so tags sharing a millisecond survive paging. Migrations run under an advisory lock and record each file in the same implicit transaction, so a crash can't strand a fresh database. Overview windowHours counts the hours it covers, hourly observer activity fills unrolled hours from raw rows, and integration fixtures are UTC.
136 lines
6.5 KiB
Go
136 lines
6.5 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
package db
|
|
|
|
import (
|
|
"encoding/json"
|
|
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Hourly activity reads the rollup, but the last ~2h aren't rolled yet; they come from raw rows.
|
|
func TestObserverActivityUnrolledTailPostgres(t *testing.T) {
|
|
ctx, tx := retentionTx(t)
|
|
applyBaseline(t, ctx, tx)
|
|
now := time.Now().UTC()
|
|
rolled := now.Truncate(time.Hour).Add(-5 * time.Hour)
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO observers (id,public_key) VALUES ('00000000-0000-0000-0000-000000000001','\x01');
|
|
INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,raw_payload,raw_header,last_heard_at,first_heard_at) SELECT int4send(i),4,0,1,'\x00','\x00',NOW(),NOW() FROM generate_series(1,4) i;`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count,payload_type,snr,rssi) VALUES
|
|
(int4send(1),'00000000-0000-0000-0000-000000000001','YOW',$1::timestamptz+interval '10 minutes',0,1,0,4,5,-90),
|
|
(int4send(2),'00000000-0000-0000-0000-000000000001','YOW',$1::timestamptz+interval '20 minutes',0,1,0,4,7,-80);`, rolled); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rollTxHours(t, ctx, tx)
|
|
// Heard since the newest rolled hour; the rollup hasn't reached them.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count,payload_type,snr,rssi) VALUES
|
|
(int4send(3),'00000000-0000-0000-0000-000000000001','YOW',$1::timestamptz-interval '100 minutes',0,1,0,4,9,-70),
|
|
(int4send(4),'00000000-0000-0000-0000-000000000001','YOW',$1::timestamptz-interval '5 minutes',0,1,0,NULL,0,0);`, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := &Store{q: sqlc.New(tx)}
|
|
id := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
activity, err := store.GetObserverActivity(ctx, id, 24*time.Hour, time.Hour, time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if activity.Summary.RecordedPackets != 4 {
|
|
t.Errorf("recorded packets = %d, want 4 (2 rolled + 2 unrolled)", activity.Summary.RecordedPackets)
|
|
}
|
|
byHour := map[int64]int64{}
|
|
for _, p := range activity.Points {
|
|
byHour[p.T] = p.Observations
|
|
}
|
|
recent := now.Add(-100 * time.Minute).Truncate(time.Hour).UnixMilli()
|
|
if byHour[rolled.UnixMilli()] != 2 || byHour[recent] != 1 || byHour[now.Add(-5*time.Minute).Truncate(time.Hour).UnixMilli()] != 1 {
|
|
t.Errorf("points %+v", activity.Points)
|
|
}
|
|
for _, p := range activity.Points {
|
|
if p.T == recent && (p.SNRAvg == nil || *p.SNRAvg != 9 || p.RSSIAvg == nil || *p.RSSIAvg != -70) {
|
|
t.Errorf("tail signal: %+v", p)
|
|
}
|
|
}
|
|
types := map[int16]int64{}
|
|
for _, v := range activity.PayloadTypes {
|
|
types[v.PayloadType] = v.Count
|
|
}
|
|
if types[4] != 3 || types[-1] != 1 {
|
|
t.Errorf("payload types %+v", activity.PayloadTypes)
|
|
}
|
|
}
|
|
|
|
func TestObserverMetricsPostgres(t *testing.T) {
|
|
ctx, tx := retentionTx(t)
|
|
applyBaseline(t, ctx, tx)
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO observers (id,public_key) VALUES ('00000000-0000-0000-0000-000000000001','\x01');
|
|
INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,raw_payload,raw_header,last_heard_at,first_heard_at) SELECT int4send(i),4,0,1,'\x00','\x00',NOW()-interval '5 days',NOW()-interval '5 days' FROM generate_series(1,3) i;
|
|
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count,payload_type,snr,rssi)
|
|
SELECT packet_hash,'00000000-0000-0000-0000-000000000001','YOW',date_trunc('hour',NOW())-interval '5 days',0,1,0,CASE WHEN packet_hash=int4send(1) THEN 4 END,CASE WHEN packet_hash IN (int4send(1),int4send(2)) THEN 'NaN'::real ELSE 0 END,-100 FROM packets;
|
|
`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := &Store{q: sqlc.New(tx)}
|
|
id := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
// Hourly activity is rolled before cleanup and survives the raw rows.
|
|
rollTxHours(t, ctx, tx)
|
|
if err := store.DeleteOldPackets(ctx, time.Now().Add(-72*time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
until := time.Now().UTC().Truncate(time.Hour)
|
|
activity, err := store.GetObserverActivity(ctx, id, 7*24*time.Hour, time.Hour, until)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := json.Marshal(activity); err != nil {
|
|
t.Fatalf("non-finite sample leaked into JSON: %v", err)
|
|
}
|
|
if activity.Summary.RecordedPackets != 3 || len(activity.PayloadTypes) != 2 || activity.Summary.LatestRecordedAt != nil || activity.Summary.LastCompleteHour != 0 {
|
|
t.Fatalf("archive summary: %+v types=%+v", activity.Summary, activity.PayloadTypes)
|
|
}
|
|
if activity.WindowEnd != until.UnixMilli() || activity.WindowStart != until.Add(-7*24*time.Hour).UnixMilli() || activity.Source != "hourly" {
|
|
t.Fatalf("window: %+v", activity)
|
|
}
|
|
if _, err := tx.Exec(ctx, `INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,raw_payload,raw_header,first_heard_at,last_heard_at) VALUES ('\xaa',4,0,1,'\x00','\x00',NOW(),NOW());
|
|
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count,snr,rssi) VALUES ('\xaa','00000000-0000-0000-0000-000000000001','YOW',date_trunc('hour',NOW())-interval '30 minutes',0,1,0,'NaN'::real,-100);
|
|
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count) VALUES ('\xaa','00000000-0000-0000-0000-000000000001','YOW',NOW(),0,1,0) ON CONFLICT DO NOTHING;`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := store.GetObserverActivity(ctx, id, time.Hour, 15*time.Minute, until)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := json.Marshal(raw); err != nil {
|
|
t.Fatalf("raw non-finite sample leaked into JSON: %v", err)
|
|
}
|
|
if raw.Summary.RecordedPackets != 1 || raw.Summary.LastCompleteHour != 1 || raw.Summary.LatestRecordedAt == nil || raw.PayloadTypes[0].PayloadType != -1 {
|
|
t.Fatalf("raw/duplicate: %+v", raw)
|
|
}
|
|
if err := store.UpsertObserverBroker(ctx, id, "one", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var packet pgtype.Timestamptz
|
|
tx.QueryRow(ctx, "SELECT last_packet_at FROM observer_brokers").Scan(&packet)
|
|
if packet.Valid {
|
|
t.Fatal("status created a packet timestamp")
|
|
}
|
|
if err := store.UpsertObserverBroker(ctx, id, "one", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.TouchObserverBrokers(ctx, []uuid.UUID{id}, []string{"one"}, []time.Time{until.Add(time.Hour)}, []time.Time{{}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tx.QueryRow(ctx, "SELECT last_packet_at FROM observer_brokers").Scan(&packet)
|
|
if !packet.Valid {
|
|
t.Fatal("status erased packet timestamp")
|
|
}
|
|
}
|