All findings · Lucene finding · LFV-626 · nrt-open-keepfullydeletedsegment-failure-leaks-segment-reader

A failing keepFullyDeletedSegment check during an NRT open leaks the segment's reader

If the merge policy's keep check for a fully deleted segment throws during an NRT open (say, SoftDeletesRetentionMergePolicy's retention query fails once), the segment reader Lucene just opened is never released, and its files stay open after IndexWriter.close(). Every release from 7.4.0 to 10.6.0 has it.

Summary

Priority
Low, rank 1095A bounded exception-path SegmentReader leak in NRT open that needs a throwing keepFullyDeletedSegment under SoftDeletesRetentionMergePolicy, the same class as the LFV-446 and LFV-495 failure-path leaks.
Impact
minor
Since
7.4.0 (2018), LUCENE-8233 (ecc17f90233)introduced 2018-04-04 · about 8 years
Affected
7.4.0 through 10.6.0 and main; not fixed
Status
documented
Found by
non-formal baseline: randomized stress with injected faults, then a JUnit test

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

Lucene opens the segment's reader first, asks the merge policy second, and lists the reader for cleanup third. A throw in step two skips step three, so nothing ever releases that reader.

Background: fully deleted segments and the keep check

  1. With soft deletes, an update marks the old document as deleted through a doc-values field instead of removing it. A segment whose documents are all deleted is normally dropped.
  2. SoftDeletesRetentionMergePolicy can keep such a segment: its keepFullyDeletedSegment runs your retention query against the segment and keeps it when the query matches a document.
  3. Every NRT open (DirectoryReader.open(writer), openIfChanged, a SearcherManager refresh) opens one reader per segment and runs this keep check for each fully deleted one.
Diagram of the bug: StandardDirectoryReader.open obtains a SegmentReader for the fully soft-deleted segment _0, calls the merge policy's keepFullyDeletedSegment, which throws, and never adds the reader to the readers list; the catch block releases only that list, so after IndexWriter.close() _0.cfs and three doc-values update files are still open. With the fix the reader is released before the exception is rethrown.
The NRT open loop for segment _0: the reader is opened, the keep check throws, and the reader never reaches the list the failure handler releases, so its files stay open after IndexWriter.close(). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Use soft deletes with SoftDeletesRetentionMergePolicy and the retention query MatchAllDocsQuery. Index one document, flush, soft-update it and close the writer: segment _0 now holds 1 document, 0 of them live, and the retention query keeps it (the replacement document goes into a second segment, _1).
  2. Open a new IndexWriter and call DirectoryReader.open(writer) while the retention query supplier throws once. The open fails with that exception, as it should.
  3. You expect the failed open to give back what it opened, so that after writer.close() no file of the index is open.
  4. Instead 4 files of _0 stay open: _0.cfs and _0_1_Lucene90_0.dvd, .dvp and .dvs. Lucene had opened a reader for _0, then asked the merge policy whether to keep it; the check threw before the reader was added to the list that the failure handler releases.

Who is affected

Opt-in: applications that use soft deletes with SoftDeletesRetentionMergePolicy (or a merge policy whose keep check can throw) and open NRT readers. Each failure leaks one segment's reader, which close() does not release; 10 failing cold-writer reopens left 40 files open. Results and the index stay correct. The default merge policy never throws here.

Mechanism, terms and numbers684 words

When IndexWriter opens an NRT reader, StandardDirectoryReader.open(IndexWriter, ...) obtains a read-only SegmentReader for each segment and, for a segment with no live documents, asks the merge policy whether to keep it: reader.numDocs() > 0 || mergePolicy.keepFullyDeletedSegment(() -> reader). Only after that is the reader added to the readers list, and the method's failure handler releases only that list. keepFullyDeletedSegment is public and declared throws IOException, and the stock SoftDeletesRetentionMergePolicy runs the application's retention query against the segment there. If the check throws, the open fails as it should, but the reader that was just obtained is neither listed nor returned, so nothing releases it: the segment's core and its files stay open after IndexWriter.close() and rollback. The cleanest trigger is the first NRT open of a freshly opened writer over a commit that holds a retained, fully soft-deleted segment; on a writer that already pools the segment, a persistent failure is thrown earlier, in IndexWriter.writeReaderPool, and leaks nothing. No wrong results and no index damage. The gap came with LUCENE-8233 (ecc17f90233, 2018) and is in every release from 7.4.0 through 10.6.0 and in main; releasing the reader when the keep decision throws fixes it.

Terms

  • Read-only clone: the SegmentReader that IndexWriter.getReader hands to the NRT reader, rld.getReadOnlyClone(IOContext.DEFAULT) (IndexWriter.java:554). It holds a reference on the segment's core, which owns the open files; the writer releases only its pool handle (:561).
  • Keep check: MergePolicy.keepFullyDeletedSegment(IOSupplier<CodecReader>), public, non-final and declared throws IOException; the default returns false (MergePolicy.java:760-768).

The bug

  1. IndexWriter.getReader calls writeReaderPool (IndexWriter.java:608), then StandardDirectoryReader.open(this, readerFactory, segmentInfos, ...) (:613-615).
  2. The loop in StandardDirectoryReader.open(IndexWriter, ...) (StandardDirectoryReader.java:129-146):
final SegmentReader reader = readerFunction.apply(info);                       // :136 obtained
if (reader.numDocs() > 0
    || writer.getConfig().mergePolicy.keepFullyDeletedSegment(() -> reader)) { // :137-138 may throw
  // Steal the ref:
  readers.add(reader);                                                         // :140 listed
  1. The failure handler releases only the list: catch (Throwable t) { ... IOUtils.applyToAll(readers, SegmentReader::decRef); ... throw t; } (:158-165). A throw at :138 skips :140.
  2. Nothing else can release the reader. getReader's own catch block closes r, still null because the open never returned, and onGetReaderMergeResources (IndexWriter.java:711-721), which is set only after a successful open (:649-666). getReader also records each clone in a local map, openedReadOnlyClones (:556-558, when maxFullFlushMergeWaitMillis > 0, the default), but reads it only after a successful open (:684-694). close() and rollback() release the pool's readers, not this clone.
  3. writer.incRefDeleter(segmentInfos) (StandardDirectoryReader.java:148) has not run yet, so file-deletion bookkeeping stays consistent: the leak is open handles and memory only.
  4. SoftDeletesRetentionMergePolicy.keepFullyDeletedSegment calls retentionQuerySupplier.get() (SoftDeletesRetentionMergePolicy.java:86) and runs the query on the segment (:84-91), so a throwing supplier or a read error there is enough.

When it leaks

  • Cold writer (the clean case). writeReaderPool first runs the same check, but only for segments already in the reader pool (IndexWriter.java:4128-4140, readerPool.get(info, false) at :4131). On the first NRT open of a new writer the segment is not pooled yet, so StandardDirectoryReader.open runs the only check and any failure leaks.
  • Warm writer. A persistent failure is thrown in writeReaderPool, before any reader is obtained, and leaks nothing; only a transient failure that hits the second check leaks, re-pinning the existing core with no new handles.
  • Merge-on-refresh. IndexWriter.maybeReopenMergedNRTReader uses the same method and says it "will take care of closing" the readers it is handed (IndexWriter.java:766-771); same window, not executed.

The numbers

scenario (one thread)expectedactual on main
retention query supplier throws once on the first NRT open of a new writer (the minimal repro)open fails; no file open after close()open fails; 4 files of _0 still open
one read error while the stock retention query reads _0 (verifiers)samesame 4 files
same steps, no failureno file openno file open
either failing case with the fixno file openno file open

In the failing cases the writer stays open, has no tragic exception, and a later NRT open works (verifiers' tests on a9779ef3c193).

Exposure and likelihood283 words
  • Who is hit. Applications using soft deletes with SoftDeletesRetentionMergePolicy, or any merge policy whose keepFullyDeletedSegment can throw, that open NRT readers from their writer (DirectoryReader.open(writer), openIfChanged, SearcherManager). The default MergePolicy.keepFullyDeletedSegment returns false with no I/O; in core only SoftDeletesRetentionMergePolicy does real work there (NoMergePolicy and FilterMergePolicy delegate). Downstream, Elasticsearch's engine passes a sequence-number retention query to the stock SoftDeletesRetentionMergePolicy (code read only, not run).
  • What it takes. A fully soft-deleted segment that the retention query keeps, and an exception inside the keep check when StandardDirectoryReader.open runs it: an I/O error while the retention query reads that segment, or a throwing retention query supplier. Cleanest on the first NRT open of a freshly opened writer. Exceptions inside this keep check have been reported on another call site (#13353 and elastic/elasticsearch #105122, #105474, #99075; no leak reported there).
  • What happens. One SegmentReader per failure stays open (in the repro the compound file and three doc-values update files); IndexWriter.close() and rollback do not release it. No wrong results: the failed open returns no reader, and under a SearcherManager 20 of 20 failed refreshes kept serving the previous reader with the soft-deleted document hidden.
  • Scale. 10 cold-writer reopens while the check kept failing left 40 inputs open, and under MMapDirectory 40 mappings still listed in /proc/self/maps at the end of the loop (48 after a later successful open and close); 0 with the fix. Warm-writer repeats re-pin the same core and add no handles. Not tested: Windows (open files cannot be deleted), disk space of deleted-but-open files, heap of a large core.
  • Rating. Minor, likelihood rare, priority low: an exceptional event at one specific step, silent outside a leak-checking test framework.

Minimal repro

One JUnit test on stock classes: SoftDeletesRetentionMergePolicy whose retention query supplier throws once, on the first NRT open of a new writer. MockDirectoryWrapper.close() checks for open files. Single thread, no timing dependence; it failed the same way on every seed tried.

TestNrtOpenKeepCheckFailureLeaksReader.java (JUnit, one test)64 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.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.tests.store.MockDirectoryWrapper;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNrtOpenKeepCheckFailureLeaksReader extends LuceneTestCase {

  private static IndexWriterConfig config(Supplier<Query> retention) {
    return new IndexWriterConfig()
        .setSoftDeletesField("soft")
        .setMergePolicy(
            new SoftDeletesRetentionMergePolicy("soft", retention, NoMergePolicy.INSTANCE));
  }

  public void testFailedKeepCheckOnFirstNrtOpen() throws Exception {
    MockDirectoryWrapper dir = newMockDirectory();
    AtomicBoolean fail = new AtomicBoolean();
    Supplier<Query> retention =
        () -> {
          if (fail.getAndSet(false)) {
            throw new IllegalStateException("retention query unavailable");
          }
          return new MatchAllDocsQuery();
        };
    try (IndexWriter w = new IndexWriter(dir, config(retention))) {
      Document doc = new Document();
      doc.add(new StringField("id", "1", Field.Store.NO));
      w.addDocument(doc);
      w.flush();
      w.softUpdateDocument(new Term("id", "1"), doc, new NumericDocValuesField("soft", 1));
    }

    IndexWriter w = new IndexWriter(dir, config(retention));
    fail.set(true);
    expectThrows(IllegalStateException.class, () -> DirectoryReader.open(w));
    w.close();
    // expected no open files after the failed open and close(); actual: _0's files are still open
    dir.close();
  }
}
Command
cp TestNrtOpenKeepCheckFailureLeaksReader.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestNrtOpenKeepCheckFailureLeaksReader -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same result)
Expected
DirectoryReader.open(writer) throws, and after writer.close() no file is open, so dir.close() succeeds.
Observed
MockDirectoryWrapper: cannot close: there are still 4 open files: {_0.cfs=1, _0_1_Lucene90_0.dvp=1, _0_1_Lucene90_0.dvs=1, _0_1_Lucene90_0.dvd=1}, with assertions on and off.

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

More files, environment, full output284 words

The leak is detected by MockDirectoryWrapper.close(), the standard Lucene open-files check that TestIndexWriterReader#testNRTOpenExceptions also uses. The package is org.apache.lucene.index by convention only; the test uses public API. NoMergePolicy inside the retention policy keeps the segments stable; a verifier's variant with default merge settings (TieredMergePolicy inside) fails the same way. The test framework randomizes the codec; it made no difference in the runs: on main, seeds DEADBEEF and 5EED1234 (codec Asserting(Lucene104)) and 5EED0626 (codec CheapBastard) leave the same 4 files open, and on 10.6.0 a seed that drew the Asserting doc-values format leaves the 4 corresponding files open. Only Linux aarch64 with OpenJDK 25 was run.

As the regression test: the same test, for example as a new method in TestSoftDeletesRetentionMergePolicy or next to testNRTOpenExceptions in TestIndexWriterReader.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 and, per the verifiers, 10.5.2 and 9.12.3
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestNrtOpenKeepCheckFailureLeaksReader.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestNrtOpenKeepCheckFailureLeaksReader -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (standalone runs give the same with -ea and -da)
TestNrtOpenKeepCheckFailureLeaksReader > testFailedKeepCheckOnFirstNrtOpen FAILED
    java.lang.RuntimeException: MockDirectoryWrapper: cannot close: there are still 4 open files: {_0.cfs=1, _0_1_Lucene90_0.dvp=1, _0_1_Lucene90_0.dvs=1, _0_1_Lucene90_0.dvd=1}
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:9CABF7EEC9364E88]:0)
        at org.apache.lucene.tests.store.MockDirectoryWrapper.close(MockDirectoryWrapper.java:884)
        at org.apache.lucene.index.TestNrtOpenKeepCheckFailureLeaksReader.testFailedKeepCheckOnFirstNrtOpen(TestNrtOpenKeepCheckFailureLeaksReader.java:62)

        Caused by:
        java.lang.RuntimeException: unclosed IndexInput: _0.cfs
            at org.apache.lucene.tests.store.MockDirectoryWrapper.addFileHandle(MockDirectoryWrapper.java:782)
            at org.apache.lucene.tests.store.MockDirectoryWrapper.openInput(MockDirectoryWrapper.java:843)
            at org.apache.lucene.codecs.lucene90.Lucene90CompoundReader.<init>(Lucene90CompoundReader.java:87)
            ...
            at org.apache.lucene.index.SegmentCoreReaders.<init>(SegmentCoreReaders.java:83)
            at org.apache.lucene.index.SegmentReader.<init>(SegmentReader.java:95)
            at org.apache.lucene.index.ReadersAndUpdates.getReader(ReadersAndUpdates.java:191)
            at org.apache.lucene.index.ReadersAndUpdates.getReadOnlyClone(ReadersAndUpdates.java:233)
            at org.apache.lucene.index.IndexWriter.lambda$getReader$0(IndexWriter.java:554)
            at org.apache.lucene.index.StandardDirectoryReader.open(StandardDirectoryReader.java:136)
            at org.apache.lucene.index.IndexWriter.getReader(IndexWriter.java:614)
            at org.apache.lucene.index.DirectoryReader.open(DirectoryReader.java:144)
            ...
  2> NOTE: test environment is: codec=Asserting(Lucene104): {}, ...

# 10.6.0 release jars, same test: there are still 4 open files: {_0.cfs=1, _0_1_Asserting_0.dvd=1, _0_1_Asserting_0.dvs=1, _0_1_Asserting_0.dvp=1}
# with the fix: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • The method's catch block releases every reader it obtained (LUCENE-5262); this one escapes only because it is not in the list yet.
  • TestIndexWriterReader#testNRTOpenExceptions: failed NRT opens must not "leak file handles".
  • keepFullyDeletedSegment is declared throws IOException, and the stock retention policy runs a query inside it.
The full argument193 words
  • The catch block's purpose. StandardDirectoryReader.java:158-165 decRefs every reader the loop took. It goes back to LUCENE-5262 (#6326, 2013) and was rewritten by LUCENE-8215 (2e35ef2b3da, 2018-03-19), two weeks before the keep hook arrived.
  • Lucene's own test. TestIndexWriterReader#testNRTOpenExceptions (TestIndexWriterReader.java:1108-1110): "LUCENE-5262: test that several failed attempts to obtain an NRT reader don't leak file handles." It ends with the same MockDirectoryWrapper open-files check as the repro (dir.close(), :1152).
  • A declared exception. keepFullyDeletedSegment is public, non-final and declared throws IOException (MergePolicy.java:765-766); SoftDeletesRetentionMergePolicy does query I/O inside it (SoftDeletesRetentionMergePolicy.java:80-94).
  • Ownership is handed over. IndexWriter.java:769-771: "as soon as we remove the reader and return it the StandardDirectoryReader#open will take care of closing it."
  • The other keep-check site that holds readers releases them in a finally block (IndexWriter.closeSegmentStates, IndexWriter.java:6743-6762).
  • An oversight in history. Before LUCENE-8233 (ecc17f90233, 2018-04-04) the condition used writer.getKeepFullyDeletedSegments(), a package-private boolean getter that cannot throw; that commit put the public throwing hook in the same spot. No comment on #9280, no CHANGES or MIGRATE entry, test or issue sanctions the leak, and no upstream test throws from the hook.
The devil's-advocate check126 words

The strongest case for "intended" (the intended-behaviour devil's advocate and the cross-model reviewer's intended lens, which rated it moderate): MergePolicy, SoftDeletesRetentionMergePolicy and DirectoryReader.open(IndexWriter) are @lucene.experimental, no javadoc promises cleanup after a merge-policy callback throws, the finder's trigger was a synthetic call-stack-filtered fault, and on a warm writer a persistent failure is caught earlier with no leak.

Why it does not hold up: @lucene.experimental is about API stability, not resource safety; the hook is declared to throw IOException; the release-on-failure catch block and testNRTOpenExceptions show the intent; the minimal repro uses only stock classes, with no subclass and no call-stack filter; and the warm-writer pre-check narrows how often it happens, not whether it is a leak: on a freshly opened writer any failure leaks.

Suggested fix

Recommended A. Release the reader when the keep decision throws

In StandardDirectoryReader.open, wrap the keep decision in try/catch and decRef() the not-yet-listed reader before rethrowing, suppressing a secondary failure (+15/-2 lines). The repro then passes with assertions on and off, and 6 neighbouring index test classes plus the repro (135 tests, testNRTOpenExceptions among them) pass, as do the format and lint checks.

All options and trade-offs2 options ยท 465 words

One option was run through gradle in a scratch worktree of 645d4fe14a20 and is recommended; the alternative was not run.

A. Release the reader when the keep decision throws (validated by the checks) (recommended)

Compute the keep decision inside try; on any Throwable, decRef() the reader that is not in readers yet (adding a secondary failure as suppressed) and rethrow, the same pattern as the method's outer catch block. The original exception still propagates. It covers both callers of the method, getReader and the merge-on-refresh reopen (IndexWriter.java:766); the second was not executed. The snippet is a hunk of the full diff, which applies with git apply at 645d4fe14a20.

         final SegmentReader reader = readerFunction.apply(info);
-        if (reader.numDocs() > 0
-            || writer.getConfig().mergePolicy.keepFullyDeletedSegment(() -> reader)) {
+        final boolean keep;
+        try {
+          keep =
+              reader.numDocs() > 0
+                  || writer.getConfig().mergePolicy.keepFullyDeletedSegment(() -> reader);
+        } catch (Throwable t) {
+          // the reader is not in readers yet, so the catch block below would not release it
+          try {
+            reader.decRef();
+          } catch (Throwable t1) {
+            t.addSuppressed(t1);
+          }
+          throw t;
+        }
+        if (keep) {
           // Steal the ref:
           readers.add(reader);

Trade-offs

  • On the success path nothing extra runs beyond entering the try block (not benchmarked); the default merge policy never throws here.
  • It does not change when the check runs or what it returns, and touches one method.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (2026-11-01): stock, the minimal test fails; with the fix it passes with assertions on and off.
  • With the fix: the closest suites (TestSoftDeletesRetentionMergePolicy, TestSoftDeletesDirectoryReaderWrapper, TestIndexWriterReader with testNRTOpenExceptions, TestDirectoryReaderReopen, TestIndexWriterMergePolicy and TestIndexWriterExceptions) plus the repro: 7 classes, 135 tests, 0 failures, 7 skipped (5 @Nightly, 2 @AwaitsFix), seed DEADBEEF, assertions on. The rest of the core module was not run.
  • Format and lint: checkGoogleJavaFormat, ecjLint and forbiddenApis for :lucene:core pass with the fix and the test in place.
  • The same change shadowed into the stock main jars, run standalone: the minimal test passes with -ea and -da. Earlier, the finder's identical change made every verifier's repro and the security probe pass (every leak count 0), and a cross-model reviewer ran TestSoftDeletesRetentionMergePolicy with it (14 tests, 0 failures).
  • Not run: the full Lucene test suite.

B. Add the reader to the list before the check (not run)

Add the reader to readers right after readerFunction.apply(info), so the existing catch block owns it, and when the segment is not kept, remove it from the list again before the decRef() and segmentInfos.remove(infosUpto).

Trade-offs

No new try, but it restructures the loop and needs care that a reader removed from the list is decRef'd exactly once.

Downstream note

Elasticsearch's engine configures the stock SoftDeletesRetentionMergePolicy with a sequence-number retention query (code read only, not run), so a keep-check failure on its NRT path would leak the same way.

How it was found

Non-formal: a randomized stress test of IndexWriter with a sequence-number replay oracle and one injected fault per round. At the keepFullyDeletedSegment fault site, 40 of 54,400 rounds left files open after IndexWriter.close(). Reading the code gave the root cause, and the case was reduced to a single-thread JUnit test.

The method in full102 words

Method: non-formal baseline (randomized stress with injected faults, then executed tests). An earlier hunt unit's stress harness saw the open-files failure in 6 rounds and recorded the root cause by code reading as a lead. A later unit completed that harness: it saw MockDirectoryWrapper report open files after IndexWriter.close() in 40 of 54,400 unfiltered rounds, all with the injected fault fired at StandardDirectoryReader.open:138, and none with this signature in 48,000 rounds with the fix shadowed in. It then reduced the case to a deterministic single-thread JUnit. Verifiers replaced the finder's call-stack-filtered fault with a stock-classes trigger, which became the minimal repro.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): three 51-query scripted prior-report searches, 37 manual GitHub searches, the lists, JIRA, downstream trackers, the web and all 409 open PRs. Closest: elastic/elasticsearch#67884 (a leak through another keep-check caller, fixed by LUCENE-9722) and #13353 (exceptions in this check on another path, no leak).

Searches3 searches

Closest items, none a report of this bug: elastic/elasticsearch#67884 (2021: _0.cfs left open with the keep check in the stack, through IndexWriter.publishFlushedSegment; fixed by LUCENE-9722 in IndexWriter.mergeMiddle); #13353 with elastic/elasticsearch #105122, #105474 and #99075 (exceptions thrown inside SoftDeletesRetentionMergePolicy.keepFullyDeletedSegment on the flush-publication path; no leak reported; fixed in IndexingChain); #6326 (LUCENE-5262, origin of the release-on-failure catch block); #9280 (LUCENE-8233, the introducing change; no comment discusses a throwing keep check); #10112 (LUCENE-9070, open: _0.cfs left open by asynchronous caching in a test). Only two apache/lucene issues mention keepFullyDeletedSegment at all, #10005 and #9375, both unrelated.

Limits: absence cannot be proven; GitHub full-text search does not index attachments or patches of migrated JIRA issues.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits2026-10-27three scripted prior-report searches of 51 queries (code site, soft-deletes trigger, generic NRT-open leaks), plus the finder's 51 and a verifier's 31; 37 manual GitHub and commit searches; full-history git -S and --grepno report; related items only (#6326, #9280, LUCENE-8215, #13353, #10112)
Open PRs and branches2026-10-27touched files of all 409 open apache/lucene PRs, with diffs read for those touching the same files; main, branch_10x, branch_10_6, branch_9_12no open PR changes this loop; every branch still has the unguarded check
Mailing lists, JIRA, downstream, web2026-10-27dev@, java-user@, issues@ and commits@; the JIRA archive; Elasticsearch, OpenSearch and Solr trackers; 8 web queriesno mail mentions keepFullyDeletedSegment; elastic/elasticsearch#67884 is a different call site

History

Introduced by LUCENE-8233 (ecc17f90233, 2018-04-04, soft deletes), which put the throwing hook between opening the reader and listing it. First release 7.4.0; executed leaks on 12 releases from 7.4.0 to 10.6.0 and on main; 7.3.1 lacks the API. Not fixed upstream as of 2026-10-30.

Versions tested8 rows

Introduced by ecc17f9023309ca2c46eaf65fd031e4af0ef5a25 on master, "LUCENE-8233: Add support for soft deletes to IndexWriter" (s1monw, 2018-04-04; JIRA LUCENE-8233, now #9280; patch era, no pull request on the Apache repository), backported to branch_7x as 02fa5a0b059 the same day. In StandardDirectoryReader.open(IndexWriter, ...) it replaced writer.getKeepFullyDeletedSegments() (package-private, cannot throw) with mergePolicy.keepFullyDeletedSegment(reader) (public, throws IOException) between obtaining the reader and listing it. Later commits moved the code but kept the window: d9041124281 (LUCENE-8253, the IOSupplier form, before 7.4.0), 8294e1ae206 (LUCENE-8962, 8.7.0, readerFunction.apply(info)) and 2d6ad2fee6d (LUCENE-9570, reformat).

By reading, all 58 release tags 7.4.0-10.5.2 and the 10.6.0 tag call the check right after obtaining the reader with no guard, and upstream main 2f424fd428d (2026-10-30) still does. There is no earlier good version of this path to bisect. The number of leaked files varies by version (2 on 7.4.0-10.0.0, 3 on 10.5.2, 4 on main and 10.6.0) with how many doc-values update files the codec keeps open; the leaked object is the same.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails (gradle; standalone -ea and -da); passes with fix A
10.6.0 affectedminimal test fails, 4 open files (release jars, 2026-11-01)
main a9779ef3c193 affectedevery verifier's test and the history test fail with -ea and -da; pass with the fix
10.5.2, 10.0.0, 9.12.3, 9.1.0, 9.0.0 affectedhistory test fails 2 of 3 (both bug tests; the control passes)
8.11.4, 8.7.0, 8.6.3, 8.0.0, 7.7.3 affectedhistory test fails 2 of 3
7.4.0 first releasehistory test fails 2 of 3 with -ea and -da; 2 files open
7.3.1 not affectedAPI probe: no keepFullyDeletedSegment hook, no soft deletes; the test does not compile
ecc17f90233 (LUCENE-8233) / 02fa5a0b059 (branch_7x) introducing commitby reading: the only commit that adds the throwing call to the file

Independent checks

Confirmed, high confidence: the finder, an independent reproducer, a cross-model reviewer (OpenAI GPT-5.6, the lenses, executed), an intended-behaviour devil's advocate and an adjudication all reproduced it and found it not intended; a credibility audit confirmed it.

Checks and verdicts211 words

Everyone agrees on the root cause. The verifiers narrowed the reach: on a writer that already pools the segment, a persistent failure surfaces earlier and leaks nothing.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateStress harness: 40 of 54,400 rounds; reduced JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3, passes with the fix.
independent reproducer real (minor)Own test, 3 bug tests and 2 controls: 3 of 5 fail with -ea and -da, 60 of 60 bug iterations; found the warm-writer pre-check.
cross-model reviewer (OpenAI GPT-5.6, executed) real, high (the lenses)Own repros fail, controls and the fix pass; intended-behaviour argument weak (refute lens) and moderate (intended lens). Ran, on the previous main, whose touched files are unchanged.
intended-behaviour devil's advocate real, not intendedWrote the stock-classes trigger; fails on main, 10.5.2 and 9.12.3 and with default merge settings; passes with the fix.
adjudication confirmed, high, minorRe-ran the reproducer's and the stock-classes tests with a fresh seed (-ea, -da) and the latter with the fix.
Follow-up, audit and this doc confirmedHistory test on 12 versions, security probe, fresh-seed re-runs, credibility audit; on 2026-11-01 the minimal test on main (gradle and standalone), 10.6.0 and with the fix.

Diagram file

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

Sources and provenance

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

Finding record

LFV-626, snapshot of 2026-11-06

Registry title
If MergePolicy.keepFullyDeletedSegment throws while IndexWriter opens an NRT reader (for example a read error while SoftDeletesRetentionMergePolicy runs its retention query), StandardDirectoryReader.open never releases the SegmentReader it obtained just before the check, so that segment's core and files stay open after IndexWriter.close()
Area
core / index NRT reader open from IndexWriter (StandardDirectoryReader.open(IndexWriter, ...) keep check for fully deleted segments; SoftDeletesRetentionMergePolicy as the stock trigger)
Priority
Low, rank 1095: A bounded exception-path SegmentReader leak in NRT open that needs a throwing keepFullyDeletedSegment under SoftDeletesRetentionMergePolicy, the same class as the LFV-446 and LFV-495 failure-path leaks.
Impact class
minor
Impact note
Applications that use soft deletes with SoftDeletesRetentionMergePolicy (or any merge policy whose keepFullyDeletedSegment can throw) and open NRT readers from their IndexWriter leak one SegmentReader, holding the segment core and its files (the compound file and the doc-values update files, 4 inputs in the main repro), each time the keep check fails inside StandardDirectoryReader.open; the cleanest case is the first NRT open of a new writer on a commit that holds a fully soft-deleted segment the retention query keeps, because there StandardDirectoryReader.open runs the only check, while on a warm writer a persistent failure surfaces earlier in IndexWriter.writeReaderPool and leaks nothing; neither IndexWriter.close() nor rollback releases the reader, repeated cold-writer reopens while the check keeps failing add one core each (10 cycles left 40 inputs open, and 40 MMapDirectory mappings that were not reclaimed in the security probe), warm-writer repeats re-pin the same core and add no handles, any index size, no wrong results, no index damage, and the writer stays open and usable [exec].
Summary
You use soft deletes with SoftDeletesRetentionMergePolicy, open a new IndexWriter on an index whose last commit holds a fully soft-deleted segment that the retention query keeps, and call DirectoryReader.open(writer); the merge policy's keep check for that segment fails (one read error while the retention query reads the segment, or your retention-query supplier throws), so the open fails with that exception. You expect the failed open to release what it acquired, as StandardDirectoryReader.open's own catch block does for every reader already in its list (LUCENE-5262), so that closing the writer leaves no file open. Instead the SegmentReader obtained just before the keep check is neither in that list nor released, so after IndexWriter.close() the segment stays open (MockDirectoryWrapper: 'there are still 4 open files: {_0.cfs=1, _0_1_Lucene90_0.dvp=1, _0_1_Lucene90_0.dvs=1, _0_1_Lucene90_0.dvd=1}'), while the writer itself stays open and a later NRT open works.
Introduced
ecc17f9023309ca2c46eaf65fd031e4af0ef5a25 on master ('LUCENE-8233: Add support for soft deletes to IndexWriter', by s1monw; JIRA LUCENE-8233, mirrored as apache/lucene#9280; patch era, no pull request on the Apache repository) and its branch_7x backport 02fa5a0b059353ae5607e91211cea24974257980 the same day. In StandardDirectoryReader.open(IndexWriter, ...) it replaced the non-throwing package-private writer.getKeepFullyDeletedSegments() with the new public hook mergePolicy.keepFullyDeletedSegment(reader), declared 'throws IOException', between obtaining the reader and readers.add(reader), so an exception from the hook bypasses the release-on-failure list [read: git log -S'keepFullyDeletedSegment(' on StandardDirectoryReader.java in the history clone finds only this commit; exec: 7.3.1 lacks the hook (API probe) and 7.4.0 leaks]. No earlier good version of the path exists to bisect: the window opens exactly when the throwing hook arrives. Later commits kept it: d9041124281 / branch_7x 330fd18f200 (LUCENE-8253, before 7.4.0, IOSupplier form called as () -> reader), 8294e1ae206 (LUCENE-8962, first released in 8.7.0, reader from readerFunction.apply(info)) and 2d6ad2fee6d (LUCENE-9570, reformat). The release-on-failure catch block itself is older: 4312f3d71ab (LUCENE-5262, 2013), rewritten by 2e35ef2b3da (LUCENE-8215, 2018-03-19) (2018-04-04 (ecc17f90233 on master and 02fa5a0b059 on branch_7x))
First affected version
7.4.0 [exec] (tagged 2018-06-22, via the branch_7x backport: the history test fails 2/3 with -ea and -da, its control passes); on the master line the first release is 8.0.0 [exec]; 7.3.1 is not affected [exec: API probe; the hook, soft deletes and SoftDeletesRetentionMergePolicy do not exist there, and git merge-base shows 7.3.1, cut from branch_7_3, does not contain the commit]
Affected versions
every release from 7.4.0 through 10.6.0 and main a9779ef3c193; never fixed. [exec] a history test (a retention-supplier throw and a FilterMergePolicy IOException, each asserted to fire inside StandardDirectoryReader.open with the writer left open and not tragic, plus a no-fault control that passes everywhere): leaks on 7.4.0 (-ea and -da), 7.7.3, 8.0.0, 8.6.3, 8.7.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), and passes 3/3 on main with the fix shadowed; the finder, the independent reproduction, the intended-behaviour check and the cross-model review re-runs also executed 10.5.2 and 9.12.3; the one-test minimal repro fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea, 4 files left open (2026-11-01) [exec]. [read] all 58 release tags 7.4.0..10.5.2 in the history clone call the keep check right after obtaining the reader with no guard (8.8.2-8.11.4 are not tagged in the clone; 8.11.4 was executed); upstream main a95c754ee003 (2026-10-26), branch_10x, branch_10_6 and branch_9_12 still have the unguarded call (read-only API check). Not affected: every release up to 7.3.1 (API absent). The number of leaked files differs by version (2 on 7.4.0-10.0.0, 3 on 10.5.2, 4 on main and 10.6.0) with how many doc-values update files the codec keeps open; the leaked object is the same.
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
non-formal baseline: stress; also reached by code reading
Upstream and prior reports
none found (2026-10-27, read-only): three scripted prior-report searches of 51 queries each (code site; SoftDeletesRetentionMergePolicy trigger; generic NRT-open leak on exception) over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus the finder's 51-query and the intended-behaviour check's 31-query runs, 37 manual GitHub and commit searches, lists.apache.org counts (dev@ and java-user@ have no mail mentioning keepFullyDeletedSegment), 8 web queries, and a touched-file scan of all 409 open apache/lucene PRs (none changes the NRT open loop of StandardDirectoryReader.open(IndexWriter, ...)). Related, not this bug: #6326 (LUCENE-5262, 2013, origin of the release-on-failure catch block this path bypasses); #9280 (LUCENE-8233, the introducing change; no comment discusses a throwing keep check); LUCENE-8215 (rewrote the catch block two weeks earlier); elastic/elasticsearch#67884 (2021, _0.cfs left open through another keepFullyDeletedSegment caller, IndexWriter.publishFlushedSegment; fixed by LUCENE-9722 in IndexWriter.mergeMiddle, a different path); #13353 with elastic/elasticsearch #105122, #105474 and #99075 (exceptions thrown inside SoftDeletesRetentionMergePolicy.keepFullyDeletedSegment on the flush-publication path; no leak reported; fixed in IndexingChain).
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test through gradle in a scratch worktree, stock and with the fix, plus the closest index suites with the fix; standalone with -ea and -da, stock and with the fix shadowed in; the 10.6.0 release jars. Earlier checks: main a9779ef3c193 (verifiers, adjudication, security probe), the 10.5.2 and 9.12.3 jars, and a history test on 7.4.0, 7.7.3, 8.0.0, 8.6.3, 8.7.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2, plus an API probe of 7.3.1. Upstream main 2f424fd428d (2026-10-30) read.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:111-165 (at a9779ef3c193) (open(IndexWriter, ...): readerFunction.apply(info) at :136, keep check at :137-138, readers.add at :140, catch decRefs only 'readers' at :157-164)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:760-768 (at a9779ef3c193) (keepFullyDeletedSegment javadoc, declared throws IOException)
  • lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:80-94 (at a9779ef3c193) (keep check runs the user's retention query on the segment)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:604-615 (at a9779ef3c193) (getReader: writeReaderPool, then StandardDirectoryReader.open), :766-770 ('StandardDirectoryReader#open will take care of closing it'), :4117-4141 (writeReaderPool keep check on pooled segments only)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:1109 (at a9779ef3c193) (LUCENE-5262: failed NRT opens must not leak file handles)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:111-166 (open(IndexWriter, ...): :136 obtain, :137-138 keep check, :140 readers.add, :148 incRefDeleter, :158-165 catch) (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:549-563 (readerFactory, getReadOnlyClone :554), :608-615 (getReader), :649-666, :711-721 (failure handling), :766-771 (merge-on-refresh comment), :4117-4144 (writeReaderPool), :6429-6435 (isFullyDeleted) (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:760-768; lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:80-94 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:556-558, :684-694 (openedReadOnlyClones, read only after a successful open); IndexWriterConfig.java:108 (default wait 500 ms) (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/MergePolicy.java:765-768; NoMergePolicy.java:84-86; FilterMergePolicy.java:94-96 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java:158-165; IndexWriter.java:769-771, :6743-6762; MergePolicy.java:765-766; SoftDeletesRetentionMergePolicy.java:80-94 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterReader.java:1108-1110, :1152 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:766-771 (at 645d4fe14a20)
History
  • a Lucene clone: git show releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java (:137-139); git show origin/main (2f424fd428d):lucene/core/src/java/org/apache/lucene/index/StandardDirectoryReader.java (:136-140)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal repro.

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.