All findings · Lucene finding · LFV-440 · sorting-flush-temp-reader-leak

With an index sort, a failure while creating the sorted stored-fields or term-vectors writer during flush leaves the temporary files open after IndexWriter closes itself

With an index sort, if creating the sorted stored-fields or term-vectors file fails during a flush (for example on too many open files), IndexWriter closes itself as designed but leaves the 2 temporary files it was copying from open. On the default MMapDirectory they stay mapped even after Directory.close().

Summary

Priority
Low, rank 1098With an index sort, a file-open failure during flush leaves two inputs on deleted temporary files open after IndexWriter closes, a bounded exception-path leak like LFV-446.
Impact
minor
Since
6.5.0 (LUCENE-7579, committed 2016-12-20; 6.x backport 2017-01-17)introduced 2016-12-20 · about 9 years
Affected
6.5.0-10.6.0 and main
Status
documented
Found by
semi-formal: exception-point enumeration against a model, 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

The bug sits in the copy step of a sorted flush: the reader on the temporary files is opened before the code that guarantees it gets closed.

Background: how a sorted flush writes stored fields

  1. IndexWriterConfig.setIndexSort(...) makes every new segment store its documents in sort order, for example by timestamp. Documents arrive in any order, so their stored fields (and term vectors) cannot be written in final order as they come.
  2. Lucene writes them in arrival order to temporary files such as _0.fdt__1.tmp and _0.fdx__4.tmp. At flush it opens a reader on those files, creates the segment's real stored-fields files (.fdm, .fdt, .fdx), copies every document across in sorted order, closes both and deletes the temporary files.
  3. A flush that fails with an exception is tragic by design: IndexWriter drops the unflushed documents and closes itself, and the last commit stays as it was.
Diagram of a sorted flush: documents sit in temporary .fdt and .fdx files in arrival order; the flush opens a reader on them; creating the final .fdm file then fails, one line before the try whose finally would close the reader; IndexWriter closes itself and abort deletes the temporary files by name; the two deleted files stay open, and on MMapDirectory stay mapped after Directory.close. With the writer created inside the try, the finally closes the reader and nothing stays open.
A sorted flush opens a reader on the temporary files, then fails to create the final writer before the try that would close it: IndexWriter closes itself, abort() deletes the files, and the reader keeps both deleted files open. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Set an index sort on a LONG field, add 1 document with a stored field and call commit(), while creating the segment's .fdm file fails once with an IOException, as it would when the process has run out of file descriptors.
  2. You expect commit() to throw and IndexWriter to close itself, as after any failed flush, with 0 files left open: IndexWriter.close() "Closes all open resources".
  3. Instead 2 files stay open, the temporary _0.fdt__1.tmp and _0.fdx__4.tmp. The flush opened a reader on them to copy the document into sorted order, then failed to create the final writer one line before the try whose finally would close that reader.
  4. Nothing can close them now: the reader was a local variable, and abort() only deletes the files by name. On MMapDirectory the 2 deleted files stay mapped after IndexWriter.close(), Directory.close() and System.gc(), so their disk space is not freed.
  5. Term vectors (.tvd/.tvx) fail the same way. Every release from 6.5.0 to 10.6.0 and main is affected; creating the writer inside the try fixes it.

Who is affected

Applications that set an index sort, when creating or opening a file fails during a sorted flush (too many open files, a permission, quota or inode limit, a read-only remount, a throwing Directory or codec). Each failure leaks 2 inputs on deleted files (1 per consumer on 6.5.0 to 7.7.3); no data is lost and the last commit stays intact.

Mechanism, terms and numbers564 words

With IndexWriterConfig#setIndexSort, a segment's stored fields (and term vectors) are first written in arrival order to temporary files. At flush, SortingStoredFieldsConsumer.flush opens a reader on those files, then creates the final sorted writer, and only then enters the try whose finally closes both. If creating the final writer throws, for example because the .fdm file cannot be created when the process has too many open files, the finally never runs and the reader, a local variable, is lost. The flush failure is aborting, so IndexWriter closes itself as designed and abort() deletes the temporary files by name, but nothing closes the 2 inputs on them. SortingTermVectorsConsumer.flush has the same order. A one-document test shows 2 handles still open after close(); on MMapDirectory, the 64-bit default, the 2 deleted files stay mapped after Directory.close() and System.gc(), so their disk space stays allocated. The order dates from LUCENE-7579 (2016): every release from 6.5.0 to 10.6.0 and main is affected. Moving the writer creation into the existing try fixes it.

Terms

  • Index sort: IndexWriterConfig#setIndexSort, an opt-in setting that keeps the documents of every segment in sort order.
  • Temporary files: while documents are buffered, SortingStoredFieldsConsumer writes their stored fields uncompressed to temporary files (_0.fdt__1.tmp, _0.fdx__4.tmp) through a TrackingTmpOutputDirectoryWrapper; SortingTermVectorsConsumer does the same for term vectors (.tvd/.tvx).
  • Tragic close: any exception during a flush is aborting; the in-memory segment is discarded and IndexWriter closes itself.

The bug

  1. With an index sort the indexing chain uses the two sorting consumers (IndexingChain.java:152, :154).
  2. At flush (IndexingChain.java:345), SortingStoredFieldsConsumer.flush opens the temporary reader (SortingStoredFieldsConsumer.java:111-113; it keeps 2 inputs open), then creates the final writer (:116-117), then enters the try (:118) whose finally runs IOUtils.close(reader, sortWriter) (:136) and deletes the temporary files (:137).
  3. The final writer's constructor creates .fdm and .fdt, two temporary outputs of FieldsIndexWriter, and writes codec headers (Lucene90CompressingStoredFieldsWriter.java:129-162). All of that is inside its own try: when any of it throws, the constructor closes its own outputs (:160) and rethrows; the statements before that block (such as compressionMode.newCompressor(), :120) create no file. The exception leaves flush before the try, so the finally never runs and the reader local is dropped.
  4. DocumentsWriterPerThread.flush treats the exception as aborting (DocumentsWriterPerThread.java:585-589) and fires the tragic event (:602): IndexWriter closes itself. SortingStoredFieldsConsumer.abort (:142-151) deletes the temporary files by name; it has no reference to the reader. The 2 inputs now point at deleted files that nothing will close.
  5. SortingTermVectorsConsumer.flush has the same order: reader :62-64, writer :68-71, try :72, finally :80; abort :97-104.
  6. On MMapDirectory the inputs are mapped in a shared arena (MMapDirectory.java:329, :402-429; RefCountedSharedArena.java:74) that only closing every input releases, so garbage collection and Directory.close() leave the mapping in place.

The numbers

One document, the default codec, createOutput of _0.fdm failing once: commit() throws, close(), and MockDirectoryWrapper counts 2 open files, {_0.fdx__4.tmp=1, _0.fdt__1.tmp=1}, the unclosed input opened at SortingStoredFieldsConsumer.flush(SortingStoredFieldsConsumer.java:112). On a real MMapDirectory both deleted files are still listed in /proc/self/maps after the tragic close, after 5 calls to System.gc() and after Directory.close(); on NIOFSDirectory, /proc/self/fd lists both until a System.gc() releases them.

Exposure and likelihood451 words
  • Who is exposed. Applications that set IndexWriterConfig#setIndexSort (opt-in). No scale is needed: one document is enough. Since 8.7.0 every sorted flush opens the temporary stored-fields reader (by code reading, not executed: before the flush StoredFieldsConsumer.finish, StoredFieldsConsumer.java:97-102, called at IndexingChain.java:344, starts every document not yet started, and starting a document creates the temporary writer, :55-57, even when no document has a stored field; the hasStoredFields check at SortingStoredFieldsConsumer.java:129 only skips the per-document reads); the term-vectors reader only when term vectors exist.
  • What it takes. A failure to create or open a file while the flush constructs the final stored-fields or term-vectors writer: too many open files (EMFILE/ENFILE), a permission, quota or inode limit, a read-only remount, or a Directory or codec that throws.
  • Not disk full, normally. On an FSDirectory the constructor's header writes go into an 8 KiB buffer (FSDirectory.java:378, :400; OutputStreamIndexOutput.java:48), so an out-of-space error usually surfaces later, inside the try, where the reader is closed. Running out of inodes or quota can fail the file creation itself (listed above).
  • How often, given such failures. Under upstream's own untargeted open-failure model (MockDirectoryWrapper#setRandomIOExceptionRateOnOpen(0.1) during commit, 300 rounds per arm), 28 of 300 index-sorted rounds leaked (23 the .fdt/.fdx pair, 5 the .tvd/.tvx pair), 0 of 300 unsorted rounds and 0 of 300 with the fix. (The history step's version-portable port, a different harness, counted 56 of 300 on the same commit; the two counts are not comparable.) The failure itself is rare, and nobody has reported the leak since 2017.
  • What happens. Per failed flush, 2 inputs on deleted temporary files stay open (on main and from 8.6.3 on; on 6.5.0 to 7.7.3 one per consumer). On MMapDirectory (the 64-bit default) they are 2 mappings that survive IndexWriter.close(), Directory.close() and System.gc(); while they live, the disk space of those files stays allocated: the uncompressed stored fields (or term vectors) of the one segment that failed to flush. On NIOFSDirectory they are 2 file descriptors, released by System.gc() in one run. The application cannot release them: no API reaches the reader. Process exit does; whether anything earlier does was not tested.
  • What does not happen. No hang, no wrong results, no corruption. The failed segment is discarded by the tragic close, as designed; the last commit stays intact and a new IndexWriter opens it normally. A later writer on the same MMapDirectory does not keep later segments' files mapped (its segments were _1 to _3; MMapDirectory groups mappings by segment name, so whether a later segment again named _0 would stay mapped was not tested). Windows, where open files cannot be deleted, was not tested.

Minimal repro

One JUnit test: index sort, 1 document, a FilterDirectory that fails createOutput for the .fdm file, and the test framework's MockDirectoryWrapper to count open files. commit() throws, then close(), then it expects 0 open files. Single thread, deterministic by inspection (a fixed file name fails); fails under both seeds tried, with assertions on and off.

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

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StoredField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.FilterDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestSortedFlushTempReaderLeak extends LuceneTestCase {

  public void testFailedSortedStoredFieldsWriterLeavesTempReaderOpen() throws IOException {
    MockDirectoryWrapper dir = new MockDirectoryWrapper(random(), new ByteBuffersDirectory());
    FilterDirectory failing =
        new FilterDirectory(dir) {
          @Override
          public IndexOutput createOutput(String name, IOContext context) throws IOException {
            if (name.endsWith(".fdm")) {
              throw new IOException("simulated failure creating " + name);
            }
            return super.createOutput(name, context);
          }
        };
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setCodec(TestUtil.getDefaultCodec())
            .setIndexSort(new Sort(new SortField("sort", SortField.Type.LONG)));
    IndexWriter w = new IndexWriter(failing, iwc);
    Document doc = new Document();
    doc.add(new NumericDocValuesField("sort", 1));
    doc.add(new StoredField("stored", "value"));
    w.addDocument(doc);
    expectThrows(IOException.class, w::commit);
    w.close();
    // expected: no open files after the failed flush; actual: the temporary .fdt/.fdx stay open
    assertEquals("open file handles after IndexWriter closed", 0, dir.getFileHandleCount());
    dir.close();
  }
}
Command
cp TestSortedFlushTempReaderLeak.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestSortedFlushTempReaderLeak -Ptests.seed=DEADBEEF
# add -Ptests.asserts=true or -Ptests.asserts=false to force assertions on or off: same result
Expected
commit() throws, IndexWriter closes itself, and 0 file handles remain open.
Observed
open file handles after IndexWriter closed expected:<0> but was:<2>: the temporary _0.fdt__1.tmp and _0.fdx__4.tmp stay open.

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

More files, environment, full output278 words

In a sorted flush only the final sorted stored-fields writer creates the .fdm file (the temporary writer goes through createTempOutput), so failing that one name hits exactly the window. The default codec is pinned so the extension is fixed. Term vectors share the root cause and the fix and were reproduced separately by the finder, the independent reproduction, a separate test and the adjudication.

With one document the test needs 8.7.0 or later (before LUCENE-9510 an already-sorted flush renamed the temporary files and opened no reader), and its org.apache.lucene.tests.* imports need 9.1.0 or later; older releases were checked with a version-portable main() port that indexes 3 documents in reverse sort order.

As the regression test: the same test, for example as a new method in TestSortingStoredFieldsConsumer, which already wraps its directory in a FilterDirectory.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the test also fails on 10.6.0; the bug is in every release from 6.5.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestSortedFlushTempReaderLeak.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestSortedFlushTempReaderLeak -Ptests.seed=DEADBEEF
# add -Ptests.asserts=true or -Ptests.asserts=false to force assertions on or off: same result

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (verbatim excerpt); the same with -Ptests.asserts=false
TestSortedFlushTempReaderLeak > testFailedSortedStoredFieldsWriterLeavesTempReaderOpen FAILED
    java.lang.AssertionError: open file handles after IndexWriter closed expected:<0> but was:<2>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:FE21DBF8CE31E49D]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.index.TestSortedFlushTempReaderLeak.testFailedSortedStoredFieldsWriterLeavesTempReaderOpen(TestSortedFlushTempReaderLeak.java:59)

# the same test calling MockDirectoryWrapper.close() instead of the assertion (standalone, -ea; excerpt, the frames in between left out)
java.lang.RuntimeException: MockDirectoryWrapper: cannot close: there are still 2 open files: {_0.fdx__4.tmp=1, _0.fdt__1.tmp=1}
Caused by: java.lang.RuntimeException: unclosed IndexInput: _0.fdx__4.tmp
	...
	at org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingStoredFieldsReader.<init>(Lucene90CompressingStoredFieldsReader.java:206)
	at org.apache.lucene.codecs.lucene90.compressing.Lucene90CompressingStoredFieldsFormat.fieldsReader(Lucene90CompressingStoredFieldsFormat.java:133)
	at org.apache.lucene.index.SortingStoredFieldsConsumer.flush(SortingStoredFieldsConsumer.java:112)
	at org.apache.lucene.index.IndexingChain.flush(IndexingChain.java:345)
	at org.apache.lucene.index.DocumentsWriterPerThread.flush(DocumentsWriterPerThread.java:492)

Why it looks unintended

  • close() is documented to close "all open resources", and the flush's own finally already closes the reader: only the writer's creation sits outside the try.
  • The review that added this code added abort() to clean up after exceptions here.
  • Lucene's tests fail on files left open after a tragic close; upstream fixed the same create-before-try pattern in #14394 and d16e25518ba.
The full argument371 words
  • The contract. IndexWriter.close(): "Closes all open resources and releases the write lock." (IndexWriter.java:1342). The class javadoc: "if you hit an Error, or disaster strikes during a checkpoint then IndexWriter will close itself" (:162-165); nothing says it may then leave files open. This states the intent, not a promise for the tragic path: the writer has already closed itself (IndexWriter.java:6094, :6098-6105), and a later close() does nothing (shutdown and shouldClose, :1319, :1374-1393).
  • The code's own intent. The finally at SortingStoredFieldsConsumer.java:136 closes the reader and the writer together on every path through the try; only the writer's creation was left one statement outside. No comment, commit or PR explains the order, and the fix keeps it.
  • Cleanup after exceptions was designed here. In the LUCENE-7579 review (#8630), mikemccand asked whether an IOException "during the renaming that SortingStoredFieldsConsumer.flush does" (a rename step of that time) could "leave undeleted files"; jimczi added abort() to both consumers in answer. IndexingChain.abort's comment says its finalizer "will e.g. close any open files in the term vectors writer" (IndexingChain.java:531): the abort path closes the files it owns, but nothing on it owns the temporary reader, a local variable of flush. The bug is that ownership gap.
  • Lucene's tests enforce it. MockDirectoryWrapper.close() throws "cannot close: there are still N open files" with no exemption for a tragic close (MockDirectoryWrapper.java:873-887); TestIndexWriterOutOfFileDescriptors accepts a tragic close (:88) and then calls dir.close() (:166). No upstream test combines setIndexSort with failure injection: a coverage gap, consistent with CI never having caught this.
  • Upstream fixes the same pattern as a bug. Two fixes move a create or open call that sat before its try into it: #14394 (9b9fbd44339, 2025: two createTempOutput calls, declared null and assigned inside the try) and d16e25518ba (2026: "the open call was outside the relevant try-catch block"). #12739 (361f0a4524d, 2023) is the same family, not the same shape: it adds a temporary output that was missing from the failure-path close list.
  • The trigger is legal. Directory#createOutput declares IOException (Directory.java:90-92), and MockDirectoryWrapper#setRandomIOExceptionRateOnOpen is upstream's own model of running out of file descriptors.
The devil's-advocate check136 words

Argument: the flush has already failed and the writer is tragically closed, so its state afterwards is best effort; the trigger is an environmental failure in a one-statement window; and the order could be deliberate.

Why it fails: close() is documented to close all open resources, abort() was added precisely to clean up after this kind of exception, Lucene's test framework fails any test that leaves a file open after a tragic close, and every other tragic flush failure the verifiers tried released all handles (the controls, and 0 of 300 unsorted rounds). Nothing documents or explains the order. The leaked inputs are also beyond the application's reach: no API returns them, and on MMapDirectory not even Directory.close() releases them. What the argument does limit is the impact: a minor leak on a rare error path.

Suggested fix

Recommended A. Create the final writer inside the existing try

Create the final writer inside the existing try (declare it null first; IOUtils.close ignores nulls) in both consumers, so the finally closes the temporary reader on every path. With it the repro passes with assertions on and off, and one run of the repro, the finder's 3 tests and 7 nearby upstream suites gives 117 tests, 0 failures, 3 skipped.

-    StoredFieldsWriter sortWriter =
-        codec.storedFieldsFormat().fieldsWriter(state.directory, state.segmentInfo, state.context);
+    StoredFieldsWriter sortWriter = null;
     try {
+      sortWriter =
+          codec
+              .storedFieldsFormat()
+              .fieldsWriter(state.directory, state.segmentInfo, state.context);

// SortingTermVectorsConsumer.flush: the same move for `TermVectorsWriter writer`
All options and trade-offs2 options · 692 words

Two options were run through gradle in a scratch worktree of 645d4fe14a20; both change only SortingStoredFieldsConsumer.flush and SortingTermVectorsConsumer.flush. A is the smallest change and keeps the existing finally.

A. Create the final writer inside the existing try (validated by the checks) (recommended)

Declare the writer as null before the try and assign it as the first statement inside, in both consumers: 10 lines added, 6 removed (formatted by google-java-format). The existing finally then runs IOUtils.close(reader, sortWriter), which ignores a null writer, and deletes the temporary files.

-    StoredFieldsWriter sortWriter =
-        codec.storedFieldsFormat().fieldsWriter(state.directory, state.segmentInfo, state.context);
+    StoredFieldsWriter sortWriter = null;
     try {
+      sortWriter =
+          codec
+              .storedFieldsFormat()
+              .fieldsWriter(state.directory, state.segmentInfo, state.context);

// SortingTermVectorsConsumer.flush: the same move for `TermVectorsWriter writer`

Trade-offs

  • Smallest change, in the same shape upstream used for #14394 (declare null, assign inside the try); the order (reader, then writer) and the finally stay as they are, so the success path is unchanged. It covers exactly the window: if super.flush or the reader's constructor throws, nothing changes.
  • IOUtils.close ignores null arguments (its javadoc: "Some of the Closeables may be null; they are ignored."), so no extra check is needed.
  • It keeps one existing quirk and extends it to the writer's creation: if closing throws in the finally, that exception replaces the one from the try body, so a failure to close the reader would now also hide the writer-creation exception. Option B would change that.

What the checks validated

  • Gradle on 645d4fe14a20, seed DEADBEEF: without a fix the repro fails with assertions on and off and the finder's 3-test class fails 4 of 4 (3 tests and the suite-level leaked-file-handle check).
  • With fix A: the repro passes with assertions on and off; in one run with assertions on, the repro, the finder's 3 tests and the closest upstream suites (TestIndexSorting 62, TestIndexWriterExceptions 40, TestIndexWriterOnDiskFull 4, TestIndexWriterOutOfFileDescriptors 1, TestSortingCodecReader 3, TestSortingStoredFieldsConsumer 1, TestTrackingTmpOutputDirectoryWrapper 2) give 9 classes, 117 tests, 0 failures, 3 skipped (the three @Nightly tests of TestIndexWriterExceptions).
  • With fix A the repro also passes with assertions off.
  • Format: written as a plain move, the stored-fields line goes over 100 columns and checkGoogleJavaFormat fails on it alone (the repro test itself is clean); applyGoogleJavaFormat wraps that line, and with the formatted diff the check passes and the repro and the finder's 3 tests pass again (4 of 4). The suites ran with the unformatted diff, which differs only in line breaks.
  • Earlier, by the study: the same change passes every focused repro, upstream's random open-failure model (0 of 300 rounds) and the MMapDirectory probe (no mapping left), and the same move on the 6.5.0 sources fixes 6.5.0. The full Lucene test suite was not run.

B. Open the reader and the writer as try-with-resources (validated by the checks)

Make both the reader and the writer resources of one try (...) and drop IOUtils.close(reader, writer) from the finally, which keeps only the deletion of the temporary files: 19 lines added, 21 removed, google-java-format clean as written.

-    StoredFieldsReader reader =
-        TEMP_STORED_FIELDS_FORMAT.fieldsReader(
-            tmpDirectory, state.segmentInfo, state.fieldInfos, IOContext.DEFAULT);
-    StoredFieldsWriter sortWriter =
-        codec.storedFieldsFormat().fieldsWriter(state.directory, state.segmentInfo, state.context);
-    try {
+    try (StoredFieldsReader reader =
+            TEMP_STORED_FIELDS_FORMAT.fieldsReader(
+                tmpDirectory, state.segmentInfo, state.fieldInfos, IOContext.DEFAULT);
+        StoredFieldsWriter sortWriter =
+            codec.storedFieldsFormat().fieldsWriter(...)) {
       ...
     } finally {
-      IOUtils.close(reader, sortWriter);
       IOUtils.deleteFiles(tmpDirectory, tmpDirectory.getTemporaryFiles().values());

// SortingTermVectorsConsumer.flush: the same, see the diff file

Trade-offs

  • The idiomatic Java form; a resource that was opened is always closed, whatever throws.
  • Small behaviour changes: resources close in reverse order (writer, then reader); an exception from the body is kept and close failures are added to it as suppressed (today a close failure in the finally replaces it); and when opening the reader itself fails, the finally now deletes the temporary files (today only abort() does).
  • Larger diff: the declarations move into the try header, and the long writer expression is re-wrapped.

What the checks validated

  • Gradle on 645d4fe14a20, seed DEADBEEF, same worktree: the repro passes with assertions on and off; the same suites run as for fix A gives 9 classes, 117 tests, 0 failures, 3 skipped; checkGoogleJavaFormat passes.

Downstream note

Elasticsearch's time_series and logsdb index modes set an index sort by default (read from its source, not tested), so their flushes take this code path.

How it was found

Semi-formal: a fault-injection harness made each distinct file-system call of short IndexWriter scripts throw one IOException in turn, checked the outcome against a model and then checked for open files. Throws inside the sorted writer's constructor were tragic, as expected, but left 2 files open; the root cause was then read from the code.

The method in full159 words

Method: semi-formal (exception-point enumeration of the real jar against a model). On 2026-10-26 a hunt unit ran short scripts of document-level IndexWriter operations (add, update, delete, batches, commits, near-real-time readers) under several configurations, one with an index sort. For every operation it enumerated each distinct MockDirectoryWrapper failure site, made it throw one IOException in turn, and classified the outcome against a model as unchanged, applied or tragic; then it checked MockDirectoryWrapper.close() for open files. In its exhaustive 2-operation runs (3 runs, about 14.4 million executions), the injections at createOutput, createTempOutput and the header writes inside the constructor of the sorted stored-fields or term-vectors writer were classified tragic, as expected, and then failed the open-file check (cannot close: there are still 2 open files: {_1.fdt__f.tmp=1, _1.fdx__m.tmp=1}). The root cause was then read from the code and a targeted JUnit written; it was later cut to the one-test repro. It could also be read as model-based randomized testing.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (incl. the JIRA archive), all open PRs, the mailing lists, downstream trackers or the web (2026-10-26, refreshed 2026-11-01). The LUCENE-7579 review (#8630) asked about exception cleanup here and added abort(); the open reader was never raised. Not fixed upstream.

Searches4 searches

Closest items, none a report of this bug: #8630 (LUCENE-7579, the introducing change; the review asked whether an IOException in the rename step of that time could leave undeleted files and abort() was the answer): context; #9487 (LUCENE-8441) and #11637 (LUCENE-10601): NullPointerException in SortingTermVectorsConsumer.abort, a different exception-path bug in the same class; #16553 (merged 2026-09): a stored-fields leak in IndexingChain when PerField#finish throws, same family, different code; #16625 (open): rollback deletes files of pooled readers; #15068 (open): MMapDirectory arena pooling keeps .si mappings alive, a different leak in the same arena pooling, related context only. Of all 411 open PRs only #16269 (draft), #12948 and #1003 touch the two files, and none moves the writer creation. Not fixed: upstream main 2f424fd428da and branch_11x 603af870375 have both files identical to 645d4fe14a20, and branch_10_6 7e5aec4370e still creates the writer before the try (it differs only in the term-vectors checkIntegrity() call); branch_10x was decommissioned on 2026-10-29 (#16911). Limits: list search is fuzzy, and private channels are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. the migrated JIRA issues) and commits2026-10-26, class-name issue search refreshed 2026-11-01a scripted prior-report search (53 issue/PR and 6 commit queries: both class names alone and with leak, exception and close words; unclosed IndexInput, cannot close: there are still, file handle leak; code fragments such as IOUtils.close(reader, sortWriter)) plus the finder's 50 queries; the issue comments fetched for its hits, and the inline review comments of the 6 PRs that touched these files, read by handno report; #8630 context; #9487, #11637, #16553, #16625, #15068 related, different
Open PRs2026-10-26changed files of all 411 open apache/lucene PRs#16269 (draft), #12948, #1003 touch the files; none fixes it
Mailing lists (dev@, java-user@, issues@, commits@, builds@) and the JIRA archive2026-10-2650 list and 8 JIRA queries; 13 CI failure mails containing "unclosed IndexInput" read one by onenothing about this leak; no CI mail names a sorting temporary file
Downstream repositories (Elasticsearch, OpenSearch, Solr) and the web2026-10-266 downstream and 12 web queriesboth class names: 0 downstream items; no report

History

Present since flush-time index sorting was added (LUCENE-7579, 4ccb9fbd2bb, 2016; first released in 6.5.0), so not a regression. 6.4.2 is clean; every executed release from 6.5.0 to 10.6.0 and main leaks. Since 8.7.0 every sorted flush with stored fields or term vectors reaches it.

Versions tested12 rows

Introduced by LUCENE-7579 "Sorting on flushed segment" (#8630; contributed by jimczi, committed by mikemccand; reviewed on JIRA, no pull request): 4ccb9fbd2bb "LUCENE-7579: sort segments at flush too" on master (2016-12-20, first released in 7.0.0) and the branch_6x backport 7d96f9f7981 (2017-01-17, first released in 6.5.0). The first version of both consumers already had this order. 9 later commits to the stored-fields flush and 7 to the term-vectors flush (#13265, #15001, #15718, #16281 among them) never moved the writer creation into the try.

Scope by version: from 6.5.0 to 8.6.3 only flushes that reorder documents leak, because an already-sorted flush renamed the temporary files and returned before opening a reader; LUCENE-9510 (8.7.0) removed that shortcut, so every sorted flush with stored fields or term vectors now goes through the leaking code.

Method: release jars SHA-1-checked against Maven Central; a version-portable main() port (3 documents in reverse sort order, one targeted failure, plus 300 rounds of upstream's random open-failure model; its counts come from its own script and are not comparable with the intended-behaviour check's 28 of 300) and the JUnit repros where the test framework allows. Not fixed: upstream main 2f424fd428da (2026-10-30) and branch_11x 603af870375 have both files identical to 645d4fe14a20; branch_10_6 7e5aec4370e still creates the writer before the try (it differs only in the term-vectors checkIntegrity() call); branch_10x was decommissioned on 2026-10-29 (#16911, 48cf3f515d6), and 10.x bug fixes now go to branch_10_6.

Versions and branches executed, with results
Version or commitVerdictResult
6.4.2 not affectedno leak: the two consumers do not exist yet; random model 0 of 300 rounds
6.5.0 affectedport leaks _0.fdt__1.tmp (stored fields) and _0.tvd__3.tmp (term vectors); random model 74 of 300; reordering flushes only
6.5.0 + fix fix checkwriter creation moved into the try: no leak, random model 0 of 300
6.6.6, 7.0.0, 7.7.3 affectedport leaks; random model 74, 62 and 63 of 300
8.6.3 affectedport leaks the .fdt/.fdx and .tvd/.tvx pairs; reordering flushes only; random 41 of 300
8.7.0 affectedalready-sorted flushes leak too (LUCENE-9510); random 78 of 300
8.11.4, 9.0.0 affectedport leaks; random 55 and 64 of 300
9.12.3, 10.0.0 affectedport leaks (random 60 of 300 each); finder's JUnit 3 of 3 fail
10.5.2 affectedport and finder's JUnit fail with assertions on and off; random 43 of 300
10.6.0 affectedminimal repro fails with -ea and -da (release jars)
main ea57360912ed affectedfinder's JUnit 3 run, 4 failures; the tests of the independent reproduction, the intended-behaviour check and the adjudication, and a separate test, fail; random 56 of 300
main 645d4fe14a20 affectedminimal repro fails in gradle and standalone, assertions on and off; passes with fix A or B

Independent checks

Confirmed, high confidence: the finder, an independent reproducer, a separate test, an intended-behaviour devil's advocate, the adjudication and a credibility audit all reproduced it with assertions on and off. A reviewer from another model family, unable to run code, returned a procedural "not real" while agreeing on the root cause and the fix.

Checks and verdicts344 words

Everyone agrees on the root cause, that the trigger is within the contract, and that moving the writer creation into the try fixes it; the impact is minor.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateException-point enumeration (about 14.4 million executions in 3 exhaustive runs); its 3-test JUnit (stored fields, term vectors, NIOFSDirectory descriptors) gives 3 run, 4 failures on main with assertions on and off, 3 of 3 on 10.5.2 and 9.12.3; its fix passes 3 of 3.
Independent reproducer realOwn public-API JUnit (a FilterDirectory counting open inputs, no MockDirectoryWrapper): 6 run, 2 failed (the claim tests) on main, 10.5.2 and 9.12.3; 4 controls pass; own fix 6 of 6. Found the MMapDirectory mapping effect.
cross-model review, refute lens (OpenAI GPT-5.6, read-only) not real, low (procedural)Could not run code and "found no substantive refutation"; agreed on the root cause, that the inputs are in contract and that the fix fixes it. A separate re-run of the finder's repro: fail as expected.
cross-model review, intended lens (OpenAI GPT-5.6, read-only) real, mediumIntended-behaviour argument weak.
separate test reproducedTrigger keyed on the final writer class in the stack: 4 run, 2 failed on main and 10.5.2; 2 controls pass; with the fix 4 of 4.
Intended-behaviour devil's advocate realNo intended, documented, misuse or unreachable argument survives. Random open-failure model: 28 of 300 sorted rounds leak, 0 of 300 unsorted, 0 of 300 with the fix; MMapDirectory mapping after Directory.close() and GC, none with the fix.
adjudication confirmed, highRe-ran the independent reproduction's JUnit with -ea (seed 7A11D6E5) and -da (seed 7A11D6E6): 6 run, 2 failed each, 4 controls pass; the independent reproduction's fix 6 of 6.
credibility audit confirmed4th independent re-run on main (-ea seed A4D1700F, -da seed A4D17010): fails as expected; the fix passes 3 of 3.
this doc (2026-11-01) reproducedGradle on 645d4fe14a20 with both fix options in a scratch worktree, and the 10.6.0 release jars (see Minimal repro and Suggested fix).

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

Finding record

LFV-440, snapshot of 2026-11-06

Registry title
With an index sort, a failure while creating the sorted stored-fields or term-vectors writer during flush leaves the reader on the temporary .fdt/.fdx (.tvd/.tvx) files open after IndexWriter closes itself
Area
index / flush-time index sorting (SortingStoredFieldsConsumer, SortingTermVectorsConsumer)
Priority
Low, rank 1098: With an index sort, a file-open failure during flush leaves two inputs on deleted temporary files open after IndexWriter closes, a bounded exception-path leak like LFV-446.
Impact class
minor
Impact note
Hits applications that set IndexWriterConfig.setIndexSort (opt-in in Lucene; by code reading, Elasticsearch's time_series and logsdb index modes sort by default) when creating or opening a file fails (too many open files, a permission, quota or inode limit, a read-only remount, or a throwing custom Directory or codec) while a flush constructs the final sorted stored-fields or term-vectors writer: IndexWriter closes itself as designed, but two inputs on the already-deleted temporary .fdt/.fdx (or .tvd/.tvx) files stay open, which on MMapDirectory (the 64-bit default) stay mapped after IndexWriter.close, Directory.close and System.gc() and so keep that one segment's uncompressed stored fields or term vectors on disk, and on NIOFSDirectory hold two file descriptors until garbage collection (released in one executed run); no data is lost beyond what the tragic close discards, the last commit stays intact, no scale is needed, and every release from 6.5.0 to 10.6.0 and main is affected.
Summary
You configure an index sort, and while a flush is creating the final sorted stored-fields (or term-vectors) files, creating or opening a file fails with an IOException, for example because the process has too many open files. You expect IndexWriter to close itself, as it does after any failed flush, and to release every file it opened. Instead the reader that the flush had just opened on the temporary .fdt/.fdx (or .tvd/.tvx) files is never closed, because it is opened one statement before the try whose finally closes it, so two inputs on already-deleted temporary files stay open (on MMapDirectory, mapped) after IndexWriter and the Directory are closed.
Introduced
4ccb9fbd2bbc3afd075aa4bc2b6118f845ea4726 (master, first released in 7.0.0) and its branch_6x backport 7d96f9f7981d (first released in 6.5.0), LUCENE-7579 = apache/lucene#8630 'Sorting on flushed segment' (contributed by jimczi, committed by mikemccand; no pull request, reviewed on JIRA), which created both consumers with this order: open the temporary reader, create the final writer, then enter the try whose finally closes both [read: git show, git log -L and git blame; the try and finally lines on main are still from 4ccb9fbd2bb, and the 9 later commits to the stored-fields flush and 7 to the term-vectors flush never moved the writer creation into the try; exec: 6.4.2 passes, 6.5.0 fails, and 6.5.0 with the one-statement fix shadowed in passes] (2016-12-20 (master commit 4ccb9fbd2bb; the branch_6x backport 7d96f9f7981 was committed 2017-01-17))
First affected version
6.5.0 [exec: 6.5.0 fails, 6.4.2 passes; no 6.4.x release contains the two consumer classes, read from the release trees]; first master-line release 7.0.0 [exec]
Affected versions
every release from 6.5.0 through 10.6.0, and main ea57360912ed. [exec] 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.6.3, 8.7.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main leak (-ea; also -da on 6.5.0, 10.5.2 and main); 6.4.2 does not leak (-ea and -da). [read] all 74 numeric release tags from 6.5.0 on in the history clone contain the change and none of the 77 older ones does (the clone has no 8.8.2-8.11.4 tags; 8.11.4 was executed). Trigger scope by version [exec]: 6.5.0 to 8.6.3 leak only on flushes that reorder documents (an already-sorted flush renamed the temporary files and opened no reader); from 8.7.0 (LUCENE-9510) every sorted flush with stored fields or term vectors is exposed. Not fixed upstream: upstream main cf398bfb142c and branch_10x still create the writer before the try [read, 2026-10-26]; the one-test minimal repro fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da (2026-11-01) [exec], and upstream main 2f424fd428da and branch_10_6 7e5aec4370e still create the writer before the try [read, 2026-11-01]
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: exhaustive check
Upstream and prior reports
none found (2026-10-26: 135 scripted prior-report search queries (53 GitHub issue/PR, 6 commit, 6 downstream-repo over Elasticsearch, OpenSearch and Solr, 50 mailing-list over dev@, java-user@, issues@, commits@ and builds@, 8 JIRA, 12 web) plus the finder's run of 50 queries, a changed-file scan of all 411 open apache/lucene PRs (#16269 draft, #12948 and #1003 touch the files and do not fix it), the comments of 23 related issues and the inline review comments of 8 PRs, and 13 CI failure mails containing 'unclosed IndexInput' (none names a sorting temporary file); 29 hits read, none the same bug); not fixed: upstream main cf398bfb142c and branch_10x still create the writer before the try. Context, not a report: in the LUCENE-7579 review (#8630, 2016) mikemccand asked whether an IOException during the then-existing rename step of SortingStoredFieldsConsumer.flush could leave undeleted files, and jimczi answered by adding abort(); the reader left open when the final writer cannot be created was never raised. Related, different: #9487 and #11637 (NullPointerException in SortingTermVectorsConsumer.abort), #16553 (stored-fields frame leak when PerField#finish throws), #16625 (rollback deletes files of pooled readers), #15068 (MMapDirectory arena pooling).
Lucene commit tested
main 645d4fe14a20 (the minimal repro through gradle with assertions on and off and standalone with -ea and -da; both fix options in a scratch worktree with the finder's 3-test class and the closest upstream suites); the SHA-1-checked 10.6.0 release jars (minimal repro, -ea and -da); the study's runs at ea57360912ed and its 24 release-level runs from 6.4.2 to 10.5.2. Upstream main 2f424fd428da (2026-10-30) and branch_11x 603af870375 have both files identical to 645d4fe14a20 (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/SortingStoredFieldsConsumer.java:109-139 (flush: temporary reader :111-113, final writer :116-117, try :118, finally IOUtils.close(reader, sortWriter) :136) and :142-151 (abort deletes the temporary files only)
  • lucene/core/src/java/org/apache/lucene/index/SortingTermVectorsConsumer.java:54-84 (flush: reader :62-64, writer :68-71, try :72, finally IOUtils.close(reader, writer) :80) and :97-104 (abort)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:585-602 (a flush exception is aborting: onAbortingException, maybeAbort, onTragicEvent); IndexWriter.java:1342 (close: 'Closes all open resources'); IndexingChain.java:529 (abort comment); store/FSDirectory.java:378 (8 KiB output chunk)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/SortingStoredFieldsConsumer.java:79-81, :95-100, :109-151 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/SortingTermVectorsConsumer.java:54-104 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:152-154, :345, :530-538 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:585-602 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/compressing/Lucene90CompressingStoredFieldsWriter.java:129-162; FieldsIndexWriter.java:83, :86 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/store/MMapDirectory.java:329, :402-429; store/RefCountedSharedArena.java:74 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:378-400; store/OutputStreamIndexOutput.java:48; index/SortingStoredFieldsConsumer.java:42-81, :129; index/StoredFieldsConsumer.java:55-57, :97-102; index/IndexingChain.java:344; store/MMapDirectory.java:124-139, :402-429; index/SortingTermVectorsConsumer.java:61 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165, :1319, :1342, :1374-1393, :6094, :6098-6105; index/IndexingChain.java:531; store/Directory.java:90-92 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/MockDirectoryWrapper.java:873-887; lucene/core/src/test/org/apache/lucene/index/TestIndexWriterOutOfFileDescriptors.java:88, :166 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/IOUtils.java:64-73 (at 645d4fe14a20)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the minimal 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.