Prevent double size decrement in ConcurrentLruCache - #37268
Open
junhyeong9812 wants to merge 1 commit into
Open
Conversation
markAsRemoved transitions a node to the removed state and decrements the current size, but it did not check whether the node had already been removed. The eviction path and an explicit removal can process the same node in sequence: when a write drain runs a queued AddTask whose eviction polls a node that a concurrent remove(K) has already taken out of the cache, the eviction decrements the size, and the queued RemovalTask for the same node decrements it again. The sibling transition markForRemoval guards against invalid transitions; this one did not. Each extra decrement makes currentSize permanently smaller than the number of cached entries, so eviction stops triggering and the cache exceeds its capacity for good, silently. A bounded two-thread stress run accumulates the drift reliably: before the change the cache stabilized far above its capacity in 20 out of 20 runs. markAsRemoved now returns without decrementing when the entry is already in the removed state, mirroring the guard in markForRemoval. The removed state is terminal, so each node is counted down exactly once. The new test races explicit removals against eviction and then verifies that the cache converges back to its capacity; it also asserts that the racing thread ran and terminated cleanly. Signed-off-by: junhyeong9812 <pickjog@gmail.com>
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.
Overview
ConcurrentLruCache.markAsRemoveddecrements the current size without checking whether the node has already been removed. The eviction path and an explicitremove(K)can process the same node in sequence, decrementing the size twice. Every extra decrement makescurrentSizepermanently smaller than the number of cached entries, so eviction stops triggering and the cache silently exceeds its capacity for good. This PR adds the missing terminal-state guard, mirroring the sibling transitionmarkForRemoval.Problem
Removal work is buffered and drained under the eviction lock. When a drain runs a queued
AddTaskwhoseevictEntries()polls a node that a concurrentremove(K)has already taken out of the cache, the eviction marks the node removed and decrements the size; the queuedRemovalTaskfor the same node then marks it removed again and decrements the size a second time.markForRemovalguards its transition with anisActive()check, butmarkAsRemovedperformed no state check at all.The resulting drift is cumulative and self-concealing:
currentSizeunder-counts,evictEntries()compares against it and stops evicting, and the map keeps more entries than the configured capacity with no exception or log. In a bounded two-thread stress run (one thread inserting new keys, one repeatedly re-inserting and removing a single key), the cache stabilized far above its capacity in 20 out of 20 runs before the change, and converged back to its capacity in all runs after it.Fix
markAsRemovednow returns without decrementing when the entry is already in the removed state. The removed state is terminal - no transition leads out of it - so each node is counted down exactly once regardless of how many cleanup paths visit it; the guard reads the same entry snapshot the CAS uses, so a lost race simply re-reads and observes the terminal state. The method comment is updated accordingly.The new test races explicit removals against eviction-driving inserts and verifies that the cache converges back to its capacity once the pending work is drained; the accumulated drift makes the assertion reliable even though a single interleaving cannot be forced through the public API. The test also asserts that the racing thread actually performed removals and terminated cleanly, so a silently dead worker cannot turn it into a vacuous pass. The full spring-core test suite passes.