A cache segment maps each key to a CompletableFuture and computeIfAbsent
installs that future before the value is loaded, so there is a window in
which a key is in the segment map while its entry is not yet linked into
the LRU list. Any keyed removal can land in that window: it removes the
future from the map, then calls delete(), whose unlink() finds
state == NEW, returns false and drops the removal notification. The
loading thread then promotes the entry, linking it into the LRU list even
though its key is already gone from the map.
The result is an entry that is counted in count()/weight(), is
unreachable via get(), and can no longer be invalidated by key, so its
removal listener never fires. For the node-level fielddata cache that
means the circuit breaker is never credited back, and because that cache
is unbounded by default there is no eviction path to reclaim the entry
either.
Handle the not-yet-linked state in delete(): mark the entry DELETED so
the pending promote() becomes a no-op, and fire the removal notification
so listeners can account for the value the loader produced. There is no
count/weight adjustment to make because the entry was never linked.
This affects every keyed removal, including the public
invalidate(key, value). It became reachable from the fielddata cache
cleanup sweep in opensearch-project#22499, which switched the sweep from iterator.remove()
-- which only removes entries reached through the LRU list, and so never
sees an unpromoted one -- to exact-key invalidation.
Signed-off-by: Josh Wilson <joshuaw@squareup.com>
Description
A cache segment maps each key to a
CompletableFuture, andcomputeIfAbsentinstalls that future in the segment map before the value is loaded, so there is a window in which a key is in the segment map while its entry is not yet linked into the LRU list.Any keyed removal can land in that window. It removes the future from the map, then calls
delete(), whoseunlink()findsstate == NEW, returns false and drops the removal notification. The loading thread then promotes the entry, linking it into the LRU list even though its key is already gone from the map. What is left is an entry thatcount()/weight(),get(),invalidate()looks in the segment map, andFor the node-level fielddata cache that means the circuit breaker is never credited back for the entry, and since
indices.fielddata.cache.sizeis unbounded by default there is no weight-based eviction path to reclaim it either.This is a gap in
Cacherather than in any one caller -- the publicinvalidate(key, value)has always been able to hit it. It became reachable from the fielddata cache cleanup sweep in #22499, which switched the sweep fromiterator.remove()to exact-key invalidation;iterator.remove()only removes entries reached through the LRU list, so it never saw an unpromoted one.delete()now handles the not-yet-linked state: mark the entryDELETEDso the pendingpromote()hits its existingcase DELETEDno-op, and fire the removal notification so listeners can account for the value the loader produced. There is nocount/weightadjustment to make, because the entry was never linked. Every otherdelete()caller passes an entry reached through the LRU list, which is alwaysEXISTING, so nothing else changes behaviour.Verification
testInvalidateBeforePromoteLeavesUnreclaimableEntryforces the interleaving rather than racing for it. The weigher runs insidelinkAtHead()while the LRU lock is held, which gives the test thread a point where it holds that lock, so the loading thread cannot promote its entry until the test thread has finished invalidating it. Onmainit fails deterministically:and with the fix the entry is reclaimed, the listener is notified once with
INVALIDATED, andcount()/weight()are correct.To confirm the interleaving is reachable without that scaffolding, I also ran a version that races two threads for the LRU lock naturally (a third thread stalled in the weigher to hold the lock, the loader parked on it, and the invalidator spinning on-CPU so it can barge ahead of the parked loader). Over 300 attempts against
main,invalidate()beatpromote()24 times and all 24 produced a leaked entry; with the fix the same run hit the interleaving 21 times and leaked none. That test is not included here, since as a permanent regression test it is timing-dependent and much harder to read than the deterministic one.testInvalidateBlocksOnInFlightLoadcovers the surrounding behaviour that made this reachable:keysSnapshot()returns keys whose load is still in flight, andinvalidate()on such a key parks the caller until the load completes, because the invalidation consumer callsfuture.get()on the incomplete future with no timeout. That blocking is a separate concern -- the fielddata sweep can stall on an in-flight load while holding its monitor -- and fixing it means makinginvalidate()asynchronous for in-flight keys, which is a semantics change worth deciding on its own. This PR only fixes the leak; the test documents the current behaviour.common.cache.*,IndicesFieldDataCacheTestsandIndexFieldDataServiceTestsall pass (83 tests), and the new tests pass over 25 iterations.Related Issues
Follow-up to #22499, from this review comment by @sgup432 (thank you).
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.