Repository navigation
fix(server): deterministic tie order in GetSubpathDetail (#273) - #319
Conversation
GetSubpathDetail builds parentPaths and observers by ranging over a map and sorted on the count only. Go map iteration order is random and sort.Slice is not stable, so tied entries came out in a different order on each call, and in the top-15 / top-10 caps it was random which tied entries made the cut. This is the same bug class as #256 (rankSubpaths). Add a stable secondary key (path for parentPaths, name for observers), matching the rankSubpaths fix. Two tests compute the detail 50 times over tied inputs and require the fully specified order every run; each kills a mutant that strips its secondary key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rapport — CS-MacBook PR#319 #273 — head 4659155Status: Done. Both sorts in Requirements
Both mutants verified independently via a scripted revert of each comparator (restored afterwards; PerfComparator-only change — same single Sibling scan (
|
Review — CS-Macmini PR#319 — head 4659155Dom: APPROVE med nits Independent, read-only review. Verified on the merged tree ( Findings
Neither finding is in the shipped code. The code change itself is minimal, correct, in scope, and well covered. Point 1 — red on master, green on head, mutant killedRed on master, deterministically: the two new tests were copied onto the plain Green on head: Stronger than the unit tests — red/green at the API level. I built a server from each tree, pointed both at the same prepared
On master the top-15 cut itself was random: which parent paths the user even sees changed on every single request. On head, 30/30 identical across 5 hop pairs. Both fields are rendered directly — Mutants — the author's two reproduced, plus three of my own, each via a scripted edit of the comparator and a verified restore afterwards (3 iterations each): [T]
M1/M2 confirm the independence claim: each test kills only its own mutant. M5 confirms the tests pin cut stability, not just ordering. Point 2 — is the secondary key the same as
|
| Location | List | Source map | Cap | Rendered at |
|---|---|---|---|---|
cmd/server/node_analytics_summary.go:409 |
peerInteractions |
a.peerDetail |
20 — random cut | public/node-analytics.js:152 |
cmd/server/node_analytics_summary.go:391 |
observerCoverage |
a.obsDetail |
none | public/node-analytics.js:249 |
cmd/server/store.go:10521 |
observerRows (GetNodeHealth) |
observerStats |
none | public/live.js:2694 |
cmd/server/store.go:10353 |
observerRows (GetBulkHealth) |
observerStats |
none | same shape |
Confirmed empirically, not just by reading: driving finalizeDisplayArrays 50 times over 25 peers all tied on count 2 and 14 observers all tied on count 2, on current master — peerInteractions gave 50 distinct orders and 50 distinct member sets out of 50 (the cap-20 cut is random, same as #273's top-15 was), and observerCoverage gave 50 distinct orders out of 50. [T]
peerInteractions is therefore the same severity as the bug this PR fixes, not a lesser cousin. Leaving the four out of scope is fine and I agree with the decision; the nit is the "all other count sorts already carry a secondary key" sentence, which would make the follow-up issues come out incomplete.
One lower-priority candidate for the same list: store.go:9569 (collisions) sorts on class order then Appearances with no final key, so ties on both stay random — the same shape as the routes.go:2539 entry already listed.
Point 4 — perf, read-only, no new maps
Still O(n log n): comparator-only change, one sort.Slice per list as before, and no DB, index, or resolver call added — the tie-break reads count/path/name already present in each in-memory entry. [A]
"No extra lookups" is not accurate (F2). Isolated measurement of the two comparator bodies on the exact slice shape the function sorts, excluding the resolver loop, n=2000, -benchtime 300x -count 3 (runs were tight): [T]
| input | master comparator | #319 comparator | typed-struct form |
|---|---|---|---|
| all counts tied | 37 µs | 668 µs | 204 µs |
| mixed counts 1..50 | 247 µs | 672 µs | 202 µs |
| n=50, all tied | 1.0 µs | 8.0 µs | — |
The large all-tied ratio is not the lookups — it is that master's comparator returned false for every pair, so pdqsort took its equal-elements fast path and did almost no work. Doing real ordering work is the point of the fix, so this is a cost worth paying, and in absolute terms it is sub-millisecond: end-to-end GetSubpathDetail on a synthetic 2000-parent-path subpath measured ~3 ms, dominated by the per-transmission resolveWithContext loop. Hoisting the counts into locals changed nothing (3.42 vs 3.43 ms end to end), so if this is ever worth tightening the lever is the one rankSubpaths already pulls: sort a typed struct instead of []map[string]interface{}, measured ~3.2× faster than the map form above. Not worth doing in this PR. [T]
cmd/server read-only: no INSERT/UPDATE/DELETE/Exec( added anywhere in the diff. [T]
No new map[string]interface{} outside tests: the diff adds none — the entries were already maps on master, and the comparator only reads them. [T]
Tests I ran
On the merged tree against origin/master 629a254a:
cmd/server:go build ./...,go vet .clean;go test .ok (~45 s), 5 clean runs. [T]- Other Go modules (the repo is multi-module):
cmd/ingestor,cmd/migrate,cmd/decrypt,internal/packetpath,internal/lora,internal/channel— all ok. [T] sh test-all.sh— 225 passed, 0 failed. [T]node test-frontend-helpers.js— 709 passed, 0 failed. [T]- E2E against a local Go server with
e2e-fixture.db(freshen,corescope-migrate, seeds 2073 and 199, instrumented frontend), server stopped afterwards by port lookup: [T]test-a11y-1705-subpath-hop-prefix-e2e.js— pass (the only E2E that references thesubpath-detailendpoint; it asserts contrast, not order)test-issue-1705-subpath-contrast-e2e.js— passtest-path-inspector-e2e.js— 6 passed, 0 failedtest-path-inspector-coverage-e2e.js— 10 passed, 0 failedtest-analytics-subtab-deeplinks-205.js— 121 passed, 0 failed
- Live API order-stability probe, master vs head — table in Point 1. [T]
Gates:
sh scripts/check-xss-sinks.sh --diff origin/master, run at the head SHA withorigin/masterpinned to the real master — clean, "nopublic/**/*.{js,html}changes to scan". The diff touches onlycmd/server/store.goand the new test file. [T]- No hardcoded colors — no frontend changes at all. [T]
- Fork guards: 9 occurrences in
deploy.yml(8 liveif:guards plus 1 in a comment) and 1 inrelease-fast-path.yml;.github/is byte-identical to master, so they cannot have regressed. [T] - No closing keywords — PR body says "Relates to Deterministic order in GetSubpathDetail (same tie-order bug class as #256) #273"; the commit subject's trailing
(#273)is not a closing reference. [T] - Commit author and committer are
dborup <kontakt@meshview.dk>, one commit, 2 files, +109/−2. [T] - No behaviour change beyond the purpose: the primary branch is byte-for-byte master's comparator, so with no tied counts the output is unchanged; the behaviour differs only where counts tie, which is the point. [A]
CI, checked per job on run 37453800895, which ran on exactly head 46591550: [K]
- Go Build & Test — success (14m07s)
- Playwright E2E Tests — success (24m48s)
- Build & Publish Docker Image — success (50s)
- Release Artifacts / Deploy Staging / Publish Badges — skipped by the fork guard
No hits on the known-flaky #256 or #267; no reruns needed.
What I did not verify
- One unattributed
cmd/serverfailure. The firstgo test .on the newer merged tree exited non-zero, and I did not capture that run's output — only the tail, whose last line beforeFAILwas[db] WAL checkpoint complete. It did not reproduce in 5 later runs of the same tree or 4 runs of plainorigin/master. I cannot name it, so I cannot call it a known flake; the shape is consistent with the flaky: TestResolvedPathBackfill_WriteHoldUnderBudget_188 — wall-clock write-hold budget fails under CI load #267 backfill write-hold class, and it cannot plausibly come from this PR, which is a comparator-only change that touches no DB code. Worth knowing that it exists. - I did not apply CI's inline Packets page collapse button in the left column of the table opens the dialog. Kpa-clawbot/CoreScope#1486 grouped-row seed SQL or the feat(nodes): estimated advert intervals (flood / zero-hop) per node, then mesh-wide settings analytics #245 advert-intervals seed. None of the E2Es I ran read them.
- I did not run the full local Playwright matrix — only the subpath, path-inspector and analytics-deeplink files above. CI ran the whole matrix green on this head.
- My local Playwright was pinned to 1.58.2 so it could use the already-cached Chromium 1208; that is not CI's exact
npm ciresolution. - The four missing siblings in F1: confirmed nondeterministic and confirmed rendered, but I did not check how much each one's order actually matters to a reader, and I filed nothing.
- No staging or production access, no server API key used, no writes to this PR, the branch, or any upstream.
Evidence legend: [T] run by me in this session, [A] from reading the code, [K] from CI.
Relates to #273
Follow-up to the #261 review (finding 1), re-confirmed on current master.
GetSubpathDetailincmd/server/store.gois a sibling of the #256-fixedrankSubpaths: it builds itsparentPathsandobserverslists by ranging over a map and sorts on the count only. Go map iteration order is random andsort.Sliceis not stable, so tied entries came out in a different order on each call, and in the top-15 (parentPaths) / top-10 (observers) caps it was random which tied entries made the cut.Fix
Same pattern as
rankSubpaths/ #256: add a stable secondary key to each sort —pathforparentPaths,nameforobservers. Comparator-only change; the sort stays O(n log n) with no extra lookups, and the no-tie output is unchanged.Tests (red before, green after)
TestSubpathDetailParentPathsTieOrder_273— 18 parent paths tied on count 2 plus one seen 5×, cap 15. Computes the detail 50× and requires the same fully specified order (high-count first, then tied paths ascending, cut stable).TestSubpathDetailObserversTieOrder_273— 14 observers tied on count 2 plus one seen 5×, cap 10. Same 50-run determinism assertion on observer name order.Both are red on master (random cut + order) and green after the fix. Mutants: stripping the
parentPathssecondary key fails only the parentPaths test; stripping theobserverssecondary key fails only the observers test — each test independently kills its mutant.Sibling scan (not fixed here — each needs its own test + PR)
sort.Sliceon a count with no secondary key, in endpoints the frontend displays:cmd/server/db.go:5574—unscopedByRepeater(hop-depth analytics), map iteration, count-only.cmd/server/routes.go:4385—RepeatersByRegion, count-only over a region map.cmd/server/routes.go:4426—OriginatingNodesByRegion, count-only over a region map.cmd/server/node_reach.go:772—directObs(node reach observers), count-only over a map.cmd/server/routes.go:2549— node paths: sorts by LastSeen then Count, no final deterministic key (ties on both are random).All other count sorts in
cmd/serveralready carry a secondary key (fixed by #256 or earlier). Left out of scope to keep this PR to #273.🤖 Generated with Claude Code