All findings · Lucene finding · LFV-1001 · lruquerycache-cleanup-monitor-deadlock

On main, LRUQueryCache.cleanUp() deadlocks with subclasses whose statistics callbacks are synchronized

On main, LRUQueryCache.cleanUp() locks the cache object and then waits for a partition lock, while a search holds that lock and calls your statistics callback. If the callback is synchronized, both wait forever and later searches hang behind them; no release is affected.

Summary

Priority
Low, rank 792This 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
hang
Since
#15558 on main (2026-04-14); unreleasedintroduced 2026-04-14
Affected
main and branch_11x only; no release (10.6.0 included)
Status
documented
Found by
semi-formal: spotted while building a TLA+ model of the cache's locks, then a JUnit

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

The finding in 30 seconds

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.

Background

  1. Partitions. On main, LRUQueryCache is 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.
  2. Statistics callbacks. The cache calls onHit, onMiss, onCacheEntryInserted and onCacheEntryEvicted while it holds that partition lock. Subclasses override them to keep their own statistics, as the class javadoc suggests.
  3. 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.
Diagram of the deadlock: a search thread caches a result in LRUQueryCache, holding the partition's write lock, and calls the subclass's onCacheEntryInserted, which waits to synchronize on the cache object; a cleanUp() thread holds the cache object's monitor (cleanUp() is synchronized) and waits for that partition's lock in keys(); each waits for the lock the other holds, forever.
Two threads and two locks: the search holds the partition lock and waits for the cache monitor in its 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.

The finding, step by step

  1. You subclass LRUQueryCache to count cache inserts and make the counter thread-safe the plain Java way: synchronized (this) { inserted++; } in onCacheEntryInserted. On main, stale entries are removed only by cleanUp(), which a background thread or your own code runs.
  2. A search caches a result: Lucene takes the partition's write lock and calls your onCacheEntryInserted. At that moment cleanUp() runs, because 1 query was cleared with clearQuery(). cleanUp() is synchronized, so it holds the cache object's monitor, and it now waits for the partition lock.
  3. You expect both to finish within milliseconds, as the same subclass does on 10.x, where Lucene never locks the cache object.
  4. Instead your callback waits for the monitor that cleanUp() holds, and cleanUp() waits for the lock the search holds. In the repro (1 document, 1 partition, 1 search), after 10 s the search is BLOCKED and cleanUp() is WAITING, and the JVM reports a Java-level deadlock.
  5. 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.

Who is affected

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.

The code (main 645d4fe14a20, LRUQueryCache.java)

  1. 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 iterates keys() (: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 partition remove() for each stale entry (:1410), which takes the write lock (:1156) and fires onCacheEntryEvicted under it (:1183). There is no read-to-write upgrade: the monitor is the only lock cleanUp() holds across these steps.
  2. Stale entries are ordinary. clearQuery() marks a query (:451-455, :1250-1256); a closed segment marks its key through clearCoreCacheKey() (:443-448). On main they are not removed inline: cleanUp() must run, from the scheduler configured with CacheCleanUpParameters (:197-204, scheduleWithFixedDelay(this::cleanUp, ...)) or from the caller (:103-113).
  3. Searches call the callbacks under partition locks. putIfAbsent() takes the write lock (:1100) and calls onCacheEntryInserted (:1126); get() takes the read lock (:1064) and calls onMiss or onHit (:1071-1077).
  4. 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 repro cleanUp() parks on the read lock in keys(), blocked by the search's write lock. With synchronized onHit, cleanUp() waits for the write lock in remove() while searches hold read locks; the JVM's deadlock detector does not report that variant, because a read lock has no owner.
  5. The monitor is used nowhere else. No other method locks the cache object (:445 locks keysToClean, :960 is a Collections.synchronizedMap with its own mutex). The second lock exists only because of the modifier, whose job is to serialize concurrent cleanUp() calls. Javadoc does not render synchronized, so the API docs show public void cleanUp().

What happens next

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 LRUQueryCache subclass whose callbacks synchronize on the cache object (synchronized methods or synchronized (this)), that run cleanUp() 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 use LongAdder. 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/onMiss run under a shared read lock, so subclasses now need their own synchronization, and synchronized is 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 under this.

Minimal repro

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.

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

import 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());
  }
}
Command
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 with cleanUp() 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.

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

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).

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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

Observed output

# 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)

Why it looks unintended

  • 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 LRUQueryCache that overrides onCacheEntryInserted and onCacheEntryEvicted, 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 ReentrantReadWriteLock since #13306 (10.5.2 and 10.6.0 LRUQueryCache.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, the clear* methods) were synchronized on the cache, its only lock and re-entrant, so no cycle was possible. No release has cleanUp().
  • 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 changed cleanUp()'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.

Suggested fix

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.

cleanUp() synchronizes on a private lock object (validated by the checks) (recommended)

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 while onClear() runs) deadlocks with the synchronized cleanUp() (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.search package (subpackages included): 214 classes, 2,339 tests, 0 failures, 49 skipped;
  • checkGoogleJavaFormat, ecjLintMain, forbiddenApisMain pass (with the lint-clean test also ecjLintTest, 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.

No lock in cleanUp() at all (not attempted)

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.

Document the monitor (not attempted)

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.

Fire callbacks after releasing the partition lock (larger, not attempted)

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 note

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.

How it was found

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 with cleanUp() on a private lock (M2, 491,278 distinct states) or without a monitor-synchronized subclass (M5, 1,237,688). The sibling clear() 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub issues/PRs, commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, builds@, JIRA archive, web)2026-10-29three 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-2927 searches; 11 GitHub threads read in full (plus 2 JIRA archive records, LUCENE-6157 and LUCENE-7237)no report
Open apache/lucene PRs2026-10-29changed files of all 414#16757 and #16847 touch LRUQueryCache.java, not cleanUp() or its locking
GitHub, read-only recheck2026-10-31commits 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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with assertions on and off (gradle and standalone); passes with the fix
main a9779ef3c193 affectedthe finder's, the independent reproduction's and the history variant's tests fail; stresses hang
d50fb36d9af (#15558), 1adb280101d (before #16657) shadowed classthe history variant fails with that commit's LRUQueryCache.java over main jars
upstream main 2f424fd428da, branch_11x readsame LRUQueryCache.java; line 1392 public synchronized void cleanUp()
10.6.0, 10.5.2, 9.12.3 not affectedthe 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

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (TLA+ model, then JUnit) candidateTLC: 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 realOwn 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 keepRe-ran both JUnits with fresh seeds: fail, controls pass.
Verification on 645d4fe14a20 and tie-break (2026-10-31) realMinimal 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 validatedStock fails, fix passes; 2,339 search-package tests pass with the fix.

Diagram file

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

Sources and provenance

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

Finding record

LFV-1001, snapshot of 2026-11-06

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 the org.apache.lucene.search package 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 main a9779ef3c193, whose LRUQueryCache.java is the same blob; upstream main 2f424fd428da and branch_11x have it too (read, last 2026-10-31).

Lucene and upstream sources

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-1392
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal test.

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