All findings · Lucene finding · LFV-1080 · indexrearranger-failed-execute-leaks-pool-threads

A failed IndexRearranger.execute() never shuts down its thread pool, so its threads keep the JVM alive

When IndexRearranger.execute() fails after it has started work (say, a selector throws its declared IOException), it throws correctly but never shuts down its own thread pool. The pool's non-daemon threads stay until the JVM ends, so a program whose rearrange failed does not exit on its own; affected since 8.9.0.

Summary

Priority
Low, rank 1106A failed IndexRearranger.execute() leaves non-daemon pool threads alive forever, so a command-line run that failed does not exit. The tool is experimental and mostly used offline, the run has already failed loudly and the input index is only read, so it is Low.
Impact
minor
Since
8.9.0 (LUCENE-9694, 2021-01-29; on 8.x the backport 3da86cb5b36)introduced 2021-01-29 · about 5 years
Affected
8.9.0-10.6.0 and main (misc module)
Status
documented
Found by
non-formal: code reading in an executor sweep, then 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

execute() creates its own pool and shuts it down only in its last statement. Any failure after the first task skips that line, and the pool's non-daemon threads wait for work until the JVM ends.

Background: what IndexRearranger does

  1. IndexRearranger (module lucene/misc, @lucene.experimental) copies an index into a chosen segment layout, mainly to build comparable indexes for benchmarks. You give it an input and an output Directory and a list of DocumentSelectors; each selector picks the documents of one output segment.
  2. execute() starts a pool of min(processors, selectors) threads for this call and runs one task per selector on it; each task calls its selector and then writer.addIndexes(...).
  3. A fixed thread pool's worker threads keep waiting for new tasks until someone calls shutdown() on the pool. Non-daemon threads keep the JVM running after main returns.
Diagram of the bug: IndexRearranger.execute() creates a fixed thread pool whose worker rearranger-2-thread-1 is a non-daemon thread. The selector throws IOException on that worker, future.get() rethrows it as ExecutionException, and execute() exits without reaching executor.shutdown() on its last line, so the worker stays parked waiting for work and the JVM cannot exit after main throws. With the shutdown in a finally, the pool shuts down and the worker ends.
execute() starts a pool with one non-daemon worker; the selector throws on it, execute() throws and skips executor.shutdown(), so the worker keeps waiting for work and the JVM cannot exit. With a finally the pool shuts down and the worker ends. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document and run IndexRearranger.execute() with one DocumentSelector that throws IOException. execute() creates a pool of min(processors, selectors) = 1 thread, rearranger-2-thread-1, and runs the selector there.
  2. You expect execute() to throw that error and to shut down the pool it created, as it does at the end of a successful run.
  3. Instead the exception skips executor.shutdown(), which is only the last line of execute(), not in a finally. The worker is still alive 10 s later, waiting for work, and stays until the JVM ends: nothing can reach the pool to shut it down, the threads are non-daemon and a fixed pool's threads never time out.
  4. A plain main() that does this prints the exception and then does not exit on its own (still running at a 20 s timeout; with the fix this one-selector run exits in 0.52 s). Each such failed call leaves min(processors, selectors) idle threads; Lucene's own CI listed 10 after a failed test task, twice in 2022.
  5. The right exception is still thrown, the input index is not changed and a successful run is unaffected. The fix is try { ... } finally { executor.shutdown(); }.

Who is affected

Callers of the experimental lucene/misc IndexRearranger whose run fails after it started work: a selector throws, addIndexes fails, and since 10.0.0 overlapping selectors or a failing deletes selector. A command-line JVM then does not exit; a process that retries collects idle threads. The input index is not changed; successful runs are unaffected.

Mechanism, terms and numbers566 words

IndexRearranger.execute() creates its own fixed pool, Executors.newFixedThreadPool(min(availableProcessors, #selectors), new NamedThreadFactory("rearranger")), for every call, and calls executor.shutdown() only as the last statement of the method, not in a finally. Any exception after the first task was submitted skips that line: a selector that throws, an addIndexes error, and since 10.0.0 also the class's own overlapping-selector IllegalStateException or a failing deletes selector. execute() still throws the right exception, but the pool's threads are non-daemon (NamedThreadFactory) and a fixed pool's threads "will exist until it is explicitly shutdown" (JDK javadoc), and the caller has no reference to the pool. So each such failed call leaves min(availableProcessors, #selectors) idle threads alive until the JVM ends, and a command-line program whose rearrange failed does not exit on its own after the exception leaves main.

The one-test repro fails on main (assertions on and off) and passes with a try/finally fix that also keeps the misc module's tests and :lucene:misc:check green. An equivalent one-test check fails on every release that was run, from 8.9.0 to 10.6.0; the releases in between have identical source or bytecode. It is minor: an experimental offline tool, a run that already failed loudly, the input index not changed.

Terms

  • Segment selector (IndexRearranger.DocumentSelector): user code, BitSet getFilteredDocs(CodecReader reader) throws IOException (IndexRearranger.java:283).
  • Fixed thread pool: Executors.newFixedThreadPool(n, factory) is a ThreadPoolExecutor(n, n, 0L, ...). Its idle threads never time out; the JDK javadoc: "The threads in the pool will exist until it is explicitly shutdown."
  • Non-daemon thread: the JVM does not exit while one is alive. NamedThreadFactory.newThread calls t.setDaemon(false) (NamedThreadFactory.java:73) and names threads <prefix>-<pool number>-thread-<n>: the pool number comes from a static, so JVM-wide, counter (:30, used with NAME_PATTERN in the constructor, :33, :41-48) and <n> from the factory's own counter (:70-71), hence rearranger-2-thread-1.

The bug (main e1faeba08bde, IndexRearranger.java)

  1. execute() (:113-137) creates the pool as a local variable (:114-117).
  2. createRearrangedIndex (:180-200) submits one task per selector (:194); each runs addOneSegment (:202-210), whose filtered reader's constructor calls selector.getFilteredDocs(in) (:256), then writer.addIndexes(readers) (:209).
  3. It waits with for (Future<Void> future : futures) { future.get(); } (:197-199). A failed task makes get() throw ExecutionException, which leaves execute().
  4. executor.shutdown() is the last statement (:136), outside any finally, so it is skipped. Nothing else references the pool: no executor parameter, no field, no close().

Which exits leak

how execute() endswhereleaks
success:136no
no index in the input directoryDirectoryReader.open(input), :127, before any taskno (no thread started yet)
a segment selector or addIndexes failsfuture.get(), :197-199yes, since 8.9.0
two selectors match the same segmentIllegalStateException, :155-161yes, since 10.0.0
the deletes selector failsfuture.get(), :230-232yes, since 10.0.0

The finder's exit-path harness ran these five cases on the stock jar (the three failure cases leak) and on a try/finally shadow (none leaks). Any other exception after the first submit skips :136 the same way (read, not run): an interrupted caller in either future.get() loop (:198, :231), an I/O error in finalizeRearrange (:146-173) or in opening or closing the second phase (:132-135), and tryDeleteDocument's IllegalStateException (:241). The fix's finally covers them too.

Exposure and likelihood260 words
  • Who is hit. Callers of IndexRearranger (@lucene.experimental, IndexRearranger.java:66) whose execute() fails after it submitted at least one task. In 8.9.0-9.12.3 only an exception out of the future.get() loop leaks (a failing segment selector or addIndexes, or an interrupted caller); from 10.0.0 (#11815) also the overlapping-selector IllegalStateException and a failing deletes selector. 8.9.0-9.0.0 have neither the ordering check nor a delete phase, and 9.1.0-9.12.3 call shutdown() before the ordering check (read from source).
  • Triggers are ordinary. getFilteredDocs is declared throws IOException; addIndexes can fail on I/O; the overlap check is the class's own validation.
  • Default vs opt-in. The tool is opt-in (a misc module, mainly for building benchmark indexes). Inside it nothing needs to be configured: every exception that leaves execute() after the first task was submitted skips shutdown().
  • Scale. min(availableProcessors, #selectors) idle threads per such failed call, until the JVM ends. The repro shows one; Lucene's CI showed 10 after a failed TestIndexRearranger task (2022).
  • Visible effects. A batch JVM does not exit on its own after main throws (executed with one selector: still alive at 20 s, 0.52 s with the fix); a long-lived process that retries collects threads; tests get ThreadLeakError.
  • What does not happen. execute() throws the right exception, the input index is only read (what a failed run leaves in the output directory was not examined), a successful run is unaffected, and assertions on or off make no difference.
  • Likelihood: rare. It needs a run that already fails; the searches found no user report since 8.9.0 (2021). Priority Low.

Minimal repro

One JUnit test on the public API: one empty document and one selector that records its thread and throws IOException. After execute() throws, the test waits 10 s for that worker and asserts it has stopped. One selector means a one-thread pool, so no special settings are needed.

TestIndexRearrangerFailureShutsDownPool.java (JUnit, one test)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.misc.index;

import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

@ThreadLeakScope(ThreadLeakScope.Scope.NONE)
public class TestIndexRearrangerFailureShutsDownPool extends LuceneTestCase {

  public void testFailedExecuteStopsWorker() throws Exception {
    try (Directory in = newDirectory();
        Directory out = newDirectory()) {
      try (IndexWriter w = new IndexWriter(in, newIndexWriterConfig())) {
        w.addDocument(new Document());
      }
      AtomicReference<Thread> worker = new AtomicReference<>();
      IndexRearranger.DocumentSelector failing =
          reader -> {
            worker.set(Thread.currentThread());
            throw new IOException("selector failed");
          };
      IndexRearranger rearranger =
          new IndexRearranger(in, out, newIndexWriterConfig(), List.of(failing));
      expectThrows(ExecutionException.class, rearranger::execute);
      // expected: the pool's worker thread ends after execute() fails; actual: it stays alive forever
      worker.get().join(10_000);
      assertFalse(worker.get().getName() + " still alive", worker.get().isAlive());
    }
  }
}
Command
cp TestIndexRearrangerFailureShutsDownPool.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestIndexRearrangerFailureShutsDownPool -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
execute() throws ExecutionException (cause IOException: selector failed) and its pool's worker thread ends.
Observed
AssertionError: rearranger-2-thread-1 still alive 10 s after execute() threw, with assertions on and off; 0.28 s pass with the fix.

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

More files, environment, full output381 words

The selector records Thread.currentThread() before it throws, so the test checks the exact pool worker and cannot pass vacuously (if the selector never ran, worker.get() is null and the test errors). @ThreadLeakScope(NONE) only keeps the framework's suite-level leak check from replacing the test's own assertion; without it the stock run also fails with ThreadLeakError. The test checks only the exception type; that its cause is the selector's IOException shows in the plain main() run below and is asserted by an independent verifier's longer test (e.getCause().getMessage() is "selector failed").

Plain main(), same scenario: prints Exception in thread "main" java.util.concurrent.ExecutionException: java.io.IOException: selector failed and is still running at a 20 s timeout; the thread dump shows rearranger-2-thread-1 WAITING (parking) in ThreadPoolExecutor.getTask and DestroyJavaVM waiting. With the fix this one-selector run exits in 0.52 s.

As the regression test: the same test. For :lucene:misc:check, rename the unused lambda parameter reader to _ (ecj lint) and wrap the comment line at 100 columns (google-java-format); that copy fails on the stock code (assertions on), passes with the fix (assertions on and off) and passes the check. With the fix, the @ThreadLeakScope(NONE) line and its import can also go. The version shown keeps reader, because _ needs Java 22+. As shown, it uses only APIs that exist from 9.1.0 on (an equivalent test ran on 9.1.0-10.6.0); 8.9.0-8.11.4 and 9.0.0 have no org.apache.lucene.tests.util.LuceneTestCase and need import org.apache.lucene.util.LuceneTestCase;, and List.of needs Java 9+.

Environment

lucenemain e1faeba08bde (12.0.0-SNAPSHOT); the same scenario also fails on the 10.6.0 release and on 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3 and 10.0.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestIndexRearrangerFailureShutsDownPool.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestIndexRearrangerFailureShutsDownPool -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main e1faeba08bde, seed DEADBEEF, assertions off (the same with assertions on)
TestIndexRearrangerFailureShutsDownPool > testFailedExecuteStopsWorker FAILED
    java.lang.AssertionError: rearranger-2-thread-1 still alive
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:79E4B6940A9732BB]: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 junit@4.13.2/org.junit.Assert.assertFalse(Assert.java:65)
        at org.apache.lucene.misc.index.TestIndexRearrangerFailureShutsDownPool.testFailedExecuteStopsWorker(TestIndexRearrangerFailureShutsDownPool.java:49)
1 test completed, 1 failed
# the test took 10.3 s (the full join); with the fix: 1 test, 0 failures, 0.28 s

# the same test without @ThreadLeakScope(NONE), stock, assertions on
com.carrotsearch.randomizedtesting.ThreadLeakError: 1 thread leaked from SUITE scope at org.apache.lucene.misc.index.TestIndexRearrangerFailureShutsDownPool:
   1) Thread[id=21, name=rearranger-2-thread-1, state=WAITING, group=TGRP-TestIndexRearrangerFailureShutsDownPool]
com.carrotsearch.randomizedtesting.ThreadLeakError: There are still zombie threads that couldn't be terminated:

Why it looks unintended

  • execute() creates the pool itself and shuts it down only on success; the caller cannot reach it.
  • CheckIndex shuts the same kind of pool down in a finally; #14971 fixed the same failure-path leak in ConcurrentMergeScheduler.
  • Lucene's test framework flags it: ThreadLeakError: 1 thread leaked from SUITE scope.
The full argument225 words
  • Ownership. execute() creates the pool (IndexRearranger.java:114-117) and calls shutdown() on success (:136). There is no executor parameter, field or close(), so only execute() can release it.
  • In-tree code does it right. CheckIndex.checkIndex(List<String>) creates Executors.newFixedThreadPool(threadCount, new NamedThreadFactory("async-check-index")) and shuts it down in a finally (CheckIndex.java:588-616). The benchmark module's JoinIndices makes its pool daemon: "daemon, since Benchmark never closes PerfRunData, and the pool must not keep the JVM alive" (JoinIndices.java:90).
  • Precedent. Issue #14971 (Dawid Weiss, 2025): "ConcurrentMergeScheduler is used and it creates an instance of CachedExecutor but - upon failure - the thread pool is never shut down (LuceneTestCase does have a grace period of 20 seconds, so it's indeed a leak)." Fixed by #15675: "GITHUB#14971: Fix CachedExecutor thread pool leak in ConcurrentMergeScheduler when IndexWriter initialization fails." (lucene/CHANGES.txt:362-363).
  • The test framework calls it a leak. Without @ThreadLeakScope(NONE) the repro fails with ThreadLeakError: 1 thread leaked from SUITE scope ... rearranger-2-thread-1, state=WAITING and "There are still zombie threads that couldn't be terminated"; the 2022 CI mails show the same for TestIndexRearranger.
  • Legal trigger, nothing documented. getFilteredDocs is declared throws IOException (:283). execute() has no javadoc, the class javadoc says nothing about threads or failures, and the existing tests call execute() only on successful runs. No review comment on lucene-solr#2246 or #11815 discusses failure cleanup.
The devil's-advocate check98 words

The strongest case for "works as designed": IndexRearranger is an @lucene.experimental offline tool, execute() promises nothing on failure, the run has already failed loudly, and a command-line caller can call System.exit.

Why it fails: @lucene.experimental is about API stability, not resource leaks; the caller cannot reach the pool; a JVM that does not exit after main throws is exactly what the in-tree JoinIndices comment avoids; and maintainers treated the same failure-path pool leak as a bug in #14971. These points lower the severity (Low), not the fact that it is a bug. There is no explicit contract.

Suggested fix

Recommended A. Shut the pool down in a finally

Wrap everything after the pool creation in try { ... } finally { executor.shutdown(); } (+12/-9 in IndexRearranger.java). The repro then passes with assertions on and off, the misc module's 317 tests pass, and so does :lucene:misc:check with the test's committable form. It does not wait for tasks still running; that is a separate bug, LFV-1011.

All options and trade-offs4 options ยท 599 words

Option A was run through gradle in a scratch worktree of e1faeba08bde and is recommended. B and C are alternatives a maintainer may prefer; neither was run as written here. No open PR changes the executor lines (#14996 and #16847 touch the file elsewhere).

A. Shut the pool down in a finally (validated by the checks) (recommended)

Wrap both phases of execute() (everything after the pool creation) in try { ... } finally { executor.shutdown(); }. The finder's and an independent verifier's versions of this fix are equivalent.

--- a/lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java
+++ b/lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java
@@ -123,17 +123,20 @@
     createSegmentsConfig.setMergePolicy(NoMergePolicy.INSTANCE);
     applyDeletesConfig.setMergePolicy(NoMergePolicy.INSTANCE);
 
-    try (IndexWriter writer = new IndexWriter(output, createSegmentsConfig);
-        IndexReader reader = DirectoryReader.open(input)) {
-      createRearrangedIndex(writer, reader, segmentSelectors, executor);
-    }
-    finalizeRearrange(output, segmentSelectors);
+    try {
+      try (IndexWriter writer = new IndexWriter(output, createSegmentsConfig);
+          IndexReader reader = DirectoryReader.open(input)) {
+        createRearrangedIndex(writer, reader, segmentSelectors, executor);
+      }
+      finalizeRearrange(output, segmentSelectors);
 
-    try (IndexWriter writer = new IndexWriter(output, applyDeletesConfig);
-        IndexReader reader = DirectoryReader.open(writer)) {
-      applyDeletes(writer, reader, deletedDocsSelector, executor);
+      try (IndexWriter writer = new IndexWriter(output, applyDeletesConfig);
+          IndexReader reader = DirectoryReader.open(writer)) {
+        applyDeletes(writer, reader, deletedDocsSelector, executor);
+      }
+    } finally {
+      executor.shutdown();
     }
-    executor.shutdown();
   }
 
   /**

Trade-offs

  • shutdown() neither waits nor interrupts running tasks (JDK: "previously submitted tasks are executed ... This method does not wait"; it interrupts only idle workers, which is how they exit), so tasks still running or queued when another task fails run to the end, and only then do the threads exit; a task that blocks keeps its thread, and the JVM, alive until it returns. That they run on a reader and writer being closed is the separate LFV-1011; this fix neither fixes nor worsens it.
  • Error path only; the success path already called shutdown(). No API or format change.

What the checks validated

  • Gradle, JDK 25, scratch worktree of e1faeba08bde (removed afterwards): the repro fails on the stock build with assertions on and off (10.3 s) and passes with the fix with assertions on and off (0.28 s).
  • With the fix: TestIndexRearranger, TestBinaryDocValueSelector and the repro with -Ptests.iters=5 and -XX:ActiveProcessorCount=4: 30 tests, 0 failures; the whole :lucene:misc:test: 24 classes, 317 tests, 0 failures, 9 skipped.
  • With the fix, the committable copy of the test passes :lucene:misc:check -x test; a copy without @ThreadLeakScope(NONE) passes the framework's leak check (on stock it fails with ThreadLeakError).
  • Earlier: the finder's and the verifier's tests and the exit-path harness (all 5 exit cases) pass with the fix shadowed; a cross-model reviewer's run kept TestIndexRearranger green.

B. shutdownNow() on the failure path

Also drops queued tasks and interrupts running ones, so a failed run stops sooner.

Trade-offs

Interrupting a thread blocked on I/O is risky in Lucene: NIOFSDirectory's javadoc warns it "can close the underlying file descriptor immediately" (NIOFSDirectory.java:41-46). Not tried.

C. Fix it together with LFV-1011

Wait for every task before rethrowing (LFV-1011's fix) and shut the pool down in a finally. Or give each phase its own pool as the last resource of its try-with-resources, so that ExecutorService.close() (JDK 19+; main requires Java 25) shuts down and waits before the reader and writer close.

Trade-offs

The combined diff (+35/-13) was validated for LFV-1011 on 645d4fe14a20, whose IndexRearranger.java is the same file; the close() variant was not tried, and a 9.x backport would need explicit shutdown() and awaitTermination().

Not enough alone: daemon threads

Make the pool's threads daemon, as the benchmark module's JoinIndices does.

Trade-offs

Lets the JVM exit, but a long-lived process still collects idle threads per failed call.

Downstream note

Only code that runs IndexRearranger itself, an offline operator-run tool, can hit it; no query or document path reaches it.

How it was found

Non-formal: in a sweep that passes unusual executors into Lucene components, the hunting AI tool found that IndexRearranger owns its pool, read its failure paths instead and saw shutdown() outside a finally. A JUnit counting live rearranger threads after a failed execute() confirmed it.

The method in full130 words

Method: non-formal (code reading during an executor sweep, 2026-10-26). The sweep passed unusual executors into the Lucene components that accept one; IndexRearranger creates its own, so none could be injected, and the hunting AI tool read its failure paths instead and saw executor.shutdown() as the last statement, outside any finally.

  • A JUnit that counts live rearranger- threads after a failed execute() failed 2 of 3 (failing selector, overlapping selectors; the success control passed). A cross-model review (an independent reviewer from another model family, OpenAI GPT-5.6, refute lens, executed) reproduced both paths.
  • A later hunt unit (2026-11-02) re-ran the lead on main e1faeba08bde, ran five exit cases of execute() through an exit-path harness on the stock jar and on a try/finally shadow, and reduced the repro to one test.

Prior reports, history and checks

Prior reports

No public report found (searched 2026-11-02: GitHub issues and PRs incl. the JIRA archive, the mailing lists, downstream projects and the web). The symptom shows, undiagnosed, in two 2022 CI mails that list 10 leaked rearranger threads after a failed test task.

Searches4 searches

Closest items, none a report of this bug:

  • builds@ 2022-07-15, Jenkins Lucene-main-Linux Build #35751: after a task failure (most likely the race #1019 fixed: opened two days earlier for a similar TestIndexRearranger NPE, merged three days later), the suite reported 10 leaked rearranger-2-thread-* threads and zombie threads. No reply.
  • builds@ 2022-11-20, Jenkins Lucene-9.4 Build #750: a task failed, execute() rethrew it, and ThreadLeakError listed 10 idle rearranger-2-thread-* threads. The one human reply (dev@, by the tool's author) addressed only the race behind the task failure. No issue was filed.
  • lucene-solr#2246 / #10733 (introduced it), #11815 (moved shutdown() for reuse), #1019, #630, #633: no discussion of failure cleanup. Open #14996 and #16847 touch the file but no executor line.
  • #14971 / #15675: the same bug class fixed in ConcurrentMergeScheduler; a precedent, not a report.

Limits: keyword search only; GitHub search does not index inline review comments (read through REST for every IndexRearranger PR).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits; Elasticsearch, OpenSearch, Solr2026-11-02three scripted prior-report searches (identifiers and messages; the bug class; file, ticket and CI traces) and 20 manual GitHub searchesno report; context items only
Recent and open PRs; the file's history on main, branch_11x and branch_10_62026-11-02REST reads of all 29 PRs updated since 2026-10-30 and of every IndexRearranger PR thread#14996 and #16847 touch the file, no executor line; the file is unchanged since #11815
Mailing lists (dev, java-user, issues, commits, builds)2026-11-02IndexRearranger, rearranger-2-thread, ThreadLeakError; both CI threads read in fulltwo undiagnosed 2022 CI mails (symptom only)
Downstream and the web2026-11-02luceneutil issues; 8 web searchesno report

History

Introduced with the class by LUCENE-9694 (e4cede0e8c5, 2021-01-29, first released in 8.9.0); #11815 (10.0.0) moved shutdown() to the end of execute(), so two more exits leak. Executed as affected from 8.9.0 to 10.6.0 and on main; not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-9694 "New tool for creating a deterministic index" (apache/lucene-solr#2246 by Patrick Zhai, merged by Mike McCandless on 2021-01-29 as e4cede0e8c5; branch_8x backport 3da86cb5b36 the same day, which 8.9.0 ships). From the PR's first commit, shutdown() was the last statement of the try block, after the future.get() loop, with no finally.

#683 (9.1.0, the backport of #630) added the segment-ordering check after the block; in 9.x shutdown() still ran before it. #11815 "Support deletions in rearrange" (369a70f2894, Stefan Vodita, 2022-11-22, 10.0.0) moved the pool creation to the top of execute() and shutdown() to its last line so the delete phase could reuse the pool. That made the existing overlapping-selector exit leak and added the delete phase's exit, which leaks too. The file has not changed since.

Versions and branches executed, with results
Version or commitVerdictResult
main e1faeba08bde affectedone-test repro fails with assertions on and off (the standalone JUnit runner and gradle); passes with the fix
10.6.0 affectedfails with assertions on and off (release jars, SHA-1 checked)
10.0.0, 10.5.2 affectedfails (10.0.0 is the first release with the #11815 layout)
9.0.0, 9.1.0, 9.12.3 affectedfails
8.9.0, 8.11.4 first affectedfails (8.9.0 is the first release with IndexRearranger)
8.8.2 no IndexRearrangerthe class does not exist (compile error)

Independent checks

Confirmed (adjudication, high confidence) by an independent reproducer, cross-model reviews and a devil's-advocate verifier, all from executed runs; a credibility audit confirmed it as a Low bug.

Checks and verdicts244 words

Everyone agrees on the root cause (shutdown() outside a finally) and the fix. The open points are judgement calls: impact is minor (only an already failing run).

Each independent check and its verdict
CheckVerdictWhat it found
first finder (executor sweep) reproducedIts JUnit failed 2 of 3 (failing selector, overlapping selectors; the success control passed); its cross-model refute review reproduced both.
second finder (later re-run) reproducedRe-ran the lead on current main (unchanged); of the five exit cases its exit-path harness ran, exactly the three failures after a task leak, and all are clean with the fix.
cross-model review (OpenAI GPT-5.6, intended lens) real, highIts own test failed, a plain main() kept the JVM alive, and the fix kept TestIndexRearranger green; intended argument weak.
Independent reproducer realIts own test fails on main, 10.6.0, 10.5.2 and 9.12.3 and passes with its fix; the plain main() check: alive at 20 s, 0.52 s with the fix.
Cross-model adversary (OpenAI GPT-5.6, refute lens) real, highRoot cause and fix agree, inputs in contract, intended argument weak.
Devil's advocate real; intended argument weakAdded the JoinIndices precedent, re-checked the CheckIndex one (already in the finder's report), and checked the reviews and tests: nothing marks the leak as intended.
adjudication and credibility audit confirmed, high, LowThe adjudication re-ran the independent reproduction's test with a new seed: 2 of 2 fail with assertions on and off.

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

Finding record

LFV-1080, snapshot of 2026-11-06

Registry title
IndexRearranger.execute() shuts down its own thread pool only when the run succeeds, so a run that fails after starting work leaves non-daemon 'rearranger' worker threads alive forever and keeps the JVM from exiting
Area
misc / index tools (IndexRearranger.execute: lifecycle of the method-local executor on the failure path)
Priority
Low, rank 1106: A failed IndexRearranger.execute() leaves non-daemon pool threads alive forever, so a command-line run that failed does not exit. The tool is experimental and mostly used offline, the run has already failed loudly and the input index is only read, so it is Low.
Impact class
minor
Impact note
Hits only callers of the experimental lucene/misc IndexRearranger (8.9.0 through 10.6.0 and main) whose execute() fails after it has submitted at least one task (a DocumentSelector that throws its declared IOException or an addIndexes error, and from 10.0.0 also the class's own overlapping-selector IllegalStateException or a failing deletes selector): execute() still throws the right exception, but each such call leaves min(availableProcessors, #selectors) idle non-daemon 'rearranger' pool threads that never end, so a command-line program whose rearrange failed does not exit after the exception leaves main (executed: still alive at a 20 s timeout, exits in 0.52 s with the fix) and a long-lived process that retries collects idle threads; the input index is only read, a failure before the first task starts no thread, and a successful execute() is unaffected [exec, -ea and -da].
Summary
You run IndexRearranger.execute() and it fails after it has started its work, for example because one of your DocumentSelectors throws an IOException. You expect execute() to throw that error and to release the thread pool it created for the run, as it does when the run succeeds. Instead executor.shutdown() is only the last statement of execute(), not in a finally, so on the failure path the pool's worker threads, which are non-daemon and never time out, stay alive forever: a program whose rearrange failed does not exit, and every failed call leaves more idle threads.
Introduced
e4cede0e8c5a640586c044f45e35448ef0f1862d (apache/lucene-solr#2246, LUCENE-9694 'New tool for creating a deterministic index', by GitHub zhaih, merged 2021-01-29 by mikemccand; JIRA migrated as apache/lucene#10733; branch_8x backport 3da86cb5b368 the same day) [read: the PR's first commit 4f75b105fb and its final commit 0e323b0ed8 already call executor.shutdown() as the last statement of the try block, after the future.get() loop, with no finally; no review comment discusses failure cleanup]. Present since the class was created; not a regression. #11815 (369a70f2894, by GitHub stefanvodita, author date 2022-11-22, first released in 10.0.0) moved the pool creation to the top of execute() and shutdown() to its last line (:136) to reuse the executor for the new deletes phase, which kept the defect, made the overlapping-selector IllegalStateException (the ordering check 9.1.0 added) leak, and added a second leaking exit, a failing deletes selector; git blame on main points at that commit, but the missing finally dates from e4cede0e8c5. (2021-01-29)
First affected version
8.9.0 (released 2021-06-16, through the branch_8x backport 3da86cb5b36) [exec: the one-test repro fails on the 8.9.0 jars]; on the main line 9.0.0 (released 2021-12-07) [exec]. Not affected: 8.8.2 and every earlier release, which have no IndexRearranger [exec: the repro does not compile against 8.8.2].
Affected versions
Every release that ships IndexRearranger: 8.9.0-8.11.4, 9.0.0-9.12.3 and 10.0.0-10.6.0 (released 2026-10-29, 4ae946e5a9b), plus main e1faeba08bde (12.0.0-SNAPSHOT); not fixed upstream (IndexRearranger.java last changed in 369a70f2894, author date 2022-11-22, on main, branch_11x and branch_10_6; re-checked 2026-11-02). [exec] the one-test repro (the independent reproduction's testSegmentSelectorFailure) fails 1 of 1 on 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.6.0 (-ea; 10.6.0 also -da) and on main (-ea and -da); the finder's test fails on main (-ea, -da) and with -da on 10.6.0, 10.5.2, 9.0.0 and 8.9.0; the independent reproduction's two-test class fails on 10.6.0 and 10.5.2 (-da) and its 9.x port on 9.12.3. The 10.6.0 runs used the released 10.6.0 jars from Maven Central (SHA-1 checked, commit 4ae946e5a9b9), because the repro needs lucene-misc. [read] every other release by identity: one IndexRearranger.java blob in 9.1.0-9.12.3, one in 10.0.0-10.6.0 equal to main, 9.0.0's execute() byte-identical to e4cede0e8c5's, and the javap bytecode of execute() in 8.10.0-8.11.3 identical to 8.9.0 and 8.11.4 (one shutdown() call, no finally copy). Which exits leak differs: in 8.9.0-9.12.3 a failing segment selector or addIndexes leaks (shutdown() ran before the segment-ordering check that 9.1.0 added, so its IllegalStateException did not leak there [read]); from 10.0.0 (#11815) the overlapping-selector IllegalStateException and a failing deletes selector leak too [exec on main: exit-path harness]. Not new in 10.6.
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: code reading
Upstream and prior reports
none found (2026-11-02, on top of the finder's 51-query scripted prior-report search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches): 200 logged queries, namely three fresh scripted prior-report searches (153 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, dev@, java-user@, issues@, commits@, the JIRA archive and the web), 20 manual GitHub searches and 10 REST reads (the file's history on main, branch_11x and branch_10_6; the changed files of all 29 PRs updated since 2026-10-30; the IndexRearranger patches of open PRs #14996 and #16847; the bodies, comments and review comments of lucene-solr#2246, #10733, #630, #11815, #1019 and #633; luceneutil #103-#129), 9 mailing-list reads by hand including builds@, and 8 web searches; 16 hits. Refresh (2026-11-02): upstream main head is still e1faeba08bde; the newest commit to IndexRearranger.java on main, branch_11x and branch_10_6 is 369a70f2894 (#11815, author date 2022-11-22); no apache/lucene issue or PR that mentions IndexRearranger was updated since 2026-10-31; the 5 PRs created since 2026-10-31 (#16956, #16957, #16959, #16960, #16961) do not touch misc/index. Public symptom only, never diagnosed: two automated builds@ Jenkins failure mails, Lucene-main Build #35751 (2022-07-15, https://lists.apache.org/thread/d9hk7yjd71gfl7fr9469x1jy7nfybvcf) and Lucene-9.4 Build #750 (2022-11-20, https://lists.apache.org/thread/q2m080c2s5cv8d1shvx06lc8cj5c78v0, with one dev@ reply https://lists.apache.org/thread/m3fn4dvmd6rwf9p1bnlwl7jv41qyjsch by the tool's author, zhaih, about the race behind the task failure only), list 10 leaked idle rearranger-2-thread-* threads (ThreadLeakError) after a TestIndexRearranger task failed; nobody named the missing shutdown or opened an issue or PR. Context, not reports: lucene-solr#2246 / #10733 (introduced it), #11815 (moved shutdown() for reuse), #1019 (fixed the race behind the 2022-07 task failure; does not mention the pool), #630 and #633 (feature work), open #14996 and #16847 (touch the file, change no executor line), #14971 / #15675 (the same bug class fixed in ConcurrentMergeScheduler, precedent), luceneutil#125 (temp-disk cleanup in the benchmark wrapper).
Lucene commit tested
main e1faeba08bde (the one-test repro with assertions on and off through the standalone JUnit runner and through gradle in a scratch worktree; the try/finally fix there: the repro, TestIndexRearranger and TestBinaryDocValueSelector with 5 iterations, the whole misc module, and :lucene:misc:check with the committable copy of the test); an equivalent one-test check on the 10.6.0 release jars (assertions on and off) and in the history runs on 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3 and 10.0.0; 10.5.2 (assertions off); 8.8.2 does not compile (no IndexRearranger).

Lucene and upstream sources

Finding record
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:57-60 (offline benchmark use case), :66 (@lucene.experimental), :113-137 (execute: pool at :114-117, try-with-resources at :126-129 and :132-135, executor.shutdown() at :136 outside any finally), :152-159 (overlapping-selector IllegalStateException), :197-199 and :230-232 (future.get() loops), :283 (getFilteredDocs throws IOException); lucene/core/src/java/org/apache/lucene/util/NamedThreadFactory.java:73 (setDaemon(false)); lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:593-614 (the same kind of pool shut down in a finally); lucene/CHANGES.txt:362 (GITHUB#14971)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:113-137, :114-117, :127, :132-135, :136, :146-173, :155-161, :180-200, :194, :197-199, :202-210, :230-232, :241, :256, :283 (at e1faeba08bde)
  • lucene/core/src/java/org/apache/lucene/util/NamedThreadFactory.java:30, :33, :41-48, :70-71, :73 (at e1faeba08bde)
  • JDK 25 src.zip java.base/java/util/concurrent/Executors.java (newFixedThreadPool(int, ThreadFactory) javadoc)
Who is affected
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:57-60, :66, :155, :283 (at e1faeba08bde)
Minimal repro
  • a Lucene clone: git ls-tree releases/lucene/9.0.0 and 9.1.0 lucene/test-framework (util/LuceneTestCase.java vs tests/util/LuceneTestCase.java)
Why it looks unintended
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:45-67, :114-117, :136, :283 (at e1faeba08bde)
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:588-616 (at e1faeba08bde)
  • lucene/benchmark/src/java/org/apache/lucene/benchmark/byTask/tasks/JoinIndices.java:90 (at e1faeba08bde)
  • lucene/CHANGES.txt:362-363 (at e1faeba08bde)
  • GitHub API: apache/lucene issue #14971 (body, opened by dweiss 2025-07-21), PR #15675 (title, files)
Suggested fix
  • JDK 25 src.zip java.base/java/util/concurrent/ExecutorService.java:150-160 (shutdown javadoc)
  • lucene/core/src/java/org/apache/lucene/store/NIOFSDirectory.java:41-46 (at e1faeba08bde)
  • gradle/libs.versions.toml:41 (minJava = "25") (at e1faeba08bde)

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

Repro files

About the diagrams

Made from the code at e1faeba08bde and the repro.

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.