fix: prevent plugin declaration cache eviction loop from spinning forever - #784
Merged
Conversation
…ever memCache tracked its size in a hand-maintained itemSize counter alongside c.items. DeletePluginDeclarationCache removed entries from the map without decrementing the counter, so itemSize drifted permanently above len(c.items). Once the drift reached maxMemCacheSize the eviction loop in set could no longer escape: it emptied the map, leastKey stayed "", nothing was deleted, and itemSize never dropped below the threshold. The loop then spun under c.Lock(), pinning a core and blocking every reader of the declaration cache until the process was restarted. Drive eviction off len(c.items) instead of a parallel counter, which removes the drift entirely, and break out of the loop when there is nothing left to evict so no future accounting error can reintroduce a non-terminating loop. Also collapse get to a single write lock. It read item.lastAccess after releasing RLock while other goroutines mutated that field under Lock, which the race detector flags. The read lock bought nothing, since every cache hit already took the write lock to update accessCount. Closes #783
GareArc
force-pushed
the
fix/memcache-eviction-infinite-loop
branch
from
July 28, 2026 07:34
2cc571e to
df891bc
Compare
wylswz
approved these changes
Jul 29, 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.
Description
memCache.setinpkg/utils/cache/helper/combined.gocould enter an infinite loop while holding the cache write lock, blocking every reader of the plugin declaration cache until the process was restarted.Closes #783
Root cause
memCachetracked its size in a hand-maintaineditemSizecounter kept alongsidec.items. LetD = itemSize - len(c.items).DeletePluginDeclarationCacheremoved an entry from the map without decrementingitemSize, so every call raisedDby one, permanently. OnceD >= maxMemCacheSize, the eviction loop insetcould no longer terminate — it drained the map,leastKeystayed"", nothing was deleted, anditemSizenever fell below the threshold. The loop then spun underc.Lock(), pinning a core while every reader piled up onRLock.Note on the reported cause
The issue attributes the drift to
itemSize++firing unconditionally on overwrites. That is a real accounting bug, but it cannot by itself reach the terminal condition, so a fix that only makes the increment conditional would not stop the hang.Tracking
Dthrough each operation:Dset,getTTL pathsetinsertsetoverwriteDeletePluginDeclarationCacheAn overwrite requires the key to be present, so
len(c.items) >= 1; and eviction runs immediately before the add wheneveritemSize >= maxMemCacheSize, exiting at exactlymaxMemCacheSize - 1. Inductively that pinsD <= maxMemCacheSize - 1— one short of the terminal condition, indefinitely.DeletePluginDeclarationCacheis the only source outside that rebalancing, and is what actually drivesDover the line.This is confirmed by the tests in this PR run against the unfixed code: the overwrite-only test passes, while the test that interleaves
setwith an external delete hangs.Changes
len(c.items)and dropitemSizeentirely. This removes the whole class of drift, including theDeletePluginDeclarationCachesource, rather than patching one contributor.breakout of the eviction loop when there is nothing left to evict, so no future accounting error can reintroduce a non-terminating loop.getto a single write lock. It readitem.lastAccessafter releasingRLockwhile other goroutines mutated that field underLock— a data race the detector flags. The read lock bought no concurrency anyway, since every cache hit already took the write lock to bumpaccessCount.Net effect on the implementation file is 19 insertions, 36 deletions.
Type of Change
Essential Checklist
Testing
Bug Fix (if applicable)
Fixes #123orCloses #123)Additional Information
Verification
Each failure was reproduced against the unfixed code first, rather than assumed.
TestSetTerminatesWhenEntriesAreDeletedExternallyTestSetTerminatesWhenOverwritingTheSameKeygo test -raceon concurrent get/set/deleteWARNING: DATA RACEingetgo test -race -count=2 ./pkg/utils/cache/helper/okgo vet,gofmtThe new tests wrap the work in a timeout so a regression fails the suite rather than hanging CI.
go build ./...reportspattern PRIVATE_KEY.pem: no matching files found. That is pre-existing onmain, unrelated to this change, and unaffected by it.Not addressed
Point 3 of the issue — the
O(n)scan per evicted item — is left alone. TheO(n*k)behaviour it describes was a consequence of the drift: withlen(c.items)driving the loop,setevicts exactly one entry per call in steady state, sok = 1. Converting to a heap or LRU list is a worthwhile but separate design change.