Skip to content

perf(neighbors): rebuild the neighbor graph only in the background (#2221) - #2222

Open
efiten wants to merge 1 commit into
Kpa-clawbot:masterfrom
efiten:fix/2221-neighbor-graph-rebuild
Open

efiten wants to merge 1 commit into
Kpa-clawbot:masterfrom
efiten:fix/2221-neighbor-graph-rebuild

Conversation

@efiten

@efiten efiten commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

Fixes #2221.

Problem

getNeighborGraph rebuilt Server.neighborGraph on the request goroutine once it passed its 5 min TTL. It held neighborMu for the whole build, which takes 11.1-11.4 s on a production store.

The 5 min recomputer went through the same staleness check. A tick that landed just before the TTL skipped the rebuild and left it to the next request. As a result /api/nodes/{pubkey}/neighbors ran at p95 10.4 s and max 11.0 s (57 requests).

Change

  • getNeighborGraph serves the current graph. It builds only when there is none yet, which happens at startup.
  • rebuildNeighborGraph builds from the packet store whatever the age of the current graph, outside neighborMu, and then swaps the pointer.
    • recomputeNeighborGraphCache calls it first on every tick.
    • Without a store it keeps the current graph, so tests that inject a graph are unaffected.
  • neighborBuildMu serializes builds, so a startup request and the first tick do not both build.

Freshness is now bounded by the recompute interval (neighborGraph.cacheRecomputeIntervalSeconds, default 5 min) plus one build. Before this change it was the TTL plus one build, so the bound has not changed. The difference is who pays for the build.

IsStale/neighborGraphTTL stay, because path_inspect.go still uses them for store.graph.

Perf

  • Request path: after the first build it does no build at all. It takes one mutex and reads one pointer.
  • Background: the recompute tick now always builds, once every 5 min. Before, it built only on the ticks that found the graph stale, and a request built in the other cases.
  • Total: the number of builds stays about the same.

Before: /api/perf on production shows p95 10404 ms. After: I will add the staging /api/perf numbers for this endpoint in a comment once it has run for more than one interval.

Tests

neighbor_graph_rebuild_test.go:

  • a stale graph is served without a rebuild;
  • a graph is built when there is none yet;
  • a recompute tick replaces a graph that is not yet stale;
  • a recompute without a store keeps the injected graph.

The first and third tests fail on master. go vet and go test . pass in cmd/server, and -race passes on the neighbor-graph tests.

No frontend change.

…pa-clawbot#2221)

getNeighborGraph rebuilt Server.neighborGraph on the request goroutine once
it passed its 5 min TTL, holding neighborMu for the whole build (~11 s on a
production store). The 5 min recomputer went through the same staleness
check, so a tick that landed just before the TTL skipped the rebuild and
left it to the next request: /api/nodes/{pubkey}/neighbors ran at p95
10.4 s.

The request path now serves the current graph and builds only when there
is none yet. Every recompute tick rebuilds unconditionally, outside
neighborMu, and swaps the pointer. neighborBuildMu keeps a startup request
and the first tick from building twice. Without a store the current graph
is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@efiten

efiten commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Measured after deploying this branch to staging, with production on current master as the control.

Method: GET /api/nodes/{pubkey}/neighbors for the same node, every 20 s for 14 min (40 requests per instance), client-side time_total.

instance median p95 max requests over 2 s
staging, this PR 0.184 s 0.204 s 0.253 s 0
production, master 0.181 s 9.911 s 10.261 s 3

The three slow production requests (15:45:27, 15:50:45, 15:56:03 local) are 5 min 18 s apart, which is one TTL plus one build.

Caveat: staging holds a shorter packet window, so its background build takes 1.03 s, against 11 s on production. The bug would therefore have shown on staging as about 1 s spikes rather than 10 s. None of the 40 staging requests exceeded 0.253 s.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: /api/nodes/{pubkey}/neighbors rebuilds the neighbor graph on the request path (~11 s)

1 participant