Files
meshcore-analyzer/cmd/server/nodes_perf_test.go
T
40f664c587 chore(#1859): gofmt sweep + gofmt/go vet CI gate (rebase of #1881) (#1941)
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three
commits are preserved, two of them cherry-picked with authorship intact;
the sweep itself had to be regenerated. Opened as a new PR rather than
force-pushing their branch.

Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2
landed as #1937.

## Why regenerated rather than merged

The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs
landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on
current master is cheaper and less error-prone than resolving 72
conflicts that are all whitespace. The drift it fixes also grew in the
meantime: 66 files now, against 72 then, but spread differently.

## The three commits

1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files.
2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet`
copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range
variable copied a `Config` embedding `sync.Once`. Cherry-picked
unchanged.
3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift
or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one
change, noted in the commit message: the ignore file pointed at
`04bc80ee`, the sweep commit on their branch, which does not exist on
this base and would make `git blame --ignore-revs-file` error. Repointed
at `d3a02599`, the sweep here.

## Verification

The claim "formatting only" is checked twice rather than asserted:

- Every changed file is byte-identical to `gofmt(previous content)`. 0
of 66 deviate.
- With line comments and all whitespace stripped, 0 of 66 files differ,
so no code outside comments changed.

14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt`
re-indents indented comment blocks to tabs and inserts a blank comment
line before them; the behavior matrix above `resolveHopWithContext` in
`cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own
output, not an edit, but it is worth naming because it makes the diff
look larger than "whitespace" suggests.

The gate was run locally exactly as the workflow runs it: `gofmt` clean,
and `go vet` clean in all 14 modules, including `cmd/ingestor` which is
what commit 2 fixes.

Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s),
`cmd/ingestor` passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically
on bare master with "A required privilege is not held by the client"
(Windows symlink privilege on my host, not code).

## Sequencing

This should go last in the queue. The sweep touches 66 files, so merging
it before the remaining open Go PRs gives each of them a conflict about
nothing but formatting. After it lands the gate is active, and any PR
with drift fails CI until it runs `gofmt -w`.

Excluded from the sweep: the misnamed `Dockerfile.go`, which is a
Dockerfile that gofmt cannot parse (the workflow excludes it too), and
`docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a
spurious modification against `docs/deployment.md` and is unrelated.

---------

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:52:03 +02:00

129 lines
4.4 KiB
Go

package main
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestHandleNodesPerfLargeFleet asserts the /api/nodes endpoint (no `limit`
// param — relying on the server-side default) returns in well under 2s on a
// realistic-shape fleet: 600 nodes, ~50 of them repeaters/rooms with rich
// path-hop activity, and a non-trivial byPayloadType + byPathHop index.
//
// Regression guard for issue #1257:
//
// /api/nodes (no limit) → 32.9s, 30KB on staging (637 nodes)
// /api/nodes?limit=2000 → 4.9s, 360KB
//
// Root cause class: per-repeater enrichment in handleNodes calls
// store.GetRepeaterRelayInfo + GetRepeaterUsefulnessScore separately for
// each node in the page. Each call takes its own RLock and walks
// byPathHop[pk] / byPayloadType, doing expensive timestamp parsing.
// For the default-page case (top-50 by last_seen, mostly hot repeaters)
// that is hundreds of thousands of timestamp parses per request.
//
// Budget: 2s. On the broken implementation with this fixture the
// endpoint blows the budget; with batched/cached per-page enrichment it
// completes in well under 500ms.
func TestHandleNodesPerfLargeFleet(t *testing.T) {
if testing.Short() {
t.Skip("perf test")
}
srv, router := setupTestServer(t)
conn := srv.db.conn
// Seed 600 nodes — 50 repeaters/rooms with most-recent last_seen so
// they land on the default page, plus 550 stale companions.
tx, err := conn.Begin()
if err != nil {
t.Fatal(err)
}
stmt, err := tx.Prepare(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count, foreign_advert)
VALUES (?, ?, ?, 0, 0, ?, '2026-01-01T00:00:00Z', 1, 0)`)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
for i := 0; i < 50; i++ {
pk := fmt.Sprintf("pkrepeat%056x", i)
ts := now.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339Nano)
if _, err := stmt.Exec(pk, fmt.Sprintf("rep%d", i), "repeater", ts); err != nil {
t.Fatal(err)
}
}
for i := 0; i < 550; i++ {
pk := fmt.Sprintf("pkcompan%056x", i)
ts := now.Add(-time.Duration(60+i) * time.Minute).Format(time.RFC3339Nano)
if _, err := stmt.Exec(pk, fmt.Sprintf("comp%d", i), "companion", ts); err != nil {
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
// Seed the in-memory packet store: a body of non-advert traffic with
// each repeater appearing as a path hop on many of them. This is what
// makes the per-node GetRepeaterRelayInfo / GetRepeaterUsefulnessScore
// calls expensive on the broken impl.
const numTx = 150000
const hopsPerTx = 6
pt2 := 2 // non-advert payload type
store := srv.store
// Also index every tx under a single shared 1-byte prefix so the
// GetRepeaterRelayInfo prefix-collision branch fans every per-node
// call through the full non-advert tx set (matches production where
// many repeaters share a 1-byte hop prefix).
for i := 0; i < numTx; i++ {
txID := 100000 + i
ts := now.Add(-time.Duration(i) * time.Second).Format(time.RFC3339Nano)
stx := &StoreTx{
ID: txID,
Hash: fmt.Sprintf("h%d", txID),
FirstSeen: ts,
PayloadType: &pt2,
}
store.byPayloadType[pt2] = append(store.byPayloadType[pt2], stx)
store.byPathHop["pk"] = append(store.byPathHop["pk"], stx)
// Index each repeater under byPathHop so per-node enrichment walks
// a non-trivial slice.
for h := 0; h < hopsPerTx; h++ {
repIdx := (i + h) % 50
pk := fmt.Sprintf("pkrepeat%056x", repIdx)
store.byPathHop[pk] = append(store.byPathHop[pk], stx)
}
}
// Warm-up to amortize first-call costs (cache misses, prepare). Note:
// the per-node Repeater* enrichment is NOT cached, so this warmup
// does not hide the perf bug — it only amortizes one-shot prep.
{
req := httptest.NewRequest("GET", "/api/nodes?limit=1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("warmup status=%d body=%s", w.Code, w.Body.String())
}
}
start := time.Now()
req := httptest.NewRequest("GET", "/api/nodes", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
elapsed := time.Since(start)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
const budget = 2 * time.Second
t.Logf("/api/nodes (no limit) elapsed=%v on %d nodes, %d tx", elapsed, 600, numTx)
if elapsed > budget {
t.Fatalf("/api/nodes (no limit) too slow for #1257: %v (budget %v) on %d nodes, %d tx",
elapsed, budget, 600, numTx)
}
}