fix(#1827): avoid per-observation SQL fetch in handleObserverAnalytics hot loop - #1829
fix(#1827): avoid per-observation SQL fetch in handleObserverAnalytics hot loop#1829SaarMesh-Bot wants to merge 3 commits into
Conversation
|
PR watch v3 — parallel polish carmack (perf): Correct fix. dijkstra (correctness): Fields aligned — munger (invariant): kent-beck (tests): Real assertion added — counts payload_type=4→2, =5→1 via seeded fixtures + verifies Verdict: 0 BLOCKER, 0 MAJOR. Ship-ready. Gate: CI is |
… after release handleObserverAnalytics (touched by Kpa-clawbot#1829) and enrichObs() both read s.store.byTxID after the snapshot RLock in handleObserverAnalytics had already been released. byTxID is guarded by s.mu for writes (ingest, eviction); reading it unlocked races with those writers — Go maps can panic with "concurrent map read and map write" during a rehash, this is not just a -race-detector-only concern. Per the Kpa-clawbot#1830 triage (@carmack): re-taking the RLock for the whole loop would revert the Kpa-clawbot#1481 P0-2 fix that deliberately keeps JSON decode/enrichment off the hot lock. Correct fix is to resolve the *StoreTx pointers during the existing RLock-held snapshot and pass them down instead of looking them up later. Changes: - handleObserverAnalytics: extend the RLock snapshot block to also resolve a txByID map (keyed by TransmissionID, not observation index, since multiple observations can share one transmission) alongside the existing obsSnapshot. RUnlock still happens immediately after. - Aggregation loop now reads txByID[...] instead of s.store.byTxID[...] directly. - store.go: split enrichObs into enrichObs (unchanged behavior/ signature, for the two existing callers that already hold s.mu for the whole call: GetPacketByID, GetObservationsForHash) and a new enrichObsWithTx(obs, tx) that takes an already-resolved *StoreTx. handleObserverAnalytics's recentPackets now calls enrichObsWithTx(obs, txByID[...]) instead of enrichObs(obs), so no unlocked byTxID read remains in this handler. Audited the other unlocked-looking byTxID reads the triage flagged (store.go: GetTransmissionByID, transmissionsForObserver, computeAnalyticsRF, GetBulkHealth) — all four are either directly under s.mu.RLock() in the same function, or (transmissionsForObserver) only ever called from callers that already hold s.mu.RLock() around the call. No changes needed there. Testing: - New TestObserverAnalytics_ConcurrentByTxIDMutation_1830: hammers the endpoint concurrently with goroutines mutating byTxID under s.mu (mirroring ingest/eviction), run under `go test -race` — passes cleanly (previously this exact scenario was the mechanism of the reported risk). - Existing TestObserverAnalytics (incl. the Kpa-clawbot#1829 packetTypes/ recentPackets assertions) passes unchanged — byte-identical output. - Full `go test ./...` in cmd/server: no new failures; same pre-existing test-order-dependent TestHandleNodePaths_* flake reproduced independently on a fresh unpatched upstream/master clone. - Full-suite `go test -race ./...` was not run to completion (VPS build host is memory-constrained with the production corescope server also resident); the targeted concurrent regression test above was run standalone under -race instead. Co-Authored-By: Claude <noreply@anthropic.com>
|
PR watch v3 — re-review after 40348eb (fix for #1830) carmack (perf/correctness): Fix is right. Resolving dijkstra (design): The kent-beck (tests): meshcore (protocol): N/A — no wire behaviour change. 3-axis: mergeable=MERGEABLE · CI=BLOCKED ( Blockers for auto-merge:
|
|
Re: kent-beck's MAJOR — verified against
|
|
Gentle ping for a maintainer: the CI/CD Pipeline run for this PR is currently sitting in Branch is up to date with |
…rverAnalytics hot loop Observer detail pages were saturating CPU on busy observers — reported as a DoS vector: loading 6-7 concurrently was enough to peg 12 cores for seconds, and repeated auto-refresh made it worse. handleObserverAnalytics iterated every observation in the requested window and called enrichObs() per observation to read payload_type and decoded_json for the packetTypes/nodesTimeline aggregates. enrichObs() also does an on-demand SQL SELECT (fetchResolvedPathForObs) and builds a full response map — both unused by this loop, since resolved_path is only actually consumed by the <=20 kept recentPackets entries. Fix: read payload_type/decoded_json directly off the transmission via s.store.byTxID[obs.TransmissionID] for the aggregate loop, and reserve the full enrichObs() call (with its resolved_path SQL lookup) for the <=20 recentPackets entries that display it. Output is unchanged — packetTypes/nodesTimeline are computed from the exact same fields, just without the O(N) SQL round-trips and map allocations per request. Per the issue triage: this is scoped to the concrete hot-path fix identified by review (byTxID fast-path), not the broader endpoint-split proposal in Kpa-clawbot#1828, which stays a separate follow-up. Co-Authored-By: Claude <noreply@anthropic.com>
… after release handleObserverAnalytics (touched by Kpa-clawbot#1829) and enrichObs() both read s.store.byTxID after the snapshot RLock in handleObserverAnalytics had already been released. byTxID is guarded by s.mu for writes (ingest, eviction); reading it unlocked races with those writers — Go maps can panic with "concurrent map read and map write" during a rehash, this is not just a -race-detector-only concern. Per the Kpa-clawbot#1830 triage (@carmack): re-taking the RLock for the whole loop would revert the Kpa-clawbot#1481 P0-2 fix that deliberately keeps JSON decode/enrichment off the hot lock. Correct fix is to resolve the *StoreTx pointers during the existing RLock-held snapshot and pass them down instead of looking them up later. Changes: - handleObserverAnalytics: extend the RLock snapshot block to also resolve a txByID map (keyed by TransmissionID, not observation index, since multiple observations can share one transmission) alongside the existing obsSnapshot. RUnlock still happens immediately after. - Aggregation loop now reads txByID[...] instead of s.store.byTxID[...] directly. - store.go: split enrichObs into enrichObs (unchanged behavior/ signature, for the two existing callers that already hold s.mu for the whole call: GetPacketByID, GetObservationsForHash) and a new enrichObsWithTx(obs, tx) that takes an already-resolved *StoreTx. handleObserverAnalytics's recentPackets now calls enrichObsWithTx(obs, txByID[...]) instead of enrichObs(obs), so no unlocked byTxID read remains in this handler. Audited the other unlocked-looking byTxID reads the triage flagged (store.go: GetTransmissionByID, transmissionsForObserver, computeAnalyticsRF, GetBulkHealth) — all four are either directly under s.mu.RLock() in the same function, or (transmissionsForObserver) only ever called from callers that already hold s.mu.RLock() around the call. No changes needed there. Testing: - New TestObserverAnalytics_ConcurrentByTxIDMutation_1830: hammers the endpoint concurrently with goroutines mutating byTxID under s.mu (mirroring ingest/eviction), run under `go test -race` — passes cleanly (previously this exact scenario was the mechanism of the reported risk). - Existing TestObserverAnalytics (incl. the Kpa-clawbot#1829 packetTypes/ recentPackets assertions) passes unchanged — byte-identical output. - Full `go test ./...` in cmd/server: no new failures; same pre-existing test-order-dependent TestHandleNodePaths_* flake reproduced independently on a fresh unpatched upstream/master clone. - Full-suite `go test -race ./...` was not run to completion (VPS build host is memory-constrained with the production corescope server also resident); the targeted concurrent regression test above was run standalone under -race instead. Co-Authored-By: Claude <noreply@anthropic.com>
40348eb to
2721934
Compare
|
Rebased onto current master to resolve the conflict with #1828's refactor (handleObserverAnalytics split into observer_analytics.go helpers). Since #1828 already eliminated the per-observation SQL fetch this PR originally targeted (buildPacketTypes/buildNodesTimeline read the transmission directly, same optimization as #1827), that first commit is now effectively a no-op against the new structure. The remaining substantive change is the #1830 fix: buildPacketTypes / buildNodesTimeline / buildRecentPackets now take the RLock-resolved Adjusted the accompanying unit tests in observer_analytics_test.go for the new signatures. Mergeable state should be clear now — just needs the workflow-approval gate. |
|
PR watch v3 — re-review after rebase onto #1828 refactor carmack (perf/correctness): LGTM. txByID resolved once under the same RLock that snapshots obsList ( kent-beck (tests): MINOR — dijkstra (invariants): Concurrency comments on munger (inversion): No surface for silent regression — if a future caller reintroduces a post-RUnlock Verdict: 0 BLOCKER, 0 MAJOR, 1 MINOR (advisory: revert-verify the -race test locally). Ready to merge once maintainer approves the Auto-merge NOT triggered: |
|
Bot review has been green for a while now — this looks ready whenever a maintainer has a moment to approve the CI run (action_required gate). Let me know if anything else is needed on my end. Thanks! |
Kpa-clawbot
left a comment
There was a problem hiding this comment.
PR watch v3 — parallel polish after ec9e916 (master merge)
Solid two-part fix: #1827 drops per-obs enrichObs() (and its fetchResolvedPathForObs SELECT) from the aggregate loop; #1830 hoists byTxID resolution under the existing RLock so map reads no longer race with ingest/eviction writers.
Verdict: 0 BLOCKER / 0 MAJOR / 2 MINOR / 1 NIT
carmack (perf) — LGTM. txByID snapshot adds one map[int]*StoreTx alloc sized to the window in exchange for N SELECTs + N enrichObs map allocs per request. Big net win on the reported hot observers; ≤20 recentPackets still get full enrichment. routes.go:2848-2862.
dijkstra (correctness) — LGTM with note. enrichObs/enrichObsWithTx split is clean; the docstring pins the lock contract. Verified the other flagged unlocked byTxID reads (GetTransmissionByID, transmissionsForObserver, computeAnalyticsRF, GetBulkHealth) either hold s.mu.RLock() in-frame or are only called under it — matches the PR body's audit.
MINOR (dijkstra): field-level mutation of an existing *StoreTx after RLock release (e.g. re-decode updating PayloadType/DecodedJSON in place) would still race. Out of scope for #1830 (which targets the map-rehash panic), but worth confirming ingest only replaces map entries, never mutates through the pointer. observer_analytics.go:13-21.
kent-beck (tests) — LGTM. TestObserverAnalytics_ConcurrentByTxIDMutation_1830 is a real regression: writers under s.mu.Lock() do add+delete on byTxID mirroring ingest/eviction, readers hammer the endpoint 8×50 — reproduces the pre-fix rehash race under -race.
MINOR (kent-beck): plain go test (no -race) will pass even without the fix. Add a comment that it's only meaningful under -race, or a testing.Short()/race-gate skip.
NIT (munger): if _, ok := txByID[…]; !ok guard at routes.go:2857 is redundant — reassigning the same nil is cheaper than the branch. Drop it.
Ready to merge once CI reports green (no check-runs on ec9e916 yet; PR is BLOCKED). Not auto-merging.
Bot polish re-review (post-master-merge, delta-only)Verdict: LGTM (unchanged) — merge commit
Blocked on: maintainer-approved CI run only. Nothing for bot to do. |
Review — PR #1829: avoid per-observation SQL fetch in handleObserverAnalyticsVerdict: LGTM with minor notes. 0 BLOCKER · 0 MAJOR · 2 MINOR What's good@carmack — perf/systems: The core fix is clean and correct. Snapshotting @kent-beck — TDD/test quality: The race regression test ( @dijkstra — correctness: The concurrency note update in MINOR findings
|
Summary
Fixes the CPU/DoS issue in #1827: observer detail pages were saturating CPU on busy observers — 6-7 concurrently loaded tabs pegged 12 cores for seconds, and auto-refresh made it self-sustaining.
Root cause
handleObserverAnalyticsiterated every observation in the requested window and calledenrichObs()per observation just to readpayload_typeanddecoded_jsonfor thepacketTypes/nodesTimelineaggregates.enrichObs()also runs an on-demand SQLSELECT resolved_path FROM observations WHERE id=?(fetchResolvedPathForObs) and builds a full response map — both of which are unused by this aggregation loop.resolved_pathis only actually consumed by the<=20keptrecentPacketsentries.Per the triage in #1827 (@carmack): "Replacing
enrichObs(obs)with a directs.store.byTxID[obs.TransmissionID].PayloadTyperead (as sketched in the body) drops a map alloc + interface boxes per obs on the loop that saturated the operator's 12 cores. Byte-identical output. That's ~90% of the value."This PR implements exactly that fast-path.
Change
packetTypes,nodesTimeline): readpayload_type/decoded_jsondirectly off the transmission vias.store.byTxID[obs.TransmissionID]— no SQL, no per-obs map allocation.recentPackets(<=20entries): unchanged, still callsenrichObs()since it needsresolved_path/raw_hex/etc. for display.packetTypes/nodesTimelineare computed from the exact same underlying fields (tx.PayloadType,tx.DecodedJSON), just without the O(N) SQL round-trips.Scope
This is the concrete hot-path fix from #1827's triage — not the broader
/api/observers/{id}/analyticsendpoint-split proposal in #1828, which (per that issue's discussion) is a separate P3 follow-up. #1828's own triage converged on this samebyTxIDfast-path as "the ground-work minimum" before any endpoint splitting.Testing
TestObserverAnalyticspasses unchanged.TestObserverAnalytics/defaultto assertpacketTypescounts come out correct ({"4":2,"5":1}for the seeded fixture) via the newbyTxIDpath, and thatrecentPacketsstill carriesresolved_pathwhere present (confirming theenrichObs()path for those 20 entries is untouched).go build ./...andgo vet ./...clean incmd/server.go test ./...incmd/server: passes except 4 pre-existing test-order-dependent failures inTestHandleNodePaths_*(unrelated to this change — reproduced identically on a fresh, unpatched clone ofupstream/master).