fix(store): lazy distance build — drop the reset sync.Once, fix the lock order (#149) - #151
Conversation
Red on master: - LoadCompletionDuringBuildDoesNotCrash: the background-load completion resets distLazyOnce while Do runs the build; the child process dies with "fatal error: sync: unlock of unlocked mutex". - TriggerAndLoadCompletionDoNotDeadlock: a debounce-path trigger holds distLazyMu and waits for s.mu.RLock while the load completion holds s.mu.Lock and waits for distLazyMu; the child times out with both goroutines parked. - LoadBeforeBuildSnapshotNoExtraBuild / LoadAfterBuildSnapshotRebuildsOnce: a load that completes before the build reads the dataset needs no second build; one that completes after it needs exactly one. - BuildingIsSetBeforeTheBuildGoroutineRuns: distLazyBuilding must be set before the build goroutine starts. - TestDistLazyMuNeverHeldWhileTakingStoreMu: AST guard for the lock order (with synthetic positive/negative controls). Pins that pass on master: concurrent triggers start one build, the 202 + Retry-After contract and post-load invalidation, the debounce policy. Scenarios that crash or hang a broken build run in a child process with short deadlines, so they fail instead of taking down or hanging the run. Relates to #149 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…istLazyMu (#149) - Remove distLazyOnce. The gate state (distLazyBuilding, distLazyBuilt, distLazyBuiltGen, distLazyLastBuilt, distLazyLastObs) lives under distLazyMu; startDistanceBuildLocked sets distLazyBuilding (and clears distLazyBuilt, so the handler answers 202 for any build, as before) under the mutex before the goroutine starts. - Lock rule: s.mu is never acquired while distLazyMu is held. The debounce path reads totalObs between two distLazyMu sections and re-checks the gate before it starts a build; the background-load completion no longer takes distLazyMu at all. - Generation: the load completion bumps distDataGen (atomic, written under s.mu.Lock). A build records the generation it read under the same s.mu section as buildDistanceIndex and builds once more only if the dataset changed after that snapshot. DistanceIndexBuilt() is built && current generation. - Debounce unchanged: rebuild when Δobs >= 5 % or 5 minutes have passed. Tests: - TestNoSyncOnceIsReassigned: AST guard against reassigning anything that holds a sync.Once by value, with synthetic controls. - Lock-order guard: stop matching s.distDataGen.Load() as PacketStore.Load (a false positive), also check calls deferred after a deferred unlock. - DebounceUnchanged also pins 202 during a debounced rebuild; DebouncedRebuildStartsOnce pins the second-section re-check. Relates to #149 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#149) Follow-up to the independent review of #151: - TriggersDuringStaleRepassStartNoBuild: when a build's dataset went stale during its first pass, distLazyBuilding is still true as the second pass starts, triggers during it start no build, and the first pass's index does not count as built. Kills "distLazyBuilding = false before the generation check" and "record the current generation instead of the one the pass read". - LoadHoldsMuWhileBuildQueuedNoExtraBuild: the build queues behind a load completion that holds s.mu and bumps the generation before the build gets the lock; no extra build. Kills "read distDataGen before s.mu.Lock". Both tests are adapted from the reviewer's. - DebouncedTriggerSkipsWhenABuildFinishedMeanwhile pins the lastBuilt re-check in the trigger's second section (previously a surviving mutant). - DebounceUnchanged checks that a build records the totalObs it read; RebuildDueBoundaries pins exactly 5 minutes / exactly 5 % (master's boundaries). - Comments: the generation read has to stay inside the build's s.mu section; the lock rule is only "no s.mu while distLazyMu is held", and s.mu -> distLazyMu is allowed (store.go and the lock-order guard). Relates to #149 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review feedback addressed (commit The follow-up is one new commit on top of
Both tests are adapted from the reviewer's
Also closed while verifying the follow-up. A second independent review and a 24-mutant hunt found three test-coverage gaps; none was a code defect.
Verification (local, go1.26.0 darwin/arm64):
|
Brings in #151 (fix #149, distance build lock order). #145's hunk that clears distCache and recomputes the distance snapshot lands inside the new runDistanceIndexBuild loop, after s.mu.Unlock and before the distLazyMu section, so it holds neither lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Relates to #149
Summary
The gate of the lazy distance-index build (Kpa-clawbot#1011,
cmd/server/store.go) had two separate defects:distLazyOnce(async.Once) was reassigned to a zero value by the background-load completion, and by the debounce path, whileDocould be running on it.Doholds the Once's internal mutex for the whole build, so its deferred unlock hit the zeroed mutex:fatal error: sync: unlock of unlocked mutex. That error is not recoverable, and the server exits.TriggerDistanceIndexBuildtooks.mu.RLockwhile holdingdistLazyMu. The background-load completion tookdistLazyMuwhile holdings.mu.Lock. With the locks taken in opposite order, both goroutines can block forever.Commits
15f179838ea5583dsync.Onceguard, test refinements (listed below)93790bb8Reproductions (red on master)
Tests that crash or hang a broken build run in a child process (
runDistBuildChild). Every wait inside the child has a 10 s deadline and dumps all goroutines when it expires, so a regression fails instead of crashing or hanging the CI run.d778bc40)LoadCompletionDuringBuildDoesNotCrashfatal error: sync: unlock of unlocked mutexinsync.(*Once).doSlow. The build is held open with the existingdistanceBuildHookseam whileloadBackgroundChunkscompletes.TriggerAndLoadCompletionDoNotDeadlock[sync.Mutex.Lock](distLazyMu) insideloadBackgroundChunks, and the trigger parked in[sync.RWMutex.RLock]insideTriggerDistanceIndexBuild. The orchestration is deterministic: a held read lock queues the loader as a writer, and the trigger then parks behind it.LoadBeforeBuildSnapshotNoExtraBuildOnce.Do.LoadAfterBuildSnapshotRebuildsOncedistLazyMu; both outcomes are red.BuildingIsSetBeforeTheBuildGoroutineRunsdistLazyBuildingis still false right after the trigger, because it was only set inside the goroutine. WithGOMAXPROCS(1)the goroutine cannot run before the check.TestDistLazyMuNeverHeldWhileTakingStoreMustore.go:4667: takes s.mu.RLock with distLazyMu held.The pins (
ConcurrentTriggersStartOneBuild,HandlerContractAndLoadInvalidation,DebounceUnchanged,DebouncedRebuildStartsOnce) pass on master without-race. Correction to the message of15f17983: it says these pins "pass on master", but under-racethree of them fail on master. The race detector reports a data race atstore.go:4680(s.distLazyOnce = sync.Once{}) againstOnce.doSlow's deferred store, which is the #149 race itself.ConcurrentTriggersStartOneBuildpasses on master either way.Design
sync.Once. The gate state lives underdistLazyMu:distLazyBuildinganddistLazyBuilt, as before;distLazyBuiltGen, new: the generation the last completed build read;distLazyLastBuiltanddistLazyLastObs, as before.startDistanceBuildLocked, called underdistLazyMu, setsdistLazyBuilding = trueanddistLazyBuilt = falsebefore thegostatement, so concurrent triggers start at most one build.distDataGenis anatomic.Uint64. The background-load completion bumps it unders.mu.Lock. It no longer touchesdistLazyMu.s.mu.LockasbuildDistanceIndex, so the recorded generation always matches the data the build read.DistanceIndexBuilt()returns true only whenbuilt && builtGen == distDataGen.runDistanceIndexBuildloops only if the generation changed after its snapshot. If the load completes before the build takess.mu, one build is enough. If it completes after, exactly one more build follows. The loop cannot spin, becausedistDataGenhas one writer, which runs once per background load.s.mu(Lock or RLock) is never acquired whiledistLazyMuis held. In the fix the two are never held together at all.totalObsbetween twodistLazyMusections.!building, andlastBuiltunchanged.Retry-After: 5whenever the index is not built. That includes a debounced rebuild, as on master, becausedistLazyBuiltis cleared when a build starts.TestDistLazyMuNeverHeldWhileTakingStoreMuis a flow-sensitive AST walk over the package's non-test sources. It rejects.mu.Lock/RLock, and calls toPacketStoremethods that takes.mudirectly or transitively, whiledistLazyMumay be held.TestNoSyncOnceIsReassignedrejects assignments to anything declared with a type that holds async.Onceby value:sync.Onceitself, or a struct or array containing one. It also rejects assigning a composite literal of such a type.Performance
This is not a hot path: the build runs at most once per trigger. The question here is whether the trigger and debounce path takes more locks than before.
distLazyMu×1distLazyMu×1distLazyMu×1; the goroutine takesdistLazyMuagain to set buildingdistLazyMu×1; building is set beforego; nos.mudistLazyMu+s.mu.RLocknesteddistLazyMu, thens.mu.RLock, not nesteddistLazyMu+ nested RLock, plusdistLazyMuin the goroutinedistLazyMu, RLock,distLazyMu, and none in the goroutine; same total per cycles.mu.Lock)distLazyMulock/unlockBenchmarkTriggerDistanceIndexBuild(-count=5 -benchtime=200000x, Apple M2 Pro, go1.26.0, 0 allocs):buildingdebouncedBoth differences are within noise; in one round the fix was faster on
debounced.Contended probe (a scratch test, not committed): a writer holds
s.mufor 200 ms while one trigger is parked in the debounce read, and the probe times a second request'sDistanceIndexBuilt().distLazyMuis held across the RLock wait;Test results
Local runs: go1.26.0, darwin/arm64.
go test -race -count=20: 16 tests × 20 = 320 PASS, 0 FAIL, 0 data races (160.7 s). The 16 are the 10TestDistanceBuild149_*tests, the two guards and their two controls, and the three existingTestDistance*lazy-build tests.cmd/serversuite,go test -race -count=1 ./...:ok github.com/corescope/server 400.1s, exit 0, no FAIL, no data race.93790bb8:#149set withgo test -race -count=20: 20 tests × 20 = 400 PASS, 0 FAIL, 0 data races (226.2 s).cmd/serverwithgo test -race -count=1 ./...:ok github.com/corescope/server 440.6s, exit 0.gofmt -lis clean on every touched file. The package has untouched files that are not gofmt-clean on master with go1.26; that is pre-existing.go vet ./...incmd/serveris clean.TestIssue1008_HandlerReturns503WhileSubpathIndexLoading,TestPollerBroadcastsNewData): did not occur in the final full run; both passed.Mutants
Each mutant was applied to the final
store.goand the#149, guard and existing lazy-build tests were run against it.distLazyOnce, builds throughOnce.Do, reset in the load completion and the debounce pathLoadCompletionDuringBuildDoesNotCrash,LoadBeforeBuildSnapshotNoExtraBuild,LoadAfterBuildSnapshotRebuildsOnce,TestNoSyncOnceIsReassignedtotalObsunders.mu.RLockinsidedistLazyMuin the triggerTestDistLazyMuNeverHeldWhileTakingStoreMudistLazyMuunders.mu(master's shape)TriggerAndLoadCompletionDoNotDeadlockstale := false)LoadAfterBuildSnapshotRebuildsOnceDistanceIndexBuilt()ignores the generationHandlerContractAndLoadInvalidationdistLazyBuildingonly inside the goroutineBuildingIsSetBeforeTheBuildGoroutineRuns,ConcurrentTriggersStartOneBuild,DebouncedRebuildStartsOnceHandlerContractAndLoadInvalidation,LoadAfterBuildSnapshotRebuildsOnceDebounceUnchangedDebounceUnchanged,DebouncedRebuildStartsOnce!buildingre-check before a debounced rebuildDebouncedRebuildStartsOncelastBuiltre-check before a debounced rebuild93790bb8)DebouncedTriggerSkipsWhenABuildFinishedMeanwhileBuildingIsSetBeforeTheBuildGoroutineRuns,ConcurrentTriggersStartOneBuild,HandlerContractAndLoadInvalidationDebounceUnchangeddistLazyBuilding = falsebefore the generation check (93790bb8)TriggersDuringStaleRepassStartNoBuilddistDataGenread befores.mu.Lock(93790bb8)LoadHoldsMuWhileBuildQueuedNoExtraBuild93790bb8)TriggersDuringStaleRepassStartNoBuildlastBuilt(93790bb8)DebouncedTriggerSkipsWhenABuildFinishedMeanwhiletotalObsit read (93790bb8)DebounceUnchanged>= 5 %/>= 5 minturned into>(93790bb8)RebuildDueBoundariesdistDataGenread afters.mu.UnlockChanges to commit A's tests in commit B
s.distDataGen.Load(). Only callsx.m()on a plain identifier count asPacketStoremethod calls. The old rule was a false positive from the name clash withPacketStore.Load; theviaNamesakecontrol still catchess.Load().defer distLazyMu.Unlock().DebounceUnchangedalso asserts thatDistanceIndexBuilt()is false while a debounced rebuild runs, so the handler answers 202 as on master. This passes on master.DebouncedRebuildStartsOnce: 16 triggers get past the first gate check together, and exactly one starts the rebuild. It is a pin, not red on master.sync_once_reset_guard_test.go.Not verified
[sync.Mutex.Lock],[sync.RWMutex.RLock],[sync.RWMutex.Lock]) in the goroutine header. CI is the check for 1.27.1.distDataGenafter the build'ss.mu.Unlockwould allow a lost invalidation, if a load completed between that Unlock and the read. Nothing blocks in that window, so the existing seams cannot produce a deterministic test. The comment at the read documents why it must stay inside the section.s.mutaken inside a function literal run by adistLazyMuholder, in a package-level function, or through a method value. The runtime deadlock test covers the actual fix(store): lazy distance build can crash (sync.Once reset during Do) and deadlock (lock order) #149 shape.sync.Onceguard does not see copies through pointers (*p = *q).go vet's copylocks would, but CI does not rungo vetforcmd/server, andgo test's vet subset does not include copylocks.runDistanceIndexBuildis exercised only in child processes, so it does not show up in the CI coverage profile.TriggerDistanceIndexBuildonly whenDistanceIndexBuilt()is false. The Δobs/5-minute debounce is therefore practically unreachable from HTTP, on master as well; it is reachable through direct calls.httptest, and there was no browser validation.Follow-up
🤖 Generated with Claude Code