WildcardMatchingQuery captures the shard-scoped SearchLookup and a
ValueFetcher supplier when built from a QueryShardContext, but its
weight unconditionally reported isCacheable() == true. Once admitted
to the LRU query cache (whose entries outlive the search), the cache
pinned the SearchLookup and everything reachable from it on the heap.
Since equals/hashCode ignore the captured lookup, every unique
wildcard pattern pinned a distinct object graph, and the RAM
accounting missed the pinned state, so memory-based eviction never
kicked in. Repeated wildcard queries could exhaust the node's heap.
The weight now reports isCacheable() == false whenever a SearchLookup
was captured. To preserve caching of the expensive work, the
first-phase trigram query is created through searcher.createWeight()
with COMPLETE_NO_SCORES (its scores were never used), keeping it
independently cacheable.
Includes a regression test that runs a context-bound query through an
LRUQueryCache, retains only a WeakReference to the SearchLookup, and
asserts it becomes unreachable after the query is released. The test
fails on the previous behavior with the lookup still strongly
reachable from the cache.
Verified on a live node (2000 unique patterns, filter context,
forced GC): before the fix, 2000 QueryShardContext and 4000
SearchLookup instances remained reachable (~88 MB on a toy index
while the cache self-reported 2.4 MB); after the fix, none remain
and heap returns to baseline, with first-phase cache hits intact.
Resolves opensearch-project#22419
Signed-off-by: Adam-Horse <legitasian321@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Description
Fixes a heap exhaustion caused by
WildcardMatchingQuery(the two-phase query behind thewildcardfield type) being admitted to the LRU query cache while holding shard-scoped state.When built from a
QueryShardContext, the query captures the shard'sSearchLookupand aValueFetchersupplier for second-phase verification, but its weight unconditionally reportedisCacheable() == true. Cache entries outlive the search, so each cached wildcard query pinned its captured object graph on the heap. Becauseequals/hashCodeignore the captured lookup, every unique pattern pinned a distinct graph, and the cache's RAM accounting (viaQuery#visit, which only sees the first-phase terms) missed the pinned state entirely, so memory-based eviction never triggered. Under steady wildcard query traffic this accumulates until the node runs out of heap.Changes:
isCacheable() == falsewhenever aSearchLookupwas captured, keeping the stateful two-phase query out of the cache.searcher.createWeight(firstPhaseQuery, ScoreMode.COMPLETE_NO_SCORES, 1f)instead offirstPhaseQuery.createWeight(...), so the cheap, stateless part of the query remains independently cacheable (its scores were never used; the outer weight is constant-score).Tests:
testSearchLookupNotPinnedByQueryCache— regression test for the reported leak: runs a context-bound query through anLRUQueryCache, retains only aWeakReferenceto the capturedSearchLookup, releases the query, and asserts the reference clears under GC. Onmainit fails withSearchLookup is still strongly reachable: the query cache is pinning shard state; with this fix it passes.testCacheabilityDependsOnSearchLookup— the weight is cacheable without aQueryShardContextand not cacheable with one.testFirstPhaseQueryCachedIndependently— the first-phase query still enters the cache and serves hits, while the outerWildcardMatchingQuerynever does.Verified on a live node built from source (1 GB heap, 20k-doc single-segment
wildcardindex, 2000 unique patterns x 6 repetitions in filter context withrequest_cache=false, then forced GC +jcmd GC.class_histogram):SearchLookup/QueryShardContextOn
mainthe cache self-reported ~2.4 MB while actually pinning ~88 MB, even with a near-empty mapping — consistent with the tens-of-GB retention reported in the issue at production scale.A follow-up restructuring
WildcardMatchingQueryso the second phase does not capture shard-scoped state at all (making the full query safely cacheable again) is discussed in the linked issue.Related Issues
Resolves #22419
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.