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

213 lines
6.7 KiB
Go

package main
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/mux"
)
// /api/nodes builds every node row into a map, enriches it and encodes the
// page on every request: on one production instance that was 95 GB of
// allocation over 45 hours (about 6.7 MB per request), a quarter of all bytes
// allocated. The same few queries repeat (three paginated pages of one query
// were 10,000 of 14,000 requests in a day), so identical queries inside
// nodesResponseTTL share one built and encoded response.
func getNodes(t *testing.T, router http.Handler, url string) string {
t.Helper()
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("GET %s: %d %s", url, w.Code, w.Body.String())
}
return w.Body.String()
}
func countNodeBuilds(srv *Server) *int32 {
var n int32
srv.nodesComputeHook = func() { atomic.AddInt32(&n, 1) }
return &n
}
func TestHandleNodes_IdenticalQueryBuiltOnce(t *testing.T) {
srv, router := setupTestServer(t)
builds := countNodeBuilds(srv)
a := getNodes(t, router, "/api/nodes?limit=50")
b := getNodes(t, router, "/api/nodes?limit=50")
if got := atomic.LoadInt32(builds); got != 1 {
t.Fatalf("expected one build for two identical queries, got %d", got)
}
if a != b {
t.Fatal("cached response differs from the built one")
}
}
func TestHandleNodes_ParameterOrderSharesEntry(t *testing.T) {
srv, router := setupTestServer(t)
builds := countNodeBuilds(srv)
getNodes(t, router, "/api/nodes?limit=50&offset=0")
getNodes(t, router, "/api/nodes?offset=0&limit=50")
if got := atomic.LoadInt32(builds); got != 1 {
t.Fatalf("expected reordered parameters to share one build, got %d", got)
}
}
func TestHandleNodes_DistinctQueriesBuiltSeparately(t *testing.T) {
srv, router := setupTestServer(t)
builds := countNodeBuilds(srv)
a := getNodes(t, router, "/api/nodes?limit=50")
b := getNodes(t, router, "/api/nodes?limit=1")
if got := atomic.LoadInt32(builds); got != 2 {
t.Fatalf("expected two builds for two different queries, got %d", got)
}
if a == b {
t.Fatal("different limits returned the same body")
}
}
func TestHandleNodes_ConcurrentMissesShareOneBuild(t *testing.T) {
srv, router := setupTestServer(t)
var n int32
srv.nodesComputeHook = func() {
atomic.AddInt32(&n, 1)
time.Sleep(50 * time.Millisecond) // make sure the callers overlap
}
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() { defer wg.Done(); getNodes(t, router, "/api/nodes?limit=50") }()
}
wg.Wait()
if got := atomic.LoadInt32(&n); got != 1 {
t.Fatalf("expected 10 concurrent identical requests to share one build, got %d", got)
}
}
func TestHandleNodes_RebuiltAfterTTL(t *testing.T) {
srv, router := setupTestServer(t)
builds := countNodeBuilds(srv)
getNodes(t, router, "/api/nodes?limit=50")
srv.expireNodesCacheForTest()
getNodes(t, router, "/api/nodes?limit=50")
if got := atomic.LoadInt32(builds); got != 2 {
t.Fatalf("expected a rebuild once the cached response expired, got %d builds", got)
}
}
func TestHandleNodes_GeoFilterChangeDropsCache(t *testing.T) {
srv, router := setupTestServer(t)
builds := countNodeBuilds(srv)
getNodes(t, router, "/api/nodes?limit=50")
srv.setGeoFilter(&GeoFilterConfig{Polygon: [][2]float64{{0, 0}, {0, 1}, {1, 1}}})
getNodes(t, router, "/api/nodes?limit=50")
if got := atomic.LoadInt32(builds); got != 2 {
t.Fatalf("expected a geo-filter change to drop cached node pages, got %d builds", got)
}
}
// expireNodesCacheForTest drops every cached /api/nodes response.
func (s *Server) expireNodesCacheForTest() { s.invalidateNodesCache() }
// nodesBenchServer serves 1,100 nodes (a production instance lists about
// 1,085), half of them repeaters.
func nodesBenchServer(b *testing.B) http.Handler {
b.Helper()
log.SetOutput(io.Discard)
b.Cleanup(func() { log.SetOutput(os.Stderr) })
db := setupTestDB(b)
now := time.Now().UTC()
for i := 0; i < 1100; i++ {
role := "companion"
if i%2 == 0 {
role = "repeater"
}
seen := now.Add(-time.Duration(i%720) * time.Hour).Format(time.RFC3339)
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
fmt.Sprintf("%064x", i+1000), fmt.Sprintf("Bench node %d", i), role,
55+float64(i%100)/50, -4+float64(i%80)/40, seen, seen, i%50); err != nil {
b.Fatal(err)
}
}
srv := NewServer(db, &Config{Port: 3000}, NewHub())
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
b.Fatal(err)
}
store.WaitIndexesReady(5 * time.Second)
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
return router
}
func benchGet(b *testing.B, router http.Handler, url string) {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
b.Fatalf("GET %s: %d", url, w.Code)
}
}
// BenchmarkHandleNodesRepeated: the production pattern, one query asked over
// and over (the top query was 3,378 of 14,053 requests in a day).
func BenchmarkHandleNodesRepeated(b *testing.B) {
router := nodesBenchServer(b)
const url = "/api/nodes?limit=500&offset=0&lastHeard=30d"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchGet(b, router, url)
}
}
// BenchmarkHandleNodesUnique: every request a different query, so nothing is
// served from cache; shows the miss path does not get slower.
func BenchmarkHandleNodesUnique(b *testing.B) {
router := nodesBenchServer(b)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchGet(b, router, fmt.Sprintf("/api/nodes?limit=500&offset=0&lastHeard=30d&search=&n=%d", i))
}
}
func TestHandleNodes_FilterListChangesMissCache(t *testing.T) {
srv, router := setupTestServer(t)
builds := countNodeBuilds(srv)
getNodes(t, router, "/api/nodes?limit=50")
srv.cfg.SetHiddenNamePrefixes([]string{"zz-hidden"})
getNodes(t, router, "/api/nodes?limit=50")
srv.cfg.SetNodeBlacklist([]string{"0000000000000000"})
getNodes(t, router, "/api/nodes?limit=50")
if got := atomic.LoadInt32(builds); got != 3 {
t.Fatalf("expected hidden-prefix and blacklist changes to miss the cache, got %d builds", got)
}
}
func TestNodesCache_BoundedByBytes(t *testing.T) {
srv := &Server{}
body := make([]byte, nodesCacheMaxBytes/5) // under the per-entry limit
for i := 0; i < 20; i++ {
srv.storeNodesResponse(fmt.Sprintf("q%d", i), body)
if srv.nodesCacheBytes > nodesCacheMaxBytes {
t.Fatalf("cache holds %d bytes after %d inserts, limit %d", srv.nodesCacheBytes, i+1, nodesCacheMaxBytes)
}
}
srv.storeNodesResponse("huge", make([]byte, nodesCacheMaxBytes/4+1))
if _, ok := srv.cachedNodesResponse("huge"); ok {
t.Fatal("expected an oversized response not to be cached")
}
}