Make ICache keys() iteration safe under concurrent mutation - #22542
Conversation
PR Reviewer Guide 🔍(Review updated until commit a0b8647)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to a0b8647 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 1b23984
Suggestions up to commit b3ac091
Suggestions up to commit 86a0946
|
|
❌ Gradle check result for 86a0946: null Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
86a0946 to
b3ac091
Compare
|
Persistent review updated to latest commit b3ac091 |
|
❌ Gradle check result for b3ac091: FAILURE Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
b3ac091 to
a91607d
Compare
|
Persistent review updated to latest commit a91607d |
|
❌ Gradle check result for a91607d: FAILURE Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
sgup432
left a comment
There was a problem hiding this comment.
Overall LGTM, one minor comment
|
@jwils I found one issue with If you wanna take it up, feel free otherwise I can also fix it. |
a91607d to
1b23984
Compare
|
Persistent review updated to latest commit 1b23984 |
|
❌ Gradle check result for 1b23984: FAILURE Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
|
@jwils Can you check on the failed unit tests? Seems like it is related to this change. |
OpenSearchOnHeapCache.keys() exposed the live Cache.keys() LRU iteration through the ICache interface. That iterator walks the LRU linked list without holding the LRU lock, so a concurrent cache hit relinking a not-yet-visited entry to the head silently skips it. The request cache cleanup sweep iterated that view after consuming its cleanup marks, so a skipped stale entry was retained until size-based eviction reached it. Return a point-in-time copy from OpenSearchOnHeapCache.keys() (built on Cache.keysSnapshot(), unmodifiable so iterator removal fails fast instead of silently no-oping on the copy), document that behavior on the implementation, and switch the request cache sweep from Iterator.remove() to exact-key invalidate(), which every ICache implementation supports and which preserves the INVALIDATED removal notification and stats accounting. Signed-off-by: Josh Wilson <joshuaw@squareup.com>
1b23984 to
a0b8647
Compare
|
Persistent review updated to latest commit a0b8647 |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #22542 +/- ##
============================================
+ Coverage 71.48% 71.53% +0.05%
- Complexity 77022 77052 +30
============================================
Files 6156 6156
Lines 358482 358481 -1
Branches 52246 52246
============================================
+ Hits 256255 256443 +188
+ Misses 81810 81685 -125
+ Partials 20417 20353 -64 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Description
Builds on
Cache#keysSnapshot(), introduced in #22499, which has now merged — this PR is rebased ontomainand is down to the single new commit (Make ICache keys() iteration safe under concurrent mutation). Not stacked on #22498, which has since been reworked to remove the asyncBitsetCacheCleanerand no longer touchesCache.Third instance of the
Cache.keys()iteration hazard fixed in #22491 / #22499 (fielddata cache) and #22498 (bitset filter cache), this time reaching the request cache through the pluggable cache layer:OpenSearchOnHeapCache#keys()returned the liveCache#keys()LRU iteration through theICacheinterface. The live iterator walks the LRU linked list without holding the LRU lock; a concurrent cache hit relinks a not-yet-visited entry to the head of the list, behind the iterator's cursor, and iteration silently skips it.IndicesRequestCache's cleanup sweep (cleanCache) consumeskeysToCleanbefore scanningcache.keys(). An entry skipped by the sweep is not re-marked — a reader closes only once — so a stale entry promoted mid-sweep by a concurrent request survives the sweep.Impact, stated plainly: unlike the fielddata cache, the request cache is size-bounded (
indices.requests.cache.size, default 1% of heap), so a skipped entry is retained until size-based eviction reaches it rather than indefinitely. The consequences are wasted cache capacity holding an entry no future request can reach, and astaleKeysCountthat never decrements for the skipped entry (the decrement is driven by the removal notification). That leaves the count permanently inflated, socanSkipCacheCleanupstops skipping sweeps that then return early at the empty-keysToCleancheck. This is a correctness bug worth fixing, but it is not the unbounded growth that motivated #22491 — I have no production measurements for this path.Change:
OpenSearchOnHeapCache#keys()returns a point-in-time copy built onCache#keysSnapshot(). The copy is wrapped unmodifiable so a caller usingIterator#remove— which would silently no-op against a copy — fails fast instead.OpenSearchOnHeapCache#keys()rather than on theICacheinterface, so theICacheSPI is left untouched (per review feedback). The other implementations already iterate safely (TieredSpilloverCachecomposes per-tierkeys(); ehcache's iterator is weakly consistent) and keep their existing removal behavior.invalidate(key)instead ofIterator#remove().IndicesRequestCacheis the only caller inserver/src/mainthat removed while iteratingICache#keys().Behavior change for the tiered cache
Switching the sweep from
Iterator#remove()toinvalidate(key)is not a no-op underTieredSpilloverCache.ConcatenatedIterator#remove()delegates to the current tier's iterator, removing the entry from only the tier being iterated;TieredSpilloverCache#invalidate(key)invalidates in both tiers under the write lock. For stale-reader cleanup, removing from both tiers is the intended outcome — a key whose reader has closed should not survive on disk — and a key present in both tiers was yielded (and removed) twice by the concatenated iterator anyway. Flagging it explicitly rather than claiming semantics are unchanged. TheINVALIDATEDremoval notification and stats accounting path are unchanged, and invalidating a concurrently removed key is a no-op.Verification
Two new deterministic regression tests (no threads, no races — the promotion is triggered at a controlled point mid-iteration):
OpenSearchOnHeapCacheTests#testKeysIsSafeToIterateUnderConcurrentPromotion— begins iteratingkeys(), then hits the LRU-tail entry (relinking it at the head behind the cursor), and asserts every inserted key is still observed. Against the live view the promoted key vanishes from iteration (4 of 5 keys observed); with the snapshot all 5 are observed.IndicesRequestCacheTests#testCleanCacheIsNotDefeatedByCacheHitPromotingEntryMidSweep— four entries across two shards, one shard's entries marked for cleanup, and a cache hit fired from the first swept entry's entity lookup promotes the not-yet-visited stale entry. Against the live view the promoted stale entry survives the sweep (3 entries remain instead of 2, and the suite's teardown leak checks trip); with the snapshot the sweep removes both stale entries.OpenSearchOnHeapCacheTests#testKeysIteratorDoesNotSupportRemovalpins the fail-fast contract.IndicesRequestCacheTests,OpenSearchOnHeapCacheTests, andCacheTestspass, as does:modules:cache-common:test(50 tests, includingTieredSpilloverCacheTests40/40) — re-verified after rebasing ontomain.Related Issues
Same defect class as #22522; follow-up to #22491, #22499 and #22498.
Check List
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.