Repository navigation
fix(server): distance analytics reads a torn snapshot under concurrent ingest/eviction - #104
Merged
Merged
Conversation
…lices
computeAnalyticsDistance snapshots distHops/distPaths under s.mu.RLock,
but the area-without-region branch iterated s.distHops and s.distPaths
directly after RUnlock. updateDistanceIndexForTxs (ingest) and eviction
shrink those slices under s.mu.Lock, so the loop could index past the
new length and panic ("index out of range") in
/api/analytics/distance?area=..., and the unlocked field reads are a
data race.
The branch now iterates hopsSnap/pathsSnap like the rest of the compute.
Tests park the compute inside the area loop and run the real writer
(updateDistanceIndexForTxs, EvictStale) before letting it finish; plus a
-race stress test against a writer that swaps the slices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqfyMm2rmAvNr27smJjyDe
computeAnalyticsDistance copies only the distHops/distPaths slice
headers under s.mu.RLock and reads the elements after RUnlock. The
comment claimed the slices were append-only, but
updateDistanceIndexForTxs (ingest, on a best-path change) and eviction
compact them in place under s.mu.Lock, over the same backing array.
A compute holding a snapshot then saw survivors shifted down and stale
tail records: duplicated/missing records and wrong totals, averages,
histograms and top paths in /api/analytics/distance, plus a data race.
- compactDistIndex replaces both compaction loops. It compacts in
place (dst[n] = r, no allocation) unless a compute holds a snapshot,
in which case it copies into fresh arrays and leaves the old one to
the snapshot.
- distSnapReaders pins the snapshot: incremented under s.mu.RLock
together with taking it, decremented once the filter loops have
copied it (and on panic). Writers read it under s.mu.Lock, so every
compute that took a snapshot before the writer got the lock is seen.
- The "append-only" safety comment is corrected.
Tests: region+area computes parked after RUnlock across eviction and
updateDistanceIndexForTxs; area-only variants that keep some records;
in-place-unless-pinned; pin released for all region/area combinations;
a -race stress test on the default ("", "") compute; and a benchmark
of one-tx updateDistanceIndexForTxs at ~110K hop records.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqfyMm2rmAvNr27smJjyDe
compactDistIndex compacts distHops/distPaths in place when no snapshot is pinned, but left the vacated tail (between the new and old length) untouched. Those stale records hold *StoreTx pointers to removed and evicted transmissions, keeping them and their observations reachable until a later append overwrites the slot. Eviction is memory-driven, so it should actually release what it evicts. The in-place branch now clear()s the tail of both slices. The pinned branch writes to fresh arrays and must not touch the old one, which belongs to the snapshot, so it clears nothing. Test: after unpinned compaction via updateDistanceIndexForTxs and via EvictStale, every tail element is the zero value and the live records are intact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqfyMm2rmAvNr27smJjyDe
dborup
marked this pull request as ready for review
September 26, 2026 13:44
dborup
added a commit
that referenced
this pull request
Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
computeAnalyticsDistancetakes a snapshot ofdistHops/distPathsunders.mu.RLockand does all filtering outside the lock. Two paths broke that snapshot contract:areaset and noregion, the match-set loops iterateds.distHops/s.distPathsdirectly afterRUnlock. Ingest (updateDistanceIndexForTxs) and eviction shrink those slices unders.mu.Lock, so the loop indexes past the new length and panics in/api/analytics/distance?area=….updateDistanceIndexForTxs(s.distHops[n] = r; s.distHops = s.distHops[:n]) andevictStaleInternal(newDistHops := s.distHops[:0]; append…) compact in place unders.mu.Lock, over the backing array a compute is still reading. The compute then sees survivors shifted down plus stale tail records, i.e. duplicated or missing records, and returns wrong totals, averages, histograms and top paths without any error.Both are data races on the same snapshot, so they are fixed together (see "Why one PR").
A third commit zeroes the tail that in-place compaction leaves behind, so evicted transmissions are actually released.
Reproduction (before the fix)
The deterministic tests park the compute after its
RUnlockby holding one tx'sdecodedOnce, the same seam asTestComputeAnalyticsDistanceReleasesLockBeforeCompute. They then run the real writer unders.mu.Lockand let the compute finish.A: the area-only compute is parked inside the area loop, and the writer removes records:
Under
-race,TestDistanceSnapshot_AreaOnly_RaceStressreports the unlocked reads atstore.go:7958/store.go:7972.B: records
hash0000…hash0004withDist1…5. The compute takes its snapshot, then a writer runs. A consistent answer is either the snapshot or the post-write state:EvictStale(4 of 5 evicted), region+areatotalHops 5, avgDist 3.8, topPathshash0001 hash0002 hash0003 hash0004 hash0004updateDistanceIndexForTxs(tx1), region+areatotalHops 5, avgDist 3.6, topPathshash0000 hash0002 hash0003 hash0004 hash0004updateDistanceIndexForTxs(4 of 5), area-onlytotalHops 5, avgDist 3.4,hash0002twiceEvictStale(3 of 5), area-onlytotalHops 5, avgDist 4.2,hash0003andhash0004twiceUnder
-race,TestDistanceSnapshot_DefaultCompute_RaceStress(the recomputer's("", "")call) reportsstore.go:7992(filter loop) racingstore.go:4539(compaction).Severity
net/httprecovers the handler panic, so the process survives, but the request gets no response. The window is the whole area loop, including the firstParsedDecodedof each tx, and ingest can compact every poll cycle (1 s) whenever a best path changes.rfCacheTTL) or until the next recompute for the default query (5 min), then corrects itself. The window is only the snapshot copy, so it is hit occasionally.Both are also data races on
distHopRecord/distPathRecordvalues (strings, slices), which Go does not define.Why one PR
The fixes depend on each other:
They share the pin window and the test harness. The commits are separate so each can be reviewed and bisected.
Fix
hopsSnap/pathsSnap, like the rest of the compute.distSnapReaders atomic.Int64pins the snapshot.s.mu.RLock, in the same critical section that captures the slice headers. It decrements it as soon as the two filter loops have copied the snapshot intofilteredHops/filteredPaths, with adeferfallback on panic.compactDistIndex(remove)replaces both compaction loops. It reads the counter unders.mu.Lock. When it is zero it compacts in place exactly as before (dst[n] = r, no allocation). When it is non-zero it copies survivors into fresh arrays, and the pinned array stays owned by the snapshot.compactDistIndexclear()s the vacated tail ([newLen, oldLen)) of both slices. Previously that tail kept*StoreTxpointers to removed and evicted transmissions, and their observations, reachable until a later append overwrote the slot. Eviction is memory-driven, so it should release what it evicts. The pinned branch writes to fresh arrays and must not touch the old one (the snapshot's), so it clears nothing.Why the pin is correct
RLock; a writer reads it while holdingLock. The RWMutex orders them, so every compute that captured a snapshot before the writer acquired the lock is visible to the writer as a non-zero count. The writer then leaves that backing array alone.Loadreturns 0, it observes the lastAdd(-1). Go's atomics are sequentially consistent, so every snapshot read made before that decrement happens-before the writer's in-place writes.IngestNewFromDB) only write at indices ≥ the current length. That can only overlap a pinned snapshot after an in-place shrink, which the pin prevents.buildDistanceIndexalways installs fresh slices.s.mu: one atomic add per compute underRLock, and one atomic load per compaction underLock.Performance
Setup: Intel Xeon @ 2.80GHz, 4 vCPU, 15 GiB, Linux, Go 1.24.7, no other load. Each row is 5 runs (
-count=5); ns/op is shown as median (min–max). Sizes come from the committed e2e fixture: 3.62 hop records and 0.72 path records per transmission, withdistHopRecordat 152 B anddistPathRecordat 80 B. At ~30K transmissions that is ~110K hops and ~21.6K paths.updateDistanceIndexForTxs(1 tx)at 110K hops / 21.6K paths. This is the per-ingest-cycle cost when a best path changes, run-benchtime=100x. The same self-contained harness runs on all three revisions:8a7a69eNew
BenchmarkUpdateDistanceIndexForTxsin this PR (same sizes, with the test store's string fields filled in):clear()costs O(removed records), a memclr of one tx's records.s.mu.Lockand ~18 MB allocated per compaction). Doing that on every ingest cycle (up to 1 Hz) was rejected; it only happens while a compute is inside its short copy window.Eviction. The tail
clear()runs once per eviction over the removed records:-benchtime=5xBenchmarkEviction100K(all 100K tx; 100K hops + 100K paths cleared)All differences are within noise, and allocations are unchanged.
Tests
New file
cmd/server/distance_snapshot_race_test.go. It usesmakeTestStorefromeviction_test.go, and every writer is the real function.TestDistanceSnapshot_AreaOnly_UpdateIndexMidLoopupdateDistanceIndexForTxsdrops all records mid-loop; answer = snapshotTestDistanceSnapshot_AreaOnly_EvictMidLoopEvictStaleevicts all mid-loop; answer = snapshotTestDistanceSnapshot_AreaOnly_RaceStress-raceagainst slice swaps (buildDistanceIndex)TestDistanceSnapshot_AreaOnly_UpdateIndexMidLoop_PartialTestDistanceSnapshot_AreaOnly_EvictMidLoop_PartialTestDistanceSnapshot_RegionArea_EvictAfterSnapshotTestDistanceSnapshot_RegionArea_UpdateIndexAfterSnapshotupdateDistanceIndexForTxsafter snapshotTestCompactDistIndex_InPlaceUnlessPinnedTestDistanceSnapshot_PinReleasedTestDistanceSnapshot_DefaultCompute_RaceStress-raceon the default("", "")computeTestCompactDistIndex_ClearsVacatedTailupdateDistanceIndexForTxsandEvictStaleBenchmarkUpdateDistanceIndexForTxsCommit 1's tests remove every record, so the writer overwrites no snapshot element and they are race-clean with only the A fix. The partial-removal variants need B and live in commit 2.
Red → green per commit:
masterfail with the two panics above, and the stress test reportsDATA RACE.InPlaceUnlessPinnedfails because the pinned snapshot is rewritten to[hash0002 hash0003 hash0004 new], and the default stress test reportsDATA RACE.PinReleasedis a guard for the new pin and passes trivially there.hash0001…hash0004and their*StoreTx.Mutations (all fail the committed tests)
Run against the committed tests at
ef04791(non-race, deterministic):s.distHops/s.distPathsagainAreaOnly_UpdateIndexMidLoop,AreaOnly_EvictMidLoop, both_PartialAreaOnly_*, bothRegionArea_*,InPlaceUnlessPinnedInPlaceUnlessPinnedRUnlockAreaOnly_*, bothRegionArea_*PinReleasedcompactDistIndexAreaOnly_EvictMidLoop_Partial,RegionArea_EvictAfterSnapshot,ClearsVacatedTailcompactDistIndexcallClearsVacatedTailclear()ClearsVacatedTailclear()the old array's tail also when pinned (overwrites the snapshot)AreaOnly_*, bothRegionArea_*,InPlaceUnlessPinnedclear()interval starts one too late (n+1)ClearsVacatedTailclear()interval ends one too early (old-1)ClearsVacatedTailclear()interval starts one too early (n-1, into live data)InPlaceUnlessPinned,ClearsVacatedTailVerification
go vet ./...andgofmt -lon the touched files are clean.go test -race -count=1 ./...incmd/serverpasses on every commit on its own: commit 1ok 601.8s, commit 2ok 587.4s, commit 3ok 602.0s(local Go 1.24.7; CI uses 1.27.1).TestServerSourceHasNoCachedRWCalls,TestServerDBHasNoWriteMethods,TestServerDBConnIsReadOnly,TestPacketStoreHasNoMultibytePersistMethods) pass. The change adds no DB writes and no newmap[string]interface{}.🤖 Generated with Claude Code
https://claude.ai/code/session_01RqfyMm2rmAvNr27smJjyDe
Generated by Claude Code