A documentation defect on main: the concurrency is intended, but the callback javadoc still describes the old single lock.
LRUQueryCachecaches the documents that a filter clause matches, per segment. For finer statistics its javadoc says to override protected callbacks and recommendsonCacheEntryInserted/onCacheEntryEvicted, which fire per entry;onClear()fires when the cache is cleared.- Up to 10.x the whole cache had one lock. Every insertion, eviction and clear callback ran under it, one at a time, and a search that found the lock busy simply skipped the cache.
- On main (#15558, in no release) the cache is split into partitions, 16 by default, each with its own lock. A query's partition is
query.hashCode() & 15.
- You subclass
LRUQueryCacheto keep your own statistics, as its javadoc suggests:entries++inonCacheEntryInserted,entries--inonCacheEntryEvicted, with a plainintfield, and you search from several threads. - You expect one callback at a time, as on 10.x: there one lock for the whole cache covered every insertion, eviction and clear callback, so a plain counter stayed exact. The javadoc still says they are "called synchronously and under a lock".
- On main the cache has 16 partitions, each with its own lock, picked by the query's hash. Two searches whose queries land in different partitions insert at the same time: the repro sees 2 insertion callbacks running at once.
- With 4 search threads and no latches or sleeps, a plain counter ends off in 4 of 5 rounds (489 against
getCacheSize()492, up to 3 callbacks at once); on 10.6.0 in 0 of 5. Andclear()callsonClear()holding none of the cache's locks. - Search results and the cache's own counters stay correct; only the subclass's numbers drift. The concurrency is intended; the javadoc and
MIGRATE.mdjust never say so.
Only main and branch_11x (the coming 11.0). It takes an application-installed LRUQueryCache subclass that keeps statistics in the insertion, eviction or clear callbacks with plain fields, the pattern the 10.x base class itself used; the query cache is off by default on main. Ordinary concurrent searches are enough. Results and the cache's own counters stay correct.
Mechanism, terms and numbers682 words
LRUQueryCache invites subclasses to keep "more fine-grained statistics, such as per-index or per-query-class statistics" by overriding its callbacks (onHit, onMiss, onClear and the deprecated per-query and per-DocIdSet hooks; it recommends the newer unified onCacheEntryInserted / onCacheEntryEvicted), and its class javadoc ends: "It is better to not perform heavy computations in these methods though since they are called synchronously and under a lock." Up to 10.6.0 that lock was one lock for the whole cache, so every insertion, eviction and clear callback ran alone, and Lucene's own base callbacks updated plain fields after asserting the lock was held.
On main, #15558 split the cache into partitions (16 by default) with one lock each, and a query goes to the partition picked by its hash. Insertion and eviction callbacks hold only their partition's lock, so callbacks of different partitions run at the same time, and clear() calls onClear() after releasing every partition lock, holding none of the cache's locks. That concurrency is the purpose of the change. The defect is documentation: the sentence was kept, it is false for onClear(), and neither the javadoc nor the MIGRATE.md section for #15558, which shows a subclass overriding these callbacks, says that implementations must now be thread-safe. A subclass that counts with plain fields silently loses updates; search results, the cache's own counters and its memory bound stay correct.
The class javadoc (LRUQueryCache.java:88-101 at 645d4fe14a20) lists the callbacks for "per-index or per-query-class statistics", onClear among them, and ends: "It is better to not perform heavy computations in these methods though since they are called synchronously and under a lock." The sentence dates from LUCENE-6157 (2015). In every release it described one cache-wide lock: in 10.5.2 the base onQueryCache, onQueryEviction, onDocIdSetCache, onDocIdSetEviction and onClear each start with assert writeLock.isHeldByCurrentThread(); and then do plain += / -= / = 0 (10.5.2 LRUQueryCache.java:229-280), and clear() calls onClear() inside writeLock.lock() (:415-426).
- A query goes to partition
queryHash & (numberOfPartitions - 1)(:426,:437; 16 partitions by default,:150). Each partition has its ownReentrantReadWriteLock(:956-959). putIfAbsenttakes only that partition's write lock and callsonQueryCache,onDocIdSetCacheandonCacheEntryInsertedunder it (:1100-1131); evictions do the same inremove(:1155-1189). Two queries in different partitions take different locks, so their callbacks run in parallel.clear()clears partition 0 to 15, each under its own lock, then callsonClear()holding none of the cache's locks (:457-463). SoonClear()is not "under a lock" for any number of partitions.onHit/onMissrun under the partition's shared read lock (:1064-1081); they could already overlap since 9.11.0 (#13306). Only the insertion, eviction and clear overlap is new on main.- The cache's own numbers are kept per partition, under each partition's lock (
:1037-1058,:1139-1143), and the memory bound uses them, so Lucene stays correct. Only state a subclass updates from more than one partition is exposed, and the documented per-index statistics normally are such state: they add up many queries, and different queries go to different partitions by hash (all entries of one query stay in one partition,:949-951).
- Minimal test (one helper thread held inside
onCacheEntryInserted, a second query in another partition):insertion callbacks running at the same time expected:<1> but was:<2>, with assertions on and off; 20 of 20 runs. - The finder's stress (4 threads x 4,000 searches for 64 random terms over 8 segments, occasional
clearQuery()+cleanUp(), noclear(), no latches or sleeps (each callback callsThread.onSpinWait()once after its update), a plainintcounter inonDocIdSetCache/onDocIdSetEviction): the counter ends off in 4 of 5 rounds (489, 491, 493, 499 againstgetCacheSize()492, 492, 492, 500), up to 3 callbacks at once; on 10.6.0 0 of 5 rounds, 1 at a time. - With one partition (the 6-argument constructor), insertions are serialized again, but
onClear()still overlaps an insertion:events=[clearer onClear active=1, searcher-B onCacheEntryInserted active=2].
Exposure and likelihood233 words
- Who is hit. Applications on 11.0 (main and branch_11x today) that install their own
LRUQueryCachesubclass and keep statistics inonCacheEntryInserted,onCacheEntryEvicted, the deprecated per-query / per-DocIdSet hooks oronClear()with plain (non-atomic) fields. Their numbers drift silently: no exception, no assertion. - Default vs opt-in. Opt-in: the query cache is off by default on main (
IndexSearcher.java:84,DEFAULT_QUERY_CACHE = null;CHANGES.txt:343, #14187), and only a subclass that overrides the callbacks is affected. Lucene's own production code has no such subclass. - Scale. Ordinary concurrent searching is enough for the cross-partition part: the stress uses no
clear(), no latches and no sleeps (each callback callsThread.onSpinWait()once after its update) and ends off in 4 of 5 rounds. That shows the race happens; it is not a rate for real workloads. The error per round is small (off by 1 to 3 of about 500 in the stress), and Lucene never corrects a subclass's field; later lost updates can add to it or offset it. - Not affected. Search results,
getCacheSize(),ramBytesUsed(), the base class's hit/miss counts (LongAdder,LRUQueryCache.java:124-125) and the memory bound. - No release has the insertion, eviction or clear overlap, 10.6.0 included: #15558 was not backported. Only
onHit/onMissoverlapped before, under the shared read lock since 9.11.0 (#13306), with the same missing thread-safety note; the documentation fix covers them too.
One JUnit test on the public API: a helper thread's search is held inside onCacheEntryInserted (bounded 10 s wait) while the test thread searches a query that hashes to another partition; it counts callbacks running at once. Deterministic; fails with assertions on and off. It shows the behaviour change, so it is not a regression test.
/*
* 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.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
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 TestLRUQueryCacheCallbacksOverlap extends LuceneTestCase {
public void testInsertionCallbacksOfTwoPartitionsRunAtTheSameTime() throws Exception {
int defaultPartitionMask = 15;
TermQuery first = new TermQuery(new Term("f", "t0"));
TermQuery second = null;
for (int i = 1; second == null; i++) {
TermQuery q = new TermQuery(new Term("f", "t" + i));
if ((q.hashCode() & defaultPartitionMask) != (first.hashCode() & defaultPartitionMask)) {
second = q;
}
}
CountDownLatch firstInside = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
AtomicInteger inside = new AtomicInteger();
AtomicInteger maxInside = new AtomicInteger();
LRUQueryCache cache =
new LRUQueryCache(100, 1 << 20, context -> true, 1f) {
@Override
protected void onCacheEntryInserted(Object coreKey, Query query, long ramBytesUsed) {
maxInside.accumulateAndGet(inside.incrementAndGet(), Math::max);
try {
if (query.equals(first)) {
firstInside.countDown();
releaseFirst.await(10, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
inside.decrementAndGet();
}
}
};
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new StringField("f", "t0", Field.Store.NO));
doc.add(new StringField("f", second.getTerm().text(), Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setQueryCache(cache);
searcher.setQueryCachingPolicy(ALWAYS_CACHE);
Thread t = new Thread(() -> search(searcher, first));
t.start();
assertTrue(firstInside.await(10, TimeUnit.SECONDS));
search(searcher, second);
releaseFirst.countDown();
t.join();
}
}
// 10.x (one cache-wide lock): 1 at a time; main (one lock per partition): 2
assertEquals("insertion callbacks running at the same time", 1, maxInside.get());
}
private static final QueryCachingPolicy ALWAYS_CACHE =
new QueryCachingPolicy() {
@Override
public void onUse(Query query) {}
@Override
public boolean shouldCache(Query query) {
return true;
}
};
private static void search(IndexSearcher searcher, Query query) {
try {
searcher.search(new ConstantScoreQuery(query), 1);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
./gradlew :lucene:core:test --tests TestLRUQueryCacheCallbacksOverlap -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
- 1 insertion callback at a time, as on 10.x, where one write lock covered every insertion callback (in this test the second search finds the cache busy and skips it).
- Observed
AssertionError: insertion callbacks running at the same time expected:<1> but was:<2>.
More files, environment, full output353 words
The test uses onCacheEntryInserted, which exists only on main, like the bug. It picks the second query with a loop because a TermQuery's hash goes through BytesRef.hashCode(), whose seed is set once per JVM (from tests.seed under the test framework, else from the clock, StringHelper.java:136-152), and it needs only one extra thread because concurrency is the bug; all waits are bounded, there are no sleeps or stress loops. The stress, the controls and the version-history runs are separate checks, described below.
The assertion maxInside == 1 encodes the 10.x behaviour (one cache-wide lock), not a written guarantee: the sentence never said "one cache-wide lock". A failure therefore shows that the behaviour changed while the sentence did not; no code fix that keeps the partitions makes it pass, and after the documentation fix it still fails, by design. It shows the cross-partition insertion overlap only; the onClear() clause rests on the code (LRUQueryCache.java:458-463) and on an executed one-partition control.
Controls, executed: with one partition (the 6-argument constructor) the same test passes on main after about 11 s, because the second search now waits for the partition lock until the first callback's bounded wait ends. With the held hook switched to onDocIdSetCache, which also exists on 10.x, it passes on 10.5.2 and 10.6.0 and fails on main.
The comment above the assertion (line 86) was reworded on 2026-11-01 after the runs above, with no code change and the same line numbers; the standalone runner re-ran the edited file with assertions on and off (seed DEADBEEF): same message, same seed frame, line 87.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also main a9779ef3c193 (identical LRUQueryCache.java) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
cp $REPRO/TestLRUQueryCacheCallbacksOverlap.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestLRUQueryCacheCallbacksOverlap -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fail the same way: -Ptests.asserts=false
TestLRUQueryCacheCallbacksOverlap > testInsertionCallbacksOfTwoPartitionsRunAtTheSameTime FAILED
java.lang.AssertionError: insertion callbacks running at the same time expected:<1> but was:<2>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:B046B89A4F35ED28]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.search.TestLRUQueryCacheCallbacksOverlap.testInsertionCallbacksOfTwoPartitionsRunAtTheSameTime(TestLRUQueryCacheCallbacksOverlap.java:87)
1 test completed, 1 failed
# the same with -Ptests.asserts=false, and with the documentation fix applied (by design)- The javadoc offers these callbacks for per-index statistics and says they run "under a lock";
onClear()now runs under none. - On 10.x Lucene's own callbacks did plain
+=under the asserted cache lock; #15558 edited this paragraph and kept the sentence. MIGRATE.mdshows a subclass overriding them with no thread-safety note;QueryCachingPolicystates that duty.
The full argument221 words
- The contract text (
LRUQueryCache.java:91-101): "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."onClear()is in the list and runs with no cache lock held (:458-463). - Lucene's reference implementation relied on it. 10.5.2
LRUQueryCache.java:229-280: each base insertion, eviction and clear callback asserts the single write lock and updates plain fields. - #15558 edited this paragraph and kept the sentence. Its squash commit
d50fb36d9af3added the "per cache entry, not per unique query" and "Prefer the newer unified callbacks" sentences (:94-99) and the partition sentence (:66-68). Its review never discusses callback concurrency; msokolov summed it up as "segment the cache into blocks that can be updated concurrently, so no functional change expected?". - The migration guide is silent.
MIGRATE.md:514-542(#16756) showsMyQueryCache extends LRUQueryCacheoverridingonCacheEntryInserted/onCacheEntryEvicted, with no thread-safety note. - Lucene states such duties where they apply:
QueryCachingPolicy.java:24, "Implementations of this class must be thread-safe." (precedent pointed out by the independent cross-model reviewer). - Reliance is real. In 2019 Ben Manes read the contract as running these callbacks "under the global lock", so that "implementations may rely on this exclusive read/write access" (#10081 / LUCENE-9038).
The devil's-advocate check259 words
The strongest case for "nothing to fix" (built by the devil's-advocate verifier, and from the refute review's reading): per-partition concurrency is the whole point of #15558, and main documents that "the cache is split into multiple partitions to reduce lock contention" (:66, :155-157); "under a lock" is performance advice that never said "the same lock" and is still true for every callback but onClear(); onHit / onMiss have overlapped under a shared read lock since 9.11.0 (#13306), where, at benwtrent's suggestion, the base class's own hit/miss counters became LongAdder; Lucene's own callback tests count with AtomicLong / AtomicInteger (TestLRUQueryCache.java:991-997, :2727-2736); and every hook is @lucene.experimental.
Why it fails: it defeats a code-bug claim, which is why none is made, and one cache-wide callback lock would be the wrong fix. It does not defeat the documentation defect. onClear() is called with none of the cache's locks held, so the sentence is false for one of "these methods" whatever "a lock" means (shown even with one partition). The documented use, per-index statistics, must keep state across partitions, and nothing tells the author that this state is now updated concurrently, while the 10.x base class showed the plain-field pattern. @lucene.experimental permits the change but does not make stale javadoc correct, and tests are not documentation. The verifier also noted, fairly, that the onHit / onMiss gap already existed on 10.x; the same doc fix covers it. Verdict: a documentation defect, not a code bug. This framing is the cross-model reviewer's and the verifier's.
Recommended A: state the concurrency in the javadoc and MIGRATE.md (recommended, validated)
Documentation only: replace "under a lock" with what main does (callbacks of different partitions, onHit / onMiss, and onClear() may run concurrently, so implementations that keep statistics must be thread-safe), say on onClear() that it holds no lock, and add the same note to the #15558 section of MIGRATE.md.
* perform heavy computations in these methods though since they are called synchronously, and all
* of them but {@link #onClear} are called while holding the lock of a cache partition.
*
* <p>These callbacks may be called concurrently from several threads: callbacks for entries of
* different partitions can run at the same time, {@link #onHit} and {@link #onMiss} only hold a
* shared read lock, and {@link #onClear} is called without holding any lock. Implementations that
* keep statistics in these callbacks must therefore be thread-safe, for instance by using {@link
* LongAdder}.
// onClear(): "called by clear() after all partitions have been cleared, without holding any lock"
// MIGRATE.md, #15558 section: the same note for subclassesAll options and trade-offs4 options · 609 words
One option was built and checked (A); B depends on a sibling fix; C and D are listed because they come up and should be avoided.
The finder's wording, narrowed by the reviewers: the class javadoc's last sentence of the callback paragraph becomes a description of main's locking plus a thread-safety duty; onClear() gets one line; MIGRATE.md's #15558 section gets one paragraph.
* perform heavy computations in these methods though since they are called synchronously, and all
* of them but {@link #onClear} are called while holding the lock of a cache partition.
*
* <p>These callbacks may be called concurrently from several threads: callbacks for entries of
* different partitions can run at the same time, {@link #onHit} and {@link #onMiss} only hold a
* shared read lock, and {@link #onClear} is called without holding any lock. Implementations that
* keep statistics in these callbacks must therefore be thread-safe, for instance by using {@link
* LongAdder}.
// onClear(): "called by clear() after all partitions have been cleared, without holding any lock"
// MIGRATE.md, #15558 section: the same note for subclassesTrade-offs
- Documents the intended behaviour instead of changing it: no runtime cost, no behaviour change, the partitioning keeps its purpose.
- It does not help a subclass that already relies on the old lock; such code has to switch to atomics (
LongAdder,AtomicLong). The note should not suggestsynchronized (this)on the cache:cleanUp()issynchronizedon the cache instance (LRUQueryCache.java:1392), the sibling deadlock LFV-1001. - If LFV-991's fix lands (
clear()callsonClear()under every partition lock), theonClear()clause must be dropped again. - Wording caveats: "without holding any lock" means none of the cache's locks (the caller of
clear()may hold its own), and "after all partitions have been cleared" does not mean the cache is still empty whenonClear()runs: other threads can refill it first (LFV-991).
What the checks validated
- Stock
645d4fe14a20(gradle, scratch worktree): the minimal test fails with assertions on (seed DEADBEEF) and off. - With A:
checkGoogleJavaFormat,ecjLintMainandrenderJavadocfor core pass;TestLRUQueryCachepasses (40 tests, 3 skipped, 0 failures) with assertions on (seed DEADBEEF) and off (seed C0FFEE). - With A the minimal test still fails, with assertions on and off, as expected: A changes no code, and the test encodes the 10.x behaviour.
- The diff applies cleanly to a pristine
645d4fe14a20.
Make clear() take every partition's write lock and call onClear() under them (LFV-991's validated fix), then document only the cross-partition concurrency.
Trade-offs
Makes "under a lock" true again for onClear() and fixes LFV-991's lost statistics, but must land together with LFV-1001's fix (cleanUp() on a private lock) to avoid a lock-order deadlock. The cross-partition overlap stays and still needs A's note: with that fix the finder's cross-partition test still fails.
What the checks validated
Not run (in this doc; LFV-991's doc reports its own validation, and the finder's run with that fix shows that only the onClear test passes).
Wrap every callback in a single extra lock, so the old sentence becomes true.
Trade-offs
Brings back the cache-wide contention #15558 removed on every insertion and eviction; the reviewers and the devil's-advocate verifier called it the wrong fix. Not run.
A one-line javadoc telling implementations to use synchronized methods.
Trade-offs
Deadlocks: cleanUp() holds the cache's monitor while it waits for partition locks, and a search holds a partition lock while its callback waits for the monitor (LFV-1001). Recommend atomics instead.
Downstream: Elasticsearch's query-cache subclass says in a code comment that its callbacks need no lock "since it is done in LRUQueryCache", above += updates of its statistics, also on its Lucene 11 branch; whether those statistics drift there was not tested.
A probe ran a plain-counter stress on the stock main jar and saw lost updates and up to 3 callbacks at once, none on 10.6.0. The finder turned it into deterministic public-API JUnits and a TLA+ model of the partitioned cache; reviewers then narrowed the claim to a documentation defect.
The method in full181 words
Method: non-formal first signal, formal confirmation. A probe for the partitioned query cache ran a latch-free plain-counter stress on the stock jars: on main up to 3 insertion and eviction callbacks ran at once and the counter lost updates in 11 of 15 rounds of a three-fold re-run; on the 10.6 release commit 1 at a time and 0 of 5.
The finder (a hunt unit) turned it into deterministic public-API JUnits (one callback held open by a latch with a bounded wait: does a second callback start?) for two insertions and for onClear() plus an insertion, and wrote a TLA+ model of the partitioned cache. TLC violated the invariant CallbacksSerialized on main in 4 steps (two inserts in two partitions), found no violation for the 10.x single-lock reference (853,774 distinct states, exhaustive), and still violated it with the sibling clear() fix.
The independent cross-model reviewer (OpenAI GPT-5.6, intended lens, read-only) and the devil's-advocate verifier then narrowed the claim: the concurrency is intended, the documentation is not. The minimal repro reduces the evidence to one test of the cross-partition insertion overlap.
No prior report: GitHub issues and PRs (with the JIRA archive), commits, mailing lists, downstream projects and the web, searched 2026-10-29 and again 2026-11-01. Closest: #10081 (LUCENE-9038, 2019), where Ben Manes warned that implementations may rely on the cache's exclusive lock.
Searches3 searches
Nobody has reported that, on main, insertion and eviction callbacks of different partitions run at the same time, that clear() calls onClear() with no lock, or that the javadoc sentence and the MIGRATE.md #15558 section are out of date. Upstream main f815e679bf6c and branch_11x (2026-11-01) still have the same text.
- #10081 (LUCENE-9038, open since 2019): Ben Manes read the contract as running the callbacks "under the global lock", so that "implementations may rely on this exclusive read/write access", and warned that external users would need review before a concurrent redesign; Adrien Grand answered. A design discussion six years before the partitioned code; it reports no defect.
- #15558 (the introducing PR): no comment or review mentions callback concurrency or subclass thread safety.
- #13306 (9.11): benwtrent noted that hit/miss counting was no longer accurate under the read lock; fixed for the base class with
LongAdder. - #16756 wrote the
MIGRATE.mdsection; #14222, #7219 (LUCENE-6157), #16657, #16212, #16306: related, different topics. - Downstream: elastic/elasticsearch#163523 (open) notes "separate locks per partition" without saying that callbacks overlap.
| 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-29 | the scripted prior-report search (255 distinct hits), 27 manual GitHub searches over apache/lucene, elastic/elasticsearch and apache/solr, the changed files of all 414 open apache/lucene PRs, the commits API for LRUQueryCache.java and MIGRATE.md, 28 threads read in full, 7 web queries | no report; nearest items #10081, #15558, #13306 |
| apache/lucene issues and PRs | 2026-10-29 (registration check) | 9 GitHub searches incl. items created since 2026-10-27; the files of the 48 PRs updated since then | nothing touches LRUQueryCache.java or the query-cache section of MIGRATE.md |
| GitHub, commits, lists, JIRA archive, web | 2026-11-01 | the scripted prior-report search again (51 queries, 524 hits); manual searches such as "synchronously and under a lock" (0 hits); the files of all 81 apache/lucene issues and PRs created or updated since 2026-10-28; new comments on 8 key threads | no report; three open PRs touch LRUQueryCache.java, none of them the javadoc or clear(): #16952 (collect(DocIdStream), the only one among the items since 2026-10-28), #16757 (tryPopulateCache) and draft #16847 (long doc ids) |
Introduced on main by #15558 (d50fb36d9af3, merged 2026-04-14) and not backported: main and branch_11x only. The sentence dates from LUCENE-6157 (2015) and was true in every release. Executed: 9.10.0, 9.12.3, 10.5.2, 10.6.0 and the parent pass; d50fb36d9af3 and 645d4fe14a20 fail.
Versions tested7 rows
Introduced by #15558 "Refactor QueryCache to improve performance" (Sagar Upadhyaya, merged by Michael Froh; design issue #14222), squash commit d50fb36d9af3, 2026-04-14. It edited the callback paragraph and kept the sentence. The sentence comes from LUCENE-6157 (d7e0fd28dd9, Adrien Grand, 2015-01-05), first released in 5.0.0 through the 5.0 branch commit 92331b62ccdb (same author, same day). All 99 release tags that have the class contain it and none has partitions (5.0.0 as LRUFilterCache, the 98 from 5.1.0 on as LRUQueryCache). The MIGRATE.md section was added later by #16756 (120b3bc18dac, 2026-10-07), without a concurrency note.
Main only: on #15558 Michael Froh said they were "erring on the side of caution" about a backport, and Sagar Upadhyaya later called it "a conscious choice"; no release tag contains d50fb36d9af3. The runs below use a test program on onDocIdSetCache, onDocIdSetEviction and onClear, which exist on 9.x, 10.x and main (3 iterations each, assertions on and off; 9.10.0 with assertions on only).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | 2 callbacks at once; the minimal test fails with assertions on and off (gradle and the standalone runner) |
main a9779ef3c193 | affected | finder, the independent reproduction, the intended-behaviour check and judge JUnits fail; plain-counter stress loses updates in 4 of 5 rounds |
main d50fb36d9af3 (#15558) | introducing commit | 2 at once: two onDocIdSetCache, and onClear + onDocIdSetCache |
main 55b4574a20d2 (its parent) | bisect | one callback at a time |
10.6.0 (release commit 4ae946e5a9b) | not affected | one callback at a time; stress 0 of 5 rounds |
| 10.5.2 | not affected | one callback at a time; the 10.x-hook JUnits of the finder, the independent reproduction and the intended-behaviour check pass |
| 9.10.0, 9.12.3 | not affected | one callback at a time (9.10.0: assertions on only) |
The finder, an independent reproducer, a devil's-advocate verifier, an adjudication with a new seed, a credibility audit, a current-main reproducer, a tie-break and this doc's gradle runs all reproduced it. Read-only reviews by OpenAI GPT-5.6 agreed on the root cause; the intended-lens one framed it as documentation.
Checks and verdicts387 words
Every check ran on main a9779ef3c193 or 645d4fe14a20 (identical LRUQueryCache.java) with OpenJDK 25.0.3 on Linux aarch64. The cross-model reviews could only read, not execute; a later executing refute review returned no verdict.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit: JUnits, stress, TLA+/TLC) | candidate | JUnits fail 2 of 2 with assertions on and off (40 of 40 over 20 iterations); the 10.x-hook JUnit fails on main and passes on 10.5.2 and 10.6.0; stress 4 of 5 rounds; TLC violation on main, 10.x reference clean. |
| Independent reproducer | real (documentation) | Own JUnit fails on main (40 of 40), passes on 10.5.2 and 9.12.3; confirmed with an executed one-partition control that onClear() still overlaps an insertion (the independent reproduction's notes credit the intended-lens reviewer with the observation). |
| Intended-lens review (OpenAI GPT-5.6, read-only) | real as a documentation defect, medium | Concurrency intended, documentation not; pointed out the QueryCachingPolicy precedent and that onClear() is called under no cache lock. |
| Refute review (OpenAI GPT-5.6, read-only) | not real, low (procedural) | Could not execute; conceded that onClear() contradicts "under a lock" and that a documentation fix is plausible. |
| Devil's advocate | real (documentation), medium | Argued intended / documented / accepted / misuse point by point; kept only the documentation defect; re-ran main (fail) and 10.5.2 (pass). |
| Adjudication | confirmed, medium | Re-ran the independent reproduction's JUnit with a new seed: fail 2 of 2 with assertions on and off. |
| Credibility audit | confirmed | Fresh seed, fail 2 of 2 with assertions on and off; impact minor; a separate defect from LFV-991 and LFV-945. |
| Current-main reproducer and tie-break (645d4fe14a20) | real | Fails on main with new seeds (20 of 20), passes on 10.5.2 and 10.6.0; executed the onClear() claim with the one-partition control. |
| This doc (gradle, scratch worktree) | reproduces; doc fix validated | Stock fails with assertions on and off; the doc fix passes format, lint, javadoc and TestLRUQueryCache, and the repro still fails, as expected. |
| Fact checks and diagram check (2026-11-01) | real, minor precision errors fixed | An independent fact check re-ran the gradle repro, the controls and fix A; a second fact check by OpenAI GPT-5.6 read the sources; a diagram check read every frame. All agreed it is a documentation defect; their wording errors are fixed, and one claimed error was rejected. |
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 514 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,491 words
- Registry title
- On main, LRUQueryCache's class javadoc still says its statistics callbacks are 'called synchronously and under a lock', but since #15558 the callbacks of different partitions run at the same time and onClear() runs under no lock, and neither the javadoc nor MIGRATE.md tells subclasses that their statistics must now be thread-safe (documentation defect, main only)
- Area
- search / query cache (LRUQueryCache callback javadoc and MIGRATE.md)
- Priority
- Low, rank 1265: This main-only stale javadoc and MIGRATE.md gap in the core LRUQueryCache misleads only through its false 'under a lock' sentence, which lets a subclass's own plain-field statistics lose updates while results, cache size and the memory bound stay correct.
- Impact class
- minor
- Impact note
- Documentation only, on unreleased main (11.0): an application that subclasses LRUQueryCache, keeps statistics in plain fields in the insertion, eviction or clear callbacks and relies on the javadoc's 'under a lock' (true for these callbacks on every 10.x and earlier release) loses updates under concurrent searches (4 of 5 stress rounds on main, 0 of 5 on the 10.6 RC); search results, 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 in its callbacks, as the class javadoc suggests, and trust its sentence that the callbacks 'are called synchronously and under a lock', which on 10.x meant one cache-wide write lock around every insertion, eviction and clear callback, so plain fields were safe. On main, #15558 gave each partition (16 by default) its own lock, so insertion and eviction callbacks of different partitions run at the same time (2 at once in a deterministic JUnit, up to 3 in a stress where a plain counter loses updates), and clear() calls onClear() with no lock held. The partitioning itself is intended; the defect, as the independent cross-model reviewer and a verifier framed it, is that the sentence was kept unchanged (it is now false for onClear()) and that neither the javadoc nor MIGRATE.md says callback implementations must now be thread-safe.
- Introduced
- d50fb36d9af3 (GITHUB#15558 'Refactor QueryCache to improve performance', by Sagar Upadhyaya (sgup432), merged by Michael Froh (msfroh); design issue #14222; the per-partition locks, the unlocked onClear() and the unchanged sentence are already in the PR's first partitioning commit ef7cf0ff101e, authored 2026-01-08, and d81d3815c4e8 (2026-04-13) rewrote the callback paragraph and kept the sentence) [exec: the history test program passes at the parent 55b4574a20d2 and fails at d50fb36d9af3, both plain javac builds of lucene-core, with -ea and -da]. The sentence itself dates from LUCENE-6157 (d7e0fd28dd9, Adrien Grand, 2015-01-05, then LRUFilterCache) and was only re-wrapped by 2d6ad2fee6d (LUCENE-9570, 2020) [read: git log -S, blame]. The MIGRATE.md #15558 section came later, from #16756 (120b3bc18dac, javanna, 2026-10-07), without a concurrency note [read] (2026-04-14 (squash commit d50fb36d9af3, author and commit date; PR opened 2026-01-09); MIGRATE.md section 2026-10-07 (#16756))
- First affected version
- unreleased (main / 11.0.0-SNAPSHOT since 2026-04-14, and branch_11x; #15558 was deliberately not backported, so every release with the class, 5.0.0 (LRUFilterCache) to 10.5.2, and 10.6.0 (released 2026-10-29; branch_10_6 4ae946e5a9b) are unaffected [exec on 9.10.0, 9.12.3, 10.5.2 and the 10.6 RC; read for all 99 release tags that have the class (98 as LRUQueryCache, 5.0.0 as LRUFilterCache): sentence present, no partitions])
- Affected versions
- main from d50fb36d9af3 (2026-04-14) to a9779ef3c193 [exec: the finder's JUnit fails 2 of 2 with -ea and -da (seed DEADBEEF; 40 of 40 over 20 iterations), the 10.x-compatible JUnit fails 2 of 2 with -ea and -da, the independent reproduction's own JUnit fails 2 of 2 with -ea and -da (40 of 40 over 20 iterations), the adjudication's re-run (seed 7A1D6E55) fails 2 of 2 with -ea and -da, the re-run (seed 5EED4002, -da) fails 2 of 2; the history test program fails at d50fb36d9af3 and a9779ef3c193 with -ea and -da]; branch_11x [read: contains d50fb36d9af3 and has the same newest LRUQueryCache.java and MIGRATE.md commits as main; not executed separately]; upstream main 645d4fe14a20 (2026-10-28) [read: no commit to LRUQueryCache.java after fadd63e0eb4e (author date 2026-09-29), none to MIGRATE.md after 8476457b8752 (2026-10-15)]. Not affected [exec]: 9.10.0, 9.12.3, 10.5.2, the 10.6 RC branch_10_6 4ae946e5a9b9 (core jar only) and main's parent commit 55b4574a20d2 (history test program: one callback at a time); the 10.x-compatible JUnit passes on 10.5.2 and branch_10_6 (finder), the independent reproduction's JUnit on 10.5.2 (-ea and -da) and 9.12.3 (-ea), the intended-behaviour check's run on 10.5.2. [read] Every release tag with the class keeps all insertion, eviction and clear callbacks under one lock. Older and separate: onHit/onMiss run under the shared read lock since 9.11.0 (#13306), so those two could already overlap on 9.11-10.x; only the insertion/eviction/clear overlap is new on main. 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 adjudication that ruled it real, 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 (searched 2026-10-29; recheck 2026-10-29): a scripted prior-report search over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (255 distinct hits, none the same bug), 27 manual GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, the changed files of all 414 open apache/lucene PRs, the commits API for LRUQueryCache.java and MIGRATE.md by author date, 28 threads read in full, 7 web queries; the recheck repeated 9 GitHub searches (incl. items created since 2026-10-27) and checked the files of the 48 apache/lucene PRs updated since 2026-10-27 (none touches LRUQueryCache.java; the MIGRATE.md hunks of #16857 and #16816 do not touch the query-cache section). Nearest items, not this bug: #10081 (LUCENE-9038, open since 2019), where Ben Manes read the contract as running the callbacks under the global 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 answered; a design discussion written six years before the partitioned code, reporting no defect. #15558 (the introducing change; no comment or review mentions callback concurrency or subclass thread safety; msokolov summed it up as 'segment the cache into blocks that can be updated concurrently, so no functional change expected?'). #13306 (benwtrent noted in review that read-lock concurrency broke the accuracy of the base class's hitCount/missCount, fixed with LongAdder; the javadoc sentence and subclasses were not discussed). #7219 (LUCENE-6157, origin of the callbacks and the sentence), #14222, #16657, #16756 (added the MIGRATE.md #15558 section), #16212, #16757. Downstream context only: elastic/elasticsearch#163523 (javanna, opened 2026-10-27, open) says the new cache has 'separate locks per partition' and lists hook work, without saying the callbacks now overlap. Not fixed upstream: upstream main 645d4fe14a20 (2026-10-28) has the same javadoc and MIGRATE.md text. Tracking issue #16937 does not list it
- Lucene commit tested
- main
645d4fe14a20(gradle in a scratch worktree, 2026-11-01: the minimal test fails with assertions on and off, before and after the documentation fix; the doc fix passes the format, lint and javadoc checks andTestLRUQueryCache; a standalone runner: fails with assertions on and off, 20 of 20 with assertions off on 2026-10-29, again with new seeds on 2026-11-01). The hunt ran on maina9779ef3c193, whoseLRUQueryCache.javaandMIGRATE.mdare identical. With a test program on the hooks that exist on every line: 9.10.0, 9.12.3, 10.5.2, 10.6.0 and the parent55b4574a20d2pass; the introducing commitd50fb36d9af3fails.
- Finding record
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:64 ('This class is thread-safe.'), :66-68 (partitions to reduce lock contention), :88-101 (callbacks for fine-grained statistics incl. onClear; 'called synchronously and under a lock'), :155-157 (6-argument constructor, numberOfPartitions), :457-463 (clear(): partition loop, then onClear() with no lock), :937-959 (one ReentrantReadWriteLock per partition), :1100-1131 and :1155-1189 (insertion and eviction callbacks under the partition's write lock); QueryCachingPolicy.java:24 ('Implementations of this class must be thread-safe.'); lucene/MIGRATE.md:514-542 (the #15558 section: no concurrency note)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:64-101, :150, :426, :437, :457-463, :956-959, :1037-1058, :1064-1081, :1100-1131, :1139-1143, :1155-1189 @645d4fe14a20releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:80-82, :229-280, :415-426, :820-823 (git show in a Lucene clone)
- Who is affected
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:69-75, :84 @645d4fe14a20; lucene/CHANGES.txt:212, :343 @645d4fe14a20
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:66-68, :88-101, :155-157, :458-463 @645d4fe14a20lucene/MIGRATE.md:514-542 @645d4fe14a20; lucene/core/src/java/org/apache/lucene/search/QueryCachingPolicy.java:24 @645d4fe14a20lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:991-997, :2727-2736 @645d4fe14a20 (atomic counters in callback tests)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:1392 @645d4fe14a20 (synchronized cleanUp())
- TestLRUQueryCacheCallbacksOverlap.java (one JUnit test):
repro/LFV-1000/src/org/apache/lucene/search/TestLRUQueryCacheCallbacksOverlap.java(108 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-1000/bug.png(514 KB)
