Files
meshcore-analyzer/cmd/server/region_nodes_test.go
T
Alex BandClaude Opus 5.5 95672ac2be perf(server): share /api/nodes responses for identical queries for 15 s (#2165)
## Summary

`/api/nodes` scans every node row into a map, enriches it (hash size,
multi-byte capability, relay info, usefulness scores, declared scope)
and encodes the page on **every** request. On one production instance (2
vCPU / 2 GB) that was **95 GB of allocation in 45 hours, about 6.7 MB
per request and a quarter of all bytes allocated**, on a process where
garbage collection was 87% of CPU.

The queries repeat heavily. In that instance's nginx log for one day,
three pages of one query (`limit=500&offset=0|500|1000&lastHeard=30d`)
were 10,000 of the 14,053 `/api/nodes` requests.

This PR keeps the encoded response for 15 s per distinct query:

- `handleNodes` serves the cached bytes when it can; otherwise it builds
through `buildNodesResponse` (the old handler body, unchanged apart from
returning instead of writing) inside a singleflight, so concurrent
identical misses share one build.
- **Cache key:** blacklist generation | hidden-prefix generation |
sorted query string. Parameter order does not split the cache, and any
change to either filter list misses it at once. This is the pattern
`/api/nodes/{pubkey}/reach` already uses (#1629).
- `setGeoFilter` drops the cache, so a geo-filter change through the
admin endpoint shows immediately.
- **Bounded twice:** by `maxCacheEntries` (256) like the other keyed
caches, and by bytes, since a full-list response is several MB. Expired
entries are dropped on every insert, the total is capped at 32 MB, and a
single response over 8 MB is not cached.
- **15 s** matches the relay/usefulness caches the response is built
from (#1257); the client already keeps its own node list for 90 s.

Related to #2146: `/api/nodes` is in its endpoint table (144 s max on
one instance). This PR reduces how often the handler runs and allocates,
not the SQL wait the issue is about, so it is not a fix for it.

## Performance

### Go benchmarks

1,100 nodes, half repeaters, 10 runs each:

```
cpu: AMD Ryzen 5 3600XT 6-Core Processor
                       │    master     │              this PR                 │
                       │    sec/op     │    sec/op     vs base                │
HandleNodesRepeated-12   5991.14µ ± 3%    41.21µ ± 7%  -99.31% (p=0.000 n=10)
HandleNodesUnique-12       6.010m ± 2%    5.967m ± 0%        ~ (p=0.393 n=10)

                       │     B/op      │     B/op      vs base                │
HandleNodesRepeated-12   1611.0Ki ± 1%   240.9Ki ± 0%  -85.05% (p=0.000 n=10)
HandleNodesUnique-12      1.566Mi ± 1%   1.753Mi ± 1%  +11.96% (p=0.000 n=10)
```

`Repeated` is one query asked over and over (the production pattern).
`Unique` makes every request a different query, so nothing is served
from cache: the time is unchanged, and it allocates 12% more because the
body is now built in memory so it can be kept.

### Expected hit rate

Replaying that day's 14,053 `/api/nodes` requests through a cache of
each lifetime:

| TTL | served from cache |
|---|---|
| 5 s | 39% |
| 10 s | 47% |
| **15 s** | **52%** |
| 30 s | 58% |
| 60 s | 69% |

### Staging A/B

The same 30 minutes of real production API traffic (5,640 requests, 279
of them `/api/nodes`), replayed at 2x on a staging host against a copy
of a production database, on `master` and on this branch:

| `/api/nodes` | master | this PR |
|---|---|---|
| p50 | 47.1 ms | **31.0 ms** |
| p95 | 165.4 ms | **114.4 ms** |
| max | 759.9 ms | 321.7 ms |
| allocated by `handleNodes` during the replay | 1,000 MB | **594 MB
(-41%)** |
| total allocated by the process | 3,704 MB | 3,349 MB (-10%) |

The -41% matches the expectation from the hit rate and the miss-path
cost: about 0.48 × 1.12 + 0.52 × 0.15 ≈ 0.61 of the old allocation.

## Tests

- Red commit `ab33bc29`: a test-only build hook, the tests and both
benchmarks. Three fail on `master`: identical queries are built once,
reordered parameters share an entry, ten concurrent misses share one
build. Three more guard against over-caching and pass on both commits:
different queries are built separately, an expired entry is rebuilt, a
geo-filter change drops entries.
- Green commit `ea0d1cf2`: the cache, plus
`TestHandleNodes_FilterListChangesMissCache` (hidden-prefix and
blacklist changes miss the cache) and `TestNodesCache_BoundedByBytes`
(stays under 32 MB; an oversized response is not cached).
- One existing test changed: `TestHandleNodesRegionUsesStore` already
clears the store's own 30 s region cache by hand before its second
request; it now clears this cache on the same line.
`TestHiddenNamePrefix_1181_NodesList`, which changes the hidden-prefix
list between requests, passes unchanged because the generation is in the
key.
- `go test -race` on the new tests passes. `make test`: 16 of 16 modules
pass.

## Review notes

- The response depends only on the query string and server state, not on
who is asking (no auth or header-dependent output in the handler), so a
shared cache is safe.
- `writeJSONBytes` writes the cached bytes with the same `Content-Type`
and status as `writeJSON`; the body has the trailing newline
`json.Encoder` adds, so output is byte-identical.
- No new `map[string]interface{}` added (the existing node maps are
unchanged; converting them to a struct is a separate, larger job).
- The TTL and byte budget are constants. They could be operator settings
later (AGENTS.md rule 8); not added here.

## SonarLint

SonarQube Community rules (the SonarLint engine) were run on the files
this PR touches, and only issues on lines it adds are listed.

- **S3776 on `buildNodesResponse`:** complexity 91. That is upstream's
existing handler body moved unchanged (it is also 91 on `master`); this
PR makes the handler itself simpler.
- **Fixed** in `25d40950`: the test hook moved to the cache-miss path,
so the builder stays identical to the old body rather than one point
above it. Splitting that function is a refactor of its own.

## Checked against the repo rules

- Rebased onto `master` (`673442cc`). Each red commit was re-run and
still fails on its assertions; each green commit passes (TDD.md).
- `make test`: all 16 modules pass. Frontend suites: all pass except
`test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME`
and fails the same way on `master` locally (#2160 addresses it).
- No private data: test fixtures and examples use the codes the existing
tests use (SJC/SFO/LAX, `#be`-style scopes).
- No new `map[string]interface{}`, no hard-coded colours. `git add` was
explicit, and there is one logical change per commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H

---------

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

270 lines
9.5 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"testing"
"time"
)
// #2101: /api/nodes?region= ran a subquery over every advert and all of its
// observations, twice per request, and saturated the reader pool on a 10 GB
// database. The region filter now resolves to a pubkey set from the packet
// store's in-memory adverts.
// mkRegionAdvert builds an advert heard by the given observer IDs. The
// per-observation IATA is left as recorded at ingest ("STALE"), so a test only
// passes when the region is resolved through the observers table.
func mkRegionAdvert(id int, pubkey string, observerIDs ...string) *StoreTx {
pt := PayloadADVERT
j, _ := json.Marshal(map[string]interface{}{"pubKey": pubkey})
tx := &StoreTx{ID: id, Hash: fmt.Sprintf("radv%d", id), PayloadType: &pt, DecodedJSON: string(j)}
for i, oid := range observerIDs {
tx.Observations = append(tx.Observations, &StoreObs{
ID: id*10 + i, TransmissionID: id, ObserverID: oid, ObserverIATA: "STALE",
})
}
return tx
}
// newRegionTestStore returns a store over a database holding the given
// observer ID → IATA rows.
func newRegionTestStore(t testing.TB, observers map[string]string) *PacketStore {
t.Helper()
db := setupTestDB(t)
for id, iata := range observers {
if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) VALUES (?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 1)`, id, id, iata); err != nil {
t.Fatal(err)
}
}
return NewPacketStore(db, nil)
}
func addToStore(ps *PacketStore, txs ...*StoreTx) {
ps.mu.Lock()
defer ps.mu.Unlock()
for _, tx := range txs {
ps.packets = append(ps.packets, tx)
ps.byHash[tx.Hash] = tx
ps.byTxID[tx.ID] = tx
if tx.PayloadType != nil {
ps.byPayloadType[*tx.PayloadType] = append(ps.byPayloadType[*tx.PayloadType], tx)
}
}
}
func sortedKeys(pks []string) []string {
out := append([]string(nil), pks...)
sort.Strings(out)
return out
}
func TestRegionNodePubkeys(t *testing.T) {
ps := newRegionTestStore(t, map[string]string{"o-sjc": "SJC", "o-sfo": "SFO", "o-sjc2": " sjc "})
chanType := PayloadGRP_TXT
chanTx := &StoreTx{ID: 90, Hash: "chan90", PayloadType: &chanType, DecodedJSON: `{"pubKey":"pk_chan"}`,
Observations: []*StoreObs{{ID: 900, TransmissionID: 90, ObserverID: "o-sjc"}}}
addToStore(ps,
mkRegionAdvert(1, "pk_sjc", "o-sjc"),
mkRegionAdvert(2, "pk_sfo", "o-sfo"),
mkRegionAdvert(3, "pk_both", "o-sfo", "o-sjc2"), // one in-region observation is enough
mkRegionAdvert(4, "pk_none"), // advert never observed
mkRegionAdvert(5, "pk_unknown", "o-gone"), // observer not in the table
chanTx, // not an advert: never counts
)
cases := []struct {
region string
want []string
}{
{"SJC", []string{"pk_both", "pk_sjc"}},
{" sjc ", []string{"pk_both", "pk_sjc"}},
{"SJC,SFO", []string{"pk_both", "pk_sfo", "pk_sjc"}},
{"ZZZ", []string{}},
}
for _, c := range cases {
got, ok := ps.RegionNodePubkeys(c.region)
if !ok {
t.Fatalf("region %q: want a resolved set, got none", c.region)
}
if g := sortedKeys(got); fmt.Sprint(g) != fmt.Sprint(c.want) {
t.Errorf("region %q: got %v, want %v", c.region, g, c.want)
}
}
if _, ok := ps.RegionNodePubkeys(" "); ok {
t.Error("a blank region must not resolve to a set")
}
}
func TestRegionNodePubkeysCached(t *testing.T) {
ps := newRegionTestStore(t, map[string]string{"o-sjc": "SJC"})
addToStore(ps, mkRegionAdvert(1, "pk_a", "o-sjc"))
if got, _ := ps.RegionNodePubkeys("SJC"); len(got) != 1 {
t.Fatalf("got %v", got)
}
addToStore(ps, mkRegionAdvert(2, "pk_b", "o-sjc"))
if got, _ := ps.RegionNodePubkeys("sjc"); len(got) != 1 {
t.Errorf("within the TTL a normalised repeat must be served from cache, got %v", got)
}
ps.regionNodesMu.Lock()
for k, e := range ps.regionNodesCache {
e.at = time.Now().Add(-time.Hour)
ps.regionNodesCache[k] = e
}
ps.regionNodesMu.Unlock()
if got, _ := ps.RegionNodePubkeys("SJC"); len(got) != 2 {
t.Errorf("after the TTL the set must be recomputed, got %v", got)
}
}
func TestGetNodesRegionPubkeys(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
nodes, total, _, err := db.GetNodes(NodeQuery{Limit: 50, RegionPubkeys: []string{"aabbccdd11223344", "eeff00112233aabb", "not-a-node"}})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(nodes) != 2 {
t.Errorf("want 2 nodes and total 2, got %d nodes, total %d", len(nodes), total)
}
nodes, total, _, err = db.GetNodes(NodeQuery{Limit: 50, Role: "repeater", RegionPubkeys: []string{"aabbccdd11223344", "eeff00112233aabb"}})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(nodes) != 1 || nodes[0]["public_key"] != "aabbccdd11223344" {
t.Errorf("role filter must combine with the set: got %v (total %d)", nodes, total)
}
nodes, total, _, err = db.GetNodes(NodeQuery{Limit: 50, Region: "SJC", RegionPubkeys: []string{}})
if err != nil {
t.Fatal(err)
}
if total != 0 || nodes == nil || len(nodes) != 0 {
t.Errorf("an empty set matches nothing, and replaces the SQL region filter: got %v (total %d)", nodes, total)
}
}
func TestHandleNodesRegionUsesStore(t *testing.T) {
srv, router := setupTestServer(t)
get := func() []string {
req := httptest.NewRequest(http.MethodGet, "/api/nodes?region=SJC", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status %d: %s", rr.Code, rr.Body.String())
}
var body struct {
Nodes []map[string]interface{} `json:"nodes"`
Total int `json:"total"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
var pks []string
for _, n := range body.Nodes {
pks = append(pks, n["public_key"].(string))
}
sort.Strings(pks)
return pks
}
// The seeded advert from aabbccdd11223344 was heard by obs1 (SJC).
if got := get(); fmt.Sprint(got) != "[aabbccdd11223344]" {
t.Fatalf("region SJC: got %v", got)
}
// An advert only the store knows about: if the handler still asked SQL,
// the companion could not appear.
addToStore(srv.store, mkRegionAdvert(7001, "eeff00112233aabb", "obs1")) // obs1 is SJC in seedTestData
srv.store.regionNodesMu.Lock()
srv.store.regionNodesCache = map[string]regionNodesEntry{}
srv.store.regionNodesMu.Unlock()
srv.invalidateNodesCache() // the 15 s /api/nodes response cache, likewise
if got := get(); fmt.Sprint(got) != "[aabbccdd11223344 eeff00112233aabb]" {
t.Errorf("region SJC must come from the store: got %v", got)
}
}
func TestRegionNodePubkeysCacheIsBounded(t *testing.T) {
ps := newRegionTestStore(t, map[string]string{"o-sjc": "SJC"})
addToStore(ps, mkRegionAdvert(1, "pk_a", "o-sjc"))
// The region parameter comes from the client, so every distinct value is
// a new cache key. The cache must not grow with them.
for i := 0; i < 1000; i++ {
ps.RegionNodePubkeys(fmt.Sprintf("X%04d", i))
}
ps.regionNodesMu.Lock()
n := len(ps.regionNodesCache)
ps.regionNodesMu.Unlock()
if n > regionNodesCacheMax {
t.Errorf("cache holds %d entries, bound is %d", n, regionNodesCacheMax)
}
}
// BenchmarkRegionNodePubkeys measures one uncached scan at the scale of a
// large deployment: 220k adverts (staging held 219,750 on 2026-10-05) from
// 4,000 nodes, each heard by 8 observers across 10 regions.
func BenchmarkRegionNodePubkeys(b *testing.B) {
regions := []string{"ANR", "BRU", "GNE", "HEP", "KJK", "LGG", "MST", "NRW", "OBL", "OST"}
observers := make(map[string]string, len(regions))
for _, r := range regions {
observers["o-"+r] = r
}
ps := newRegionTestStore(b, observers)
txs := make([]*StoreTx, 0, 220000)
for i := 0; i < 220000; i++ {
ids := make([]string, 8)
for j := range ids {
ids[j] = "o-" + regions[(i+j*3)%len(regions)]
}
txs = append(txs, mkRegionAdvert(i+1, fmt.Sprintf("pk%04d", i%4000), ids...))
}
addToStore(ps, txs...)
ps.RegionNodePubkeys("BRU") // parse each heard advert's JSON once, as a running server has
b.ResetTimer()
for i := 0; i < b.N; i++ {
ps.regionNodesMu.Lock()
ps.regionNodesCache = map[string]regionNodesEntry{}
ps.regionNodesMu.Unlock()
if keys, _ := ps.RegionNodePubkeys("BRU"); len(keys) == 0 {
b.Fatal("no keys")
}
}
}
// An observer's IATA is copied onto each observation at ingest. When the
// operator changes it, the observers table is the truth, as it was for the SQL
// filter and is for every other store region filter (resolveRegionObservers).
func TestRegionNodePubkeysFollowsObserverIATAChange(t *testing.T) {
ps := newRegionTestStore(t, map[string]string{"o-1": "SFO"})
tx := mkRegionAdvert(1, "pk_moved", "o-1")
tx.Observations[0].ObserverIATA = "SJC" // what the observer was when this was ingested
addToStore(ps, tx)
if got, _ := ps.RegionNodePubkeys("SJC"); len(got) != 0 {
t.Errorf("the observer is SFO now, SJC must not match: got %v", got)
}
if got, _ := ps.RegionNodePubkeys("SFO"); fmt.Sprint(got) != "[pk_moved]" {
t.Errorf("SFO must match through the observers table: got %v", got)
}
}
// History loaded in the background (loadChunk) carries the observer ID but no
// IATA on its observations. Matching on the per-observation IATA dropped every
// node whose adverts came only from those chunks.
func TestRegionNodePubkeysMatchesChunkLoadedObservations(t *testing.T) {
ps := newRegionTestStore(t, map[string]string{"o-1": "SJC"})
tx := mkRegionAdvert(1, "pk_history", "o-1")
tx.Observations[0].ObserverIATA = "" // as loadChunk leaves it
addToStore(ps, tx)
if got, _ := ps.RegionNodePubkeys("SJC"); fmt.Sprint(got) != "[pk_history]" {
t.Errorf("a chunk-loaded advert must match through its observer ID: got %v", got)
}
}