Skip to content

fix(server): deterministic tie order in GetSubpathDetail (#273) - #319

Merged
dborup merged 1 commit into
masterfrom
codex/issue-273-subpath-detail-order
Oct 6, 2026
Merged

dborup merged 1 commit into
masterfrom
codex/issue-273-subpath-detail-order

Conversation

@dborup-agent

Copy link
Copy Markdown
Collaborator

Relates to #273

Follow-up to the #261 review (finding 1), re-confirmed on current master. GetSubpathDetail in cmd/server/store.go is a sibling of the #256-fixed rankSubpaths: it builds its parentPaths and observers lists by ranging over a map and sorts 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 (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 — path for parentPaths, name for observers. 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 parentPaths secondary key fails only the parentPaths test; stripping the observers secondary key fails only the observers test — each test independently kills its mutant.

Sibling scan (not fixed here — each needs its own test + PR)

sort.Slice on 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/server already carry a secondary key (fixed by #256 or earlier). Left out of scope to keep this PR to #273.

🤖 Generated with Claude Code

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>
@dborup-agent

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-MacBook PR#319 #273 — head 4659155

Status: Done. Both sorts in GetSubpathDetail now have a stable secondary key; two tests are red on master and green after; each kills its own mutant; full CI green.

Requirements

Requirement Test (red→green) Mutant
parentPaths deterministic tie order (secondary key path, top-15 cut stable) TestSubpathDetailParentPathsTieOrder_273 — 18 paths tied on 2 + one seen 5×, 50 runs, exact order required. Red on master (random cut+order), green after. [T] Strip the topParents secondary key → only this test fails; observers test still passes. [T]
observers deterministic tie order (secondary key name, top-10 cut stable) TestSubpathDetailObserversTieOrder_273 — 14 observers tied on 2 + one seen 5×, 50 runs, exact order required. Red on master, green after. [T] Strip the topObs secondary key → only this test fails; parentPaths test still passes. [T]

Both mutants verified independently via a scripted revert of each comparator (restored afterwards; git diff back to the fix). [T]

Perf

Comparator-only change — same single sort.Slice per list, still O(n log n), no new DB/index/resolver lookups (the tie-break reads the count/path/name already present in each in-memory entry). [A]

Sibling scan (sort.Slice on a count with no secondary key, in frontend-displayed endpoints)

Reported, not fixed (each needs its own red-first test + mutant + PR; out of #273 scope):

  • cmd/server/db.go:5574 — unscopedByRepeater (hop-depth analytics), map iteration, count-only. [A]
  • cmd/server/routes.go:4385 — RepeatersByRegion, count-only over a region map. [A]
  • cmd/server/routes.go:4426 — OriginatingNodesByRegion, count-only over a region map. [A]
  • cmd/server/node_reach.go:772 — directObs (node reach observers), count-only over a map. [A]
  • cmd/server/routes.go:2549 — node paths: LastSeen then Count, no final deterministic key (ties on both still random). [A]

All other count sorts in cmd/server already carry a secondary key (fixed by #256 or earlier). [A]

Checks

  • go build ./... + go vet . in cmd/server — clean. [T]
  • go test . (full cmd/server suite) — ok, 55s. [T]
  • sh test-all.sh — 222 passed, 0 failed. [T]
  • node test-frontend-helpers.js — passed (within test-all.sh). [T]
  • sh scripts/check-xss-sinks.sh --diff origin/master — clean (no public/** changes). [T]
  • No new map[string]interface{} outside tests; cmd/server introduces no DB writes. [T]
  • Fork-guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml; .github/ byte-identical to master. [T]
  • No hardcoded colors (backend-only change, no frontend edits). [A]

CI (run 37453800895, per job)

  • 🟢 Go Build & Test — success
  • 🟢 Playwright E2E Tests — success (covers subpath-detail rendering; no E2E asserts tie order, so the Go unit tests are the real coverage) [K]
  • 🟢 Build & Publish Docker Image — success (build ran; publish gated by fork guard)
  • ⚪ Release Artifacts / Deploy Staging / Publish Badges — skipped by fork guard

Known-flaky #271 was not hit; no reruns needed.

Remainders

  • The 5 siblings above are left for separate issues/PRs by design.
  • No local Go-server E2E was run for this change: no E2E asserts parentPaths/observers ordering (the "subpath" E2Es are a11y/contrast), so CI's full Playwright run is the relevant end-to-end coverage. [K]

Evidence legend: [T] run in this session, [A] from reading the code, [K] from CI.

@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator

Review — CS-Macmini PR#319 — head 4659155

Dom: APPROVE med nits

Independent, read-only review. Verified on the merged tree (git merge-tree --write-tree of origin/master and the full head SHA), from git archive copies in scratch. git ls-remote showed head 46591550 before and after. origin/master moved from 30c7de46 to 629a254a during the review (#306, #311); neither touches cmd/server/store.go, the merge is clean against both, and everything below was re-run on the newer merge.

Findings

# Sev Where Finding
F1 nit PR body + report, "Sibling scan" "All other count sorts in cmd/server already carry a secondary key (fixed by #256 or earlier)" is not correct. Four more count-only sorts over map iteration, all rendered to the user, are missing from the list — one of them capped, so its cut is random too, exactly the #273 severity. Details below.
F2 nit report, "Perf" "no extra lookups" overstates. The comparator does 4 map lookups + type assertions in the non-tie branch where master did 2, and — more significantly — where master's all-equal comparator let pdqsort take its equal-elements fast path, the fix now performs a real sort. Measured below. Immaterial in absolute terms; the claim just is not accurate as written.

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 killed

Red on master, deterministically: the two new tests were copied onto the plain origin/master tree and run with -count=10. Both failed in all 10 iterations, each failing on internal run 0 of its own 50, i.e. the first sample already differs. [T]

Green on head: -count=20 ok, and -run '_273$|_256$' -shuffle=on -count=5 ok. [T]

Stronger than the unit tests — red/green at the API level. I built a server from each tree, pointed both at the same prepared e2e-fixture.db, and called GET /api/analytics/subpath-detail 30 times per hop pair: [T]

endpoint call tree distinct orders / 30 distinct member sets / 30
hops=d6,33 parentPaths (15 shown, 14 tied slots) master 30 30
hops=d6,33 parentPaths head 1 1
hops=29,87 observers (10 shown, 7 tied slots) master 28 —
hops=29,87 observers head 1 1

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 — public/analytics.js maps data.observers and data.parentPaths into the subpath detail panel. [A]

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]

Mutant Result
M1 strip the topParents secondary key killed — only …ParentPathsTieOrder_273 fails
M2 strip the topObs secondary key killed — only …ObserversTieOrder_273 fails
M3 (mine) reverse the parentPaths tie-break to > killed — only the parentPaths test fails
M4 (mine) reverse the observers tie-break to > killed — only the observers test fails
M5 (mine) apply the top-15 cut before the sort killed — parentPaths test fails

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 rankSubpaths?

Yes, identical in shape — if count != count { return count > count } then an ascending string key — matching rankSubpaths at cmd/server/store.go:11167 (#256, restored by #293). rankSubpaths breaks on path; #319 breaks on path for parentPaths and name for observers. [A]

Meaningful to the user: both keys are exactly the strings rendered in the panel, so the tie order is alphabetical in the column the reader is looking at. [A]

A total order, not just a tie-break: in both cases the secondary key is the key of the source map, so it is unique by construction. The comparator is therefore strict and total, with no residual nondeterminism behind the tie-break. [A]

Stable across calls: 30/30 identical, measured above. [T]
Stable across restarts: yes for a fixed dataset — both lists are derived purely from the matched transmissions, with no clock, cache, or iteration-order input left in the comparator. One caveat worth knowing rather than fixing: parentPaths keys are resolved display names, so if hop→node resolution changes (new adverts shifting the prefix map), the display string changes and with it its sort position. That is inherent to sorting on the display value and is exactly what rankSubpaths already does — not a regression, and not something #273 asks for. [A]

Point 3 — the sibling list (F1)

I ran the scan myself: 90 non-test sort.Slice/sort.SliceStable sites in cmd/server, each inspected with its comparator.

All five entries the report lists are accurate — db.go:5574, routes.go:4385, routes.go:4426, node_reach.go:772, and the routes.go:2539 LastSeen-then-Count sort (cited as 2549, which is inside that comparator). [T]

Four are missing, all count-only over map iteration, all displayed to the user: [T]

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 the subpath-detail endpoint; it asserts contrast, not order)
    • test-issue-1705-subpath-contrast-e2e.js — pass
    • test-path-inspector-e2e.js — 6 passed, 0 failed
    • test-path-inspector-coverage-e2e.js — 10 passed, 0 failed
    • test-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 with origin/master pinned to the real master — clean, "no public/**/*.{js,html} changes to scan". The diff touches only cmd/server/store.go and the new test file. [T]
  • No hardcoded colors — no frontend changes at all. [T]
  • Fork guards: 9 occurrences in deploy.yml (8 live if: guards plus 1 in a comment) and 1 in release-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

Evidence legend: [T] run by me in this session, [A] from reading the code, [K] from CI.

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.

3 participants