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.
- 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.
SoftDeletesRetentionMergePolicycan keep such a segment: itskeepFullyDeletedSegmentruns your retention query against the segment and keeps it when the query matches a document.- Every NRT open (
DirectoryReader.open(writer),openIfChanged, aSearcherManagerrefresh) opens one reader per segment and runs this keep check for each fully deleted one.
_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.- Use soft deletes with
SoftDeletesRetentionMergePolicyand the retention queryMatchAllDocsQuery. Index one document, flush, soft-update it and close the writer: segment_0now holds 1 document, 0 of them live, and the retention query keeps it (the replacement document goes into a second segment,_1). - Open a new
IndexWriterand callDirectoryReader.open(writer)while the retention query supplier throws once. The open fails with that exception, as it should. - You expect the failed open to give back what it opened, so that after
writer.close()no file of the index is open. - Instead 4 files of
_0stay open:_0.cfsand_0_1_Lucene90_0.dvd,.dvpand.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.
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.
- Read-only clone: the
SegmentReaderthatIndexWriter.getReaderhands 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 declaredthrows IOException; the default returnsfalse(MergePolicy.java:760-768).
IndexWriter.getReadercallswriteReaderPool(IndexWriter.java:608), thenStandardDirectoryReader.open(this, readerFactory, segmentInfos, ...)(:613-615).- 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- The failure handler releases only the list:
catch (Throwable t) { ... IOUtils.applyToAll(readers, SegmentReader::decRef); ... throw t; }(:158-165). A throw at:138skips:140. - Nothing else can release the reader.
getReader's own catch block closesr, stillnullbecause the open never returned, andonGetReaderMergeResources(IndexWriter.java:711-721), which is set only after a successful open (:649-666).getReaderalso records each clone in a local map,openedReadOnlyClones(:556-558, whenmaxFullFlushMergeWaitMillis > 0, the default), but reads it only after a successful open (:684-694).close()androllback()release the pool's readers, not this clone. writer.incRefDeleter(segmentInfos)(StandardDirectoryReader.java:148) has not run yet, so file-deletion bookkeeping stays consistent: the leak is open handles and memory only.SoftDeletesRetentionMergePolicy.keepFullyDeletedSegmentcallsretentionQuerySupplier.get()(SoftDeletesRetentionMergePolicy.java:86) and runs the query on the segment (:84-91), so a throwing supplier or a read error there is enough.
- Cold writer (the clean case).
writeReaderPoolfirst 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, soStandardDirectoryReader.openruns 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.maybeReopenMergedNRTReaderuses the same method and says it "will take care of closing" the readers it is handed (IndexWriter.java:766-771); same window, not executed.
| scenario (one thread) | expected | actual 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) | same | same 4 files |
| same steps, no failure | no file open | no file open |
| either failing case with the fix | no file open | no 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 whosekeepFullyDeletedSegmentcan throw, that open NRT readers from their writer (DirectoryReader.open(writer),openIfChanged,SearcherManager). The defaultMergePolicy.keepFullyDeletedSegmentreturnsfalsewith no I/O; in core onlySoftDeletesRetentionMergePolicydoes real work there (NoMergePolicyandFilterMergePolicydelegate). Downstream, Elasticsearch's engine passes a sequence-number retention query to the stockSoftDeletesRetentionMergePolicy(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.openruns 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
SegmentReaderper 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 aSearcherManager20 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
MMapDirectory40 mappings still listed in/proc/self/mapsat 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.
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.
/*
* 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();
}
}
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 afterwriter.close()no file is open, sodir.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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 and, per the verifiers, 10.5.2 and 9.12.3 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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)- 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".keepFullyDeletedSegmentis declaredthrows IOException, and the stock retention policy runs a query inside it.
The full argument193 words
- The catch block's purpose.
StandardDirectoryReader.java:158-165decRefs 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 sameMockDirectoryWrapperopen-files check as the repro (dir.close(),:1152). - A declared exception.
keepFullyDeletedSegmentis public, non-final and declaredthrows IOException(MergePolicy.java:765-766);SoftDeletesRetentionMergePolicydoes 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
finallyblock (IndexWriter.closeSegmentStates,IndexWriter.java:6743-6762). - An oversight in history. Before LUCENE-8233 (
ecc17f90233, 2018-04-04) the condition usedwriter.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.
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.
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
tryblock (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,TestIndexWriterReaderwithtestNRTOpenExceptions,TestDirectoryReaderReopen,TestIndexWriterMergePolicyandTestIndexWriterExceptions) plus the repro: 7 classes, 135 tests, 0 failures, 7 skipped (5@Nightly, 2@AwaitsFix), seedDEADBEEF, assertions on. The rest of the core module was not run. - Format and lint:
checkGoogleJavaFormat,ecjLintandforbiddenApisfor:lucene:corepass with the fix and the test in place. - The same change shadowed into the stock main jars, run standalone: the minimal test passes with
-eaand-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 ranTestSoftDeletesRetentionMergePolicywith it (14 tests, 0 failures). - Not run: the full Lucene test suite.
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs (incl. the migrated JIRA issues) and commits | 2026-10-27 | three 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 --grep | no report; related items only (#6326, #9280, LUCENE-8215, #13353, #10112) |
| Open PRs and branches | 2026-10-27 | touched 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_12 | no open PR changes this loop; every branch still has the unguarded check |
| Mailing lists, JIRA, downstream, web | 2026-10-27 | dev@, java-user@, issues@ and commits@; the JIRA archive; Elasticsearch, OpenSearch and Solr trackers; 8 web queries | no mail mentions keepFullyDeletedSegment; elastic/elasticsearch#67884 is a different call site |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle; standalone -ea and -da); passes with fix A |
| 10.6.0 | affected | minimal test fails, 4 open files (release jars, 2026-11-01) |
main a9779ef3c193 | affected | every 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 | affected | history 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 | affected | history test fails 2 of 3 |
| 7.4.0 | first release | history test fails 2 of 3 with -ea and -da; 2 files open |
| 7.3.1 | not affected | API probe: no keepFullyDeletedSegment hook, no soft deletes; the test does not compile |
ecc17f90233 (LUCENE-8233) / 02fa5a0b059 (branch_7x) | introducing commit | by reading: the only commit that adds the throwing call to the file |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Stress 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 intended | Wrote 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, minor | Re-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 | confirmed | History 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 494 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,424 words
- 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-eaand-da, stock and with the fix shadowed in; the 10.6.0 release jars. Earlier checks: maina9779ef3c193(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 main2f424fd428d(2026-10-30) read.
- 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)
- TestNrtOpenKeepCheckFailureLeaksReader.java (JUnit, one test):
repro/LFV-626/src/org/apache/lucene/index/TestNrtOpenKeepCheckFailureLeaksReader.java(64 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed minimal repro.
assets/LFV-626/bug.png(494 KB)
