mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-19 00:10:30 +00:00
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.
249 lines
7.8 KiB
Go
249 lines
7.8 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package background
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"log/slog"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type refreshStub struct {
|
|
calls []string
|
|
errs map[string]error
|
|
}
|
|
|
|
type observerCleanupStub struct {
|
|
ids []uuid.UUID
|
|
err error
|
|
calls int
|
|
cutoff time.Time
|
|
}
|
|
|
|
func (s *observerCleanupStub) DeleteOldObservers(ctx context.Context, cutoff time.Time) ([]uuid.UUID, error) {
|
|
s.calls++
|
|
s.cutoff = cutoff
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.ids, s.err
|
|
}
|
|
|
|
func TestObserverCleanupTask(t *testing.T) {
|
|
failure := errors.New("cleanup unavailable")
|
|
for _, tc := range []struct {
|
|
name string
|
|
retention time.Duration
|
|
err error
|
|
cancelled bool
|
|
}{
|
|
{"disabled", 0, nil, false},
|
|
{"negative disables", -time.Hour, nil, false},
|
|
{"enabled", 24 * time.Hour, nil, false},
|
|
{"failure", 24 * time.Hour, failure, false},
|
|
{"cancelled", 24 * time.Hour, context.Canceled, true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
store := &observerCleanupStub{ids: []uuid.UUID{uuid.New(), uuid.New()}, err: tc.err}
|
|
var invalidated []uuid.UUID
|
|
task := ObserverCleanupTask(store, tc.retention, time.Minute, func(_ context.Context, id uuid.UUID) {
|
|
invalidated = append(invalidated, id)
|
|
})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
if tc.cancelled {
|
|
cancel()
|
|
}
|
|
before := time.Now().Add(-tc.retention)
|
|
err := task.Run(ctx)
|
|
if !errors.Is(err, tc.err) || task.Name != "observer_cleanup" || task.Interval != time.Minute {
|
|
t.Fatalf("unexpected task outcome: %v, %+v", err, task)
|
|
}
|
|
if tc.retention <= 0 {
|
|
if store.calls != 0 || len(invalidated) != 0 {
|
|
t.Fatal("disabled retention performed work")
|
|
}
|
|
return
|
|
}
|
|
if store.calls != 1 || store.cutoff.Before(before) || store.cutoff.After(time.Now().Add(-tc.retention)) {
|
|
t.Fatal("incorrect cleanup cutoff or number of calls")
|
|
}
|
|
if tc.err != nil {
|
|
if len(invalidated) != 0 {
|
|
t.Fatal("failed cleanup invalidated cache entries")
|
|
}
|
|
} else if len(invalidated) != 2 || invalidated[0] != store.ids[0] || invalidated[1] != store.ids[1] {
|
|
t.Fatal("deleted observers were not invalidated")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *refreshStub) refresh(ctx context.Context, name string) error {
|
|
s.calls = append(s.calls, name)
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return s.errs[name]
|
|
}
|
|
func (s *refreshStub) RefreshRadioPresets(ctx context.Context) error {
|
|
return s.refresh(ctx, "radio presets")
|
|
}
|
|
|
|
func TestViewRefreshTask(t *testing.T) {
|
|
failure := errors.New("refresh failed")
|
|
for _, tc := range []struct {
|
|
name string
|
|
errs map[string]error
|
|
}{
|
|
{"success", nil},
|
|
{"failure", map[string]error{"radio presets": failure}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
store := &refreshStub{errs: tc.errs}
|
|
err := ViewRefreshTask(store, time.Minute).Run(context.Background())
|
|
if len(store.calls) != 1 {
|
|
t.Fatalf("refreshed %d views, want 1", len(store.calls))
|
|
}
|
|
if len(tc.errs) == 0 && err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tc.errs) > 0 && (!errors.Is(err, failure) || !strings.Contains(err.Error(), "radio presets")) {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestViewRefreshTaskCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
err := ViewRefreshTask(&refreshStub{}, time.Minute).Run(ctx)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("got %v, want cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestSchedulerFailureIsNotComplete(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
previous, writer, flags := slog.Default(), log.Writer(), log.Flags()
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&out, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
defer func() { slog.SetDefault(previous); log.SetOutput(writer); log.SetFlags(flags) }()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
New([]Task{{Name: "fails", Interval: time.Millisecond, Run: func(context.Context) error { cancel(); return errors.New("refresh unavailable") }}}).Start(ctx)
|
|
time.Sleep(2 * time.Millisecond) // advance the virtual clock to the first task tick
|
|
synctest.Wait()
|
|
failed := false
|
|
for _, line := range bytes.Split(bytes.TrimSpace(out.Bytes()), []byte("\n")) {
|
|
var record map[string]any
|
|
if err := json.Unmarshal(line, &record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if record["msg"] == "task complete" {
|
|
t.Fatal("failed task reported complete")
|
|
}
|
|
if record["msg"] == "task failed" {
|
|
failed = true
|
|
if record["level"] != "ERROR" || record["error"] != "refresh unavailable" || record["task"] != "fails" {
|
|
t.Fatalf("incorrect failure: %v", record)
|
|
}
|
|
}
|
|
}
|
|
if !failed {
|
|
t.Fatal("scheduler did not report task failure")
|
|
}
|
|
})
|
|
}
|
|
|
|
type cleanupStub struct {
|
|
calls []string
|
|
cutoffs map[string]time.Time
|
|
oldest time.Time // zero = no missing hour
|
|
}
|
|
|
|
func (s *cleanupStub) record(name string, cutoff time.Time) error {
|
|
s.calls = append(s.calls, name)
|
|
if s.cutoffs == nil {
|
|
s.cutoffs = map[string]time.Time{}
|
|
}
|
|
s.cutoffs[name] = cutoff
|
|
return nil
|
|
}
|
|
func (s *cleanupStub) DeleteOldTelemetry(_ context.Context, c time.Time) error {
|
|
return s.record("telemetry", c)
|
|
}
|
|
func (s *cleanupStub) OldestMissingRollupHour(context.Context) (time.Time, bool, error) {
|
|
s.calls = append(s.calls, "oldest")
|
|
return s.oldest, !s.oldest.IsZero(), nil
|
|
}
|
|
func (s *cleanupStub) DeleteOldPackets(_ context.Context, c time.Time) error {
|
|
return s.record("packets", c)
|
|
}
|
|
func (s *cleanupStub) DeleteOldChannelIATAs(_ context.Context, c time.Time) error {
|
|
return s.record("channel iatas", c)
|
|
}
|
|
func (s *cleanupStub) DeleteOldTraceIATAs(_ context.Context, c time.Time) error {
|
|
return s.record("trace iatas", c)
|
|
}
|
|
func (s *cleanupStub) DeleteOldTraceTags(_ context.Context, c time.Time) error {
|
|
return s.record("trace tags", c)
|
|
}
|
|
func (s *cleanupStub) DeleteOldRollups(_ context.Context, c time.Time) error {
|
|
return s.record("rollups", c)
|
|
}
|
|
func (s *cleanupStub) DeleteOldNodes(_ context.Context, c time.Time) error {
|
|
return s.record("nodes", c)
|
|
}
|
|
|
|
func TestCleanupTask(t *testing.T) {
|
|
now := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC)
|
|
cfg := CleanupConfig{TelemetryRetention: 31 * 24 * time.Hour, PacketRetention: 7 * 24 * time.Hour,
|
|
RollupRetention: 90 * 24 * time.Hour, NodeDeleteAfter: 30 * 24 * time.Hour, Interval: time.Hour}
|
|
retention := now.Add(-cfg.PacketRetention)
|
|
for _, tc := range []struct {
|
|
name string
|
|
oldest time.Time
|
|
want time.Time
|
|
}{
|
|
{"no unrolled hours", time.Time{}, retention},
|
|
{"unrolled hour after the cutoff", retention.Add(time.Hour), retention},
|
|
{"held behind an unrolled hour", retention.Add(-2 * time.Hour), retention.Add(-2*time.Hour - 35*time.Minute)},
|
|
{"holdback capped", retention.Add(-72 * time.Hour), retention.Add(-24 * time.Hour)},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := &cleanupStub{oldest: tc.oldest}
|
|
if err := cleanupTask(s, cfg, func() time.Time { return now }).Run(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"telemetry", "oldest", "packets", "channel iatas", "trace iatas", "trace tags", "rollups", "nodes"}
|
|
if !reflect.DeepEqual(s.calls, want) {
|
|
t.Errorf("calls %v, want %v", s.calls, want)
|
|
}
|
|
for _, name := range []string{"packets", "channel iatas", "trace iatas"} {
|
|
if !s.cutoffs[name].Equal(tc.want) {
|
|
t.Errorf("%s cutoff %s, want %s", name, s.cutoffs[name], tc.want)
|
|
}
|
|
}
|
|
if !s.cutoffs["trace tags"].Equal(tc.want.Add(-30 * time.Minute)) {
|
|
t.Errorf("trace tags cutoff %s, want 30 min behind packets", s.cutoffs["trace tags"])
|
|
}
|
|
if !s.cutoffs["rollups"].Equal(now.Add(-cfg.RollupRetention)) || !s.cutoffs["telemetry"].Equal(now.Add(-cfg.TelemetryRetention)) {
|
|
t.Errorf("rollup/telemetry cutoffs %v", s.cutoffs)
|
|
}
|
|
})
|
|
}
|
|
}
|