All findings · Lucene finding · LFV-1011 · indexrearranger-failed-execute-closes-reader-under-running-tasks

A failed IndexRearranger.execute() closes its input reader under tasks that are still running

When a task of IndexRearranger.execute() fails while a task listed after it still runs, execute() stops waiting and closes the shared input reader, then the IndexWriter, under that task, whose selector code can then fail with AlreadyClosedException. Every release since 8.9.0 is affected.

Summary

Priority
Low, rank 1105Same pattern as LFV-357 (cleanup runs before the submitted tasks finish) in the experimental IndexRearranger, failing loudly with nothing corrupted.
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
semi-formal: failure-point model, then JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

When one task fails, execute() stops waiting at once (it waits in list order), and leaving its try-with-resources closes the reader and the writer that tasks listed after it are still using.

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() opens one IndexWriter on the output and one reader on the input, and runs one task per selector in a pool of min(processors, selectors) threads. Each task calls its selector on the input's segments and then writer.addIndexes(...).
  3. It then waits for the tasks with future.get(), one after the other in list order, inside a try-with-resources that closes the reader and the writer when the block ends.
Diagram of the bug: IndexRearranger.execute() runs two selector tasks in a 2-thread pool on one shared input reader and one shared IndexWriter. Task 1's selector throws IOException while task 2's selector is still running. The await loop's first Future.get() rethrows at once, and the try-with-resources closes the reader, then the writer, under task 2, whose next read fails with AlreadyClosedException. With the fix, execute() waits for task 2 to finish before the reader and the writer are closed.
Two selector tasks share one input reader and one IndexWriter. Task 1 throws, execute() stops waiting at the first failed Future.get(), and the try-with-resources closes the reader and then the writer while task 2 is still running on them. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Run IndexRearranger.execute() with two segment selectors on a machine with 2 or more processors. Both selectors run at the same time in a 2-thread pool, on one shared input reader, and each task then calls addIndexes on one shared IndexWriter.
  2. Selector 1 throws an IOException (its signature allows that) while selector 2 is still running. You expect execute() to throw that error once selector 2 has finished, and only then to close the reader and the writer.
  3. Instead execute() stops waiting at the first failed Future.get(), and its try-with-resources closes the reader, then the writer, under selector 2. The one-test repro sees the reader close under the running selector in every run: 20 of 20 with assertions off, and with assertions on too.
  4. Under normal timing: selector 2 scans the doc values of a 200,000-document segment (about 80 ms). The reader was closed 4.6 ms into the scan, and the scan failed with AlreadyClosedException. Since 9.4.0 an addIndexes merge still running also dies on a merge thread with an uncaught MergeException.
  5. execute() still throws the same first error, the input index is not changed and a successful run is unaffected: the damage is work left running on closed resources after a failure. It does not need two processors: with a one-thread pool the next queued task can start while the reader is being closed (9 of 10 check runs).

Who is affected

Callers of the experimental lucene/misc IndexRearranger with two or more selectors whose run fails (a selector throws, or addIndexes hits an I/O error) while a later-listed task still runs. That task can then fail with AlreadyClosedException; since 9.4.0 each in-flight merge adds an uncaught MergeException. The input index is not changed; successful runs are unaffected.

Mechanism, terms and numbers985 words

IndexRearranger.execute() opens the output IndexWriter and the input reader in one try-with-resources and runs one task per segment selector in a pool of min(processors, selectors) threads; each task calls its DocumentSelector on the input's segments and then writer.addIndexes(...). It waits with for (future : futures) future.get() in list order, so the first failed task makes it leave the block at once, without waiting for or stopping the tasks listed after it, and leaving the block closes the reader and then the writer while those tasks still use them. The delete phase (10.0.0+) has the same loop. In the one-test repro the reader is closed under a running selector in 20 of 20 runs; under natural timing a selector scanning a 200,000-document segment saw the reader closed 4.6 ms into its scan and failed with AlreadyClosedException, and since 9.4.0 an in-flight addIndexes merge also reports an uncaught MergeException on a merge thread. A one-thread pool is no protection: its thread can start the next queued task while the caller closes the reader (9 of 10 check runs). execute() still throws the same first exception as before, the input is only read and successful runs are unaffected, so the impact is minor. Waiting for every task before rethrowing fixes it; it is as old as the class (8.9.0) and not fixed upstream.

Terms

  • Segment selector (IndexRearranger.DocumentSelector): user code, BitSet getFilteredDocs(CodecReader reader) throws IOException, called once per input segment; the selected documents form one output segment.
  • Task: one per selector, submitted to the pool; it wraps each input segment in a filtered reader (whose constructor calls the selector) and passes them to writer.addIndexes(...).
  • try-with-resources: Java closes the resources in reverse order when the block ends, also when an exception leaves it; here the reader first, then the writer.

The bug (main 645d4fe14a20, IndexRearranger.java)

  1. execute() (:113-137) creates Executors.newFixedThreadPool(Math.min(availableProcessors, segmentSelectors.size()), ...) (:114-117) and opens try (IndexWriter writer = new IndexWriter(output, ...); IndexReader reader = DirectoryReader.open(input)) (:126-129).
  2. createRearrangedIndex (:180-200) submits one task per selector (:188-195). Each runs addOneSegment (:202-210): a DocSelectorFilteredCodecReader per leaf, whose constructor calls selector.getFilteredDocs(in) (:256), then writer.addIndexes(readers) (:209).
  3. It waits with for (Future<Void> future : futures) { future.get(); } (:197-199), in list order, so every task listed before the failed one has already finished. The first failed get() throws ExecutionException (the first failure in list order, not necessarily the first in time); the later futures are neither waited for nor cancelled. A check of both orders on main: [failing, running] closed the reader under the running selector in 5 of 5 runs, [running, failing] in 0 of 5.
  4. The exception leaves the try-with-resources, which closes the reader (its segment readers' closed listeners fire) and then the writer while the later tasks are still inside their selector or addIndexes. Only then does execute() throw.
  5. A running selector's next read fails with AlreadyClosedException: this IndexReader is closed; since 9.4.0 an addIndexes merge reading the input fails on its merge thread with MergePolicy$MergeException: AlreadyClosedException: Already closed: MemorySegmentIndexInput(.../_0.cfs) [slice=_0.fdx].
  6. In the selector phase, a task that starts only after the close fails at once in reader.leaves() (:204-205; CompositeReader.getContext() calls ensureOpen()), in a future nobody reads, so it never reaches its selector. The delete phase differs (code reading, not run): each task gets its segment reader before it is submitted (:221-227) and calls the deletes selector directly (:237), so a delete task that starts late does run the user's deletes selector.
  7. applyDeletes (:212-233, since 10.0.0) has the same loop (:230-232) inside the second try-with-resources (:132-135), whose reader is an NRT reader of that writer. It is a separate failure point, reached only when the selector phase succeeded, with one task per output segment (normally one per selector).

The one-test repro needs a pool of two threads, so that its signals can run both selectors at once. The bug does not: with one thread the tasks run one after the other, but FutureTask records the failure and wakes the caller before the pool thread takes the next queued task, which can then start while the caller is closing the reader. A check with the JVM limited to one processor (on a 32-processor host): the reader was closed under the next selector's scan in 9 of 10 runs on main, in 0 of 10 with the fix; a machine with a single core was not tried. Separately, executor.shutdown() (:136) runs only when both phases succeed, so a failed run also leaves its pool threads idle; that needs its own change.

The numbers

  • The one-test repro (one document, two selectors): the reader is closed under the running second selector in 20 of 20 runs on main with assertions off, and fails on the 10.5.2 and 10.6.0 release jars too; with the fix it passes 20 of 20.
  • A natural-timing probe (verification only, no ordering signals): one selector throws at once, the other scans a NumericDocValues field of a 200,000-document segment 100 times (57-93 ms when both succeed, 21 runs). On stock main the reader was closed 4.6 ms into the scan and the scan failed with AlreadyClosedException: Already closed: MemorySegmentIndexInput(.../_0.cfs) [slice=_0_Lucene90_0.dvd]. Over 10 iterations all 10 failure runs went wrong: 4 hit that exception mid-scan, and in 6 the scan never started within the probe's 60 s wait (from the code, the task met the already closed reader first). With the fix 20 of 20 pass, and the scan always finished 33-97 ms before the reader was closed.
  • A one-thread-pool check (the same selectors, -XX:ActiveProcessorCount=1, assertions off, 10 iterations): on main the reader was closed under the running scan in 9 of 10 runs (in the 10th the scan never started); with the fix in 0 of 10.
Exposure and likelihood323 words
  • Who is hit. Callers of IndexRearranger (@lucene.experimental, IndexRearranger.java:66) with two or more selectors whose run fails while a task listed after the failed one is still running (the await loop waits in list order). Two or more processors, the normal shape, make that the usual case; with a one-thread pool it is a race, which still happened in 9 of 10 check runs.
  • Triggers are ordinary. DocumentSelector.getFilteredDocs is declared throws IOException; addIndexes can fail on I/O. The shipped BinaryDocValueSelector.getFilteredDocs dereferences reader.getBinaryDocValues(field) without a null check, so a segment without that field throws NullPointerException (code reading). #1019 (2022) fixed a race after its author hit a rare TestIndexRearranger task failure, and a Jenkins build in November 2022 shows another task failure.
  • Default vs opt-in. The tool itself is opt-in (a misc module). Inside it no setting is needed: every multi-selector run on a multi-core machine waits with the early-leaving loop.
  • Per failed run. The tasks listed after the failed one that are running when the reader closes (at most one per pool thread) can each fail with AlreadyClosedException on their next read of the closed input, while execute() unwinds or after it has already thrown; since 9.4.0 each addIndexes merge still running also reports one uncaught MergePolicy.MergeException on a merge thread. In the selector phase, tasks that start after the close fail in futures nobody reads. The user's selector code can still run after execute() has thrown.
  • What does not happen. The input index is not changed (it is only read); execute() throws the same first exception as before (the first failure in list order); successful runs are unaffected; assertions on or off make no difference. The output of a failed run should not be relied on (not part of this claim).
  • Reach. Its main known consumer is benchmark tooling (luceneutil discussed using it for its nightly benchmark). No public report since 8.9.0 (2021) was found; likelihood rare, priority low.

Minimal repro

The test shows that a failed IndexRearranger.execute() rethrows the first selector's failure at once and closes its input reader while a second selector of the same call is still running on it.

TestIndexRearrangerFailedExecute.java (JUnit test)88 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.ThreadFilter;
import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.QuickPatchThreadsFilter;
import org.apache.lucene.util.FixedBitSet;

@ThreadLeakFilters(
    filters = {
      QuickPatchThreadsFilter.class,
      TestIndexRearrangerFailedExecute.RearrangerPool.class
    })
public class TestIndexRearrangerFailedExecute extends LuceneTestCase {

  public static class RearrangerPool implements ThreadFilter {
    @Override
    public boolean reject(Thread t) {
      return t.getName().startsWith("rearranger-");
    }
  }

  public void testFailedExecuteClosesReaderUnderRunningSelector() throws Exception {
    assumeTrue("needs two rearranger threads", Runtime.getRuntime().availableProcessors() >= 2);
    Directory in = new ByteBuffersDirectory();
    try (IndexWriter w = new IndexWriter(in, new IndexWriterConfig())) {
      w.addDocument(new Document());
    }
    Thread caller = Thread.currentThread();
    CompletableFuture<Void> secondEntered = new CompletableFuture<>();
    CompletableFuture<Void> callerGotFailure = new CompletableFuture<>();
    CompletableFuture<Void> readerClosed = new CompletableFuture<>();
    CompletableFuture<Boolean> closedUnderSecond = new CompletableFuture<>();
    IndexRearranger.DocumentSelector first =
        reader -> {
          secondEntered.join();
          throw new IOException("first selector fails") {
            @Override
            public String toString() {
              callerGotFailure.complete(null);
              return super.toString();
            }
          };
        };
    IndexRearranger.DocumentSelector second =
        reader -> {
          reader.getReaderCacheHelper().addClosedListener(_ -> readerClosed.complete(null));
          secondEntered.complete(null);
          callerGotFailure.join();
          while (readerClosed.isDone() == false && caller.getState() == Thread.State.RUNNABLE) {
            Thread.onSpinWait();
          }
          closedUnderSecond.complete(readerClosed.isDone());
          return new FixedBitSet(reader.maxDoc());
        };
    IndexRearranger rearranger =
        new IndexRearranger(
            in, new ByteBuffersDirectory(), new IndexWriterConfig(), List.of(first, second));
    expectThrows(ExecutionException.class, rearranger::execute);
    // expected: the reader is closed after the second selector returns; actual: closed under it
    assertFalse("reader closed under the running second selector", closedUnderSecond.join());
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-1011/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.misc.index.TestIndexRearrangerFailedExecute
Expected vs actual
How: the second selector keeps running until the caller either closes the reader under it (the bug) or stops running to wait for it (the fix). It starts this check once the caller holds the first failure: Future.get() builds new ExecutionException(cause), which calls the failure's toString(). No sleeps or timeouts. Needs at least 2 processors (pool = min(processors, selectors)), otherwise it is skipped.

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

More files, environment, full output37 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: reader closed under the running second selector
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.junit.Assert.assertFalse(Assert.java:65)
       org.apache.lucene.misc.index.TestIndexRearrangerFailedExecute.testFailedExecuteClosesReaderUnderRunningSelector(TestIndexRearrangerFailedExecute.java:86)

Why it looks unintended

  • IndexReader.close(): "No other methods should be called after this has been called". IndexRearranger opens the reader for its own tasks, then closes it while they still call it.
  • Lucene fixed this pattern in its own TaskExecutor: wait for all tasks before rethrowing (#12523), cancel the rest (#12689).
  • Nothing documents the current behaviour, and selectors may throw IOException by signature.
The full argument240 words
  • Reader contract. IndexReader.close(): "Closes files associated with this index. Also saves any new deletions to disk. No other methods should be called after this has been called." (IndexReader.java:345-346). addIndexes(CodecReader...): "The provided IndexReaders are not closed." (IndexWriter.java:3257), so the caller keeps them open for the call. IndexRearranger opens the reader itself and hands it to its own tasks, so it is that caller.
  • Precedent. Issue #12278 (opened by javanna) asked: "Is it a reasonable expectation that once IndexSearcher#search(Query, CollectorManager) returns, that specific search has completed and has left no threads behind still working on it? I would expect this to be true". 9.9.0 then shipped "GITHUB#12523: TaskExecutor waits for all tasks to complete before returning when Exceptions are thrown during concurrent operations" and "GITHUB#12689: TaskExecutor to cancel all tasks on exception to avoid needless computation." (lucene/CHANGES.txt:3168, :3198); the code says "We rather want to make sure that invokeAll does not leave any running tasks behind when it returns." (TaskExecutor.java:186). StandardDirectoryReader keeps processing the remaining futures after one fails (:208-216), and CheckIndex shuts down and awaits its pool in a finally (:599-614).
  • Not documented. execute() has no javadoc; the class javadoc says nothing about failures; the CHANGES entries are features; TestIndexRearranger has 3 tests, all on successful runs, with no expectThrows.
  • Legal trigger. DocumentSelector.getFilteredDocs is declared throws IOException (IndexRearranger.java:283), and addIndexes(CodecReader...) declares IOException.
The devil's-advocate check235 words

The strongest case for "works as designed" (the intended-behaviour verifier, the cross-model review and the adjudication): IndexRearranger is an @lucene.experimental offline tool whose javadoc promises nothing on failure, fail-fast Future.get() loops are a common idiom, and IndexWriter is built to tolerate close() racing addIndexes: LUCENE-1335's TestAddIndexes.testAddIndexesWithClose, testAddIndexesWithCloseNoWait and testAddIndexesWithRollback close or roll back the writer while other threads add indexes, and since LUCENE-10216 (9.4.0) addIndexes(CodecReader...) merges are aborted on close (IndexWriter.java:3298-3302). So the IndexWriter.close() NOTE, "You must ensure no other threads are still making changes at the same time that this method is invoked." (:1359-1360), is weak support for the writer half.

Why it fails: it does not cover the reader half, where both observed failures come from (the selector's read, and the merge's read of the input _0.cfs, because the reader is closed before the writer). IndexRearranger owns the reader and the tasks and closes the reader while its own tasks still call it, and Lucene rejected fail-fast for its own concurrent code (#12278, #12523, #12689). @lucene.experimental is about API stability, not about leaving work running. The argument limits priority (low), not validity; this is an inference from the code, its contracts and precedent, not a maintainer ruling. Conceded: IndexReader.close() is a usage rule, not a concurrency or lifetime protocol, and a maintainer could reasonably see this as low-value clean-up of a run that has already failed.

Suggested fix

Recommended A. Wait for every task before rethrowing

Wait for every task before rethrowing: both await loops call get() on every future, keep the first ExecutionException and add later failures as suppressed (+23/-4 in IndexRearranger.java). The repro then passes with assertions on and off, and the misc module's tests and :lucene:misc:check pass. Trade-off: queued tasks still run to completion on a failing run.

All options and trade-offs3 options · 770 words

Option A was run through gradle in a scratch worktree of 645d4fe14a20 and is recommended, ideally together with shutting the pool down in a finally (the separate pool defect). B and C are alternatives a maintainer may prefer; neither was run as written. No open PR changes the await loops.

A. Wait for every task before rethrowing (validated by the checks) (recommended)

Both await loops (createRearrangedIndex and applyDeletes) call a new awaitAll: get() on every future, keep the first ExecutionException, add the later causes as suppressed, and throw the first one after the loop. The reader and the writer then close only after every task has finished.

--- a/lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java
+++ b/lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java
@@ -194,8 +194,29 @@
       futures.add(executor.submit(addSegment));
     }
 
+    awaitAll(futures);
+  }
+
+  /**
+   * Waits for every task before rethrowing the first failure, so that no task is still using the
+   * writer or the reader when the caller closes them.
+   */
+  private static void awaitAll(List<Future<Void>> futures)
+      throws ExecutionException, InterruptedException {
+    ExecutionException first = null;
     for (Future<Void> future : futures) {
-      future.get();
+      try {
+        future.get();
+      } catch (ExecutionException e) {
+        if (first == null) {
+          first = e;
+        } else {
+          first.addSuppressed(e.getCause());
+        }
+      }
+    }
+    if (first != null) {
+      throw first;
     }
   }
 
@@ -227,9 +248,7 @@
       futures.add(executor.submit(applyDeletesToSegment));
     }
 
-    for (Future<Void> future : futures) {
-      future.get();
-    }
+    awaitAll(futures);
   }
 
   private static void applyDeletesToOneSegment(

Trade-offs

  • A failing execute() returns only after every submitted task has finished, including queued ones, which still do their full addIndexes work for a run that is reported as failed (bounded: one task per selector). B avoids that.
  • Only ExecutionException is collected. An InterruptedException in get() still leaves the loop at once, so an interrupt of the calling thread can still close the reader under running tasks; a fuller fix keeps waiting and restores the interrupt flag, or uses C. (A CancellationException cannot occur: nothing cancels these futures.)
  • A task that hangs now also keeps a failing execute() waiting, indefinitely, as it already does for a successful one.
  • It does not shut the pool down on failure (:136); the study's combined change adds a finally that does (+35/-13). With both, the regression test needs no thread-leak filter (executed).
  • Future.cancel(false) alone would not do: a running FutureTask keeps running after cancel, and get() then throws CancellationException at once, which is why TaskExecutor overrides cancel.
  • Error path only; no API or format change.

What the checks validated

Gradle with JDK 25 in a scratch worktree of 645d4fe14a20 (removed afterwards):

  • the repro fails on the stock build and passes with the fix, assertions on and off; its committable copy (unused lambda parameter renamed to _) too;
  • with the fix, TestIndexRearranger, TestBinaryDocValueSelector and the repro with 5 iterations and -XX:ActiveProcessorCount=4: 30 tests, 0 failures;
  • with the fix, the whole :lucene:misc:test (default settings): 24 classes, 317 tests, 0 failures, 10 skipped;
  • :lucene:misc:check -x test with the fix and the committable test: BUILD SUCCESSFUL;
  • a filter-free copy of the test (a check only): with A alone the suite fails on 2 leaked idle rearranger threads; with A plus the pool shutdown in a finally it passes (1 of 1 with assertions on, 5 of 5 off).

Earlier, by the study: the finder's two-test class and an independent three-leg class pass with the fix.

B. Cancel the tasks that have not started, then wait for the running ones

As TaskExecutor does since #12689: wrap each task so that it records when it starts; on the first failure cancel every task that has not started and wait for the started ones before rethrowing.

Trade-offs

Saves the wasted work of A on a failing run. More code: plain Future.cancel(false) returns true for a queued and for a running task alike, so it cannot tell them apart; it needs an atomic started flag as in TaskExecutor. Not tried.

C. Drain the pool before the reader and writer close

Use a pool per phase and declare it as the last resource of each try-with-resources, so that its close() runs first: ExecutorService.close() (JDK 19+) shuts down and waits for all tasks, and on interrupt calls shutdownNow() and keeps waiting for the running ones. This also fixes the pool leak.

Trade-offs

Queued tasks still run unless also cancelled. Interrupts are cooperative, so a selector that ignores them keeps close() waiting. Lucene 10.x and main (Java 21+) have close(); a 9.x backport (Java 11) needs an explicit shutdown() and awaitTermination() in a finally. Not tried.

Downstream note

Downstream, its main known users are benchmark tools (luceneutil discussed it for its nightly benchmark); no query or document path reaches it.

How it was found

Semi-formal: while building a failure-point model of execute() (no task may outlive it, and the reader and writer must not close under a task), the hunting AI tool found the early-leaving future.get() loop by reading the code. The model's bounded runs never hit it with 6-document inputs; deterministic JUnit tests on the stock jars then showed it.

The method in full167 words

Method: semi-formal (the hunt's unit on failed-run cleanup in IndexRearranger and PKIndexSplitter, 2026-10-29, main 645d4fe14a20). The hunting AI tool built a failure-point model of execute() with obligation I4: no task of the call outlives execute(), and the writer and reader are not closed while a task uses them. Reading the modelled resource lifecycle against the reader and writer contracts, it saw that the await loop leaves on the first failure inside the try-with-resources.

  • The model's bounded-exhaustive single-fault model-based test (4,147 IndexRearranger runs, each at 1 and at 4 processors; vacuity mutants killed) never fired I4 with its 6-document inputs, so the model did not find it by itself.
  • Two deterministic, latch-ordered public-API JUnit tests on the stock jars showed it (a selector still running after execute() threw, and an addIndexes merge dying on a merge thread).
  • On 2026-10-29 the repro was cut to one test with no sleeps or timeouts; on 2026-11-01 a current-main re-verification and a tie-break re-ran it and added a natural-timing probe.

Prior reports, history and checks

Prior reports

No public report found (searched 2026-11-01: GitHub issues and PRs incl. the JIRA archive, the mailing lists, downstream projects and the web). Closest: a 2022 CI mail showing idle pool threads left after a task failure (the separate pool defect) and #1019 (a race that made one task fail).

Searches4 searches

Closest items, none a report of this bug:

  • dev@ 2022-11-20, "[JENKINS] Lucene-9.4-Linux Build # 750": a TestIndexRearranger task failed in addIndexes, execute() rethrew it, and the suite then reported 10 idle rearranger threads. No closed reader and no task still running: a symptom of the separate pool defect. The tool's author could not reproduce it; no issue was filed.
  • #1019 (2022): fixed the FieldInfos race behind a rare TestIndexRearranger task failure; it does not discuss what execute() does with the other tasks.
  • lucene-solr#2246 / #10733 (the introducing change), #630/#683/#11407 and #11814/#11815 (features), #633 (why the merge leg shows on a merge thread since 9.4): no discussion of failures.
  • #14996 and #16847 (open) touch IndexRearranger.java elsewhere; #16919 (merged 2026-10-28) changed, in misc/index, only BinaryDocValueSelector (and its test), not IndexRearranger.java.
  • #12523, #12689, #12278 and elastic/elasticsearch#95909: the same bug class, fixed or tracked for TaskExecutor; precedents, not reports.

Limits: keyword search only; GitHub search does not index inline PR review comments (they were read for every IndexRearranger thread); 13 broad bug-class queries were truncated at 50 results.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits; Elasticsearch, OpenSearch, Solr2026-11-01three scripted prior-report searches (identifiers and error messages; the bug class; the misc/index tool family) and 16 direct GitHub searchesno report of this bug; closest #1019
Recent and open PRs2026-11-01all PRs created 2026-10-28 to 2026-10-31 with their files; the file lists of open PRs updated since 2026-10-28#14996 and #16847 touch the file, not the await loops
Mailing lists (dev, java-user, issues, commits)2026-11-01rearranger and IndexRearranger; the 2022 CI thread read in fullthe 2022 CI mail (pool symptom only)
Downstream and the web2026-11-01luceneutil issues; 6 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) added the delete phase with the same loop. Executed as affected from 8.9.0 to 10.6.0 and on main; not fixed upstream.

Versions tested5 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 is what 8.9.0 ships). The class was created with the future.get() loop inside the try-with-resources over the writer and the reader. #630/#683 (9.1.0) added segment reordering after the block; #11815 (369a70f2894, Stefan Vodita, 2022-11-22, 10.0.0) moved the loop into createRearrangedIndex and added applyDeletes with the same loop. The file has not changed since; no fix was ever attempted.

How it shows depends on the version: in 8.9.0-9.3.0 addIndexes(CodecReader...) merges in the task's own thread, so no merge-thread exception appears; from 9.4.0 (LUCENE-10216, #633) an in-flight merge also dies on a merge thread.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedone-test repro fails with assertions on and off (standalone and gradle); passes with the fix
10.6.0, 10.5.2 affectedone-test repro fails with assertions on and off (release jars, SHA-1 checked)
8.11.4, 9.0.0, 9.1.0, 9.4.0, 9.12.3, 10.0.0 affectedone-test repro fails (8.x and 9.0.0 with two test-framework imports changed to the pre-9.1 package)
8.9.0 first affectedfails with assertions on and off; passes with only the await loop changed
8.8.2 no IndexRearrangerthe class does not exist (compile error)

Independent checks

Confirmed (adjudication, medium confidence) by an independent reproducer, a devil's-advocate verifier and an adjudication, all from executed runs, and by a cross-model review (reading only). Re-verified on current main and in a tie-break that added a natural-timing probe; a credibility audit confirmed it.

Checks and verdicts319 words

Everyone agrees on the root cause: the await loop leaves on the first failure inside the try-with-resources. The open points are judgement calls: impact is minor (only an already failing run), and the reader contract is stronger support than the IndexWriter.close() note.

Each independent check and its verdict
CheckVerdictWhat it found
finder (failure-point model and JUnit) candidateFound it by reading the modelled lifecycle; two latch-ordered tests fail on main with assertions on and off, 20 of 20 over 10 iterations.
cross-model review (OpenAI GPT-5.6, intended lens) real, medium (reading only)Rated "experimental tool, nothing promised on failure" weak. The refute lens returned not real only because it could not run code; its substantive points support the claim.
Independent reproducer realIts own three-leg test (selector, addIndexes, delete phase) fails on main and 10.5.2, and in its two applicable legs on 9.12.3 (no delete phase there); passes with the fix.
Devil's advocate real; framing correctedShowed the writer half is weak support and the reader half holds; corrected the remark that queued tasks run on (they fail in reader.leaves()).
adjudication confirmed, medium, minorRe-ran the independent reproduction's test with a new seed (3 of 3 fail) and with the fix (3 of 3 pass).
credibility audit, current-main verify, tie-break confirmed; real; realThe minimal test fails 20 of 20 on main and on 10.5.2 and 10.6.0; the tie-break's probe without ordering signals shows the same under natural timing.
fact checks (an independent one that re-ran the repro and the fix, and a cross-model one, reading only) core claim holds; wording correctedBoth agree the bug is real. They narrowed when it triggers (only tasks listed after the failed one are left running), pointed out that a one-thread pool still races (a check run then showed it: 9 of 10), and corrected smaller details; this page includes the corrections.

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

Finding record

LFV-1011, snapshot of 2026-11-06

Registry title
A failed IndexRearranger.execute() throws without waiting for its other tasks, and closes the input reader and the IndexWriter while those tasks still use them
Area
misc / index tools (IndexRearranger.execute failure path: the await loops of createRearrangedIndex and applyDeletes inside the try-with-resources)
Priority
Low, rank 1105: Same pattern as LFV-357 (cleanup runs before the submitted tasks finish) in the experimental IndexRearranger, failing loudly with nothing corrupted.
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() is already failing, because one task's DocumentSelector throws or addIndexes hits an I/O error, while another task of the same call still runs (needs a pool of at least two threads, that is two or more selectors and processors, the normal shape): execute() still throws the correct first exception, but the other tasks' selector code and addIndexes merges keep running after it on a reader and a writer it has closed and then fail with AlreadyClosedException, and since 9.4.0 each in-flight merge also reports one extra uncaught MergePolicy.MergeException on a merge thread; nothing is corrupted, the input index is only read, and a successful execute() is unaffected [exec, -ea and -da].
Summary
You run IndexRearranger.execute() with several segment selectors and one of the tasks fails (its selector throws an IOException, or addIndexes hits an I/O error). You expect execute() to throw that error only after the tasks it started have stopped using its input reader and output IndexWriter. Instead execute() rethrows the first failed Future.get() at once and its try-with-resources closes the reader and the writer while the other tasks still run, so their selector code keeps running after execute() has thrown and fails with AlreadyClosedException 'this IndexReader is closed', and an addIndexes merge that is still running dies on a merge thread with an uncaught MergeException.
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; backported to lucene-solr branch_8x the same day as 3da86cb5b368) [read: git log -S 'future.get();' and git blame -w -M -C -C on IndexRearranger.java in the full history clone; the commit created the class with 'for (Future f : futures) f.get()' inside the try-with-resources over the writer and the reader]. Present since the class was created; not a regression. #11815 (369a70f2894, 2022-11-22, first released in 10.0.0) moved the loop into createRearrangedIndex and added the delete phase (applyDeletes) with the same loop. (2021-01-29)
First affected version
8.9.0 (tag 2021-06-16, through the branch_8x backport 3da86cb5b36) [exec: the finder's repro fails 2 of 2 with -ea and -da]; on the main line 9.0.0 (tag 2021-12-01) [exec: 2 of 2]. Not affected: 8.8.2 and every earlier release, where the class does not exist [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.5.2, plus branch_10_6 (10.6.0, released 2026-10-29), branch_10x and main up to at least 645d4fe14a20. [exec] the finder's repro fails 2 of 2 on 8.9.0 (-ea and -da), 8.11.4, 9.0.0, 9.3.0, 9.4.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b and main (-ea, -da, 20 of 20 over 10 iterations); the independent reproduction's own test fails on main (-ea and -da, 30 of 30 over 10 iterations), 10.5.2 (-ea and -da) and 9.12.3. [read] every other release has one of the three IndexRearranger.java blobs that were executed (17fb74d2234 in 8.9.0-8.11.4 and 9.0.0, c05a1a7b959 in 9.1.0-9.12.3, 7c3bd53cece in 10.0.0-10.5.2, branch_10_6, branch_10x and main). How it shows differs: in 8.9.0-9.3.0 addIndexes merges in the task's own thread, so the closed reader and the IndexWriter.close() under the running merge show but no merge-thread exception does; from 9.4.0 (LUCENE-10216, #633) the in-flight merge also dies on a merge thread with MergePolicy.MergeException(AlreadyClosedException). The delete-phase instance exists from 10.0.0 on (executed by the independent reproduction on main and 10.5.2). Not fixed upstream: IndexRearranger.java last changed in 369a70f2894 (author date 2022-11-22).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading
Upstream and prior reports
none found (searched 2026-10-29, read-only, and refreshed the same day): 177 query records: three scripted prior-report searches (135 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, and the JIRA archive; 127, 3,770 and 3,116 distinct hits), 27 GitHub API calls (25 searches over apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and mikemccand/luceneutil, 2 commit listings), the changed-file lists of all 415 open apache/lucene PRs and of every PR created since 2026-10-26, list-archive thread reads, the git history of main, branch_10x and branch_10_6, and 7 web searches. All 16 hits are other defects, the introducing and later feature changes, or precedents. Nearest item, not a report of this bug: a 2022-11-20 dev@ CI thread (Lucene-9.4-Linux build 750) in which one TestIndexRearranger task failed inside addIndexes and the suite then reported leaked idle 'rearranger' pool threads, a public symptom of the sibling pool-lifecycle defect with no closed-reader or merge-thread failure; #1019 (2022) was triggered by a similar task failure and fixed only a FieldInfos race. Precedents for the expected behaviour: #12523 and #12689 (TaskExecutor waits for, then cancels, the other tasks; 9.9.0). Not fixed upstream: IndexRearranger.java last changed in 369a70f2894 (author date 2022-11-22) on main, branch_10x and branch_10_6; the open PRs that touch it (#14996, #16847) do not change execute(), createRearrangedIndex or applyDeletes, and #16919 (merged 2026-10-28) changed only BinaryDocValueSelector.
Lucene commit tested
main 645d4fe14a20 (the one-test repro with assertions on and off standalone and through gradle in a scratch worktree; the await-all fix there: the repro, TestIndexRearranger and TestBinaryDocValueSelector with 5 iterations, the whole misc module and :lucene:misc:check; checks of the task order and of a one-thread pool, stock and with the fix); the 10.6.0 and 10.5.2 release jars (assertions on and off); the history runs on 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.4.0, 9.12.3 and 10.0.0; the study's two-test and three-leg classes on main, 10.5.2 and 9.12.3. Upstream main 2f424fd428da (2026-10-30) has the same IndexRearranger.java (read).

Lucene and upstream sources

Finding record
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:66 (@lucene.experimental), :113-137 (execute), :126-129 and :132-135 (try-with-resources), :136 (executor.shutdown only on success), :180-200 and :197-199 (createRearrangedIndex await loop), :202-210 (addOneSegment, addIndexes at :209), :212-233 and :230-232 (applyDeletes await loop), :283 (DocumentSelector.getFilteredDocs throws IOException); lucene/core/src/java/org/apache/lucene/index/IndexReader.java:345-346 (close: no other methods after close); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1359-1360 (close NOTE), :3257 (addIndexes: the provided IndexReaders are not closed)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:113-137 (:114-117, :126-129, :132-135, :136), :180-200 (:188-195, :197-199), :202-210 (:204-205, :209), :212-233 (:221-227, :230-232), :235-246 (:237), :253-258 (:256), :282-284 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/CompositeReader.java:93-94 (at 645d4fe14a20)
  • JDK 25.0.3 sources: java.base/java/util/concurrent/FutureTask.java:310-316 (setException wakes the waiters), :318-348 (run)
Who is affected
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:66, :114-117, :283 (at 645d4fe14a20)
  • lucene/misc/src/java/org/apache/lucene/misc/index/BinaryDocValueSelector.java:52-56 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:345-346; lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1359-1360, :3257, :3298-3302 (at 645d4fe14a20)
  • lucene/CHANGES.txt:3168, :3198 (Lucene 9.9.0), :2210, :4492, :5367 (IndexRearranger features) (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:182-197 (:186); lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:208-216; lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:599-614 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestAddIndexes.java:1174-1175, :1260-1261, :1286; lucene/misc/src/test/org/apache/lucene/misc/index/TestIndexRearranger.java (3 tests, no expectThrows) (at 645d4fe14a20)
  • lucene/misc/src/java/org/apache/lucene/misc/index/IndexRearranger.java:45-67, :283 (at 645d4fe14a20)
  • GitHub API (read-only, 2026-11-01): apache/lucene issue #12278 body (javanna)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:176-197 (setException cancels all; cancel override with a started flag) (at 645d4fe14a20)
  • JDK 25.0.3 sources: java.base/java/util/concurrent/ExecutorService.java close() javadoc (@since 19; shutdownNow on interrupt)
  • a Lucene clone: versions.toml minJava 21 at releases/lucene/10.5.2; build.gradle minJavaVersion VERSION_11 at releases/lucene/9.12.3
History
  • lucene/CHANGES.txt:4007 (LUCENE-10216, Lucene 9.4.0) (at 645d4fe14a20)

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