All findings · Lucene finding · LFV-1067 · nrt-open-leaf-sorter-failure-keeps-deleter-references

A failed NRT reader open whose leaf sorter throws never releases its file reference, so the open writer keeps that point in time's segment files on disk

If the IndexWriterConfig leaf sorter throws while DirectoryReader.open(writer) builds an NRT reader, the failed open never gives back the file reference it took on the writer. The still-open writer then never deletes those files: after forceMerge(1) and commit(), all 6 files of the two merged-away segments stay in the directory.

Summary

Priority
Low, rank 1094In every release since 8.9.0, when an opt-in leaf sorter throws during an NRT open, the still-open writer keeps every file of that point in time on disk. The open fails loudly and the leak only grows if the application keeps the writer and retries, so it is Low.
Impact
minor
Since
8.9.0 (LUCENE-9507: 48715fe898bf on main, 2021-03-26; in 8.9.0 through the branch_8x backport 1cfd0df4e7bb)introduced 2021-03-26 · about 5 years
Affected
8.9.0-10.6.0 and main; only writers with a leaf sorter
Status
documented
Found by
semi-formal: noticed while modelling reader reference counting in TLA+, then a TLC model of the NRT open and a public-API 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 open takes a file reference on the writer before it builds the reader, and only that reader's close() gives it back. When the leaf sorter throws inside the constructor, there is no reader to close.

Background: NRT readers, file references and leaf sorters

  1. An NRT reader opened from an IndexWriter (DirectoryReader.open(writer), openIfChanged, SearcherManager) sees the writer's current segments, committed or not.
  2. While such a reader is open the writer must not delete its segments' files. So the open adds one reference per file in the writer's file bookkeeping, and closing the reader takes them back. A file is deleted only when no commit and no reader references it.
  3. IndexWriterConfig#setLeafSorter (since 8.9.0) adds a Comparator<LeafReader> that orders the segments inside every NRT reader, for example newest first for time-based data. It runs inside the reader's constructor.
Diagram of the bug: an IndexWriter with a leaf sorter has two segments, _0 and _1. DirectoryReader.open(writer) first adds a file reference to every file of _0 and _1, then builds the reader; the reader's constructor runs the leaf sorter, which throws. The cleanup releases the segment readers but not the file reference, and no reader is returned to close. After forceMerge(1) and commit, _2 replaces _0 and _1, but the six files of _0 and _1 stay on disk while the writer stays open. The fix releases the file reference in the same cleanup.
One NRT open: it takes a file reference on segments _0 and _1, the leaf sorter throws inside the reader's constructor, the cleanup gives back the segment readers but not the file reference, and after forceMerge(1) and commit() the files of _0 and _1 stay on disk while the writer lives. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Give IndexWriterConfig a leaf sorter, a Comparator<LeafReader> that orders the segments of every NRT reader, and commit two one-document segments, _0 and _1.
  2. Call DirectoryReader.open(writer). The open first tells the writer "keep every file of _0 and _1, a reader uses them", then builds the reader, whose constructor runs the sorter. Here it throws, as a sorter bug on a segment without the sort field, or an I/O error it rethrows, would.
  3. You expect only that exception. No reader exists, so once forceMerge(1) merges _0 and _1 into _2 and commit() runs, the writer deletes their files. Without the failed open, only _2's 3 files and segments_3 remain.
  4. Instead all 6 files of _0 and _1 stay. The open's cleanup gave back the segment readers but not the file reference, and the writer, which survives the exception, holds it until it closes.
  5. Every failed retry pins the current files again: 19 failed SearcherManager refreshes with merges in between left 118 files (524,782 bytes) in the directory for an index of 4 files (41,211 bytes). A new IndexWriter on the directory deletes them.

Who is affected

NRT applications that set the opt-in IndexWriterConfig#setLeafSorter (since 8.9.0) and refresh through DirectoryReader.open(writer), openIfChanged or a SearcherManager/ReaderManager on the writer. It takes a sorter that throws during a refresh of 2+ segments. Each failure keeps that point in time's segment files, up to the whole index, while the writer stays open; results stay correct.

Mechanism, terms and numbers775 words

IndexWriterConfig#setLeafSorter (LUCENE-9507, since 8.9.0) gives the writer a Comparator<LeafReader> that orders the segments of every NRT reader opened from it. The NRT open StandardDirectoryReader.open(IndexWriter, ...), behind DirectoryReader.open(writer), openIfChanged(reader[, writer]) and SearcherManager/ReaderManager refreshes over a writer, first calls writer.incRefDeleter(segmentInfos), which makes the writer keep every segment file (not segments_N) of the point in time it is about to expose, and then constructs the reader. That constructor sorts the segment readers with the leaf sorter. If the sorter throws, the open's catch (Throwable) gives back only the segment readers; the file reference is released only by the returned reader's close(), and no reader is returned. IndexWriter.getReader treats only an Error as tragic, so the writer stays open and usable while it keeps those files referenced for the rest of its life. With two one-document segments, after the failed open, forceMerge(1) and commit(), the six files of _0 and _1 are still in the directory; 19 failed refreshes with merges in between left 118 files / 524,782 bytes in the directory for an index of 4 files / 41,211 bytes. No wrong results, no index damage, and the next IndexWriter opened on the directory deletes the files. A 9-line guarded decRefDeleter in the same catch fixes it. Every release from 8.9.0 to 10.6.0 and main is affected (8 of those releases and main executed, the rest by reading).

Terms

  • NRT open: StandardDirectoryReader.open(IndexWriter, IOFunction, SegmentInfos, boolean, boolean) (StandardDirectoryReader.java:110-166 at 645d4fe14a20), called by IndexWriter.getReader (IndexWriter.java:613-615) under the writer's lock. DirectoryReader.open(writer) (DirectoryReader.java:122, :144), openIfChanged(reader) on an NRT reader (through doOpenFromWriter, getReader at StandardDirectoryReader.java:466), openIfChanged(reader, writer) (DirectoryReader.java:340, :403-408, then doOpenIfChanged(IndexWriter, boolean) at StandardDirectoryReader.java:434-442: doOpenFromWriter at :438 for the reader's own writer, getReader at :440 for another; :452 is the same call in the ExecutorService overload), and SearcherManager/ReaderManager refreshes over a writer all end there.
  • Deleter reference: IndexWriter.incRefDeleter(segmentInfos) (IndexWriter.java:6306-6317) adds one reference in the writer's IndexFileDeleter to every file of the segments in a point in time, not to segments_N (it calls deleter.incRef(segmentInfos, false), IndexFileDeleter.java:603-609); decRefDeleter (:6319-6336) takes it back. IndexFileDeleter deletes a file when its count reaches zero (IndexFileDeleter.java:47-50).
  • Leaf sorter: the Comparator<LeafReader> from IndexWriterConfig#setLeafSorter (IndexWriterConfig.java:517-527), null by default.

The bug

  1. The NRT open gets one segment reader per segment (StandardDirectoryReader.java:136), keeps it or drops a fully deleted one (:137-145), and then calls writer.incRefDeleter(segmentInfos) (:148).
  2. It returns new StandardDirectoryReader(dir, readers.toArray(...), writer, segmentInfos, writer.getConfig().getLeafSorter(), ...) (:150-157). The BaseCompositeReader constructor runs Arrays.sort(subReaders, subReadersSorter) (BaseCompositeReader.java:74-77): the application's comparator.
  3. The comparator throws. The catch (StandardDirectoryReader.java:158-164) runs IOUtils.applyToAll(readers, SegmentReader::decRef) and rethrows. The only decRefDeleter for this reference is in the reader's doClose (:548-568), and no reader was returned.
  4. IndexWriter.getReader's catch (IndexWriter.java:711-721) calls onTragicEvent only for an Error, so after the sorter's RuntimeException the writer stays open and usable, with a count on every segment file of that point in time that nothing will take back.
  5. Later merges and commits drop their own references, but these files never reach zero while the writer lives. They are still there after IndexWriter.close(); the next IndexWriter opened on the directory deletes them as unreferenced.

Without a leaf sorter no application code runs in that constructor; its only other explicit throw is the "Too many documents" check (BaseCompositeReader.java:89-98), which IndexWriter prevents. So before 8.9.0 no realistic input reached this gap (read, not executed); the one contrived way, then and now, is a caller-supplied InfoStream (trusted configuration) whose message throws inside incRefDeleter after deleter.incRef has run (IndexWriter.java:6306-6317), which the fix below does not cover either. By reading only: IndexWriter's merge-on-refresh second open (maybeReopenMergedNRTReader, IndexWriter.java:755, :766) calls the same method and would leak the same way.

The numbers

runfiles in the directory after the failed open, forceMerge(1) and commit()
no failed open (control)_2.cfe, _2.cfs, _2.si, segments_3
failed open_0.cfe, _0.cfs, _0.si, _1.cfe, _1.cfs, _1.si, _2.cfe, _2.cfs, _2.si, segments_3

Repeated: a SearcherManager over the writer, 20 rounds of 200 documents, commit, a refresh whose sorter throws (19 failures; round 0 has one segment, so the sorter is not called), forceMerge(1) and commit. After round 19: 118 files / 524,782 bytes in the directory (a heap ByteBuffersDirectory; by reading, any Directory keeps them the same way), the same after the writer closes, and 4 files / 41,211 bytes once a new IndexWriter has opened and closed on the directory. The control without failures never has more than 10 files.

Exposure and likelihood350 words
  • Who is hit. NRT applications with IndexWriterConfig#setLeafSorter (for example a newest-first sorter for time-based indices) that refresh through DirectoryReader.open(writer), openIfChanged(reader[, writer]), or a SearcherManager/ReaderManager built on the writer (SearcherManager.java:66, ReaderManager.java:41; refreshIfNeeded at SearcherManager.java:194-205). Executed: DirectoryReader.open(writer) and SearcherManager.maybeRefresh().
  • Default vs opt-in. Opt-in: the leaf sorter is null by default (LiveIndexWriterConfig.java:422-431), and Lucene ships none.
  • What triggers it. The sorter must throw during an NRT open of two or more segments (with one segment the sort makes no comparison): a comparator bug on a segment that lacks the sort field (Lucene's own test sorter handles a null getPointValues, TestIndexWriterReader.java:1212-1222), or an I/O error inside a correct sorter, which Comparator.compare forces it to rethrow unchecked. Elasticsearch's time-series leaf sorter does exactly that (read through the GitHub API by a verifier).
  • How much. One failure pins every segment file of that point in time for the writer's lifetime: after merges, up to the index size at the time of the failure. Each further failure pins the then-current segment files again, which adds up when new segments or merges are written between the attempts (118 files / 524,782 bytes against a 41,211-byte index after 19 failures). Any index size.
  • When it builds up. Only if the application keeps the writer open and retries. A product that closes the writer on a refresh failure does not accumulate (a verifier cites Elasticsearch's failEngine("refresh failed ...")), and ControlledRealTimeReopenThread ends at the first failed refresh (ControlledRealTimeReopenThread.java:212-216 catches only IOException and rethrows it unchecked). A service that catches the exception and retries maybeRefresh() on its own schedule accumulates.
  • What does not happen. No wrong or resurrected results (no reader or commit refers to these files), no index modification, no hang. The refresh fails loudly with the sorter's exception, and the next IndexWriter on the directory deletes the files.
  • Related findings. LFV-1059: the same trigger on the non-NRT reopen factory, which leaks segment reader references; LFV-626: the keep check in this same method, before incRefDeleter, which leaks one segment reader. Each is an independent fix.

Minimal repro

One JUnit test on the public API: a plain ByteBuffersDirectory, the default config plus a leaf sorter that always throws, and two one-document commits. It expects the exception from DirectoryReader.open(w), runs forceMerge(1) and commit(), and checks that no _0 or _1 file is left. Deterministic, one test thread, about a second.

TestNrtOpenLeafSorterFailureReleasesFiles.java (one test, public API)49 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.util.Comparator;
import org.apache.lucene.document.Document;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNrtOpenLeafSorterFailureReleasesFiles extends LuceneTestCase {

  // Expected: after a failed NRT open, merge and commit delete _0/_1; actual: their files stay.
  public void testFailedNrtOpenDoesNotKeepFilesOfMergedAwaySegments() throws Exception {
    Comparator<LeafReader> leafSorter =
        (a, b) -> {
          throw new IllegalStateException("leaf sorter failed");
        };
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig().setLeafSorter(leafSorter))) {
      w.addDocument(new Document());
      w.commit();
      w.addDocument(new Document());
      w.commit();
      expectThrows(IllegalStateException.class, () -> DirectoryReader.open(w));
      w.forceMerge(1);
      w.commit();
      for (String file : dir.listAll()) {
        assertFalse(
            "file of a merged-away segment was not deleted: " + file,
            file.startsWith("_0") || file.startsWith("_1"));
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestNrtOpenLeafSorterFailureReleasesFiles.java lucene/core/src/test/org/apache/lucene/index/
./gradlew :lucene:core:test --tests org.apache.lucene.index.TestNrtOpenLeafSorterFailureReleasesFiles -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure); another seed (FC1067) fails the same way
Expected
After the failed open, forceMerge(1) and commit(), no file of _0 or _1 is left; only _2's files and segments_3 remain.
Observed
AssertionError: file of a merged-away segment was not deleted: _0.cfe; all 6 files of _0 and _1 remain, assertions on and off, on main and 10.6.0.

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

More files, environment, full output233 words

The smallest input: two segments are the fewest that make the sorter run, a sorter that always throws needs no field or data, and forceMerge(1) plus commit() is the shortest way to make _0 and _1 unreferenced. The plain ByteBuffersDirectory keeps the oracle to "which files are left". The expectThrows passes in every run, so the failing open really happened. The same test is the regression test. Assertions on and off give the same failure, and so does another seed (FC1067, run by the fact check). All runs were on Linux aarch64 with OpenJDK 25.0.3; the bug is plain control flow.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock 645d4fe14a20, seed DEADBEEF, assertions on
TestNrtOpenLeafSorterFailureReleasesFiles > testFailedNrtOpenDoesNotKeepFilesOfMergedAwaySegments FAILED
    java.lang.AssertionError: file of a merged-away segment was not deleted: _0.cfe
        at org.apache.lucene.index.TestNrtOpenLeafSorterFailureReleasesFiles.testFailedNrtOpenDoesNotKeepFilesOfMergedAwaySegments(TestNrtOpenLeafSorterFailureReleasesFiles.java:43)

# assertions off (-Ptests.asserts=false): the same failure
# 10.6.0 release jars, assertions on and off: the same assertion

# with the fix, assertions on and off
classes=1 tests=1 failures=0 errors=0 skipped=0

# closest suites (TestIndexWriterReader, TestDirectoryReader, TestDirectoryReaderReopen, TestIndexFileDeleter, TestNRTReaderWithThreads, TestSearcherManager), seed DEADBEEF
stock:    classes=6 tests=112 failures=0 errors=0 skipped=1
with fix: classes=6 tests=112 failures=0 errors=0 skipped=1

# an independent verifier's test, oracle 'every file belongs to the latest commit', assertions off:
# files neither in the latest commit nor held by a reader expected:<[]> but was:<[_0.cfe, _0.cfs, _0.si, _1.cfe, _1.cfs, _1.si]>

Why it looks unintended

  • decRefDeleter: "Only call this if you are sure you previously called incRefDeleter". Only the open knows it did, and it returns no reader.
  • The same catch (Throwable) already gives back the open's segment readers (LUCENE-5262, LUCENE-8215).
  • The writer stays usable after the exception, so the reference is kept for the writer's whole life.
The full argument274 words
  • The pair contract. IndexWriter.decRefDeleter (IndexWriter.java:6319-6324): "Record that the files referenced by this {@link SegmentInfos} are no longer in use. Only call this if you are sure you previously called {@link #incRefDeleter}."
  • The same catch. LUCENE-5262 (#6326, 2013, "StandardDirectoryReader should decRef readers on exception, not close them") added the cleanup that gives the segment readers back when the open fails (LUCENE-8215, #9262, 2018, rewrote it as today's catch (Throwable)); the deleter reference added in 2014 (LUCENE-5434, #6497) was never added to it.
  • The writer is meant to stay usable. getReader's catch rethrows a non-Error without a tragic event (IndexWriter.java:711-721; the inner catch says "Uh oh, tidy up then rethrow", :669-676). The class javadoc reserves self-closing for an Error or a failed checkpoint, when "reference counts" may be corrupted (:162-165).
  • Tests. TestIndexWriterReader.testNRTOpenExceptions (:1108-1153): "test that several failed attempts to obtain an NRT reader don't leak file handles"; its injected failure comes before incRefDeleter, so it never reaches this gap. No test at 645d4fe14a20 makes a leaf sorter throw: the only test that sets one, testIndexReaderWriterWithLeafSorter (:1204-1222), does not throw.
  • No precondition on the sorter. setLeafSorter (IndexWriterConfig.java:517-527) and getLeafSorter (LiveIndexWriterConfig.java:422-431) say nothing about exceptions, and Comparator.compare may throw.
  • History. The missing release existed since 2014, but no realistic input reached it: before LUCENE-9507 (48715fe898bf, 2021) the constructor could throw only "Too many documents", which IndexWriter prevents (a throwing caller-supplied InfoStream inside incRefDeleter is the only, contrived, exception). LUCENE-9507 put the application's comparator inside it; none of the review comments on #32, apache/lucene-solr#2256 or #2473 discuss exceptions.
The devil's-advocate check248 words

The strongest case for "intended" (the devil's-advocate verifier and the intended-lens reviewer): Lucene tolerates leftover files, since the doClose comment calls them "harmless" because "the next time IW is opened on the index, it will delete them" (StandardDirectoryReader.java:554-559), LUCENE-5574 (#6636) turned the test framework's unreferenced-files check off by default, and on #12228 rmuir wrote that a new IndexWriter "will delete the unnecessary files" and that IndexWriter should not "try to be a superhero and do dangerous things such as deleting files when handling exceptions"; the sorter is application code and the repro's sorter always throws; DirectoryReader.open(writer) is @lucene.experimental and incRefDeleter/decRefDeleter are @lucene.internal.

Why it fails: the tolerance covers a writer that is already closed or a tragic event, while here the writer stays open after a non-Error exception and keeps the files for its whole life; the fix only gives the count back: in the repro it deletes nothing while handling the exception (the directory listing is unchanged right after the failed open), and by reading the writer itself still references those files at that moment, so it does not do what that comment warns about. An always-throwing comparator is within Comparator's contract, no javadoc asks for a non-throwing sorter, and a correct sorter doing I/O reaches the same path. Experimental is about API stability, not a licence to leak, and the user path is public. Every reviewer rated the intended case weak; it explains the Low priority, not the leak.

Suggested fix

Recommended Release the deleter reference in the NRT open's catch, guarded by a flag

In the NRT open's catch (Throwable), call writer.decRefDeleter(segmentInfos) (adding its exception as suppressed) when a flag set right after incRefDeleter says the reference was taken. The flag matters: the catch also runs for failures before incRefDeleter. Validated through gradle: the repro passes with assertions on and off, and the 112 tests of the closest suites pass.

+    boolean deleterIncRefd = false; // declared before the try
       writer.incRefDeleter(segmentInfos);
+      deleterIncRefd = true;
       return new StandardDirectoryReader(...); // runs the leaf sorter
     } catch (Throwable t) {
       // ... decRef the segment readers (unchanged) ...
+      if (deleterIncRefd) {
+        try {
+          writer.decRefDeleter(segmentInfos);
+        } catch (Throwable t1) {
+          t.addSuppressed(t1);
+        }
+      }
       throw t;
All options and trade-offs4 options ยท 686 words

The hunt's fix, unchanged, run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF) with the minimal repro and the closest suites. A broader patch that also covers the sibling leaf-sorter leaks was validated by the hunt.

Release the deleter reference in the NRT open's catch, guarded by a flag (validated by the checks) (recommended)

In StandardDirectoryReader.open(IndexWriter, ...) (StandardDirectoryReader.java:126-165), declare boolean deleterIncRefd = false before the try and set it to true right after writer.incRefDeleter(segmentInfos) (:148); in the catch, after the segment readers are given back, call writer.decRefDeleter(segmentInfos) when the flag is set and add any exception from it as suppressed. +9 lines.

+    boolean deleterIncRefd = false; // declared before the try
       writer.incRefDeleter(segmentInfos);
+      deleterIncRefd = true;
       return new StandardDirectoryReader(...); // runs the leaf sorter
     } catch (Throwable t) {
       // ... decRef the segment readers (unchanged) ...
+      if (deleterIncRefd) {
+        try {
+          writer.decRefDeleter(segmentInfos);
+        } catch (Throwable t1) {
+          t.addSuppressed(t1);
+        }
+      }
       throw t;

Trade-offs

  • Why the flag. The catch also runs when readerFunction.apply or the keep check fails, before incRefDeleter. An unconditional decRefDeleter would then release a reference this open never took, one that belongs to a commit point or another reader: in the TLA+ model the unguarded variant violates NoOverRelease for every failure before incRefDeleter.
  • No deletion during exception handling. It only gives the count back. The open runs under the writer's lock on a clone of the writer's current segments, which the writer itself still references at that moment (by reading; with the fix, the fact check's probe saw the directory listing unchanged right after the failed open), so the files go only at the next merge or commit that drops them.
  • Writer closed meanwhile. decRefDeleter calls ensureOpen(); its AlreadyClosedException would be added as suppressed and the sorter's exception rethrown.
  • Not covered. It does not make incRefDeleter itself transactional: if incRefDeleter throws after deleter.incRef has run (only a throwing caller-supplied InfoStream can do that; trusted configuration, contrived), the flag is still false and those references stay, as they do today.
  • Scope. No change on success. Fixes this leg only; the reopen-factory leak (LFV-1059) and the keep-check leak (LFV-626) are other lines.

What the checks validated

Through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-02): the minimal test fails on stock and passes with the fix, assertions on and off. The closest suites (TestIndexWriterReader, which holds testNRTOpenExceptions and testIndexReaderWriterWithLeafSorter, TestDirectoryReader, TestDirectoryReaderReopen, TestIndexFileDeleter, TestNRTReaderWithThreads, TestSearcherManager: 6 classes, 112 tests) pass on stock and with the fix (1 skipped both times). Earlier, the hunt's shadow checks, an independent verifier's own guarded fix and the refute reviewer's own test plus testIndexReaderWriterWithLeafSorter passed with the same change.

One exception-safety patch for the three leaf-sorter leaks of StandardDirectoryReader (validated by the checks)

The guarded release above, plus the StandardDirectoryReader constructor sorts a copy (super(directory, readers.clone(), leafSorter), so the initial open's cleanup still sees every reader once), plus LFV-1059's try/catch around the reopen factory's constructor call.

Trade-offs

One patch closes the family, but it is three changes needing three tests; the copy costs one array copy per open.

What the checks validated

By the hunt, not re-run here: it passes this test, LFV-1059's minimal test, the initial-open late-sort test and 99 upstream tests (TestDirectoryReader, TestDirectoryReaderReopen, TestIndexWriterReader, TestIndexFileDeleter, TestNRTReaderWithThreads; standalone JUnit runner, seed 5EED0018).

Call decRefDeleter unconditionally in the catch (not recommended)

The same call without the flag.

Trade-offs

Over-releases when the failure comes before incRefDeleter (a failing readerFunction or keep check): it would take back a reference owned by a commit point or another reader. The TLA+ model shows the violation.

Take the deleter reference only after the reader is built (considered, not run)

Move incRefDeleter after the constructor.

Trade-offs

The returned reader's doClose always calls decRefDeleter, so a failing incRefDeleter (for example ensureOpen on a writer closed meanwhile) would need its own cleanup that closes the reader without that call: more invasive than the guarded release for the same effect.

Downstream note

Services that keep the writer open and retry a failing refresh on a schedule pin the then-current segment files on each attempt, so the retained files grow while new segments or merges are written between attempts, until the writer closes and a new writer cleans up; a service that closes its writer when a refresh fails does not accumulate.

How it was found

Semi-formal. While mapping a TLA+ model of reader reference counting to the NRT open, an AI tool saw in the code that the file reference is never released when the constructor throws, and confirmed it with a JUnit test. A second TLA+ model, checked with TLC, reproduced it as an invariant violation and separated it from a sibling leak.

The method in full171 words

Method: semi-formal, a hypothesis formed while modelling, then witnessed by an executed test.

  • A hunt unit (2026-10-26), building a TLA+ model of IndexReader reference counting (ReaderLifecycle.tla), noticed this leg by code reading while mapping the model to StandardDirectoryReader.open(IndexWriter, ...) and witnessed it with a public-API JUnit (testNrtOpenLeaksFileReferences), the second manifestation of its leaf-sorter finding. LFV-1059 covers that finding's reopen leg and leaves this leg out of its claim; a later probe re-ran it.
  • The adjudication (2026-11-01) wrote a TLA+ model of the NRT open's acquire/release protocol (NrtOpen.tla) with a fault at five selected steps (each of two readerFunction.apply calls, the second segment's keep check, incRefDeleter failing before its increment, and the constructor; not every statement that can throw) and checked 35 TLC configurations: only the constructor fault violates NoDeleterLeak (10 distinct states), only the keep-check fault violates NoSegmentReaderLeak (that is LFV-626), each fix alone leaves the other violation, and an unguarded decRefDeleter violates NoOverRelease. It then reduced the witness to the one-test repro.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, open PRs' changed files, the JIRA archive, four mailing lists, downstream projects and the web (2026-11-01), re-checked on GitHub the same night. LUCENE-5434, LUCENE-5262 and LUCENE-9507 are the origins, not reports.

Searches3 searches

Context only: #6497 (LUCENE-5434, added the reference before the constructor), #6326 (LUCENE-5262) and #9262 (LUCENE-8215) (the catch that releases only segment readers), #10546 / #32 (LUCENE-9507, the leaf sorter; no review comment on exceptions), #5772 (LUCENE-4707, open: NRT files deleted too early, the opposite failure), #6636 (LUCENE-5574, leftover files after the writer closed first, called harmless), #12228 (unreferenced files after a failed merge), #16625 (premature deletion on rollback).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA archive; the web2026-11-01three scripted prior-report searches (153 queries: code site, leaf-sorter trigger, refresh and disk symptoms) and 25 manual GitHub searches; full comments and reviews of the origin itemsno report; context #6497, #6326, #9262, #10546, #32, #5772, #6636, #12228, #16625
Open and recent apache/lucene PRs and issues2026-11-01changed files of all 409 open PRs, the 12 PRs updated since 2026-10-30, the 6 issues created since 2026-10-2922 open PRs touch related files; their hunks change other methods, none the NRT open's catch
apache/lucene issues, PRs and commits2026-11-018 searches (leafSorter, leaf sorter, incRefDeleter, decRefDeleter, NRT open exception symptoms, items updated since 2026-10-31); latest commits to the fix-site filesno report; fix-site files unchanged on main

History

Introduced by LUCENE-9507 (48715fe898bf, 2021-03-26; branch_8x backport 1cfd0df4e7bb), which put the leaf sorter inside a constructor that runs after the file reference is taken; the unreleased reference dates from 2014, but no realistic input reached it before. First released in 8.9.0; 8 releases executed from 8.9.0 to 10.6.0 fail. Not fixed upstream.

Versions tested6 rows

Introduced by 48715fe898bf, LUCENE-9507 "Custom order for leaves in IndexReader and IndexWriter (#32)" (#10546; author date 2021-03-26), backported to branch_8x as 1cfd0df4e7bb (apache/lucene-solr#2473, 2021-03-30). It passed the writer's leaf sorter into the StandardDirectoryReader constructor that the NRT open calls right after incRefDeleter, and added the Arrays.sort to BaseCompositeReader.

Older pieces, not reached by realistic input until then: the incRefDeleter before the constructor and its release in doClose (65274f5965b, LUCENE-5434, 2014-02-06), and the catch that releases only the segment readers (4312f3d71ab, LUCENE-5262, 2013; rewritten by 2e35ef2b3da, LUCENE-8215, 2018). Since 48715fe898bf the method had only cosmetic changes (db8fcb84bbc, 448c7d8e241), so the releases between the executed ones are covered by reading. Upstream main f815e679bf6 (2026-11-01) and branch_10_6 have no commit to StandardDirectoryReader.java after 2026-02-11.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle and the standalone JUnit runner, assertions on and off
10.6.0 affectedthe test fails on the release jars, assertions on and off
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe test fails on the release jars
9.0.0, 8.11.4, 8.9.0 affectedthe test with the pre-9.1 test-framework import fails; 8.9.0 and 9.0.0 also a core-only port (its 8.9.0 control without the failing open passes)
8.8.2 not affectedIndexWriterConfig#setLeafSorter does not exist (compile error)
main 645d4fe14a20 + fix fixthe minimal test passes through gradle and the standalone JUnit runner, assertions on and off

Independent checks

Confirmed with high confidence by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and two executed reviews by another model family (OpenAI GPT-5.6), none dissenting; a credibility audit agreed; re-run through gradle for this doc, with the fix validated.

Checks and verdicts352 words

Each checker that executed wrote or re-ran a test from the claim; the adjudication re-ran the finder's test with assertions on and the independent reproducer's test with assertions off.

Each independent check and its verdict
CheckVerdictWhat it found
finder (TLA+ model and JUnit) reproduced35 TLC configurations: only the constructor fault violates NoDeleterLeak; the guarded fix passes. Its JUnit fails on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3 and passes with the fix.
refute review (OpenAI GPT-5.6) real, highExecuted. Its own test keeps all nine pre-merge files of three segments after a failed open, merge, commit and writer close (assertions on and off); its successful-open control keeps none; the fix passes it and testIndexReaderWriterWithLeafSorter. Ran on main ea57360912ed (fix-site files identical).
intended review (OpenAI GPT-5.6) real, highExecuted. Its own test shows the six files kept while the writer is open and after it closes, gone only when another writer opens; intended case weak.
Independent reproducer realIts own test with a stricter oracle fails on main (assertions on and off), 10.6.0, 10.5.2 and 9.12.3; its non-throwing control passes; its own guarded fix makes it pass.
Intended-behaviour devil's advocate real, LowBuilt the case for "intended" (leftover files are harmless, application comparator, experimental API) and showed why it fails; re-ran the finder's test (fails, assertions on and off).
adjudication confirmed, highRe-ran the finder's test (assertions on) and the independent reproduction's test (assertions off): both fail; the finder's test passes with the fix. Rated minor, Low.
credibility audit confirmed (Low)The audit confirmed it after reading the code and the related findings: a real, minor exception-safety bug.
fact check (another seed) realRe-ran the minimal test with seed FC1067: fails with assertions on and off, passes with the fix shadowed, both ways. Its own probe matched every file list and impact number in this doc.
this doc real, fix validatedThe minimal test fails through gradle on stock main with assertions on and off; the fix passes it and the 112 tests of the closest suites.

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

Finding record

LFV-1067, snapshot of 2026-11-06

Registry title
A failed NRT reader open (DirectoryReader.open(IndexWriter), openIfChanged over the writer, or a SearcherManager/ReaderManager refresh) whose IndexWriterConfig leaf sorter throws never releases the IndexFileDeleter reference it took, so the still-open writer keeps every segment file of that point in time on disk for the rest of its life
Area
core / NRT reader open from IndexWriter (StandardDirectoryReader.open(IndexWriter, ...) failure path, with an IndexWriterConfig leaf sorter, LUCENE-9507)
Priority
Low, rank 1094: In every release since 8.9.0, when an opt-in leaf sorter throws during an NRT open, the still-open writer keeps every file of that point in time on disk. The open fails loudly and the leak only grows if the application keeps the writer and retries, so it is Low.
Impact class
minor
Impact note
NRT applications that set the opt-in IndexWriterConfig#setLeafSorter (since 8.9.0) and refresh through DirectoryReader.open(writer), openIfChanged(reader, writer) or SearcherManager/ReaderManager over the writer: when the sorter throws during an NRT open of two or more segments (a comparator bug on a segment that lacks the sort field, or an I/O error that a sorter reading per-segment data rethrows unchecked), the refresh fails loudly but the writer stays open and keeps a file reference on every segment of that point in time, so merges and commits no longer delete those files until the writer closes and a new IndexWriter opens on the directory (executed: the 6 files of two merged-away segments are left after forceMerge(1) and commit; 19 failed refreshes with indexing and merges in between left 118 files / 524,782 bytes on disk for an index of 4 files / 41,211 bytes, against at most 10 files without the failures); the leak builds up only if the application keeps the writer open and retries; no wrong results, no index damage, any index size.
Summary
You set a leaf sorter on IndexWriterConfig and open an NRT reader from the writer; if the sorter throws while the new reader sorts its leaves, you expect only that exception and a writer left as it was, because the open's own catch releases the segment readers it took and IndexWriter.getReader keeps the writer usable after a non-Error exception. Instead the open has already called writer.incRefDeleter(segmentInfos), which only the returned reader's close undoes, and no reader is returned, so the writer keeps every segment file of that point in time referenced for the rest of its life: after forceMerge(1) and commit, the files of the merged-away segments _0 and _1 are still in the directory.
Introduced
48715fe898bf0a900e3e6b681882ecb1b6d8e1b1 (main, 'LUCENE-9507 Custom order for leaves in IndexReader and IndexWriter (#32)', apache/lucene#32 by mayya-sharipova, tracker LUCENE-9507 = #10546) and its branch_8x backport 1cfd0df4e7bb6180b67abb30bd92390bc729ec3f (apache/lucene-solr#2473) [read: history clone and GitHub; exec: 8.8.2 lacks setLeafSorter (compile error), 8.9.0 fails]. The commit passed the application's leaf sorter into the StandardDirectoryReader constructor that the NRT open calls right after writer.incRefDeleter(segmentInfos). The incRefDeleter placement (65274f5965b, LUCENE-5434, 2014) and the catch that releases only the segment readers (4312f3d71ab LUCENE-5262, 2e35ef2b3da LUCENE-8215) are older, but before LUCENE-9507 the constructor could throw only through BaseCompositeReader's too-many-documents check, which IndexWriter prevents [read, not executed]; so the window opens with the leaf sorter. (2021-03-26 (author date of 48715fe898bf on main); branch_8x backport author date 2021-03-30; first release 8.9.0)
First affected version
8.9.0 [exec] (first release with the leaf sorter, through the branch_8x backport: a copy of the JUnit that differs only in the LuceneTestCase import fails, and a core-only port fails while its no-failure control passes); 8.8.2 lacks IndexWriterConfig#setLeafSorter [exec: compile error]; first 9.x release 9.0.0 [exec]
Affected versions
every release from 8.9.0 through 10.6.0 and main 645d4fe14a20; never fixed. [exec] the minimal JUnit TestNrtOpenLeafSorterFailureReleasesFiles fails on main (-ea and -da; re-run 2026-11-01, seed DEADBEEF, 'file of a merged-away segment was not deleted: _0.cfe') and passes with the fix shadowed (-da); it fails on the released 10.6.0 jars (4ae946e5a9b, -ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.1.0; the import-only copy fails on 9.0.0, 8.11.4 and 8.9.0 (-ea); the core-only main() port fails on 8.9.0, 9.0.0 and main and its control passes on 8.9.0 and main; the independent reproduction's independent test fails on main (-ea, -da), 10.6.0 (-ea, -da), 10.5.2 and 9.12.3. [read] the releases in between by code identity of the NRT open (no non-cosmetic change since 48715fe898bf). Upstream main f815e679bf6 (2026-11-01) and branch_10_6 have no commit to StandardDirectoryReader.java after 2026-02-11 (448c7d8e241 on main, c8b00d2ba72d on branch_10_6, author dates); no commit to BaseCompositeReader.java after 2025-07-30 or to IndexWriter.java after 2026-09-21 on main. Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: TLA+/TLC, code reading
Upstream and prior reports
none found (2026-11-01, on top of the finder's scripted prior-report search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches): three scripted prior-report searches (153 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 25 manual GitHub searches, the changed files of all 409 open apache/lucene PRs (22 touch related files; their hunks change other methods), the 12 PRs updated since 2026-10-30 and the 6 issues created since 2026-10-29, the full comments and reviews of the origin items, and the upstream history of the fix-site files with author dates. Refresh 2026-11-01: no new item; 8 issue and PR searches (leafSorter, leaf sorter, incRefDeleter, decRefDeleter, NRT open exception symptoms, items updated since 2026-10-31) find only unrelated items, and the latest commits on main to StandardDirectoryReader.java, BaseCompositeReader.java and IndexWriter.java are still 448c7d8e241 (2026-02-11), 96f862dfb81 (2025-07-30) and 169cbffd12f (2026-09-21), main head f815e679bf6. Related, not this bug: #6497 (LUCENE-5434, 2014, added the incRefDeleter before the constructor), #6326 (LUCENE-5262, 2013) and #9262 (LUCENE-8215, 2018) (built the catch that releases only the segment readers), #10546 / #32 (LUCENE-9507, put the leaf sorter into that constructor; no review comment on exceptions), #5772 (LUCENE-4707, open: NRT reader files deleted too early, the opposite failure), #6636 (LUCENE-5574, leftover files called harmless when the writer closed first), #12228 (unreferenced files after a failed merge), #16625 (premature deletion on rollback).
Lucene commit tested
main 645d4fe14a20: the minimal test through gradle in a scratch worktree, stock and with the fix, assertions on and off, plus the 6 closest suites (112 tests) stock and with the fix (2026-11-02); the minimal test through the standalone JUnit runner with -ea and -da. Release jars: 10.6.0 (-ea, -da), 10.5.2, 10.0.0, 9.12.3 and 9.1.0 (the test), 9.0.0, 8.11.4 and 8.9.0 (the test with the pre-9.1 test-framework import; 8.9.0 and 9.0.0 also a core-only port), 8.8.2 (API absent). Upstream main f815e679bf6 (2026-11-01) has no change to the fix-site files (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:110-166 (at 645d4fe14a20) (NRT open open(IndexWriter, IOFunction, SegmentInfos, boolean, boolean): readerFunction.apply and keep check :136-145, writer.incRefDeleter(segmentInfos) :148, 'return new StandardDirectoryReader(..., writer.getConfig().getLeafSorter(), ...)' :150-157, catch (Throwable t) that only runs IOUtils.applyToAll(readers, SegmentReader::decRef) :158-164), :548-568 (doClose: the only writer.decRefDeleter(segmentInfos); the 'harmless' comment covers a writer closed before the reader)
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-77 (at 645d4fe14a20) (constructor runs Arrays.sort(subReaders, subReadersSorter): the application's leaf sorter)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:711-721 (at 645d4fe14a20) (getReader catch: onTragicEvent only for an Error; closes r, which is null when the open threw), :6319-6324 (decRefDeleter javadoc: 'Only call this if you are sure you previously called incRefDeleter')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:517-527 (at 645d4fe14a20) (setLeafSorter: public, no non-throwing precondition)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:110-166 (at 645d4fe14a20) (:126, :136-148, :150-157, :158-164), :434-442, :452, :466, :548-568
  • lucene/core/src/java/org/apache/lucene/index/BaseCompositeReader.java:74-77, (at 645d4fe14a20) :89-98
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:516, (at 645d4fe14a20) :598-615, :711-721, :755-766, :6306-6336
  • lucene/core/src/java/org/apache/lucene/index/IndexFileDeleter.java:47-50, (at 645d4fe14a20) :603-609; index/IndexWriterConfig.java:517-527; index/DirectoryReader.java:122, :144, :340, :403-408
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/SearcherManager.java:66, (at 645d4fe14a20) :194-205; index/ReaderManager.java:41; index/LiveIndexWriterConfig.java:422-431
  • lucene/core/src/java/org/apache/lucene/search/ControlledRealTimeReopenThread.java:212-216 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:1204-1222 (at 645d4fe14a20) (testIndexReaderWriterWithLeafSorter: its sorter handles missing points)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165, (at 645d4fe14a20) :669-676, :711-721, :6319-6324
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:158-164, (at 645d4fe14a20) :554-559
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:517-527; (at 645d4fe14a20) index/LiveIndexWriterConfig.java:422-431
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:1108-1153 (at 645d4fe14a20) (testNRTOpenExceptions)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:126-165, (at 645d4fe14a20) :548-568; index/IndexWriter.java:598-615, :6306-6336

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

Repro files

About the diagrams

Made from the code at 645d4fe14a20 and the executed runs.

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.