- A
DirectoryReaderis a list of segment readers, one per segment. Opening a segment reader opens that segment's files, and closing it closes them. - Since 10.4.0 you can pass an
ExecutorServicetoDirectoryReader.openoropenIfChanged. Lucene then opens the segments in parallel, one task per segment, and collects the readers. - An
ExecutorServicemay refuse a task:submitthrowsRejectedExecutionException, for example when a boundedThreadPoolExecutoris full or the executor was shut down.
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.- Commit two documents separately, so the index has two segments,
_0and_1, and callDirectoryReader.open(dir, executor). Lucene submits one task per segment, newest first:_1, then_0. - The executor runs the
_1task, which opens_1's files, then rejects the_0task withRejectedExecutionException, as a fullThreadPoolExecutorwith the defaultAbortPolicydoes. - 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.
- 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; foropenIfChanged, a reused segment reader stays at refCount 2 instead of 1.
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.
- Executor overloads: eight public
DirectoryReadermethods take anExecutorService; six of them can reachStandardDirectoryReader.createSegmentReaders(StandardDirectoryReader.java:187-247):DirectoryReader.open(Directory, ExecutorService)and five siblings (openwith a leaf sorter or anIndexCommit,openIfChangedwith or without anIndexCommit;DirectoryReader.java:71,:106-107,:164,:217-221,:269-270,:295-296at645d4fe14a20), 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 anIndexWriter(DirectoryReader.java:383-384,:425-429), never use the executor: they returnnullor callwriter.getReader(StandardDirectoryReader.java:444-475). On an NRT reader onlyopenIfChangedwith anIndexCommituses the executor, by reopening to the commit (StandardDirectoryReader.java:420-430,:496-505). - Rejection:
ExecutorService.submitthrowsRejectedExecutionExceptionwhen it cannot take a task, for example aThreadPoolExecutorwhose threads and queue are full, with the JDK's defaultAbortPolicy, or any executor aftershutdown().
- The executor branch (
:195-228) first submits one task per segment, newest first, in a loop with no try/catch (:197-207,executor.submitat:203). A task opens a newSegmentReader(: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). - Then it collects the results with
Future.get()intoreaders[](:208-217). If a task failed it keeps going, "to allow us to close created readers" (:213-214), and the cleanupdecRefWhileSuppressingException(firstException, readers)(:221) releases every collected reader. - A
RejectedExecutionExceptionfrom the submit at:203leaves the method before step 2. The futures of the tasks already accepted are dropped without being awaited, their readers never reachreaders[], and the cleanup never runs. The caller gets only the exception. - Second trigger: an
InterruptedExceptionfromget()is caught together withExecutionException(:212), and the loop moves on. The task it was waiting for still runs; if it builds a reader, nobody stores or closes it. - The branch without an executor (
:229-245) creates readers insidetryand releases them on anyThrowable(:241-242).
| case | expected | actual |
|---|---|---|
open of 2 segments; the executor runs _1's task and rejects _0's | RejectedExecutionException, then 0 files open | the 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 interrupted | the reused segment reader back at refCount 1 | refCount 2 |
open of 1 segment; the opener is interrupted while its task is still queued | 0 files open after the task has run | 5 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:
IndexWriteruses the NRT open, and the replicator calls the reopen factory withnull. - Default vs opt-in. Opt-in. Without an executor the sequential branch runs, which cleans up. Common pools such as
Executors.newFixedThreadPoolqueue without bound and do not reject while running. - What triggers it. (a) A bounded pool with
AbortPolicythat is full when the open submits its tasks: a stockThreadPoolExecutor(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 withFuture.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.
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.
/*
* 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();
}
}
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
openthrowsRejectedExecutionExceptionand 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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.4.0-10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
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.)- 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
testExecutorServiceExceptionHandlingrequires 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 aRuntimeException, then closes anewDirectory(), 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
TaskExecutortreats 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.
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
openthrows, 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
AtomicBooleanand onecompareAndSetper segment on the success path. It still callsexecutor.submit, so executors that overridesubmit(as upstream'screateExecutorServiceThatFailsOneTaskdoes) 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
RuntimeExceptionalso covers theIllegalStateExceptionthatgetOldSegmentReader(:263-285) can throw inside the submit loop; anErrorfromsubmitstill 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.
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.
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.
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.
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.
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) andinterrupt(the opener is interrupted inFuture.get), theNoLeakinvariant fails; the model with the fix passes. Re-run on645d4fe14a20by 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.
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).
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA; the web | 2026-10-30 and 2026-11-01 | a 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 #15428 | no report; context #15428, #15445, #15387, #15466, #15523, #15580, #6326, #13622 |
| Open apache/lucene PRs | 2026-11-01 | changed files of all 410 open PRs | #16923, #16847 and #15556 touch StandardDirectoryReader.java, none the submit or collect loop |
| apache/lucene main commits | 2026-11-01 | commit list of StandardDirectoryReader.java | no change since 2026-02-11 (#15681); main head 2f424fd428d (2026-10-30) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the 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 | affected | the minimal test fails on the release jars (-da): {_1.cfs=1} |
| 10.5.2 | affected | the minimal test fails with assertions on and off (re-run 2026-11-01): {_1.cfs=1} |
| 10.4.0 | affected | the minimal test fails with assertions on and off; the first release with the API |
| 10.3.2, 9.12.3 | not affected | no DirectoryReader.open(Directory, ExecutorService) (compile error) |
main 645d4fe14a20 + fix B | fix | the minimal test and 68 closest-suite tests pass through gradle |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | TLC 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 | real | Its 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, high | Executed. 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, high | Executed; 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, high | Re-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, Low | The 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 main | The 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 validated | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 457 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,447 words
- 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 main2f424fd428d(2026-10-30) has no commit toStandardDirectoryReader.javaafter 2026-02-11 (read).
- 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-505lucene/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-296lucene/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-245lucene/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
- TestExecutorOpenRejectedSubmitLeak.java (one test, public API):
repro/LFV-1061/TestExecutorOpenRejectedSubmitLeak.java(77 lines)
Made from the code at 645d4fe14a20 and the executed runs.
assets/LFV-1061/bug.png(457 KB)
