All findings · Lucene finding · LFV-486 · bpindexreorderer-forward-index-input-leak

BPIndexReorderer leaks the term-ids temp input when opening the start-offsets temp file fails

When BP reordering opens the two temp files of its forward index and the second open fails (for example, out of file descriptors), the first file's IndexInput is never closed. The reorder or merge fails as it should, but that input stays open after IndexWriter.close() and Directory.close().

Summary

Priority
Low, rank 1099The 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
minor
Since
9.8.0 (#12489, 2023-09-14); the merge-policy path from 9.9.0introduced 2023-09-14 · about 3 years
Affected
9.8.0 to 10.6.0, and main
Status
documented
Found by
fault injection at every I/O call, with and without BP reordering, then a JUnit test

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: BP reordering and its forward index

  1. BP (recursive graph bisection) renumbers documents so that documents with similar terms get nearby ids, which makes postings smaller and conjunctions faster. BPIndexReorderer computes the new order; BPReorderingMergePolicy applies it whenever segments merge. Both are opt-in, in lucene/misc.
  2. To compute the order, the reorderer writes a temporary forward index into a Directory: a term-ids file (each document's term ids) and a start-offsets file (where each document starts in it). It then opens both files for reading.
  3. An open IndexInput holds an operating-system resource: a file descriptor with NIOFSDirectory, a memory mapping with MMapDirectory. On Linux, a deleted file's disk space is freed only when the last descriptor or mapping on it is gone.
Diagram of the bug: BPIndexReorderer.buildForwardIndex writes two temp files, term-ids and start-offsets, then opens term-ids for reading (an open input), then tries to open start-offsets, which throws an IOException. The term-ids input is held only in a local variable and is never closed. The caller's cleanup deletes both temp files, so the open input now points at a deleted file and keeps its descriptor or mapping after IndexWriter.close() and Directory.close(). With the fix, a catch block closes the term-ids input before rethrowing, and 0 inputs stay open.
buildForwardIndex opens term-ids, then the start-offsets open fails: the term-ids input is never closed, while the cleanup deletes both files. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You reorder doc ids with BP from lucene/misc: BPReorderingMergePolicy with a BPIndexReorderer, or BPIndexReorderer.computeDocMap() directly. It writes two temp files, term-ids and start-offsets, then opens both for reading.
  2. Opening start-offsets throws an IOException. 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.
  3. You expect computeDocMap() to throw that IOException and leave 0 temp inputs open: the class deletes its temp files on any failure.
  4. 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 after IndexWriter.close().
  5. On a real file system, 3 failed reorders leave 3 descriptors on deleted files with NIOFSDirectory (until garbage collection) and 3 mappings with MMapDirectory, even after Directory.close() and System.gc(). With the fix: 0 and 0.

Who is affected

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.

Mechanism (Lucene main 645d4fe14a20)

// 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;
}
  1. buildForwardIndex writes the two temp files in a try-with-resources (:715-766), then opens both for reading (:768, :769) and passes them to the ForwardIndex constructor (:770), with no try around the three statements.
  2. Directory.openInput may throw ("@throws IOException in case of I/O error", Directory.java:142). When :769 throws, termIDsInput exists only as a local variable; the exception leaves the method and nothing closes it.
  3. The ForwardIndex constructor calls randomAccessSlice and rethrows an IOException as UncheckedIOException (:613-624). If it throws, both inputs are lost. Its close() 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.
  4. computePermutation takes ownership in a try-with-resources only after buildForwardIndex returns (:871-872). Its catch (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, FSDirectory keeps it as a pending delete, FSDirectory.java:355-366, and the file stays on disk).
  5. Merge path: BPReorderingMergePolicy calls reorderer.computeDocMap(reader, dir, executor) and catches only NotEnoughRAMException (BPReorderingMergePolicy.java:133-145); IndexWriter passes its own directory (IndexWriter.java:5558); the merge exception ends in tragicEvent(t, "merge") (IndexWriter.java:5109-5113), which closes the writer. The writer never tracked the leaked input, so IndexWriter.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 (tragicEvent only for an Error, IndexWriter.java:3390-3392), so the writer stays open.
  6. Why the mapping outlives Directory.close() on MMapDirectory (by reading): temp names like term-ids__1.tmp do not match IndexFileNames.CODEC_FILE_PATTERN (IndexFileNames.java:199), so the default GROUP_BY_SEGMENT grouping gives no group and each input gets its own Arena.ofShared() (MMapDirectory.java:124-127, :192, :402-407), which only the input's close() frees. The input is unreachable, so the mapping stays until the JVM exits; the runs show it after Directory.close() and System.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: BPReorderingMergePolicy with a BPIndexReorderer (merge time), or direct calls to BPIndexReorderer.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 randomAccessSlice in the ForwardIndex constructor is a second, rarer point). Realistic causes: file-descriptor exhaustion (NIOFSDirectory), address-space or mapping-count limits (MMapDirectory), a storage error, or a faulty custom Directory. Every run injected the IOException; no public report was found (the code dates from 9.8.0, 2023).
  • What leaks. One IndexInput per failed reorder (two on the constructor path), on a deleted temp file: a descriptor until garbage collection on NIOFSDirectory; a mapping on MMapDirectory (seen after Directory.close() and System.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 MockDirectoryWrapper tracks 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-ids file 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 failed IndexWriter.addIndexes(CodecReader...) through BPReorderingMergePolicy does 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 after IndexWriter.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.

Minimal repro

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.

TestBPIndexReordererOpenFailureLeak.java60 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.misc.index;

import 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();
    }
  }
}
Command
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 injected IOException, and the temp directory has 0 open inputs.
Observed
The IOException is thrown, but 1 input is still open (expected:<0> but was:<1>): the one on term-ids__1.tmp, with assertions on and off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with either fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

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

Why it looks unintended

  • 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.
  • openInput declares IOException, 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 own Directory (IndexWriter.java:5558).
  • The class's own failure handling. computePermutation wraps the whole computation in a catch (Throwable) that deletes its temp files (BPIndexReorderer.java:899-902); ForwardIndexSorter opens every temp input in try-with-resources (:1094, :1104-1105). Before #14913 (2025-07, main only) the failure branch called IOUtils.closeWhileHandlingException(postingsOutput, forwardIndex): the authors meant to close the forward index on failure, but forwardIndex was still null when buildForwardIndex threw, 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 ships MockDirectoryWrapper.setRandomIOExceptionRateOnOpen to test exactly this, and CHANGES 4.5.1, LUCENE-5263, treats "Transient IOExceptions, e.g. due to disk full or file descriptor exhaustion" inside IndexWriter as bugs.
  • Nothing sanctions the leak. IndexReorderer.computeDocMap says only "temporary files may be stored here while reordering"; MergePolicy.OneMerge.reorder says the Directory "may be used to create temporary files"; TestBPIndexReorderer and TestBPReorderingMergePolicy inject 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.

Suggested fix

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.

A. catch (Throwable), close what was opened, rethrow (validated by the checks) (recommended)

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; computePermutation already ends in catch (Throwable t) ... throw t (:899-902).
  • closeWhileSuppressingExceptions skips nulls and adds close failures to t as suppressed (IOUtils.java:104-137), so the caller still sees the original IOException (if t is not an Error and a close throws one, that Error is thrown with t suppressed; if t is an Error, t is thrown). throw t compiles under throws IOException by Java's precise rethrow.
  • IOUtils.closeWhileSuppressingExceptions came 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 on branch_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, TestBPIndexReorderer and TestBPReorderingMergePolicy pass with assertions on and off (3 suites, 12 tests, 0 failures, 2 skipped: the merge-policy suite's two @Nightly tests) and with -Ptests.nightly=true (12 tests, 0 skipped); the whole :lucene:misc:test passes with assertions on (24 suites, 317 tests, 0 failures, 9 skipped).
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:misc pass with the fix and the repro test in place.

B. try/finally with a success flag (the finder's sketch) (validated by the checks)

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.
  • closeWhileHandlingException drops 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 ran TestSoftDeletesRetentionMergePolicy with an unrelated fix shadowed, 25 tests in all); both OS-level probes report 0 leaked descriptors and 0 mappings.

C. Let the caller own the inputs (not tried)

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.

Downstream note

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.

How it was found

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 at BPIndexReorderer.buildForwardIndex:769, the start-offsets openInput.
  • 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26; refreshed 2026-11-01four 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 openInputonly related items (#12489, #14913, #12622, #12712, #15068); refresh: no hit (one unrelated, #11608)
Open PRs by touched file2026-10-26; refreshed 2026-11-01the 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 web2026-10-26dev@, 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 queriesa 2023-11-29 builds@ CI mail with an OutOfMemoryError in the sort phase, before the two opens; no report of this bug

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
9.7.0 not affectedthe class does not exist (the test does not compile)
9.8.0 affectedfails: 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 affectedfail: direct {term-ids__1.tmp=1}, merge path {term-ids__5.tmp=1} after IndexWriter.close()
main ea57360912ed + fix B causalitypasses 3 of 3
10.6.0, branch_10_6, branch_11x, upstream main 2f424fd428d by readingthe 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 affectedthe minimal test fails through gradle with assertions on and off; passes with either fix

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateFault-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, highRoot cause agrees, inputs in contract, intended argument weak. A separate run re-ran the repro: fails.
independent reproducer realOwn 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 realRe-ran main (assertions on and off) and 10.5.2: fails 2 of 2; no intended-behaviour argument survived.
adjudication confirmed, highRe-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; confirmedHistory 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 validatedGradle 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 correctedIndependent 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,599 words

Finding record

LFV-486, snapshot of 2026-11-06

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>, the term-ids input still open after the failed computeDocMap); with fix A it passes, as do TestBPIndexReorderer and TestBPReorderingMergePolicy (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 same BPIndexReorderer.java is on upstream main 2f424fd428d and branch_11x; 10.6.0 and branch_10_6 have 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 main ea57360912ed (fail), the hunt's and verifiers' tests on ea57360912ed, OS-level descriptor and mapping probes on ea57360912ed, and the minimal test standalone on 645d4fe14a20. A fact check re-ran the minimal test and both fixes on 645d4fe14a20 and added a probe of addIndexes(CodecReader...): 3 failed calls on one writer left the writer open and 3 inputs open; 0 with fix A.

Lucene and upstream sources

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-145
  • a 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, 5558
  • a Lucene main checkout (645d4fe14a20) lucene/misc/src/java/org/apache/lucene/misc/index/BPIndexReorderer.java:773-777, 899-902, 1094, 1104-1105
  • a 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, 883
  • a 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 file
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:3347, 3377 (9.8.0, GITHUB#12489)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

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