cubemaster: fix the data races in localcache - #1376
Conversation
…ta races in localcache
…some proper tests for fixing the data race
| Value: val, | ||
| LastAccess: time.Now().Unix(), | ||
| Expired: expired} | ||
| element.Value = next |
There was a problem hiding this comment.
put() now writes element.Value = next while holding localCache's mutex, but saveFile() (unchanged in this PR, called from Destroy()) reads the same *list.Element.Value field — value.Value.(*util.CacheValue) at line 328 — without holding that mutex.
This is a newly-introduced data race. Before this PR, element.Value was write-once (assigned at PushBack), so saveFile's unlocked read was safe; now every refresh of an existing key writes the field. The race is reachable: asyncRefresh() spawns goroutines that are not tracked by waitGroup, and concurrent Gets can still be inside loadAndRefresh → put while Destroy() runs saveFile. The new race tests never overlap Destroy with an in-flight put, so the reported green -race run wouldn't catch it.
Fix suggestion: snapshot the *util.CacheValue under localCache.Lock() in saveFile (e.g. read value.Value into a local while holding the lock, then gob-encode after), so all readers of element.Value are serialized.
Review: cubemaster: fix the data races in localcacheAI-generated review — no human approval implied. Verdict: Approve with minor comments. The core fix is correct, well-scoped, and backed by good red/green verification. What I verified against the base tree
CommentsMinor (non-blocking)
Pre-existing, adjacent to the change
On the remaining redThe two remaining races and six remaining failures are, as the PR states, pre-existing and outside this package; nothing in the diff regresses them. |
…snapshot and read curCacheSize atomically
… Destroy with an in-flight refresh
|
|
||
| localCache.Lock() | ||
| itm := element.Value.(*util.CacheValue) | ||
| localCache.valueList.MoveToBack(element) |
There was a problem hiding this comment.
Behavior change vs. the PR description. The description says "the LRU effect is the same — exactly one MoveToBack per hit, on access", but on the default config (ExpiredUse: false) the old code returned early from the sync-refresh branch — return r, f, err — without MoveToBack, both on refresh success and on refresh failure without demotion. Moving MoveToBack to before the refresh decision means every found hit now promotes the element, including entries whose loader keeps failing with demotion disabled. Those failing entries are now pinned at the back of the list, so shrinkCache's front-based eviction won't reclaim them; they live until the CacheExpiredRemove ticker (default 24 h) instead of drifting toward the eviction front. If this is intended, it's worth stating in the description; if not, the promotion should stay on the non-refresh hit path only.
| localCache.loadFile(localCacheConfig.LoadFileName) | ||
| } | ||
| localCache.localCacheConfig = localCache.SetupConfig(localCacheConfig) | ||
| localCache.expiredUse.Store(localCache.localCacheConfig.ExpiredUse) |
There was a problem hiding this comment.
SetupConfig deliberately guards a nil config (if localCacheConfig == nil { return nil }), so localCache.localCacheConfig can be nil here and this new unconditional localCacheConfig.ExpiredUse dereference turns a nil config into a panic at construction time — previously the panic was deferred to the first Get on an expired entry. No current caller passes nil, so this is latent rather than a live bug, but the new code bypasses the nil guard that SetupConfig provides. Consider localCache.expiredUse.Store(localCacheConfig != nil && localCacheConfig.ExpiredUse) or an explicit nil check.
fslongjin
left a comment
There was a problem hiding this comment.
LGTM
Verified the races on origin/master with go test -race, and this branch is clean. The immutable CacheValue swap under the mutex is the right fix for the torn interface{} read; the atomic / sync.Once / expiredUse changes are correct.
…promotion points and guard a nil config
…t a failing refresh does not promote
| close(localCache.chCacheExit) | ||
| localCache.waitGroup.Wait() | ||
| localCache.chCacheExit = nil | ||
| if localCache.chCacheExit == nil { |
There was a problem hiding this comment.
Dead code now: after this PR nothing assigns chCacheExit = nil (that was the race being removed), so this guard can only be true for a LocalCache not created via NewCache. In that case the surrounding destroyOnce.Do marks the cache as destroyed and a later, legitimate Destroy is silently skipped. Consider removing the guard, or making the "not initialized via NewCache" no-op explicit.
| if found { | ||
| element := item.(*list.Element) | ||
|
|
||
| localCache.Lock() |
There was a problem hiding this comment.
The PR description says Get reads the *CacheValue pointer and does its MoveToBack inside one critical section, but the MoveToBack at the end of Get is still a separate lock acquisition. The separation is actually load-bearing — promoting the entry before the refresh decision would make a failed refresh promote the entry, which TestFailingRefreshDoesNotPromoteTheEntry explicitly forbids — so the code is correct, just worth a comment. Note it also means every hit now acquires the cache-wide mutex twice (snapshot read + MoveToBack), doubling exclusive-lock contention on the hot read path of this shared cache.
Closes #1375.
Motivation
go test -race ./...in CubeMaster reported 9 races, five of them inpkg/base/localcacheproduction code. The most serious is
put()mutating a*util.CacheValuein place whileGet()readers dereference it:
Valueis a two-wordinterface{}, so a torn read can pair one value's typeword with another's data word and the caller's type assertion then dereferences a mismatched pointer.
What this changes
All in
CubeMaster/pkg/base/localcache/localcache.go:1. Cache entries are now immutable once published.
put()no longer overwrites the liveCacheValue; it builds a replacement and swapselement.Valueunder the existing mutex:Get()correspondingly reads the*CacheValuepointer and does itsMoveToBackinside onecritical section, then works from that snapshot. Because nothing mutates a published entry any more,
the subsequent field reads need no lock.
This moves
Get'sMoveToBackfrom after the refresh decision to before it. The LRU effect is thesame — exactly one
MoveToBackper hit, on access — and it removes the duplicatedlock/
MoveToBackblock that theDemotionExpiredUsepath used to need.2.
consecutiveFailNum— the plain store at:201becomesatomic.StoreInt64, matching theatomic.AddInt64at:197and theatomic.LoadInt64inerrStrategy.3.
curCacheSize— the read input()becomesatomic.LoadInt64, matching its atomic writes.4.
Destroy()— no longer assignschCacheExit = nil(which raced witherrStrategy's select).Idempotency now comes from a
sync.Once, so repeatedDestroy()calls still cannot double-close.5.
ExpiredUse—errStrategyno longer writes the shared*LocalCacheConfigthatGetreads.LocalCachegains anexpiredUse atomic.Bool, seeded from config inNewCacheand driven byerrStrategy;Getreads it atomically. The yaml-taggedLocalCacheConfig.ExpiredUsefield isunchanged, so config parsing and every existing test that sets it still work.
Also fixed, because it is the last thing standing between this package and a green
-racerun:localcache_test.go:48reassigned the sharedctxin a loop while spawned goroutines read it. Thecontext is now derived per iteration and passed as a parameter, which also removes an unintended
2700-deep context chain.
No comment changes.
Testing
New:
CubeMaster/pkg/base/localcache/localcache_race_test.goTestConcurrentGetAndRefreshOnSameKey— 32 readers × 300 iterations on one hot key with a 1 msexpiry, so
Getand the async-refreshputinterleave continuously. It type-asserts every hit,which is what would catch a torn
interface{}.TestDestroyIsIdempotent— repeatedDestroy()does not panic.TestConcurrentDestroyDoesNotRaceWithBackgroundLoops— four concurrentDestroy()calls.Red/green verified — with
localcache.goreverted to master, the new tests alone report 4 dataraces. With the fix:
Module-wide,
go test -race ./...:TestLocalCache,TestNoValue,TestSaveFileCI gates checked locally:
gofmt -l ./pkg/base/localcache— clean (fmt-check).GOOS=linux go build ./pkg/base/localcache/— clean.Still red after this change (pre-existing, not touched here)
The 2 remaining races and the 6 remaining failures are all pre-existing on master and belong to other
branches:
pkg/base/queueworker/queue_test.go:65,67,75— test-side counter race.pkg/base/recov/runtime_test.govsruntime.go:30— test-side read of an atomically-writtencounter.
pkg/base/bufferqueue/bufferqueue_test.go:131,138— same class (this one is not yet tracked; worthits own issue).
TestDestroySandboxMissingSandboxReturnsNotFound,TestHostChangeGoodBodyReturnsSuccessEnvelope,TestTemplateLocalityFilterSelect,Test_local_appendNodeByCluster— fail on master too(
gomonkeycannot patch binaries on darwin; these need the Linux builder).Risk / rollout
Moderate — this touches the hot read path of a shared cache. The behavioural contract is unchanged:
same LRU semantics, same expiry decisions, same return values. The notable internal change is that a
Gethit now returns a value snapshot taken at lock time rather than whatever the entry happened tohold mid-refresh, which is the point.
One extra allocation per
put()on an existing key (the replacementCacheValue, ~48 bytes). Giventhat
putalready runs a loader call and a JSON-ish payload, this is not a meaningful cost.