Files
meshcore-analyzer/cmd/server/node_battery_test.go
T
efitenandClaude Opus 5.5 a2b5a4931e perf(server): battery history via the primary key, cached /api/perf row counts (#2156)
Refs #2146 (items: `/api/nodes/:pubkey/battery`, `/api/perf`).

## Situation

Two endpoints ran SQL that reads more than it needs, on every request.
Timings are read-only on a production database (18.3M observations, 283k
`observer_metrics` rows), first run / second run:

| Query | Now | With this PR |
|---|---|---|
| battery history, `WHERE LOWER(observer_id) = ?` | 174 / 29 ms (plan:
`idx_observer_metrics_timestamp`) | 5.9 / 5.5 ms (plan: primary key
`(observer_id, timestamp)`) |
| `/api/perf`: `SELECT COUNT(*) FROM observations` | 118 / 116 ms on
every call | once per 60 s |

## My changes in this PR

1. **Battery history** (`5bdd3c7f`). `LOWER(observer_id) = ?` keeps
SQLite off the primary-key index, so it walked the timestamp index over
every observer's metrics in the window. The query now matches
`observer_id IN (?, ?)` with the pubkey lowercased and uppercased, the
two casings observer IDs are stored in.
2. **`/api/perf` row counts** (`e24c24cf`). The four `COUNT(*)`
(transmissions, observations, nodes, observers) are cached for 60 s.
File, WAL and freelist sizes are still read on every call.

## Tests

- `TestNodeBatteryQueryUsesObserverIndex` loads the `sqlite_stat1`
values of that database and checks the plan uses
`sqlite_autoindex_observer_metrics_1`. With the `LOWER()` form it fails
with `SEARCH observer_metrics USING INDEX idx_observer_metrics_timestamp
(timestamp>?)`.
- `TestGetNodeBatteryHistory_LowercaseStoredID`: a lowercase-stored
observer ID is still found.
- `TestGetDBSizeStatsTypedCachesRowCounts`: the cached count is served
within the TTL and refreshed after it.
- `cmd/server`: `go test ./...` green.

## Not in this PR

Other per-request queries listed in #2146 measured too small to change
on the same database: `COUNT(*) FROM dropped_packets` 0.0 ms, `SELECT
DISTINCT iata FROM observers` 0.2 ms, the observers packet-count query
1.3 ms, the 7-day active node count 0.1 ms. An observer ID stored in
mixed case would no longer match the battery query; in that database all
41 distinct `observer_metrics.observer_id` values are uppercase.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:03:56 +02:00

216 lines
7.4 KiB
Go

package main
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
)
// TestGetNodeBatteryHistory_FromObserverMetrics validates that the DB layer
// can pull a node's battery_mv time-series from observer_metrics, joining
// observers.id (uppercase hex pubkey) to nodes.public_key (lowercase hex).
func TestGetNodeBatteryHistory_FromObserverMetrics(t *testing.T) {
db := setupTestDB(t)
now := time.Now().UTC()
// node + observer with matching pubkey (cases differ on purpose)
pkLower := "deadbeefcafef00d11223344"
idUpper := strings.ToUpper(pkLower)
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen) VALUES (?, 'BatNode', 'repeater', ?, ?)`,
pkLower, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, 'BatNode', ?, ?)`,
idUpper, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
// 3 metrics samples: 3700, 3500, 3200 mV
for i, mv := range []int{3700, 3500, 3200} {
ts := now.Add(time.Duration(-2+i) * time.Hour).Format(time.RFC3339)
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`,
idUpper, ts, mv)
}
// One sample with NULL battery should be skipped
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp) VALUES (?, ?)`,
idUpper, now.Add(-3*time.Hour).Format(time.RFC3339))
since := now.Add(-24 * time.Hour).Format(time.RFC3339)
samples, err := db.GetNodeBatteryHistory(pkLower, since)
if err != nil {
t.Fatalf("GetNodeBatteryHistory: %v", err)
}
if len(samples) != 3 {
t.Fatalf("expected 3 samples, got %d", len(samples))
}
if samples[0].BatteryMv != 3700 || samples[2].BatteryMv != 3200 {
t.Errorf("samples=%+v", samples)
}
}
// TestNodeBatteryEndpoint validates the /api/nodes/{pubkey}/battery endpoint
// returns time-series data plus configured thresholds and a status flag.
func TestNodeBatteryEndpoint(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
now := time.Now().UTC()
pkLower := "aabbccdd11223344"
idUpper := strings.ToUpper(pkLower)
db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, 'TestRepeater', ?, ?)`,
idUpper, now.Format(time.RFC3339), now.Add(-72*time.Hour).Format(time.RFC3339))
for i, mv := range []int{3800, 3600, 3200} {
ts := now.Add(time.Duration(-2+i) * time.Hour).Format(time.RFC3339)
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`,
idUpper, ts, mv)
}
cfg := &Config{Port: 3000}
hub := NewHub()
srv := NewServer(db, cfg, hub)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load: %v", err)
}
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/nodes/"+pkLower+"/battery?days=7", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
samples, ok := body["samples"].([]interface{})
if !ok {
t.Fatalf("samples missing: %+v", body)
}
if len(samples) != 3 {
t.Errorf("expected 3 samples, got %d", len(samples))
}
thr, ok := body["thresholds"].(map[string]interface{})
if !ok {
t.Fatalf("thresholds missing: %+v", body)
}
if int(thr["low_mv"].(float64)) != 3300 {
t.Errorf("default low_mv expected 3300, got %v", thr["low_mv"])
}
if int(thr["critical_mv"].(float64)) != 3000 {
t.Errorf("default critical_mv expected 3000, got %v", thr["critical_mv"])
}
// latest 3200 -> "low" (below 3300, above 3000)
if body["status"] != "low" {
t.Errorf("expected status=low, got %v", body["status"])
}
if int(body["latest_mv"].(float64)) != 3200 {
t.Errorf("latest_mv expected 3200, got %v", body["latest_mv"])
}
}
// TestNodeBatteryEndpoint_NoData returns 200 with empty samples and status="unknown".
func TestNodeBatteryEndpoint_NoData(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344/battery", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "unknown" {
t.Errorf("expected unknown when no samples, got %v", body["status"])
}
}
// TestNodeBatteryEndpoint_404 returns 404 for unknown node.
func TestNodeBatteryEndpoint_404(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/nodes/notarealnode00000000/battery", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 404 {
t.Errorf("expected 404, got %d", w.Code)
}
}
// TestBatteryThresholds_ConfigOverride confirms config overrides take effect.
func TestBatteryThresholds_ConfigOverride(t *testing.T) {
cfg := &Config{
BatteryThresholds: &BatteryThresholdsConfig{LowMv: 3500, CriticalMv: 3100},
}
if cfg.LowBatteryMv() != 3500 {
t.Errorf("LowBatteryMv override failed: %d", cfg.LowBatteryMv())
}
if cfg.CriticalBatteryMv() != 3100 {
t.Errorf("CriticalBatteryMv override failed: %d", cfg.CriticalBatteryMv())
}
empty := &Config{}
if empty.LowBatteryMv() != 3300 {
t.Errorf("default LowBatteryMv expected 3300, got %d", empty.LowBatteryMv())
}
if empty.CriticalBatteryMv() != 3000 {
t.Errorf("default CriticalBatteryMv expected 3000, got %d", empty.CriticalBatteryMv())
}
}
// #2146: LOWER(observer_id) = ? kept SQLite off the (observer_id, timestamp)
// primary-key index, so it walked the timestamp index over every observer's
// metrics in the window: 29 ms warm and 174 ms cold on 283k rows, against
// 5.5 ms through the primary key.
func TestNodeBatteryQueryUsesObserverIndex(t *testing.T) {
db := setupTestDB(t)
for _, s := range []string{
`ANALYZE`,
`DELETE FROM sqlite_stat1`,
`INSERT INTO sqlite_stat1 (tbl, idx, stat) VALUES
('observer_metrics', 'idx_observer_metrics_timestamp', '283070 32'),
('observer_metrics', 'sqlite_autoindex_observer_metrics_1', '283070 5001 1')`,
`ANALYZE sqlite_master`,
} {
if _, err := db.conn.Exec(s); err != nil {
t.Fatal(err)
}
}
rows, err := db.conn.Query("EXPLAIN QUERY PLAN "+nodeBatteryHistorySQL, "ab", "AB", "2026-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var plan []string
for rows.Next() {
var id, parent, notused int
var detail string
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
t.Fatal(err)
}
plan = append(plan, detail)
}
if len(plan) == 0 || !strings.Contains(plan[0], "sqlite_autoindex_observer_metrics_1") {
t.Fatalf("battery history must use the (observer_id, timestamp) index; plan: %q", plan)
}
}
// The pubkey is matched in lowercase and in uppercase, the two casings
// observer IDs are stored in.
func TestGetNodeBatteryHistory_LowercaseStoredID(t *testing.T) {
db := setupTestDB(t)
now := time.Now().UTC()
pk := "deadbeefcafef00d11223344"
db.conn.Exec(`INSERT INTO observer_metrics (observer_id, timestamp, battery_mv) VALUES (?, ?, ?)`,
pk, now.Add(-time.Hour).Format(time.RFC3339), 3600)
samples, err := db.GetNodeBatteryHistory(strings.ToUpper(pk), now.Add(-24*time.Hour).Format(time.RFC3339))
if err != nil {
t.Fatal(err)
}
if len(samples) != 1 || samples[0].BatteryMv != 3600 {
t.Fatalf("want the lowercase-stored sample, got %+v", samples)
}
}