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.
IndexRearranger(modulelucene/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 outputDirectoryand a list ofDocumentSelectors; each selector picks the documents of one output segment.execute()starts a pool ofmin(processors, selectors)threads for this call and runs one task per selector on it; each task calls its selector and thenwriter.addIndexes(...).- 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 aftermainreturns.
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.- Index one document and run
IndexRearranger.execute()with oneDocumentSelectorthat throwsIOException.execute()creates a pool ofmin(processors, selectors)= 1 thread,rearranger-2-thread-1, and runs the selector there. - 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. - Instead the exception skips
executor.shutdown(), which is only the last line ofexecute(), not in afinally. 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. - 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 leavesmin(processors, selectors)idle threads; Lucene's own CI listed 10 after a failed test task, twice in 2022. - 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(); }.
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.
- Segment selector (
IndexRearranger.DocumentSelector): user code,BitSet getFilteredDocs(CodecReader reader) throws IOException(IndexRearranger.java:283). - Fixed thread pool:
Executors.newFixedThreadPool(n, factory)is aThreadPoolExecutor(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.newThreadcallst.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 withNAME_PATTERNin the constructor,:33,:41-48) and<n>from the factory's own counter (:70-71), hencerearranger-2-thread-1.
execute()(:113-137) creates the pool as a local variable (:114-117).createRearrangedIndex(:180-200) submits one task per selector (:194); each runsaddOneSegment(:202-210), whose filtered reader's constructor callsselector.getFilteredDocs(in)(:256), thenwriter.addIndexes(readers)(:209).- It waits with
for (Future<Void> future : futures) { future.get(); }(:197-199). A failed task makesget()throwExecutionException, which leavesexecute(). executor.shutdown()is the last statement (:136), outside anyfinally, so it is skipped. Nothing else references the pool: no executor parameter, no field, noclose().
how execute() ends | where | leaks |
|---|---|---|
| success | :136 | no |
| no index in the input directory | DirectoryReader.open(input), :127, before any task | no (no thread started yet) |
a segment selector or addIndexes fails | future.get(), :197-199 | yes, since 8.9.0 |
| two selectors match the same segment | IllegalStateException, :155-161 | yes, since 10.0.0 |
| the deletes selector fails | future.get(), :230-232 | yes, 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) whoseexecute()fails after it submitted at least one task. In 8.9.0-9.12.3 only an exception out of thefuture.get()loop leaks (a failing segment selector oraddIndexes, or an interrupted caller); from 10.0.0 (#11815) also the overlapping-selectorIllegalStateExceptionand 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 callshutdown()before the ordering check (read from source). - Triggers are ordinary.
getFilteredDocsis declaredthrows IOException;addIndexescan 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 skipsshutdown(). - 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 failedTestIndexRearrangertask (2022). - Visible effects. A batch JVM does not exit on its own after
mainthrows (executed with one selector: still alive at 20 s, 0.52 s with the fix); a long-lived process that retries collects threads; tests getThreadLeakError. - 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.
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.
/*
* 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());
}
}
}
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()throwsExecutionException(causeIOException: selector failed) and its pool's worker thread ends.- Observed
AssertionError: rearranger-2-thread-1 still alive10 s afterexecute()threw, with assertions on and off; 0.28 s pass with the fix.
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+.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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:execute()creates the pool itself and shuts it down only on success; the caller cannot reach it.CheckIndexshuts the same kind of pool down in afinally; #14971 fixed the same failure-path leak inConcurrentMergeScheduler.- 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 callsshutdown()on success (:136). There is no executor parameter, field orclose(), so onlyexecute()can release it. - In-tree code does it right.
CheckIndex.checkIndex(List<String>)createsExecutors.newFixedThreadPool(threadCount, new NamedThreadFactory("async-check-index"))and shuts it down in afinally(CheckIndex.java:588-616). The benchmark module'sJoinIndicesmakes 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 withThreadLeakError: 1 thread leaked from SUITE scope ... rearranger-2-thread-1, state=WAITINGand "There are still zombie threads that couldn't be terminated"; the 2022 CI mails show the same forTestIndexRearranger. - Legal trigger, nothing documented.
getFilteredDocsis declaredthrows IOException(:283).execute()has no javadoc, the class javadoc says nothing about threads or failures, and the existing tests callexecute()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.
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).
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,TestBinaryDocValueSelectorand the repro with-Ptests.iters=5and-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 withThreadLeakError). - 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
TestIndexRearrangergreen.
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.
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().
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.
Only code that runs IndexRearranger itself, an offline operator-run tool, can hit it; no query or document path reaches it.
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 failedexecute()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 ofexecute()through an exit-path harness on the stock jar and on a try/finally shadow, and reduced the repro to one test.
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
TestIndexRearrangerNPE, merged three days later), the suite reported 10 leakedrearranger-2-thread-*threads and zombie threads. No reply. - builds@ 2022-11-20, Jenkins Lucene-9.4 Build #750: a task failed,
execute()rethrew it, andThreadLeakErrorlisted 10 idlerearranger-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).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits; Elasticsearch, OpenSearch, Solr | 2026-11-02 | three scripted prior-report searches (identifiers and messages; the bug class; file, ticket and CI traces) and 20 manual GitHub searches | no report; context items only |
| Recent and open PRs; the file's history on main, branch_11x and branch_10_6 | 2026-11-02 | REST 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-02 | IndexRearranger, rearranger-2-thread, ThreadLeakError; both CI threads read in full | two undiagnosed 2022 CI mails (symptom only) |
| Downstream and the web | 2026-11-02 | luceneutil issues; 8 web searches | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main e1faeba08bde | affected | one-test repro fails with assertions on and off (the standalone JUnit runner and gradle); passes with the fix |
| 10.6.0 | affected | fails with assertions on and off (release jars, SHA-1 checked) |
| 10.0.0, 10.5.2 | affected | fails (10.0.0 is the first release with the #11815 layout) |
| 9.0.0, 9.1.0, 9.12.3 | affected | fails |
| 8.9.0, 8.11.4 | first affected | fails (8.9.0 is the first release with IndexRearranger) |
| 8.8.2 | no IndexRearranger | the class does not exist (compile error) |
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).
| Check | Verdict | What it found |
|---|---|---|
| first finder (executor sweep) | reproduced | Its 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) | reproduced | Re-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, high | Its own test failed, a plain main() kept the JVM alive, and the fix kept TestIndexRearranger green; intended argument weak. |
| Independent reproducer | real | Its 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, high | Root cause and fix agree, inputs in contract, intended argument weak. |
| Devil's advocate | real; intended argument weak | Added 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, Low | The adjudication re-ran the independent reproduction's test with a new seed: 2 of 2 fail with assertions on and off. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 468 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,413 words
- 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,TestIndexRearrangerandTestBinaryDocValueSelectorwith 5 iterations, the whole misc module, and:lucene:misc:checkwith 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).
- 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)
- TestIndexRearrangerFailureShutsDownPool.java (JUnit, one test):
repro/LFV-1080/TestIndexRearrangerFailureShutsDownPool.java(52 lines)
Made from the code at e1faeba08bde and the repro.
assets/LFV-1080/bug.png(468 KB)
