Opening the forward index's second temp file fails. The first file is already open, held only in a local variable, and nothing closes it. The cleanup then deletes both files, so on Linux the open input points at a deleted file.
- BP (recursive graph bisection) renumbers documents so that documents with similar terms get nearby ids, which makes postings smaller and conjunctions faster.
BPIndexReorderercomputes the new order;BPReorderingMergePolicyapplies it whenever segments merge. Both are opt-in, inlucene/misc. - To compute the order, the reorderer writes a temporary forward index into a
Directory: aterm-idsfile (each document's term ids) and astart-offsetsfile (where each document starts in it). It then opens both files for reading. - An open
IndexInputholds an operating-system resource: a file descriptor withNIOFSDirectory, a memory mapping withMMapDirectory. On Linux, a deleted file's disk space is freed only when the last descriptor or mapping on it is gone.
- You reorder doc ids with BP from
lucene/misc:BPReorderingMergePolicywith aBPIndexReorderer, orBPIndexReorderer.computeDocMap()directly. It writes two temp files,term-idsandstart-offsets, then opens both for reading. - Opening
start-offsetsthrows anIOException. In the repro a test directory fails that one open, on an index of 1 empty document; in production it could be file-descriptor exhaustion, a mapping limit or a storage error. - You expect
computeDocMap()to throw thatIOExceptionand leave 0 temp inputs open: the class deletes its temp files on any failure. - Instead 1 input stays open, on
term-ids__1.tmp: it was opened one line earlier into a local variable that nothing closes, and the cleanup only deletes the files. Through a merge, the input is still open afterIndexWriter.close(). - On a real file system, 3 failed reorders leave 3 descriptors on deleted files with
NIOFSDirectory(until garbage collection) and 3 mappings withMMapDirectory, even afterDirectory.close()andSystem.gc(). With the fix: 0 and 0.
Applications that opt into BP reordering from lucene/misc (BPReorderingMergePolicy with BPIndexReorderer, or direct reorder/computeDocMap calls), 9.8.0 to 10.6.0 and main, when the I/O fails at exactly that one open. One descriptor or mapping leaks per failed reorder, silently; results and index contents are unaffected. Rare.
Mechanism, terms and numbers638 words
BPIndexReorderer (opt-in, lucene/misc) computes its doc-id order from a temporary on-disk forward index of two files, term-ids and start-offsets. buildForwardIndex writes both, then opens both for reading and builds the ForwardIndex with three plain statements outside any try (BPIndexReorderer.java:768-770 on main 645d4fe14a20). If the second openInput throws, the term-ids input opened one statement earlier is lost with the exception; if the ForwardIndex constructor throws, both are lost. The caller's cleanup only deletes the temp files. So a failed reorder, or a failed merge through BPReorderingMergePolicy, leaves an IndexInput open on a deleted temp file, even after IndexWriter.close() (Linux runs): a file descriptor until garbage collection on NIOFSDirectory, and a mapping that survives Directory.close() and System.gc() on MMapDirectory. A failed IndexWriter.addIndexes(CodecReader...) through the same merge policy leaves the writer open, so each retry leaks one more input. Every release from 9.8.0 to 10.6.0 and main has it. It needs an I/O failure at that exact open, so it is rare, and the harm is a leak only: no wrong results and no index damage.
// BPIndexReorderer.buildForwardIndex, condensed
try (IndexOutput termIDs = tempDir.createTempOutput("term-ids", "", IOContext.DEFAULT); // :715
IndexOutput startOffsets = tempDir.createTempOutput("start-offsets", "", IOContext.DEFAULT)) { // :716-717
... sort the postings and write both files ...
}
IndexInput termIDsInput = tempDir.openInput(termIDsFileName, IOContext.DEFAULT); // :768 opened
IndexInput startOffsets = tempDir.openInput(startOffsetsFileName, IOContext.DEFAULT); // :769 throws: :768 is lost
return new ForwardIndex(startOffsets, termIDsInput, maxTerm); // :770 throws: both lost
// BPIndexReorderer.computePermutation, condensed
try {
...
try (ForwardIndex forwardIndex = buildForwardIndex(trackingDir, postingsName, maxDoc, numTerms)) { // :871-872
...
}
} catch (Throwable t) {
IOUtils.deleteFilesSuppressingExceptions(t, trackingDir, trackingDir.getCreatedFiles()); // :900 deletes, never closes
throw t;
}buildForwardIndexwrites the two temp files in a try-with-resources (:715-766), then opens both for reading (:768,:769) and passes them to theForwardIndexconstructor (:770), with notryaround the three statements.Directory.openInputmay throw ("@throws IOException in case of I/O error",Directory.java:142). When:769throws,termIDsInputexists only as a local variable; the exception leaves the method and nothing closes it.- The
ForwardIndexconstructor callsrandomAccessSliceand rethrows anIOExceptionasUncheckedIOException(:613-624). If it throws, both inputs are lost. Itsclose()would close both (:647-650), but the object never exists. This path needs a failure on a file written a moment earlier, so it is rare. computePermutationtakes ownership in a try-with-resources only afterbuildForwardIndexreturns (:871-872). Itscatch (Throwable)deletes every tracked temp file (:899-902), so the open input now points at a deleted file (on Linux; where the OS refuses to delete an open file, as Windows can,FSDirectorykeeps it as a pending delete,FSDirectory.java:355-366, and the file stays on disk).- Merge path:
BPReorderingMergePolicycallsreorderer.computeDocMap(reader, dir, executor)and catches onlyNotEnoughRAMException(BPReorderingMergePolicy.java:133-145);IndexWriterpasses its owndirectory(IndexWriter.java:5558); the merge exception ends intragicEvent(t, "merge")(IndexWriter.java:5109-5113), which closes the writer. The writer never tracked the leaked input, soIndexWriter.close()cannot release it.IndexWriter.addIndexes(CodecReader...)reorders through the same merge policy (BPReorderingMergePolicy.java:200-203,IndexWriter.java:3562), but there the exception goes back to the caller without a tragic event (tragicEventonly for anError,IndexWriter.java:3390-3392), so the writer stays open. - Why the mapping outlives
Directory.close()onMMapDirectory(by reading): temp names liketerm-ids__1.tmpdo not matchIndexFileNames.CODEC_FILE_PATTERN(IndexFileNames.java:199), so the defaultGROUP_BY_SEGMENTgrouping gives no group and each input gets its ownArena.ofShared()(MMapDirectory.java:124-127,:192,:402-407), which only the input'sclose()frees. The input is unreachable, so the mapping stays until the JVM exits; the runs show it afterDirectory.close()andSystem.gc().
The same class does it right elsewhere: ForwardIndexSorter opens every temp input in try-with-resources (:1094, :1104-1105).
Exposure and likelihood355 words
- Who. Applications that opt into BP reordering:
BPReorderingMergePolicywith aBPIndexReorderer(merge time), or direct calls toBPIndexReorderer.reorder/computeDocMap. Not the default configuration. - When. Mainly when the I/O fails at one exact point: opening the second forward-index temp file, which Lucene wrote a moment earlier (a failing
randomAccessSlicein theForwardIndexconstructor is a second, rarer point). Realistic causes: file-descriptor exhaustion (NIOFSDirectory), address-space or mapping-count limits (MMapDirectory), a storage error, or a faulty customDirectory. Every run injected theIOException; no public report was found (the code dates from 9.8.0, 2023). - What leaks. One
IndexInputper failed reorder (two on the constructor path), on a deleted temp file: a descriptor until garbage collection onNIOFSDirectory; a mapping onMMapDirectory(seen afterDirectory.close()andSystem.gc(); until the JVM exits by reading). On Linux either one keeps the deleted file's disk blocks allocated (file-system semantics, not measured). - Silent. Lucene's production code does not report the leaked input (only the test framework's
MockDirectoryWrappertracks open files), and on Linux the directory listing no longer shows the deleted file. - Scale. No scale is needed to trigger it: the repro uses 1 document. A leaked
term-idsfile grows with the merge (it holds each document's qualifying term ids); how large it gets was not measured. - Accumulation. A failed merge closes the
IndexWriter(tragic event) and Lucene does not retry it, so on the merge path each failed merge leaks one input (two), and only merges already running when the writer closes can add to that (reasoning). A failedIndexWriter.addIndexes(CodecReader...)throughBPReorderingMergePolicydoes not close the writer, so an application that retries it leaks one input per failed call (executed: 3 failed calls on one writer, with a serial and with a concurrent merge scheduler, left 3 inputs open afterIndexWriter.close(); 0 with fix A). An application that keeps reopening writers while a descriptor or mapping shortage lasts would also pile them up (reasoning, not measured). - Not affected. Search results, index contents, deletes and commits: the exception comes before the merged segment is written, and the temp files are deleted.
One JUnit test, public API plus the test framework's MockDirectoryWrapper: index 1 empty document, call computeDocMap() with a temp directory whose openInput throws for start-offsets* files, then assert that no temp input is left open.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.misc.index;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.CodecReader;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestBPIndexReordererOpenFailureLeak extends LuceneTestCase {
public void testFailedStartOffsetsOpenClosesTermIdsInput() throws IOException {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
}
MockDirectoryWrapper tempDir =
new MockDirectoryWrapper(random(), new ByteBuffersDirectory()) {
@Override
public synchronized IndexInput openInput(String name, IOContext context)
throws IOException {
if (name.startsWith("start-offsets")) {
throw new IOException("simulated failure opening " + name);
}
return super.openInput(name, context);
}
};
try (DirectoryReader reader = DirectoryReader.open(dir)) {
CodecReader leaf = (CodecReader) getOnlyLeafReader(reader);
expectThrows(
IOException.class, () -> new BPIndexReorderer().computeDocMap(leaf, tempDir, null));
}
// expected: 0 temp inputs left open after the failed computeDocMap; actual: 1 (term-ids)
assertEquals(0, tempDir.getFileHandleCount());
tempDir.close();
}
}
}
cp $REPRO/TestBPIndexReordererOpenFailureLeak.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestBPIndexReordererOpenFailureLeak -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
- Expected
computeDocMap()throws the injectedIOException, and the temp directory has 0 open inputs.- Observed
- The
IOExceptionis thrown, but 1 input is still open (expected:<0> but was:<1>): the one onterm-ids__1.tmp, with assertions on and off.
More files, environment, full output293 words
One test class, one method, single thread, 1 empty document; the injected failure does not depend on the seed, and the test fails the same way with both seeds run. The only subclass is the test framework's MockDirectoryWrapper, which throws from openInput for start-offsets* names and counts open files. With no indexed field no term qualifies, but computePermutation still writes the postings file and calls buildForwardIndex, which writes both temp files and then opens term-ids and start-offsets, so the index content does not matter.
The assertion reports a count. A diagnostic copy that calls tempDir.close() instead names the open file and where it was opened: "cannot close: there are still 1 open files: {term-ids__1.tmp=1}", caused by "unclosed IndexInput: term-ids__1.tmp" opened at BPIndexReorderer.buildForwardIndex(BPIndexReorderer.java:768). (The FilterDirectory frame there is the TrackingDirectoryWrapper that computePermutation wraps around the temp directory.)
This same test is the suggested regression test: it fails on stock main and passes with either fix.
Earlier tests of the hunt also showed the merge path: forceMerge through BPReorderingMergePolicy(LogDocMergePolicy, BPIndexReorderer) with the same injected failure throws, and after IndexWriter.close() the index directory still has {term-ids__5.tmp=1} open.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with either fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (some frames cut)
TestBPIndexReordererOpenFailureLeak > testFailedStartOffsetsOpenClosesTermIdsInput FAILED
java.lang.AssertionError: expected:<0> but was:<1>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:B35574837A23A9E5]:0)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.misc.index.TestBPIndexReordererOpenFailureLeak.testFailedStartOffsetsOpenClosesTermIdsInput(TestBPIndexReordererOpenFailureLeak.java:56)
1 test completed, 1 failed
# the same with -Ptests.asserts=false
# diagnostic copy of the test (tempDir.close() in place of the count assertion), 645d4fe14a20, seed DEADBEEF
java.lang.RuntimeException: MockDirectoryWrapper: cannot close: there are still 1 open files: {term-ids__1.tmp=1}
at org.apache.lucene.tests.store.MockDirectoryWrapper.close(MockDirectoryWrapper.java:884)
...
Caused by: java.lang.RuntimeException: unclosed IndexInput: term-ids__1.tmp
...
at org.apache.lucene.tests.store.MockDirectoryWrapper.openInput(MockDirectoryWrapper.java:843)
at org.apache.lucene.misc.index.TestBPIndexReordererOpenFailureLeak$1.openInput(TestBPIndexReordererOpenFailureLeak.java:47)
at org.apache.lucene.store.FilterDirectory.openInput(FilterDirectory.java:101)
at org.apache.lucene.misc.index.BPIndexReorderer.buildForwardIndex(BPIndexReorderer.java:768)
...IndexWriter.close()"Closes all open resources", yet an input opened by the writer's own merge survives it.- The class cleans up everywhere else: it deletes its temp files on any failure and opens every other temp input in try-with-resources.
openInputdeclaresIOException, and Lucene's test framework fails any test that leaves a file open (LUCENE-3147).
The full argument261 words
IndexWriter.close(): "Closes all open resources and releases the write lock." (IndexWriter.java:1342). On the merge path the input was opened by the writer's own merge on the writer's ownDirectory(IndexWriter.java:5558).- The class's own failure handling.
computePermutationwraps the whole computation in acatch (Throwable)that deletes its temp files (BPIndexReorderer.java:899-902);ForwardIndexSorteropens every temp input in try-with-resources (:1094,:1104-1105). Before #14913 (2025-07, main only) the failure branch calledIOUtils.closeWhileHandlingException(postingsOutput, forwardIndex): the authors meant to close the forward index on failure, butforwardIndexwas stillnullwhenbuildForwardIndexthrew, so this window was never covered. - The contract Lucene holds every component to. CHANGES 3.3.0, LUCENE-3147/3152: "Fixed open file handles leaks in many places in the code. Now MockDirectoryWrapper (in test-framework) tracks all open files, including locks, and fails if the test fails to release all of them." That tracking is the repro's oracle.
- The trigger is in contract.
Directory.openInput: "@throws IOException in case of I/O error" (Directory.java:142). The test framework shipsMockDirectoryWrapper.setRandomIOExceptionRateOnOpento test exactly this, and CHANGES 4.5.1, LUCENE-5263, treats "Transient IOExceptions, e.g. due to disk full or file descriptor exhaustion" insideIndexWriteras bugs. - Nothing sanctions the leak.
IndexReorderer.computeDocMapsays only "temporary files may be stored here while reordering";MergePolicy.OneMerge.reordersays theDirectory"may be used to create temporary files";TestBPIndexReordererandTestBPReorderingMergePolicyinject no I/O failure; no CHANGES or MIGRATE entry mentions it. - History. The three lines came with the class in #12489 and were never revisited; the PR's discussion is about quality and benchmarks.
The devil's-advocate check169 words
The strongest case for "works as designed" (the intended-behaviour verifier, a cross-model reviewer and the adjudication): computeDocMap is an Expert API and the caller supplies the temp Directory; no javadoc promises failure atomicity; an openInput failure on a file just written is artificial; the constructor path is theoretical; and the impact is small.
Why it fails: the javadoc calls computeDocMap Expert because it exists for BPReorderingMergePolicy (BPIndexReorderer.java:773-777), and that recommended path reaches the same lines with IndexWriter's own Directory. The contract comes from the class's own cleanup, IndexWriter.close() and the test framework's open-file rule. openInput declares IOException, and descriptor and mapping exhaustion are real. The constructor path is indeed rare, which is why the second openInput is the headline; that narrows the claim, not its validity. Small impact is a severity question, and the bug is classed minor. A reviewer's objection that the finder's test subclasses the @lucene.internal FilterDirectory does not apply to the minimal repro, which overrides the test framework's MockDirectoryWrapper.openInput.
Recommended A. catch (Throwable), close what was opened, rethrow
Open both inputs inside a try; in catch (Throwable t) call IOUtils.closeWhileSuppressingExceptions(t, termIDsInput, startOffsets) and rethrow, the catch-and-rethrow style of #14633 and #14913. The repro passes with assertions on and off, and the misc module's 317 tests run with 0 failures (9 skipped). A success-flag version (B) also works and suits a 9.x backport.
- IndexInput termIDsInput = tempDir.openInput(termIDsFileName, IOContext.DEFAULT);
- IndexInput startOffsets = tempDir.openInput(startOffsetsFileName, IOContext.DEFAULT);
- return new ForwardIndex(startOffsets, termIDsInput, maxTerm);
+ IndexInput termIDsInput = null;
+ IndexInput startOffsets = null;
+ try {
+ termIDsInput = tempDir.openInput(termIDsFileName, IOContext.DEFAULT);
+ startOffsets = tempDir.openInput(startOffsetsFileName, IOContext.DEFAULT);
+ return new ForwardIndex(startOffsets, termIDsInput, maxTerm);
+ } catch (Throwable t) {
+ IOUtils.closeWhileSuppressingExceptions(t, termIDsInput, startOffsets);
+ throw t;
+ }All options and trade-offs3 options · 655 words
A and B change only the last three statements of buildForwardIndex; the success path is unchanged. C is a larger change. A and B were run through gradle in a scratch worktree of 645d4fe14a20; C was not tried.
In BPIndexReorderer.buildForwardIndex (BPIndexReorderer.java:768-770), +10/-3.
- IndexInput termIDsInput = tempDir.openInput(termIDsFileName, IOContext.DEFAULT);
- IndexInput startOffsets = tempDir.openInput(startOffsetsFileName, IOContext.DEFAULT);
- return new ForwardIndex(startOffsets, termIDsInput, maxTerm);
+ IndexInput termIDsInput = null;
+ IndexInput startOffsets = null;
+ try {
+ termIDsInput = tempDir.openInput(termIDsFileName, IOContext.DEFAULT);
+ startOffsets = tempDir.openInput(startOffsetsFileName, IOContext.DEFAULT);
+ return new ForwardIndex(startOffsets, termIDsInput, maxTerm);
+ } catch (Throwable t) {
+ IOUtils.closeWhileSuppressingExceptions(t, termIDsInput, startOffsets);
+ throw t;
+ }Trade-offs
- The catch-and-rethrow style that #14633 ("Catch and re-throw Throwable rather than using a success boolean") introduced and #14913 ("Remove a few more boolean success flags") applied to this class;
computePermutationalready ends incatch (Throwable t) ... throw t(:899-902). closeWhileSuppressingExceptionsskipsnulls and adds close failures totas suppressed (IOUtils.java:104-137), so the caller still sees the originalIOException(iftis not anErrorand a close throws one, thatErroris thrown withtsuppressed; iftis anError,tis thrown).throw tcompiles underthrows IOExceptionby Java's precise rethrow.IOUtils.closeWhileSuppressingExceptionscame to main with #14633 and to the 10.x releases in 10.4.0 (absent at the 9.12.3 and 10.3.2 tags; present onbranch_10_6), so only a backport to 9.x or 10.0-10.3 would need B.
What the checks validated
- Stock
645d4fe14a20: the repro fails with assertions on and off. - With A: the repro,
TestBPIndexReordererandTestBPReorderingMergePolicypass with assertions on and off (3 suites, 12 tests, 0 failures, 2 skipped: the merge-policy suite's two@Nightlytests) and with-Ptests.nightly=true(12 tests, 0 skipped); the whole:lucene:misc:testpasses with assertions on (24 suites, 317 tests, 0 failures, 9 skipped). checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestof:lucene:miscpass with the fix and the repro test in place.
Same place, +13/-3: open both inputs and build the ForwardIndex in a try, set success = true before returning, and in finally call IOUtils.closeWhileHandlingException(termIDsInput, startOffsets) unless it succeeded.
- IndexInput termIDsInput = tempDir.openInput(termIDsFileName, IOContext.DEFAULT);
- IndexInput startOffsets = tempDir.openInput(startOffsetsFileName, IOContext.DEFAULT);
- return new ForwardIndex(startOffsets, termIDsInput, maxTerm);
+ IndexInput termIDsInput = null, startOffsets = null;
+ boolean success = false;
+ try {
+ termIDsInput = tempDir.openInput(termIDsFileName, IOContext.DEFAULT);
+ startOffsets = tempDir.openInput(startOffsetsFileName, IOContext.DEFAULT);
+ ForwardIndex forwardIndex = new ForwardIndex(startOffsets, termIDsInput, maxTerm);
+ success = true;
+ return forwardIndex;
+ } finally {
+ if (success == false) {
+ IOUtils.closeWhileHandlingException(termIDsInput, startOffsets);
+ }
+ }Trade-offs
- Maintainers removed boolean success flags from this class in #14913, so A is the likelier preferred shape on main.
closeWhileHandlingExceptiondrops close failures instead of attaching them to the original exception. It exists on every affected branch (9.8.0 on), so B suits a backport to 9.x.
What the checks validated
- With B: the repro and the two BP suites pass with assertions on (3 suites, 12 tests, 0 failures, 2 skipped
@Nightly). - Earlier, on main
ea57360912ed: the finder's, the independent reproducer's and the history tests pass with it; the two upstream BP suites pass (11 tests, 3 seeds, nightly on and off; the same job also ranTestSoftDeletesRetentionMergePolicywith an unrelated fix shadowed, 25 tests in all); both OS-level probes report 0 leaked descriptors and 0 mappings.
buildForwardIndex returns the two file names, and computePermutation opens both inputs and the ForwardIndex in one try-with-resources, which closes earlier resources when a later one fails.
Trade-offs
A larger diff, and ForwardIndex.close() already closes both inputs, so they would be closed twice unless ForwardIndex stops owning them. A keeps ownership as it is.
Apache Solr exposes this merge policy as BPReorderingMergePolicyFactory, so Solr cores configured with it are reachable the same way; a read-only code search found no use in Elasticsearch or OpenSearch.
Fault injection: an engine made every I/O call of 700 random test programs fail in turn and used MockDirectoryWrapper to catch files left open. Re-run with BP reordering added, the only new leak was term-ids__N.tmp, always with the failure at the start-offsets open; the code then showed why, and a JUnit test confirmed it.
The method in full145 words
Method: exception-point enumeration (the study's semi-formal tier). An engine runs random sequences of index operations and makes each I/O and callback throw site fail in turn, checks the outcome against a model, and uses MockDirectoryWrapper.close() as the leak oracle. A hunting AI tool re-ran it unchanged on the real jars with one added dimension, BPReorderingMergePolicy with BPIndexReorderer around the engine's LogDocMergePolicy, over the same 700 scripts (seed 7).
- With BP reordering, the only new "cannot close" signature was
{term-ids__N.tmp=1}: 532 of 327,533 injection runs, always with the failure injected atBPIndexReorderer.buildForwardIndex:769, the start-offsetsopenInput. - A deterministic reverse reorderer, which never reaches
buildForwardIndex, had the same BUG counts as the base run. - The root cause was then read from the code, and the finding minimized to a JUnit test on the public API, later cut down to the one-method test above.
No prior report: issues, PRs and commits, the JIRA archive, the dev@, java-user@, issues@, commits@ and builds@ lists, downstream projects and the web (229 queries, 2026-10-26; refreshed 2026-11-01). No open PR changes the three lines.
Searches3 searches
Verdict new. The nearest items are context, not reports: #12489 introduced the code with no discussion of failure cleanup; #14913 reshaped the neighbouring cleanup without mentioning these inputs; a 2023-11-29 builds@ CI mail shows an OutOfMemoryError earlier in buildForwardIndex, before the two opens, with no unclosed-file report; #15068 is about MMapDirectory keeping maps through arena pooling, a different cause.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26; refreshed 2026-11-01 | four scripted prior-report search runs plus 48 GraphQL searches: class and method names (BPIndexReorderer, ForwardIndex, buildForwardIndex, computeDocMap, BPReorderingMergePolicy), term-ids / start-offsets, leak symptoms and the exact MockDirectoryWrapper text; refresh: the BP classes updated since 2026-10-25, buildForwardIndex, "term-ids" leak, ForwardIndex openInput | only related items (#12489, #14913, #12622, #12712, #15068); refresh: no hit (one unrelated, #11608) |
| Open PRs by touched file | 2026-10-26; refreshed 2026-11-01 | the changed files of all 408 open PRs; refresh: the 99 PRs updated since 2026-10-26 | #13186, #15430, #16847 touch BPIndexReorderer.java and #15430, #16571 touch BPReorderingMergePolicy.java, none at lines 768-770; refresh: only #16571, BPReorderingMergePolicy.java only |
| Mailing lists, JIRA archive, downstream projects and the web | 2026-10-26 | dev@, java-user@, issues@, commits@ and builds@ on lists.apache.org; JIRA queries (BP dates from 2023, after the migration); Solr, Elasticsearch and OpenSearch; general web queries | a 2023-11-29 builds@ CI mail with an OutOfMemoryError in the sort phase, before the two opens; no report of this bug |
Introduced with the class by #12489 (39f3777886c, 2023-09-14), first released in 9.8.0; the merge-policy path from 9.9.0. 9.7.0 lacks the class; every executed release from 9.8.0 to 10.5.2 and main fails; 10.6.0, branch_10_6, branch_11x and upstream main have the same lines.
Versions tested6 rows
Introduced by 39f3777886c "Add support for recursive graph bisection. (#12489)" (Adrien Grand, 2023-09-14) and its same-day branch_9x copy 7372a4ef055, first released in 9.8.0. Not a regression: git log -S on the start-offsets openInput line and -G on the new ForwardIndex(startOffsets, termIDsInput line find only these two commits; the only later edit is 32d692049fc (#13242, 10.0.0), an IOContext.READ to IOContext.DEFAULT rename. BPReorderingMergePolicy came with #12622 (f7cab164501, 2023-11-23, 9.9.0). #14913 (27d1272984f, 2025-07-14, main only) rewrote computePermutation's cleanup and left the three lines unchanged.
The executed runs use a version-portable test that fails the first open of a start-offsets* file through a MockDirectoryWrapper subclass, with a no-injection control that passes everywhere; release jars from Maven Central, JDK 25. Releases not run (9.9.1, 9.9.2, 9.11.0, 9.12.1, 9.12.2, 10.1.0 to 10.4.0, 10.5.0, 10.5.1) have the identical statements. branch_10x was decommissioned upstream (#16911) and no longer has the file.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.7.0 | not affected | the class does not exist (the test does not compile) |
| 9.8.0 | affected | fails: direct reorder leaves {term-ids__2.tmp=1} open (no merge policy yet) |
| 9.9.0, 9.10.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 | affected | fail: direct {term-ids__1.tmp=1}, merge path {term-ids__5.tmp=1} after IndexWriter.close() |
main ea57360912ed + fix B | causality | passes 3 of 3 |
10.6.0, branch_10_6, branch_11x, upstream main 2f424fd428d | by reading | the same three statements (10.6.0 and branch_10_6 at lines 823-825, the 10.5.2 file; branch_11x and main the 645d4fe14a20 file) |
main 645d4fe14a20 | affected | the minimal test fails through gradle with assertions on and off; passes with either fix |
Confirmed, high confidence: the finder, an independent reproducer, a devil's-advocate verifier and an adjudication each reproduced it; the reproducer's and the adjudication's second-open tests, the history runs and the minimal test need no @lucene.internal FilterDirectory. A reviewer from another model family, unable to run code, returned a procedural "not real" while agreeing with the root cause.
Checks and verdicts449 words
Everyone agrees on the root cause and that the harm is a leak on a failure path only.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Fault-injection differential (532 of 327,533 runs, all at the start-offsets open); JUnit fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3; passes with fix B, as do the upstream BP suites. MMap probe: 3 deleted mappings, 0 with the fix. |
| cross-model review, intended lens (OpenAI GPT-5.6, read-only) | real, high | Root cause agrees, inputs in contract, intended argument weak. A separate run re-ran the repro: fails. |
| independent reproducer | real | Own JUnit (a MockDirectoryWrapper.Failure for the two second-open tests, a FilterDirectory for the randomAccessSlice test, plus a control) fails 3 of 4 on main (assertions on and off), 10.5.2, 9.12.3, 9.11.1 and 9.9.0, including the constructor path ({term-ids__1.tmp=1, start-offsets__2.tmp=1}); OS probe: NIOFS 3 descriptors until GC, MMap 3 mappings after close and GC; 0 and 0 with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6, read-only) | not real, low (procedural) | Could not run code, so its verdict is procedural; it agreed on the root cause, found the inputs in contract and the intended argument weak. A separate JUnit fails (control passes) and passes with the fix. |
| devil's-advocate verifier | real | Re-ran main (assertions on and off) and 10.5.2: fails 2 of 2; no intended-behaviour argument survived. |
| adjudication | confirmed, high | Re-ran the reproducer's JUnit with a new seed: 3 of 4 fail with assertions on and off, the control passes; 4 of 4 pass with the fix. Class minor. |
| history, security screen, audit, minimization | 9.8.0 first affected; security none; confirmed | History runs from 9.7.0 to main; the security screen (none); the audit's re-runs on ea57360912ed; the 2026-10-29 one-test minimization on 645d4fe14a20 (fails with assertions on and off, 5 of 5 with a second seed; passes with the fix). |
| this doc (2026-11-01) | reproduced, both fixes validated | Gradle on 645d4fe14a20: the minimal test fails with assertions on and off; fix A passes it, the two BP suites (also with nightly tests) and the misc module's 317 tests (0 failures, 9 skipped), plus lint; fix B passes it and the two BP suites. Prior-art refresh: nothing new. |
| fact checks (2026-11-01) | reproduced; text corrected | Independent fact checks of this doc, one by a reviewer from another model family (read-only). The other re-ran the minimal test (fails with assertions on and off) and both fixes (pass), and its own probe showed that a failed addIndexes(CodecReader...) leaves the writer open and leaks one input per call (3 calls, 3 inputs; 0 with fix A). The corrections are applied in this doc. |
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 421 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,599 words
- Registry title
- BPIndexReorderer leaks the forward index's term-ids temp input when opening the start-offsets temp file (or building the ForwardIndex) fails, so a failed BP reordering merge leaves an input on a deleted temp file open after IndexWriter and the Directory are closed
- Area
- misc / BP reordering (BPIndexReorderer forward index; merge-time reordering through BPReorderingMergePolicy)
- Priority
- Low, rank 1099: The same class as LFV-440 (an input leaked on a deleted temp file), but behind opt-in BP reordering and an I/O failure at one exact open.
- Impact class
- minor
- Impact note
- Hits applications that opt into the lucene/misc BPReorderingMergePolicy with BPIndexReorderer (Apache Solr exposes it as BPReorderingMergePolicyFactory) or call BPIndexReorderer#reorder or #computeDocMap directly, when Directory#openInput of the reorderer's start-offsets temp file throws (for example file-descriptor exhaustion, a mapping limit, a storage error or a faulty custom Directory) or, rarely, randomAccessSlice in the ForwardIndex constructor throws: the reorder fails and the merge fails as it would anyway (a failed merge closes the IndexWriter), but one IndexInput on the already-deleted term-ids temp file (two on the constructor path) stays open, which on MMapDirectory stays mapped, and so keeps the deleted file's disk blocks, after IndexWriter#close, Directory#close and System.gc(), and on NIOFSDirectory holds a file descriptor until garbage collection (executed); no wrong results, no index damage, no scale needed, every release from 9.8.0 to 10.6.0 (10.6.0 by reading) and main; how large a leaked term-ids file gets on a big merge is known from code reading only.
- Summary
- You merge with BPReorderingMergePolicy and a BPIndexReorderer (or call BPIndexReorderer#computeDocMap yourself), and while the reorderer opens the two temp files of its on-disk forward index, the second open (the start-offsets file) fails with an IOException, for example because the process has run out of file descriptors. You expect the reorder and the merge to fail and every temp-file input the reorderer opened to be released, as the class does for its other temp inputs and as IndexWriter#close promises ('Closes all open resources'). Instead the input on the term-ids temp file, opened one statement earlier, is never closed, because the two opens and the ForwardIndex constructor sit outside any try/finally, so it stays open (on MMapDirectory, mapped, keeping the deleted file's disk space) after IndexWriter and the Directory are closed.
- Introduced
- 39f3777886cd1967f41987a33c1991ddb901cec0 (main) and its branch_9x copy 7372a4ef055d39509144e421de7703b7977627c5, apache/lucene#12489 'Add support for recursive graph bisection.' (by jpountz, merged 2023-09-14, milestone 9.8.0), which added BPIndexReorderer with the three unguarded statements at the end of buildForwardIndex [read: git log --follow, git log -S 'tempDir.openInput(startOffsetsFileName' and -G 'new ForwardIndex\(startOffsets, termIDsInput' find only these two commits; git log -L shows the only later edit is 32d692049fc (#13242, 10.0.0), an IOContext.READ to IOContext.DEFAULT rename; exec: 9.7.0 does not compile the history repro (class absent), 9.8.0 fails]. Not a regression; #14913 (27d1272984f, 2025-07-14, main only) rewrote computePermutation's failure cleanup without touching these lines, and neither the old cleanup (closeWhileHandlingException(postingsOutput, forwardIndex) with forwardIndex still null) nor the new one (delete files only) can close them (2023-09-14)
- First affected version
- 9.8.0 [exec] (tagged with #12489; only the direct BPIndexReorderer#reorder path exists there); the merge path through BPReorderingMergePolicy (#12622) from 9.9.0 [exec]; 9.7.0 and earlier lack the class [exec: compile error]
- Affected versions
- every release from 9.8.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed; never fixed. [exec] a history repro (a MockDirectoryWrapper subclass that fails the first open of a start-offsets file, with a no-injection control that passes everywhere) fails on 9.8.0, 9.9.0, 9.10.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and passes on main with the fix shadowed; the finder's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3 (it does not compile on 9.9.0 and 9.10.0, whose computeDocMap has the older two-argument signature); the independent reproduction's JUnit fails on main (-ea, -da), 10.5.2, 9.12.3, and in a signature-adapted copy on 9.11.1 and 9.9.0. [read] 9.9.1, 9.9.2, 9.11.0, 9.12.1, 9.12.2, 10.1.0 to 10.4.0, 10.5.0 and 10.5.1 have the identical three statements and the same caller cleanup (23 release tags checked). Not fixed upstream: main a9779ef3c193 (lines 768-770), branch_10x and branch_10_6 (lines 823-825) unchanged, read 2026-10-26; no open PR changes these lines. All runs: JDK 25, SHA-1-verified release jars from Maven Central, aarch64 Linux
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal
- Upstream and prior reports
- none found (2026-10-26, read-only, on top of the finder's scripted prior-report search and the intended-behaviour check's): 229 logged queries (scripted prior-report searches of 45, 28, 45 and 29 queries over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and Elasticsearch, OpenSearch and Solr; 48 GitHub GraphQL searches; manual JIRA, list and web queries), the changed files of all 408 open apache/lucene PRs (BPIndexReorderer.java is touched by #13186, #15430 and #16847, BPReorderingMergePolicy.java by #15430 and #16571; none changes lines 768-770), CHANGES.txt (only GITHUB#12489 and GITHUB#13125 for BP), and the bodies, comments and review comments of the related PRs and issues. Related, not this bug: #12489 (introducing change; no discussion of failure cleanup), #14913 ('Remove a few more boolean success flags', 2025, rewrote computePermutation's cleanup next to the bug, no comments), #12622 (adds BPReorderingMergePolicy), #12712 (sort phase of buildForwardIndex), a 2023-11-29 builds@ CI mail with an OutOfMemoryError in ForwardIndexSorter (before the two opens, no unclosed-file report), #15068 (MMapDirectory map retention through arena pooling, different cause). Not fixed upstream (main a9779ef3c193, branch_10x, branch_10_6, read 2026-10-26).
- Lucene commit tested
- main
645d4fe14a20(2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off (expected:<0> but was:<1>, theterm-idsinput still open after the failedcomputeDocMap); with fix A it passes, as doTestBPIndexReordererandTestBPReorderingMergePolicy(assertions on and off, and with nightly tests on) and the whole misc module (24 suites, 317 tests, 0 failures, 9 skipped), with the module's format, ECJ and forbidden-apis checks; with fix B the test and the two BP suites pass. The sameBPIndexReorderer.javais on upstream main2f424fd428dandbranch_11x; 10.6.0 andbranch_10_6have the 10.5.2 file (read). Earlier runs: a version-portable test on 9.7.0 (class absent) and 9.8.0, 9.9.0, 9.10.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.5.2 and mainea57360912ed(fail), the hunt's and verifiers' tests onea57360912ed, OS-level descriptor and mapping probes onea57360912ed, and the minimal test standalone on645d4fe14a20. A fact check re-ran the minimal test and both fixes on645d4fe14a20and added a probe ofaddIndexes(CodecReader...): 3 failed calls on one writer left the writer open and 3 inputs open; 0 with fix A.
- Finding record
lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:603-624 (at ea57360912ed) (ForwardIndex constructor: startOffsetsInput.randomAccessSlice, IOException rethrown as UncheckedIOException), :710-766 (buildForwardIndex writes the term-ids and start-offsets temp files inside try-with-resources), :768-770 (termIDsInput = tempDir.openInput(...); startOffsets = tempDir.openInput(...); return new ForwardIndex(startOffsets, termIDsInput, maxTerm), with no try/finally), :773-780 (computeDocMap javadoc 'Expert: ... exposed to enable integration into BPReorderingMergePolicy'), :871-872 (try-with-resources on the ForwardIndex, entered only after buildForwardIndex returns), :896 and :899-901 (temp files deleted on success and in catch (Throwable); nothing closes the inputs), :1093-1105 (ForwardIndexSorter opens its own temp inputs in try-with-resources)lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:34-39 (at ea57360912ed) (class javadoc: when RAM is short it skips reordering 'in order not to fail the merge'), :132-145 (reorder forwards to reorderer.computeDocMap(reader, dir, executor) and catches only NotEnoughRAMException)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1342 (at ea57360912ed) (close: 'Closes all open resources and releases the write lock'), :5555-5558 (mergeMiddle calls merge.reorder(mergedView, directory, intraMergeExecutor) with the writer's own Directory), :5084-5085 and :5109-5113 (a merge exception goes to handleMergeException and then tragicEvent(t, 'merge'))lucene/core/src/java/org/apache/lucene/store/Directory.java:135-144 (at ea57360912ed) (openInput '@throws IOException in case of I/O error'); lucene/core/src/java/org/apache/lucene/store/FilterDirectory.java:31 (@lucene.internal, a separate harness objection); lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:883 ('cannot close: there are still ... open files')
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:603-650 (ForwardIndex; constructor 613-624; close 647-650), :710-771 (buildForwardIndex; 715-717 temp outputs, 768-770 the three statements), :847-903 (computePermutation; 850, 871-872, 899-902), :1094, :1104-1105 (ForwardIndexSorter)a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:133-145a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5558, 5084-5085, 5109-5113; lucene/core/src/java/org/apache/lucene/store/Directory.java:135-144 (openInput, @throws IOException at 142)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexFileNames.java:199; lucene/core/src/java/org/apache/lucene/store/MMapDirectory.java:124-127, 192, 402-407; lucene/core/src/java/org/apache/lucene/store/Directory.java:225-228 (temp file names)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:277-280 (close() does not close open inputs)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3307, 3312, 3381-3382, 3390-3392, 3562 (addIndexes(CodecReader...)); lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:200-203; lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:355-366 (pending deletes)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3390-3392, 3562, 5109-5113; lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:200-203
- Minimal repro
a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:862-872
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1342, 5558a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:773-777, 899-902, 1094, 1104-1105a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:17313-17316 (LUCENE-3147/3152, 3.3.0), 13240-13242 (LUCENE-5263, 4.5.1)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/store/Directory.java:142; lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:579, 622, 883a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/IndexReorderer.java:31; lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:320; lucene/misc/src/test/org/apache/lucene/misc/index/TestBPIndexReorderer.java, TestBPReorderingMergePolicy.java (no failure injection)
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/IOUtils.java:104-137, 145; lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:647-650, 899-902
- History
a Lucene clone (fetched 2026-10-30/31): BPIndexReorderer.java blob 20fc930c02f at 645d4fe14a20, origin/main 2f424fd428d and origin/branch_11x; blob f51321fe842 at releases/lucene/10.5.2, releases/lucene/10.6.0 and origin/branch_10_6 (lines 823-825); origin/branch_10x 48cf3f515d6 'Decommission branch 10x (#16911)' without the filea Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:3347, 3377 (9.8.0, GITHUB#12489)
- TestBPIndexReordererOpenFailureLeak.java:
repro/LFV-486/src/org/apache/lucene/misc/index/TestBPIndexReordererOpenFailureLeak.java(60 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-486/bug.png(421 KB)
