All findings · Lucene finding · LFV-769 · monitor-termfilters-hashmap-race

Monitor.match can throw NullPointerException while queries are being registered

If one thread registers queries while another calls Monitor.match, the match can throw NullPointerException: per-searcher term filters sit in a plain HashMap, and a lookup during its resize past 12 entries finds nothing. Releases 8.2.0 to 10.6.0 are affected; a one-line ConcurrentHashMap fixes it.

Summary

Priority
Low, rank 633It is a loud one-off NullPointerException in the opt-in Monitor under a race that needs more than 12 query-index readers open at a new peak and failed 0 times in 73k natural matches, so it follows the similar rarely reached race-window row LFV-263.
Impact
crash
Since
8.2.0 (2019), when the monitor module was addedintroduced 2019-06-13 · about 7 years
Affected
8.2.0 to 10.6.0 and main; the read-only Monitor from 9.2.0
Status
documented
Found by
code reading of the monitor's query-index concurrency, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

Each refresh of the query-index searcher puts the new searcher's term filter into QueryIndex.termFilters, a plain HashMap, while match threads read it with no lock. When a put grows the map past 12 entries (then 24, 48), HashMap.resize() installs the new, empty table first, so a concurrent get returns null and the presearcher dereferences it.

Background: the Monitor's query index

  1. A Monitor stores its registered queries in a small index of its own, the query index. Every register, deleteById or clear commits it and opens a new searcher; a match that is still running keeps the searcher it started with open until it finishes.
  2. For each open searcher the Monitor builds a term filter, the set of terms in that query index. The default presearcher uses it to turn a document into the query that finds candidate queries.
  3. java.util.HashMap must be synchronised externally when one thread changes it while others read it. While it grows, a reader can miss a key that is in the map.
Summary card of the race. Panel 1, what you do: one Monitor; thread A calls Monitor.match in a loop while 12 slower matches hold their searchers and thread B registers one more query; expected: every match returns query a. Panel 2, inside Lucene: each refresh puts one entry per open reader into the plain HashMap termFilters, and match calls get with no lock; 12 held matches plus one register give 13 entries, and the map grows from 16 to 32 buckets. Panel 3, HashMap.resize(): line 712 installs the new, empty 32-bucket table first and then moves the buckets one by one; thread A's get for reader 12 reads an empty bucket of the new table and gets null, although reader 12 is still in the old table. Panel 4, actual: NullPointerException, this.val$termAcceptor is null, at TermFilteredPresearcher.java:122; the fix sketch new ConcurrentHashMap<>() passes.
The race in one picture: the registering thread's put makes the term-filter map resize, and a match thread's get for its own searcher reads the new, still empty table and gets null. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Share one default Monitor between threads. Thread A calls match on a one-word document in a loop. Meanwhile 12 slow matches are still running, each holding the query-index searcher it started on, and thread B registers one more query.
  2. For each open searcher the Monitor keeps a term filter in a plain HashMap: 12 searchers, 12 entries, which is the resize limit of its 16 buckets. Thread B's register opens searcher 13 and adds the 13th entry.
  3. You expect every match to finish: the Monitor is built so that searches run during commits, and register and match need no lock of yours.
  4. Instead the HashMap first installs a new, empty 32-bucket table and only then moves the 13 entries over. A match that looks up its searcher in between gets null, and Monitor.match throws NullPointerException: ... "this.val$termAcceptor" is null at TermFilteredPresearcher.java:122.
  5. In every executed run later matches succeeded, and in a natural workload without blocking it never happened: 0 failures in 73,064 matches. The two-thread test below fails within seconds, and pausing the resize in a debugger makes the match throw 16 times out of 16.

Who is affected

Users of the optional lucene-monitor module who call match while other threads register, deleteById or clear queries (default configuration and presearcher), and read-only Monitors whose refresh runs during matches. A match call fails with a bare NPE and can be retried; it needs more than 12 searchers open at a new peak, so it is rare.

Mechanism, terms and numbers800 words

QueryIndex keeps, for every open searcher over the Monitor's query index, a QueryTermFilter in termFilters, a plain java.util.HashMap (QueryIndex.java:56). Each register, deleteById or clear commits and refreshes the searcher, and the refresh puts the new reader's filter into the map (TermsHashBuilder.java:38); a read-only Monitor does the same when its refresh runs. Monitor.match reads the map with no lock (WritableQueryIndex.java:221-223, ReadonlyQueryIndex.java:80-82). When a put grows the map past 12, 24, 48, ... entries, HashMap.resize() publishes the new, empty table before it moves the old entries, so a concurrent get can return null for the match's own reader, and TermFilteredPresearcher.buildQuery throws NullPointerException at :122. It needs more than 12 query-index readers open at once (slow in-flight matches spanning refreshes) and fires only at a new peak, so it is rare: a natural workload failed 0 times in 73,064 matches. In every executed run later matches succeeded, and no wrong results were seen. It has been there since the module was added (8.2.0) and is still in 10.6.0 and main; termFilters = new ConcurrentHashMap<>() fixes it.

The mechanism (main 645d4fe14a20)

  1. One map, one entry per open reader. QueryIndex.java:55-56: // package-private for testing / final Map<IndexReader.CacheKey, QueryTermFilter> termFilters = new HashMap<>();. Both indexes build their SearcherManager with new TermsHashBuilder(termFilters) (WritableQueryIndex.java:79, ReadonlyQueryIndex.java:48).
  2. Writers. TermsHashBuilder.newSearcher runs on the refreshing thread: termFilters.put(reader.getReaderCacheHelper().getKey(), new QueryIndex.QueryTermFilter(reader)); (TermsHashBuilder.java:38) and reader.getReaderCacheHelper().addClosedListener(termFilters::remove); (:39). The writable refresh is manager.maybeRefresh() in commitWithoutNotify (WritableQueryIndex.java:128), reached from register (commit, :102-105), deleteById and clear (:338-351). The read-only refresh is manager.maybeRefresh() in purgeCache() (ReadonlyQueryIndex.java:107-111), called by the application or every purge period by the index's own cache-purge thread (:51-64; default 5 minutes). The remove runs on whichever thread drops the last reference to an old reader, often a match thread releasing its searcher.
  3. The unlocked reader. WritableQueryIndex.search holds the purge read lock only around manager.acquire() (:211-217), then calls termFilters.get(searcher.getIndexReader().getReaderCacheHelper().getKey()) with no lock (:221-223); ReadonlyQueryIndex.search takes no lock at all (:73-82). The commit holds commitLock and the read lock (:109-110), the same shared mode that search takes, so nothing orders the put against the get.
  4. Why the get returns null. A default HashMap has 16 buckets and load factor 0.75, so a resize threshold of 12; putVal resizes when ++size > threshold. OpenJDK 25's HashMap.resize() publishes the new array with table = newTab; (HashMap.java:712) and only then moves each old bucket, clearing it (oldTab[j] = null;, :717). A lock-free get in that window reads the new, still empty bucket and misses a key that is present. The failing match holds its searcher, so its entry cannot have been removed: the closed listener runs only when the reader's reference count reaches 0. A debugger paused in that window saw exactly this: the published table had 32 slots and 0 entries, while the old 16-slot table still held all 13.
  5. Where it throws. Monitor.match calls queryIndex.search(t -> presearcher.buildQuery(reader, t), collector) (Monitor.java:224). The default presearcher, TermFilteredPresearcher (Monitor.java:56-58), filters each document term of a field not in filterFields with termAcceptor.test(field.name, termAtt.getBytesRef()) (TermFilteredPresearcher.java:120-122, reached from the token loop at :127); termAcceptor is the null. A batch with no such term never reads it and does not throw. MultipassTermFilteredPresearcher is a subclass; Presearcher.NO_FILTERING ignores the acceptor.

When it happens

  • In practice a resize happens when a put grows the map past 12 entries, so more than 12 query-index readers are open at once: 12 or more in-flight matches each still holding an older searcher while a refresh opens a new one. A HashMap never shrinks, so after the first resize the next needs a new peak past 24, then 48. (A small HashMap also resizes early if 9 keys collide in one bucket; the keys use identity hash codes, so it needs 9 of at most 12 keys in one of 16 buckets, or of at most 24 in one of 32. It was not measured, and no control run with 11 held searchers ever failed.)
  • Threshold controls on stock jars: 11 held searchers (at most 12 entries, no resize) pass 300 rounds (1,589,156 matches) and, in another program, 1,000 rounds; 12 held searchers (13 entries, one resize per round) fail.
  • getQuery and scan also call search, but with a builder that ignores the filter (QueryIndex.java:62-81), so a null there is harmless.
  • By code reading only, not executed: a reader-close remove racing a refresh put in the same bucket could drop the new reader's entry, and two concurrent removals could leak a filter. By reading, the fix's atomic put and remove would close both; neither variant was run.
Exposure and likelihood231 words
  • Who is exposed. Applications that share one Monitor between matching threads and threads that register, delete or clear queries, with the default configuration and presearcher (also MultipassTermFilteredPresearcher, a subclass). Read-only Monitors (a read-only configuration with a directory) fail the same way when a refresh, explicit purgeCache() or scheduled, runs while the application matches: it was executed with explicit purgeCache() calls and with only the scheduled refresh at a 2 ms period. With the default 5-minute period, 13 open readers would need matches that span about 12 refreshes of a changing index.
  • Effect. A Monitor.match call throws NullPointerException; in every executed run one or a few matches failed and later matches succeeded. No wrong results, corruption or lasting damage were observed, but the tests only check for exceptions, and a HashMap gives no guarantee under such a race.
  • Precondition. In practice, more than 12 query-index readers open at the moment of a refresh, and only at a new peak (past 12, 24, 48, ...). Matches that span many registrations get there: long-running queries, big document batches, bursts of registrations.
  • How likely. A natural probe on stock jars (default SIMPLE_MATCHER, no blocking, 24 match threads with 200-document batches, one registering thread) went past 12 open readers in 39 of 40 fresh Monitors (at most 20) and failed 0 times in 73,064 matches. Likelihood: rare. No report was found since 8.2.0.

Minimal repro

One JUnit test, public API, two threads because the race is the bug: a background thread matches in a loop while the test thread nests 48 matches that each register a query, so 48 searchers stay open. At most 100 light rounds; it fails within seconds, with assertions on and off.

TestMonitorMatchDuringRegister.java (one test, public API)91 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.monitor;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMonitorMatchDuringRegister extends LuceneTestCase {

  public void testMatchWhileRegistering() throws Exception {
    Document doc = new Document();
    doc.add(new TextField("f", "a", Field.Store.NO));
    AtomicReference<Throwable> failure = new AtomicReference<>();
    for (int round = 0; round < 100 && failure.get() == null; round++) {
      try (Monitor monitor = new Monitor(new StandardAnalyzer())) {
        monitor.register(new MonitorQuery("a", new TermQuery(new Term("f", "a"))));
        AtomicBoolean stop = new AtomicBoolean();
        Thread matcher =
            new Thread(
                () -> {
                  while (stop.get() == false) {
                    try {
                      monitor.match(doc, QueryMatch.SIMPLE_MATCHER);
                    } catch (Throwable t) {
                      failure.compareAndSet(null, t);
                    }
                  }
                });
        matcher.start();
        try {
          registerWhileMatching(monitor, doc, 48);
        } finally {
          stop.set(true);
          matcher.join();
        }
      }
    }
    // expected: match never throws while a query is registered; actual: NullPointerException
    if (failure.get() != null) {
      throw new AssertionError("Monitor.match threw while a query was registered", failure.get());
    }
  }

  private static void registerWhileMatching(Monitor monitor, Document doc, int depth)
      throws IOException {
    if (depth == 0) {
      return;
    }
    monitor.match(
        doc,
        searcher ->
            new CandidateMatcher<QueryMatch>(searcher) {
              @Override
              public void matchQuery(String queryId, Query matchQuery, Map<String, String> metadata)
                  throws IOException {
                Query q = new TermQuery(new Term("f", "q" + depth));
                monitor.register(new MonitorQuery("q" + depth, q));
                registerWhileMatching(monitor, doc, depth - 1);
              }

              @Override
              public QueryMatch resolve(QueryMatch match1, QueryMatch match2) {
                return match1;
              }
            });
  }
}
Command
cd $LUCENE && ./gradlew :lucene:monitor:test --tests org.apache.lucene.monitor.TestMonitorMatchDuringRegister -Ptests.seed=DEADBEEF
Expected
No Monitor.match call throws while another thread registers queries.
Observed
NullPointerException: ... "this.val$termAcceptor" is null at TermFilteredPresearcher.java:122, thrown out of Monitor.match, with assertions on and off.

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

More files, environment, full output376 words

Plain test code has no deterministic form: HashMap.resize() calls no code a test could pause in (a debugger can, see "Independent checks"). So the test repeats a light round (one fresh Monitor, one query f:a, one document f = "a") at most 100 times and stops at the first failure. No latches, sleeps, reflection or shadow classes; it sits in package org.apache.lucene.monitor like the module's tests but uses only public API, and passes checkGoogleJavaFormat, ecjLint and forbiddenApis as is.

It is an amplifier, not a field rate. Through gradle (whose test JVMs run with -XX:ActiveProcessorCount=1), seed DEADBEEF failed with assertions on and off in three separate runs, and 4 of 5 iterations failed with -Ptests.iters=5; on a 32-core machine 20 of 20 iterations failed with -ea and with -da. The seed does not control the interleaving. Nesting depth 11 (at most 12 map entries) passed 2 x 300 rounds; depth 12 failed.

The nested register inside CandidateMatcher.matchQuery only keeps older searchers open on one thread. It is legal public API but unusual. Independently written programs without re-entrant calls (48 slow matches parked on their own threads) fail the same way: an independent verifier's test on main, 10.5.2 and 9.12.3; a tie-break test on main, 10.5.2 and 10.6.0; a plain main() program on main and 10.6.0. A read-only Monitor refreshed only by its own background thread fails on main, 10.5.2 and 10.6.0.

Regression test: this same test; it passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Also fails on the 10.6.0, 10.5.2 and 8.2.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run)
Command (environment)
cp $REPRO/TestMonitorMatchDuringRegister.java $LUCENE/lucene/monitor/src/test/org/apache/lucene/monitor/
cd $LUCENE && ./gradlew :lucene:monitor:test --tests org.apache.lucene.monitor.TestMonitorMatchDuringRegister -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# gradle, scratch worktree of 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with -Ptests.asserts=false)
TestMonitorMatchDuringRegister > testMatchWhileRegistering FAILED
    java.lang.AssertionError: Monitor.match threw while a query was registered
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:1F48782182A9B9AD]:0)
        at org.apache.lucene.monitor.TestMonitorMatchDuringRegister.testMatchWhileRegistering(TestMonitorMatchDuringRegister.java:64)

        Caused by:
        java.lang.NullPointerException: Cannot invoke "java.util.function.BiPredicate.test(Object, Object)" because "this.val$termAcceptor" is null
            at org.apache.lucene.monitor.TermFilteredPresearcher$1.accept(TermFilteredPresearcher.java:122)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.analysis.FilteringTokenFilter.incrementToken(FilteringTokenFilter.java:52)
            at org.apache.lucene.monitor.TermFilteredPresearcher.buildQuery(TermFilteredPresearcher.java:127)
            at org.apache.lucene.monitor.Monitor.lambda$match$0(Monitor.java:224)
            at org.apache.lucene.monitor.WritableQueryIndex.search(WritableQueryIndex.java:222)
            at org.apache.lucene.monitor.Monitor.match(Monitor.java:224)
            at org.apache.lucene.monitor.Monitor.match(Monitor.java:242)
            at org.apache.lucene.monitor.TestMonitorMatchDuringRegister.lambda$testMatchWhileRegistering$0(TestMonitorMatchDuringRegister.java:47)

# with termFilters = new ConcurrentHashMap<>(): the repro passes (3 of 3 iterations, 300 rounds, assertions on and off)

Why it looks unintended

  • Built for searches during commits: "commit is slow and search cannot be slow" (WritableQueryIndex.java:290).
  • #12801 fixed match latency "when it is called concurrently with Monitor.register/Monitor.deleteById"; the module's author: "you can index and query it from multiple threads".
  • HashMap needs external synchronisation here; an earlier concurrent-match race, inside each term filter's BytesRefHash, was fixed as a bug (LUCENE-9791).
The full argument362 words
  • Design. The purge-lock comment exists so that searches never wait on commits: "... searches waiting on purge waiting on commit which is problematic since commit is slow and search cannot be slow" (WritableQueryIndex.java:276-290). The query cache is a volatile ConcurrentMap built as new ConcurrentHashMap<>() (:70, :78); the purge log and the new cache are ConcurrentHashMaps (:292, :297). termFilters is the only plain HashMap in the module's main code that changes while matches read it. Both index types start their own cache-purge thread, so a Monitor is multi-threaded by construction.
  • SearcherManager. The map is filled by a SearcherFactory plugged into a SearcherManager, "Utility class to safely share IndexSearcher instances across multiple threads, while periodically reopening".
  • Upstream treats concurrent register and match as the workload. CHANGES GITHUB#12801: "Remove possible contention on a ReentrantReadWriteLock in Monitor which could result in searches waiting for commits". Its PR body (@daviscook477, merged 2023-11-14) describes latency in Monitor.match "when it is called concurrently with Monitor.register/Monitor.deleteById and QueryIndex.purgeCache" and says commit and search "should be able to execute concurrently".
  • The author's statement. In the original library's tracker (flaxsearch/luwak#174, 2018), Alan Woodward (@romseygeek), who later added the module to Lucene, wrote: "You should only have a single Monitor object, but you can index and query it from multiple threads".
  • Precedent. LUCENE-9791 (#10830, fixed by #8) fixed a different concurrent-match race as a "Luwak/Monitor bug causing registered queries to sometimes fail to match". In that thread Uwe Schindler (@uschindler) stated the rule this map breaks: a built HashMap may be read concurrently, "but never ever change it again".
  • More signals. purgeCache is "normally called from a background thread" (Monitor.java:131); the package docs offer ParallelMatcher and PartitionMatcher "to increase performance in low-concurrency systems" (package-info.java:50-51); upstream TestCachePurging registers queries on a second thread while the test thread purges the cache (TestCachePurging.java:81-127). A null term acceptor has no documented meaning.
  • The limit of the claim. Concurrent register and match do not promise that a given match sees a query being registered; the claim is only that match must not throw because of the Monitor's internal state.
The devil's-advocate check191 words

Strongest case (from the devil's-advocate verifier, the cross-model reviews and the tie-breaks): no Monitor javadoc says "thread-safe"; ConcurrentQueryLoader says "You can use multiple threads to parse and index queries before starting matches" (ConcurrentQueryLoader.java:37-38), which could mean "register first, then match"; and the test's register inside matchQuery is unusual.

Why it fails: that sentence describes an optional startup helper, not a precondition of register or match, and the helper's own workers call monitor.register from several threads; the code, CHANGES, #12801 and the module's author treat concurrent register and match as supported; a read-only Monitor refreshes on its own thread while the application matches, so the application cannot schedule its matches around the refresh; and tests without re-entrant calls, a plain main() program and a debugger-forced interleaving fail the same way. The cross-model reviews that read the code and the devil's-advocate verifier rated the "intended" argument weak. A later cross-model fact check (reading only) rated it moderate: no Monitor javadoc promises thread safety, more than 12 held readers is uncommon (which lowers the urgency, not the defect), and the re-entrant test is a presentation risk that the non-re-entrant tests answer.

Suggested fix

Recommended Make termFilters a ConcurrentHashMap (validated)

Make QueryIndex.termFilters a ConcurrentHashMap (one line plus the import). Its get never sees a half-moved table, and its put and remove are atomic. Validated: the repro passes, and the lucene/monitor suite runs 182 tests with 0 failures (1 skipped), with assertions on and off.

import java.util.concurrent.ConcurrentHashMap;
...
  // package-private for testing
  // concurrent: searcher refreshes put and reader-close listeners remove while matches get
  final Map<IndexReader.CacheKey, QueryTermFilter> termFilters = new ConcurrentHashMap<>();
All options and trade-offs3 options · 393 words

One fix was validated through gradle; the two alternatives were not run.

Make termFilters a ConcurrentHashMap (validated) (recommended)

QueryIndex.java:56: new HashMap<>() becomes new ConcurrentHashMap<>(), plus the import and a one-line comment (1 file, +3/-1). A ConcurrentHashMap keeps the old table published until a resize ends and forwards each moved bin, so a get stays correct during a resize. The put happens in newSearcher before SearcherManager publishes the searcher, so a match that acquired that searcher finds the entry.

import java.util.concurrent.ConcurrentHashMap;
...
  // package-private for testing
  // concurrent: searcher refreshes put and reader-close listeners remove while matches get
  final Map<IndexReader.CacheKey, QueryTermFilter> termFilters = new ConcurrentHashMap<>();

Trade-offs

  • By reading, it also closes the two code-reading-only variants (a lost entry, a leaked filter), since put and remove become atomic; those variants were not run.
  • Cost: a ConcurrentHashMap.get per match instead of a HashMap.get, on a map with one entry per open query-index reader. No benchmark was run.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, OpenJDK 25.0.3: the repro fails on the stock tree with assertions on and off; with the fix applied it passes 3 of 3 iterations (300 rounds) with assertions on and 3 of 3 with them off.
  • :lucene:monitor:test, the whole module: 33 suites, 182 tests, 1 skipped (a @Nightly test), 0 failures, with assertions on and off, including TestQueryTermFilter, TestCachePurging and TestMonitorReadonly. :lucene:monitor:check -x test passes (google-java-format, ecjLint, forbiddenApis) with the test in the tree.
  • Earlier: the same change shadowed onto the jars made the finder's, the independent reproduction's and the tie-breaks' writable and read-only tests pass on main, the intended-behaviour check's writable test pass, and the minimal test pass on 8.2.0; in the debugger check the paused mid-resize match returned normally 4 of 4 times.
  • Not run: the full Lucene suite, nightly tests, benchmarks, x86.

Wrap the map with Collections.synchronizedMap (not run)

Also correct.

Trade-offs

Every match then takes the map's lock, which the purge-lock design tries to avoid for searches.

Drop the map: keep each filter with its searcher (not run)

Create an IndexSearcher subclass in TermsHashBuilder.newSearcher that carries the reader's QueryTermFilter, and read it from the acquired searcher in search.

Trade-offs

No shared mutable state and no close listener, but it changes QueryIndex, both subclasses and TestQueryTermFilter (which reads the map): a larger change for the same effect.

Downstream note

Services built on the monitor that register queries while they match (alerting or saved-search systems) can see an occasional failed match request and should retry it until they carry the fix; the timing comes from their own registrations, not from documents or queries.

How it was found

Code reading in a hunt unit on the monitor's query-index concurrency, planned for model checking. The model checkers at hand cannot explore an unsynchronised HashMap, so the finder confirmed the race with a public-API stress test on the stock jars; later steps reduced it to the two-thread test and forced it with a debugger.

The method in full185 words

Method: non-formal (code reading, then a directed JUnit), 2026-10-27, main a9779ef3c193. The unit planned Lincheck and Fray model checking of the monitor's query index. Fray switches threads only at synchronisation points, and Lincheck's model checking could not run IndexWriter here, so neither can explore a plain HashMap race. The unit's plan carried the map as a lead that earlier hunt notes had recorded without investigating it.

The finder's JUnit (4 matching threads, up to 48 latch-parked slow matches, public API only) failed on main with assertions on (1 of 4,453 matches) and off (1 of 14,049), on 10.5.2 and on 9.12.3; a read-only variant failed on main; on stock jars 42-56 of 100 single rounds failed (4 seeds), 0 of 400 with the fix. A minimisation step on 2026-10-29 replaced it with the two-thread nested test. On 2026-10-30 a tie-break step paused the registering thread with a debugger right after HashMap.resize() published its new table and ran one match in that pause: it threw in 16 of 16 paused resizes, and returned normally before the put, after the registration and with the fix.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (Lucene, the original Luwak library, downstream projects; all of GitHub for the exact NPE text), all 408 open PRs, the JIRA archive, the mailing lists and the web, in three searches from 2026-10-27 to 2026-10-30. Closest: LUCENE-9791 (#10830), a different match race fixed in 2021.

Searches4 searches

Context, not reports: #10830 / LUCENE-9791 (fixed by #8): a race inside one QueryTermFilter's BytesRefHash#find; its report says the filter is "correctly published" and the thread never mentions the map. #10174 / LUCENE-9133 (2020): the same method and exception type, single-threaded (a field without terms). #679 / LUCENE-10422: created TermsHashBuilder and the read-only Monitor; the reviewers discussed a race between the query cache and maybeRefresh(). #14115 (open): a read-only Monitor test flake from a latch timeout, with no NPE. #12801, #12318 and flaxsearch/luwak#174: concurrent match and register as the intended workload. Open PRs #16847 and #13948 edit the same files for other reasons and keep the HashMap.

Search limits: keyword search only; GitHub code search covers default branches only; private channels are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; Luwak, Solr, lucene-solr and two downstream engines; all of GitHub)2026-10-27, 2026-10-30 (twice)a scripted prior-report search, run fresh (32 GitHub, 3 commit and 2 downstream queries of its 91; 385 distinct hits in all), 70 own searches (TermsHashBuilder, termAcceptor, termFilters, QueryIndex race/NPE, Monitor NPE, label:module:monitor, four exact NPE strings), PR searches and the changed files of all 408 open PRs, 3 code searchesno report; 0 high-overlap hits; the exact NPE text has 0 hits; no open PR changes the map; no fixed copy
Mailing lists (dev@, java-user@, issues@, commits@, builds@) and the JIRA archive2026-10-3040 tool and 17 direct list queries; 7 tool and 10 direct JIRA queries over the monitor componentbuilds@: no monitor NPE; monitor JIRA items LUCENE-9791, -10422 and -10675 only, none about the map
Web2026-10-307 searches (NPE text with concurrent register, monitor thread safety, termFilters HashMap race, Luwak NPE, read-only refresh NPE)generic NPE pages, #12318 and older Luwak issues; none is this bug
GitHub refresh2026-10-30termAcceptor (issues), termFilters (PRs), last monitor commit, upstream main QueryIndex.java:55-56nothing; line 56 unchanged on main 2f424fd428da; last monitor commit df6f71a3729a (2026-09-25)

History

Present since the monitor module was added (LUCENE-8766, 251dbe7cea81, 2019-06-13, first released in 8.2.0), with the map inherited from the Luwak library; the read-only Monitor (9.2.0, #679) reuses it. Every release from 8.2.0 to 10.6.0 and main is affected; not fixed upstream.

Versions tested8 rows

LUCENE-8766 (#9811) "Add monitor subproject" (@romseygeek) is 251dbe7cea81 on main and b8773d13ad8e on branch_8x (both 2019-06-13); it was reviewed on apache/lucene-solr#648, closed unmerged because the code was committed directly. Its QueryIndex already had the plain HashMap (:88), the put and the close-listener remove (:146-147) and the unlocked get (:279). The map came from Luwak (its commit f2f3868f54d1, 2015-12-28, "Move QueryTermFilters into QueryIndex"). LUCENE-10422 (#679, be9917895645, 9.2.0) moved the put and remove into TermsHashBuilder and gave the new read-only Monitor the same map: a second trigger path, not the introduction.

Read, not run: all 48 release tags 8.2.0-8.8.1 and 9.0.0-10.5.2 have the same code; for 8.8.2-8.11.4 javap of the released jars shows new java.util.HashMap; the relevant monitor sources in 10.6.0 are byte-identical to 10.5.2. Upstream main 2f424fd428da, branch_11x and branch_10_6 still declare it at QueryIndex.java:56.

Versions and branches executed, with results
Version or commitVerdictResult
8.1.1 and earlier no modulelucene-monitor does not exist
8.2.0 first releasethe minimal test fails with -ea and -da; passes with the fix
8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedthe finder's writable test fails
9.2.0 (read-only path) read-only from herethe read-only test fails (9.1.0 has no read-only API)
10.5.2 affectedthe minimal test fails with -ea and -da; the read-only test fails
10.6.0 (4ae946e5a9b9) newest releasethe minimal test fails with -ea and -da; the read-only test and the debugger check fail
main 645d4fe14a20 affectedfails, gradle and standalone, assertions on and off; passes with the fix
upstream main 2f424fd428da, branch_11x, branch_10_6 source readQueryIndex.java:56 still a plain HashMap (2026-10-30)

Independent checks

The finder, an independent reproducer, a devil's-advocate verifier, an adjudication and three tie-break runs (one forcing the race with a debugger) rate it real. Cross-model reviews agreed on the root cause; the refute-lens attempts could not execute code and returned a procedural "not real". This doc's gradle runs reproduced it and validated the fix.

Checks and verdicts561 words

Every failing run that printed a stack showed the same NPE at TermFilteredPresearcher.java:122. In longer standalone runs the JIT later throws a preallocated NPE with no message or stack (17 of the 20 failures in each standalone 20-iteration run, -ea and -da; also the adjudication's writable -ea run and the independent reproduction's first 9.12.3 run). Re-runs with -XX:-OmitStackTraceInFastThrow showed the same site every time (20 of 20 in the 20-iteration -ea re-run; the independent reproduction's 9.12.3 re-run). The gradle runs printed the full message every time. The natural-workload probe reproduced nothing (0 of 73,064). All executed evidence comes from Claude-family AI tools and separate runs; the refute-lens review by an independent reviewer from another model family (OpenAI GPT-5.6) was attempted three times and never ran Java, so it returned a procedural "not real" each time.

Each independent check and its verdict
CheckVerdictWhat it found
finder (code reading, then a stress JUnit) candidateFails on main (-ea, -da), 10.5.2 and 9.12.3; read-only variant fails; 42-56 of 100 rounds on stock, 0 of 400 with the fix.
cross-model review, refute lens (OpenAI GPT-5.6), 3 attempts procedural "not real"No attempt could execute Java. The two that read the code agreed with the root cause, found the inputs legal and rated the intended argument weak.
Independent reproducer realOwn test in an outside package: writable and read-only fail on main, 10.5.2 and 9.12.3; 11 parked matches pass, 12 fail; the fix passes.
Cross-model review, intended lens (OpenAI GPT-5.6, reading only) real, mediumThe ConcurrentQueryLoader wording documents an optional helper, not a precondition.
Intended-behaviour devil's advocate real, highNo intended or misuse argument survives; natural probe: 39 of 40 Monitors past 12 readers, 0 failures in 73,064 matches.
adjudication confirmed, highRe-ran the independent reproduction's test with a new seed: 2 of 2 fail with -ea and -da, 0 of 2 with the fix.
re-verify on 645d4fe14a20 and tie-breaks 1-2 (2026-10-30) realMinimal test fails on main, 10.5.2 and 10.6.0; a non-re-entrant JUnit and a plain main() program fail, writable and read-only; the 11-searcher control passes; the fix passes.
tie-break 3: debugger-forced interleaving real, highThe registering thread paused right after table = newTab in HashMap.resize(): one match there threw in 16 of 16 paused resizes (main and 10.6.0); 30 of 30 controls and 4 of 4 fixed runs returned normally.
this doc's runs reproduced, fix validatedGradle: fails with assertions on and off; with the fix the repro passes and the module runs 182 tests, 1 skipped, 0 failures.
independent fact check of this doc (executed) reproduced, fix validatedOwn scratch worktree of 645d4fe14a20: the doc's command failed with assertions on and off; with the fix the repro passed 3 of 3 each way and the module ran 182 tests, 1 skipped, 0 failures; nesting depth 11 passed 3 of 3 iterations, depth 12 failed 3 of 3. Its one error (the count of NPEs without a stack) is corrected here.
cross-model fact check (OpenAI GPT-5.6, reading only) 2 errors, fixed; intended: moderateVerified the file:line references, the JDK resize order, the debugger tally and the fix records. Its two errors, both in the brief (a too-general security sentence, an [exec] tag on every release), are fixed, and its overclaim notes are softened in the brief and here.

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,624 words

Finding record

LFV-769, snapshot of 2026-11-06

Registry title
Monitor.match can throw NullPointerException while queries are being registered (or while a read-only Monitor refreshes), because QueryIndex keeps its per-reader term filters in a plain HashMap that the searcher refresh and reader-close listeners modify while match threads read it without a lock
Area
monitor / QueryIndex term-filter map (searcher refresh vs concurrent match, writable and read-only Monitor)
Priority
Low, rank 633: It is a loud one-off NullPointerException in the opt-in Monitor under a race that needs more than 12 query-index readers open at a new peak and failed 0 times in 73k natural matches, so it follows the similar rarely reached race-window row LFV-263.
Impact class
crash
Impact note
Applications of the optional lucene/monitor module that call Monitor.match on some threads while other threads register or delete queries (or while a read-only Monitor's scheduled refresh or purgeCache() opens a new searcher), with the default configuration and presearcher, can get a NullPointerException ('this.val$termAcceptor is null' at TermFilteredPresearcher.java:122) out of one Monitor.match call when the termFilters HashMap resizes during that call's lookup; this needs more than 12 query-index readers open at once (slow in-flight matches still holding older searchers across a dozen or more refreshes) and can happen only when that count reaches a new peak past 12, 24, 48, ... because the map never shrinks; the next match succeeds, no wrong results or corruption were observed, and a natural stock-matcher workload reached the precondition in 39 of 40 Monitors but failed 0 times in 73,064 matches, so the latch-based repros are amplifiers, not a field rate; released 8.2.0-10.6.0 (10.6.0 executed on the release jars).
Summary
You share one Monitor between threads: some threads call Monitor.match on documents while another registers new queries, and each register commits and opens a new query-index searcher, which the Monitor's design for searches during commits allows. You expect every match of a valid document to return its matching queries. Instead, when the internal map from searcher to term filter (a plain java.util.HashMap) grows, a concurrent match can look up its own searcher's entry and get null, and Monitor.match throws NullPointerException from TermFilteredPresearcher.buildQuery; the read-only Monitor fails the same way while its refresh runs.
Introduced
251dbe7cea8126cb7ecadc921ba444c63d06c618 (main, 2019-06-13, 'LUCENE-8766: Add monitor subproject', by GitHub romseygeek), with the branch_8x twin b8773d13ad8e249a045862249bf476c30a444461 (same message and date); LUCENE-8766 is GitHub #9811, reviewed on apache/lucene-solr#648, which was closed unmerged because the code was committed directly. Its QueryIndex already had the whole race: the plain HashMap (QueryIndex.java:88), the put and the addClosedListener(termFilters::remove) in the SearcherFactory (:146-147), and the unlocked get in search (:279), with commit() holding the purge read lock only in shared mode around maybeRefresh(). The map came with the code donated from the Luwak library (Luwak commit f2f3868f54d1, 2015-12-28, 'Move QueryTermFilters into QueryIndex'). LUCENE-10422 / #679 (be9917895645 on main, 72cf36a2b8d8 on branch_9x, first released in 9.2.0) moved the put and remove into TermsHashBuilder and gave the new read-only Monitor the same map: a second trigger path, not the introduction. [read: git log -S termFilters and blame -C -C -M over lucene/monitor in the full history clone; exec at release granularity, see affected_versions]. Latent since the module was added, not a regression. (2019-06-13 (251dbe7cea8 on main and b8773d13ad8 on branch_8x); read-only path 2022-03-21 (#679))
First affected version
8.2.0 [exec] (the first lucene-monitor release; 8.1.1 has no monitor module, lucene-monitor 8.1.1 is not on Maven Central). Read-only Monitor path: 9.2.0 [exec] (the read-only API does not exist in 9.1.0)
Affected versions
Every release that ships lucene-monitor, 8.2.0 to 10.6.0 (10.6.0 [exec] on the release jars in the doc's tie-break checks), plus main a9779ef3c193. [exec] the finder's writable JUnit fails on 8.2.0, 8.11.4 and 9.0.0 (there with only the LuceneTestCase import changed), 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); its read-only JUnit fails on 9.2.0, 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's replays fail on main (-ea and -da) and 10.5.2; 8.2.0 with the ConcurrentHashMap fix shadowed passes 200 rounds. [read] the same plain HashMap, put, closed-listener remove and unlocked get at every release tag 8.2.0-8.8.1 and 9.0.0-10.5.2 in the history clone (48 tags); the released 8.8.2-8.11.4 jars construct termFilters as java.util.HashMap (javap). Not affected: 8.1.1 and earlier (no monitor module); the read-only path does not exist before 9.2.0. Not fixed upstream: on upstream main f7bcc0abd816 (2026-10-27) QueryIndex, TermsHashBuilder, WritableQueryIndex and ReadonlyQueryIndex are byte-identical to a9779ef3c193, branch_10x 657014b3c579 and branch_10_6 4ae946e5a9b9 keep the plain HashMap, and no open PR changes the map. Each version was run once with a latch amplifier, so its failed/total match counts measure neither field frequency nor differences between versions. Not executed: x86; 8.3.0-8.11.3, 9.1.0 (writable), 9.3.0-9.12.2 and 10.1.0-10.5.1 (by reading only).
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
non-formal: code reading; also reached by stress
Upstream and prior reports
none found (searched 2026-10-27, the finder's scripted prior-report search and the follow-up step, read-only; the prior-report record): two scripted prior-report searches (the follow-up run: 16 GitHub issue and PR queries, 3 commit, 2 downstream-repository, 20 list (dev@, java-user@, issues@, commits@), 4 JIRA and 6 web queries, 351 distinct hits with 1 high overlap, #10830, which is not this bug; the finder's run: 45 non-web queries, 0 high), 70 GitHub GraphQL searches (every class and identifier, symptom words, label:module:monitor, flaxsearch/luwak, apache/solr, apache/lucene-solr and two downstream search engines, and all of GitHub for the exact NPE text: 0 hits), 4 code searches (no copy with termFilters = new ConcurrentHashMap), the file lists of all 408 open apache/lucene PRs (only #13948 touches the index classes, for its binary-value API; the heads of #365, #13949 and #16847 keep the HashMap), 23 issues and PRs read in full with their comments, 40 direct list queries (termAcceptor: 0 hits on every list; the monitor-test CI mails show no NullPointerException), 13 JIRA queries over the modules/monitor component, and the Luwak tracker and history. Not fixed upstream (main f7bcc0abd816, branch_10x, branch_10_6). Nearest items, not reports of this bug: #10830 / LUCENE-9791 (closed 2021, fixed by apache/lucene#8; a different concurrent-match race inside one QueryTermFilter's BytesRefHash#find scratch buffer, whose discussion never mentions the map but states the rule it breaks: concurrent lookups only once a map is built, 'but never ever change it again'), #10174 / LUCENE-9133 (closed 2020; a single-threaded NPE in buildQuery for a field without terms), #679 / LUCENE-10422 (created the read-only Monitor and TermsHashBuilder; the reviewers discussed a different race), #12801 (match latency behind commits; documents concurrent register and match as the workload), #12318 and luwak#174 (contract context: one Monitor may be indexed and queried from multiple threads), luwak#68 (other races, fixed before the map existed). Gaps: keyword search only; private reports to security@lucene.apache.org and chat channels are not visible; the Stack Exchange API was rate-limited (Stack Overflow covered only through the web index); one planned web query was throttled.
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT, 2026-10-28): the repro test through gradle in a scratch worktree fails with assertions on and off; with the fix it passes (3 of 3 iterations each way), the whole lucene/monitor suite runs 182 tests, 1 skipped, 0 failures with assertions on and off, and :lucene:monitor:check -x test passes. Also the standalone runner with -ea and -da and with the fixed class shadowed, and a debugger-forced interleaving (16 of 16 paused resizes throw, 30 of 30 controls and 4 of 4 fixed runs return normally). Release jars: the minimal test fails on 10.6.0 (4ae946e5a9b9), 10.5.2 and 8.2.0 (and passes on 8.2.0 with the fix); earlier tests fail on 8.11.4, 9.0.0, 9.12.3 and 10.0.0, and read-only tests on 9.2.0, 9.12.3, 10.5.2 and 10.6.0. The hunt ran on main a9779ef3c193; lucene/monitor is unchanged since. Upstream main 2f424fd428da was read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryIndex.java :55-56 (termFilters = new HashMap<>(), 'package-private for testing'); TermsHashBuilder.java :33-41 (newSearcher: put :38, addClosedListener(termFilters::remove) :39); WritableQueryIndex.java :107-131 (commitWithoutNotify: commitLock, purgeLock.readLock, maybeRefresh :128), :209-223 (search: read lock released before the unlocked termFilters.get :221-223), :280-290 (design comment, 'commit is slow and search cannot be slow'); ReadonlyQueryIndex.java :48-64 (TermsHashBuilder on the same map, scheduled refresh executor), :80-82 (unlocked get); TermFilteredPresearcher.java :100-127 (termAcceptor.test at :122); Monitor.java :129-139 (purgeCache javadoc: 'normally called from a background thread'); ConcurrentQueryLoader.java :38 ('before starting matches')
The finding in 30 seconds
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryIndex.java:55-56, :62-81
  • lucene/monitor/src/java/org/apache/lucene/monitor/TermsHashBuilder.java:34-41
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:79, :102-133, :206-246, :338-351
  • lucene/monitor/src/java/org/apache/lucene/monitor/ReadonlyQueryIndex.java:48, :51-64, :73-111
  • lucene/monitor/src/java/org/apache/lucene/monitor/TermFilteredPresearcher.java:100-127; lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:56-58, :218-227; lucene/monitor/src/java/org/apache/lucene/monitor/Presearcher.java:36-41; lucene/monitor/src/java/org/apache/lucene/monitor/MultipassTermFilteredPresearcher.java:55
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:129-133 (CacheKey, identity hash), :215-227 (decRef runs the closed listeners)
Who is affected
  • lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:36-37 (purge period 5 minutes), :40 (readOnly false by default)
  • lucene/monitor/src/java/org/apache/lucene/monitor/MultipassTermFilteredPresearcher.java:55; lucene/monitor/src/java/org/apache/lucene/monitor/TermFilteredPresearcher.java:100 (final buildQuery)
Why it looks unintended
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:70, :78, :85-98, :276-297; lucene/monitor/src/java/org/apache/lucene/monitor/ReadonlyQueryIndex.java:51-64
  • lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:27-29
  • lucene/CHANGES.txt:3203-3204 (GITHUB#12801), :5403-5405 (LUCENE-9791)
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:131; lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:50-51; lucene/monitor/src/java/org/apache/lucene/monitor/ConcurrentQueryLoader.java:37-38, :141
  • lucene/monitor/src/test/org/apache/lucene/monitor/TestCachePurging.java:81-127
Suggested fix
  • lucene/monitor/src/test/org/apache/lucene/monitor/TestQueryTermFilter.java:38-48 (reads the map); lucene/monitor/src/test/org/apache/lucene/monitor/TestMonitor.java:134-135 (@Nightly testUpdateReporting)
Diagrams
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryIndex.java:56; lucene/monitor/src/java/org/apache/lucene/monitor/TermsHashBuilder.java:38-39; lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:221-223

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, OpenJDK 25.0.3's HashMap.java, a single-thread probe of the map's size and table length on the stock jars, and the minimal repro's results. Bucket positions are illustrative (identity hash codes decide them), and the drawn interleaving is one of those that lose the entry.

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.