Files
meshcore-analyzer/cmd/server
Alex BandClaude Opus 5.5 b8e571c9c5 perf(store): build the relay-filtered candidate lists once (#2195)
## Problem
`relayCandidates` runs once per hop of every observation the cold loader
resolves, and again from the direct-heard and neighbor analytics. Once
any listener-only observer was marked (#1290), every call built a
filtered copy of the candidate `nodeInfo` structs, even when none of the
candidates was a listener. On a week of a busy regional network that
accounted for 1.1 GB of the 5.4 GB allocated over one startup, the
biggest single source of startup churn.

## Change
`markNonRelay` now builds a map once, holding the filtered list for each
prefix whose candidates include a listener. A listener can only be in
the lists of its own prefixes and its full key, so only those are
filtered: at most 9 lists per listener, not every list in the prefix
map. `relayCandidates` returns either that list or the prefix map's own
list, without copying. Every caller only reads the slice (checked:
direct-heard, neighbor persist, node hop analytics and
`resolveWithContext`).

## Measured on a copy of that week
| | before | after |
|---|---|---|
| allocated over startup | 5.44 GB | 4.32 GB |
| server CPU over the run | 175.6 s | 169.1 s |
| peak RSS | 1074 MB | 1077 MB (unchanged; the GC limit sets it) |

## Cost per rebuild against cost per lookup
`markNonRelay` now does once per node cache rebuild (as often as every
30 s, `nodeCacheTTL`) the filtering that master did on every
`relayCandidates` call. 5,000 repeaters and 3 listeners, median of 6
runs (`BenchmarkMarkNonRelay5000`, `BenchmarkRelayCandidates256`):

| | master | first version of this PR | this PR now |
|---|---|---|---|
| `markNonRelay`, once per rebuild | 0.59 µs, 320 B, 3 allocs | 14.8 ms,
6.57 MB, 36,285 allocs | 20 µs, 20.6 KB, 33 allocs |
| one `relayCandidates` lookup | ~7.8 µs, 6.7 KB | ~30 ns, 0 B | ~30 ns,
0 B |
| 256 lookups (one per 2-character hop) | 2.0 ms, 1.71 MB, 1,453 allocs
| 7.7 µs, 0 B | 7.7 µs, 0 B |

The extra ~20 µs per rebuild is paid back after about 3 lookups. Every
ingested observation looks up each hop of its path, so a running server
makes far more than that between rebuilds, and a cold load makes
millions (the 1.1 GB above).

## Tests
- `TestRelayCandidatesDoesNotAllocate`: zero allocations per call, and
listeners still filtered.
- `TestRelayCandidatesAfterSecondMark`.
- `TestMarkNonRelayVisitsOnlyListenerPrefixes`: at most 80 allocations
for 3 listeners among 5,000 repeaters (36,285 before the fix). It has
its own red commit.
- `BenchmarkMarkNonRelay5000` and `BenchmarkRelayCandidates256` give the
table above.
- `TestMarkNonRelayMatchesFilteringEachList`: every prefix of the
5,000-repeater map matches filtering its list on the spot.
- Red commit first, then green.
- `go test ./cmd/server` passes.

No config or UI changes.

🤖 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-13 15:42:18 +02:00
..