mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 21:37:42 +00:00
## 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>
213 lines
6.7 KiB
Go
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")
|
|
}
|
|
}
|