Files
meshcore-analyzer/cmd/server/node_battery.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

156 lines
4.2 KiB
Go

package main
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
)
// BatteryThresholdsConfig: voltage cutoffs for low-battery alerts (#663).
// All values in millivolts. When a node's most-recent battery sample falls
// below LowMv it is flagged "low"; below CriticalMv it is flagged "critical".
type BatteryThresholdsConfig struct {
LowMv int `json:"lowMv"`
CriticalMv int `json:"criticalMv"`
}
// LowBatteryMv returns the configured low-battery threshold or the default 3300mV.
func (c *Config) LowBatteryMv() int {
if c.BatteryThresholds != nil && c.BatteryThresholds.LowMv > 0 {
return c.BatteryThresholds.LowMv
}
return 3300
}
// CriticalBatteryMv returns the configured critical-battery threshold or the default 3000mV.
func (c *Config) CriticalBatteryMv() int {
if c.BatteryThresholds != nil && c.BatteryThresholds.CriticalMv > 0 {
return c.BatteryThresholds.CriticalMv
}
return 3000
}
// NodeBatterySample is a single (timestamp, battery_mv) point.
type NodeBatterySample struct {
Timestamp string `json:"timestamp"`
BatteryMv int `json:"battery_mv"`
}
// nodeBatteryHistorySQL matches the observer ID in lowercase and in
// uppercase instead of LOWER(observer_id) = ?, so SQLite can use the
// (observer_id, timestamp) primary-key index (#2146). Arguments: the pubkey
// lowercased, uppercased, and the window start.
const nodeBatteryHistorySQL = `
SELECT timestamp, battery_mv
FROM observer_metrics
WHERE observer_id IN (?, ?)
AND battery_mv IS NOT NULL
AND timestamp >= ?
ORDER BY timestamp ASC`
// GetNodeBatteryHistory returns time-ordered battery_mv samples for a node,
// pulled from observer_metrics by joining observers.id (uppercase pubkey)
// against the node's public_key (lowercase). Rows with NULL battery are skipped.
//
// observer_id is matched in lowercase and in uppercase to tolerate historical
// variation in pubkey casing.
func (db *DB) GetNodeBatteryHistory(pubkey, since string) ([]NodeBatterySample, error) {
if pubkey == "" {
return nil, nil
}
rows, err := db.conn.Query(nodeBatteryHistorySQL, strings.ToLower(pubkey), strings.ToUpper(pubkey), since)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NodeBatterySample
for rows.Next() {
var ts string
var mv int
if err := rows.Scan(&ts, &mv); err != nil {
return nil, err
}
out = append(out, NodeBatterySample{Timestamp: ts, BatteryMv: mv})
}
return out, rows.Err()
}
// handleNodeBattery serves GET /api/nodes/{pubkey}/battery?days=N (#663).
//
// Returns voltage time-series for a node and a status flag based on the most
// recent sample evaluated against configured thresholds:
// - "critical" : latest_mv < CriticalBatteryMv
// - "low" : latest_mv < LowBatteryMv
// - "ok" : latest_mv >= LowBatteryMv
// - "unknown" : no samples in window
func (s *Server) handleNodeBattery(w http.ResponseWriter, r *http.Request) {
pubkey := mux.Vars(r)["pubkey"]
if pubkey == "" {
writeError(w, 400, "missing pubkey")
return
}
// 404 if node unknown — keeps URL space tidy and matches /health behavior.
node, err := s.db.GetNodeByPubkey(pubkey)
if err != nil {
writeError(w, 500, err.Error())
return
}
if node == nil {
writeError(w, 404, "node not found")
return
}
days := 7
if d, _ := strconv.Atoi(r.URL.Query().Get("days")); d > 0 && d <= 365 {
days = d
}
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
samples, err := s.db.GetNodeBatteryHistory(pubkey, since)
if err != nil {
writeError(w, 500, err.Error())
return
}
if samples == nil {
samples = []NodeBatterySample{}
}
low := s.cfg.LowBatteryMv()
crit := s.cfg.CriticalBatteryMv()
status := "unknown"
var latestMv interface{}
var latestTs interface{}
if n := len(samples); n > 0 {
mv := samples[n-1].BatteryMv
latestMv = mv
latestTs = samples[n-1].Timestamp
switch {
case mv < crit:
status = "critical"
case mv < low:
status = "low"
default:
status = "ok"
}
}
writeJSON(w, map[string]interface{}{
"public_key": strings.ToLower(pubkey),
"days": days,
"samples": samples,
"latest_mv": latestMv,
"latest_ts": latestTs,
"status": status,
"thresholds": map[string]interface{}{
"low_mv": low,
"critical_mv": crit,
},
})
}