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.
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.- Lucene writes them in arrival order to temporary files such as
_0.fdt__1.tmpand_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. - 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.
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.- Set an index sort on a LONG field, add 1 document with a stored field and call
commit(), while creating the segment's.fdmfile fails once with anIOException, as it would when the process has run out of file descriptors. - 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". - Instead 2 files stay open, the temporary
_0.fdt__1.tmpand_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 thetrywhosefinallywould close that reader. - 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 afterIndexWriter.close(),Directory.close()andSystem.gc(), so their disk space is not freed. - 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 thetryfixes it.
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.
- Index sort:
IndexWriterConfig#setIndexSort, an opt-in setting that keeps the documents of every segment in sort order. - Temporary files: while documents are buffered,
SortingStoredFieldsConsumerwrites their stored fields uncompressed to temporary files (_0.fdt__1.tmp,_0.fdx__4.tmp) through aTrackingTmpOutputDirectoryWrapper;SortingTermVectorsConsumerdoes 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.
- With an index sort the indexing chain uses the two sorting consumers (
IndexingChain.java:152,:154). - At flush (
IndexingChain.java:345),SortingStoredFieldsConsumer.flushopens the temporary reader (SortingStoredFieldsConsumer.java:111-113; it keeps 2 inputs open), then creates the final writer (:116-117), then enters thetry(:118) whosefinallyrunsIOUtils.close(reader, sortWriter)(:136) and deletes the temporary files (:137). - The final writer's constructor creates
.fdmand.fdt, two temporary outputs ofFieldsIndexWriter, and writes codec headers (Lucene90CompressingStoredFieldsWriter.java:129-162). All of that is inside its owntry: when any of it throws, the constructor closes its own outputs (:160) and rethrows; the statements before that block (such ascompressionMode.newCompressor(),:120) create no file. The exception leavesflushbefore thetry, so thefinallynever runs and thereaderlocal is dropped. DocumentsWriterPerThread.flushtreats 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.SortingTermVectorsConsumer.flushhas the same order: reader:62-64, writer:68-71,try:72,finally:80;abort:97-104.- On
MMapDirectorythe inputs are mapped in a shared arena (MMapDirectory.java:329,:402-429;RefCountedSharedArena.java:74) that only closing every input releases, so garbage collection andDirectory.close()leave the mapping in place.
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 flushStoredFieldsConsumer.finish,StoredFieldsConsumer.java:97-102, called atIndexingChain.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; thehasStoredFieldscheck atSortingStoredFieldsConsumer.java:129only 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
Directoryor codec that throws. - Not disk full, normally. On an
FSDirectorythe 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 thetry, 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/.fdxpair, 5 the.tvd/.tvxpair), 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 surviveIndexWriter.close(),Directory.close()andSystem.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. OnNIOFSDirectorythey are 2 file descriptors, released bySystem.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
MMapDirectorydoes not keep later segments' files mapped (its segments were_1to_3;MMapDirectorygroups mappings by segment name, so whether a later segment again named_0would stay mapped was not tested). Windows, where open files cannot be deleted, was not tested.
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.
/*
* 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();
}
}
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.tmpand_0.fdx__4.tmpstay open.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the test also fails on 10.6.0; the bug is in every release from 6.5.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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
# 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)close()is documented to close "all open resources", and the flush's ownfinallyalready closes the reader: only the writer's creation sits outside thetry.- 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-
trypattern in #14394 andd16e25518ba.
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 laterclose()does nothing (shutdownandshouldClose,:1319,:1374-1393). - The code's own intent. The
finallyatSortingStoredFieldsConsumer.java:136closes the reader and the writer together on every path through thetry; 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 thatSortingStoredFieldsConsumer.flushdoes" (a rename step of that time) could "leave undeleted files"; jimczi addedabort()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 offlush. 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);TestIndexWriterOutOfFileDescriptorsaccepts a tragic close (:88) and then callsdir.close()(:166). No upstream test combinessetIndexSortwith 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
tryinto it: #14394 (9b9fbd44339, 2025: twocreateTempOutputcalls, declarednulland assigned inside thetry) andd16e25518ba(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#createOutputdeclaresIOException(Directory.java:90-92), andMockDirectoryWrapper#setRandomIOExceptionRateOnOpenis 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.
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.
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 thetry); the order (reader, then writer) and thefinallystay as they are, so the success path is unchanged. It covers exactly the window: ifsuper.flushor the reader's constructor throws, nothing changes. IOUtils.closeignoresnullarguments (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 thetrybody, 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, seedDEADBEEF: 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 (
TestIndexSorting62,TestIndexWriterExceptions40,TestIndexWriterOnDiskFull4,TestIndexWriterOutOfFileDescriptors1,TestSortingCodecReader3,TestSortingStoredFieldsConsumer1,TestTrackingTmpOutputDirectoryWrapper2) give 9 classes, 117 tests, 0 failures, 3 skipped (the three@Nightlytests ofTestIndexWriterExceptions). - 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
checkGoogleJavaFormatfails on it alone (the repro test itself is clean);applyGoogleJavaFormatwraps 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
MMapDirectoryprobe (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.
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 fileTrade-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
finallyreplaces it); and when opening the reader itself fails, thefinallynow deletes the temporary files (today onlyabort()does). - Larger diff: the declarations move into the
tryheader, and the long writer expression is re-wrapped.
What the checks validated
- Gradle on
645d4fe14a20, seedDEADBEEF, 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;checkGoogleJavaFormatpasses.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. the migrated JIRA issues) and commits | 2026-10-26, class-name issue search refreshed 2026-11-01 | a 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 hand | no report; #8630 context; #9487, #11637, #16553, #16625, #15068 related, different |
| Open PRs | 2026-10-26 | changed 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 archive | 2026-10-26 | 50 list and 8 JIRA queries; 13 CI failure mails containing "unclosed IndexInput" read one by one | nothing about this leak; no CI mail names a sorting temporary file |
| Downstream repositories (Elasticsearch, OpenSearch, Solr) and the web | 2026-10-26 | 6 downstream and 12 web queries | both class names: 0 downstream items; no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 6.4.2 | not affected | no leak: the two consumers do not exist yet; random model 0 of 300 rounds |
| 6.5.0 | affected | port 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 check | writer creation moved into the try: no leak, random model 0 of 300 |
| 6.6.6, 7.0.0, 7.7.3 | affected | port leaks; random model 74, 62 and 63 of 300 |
| 8.6.3 | affected | port leaks the .fdt/.fdx and .tvd/.tvx pairs; reordering flushes only; random 41 of 300 |
| 8.7.0 | affected | already-sorted flushes leak too (LUCENE-9510); random 78 of 300 |
| 8.11.4, 9.0.0 | affected | port leaks; random 55 and 64 of 300 |
| 9.12.3, 10.0.0 | affected | port leaks (random 60 of 300 each); finder's JUnit 3 of 3 fail |
| 10.5.2 | affected | port and finder's JUnit fail with assertions on and off; random 43 of 300 |
| 10.6.0 | affected | minimal repro fails with -ea and -da (release jars) |
main ea57360912ed | affected | finder'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 | affected | minimal repro fails in gradle and standalone, assertions on and off; passes with fix A or B |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Exception-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 | real | Own 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, medium | Intended-behaviour argument weak. |
| separate test | reproduced | Trigger 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 | real | No 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, high | Re-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 | confirmed | 4th 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) | reproduced | Gradle on 645d4fe14a20 with both fix options in a scratch worktree, and the 10.6.0 release jars (see Minimal repro and Suggested fix). |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 504 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,196 words
- 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-eaand-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,-eaand-da); the study's runs atea57360912edand its 24 release-level runs from 6.4.2 to 10.5.2. Upstream main2f424fd428da(2026-10-30) andbranch_11x603af870375have both files identical to645d4fe14a20(read).
- 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)
- TestSortedFlushTempReaderLeak.java (JUnit, one test):
repro/LFV-440/src/org/apache/lucene/index/TestSortedFlushTempReaderLeak.java(62 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's input and output.
assets/LFV-440/bug.png(504 KB)
