Files
MrAlders0n 7650d9b742 feat(observers): per-observer activity endpoint
GET /observers/{id}/activity returns bucketed observation counts, LoRa
airtime, SNR/RSSI aggregates and a payload-type breakdown for one
observer. Airtime is computed at ingest from the raw frame length and
stored on the observation; intervals of 1h and up are served from an
hourly matview so no request path joins back to packets.
2026-09-10 06:37:45 -04:00

138 lines
4.2 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package background
import (
"context"
"errors"
"fmt"
"time"
"github.com/MeshCore-Beacon/beacon-server/db"
"github.com/google/uuid"
)
type viewRefresher interface {
RefreshHourlyStats(context.Context) error
RefreshTopNodes(context.Context) error
RefreshTopObservers(context.Context) error
RefreshPayloadBreakdown(context.Context) error
RefreshTopTalkers(context.Context) error
RefreshTopAdvertisers(context.Context) error
RefreshRadioPresets(context.Context) error
RefreshObserverActivity(context.Context) error
}
// ViewRefreshTask returns a Task that refreshes all materialized views.
func ViewRefreshTask(store viewRefresher, interval time.Duration) Task {
return Task{
Name: "view_refresh",
Interval: interval,
Run: func(ctx context.Context) error {
var errs []error
for _, view := range []struct {
name string
refresh func(context.Context) error
}{
{"hourly stats", store.RefreshHourlyStats},
{"top nodes", store.RefreshTopNodes},
{"top observers", store.RefreshTopObservers},
{"payload breakdown", store.RefreshPayloadBreakdown},
{"top talkers", store.RefreshTopTalkers},
{"top advertisers", store.RefreshTopAdvertisers},
{"radio presets", store.RefreshRadioPresets},
{"observer activity", store.RefreshObserverActivity},
} {
if err := view.refresh(ctx); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", view.name, err))
}
}
return errors.Join(errs...)
},
}
}
// CleanupTask returns a Task that prunes old telemetry, packet, and node rows.
func CleanupTask(store *db.Store, telemetryRetention, packetRetention, nodeDeleteAfter, interval time.Duration) Task {
return Task{
Name: "cleanup",
Interval: interval,
Run: func(ctx context.Context) error {
if err := store.DeleteOldTelemetry(ctx, time.Now().Add(-telemetryRetention)); err != nil {
return err
}
// One cutoff for all three so the IATA tables stay in step
// with the packets they mirror.
cutoff := time.Now().Add(-packetRetention)
if err := store.DeleteOldPackets(ctx, cutoff); err != nil {
return err
}
if err := store.DeleteOldChannelIATAs(ctx, cutoff); err != nil {
return err
}
if err := store.DeleteOldTraceIATAs(ctx, cutoff); err != nil {
return err
}
if err := store.DeleteOldNodes(ctx, time.Now().Add(-nodeDeleteAfter)); err != nil {
return err
}
return nil
},
}
}
// reconfirmBatchSize bounds per-tick reconfirm work; at hourly ticks a 16M-row
// table gets fully re-checked roughly daily.
const reconfirmBatchSize = 750_000
type observerCleaner interface {
DeleteOldObservers(context.Context, time.Time) ([]uuid.UUID, error)
}
// ObserverCleanupTask ages out unreferenced observers through the presence
// coalescer, then invalidates any cached details for the deleted IDs.
func ObserverCleanupTask(store observerCleaner, deleteAfter, interval time.Duration, onDelete func(context.Context, uuid.UUID)) Task {
return Task{
Name: "observer_cleanup", Interval: interval,
Run: func(ctx context.Context) error {
if deleteAfter <= 0 {
return nil
}
ids, err := store.DeleteOldObservers(ctx, time.Now().Add(-deleteAfter))
if err != nil {
return err
}
if onDelete != nil {
for _, id := range ids {
onDelete(ctx, id)
}
}
return nil
},
}
}
// ReconfirmTask returns a Task that prunes aged routes first, then reconfirms
// stale and ambiguous resolved paths and neighbors, so known_routes only ever
// has one writer at a time.
func ReconfirmTask(store *db.Store, routeRetention, routeGrace time.Duration, routeMinObservations int64, interval time.Duration) Task {
return Task{
Name: "reconfirm",
Interval: interval,
Run: func(ctx context.Context) error {
now := time.Now()
if err := store.DeleteOldRoutes(ctx, now.Add(-routeRetention), routeMinObservations, now.Add(-routeGrace)); err != nil {
return fmt.Errorf("route retention: %w", err)
}
if err := store.ReconfirmRoutes(ctx, reconfirmBatchSize); err != nil {
return fmt.Errorf("routes: %w", err)
}
if err := store.ReconfirmNeighbors(ctx); err != nil {
return fmt.Errorf("neighbors: %w", err)
}
return nil
},
}
}