A statistics bug on main: search results and the cache itself stay correct, but the statistics a subclass keeps through the cache's callbacks drift.
LRUQueryCachecaches the documents that a filter clause matches, per segment. Its own counters,getCacheSize()andramBytesUsed(), are totals for the whole cache.- For finer statistics (per index, per query type) its javadoc says to override callbacks:
onCacheEntryInsertedandonCacheEntryEvictedfire per entry, andonClear()fires when the cache is completely cleared, so a subclass resets its numbers there. - On main (since #15558, in no release) the cache is split into partitions, 16 by default, each with its own lock, instead of one lock for the whole cache.
clear() and one search on main, step by step: the partition is emptied and its lock released, the search caches f:t and the subclass counts it, then onClear() resets the count to 0 while the cache still holds the entry. Select the still to open it at full size. Generated with AI, checked against the code.- You subclass
LRUQueryCacheto keep statistics, as its javadoc suggests: +1 inonCacheEntryInserted, -1 inonCacheEntryEvicted, reset to 0 inonClear(). One thread callsclear()while another thread's search caches the filterf:t. - You expect your count to match
getCacheSize()afterwards: the entry was either cached before the clear (and is gone) or after it (and is counted). On 10.x that always holds:clear()callsonClear()under the lock every insertion takes. - On main,
clear()empties the partition, releases its lock, and only then callsonClear(). The search cachesf:tin that gap (your count: 1), andonClear()resets your count to 0. - Result:
getCacheSize()is 1 but your count is 0. Once that entry is evicted, your count goes to -1 (a longer test that also counts bytes reports -336 bytes against the cache's 0). It stays wrong until the nextclear(); search results and the cache's own counters are correct. - With the default 16 partitions the same happens when a search caches into a partition that
clear()has already passed while it is still clearing later ones.
Only main and branch_11x (the coming 11.0). It takes an application-installed LRUQueryCache subclass that keeps statistics through the callbacks and resets them in onClear(), and a clear() while searches run, for example an admin "clear cache" action. Search results, the memory bound and the cache's own counts stay correct; the subclass's numbers stay wrong until the next clear().
Mechanism, terms and numbers678 words
LRUQueryCache invites subclasses to keep per-index or per-query statistics through callbacks: count in onCacheEntryInserted / onCacheEntryEvicted, reset in onClear(), the "callback when the cache is completely cleared"; its javadoc says these callbacks "are called synchronously and under a lock". On main, #15558 split the cache into partitions with one lock each, and clear() now empties the partitions one at a time and calls onClear() afterwards with no lock held. A search that caches an entry into an already emptied partition, or after the loop and before the subclass's onClear() has reset its statistics, is counted by the subclass and then wiped by that reset, while the entry stays cached.
The subclass then reports 0 entries and 0 bytes for a cache that holds 1 entry of 336 bytes, and -1 / -336 once that entry is evicted, until the next clear(). Search results, the memory bound, and getCacheSize() / ramBytesUsed() once the threads are done stay correct. Every release, 10.6.0 included, runs clear() and onClear() under the lock every insertion takes, so only main and branch_11x (the coming 11.0) are affected.
// LRUQueryCache.java:457-463
/** Clear the content of this cache. */
public void clear() {
for (int i = 0; i < this.numberOfPartitions; i++) {
this.lruQueryCachePartition[i].clear(); // :1191-1203, that partition's write lock only
}
onClear(); // the subclass hook: no lock held
}- A query goes to partition
query.hashCode() & (numberOfPartitions - 1)(:437); each partition has its own read-write lock (:956-959). An insertion takes only its partition's write lock and firesonCacheEntryInsertedunder it (putIfAbsent,:1100-1131). On main a search waits for a busy lock (get(),:1064); nothing skips the cache. clear()releases each partition's lock as soon as that partition is empty, so two windows open: partitions already passed accept new entries while later ones are being cleared, and after the loop every partition accepts new entries until the subclass'sonClear()has reset its statistics (beforeonClear()is called, and while it runs, since it holds no lock).- An insertion in either window is counted by the subclass, then wiped by its reset in
onClear(), while the entry stays cached. The callback history "insert(q), then clear" with q still cached matches no serial order of the two operations. getCacheSize()andramBytesUsed()sum the partitions' own counters (:730-736,:563-569). The partition's ownonClear()still asserts the write lock that insertions take before it resets them (:1139-1143), so the cache's counts are right once the threads are done (the getters sum the partitions without a lock); only the subclass hook lost the lock.- A subclass cannot repair it:
clear()reports no per-entry evictions (neither does 10.x), andonClear()says nothing about partitions or entries. Serializing its own callbacks withclear()does not work either: a private lock held aroundsuper.clear()and taken in every ledger callback deadlocks, because the callbacks run under a partition write lock thatsuper.clear()then needs (executed: a forced schedule with one partition and a latch-free run with 16 partitions both deadlock, assertions on and off). - On every release, 5.1.0 to 10.6.0,
clear()runscache.clear()andonClear()under the one lock every insertion takes (10.5.2 and 10.6.0LRUQueryCache.java:415-426), and from 6.1.0 on a search that finds it busy skips the cache (lock.tryLock()in 6.1.0-9.10.0,readLock.tryLock()from 9.11.0, 10.5.2:821); in 5.1.0-6.0.1 the cache issynchronizedand a search waits.
- Minimal test (one partition, main
645d4fe14a20):getCacheSize()1, subclass count 0, with assertions on and off. - The finder's longer test (main
a9779ef3c193, identicalLRUQueryCache.java): "after clear() returned, subclass stats entries=0 bytes=0 but getCacheSize()=1 ramBytesUsed()=336; after evicting the survivor, subclass stats entries=-1 bytes=-336", both for an insertion just beforeonClear()and for one into an already cleared partition (16 partitions). - A TLA+ model of the partitioned cache (TLC) finds the violation with 2 partitions (17-state trace) and with 1; the 10.x single lock (853,774 distinct states) and the cache without
clear()(1,237,688) are clean.
Exposure and likelihood211 words
- Who. Main and branch_11x only. Caching is disabled by default on main (
IndexSearcher.java:82-84, #14187), so it takes an application that installs its ownLRUQueryCachesubclass keeping statistics through the callbacks and resetting them inonClear()(the pattern the class javadoc suggests and upstream'stestFineGrainedStatsuses), and that callsclear()while searches run. - What goes wrong. The subclass undercounts the entries and bytes that survive the clear, goes negative once they are evicted, and stays wrong until the next
clear(). Each entry cached in the window that is still cached after the reset is one entry the subclass no longer counts. No exception, no assertion. - Not affected. Search results; the cache's own
getCacheSize()andramBytesUsed()once the threads are done; the memory bound (eviction uses the partitions' own counters,requiresEviction(),LRUQueryCache.java:1214-1221); every release. - How likely: plausible. Without any latch, harnesses with 3 search threads (2 in the cross-model review's) and one thread calling
clear()5 times per round ended with a mismatch in 4 of 300 (assertions off) and 7 of 300 (on) rounds (finder), 132-140 of 300, 2 of 500 and 18-29 of 1,000 (history step); 0 withoutclear(), and 0 on 10.5.2 and 10.6. The rates depend on the harness and the machine load.
The test shows that on main, LRUQueryCache.clear() (1 partition) releases the partition lock before it calls onClear(), so an entry a search caches in that window stays cached while a subclass that counts entries in onCacheEntryInserted and resets in onClear() drops it.
/*
* 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 java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
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.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 TestLRUQueryCacheClearOnClear extends LuceneTestCase {
public void testInsertBeforeOnClearIsNotLost() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new StringField("f", "t", Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
AtomicLong entries = new AtomicLong();
CountDownLatch inOnClear = new CountDownLatch(1);
CountDownLatch inserted = new CountDownLatch(1);
LRUQueryCache cache =
new LRUQueryCache(10, 1 << 20, context -> true, 1f, 1, null) {
@Override
protected void onCacheEntryInserted(Object coreKey, Query query, long ramBytesUsed) {
entries.incrementAndGet();
}
@Override
protected void onClear() {
inOnClear.countDown();
try {
inserted.await(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new AssertionError(e);
}
entries.set(0);
}
};
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;
}
});
Thread clearer = new Thread(cache::clear);
clearer.start();
inOnClear.await();
searcher.search(new ConstantScoreQuery(new TermQuery(new Term("f", "t"))), 1);
inserted.countDown();
clearer.join();
// expected: 1, the entry cached before onClear() is still cached; actual: 0
assertEquals("callback entry count", cache.getCacheSize(), entries.get());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-991/, 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.TestLRUQueryCacheClearOnClear
- Expected vs actual
- Expected: callback count 1 =
getCacheSize(). Actual: 0 for a cache holding 1 entry,expected:<1> but was:<0>, with -ea and -da.
More files, environment, full output36 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: callback entry count expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.search.TestLRUQueryCacheClearOnClear.testInsertBeforeOnClearIsNotLost(TestLRUQueryCacheClearOnClear.java:83)- The class javadoc offers these callbacks for per-index statistics and says they are "called synchronously and under a lock"; on main
onClear()runs under none. - Lucene's own partition
onClear()still resets under the insertion lock, and upstream'stestFineGrainedStatskeeps exactly this ledger. - Every release keeps
clear()and inserts mutually exclusive; nothing announces the change.
The full argument274 words
- The callbacks exist for this. "In case you would like to have more fine-grained statistics, such as per-index or per-query-class statistics, it is possible to override various callbacks: ... and {@link #onClear}. ... they are called synchronously and under a lock." (
LRUQueryCache.java:88-101). The sentence dates from LUCENE-6157 ("Add the ability to compute finer-grained stats on the LRU filter cache", Adrien Grand, 2015); on main it is false foronClear(). - Lucene's own code still relies on the lock. The partition's
onClear()asserts the write lock insertions take before it resets its counters (:1139-1143). - Upstream's test keeps exactly this ledger.
testFineGrainedStatscounts inonDocIdSetCache/onDocIdSetEviction, resets inonClear()and asserts the count equalsgetCacheSize()(TestLRUQueryCache.java:954-1130, sequential);testConcurrencycallsclear()while searches run (:114,:155). The class is "thread-safe" (:64). - Nothing announces a change. CHANGES says "GITHUB#15558: Refactor QueryCache for performance." (
lucene/CHANGES.txt:212); MIGRATE coversclearCoreCacheKey(CacheKey)and the per-entry firing of the old hooks (lucene/MIGRATE.md:514-542). On #15558 Michael Sokolov asked "so no functional change expected?" (their question); no comment there mentionsclear()oronClear. - The risk was named in general before. On #10081 (LUCENE-9038, 2019) Ben Manes read the contract as letting implementations "rely on this exclusive read/write access" under the cache's single lock and warned that a concurrent redesign would need to review external users; Adrien Grand replied that a better cache would come with "a way to transition our users to it" and that he "wouldn't worry about the migration path yet" (his words). That warning predates the partitioned code and names neither
clear()noronClear().
The devil's-advocate check197 words
The strongest case for "intended" (built by the refute reviewer, the intended-lens reviewer and the devil's-advocate verifier): clear() says only "Clear the content of this cache." and promises no atomicity; the hooks are Expert and @lucene.experimental; the "under a lock" sentence is performance advice, and onHit / onMiss already run under a shared read lock since #13306; a global barrier would undo the partitioning; on #15558 sgup432 said the deprecated per-unique-query hooks "won't report accurate data"; and the deterministic tests block inside a callback.
Why it fails: latch-free stress runs show the drift with no blocking callback; sgup432's remark concerns the deprecated per-unique-query hooks, while the minimal test uses the recommended onCacheEntryInserted, and the drift is 0 without clear(); Lucene's own testConcurrency calls clear() while searches run; a subclass cannot fix it on its side (a private lock around super.clear() and its callbacks deadlocks, see the mechanism, point 5); clear() is rare, so taking every partition lock there costs little; and the drift is permanent until the next clear(). Verdict: a regression against 10.x and the documented statistics use, not a broken atomicity promise. Documenting a best-effort onClear() remains a legitimate maintainer choice.
Recommended A: clear() holds every partition's write lock (recommended, validated)
Make clear() take every partition's write lock in ascending order, clear all partitions and call onClear() under them (+16/-3 in LRUQueryCache.java). The repro and TestLRUQueryCache then pass with assertions on and off. Land it with LFV-1001's fix (cleanUp() on a private lock): alone, it lets a subclass whose onClear() synchronizes on the cache deadlock against cleanUp().
int locked = 0;
try {
for (; locked < numberOfPartitions; locked++) {
lruQueryCachePartition[locked].writeLock.lock(); // always in ascending order
}
for (int i = 0; i < numberOfPartitions; i++) {
lruQueryCachePartition[i].clear(); // re-enters the write lock it holds
}
onClear(); // now under every partition lock, like the single lock on 10.x
} finally {
for (int i = locked - 1; i >= 0; i--) {
lruQueryCachePartition[i].writeLock.unlock();
}
}All options and trade-offs3 options · 621 words
One fix was built and validated in a scratch worktree of 645d4fe14a20 with gradle, alone and together with the sibling fix for LFV-1001; two alternatives are described, not built.
The finder's fix, with its comment rewrapped for google-java-format. clear() becomes:
int locked = 0;
try {
for (; locked < numberOfPartitions; locked++) {
lruQueryCachePartition[locked].writeLock.lock(); // always in ascending order
}
for (int i = 0; i < numberOfPartitions; i++) {
lruQueryCachePartition[i].clear(); // re-enters the write lock it holds
}
onClear(); // now under every partition lock, like the single lock on 10.x
} finally {
for (int i = locked - 1; i >= 0; i--) {
lruQueryCachePartition[i].writeLock.unlock();
}
}Trade-offs
- Restores the 10.x ordering for
clear()and makes "under a lock" true foronClear()again. It does not make callbacks of different partitions mutually exclusive (LFV-1000, the stale javadoc). - While
clear()runs, lookups and inserts on every partition wait for it (on main a search waits for a busy partition lock; on 10.x it skipped the cache instead).clear()is rare and only clears maps, so the pause is short unless a subclass'sonClear()is slow. - No other path holds two partition locks (
assertConsistent()takes them one at a time), so ascending order suffices among them; TLC model E: no deadlock and the statistics invariant holds (4,852,161 distinct states). - Coupling with LFV-1001.
cleanUp()issynchronizedon the cache and takes partition locks while it holds that monitor (LRUQueryCache.java:1392). With this fix alone, a subclass whoseonClear()synchronizes on the cache can deadlock againstcleanUp(): found by the finder's TLC model (M4) and raised independently by the cross-model reviewer. WithcleanUp()on a private lock (LFV-1001's fix) the combination is clean (TLC M3, 2,180,461 distinct states). Land both together.
What the checks validated
- Stock
645d4fe14a20: the minimal test fails with assertions on and off. - With A: the minimal test and
TestLRUQueryCachepass (41 tests: the minimal test plusTestLRUQueryCache's 40, of which 3 are skipped as@AwaitsFix; 0 failures), seed DEADBEEF with assertions on and seed C0FFEE with assertions off. checkGoogleJavaFormat,ecjLintMain,forbiddenApisMain,ecjLintTestandforbiddenApisTestfor core pass with A and the lint-clean test (re-run from scratch by the fact check).- A plus LFV-1001's private-lock fix: the minimal test and
TestLRUQueryCachepass (seed 5EED1234, assertions on; that run had the earlier comment wording, a comment-only difference; the fact check ran the final combined diff with the same seed: the five checks and both test classes pass). - From the hunt: the finder's and the independent reproduction's JUnits pass with the fix shadowed in, and the independent reproduction's latch-free stress drops to 0 of 300 rounds.
Say in the class and onClear() javadocs that on a partitioned cache onClear() runs after the partitions are cleared, with no lock, and that entries cached concurrently with clear() may survive it.
Trade-offs
No behaviour change, but subclass statistics keep drifting and a subclass has no way to resync (onClear() carries no partition or entry information). The minimal test keeps failing, so it is no regression test for this option. The stale "under a lock" sentence needs fixing anyway (LFV-1000).
Each partition fires the eviction callback for its entries, under its own lock, as it clears them, so a per-entry ledger balances without relying on onClear().
Trade-offs
Changes what clear() has reported since 10.x (no per-entry evictions) and costs one callback per entry; a subclass that still resets in onClear() keeps losing the entries cached after the loop unless onClear() also moves under the locks.
Downstream: Elasticsearch's query-cache subclass resets its per-shard statistics in onClear(), and a code comment there says its callbacks need no lock of their own "since it is done in LRUQueryCache"; whether its statistics drift on Lucene 11 was not tested.
An executed interleaving probe on the stock main jar (a latch in the insertion callback) showed the subclass count drift from getCacheSize(). The finder turned it into public-API JUnits and a TLA+ model of the partitioned cache, which found the same failure with a single partition and checked the 10.x lock and the fix.
The method in full162 words
Method: non-formal first signal, then TLA+ model checking (TLC).
- A round-14 planning probe for the partitioned query cache ran a deterministic interleaving test on the stock main jar: a latch in the insertion callback let a search cache into an already cleared partition, and the subclass reported 0 entries / 0 bytes while
getCacheSize()was 1 andramBytesUsed()336, then -1 after eviction. - A hunt unit turned it into public-API JUnits (16 partitions and the window before
onClear()), a 10.x-callback variant, and a latch-free stress harness. - It then model-checked the partitioned cache in TLA+ (
LruPartClear.tla: N partitions with one write lock each, insertions with their callback, evictions,clear()'s loop thenonClear(),cleanUp()'s steps, a subclass ledger). TLC's counterexample widened the finding to a single partition (onClear()runs outside the partition lock), the 10.x single-lock reference and the all-partition-locks fix were clean, and a second model found the fix's lock-order coupling with the synchronizedcleanUp()(M4).
No prior report: GitHub issues and PRs (with the JIRA archive), commits, mailing lists, downstream projects and the web, searched 2026-10-28/29 and rechecked 2026-10-31. Closest: #10081 (LUCENE-9038, 2019), where Ben Manes warned that subclasses may rely on the cache's single lock.
Searches3 searches
- Closest items, none a report of this bug: #10081 (LUCENE-9038, 2019: Ben Manes' general warning that implementations may rely on exclusive access under the cache's lock, years before the partitioned code); #15558 (the introducing PR; no comment mentions
clear()oronClear); #16657 (sgup432 noted thatclear()empties the cache partition by partition, for a different design question); #16212 (aConcurrentModificationExceptionincleanUp(), fixed); elastic/elasticsearch#163523 (javanna's list of what Elasticsearch must adapt for the Lucene 11 cache, which does not mention this). - Limits: GitHub search does not index review comments (the #15558 threads were read directly); private discussions are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the JIRA archive), downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the web | 2026-10-28 to 2026-10-29 | two scripted prior-report search runs (run 2 alone: 598 distinct hits), 20 manual GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, the changed files of all 78 apache/lucene PRs updated since 2026-10-24, the commits API for LRUQueryCache.java, 19 threads read in full | no report; nearest items listed above |
| apache/lucene issues, PRs and commits | 2026-10-29 (registration check) | the 12 PRs and 10 issues created since 2026-10-27 and the files of all 38 open PRs updated since then | nothing touches the query cache |
| apache/lucene issues, PRs and commits | 2026-10-31 | 'onClear'; LRUQueryCache issues and PRs updated since 2026-10-28; open PRs on LRUQueryCache; commits to LRUQueryCache.java on main and branch_11x | only #10081; open PRs #16757, #16306, #16051 do not touch clear(); no commit after fadd63e0eb4 (2026-09-29) |
Introduced on main by #15558 (d50fb36d9af3, merged 2026-04-14) and not backported, so main and branch_11x only. Every release runs clear() and onClear() under the insertion lock; executed: 8.11.4, 9.12.3, 10.4.0, 10.5.0, 10.5.2 and the 10.6.0 commit pass, the parent passes, d50fb36d9af3 fails.
Versions tested7 rows
Introduced by d50fb36d9af3 (apache/lucene#15558, "Refactor QueryCache to improve performance", Sagar Upadhyaya, merged by Michael Froh on 2026-04-14; design issue #14222). The shape is already in the PR's first partitioning commit ef7cf0ff101e (2026-01-08). It was not backported: Michael Froh said on #15558 they were "erring on the side of caution", and Sagar Upadhyaya later called it "a conscious choice" (their words). No release tag contains it; branch_11x, cut from main on 2026-10-28, does.
By reading, every release from 5.1.0 to 10.5.2 runs clear() and onClear() under the one lock insertions take (synchronized in 5.1.0-6.0.1, a ReentrantLock in 6.1.0-9.10.0, a ReadWriteLock write lock from 9.11.0, #13306). Upstream main 2f424fd428da (2026-10-30) has no LRUQueryCache.java commit after fadd63e0eb4 (2026-09-29).
The version runs use a driver written on the callbacks that exist from 8.x on (onDocIdSetCache, onDocIdSetEviction, onClear), core jar only; the minimal test needs main-only API.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and the standalone runner); passes with fix A |
main a9779ef3c193 | affected | driver inconsistent (count 0, getCacheSize() 1), assertions on and off; latch-free stress 18 of 1,000 rounds |
main d50fb36d9af3 (#15558) | introducing commit | driver inconsistent, assertions on and off; stress 29 of 1,000 |
main 55b4574a20d2 (its parent) | bisect | driver consistent, assertions on and off; stress 0 of 1,000 |
10.6.0 (release commit 4ae946e5a9b) | not affected | driver consistent; the finder's stress 0 of 300 |
| 10.5.2 | not affected | driver consistent; stress 0 of 1,000; the 10.x-callback JUnits of the finder, the independent reproduction and the intended-behaviour check pass |
| 8.11.4, 9.12.3, 10.4.0, 10.5.0 | not affected | driver consistent: the search skips the cache while clear() holds the lock |
The finder, an independent reproducer, a devil's-advocate verifier, an adjudication with a new seed, a credibility audit, the minimal-repro step and this doc's gradle runs all reproduced it. Read-only reviews by OpenAI GPT-5.6 agreed on the root cause; the refute one also raised the fix's lock-order coupling.
Checks and verdicts462 words
Neither cross-model review could run code (both were read-only); all execution evidence comes from Claude-family AI tools and separate re-runs of the repro.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit: JUnit, stress, TLA+/TLC) | candidate | 3-test JUnit fails 2 of 3 with assertions on and off (40 of 40 race runs over 20 iterations), the control passes; the 10.x-callback test fails on main and passes on 10.5.2 and branch_10_6; latch-free stress 4-7 of 300; TLC counterexample with 2 and 1 partitions, the 10.x lock and the fix clean. |
| Refute review (OpenAI GPT-5.6, read-only) | not real, low (procedural) | Could not run code; root cause agrees, inputs in contract; independently pointed out the fix's lock inversion with the synchronized cleanUp(). |
| Independent reproducer | real | Own JUnit (16-partition and 1-partition cases) fails 2 of 3 with assertions on and off; own stress 132-140 of 300, 0 without clear(); passes on 10.5.2 and 9.12.3; with the fix both classes pass and the stress is 0 of 300. |
| Intended-behaviour review (OpenAI GPT-5.6, read-only) | real (medium) | The works-as-designed case rated moderate and rejected; the step re-ran the JUnit (fails) and its own stress on the new callbacks: 2 of 500, 0 without clear(). |
| Devil's advocate | real (medium-high) | Seven works-as-designed arguments built and rejected; main fails, 10.5.2 passes. Framing: a regression, not a broken atomicity promise. |
| Adjudication | confirmed (high); impact minor | Re-ran the independent reproduction's JUnit with seed C0FFEE42: fails 2 of 3 with assertions on and off, the control passes. |
| Credibility audit | keep | Seed A0D17005 fails 2 of 3, the fix passes 3 of 3; found that the 16-partition test's query pick depends on the seed, one reason the minimal test uses one partition. |
| Minimal repro (2026-10-29) | reproduces | Standalone runner on 645d4fe14a20: fails with assertions on and off, 20 of 20 per mode; the fix passes. |
| This doc (2026-10-31) | reproduces; fix validated | Gradle in a scratch worktree: stock fails (assertions on and off); fix A passes the repro and TestLRUQueryCache (on and off) and the core lint checks; A plus LFV-1001's fix passes. |
| Fact check (2026-10-31) | holds; 3 text fixes | Re-ran the repro (stock fails with assertions on and off, fix A passes), the gradle command, fix A and the final combined diff with the five core checks. Its three text fixes (the TLC trace length, the cross-model review's thread count, the release range of skip-on-busy) are applied here. |
| Cross-model fact check (OpenAI GPT-5.6, read-only) | substantially sound | Two wording fixes adopted (where the minimal test pins the insertion; the test counts). Its proposed subclass-side workaround, a private lock around super.clear() and the callbacks, was run and deadlocks, so that point is answered in the mechanism, point 5. |
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.
- PNG 477 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,394 words
- Registry title
- On main, LRUQueryCache.clear() empties its partitions one at a time and then calls onClear() with no lock held, so an entry cached during clear() survives while a subclass that resets its statistics in onClear() drops it (it reports 0 entries for a cache holding 1, and -1 once that entry is evicted); regression from #15558, main only
- Area
- search / query cache (LRUQueryCache.clear() and the onClear statistics callback)
- Priority
- Low, rank 759: An unreleased main-only LRUQueryCache regression that only skews a subclass's own statistics when clear() races with searches while cache contents stay correct, so it follows the broader main-only query-cache row LFV-054.
- Impact class
- minor
- Impact note
- Statistics only, on unreleased main (11.0): an application that installs an LRUQueryCache subclass keeping statistics through the documented callbacks (counted on insert and evict, reset in onClear()) and calls clear() while searches run gets statistics that undercount the surviving entries, turn negative once those are evicted and stay wrong until the next clear(); search hits, the cache's own getCacheSize()/ramBytesUsed() and its memory bound stay correct, the default query cache is off on main, and every release, 10.6.0 (released 2026-10-29) included, is unaffected.
- Summary
- You subclass LRUQueryCache to keep per-index statistics the way its class javadoc suggests (count in onCacheEntryInserted/onCacheEntryEvicted, reset in onClear()) and call clear() while searches are running. You expect the subclass's numbers to match getCacheSize() and ramBytesUsed() afterwards, as on 10.x, where clear() and onClear() run under the same lock every insertion takes; on main, clear() empties the partitions one at a time, each under its own lock, and then calls onClear() with no lock, so an entry that a search caches into an already emptied partition (or just before onClear()) stays in the cache while onClear() wipes its count. The subclass then reports 0 entries and 0 bytes for a cache that holds 1 entry of 336 bytes, -1 and -336 once that entry is evicted, and the error lasts until the next clear().
- Introduced
- d50fb36d9af3 (GITHUB#15558 'Refactor QueryCache to improve performance', by Sagar Upadhyaya (sgup432), merged by Michael Froh (msfroh); design issue #14222; the clear()/onClear() shape is already in the PR's first partitioning commit ef7cf0ff101e, authored 2026-01-08) [exec: the history driver passes at the parent 55b4574a20d2 and fails at d50fb36d9af3, both plain javac builds of lucene-core, with -ea and -da; read: blame of LRUQueryCache.java:459-462 on a9779ef3c193, git log -L 457,463 and git log -S show no later change]. The class javadoc sentence it made false for onClear ('called synchronously and under a lock') dates from LUCENE-6157 (d7e0fd28dd9, Adrien Grand, 2015) [read: blame] (2026-04-14 (squash commit d50fb36d9af3, author and commit date; PR opened 2026-01-09))
- First affected version
- unreleased (main / 11.0.0-SNAPSHOT since 2026-04-14 and branch_11x, cut from main 2026-10-28; #15558 was deliberately not backported, so every release with LRUQueryCache, 5.1.0 to 10.5.2, and 10.6.0 (released 2026-10-29; branch_10_6 4ae946e5a9b) are unaffected [exec on 8.11.4, 9.12.3, 10.4.0, 10.5.0, 10.5.2 and the 10.6 RC; read for the other release tags])
- Affected versions
- main from d50fb36d9af3 (2026-04-14) to a9779ef3c193 [exec: the finder's JUnit fails 2 of 3 with -ea and -da (seed DEADBEEF; 40 of 40 race runs over 20 iterations), the independent reproduction's own JUnit fails 2 of 3 with -ea and -da, the adjudication's re-run with seed C0FFEE42 fails with -ea and -da, the re-run with seed 5EED4001 fails 2 of 3 with -da; the history driver fails at d50fb36d9af3 and a9779ef3c193 with -ea and -da]; branch_11x [read: contains d50fb36d9af3, newest LRUQueryCache.java commit fadd63e0eb4, same as main; not executed separately]; upstream main 645d4fe14a2 (2026-10-28) [read: no LRUQueryCache.java commit after fadd63e0eb4, author date 2026-09-29]. Not affected [exec]: 8.11.4, 9.12.3, 10.4.0, 10.5.0, 10.5.2, the 10.6 RC branch_10_6 4ae946e5a9b9 (core jar only) and main's parent commit 55b4574a20d2 (history driver; latch-free stress 0 of 1000 rounds on 10.5.2 and the parent); 10.5.2 and 9.12.3 with -ea and -da; 10.5.2; 10.5.2 and branch_10_6 (finder's compat JUnit; finder's stress 0 of 300 on branch_10_6). [read] Every release tag from 5.1.0 to 10.5.2 runs clear() and onClear() under the one lock insertions take (synchronized 5.1.0-6.0.1, ReentrantLock 6.1.0-9.10.0, ReadWriteLock write lock from 9.11.0, #13306); on 10.x a search that finds the write lock held skips caching (readLock.tryLock), so the interleaving cannot occur. 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by TLA+/TLC
- Upstream and prior reports
- none found (the prior-report record verdict new, searched 2026-10-28, read-only; recheck 2026-10-29): two scripted prior-report searches (GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; run 2 alone 598 distinct hits, none the same bug), 20 manual GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, the changed files of all 78 apache/lucene PRs updated since 2026-10-24, the upstream commits API for LRUQueryCache.java on main, branch_10x and branch_10_6 (by author date), 19 threads read in full and 9 web queries. Nearest items, not this bug: #10081 (LUCENE-9038, open since 2019), where Ben Manes read the class contract as running the hooks under one lock, so that 'implementations may rely on this exclusive read/write access', and warned that external users would need review before a concurrent redesign, which Adrien Grand acknowledged; it is a general design warning written years before the partitioned code existed and names neither clear() nor onClear(). #15558 (the introducing change; no comment or review mentions clear(), onClear or callback ordering; msokolov asked 'so no functional change expected?' apart from the hook deprecation). #16657 (sgup432 noted that clear() clears entries partition by partition, for a different design question). #14222, #16212, #13306, #16757, #16306, #16169, #15964, #7976 and #8868 (other cache changes or defects). Downstream: elastic/elasticsearch#163523 (javanna, 2026-10-27, open) lists what Elasticsearch must adapt for the Lucene 11 cache and does not mention clear()/onClear() atomicity; elastic/elasticsearch#82455 and opensearch-project/OpenSearch#22563 are other issues. Not fixed upstream: upstream main 645d4fe14a2 (2026-10-28) has no LRUQueryCache.java commit after fadd63e0eb4 (author date 2026-09-29). Recheck: the 12 apache/lucene PRs and 10 issues created since 2026-10-27 and the files of all 38 open PRs updated since 2026-10-27 touch no query-cache file; 'onClear' still returns only #10081; query-cache PRs and issues updated since 2026-10-26: none; tracking issue #16937 does not mention the query cache
- Lucene commit tested
- main
645d4fe14a20(gradle in a scratch worktree, 2026-10-31: the minimal test fails with assertions on and off, passes with the fix; a standalone runner, 2026-10-29: fails, 20 of 20 per mode). The hunt ran on maina9779ef3c193, whoseLRUQueryCache.javais identical. Releases 8.11.4, 9.12.3, 10.4.0, 10.5.0, 10.5.2 and the 10.6.0 commit4ae946e5a9b, the parent55b4574a20d2and the introducing commitd50fb36d9af3with a driver on the 10.x callbacks.
- Finding record
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:64 ('This class is thread-safe.'), :88-101 (callbacks for fine-grained statistics, 'called synchronously and under a lock'), :413-418 (onClear: 'Expert: callback when the cache is completely cleared.'), :457-463 (clear() loop, then onClear() with no lock), :1100-1131 (putIfAbsent under the partition lock), :1139-1143 (the partition's own onClear() asserts its write lock), :1191-1203 (LRUQueryCachePartition.clear())SECURITY.md (In-scope reports; Other out-of-scope reports; Reporting out-of-scope issues publicly)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:437, :457-463, :730-736, :563-569, :956-959, :1064, :1100-1131, :1139-1143, :1191-1203 (read)a Lucene clone releases/lucene/10.5.2 and releases/lucene/10.6.0: LRUQueryCache.java:415-426 (clear() under the write lock), 10.5.2 :821 and :917 (readLock.tryLock)a Lucene clone release tags 5.1.0, 6.0.1 (synchronized get/putIfAbsent/clear, no tryLock), 6.1.0 and 9.10.0 (ReentrantLock, lock.tryLock()), 9.11.0 (ReentrantReadWriteLock, readLock.tryLock()): LRUQueryCache.java (read)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84 (caching disabled by default)lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:954-1130 (testFineGrainedStats; onClear() override :1063-1068)lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:1214-1221 (requiresEviction)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:64, :88-101, :413-418, :1139-1143 (read)lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:114, :155, :954-1130 (read)lucene/CHANGES.txt:212; lucene/MIGRATE.md:514-542 (read)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:474-535 (assertConsistent), :1392 (synchronized cleanUp)
- History
a Lucene clone: git log -1 releases/lucene/10.6.0 (4ae946e5a9b)
- TestLRUQueryCacheClearOnClear.java (JUnit test):
repro/LFV-991/src/org/apache/lucene/search/TestLRUQueryCacheClearOnClear.java(87 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test (one partition).
assets/LFV-991/bug.png(477 KB)
