All findings · Lucene finding · LFV-326 · monitor-concurrent-matchers-unchecked-query-error

Monitor's ParallelMatcher and PartitionMatcher lose every match when one query throws an unchecked exception, and ParallelMatcher can hang

With PartitionMatcher or ParallelMatcher, one registered query that throws an unchecked exception (say an IntPoint query on a LongPoint field) makes Monitor.match throw "Interrupted during match" and lose every other match; the serial matcher reports just that query. Once all its workers have died this way and its 1,024-slot queue fills, ParallelMatcher blocks forever.

Summary

Priority
Low, rank 808The opt-in Monitor Parallel and PartitionMatcher lose a whole batch loudly when one query fails and hang only with many failing candidates.
Impact
hang
Since
8.2.0 (LUCENE-8766, the monitor module's first commit, 2019)introduced 2019-06-13 · about 7 years
Affected
all 58 lucene-monitor releases, 8.2.0 to 10.6.0, and main
Status
documented
Found by
code reading of the monitor's concurrent matchers, then a 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

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.

How the Monitor runs registered queries

  1. A Monitor stores 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.
  2. 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.
  3. PartitionMatcher and ParallelMatcher wrap such a matcher to run the queries on several threads; the package docs offer them to increase performance.
LFV-326: two registered queries, long (LongPoint) and int (IntPoint), and one document with a LongPoint num = 5. The serial matcher returns 1 match (long) and reports int's IllegalArgumentException in getErrors(). PartitionMatcher's worker catches only IOException, so the IllegalArgumentException escapes and Monitor.match throws RuntimeException "Interrupted during match" with no matches.
The repro's two-query example: the serial matcher reports 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.

The finding, step by step

  1. Register two queries in a Monitor: long = LongPoint.newExactQuery("num", 5) and int = IntPoint.newExactQuery("num", 5). Match one document with a LongPoint field num = 5, using PartitionMatcher with 2 threads, which the docs offer to speed up matching.
  2. You expect what the default serial matcher returns for the same input: 1 match (long), and the error of int (the field has 8-byte points, the query 4-byte ones) in getErrors().
  3. Instead Monitor.match throws RuntimeException: Interrupted during match, and the match of long is lost too. The worker threads catch only IOException; the serial path catches every Exception.
  4. With ParallelMatcher it can be worse: a dead worker stops emptying a 1,024-slot queue. With 1 thread, 1 failing and 1,100 good queries, Monitor.match was still blocked after 30 s, and stopping it took 77 interrupts.

Who is affected

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.

The path (file:line at 645d4fe14a20, lucene/monitor/src/java/org/apache/lucene/monitor/)

  1. Serial path, the contract. Monitor.match feeds each selected query through StandardQueryCollector.matchQuery and then calls matcher.finish(...) (Monitor.java:218-226). The collector catches Exception and calls matcher.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 a Map<String, Exception>.
  2. PartitionMatcher. matchQuery only queues the task (PartitionMatcher.java:70-73), so the collector's catch never sees a failure. doFinish splits the tasks into threads partitions and runs them with executor.invokeAll (:81-90). Each worker wraps each query in catch (IOException e) only (:116-121). An unchecked exception escapes call(), future.get() (:91) throws ExecutionException, and that becomes RuntimeException("Interrupted during match", e) (:99-100). Monitor.match propagates it: no result at all.
  3. ParallelMatcher. The constructor submits threads workers (ParallelMatcher.java:69-72; the two-argument factory uses availableProcessors(), :180-184). matchQuery puts each task on a LinkedBlockingQueue<>(1024) with an untimed put (:49, :76-84). Each worker loops on queue.take() and catches only IOException (:124-138, catch at :130). doFinish puts one END per worker (:94-96) and calls future.get() (:99), wrapping ExecutionException in RuntimeException("Interrupted during match") (:110-111).
  4. 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 the END markers overflow it, doFinish's put(END) at :95 does). An interrupt turns that one put into IOException("Interrupted during match") (:81-82), which the collector records as that query's error (Monitor.java:300-301), and the next put blocks again. With 1 thread, 1 failing query first and 1,100 good ones: 1,100 - 1,024 = 76 blocked puts plus the final put(END) = 77 interrupts, as observed.
  5. 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.match throws without hanging. QueryDecomposer splits a plain disjunction (no MUST or FILTER clause, minimumNumberShouldMatch at most 1) into one candidate per distinct SHOULD clause; a conjunction or minimumNumberShouldMatch > 1 stays whole, and equal clauses collapse into one candidate (QueryDecomposer.java:77-103). So one registered disjunction with threads distinct failing clauses is enough.
  6. Only the exception type matters. Both wrappers report a checked IOException from a query per query and return the other matches; the same failure thrown as UncheckedIOException loses the batch (tie-break probe).
Exposure and likelihood280 words
  • Who is hit. Applications that match with ParallelMatcher or PartitionMatcher from the optional lucene-monitor module. The default serial matchers are not affected.
  • Ordinary triggers, all accepted by Monitor.register and 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 each BooleanQuery builder but exceeds IndexSearcher's clause limit at rewrite (two 601-clause disjunctions in a conjunction give TooManyNestedClauses; one classic QueryParser string with default settings is enough); a custom Query that throws a RuntimeException; an UncheckedIOException.
  • Lost batch. One failing query makes the whole Monitor.match call 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 (ParallelMatcher only). Needs at least threads failing candidates in one batch (default threads = availableProcessors()) and then roughly 1,024 or more candidates still to queue. The matching thread then blocks forever, and one interrupt (what Future.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.

Minimal repro

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.

TestPartitionMatcherUncheckedQueryError.java (lucene/monitor test, one method)52 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.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"));
    }
  }
}
Command
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 of int in getErrors(), as the serial matcher returns; with the fix that is the result.
Observed
Monitor.match throws RuntimeException: Interrupted during match (cause: IllegalArgumentException ... bytesPerDim=8 but this query has bytesPerDim=4), and no match is returned.

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

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.5.2 and 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run; pure Java logic)

Observed output

# 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

Why it looks unintended

  • The Monitor's serial collector catches every Exception and reports it per query; reportError is "Called by the Monitor if running a query throws an Exception".
  • Both wrappers already report a checked IOException per query; the same failure as UncheckedIOException loses 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 Exception per query. The collector that drives every matcher catches Exception (Monitor.java:300); reportError takes an Exception and its javadoc says it is "Called by the Monitor if running a query throws an Exception" (CandidateMatcher.java:97); getErrors() is a Map<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 IOException per query (PartitionMatcher.java:119-120, ParallelMatcher.java:130-131), and that works; the same failure as UncheckedIOException loses 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.

Suggested fix

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.IOException
All 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.

A. Catch Exception in both workers (recommended) (validated by the checks)

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

Trade-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 an AssertionError under -ea, or StackOverflowError) still ends a worker. For PartitionMatcher that matches the serial path, which does not catch Error either; for ParallelMatcher enough such workers can still produce the hang (option B).
  • The lock guards a race that exists today on the IOException path; it has no repro and is not claimed separately.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20 with the repro test added, seed DEADBEEF. Stock: the repro fails with -Ptests.asserts=true and false, and the whole :lucene:monitor:test suite 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); checkGoogleJavaFormat and ecjLintMain of the monitor module pass.
  • With the revised test (its pool built with NamedThreadFactory): the repro passes with assertions on and off, and :lucene:monitor:check passes (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).

B. A, plus never block on a dead ParallelMatcher worker

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.

C. Variant of A for PartitionMatcher: per-worker error maps

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.

Optional with any option: a truthful message

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; Luwak, Solr, OpenSearch, Elasticsearch, Lucene.NET), the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, the web2026-10-26a 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 runno report of this bug
the same sources, repeated2026-10-31171 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-20nothing new; no open PR touches either matcher file

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
8.1.1 before the moduleno lucene-monitor artifact; the test does not compile
8.2.0 first releasetest 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 affectedtest fails with the same cause chain, assertions off (56 releases)
10.6.0 affectedtest fails with assertions on and off; the hang probe hangs (78 interrupts)
main 645d4fe14a20 affectedtest 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 readsame matcher code (2026-10-31)
main 645d4fe14a20 + fix A fixthe test, the 182-test monitor suite and :lucene:monitor:check pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (code reading of the monitor's matchers, then a JUnit) candidateNested 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 confirmedReview: 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 verdictNo 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 realOwn 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, highPoint-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, highRe-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 keepThe 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) realThe 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, highOwn 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 affected8.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 validatedGradle: 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.

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

Finding record

LFV-326, snapshot of 2026-11-06

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 threads failing 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 whole lucene/monitor test 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 with NamedThreadFactory, 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 main ea57360912ed (identical monitor files): the finder, cross-model reviews, verifiers, the adjudication, the registry re-runs and the credibility audit.

Lucene and upstream sources

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-303
  • lucene/monitor/src/java/org/apache/lucene/monitor/CandidateMatcher.java:35, :58-68, :97-100, :105-106
  • lucene/monitor/src/java/org/apache/lucene/monitor/PartitionMatcher.java:70-73, :81-102, :115-124
  • lucene/monitor/src/java/org/apache/lucene/monitor/ParallelMatcher.java:49, :69-72, :76-84, :91-113, :124-138, :180-184
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryAnalyzer.java:120-126; QueryTree.java:119-130; TermFilteredPresearcher.java:136; QueryDecomposer.java:77-103
  • lucene/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:99
  • lucene/monitor/src/java/org/apache/lucene/monitor/PartitionMatcher.java:31-43, :117-121; ParallelMatcher.java:34-46, :128-132; package-info.java:50-51
  • lucene/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

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

Repro files

About the diagrams

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

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.