Two locks taken in opposite order. cleanUp() holds the cache object's monitor and waits for a partition lock; a search holds that partition lock and, inside your synchronized callback, waits for the monitor.
- Partitions. On main,
LRUQueryCacheis split into partitions (16 by default), each with its own read/write lock. A search that looks up a cached entry holds its partition's read lock; one that inserts an entry holds the write lock. - Statistics callbacks. The cache calls
onHit,onMiss,onCacheEntryInsertedandonCacheEntryEvictedwhile it holds that partition lock. Subclasses override them to keep their own statistics, as the class javadoc suggests. - cleanUp(). On main, stale entries (of closed segments, or of queries passed to
clearQuery()) are not removed right away.cleanUp()removes them, run by a background thread you configure or by your own periodic calls.
synchronized callback; cleanUp() holds the cache monitor and waits for the partition lock. Select the still to open it at full size. Generated with AI, checked against the code.- You subclass
LRUQueryCacheto count cache inserts and make the counter thread-safe the plain Java way:synchronized (this) { inserted++; }inonCacheEntryInserted. On main, stale entries are removed only bycleanUp(), which a background thread or your own code runs. - A search caches a result: Lucene takes the partition's write lock and calls your
onCacheEntryInserted. At that momentcleanUp()runs, because 1 query was cleared withclearQuery().cleanUp()issynchronized, so it holds the cache object's monitor, and it now waits for the partition lock. - You expect both to finish within milliseconds, as the same subclass does on 10.x, where Lucene never locks the cache object.
- Instead your callback waits for the monitor that
cleanUp()holds, andcleanUp()waits for the lock the search holds. In the repro (1 document, 1 partition, 1 search), after 10 s the search is BLOCKED andcleanUp()is WAITING, and the JVM reports a Java-level deadlock. - It also happens on its own under load: with 3 search threads, background cleanup every 1 s and NRT refresh, 3 of 3 runs hung within 2 s, with the search threads stuck behind the cleanup thread. With
cleanUp()on a private lock: 12,978,280 searches in 30 s, no hang.
Applications on main / the coming 11.0 that subclass LRUQueryCache and guard callback statistics with synchronized on the cache, while cleanup runs (background or periodic cleanUp(), which main requires). Atomics or a private lock are safe; the query cache is off by default. With the idiom in place, ordinary search load hangs within about 2 s.
Mechanism, terms and numbers685 words
On main, LRUQueryCache.cleanUp() is public synchronized: it holds the cache object's monitor while it takes each partition's read lock in keys() and the write lock in the partition remove(). Searches call the statistics callbacks while they hold a partition lock: onCacheEntryInserted under the write lock, onHit and onMiss under the read lock. A subclass that makes its counters thread-safe with synchronized methods or synchronized (this), the plain Java idiom, therefore takes the two locks in the opposite order to cleanUp(), and the two threads deadlock as soon as cleanUp() has stale entries to remove while a search is in such a callback. The hang is permanent and uninterruptible: every later cleanUp(), every search whose callback needs the monitor, and every other search that looks up or inserts in that partition queue behind it. The class says "This class is thread-safe.", invites callback overrides for statistics, and calls cleanUp() "safe to call concurrently"; on main cleanUp() must run, from a background scheduler or the caller. On 10.x the same subclass is safe, because LRUQueryCache there locks only a private lock. The modifier came with #15558 (2026-04-14), so only main and branch_11x are affected. Moving cleanUp() to a private lock object removes the cycle without changing what the cache stores or evicts; with it the repro passes, and so do 2,339 tests of the org.apache.lucene.search package.
cleanUp()takes the cache monitor.public synchronized void cleanUp()(:1392). It copies the stale-key sets and returns early if both are empty (:1393-1399). Otherwise, still holding the monitor, it iterateskeys()(:1404), which visits every partition (:214-222) and takes each partition's read lock (:1025) just long enough to copy its keys (released at:1029), and then calls the partitionremove()for each stale entry (:1410), which takes the write lock (:1156) and firesonCacheEntryEvictedunder it (:1183). There is no read-to-write upgrade: the monitor is the only lockcleanUp()holds across these steps.- Stale entries are ordinary.
clearQuery()marks a query (:451-455,:1250-1256); a closed segment marks its key throughclearCoreCacheKey()(:443-448). On main they are not removed inline:cleanUp()must run, from the scheduler configured withCacheCleanUpParameters(:197-204,scheduleWithFixedDelay(this::cleanUp, ...)) or from the caller (:103-113). - Searches call the callbacks under partition locks.
putIfAbsent()takes the write lock (:1100) and callsonCacheEntryInserted(:1126);get()takes the read lock (:1064) and callsonMissoronHit(:1071-1077). - The cycle. The search holds a partition lock and waits for the cache monitor in the subclass callback;
cleanUp()holds the monitor and waits for that partition lock. In the reprocleanUp()parks on the read lock inkeys(), blocked by the search's write lock. WithsynchronizedonHit,cleanUp()waits for the write lock inremove()while searches hold read locks; the JVM's deadlock detector does not report that variant, because a read lock has no owner. - The monitor is used nowhere else. No other method locks the cache object (
:445lockskeysToClean,:960is aCollections.synchronizedMapwith its own mutex). The second lock exists only because of the modifier, whose job is to serialize concurrentcleanUp()calls. Javadoc does not rendersynchronized, so the API docs showpublic void cleanUp().
Monitor entry and lock() ignore interrupts and timeouts, so the pair never returns. Every later cleanUp() and every search whose callback needs the monitor block behind them, and so do all other searches that look up or insert in that partition and every clearQuery() call (it takes each partition's write lock, :1250-1256), because the stuck search holds that partition's write lock. With synchronized onHit/onMiss, that is every search that consults the cache. Searches that skip the cache (segment not cacheable, not eligible, or without a core cache helper: :816-831) are not blocked. Lucene logs nothing about it; results and the index are not damaged.
Exposure and likelihood303 words
- Who. Applications on main / 11.0 with an
LRUQueryCachesubclass whose callbacks synchronize on the cache object (synchronizedmethods orsynchronized (this)), that runcleanUp()while searching, through the background scheduler or their own periodic calls. - Opt-in. The query cache is off by default on main (
IndexSearcher.java:84, GITHUB#14187), and the stock callbacks useLongAdder. Subclasses with atomics or a private lock are not affected (executed controls). - Why the idiom is likely. On main, callbacks of different partitions run at the same time (LFV-1000) and
onHit/onMissrun under a shared read lock, so subclasses now need their own synchronization, andsynchronizedis the default Java choice. The Elasticsearch and OpenSearch subclasses say today "It's ok to not protect these callbacks by a lock since it is done in LRUQueryCache"; neither synchronizes on the cache, so neither is known to hit this. - Scale: none needed. The finder's stress (3 search threads, background cleanup every 1, 100 or 1000 ms) hung after about 1 s in 5 of 5 runs, an independent stress in 7 of 7. A workload with nothing waiting inside a callback (16 partitions, cleanup every 1 s, NRT refresh closing merged segments) hung within 2 s in 3 of 3 JVMs, and 3 of 3 with
clearQuery()on 1 in 20 searches. Controls, 30 s each: with the fix 12,978,280 searches, with a private-lock subclass on stock main 10,485,017, no hang. - Effect. A permanent hang of the cleanup thread and of searches that consult the cache; no wrong results, no index damage.
- How likely. Rare: none of the four public downstream subclasses surveyed (Elasticsearch, OpenSearch, nrtsearch, mongot) uses the idiom today, and the code is unreleased.
- Workaround. Keep subclass statistics in atomics or
LongAdder, or under a private lock object, never underthis.
One JUnit test, one method, public and protected API only, 1 partition so query routing does not matter. Concurrency is the bug, so two threads: the search, and the cleanUp() it starts from inside its insertion callback. A spin until cleanUp() is parked is the only ordering control; the wait is bounded at 10 s.
/*
* 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 static org.junit.Assert.assertFalse;
import java.io.IOException;
import java.io.UncheckedIOException;
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.junit.Test;
/** Plain JUnit: deadlocked threads cannot be stopped, which LuceneTestCase reports as a leak. */
public class TestLRUQueryCacheCleanUpDeadlock {
/** Keeps its statistics under the cache's monitor; cleanUp() runs during an insertion. */
static class StatsCache extends LRUQueryCache {
long inserted;
volatile Thread cleaner;
StatsCache() {
super(100, 1 << 20, context -> true, 1f, 1, null);
}
@Override
protected void onCacheEntryInserted(Object readerCoreKey, Query query, long ramBytesUsed) {
cleaner = new Thread(this::cleanUp);
cleaner.setDaemon(true);
cleaner.start();
while (cleaner.isAlive() && cleaner.getState() != Thread.State.WAITING) {
Thread.onSpinWait(); // until cleanUp() waits for the partition lock this search holds
}
synchronized (this) {
inserted++;
}
}
}
@Test
public void testCleanUpDuringInsertionCallback() throws Exception {
Directory dir = new ByteBuffersDirectory();
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new StringField("f", "x", Field.Store.NO));
w.addDocument(doc);
}
StatsCache cache = new StatsCache();
cache.clearQuery(new TermQuery(new Term("f", "y"))); // gives cleanUp() something to do
IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dir));
searcher.setQueryCache(cache);
searcher.setQueryCachingPolicy(
new QueryCachingPolicy() {
@Override
public void onUse(Query query) {}
@Override
public boolean shouldCache(Query query) {
return true;
}
});
Query query = new ConstantScoreQuery(new TermQuery(new Term("f", "x")));
Thread search =
new Thread(
() -> {
try {
searcher.search(query, 1);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
search.setDaemon(true);
search.start();
search.join(10_000);
// expected: the search finishes; actual: it blocks on the monitor held by cleanUp(), forever
assertFalse(
"expected the search to finish; after 10 s search="
+ search.getState()
+ ", cleanUp()="
+ cache.cleaner.getState(),
search.isAlive());
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestLRUQueryCacheCleanUpDeadlock -Ptests.seed=DEADBEEF
- Expected
- The search finishes and
cleanUp()then removes the stale entry, so the test passes, as it does withcleanUp()on a private lock. - Observed
- After 10 s the search is BLOCKED and
cleanUp()WAITING:AssertionError: expected the search to finish; after 10 s search=BLOCKED, cleanUp()=WAITING(line 96), assertions on and off.
More files, environment, full output453 words
clearQuery(f:y) leaves one stale query, so cleanUp() takes partition locks instead of returning early. The spin in the callback only orders the two threads the way a background cleanup thread can meet them; nothing waits inside Lucene code, and runs with no wait inside any callback hang the same way. The test is plain JUnit 4, not LuceneTestCase, because deadlocked threads cannot be stopped and the thread-leak checker would add suite errors on stock; the threads are daemons. The test itself uses no randomness; the seed only picks gradle's randomized JVM arguments (seeds DEADBEEF and 5EED1234 were run).
Lint: :lucene:core:ecjLintTest rejects the unused lambda parameter context (line 43); write _ -> true when adding the test to Lucene's tree. With that one-token change the test still fails on stock and passes with the fix, and the test lint passes.
Regression test: this same test (with _ -> true); with the fix it passes in 0.58 s to 7.3 s through gradle, depending on host load, while on stock it waits the full 10 s bound and fails (a passing run returns at once, so a larger bound costs nothing). As a LuceneTestCase run as its own suite it passes with the fix with no thread-leak report, and on stock adds "2 threads leaked from SUITE scope", so it could also become a method of TestLRUQueryCache (not run there).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. 10.6.0 and 10.5.2 release jars: the API does not exist, not affected |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestLRUQueryCacheCleanUpDeadlock.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestLRUQueryCacheCleanUpDeadlock -Ptests.seed=DEADBEEF
# the same result with -Ptests.asserts=false
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestLRUQueryCacheCleanUpDeadlock > testCleanUpDuringInsertionCallback FAILED
java.lang.AssertionError: expected the search to finish; after 10 s search=BLOCKED, cleanUp()=WAITING
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.junit.Assert.assertFalse(Assert.java:65)
at org.apache.lucene.search.TestLRUQueryCacheCleanUpDeadlock.testCleanUpDuringInsertionCallback(TestLRUQueryCacheCleanUpDeadlock.java:96)
# thread dump (jcmd Thread.print -l) 5 s into an unchanged standalone run, -ea
# (Thread-0 is the search, Thread-1 runs cleanUp(); frames below the test left out, "java.base@25.0.3/" module prefixes removed)
Found one Java-level deadlock:
=============================
"Thread-0":
waiting to lock monitor 0x0000ffff0c00ae60 (object 0x00000000f78f2ae0, a org.apache.lucene.search.TestLRUQueryCacheCleanUpDeadlock$StatsCache),
which is held by "Thread-1"
"Thread-1":
waiting for ownable synchronizer 0x00000000f7705898, (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync),
which is held by "Thread-0"
"Thread-0" BLOCKED (on object monitor)
at org.apache.lucene.search.TestLRUQueryCacheCleanUpDeadlock$StatsCache.onCacheEntryInserted(TestLRUQueryCacheCleanUpDeadlock.java:55)
- waiting to lock <0x00000000f78f2ae0> (a org.apache.lucene.search.TestLRUQueryCacheCleanUpDeadlock$StatsCache)
at org.apache.lucene.search.LRUQueryCache$LRUQueryCachePartition.putIfAbsent(LRUQueryCache.java:1126)
at org.apache.lucene.search.LRUQueryCache.putIfAbsent(LRUQueryCache.java:438)
at org.apache.lucene.search.LRUQueryCache.tryPopulateCache(LRUQueryCache.java:610)
...
Locked ownable synchronizers:
- <0x00000000f7705898> (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)
"Thread-1" WAITING (parking)
- parking to wait for <0x00000000f7705898> (a java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)
at java.util.concurrent.locks.ReentrantReadWriteLock$ReadLock.lock(ReentrantReadWriteLock.java:744)
at org.apache.lucene.search.LRUQueryCache$LRUQueryCachePartition.keys(LRUQueryCache.java:1025)
at org.apache.lucene.search.LRUQueryCache.keys(LRUQueryCache.java:217)
at org.apache.lucene.search.LRUQueryCache.cleanUp(LRUQueryCache.java:1404)
- locked <0x00000000f78f2ae0> (a org.apache.lucene.search.TestLRUQueryCacheCleanUpDeadlock$StatsCache)
# with the fix: 1 test, 0 failures (assertions on and off)- The class is "thread-safe", invites callback overrides for statistics, and calls
cleanUp()"safe to call concurrently"; nothing names the cache monitor or a lock order. - No release takes the cache monitor together with another lock, so the same subclass works on 10.x; #15558 added the modifier without discussing it.
- A private lock keeps
cleanUp()serialized and removes the cycle.
The full argument214 words
- The contract. "This class is thread-safe." (
LRUQueryCache.java:64). The class javadoc invites overriding the callbacks "to have more fine-grained statistics" (:88-101).cleanUp(): "This method is safe to call concurrently." (:1384), and on main it must run (:103-113). - Lucene's own migration example for #15558 is a
class MyQueryCache extends LRUQueryCachethat overridesonCacheEntryInsertedandonCacheEntryEvicted, with no locking caveat (lucene/MIGRATE.md:514-542). - It worked before. Since LUCENE-7237 (2016, Adrien Grand) the cache has locked only a private lock, a
ReentrantReadWriteLocksince #13306 (10.5.2 and 10.6.0LRUQueryCache.java:100-101). In 2015 (LUCENE-6303, releases 5.1 to 6.0) the methods that read or change the cache's state (get,putIfAbsent, eviction, theclear*methods) weresynchronizedon the cache, its only lock and re-entrant, so no cycle was possible. No release hascleanUp(). - Nobody decided it. The #15558 PR body, its 22 issue comments and 5 review comments never discuss
cleanUp()'s monitor, a lock order or locking in callbacks; #16212 changedcleanUp()'s locking and kept the modifier without comment. - Standard Java guidance. For classes designed for inheritance, Effective Java (3rd ed., Item 82) recommends a private lock object, so that a subclass cannot "unintentionally interfere with the operation of the base class, or vice versa".
The devil's-advocate check209 words
The strongest case: the callbacks are Expert / @lucene.experimental, and the class javadoc warns "It is better to not perform heavy computations in these methods though since they are called synchronously and under a lock" (LRUQueryCache.java:99-101), so a subclass that locks inside a callback does so at its own risk, and a maintainer may prefer a javadoc note to a code change. (The independent cross-model reviewer, OpenAI GPT-5.6, raised this case.) In #10081 (LUCENE-9038, 2019) Ben Manes read that contract as: the callbacks "are executed under the global lock. This means implementations may rely on this exclusive read/write access to data structures".
Why it fails: the sentence is advice against heavy work and names no lock or lock order; a counter update is not heavy. It dates from LUCENE-6157 (2015), when "the lock" was the cache's own re-entrant monitor, so a synchronized callback simply re-entered it. Ben Manes' reading is why subclasses never needed a lock before, and why they need one now that main runs callbacks concurrently. @lucene.experimental allows API changes, not a permanent hang. The cycle needs Lucene to reuse the subclass's public monitor as an internal lock, which the rendered javadoc does not even show. Verdict: not intended. A javadoc-only answer stays possible.
Recommended cleanUp() synchronizes on a private lock object
Serialize cleanUp() on a private final Object (synchronized (cleanUpLock)) instead of the cache object, so Lucene never takes the public monitor. Cleanups stay serialized; only code that locked the cache to keep cleanUp() out sees a change. Validated: the repro passes, and so do 2,339 tests of the org.apache.lucene.search package. Land it with LFV-991's clear() fix.
private final Object cleanUpLock = new Object();
public void cleanUp() {
// A private lock, not this object's monitor: subclasses may synchronize on the cache in their
// hit, miss, insertion and eviction callbacks, run under the partition locks cleanUp() takes.
synchronized (cleanUpLock) {
doCleanUp();
}
}
private void doCleanUp() {
// the former body of cleanUp(), unchanged
}All options and trade-offs4 options · 692 words
One fix was validated in a scratch worktree of 645d4fe14a20; the others are directions, not attempted. The cycle needs cleanUp() holding the public cache monitor and a callback that takes that monitor under a partition lock; Lucene controls the first.
One file, +10/-1 in LRUQueryCache.java: a private final Object cleanUpLock, and cleanUp() runs its unchanged body inside synchronized (cleanUpLock). Concurrent cleanUp() calls stay serialized, the modifier's apparent purpose, but Lucene no longer takes the cache object's monitor anywhere, so a subclass's monitor is a leaf lock again, as in every release since 6.1 (2016).
private final Object cleanUpLock = new Object();
public void cleanUp() {
// A private lock, not this object's monitor: subclasses may synchronize on the cache in their
// hit, miss, insertion and eviction callbacks, run under the partition locks cleanUp() takes.
synchronized (cleanUpLock) {
doCleanUp();
}
}
private void doCleanUp() {
// the former body of cleanUp(), unchanged
}Trade-offs
- No change to what the cache stores, evicts or reports, and cleanups stay serialized; the cleanup body is unchanged.
- One observable change: code that synchronized on the cache to keep
cleanUp()out would no longer exclude it. That was never documented, and the rendered javadoc does not show the modifier; the issue can ask the maintainers whether anyone relies on it. - Callbacks of different partitions still run at the same time (LFV-1000, a javadoc matter); subclasses still need their own thread-safety. With this fix, synchronizing on the cache object in a callback no longer deadlocks against
cleanUp(); locks a subclass shares with other code remain its own concern. - The comment in the diff was reworded after the fact check to name the hit, miss, insertion and eviction callbacks (
onClear()runs with no partition lock,:458-463); the code is unchanged, compiles to byte-identical class files, and google-java-format 1.37.0 leaves the patched file unchanged. - Land it with LFV-991's
clear()fix. In the finder's TLA+ model, that fix alone (all partition locks held whileonClear()runs) deadlocks with the synchronizedcleanUp()(run M4); both fixes together are clean (run M3, 2,180,461 distinct states; its vacuity probe fails as required). - Worth adding to the class javadoc (documentation only): which locks are held during each callback, and that the cache never synchronizes on itself.
What the checks validated
Scratch worktree of 645d4fe14a20, gradle --no-daemon, OpenJDK 25.0.3, seed DEADBEEF, 2026-10-31 (worktree removed afterwards):
- stock: the minimal test fails with assertions on and off;
- with the fix: it passes with assertions on and off (7.3 s once, against a 10 s bound; the lint-clean copy in 0.77 s and, with seed 5EED1234, 0.58 s; the fact check's gradle re-runs 0.78 s, 0.80 s and 3.7 s);
- closest suites
TestLRUQueryCache,TestUsageTrackingFilterCachingPolicy,TestBooleanScorer,TestDocValuesOrdinalSetIterator: 88 tests, 0 failures, 3 skipped; - the whole
org.apache.lucene.searchpackage (subpackages included): 214 classes, 2,339 tests, 0 failures, 49 skipped; checkGoogleJavaFormat,ecjLintMain,forbiddenApisMainpass (with the lint-clean test alsoecjLintTest,forbiddenApisTest).
Earlier, the same diff: no hang in a 60 s stress (96,563,907 searches) and upstream TestLRUQueryCache 37 of 37. Not run: nightly tests, other modules, benchmarks.
Drop the modifier and let concurrent cleanUp() calls run in parallel, each on its own snapshot of the stale sets.
Trade-offs
Smaller in code, but whether two concurrent cleanups stay correct (RAM accounting, eviction callbacks fired once) needs its own analysis and tests; the private lock keeps today's serialization.
State that cleanUp() holds the cache object's monitor and that callbacks must not synchronize on the cache.
Trade-offs
No code change, but the most common Java idiom stays a permanent hang that no release had, and the modifier stays invisible in the rendered API docs.
Collect callback events under the lock and fire them after releasing it.
Trade-offs
Removes every lock-order hazard with callbacks, but changes the documented "under a lock" behaviour and the exclusive access subclasses may rely on (#10081); a much larger change.
Downstream: the Elasticsearch and OpenSearch subclasses do not synchronize on the cache today, so neither is known to hit this. Elasticsearch is adapting to the Lucene 11 cache (elastic/elasticsearch#163523); on Lucene 11 the comment both subclasses carry, that the callbacks are already protected by a lock in LRUQueryCache, no longer holds.
While building a TLA+ model of the partitioned cache's locks, reading cleanUp() to fill the model's lock table showed the synchronized modifier, a lock the model did not have. TLC found the deadlock as a 4-state trace; a JUnit on the stock jar confirmed it, and independent AI tools reproduced it with their own tests.
The method in full108 words
Method: semi-formal (a hypothesis formed while modelling, then witnessed by execution).
- The model (
LruPartClearMon.tla: the partitioned cache plus the cache monitor) deadlocks on main with a 4-state trace (run M1). It has no deadlock withcleanUp()on a private lock (M2, 491,278 distinct states) or without a monitor-synchronized subclass (M5, 1,237,688). The siblingclear()fix alone deadlocks (M4); both fixes together are clean (M3, 2,180,461), with a vacuity probe (M3v) that fails as required. - The finder's three-case JUnit, an independent reproducer's six-case JUnit and two no-latch stresses failed on main; the minimal test here is the insertion case cut down to one method with one partition.
No prior report found (2026-10-29: 260 tool queries and 27 manual searches over GitHub issues, PRs and commits incl. the JIRA archive, the Lucene lists, downstream trackers and the web; all 414 open PRs; rechecked 2026-10-31). Closest: Ben Manes' 2019 reading of the callback contract on #10081, no deadlock.
Searches4 searches
Verdict new. Related: #15558 (the introducing PR), #10081 (LUCENE-9038: Ben Manes, callbacks run under the cache's lock and implementations may rely on exclusive access), #16212 (a different cleanUp() defect, fixed, modifier kept) and #8292 (LUCENE-7237: the 2016 move to a private lock). Open PRs #16757 and #16847 touch LRUQueryCache.java elsewhere. Downstream: elastic/elasticsearch#163523 lists Lucene 11 cache adaptations without callback locking.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (GitHub issues/PRs, commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, builds@, JIRA archive, web) | 2026-10-29 | three runs: lock order, callbacks, partitioned cleanup (68, 68, 73 queries) plus the finder's run (51) | no report; 'high' hits are context (#10081, #15558, #16212) |
| By hand: GitHub (apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, all of GitHub) | 2026-10-29 | 27 searches; 11 GitHub threads read in full (plus 2 JIRA archive records, LUCENE-6157 and LUCENE-7237) | no report |
| Open apache/lucene PRs | 2026-10-29 | changed files of all 414 | #16757 and #16847 touch LRUQueryCache.java, not cleanUp() or its locking |
| GitHub, read-only recheck | 2026-10-31 | commits on LRUQueryCache.java (main and branch_11x); apache/lucene issues and PRs on LRUQueryCache updated since 2026-10-29; 'query cache deadlock'; 'cleanUp synchronized'; 'LRUQueryCache cleanUp deadlock'; OpenSearch 'Lucene 11 query cache' | newest commit still fadd63e0eb4e (#16657); nothing new; no OpenSearch item about the Lucene 11 cache |
Introduced by #15558 (d50fb36d9af3, merged 2026-04-14), which added cleanUp() with the synchronized modifier together with the partition locks; not backported to 10.x. Main and branch_11x are affected; no release is (10.6.0 included). Not fixed upstream as of 2026-10-31.
Versions tested5 rows
Introduced by d50fb36d9af3, apache/lucene#15558 "Refactor QueryCache to improve performance" (sgup432, merged by msfroh; design issue #14222). The modifier is already in the PR's first revision (2026-01-08). #16212 (372b1db88a5) later made keys() take every partition's read lock under the monitor, a wider window; #16657 (fadd63e0eb4) changed the partition routing, the cache-key hashCode/equals and the query RAM accounting, but none of the locks in this cycle. The PR author said on #15558 (2026-07-20) that not backporting to 10.x was "a conscious choice".
Before (no release has cleanUp()): 2015 (LUCENE-6303, releases 5.1 to 6.0) the state-changing methods synchronized on the cache, its only lock and re-entrant; 2016 (LUCENE-7237, 6.1 to 9.10) a private ReentrantLock; 2024 (#13306, 9.11 to 10.6) a private ReentrantReadWriteLock. None can form this cycle.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails with assertions on and off (gradle and standalone); passes with the fix |
main a9779ef3c193 | affected | the finder's, the independent reproduction's and the history variant's tests fail; stresses hang |
d50fb36d9af (#15558), 1adb280101d (before #16657) | shadowed class | the history variant fails with that commit's LRUQueryCache.java over main jars |
upstream main 2f424fd428da, branch_11x | read | same LRUQueryCache.java; line 1392 public synchronized void cleanUp() |
| 10.6.0, 10.5.2, 9.12.3 | not affected | the API does not exist (the test does not compile); a 10.x analog with the same synchronized subclass passes 3 of 3 on 9.12.3, 10.5.2 and the 10.6 RC |
Confirmed by an independent reproducer, a devil's-advocate verifier, an adjudication, a credibility audit and a tie-break, each with its own executed tests and controls. Cross-model reviews (OpenAI GPT-5.6) could only read code; one said not real on procedure alone. This doc's gradle run reproduced it and validated the fix.
Checks and verdicts228 words
Every executed check says real. The one not-real record ran no code and agreed with the root cause.
| Check | Verdict | What it found |
|---|---|---|
| Finder (TLA+ model, then JUnit) | candidate | TLC: a 4-state deadlock trace; JUnit fails on main -ea/-da; no-latch stress hangs 5 of 5. |
| Cross-model review, refute lens (OpenAI GPT-5.6, reading only) | not real (procedural) | Could not run code; agreed with the root cause; rated its intended-behaviour case moderate; its private-lock control was added and passes. |
| Independent reproducer | real | Own JUnit fails 3 of 6 (write-lock, read-lock, stress), private-lock controls pass; stress hangs 7 of 7. |
| Cross-model review, intended lens (OpenAI GPT-5.6, reading only) | real (medium) | Rejected the works-as-designed case; a separate re-run of the finder's JUnit fails. |
| Intended-behaviour devil's advocate | real (high) | Seven works-as-designed arguments rejected; javadoc rendering check; downstream survey. |
| Adjudication | confirmed (high) | Re-ran the finder's and the independent reproduction's JUnits with a new seed: fail, controls pass. |
| Credibility audit | keep | Re-ran both JUnits with fresh seeds: fail, controls pass. |
| Verification on 645d4fe14a20 and tie-break (2026-10-31) | real | Minimal test fails -ea/-da; natural workload hangs 6 of 6 JVMs; fix and private-lock controls do not hang. |
| This doc (gradle, scratch worktree) | real; fix validated | Stock fails, fix passes; 2,339 search-package tests pass with the fix. |
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 490 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,546 words
- Registry title
- On main, LRUQueryCache.cleanUp() is synchronized on the cache object and takes partition locks while it holds that monitor, so a subclass whose statistics callbacks synchronize on the cache (synchronized methods or synchronized (this)) deadlocks the cleanup thread against a search, and every later search whose callback needs the monitor hangs too; regression from #15558, main only
- Area
- search / query cache (LRUQueryCache.cleanUp() locking vs the statistics callbacks)
- Priority
- Low, rank 792: This permanent deadlock blocks every search but is an unreleased main regression that only fires for an LRUQueryCache subclass whose callbacks synchronize on the cache, which no known public subclass does.
- Impact class
- hang
- Impact note
- Availability only, on unreleased main (11.0) and branch_11x: an application that installs an LRUQueryCache subclass whose statistics callbacks synchronize on the cache object, and runs cleanUp() while searches run (through the background scheduler it configures with CacheCleanUpParameters, or through its own periodic calls, one of which the class javadoc requires), gets a permanent, uninterruptible hang of the cleanup thread and of every search whose callback needs that monitor; when onHit/onMiss are among the synchronized callbacks, that is every search that consults the cache; stock LRUQueryCache and subclasses that use atomics or a private lock are unaffected, none of the four public downstream subclasses read uses the idiom, the default query cache is off on main (#14187), and every release, 10.6.0 (released 2026-10-29) included, is unaffected.
- Summary
- You subclass LRUQueryCache to keep your own statistics in the callbacks its javadoc offers (onHit, onMiss, onCacheEntryInserted, onCacheEntryEvicted) and make them thread-safe the plain Java way, with synchronized methods or synchronized (this), and you let the cache clean up stale entries as documented. You expect searches and cleanUp(), which its javadoc calls 'safe to call concurrently', to keep running, as the same subclass does on 10.x, where Lucene never locks the cache object itself; on main, cleanUp() is a synchronized method, so it holds the cache object's monitor while it waits for a partition lock, while a search holds that partition lock and waits for the same monitor inside your callback. Both threads wait for each other forever (the JVM's deadlock detector names the cycle for the insertion callback; for onHit it cannot, because a read-lock holder has no owner), and every later search whose callback needs the monitor, every clearQuery() on that partition and every later cleanUp() block behind them.
- Introduced
- d50fb36d9af3 (GITHUB#15558 'Refactor QueryCache to improve performance', by Sagar Upadhyaya (sgup432), merged by Michael Froh (msfroh); design issue #14222; 'public synchronized void cleanUp()' is already in the PR's first revision ef7cf0ff101, 2026-01-08) [exec: the history variant of the JUnit fails with that commit's LRUQueryCache.java shadowed over main jars; at the parent 55b4574a20d the API does not exist (cleanUp(), CacheCleanUpParameters and the unified callbacks came with this commit, so the JUnit does not compile) and the 10.x analog passes; read: git log -S 'public synchronized void cleanUp' over full history finds only d50fb36d9af; #16212 (372b1db88a5) later made keys() take every partition's read lock under the monitor, widening the window; #16657 (fadd63e0eb4) changed routing only] (2026-04-14 (commit author date 2026-04-14T18:55:32+02:00, merged 2026-04-14T16:55:32Z))
- First affected version
- unreleased (main / 11.0.0-SNAPSHOT since 2026-04-14 and branch_11x, cut from main 2026-10-28 [read]; #15558 was deliberately not backported, so no release, 10.6.0 (released 2026-10-29; branch_10_6 4ae946e5a9b) included, is affected [exec on 9.12.3, 10.5.2 and the 10.6 RC: the JUnit does not compile because the API is absent, and the 10.x analog with the same synchronized subclass passes; read: no tag among 481 contains d50fb36d9af])
- 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, 3 separate runs), the independent reproduction's own JUnit fails 3 of 6 with -ea and -da, the adjudication's re-runs with seed 1A2B3C fail (-ea finder's, -da the independent reproduction's), the cross-model review's re-run with seed 5EED5 -da fails 2 of 3, the intended-behaviour check's re-runs fail with -ea and -da, the re-run with seed 5EED4002 -da fails 2 of 3; no-latch stresses with the documented background cleanup hang in 5 of 5 (finder) and 7 of 7 runs; the history variant fails with the LRUQueryCache.java of d50fb36d9af and of 1adb280101d (just before #16657) shadowed over main jars, not full builds of those commits]; branch_11x [read: newest LRUQueryCache.java commit fadd63e0eb4, same as main, line 1392 'public synchronized void cleanUp()'; not executed separately]; upstream main 645d4fe14a2 (2026-10-28) [read: no LRUQueryCache.java commit after fadd63e0eb4, author date 2026-09-29]. Not affected [exec]: 9.12.3, 10.5.2 and the 10.6 RC branch_10_6 4ae946e5a9b (core jar only): the JUnit does not compile (no cleanUp(), no CacheCleanUpParameters, no onCacheEntryInserted/Evicted) and the 10.x analog (the same synchronized (this) subclass against clearQuery()/clear()) passes 3 of 3; the independent reproduction ran the analog on 10.5.2, 9.12.3 and main (pass). [read] From LUCENE-7237 (bf232d7635e, 2016) to #15558 LRUQueryCache used only private locks (ReentrantLock, then ReentrantReadWriteLock from #13306) and never its own monitor; in 5.x (LUCENE-6303, 2015) every method was synchronized on the cache, a single re-entrant lock, so no cycle was possible. All runs JDK 25.0.3 on Linux aarch64 with -XX:ActiveProcessorCount=4
- 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 “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
- semi-formal: code reading; also reached by TLA+/TLC
- Upstream and prior reports
- none found (the prior-report record verdict new, searched 2026-10-29, read-only): three scripted prior-report searches on lock order, callbacks and partitioned cleanup (68, 68 and 73 queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists, the JIRA archive and the web) plus the finder's run (51 queries), 27 manual GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and all of GitHub, the changed files of all 414 open apache/lucene PRs, the commits API by author date and 15 threads read in full. Nearest items, not this bug: #10081 (LUCENE-9038, open since 2019), where Ben Manes read the callbacks' contract as running under the global lock, so that 'implementations may rely on this exclusive read/write access to data structures'; it predates cleanUp(), CacheCleanUpParameters and the partition locks. #15558 (the introducing change; the only synchronization discussion is the uniqueQueries synchronizedMap contention; nobody mentions cleanUp()'s monitor, lock order or a deadlock). #16212 (a ConcurrentModificationException in cleanUp(), fixed, another defect; it leaves 'public synchronized void cleanUp()' unchanged). #16657, #14222, #13306, #14677, #9301 (LUCENE-8254), #8292 (LUCENE-7237) and #7570 (LUCENE-6511) (other cache changes or defects); the open PRs #16757 and #16847 touch LRUQueryCache.java elsewhere. Downstream: elastic/elasticsearch#163523 (javanna, open since 2026-10-27) lists what Elasticsearch must adapt for the Lucene 11 cache and does not mention locking in callbacks; opensearch-project/OpenSearch#22803 removes a synchronized from a method of another object. Not fixed upstream: no LRUQueryCache.java commit after fadd63e0eb4 (author date 2026-09-29) on main (head 645d4fe14a2) or branch_11x; no PR or issue about the query cache was opened or updated since 2026-10-27
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree (gradle's randomized JVM arguments for seed DEADBEEF included-XX:ActiveProcessorCount=1) and in repeated standalone runs; a thread dump of a standalone run shows the JVM-detected deadlock. With the recommended fix it passes (0.58 s to 7.3 s, depending on host load, against a 10 s bound), and 88 tests in the 4 closest suites and 2,339 tests of theorg.apache.lucene.searchpackage pass; core lint passes. The 10.6.0 and 10.5.2 release jars do not compile the test (the API is absent). The hunt ran on maina9779ef3c193, whoseLRUQueryCache.javais the same blob; upstream main2f424fd428daand branch_11x have it too (read, last 2026-10-31).
- 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'), :103-113 (background cleanup or periodic manual cleanUp()), :197-204 (scheduleWithFixedDelay of cleanUp()), :1024-1031 (keys(): partition read locks), :1060-1083 (get(): onHit/onMiss under the read lock), :1100-1131 (putIfAbsent(): onCacheEntryInserted under the write lock), :1155-1189 (remove(): write lock, onCacheEntryEvicted), :1382-1391 (cleanUp() javadoc 'This method is safe to call concurrently.'), :1392-1413 ('public synchronized void cleanUp()')lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84 (DEFAULT_QUERY_CACHE = null) and lucene/CHANGES.txt:333 (GITHUB#14187: the query cache is disabled by default)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, (at 645d4fe14a20) :88-113, :197-204, :214-222, :443-455, :960, :1024-1031, :1060-1083, :1100-1131, :1155-1189, :1250-1256, :1382-1413
- Who is affected
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84; (at 645d4fe14a20) lucene/CHANGES.txt:343 (GITHUB#14187)lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:305-318 (at 645d4fe14a20) (stock onHit/onMiss use LongAdder)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:64, (at 645d4fe14a20) :88-113, :1382-1392lucene/MIGRATE.md:514-542 (at 645d4fe14a20) (#15558 migration example)a Lucene clone: releases/lucene/10.5.2 and releases/lucene/10.6.0 LRUQueryCache.java:100-101 (private ReentrantReadWriteLock; no synchronized method)
- History
a Lucene clone: origin/main 2f424fd428da, origin/branch_11x LRUQueryCache.java:1392; releases/lucene/10.5.2 and 10.6.0
- TestLRUQueryCacheCleanUpDeadlock.java (one test):
repro/LFV-1001/src/org/apache/lucene/search/TestLRUQueryCacheCleanUpDeadlock.java(103 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.
assets/LFV-1001/bug.png(490 KB)
