Files
meshcore-analyzer/cmd
efitenandClaude Opus 5.5 be9f382526 perf(server): /paths reads canonical paths in batches and skips redundant collision checks (#2157)
Refs #2146 (item: `/api/nodes/:pubkey/paths`).

## Situation

Per-step timing in a staging build (3 most-connected nodes, 2 calls
each) showed where a `/paths` request spends its time:

| Step | Time |
|---|---|
| build candidates under the read lock | 73-115 ms |
| collision check: one `INSTR(LOWER(resolved_path), ?)` query per index
hit, 13,367-16,790 per call | 1.3-2.7 s (first call after start: 13.5 s)
|
| canonical resolved_path per survivor (`bestResolvedPath`, one or two
queries each) | 1.7-2.9 s |
| aggregation under the read lock | 82-120 ms |

So 3.2-4.1 s of a 3.4-5.1 s request was SQL.

On a production database (busiest node, 27k transmissions, read-only):
the collision check costs 1.85 s one by one and still 1.69 s batched,
because SQLite reads and lowercases every stored path either way.
Reading the canonical path (the longest observation's stored path) costs
0.18 s one by one and 0.09 s batched by id.

## My changes in this PR

- **The collision check runs only where it decides something.** The
aggregation keeps a transmission with a canonical path only if that path
names the node (`if !containsTarget { continue }`). If no stored path
names the node, the canonical one does not either. So for index hits
with a canonical path the check changes nothing, and it now runs only
for index hits without one, as before.
- **Canonical paths are read in batches.** `loadCanonicalResolvedPaths`
reads the stored path of each candidate's longest observation, one query
per 500 transmissions. When that path is missing, empty or unparseable
it calls `bestResolvedPath` for that transmission, so the result is the
same as before.
- The snapshot of observation IDs and `path_json` strings is taken in
the existing candidate read lock (string copies, no parsing).

The PR has two commits: a first version that batched both queries and
did not get faster on staging, and the change described above, whose
message has the measurements. Squash-merging is fine.

## Measurements

Staging, after the ingestor's startup `ANALYZE`, same probe for both
builds (ms):

| | without this PR | with this PR |
|---|---|---|
| node 1 (degree 55), call 1 / call 2 | 22,237 / 3,364 | 1,793 / 1,686 |
| node 2 (degree 47) | 4,427 / 3,615 | 1,828 / 1,809 |
| node 3 (degree 46) | 5,171 / 4,035 | 3,050 / 2,408 |
| 4 concurrent clients over 40 nodes, 120 s: calls / p50 / p95 | 73 /
6,717 / 15,160 | 125 / 2,861 / 10,630 |

Both staging builds also contain #2147 and #2155.

## Tests

- `TestLoadCanonicalResolvedPathsMatchesBestResolvedPath` compares the
batched result with `bestResolvedPath` on seven cases: the #810 case
(longest observation without a stored path), equal lengths, an empty
stored path, case, an observation missing from the snapshot, longest
wins, nothing stored.
- `TestLoadCanonicalResolvedPathsBatchesQueries`: 1,001 transmissions
take 3 batched queries and no per-transmission lookups.
- `TestHandleNodePaths_CanonicalPathDecidesIndexHits`: the longest
observation names A, a shorter one names B; both are index hits. A gets
the transmission, B does not, with no per-transmission query. Run
against master, the A/B results are the same and only the query-count
check fails (1 there, 0 here).
- The existing `/paths` tests pass unchanged (#810, #1278, #1352
collision and fallback cases, anchor bias).
- `cmd/server`: `go test ./...` and `go test -race` green.

## Not in this PR

- `nodeInResolvedPathViaIndex` still calls the per-transmission check;
it has no production caller, only tests.
- The concurrent p95 is still 10.6 s on staging. What is left there is
the 4-connection read pool shared with other endpoints; a staging A/B
with 8 connections gave no gain (`/paths` throughput dropped from
133-146 to 81-103 calls per 120 s), so the pool stays at 4.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:58:33 +02:00
..