Both wrappers run each query on a worker thread inside catch (IOException e). Any other exception ends the worker, and finish() turns it into RuntimeException("Interrupted during match"): Monitor.match returns nothing. ParallelMatcher also hands queries to its workers through a 1,024-slot queue with a blocking put; once all workers are dead, nobody empties it.
- A
Monitorstores registered queries. For each document, a presearcher picks the queries that might match; a query with no terms, such as a point query, is picked for every document. - A matcher then runs each picked query against the document. The default matchers do this one query at a time; if a query throws, the Monitor records the error in
getErrors()and carries on with the next query. PartitionMatcherandParallelMatcherwrap such a matcher to run the queries on several threads; the package docs offer them to increase performance.
int and keeps the match of long, while under a wrapper the exception escapes the worker and the whole match fails. Select the still to open it at full size. Generated with AI, checked against the code.- Register two queries in a
Monitor:long=LongPoint.newExactQuery("num", 5)andint=IntPoint.newExactQuery("num", 5). Match one document with aLongPointfieldnum= 5, usingPartitionMatcherwith 2 threads, which the docs offer to speed up matching. - You expect what the default serial matcher returns for the same input: 1 match (
long), and the error ofint(the field has 8-byte points, the query 4-byte ones) ingetErrors(). - Instead
Monitor.matchthrowsRuntimeException: Interrupted during match, and the match oflongis lost too. The worker threads catch onlyIOException; the serial path catches everyException. - With
ParallelMatcherit can be worse: a dead worker stops emptying a 1,024-slot queue. With 1 thread, 1 failing and 1,100 good queries,Monitor.matchwas still blocked after 30 s, and stopping it took 77 interrupts.
Applications that match with ParallelMatcher or PartitionMatcher (opt-in wrappers of the optional monitor module) and have a registered query that can throw while it runs: a point-width mismatch, a query over IndexSearcher's clause limit, a custom Query. One such query loses the whole batch at any thread count; the hang needs as many failing queries as threads.
Mechanism, terms and numbers595 words
Monitor.match runs every query the presearcher selects through a CandidateMatcher. The default serial path wraps each query in catch (Exception e) { matcher.reportError(id, e); }, so a query that throws is reported in getErrors() and the other matches are returned. The package docs offer PartitionMatcher and ParallelMatcher as wrappers "to increase performance in low-concurrency systems". Their workers catch only IOException. An unchecked exception, such as the IllegalArgumentException an IntPoint query throws on a field indexed as LongPoint, ends the worker; doFinish turns it into RuntimeException("Interrupted during match"), and Monitor.match throws and returns nothing, with a message that names neither the query nor the cause. ParallelMatcher also feeds its workers through a bounded queue of 1,024 tasks with an untimed put: once every worker has died and the queue fills, Monitor.match blocks forever, and each interrupt frees only one blocked put (the recorded runs needed 77 to 79). The code came with the module (LUCENE-8766, 2019); all 58 releases from 8.2.0 to 10.6.0 and main are affected.
- Serial path, the contract.
Monitor.matchfeeds each selected query throughStandardQueryCollector.matchQueryand then callsmatcher.finish(...)(Monitor.java:218-226). The collector catchesExceptionand callsmatcher.reportError(id, e)(Monitor.java:297-302).CandidateMatcher.reportError(String, Exception)is "Called by the Monitor if running a query throws an Exception" (CandidateMatcher.java:97-100);getErrors()returns aMap<String, Exception>. - PartitionMatcher.
matchQueryonly queues the task (PartitionMatcher.java:70-73), so the collector's catch never sees a failure.doFinishsplits the tasks intothreadspartitions and runs them withexecutor.invokeAll(:81-90). Each worker wraps each query incatch (IOException e)only (:116-121). An unchecked exception escapescall(),future.get()(:91) throwsExecutionException, and that becomesRuntimeException("Interrupted during match", e)(:99-100).Monitor.matchpropagates it: no result at all. - ParallelMatcher. The constructor submits
threadsworkers (ParallelMatcher.java:69-72; the two-argumentfactoryusesavailableProcessors(),:180-184).matchQueryputs each task on aLinkedBlockingQueue<>(1024)with an untimedput(:49,:76-84). Each worker loops onqueue.take()and catches onlyIOException(:124-138, catch at:130).doFinishputs oneENDper worker (:94-96) and callsfuture.get()(:99), wrappingExecutionExceptioninRuntimeException("Interrupted during match")(:110-111). - The hang. When every worker has died, nobody takes from the queue. After 1,024 queued tasks the next
put(:80) blocks with no timeout (or, if only theENDmarkers overflow it,doFinish'sput(END)at:95does). An interrupt turns that oneputintoIOException("Interrupted during match")(:81-82), which the collector records as that query's error (Monitor.java:300-301), and the nextputblocks again. With 1 thread, 1 failing query first and 1,100 good ones: 1,100 - 1,024 = 76 blocked puts plus the finalput(END)= 77 interrupts, as observed. - Precondition of the hang. With 2 threads, 2 failing queries plus 1,100 good ones hang; with 1 failing query (one worker survives and empties the queue) or with only 100 good ones,
Monitor.matchthrows without hanging.QueryDecomposersplits a plain disjunction (noMUSTorFILTERclause,minimumNumberShouldMatchat most 1) into one candidate per distinctSHOULDclause; a conjunction orminimumNumberShouldMatch> 1 stays whole, and equal clauses collapse into one candidate (QueryDecomposer.java:77-103). So one registered disjunction withthreadsdistinct failing clauses is enough. - Only the exception type matters. Both wrappers report a checked
IOExceptionfrom a query per query and return the other matches; the same failure thrown asUncheckedIOExceptionloses the batch (tie-break probe).
Exposure and likelihood280 words
- Who is hit. Applications that match with
ParallelMatcherorPartitionMatcherfrom the optionallucene-monitormodule. The default serial matchers are not affected. - Ordinary triggers, all accepted by
Monitor.registerand all reported per query by the serial matcher: a point query whose width does not match the indexed field (it depends on the documents, so it matters where documents and queries come from independent producers); a query that passes eachBooleanQuerybuilder but exceedsIndexSearcher's clause limit at rewrite (two 601-clause disjunctions in a conjunction giveTooManyNestedClauses; one classicQueryParserstring with default settings is enough); a customQuerythat throws aRuntimeException; anUncheckedIOException. - Lost batch. One failing query makes the whole
Monitor.matchcall throw, at any thread count, with assertions on or off: every match of every document in the batch is lost, and "Interrupted during match" hides the cause (it is only in the cause chain) and the failing query id (nothing names it). A term-less point query is selected for every document, so it breaks every match whose document has the field with the other point width. - Hang (
ParallelMatcheronly). Needs at leastthreadsfailing candidates in one batch (defaultthreads=availableProcessors()) and then roughly 1,024 or more candidates still to queue. The matching thread then blocks forever, and one interrupt (whatFuture.cancel(true)sends) does not free it. - Reach. Rare. Code search found no use of the two wrappers in Elasticsearch, OpenSearch or Solr (Solr's open monitor-integration PR uses its own matcher); private deployments are not visible; nothing has been reported in seven years.
- No data damage. The call fails loudly or blocks; the query index is not changed.
One JUnit test in lucene/monitor: two point queries, one document, PartitionMatcher with 2 threads. It asserts what the serial matcher returns for the same input. Public API only, deterministic, fails 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.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.IntPoint;
import org.apache.lucene.document.LongPoint;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.NamedThreadFactory;
public class TestPartitionMatcherUncheckedQueryError extends LuceneTestCase {
public void testUncheckedQueryErrorIsReportedPerQuery() throws Exception {
Document doc = new Document();
doc.add(new LongPoint("num", 5));
try (ExecutorService executor =
Executors.newFixedThreadPool(2, new NamedThreadFactory("matchers"));
Monitor monitor = new Monitor(new StandardAnalyzer())) {
monitor.register(
new MonitorQuery("long", LongPoint.newExactQuery("num", 5)),
new MonitorQuery("int", IntPoint.newExactQuery("num", 5)));
MatchingQueries<QueryMatch> matches;
try {
matches =
monitor.match(doc, PartitionMatcher.factory(executor, QueryMatch.SIMPLE_MATCHER, 2));
} catch (RuntimeException e) {
// the default serial matcher returns the match of "long" and reports "int" in getErrors()
throw new AssertionError(
"expected 1 match and an error for query \"int\", but Monitor.match threw " + e, e);
}
assertEquals(1, matches.getMatchCount());
assertTrue(matches.getErrors().containsKey("int"));
}
}
}
cp $REPRO/TestPartitionMatcherUncheckedQueryError.java $LUCENE/lucene/monitor/src/test/org/apache/lucene/monitor/
cd $LUCENE && ./gradlew :lucene:monitor:test --tests org.apache.lucene.monitor.TestPartitionMatcherUncheckedQueryError -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
- Expected
- 1 match (
long) and the error ofintingetErrors(), as the serial matcher returns; with the fix that is the result. - Observed
Monitor.matchthrowsRuntimeException: Interrupted during match(cause:IllegalArgumentException ... bytesPerDim=8 but this query has bytesPerDim=4), and no match is returned.
More files, environment, full output417 words
Public API only (the test sits in org.apache.lucene.monitor for locality; nothing package-private is used), one method, no randomness of its own; a second seed fails the same way. An executor is part of the API under test (PartitionMatcher always runs its partitions on one); the two threads are the test's choice. The pool is built with Lucene's NamedThreadFactory, as the forbidden-API check requires (it rejects Executors.newFixedThreadPool(int)). The serial matcher returns exactly what the test asserts (1 match and the error of int), and with the fix PartitionMatcher does too.
It covers PartitionMatcher only. ParallelMatcher has the identical catch, and the same fix covers it; its lost batch and its hang were shown by a separate probe (serial control, PartitionMatcher(4), ParallelMatcher(4), and the 4-thread hang with one 4-clause failing query plus 1,100 good ones, bounded at 20 s with an interrupt loop), which is evidence, not a regression test.
Suggested regression test: this same test, as its own class or as one method in TestPartitionMatcher. With the fix it passes, and so does :lucene:monitor:check (format check, ecj lint, forbidden APIs and the whole suite).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.5.2 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run; pure Java logic) |
# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true (the same with false); excerpt, the cut marked
TestPartitionMatcherUncheckedQueryError > testUncheckedQueryErrorIsReportedPerQuery FAILED
java.lang.AssertionError: expected 1 match and an error for query "int", but Monitor.match threw java.lang.RuntimeException: Interrupted during match
at __randomizedtesting.SeedInfo.seed([DEADBEEF:E125866324F54F3E]:0)
at org.apache.lucene.monitor.TestPartitionMatcherUncheckedQueryError.testUncheckedQueryErrorIsReportedPerQuery(TestPartitionMatcherUncheckedQueryError.java:45)
Caused by:
java.lang.RuntimeException: Interrupted during match
at org.apache.lucene.monitor.PartitionMatcher.doFinish(PartitionMatcher.java:100)
at org.apache.lucene.monitor.CandidateMatcher.finish(CandidateMatcher.java:106)
at org.apache.lucene.monitor.Monitor.match(Monitor.java:225)
at org.apache.lucene.monitor.Monitor.match(Monitor.java:242)
at org.apache.lucene.monitor.TestPartitionMatcherUncheckedQueryError.testUncheckedQueryErrorIsReportedPerQuery(TestPartitionMatcherUncheckedQueryError.java:42)
Caused by:
java.util.concurrent.ExecutionException: java.lang.IllegalArgumentException: field="num" was indexed with bytesPerDim=8 but this query has bytesPerDim=4
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:124)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:193)
at org.apache.lucene.monitor.PartitionMatcher.doFinish(PartitionMatcher.java:91)
... 4 more
Caused by:
java.lang.IllegalArgumentException: field="num" was indexed with bytesPerDim=8 but this query has bytesPerDim=4
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.PointRangeQuery.checkValidPointValues(PointRangeQuery.java:641)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.PointRangeQuery.rewrite(PointRangeQuery.java:548)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.ConstantScoreQuery.rewrite(ConstantScoreQuery.java:44)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.rewrite(IndexSearcher.java:873)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.rewrite(IndexSearcher.java:887)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:753)
at org.apache.lucene.monitor.CollectingMatcher.matchQuery(CollectingMatcher.java:43)
at org.apache.lucene.monitor.PartitionMatcher$MatcherWorker.call(PartitionMatcher.java:118)
at org.apache.lucene.monitor.PartitionMatcher$MatcherWorker.call(PartitionMatcher.java:104)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
at java.base/java.lang.Thread.run(Thread.java:1474)
[... gradle's copy of the same trace and the test-environment notes cut ...]
1 test completed, 1 failed
# the same test with the fix (both workers catch Exception), asserts on and off: :lucene:monitor:test (SUCCESS): 1 test
# ParallelMatcher hang, supporting probe on main, assertions off (not the minimal test; one frame cut, marked)
ParallelMatcher(1 thread, 1 failing + 1100 good) = BUG: HANG, Monitor.match still blocked after 30 s in
java.util.concurrent.LinkedBlockingQueue.put <- org.apache.lucene.monitor.ParallelMatcher.matchQuery
<- org.apache.lucene.monitor.Monitor$StandardQueryCollector.matchQuery <- [...]; stopped after 77 interrupts- The Monitor's serial collector catches every
Exceptionand reports it per query;reportErroris "Called by the Monitor if running a query throws an Exception". - Both wrappers already report a checked
IOExceptionper query; the same failure asUncheckedIOExceptionloses the batch. - The docs offer the wrappers only for performance; "Interrupted during match" is raised when nothing was interrupted.
The full argument256 words
- The serial path reports any
Exceptionper query. The collector that drives every matcher catchesException(Monitor.java:300);reportErrortakes anExceptionand its javadoc says it is "Called by the Monitor if running a query throws an Exception" (CandidateMatcher.java:97);getErrors()is aMap<String, Exception>. No javadoc states this as a guarantee of the wrappers; it is the behaviour of the code that drives them. All of this dates from the module's first commit. - The wrappers report per query too, but for one exception type only. Both workers catch and report an
IOExceptionper query (PartitionMatcher.java:119-120,ParallelMatcher.java:130-131), and that works; the same failure asUncheckedIOExceptionloses the batch. - Documented as a performance option. The package docs (
package-info.java:50-51) and the class javadocs discuss only performance trade-offs; nothing says one failing query aborts the batch or that the caller may block. - The module's author stated the intent in the predecessor project. On Luwak #3 (2014), about a query that threw an unchecked exception, they wrote that "this should be dealt with by the error-handling code now ... it will add an error to the final DocumentMatches object". Luwak #1 ("Aborting search after single query fail") led to the per-query error handling. The concurrent matchers came later and never got the same catch.
- The message shows the path was not foreseen: "Interrupted during match" when nothing was interrupted.
- Tests. The shared concurrent-matcher tests cover only the no-error path; the two error tests (
TestMonitorErrorHandling.testMonitorErrors,TestHighlightingMatcher.testQueryErrors) use serial matchers.
The devil's-advocate check141 words
The strongest case for "not a bug" (the devil's-advocate verifier and the cross-model reviews): CandidateMatcher.matchQuery declares only @throws IOException on IO errors, and both upstream error tests use a query that throws an IOException, so perhaps only I/O errors are meant to be isolated and a RuntimeException is a programming error that should propagate; core also throws IllegalArgumentException for a point-width mismatch on purpose.
Why it fails: throws IOException is the checked-exception declaration Java requires, and the code that calls every matchQuery, the Monitor's collector, catches Exception. Nobody disputes that core throws; the question is how the monitor reports it, and its serial path reports it per query. Even on the narrowest reading (let unchecked exceptions propagate), the misleading message stays a defect, and a matching thread that blocks forever is no defensible outcome. Verdict: not intended.
Recommended A. Catch Exception in both workers (recommended)
Catch Exception instead of IOException in both workers, as the serial collector does; PartitionMatcher reports under a lock, because its workers share one error map. 2 files, +5/-4 lines. The repro, the whole monitor test suite (182 tests) and :lucene:monitor:check pass. An Error can still kill a ParallelMatcher worker; option B would cover that.
// ParallelMatcher.MatcherWorker.call
} catch (Exception e) { // was: IOException
matcher.reportError(task.id, e);
}
// PartitionMatcher.MatcherWorker.call
} catch (Exception e) { // was: IOException
synchronized (PartitionMatcher.this) {
PartitionMatcher.this.reportError(task.queryId, e);
}
}
// and drop PartitionMatcher's now unused import java.io.IOExceptionAll options and trade-offs4 options · 548 words
Option A was validated with gradle in a scratch worktree of 645d4fe14a20; options B and C were not built.
In ParallelMatcher.MatcherWorker.call and PartitionMatcher.MatcherWorker.call, catch Exception instead of IOException and report it per query, as Monitor's collector does. In PartitionMatcher the report goes into the outer matcher's HashMap, which several workers can write at once, so it is wrapped in synchronized (PartitionMatcher.this); the now unused java.io.IOException import goes (the format check rejects it otherwise).
// ParallelMatcher.MatcherWorker.call
} catch (Exception e) { // was: IOException
matcher.reportError(task.id, e);
}
// PartitionMatcher.MatcherWorker.call
} catch (Exception e) { // was: IOException
synchronized (PartitionMatcher.this) {
PartitionMatcher.this.reportError(task.queryId, e);
}
}
// and drop PartitionMatcher's now unused import java.io.IOExceptionTrade-offs
- Same behaviour as the serial path: the failing query is in
getErrors(), all other matches are returned. The changed code (the wider catch and the lock) is reached only when a query throws; the cost was not measured. - Not covered: an
Error(for example anAssertionErrorunder-ea, orStackOverflowError) still ends a worker. ForPartitionMatcherthat matches the serial path, which does not catchErroreither; forParallelMatcherenough such workers can still produce the hang (option B). - The lock guards a race that exists today on the
IOExceptionpath; it has no repro and is not claimed separately.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20with the repro test added, seed DEADBEEF. Stock: the repro fails with-Ptests.asserts=trueandfalse, and the whole:lucene:monitor:testsuite runs 182 tests (33 classes) with exactly 1 failure, the repro, and 1 skipped. With the fix: the repro passes with assertions on and off; the whole suite passes (182 tests, 0 failures, 1 skipped);checkGoogleJavaFormatandecjLintMainof the monitor module pass. - With the revised test (its pool built with
NamedThreadFactory): the repro passes with assertions on and off, and:lucene:monitor:checkpasses (format check, ecj lint and forbidden APIs of main and test sources, and the suite: 182 tests, 0 failures, 1 skipped). - The same catch change, shadowed into the stock jars, also passed the independent verifier's 7 tests, the devil's-advocate probe (4 of 4: its 4-thread hang test then returns) and the tie-break probe (7 of 7: the 1-thread case returns 1,100 matches plus the error).
Let ParallelMatcher.MatcherWorker.call catch Throwable around one task, remember it and keep emptying the queue until END before rethrowing it; or make matchQuery and doFinish use queue.offer(task, timeout) in a loop that gives up once every worker future is done.
Trade-offs
Removes the hang for Errors too, at the cost of more code; the timed variant polls. Not built.
Report into each worker's own sub-matcher (matcher.reportError) and copy matches.getErrors() in doFinish, as ParallelMatcher.doFinish already does (ParallelMatcher.java:105-107). This avoids the shared map and the lock.
Trade-offs
Two more lines in doFinish. Not built.
For an ExecutionException, say that a query failed (and keep the cause) instead of "Interrupted during match".
Trade-offs
Cosmetic on its own; with A the path is reached only by an Error.
No public downstream project using these wrappers was found (Elasticsearch, OpenSearch, Solr); in a service where users register their own monitor queries, one user's failing query would drop every user's matches in each batch that selects it.
Code reading of the monitor's concurrent matchers during a review of the monitor module, after exhaustive checks of its presearcher came back clean; a JUnit on the stock main jars then confirmed it.
The method in full84 words
Non-formal. A hunt unit on the monitor module first ran exhaustive presearcher checks (clean), then read the matchers it named. The workers' IOException-only catch, next to the serial collector's catch (Exception e), stood out; the finder wrote a JUnit with a stock trigger (a nested BooleanQuery over IndexSearcher's clause limit), which failed on the stock main jars with assertions on and off, while the serial control passed. Independent verifiers then found the simpler point-width trigger and pinned down the hang's precondition.
No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream projects or the web (searched 2026-10-26 and 2026-10-31). Closest: Luwak #63 (2015), the same wrapper message around an unrelated AssertionError, fixed at that trigger.
Searches2 searches
Context, not reports: flaxsearch/luwak#63 and #60 (2015: "Interrupted during match" around an AssertionError from a MemoryIndex.freeze() regression, fixed at the trigger, the catch never discussed); luwak#1, #3, #7 and PR #5 (2014, serial path, before the concurrent matchers existed; they led to the per-query error handling, which the author added in their own commit); apache/lucene#13109 and #13632 (method visibility; #13109's sample matcher copies the same catch without comment); #12318 (the blocking queue as a reactive-programming concern); #16847 (open, long global doc ids across the codebase; it changes six monitor files, CandidateMatcher among them, but neither matcher wrapper). Limits: keyword search only; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; Luwak, Solr, OpenSearch, Elasticsearch, Lucene.NET), the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, the web | 2026-10-26 | a search script with 56 queries, 57 GraphQL searches including every module:monitor item, a file-level scan of all 411 open PRs, commit-path history, 12 direct list queries, plus the devil's advocate's 43-query run | no report of this bug |
| the same sources, repeated | 2026-10-31 | 171 recorded queries (two script runs, 52 GraphQL searches, REST variants, direct list searches), all 409 open PRs file by file, every issue, PR and comment updated since 2026-10-20 | nothing new; no open PR touches either matcher file |
In the monitor module since its first commit (LUCENE-8766, 2019-06-13, the import of the Luwak library) and first released in 8.2.0. All 58 lucene-monitor releases up to 10.6.0 and main fail the test; never fixed.
Versions tested7 rows
Introduced by 251dbe7cea8 (master) and b8773d13ad8 (branch_8x), "LUCENE-8766: Add monitor subproject" by Alan Woodward, 2019-06-13 (issue apache/lucene#9811; the import PR apache/lucene-solr#648 was closed unmerged after the direct commit). The IOException-only catches, the bounded queue, the untimed put and the "Interrupted during match" wrappers all came with it, next to the serial collector's catch (Exception e); Luwak already had the same divergence. Later commits on the matchers changed formatting, visibility (#13632) and records (#13328) only. Old releases need small test variants: 8.6.0-9.0.0 the old LuceneTestCase import, 8.2.0-8.5.2 also an extra StringField to get past the unrelated LUCENE-9133 presearcher NPE.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.1.1 | before the module | no lucene-monitor artifact; the test does not compile |
| 8.2.0 | first release | test fails with assertions on and off; with the fix it passes; the hang probe hangs (77 interrupts) |
| 8.3.0 to 8.11.4, 9.0.0 to 9.12.3, 10.0.0 to 10.5.2 | affected | test fails with the same cause chain, assertions off (56 releases) |
| 10.6.0 | affected | test fails with assertions on and off; the hang probe hangs (78 interrupts) |
main 645d4fe14a20 | affected | test fails with assertions on and off (gradle and the standalone runner); the hang probe hangs (77 interrupts) |
upstream main 2f424fd428da, branch_11x, branch_10_6 | source read | same matcher code (2026-10-31) |
main 645d4fe14a20 + fix A | fix | the test, the 182-test monitor suite and :lucene:monitor:check pass |
The finder, independent verifiers (a reproducer, a reading-only cross-model review whose verifier also ran a hang-precondition probe, and a devil's advocate), an adjudication, registry re-runs, a credibility audit, a current-main check, a tie-break and this doc's runs all reproduced it or agreed with it; none judged it intended. A second cross-model review could not run any command.
Checks and verdicts544 words
Every executed check hit the same wrapper exception from doFinish (PartitionMatcher.java:100 or ParallelMatcher.java:111 on main and 10.x; in the release matrix PartitionMatcher.java:110 on 8.2.0 and :111 on 9.0.0 and 9.12.3), with the query's exception as the cause; every hang check was parked in LinkedBlockingQueue.put from ParallelMatcher.matchQuery (:80 at 645d4fe14a20) and needed 77 to 79 interrupts. The refute-lens review produced no verdict in the study and, on 2026-10-31, a procedural "not real, low" because none of its commands could run; its reading agreed with the root cause, and a separate run on 10.5.2 failed.
| Check | Verdict | What it found |
|---|---|---|
| finder (code reading of the monitor's matchers, then a JUnit) | candidate | Nested BooleanQuery over the clause limit: 3 of 4 tests fail on main (-ea, -da), 10.5.2 and 9.12.3; serial control passes; the 1-thread hang test needs 77 interrupts; with the fix 4 of 4 pass. |
| Cross-model review, works-as-designed lens (OpenAI GPT-5.6, reading only), plus a hang-precondition probe run by a verifier | real, high; precondition confirmed | Review: intended-behaviour argument weak; root cause agrees; a separate re-run fails 3 of 4. Probe on main: threads = 2: 2 failing + 1,100 good hangs; 1 failing, or only 100 good, throws without hanging. |
| cross-model review, refute lens (OpenAI GPT-5.6) | no executed verdict | No command could run, so a procedural 'not real, low'; its reading agrees with the root cause and finds the inputs in contract; a separate run on 10.5.2 (-da) fails 1 of 1. |
| Independent reproducer | real | Own public-API JUnit (custom Query, TooManyNestedClauses): 5 of 7 fail on main, 10.5.2 and 9.12.3 (-ea, -da), 40 of 40 over 20 iterations; with the fix 7 of 7 pass. |
| Intended-behaviour devil's advocate | real, high | Point-width trigger: Partition(4) and Parallel(4) lose the batch; Parallel(4) with one 4-clause failing query plus 1,100 good hangs (77 interrupts); with the fix 4 of 4 pass. |
| adjudication | confirmed, high | Re-ran the independent reproduction (7 run, 5 fail, 77 interrupts) and the intended-behaviour check (4 run, 3 fail) with new seeds. |
| registry re-runs and credibility audit | keep | The intended-behaviour check's probe fails 3 of 4 (-ea, -da, 77-78 interrupts) and passes 4 of 4 with the fix. |
| current-main check (2026-10-31) | real | The minimal test fails on 645d4fe14a20, 10.5.2 and 10.6.0 (-ea, -da); the serial control and the fix pass; the intended-behaviour check's hang re-run needs 77 interrupts. |
| tie-break (2026-10-31) | real, high | Own probe outside the package: checked IOException reported per query by both wrappers; UncheckedIOException and TooManyNestedClauses lose the batch; the 1-thread hang needs 77 interrupts; on main, 10.6.0 and 9.12.3; with the fix 7 of 7 pass. |
| history matrix (2026-10-31) | 58 of 58 releases affected | 8.2.0 to 10.6.0 fail with the bug's signature; 8.2.0 with the fix passes; the hang reproduces on 8.2.0, 10.6.0 and main. |
| this doc (2026-10-31) | reproduced, fix validated | Gradle: stock fails (-ea, -da) and the 182-test suite has exactly that 1 failure; with the fix the test and the suite pass, and the format check and ecj lint pass. The revised test (NamedThreadFactory) fails on stock main, 10.5.2 and 10.6.0 (-ea, -da), passes the format, forbidden-API and ecj checks, and with the fix passes together with :lucene:monitor:check. |
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 372 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,296 words
- Registry title
- Monitor's ParallelMatcher and PartitionMatcher catch only IOException per query: one registered query that throws an unchecked exception at match time makes Monitor.match throw and lose every match of the batch, and ParallelMatcher blocks forever once all its workers have died
- Area
- monitor / concurrent candidate matchers (ParallelMatcher, PartitionMatcher)
- Priority
- Low, rank 808: The opt-in Monitor Parallel and PartitionMatcher lose a whole batch loudly when one query fails and hang only with many failing candidates.
- Impact class
- hang
- Impact note
- Applications that match with the opt-in ParallelMatcher or PartitionMatcher wrappers of the optional monitor module (the default serial matcher is not affected): one registered query that throws an unchecked exception at match time (a point query of the wrong numeric type, a query over IndexSearcher's clause limit, a custom Query) makes every Monitor.match that selects it throw RuntimeException('Interrupted during match') and lose the other matches of the batch, at any thread count; with ParallelMatcher, once every worker has died (at least
threadsfailing candidates, for example one registered query with that many failing disjuncts) and about 1,024 more candidates still have to be queued, Monitor.match blocks forever and one interrupt does not free it; ordinary scale; released 8.2.0 to 10.6.0. - Summary
- You register queries in a Monitor and match documents with ParallelMatcher or PartitionMatcher, which the package docs offer as a performance option, and one registered query throws an unchecked exception at match time, for example an IntPoint range query meeting a field indexed as LongPoint (IllegalArgumentException). You expect what the default serial matcher does: that query is reported in getErrors() and every other match is returned. Instead the worker threads catch only IOException, so Monitor.match throws RuntimeException('Interrupted during match') and the whole batch's matches are lost, and with ParallelMatcher, once every worker has died, the caller blocks forever in LinkedBlockingQueue.put (executed: 4 threads, one registered query with 4 failing disjuncts plus 1,100 other queries, still blocked after 20 s, 77 interrupts to stop).
- Introduced
- 251dbe7cea8126cb7ecadc921ba444c63d06c618 on master and its identical branch_8x commit b8773d13ad8e249a045862249bf476c30a444461, 'LUCENE-8766: Add monitor subproject' by romseygeek (import of the Luwak library as lucene/monitor; JIRA LUCENE-8766 = apache/lucene#9811) [read: git log --follow on both matcher files lists only this commit plus 2cbf261032d (reformat, LUCENE-9570), 926d8f4ce63 (#13632, visibility) and 942065cc3e7 (#13328, records); git log -S 'catch (IOException e)' returns only 251dbe7cea8; git blame of ParallelMatcher.java:49, 78-84, 92-112, 124-138 and PartitionMatcher.java:81-101, 115-124 at main gives 251dbe7cea8 except one reformatted line]. The IOException-only worker catch, the bounded LinkedBlockingQueue(1024), the blocking queue.put and the 'Interrupted during match' wrapper are all in the first commit, as is the serial collector's catch (Exception e). No merged PR: the import PR apache/lucene-solr#648 was closed unmerged after the direct commit. The divergence already existed in the Luwak predecessor [read] (2019-06-13 (author and commit date of 251dbe7cea8 and b8773d13ad8))
- First affected version
- 8.2.0 [exec] (first release with lucene-monitor; 8.1.1 has no monitor module: no lucene-monitor 8.1.1 artifact, compile_error against 8.1.1; on the master line the first release is 9.0.0)
- Affected versions
- Every lucene-monitor release: 8.2.0-8.11.4 (24), 9.0.0-9.12.3 (21) and 10.0.0-10.6.0 (13), 58 in total, plus main ea57360912ed and 645d4fe14a20. [exec] the follow-up's history matrix fails the minimal test on all 58 releases (-da; 8.2.0 also -ea) and on main 645d4fe14a20 (-ea and -da), and its ParallelMatcher hang probe hangs on 8.2.0, 10.6.0 and main 645d4fe14a20 (2026-10-31); the minimal test also fails on 10.6.0 with -ea; the follow-up's portable public-API repro fails on 8.2.0 (-ea and -da), 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); JUnit repro fails on 9.1.0, 9.12.3, 10.5.2 and main; the independent reproduction on main, 10.5.2 and 9.12.3 (-ea and -da); the intended-behaviour check on main (-ea and -da) and 10.5.2. [read] the introducing commit is in every release tag of the history clone from 8.2.0 (the 8.x tags up to 8.8.1 and all 34 from 9.0.0 to 10.6.0), and no later commit changes the behaviour. Not fixed: no open apache/lucene PR touches ParallelMatcher.java or PartitionMatcher.java (2026-10-26).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record, verdict new: scripted prior-report searches with 8 GitHub issue/PR queries on apache/lucene, 2 commit queries, 2 downstream queries, 32 mailing-list queries over dev@, java-user@, issues@ and commits@, 6 JIRA-archive queries and 6 web queries; 57 GraphQL issue/PR searches over apache/lucene (including all label:module:monitor items), flaxsearch/luwak, apache/solr, OpenSearch and Elasticsearch; a file-level scan of all 411 open apache/lucene PRs; commit-path history on main, branch_10x and Luwak; 12 direct lists.apache.org queries; 1 JIRA component query; plus the intended-behaviour check's earlier 43-query run); not fixed upstream (matcher files last changed by 942065cc3e7, #13328, records only). Related items, not reports of this bug: flaxsearch/luwak#63 and #60 (2015, predecessor project: the same 'Interrupted during match' wrapper around an AssertionError from a MemoryIndex-freeze regression, fixed at that trigger; matcher error handling not discussed), flaxsearch/luwak#1, #3, #7 and PR #5 (2014, serial-path reports that led to per-query error reporting, before the concurrent matchers existed; #3 states unchecked query exceptions should become per-query errors), apache/lucene#12318 (ParallelMatcher's blocking queue as a reactive-programming concern, nothing about exceptions), #13109 and #13632 (CandidateMatcher visibility), #16198, #16433, #16197 and #13735 (other monitor concurrency and collector work).
- Lucene commit tested
- Main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-31: the minimal test through gradle in a scratch worktree with assertions on and off, stock and with the fix, plus the wholelucene/monitortest suite (182 tests, the repro included), the format check and ecj lint; the standalone runner on main, 10.5.2 and 10.6.0 (assertions on and off) with a serial control and the fix shadowed in; a tie-break probe on main, 10.6.0 and 9.12.3; the history matrix over all 58 releases from 8.2.0 to 10.6.0 and the hang probe on 8.2.0, 10.6.0 and main; then the revised test (its pool built withNamedThreadFactory, as Lucene's forbidden-API check requires) through gradle (stock fails with assertions on and off and passes the format, forbidden-API and ecj checks; with the fix it passes and so does:lucene:monitor:check) and the standalone runner on main, 10.5.2 and 10.6.0. Earlier, on mainea57360912ed(identical monitor files): the finder, cross-model reviews, verifiers, the adjudication, the registry re-runs and the credibility audit.
- Finding record
lucene/monitor/src/java/org/apache/lucene/monitor/ParallelMatcher.java:49 (LinkedBlockingQueue<>(1024)), :77-84 (matchQuery: queue.put; InterruptedException becomes IOException 'Interrupted during match'), :92-112 (doFinish: put(END) per worker, future.get, RuntimeException 'Interrupted during match'), :124-138 (MatcherWorker.call: catch (IOException e) only), :180-184 (factory: threads = availableProcessors())lucene/monitor/src/java/org/apache/lucene/monitor/PartitionMatcher.java:81-101 (doFinish: invokeAll, future.get, RuntimeException 'Interrupted during match' at :100), :115-124 (MatcherWorker.call: catch (IOException e) only)lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:294-303 (StandardQueryCollector.matchQuery: catch (Exception e) -> matcher.reportError)lucene/monitor/src/java/org/apache/lucene/monitor/CandidateMatcher.java:97 (reportError javadoc 'Called by the Monitor if running a query throws an Exception')lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:50-51 (the wrappers are presented as a way 'to increase performance in low-concurrency systems')
- The finding in 30 seconds
lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:218-226, :293-303lucene/monitor/src/java/org/apache/lucene/monitor/CandidateMatcher.java:35, :58-68, :97-100, :105-106lucene/monitor/src/java/org/apache/lucene/monitor/PartitionMatcher.java:70-73, :81-102, :115-124lucene/monitor/src/java/org/apache/lucene/monitor/ParallelMatcher.java:49, :69-72, :76-84, :91-113, :124-138, :180-184lucene/monitor/src/java/org/apache/lucene/monitor/QueryAnalyzer.java:120-126; QueryTree.java:119-130; TermFilteredPresearcher.java:136; QueryDecomposer.java:77-103lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:50-51
- Why it looks unintended
lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:297-302; CandidateMatcher.java:58-68, :97-100; MatchingQueries.java:99lucene/monitor/src/java/org/apache/lucene/monitor/PartitionMatcher.java:31-43, :117-121; ParallelMatcher.java:34-46, :128-132; package-info.java:50-51lucene/monitor/src/test/org/apache/lucene/monitor/ConcurrentMatcherTestBase.java; TestMonitorErrorHandling.java:28-45; TestHighlightingMatcher.java:113-129; MonitorTestBase.java:65-70
- Diagrams
lucene/monitor/src/java/org/apache/lucene/monitor/PartitionMatcher.java:115-124; ParallelMatcher.java:49, :76-84, :124-138
- TestPartitionMatcherUncheckedQueryError.java (lucene/monitor test, one method):
repro/LFV-326/src/org/apache/lucene/monitor/TestPartitionMatcherUncheckedQueryError.java(52 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-326/bug.png(372 KB)
