All findings · Lucene finding · LFV-945 · lruquerycache-clear-methods-javadoc-promise-removal

LRUQueryCache.clearQuery and clearCoreCacheKey say "Remove all cache entries", but on main they only mark the entries for cleanUp()

On unreleased Lucene (main and branch_11x), LRUQueryCache.clearQuery and clearCoreCacheKey still document "Remove all cache entries", but since #15558 they only mark the entries: until cleanUp() runs (or LRU eviction or clear() drops them), the cache keeps counting them, keeps their memory and answers searches from them. MIGRATE.md never mentions this duty; search results stay correct.

Summary

Priority
Low, rank 1264This main-only javadoc and MIGRATE.md gap in the core LRUQueryCache affects only cache statistics and memory until cleanUp() runs.
Impact
minor
Since
#15558 (d50fb36d9af3, 2026-04-14); not backported to 10.xintroduced 2026-04-14
Affected
unreleased only: main (12.0.0-SNAPSHOT since #16942; 11.0.0-SNAPSHOT at the tested 645d4fe14a20) and branch_11x (the coming 11.0); 10.6.0 and earlier remove at once and are correct
Status
documented
Found by
semi-formal: a bounded-exhaustive model-based test of the real cache against the method javadocs and the class javadoc

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Since #15558 the two clear methods only add the query or segment key to a pending set, and only cleanUp() drains those sets. Lookups ignore the pending sets, so a cleared entry is still counted, still uses memory and still answers searches. The class javadoc documents this; the methods' own one-line javadocs still say they remove.

Background: the query cache and its clear methods

  1. LRUQueryCache keeps the matching documents of a query per segment, as (segment, query) entries. It is off by default on main; an application turns it on with IndexSearcher.setQueryCache or setDefaultQueryCache.
  2. clearQuery(q) drops the entries of one query, clearCoreCacheKey(key) those of one segment. The cache also calls clearCoreCacheKey itself when a segment is closed.
  3. Up to 10.6.0 both removed the entries before returning. #15558 (11.0) moved the removal to cleanUp(), which runs on a background scheduler if you pass CacheCleanUpParameters, or when you call it.
Diagram of the bug: on Lucene main a query is cached (cache size 1); clearQuery returns but only marks the entry, so the size stays 1 and the next identical search is a cache hit with the correct document; only cleanUp() removes it (size 0). On 10.6.0 the same clearQuery call removes the entry at once (1 -> 0).
A cached entry, the clear call that only marks it, the next search still served from it, and cleanUp() that finally removes it. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. On main you enable the query cache, here new LRUQueryCache(100, 1 << 20, context -> true, 1f), and run a TermQuery once on a 1-document index: getCacheSize() is 1.
  2. You call cache.clearQuery(query), whose javadoc says "Remove all cache entries for the given query." You expect the size to go 1 -> 0, as it does on 10.6.0 and earlier releases.
  3. Instead the size stays 1, ramBytesUsed() is unchanged, no eviction callback fires, and the next identical search is a cache hit on the "removed" entry (hit count 0 -> 1, the correct 1 document). clearCoreCacheKey(key) does the same.
  4. The call only put the query into a pending set. Only cleanUp() acts on that mark: called by you, or by a scheduler that only the 6-argument constructor with CacheCleanUpParameters sets up; until then the entry stays unless LRU eviction or clear() removes it first. The class javadoc says so; the two method javadocs and MIGRATE.md do not.

Who is affected

Applications on 11.0 and later that enable the query cache and call clearQuery or clearCoreCacheKey, for example behind a clear-cache action, trusting the method javadoc. Until the next cleanUp() they still see the entries in getCacheSize(), ramBytesUsed() and hit counts, the memory is not freed, and eviction callbacks come late. Opt-in: the cache is off by default. Results stay correct.

Mechanism, terms and numbers619 words

LRUQueryCache.clearQuery(query) and clearCoreCacheKey(key) are documented as "Remove all cache entries for the given query" and "... for the given core cache key" (LRUQueryCache.java:450, :442). Up to 10.6.0 they did exactly that before returning. #15558 (2026-04-14, not backported to 10.x) made removal deferred on purpose: both methods now only add the query or segment key to a pending set, and cleanUp() removes the marked entries later, on a background schedule if the cache was built with CacheCleanUpParameters, otherwise only when the application calls it. #15558 described this in the class javadoc ("Stale cache entries (from closed segments or cleared queries) are not removed inline"), but left the two method one-liners as they were.

So on main, after a clear call returns, the entry is still counted by getCacheSize(), still accounted in ramBytesUsed(), still returned by lookups and counted as a hit, and its eviction callback has not fired. MIGRATE.md, whose 11.0 recipe enables the cache with the 2-argument constructor (no automatic cleanup), never mentions cleanUp() or CacheCleanUpParameters. Search results stay correct. The fix is text only: reword the two javadocs and add a MIGRATE.md note.

The code (main 645d4fe14a20)

// LRUQueryCache.java:442-455
/** Remove all cache entries for the given core cache key. */
public void clearCoreCacheKey(IndexReader.CacheKey coreKey) {
  this.registeredClosedListeners.remove(coreKey);
  synchronized (keysToClean) {
    keysToClean.add(coreKey);                    // only marks the key
  }
}

/** Remove all cache entries for the given query. */
public void clearQuery(Query query) {
  for (int i = 0; i < numberOfPartitions; i++) {
    this.lruQueryCachePartition[i].clearQuery(query);   // :1250-1257: queriesToClean.add(query)
  }
}
  1. Both methods only mark. Neither removes a cache entry, fires an eviction callback or lowers ramBytesUsed() inside the call; clearCoreCacheKey also drops the segment's closed-listener registration (:443).
  2. Lookups ignore the marks. LRUQueryCachePartition.get (:1060-1083) returns the cached entry and calls onHit (:1077) without looking at keysToClean or queriesToClean.
  3. Only cleanUp() drains the marks (:1392-1413, the only code that drains the two sets); until it runs, a marked entry stays unless LRU eviction (:1214-1246) or clear() (:457-463) removes it first. It runs on a scheduler only when the 6-argument constructor got CacheCleanUpParameters with an executor (:197-207); the 4-argument constructor passes null (:150) and the 2-argument one delegates to it (:252).
  4. Closed segments take the same path. The close listener registered for each cached segment is clearCoreCacheKey itself (:1135).
  5. Results stay correct. Only SegmentCacheable queries are cached, which depend only on segment-immutable structures (SegmentCacheable.java:27-36), so a still-served entry holds the documents the query would compute again. The study's model-based test found 0 cached-vs-uncached mismatches.

The documentation

wherewhat it says
method javadocs LRUQueryCache.java:442, :450"Remove all cache entries for the given core cache key." / "... for the given query."
class javadoc :103-114"Stale cache entries (from closed segments or cleared queries) are not removed inline during cache operations"; without CacheCleanUpParameters "the caller is responsible for invoking cleanUp() periodically"
6-arg constructor :162-167, CacheCleanUpParameters :1353-1362, cleanUp() :1382-1391describe the deferral
MIGRATE.md :227-236 (11.0 recipe)IndexSearcher.setDefaultQueryCache(new LRUQueryCache(maxCachedQueries, maxRamBytesUsed));, no cleanup
MIGRATE.md :514-542 (#15558 section)the CacheKey signature and the callbacks, with a cache.clearCoreCacheKey(...) example; no word on deferred removal

MIGRATE.md has no occurrence of cleanUp or CacheCleanUpParameters.

The numbers (tie-break probe, main)

For both clearQuery and clearCoreCacheKey, on one document: cached size=1; right after the call size=1, ramBytesUsed() unchanged, 0 evictions; next identical search size=1, hits=1, 1 document; after cleanUp() size=0, 1 eviction, ramBytesUsed() 0. With the 2-argument constructor and a 10,000-document segment, closing the reader leaves the entry counted with no eviction until cleanUp(): cached=1 afterReaderClose=1 evictedAfterClose=0 afterCleanUp=0 evicted=1.

Exposure and likelihood319 words
  • Who. Applications on 11.0 (branch_11x) and later (main) that install an LRUQueryCache and call clearQuery or clearCoreCacheKey, and 10.x users who learned that these calls remove at once. The class is @lucene.experimental (LRUQueryCache.java:117).
  • Default vs opt-in. Opt-in: the query cache is off by default on main (IndexSearcher.java:84, DEFAULT_QUERY_CACHE = null, #14187). Among those who turn it on, MIGRATE.md's own recipe builds a cache with no automatic cleanup.
  • What they see. After a clear call: the entries in getCacheSize(), ramBytesUsed(), hit counts and onHit; memory not freed; onCacheEntryEvicted / onDocIdSetEviction only at the next cleanUp(), LRU eviction or clear().
  • Scale. Every call, deterministically. If the application never calls cleanUp(), the study's 1,000-round near-real-time run ended with 996 pending keys and 402 of 434 entries belonging to closed segments (432,704 accounted bytes, against 32 entries and 206,400 bytes with cleanUp() every round), and a Query passed to clearQuery stayed strongly reachable after LRU had evicted its entries. Entry count and accounted memory stay within maxSize and maxRamBytesUsed, and that run's hit and miss counts matched a run with cleanUp() every round, so no hit-rate loss is claimed. The PR author wrote that without cleanup "it will affect cache hit rates pretty badly", about stale segment entries piling up until LRU evicts them (sgup432 on #15558, 2026-04-22); one synthetic run settles neither way, so no hit-rate claim is made either way.
  • Not affected. Search results; the cache's size and memory bounds; every release up to 10.6.0.
  • Migration impact is recognized (evidence that someone relied on the two summaries was not found). On #15558, msokolov warned that cleanups being disabled by default "could bite someone if they are relying on it"; elastic/elasticsearch#163523 (open) lists "Cleanup is now async: a scheduler, or cleanup(). Without either, stale keys sit until LRU evicts them." among its adaptations to Lucene 11.

Minimal repro

The test shows that on main LRUQueryCache.clearQuery(q) and clearCoreCacheKey(key) leave the cache entry in place until cleanUp(), although their javadocs still say "Remove all cache entries for the given query" / "... core cache key" (a documentation defect; the deferral itself is the intended #15558 design).

TestLRUQueryCacheClearRemovesEntries.java (JUnit test)75 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestLRUQueryCacheClearRemovesEntries extends LuceneTestCase {

  public void testClearQueryAndClearCoreCacheKeyRemoveEntries() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new StringField("f", "a", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        LRUQueryCache cache = new LRUQueryCache(100, 1 << 20, context -> true, 1f);
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setQueryCache(cache);
        searcher.setQueryCachingPolicy(
            new QueryCachingPolicy() {
              @Override
              public void onUse(Query query) {}

              @Override
              public boolean shouldCache(Query query) {
                return true;
              }
            });
        Query query = new TermQuery(new Term("f", "a"));
        IndexReader.CacheKey coreKey =
            reader.leaves().get(0).reader().getCoreCacheHelper().getKey();

        searcher.search(new ConstantScoreQuery(query), 1);
        cache.clearQuery(query);
        long sizeAfterClearQuery = cache.getCacheSize();

        cache.cleanUp();
        searcher.search(new ConstantScoreQuery(query), 1);
        cache.clearCoreCacheKey(coreKey);
        long sizeAfterClearCoreCacheKey = cache.getCacheSize();

        // expected 0 / 0 (javadocs: "Remove all cache entries for the given ..."); actual 1 / 1
        assertEquals(
            "cache size after clearQuery / after clearCoreCacheKey",
            "0 / 0",
            sizeAfterClearQuery + " / " + sizeAfterClearCoreCacheKey);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-945/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.TestLRUQueryCacheClearRemovesEntries
Expected vs actual
Expected (method javadocs): cache size 0 after each call. Actual: cache size after clearQuery / after clearCoreCacheKey expected:<[0 / 0]> but was:<[1 / 1]> (-ea and -da).

All files of this repro: repro/LFV-945/

More files, environment, full output42 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: cache size after clearQuery / after clearCoreCacheKey expected:<[0 / 0]> but was:<[1 / 1]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.apache.lucene.search.TestLRUQueryCacheClearRemovesEntries.testClearQueryAndClearCoreCacheKeyRemoveEntries(TestLRUQueryCacheClearRemovesEntries.java:68)

Why it looks unintended

  • The method javadocs say "Remove all cache entries for the given query"; from 5.1.0 to 10.6.0 these words meant removal before return.
  • The class javadoc in the same file says cleared-query entries "are not removed inline": the two texts contradict each other.
  • #15558 rewrote the surrounding javadocs and tests but left the two one-liners; MIGRATE.md says nothing.
The full argument255 words
  • The method text. "Remove all cache entries for the given core cache key." (LRUQueryCache.java:442) and "Remove all cache entries for the given query." (:450). The text dates from LUCENE-6077 (2014) and LUCENE-6303 (2015); in 10.6.0 it sits over bodies that remove under the write lock (10.6.0 LRUQueryCache.java:373-404).
  • The same file disagrees. "Stale cache entries (from closed segments or cleared queries) are not removed inline during cache operations." (:103-104). After the call the entries are not even logically removed: they are still returned and counted as hits.
  • #15558 updated everything around them. The squash commit d50fb36d9af3 wrote the class paragraph, the 6-argument constructor, CacheCleanUpParameters and cleanUp() javadocs; in TestLRUQueryCache it inserted cleanUp() between existing clear calls and their assertions (e.g. :472-473, :762-769) and added a new test, testInvalidation (:2679-2722), whose clear calls are followed by cleanUp(). It left the two one-liners; no comment or review comment discusses them (the one inline review thread on the new clearCoreCacheKey body is about what it removes, not its javadoc).
  • The migration guide is silent. MIGRATE.md's #15558 section (:514-542, added by #16756) shows a clearCoreCacheKey call and no cleanup note. Explaining why #15558 stayed off 10.x ("the query cache re-implementation introduced some breaking changes"), the PR author added: "we also changed the cleanup logic of the cache from sync to async flow" (sgup432, 2026-07-20).
  • What is not claimed. That deferral is wrong (it is the design), that results are wrong, or that the hit rate drops.
The devil's-advocate check192 words

Strongest case for "intended / noise" (the cross-model refute reviewer, OpenAI GPT-5.6, which ran its own failing two-segment test and voted not real with high confidence): the deferral is deliberate and documented four times (class, 6-argument constructor, CacheCleanUpParameters, cleanUp()); a test comment says "clearQuery() populates queriesToClean so that cleanUp() actually scans cache entries" (TestLRUQueryCache.java:272); the later fix #16212 kept the two-stage design; "Remove all" does not say "immediately"; the class is @lucene.experimental; no result is ever wrong.

Why it fails. It defeats a code-bug claim, which nobody makes. It does not make the method text true: on that reading "Remove all cache entries" would also fit a method that removes nothing until another method runs, and the class javadoc in the same file says these entries are "not removed inline". The reader most likely to trust the one-liner is the 10.x user, for whom the same words meant removal before return. The reviewer itself conceded that "MIGRATE.md and the method summaries could more explicitly repeat the cleanup requirement" and called the wording patch "a reasonable documentation enhancement". The objection fits the MIGRATE.md half better than the one-liners.

Suggested fix

Recommended Reword the two javadocs and add a MIGRATE.md note

Documentation only: reword the two method javadocs to say the entries are marked and removed by the next cleanUp() (scheduled through CacheCleanUpParameters, or called by the application), and add a MIGRATE.md paragraph on deferred removal and the cleanup duty. No behaviour change.

/**
 * Marks all cache entries for the given core cache key for removal. Marked entries are removed by
 * the next {@link #cleanUp()}, which runs either on the background schedule configured through
 * {@link CacheCleanUpParameters} or when the caller invokes it, unless LRU eviction or {@link
 * #clear()} removes them first. Until then they may still be returned by lookups and are counted
 * by {@link #getCacheSize()} and {@link #ramBytesUsed()}.
 */
public void clearCoreCacheKey(IndexReader.CacheKey coreKey) {
// clearQuery(Query): the same text, "for the given query"; MIGRATE.md: one paragraph, quoted in this option
All options and trade-offs2 options ยท 749 words

Two options. The deferral is #15558's design, so the recommended fix changes only text; a code option that restores removal-on-call for the two explicit methods is listed for upstream to weigh.

Reword the two javadocs and add a MIGRATE.md note (validated by the checks) (recommended)

Both method javadocs: "Marks all cache entries for the given query (core cache key) for removal. Marked entries are removed by the next cleanUp(), which runs either on the background schedule configured through CacheCleanUpParameters or when the caller invokes it, unless LRU eviction or clear() removes them first. Until then they may still be returned by lookups and are counted by getCacheSize() and ramBytesUsed()."

MIGRATE.md, in the #15558 section: "clearQuery, clearCoreCacheKey and the segment close listener no longer remove entries right away: they mark them, and cleanUp() removes them. Either pass CacheCleanUpParameters with a ScheduledThreadPoolExecutor to the 6-argument constructor (and close() the cache when done), or call cleanUp() yourself. The 2- and 4-argument constructors configure no automatic cleanup."

Related nits: the class-javadoc example (LRUQueryCache.java:76-86) builds a cache with no cleanup, and :74 still says IndexSearcher uses a default query cache, although DEFAULT_QUERY_CACHE is null on main. LFV-1000 is another #15558 javadoc gap in the same file and MIGRATE.md section.

/**
 * Marks all cache entries for the given core cache key for removal. Marked entries are removed by
 * the next {@link #cleanUp()}, which runs either on the background schedule configured through
 * {@link CacheCleanUpParameters} or when the caller invokes it, unless LRU eviction or {@link
 * #clear()} removes them first. Until then they may still be returned by lookups and are counted
 * by {@link #getCacheSize()} and {@link #ramBytesUsed()}.
 */
public void clearCoreCacheKey(IndexReader.CacheKey coreKey) {
// clearQuery(Query): the same text, "for the given query"; MIGRATE.md: one paragraph, quoted in this option

Trade-offs

  • No code or behaviour change, no risk; the deferral and its performance reasons stay.
  • Callers still have to remember cleanUp(); the text only tells them. close() shuts down the executor passed in CacheCleanUpParameters (LRUQueryCache.java:271-275), so a shared executor needs care; the MIGRATE.md note could say so.
  • "The next cleanUp()" strictly means the next one that starts after the call: a cleanUp() already running has taken its copy of the pending sets (LRUQueryCache.java:1393-1394).

What the checks validated

  • Scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, 2026-11-01, with the final wording (after the fact checks): the patch applies cleanly.
  • ./gradlew :lucene:core:checkGoogleJavaFormat :lucene:core:renderJavadoc passes; the rendered javadoc of both methods shows the new text with working links to cleanUp(), clear(), LRUQueryCache.CacheCleanUpParameters, getCacheSize() and ramBytesUsed().
  • ./gradlew :lucene:core:test --tests TestLRUQueryCache --tests TestLRUQueryCacheClearThenCleanUpRemovesEntries -Ptests.seed=DEADBEEF: 41 tests, 3 skipped, 0 failures.
  • The change is comments and Markdown only: with the patch applied the repro still fails exactly as shown, which is now what the javadoc says; the cleanUp() control passes. MIGRATE.md was not rendered to HTML. An earlier wording without the eviction/clear() clause passed the same checks.

Code option: make the two explicit clear methods remove at once again (for upstream to weigh) (validated by the checks)

From the hunt: clearQuery removes the query's entries from its one partition under that partition's write lock (routing is by query alone); an explicit clearCoreCacheKey removes the segment's entries from every partition; the segment close listener moves to a private onCoreClosed that keeps the deferred path, so segment close stays cheap. The javadocs then stay true as written.

Trade-offs

  • Reverses part of a deliberate #15558 design; the author moved cleanup off the calling thread on purpose.
  • clearCoreCacheKey scans every partition on the calling thread (linear in the cache size); clearQuery scans one partition under its write lock.
  • clearCoreCacheKey takes the partition locks one at a time, so it is not atomic with concurrent inserts (10.6.0's method held the cache's single write lock); it also replaces the public LRUQueryCachePartition.clearQuery(Query) (LRUQueryCache.java:1250, partitions public through getLruQueryCacheSegments(), :210) with a package-private removeIf. Neither point was stress-tested.
  • With this option the minimal test is the regression test.

What the checks validated

  • Scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, 2026-11-01: ./gradlew :lucene:core:checkGoogleJavaFormat passes; ./gradlew :lucene:core:test --tests TestLRUQueryCacheClearRemovesEntries --tests TestLRUQueryCacheClearThenCleanUpRemovesEntries --tests TestLRUQueryCache -Ptests.seed=DEADBEEF: the repro passes, the control passes, TestLRUQueryCache 40 tests, 3 skipped, 0 failures.
  • Earlier, in the study: the same file shadowed over the main jar passed the repro and upstream TestLRUQueryCache (37 of 37 then), and flipped the model-based test to conform to the method javadocs.
  • Not benchmarked; concurrency of the new removal path was not stress-tested.

Downstream note

Projects that expose a clear-cache operation on top of these methods need to call cleanUp() or configure CacheCleanUpParameters when they move to Lucene 11; Elasticsearch already tracks the new cleanup model in elastic/elasticsearch#163523.

How it was found

A probe saw an entry survive clearQuery. The hunt then drove the real cache through every sequence of up to 6 operations on a 2-segment reader against two models, the method javadocs and the class javadoc: on main 165,496 of 299,592 sequences break the first and none the second; on 10.x none break the first.

The method in full156 words
  • Lead. A code-reading probe on main showed the entry still present and served after clearQuery and clearCoreCacheKey; clear() was the control.
  • Intended or not. The hunt unit read #15558's body and comments and the class javadoc and concluded that the deferral is intended, so this is a documentation defect, not a code bug.
  • Model-based test. It drove the real jar through every sequence of up to 6 operations (search q0/q1, clearQuery q0/q1, clearCoreCacheKey s0/s1, clear, cleanUp) on a 2-segment reader and compared it with two reference models, SYNC (the method javadocs and 10.x) and DEFERRED (the class javadoc). Main: 299,592 sequences, 165,496 diverge from SYNC (the first is [search q0, clearQuery q0]) and 0 from DEFERRED. 10.5.2 and 10.6.0: 137,256 sequences each, 0 diverge from SYNC. A result oracle (cached documents equal uncached documents) found 0 mismatches in all three.
  • Reduction. The finding was reduced to a one-test public-API JUnit, then made portable to 10.x.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-28 and again 2026-11-01). Closest: #15558 itself, where msokolov warned that disabled cleanup "could bite someone", #16756, which wrote the MIGRATE.md section, and elastic/elasticsearch#163523, a downstream adaptation list.

Searches3 searches

Nobody has reported that the two method javadocs contradict the deferred behaviour, or that MIGRATE.md omits the cleanup duty; no open PR touches them.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), JIRA archive, dev@ / java-user@ / issues@ / commits@, downstream repositories, web2026-10-28 and 2026-11-01a scripted prior-report search (51 queries per run on 2026-10-28, 65 on 2026-11-01: class and method names, the javadoc phrases, CacheCleanUpParameters, "not removed inline", symptoms), plus 40 manual GitHub searchesno report; the exact-phrase queries return nothing or only the 2019 design issue #10081
All recent apache/lucene activity (REST scans)2026-11-01every issue and PR updated since 2026-10-27, their 155 comments, and the file lists of all 409 open PRsnothing on the clear methods or the migration text; open PRs #16952, #16847, #16757 touch LRUQueryCache.java elsewhere
Related threads (read in full)2026-10-28 and 2026-11-01#15558 body and comments, #16756, #14222, #16212, #16657, #16740, elastic/elasticsearch#163523the deferral is public and intended; msokolov's warning on #15558 is a design remark; #163523 reports no Lucene defect

History

Introduced on main by #15558 (d50fb36d9af3, 2026-04-14), which made removal deferred and left the 2014/2015 one-liners unchanged; not backported, so no release has it. 9.12.3, 10.0.0, 10.5.2, 10.6.0 and #15558's parent pass; #15558 and main 645d4fe14a20 fail.

Versions tested5 rows

Introduced by #15558 "Refactor QueryCache to improve performance" (Sagar Upadhyaya, merged by Michael Froh on 2026-04-14 as d50fb36d9af3; design issue #14222). The one-liners come from LUCENE-6077 (86a6c37b473, 2014, clearCoreCacheKey) and LUCENE-6303 (e4d88cab5ca, 2015, clearQuery), both by Adrien Grand. MIGRATE.md's 11.0 recipe is from #14187 (26387b4b00e, 2025-04-02) and its #15558 section from #16756 (120b3bc18da, Luca Cavanna, 2026-10-07). #15558 was not backported ("I think it was a conscious choice", sgup432); branch_11x carries the same file.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affected1 -> 1, 1 -> 1 with -ea and -da, 10 of 10 iterations
branch_11x da3ea39fa597, upstream main f815e679bf6c readsame LRUQueryCache.java blob and MIGRATE.md text (not executed)
main d50fb36d9af3 (#15558) vs parent 55b4574a20d2 boundarylucene-core built at each: #15558 1 -> 1, 1 -> 1, its parent 1 -> 0, 1 -> 0
10.6.0 not affected1 -> 0, 1 -> 0 with -ea and -da
9.12.3, 10.0.0, 10.5.2 not affected1 -> 0, 1 -> 0

Independent checks

The finder, an independent reproducer, a devil's advocate, the adjudication, a credibility audit, the current-main verifier and a tie-break call it real, as a minor documentation defect. Two cross-model refute reviews voted not real because the class javadoc documents the deferral, while agreeing with every fact and conceding the wording gap.

Checks and verdicts377 words

The study ran on main a9779ef3c193; LRUQueryCache.java and MIGRATE.md are identical at 645d4fe14a20, where the current-main checks and this doc's runs were made.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) reproduced (doc)Model-based test and a public-API JUnit failing for both clear methods on main; passes on 10.5.2 and 10.6.0.
cross-model review real, high, reproducedOpenAI GPT-5.6, executing; its overclaim corrections (callers may still call cleanUp(); LRU eviction and clear() also remove entries) were applied.
Independent reproducer real (doc)Own JUnit fails on main, passes on 10.5.2, 9.12.3 and 10.6.0; its mechanism test shows the entry counted, in ramBytesUsed() and served until cleanUp().
Cross-model refute (study) not real, mediumReading only; disputed no fact, called the one-liners shorthand and conceded they are potentially misleading.
Intended-behaviour devil's advocate real, low (doc)The intended case holds for the deferral, not for the documentation.
adjudication confirmed, high, docRe-ran the independent reproduction's JUnit with -ea and -da; the cross-model review's "not real" does not defeat the doc claim.
credibility audit confirmedRe-ran the finder's JUnit with a fresh seed; code and MIGRATE.md read.
current-main verifier realThe portable one-test repro fails on 645d4fe14a20 with -ea and -da and passes on 10.5.2; the cleanUp() control passes.
cross-model refute (current main) not real, highOpenAI GPT-5.6, executing: its own test fails on main and its controls pass; argued the class javadoc covers it, called the wording patch a reasonable enhancement.
Second adjudication real (doc, minor)Re-ran the repro, control, 10.5.2 and 10.6.0; executed the hit, memory and callback effects; the refute verdicts answer a code-bug claim the finding does not make.
Fact checks and diagram check of this doc facts hold; wording correctedAn independent fact check re-ran the repro (main with assertions on and off, 10.6.0), the documented gradle command and both fix options (4 wording errors). A read-only fact check by an independent reviewer from another model family (OpenAI GPT-5.6) found 7 errors and several overclaims and missing caveats, none changing the conclusion. The diagram check found 2 errors (a fade glitch and a footnote). Every accepted correction is in this doc; the suggested javadoc was reworded and re-validated.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,306 words

Finding record

LFV-945, snapshot of 2026-11-06

Registry title
On main, the javadocs of LRUQueryCache.clearQuery and clearCoreCacheKey still say 'Remove all cache entries', but since #15558 both methods only mark the entries for the next cleanUp(), so the entries stay counted and keep producing cache hits until then; MIGRATE.md does not mention the deferred removal or the new cleanUp() duty (documentation defect, main only)
Area
search / query cache (LRUQueryCache method javadocs and MIGRATE.md)
Priority
Low, rank 1264: This main-only javadoc and MIGRATE.md gap in the core LRUQueryCache affects only cache statistics and memory until cleanUp() runs.
Impact class
minor
Impact note
Documentation only, in an opt-in expert cache on unreleased main (11.0): search results are always correct, but an application that installs an LRUQueryCache, calls clearQuery or clearCoreCacheKey and trusts the method javadoc still sees the entries in getCacheSize(), ramBytesUsed(), hit counts and onHit callbacks until the next cleanUp() (manual, or a background run that is off unless CacheCleanUpParameters is passed; default delay 60 s), and MIGRATE.md's own 11.0 recipe configures no cleanup; 10.6.0 (released 2026-10-29) and every earlier release are unaffected.
Summary
On main you install an LRUQueryCache, run a query so that it is cached, and call clearQuery(query) or clearCoreCacheKey(key), whose javadocs say 'Remove all cache entries for the given query' and '... for the given core cache key'. You expect the entries to be gone when the call returns, as on 10.x; instead #15558 made both methods only add the query or key to a pending set, so getCacheSize() still counts the entry and the next search is a cache hit (with correct documents) until cleanUp() runs. The deferral is deliberate and the class javadoc describes it, but the two method javadocs were left unchanged, and MIGRATE.md, whose 11.0 recipe builds a cache with no cleanup, never mentions the new cleanUp() or CacheCleanUpParameters duty.
Introduced
d50fb36d9af3 (GITHUB#15558 'Refactor QueryCache to improve performance', by sgup432, merged by msfroh; design issue #14222) [exec: file-level boundary, this commit's LRUQueryCache.java shadowed over the main a9779ef3c193 core jar fails, its parent 55b4574a20d's passes]. That commit made removal deferred and documented it in the class and 6-arg constructor javadocs, but left the method javadocs, whose text dates from LUCENE-6077 (86a6c37b473, 2014, clearCoreCacheKey) and LUCENE-6303 (e4d88cab5ca, 2015, clearQuery) and was last reformatted by 2d6ad2fee6df (2020) [read: blame]. The MIGRATE.md #15558 section came later from #16756 (120b3bc18da, by javanna, 2026-10-07) and covers only the CacheKey signature and the callbacks; the 11.0 recipe with the 2-arg constructor is from #14187 (26387b4b00e, 2025-04-02) [read] (2026-04-14 (author date of d50fb36d9af, 2026-04-14T18:55:32+02:00; merged 16:55:32Z); MIGRATE.md section 2026-10-07 (#16756))
First affected version
unreleased (main / 11.0.0-SNAPSHOT only; #15558 was not backported to 10.x, so 10.6.0 (released 2026-10-29; branch_10_6 head 4ae946e5a9b) is unaffected [exec])
Affected versions
main from d50fb36d9af on [exec: main a9779ef3c193, the finder's JUnit fails 2 of 3 with -ea and -da (seed DEADBEEF; 40 of 40 clear-method runs over 20 iterations), the independent reproduction's JUnit fails 2 of 3 with -ea and -da; file-level boundary parent pass / introducing commit fail]; upstream main 645d4fe14a20 (2026-10-28) still has the same LRUQueryCache.java and MIGRATE.md text [read; no commit to either file since 2026-10-15]. Not affected: branch_10_6 head 4ae946e5a9b (10.6.0 RC), 10.5.2, 10.0.0 and 9.12.3 [exec: synchronous removal, JUnit or an equivalent test program passes]; branch_10x [read: same LRUQueryCache.java blob as branch_10_6]; 5.1.0-8.x [read: synchronous removal at the 5.1.0 tag]. All runs JDK 25.0.3 on Linux aarch64
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by exhaustive check, randomized/MBT
Upstream and prior reports
none found (searched 2026-10-28: three scripted prior-report searches (51 queries each over GitHub issues and PRs, commits, downstream repositories, dev@/java-user@/issues@/commits@ lists, the JIRA archive and the web), 40 manual GitHub searches incl. downstream, cross-references of #15558, #14222, #16212 and #16657, the changed files of all 174 open apache/lucene PRs updated since 2026-08-15, direct list searches, and the finder's own 45 queries). Nearest items, not this bug: #15558 (the introducing change; its body describes the asynchronous or manual cleanup, and msokolov's post-merge comment #issuecomment-4291699876 of 2026-04-21 warned that disabled-by-default cleanup 'could bite someone if they are relying on it'; nobody mentions the method javadocs or a migration note), #16756 (added the MIGRATE.md #15558 section without the cleanUp duty), #14222 (design issue), #16212, #16657 and #16740 (other LRUQueryCache changes), and downstream elastic/elasticsearch#163523 (javanna, opened 2026-10-27, open), which describes adapting to the new async cleanup ('Without either, stale keys sit until LRU evicts them') but reports no Lucene defect and does not mention clearQuery, the method javadocs or MIGRATE.md. Not fixed upstream: upstream main 645d4fe14a20 (2026-10-28) still has both javadoc lines and no cleanup text in MIGRATE.md; the only open PR touching LRUQueryCache.java (#16757) does not touch these lines. Recheck 2026-10-28 (GitHub search and REST API): no apache/lucene issue or PR mentioning LRUQueryCache updated since 2026-10-27, none about 'query cache' created since 2026-10-26, 'clearQuery' and 'clearCoreCacheKey' still find only #15558 and #10081, the six javadoc issues and PRs created on 2026-10-28 (another contributor's sweep) are about other classes, no Elasticsearch or OpenSearch item beyond #163523, and the latest upstream commits by author date are fadd63e0eb4 (#16657, 2026-09-29) for LRUQueryCache.java and 8476457b875 (#16741, 2026-10-15) for MIGRATE.md.
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT), OpenJDK 25.0.3, Linux aarch64: the repro through gradle with -ea and -da (fails; 2026-11-01) and through the standalone runner (fails, 10 of 10 iterations), its cleanUp() control (passes), and the tie-break probe of hits, memory and callbacks; the recommended documentation fix (format, javadoc and TestLRUQueryCache; its final wording re-run after the fact checks) and the code option (repro and TestLRUQueryCache) in a scratch worktree with gradle (2026-11-01). Release jars 10.5.2 and 10.6.0: the repro passes; 9.12.3 and 10.0.0: an equivalent test program passes. lucene-core built at #15558 d50fb36d9af3 (fails) and at its parent 55b4574a20d2 (passes). Upstream main f815e679bf6c and branch_11x da3ea39fa597 read on 2026-11-01: unchanged.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:103-114 (class javadoc: stale entries from closed segments or cleared queries are not removed inline; without CacheCleanUpParameters the caller must invoke cleanUp()), :117 (@lucene.experimental), :442-448 (clearCoreCacheKey: javadoc 'Remove all cache entries for the given core cache key.', body only adds the key to keysToClean), :450-455 and :1250-1257 (clearQuery: javadoc 'Remove all cache entries for the given query.', partitions only add the query to queriesToClean), :1060-1083 (partition get() consults neither pending set and calls onHit), :1383-1413 (cleanUp() javadoc and body, the only drain); identical to Lucene main ea57360912ed, cmp 2026-10-28
  • lucene/MIGRATE.md:227-236 ('Query caching is now disabled by default' recipe with new LRUQueryCache(maxCachedQueries, maxRamBytesUsed), no cleanup) and :514-542 (#15558 section: CacheKey signature and callbacks only); no occurrence of cleanUp, cleanup or CacheCleanUpParameters (grep); lucene/CHANGES.txt:210 ('GITHUB#15558: Refactor QueryCache for performance.')
  • lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:24-36 (cacheable means segment-immutable) and search/IndexSearcher.java:82-84 (DEFAULT_QUERY_CACHE = null: caching off by default)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:103-117, (at 645d4fe14a20) :145-151, :162-167, :196-207, :251-253, :442-455, :1060-1083, :1135, :1250-1257, :1353-1413
  • lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:24-36 (at 645d4fe14a20)
  • lucene/MIGRATE.md:227-236, (at 645d4fe14a20) :514-542 (grep -i 'cleanup|CacheCleanUpParameters': no hit)
  • a Lucene clone releases/lucene/10.6.0 (4ae946e5a9b9): lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:373-404 (synchronous bodies; git show)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:117, (at 645d4fe14a20) :150, :252
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84; (at 645d4fe14a20) lucene/MIGRATE.md:227-236 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:103-114, (at 645d4fe14a20) :442, :450
  • lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:271-273, (at 645d4fe14a20) :466-475, :758-771, :2708-2717
  • lucene/MIGRATE.md:514-542 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:74-86, (at 645d4fe14a20) :210, :271-275, :1250, :1393-1394
  • a Lucene clone releases/lucene/10.6.0: lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:374-375 (single write lock)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.