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.
- A
Monitorstores its registered queries in a small index of its own, the query index. Everyregister,deleteByIdorclearcommits it and opens a new searcher; a match that is still running keeps the searcher it started with open until it finishes. - 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.
java.util.HashMapmust 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.
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.- Share one default
Monitorbetween threads. Thread A callsmatchon 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. - 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'sregisteropens searcher 13 and adds the 13th entry. - You expect every match to finish: the Monitor is built so that searches run during commits, and
registerandmatchneed no lock of yours. - Instead the
HashMapfirst installs a new, empty 32-bucket table and only then moves the 13 entries over. A match that looks up its searcher in between getsnull, andMonitor.matchthrowsNullPointerException: ... "this.val$termAcceptor" is nullatTermFilteredPresearcher.java:122. - 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.
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.
- 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 theirSearcherManagerwithnew TermsHashBuilder(termFilters)(WritableQueryIndex.java:79,ReadonlyQueryIndex.java:48). - Writers.
TermsHashBuilder.newSearcherruns on the refreshing thread:termFilters.put(reader.getReaderCacheHelper().getKey(), new QueryIndex.QueryTermFilter(reader));(TermsHashBuilder.java:38) andreader.getReaderCacheHelper().addClosedListener(termFilters::remove);(:39). The writable refresh ismanager.maybeRefresh()incommitWithoutNotify(WritableQueryIndex.java:128), reached fromregister(commit,:102-105),deleteByIdandclear(:338-351). The read-only refresh ismanager.maybeRefresh()inpurgeCache()(ReadonlyQueryIndex.java:107-111), called by the application or every purge period by the index's owncache-purgethread (:51-64; default 5 minutes). Theremoveruns on whichever thread drops the last reference to an old reader, often a match thread releasing its searcher. - The unlocked reader.
WritableQueryIndex.searchholds the purge read lock only aroundmanager.acquire()(:211-217), then callstermFilters.get(searcher.getIndexReader().getReaderCacheHelper().getKey())with no lock (:221-223);ReadonlyQueryIndex.searchtakes no lock at all (:73-82). The commit holdscommitLockand the read lock (:109-110), the same shared mode thatsearchtakes, so nothing orders theputagainst theget. - Why the get returns null. A default
HashMaphas 16 buckets and load factor 0.75, so a resize threshold of 12;putValresizes when++size > threshold. OpenJDK 25'sHashMap.resize()publishes the new array withtable = newTab;(HashMap.java:712) and only then moves each old bucket, clearing it (oldTab[j] = null;,:717). A lock-freegetin 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. - Where it throws.
Monitor.matchcallsqueryIndex.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 infilterFieldswithtermAcceptor.test(field.name, termAtt.getBytesRef())(TermFilteredPresearcher.java:120-122, reached from the token loop at:127);termAcceptoris thenull. A batch with no such term never reads it and does not throw.MultipassTermFilteredPresearcheris a subclass;Presearcher.NO_FILTERINGignores the acceptor.
- In practice a resize happens when a
putgrows 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. AHashMapnever shrinks, so after the first resize the next needs a new peak past 24, then 48. (A smallHashMapalso 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.
getQueryandscanalso callsearch, but with a builder that ignores the filter (QueryIndex.java:62-81), so anullthere is harmless.- By code reading only, not executed: a reader-close
removeracing a refreshputin the same bucket could drop the new reader's entry, and two concurrent removals could leak a filter. By reading, the fix's atomicputandremovewould close both; neither variant was run.
Exposure and likelihood231 words
- Who is exposed. Applications that share one
Monitorbetween matching threads and threads that register, delete or clear queries, with the default configuration and presearcher (alsoMultipassTermFilteredPresearcher, a subclass). Read-only Monitors (a read-only configuration with a directory) fail the same way when a refresh, explicitpurgeCache()or scheduled, runs while the application matches: it was executed with explicitpurgeCache()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.matchcall throwsNullPointerException; 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 aHashMapgives 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.
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.
/*
* 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;
}
});
}
}
cd $LUCENE && ./gradlew :lucene:monitor:test --tests org.apache.lucene.monitor.TestMonitorMatchDuringRegister -Ptests.seed=DEADBEEF
- Expected
- No
Monitor.matchcall throws while another thread registers queries. - Observed
NullPointerException: ... "this.val$termAcceptor" is nullatTermFilteredPresearcher.java:122, thrown out ofMonitor.match, with assertions on and off.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run) |
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)
# 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)- 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". HashMapneeds external synchronisation here; an earlier concurrent-match race, inside each term filter'sBytesRefHash, 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 avolatile ConcurrentMapbuilt asnew ConcurrentHashMap<>()(:70,:78); the purge log and the new cache areConcurrentHashMaps (:292,:297).termFiltersis the only plainHashMapin the module's main code that changes while matches read it. Both index types start their owncache-purgethread, so a Monitor is multi-threaded by construction. SearcherManager. The map is filled by aSearcherFactoryplugged into aSearcherManager, "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 inMonitor.match"when it is called concurrently withMonitor.register/Monitor.deleteByIdandQueryIndex.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
HashMapmay be read concurrently, "but never ever change it again". - More signals.
purgeCacheis "normally called from a background thread" (Monitor.java:131); the package docs offerParallelMatcherandPartitionMatcher"to increase performance in low-concurrency systems" (package-info.java:50-51); upstreamTestCachePurgingregisters queries on a second thread while the test thread purges the cache (TestCachePurging.java:81-127). Anullterm acceptor has no documented meaning. - The limit of the claim. Concurrent
registerandmatchdo not promise that a given match sees a query being registered; the claim is only thatmatchmust 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.
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.
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
putandremovebecome atomic; those variants were not run. - Cost: a
ConcurrentHashMap.getper match instead of aHashMap.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@Nightlytest), 0 failures, with assertions on and off, includingTestQueryTermFilter,TestCachePurgingandTestMonitorReadonly.:lucene:monitor:check -x testpasses (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.
Also correct.
Trade-offs
Every match then takes the map's lock, which the purge-lock design tries to avoid for searches.
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| 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 searches | no 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 archive | 2026-10-30 | 40 tool and 17 direct list queries; 7 tool and 10 direct JIRA queries over the monitor component | builds@: no monitor NPE; monitor JIRA items LUCENE-9791, -10422 and -10675 only, none about the map |
| Web | 2026-10-30 | 7 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 refresh | 2026-10-30 | termAcceptor (issues), termFilters (PRs), last monitor commit, upstream main QueryIndex.java:55-56 | nothing; line 56 unchanged on main 2f424fd428da; last monitor commit df6f71a3729a (2026-09-25) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.1.1 and earlier | no module | lucene-monitor does not exist |
| 8.2.0 | first release | the minimal test fails with -ea and -da; passes with the fix |
| 8.11.4, 9.0.0, 9.12.3, 10.0.0 | affected | the finder's writable test fails |
| 9.2.0 (read-only path) | read-only from here | the read-only test fails (9.1.0 has no read-only API) |
| 10.5.2 | affected | the minimal test fails with -ea and -da; the read-only test fails |
10.6.0 (4ae946e5a9b9) | newest release | the minimal test fails with -ea and -da; the read-only test and the debugger check fail |
main 645d4fe14a20 | affected | fails, gradle and standalone, assertions on and off; passes with the fix |
upstream main 2f424fd428da, branch_11x, branch_10_6 | source read | QueryIndex.java:56 still a plain HashMap (2026-10-30) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder (code reading, then a stress JUnit) | candidate | Fails 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 | real | Own 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, medium | The ConcurrentQueryLoader wording documents an optional helper, not a precondition. |
| Intended-behaviour devil's advocate | real, high | No intended or misuse argument survives; natural probe: 39 of 40 Monitors past 12 readers, 0 failures in 73,064 matches. |
| adjudication | confirmed, high | Re-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) | real | Minimal 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, high | The 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 validated | Gradle: 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 validated | Own 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: moderate | Verified 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 633 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,624 words
- 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 wholelucene/monitorsuite runs 182 tests, 1 skipped, 0 failures with assertions on and off, and:lucene:monitor:check -x testpasses. Also the standalone runner with-eaand-daand 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 maina9779ef3c193;lucene/monitoris unchanged since. Upstream main2f424fd428dawas read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.
- 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-81lucene/monitor/src/java/org/apache/lucene/monitor/TermsHashBuilder.java:34-41lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:79, :102-133, :206-246, :338-351lucene/monitor/src/java/org/apache/lucene/monitor/ReadonlyQueryIndex.java:48, :51-64, :73-111lucene/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:55lucene/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-64lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:27-29lucene/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, :141lucene/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
- TestMonitorMatchDuringRegister.java (one test, public API):
repro/LFV-769/TestMonitorMatchDuringRegister.java(91 lines)
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.
assets/LFV-769/bug.png(633 KB)
