All findings · Lucene finding · LFV-1061 · directoryreader-executor-open-leaks-readers-of-accepted-tasks

DirectoryReader.open and openIfChanged with an ExecutorService leak the segment readers of accepted tasks when a later submit is rejected

If the ExecutorService you pass to DirectoryReader.open or openIfChanged accepts one segment task and rejects the next, as a full bounded pool does, the open fails as it should, but the accepted task's reader is never closed. Its files stay open, and you cannot close it: you never got it.

Summary

Priority
Low, rank 1092A bounded reader-open leak on an exception path that happens only with the opt-in executor overload (10.4+) when a submit is rejected or the opening thread is interrupted, and the open fails loudly.
Impact
minor
Since
10.4.0 (#15428, 7d4e22454b5, 2025-11-21; came with the API)introduced 2025-11-21
Affected
10.4.0-10.6.0 and main; only the opt-in ExecutorService overloads
Status
documented
Found by
formal: TLA+ model checking (TLC) of reader reference counting, then a public-API JUnit test

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Background: opening a reader with an executor

  1. A DirectoryReader is a list of segment readers, one per segment. Opening a segment reader opens that segment's files, and closing it closes them.
  2. Since 10.4.0 you can pass an ExecutorService to DirectoryReader.open or openIfChanged. Lucene then opens the segments in parallel, one task per segment, and collects the readers.
  3. An ExecutorService may refuse a task: submit throws RejectedExecutionException, for example when a bounded ThreadPoolExecutor is full or the executor was shut down.
Diagram of the bug: DirectoryReader.open with an executor submits one task per segment, newest first. The executor accepts and runs the task for segment _1, which opens _1's files, and rejects the task for _0 with RejectedExecutionException. The exception leaves the submit loop before the collect loop and its cleanup run, so _1's reader is never closed and its files stay open; the caller gets only the exception. The fix closes the readers of tasks that started before rethrowing.
One open of a two-segment index: the executor runs the task for _1 and rejects the one for _0; the exception skips the loop that collects and closes the readers, so _1's reader and its files stay open. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Commit two documents separately, so the index has two segments, _0 and _1, and call DirectoryReader.open(dir, executor). Lucene submits one task per segment, newest first: _1, then _0.
  2. The executor runs the _1 task, which opens _1's files, then rejects the _0 task with RejectedExecutionException, as a full ThreadPoolExecutor with the default AbortPolicy does.
  3. You expect that exception and nothing left open: no reader was returned to you, and the code's own comment says a failed open closes the readers it created.
  4. Instead the exception leaves the submit loop before any result is collected, so _1's reader is never closed: closing the directory fails with "still 2 open files: {_1.fdx=1, _1.fdt=1}". Every failed attempt leaks again; for openIfChanged, a reused segment reader stays at refCount 2 instead of 1.

Who is affected

Applications that pass an executor to DirectoryReader.open or openIfChanged (opt-in, since 10.4.0) that can reject: a full bounded pool with the default AbortPolicy, for example one shared with other work, or a pool shut down during an open. Each rejected attempt leaks the readers of the tasks already accepted. Results stay correct.

Mechanism, terms and numbers665 words

Since 10.4.0, DirectoryReader.open and openIfChanged accept an ExecutorService and open one segment per task (#15428). StandardDirectoryReader.createSegmentReaders submits all tasks in one loop with no try/catch, and closes readers on failure only in its second loop, the one that collects the results. When the executor accepts some tasks and then rejects a submit, as a full bounded ThreadPoolExecutor with the default AbortPolicy does, RejectedExecutionException leaves the method from the first loop: the readers the accepted tasks built are never collected and never closed, and the caller cannot close them because it never receives them. With two one-document segments and an executor that runs the first task and rejects the second, MockDirectoryWrapper then refuses to close: "there are still 2 open files: {_1.fdx=1, _1.fdt=1}". For a refresh, a reused segment reader is left at refCount 2 instead of 1. Interrupting the opening thread while it waits leaks that task's reader the same way. The open fails loudly and search results are unaffected; the leak is silent and repeats on every failed attempt. Every release from 10.4.0 to 10.6.0 and main is affected. A fix that skips tasks that have not started and waits for the running ones passes the test and the closest suites.

Terms

  • Executor overloads: eight public DirectoryReader methods take an ExecutorService; six of them can reach StandardDirectoryReader.createSegmentReaders (StandardDirectoryReader.java:187-247): DirectoryReader.open(Directory, ExecutorService) and five siblings (open with a leaf sorter or an IndexCommit, openIfChanged with or without an IndexCommit; DirectoryReader.java:71, :106-107, :164, :217-221, :269-270, :295-296 at 645d4fe14a20), added in 10.4.0 by #15428. The initial open reaches it at :96, a refresh through the reopen factory at :181. The other two, the NRT overloads that also take an IndexWriter (DirectoryReader.java:383-384, :425-429), never use the executor: they return null or call writer.getReader (StandardDirectoryReader.java:444-475). On an NRT reader only openIfChanged with an IndexCommit uses the executor, by reopening to the commit (StandardDirectoryReader.java:420-430, :496-505).
  • Rejection: ExecutorService.submit throws RejectedExecutionException when it cannot take a task, for example a ThreadPoolExecutor whose threads and queue are full, with the JDK's default AbortPolicy, or any executor after shutdown().

The bug

  1. The executor branch (:195-228) first submits one task per segment, newest first, in a loop with no try/catch (:197-207, executor.submit at :203). A task opens a new SegmentReader (:292-296) or, for a refresh, takes a reference on the old reader of an unchanged segment ("this reader will be shared between the old and the new one, so we must incRef it", :319-323).
  2. Then it collects the results with Future.get() into readers[] (:208-217). If a task failed it keeps going, "to allow us to close created readers" (:213-214), and the cleanup decRefWhileSuppressingException(firstException, readers) (:221) releases every collected reader.
  3. A RejectedExecutionException from the submit at :203 leaves the method before step 2. The futures of the tasks already accepted are dropped without being awaited, their readers never reach readers[], and the cleanup never runs. The caller gets only the exception.
  4. Second trigger: an InterruptedException from get() is caught together with ExecutionException (:212), and the loop moves on. The task it was waiting for still runs; if it builds a reader, nobody stores or closes it.
  5. The branch without an executor (:229-245) creates readers inside try and releases them on any Throwable (:241-242).

The numbers

caseexpectedactual
open of 2 segments; the executor runs _1's task and rejects _0'sRejectedExecutionException, then 0 files openthe exception, then 2 files open: _1.fdx, _1.fdt (main; _1.cfs on 10.4.0-10.6.0)
openIfChanged; a later submit is rejected, or the opener is interruptedthe reused segment reader back at refCount 1refCount 2
open of 1 segment; the opener is interrupted while its task is still queued0 files open after the task has run5 files of _0 open
Exposure and likelihood269 words
  • Who is hit. Applications that call one of the six executor overloads that can reach this code with an executor that can reject. No Lucene module passes an executor here itself: IndexWriter uses the NRT open, and the replicator calls the reopen factory with null.
  • Default vs opt-in. Opt-in. Without an executor the sequential branch runs, which cleans up. Common pools such as Executors.newFixedThreadPool queue without bound and do not reject while running.
  • What triggers it. (a) A bounded pool with AbortPolicy that is full when the open submits its tasks: a stock ThreadPoolExecutor(1, 1, 0, ms, new SynchronousQueue<>()) whose worker is busy reproduced it. (b) An executor shut down while an open or refresh is in flight, after it accepted the first task (if it rejects the first submit, nothing leaks). (c) Secondary: the opening thread is interrupted, or cancelled with Future.cancel(true), while it waits.
  • How much. Per failed attempt, the reader of every task accepted before the rejection: its open files and segment core; for a refresh, an extra reference on each reused segment reader, so closing the old reader no longer closes it. Lucene never releases them. From the code, linear in failed attempts (a retry loop was not run) and in accepted tasks (one leaked reader in the minimal test, two in the first finder's three-segment open test); any index with at least two segments. Whether garbage collection later reclaims file handles was not measured.
  • What does not happen. No wrong results, no index change, no hang. The open or refresh fails loudly, and after a failed refresh the old reader keeps working.

Minimal repro

One JUnit test on the public API: a MockDirectoryWrapper, NoMergePolicy, two one-document commits and an inline executor that runs the first task and rejects the next submit, a deterministic stand-in for a full bounded pool. It expects the RejectedExecutionException, then a clean dir.close(). Single thread, about a second.

TestExecutorOpenRejectedSubmitLeak.java (one test, public API)77 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.index;

import java.util.List;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestExecutorOpenRejectedSubmitLeak extends LuceneTestCase {

  public void testRejectedSubmitReleasesReaderOfAcceptedTask() throws Exception {
    Directory dir = newMockDirectory();
    IndexWriter w =
        new IndexWriter(dir, newIndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE));
    w.addDocument(new Document());
    w.commit();
    w.addDocument(new Document());
    w.close();

    AbstractExecutorService acceptOneThenReject =
        new AbstractExecutorService() {
          boolean accepted;

          @Override
          public void execute(Runnable task) {
            if (accepted) throw new RejectedExecutionException();
            accepted = true;
            task.run();
          }

          @Override
          public void shutdown() {}

          @Override
          public List<Runnable> shutdownNow() {
            return List.of();
          }

          @Override
          public boolean isShutdown() {
            return false;
          }

          @Override
          public boolean isTerminated() {
            return false;
          }

          @Override
          public boolean awaitTermination(long timeout, TimeUnit unit) {
            return true;
          }
        };
    expectThrows(
        RejectedExecutionException.class, () -> DirectoryReader.open(dir, acceptOneThenReject));
    // expected: dir closes cleanly; actual: MockDirectoryWrapper reports the accepted segment's files still open
    dir.close();
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestExecutorOpenRejectedSubmitLeak.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestExecutorOpenRejectedSubmitLeak -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure); every seed tried fails
Expected
open throws RejectedExecutionException and the directory then closes with no file left open.
Observed
MockDirectoryWrapper: cannot close: there are still 2 open files: {_1.fdx=1, _1.fdt=1}, the files of the accepted segment _1; assertions on and off.

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

More files, environment, full output278 words

Two segments are the fewest that allow "accept one, reject the next"; running the accepted task in the calling thread makes the outcome independent of timing. The single oracle is that the directory closes with nothing open; the same test is the regression test. Assertions on and off give the same failure, and every seed tried fails (DEADBEEF, 1, C0FFEE); only the names of the leaked files change with the randomized codec, and they always belong to _1.

./gradlew :lucene:core:checkGoogleJavaFormat flags only the test's one comment line (113 characters), which is too wide for the formatter. The independent reproducer and the cross-model reviews also reproduced the leak with a stock single-worker ThreadPoolExecutor over a SynchronousQueue whose worker was held inside the first task; that variant needs a gate in the directory to be deterministic, so it stays supporting evidence. All runs were on Linux aarch64 with OpenJDK 25.0.3; the bug is plain control flow, so no platform dependence is expected.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.4.0-10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestExecutorOpenRejectedSubmitLeak > testRejectedSubmitReleasesReaderOfAcceptedTask FAILED
    java.lang.RuntimeException: MockDirectoryWrapper: cannot close: there are still 2 open files: {_1.fdx=1, _1.fdt=1}
        at org.apache.lucene.tests.store.MockDirectoryWrapper.close(MockDirectoryWrapper.java:884)
        at org.apache.lucene.index.TestExecutorOpenRejectedSubmitLeak.testRejectedSubmitReleasesReaderOfAcceptedTask(TestExecutorOpenRejectedSubmitLeak.java:75)

        Caused by:
        java.lang.RuntimeException: unclosed IndexInput: _1.fdx
            at org.apache.lucene.tests.store.MockDirectoryWrapper.openInput(MockDirectoryWrapper.java:843)
            ...
            at org.apache.lucene.index.SegmentReader.<init>(SegmentReader.java:95)
            at org.apache.lucene.index.StandardDirectoryReader.createOrReuseSegmentReader(StandardDirectoryReader.java:296)
            at org.apache.lucene.index.StandardDirectoryReader.lambda$createSegmentReaders$0(StandardDirectoryReader.java:205)
            at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
            at org.apache.lucene.index.TestExecutorOpenRejectedSubmitLeak$1.execute(TestExecutorOpenRejectedSubmitLeak.java:46)
            at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:149)
            at org.apache.lucene.index.StandardDirectoryReader.createSegmentReaders(StandardDirectoryReader.java:203)
1 test completed, 1 failed

(gradle, main 645d4fe14a20, seed DEADBEEF, assertions on; assertions off gives the same message. The "Caused by"
trace shows the leaked file was opened inside the accepted task, called from the submit at line 203. On the
10.4.0-10.6.0 release jars the same test reports {_1.cfs=1}: the codec picked for the seed differs.)

Why it looks unintended

  • The method's own comment: "we still process the rest of the completed futures to allow us to close created readers".
  • The #15428 author kept a plain ExecutorService "to ensure we could gracefully close out the readers in the event of an exception".
  • Upstream's testExecutorServiceExceptionHandling requires a clean close after a rejecting executor; it rejects only the first submit.
The full argument212 words
  • The code says so. "If there is an exception creating the reader we still process the rest of the completed futures to allow us to close created readers" (StandardDirectoryReader.java:213-214).
  • The author said so. In the #15428 review a reviewer suggested TaskExecutor; the author answered: "I went with an ExecutorService here directly to ensure we could gracefully close out the readers in the event of an exception" (BryceKan3, 2025-11-18).
  • The same method without an executor releases every reader on any Throwable (:229-245).
  • Upstream tests. TestDirectoryReader.testExecutorServiceExceptionHandling (:1300-1316) passes a shut-down executor, expects a RuntimeException, then closes a newDirectory(), which fails on open files. It rejects the first submit, so no task was accepted. testExecutorServicePartialFailure (:1318-1335) fails a task through a failed future, not a rejected submit. Neither covers this path.
  • Ownership. IndexReader.incRef (IndexReader.java:164-174): "Be sure to always call a corresponding decRef, in a finally clause; otherwise the reader may never be closed." The caller cannot do that for readers it never received.
  • Rejection is ordinary. Lucene's own TaskExecutor treats rejecting executors as legitimate: "execute directly on the current thread in case of rejection to ensure a rejecting executor only reduces parallelism and does not result in failure" (search/TaskExecutor.java:58-67, #13622).
The devil's-advocate check133 words

The strongest case for "intended" (the devil's-advocate verifier and the intended-lens reviewer): the javadoc of the executor overloads says only "executorService used to open segment readers in parallel" and @throws IOException (DirectoryReader.java:64-70), and the executor is the caller's own object, so its rejections are the caller's problem.

Why it fails: the caller can release only what it receives, and the failed open returns nothing; the code comment, the author's review answer, the sequential branch and upstream's rejecting-executor test all require cleanup. For the interrupt trigger the case is stronger: FSDirectory (store/FSDirectory.java:77-82) tells applications that use Thread.interrupt() or Future#cancel to switch to RAFDirectory. That note is about channels closed by an interrupt during I/O, while this code catches InterruptedException on purpose and carries on. Verdict: rejection high, interrupt medium.

Suggested fix

Recommended B: close what started, never wait for what did not (claim or wait)

Recommended, fix B (validated): give each task a claim flag. Once the open fails, the opener claims every task that has not started, so that task builds nothing and is not waited for; it waits only for running tasks, closes every reader built and rethrows. The minimal test and the 68 tests of the closest suites pass.

All options and trade-offs4 options ยท 1,644 words

Both fixes were run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3; seeds DEADBEEF and 5EED1234) with the minimal repro, the closest suites (TestDirectoryReader, TestDirectoryReaderReopen, TestIndexReaderClose, TestReaderClosed) and the format check, and standalone with the supporting tests. They differ in what a failed open waits for.

B: close what started, never wait for what did not (claim or wait) (validated by the checks) (recommended)

In createSegmentReaders, wrap the submit loop in try/catch (RuntimeException). Each task first claims an AtomicBoolean; if the claim fails, it returns null without opening anything. In the collect loop, once the open has failed (a rejected submit, a failed task or an interrupt), the opener claims each task that has not started and skips it; it waits only for tasks that already started. Then it releases every collected reader as today, restores the interrupt flag and rethrows. +45/-17 lines in StandardDirectoryReader.java.

@@ -31,6 +31,7 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.lucene.store.AlreadyClosedException;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IOContext;
@@ -194,31 +195,58 @@
     final SegmentReader[] readers = new SegmentReader[sis.size()];
     if (executor != null) {
       List<Future<SegmentReader>> futures = new ArrayList<>();
-      for (int i = sis.size() - 1; i >= 0; i--) {
-        SegmentCommitInfo commitInfo = sis.info(i);
-        SegmentReader oldReader =
-            getOldSegmentReader(
-                oldReaders, previousSegmentReaders.get(commitInfo.info.name), commitInfo);
-        futures.add(
-            executor.submit(
-                () ->
-                    createOrReuseSegmentReader(
-                        commitInfo, oldReader, sis.getIndexCreatedVersionMajor())));
-      }
+      // A task only creates a reader if it claims its flag first. Once the open has failed, we
+      // claim the flags of the tasks that have not started yet, so we never wait for them.
+      List<AtomicBoolean> claims = new ArrayList<>();
       RuntimeException firstException = null;
+      try {
+        for (int i = sis.size() - 1; i >= 0; i--) {
+          SegmentCommitInfo commitInfo = sis.info(i);
+          SegmentReader oldReader =
+              getOldSegmentReader(
+                  oldReaders, previousSegmentReaders.get(commitInfo.info.name), commitInfo);
+          AtomicBoolean claim = new AtomicBoolean();
+          futures.add(
+              executor.submit(
+                  () ->
+                      claim.compareAndSet(false, true)
+                          ? createOrReuseSegmentReader(
+                              commitInfo, oldReader, sis.getIndexCreatedVersionMajor())
+                          : null));
+          claims.add(claim);
+        }
+      } catch (RuntimeException e) {
+        // e.g. RejectedExecutionException: readers of tasks that already started are closed below
+        firstException = e;
+      }
+      boolean interrupted = false;
       for (int i = 0; i < futures.size(); i++) {
-        try {
-          readers[sis.size() - 1 - i] = futures.get(i).get();
-        } catch (ExecutionException | InterruptedException e) {
-          // If there is an exception creating the reader we still process
-          // the rest of the completed futures to allow us to close created readers
-          if (firstException == null) firstException = new RuntimeException(e);
+        while (true) {
+          if (firstException != null && claims.get(i).compareAndSet(false, true)) {
+            break; // the task has not started, and now it never creates a reader
+          }
+          try {
+            readers[sis.size() - 1 - i] = futures.get(i).get();
+            break;
+          } catch (InterruptedException e) {
+            // Skip the task if it has not started yet, else wait for the reader it creates
+            interrupted = true;
+            if (firstException == null) firstException = new RuntimeException(e);
+          } catch (ExecutionException e) {
+            // If there is an exception creating the reader we still process
+            // the rest of the completed futures to allow us to close created readers
+            if (firstException == null) firstException = new RuntimeException(e);
+            break;
+          }
         }
       }
       if (firstException != null) {
         // If its a new reader decRef should close... otherwise old readers we will remove the new
         // refs that were created
         decRefWhileSuppressingException(firstException, readers);
+        if (interrupted) {
+          Thread.currentThread().interrupt();
+        }
         // Unwrap IOExceptions
         Throwable cause = firstException.getCause();
         if (cause instanceof ExecutionException && (cause).getCause() instanceof IOException) {

Trade-offs

  • Strict and live. When open throws, every reader built has been released. Once the open has failed, the opener never waits for a task that has not started: a full or shut-down pool cannot make a failed open hang, and an interrupted opener returns once the running task finishes. It still waits for tasks that have started, as today's success path does.
  • Cost. One AtomicBoolean and one compareAndSet per segment on the success path. It still calls executor.submit, so executors that override submit (as upstream's createExecutorServiceThatFailsOneTask does) see no change. No API change.
  • Behaviour change. After a failure, still-queued tasks become no-ops. The cross-model reviewer's stock-pool test assumes its accepted task always opens a file; under B the opener may claim that task first, so the test fails that precondition ("accepted worker never opened a segment file") in its 3 iterations and the suite reports the pool threads it left behind; its other 9 runs pass. Not a leak.
  • Scope. Catching RuntimeException also covers the IllegalStateException that getOldSegmentReader (:263-285) can throw inside the submit loop; an Error from submit still skips the cleanup, as today.

What the checks validated

Gradle, scratch worktree of 645d4fe14a20, removed afterwards: the minimal test fails stock and passes with B, assertions on and off; TestDirectoryReader (34), TestDirectoryReaderReopen (30), TestIndexReaderClose (3) and TestReaderClosed (1): 68 of 68 pass stock (seed DEADBEEF; the fact check also ran them stock at 5EED1234, 68 of 68) and with B (seeds DEADBEEF and 5EED1234); checkGoogleJavaFormat flags only the test's comment line, not the fix, and passes with B once the test is removed. Standalone: the minimal test (-ea, -da), the independent reproducer's interrupt test (-ea, -da; it hangs with fix A) and the first finder's three tests (two openIfChanged, one open) pass.

A: wait for every submitted task (drain) (validated by the checks)

Wrap the submit loop in try/catch (RuntimeException), then wait for every future already submitted, also across an interrupt; release what was collected, restore the interrupt flag and rethrow. The study's sketch, re-done so that it keeps the method's comments. +35/-17 lines.

@@ -194,31 +194,49 @@
     final SegmentReader[] readers = new SegmentReader[sis.size()];
     if (executor != null) {
       List<Future<SegmentReader>> futures = new ArrayList<>();
-      for (int i = sis.size() - 1; i >= 0; i--) {
-        SegmentCommitInfo commitInfo = sis.info(i);
-        SegmentReader oldReader =
-            getOldSegmentReader(
-                oldReaders, previousSegmentReaders.get(commitInfo.info.name), commitInfo);
-        futures.add(
-            executor.submit(
-                () ->
-                    createOrReuseSegmentReader(
-                        commitInfo, oldReader, sis.getIndexCreatedVersionMajor())));
-      }
       RuntimeException firstException = null;
+      try {
+        for (int i = sis.size() - 1; i >= 0; i--) {
+          SegmentCommitInfo commitInfo = sis.info(i);
+          SegmentReader oldReader =
+              getOldSegmentReader(
+                  oldReaders, previousSegmentReaders.get(commitInfo.info.name), commitInfo);
+          futures.add(
+              executor.submit(
+                  () ->
+                      createOrReuseSegmentReader(
+                          commitInfo, oldReader, sis.getIndexCreatedVersionMajor())));
+        }
+      } catch (RuntimeException e) {
+        // e.g. RejectedExecutionException: still wait for the tasks that were already submitted,
+        // so that the readers they create are closed below
+        firstException = e;
+      }
+      boolean interrupted = false;
       for (int i = 0; i < futures.size(); i++) {
-        try {
-          readers[sis.size() - 1 - i] = futures.get(i).get();
-        } catch (ExecutionException | InterruptedException e) {
-          // If there is an exception creating the reader we still process
-          // the rest of the completed futures to allow us to close created readers
-          if (firstException == null) firstException = new RuntimeException(e);
+        while (true) {
+          try {
+            readers[sis.size() - 1 - i] = futures.get(i).get();
+            break;
+          } catch (InterruptedException e) {
+            // Keep waiting: the task may still create a reader that must be closed
+            interrupted = true;
+            if (firstException == null) firstException = new RuntimeException(e);
+          } catch (ExecutionException e) {
+            // If there is an exception creating the reader we still process
+            // the rest of the completed futures to allow us to close created readers
+            if (firstException == null) firstException = new RuntimeException(e);
+            break;
+          }
         }
       }
       if (firstException != null) {
         // If its a new reader decRef should close... otherwise old readers we will remove the new
         // refs that were created
         decRefWhileSuppressingException(firstException, readers);
+        if (interrupted) {
+          Thread.currentThread().interrupt();
+        }
         // Unwrap IOExceptions
         Throwable cause = firstException.getCause();
         if (cause instanceof ExecutionException && (cause).getCause() instanceof IOException) {

Trade-offs

Simplest. But it waits for every accepted task, also tasks that have not started. The independent reproducer's interrupt test, whose queued task can run only after open returns, hangs (timeout after 120 s, the opener parked in FutureTask.get). If the executor drops an accepted task, as shutdownNow() does with queued tasks without completing their futures (probe), a failed open would wait forever where today it throws at once. Today's success path already waits for every accepted task the same way.

What the checks validated

The same gradle runs as B: the minimal test passes with assertions on and off; 68 of 68 at seeds DEADBEEF and 5EED1234; the format check flags only the test's comment line, not the fix. Standalone: the cross-model reviewer's 12 tests (-ea, -da, 3 iterations) and the first finder's three tests pass.

Not recommended: Future.cancel(false) before waiting

The study's reviewers suggested cancelling the futures that have not started. The Future API cannot tell "not started" from "running".

Trade-offs

A probe on JDK 25: cancel(false) on a running FutureTask returns true, the task keeps running and produces its value, and get() then throws CancellationException: the value is lost. The reader of a running task would leak in a new way. B gets "cancel only if not started" from its claim flag.

Not run: run a rejected task on the calling thread

As TaskExecutor does (search/TaskExecutor.java:58-67): catch the rejection and run the task in the opener's thread, so the open succeeds on a full pool.

Trade-offs

Changes behaviour that upstream's testExecutorServiceExceptionHandling asserts (a shut-down executor makes open throw), and does not fix the interrupt trigger, so it would still need A or B.

Downstream note

Services that share one bounded pool between reader refreshes and request work, and retry a failed refresh, leak again on every rejected attempt while the pool stays full.

How it was found

Formal: TLA+ model checking (TLC) of Lucene's reader reference counting and its open paths. With the faults "a later submit is rejected" and "the opener is interrupted while it waits", the invariant that no reader leaks fails, and the modelled fix passes. Two code-reading passes found the same spot; a public-API JUnit test confirmed it.

The method in full143 words

Method: formal, TLA+ model checking with TLC (a hunt unit, 2026-10-26), on a model of IndexReader reference counting and the StandardDirectoryReader open paths built from the code with a file:line map.

  • With the faults rejectAt (a later submit is rejected) and interrupt (the opener is interrupted in Future.get), the NoLeak invariant fails; the model with the fix passes. Re-run on 645d4fe14a20 by a hunt unit: rejectAt3 violated at 93 distinct states (fixed: 77, OK), interrupt2 violated at 215 (fixed: 132, OK).
  • The fault edges were added while modelling the code, so a strict reading could call the discovery semi-formal.
  • Found independently by two other hunt units: one by code reading during an exception-path scan (both triggers), one by code reading while modelling commit discovery (interrupt trigger).
  • A later hunt unit re-ran the model and reduced the counterexample to the one-test repro.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, open PRs' file lists, JIRA, the mailing lists, downstream projects and the web (2026-10-30 and 2026-11-01). #15428 introduced the code; #15466 shows the same MockDirectoryWrapper message for an unrelated test problem.

Searches3 searches

Context only: #15428 / #15445 / #15387 (introduced the API; the review discusses cleanup on exceptions, not a rejected later submit or an interrupt), #15466 / #15523 (same message in testExecutorServicePartialFailure, caused by random merges in the test helper and fixed in the test), #15580 (too many open files on the sequential path, closed as not reproducing), #6326 (LUCENE-5262, 2013: the NRT open made to decRef readers on exception), #13622 (TaskExecutor runs rejected tasks on the caller).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web2026-10-30 and 2026-11-01a scripted prior-report search (51 queries, 341 hits; #15428 at rank 1, all 13 medium hits read), 16 GitHub queries with qualifier variants, 35 own GitHub searches (RejectedExecutionException, AbortPolicy, createSegmentReaders, the #15428 author, ...), 18 on downstream projects, 6 JIRA queries; every comment and review of #15428no report; context #15428, #15445, #15387, #15466, #15523, #15580, #6326, #13622
Open apache/lucene PRs2026-11-01changed files of all 410 open PRs#16923, #16847 and #15556 touch StandardDirectoryReader.java, none the submit or collect loop
apache/lucene main commits2026-11-01commit list of StandardDirectoryReader.javano change since 2026-02-11 (#15681); main head 2f424fd428d (2026-10-30)

History

Came with the API: #15428 (7d4e22454b5, 2025-11-21; backport c5a4d07e554, #15445). First released in 10.4.0; 10.4.0 through 10.6.0 and main fail the test, and 10.3.2 lacks the API. Not fixed upstream.

Versions tested6 rows

Introduced by 7d4e22454b5, "Adding support for passing an ExecutorService into DirectoryReader.open() to enable concurrent segment reader initialization (#15428)" (fixes #15387; author date 2025-11-21), backported to branch_10x as c5a4d07e554 (#15445, 2025-11-23 UTC). git blame attributes the whole executor branch (:195-228) to it. The PR's first commit 40de473462f (2025-11-13) already had both loops; d62b706e31d (2025-11-19) moved openIfChanged onto the same helper. New code that came with the API, not a regression.

First release 10.4.0 (release commit 2026-02-20, tag 2026-02-25). Upstream main 2f424fd428d (2026-10-30) has no commit to StandardDirectoryReader.java after 448c7d8e241 (2026-02-11, #15681), which leaves the executor branch alone.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle and standalone, assertions on and off: {_1.fdx=1, _1.fdt=1}
10.6.0, 10.5.1, 10.5.0 affectedthe minimal test fails on the release jars (-da): {_1.cfs=1}
10.5.2 affectedthe minimal test fails with assertions on and off (re-run 2026-11-01): {_1.cfs=1}
10.4.0 affectedthe minimal test fails with assertions on and off; the first release with the API
10.3.2, 9.12.3 not affectedno DirectoryReader.open(Directory, ExecutorService) (compile error)
main 645d4fe14a20 + fix B fixthe minimal test and 68 closest-suite tests pass through gradle

Independent checks

Confirmed with high confidence by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6), none dissenting; confirmed by a credibility audit; re-verified on 2026-11-01 and re-run through gradle for this doc, with two fixes validated.

Checks and verdicts318 words

Each checker that executed wrote or re-ran a test from the claim; the adjudication re-ran the independent reproducer's test with assertions on and off.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTLC re-run on 645d4fe14a20: violations without the fix, pass with it. The minimal test fails on main (assertions on and off), 10.6.0, 10.5.2 and 10.4.0 and passes with the fix sketch; the other finders' tests fail (3/3, 2/2, 1/1).
Independent reproducer realIts own test fails on main (assertions on and off), 10.6.0, 10.5.2 and 10.4.0; an accept-all control leaves nothing open; a stock single-worker pool variant fails; the interrupt trigger reproduces.
refute review (OpenAI GPT-5.6) real, highExecuted. 9 of 9 leak assertions of its own class fail with assertions on and off; the fixed-pool control passes; re-run on 645d4fe14a20 on 2026-11-01: 9 of 12 fail stock, 12 of 12 pass with the study's fix sketch.
intended review (OpenAI GPT-5.6) real, highExecuted; its stock-pool test fails with assertions on and off.
Intended-behaviour devil's advocate real; high (rejection), medium (interrupt)The intended case does not survive for the rejection; re-ran the finder's test on main and 10.6.0 (fails).
adjudication confirmed, highRe-ran the independent reproduction's test (assertions on and off, seed 5EED1234) and the finder's test (seed 0BADF00D): all fail. Class minor.
credibility audit confirmed, LowThe test re-run fails with assertions on and off; the audit confirmed it as a real, minor exception-path leak.
re-verification 2026-11-01 real, not fixed on mainThe minimal test fails on main and on 10.5.2 with assertions on and off; no upstream commit touches the executor branch.
this doc real, fixes validatedThe minimal test fails through gradle on main (assertions on and off); fixes A and B pass it and 68 closest-suite tests at two seeds; a JDK probe backs the fix trade-offs.

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

Finding record

LFV-1061, snapshot of 2026-11-06

Registry title
DirectoryReader.open and openIfChanged with an ExecutorService never close the segment readers of tasks the executor already accepted when a later submit is rejected (or when the opening thread is interrupted while it waits), so those segments' files stay open
Area
core / index reader open (StandardDirectoryReader.createSegmentReaders, executor branch behind the ExecutorService overloads of DirectoryReader.open and openIfChanged, #15428)
Priority
Low, rank 1092: A bounded reader-open leak on an exception path that happens only with the opt-in executor overload (10.4+) when a submit is rejected or the opening thread is interrupted, and the open fails loudly.
Impact class
minor
Impact note
Applications that open or refresh readers through the opt-in ExecutorService overloads of DirectoryReader.open and openIfChanged (since 10.4.0) and pass an executor that can reject (a bounded ThreadPoolExecutor with the default AbortPolicy that is saturated when the open submits its per-segment tasks, or an executor shut down while an open or refresh is in flight): the open fails loudly with RejectedExecutionException, but the segment readers the already-accepted tasks built (for a refresh, the extra reference on a reused segment reader) are never released, so Lucene keeps their files and segment cores open and the caller cannot release them because it never receives those readers (executed: MockDirectoryWrapper reports the accepted segment's files still open; after a failed refresh the reused segment reader's refCount is 2 instead of 1); it repeats on every failed attempt; interrupting the opening thread while it waits is a second, weaker trigger; search results are unaffected; any index size.
Summary
You call DirectoryReader.open(directory, executor), or openIfChanged with an executor, and the executor accepts the task for one segment but rejects the submit for the next one, as a saturated bounded pool does; you expect the RejectedExecutionException and nothing left behind, as the method's own cleanup comment intends, as the sequential path does and as upstream's rejecting-executor test requires. Instead the exception leaves the submit loop before any future is collected, so the reader the accepted task built is never decRef'd: on a two-segment index, MockDirectoryWrapper then refuses to close because the files of the accepted segment are still open.
Introduced
c5a4d07e5540d0c8adce0af390275c8fd731b52a (branch_10x backport, apache/lucene#15445, the commit that reached the releases) of 7d4e22454b53485a44403ac28c79cd4e5166d487 (main, 'Adding support for passing an ExecutorService into DirectoryReader.open() to enable concurrent segment reader initialization (#15428)', fixes #15387) [read: git blame in the full-history clone attributes every line of the executor branch to 7d4e22454b5, and git log -S 'executor.submit(' finds only it and its backport; exec: 10.3.2 lacks the API, 10.4.0 leaks]. New code that came with the API, not a regression: the unguarded submit loop and the collect loop that treats InterruptedException like a task failure were already in the PR's first commit 40de473462f (2025-11-13); openIfChanged started sharing the helper in d62b706e31d (2025-11-19), same PR. (2025-11-21 (author date of 7d4e22454b5 on main); backport author date 2025-11-23; first release 10.4.0 (release commit 2026-02-20, tag 2026-02-25))
First affected version
10.4.0 [exec] (first release with the ExecutorService overloads); 10.3.2 and every 9.x release lack DirectoryReader.open(Directory, ExecutorService) [exec: compile error on 10.3.2 and 9.12.3]
Affected versions
10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main 645d4fe14a20; never fixed. [exec] the minimal JUnit TestExecutorOpenRejectedSubmitLeak fails on main (-ea and -da; re-run at the registration check 2026-10-30, seed DEADBEEF, 'there are still 4 open files: {_1_MockRandom_0.doc=1, _1_MockRandom_0.tio=1, _1.fdx=1, _1.fdt=1}'), on the 10.6.0 release jars (4ae946e5a9b, -da and -ea), on 10.5.2 (-da; -ea and -da again on 2026-11-01), on 10.5.1 and 10.5.0 (-da, 2026-11-01) and on 10.4.0 (-da and -ea), and passes on main with the fix shadowed; the independent reproduction's own test fails on main (-ea, -da), 10.6.0, 10.5.2 and 10.4.0 (-da); a core-only main() port fails on 10.4.0, 10.5.2, 10.6.0 and main and passes on main with the fix shadowed; 10.3.2 and 9.12.3 do not compile (API absent). [read] 10.5.0 and 10.5.1: createSegmentReaders is textually identical and the backport is an ancestor of both tags. Upstream main 716d6f216f5 (2026-10-29) and branch_10_6 have no commit to StandardDirectoryReader.java after 2026-02-11 (author date) and none ever changed the executor branch. Not affected in any version: the overloads without an executor (sequential branch). Not new in 10.6 (present since 10.4.0).
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
formal: TLA+/TLC; also reached by code reading
Upstream and prior reports
none found (2026-10-30, read-only, on top of the finder's scripted prior-report search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches): two scripted prior-report searches (16 GitHub issue/PR, 3 commit, 20 mailing-list and 4 JIRA queries each, plus downstream queries; 252 and 477 hits) over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 31 manual GitHub searches; the changed files of all 409 open apache/lucene PRs (none touches the executor branch; #16923, #16847 and #15556 touch other methods of StandardDirectoryReader); all conversation comments, inline review comments and reviews of #15428; the upstream history of the file with author dates. Refresh 2026-10-30: no new item; 8 focused searches find only #15428 and #15580; none of the 50 PRs updated since 2026-10-28 changes StandardDirectoryReader or DirectoryReader; no issue created since then is about readers or executors; no commit to the file after 448c7d8e241 (main) / c8b00d2ba72 (branch_10_6). Related, not this bug: #15428 / #15445 / #15387 (introduced the API; the review discusses exception cleanup intent, not a rejected later submit or an interrupt), #15466 / #15523 (same MockDirectoryWrapper symptom text in testExecutorServicePartialFailure, a test-helper merge problem fixed test-only), #15580 (too many open files under HandleLimitFS on the sequential path, closed as not reproducing), #6326 (LUCENE-5262, 2013: made the NRT open decRef readers on exception), #12498 and LUCENE-9074 (executor topics in IndexSearcher).
Lucene commit tested
main 645d4fe14a20: the minimal test through gradle in a scratch worktree, stock and with fixes A and B, assertions on and off, plus the closest suites (68 tests) stock (seed DEADBEEF; the fact check also ran them stock at 5EED1234) and with both fixes at seeds DEADBEEF and 5EED1234, and the format check (it flags only the test's comment line, not the fixes); the minimal test and the supporting tests standalone (2026-11-01). The 10.5.2 release jars with assertions on and off (2026-11-01); the study's runs on 10.4.0, 10.5.0, 10.5.1 and 10.6.0 (fail) and on 10.3.2 and 9.12.3 (API absent). Upstream main 2f424fd428d (2026-10-30) has no commit to StandardDirectoryReader.java after 2026-02-11 (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:187-246 (at 645d4fe14a20) (createSegmentReaders): executor branch :195-228 with the submit loop :197-207 (executor.submit at :203, no try/catch), the collect loop :208-217 (catch (ExecutionException | InterruptedException) at :212, comment :213-214 'we still process the rest of the completed futures to allow us to close created readers'), the cleanup decRefWhileSuppressingException at :221; sequential branch :229-245 (catch (Throwable t) at :241, decRef at :242); callers :96 (initial open) and :181 (reopen factory); :444-475 (the NRT doOpenIfChanged/doOpenFromWriter paths call writer.getReader and ignore the executor unless a commit is given)
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:63-74 (at 645d4fe14a20) (open(Directory, ExecutorService): 'executorService used to open segment readers in parallel', '@throws IOException'), :107, :164, :221 (other open overloads with an executor), :270, :296 (openIfChanged with an executor)
  • lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-195 (at 645d4fe14a20) (incRef/decRef: 'always call a corresponding decRef ... otherwise the reader may never be closed')
  • lucene/core/src/test/org/apache/lucene/index/TestDirectoryReader.java:1300-1316 (at 645d4fe14a20) (testExecutorServiceExceptionHandling: a shut-down executor, open must throw and the MockDirectoryWrapper must close; only the first submit is rejected) and :1318-1335 (testExecutorServicePartialFailure)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:96, (at 645d4fe14a20) :181, :187-247 (:195-228, :197-207, :203, :208-217, :212, :213-214, :221, :229-245, :241-242), :292-296, :319-323, :420-430, :444-475, :496-505
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:71, (at 645d4fe14a20) :106-107, :164, :217-221, :269-270, :295-296, :383-384, :425-429 (the two IndexWriter overloads)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:71, (at 645d4fe14a20) :106-107, :164, :217-221, :269-270, :295-296
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:229-245 (at 645d4fe14a20) (sequential branch), :319-323 (incRef of a reused reader); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:613-615, (at 645d4fe14a20) :766 (NRT open); lucene/replicator/src/java/org/apache/lucene/replicator/nrt/SegmentInfosSearcherManager.java:64, :116 (executor null)
  • lucene/core/src/test/org/apache/lucene/index/TestDirectoryReader.java:1300-1316 (at 645d4fe14a20) (a shut-down executor rejecting the first submit leaves nothing open)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:213-214, (at 645d4fe14a20) :229-245
  • lucene/core/src/java/org/apache/lucene/index/DirectoryReader.java:64-70; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexReader.java:164-174; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:58-67; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:77-82 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestDirectoryReader.java:1300-1316, (at 645d4fe14a20) :1318-1335
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:58-67; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestDirectoryReader.java:1300-1316, (at 645d4fe14a20) :1337-1356

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 645d4fe14a20 and the executed runs.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.