All findings · Lucene finding · LFV-637 · parallelmatcher-workers-leak-or-hang-when-match-fails-before-finish

A Monitor#match that throws leaves ParallelMatcher's worker tasks blocked on the executor forever

ParallelMatcher starts its worker tasks on your executor when a match begins, and only finish() stops them. If the query-index search throws first, for example TooManyClauses for a document with terms in 1,025 query fields, the workers stay blocked until interrupted, and later matches on that pool can hang.

Summary

Priority
Low, rank 806One failed match permanently hangs the opt-in ParallelMatcher's pool, which is more reachable than the batch-loss hang LFV-326, so it heads the Monitor hang rows after LFV-207 and above LFV-485 and LFV-326.
Impact
hang
Since
8.2.0 (LUCENE-8766, 2019)introduced 2019-06-13 · about 7 years
Affected
every lucene-monitor release 8.2.0 to 10.6.0, and main
Status
documented
Found by
non-formal: an executor-behaviour sweep, then a TLA+ model of the worker lifecycle

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

ParallelMatcher starts its workers when the matcher is created and stops them only in finish(); Monitor#match never calls finish() when the search fails.

Monitor, the presearcher and the matchers

  1. A Monitor stores standing queries. Monitor#match(doc, factory) turns the document into a tiny index, asks the presearcher which stored queries could match, and runs those candidates against the document with a CandidateMatcher made by the factory.
  2. The default presearcher's query has one clause per document field whose terms appear in stored queries, so it is subject to the clause limit (IndexSearcher.getMaxClauseCount(), 1,024 by default).
  3. The default matcher runs the candidates on the calling thread. ParallelMatcher.factory(executor, matcher, threads) runs them on threads worker tasks fed through a queue; PartitionMatcher collects all candidates and hands them to the executor at the end.
Diagram of the bug: Monitor#match creates a ParallelMatcher, whose constructor starts a worker task on the 1-thread pool; the worker waits in queue.take() for an END marker. The presearcher then throws TooManyClauses for a document with terms in 1,025 query fields, so finish(), the only code that puts END, never runs. The exception reaches the caller, but the worker stays blocked and the pool is still running 5 s after shutdown(); only shutdownNow() frees it.
A failed Monitor#match: the worker started when the matcher was created waits for an end marker that only finish() sends, so it keeps its pool thread after the exception. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Register 1,025 term queries, one per field (f0:x to f1024:x), and match one document that has x in all 1,025 fields with ParallelMatcher.factory(Executors.newFixedThreadPool(1), QueryMatch.SIMPLE_MATCHER, 1).
  2. The presearcher's query needs 1,025 clauses, one over the limit of 1,024, so Monitor#match throws IndexSearcher.TooManyClauses, as the default serial matcher does. You expect that, and an idle pool: awaitTermination(5 s) after shutdown() returns true, as with PartitionMatcher.
  3. Instead the pool is still busy 5 s after shutdown(). The matcher submitted its 1 worker before the search failed; the worker waits in queue.take() for an end marker that only finish() sends, and Monitor#match never calls finish(). Only an interrupt, such as shutdownNow(), frees it.
  4. On a shared pool it sticks: after one failed match on a 2-thread pool with threads = 2, the next valid match on the same pool (still in use) does not return within 10 s. A rejected submit leaks accepted workers the same way; with CallerRunsPolicy on a full pool, Monitor#match itself never returns.

Who is affected

Applications that match with the opt-in ParallelMatcher of the optional monitor module. Each match whose query-index search throws strands its threads worker tasks until an interrupt; on a fixed pool sized to threads, one failure hangs every later match on that pool until shutdownNow() or a restart. The serial matcher and PartitionMatcher are unaffected. Rare: unreported since 2019.

Mechanism, terms and numbers672 words

ParallelMatcher's constructor submits threads worker tasks to the caller's ExecutorService before any candidate query exists. Each worker loops on queue.take() until it sees an END marker, and END is put only by finish(). Monitor#match creates the matcher, runs the presearcher and the query-index search, and only then calls finish(); when the search throws, its try-with-resources closes the document batch, but CandidateMatcher has no close or abort method, so nothing releases the workers. They stay parked until something interrupts them, each holding a pool thread; on a fixed pool sized to threads, one failed match makes every later ParallelMatcher match on that pool hang until shutdownNow() (an interrupt) or a restart. The headline trigger is legal input: a document whose terms hit more stored-query fields than the clause limit makes the presearcher throw TooManyClauses, which the serial matcher throws too. Two executor-side triggers hit the same missing release: a rejected submit leaks the workers already accepted, and a worker run on the calling thread (CallerRunsPolicy on a saturated pool, or a direct executor) blocks Monitor#match itself. Monitor#debug has the same shape. The default serial matcher and PartitionMatcher are not affected. Every lucene-monitor release since 8.2.0 and main are affected.

The code

  • Workers start in the constructor (ParallelMatcher.java:63-74), before matchQuery has queued anything:
for (int i = 0; i < threads; i++) {
  MatcherWorker mw = new MatcherWorker(matcherFactory);
  futures.add(executor.submit(mw));
}
  • Each worker blocks until END (ParallelMatcher.java:124-138): while ((task = queue.take()) != END) { ... }.
  • Only finish() puts END: doFinish puts one END per worker and waits on each future (ParallelMatcher.java:91-113); CandidateMatcher.finish is its only caller (CandidateMatcher.java:105-106), and CandidateMatcher has no other lifecycle method.
  • Monitor#match has no failure path for the matcher (Monitor.java:218-227):
try (DocumentBatch batch = DocumentBatch.of(analyzer, docs)) {
  LeafReader reader = batch.get();
  CandidateMatcher<T> matcher = factory.createMatcher(new IndexSearcher(batch.get()));
  StandardQueryCollector<T> collector = new StandardQueryCollector<>(matcher);
  long buildTime = queryIndex.search(t -> presearcher.buildQuery(reader, t), collector);
  return matcher.finish(buildTime, collector.queryCount);
}
  • Where the exception comes from: TermFilteredPresearcher adds one TermInSetQuery clause per document field whose terms appear in the query index (TermFilteredPresearcher.java:224-231), and BooleanQuery.Builder.add throws TooManyClauses at the limit (BooleanQuery.java:82-83). A diagnostic stack on main shows it thrown from TermFilteredPresearcher.buildQuery <- WritableQueryIndex.search <- Monitor.java:224: after createMatcher (:222), before finish (:225). With MultipassTermFilteredPresearcher(4) each pass's builder stays under the limit, and the search's clause-count check in IndexSearcher.rewrite throws IndexSearcher.TooManyNestedClauses (a TooManyClauses subclass) instead, from 257 fields on (256 pass); that is also between createMatcher and finish. Any other exception that escapes the query-index search takes the same path, for example the filter-field batch check (TermFilteredPresearcher.java:183-188) or an I/O error from a persistent query index. An exception from a single candidate query does not: StandardQueryCollector.matchQuery catches it and reports it as a match error (Monitor.java:294-303).
  • Monitor#debug (Monitor.java:317-330) has the same create-search-finish shape and leaks the same way (executed for this doc).

Two more triggers of the same missing release

  • Rejected submit: if the k-th executor.submit throws RejectedExecutionException (k >= 2), it leaves the constructor with k-1 workers accepted and parked. AbortPolicy is ThreadPoolExecutor's default. Executed: a 1-thread SynchronousQueue pool with threads = 2 still runs the accepted worker 10 s after shutdown().
  • Caller runs: with CallerRunsPolicy on a saturated pool, or a direct executor, submit runs a worker on the calling thread inside the constructor, where it blocks in take() before any task exists. Executed: a pool sized to threads (2 threads, ArrayBlockingQueue(2)) shared by two in-progress matches (built through the public MatcherFactory.createMatcher as stand-ins, not two concurrent Monitor#match calls); a third Monitor#match was still blocked 10 s after the pool went idle. An interrupt ends it with RuntimeException("Interrupted during match").

Not affected

The serial matcher (same TooManyClauses, no executor) and PartitionMatcher, which takes the same ExecutorService parameter but submits only in doFinish via invokeAll (PartitionMatcher.java:70-102): after the same failure its pool terminates, and a caller-run task simply runs.

Exposure and likelihood397 words
  • Who. Code that matches with ParallelMatcher.factory(...) from lucene-monitor; the package javadoc offers it "to increase performance in low-concurrency systems" (package-info.java:50-51). The default serial matcher and PartitionMatcher are not affected.
  • Effect. Every Monitor#match or Monitor#debug whose query-index search throws strands the matcher's threads worker tasks until an interrupt; each one that runs holds a pool thread. On a fixed pool of threads threads, the sizing the upstream tests use (a pool of 10 with threads = 10, ConcurrentMatcherTestBase.java:40, :51-52), one failure makes every later ParallelMatcher match on that pool hang, even after the offending document is gone. The caller gets an exception that gives no hint that pool threads are lost; only an interrupt (shutdownNow()) or a restart recovers.
  • Pool shape. On a fixed pool with more threads than threads, later matches keep working until failed matches have taken every thread; on a cached pool parked threads pile up instead (neither executed). A later match on a used-up pool waits in doFinish's future.get() (ParallelMatcher.java:99, executed) or, once its 1,024-slot queue is full, in matchQuery's put (:49, :80, read). The 2-argument factory uses availableProcessors() workers (ParallelMatcher.java:170-184), so one failure can strand that many.
  • Scale. Ordinary: one document and one pool thread in the repro. The headline trigger needs stored queries over more fields than the clause limit (1,024 by default, about 1,024 / passes with MultipassTermFilteredPresearcher) and a document with matching terms in all of them; an I/O error from a persistent query index or a mixed filter-field batch takes the same path. The executor triggers need a rejecting pool or a caller-runs or direct executor.
  • Not executed: an unbounded cached pool (expected: a growing number of parked threads rather than a hang), a pool shared by several Monitors, an end-to-end service scenario.
  • Workaround until fixed. The serial matcher and PartitionMatcher are not affected. An application that keeps ParallelMatcher can wrap its factory, keep the matcher it creates and call its public finish(0, 0) after a failed match; that frees the worker (executed, awaitTermination(5 s) true). Nothing documents this, and finish() also runs any candidates already queued.
  • Likelihood: rare. Each trigger is uncommon among ParallelMatcher users, no downstream user was found and nothing has been reported since 2019; once it happens on a fixed pool, the hang is permanent.

Minimal repro

One JUnit class, one method, public API only: register 1,025 one-term queries, match a document that hits all of them with ParallelMatcher on a 1-thread pool, expect TooManyClauses, then assert that the pool terminates within 5 s of shutdown(). It fails with assertions on and off.

TestParallelMatcherFailedMatch.java (one test class)56 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.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestParallelMatcherFailedMatch extends LuceneTestCase {

  public void testFailedMatchReleasesWorkers() throws Exception {
    Document doc = new Document();
    List<MonitorQuery> queries = new ArrayList<>();
    for (int i = 0; i <= IndexSearcher.getMaxClauseCount(); i++) {
      doc.add(new StringField("f" + i, "x", Field.Store.NO));
      queries.add(new MonitorQuery("q" + i, new TermQuery(new Term("f" + i, "x"))));
    }
    ExecutorService pool = Executors.newFixedThreadPool(1);
    try (Monitor monitor = new Monitor(new StandardAnalyzer())) {
      monitor.register(queries);
      expectThrows(
          IndexSearcher.TooManyClauses.class,
          () -> monitor.match(doc, ParallelMatcher.factory(pool, QueryMatch.SIMPLE_MATCHER, 1)));
      pool.shutdown();
      assertTrue(
          "expected the pool to terminate after the failed match, but its worker is still running",
          pool.awaitTermination(5, TimeUnit.SECONDS));
    } finally {
      pool.shutdownNow();
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:monitor:test --tests org.apache.lucene.monitor.TestParallelMatcherFailedMatch -Ptests.seed=DEADBEEF
Expected
Monitor#match throws TooManyClauses and the pool terminates within 5 s of shutdown(), as with PartitionMatcher.
Observed
TooManyClauses is thrown, but the pool's thread stays parked in ParallelMatcher's queue.take(): expected the pool to terminate after the failed match, but its worker is still running.

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

More files, environment, full output339 words

The class sits in org.apache.lucene.monitor next to Lucene's monitor tests, but uses nothing package-private. Smallest shape: one pool thread, threads = 1, 1,025 one-term queries, one document; deterministic, no threads besides the pool. The finally block's shutdownNow() frees the parked worker, so the suite reports no thread leak. The test takes about 6-7 s, 5 of them the bounded wait; the whole gradle command took 20-62 s here.

Regression test: this same test, with one change for Lucene's build: create the pool as Executors.newFixedThreadPool(1, new NamedThreadFactory("matchers")) (import org.apache.lucene.util.NamedThreadFactory), because Lucene's forbidden-apis check rejects Executors#newFixedThreadPool(int) in test code. That form fails on stock main with the same message and passes with the fix and the format, ECJ and forbidden-apis checks.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 8.2.0 to 10.6.0 (7 run): fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; a task waiting on a queue nobody fills)
Command (environment)
cp $REPRO/TestParallelMatcherFailedMatch.java $LUCENE/lucene/monitor/src/test/org/apache/lucene/monitor/
cd $LUCENE && ./gradlew :lucene:monitor:test --tests org.apache.lucene.monitor.TestParallelMatcherFailedMatch -Ptests.seed=DEADBEEF
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (verbatim, log lines 94-99)
TestParallelMatcherFailedMatch > testFailedMatchReleasesWorkers FAILED
    java.lang.AssertionError: expected the pool to terminate after the failed match, but its worker is still running
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:5AE480DC4E23BD8C]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.monitor.TestParallelMatcherFailedMatch.testFailedMatchReleasesWorkers(TestParallelMatcherFailedMatch.java:49)
# (line 49 is the awaitTermination assert; the expectThrows(TooManyClauses) before it passed)

# diagnostic run on main 645d4fe14a20, assertions off (a separate observation test, not the repro); selected lines: the Monitor lambda, Monitor.match(:242), test-method, JDK park and executor frames left out
DIAG parallel: match threw org.apache.lucene.search.IndexSearcher$TooManyClauses
DIAG parallel:   thrown via org.apache.lucene.monitor.TermFilteredPresearcher$2.build(TermFilteredPresearcher.java:227)
DIAG parallel:   thrown via org.apache.lucene.monitor.TermFilteredPresearcher.buildQuery(TermFilteredPresearcher.java:132)
DIAG parallel:   thrown via org.apache.lucene.monitor.WritableQueryIndex.search(WritableQueryIndex.java:222)
DIAG parallel:   thrown via org.apache.lucene.monitor.Monitor.match(Monitor.java:224)
DIAG parallel: awaitTermination(5s) after shutdown = false
DIAG parallel: diag-pool-thread state=WAITING
DIAG parallel:   at java.base/java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:435)
DIAG parallel:   at org.apache.lucene.monitor.ParallelMatcher$MatcherWorker.call(ParallelMatcher.java:127)
DIAG parallel: terminated after shutdownNow = true
DIAG partition: awaitTermination(5s) after shutdown = true

# standalone runner, seed DEADBEEF: main 645d4fe14a20 fails with -ea and -da; 10.5.2 (-ea, -da) and 10.6.0 fail the same way

Why it looks unintended

  • No javadoc puts any requirement on the executor, and Monitor#match itself creates the matcher and drops it on failure; the normal call gives the caller no reference to it.
  • The serial matcher and PartitionMatcher (same executor parameter) throw the same exception and leave nothing behind.
  • The trigger is legal input: the TooManyClauses is expected, only the leaked workers are wrong.
The full argument259 words
  • No documented executor requirement. The javadocs say only "Matcher class that runs matching queries in parallel", "an ExecutorService to use for parallel execution" and "the number of threads to execute on" (ParallelMatcher.java:34-46, :55-62, :157-184); nothing about rejection, caller-thread execution, sizing or cleanup after a failed match. Lucene states such limits where it has them: "The provided Executor must not reject tasks" (BPIndexReorderer.java:834).
  • Lucene's own code drops the matcher. Monitor#match closes its DocumentBatch on failure (Monitor.java:220) but has nothing to call on the matcher. In the normal call, monitor.match(doc, ParallelMatcher.factory(...)), the caller holds no reference to it; releasing the workers needs an undocumented workaround (wrap the public MatcherFactory, keep the matcher, call its public finish() after the failure; executed).
  • The siblings clean up. The serial matcher throws the same TooManyClauses and uses no executor; PartitionMatcher, with the same ExecutorService parameter, throws it and its pool terminates (executed on main and on 8.2.0).
  • The JDK and core Lucene allow caller-runs and rejection. The Executor javadoc lets an executor run a task "immediately in the caller's thread"; ThreadPoolExecutor's default handler is AbortPolicy. Core's TaskExecutor runs a rejected task on the calling thread "to ensure a rejecting executor only reduces parallelism and does not result in failure" (TaskExecutor.java:55-67).
  • Tests. No upstream test makes a ParallelMatcher match fail. The upstream concurrent test runs threads = 10 on a pool of 4 (ConcurrentMatcherTestBase.java:62, :76-77), so "the pool must have threads free threads" is not an upstream assumption.
The devil's-advocate check189 words

The strongest case for "intended" (built by the devil's-advocate verifier; every verifier rated it weak): the class javadoc says the matcher "distributes queries amongst its worker threads using a BlockingQueue" and threads is "the number of threads to execute on" (ParallelMatcher.java:40-42, :61). So the caller must supply an executor that really runs threads long-lived consumer tasks asynchronously; a caller-runs, direct or saturated rejecting pool is a provisioning error, the leaks happen only on exceptional paths where the caller does get an exception, and shutdownNow() frees a dedicated pool.

Why it fails: no text states that precondition, and the upstream test itself runs 10 workers on 4 threads. It does not touch the headline at all: the failed-match leak happens on a correctly sized Executors.newFixedThreadPool with the default policy, because Monitor#match creates the matcher and lets the exception escape without releasing it, and shutdownNow() is not an option for the shared pool the factory invites. The caller-runs part is a separate, more defensible question (which executors are allowed): there a javadoc constraint is a reasonable maintainer answer, which is why the claim leads with the failed-match leak.

Suggested fix

Recommended Release the workers when the search fails or a submit is rejected (validated)

Release the workers on failure: a no-op CandidateMatcher#doAbort() hook that Monitor#match and Monitor#debug call when the search throws; ParallelMatcher cancels its worker tasks there, and also when a submit is rejected (+46/-10 lines). It passes the repro and the monitor module's tests. The caller-runs hang needs a separate change or a javadoc note.

// CandidateMatcher: new hook, no-op by default
protected void doAbort() {}

// Monitor#match (the same in Monitor#debug)
long buildTime;
try {
  buildTime = queryIndex.search(t -> presearcher.buildQuery(reader, t), collector);
} catch (Throwable e) {
  matcher.doAbort();
  throw e;
}
return matcher.finish(buildTime, collector.queryCount);

// ParallelMatcher: doAbort() and a failed submit call future.cancel(true) for each worker
All options and trade-offs4 options ยท 657 words

One fix was built and validated through gradle in a scratch worktree of 645d4fe14a20; it covers the failed-match and rejected-submit triggers and leaves the caller-runs hang. The other options are design sketches, not built.

Release the workers when the search fails or a submit is rejected (validated) (recommended)

Add a no-op protected void doAbort() to CandidateMatcher, next to doFinish(). Monitor#match and Monitor#debug call it when the query-index search throws, then rethrow. (A throw from finish() itself needs no hook for ParallelMatcher: doFinish puts every END before it waits.) ParallelMatcher overrides it to call future.cancel(true) on each worker; its constructor does the same for the workers already accepted when a submit throws (for example RejectedExecutionException), and now creates collectorMatcher before the loop.

// CandidateMatcher: new hook, no-op by default
protected void doAbort() {}

// Monitor#match (the same in Monitor#debug)
long buildTime;
try {
  buildTime = queryIndex.search(t -> presearcher.buildQuery(reader, t), collector);
} catch (Throwable e) {
  matcher.doAbort();
  throw e;
}
return matcher.finish(buildTime, collector.queryCount);

// ParallelMatcher: doAbort() and a failed submit call future.cancel(true) for each worker

Trade-offs

  • A running worker is interrupted out of take(), a queued one never starts, and the original exception propagates (ParallelMatcher's doAbort() does not throw; an outside override that throws would replace it, so a PR may add such a failure as suppressed). Successful matches are unchanged; candidate tasks already queued are dropped, which is fine because the match has failed. Cancelled worker tasks still waiting in a bounded executor queue stay there, as no-ops, until a thread takes them.
  • It adds a protected method to a public abstract class. Maintainers may prefer another name, a public abort() (a wrapping CandidateMatcher in another package cannot forward a protected call) or making CandidateMatcher Closeable.
  • cancel(true) interrupts the pool threads that run this matcher's workers, as shutdownNow() does today for the whole pool.
  • It does not fix the caller-runs hang (the worker blocks inside the constructor, before any failure). The study's TLA+ model predicted this: releasing the workers on failure clears the failed-match and rejection deadlocks, not the caller-runs one.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails, and so does a probe that calls Monitor#debug instead. With the fix: both pass with assertions on and off; :lucene:monitor:test runs 183 tests (the module's own 181, the repro and the probe), 1 skipped (@Nightly), 0 failures; checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.
  • The study's other trigger tests on the fixed build: failed match, next match after a failed match, rejected submit, multipass presearcher and filter-field batch now pass; both caller-runs tests still fail, as expected.

Never block a worker that runs on the calling thread (for caller-runs; sketch)

Record the constructing thread and let a worker that starts on it return at once; doFinish drains what is left in the queue on the calling thread, and matchQuery runs a task inline when no worker can take it (otherwise its put blocks once the 1,024-slot queue is full). Or submit workers lazily and let the caller help drain the queue.

Trade-offs

Fixes the remaining caller-runs and direct-executor hang, but adds more code to the matching path than the release fix and changes how work is split between threads. Not built or run.

Document the executor requirements (complement)

A note on ParallelMatcher.factory that the executor must run the threads workers asynchronously (no caller-runs or direct executor) and that a rejected submit fails the match.

Trade-offs

A plausible maintainer answer for the caller-runs part; alone it leaves the failed-match and rejection leaks in place.

Call finish() on failure instead of adding a hook (considered)

No API change: on failure Monitor#match calls matcher.finish(...), which puts END and joins the workers.

Trade-offs

Runs (for PartitionMatcher, starts) every candidate query already collected for a match that has failed, delays the exception until they finish, and can itself throw. Not built.

Downstream note

Downstream, a service that matches documents from many sources with ParallelMatcher on a shared pool can lose that pool to a document whose terms span more stored-query fields than the clause limit; the serial matcher and PartitionMatcher avoid it.

How it was found

A sweep ran Lucene APIs that take an executor against nine executor behaviours and flagged ParallelMatcher: caller-runs hung, and a rejected submit left the pool running. A TLA+ model of the worker lifecycle then showed the same deadlock for a match that fails after the matcher is created, which became the repro.

The method in full176 words

Method: non-formal first signal (a differential sweep of the real main jar), then a TLA+ model.

  • Sweep. 13 API surfaces that accept an executor, times 9 executor behaviours; oracles: the same result as the executor-less call, a return within 8 s, and an executor that terminates after shutdown(). The ParallelMatcher cells: caller-runs = hang, rejection at the 2nd or 3rd submit = pool never terminates (first run 2026-10-26).
  • Model. ParallelMatcherLifecycle.tla (TLC, deadlock checking on) encodes a failure between createMatcher and finish on purpose; TLC found the failed-match deadlock (12 distinct states) as well as the caller-runs and abort deadlocks, none in the two capacity controls. A fix mutant ("one END per accepted worker on failure") clears the failed-match and abort deadlocks but not the caller-runs one. The verifiers did not re-check the model.
  • Confirmation. A public-API JUnit on stock builds, then an independent reproducer, a devil's-advocate verifier, cross-model reviews (OpenAI GPT-5.6) that ran their own tests, an adjudication and a credibility audit; a follow-up ran the version history, a prior-report search and a security screen.

Prior reports, history and checks

Prior reports

No prior report found (394 queries on 2026-10-31 over GitHub issues and PRs, the JIRA archive, the mailing lists, downstream trackers and the web; the same verdict on 2026-10-27). Closest: #12318 (2023), a usage question about ParallelMatcher's blocking queue that reports no failure. No open PR touches this code.

Searches2 searches

Verdict new: no issue, PR, review comment, commit, JIRA entry, list thread or downstream issue describes ParallelMatcher workers left behind by a failed match, a rejected submit or a caller-runs executor. #12318 asks which matcher suits a reactive application because put()/take() might starve threads, and was answered with usage guidance. None of the related items (#13109, #13632, #13735, #16198, #16433; core TaskExecutor precedents #13622, #10032, #12569; Luwak #63, #68) is this bug. Of 409 open PRs, 9 touch lucene/monitor and none touches ParallelMatcher.java or Monitor#match.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, downstream engines, Luwak), JIRA, mailing lists, web2026-10-31three scripted prior-report search runs (90 GitHub issue/PR, 18 commit, 15 downstream, 96 list, 24 JIRA and 24 web queries), 82 GitHub GraphQL searches incl. every label:module:monitor item, 16 list and 8 JIRA queries, 15 web searches, and 6 one-off lookups (the file lists of all 409 open and of recently updated apache/lucene PRs, the commit history and blob SHAs of the three files, one code search): 267 + 82 + 16 + 8 + 15 + 6 = 394no report; closest #12318 (usage question); related #13109, #13632, #13735, #16198, #16433, #13622, #10032, #12569, Luwak #63 and #68
Study search (follow-up, finder, a verifier)2026-10-27three scripted prior-report search runs, the finder's 42-query and a verifier's 45-query runs, 78 GitHub GraphQL searches, direct JIRA and list queriesno report

History

Introduced with the monitor module itself (LUCENE-8766, 251dbe7cea8, 2019-06-13) and first released in 8.2.0. Every release from 8.2.0 to 10.6.0 and main fails the minimal repro (7 releases and main run, 49 release tags read); never fixed.

Versions tested5 rows

Introduced by 251dbe7cea8 "LUCENE-8766: Add monitor subproject" (Alan Woodward, 2019-06-13), the import of the Luwak library as lucene/monitor; branch_8x copy b8773d13ad8 the same day. No merged PR (the review PR apache/lucene-solr#648 was closed unmerged; issue LUCENE-8766 = #9811). git blame gives 251dbe7cea8 for the submit loop, put(END), the take loop and the Monitor#match body; the three later commits on ParallelMatcher.java (a reformat, #13632 visibility, #13328 records) do not change the worker lifecycle. Not a regression: the design came with the import. A source check of all 49 release tags with lucene/monitor (8.2.0-8.8.1, 9.0.0-9.12.3, 10.0.0-10.6.0) finds the same code in each. The history clone has no 8.9.0-8.11.x tags; for those releases the evidence is the executed 8.11.2 (finder) and 8.11.4 runs.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0; 10.5.2 affectedminimal repro fails with assertions on and off
8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedminimal repro fails (8.2.0 also with assertions off; 8.x and 9.0.0 with small API renames)
8.2.0, PartitionMatcher instead controlTooManyClauses thrown, the pool terminates
8.1.1 not affectedno monitor module
upstream main 2f424fd428d, branch_11x 603af870375, branch_10_6 7e5aec4370e readsame lifecycle code: lucene/monitor byte-identical on main and branch_11x; on branch_10_6 ParallelMatcher.java identical, CandidateMatcher.java and Monitor.java differ only by _ lambda parameters (3 lines)

Independent checks

The finder, an independent reproducer, a devil's-advocate verifier, cross-model reviews (OpenAI GPT-5.6) and a credibility audit each reproduced it with their own test; an adjudication re-ran one of them. This doc reproduced it on current main and validated the fix.

Checks and verdicts312 words

Every executed check ran on stock builds. The TLA+ model was not re-checked by the verifiers.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (sweep, TLA+ model, 5-test JUnit) candidate4 of 5 tests fail on main with assertions on and off and on 10.5.2, 9.12.3, 9.1.0, 8.11.2 and 8.2.0 (caller-runs, rejected submit, failed match, next match after a failed match); the serial-matcher control passes.
Cross-model review, refute lens (OpenAI GPT-5.6) real, highIts own public-API JUnit: 3 of 5 fail with assertions on and off, both controls pass; re-run on 645d4fe14a20 and 10.5.2 with the same result.
Cross-model review, intended lens (OpenAI GPT-5.6) real, highIts own observing test confirmed 4 of 4 observations; a PartitionMatcher control 2 of 2; the intended-behaviour case is weak.
Reproducer (own JUnit) realFrom a package outside the monitor package, with a filter-field batch as trigger: 4 of 8 fail on main (on and off), 10.5.2 and 9.12.3; all 4 controls pass.
Devil's advocate real (caller-runs: moderate-high)Own probes: a pool sized to threads under caller-runs, the multipass presearcher with 266 fields and a filter-field batch: 3 of 3 fail; 6 controls pass (shutdownNow and interrupt free the workers).
Adjudication confirmed, highRe-ran the independent reproduction's JUnit on main with assertions on and off: 4 of 8 fail, controls pass.
Credibility audit keepOwn JUnit outside the package with the filter-field trigger: 2 of 4 fail with assertions on and off; serial and PartitionMatcher controls pass.
Re-verification (2026-10-31) realThe minimal repro fails on 645d4fe14a20 and 10.5.2 (on and off) and on 10.6.0; a diagnostic ties the leftover thread to ParallelMatcher.java:127.
This doc reproduced, fix validatedGradle on 645d4fe14a20: the repro fails; found the same leak through Monitor#debug; the fix passes the repro, the probe and the whole monitor module (183 tests).

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

Finding record

LFV-637, snapshot of 2026-11-06

Registry title
Monitor's ParallelMatcher starts blocking worker tasks in its constructor that only finish() releases: a Monitor#match that fails before finish() (an exception such as TooManyClauses from the presearcher, or a rejected submit) leaves its workers parked on the executor forever, and an executor that runs a worker on the calling thread (CallerRunsPolicy on a saturated pool) makes Monitor#match hang
Area
monitor / ParallelMatcher worker lifecycle (Monitor#match)
Priority
Low, rank 806: One failed match permanently hangs the opt-in ParallelMatcher's pool, which is more reachable than the batch-loss hang LFV-326, so it heads the Monitor hang rows after LFV-207 and above LFV-485 and LFV-326.
Impact class
hang
Impact note
Applications that match with the opt-in ParallelMatcher of the optional monitor module (the default serial matcher and the sibling PartitionMatcher are not affected): once Monitor#match throws after the matcher was created (for example IndexSearcher.TooManyClauses when a document has terms in more fields used by registered queries than the clause limit, 1024 by default or about 1024/passes with MultipassTermFilteredPresearcher), its threads workers stay blocked in queue.take() forever, and on a fixed pool of that size every later ParallelMatcher match hangs until shutdownNow() or a restart; a rejected submit (AbortPolicy, ThreadPoolExecutor's default, on a bounded pool) leaks the workers already accepted; with CallerRunsPolicy on a saturated pool (including a pool sized to threads and shared by concurrent matches) or a direct executor, the calling thread runs a worker inside the constructor and Monitor#match never returns; ordinary scale; every release since 8.2.0 [exec].
Summary
You match documents with Monitor#match using ParallelMatcher.factory(executor, matcher, threads), for example on Executors.newFixedThreadPool(threads), and you expect what the default serial matcher does: Monitor#match returns the matches or throws, and leaves nothing running on your executor. Instead the ParallelMatcher constructor submits threads workers that only finish() can release, so when the match throws before finish() (here IndexSearcher.TooManyClauses from the presearcher for a document with terms in 1,025 fields used by registered queries, the same exception the serial matcher throws) the workers stay parked forever and the next valid match on the same pool never returns. A rejected submit leaks the already-accepted workers the same way, and with CallerRunsPolicy on a saturated pool the calling thread runs a worker itself and Monitor#match never returns.
Introduced
251dbe7cea8126cb7ecadc921ba444c63d06c618 on master and its branch_8x backport b8773d13ad8e249a045862249bf476c30a444461, 'LUCENE-8766: Add monitor subproject' by romseygeek (import of the Luwak library as lucene/monitor; JIRA LUCENE-8766 = apache/lucene#9811; PR apache/lucene-solr#648 closed unmerged after the direct commit, no line review comments) [read: git blame -w -M -C at main gives 251dbe7cea8 for the constructor's submit loop (ParallelMatcher.java:68-72), doFinish's put(END) (:94-96), MatcherWorker.call's take loop (:124-138) and the body of Monitor#match (Monitor.java:220-226); the later commits on ParallelMatcher.java, 2cbf261032d (LUCENE-9570 reformat), 926d8f4ce63 (#13632 visibility) and 942065cc3e7 (#13328 records), do not change the worker lifecycle; exec: 8.2.0 fails, 8.1.1 has no monitor module]. Not a regression: the defect arrived with the module import; the last Luwak version has the same design (the prior-report record), its earlier history was not inspected (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: lucene-monitor 8.1.1 is not on Maven Central, setup_error); first main-line release 9.0.0 [exec]
Affected versions
Every lucene-monitor release, 8.2.0 through 10.6.0 (10.6.0 by reading), and main a9779ef3c193; never fixed. [exec] follow-up history matrix with the finder's JUnit (5 run / 4 failed each, the serial control passes): 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da); the finder also ran 8.11.2, 9.1.0, 9.12.3 and 10.5.2; the independent reproduction's own JUnit fails on 10.5.2 and 9.12.3, the intended-behaviour check's probe on 10.5.2. [read] a source check of all 48 release tags in the history clone (8.2.0-8.8.1, 9.0.0-10.5.2) finds the submit-in-constructor, take-until-END, put(END)-only-in-doFinish and finish-only-on-success statements in every tag; the clone has no 8.8.2-8.11.x tags (8.11.2 and 8.11.4 were executed). Not fixed upstream: no open apache/lucene PR touches ParallelMatcher.java or Monitor#match (2026-10-27).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by TLA+/TLC
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new): three scripted prior-report searches (48 GitHub issue/PR, 9 commit, 6 downstream, 60 mailing-list over dev@, java-user@, issues@ and commits@, 12 JIRA-archive and 12 web queries), the finder's 42-query run and the intended-behaviour check's 45-query run, 78 GitHub GraphQL searches (apache/lucene including all 50 label:module:monitor items, flaxsearch/luwak, bloomberg/luwak, apache/solr, apache/lucenenet, Elasticsearch, OpenSearch, global), 6 direct JIRA and 8 direct lists.apache.org queries, the file lists of all 409 open apache/lucene PRs (9 touch lucene/monitor, none touches ParallelMatcher.java or Monitor#match), and the history of ParallelMatcher.java, Monitor.java and CandidateMatcher.java on main, branch_10x and branch_10_6 and of Luwak's ParallelMatcher. Not fixed upstream: ParallelMatcher.java last changed by 942065cc3e7 (#13328, 2024-09, records only); upstream main HEAD at search time a95c754ee00. Related, not reports of this bug: #12318 (2023, closed: a user asks which matcher suits a reactive application because ParallelMatcher's blocking put()/take() could starve threads; answered with usage guidance, no defect reported; re-read 2026-10-27), #13109 and #13632 (CandidateMatcher visibility), #13622 and #10032 / LUCENE-8989 (core TaskExecutor tolerates rejection and runs tasks on the caller: a design precedent, not monitor code), #16198 and #16433 (an executor for the monitor's internal IndexSearcher), flaxsearch/luwak#14, #44, #63, #172-#174 and bloomberg/luwak#2 (origin of ParallelMatcher, usage, other regressions)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails through gradle in a scratch worktree, and through a standalone runner with assertions on and off; with the recommended fix it passes, as does the whole monitor module (183 tests, 0 failures). Releases 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 fail the minimal test. The hunt and the verifiers ran on main a9779ef3c193, the cross-model reviews on ea57360912ed; both have the same lucene/monitor. Upstream main 2f424fd428d (2026-10-30) and branch_11x 603af870375 have a byte-identical lucene/monitor; on branch_10_6 7e5aec4370e ParallelMatcher.java is identical and CandidateMatcher.java and Monitor.java differ only by _ lambda parameters (3 lines).

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/ParallelMatcher.java:34-46 (at a9779ef3c193) (class javadoc: worker threads fed by a BlockingQueue), :49 (LinkedBlockingQueue(1024)), :55-74 (constructor javadoc 'an ExecutorService to use for parallel execution'; submit loop :69-72, submit at :71), :91-113 (doFinish: put(END) per future at :94-96, future.get at :99), :124-138 (MatcherWorker.call: take loop at :127), :157-184 (factory javadocs: no executor requirement)
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:207-226 (at a9779ef3c193) (match: try-with-resources closes only the DocumentBatch at :220; createMatcher :222; queryIndex.search :224; finish :225; no failure path that releases the matcher)
  • lucene/monitor/src/java/org/apache/lucene/monitor/CandidateMatcher.java:89-105 (at a9779ef3c193) (finish() is the only lifecycle method; no close or abort hook), PartitionMatcher.java:90 (PartitionMatcher submits only in doFinish via invokeAll), package-info.java:50-51 (the wrappers are offered 'to increase performance in low-concurrency systems')
  • git -C a Lucene main checkout diff --stat ea57360912ed a9779ef3c193 -- lucene/monitor (empty: the monitor module is identical on the two mains)
  • https://github.com/apache/lucene/issues/12318 (closest upstream item; tangential, re-read 2026-10-27)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/ParallelMatcher.java:63-74 (constructor submit loop :69-72), :91-113 (doFinish, put(END) :94-96, future.get :99), :124-138 (MatcherWorker.call, take :127, interrupt :134-135)
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:56-58 (default presearcher TermFilteredPresearcher), :218-227 (match: batch :220, createMatcher :222, search :224, finish :225), :317-330 and :356-358 (debug)
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/CandidateMatcher.java:105-106 (finish calls doFinish), :117-118 (doFinish default); PartitionMatcher.java:70-102
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/TermFilteredPresearcher.java:100-147, :183-188, :224-231; WritableQueryIndex.java:221-223; lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:82-83
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:50-51
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/test/org/apache/lucene/monitor/ConcurrentMatcherTestBase.java:40, :51-52
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/ParallelMatcher.java:49, :80, :99, :170-184
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/java/org/apache/lucene/monitor/ParallelMatcher.java:34-46, :55-62, :157-184 (javadocs); Monitor.java:220
  • a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:834; lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:55-67
  • a Lucene main checkout (645d4fe14a20) lucene/monitor/src/test/org/apache/lucene/monitor/ConcurrentMatcherTestBase.java:62, :76-77
  • JDK 25 API: java.util.concurrent.Executor class javadoc; ThreadPoolExecutor (default AbortPolicy)
Prior reports
  • https://github.com/apache/lucene/issues/12318 (closest item; re-read)
History
  • a Lucene clone origin/main 2f424fd428d, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e (git diff over lucene/monitor) [read]

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 minimal repro's output.

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.