Repository navigation
perf(server): remove per-candidate SQL and JSON parsing from /paths and /hop_analytics - #246
Conversation
…nd /hop_analytics
A 60 s CPU profile of /api/nodes/{pk}/paths under load showed handleNodePaths
at 76% of all CPU, split between two avoidable costs:
1. confirmResolvedPathContains (43% of CPU): one SELECT ... INSTR(LOWER(
resolved_path), ?) per hash-index candidate, run sequentially, each
scanning every observation row of the transmission.
2. pathLen (29% of CPU, ~16% of all allocations): json.Unmarshal of the whole
path array into []interface{} for every observation of every candidate,
only to take len().
Changes:
- pathLen gets an allocation-free fast path for arrays of plain ASCII
strings and falls back to the original json.Unmarshal implementation for
everything else, so results are identical for all inputs (verified against
the reference on a fixed corpus and 300k randomised inputs).
- handleNodePaths no longer runs the SQL confirmation up front. Membership
is decided from the canonical resolved_path whenever one exists, which
also rejects hash collisions and stale index entries. The query is kept
for the few candidates with no canonical path, where the legacy fallback
arm still consumes confirmedBySQL.
- GetNodeHopAnalytics drops the same redundant pre-filter; its loop already
skips rp == nil and idx < 0.
- The index membership list for the queried pubkey is turned into a set once
instead of being scanned for every candidate.
- confirmResolvedPathQueries counts SQL confirmations so tests can pin the
N+1 as gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Note for the review — Playwright failure on the re-runThe re-run (run 37324912879, merge with master Every other PR run since #252 passed this test, and so did master. Please establish whether this PR causes it (for example through changed data or timing in the packets view), or whether it is a remainder of the flake. Run the test at least 5 times each on the merged tree and on master. |
Review — CS-pve-agent3 PR#246 paths-cpu — head 45dc081Dom: APPROVE med nits This is an independent, read-only review. Evidence tags: [T] means a test, probe, benchmark or command I ran in this review. [A] means analysis or reading of code. [K] means taken from the PR description or comments, and not re-checked here. The change does what it says. On the CI-prepared fixture and on a 20× scaled copy of it, Findings
1. Correctness: does the "no change" argument hold?
2.
|
| Test | master harness | merged head |
|---|---|---|
TestPathLenFast_MatchesReference |
pass (trivial) | pass |
TestPathLenFast_RandomisedAgainstReference |
pass (trivial) | pass |
TestPathLenFast_WellFormedPathsStayOnFastPath |
FAIL (11 allocs/call) | pass |
TestNodePaths_NoPerCandidateSQLConfirm |
FAIL (26 queries for 25 candidates) | pass (0) |
TestNodePaths_StaleIndexEntryStillExcluded |
FAIL (count 4, exclusion holds) | pass |
TestNodePaths_NoCanonicalPathStillConfirmedBySQL |
FAIL (count 2, exclusion holds) | pass |
Fast-path grammar [A]:
- whitespace is exactly JSON's four characters;
- strings must be printable ASCII without a backslash;
- empty input,
null, a BOM, numbers, nesting and trailing data all fall back.
On top of the PR's corpus and its 300k randomised inputs, I ran a 60 s native Go fuzz of pathLen against pathLenSlow: about 450k executions and no divergence [T].
3. Performance
BenchmarkPathLen (["aa","bb","cc","dd","ee"], -count 5, 4-vCPU box, idle) [T]:
| ns/op | B/op | allocs/op | |
|---|---|---|---|
before (master pathLen, json.Unmarshal) |
980–1024 | 456 | 16 |
| after (fast path) | 24.3–26.0 | 0 | 0 |
That is about 40× faster. My absolute numbers are about twice the PR's (467 → 15 ns), which is the machine; the ratio matches.
Endpoint timings against a local server (one server at a time, each started fresh on its own DB copy). Cold means the first request per node after start (empty LRU); warm means 3 more passes; hop is 3 passes of /hop_analytics?days=3650. All 202 nodes, sequential curl [T]:
| Dataset | Endpoint | master mean / p50 / p95 / max (ms) | PR mean / p50 / p95 / max (ms) |
|---|---|---|---|
| fixture (500 tx) | /paths cold |
0.92 / 0.72 / 1.97 / 7.8 | 0.67 / 0.56 / 1.08 / 4.4 |
| fixture | /paths warm |
0.92 / 0.72 / 2.06 / 5.1 | 0.63 / 0.57 / 1.06 / 2.2 |
| fixture | /hop_analytics |
0.83 / 0.70 / 1.64 / 8.9 | 0.51 / 0.50 / 0.69 / 0.85 |
| scaled (9,981 tx / 79,843 obs), run 1 | /paths cold |
10.08 / 4.45 / 31.2 / 213 | 1.70 / 0.86 / 3.86 / 53 |
| scaled, run 1 | /paths warm |
9.32 / 3.93 / 30.4 / 164 | 1.23 / 0.77 / 3.18 / 12.6 |
| scaled, run 1 | /hop_analytics |
8.76 / 3.72 / 27.7 / 147 | 0.83 / 0.63 / 1.85 / 6.8 |
| scaled, run 2 | /paths cold |
9.26 / 3.71 / 28.1 / 186 | 1.79 / 0.86 / 4.01 / 63 |
| scaled, run 2 | /paths warm |
9.75 / 4.32 / 32.2 / 179 | 1.42 / 0.95 / 3.76 / 13.6 |
| scaled, run 2 | /hop_analytics |
9.38 / 3.93 / 32.8 / 149 | 0.86 / 0.66 / 1.88 / 11.8 |
The scaled data is synthetic and local-disk, with no production-sized file. As in the PR, this probably understates the I/O saving on a multi-GB DB.
4. Rules
cmd/serverstays read-only. The only SQL change is a counter around an existingSELECT, andreadonly_invariant_test.gois in the green suite [T][A].- No new
map[string]interface{}: the non-test diff adds 0 [T]. - Locks:
handleNodePathsbuildsindexedForTargetunders.store.mu.RLock. That is O(len(index list)) once, replacing the O(candidates × list) scan that ran under the same lock.- The deferred SQL confirmation and the canonical fetches run between
RUnlockand the secondRLock, and the lock order is unchanged (lruMunever undermu). GetNodeHopAnalyticsfollows the same pattern.- The in-place filters (
kept := candidates[:0]) only alias the handler's local slice, notbyPathHop[A]. go test -raceon the touched tests and the full race suite are green [T].
- Fork guards: 9 in
deploy.ymland 1 inrelease-fast-path.ymlon the merged tree, with no.github/change [T]. - The single commit is authored and committed by
dborup <kontakt@meshview.dk>[T]. check-xss-sinks.sh --diff: no frontend files changed [T].
Playwright 1122 failure (requested in the PR comments)
Every run below is on its own server with a CI-prepared fixture [T]:
| Tree | Test version | Fixture age | Runs | Result |
|---|---|---|---|---|
origin/master 0572e7f |
fixed (#244) | ~45 min | 5 | 5 × 18/18 |
| merged tree (0572e7f + head) | fixed (#244) | ~45 min | 5 | 5 × 18/18 |
| both of the above | fixed | ~55 min | 1 each | 18/18 |
| PR head as-is (= CI's merge, base 2a7877a) | old | < 13 min | 7 | 7 × 18/18 |
| PR head as-is | old | ~22 min | 2 | 2 × 16/18, the exact CI failure: [desktop-1200]/[tablet-900] {"found":true,"hitIsLink":false,"text":"R5-D4 300D Rak"} |
| base 2a7877a without this PR | old | ~22 min | 2 | 2 × 16/18, same steps |
The PR changes no frontend file, and public/ plus the test are identical between master and the merged tree [T]. Verdict: a remainder of #244, caused by the stale CI base and not by this PR.
Tests (merged tree = origin/master 0572e7f + head, tree 49cb9bf3, git archive copies)
cmd/server:go test -race -count=1 ./...ok (ran 16 min on an idle box) [T].sh test-all.sh: 219 passed, 0 failed.node test-frontend-helpers.js: 707 passed, 0 failed [T].- The Go jobs in CI run 37324912879 also used the old base (finding 2).
Mutants (mine, each applied to a fresh copy and reverted; targeted PathLen|NodePaths|HopAnalytics|ResolvedIndex|Paths tests) [T]:
| Mutant | Result |
|---|---|
| B1 fast path accepts a backslash inside strings | caught: _RandomisedAgainstReference |
B3 trailing data after ] ignored |
caught: _MatchesReference, _Randomised… |
| B4 no SQL confirmation for no-canonical candidates (trust the index) | caught: _NoCanonicalPathStillConfirmedBySQL |
B6 /hop_analytics keeps a candidate whose path lacks the target |
caught: _StaleIndexEntryStillExcluded |
B7 /paths canonical arm trusts the hash index |
caught: _StaleIndexEntryStillExcluded, …AnchorBiasInconsistency_Issue1278 |
| B8 index membership set built for the wrong key | caught: 8 tests |
Not verified
- Behaviour and timings on production-sized data, or on a multi-GB DB file. Only the fixture and a synthetic 20× copy were measured.
- The pprof profile in the description [K].
- Concurrency under load (many parallel
/pathscalls); I measured sequential requests only. - A CI run on the current master base (finding 2).
Summary
/api/nodes/{pk}/paths(and/hop_analytics, which shares the same code) spend almost all of their time on two avoidable costs. This PR removes both without changing what the endpoints return.Evidence
A 60 s CPU profile (pprof) of a CoreScope v0.2.0 instance under a synthetic load (50 websocket clients, heavy endpoints at 20x production rate):
handleNodePathsconfirmResolvedPathContains(SQLite)pathLen(json.Unmarshal)confirmResolvedPathContainsis called byhandleNodePaths(90.5%) andGetNodeHopAnalytics(9.5%). In the allocation profile,pathLenaccounts for 6.8 GB of 42 GB allocated in about 12 minutes (16%), i.e. GC churn on top of the CPU cost. On a 2-vCPU host this is enough for a handful of concurrent/pathscalls to saturate the box.SELECT COUNT(*) ... INSTR(LOWER(resolved_path), ?), run sequentially, each scanning all observation rows of that transmission.pathLenparses the whole array just to count it.fetchResolvedPathForTxBestcalls it for every observation of every candidate (about 17 per transmission) and each call does a fulljson.Unmarshalinto[]interface{}.Changes
pathLen: allocation-free fast path for arrays of plain ASCII strings (the shape real observations have). Anything outside that grammar (escapes, non-ASCII, control bytes, nested values, numbers,null, trailing data, malformed input) falls back to the originaljson.Unmarshalimplementation (pathLenSlow), so results are identical for every input.handleNodePaths: the per-candidate SQL confirmation is no longer run up front. It only guards against hash collisions and a stale index. For every candidate that has a canonical persistedresolved_path, membership is decided again later from that exact path (resolvedPK == lowerPK), which yields the same answer. The query is now issued only for candidates with no canonical path, where the legacy fallback arm still consumesconfirmedBySQL.GetNodeHopAnalytics: drops the same pre-filter; its loop already skipsrp == nilandidx < 0.confirmResolvedPathQueriescounter so tests can assert the N+1 is gone.Why results are unchanged
If a candidate is rejected by the old SQL check, no observation of that transmission has the pubkey in its
resolved_path. The canonical path is one of those observations'resolved_path, so it cannot contain the pubkey either, and the later check rejects it. If the old check accepted it, the later check decides anyway. The only behavioural difference is that a few rejected candidates now go through the canonical-path fetch before being dropped.Test plan
go vet ./...andgo test ./... -count=1incmd/server(full suite passes)go test -raceon the touched paths (NodePaths,HandleNodePaths,HopAnalytics,ResolvedIndex,PathLen)TestPathLenFast_MatchesReference/_RandomisedAgainstReference: fast path vs the original implementation on a fixed corpus plus 300k randomised near-valid inputs;_WellFormedPathsStayOnFastPathpins zero allocationsTestNodePaths_NoPerCandidateSQLConfirm: 25 candidates, 0 confirmation queries (was 26 on master)TestNodePaths_StaleIndexEntryStillExcluded: a hash-index entry pointing at a tx whose stored path lacks the pubkey is still excluded from/pathsand/hop_analyticsTestNodePaths_NoCanonicalPathStillConfirmedBySQL: the fallback arm keeps its SQL confirmationhandleNodePathsMeasurements
Microbenchmark (
BenchmarkPathLen,["aa","bb","cc","dd","ee"]):json.Unmarshal)End-to-end on a synthetic dataset (4,000 transmissions x 8 observations, in-memory SQLite, same machine, master vs this branch):
/pathscold LRU/pathswarm/hop_analyticsThe in-memory test database has no disk I/O, so this understates the gain on a real deployment where the confirmation queries read scattered rows from a multi-GB file. I have not yet measured this on production-sized data; that is the next step.
Not in this PR (follow-ups)
fetchResolvedPathForObsstill issues one single-row query per candidate on a cold LRU (about 4 s of the 89 s profile).loadChunk/resolvePathForObsColdLoadallocate about 31 GB to build a 3 GB live heap (separate optimisation).🤖 Generated with Claude Code